Skip to content

锁机制详解

一、Java锁的基础:synchronized的底层实现

synchronized是Java中最基本的同步机制,其底层依赖JVM的 Monitor(监视器)对象头(Mark Word) 实现。要理解synchronized,必须先搞清楚这两个核心结构。

1.1 对象头(Mark Word):锁状态的存储载体

在JVM中,每个对象都有一个对象头(Object Header),用于存储对象的元数据(如哈希码、GC年龄、锁状态等)。其中,Mark Word是对象头的核心部分(占32位或64位,取决于JVM位数),其结构会根据锁状态动态变化。

64位JVM为例,Mark Word的默认结构(无锁状态)如下:

位偏移含义
0-2锁状态标志(001:无锁)
3-4GC年龄
5是否偏向锁(0:未偏向)
6-11未使用
12-63对象哈希码(HashCode)

当对象被加锁时,Mark Word的结构会发生变化,以存储锁的状态信息(如偏向线程ID、轻量级锁指针、重量级锁Monitor地址等)。不同锁状态对应的Mark Word结构如下:

锁状态锁标志位Mark Word存储内容
无锁001哈希码、GC年龄、是否偏向锁(0)
偏向锁101偏向线程ID、偏向时间戳、GC年龄、是否偏向锁(1)
轻量级锁000指向栈帧中锁记录(Lock Record)的指针
重量级锁010指向Monitor对象的指针
GC标记111无意义(GC回收时使用)

1.2 Monitor:重量级锁的实现核心

Monitor(监视器) 是JVM中的同步工具,每个对象都关联一个Monitor(通过对象头的Mark Word指向)。Monitor的结构如下(逻辑模型):

c
typedef struct Monitor {
    Object*     owner;          // 当前持有锁的线程
    ThreadList* entryList;      // 等待获取锁的线程队列(阻塞队列)
    ThreadList* waitSet;        // 调用wait()后等待的线程队列
    int         recursiveCount; // 重入次数(可重入锁的实现)
} Monitor;

当线程尝试获取synchronized锁时,JVM会执行以下步骤:

  1. 尝试获取Monitor:如果Monitor的ownernull,则当前线程将owner设为自己,并将recursiveCount设为1,成功获取锁。
  2. 重入处理:如果当前线程已经是Monitor的owner,则recursiveCount加1(可重入性)。
  3. 阻塞等待:如果Monitor的owner是其他线程,则当前线程进入entryList队列,进入阻塞状态(BLOCKED),等待被唤醒。

当线程释放锁时(退出synchronized块或方法),recursiveCount减1,若减至0,则将owner设为null,并唤醒entryList中的一个线程(公平性取决于JVM实现,默认非公平)。

1.3 synchronized的三种使用方式与Monitor关联

synchronized可以修饰方法代码块,其底层都通过Monitor实现,但关联的对象不同:

  • 修饰实例方法:Monitor关联当前对象(this)的对象头。
  • 修饰静态方法:Monitor关联当前类的Class对象(存储在方法区)的对象头。
  • 修饰代码块:Monitor关联 synchronized(lockObj)中的lockObj的对象头。

二、JVM的锁升级:从偏向锁到重量级锁

JDK 1.6之前,synchronized重量级锁(直接关联Monitor),性能较差。JDK 1.6引入了分层锁机制(偏向锁→轻量级锁→重量级锁),根据竞争强度动态调整锁的类型,优化性能。

2.1 偏向锁(Biased Locking):单线程优化

核心思想:当一个线程第一次获取锁时,将对象头的Mark Word设置为偏向模式(锁标志位101),记录该线程的ID。之后该线程再次获取锁时,无需进行CAS操作,直接判断Mark Word中的线程ID是否为当前线程,即可快速获取锁。

实现细节:

  1. 偏向锁的获取

    • 线程第一次访问synchronized块时,JVM检查对象头的Mark Word是否为无锁状态(001)未偏向(是否偏向锁位为0)
    • 如果是,通过CAS操作将Mark Word的是否偏向锁位设为1线程ID设为当前线程ID偏向时间戳设为当前时间
    • 后续该线程再次进入synchronized块时,直接比较Mark Word中的线程ID,若匹配则无需任何操作,快速获取锁。
  2. 偏向锁的撤销

    • 当有其他线程尝试获取锁时,偏向锁会被撤销(升级为轻量级锁)。撤销过程需要暂停拥有偏向锁的线程,并检查该线程是否还在执行synchronized块:
      • 如果线程已退出,则将对象头恢复为无锁状态。
      • 如果线程仍在执行,则将对象头升级为轻量级锁(锁标志位000),并让该线程重新获取轻量级锁。

适用场景:

  • 单线程场景(如单线程循环执行同步代码):偏向锁几乎没有开销(仅第一次CAS),性能最优。
  • 避免场景:多线程竞争频繁的场景(偏向锁会频繁撤销,反而增加开销)。可以通过-XX:-UseBiasedLocking禁用偏向锁。

