Java GC

Java垃圾回收器相关基础

Java内存区域

Java与c++很大的区别在于内存管理上,c++中主要由程序员对内存进行申请和释放(new、delete/malloc、free),而java中内存交由JVM来管理,虽然这在一定程度上减少了Java程序员的负担,但了解JVM内存管理、GC机制能够帮助我们写出高质量、高效率的代码、并能排除内存溢出、内存泄漏等常见问题,下面我们先了解一下Java的内存区域。
JVM的内存区域划分如下图所示:
img

程序计数器

程序计数器是一块较小的内存空间,用于记录线程的虚拟机字节码指令地址,程序的分支、循环、跳转、异常处理、以及线程切换都依赖与该计数器完成,它是线程私有的内存空间。

Java虚拟机栈

虚拟机栈也是线程私有的,它描述了Java方法执行的内存模型:每一个方法执行时都会创建一个栈帧用于存放局部变量表、操作数、方法出口等信息。每一个方法的调用和结束对应着栈帧入栈和出栈操作。局部变量表存放了基本的类型(boolean、byte、char、int、long、float、double),引用类型(reference,简单理解为对象的内存地址而不是对象本身),returnAddress(返回地址即虚拟机一条字节码指令,局部变量表大小在编译器确定,运行期间不会改变)。该区域中可能出现StackOverFlowError(栈深度超过JVM允许深度),OutOfMemoryError(无法申请足够内存)。

本地方法栈

与虚拟机栈很类似,区别在于虚拟机栈对应的是Java方法,而本地方法栈对应的是native方法服务(如c++),JVM对本地方法栈的语言、数据结构没有要求,有的虚拟机(如HotSpot)甚至把虚拟机栈和本地方法栈合二为一统一管理。

Java堆

这部分区域对大家最熟悉不过了,它是JVM管理的内存区域中最大的一块,存放着对象的实例,为所有线程共享,几乎所有的对象实例都在堆上分配内存(几乎而不是完全绝对)。堆也是GC回收的主要的区域,同样该区域也会抛出 OutOfMemoryError异常。

方法区

方法区也为所有线程共享,存放了虚拟机加载的类信息、常量、静态变量、编译后的代码等数据。运行时常量池是方法区的一部分,用于存放编译期的常量、符号引用、直接引用。方法区也会抛出OutOfMemoryError异常。

直接内存

直接内存不是JVM运行时数据区的一部分,也不是JVM定义的内存区域。但该区域也被频繁使用,可能导致OutOfMemoryError异常。NIO出现,引入了基于channel和buffer的I/O方式,它可以使用native函数库直接分配堆外内存,然后通过Java堆中的directByteBuffer对象作为该内存的应用进行操作,以此来避免Java堆和native堆来回复制数据,提高了性能。
下面我们以HotSpot虚拟机为例,了解一下创建对象时堆内存分配以及对象的访问过程。
(1)对象创建
当使用new创建一个对象时,虚拟机首先在方法区的常量池中定位到一个类的符号引用,检查符号引用的类是否被加载、解析、初始化,具体细节可以阅读java类加载机制相关知识点。在类加载后,需要在堆中分配内存,堆中的内存常常不是规整的往往存在内存碎片,所以需要使用“空闲列表”的方式来选择分配的地址。在分配内存时还需要注意线程安全,防止出现给对象A分配内存后没修改地址而对象B使用了相同的地址指针,所以可以采用2种办法:一是使用同步方式(虚拟机采用CAS的原子语义方式而不是锁),二是给每个线程分配一小块缓冲(TLAB),当线程使用完TLAB后再同步分配内存。对象内存分配后,JVM将内存空间初始化为零值(不包括对象头),接下来虚拟机将对象所属类的元数据信息、对象哈希码、gc分代年龄、对象锁信息放入对象内存的对象头中,这样JVM的对象初始化工作完成了,但在程序员看来对象的字段都还没初始化,接下来执行方法,按照构造函数要求对对象进行初始化,这样对象的整个初始化过程完成。
(2)对象访问
使用栈上的reference来操作堆中对象实例,应用定位和访问的方式有句柄和直接指针两种。
img
img
句柄方式需要在堆中申请一块额外的内存,reference存储了句柄的的地址,句柄优势在于对象实例被移动时只会改变句柄中实例数据指针,reference不用改变。直接指针方式优势在于定位速度快,省掉一次指针定位时间,hotspot虚拟机采用的直接指针方式,在大量对象访问情况下性能提升比较客观。

