一、概述
synchronized 是由一对 monitorenter/monitorexit 指令实现的,monitor 对象是同步的基本实现单元。在JVM处理字节码会出现相关指令。
在 Java 6 之前,monitor 的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作,性能也很低。
但在 Java 6 的时候,Java 虚拟机 对此进行了大力的改进,提供了三种不同的 monitor 实现,也就是常说的三种不同的锁:偏向锁(Biased Locking)、轻量级锁和重量级锁,大大改进了其性能。
这个monitor监视器锁到底是什么呢?
在Java中 ,每一个Java对象都有一把看不见的锁,它叫做内部锁或者Monitor锁。也就是通常说Synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于
HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):
1 ObjectMonitor() {
2 _header = NULL;
3 _count = 0; // 记录个数
4 _waiters = 0,
5 _recursions = 0;
6 _object = NULL;
7 _owner = NULL;
8 _WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
9 _WaitSetLock = 0 ;
10 _Responsible = NULL ;
11 _succ = NULL ;
12 _cxq = NULL ;
13 FreeNext = NULL ;
14 _EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
15 _SpinFreq = 0 ;
16 _SpinClock = 0 ;
17 OwnerIsThread = 0 ;
18 }
1.monitorenter:线程执行该命令尝试获取monitor锁的所有权,
1>monitor的进入数为0的时候,该线程成功进入monitor,将进入数设置为1,该进程成为monitor的所有者;
2>monitor的进入数大于0,且该进程为monitor的所有者,则该进程重新进入,进入数加1;
3>monitor的进入数大于0,但是该monitor的所有者并不是当前线程,则该线程进入阻塞状态,直至monitor的进入数为0后,再重新尝试获取monitor的所有权。
2.monitorexit:执行该命令的线程必须是monitor的所有者,执行指令的时候monitor的进入数会减1,直至进入数为0,那该线程退出monitor,不再是该monitor的所有者。其它被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
二、实现原理
jvm基于进入和退出Monitor对象来实现方法同步和代码块同步。
1>方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。
2>代码块的同步是利用monitorenter和monitorexit这两个字节码指令。它们分别位于同步代码块的开始和结束位置。当jvm执行到monitorenter指令时,当前线程试图获取monitor对象的所有权,如果未加锁或者已经被当前线程所持有,就把锁的计数器+1;当执行monitorexit指令时,锁计数器-1;当锁计数器为0时,该锁就被释放了。如果获取monitor对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。
注意:1>synchronized是可重入的,所以不会自己把,自己锁死;
2>synchronized锁一旦被一个线程持有,其他试图获取该锁的线程将被阻塞。
public class SynchronizedDemo {
public synchronized void f(){ //这个是同步方法
System.out.println("Hello world");
}
public void g(){
synchronized (this){ //这个是同步代码块
System.out.println("Hello world");
}
}
public static void main(String[] args) {
}
}
使用javap -verbose SynchronizedDemo
反编译后得到:
三、锁优化
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。
实例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
HotSpot虚拟机的对象头分为两部分信息,第一部分用于存储对象自身运行时数据,如哈希码、GC分代年龄等,这部分数据的长度在32位和64位的虚拟机中分别为32位和64位。官方称为Mark Word。另一部分用于存储指向对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分存储数组长度。
由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间。
Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”:锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级。
1、偏向锁
偏向锁是JDK1.6中引用的优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的性能。
偏向锁的获取:
1>判断是否为可偏向状态
2>如果为可偏向状态,则判断线程ID是否是当前线程,如果是进入同步块;
3>如果线程ID并未指向当前线程,利用CAS操作竞争锁,如果竞争成功,将Mark Word中线程ID更新为当前线程ID,进入同步块
4>如果竞争失败,等待全局安全点,准备撤销偏向锁,根据线程是否处于活动状态,决定是转换为无锁状态还是升级为轻量级锁。
当锁对象第一次被线程获取的时候,虚拟机会把对象头中的标志位设置为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word中,如果CAS操作成功。持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
偏向锁的释放:
偏向锁使用了遇到竞争才释放锁的机制。偏向锁的撤销需要等待全局安全点,然后它会首先暂停拥有偏向锁的线程,然后判断线程是否还活着,如果线程还活着,则升级为轻量级锁,否则,将锁设置为无锁状态。
2、轻量级锁
轻量级锁也是在JDK1.6中引入的新型锁机制。它不是用来替换重量级锁的,它的本意是在没有多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
加锁过程:
在代码进入同步块的时候,如果此对象没有被锁定(锁标志位为“01”状态),虚拟机首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word)。然后虚拟机使用CAS操作尝试将对象的Mark Word更新为指向锁记录(Lock Record)的指针。如果更新成功,那么这个线程就拥有了该对象的锁,并且对象的Mark Word标志位转变为“00”,即表示此对象处于轻量级锁定状态;如果更新失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块中执行,否则说明这个锁对象已经被其他线程占有了。如果有两条以上的线程竞争同一个锁,那轻量级锁不再有效,要膨胀为重量级锁,锁标志变为“10”,Mark Word中存储的就是指向重量级锁的指针,而后面等待的线程也要进入阻塞状态。
解锁过程:
如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作将对象当前的Mark Word与线程栈帧中的Displaced Mark Word交换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁比传统重量级锁开销更大。
3、重量级锁
Synchronized的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。
4、自旋锁
互斥同步对性能影响最大的是阻塞的实现,挂起线程和恢复线程的操作都需要转入到内核态中完成,这些操作给系统的并发性能带来很大的压力。
于是在阻塞之前,我们让线程执行一个忙循环(自旋),看看持有锁的线程是否释放锁,如果很快释放锁,则没有必要进行阻塞。
5、锁消除
锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是检测到不可能发生数据竞争的锁进行消除。
6、锁粗化
如果虚拟机检测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
举一个例子来实际观察一下加锁的过程吧
引入对象头分析工具:运行时对象头锁状态分析工具JOL,他是OpenJDK开源工具包
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol‐core</artifactId>
<version>0.10</version>
</dependency>
// 打印markword(其中object为锁对象)
System.out.println(ClassLayout.parseInstance(object).toPrintable());
1>新建一个对象
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
运行结果如下:
处于无锁的状态,hashcode为0:
2>为该对象加锁
2-1>无其它线程竞争
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
运行结果如下:
升级轻量级锁
为什么不是升级为偏向锁,而是跳级升级为轻量级锁呢?答:因为JVM启动时候,本身就存在很多的线程需要进行启动,也就会存在一定的竞争(例如:new一些对象、存在synchronize同步块等),为了避免减少“偏向锁 -> 轻量级锁 ->重量级锁”的这个升级过程所导致的开销。而是延迟启动偏向锁。
延迟启动5s后,发现变为了偏向锁(可以通过-XX:-UseBiasedLocking关掉偏向锁的功能)
TimeUnit.SECONDS.sleep(5);
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
又出现了另一个问题,为什么睡眠5s后new一个对象的时候就是偏向锁呢,这个状态属于匿名偏向(没有指向任何线程),指的是当前对象可偏向,属于可偏向的状态。
2-2>两个线程顺序执行
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
new Thread(()->{
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}).start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(ClassLayout.parseInstance(o).toPrintable());
new Thread(()->{
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}).start();
运行结果如下:偏向锁 -> 轻量级锁
2-3>两个线程同时竞争
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
Thread thread1 = new Thread(){
@Override
public void run() {
synchronized (o){
System.out.println("t1 locking");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
try {
// 假设t1拿到了锁
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread thread2 = new Thread(){
@Override
public void run() {
synchronized (o){
System.out.println("t2 locking");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
try {
// 当t1获取到锁后,此时如果有线程t2也要访问,则只能进行等待(搞一个循环判断lock的使用情况-》自旋,会一直占用CPU,这样其他线程获取到线程后可以立刻执行下面的业务代码,耗时较短,方便下一个线程的调用
// ,减少了将当前线程中断,把下一个线程加载到CPU中进行业务执行的时间损耗)。但是spin超过指定的次数后会升级成为重量级锁
// while (lock) { // 偏向锁、轻量级锁
//
// }
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
thread1.start();
thread2.start();
运行结果如下:偏向锁 -> 重量级锁
随着锁升级,hashcode存在哪里了呢?
无锁:对象头中
偏量级锁:System.lazy中的方法获取
轻量级锁:lock record(markword)
重量级锁:记录在Monitor中