十三.吊打面试官系列-JVM优化-深入JVM对象创建流程
在Java中,对象的内存分配主要由Java虚拟机(JVM)的堆内存管理器负责。当使用new关键字创建对象时,JVM会在堆内存中为对象分配内存。
前言
一.对象是如何被创建的
在Java中,对象的内存分配主要由Java虚拟机(JVM)的堆内存管理器负责。当使用new关键字创建对象时,JVM会在堆内存中为对象分配内存。以下是Java为对象分配内存的大致过程:
1.检查类是否已加载
在创建对象之前,JVM会确保对象的类已经被加载到方法区(在JDK 1.8及以后,这通常被称为元空间Metaspace),首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果类还没有被加载,JVM会先加载类,包括加载类的二进制数据到内存中,为类的静态变量分配内存,并设置类变量的初始值等
2.内存分配
一旦类加载完成,JVM会在堆内存中为对象实例分配内存。这个过程是自动的,并且是由JVM的内存管理器控制的。具体的内存分配算法和策略可能因JVM的不同而有所差异,但大多数现代JVM都使用了类似“空闲列表”或“位图”这样的数据结构来跟踪哪些内存块是空闲的,从而可以快速地为新对象分配内存。下面介绍两种内存划分方式
- 指针碰撞 : 在内存规整的情况下,通过一个指示器(指针)来区分那边是已使用内存和未使用内存,分配内存时把指针向空闲空间那边挪动一段与对象大小相等的距离即可。
- 空闲列表 :内存不规则的情况下,通过一个列表来维护可用内存,需要为对象分配内存时就找到一块足够大的空闲空间划分给实例对象,并更记录到列表中
还需要考虑一个问题就是并发冲突,如果多个线程都想要划分同一个内存区域就形成了并发冲突问题,解决方案有两种如下
- CAS(Compare and swap) : 虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。
- 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB):每个线程会预先分得一小块内存空间,为对象分配内存的时候就从线程私有的内存空间去分配,通过XX:+/
UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启XX:+UseTLAB),XX:TLABSize 指定TLAB大小。
3.初始化内存
在内存分配之后,JVM会将新分配的内存空间初始化为零值(对于所有的基本数据类型)或者null(对于引用类型)。这是为了确保对象字段的默认值在对象被构造函数初始化之前就已经设置好了。
4.设置对象头信息
在对象内存空间被分配并初始化之后,JVM会在对象内存空间的头部设置一些信息
,包括对象的哈希码(hash code)、GC分代年龄(GC generation age)、锁状态标志(lock status flags)等。这些信息是JVM进行对象管理和垃圾回收所必需的。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data)和对齐填充(Padding)
。
对象头
HotSpot虚拟机的对象头包括两部分信息,第一部分是MarkWord
标志位:用于存储对象自身的运行时数数据的,另外一部分是KlassPoint类型指针
,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
第一部分MarkWord : 用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机(暂 不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。一个类被加载的时候,hashCode是被存放在对象头里面的Mark Word里面的。
第二部分KlassWord : 即类型指针,即是对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说查找对象的元数据信息并不一定要经过对象本身。
另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据
,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小
实例数据
实例数据部分是对象真正存储的有效信息
,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
对齐填充
第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍
,换句话说,就是对象的大小必须是8字节的整数倍
。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全
。
5.执行init和构造方法
最后会调用对象的init方法为对象赋初始值,底层由c++来完成执行,JVM会调用对象的构造函数方法来初始化对象的状态。构造函数可以设置对象的字段值,执行必要的初始化逻辑等。一旦构造函数执行完毕,对象就创建完成了,并且可以在程序中被使用。
二.查看对象结构
1.导入 jol-core 包
我们可以通过一个jar包来查看对象实例的解构,首先需要导入依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
2.对象大小计算
然后通过 ClassLayout.parseInstance(object).toPrintable() 去打印对象实例,如下
public class ObjectSizeTest {
public static void main(String[] args) {
ClassLayout layout = ClassLayout.parseInstance(new ObjectSizeTest ());
System.out.println(layout.toPrintable());
}
}
打印效果如下
OFF SZ : 代表的是偏移量 和 每个部分的大小,如上图中共16字节,可以分为三个段来计算
下面我创建了一个Temp对象,对象中给了2个成员变量,打印的对象头如下
class Temp{
private int id = 1;
private String name = "zs";
}
public static void main(String[] args) {
ClassLayout layout2 = ClassLayout.parseInstance(new Temp());
System.out.println(layout2.toPrintable());
}
打印效果如下,总的长度是24 = 8(MarkWord) + 4(KlassPoint) + 4(id) + 4(name) + 4(对其填充)
这里需要注意一个点:对于String name这种值在64位的机器应占用8个字节,但是上面只占用了4二个字节,这是因为JVM默认开启了指针压缩。下面是对int[]的测试
ClassLayout layout3 = ClassLayout.parseInstance(new int[]{1,2});
System.out.println(layout3.toPrintable());
打印效果如下
这里看到是没有对其填充的,因为四个部分加起来正好是24个字节已经是8的倍数了,所以不需要对其填充。
3.指针压缩
在64位平台的HotSpot中使用32位指针,内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力。为了减少64位平台下内存的消耗,启用指针压缩功能在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的压缩编码、解码方式进行优化,使得jvm只用32位地址就可以支持更大的内存配置
(小于等于32G)。
jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩,启用指针压缩:XX:+UseCompressedOops(默认开启),禁止指针压缩:-XX:-UseCompressedOops。
下面是在关闭JVM指针压缩,在启动VM参数中添加:-XX:-UseCompressedOops
然后再打印对象头信息如下:
- 对于String name : 默认开启指针压缩的时候只占用4字节,关闭指针压缩后占用 8字节
指针压缩后可以节约内存,在总内存不变的情况下,每个对象占用的内存越小,那么在程序运行的过程中就可以减缓内存装满,也能减少GC次数,从而能提高程序性能。
三.对象内存如何分配
1.对象在栈中分配
理论上来说对象都是在堆里面分配的,当堆中的对象没有栈中的变量去引用的时候就会被GC标记为垃圾,等待被回收。如果堆中的对象比较多,GC的压力也会比较大,那么我们思考一个问题:方法的执行会形成栈帧压栈,方法调用结束栈帧销毁,栈中的数据也会被销毁,那么,如果对象直接在栈中分配内存,那么是不是随着线程结束,方法被销毁,而对象是不是也被销毁了呢,那就不需要GC去单独回收了。
实际上JVM在为对象分配内存的时候就会使用逃逸算法
来计算出对象会不会被方法外部访问,如果对象只是方法内部访问,那么就会把对象在栈上分配
,该对象所占用的内存空间也就会随着方法的结束而释放,大大减轻GC回收堆的压力。比如下面的代码
public User method(){
User u = new User();
...
return User(); //对象外部会被访问
}
public void method(){
User u = new User(); //对象只是在方法内部访问
...
}
当然如果对象会被方法外部访问,或者对象在栈中放不下就会把该对象在堆上分配。JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)
标量替换
通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时(聚合量),JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间
,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启。
什么是标量
: 标量即不可被进一步分解的量
,比如java中的基本数据类型,聚合量就是 可以被分解的量,对象就是属于聚合量
这里我们大胆做一个测试,我这里编写了一个测试代码,通过大量创建对象来观察GC次数
public static void main(String[] args) {
long start = System.currentTimeMillis();
for(int i = 0 ; i < 1000000 ; i++) {
create();
}
long end = System.currentTimeMillis();
System.out.println("耗时:"+(end - start));
}
public static void create(){
DoEscapeAnalysis doEscapeAnalysis = new DoEscapeAnalysis();
}
代码循环1亿次,然后在方法中去创建对象,如果对象是在堆中创建的那么一定会伴随着大量的GC,如果对象是在栈中分配的,那就不会有大量的GC,在IDEA中的VM参数指定打印GC : -XX:+PrintGC -XX:+EliminateAllocations -Xmx20m
,执行代码测试只进行了一次GC,说明对象是在栈中分配的
[GC (Allocation Failure) 5632K->1178K(19968K), 0.0018673 secs]
耗时:8
在vm参数中关闭 逃逸算法分析 -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations -Xmx20m
,测试果然出现了大量的GC,说明对象是在堆中分配的。
[GC (Allocation Failure) 2048K->934K(9728K), 0.0021348 secs]
[GC (Allocation Failure) 2982K->1291K(9728K), 0.0022295 secs]
[GC (Allocation Failure) 3339K->1411K(9728K), 0.0009597 secs]
[GC (Allocation Failure) 3459K->1443K(9728K), 0.0013767 secs]
[GC (Allocation Failure) 3491K->1467K(9728K), 0.0008916 secs]
[GC (Allocation Failure) 3515K->1491K(8704K), 0.0014422 secs]
[GC (Allocation Failure) 2515K->1551K(9216K), 0.0010245 secs]
[GC (Allocation Failure) 2575K->1503K(9216K), 0.0009302 secs]
[GC (Allocation Failure) 2527K->1455K(9216K), 0.0007759 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0010963 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0006501 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0006508 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0007437 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0007572 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0007492 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0007694 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0007039 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0006634 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0006510 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0006325 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0008377 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0010593 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0007442 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0008052 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0008203 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0007136 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0006254 secs]
[GC (Allocation Failure) 2479K->1455K(9216K), 0.0005407 secs]
耗时:37
2.对象内存分配流程
稍微回顾一下:堆分为新生代(YoungGeneration)和 老年代(OldGeneration).新生代中又可以细分为一个Eden,两个Survivor区(From,To).Eden中存放的是通过new 或者newInstance方法创建出来的对象 。
绝大多数都是很短命的.正常情况下经历一次gc之后,存活的对象会转入到其中一个Survivor区,然后再经历默认15次的gc,就转入到老年代.这是常规状态下,在Survivor区已经满了的情况下,JVM会依据担保机制将一些对象直接放入老年代。`
下面是对象内存分配流程图
- JVM通过
逃逸算法
确定对象是否能在栈中分配,如果可以就会在栈中分配 - 如果对象不能在栈中分配,就会在堆中分配。对象会优先在新生代的伊甸区存放
- 在存放之前会检查是否是大对象,如果是大对象会直接放入老年代
- 确定要放到伊甸区,会优先采用线程本地分配(优化技术,在线程私有的空间优先分配,减少并发冲突),最终对象都会落到伊甸区
- 如果伊甸区内存不够会触发MinorGC,存活的对象会通过
复制算法
复制到其中一个幸存区,随着不断的GC存活对象会在2个幸存区中来回复制。15次之后移动到老年代
文章结束,如果对你有帮助请给好评哦。
更多推荐
所有评论(0)