4. 什么是monitor?
可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被 描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。也就是通常说Synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):
ObjectMonitor() { _header = NULL; _count = 0; // 记录个数 _waiters = 0, _recursions = 0; _object = NULL; _owner = NULL; _WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; FreeNext = NULL ; _EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表 _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; }
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有
ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时:
- 首先会进入 _EntryList 集合,当线程获取到对象的monitor后,进入 _Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;
- 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒;
- 若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);
Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,
同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。
监视器Monitor有两种同步方式:互斥与协作。
多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。
5. 那么有个问题来了,我们知道synchronized加锁加在对象上,对象是如何记录锁状态的呢?
答案是锁状态是被记录在每个对象的对象头(Mark Word)中,下面认识一下对象的内存布局
6. 对象的内存布局
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
- 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等。
Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是 如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
- 实例数据:存放类的属性数据信息,包括父类的属性信息;
- 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
1) 对象头
HotSpot虚拟机的对象头包括三部分信息,
- 第一部分是“Mark Word”
用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键。
这部分数据的长度在32位和64位的虚拟机(暂 不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。
对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额 外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
例如在32位的HotSpot虚拟机 中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示。
但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化。
变化状态如下:
32位虚拟机
锁状态 |
25bit |
4bit |
1bit |
2bit |
|
23bit |
2bit |
是否偏向锁(是否禁用偏向) |
锁标志位 |
||
无锁态 |
对象的hashCode |
分代年龄 |
0 |
01 |
|
轻量级锁 |
指向栈中锁记录的指针 |
00 |
|||
重量级锁 |
指向Monitor的指针 |
10 |
|||
GC标记 |
空 |
11 |
|||
偏向锁 |
线程ID |
Epoch |
分代年龄 |
1 |
01 |
这里锁标志位为最后一位, 有两个比特来标记, 占16个字节.
- 如果最后两位的锁标志位是01, 则锁的状态只可能是无锁态和偏向锁.
- 如果是00:就只能是轻量级锁
- 如果是10: 就是重量级锁
- 如果是11:就是GC标志
有1bit来标记是否是偏向锁. 如果是0 , 也就是后三位是001, 表示的是无锁态; 如果是101, 表示的是偏向锁.
64位虚拟机
现在我们虚拟机基本是64位的,而64位的对象头有点浪费空间, JVM默认会开启指针压缩,所以基本上也是按32位的形式记录对象头的。
手动设置-XX:+UseCompressedOops
- 第二部分: “Mark Word”
- 第三部分:
7. 验证无锁状态的mark word存储内容
首先, 引入一个依赖, 打印对象的头信息
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.10</version> </dependency>
然后运行代码, 就可以大运出对象的头信息了
package com.example.tulingcourse.chapter5; public class ObjectSize { public static void main(String[] args) { Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); } }
备注:我在打印的时候, 并没有成功, jar包直接引入是引入不进来的, 手动引入的jar包. 但是没有ClassLayout这个类
接下来看看这段代码的头信息
这里的第一条就是mark word的信息
在操作系统里面, 采用的是高位和低位. 这里采用的是低位, 需要将参数反过来
没有加锁
00000001000000000000000000000000
反过来看, 才是mark word的顺序
00000000000000000000000000000001
我们对照表格, 最后三位刚好是001
也就是说, 当对象没有任何锁的时候, 最后三位是001, 但是往前的29位存储的是hashCode和分代年龄
但这里全部都是0 , 好像并没有存储hashCode , 这是什么原因呢?
原因是对象在初始化加载hashCode的时候, 使用了类似懒加载的方式, 使用的时候才会去加载
8. 无锁升级为偏向锁的mark word内存存储
偏向锁的启动有一个延迟加载时间, 时间为4s. 为什么偏向锁会延迟加载呢? 这是因为, jvm在启动的时候,本身会启动好多个线程, 这些线程之间也会有锁竞争, 等待其他初始化线程的锁竞争稳定后, 启动偏向锁. 可以验证偏向锁的延迟加载.
比如下面这段代码:
public static void main(String[] args) { Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); synchronized (o) { System.out.println(ClassLayout.parseInstance(o).toPrintable()); } }
启动这个主线程, 当只有一个主线程启动的时候, 不存在竞争, 所以, 这时候会由无锁升级为一个偏向锁, 我们来看看内存结构. 偏向锁的锁标志位是101, 我们来看看是不是101.
如上面代码, 一共打印了两次内存空间. 我们看看, 两次的锁状态分别是
第一次
00000001000000000000000000000000
第二次
11101000111100101001100100000010
根据地位原则, 反过来看也不是101呀, 先打印的是01无锁, 再打印的是00轻量级锁. 这是怎么回事呢? 难道直接从无锁状态升级到轻量级锁了么?
这就是上面说的问题, jvm启动的时候会延迟启动偏向锁. 延迟多久呢, 大概是4s的时间.
为什么这样呢?
因为jvm启动的时候, 会依赖大量的hashmap, class, 各种对象, 这些对象里面也存在大量的同步块. 而且, jvm启动的时候其内部也会启动线程. 这十几个线程也会产生竞争. 所以, jvm为了避免偏向锁向轻量级锁, 再向重量级锁升级的过程, 为了减少锁升级带来的开销, 所以, 把偏向锁推迟启动了.
偏向锁有一个4s的时间延迟, 所以, 这里没有显示偏向锁.
我们在代码上加上5秒钟的延迟, 再来看看
public static void main(String[] args) throws InterruptedException { Thread.sleep(5000); Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); synchronized (o) { System.out.println(ClassLayout.parseInstance(o).toPrintable()); } }
我们把打印的内存信息提取出来
第一次:
00000101000000000000000000000000
第二次
000001010100000000010000100000011
根据低位原则, 反过来看. 那么第一次打印的是101偏向锁, 第二次打印的是101偏向锁. 我们来和之前没有锁的情况进行对比一下
未启动偏向锁, 没加锁 00000001 00000000 00000000 00000000 开启偏向锁 第一次: 无锁状态(匿名偏向,可偏向状态) 00000101 00000000 00000000 00000000 第二次: 有同步块 00000101 01000000 000100001 00000011
我们发现, 第一次没有锁的时候, 我们锁状态时01
在第二次, 加了偏向锁, 打印了两次内存, 第一次是在同步代码块以外, 这时候打印的锁状态是101偏向锁, 难道说, 启动了偏向锁以后, 我的对象没有加任何同步块, 也会加一个锁么?
通过观察, 我们发现, 这个时候, 锁的状态是101 ,但是其后面的状态码都是0, 这时候其实是无锁状态, 同时是匿名偏向, 可偏向状态.
看这个图, 偏向锁的钱23位本应该记录的是线程ID, 但是, 这里全部都是0, 这表示, 我当前是一个偏向锁, 但是还没有偏向于任何线程. 后面哪个线程抢到了偏向锁, 线程ID就记录哪个线程的.
我们再来看第二个
第二次: 有同步块
000001010100000000010000100000011
换算成大端
00000011000100010100000000000101
低3位是101, 高23位是线程ID: 00000011 00010001 0100000将其换算为10进制, 就是线程id了
9. 偏向锁升级为轻量级锁
public class BasicLock { public static void main(String[] args) throws InterruptedException { Thread.sleep(5000); Object o = new Object(); Log.info(ClassLayout.parseInstance(o).toPrintable()); Thread t1 = new Thread(() -> { synchronized (o) { Log.info(ClassLayout.parseInstance(o).toPrintable()); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } }); Thread t2 = new Thread(() -> { synchronized (o) { Log.info(ClassLayout.parseInstance(o).toPrintable()); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } }); t1.start(); t2.start(); Thread.sleep(2000); Log.info(ClassLayout.parseInstance(o).toPrintable()); } }
这里一共打印了四次sysout
其中, 头两次高位排序后, 仍然是101. 其中第一个是匿名偏向. 第二个记录了线程ID
再看看打印的第二条和第三条数据. 看高位排序后的后两位, 知道他是00, 00表示的是轻量级锁. 也就是从偏向锁向轻量级锁转换了.
为什么会从偏向锁向轻量级锁转换呢? 原因是, 整个程序是从上往下执行的, 上面执行完了, 才执行下面的进程. 有两个线程竞争同时去调用共享资源o, 但他们的竞争不激烈 所以, 会将偏向锁向轻量级锁升级.
11. 问题
一个偏向锁在调用hashcode以后会升级为一个轻量级锁
10. 轻量级锁升级为重量级锁
public class BasicLock { public static void main(String[] args) throws InterruptedException { Thread.sleep(5000); Object o = new Object(); Log.info(ClassLayout.parseInstance(o).toPrintable()); Thread t1 = new Thread(() -> { synchronized (o) { Log.info(ClassLayout.parseInstance(o).toPrintable()); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } }); Thread t2 = new Thread(() -> { synchronized (o) { Log.info(ClassLayout.parseInstance(o).toPrintable()); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } }); t1.start(); t2.start(); Thread.sleep(2000); } }
重量级锁, 什么时候升级为重量级锁呢? 在轻量级锁内部有一个while循环, 加入200次, 如果200还没有释放锁, 就是重量级锁. 我们通过Thread.sleep()来模拟
我们看到地位的最后两位是10. 通过查看上图, 我们知道10表示的商量级锁.
哪些信息会被压缩?
1.对象的全局静态变量(即类属性)
2.对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节
3.对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节
4.对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节
在Scott oaks写的《java性能权威指南》第八章8.22节提到了当heap size堆内存大于32GB是用不了压缩指针的,对象引用会额外占用20%左右的堆空间,也就意味着要38GB的内存才相当于开启了指针压缩的32GB堆空间。
这是为什么呢?看下面引用中的红字(来自openjdk wiki:
https://wiki.openjdk.java.net/display/HotSpot/CompressedOops)。32bit最大寻址空间是4GB,开启了压缩指针之后呢,一个地址寻址不再是1byte,而是8byte,因为不管是32bit的机器还是64bit的机器,java对象都是8byte对齐的,而类是java中的基本单位,对应的堆内存中都是一个一个的对象。
Compressed oops represent managed pointers (in many but not all places in the JVM) as 32-bit values which must be scaled by a factor of 8 and added to a 64-bit base address to find the object they refer to. This allows applications to address up to four billion objects (not bytes), or a heap size of up to about 32Gb. At the same time, data structure compactness is competitive with ILP32 mode.
对象头分析工具
运行时对象头锁状态分析工具JOL,他是OpenJDK开源工具包,引入下方maven依赖
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.10</version> </dependency>
打印markword
System.out.println(ClassLayout.parseInstance(object).toPrintable()); object为我们的锁对象
synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁, 性能较低。当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,,内置锁的并发性能已经基本与Lock持平。
synchronized关键字被编译成字节码后会被翻译成monitorenter 和 monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置。
每个同步对象都有一个自己的Monitor(监视器锁),加锁过程如下图所示:
锁的膨胀升级过程
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。从JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。下图为锁的升级全过程:
偏向锁
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。
默认开启偏向锁
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。
锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。锁消除的依据是逃逸分析的数据支持。
锁消除,前提是java必须运行在server模式(server模式会比client模式作更多的优化),同时必须开启逃逸分析
:-XX:+DoEscapeAnalysis 开启逃逸分析
-XX:+EliminateLocks 表示开启锁消除。