使用CompletableFuture时静态工具类方法无响应求助
问题描述
在Spring Bean注入的测试场景中,需要并行执行多个API调用,因此使用了CompletableFuture,但发现调用工具类的静态方法时线程直接停滞,无任何报错。通过日志定位到静态方法调用环节,所有Bean注入正常,同步调用时功能完全正常,找不到有效的单元测试排查方法。
线程流程:调用已注入属性的服务Bean → 调用JobService Bean
测试类代码
@EnableAsync class BillingRevenueVsExpensesServiceTest { private final Logger LOGGER = LogManager.getLogger(this.getClass().getName()); @Test public void getBillingRevenueVsExpenses() { LOGGER.info("1 Starting Thread"); ApplicationContext billingRevenueVsExpensesServiceThreadContext = new AnnotationConfigApplicationContext(billingRevenueVsExpensesDataServiceThreadConfig.class); ApplicationContext topLevelOwnerDataServiceThreadContext = new AnnotationConfigApplicationContext(TopLevelOwnerDataServiceThreadConfig.class); BillingRevenueVsExpensesDataServiceThread billingRevenueVsExpensesDataServiceThread = billingRevenueVsExpensesServiceThreadContext.getBean(BillingRevenueVsExpensesDataServiceThread.class); TopLevelOwnerDataServiceThread topLevelOwnerDataServiceThread = topLevelOwnerDataServiceThreadContext.getBean(TopLevelOwnerDataServiceThread.class); try { CompletableFuture<String> billingRevenueVsExpensesDataServiceThreadCompletableFutureJsonString = billingRevenueVsExpensesDataServiceThread.getData(); CompletableFuture<String> topLevelOwnerDataServiceThreadCompletableFutureJsonString = topLevelOwnerDataServiceThread.getData(); } catch (Exception e) { LOGGER.error(e); throw new RuntimeException(e); } } }
JobService类代码
@Service("jobService") public class JobService { private final Logger LOGGER = LogManager.getLogger(this.getClass().getName()); int spacesToIndentEachLevel = 2; // 注入字段 private final String baseURL; private final String portfolioID; private final String portfolioType; // ... 省略其他代码 public String getJobID(String viewName, String viewID, String timePeriod) { LOGGER.info("7 Starting job name: " + viewName + " view ID: " + viewID); // 调用本类中的以下方法 String[] timePeriodList = timePeriodCreator(timePeriod); // 执行到此处后不再继续 // ... 省略其他代码 } private String[] timePeriodCreator(String timePeriod) { String[] timePeriodList = new String[2]; Date date = new Date(); LOGGER.info("Todays Date: " + date); switch (timePeriod) { case "previousEndOfMonthCurrentEndOfMonth" -> { LOGGER.debug("In the timeperiod utlity"); timePeriodList[0] = DateUtilities.getPreviousYearMonthDate(date); timePeriodList[1] = DateUtilities.getEndCurrentYearMonthDate(date); LOGGER.debug("9 Timeperiod produced: " + timePeriodList[0] + " to " + timePeriodList[1]); // ... 省略其他代码 } // ... 省略其他case } return timePeriodList; } }
日期工具类代码
public final class DateUtilities { private static final Logger LOGGER = LogManager.getLogger(DateUtilities.class.getName()); final static DateFormat yearMonthDateFormat = new SimpleDateFormat("yyyy-MM-dd"); // 获取上月月末日期 public static String getPreviousYearMonthDate(Date date) { LOGGER.info("Getting Previous Month Date"); Calendar cal = Calendar.getInstance(); cal.setTime(date); cal.add(Calendar.MONTH, -1); cal.set(Calendar.DAY_OF_MONTH, cal.getActualMaximum(Calendar.DAY_OF_MONTH)); Date previousYearMonthDate = cal.getTime(); return yearMonthDateFormat.format(previousYearMonthDate); } // ... 省略其他方法 }
控制台输出
15:24:29.847 [poolThread-1] INFO com.operations.backend.services.JobService - 7 Starting job name: billingRevenueVsExpensesView view ID: 386839 15:24:29.847 [poolThread-1] INFO com.operations.backend.services.JobService - 7 Starting job name: topLevelOwnerView view ID: 392697 15:24:29.847 [poolThread-1] INFO com.operations.backend.services.JobService - 8 Generating job for view ID: 386839 15:24:29.847 [poolThread-1] INFO com.operations.backend.services.JobService - 8 Generating job for view ID: 392697 15:24:29.847 [poolThread-1] INFO com.operations.backend.services.JobService - Todays Date: Fri Jan 13 15:24:29 EST 2023 15:24:29.847 [poolThread-1] DEBUG com.operations.backend.services.JobService - In the timeperiod utlity 15:24:29.847 [poolThread-1] INFO com.operations.backend.services.JobService - Todays Date: Fri Jan 13 15:24:29 EST 2023 15:24:29.847 [poolThread-1] DEBUG com.operations.backend.services.JobService - In the timeperiod utlity Process finished with exit code 0
补充:线程类与父类代码
线程类代码
@Service("billingRevenueVsExpensesDataServiceThread") public class BillingRevenueVsExpensesDataServiceThread extends AddeparJobsAPICallService { // ... 省略其他代码 public BillingRevenueVsExpensesDataServiceThread(String viewName, String viewID, String timePeriod) { LOGGER.info("3 Thread Setting Thread Parameters: " + viewName + " " + viewID); this.viewName = viewName; this.viewID = viewID; this.timePeriod = timePeriod; } @Async public CompletableFuture<String> getData() { try { LOGGER.info("4 Starting Addepar Jobs API Call Service: " + viewName + " " + viewID); jsonStringOutput = runJob(viewName, viewID, timePeriod); return CompletableFuture.completedFuture(jsonStringOutput); } catch (Exception e) { LOGGER.error(e); return CompletableFuture.completedFuture("ERROR"); } } }
父类AddeparJobsAPICallService代码
@ContextConfiguration(classes = {JobServiceConfig.class}) public class AddeparJobsAPICallService { // ... 省略其他代码 public String runJob(String viewName, String viewID, String timePeriod) { LOGGER.info("5 Running Addepar API Jobs Call Service: " + viewName); ApplicationContext jobServiceContext = new AnnotationConfigApplicationContext(JobServiceConfig.class); JobService jobService = jobServiceContext.getBean(JobService.class); // LOGGER.info("Generating view: " + viewName + " job ID for: " + viewID + " for timeperiod:" + timePeriod); String jobID = jobService.getJobID(viewName, viewID, timePeriod); LOGGER.info("Generated Job ID: " + jobID + " for view: " + viewName + " job ID for: " + viewID + " for timeperiod:" + timePeriod); try { LOGGER.info("Waiting for job generation 10 minutes"); Thread.sleep(600000); // 将json输出存入key: billingRevenueVsExpenses return jobService.getJobResults(jobID, viewName); } catch (Exception e) { LOGGER.error("Issue with: " + AddeparJobsAPICallService.class.getName() + " : " + e); return "Loading"; } } }
问题分析与解决
核心原因
- SimpleDateFormat线程不安全:DateUtilities中的
yearMonthDateFormat是静态实例,而SimpleDateFormat本身不是线程安全类。多线程并行调用时,多个线程同时操作该实例会导致内部状态混乱,出现死锁、数据错误或线程停滞。这就是同步调用正常、并行调用出问题的根本原因。 - 测试未等待异步任务完成:测试方法启动CompletableFuture后未调用
join()或get()等待任务执行,测试方法直接结束导致JVM退出,异步线程被强制终止,看起来像是停滞。
修复步骤
1. 修复日期工具类的线程安全问题
替换为线程安全的日期格式化类(Java 8+推荐),或每次调用创建新的SimpleDateFormat实例:
public final class DateUtilities { private static final Logger LOGGER = LogManager.getLogger(DateUtilities.class.getName()); // 使用线程安全的DateTimeFormatter private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); // 获取上月月末日期 public static String getPreviousYearMonthDate(Date date) { LOGGER.info("Getting Previous Month Date"); LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); LocalDate previousMonthEnd = localDate.minusMonths(1).with(TemporalAdjusters.lastDayOfMonth()); return YEAR_MONTH_FORMATTER.format(previousMonthEnd); } // 兼容旧版本方案:每次调用创建新的SimpleDateFormat /* public static String getPreviousYearMonthDate(Date date) { LOGGER.info("Getting Previous Month Date"); Calendar cal = Calendar.getInstance(); cal.setTime(date); cal.add(Calendar.MONTH, -1); cal.set(Calendar.DAY_OF_MONTH, cal.getActualMaximum(Calendar.DAY_OF_MONTH)); Date previousYearMonthDate = cal.getTime(); DateFormat yearMonthDateFormat = new SimpleDateFormat("yyyy-MM-dd"); return yearMonthDateFormat.format(previousYearMonthDate); } */ }
2. 让测试等待异步任务完成
在测试方法中添加等待逻辑,确保异步任务执行完毕:
@Test public void getBillingRevenueVsExpenses() { LOGGER.info("1 Starting Thread"); ApplicationContext billingRevenueVsExpensesServiceThreadContext = new AnnotationConfigApplicationContext(billingRevenueVsExpensesDataServiceThreadConfig.class); ApplicationContext topLevelOwnerDataServiceThreadContext = new AnnotationConfigApplicationContext(TopLevelOwnerDataServiceThreadConfig.class); BillingRevenueVsExpensesDataServiceThread billingRevenueVsExpensesDataServiceThread = billingRevenueVsExpensesServiceThreadContext.getBean(BillingRevenueVsExpensesDataServiceThread.class); TopLevelOwnerDataServiceThread topLevelOwnerDataServiceThread = topLevelOwnerDataServiceThreadContext.getBean(TopLevelOwnerDataServiceThread.class); try { CompletableFuture<String> future1 = billingRevenueVsExpensesDataServiceThread.getData(); CompletableFuture<String> future2 = topLevelOwnerDataServiceThread.getData(); // 等待所有异步任务完成 CompletableFuture.allOf(future1, future2).join(); // 可选:获取结果验证 String result1 = future1.get(); String result2 = future2.get(); LOGGER.info("Result 1: " + result1); LOGGER.info("Result 2: " + result2); } catch (Exception e) { LOGGER.error(e); throw new RuntimeException(e); } }
3. 优化Spring上下文创建(可选)
父类AddeparJobsAPICallService每次调用runJob都创建新ApplicationContext,会造成资源浪费,建议改为依赖注入JobService:
public class AddeparJobsAPICallService { private final JobService jobService; // 构造注入JobService public AddeparJobsAPICallService(JobService jobService) { this.jobService = jobService; } public String runJob(String viewName, String viewID, String timePeriod) { LOGGER.info("5 Running Addepar API Jobs Call Service: " + viewName); String jobID = jobService.getJobID(viewName, viewID, timePeriod); LOGGER.info("Generated Job ID: " + jobID + " for view: " + viewName + " job ID for: " + viewID + " for timeperiod:" + timePeriod); try { LOGGER.info("Waiting for job generation 10 minutes"); Thread.sleep(600000); return jobService.getJobResults(jobID, viewName); } catch (Exception e) { LOGGER.error("Issue with: " + AddeparJobsAPICallService.class.getName() + " : " + e); return "Loading"; } } }
内容的提问来源于stack exchange,提问作者paukyducky
相关产品推荐
相关产品推荐

