JVM相关及其拓展(四) -- 垃圾收集器与内存分配策略

垃圾收集器与内存分配策略

垃圾收集器

1.概述

垃圾收集(Garbage Collection,GC):自动管理回收不再引用的内存数据需要完成的三件事情:

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

Java语言会自动管理和回收不再引用的内存数据,由垃圾回收机制来完成。Java自身提供了内存管理机制,应用程序不需要去关注内存如何释放,内存用完后,GC会去自动进行处理,不需要人为干预出现错误。

JVM相关及其拓展(一)-- JVM内存区域章节中介绍了JVM的内存区域。

其中程序计数器虚拟机栈本地方法栈随线程而生,随线程而灭。栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈操作,因此每一个栈帧分配多少内存基本是在类结构确定下来就已经是已知的。因此这几个区域的内存分配和回收都具备确定性。所以不需要过多考虑回收的问题,在方法结束或者线程结束后,内存就随着回收了,也就实现了内存的自动清理。

Java堆方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有在程序运行期间才知道会创建哪些对象,这部分的内存分配和回收是动态的。垃圾收集器关注的就是这部分的内存。

2.判断对象是否可以回收

在堆里面存放着几乎所有的对象实例,垃圾收集器在回收前需要去判断对象是否还被引用来决定是否回收,即找到那些不再被任何途径使用的对象。

  • 引用计数算法(Refrence Counting)

    给对象添加一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1;任何时候计数器为0的对象是不能再被引用的,可以被当做垃圾收集。

    优点:实现简单,判断效率高
    缺点:无法检测出对象之间相互循环引用,开销大(会发生频繁且大量的引用变化,带来大量的额外运算)。

  • 可达性分析算法(Reachability Analysis)

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

可达性分析算法

在Java语言中,可作为GC Roots的对象包括以下几种:

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

    优点:更加精确严谨可以分析出循环引用的情况

    缺点:实现复杂,效率低,分析过程中需要GC停顿(因为应用关系不能发生改变,需要停止所有Java线程)

3.对象是生存还是死亡

真正宣告一个对象死亡,至少要经历两次标记过程

  • 第一次标记

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

    • 没必要执行

      对象没有覆盖finalize()方法,或者finalize()方法已被虚拟机调用过。

    • 有必要执行

      对象会被放置在一个F-Queue的队列中,稍后会由一个JVM自动建立的、低优先级的Finalizer线程去执行。

  • 第二次标记:

    GC对F-Queue中的对象进行第二次小规模的标记,finalize()是对象摆除被回收的最后方法

    • 若对象要避免自己被回收,需要重新与引用链上的任何一个对象建立关系即可,譬如把自己(this)赋值给某个变量或者对象的成员变量,那就会移除被回收的集合
    • 如果没有摆除,则基本上会被回收。任何一个对象的finalize()方法只会被系统自动调用一次,再次调用finalize()方法则不会再次执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;

public void isAlive() {
System.err.println("It is live");
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.err.println("finalize is executed");
FinalizeEscapeGC.SAVE_HOOK = this;
}

public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();

SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.err.println("It is over");
}
//将对象的引用链重新置为null,则拯救失败
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.err.println("It is over");
}

}

}
  • finalize()

    运行代价高昂,不确定性大,无法保证各个对象的调用顺序。

判断Java对象是否存活

4.回收方法区

永久代的垃圾收集主要分为两部分:废弃常量和无用的类

废弃常量:假如常量池中存在一个常量,但是没有任何对象引用该常量,在发生回收的时候,该常量就会被系统清理出常量池,常量池中的其他类(接口)、方法、字段的符号引用类似。

无用的类:需要同时满足以下条件

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

5.垃圾收集算法

标记-清除算法(Mark-Sweep) 最基础的收集算法

算法分为标记清除两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。主要不足有两个:一个是效率问题(标记和清除两个过程的效率都不高);另一个是空间问题(标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前出发另一次垃圾收集动作)。

标记-清除算法

复制算法(Copying)

