很多人学多线程,上来就盯着API怎么调、锁怎么用,但真到排查线上问题或者做性能优化的时候,才发现自己只知道个皮毛。尤其是线程怎么停下来、volatile到底干了什么、wait和park有什么区别,这些如果只停留在“会用”层面,遇到诡异现象会非常头疼。这篇文章我就把线程中止、CPU缓存、内存屏障、线程通信这几块硬骨头放一起拆开讲,结合我实际踩过的坑,尽量讲透。
这个主题适合谁?一个是准备面试、想把这些概念串成体系的人,另一个是真正在写并发代码、遇到过数据不一致或者死锁问题的开发者。看完你会明白,这些知识点不是孤立的,它们其实围绕同一个核心问题:多线程环境下,如何让数据访问既高效又安全。
1. 线程中止:别想着“杀掉”线程,要想着“协商退出”
1.1 Thread.stop为什么是废的
我刚工作那会儿也犯过傻,觉得停线程嘛,调个stop()不就行了?后来看源码注释和同事的代码评审才知道,这方法早就被标记为废弃了。废弃的原因很简单:stop()会直接抛出ThreadDeath异常,强制终止线程,但它在终止的时候根本不管锁有没有释放、对象状态有没有处于中间值。
举个例子,一个线程正在往HashMap里写数据,写到一半被你stop了,那么这个HashMap可能就停留在“看似完整但实际损坏”的状态。更麻烦的是,这个锁还没释放,其他线程再拿这个锁就永远卡住了。这就像你让一个人跑步,跑到一半突然把他推倒,他手里的东西散了一地不说,门还被他锁上了。
所以我现在看到有人用stop(),基本可以断定代码有问题。正确的做法从来只有一个:协作式的中断。
1.2 interrupt机制:通知不等于强制
Java的线程中止核心是interrupt机制,但是很多人的理解有偏差。interrupt()方法并不是“中断线程执行”,而是“给线程设置一个中断标志位”。线程要不要响应这个标志,完全看它自己。
这里面有个关键区别:当线程处于wait()、sleep()、join()这些可中断阻塞状态时,收到interrupt信号会抛出InterruptedException,并且中断标志会被清除。而当线程在正常执行CPU计算时,interrupt只是设置一个标志,线程如果不主动检查,可能根本感知不到。
我早期写过一段代码,线程里有个while循环在跑,我以为调了interrupt线程就会停,结果跑了半天也没停。原因就是我循环里没有检查中断标志。用大白话说就是:有人喊你停下来,但你把耳朵捂住了。
Thread worker = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { // 执行任务 } }); worker.start(); // 某个时刻 worker.interrupt();这个写法是标准做法。但要注意,如果循环里有sleep(),情况就不一样了:
Thread worker = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { try { Thread.sleep(1000); } catch (InterruptedException e) { // 中断标志被清除了,需要重新设置 Thread.currentThread().interrupt(); break; } } });sleep()抛异常后,中断标志会被清除,如果不重新设置,循环可能继续跑下去。所以catch里必须重新interrupt(),这是一个很多人会漏掉的细节。
1.3 响应中断的三种模式:怎么写才算优雅
在实际业务代码里,我总结出三种常见的响应中断的写法,分别应对不同场景:
第一种是传递中断。如果你的方法只是中间层,不负责最终处理,那么捕获InterruptedException之后,要么直接往上抛,要么重新设置中断标志让别人来处理。千万不要catch了之后什么都不做,这样等于把中断信号吃了。
第二种是恢复中断状态。有些场景你不能抛异常,比如Runnable的run()方法签名不允许抛受检异常。这时候你必须捕获InterruptedException,然后调用Thread.currentThread().interrupt()把状态恢复,让上层或者后续代码能看到中断请求。
第三种是自定义标志位。如果interrupt机制满足不了你的业务需求,比如你需要更精细的控制,可以自己定义一个volatile boolean变量来控制线程退出。但这里有个坑:如果线程同时在等一个锁或者执行阻塞IO,光靠标志位是停不下来的,还得配合中断。
经验之谈:我见过一种很经典的问题——线程池里的任务里写了
while (true)没有任何退出条件,结果shutdownNow()也停不掉线程池,因为线程池只能中断工作线程,如果你的代码不响应中断,那工作线程就永远卡在那。所以凡是丢到线程池里的任务,一定要有响应中断的逻辑。
2. CPU缓存优化:为什么你的程序比理论上慢几十倍
2.1 从内存模型说起:CPU不是直接读内存的
要理解CPU缓存优化,先得知道一个事实:CPU读内存的速度比CPU执行指令慢几个数量级。为了弥补这个差距,CPU芯片上加了多层缓存——L1、L2、L3。L1最快但容量最小,L3最慢但容量大。
这里面最核心的概念是缓存行。CPU从内存读数据不是按字节读的,而是按缓存行读的,通常一行是64字节。就像你去图书馆借书,不是一页一页借,而是一本一本借。你只需要第10页,但CPU会把整个缓存行都加载进来,因为它的设计假设是:你访问了一个地址,附近的数据大概率也会被使用。
这个设计在单线程下很好,但在多线程下会引发一个著名的问题——伪共享。
2.2 伪共享:多线程性能杀手
伪共享是并发编程里非常隐蔽的性能问题。假设有两个线程,分别操作两个不同的变量,这两个变量不幸被分配在同一个缓存行里。虽然两个线程理论上操作的是完全独立的数据,不需要任何同步,但CPU为了保证缓存一致性,会让这两个变量所在的缓存行在多个核心之间来回同步。
打个比方:两个室友各自写自己的作业,互不打扰,但他们偏偏坐在同一张桌子上。一个人一动桌子,另一个人就感觉桌子被晃到了,得重新稳定下来。这个“桌子”就是缓存行。两个线程的操作彼此没有逻辑关联,但因为共享了同一个缓存行,性能被拖累了。
Java里有个经典例子,在高并发下操作一个数组的不同下标,如果下标恰好落在同一个缓存行里,性能会急剧下降。在实际生产中我曾碰到过一个统计系统:多个线程各自累加一个独立的计数器,按理说应该是线性扩展的,结果跑出来的性能惨不忍睹。后来排查才发现,计数器对象在内存里挨得太近,触发了伪共享。
2.3 避免伪共享的几种手段
解决伪共享的思路就是让不同线程操作的变量不要落在同一个缓存行。常见做法是缓存行填充,在变量前后加一些无意义的字段,把变量“撑”到不同的缓存行里。
public final static class Counter { private volatile long value; // 填充字段,让value独立占用一个缓存行 private long p1, p2, p3, p4, p5, p6, p7; }Java 8之后提供了更方便的注解@Contended,加上这个注解之后JVM会自动做填充。但是注意,这个注解默认是内部API,加上了还要加-XX:-RestrictContended参数才能生效,否则会被忽略。
public final static class Counter { @Contended private volatile long value; }注意事项:伪共享优化属于“性能调优的最后一步”,不要在一开始就过度设计。我在项目里见过功能还没写完就开始填缓存行的,纯属本末倒置。先跑基准测试,确认是伪共享导致的问题,再去做优化,而且要验证优化后的效果。
说到验证,如果你用的是Linux,可以用perf工具看缓存未命中率;如果只想快速确认是不是伪共享,可以把线程数从1加到4,看性能是否近似线性增长。如果线程多了性能反而掉,那基本就是伪共享的嫌疑。
2.4 缓存一致性协议:MESI与写操作的代价
缓存行充满之后,CPU之间怎么保持数据一致?现代CPU普遍用的是MESI协议——四个状态分别对应Modified、Exclusive、Shared、Invalid。
当一个CPU核修改了某个缓存行,需要通知其他核把对应的缓存行置为Invalid。其他核再次访问这个变量时,发现缓存行过期了,就不得不重新从内存或者其他核获取数据。这个同步过程有代价,也解释了为什么多线程写同一个变量会这么慢。
结合内存屏障再往后看,你会发现volatile的实现和MESI有千丝万缕的联系:volatile变量的读写在底层会有额外的指令来确保其他CPU能看到最新的值,本质上是让缓存行的状态转换按照我们期望的顺序发生。
3. 内存屏障:看不见的“顺序阀门”
3.1 重排序是怎么回事
先问一个问题:上面这段代码,如果a = 1和flag = true没有依赖关系,编译器或者CPU会不会把它们的执行顺序调换?
答案是:会。编译器会做指令重排序,CPU也可能做乱序执行。重排序的前提是“单线程语义不变”,也就是在单线程环境里,重排序不影响最终结果。但在多线程环境里,一个线程的重排序可能对另一个线程造成逻辑错误。
// 线程A data = 42; flag = true; // 线程B while (!flag); System.out.println(data);如果编译器把flag = true重排到了data = 42前面,那么线程B看到flag为true时,data可能还是旧值。这种bug是噩梦级的,因为它不是必现,而是概率性出现,和CPU负载、内核调度都有关系。
3.2 volatile与内存屏障的对应关系
volatile在Java里有两个语义:可见性和有序性。可见性说的是volatile变量的修改对其他线程立即可见;有序性说的是编译器不会把volatile变量的读写操作随意重排序。
从硬件层面看,volatile的读写会插入内存屏障指令。JMM(Java内存模型)针对volatile定义了四个屏障规则:
- 在每个volatile写操作的前面插入StoreStore屏障,禁止上面的普通写与下面的volatile写重排序。
- 在每个volatile写的后面插入StoreLoad屏障,防止volatile写与之后可能有的volatile读写重排序。
- 在每个volatile读的后面插入LoadLoad屏障,禁止volatile读与其后的普通读重排序。
- 在每个volatile读的后面插入LoadStore屏障,禁止volatile读与其后的普通写重排序。
这些屏障在不同平台上的实现不一样。x86架构本身有比较强的内存模型,实际用到的屏障指令不多,通常靠lock前缀指令来完成。而ARM、PowerPC这种弱内存模型架构,就必须显式使用dmb等指令。
如果你不是写JVM的人,这些底层指令不用记,但需要理解一个核心点:内存屏障是用来限制重排序的,不是用来保证“立即生效”的。它解决的是“CPU会不会看到旧值”以及“CPU看到的乱序程度”的问题。
3.3 volatile做不到什么
volatile经常和synchronized对比,但两者根本不在一个层面。volatile只能保证单个变量的可见性和有序性,不能保证复合操作的原子性。
典型的例子是count++。这个操作在字节码层面是读、加、写三步,volatile只能保证每一步都在最新值上操作,但不能保证三步之间没有其他线程插进来。所以并发场景下的计数器,该用AtomicInteger或者锁还是得用。
另外一个容易混淆的点:volatile数组的引用是volatile的,但数组元素不是。volatile int[] arr只保证arr这个引用变量的可见性,arr[0]的修改并不受volatile语义保护。我见过有人写volatile int[]然后用多线程直接改数组元素,结果数据错乱,还一直怀疑是JDK的bug。
3.4 内存屏障bug案例:双重检查锁
双重检查锁是面试高频题,也是内存屏障知识的经典应用场景。
class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }new Singleton()在JIT编译之后,不是原子操作。它大致要经历三步:分配内存、调用构造方法初始化字段、把引用赋值给instance。如果没有volatile,第二步和第三步可能被重排序,也就是说instance可能先指向一块还没初始化的内存,然后另一个线程进来发现instance不为null,直接返回了一个半成品对象。
加上volatile之后,instance = new Singleton()之前的普通写操作(构造方法里的字段赋值)不会被重排到volatile写之后,防止了拿到半成品对象的可能。这就是内存屏障在代码设计里的实际作用。
心得:我曾经在一个系统里写过类似的懒加载单例,但漏了volatile,结果在压测时出现了偶发的空指针异常。当时排查了很久,最后怀疑到指令重排序上才解决。从那之后我对volatile这个关键字多了一份敬畏——它不是什么“缓存神器”,而是内存可见性的底线保证。
4. 线程通信:wait/notify与park/unpark的两套哲学
4.1 monitor机制:为什么wait必须持有锁
Java的synchronized关键字背后是monitor的概念。每个Java对象天生关联一个监视器锁,进入synchronized代码块就是获取这个对象的monitor,退出就是释放。
wait()的作用是“我暂时不干了,把锁交出去,你们谁拿到锁谁干活”。调用wait()后,当前线程会释放monitor并进入WAITING状态,直到其他线程调用notify()或notifyAll()把它唤醒。
这里有一个硬性规定:调用wait()、notify()、notifyAll()的线程,必须先持有对应对象的monitor,否则会抛IllegalMonitorStateException。为什么?因为如果不持有锁就调用wait(),这个线程根本不在“等这个对象”的队列里,notify()通知谁?所以JVM直接把这个场景定义为非法。
从实现上看,每个monitor都维护了一个等待队列。调用wait()的线程会进入这个队列,notify()随机唤醒一个,notifyAll()唤醒全部。注意,唤醒不等于立刻执行,被唤醒的线程还要重新竞争锁,竞争成功才能继续。
4.2 wait/notify的经典使用姿势
我一直强调,wait()必须放在while循环里,而不是if里。原因是两个:一个是虚假唤醒——在某些底层实现中,即使没有notify(),线程也可能被唤醒,虽然这种情况极少见,但JVM不禁止这种行为;另一个是多线程竞争——即使某个线程被唤醒了,它要等锁释放才能继续,在这期间条件可能又被其他线程改变了。
所以标准姿势是这样的:
synchronized (lock) { while (!condition) { lock.wait(); } // 条件满足,继续执行 }有人会问,为什么notify()而不是notifyAll()?notify()只唤醒一个等待线程,效率更高,但适用于只有一个消费者线程的场景。如果有多个线程在等不同的条件,notify()可能唤醒一个条件不满足的线程,导致它继续wait,而你本来想唤醒的那个线程反而没被唤醒。所以,如果无法保证“一个条件只对应一个等待线程”,优先用notifyAll()。
4.3 丢失唤醒问题与LockSupport的设计
wait/notify有一个天生缺陷:如果线程还没来得及执行wait(),notify()就先来了,那么这个notify信号就丢了。等线程真的进入wait()后,再也没人来唤醒它,它就永久阻塞了。
这个问题的根源是wait/notify之间没有“信号存储”。LockSupport的设计思路从根本上解决了这个问题。它的核心是一个per-thread的许可证。unpark()相当于颁发一张许可证,park()则是在消费许可证:如果有许可证,park()立刻返回并消耗掉;如果没有,线程就阻塞直到许可证到来。
正因为有许可证的存在,即使unpark()先于park()发生,信号也不会丢。许可证本质是“一个只能存一张的令牌”,这就把wait/notify的那个缺陷给堵上了。
// 用LockSupport实现等待 class WaitNode { volatile Thread waiter; } // 线程A LockSupport.park(); // 线程B LockSupport.unpark(threadA);LockSupport还有一个很大的优势:它不需要在同步代码块里使用。你不需要持有任何锁就能park/unpark,这大大简化了代码结构。当然,便利背后也有代价——没有锁的保护,共享变量的可见性需要自己用volatile保证。
4.4 wait/notify与park/unpark怎么选
这两套机制不是替代关系,而是适用场景不同。wait/notify依赖monitor,要求必须持有锁,适合在临界区里做条件等待;park/unpark更轻量,适合不需要锁控制的线程间通信。
从使用体验来看,park/unpark像是一对一的信号量,而wait/notify像是一对多的广播。park/unpark可以精确唤醒指定线程,notify无法指定唤醒哪一个。
我自己在实现任务队列时,早期用的是synchronized加wait/notify,后来改成ReentrantLock加Condition,再到后来很多简单场景直接上LockSupport。LockSupport还有一个好用之处:它支持超时park,比如parkNanos()可以指定休眠时间,非常适合做轮询类的逻辑。
| 对比维度 | wait/notify | park/unpark |
|---|---|---|
| 前置条件 | 必须持有monitor锁 | 不需要锁 |
| 唤醒目标 | notify随机唤醒一个,notifyAll全唤醒 | 精确定位线程 |
| 信号存储 | 不存储,丢了就完蛋 | 有per-thread许可证 |
| 中断响应 | 抛InterruptedException | 阻塞时中断,不会抛异常 |
| 典型应用 | 生产者消费者、线程池 | 锁的阻塞、ForkJoinPool、AQS |
注意最后一行的区别很关键:wait()被interrupt时,线程会抛出InterruptedException;而LockSupport.park()被interrupt时,只是返回,线程需要自己检查中断状态。这两个行为差异在实际编码中经常让人踩坑。
4.5 AQS:park/unpark背后的大设计
讲完park/unpark,不提Java并发锁的基石AQS就太可惜了。ReentrantLock、CountDownLatch、Semaphore这些工具,底层几乎都是基于AQS实现的。
AQS的核心是一个volatile int类型的state,加上一个CLH变种的等待队列。当线程获取锁失败,就进入等待队列并调用LockSupport.park()挂起;当锁释放时,从队列头部唤醒等待线程,调用LockSupport.unpark()。
关键在于state这个volatile变量。它是整个锁的内存屏障:修改state会触发volatile写的屏障语义,唤醒线程时能保证之前对共享变量的修改对唤醒线程可见。这就是为什么写了synchronized或ReentrantLock保护的代码,不用额外加volatile就能保证可见性——锁的获取/释放过程已经帮你把内存屏障补齐了。
理解了AQS,你会发现之前的CPU缓存、内内存屏障、park/unpark全都串起来了:锁的state是volatile,保证状态变更对所有线程可见;线程挂起和唤醒用park/unpark,实现精准的阻塞控制;获取到锁的线程通过内存屏障,能看到释放锁线程的所有写操作。
5. 常见问题与排查技巧实录
5.1 死锁之外的“活锁”和“饥饿”
死锁大家都熟悉,两个线程互相持有对方需要的锁。但“活锁”和“饥饿”在业务代码里也很常见,而且更难发现。
活锁是线程一直在运行,但谁也做不了正事,像两个人在小巷子里互相让路,你往左、我往左,你往右、我往右,始终过不去。饥饿是某个线程始终得不到锁或CPU时间片,一直无法推进。
我排查过一次特别诡异的“假死”问题:服务线程还在运行,但任务就是没进展。最后发现是几个线程之间频繁地park和unpark,互相抢占资源,陷入了一种活锁状态。解决办法是引入随机退避(类似指数退避),打破对称性。
5.2 线程dump:排查阻塞问题的第一手段
遇到线程卡死,我的第一反应是打线程dump。jstack这个命令虽然老,但在排查线程问题时依然是最直接的工具。
线程dump里能看到每个线程的状态,重点看三种:WAITING、BLOCKED、RUNNABLE。WAITING通常是调用了wait()或park(),BLOCKED是在等锁,RUNNABLE可能是真的在跑,也可能是处于某种IO等待。
之前有个线上服务,偶尔响应变慢,我用jstack连续抓了几次dump,发现总有几个线程卡在同一个锁上。顺着锁的持有者一路查下去,发现是一个数据库连接池被耗尽,导致持锁线程长时间等待连接,其他线程全堵在锁外面。线程dump能直接告诉我等待链的源头,比盲猜高效得多。
5.3 中断标志位丢失问题
前面提到sleep()会清除中断状态,这个其实只是中断标志丢失的一种情况。还有一个经典问题:FutureTask的cancel()方法。当你调用future.cancel(true)去取消任务时,如果任务正在执行一个不响应中断的阻塞操作,cancel几乎不起作用。
举个例子,一个线程在执行URLConnection.connect(),这个方法在被中断时不会返回。那么即使你调了interrupt(),线程依然会卡在网络连接上。解决办法是给连接设置连接超时,比如connectTimeout和readTimeout,让阻塞操作自己超时返回,然后再检查中断标志。
5.4 伪共享问题排查经验
排查伪共享,有迹可循。一个项目在压测时表现反常:线程从1加到4,吞吐量没涨反掉,CPU使用率却上去了。我用perf去采集CPU事件,发现大量时间花在了缓存一致性同步上。换句话说,CPU核之间在疯狂同步缓存行,而不是真正干计算。
定位到具体变量之后,我用了字段填充,优化后性能立刻提了上来,虽然不是线性扩展,但也比之前好太多。如果你不想用perf这种重型工具,也可以用JMH写一个微基准测试,专门验证某个数据结构在多线程访问下的性能曲线。
5.5 volatile与可见性的迷思
我见过一些线上事故是因为对volatile的理解不到位。最常见的是用volatile修饰计数器然后做累加,结果数据对不上。这个问题我在前面讲volatile做不到什么时提过,但实际中总有人一遍遍踩。
另外还有一种情况:用volatile变量作为线程间的“开关”,但开关读取时没注意重排序。比如:
volatile boolean running = true; // 线程A while (running) { // do something } // 线程B running = false;这个例子本身没问题,因为running是volatile,修改了对循环可见。但如果循环里还依赖了其他普通变量的状态,那就要小心了——volatile只保证自己这个变量的可见性,不保证其他普通变量在volatile写之前的状态在volatile读之后依然可见。想要这种传递性,得依赖happens-before规则来推导。
排查建议:凡是在多线程环境里出现“偶尔正常、偶尔异常”的数据错乱问题,先列一遍共享变量清单,找到哪些是volatile、哪些是final、哪些是普通变量。如果你发现共享状态完全没同步,又没加锁,那问题基本就解决了——不是数据结构的问题,是并发控制缺失。
6. 实用工具与代码模式分享
6.1 用等待唤醒实现一个简易阻塞队列
把前面说的知识点串起来,我可以手写一个非常简单的阻塞队列。这里我不用JDK自带的高级工具,只用synchronized、wait、notifyAll来实现,目的是跑通整个通信机制。
public class SimpleBlockingQueue<T> { private final Object[] items; private int head, tail, count; public SimpleBlockingQueue(int capacity) { items = new Object[capacity]; } public synchronized void put(T item) throws InterruptedException { while (count == items.length) { wait(); } items[tail] = item; tail = (tail + 1) % items.length; count++; notifyAll(); } @SuppressWarnings("unchecked") public synchronized T take() throws InterruptedException { while (count == 0) { wait(); } T item = (T) items[head]; head = (head + 1) % items.length; count--; notifyAll(); return item; } }这里有几个值得注意的点:队列满时put等待,队列空时take等待,wait都在while循环里,入队出队后都调notifyAll。因为可能有多个生产者多个消费者,只用notify()大概率会在某些场景下出现唤醒错误线程的问题。
6.2 线程池里如何优雅停止
线程池的shutdown和shutdownNow一直是很多人分不清的。shutdown()会等待队列里的任务全部执行完毕再终止线程池,shutdownNow()则是尝试立刻停止所有任务。
但shutdownNow()不是万能的,它只是给每个工作线程发送interrupt信号。如果线程本身不响应中断,shutdownNow也拿它没办法。所以线程池里跑的任务,必须写得能响应中断。我自己会在任务循环体里,每轮都检查isInterrupted(),并且捕获InterruptedException后重新设置中断标志,这样线程池在关闭时才能顺利停下来。
6.3 生产环境中常用的高级同步工具
除了基础的wait/notify和park/unpark,日常开发中我更推荐直接用JDK封装好的高级工具,因为它们经过了充分优化,出错概率更低。
Semaphore适合控制并发流量,比如限制某个接口的并发请求数。CountDownLatch适合“等待多个线程都完成某件事”,比如并发请求多个下游,等全部返回后再聚合。CyclicBarrier适合多个线程互相等待达到某个点后再一起继续,比如并行计算中每个线程算完一块,然后一起汇总。
这些工具底层的同步逻辑,要么依赖AQS的state和park/unpark,要么依赖AbstractQueuedSynchronizer的条件队列,看起来各不相同,但核心都没跳出前面讲的几块知识。理解底层之后,上层的选择就变得很简单。
7. 性能对比与选型参考
7.1 一个简单的基准结果解读
我在同一台机器上跑过一个对比测试:分别用synchronized、ReentrantLock、LongAdder对同一个计数器进行1000万次累加。结果大概如下:
| 方案 | 耗时(毫秒) | 说明 |
|---|---|---|
| 单线程直接自增 | 约10ms | 基准值,无竞争 |
| synchronized加锁自增 | 约300ms | 锁竞争开销大 |
| AtomicLong | 约150ms | CAS乐观锁,但竞争激烈时自旋开销高 |
| LongAdder | 约30ms | 分段累加,适合高并发计数 |
这个结果说明了什么?无脑加锁不是好方案。AtomicLong在低竞争场景下很快,但高竞争下会频繁自旋;LongAdder通过分段累加减少了并发冲突。选型时要根据你的真实并发量来定,而不是听着哪个高级就用哪个。
7.2 锁粒度优化:粗锁改细锁
锁粒度太大,性能差;锁粒度太小,容易死锁。这是锁优化的核心矛盾。
我优化过一个订单状态机服务,早期是整条方法加synchronized,后来改成只锁具体订单ID的锁对象,粒度从“全类”细化到“单订单”,并发能力立刻提升了一个数量级。实现方式是用一个ConcurrentHashMap,key是订单ID,value是锁对象。
private final ConcurrentHashMap<String, Object> lockMap = new ConcurrentHashMap<>(); public void processOrder(String orderId) { Object lock = lockMap.computeIfAbsent(orderId, k -> new Object()); synchronized (lock) { // 处理订单 } }注意这个方法有个隐患:lockMap里的锁对象不会自动清理,如果订单量巨大,会有内存泄漏风险。所以要加一个remove逻辑,比如在finally块里判断锁竞争者是否为零时移除。这块也可以用现成的Striped锁实现,比如Guava的Striped,它能自动管理锁对象的数量和生命周期。
7.3 锁消除与锁粗化
JVM在JIT编译时会做锁消除和锁粗化的优化。锁消除指的是JVM发现某个锁对象只会被一个线程访问,那么直接把这个锁去掉。锁粗化指的是JVM把多个相邻的加锁/解锁操作合并成一个大锁,减少加解锁次数。
这些优化对程序员是透明的,但有一个启发:不要写过于琐碎的同步代码。比如在一个循环体内部调用一个带锁的方法,每次循环都加锁解锁,即使JVM能做优化,也不如自己在循环外一次性拿锁来得干脆。
8. 最后的实践心得
写并发代码这几年,我最大的感受是:这些底层概念不是孤立的知识点,而是一张互相牵连的网。你理解了CPU缓存,才会明白为什么volatile不能包治百病;你理解了内存屏障,才能解释为什么双检锁必须加volatile;你理解了park/unpark的许可证机制,才能看懂AQS的等待队列是怎么做到高效唤醒的。
在实际编码里,我现在的习惯是:默认不使用stop、suspend这类废弃方法;凡是要做线程停止控制的,一律通过interrupt加标志位实现,并把中断响应逻辑写进每个长期运行的任务;共享变量的可见性尽量用并发工具来保证,而不是裸用volatile;线程间通信优先用高级并发工具,避免手写wait/notify的裸逻辑。
线程中止、CPU缓存优化、内存屏障、线程通信这些主题,单独拿出来都能写一本书,但真正在工作里用的,还是这些基础概念的组合应用。希望这篇文章能把它们串起来,让大家面对并发问题时不那么心虚。如果你们在实际项目里碰到过类似的现象,欢迎按这个思路去排查,很多时候答案就藏在缓存行和内存屏障的细节里。