并发编程是后端面试的重中之重。很多同学在写业务代码时很少手写多线程,对并发底层理解浮于表面。 并发的根基就是可见性、原子性、有序性三大特性,volatile、synchronized、ReentrantLock 都是用来解决三大特性问题的工具。

提问:volatile 能不能保证原子性?synchronized 锁优化(偏向锁、轻量级锁、重量级锁)?synchronized 和 ReentrantLock 区别?可重入、公平锁、自旋锁原理。

本文从底层原理、JMM 内存模型、三大特性讲起,逐个拆解 volatile、synchronized、ReentrantLock,附带代码示例、踩坑案例、对比总结,一次性吃透这块核心知识。

前言

单核 CPU 时代,程序串行执行,不存在线程安全问题。 现代 CPU 都是多核,操作系统通过时间片轮转实现线程切换。同时 CPU 有高速缓存、指令重排优化,这就导致多线程共享变量会出现各种诡异问题:修改不可见、执行结果错乱、代码执行顺序和写的不一样。 Java 内存模型(JMM,Java Memory Model),是一套抽象规范,屏蔽不同操作系统、CPU 硬件差异,定义线程、主内存、工作内存之间的读写规则。

JMM 抽象模型:所有共享变量存储在主内存;每个线程有自己的工作内存(CPU 缓存寄存器抽象)。线程读写共享变量,不是直接操作主内存,而是先拷贝副本到工作内存,操作完成后再刷回主内存。 线程安全问题根源:多线程同时读写共享可变变量。

一、并发三大核心特性(基础中的基础)

1. 可见性

定义:一个线程修改共享变量的值,其他线程能够立刻感知到这个修改。 问题:CPU 缓存机制。线程 A 修改变量,只更新到自己 CPU 缓存,没有立刻刷入主内存;线程 B 读取变量,读到的还是旧副本。

典型场景:普通变量不具备可见性。

// 可见性问题示例
public class VisibilityDemo {
    private static boolean flag = false;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (!flag) {
                // 死循环
            }
            System.out.println("线程感知到flag变化,退出循环");
        }).start();
        Thread.sleep(1000);
        flag = true;
        System.out.println("主线程修改flag=true");
        // 很多时候,子线程永远不会退出!
    }
}

原因:JIT 优化,子线程把 flag 缓存到寄存器,不再从主内存读取。

解决方案:volatile 修饰变量,保证可见性。

2. 原子性

定义:一组操作,要么全部执行成功,要么全部不执行,执行过程不可被线程中断。

经典例子:i++,看上去一行代码,实际拆成三步:

  1. 读 1. 读取 i 的值;
  2. i+1;
  3. 写回 i。 这三步不是原子操作,多线程并发执行会丢失更新。
public class AtomicDemo {
    private static int count = 0;
    public static void add() {
        count++;
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {for(int i=0;i<10000;i++) add();});
        Thread t2 = new Thread(() -> {for(int i=0;i<10000;i++) add();});
        t1.start();t2.start();
        t1.join();t2.join();
        System.out.println(count); // 结果大概率小于20000
    }
}

普通变量、volatile无法保证原子性。原子性需要锁 / CAS / 原子类(AtomicInteger)保障。

3. 有序性

定义:代码按照程序员编写的顺序执行。 CPU 和 JVM 为了提升执行效率,会做指令重排。只要不改变单线程内程序结果,可以调整指令执行顺序。

指令重排在单线程无害;多线程共享变量场景会出现诡异 bug。 经典案例:双重检查锁 DCL 单例不加 volatile,指令重排导致半初始化对象逸出。

三大特性小结:

特性含义产生原因解决方案
可见性一个线程修改,其他线程立刻看见CPU 高速缓存volatile / synchronized / ReentrantLock
原子性操作不可拆分,不能中途被打断线程切换synchronized / ReentrantLock / CAS 原子类
有序性禁止指令乱序执行CPU、JVM 指令重排volatile / synchronized / ReentrantLock

二、volatile 关键字

作用

  1. 保证可见性:写操作后,强制刷新到主内存;读操作直接从主内存读取,清空本地缓存副本。
  2. 禁止指令重排:volatile 读写内存屏障,阻止编译器、CPU 对 volatile 变量前后指令重排序。
  3. 不保证原子性!这是最高频考点。volatile i++依旧线程不安全。

内存屏障(简要理解)

volatile 底层依靠内存屏障实现:

  • 写 volatile:在写指令前后插入写屏障,前面的指令不会重排到 volatile 写之后;写完成强制刷入主内存。
  • 读 volatile:在读指令前后插入读屏障,后面指令不会重排到 volatile 读之前,强制从主内存加载。

