一. 概述
1. Synchronized锁升级的原因
用锁能够实现数据的安全性,但是会带来性能下降。无锁能够基于线程并行提升程序性能,但是会带来安全性下降。
2. Synchronized锁升级的过程
无锁-> 偏向锁 -> 轻量级锁 -> 重量级锁
3. 早期synchronized效率低的原因
Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
在Java早期版本中,synchronized属于重量级,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长,时间成本相对较高。
3.1 Monitor
Monitor可以理解为一种同步工具,也可理解为一种同步机制,常常被描述为一个Java对象。Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。(所以每个对象都能成为一把锁)
Monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的转换,成本非常高。
3.2 Mutex Lock
Monitor是在jvm底层实现的,底层代码是c++。本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的转换,状态转换需要耗费很多的处理器时间成本非常高。所以synchronized是Java语言中的一个重量级操作。
Monitor与java对象以及线程是如何关联 ?
1.如果一个java对象被某个线程锁住,则该java对象的Mark Word字段中LockWord指向monitor的起始地址
2.Monitor的Owner字段会存放拥有相关联对象锁的线程id
3.3 Synchronized的性能变化
Java5之前,只有重量级锁synchronized,需要用户态和内核态之间的切换。
Java6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁。
4. Synchronized锁升级的策略
Synchronized用的锁是存在Java对象头里的MarkWord中,锁升级功能主要依赖MarkWord中锁标志位和释放偏向锁标志位
二. Synchronized锁种类及升级步骤
引入如下依赖,执行 System.out.println(ClassLayout.parseInstance(o).toPrintable()),可打印Java对象在内存中的内存布局
<!--
官网:http://openjdk.java.net/projects/code-tools/jol/
定位:分析对象在JVM的大小和分布
-->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
1. 总体设计
synchronized在修饰方法和代码块在字节码上实现方式有很大差异,但是内部实现还是基于对象头的MarkWord来实现的,MarkWord64位图如下:
2. 无锁
2.1 MarkWord64位
MarkWord64位如下(倒着看):
00000001 00000000 00000000 00000000
00000000 00000000 00000000 00000000
前25位不使用,接着31位表示hashCode(如何调用则显示具体的哈希值,否则默认为0),接着1位不使用,接着4位表示分代年龄(因不存在GC,故为0),下1位标识偏向锁(0),最后2位是锁标志位(01) 故最后3位001表示无锁
@Test
public void nl() {
Object o = new Object();
System.out.println("无锁--------------------------");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
3. 偏向锁
3.1 MarkWord64位
偏向锁在JDK1.6之后是默认开启的,但是启动时间有延迟,所以需要添加参数
-XX:BiasedLockingStartupDelay=0,让其在程序启动时立刻启动。
MarkWord64位如下(倒着看):
00000101 10100000 10001111 00011011
00000000 00000000 00000000 00000000
前54位表示当前线程指针,最后3位101表示偏向锁
@Test
public void bl(){
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
System.out.println("偏向锁--------------------------");
//设置参数 -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0,关闭延时
new Thread(()->{
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
},"bl").start();
}
3.2 偏向锁的作用
当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁。多线程的情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。
3.3 偏向锁的持有
理论落地:
在实际应用运行过程中发现,“锁总是同一个线程持有,很少发生竞争”,也就是说锁总是被第一个占用他的线程拥有,这个线程就是锁的偏向线程。
那么只需要在锁第一次被拥有的时候,记录下偏向线程ID。这样偏向线程就一直持有着锁(后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁)。
如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了,直到竞争发生才释放锁。以后每次同步,检查锁的偏向线程ID与当前线程ID是否一致,如果一致直接进入同步。无需每次加锁解锁都去CAS更新对象头。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
假如不一致意味着发生了竞争,锁已经不是总是偏向于同一个线程了,这时候可能需要升级变为轻量级锁,才能保证线程间公平竞争锁。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
技术实现:
一个synchronized方法被一个线程抢到了锁时,那这个方法所在的对象就会在其所在的Mark Word中将偏向锁修改状态位,同时还会有占用前54位来存储线程指针作为标识。若该线程再次访问同一个synchronized方法时,该线程只需去对象头的Mark Word 中去判断一下是否有偏向锁指向本身的ID,无需再进入 Monitor 去竞争对象了。
3.4 偏向锁的好处
偏向锁的操作不用直接捅到操作系统,不涉及用户到内核转换,不必要直接升级为最高级。JVM不用和操作系统协商设置Mutex(争取内核),它只需要记录下线程ID就标示自己获得了当前锁,不用操作系统介入。在没有其他线程竞争的时候,一直偏向偏心当前线程,当前线程可以一直执行。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
3.5 偏向锁的撤销
偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来线程才会被撤销。撤销需要等待全局安全点(该时间点上没有字节码正在执行),同时检查持有偏向锁的线程是否还在执行:
1. 第一个线程正在执行synchronized方法(处于同步块),它还没有执行完,其它线程来抢夺,该偏向锁会被取消掉并出现锁升级。此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁
2. 第一个线程执行完成synchronized方法(退出同步块),则将对象头设置成无锁状态并撤销偏向锁,重新偏向。
3.6 代码示例
以卖电影票为例,50张票很夸张的全由线程t1卖出,t2,t3,t4没有卖出一张,就是基于偏向锁
class TrainTicket {
private int number = 50;
Object objectLock = new Object();
public void sale() {
synchronized (objectLock) {
if (number > 0) {
System.out.println(Thread.currentThread().getName() + "卖出倒数第" + (number--) + "张票");
}
}
}
}
public class BiasedLockDemo {
public static void main(String[] args) {
TrainTicket trainTicket = new TrainTicket();
new Thread(() -> {
for (int i = 0; i < 55; i++) {
trainTicket.sale();
}
}, "t1").start();
new Thread(() -> {
for (int i = 0; i < 55; i++) {
trainTicket.sale();
}
}, "t2").start();
new Thread(() -> {
for (int i = 0; i < 55; i++) {
trainTicket.sale();
}
}, "t3").start();
new Thread(() -> {
for (int i = 0; i < 55; i++) {
trainTicket.sale();
}
}, "t4").start();
}
}
4. 轻量级锁
4.1 MarkWord64位
关闭偏向锁之后程序默认会直接进入-------------轻量级锁状态,所以添加参数
-XX:-UseBiasedLocking,关闭偏向锁。
MarkWord64位如下(倒着看):
00000000 11110010 10000010 00011100
00000000 00000000 00000000 00000000
前62位表示指向线程栈中Lock Record的指针,最后2位00表示轻量级锁
public class LightLockDemo {
public static void main(String[] args) {
Object o = new Object();
System.out.println("轻量级锁--------------------------");
//设置参数 -XX:-UseBiasedLocking 关闭偏向锁
new Thread(()->{
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
},"bl").start();
}
}
4.2 轻量级锁的作用
轻量级锁是为了在线程近乎交替执行同步块时提高性能。主要目的:在没有多线程竞争的前提下,通过CAS减少重量级锁使用操作系统互斥量产生的性能消耗,也就是先自旋再阻塞。
4.3 轻量级锁的获取
当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量级锁。
假如线程A已经拿到锁,这时线程B又来抢该对象的锁,由于该对象的锁已经被线程A拿到,当前该锁已是偏向锁了。而线程B在争抢时发现对象头MarkWord中的线程ID不是线程B自己的线程ID(而是线程A),那线程B就会进行CAS操作希望能获得锁。
此时线程B操作中有两种情况:如果锁获取成功,直接替换Mark Word中的线程ID为B自己的ID(A → B),重新偏向于其他线程(即将偏向锁交给其他线程,相当于当前线程"被"释放了锁),该锁会保持偏向锁状态,A线程Over,B线程上位;如果锁获取失败,则偏向锁升级为轻量级锁,此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程B会进入自旋等待获得该轻量级锁。
4.4 轻量级锁的升级
自旋达到一定次数依然没有成功时,升级为重量级锁。JDK6以后,自旋次数是自适应的,根据同一个锁上一次自旋的时间和拥有锁线程的状态来决定。
4.5 轻量锁与偏向锁的区别和不同
轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁
5. 重量级锁
5.1 MarkWord64位
MarkWord64位如下(倒着看):
00001010 11111111 01111000 00011010
00000000 00000000 00000000 00000000
前62位表示指向互斥量(重量级锁)的指针,最后2位10表示重量级锁
public class HeavyLockDemo {
private static Object objectLock = new Object();
public static void main(String[] args) {
System.out.println("--------------------重量级锁");
new Thread(()->{
synchronized (objectLock){
System.out.println(ClassLayout.parseInstance(objectLock).toPrintable());
}
},"t1").start();
new Thread(()->{
synchronized (objectLock){
System.out.println(ClassLayout.parseInstance(objectLock).toPrintable());
}
},"t2").start();
new Thread(()->{
synchronized (objectLock){
System.out.println(ClassLayout.parseInstance(objectLock).toPrintable());
}
},"t3").start();
}
}
5.2 重量级锁的作用
适用于有大量的线程参与锁的竞争,冲突性很高
6. 总结
Synchronized锁升级过程总结:先自旋,不行再阻塞。实际上是把之前的悲观锁(重量级锁)变成在一定条件下使用偏向锁以及使用轻量级(自旋锁CAS)的形式。
Synchronized在修饰方法和代码块在字节码上实现方式有很大差异,但是内部实现还是基于对象头的MarkWord来实现的。JDK1.6之前synchronized使用的是重量级锁,JDK1.6之后进行了优化,拥有了无锁->偏向锁->轻量级锁->重量级锁的升级过程,而不是无论什么情况都使用重量级锁。
偏向锁: 适用于单线程适用的情况,在不存在锁竞争的时候进入同步方法/代码块则使用偏向锁。
轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似), 存在竞争时升级为轻量级锁,轻量级锁采用的是自旋锁,如果同步方法/代码块执行时间很短的话,采用轻量级锁虽然会占用cpu资源但是相对比使用重量级锁还是更高效。
重量级锁:适用于竞争激烈的情况,如果同步方法/代码块执行时间很长,那么使用轻量级锁自旋带来的性能消耗就比使用重量级锁更严重,这时候就需要升级为重量级锁。
锁的优缺点对比