JVM笔记——垃圾收集与内存分配

前言


垃圾收集(GC)与内存动态分配,是JVM中很重要的一块内容,与C++语言不同的是,Java语言能够自己对内存进行回收以及动态分配,但是仍然可能产生内存的溢出或者内存泄漏的问题,所以了解垃圾收集与内存分配的原理是必须的。从JVM的内存划分区域来看,栈区(由于是以HotSpot虚拟机为例,将Java虚拟机栈以及本地方法区栈统称为栈区)、程序计数器是线程私有的,随着线程的消亡而消失,这几个区域的内存分配与回收都存在确定性,所以这几个区域就不必去过多考虑回收的问题。但是,堆和方法区就不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中多个分支需要的内存可能也不一样,我们只有在程序运行的过程中才会知道创建哪些对象,这部分的内存分配和回收是动态的,所以这两个区域是垃圾回收和内存分配所重点关注的区域。其中Java堆区是垃圾收集器主要管理的区域。

垃圾回收之前的工作

​ 垃圾收集或者说是内存回收,我们怎么判断一个对象是否已经“没有用”了呢?这是在进行垃圾收集之前,一个很直接的问题。在Java堆中主要有这么几种策略。

引用计数算法

算法描述:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效的时候,计数器的值就减1;任何时刻计数器的值为0的时候,对象就不再被使用。

优点:算法的判定效率很高,在大部分的情况下是个不错的算法,但是在主流的Java虚拟机中却没有选用这种方法来进行内存管理。

缺点:它很难解决对象之间相互循环引用的问题。比如说:

1
2
3
4
5
6
7
8
9
10
11
public static void testGC(){
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;
// 进行一次内存的回收
System.gc();
}

如果按照引用计数算法,最后的结果应该会这样子,由于对象objA和对象objB的instance相互引用,所以他们的引用计数器都不是0,造成对象形成一种类似于死锁的形态,从而两个对象都不能被回收。但是事实是这两个对象会被JVM回收,所以JVM的回收算法并不是采用这种方式的。

可达性分析算法

算法描述:通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到“GC Roots”没有任何引用链相连的时候,证明此对象不可用。如下图所示,object1、object2、object3、object4都是GC Roots可达的,但是object5、object6、object7之间虽然有关联,但是却是GC Roots不可达的,所以它们将会被回收。Java语言也主要是通过这种方式来判断对象是否存活的。

注意:在可达性分析算法中,没有被引用链相连的对象也并不是非死不可。其中一个对象的回收要经历至少两次的标记过程。如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。如果有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。稍后GC将对这个队列中的对象进行第二次的标记,若对象能够重新与引用链上的任何一个对象建立关联,那么它就不会被收集。否则该对象就被回收。

GC Roots

GC Roots的对象有哪些:

虚拟机栈(栈帧中的本地变量表)中引用的对象。也就是我们在程序中正常创建的一个对象,对象会在堆上开辟一块空间,同时还将这块堆上的地址作为引用保存虚拟机栈中,如果引用还存在,说明这个对象还存在,就不用进行垃圾回收,这种情况是最常见的。

本地方法栈中JNI(即一般说的native方法)引用的对象。有时候Java代码中会调用C++或者C的代码,JVM中专门有个本地方法栈来保存这些对象的引用。

方法区中类静态属性引用的对象。理解为:引用方法区中某个静态属性(也就是有static关键字的属性)的所有对象

方法区中常量引用的对象。理解为:引用方法区中某个常量(也就是有static final关键字的属性)的所有对象

这里我的理解是这样的:我们要知道哪些对象是不可用的,首先我们要找“还存在引用的对象”,也就是GC Roots,而对象需要通过在虚拟机栈中或是本地方法栈中或是方法区中引用来找到,当找到在这些内存区域中有引用的对象时,我们就给它标记一下。接着顺着引用链向下找,能够被链接起来的就标记一下,证明不能被回收。反之,没有被标记的则通过回收策略进行回收。

现在我们再把这段代码拿下来分析一下为什么objA和objB两个对象会被回收。

1
2
3
4
5
6
7
8
9
10
11
public static void testGC(){
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;
// 进行一次内存的回收
System.gc();
}

