0%

深入理解JVM之垃圾回收算法

判断对象是否死亡的方法

引用计数法

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

很难解决对象之间相互循环引用的问题。

可达性分析算法

通过一系列叫做“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明对象是不可用的。

在Java语言中,可作为GC Roots的对象包括下面几种:

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

引用

引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次减弱。

强引用

强引用就是指在程序中普遍存在的,类似Object obj = new Object();这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

软引用

软引用是用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后 使用SoftReference类来实现软引用。

弱引用

弱引用也是用来描述非必须对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2之后 使用WeakReference类来实现弱引用。

虚引用

虚引用也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被垃圾收集器回收时受到一个系统通知。在JDK 1.2之后 使用PhantomReference类来实现虚引用。

垃圾收集算法

标记-清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要清除的对象,在标记完成后统一回收所有被标记的对象。

复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另一块上面,然后再把已使用的内存空间一次清理掉。

标记-整理算法

标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都像一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

将对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,根据各个年代的特点采用最适用的收集算法。

在新生代中每次垃圾收集时都有大量对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代因为对象存活率高、没有额外空间对它进行分配的担保,就必须使用标记-清理或者标记-整理算法来进行回收。

垃圾收集器

HotSpot虚拟机的垃圾收集器

Serial收集器

Serial收集器是单线程收集器。它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。

Serial收集器是使用复制垃圾回收算法的收集器。

Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择。

ParNew收集器

ParNew 收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World 、对象分配规则、回收策略等都与Serial完全一样。

什么是并发什么是并行

这两个名词都是并发编程中的概念,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

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

Parallel Scavenge收集器

Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。

CMS等收集器关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量(Throughput)。

所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花费1分钟,那吞吐量就是99%。

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRadio参数。

Serial Old收集器

Serial Old是Serial收集器老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义在于给Client模式下的虚拟机使用。

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短。CMS收集器就非常符合这类应用的需求。

CMS收集器使用标记-整理算法实现的。它的运作过程包括以下四个步骤:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

其中,初始标记、重新标记这两个步骤仍然需要”Stop The World”。

初始标记仅仅只是标记一下GC Roots能直接关联的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因为用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。

CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿。但是它也有以下4个明显缺点:

  • 对CPU资源非常敏感
  • 无法处理浮动垃圾,可能出现Concurrent Mode Failure失败而导致另一次Full GC的产生。
  • 基于标记-清除算法实现,有可能收集结束时有大量空间碎片产生。

G1收集器

G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一。G1具有如下特点:

  • 并行与并发:G1能充分利用多CPU,来缩短Stop The World停顿时间,仍然可以通过并发的方式让Java程序继续运行。
  • 分代收集:它能采用不同的方式处理新创建的对象和已经存活一段时间的对象、熬过多次GC的旧对象以获取更好的收集效果。
  • 空间整合:G1从整体来看是基于标记-整理算法,从局部(两个Region)上来看是基于复制算法实现的。这两种算法都不会产生空间碎片,收集后能提供规整的可用内存。
  • 可预测的停顿:除了追求低停顿外,还能建立可预测的停顿时间模型。能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N秒。

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage First的由来)。

在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set既可保证不对全堆扫描也不会有遗漏。

如果不计维护Remembered Set的操作,G1收集器的运作大概分为以下几步:

  • 初始标记(Initial Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Marking)
  • 筛选回收(Live Data Counting and Evacuation)

内存分配与回收策略

对象优先在Eden分配

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

虚拟机提供了-XX:PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。

大对象直接进入老年代

所谓大对象是指,需要大量连续内存空间的Java对象,最典型的那种大对象就是那种很长的字符串以及数组。经常出现大对象容易导致内存还有不少空间时,就提前触发垃圾收集以获取足够的连续空间。

虚拟机提供一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配(不是所有虚拟机都支持该参数)。这样做的目的是避免在Eden区以及两个Suvivor区之间发生大量的内存复制。

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

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生,并且经历过一次Minor GC后仍存活,并且能被Suvivor容纳的话,将被移动到Suvivor空间中,并且对象年龄设为1。对象在Suvivor区中没熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。

对象晋升到老年代的阈值,可以通过参数-XX:MaxTenuringThreshold设置。

动态对象年龄判定

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

空间分配担保

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次 Full GC

Minor GC和Full GC有什么不一样?

Minor GC

新生代GC (Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC会非常频繁,一般回收速度也比较快。

Full GC

老年代GC (Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(非绝对)。Major GC的速度一般会比Minor GC慢10倍以上。

iisheng wechat
微信扫码关注 Coder阿胜