浅谈Java的GC机制

一、GC机制概述

Java的GC机制概括来说,该机制对JVM中的内存进行标记,并确定哪些内存需要回收,根据一定的回收策略,自动的回收内存,持续的保证JVM中的内存空间充足,防止出现内存泄露和溢出的问题。总结下来,GC机制一共做了三件事:回收什么、什么时候回收、怎么回收。围绕着这三件事,对GC机制来进行分析,在分析之前需要对Java的内存管理进行一定的学习了解。

二、JAVA的内存管理

在这里插入图片描述
图中就是Java对内存区管理的示意图,根据线程是否共享内存,分为私有内存区和共享内存区。

1、私有内存区:

伴随线程的产生而产生,一旦线程终止,私有内存区也会自动消除。
虚拟机栈区(Java栈区)
作用:存放的是Java方法执行时的所有数据。
组成:由栈帧组成,一个栈帧代表一个方法的执行。
栈帧(Stack Frame)

  • 每个方法从调用到执行完成,对应一个栈帧在虚拟机栈中入栈和出栈。eg:a方法调用b方法,虚拟机会创建一个b方法的栈帧,并放入Java栈区,当b方法执行完成返回a方法时,b方法的栈帧就会被从Java栈区中移除。
  • 每个栈帧包含的内容:局部变量表、栈操作数、动态链接、方法出口等。
  • Java栈区中定义了两种异常(StackOverFlowError、OutOfMemoryError),如果栈的深度大于JVM所能允许的最大深度时,会报StackOverFlowError这个异常。但是大部分JVM是允许动态扩展虚拟机栈区的大小的,线程可以一直申请栈,知道内存不足,会抛出OutOfMemoryError异常。

程序计数器区
作用:该区是一个比较小的内存区,用于指示当前线程所执行的字节码执行到了第几行。若正在执行一个Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址。若正在执行一个本地方法(Native方法),则计数器的值为null。该区域不会出现内存溢出,也是JVM内存区域唯一一个没有定义OutOfMemoryError的区域。

本地方法栈区
作用:专门为native方法服务
实现方法:与普通Java栈区一致,通过栈帧来记录方法的调用。

2、共享内存区

在运行期间,每个线程都共享的内存区域。
堆区
作用:所有通过new创建的对象,即存储对象实例。其内存都在堆区中进行分配。
特点:是虚拟机中最大的一块内存,是GC主要回收的部分。
注意:堆区的构成,将会在“内存分配的机制”部分中详解

方法区
作用:存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等等。
特点:可选择是否进行垃圾回收。一般,方法区上执行的垃圾收集是很少的,其主要垃圾收集是针对常量池的内存回收和已加载类的卸载。

三、GC机制详述

(一) 确定回收对象

1、如何确定哪些内存需要被回收?

(1) 引用计数法:内存中创建对象的同时,会创建一个引用计数器,同时将计数器+1。每当有新的地方引用到这个对象时,计数器就会+1。引用失效时,计数器就会减1。当计数器为0时,代表对象可以被视为垃圾回收。存在问题,若对象A和对象B相互引用,对象将无法被回收。

(2) 可达性分析法:将一系列称之为GCRoots的对象作为起始节点,根据对象间的引用关系,从GCRoots向下索引,会形成一条或多条"引用链"。当一个对象没有任何引用链时,我们称之为"不可达",则该对象可被回收。
那么,这个GCRoots该如何选取那?在Java中,可以作为GCRoots的对象包括以下几种:
① 虚拟机栈中引用的对象(栈帧中的局部变量区)
② 方法区中类静态属性引用的对象
③ 方法区中常量引用的对象
④ 本地方法栈中JNI(Native方法)引用的对象。