在 objA = null; 和 objB = null; 这两步中程序将objA和objB两个对象的引用都删去了,相当于在栈帧中的本地变量表中的引用被删去了。这时候objA和objB就不能成为GC Roots,在垃圾回收的时候将会被回收。

总结与扩展

不管是引用计数算法还是可达性分析算法,判断对象是否存活都与“引用”有关,从jdk1.2之后,Java对引用概念进行了扩充,将引用分为:强引用、软引用、弱引用、虚引用四种,引用强度从强到弱。

强引用:指的是代码中普遍存在的现象,类似于“Object obj = new Object()”,这种类型的引用,只要强引用一直存在,对象就不会被回收掉。

软引用:是用来描述一些还有用并非必要的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,才会把这些对象列进回收范围之中进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出的异常。

弱引用:也是用来描述非必须对象的,但是它的强度比软引用更弱一点,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

虚引用:也称幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是能在这个对象被收集器回收的时候收到一个系统通知。

以上部分其实都是围绕着堆进行的算法,其实在方法区中也存在着垃圾的回收。但是和堆的性价比相比,方法区(永久带)的垃圾收集效率十分低下。永久带的垃圾收集主要回收两部分:废弃常量和无用的类。

回收废弃常量与回收Java堆中的对象类似,比较简单。回收无用的类在判断是否该类无用上需要满足以下条件:

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

2、加载该类的ClassLoader已经被回收。

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

垃圾收集算法

标记-清除算法

1、算法的示意图

标记清除算法示意图

2、算法描述

​ 算法分为两个阶段,分别为“标记”和“清除”两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。其中标记的过程就是前面所介绍的“一个对象至少被标记两次的部分”。它是最基础的算法,后续的收集算法都是基于这种思路进行改进的。

​ 该算法主要有两个不足:其一是效率问题。标记和清除两个过程的效率都不高;另一个是空间问题。标记清除之后会产生大量的不连续的内存碎片,控件碎片太多会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。

​ 为了解决效率问题,提出了复制算法。

复制算法

1、算法的示意图

复制算法

2、算法描述

​ 该算法将可用内存按照容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都对整个半区进行内存回收,内存分配也不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

​ 算法的不足:这种算法的代价是将内存缩小到了原来的一半,代价太高了。所以IBM通过研究发现,并不需要按照1:1的分配来划分内存空间,而是划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和一块Survivor,当回收时,将Eden和Survivor中存活的对象复制到另一块Survivor中,最后清理掉Eden和刚刚用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor比例为8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的新生代空间会被浪费掉。但是也会有一种情况,也就是当存活的对象大于10%的时候,会导致Survivor的空间不够用,这时候就要依赖其他内存(这里指老年代)进行分配担保

注:堆被分为新生代和老年代,默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定),即:新生代 ( Young ) = 1/3 的堆空间大小。老年代 ( Old ) = 2/3 的堆空间大小。其中,新生代 ( Young )被细分为 Eden 和 两个 Survivor 区域。

​ 复制收集算法在对象存活率较高的时候会进行较多的复制操作,效率会变低。并且如果不想浪费50%的空间就要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代中不能使用这种算法,因此提出了另外一种适合老年代的算法——标记-整理算法。

标记-整理算法

1、算法的示意图

标记整理算法示意图

2、算法描述

​ 标记-整理算法的标记过程与标记-清除算法一致,但后续步骤不是直接怼可回收的对象进行清理,而是让所有存活的对象移动到一端,然后直接清理掉端边界以外的内存。

分代收集算法

​ 这种收集算法是当前商业虚拟机的垃圾收集的主要算法,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,根据不同的年代采用不同的收集算法。在新生代中,每次都有大批的对象死去,因此使用复制算法,在老年代中,对象的存活率高,就要使用标记-清除或者标记-整理算法。

几种常见的垃圾收集器

​ 这里讨论的收集器是基于JDK1.7 Update 14之后的Hotspot虚拟机(在这个版本中正式提供了商用的G1收集器,之前的G1收集器是处于实验的状态,关于G1收集器在后面也会详细分析)。其中这个虚拟机所包含的所有收集器如图所示。

