You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用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";
        }
    }
}

问题分析与解决

核心原因

  1. SimpleDateFormat线程不安全:DateUtilities中的yearMonthDateFormat是静态实例,而SimpleDateFormat本身不是线程安全类。多线程并行调用时,多个线程同时操作该实例会导致内部状态混乱,出现死锁、数据错误或线程停滞。这就是同步调用正常、并行调用出问题的根本原因。
  2. 测试未等待异步任务完成:测试方法启动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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.05 01:05:23