将可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象移到另一块上面,然后把已使用过的内存空间一次清理掉,这样使得每次都是对整个半区进行内存回收,内存分配时就不需考虑内存碎片等情况。

缺点:可用内存会减少一半;效率会随存活对象的升高而降低(当对象存活率较高的时候,需要更多的copy操作,导致效率降低)

整理算法

现在的商业虚拟机都采用这种收集算法来回收新生代。

提供了改良算法(基于弱代理论①):不是按照1:1的比例去划分内存空间,而是分为较大的Eden空间和两块较小的Survivor空间,在回收时将Eden和Survivor存活的对象移至到另一块Survivor空间上。HotSpot中Eden和Survivor的大小比例为8:1。在一般场景下足够使用,当Survivor空间不够使用时,需要依赖其他内存(代指老年代)进行分配担保②

弱代理论:1. 大多数分配了内存的对象存活不会太久,在年轻代就会死掉;2. 很少有对象从年老代变成年轻代。

分配担保:如果另外一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。若老年代也满了就会触发一次full GC,也就是新生代和老年代都会进行回收。

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

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

标记-整理算法

优点:1. 效率不随对象存活率升高而降低。 2. 不像标记-清除算法会产生大量的内存碎片(因为会进行一次整理,将存活对象集中到一端)。

缺点:除了需要进行标记,还需要整理过程,执行效率更低。

分代收集算法(Generational Collection)——主流收集算法

根据对象存活周期的不同将内存划分为几块,一般是把Java堆分成新生代和老年代和持久代(JDK8中移除),这样就可以根据各个年代的特点采用最适当的收集算法。
新生代中每次垃圾收集都会有大量的对象被回收,只有少量存活,就可以使用复制算法。

老年代中因为对象存活率较高,没有额外空间进行分配担保,所以必须使用“标记-清理”或者“标记-整理”算法。

  • 新生代(Young Generation):所有新生对象都会放在新生代,新生代的目标是尽快收集生命周期短的对象,每次GC过后只有少量存活。新生代发生的GC叫做Minor GC(频率较高,新生代Eden区满才触发)。新生代细分为Eden、From Survivor、To Survivor三块空间(三块空间大小并非均分,默认比例为8:1:1)。

    新生代的垃圾回收执行过程:

    1. Eden区 + From Survivor区存活的对象复制到To Survivor
    2. 清空Eden以及From Survivor
    3. From SurvivorTo Survivor进行交换
  • 老年代(Tenured Generation):新生代发生几次GC后依然存活的对象会放到老年代中,所以老年代中的对象生命周期较长。内存也比新生代大很多(大概2:1),当老年代内存满时会触发Full GC/Major GC(针对新生代和老年代触发,经常会伴随至少一次的Minor GC,收集频率较低且耗时长,一般慢10倍以上)

  • 持久代(Permanent Generation):用于存放静态文件,如Java类,方法等,对GC没有影响。

  • 拓展:别处也有介绍 Full GC针对整个堆空间(包含新生代,老年代,永久代(如果包含))的回收。而Major GC是针对老年代的内存回收。

  • Minor GC:新生代是堆的一部分,对象首先在新生代中分配。新生代填满时,垃圾收集器会暂停所有的应用程序,回收新生代空间。不再使用的对象会被回收,仍然使用的对象移动至其他地方。

  • Full GC: 对象不断地移至老年代,最终老年代也被填满,JVM需要找到老年代不再使用的对象并进行回收。会导致长时间停顿。

Java堆内存

6.垃圾收集器

收集算法是内存回收的方法论,垃圾收集器就为内存回收的具体实现。

垃圾收集器

根据上图分析新生代收集器主要是:Serial收集器,ParNew收集器,Parallel Scavenge收集器和G1收集器。老年代收集器为CMS收集器,Serial Old收集器,Parallel收集器和G1收集器

在两个收集器之间存在连线,则意味着他们之间可以搭配使用。

Serial收集器