Hotspot垃圾收集器

Serial收集器

1、运行示意图

serial垃圾收集器

2、介绍

​ Serial收集器是最基本、发展历史最悠久的收集器。这个收集器是一个单线程的收集器,但是它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有工作线程(Stop the world),直到它收集结束。这个收集器其实是很难让用户接受的,试想当你在正常的工作的时候,要求每一个小时就要暂停所有线程1分钟,显然用户体验不好。

3、优缺点

​ 缺点:缺点便是之前提到的因内存回收而导致的停顿时间过久。

​ 优点:简单而高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。它依然是虚拟机运行在Client模式下的默认新生代收集器。

ParNew收集器

1、运行示意图

ParNew垃圾收集器

2、介绍

​ ParNew收集器其实就是Serial收集器的多线程版本,它是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但是很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。

Parallel Scavenge收集器

1、运行示意图

Parallel Scavenge收集器

2、介绍

​ Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并发的多线程收集器,这些与ParNew收集器相同。与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目的是达到一个可控制的吞吐量,其中吞吐量的定义如下。
$$
吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间)
$$
也就是说,CMS等收集器关注的是与用户的交互,提升用户体验。而高吞吐量可以高效地利用CPU时间,尽快完成程序的 运算任务,主要适合在后台运算而不需要太多地交互任务。

​ Parallel Scavenge收集器有这些值得关注的参数:

-XX:MaxGCPauseMills参数:控制最大垃圾收集停顿时间

注:将-XX:MaxGCPauseMills参数调小并不意味着垃圾收集的速度变快了,GC停顿时间缩短是以牺牲吞吐量和新生代空间换取的。解释是:收集300M新生代的时间肯定比收集500M新生代的时间短,这也直接导致了垃圾收集的频繁发生,原来10s收集一次,停顿时间为100ms,现在变成5s收集一次,但是停顿时间为70ms,造成吞吐量降低。

-XX:GCTimeRatio参数:直接设置吞吐量大小

注:GCTimeRatio参数默认值是99,相当于是吞吐量的倒数。也就是说默认允许最大1%(1/(1+99))的垃圾收集时间。

-XX:UseAdaptiveSizePolicy参数:相当于是一个开关,当这个参数打开之后,就不需要手工指定新生代的大小、Eden与Survivor区的比例、晋升老年代对象大小等细节参数,虚拟机会动态调节,也称为GC自适应的调剂策略。自适应的调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

到目前为止,上面三个收集器都是新生代收集器,还有三个老年代的收集器。

Serial Old收集器

它是Serial收集器的老年代版本

Parallel Old收集器

它是Parallel Scavenge收集器的老年代版本。

CMS收集器

1、运行示意图

CMS收集器

2、介绍

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短。

CMS收集器主要是基于“标记-清除”算法。整个过程分为四个步骤:1.初始标记。2、并发标记。3、重新标记。4、并发清除。下面详细介绍一下。

初始标记

这个阶段仅标记一下GC Roots能直接关联到的对象,速度很快,但需要”Stop The World”

并发标记

进行GC Roots Tracing的过程,刚才产生的集合中标记出存活对象,这个阶段的应用程序也在运行;并不能保证可以标记出所有的存活对象;

举个例子:并发标记就需要标记出 GC roots 关联到的对象的引用对象有哪些。比如说 A -> B (A 引用 B,假设 A 是 GC Roots 关联到的对象),那么这个阶段就是标记出 B 对象, A 对象会在初始标记中标记出来。

重新标记

为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录,需要”Stop The World”,且停顿时间比初始标记稍长,但远比并发标记短,采用多线程并行执行来提升效率;

简单解释一下:这边重新标记的对象是在之前并发标记的时候用户线程产生的新的对象。由于在并发标记的时候也会执行用户线程,此时用户线程产生的新的对象是没有进行标记过的,当然也不能直接判定它要被垃圾回收,这时候就需要进行重新标记一下。

并发清除

回收所有的垃圾对象。

总结:整个过程中耗时最长的并发标记和并发清除都可以与用户线程一起工作;所以总体上说,CMS收集器的内存回收过程与用户线程一起并发执行;

3、CMS的三个缺点

