火柴人武林大会
156.74M · 2026-02-04
在 Java 中,可以使用 synchronized 关键字来实现线程之间的同步操作。当一个对象被 synchronized 关键字修饰的代码块或方法锁定时,其他线程无法进入这段代码块或方法,直到该对象的锁被释放为止。
当你编写一个使用 synchronized的 Java 方法时,Java 编译器(javac)在将源 代码编译为字节码(.class 文件)时,就已经在相应的位置插入了 monitorenter和 monitorexit指令。
接下来我们稍微看一下使用synchronized的几种情况,以及其对应的字节码:
现在我们写一段简单的代码,并查看他的字节码。
public void hello(){
synchronized (this){
System.out.println("hello");
}
}
该方法编译后的字节码如下:
//将布局变量表中的第0位压入操作数栈,(因为这是一个普通方法,第0位存的是当前对象,所以就是将当前对象压入操作数栈)
0 aload_0
//复制当前对象引用并将其压入操作数栈
1 dup
//将复制的对象引用存储在局部变量表下标为1的位置
2 astore_1
//获取对象锁,进入同步块
3 monitorenter
//从System类的out字段中获取静态PrintStream对象的引用
4 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
//将常量池中索引为#5的字符串“hello”压栈
7 ldc #5 <hello>
//调用PrintStream对象的println()方法来打印堆栈顶部的字符串对象
9 invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>
//从局部变量表1的位置中加载先前保存的对象引用
12 aload_1
//退出同步块并释放对象锁
13 monitorexit
//跳转到第 22 行代码(即方法返回处),跳过异常处理代码
14 goto 22 (+8)
//捕获任何异常并将其存储在局部变量表下标为2的位置。
17 astore_2
//从局部变量1中的位置加载对象引用。
18 aload_1
//退出同步块并释放对象锁
19 monitorexit
//将先前捕获的异常重新抛出
20 aload_2
//抛出异常
21 athrow
//正常返回方法
22 return
我们可以看到上述字节码中有一个monitorenter指令,但是有两个monitorexit指令。 这是因为在synchronized块中,即使您的代码中没有明显的异常,也可能存在隐式异常,例如NullPointerException等。因此,即使代码中没有明显的异常,也有可能在字节码层面上存在多个monitorexit指令。JVM需要确保监视器锁得到释放,以避免死锁。
现在我们再来看一下其他情况:
将代码加上 throw new RuntimeException();
public void hello(){
synchronized (this){
System.out.println("hello");
throw new RuntimeException();
}
}
代码对应的字节码如下:
//将布局变量表中的第0位压入操作数栈,(因为这是一个普通方法,第0位存的是当前对象,所以就是将当前对象压入操作数栈)
0 aload_0
//复制当前对象引用并将其压入操作数栈
1 dup
//将复制的对象引用存储在局部变量表下标为1的位置
2 astore_1
//获取对象锁,进入同步块
3 monitorenter
//从System类的out字段中获取静态PrintStream对象的引用
4 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
//将常量池中索引为#5的字符串“hello”压栈
7 ldc #5 <hello>
//调用PrintStream对象的println()方法来打印堆栈顶部的字符串对象
9 invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>
//创建一个 RuntimeException 实例。
12 new #6 <java/lang/RuntimeException>
//复制操作数栈栈顶的值。并压栈(这里就是复制RuntimeException实例)
15 dup
//调用 RuntimeException 对象的构造函数。(调用init方法,消耗了一个实例)
16 invokespecial #7 <java/lang/RuntimeException.<init> : ()V>
//抛出栈顶的异常
19 athrow
//将操作数栈栈顶的数值存储到局部变量表中下标为 2 的位置。(这里放的还是RuntimeException实例)
20 astore_2
//将局部变量表中下标为 1 的元素加载到操作数栈中。
21 aload_1
//释放对象的监视器锁。
22 monitorexit
//将局部变量表中下标为 2 的变量压入操作数栈。
23 aload_2
//抛出栈顶的异常
24 athrow
我们可以看到显式抛出异常后,只有一个monitorenter和一个monitorexit。方法执行结束后自动释放锁。并把未处理的异常抛出。
现在我们用synchronized修饰方法
public synchronized void hello(){
System.out.println("hello");
}
其对应的字节码如下
0 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
3 ldc #3 <hello>
5 invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>
8 return
可以看到同步方法在字节码层面没有monitorenter,monitorexit相关的指令,当synchronized锁修饰方法时,被修饰的方法会比普通方法的多一个ACC_SYNCHRONIZED 标识符,根据这个是否有这个标识来决定是否要获取锁对象。
如果说看不懂上面的字节码其实对我们继续深入synchronized影响并不大。我们只需要知道,无论是monitorenter和monitorexit还是ACC_SYNCHRONIZED, 它们都是是 Java 虚拟机(JVM)实现同步(synchronized)的两种不同机制
好了,下面总结一下:
| 特性 | 同步代码块 (Synchronized Block) | 同步方法 (Synchronized Method) |
|---|---|---|
| 字节码表现 | 包含显式的 monitorenter 和 monitorexit | 无特殊指令,仅有 ACC_SYNCHRONIZED 标志 |
| 触发时机 | 执行到指令时尝试获取 monitor | 方法调用指令(如 invokevirtual)识别到标志时 |
| 锁的对象 | 括号中指定的对象 | this 对象(实例方法)或 Class 对象(静态方法) |
| 异常处理 | 需要显式的异常路径来确保 monitorexit | 由 JVM 隐式确保方法退出(无论正常还是异常)时释放锁 |
在继续深入synchronized之前,我们有必要先知道一下java对象的内存布局。
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:
对于synchronized我们重点需要关注一下对象头(Header)。那对于一个对象的对象头而言里面有什么呢?这里先以64位虚拟机说明一下。
Java对象头包括三部分:
但是由于存在指针压缩,对象头大小并不是说是固定的。这里可以查阅指针压缩的相关知识。
java中使用一个对象来作为一把锁,其锁的状态分为4种(由轻到重):无锁->偏向锁->轻量锁->重量锁,锁状态的标志位就存储在对象头的Mark Word中最后2个比特位。
Mark Word 最后两位为 11 代表当前对象处于不可用的状态,即在进行垃圾回收时对象已经被标记为不可达
通过上述了解,我们也知道了synchronized锁是有等级的。为了提高并发性能和减少锁竞争,Java从1.6版本开始引入了锁升级机制,将synchronized锁从偏向锁状态转换为轻量级锁状态、重量级锁状态等级别。锁升级的过程是自动进行的,开发者无需手动干预。Java虚拟机会根据当前锁的状态、竞争情况等因素自动决定锁的级别。下面由轻到重来讲解:
偏向锁中的“偏”,指的是“偏心、偏向”。它的含义是:锁会优先偏向第一个获取它的线程。如果在后续的执行过程中,没有其他线程来竞争这把锁,那么这个线程在整个使用期间都无需再进行任何同步操作。
当一个线程获得了对象的锁并且这个对象没有被其他线程所访问时,该线程会进入偏向锁状态,并在对象头中记录下该线程的ID(如上图所示)。此时,如果其他线程想要访问该对象,只需要检查对象头中的线程ID是否与自己相同即可。
当多个线程竞争同一个锁时,会进入轻量级锁状态。此时,系统会在当前线程的栈帧中创建一个Lock Record(锁记录),并将对象头中的Mark Word复制到该锁记录中,并将对象头中的Mark Word指向该锁记录。然后,当前线程会尝试使用CAS原子操作来修改对象头的Mark Word为指向锁记录的指针。
synchronized 代码块进行解锁时,如果当前弹出的 Lock Record 的 obj 不为 null,JVM 会通过 CAS 操作尝试将对象头中的 Mark Word 恢复为锁前的状态; 在介绍重量级锁之前,需要先了解一个概念:Monitor 锁。它是 JVM 层面的锁,由 C++ 实现。
在底层,synchronized 会关联一个 ObjectMonitor 对象。当一个对象在重量级锁状态下被 synchronized 锁住时,该对象头的 Mark Word 会存储一个指向 ObjectMonitor 的指针,从而将对象与其对应的 ObjectMonitor 关联起来,实现真正的锁管理。
其底层对应的objectMonitor代码可以在openjdk的官网和GitHub找到:
官网:
gayhub:
ObjectMonitor() {
//初始值是0,用于判断当前对象是否被锁定。加锁+1,解锁-1
_count = 0;
//锁重入次数
_recursions = 0;
//锁定当前对象的线程ID
_owner = NULL;
//等待队列,存放等待的线程
_WaitSet = NULL;
//阻塞队列,存放阻塞的线程
_EntryList = NULL ;
}
加锁流程简述:
当多个线程竞争同一个锁,并且轻量级锁无法通过自旋成功获取时,锁会膨胀为重量级锁。此时,JVM 会在内存中为该对象分配一个 ObjectMonitor 对象,并将对象头的 Mark Word 指向它,从而将对象与 Monitor 锁关联起来,实现线程间的互斥和等待管理。
在锁升级为轻量级或者重量级锁后,Mark Word中保存的分别是线程栈帧里的锁记录指针和重量级指针,已经没有位置保存HashCode,GC年龄了,那这些信息去哪里了呢?
JVM的自旋次数是通过PreBlockSpin参数控制。这个参数可以在如下网址看到
www.oracle.com/java/techno…
在自适应锁出现之前,JVM 的自旋次数是“一刀切”的。而自适应自旋将这种固定次数进化成了基于历史经验的动态预测, JVM默认的自旋次数是10。
1. 动态增加
如果一个线程在某个锁对象上,刚刚成功地通过自旋获得过锁,且当前持有锁的线程正在运行中。
2. 动态减少/取消
如果对于某个锁,自旋很少成功获得过。
当 JVM 的即时编译器(JIT)在运行时检测到某些代码虽然使用了锁,但其实根本不存在共享数据竞争时,就会把这个锁删掉。这主要依靠逃逸分析(Escape Analysis)。如果 JVM 发现一个对象只会在当前线程内部使用(不会逃逸到其他线程),那给它加锁就是白费力气。
我们常用的 StringBuffer 是线程安全的,它的 append 方法带了 synchronized。下面是一个栗子:
public String concatString(String s1, String s2) {
// StringBuffer 是局部变量,不会被其他线程访问
// 它属于“非逃逸对象”
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
在上述代码中,sb 对象只在 concatString 方法内部有效。JVM最终会发现:没有任何其他线程能访这个 sb。此时,它会大胆地将 append 方法内部的同步锁直接消除。
原则上我们建议同步块越小越好(只在必要时加锁)。但如果 JVM 发现一系列连续的操作都对同一个对象反复加锁、解锁,甚至锁出现在循环体内部,它就会把加锁的范围扩大。原因是因为频繁地“获取-释放”锁会产生大量的资源消耗。为了减少这种无谓的性能损耗,JVM 会将多个连续的锁合并成一个范围更大的锁。
比如说下面这段代码:
for (int i = 0; i < 1000; i++) {
synchronized(lock) {
// do something
}
}
会优化成:
synchronized(lock) {
for (int i = 0; i < 1000; i++) {
// do something
}
}
对于偏向锁,在JDK15标记为弃用,在JDK17进行相关实现逐步移除。在java的技术浪潮中已经成为了前浪。主要原因是 它的性能优势在现代硬件和 JVM 优化下越来越小,而实现复杂度高。