注意:对于可达性分析法,不可达对象并非“非死不可”,那么必死对象该如何鉴别?Java虚拟机在进行死亡对象判定时,会经历两个过程:
① 如果对象在进行可达性分析后没有与GC Roots相关联的引用链,则该对象会被JVM进行第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,若对象没有覆盖finalize方法,或者finalize方法已经被JVM调用过,都会被虚拟机判定为“没有必要执行finalize方法”,即该对象将会被回收。反之,如果该对象被判定为有必要执行,那么该对象将会被放置在一个叫做F-Queue的队列当中,之后会由虚拟机自动建立的、低优先级的Finalizer线程去执行它。在执行过程中JVM可能不需要等待该线程执行完毕,因为如果一个对象在finalize方法中执行缓慢,或者发生死循环,将很有可能导致F-Queue队列中其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。所以JVM只负责建立线程即可。
② 对F-Queue中的对象进行第二次标记,如果在finalize方法中该对象重新与引用链上的任何一个对象建立了关联,即该对象连上了任何一个对象的引用链,例如this关键字,那么该对象就会逃脱垃圾回收系统;如果该对象在finalize方法中没有与任何一个对象进行关联操作,那么该对象会被虚拟机进行第二次标记,该对象就会被垃圾回收系统回收。值得注意的是finaliza方法JVM系统只会自动调用一次,如果对象面临下一次回收,它的finalize方法不会被再次执行。
补充:finalize()方法是Object类的方法,子类可以覆盖这个方法,来做一些系统资源的释放或者数据的清理,有时候,我们可以在finalize()方法中再次被引用,避免被GC回收。

2、四种引用类型

Java将引用分为强引用、软引用、弱引用、虚引用四种类型。

  • 强引用:代码中普遍存在,例如new一个类的对象,只要强引用还存在,垃圾收集器就永远不会回收被引用的对象。
  • 软引用:描述有些还有用但是非必需的对象,在系统将要发生OOM之前,将这些对象回收。如果这次回收后,还没有足够的内存,才会抛出OOM异常。Java中的类SoftReference表示软引用。
  • 弱引用:描述非必需对象,被弱引用关联的对象只能生存到下一次垃圾回收之前。Java中的类WeakReference表示弱引用。
  • 虚引用:这个引用存在的唯一目的就是在这个对象被收集器回收时,可以收到一个系统通知,被虚引用关联的对象与其生命周期没有任何关系。Java中的类PhantomReference表示虚引用。

(二) 确定回收机制

确定回收机制,也可以说是确定用什么算法来进行回收,下面对四种垃圾收集算法一一进行分析。

1、标记-清除算法(Mark-Sweep)

原理:该算法从GCRoots向下索引,对于没有引用链的对象进行标记,待标记完成后统一回收所有被标记的对象。
优缺点:效率上来说,标记和清除两个过程先后执行效率并不高。从空间上来说,标记清除后,会产生大量的不连续的内存碎片,内存碎片太多可能导致以后需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

2、复制算法(Copying)

原理:复制算法很大程度上解决了效率问题,他将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完,需要进行垃圾收集时,就将存活者的对象复制到另一块上面,然后将第一块内存全部清除。
适用场景:年轻代内存的回收
优缺点:不会有内存碎片的问题,但内存缩小为原来的一半,浪费空间,适用于存活率低的情况,若存活率高则需进行大量的复制操作。

3、标记-整理算法(Mark-Compact)

原理:过程与标记-清除算法大体一致,但不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
适用场景:年老代内存的回收
优缺点:避免的碎片化问题,同时适用于存活率高的情况

4、分代收集算法

现在的商用虚拟机基本都是采用该算法来进行垃圾回收的。其原理是根据对象的存活周期不同,将内存划分为几块,然后根据各块的特点采用最适当的收集算法。
根据存活时间可将内存分为:年轻代(Young Generation)、年老代(Old Generation)、永久代(Permanent Generation)。
在这里插入图片描述
其中,将堆区分为年轻代和年老代,二者的占比分别为1/3和2/3,方法区所对应的内存代是永久代。
(1) 年轻代:对象被创建时,内存首先被分配到在年轻代(对于大对象可以直接创建在年老代),大部分对象被创建后就不再使用,只有少量对象存活。年轻代采用”复制算法“的GC机制清理内存,这个GC机制称之为MinorGC或YoungGC,具体实现如下:
Step1 : 如上图所示,年轻代又被细分为三个区,一般对象刚刚创建时,会被分配到Eden区中,当Eden区满后,会经历一次MinorGC,Eden区中的存活对象就会被复制到第一块Survivor区中,这里我们称为S0区,Eden区被清空。(注意这里只移动存活对象,无用对象已被GC机制清理掉,至于哪些时无用对象,在第三部分会讲解。)
Step2 : 等到Eden区再满,就会再次出发MinorGC,Eden和S0中的存活对象就会被复制到另一块Survivor区S1中,S0和Eden被清空。
Step3 : 重复Step2,S0和S1交换角色。
Step4 : 步骤2和3反复进行,直到对象的赋值次数达到16次,也可理解为对象的年龄大于15时,该对象被送至年老代。另外在此过程中,若某个年龄的对象的大小总和超过了Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入年老代,无需等到MaxTenuringThreshold中要求的年龄。
Eden区和两个Survivor的占比为8:1:1,也就是说,每次年轻代的中的可用内存为整个年轻代的90%。但是我们是无法保证每次回收后的存活对象低于10%,这时就需要年老代来帮忙。

