Android之java的GC垃圾回收机制详解-层层分析步步深入

开发 后端
垃圾回收机制大家应该都有所了解,它不仅是面试的常客,也是Java体系中相当重要的一块知识。深入理解Java的GC机制,不仅有助于我们在开发中提高程序的性能,更有了在面试官面前炫技的资本。本篇文章将全面且深入的分析JVM的垃圾回收机制,进行讲解。

[[404562]]

前言

Java语言中一个显著的特点就是引入了垃圾回收机制,使c++程序员最头疼的内存管理的问题迎刃而解,它使得Java程序员在编写程序的时候不再需要考虑内存管理。

由于有个垃圾回收机制,Java中的对象不再有“作用域”的概念,只有对象的引用才有“作用域”。垃圾回收可以有效的防止内存泄露,有效的使用空闲的内存;

垃圾回收机制大家应该都有所了解,它不仅是面试的常客,也是Java体系中相当重要的一块知识。深入理解Java的GC机制,不仅有助于我们在开发中提高程序的性能,更有了在面试官面前炫技的资本。

本篇文章将全面且深入的分析JVM的垃圾回收机制,进行讲解。

对象的创建是由JVM完成的,在对象创建的时候JVM会在Java堆中开辟一块空间用来存储这个对象,而当对象“死亡”的时候,同样是由JVM来处理的,JVM处理“死亡”对象的过程就是垃圾回收机制。

一、GC机制

1.堆内存的区域划分

关于堆内存区域的划分,其实是由垃圾收集器的特性决定的。

为了方便JVM更好的管理和回收对象,Java的设计者们将Java的堆内存成为了两大块,分别为:

新生代(Young Generation) 和 老年代(Old Generation)

而根据新生代的特性,又将新生代分成了一块较大的Eden区域和两块较小但大小相等的Survivor区域。

至于新时代和老年代这两块区域,是我们今天要探讨的重点。

垃圾回收的特点。垃圾收集器在执行一次垃圾回收时,可能是部分收集(Partical GC)也可能是整堆收集(Full GC),部分收集又可以分为新生代收集(Minor GC/Young GC)和老年代收集(Major GC/Old GC)。

既然有这样的划分,那收集器回收区域的规则是根据什么条件确定的呢?在JDK6 之后,回收区域的规则为:只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则将进行Full GC。

对象通常是在Eden区域被创建,JVM会给每个对象定义一个年龄(Age)计数器,存储在对象头中。如果经过第一次Minor GC后对象仍然存活,

并且能被Survivor区域容纳的话,对象则会被移动到Survivor区域,同时会将对象的年龄设置为1岁。接下来,该对象会经历多次的垃圾回收,

Survivor区中的对象每熬过一次Minor GC,它的年龄就会增加一岁。如果对这个象增加到一定年龄(默认15,可通过-XX:MaxTenuringThreshold参数设置),就会被移动到老年代中。

当然,为了更好的适应不同程序的内存情况,HotSpot虚拟机并不是绝对要求对象年龄达到后才能转移到老年代,特殊情况有如下两种:

①如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,则年龄大于或等于该年龄的对象就可以直接进入老年代。

②对于大对象,HotSpot虚拟机可通过-XX:PretenureSizeThreshold参数进行设置,当对象内存大于设定的值的话,这个对象会绕过Eden区域直接被分配到老年代。

2.永久代(Permanent Generation)

在JDK7以及之前,HotSpot虚拟机还有另外一块叫永久代(Permanent Generation) 的存储区域,这块区域并不属于堆内存,而是对于方法区的实现。

主要用于存放Class和Meta(元数据)的信息,Class在类加载的时候被放入永久代。永久代和存放实例的堆内存区域不同,GC不会在主程序运行期对永久代进行清理。所以这也导致了永久代的区域会随着加载的Class的增多而爆满,最终抛出OOM异常。虽然被称为永久代,但这块内存区域也会进行垃圾回收。

永久代的垃圾收集主要包废弃常量和无用的类(被类加载器卸载的Class)。永久代触发垃圾回收的条件比较困难,需要同时满足以下三点:

①该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;

②加载该类的ClassLoader已经被回收;

③该类对应的java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法.

3.元空间(MetaSpace)

由于永久代可能存在内存溢出的问题,在JDK8之后永久代已经不复存在,取而代之的是元空间(MetaSpace)

元空间的本质和永久代类似,不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。

因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过-XX:MetaspaceSize这个参数来指定初始空间大小,当达到设置的最大值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。可以通过-XX:MaxMetaspaceSize来设置元空间能够使用的最大内存,默认是没有限制的。

除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:-XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集   -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。

有关垃圾回收的区域如下图所示:

图中的Permanet Generation区域,在Jdk8中,被MetaSpace区取代了

二、垃圾收集的标记算法

垃圾收集器回收垃圾的第一步先要确定哪些对象是可以被回收的。因此,JVM会扫描堆内存中的所有对象,并标记出可被回收的对象。而垃圾收集的标记算法有以下两种:

