JVM的内存区域划分以及垃圾回收机制详解

时间:2021-02-22 04:13:42

在我们写Java代码时,大部分情况下是不用关心你New的对象是否被释放掉,或者什么时候被释放掉。因为JVM中有垃圾自动回收机制。在之前的博客中我们聊过Objective-C中的MRC(手动引用计数)以及ARC(自动引用计数)的内存管理方式,下方会对其进行回顾。而目前的JVM的内存回收机制则不是使用的引用计数,而是主要使用的“复制式回收”和“自适应回收”。

当然除了上面是这两种算法外,还有其他是算法,下方也将会对其进行介绍。本篇博客,我们先简单聊一下JVM的区域划分,然后在此基础上介绍一下JVM的垃圾回收机制。

一、JVM内存区域划分简述

当然本部分简单的聊一下JVM的内存区域的划分,为下方垃圾回收机制内容的展开进行铺垫。当然对JVM内存区域划分的内容网上有好多详细的内容,请自行Google。

根据JVM内存区域的划分,简单的画了下方的这个示意图。区域主要分为两大块,一块是堆区(Heap),我们所New出的对象都会在堆区进行分配,在C语言中的malloc所分配的方法就是从Heap区获取的。而垃圾回收器主要是对堆区的内存进行回收的。

而另一部分则是非堆区,非堆区主要包括用于编译和保存本地代码的“代码缓存区(Code Cache)”、保存JVM自己的静态数据的“永生代(Perm Gen)”、存放方法参数局部变量等引用以及记录方法调用顺序的“Java虚拟机栈(JVM Stack)”和“本地方法栈(Local Method Stack)”。

  JVM的内存区域划分以及垃圾回收机制详解

垃圾回收器主要回收的是堆区中未使用的内存区域,并对相应的区域进行整理。在堆区中,又根据对象内存的存活时间或者对象大小,分为“年轻代”和“年老代”。“年轻代”中的对象是不稳定的易产生垃圾,而“年老代”中的对象比较稳定,不易产生垃圾。之所以将其分开,是分而治之,根据不同区域的内存块的特点,采取不同的内存回收算法,从而提高堆区的垃圾回收的效率。下方会给出具体的介绍。

二、常见的内存回收算法简介

上面我们简单的了解的JVM中内存区域的划分,接下来我们就来看一下几种常见的内存回收算法。当然,下方所介绍的内存回收的算法不仅仅是JVM中所使用到的,我们还会回顾一下OC中的内存回收方式。下方主要包括“引用计数式回收”、“复制式回收”、“标记整理式回收”、“分代式回收”。

1、引用计数式内存回收

引用计数(Reference Count)式内存回收机制是Objective-C以及Swift语言中正在使用的内存回收机制,在之前的博客中我们也详细的聊过引用计数式的内存回收。只要有引用,那么引用计数就加1。当引用计数为0时,该块内存就会被回收。当然这中内存清理方式容易形成“引用循环”。

在Objective-C的引用计数中循环引用而造成内存泄露的问题,可以将变量声明成weak或者strong类型。也就是说我们可以将引用定义为“强引用”或者“弱引用”。当出现“强引用循环”时,我们将其中的一个引用设置为weak类型即可,然后这种强引用循环就被打破了,也就不会造成“内存泄露”的问题。关于“引用计数式内存回收”的更多以及更详细的内容,请参考之前发布的关于OC内容的相关博客。

为了更清晰的了解引用计数的工作方式,就简单的画了下方这个图。在左边的栈中的a、b、c三个引用分别指向堆中的不同区域块。在堆中的内存区域块中,该区域有一个强引用时,其retainCount就会加1。而在弱引用时,就retainCount就不会加1。

我们先来看看a引用的第1块内存区域,因为该内存块只有a在强引用,所以retainCount=1,当a不在引用该内存区域时,retainCount=0,该内存会理解被回收的。这种情况下是不会造成内存泄露的。

