一、synchronized:JVM内置锁的底层原理

1.1 同步代码块与monitor机制

synchronized可以修饰方法或代码块,其底层依赖JVM的monitor(监视器)实现。当我们对一个代码块加锁时:

synchronized (lock) {
    // 临界区代码
}

编译后会在字节码中插入monitorenter和monitorexit指令——前者在进入同步块时执行,后者在正常或异常退出时执行。即使临界区抛出异常,monitorexit也会被触发,从而释放锁。这是一个重要的面试点:抛异常不会导致锁一直占用,避免死锁。

底层实现细节

synchronized在字节码层面通过两个指令实现:

  • monitorenter:进入同步块,尝试获取对象监视器(monitor)的所有权
  • monitorexit:退出同步块,释放对象监视器的所有权

编译器会自动在同步代码块前后插入这两个指令。为了保证异常时也能释放锁,编译器会生成一个异常表,在异常发生时自动执行monitorexit。

对象监视器(Monitor)原理

Monitor是操作系统提供的同步原语,在HotSpot中由ObjectMonitor实现,其核心结构如下:

ObjectMonitor {
    _owner      // 持有当前锁的线程指针
    _count      // 锁计数器(支持可重入性)
    _waiters    // 等待队列(调用wait()的线程)
    _entryList  // 入口队列(竞争锁失败的线程)
    _recursions // 重入次数
}

执行流程:

  1. 线程执行monitorenter时,检查_count是否为0
  2. 若为0,将_owner设为当前线程,_count加1
  3. 若不为0且_owner是当前线程,_count加1(可重入)
  4. 若不为0且_owner不是当前线程,线程进入_entryList阻塞
  5. 执行monitorexit时,_count减1,减到0时释放锁并唤醒_entryList中的线程

synchronized的三大核心作用

  • 原子性:保证临界区代码的原子执行,防止多线程交错执行导致的数据不一致
  • 可见性:根据JMM规范,锁释放时会将工作内存数据刷新到主内存,锁获取时会清空工作内存并从主内存重新加载
  • 有序性:禁止临界区内部代码与外部代码的指令重排序

三种使用方式及锁对象

使用方式 锁对象 作用范围
修饰实例方法 this(当前实例对象) 同一实例的所有同步方法互斥
修饰静态方法 类的Class对象 该类所有实例的静态同步方法互斥
修饰代码块 括号内指定的对象 同一锁对象的所有同步代码块互斥

1.2 锁存放在哪里?——对象头

Java中每个对象都有一个对象头(Header),其中Mark Word区域记录了锁的相关信息。在32位HotSpot虚拟机中,对象在内存中分为三部分:对象头、实例数据、对齐填充。其中对象头是synchronized锁机制的核心载体。

对象头结构(32位)

长度 内容 说明
32bit Mark Word 存储对象的哈希码、GC分代年龄、锁状态标志等
32bit Klass Pointer 指向对象所属类的元数据的指针
可选 数组长度 只有数组对象才有,占32bit

Mark Word在不同锁状态下的位图结构(64位)

锁状态 存储内容 锁标志位
无锁 unused:25|age:4|biased_lock:0 01
偏向锁 threadId:54|epoch:2|age:4|biased_lock:1 01
轻量级锁 ptr_to_lock_record:62 00
重量级锁 ptr_to_monitor:62 10

Mark Word是一个动态的数据结构,会根据对象的锁状态复用存储空间。当Mark Word的最后两位的锁标志位是10时,表示处于重量级锁的模式,此时Mark Word记录的是monitor的地址。

小知识:两个不同对象的hashcode有可能相同,但对象头中除了hashcode,还存储了分代年龄、锁标记等。

1.3 锁的升级(膨胀)过程 —— 必考!

synchronized在JDK 1.6之后做了重大优化,引入了锁升级机制,且升级不可逆(偏向锁→轻量级锁→重量级锁)。

完整锁状态转换图

