JVM的内存管理机制

一、JVM的内存区域

对于C、C++程序员来说,在内存管理领域,他们既拥有每一个对象的“所有权”,又担负着每一个对象生命开始到终结的维护责任。

对Java程序员来说,在虚拟机的自动内存管理机制的帮助下,不再需要为每个new操作去写匹对的 delete/free 代码,不容易出现内存泄露和内存溢出的问题。

1、内存区域

根据《Java虚拟机规范(Java SE 7版)》规定,Java虚拟机所管理的内存将包括以下几个运行时数据区域,如图:

线程私有的内存区域:

  • 程序计数器:可看做当前线程执行字节码的行号指示器,字节码解释器工作时通过改变计数器的值来选择下一条所需执行的字节码指令
  • 虚拟机栈:Java方法执行的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用至执行完成的过程,都对应一个栈帧在虚拟机栈的入栈到出栈的过程
    • 局部变量表:存放编译期可知的基本数据类型(boolean、byte、char、int等)、对象引用(reference类型)和 returnAddress类型(指向一条字节码指令的地址)
  • 本地方法栈:Native方法执行的栈帧

所有线程共享的内存区域:

  • :存放对象实例和数组
  • 方法区:存储被虚拟机加载的Class类信息、final常量、static静态变量、即时编译器编译后的代码等数据
    • 运行时常量池:存放编译生成的各种字面量和符号引用,运行期间也可能将新的常量放入池中

2、对象的创建

在语言层面,创建对象(例如:clone,反序列化)通常是一个 new 关键字,而在虚拟机中,对象创建的过程是如何呢?

在虚拟机遇到 new 指令时:

1. 类加载:确保常量池中存放的是已解释的类,且对象所属类型已经初始化过,如果没有,则先执行类加载

2. 为新生对象分配内存:对象所需内存大小在类加载时可以确定,将确定大小的内存从Java堆中划分出来

  • 分配空闲内存方法:
    • 指针碰撞:假如堆是规整的,用过的内存和空闲的内存各一边,中间使用指针作为分界点,分配内存时则将指针移动对象大小的距离
    • 空闲列表:假如堆是不规整的,虚拟机需要维护哪些内存块是可用的列表,分配时候从列表中找出足够大的空闲内存划分,并更新列表记录
  • 对象创建在并发情况下保证线程安全:例如,正在给对象A分配内存,指针还没修改,对象B同时使用了原来的指针来分配内存
    • CAS配上失败重试
    • 本地线程分配缓冲TLAB(ThreadLocal Allocation Buffer):将内存分配动作按线程划分到不同空间中进行,即每个线程在Java堆中预先分配一小块内存

3. 将分配的内存空间初始化为零值:保证对象的实例在Java代码中可以不赋值就可直接使用,能访问到这些字段的数据类型对应的零值(例如,int类型参数默认为0)

4. 设置对象头:设置对象的类的元数据信息、哈希码、GC分代年龄等

5. 执行<init>方法初始化:将对象按照程序员的意愿初始化

3、对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局分为3个区域,如下图所示:

  • 对象头(Header)
    • MarkWord:存储对象自身的运行时数据,例如:哈希码HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等。考虑空间效率,MarkWord设计为非固定的数据结构,它根据对象的不同状态复用自己的空间,如下图所示:
    • 指向Class的指针:即对象指向它的类的元数据的指针,虚拟机通过这个指针来确定是哪个类的实例
    • 如果对象是Java数组,对象头中还需要一块记录数组长度的数据
  • 实例数据(Instance Data):对象真正存储的有效信息,也是程序代码中定义的各种类型字段的内容
  • 对齐填充(Padding):起占位符的作用。因为HotSpot VM的要求对象起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。当对象实例数据部分没有对齐时,需要对齐填充来补充

4、内存溢出异常

除程序计数器外,JVM其他几个运行时区域都可能发生OutOfMemoryError异常。

