美文网首页
【重要】第三章:垃圾收集器与内存分配策略

【重要】第三章:垃圾收集器与内存分配策略

作者: linyk3 | 来源:发表于2020-01-18 00:19 被阅读0次

Java 和 C++ 之间有一堵由内存动态分配垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。

3.1 概述

垃圾收集(Garbage Collection,GC)的历史比Java久远。第一门真正使用内存动态分配和垃圾收集技术的语言是1960年诞生于MIT的Lisp。

需要回收的内存区域:Java堆和方法区。
程序计数器,虚拟机栈和本地方法栈是线程私有的,内存分配和回收都具备确定性,不需要过多考虑回收问题。

3.2 对象已死吗

Java堆里面几乎所有的对象实例。垃圾回收前需要确认对象是否存活。

3.2.1 引用计数算法

给对象添加一个引用计数器,每当一个地方引用它时,计数器的值+1,当引用失效时,计 数器值-1.任何时刻计时器为0的对象就是不可能再被使用的。
缺点: 无法解决对象之间相互循环引用的问题。

3.2.2 可达性分析算法

主流的商用程序语言都是通过可达性分析(Reachability Analysis)来判定对象是否存活的。
思路:通过一系列被称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots 没有任何引用链相连时,则证明此对象是不可用的。

Java中的GC Roots对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。