无锁 → 偏向锁 → 轻量级锁 → 重量级锁(不可降级)

各锁状态详解

锁状态 适用场景 原理简述 优缺点
偏向锁 只有一个线程反复获取锁 在Mark Word中记录线程ID,该线程再次进入时无需任何同步操作 优点:开销几乎为0。缺点:一旦有第二个线程竞争,立即膨胀,且需要执行偏向锁撤销(STW)
轻量级锁 少量线程交替持有锁 通过CAS尝试将对象头的Mark Word复制到线程栈的Lock Record中,成功则获取锁;失败则自旋等待 优点:线程不用阻塞,响应快。缺点:自旋会消耗CPU,不适合大量线程竞争
重量级锁 多线程激烈竞争 竞争失败的线程进入阻塞队列,由操作系统内核完成线程调度 优点:不浪费CPU。缺点:线程挂起和唤醒的切换成本高(用户态↔内核态)

重量级锁的注意点:重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

🔥 重要更新:偏向锁在现代JDK中的变化

JDK 15+默认禁用偏向锁,JDK 17+彻底移除偏向锁逻辑。现代JVM(JDK 17~21)重点优化的是轻量级锁的自适应自旋。

为什么?偏向锁撤销的成本远高于其带来的收益:

  • 偏向锁撤销需要在全局安全点(Safepoint)执行,会导致Stop-The-World(STW)停顿
  • 一次撤销需要停顿所有线程,遍历所有线程栈帧查找锁记录,时间复杂度为O(N×M)
  • 在高并发场景下,频繁的锁撤销可能导致毫秒级延迟,对延迟敏感服务不可接受

如果使用JDK 8-14且遇到因偏向锁撤销导致的性能问题,可以通过启动参数手动禁用:

-XX:-UseBiasedLocking

为什么锁升级不可逆? 因为一旦出现过竞争,JVM认为后续竞争的概率依然存在,反向降级会引入复杂度且收益很小。

1.4 锁的优点与缺点

优点

  • 使用简单,无需手动释放(JVM自动完成)
  • JVM层面做了大量优化(锁消除、锁粗化、自适应自旋)
  • 与JVM调度完美整合,不会出现像ReentrantLock那样的忘记unlock

缺点

  • 重量级锁下性能差于显式Lock
  • 锁升级过程不可控,不能像ReentrantLock那样尝试加锁(tryLock)
  • 偏向锁在某些场景下撤销会引发STW(JDK 15+已解决)

二、CAS:无锁并发的基石

2.1 什么是CAS?

CAS(Compare And Swap)是一种原子操作,需要调用操作系统内核函数才能实现真正的原子性。它包含三个参数:内存地址V,期望值A,新值B。只有当V的值等于A时,才将V更新为B,整个过程不可被中断(CPU指令级别保证)。

// 伪代码示意
boolean compareAndSwap(V, A, B) {
    if (V == A) {
        V = B;
        return true;
    }
    return false;
}

CAS是一种"乐观锁"的实现:区别于synchronized等悲观锁的"先加锁再操作"思路,CAS采用"先验证再更新"的无锁方式,在低并发场景下大幅降低线程调度与上下文切换的开销。

CAS操作的内存语义:CAS操作同时具备volatile的读写内存语义,保证变量的可见性与禁止指令重排序。

CPU层面的原子性实现

CAS的原子性本质是CPU硬件层面的指令支持。以主流X86_64架构为例,CAS的核心是CMPXCHG指令(比较并交换指令),但该指令本身不具备多核原子性,必须搭配LOCK前缀才能实现多核环境下的原子操作。

LOCK前缀的核心作用:

  • 锁定操作对应的内存地址的缓存行(基于MESI协议),避免多核CPU同时修改该内存地址
  • 禁止该指令与前后的读写指令重排序
  • 刷新写缓冲区,保证操作结果对所有CPU核心立即可见