1.引用计数算法

引用计数算法通过在每个对象中添加一个计数器,当有一个地方引用它的时候计数器的值就会增加1;当引用失效的时候计数器的值则会减1。当计数器的值为0时,则可认为这个对象已经不再使用。因此对于引用计数算法,垃圾收集器只需要回收计数器为0的对象即可。

引用计数算法的优点是效率很高,不需要遍历所有对象。但它是存在一个致命的缺点,即无法解决对象之间循环引用的问题。比如对象A引用了对象B,对象B也引用了对象A,除此之外,A、B两个对象再也没有被其他地方引用。此时对象A和对象B的计数器均不为0,所以A、B两个对象都无法被回收。所以,目前商用的Java虚拟机都没有选用引用计数算法来进行标记。

2.可达性分析算法

可达性分析算法也被称为根搜索算法。这一算法的基本思路是用一系列的“GC Roots"的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径被称为”引用链“(Reference Chain)。如果一个对象到”GC Roots"没有任何的引用链相连,则证明此对象可能不再被使用。

如下图所示,灰色部分的对象没有关联到引用链上,此时这些对象就会被判定为可回收对象。

哪些对象可以被作为GC Roots呢?主要包括以下几种:

①在虚拟机栈(栈帧中的本地变量表)中引用的对象。

②方法区中类静态属性引用的对象。

③在方法区中引用的对象,如字符串常量池(String Table)里的引用

本地方法栈中JNI引用的对象

④Java虚拟机内部的引用,如基本数据类型对应的Class对象以及一些常驻的异常对象等。

⑤所有同步锁持有的对象,反应Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

三、垃圾收集算法

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

标记-清除算法是最早出现也是最基础的一种垃圾收集算法。该算法分为“标记”和”清除“两个阶段,标记阶段就是上边讲到的对垃圾的标记。首先会通过可达性分析算法标记出所有需要回收的对象,然后统一回收掉所有被标记的对象。标记-清除算法的执行过程如下图所示:

图中深灰色区域为可回收区域,在标记完成后直接将深灰色区域进行清理。这一算法很容易理解,实现起来也很便捷,但是也存在两个缺点:

①.执行效率会随对象增多而降低。如果Java堆中包含大量需要回收的对象。此时需要进行大量标记和清除操作。导致标记和清除这两个过程需要大量的时间,降低了执行效率

②引起严重的内存碎片化问题。标记、清除之后会产生大量不连续的内存空间,这可能会导致在需要分配大对象时无法找到足够的连续空间,进而引发GC

2.标记-复制算法(Copying)

标记-复制算法也被简称为复制算法。它是对标记-清除算法的改进。复制算法将内存划分为大小相等的两块,分配对象时只使用其中的一块。当这块内存用完时,就将存活的对象复制到另外一块上面,然后把已使用的这块内存一次性清理掉。复制算法的执行过程如下图所示:

复制算法虽然解决了标记-清除算法的一些问题。但其缺陷也显而易见,直接导致了可用内存变为原来的一半,内存使用率太低;

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

标记整理算法在标记了存活对象之后,会让所有存活的对象向内存的一端移动,然后直接清除掉边界外的内存。该算法的示意图如下图所示:

移动存活对象并更新所有被移动对象的引用是一个比较耗时的操作。而且,在移动对象时必须暂停所有用户线程才能进行(这一操作有个专有名词叫“Stop The World”,简称STW),拖累了用户程序的执行效率;

4.分代收集(Generational Collection)

分代收集不能称得上是一种算法,它会根据堆内存的不同区域采用不同的收集算法,因地制宜。

比如上边我们已经说过的,在G1收集器之前,所有的收集器都是将Java堆划分为新生代和老年代,由于新生代中对象存活率比较低,因此在新时代采用优化了的复制算法。HotSpot虚拟机中将Eden和Survivor的大小大小划分为8:1的比例,分配对象只使用Eden和其中的一块Surivor区域,在标记完成后将存活的对象复制到另外一块Survior空间中,然后清除Eden和使用的一块Surivor。这样,新生代的空间利用率就达到了90%。

对于老年代每次垃圾回收存活的对象比较多,因此这一区域采用的是标记-整理算法进行垃圾回收。

四、垃圾收集器

垃圾收集器其实就是对于前面讲到的原理的实现,只不过在Java的发展史中出现了一代又一代的垃圾收集器,而新一代的垃圾收集器都是对上一代垃圾收集器缺点的弥补。直到前几天(2020年9月15日),在Oracle JDK15中又引入了新的垃圾收集器Shenandoah。可见直到今天Java的设计者们依然还在对收集器进行优化。

经典的几款垃圾收集器,图中连线表示这两款收集器可以配合使用

1.新生代收集器

①Serial收集器

Serial收集器是最基础、发展历史最悠久的收集器。它是一个单线程工作的收集器,

对于早期的单核处理器或处理器核心数较少的情况下,Serial收集器由于没有线程交互的开销,

