Appearance
本篇可以说是介绍对象头中的MarkWord的构成,也可以说是对JVM中锁的探索。
首先运行下面代码,其中MyObject()
是一个空对象,里面什么属性都没有。
java
public static void main(String[] args) {
MyObject obj = new MyObject();
System.out.println(obj + " 十六进制哈希:" + Integer.toHexString(obj.hashCode()));
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
运行结果如下,注意70 c1 09
哈希值和第一行09 c1 70
正好相反,原因百度搜索“计算机基础大端与小端”,只要知道我们打印输出的哈希值和头信息相同即可。
java
com.xk857.test3.MyObject@d70c109 十六进制哈希:d70c109
com.xk857.test3.MyObject object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
// --16进制-- -------------------- 2进制 -------------------- -- 10进制 --
0 4 (object header) 01 09 c1 70 (00000001 00001001 11000001 01110000) (1891698945)
4 4 (object header) 0d 00 00 00 (00001101 00000000 00000000 00000000) (13)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
这个图中的二进制是第二行+第一行的数据也就是Markword
,具体功能解释见后文。
Java对象头为什么存储锁信息
高并发多线程抢obj,如果是线程B抢到,线程B就锁住了obj,其他线程就不能抢;问题:谁来记录线程B抢到obj,并告诉其他线程等待?如果是你,你会怎么做?
- A方案:开辟一个空间来存储,obj=B, 当B解锁时把obj=nulI,其他线程每次检查obj是否为null,不是为null就能继续抢obj。
- B方案:在obj的对象头开辟一块锁空间把B设置进去,当B解锁时,obj的对象头锁空间清空,其他线程请求时只要对象头锁空间为空,都可以继续抢。
TIP
这2种方案中,A方案有个致命性的缺陷,就是新开辟的空间有线程安全问题,还要继续加锁,麻烦。而B方案就没有线程安全的问题了,obj本身就是被锁住,谁拿到锁谁在obj身上设置自己。这个就是对象头Mark Word空间。
什么是无锁,什么是匿名偏向锁 ?
先来看无锁代码,二进制下锁的表示,001表示无锁。
给上图代码加上睡眠,查看控制台输出,发现从无锁001→偏向锁101(1是偏向锁,01是锁的类型即无锁),匿名偏向锁是没有线程ID的,至于偏向锁看下一个图片。
偏向锁与匿名偏向锁
注意看红色的是锁,蓝色的线程ID,线程ID为0代表的偏向锁是匿名偏向锁,这就是二者的区别。
那么为什么要睡眠?
因为虚拟机在启动的时候对于偏向锁有延迟,如果没有偏向锁的延迟的话,虚拟机在启动的时候,可能JVM某个线程调用你的线程,这样就有可能变成了轻量锁或者重量锁,所以要做偏向锁的延迟(可以简单理解成JVM的优化),那我们怎么看到打印的对象头是偏向锁呢?
- 加锁之前先让线程睡几秒。
- 加上JVM的运行参数,关闭偏向锁的延迟,具体的命令如下:
-XX: +UseBiasedLocking -XX:BiasedLockingStartupDelay=0
偏向锁线程ID的作用
- 一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁的偏向的线程ID。
- 当下次该线程进入这个同步块时,会去检查锁的MarkWord里面是不是放的自己的线程ID。
- 如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁
- 如果不是,就代表有另一个线程来竞争这个偏向锁。
- 这个时候会尝试使用CAS来替换MarkWord里面的线程ID为新线程的ID,这个时候要分两种情况
- 成功,表示之前的线程不存在了,Mark Word里面的线程ID为新线程的ID,锁不会升级,仍然为偏向锁;
- 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。
- 本质上的功能是在无竞争的情况下减少锁竞争的开销。
- 注意:这个线程ID并不是JVM分配的线程ID号,和Java Thread中的ID是两个概念。
什么情况偏向锁会升级为轻量级锁?
观察如下代码,当主线程进入的时候锁没有升级,此时还是偏向锁;但是当其他的线程进入的时候,偏向锁便升级为了轻量级锁。
java
public void test4() throws InterruptedException {
MyObject obj1 = new MyObject();
System.out.println(ClassLayout.parseInstance(obj1).toPrintable()); // 无锁001
Thread.sleep(5000);
MyObject obj2 = new MyObject();
System.out.println(ClassLayout.parseInstance(obj2).toPrintable()); // 匿名偏向锁101
synchronized (obj2) {
System.out.println(ClassLayout.parseInstance(obj2).toPrintable()); // 偏向锁101,有线程id
}
new Thread(() -> {
synchronized (obj2) {
System.out.println(ClassLayout.parseInstance(obj2).toPrintable()); // 轻量级锁 000
}
});
}
什么是轻量级锁?
多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM采用轻量级锁来避免线程的阻塞与唤醒。
偏向锁用CAS替换MarkWord里面的线程ID为新线程的ID,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。
- 一旦获取失败,就说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。
- 自旋:不断尝试去获取锁,用循环实现;
- 自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。
- 解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。
- JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
- 自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。
- 同时这个锁就会升级成重量级锁。
轻量级锁什么情况会升级为重量级锁?
自旋一直失败(达到一定程度)依然拿不到锁就会阻褰,此时升级为重量级锁。重量级锁依赖于操作系统的互斥量实现的,而操作系统中线程间状态的转换需要相对比较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗CPU。
重量级锁是悲观锁的一种,自旋锁、轻量级锁与偏向锁属于乐观锁。偏向锁的对象头MarkWord格式:偏向锁为0、锁类型为10
java
public void test5() throws InterruptedException {
MyObject obj = new MyObject();
Thread t2 = new Thread(() -> {
synchronized (obj) {
System.out.println(ClassLayout.parseInstance(obj).toPrintable()); // 线程1重量级锁010
}
});
Thread t1 = new Thread(() -> {
synchronized (obj) {
System.out.println(ClassLayout.parseInstance(obj).toPrintable()); // 线程2重量级锁010
}
});
t1.start();
t2.start();
Thread.sleep(20 * 1000);
}
总结
添加线程睡眠会从无锁变为匿名偏向锁,原因是当一个线程获取的锁时,JVM会将此全局线程ID记录在对象头中,如果其他线程尝试获取该锁,JVM会检查对象头的线程ID是否与全局线程ID相同,如果相同,则仍然是偏向锁状态,这样可以避免在对象头中记录线程ID的开销。
匿名偏向锁的优点是在短暂的锁竞争场景下,减少了锁升级的开销,并提高了应用程序的并发性能和吞吐量。
如果使用
synchronized
锁住对象,会从匿名偏向锁升级为偏向锁,因为此时锁住的时具体的对象,需要在对象头中记录线程ID,以此当其他线程尝试获取对象时,可以从头信息知道此对象已被偏向锁定位,会等待其释放资源才会获取到信息(CAS)。多个
synchronized
会从偏向锁升级为轻量级锁,如果获取失败会使用自旋的方式(for循环)尝试获取对象。在同一线程顺序执行的情况下使用轻量级锁,如果是多个线程同时获取同一加锁对象,此时可能会造成自旋时间过长,那么此时就会升级成重量级锁,重量级锁依赖于操作系统的互斥量实现,属于悲观锁的一种,重量级锁效率很低,但被阻塞的线程不会消耗CPU。