GC

在学习GC前,我们首先需要明白几个问题:哪些内存需要回收?什么时候回收?怎么回收?

对象的存活

判断对象是否死亡方式有多种,其中主要的方式有:引用计数法、可达性分析法。

引用计数法

给对象添加一个引用计数器,每当有一个引用指向它,计数器加1,当引用失效,计数器减1,减为0则对象可以回收,但无法解决对象之间相互循环引用问题。

可达性分析法

通过GC Roots对象为起点,往下搜索,经过的路径称为引用链,当对象通过引用链和GC Roots不可达时则可以回收。Java中使用可达性分析算法来判断对象存活情况,Java中常见的GC Roots有虚拟机栈引用的对象,方法区中常量、静态变量引用的对象、本地方法栈JNI引用的对象。即使对象与GC Roots不可达,对象也并非必死不可,对象死亡至少经历2个标记过程,首先前面提到的不可达是第一次标记,然后进行一次筛选来确定是否需要执行finalize方法,若对象没有复写finalize方法或者之前已经执行过finalize,那么虚拟机将不再执行finalize方法。在第一次标记后,并需要执行finalize时,finalize是对象逃脱回收的最后机会,对象被放在F-Queue队列中,然后由一个低优先级的Finalizer线程执行对象finalize方法,如果对象在finalize中把自己引用(this)赋值给某个类变量或对象成员变量,那它将被移除被回收的集合,如果仍没有逃脱那基本将被JVM真正回收。一个对象可以在GC时逃脱,但只能逃脱一次,因为finalize只能执行一次。
GC并不仅仅发生在堆中,在方法区(hotspot中成为永久代)也会发生GC,只是回收效率较低。永久代中回收的内容主要有常量和类,一个常量没有任何地方使用就被认为是废弃的可以进行回收了,但类的判断要求严格的多,需要满足的条件有:类所有实例被回收;加载类的ClassLoader被收回手;Class对象没有在任何地方引用,无法通过反射访问类方法和字段。只有满足这3个要求,JVM才有可能(并不一定)回收该类。

GC算法

主要的gc算法有4种:标记-清除算法,复制算法,标记-整理算法,分代收集算法。

标记-清除算法

标记-清除算法分为标记、清除2个阶段,首先对需要回收的对象进行标记,完成后统一回收标记的对象。它存在2个缺点:标记清除过程效率低,而且gc后存在大量不连续的内存碎片,导致以后分配大内存对象时内存不足,不得不触发另一次gc。

复制算法

复制算法思想是把内存容量划分为大小相等的2块,每次使用其中一块,当内存不足时将存活的对象复制到另一块内存,这样就不存在内存碎片问题。但这样内存的使用率较低,没有充分利用内存,有些浪费。IBM经过研究表明,大部分对象(约98%)都是朝生夕死,并不需要按照1:1来划分,所以提出了eden,survivor(2个survivor,eden,suivivor比例为8:1)区域,将eden,survivor中对象移动另一块survivor中,最后清理掉eden,survivor区域,这样仅仅浪费了10%的内存,这也是hotspot默认的方式,当不能保证不超过10%内存大小对象存活时,需要依赖其他内存(老年代)进行分配担保。当存活对象较多时,回收效率较低,所以复制算法一般用于年轻代。

标记-整理算法