我们再来看看b指向的内存区域2。b和内存块3都强引用了内存块2,所以2的retainCount=2。而内存块2也强引用了内存块3,所以3的retainCount=1。所以b指向的这块内存区域就存在“强引用循环”,因为当b不再指向这块内存区域时,rc=2就会变为rc=1。因为retainCount不为零,所以这2块内存区域是不会被释放的,2不会被释放,那么自然而然的3块内存区域也不会被释放,但是这块内存区域有不会再被使用到了,所以就会造成“内存泄露”的情况。如果这两块内存区域特别大,那么我们可想而知,后果是比较严重的。

像c引用的这块情况,就不会引起“强引用循环”,因为其中的一个引用链是是弱引用的。当c不在引用第4块内存时,rc由1变为零,那么该块区域就会被立即释放。而内存块4被释放后,内存块5的rc由1变为0,内存块5也会被释放掉。这种情况下是不会引起内存泄露的。而在Objective-C中正是采用的这种方式来回收内存的,当然了,在OC中除了“强引用”和“弱引用”外,还有自动释放池。也就是说,Autorealease类型的引用,让retainCount = 0时,不会被立即释放掉,而是在出自动释放池时才会被释放掉,在此就不做过多赘述了。

  JVM的内存区域划分以及垃圾回收机制详解

2、复制式内存回收

聊完引用计数回收,我们知道引用计数容易引起“循环引用”的问题,为了解决“循环引用”引起的内存泄露问题,OC中引入和“强引用”和“弱引用”的概念。接下来我们在看看复制式内存回收机制,在该机制中是不需要关心“循环引用”的问题的。简单的说,复制式回收其核心就是“复制”,但前提是有条件复制。在垃圾回收时,将“活对象”复制到另一块空白的堆区,然后将之前的区域一并清除。“活对象”就是指沿着对象的引用链可以到“栈”上的对象。当然在将活对象复制到新的“堆区”后,也要将栈区的引用进行修改。

下方就是我们画的复制式回收的简图,主要将堆分为两大部分,在进行垃圾回收时,会将一个堆上的活对象复制到另一个堆上。下方堆1区是目前正在使用的区块,堆2区则是空闲区。而在堆1区中未被标记的那些内存块,也就是2、3是要被回收的垃圾对象。而1、4、5是要被复制的“活对象”。因为沿着栈上的a可到达区块1、沿着c可到达区块4、5。而区块2和3虽然有引用,但是不是来自非堆区,也就是2和3的引用都是来自堆区的引用,所以是要被回收的对象。

  JVM的内存区域划分以及垃圾回收机制详解

找到了活对象后,接下来要做的就是将活对象进行复制,将其复制到堆2区。当然,复制到堆2区的对象间的内存地址是连续的,如果要分配新的内存空间的话,直接从堆空闲的一段分配即可。这样在分配内存空间时的效率是比较高的。对象复制后,要修改来自“非堆区”的引用地址。如下所示。

  JVM的内存区域划分以及垃圾回收机制详解

复制完毕后,我们直接将堆2区的中的所有内存空间进行回收即可,下方就是复制回收后的最终结果。下方的堆1区清空后,可以接收复制过来的对象了。当对堆2区进行垃圾回收时,会把堆2区的活对象拷贝到堆1区上。

从该实例中我们可以看出当内存垃圾特别多的时候“复制式”垃圾回收的效率还是比较高的,因为复制的对象比较少,清除时直接将旧的堆空间进行清理即可。但是,当垃圾比较少的时候,这种方式会复制大量的活对象,效率还是比较低的。这种方式也会将堆的存储空间进行分半。也就是说,总有一半是空闲的,堆空间的利用率不高。

  JVM的内存区域划分以及垃圾回收机制详解

3、标记-压缩回收算法

从上述“复制式”垃圾回收过程中,我们知道,垃圾多时其效率比较高,而垃圾少时,其工作方式效率是比较低的。那么,接下来,我们来介绍另一种标记-压缩回收算法,这种算法在垃圾少时的工作效率比较高,而垃圾多的情况下,工作效率反而不高,这就与“复制式”形成了互补。下方我们将会对标记-压缩回收算法进行介绍。

