垃圾收集与内存分配

一.垃圾收集

1.概述

我们都知道JVM 已经为我们提供了自动垃圾收集和自动内存分配,为什么我们还有学习这方面的内容呢?

周志明大神给出的答案是这样的:

当我们排查各种内存溢出和内存泄漏的问题时,(内存溢出和内存泄漏的区别)或者当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要手动的对垃圾回收的内存分配实施必要的监控和调节。


在之前的运行时数据区中,我们知道程序计数器、Java栈、本地方法栈的生命周期和线程是一样的,栈中的栈帧也是随着方法的进入和退出执行着进栈和出栈操作,栈的生命周期和占用内存大小在类结构确定时就已经确定下来,并且是已知的。所以这几个线程私有的内存区域不必考虑太多垃圾回收的问题

但是堆具有非常大的不确定性和动态性,在类结构中,一个接口的多个实现类占用的内存大小可能不一样,一个方法的多个分支占用的内存也可能不一样,我们只有在程序运行期间才能知道会创建哪些对象,Java堆才是垃圾回收的主战场。

2.如何判断对象已死

垃圾收集器在对Java堆中的对象进行回收时,首先要判断对象是否已经死去,对象死去是指不可能被任何途径使用。

1.引用计数算法

大概步骤是这样的:

给对象添加一个引用计数器,每当有一个地方引用他时,计数器就加1,当引用失效时,计数器就减1,任何时刻计数器为0的对象就是不可能再被使用的

这种方法虽然实现简单,但是存在很大的缺陷,其中可能存在两个对象互相引用对方的情况,也就是出现死锁,这样这两个对象会因为计数器的值不为0而得不到回收,因为这个问题得饿存在,至少目前主流的Java虚拟机并没有选用引用计数器的方法来管理内存。

2.可达性分析算法

这个算法的基本思想是以GC Roots对象作为起始点,向下进行搜索,搜索过程中走过的路径成为引用链,当一个对象到GC Roots没有任何的引用链时,(从GC Roots到这个对象不可达),整明此对象是不可用的。

image