极端情况下(操作数据跨缓存行时),LOCK前缀会降级为总线锁,保证操作的原子性,但性能开销会显著提升。

2.2 代码演示:CAS实现线程安全计数器

Java代码示例

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public class Counter {
    private AtomicInteger atomicI = new AtomicInteger(0);
    private int i = 0;

    public static void main(String[] args) {
        final Counter cas = new Counter();
        List<Thread> ts = new ArrayList<>(600);
        long start = System.currentTimeMillis();

        // 启动100个线程,每个对普通int和AtomicInteger各累加10000次
        for (int j = 0; j < 100; j++) {
            Thread t = new Thread(() -> {
                for (int k = 0; k < 10000; k++) {
                    cas.count();
                    cas.safeCount();
                }
            });
            ts.add(t);
        }

        for (Thread t : ts) t.start();
        for (Thread t : ts) {
            try { t.join(); } catch (InterruptedException e) { e.printStackTrace(); }
        }

        System.out.println("非线程安全结果: " + cas.i);          // 大概率小于1000000
        System.out.println("CAS安全结果: " + cas.atomicI.get()); // 总是1000000
        System.out.println("耗时(ms): " + (System.currentTimeMillis() - start));
    }

    /** 使用CAS实现线程安全计数器 */
    private void safeCount() {
        for (;;) {
            int cur = atomicI.get();
            boolean suc = atomicI.compareAndSet(cur, cur + 1);
            if (suc) break;
        }
    }

    /** 非线程安全计数器 */
    private void count() { i++; }
}

讲解

普通i++不是原子操作(读-改-写三步),多线程下结果丢失。而CAS通过compareAndSet在循环中不断尝试,直到成功。这种方式避免了互斥锁的阻塞唤醒,但激烈竞争时CPU空转严重——此时反而不如重量级锁。

Unsafe类中CAS方法的底层实现

Unsafe提供了三个核心的CAS方法,均为native方法,直接调用底层CPU指令:

public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
public final native boolean compareAndSwapLong(Object o, long offset, long expected, long x);

参数说明

  • o:要操作的对象
  • offset:要操作的字段在对象中的内存偏移量
  • expected:预期值
  • x:新值

获取Unsafe实例

import sun.misc.Unsafe;
import java.lang.reflect.Field;

public class UnsafeUtil {
    private static Unsafe unsafe;
    static {
        try {
            Field theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafeField.setAccessible(true);
            unsafe = (Unsafe) theUnsafeField.get(null);
        } catch (Exception e) {
            throw new RuntimeException("Failed to get Unsafe instance", e);
        }
    }
    public static Unsafe getUnsafe() { return unsafe; }
}

2.3 CAS的三大原子问题及解决方案

(1)ABA问题

描述:线程1将值从A改为B再改回A;线程2看到值还是A,CAS成功,但中间状态已被改变过。

影响:某些场景(如栈顶指针、链表头)可能造成错误。例如,在线程安全的栈结构中,如果栈顶指针经历了多次压栈和弹栈操作后回到原来的值,CAS可能会错误地认为栈状态没有变化。

解决方案:使用版本号/标记位。AtomicStampedReference同时保存引用和版本戳,每次更新同时递增版本号。

完整示例
import java.util.concurrent.atomic.AtomicStampedReference;

public class ABADemo {
    private static AtomicStampedReference<Integer> atomicStampedRef = 
        new AtomicStampedReference<>(100, 0);

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            int[] stampHolder = new int[1];
            Integer oldValue = atomicStampedRef.get(stampHolder);
            int oldStamp = stampHolder[0];
            try { 
                Thread.sleep(1000); // 模拟线程A在读取后等待一段时间
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 版本号必须匹配才能更新,由于线程B已经改变了版本号,这里会返回false
            boolean updated = atomicStampedRef.compareAndSet(oldValue, 200, oldStamp, oldStamp + 1);
            System.out.println("Thread A: Updated: " + updated);
        });

        Thread threadB = new Thread(() -> {
            int[] stampHolder = new int[1];
            Integer oldValue = atomicStampedRef.get(stampHolder);
            int oldStamp = stampHolder[0];
            // 将值从100改为150,版本号+1
            atomicStampedRef.compareAndSet(oldValue, 150, oldStamp, oldStamp + 1);
            // 将值从150改回100,版本号再+1(此时版本号已经是+2了)
            atomicStampedRef.compareAndSet(150, oldValue, oldStamp + 1, oldStamp + 2);
        });

        threadA.start();
        threadB.start();
    }
}