对于老年代,提出了另一种方法:标记-整理算法,相比标记-清除算法,他不是直接对对象进行回收,而是把存活对象移动到一端,然后清理到边界以外的内存区域。

分代收集算法

分代收集算法根据对象存活时间把对象分为年轻代、老年代,根据每个阶段采取不同的收集算法,一般对于年轻代大量的对象会死去,一般采用复制算法,对于老年代对象存活旅高,也没有分配担保,一般使用标记-清除,标记-整理算法。

HotSpot GC算法实现

在gc前,首先需要找出GC Roots节点以及引用链,一般作为GC Roots的节点主要有全局性引用(常量、类静态属性)以及执行上下文(栈帧的本地变量表)。在可达性分析时需要保证一致性,在枚举gc roots以及引用链时,需要停止java线程的执行(stop the world),不然对象引用关系在不断变化。在线程停止,检查寻找gc roots时,JVM并不需要一个一个的检查,hotspot使用了OopMap数据结构来达到这个目的,该数据结构记录了对象偏移量上是什么类型数据。
在OopMap帮助下,hotspot快速完成gc roots枚举,但hotspot没有为每个指令都生成OopMap,只是在特定的位置纪录了这些信息,这些位置被称为安全点。程序执行时并非在所有地方都可以停止下来gc,只能在安全点暂停下来,所以一般的安全点选取在方法调用、循环跳转、异常跳转。在gc时需要使所有的线程都运行到最近的安全点(safe point)上,所以有2种方案,一是抢先式中断:在gc时中断全部线程,若线程不在安全点上让他恢复运行到安全点上,另一种是主动式中断:不主动中断线程,通过设置标志,让线程主动轮询标志,发现中断标志后自己中断挂起,轮询位置刚好和安全点重合。
对于线程处于sleep、blocked时,线程无法执行到安全点时,需要使用安全域来解决。线程进入安全域(safe region)后,gc时不需要关注处于安全域状态的线程。当线程离开安全域时需要检查是否已经完成gc过程,若没有完成必须等到整改gc完成才能离开安全域。

垃圾收集器

垃圾收集器是gc算法的具体实现,JVM中的垃圾收集器很多,不同的版本、不同厂商虚拟机提供的垃圾收集器差异也很大,常见的gc收集器如下图所示:
img

Serial

serial收集器是单线程的收集器,同时在gc时必须停止其他工作线程(stop the world),该收集器主要用于运行在client模式下的虚拟机。

ParNew

parent是serial的多线程版本,其他策略与serial基本一致,是工作在server模式下的虚拟机首选的新生代收集器,能够与cms配合工作。在单cpu下它还不如serial的性能,但在多核下并发效果还是有不少提升。

Parallel Scavenge

Parallel Scavenge收集器也是基于复制算法的新生代收集器, 相比其他收集器,它主要关注吞吐量(cpu运行用户代码时间与gc时间比例),cms主要关注了停顿时间适用于用户交互的程序,响应速度快,parallel scavenge使用了快速完成运算任务,不需要太多交互。parallel scavenge提供了2个参数用于控制吞吐量:-XX:MaxGCPauseMillis用于保障gc时间不超过设定值,-XX:GCTimeRatio设置gc时间最多占总时间比例,同时它该提供了-XX:+UseAdaptiveSizePolicy虚拟机会根据系统情况自动调节gc时间和吞吐量,所以改收集器是一种吞吐量优先收集器。

Serial Old

Serial Old也是一个单线程的老年代收集器,使用标记-整理算法,在client模式下配合serial工作,在server模式下与Parallel Scavenge搭配使用,或作为cos的后备收集器,在其发生concurrent mode failure备用。
img

Parallel old

该收集器也适用了标记-整理算法,用于配合 Parallel Scavenge(无法与cms配合工作),因为Serial Old但线程收集能力较差无法利用多cpu处理能力。
img

CMS

