3 垃圾收集器与内存分配策略

3.2 对象已死?

在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是确定这些对象之中还有哪些存活着。

3.2.1 引用计数算法

3.2.2 可达性分析

在Java计数体系里面,固定作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如当前正在运行的方法所使用到的参数,局部变量,临时变量。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量
  • 在方法区中常量引用的对象,譬如字符串常量池(String table)里的引用
  • 在本地方法栈中JNI引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象等,还有类加载器。
  • 所有被同步锁持有的对象
  • 反映Java虚拟机内部的JMXBean,JVMTI中注册的回调、本地代码缓存。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还有其他对象“临时性”加入,共同构成完整GC Roots集合,譬如后文将会提到的分代收集和局部回收(Partial GC),如果只是针对Java堆中某一块区域发起垃圾收集时(最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节,更不是鼓励封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中,才能保证可达性分析的正确性。

3.2.3 在谈引用

在JDK1.2版之前,Java里的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的其实地址,就称该reference数据是代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看来有些过于狭隘。

3.2.5 回收方法区

方法区垃圾收集的“性价比”通常是比较低的:在Java堆中,尤其是新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往低于此。
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串Java曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“Java”,换句话说,已经没有任何字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的Java。且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断有必要的话,这个Java常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。
判定一个常量是否“废弃”还是相对简单,而要判断一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件。

  • 该类所有的实例都已经被回收,也就是java堆中不存在该类以及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,否则通常很难达成。
  • 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被运行对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。

3.3 垃圾收集算法

3.3.1 分代收集理论

分代收集名为理论,实质是一套符合大多数程序运行的实际情况的经验法则,它建立在两个分代假说之上。

  • 弱分代假说:绝大多数对象都是朝生夕灭的。
  • 强分代假说:熬过越多垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把他们集中放在一起,每次回收时只关注如何保留少量存活而不失去标记那些大量将要被回收的对象,就能以较低代价回收到大量空间;如果剩下的都是难以消亡的对象,那把他们集中放在一块,虚拟机便可以使用较低评率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存空间有效利用。
在Java堆划分出不同的区域之后,垃圾收集才可以每次只回收其中某一个或某些部分的区域——因而才有了“Minor GC Major GC Full GC”这样的回收类型的划分;也才能够针对不同点区域安排与里面存储对象存放特征相匹配的垃圾收集算法。

把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆分成新生代(Yong Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集是都会发现有大批对象死去,而每次回收后存活的少了对象,将会晋升到老年代中存放。

假如要在现在进行一次局部新生代区域的内的手机(MinorGC),但新生代中的对象是完全有可能被老年代所引用的。为了找出该区域中的存活对象,不得不固定在GC Roots之外,在额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,返过来也一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个分代收集理论添加第三条经验法则。

  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在那些跨代引用,只需要在新生代上建立一个全局的数据结构(该结构被称为“记忆集”),这个结构把老年代划分成若干个小块,标识出来年的的哪一块内存会存在跨代引用,此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系是维护记录数据的正确性,会增加一些运行时的开销,但比起收集是扫描整个老年代来说仍然是划算的。

部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中由分为:

  • 新生代收集(Minor GC/Yong GC):指目标只是新生代的垃圾收集
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集,目前只有CMS收集器会有单独收集老年代的行为。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集,目前只有G1收集器有这种行为。
  • 整堆收集(Full GC): 收集整个Java堆和方法区的垃圾收集。

3.3.2 标记-清除算法

它是最基础的收集算法,它的主要缺点有两个:第一是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作。导致标记和清除两个过程的执行效率都随对象的数量增长而降低;第二是内存空间的碎片化问题。标记、清除之后产生大量不连续的内存碎片,空间碎片太多可能导致当以后在程序运行过程中需要分配较大对象是无法找到足够的连续内存不得不提前触发另一种垃圾收集动作。

3.3.3 标记-复制算法

简称为复制算法。为了解决标记-清除算法而大量可回收对象是执行效率低的问题。

3.3.4 标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有的对象都100%存活的计算情况。所以老年代一般都不直接选用这种算法。

针对老年代对象的存亡特征,提出了标记-整理算法。齐总标记过程仍然与“标记-清除”算法一样。但后续步骤不是直接可回收对象进行清理,而是让所有存活对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

3.4 HotSpot的算法细节实现

3.5 经典垃圾收集器

3.5.1 Serial收集器

这是最基础、历史最悠久的收集器,也是一个单线程工作的收集器。但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集器线程去完成垃圾收集工作,更重要的是强调它在进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。但这项工作是由虚拟机在后台自动发起和自动完成的。
迄今为止,它依然是HotSpot虚拟机在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的;对于单核处理器或处理器核数教少的环境来说,Serial收集器由于没有现存交互的开销,专心做垃圾收集自然可以获得醉倒的单线程收集系效率。在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大。,收集几十兆甚至一两百兆的新生代,垃圾收集的停顿时间可以完全控制在十几、几十毫秒,做多一百多毫米以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

3.5.2 ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有参数控制,收集算法,Stop the World、对象分配规则、回收策略等都与Serial收集器完全一致。
ParNew收集器除了支持多线程并行收集之外。其他与Serial收集器相比并没有太多创新之处。但它却是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK7之前的遗留系统中首选的新生代后机器,其中有一个功能、性能无关但其实很重要的原因是:除了Serial收集器外,目前只有它能与CMS收集器配合工作。
在JDK5发布时,HotSpot退出了一款在强交互应用中几乎可称为具有划时代意义的垃圾收集器——CMS收集器。这款收集器是HotSpot虚拟机中第一款真正意义上支持并发的垃圾收集器,它首先实现了让垃圾收集线程与用户线程(基本上)同时工作。
遗憾的是,CMS作为老年代的收集器,却无法与JDK1.4.0中已经存在的新生代收集器ParallelScavenge配合工作,所以在JDK5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweepGC选项)的默认新生代收集器。
可以说直到CMS的出现才巩固了ParNew的地位。随着垃圾收集器技术的不断改进,更先进的G1收集器带着CMS的继承者和替代者的光环登场。G1是一个面向全堆的收集器,不再需要其他心神带收集器的配合工作。所以自JDK9开始,ParNew加CMS收集器的组合就不再是官方推荐的服务器模式下的收集器解决方案了。官方希望它能完全被G1所取代。甚至还取消了ParNew加Serial Old以及Serial加CMS这两种收集器组合的支持。并直接取消了-XX:+UseParNewGC参数,这意味着ParNew和CMS从此只能互相搭配使用,再也没有其他收集器能够和他们配合了。可以理解为从此以后,ParNew合并加入CMS,成为专门处理新生代的组成部分。
ParNew收集器在单核心处理器的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器通过超线程技术实现的伪双核处理器环境中都不能百分百保证超越Serial收集器。当然,随着可以被使用的处理器核心数量的增加,ParNew对于垃圾收集时系统资源的高效利用还是很有好处的。它默认开启的手机线程数与处理器核心数量相同,在处理器核心非常多。可与使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

  • 并行(Parallel):并行描述的是多条垃圾收集线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程处于等待状态。
  • 并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

3.5.3 Parallel Scavenge收集器

此收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器。也是能够并行收集的多线程收集器。Parallel Scavenge的诸多特性从表面上看和ParaNew非常相似。
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器关注点是尽可能地缩短垃圾收集是用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。

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

由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。除上述两个参数之外,Parallel Scanvenge收集器海鸥故意而参数-XX:+UseAdaptiveSizePolicy值得我们关注。这是一个开关参数。当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn) Eden和Survivor区的比例(-XX:SurvivorRatio),晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提升最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略。
使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成也许是一个很不错的选择。只需要把基本的内存数据设置好(比如最大堆-Xmx)。然后使用-XX:MaxGCPauseMills参数(更关注最大停顿时间)或-XX:GCTimeRatio (更关注吞吐量)参数给虚拟机设立一个优化目标。自适应调节策略也是Parallel Scavenge收集器区别与ParNew收集器的一个重要特性。