2.2 轻量级锁(Lightweight Locking):低竞争优化

核心思想:当线程竞争不激烈时(如线程交替执行同步代码),使用**自旋锁(Spin Lock)**替代重量级锁的阻塞,减少上下文切换开销。

实现细节:

  1. 轻量级锁的获取

    • 线程进入synchronized块时,JVM会在当前线程的栈帧中创建一个锁记录(Lock Record),用于存储对象头的Mark Word副本(称为displaced Mark Word)。
    • 线程通过CAS操作将对象头的Mark Word替换为指向锁记录的指针(锁标志位设为000)。
    • 如果CAS成功,线程获取轻量级锁;如果失败(说明有其他线程竞争),则进入自旋等待(循环尝试CAS)。
  2. 轻量级锁的自旋

    • 自旋的目的是避免线程阻塞(上下文切换开销大),适用于锁持有时间短的场景。
    • JVM采用自适应自旋(Adaptive Spinning):根据之前的自旋结果调整自旋次数(如上次自旋成功,则增加自旋次数;上次失败,则减少或取消自旋)。
  3. 轻量级锁的升级

    • 如果自旋次数超过阈值(默认10次,可通过-XX:PreSpin调整),或有多个线程竞争(如第三个线程尝试获取锁),轻量级锁会升级为重量级锁(锁标志位设为010,指向Monitor对象)。此时,自旋的线程会进入Monitor的entryList队列,进入阻塞状态。

适用场景:

  • 低竞争场景(如线程交替执行同步代码):轻量级锁的自旋开销远小于重量级锁的阻塞开销。
  • 避免场景:高竞争场景(自旋会浪费CPU资源,此时应直接使用重量级锁)。

2.3 重量级锁(Heavyweight Locking):高竞争兜底

核心思想:当线程竞争激烈时(如多个线程同时争夺锁),使用Monitor的阻塞机制,确保线程安全,但开销最大(涉及上下文切换)。

实现细节:

  • 重量级锁的获取与释放依赖Monitor的entryListwaitSet队列:
    • 线程获取锁失败时,进入entryList队列,状态变为BLOCKED
    • 线程调用wait()方法时,释放锁(将owner设为nullrecursiveCount减至0),进入waitSet队列,状态变为WAITING
    • 线程调用notify()/notifyAll()方法时,唤醒waitSet中的一个/所有线程,这些线程会进入entryList队列,重新竞争锁。

适用场景:

  • 高竞争场景(如多个线程同时访问共享资源):重量级锁的阻塞机制可以避免CPU资源浪费,但性能最差。

2.4 锁升级的整体流程

JVM的锁升级是不可逆的(除了偏向锁可以被撤销),流程如下:

无锁状态(001)
  ↓(第一次获取锁,单线程)
偏向锁(101)
  ↓(其他线程竞争,撤销偏向锁)
轻量级锁(000)
  ↓(自旋失败/多线程竞争)
重量级锁(010)

三、并发包的核心:AQS框架

java.util.concurrent.locks包中的锁(如ReentrantLockCountDownLatchSemaphore)均基于AQS(AbstractQueuedSynchronizer,抽象队列同步器)实现。AQS是JUC的核心框架,其设计思想是将同步状态(State)与等待队列(CLH队列)分离,提供通用的同步机制。

3.1 AQS的核心结构

AQS的核心由两部分组成:

  1. 同步状态(State)

    • volatile int state表示,用于存储同步状态(如ReentrantLock的重入次数、Semaphore的许可数)。
    • 状态的修改必须通过CAS操作compareAndSetState()),确保原子性。
  2. 等待队列(CLH队列)

    • 是一个双向链表,每个节点代表一个等待锁的线程(Node对象)。
    • 节点的状态(waitStatus)包括:CANCELLED(取消)、SIGNAL(等待唤醒)、CONDITION(等待条件)、PROPAGATE(共享模式传播)。
    • 队列的头节点(head)是当前持有锁的线程,尾节点(tail)是最后一个等待的线程。

3.2 AQS的两种模式

AQS支持独占模式(Exclusive Mode)共享模式(Shared Mode),分别对应不同的同步场景:

  • 独占模式:同一时间只有一个线程可以获取锁(如ReentrantLock)。
  • 共享模式:同一时间多个线程可以获取锁(如SemaphoreCountDownLatch)。

