深入理解JVM(四):垃圾收集器和内存分配策略

1.哪些内存该回收

我们要讨论内存的回收,先要判断哪些内存要回收。前面我们介绍了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈这三个区域随着线程而生,随着线程而灭。这几个区域的内存分配和回收都具有确定性,在几个区域就不需要过多的考虑回收的问题,因为方法结束或者线程结束,内存自然就跟着回收了。而Java堆和方法区则不一样,需要我们去研究

1.1堆中需要回收的对象

堆中存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就是确定这些对象是否需要回收。判断对象是否存活的算法有两种:引用计数算法、可达性分析算法

(1)引用计数算法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。

引用计数算法实现简单,判断效率也很高,但是它很难解决对象之间相互引用的问题,主流的Java虚拟机中并没有选用引用计数算法来管理内存。

(2)可达性分析算法

通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是GC Roots到这个对象不可达)时,则证明此对象是不可用的。

在Java中可作为GC Roots的对象包含了下面几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI(即一般说的Native方法)引用的对象
关于引用

在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用4种,这4种引用依次逐渐减弱。(上面的计数算法和可达性分析算法中的引用的概念该应该是强引用的

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

即使在可达性分析算法中不可达的对象,也并不是“非死不可”的,这时候它们暂时处于“缓刑”阶段,真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后没有与GC Roots相连接的引用链,那么它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法。当对象没有覆盖finalize方法或者finalize方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判断有必要执行finalize方法,那么这个对象将会被放置在一个F-Queue的队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但是不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize方法中执行缓慢,或者发生了死循环,将很有可能导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize方法是对象逃脱死亡最后的机会,稍后GC将堆F-Quue中进行第二次小规模的标记,如果对象要在finalize中拯救自己——只要重新与引用链上的任何一个对象建立关联即可但是任何一个对象的finalize方法都自会被系统调用一次

1.2回收方法区

方法区的在jdk1.6中是使用永生代实现的,在jdk1.7中将常量区移除永生代,在jdk1.8中移除了永生代。

永生代的垃圾回收两部分分为:废弃常量和无用的类。

回收废弃常量与回收Java堆中的对象非常类似。以常量池中的字面量的回收为例子,假如一个字符串“abc”已经进入了这个常量池中,但是目前系统中没有任何一个String对象是叫作“abc”的,换句话说,就是没有任何String对象引用常量池中的“abc”常量,也没有其他的地方引用这个字面量,如果发生内存回收,这个“abc”常量就会被清理出常量池。

判定一个常量是否“废弃常量”比较简单,而且判定一个类是否是“无用的类”的条件则苛刻许多。类需要同时满足下面三个条件才算是“无用的类”:

  1. 该类所有的实例都已经被回收了,也就是Java堆中不存在该类的实例
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法再任何地方通过反射访问到该类的方法。

满足了上面的三个条件,仅仅是是可以回收,而不是必然回收。

在大量使用反射、动态代理、CGLib等字节码框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

2.怎么回收

2.1 垃圾回收的算法(理论知识)
2.1.1 标记——清除算法

算法分为“标记”和“清除”两个阶段:首先标记出需要回收的对象,在标记完成之后统一回收,所有被标记的对象。(标记的部分就是上面1中讲述的过程)

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

之所以说它是基础的收集算法,是因为后续的收集算法都是基于这种思路对其不足进行改进而得到的。

2.1.2 复制算法

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

现在虚拟机都采用这种收集算法来回收新生代,将新生代分为一个Eden(80%),两个Survivor(各占10%)。当回收时,将Eden和Survivor中还存活的对象一次性复制到另外一块Survivor空间上,最后清理Eden和刚刚用过的Survivor空间。我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够时,需要依赖其他内存进行分配担保。

2.1.3 标记——整理算法

复制收集算法在对象存活率较高的时候就要进行较多的复制操作,效率就会变得很低。老年代一般不采用这种方法。

标记——整理的标记过程和标记——清除的算法一样,但是后续的过程是让存活的对象向一端移动,然后清理掉端边界以外的内存

2.1.4 分代收集算法

根据对象存活周期的不同将内存划分为几块,一般把堆分为新生代和老年代,这样就可以根据每个年代的不同采用最适合的收集算法。

在新生代中,每次垃圾收集时都会发现大批的对象死去,只有少量的对象存活,就选用复制算法。

老年代因为对象存活率高,没有额外的空间进行分配担保,就必须采用“标记——清除”或者“标记——整理”。

2.2 具体实现的对一些细节的完善

之前我们介绍了关于对象存活的判定算法和垃圾收集算法,而HotSpot的虚拟机在实现这些算法的时候,必须对细节进行一些完善,才能保证虚拟机的高效运行。

2.2.1枚举根结点

从可达性分析中从GC Roots节点找引用链这个操作为例子,一个是遍历去查找引用,必然会消耗很多的时间。在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到目的的,在类加载的时候,会在特定的位置(安全点)使用OopMap记录下哪些位置是引用。GC只用扫描OopMap即可得知了。

还有一个是为了保证一致性,必须导致GC在枚举根结点的时候要停顿所有的Java执行线程。(即使是在号称几乎不会发生停顿的CMS的收集器中)

2.2.2安全点

在OopMap的协助下,HotSpot可以快速准确的完成GC Roots枚举,能够是OopMap内容变化的指令非常多,如果每个条指令都生成对应的OopMap,那将会浪费大量的空间。

实际上,HotSpot只在“安全点”记录了这些信息。程序执行并非在所有的地方都能停顿下来开始GC,只有到达安全点才能暂停。

安全点的选取既不能太多也不能太多,太少会导致一次标记时等待时间过长,太多会浪费大量的空间。安全点选定基本上是方法调用、循环跳转、异常跳转这种指令序列复用。

另一个考虑的问题是,如何在GC发生的时候让所有的线程都跑到安全点上停顿下来,这里有两种方案:抢先式中断和主动式中断。

其中抢断式中断,是把所有的线程全部中断,如果发现线程中断的地方不在安全点上,就恢复线程,让它跑在安全点上,现在几乎没有虚拟机采用抢先式中断来暂停线程从而响应GC事件。

而主动中断思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单设置一个标志位,各个线程执行时主动轮询这个标志为真时自己中断挂起,轮询标志的地方和安全点是重合的,另外加上创建对象需要分配内存的地方。

2.2.3安全区域

使用Safepoint似乎已经完美解决了如何进入GC的问题,但是如果线程是处于Sleep状态或者Blocked状态时,线程无法响应JVM的中断请求。对于这种情况就需要安全区域来解决。

安全区域是指在一段代码片段之中,引用关系不会发生变化。JVM发起GC时,就不用管标志自己为Safe Region状态的线程了。在线程要离开Safe Region,它要检查系统是否完成了根节点的枚举,如果完成了,线程就继续执行,否则它必须等待直到可以安全离开Safe Region的信号为止。

2.3 垃圾收集器

上面2.1介绍了一些理论知识,2.2让理论靠近现实,而2.3介绍的收集器就是具体的实现。

这里讨论的收集器基于JDK1.7Update 14之后的HotSpot虚拟机(在这个版本正式提供了商用的G1收集器)

我们应该针对具体的应用采用最适合的收集器。如果有一种任何场景都适用的收集器的存在,那么HotSpot虚拟机就没必要实现这么多收集器了。
在这里插入图片描述

2.3.1 Serial收集器

Serial收集器是最基本的、发展历史最悠久的收集器,曾经是虚拟机(jdk1.3.1之前)新生代收集唯一的选择。这是一个单线程收集器,它在进行垃圾收集的时候,必须暂停所有的工作线程直到它收集结束。
这对很多应用来说是难以接受的。

下面是Serial和Serial Old收集器的运行过程:
在这里插入图片描述

从JDK1.3开始,一直到最新的JDK1.7,HotSpot虚拟机从Serial收集器到Parallel收集器再到Concurrent Mark Sweep(CMS)乃至GC收集器的最新成果Garbage First(G1)收集器,用户线程停顿的时间不断的在缩短,但是仍然没有办法消除。

Serial的适用场景
Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择。原因如下:
(1)在用户的桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,停顿的时间完全可以控制在十几毫秒至多一百毫秒以内,只要不是频繁发生,就能够接收。
(2)对于限定的单个CPU的环境来说(或者说在CPU数量比较少的情况下,客户端的线程一般比较少),Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高单线程的效率。

2.3.2 ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为都和Serial收集器完全一样。

ParNew收集器和Serial Old收集器工作过程如下图:
在这里插入图片描述

ParNew的适用场景
许多运行在Server模式下的首选新生代收集器。原因如下:
(1)除了Serial收集器之外,只有它能够与CMS(真正意义上的并发收集器,是老年代收集器)收集配合工作。
(2)ParNew收集器在单CPU的环境中决定不会比Serial收集器有更好的效果,甚至由于线程交互的开销,该收集器通过超线程技术实现的两个CPU环境中不能百分之百的超越Serial收集器。但是随着CPU的数量的增加,它对GC时系统资源的利用是很有好处的。(服务器超过32个逻辑CPU的情况越来越多)。

2.3.3 Parallel Scavenge收集器

Parallel Scavenger收集器和之前介绍的两个一样都是复制算法,也是多线程的收集器。但是它的关注点是达到一个可控制的吞吐量。所谓吞吐量就是CPU用于运行用户代码的时间和CPU总消耗的时间的比值,即吞吐量=运行用户代码的时间/(运行用户代码时间+垃圾收集时间)。

ParallelScavenger收集器提供了两个参数用于精准控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRation参数。

Parallel Scavenger使用场景
高吞吐量适合后台运算而不需要太多的交互任务

2.3.4 Serial Old 收集器

Serial Old是Serial收集器的老年版,它同样是一个单线程的收集器,使用“标记——整理”算法。

下面还是Serial和Serial Old的收集器的运行示意图:
在这里插入图片描述

Serial Old的应用场景
(1)客户端:主要意义也是在于给Client下的虚拟机使用。
(2)服务器端:
1.在JDK1.5以及之前搭配Scavenge收集器使用
2.作为CMS的后备预选方案,在并发收集发生Concurrent Mode Failure时使用。

2.3.5 Parallel Old收集器

Parallel Old是Parallel Scavenger收集器的老年版本。使用多线程和“标记——整理”法。

Parallel Old收集器的应用场景
配合Parallel Scavenge收集器使用,在注重吞吐量的场合都可以考虑Parallel Scavenge加上Parallel Old收集器。

在此之前Paralle Scavenge收集器一直处于比较尴尬的状态,原因是新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器之外别无选择。由于老年代使用Serial Old收集器在服务端应用性能上的“拖累”,使用了Parallel Scavenge收集器未必能在整体应用上获得吞吐量最大化的效果。有了Parallel Old才使得整体发挥出预期的效果 。

下面是Parallel Scavenge 和 Parallel Old的工作过程:
在这里插入图片描述

2.3.6 CMS收集器

在介绍CMS之前我们先来关注一下什么是并发(Concurrent)和并行(Parallel)。并行指的是多条垃圾收集线程并行工作,但是用户线程还处于等待状态。(一个时间段的干两件及以上的事)并发指的是用户线程和垃圾收集线程同时执行,用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。(同一时刻干两件及以上的事)

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。Parallel Scavenger是注重垃圾回收线程参与时间是最短的,而CMS注重的是停止用户程序的时间是最短的)并发收集,低停顿。