volatile 适用场景

场景 1:状态标记变量(可见性),例如上面 flag 停止标记。 场景 2:双重检查锁 DCL 单例模式,防止指令重排。

public class Singleton {
    // 必须加volatile
    private static volatile Singleton instance;
    private Singleton(){}
    public static Singleton getInstance(){
        if(instance == null){
            synchronized (Singleton.class){
                if(instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

new Singleton()分为三步:

  1. 分配对象内存;
  2. 初始化对象;
  3. 将 instance 引用指向内存地址。 不加 volatile:指令重排可能变成 1→3→2。线程 A 执行 1、3,还没初始化;线程 B 判断 instance 不为 null,拿到半初始化对象,引发严重 bug。volatile 禁止这三步重排。

volatile 不适用场景

  • 计数场景:count++,复合操作,无法保证原子性。
  • 多线程同时写同一个共享变量。

三、synchronized 内置锁(重量级锁,JDK1.6 做了锁升级优化)

synchronized 是 Java 内置关键字,可重入互斥锁,可以修饰方法或者代码块。

互斥:同一时间,只允许一个线程获取锁,执行同步代码。保证原子性、可见性、有序性。

使用方式

1.修饰实例方法:锁对象是当前 this 对象

public synchronized void test(){}

2.修饰静态方法:锁对象是类的 Class 对象

public static synchronized void test(){}

3.修饰代码块:手动指定锁对象(推荐,锁粒度更小)

synchronized (obj) {
    // 同步代码
}

JDK1.6 锁升级(核心)

synchronized 不再是单纯重量级锁,会自动升级,不可逆:偏向锁 → 轻量级锁 → 重量级锁。

  1. 偏向锁 大多数场景:锁长时间只被同一个线程持有。偏向锁:消除无竞争场景下 CAS 操作。 线程获取锁,在对象头 Mark Word 记录线程 ID。后续该线程再次拿锁,无需 CAS,直接拿到。 当出现其他线程竞争,偏向锁撤销,升级轻量级锁。
  2. 轻量级锁 竞争不激烈:线程自旋(循环尝试获取锁),不阻塞。依靠 CAS 自旋抢锁。 优点:线程不阻塞,不需要内核态切换; 缺点:自旋消耗 CPU。自旋多次拿不到锁,升级重量级锁。
  3. 重量级锁 底层依赖操作系统互斥锁(Mutex)。抢锁失败的线程进入阻塞队列,操作系统挂起线程。 线程挂起 / 唤醒需要用户态↔内核态切换,开销大。

锁升级单向:只能升级,不能降级。

synchronized 特性

  •  可重入:同一个线程,已经持有锁,可以再次获取同一把锁(避免死锁)
  • 自动释放锁:代码正常退出 / 抛出异常,JVM 自动释放锁,不会死锁
  • 非公平锁:抢锁不按先来后到,后到线程可能抢先拿到锁
  • 同时保证三大特性:原子性、可见性、有序性

四、ReentrantLock 显式锁(java.util.concurrent.locks)

ReentrantLock 是API 层面的锁,不是 JVM 内置。实现 Lock 接口,底层基于 AQS(AbstractQueuedSynchronizer)。

AQS:底层维护一个 state 状态变量 + 等待队列,state=0 无锁,state>0 代表锁被持有。

基础使用模板

Lock lock = new ReentrantLock();
lock.lock();
try{
    // 同步业务代码
}finally {
    // 必须finally手动释放锁!!
    lock.unlock();
}

重点:必须手动unlock()释放锁,如果代码异常忘记释放锁,会永久死锁!

ReentrantLock 核心特性

  1. 可重入:和 synchronized 一样,同一个线程可多次获取锁,state 累加。
  2. 支持公平锁 / 非公平锁(synchronized 只能非公平)
// true:公平锁,线程按等待顺序获取
ReentrantLock fairLock = new ReentrantLock(true);
// false:非公平锁(默认),性能更高
ReentrantLock unfairLock = new ReentrantLock(false);

公平锁:线程排队,严格 FIFO;缺点:大量线程切换,性能低。 非公平锁:上来先 CAS 抢锁,不排队,吞吐量更高,默认推荐。

  1. 可中断:lockInterruptibly(),等待锁的时候可以响应中断。synchronized 等待锁时无法被中断。
  2. 尝试获取锁,支持超时:tryLock()、tryLock(time,unit),拿不到锁立刻返回 false,不会死等。synchronized 拿不到锁会一直阻塞。
  3. 条件变量 Condition:一把锁可以绑定多个 Condition,精准唤醒线程,替代 wait/notify。synchronized 只有一个等待队列,notify 随机唤醒。

五、synchronized VS ReentrantLock 对比

对比维度synchronizedReentrantLock
实现层面JVM 内置关键字,底层 C++ 实现API 层面锁,基于 AQS,Java 代码实现
锁释放自动释放,异常自动解锁手动 unlock (),忘记释放会死锁
可重入支持支持
锁类型只能非公平锁支持公平 / 非公平锁可选
中断等待等待锁时不可中断lockInterruptibly () 可中断
超时抢锁不支持tryLock (time) 支持超时尝试获取
条件变量wait/notify,单个等待队列多个 Condition,精准唤醒
锁升级偏向→轻量级→重量级,自动优化无锁升级机制,CAS + 队列
性能JDK1.6 优化后,轻量场景性能接近高并发竞争场景,功能更灵活

选型建议:

  1. 简单同步场景,优先 synchronized,代码简洁,自动释放锁,不易出错;
  2. 需要公平锁、超时锁、可中断、多条件唤醒 → 选择 ReentrantLock。

六、wait () /notify () 和 Condition 补充

wait、notify/notifyAll 必须在 synchronized 同步代码块内调用,属于 Object 方法。

  • wait:释放锁,线程进入等待队列;
  • notify:随机唤醒一个等待线程;notifyAll 唤醒全部。 缺点:无法区分等待条件,唤醒是随机的。 Condition 是 ReentrantLock 配套:await()、signal()、signalAll()。 一把锁可以创建多个 Condition,不同条件的线程放在不同等待队列,可以精准唤醒,更加灵活。

七、开发实战踩坑清单

  1. 误以为 volatile 保证原子性,拿 volatile 做计数。count++依旧并发不安全。
  2. DCL 单例忘记加 volatile,指令重排导致半初始化对象泄露。
  3. ReentrantLock unlock 写在 try 代码块,异常情况下无法释放锁,死锁。 规范:lock 写在 try 外面,unlock 放在 finally。
  4. synchronized 锁字符串常量、包装类。常量池复用对象,不同地方共用同一把锁,意外锁竞争。推荐 new Object () 作为独立锁对象。
  5. 锁对象发生引用替换。synchronized 锁对象变量指向新对象,锁失效。
Object lock = new Object();
synchronized (lock){
    lock = new Object(); // 锁对象被替换!
}

6.wait () 不在同步代码块调用,直接抛出 IllegalMonitorStateException。

7.原子类 AtomicInteger 底层 CAS,只能保证单个变量原子性,复合操作依然不安全。

八、三大锁的能力汇总表

原子性可见性有序性可重入
volatile❌✅✅无(不是锁)
synchronized✅✅✅✅
ReentrantLock✅✅✅✅

九、问答

Q1:JMM 是什么?三大特性是什么?

A:JMM 是 Java 内存模型抽象规范,定义多线程共享变量读写规则。三大特性:可见性、原子性、有序性。

Q2:volatile 原理,能保证原子性吗?使用场景?

A:volatile 依靠内存屏障保证可见性、禁止指令重排;不能保证原子性。适合状态标记、DCL 单例。

Q3:synchronized 锁升级过程?

A:偏向锁 → 轻量级锁 → 重量级锁,升级不可逆。偏向锁适合单线程无竞争;轻量级锁 CAS 自旋;重量级锁操作系统互斥锁,线程阻塞。

Q4:synchronized 和 ReentrantLock 区别?

A:synchronized 是 JVM 内置锁,自动释放锁,只能非公平;ReentrantLock 是 AQS 实现显式锁,手动 unlock,支持公平锁、超时锁、可中断、多 Condition。简单场景优先 synchronized。

Q5:为什么 ReentrantLock 一定要在 finally 释放锁?

A:如果业务代码抛出异常,不执行 unlock,锁永远不会释放,其他线程永久阻塞,死锁。

Q6:什么是可重入锁?作用?

A:同一个线程拿到锁之后,可以再次获取同一把锁。防止递归调用同步代码时,自己把自己死锁。synchronized、ReentrantLock 都是可重入锁。

Q7:轻量级锁自旋的缺点?

A:自旋会持续占用 CPU。大量线程长时间自旋,CPU 飙升。竞争激烈升级重量级锁。

结语

并发三大特性是整个 Java 并发体系的地基。volatile 只解决可见性、有序性,无法原子性;synchronized 和 ReentrantLock 可以同时保障三大特性,但各有取舍。 并发编程最大难点不是 API 调用,而是识别共享可变变量、判断竞态条件。

写并发代码优先考虑:尽量避免共享可变状态,无锁方案优先;必须共享时,选合适锁机制,规避复合操作、锁对象变更、忘记释放锁等坑。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