4915 2017-10-03 2020-06-25

前言:引用一句经典的话:Java和C++之间有一堵由内存动态分配和垃圾收集技术所围城的“高墙”,墙外面的人想进去,墙里面的人却想出来。

一、概述

Java语言的一个重要特性就是对于内存的自动管理,其中包括两大部分:内存的分配内存的回收。相对来说,内存回收的工作较为复杂,因此我们只从概念上进行说明,且为了全文的流畅性,把这一部分放到内存分配前面讲。

内存回收关注三个点,如下

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

下面进行详细分析。

二、哪些内存需要回收

通过上节我们知道,线程隔离的三个区域:程序计数器Java虚拟机栈本地方法栈依赖于线程,是线程的数据载体,与线程同生共死,因此这三个内存区域的内存分配和回收是确定的,不需要多加干涉。

剩下的两个区域:Java堆方法区线程共享的,是为全局服务的,在这个过程中,不断有新的对象创建,也不断有旧的对象灭亡,这是一个动态的过程,需要对其进行大量的内存操作。由此我们知道,需要进行内存回收的区域是:Java堆方法区

三、什么时候回收

直观意义上,当这个对象无用时,就对该对象所占的内存进行回收。而在面向对象的语言中,进一步可以抽象为该对象是否“死亡”,这里的难点是,如何判断一个对象是否为无用或“死亡”?

下面介绍几种通用的判定算法。

1、引用计数算法

这不是Java采用的判定对象是否存活的算法,但我们还是有必要了解一下,它是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值减1;任何时刻计数器为0的对象就是不可能再被使用的。为什么Java不采用呢?因为它很难解决循环引用的问题。

如下代码

// 循环依赖
Test test1 = new Test();
Test test2 = new Test();
test1.t = test2;
test2.t = test1;
test1 = null;
test2 = null;

2、可达性分析算法

这个算法是Java采用的,它是这样的:通过一系列的被称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为“引用链”,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,应予以回收内存。其中可以被视为GC Roots的

  • 虚拟机栈(栈桢中的本地变量表)中引用的对象
  • 方法区中类静态属性变量引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈JNI(即一般说的Native方法)引用的对象

通俗的讲,就是运行时期方法(包含native方法)所引用的对象、类变量和类final常量所引用的对象。

3、再谈引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在JDK1.2以前,Java中的引用的定义很传统:如果reference类型的数据中的存储的数值代表是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些“食之无味,弃之可惜”的对象就显得无能为力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

  • 强引用就是指在程序代码之中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用。
  • 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。
  • 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类实现虚引用。

4、补充说明

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程

当对象进行可达性分析后没有发现与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行finalize()方法

当对象没有覆盖finalize()法,或finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行finalize()方法,即对象逃脱回收。反之对象将会被放到一个低优先级的队列稍后进行第二次标记,如果依然没有发现引用链,则进行回收。

以上讨论的都是对象,即针对于Java堆而言的。下面我们看下方法区。

5、方法区

很对人认为方法区(或者HotSpot虚拟机中的永久代)是没有垃圾收集(内存回收)的,因为在方法区中进行回收的“性价比”一般较低:在堆中,尤其在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此

方法区的垃圾收集主要回收两部分内容:废弃常量无用的类。其中

  • 废弃常量:假设一个字符串的字面量为“abc”,在当前系统中没有被任何一个对象引用,那么它就是废弃的。常量池中其他类接口方法字段符号引用也与此类似。
  • 无用的类:这个判断比较麻烦,有三个条件:该类的所有实例都已经被回收加载该类的ClassLoader已经被回收该类对应的Class对象没有在任何地方被引用,且无法在任何地方通过反射访问该类的方法

注意:以上说明的废弃常量无用的类可以被回收,不是必须的,可通过参数设置。

四、如何回收

通过上面两步,我们已经确定了内存回收的回收区域回收时机,天时地利,剩下的就是“人和”了。这里的“人和”代指如何对内存进行回收了。

由于垃圾收集算法的实现涉及大量的程序细节,而且各个平台的虚拟机操作内存的方法有各不相同,因此这里不过多地讨论算法的实现,只是介绍几种算法的思想及其发展过程

1、标记-清除算法

最基础的垃圾收集算法是“标记-清除“(Mark-Sweep)算法。如同它的名字一样,算法分为”标记“和”清除“两个阶段:首先标记出所有需要回收的对象在标记完成后统一回收所有被标记的对象。后续的算法都是基于这种思路并对其不足进行改进而得到的。它有两个不足

  • 效率问题,标记和清除效率都不高
  • 空间问题,会产生大量不连续的碎片

回收的大致过程如图

标记清除算法

2、复制算法