该收集器是最基本、发展历史最悠久的收集器。这个收集器是一个单线程收集器(不仅是他只会使用一个CPU或者一条收集线程去完成垃圾收集工作,更重要的是他在进行垃圾收集时必须停止其他的工作线程(Stop The World),直到收集结束。进行Full GC时,还会对老年代空间对象进行压缩整理。)。

是虚拟机运行在Client端的默认新生代收集器

有着优于其他收集器的地方:

  • 简单而高效
  • 没有线程交互的开销,专心做垃圾收集可以获得最高的单线程收集效率

关键控制参数:

  • -XX:SurvivorRatio:设置两个Survivor区和Eden区的比值(8表示 1:1:8)
  • -XX:PretenureSizeThreshold:设定对象超过多少岁时进入老年代
  • -XX:HandlePromotionFailure:设置是否允许担保失败

ParNew收集器

ParNew收集器其实是Serial收集器的多线程版本。除了使用多线程进行垃圾收集之外,其他科Serial收集器完全一样。

该收集器是运行在Server模式下的虚拟机中的首选的新生代收集器,其中有一个重要的原因就是:除了Serial收集器外,目前只有它能和CMS收集器配合工作。随着可以使用的CPU数量增加,GC时系统资源的有效利用还是有好处的。默认开启的收集线程数与CPU的数量相同

并行(Parallel):指多条垃圾收集器线程并行工作,但此时用户线程仍处于等待状态

并发(Concurrent):指用户线程与垃圾收集器同时执行,用户程序仍继续运行,而垃圾收集器执行于另一个CPU上。

关键控制参数:

  • -XX:UserParNewGC:是否开启ParNew收集器

Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,使用了复制算法以及并行的多线程收集器。

该收集器的目标是:达到一个可控制的吞吐量(ThroughPut)①。

停顿时间越短越适合需要与用户交互的程序,良好的响应速度可以提升用户体验,高吞吐量就可以高效率的利用CPU时间,主要适合在后台运算而不需要太多交互的任务。

吞吐量:CPU用于运行用户代码时间与CPU总消耗时间的比值。

关键控制参数:

  • -XX:UseAdaptiveSizePolicy:开关参数,当打开时就不需要去指定新生代大小以及Eden与Survivor比例,晋升老年代对象岁数大小等参数,触发GC自适应调节策略(虚拟机会根据当前系统运行情况收集性能监控信息,动态调整这些参数已提供最适合的停顿时间或者最大的吞吐量)

Serial Old收集器

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

该收集器主要为了给Client模式下的虚拟机使用。如果在Server模式下,还有以下用途:

  • 在JDK1.5及之前的版本搭配Paraller Scavenge收集器
  • 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用

Parallel Old收集器

是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理算法”。

在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge + Parallel Old组合。

CMS收集器(Concurrent Mark Sweep)

以获取最短回收停顿时间为目标的收集器。基于“标记-清除算法”实现。整体上来说内存回收过程是与用户线程一起并发执行的。

CMS收集器

运作过程比较复杂,分为4个步骤:

  • 初始标记:仅仅标记一下GC Roots能关联到的对象,速度很快 触发Stop The World
  • 并发标记:进行GC Roots Tracing的过程
  • 重新标记:修正并发标记期间因用户程序继续运做而导致标记产生变动的那一部分对象的标记记录,停顿时间比初始标记长一些,但短于并发标记时间 触发Stop The World
  • 并发清除:可以和用户线程一起工作
CMS

