全网最权威:再次打破你对synchronized的认知!!!

这篇文章,我会直接把对象头的信息打印出来给你看!!!

其实 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 层面,无法获取到这些虚拟机底层的代码信息,所以最终没能验证出;
所以,对于这些,笔者无法给出答案。

想要继续深入这些知识点的话,那就需要阅读虚拟机源码了。
笔者能力有限,再加上时间精力的有限,暂时只能给出这些知识点,大家可以根据自己的情况,来继续学习。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章