文章目录
JAVA 中的锁(一)锁分类
锁的信息存储于对象头里的Mark Word中
锁的量级:
无锁<偏向锁<轻量级锁<重量级锁
锁的标志是存在对象头的Mark Word中
1一个bit表示是否为偏向锁,0否1是
2个bit表示锁的状态
- 01,并且25/31个bit是对象的hashcode,上面的1比特位为0 -> 无锁
- 01,并且23/54个bit是当前线程ID,上面的1比特位为1->偏向锁
- 00,轻量级锁
- 10,重量级锁
偏向锁
假设/经过HotSpot作者大量研究发现:对象A的实例在大多数情况被线程B多次访问。针对这种情况,如果每次访问都要经历申请锁、释放锁的步骤,实在是太麻烦了。不如,线程B申请到锁之后,就一直占有,反正少数情况下才会有其他线程需要这把锁.
获取偏向锁的过程
- 查看对象头的MarkWord中是否有当前线程ID,如果没有,则进行CAS竞争锁,替换Mark Word,将当前线程ID放在对象头中,如上图标红所示
- 替换成功,即拿到了锁,开始执行同步代码
- 执行结束之后,也不会释放掉。当再次访问同步代码的时候,发现对象的Mard Word中的线程ID是当前线程ID,则直接使用
锁竞争的过程
- 当上面第二步正在执行时,第二个线程进入,也要访问同步代码,同样也会检查对象的Mark Word中是否有当前线程ID,发现并不是当前线程ID
- 检查Mark Word中的偏向锁标记,是否是可偏向的标记0,此时是1-已偏向了,并且偏向的线程不是自己,尝试使用CAS将Mark Word的线程ID指向自己,这一步如果失败,则会进行偏向锁的撤销
偏向锁的撤销
一旦出现两个线程竞争一个偏向锁,那么就要第一个持有偏向锁的线程会根据情况进行锁的释放,或者升级
- 在全局安全点到达之后-没有任何字节码执行,开始进行偏向锁的撤销工作
- 暂停拥有偏向锁的线程B,检查拥有偏向锁的线程B是否还活着,如果没活着,则直接将MarkWord设为无锁状态,重新偏向新的线程
- 如果还活着,并且该线程的同步体部分执行完毕了,这个时候也可以将其恢复成无锁状态。并恢复当前线程
- 如果还活着,但是该线程的同步体部分未执行完毕,开始进行锁升级则将偏向锁升级至轻量级锁。
偏向锁升级至轻量级锁
- 在当前线程B的栈帧中创建锁记录-lock record
- 将MarkWord复制到锁记录中
- 将锁记录中的owner指向锁对象
- 将MarkWord修改为【指向】栈中锁记录的指针-CAS操作的
- 修改成功,恢复线程。此时两个线程B、C看到的都是轻量级锁了
- 现在由线程B持有轻量级锁。同时MarkWord锁标志变为00
利用JOL查看偏向锁信息
@Test
@SneakyThrows
public void assertBiasedLock() {
System.out.println("虚拟机启动");
TimeUnit.SECONDS.sleep(6);
ThreadEntity threadEntity = new ThreadEntity();
ClassLayout layout = ClassLayout.parseInstance(threadEntity);
System.out.println("虚拟机启动6秒后");
System.out.println(layout.toPrintable());
synchronized (threadEntity){
System.out.println("执行同步代码块");
System.out.println("线程ID:"+Thread.currentThread().getId());
System.out.println(layout.toPrintable());
}
System.out.println("执行同步代码块结束");
System.out.println(layout.toPrintable());
}
执行结果如下图
可以看到,途中锁的标识位为101,执行完同步体之后还是101,并没有锁释放
疑问解答
-
为什么要延迟6秒?
答:因为应用启动几秒后才激活延迟锁,所以要看到偏向锁,要等待一会
开启/关闭偏向锁
-XX:+/- UseBiasedLocking
-
途中锁的标志位为什么在第一组8bit中,64位的JVM锁标记不是在最后三位吗?
答:MarkWord占8个字节,因此,前8个自己就是MarkWord的信息,这里有一个概念是大端存储和小端存储,因此,前两行要从后往前看。
大端存储与小端存储模式主要指的是数据在计算机中存储的两种字节优先顺序。小端存储指从内存的低地址开始,先存储数据的低序字节再存高序字节;相反,大端存储指从内存的低地址开始,先存储数据的高序字节再存储数据的低序字节。
例如:
十进制数9877,如果用小端存储表示则为:
高地址 <- - - - - - - - 低地址
10010101[高序字节]
00100110[低序字节]
用大端存储表示则为:
高地址 <- - - - - - - - 低地址
00100110[低序字节]
10010101[高序字节]
详情可参考这篇博文
轻量级锁
当偏向锁升级至轻量级锁,或者禁用了偏向锁之后(什么情况要禁用?明确知道存在多个线程竞争,为了减少锁升级的开销,直接禁用偏向锁),多线程竞争时,则首先使用轻量级锁
轻量级锁也被称为非阻塞同步、乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待,串行执行
轻量级锁会有自旋操作,自旋会消耗CPU,而阻塞不会。因为轻量级锁使用的场景为同步体执行很快的情况。所以加入了自旋操作
获取轻量级锁过程
- 创建锁记录,将MarkWord复制到锁记录中
- 利用CAS操作,将MarkWord中指向线程B的锁记录指针进行替换。一开始的图,轻量锁那里
- 如果替换成功,则表示获得了轻量锁,锁标志位变成了00
锁竞争
- 在线程B执行同步体过程中,此时线程C也需要执行同步体,于是线程C也进行如上第1、2步,但是在CAS修改锁记录指针的时候失败,这时候线程C会进行自旋
- 如果在线程C自旋几次之后,线程B执行同步体完毕,线程B利用CAS将对象头替换。此时C会立刻获得锁
- 如果C自旋几次之后,线程B还没有执行完同步体,则线程C会修改对象头MarkWord为重量级锁
锁释放
- 当线程B执行完同步体之后,会将MarkWord原来指向该线程锁记录的指针替换掉,替换为最初的对象头001无锁状态。当这个CAS过程成功,则没有发生锁竞争
- 一旦这个过程失败,例如上面的第三步,此时锁标记已经是重量级锁了。那么就会膨胀为重量级锁,此时线程C不会再自旋,而是会阻塞。
- 当线程B执行同步完毕之后,释放锁,并同时唤醒阻塞在这把锁上的线程,这些线程开始新一轮的争抢
利用JOL查看轻量级锁的信息
@Test
public void assertThinLock() {
ClassLayout layout = ClassLayout.parseInstance(entity);
System.out.println(layout.toPrintable());
synchronized (entity){
System.out.println("执行同步体");
System.out.println(layout.toPrintable());
}
System.out.println("执行同步体结束");
System.out.println(layout.toPrintable());
}
可以看到,一开始是无锁状态,进入同步体之后锁标志位变为00
疑问解答
-
轻量级锁的自旋次数固定吗?
答:可以通过-XX:PreBlockSpin进行设置。还有自适应自旋了解一下?
-
轻量级锁适用场景?
答:追求响应时间,并且同步体执行非常快
重量级锁
当轻量级锁升级为重量级锁之后,则不会再恢复到轻量级锁的状态。
互斥锁(重量级锁)也称为阻塞同步、悲观锁
JOL查看重量锁升级过程
@Test
@SneakyThrows
public void assertThinLockToFatLock() {
ClassLayout layout = ClassLayout.parseInstance(entity);
System.out.println("初始对象头");
System.out.println(layout.toPrintable());
Thread thread = new Thread(() ->{
synchronized (entity){
try {
TimeUnit.SECONDS.sleep(4);
}catch (InterruptedException exception){
}
}
});
thread.start();
TimeUnit.SECONDS.sleep(1);
System.out.println("锁竞争之前的对象头");
System.out.println(layout.toPrintable());
synchronized (entity){
System.out.println("主线程执行同步体时的对象头");
System.out.println(layout.toPrintable());
}
}
先让子线程执行,此时只有子线程一个线程竞争entity对象,此时为轻量级锁00,1秒之后,主线程执行,但是此时子线程拿到锁之后还在休眠,过了一会,当主线程拿到锁时,锁已经升级为重量级锁了10
疑问解答
-
为什么重量级锁开销大?
答:当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。
这就是说为什么重量级线程开销很大的。