1. 项目概述:为什么并发测试是每个开发者的必修课?
最近在排查一个线上服务偶发性响应变慢的问题,日志里风平浪静,单接口压测也一切正常,但只要用户量一上来,系统就开始“咳嗽”。最后定位到问题,是一个看似不起眼的静态变量在多线程环境下被意外修改,导致了数据错乱。这个经历让我再次深刻体会到,并发测试绝不是性能压测的附属品,而是保障现代多线程、分布式系统稳定性的生命线。无论你是开发一个高并发的电商秒杀系统,还是一个简单的后台管理工具,只要你的代码运行在多核CPU上、部署在多个容器里,并发问题就可能像幽灵一样潜伏着,只在特定条件下给你致命一击。
所谓并发测试,核心目标就是模拟多个用户或线程在同一时间段内对系统资源(如数据库行、内存对象、API接口)进行竞争性操作,以发现那些在单线程环境下永远无法暴露的缺陷:数据竞争、死锁、活锁、资源耗尽、状态不一致等等。很多人觉得并发测试高深莫测,是测试工程师的专属领域,其实不然。掌握几种简单、实用的并发测试方法,应该是每一位一线开发者的基本功。它能帮助你在代码上线前,就提前“预演”高并发场景,把问题扼杀在摇篮里。接下来,我就结合自己多年的踩坑经验,分享几种在开发阶段就能快速上手的并发测试方法,从理论到实操,让你也能为自己的代码加上一道可靠的“并发安全锁”。
2. 并发测试的核心思路与常见误区
在动手写测试代码之前,我们必须先理清思路,避开几个常见的认知误区。并发测试不是“用Jmeter猛灌流量”那么简单,它有着更精细的划分和目标。
2.1 并发测试的三大核心目标
首先,我们要明确测试想发现什么:
- 线程安全缺陷:这是最常见的。当多个线程不加控制地访问和修改同一块共享数据(如一个全局List、一个静态Map、一个单例对象内的属性)时,就会导致数据状态不可预测。例如,经典的“i++”问题,在并发下会导致计数不准。
- 资源同步问题:包括死锁(两个以上的线程互相等待对方释放锁)、活锁(线程不断改变状态以试图获取资源,但始终无法取得进展)和资源饥饿(某些线程长期无法获得所需资源)。这类问题通常与锁(
synchronized、ReentrantLock)的使用不当有关。 - 系统资源与性能边界:在高并发下,系统资源(如数据库连接池、线程池、内存、CPU)是否会快速耗尽?响应时间是否线性增长?吞吐量是否达到瓶颈?这关系到系统的可伸缩性。
2.2 必须避开的两个典型误区
基于以上目标,我们在设计测试时需要警惕:
误区一:把压力测试等同于并发测试。这是最大的误解。压力测试(Stress Test)主要关注系统在极限负载下的表现和恢复能力,比如用每秒数万的请求把接口打满。而并发测试(Concurrency Test)更关注正确性,负载可能不大,但操作序列设计得非常“刁钻”,旨在触发特定的竞态条件。一个接口能承受1万QPS,不代表它在10个用户同时修改同一条数据时不会出错。
误区二:认为“跑一遍没出错就是线程安全”。并发缺陷具有极强的偶发性。由于线程调度由操作系统决定,具有随机性,一个存在数据竞争的程序可能运行成千上万次都是正确的结果,只在某种特定的、极难重现的线程交错顺序下才会出错。因此,并发测试需要反复、大量地执行,并尽可能增加线程调度的不确定性,以提高发现问题的概率。
理解了这些,我们就可以选择合适的方法和工具了。下面介绍的几种方法,其有效性正是基于它们能够系统地放大并发冲突的可能性。
3. 方法一:基于JUnit与ExecutorService的轻量级单元并发测试
这是开发阶段最快捷、成本最低的测试方式,适合对某个具体的类或方法进行并发安全性验证。我们不需要引入复杂的测试框架,利用Java标准库的ExecutorService线程池就能搭建一个简单的测试环境。
3.1 测试场景搭建
假设我们有一个简单的计数器服务UnsafeCounter,它存在明显的线程安全问题:
public class UnsafeCounter { private int count = 0; // 非线程安全的递增方法 public void increment() { count++; // 这是一个“读取-修改-写入”的非原子操作 } public int getCount() { return count; } }我们要测试increment方法在并发下的行为。以下是基于JUnit 5的测试类:
import org.junit.jupiter.api.Test; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; class UnsafeCounterTest { @Test void testIncrementConcurrently() throws InterruptedException { // 1. 定义并发线程数和工作任务总数 int threadCount = 10; int totalIncrements = 10000; UnsafeCounter counter = new UnsafeCounter(); // 2. 创建固定大小的线程池 ExecutorService executorService = Executors.newFixedThreadPool(threadCount); // 使用CountDownLatch协调所有线程同时开始 CountDownLatch startLatch = new CountDownLatch(1); // 使用CountDownLatch等待所有线程结束 CountDownLatch endLatch = new CountDownLatch(totalIncrements); // 3. 提交任务 for (int i = 0; i < totalIncrements; i++) { executorService.submit(() -> { try { startLatch.await(); // 所有线程在此等待 counter.increment(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { endLatch.countDown(); // 每个任务完成时计数减一 } }); } // 4. 释放“起跑线”,所有线程开始并发执行 startLatch.countDown(); // 5. 等待所有任务完成 endLatch.await(); // 6. 关闭线程池 executorService.shutdown(); // 7. 验证结果 System.out.println("Expected count: " + totalIncrements); System.out.println("Actual count: " + counter.getCount()); // 因为存在线程安全问题,实际结果几乎必然小于预期 assertNotEquals(totalIncrements, counter.getCount()); } }3.2 关键技巧与避坑指南
这个简单的测试里藏着几个确保测试有效的关键点:
1. 使用CountDownLatch制造“并发齐射”:startLatch.await()让所有提交的任务线程都准备就绪,并阻塞在同一个点上。当主线程调用startLatch.countDown()时,所有任务线程近乎同时被释放,极大地增加了它们对count++这个临界资源竞争的激烈程度和随机性。如果不这样做,线程可能按提交顺序依次启动,降低了并发冲突的概率。
2. 任务数要远大于线程数:例子中,我们用了10个线程执行10000次递增。让每个线程执行多次操作,可以增加线程上下文切换的次数,从而创造出更多种可能的线程执行序列,更容易触发那些隐藏很深的竞态条件。
3. 断言“不相等”而非“相等”:对于已知非线程安全的代码,我们的测试目的是验证它在并发下会出错。因此,断言assertNotEquals是合理的。但对于一个你希望它是线程安全的代码,你需要在多次运行中,断言其结果始终等于预期值。可以考虑在循环中多次运行整个测试逻辑。
4. 别忘了关闭线程池:在测试方法中创建了线程池,一定要在最后调用shutdown()。更好的做法是使用try-with-resources(如果ExecutorService实现了AutoCloseable)或在@AfterEach方法中清理,防止线程泄漏影响其他测试。
注意:这种测试方法在常规的IDE(如IntelliJ IDEA)中直接运行
@Test方法可能无法充分暴露问题,因为测试运行器的环境可能比较“温和”。建议使用Maven或Gradle在命令行中反复执行测试(如mvn test -Dtest=UnsafeCounterTest),或者将测试方法放在一个循环中执行多次。
4. 方法二:利用CompletableFuture进行异步流程的并发模拟
如果你的业务逻辑涉及多个异步任务的组合、编排,那么CompletableFuture是进行并发测试的绝佳工具。它不仅能模拟并发执行,还能方便地测试各种任务依赖关系下的并发行为。
4.1 测试异步编排下的资源竞争
考虑一个常见的场景:用户下单后,需要同时调用库存服务、优惠券服务和风控服务,三者都成功后,再执行扣款。我们如何测试这三个异步调用对共享资源(比如,更新同一个订单状态)的并发操作?
import org.junit.jupiter.api.Test; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; class AsyncOrderServiceTest { @Test void testConcurrentStatusUpdate() throws Exception { // 模拟一个共享的订单状态管理器(非线程安全) class OrderStatusManager { private String status = "INIT"; public void updateStatus(String newStatus) { // 模拟一个非原子性的复杂状态转换 String old = this.status; // 此处模拟一个耗时操作,放大竞态窗口 try { Thread.sleep(ThreadLocalRandom.current().nextInt(5)); } catch (InterruptedException e) {} this.status = newStatus; System.out.println(Thread.currentThread().getName() + ": Updated status from \"" + old + "\" to \"" + newStatus + "\""); } public String getStatus() { return status; } } OrderStatusManager manager = new OrderStatusManager(); AtomicInteger updateCount = new AtomicInteger(0); // 使用CompletableFuture模拟三个异步服务调用 CompletableFuture<Void> future1 = CompletableFuture.runAsync(() -> { manager.updateStatus("CHECKING_INVENTORY"); updateCount.incrementAndGet(); }); CompletableFuture<Void> future2 = CompletableFuture.runAsync(() -> { manager.updateStatus("CHECKING_COUPON"); updateCount.incrementAndGet(); }); CompletableFuture<Void> future3 = CompletableFuture.runAsync(() -> { manager.updateStatus("RISK_CONTROLLING"); updateCount.incrementAndGet(); }); // 等待所有异步任务完成 CompletableFuture.allOf(future1, future2, future3).join(); // 验证:三个任务都执行了更新 assertEquals(3, updateCount.get()); // 但最终状态可能不是我们预期的最后一个,而是由线程竞争决定的任意一个 System.out.println("Final status: " + manager.getStatus()); // 断言可能失败,证明存在并发问题 // assertTrue(Set.of("CHECKING_INVENTORY", "CHECKING_COUPON", "RISK_CONTROLLING").contains(manager.getStatus())); } }4.2 使用thenCombine测试合并操作的并发安全
CompletableFuture的thenCombine方法常用于合并两个异步任务的结果。这是测试合并逻辑线程安全的经典场景。
@Test void testThenCombineConcurrency() throws Exception { // 模拟一个非线程安全的计算结果收集器 class ResultAggregator { private List<Integer> results = new ArrayList<>(); public void addResult(int result) { // ArrayList的add方法非线程安全 results.add(result); } public int getSum() { return results.stream().mapToInt(Integer::intValue).sum(); } } ResultAggregator aggregator = new ResultAggregator(); // 创建两个异步计算任务 CompletableFuture<Integer> futureA = CompletableFuture.supplyAsync(() -> { try { Thread.sleep(50); } catch (InterruptedException e) {} return 10; }); CompletableFuture<Integer> futureB = CompletableFuture.supplyAsync(() -> { try { Thread.sleep(30); } catch (InterruptedException e) {} return 20; }); // 合并结果,并在合并后执行添加操作(这里存在并发风险!) CompletableFuture<Void> combinedFuture = futureA.thenCombine(futureB, (a, b) -> { // 这个BiFunction在哪个线程执行?可能是futureA或futureB的线程,也可能是调用thenCombine的线程。 // 如果在此直接操作共享的aggregator,需要小心。 int sum = a + b; aggregator.addResult(sum); // 潜在并发点! return null; }); combinedFuture.join(); // 等待合并完成 // 由于ArrayList的并发问题,这里可能会抛出ArrayIndexOutOfBoundsException // 或者getSum()的结果不正确(如果add操作丢失了数据) System.out.println("Aggregator sum: " + aggregator.getSum()); }实操心得:
CompletableFuture的回调函数(如thenApply、thenAccept、thenCombine中的函数)执行线程是不确定的。它可能在完成当前阶段的线程上执行,也可能在调用回调的线程(如join()或get()的线程)上执行。如果多个回调函数并行执行并访问共享资源,必须像对待普通多线程代码一样考虑同步。一个最佳实践是:在回调函数内部,只处理传入的参数和局部变量,将需要线程安全操作的部分封装到专门的同步类或使用并发集合中。
5. 方法三:使用ThreadLocalRandom与循环屏障制造不确定性
并发缺陷的复现依赖于“巧合”的线程执行时序。我们可以主动在代码中插入一些可控的随机性和同步点,来主动制造这种“巧合”,提高测试的强度。ThreadLocalRandom和CyclicBarrier是两位好帮手。
5.1 利用随机休眠放大竞态窗口
大多数竞态条件发生在两个线程操作的间隙非常小的时候。我们可以通过在线程的关键操作前后随机休眠一小段时间,来人为地放大这个“竞态窗口”,使得线程交错更容易发生。
@Test void testWithRandomDelay() throws InterruptedException { class BankAccount { private double balance = 100.0; public void withdraw(double amount) { // 1. 读取余额 double current = this.balance; // !!!插入随机延迟,让其他线程有机会在此刻介入 try { Thread.sleep(ThreadLocalRandom.current().nextInt(0, 3)); } catch (InterruptedException e) {} // 2. 检查并更新余额 if (current >= amount) { // !!!再次插入随机延迟 try { Thread.sleep(ThreadLocalRandom.current().nextInt(0, 3)); } catch (InterruptedException e) {} this.balance = current - amount; System.out.println(Thread.currentThread().getName() + " 取款 " + amount + " 成功,余额: " + this.balance); } else { System.out.println(Thread.currentThread().getName() + " 取款 " + amount + " 失败,余额不足"); } } public double getBalance() { return balance; } } BankAccount account = new BankAccount(); int threadCount = 5; ExecutorService executor = Executors.newFixedThreadPool(threadCount); CountDownLatch latch = new CountDownLatch(threadCount); // 模拟5个线程同时取款80元 for (int i = 0; i < threadCount; i++) { executor.submit(() -> { try { account.withdraw(80.0); } finally { latch.countDown(); } }); } latch.await(); executor.shutdown(); double finalBalance = account.getBalance(); System.out.println("最终余额: " + finalBalance); // 由于存在竞态条件,最终余额可能是错误的(例如,20, 60, 甚至-60),而不是正确的-300(100-5*80) // 理论上,最多只有一个线程能成功取款,最终余额应为20或100。但实际可能多个线程都成功。 }这个测试中,withdraw方法不是原子操作,我们又在读取和更新之间插入了随机休眠,这使得多个线程更有可能都读到初始余额100,并都认为自己可以取款80,导致余额被透支为负数。
5.2 使用CyclicBarrier精确控制并发执行点
CountDownLatch是一次性的,而CyclicBarrier(循环屏障)可以重复使用,它能让一组线程互相等待,直到所有线程都到达某个屏障点,然后一起继续执行。这对于测试需要严格同时触发的并发场景非常有用。
@Test void testCacheStampedeWithBarrier() throws Exception { // 模拟缓存击穿场景:多个线程同时发现缓存失效,同时去加载数据库 class CacheLoader { private String cache = null; private int loadCount = 0; // 记录实际加载数据库的次数 public String getData() { if (cache == null) { // 模拟加载数据库,耗时操作 synchronized (this) { // 双重检查锁定 (DCL) if (cache == null) { System.out.println(Thread.currentThread().getName() + " 开始加载数据库..."); loadCount++; try { Thread.sleep(100); } catch (InterruptedException e) {} // 模拟IO cache = "Data from DB"; } } } return cache; } public int getLoadCount() { return loadCount; } } int threadCount = 10; CacheLoader loader = new CacheLoader(); ExecutorService executor = Executors.newFixedThreadPool(threadCount); // 创建一个CyclicBarrier,等待所有线程就绪 CyclicBarrier barrier = new CyclicBarrier(threadCount + 1); // +1 包括主线程 for (int i = 0; i < threadCount; i++) { executor.submit(() -> { try { // 所有线程在此等待 barrier.await(); // 同时开始执行getData() loader.getData(); } catch (Exception e) { e.printStackTrace(); } }); } System.out.println("所有线程已提交,准备同时触发..."); Thread.sleep(100); // 给线程一点时间到达屏障 // 主线程到达屏障,释放所有等待的线程 barrier.await(); executor.shutdown(); executor.awaitTermination(2, TimeUnit.SECONDS); System.out.println("数据库实际加载次数: " + loader.getLoadCount()); // 在DCL正确的实现下,loadCount应为1。但如果缺少volatile或指令重排,可能大于1。 assertEquals(1, loader.getLoadCount()); }注意事项:使用
CyclicBarrier时,屏障数量(parties参数)一定要设置正确,包括所有的工作线程,有时还需要包括主控线程。如果有一个线程因为异常没有到达屏障,或者屏障数量对不上,所有其他线程都会永远等待下去,导致测试挂起。务必设置合理的超时时间(barrier.await(5, TimeUnit.SECONDS))或在测试框架中管理超时。
6. 方法四:集成测试中的并发场景模拟——以Spring Boot与TestContainers为例
单元测试能覆盖类内部的并发问题,但一些并发缺陷只有在集成环境中,当多个组件(如应用服务、数据库、缓存)交互时才会显现。比如,数据库事务隔离级别设置不当导致的数据不一致。这里我们结合Spring Boot和TestContainers,搭建一个更贴近真实环境的并发集成测试。
6.1 模拟并发下单导致的超卖问题
超卖是电商系统经典的并发问题:商品库存为1,两个用户同时下单,都成功扣减库存,导致卖了2件商品。
1. 准备测试环境(使用H2内存数据库和TestContainers for Redis)
首先在pom.xml中引入依赖(以Maven为例):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers</artifactId> <version>1.19.3</version> <!-- 使用最新版本 --> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <version>1.19.3</version> <scope>test</scope> </dependency>2. 编写有缺陷的库存扣减服务
@Service public class InventoryService { @Autowired private JdbcTemplate jdbcTemplate; // 存在并发问题的扣减方法:先查后改 @Transactional public boolean deductStock(Long productId, Integer quantity) { // 1. 查询当前库存 Integer currentStock = jdbcTemplate.queryForObject( "SELECT stock FROM product WHERE id = ?", Integer.class, productId); if (currentStock == null || currentStock < quantity) { return false; } // 2. 模拟一些业务逻辑处理耗时 try { Thread.sleep(10); } catch (InterruptedException e) {} // 3. 更新库存 int rows = jdbcTemplate.update( "UPDATE product SET stock = stock - ? WHERE id = ?", quantity, productId); return rows > 0; } }3. 编写并发集成测试
@SpringBootTest @Testcontainers class InventoryServiceConcurrentTest { @Autowired private InventoryService inventoryService; @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine"); @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); } @BeforeEach void setup() { // 初始化数据库,插入一条库存为10的商品 jdbcTemplate.execute("DROP TABLE IF EXISTS product"); jdbcTemplate.execute("CREATE TABLE product (id BIGINT PRIMARY KEY, stock INT)"); jdbcTemplate.update("INSERT INTO product (id, stock) VALUES (1, 10)"); } @Test void testDeductStockConcurrencyProblem() throws InterruptedException { int threadCount = 15; int deductPerThread = 1; CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch endLatch = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(0); ExecutorService executor = Executors.newFixedThreadPool(threadCount); for (int i = 0; i < threadCount; i++) { executor.submit(() -> { try { startLatch.await(); boolean success = inventoryService.deductStock(1L, deductPerThread); if (success) { successCount.incrementAndGet(); } } catch (Exception e) { e.printStackTrace(); } finally { endLatch.countDown(); } }); } startLatch.countDown(); endLatch.await(); executor.shutdown(); // 查询最终库存 Integer finalStock = jdbcTemplate.queryForObject( "SELECT stock FROM product WHERE id = 1", Integer.class); System.out.println("成功扣减的线程数: " + successCount.get()); System.out.println("数据库最终库存: " + finalStock); // 断言:成功次数 + 最终库存 应该等于初始库存 // 初始库存10,每个线程扣1,最多成功10次,库存为0。 // 但由于并发问题,可能出现成功次数>10,库存为负数的情况。 assertEquals(10, successCount.get() + finalStock); } }6.2 分析与解决方案
运行上述测试,你很可能会发现断言失败:successCount.get() + finalStock不等于10。这是因为deductStock方法存在“先查后改”的竞态条件。在READ COMMITTED(读已提交,PostgreSQL默认)或REPEATABLE READ隔离级别下,两个事务可能同时读到库存为10,都判断为足够,然后都去扣减,导致超卖。
解决方案1:使用数据库悲观锁(SELECT FOR UPDATE)
@Transactional public boolean deductStockPessimistic(Long productId, Integer quantity) { // 使用悲观锁锁定这行数据 Integer currentStock = jdbcTemplate.queryForObject( "SELECT stock FROM product WHERE id = ? FOR UPDATE", Integer.class, productId); // ... 后续逻辑不变 }解决方案2:使用数据库乐观锁(版本号或条件更新)
@Transactional public boolean deductStockOptimistic(Long productId, Integer quantity) { // 直接使用原子性更新操作 int rows = jdbcTemplate.update( "UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?", quantity, productId, quantity); return rows > 0; // 返回更新行数,大于0表示成功 }解决方案3:在应用层使用分布式锁对于分布式环境,可以使用Redis或ZooKeeper实现分布式锁,确保同一时间只有一个实例可以执行扣减逻辑。
集成测试心得:使用TestContainers可以轻松启动真实的外部服务(如PostgreSQL, Redis, Kafka),让集成测试的环境与生产环境高度一致,能发现更多依赖特定数据库行为或中间件特性的并发问题。但这类测试运行较慢,更适合在CI/CD流水线中作为关键路径的验收测试,而不是每次开发都运行。
7. 并发测试中常见问题与排查技巧实录
即使使用了上述方法,并发问题依然可能难以复现和定位。下面分享一些我在实践中总结的排查技巧和工具。
7.1 问题复现与日志排查
并发问题日志往往杂乱无章,关键信息被淹没。
技巧1:给日志加上唯一追踪标识在每个请求或任务开始时,生成一个唯一的ID(如UUID或TraceId),并在线程上下文(如ThreadLocal或MDC)中传递。在所有的日志语句中都输出这个ID。这样,你可以通过这个ID将分散在不同线程日志中的、属于同一个逻辑流程的消息串联起来。
// 使用Slf4j MDC import org.slf4j.MDC; class ConcurrentService { public void process() { String traceId = UUID.randomUUID().toString(); MDC.put("traceId", traceId); try { log.info("开始处理"); // ... 业务逻辑 log.info("处理完成"); } finally { MDC.clear(); // 务必清理,防止内存泄漏 } } } // 在logback.xml中配置pattern: %d{yyyy-MM-dd HH:mm:ss} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n技巧2:有选择地增加详细日志,并使用Thread.currentThread().getName()在怀疑存在竞态条件的代码块前后,打印关键变量的状态和线程名。
public void suspiciousMethod(SharedObject obj) { log.debug("Thread[{}] - 进入方法,obj.state={}", Thread.currentThread().getName(), obj.getState()); // ... 危险操作 log.debug("Thread[{}] - 操作完成,obj.state={}", Thread.currentThread().getName(), obj.getState()); }7.2 利用JVM工具与Java诊断工具
当日志无法定位时,需要借助更强大的工具。
1.jstack查看线程状态和锁持有情况jstack是JDK自带的命令行工具,可以抓取JVM所有线程的堆栈信息。当应用疑似发生死锁时,这是第一选择。
# 找到Java进程的PID jps -l # 生成线程转储 jstack <pid> > thread_dump.txt在thread_dump.txt中搜索“deadlock”或“BLOCKED”状态,可以快速找到相互等待锁的线程。
2. 使用jconsole或VisualVM进行可视化监控这些工具可以实时查看线程状态、内存使用、监视器(锁)信息。VisualVM的“线程”标签页可以直观地看到哪些线程在运行、等待、阻塞,并且可以手动执行垃圾回收或进行线程转储。
3. 使用-XX:+PrintConcurrentLocks和-XX:+PrintSafepointStatistics等JVM参数在启动应用时添加这些参数,可以输出更详细的锁竞争和JVM安全点信息,帮助分析由垃圾回收(GC)引起的“Stop-The-World”导致的并发停顿问题。
7.3 编写可测试的并发代码
最好的排查是预防。在编写代码时,就为并发测试做好准备。
原则1:缩小同步范围尽量只锁住必要的共享数据,而不是整个方法。使用更细粒度的锁对象。
// 不好 public synchronized void processEverything() { ... } // 更好 private final Object specificLock = new Object(); public void process() { // 非同步部分... synchronized (specificLock) { // 只同步真正需要保护的一小段代码 } // 非同步部分... }原则2:优先使用并发工具类,而非手动synchronizedjava.util.concurrent包提供了丰富且高性能的并发工具,如ConcurrentHashMap、CopyOnWriteArrayList、AtomicInteger、CountDownLatch、CyclicBarrier、Semaphore等。它们经过了充分的测试和优化,比自己实现的同步逻辑更可靠。
原则3:避免在同步块中调用外部方法(“外星方法”)这可能导致死锁或性能问题,因为你无法控制外部方法的行为。
synchronized(lock) { // 不要这样做! someExternalService.call(); // 这个外部服务可能也在获取锁,导致死锁 list.add(item); }原则4:使用不可变对象和线程封闭最简单的线程安全策略就是避免共享。尽可能使用不可变对象(所有字段final,状态在构造后不变),或者使用ThreadLocal将对象封闭在单个线程内。
并发测试是一场与不确定性的战斗。没有一种方法能保证发现所有问题,但通过组合运用单元测试、集成测试、代码审查和静态分析工具(如FindBugs、SpotBugs、Error Prone),我们可以将并发缺陷的风险降到最低。记住,面对并发,永远要保持敬畏之心。在你认为“这怎么可能出错”的地方,往往就是bug藏身之处。