3.5.4 Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模型下,它可能有两种用途:一种是在JDK5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS收集器发生失败时作为后备预案,在并发收集发生Concurrent Mode Failure时使用。

3.5.5 Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器知道JDK6时才开始提供的,再次之前,新生代的Parallel Scavenge收集器一直处于相对尴尬的状态,原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作。由于老年代Serial Old收集器在服务器端应用性能上的“拖累”,使用Parallel Scavenge收集器也未必能整体上获得吞吐量最大化的效果同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的吞吐量甚至不一定比ParNew加CMS的组合来得优秀。
直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。

3.5.6 CMS收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器,目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务器的响应速度,希望停顿时间尽可能短,已给用户带来良好的交互体验。CMS收集器就非常符合这类应用需求。

CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说更要复杂一些,整个过程分为四个步骤

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清理

首先,CMS收集器对处理资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低吞吐量。CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说处理器核心数在4个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数不足4个是,CMS对用户程序的影响就可能变得很大。如果本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低,为了缓解这种情况,虚拟机提供了一种称为“增量式并发收集器”的CMS收集器变种,所做的事情和以前单核处理器年代PC机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样,是在并发标记,清理的时候让收集器线程,用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变慢的时间更多了,但是速度下降幅度就没有那么明显。实践证明增量式的CMS收集器效果很一般,从JDK7开始,i-CMS 已经被声明过时,到JDK9发布后i-CMS模式被完全废弃。
由于CMS收集器无法处理“浮动垃圾”有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就会伴随所有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程接受以后,CMS无法当次收集中处理掉他们,只要留待下一次垃圾收集时在清理掉。这一部分垃圾就称为“浮动垃圾”。同样也是由于再垃圾收集阶段用户线程还需要持续运行,那就还需要预留给足够内存空间提供给用户实线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全别填满了在进行收集,必须预留一部分空间供并发收集是的程序运作使用。在JDK5的默认设置下,CMS收集器当老年代使用了68%的空间后就会别激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数来提高CMS的触发百分比,降低内存回收频率,获取更好地性能,到了JDK6时,CMS收集器的启动阀值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留内存无法满足程序分配新对象的需求,又会出现一次“并发失败”,这时虚拟机将不得不启动后备预案:冻结用户线程执行,临时启动Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就更长了。

