前言
写代码new
过那么多对象,你知道Cat cat = new Cat();
这行代码,在JVM内部的执行流程是什么吗?
我们都知道对象,那你听过对象头吗?你知道对象头内部的组成吗?
我们都知道通过关键字new
对象,并且对象实例通常是放在堆内存上,那么你知道JVM内部给一个新生对象分配内存的机制是怎样的吗?
不知道也不用慌,下面我们一起来深入了解一下。
对象的创建
当执行代码Cat cat = new Cat();
时,JVM内部会涉及一系列步骤来完成对象的创建,下图为大致的执行流程:
1. 类加载检查
当JVM遇到new
指令时,首先会检查是否已经加载过Cat
类,就是检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。如果还未加载,它会从类路径中寻找并加载Cat
类的字节码文件等执行类的加载过程。
这里所说的new
指令对应到语言层面上,除了代码中使用new
关键字,还涵盖一些与对象创建相关的操作,主要如下:
new
关键字直接创建对象- 对象客隆
- 反射机制
- 对象序列化
2. 分配内存
一旦Cat
类已经被加载,JVM会在堆内存中分配足够大小的连续空间来存放一个Cat
对象。
对象所需内存的大小,在类加载完成之后就已经完全确定了。为对象分配空间的任务,相当于是要把一块大小确定的内存从Java堆中划分出来。
这个步骤有两个问题:
-
如何划分内存?
-
在并发的情况下,可能出现正在给对象A分配内存,指针还没来得及修改,此时,对象B也在原指针指向的位置分配内存。
这个问题可能有点抽象。
这里的"指针"是指JVM内部维护的一个数据结构,用于记录已经分配的内存块的位置。
当一个线程A在给一个新生分配内存时,它会向JVM申请一块内存块,并更新"指针位置"来指向分配后的下一个可用内存块的位置。另一个线程B在同时分配内存时,可能看到的还是旧的"指针位置",然后尝试分配内存并更新指针位置,导致指针被覆盖,从而引发内存分配的冲突和错误。大致如下图:
内存划分的方式
-
"指针碰撞"(Bump the Pointer)(默认用指针碰撞) 如果Java堆中内存布局是规整的,所有已分配的内存块在一侧,空闲的内存块在另一侧,通过一个指针作为分界点,标记已分配和空闲内存的边界。当分配内存时,只需要将指针向空闲内存的那一侧移动一段与对象大小相等的距离,从而就完成内存的分配。
内存的分配与内存的回收紧密相关。在具有压缩过程的回收策略中,代表的GC回收器:Serial、ParNew、G1。
-
"空闲列表"(Free List) 在Java堆中,内存块可能是分散的、不规整的,已经使用的内存块和和空闲的内存块可能会相互交错。在这种情况下,就无法简单地使用指针碰撞的方式进行内存分配。
为了管理这种不规整的内存布局,JVM需要维护一个"空闲列表",记录哪些内存块是可用的,即还没有被分配的。当需要分配内存时,JVM会从空闲列表中查找一个足够大的内存块,然后将其分给对象实例。同时更新空闲列表上的记录,标记该内存为已使用。
没有压缩过程的回收策略,代表的GC回收器:CMS(Concurrent Mark-Sweep)。
解决并发问题的方案
-
CAS(compare and swap)
JVM采用
CAS+失败重试
的方式,保证更新操作的原子性来对分配的内存空间的动作进行同步处理。CAS允许线程在修改数据之前,先与期望的值进行比较,如果一致,则执行更新操作,否则重试。通过这种方式,可以在并发情况下确保操作的原子性。
-
本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
解决并发问题,最直接的想法就是加全局锁,但是这个性能会很差。为了优化性能,考虑为每个线程分配一个线程本地私有的内存池,TLAB就是JVM在Hotspot1.6所引入的优化技术。
我们一般认为Java中new的对象都是在堆上分配,其实这个说法不够准确。应该是大部分对象在堆上的TLAB分配 ,还有一部分在栈上分配 ,或堆上直接分配(可能Eden区也可能老年代,对于一些GC算法,可能直接在老年代上分配,例如G1 GC 中的 humongous allocations(大对象分配))。
这里,我们只关心TLAB分配。TLAB的核心思想,在线程启动时,在堆的Eden区预先分配一小块私有内存,这样,每个线程都有自己的一块内存区域来进行对象的内存分配,从而避免线程间竞争,提高性能。其内部结构,包含start、top(归属线程最后一次申请的尾位置)、end。如下图所示:
-
TLAB分配机制
首先都会检查是否启用了TLAB,如果启用了,则会尝试 TLAB 分配;如果当前线程的 TLAB 大小足够,那么从线程当前的 TLAB 中分配;如果不够,但是当前 TLAB 剩余空间小于TLAB最大浪费空间限制(一个动态的参数值),则丢弃当前的TLAB回归Eden,从堆上(一般是 Eden 区) 重新申请一个新的 TLAB 进行分配。否则,直接在 TLAB 外进行分配(通常是Eden区),TLAB 外的分配策略,不同的 GC 算法不同。例如G1:
- 如果是 Humongous 对象(对象在超过 Region 一半大小的时候),直接在 Humongous 区域分配(老年代的连续区域)。
- 根据 Mutator 状况在当前分配下标的 Region 内分配。
-
TLAB相关参数 :通过
-XX:+/-UseTLAB
参数来设定虚拟机是否使用TLAB(JVM会默认开启-XX:+UseTLAB
),-XX:TLABSize
指定TLAB大小。
-
3. 初始化零值
在分配内存后,JVM会将内存块的内容初始化为默认的零值,主要是将对象的成员变量初始化,这些默认值值根据成员变量的类型而定。
比如代码Cat
类:
arduino
public class Cat {
String name;
int age;
}
在执行Cat cat = new Cat();
这行代码时,JVM会创建一个新的 Cat
对象,在给此对象分配内存后,会将其内部的 name
成员变量初始化为 null
,age
成员变量初始化为 0
。
如果使用的是TLAB,这一工作过程可以提前至TLAB分配时进行。也就是说,当对象的内存分配是通过TLAB进行的时候,内存空间初始化可以在TLAB分配的过程中完成。
这个优化的目的是,如果对象的内存分配是在TLAB中完成的,那么可以在分配的同时就将内存初始化为零值,从而避免了后续对该内存空间的初始化操作。这有助于提高对象分配和初始化的效率。不使用TLAB时,分配和初始化是分开进行的,是因为合并在一起可能会增加分配的成本,带来性能上的开销。
4. 设置对象头
初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data)和对齐填充(Padding)。 HotSpot虚拟机的对象头包括两部分信息:
-
第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
-
对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
32位对象头
64位对象头
关于Kclass Point类型指针 与 类被加载后在堆中对应的一个java.lang.Class对象,经常会容易混淆。 两者区别:
1、堆中的Class对象 :
可理解为类装载完之后,JVM会在堆中创建一个对应的java.lang.Class
对象,用来表示这个类的元数据信息,包括类名、父类、实现的接口、方法、字段等等。通过这个Class对象,我们可以获取关于类的各种信息,如类名、方法、字段等。在Java中,我们可以使用反射机制来操作这个Class对象,获取类的信息、调用方法等。
例如:
ini
Class<? extends Math> mathClass = math.getClass();
String name = mathClass.getName(); // 获取类名称
Field[] fields = mathClass.getDeclaredFields(); // 获取类所有属性名
2、Klass Point类型指针
类型指针是JVM的内部机制,由C++实现。我们知道类的元数据信息是被加载到方法区中,例如如下的代码,math对象要去找到compute方法在元数据区中的代码,实际上jvm内部就是通过Kclass Point类型指针去找的。。
typescript
public static void main(String[] args) {
Math math = new Math();
int result = math.compute();
}
对应到上篇掌握JVM内存模型,不再是面试绊脚石的内存模型图,两者区别如下所示:
5. 执行<init>
方法
接下来,执行<init>
方法,也就是按程序员的意愿,对对象进行初始化。就是为属性赋值 以及 执行构造方法。
这里的为属性赋值,与上面「初始化了零值」阶段不同,这里是按程序员的期望值赋的值。比如代码Cat
类:
arduino
public class Cat {
String name;
int age;
public Cat(String name, int age) {
this.name = name;
this.age = age;
}
}
在执行Cat cat = new Cat("lucky", 1);
之后,这个阶段,属性name的值将变为"lucky",属性age的值将变为0。
对象大小与指针压缩
想要查看对象的对象头、对象的大小,可以通过引入依赖jol-core包:
xml
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
代码:
csharp
package com.jvm;
import org.openjdk.jol.info.ClassLayout;
/**
* 查看对象头、计算对象大小
*/
public class JOLSample {
public static void main(String[] args) {
ClassLayout layout = ClassLayout.parseInstance(new Object());
System.out.println(layout.toPrintable());
System.out.println();
ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
System.out.println(layout1.toPrintable());
System.out.println();
ClassLayout layout2 = ClassLayout.parseInstance(new A());
System.out.println(layout2.toPrintable());
}
// -XX:+UseCompressedOops 默认开启的压缩所有指针
// -XX:+UseCompressedClassPointers 默认开启的压缩对象头里的类型指针Klass Pointer
// Oops : Ordinary Object Pointers
public static class A {
//8B mark word
//4B Klass Pointer 如果关闭压缩-XX:-UseCompressedClassPointers或-XX:-UseCompressedOops,则占用8B
int id; //4B
String name; //4B 如果关闭压缩-XX:-UseCompressedOops,则占用8B
byte b; //1B
Object o; //4B 如果关闭压缩-XX:-UseCompressedOops,则占用8B
}
}
运行结果:
kotlin
// 前两个是Mark Word,64位机器占8个字节。
// 第三个是klass Point类型指针,64位机器正常是要占8个字节,但是如下的结果看到只占用4个字节,是因为默认做了指针压缩。
// 第四个,对象对其填充,可能有可能没有。保证对象是8个字节的整数倍(下面对象真正的大小只有12个字节,不是8的整数倍,需要填充4个字节=16,为8的整数倍),目的是为了提高计算机存取效率。
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) // Mark Word
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) // Mark Word
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) // Klass Pointer
12 4 (loss due to the next object alignment) // 对象对其填充
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
// 下面的实例,对象本身的大小已经是16,已经是8的整数倍。就不需要对象对其填充。
[I object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) // Mark Word
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) // Mark Word
8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363) // Klass Pointer
12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) // 数组长度,4个字节
16 0 int [I.<elements> N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
com.jvm.JOLSample$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5) // Mark Word
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) // Mark Word
8 4 (object header) 12 f2 00 f8 (00010010 11110010 00000000 11111000) (-134155758) // Klass Pointer
12 4 int A.id 0 // id为int类型,占用4个字节
16 1 byte A.b 0 //b为byte类型,占用1个字节
17 3 (alignment/padding gap) b字段的内部数据对齐
20 4 java.lang.String A.name null // name为String类型,默认占4个字节
24 4 java.lang.Object A.o null // o为对象类型,存的是对象的引用(地址指针),64位机器默认应该是8个字节,但是这里只占了4个字节,是默认进行了指针压缩
28 4 (loss due to the next object alignment) // 对象的数据对齐
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
什么是java对象的指针压缩?
-
jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩
-
jvm配置参数:UseCompressedOops,compressed--压缩、oop(ordinary object pointer)--对象指针
-
启用指针压缩:-XX:+UseCompressedOops(默认开启),禁止指针压缩:-XX:-UseCompressedOops
通过-XX:-UseCompressedOops
禁止指针压缩,然后在运行JOLSample类,结果如下:
kotlin
// 可以看到:
// 对象头的Klass pointer,之前是占用4个字节,现在变成占用8个字节了
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 00 cc aa 97 (00000000 11001100 10101010 10010111) (-1750414336)
12 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
// 可以看到:
// 对象头的Klass pointer,之前是占用4个字节,现在变成占用8个字节了
[I object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) // Mark Word
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) // Mark Word
8 4 (object header) 68 bb aa 97 (01101000 10111011 10101010 10010111) (-1750418584) // Klass Pointer
12 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) // Klass Pointer
16 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) // 数组长度
20 4 (alignment/padding gap)
24 0 int [I.<elements> N/A
Instance size: 24 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
// 可以看到:
// 1、对象头的Klass pointer,之前是占用4个字节,现在变成占用8个字节了
// 2、name 字段的内存地址,之前是占用4个字节,现在变成占用8个字节了
// 2、o 字段的内存地址,之前是占用4个字节,现在也变成占用8个字节了
com.jvm.JOLSample$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) d0 2c 12 98 (11010000 00101100 00010010 10011000) (-1743639344)
12 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
16 4 int A.id 0
20 1 byte A.b 0
21 3 (alignment/padding gap)
24 8 java.lang.String A.name null
32 8 java.lang.Object A.o null
Instance size: 40 bytes
Space losses: 3 bytes internal + 0 bytes external = 3 bytes total
为什么要进行指针压缩?
-
在64位平台的HotSpot中使用32位指针(实际存储用64位),内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力
-
为了减少64位平台下内存的消耗,启用指针压缩功能
-
在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的存入堆内存时压缩编码、取出到cpu寄存器后解码方式进行优化(对象指针在堆中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)
-
堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间
-
堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好
关于对齐填充:对于大部分处理器,对象以8字节整数倍来对齐填充都是最高效的存取方式。
写到最后
今天学习了Java中new一个对象时,JVM内部的执行流程及内部机制,做个小结:
- 对象创建的主要流程 : 类加载检查 >> 分配内存 >> 初始化 >> 设置对象头 >> 执行
<init>
方法 - 内存划分的方式:
- "指针碰撞"(Bump the Pointer)(默认用指针碰撞)
- "空闲列表"(Free List)
- 解决分配内存时并发问题的方法:
- CAS(compare and swap)+ 失败重试
- 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
-
初始化零值:内存分配完成之后,对对象的成员变量,按其数据类型进行赋对应的零值。
-
设置对象头 :初始化零值之后,JVM会对对象进行对象头的设置。例如:这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存于对象的对象头中。
对象在内存中的布局 :
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)
对象头的组成 :
-
Mark Word 标记字段:32位占4字节,64位占8字节,用于存储自身运行时数据,如:哈希值、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳。
-
klass Pointer类型指针: 类的元数据指针。开启指针压缩占4个字节,关闭压缩占8个字节。
默认是开始压缩。压缩的目的,是为了减少内存的消耗
-
数组长度(只有数组类型对象才有)
-
对齐填充:对于大部分处理器,对象以8字节整数倍来对齐填充都是最高效的存取方式。