在这个示例中,线程A和线程B同时操作atomicStampedRef。线程B先将值从100改为150,然后再改回100。由于使用了AtomicStampedReference,线程A在比较并设置时会检查引用和标记是否都匹配,因此能够检测到这个变化并避免ABA问题。

AtomicStampedReference.compareAndSet有四个参数:预期引用、更新后的引用、预期标志、更新后的标志。正是通过引入整数标记(stamp)来解决ABA问题。

(2)循环时间长开销大

自旋CAS如果长时间不成功,会大量消耗CPU。

解决方案:

  • 设置自旋次数上限
  • 退化为互斥锁(JVM自适应自旋)
  • 使用JVM的自适应自旋优化——JVM会根据历史竞争情况动态调整自旋次数
(3)只能保证一个共享变量的原子操作

解决方案:

  • 将多个变量封装成一个对象,使用AtomicReference对该对象进行CAS
  • 使用锁(synchronized或ReentrantLock)

2.4 总线锁与缓存锁

处理器实现原子操作依赖两种锁机制:

  • 总线锁:使用LOCK#信号,锁定整个总线,其他处理器无法访问内存。代价高。
  • 缓存锁:如果内存地址被缓存在CPU的缓存行中,且该缓存行处于独占状态(MESI协议的M状态),CPU可以直接操作缓存行,无需锁住总线。缓存锁的开销远小于总线锁,是现代CPU的主流实现方式。

以下两种情形不会被缓存锁定:

  • 操作的数据跨越多个缓存行(无法被单个缓存行覆盖)
  • 处理器不支持缓存锁定(老式CPU自动降级为总线锁)

三、内存可见性与指令重排序

3.1 为什么会看到"过期的"数据?

每个CPU核心都有自己的高速缓存(L1/L2/L3缓存),主存(RAM)与CPU之间通过缓存一致性协议(如MESI)进行同步。一个线程对变量的修改可能暂时停留在缓存中,没有刷新到主内存,导致另一个线程看不到修改。这就是内存可见性问题。

现代多核处理器系统中,每个CPU核心都有自己的高速缓存,在没有内存模型约束的情况下,编译器可能会对指令进行重排序以优化性能,CPU也可能会乱序执行。这导致在多线程环境下,一个线程对变量的修改可能对其他线程不可见,或者指令执行顺序出乎意料。

代码示例:可见性问题

public class VisibilityDemo {
    private static boolean flag = true;
    
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (flag) { }
            System.out.println("Thread exit");
        }).start();
        
        Thread.sleep(1000);
        flag = false; // 主线程修改flag
    }
}

由于没有同步机制,JVM可能将while(flag)优化为if(!flag) while(true),导致子线程永远看不到flag的更新。

3.2 指令重排序

现代CPU采用流水线技术提高指令吞吐量,但会带来重排序问题。从源码到最终执行,可能经历:

源代码 → 编译器重排序 → 指令级并行重排序 → 内存系统重排序 → 最终指令序列

final关键字可以阻止部分重排序(构造函数返回前保证初始化完成),但范围有限。

3.3 happens-before规则

Java内存模型的核心是happens-before关系。如果两个操作之间存在happens-before关系,那么第一个操作的结果对第二个操作可见,且第一个操作的执行顺序排在第二个操作之前。

八大happens-before规则:

规则 说明
程序顺序规则 同一个线程中,书写在前面的操作happens-before后面的操作
监视器锁规则 对一个锁的解锁happens-before随后对同一个锁的加锁
volatile变量规则 对一个volatile变量的写happens-before随后对该变量的读
传递性 如果A happens-before B,且B happens-before C,则A happens-before C
线程启动规则 线程的start()方法happens-before该线程内的任何动作
线程终止规则 线程内的任何动作happens-before其他线程检测到该线程终止
中断规则 对线程的interrupt()调用happens-before被中断线程检测到中断
finalizer规则 对象的构造函数执行结束happens-before其finalize()方法开始

3.4 内存屏障

JMM通过插入内存屏障(LoadLoad、StoreStore等)来禁止特定类型的重排序。volatile和synchronized底层都会插入内存屏障,从而保证可见性和有序性。

四种内存屏障类型:

屏障类型 作用
LoadLoad 禁止处理器把上面的volatile读与下面的普通读重排序
LoadStore 禁止处理器把上面的volatile读与下面的普通写重排序
StoreStore 禁止处理器把上面的volatile写与下面的普通写重排序
StoreLoad 禁止处理器把上面的volatile写与下面的读/写重排序,并强制刷新缓存

JMM在volatile写操作前后插入屏障的策略:

在每个volatile写操作的前面插入一个StoreStore屏障

在每个volatile写操作的后面插入一个StoreLoad屏障

JMM在volatile读操作后面插入屏障的策略:

在每个volatile读操作的后面插入一个LoadLoad屏障

在每个volatile读操作的后面插入一个LoadStore屏障

在x86架构中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在x86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。

3.5 volatile关键字详解

volatile关键字是Java中最轻量级的同步机制,它保证了对volatile变量的读写具有可见性——写入volatile变量的值立即对其他线程可见。

volatile的局限性:

不保证原子性:复合操作如count++在volatile变量上仍然不是线程安全的

只适用于以下场景:写入变量不依赖于当前值(如设置标志位)、该变量不与其他状态变量共同构成不变量

与C++ volatile的区别:Java的volatile不用于硬件映射变量,而是专门用于多线程通信。

volatile vs synchronized:

特性 volatile synchronized
原子性 不保证 保证
可见性 保证 保证
有序性 保证 保证
互斥性
性能开销 相对较高

四、线程间通信方式

方式 原理 Java示例
共享内存 线程读写同一块内存(堆中的变量) volatile、synchronized、CAS
消息传递 线程间显式发送消息/事件 wait/notify、BlockingQueue、管道流

所有实例域、静态域、数组元素都存储在堆内存中,堆是线程共享的。JMM决定一个线程对共享变量的写入何时对其他线程可见。

五、final关键字的内存语义

final字段在Java内存模型中有特殊语义。在构造函数中正确初始化后的final字段,在其他线程中可见时一定处于已初始化状态,无需同步。这为不可变对象的安全发布提供了保证。

注意:如果final字段指向一个可变对象,该对象内部的状态仍然需要额外同步。

从内存模型的角度来看,JMM规定了final域的内存语义:在构造函数内对一个final域的写入,与随后将该对象引用赋值给另一个引用变量之间,存在happens-before关系。

六、死锁的排查与预防

6.1 什么是死锁?

最常见死锁模式:线程A先锁accountA再锁accountB,线程B反过来先锁accountB再锁accountA。只要调度稍有交错,就会卡死。

6.2 死锁排查方法

使用jstack命令:

# 查看进程ID
jps

# 导出线程堆栈(关键:必须加 -l 参数才显示锁信息)
jstack -l <pid>

# 如果输出末尾有 "Found 1 deadlock." 即确认死锁

jstack -l输出末尾如果有"Found 1 deadlock."就是实锤;它会列出锁持有者与等待者地址,匹配locked <0x...>和waiting to lock <0x...>即可定位。

关键分析方法:

重点盯住java.lang.Thread.State: BLOCKED (on object monitor)和Locked ownable synchronizers

