JVM中的自动内存管理可以归结为解决两个问题:给对象内存分配和回收对象内存。回收内存主要指垃圾回收机制。在JVM中,垃圾回收主要解决了以下三个问题:
1.哪些内存需要回收?
2. 什么时候需要回收?
3.如何回收?
在java内存区域中,堆和方法区这两个区域有着显著的不确定性,这部分内存的分配和回收是动态的,垃圾回收器关注的正是这部分的内存如何管理。
1.1 引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加一;当引用失效时,计数器值减一。在任何时刻计数器为零的对象就是不可能再被使用的对象。即当对象的引用计数为0时,也意味着该对象可以被回收。
该方法十分简单,但在java虚拟机中并没有采用该方法管理内存,主要是因为该方法必须配合大量的额外处理才能保证正确地工作,比如很难解决循环引用的问题。
在上述代码中objA和objB的instance字段分别引用了对方,除此之外这两个对象也没有其他引用了。实际上,这两个对象也没有其他任务引用,但因为它们互相引用,导致这两个对象的引用计数都不为0,使用引用计数算法也就无法回收这两个对象。
1.2 可达性分析算法
当前主流的java虚拟机,基本都是使用该算法来判定对象是否存活的。
该算法的基本思路是:从一系列“GCRoot”的根对象开始,根据引用关系向下搜索,搜索过程中所走过的路径称为“引用链”,如果某个对象到GC Root间没有任何引用链相连即该对象和GC Root不相连,则认为此对象时不可能再被使用的。
在java中,可以作为GC Root的对象包括以下几种:
①在虚拟机栈中引用的对象,如局部变量、临时变量、使用到的参数;
②在方法区中类静态属性引用的对象;
③在方法去中常量引用的对象;
④在本地方法中JNI引用的对象;
⑤Java虚拟机内部的引用,如基本数据类型对应的class对象,一些常驻异常对象,系统类加载器等;
⑥所有被同步锁持有的对象;
1.3 什么时候回收方法区
在方法区的垃圾回收主要包括两部分内容:废弃的常量和不再使用的类型。
判断一个常量是否“废弃”相对比较简单,即虚拟机中没有地方引用该常量。判断一个类是否不再使用时,需要同时满足以下三个条件:
①该类的所有实例对象都已经被回收;
②加载该类的类加载器已经被回收;
③该类对应的java.lang.Class对象没有任何地方被引用,即无法在任何地方通过反射访问该类的方法。
目前,大部分虚拟机都采用了“分代收集”的策略进行设计。其主要建立在两个分带假说智商:
1.弱分代假说:绝大多数对象都是朝生夕灭的。
2.强分代假说:熬过多次垃圾收集过程的对象时比较难消灭的。
将分代收集假说应用到虚拟机里,具体体现在将java堆划分为新生代和老年代两个区域。新生代中,每次垃圾回收时会有大量对象死去,而每次回收后存活的老年对象会逐步晋升到老年代中。
除了上述两条假说之外,还有跨代引用假说:即某个对象引用了一个不属于同一代的对象。对于这种情况,两个对象应该是同时存在和消亡的。针对这种情况,只需在新生代上建立一个全局的数据结构(记忆表——一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用,此后发生MinorGC时,只有包含的跨代引用的小块内存里的对象才会被加入到GCRoot进行扫描。
垃圾回收的划分有如下几种:
Minor GC:针对新生代进行回收;
Major GC:只是针对老年代进行垃圾回收,只有CMS收集器会有单独收集老年代的行为。
Mixed GC:指回收整个新生代和部分老年代,只有G1有这种行为
Full GC:指针对真个堆和方法区的垃圾回收。
经典的垃圾回收算法主要有以下三种:标记清除、标记复制、标记整理。
2.1 标记-清除算法
标记-清除算法主要分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,同一回收所有未被标记的对象。
标记-清除算法主要有两个缺点。一是执行效率不稳定,如果java堆中大部分对象是需要回收的话,会产生大量的标记和清除动作,导致两个过程的执行效率随着对象的增多而降低。二是会产生很多局部碎片,可能导致无法为大对象分配空间,从而不得不再次进行垃圾回收。
2.1 标记-复制算法
主要思想是将可能的内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活的对象复制到另一块中,然后把已使用过的内存空间一次清理掉。
在Appel式回收中,将新生代划分为一个较大的Eden区域和两块较小的Survior区域(大概是8:1:1),每次分配内存时只使用Eden和一块Survior区域。当垃圾回收时,将Eden区域和Survior区域中还存活的对象一次性复制到另一块的Survior区域,然后直接清理掉Eden和已使用过的Survior区域,并交换两块Survior区域的角色。
当一块Survior区域不足以存放存活的对象时,需要借助老年代的区域进行分配担保。
标记-复制算法每次会浪费掉一部分内存空间,空间利用率不高。
2.3 标记-整理算法
标记-整理算法其标记过程与标记清除算法相同,但在标记之后,不是直接回收对象,而是将所有存活对象移到内存空间的一端,然后直接清理掉边界以外的内存。
标记-清除算法是一种非移动式回收算法,而标记-整理算法是一种移动式算法。
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新引用这些对象的地方是一种极为负重的操作,而且这种对象移动操作必须全称暂停用户应用程序才能进行。这种停顿一开始被称为“Stop The World”。
上述的主要内容可以说是内存回收的方法论,而内存回收的实践主要体现于垃圾收集器
在介绍垃圾收集器前,先简单说下延迟和吞吐量。延迟主要是指垃圾回收时用户线程的停顿时间。吞吐量指处理器用于运行用户代码的时间与处理器总消耗时间的比值。
吞吐量=运行用户代码时间运行用户代码时间+垃圾回收时间
在设计垃圾收集器时,主要是以低延迟和高吞吐量为目标。
3.1 Serial收集器
Serial收集器是一个单线程收集器,主要用于新生代的垃圾回收。其单线程不仅仅是指它只会使用一个处理器或一条收集线程进行回收,更是指其在进行垃圾回收时,必须暂停其他所有工作线程,直到它收集结束。
3.2 ParNew收集器
ParNew收集器可以说是Serial收集器的多线程版本,除了使用多条线程进行垃圾回收外,其他的行为(收集算法、对象分配策略、回收策略等)与Serial完全一致。
除Serial收集器外,目前只有它能与CMS配合使用。
3.3 Parallel Scavenge收集器
Parallel Scavenge收集器也是基于标记-复制算法实现的新生代收集器。Parallel Scavenge收集器的主要目标是达到一个可控制的吞吐量。
3.4 Serial Old收集器
Serial Old收集器是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记-整理算法。
3.5 Parallel Old收集器
Parallel Old收集器则是Parallel Scavenge收集器的老年代版本,支持对象池并发收集,同样是基于标记-整理算法实现。
3.6 CMS收集器
CMS收集器是以最短回收停顿时间为目标的收集器。其主要是基于标记-清除算法实现的,整个过程可以分为四个步骤:
1.初始标记
2.并发标记
3.重新标记
4.并发清除
在上述步骤中,耗时最长的并发标记和并发清除阶段,垃圾收集线程都可以与用户线程一起执行,所有从总体上说,CMS收集器的内存回收过程是与用户线程一起并发执行的,
3.7 G1收集器
G1(Garbage First)收集器开创了收集器面向局部搜集的设计思路和基于Region的内存布局形式。在使用该收集器时,除了并发标记阶段,其他阶段都需要暂停用户线程。
在G1收集器之前,所有的收集器垃圾收集的目标范围要么是整个新生代,要不是整个老生代,而G1可以面向堆内存任何部分来组成回收集,通过衡量哪块内存中存放的垃圾数量多,回收收益最大,从而确定回收集。
G1首先使用基于Region的堆内存布局,不再坚持固定大小以及固定数量的分带区域划分,而是把连续的java堆划分为多个大小相等的独立区域,每一个Region都可以扮演新生代的Eden空间、Survior空间或老年代空间。Region中还有一类特殊的Humongous区域,专门存储大对象。G1认为一个对象超过了Region容量的一半的时候就是大对象。
具体的处理思路是让G1收集器跟踪各个Region里面的垃圾堆积的“价值大小”,价值即回收所获得的空间大小以及回收所需时间的经验值,然后再后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region。
G1的记忆集在存储结构本质上是一种哈希表,key是Region的起始地址,Value是一个集合,里面存储卡表的索引号。
G1收集器的大致过程为:
1.初始标记
2.并发标记
3.最终标记:暂时暂停用户线程,处理并发阶段结束后仍遗留下来的最后那少量的原始快照记录
4.筛选回收:可以自由选择多个Region构成回收集,然后把决定回收的那一部分的Region的存活对象复制到空的Region中,在清理掉整个旧Region的全部空间。这一步设计到存活对象的移动,必须暂停用户线程;CMS采用标记清除,在这一步不涉及存活对象的移动,不用暂停用户线程。
3.8 低延迟垃圾收集器——ZGC收集器
ZGC收集器基于Region内存布局,(暂时)不设分代,使用了度屏障、染色体指针和内存多重映射等技术来实现可并发的标记-整理算法。
在ZGC收集器中,将可以将Region分别三类:
1)小型Region:容量为2MB,用于存放小于256KB的小对象;
2)中型Region:容量为32MB,用于放置大于等于256KB但小于4MB的对象;
3)大型Region:容量不固定,但必须为2MB的整数倍,用于放置4MB或以上的大对象,每个大型Region中只会存放一个大对象。
ZGC收集器有一个标志性的设置及就是其采用了染色体指针技术。它可以直接把标记信息记载引用对象的指针上。
染色体指针技术是一种直接将少量额外的信息存储在指针上的技术。
ZGC能够管理的内存不超过4TB。
染色体指针可以使一旦某一Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。
ZGC收集器主要有以下几个步骤:
1)并发标记:
遍历对象图做可达性分析的阶段,与前几种方法不同的是,ZGC的标记是在指针上而不是在对象上进行,标记阶段会更新染色体指针中的Marked0、Marked1标志位。
2)并发预备重分配:
根据特征的查询条件统计得出本次收集过程要清理哪些region,将这些region组成重分配集。ZGC的重分配集只是说明了这个里面存活的对象会被复制到其他region,里面的region会被释放,而不能说回收行为就只是针对这个集合里的Region进行。
3)并发重分配:
重分配是ZGC执行过程中的核心阶段。这个过程要把重分配集中的存活对象复制到新的Region中,并为重分配集中的每个Region维护一个转发表,记录从旧对象到新对象的转发关系。
在这一步,指针会有“自愈”行为,即第一次访问对象时,这次访问会被预置的内存屏障截获,然后根据转发表中的记录将访问转发到新复制的对象上,并同时修正更新该引用的值。
一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配(转发表暂时还不能释放)。
4)并发重映射:
重映射主要就是修正整个堆中指向重分配集中旧对象的所有引用。在ZGC中,该过程并不是一个急需完后完成的过程,可以放到下一次垃圾收集中的并发标记中完成。
以上内容主要参考《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》
强烈推荐大家阅读下原版书籍。