Java相比C/C++的一个显著特点就是自动管理内存,不用程序员关心内存的释放,当Java程序也出现OOM异常的时候,不禁让人摸不着头脑,Java是如何实现对内存的管理的?主要是JVM实现的垃圾回收机制(Garbage collection),判断堆中的对象实例或者数据是否是垃圾,是垃圾就定时清理掉。

如何识别垃圾

引用计数法

简单来说,就是对象被引用一次,则在对象头上加一个引用计数增1,不在使用时减1,当引用计数变为0的时候,则认为可以回收。
主要的问题,无法解决循环引用,就是A类中一个属性引用了B类对象,B类中一个属性引用了A类对象,这样一来,就算你把A类和B类的实例对象引用置为null,它们还是不会被回收;

可达性分析法

核心思想为 : 通过一系列称为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为引用链,当一个对象到GC Roots没有任何的引用链相连时(从GC Roots到这个对象不可达)时,证明此对象是不可用的。

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

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

垃圾收集算法

主要有以下几种方法进行垃圾回收:

  • 标记清除算法
  • 复制算法
  • 标记整理法
  • 分代收集算法

标记清除法

copy.png
首先根据可达性分析算法标记出可以回收的对象,然后清楚这些区域。
主要问题是容易产生内存碎片,导致分配大的对象失败。

复制算法

copy.png

把堆等分成两块区域, A 和 B,区域 A 负责分配对象,区域 B 不分配,对区域 A 使用以上所说的标记法把存活的对象标记出来,然后把区域 A 中存活的对象都复制到区域 B(存活对象都依次紧邻排列)最后把 A 区对象全部清理掉释放出空间,这样就解决了内存碎片的问题了。
主要问题是空间利用率只有一半,并且每次回收都要将对象移动一次,效率依然很低。

标记整理法

copy.png
在标记清除法的基础上添加了一个整理的过程 ,即将所有的存活对象都往一端移动,紧邻排列(如图示),再清理掉另一端的所有区域,这样的话就解决了内存碎片的问题。
缺点依然是频繁移动导致效率低。

分代收集算法

IBM 专业研究表明,一般来说,98% 的对象都是朝生夕死的,经过一次 Minor GC 后就会被回收,所以分代收集算法根据对象存活周期的不同将堆分成新生代和老生代。

工作原理
新生代采用的复制算法,其实是优化后的复制算法,即在复制算法的基础上,使用三个分区(Eden/S0/S1)进行处理。
对象在新生代的分配和回收,大部分对象存活周期很短,当Eden区满的时候触发MinorGC。
copy.png
然后,少部分存活的对象被放到S0区

当触发下一次 Minor GC 时,会把 Eden 区的存活对象和 S0(或S1) 中的存活对象(S0 或 S1 中的存活对象经过每次 Minor GC 都可能被回收)一起移到 S1(Eden 和 S0 的存活对象年龄+1), 同时清空 Eden 和 S0 的空间。

若再触发下一次 Minor GC,则重复上一步,只不过此时变成了从 Eden,S1 区将存活对象复制到 S0 区,每次垃圾回收,S0,S1 角色互换,都是从 Eden ,S0(或S1) 将存活对象移动到 S1(或S0)。也就是说在 Eden 区的垃圾回收我们采用的是复制算法,因为在 Eden 区分配的对象大部分在 Minor GC 后都消亡了,只剩下极少部分存活对象,S0,S1 区域也比较小,所以最大限度地降低了复制算法造成的对象频繁拷贝带来的开销。

对象如何到老年代?

  1. 当对象的年龄达到了我们设定的阈值,则会从S0(或S1)晋升到老年代;
  2. 大对象,当某个对象分配需要大量的连续内存时,此时对象的创建不会分配在 Eden 区,会直接分配在老年代,因为如果把大对象分配在 Eden 区,Minor GC 后再移动到 S0,S1 会有很大的开销(对象比较大,复制会比较慢,也占空间),也很快会占满 S0,S1 区,所以干脆就直接移到老年代。最典型的大对象就是那种很长的字符串以及数组;
  3. 还有一种情况也会让对象晋升到老年代,即在 S0(或S1) 区相同年龄的对象大小之和大于 S0(或S1)空间一半以上时,则年龄大于等于该年龄的对象也会晋升到老年代;
  4. 当Eden区和S0区的存活对象大小之和大于S1的时候,多余的也会被放到老年代。

一般 Full GC 会导致工作线程停顿时间过长(因为Full GC 会清理整个堆中的不可用对象,一般要花较长的时间),因此尽可能地避免对象过早地进入老年代,尽可能晚地触发 Full GC。这也是为什么分代和设置各区大小不同的原因。通过S0和S1的缓冲,让少数的对象进入老年代。

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java 虚拟机规范并没有规定垃圾收集器应该如何实现,因此一般来说不同厂商,不同版本的虚拟机提供的垃圾收集器实现可能会有差别,一般会给出参数来让用户根据应用的特点来组合各个年代使用的收集器,主要有以下垃圾收集器:
copy.png
连线表示可以配合工作。

Serial收集器

Serial 收集器是工作在新生代的,单线程的垃圾收集器。意味着只能使用一个线程完成整个垃圾收集过程,而这个过程是STW的,用户线程会暂停,看起来是没用的,但是在Client场景下,或限定单个CPU的环境下,单线程确能利用它的优势,减少与其他线程的交互开销,或者小内存的场景下,也能保证良好的性能。