CMS收集器是以获取最短停顿时间为目标的收集器,能够加快响应速度,它是基于标记-清理算法,收集过程包含4个过程:初始标记、并发标记、重新标记、并发清除。1、3阶段需要stop the world,初始阶段标记gc roots直接关联的对象,速度较快,并发标记对gc roots进行追踪,重新标记修正并发标记中程序继续运行的变动,该阶段比初始标记慢但远比并发标记快。整个过程耗时的并发标记、清除可以与用户并发执行,所以停顿时间较短。
CMS作为优秀的收集器也有3个明显的缺陷,首先CMS对cpu资源敏感,并发时和工作线程一起运行占用cpu资源,导致程序执行速度变慢,吞吐量降低。其次,cms存在浮动垃圾,可能出现concurrent mode failure导致另一次full gc,原因在于cms在并发清除阶段程序还在运行,所以会有新的内存和垃圾出现,cms无法在这次gc中回收它们,由于在该阶段必须有足够的内存留给用户线程执行,所以cms不能等到老年代被使用完才gc,需要流出一部分空间在并发清除阶段给用户线程使用。如果这个阶段剩下内存不够用户线程使用,那么就出现了concurrent mode failure失败,jvm不得不使用serial old进行一次full gc,停顿用户线程进行gc,时间更长了。最后,cms基于标记-清除算法实现,存在内存碎片问题,所以cms提供了参数,在多少次gc后需要进行一次内存整理过程。
img

G1

G1是当前gc中最前沿的研究成果,它不需要与其它垃圾收集器一起工作,相比其它收集器它的优势如下:
(1)并行与并发:利用多cpu多核优势,减少stop the world时间,采用并发方式执行gc
(2)分代回收:采用不同的方式处理新对象和旧对象,不需要配合其它收集器
(3)空间整合:与cms的标记-清除不同,G1整体上基于标记-整理方式,局部上基于复制算法,都不会存在内存碎片问题
(4)可预测停顿:不断能够减少停顿时间,还能建立可预测的时间模型,保证在M时间内gc时间不超过N
G1将java堆划分为大小相同的区域(Region),保留了新时代老年代概念,他避免了在整个堆进行全区域gc,G1跟踪各个region的价值大小(gc时间、回收大小)维护一个优先级列表,回收价值最大的region,保证了高效的回收效率。
在回收region时,对象的引用可能存在不同的region中,G1使用了Remembered Set来避免全表扫描引用关系,每一个region都有一个remembered set保存对象引用关系,G1收集过程分为4个步骤:初始标记、并发标记、最终标记、筛选回收。初始标记标记gc roots直接关联的对象,并发标记进行可达性分析,找出存活的对象,可与用户线程并发执行,最终标记修正并发阶段的变化,并将变化纪录在remembered set中,该过程需要停顿用户线程,最后筛选阶段根据region排序结果,按照优先级回收,该过程其实也可以做到并发执行,但仅仅回收一部分region速度较快,也没有太大必要。所以G1追求了低停顿,吞吐量没有改进。
img

内存分配和回收策略

(1)对象优先分配在eden区域中,eden区域空间不足将发起MinorGC
(2)大对象直接进入老年代,jvm允许通过-XX:PretenureSize Threshold参数设置对象直接在老年代分配
(3)长期存活对象进入老年代,可设置-XX:MaxTenuring Threhold改变阈值
(4)动态对象年龄判断,jvm并不一定要求年龄达到阈值才能进入老年代,若survivor对象中相同年龄所有对象大小达到survivor一半,大于等于该年龄即可进入老年代
(5)空间分配担保,在Minor GC之前,虚拟机会检查老年代可用连续空间是否大于新生代对象总空间,若成立则Minor GC是安全的,否则需要查看是否允许担保失败,若允许则检查历次从新生代晋升到老年代的对象平均大小,若小于则进行Minor GC(虽然有风险),若不允许冒险或大于则进行一次 Full GC。

谢谢大佬的打赏!