[toc]
一. 堆区结构分析
堆区是一组物理上不连续的内存地址空间,它是由链表组织在一起的结构,由低位往高位扩展。它被所有的线程共享,但是堆区中为了线程安全,为每个线程分配了一块空间TLAB(Thread local allocation buffer)。
默认情况下堆区的大小为可用内存的1/64,堆区最大大小为1/4物理内存。
在jvm中,堆区从概念上被分为三部分:
- 新生代
- 老年代
- 元空间
对象默认情况下实例化都在新生代,经过N次(具体的N在jvm中是15次,在art/dalvik是6次)的gc过程,还没有被回收的对象会进入到老年代,元空间存储的是方法区的内容,它使用的是本地内存,不是虚拟机的堆内存。
通过jvm初始化参数,我们可以配置堆区新生代,老年代的大小和比例,:
更多的jvm参数可以参考官网
1.1 新生代结构
新生代数据结构为链表,概念上又分为
- eden : 伊甸园区
- suvivor : 存活区
eden和suvivor的大小占比一般为8:2,suvivor区又被划分为两部分s0(from),s1(to),大小一样,分别为1/10的新生代大小。也就是eden:s0:s1 = 8:1:1
初始情况下,新对象的产生都在eden区,经过gc之后没有被回收的对象进入到suvivor区,每次gc会导致suvivor区的s0和s1的拷贝赋值,因此总有一块suvivor区是完全空的。
新生代的gc类型为minor GC, 它的速度较快,但是也会产生 stw(stop - the - world)
1.2 老年代结构
老年代数据结构为链表,它初始情况下为空的,对象经过minor gc会被放置到新生代的suvivor区中,并且标记对象的年龄+1,在suvivor区中经过N次minorgc到达阈值后,suvivor中的对象会被拷贝到老年代中。
老年代的gc类型为major GC, 它的速度非常慢,是minor GC的1/10.
1.3 元空间
元空间不属于虚拟机的堆内存,但是当元空间耗尽时,也会造成OOM,它的大小受限与本地内存大小。它存储类型信息/域信息/方法信息/常量信息等。
可以理解为永久代(jdk 1.8之前)/元空间为方法区的实现
1.4 堆区分代的原因
堆区如果不分代也可以做到对象的分配与回收,但是这样势必需要对整个堆内存进行分析,效率低下,考虑到创建的对象70%以上具有朝生夕死的特性,因此划分出来新生代用于快速gc。
二. 对象分配过程分析
对象分配的过程是一次不断尝试直到抛出OOM的过程,对象初始在eden中产生,经过gc没有被回收的进入到suvivor区,suvivor区中分为两个部分s0和s1,对象在gc过程中会不断的在s0/s1中来回拷贝复制,原因是为了整理内存碎片。
当有新的对象需要分配时,首先检查eden区是否足够,足够则分配,不够执行minorgc,gc完后检查eden是否足够,还不够,则检查老年代是否足够,足够则分配,否则执行majorgc,再次检查老年代是否足够,如果足够,则分配,否则抛出oom.
三. 对象分配逃逸分析
对象逃逸分析是jit的编译优化策略,有了逃逸分析和标量替换优化,在大量生产对象时,能够大大减少堆上对象的生成和gc的次数。
1. 逃逸分析
逃逸的定义
- 对象的作用域仅限于方法区域内部在使用的情况下为非逃逸
- 对象如果被外部其他类调用,或者是作用于属性中,则被称之为对象逃逸
非逃逸的例子,NoEscape的作用域仅为test方法内部,因此为非逃逸对象
void test(){
NoEscape ne = new NoEscape();
ne.print();
}
逃逸的例子,test方法返回了Escape对象,它可能被其他方法使用,因此产生逃逸。
Escape test(){
Escape es = new Escape();
es.print();
return es;
}
2.逃逸分析的优势
使用逃逸分析,编译器可以堆代码做如下优化:
栈上分配
JIT编译器在编译期间根据逃逸分析计算结果,如果发现当前对象没有发生逃逸现象,那么当前对象就可能被优化成栈上分配,会将对象直接分配在栈中
标量替换
有的对象可能不需要作为一个连续的内存结构存在也能被访问到,那么对象部分可以不存储在内存,而是存储在CPU寄存器中。标量替换的两个概念:
-
标量是指一个无法再分解成更小的数据的数据。Java中的原始数据类型为标量。
-
聚合量指的是类,封装的行为就是聚合
标量替换是指在未发生逃逸的情况下,方法内部生成的聚合量在经过JIT优化后会将其拆解成标量。
同步消除
当对象为非逃逸对象时,可以移除对象的同步锁。
3.开启逃逸分析的参数
逃逸分析
- 开启逃逸分析:-XX:+DoEscapeAnalysis
- 关闭逃逸分析:-XX:-DoEscapeAnalysis
- 显示分析结果:-XX:+PrintEscapeAnalysis
同步锁依赖于逃逸分析
- 开启锁消除:-XX:+EliminateLocks
- 关闭锁消除:-XX:-EliminateLocks
标量替换依赖于逃逸分析
- 开启标量替换:-XX:+EliminateAllocations
- 关闭标量替换:-XX:-EliminateAllocations
- 显示标量替换详情:-XX:+PrintEliminateAllocations
四. 对象的创建过程
1. 类加载
当发生new指令的时候,jvm会根据指令的参数查找方法区中是否存在对应的类型信息,如果没有,则进行双亲委托加载,如果有,则加载并解析class信息,进入第二步。
2. 对象内存分配
类加载完成之后,类实例化的对象所占用的空间就确定了。根据gc策略的不同,如果是标记整理那么会采用指针碰撞的方式进行内存分配,如果是标记清除则会采用空闲列表的方式进行内存分配。
分配策略
- 指针碰撞 标记整理后的内存,由低到高,中间有一个分界线,分界线下为使用过的堆区,分界线上为未使用的堆区,当有新的分配请求到来,会移动指针至当前位置+对象长度的位置。
- 列表分配 标记清除方式,内存会有很多碎片,并将这些碎片用链表的形式管理起来,当有新的分配请求过来时,会查找链表中的区域,直到找到能放下对象大小的区块。同时更新链表信息。
线程安全
多个线程可能会同时申请同一块区域产生冲突,多线程分配冲突的处置方式为:
- CAS 乐观锁 +失败重试,因为cpu直接操作的内容都不在内存上,cpu直接操作的都在高速缓冲区,因此在操作时,先比较当前操作的值与内存中是否一致,如果一致则修改并更新内存中的值,否则失败重试
- TLAB(Thread local allocation buffter) 每个线程都在单独的空间TLAB上分配对象
3. 初始化
对对象中的属性进行默认值的赋值。
4. 设置对象头
对象头是一个结构体,长度为32位或者64位,存储运行时对象的一些基础信息,包括锁状态,hash code,gc年龄等
5. 执行初始化方法
实例化直接父类,给父类属性赋值,执行父类的非静态代码块,后执行父类构造方法;然后实例化子类,给子类的属性赋值,执行子类的非静态代码,最后执行子类的构造方法。
五. 对象的内存结构
对象从结构上分为三部分
- 对象头
- 实例数据
- 对齐填充数据
对象头
对象头包含 markword运行时数据,klass指针,如果是数组对象,还包含数组长度。
markword结构如下:
关于对象分配的锁的升级过程在后续篇幅分析。
实例数据
存储对象父类属性字段和自身属性字段,其中相同长度的属性放在一起,父类属性字段在前,子类在后。
如果开启了虚拟机参数CompactFields(默认开启),则子类的属性长度较小者可能会插入到父类属性的空隙里面。
开启CompactFields的虚拟机参数的命令:
# 开启
-XX:+CompactFields
# 关闭
-XX:-CompactFields
对齐填充数据
对象大小必须是8字节整数倍,当对象大小不够8的整数倍时,用数据填充来占位。