写点什么

Java 8 内存管理原理解析及内存故障排查实践

  • 2024-03-21
    广东
  • 本文字数:11957 字

    阅读完需:约 39 分钟

作者:vivo 互联网服务器团队-  Zeng Zhibin


介绍 Java8 虚拟机的内存区域划分、内存垃圾回收工作原理解析、虚拟机内存分配配置,介绍各垃圾收集器优缺点及场景应用、实践内存故障场景排查诊断,方便读者面临内存故障时有一个明确的思路和方向。

一、背景

Java 是一种流行的编程语言,可以在不同的操作系统上运行。它具有跨平台、面向对象、自动内存管理等特点,Java 程序在运行时需要使用内存来存储数据和程序状态。


Java 的自动内存管理机制是由 JVM 中的垃圾收集器来实现的,垃圾收集器会定期扫描堆内存中的对象,检测并清除不再使用的对象,以释放内存资源。


Java 的自动内存管理机制带来了许多好处,首先,它可以避免程序员手动管理内存时的错误,例如内存泄漏和悬空指针等问题。其次,它可以提高程序的运行效率,因为程序员不需要频繁地手动分配和释放内存,而是可以将更多时间和精力专注于程序的业务逻辑,最后,它可以提高程序的可靠性和稳定性,因为垃圾收集器可以自动检测和清除不再使用的内存资源,避免内存溢出等问题。


了解和掌握垃圾收集器原理可以帮助提高程序的性能、稳定性和可维护性。


名词解释:

响应速度:响应速度指程序或系统对一个请求的响应有多迅速。比如,用户查询数据响应时间,对响应速度要求很高的系统,较大的停顿时间是不可接受的。

吞吐量:吞吐量关注在一个特定时间段内应用系统的最大工作量,例如每小时批处理系统能完成的任务数量,在吞吐量方面优化的系统,较长的 GC 停顿时间也是可以接受的,因为高吞吐量应用更关心的是如何尽可能快地完成整个任务,不考虑快速响应用户请求。


GC 导致的应用暂停时间影响系统响应速度,GC 处理线程的 CPU 使用率影响系统吞吐量。

二、Java 8 的内存管理

2.1 JVM(Java 虚拟机)内存划分

Java 运行时数据区域划分,Java 虚拟机在执行 Java 程序时,将其所管理的内存划分为不同的数据区域,每个区域都有特定的用途和创建销毁的时间。其中,有些区域在虚拟机进程启动时就存在,而有些区域则是随着用户线程的启动和结束而建立和销毁。这些数据区域包括程序计数器、虚拟机栈、本地方法栈、堆、方法区等,每个区域都有其自身的特点和作用。了解这些数据区域的使用方式和特点,可以更好地理解 Java 虚拟机的内存管理机制和运行原理。


JVM 的内存区域划分可分为:1.堆内存空间、2.Java 虚拟机栈区域、3.程序计数器、4.本地方法栈、5.元空间区域、6.直接内存。


  • 堆内存空间:JVM 中占用内存空间最大的是堆,平常对象的创建大部分都是在堆上分配内存的,是垃圾回收的主要目标和方向。

  • 本地方法栈区域:Native Mehod Stack 与 Java 虚拟机栈的作用非常相似,区别是 Java 虚拟机栈为虚拟机执行 Java 方法或者为字节码而服务,本地方法栈是为了 Java 虚拟机栈得到 Native 方法。

  • Java 虚拟机栈区域:负责 Java 的解释过程、程序的执行过程、入栈和出栈,它是与线程相关的,当启动一个新的线程时,Java 程序就会分配一个 Java 虚拟机栈提供运行;Java 虚拟机栈从方法入栈到具体字节码执行是一个双层栈结构,可以栈里包含栈。

  • 程序计数器:记录线程执行位置,线程私有,因为操作系统不停的调度,无法获取到线程被调度之前的位置,程序计数器提供了这样一个线程执行位置。

  • 元空间区域:在原来的老的 Java 7 之前划分中,永久代用来存放类的元数据信息、静态变量以及常量池等。在现在 Java8 后类的元信息存储在元空间中,静态变量和常量池等并入堆中,相当于原来的永久代中的数据,被元空间和堆内存给瓜分了。

  • 直接内存:使用了 Java 的直接内存的 API 的内存,例如缓冲 ByteBuffer,可以控制虚拟机参数调整大小,而本地内存是使用了 native 函数操作的内存,是不受 JVM 管理控制。


