概述
本文我们将回答两个问题:
- synchronized 锁的是什么?
- 为什么 wait() 和 notify() 需要搭配 synchonized 关键字使用 ?
我将通过先介绍基础知识再回答问题的方式来解答这两个问题,了解了前面的基础知识后,问题也就迎刃而解了。
前知识-对象头(mark word)
内存布局
我们知道 java 对象的内存布局如下图所示:
而其中对象头区域包含 markword 和 class pointer
利用 JOL 可以分析内存中的对象布局
“JOL 的全称是 Java Object Layout。是一个用来分析 JVM 中 Object 布局的小工具。包括 Object 在内存中的占用情况,实例对象的引用情况等等。”
添加依赖
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.10</version> </dependency>
public class A { //占一个字节的 boolean 字段 private boolean flag; public static void main(String[] args) { A a = new A(); //打印对应的对象头信息 System.out.println(ClassLayout.parseInstance(a).toPrintable()); } }
我们利用上面的程序对对象头的内存情况进行一下探究。上面程序执行后的结果如下图:
这里 一共 16 个字节
- mark word 占了 8 个字节
- class pointer 类型指针占了 4 个字节
- 实例数据 1 个字节
- 对齐填充部分 3 个字节
其中由于 JVM 开启了指针压缩,所以 class pointer 是 4 个字节,如果关闭指针压缩(添加 vm 参数:-XX:-UseCompressedOops
),则是 8 个字节。
另外,64 位虚拟机上对象的大小必须是 8 的倍数,上图中一共 16 个字节,是 8 的倍数。
对象头
根据 文档 (http://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html) 得知 对象头有两个 word , 其一为 markword ,另一为 klass pointer
通过上面的例子我们已经知道了,在开启指针压缩的情况下 对象头(mark workd + klass pointer) 一般占 12 个字节。
但是,如果对象是数组,情况就不一样了。当对象是一个数组对象时,那么在对象头中有一个保存数组长度的空间,占用 4 字节(32bit)空间
public class A { //占一个字节的 boolean 字段 private boolean flag; public static void main(String[] args) { A[] a = new A[2]; //打印对应的对象头信息 System.out.println(ClassLayout.parseInstance(a).toPrintable()); } }
可以看到 对象头(object header)又多了 4 个字节用于存放数组长度。
klass pointer
Klass Pointer
是一个指向方法区中Class
信息的指针,虚拟机通过这个指针确定该对象属于哪个类的实例。
在 64 位的 JVM 中,支持指针压缩功能,根据是否开启指针压缩,Klass Pointer
占用的大小将会不同:
- 未开启指针压缩时,类型指针占用 8B (64bit)
- 开启指针压缩情况下,类型指针占用 4B (32bit)
指针压缩原理
我们将程序从 32 位移到 64 位是为了程序性能的提升,但是涉及 JVM 的情况并非总是如此,造成这种性能下降的主要原因是 64 位对象引用。
64 位引用占用的空间是 32 位引用的两倍,这通常导致更多的内存消耗和更频繁的 GC 周期,而且对象的引用完全用不到 64 位,因为 64 位代表的内存大小为 2^64,其内存大小完全达不到,因此就需要压缩指针来获取性能上的提升。
内存寻址是以字节为单位 ,32 位寻址空间约 4GB (4 * 1024 * 1024 * 1024 Byte) = 2 的 32 次方。同理 64 位理论上可以达到 2 的 64 次方字节,2097152T
我们知道 JVM 对象对齐会使对象的大小都是 8 字节的倍数,这会使 oops 的最后三位始终为零,这是因为 8 的倍数始终以二进制 000 结尾。
这 3 位 000 在堆中的存储是完全没有意义的,因此我们可以将这 3 位用来存储更多的内存地址,相当于 35 位的地址压缩在 32 位地址上使用,这样我们内存使用就从原来的 2^32=4G 提升为 2^35=32G。
何为 Oop?
Oop(ordinary object pointer),可以翻译为普通对象指针,指向的是 Java 对象在内存中的引用。
哪些对象会被压缩?
如果配置 JVM 参数 UseCompressedOops 为 true,则代表启用压缩指针,则将压缩堆中的以下 oops:
- 每个对象头的 klass 字段
- 每个 oop 实例字段
- oop 数组(objArray)的每个元素
需要注意的是,在 UseCompressedOops 已经开启的基础上,klass 可以通过 UseCompressedClassPointers 单独设置是否开启。UseCompressedClassPointers 必须基于 UseCompressedOops 开启的情况下才可以设置是否开启,如果 UseCompressedOops 设为 false,则 UseCompressedClassPointers 无法设置为 ture。
mark word
具体来看一下 markword
的内部结构
根据 JVM 源码
具体我们写代码看一下:
public class A { //占一个字节的 boolean 字段 private boolean flag; public static void main(String[] args) { A a = new A(); 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。这跟上图 64 位 markword 所描述的一样。
那么在无锁状态下 ojbect header
第一个字节 8 位存储的就是:
即 00000001 。
最后一位代表的锁标志为 1 ,表示该对象 无锁。
然而锁标志位 2bit 只能表示 4 种状态(00,01,10,11)JVM 的做法将偏向锁和无锁的状态表示为同一个状态,然后根据上图中偏向锁的标识再去标识是无锁还是偏向锁状态。
Java 的对象头在对象的不同的状态下会有不同的表现形式,主要有三种状态
- 无锁状态
- 加锁状态
- GC 标记状态
那么就可以理解 Java 当中的上锁其实可以理解给对象上锁,也就是改变对象头的状态 synchronized 锁的是什么?
当 Java 处在偏向锁、重量级锁状态时,hashcode 值存储在哪?
“简单 答案 是:
- 当一个对象已经计算过 identity hash code,它就无法进入偏向锁状态;
- 当一个对象当前正处于偏向锁状态,并且需要计算其 identity hash code 的话,则它的偏向锁会被撤销,并且锁会膨胀为重量锁;
- 重量锁的实现中,ObjectMonitor 类里有字段可以记录非加锁状态下的 mark word,其中可以存储 identity hash code 的值。或者简单说就是重量锁可以存下 identity hash code。
请一定要注意,这里讨论的 hash code 都只针对 identity hash code。用户自定义的 hashCode() 方法所返回的值跟这里讨论的不是一回事。
Identity hash code 是未被覆写的 java.lang.Object.hashCode() 或者 java.lang.System.identityHashCode(Object) 所返回的值。”
前知识-JAVA 中的锁
偏向锁
作用
偏向锁是为了消除无竞争情况下的同步原语,进一步提升程序性能。其目标就是在只有一个线程执行同步代码块时能够提高性能。
与轻量级锁的区别
轻量级锁是在无竞争的情况下使用CAS
操作来代替互斥量的使用, 从而实现同步;而偏向锁是在无竞争的情况下完全取消同步。
与轻量级锁的相同点
它们都是乐观锁,都认为同步期间不会有其他线程竞争锁。
撤消
偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁的延迟
虚拟机在启动的时候对于偏向锁有延迟。为什么要延迟呢?
JVM 刚启动的时候,一定是有很多的线程在运行,操作系统也是知道的,所以明明知道有高并发的场景,所以就延迟了 4s。
原理
当线程请求到锁对象后, 将锁对象的状态标志位改为 01, 即偏向模式。然后使用CAS
操作将线程的 ID 记录在锁对象的 Mark Word 中。
以后该线程可以直接进入同步块, 连CAS
操作都不需要。但是,一旦有第二条线程需要竞争锁,那么偏向模式立即结束,进入轻量级锁的状态。
优点
偏向锁可以提高有同步但没有竞争的程序性能。但是如果锁对象时常被多条线程竟争,那偏向锁就是多余的。
匿名偏向
刚刚 new 完这个对象还没有任何线程持有这把锁,那它偏向谁呢,这种的谁也不偏向,叫做匿名偏向。
我们刚刚 new 出来的对象,如果偏向锁启动是匿名偏向,没有启动就是普通对象。
public class A { //占一个字节的 boolean 字段 private boolean flag; public static void main(String[] args) throws InterruptedException { //延迟 5s // TimeUnit.SECONDS.sleep(5); A a = new A(); 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()); } }
这里要联系对象头一起理解
JVM 参数
-XX:BiasedLockingBulkRebiasThreshold = 20 // 默认偏向锁批量重偏向阈值 -XX:BiasedLockingBulkRevokeThreshold = 40 // 默认偏向锁批量撤销阈值 -XX:+UseBiasedLocking // 使用偏向锁,jdk6 之后默认开启 -XX:BiasedLockingStartupDelay = 0 // 延迟偏向时间,默认不为 0,意思为 jvm 启动多少 ms 以后开启偏向锁机制(此处设为 0,不延迟)
偏向锁可以通过虚拟机的参数来控制它是否开启。
批量重偏向与批量撤消
渊源 从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到 safe point 时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。
原理:以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,每一次该 class 的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认 20)时,JVM 就认为该 class 的偏向锁有问题,因此会进行批量重偏向。
每个 class 对象会有一个对应的 epoch 字段,每个处于偏向锁状态对象的 Mark Word 中也有该字段,其初始值为创建该对象时 class 中的 epoch 的值。每次发生批量重偏向时,就将该值+1,同时遍历 JVM 中所有线程的栈,找到该 class 所有正处于加锁状态的偏向锁,将其 epoch 字段改为新值。下次获得锁时,发现当前对象的 epoch 值和 class 的 epoch 不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过 CAS 操作将其 Mark Word 的 Thread Id 改成当前线程 Id。当达到重偏向阈值后 ,假设该 class 计数器继续增长,当其达到批量撤销的阈值后(默认 40),JVM 就认为该 class 的使用场景存在多线程竞争,会标记该 class 为不可偏向,之后,对于该 class 的锁,直接走轻量级锁的逻辑。
解决场景:批量重偏向(bulk rebias)机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。批量撤销(bulk revoke)机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。
具体例子可以参考:https://www.cnblogs.com/LemonFive/p/11248248.html
流程
轻量级锁
轻量级锁
是相对于重量级锁
而言的,而重量级锁就是传统的锁。
本质
使用 CAS 取代互斥同步。
轻量级锁与重量级锁的比较:
重量级锁是一种悲观锁,它认为总是有多条线程要竞争锁,所以它每次处理共享数据时,不管当前系统中是否真的有线程在竞争锁,它都会使用互斥同步来保证线程的安全;
而轻量级锁是一种乐观锁,它认为锁存在竞争的概率比较小,所以它不使用互斥同步,而是使用 CAS 操作来获得锁, 这样能减少互斥同步所使用的『互斥量』带来的性能开销。
实现原理
当线程请求锁时, 若该锁对象的 Mark Word 中标志位为 01(未锁定状态) , 则在该线程的栈帧中创建一块名为锁记录
的空间, 然后将锁对象的 Mark Word 拷贝至该空间;最后通过 CAS 操作将锁对象的 Mark Word 指向该锁记录;
若 CAS 操作成功, 则轻量级锁的上锁过程成功;·若 CAS 操作失败, 再判断当前线程是否已经持有了该轻量级锁;若已经持有, 则直接进入同步块;若尚未持有,则表示该锁已经被其他线程占用,此时轻量级锁就要膨胀成重量级锁。
前提
轻量级锁比重量级锁性能更高的前提是,在轻量级锁被占用的整个同步周期内,不存在其他线程的竞争。若在该过程中一旦有其他线程竞争,那么就会膨胀成重量级锁,从而除了使用互斥量以外, 还额外发生了 CAS 操作, 因此更慢!
流程
有偏向锁为什么还要用轻量级锁呢?
轻量级锁设计之初是为了应对线程之间交替获取锁的场景,而偏向锁的场景则是用于一个线程不断获取锁的场景。
通过源码我们可以看出当一个线程获取偏向锁后,这个锁就会永久偏向这个线程,因为一旦发生偏向锁撤销,就代表锁要升级成为轻量级锁。虽然偏向锁在加锁时会进行一次 cas 操作,但是后续的获取只会进行简单的判断,不会再进行 cas 操作。但是轻量级锁的加锁和释放都需要进行 cas 操作。
我们看下如果把轻量级锁使用在偏向锁的场景下对比:
我们可以看到轻量级锁情况下每次获取都需要进行加锁和释放,每次加锁和释放都会进行 cas 操作,所以单个线程获取锁的情况使用偏向锁效率更高。
在看下如果把偏向锁使用在轻量级锁的场景下对比:
重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时 Mark Word 中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
java 对象与 monitor 的关联图
锁升级
整体的锁状态升级流程如下:
锁粗化和锁消除
- 锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。
- Java 虚拟机在 JIT 编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间
小结
偏向锁通过对比 Mark Word 解决加锁问题,避免执行 CAS 操作。而轻量级锁是通过用 CAS 操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。