(二)垃圾收集器和内存分配策略

垃圾收集器和内存分配策略

本章的核心内容:

  1. 垃圾回收算法和常见的垃圾回收器
  2. 堆内存自动分配的一些规则

哪些内存需要回收?

程序计数器、本地方法栈和虚拟机栈是随着线程的产生而产生,随着线程的消亡而消亡的,这几部分的内存分配和回收是确定好了的,随方法结束或线程结束时,内存就紧跟着回收了。

而Java堆和方法区不一样。一个接口中多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在运行期间才知道会创建哪些对象,故内存回收与分配重点关注的是堆内存和方法区内存。

  • 方法区:永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。
  • 堆:其中存放的是对象实例,对于对象实例的回收,我们首先要判断哪些对象是“存活的”,对于那部分“死亡的”对象,就是我们要回收的。判断对象的存活有两种方法:
    1. 引用计数算法
    2. 可达性分析算法

什么时候回收?

不足以分配新内存时

对象已死吗

如何判断对象是否还有用?

  • 引用计数法:给对象添加一个引用计数器, 每当有一个地方引用它时, 计数器值+1, 引用失效, -1, 为0的对象不能被使用。

    1. 优势:实现简单,效率高。
    2. 无法解决对象相互引用的问题——会导致对象的引用虽然存在,但是已经不可能再被使用,却无法被回收。
  • 可达性分析:通过一系列的称为”GC Roots”的对象作为起始点, 从这些节点开始向下搜索, 搜索走过的路径称为引用链(Reference Chain), 当一个对象到GC Roots不可达(也就是不存在引用链)的时候, 证明对象是不可用的。

可达性分析

哪些对象可以作为GC Roots?

  1. 方法区: 类静态属性引用的对象;
  2. 方法区: 常量引用的对象;
  3. 虚拟机栈(本地变量表)中引用的对象.
  4. 本地方法栈JNI(Native方法)中引用的对象。

对引用的理解?

为了描述“当内存空间还足够时,则能保留在内存之屮;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象”。在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

引用类型

可达性分析中一个对象的死亡需要经历哪些过程?

会经历两次标记:

  1. 进行可达性分析,第一次标记不可达的对象,并把“覆盖了finalize()方法且未执行该方法”的对象加入F-Quene队列中。
  2. 启动低优先级的线程,“尝试”执行F-Quene队列中的finalize()方法,若自救(与可达引用链上的任一对象关联)失败,则进行第二次标记。

    注意:每个对象finalize()方法只是可能执行,并不能保证执行

方法区需要被回收吗?

没有规定,可以回收,但是回收率很低,实现回收机制,性价比不高
永久代的回收主要是“废弃常量”和“无用的类”

如何判断一个常量是否“废弃常量”?

假如有个字符串“abc”进入常量池,没有任何地方引用到该常量

如何判断一个类是否“废弃类”?

  1. 所有实例已被回收
  2. 该类的加载器已被回收
  3. 该类对应的Class对象没有任何地方被引用,也没有任何地方用过反射访问该类
    满足上诉3个条件就有可能被回收

垃圾收集算法

标记清除算法

分为标记和清除两个阶段,先标记出需要回收的对象(可达性分析算法或者引用计数算法),在标记完成后统一回收所有被标记的对象。

不足之处:效率问题,标记和清除效率都不高。空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。

标记清除算法

复制算法

将可用内存划分为大小相等的两块,每次只使用其中的一块。当这块用完了,就将还存活的复制到另一块上,然后将这一块一次性清除。商业虚拟机都是采用该方法来回收新生代,新生代98%都是朝生夕死的。将内存分为较大Eden和两个较小的survivor空间。每次使用其中一块Eden和survivor,回收时将存活的对象一次性地复制到另一块survivor中,再清理掉之前的。HotSpot虚拟机Eden与Survivor默认的大小比例为8:1:1。survivor空间不够时,需要依赖其他内存(老年代)进行分配担保,即让对象进入老年代。

不足之处:复制在对象存活率较高时效率很低。不适合老年代

复制算法

标记-整理算法

标记过程同标记清除一样,但不是直接对可回收对象进行清理,而是让存活对象朝着一端移动,然后直接清理掉端边界外的内存。

复制算法

分代算法

根据各年代特点分别采用最适当的GC算法。在新生代:中每次垃圾收集都能发现大批对象已死, 只有少量存活. 因此选用复制算法, 只需要付出少量存活对象的复制成本就可以完成收集。在老年代: 因为对象存活率高、没有额外空间对它进行分配担保, 就必须采用“标记—清理”或“标记—整理”算法来进行回收, 不必进行内存复制, 且直接腾出空闲内存。即:

新生代:存活率低,使用复制算法
老年代:存活率高,使用“标记-整理”或“标记-清除”算法

HotSpot的算法实现

什么是“Stop The World”?

可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作在一个能确保一致性的快照中进行——这里“一致性”的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致GC进行时必须停顿所有Java执行线程(Sun将这件事情称为“Stop The World”)的其中一个重要原因,即使是在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

如何枚举根节点?

在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。

问题:可能导致引用关系变化的指令非常多,如果为每一个指令都生成对应的OopMap,将需要大量的空间,怎么办?

实际上,HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint)

如何选定安全点?

Safepoint的选定即不能太少以致于让GC等待时间太长,也不能过于频繁以至于过分增大运行时的负荷。所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。

如何在GC时,让所有线程都在安全点停下?

这里有两种方案可供选择:

  • 抢先式中断(Preemptive Suspension):抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让他“跑”到安全点上。
  • 主动式中断(Voluntary Suspension):主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

    现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。

如果程序不被执行呢?

所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方去中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看作是被扩展了的Safepoint。