堆内存空间

JVM 回收的主要目标是堆内存,对象主要的创建分配内存在堆上进行,堆可以想象成一个对象池子,对象不停创建放入池子中,而 JVM 垃圾回收是不停的回收池子中一些被标记为可回收对象的对象,启动回收线程进行打扫战场,当回收对象的速度赶不上程序的创建时,池子就会立马满,当满了之后从而发生溢出,就是常见的 OOM。


GC 的速度和堆的内存中存活对象的数量有关,与堆内存所有的对象无关,GC 的速度和堆内存的大小无关,如一个 4GB 大小的堆内存和一个 16GB 的堆内存,只要 2 个堆内存存活对象都是一样多的时候,GC 速度都是基本差不多。每次垃圾回收也不是必须要把垃圾清理干净,重要的是保证不把正在使用的对象给标记清除掉。

2.2 堆内存管理

JVM 中占用内存空间最大的是堆内存,平常对象的创建大部分都是在堆上分配内存的,是 Java 垃圾回收的主要目标和方向、是 Java 内存管理机制的核心组成部分,它可以自动管理 Java 程序的内存分配和释放,Java 垃圾收集器可以自动检测和回收不再使用的内存,以便重新分配给其他需要内存的程序。这种自动内存管理的机制可以提高程序的运行效率和可靠性,防止因内存泄漏等问题导致程序崩溃或性能下降,Java 垃圾收集器使用了不同的垃圾回收算法和垃圾收集器实现,以适应不同的应用场景和需求。Java 垃圾收集器的性能特征和优化技术也是 Java 程序员需要了解和掌握的重要知识。


因此,了解 Java 垃圾回收的背景、原理和实践经验对于编写高效、可靠的 Java 程序非常重要。

2.2.1 对象如何被判断为可回收

JVM 怎么判断堆内存里面的对象是否可回收的,就是当一个对象没有任何引用指向它了,它就是可回收对象,判断的方式有两种算法,一个是引用计数法,一个是可达性分析法。


可回收对象:

(1)引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它时,这个计数器值加一,当引用失效断开时,计数器值就减一,在任何时刻时计数器为 0 的时候,代表这个对象是可以被回收的,没有任何引用使用它了。

引用计数法是有缺点,当对象直接互相依赖引用时,这些对象的计数器都不能为 0,都不能被回收。


(2)可达性分析法

它使用 tracing(链路追踪)方式寻找存活对象的方法,通过一些列称为“GC Roots”的对象作为初始点,从这些初始点开始向下查找,直到向下查找没有任何链路时,代表这个对象可以被回收,这种算法是目前 Java 唯一且默认使用来判定可回收的算法。

2.2.2 GC Roots 的概念和对象类型

  1. Java 虚拟机栈中引用的对象,例如各个线程被调用的方法栈用到的参数、局部变量或者临时变量等。

  2. 方法区的静态类属性引用对象或者说 Java 类中的引用类型的静态变量。

  3. 方法区中的常量引用或者运行时常量池中的引用类型变量。

  4. JVM 内部的内存数据结构的一些引用、同步的监控对象(被修饰同步锁)。

  5. JNI 中的引用对象。


当然,被 GC Roots 追溯到的对象不是一定不会被垃圾回收,具体需要看情况,Java 对象与对象引用存在四种引用级别:分别是强引用、软引用、弱引用、虚引用,默认的对象关系是强引用,只有在和 GCRoots 没有关系时才会被回收;软引用用于维护一些可有可无的对象,当内存足够时不会被回收;弱引用只要发生了垃圾回收就会被清理;虚引用人如其名形同虚设,任何对象都与它无关。

2.2.3 垃圾对象回收算法

当 JVM 定位到了那些对象可回收时,这个时候是通过三个算法标记清除,分别是标记清除算法、复制算法、标记压缩算法。


(1)标记清除算法

首先标记出所有需要回 收的对象,在标记完成后,统一回收掉所有被标记的对象,但是该算法缺点是执行效率低,当大量对象时需要大量标记和清理动作,而且容易产生内存碎片化,当需要一块连续内存时,会因为碎片化无法分配。

