JVM GC日志简介和垃圾收集算法

不同JDK版本或者收集器,其对应的配置参数和日志的基本信息名词描述都有所不同,具体查看权威文档网址:

Java启动参数共分为三类:

  • 其一是标准参数(-),所有的JVM实现都必须实现这些参数的功能,而且向后兼容;
  • 其二是非标准参数(-X),默认jvm实现这些参数的功能,但是并不保证所有jvm实现都满足,且不保证向后兼容;
  • 其三是非Stable参数(-XX),此类参数各个jvm实现会有所不同,将来可能会随时取消,需要慎重使用;

一、GC概述

1、垃圾回收的三个基本问题

说起垃圾收集(Garbage Collection,简称GC),实质上就是内存动态分配与内存自动回收技术的实现,这个技术也是我们java开发和C/C++开发的重要的差异,C/C++开发是需要自己申请和释放内存,Java程序员不需要,因为GC自动帮我们处理了回到垃圾回收的三个基本问题:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

GC帮我们自动处理这些问题,但是我们还是要去了解垃圾收集和内存分配。原因:(不了解不行)

  • 1. 当需要排查各种内存溢出、内存泄漏问题时,
  • 2. 当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些” 自动化”的技术实施必要的监控和调节。

2、垃圾收集器所管理的内存(Java堆和方法区)

在Java内存运行时区域的各个部分中,其中程序计数器、虛拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的开始执行到执行结束,有条不紊地进行着入栈和出栈。因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。

Java堆和方法区这两个区域有着很显著的不确定性,因为:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收必须是动态的。

垃圾收集器所关注的正是这部分内存该如何管理,后续讨论中的"内存”分配与回收也仅仅特指这一部分内存。

二、判断一个对象是存活还是死亡

哪些内存需要回收?

一般情况下,我们说死掉的对象,就是垃圾,垃圾占据的内存就是需要回收的。怎么判断一个对象是存活还是死亡,这里主要讨论两个算法:

1、引用计数算法

很多教科书判断对象是否存活的算法是这样的:

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一 ;任何时刻计数器为零的对象就是不可能再被使用的对象,即死掉的对象。

客观地说,引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。很多的技术和语言也确实都使用了引用计数算法进行内存管理。但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,比如单纯的引用计数就很难解决对象之间相互循环引用的问题。

2、可达性分析算法

当前主流的商用程序语言(Java、 C#...)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是:

通过一系列称为"GC Roots"的根对象作为起始节点集合,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

     

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 在方法区中类静态属性引用的对象。
  • 在方法区中常量引用的对象,如字符串常量池(String Table)里的引用指向的对象。
  • 在本地方法栈中JNI (即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、 OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字) 持有的对象。
  • 反映Java虚拟机内部情况的JMXBean,JVMTI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象"临时性”地加入,共同构成完整GC Roots集合。

3、引用关系

无论是引用计数算法还是可达性分析算法中,判定对象是否存活都和“引用”离不开关系。

在JDK 1.2版之前,Java里面的引用是很传统的定义:这种定义有些过于狭隘了,只有"被引用”或者"未被引用”两种状态

如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。

在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为4种,这4种引用强度依次逐渐减弱。

  • 强引用(Strongly Re-ference)
  • 软引用(Soft Reference)
  • 弱引用(Weak Reference)
  • 虚引用(Phantom Reference)

强引用是最传统的”引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似"Object obj=new Object()"这种引用关系。 无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

软引用是用来描述一些还有用,但非必须的对象。软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收, 如果这次回收还没有足够的内存,才会抛出内存溢出异常。SoftReference类来实现软引用。

    SoftReference<String> ss = new SoftReference<>("hello world");
    String rs = ss.get();

弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。WeakReference类来实现弱引用。

虚引用也称为“幽灵引用”或者"幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。PhantomReference类来实现虚引用。

4、判断一个对象是存活还是死亡

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于”缓刑” 阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,

随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法(假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为"没有必要执行”)。

在对象真的被干掉以前,可以通过覆盖finalize()方法,对象有一次自我拯救的机会,因为任何一个对象的finalize()方法都只会被系统自动调用一次。官方不推荐使用这种语法。

5、打印GC日志信息的配置参数认识

  • 1)查看GC基本信息,在JDK 9之前使用-XX:+PrintGC, JDK 9后使用-Xlog: gc
  • 2)查看GC详细信息,在JDK 9之前使用-XX:+PrintGCDetails,在JDK 9之后使用-Xlog: gc*, 用通配符*将GC标签下所有细分过程都打印出来
  • 3)查看GC前后的堆、方法区可用容量变化,在JDK 9之前使用-XX:+PrintHeapAtGC, JDK 9之后使用-Xlog: gc+heap=debug
  • 4)查看GC过程中用户线程并发时间以及停顿的时间,在JDK 9之前使用-Xx:+PrintGCApplicationConcurrentTime,以及-XX:+PrintGCApplicationStoppedTime, JDK 9之后使用-Xlog: safepoint
  • 5)把gc日志输出到文件-Xloggc:d:\gc.log