标记-压缩的第一部就是标记,需要将堆区中的“活对象”进行标记。上面的内容我们已经聊了什么是“活对象”,在此就不做过多赘述了。由“活对象”的特征我们可以看出,下方的活对象是内存区域1和3,所以我们将其进行标记。

  JVM的内存区域划分以及垃圾回收机制详解

标记完成后,我们就开始进行压缩了,将活对象压缩到“堆区”的一段,然后将剩余的部分进行清除。下方就是将1和3这两个活对象进行了压缩。压缩后,将下方的空间进行Clean。也就是说Clean的部分,就可以分配新的对象了。

  JVM的内存区域划分以及垃圾回收机制详解

下方截图是标记-压缩清理后的状态。标记-压缩式垃圾回收可充分利用堆区的空间,当垃圾比较少时,这种处理方式效率还是比较高的,如果垃圾太多碎片化严重时,移动的“活对象”较多,效率比较低。这种方式可以与“复制式”结合使用,根据当前堆区的垃圾状态来选择哪种回收方式。正好与“复制式”形成优势互补。将“复制式”、“标记-压缩式”的回收方式进行整合的算法,就是“分代式”垃圾回收机制,下方会详细介绍到。

  JVM的内存区域划分以及垃圾回收机制详解

4、分代式垃圾回收

“分代”即根据对象易产生垃圾的状态或者对象的大小将其分为不同的代,可分为“年轻代”、“年老代”和“永久代”。“永久代”不在堆中,再次先不做讨论。根据分代垃圾回收的特点,画了下方的简图。

在堆中,主要把区域分为“年轻代”、“年老代”。位于“年轻代”的对象内存创建的时间不长,更新比较快,易产生“内存垃圾”,所以“年轻代”的垃圾回收使用“复制式”回收方式效率比较高。“年轻代”又可分为两个区,一个是Eden Space(伊甸园)和Survivor Sprace(幸存者区)。Eden Space去主要存放那些初次被创建的对象,而Survivor Sprace存放的是从Eden Space幸存下来的“活对象”。在Survivor Sprace(幸存者区)中又分为form和to两块,用于相互复制对象来进行垃圾清理。

而“年老代”中存放的是一些“大对象”以及从Survivor Sprace中存活下来的“对象”,一般到“年老代”的对象比较稳定,产生垃圾较少,针对这种情况,使用“标记-压缩”式回收效率比较高。“分代垃圾回收”主要是分而治之,根据不同对象的特点将其分类,根据分类的特点来具体选择合适的垃圾回收方案。

  JVM的内存区域划分以及垃圾回收机制详解

三、分代式垃圾回收的具体工作原理

当然在JVM具体的垃圾回收时,根据线程分可分为使用单个线程回收的“串行垃圾回收”,使用多个线程回收的“并行垃圾回收”。根据程序的挂起状态,又可分为“独占式回收”和“并发式回收”。当然之前也多次聊过“并行”与“并发”绝对不是一个概念,切不可将其混淆。本篇博客就不对上述这些方式进行详述了,感兴趣的,请自行Google。

下面我们来看一下“分代式垃圾回收”的具体工作原理的完整步骤,来直观的感受一下“分代式”的垃圾回收的执行方式。

1、垃圾回收前

下图是等待“分代垃圾回收”的简图,从下图中,我们可以看出在堆中有些已分配的对象内存并没有被栈上引用,这些就是要被回收的对象。我们可以看出,下方的堆,整体上分为“年轻代”和“年老代”,而年轻代,有可细分为Eden Space, From以及To三个区域。关于每个区域的作用,在上面介绍“分代垃圾回收”时,我们已经介绍过了,所以在此部分我们不做详细介绍了。

  JVM的内存区域划分以及垃圾回收机制详解

2、分代垃圾回收

下图是对上述堆控件的垃圾回收过程。因为我们有上图可以看出,To区域是空白区,可以接受被复制的对象。由于“年轻代”易产生内存垃圾,所以采用“复制式”内存回收的方式。我们将Eden Space和From两个堆区块中的“活对象”拷贝到To区。拷贝的同时,我们也要修改被拷贝内存的栈引用地址。而对From或者Eden区域的“大对象”存储空间直接将其复制到“年老代”。因为“大对象”在From与To区多次复制的效率比较低,直接将其加入到“年老代”中以提高回收效率。