(2)标记压缩算法

标记压缩算法跟清除算法很像,只不过它对内存进行了整理, 让存活对象都向内存空间的一端移动,然后将边界的其它对象全部清理,这样能达到内存碎片化问题,不过它比清除算法多了移步动作。

(3)复制算法

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,将存活对象复制到一块空置的空间里,然后将原来的区域全部清理,缺点是需要额外空间存放存活对象。

2.2.4 分代垃圾回收模型概念和原理

堆内存分代模型图

当 JVM 进行 GC(垃圾回收)时,JVM 会发起“Stop the world”,所有的业务线程都进行停止,进入 SafePoint 状态,JVM 回收垃圾线程开始进行标记和追溯,如何解决这种停止和如何减少 STW 的时间呢?


目前主流垃圾收集器采用分代垃圾回收方式,大部分对象的声明周期都比较短,只有少部分的对象才存活的比较长,分代垃圾回收会在逻辑上把堆内存空间分为两部分,一部分为年轻代,一部分为老年代。


(1)年轻代空间

年轻代主要是存放新生成的对象,一般占用堆空间的三分之一空间,因为会频繁创建对象,所以年轻代 GC 频率是最高的。


分为 Eden 空间、Survivor1(from)区、Survivor2(to)区,S1 和 S2 总要有一块空间是空的,为了方便年轻代存活对象来回存放,晋升存活对象年龄。


三个区的默认比例是 8:1:1,可以通过配置参数调整比例。


年轻代回收发起 Minor GC(YongGC),当 Eden 内存区域被占满之后就发起 GC,短暂的 STW,基于垃圾收集器。


(2)老年代空间

是堆内存中最大的空间, ,里面的对象都是比较稳定或者老顽固,GC 频率不会频繁执行。

老年代对象:

  1. 正常提升:由年轻代存活对象年龄到达阈值时,这个对象则会被移动到老年代中。

  2. 分配担保:如果年轻代中的空间不足时,此时有新的对象需要分配对象空间,需要依赖其它内存进行分配担保,老年代担保直接创建。

  3. 大对象:当创建需要大量连续内存空间的对象时,如长字符串或者数组等,大小超过了阈值时,直接在老年代分配。

  4. 动态年龄对象:有的垃圾收集器不需要到达指定年龄大小直接晋升老年代,比如相同年龄的对象的大小总和 > Survivor 空间的 50%, 年龄大于等于该年龄对象直接移动老年代,无需等待正常提升。

老年代回收发起 Major GC / FULL GC,当老年代满时会触发 MajorGC,通常至少经历过一次 Minor GC,再紧接着进行 Major GC, Major GC 清理 Tenured 区,用于回收老年代(CMS 才能单独清理)。


FUll GC:清除整个堆空间,一般来说是针对整个新生代、老生代、元空间的全局范围的清理。


不管是 Major GC 还是 Full GC, STW 的耗时都是 Ygc 的十倍以上,所以说对象能在年轻代被回收是最优的。


Full GC 触发条件:

  • 老年代空间不足。

  • 元空间不足扩容导致。

  • 程序代码执行 System.gc 时可能会执行。

  • 当程序创建一个大对象时,Eden 区域放不下大对象,老年代内存担保分配,老年代也不足空间时。

  • 年轻代存留对象晋升老年代时,老年代空间不足时。

2.2.5 Java 对象内存分配过程


对象的分配过程

  1. 编译器通过逃逸分析优化手段,确定对象是否在栈上分配还是堆上分配。

  2. 如果在堆上分配,则确定是否大对象,如果是则直接进入老年代空间分配, 不然则走 3。

  3. 对比 tlab, 如果 tlab_top + size <= tlab_end, 则在 tlab 上直接分配,并且增加 tlab_top 值,如果 tlab 不足以空间放当前对象,则重新申请一个 tlab 尝试放入当前对象,如果还是不行则往下走 4。

  4. 分配在 Eden 空间,当 eden 空间不足时发生 YGC, 幸存者区是否年龄晋升、动态年龄、老年代剩余空间不足发生 Full GC 。

  5. 当 YGC 之后仍然不足当前对象放入,则直接分配老年代。