还有最后一个缺点,在本节开头提到,CMS是基于“标记-清除”算法是吸纳的收集器,这意味着收集结束是会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMS-CompactAtFullCollection开关参数(默认是开启的,此参数从JDK9开始废弃)。

3.5.7 Garbage First收集器

简称G1收集器。

3.6 低延迟垃圾收集器

衡量垃圾收集器的三项最重要的指标是:内存占用、吞吐量和延迟。

3.7 选择最合适的垃圾收集器

3.8 实战:内存分配与回收策略

对象的内存分配,从概念上讲,应该是在堆上分配(实际上也有可能经过即时编译后被拆散为标量类并间接地在栈上分配)。在经典分代的设计下,新生对象通常会分配在新生代中,少数情况下(例如对象大小超过一定阀值)也可能会直接分配在老年代。对象分配的规则并不是固定的,《Java虚拟机规范》并未规定新对象的创建和存储细节,这取决于虚拟机当前使用的是哪种垃圾收集器,以及虚拟机中与内存相关的参数的设定。

3.8.1 对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
HotSpot虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机发生垃圾收集行为是打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。
在 testAllocation代码中,尝试分配三个2MB大小和一个4MB大小的对象,在运行时通过-Xms20M 、-Xmx20M -Xmn10M 这三个参数限制了Java堆大小为20MB,不可扩展,其中10MB分配给了新生代,剩下的10MB分配给老年代,-XX:Survivor-Ratio=8决定了新生代总Eden区与一个Survivor区的空间比例是8:1

  1. private static final int _1MB = 1024 * 1024;
  2. /**
  3. * VM参数:-XX:+UseSerialGC -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
  4. */
  5. public static void testAllocation() {
  6. byte[] allocation1, allocation2, allocation3, allocation4;
  7. allocation1 = new byte[2 * _1MB];
  8. allocation2 = new byte[2 * _1MB];
  9. allocation3 = new byte[2 * _1MB];
  10. allocation4 = new byte[4 * _1MB]; // 出现一次Minor GC
  11. }

3.8.2 大对象直接进入老年代

大对象就是需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。
HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的是避免在Eden区和两个Survivor区之间来回复制,产生大量的内存复制操作。

  1. /**
  2. * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728
  3. *
  4. */
  5. public static void testPretenureSizeThreshold() {
  6. byte[] allocation;
  7. allocation = new byte[4 * _1MB]; //直接分配在老年代中
  8. }

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

HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存,