JVM 并发入门:别被「线程安全」吓住
用生活化的秩序与踩踏比喻理解并发,再从 count++ 实验走进可见性、原子性与 Java 锁/CAS 的解法地图
很多人喜欢把这件事叫成线程安全(Thread Safety),但我不太喜欢这种叫法——它总在把一个简单的问题复杂化。实际上,它应该只是一个朴素的并发概念。
把一个概念性质的问题叫成「xxx 安全」,感觉就像国家安全、反恐安全那种宏大叙事。用一些听起来很厉害的名词去包装一个简单想法,有点像:我们明明只是在述职——这个季度做了什么、下季度展望在哪——互联网那帮人非要造一个通晒当新鲜词。
它的价值何在?我不理解。
本文想做的,是把 JVM 并发从「术语墙」后面拽出来,用你能推演的生活例子讲清楚;再落到几段可运行的 Java 代码,让你看见不加保护时数字为什么不对、加了锁为什么又对。更底层的锁实现、CAS、AQS,可以接着读 Java Lock;协程如何把「等待」从线程上剥离,可以看 协程与 Executors 的资源对比。
一、并发从哪来:资源被同时使用
并发问题的由来,就是多个执行流同时碰同一份资源。做事情总要有一个先来后到的秩序。
比如 A 领导说:鹏,这里有一个需求 A,你来处理一下吧。
同一时刻,B 领导说:鹏,这里有一个需求 B,你来处理一下吧。
好的,但现在只有一个鹏——鹏到底先处理谁?这就尴尬了。
这时引入的第一个学问是:排队与挂起等待。你要么先 A 后 B,要么先 B 后 A,总之得给出秩序,否则就会乱套。
在 JVM 里,「鹏」就是某条 OS 线程;「需求 A / B」就是两个 Runnable 或两个 Thread,它们可能同时改同一个对象的字段、同一份 List、同一个计数器。
二、不止「先做谁」:还要「做得对」
问题不仅是调度顺序(谁先谁后),还包括结果正确(有没有互相踩脚)。
想象一下:
- A 领导说:「先帮我把需求 A 做完,把状态更新成完成。」
- 你刚做到一半,B 领导冲进来,把状态改成「处理中」,还顺手改了几个关键字段。
- 等你回头再做 A,发现状态已经被改了;你再提交,就会把 B 的修改覆盖掉。
这就是数据竞争(data race)。听起来很技术,其实就是:多个人在同一个本子上写字,结果互相把对方写的擦掉了。
在 Java 内存模型(JMM)里,这类问题通常拆成三个更精确的词——别被吓到,下面有人话翻译:
| 术语 | 人话 | 典型场景 |
|---|---|---|
| 原子性 | 要么整件事一次做完,要么像没做过 | count++ 被拆成读-改-写三步 |
| 可见性 | A 线程改了,B 线程能不能立刻看见 | 没 volatile / 没同步时,可能一直读旧值 |
| 有序性 | 代码书写顺序 vs 实际执行顺序 | 指令重排导致「以为先发生后发生」的错觉 |
大家爱发明很多高大上的词——线程安全、死锁、饥饿——翻译过来往往是:
- 线程安全:别同时来捣乱(或捣乱时有规则)
- 原子性:要么都成功,要么都失败
- 死锁:大家都在等别人干完,结果谁也没干
- 饥饿:有些人永远没机会排到队
三、我的看法:三个生活原则
与其把「线程安全」讲得很吓人,不如把它想象成:
- 分配秩序:谁先谁后(调度、锁、队列)
- 避免踩踏:不要互相覆盖(互斥、事务、版本号)
- 及时放手:用完资源就让别人用(解锁、
unlock、缩小临界区)
并发并不是神秘的「技术黑魔法」,而是人类社会早就解决过的问题——排队、取号、签字确认——只是搬到了代码里。
四、实验:两个线程各加一万次,结果为什么不是 20000?
下面这段代码是 JVM 并发课的经典开场:没有锁的计数器。
public class CounterWithoutLock {
private int count = 0;
public void increment() {
count++; // 看起来一行,其实不是原子操作
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
CounterWithoutLock counter = new CounterWithoutLock();
Runnable task = () -> {
for (int i = 0; i < 10_000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终计数值: " + counter.getCount());
// 期望 20000,实际往往小于 20000,且每次运行可能不同
}
}4.1 count++ 在字节码里不是「一步」
count++ 在 JVM 上大致等价于:
- 把
count从主内存读到线程工作内存(GETFIELD/ILOAD) - 加 1(
IINC) - 写回字段(
PUTFIELD)
两个线程交错执行时,可能出现:
时刻 Thread-1 Thread-2 count ───────────────────────────────────────────────────────── 1 读取 count = 100 2 读取 count = 100 3 写入 count = 101 4 写入 count = 101 ← 两次 ++ 只涨 1
这就是丢失更新:不是「线程坏了」,而是没有互斥 + 没有原子写。
4.2 运行结果说明什么
- 结果经常小于 20000,且非确定性——每次运行可能不同;
- 说明问题出在共享可变状态上的并发写,而不是
println或main写错了; - 单线程跑 20000 次一定对——进一步证明是并发交错导致的。
五、解法地图:从加锁到 CAS
解决「多人改同一本子」的手段,在 Java 里有一条清晰的路径:
发现问题(count 不对) → 互斥锁(synchronized / ReentrantLock) → 更小粒度锁(分段锁、读写锁) → 无锁原子类(AtomicInteger + CAS) → 不变式 + 并发容器(ConcurrentHashMap 等)
5.1 synchronized:JVM 自带的「占本子」
语法最简单,适合临界区短、逻辑清晰的场景:
public class CounterWithSynchronized {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}synchronized 保证:同一时刻只有一个线程进入被同一个监视器锁保护的区域,同时建立 happens-before——解锁前的写,对随后加锁的线程可见。
5.2 ReentrantLock:显式占坑,记得放手
ReentrantLock,这也是日常很常见的一种写法:
import java.util.concurrent.locks.ReentrantLock;
public class CounterWithLock {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock(); // 一定要释放锁,放 finally 里
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
CounterWithLock counter = new CounterWithLock();
Runnable task = () -> {
for (int i = 0; i < 10_000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终计数值: " + counter.getCount()); // 稳定输出 20000
}
}与 synchronized 相比,ReentrantLock 更灵活:tryLock、可中断等待、公平/非公平策略。代价是必须手动 unlock,否则就是「占着本子不撒手」——别的线程永远排不上。
更细的对比(Monitor、AQS、公平锁)见 Java Lock。
5.3 AtomicInteger:用 CAS 做「乐观核对」
如果只是单个计数器,不一定要上重锁:
import java.util.concurrent.atomic.AtomicInteger;
public class CounterWithAtomic {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // CAS 循环,直到改成功
}
public int getCount() {
return count.get();
}
}思路是乐观锁:读当前值 → 算新值 → CAS 比较并交换;若期间被别人改过,就重试。适合竞争不激烈的计数、状态位;竞争激烈时 CAS 自旋也会烧 CPU——没有银弹。
5.4 方案怎么选(务实版)
| 场景 | 倾向方案 | 原因 |
|---|---|---|
| 短临界区、简单对象 | synchronized | 写法少、JVM 会优化 |
| 需要超时/可中断/公平 | ReentrantLock | API 更全 |
| 单个整型/引用计数 | Atomic* | 无显式锁,代码干净 |
| 整表 Map 并发读写 | ConcurrentHashMap | 见 分段锁 |
| 复合逻辑「读-判断-写」 | 锁或 synchronized 包住整段 | 单独 CAS 字段不够,要保护不变式 |
六、可见性:volatile 管看得见,不管原子
很多人把 volatile 当成「轻量锁」,这是误解。
public class StopFlag {
private volatile boolean stopped = false;
public void stop() {
stopped = true;
}
public void work() {
while (!stopped) {
// 若没有 volatile,子线程可能永远看不到 stopped 变成 true
}
}
}volatile 保证:对这个变量的写,立刻对其他线程可见,并限制与之相关的重排。但它不保证 count++ 这种复合操作的原子性。
记一句就够:volatile 解决「看不看见」;锁和原子类解决「会不会被打断」。
七、线程从哪来:Thread 与 ExecutorService
上文一直用 new Thread(...) 是为了把并发交错看清楚。真实项目里更常见的是线程池:
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int i = 0; i < 100; i++) {
pool.submit(() -> {
// 任务逻辑;若改共享状态,仍要锁/原子类/并发容器
});
}
pool.shutdown();线程池解决的是**「鹏不够,但活很多」时的调度与复用**——不会为每个任务新建一条 OS 线程。但它不自动解决数据竞争:把任务丢进池子,若多个任务改同一个 CounterWithoutLock,结果依然会乱。
线程池管「谁去干活」;锁和原子类管「干活时别踩脚」。两件事正交,别混为一谈。
Kotlin 协程在 JVM 上则是再往上一层:用用户态任务复用少量线程,适合大量等待型 IO。那是另一条故事,见 协程与 Executors 的资源对比。
八、死锁与饥饿:秩序走极端时
8.1 死锁
// 反例:两把锁交叉持有
synchronized (lockA) {
synchronized (lockB) { /* ... */ }
}
// 另一个线程反过来先拿 lockB 再拿 lockA → 可能永远互相等人话:大家都在等别人先放手,结果谁都动不了。 预防思路包括:锁顺序一致、tryLock 超时、缩小持锁范围。
8.2 饥饿
非公平锁下,某些线程可能长期抢不到锁——像排队永远插不进去。公平锁可以缓解,但吞吐通常更差。又是权衡,没有免费午餐。
九、Android / 业务开发里的落点
写 App 时,并发往往长这样:
- 主线程:UI 渲染、点击响应(一条「鹏」专门面对用户)
- 后台线程 / IO 池:网络、数据库、文件
- 共享状态:
ViewModel里的StateFlow、Repository缓存、单例工具类
几条实用原则:
- 默认别共享可变状态——能 immutable、能拷贝、能局部变量,就别挂在多处引用的对象上。
- 必须共享时,选对的工具——
ConcurrentHashMap、Atomic*、synchronized块,而不是「应该没事吧」。 - IO 别堵主线程——ANR 是产品级故障,不是「线程安全」术语能描述的。
- 新 Kotlin 项目——
viewModelScope+ 协程管生命周期与取消,但协程不替代对共享数据的保护;Mutex仍然有用。
十、总结
| 层次 | 问题 | 手段 |
|---|---|---|
| 概念 | 多人同时用一份资源 | 秩序、互斥、可见性 |
| 代码 | count++ 非原子 | synchronized / Lock / AtomicInteger |
| 工程 | 线程创建太贵、任务太多 | ExecutorService、协程 |
| 深入 | Monitor、AQS、分段锁 | Java Lock、分段锁 |
我不喜欢「线程安全」四个字带来的压迫感。你真正需要记住的,还是开头那三句:分配秩序、避免踩踏、及时放手。CounterWithoutLock 跑一遍,看数字不到 20000;加上锁或原子类再跑一遍,看数字稳定——比背十个定义更有用。
并发题在面试里会继续延伸到 HashMap 为什么线程不安全、ConcurrentHashMap 怎么分段、synchronized 和 ReentrantLock 底层差别。这篇当作地图上的第一格就好:先把「资源争抢」想明白,再往锁和容器深处走,心里不会虚。
Original DIPENGXU · 2025年06月28日 · 广东珠海