1. 堆内存溢出,OutOfMemoryError:java heap space

    原因:Java堆用于存储对象实例,只要不断创建对象,并保证GC Roots到对象间有可达路径避免这些对象的GC,当对象数量达到堆的最大容量限制后就会产生OOM

    解决方法

  • 通过参数 -XX:HeapDumpOnOutOfMemoryError 可以让虚拟机在内存溢出异常时Dump当前内存堆转储快照
  • 通过内存映像分析工具(如:Eclipse Memory Analyzer)对Dump出的堆转储快照分析,判断是内存泄露还是内存溢出
  • 如果是内存泄露:通过工具查看泄露对象的类型信息和它们到 GC Roots 的引用链信息,分析GC收集器无法自动回收它们的原因,定位内存泄露的代码位置
  • 如果是内存溢出:检查堆参数 -Xms和-Xmx,看是否可调大;代码上检查某些对象生命周期过长,持有时间过长的情况,尝试减少程序运行期间内存消耗

2. 栈内存溢出,StackOverflowError

    原因

  • StackOverFlowError异常:线程请求的栈深度大于虚拟机所允许的最大深度
  • OutOfMemoryError异常:虚拟机扩展栈时无法申请足够的内存空间

    解决方法

  • 检查代码中是否有死递归;配置 -Xss 增大每个线程的栈内存容量,但会减少工作线程数,需要权衡

二、垃圾回收策略

1、对象存活判断

堆中存放着几乎所有的对象实例,GC收集器在对堆进行回收前,首先要确定哪些对象是“存活”的,哪些是“死去”的

1. 引用计数法

给每个对象添加一个引用计数器,每当有地方引用它时,计数器 +1;引用失效时,计数器 -1。当计数器为0时对象就不再被引用。

但主流Java虚拟机没有采用这种算法,主要原因是:它难以解决对象之间循环引用的问题

2. 可达性分析算法

通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连(即从 GC Roots 到该对象不可达),则此对象是不可用的,会判断为可回收对象。

Java中,可作为 GC Roots 的对象包括:

  • 栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类 static 静态属性引用的对象
  • 方法区中 final 常量引用的对象
  • 本地方法栈中 JNI 引用的对象 

2、垃圾回收区域

垃圾回收主要是回收堆内存。在堆中,新生代常规应用进行一次GC一般可回收 70%~95% 的空间,永久代的 GC效率远低于此

方法区进行垃圾回收的“性价比”一般比较低,主要回收两部分内容:废弃常量和无用的类

  • 废弃常量回收:假如常量池的字符串,例如:“abc”,当前系统没有任何一个String对象引用这个字面量,则“abc”常量会被清理出常量池。常量池中的其他类、方法、字段的符号引用与此类似
  • 无用的类回收:类需要满足下面3个条件才算是“无用的类”
    • 该类的堆中所有实例都被回收
    • 加载该类的 Class Loader 已被回收
    • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方反射访问该类的方法

堆外内存是把内存对象分配在Java虚拟机的堆以外的内存,包括JVM自身运行过程中分配的内存,JNI 里分配的内存、java.nio.DirectByteBuffer 分配的内存等,这些内存直接受操作系统管理。这样能一定程度的减少GC对应用程序的影响。但 JVM 不直接管理这些堆外内存,存在 OOM 的风险,可以在 JVM 启动参数加上 -XX:MaxDirectMemorySize,对申请的堆外内存大小进行限制

DirectByteBuffer 对象表示堆外内存,DirectByteBuffer 对象中持有 Cleaner 对象,它唯一保存了堆外内存的数据、开始地址、大小和容量。在创建完后的下一次 Full GC 时, Cleaner对象会对堆外内存回收

3、垃圾回收算法

① 标记-清除算法

标记-清除算法分为“标记”阶段和“清除”阶段。标记阶段是把所有活动对象都做上标记。清除阶段是把那些没有标记的对象(非活动对象)回收

它主要有两个不足:

  1. 效率问题:标记和清除两个过程的效率都不高
  2. 空间问题:标记清除之后会有大量不连续的内存碎片。空间碎片过多可能导致后续需要分配大对象时,无法找到足够的连续内存而不得不提前触发另一次 GC

② 复制算法

复制算法是将可用内存划分为大小相等的两块,每次只使用一块,当一块内存用完了,就将存活的对象复制到另一块上,然后将已使用的内存空间一次清理掉。

这样分配内存时不用考虑内存碎片等复杂情况,但代价是将内存缩小为原来的一半。当对象存活率较高时,就要较多的复制操作,效率也会降低。

