Synchronized会应用在一下三种场景当中:
public class Test {
public static void main(String[] args) {
new Test().hello();
}
//锁方法
public synchronized void hello(){
System.out.println("hello world");
}
}
我们使用javap-c可以查看Test类所对应的字节码文件如下:
//其余部分省略
public synchronized void hello();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #6 // String hello world
5: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 16: 0
line 17: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this LTest;
可以看到:flags中出现了两个属性,一个是ACC_PUNLIC,另外一个是ACC_SYNCHRONIZED。第一个属性是表示该类的访问类型为public,如果为ACC_PRIVATE就表示访问类型为private。第二个属性就表示该方法为同步方法,同一时间只允许一个线程对其进行操作。
代码块:在代码块中存在共享变量的操作,而该方法又存在多线程场景,那么我们一般会对该方法加上Synchronized关键字,这边举个例:
public class Test {
public static void main(String[] args) {
new Test().hello();
}
public void hello(){
//锁同步代码块
synchronized (this){
System.out.println("hello world");
}
}
}
用javap-c可以查看Test类所对应的字节码文件如下:
//其余部分省略
//这是加了锁的字节码
public void hello();
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 #5 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #6 // String hello world
9: invokevirtual #7 // 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
可以看到,在指令当中多了monitorenter,和 monitorexit指令,这两个指令的意思就是进入同步代码块,和出同步代码块的意思,中间的内容同一时间只能由一个线程访问。
静态方法:在静态方法中存在共享变量的操作,而该方法又存在多线程场景,那么我们一般会对该方法加上Synchronized关键字,这边举个例:
public class Test {
public static void main(String[] args) {
new Test().hello();
}
public synchronized static void hello(){
System.out.println("hello world");
}
}
用javap-c可以查看Test类所对应的字节码文件如下:
//其余部分省略
public static synchronized void hello();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=0, args_size=0
0: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #6 // String hello world
5: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 16: 0
line 17: 8
和普通方法相同,只不过多了static字段而已,但是其实还是有所不同,因为对于static的方法不是作用于对象头的,这一点后面我们再讲。
其实字节码层面是基于两种命令来实现的,因为我们知道,jvm会将Java文件编译为自己可以识别的字节码文件,也就是class文件。可以理解为Java文件的执行,其实就是字节码指令的执行。除了几个比较特殊的存在,几乎所有Java当中的功能的最终实现,其实都是jvm对字节码指令的执行得到的结果。这里举几个例子,大家看看就好:
本地方法调用(Native Method Invocation): Java 允许通过使用 native
关键字声明本地方法,这些方法的实现是用其他语言(如 C 或 C++)编写的。这些本地方法可以直接调用底层操作系统的功能或库。虽然本地方法使用字节码调用的方式,但它们涉及到与 Java 虚拟机外部环境的交互,因此不完全属于字节码执行范畴。
JNI(Java Native Interface): JNI 是一种允许 Java 代码与本地代码(如 C 或 C++)交互的机制。通过 JNI,Java 代码可以调用本地方法,并且本地代码也可以回调 Java 方法。这涉及到更多的底层交互,不仅仅是字节码指令的执行。
Java 虚拟机内部机制: Java 虚拟机实现了许多内部机制,如垃圾回收、类加载、字节码解释、即时编译等。这些机制在一定程度上超出了纯粹的字节码指令执行,涉及到虚拟机的内部逻辑和管理。
Java 标准库之外的外部库和框架: Java 生态系统包含许多外部的库和框架,例如 Spring、Hibernate、Netty 等。这些库和框架提供了更高级别的抽象和功能,它们的实现可能涉及多种技术和机制,不仅仅局限于字节码执行。
当然Synchronized是属于字节码指令执行的结果,对应的两个指令分别为:monitorenter,monitorexit。当带有Synchronized关键字的文件反编译后我们会发现存在一个monitorenter指令和两个monitorexit指令。当执行到monitorenter指令后程序进入同步状态,此时只允许一个线程进入执行该代码块。等到monitorexit执行以后,才允许别的线程来执行。我们可以看到一般一个monitorenter后面会跟着两个monitorexit,第一个monitorexit是结束同步,第二个monitorexit的意思是保证程序可以退出,可以参考finally()。
好了,到了今天的重点了,Synchronized它在底层究竟是怎么实现的呢。他其实是通过mark word(对象头)中的地址去找到一个叫做monitor的东西来实现的,要搞清楚这些东西,我们需要了解下mark word的结构,还有monitor这个东西具体是什么。
在我们聊对象头的结构之前,我们需要知道一个类的对象当中都包含了什么信息
在一个类被加载时,会为这个类生成一个专属的class对象。而这个类每次被实例化以后,都会为这个类生成一个实例对象,我们这里说的对象指的是后者。一个类的对象分为三个部分:对象头,实例变量,对其填充。
对象头:Mark word和一个指向类对象的指针。
实例变量:存放一些对象的基本信息,如果是普通类型数据的话就是一些值,如果是引用类型的话就是一个指向内存的指针。
对其填充:没有什么实际意义,因为jvm对象必须是8的整数倍,如果不满足这个条件的话这个字段会对其进行填补。
那Mark word又是个什么东西呢?
Mark word是jvm实现的一种可变的数据结构,里面包含了一个对象的hash码,gc年龄以及锁状态信息。为什么说它可变的,因为这个结构的前30位都可以用来表示不同的信息,后两位都是用来表示锁状态相关信息的。
因为Synchronized在jdk6以前,对于互斥量会直接加一个重量级的锁,即通过Mark word中monitor的地址去访问monitor,而monitor是由ObjectMonitor实现的,源码由c++实现,里面主要的属性如下图
这些属性我们在这里不做过多注释,因为已经超出了Java语言的范畴,我们主要研究下jdk6以后的一些变动。
虽然Synchronized可以保证数据的安全性,可是当锁住整个共享资源以后,其他访问共享资源的线程再次访问会进入阻塞状态,而阻塞的操作是非常耗费系统资源的。
因为Java的线程是映射到操作系统的原生线程之上的,一个线程的阻塞和唤醒是需要操作系统介入完成的,这就牵扯到了用户态向内核态的转换,这种操作是非常耗费系统资源的。因为用户态和内核态都有自己专用的内存空间,和专属的寄存器等,用户态切换内核态需要传递许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。了解
由此可以大致推测出Synchronized实现的锁是一个极其笨重的锁,一点也不灵活。其实实际上也是这样的。不过在jdk1.6以后,Synchronized加入了一个锁升级,锁粗化,锁消除等几个过程,这解决了在简单的同步代码块中,Synchronized过于笨重的问题,使Synchronized变成了一个很“灵活”的锁。至于锁升级的过程下面我们继续研究。
这是mark word的结构,可以看到,其中不仅仅有有关monitor的重量级锁,还包含了偏向锁,轻量级锁这两个字段。没错,这就是jdk6以后对于Synchronized进行的优化操作,具体的优化过程我们开始解密:
偏向锁
。
轻量级锁
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示:
至于为什么要复制一份Mark word的信息到Lock Record,我在刚刚看的时候是有疑惑的,最后明白了是要和原Mark word中数据对比,因为期间如果有其他线程来尝试获取锁了,并且该线程对markword做了修改,两者比对发现不一致,则切换到重量锁。
上面在介绍Synchronized作用于静态方法的时候,锁的是类,就不是对象头了。对于类级别的锁(静态synchronized
方法或代码块),情况略有不同。在Java虚拟机中,并没有为类的元数据(Class对象)分配对象头。因此,JVM不会直接使用对象头来实现对整个类的锁。相反,JVM在内部维护了一个用于类级别锁的数据结构。当一个线程尝试进入一个类级别的synchronized
代码块或方法时,JVM会使用该类的元数据(Class对象)作为锁标识,而不是使用对象头。这个锁标识实际上是一个指向内部数据结构的引用,而不是实际的对象头。这使得可以在没有类实例的情况下锁定整个类,也就是类级别的锁。
这就是Synchronized的锁升级的过程
总结:本文我们从Java语言,字节码,jvm三个层面介绍了Synchronized的功能,作用场景,原理等一些内容,如有问题欢迎指正。