注意看locked <0x0000000712345678>和waiting to lock <0x0000000712345678>的十六进制地址是否匹配

jstack死锁定位示例:

# 采样三次对比,找出卡住的线程
jstack <pid> > jstack_1.txt
sleep 5
jstack <pid> > jstack_2.txt
sleep 5
jstack <pid> > jstack_3.txt

# 查看锁的卡顿情况
cat jstack_1.txt | grep "waiting to lock" | sort | uniq -c

实用分析脚本:

#!/bin/bash
# jstack_analyzer.sh
PID=$1
LOOPS=${2:-3}
INTERVAL=${3:-5}

echo "分析进程 PID: $PID"
for i in $(seq 1 $LOOPS); do
    echo "第 $i 次采样..."
    jstack "$PID" > "jstack_${PID}_$i.txt"
    sleep $INTERVAL
done

# 统计线程状态分布
for file in jstack_${PID}_*.txt; do
    echo "=== $file ==="
    grep "java.lang.Thread.State:" "$file" | sort | uniq -c
    echo "锁等待情况:"
    grep "waiting to lock" "$file" | sort | uniq -c | sort -nr | head -5
done

6.3 死锁预防策略

统一加锁顺序

long id1 = accountA.getId();
long id2 = accountB.getId();
if (id1 < id2) {
    synchronized (accountA) {
        synchronized (accountB) {
            // 转账操作
        }
    }
} else {
    synchronized (accountB) {
        synchronized (accountA) {
            // 转账操作
        }
    }
}

使用tryLock超时机制

ReentrantLock lockA = new ReentrantLock();
ReentrantLock lockB = new ReentrantLock();

if (lockA.tryLock(100, TimeUnit.MILLISECONDS)) {
    try {
        if (lockB.tryLock(100, TimeUnit.MILLISECONDS)) {
            try {
                // 临界区代码
            } finally {
                lockB.unlock();
            }
        }
    } finally {
        lockA.unlock();
    }
}

避免在同步块内调用外部方法——万一那个方法内部又去抢另一把锁,你根本控制不了顺序。

降低锁粒度,减少持锁时间

七、总结

7.1 8个高频问题解答

  1. synchronized抛异常会释放锁吗?
    → 会,monitorexit指令保证释放,且编译器会生成异常表在异常发生时自动执行monitorexit。

  2. 锁升级可以降级吗?
    → 不能,不可逆。一旦出现过竞争,JVM认为后续竞争的概率依然存在,反向降级会引入复杂度且收益很小。

  3. CAS一定比锁快吗?
    → 不一定。低竞争时快,高竞争时自旋空转,不如重量级锁。

  4. 如何解决ABA?
    → AtomicStampedReference(使用版本号)或AtomicMarkableReference(使用布尔标记)。

  5. volatile能保证原子性吗?
    → 不能,只能保证可见性和有序性。复合操作如count++在volatile变量上仍然不是线程安全的。

  6. JDK 15+为什么默认禁用偏向锁?
    → 因为偏向锁撤销需全局安全点(STW),导致毫秒级延迟;现代应用多为短生命周期对象+高并发,偏向锁收益为负。

7.2 ReentrantLock和synchronized的区别?

特性 synchronized ReentrantLock
实现层面 JVM内置 Java代码层面
锁获取方式 自动 lock()/tryLock()
锁释放 JVM自动释放 必须手动unlock()
中断响应 不支持 支持
公平锁 非公平 可选公平/非公平
条件变量 单一wait/notify 多个Condition
性能(低竞争) 较好 较好
性能(高竞争) 重量级锁下较差 较好

7.3 什么是伪共享(False Sharing)?如何避免?

当多个线程访问位于同一缓存行的不同变量时,每次写入都会导致整个缓存行失效,造成性能下降。解决方案:使用缓存行填充(@Contended注解)或确保相关变量对齐到不同缓存行。

Logo

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

更多推荐