所以收集效率比较高。但是,Serial收集器整个收集过程是需要STW的。这也是导致了早期的Java程序慢的主要原因之一。Serial收集器新生代采用的是标记-复制算法,运行过程如下图所示

②Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现。也是能够并行收集的多线程收集器,从表面上看它与ParNew非常相似,Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput);

吞吐量=(运行用户代码的时间)/(运行用户代码时间+运行垃圾收集时间)

Parallel Scavenge收集器运行过程如下图所示:

2.老年代收集器

① Serial Old收集器

Serial Old是Serial收集器的老年代版本,它与Serial一样都是单线程收集器。Serial Old使用的是标记-整理算法。它的主要意义也是提供客户端模式下的HotSpot虚拟机使用。

② Parallel Old收集器

Parallel Old 是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法。这个收集器是在JDK 6时开始提供。

③ CMS收集器

CMS(Concurrent Mark Sweep)收集器是一款具有划时代意义的收集器。前面我们提到的几款收集器在工作期间全程都需要STW,而CMS第一次实现了垃圾收集的并发处理。因此,这款收集器可以有效的减少垃圾收集过程中的停顿时间。CMS收集器是基于标记-清除算法实现的。我们来详细了解一下CMS的工作过程:

(1)初始标记:从GC Roots出发标记全部直接子节点的过程,该阶段是STW的。由于GC Roots数量不多,通常该阶段耗时非常短。

(2)并发标记:并发标记阶段是指从GC Roots开始对堆中对象进行可达性分析,找出存活对象。该阶段是并发的,即应用线程和GC线程可以同时活动。并发标记耗时相对长很多,但因为不是STW,所以我们不太关心该阶段耗时的长短。

(3)重新标记:重新标记那些在并发标记阶段发生变化的对象。该阶段是STW的。

(4)并非清除:并行清理, 开启用户线程,同时GC线程开始对为标记的区域做清扫。

从上面描述可以看到,CMS能够并发收集,有效减少停顿时间。但CMS并不是一款完美的垃圾收集器,不然也不会在JDK15中将其移除。它的缺点主要有以下几个:

(1)并发收集占用CPU资源。虽然并发阶段不会导致用户停顿,并发时的收集线程却占用了一部分CPU资源,导致应用程序变慢,降低了吞吐量。

(2)无法处理浮动垃圾。CMS的并发标记和并发清理阶段,用户线程是继续运行的,这期间必然会有新的垃圾对象产生。对于已经收集过的区域,CMS无法再去回头处理它们,只能等到下一次垃圾收集时再清理掉。

(3)并发清理阶段需要保证内存充足。由于在垃圾收集阶段用户线程依然在运行,所有不得不预留足够的空间提供给用户线程使用。因此CMS收集器在垃圾收集开始时需要预留足够的内存。JDK 5的默认设置,当老年代使用了68%的空间后就垃圾收集会被激活。虽然可以通过参数-XX:CMSInitiatingOccupancyFraction来调高CMS的触发百分比,但这样又会导致CMS运行期间可能出现预留内存不足的情况。此时,CMS就会出现一次”并发失败“(Concurrent Mode Failure),虚拟机不得不启动后备预案,停止用户线程的执行,启动Serial Old收集器重新进行老年代的垃圾收集。

(4)产生大量碎片空间 。由于CMS使用的是“标记-清除”算法,因此会导致大量空间碎片产生。

总结:

这篇文章从堆的分代到垃圾收集算法再到垃圾收集器都做了比较详细的分解,一步一步分析,为了让老铁们多学习下gc这方面的知识点。

 

责任编辑:姜华 来源: Android开发编程
相关推荐

2017-03-03 09:26:48

PHP垃圾回收机制

2010-09-26 14:08:41

Java垃圾回收

2009-06-23 14:15:00

Java垃圾回收

2011-07-04 16:48:56

JAVA垃圾回收机制GC

2019-08-19 12:50:00

Go垃圾回收前端

2011-06-28 12:39:34

Java垃圾回收

2015-06-04 09:38:39

Java垃圾回收机

2010-10-13 10:24:38

垃圾回收机制JVMJava

2010-09-26 11:22:22

JVM垃圾回收JVM

2010-09-25 15:33:19

JVM垃圾回收

2017-08-17 15:40:08

大数据Python垃圾回收机制

2017-06-12 17:38:32

Python垃圾回收引用

2021-11-05 15:23:20

JVM回收算法

2010-09-16 15:10:24

JVM垃圾回收机制

2010-09-25 15:26:12

JVM垃圾回收

2021-05-27 21:47:12

Python垃圾回收

2021-12-07 08:01:33

Javascript 垃圾回收机制前端

2021-09-26 05:06:46

JS垃圾内存

2016-08-11 15:02:54

Java垃圾回收机制内存

2016-08-11 14:26:29

Java垃圾回收机制内存分配
点赞
收藏

51CTO技术栈公众号