0.前言
前几个章节我们了解到Class文件的结构剖析,以及字节码的场景语句的底层原理,以及字节码中的指令的基本含义。本章节我们学以致用,来从字节码层面分析synchronized的实现原理。
首先,我们来看一个简单的Synchronized
示例:
public class SynchronizedExample { public void method() { synchronized (this) { System.out.println("Synchronized Method"); } } }
我们可以通过javap来看一下这段代码的字节码:
$ javap -c -s -v -l SynchronizedExample
生成的字节码大致如下:
public class SynchronizedExample { public void method(); Code: 0: aload_0 // 将"this"加载到操作数栈顶 1: dup // 复制操作数栈顶的值 2: astore_1 // 将操作数栈顶的值存储到局部变量表的第1个位置,也就是"this" 3: monitorenter // 为对象(此处即"this")加锁 4: getstatic #2 // 获取静态字段,此处是 java/lang/System.out:Ljava/io/PrintStream; 7: ldc #3 // 从常量池中加载字符串常量 "Synchronized Method" 9: invokevirtual #4 // 调用方法,此处是 java/io/PrintStream.println:(Ljava/lang/String;)V 12: aload_1 // 把局部变量表的第1个位置的值(即"this")加载到操作数栈顶 13: monitorexit // 为对象(此处即"this")解锁 14: goto 22 17: astore_2 // 将操作数栈顶的异常存储到局部变量表的第2个位置 18: aload_1 // 把局部变量表的第1个位置的值(即"this")加载到操作数栈顶 19: monitorexit // 为对象(此处即"this")解锁 20: aload_2 // 把局部变量表的第2个位置的值(即异常)加载到操作数栈顶 21: athrow // 抛出异常 22: return // 方法返回 Exception table: // 异常处理表 from to target type 4 14 17 any 17 20 17 any }
此处我们注意到,在字节码指令中,synchronized
关键字对应的是monitorenter
和monitorexit
两个指令。后面章节我们后着重的讲解这两个字节码指令的原理。
当进入synchronized
块的时候,就会执行monitorenter
指令对对象加锁,而当退出synchronized
块的时候,就会执行monitorexit
指令解锁。
当发生异常的时候,无论是否有catch
语句,JVM都会确保monitorexit
指令的执行,这样可以保证在发生异常时,锁定的对象能够被正确释放。
我们看到了字节码指令中有,Java字节码中的异常处理表(Exception table
)不仅仅是用来处理我们显式声明的try-catch块,也被用来处理一些隐式的运行时异常,以及一些Java语言特性的实现。
Exception table: // 异常处理表 from to target type 4 14 17 any 17 20 17 any
同步块,同步静态方法,同步实例方法的字节码区别
(1)普通同步方法(实例方法),锁是当前实例对象 ,进入同步代码前要获得当前实例的锁
(2)静态同步方法,锁是当前类的Class对象,进入同步代码前要获得当前类的Class对象的锁
(3)同步方法块,锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
我们写一个类
package com.icepip.project; public class MyClass { public synchronized void synchronizedMethod() { System.out.println("sync"); } public static synchronized void synchronizedStaticMethod() { System.out.println("sync"); } public void synchronizedBlock() { synchronized (this) { System.out.println("sync"); } } private int myPrivateMethod() { return 42; } }
输出字节码
- 可以看到在反编译代码块那里的字节码中可以看到其中有一个
monitorenter
指令和两个monitorexit
指令。其实这就是synchronized
的关键所在,synchronized
修饰的代码块会带有monitorenter和monitorexit指令
,用于jvm中控制同步代码块访问时必须获得对象锁。那么这里为啥会有两个monitorexit指令,获取对象锁执行完代码后不是释放对象锁就行了吗?
按理来说monitorenter和monitorexit应该是一对一的?这里就是关键所在,JVM为了防止异常,获得锁的线程无法释放的情况,规定了synchronized锁修饰的代码块当线程执行异常时,会自动释放对象锁。因此这里有两个monitorexit指令,分别对应正常释放和异常释放。 - 可以看到在修饰静态方法和实例方法那里并没有monitorenter和monitorexit指令。但是可以观察到在方法的访问flags那里都有
ACC_SYNCHRONIZED
修饰。其实这里也是会用到monitorenter和monitorexit
,只不过在修饰方法时用ACC_SYNCHRONIZED
代替了。再加上ACC_STATIC
就可以判断是静态方法还是实例方法了,后面拿到需要获取的所对象,实现方式就跟代码块那里一样了。
我再补充一点就是这三种不同的同步方式之间在字节码层的主要区别:总结一下
1)普通同步方法(实例方法):锁定的是实例对象本身,也就是说,线程要执行那个实例对象的同步方法,就必须先获得那个实例对象的锁。在解码层,这是通过ACC_SYNCHRONIZED
标记来实现的。
2)静态同步方法:锁定的是类的Class对象。由于静态方法属于类,而不属于类的任何一个实例对象,所以通过锁定Class对象来控制静态方法的并发访问。在解码层,同样是通过ACC_SYNCHRONIZED和ACC_STATIC
标记来实现的。
3)同步方法块:同步方法块可以锁定任何对象,这通过在字节码中插入monitorenter和monitorexit指令
来实现。获取锁的对象是在括号内指定的对象。它提供了更大的灵活性,因为你可以锁定任何对象,不仅仅是实例对象或Class对象。
解析synchronized
代码块的字节码
Java中的synchronized
代码块在字节码层面上是通过monitorenter
和monitorexit
指令来实现的。这两个指令都会和一个引用一起使用,该引用就是需要被同步的对象。
考虑一个简单的synchronized
代码块:
Object lock = new Object(); synchronized(lock) { // do something }
在字节码层面上,这段代码会被转换成如下的形式:
0: new #2 3: dup 4: invokespecial #3 7: astore_1 8: aload_1 9: dup 10: astore_2 11: monitorenter 12: // do something : // 异常处理 : aload_2 : monitorexit : goto end : // 异常处理 : aload_2 : monitorexit : athrow : end
在这段字节码中:
0: new
,3: dup
,4: invokespecial
和7: astore_1
这四条指令创建了一个新的Object对象,并将其引用存储在局部变量表的第一个位置(即lock
)。8: aload_1
将lock
的引用压入到操作数栈顶,9: dup
将栈顶的引用复制一份并压入栈顶,11: monitorenter
获取lock
上的监视器锁,开始同步块。- 在同步块中,如果没有异常发生,会执行到
aload_2
和monitorexit
,释放lock
上的监视器锁,结束同步块。 - 如果在同步块中有异常发生,控制流会转到异常处理代码,
aload_2
和monitorexit
会释放lock
上的监视器锁,并重新抛出异常。
因此,无论同步块是否正常结束,都会确保lock
上的监视器锁被释放。这就是synchronized
代码块在字节码层面上的实现。
1. 基础知识
1.1 synchronized
关键字的作用和重要性
在Java中,synchronized
是一个关键字,用于控制多个线程对共享资源的访问。synchronized
能够保证一个线程在执行同步代码块的过程中,不会被其他线程打断,从而避免造成数据不一致的问题。
这就是所谓的“互斥”,即在同一时间内,只有一个线程能够执行某一段代码。这种机制可以防止多个线程同时修改一个数据,避免出现数据不一致的问题。这对于多线程编程来说极其重要,因为在没有适当的同步措施下,多个线程同时修改一个数据可能会导致程序的结果不可预知。
还有一种情况就是“可见性”,考虑这样一种情况,当一个线程修改了一份数据,而另一个线程需要读取这份数据,如果修改后的数据不能立即对其他线程可见,那么其他线程读取的就可能是旧的数据,这同样会导致数据不一致的问题。synchronized
关键字也能够保证可见性,确保修改后的数据对所有线程立即可见。
1.2 Java对象监视器
1.2.1 Java中的对象监视器概念
在Java中,每一个对象都可以作为一个监视器。这个监视器通过内部的一个监视器锁来实现的。当线程试图获取这个监视器的所有权时,它首先会试图去获取这个监视器锁。
当一个线程获取到对象的监视器锁时,这个线程便成为了这个监视器的所有者,其他线程如果也想要获取这个监视器的所有权,就必须等待当前所有者线程释放监视器锁。
Java中的synchronized
关键字,就是通过这种方式获取和释放对象监视器的。
1.2.2 对象监视器在多线程环境中的作用
对象监视器在多线程环境中起到同步和互斥的作用。
- 同步:Java中的
synchronized
关键字可以用来修饰方法或者代码块,当它修饰的是静态方法时,线程在调用这个方法的时候会尝试获取调用该方法的类的类对象的监视器锁;当它修饰的是实例方法时,线程在调用这个方法的时候会尝试获取调用该方法的对象的监视器锁;当它修饰的是代码块时,线程在执行这段代码的时候会尝试获取synchronized
后面括号里面对象的监视器锁。 - 互斥:当一个线程获取到了对象的监视器锁,其他线程就无法再获取到这个锁,只能在当前线程释放锁之后才有机会获取。这就实现了线程间的互斥,保证了线程安全。
通过对象监视器,Java可以在多线程环境中实现线程间的同步和互斥,保证了线程安全。
2. 基本原理
理解了synchronized
的作用和重要性后。它是如何保证线程间的互斥和可见性的?这背后的机制是什么?
实际上,当我们用synchronized
修饰方法或者代码块的时候,JVM会自动在这段代码前后插入特殊的指令(monitorenter
,monitorexit
)这个指令就是我们上面所说的对象监视器。来管理和控制线程的执行,从而实现线程同步。后面章节,我们着重了解这些指令的。
但是,synchronized
的实现原理远不止于此。它还涉及到底层操作系统的内存管理和线程调度等复杂的机制。例如,synchronized
会与Java内存模型(JMM)、操作系统内核的互斥锁、CPU的缓存一致性协议等概念密切相关。
2.1. Java层面的实现机制
在Java层面,synchronized
的实现主要依赖于JVM。当我们用synchronized
修饰方法或者代码块时,JVM会自动在这段代码前后插入特殊的指令(monitorenter,monitorexit)来管理和控制线程的执行。
monitorenter
:位于同步代码块的前端,表示当前线程尝试获取锁。如果获取成功,线程就可以执行同步代码块;如果失败,线程就会被阻塞,直到获取到锁。monitorexit
:位于同步代码块的后端,表示当前线程释放锁。
这样,synchronized
就可以实现线程间的互斥,保证同一时间只有一个线程能够执行某一段代码。
2.2. 操作系统层面的实现机制
在操作系统层面,synchronized
的实现主要依赖于操作系统的内存管理和线程调度机制。
当线程试图获取对象的锁时,如果该锁已经被其他线程持有,操作系统会让当前线程进入阻塞状态,并将其放入等待队列。当持有锁的线程执行完同步代码块并释放锁之后,操作系统会从等待队列中唤醒一个线程,并将锁分配给这个线程,这个线程就可以开始执行同步代码块了。
此外,操作系统还可以通过内存屏障
来保证线程间的可见性。内存屏障
是一种处理器指令,用于阻止特定类型的内存操作的重排序。通过插入内存屏障,可以确保某些操作的执行顺序,从而保证线程间的可见性。
通过以上两个层面的实现,synchronized
实现了线程间的同步,确保了线程安全。那接来我们聊聊从字节码层面聊聊synchronized关键字
3. 字节码指令与synchronized
关键字
我们先看一个synchronized
代码块的Java示例并解析出其字节码
public class SynchronizedBlockExample { public void method() { synchronized (this) { System.out.println("Synchronized Block"); } } }
编译后的字节码:
public class SynchronizedBlockExample { public void method(); Code: 0: aload_0 // 将"this"加载到操作数栈顶 1: dup // 复制操作数栈顶的值 2: astore_1 // 将操作数栈顶的值存储到局部变量表的第1个位置,也就是"this" 3: monitorenter // 为对象(此处即"this")加锁 4: getstatic #2 // 获取静态字段,此处是 java/lang/System.out:Ljava/io/PrintStream; 7: ldc #3 // 从常量池中加载字符串常量 "Synchronized Block" 9: invokevirtual #4 // 调用方法,此处是 java/io/PrintStream.println:(Ljava/lang/String;)V 12: aload_1 // 把局部变量表的第1个位置的值(即"this")加载到操作数栈顶 13: monitorexit // 为对象(此处即"this")解锁 14: goto 22 17: astore_2 // 将操作数栈顶的异常存储到局部变量表的第2个位置 18: aload_1 // 把局部变量表的第1个位置的值(即"this")加载到操作数栈顶 19: monitorexit // 为对象(此处即"this")解锁 20: aload_2 // 把局部变量表的第2个位置的值(即异常)加载到操作数栈顶 21: athrow // 抛出异常 22: return // 方法返回 Exception table: // 异常处理表 from to target type 4 14 17 any 17 20 17 any }
monitorenter
和monitorexit
无论是synchronized
方法还是synchronized
代码块,字节码层面的处理方式都是类似的,即通过monitorenter
和monitorexit
指令来实现加锁和解锁的操作。同时,无论异常是否被处理,JVM都会确保monitorexit
指令的执行,以确保锁定的对象能被正确释放。
在这个例子中,
synchronized (this)
块被编译为:
- 首先获取
this
对象的引用- 执行
monitorenter
指令,获取this
对象的锁- 执行
synchronized
块中的内容(即System.out.println("Synchronized Method")
)- 执行
monitorexit
指令,释放this
对象的锁
Exception table 异常处理表
我们前两个章节学习的时候,Exception table
当时说的是是用来处理我们显式声明的try-catch
块,指定异常的时候程序的跳转位置。但是在本示例中,我们并没有看到try-catch
块,但是在字节码中出现了Exception table
。其实这也是synchronized
关键字字节码指令的一个特性。所以Exception table 异常处理表
之前的描述相对来说比较局限。它也被用来处理一些隐式的运行时异常,以及一些Java语言特性的实现。
这个过程中,如果monitorenter
指令成功执行,但是synchronized
块的内容执行中发生了异常,monitorexit
指令就不会被正常执行。为了确保即使有异常发生,锁依然能被正确释放,Java编译器会在字节码中加入一个异常处理表,用来在发生异常时跳转到一个monitorexit
指令以释放锁,然后再重新抛出该异常。这就是为什么在这个例子的字节码中会有异常处理表的原因。
3.1 monitorenter
指令
monitorenter
是Java虚拟机的一个字节码指令,用于获取对象的锁。当代码块或方法前使用synchronized
修饰后,在编译成的字节码中就会出现这个指令。
在Java中,每个对象都有一个内置锁(也称为监视器锁或互斥锁)。当一个线程需要访问一个被synchronized
修饰的代码块或方法时,需要先获取这个对象的内置锁。
- 如果锁的计数器是0,表示这个锁没有被任何线程持有。这时,Java虚拟机会让请求的线程获取这个锁,并把锁的计数器设为1。然后线程就可以进入
synchronized
代码块或方法进行操作。 - 如果当前线程已经持有这个锁,比如在一个已经获取锁的
synchronized
方法或代码块中,再次请求获取同一个锁。这时monitorenter
会使锁的计数器增加1,这被称为锁的重入。 - 如果锁已经被其他线程持有,这时当前线程的
monitorenter
请求会失败,线程就会进入阻塞状态,等待锁的释放。 - 当锁被释放后(即其他线程执行了
monitorexit
),系统会从等待队列中唤醒一个或多个线程,让它们再次尝试获取锁。
monitorenter
和monitorexit
必须配对使用,每一个monitorenter
操作都需要对应一个monitorexit
操作来释放锁。
这种锁机制可以保证同一时间内,只有一个线程可以执行synchronized
修饰的代码块或方法,从而避免了线程间的数据竞争,达到了线程同步的效果。
3.2 monitorexit
指令
monitorexit
是Java虚拟机的一个字节码指令,用于释放对象的锁,它与monitorenter
指令相对。当synchronized
修饰的代码块或方法执行完后,在编译生成的字节码中就会出现这个指令。
在Java中,每个对象都有一个内置锁(也称为监视器锁或互斥锁)。当一个线程已经获取了一个对象的内置锁,执行了synchronized
修饰的代码块或方法后,需要释放这个锁,以让其他线程也能获取这个锁来访问这个代码块或方法。
当一个线程执行到monitorexit
指令时,Java虚拟机会检查这个线程是否是这个对象的锁的持有者。如果是,那么它会释放这个锁,并将锁的计数器减1。如果计数器变为0,表示锁完全被释放。
如果这个线程不是锁的持有者,那么monitorexit
指令会导致Java虚拟机抛出IllegalMonitorStateException
异常。
monitorenter
和monitorexit
必须配对使用,每一个monitorenter
操作都需要对应一个monitorexit
操作来释放锁。这种机制可以保证同一时间内,只有一个线程可以执行synchronized
修饰的代码块或方法,从而避免了线程间的数据竞争,达到了线程同步的效果。
3.3 指令在字节码层面上的作用和原理
在字节码层面上,monitorenter
和monitorexit
指令配合实现了synchronized
的语义。
当线程执行到monitorenter
指令时,它尝试获取对象的监视器锁。如果监视器锁未被其他线程持有,则该线程获取该锁并将锁的计数器设为1;如果当前线程已经持有该锁,那么它将仅仅将锁的计数器增加1;如果锁被其他线程持有,那么当前线程将被阻塞,进入等待状态,直到获取到锁为止。
当线程执行到monitorexit
指令时,如果当前线程持有该锁,它将锁的计数器减一,如果计数器的值变为0,那么锁就会被释放;如果当前线程并不持有该锁,那么将会抛出java.lang.IllegalMonitorStateException
异常。
所以,每一个monitorenter
必须要有相对应的monitorexit
,它们之间的执行逻辑必须完整,这样就能保证获取到的锁能够被正确释放。
4. monitor和异常处理
4.1 获取和释放对象监视器
在Java中,每个对象都有一个与之关联的监视器(monitor)。当线程需要进入synchronized
代码块时,它需要先获取这个对象的监视器。如果该监视器已经被另一个线程持有,那么当前线程就会阻塞,直到监视器被释放。
获得对象监视器实际上就是获得一个“锁”。这是通过monitorenter
指令实现的。当线程达到monitorenter
指令时,它会试图获取锁。如果锁已经被其他线程持有,则当前线程会进入阻塞状态,直到锁被释放。
当线程完成synchronized
代码块的执行或者遇到异常需要退出时,它需要释放对象监视器。这是通过monitorexit
指令实现的。释放监视器会唤醒等待该监视器的其他线程。
4.2 同步块的异常处理
如前面的字节码解析中所展示的,当发生异常时,控制流会转向异常处理部分,执行monitorexit
指令来释放监视器,然后重新抛出异常。这确保了即使在synchronized
代码块中发生异常,监视器仍然会被释放,避免了线程因无法获取到监视器而陷入无限等待的情况。
在Java中,异常处理是通过异常表来实现的。每个方法都可以有一个异常表,表中每项包括:开始PC、结束PC、处理异常的代码的起始PC、需要捕获的异常类型。当发生异常时,JVM会查找异常表,找到一个范围包含当前PC并且可以处理该类型异常的项,将PC设为该项的处理代码的起始PC,开始执行异常处理代码。如果找不到,异常会继续向上抛出。
因此,synchronized
代码块在字节码层面上的实现确保了无论代码块是否正常执行完毕,监视器锁都会被释放,这是通过JVM的异常处理机制和monitorexit
指令共同实现的。这样可以避免因为异常导致的死锁情况,提高了程序的健壮性。
5. synchronized
优化
5.1 JVM 在synchronized
上的优化策略
Java虚拟机为了提高synchronized
的性能,使用了一系列的优化策略,其中包括适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。
- 适应性自旋:自旋等待是指当一个线程尝试获取锁时,如果锁被其他线程占用,那么该线程不会立即挂起,而是选择进行循环等待,看是否能在短时间内获取锁。适应性自旋就是自旋等待的一种优化方式。自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
- 锁消除:锁消除是指编译器在运行期间,对一些代码要求同步,但是对象只会被一个线程使用,不存在多线程竞争的情况,此时JVM会取消对这部分代码的同步。
- 锁粗化:锁粗化是将多个连续的加锁、解锁操作合并为一次,扩大了锁的范围。
5.2 锁升级和锁消除等技术
Java虚拟机还使用了锁升级的技术来优化synchronized
。
- 偏向锁:偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
- 轻量级锁:当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋形式尝试获取锁,不会阻塞,提高性能。
- 重量级锁:当锁是轻量级锁的时候,另一个线程尝试获取锁,则会膨胀为重量级锁,线程进入阻塞状态。重量级锁会让所有请求的线程进入阻塞,效率较低。
锁的升级过程是单向的,也就是说,偏向锁只能升级为轻量级锁,轻量级锁只能升级为重量级锁,不会发生降级。