synchronized 的用法可以分为三种,分别为同步实例方法、同步静态方法和同步代码块。
当一个类中的普通方法被 synchronized 修饰时,相当于对 this 对象加锁,这个方法被声明为同步方法。此时,多个线程并发调用同一个对象实例中被 synchronized 修饰的方法是线程安全的。
public synchronized void testMethod(){
// 业务逻辑
}
可以在 Java 的静态方法上添加 synchronized 关键字来对其进行修饰,当一个类的某个静态方法被 synchronized 修饰时,相当于对这个类的 Class 对象加锁,而一个类只对应一个 Class 对象。此时,无论创建多少个当前类的对象调用被 synchronized 修饰的静态方法,这个方法都是线程安全的。
public static synchronized void testMethod(){
// 业务逻辑
}
synchronized 关键字修饰方法可以保证当前方法是线程安全的,但如果修饰的方法临界区较大,或者方法的业务逻辑过多,则可能影响程序的执行效率。
synchronized 修饰代码块可以分为两种情况,一种情况是对某个对象加锁,另一种情况是对类的 Class 对象加锁。
public void testMethod(){
synchronized(obj) {
// 业务逻辑
}
}
public void testMethod(){
synchronized(Test.class) {
// 业务逻辑
}
}
从图中可以看出,一个类生成的对象实例会存储在 JVM 的堆区。一个完整的 Java 对象除了包括在类中定义的成员变量和方法等信息,还会包括对象头,对象头又包括 Mark Word、类型指针和数组长度,而数组长度只在当前对象是数组时才存在。同时为了满足 JVM 中对象的起始地址必须是 8 的整数倍的要求,对象在 JVM 堆区中的存储结构还会有一部分对齐填充位。
一个类的类元信息会存储在 JVM 的方法区中,对象头的类型指针会指向存储方法区中的类元信息。
类元信息:类元信息在类编译期间放入方法区,里面放置了类的基本信息,包括类的版本、字段、方法、接口以及常量池表(Constant Pool Table)。
Java 中的对象头一般占用 2 个机器码的存储空间。在 32 位 JVM 中,1 个机器码占用 4 字节的存储空间,也就是 32 位;而在 64 位 JVM 中,1 个机器码占用 8 字节的存储空间,也就是 64 位。
对象头中存储了对象的 Hash 码、对象所属的分代年龄、对象锁、锁状态、偏向锁的 ID(获得锁的线程 ID)、获得偏向锁的时间戳等,如果当前对象是数组对象,则对象头中还会存储数组的长度信息。
所以,如果当前对象是数组对象,则对象头会占用 3 个机器码空间,多出来的一个机器码空间用于存储数组的长度。
实例数据部分主要存储的是对象的成员变量信息,例如,存储了类的成员变量的具体值,也包括父类的成员变量值,在 JVM 中,这部分的内存会按照 4 字节进行对齐。
在 HotSpot JVM 中,对象的起始位置地址必须是 8 的整数倍。对象头占用的存储空间已经是 8 的整数倍,所以如果当前对象的实例变量占用的存储空间不是 8 的整数倍,则需要使用填充数据来保证 8 字节的对齐。
如果当前对象的实例变量占用的存储空间是 8 的整数倍,则不需要使用填充数据来保证字节对齐,也就是说,填充数据不是必须存在的,它的存在仅仅是为了进行 8 字节的对齐。
Java 中的对象头可以进一步分为 Mark Word、类型指针和数字长度三部分。
Mark Word 主要用来存储对象自身的运行时数据,例如:对象的 Hash 码、GC(垃圾回收)的分代年龄,锁的状态标志、对象的线程锁状态信息、偏向线程 ID、获得偏向锁的时间戳等。在 Java 中,Mark Word 是实现偏向锁和轻量级锁的关键。
Mark Word 字段的长度与 JVM 的位数相关,在 32 位 JVM 中,Mark Word 占用 32 位存储空间;在 64 位 JVM 中,Mark Word 占用 64 位存储空间。
由于对象头所占用的存储空间与对象自身存储的数据无关,所以对象头占用的是额外的空间, JVM 为了提高存储效率,将 Mark Word 设计成一个非固定的数据结构,以便能够在 Mark Word 中存储更多信息。同时,Mark Word 会随着程序的运行发生一系列的变化。
32 位 JVM 中 Mark Word 的结构如图所示
我们重点看一下 64 位 JVM 中 Mark Word 的结构:
不同位数的 JVM 的类型指针所占用的位数也不同。在 32 位 JVM 中,类型指针占用 32 位存储空间;而在 64 位 JVM 中,类型指针占用 64 位存储空间。
如果当前对象是数组类型的,则在对象头中还需要额外的空间存储数组的长度信息。数组的长度纤细在不同位数的 JVM 所占用的存储空间是不同的。在 32 位 JVM 中,数组长度占用 32 位存储空间;而在 64 位 JVM 中,数组长度占用 64 位存储空间。
JOL 工具包能够比较精确地分析对象在 JVM 中的结构,也可以计算某个对象的大小。
<dependency>
<groupId>org.openjdk.jolgroupId>
<artifactId>jol-coreartifactId>
<version>0.11version>
dependency>
新建 MyObject 测试类
public class MyObject {
private int count = 0;
public int getCount() {
return count;
}
public void setCount(int count) {
this.count = count;
}
}
接下来,创建 ObjectSizeAnalysis 类
public class ObjectSizeAnalysis {
public static void main(String[] args) {
MyObject obj = new MyObject();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
运行 ObjectSizeAnalysis 中的 main 方法,结果如下:
测试代码如下:
public class ObjectLockAnalysis {
public static void main(String[] args) throws InterruptedException {
printNormalLock();
}
/**
* 打印锁信息
*/
private static void printNormalLock() throws InterruptedException {
//创建测试类对象
MyObject obj = new MyObject();
//打印对象信息,此时对象处于无锁状态
System.out.println("打印对象信息,此时对象处于无锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj) {
//打印对象信息,此时对象处于轻量级锁状态
System.out.println("打印对象信息,此时对象处于轻量级锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
//计算对象的Hashcode值
System.out.println("计算对象的Hashcode值=====" + obj.hashCode());
//计算处于轻量级状态的对象的HashCode值,轻量级锁会膨胀为重量级锁
System.out.println("计算处于轻量级状态的对象的HashCode值,轻量级锁会膨胀为重量级锁=====" + ClassLayout.parseInstance(obj).toPrintable());
}
synchronized (obj) {
//打印对象信息,此时对象处于重量级锁状态
System.out.println("打印对象信息,此时对象处于重量级锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
}
//打印对象信息,此时对象处于重量级锁状态
System.out.println("打印对象信息,此时对象处于重量级锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
}
}
通过 printNormalLock() 方法的输出结果,我们可以看出如下信息
上面的打印对象锁状态中没有输出偏向锁的状态,这是由于 Java 中的偏向锁默认在 JVM 启动几秒之后才会激活。在 ObjectLockAnalysis 类中新增 printBiasLock() 方法来打印偏向锁信息,代码如下:
public class ObjectLockAnalysis {
public static void main(String[] args) throws InterruptedException {
printBiasLock();
}
/**
* 打印偏向锁信息
*/
private static void printBiasLock() throws InterruptedException {
//Java中的偏向锁在JVM启动几秒之后才会被激活
//所以程序启动时先休眠5秒钟,等待激活偏向锁
//否则会出现一些没必要的锁撤销
Thread.sleep(5000);
//创建测试类对象
MyObject obj = new MyObject();
//打印对象信息,此时对象处于偏向锁状态
System.out.println("打印对象信息,此时对象处于偏向锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj) {
//打印对象信息,此时对象处于偏向锁状态
System.out.println("打印对象信息,此时对象处于偏向锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
//计算对象的Hashcode值
System.out.println("计算对象的Hashcode值=====" + obj.hashCode());
//计算处于偏向锁状态的对象的HashCode值,偏向锁会膨胀为重量级锁
System.out.println("计算处于偏向锁状态的对象的HashCode值,偏向锁会膨胀为重量级锁=====" + ClassLayout.parseInstance(obj).toPrintable());
}
synchronized (obj) {
//打印对象信息,此时对象处于重量级锁状态
System.out.println("打印对象信息,此时对象处于重量级锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
}
//打印对象信息,此时对象处于重量级锁状态
System.out.println("打印对象信息,此时对象处于重量级锁状态=====" + ClassLayout.parseInstance(obj).toPrintable());
}
}
通过 printBiasLock() 方法的输出结果,我们可以看出如下信息
对比上面两个方法的打印信息,我们可以发现如下特点:
synchronized 是基于 JVM 中的 Monitor 锁实现的,从 Java 1.6 版本开始,对 synchronized 锁进行了大量的优化,引入了锁粗化、锁消除、偏向锁、轻量级锁、适应性自旋等技术来提升 synchronized 锁的性能。
当 synchronized 修饰方法时,当前方法会比普通方法在常量池中多一个「ACC_SYNCHRONIZED」标识符
该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,则当前线程将先获取 monitor 对象。同一时刻,只会有一个线程获取 monitor 对象成功,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。从而保证了同一时刻只能有一个线程进入被 synchronized 修饰的方法中执行方法体的逻辑。
当 synchronized 修饰方法时,synchronized 关键字会被编译成「monitorenter」和「monitorexit」两条指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
从图中可以看出,当源码中使用了 synchronized 修饰代码块,源码被编译成字节码后,同步代码块的逻辑前后会分别被添加 monitorenter 和 monitorexit 两条指令,使得同一时刻只能有一个线程进入 monitorenter 和monitorexit 两条指令中间的同步代码块。
synchronized 底层是基于 Monitor 锁实现的,而 Monitor 锁是基于操作系统的 Mutex 锁实现的,Mutex 锁是操作系统级别的重量级锁,其性能较低。
在 Java 中,创建出来的任何一个对象在 JVM 中都会关联一个 Monitor 对象,当 Monitor 对象被一个 Java 对象持有之后,这个 Monitor 对象处于锁定状态,synchronized 在 JVM 底层本质上都是基于进入和退出 Monitor 对象来实现同步方法和同步代码块的。
测试代码如下:
public class SynchronizedDecompileTest {
public synchronized void syncMethod(){
System.out.println("hello synchronized method");
}
public void synCodeBlock(){
synchronized (this){
System.out.println("hello synchronized code block");
}
}
}
使用「javac -g」将源代码编译成 .class 文件。
使用「javap -v」将 .class 文件进行反编译,代码如下:
public synchronized void syncMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello synchronized method
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 4: 0
line 5: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this LSynchronizedDecompileTest;
从输出结果我们可以看到,syncMethod 方法在反编译后的 flags 中会有一个 ACC_SYNCHRONIZED 标识符。当调用 syncMethod 方法时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 Monitor 锁对象,获取成功之后才能执行方法体,方法执行完后再释放 Monitor。在方法执行期间,其他任何线程都无法再获得同一个 Monitor 对象,从而保证了被 synchronized 修饰的方法同一时刻只能被一个线程执行。
测试代码如下:
public class SynchronizedDecompileTest {
public void synCodeBlock(){
synchronized (this){
System.out.println("hello synchronized code block");
}
}
}
public void synCodeBlock();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String hello synchronized code block
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit
14: goto 22
17: astore_2
18: aload_1
19: monitorexit
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
17 20 17 any
从输出就可以看出,当 synchronized 修饰代码块时,会在编译出的字节码中插入 monitorenter 指令和 monitorexit 指令,如下所示
3: monitorenter
13: monitorexit
19: monitorexit
在 JVM 的规范中也有对上面两个指令 monitorenter、monitorexit 的描述,我简单总结一下对 monitorenter 的描述:
每个对象都有一个监视器锁(monitor)。当 monitor 被占用时就会处于锁定状态,线程执行 monitorenter 指令时首先会尝试获取 monitor 的所有权,整个流程如下:
JVM 规范中对于 monitorexit 指令的描述原文如下:
在执行 monitorexit 指令时,monitor 的计数会减 1。如果减 1 后 monitor 的计数为 0,则当前线程将退出 monitor,不再是当前 monitor 的所有者。其他被阻止进入当前 monitor 的线程可以尝试再次获取当前 monitor 的所有权。
参考: 《深入理解高并发编程》