深入理解JVM虚拟机:(二)垃圾收集器概述

前言

在上一篇深入理解JVM虚拟机:(一)Java运行时数据区域 文章中,我们了解到了Java运行时的数据区域,这一篇,我们将会去了解一下Java的垃圾回收机制。

概述

说起垃圾收集器,大部分人都把这项技术当做Java语言的伴生产物。事实上,GC的历史比Java更加悠久,Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言,一直以来,人们就在思考GC需要完成的3件事情:

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

接下来我们就这三个问题进行展开,去了解一下Java的垃圾回收机制。

哪些内存需要回收?

在堆中存放着Java世界中几乎所有的对象实例,垃圾回收器在对堆进行回收之前,第一件事情就是要确定这些对象中哪些还“存活”着,哪些已经“死去”,即不可能再被任何途径使用的对象,这些对象占用的内存就是我们需要进行回收的内存。
接下来来看一下Java虚拟机是如何判断一个对象是否需要回收。

引用计数法

很多教科书判断对象是否存活的算法是这样的:给对象添加一个引用计数器,每当有一个地方引用它,计数器数值就加1;当引用失效时,计数器数值就减1;任何时刻计数器为0的对象就是不可能再被使用的。客观地说,引用计数法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,但是至少主流Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间循环引用的问题。
举一个简单的例子,一个类中有一个成员变量对象,创建两个该类的实例对象,让两个对象的成员变量互相引用,即objA.instance=objB.instance,除此之外,这两个对象再无其他的地方引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为0,于是引用计数算法无法通知GC收集器去回收它们。
但是实际上,JVM并不是通过引用技术算法来判断对象是否存活的。

可达性分析算法

在主流的商业程序语言的主流实现中,都是称通过可达性分析来判定对象是否存活的。这个算法的基本思路就是通过一系列的称之为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,如下图所示,对象object5、object6、object7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可以回收的对象。
image

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

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

关于引用

Java对引用的概念进行了扩充,将引用划分为强引用、软引用、弱引用、虚引用这4种,这4种引用的强度依次减弱。

  • 强引用就是指在程序代码中普遍存在的,类似“Object object = new Object();”这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围中进行第二次的回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用。
  • 弱引用是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾回收之前。当垃圾回收开始时,无论当前内存是否足够,都会回收掉弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。
  • 虚引用也称为幽灵引用或幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可行性分析后发现没有与GC Roots相连接,那么它会被第一次标记且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法。当对象没有覆盖finalize方法,或者finalize方法已经被虚拟机调用过,虚拟机将这两种情况视为“没有必要执行”。

被判定有必要执行finalize方法的对象将被放置与F-Queue的队列中。并在稍后由一个虚拟机自动建立的、低优先级的finalize线程去执行它,这个执行并不会等待其运行结束,防止阻塞和崩溃。finalize方法是对象逃过死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize方法中拯救自己——只要重新与引用链上的任何一个对象建立关联即可。但是一个对象的finalize方法只能被执行一次。

回收方法区

方法区一般不回收,回收效率很低。在堆中,新生代的垃圾收集效率70-90%,而永生代的垃圾回收效率远低于此。

扫描二维码关注公众号,回复: 2583263 查看本文章

永生代的垃圾回收主要回收两部分内容:废弃常量和无用的类。“废弃常量”判断比较简单,但是“无用的类”的判断复杂一些,需要满足下面3个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
  • 加载该类的ClassLoader已经被回收
  • 该类对应的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading, -XX:+TraceClassUnLoading查看类的加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。

在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证永久带不能溢出。

垃圾回收算法

标记-清除算法

算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记过程就是使用可达性算法进行标记的。

主要缺点有两个:

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

image

复制算法

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

内存分配时不用考虑内存碎片的问题,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。代价就是将内存缩小到原来的一半。

image

标记-整理算法

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

image

分代收集算法

根据对象存活周期的不同将内存分为几块。一般把Java堆分为新生代和老生代,根据各个年代的特点采用最合适的收集算法。在新生代中,每次垃圾收集时有大批的对象死去,只有少量的存活,可以选用复制算法。而老生代对象存活率最高,使用标记清除或标记整理算法。

HopSpot的算法实现

枚举根节点

GC进行时必须停顿所有的Java执行线程。即使是在号称不会停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到找到引用对象这个目的的。

安全点

