1
作者: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收集器则物理不分代逻辑保留了以前分代,讲述了不同收集器的原理实现和优缺点,可以根据项目的业务属性,机器配置等因素选择最优的收集器,帮助程序使用最优的收集器可以使得程序的吞吐量和响应速度达到最佳状态。还讲述了不同的参数调优收集器,并且当发生了程序内存溢出崩溃,如何进行内存分析,介绍不同工具的使用,快速定位内存溢出的罪魁祸首,从而在代码层面上根本解决这类问题。


vivo互联网技术
3.3k 声望10.2k 粉丝