Skip to content

想象一个场景:活动页上线,两个入口同时在扣同一份库存,各扣 10 万次,运营对账时发现账对不上——凭空少了一截。日志里没有任何报错,把请求单独重放一遍,每一笔又都是对的。这种“单看每一步都对,合在一起就是错”的问题,十有八九出在并发。

volatilesynchronizedAtomicInteger、线程池——这些东西平时都在用,但“各自到底解决什么问题、为什么要配合着用”,值得系统梳理一遍。这篇就按这条线走:先看多线程为什么会出事,再按“一个问题一把钥匙”讲每件工具,最后讲线程池怎么配。文中的示例代码都实际跑过(JDK 21),运行结果是真实输出,不是编的。

一、解决什么问题:多线程下的共享数据安全

先看最经典的翻车现场。两个人同时往一个账本上记账,每人记 10 万笔,最后账上应该有 20 万:

java
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();

实际跑出来的结果:

text
期望:200000
实际:121510        ← 少了快 8 万笔

为什么少了?因为 count++ 看着是一行代码,底层其实是三步:

text
读 count(比如读到 5)  →  加 1(得 6)  →  写回 count(写 6)

单线程没问题,两个线程同时跑就可能交错执行——B 在 A 写回之前也读了,读到的还是旧值:

count++ 被两个线程交错执行,一次加法凭空消失

这就是“线程不安全”——两个线程的操作互相踩脚,一部分加法凭空消失了

二、先补课:线程的生命周期

讲三大问题之前,先把线程本身的状态搞清楚,后面“线程卡在哪”都靠这套语言描述。

Java 线程有 6 个状态(Thread.State 枚举),一张状态流转图先立住:

Java 线程 6 种状态流转:NEW → RUNNABLE → 三个等待状态 → TERMINATED

用外卖订单类比一遍就好记了:

状态类比什么时候进入
NEW订单刚下单,商家还没接new Thread() 之后、start() 之前
RUNNABLE商家接单开做了(或在排队等着被系统调度)调了 start()
BLOCKED到店取餐,窗口被别人占着干等想进 synchronized 但锁被别人拿着
TIMED_WAITING定了个闹钟歇 30 秒sleep(n)wait(n)join(n)
WAITING没有闹钟,干等到有人喊wait()join()(不带时间)
TERMINATED订单送达run() 执行完毕

光背状态没用,跑一遍最直观——用一个线程依次经历三种状态,在旁边偷看它的状态值:

java
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:跑完了
    }
}

实测输出:

text
NEW
TIMED_WAITING
BLOCKED
TERMINATED

注意一个容易懵的点:BLOCKED 和 WAITING 都是“等”,区别是等什么。BLOCKED 是等锁(排队进门),WAITING 是等通知(等别人喊它)。RUNNABLE 并不代表“正在 CPU 上跑”,它只表示“有资格跑”——2 核 CPU 上跑 10 个 RUNNABLE 线程,同时真正在跑的最多 2 个,其余在操作系统层面排队。

三、三大问题的根源:Java 内存模型(JMM)

回到第一节的翻车现场。count++ 踩脚只是三大问题中的一个(原子性),要理解另外两个,得先看 Java 是怎么安排内存的。

Java 内存模型(JMM)规定:所有共享变量存在主内存里;每个线程干活时有自己的工作内存(可以理解为 CPU 缓存和寄存器的抽象),要用变量就先拷贝一份到自己的工作内存,改完再写回主内存:

JMM:主内存 + 每个线程自己的工作内存拷贝

问题就出在这:每个线程操作的都是自己手里的拷贝,什么时候写回主内存、别的线程什么时候重新去读,JVM 说了不算,编译器和 CPU 也会“自作主张”地优化。于是三大问题全来了:

问题是什么在 JMM 里怎么发生的例子
原子性一段操作不可分割,中间不能被打断三步操作被另一个线程插队交错count++ 读改写被打断,结果丢失
可见性一个线程改了变量,另一个线程看不到改的是自己工作内存的拷贝,没及时写回;或对方一直读旧的拷贝线程 A 改 flag=true,线程 B 死循环读不到
有序性代码执行顺序变了编译器/CPU 为了跑得快,重排没有依赖的指令单例双重检查的 instance = new X() 重排