TLAB 作用原理:Java 在内存新生代 Eden 区域开辟了一小块线程私有区域,这块区域为 TLAB,默认占 Eden 区域大小的 1%, 作用于小对象,因为小对象用完即丢,不存在线程共享,快速消亡 GC,JVM 优先将小对象分配在 TLAB 是线程私有的,所以没有锁的开销,效率高,每次只需要线程在自己的缓冲区分配即可,不需要进行锁同步堆 。


对象除了基本类型的不一定是在堆内存分配,在 JVM 拥有逃逸分析,能够分析出一个新的对象所拥有的范围,从而决定是否要将这个对象分配到堆上,是 JVM 的默认行为;Java 逃逸分析是一种优化技术,可以通过分析 Java 对象的作用域和生命周期,确定对象的内存分配位置和生命周期,从而减少不必要的内存分配和垃圾回收。可以在栈上分配,可以在栈帧上创建和销毁,分离对象或标量替换,同步消除。

public class TaoYiFenxi {     Object obj;     public void setObj() {        obj = new Object();    }     public Object getObject() {        Object obj1 = new Object();        return obj1;    }      public void test1() {        synchronized (new Object()) {         }    } }
复制代码

2.2.6 JVM 垃圾收集器特点与原理

(1)Serial 垃圾收集器、Serial Old 垃圾收集器

Serial 收集器采用复制算法, 作用在年轻代的一款垃圾收集器,串行运行,执行过程中会 STW,是使用单个线程进行垃圾回收,响应速度优先。


Serial Old 收集器采用标记整理算法,作用在老年代的一款收集器,串行运行,执行过程中会暂停所有用户线程,会 STW,使用单个线程进行垃圾回收,响应速度优先。


  • 使用场景:适合内存小几十兆以内,比较适合简单的服务或者单 CPU 服务,避免了线程交互的开销。

  • 优点:小堆内存且单核 CPU 执行效率高。

  • 缺点:堆内存大,多核 CPU 不适合,回收时长非常长。


(2)Parallel Scavenge 垃圾收集器、Parallel Old 垃圾收集器

Parallel Scavenge 垃圾收集器采用了复制算法,作用在年轻代的一款垃圾收集器,是并行的多线程运行,执行过程中会发生 STW,关注与程序吞吐量。


Parallel Old 垃圾收集器采用标记整理算法,作用,作用在老年代的一款垃圾收集器, 是并行的多线程运行,执行过程中会发生 STW,关注与程序吞吐量。


Parallel Scavenge + Parallel Old 组合是 Java8 当中默认使用的一个组合垃圾回收。


所谓的吞吐量是 CPU 用于运行用户代码时间与 CPU 总消耗时间的比值,也就是说吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集器时间), 录入程序运行了 100 分钟,垃圾收集器花费时间 1 分钟,则吞吐量达到了 99%。


  • 使用场景:适用于内存在几个 G 之间,适用于后台计算服务或者不需要太多交互的服务,保证吞吐量的服务。

  • 优点:可控吞吐量、保证吞吐量,并行收集。

  • 缺点:回收期间 STW,随着堆内存增大,回收暂停时间增大。


(3)Par New 垃圾收集器

Par New 垃圾收集器采用了复制算法,作用在年轻代的一款垃圾收集器, 也是并行多线程运行,跟 Parallel 非常相似,是它的增强版本,或者说是 Serial 收集器的多线程版本,是搭配 CMS 垃圾收集器特制的一个收集器。


  • 使用场景:搭配 CMS 使用


(4)CMS 垃圾收集器

CMS 是一款多线程+分段操作的一款垃圾收集器。其最大的优点就是将一次完整的回收过程拆分成多个步骤,并且在执行的某些过程中可以使用户线程可以继续运行,分别有初始标记,并发标记,重新标记,并发清理和并发重置。

CMS 是一款多线程+分段操作的一款垃圾收集器。其最大的优点就是将一次完整的回收过程拆分成多个步骤,并且在执行的某些过程中可以使用户线程可以继续运行,分别有初始标记,并发标记,重新标记,并发清理和并发重置。


