目录
1、竞态条件
1.1、问题本质
1.2、解决方案
2、死锁
2.1、死锁四要素
2.2、Java 死锁
2.3、如何检测死锁
2.4、预防策略
3、性能开销
3.1、锁竞争
3.2、可维护性
4、现代替代方案
前沿
"编写正确的并发程序,比登天还难。"
当一个线程运行时候的整个生命周期,如下所示:
在多核 CPU 成为主流的今天,Java 多线程(Multithreading)被视为提升系统吞吐量和响应速度的“银弹”。然而,并发不是功能,而是复杂性的放大器。
设计更复杂
虽然有一些多线程应用程序比单线程的应用程序要简单,但其他的一般都更复杂。在多线程访问共享数据的时候,这部分代码需要特别的注意,线程之间的交互往往非常复杂。
如下所示:
上下文切换的开销
当CPU从执行一个线程切换到执行另外一个线程的时候,它需要先存储当前线程的本地的数据,程序指针等,然后载入另一个线程的本地数据,程序指针等,最后才开始执行。这种切换称为“上下文切换”(“context switch”)。CPU会在一个上下文中执行一个线程,然后切换到另外一个上下文中执行另外一个线程。
上下文切换并不廉价。如果没有必要,应该减少上下文切换的发生。
本文将结合真实代码、底层原理与前沿实践,系统性地揭示 Java 多线程编程的六大核心缺点,并探讨如何在享受并发红利的同时规避其陷阱。
1、竞态条件
1.1、问题本质
当多个线程无序访问共享可变状态,且至少有一个线程在修改数据时,最终结果依赖于线程调度的时序,导致不可预测的行为。
Java 代码示例:
public class UnsafeCounter { private int count = 0; public void increment() { count++; // 非原子操作! } public int getCount() { return count; } } // 测试代码 public static void main(String[] args) throws InterruptedException { UnsafeCounter counter = new UnsafeCounter(); Thread t1 = new Thread(() -> { for (int i = 0; i < 100_000; i++) counter.increment(); }); Thread t2 = new Thread(() -> { for (int i = 0; i < 100_000; i++) counter.increment(); }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("Expected: 200000, Actual: " + counter.getCount()); // 输出可能为:138456, 199872, ... 永远不等于 200000 }为什么 count++不安全?
JVM 字节码层面,count++ 被分解为三步:
// 字节码(简化) getfield count // 读取 iconst_1 // 加1 putfield count // 写回若两个线程同时执行到 getfield,它们会读取相同的值,导致更新丢失。
1.2、解决方案
使用 synchronized:
public synchronized void increment() { count++; }使用 AtomicInteger(无锁 CAS):
private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); }2、死锁
2.1、死锁四要素
如下所示:
1.互斥(Mutual Exclusion)
线程对所分配到的资源进行排他性使用,即在一段时间内某资源只由一个线程占用。如果此时还有其它线程请求该资源,则请求者只能等待,直至占有资源的线程用毕释放。
2.持有并等待(Hold and Wait)
线程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其它线程占有,此时请求线程阻塞,但又对自己已获得的其它资源保持不放。
3.不可抢占(No Preemption)
线程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
4.循环等待(Circular Wait)
在发生死锁时,必然存在一个线程 —— 资源的环形链,即线程集合 {T0,T1,T2,・・・,Tn} 中的 T0 正在等待一个 T1 占用的资源;T1 正在等待 T2 占用的资源,……,Tn 正在等待已被 T0 占用的资源。
更多关于死锁的知识,可参考:有关Java死锁和活锁的联系
2.2、Java 死锁
示例
public class DeadlockDemo { private final Object lockA = new Object(); private final Object lockB = new Object(); public void methodA() { synchronized (lockA) { System.out.println("Thread " + Thread.currentThread().getName() + " got lockA"); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockB) { // 等待 lockB System.out.println("Acquired both locks"); } } } public void methodB() { synchronized (lockB) { System.out.println("Thread " + Thread.currentThread().getName() + " got lockB"); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockA) { // 等待 lockA System.out.println("Acquired both locks"); } } } public static void main(String[] args) { DeadlockDemo demo = new DeadlockDemo(); new Thread(demo::methodA, "T1").start(); new Thread(demo::methodB, "T2").start(); // 程序将永久挂起! } }2.3、如何检测死锁
- jstack:jstack <pid> 会自动检测死锁并打印:
Found one Java-level deadlock: ============================= "T2": waiting to lock <0x000000076b8a1234> (lockA) "T1": waiting to lock <0x000000076b8a5678> (lockB)2.4、预防策略
锁排序:所有线程按固定顺序获取锁(如先 A 后 B)
超时机制:tryLock(timeout)
避免嵌套锁
3、性能开销
要知道并发≠加速,有时更慢。
3.1、锁竞争
高并发下,线程频繁争抢同一把锁,导致:
上下文切换开销(用户态 ↔ 内核态)
CPU 缓存失效(False Sharing)
False Sharing 示例
public class FalseSharing implements Runnable { public final static int NUM_THREADS = 4; public final static long ITERATIONS = 500L * 1000L * 1000L; private final int arrayIndex; private static VolatileLong[] longs = new VolatileLong[NUM_THREADS]; static { for (int i = 0; i < longs.length; i++) { longs[i] = new VolatileLong(); } } public FalseSharing(final int arrayIndex) { this.arrayIndex = arrayIndex; } public void run() { long i = ITERATIONS + 1; while (0 != --i) { longs[arrayIndex].value = i; // 多个线程写相邻内存 } } public final static class VolatileLong { public volatile long value = 0L; // 在 Java 8+ 可加 @Contended 注解避免 False Sharing } }若 VolatileLong 对象位于同一 CPU 缓存行(64 字节),一个核心修改会使其他核心缓存失效,性能下降 10 倍以上。
2. 内存消耗
每个 Java 线程默认栈大小1MB(64位 JVM)
1000 个线程 ≈ 1GB 内存仅用于栈
3. 上下文切换成本
切换一次约1~10 微秒
线程数 > CPU 核心数时,吞吐量反而下降
在 16 核机器上,当线程数超过 32,吞吐量开始下降。
如下所示:
3.2、可维护性
1. 逻辑碎片化
业务逻辑被 synchronized、wait/notify、Lock 切割得支离破碎。
2. 异常处理陷阱
synchronized (lock) { try { // 业务逻辑 } finally { // 必须释放锁!否则死锁 lock.unlock(); // ReentrantLock 需手动释放 } }3. 中断处理困难
如何安全停止一个运行中的线程?
Thread.stop() 已废弃(不安全)
正确做法:使用协作式中断(Thread.interrupt() + 检查 isInterrupted())
4、现代替代方案
超越传统多线程,面对多线程的诸多缺点,Java 社区正转向更安全的并发模型:
Java 21 虚拟线程示例(告别线程池)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 10_000).forEach(i -> { executor.submit(() -> { // 每个任务一个虚拟线程,几乎无开销 return doWork(i); }); }); } // 自动 join 所有虚拟线程虚拟线程不解决竞态条件,但极大降低线程管理成本。
总结
| 缺点 | 根本原因 | 应对策略 |
|---|---|---|
| 竞态条件 | 共享可变状态 | 使用不可变对象、原子类、同步块 |
| 死锁 | 循环等待锁 | 锁排序、超时、避免嵌套 |
| 性能开销 | 锁竞争、上下文切换 | 分拆大事务、减少共享、用无锁结构 |
| 调试困难 | 非确定性 | 使用 TSan、增加日志、单元测试覆盖 |
| 可维护性差 | 逻辑分散 | 封装并发原语、使用高级工具类 |
| 资源消耗大 | 线程栈开销 | 采用虚拟线程、协程、异步模型 |