ParNew 收集器

是Serial收集器的多线程版本,其他的如收集算法,分配规则,回收策略基本一致。当然工作在Server模式下,减少了停顿时间,提升响应时间。
在多 CPU 的情况下,由于 ParNew 的多线程回收特性,毫无疑问垃圾收集会更快,也能有效地减少 STW 的时间,提升应用的响应速度。

Parallel Scavenge 收集器

Parallel Scavenge 收集器也是一个使用复制算法,多线程,工作于新生代的垃圾收集器,与ParNew 收集器类似,关注点在于达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间)),更适合做后台运算等不需要太多用户交互的任务。
提供了两个参数来精确控制吞吐量,分别是控制最大垃圾收集时间的-XX:MaxGCPauseMillis 参数及直接设置吞吐量大小的 -XX:GCTimeRatio(默认99%)
也提供了-XX:UseAdaptiveSizePolicy参数,自适应策略,可以根据实际的情况动态调整新生代大小,最大垃圾收集时间和吞吐量大小。

Serial Old 收集器

Serial Old 是工作于老年代的单线程收集器,此收集器的主要意义在于给 Client 模式下的虚拟机使用。另外可以作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

Parallel Old 收集器

Parallel Old 是相对于 Parallel Scavenge 收集器的老年代版本,使用多线程和标记整理算法。与Parallel Scavenge配合使用,吞吐量优先。
Java8 默认使用 Parallel Scavenge + Parallel Old 组合,Java9 开始 G1 取代了它们。

CMS 收集器

CMS(Concurrent Mark Sweep) 收集器是以实现最短 STW 时间为目标的收集器,如果应用很重视服务的响应速度,希望给用户最好的体验,则 CMS 收集器是个很不错的选择!
一般老年代使用的是标记整理法,但是CMS采用的是标记清除算法。经过了以下几个步骤:

1
初始标记-->并发标记-->重新标记-->并发清除

copy.png
初始标记和重新标记两个阶段会发生 STW,造成用户线程挂起,不过初始标记仅标记 GC Roots 能关联的对象,速度很快,并发标记就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,重新标记是为了修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这一阶段停顿时间一般比初始标记阶段稍长,但远比并发标记时间短。
整个过程中耗时最长的是并发标记和标记清理,不过这两个阶段用户线程都可工作,所以不影响应用的正常使用,所以总体上看,可以认为 CMS 收集器的内存回收过程是与用户线程一起并发执行的。

缺点如下:

  1. CMS对CPU资源的要求较高,要分出额外的线程去回收垃圾,造成吞吐量的下降,CMS默认开启的线程数为CPU数+3/4;
  2. CMS无法处理浮动垃圾(Floating Garbage),并发清理的时候可能产生新的垃圾,因此不能等老年代满的时候再清理。另外当出现promotion failedconcurrent mode failure的时候,会启用Serial Old 收集器来重新进行老年代的收集,会导致STW更长;
  3. 内存碎片化的产生。如果无法找到足够大的连续空间来分配对象,将会触发 Full GC,这会影响应用的性能。默认开启了-XX:+UseCMSCompactAtFullCollection进行内存整理,通过参数-XX:CMSFullGCsBeforeCompation指定多少次Full GC后压缩。

G1(Garbage First) 收集器

G1 收集器是面向服务端的垃圾收集器,被称为驾驭一切的垃圾回收器,主要有以下几个特点:

  • 像 CMS 收集器一样,能与应用程序线程并发执行。
  • 整理空闲空间更快。
  • 需要 GC 停顿时间更好预测。
  • 不会像 CMS 那样牺牲大量的吞吐性能。
  • 不需要更大的 Java Heap

与 CMS 相比,它在以下两个方面表现更出色:

  1. 运作期间不会产生内存碎片,G1 从整体上看采用的是标记-整理法,局部(两个 Region)上看是基于复制算法实现的,两个算法都不会产生内存碎片,收集后提供规整的可用内存,这样有利于程序的长时间运行。
  2. 在 STW 上建立了可预测的停顿时间模型,用户可以指定期望停顿时间,G1 会将停顿时间控制在用户设定的停顿时间以内。

G1的各代的内存如下,是不连续的,每一代都使用了 n 个不连续的大小相同的 Region,每个 Region 占有一块连续的虚拟内存地址:
copy.png

Region 还多了一个H,它代表 Humongous,这表示这些 Region 存储的是巨大对象(humongous object,H-obj),即大小大于等于 region 一半的对象,这样超大对象就直接分配到了老年代,防止了反复拷贝移动。那么 G1 分配成这样有啥好处呢?

传统的收集器如果发生 Full GC 是对整个堆进行全区域的垃圾收集,而分配成各个 Region 的话,方便 G1 跟踪各个 Region 里垃圾堆积的价值大小(回收所获得的空间大小及回收所需经验值),这样根据价值大小维护一个优先列表,根据允许的收集时间,优先收集回收价值最大的 Region,也就避免了整个老年代的回收,也就减少了 STW 造成的停顿时间。同时由于只收集部分 Region,也就做到了 STW 时间的可控。

G1的工作流程如下:
copy.png