CMS 分段

  • 初始标记阶段, 这个阶段会暂停用户线程, 扫描所有的根对象,因为根对象比较少,所以一般 stw 时间都非常短。

  • 并发标记阶段,这个阶段与用户线程一起执行,会一直沿着根往下扫描,不停的识别对象是否为垃圾,标记,采用了三色算法, 在对象头(Mark World)标识了一个颜色属性,不同的颜色代表不同阶段,扫描过程中给与对象一个颜色,记录扫描位置,防止 cpu 时间片切换不需要重新扫描。

  • 重新标记阶段, 这个阶段暂停用户线程, 修正一些漏标对象,回扫发生引用变化的对象。

  • 并发清理阶段, 这个阶段与用户线程一起执行,标记清除已经成为垃圾的对象。

三色标记

  • 黑色:代表了自己已经被扫描完毕,并且自己的引用对象也已经确定完毕。

  • 灰色:代表自己已经被扫描完毕了, 但是自己的引用还没标记完。

  • 白色:则代表还没有被扫描过。

标记过程结束后,所有未被标记的对象都是不可达的,可以被回收。


三色标记算法的问题场景:当业务线程做了对象引用变更,会发生 B 对象不会被扫描,当成垃圾回收。

public class Demo3 {     public static void main(String[] args) {        R r = new R();        r.a = new A();        B b = new B();        // GCroot遍历R, R为黑色, R下面的a引用链还未扫完置灰灰色,R.b无引用, 切换时间分片        r.a.b = b;        // 业务线程发生了引用改变, 原本r.a.b的引用置为null        r.a.b = null;        // GC线程回来继续上次扫描,发现r.a.b无引用,则认为b对象无任何引用清除        r.b = b;        // GC 回收了b, 业务线程无法使用b    }} class R {    A a;    B b;} class A {    B b;} class B {}
复制代码


当 GC 线程标记 A 时,CPU 时间片切换,业务线程进行了对象引用改变,这时候时间片回到了 GC 线程,继续扫描对象 A, 发现 A 没有任何引用,则会将 A 赋值黑色扫描完毕,这样 B 则不会被扫描,会标记 B 是垃圾, 在清理阶段将 B 回收掉,错误的回收正常的对象,发生业务异常。


CMS 基于这种错误标记的解决方案是采取写屏障 + 增量更新 Incremental Update , 在业务线程发生对象变化时,重新将 R 标识为灰色,重新扫描一遍,Incremental Update 在特殊场景下还是会产生漏标。


public class Demo3 {     public static void main(String[] args) {        // Incremental Update还会产生的问题        R r = new R();        A a = new A();        A b = new A();        r.a1 = a;        // GC线程切换, r扫完a1, 但是没有扫完a2, 还是灰色        r.a2 = b;        // 业务线程发生引用切换, r置灰灰色(本身灰色)        r.a1 = b;        // GC线程继续扫完a2, R为黑色, b对象又漏了~    }} class R {    A a1;    A a2;} class A {}
复制代码

当 GC 1 线程正在标记 O, 已经标记完 O 的属性 O.1, 准备标记 O.2 时,业务线程把属性 O,1 = B,这时候将 O 对象再次标记成灰色, GC 1 线程切回,将 O.2 线程标记完成,这时候认为 O 已经全部标记完成,O 标记为黑色, B 对象产生了漏标, CMS 针对 Incremental Update 产生的问题,只能在 remark 阶段,暂停所有线程,将这些发生过引用改变过的,重新扫描一遍。


  • 使用场景:适用于互联网或者 B/S 服务, 响应速度优先,适合 6G 左右。

  • 优点:并发收集, 低停顿,回收过程中最耗时的是并发标记和并发清除,它都能与用户线程保持一起工作。

  • 缺点:

  • 收集器对 CPU 的资源非常敏感,会占用用户线程部分使用,导致程序会变得缓慢,吞吐量下降。

  • 无法处理浮动垃圾,在并发清理阶段用户线程还是在运行,这时候产生的新垃圾无法在这次当中处理,只有等待下次才会清理。

  • 因为 CMS 使用了 Incremental Update,remark 阶段还是会所有暂停,重新扫描发生引用改变的 GC root,效率慢耗时高。