独占模式的实现(以ReentrantLock为例):

  1. 获取锁(lock()

    • 调用tryAcquire(int arg)方法(由子类实现),尝试修改状态(如state从0变为1)。
    • 如果tryAcquire成功,当前线程获取锁。
    • 如果tryAcquire失败,将当前线程封装为Node,加入CLH队列的尾部(通过CAS操作),然后进入阻塞状态(LockSupport.park()
  2. 释放锁(unlock()

    • 调用tryRelease(int arg)方法(由子类实现),修改状态(如state减1)。
    • 如果tryRelease成功,唤醒CLH队列中的头节点的下一个节点(Node.signal()),该节点的线程会重新尝试获取锁。

共享模式的实现(以Semaphore为例):

  1. 获取许可(acquire()

    • 调用tryAcquireShared(int arg)方法(由子类实现),尝试修改状态(如statearg)。
    • 如果tryAcquireShared返回值≥0(成功),当前线程获取许可。
    • 如果返回值<0(失败),将当前线程封装为Node,加入CLH队列的尾部,然后进入阻塞状态。
  2. 释放许可(release()

    • 调用tryReleaseShared(int arg)方法(由子类实现),修改状态(如statearg)。
    • 如果tryReleaseShared成功,唤醒CLH队列中的头节点的下一个节点,该节点的线程会重新尝试获取许可,并将唤醒操作传播给后续节点(共享模式的特性)。

3.3 AQS的子类实现:以ReentrantLock为例

ReentrantLock是AQS的典型子类,实现了可重入的独占锁,支持公平锁非公平锁

公平锁与非公平锁的区别:

  • 非公平锁(默认):线程尝试获取锁时,先通过CAS修改状态(state从0变为1),如果成功则直接获取锁;如果失败,再加入CLH队列。这种方式允许线程“插队”,提高性能,但可能导致线程饥饿(某些线程长期无法获取锁)。
  • 公平锁:线程尝试获取锁时,先检查CLH队列是否有等待的线程,如果有,则直接加入队列;如果没有,再通过CAS修改状态。这种方式保证线程按等待顺序获取锁,公平性好,但性能略低(因为需要维护队列顺序)。

ReentrantLocktryAcquire实现(非公平锁):

java
protected boolean tryAcquire(int arg) {
    if (arg != 1) throw new IllegalArgumentException();
    // 非公平锁:先尝试CAS修改state(插队)
    if (compareAndSetState(0, 1)) {
        setExclusiveOwnerThread(Thread.currentThread());
        return true;
    }
    // 重入处理:当前线程已持有锁,state加1
    Thread current = Thread.currentThread();
    if (getExclusiveOwnerThread() == current) {
        int nextState = getState() + arg;
        if (nextState < 0) throw new Error("Maximum lock count exceeded");
        setState(nextState);
        return true;
    }
    // 失败,返回false
    return false;
}

3.4 AQS与synchronized的区别

特性synchronizedReentrantLock(AQS实现)
实现层面JVM层面(Monitor)JDK层面(AQS框架)
锁类型可重入、独占可重入、独占(支持公平/非公平)
锁升级偏向锁→轻量级锁→重量级锁无(直接使用AQS的队列机制)
手动释放自动(退出同步块/方法)手动(必须调用unlock(),否则死锁)
中断支持不支持(线程阻塞时无法中断)支持(lockInterruptibly()
超时获取不支持支持(tryLock(long timeout, TimeUnit unit)
公平性非公平(默认)支持公平/非公平(可配置)
条件变量支持(wait()/notify()支持(Condition接口,更灵活)

四、锁的内存语义:可见性与有序性

锁的核心作用是保证共享变量的原子性,但同时也通过内存屏障(Memory Barrier)保证了可见性有序性

4.1 synchronized的内存语义

根据JMM(Java内存模型),synchronized进入退出会插入以下内存屏障:

  • 进入同步块:插入LoadLoadLoadStoreStoreStore屏障,禁止重排序(确保同步块内的代码不会被重排序到同步块外)。
  • 退出同步块:插入StoreLoad屏障,强制将缓存中的数据刷新到主内存(确保其他线程能看到当前线程修改的共享变量)。

简单来说:

  • 线程A进入synchronized块修改共享变量x,修改后的数据会被刷新到主内存。
  • 线程B进入同一个synchronized块时,会从主内存读取x的最新值(可见性)。
  • 同步块内的代码顺序不会被重排序(有序性)。

4.2 volatilesynchronized的内存语义对比

  • volatile:仅保证可见性有序性(禁止重排序),但不保证原子性(如i++操作不是原子的)。
  • synchronized:保证原子性可见性有序性(全能,但开销更大)。

五、锁的优化策略:从JVM到应用层

为了提高锁的性能,JVM和应用层都有一系列优化策略,以下是常见的几种:

5.1 锁消除(Lock Elimination)

核心思想:JIT编译器通过逃逸分析(Escape Analysis),识别出不会被其他线程访问的共享变量,从而消除不必要的锁。

例如,以下代码中的synchronized块可以被消除:

java
public String concat(String a, String b) {
    StringBuffer sb = new StringBuffer(); // sb不会逃逸到方法外
    sb.append(a);
    sb.append(b);
    return sb.toString();
}

StringBufferappend()方法是synchronized的,但sb是方法内的局部变量,不会被其他线程访问(未逃逸),因此JIT编译器会消除append()方法中的锁,优化为StringBuilder的非同步操作。

5.2 锁粗化(Lock Coarsening)

核心思想:将多个连续的锁操作合并为一个大的锁操作,减少锁的开销(如CAS操作、上下文切换)。

例如,以下代码中的循环内的synchronized块会被粗化为循环外的一个锁:

java
for (int i = 0; i < 1000; i++) {
    synchronized (lock) { // 连续的小锁
        // 业务逻辑
    }
}

JIT编译器会将其优化为:

java
synchronized (lock) { // 合并为一个大锁
    for (int i = 0; i < 1000; i++) {
        // 业务逻辑
    }
}

5.3 自旋锁(Spin Lock)

核心思想:线程获取锁失败时,不立即阻塞,而是循环尝试获取锁(自旋),避免上下文切换开销。

自旋锁适用于锁持有时间短的场景(如轻量级锁的自旋)。JVM采用自适应自旋,根据之前的自旋结果调整自旋次数(如上次自旋成功,则增加自旋次数;上次失败,则减少或取消自旋)。

5.4 锁分离(Lock Splitting)

核心思想:将一个大的锁拆分为多个小的锁,减少锁的竞争范围。

例如,ConcurrentHashMap的**分段锁(Segment)**机制:将哈希表分为多个Segment,每个Segment对应一个锁,不同Segment的操作可以并行执行,提高并发性能。

5.5 读写锁(ReadWriteLock)

核心思想:将读操作写操作分离,允许多个读线程同时访问(读共享),但写线程独占(写独占),适用于读多写少的场景。

ReentrantReadWriteLock是读写锁的实现,其特点:

  • 读锁(共享锁):多个线程可以同时获取,不会阻塞其他读线程,但会阻塞写线程。
  • 写锁(独占锁):一个线程获取后,会阻塞所有读线程和写线程。
  • 锁降级:写线程可以降级为读线程(先获取写锁,再获取读锁,最后释放写锁),避免写线程释放锁后其他线程修改数据。

六、高级话题:死锁与锁的公平性

6.1 死锁的分析与避免

死锁是指两个或多个线程互相等待对方释放锁,导致无限阻塞的状态。死锁的四个必要条件:

  1. 互斥条件:资源只能被一个线程持有。
  2. 请求与保持条件:线程持有一个资源的同时,请求另一个资源。
  3. 不可剥夺条件:资源不能被强制剥夺。
  4. 循环等待条件:线程之间形成循环等待链(如线程A等待线程B的锁,线程B等待线程A的锁)。

死锁的排查:

  • 使用jstack命令:查看线程栈信息,寻找BLOCKED状态的线程,以及它们等待的锁。
  • 使用jconsoleVisualVM:可视化工具,查看线程状态和锁信息。

死锁的避免:

  • 顺序加锁:线程获取多个锁时,按固定的顺序(如从小到大)获取,避免循环等待。
  • 超时加锁:使用tryLock(long timeout, TimeUnit unit)方法,超时后放弃获取锁,避免无限等待。
  • 释放锁:确保锁的释放操作在finally块中,避免异常导致锁未释放。
  • 使用并发工具:如CountDownLatchSemaphore等,替代手动加锁。

6.2 锁的公平性:公平锁与非公平锁

公平锁:线程按等待顺序获取锁(先到先得),公平性好,但性能略低(因为需要维护队列顺序)。 非公平锁:线程尝试获取锁时,先插队(直接尝试CAS修改状态),如果失败再加入队列,性能好,但可能导致线程饥饿。

选择建议:

  • 高并发、低延迟:选择非公平锁(如ReentrantLock的默认模式),提高吞吐量。
  • 需要公平性:选择公平锁(如ReentrantLock(true)),避免线程饥饿。

七、总结:JVM锁的设计思想

JVM中的锁机制(synchronized)和并发包中的锁(ReentrantLock等),其设计思想都是分层优化适应不同场景

  • 分层锁:偏向锁(单线程)→轻量级锁(低竞争)→重量级锁(高竞争),根据竞争强度动态调整,优化性能。
  • AQS框架:将同步状态与等待队列分离,提供通用的同步机制,支持各种同步工具(如锁、信号量、计数器)。
  • 锁的优化:通过锁消除、锁粗化、自旋锁等策略,减少锁的开销,提高并发性能。

作为高级开发人员,需要深入理解锁的底层实现(如Mark Word、Monitor、AQS),掌握锁的优化策略(如读写锁、锁分离),并能根据场景选择合适的锁(如synchronized vs ReentrantLock),从而写出高效、安全的并发代码。