【原理】【Java并发】【synchronized】适合中学者体质的synchronized原理

👋hi,我不是一名外包公司的员工,也不会偷吃茶水间的零食,我的梦想是能写高端CRUD

🔥 2025本人正在沉淀中... 博客更新速度++

👍 欢迎点赞、收藏、关注,跟上我的更新节奏

📚欢迎订阅专栏,专栏名《在2B工作中寻求并发是否搞错了什么》

前言

聪明的你通过上一篇的学习:【Java并发】【synchronized】适合初学者体质入门的synchronized

一定很想了解synchronized更多更多吧!没问题,主播来咯!3、2、1,上文章📖

sychronzied代码块原理

修饰代码块时

java 复制代码
public void test(Object obj) {
    synchronized (obj) {
        System.out.println("Hello");
    }
}

我们使用javap -c SynchronizedMethodTest.class。我们可以看到,编译器在代码块入口插入monitorenter,在正常退出和异常退出的路径各插一个monitorexit,确保锁一定会被释放。这个monitorentermonitorexit是干嘛的,不用着急,后面会介绍😄。

shell 复制代码
0: aload_1             // 加载对象 obj 到操作数栈
1: dup                 // 复制栈顶值(obj 的引用)
2: astore_2            // 存储到局部变量表(保存锁对象)
3: monitorenter        // 进入监视器(获取锁)
4: getstatic #2        // 获取 System.out 静态字段
7: ldc #3              // 加载字符串 "Hello"
9: invokevirtual #4    // 调用 println 方法
12: aload_2
13: monitorexit         // 正常退出时释放锁
14: goto 22
17: astore_3           // 异常处理块
18: aload_2
19: monitorexit         // 异常退出时释放锁
20: aload_3
21: athrow
22: return

synchronized修饰方法原理

修饰synchronized方法时

java 复制代码
public class SynchronizedMethodTest {
    static int cnt = 0;
    public synchronized void cntUpdate() {
        cnt++;
        System.out.println("修改cnt后的数值是:" + cnt);
    }
}

使用javap -v SynchronizedMethodTest.class反编译后的结果,我们可以看到ACC_SYNCHRONIZED标识。

shell 复制代码
public synchronized void cntUpdate();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED		 // 重点是这个!!ACC_SYNCHRONIZED
Code:
  stack=3, locals=1, args_size=1
     0: getstatic     #2                  // Field cnt:I
     3: iconst_1
     4: iadd
...

monitorenter/monitorexit的作用

写到这里,你是否会好奇,monitorenter和monitorexit是来干嘛的?🤔,不要着急,下面马上开讲解

宝贝来看了,官网文档对monitorenter的描述,不着急哈,马上用ai翻译下。

monitorenter

The objectref must be of type reference.

翻译 :对对象引用类型要求 objectref 必须是引用类型(即对象实例),不能是基本数据类型。所以,我们使用synchronized都时候,里面要用对象类型,我们用了基本数据类型就会编译不通过。如下图所示(IDEA的提示):

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:

● If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.

● If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.

● If another thread already owns the monitor associated with objectref, the thread blocks until the monitor's entry count is zero, then tries again to gain ownership.

翻译 :监视器(Monitor)关联机制 每个Java对象都会关联一个监视器(Monitor),该监视器被锁定当且仅当它拥有所有者(即被某个线程持有)。当线程执行monitorenter指令时,尝试获取与objectref关联的监视器所有权:

  • 首次获取锁 如果监视器的进入计数(entry count)为0,线程立即成为监视器所有者,并将计数设为1。
  • 重入锁 如果线程已是监视器所有者(如synchronized代码块嵌套),则进入计数+1。
  • 竞争锁失败 若其他线程持有锁,当前线程会阻塞,直到监视器的计数归零后重新尝试获取。

monitorexit

The objectref must be of type reference.

翻译:objectref必须是引用类型(即对象实例),与monitorenter要求一致。

The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.

