3 垃圾收集器与内存分配策略
3.1 概述
- p61:1960年诞生于MIT的Lisp语言是第一门真正使用内存动态分配与垃圾收集技术的语言。
- p61:程序计数器,虚拟机栈,本地方法栈跟随线程而生死具有确定性,所以回收不需要考虑这三个区域,而需要考虑java堆和方法区两部分。
3.2 对象已死嘛
3.2.1 引用计数法(Reference Counting)
主流的java虚拟机中没有选用引用计数法来管理内存,主要原因是他很难解决对象间循环引用的问题。
3.2.2 可达性分析(Reachability Analysis)算法
主流商用语言(java,C#,Lisp等)的主流实现中都是通过可达性分析来判断对象是否存活的。
这个算法的基本思想就是通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链
当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,即认为是可以回收的。
可作为GC Roots的对象:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的变量。
- 方法区中常量引用的对象。
- 本地方法栈中JNI引用的对象。
3.2.3 再谈引用
JDK1.2之后将引用分为强引用,软引用,弱引用,虚引用4种。
3.2.4 生存还是死亡
对象死亡需要经历两次标记的过程。如果对象在可达性分析后发现没有与GC Roots相连接的引用链,那他会第一次被标记,并进行一次筛选,筛选条件是对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法或者finalize()方法已经被虚拟机调用过,则视为没有必要执行。如果对象呗判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫F-Queue的队列之中,并在稍后由一个虚拟机自动建立的,低优先级的Finalizer线程去执行(只是触发执行,不承诺等待执行结束)。finalize()方法是对象逃脱死亡的最后一次机会。稍后GC将会对F-Queue中的对象进行第二次小规模的标记,如果对象此时与任何引用链上的对象建立联系即可在第二次标记时移出“即将回收”的集合。如果对象这时候还没有逃脱那么就会被回收。
3.2.5 回收方法区
方法区(HotSpot中的永久代)垃圾收集主要回收两部分:废弃常量和无用的类。
判定无用的类的三个条件:
- 该类所有的实例都已经被回收,也就是JAVA堆中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。虚拟机可以对满足上述3个条件的无用类进行回收。
虚拟机可以对满足上述3个条件的无用类进行回收。
3.3 垃圾收集算法
3.3.1 标记-清除算法(Mark-Sweep)
标记-清除算法:(最基础的收集算法)分为标记和清除两个阶段。
缺点:1.效率问题,标记与清除效率都不高。2.空间问题,产生大量不连续的内存碎片。
3.3.2 复制算法(Copying)
将内存分为大小相等的两块,每次只用其中的一块,当这一块内存用完了,就将还活着的对象复制到另一块上面,然后把已经使用过的内存空间清理掉。实现简单,运行高效,代价是将内存空间缩小到原来的一半。
现代的商业虚拟机都采用这种算法来回收新生代,IBM公司的专门研究表明,新生代的对象98%是“朝生夕死”的,所以不需要按1:1的比例划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才使用过的Survivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的空间会被浪费掉。
我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion)。
缺点:存活率较高的时候需要较多的复制操作,效率会变低。如果不想浪费50%的空间就需要额外的空间进行分配担保,以应对极端情况。
3.3.3 标记-整理算法(Mark-Compact)
标记过程与标记-清除算法一样,之后让所有存活着的对象都向一端移动,然后直接清理掉端边界以外的内存。
3.3.4 分代收集算法(Generational Collection)
当前商业虚拟机都采用分代收集算法。一般将java堆分为新生代和老年代,根据各个年代的特点选用适当的收集算法,如新生代选用复制算法,老年代选用标记-清理算法或者标记-整理算法。
3.4 HotSpot的算法实现
3.4.1 枚举根节点
可作为GC Roots的节点主要在全局性的引用(例如常量或者类静态属性)与执行上下文(例如栈帧中的本地变量表)中
GC进行时必须停顿所有java执行线程(Sun将这件事情称为"Stop The Word"),即使是在号称不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
目前主流的Java虚拟机中使用的都是准确式GC,所以系统停下来之后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置。
在HotSpot虚拟机中,使用一组称为OopMap的数据结构来获知哪些地方存放着对象引用。在类加载完的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样GC扫描的时候就可以得知这些信息了。
3.4.2 安全点
在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举。同时HoSpot只会在特定位置即--安全点(Safepoint)停顿下来生成OopMap记录这些信息。
在GC时如何让所有线程(不包括执行JNI调用的线程)都跑到最近的安全点上再停顿下来,这里有两种方案:抢先式中断和主动式中断。
- 抢先式中断:在GC时,首先把所有的线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让他跑到安全点上。(现在几乎没有虚拟机采用抢先式中断来暂停线程从而响应GC事件)
- 主动式中断:不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现标志为真时就自己中断挂起。轮询标志的位置和安全点是重合的,另外再加上创建对象需要分配内存的地方。
3.4.3 安全区域(Safe Region)
安全区域用于解决线程没有被分配CPU时间的时候(如Sleep或者Blocked状态)无法响应JVM的中断请求,走到安全的地方去中断挂起的问题。
安全区域是指在一段代码中,引用关系不会发生变化,在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看作是被拓展了的Safepoint。
在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样当这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则他就必须等待直到收到可以安全离开Safe Region的信号为止。
3.5垃圾收集器
这里讨论的收集器基于JDK 1.7 Update 14之后的HotSpot虚拟机(在这个版本中正式提供了商用的G1收集器),包含的所有收集器如图:
Our Collectors
上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明他们可以搭配使用。
3.5.1 Serial收集器
最基本发展历史最悠久的收集器。在进行垃圾收集的时候,必须暂停其他所有的工作线程,直到收集结束。在jdk1.7中它仍然是虚拟机在client模式下的新生代默认收集器。与其他收集器的单线程比他简单而高效,收集一两百兆的新生代可以控制在100多毫秒内。
新生代采取复制算法暂停永华所有线程
3.5.2 ParNew收集器
Serial收集器的多线程版本,除了多线程收集之外两者非常相像。ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器。一个与性能无关的重要原因是,他是除了Serial收集器外目前唯一能与CMS收集器配合工作的收集器。
新生代采取复制算法暂停永华所有线程
3.5.3 Parallel Scavenge收集器
使用复制算法的多线程新生代收集器。关注点是达到一个可控制的吞吐量(Throughput)。常被称为“吞吐量优先“收集器
吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值
吞吐量 = 运行用户代码时间/(运行用户代码的时间+垃圾收集的时间)
可以设置GC自适应的调节策略(GC Ergonomics),这也是与ParNew收集器的一个重要区别。
3.5.4 Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是给Client模式下的虚拟机使用。
3.5.5 Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。在JDK1.6中开始提供。在注重吞吐量以及CPU资源敏感的场合,可以考虑Parallel Scavenge加Oarallel Old收集器。
3.5.6 CMS收集器(Concurrent Mark Sweep)
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。基于“标记-清除”算法。
运作过程分为4个步骤:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
其中,初始标记,重新标记这两个步骤仍然是需要"Stop The Work".初始标记只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因为用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
- 优点:并发收集,低停顿(SUN的一些文档中也称为低停顿收集器(Concurrent Low Pause Collector))
- 缺点:1. 对CPU资源非常敏感。在并发阶段由于占用了一部分CPU资源导致应用变慢,总吞吐量降低。2.无法处理浮动垃圾(Floating Garbage),可能出现"Concurrent Mode Failure"失败而导致另一次Full GC的产生。浮动垃圾就是在CMS并发清理过程中用户线程产生的垃圾,CMS在当次中无法处理他们,只能留待下次GC时再清掉。同时CMS需要预留一部分空间提供并发收集时的程序运作使用,所以需要设置一个启动阈值,JDK1.5默认是68%,JDK1.6默认是92%,要是CMS运行期间预留的内存无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败,这时虚拟机就会启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集。3.由于采用“标记-清除”算法,所以在收集结束后会有大量的空间碎片产生,难以给大对象分配内存。有多个参数可以对此进行优化设置。
3.5.7 G1收集器
G1是一款面向服务端应用的垃圾收集器。G1具备如下特点:
- 并行与并发:充分利用多CPU,多核环境下的硬件优势,使用多个CPU来缩短Stop-The-Word停顿的时间。
- 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新对象与旧对象。
- 空间整合:G1从整体上来看是采用“标记-整理”算法实现的,从局部(两个Region之间)来看是基于“复制”算法实现的。这两种算法都意味着G1运作期间不会产生内存空间碎片。
- 可预测的停顿:G1除了追求低停顿外,还可以建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
使用G1收集器时,java堆的内存布局就与其他收集器有很大区别,她将整个java堆划分为多个大小相等的独立区域(Region),虽然还保留着新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,他们都是一部分Region(不需要连续)的集合。
G1收集器之所以能够建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也是Garbage-First名称的由来)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
在G1收集器中Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是用Remembered Set来避免全堆扫描的。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Refrence类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Refrence引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
如果不计算维护Remembered Set的操作,G1收集器的运作大致可以分为以下几个步骤:
- 初始标记(Initial MARKING)
- 并发标记(Concurrent Marking)
- 最终标记(Final Marking)
- 筛选标记(Live Data Counting and Evacuation)
初始标记阶段只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿,但耗时很短。
并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活对象,这阶段耗时较长,但可与用户程序并发执行。
而最终标记阶段则是为了修正在并发标记期间因用户程序继续运行而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿但是可以并行执行。
最后在筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,从Sun公司透露的信息看,这个阶段也是可以做到与用户线程并发执行的,但是因为只回收一部分Region,时间是用户控制的,而且停顿用户线程将大幅提高手机效率。
在JDK9中G1垃圾收集器已经成为默认的垃圾收集。
ZGC
The Z Garbage Collector, also known as ZGC, is a scalable low latency garbage collector designed to meet the following goals:
- Pause times do not exceed 10ms
- Pause times do not increase with the heap or live-set size
- Handle heaps ranging from a few hundred megabytes to multi terabytes in size
At a glance, ZGC is:
- Concurrent
- Region-based
- Compacting
- NUMA-aware
- Using colored pointers
- Using load barriers
At its core, ZGC is a concurrent garbage collector, meaning all heavy lifting work is done while Java threads continue to execute. This greatly limits the impact garbage collection will have on your application's response time.
Z垃圾收集器,也称为ZGC,是一种可扩展的低延迟垃圾收集器,旨在实现以下目标:
暂停时间不超过10毫秒
暂停时间不会随堆或实时设置大小而增加
处理堆范围从几百兆到多兆兆字节大小
ZGC的核心是并发垃圾收集器,这意味着所有繁重的工作都在Java线程继续执行时完成。这极大地限制了垃圾收集对应用程序响应时间的影响。
- 垃圾收集器当前的性能影响
https://static.rainfocus.com/oracle/oow18/sess/1526508331252001MoOX/PF/gcperf_1540322971075001Jr04.pdf - 新一代垃圾收集器ZGC:
https://static.rainfocus.com/oracle/oow18/sess/1526549409932001iXVy/PF/ZGC-OracleCodeOne2018_1540425852649001Dbiq.pdf
3.5.8 理解GC日志 p89
3.6内存分配与回收策略
3.6.1 对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor 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倍以上。
3.6.2 大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。
虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(新生代采用复制算法收集内存)。
3.6.3长期存活的对象将进入老年代
为了识别哪些对象应该放在新生代中哪些对象应该放在老年代中,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1.对象在Survivor区中每熬过一次Minor GC,年龄就增加一岁,当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升老年代的阈值,可以通过参数-XX:MaxTenuringThreshold设置。
3.6.4 动态对象年龄判定
为了更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。
3.6.5 空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
这里的冒险是指:因为新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。老年代要进行这样的担保,前提是老年代还有容纳这些对象的剩余空间,一共多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。
如果某次Minor GC存活后的对象突增,远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕圈子是最大的,但大部分情况下还是将HandlePromotionFailure开关打开,避免Full GC过于频繁。
在JDK 6 Update 24之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略。规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC.
网友评论