参考:https://blog.csdn.net/wxy941011/article/details/80616843

(A)、对CPU资源非常敏感

并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。CMS的默认收集线程数量是=(CPU数量+3)/4,当CPU数量多于4个,收集线程占用的CPU资源多于25%,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。

这时候就提出一种增量式并发收集器:”增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS),类似使用抢占式来模拟多任务机制的思想,让收集线程和用户线程交替运行,减少收集线程运行时间,但效果并不理想,JDK1.6后就官方不再提倡用户使用

(B)、无法处理浮动垃圾,可能出现”Concurrent Mode Failure”失败

(1)、浮动垃圾(Floating Garbage)

在并发标记时,用户线程新产生的垃圾,称为浮动垃圾,所谓的“浮动垃圾”,就是在并发标记阶段,由于用户程序在运行,那么自然就会有新的垃圾产生,这部分垃圾被标记过后,CMS无法在当次集中处理它们(为什么?原因在于CMS是以获取最短停顿时间为目标的,自然不可能在一次垃圾处理过程中花费太多时间),只好在下一次GC的时候处理。这部分未处理的垃圾就称为“浮动垃圾”。这使得并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集;也要可以认为CMS所需要的空间比其他垃圾收集器大;
-XX:CMSInitiatingOccupancyFraction:该值代表老年代堆空间的使用率。比如,value=75意味着第一次CMS垃圾收集会在老年代被占用75%时被触发。JDK1.5默认值为68%,JDK1.6变为大约92%。

(2)、”Concurrent Mode Failure”失败

如果CMS预留内存空间无法满足程序需要,就会出现一次”Concurrent Mode Failure”失败,这时JVM启用后备预案:临时启用Serail Old收集器,而导致另一次Full GC的产生,这样的代价是很大的,所以CMSInitiatingOccupancyFraction不能设置得太大。

(C)、产生大量内存碎片

由于CMS基于”标记-清除”算法,清除后不进行压缩操作,产生大量不连续的内存碎片会导致分配大内存对象时,无法找到足够的连续内存,从而需要提前触发另一次Full GC动作。

解决方法
(1)、”-XX:+UseCMSCompactAtFullCollection”

使得CMS出现上面这种情况时不进行Full GC,而开启内存碎片的合并整理过程;但合并整理过程无法并发,停顿时间会变长;默认开启(但不会进行,结合下面的CMSFullGCsBeforeCompaction)。

(2)、”-XX:+CMSFullGCsBeforeCompaction”

设置执行多少次不压缩的Full GC后,来一次压缩整理,为减少合并整理过程的停顿时间;默认为0,也就是说每次进入Full GC时都进行碎片整理。

总体来看,与Parallel Old垃圾收集器相比,CMS减少了执行老年代垃圾收集时应用暂停的时间,但却增加了新生代垃圾收集时应用暂停的时间、降低了吞吐量而且需要占用更大的堆空间;

G1收集器

1、运行示意图

G1收集器

2、介绍

G1收集器是当今收集器技术发展的最前沿成果之一,G1是一款面向服务端应用的垃圾收集器。同优秀的CMS垃圾回收器一样,G1也是关注最小时延的垃圾回收器,G1最大的特点是引入分区的思路,弱化了分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至CMS的众多缺陷。

主要分为以下几个步骤:

初始标记:这阶段仅仅只是标记GC Roots能直接关联到的对象并修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确的可用的Region中创建新对象,这阶段需要停顿线程,但是耗时很短。而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。

并发标记:从GC Roots开始对堆的对象进行可达性分析,递归扫描整个堆里的对象图,找出存活的对象,这阶段耗时较长,但是可以与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB(原始快照)记录下的在并发时有引用变动的对象。

最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。

筛选回收:负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。

筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划,比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region,尽量把GC导致的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。

与CMS的“标记–清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。

在《深入理解JAVA虚拟机》一书中并没有对G1收集器做过多的叙述,这边可以参考这几篇文章

Java G1深入理解

详解 JVM Garbage First(G1) 垃圾收集器

G1垃圾回收器详解

内存分配与回收策略

对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

大对象直接进入老年代

所谓大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden和Survivor区之间发生大量的内存复制。