这篇文章,我会直接把对象头的信息打印出来给你看!!!
其实 synchronized 有关的博客我之前也写过,描述的也还算比较清晰,比较深入。
比如这篇:99%的人答不对的并发题
还有这篇:当面试官怼你 synchronized 性能差时,你拿这篇文章吊打他(ReentrantLock 与 synchronized 的前世今生)
这两篇对我来说算是比较古老的文章了,都快有两个月了。
而且实际上,个人认为,写得不算很出色,虽然读者给的反馈还不错。
不过,对于我们要精通 Java 的人来说,我觉得还不够。
首先,里面的很多知识,网上面有一部分博客也写到了,对于这些很多大家都知道的知识,写出来毕竟意义也不是特别大。
而对于很多 synchronized 的优化点,大部分人是不清楚的。
实际上,在 synchronized 被优化之后,有很多很多的优化点,除了锁粗化、锁消除、偏向锁这些耳熟能详的之外,实际上还有一些其它的优化。
在这里,我的画图演示不会很多,因为我之前的博客已经描述过了;
这里更多的是一个证明,和知识的补充。
所以,这里重点关注的,是我代码运行之后的结果!!!
你可以直接看到我程序中打印出来的对象头的信息,从而弄清,synchronized 的锁到底是怎么一回事。
如果你要看一些基础的流程等等,那就去看我之前的博客即可。
对象头
首先,基本上只要你去看 synchronized 的文章,你大部分情况会看到这么一张图:
可能很多人都是看了别人的博客是这么写的,然后自己也总结了一份,也是这样子,然后慢慢的,几乎网上的都是这个样子。
但是,我在这里不是说这个图是错的!
但是我也不是说它就是对的!
什么意思,就是这个图很可能对你来说就是错的。
因为,那篇文章是别人写的,他研究的可能就是 32 位的机器,而你用的,则是 64 位的 Java 虚拟机。
比如我的就是 64 位:
现在 2020 年了,好歹你得看一个 64 位的吧。
而对于这个对象头,Hotspot 源码里面有这么一段注释:
整理一下知识点可以有如下的表格:
|------------------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | Normal |
|------------------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | Biased |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | lock:2 | Lightweight Locked |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 | Heavyweight Locked |
|------------------------------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|------------------------------------------------------------------------------|--------------------|
如果你不太明白的话,还是最好去先看一下我之前写的那篇文章。
对于对象的分析,openjdk 提供了 jol 工具,可以用来查看对象的信息。
所以我们先来看一下一个普通对象是什么样子的:
public static void main(String[] args) {
// 直接new一个Object查看分析
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
我们能看到,这个对象的大小一共是 16 bytes;
其中对象头有 12 bytes;
多余的 4 bytes 用于对齐。
如果你不明白对齐是什么意思,那我给你一个示例:
public class ObjectSize {
static class MyObject {
byte b; // 一个类中有一个1byte的成员变量
}
public static void main(String[] args) {
MyObject o = new MyObject();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
可以看到,这个时候,对象头 12 bytes;
加上一个成员变量 1 byte,一共 13 bytes;
这时候,对齐,到了 16 bytes。
其实就是对象的大小,必须是 8 bytes的倍数。
其它的各种大小我就不演示了,你可以自己去测试,就能证明,对象的对齐是什么意思。
当然,还有一点提及一下,如果是数组对象,还会有一个额外的空间,4 bytes, 来表示数组的长度。
public static void main(String[] args) {
byte[] bytes = new byte[10];
System.out.println(ClassLayout.parseInstance(bytes).toPrintable());
}
很明显,对象头变成 4 行了,多了 4 bytes 的数据;
然后,由于数据大小是 26 bytes,对齐之后变成了 32 bytes。
不过,有一个小点要提一下,就是对象头里的执行类的指针按道理是 64 位,也就是 8 字节对不对?
但是,我们看到的,第三行类指针只有 4 bytes 对吧?
这是因为,虚拟机默认开启了指针压缩,如果我把指针压缩关了,就会变成 8 bytes 了。
我再拿之前的 MyObject 来举例:
可以发现,确实整个对象头比之前大了 4 bytes;
然后这时对齐之后就会变成 24 bytes 了。
这个知识点就算过了,因为主要是要讲 synchronized 的,所以,通过这些演示,你能看懂这些是什么意思即可。
MarkWord
下面要开始研究 synchronized 锁机制:
首先我们看一下,一个刚 new 出来的新对象,是什么样子的。
我们现在既然要探究 synchronized,那么自然着重研究那 64 位的 MarkWord,所以我截图就只截关键的那部分。
public static void main(String[] args) throws Exception {
final Object o = new Object();
// 打印新对象
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
然后,我们就可以对照那张表格进行分析:
首先,是没有加锁的情况。
首先,如果我什么都不说,你们直接对着这张表,去按照顺序对照里面的每一个位,你会发现是对不上的!
因为,计算机里面的存储方式,并没有你想象的那么简单,有大小端存储的概念。
我这里帮你们把概念百度出来。
其实看不看无所谓,因为我会帮你们标出来,哪些位分别对应哪些。
大端模式,是指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中,这样的存储模式有点儿类似于把数据当作字符串顺序处理:地址由小向大增加,而数据从高位往低位放;这和我们的阅读习惯一致。
小端模式,是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中,这种存储模式将地址的高低和数据位权有效地结合起来,高地址部分权值高,低地址部分权值低。
00000000(56-63) 00000000(48-55) 00000000(40-47) 00000000(32-39)
00000000(24-31) 00000000(16-23) 00000000(08-15) 00000000(0-7)
从每一个大段来看,是倒着排列的;
从每一个小段来看,是正着排列的。
这样,你就可以对应起来,哪一些位对应表示什么信息。
00000000 00000000 00000000 00000000
00000000 00000000 00000000 00000000
0-24:unused
所以加粗的这些 bit 全部是 0。
00000000 00000000 00000000 00000000
00000000 00000000 00000000 00000000
25-55:identity_hashcode
加粗的这些 bit 就表示 hashcode。
00000000 00000000 00000000 00000000
00000000 00000000 00000000 00000000
61 位:偏向锁标志:biased_lock
00000000 00000000 00000000 00000000
00000000 00000000 00000000 00000000
62、63:lock
00000000 00000000 00000000 00000000
00000000 00000000 00000000 00000000
这时,我们再看新 new 出来的 Object,它的 MarkWord 除了最后三位 101,其他全部是 0。
这时,由于虚拟机默认是开启偏向锁的,所以我们和偏向锁的 MarkWord 做一个对比:
- 偏向锁标志位:1,代表可以偏向。
- 锁标志位:01,结合偏向锁标志位,代表这是一个偏向锁。
- 其他全是 0,说明此时还没有偏向任何一个线程。
偏向锁
然后,我们看,当一个线程获取锁之后,打印的结果:
public static void main(String[] args) throws Exception {
final Object o = new Object();
synchronized (o) {
// 获取锁时的效果
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
// 锁释放后的效果
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
也就是 0-53 全部用来标记了线程,然后 54、55 用来记录 epoch 值。
我们这时也可以看到锁的标志为 101,代表着这是一把偏向锁。
不过,有意思的是,释放锁之后,打印出的信息,和释放锁之前的信息完全一样,也就是,实际上,线程执行结束同步代码块,并没有释放锁!!!
所以,这很好的证明了,一但一把锁偏向了一个线程之后,它就会把这个线程信息记录起来,不去释放锁;
然后以后同样的线程再来加锁,就可以省去很多加锁操作。
不过,有一个点大家可能不知道,或者听说过,但是也不知道是不是真的:
就是,对象计算过 identity_hashcode 之后不能够再偏向!
所以,我们现在就可以进行证明,这个说法,到底是不是真的?
public static void main(String[] args) throws Exception {
final Object o = new Object();
o.hashCode(); // 计算identity_hashcode
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
答案很明确,计算过 identity_hashcode 之后,就无法再偏向。
原因也很好理解:
- 因为偏向锁要存放偏向的线程信息;
- 而计算了 identity_hashcode 之后,就要存放 identity_hashcode 的值;
- 所以此时没有空位存放线程信息了,就无法再进行偏向。
性能对比
现在,看完了这么多情况的 MarkWord,想必你对偏向锁就有一定的了解了。
不过,其实很多人认为,synchronized 优化的比较鸡肋,既然已经有轻量级锁这种东西了,还需要偏向锁干什么?
它可能会快那么一点,但是又能快到哪里去?
所以,为了防止你有这样的误会,我给你做一个测试:
让在偏向锁、轻量级锁的情况下,分别加锁 1 亿次,来测试时间。
public static void main(String[] args) throws InterruptedException {
final Object o = new Object(); // 锁
long start,end; // 记录时间
// 偏向锁测试
start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
synchronized (o) {}
}
end = System.currentTimeMillis();
System.out.println("偏向锁" + (end - start) + "ms");
// 用另一个线程使锁升级为轻量级锁
Thread t2 = new Thread() {
public void run() {
synchronized (o) {}
}
};
t2.start();
t2.join();
// 测试轻量级锁
start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
synchronized (o) {}
}
end = System.currentTimeMillis();
System.out.println("轻量级锁" + (end - start) + "ms");
}
有了数据,我就没有必要去多做解释了。
锁是否会退化
然后,有些人还有疑问:
这里锁升级了,成为了轻量级锁,但是,会不会出现锁降级的情况?
你看,这里 main 线程,又循环加锁了 1 亿次,会不会虚拟机看不下去,重新改回了偏向锁?
这种问题也只要做一个测试:
我在最后添加了一行代码,打印一下,对象的当前信息。
你可以很清晰的发现,即使循环了 1 亿次,也没有重新偏向。
批量重偏向
那么,很多人就会认为,偏向锁不能重偏向。
还有和很多人认为能,但是也不知道怎么才能。
这里,我就要提到一个批量重偏向的概念:
public static void main(String[] args) throws InterruptedException {
// 创建100个object
final Object[] objects = new Object[100];
for(int i = 0; i < objects.length; i++) {
objects[i] = new Object();
}
// t1全部上一遍锁,此时便全部是偏向t1的偏向锁
Thread t1 = new Thread() {
public void run() {
for(int i = 0; i < objects.length; i++) {
synchronized (objects[i]) {}
}
}
};
t1.start();
t1.join();
// t2,全部上一次锁
for(int i = 0; i < objects.length; i++) {
synchronized (objects[i]) {}
}
// 打印18、19号对象
System.out.println(ClassLayout.parseInstance(objects[18]).toPrintable());
System.out.println(ClassLayout.parseInstance(objects[19]).toPrintable());
}
我们可以发现,在第 19 个对象(也就是 18 号)第二次加锁的时候,还会升级为轻量级锁;
但是,从第 20 个对象(19 号)第二次加锁的时候,就不会升级为轻量级锁,而是重新偏向,偏向主线程。
延迟偏向锁
其实,偏向锁还有一个延迟的概念。
有些人如果了解偏向锁额延迟,那么在看我之前的代码的时候,可能就会认为,我手动调整了参数,让虚拟机启动的时候就立刻开启偏向锁。
实际上不是的,应该每个版本,它的机制会有变化,我的虚拟机默认在启动的时候就开启偏向锁,并没有手动设置。
当然,下面为了解释延迟这个概念,我手动设置一下,给我的虚拟机增加一个偏向锁延迟。
这时,启动时 new 一个新对象,然后查看信息:
public static void main(String[] args) {
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
你会发现,这个对象不是一个偏向锁,而是一个轻量级锁。
如果,我在开头,增加一行代码:
public static void main(String[] args) throws InterruptedException {
Thread.sleep(1000); // 睡眠一会,比你设置的延迟时间长即可
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
你就会发现,这个对象又变回了偏向锁:
也就是说,虚拟机并不是一启动就立刻有了偏向锁,而是在短暂延迟过后,才会开启偏向锁。
其实因为虚拟机启动的时候,会做很多很多的事,这里面用到了很多 synchronized 关键字来同步,而启动的时候,是有很多线程竞争的,所以锁一定不会保持偏向的状态。
于是,虚拟机便将偏向锁关闭了,直到启动完成之后,才会将偏向锁又重新设置打开。
所以,就会有一定的延迟,而这个延迟时间,则可以由参数配置。
不同线程获取相同偏向锁
不过,除了这些,还有一个神奇的现象:
public static void main(String[] args) throws InterruptedException {
final Object o = new Object();
// 第1个线程加锁
Thread t1 = new Thread() {
public void run() {
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
};
t1.start();
t1.join();
// 第2个线程加锁
Thread t2 = new Thread() {
public void run() {
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
};
t2.start();
t2.join();
// 第3个线程加锁
Thread t3 = new Thread() {
public void run() {
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
};
t3.start();
}
我们看起来是 new 了几个不同的线程去依次执行,但是,可能会出现这种结果:
(这个结果是可能出现,可能运行了会出现,也可能不会)
我们发现,虽然不同的线程来执行,但是,我们发现,每次不一样的线程来执行的时候,却发现,底层虚拟机记录的线程是同一个线程,所以就能获取同一个偏向锁,没有使得锁升级。
但是,由于每次运行完一个线程之后,线程死亡,然后创建新线程运行;
而对象中记录的线程的信息,是 JVM 映射的操作系统中的线程;
那么我们现在就暂时无法区分:
- 到底是操作系统分配线程的时候,在前一个线程死亡后,就又分配了同样的线程 id;
- 还是操作系统并没有分配同样的线程 id,而是 JVM 为了利用偏向锁,而把这个不一样的线程,在虚拟机中分配了前一个线程的相同的线程 id,所以在虚拟机看来就是同一个线程;
为了证明,我给每个线程加了点 sleep,然后我好在外部去打印线程信息。
然后点击运行,开始操作。
这时,我们便知道答案了。
操作系统分配的线程是不同的;
但是 JVM 给这三个线程分配的线程 id 都是一样的。
所以,很明显,是 JVM 为了优化 synchronized 的偏向锁,故意分配了相同的线程 id,来提高性能。
wait
还有一点要提的就是,在调用 wait 的时候,也会使对象膨胀成重量级锁。
毕竟,wait 就是对象监视器来管理的,并且,要阻塞自己。
所以,肯定需要升级成为重量级锁。
public static void main(String[] args) throws InterruptedException {
final Object o = new Object();
new Thread() {
public void run() {
synchronized (o) {
try {
o.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}.start();
// 没有再次上锁,直接打印信息
Thread.sleep(1000);
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
写在最后
实际上,对于 synchronized 的解释,笔者不能给出过于权威的答案,因为毕竟没有从虚拟机源码的角度来给出解答。
所以,笔者在这里,仅仅只是提及了这么一些知识点,通过官方的文档给出的说明,一一作了验证,来给出各位答案,避免走入误区。
当然,synchronized 的知识点,想来还会有更多,比如:
Object monitor,wait set,entry list;
锁消除、所粗化等等;
这些笔者尝试过验证出结果给各位,但是,在 Java 层面,无法获取到这些虚拟机底层的代码信息,所以最终没能验证出;
所以,对于这些,笔者无法给出答案。
想要继续深入这些知识点的话,那就需要阅读虚拟机源码了。
笔者能力有限,再加上时间精力的有限,暂时只能给出这些知识点,大家可以根据自己的情况,来继续学习。
转载:https://blog.csdn.net/weixin_44051223/article/details/105875437