为了解决效率问题,一种称为”复制“(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这块内存用完了,就将还存活着的对象复制到另外一块上面,然后在把已使用过的内存空间一次清理掉。只是这种算法将内存缩小为了原来的一般,代价太大了。

回收的大致过程如图

复制算法

3、标记-整理算法

有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让存活的对象都向一端移动,然后直接清理掉端边界以外的内存

这种算法是针对对象存活率较高的内存区域避免进行较多的复制操作,同时也没有产生的不连续的空间

回收的大致过程如图

标记整理算法

4、分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集算法”(Generational Collection)算法,这种算法并没有新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代老年代,这样就可以根据各个年代的特点采用最适当的收集算法。其中

  • 新生代主要存放的是哪些很快就会被GC回收掉的或者不是特别大的对象,这种情况下,只有少量对象存活,宜采用复制算法
  • 老年代存放那些在程序中经历了好几次回收仍然还活着或者特别大的对象,这种情况下,对象存活率较高,宜采用标记-清除或者标记-整理算法

5、复制算法补充

现在的商业虚拟机都采用“复制算法”来回收新生代,而采用“标记-清除”或“标记-整理”算法来回收老年代

其中新生代中的绝大部分对象是“朝生夕死”的,所以,主流的虚拟机对内存是这样划分的:将内存分为一块较大的Eden空间两块较小的Survivor空间新生代使用Eden和其中一块Survivor剩下一块Survivor留作复制的载体

当Survivor空间不够时,即多余10%的对象存活,需要依赖其他内存(老年代)进行分配担保(Handle Promotion)。

当回收时将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。当另外一块Survivor空间不够时,需要依赖老年代进行分配da

HotSpot虚拟机默认的Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被”浪费“。且默认的,新生代与老年代比值为1:2,如图

分代

6、一个有趣的问题

为什么有两个survivor呢?

简单的回答就是:防止内存空间碎片化,具体可以查看这篇博客

五、垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下面简要概述几种常见的垃圾收集器(以HotSpot虚拟机为例)。

1、Serial收集器

Serial收集器是最基本、发展历史最悠久的单线程新生代收集器。但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程完成垃圾收集工作,更重要的是在它进行垃圾收集时必须暂停其他所有工作线程直到它收集结束,这一过程又被称为“Stop The World”。

但它也有优于其他收集器的地方:简单而高效与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。常用在Client模式下。

2、ParNew收集器

ParNew收集器是Serial收集器的多线程版本,在实现上,这两种收集器也共用了相当多的代码。ParNew收集器除了多线程收集之外,其他与Serial收集器相比并没有太多创新之处,但它确实许多运行在Server模式下的虚拟机中首选的新生代收集器,其中一个与性能无关的但很重要的原因是,除了Serial收集器之外,目前只有它能与CMS收集器配合工作

3、Parrallel Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法多线程收集器。和其他收集器关注点不一样,ParNew、CMS等收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目的则是达到一个可控制的吞吐量(Throughput)。

所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。如虚拟机总共运行了100分钟,其中垃圾收集花店1分钟,那么吞吐量就是99%。主要适合用于在后台运算而不需要太多交互的任务

4、Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是个单线程收集器,使用“标记-整理”算法。主要也是用于Client模式下的虚拟机。

5、Parallel Old

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。它的出现,主要是为了匹配Parallel Scavenge收集器,使得“吞吐量优先”收集器终于有了比较名副其实的应用组合。在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。

6、CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的老年代收集器。目前很大一部分的Java引用几种在互联网或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验,CMS收集器就非常符合这类应用的需求。

7、G1收集器

G1(Garbage-First)收集器是当今收集器技术发展的最前沿成功之一,面向服务端,HotSpot开发团队赋予它的使命是(比较长期的)未来可以替换JDK1.5种发布的CMS收集器

8、小结

在HotSpot虚拟机中,对于新生代和老年代采用的收集器组合搭配,如下图

收集器组合

其中,可能需要用到的虚拟机参数

参数说明
UseSerialGC虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial + Serial Old的收集器组合进行内存回收
UseParNewGC打开此开关,使用ParNew + Serial Old的收集器组合进行内存回收
UseConcMarkSweepGC打开此开关后,使用ParNew + CMS + Serial Old的收集器组合进行内存回收。Serial Old收集器作为CMS收集器出现Concurrent Mode Failure失败后的后备收集器使用
UseParallelGC虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge + Parallel Old(PS MarkSweep)的收集器组合进行内存回收
UseParalllelOldGC打开此开关后,采用Parallel Scavenge + Parallel Old的收集器组合进行内存回收
NewRatio新生代与老年代的比例,如 –XX:NewRatio=2,则新生代占整个堆空间的1/3,老年代占2/3
ServivorRatio新生代Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Survivor=8:1
PretenureSizeThreshold直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配
MaxTenuringThreshold晋升到老年代的对象年龄。每个对象在坚持过一次Minor GC后,年龄就增加1,当超过这个参数值时就进入老年代
UseAdaptiveSizePolicy动态调整Java堆中各个区域打大小以及进入老年代的年龄
HadnlePromotionFailure是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个Eden和Survivor区的所有对象都存活的极端情况
ParallelGCThreads设置并行GC时进行内存回收的线程数
GCTimeRatioGC时间占总时间的比率,默认值为99,即允许1%的GC时间。仅在使用Parallel Scanvenge收集器时生效
MaxGCPauseMillis设置GC的最大停顿时间。仅在使用Parallel Scavenge收集器时生效
CMSInitiatingOccupancyFraction设置CMS收集器在老年代空间被使用多少后出发垃圾收集,默认值为68%,仅在CMS收集器时有效
UseCMSCompactAtFullCollection由于CMS收集器会产生碎片,此参数设置在垃圾收集器后是否需要一次内存碎片整理过程,仅在CMS收集器时有效
CMSFullGCBeforeCompaction设置CMS收集器在进行若干次垃圾收集后再进行一次内存碎片整理过程,通常与UseCMSCompactAtFullCollection参数一起使用
总访问次数: 247次, 一般般帅 创建于 2017-10-03, 最后更新于 2020-06-25

进大厂! 欢迎关注微信公众号,第一时间掌握最新动态!