一、Java 对象内存布局
1、对象内存布局
一个对象在 Java 底层布局(右半部分是数组连续的地址空间),如下图示:
总共有三部分总成:
1. 对象头:储对象的元数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁等等。
2. 实例数据:存储对象实际的数据内容,即程序员定义的各种类型的变量。
3. 对其填充:为了 JVM 能够更快地访问对象内部的数据,会在实例数据后面填充额外的空间,使得对象的大小能够被虚拟机的内存管理系统所整除(一般都是8的倍数)。
具体对象头的大小和实例数据的大小,与 Java 虚拟机的具体实现、对象的类型、虚拟机运行时参数等都有关系,一般不是固定的数值。需要注意的是,数组对象与普通对象的内存布局是不一样的,数组对象会额外存储数组长度信息。
1.1、对象头
点击查看 hotspot 官网文档
(1) mark word 标记字
对象头两部分组成:
1. 对象标记(mark word):储对象的元数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁
2. 类元信息(klass pointer):存储的是指向该对象类元数据(klass)所在的首地址。
在 64位操作系统上,Markword 占了8个字节,类型指针占了8个字节,一共占16个字节。也就是说你随便 new 一个对象对象头就直接占了 16个字节(但是不一定,可能会压缩类型指针)。
(2) klass pointer 类型指针
可以参考下图,类型指针指向方法区,比如有个 Customer 类,new 一个 Customer 实例,这个实例的类型指针指向方法区中的 Customer 类元信息。
1.2、实例数据
存放类的 Field 数据信息,包括父类的属性信息;如果是数组实例部分,还需要包括数组的长度,这部分内存按照4个字节对齐。
举个例子如下:
public class MarkwordDemo {
public static void main(String[] args) {
new Apple();
}
}
class Apple {
}
直接 new 一个空属性 Apple 实例,在内存中就已经占用16字节(不考虑类型指针压缩),如果 Apple 类中还有其他属性呢?如下所示:
public class MarkwordDemo {
public static void main(String[] args) {
new Apple();
}
}
class Apple {
int size = 100;
char a = 'a';
}
一个 int
类型占 4个字节,一个 char
字符占1个字节,所以 new 一个 Apple 实例就会占用 16+5 = 21 个字节,但是最终会占用24个字节,因为 Java 底层为了方便内存管理,需要将其对齐填充,并且一般是8的倍数,所以是24字节。
1.3、对其填充
虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐,这部分内存按照8字节补充对齐。
二、同步锁底层探究
在 markOop.hpp
源码中有如下一段注释,如下图示:
把上述注释简化后,得到64位虚拟机对象头示意图,如下:
知道对象内部基本结构,那么下面来看看之前的 synchornized 同步锁在对象头中是怎么的变化过程。
1、 Java 查看对象内存布局
可以借助 Java 工具类 jol,帮助查看 new Object() 在内存中的布局创,如下所示:
1、先引入依赖
依赖包推荐使用 0.9 版本的,其他版本可能有不一样的效果,珍重。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
2、演示代码
class MyObject {
}
public class ObjectMarkWordDemo {
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
}
}
直接 new class MyObject(),然后通过 ClassLayout 工具类查看内存布局,输出结果如下:
在 MyObject 类添加两个类型的变量,如下所示:
class MyObject {
int i = 25;
boolean flag = false;
}
public class ObjectMarkWordDemo {
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance(new MyObject()).toPrintable());
}
}
然后输出之后的 Java 内存布局如下图示:
从上面可以看到,类型指针按理应该是占8个字节的,但现在是占用4个字节,我们可以通过命令查询 JVM 启动运行了哪些命令:
java -XX:+PrintCommandLineFlags -version
从上面 +
号就可以看出 JVM 默认采取类型指针压缩,可以节约内存空间,现在去修改一下这个参数设置,如下图示:
-XX:-UseCompressedClassPointers
开启之后,在重新测试下,输出结果如下:
学习上面已经知道怎么查看 Java 内存布局,现在再来学习一下,synchornized 锁优化 、锁升级相关。
2、synchornized 锁研究
在来看看对象头中 mardkword
标记字内存结构,如下图示:
synchornized 锁优化背景:
用锁能够实现安全性,但是也会带来性能的下降。无锁能够基于线程并提升程序性能,但是会带来安全性下降,那么怎么才能做到平衡呢?
所以在 jdk1.5开始就采取 synchornized 锁升级
来提高程序性能,并且做到程序安全性。
在 jdk1.5 之前都是 synchornized 都是使用的操作系统重量级锁,每次上锁都需要进行用户态
、内核态
之间的切换,切换的时候又伴随很多数据拷贝过程,性能很低。
Java 线程是映射到操作系统原生线程之上的,如果要阻塞或者唤醒一个线程就需要操作系统介入,需要在用户态和内核态之间切换,这种切换会消耗大量系统资源,因为用户态和内核态都有各自专用的内存空间,专用的寄存器等,用户态切换到内核态需要传递许多变量、参数给内核,内核也需要保存好用户态在切换映射的一些寄存器值、变量等,以便于内核态调用结束后切换回用户态继续工作。
在 Java 早期版本,synchornized 属于重量级锁,效率低下,因为监视器(Monitor
)是依赖底层操作系统的 Mutex Lock
实现的,挂起和恢复线程都需转入内核态完成,阻塞或者唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态切换需要消耗 CPU 时间,如果通过代码块中内容过于简单,这种切换成本太高。
比如我们在代码块中加上 synchornized 关键字,代码如下:
class MyObject {
int a = 25;
char b = 'b';
}
public class ObjectMarkWordDemo {
public static void main(String[] args) {
MyObject myObject = new MyObject();
new Thread(()->{
synchronized (myObject) {
System.out.println(">>>>>>");
}
}).start();
}
}
在 Java 层面加上一个 synchronized 关键字,底层默认会加上一个看不见的锁—Monitor 锁
,如下图示:
那么 Monitor
是如何与 Java 对象以及线程进行关联?
- 如果一个 Java 对象被某个线程锁住,该对象中的 markword 字段中的 lock word 会指向
Monitor
的起始地址。 Monitor
的 Owner 字段会存放拥有相关联对象锁的线程 ID
3、锁优化过程
(1) 无锁
看下面这段代码没有加锁,如下所示:
public class ObjectMarkWordDemo {
public static void main(String[] args) throws InterruptedException {
Object abc = new Object();
System.out.println(ClassLayout.parseInstance(abc).toPrintable());
}
}
如果无锁,正常一个对象在 Java 内存中对象中的 markword 标记字,如下图示:
通过 Java 打印出信息如下:
注意上述展示的结果倒过来看,蓝色框框的001
表示此时无锁状态
,在无锁状态时
红色框框的31位表示 hashCode
,其中一位是忽略补0。但是发现 hashCode 没有发现展现出来,是因为这个操作是懒加载
,需要调方法才会触发 hashCode。例如下面代码:
public class ObjectMarkWordDemo {
public static void main(String[] args) {
MyObject myObject = new MyObject();
System.out.println("十进制表示: myObject.hashCode() = " + myObject.hashCode());
System.out.println("二进制表示:"+Integer.toBinaryString(myObject.hashCode()));
System.out.println("十六进制表示:"+Integer.toHexString(myObject.hashCode()));
System.out.println(ClassLayout.parseInstance(myObject).toPrintable());
}
}
输出结果如下:
十进制表示: myObject.hashCode() = 1435804085
二进制表示:1010101100101001010000110110101
十六进制表示:5594a1b5
为了方便观察,把 hashCode 编码各个进制位打印出来。从右边往左开始拷贝(从右边往左边开始8个字节8个字节拷贝出来组成一个长串,前25位属于 unused
暂时不管它,后面31位属于 hashCode(蓝色框框的),红色框框3位表示锁相关,001
表示无锁
状态)。第一拷贝:1010101(前面的0是补位忽略,不要拷贝,就是前面25位中 unused 的其中1位而已),第二个拷贝:10010100,第三个拷贝:10100001,第四个拷贝:10110101 然后连成一串就和上面打印出来的二级制(1010101100101001010000110110101)一模一样,这31位bit位就是存放的 hashCode。
从上面也可以看出,此时无锁状态时就是用 001
表示的。
(2) 偏向锁
当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁,如下图示(红色框):
只要哪个线程获取到偏向锁,就会把当前线程指针保存到这个对象的前54位中(无锁
保存 hashCode 码),并且偏向锁位置成1,
为什么需要2个 bit 位表示锁标志位?
具体来说,synchronized 锁最开始是无锁状态,当第一个线程来竞争锁时,会将对象头中的锁标志位修改为偏向锁,然后将线程 ID 记录在对象头中,表示该线程获得了偏向锁。当第二个线程来竞争锁时,如果发现对象头中记录的线程 ID 和当前线程 ID 一致,那么它就可以获得锁,否则就需要撤销偏向锁,并转为轻量级锁状态。当有多个线程竞争锁时,就会进入到重量级锁状态。因此,为了实现锁升级过程,Java 在对象头中增加了两个 bit 位来表示锁标志位,以实现从无锁
状态到偏向锁
状态、再到轻量级锁
状态、最后到重量级锁
状态的转换过程。
这里有四种情况,所以刚好使用2bit表示上面出现的四种情况 。
假如有个线程执行到 synchornized
代码块时,JVM 使用 CAS 操作把线程指针 ID 记录到 mark word 中,并修改偏向锁位,标示当前线程获得到该锁。锁对象变成偏向锁
(通过 CAS 修改对象头中的锁标志位)。执行完同步代码块之后,线程并不会主动释放偏向锁。
线程获得了锁,可以执行同步代码块。当线程2到达同步代码块时会判断此时持有锁的线程是否是自己,如果是自己的线程 ID,那说明还持有这个对象的锁,就可以继续执行同步代码块。由于之前没有主动去释放偏向锁,这里也就不需要重新加锁(不用重新去调用操作系统的 Mutex
上锁)。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎在这里没有额外开销,性能极高。
①查询偏向锁是否开启
java -XX:+PrintFlagsInitial | grep BiasedLock
运行结果:
intx BiasedLockingBulkRebiasThreshold = 20 {product}
intx BiasedLockingBulkRevokeThreshold = 40 {product}
intx BiasedLockingDecayTime = 25000 {product}
// 然后偏向锁开启之后默认会有4s钟的延迟,测试的时候需要注意,可以将这个值设置成0,方便查看效果
intx BiasedLockingStartupDelay = 4000 {product}
bool TraceBiasedLocking = false {product}
// JVM 默认开启了偏向锁的设置
bool UseBiasedLocking = true {product}
②开启偏向锁设置
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
通过展示结果 UseBiasedLocking = true 可以知道 JVM 默认是开启偏向锁,但是并不是程序启动就立即开启偏向锁,而是需要延迟 4s 后才会真正开启偏向锁。
为什么要延迟4s开启偏向锁?
由于偏向锁的获取需要一定的时间开销,因此JVM并不是在对象创建时立即开启偏向锁。相反,JVM在对象创建后,等待一定的时间(默认为4秒),以观察该对象的使用情况。如果在这段时间内,只有一个线程访问了该对象,那么JVM就会将对象的锁标志位设置为偏向锁,并将线程ID记录在对象头中,表示该线程已经获取了该对象的锁。如果在这段时间内,有多个线程访问了该对象,那么JVM就不会将对象的锁标志位设置为偏向锁,而是直接将锁标志位设置为轻量级锁或重量级锁,使用常规的加锁方式。
这种等待一定时间才开启偏向锁的策略,是为了避免在短时间内频繁创建和销毁对象,导致偏向锁的开销大于加锁的性能损耗。
演示效果是,需要将偏向锁延迟设置成0s,如图图示:
VM 中的命令如下:
-XX:BiasedLockingStartupDelay=0
演示代码如下:
public class ObjectMarkWordDemo {
public static void main(String[] args) {
Object abc = new Object();
new Thread(() -> {
synchronized (abc) {
// 注意这里不要写任何代码操作
System.out.println(ClassLayout.parseInstance(abc).toPrintable());
}
}).start();
}
}
注意:在上述输出语句上不要写其他代码
输出结果如下:
可以对内布局中已经变为偏向锁 101
。但是现在只是不存在锁竞争的情况下,如果一旦发现竞争就会进行锁撤销
,去释放锁变成轻量级锁
。
(3) 偏向锁撤销
问题:偏向锁撤销带来性能严重下降?
偏向锁撤销
是指在偏向锁状态下,由于竞争或者其他原因,需要将对象的锁状态恢复到无锁状态的过程。偏向锁撤销是指撤销偏向锁,恢复到无锁状态。
在偏向锁状态下,如果有其他线程尝试获取锁,则需要先撤销偏向锁。撤销偏向锁的过程需要检查对象的 hashCode 是否发生改变,如果 hashCode 发生改变,则需要撤销偏向锁,否则可以直接将锁升级为轻量级锁。在撤销偏向锁的过程中,需要重新偏向、清除偏向锁标志、设置线程 ID 为 0 等。
偏向锁撤销的过程是比较耗费性能的,因此需要尽量避免偏向锁撤销的情况发生,尤其是在高并发的场景下。
优化: 在竞争激励情况下,可以关闭偏向锁,直接升级到轻量级锁。
偏向锁撤销
案例如下:
public class ObjectMarkWordDemo {
public static void main(String[] args) throws InterruptedException {
Object abc = new Object();
synchronized (abc) {
System.out.println("偏向锁:" + ClassLayout.parseInstance(abc).toPrintable());
}
System.out.println("偏向锁:" + ClassLayout.parseInstance(abc).toPrintable());
new Thread(() -> {
synchronized (abc) {
System.out.println(">>>>>>发生竞争锁,触发偏向锁撤销...");
}
}).start();
System.out.println("偏向锁撤销:" + ClassLayout.parseInstance(abc).toPrintable());
}
}
(4) Lock Record
问题:什么是 Lock Record ?
线程A在运行期间会在栈帧里面创建一个空间,叫做 Lock Record
记录,用来存储锁记录。当虚拟机检测到这个对象是无锁状态时,就会在这个线程的栈帧上面创建一个这个空间,存储来自 Mark Word
锁相关信息。
Lock Record
里面的数据都是拷贝 Mark Word
里面的,因为锁的相关信息都是在 Mark Word
上面。同时,这个拷贝过程官方名为:Displaced Mark Word
。最终通过 CAS 自旋操作,把这个栈帧的指针写到 Mark Word 中,写成功,表示这个线程A获取锁成功。写失败,表示这个锁被其他线程占用。
(5) 轻量级锁
轻量级锁
为了在线程近乎交替执行同步
代码时提高效率。
主要目的,在没有多线程竞争的前提下,通过 CAS
减少重量级锁使用操作系统互斥量产生的性能消耗,说白了先自旋在阻塞。升级时机,当关闭偏向锁功能或者多线程竞争偏向锁会导致升级为轻量级锁。
假如线程A已经拿到锁,这时候线程B又过来抢夺该对象的锁,由于该对象的锁已经被线程A拿到,当前该锁已经是偏向锁;而线程B在争夺发现 Makr Word 中线程ID不是自己的,那么线程B就会进入 CAS 自旋操作希望能够获取到锁。此时线程B操作中有两种情况:
①如果获取锁成功,直接替换 Mark Word 中的线程ID为线程B自己的ID,重新偏向于线程B,该锁保持偏向锁状态,A线程结束,B线程上位。
②如果获取锁失败,则偏向锁升级为轻量级锁,此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码块,而在竞争的线程B会进入自旋等待该轻量级锁。
轻量级锁自旋次数过多,造成CPU资源浪费,在 JDK6 之前默认自旋10次或者自旋线程数量超过 cpu 核数一半直接放弃自旋,升级为重量级锁 10
。
修改自旋次数命令:
-XX:PreBlockSpin=10
轻量级锁
案例如下(可以把偏向锁延迟时间恢复):
-XX:BiasedLockingStartupDelay=4000
package com.xxl.job.admin.mytest;
import org.openjdk.jol.info.ClassLayout;
import java.util.concurrent.TimeUnit;
public class ObjectMarkWordDemo {
public void test() {
Object obj = new Object();
synchronized (obj) {
System.out.println("111");
}
synchronized (obj) {
System.out.println("111");
}
synchronized (obj) {
System.out.println("111");
}
}
public static void main(String[] args) throws InterruptedException {
// 打印 JVM 相关的信息
// System.out.println(VM.current().details());
// 打印每个对象是否为 8 的整数倍大小
// System.out.println(VM.current().objectAlignment());
MyObject myObject = new MyObject();
System.out.println(Integer.toHexString(myObject.hashCode()));
new Thread(()->{
// 在 myObject 对象头上进行加锁(默认直接干到轻量级锁,这里我非要把他干到偏向锁状态)
// 默认是开启偏向锁的,所以这里我们只需要把开启偏向锁的延迟时间修改成 0 方便看效果 -XX:+BiasedLockingStartupDelay=0
synchronized (myObject) {
// 给这个线程加锁,并且还设置了偏向线程 ID
System.out.println(ClassLayout.parseInstance(myObject).toPrintable());
}
}).start();
TimeUnit.MICROSECONDS.sleep(500);
// 锁被释放了,所以这里打印的肯定是无锁状态 001
System.out.println(ClassLayout.parseInstance(myObject).toPrintable());
}
}
class MyObject {
}
运行结果:
76fb509a
# WARNING: Unable to attach Serviceability Agent. You can try again with escalated privileges. Two options: a) use -Djol.tryWithSudo=true to try with sudo; b) echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
com.xxl.job.admin.mytest.MyObject object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) e8 29 c3 0a (11101000 00101001 11000011 00001010) (180562408)
4 4 (object header) 03 00 00 00 (00000011 00000000 00000000 00000000) (3)
8 4 (object header) 44 c1 00 f8 (01000100 11000001 00000000 11111000) (-134168252)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
# WARNING: Unable to attach Serviceability Agent. You can try again with escalated privileges. Two options: a) use -Djol.tryWithSudo=true to try with sudo; b) echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
com.xxl.job.admin.mytest.MyObject object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) e8 29 c3 0a (11101000 00101001 11000011 00001010) (180562408)
4 4 (object header) 03 00 00 00 (00000011 00000000 00000000 00000000) (3)
8 4 (object header) 44 c1 00 f8 (01000100 11000001 00000000 11111000) (-134168252)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
(6) 重量级锁
当在轻量级锁一直自旋时,就需要考虑是不是用重量级锁还可以提高性能。
public class ObjectMarkWordDemo {
public static void main(String[] args) throws InterruptedException {
Object abc = new Object();
for (int i = 0; i < 2; i++) {
Thread thread = new Thread(() -> {
synchronized (abc) {
System.out.println("重量级锁:" + ClassLayout.parseInstance(abc).toPrintable());
}
});
thread.join();
thread.start();
}
}
}
(7) 锁粗化
类似下面这个例子:
public class ObjectMarkWordDemo {
Object abc = new Object();
public void show() {
synchronized (abc) {
// 复杂操作
}
synchronized (abc) {
// 复杂操作
}
synchronized (abc) {
// 复杂操作
}
}
}
使用的一直都是同一把锁,并且前后执行时间都非常短,JVM 会进行优化,将这些锁直接合并成为一个大的锁,可以称之为锁膨胀
,提供程序性能。
(8) 锁消除
类似下面这个例子:
public class ObjectMarkWordDemo {
public void show() {
Object abc = new Object();
synchronized (abc) {
// 复杂操作
}
}
}
show() 方法每次都会加锁,但是这个锁根本没有任何意义,所以 JVM 底层会把它优化掉提高程序性能。
三、常用命令
设置 JVM 堆大小
-Xms10m -Xmx10m
查询 JVM 启动运行了哪些命令
java -XX:+PrintCommandLineFlags -version
关闭对象头中类指针的压缩配置
-XX:-UseCompressedClassPointers
查询偏向锁是否开启
java -XX:+PrintFlagsInitial | grep BiasedLock
运行结果:
intx BiasedLockingBulkRebiasThreshold = 20 {product}
intx BiasedLockingBulkRevokeThreshold = 40 {product}
intx BiasedLockingDecayTime = 25000 {product}
// 然后偏向锁开启之后默认会有4s钟的延迟,测试的时候需要注意,可以将这个值设置成0,方便查看效果
intx BiasedLockingStartupDelay = 4000 {product}
bool TraceBiasedLocking = false {product}
// JVM 默认开启了偏向锁的设置
bool UseBiasedLocking = true {product}
开启偏向锁设置
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0