  • 因为收集器是基于标记清除算法实现的,所以在收集器回收结束后,内存会产生碎片化,当碎片化非常严重的时候,这时候有大对象进入无法分配内存时会触发 FullGC,特殊场景下会使用 Serial 收集器,导致停顿不可控。


(5)G1 垃圾收集器

G1 也是采用三色标记分段式进行回收的算法, 不过它是写屏障 + STAB 快照实现,G1 设定的目标是在延迟可控(低暂停)的情况下获得尽可能高的吞吐量,仍然可以通过并发的方式让 Java 程序继续运行,G1 垃圾收集器在很多方面弥补了 CMS 的不足,比如 CMS 使用的是 mark-sweep 标记清除算法,自然会产生内存碎片(CMS 只能在 Full GC 时,STW 整理内存碎片),然而 G1 整体来看是基于标记整理算法实现的收集器,但是从局部来看也是基于复制算法实现的,高效的整理剩余内存,而不需要管理内存碎片它。


G1 同样有年轻代和老年代的概念,只不过物理空间划分已经不存在,逻辑分区还存在,G1 会把堆切成若干份,每一份当作一个目标,在部分上目标很容易达成,G1 在进行垃圾回收的时候,将会根据最大停顿时间设置值动态选取部分小堆区垃圾回收。

G1 的特点是尽量追求吞吐量,追求响应时间,并发收集,压缩空闲空间不会延长 GC 暂停时间,更容易预测 GC 暂停时间,能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU 对 STW 进行控制(200ms 以内)灵活的分区回收,优先回收花费时间少的或者垃圾比例高的 region 新老比例也是动态调整,不需要配置;年龄晋升也是 15,但是可以动态年龄,当幸存者 region 超过了 50 时,会把年龄最大的放入老年代。


G1 动态 Y 区域设置,G1 每个分区都可能是年轻代或者老年代,但是同一时刻只属于一个代,分代概念还存在,逻辑上分代方便复用以前分代逻辑,在物理上不需要连续,这样能带来额外好处,有的分区内垃圾比较多,有的分区比较少,G1 会优先回收垃圾比较多的分区,这样可以花费少量的时间来回收这些分区垃圾,即收集最多垃圾分区;但是新生代回收不适合这种,新生代达到阈值时发生 YGC,对整个新生代进行回收或者晋升幸存,新生代也分区是方便动态调整分区大小,在进行垃圾回收时,会将存活对象拷贝到另一个可用分区上,这样也能避免一定程度的内存碎片化过程,每个分区的大小都是在 1M- 32M 之间,取决 2 的幂次方。


Humingous:如果一个对象占用的空间超过了分区容量 50%以上,G1 收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响;为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放巨型对象。如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。


CardTable:记录每一块 card 内存区域是否 dirty,如果在发生 YGC 时,怎么知道那些是存活对象,并且其它代区域有没有引用这部分对象,于是把内存划分了很多 card 区域, 每个区域大小不超过 512b,当该 card 区域里的对象有引用关系,将当前 card 置为“dirty”, 并且使用卡表(CardTable)来记录每一块 card 是否 dirty,在进行 GC 时,不用遍历所有的空间, 只需要遍历卡表中为"dirty"或者说布尔符合条件的 card 区域进行回扫。

CSet:Collection SET 用于记录可被回收分区的集合组, G1 使用不同算法,动态的计算出那些分区是需要被回收的,将其放到 CSet 中,在 CSet 当中存活的数据都会在 GC 过程中拷贝到另一个可用分区,CSet 可以是所有类型分区,它需要额外占用内存,堆空间的 1%。


RSet:RememberedSet 每个 Region 都有一个 Rset,是一个记录了其他 Region 中的对象到本身 Region 的引用,它可以使得垃圾收集器不需要扫描整个堆去找到谁的引用了当前分区对象,是 G1 高效回收的关键点,也是三色算法的一个以来点。

RSet 和卡表的区别是什么?

卡表记录的是堆内存中 card 有没有变成"dirty", 但是它本身不知道 dirty 里面哪些是引用了的对象,它是一个大维度的一个记录,RSet 是记录自身 Region 中对象引用了其它 Region 中的那些对象,详细的记录对方引用对象信息,G1 使用了两者的结合,实现了增量式的垃圾回收,并优化跨区引用的最终处理。


SATB 算法:是一种基于快照的算法,它可以避免在垃圾回收时出现对象漏标或者重复标记的问题,从而提高垃圾回收的准确性和效率,在垃圾回收开始时,对堆中的对象引用进行快照,然后在并发标记阶段中记录下所有被修改过对象引用,保存到 satb_mark_queue 中,最后在重新标记阶段重新扫描这些对象,标记所有被修改的对象,保证了准确性和效率。


SATB 算法在 remark 阶段不需要暂停遍历整个堆对象,只需要扫描“satb_mark_queue”队列中的记录,避免了这个阶段长耗时,而 cms 的增量算法在这个阶段是需要重新扫描 GC Roots 标记整个堆对象,导致了不可控时间暂停,总的来说 G1 是通过回收领域应用并行化策略,将原来的几块大内存块回收问题,演变成了 N 个小内存块回收,使得回收效率可以高度并行化,停顿时间可控,可以与用户线程并发执行,将一块内存分而治之。

G1 默认当分区内存占用阈值达到总内存的 45%,会发生 Mixed gc(混和 GC),YoungGC + 并发回收 Mixed GC 过程:初始标记(stw)、并发标记、最终标记(重新标记 stw)、筛选回收(stw 并行)。