示例如下:

//-XX:+PrintGCDetails
public class GCDemo {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    /**
     * 这个成员属性的唯一意义就是占点内存,以便在能在GC日志中看清楚是否有回收过
     */
    private byte[] bigSize = new byte[2 * _1MB];
    public static void main(String[] args) {
        GCDemo A = new GCDemo();
        GCDemo B = new GCDemo();
        A.instance = B;
        B.instance = A;
        A = null;
        B = null;
        //通知GC,可以回收了,GC会记录下来,满足了一定的机制就会执行GC,也就是说不一定马上就会执行GC!
        System.gc();
    }
}

基本看懂日志名词(JDK版本和各收集器名词可能存在不同):

PSYoungGen:表示新生代,这个名称由收集器决定。如果是收集器Parallel Scavenge收集器,新生代名称为SYoungGen

ParOldGen:Parallel Scavenge收集器配套的老年代

Metaspace: Parallel Scavenge收集器配套的元空间

total & used:总的空间和用掉的空间

PSYoungGen新生代又分化eden space、from space和to space这三部分!

GC操作有两种类型:

新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度非常快。

老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC,Major GC的速度一般会比Minor GC慢10倍以上。

5.1 Minor GC的日志信息

Minor GC的日志信息格式:

[PSYoungGen: 2673K->496K(38400K)] 2673K->504K(125952K), 0.0010649 secs] 
[Times: user=0.00 sys=0.00, real=0.00 secs]

1)方括号内部的 [PSYoungGen: 2673K->496K(38400K)] 分别表示

GC前该内存区域已使用容量->GC后该内存区域已使用容量,后面圆括号里面的38400K为该内存区域的总容量。

2)方括号外面的 2673K->504K(125952K), 0.0010649 secs],表示

GC前Java堆已使用容量->GC后Java堆已使用容量,后面圆括号里面的125952K为Java堆总容量。

3)[Times: user=0.00 sys=0.00, real=0.00 secs]分别表示

  • user time是进程执行用户态代码(内核外)耗费的CPU时间,仅统计该进程执行时实际使用的CPU时间,而不计入其他进程使用的时间片和本进程阻塞的时间
  • sys time 是该进程在内核态运行所耗费的CPU时间,即内核执行系统调用所使用的CPU时间
  • real time是从进程开始执行到执行完毕所经历的实际时间,包括其他进程使用的时间片(time slice)和本进程耗费在阻塞(如等待I/O操作完成)上的时间。

user+sys是CPU时间,每个CPU 内核单独计算,所以这个时间可能会是real的好几倍。

5.2 Full GC日志信息

Full GC日志信息:

[Full GC (System.gc()) [PSYoungGen: 496K->0K(38400K)] [ParOldGen: 8K->402K(87552K)] 504K->402K(125952K),
[Metaspace: 3300K->3300K(1056768K)], 0.0066154 secs] 
[Times: user=0.01 sys=0.00, real=0.00 secs]