现在的商业虚拟机都采用复制算法来回收新生代。IBM专门的研究表明:新生代中对象 98% 是“朝生夕死”的,所有不需要 1:1 来划分空间,HotSpot虚拟机是将内存分为1块大的 Eden 和 2块小的 Survivor 空间,大小比例为 8:1:1。每次使用 Eden 和 其中一块 Survivor。当回收时,将 Eden 和 一块 Survivor 中的存活对象复制到另一块 Survivor 上,最后清理掉刚才的 Eden 和 Survivor。新生代每次可利用的整个新生态内存的 90%,10% 会被浪费掉。但当每次回收多余 10% 对象存活时,即剩下一个 Survivor 空间不够时,需要老年代内存担保,这些对象将直接进入老年代中。 

③ 标记-整理算法

标记-整理算法在“标记”阶段和标记-清除一样,但后续是让所有存活对象都向一端移动,然后清理掉端边界外的内存

④ 分代算法

根据对象存活周期的不同将内存划分为几块看,一般把堆分为“年轻代”和“老年代”,根据各个年代的特点采用适当的收集算法。

新生态中,每次 GC 只有少量的对象存活,就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集

老年代中,对象存活率高、没有额外的担保空间,就必须使用“标记-清除”或“标记-整理”算法

4、垃圾回收器比较

垃圾回收算法性能:

  • 吞吐量:运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。吞吐量越高,CPU利用越高效,则算法越好
  • 最大暂停时间:因 GC 而暂停应用程序线程的最长时间。暂停时间越短,则算法越好

高吞吐量和低暂停时间不可兼得。为了获得最大吞吐量,JVM 必须尽可能少地运行 GC,只有在迫不得已的情况下才运行GC,比如:新生代或者老年代已经满了。但是推迟运行 GC 的结果是,每次运行 GC 时需要做的事情会很多,比如有更多的对象积累在堆上等待回收,因此每次的 GC 时间则会变高,由此引起的平均和最大暂停时间也会很高

垃圾收集器是内存回收算法的具体实现。本文主要介绍 HotSpot 虚拟机中的垃圾回收器,如图所示:

如果两个收集器之间存在连线,说明他们可搭配使用。各垃圾回收器的功能比较如下表:

该选用哪一种垃圾回收器?

1. 客户端程序: 一般使用 -XX:+UseSerialGC (Serial + Serial Old). 特别注意, 当一台机器上起多个 JVM, 每个 JVM 也可以采用这种 GC 组合

2. 吞吐率优先的服务端程序(计算密集型): -XX:+UseParallelGC 或者 -XX:+UseParallelOldGC

3. 响应时间优先的服务端程序: -XX:+UseConcMarkSweepGC

4. 响应时间优先同时也要兼顾吞吐率的服务端程序:-XX:+UseG1GC

5、CMS垃圾回收器

CMS(Concurrent Mark Sweep)垃圾收集器是以最短回收停顿时间为目标的垃圾收集器。一般B/S或互联网站的服务端比较重视响应速度,希望系统的停顿时间最短,从而带给用户更好的体验,CMS就比较符合这类应用的需求。

① 执行过程

CMS是基于 "标记-清除" 算法实现的,由上文『复制GC算法』中所描述,新生代98%对象是朝生夕死的,所以将新生代分为1个Eden和2个survivor区(默认内存大小是8:1:1),每次使用Eden和一个survivor区,回收时,将活着的对象拷贝到剩余的一个survivor区,并清理之前使用的Eden和survivor区的空间。

它运作过程分为以下几个阶段:

1、初始标记(需要 Stop The World):标记 GC Roots 能直接关联到的对象,速度很快

2、并发标记(和用户线程一起工作):GC Roots Tracing的过程,例如:A是GC Root关联到的对象,A引用B,A在初始阶段标记出来,这个阶段就是标记B对象

3、并发预清理(和用户线程一起工作):并发查找在并发标记阶段,从新生代晋升到老年代的对象、或直接在老年代分配的大对象、或被用户线程更新的对象,来减少 "重新标记" 阶段的工作量