  • 使用场景:响应速度优先,较高的吞吐量,面向服务端,使用内存 6G 以上。

  • 优点:并行与并发收集,分代分区收集,优先垃圾收集,空间整合,可控或者可预测停顿时间。

  • 缺点:

  • 收集中产生内存,G1 的每个 region 都需要有一份记忆集和卡表记录跨代指针,这导致记忆集可能占用堆空间 10-20%甚至更多空间。

  • 执行过程中额外负载开销加大,写屏障进行维护卡表操作外,还需要原始快照能够减少并发标记和重新标记阶段的消耗,避免最终标记阶段停顿过长,运行过程中会产生由跟踪引用变化带来的额外开销负担,比 CMS 增量算法消耗更多,CMS 的写屏障实现直接是同步操作, 而 G1 是把写屏障和写后屏障中要做的事情放到队列里异步处理。

  • G1 对于 Full GC 是没有处理流程, 一旦发生 Full GC G1 的回收执行的是单线程的 Serial 回收器进行回收。

2.2.7 垃圾收集器配置使用

机器配置:64 位 4C8G


Java 程序使用 CMS 收集器进行内存垃圾回收初始内存划分情况:

-Xms4096M -Xmx4096M -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/{runuser}/logs/other -XX:+UseConcMarkSweepGC
复制代码


CMS 跟 parNew 占比情况, 默认下 ParNew 占用整个堆的空间为:机器位数 * CPU 核数 * 13 /10 , 当前机器配置计算得出 64 * 4 * 13 / 10 = 332M , 与图上数值差别不大。


Java 程序使用 G1 收集器进行内存垃圾回收初始内存划分情况:

-Xms4096M -Xmx4096M -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/{runuser}/logs/other -XX:+UseG1GC
复制代码


G1 新老年代的占比是动态调整, 随着运行时根据实际情况划分空间。


Java8 默认 ParallerGC 收集器初始内存划分情况:

parallel GC 回收器默认堆 old 区与 young 区内存大小比例 2:1, 图上数值差别不大。

三、内存诊断实践

3.1 内存快照生成

当发生线上应用告警,告警相关内存故障问题时, 应当如何进行故障排查呢?首先应用在发生内存溢出无法执行时,应 DUMP 当前内存快照,需要在 Java 程序执行启动命令时添加上:

-XX:+HeapDumpOnOutOfMemoryError 

-XX:HeapDumpPath=${filePath} 参数


当发生时自动生成一份当前内存快照,方便与开发人员使用快照文件进行问题诊断分析。


在 Java 应用运行时,想手动生成内存快照,可以使用 JDK 自带几个问题排查工具,可以使用 jmap 工具生成指定 PID 内存快照,不过需要耗费较长的一个时间,会暂停应用程序执行,使用 jcmd 工具可以快速的 DUMP 内存快照,因为在堆转储存文件过程中,jcmd 可以利用虚拟机中的一些优化技术,例如分代堆、增量式垃圾回收等技术,相比传统的 jmap 效率高很多,一般来说在 DUMP 内存前会进行一次

Full FC,可以指定屏蔽这次 Full GC,保留当前所有内存中的对象。


除了自带的内存诊断工具, 也可以使用 Arthas 诊断工具,提供了多个命令来帮助诊断内存问题,例如 dashboard(当前 Java 程序内存实时数据面板)、JVM(查看当前 JVM 信息,包括使用的 gc 收集器、内存分区分布情况等信息)、heapdump(当前内存快照类似 jmap 命令的 heap dump)、memory(当前内存分区及占用情况)、monitor(监控模式,可监控内存及查看对象占用情况)profiler(火焰图可以输出多种火焰图,内存分区占用火焰图)等相关内存命令。这些命令可以帮助获取应用程序的内存快照、堆内存使用情况等信息,能快速定位内存问题。


引用:Arthas 命令列表

3.2 dump 内存快照分析

(1)jhat 是 Java 开发工具包自带的一款堆内存分析工具,它可以帮助解决 Java 应用程序的内存问题。Jhat 可以读取 Java 应用程序生成的堆转储文件,并以 HTML 格式展示内存中的对象信息和引用关系,支持 OQL 查询和灵活的过滤和排序功能。


用例  jhat E:\diydump\Java_pid2680.hprof