从左到右分别为:

[GC类型 (System.gc()) [Young区: GC前Young的内存占用->GC后Young的内存占用(Young区域总大小)]

[old老年代: GC前Old的内存占用->GC后Old的内存占用(Old区域总大小)]  GC前堆内存占用->GC后堆内存占用(JVM堆总大小),

[方法区: GC前占用大小->C后占用大小(方法区总大小)], GC用户耗时]

[Times:用户耗时 sys=系统时间, real=实际时间]

Metaspace used 3354K, capacity 4496K, committed 4864K, reserved 1056768K

这些used,capacity,committed和reserved并不纯粹是JVM的概念,它和操作系统相关。先来看committed和reserved。

reserved是指,操作系统已经为该进程“保留”的。所谓的保留,更加接近一种记账的概念,就是操作系统承诺说一大块连续的内存已经是你这个进程的了。

注意的是,这里强调的是连续的内存,并且强调的是一种名义归属。那么实际上这一大块内存有没有真实对应的物理内存呢?答案是不知道。那么什么时候才知道呢?等进程committed的时候。当进程真的要用这个连续地址空间的时候,操作系统才会分配真正的内存。所以,这也就是意味着,这个过程有可能会失败。

used和capacity就是JVM的概念了。

这两个概念非常接近JVM一些集合框架的概念。比如说ArrayList的实现里面就有capacity和size的概念。假如说我创建了一个可以存放20个元素的ArrayList,但是我实际上只放了10个元素,那么capacity就是20,而size就是10。这里的size和used就是同样的概念。只不过“元素”是一个个内存块"block“。

[0x000000071f300000,0x000000071f300000,0x0000000720800000)

显然这种格式就是三个内存地址,在HotSpot里分别称为 low_boundary、high、high_boundary。

  • low_boundary: reserved space的最低地址边界;也是commited space的最低地址边界
  • high: commited space的最高地址边界
  • high_boundary: reserved space的最高地址边界。

[low_boundary, high_boundary)范围内的就是reserved space,这个space的大小就是max capacity。

[low, high)范围内的就是commited space,而这个space的大小就是current capacity(当前容量),简称capacity。

capacity有可能在一对最小值和最大值之间浮动。最大值就是上面说的max capacity。

6、方法区中的垃圾收集(了解)

《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在,如JDK 11时期的ZGC收集器就不支持类卸载,方法区垃圾收集的”性价比”通常也是比较低的,

在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收因为有苛刻的判定条件,垃圾收集的回收成果往往远低于此。

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。

三、垃圾收集算法

1、分代收集理论

垃圾收集算法这是只了解主流的。现在认识下分代收集理论。

当前商业虚拟机的垃圾收集器,大多数都遵循了"分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:

收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

正是上述的分代理论的经验法则,所以一个经典的堆内存划分:

      

基于上面的堆内存分区域,对应出一系列GC的概念:进一步衍生出一系列的垃圾收集算法:

新生代收集(Minor GC/Young GC) :指目标只是新生代的垃圾收集。

老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。

        另外请注意"Major GC”这个说法现在有点混淆,需按上下文区分到底是指老年代的收集还是整堆收集。

混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾。

整堆收集(Full GC):收集整个Java堆和方法区的垃圾。

2、标记-清除(Mark-Sweep)算法

最早出现也是最基础的垃圾收集算法是”标记-清除”(Mark-Sweep)算法,算法分为”标记”和“清除”两个阶段:

首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

主要缺点有两个:

  • 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  • 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存,而不得不提前触发另一次垃圾收集动作。

3、标记-复制算法

标记-复制算法常被简称为复制算法。为了解决标记清除算法面对大量可回收对象时执行效率低的问题,1969年提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法。

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。

这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,新生代中的对象有98%熬不过第一轮收集。

4、标记-整理(Mark-Compact)算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。所以在老年代一般不能直接选用这种算法。