CMS收集器有3个明显的缺点:

  1. 对CPU资源非常敏感

    面向并发设计的程序都对CPU资源比较敏感。在并发阶段,虽然不会导致用户线程停顿,但是会因为占了一部分CPU资源而导致线程变慢,吞吐量会降低。CMS默认启动的回收线程数量为(CPU数量+3)/4

  2. 无法处理浮动垃圾(Floating Garabge)

    由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就有新的垃圾产生,即浮动垃圾(这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉他们,只能留到下一次GC进行清理)

    因此CMS收集器不能像其他收集器一样等到老年代几乎完全满了在进行收集,需要预留一部分空间提供并发收集时使用。

    JDK1.5默认设置下,CMS收集器到老年代到了68%即会激活,到1.6时提高到了92%。

    要是CMS运行期间预留的内存无法满足程序需要,就会出现一次Concurrent Mode Failure失败,虚拟机将会启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,停顿时间就会变得很长了。

  3. 产生的空间碎片

    由于CMS是基于“标记-清除”算法实现的收集器。这种方式会产生大量的空间碎片,碎片过多时将会给对象分配来很大麻烦,往往会出现老年代还有很大空间剩余,当无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。

关键控制参数:

  • -XX:CMSInitiatingOccupancyFraction:设置CMS触发阈值即老年代对象占用空间

G1收集器

全称Garbage-First收集器,通过-XX:+UseG1GC参数来启用,在JDK9中,被提议为默认收集器。在JDK10中,把单线程的Full GC改良为了多线程Full GC

G1收集器

G1收集器是一款面向服务端的垃圾收集器,设计目标是为了取代CMS收集器。具备如下特点:

  • 并行与并发:使用多个CPU来缩短停顿时间,也会通过并发的方式让Java程序继续运行
  • 分代收集:分代概念在G1中得以保留,可以不需要其他的收集器配合管理整个堆,可以采用不同的方式去处理新创建的对象和旧对象。
  • 空间整合:整体基于“标记-整理”算法,局部(两个Region之间)采用“复制”算法实现
  • 可预测的停顿:除了追求低停顿外,还可建立可预测的时间停顿模型,用户可以指定期望停顿的时间
Region

在G1收集器之前其他收集器进行收集的范围都是整个新生代或者老年代,而G1可以通用。使用G1收集器,Java堆的内存布局就与其他收集器不同,将整个Java堆划分为多个大小相等的独立区域(Region),虽然保留了新生代老年代的概念,但他们都变成了一部分Region的集合。

可停顿的时间模型

可以实现有计划的避免在整个Java堆中进行全区域的垃圾收集。跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region空间。可以保证G1收集器在有限时间内获得尽可能高的收集效率。

Remembered Set

Region不可能是独立的,由于可能会被其他对象引用。在G1中,Region之间的对象引用以及其他收集器中的新生代老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。每一个Region都会对应一个Remembered Set,虚拟机发现在对Reference进行读写操作时,产生一个Write Barrier暂时中断写操作,检查对象引用是否位于不同的Region中,若是则通过CardTable记录相关引用信息到Remembered Set中。在进行内存回收时,在GC Roots中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。

stop-the-world

概念:除GC所需线程外,多有线程都要进如等待状态,直到GC任务完成。

解决方法:使用多个CPU来缩短停顿时间。

G1运作步骤
  • 初始标记:标记一下GC Roots能直接关联的对象,需要停顿线程
  • 并发标记:从GC Roots开始进行可达性分析,找出存活的对象耗时较长
  • 最终标记:修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,需要停顿线程,可并行执行
  • 筛选回收:对各个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间来指定回收计划。

如果应用追求吞吐量,那并不会带来特别的好处

ZGC收集器

在Java11 中引入的新型收集器

7.内存分配与回收策略

对象的内存分配,就是在堆上进行分配。

对象优先在Eden分配,就是在JVM的堆上进行内存分配

大对象直接进入老年代

大对象代指 需要连续内存空间的Java对象

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

当Eden区满了,在创建对象会触发Minor GC(执行Minor GC时,Eden空间存活的对象会被复制到To Survivor·空间,并且之前经过一次Minor GC在From Survivor存活并年轻的对象也会被复制到To Survivor空间。如果存活对象的分代年龄超过阈值,则会晋升到老年代。)

动态对象年龄判定

为了更好的适应不同程序的内存状况,并不需要永远要求对象年龄必须达到maxTenuringThreshold才可以晋升老年代,若在Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

如果另外一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。若老年代也满了就会触发一次full GC,也就是新生代和老年代都会进行回收。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!