类比:主内存是仓库总账本,每个线程的工作内存是自己手里的草稿纸。你在草稿纸上记的数,别人看不到;你不把新数抄回总账本,别人查的永远是旧数。Java 后面所有的并发工具,本质都是在约束“什么时候必须抄回去、什么时候必须重新读”。

三大问题分别由谁解决,一张表先立住,后面逐个展开:

工具可见性原子性有序性
volatile
synchronized
CAS / 原子类✅(单变量)——(不涉及)

四、volatile:解决可见性 + 有序性(不解决原子性)

4.1 可见性:让“草稿纸”作废

volatile 保证:一个线程改了变量,其他线程立刻能看到

先看不加 volatile 会怎样。下面的代码,main 线程负责改标志位,另一个线程死循环等标志位:

java
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 之后循环最多再转一圈就该退出了。实测结果:

text
读线程还活着吗:true        ← main 改完 1.2 秒了,读线程还在死循环

原因是 JIT 编译器看到循环体里没人改 flag,做了一个很“聪明”的优化:把读 flag 的操作提到循环外面,只读一次,相当于:

java
if (!flag) {          // 只读这一次
    while (true) { i++; }   // 从此再也不看 flag
}

这就是可见性问题的极端形态——不是“看到得慢”,是永远看不到了。给 flag 加上 volatile 再跑:

text
读线程还活着吗:false       ← 立刻退出

volatile 做了两件事:① 禁止这种“读提出循环”的优化,每次都从主内存重新读;② 写操作立刻刷回主内存,并把其他线程工作内存里对应的拷贝标记为过期。类比:volatile 变量就是写进群公告 @ 了所有人——不允许任何人拿自己的小本本记录它的旧值。

volatile 前后对比:不刷主内存死循环 vs 强制重读立刻退出

4.2 有序性:禁止指令重排

volatile 还禁止指令重排。最经典的翻车现场是单例模式的双重检查锁(DCL):

java
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 可能把它重排成 ①③②——门牌先挂了,房子还没盖

DCL 指令重排翻车时序:线程 B 拿到半成品对象

单线程下重排无所谓(最终结果一样),但多线程下:线程 A 执行完③还没执行②,线程 B 进来一查 instance != null,直接把这个“半成品”拿去用了,字段全是默认值。

所以这里的 volatile 不是为了可见性,而是禁止 ②③ 重排,保证“先盖完房、再挂门牌”。这也是 DCL 必须加 volatile 的原因——两次 if 检查只能挡住“还没开始建”的对象,挡不住重排造出来的半成品。

4.3 最容易踩的误区:volatile 不保证原子性

很多人以为“加了 volatile 就线程安全了”,跑个实验就知道不是:

java
static class VolatileCounter {
    volatile int count = 0;      // 已经加了 volatile
    void increment() { count++; }
}

两个线程各加 10 万次,实测:

text
期望:200000
实际:119754        ← 加了 volatile 照样丢一大截

原因:volatile 只管住“每次都读最新值、写完立刻刷回去”,但 count++ 是三步——读、加、写之间没有约束,线程 B 依然可以在线程 A“读完、还没写回”的空档插进来。可见性和原子性是两码事,volatile 只管前者。原子性要靠下一节的 synchronizedAtomicInteger

还有一个小坑:volatile 修饰数组或对象引用时,只保证引用本身的可见性,数组里面元素的改动它管不着(那要用 AtomicIntegerArray 或锁)。

五、synchronized:解决原子性 + 可见性

5.1 基本用法:把三步焊成一步

synchronized 保证一段代码同一时刻只有一个线程执行——别的线程进不来,自然踩不了脚:

java
public class SyncCounter {
    private int count = 0;
    private final Object lock = new Object();

    public void increment() {
        synchronized (lock) {   // 同一时刻只允许一个线程进入
            count++;            // 三步焊成一步:原子 + 可见
        }
    }
}

同样两个线程各加 10 万次,实测:

text
期望: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 去排队:

synchronized 锁竞争过程:抢不到锁的线程 BLOCKED 挂起,等释放后被唤醒

关键点:抢不到锁的线程会被挂起(BLOCKED 状态),由操作系统调度,这是个有开销的操作(要陷入内核态)。这就是 synchronized 被叫“重量级”方案的原因——上下文切换比几条指令贵得多。