CMS的应用场景:
注重服务的响应速度的互联网网站或者B/S系统的服务端上。

从名字上可以看出,CMS收集器是基于“标记——清除”算法实现的,它的运行过程相对于前面几种收集器来说更复杂一些。整个过程分为四步:

  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清除

下面是CMS的工作过程:
在这里插入图片描述

其中,初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记GC Roots能直接关联到对象,速度很快。并发标记就是进行GC Roots Tracing的过程。而重新标记就是为了修正并发标记期间因用户继续运作而导致的标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一点,但是远比并发标记的时间短。

由于整个过程中耗时最长的并发标记和并发清除的线程都可以于用户线程一起工作。所以从总体上来看,CMS收集器的内存回收过程是与用户线程一起并发执行的。

但是它有三个明显的缺点:

(1)CMS对CPU资源非常敏感
在并发阶段,它虽然不会导致用户线程停顿,但是因为占了一部分线程导致应用程序变慢,总吞吐量会下降,但随着CPU数量的增加,占用CPU资源的比例会下降。为了应付这种情况,提供了一种“增量式并发收集器”的CMS收集器变种,在并发标记、清理的时候尽量让GC线程、用户线程交替运行,尽量减少线程独占资源的时间。但实践证明:效果很一般。

(2)CMS收集器无法处理浮动垃圾
由于CMS并发清理阶段用户线程还在运行着,伴随着程序运行自然就还会有限的垃圾不断产生,这部分只能下次GC的时候再去清理掉。由于垃圾回收阶段用户线程还在运行,所以得预留一部分空间给用户线程使用,如果预留的内存不够使用,就会出现“Concurrent Model Failure”失败,这时候会启动后备预案:临时启用Serial Old 收集器来重新进行老年代的垃圾收集。

