文章目录
- 1、轻量锁
- 2、轻量锁的作用
- 3、轻量锁的加锁和释放
- 4、轻量级锁的代码演示
- 5、重量级锁
- 6、重量级锁的原理
- 7、锁升级和hashcode的关系
- 8、锁升级和hashcode关系的代码证明
- 9、synchronized锁升级的总结
- 10、JIT编译器对锁的优化:锁消除和锁粗化
- 11、结语
📕相关笔记:
【synchronized锁升级之 无锁】
【synchronized锁升级之 偏向锁】
1、轻量锁
前面一篇提到偏向锁,即只有一个线程在竞争,此时通过资源类对象的对象头的Mark Word来标记,避免了用户态和内核态的频繁切换。
再往下,又来了一个线程也来竞争这个锁,且此时这两个线程近乎可以错开交替执行(或者说同步代码块/方法执行一次时间很短,哪怕另一个线程等,也不会等太久),如下图的1、2、3、4标号:
这就是轻量级锁的出现场景:有线程来参与竞争了,但不存在锁竞争太过激烈的情况,获取锁的冲突时间极端,本质就是CAS自旋锁,不要直接往重锁走。对应的共享对象内存图:
2、轻量锁的作用
轻量锁是为了在两个线程近乎交替执行同步块时来提高性能。
直白说就是先CAS自旋,不行了再考虑升级为重锁,使用操作系统的互斥量。升级到轻量锁的时机有:
- 关闭了偏向锁
- 多线程竞争偏向锁,可能导致偏向锁升级为轻量锁(这里写可能,是因为如果恰好是一个线程over,一个线程上位,则依旧是偏向锁)
举个例子:比如现有A线程拿到了锁,A一个人走偏向锁玩了一会儿后,线程B来了,B在争抢时发现共享对象的对象头中Mark Word里的线程ID标记不是线程B的ID(而是线程A),此时,B线程通过CAS来尝试修改标记。当:
- 此时线程A刚好Over,B上位,修改Mark Word里的线程ID为B,此时,仍为偏向锁,且偏向B
- 如果A正在执行,B修改失败,则升级为轻量级锁,且轻量级锁继续由原来的线程A持有,接着执行刚才没执行完的,而线程B则自旋等待获取这个轻量级锁
3、轻量锁的加锁和释放
加锁:
JVM会在线程的栈帧中创建用于存储锁记录Lock Record的空间,称为Displaced Mark Word。
若一个线程获得锁时发现是轻量级锁,会把对象锁的MarkWord复制到自己的Displaced Mak Word里面。然后线程尝试用CAS将锁的MarkWord替换为指向锁记录的指针。如下面两幅草图示意的变化过程:
如果替换成功,当前线程获得轻量锁。如果失败,表示Mark Word已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁(自旋一定次数后仍未获得锁,升级为重量锁)。
轻量级锁的释放:
在释放锁时,当前线程会使里CAS操作将Displaced Mark Word的内容复制回对象锁的Mark Word里面。如果没有发生竞争。那么这个复制的操作会成功。如果持有锁期间有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释放锁并唤醒被阻塞的线程。
4、轻量级锁的代码演示
-XX:-UseBiasedLocking
添加JVM参数,关闭偏向锁,就可以直接进入轻量级锁:
Object object = new Object();
new Thread(() -> {
synchronized (object){
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
}).start();
运行:
轻量锁下,自旋达到一定次数或者说程度,会升级为重量锁:
- Java6之前,默认情况下自旋的次数是10次或者自旋的线程数超过了cpu核数的一半,可
-XX:PreBlockSpin=10
来修改 - Java6之后,JVM做了优化,采用
自适应自旋
自适应自旋,即线程如果自旋成功了,那下次自旋的最大次数会增加,因为JVM认为既然上次成功了,那么这一次也很大概率会成功。反之,如果很少会自旋成功,那么下次会减少自旋的次数甚至不自旋,以避免CPU空转。直白说就是会总结前人的经验了、会预判走位了。
轻量锁与偏向锁的区别:
- 偏向锁是一个线程自己在玩,而偏向锁涉及竞争,且争夺轻量级锁失败时,自旋尝试抢占锁
轻量级锁每次退出同步块都需要释放锁(要不就不会是一个走了一个接上了),而偏向锁则只在有线程来竞争时才释放锁
5、重量级锁
竞争太激烈时,只能捅到重量级锁,进行内核态和用户态的切换,但前面偏向锁和轻量级锁已然做了一定程度的缓冲和优化了。
有大量的线程参与锁的竞争,冲突性很高:
Object object = new Object();
//多个线程
for (int i = 0; i < 6; i++) {
new Thread(() -> {
synchronized (object){
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
},String.valueOf(i)).start();
}
运行:
6、重量级锁的原理
Java中synchronized的重量级锁,是基于进入和退出Monitor对象实现的。在编译时会将同步块的开始位置插入monitor enter指令,在结束位置插入monitor exit指令。
当线程执行到monitor enter指令时,会尝试获取对象所对应的Monitor所有权,如果获取到了,即获取到了锁,会在Monitor的owner中存放当前线程的id,这样它将处于锁定状态,除非退出同步块,否则其他线程无法获取到这个Monitor。
7、锁升级和hashcode的关系
可以看到,无锁状态下,Java对象头的Mark Word中是有空间存hashcode的,锁升级后,则没有位置了,那要是锁升级后hashcode去哪儿了 ?
总结下:
1) 在无锁状态下,Mark Word可以存储对象的identity hash code值,当对象的hashCode()方法第一次被调用时,JVM会生成对应的identity hash code值,存于对象头的Mark Word中。
2) 对于偏向锁,在线程获取偏向锁时,用Thread Id和epoch值(看成时间戳)去覆盖identity hash code所在的位置。如果一个对象的hashcode()方法已经被调用过一次,则这个对象不能被设置偏向锁
,因为如果可以,那identity hash code就会被线程ID覆盖,就会造成同一对象,前后两次调用hashcode方法得到的结果不一致。
3) 升级为轻量锁时,JVM会在当前线程的栈帧中创建一个锁记录空间Lock Record
(前面已提到),用于拷贝和存储锁对象的Mark Word
,里面自然包含了identity hash code、GC年龄等,且释放轻量锁时,这些数据又会写回对象头,因此轻量级锁可以和identity hash code共存。
4) 到重量级锁时,Mark Word保存的是重量级锁指针,而代表重量级锁的ObiectMonitor类里有字段记录了
非加锁状态下的Mark Word,锁释放以后也会写回对象头。
8、锁升级和hashcode关系的代码证明
Case1:当一个对象已经计算过identity hashcode,它就无法进入偏向锁状态,会跳过偏向锁,直接升级轻量级锁
//先睡5秒,抵消偏向锁开启的延时,保证开启偏向锁
TimeUnit.SECONDS.sleep(5);
Object object = new Object();
System.out.println("这里应该是偏向锁==>");
System.out.println(ClassLayout.parseInstance(object).toPrintable());
//没有重写hashcode,重写后无效
int hashCode = object.hashCode();
//验证当一个对象已经计算过identity hash code后,就无法进入偏向状态
new Thread(() -> {
synchronized (object){
System.out.println("这里本应是偏向锁,但刚才计算过一致性哈希hashcode,这里会直接升级为轻量级锁 ==>");
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
}).start();
Case2:偏向锁过程中遇到一致性哈希计算请求,立马撤销偏向模式,膨胀为重量级锁
//先睡5秒,抵消偏向锁开启的延时,保证开启偏向锁
TimeUnit.SECONDS.sleep(5);
Object object = new Object();
synchronized (object){
System.out.println(ClassLayout.parseInstance(object).toPrintable());
System.out.println("此时是偏向锁,但下面一计算哈希,会立马撤销偏向模式,膨胀为重量级锁");
//计算哈希值,这里的hashcode方法是没有重写过的
int hashCode = object.hashCode();
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
9、synchronized锁升级的总结
synchronized锁升级,目的还是实现一个性能优化,思想就是:先自旋,不行了再阻塞。一直都是围绕尽量避免内核态和用户态频繁切换来展开的。实际上是把之前的悲观锁(重量级锁)变成在一定条件下使用偏向锁以及使用轻量级(自旋锁CAS)的形式。太精辟了这句!道出了这几种锁的关系。
另外,synchronized在修饰方法和代码块时,在字节码上实现方式有很大差异,但是内部实现还是基于对象头的MarkWord来实现的。JDK1.6之前synchronized使用的是重量级锁,JDK1.6之后进行了优化,拥有了无锁->偏向锁->轻量级锁->重量级锁的升级过程,而不是无论什么情况都使用重量级锁。
最后 :
- 偏向锁:适用于单线程的情况,在不存在锁竞争的时候进入同步方法/代码块则使用偏向锁。
- 轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似),轻量级锁采用的是自旋锁,
如果同步方法/代码块执行时间很短的话(就很容易一个线程完事儿了,另一个线程尚未,哪怕不是这么刚刚好,也自旋等不了太久)
,采用轻量级锁自旋虽然会占用Cpu资源,但是相对比使用重量级锁还是更高效。 - 重量级锁:适用于竞争激烈的情况,如果同步方法/代码块执行时间很长,那么使用轻量级锁自旋带来的性能消耗就比使用重量级锁一更严重,这时候就需要升级为重量级锁。
10、JIT编译器对锁的优化:锁消除和锁粗化
JIT,即Just Time Compiler,翻译:即时编译器。
synchronized锁消除
以下是一个简单的synchronized代码,没啥毛病(别说优化成线程池):
public class LockClearDemo {
static Object objectLock = new Object();
public void m1(){
synchronized (objectLock){
System.out.println("----hello clearDemo");
}
}
public static void main(String[] args) {
LockClearDemo lockClearDemo = new LockClearDemo();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
lockClearDemo.m1();
},String.valueOf(i)).start();
}
}
}
但此时,做出这样一个修改:
这么写,看似有synchronized,语法也没报错,实际每个线程进来都new一个自己的object对象,相当于是每一个线程一个自己创造的锁,而不是正常的所有线程共同抢一个对象的锁,因此,这么写毫无意义,JIT编译器会无视它,极端的说就是根本没有加这个锁对象的底层的机器码,是消除了锁的使用。
synchronized锁粗化
看示例代码:
public class LockDemo {
static Object objectLock = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (objectLock){
System.out.println("111111");
}
synchronized (objectLock){
System.out.println("222222");
}
synchronized (objectLock){
System.out.println("333333");
}
synchronized (objectLock){
System.out.println("444444");
}
}).start();
}
}
注意,这不是可重入锁,这里是频繁加锁解锁。虽然无语法错误,但底层编译器会把它合并优化为:
锁粗化:假如方法中首尾相接,前后相邻的都是同一个锁对象,那JIT编译器就会把这几个synchronized块合并成一个大块,加粗加大范围,一次申请锁使用即可,避免次次的申请和释放锁,提升了性能。
11、结语
- 没有锁:自由自在
- 偏向锁:唯我独尊
- 轻量锁:楚汉争霸
- 重量锁:群雄逐鹿