对于“年老代”的垃圾回收,就采用“标记-压缩”式垃圾回收。首先,先将活对象进行“标记”。

  JVM的内存区域划分以及垃圾回收机制详解

3、垃圾回收后的结果

下方就是“分代”垃圾回收后的具体结果。从下方简图中,我们可以看出,Eden Space和From中的活对象都被复制到了To区,而“年老代”的堆区的存储空间也变化不少。而且在“年老代”中多出了从From区复制过来的大对象。具体如下所示。

  JVM的内存区域划分以及垃圾回收机制详解

四、Eclipse的GC日志配置与分析

上面聊这么多,接下来我们来直观的感受一下在Eclipse如何查看垃圾回收的过程以及分析垃圾回收的日志信息。默认情况下,是不显示垃圾回收的过程以及打印日志的,需要在运行配置中添加相关的配置项来将垃圾回收的日志进行打印。本部分我们来看一下Eclipse中的垃圾回收日志记录的配置,然后我们来分析一下这些日志记录。当然我们本篇博客中使用的是Java8,如果你用其他版本的Java打印出来的日志信息会略有不同,好开始本部分的内容。

1、配置Eclipse的运行设置

在Eclipse中的运行设置中添加相应的配置项,垃圾回收时才会打印相应的日志信息。选择我们的工程,然后找到Run Configurations…选项,进行运行时的配置。

  JVM的内存区域划分以及垃圾回收机制详解

下方就是上述选项打开的对话框,然后找到(x)=Arguments这个标签栏,在VM arguments中添加相应的虚拟机参数,这些参数都会作为工程在运行时的参数。下方我们添加了-XX:+PrintGCTimeStamps-XX:+PrintGCDetails两个参数。由这两个参数名我们不难看出相应参数所对应的功能,一个是打印垃圾回收时的时间戳,另一个是打印垃圾回收时的细节。当然还有好多其他的参数,比如选择“垃圾回收”时的具体算法的参数,以及选择是“串行”还是“并行”的参数,还有一些选择是“独占式”还是“并发式”垃圾回收的参数。在此就不做过多赘述了,请自行Google。

  JVM的内存区域划分以及垃圾回收机制详解

2、回收日志的打印与解析

配置完上述参数后,当我们使用System.gc(); 来进行强制垃圾回收时,会打印出相应的参数信息。首先我们得创建测试用的代码,下方就是我们所创建的测试类,当然测试类中的代码比较简单。主要就是new了以字符串,然后将引用置为null, 最后调用System.gc()进行回收。具体代码如下所示:

package com.zeluli.gclog;

public class GCLogTest {
public static void main(String[] args) {
String s = new String("Value");
s = null;
System.gc();
}
}

下方就是上述代码所运行的效果,接下来我们将对下方日志信息的主要内容进行介绍。

  • [PSYoungGen: 1997K->416K(38400K)] 1997K->424K(125952K), 0.0010277 secs]

    • PSYoungGen表示,并行对“年轻代”进行回收,1997K->416K表示年轻代相应区域中“回收前->回收后”的大小,而(38400K)表示“年轻代”堆的总大小。而后方的1997K->424K(125952K)数据是以整个堆的角度来看待的问题。1997K(堆回收前使用的内存) -> 424K(堆回收后使用的内存)(125952K-堆的总内存空间)。

  • [ParOldGen: 8K->328K(87552K)]

    • ParOldGen并行回收“年老代”,后边的参数与上述并行回收年轻代的参数类似,就不多说了。
  • [Metaspace: 2669K->2669K(1056768K)]
    • 则表示“元数据区”的回收情况,Metaspace及“永久代”区,用于存放静态数据或者系统方法的区域。  

  JVM的内存区域划分以及垃圾回收机制详解

上述就是简单的垃圾回收的日志,本篇博客的内容就先到这儿吧,关于JVM中的垃圾回收的内容还有好多,以后结合着具体情况,再陆陆续续的进行介绍。今天博客就先到这儿。