2、对象的实例化内存布局与访问定位
首先来看看大厂面试题
美团:
对象在JVM中是怎么存储的?
对象头信息里面有那些东西?
蚂蚁金服:
二面:java对象头里面有什么?
我们带着上面这些问题来学习
对象的实例化
创建对象的方式
new 最常见的方式
变形1:单例模式下的获取对象的xxx静态方法
变形2:XxxBuilder/XxxFactory的静态方法
Class的newInstance():反射的方式,只能调用空参构造器,权限必须是public
Constructor的newInstance(Xxx):反射的方式,可以调用空参、带参的构造器,权限没有要求
使用clone:不调用任何构造器,当前类需要实现Cloneable接口,实现clone()——浅克隆
使用反序列化:从文件、网络中获取一个对象的二进制流。
第三方库Objenesis
对象创建的步骤

- 判断对象对应的类是否加载、链接、初始化
虚拟机遇到一条new指令,首先检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个富豪已用代表的类是否已经被加载、解析和初始化。(即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的.class文件。如果没有找到文件,则抛出ClassnotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象。 - 为对象分配内存
首先计算独享占用空间大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。- 如果内存规整,就会有指针碰撞
如果是内存规整的,那么虚拟机将采用的是指针碰撞法(Bump The Pointer)来为对象分配内存
意思是所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一盘使用带有compact(整理)过程的收集器时,使用指针碰撞。 - 如果内存不规整
- 虚拟机需要维护一个列表
- 空闲列表分配
已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存。虚拟机维护了一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式称为“空闲列表(Free List)。
- 如果内存规整,就会有指针碰撞
- 处理并发安全问题
- 采用CAS配上失败重试保证更新的原子性,对分配内存的动作进行同步处理。
- 每个线程预先分配一块本地线程分配缓存(Thread Local Allocation Buffer,TLAB),通过
-XX:+/-UseTLAB参数来设定是否是用TLAB(默认开启),-XX:TLABSize指定TLAB大小。把内存分配的动作按照线程分在不同的空间之中进行,即每个线程在Java堆中预先分配一块小内存,
- 初始化
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这些工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
所有属性设置默认值,保证对象实例字段在不赋值时可以自己使用 - 设置对象的对象头
将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。 - 执行init方法进行初始化
从程序的视角来看,初始化才正式开始。初始化成员变量,执行实例化带买块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
因此一般来说(由字节码中是否跟随有invokespecial指令所决定的),new指令之后接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。
对象的内存布局
对象头(Header)
包含两部分
- 运行时元数据(Mark Word)
- 哈希值
- GC分代年龄
- 锁状态标志
- 线程持有的锁
- 偏向线程ID
- 偏向时间戳
- klass pointer类型指针——指向类元数据InstanceKlass,确定该对象所属的类型。
开启压缩占4字节,关闭占8字节 - 说明:如果是数组,还需记录数组的长度
实例数据
说明:它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)
规则:
- 相同宽度的字段总是被分配在一起
- 父类中定义的变量会出现在子类之前
- 如果CompactFields参数为true(默认为true):子类的窄变量可能插入到父类变量的空隙
对齐填充
不是必须的,也没有特别的含义,仅仅起到占位符的作用,保证对象是8个字节的整数倍

使用jol可以查看Java对象布局
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>put-the-version-here</version>
</dependency>
普通对象

mark word占用64字节,klass pointer在64位的机器上占用8个字节,这里为什么是4个字节?这是我们开启了指针压缩,后面讲。
loss due to the next object alignment:这个是对齐填充(Padding),保证对象是8个字节的整数倍,就比如说,前面mark word和pointer占用了14个字节,为了保证对象是2的整数倍,又在后面追加了4个字节,让整个对象变成了16个字节,这样在计算的时候就不需要计算偏移量了。
数组对象

mark word在c++的内部结构


指针压缩
public class A{
// 8B mark word
// 4B Klass Pointer 如果关闭压缩`-XX:-UseCompressedClassPointers`或`-XX:-UseCompressedOops`,则占用编程了8B
int id; // 4B
String name; // 4B 如果关闭压缩,占用为8B
byte b; // 1B
Object o; // 4B 如果关闭压缩,占用为8B
}
//-XX:-UseCompressedOops 默认开启的压缩所有指针
//-XX:-UseCompressedClassPointers 默认开启的只压缩对象头里的累心指针klass Pointer
开启压缩:-XX:+UseCompressedClassPointers