5.3 三种用法与背后的锁对象

synchronized 有三种写法,锁的对象各不相同,混用容易出事故:

写法等价于锁范围
synchronized(lock) 代码块你指定的对象最灵活,推荐
synchronized 实例方法this(实例本身)同一实例的方法之间互斥
synchronized 静态方法Xxx.class(类对象)全局唯一,所有实例互斥

容易翻车的组合:两把锁守同一个资源。两个方法都在改同一个 count,但加锁姿势不一致:

java
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 的锁是可重入的:同一个线程拿到锁后,可以再次进入同一把锁保护的代码,不会把自己卡死:

java
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 的单条指令保证原子,不需要加锁。

java
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();               // 原子地 +1,无锁
boolean ok = count.compareAndSet(expect, update);
// 内存值 == expect 时才改成 update,返回是否成功

incrementAndGet() 的内部逻辑本质是个自旋循环——失败就重来,直到成功:

CAS 自旋循环:读旧值 → 算新值 → 比较交换,失败就重来

类比:改共享文档前先瞄一眼版本号——你看到的还是 v5 才动手保存;保存时发现已经变 v6 了,说明别人改过,重新拉最新版再改一遍。不加锁、不排队,冲突了就重试。

实测验证 CAS 的判断语义:

java
AtomicInteger v = new AtomicInteger(0);
boolean ok1 = v.compareAndSet(0, 1);   // 预期 0,内存里是 0 → true,更新成功
boolean ok2 = v.compareAndSet(0, 2);   // 预期 0,内存里已是 1 → false,拒绝
text
CAS(0→1) = true,CAS(0→2) = false,最终值 = 1

6.2 AtomicInteger 实测:又快又准

两个线程各加 10 万次:

java
AtomicInteger count = new AtomicInteger(0);
// 线程 A / B:for (int i = 0; i < 100_000; i++) count.incrementAndGet();
text
期望:200000
实际:200000        ← 无锁,同样一笔不少

低竞争下它比 synchronized 快:不用阻塞线程、不用上下文切换,失败了原地重试就行。但有两个边界要知道:

  1. 只能保证单个变量的原子性countA++countB++ 两个变量的复合操作,CAS 管不了,还是要锁
  2. 高竞争下自旋空转烧 CPU:几十个线程抢一个变量,失败率飙升,重试次数暴涨,反而不如直接挂起等锁划算

6.3 ABA 问题:CAS 的盲区

CAS 只比较“值等不等”,比较不出“中间被人改过又改回来”:

ABA 问题:值 100→200→100,CAS 照样成功;解法是加版本号

大多数场景(计数、累加)ABA 无害;但依赖“中间状态没变过”的场景(比如无锁栈的节点复用)会出真 bug。解法是加版本号,每次改动版本 +1,改回来版本也对不上:

java
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);   // 值和版本一起比

实测对照:

text
裸 CAS(100→300)                    = true    (中间被改过,照样成功)
带版本戳 CAS(100→300, 预期版本 v2)  = false   (版本对不上,拒绝)

这也是数据库乐观锁(version 字段)和分布式锁续期里“校验持有者”的同一套思想。

七、线程池:复用线程,别每次 new Thread

7.1 为什么需要线程池

线程虽然轻量,但创建销毁有开销(要分配栈内存、操作系统要建内核对象),而且无限开线程会把内存和 CPU 调度压垮。线程池的思路是养一批工人,活来了排队领,干完不辞退,接着等下一个活

  • 核心线程 = 正式工(常驻,默认不裁)
  • 非核心线程 = 忙不过来时招的临时工(闲 60 秒裁掉)
  • 任务队列 = 待办事项看板
  • 拒绝策略 = 实在装不下了怎么处理
java
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 个任务(每个都很耗时),边提交边抓快照:

java
ThreadPoolExecutor pool = new ThreadPoolExecutor(
    2, 4, 60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(2),
    new ThreadPoolExecutor.AbortPolicy());

// 连续提交 7 个长任务,在第 4 个、第 6 个之后打印池状态
// 第 7 个提交时捕获 RejectedExecutionException

实测输出:

text
[提交 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 内存模型与垃圾回收》。