3.2.3 再谈引用

  • JDK1.2 引用的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。(被引用 VS 没有被引用)
  • JDK1.2 之后: 引用分为 强引用,软引用,弱引用和虚引用4种,引用强度依次递减。
    • 1.强引用:代码中普遍存在,类似 Object obj = new Object(),只要引用存在,就不能被垃圾回收器回收。
    • 2.软引用:描述一些还有用但并非必须的对象。在即将发生内存溢出时才进行第二次回收,如果仍没有足够内存,才抛出OOM异常。(SoftReference
    • 3.弱引用:非必须对象,只能生存到下一次垃圾回收发生之前。一旦执行垃圾收集,就会被释放内存。(WeakReference
    • 4.虚引用:幽灵引用或幻影引用,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。存在的目的就是为了能在这个对象被收集器回收时收到一个系统通知。(PhantomReference

3.2.4 生存还是死亡

可达性分析算法中不可达对象,不是直接回收,而是要经历两次标记过程:


image.png

备注:任何一个对象的finalize()方法只会被系统自动调用一次。finalize() 方法运行代价高,不确定性大,无法保证各个对象的调用顺序,强烈建议不使用。可以用try-finally或其他方法实现。

3.2.5 回收方法区

方法区(或者HotSpot虚拟机中的永久代)的垃圾收集主要回收两部分内容:废弃常量和无用的类。

  • 废弃常量:常量的对象没有被引用。
  • 无用的类:需要同时满足以下三个条件:
    • 该类的所有实例都被回收,Java堆中不存在该类的任何实例;
    • 加载该类的ClassLoader 已经被回收;
    • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

在大量使用反射,动态代理,CGLib等ByteCode框架,动态生成JSP 以及 OSGi 这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

3.3 垃圾收集算法

  • 标记-清除算法 Mark-Sweep
  • 复制算法 Copying
  • 标记-整理算法 Mark-Compact
  • 分代收集算法: 新生代(复制算法) 老年代(标记-清除算法,标记-整理算法)

3.3.1 标记-清除算法

最基础的收集算法是:标记-清除算法,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
缺点

  • 1.效率不高
  • 2.产生大量不连续的内存碎片


    image.png

3.3.2 复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块内存,然后再把已使用的内存空间一次清理掉。
优点:实现简单,运行高效。
缺点:内存利用率只有一半。


image.png

现代的商业虚拟机都是采用复制算法来回收新生代,因为新生代中的对象98%都是短暂存在的,
新生代: Eden :Survivor1 : Survivor2 = 8 :1 :1
老年代:为新生代进行分配担保。新生代:老年代 = 1 :2

3.3.3 标记-整理算法

标记-整理算法:首先标记出所有需要回收的对象,然后让所有存活的对象都向一端移动,最后直接清理掉边界以外的内存。


image.png

3.4 HotSpot的算法实现

3.4.1 枚举根节点

GC Roots的节点:

  • 全局性的引用(例如常量或类静态属性)
  • 执行上下文(例如栈帧中的本地变量表)

一致性:分析过程中对象引用关系保持不变,所以必须停顿所有的Java线程(Stop The World)。
即使是在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

目前的主流Java虚拟机使用的都是准确式GC,所以有办法直接得知哪些地方存放着对象的引用,而不需要一个不漏的检查完所有执行上下文和全局的引用位置。
在HotSpot的实现 ,是使用一组称为OopMap的数据结构来达到这个目的的。

3.4.2 安全点 Safe Point

在OopMap的协助下,HotSpot可以快速且准确的完成GC Roots 枚举。
程序执行不是在所有的地方都能停顿下来开始GC,只有在到达安全点时才能暂停下来开始GC。
安全点的标准: 是否具有让程序长时间执行的特征。

3.4.3 安全区域 Safe Region

安全区域是指一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。
在线程要离开Safe Region时,他要检查是否已经完成了根节点枚举(或者整个GC过程):

  • 如果完成了,线程继续执行;
  • 如果还没有完成,线程需要等待直到接收可以安全离开Safe Region的信号为止。

3.5 垃圾收集器

  • 收集算法是内存回收的方法论。
  • 垃圾收集器是内存回收的具体实现。

下面讨论的收集器是JDK1.7之后的HotSpot虚拟机:


HotSpot虚拟机中的垃圾收集器

上面一共有7种作用于不同分代的垃圾收集器。两个收集器之间的连线表明他们可以搭配使用。
直到目前为止还没有最好的收集器,更加没有万能的收集器。只有对具体应用最适合的收集器。

Serial收集器 --> Parallel收集器 --> CMS --> G1
从JDK1.3 --> JDK1.7 用户线程停顿时间不断缩短,但仍然无法完全消除

参考文章

3.5.1 Serial 收集器

  • Serial收集器是最基本、发展历史最悠久的收集器,曾是(JDK1.3.1之前)虚拟机新生代收集的唯一选择。
  • Serial收集器是一个单线程的收集器。“单线程”的意义不仅仅是它只会使用一个CPU或一条收集器线程去完成垃圾收集工作,更重要的是它在垃圾收集的时候,必须暂停其他所有工作的线程,直到它收集结束。
  • Serial收集器是HotSpot虚拟机运行在Client模式下的默认新生代收集器。
  • Serial收集器具有简单而高效,由于没有线程交互的开销,可以获得最高的单线程收集效率(在单个CPU环境中)。
  • "-XX:+UseSerialGC":添加该参数来显式的使用Serial垃圾收集器。


    Serial / Serial Old 收集器运行示意图

3.5.2 ParNew 收集器

  • ParNew 收集器其实就是 Serial 收集器的多线程版本。除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The Word、对象分配规则、回收策略等都与Serial收集器一样。
  • ParNew收集器是许多运行在Server模式下的虚拟机首选的新生代收集器,其中一个原因是,除了Serial收集器之外,目前只有ParNew收集器能与CMS收集器配合工作。
    • "-XX:+UseConcMarkSweepGC":指定使用CMS后,会默认使用ParNew作为新生代收集器。
    • "-XX:+UseParNewGC":强制指定使用ParNew。
    • "-XX:ParallelGCThreads":指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同。


      ParNew / Serial Old 收集器运行示意图

并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能是交替执行),用户线程继续工作,而垃圾收集程序运行在另一个CPU上。

3.5.3 Parallel Scavenge 收集器 (吞吐量优先收集器)

  • Parallel Scavenge收集器是一个新生代收集器,使用复制算法,且是并行的多线程收集器。
  • 关注点不同:
    • Parallel Scavenge收集器关注点是达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)) =》 高效率的利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
    • 其他收集器关注点在尽可能的缩短垃圾收集时用户线程的停顿时间。 =》 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。
  • Parallel Scavenge收集器提供了两个参数来用于精确控制吞吐量,一是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis参数,二是直接设置吞吐量大小的 -XX:GCTimeRatio参数;
    • “ -XX:MaxGCPauseMillis” 参数允许的值是一个大于0的毫秒数,收集器将尽可能的保证内存垃圾回收花费的时间不超过设定的值(但是,并不是越小越好,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的,如果设置的值太小,将会导致频繁GC,这样虽然GC停顿时间下来了,但是吞吐量也下来了。
      例如: 【新生代 500MB,10秒收集一次,每次停顿100毫秒】
      对比 :【新生代 300MB,5秒收集一次,每次停顿70毫秒】。
    • “ -XX:GCTimeRatio”参数的值是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比率,默认值是99,就是允许最大1%(即1/(1+99))的垃圾收集时间。
  • “-XX:UseAdaptiveSizePolicy” 是一个开关参数,如果这个参数打开之后,虚拟机就不需要手工指定新生代的大小(-Xmm),Eden与Survivor区的比例(-XX:SurvivorRatio),晋升老年代对象大小(-XX:PertenureSizeThreshold)等细节参数了,虚拟机会根据当前系统运行情况收集监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略
    Parallel Scavenge / Parallel Old 收集器运行示意图

3.5.4 Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本。也同样是一个单线程收集器。

  • Serial 收集器: 新生代,复制算法
  • Serial Old 收集器: 老年代,标记-整理算法
    主要是给Client模式下的虚拟机使用。
    在Server模式下,主要是有两个用途:
    • 在JDK1.5及之前版本与 Parallel Scavenge收集器搭配使用
    • 作为CMS收集器的后备预案。


      Serial / Serial Old 收集器运行示意图

3.5.5 Parallel Old 收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法进行垃圾回收。其通常与Parallel Scavenge收集器配合使用,“吞吐量优先”是这个组合的特点,在注重吞吐量和CPU资源敏感的场合,都可以使用这个组合。

Parallel Scavenge / Parallel Old 收集器运行示意图

3.5.6 CMS 收集器

CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器.
运作过程相对复杂,可以分为4个步骤:

    1. 初始标记(CMS initail mark):需要Stop The World, 只是标记 GC Roots 能直接关联的对象,速度很快.
    1. 并发标记(CMS concurrent mark):GC Roots Tracing的过程
    1. 重新标记(CMS remark):需要Stop The World, 时间比初始标记稍长,比并发标记短. 修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录.
    1. 并发清除(CMS concurrent sweep): 并发清除内存.
Concurrent Mark Sweep 收集器运行示意图

优点: 并发收集, 低停顿
缺点:

  • 1.CMS 收集器对CPU的资源非常敏感, 因为并发会占用资源.
  • 2.CMS 收集器无法处理浮动垃圾,可能出现 Concurrent Mode Failure 失败而导致另一次 Full GC 的产生.
    1. "标记-清除" 算法, 会有大量的空间碎片.

3.5.7 G1收集器

G1(Garbage-First) 收集器是当今收集器技术发展最前沿成果之一.
G1 是一款面向服务端应用的垃圾收集器.HotSpot 开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器. 与其他收集器相比,G1具备以下特点:

  • 并行与并发: G1 能充分利用多CPU, 多核环境下的硬件优势, 使用多个CPU来缩短Stop The World 停顿时间, 部分其他收集器原本需要停顿Java线程的GC动作, G1收集器仍然可以通过并发的方式让Java程序继续运行.
  • 分代收集: G1收集器保留分代收集的概念.虽然G1可以不需要通过其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式区处理新创建的对象和已经存活了一段时间,熬过多次GC的旧对象以获取更好的手机效果.
  • 空间整合: 与CMS的"标记-清除"相比,G1 从整体来看是基于"标记-整理"算法实现的收集器. 从局部来看,是基于"复制"算法来实现的. 所以G1运作期间不会产生内存碎片.
  • 可预测的停顿: 降低停顿时间是G1和CMS的共同关注点. 但G1除了最求低停顿外,还能建立可预测的停顿时间模型, 能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒. 这几乎是实时Java(RTSJ) 的垃圾收集器的特征了.

在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代. 而使用G1收集器时, Java堆的内存布局就与其他的收集器有很大的差别.
G1将整个Java堆划分为多个大小相等的独立区域(Region), 虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的,他们都是一部分不需要连续的Region的集合.

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集. G1 跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要时间的经验值), 在后台维护一个优先列表, 每次根据允许的收集时间, 优先回收价值最大的Region(这个也是 Garbage-First名称的由来). 这种使用Region 划分内存空间以及有优先级的区域回收方式, 保证了G1收集器在有限的时间内可以获取尽可能高的收集效率.