针对老年代对象的存亡特征,1974年提出了另外一种有针对性的“标记-整理”(Mark-Compact) 算法。

其中的标记过程仍然与”标记清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

标记清除算法与标记整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动存活对象是一项优缺点并存的风险决策:

  • 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,像这样的停顿被最初的虚拟机设计者形象地描述为”Stop The World"。
  • 但如果跟标记清除算法那样完全不考虑移动和整理存活对象的话,分散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。如通过"分区空闲分配链表”来解决内存分配问题。但是,内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。所以具体来说两种算法用哪个,应该根据具体应用特点来选择:

  • HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记整理算法的,而关注延迟的CMS收集器则是基于标记清除算法的,这也从侧面印证这点。
  • 还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记整理算法收集一次,以获得规整的内存空间。前面提到的基于标记清除算法的CMS收集器面临空间碎片过多的时候就是这样子干的。

四、HotSpot的算法细节实现(了解) 

1、根节点枚举和OopMap

前面介绍过GC Roots的概念,每次从GC Roots开始检查引用链的时候,都是要让所有用户线程暂停,GC Roots里的引用对象太多,如恒河沙数,一个不漏地从方法区、栈区等GC Roots中的引用开始查找这将是一个非常耗时的过程,所以,解决方案是从外部记录下类型信息,生成映射表,在HotSpot中把这种映射表称之为OopMap

实现这种功能,需要虚拟机的解释器和JIT编译器支持,由他们来生成OopMap。生成这样的映射表一般有两种方式:

  • 每次都遍历原始的映射表,循环的一个个偏移量扫描过去;这种用法也叫“解释式”;
  • 为每个映射表生成一块定制的扫描代码(想像扫描映射表的循环被展开的样子),

以后每次要用映射表就直接执行生成的扫描代码;这种用法也叫“编译式”。

总而言之,GC开始的时候,就通过OopMap这样的一个映射表知道,在对象内的什么偏移量上是什么类型的数据,而且特定的位置记录下栈和寄存器中有哪些位置是对应的引用。

2、安全点

上面讲到了为了快点进行可达性的分析,使用了一个引用类型的映射表,可以快速的知道那些对象是可达的,那些是不可达的。但是随着而来的又有一个问题,就是在方法执行的过程中, 可能会导致引用关系发生变化,那么保存的OopMap就要随着变化。如果每次引用关系发生了变化都要去修改OopMap的话,这又是一件成本很高的事情。所以这里就引入了安全点的概念。

1. 什么是安全点?

OopMap的作用是为了在GC的时候,快速进行可达性分析,所以OopMap并不需要一发生改变就去更新这个映射表。只要这个更新在GC发生之前就可以了。所以OopMap只需要在预先选定的一些位置上记录变化的OopMap就行了。这些特定的点就是SafePoint(安全点)。

由此也可以知道,程序并不是在所有的位置上都可以进行GC的,只有在达到这样的安全点才能暂停下来进行GC。

既然安全点决定了GC的时机,那么安全点的选择就至为重要了。安全点太少,会让GC等待的时间太长,太多会浪费性能。

一般会在如下几个位置选择安全点:

  1. 循环的末尾
  2. 方法临返回前 / 调用方法的call指令后
  3. 可能抛异常的位置

3、增量更新和原始快照

当垃圾回收的线程和用户的线程并发执行的时候,垃圾线程从GC Roots开始扫描,扫描过的对象,又被用户线程改变了引用关系,会造成本应该标记存活的对象,被错误标记为垃圾对象的问题!

由此分别产生了两种解决这个问题的方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)

  • 增量更新: 当从存活对象插入新的指向原垃圾对象的引用的时候,就将这个新插入的引用记录下来,等并发扫描结之后,再将这些记录过的引用关系中的存活对象为根,重新扫描一次。
  • 原始快照:当删除从一个存活对象指向原本存活对象的引用的时候,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的第一个存活对象为根,重新扫描一次。

—— Stay Hungry. Stay Foolish. 求知若饥,虚心若愚。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值