上一篇博客我们编译了Linux源码来证明了Java中有偏向锁,但是我们从周志明大佬的《深入理解java虚拟机》的书中知道,我们可以通过分析Java对象头中MarkWord来查看是那种锁,下面是32位JVM的对象中的Mark Word图,但是随着JDK的不断升级,JDK没有32位的版本,所以我们要研究64的JVM中对象的MarkWord。
当我在网上找了很多资料的后,发现都是32位JVM,无法满足我们对64位JVM的研究,于是我想到了JDK源码,看看其中有没有注释,于是我去编译好的JDK源码找找看,找到对应的源码的注释如下:
我们可以将上面的注释转成以下的表格
|-----------------------------------------------------------------------------------------------------------------|
| Object Header(128bits) |
|-----------------------------------------------------------------------------------------------------------------|
| Mark Word(64bits) | Klass Word(64bits) | State |
|-----------------------------------------------------------------------------------------------------------------|
| unused:25|identity_hashcode:31|unused:1|age:4|biase_lock:1|lock:2 | OOP to metadata object | Nomal |
|-----------------------------------------------------------------------------------------------------------------|
| thread:54| epoch:2 |unused:1|age:4|biase_lock:1|lock:2 | OOP to metadata object | Biased |
|-----------------------------------------------------------------------------------------------------------------|
| ptr_to_lock_record:62 |lock:2 | OOP to metadata object | Lightweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
| ptr_to_heavyweight_monitor:62 |lock:2 | OOP to metadata object | Heavyweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
| |lock:2 | OOP to metadata object | Marked for GC |
|-----------------------------------------------------------------------------------------------------------------|
从上面的表格,我们可以看出Java的对象头在对象的不同的状态下会有不同的表现形式,主要有三种状态,无锁状态,加锁状态,GC标记状态。那么就可以理解Java当中的上锁其实可以理解给对象上锁。也就是改变对象头的状态,如果上锁成功则进入同步代码块。但是Java当中的锁又分为很多种,从上图可以看出大体分为偏向锁、轻量锁、重量锁三种锁状态。这三种锁的效率是完全不同、关于效率的分析会在下文分析。我们需要查看对象头,就需要用到借助JOL工具。
首先我们在项目中引入JOL的依赖,具体如下图:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
然后创建A.java
public class A{}
然后创建JOLExample1.java
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
import static java.lang.System.out;
public class JOLExample1 {
static A a;
public static void main(String[] args) {
a = new A();
//打印JVM的详细信息
out.println(VM.current().details());
//打印对应的对象头信息
out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
运行的结果如下
不是说Klass是64bits(8个字节)但是这儿只有4个字节,是因为我们开启了指针压缩,我们可以关闭指针压缩看看,是不是8个字节。我们只需要使用以下的JVM运行参数
-XX:-UseCompressedOops
再次运行刚才的程序,可以看到我们Klass对象是64bits(16个字节),具体如下图
解决了上面的问题后,我们再回到原来开启指针压缩图上来
我们可以看到整个对象是16B(16个字节),其中对象头(object header)12B(12个字节),还有4B是对齐的字节(因为在64位虚拟机上对象的大小必须是8的倍数),由于这个对象里面没有任何字段,故而对象的实例数据为0B,那怎么让它不是0B呢?我们可以在A中添加一个boolean类型的数据,再看结果,修改A.java如下所示
public class A {
//占一个字节的boolean字段
private boolean flag;
}
我们再次运行JOLExample.java,查看结果如下:
这个对象的大小还是没有改变一共16B,其中对象头(object header) 12B,boolean字段flag(对象的实例数据)占1B,剩下的3B就是对齐字节。由此我们可以认为一个对象的布局大体分为三个部分分别是对象头(Object header)、对象的实例数据、字节对齐。
上面说是64位虚拟机上对象的大小必须是8的倍数,我们可以证明一下,再加一个int(4个字节)数据,我们再次修改A.java如下所示
public class A {
//占一个字节的boolean字段
private boolean flag;
//占四个字节的int字段
private int a;
}
再次运行程序,如下所示,可以看到对象的大小是8的倍数
看完了对象的实例数据,我们就来到了今天的重头戏,Java的对象头(在开启JVM指针压缩的情况下是12B),那么这12B存储的是什么?我们可以看下
OpenJDK的官网的解释
首先引用openjdk文档当中对对象头的解释
上述引用中提到一个java对象头包含2个word,并且包含了堆对象的布局、类型、GC状态、同步状态和标识哈希码,具体怎么包含的呢?又是哪两个word呢?
Mark word为第一个word根据文档可以知道它里面包含了锁的信息、hashcode、gc信息等等,第二个word是什么呢?
klass word 为对象头的第二个word主要指向对象的元数据。
假设我们理解一个对象头主要由上图两个部分组成(数组对象除外,数组对象的对象还包含一个数组长度),由我们的推导出Mark word是8个字节,klass word(开启指针压缩的情况下是4个字节,不开启的时候是8个字节)。我们打印出来的对象头是12个字节,所以其中的8个字节是Mark word,剩下的4个字节是klass word,但是和锁相关的就是Mark word,那么接下来要重点分析Mark word里面信息。
由最开始的64位的表格,我们可以得知在无锁的情况下Markword当中前56bit存的是对象的hashcode,我们来验证一下
修改A.java 的代码如下
public class A {
//占一个字节的boolean字段
private boolean flag;
}
新建一个JOLExample2.java具体代码如下
import org.openjdk.jol.info.ClassLayout;
import static java.lang.System.out;
public class JOLExample2 {
public static void main(String[] args) {
A a = new A();
//没有计算HashCode之前的对象头
out.println("before hash");
out.println(ClassLayout.parseInstance(a).toPrintable());
//jvm计算HashCode
out.println("jvm----------" + Integer.toHexString(a.hashCode()));
//当计算完HashCode之后,我们可以查看对象头的信息变化
out.println("after hash");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
运行的结果如下:
可以看到我们在没有进行hashcode运算的时候,所有的值都是空的。当我们计算完了hashcode,对象头就是有了数据。因为是小端存储,所以你看的值是倒过来的。前25bit没有使用所以都是0,后面31bit存的hashcode,所以第一个字节中八位存储的分别就是分代年龄、偏向锁信息、对象状态,这8bit分别表示的信息如下图所示,这个图会随着对象的状态改变而改变,下图是无锁的状态下
关于对象状态一共分为五种状态,分别是无锁、偏向锁、轻量锁、重量锁、GC标记,但是2bit只能表示4种状态(00,01,10,11)JVM的做法将偏向锁和无锁的状态表示为同一个状态,然后根据图中偏向锁的标识再去标识是无锁还是偏向锁状态。写个代码分析一下,在写代码之前我们先记得无锁状态下的信息是00000001,写个偏向锁的例子如下所示
新建一个JOLExample3.java,代码如下:
import org.openjdk.jol.info.ClassLayout;
import static java.lang.System.out;
public class JOLExample3 {
static A a;
public static void main(String[] args) throws InterruptedException {
a = new A();
out.println("before lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
sync();
out.println("after lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
private static void sync() {
synchronized (a) {
out.println("我不知道要打印什么");
}
}
}
查看运行结果如下:
上面这个程序只有一个线程去调用sync方法,应该是偏向锁,但是你会发现输出的结果(第一个字节)依然是00000001和无锁的时候一模一样,其实这是因为虚拟机在启动的时候对于偏向锁有延迟,如果没有偏向锁的延迟的话,虚拟机在启动的时候,可能JVM某个线程调用你的线程,这样就有可能变成了轻量锁或者重量锁,所以要做偏向锁的延迟,那我们怎么看到打印的对象头是偏向锁呢?有两种方式:第一种是加锁之前先让线程睡几秒。第二种加上JVM的运行参数,关闭偏向锁的延迟,具体的命令如下:
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
第一种方式:修改JOLExample3.java如下
import org.openjdk.jol.info.ClassLayout;
import static java.lang.System.out;
public class JOLExample3 {
static A a;
public static void main(String[] args) throws InterruptedException {
//切记延迟一定要放在对象创建之前,不然是无效的,因为在你对象创建之前,偏向锁的延迟的时间
//没有给你睡过去,这时候,对象已经创建了,对象头的信息已经生成了。
Thread.sleep(5000);
a = new A();
out.println("before lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
sync();
out.println("after lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
private static void sync() {
synchronized (a) {
out.println("lock ing");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
}
再次运行,查看结果如下:
可以发现已经变成了00000101,偏向锁,需要注意的after lock,退出同步后依然保持了偏向信息。
第二种方式:利用jvm参数,首先我们先关闭睡眠5秒的,然后运行配置如下:
再次运行查看结果如下:
这时候大家会有疑问了,为什么在没有加锁之前是偏向锁,准确的说,应该是叫可偏向的状态,因为它后面没有存线程的ID,当lock ing的时候,后面存储的就是线程的ID(44969989)既然这儿存储是线程的ID,那么HashCode又存储到什么地方去了?是不是计算了HashCode就是不能偏向了?我们来验证一下,计算完HashCode,还是不是偏向锁了
我们再次修改JOLExample3.java,具体代码如下:
import org.openjdk.jol.info.ClassLayout;
import static java.lang.System.out;
public class JOLExample3 {
static A a;
public static void main(String[] args) throws InterruptedException {
//切记延迟一定要放在对象创建之前,不然是无效的,因为在你对象创建之前,偏向锁的延迟的时间
//没有给你睡过去,这时候,对象已经创建了,对象头的信息已经生成了。
//Thread.sleep(5000);
a = new A();
out.println("before lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
a.hashCode();
sync();
out.println("after lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
private static void sync() {
synchronized (a) {
out.println("lock ing");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
}
同时关闭JVM中偏向锁的延迟,运行的结果如下:
我们可以发现:在before lock的时候是可偏向的状态,lock ing的时候变成了轻量锁,after lock 的时候变成了无锁,所以我们得出对象计算了HashCode,就不是偏向锁了。
看完了偏向锁的对象头,我们再来看看轻量锁的对象头,轻量级锁尝试在应用层面解决线程同步问题,而不触发操作系统的互斥操作,轻量级锁减少多线程进入互斥的几率,不能代替互斥。
创建JOLExample4.java,代码如下:
import org.openjdk.jol.info.ClassLayout;
import static java.lang.System.out;
public class JOLExample4 {
static A a;
public static void main(String[] args) {
a = new A();
out.println("before lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
sync();
out.println("after lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
private static void sync() {
synchronized (a) {
out.println("lock ing");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
}
运行结果如下:
可以得出:before lock 的时候是 00000001 无锁的状态,lock ing 的时候是 01010000 轻量锁的状态,after lock 的时候是 00000001 无锁的状态。
看完了轻量锁的对象头,我们再来看看重量锁的对象头,我们先创建一个JOLExample5.java具体代码如下:
import org.openjdk.jol.info.ClassLayout;
import static java.lang.System.out;
public class JOLExample5 {
static A a;
public static void main(String[] args) throws InterruptedException {
a = new A();
out.println("before lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
Thread t1 = new Thread(()->{
synchronized (a) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
out.println("t1 release");
}
});
t1.start();
Thread.sleep(1000);
out.println("t1 lock ing");
out.println(ClassLayout.parseInstance(a).toPrintable());
sync();
out.println("after lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
System.gc();
out.println("after gc()");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
private static void sync() {
synchronized (a) {
out.println("main lock ing");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
}
运行结果如下:
在加锁之前(before lock)是 00000001 无锁,这时候t1来加锁,因为只有他一个线程所以轻量锁(t1 lock ing 00010000)由于t1在run方法中睡眠了5秒,这时候主线程也来尝试加锁,这个时候就是两个线程竞争了,所以是重量锁(main lock ing 00101010)
当结束的时候,还是重量锁(afteer lock 00101010),当执行一次gc操作过后发现变成了无锁但是年龄加了1(after gc() 00001001)
还有一点需要我们注意的就是:当调用wait方法会直接变成重量锁,我们来验证一下,创建JOLExample6.java,代码如下:
import org.openjdk.jol.info.ClassLayout;
import static java.lang.System.out;
public class JOLExample6 {
static A a;
public static void main(String[] args) throws Exception {
a = new A();
out.println("before lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
Thread t1 = new Thread(() -> {
try {
synchronized (a) {
out.println("before wait");
out.println(ClassLayout.parseInstance(a).toPrintable());
a.wait();
out.println("after wait");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
Thread.sleep(5000);
synchronized (a) {
a.notifyAll();
}
}
}
运行结果如下:
可以看到在加锁之前是无锁的状态,执行wait方法之前是轻量锁,执行wait 方法之后,被唤醒的后是重量锁。
既然synchronized关键字有这三种锁,我们简单的比较它们之间的性能(粗略的比较下),书写以下的代码
public class A {
int i;
public synchronized void parse() {
i++;
}
}
//关闭偏向锁延迟‐XX:BiasedLockingStartupDelay=0
public class JOLExample7 {
public static void main(String[] args) throws Exception {
A a = new A();
long start = System.currentTimeMillis();
//调用同步方法1000000000L 来计算1000000000L的++,对比偏向锁和轻量级锁的性能
//如果不出意外,结果灰常明显
for (int i = 0; i < 1000000000L; i++) {
a.parse();
}
long end = System.currentTimeMillis();
System.out.println(String.format("%sms", end - start));
}
}
先运行加上jvm参数关闭偏向锁延迟,就是偏向锁,然后运行的结果如下:
我们在开启偏向锁延迟就是轻量锁,然后运行结果如下:
最后我们在看重量锁,具体代码如下:
public class A {
int i;
public synchronized void parse() {
JOLExample8.countDownLatch.countDown();
i++;
}
}
import java.util.concurrent.CountDownLatch;
public class JOLExample8 {
static CountDownLatch countDownLatch = new CountDownLatch(1000000000);
public static void main(String[] args) throws Exception {
final A a = new A();
long start = System.currentTimeMillis();
//调用同步方法1000000000L 来计算1000000000L的++,对比各种锁的性能
//如果不出意外,结果灰常明显
for (int i = 0; i < 2; i++) {
new Thread(() -> {
while (countDownLatch.getCount() > 0) {
a.parse();
}
}).start();
}
countDownLatch.await();
long end = System.currentTimeMillis();
System.out.println(String.format("%sms", end - start));
}
}
重量级锁的执行结果如下:
最后总结的结果如下:
偏向锁 | 轻量锁 | 重量锁 |
---|---|---|
2355ms | 23564ms | 31227ms |
最后我们再画个图总结下各种锁的对象头(只画出了最重要的部分,其他的省略)