想象一个场景:活动页上线,两个入口同时在扣同一份库存,各扣 10 万次,运营对账时发现账对不上——凭空少了一截。日志里没有任何报错,把请求单独重放一遍,每一笔又都是对的。这种“单看每一步都对,合在一起就是错”的问题,十有八九出在并发。
volatile、synchronized、AtomicInteger、线程池——这些东西平时都在用,但“各自到底解决什么问题、为什么要配合着用”,值得系统梳理一遍。这篇就按这条线走:先看多线程为什么会出事,再按“一个问题一把钥匙”讲每件工具,最后讲线程池怎么配。文中的示例代码都实际跑过(JDK 21),运行结果是真实输出,不是编的。
一、解决什么问题:多线程下的共享数据安全
先看最经典的翻车现场。两个人同时往一个账本上记账,每人记 10 万笔,最后账上应该有 20 万:
public class Counter {
private int count = 0;
public void increment() { count++; }
}
// 两个线程各加 10 万次
Counter c = new Counter();
// 线程 A:for (int i = 0; i < 100_000; i++) c.increment();
// 线程 B:for (int i = 0; i < 100_000; i++) c.increment();实际跑出来的结果:
期望:200000
实际:121510 ← 少了快 8 万笔为什么少了?因为 count++ 看着是一行代码,底层其实是三步:
读 count(比如读到 5) → 加 1(得 6) → 写回 count(写 6)单线程没问题,两个线程同时跑就可能交错执行——B 在 A 写回之前也读了,读到的还是旧值:
这就是“线程不安全”——两个线程的操作互相踩脚,一部分加法凭空消失了。
二、先补课:线程的生命周期
讲三大问题之前,先把线程本身的状态搞清楚,后面“线程卡在哪”都靠这套语言描述。
Java 线程有 6 个状态(Thread.State 枚举),一张状态流转图先立住:
用外卖订单类比一遍就好记了:
| 状态 | 类比 | 什么时候进入 |
|---|---|---|
| NEW | 订单刚下单,商家还没接 | new Thread() 之后、start() 之前 |
| RUNNABLE | 商家接单开做了(或在排队等着被系统调度) | 调了 start() |
| BLOCKED | 到店取餐,窗口被别人占着干等 | 想进 synchronized 但锁被别人拿着 |
| TIMED_WAITING | 定了个闹钟歇 30 秒 | sleep(n)、wait(n)、join(n) |
| WAITING | 没有闹钟,干等到有人喊 | wait()、join()(不带时间) |
| TERMINATED | 订单送达 | run() 执行完毕 |
光背状态没用,跑一遍最直观——用一个线程依次经历三种状态,在旁边偷看它的状态值:
public class ThreadStateDemo {
public static void main(String[] args) throws Exception {
Object lock = new Object();
Thread t = new Thread(() -> {
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
synchronized (lock) { } // 睡醒后抢锁
});
System.out.println(t.getState()); // NEW:还没 start
t.start();
Thread.sleep(20);
System.out.println(t.getState()); // TIMED_WAITING:正在 sleep(100)
synchronized (lock) { // main 先把锁占了
Thread.sleep(150); // 此时 t 睡醒了,正卡在抢锁
System.out.println(t.getState());// BLOCKED:等 main 放锁
}
t.join();
System.out.println(t.getState()); // TERMINATED:跑完了
}
}实测输出:
NEW
TIMED_WAITING
BLOCKED
TERMINATED注意一个容易懵的点:BLOCKED 和 WAITING 都是“等”,区别是等什么。BLOCKED 是等锁(排队进门),WAITING 是等通知(等别人喊它)。RUNNABLE 并不代表“正在 CPU 上跑”,它只表示“有资格跑”——2 核 CPU 上跑 10 个 RUNNABLE 线程,同时真正在跑的最多 2 个,其余在操作系统层面排队。
三、三大问题的根源:Java 内存模型(JMM)
回到第一节的翻车现场。count++ 踩脚只是三大问题中的一个(原子性),要理解另外两个,得先看 Java 是怎么安排内存的。
Java 内存模型(JMM)规定:所有共享变量存在主内存里;每个线程干活时有自己的工作内存(可以理解为 CPU 缓存和寄存器的抽象),要用变量就先拷贝一份到自己的工作内存,改完再写回主内存:
问题就出在这:每个线程操作的都是自己手里的拷贝,什么时候写回主内存、别的线程什么时候重新去读,JVM 说了不算,编译器和 CPU 也会“自作主张”地优化。于是三大问题全来了:
| 问题 | 是什么 | 在 JMM 里怎么发生的 | 例子 |
|---|---|---|---|
| 原子性 | 一段操作不可分割,中间不能被打断 | 三步操作被另一个线程插队交错 | count++ 读改写被打断,结果丢失 |
| 可见性 | 一个线程改了变量,另一个线程看不到 | 改的是自己工作内存的拷贝,没及时写回;或对方一直读旧的拷贝 | 线程 A 改 flag=true,线程 B 死循环读不到 |
| 有序性 | 代码执行顺序变了 | 编译器/CPU 为了跑得快,重排没有依赖的指令 | 单例双重检查的 instance = new X() 重排 |
类比:主内存是仓库总账本,每个线程的工作内存是自己手里的草稿纸。你在草稿纸上记的数,别人看不到;你不把新数抄回总账本,别人查的永远是旧数。Java 后面所有的并发工具,本质都是在约束“什么时候必须抄回去、什么时候必须重新读”。
三大问题分别由谁解决,一张表先立住,后面逐个展开:
| 工具 | 可见性 | 原子性 | 有序性 |
|---|---|---|---|
volatile | ✅ | ❌ | ✅ |
synchronized | ✅ | ✅ | ✅ |
| CAS / 原子类 | ✅ | ✅(单变量) | ——(不涉及) |
四、volatile:解决可见性 + 有序性(不解决原子性)
4.1 可见性:让“草稿纸”作废
volatile 保证:一个线程改了变量,其他线程立刻能看到。
先看不加 volatile 会怎样。下面的代码,main 线程负责改标志位,另一个线程死循环等标志位:
public class VisibilityDemo {
static boolean flag = false; // 注意:没加 volatile
public static void main(String[] args) throws Exception {
Thread t = new Thread(() -> {
long i = 0;
while (!flag) { i++; } // flag 一变 true 就退出
});
t.setDaemon(true);
t.start();
Thread.sleep(300);
flag = true; // main 把标志位改成 true
t.join(1200);
System.out.println("读线程还活着吗:" + t.isAlive());
}
}按“常理”想,flag 变 true 之后循环最多再转一圈就该退出了。实测结果:
读线程还活着吗:true ← main 改完 1.2 秒了,读线程还在死循环原因是 JIT 编译器看到循环体里没人改 flag,做了一个很“聪明”的优化:把读 flag 的操作提到循环外面,只读一次,相当于:
if (!flag) { // 只读这一次
while (true) { i++; } // 从此再也不看 flag
}这就是可见性问题的极端形态——不是“看到得慢”,是永远看不到了。给 flag 加上 volatile 再跑:
读线程还活着吗:false ← 立刻退出volatile 做了两件事:① 禁止这种“读提出循环”的优化,每次都从主内存重新读;② 写操作立刻刷回主内存,并把其他线程工作内存里对应的拷贝标记为过期。类比:volatile 变量就是写进群公告 @ 了所有人——不允许任何人拿自己的小本本记录它的旧值。
4.2 有序性:禁止指令重排
volatile 还禁止指令重排。最经典的翻车现场是单例模式的双重检查锁(DCL):
public class Singleton {
private static volatile Singleton instance; // 这里的 volatile 能去掉吗?
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 危险!
}
}
}
return instance;
}
}instance = new Singleton() 看着是一行,底层是三步。CPU 可能把它重排成 ①③②——门牌先挂了,房子还没盖:
单线程下重排无所谓(最终结果一样),但多线程下:线程 A 执行完③还没执行②,线程 B 进来一查 instance != null,直接把这个“半成品”拿去用了,字段全是默认值。
所以这里的 volatile 不是为了可见性,而是禁止 ②③ 重排,保证“先盖完房、再挂门牌”。这也是 DCL 必须加 volatile 的原因——两次 if 检查只能挡住“还没开始建”的对象,挡不住重排造出来的半成品。
4.3 最容易踩的误区:volatile 不保证原子性
很多人以为“加了 volatile 就线程安全了”,跑个实验就知道不是:
static class VolatileCounter {
volatile int count = 0; // 已经加了 volatile
void increment() { count++; }
}两个线程各加 10 万次,实测:
期望:200000
实际:119754 ← 加了 volatile 照样丢一大截原因:volatile 只管住“每次都读最新值、写完立刻刷回去”,但 count++ 是三步——读、加、写之间没有约束,线程 B 依然可以在线程 A“读完、还没写回”的空档插进来。可见性和原子性是两码事,volatile 只管前者。原子性要靠下一节的 synchronized 或 AtomicInteger。
还有一个小坑:volatile 修饰数组或对象引用时,只保证引用本身的可见性,数组里面元素的改动它管不着(那要用 AtomicIntegerArray 或锁)。
五、synchronized:解决原子性 + 可见性
5.1 基本用法:把三步焊成一步
synchronized 保证一段代码同一时刻只有一个线程执行——别的线程进不来,自然踩不了脚:
public class SyncCounter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) { // 同一时刻只允许一个线程进入
count++; // 三步焊成一步:原子 + 可见
}
}
}同样两个线程各加 10 万次,实测:
期望:200000
实际:200000 ← 一笔不少为什么这也顺带解决了可见性?因为 synchronized 的语义里包含:进入同步块前先清空工作内存的旧值(从主内存重读),退出前把修改刷回主内存。相当于每进一次门都强制“重新对账”,出来必须“当场结账”。
用类比理解锁的模型:synchronized(lock) 就是一间单人会议室,门口挂一块写着 lock 的牌子——谁先抢到牌子谁进去开会,进去后把牌子摘了;后面的人到了发现牌子没了,就在门口排队。lock 只是个“牌子”,任何对象都能当(对象头里有专门的区域记录锁状态),private final Object lock = new Object() 是标准模板:
- private:锁只在本类内部用,外面拿不到引用,就没人能跟你抢同一块牌子
- final:牌子永不更换。如果引用中途被换,线程 A 拿着旧牌进去了,线程 B 拿新牌也能进——同一间“会议室”同时进了两个人,锁就裂了
- new Object():全新独一份,不会撞上字符串常量池、Integer 缓存这种全 JVM 共享的对象(写
synchronized("lock")就是跟全世界抢同一块牌子)
5.2 锁竞争的过程
两个线程抢锁时发生了什么——抢不到的那个会被挂起(BLOCKED),让出 CPU 去排队:
关键点:抢不到锁的线程会被挂起(BLOCKED 状态),由操作系统调度,这是个有开销的操作(要陷入内核态)。这就是 synchronized 被叫“重量级”方案的原因——上下文切换比几条指令贵得多。
5.3 三种用法与背后的锁对象
synchronized 有三种写法,锁的对象各不相同,混用容易出事故:
| 写法 | 等价于锁 | 范围 |
|---|---|---|
synchronized(lock) 代码块 | 你指定的对象 | 最灵活,推荐 |
synchronized 实例方法 | this(实例本身) | 同一实例的方法之间互斥 |
synchronized 静态方法 | Xxx.class(类对象) | 全局唯一,所有实例互斥 |
容易翻车的组合:两把锁守同一个资源。两个方法都在改同一个 count,但加锁姿势不一致:
public class Counter {
private int count;
private final Object lock = new Object();
public synchronized void increment() { // 实例方法 → 锁的是 this
count++;
}
public void decrement() {
synchronized (lock) { // 代码块 → 锁的是 lock
count--;
}
}
}线程 A 进 increment 抢的是 this 的锁,线程 B 进 synchronized(lock) 抢的是 lock 的锁——两把不同的锁,互不干扰:A 在改 count 的同时 B 也能进,count-- 照样在 count++ 的读和写之间插队,丢失更新照样发生,该挡的没挡住。
synchronized 的互斥有个隐含前提:抢的是同一个锁对象。锁不是全局唯一的警察,而是每个对象一扇独立的门,各守各的。所以排查“明明加了锁怎么还有并发问题”,第一件事不是怀疑 synchronized 失灵,而是核对:加锁的几处代码,锁的是不是同一个对象。
5.4 可重入:自己不会锁死自己
synchronized 的锁是可重入的:同一个线程拿到锁后,可以再次进入同一把锁保护的代码,不会把自己卡死:
synchronized (lock) {
methodA(); // methodA 里也有 synchronized (lock)——没问题
}这靠对象头里的“持有线程 + 重入次数”实现:同一线程进来次数加一,出去次数减一,减到零才真正释放。类比:你拿了会议室的牌子,中途出去取个材料再回来,牌子还是你的,不用重新排队。这个特性在“同步方法里调同步方法”的调用链上必不可少,否则所有递归式的锁调用都会死锁。
5.5 底层实现一句话
synchronized 锁的信息记在**对象头(Mark Word)**里。JVM 会根据竞争激烈程度逐步升级:没有竞争时用轻量的 CAS 标记(偏向锁/轻量级锁,不用挂起线程),竞争激烈了才膨胀成重量级锁(走操作系统互斥量,BLOCKED 挂起)。所以现代 JVM 的 synchronized 没有传说中那么慢,低竞争场景性能很好,日常首选它没错。锁的进阶用法(tryLock、可中断、公平锁、多条件队列)看《ReentrantLock 详解》,排队机制看《AQS 原理详解》。
5.6 误区速查
- 锁粒度太大:把整个大方法都
synchronized,锁住了不共享的部分,并发度直接掉到底。锁应该只包住真正碰共享变量的那几行 - 锁可变对象:
synchronized(list)之后又对list = newList重新赋值——别人拿的还是旧对象的锁,全乱套。锁对象要final - 锁 String/Integer 等缓存对象:字符串走常量池、小整数走缓存,全 JVM 共享一份,可能和毫不相关的代码锁到同一个对象上
- synchronized 不解决死锁:两把锁交叉获取(A 拿锁 1 等锁 2,B 拿锁 2 等锁 1)照样死锁,它只保证单把锁的互斥
六、CAS 和原子类:无锁实现原子性
6.1 CAS 是什么:先看一眼,没被动过才动手
CAS(Compare-And-Swap,比较并交换)是另一条实现原子性的路:改之前先比较内存值是不是预期值,是才更新,不是就重来。整个“比较 + 交换”由 CPU 的单条指令保证原子,不需要加锁。
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // 原子地 +1,无锁
boolean ok = count.compareAndSet(expect, update);
// 内存值 == expect 时才改成 update,返回是否成功incrementAndGet() 的内部逻辑本质是个自旋循环——失败就重来,直到成功:
类比:改共享文档前先瞄一眼版本号——你看到的还是 v5 才动手保存;保存时发现已经变 v6 了,说明别人改过,重新拉最新版再改一遍。不加锁、不排队,冲突了就重试。
实测验证 CAS 的判断语义:
AtomicInteger v = new AtomicInteger(0);
boolean ok1 = v.compareAndSet(0, 1); // 预期 0,内存里是 0 → true,更新成功
boolean ok2 = v.compareAndSet(0, 2); // 预期 0,内存里已是 1 → false,拒绝CAS(0→1) = true,CAS(0→2) = false,最终值 = 16.2 AtomicInteger 实测:又快又准
两个线程各加 10 万次:
AtomicInteger count = new AtomicInteger(0);
// 线程 A / B:for (int i = 0; i < 100_000; i++) count.incrementAndGet();期望:200000
实际:200000 ← 无锁,同样一笔不少低竞争下它比 synchronized 快:不用阻塞线程、不用上下文切换,失败了原地重试就行。但有两个边界要知道:
- 只能保证单个变量的原子性。
countA++加countB++两个变量的复合操作,CAS 管不了,还是要锁 - 高竞争下自旋空转烧 CPU:几十个线程抢一个变量,失败率飙升,重试次数暴涨,反而不如直接挂起等锁划算
6.3 ABA 问题:CAS 的盲区
CAS 只比较“值等不等”,比较不出“中间被人改过又改回来”:
大多数场景(计数、累加)ABA 无害;但依赖“中间状态没变过”的场景(比如无锁栈的节点复用)会出真 bug。解法是加版本号,每次改动版本 +1,改回来版本也对不上:
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 0);
int[] stamp = new int[1];
Integer old = ref.get(stamp);
ref.compareAndSet(old, 200, stamp[0], stamp[0] + 1); // 值和版本一起比实测对照:
裸 CAS(100→300) = true (中间被改过,照样成功)
带版本戳 CAS(100→300, 预期版本 v2) = false (版本对不上,拒绝)这也是数据库乐观锁(version 字段)和分布式锁续期里“校验持有者”的同一套思想。
七、线程池:复用线程,别每次 new Thread
7.1 为什么需要线程池
线程虽然轻量,但创建销毁有开销(要分配栈内存、操作系统要建内核对象),而且无限开线程会把内存和 CPU 调度压垮。线程池的思路是养一批工人,活来了排队领,干完不辞退,接着等下一个活:
- 核心线程 = 正式工(常驻,默认不裁)
- 非核心线程 = 忙不过来时招的临时工(闲 60 秒裁掉)
- 任务队列 = 待办事项看板
- 拒绝策略 = 实在装不下了怎么处理
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, // corePoolSize 核心线程数
4, // maximumPoolSize 最大线程数
60, TimeUnit.SECONDS, // 临时工空闲 60 秒回收
new LinkedBlockingQueue<>(100), // 任务队列(容量 100)
new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
);
pool.execute(() -> { /* 任务 */ });7.2 执行顺序(面试高频,但多数人背错了)
任务来了的处理顺序,注意第二步是先入队、不是先扩线程——这是最反直觉的地方(核心线程 → 队列 → 最大线程 → 拒绝策略):
这个顺序光背容易忘,实测一遍最稳。造一个“2 核心、4 最大、队列容量 2”的池子,连灌 7 个任务(每个都很耗时),边提交边抓快照:
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, 4, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2),
new ThreadPoolExecutor.AbortPolicy());
// 连续提交 7 个长任务,在第 4 个、第 6 个之后打印池状态
// 第 7 个提交时捕获 RejectedExecutionException实测输出:
[提交 4 个后] 池内线程=2, 队列=2 ← 核心满了,任务全在排队,没开新线程
[提交 6 个后] 池内线程=4, 队列=2 ← 队列塞满了,才开始招临时工
第 7 个任务 → RejectedExecutionException ← 线程和队列全满,拒绝对照结果看流程就通了:队列没满之前,线程数死守核心数。这也解释了一个经典疑惑——“明明队列还没满,为什么线程数不涨?”:设计如此,入队比开线程便宜。
7.3 四种拒绝策略
| 策略 | 行为 | 适用 |
|---|---|---|
| AbortPolicy(默认) | 抛 RejectedExecutionException | 想第一时间知道装不下了 |
| CallerRunsPolicy | 谁提交谁自己执行 | 天然降速器:提交线程被占住,提交自然变慢 |
| DiscardPolicy | 静默丢弃,不吭声 | 丢得起的数据(如监控采样) |
| DiscardOldestPolicy | 丢掉队列里最老的,塞新的 | 只要最新数据(如行情快照) |
最危险的是 DiscardPolicy:任务无声无息消失,出了问题连日志都没有。除非明确“丢得起”,否则别用。
7.4 线程数怎么定
不是越大越好——线程太多,CPU 全耗在上下文切换上:
- CPU 密集型(计算、序列化):线程数 ≈ CPU 核数 + 1。线程再多也没多核可用,纯排队
- IO 密集型(查库、调接口):线程数 ≈ 核数 × 2 或更高。线程大部分时间在等 IO,CPU 闲着,多开几个把等待时间利用起来
更稳的做法是压测定参,别拍脑袋。
7.5 误区速查
- 别用
Executors.newFixedThreadPool():它内部队列是无界的(Integer.MAX_VALUE),任务堆积不拒绝,堆积到内存爆掉(OOM)。阿里巴巴 Java 开发手册明确禁用Executors快捷方法,理由就在这——手动new ThreadPoolExecutor,把队列容量、拒绝策略都显式写清楚 newCachedThreadPool()同理:最大线程数是Integer.MAX_VALUE,来多少任务开多少线程,线程爆炸- 别忘了优雅关闭:
shutdown()(不再收新任务,等旧的跑完)或shutdownNow()(尝试中断正在跑的),否则 JVM 都退不干净 - ThreadLocal 配线程池要
remove():线程是复用的,上一个任务留在 ThreadLocal 里的数据会被下一个任务读到——脏数据 + 内存泄漏双重隐患
八、三大问题 × 工具对照总表
学完全部工具,回头把这张表补全,整个 Java 并发的主线就齐了:
| 工具 | 可见性 | 原子性 | 有序性 | 代价 | 适用场景 |
|---|---|---|---|---|---|
volatile | ✅ | ❌ | ✅ | 几乎为零 | 状态标志位、DCL 单例 |
synchronized | ✅ | ✅ | ✅ | 锁竞争、线程挂起 | 复合操作、临界区 |
AtomicInteger 等 | ✅ | ✅(单变量) | —— | 自旋重试 | 计数器、无锁累加 |
ThreadPoolExecutor | —— | —— | —— | 线程复用省开销 | 管理线程生命周期 |
小结:
- 多线程出问题,根源就三个:可见性、原子性、有序性,都源自 JMM 的“工作内存”设计
volatile管可见性 + 有序性,不管原子性——volatile int照样丢数据(实测 20 万丢到 12 万)synchronized三样全管,低竞争下性能不差,日常首选;锁对象要private final,别锁this和缓存对象- CAS/原子类无锁实现原子性,快但只限单变量,警惕 ABA 和高竞争自旋
- 线程池核心是“核心线程 → 队列 → 最大线程 → 拒绝策略”四步流程,队列没满不扩线程;禁用
Executors快捷工厂
想继续深入的:volatile 的内存屏障和 happens-before 细看《volatile 关键字详解》;锁的进阶用法(tryLock/可中断/公平锁/Condition)看《ReentrantLock 详解》,读多写少场景看《读写锁详解》;锁底下的排队机制看《AQS 原理详解》;DCL 单例的完整展开看《单例模式》;JVM 怎么支撑并发(对象头、内存模型、GC)看《JVM 内存模型与垃圾回收》。