关闭压缩:-XX:-UseCompressedClassPointers

上面这些信息都要放在堆里面,无形的都会增大很多空间,会导致堆的压力很大,指针压缩会减少每个对象的大小,同样的内存能放更多的对象,减少GC。
jdk1.4 update14开始,64位操作系统中,JVM支持指针压缩。
为什么要进行指针压缩?
1、在64位平台的 Hotspot中使用32位指针(实际存储用64位),内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力
2、为了减少64位平台下内存的消耗
3、在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针存入堆内存时压缩编码、取出到cpu寄存器后解码方式进行优化(对象指针在堆中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)。
4、堆内存小于4G时,不需要启用指针压缩,JVM会直接去除高32位地址,即使用低虚拟地址空间
5、堆内存大于32G时,压缩指针会失效,会强制使用64位(8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好。
关于对象填充:对于大部分处理器,对象以8字节整数倍来对齐填充都是最高效的存取方式。
对象的访问定位
JVM是如何通过栈帧中的的对象引用访问到其内部的对象实例的呢?

定位,通过栈上reference访问。对象访问方式主要有两种:
- 句柄定位
- 优点:reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时才会改变句柄中实例数据指针即可,reference本身不需要被修改
- 缺点:稍微有点浪费内存;如果想要访问对象,需要先访问句柄池中的指针,然后这个指针才指向对象实体,效率比较低
- 直接指针(HotSpot)
- 优点:访问对象效率高;没有专门去记录句柄,不浪费空间
- 缺点:一旦对象移动了,就要重新去维护reference。
对象的内存分配

对象栈上分配
我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆內分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧岀栈而销毁,就减轻了垃圾回收的压力。
对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。
public User test1(){
User user = new User();
user.setId(1);
user.setName("zhangsan");
return uesr;
}
public void test2(){
User user = new User();
user.setId(1);
user.setName("zhangsan");
}
很显然,test1的user被返回了,这个对象的作用域范围不确定,test2方法中的user对象我们可以确定当方法结束这个对象就可以认为是无效对象了,对于这样的对象我们其实可以将其分配在栈中,让其在方法结束后跟随栈内存一起被返回掉。
JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优先配在栈上,JDK7之后默认开启,如果要关闭使用-XX:-DoEscapeAnalysis
标量替换:通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解成若干个被这个方法使用的成员变量所替代,这些替代的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换-XX:+EliminateAllocations,JDK7之后默认开启
标量与聚合:标量即不可被进一步分解的量,而Java的基本数据类型就是标量(如:int、long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在Java中对象就是可以被进一步分解的聚合量。
public class AllotOnStack {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println(end-start);
}
private static void alloc() {
User user = new User();
user.setId(1);
user.setName("zhangsan");
}
}
开启参数-Xmx15m -Xms15m -XX:+PrintGC,用的是java8,默认开启内存逃逸分析和标量替换。
结果:
[GC (Allocation Failure) 4096K->712K(15872K), 0.0008497 secs]
7
使用以下参数:
开启标量替换,关闭内存逃逸分析
-Xmx15m -Xms15m -XX:+PrintGC -XX:-DoEscapeAnalysis -XX:+EliminateAllocations
结果:
[GC (Allocation Failure) 4096K->748K(15872K), 0.0007852 secs]
[GC (Allocation Failure) 4844K->844K(15872K), 0.0005929 secs]
.....省略
[GC (Allocation Failure) 5084K->988K(15872K), 0.0003408 secs]
[GC (Allocation Failure) 5084K->988K(15872K), 0.0002235 secs]
[GC (Allocation Failure) 5084K->988K(15872K), 0.0002463 secs]
[GC (Allocation Failure) 5084K->988K(15872K), 0.0002596 secs]
531
开启内存逃逸分析,关闭标量替换
-Xmx15m -Xms15m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:-EliminateAllocations
结果:
[GC (Allocation Failure) 4096K->684K(15872K), 0.0007666 secs]
[GC (Allocation Failure) 4780K->860K(15872K), 0.0008464 secs]
.....省略
[GC (Allocation Failure) 4908K->812K(15872K), 0.0002774 secs]
[GC (Allocation Failure) 4908K->812K(15872K), 0.0002842 secs]
[GC (Allocation Failure) 4908K->812K(15872K), 0.0002735 secs]
[GC (Allocation Failure) 4908K->812K(15872K), 0.0002853 secs]
[GC (Allocation Failure) 4908K->812K(15872K), 0.0002183 secs]
[GC (Allocation Failure) 4908K->812K(15872K), 0.0002849 secs]
[GC (Allocation Failure) 4908K->812K(15872K), 0.0003510 secs]
549
可以看出无论是开启标量替换关闭逃逸分析,还是关闭标量替换和开启逃逸分析都会有大量的GC。
结论:栈上分配依赖于逃逸分析和标量替换
对象在Eden区分配
大多数情况下,对象在新生代中Eden区分配。当εden区没有足够空间进行分配时,虚拟机将发起一次 Minor go。我们来进行实际测试一下。在测试之前我们先来看看 Minor gc和FuGC有什么不同呢?
- minor GC/Young GC: 指发生新生代的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。
- Major GC/Full GC:一般会回收老年代,年轻代、方法区的垃圾,Major GC的速度一般会比Minor GC慢10倍以上。
Eden与Survivor区默认8:1:1
大量的对象被分配在eden区,eden区满了后会触发 minor gc,可能会有99%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到为空的那块 survivor区,下一次eden区满了后又会触发 minor gc,把eden区和 survivor区垃圾对象回收,把剩余存活的对象一次性挪动到另外一块为空的 survivor区,因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适的,让eden区尽量的大, survivor区够用即可
JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy
public class GCTest {
public static void main(String[] args) {
byte[] allocation1;
allocation1 = new byte[60000 * 1024];
}
}
Heap
PSYoungGen total 153088K, used 70527K [0x0000000715d00000, 0x0000000720780000, 0x00000007c0000000)
eden space 131584K, 53% used [0x0000000715d00000,0x000000071a1dfca8,0x000000071dd80000)
from space 21504K, 0% used [0x000000071f280000,0x000000071f280000,0x0000000720780000)
to space 21504K, 0% used [0x000000071dd80000,0x000000071dd80000,0x000000071f280000)
ParOldGen total 349696K, used 0K [0x00000005c1600000, 0x00000005d6b80000, 0x0000000715d00000)
object space 349696K, 0% used [0x00000005c1600000,0x00000005c1600000,0x00000005d6b80000)
Metaspace used 3303K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 359K, capacity 388K, committed 512K, reserved 1048576K
大对象直接进入老年代
大对象就是需要大量连续内存间的对象(比如:字符串、数组)。JVM参数-XX: PretenureSizeThreshold可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在Serial和 ParNew两个收集器下有效.
比如设置JM参数:-XX: PretenureSizeThreshold=1000000(单位是字节) -XX:+UseSerialGC,再执行下上面的第一个程序会发现大对象直接进了老年代
为什么要这样呢?
为了避免大对象分配内存时的复制操作而降低效率。
长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理內存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。
如果对象在Eden岀生并经过第一次 Minor gc后仍然能够存活,并且能被 Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor中每熬过一次 MinorGC,年龄就増增加1岁,当它的年龄増加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同)就会被晋升到老年代中,对象晋升到老年代阈值可以通过-XX:MaxTenuringThreshold来设置。
对象动态年龄判断
当前放对象的 Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块 Survivor区域内存大小的50%(- XX: TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如 Survivor区域里现在有一批对象,年龄1+年龄2+年龄η的多个年龄对象总和超过了 Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在 minor gc之后触发的。
老年代空间分配担保机制
年轻代每次minor gc之前JVM都会计算老年代剩余可用空间,如果这个可用空间小于年轻代里现有的所有对象大小之后(包括垃圾对象),就会看一个-XX:-HandlePromotionFailure(JDK1.8默认就设置了)的参数是否设置了。如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor GC后进入老年代的对象的平均大小。
如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full GC,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生“OOM”。当然,如果minor GC之后剩余存活的需要挪动到老年代的对象大小还是大雨老年代可用空间,那么也会触发full GC,full GC完之后如果还是没有空间放,minor gc之后的存活对象,则会发生“OOM”。

为什么要进行空间担保?
新生代采用复制算法,如果大量对象在minor gc后仍然存活,而surivior区比较小,就需要老年代来担保了,把Surivior无法存放的对象放到老年代。老年代进行空间担保,前提是能容纳这些对象,但每一次有多少对象在内存回收后能够存活是不可知的,因此只好区每次晋升到老年代对象大小的平均值作为参考。用这个平均值和老年代剩余空间来对比,来决定是否进行Full GC来让老年代腾出更多空间。

浙公网安备 33010602011771号