G1按照Region划分内存的思路是好的,但实现起来却是很难的,因为Region不可能是孤立的. 一个对象分配在某个Region中, 它并非只能被本Region中的其他对象引用, 而是可以和整个Java堆中的任意对象发生引用关系.

在G1收集器中, Region之间的对象引用以及其他收集器中的新生代和老年代之间的对象引用,虚拟机都是使用Remembered Set 来避免全堆扫描的. G1中的每个Region都会有一个与之对应的Remembered Set.

如果不计算维护 Remmenbered Set 的操作, G1收集器的运作大致可以划分为以下几个步骤:

    1. 初始标记(Initial Marking): 需要停顿线程, 耗时很短. 只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象.
  • 2.并发标记(Concurrent Marking): 不需要停顿线程, 与用户程序并发执行. 从GC Roots 开始对堆中对象进行可达性分析,找出存活对象,耗时较长.
    1. 最终标记(Final Marking): 需要停顿线程,但是可以并行执行. 修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录, 虚拟机将这段时间对象变化记录在线程 Remembered Set Logs中. 最终标记阶段需要把 Remembered Set Logs的数据合并到 Remembered Set 中.
    1. 筛选回收(Live Data Counting and Evacuation): 首先对各个Region 的回收价值和成本进行排序, 根据用户所期望的GC停顿时间来指定回收计划.
