并发编程,从 volatile 到线程池的一次梳理
三大特性、synchronized 锁升级、CAS、AQS、线程池、ThreadLocal——把并发考点串成一条链。

并发这块,是区分"会用"和"真懂"的分水岭。面试时别急着报术语,先把三条根子上的问题讲清楚,后面的东西都是围着它们转的。
一切的源头:三个"性"
原子性:一个操作不可分割。i++ 不是原子操作,它其实是"读、改、写"三步,中间随时可能被别的线程插进来。
可见性:一个线程改了共享变量,别的线程能不能立刻看到。CPU 缓存和指令重排序都会让"改了看不见"。
有序性:编译器和 CPU 为了快会重排指令。单线程内无所谓,多线程共享数据时,重排就可能出问题。
后面所有工具,本质都是在解决这三个"性"里的一个或几个。抓住这条线,很多题就不需要背了。
volatile:能管可见性和有序性,管不了原子性
volatile 保证两件事:写 volatile 变量会立刻刷新到主内存,别的线程能看见(可见性);同时禁止它前后代码被重排(有序性)。但它不保证原子性。
所以 volatile 适合"一写多读"的场景,比如一个开关标志位;绝不适合 count++ 这种读改写操作——三个线程同时读一个值加 1 再写回去,丢更新是家常便饭。那类操作得上 Atomic 类或者加锁。
synchronized:锁是怎么一步步升级的
JDK 6 之后 synchronized 是"锁升级"的:从偏向锁到轻量级锁到重量级锁。
- 偏向锁:一个线程反复进,CAS 记个线程 ID 就完事,几乎零开销;
- 轻量级锁:有第二个线程来抢了,靠自旋 + CAS 在栈帧里做锁记录,谁抢到谁执行;
- 重量级锁:自旋超过次数还没抢到,升级成 monitor 锁,抢不到的线程直接阻塞排队。
这里有个 2026 年容易被旧资料坑的地方:偏向锁 JDK 15 起默认禁用了(JEP 374),因为维护成本大于收益。所以现在面试别再答"偏向锁默认开启"——新 JDK 里一上来就是无锁状态,走轻量级、重量级两条路。
CAS:无锁并发的地基
CAS(Compare And Swap)就一句话:比较当前值和期望值,相等就更新,不相等就重来。AtomicInteger、LongAdder、ConcurrentHashMap 的插入,底层都是它。
它有个著名的坑叫 ABA 问题:线程 A 读到值是 V,中间线程 B 把它改成 W 又改回 V,A 的 CAS 照样成功,但中间已经被动过手脚。解法是加版本号,用 AtomicStampedReference。
AQS:JUC 那堆工具的地基
AbstractQueuedSynchronizer,ReentrantLock、Semaphore、CountDownLatch 这些全是它长出来的。核心就三样:
- 一个
volatile int state,表示资源状态——ReentrantLock 里是重入次数,Semaphore 里是剩余许可; - 一个等待队列,抢不到资源的线程排队;
- 模板方法
tryAcquire/tryRelease留给子类实现。
经典的追问:ReentrantLock 和 synchronized 有什么区别?
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 释放 | 自动 | 手动 unlock,得放 finally |
| 可中断 | 不行 | lockInterruptibly |
| 超时 | 不行 | tryLock(timeout) |
| 公平 | 非公平 | 可指定公平 |
| 条件 | wait/notify | Condition 多条件队列 |
一句话总结:默认用 synchronized,够用;真要公平锁、超时、可中断,再上 ReentrantLock。
线程池:七个参数和一个陷阱
ThreadPoolExecutor 的七个参数:核心线程数、最大线程数、非核心线程存活时间、时间单位、任务队列、线程工厂、拒绝策略。
执行流程就一条流水线:提交任务 → 核心线程没满先建线程 → 满了丢队列 → 队列满了没到最大线程数就扩线程 → 全满了走拒绝策略。拒绝策略四种:抛异常(默认)、调用者线程自己跑、静默丢弃、丢最老的。
重点陷阱:别用 Executors 的快捷方法。newFixedThreadPool 用的是无界队列,任务堆积能把内存吃满;newCachedThreadPool 最大线程数是 Integer.MAX_VALUE,线程爆炸也是 OOM。生产上我都是手写 ThreadPoolExecutor,顺手给线程起个能看懂的名字,排查问题的时候少受罪。
ThreadLocal:用完记得 remove
每个线程一个 ThreadLocalMap,key 是 ThreadLocal 的弱引用,value 是强引用。问题就在这:ThreadLocal 被回收后 key 变 null,value 却还挂在线程上拿不走——线程池里的线程常年活着,越积越多,就是内存泄漏。
所以规范是:用完必须 remove(),而且要用 try-finally 包着,就算业务抛异常也得清理。
异步编排:CompletableFuture
老 Future 只能傻等 .get(),CompletableFuture 能回调式地串起来:
CompletableFuture.supplyAsync(() -> queryOrder())
.thenApply(order -> calcPrice(order))
.thenAccept(price -> notify(price))
.exceptionally(e -> { log.error("失败", e); return null; });thenApply 转换结果,thenCombine 合并两个异步结果(并行查询后汇总,经典场景),allOf 等一批任务全完成。JDK 21 之后配合虚拟线程用很舒服——虚拟线程把阻塞 IO 的成本打到趋近于零,编排交给 CompletableFuture,执行交给虚拟线程。
死锁:四个条件和排查
死锁四条件:互斥、持有并等待、不可剥夺、循环等待。四个都满足才死锁,所以破局就是打破其中任何一个。
排查流程基本固定:jps 找进程号,jstack 打线程栈,搜 "deadlock"。预防上,我习惯统一加锁顺序、用 tryLock 加超时,能用并发容器就不用手工同步。
高频追问
String为什么不可变?答到缓存、安全、线程安全,基本齐了。- 线上 CPU 飙升怎么查?
top找进程 →jstack看线程栈 → 定位热点方法,就这三步。
最后唠叨一句:并发面试能不能过,看你能不能把 三大特性 → volatile → synchronized → CAS → AQS → JUC 工具 串成一条链。每个工具其实都在解决那三个"性"里的某一个,串起来,比背十个定义管用。