深入理解Java虚拟机(3)垃圾回收

本文主要解决3个问题:

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

一、哪些内存需要回收?

程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭,而且每一个栈帧中分配多少内存基本在类结构确定下来时就是已知的,不需要考虑复杂的回收问题。线程结束,内存就直接回收了。
Java堆和方法区则只有处于运行时才会知道存放哪些实例数据等。

  • Java堆回收类实例
  • 方法区主要回收废弃常量和无用的类

对象回收判定算法

可达性分析算法
通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时(用图论的话来说就是从GC Roots到这个对象不可达),则证明此对象是不可用的,其将会被判定为可回收对象。

GC Roots:
  • 虚拟机栈
  • 方法区静态属性引用的对象
  • 方法区常量引用的对象
  • 本地方法栈中JNI引用的对象
Java引用类
  • 强引用就是在程序代码中普遍存在的,类似Object obj=new Object()这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用但并非必须的元素。对于它在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二回收,如果这次回收还没有足够的内存才会抛出内存溢出异常。
  • 弱引用是用来描述非必须对象的,但是它的强度比软引用更弱一些,被引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器工作时,无论当前内存是否足够都会回收掉只被弱引用关联的对象
  • 虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知

二、什么时候回收?

对象的垃圾回收:

宣告一个对象死亡,至少要经历两次标记过程:
若第一次通过可达性算法被判定为可回收,那么他将会被第一次标记且进行一次筛选,筛选条件是该对象是否有必要执行finalize()方法,若对象未覆写finalize()方法,或finalize()方法已被虚拟机调用过,则没有必要执行
若有必要执行:对象放置到一个队列,并稍后由一个虚拟机自动建立的,低优先级的Finalize线程去执行
finalize()是对象逃脱死亡命运的最后一次机会,之后GC会对该队列中的对象再次进行标记,若还是没有可达引用链,则他会被真的回收。

  • 任何一个对象的finalize()方法只会被系统调用一次

方法区的垃圾回收:

废弃的常量:当前没有实例的属性赋值为该常量
无用的类:3个条件都要满足

  • Java堆中不含该类的实例
  • 加载该类的ClassLoader已被回收
  • 该类对应的java.lang.Class对象没有被引用,包括反射

三、如何回收?垃圾回收算法

Mark-Sweep算法

首先标记出所有需要回收的对象,在标记完成后统一回收被标记的对象

  1. 效率问题:标记和清除两个过程的效率都不高
  2. 空间问题:会产生大量不连续的内存碎片

copying算法

将可用内存按容量大小分为大小相等的两块,当这一块的内存用完了,就将存活的对象复制到另一块,然后清除已使用过的内存空间,每次对整个半区回收

  • 效率更高,无内存碎片
  • 对象存活率较高是,有较多的复制操作,效率变低
  • 不足是内存自动变为了之前的一半,代价较高

解决办法: 不按照1:1比例划分内存,几乎98%的对象都是 ‘朝生夕死’,HotSpot默认按照8:1,将内存划分为1块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor,只有10%内存被“浪费”,当另一个Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

mark-compact算法

标记回收对象之后,让所有存活的对象都像一端移动,然后直接清理边界以外的内存。

分代收集算法(各算法结合)

将Java堆分为新生代,老年代,根据各个年代的特点采用适合的算法。
新生代:copying算法
老年代:mark-sweep算法或者mark-compact

HotSpot的算法实现

1,枚举根节点
VM 的准确式内存管理
虚拟机有能力知道内存中某个位置数据具体是引用类型还是其他类型,这样在GC的时候能够准确的判断堆上的数据是否还可能被使用。
HotSpot实现中,通过使用一组OopMap的数据结构来实现该特性。在类加载完成之后,HotSpot就把对象内各个偏移量上数据类型是什么都计算出来,这样GC扫描时也知道这些信息,直接去顺着引用类型的数据扫描就行了,快速准确
2,安全点
仅在安全点中断,GC设置中断标志,各线程主动去轮询标志,从而触发线程中断,开始执行GC
3, 安全区域
在一段代码片段如sleep,blocked状态中,内存引用不会发生变化,在这片区域的任何位置GC都是安全的。即连续的安全点。
线程执行到安全区域的代码之后,标识自己进入安全区域,这样,JVM发起GC时,就不用管该线程了。

四、HotSpot中的垃圾收集器

JVM收集器
上面为新生代收集器,下面是老年代收集器。如果两个收集器之间存在连线,就说明它们可以搭配使用。

并发和并行
  先解释下什么是垃圾收集器的上下文语境中的并行和并发:

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

Serial收集器(串行GC)

   是Jvm client模式下默认的新生代收集器 jdk1.3
   这个收集器是一个单线程的收集器,使用Copying算法。它在进行垃圾收集时,它不仅只会使用一个CPU或者一条收集线程去完成垃圾收集作,而且必须暂停其他所有的工作线程(用户线程),直到它收集完成。