G1 收集器运行示意图

3.5.8 理解GC日志

阅读GC日志是处理Java虚拟机内存问题的基础技能, 它只是一些认为确定的规则, 没有太多的技术含量.

33.125: [GC (Allocation Failure) --[PSYoungGen: 5591K->5591K(9216K)] 9687K->9751K(15360K), 0.0015296 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

100.667: [Full GC (Ergonomics) [PSYoungGen: 5591K->0K(9216K)] [ParOldGen: 4160K->5133K(6144K)] 9751K->5133K(15360K), [Metaspace: 2632K->2632K(1056768K)], 0.0045365 secs] [Times: user=0.06 sys=0.00, real=0.00 secs]
  • 1、"33.125"和"100.667"这两个数字代表了GC发生的时间,这个数字是从Java虚拟机启动以来经过的秒数

  • 2、GC日志开头的“[GC”和“[FULL GC”说明了这次垃圾收集的停顿类型,而不是用来区分老年代GC还是新生代GC的。如果有FULL,说明这次GC是发生了Stop-The-World的。新生代收集器ParNew也会出现"[Full GC"(这一般是因为分配担保失败之类的问题,所以才导致STW)。如果是调用System.gc()方法所触发的收集,那么在这里将显示"FULL GC(System)"。

  • 3、接下来的"[DefNew"、"[Tenured"、"[Perm"表示GC发生的区域,这里显示的区域名称与使用的GC收集器都是密切相关的。
    例如:使用ParNew收集器中的新生代名为“Default New Generation”,所以显示的是“[DefNew”。如果是ParNew收集器,新生代名称就会变为"[ParNew",意为"Parallel New Generation"。如果采用Parallel Scavenge收集器,那它配套的新生代称为"PSYoungGen",老年代和永久代同理,名称也是由收集器决定的。

  • 4、后面方括号内部的“5591K->0K(9216K)”,含义是“GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)

  • 5、而在方括号之外的“9751K->5133K(15360K)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)

  • 6、再往后“0.0015296 secs”表示该内存区域GC所占用的时间,单位是秒。有的收集器会给出更具体的数据 [Times: user=0.00 sys=0.00, real=0.00 secs]。这里的user、sys和real与Linux的time命令所输出的时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU时间和操作从开始到结束所经过的墙钟时间(Wall Clock Time)。墙钟时间包括各种各种非运算的等待耗时,例如等待磁盘I/O等,而CPU时间不包括这些耗时。

3.6 内存分配与回收策略

Java 技术体系中所提倡的自动内存管理最终可以归结为自动化的解决两个问题:

  • 给对象分配内存
  • 回收分配给对象的内存

对象的内存分配, 往大方向讲,就是在堆上分配.对象主要分配在新生代的Eden区上, 如果启动了本地线程分配缓冲, 将按线程优先在TLAB上分配. 少数情况下也可能直接分配在老年代中. 分配的规则并不是百分百固定的, 其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置.

3.6.1 对象优先在Eden分配

大多数情况下, 对象在新生代Eden区中分配. 当Eden区中没有足够空间进行分配时,虚拟机将发起一次 Minor GC.

  • 新生代GC(Minor GC): 发生在新生代的垃圾收集动作.因为Java对象大多都具备朝生夕灭的特性, 所以Minor GC 会非常频繁, 一般回收速度也比较快.
  • 老年代GC(Major GC / Full GC): 发生在老年代的GC, 出现了Major GC, 经常会伴随着至少一次的Minor GC(但并非绝对的, 在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程). Major GC的速度一般比Minor GC 慢 10倍以上.

3.6.2 大对象直接进入老年代

所谓的大对象是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组. 大对象对虚拟机的内存分配来说就是一个坏消息(比遇到一个大对象更加坏的消息就是遇到一群朝生夕灭的短命大对象).经常出现大对象容易导致内存还有不少空间时就提前出发垃圾收集以获取足够的连续空间来安置它们.

虚拟机提供了一个 -XX:PertenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配. 以避免在Eden以及两个Survivor区之间发生大量的内存复制.

3.6.3 长期存活的对象将进入老年代

虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中. 为了做到这一点, 虚拟机给每个对象定义了一个对象年龄计数器.

  • 如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor 容纳的话,将被移动到Survivor空间中, 并且对象年龄设为1. 对象在Survivor区中每熬过一次Minor GC, 年龄就增加1岁. 当它的年龄增加到一定程度(默认为15岁, 可以通过参数 -XX:MaxTenuringThreshold设置), 将会被晋升到老年代中.

3.6.4 动态对象年龄判定

为了更好的适应不同程序的内存状况, 虚拟机并不是永远的要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代, 如果在Survivor空间中相同年龄的所有对象大小总和大于Survivor空间的一半, 年龄大于或等于该年龄的对象就直接进入老年代.

3.6.5 空间分配担保

在发生Minor GC前, 虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间. 如果这个条件成立,那么Minor GC可以确保是安全的.

image.png

JDK1.6之后, HandlePromotionFailure 参数将会失效. 只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC, 否则将进行Full GC.

image.png

3.7 本章小结

本章介绍了垃圾收集的算法, 还有几款JDK1.7中提供的垃圾收集器特点以及运作原理. Java虚拟机中自动内存分配及回收的主要规则.

内存回收与垃圾收集器在很多时候都是影响系统性能,并发能力的主要因素之一. 虚拟机之所以提供多种不同的收集器以及提供大量的调节参数, 是因为只有根据实际应用需求,实现方式选择最优的收集方式才能获取最高的性能.没有固定收集器, 参数组合, 也没有最优的调优方法,虚拟机也就没有什么必然的内存回收行为.因此,在学习虚拟机内存知识,如果要到实践调优阶段,那么必须了解每个具体收集器的行为, 优势和劣势,以及调节参数.

相关文章

网友评论

      本文标题:【重要】第三章:垃圾收集器与内存分配策略

      本文链接:https://www.haomeiwen.com/subject/widomqtx.html