翻译 :执行monitorexit的线程必须是该监视器(关联于objectref)的当前所有者。否则会抛出IllegalMonitorStateException

The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

翻译:这里说的是锁计数器递减逻辑和唤醒竞争线程。

锁计数器递减逻辑

  • 线程将监视器的进入计数器(entry count)减1
  • 完全释放锁:若计数器减至0,线程退出监视器并失去所有权
  • 未完全释放:若计数器仍大于0(重入锁场景),线程仍保持所有权 唤醒竞争线程逻辑 :当计数器归零后,其他阻塞等待该监视器的线程会被唤醒并尝试获取锁。

ACC_SYNCHRONIZED的作用

这个就相当于隐式的synchronized代码块了,底层还是monitorenter/monitorexit那套。 JVM 会在方法调用前隐式调用 monitorenter,在方法返回(包括正常返回和异常返回)时隐式调用 monitorexit

注意:ACC_SYNCHRONIZED 并不会在字节码中生成显式的 monitorenter 和 monitorexit 指令,而是由 JVM 在方法调用时自动处理锁操作。

下面的同步方法和同步代码块执行起来是一个效果。 如果是静态的同步方法,那锁就是这个类。

java 复制代码
// 同步方法
public synchronized void syncMethod() { 
    System.out.println("Hello"); 
}

// 同步代码块
public void syncBlock() {
    synchronized(this) { 
        System.out.println("Hello"); 
    }
}

// 静态同步方法
public static synchronized void syncMethod() { 
    System.out.println("Hello"); 
}

// 同步代码块
public void syncBlock() {
    synchronized(Syn.class) { 
        System.out.println("Hello"); 
    }
}

并发三大特性synchronized保证原理

原子性

  • 原子性的意思:一个操作是不可中断的。
  • 保证原理 :通过monitorentermonitorexit来保证。
  • 过程 :一个线程a执行monitorenter后,会对monitor进行加锁,加锁后其他线程无法获取锁,除非线程a主动解锁。即使cpu时间片用完,线程a放弃了cpu,但是线程a没有释放锁,下次synchronzied重入的时候,还是只能线程a继续执行。直到代码执行完成。monitorexit释放锁。

有序性

  • 有序性的意思:按照代码的顺序先后执行
  • 保证原理synchronized代码块里是单线程的。虽然同步块内的代码可能被重排序,但由于同一时刻只有一个线程执行,其他线程无法观察到这种重排序,因此逻辑上等同于顺序执行。
  • 与volatile的有序性不同synchronized保证是代码级别的有序,volatile是指令级别的有序。

