Java虚拟机系列四:堆区管理

1,711 阅读8分钟

[toc]

一. 堆区结构分析

堆区是一组物理上不连续的内存地址空间,它是由链表组织在一起的结构,由低位往高位扩展。它被所有的线程共享,但是堆区中为了线程安全,为每个线程分配了一块空间TLAB(Thread local allocation buffer)。

默认情况下堆区的大小为可用内存的1/64,堆区最大大小为1/4物理内存。

在jvm中,堆区从概念上被分为三部分:

  • 新生代
  • 老年代
  • 元空间

对象默认情况下实例化都在新生代,经过N次(具体的N在jvm中是15次,在art/dalvik是6次)的gc过程,还没有被回收的对象会进入到老年代,元空间存储的是方法区的内容,它使用的是本地内存,不是虚拟机的堆内存。

jvm_heap

通过jvm初始化参数,我们可以配置堆区新生代,老年代的大小和比例,: jvm_params

更多的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.

jvm_allocation

三. 对象分配逃逸分析

对象逃逸分析是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. 执行初始化方法

实例化直接父类,给父类属性赋值,执行父类的非静态代码块,后执行父类构造方法;然后实例化子类,给子类的属性赋值,执行子类的非静态代码,最后执行子类的构造方法。

五. 对象的内存结构

对象从结构上分为三部分

  • 对象头
  • 实例数据
  • 对齐填充数据

jvm_object

对象头

对象头包含 markword运行时数据,klass指针,如果是数组对象,还包含数组长度。

jvm_object_header

markword结构如下: jvm_object_markword

关于对象分配的锁的升级过程在后续篇幅分析。

实例数据

存储对象父类属性字段和自身属性字段,其中相同长度的属性放在一起,父类属性字段在前,子类在后。

如果开启了虚拟机参数CompactFields(默认开启),则子类的属性长度较小者可能会插入到父类属性的空隙里面。

开启CompactFields的虚拟机参数的命令:

# 开启
-XX:+CompactFields
# 关闭
-XX:-CompactFields

对齐填充数据

对象大小必须是8字节整数倍,当对象大小不够8的整数倍时,用数据填充来占位。