(2) 年老代:年老代的空间一般大于年轻代,能存放更多的对象,对象存活率高,年老代的GC次数也相对较少,没有额外空间进行分担担保。当年老代的内存不足时,将执行MajorGC或叫FullGC。该GC机制的实现方式采用的是“标记-整理算法“。

(3) 永久代:也就是方法区。永久代的回收有两种:常量池中的常量、无用的类信息。常量池中的常量,没有引用即可回收。无用类信息,需满足以下三点可定义为无用类:
· 类的所有实例都已经被回收
· 加载类的ClassLoader已经被回收
· 类对象的Class对象没有被引用,没有在任何地方通过反射访问该类的方法。

(三) 确定回收时机

由于回收时机需要了解收集算法的相关知识点,所以放在了最后说,一般情况,年轻代和年老代的空间满了,Java虚拟机无法再为新的对象分配空间时,会触发GC,回收垃圾对象。

  • 年轻代触发MinorGC(Young GC):
    虚拟机在进行minorGC之前会判断老年代最大的可用连续空间是否大于年轻代的所有对象总空间
    ① 如果大于的话,直接执行minorGC
    ② 如果小于,判断是否开启HandlerPromotionFailure,没有开启直接FullGC
    ③ 如果开启了HanlerPromotionFailure, JVM会判断年老代的最大连续内存空间是否大于历次晋升的大小,如果小于直接执行FullGC
    ④ 如果大于的话,执行minorGC
  • 年老代触发FullGC:
    ① 老年代空间不足
    ② 如果创建一个大对象,Eden区域当中放不下这个大对象,会直接保存在老年代当中,如果老年代空间也不足,就会触发Full GC。为了避免这种情况,最好就是不要创建太大的对象。
    ③ 永久代空间不足,如果有永久代空间的话,系统当中需要加载的类,调用的方法很多,同时持久代当中没有足够的空间,就出触发一次Full GC
    ④ 当发生Young GC时, 如果Survivor区当中存活对象的年龄达到了设定值,会就将Survivor区当中的对象拷贝到老年代,如果老年代的空间不足,就会发生Full GC。
    ⑤ 统计YoungGC发生时晋升到老年代的平均总大小大于老年代的空闲空间,在发生YoungGC时会判断,是否安全,这里的安全指的是,当前老年代空间可以容纳YoungGC晋升的对象的平均大小,如果不安全,就不会执行YoungGC,转而执行FullGC。

四、垃圾收集器

垃圾收集器是垃圾回收算法的具体实现,对于该部分内容,作为一个Android或Java开发人员来说,个人觉得了解即可,如果有人问,能够说出大致的几种收集器和每个收集器大概属性即可。最常见的垃圾收集器有六种,下面会一一介绍:

1、Serial收集器

Serial收集器是最古老,最稳定以及效率高的收集器,使用复制算法,单线程进行回收操作。在其进行垃圾收集时,必须暂停其他线程的所有工作,直至其收集完毕,所以可能会产生一定时间的停顿,但在几十兆甚至几百兆的垃圾收集的停顿时间不超过100毫秒,完全可以被接受,且减少了线程开销,所以时至今日依然是Client模式下的默认新生代收集器。

2、Serial Old收集器