实际上,HotSpot没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置称为安全点,即程序执行时并非在所有地方都能停顿下来开始GC,只有到达安全点时才可以暂停。SafePoint的选定既不能太少以至于让GC等待时间过长,也不能过于频繁以至于过分增大运行时的负载。所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准选定的——因为每条指令执行的时间都非常的短暂,程序不太可能因为流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生SafePoint。

由于GC时,需要所有线程在安全点中断,一种是抢占式中断;另一种是主动式中断,其中抢占式中断就是在GC发生的时候,首先把所有线程全部中断,如果发现有线程不在安全点,就恢复线程,让它跑到安全点上。现在几乎没有JVM采用这种方式来响应GC事件。而主动式中断的思想不是直接对线程操作,仅仅是简单设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的。

安全区域

有了安全点之后,也不能完美地解决GC的问题,但实际情况却不一定。当程序没有分配cpu时间,典型的例子就是线程处于sleep或者blocked状态,这个时候线程无法响应JVM的中断请求,“走”到安全点挂起。对于这种情况,就需要安全区域来解决。

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

关于内存回收,如何进行是由JVM所采用的GC收集器来决定的。而通常JVM中往往不止有一种GC收集器。接下来来一起看一下HotSpot虚拟机的GC收集器。

垃圾收集器

image

Serial Collector

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

新生代:单线程复制收集算法;老年代:单线程标记整理算法。

Serial一般在单核的机器上使用,是Java5非服务端JVM的默认收集器,参数-XX:UseSerialGC设置使用。

Parallel Scavenge收集器

一个新生代收集器,使用复制算法的收集器,又是并行的多线程收集器。目标是达到一个可控制的吞吐量。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是单线程的,使用“标记-整理”算法。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK1.6中才开始提供的。

CMS收集器

也称”low-latency collector”,为了解决老年代暂停时间过长的问题,并且真正实现并行收集(程序与GC并行执行)。是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于“标记-清除”算法来实现的。

新生代:收集和Parallel Collector新生代收集方式一致。

老年代:GC与程序同时进行。

分为四个阶段:

  • 1、初始标记:暂停一会,找出所有活着对象的初始集合。
  • 2、并行标记:根据初始集合,标记出所有的存活对象,由于程序在运行,一部分存活对象无法标记出。此过程标记操作和程序同事执行。
  • 3、重新标记:程序暂停一会,多线程进行重新标记所有在2中没有标记的存活对象。
  • 4、并行清理:回收所有被标记的垃圾区域。和程序同时进行。

image

由于此收集器在remark阶段重新访问对象,因此开销有所增加。

此收集器的不足是,老年代收集采用标记清除算法,因此会产生很多不连续的内存碎片。

此收集器一般多用于对程序暂停时间要求更短的程序中,多用于web应用(实时性要求很高)。可以通过参数-XX:+UseConcMarkSweepGC设置使用它。

G1收集器

G1收集器是当今收集技术发展的最前沿成果之一。G1是一款面向服务端应用的垃圾收集器。HotSpot开发团队赋予它的使命是在未来替换CMS收集器。

它具有以下几个特点:

  • 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势
  • 分代收集
  • 空间整合:基于“标记-整理”算法实现的收集器
  • 可预测的停顿:这是G1相对于CMS的另一大优势

内存分配与回收策略

对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。在实际应用中,内存回收日志一般是打印到文件后通过日志工具进行分析。

Minor GC与Full GC有什么不一样吗?
  • 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多数都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快。
  • 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC速度一般会比Minor GC慢10倍以上。

大对象直接进入老年代

所谓的大对象是指,需要大量的连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说就是一个坏消息(替Java虚拟机抱怨一句,比遇见一个大对象更加坏的消息就是遇到一群“朝生夕死”的短命大对象,写程序时应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来放置它们。

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

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

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

动态对象年龄判定

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

空间分配担保

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

下面解释一下“冒险”是冒了什么险,前面提到过,新生代使用复制收集算法,但为了内存使用率,只使用其中一个Surivor空间来作为轮换备份,因此出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收新生代中所有对象都存活),就需要老年代进行分配担保,把Survior无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代如果要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多的空间。

取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败。如果出现了担保失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但是大部分的情况下还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。

更多Java干货文章请关注我的个人微信公众号:老宣与你聊Java

这里写图片描述

猜你喜欢

转载自blog.csdn.net/wtopps/article/details/80462209