4、重新标记(需要 Stop The World):修正『并发标记』和『并发预清理』用户线程与GC线程并发执行,用户线程产生了新对象,将这些对象重新标记。这阶段 STW 时间会比『初始标记』阶段长一些,但远比『并发标记』的时间短。暂停用户线程,GC线程重新扫描堆中的对象,进行可达性分析,标记活着的对象

5、并发清理(和用户线程一起工作):移除不用的对象,回收他们占用的堆空间。此时会产生新的垃圾,在此次GC无法清除,只好等到下次清理,这些垃圾名为:浮动垃圾

6、并发重置:重新设置 CMS 内部的数据结构,准备下一次 CMS 生命周期的使用


并发标记阶段修改了对象如何处理?

上述 CMS GC过程中第3个步骤:并发预清理,如何处理并发标记阶段被修改的对象呢?初始标记阶段的引用为 A → B → C,并发标记时,引用关系由用户程序改为 A → C,B不再引用C ,由于C在 "并发标记" 阶段无法被标记,就会被回收,而这是不允许的。

这可以通过三色标记法解决,将GC中的对象分为三种情况:

  • 黑色:自身和它的子对象都扫描完成的对象,不会当成垃圾对象,不会被GC  
  • 灰色:对象本身被扫描,但还没扫描完成子对象
  • 白色:还没有扫描过的对象,扫描完所有对象后,最终为白色的为不可达对象,会被当做垃圾对象

初始标记时,A 会被标记为灰色(正在扫描 A 相关),然后扫描 A 的引用,将 B 标记为灰色,然后 A 就扫描完成了,变为黑色

并发标记时,如果用户线程将 A 引用改为了 C,即 A → C,此时 CMS 在写屏障(Write Barrier)里发现有一个白色对象的引用(C)被赋值到黑色对象(A)的字段里,那就会将 C 这个白色对象设置为灰色,即增量更新(Imcremental update)。


出现老年代引用新生代的对象,GC 时如何处理?

JVM采用卡片标记(Card Marking)方法,避免 Minor GC 时需要扫描整个老年代。做法是:将老年代按照一定大小分片,每一片对应 Cards 中一项,如果老年代的对象发生了修改或指向了新生代对象,就将这个老年代的 Card 标记为 dirty。Young GC 时,dirty card 加入待扫描的 GC Roots 范围,避免扫描整个老年代

② CMS的优缺点

优点:

1、并发收集、低停顿,Sun公司的一些官方文档也称之为并发低停顿收集器(Concurrent Low Pause Collector)

缺点:

1、对CPU资源非常敏感:在并发阶段,它虽然不会导致用户线程停顿,但会因为占用一部分线程(或CPU资源)而导致应用程序变慢,总吞吐量降低

2、产生空间碎片:基于“标记-清除”算法实现,意味着收集结束后会有大量空间碎片产生,给大对象分配带来麻烦

3、需要更大的堆空间:CMS标记阶段应用程序还在继续执行,就会有堆空间继续分配的情况,为保证 CMS 将堆回收完之前还有空间分配给正在运行的程序,必须预留一部分空间

③ CMS调优策略

-XX:CMSInitiatingOccupancyFraction=70 : 该值代表老年代堆空间的使用率,默认值是92,假如设置为70,就表示第一次 CMS 垃圾收集会在老年代占用 70% 时触发。过大会使 STW 时间过程,过小会影响吞吐率

-XX:+UseCMSCompactAtFullCollection,-XX:CMSFullGCsBeforeCompaction=4:执行4次不压缩的 Full GC 后,会执行一次内存压缩的过程,用来消除内存碎片

-XX:+ConcGCThreads:并发 CMS 过程运行时的线程数,CMS 默认回收线程数是 (CPU+3) / 4。更多的线程会加快并发垃圾回收过程,但会带来额外的同步开销。

年轻代调优:Young GC 频次高,则增大新生代;Young GC 时间长,则减少新生代。尽量在 Young GC 时候回收大部分垃圾

6、G1

G1(Garbage-First)是一款面向服务端应用的垃圾收集器,G1的设计初衷是最小化 STW 中断时间,通常限制 GC 停顿时间比最大化吞吐率更重要。在Java9里,G1已经成为默认的垃圾回收器。 

① 执行过程