ParNew收集器(并行GC)

   是运行在Service模式下虚拟机中首选的新生代收集器
   Serial收集器的多线程版本,除了使用多线程进行收集以外,其余行为和Serial收集器一样。
   PreNew收集器在单CPU环境中绝对没有Serial的效果好,由于存在线程交互的开销。
   可通过-XX:parallelGCThreads参数来限制收集器线程数

Parallel Scanvenge(并行回收GC)

   新生代收集器,它是使用复制算法的收集器,又是并行的多线程收集器。
   parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。
   吞吐量:就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)

Serial old收集器(串行GC)

   是Serial收集器的老年代版本,是一个单线程收集器,使用Mark-Compact算法。

Parallel Old收集器(并发GC)

   是Parallel Scavenge收集器的老年代版本,使用多线程和Mark-Compact算法。

CMS收集器(Concurrent Mark Sweep并发GC)

以获取最短回收停顿时间为目标的收集器,JDK1.5发布

   CMS收集器是基于标记清除算法实现的,整个过程分为4个步骤:
   ①.初始标记(CMS initial mark)
②.并发标记(CMS concurrent mark)
③.重新标记(CMS remark)
④.并发清除(CMS concurrent sweep)
优点:并发收集、低停顿
缺点:
1, CMS收集器对CPU资源非常敏感,在并发(并发标记、并发清除)阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致应用程序变慢,总吞吐量下降,此时始终不会占用少于25%的CPU。CMS默认启动的回收线程是(CPU数量+3)/4;
2, CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure“,失败后而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自热会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理它们,只好留待下一次GC时将其清理掉。这一部分垃圾称为“浮动垃圾”。
3, CMS是基于标记清除算法实现的,会产生大量碎片。

G1收集器:

  它是一款面向服务器应用的垃圾收集器,JDK1.7发布,其目标就是替换掉JDK1.5发布的CMS收集器
  优点:
  1.并发与并行:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或CPU核心)来缩短停顿(Stop The World)时间。
  2.分代收集:G1不需要与其他收集器配合就能独立管理整个GC堆,但他能够采用不同方式去处理新建对象和已经存活了一段时间、熬过多次GC的老年代对象以获取更好收集效果。
  3.空间整合:从整体来看是基于“标记-整理”算法实现,从局部(两个Region之间)来看是基于“复制”算法实现的,但是都意味着G1运行期间不会产生内存碎片空间,更健康,遇到大对象时,不会因为没有连续空间而进行下一次GC,甚至一次Full GC。
  4.可预测的停顿:降低停顿是G1和CMS共同关注点,但G1除了追求低停顿,还能建立可预测的停顿模型,可以明确地指定在一个长度为M的时间片内,消耗在垃圾收集的时间不超过N毫秒
  5.跨代特性:之前的收集器进行收集的范围都是整个新生代或老年代,而G1扩展到整个Java堆(包括新生代,老年代)。
如何实现:
1.如何实现新生代和老年代全范围收集:其实它的Java堆布局就不同于其余收集器,它将整个Java堆划分为多个大小相等的独立区域(Region),仍然保留新生代和老年代的概念,可是不是物理隔离的,都是一部分Region(不需要连续)的集合。
2.如何建立可预测的停顿时间模型:是因为有了独立区域Region的存在,就避免在Java堆中进行全区域的垃圾收集,G1跟踪各个Region里面的垃圾堆积的价值大小(回收可以获得的空间大小和回收所需要的时间的经验值),后台维护一个优先队列,根据每次允许的收集时间,优先回收价值最大的RegionGarbage-First理念)。因此使用Region划分内存空间以及有优先级的区域回收方式,保证了有限时间获得尽可能高的收集效率。
3.如何保证垃圾回收真的在Region区域进行而不会扩散到全局:由于Region并不是孤立的,一个Region的对象可以被整个Java堆的任意其余Region的对象所引用,在做可达性判定确定对象是否存活时,仍然会关联到Java堆的任意对象,G1中这种情况特别明显。而以前在别的分代收集里面,新生代规模要比老年代小许多,新生代收集也频繁得多,也会涉及到扫描新生代时也会扫描老年代的情况,相反亦然。解决:G1收集器Region之间的对象引用以及新生代和老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(分代的例子中就检查是否老年代对象引用了新生代的对象),如果是则通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中,当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可避免全堆扫描。

  运行步骤:
1.初始标记:初始标记仅仅标记GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新的对象。这阶段需要停顿线程,不可并行执行,但是时间很短。
2.并发标记:此阶段是从GC Roots开始对堆中对象进行可达性分析,找出存活对象,此阶段时间较长可与用户程序并发执行。
3.最终标记:此阶段是为了修正在并发标记期间因为用户线程继续运行而导致标记产生变动的那一份标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这段时间需要停顿线程,但是可并行执行。
4.筛选回收:对各个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间来制定回收计划。

猜你喜欢

转载自blog.csdn.net/iamcodingmylife/article/details/79890218