(3)会有大量的空间碎片产生
因为CMS是一款基于“标记——清除”算法实现的收集器,如果读者对签名这种算法介绍还有印象的话,这可能想到意味着收集结束时会产生大量的空间碎片。CMS收集器提供了一个-XX:+UserCMSCompactAtFullCollection开关参数,用于CMS收集器顶不住要进行FullGC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片没有了,但是停顿的时间不得不变长。

2.3.7 G1收集器

G1的应用场景
G1是一款面向服务端应用的垃圾收集器。HotSpot团队赋予它的使命就是未来可以替换掉JDK1.5中发布的CMS收集器。

G1相比其他收集器有以下特点:
(1)并发:和CMS一样通过并发的方式让Java程序继续执行
(2)分代收集:和其他收集器一样,分代概念在G1中得以保存,G1可以不需要其他的收集器配合就能够独立管理整个GC堆,但是它能够采取不同的方式去处理新创建的对象和已经存活了一段时间的对象。
(3)空间整合:G1从整体上看是基于“标记——整理”算法实现的收集器,从局部(两个Region之间)是基于“复制算法”实现的。都意味着运行期间不会产生内存空间碎片,收集后能够提供规整的可用内存。
(4)可预测的停顿:降低停顿时间是G1和CMS共同关注的点,但是G1还能建立停顿时间模型,能够明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集器时间不得超过N毫秒。

