Java对象内存模型
前言
我们的一个Java对象在内存中究竟长什么样子,我们类文件最终会被编译为字节码文件,然后被类加载器加载,并加入到内存。我们的字节码文件是个二进制文件,虽然我们可以通过可以把.class文件反编译为JVM指令,但是还是无法观察到Java对象的信息。
初探内存模型
内存可视化工具
Java对象内存模型可视化工具,提供一个工具类,可以讲一个对象的内存信息变成可以打印(print)的形式。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
<dependency>
基本使用
我们new一个空的Object对象,我们看看打印结果是什么
@Test
public void test0() {
Object o = new Object();
String s = ClassLayout.parseInstance(o).toPrintable();
System.out.println(s);
}
结果
看不懂,别着急。
- OFFSET表示初始字节,SIZE表示字节大小,Instance size:16 bytes 实例大小,VALUE表示值。
我们得到几个信息:
- 一个空的Obect对象有16字节的大小,一个字节是八位,所以一共是128位,也就是128个0101这种二进制组成的。
- OFFEST=0,SIZE=4表示从第0的个字节开始,右移四位。我们看到一个空的对象的前面8个字节几乎没有信息,如果我们赋予Object一个值,或者其他操作,说不定就有信息了。
- 前12位的描述都是object header(对象头),第12字节往后4个字节的描述是(loss due to the next object alignment)没有指向下一个对象的地址。
根据以上信息我们试着给这个Object赋值
@Test
public void test0() {
Object o = new Integer(2);
String s = ClassLayout.parseInstance(o).toPrintable();
System.out.println(s);
}
我们看到第12字节往后4个字节的位置存放了int类型的值,VALUE为2。
我们知道int类型的长度为32位,也就是4个字节,那如果我们给Object赋值一个Long会怎么样?
@Test
public void test0() {
Object o = new Long(123);
String s = ClassLayout.parseInstance(o).toPrintable();
System.out.println(s);
}
我们知道Long占用8个字节,显然这边4个字节是不够的,我们发现最终申请了24字节的大小,其中long占用了8字节,对象头4+4+4=12字节,还有4个字节的描述是alignment/padding gap(对齐填充)。
对齐填充
关于这个对齐填充,我查阅了一下资料,总结这么几点:
Java使用对齐填充,为什么对象必须是8个字节的倍数?
- 其目的是alignment,允许以某些空间为代价更快地访问内存。如果数据未对齐,那么处理器需要在加载内存后进行一些调整才能访问它。
- 此外,垃圾收集简化(加速)越大最小分配单元的大小。
- Java不太可能需要8个字节(64位系统除外),但由于32位架构是常态在创建Java时,Java标准中可能需要4字节对齐。简单的说,8的倍数就完事了。
理性分析
光看这个打印图说明不了什么,我们看一下Java对象内存模型示意图,来找一找方向。
我们看到,一个Java对象由对象头markword,类型指正class pointer,实例数据 instance data 和对齐 padding组成,之前控制台结果我们知道,一个new Object()对象的对象头分为三部分,每部分4字节,实例数据占用4个字节。结合这个图我们对普通的对象进行分析,数组多了一个数组长度四个字节,也就是是说一个数组的长度最多占用2^31的大小;
如图:
- markword占用8个字节
- class pointer占用4个字节
- 实例数据为空时,对齐padding占4个字节
可以把markword和class pointer这12个字节概括为对象头,而实例数据更具其大小,可能会导致对象的大小为16或者24字节。
class pointer
@Test
public void test0() {
Object o = new Object();
Object o2 = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
System.out.println(ClassLayout.parseInstance(o2).toPrintable());
}
我们看到,不同的Object对象,class pointer(OFFSET 8 SIZE 4)是一致的,也就是说,我们这个class pointer代表了这个对象是什么类型的,而且每次运行这个代码,结果都是一致的,所以这个二进制码是精确代表某个类的(下面我们讲hashcode的时候,结果是变化的)。
markword
我们看到markdown里面默认有一个01,实际上01代表的是字节,1的二进制是0001。其他值空空如也,那么我们的markword里面又涵盖什么信息呢?
我们知道Java的每个对象都可以当一把锁使用,我们参考如下图,首先一共是64bit,也就是8个字节,这就是markword里面的的信息。
观察此图,我们得到几个结论:
- 默认情况下,一个类是无锁态,我们会有3bit记录锁的标记,4bit记录分带年龄,25bit记录hashcode。
- 当我们把类传入synchronized里才会附加一些锁的信息。
我们一一进行测试:
分代年龄
我们知道垃圾回收清除不掉的时候,我们给清不掉的对象分代年龄加1,我们先答应一下没进行垃圾回收的,再进行一个垃圾回收,再打印看看。
我们看到加了一位,之前我们的图可知,一个对象默认有一个01的锁状态还有一个1代表偏向锁,所以默认有一个001,这个新的1代表分代年龄1岁。由于图中得知分代年龄占用4个bit,所以一个类最多15岁。JVM调优问你永久代经常OOM怎么办,你说分代年龄调整到31,让他进行永久代的机会变难一点,不好意思,你可以走了。
hashcode
32位操作系统
64位操作系统
以64位操作系统为例:我们看到,无锁态时,锁标记我们看到了,为什么看不到这个hashcode呢?这里有点像懒加载,我们知道hashcode是个本地方法,C++实现。当我们不去调用hashcode方法时,我们的Java对象头是不会记录信息的。
我们调用以下hashcode方法
@Test
public void test0() {
Object o = new Object();
o.hashCode();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
我们看到结果发生了变化
那么我们Object算出来的hashcode和这些0101,有什么关系吗?我们知道hashcode是31位的,我们对比一下:
@Test
public void showHash(){
System.out.println("hashcode:"+Integer.toBinaryString(object.hashCode()).substring(0,7));
System.out.println("hashcode:"+Integer.toBinaryString(object.hashCode()).substring(7,15));
System.out.println("hashcode:"+Integer.toBinaryString(object.hashCode()).substring(15,23));
System.out.println("hashcode:"+Integer.toBinaryString(object.hashCode()).substring(23,31));
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
我们发现:在不重写Object的hashcode方法时,我们对象头的hashcode等于我们object算出来的hashcode值
奇怪,为什么顺序反了?是这样的,我ClassLayout工具只是负责打印对象头的二进制值,实际上我们JVM读取对象头信息时,是从高位往低位读取的。
**注意!**这并不代表逆序,而是先顺序的读取最高的8位,然后读取稍低的8位,以此类推。所以我们实际读取到的顺序是(图中白色的顺序):
00000000 00000000 00000000 01100110 11001101 01010001 1100011 00000001
对比一下,之前的图,图确实不错,顺序是对的!
之前我们创建空对象的时候我们看到01就猜想是不是锁标志位01,然后垃圾回收一次时,分代年龄还在01的前面还要在前面一位置为1,所以我们猜想01前面的0是不是偏向锁标识位,我们这回证明了吧!
锁信息
偏向锁
好了这个对象头已经被我们肢解的差不多了,我们跑几个锁试试。关于这些锁的知识,我单独整理了一篇博客。
@Test
public void test4() {
Object o = new Object();
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
根据示意图我们得知,此时我们的Object对象从无锁态变成偏向锁态,对象头记录了当前的线程的信息。
分析一波:
我们先整理一下这个对象头,实际顺序是:
我们看到和图片有点对不上啊!偏向锁还有一个隐式的条件,我们的Java程序如果4秒之内就执行完了,说明是个小程序,还锁什么锁啊?
这里我们少一个条件,那就是让线程睡个5秒,来保证偏向锁的开启。(JVM启动之后会有4秒的延迟才会开启偏向锁)
public class Test1 {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
Object o = new Object();
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
运行结果
哈哈,来了老弟
在讲轻量级锁之前,我发现一个很有意思东西
public class Test1 {
static Object o = new Object();
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
如果把Object作为静态的,那么我们发现这个锁标志位变成了00,按照标识图。这是轻量级锁的意思,但是我们执行完synchronized再打印一个锁信息,锁又是无锁态有人知道为什么吗?欢迎评论
轻量级锁
没搞懂,以后再来
重量级锁
这还不简单吗?睡久一点肯定重量啊.
我们知道锁不能降级只能升级,我们跑两个线程,第一个线程进去睡10秒,第二个线程等10秒早重量了!然后等到两个线程执行完,我们再看看锁信息
public class Test1 {
static Object o = new Object();
public static void main(String[] args) throws InterruptedException {
Thread.sleep(10);
for (int i = 0; i < 2; i++) {
new Thread(Test1::run).start();
}
TimeUnit.SECONDS.sleep(15);
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
public static void run() {
synchronized (o) {
try {
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
结果
总结:
- Java对象由markword,classpoint,instance data,padding组成,其中
- markdword占用8字节 存放锁,分代年龄和hashcode信息。
- classpoint占用4字节 表示这个对象是哪个类
- 实例数据根据数据类型有所不同 int 4字节 long 8字节
- padding为对齐填充,保证整个Java对象的内存空间是8的整数倍
- hashcode只要在调用hashcode方法的时候才会加入对象头中吗,每次计算结果都不一样
- 数组则多一个数组长度,以后面试官问你为什么数组的长度是2^31答的上来的