可见性

  • 可见性的意思:多个线程访问一个共享变量时,一个线程修改了这个变量,其他线程能够立刻看到修改。
  • 保证原理 :内存屏障 + JMM规则(happens-before
    • synchronized修饰的代码块,在执行完释放锁后,JVM 会插入 写屏障(Store Barrier),强制将线程本地内存(如 CPU 缓存)中的修改刷新到主内存。
    • 在进入 synchronized 代码块时(获取锁时),JVM 插入 读屏障(Load Barrier),强制从主内存重新加载共享变量,丢弃本地缓存中的旧值。
    • 对一个锁的解锁操作(unlock)必须发生在后续同一锁的加锁操作(lock)之前,保证修改对其他线程可见。

锁升级

好奇的你一定会问为什么要说锁升级呢?锁升级是什么?

为什么要优化?jdk1.6以前,是直接升级为重量级锁,然后对象头指向monitor。但是直接这样的话这个性能开销很大。

monitor监视器锁,依赖于底层操作系统的Mutex Lock来实现,而操作系统实现线程之间的切换时,需要从用户态转换到内核态,这个切态过程需要较长的时间,并且更方面成本较高,这也是早期的synchronized性能效率低的原因。

所以Java 中的锁升级机制是 JVM 为了优化 synchronized 关键字在 不同竞争场景下的性能 而设计的,通过动态调整锁状态(无锁 → 偏向锁 → 轻量级锁 → 重量级锁),在保证线程安全的前提下,尽可能减少锁操作的开销。 下图为优化后的结果:

对象头里MarkWord

jdk1.6之前,直接就是升级为重量级锁,所以没必要对对象头那么深入的了解。但是有了后续的锁优化之后,我们的对象头中的markword的事情可多了(对象头里面的markword记录锁的状,偏向锁、轻量级锁...),所以这里要多说下对象头,特别注意看markword。

对象头 = Mark Word + 类型指针 + [数组长度](可选)

Mark Word(标记字段):固定长度(32位系统4字节/64位系统8字节)

  • 存储运行时数据:哈希码(Identity HashCode)、GC分代年龄(4bit)、锁状态标志(2bit)、线程持有的锁信息、偏向线程ID/epoch、是否可偏向(biased_lock)。

类型指针(Klass Pointer)

  • 指向方法区中对象的类元数据
  • 开启指针压缩(-UseCompressedOops)时为4字节,否则8字节

数组长度(仅数组对象):4字节存储数组长度

锁升级MarkWord的变化

锁升级流程和变化

前面铺垫了这么久,终于可以细说🔒锁升级流程了,我觉得这是核中核了。

流程其实很简单很快的概括,就是:

shell 复制代码
无锁 → (单线程访问) → 偏向锁 → (竞争发生) → 轻量级锁 → (自旋失败) → 重量级锁

1.无锁状态(初始状态)

场景:对象刚被创建,未有任何线程竞争。

特点:所有线程均可直接访问同步块,无锁竞争。

2.偏向锁(Biased Lock)

核心优化 :消除无竞争时的同步开销。同一个线程进入同步代码块时,直接检查线程ID,发现MarkWord中的线程ID和当前一致。无需任何同步操作(如CAS、操作系统互拆),直接执行代码。

触发条件 :当一个线程进入synchronized同步代码块时,JVM检查当前对象处于 无锁状态(锁标志为001)。

流程

  1. 检查对象是否可偏向 :JVM检查对象头MarkWord,锁标志为是否为001?是否已偏向过?如果已经偏向过,会走偏向锁的撤销流程,升级为轻量级锁。(不要急,下面会细说偏向锁的撤销)
  2. cas设置偏向锁 :如果对象从未被偏向,JVM执行CAS操作,讲当前线程ID 写入MarkWord并将锁标志从001改为偏向模式101。如果cas失败,说明有别的线程竞争,升级为轻量级锁。

偏向锁的延迟启用

JVM 默认在程序启动后 4秒(可通过 -XX:BiasedLockingStartupDelay 设置)才启用偏向锁。 目的:避免启动阶段因类加载、初始化等操作导致的频繁偏向锁撤销。

JDK 15 后默认禁用偏向锁(-XX:-UseBiasedLocking)

被移除的原因JEP 374: Deprecate and Disable Biased Locking

简单来说就是:偏向锁为整个「同步子系统」引入了大量的复杂度,并且这些复杂度也入侵到了 HotSpot 的其它组件。

3.轻量级锁(Lightweight Lock)

偏向锁升级为轻量级锁,又称为偏向锁的撤销(Revoke)。

触发条件 :JVM 检测到当前锁已偏向其他线程,触发 偏向锁撤销,并升级为轻量级锁。

核心优化:通过CAS自旋避免线程阻塞。

流程

  1. 暂停原持有线程:JVM出发安全点(Safe Point),暂停原持有线程(STW,stop the world,STW 会导致短暂停顿,高并发场景下频繁撤销偏向锁可能降低性能。),确保原持有线程的状态稳定。防止在撤销的过程中修改对象头或执行同步代码块。
  2. 检查原持有线程锁状态 : a. 原持有线程退出同步代码块:将对象头恢复为 无锁状态,允许其他线程重新竞争;其他线程可以参试CAS直接获取偏向锁。 b. 原持有线程任然在同步代码中:创建Lock Record在原持有线程栈帧中分配一个Lock Record,;CAS更新对象头,将原对象头的Mark Word复制到Lock Record中(备份原状态);,并将锁标志位改为 00;最后唤醒原持有线程,继续执行同步代码块。其他线程,通过 自旋(Spin) 尝试获取轻量级锁(CAS 替换对象头,对象头指向自己的 Lock Record)。

为什么需要Lock Record?

说人话就是,我们需要保留对象的原始信息。之前的对象头还要存hashcode、分代年龄啥的,升级为轻量级锁之后,这部分要被保存下来,方便后续锁释放时恢复。

当线程释放轻量级锁时,将 Lock Record 中保存的原始 Mark Word 写回对象头,恢复对象为无锁状态。

自旋优化

早期固定自旋(JDK 1.6 之前)

  • 固定阈值:默认自旋 10 次(可通过 -XX:PreBlockSpin=10 设置)。
  • 问题:无法适应不同场景(如短任务自旋浪费 CPU,长任务自旋不足)。

自适应自旋(JDK 1.6+ 默认)

  • 动态调整:根据 上一次自旋的成功率 和 持有锁的线程状态 动态调整次数。
  • 规则
    • 若上次自旋成功获取锁,则允许更长的自旋(逐步增加次数)。
    • 若自旋失败率高,则减少次数甚至直接跳过自旋。
  • 优势:避免无意义的 CPU 空转,适应不同竞争强度。

4.重量级锁(Heavyweight Lock)

触发条件:线程自旋等待超过阈值,多个线程尝试获取锁,自旋等待期见锁未被释放。

核心机制:依赖操作系统互斥量(mutex)实现阻塞与唤醒。

流程

  1. 自旋失败:自旋次数超过动态阈值,或新线程加入竞争。
  2. 创建重量级线锁:JVM为对象分配一个Monitor对象,
  3. 修改对象头:Mark Word指向这个Monitor对象;锁标志位从轻量级锁的 00 改为重量级锁的 10
  4. 阻塞竞争线程:所有未获取锁的线程进入_EntryList队列,由操作系统调度为 阻塞状态。线程从用户态切换到内核态,依赖操作系统的互斥锁(Mutex)实现阻塞。

锁的唤醒机质

当持有锁的线程退出同步块时:

  1. _owner 字段置为 null
  2. _EntryList 中唤醒一个线程(synchronized强制非公平策略,无法调整)。

MarkWord角度观察锁升级

这部分得物的那篇写的很好,这里主播就直接大量参考了😎: 【得物技术】深入理解synchronzied底层原理

我们玩的就是真实,导入下面的pom.xml,跟上主播的节奏,直接看对象头😎。

xml 复制代码
<dependency>
  <groupId>org.openjdk.jol</groupId>
  <artifactId>jol-core</artifactId>
  <version>0.9</version>
</dependency>

无锁状态

java 复制代码
public static void main(String[] args) {
    Object o = new Object();
    System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

markword

shell 复制代码
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

这里详细说下意思,后续就不再详细说了。

列名 说明 示例对应值
OFFSET 内存偏移量(字节),表示该字段在对象内存中的起始位置 0 表示对象头从内存起始位置开始 , 8 表示类型指针从第8字节开始
SIZE 占用内存大小(字节),表示该字段占用的内存空间 4 表示占用4字节(loss...) 中的4 表示对齐填充占4字节
TYPE 字段类型(JOL输出中此列常为空)可能用于描述数据类型(如对象头、实例字段等) -
DESCRIPTION 字段描述,说明该内存区域的用途 (object header) 表示对象头(loss...) 表示对齐填充
VALUE 内存中的实际值 ,十六进制字节序列 + 二进制位解释 + 十进制数值(括号内) 01 00 00 00 表示小端存储的4字节(00000001 ...) 二进制展开(1) 十进制结果

不是主播偷懒哈,这后面的内容,其实得物写的就很好了,主播补充下这些基础知识,大伙去原文看就好了,讲的是非常的细了。

锁的其他优化

锁升级只是synchronzied优化的一小部分,下面还会说说别的优化点

锁消除(Lock Elimination):JVM检测到不存在共享数据竞争时(如局部变量),直接消除锁。

java 复制代码
public void method() {
    // 局部变量,不存在共享数据竞争
    Object lock = new Object();
    synchronized (lock) {
        System.out.println("LockEliminationExample: This is a local lock.");
    }
}

// JVM优化
public void method() {
    System.out.println("LockEliminationExample: This is a local lock.");
}

锁粗化(Lock Coarsening):对连续加锁/解锁操作合并为一次,减少CAS开销。

java 复制代码
public void method() {
    synchronized (lock) {
        System.out.println("Operation 1");
    }
    synchronized (lock) {
        System.out.println("Operation 2");
    }
    synchronized (lock) {
        System.out.println("Operation 3");
    }
}

// JVM优化
public void method() {
    synchronized (lock) {
        System.out.println("Operation 1");
        System.out.println("Operation 2");
        System.out.println("Operation 3");
    }
}

Monitor

聊了这么半天了,这个monitor是啥呢?诶嘿嘿,monitor来咯!✋😄✋

当你使用wait()notify()notifyAll()的时候,你是否会好奇?为啥会报错IllegalMonitorStateException,这个monitor状态是啥?比如下面的代码报错。

java 复制代码
public static void main(String[] args) throws InterruptedException {
    Object o = new Object();
    o.wait();
}

Exception in thread "main" java.lang.IllegalMonitorStateException
	at java.lang.Object.wait(Native Method)
	at java.lang.Object.wait(Object.java:502)

为什么需要放在synchronzied的代码块里面呢?

java 复制代码
public static void main(String[] args) throws InterruptedException {
    Object o = new Object();
    synchronized (o) {
        o.wait();
    }
}

还有前面说的,升级为重量级锁之后,对象头markword指向monitor,这个monitor是个啥呢?不要着急,跟上主播的节奏,扣111,马上开始进入神奇的monitor。

Monitor介绍

Monitor (监视器)并不是直接存储在对象头(Object Header)中,但对象头中的信息会指向 Monitor。具体来说,当对象被用作锁时,JVM 会根据锁状态(无锁、偏向锁、轻量级锁、重量级锁)在对象头中存储不同的信息。 只有在 重量级锁 状态下,对象头中的指针才会指向一个独立的 Monitor 对象(位于堆内存中)。 我们直接深入到源码,Monitor的具体实现在 objectMonitor.hpp 和 objectMonitor.cpp 中:

cpp 复制代码
//hotspot/src/share/vm/runtime/objectMonitor.hpp
class ObjectMonitor {
  // 核心字段
  volatile markOop   _header;       // 对象头(存储锁状态)
  void*     volatile _owner;        // 持有锁的线程
  volatile intptr_t  _recursions;   // 重入次数
  ObjectWaiter* volatile _WaitSet;  // 等待队列(调用 wait() 的线程)
  ObjectWaiter* volatile _EntryList;// 阻塞队列(竞争锁失败的线程)
  volatile intptr_t  _count;        // 锁计数器(重入时递增)
  volatile intptr_t  _waiters;      // 等待的线程数
  ObjectWaiter* volatile _cxq;      // 竞争锁的临时队列(内部优化)
  // ... 其他字段(如自旋锁、等待策略等)
};

我们重点下面这些就行了:

  • _owner:持有锁的线程指针(如0x00007f4b3800e8d0)
  • _recursions :记录锁重入次数(synchronized嵌套时递增)
  • _cxq:竞争锁的临时队列(JVM 内部优化用,某些情况下线程会先进入这里)。
  • _EntryList:线程因竞争锁失败而进入,等待锁释放后被唤醒。
  • _WaitSet :线程主动调用wait()释放锁后进入,等待其他线程调用notify()/notifyAll()唤醒,唤醒后移入Entry Set重新竞争锁

线程抢monitor流程

在变为重量级锁之前(偏向锁/轻量级锁)不涉及monitor竞争,偏向锁仅通过cas记录线程id,轻量级锁通过用户态的cas自旋。但是咱们这里是说重量级锁的,所以必须来说说这个重量级锁的monitor抢夺😄 🏀题外话:这个_cxq队列,从学生时代就让我想到了一位爱穿背带裤,唱、跳、RAP的明星。

假设 线程A 已持有锁,线程B 和 线程C 尝试获取锁:

步骤 1:线程B尝试获取锁

检查 _owner

  • 如果 _owner 为 NULL(锁未被持有),线程B通过 CAS 操作 将 _owner 设为自己,获取锁成功。
  • 如果 _owner 是线程B自己(可重入),计数器递增。
  • 如果锁已被线程A持有,线程B进入竞争流程。

步骤 2:线程B竞争失败,进入队列

进入 _cxq 队列 :线程B被封装成一个节点,插入到_cxq 队列的头部。线程B调用 park() 挂起自己,等待唤醒。

步骤 3:线程C竞争失败,同样进入队列

_cxq 队列结构: 线程C的节点插入到 _cxq 头部,队列变为:_cxq -> 线程C -> 线程B。

步骤 4:线程A释放锁

  1. 清空 _owner

    • 线程A将 _owner 设为 NULL,表示释放锁。
  2. 迁移队列

    • 如果 _EntryList 为空,将 _cxq 中的线程(线程C、线程B)迁移到 _EntryList
  3. 唤醒线程

    • _EntryList 头部(线程C)唤醒,线程C尝试通过 CAS 获取锁。

步骤 5:线程C获取锁成功

  • 线程C通过 CAS 将_owner 设为自己,开始执行同步代码。
  • 线程B仍在 _EntryList 中等待下次唤醒。

_WaitSet 相关

等待与唤醒(wait/notify)

  • 调用 wait()
    • 线程释放锁,进入 _WaitSet 队列。
    • 线程挂起,等待其他线程调用 notify()。
  • 调用 notify()
    • 从 _WaitSet 中移出一个线程,放入 _EntryList 或 _cxq。
    • 被移出的线程会在下次锁释放时参与竞争。

后话

没想到,这个synchronzied和volatile一样难搞,居然这么多细节,校招的时候,都顾着赶路了,背个锁升级,就是印象深刻了。毕业后再来学习,发现这里居然还有这么多内容,我以前还是太狂妄了。

其实还有很多内容还是可以补充的,也欢迎各位大佬指出我的不足🙇‍♂️🙇‍♂️🙇‍♂️

参考

Java对象头学习指南------实战篇 - 极客子羽 - 博客园

【得物技术】深入理解synchronzied底层原理

从源码全面解析 synchronized 关键字的来龙去脉

Synchronization (The Java™ Tutorials > Essential Java Classes > Concurrency)

相关推荐
开开心心就好9 分钟前
便捷开启 PDF 功能之旅,绿色软件随心用
android·java·windows·智能手机·eclipse·pdf·软件工程
uhakadotcom11 分钟前
Python 缓存利器:`cachetools`
后端·面试·github
Key~美好的每一天26 分钟前
一文了解JVM的垃圾回收
java·jvm
tan180°27 分钟前
版本控制器Git(4)
linux·c++·git·后端·vim
龙雨LongYu1243 分钟前
Go执行当前package下的所有方法
开发语言·后端·golang
程序员小刚1 小时前
基于springboot + vue 的实验室(预约)管理系统
vue.js·spring boot·后端
程序员小刚1 小时前
基于SpringBoot + Vue 的校园论坛系统
vue.js·spring boot·后端
By北阳1 小时前
Go语言 vs Java语言:核心差异与适用场景解析
java·开发语言·golang
J总裁的小芒果1 小时前
java项目发送短信--腾讯云
java·python·腾讯云
wenbin_java1 小时前
设计模式之桥接模式:原理、实现与应用
java·设计模式·桥接模式