Serial Old是Serial收集器的老年代版本,同样是单线程收集器,但使用的是"标记-整理"算法。
在这里插入图片描述

3、ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,新生代并行,老年代串行;新生代复制算法、老年代标记-整理算法。ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器。很重要的原因是:除了Serial收集器之外,目前只有它能与CMS收集器配合工作(看图)。在JDK1.5时期,HotSpot推出了一款几乎可以认为具有划时代意义的垃圾收集器-----CMS收集器,将在后续介绍。在单CPU中的环境中,不会比Serail收集器有更好的效果,因为存在线程交互开销,甚至由于线程交互的开销,该收集器在两个CPU的环境中都不能百分百保证可以超越Serial收集器。当然,随着可用CPU数量的增加,它对于GC时系统资源的有效利用还是很有好处的,它默认开启的收集线程数与CPU数量相同。
在这里插入图片描述

4、Parallel Scavenge收集器

Parallel Scavenge收集器类似ParNew收集器,与其他收集器不同,Parallel收集器更关注系统的吞吐量。吞吐量等于运行用户代码时间和CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例;新生代复制算法、老年代标记-整理算法。

5、Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器的组合。
在这里插入图片描述

6、CMS收集器

① 特性概述
CMS收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作,CMS收集器是以获取最短时间回收停顿时间为目标的收集器。使用"标记-清除"算法。
② 收集过程分为如下四步:

  • 初始标记,标记GCRoots能直接关联到的对象,时间很短。
  • 并发标记,进行可达性分析过程,并不能保证可以标记出所有的存活对象,时间很长。
  • 重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,时间较长。
  • 并发清除,回收内存空间,时间很长。
    整个过程中耗时最长的并发标记和并发清除过程,收集器线程都可以与用户线程一起工作。

③ 缺点

  • 对CPU资源非常敏感,在并发阶段,虽然不会造成停顿,但由于会占用一部分线程,也就是CPU资源,可能会导致程序变慢,吞吐率下降。CMS默认启动的回收线程数=(CPU数量+3)/ 4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个时,CMS对用户程序的影响更大,可能会无法接受。
  • 无法处理浮动垃圾,因为在并发清除阶段,用户线程还在运行,自然会产生新的垃圾,而在此次收集过程中,无法收集这些浮动垃圾,只能下次再进行回收。而且由于在垃圾收集阶段用户线程还需要运行,那就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,也可以理解为CMS所需要的空间比其他垃圾收集器大。如果CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样会导致另一次Full GC的产生。这样停顿时间就更长了,代价会更大。
  • 由于采用"标记-清除"算法,会产生大量的内存碎片,不后续利于大对象的分配,可能会提前触发一次FullGC。
    在这里插入图片描述

7、G1收集器

G1是目前技术发展最前沿的成果之一,相比其他GC收集器,G1有以下特点:
① 并行和并发,使用多个CPU来缩短停顿时间,与用户线程并发执行。
② 分代收集,独立管理整个堆,但能够采用不同的方式去处理新创建的对象和已经存活了一段时间且熬过多次GC的对象,从而来获取更好的收集效果。
③ 空间整合,基于"标记-整理"算法,无内存碎片产生。
④ 可预测的停顿,能建立可预测的停顿时间模型,能让使用者明确指定在一定时间片段内,消耗在垃圾手机上的时间不超过固定值。
在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。
G1收集器的运作大致可划分为以下几个步骤:

  • 初始标记(Initial Marking)
    初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。
  • 并发标记(Concurrent Marking)
    并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
  • 最终标记(Final Marking)
    最终标记阶段是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。
  • 筛选回收(Live Data Counting and Evacuation)
    筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分价值高的Region区的垃圾对象,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。回收时,采用“复制”算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存。

垃圾收集器这部分没有太多深入研究,知识大致了解了下原理,希望以后有机会深入学习研究一下,看了很多篇博客,有两篇篇https://www.cnblogs.com/haitaofeiyang/p/7811311.html ,https://www.cnblogs.com/ityouknow/p/5614961.html写的比较好,记录下来,方便以后学习查看。

原创文章 4 获赞 2 访问量 769

猜你喜欢

转载自blog.csdn.net/muanye5091/article/details/105793699
今日推荐