下面就是G1的运作步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 晒选标记

在这里插入图片描述

G1收集器将整个Java堆划分成了多个大小相等的独立区域,但是新生代不再是物理隔离的了,它们都是一部分Region的集合。

G1收集器之所以能够建立停顿时间模型就是因为它可以有计划的进行了全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据运允许的收集时间,优先回收价值最大的Region。

G1中的每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中,如果是,便通过CardTable把相关的信息记录到被引用对象所属的Region的Remembered Set 之中。当GC根节点的枚举范围中加入Remembered Set 即可保证部队全堆进行扫描。虚拟机将并发标记期间对象变化的记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。

3.什么时候回收

Minor GC触发的条件
当Eden区满时,触发MinorGC。

Full GC触发的条件
(1)老年代空间不足的时候
下图描述了对象进入老年代4种情况在这里插入图片描述
1.大对象直接进入老年代
大对象指的就是需要大量连续空间的Java对象。虚拟机提供了一个-XX:PertenureSize Threshold参数,令这个对象直接在老年代中分配避免了Eden区及两个Survivor区之间的发生大量的内存复制。

2.分配担保机制
Eden区满时,进行Minor GC,当Eden和一个Survivor区中依然存活的对象无法放入到Survivor中,则通过分配担保机制提前转移到老年代中。在发生MinorGC之前,虚拟机会先检查老年代最大的可用的连续空间是否会大于新生代所有的对象总空间。如果不成立,则看HandlePromotionFailure设置值是否允许担保失败,如果不允许则进行FullGC。如果允许担保失败则检查老年代最大连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险。如果进行MinorGC后发现老年代不够存储,则会出现HandlePromotionFailure失败,会重新发起一次FullGC。
在这里插入图片描述

3.动态对象年龄判定
为了更好的适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold要求的年龄

4.长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC 后仍然存活,并且能够被Survivor容纳的话,将被移动到Survivor空间,并被移动到Survivor空间中,并且对对象年龄设置为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加一岁,当它增加到一定程度,就会晋升到老年代中。

(2)永久代空间不足的时候(百度上说的,不是很理解)

(3)调用System.gc()
不保证一定会触发,但非常多情况下它会触发 Full GC,从而添加Full GC的频率,也即添加了间歇性停顿的次数。(强烈影响系建议能不使用此方法就别使用,让虚拟机自己去管理它的内存,可通过通过-XX:+ DisableExplicitGC来禁止RMI调用System.gc。)

关于Minor GC的详细过程可以查看这篇博客:
https://blog.csdn.net/weixin_39788856/article/details/80388002

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值