可以作为GC Roots的对象包括以下几种:

  • Java栈(栈帧中的局部变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中引用的对象

3.方法区的回收

垃圾回收行为不仅发生在Java堆中,在方法区也是存在的,尽管前面提到方法区中的垃圾回收效果通常不是很理想,性价比很低,但是也要做。

方法区中垃圾回收主要是两方面的内容:常量池中的废弃常量,还有无用的类,也就是类的卸载。

  • 回收废弃常量比较简单,和Java堆中的对象回收的引用计数方法类似,加入在常量池中存在一个"abc"字符串,这时如果没有一个String类型的变量对“abc”字符串进行引用,那么将其进行内存回收。

  • 类的卸载要求的条件非常严格,类需要满足下面三个条件才是无用的类:

  1. 这个类的所有实例都已经被回收,也就是Java堆中不存在该类的对象
  2. 加载该类的ClassLoader已经被回收
  3. 给类对应的java.lang.Class对象没有在任何地方被引用,不会在其他地方通过反射该类的方法

即使满足上面三个条件,虚拟机也是仅仅可以进行回收,具体回不回收还是要取决于虚拟机的一些参数。

3.垃圾回收算法

1.标记-清除算法

这是最基础的垃圾回收算法,因为后面的算法都是根据他的不足而进行改进的。这个算法首先标记出所有要回收的对象,在标记完成之后统一回收被标记的对象。但是这个方法主要存在两个不足:

  1. 效率问题,标记和清除两个过程效率都不高
  2. 空间问题,在清除之后会存在很多不连续的内存碎片,之后在分配较大内存的对象时,就会因为不能找到足够大的连续内存,而提前触发一次垃圾回收行为

image

2.复制算法

这是对标记-清除算法空间利用率上的改进

他将可用内存划分为大小相等的两块,每次只使用其中一半的内存,当这一块的内存用完了之后,就将存活着的对象复制到另一半,然后对之前一半的内存进行整体清除,这样就不会存在内存碎片的问题,但是这样做将可用内存仅仅缩小为原来的一半,代价未免太大。

但现在的大多数虚拟机还是采用这种方式来进行新生代的回收,因为研究表明,新生代的对象98%都是朝生夕死的,所以不需要将内存划分为大小相等的两部分,只需要划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用的是Eden和一块Survivor。在进行垃圾回收时,将存活着的对象复制到另外一块没有使用的Survivor中,这样每次空间利用率由50%提升到90%,只有10%的空间被浪费。

但是上面都是在新生代可存活对象少于10%的情况,如果出现一些极端情况,就招架不住了,当空闲Survivor空间不足时,需要依赖老年代进行分配担保,将Survivor招架不住的直接放在老年代

3.标记-整理算法

这是对标记-清除算法存在不连续的内存碎片的改进

老年代是不会采用上面的复制算法的,因为根据老年代的饿特点,对象存活率一般较高,进行复制的成本未免太大,而标记-整理的做法是:也是先进行标记,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清除掉端边界之外的内存。

4.分代收集算法

这个算法并不是新的垃圾收集思想,只是根据对象的存活周期将对象分为新生代和老年代,每个部分采用适当的算法进行收集,在新生代,因为每次垃圾回收对会有大批量的对象死去,那么就采用复制算法,只需要付出10%的空间成本和一些复制成本就可以。而老年代的对象存活率高,也没有其他空间为老年代做分配担保,那么就需要使用标记-清除或者标记-整理算法进行回收

4.垃圾收集器

如果所垃圾回收算法是内存回收的方法论,那么垃圾回收算法就是内存回收的具体实现

HotSpot虚拟机中包含的所有收集器:

image

如果两个垃圾收集器之间存在那么连线,那么就说明他们可以搭配使用,我们要明确一个观点:没有万能的,完美的收集器,只有最适合的。如果存在一个超级牛逼的,那么为什么HotSpot还要使用这么多进行搭配组合呢?

接下来逐一介绍每一个收集器:

1.Serial收集器

这是一个单线程的收集器,这里单线程的含义不只是他仅仅使用一个CPU或者一个线程来完成垃圾收集工作,而更重要的是他进行垃圾收集工作的时候,必须暂停所有其他的工作线程,直到他收集结束。这种“Stop The World”会给用户带来非常差的体验,但是即使在打算房间时,也不能一边拖地一边乱踩啊,是不是?

 Serial 收集器

随着越来越优秀、越来越复杂的垃圾收集器出现,用户工作线程的停顿时间不断缩短,但是还没有达到消除的地步。

Serial收集器的主要应用场景还是用作客户端模式下的新生代收集器,因为使用单线程,他简单而高效,没有线程交互的开销,而且客户端情况下,分配给虚拟机的内存都比较小,停顿时间不会太长而影响用户体验。

2.ParNew收集器

这个收集器其实就是Serial的多线程版本,二者在实现上公用了相当多的代码。

ParNew 收集器

他是运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关的原因是:

除了Serial以外,目前只有他能够和CMS(Concurrent Mark Sweep)收集器配合工作。CMS是HotSpot虚拟机中第一款真正意义上的并发收集器,第一次实现了垃圾回收线程和用户线程的同时工作(基本上),例如可以实现一边拖地一边上去踩,这是可以接受的。

3.Parallel Scavenge收集器

他也是一个新生代收集器,也是采用复制算法,也是多线程情况,那么他和ParNew收集器有什么区别呢?

它的关注点和其他收集器不一样,其他收集器关注的是尽可能缩短在垃圾收集时用户线程停顿的时间,保证用户交互体验,而Parallel Scavenge收集器的关注点是是垃圾回收工作达到一个可控的吞吐量。

吞吐量 = 运行用户代码的时间 / (运行用户代码时间 + 垃圾收集时间)

这个收集器可以保证高吞吐量,以高效率的利用CPU时间,尽快完成程序的运算任务,主要适合在后台进行没有太多交互性操作的运算。

Parallel Scavenge 收集器

4.Serial Old收集器

Serial Old收集器是Serial的老年代版本,同样也是单线程的,使用标记-整理算法。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

5.Parallel Old收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

6.CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除: 开启用户线程,同时 GC 线程开始对为标记的区域做清扫。

CMS 垃圾收集器

从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感;
  • 无法处理浮动垃圾;
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

7.G1收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.

被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备一下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记--清理”算法不同,G1 从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

G1 收集器的运作大致分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

二.内存分配

Java中的自动内存管理实际上就是解决了两个问题:垃圾的自动回收和内存的自动分配,前面垃圾回收已经比较详细的介绍了,下面来看一下内存分配那些事

1.对象优先在Eden区分配

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

Minor GC :指发生在新生代的垃圾收集动作,因为Java对象大多朝省夕死,所以Minor GC非常频繁,回溯速度也比较快

Full GC :指发生在老年代的GC,出现了Full GC,那么通常伴有至少一次的Minor GC,(但并非绝对的,Parallel Scavenge收集器中可以直接进行Full GC),老年代GC的速度通常比Minor GC慢10倍以上

垃圾回收过程中,如果新生代的预留的一个Survivor空间不足以保存存留下来的对象,那么通过分配担保机制直接进入老年代

2.大对象直接进入老年代

所谓的大对象就是需要大量连续内存空间的Java对象,例如很长的字符串或数组对象。虚拟机非常不愿意处理大对象,更糟糕的是朝生夕死的大对象,可能内存中还有不少空间就提前触发一次垃圾收集,来获得足够的连续空间。

虚拟机提供了一个参数,当大对象的大于这个设置值得时候,就直接进入老年代,避免在新生代得内存复制开销(新生代采用复制算法)

3.长期存活的对象进入老年代

虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经历一次Minor GC之后仍然存活,并且预留的Survivor空间能够招的下他,那么他将被移动到Survivor空间,对象年龄设为1,这个对象在Survivor空间中没熬过一次Minor GC ,那么对象的年龄+1,当它的年龄增加到一定程度,(默认为15),就会晋升为老年代,进入老年代的阙值,可以通过参数指定。

4.动态对象年龄判定

虚拟机并没有永远要求只要达到阙值的对象才能进入老年代,如果在Survivor空间中相同年龄所有对象大小的综合大于Survivor空间的一半,年龄大于或等于该相同年龄的对象就可以直接进入老年代,无需等到阙值要求的年龄。

5.空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续内存是否大于新生代所有对象的总空间,如果这个条件成立,那么Minor GC可以确保是安全的,如果不成立,虚拟机就会查看HandlePromotionFailure设置值是否允许担保失败,如果允许,那么就继续检查老年代最大可用连续内存空间是否大于历次晋升到老年代对象的平均大小,如果大于,就冒险进行一次Minor GC,尽管可能有失败的概率,如果小于,或者是HandlePromotionFailure设置不允许冒险,那么接下来进行一次Full GC。

前面已经提到,新生代采用复制算法,为了提高内存利用率仅留下了一小部分空间作为Survivor,那么如果出现一次Minor GC之后大量对象都存活的情况,更极端的是所有对象都存活,这时候那个Survivor就招架不住了,需要通过分配担保机制将这些对象直接放入老年代,风险就出在这里,因为对象究竟能存活下来多少在内存回收之前是具有不确定性的,那么在HandlePromotionFailure设置允许冒险的情况下,只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

三.总结

这篇笔记主要摘取了虚拟机自动内存管理的理论知识部分,对各种垃圾回收算法,垃圾收集器有一个大致的理解。涉及到具体的实践调优部分,在这里口嗨还是不够的,当然这不是近期面试考察的内容,如有需要,还会进行第三次回顾这本书的。

垃圾回收在很多时候都是影响系统性能、并发能力的非常主要的因素,虚拟机之所以提供多种不同的收集器以及提供大量的调节参数,是因为只有根据实际应用需求、实现方式选择最优的收集方式才能获得更好的性能,没有最好的,只有最合适的。没有固定的收集器、参数组合,也没有最有的调优算法,虚拟机也没有什么必然的内存回收行为。因此,今后到了实践调优阶段,那就要好好理解具体收集器的原理、行为、优势和劣势、调节参数,只有获得了全部的真相,才能得到全部的自由。

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://hadoo666.top/archives/垃圾收集与内存分配md