G1的内存布局和其他垃圾收集器有很大区别,它将整个Java堆分为 n 个大小相等的 Region,每个 Region 占有一块连续的虚拟内存地址。新生代和老年代不再是物理隔离,而是一部分 Region 的集合。

Region的大小可以通过 -XX:G1HeapRegionSize 指定,如果未设置,默认将堆内存平均分为 2048 份。G1仍属于分代收集器,除了Eden、Survivor、Old区外,还有 Humongous 区用于专门存放巨型对象(一个对象占用空间>50%分区容量),减少短期存在的巨型对象对垃圾收集器造成的负面影响。

G1 的运作过程分为以下几个步骤:

1、全局并发标记:基于 STAB(snapshot-at-the-beginning)形式的并发标记,标记完成后,G1 基本知道了哪个区域是空的,它首先会收集哪些产出大量空闲空间的区域,这也是它命名为 Garbage-First 的原因

1.1  初始标记(STW,耗时很短):标记 GC Roots 能直接关联到的对象,将它们的字段压入扫描栈

1.2  并发标记(与用户线程并发执行,耗时较长):GC 线程不断从扫描栈中取出引用,然后递归标记,直至扫描栈清空

1.3  最终标记(STW):重新标记『并发标记』期间因用户程序执行而导致引用发生变动的那一部分标记(写入屏障 Write Barrier 标记的对象)

1.4  清理(STW):统计各个 Region 被标记存活的对象有多少,如果发现没有存活对象的 Region,就会将其整体回收到可分配的 Region 中

2、拷贝存活对象:将一部分 Region 里的存活对象拷贝到空 Region 里去,然后回收原本的 Region 的空间。

G1 的 GC 可以分为 Young GC 和 Mixed GC 两种类型。Young GC 是选定所有新生态的 Region,通过控制新生代的 Region 个数控制 Young GC 的开销。Mixed GC 是选定所有新生代里的 Region,外加根据『全局并发标记』统计的收益较高的若干老年代 Region,在用户指定的停顿时间内尽可能选择收益较高的老年代 Region。G1 里不存在 Full GC,老年代的收集全靠 Mixed GC 来完成。

在 G1 中,使用 Rememberd Set 跟踪 Region 内的对象引用,来避免全堆扫描的。每个 Region 都有一个与之对应的 Rememberd Set,当程序对 Reference 类型的数据进行写操作时,会产生 Write Barrier 暂停中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中,如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属 Region 的 Rememberd Set 中。当 GC 时,在GC Root 的枚举范围中加入 Rememberd Set 即可保证不对全堆扫描,也不会遗漏。

② G1与CMS的比较

G1的设计目标是取消CMS收集器,G1与CMS相比,有一些显而易见的优点:

1、简单可行的性能调优:-XX:+UseG1GC -Xmx32g,使用这两个参数即可应用于生产环境,表示开启G1,堆最大内存为32G;-XX:MaxGCPauseMillis=n 使用这个参数可设置期望的GC中暂停时间。取消老年代的物理空间划分,无需对每个代的空间进行大小设置

2、可预测的 STW 停顿时间:G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定 GC 的停顿时间不超过 n 毫秒。这是通过跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护优先列表,每次根据允许的收集时间,优先回收价值最大的Region,保证了 G1 能在有限的时间内可以获取尽可能高的收集效率

3、空间整合:G1 的两个 Region 之间是基于『复制』算法实现,在运作期间不会产生内存碎片,分配大对象时不会因为无法找到连续空间而提前出发 Full GC 

③ CMS调优策略

-XX:MaxGCPauseMillis=n:设置GC时最大暂停时间,这个目标不一定能满足,JVM会尽最大努力实现它,不建议设置的过小(<50ms)

-XX:InitiatingHeapOccupancyPercent=n:触发G1启动 Mixed GC,表示垃圾对象在整个 G1 堆内存空间的占比

避免使用 -Xmn 或 -XX:NewRatio等其他显式设置年轻代大小的选项,固定年轻代大小会覆盖暂停时间目标

参考

深入理解Java虚拟机(第2版)

Getting Started with the G1 Garbage Collector

深入理解 Java G1 垃圾收集器

Java Hotspot G1 GC的一些关键技术

原文地址:https://cloud.tencent.com/developer/article/1390538