在线程执行到Safe Region中的代码中,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,他要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则他就必须等待直到收到可以安全离开Safe Region的信号为止。

垃圾收集器

没有最好的收集器,只有最适合的收集器;

垃圾收集器

Serial 收集器

用于新生代,采取复制算法

特点:最悠久,最基本的收集器;单线程收集器,只使用一个CPU或一条收集线程去完成垃圾收集工作,且在收集时,必须暂停其他所有的工作线程,直到收集结束。在进行垃圾收集时必须暂停其他所有的工作线程,即“Stop The World”。依然是虚拟机运行在Client模式下的默认新生代收集器。简单而高效。
不足:“Stop The World”给用户带来不好的体验

Serial

ParNew 收集器

用于新生代,采用复制算法

特点:Serial收集器的多线程版,多条线程进行垃圾收集。其余和Serial收集器一样。目前唯一能与CMS收集器配合工作。

采用多线程只会在多核的情况下才会有比较好的性能,可以使用-XX:ParallelGCThreads来限制线程数

ParNew

Parallel Scavenge 收集器

用于新生代,采用复制算法

特点:目标是达到一个可控制的吞吐量——CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量=运行用户代码的时间/(运行用户代码的时间+垃圾收集的时间))。

停顿时间越短,越适用需要与用户交互的程序。而高吞吐量可以高效的利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的程序。

-XX:MaxGCPauseMillis设置停顿时间,以牺牲吞吐量和新生代空间为代价。-XX:GCTimeRatio设置吞吐量

Parallel Scavenge

Serial Old 收集器

用于老年代,使用“标记-整理”算法。

可以与JDK1.5及之前的Parallel Scavenge搭配使用;也可以作为CMS收集器的后备预案,在并发收集发生Concureent Mode Failure时使用。

ParNew Old 收集器

用于老年代,使用“标记-整理”算法。

JDK1.6前,Parallel Scavenge只能与老年代收集器Serial Old(PS MarkSweep)组合,由于Serial Old无法充分利用服务器多CPU的处理能力,会拖累整体性能。

JDK1.6后,Parallel Scavenge可与Parallel Old组合,达到名副其实的“吞吐量优先”,在注重吞吐量以及CPU资源敏感的场合可以优先考虑这个组合。

CMS 收集器

用于老年代,使用“标记—清除”算法。

特点:以获取最短回收停顿时间、低延迟为目标,适用于重视服务响应速度的应用。

主要过程为一下四步:

  1. 初始标记;Stop the World,仅标记GCRoots能关联的对象,速度很快。
  2. 并发标记;进行GCRootsTracing的过程。
  3. 重新标记;Stop the World,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。比1长但远比2短。
  4. 并发清除;

Parallel Scavenge

缺点:

  1. CMS对CPU资源非常敏感。
  2. CMS无法处理浮动垃圾(Floating Garbage)——并发清楚阶段产生的垃圾,可能出现Concurrent Mode Failure失败(CMS运行期间预留的内存无法满足用户线程的需求)而导致另一次Full GC的产生。
  3. CMS是标记清除,会产生大量碎片空间,对大对象内存分配带来麻烦。

    默认是老年开使用了68%开始收集,可以使用-XX:CMSInitiatingOccupancyFraction来设置,太高了容易出现Concurrent Mode Failure

G1 收集器

与其他基于分代的收集器不同,G1将整个Java堆划分为多个大小相等的独立区域Region,新生代和老年代不再是物理隔离的。

从整体来看:“标记-整理” 算法
从局部(两个Region之间)来看:“复制”算法

G1为什么能建立可预测的停顿时间模型?
如何维护每个Region之间的引用关系?

理解GC日志

参考

内存分配与回收策略

  • 新生代GC(Minor GC):发生在新生代的垃圾收集动作,非常频繁,一般回收速度也比较快。
  • 老年代GC(Major GC/Full GC):发生在老年代的垃圾收集动作,一般会伴随Minor GC 速度一般比Minor GC慢上10倍以上。

新生代晋升老年代的条件?

  1. 优先在Eden区分配(如果启动本地线程分配缓冲TLAB-Thread Local Allocation Buffer,则优先在TLAB)如果Eden区满,则触发一次Minor GC。使用-XX:SurvivorRatio设置比例
  2. 大对象直接进入老年代;大对象,即大量连续内存空间的Java对象,最典型的是那种很长的字符串及数组。这样做的好处是减少复制。使用-XX:PretenureSizeThreshold设置阈值
  3. 长期存活的对象将进入老年代:设置对象年龄计数器。对象在Eden出生并经过第一次MinorGC后仍存活,年龄+1,移入Survivor区。以后每经过一次MinorGC年龄加一,当达到15时(默认的)就进入老年代。使用-XX:MaxTenureingThreshold设置进入年龄
  4. 动态对象年龄判定:并不是对象年龄必须达到最大阈值才会进入老年代。如果survivor空间中相同年龄所有对象大小总和大于其空间的一般,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到阈值时才进入。
  5. 空间分配担保:发生minorGC之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,成立,MinorGC可以确保是安全的。不成立,则检查HandlePromotionFailure设置值是否允许担保失败。允许,则检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小。大于将尝试MinorGC,小于或者不允许冒险,也要进行一次FullGC。老年代分配担保,将survivor无法容纳的对象直接进入老年代。依然担保失败,则只好在失败后重新发起一次Full GC。使用-XX:HandlePromotionFailure来设置是否允许担保失败

本文标题:(二)垃圾收集器和内存分配策略

文章作者:Sun

发布时间:2019年10月14日 - 18:10

最后更新:2019年11月04日 - 14:11

原始链接:https://sunyi720.github.io/2019/10/14/JVM/(二)垃圾收集器和内存分配策略/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。