  • All classes including platform:列举应用程序中所有类的信息,并快速定位内存问题。

  • Show all members of the rootset:显示堆内存中所有根对象的信息,包括系统对象、静态对象、本地对象等。

  • Show instance counts for all classes (including platform):显示所有类的实例数量。

  • Show heap histogram:显示程序堆内存的直方图,可以知道每个类的实例数量和占用内存大小等信息,快速知道内存泄漏原因。


(2)jvisualvm 也是 Java 开发工具包里自带的一款图形化工具,可以用于监控和诊断 Java 应用程序的性能问题。使用它可以实时查看 Java 应用程序的内存使用情况、CPU 使用情况、线程情况等,并可以进行内存分析、CPU 分析、线程分析等内容。


以 Java_pid2680.hprof 为例,进行内存分析内存泄漏原因:


(3)MAT 是基于 Eclipse 的内存分析工具,是一个快速、功能丰富的 Java 内存分析工具,能够快速的分析出 dump 文件中各项结果,快速给出内存泄漏原因报告。


还是以 Java_pid2680.hprof 文件进行分析,比原生的 jhat 方便很多,功能也比原生的更加丰富:


MAT 的一些常用功能点介绍(如图所示):

  • Overview 标签内容有比较多块内容,其中 details 末块介绍总共使用内存大小,类的数量,实例的数量,类的加载器,以及实例的内存直方图;

  • Biggest Objects by Retained Size 模块,使用了饼状图列出了当前内存中占用最大的几个对象,按照百分比划分,点击不同的饼状块能够看到具体对象及其对象属性等信息;

  • actions 模块,这里拥有不同的分析功能,Histogram 生成视图列出每个类所对应的对象个数以及占用内存大小,Dominator Tree 生成视图寻找出大对象,每个实例对象的内存占比比重;

  • Reports 模块是生成报告,其中 Leak Suspects 可以自动分析内存泄漏主要原因报告,可以通过报告准确定位泄漏原因或者可能造成泄漏的原因,并且可以定位到具体累积实例,线程 stack 等信息。


例子中:leak Suspects 报告给出“0xfe3be480” 非常多内存, Gc root Thread 所引用,在发生 gc 时,不是可回收对象,无法回收内存,导致内存溢出。

四、总结

本文介绍了 Java 程序中的内存模型,内存模型划分多份内存区域,不同区域的作用介绍及不同区域的线程之间的内存共享范围,可以帮助开发人员更加理解 Java 中内存管理的机制和原理。


堆是内存模型中最大的一块内存区域,以堆的空间划分详细的介绍了内存分代,部分垃圾收集器即是物理分代和逻辑分代,G1 收集器则物理不分代逻辑保留了以前分代,讲述了不同收集器的原理实现和优缺点,可以根据项目的业务属性,机器配置等因素选择最优的收集器,帮助程序使用最优的收集器可以使得程序的吞吐量和响应速度达到最佳状态。还讲述了不同的参数调优收集器,并且当发生了程序内存溢出崩溃,如何进行内存分析,介绍不同工具的使用,快速定位内存溢出的罪魁祸首,从而在代码层面上根本解决这类问题。

发布于: 17 分钟前阅读数: 7
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020-07-10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
Java 8 内存管理原理解析及内存故障排查实践_实现原理_vivo互联网技术_InfoQ写作社区