理解JVM之GC垃圾回收器

392 阅读18分钟

这是我参与11月更文挑战的第17天,活动详情查看:2021最后一次更文挑战

GC主要用于回收、释放垃圾占用的空间。

需要回收的内存有哪些

我们知道,线程私有区域为程序计数器、本地方法栈、虚拟机栈。这些区域的数据随着线程的启动创建,随着线程的结束而销毁。虚拟机栈里的栈帧随着方法的进入顺序执行着入栈和出栈操作,一个栈帧需要多少内存取决于具体虚拟机实现并且在编译期间就已确定下来。当方法和线程执行完毕后,内存就会随着回收,因此无需担心。

而java堆和方法区不一样。方法区存放着类加载信息,但是一个接口中多个实现类需要的内存可能不太一样,一个方法中多个分支需要的内存也可能不一样(因为只有在运行期间才能知道这个方法创建了哪些对象,需要多少内存),这部分的内存分配和回收都是动态的,gc关注的也正是这部分的内存。

java堆是gc回收的重点区域,这里存放着所有对象实例,gc进行回收前,需要确认的第一件事情就是哪些对象存活,哪些对象死去。

堆的回收区域

为了高效地回收,java根据对象内存的存活时间或者对象大小,将堆分为三个区域。

新生代(年轻代)不稳定,易产生垃圾。

年老代对象比较稳定,不易产生垃圾。

永久代【1.8以后采用元空间,就不在堆中了】

之所以分开,是分而治之。根据不同区域的内存块的特点,采用不同的内存回收算法,从而提高堆中垃圾回收的效率。

新生代又可细分为三个区域,分别为Eden区和两个survior区,为区别survior区将其分别命名为from和to。其三个区域的默认比例为8:1:1(可以通过参数 –XX:SurvivorRatio 来设定)。

JVM每次只会使用Eden和from survivor区域来为对象服务,to survivor区域又可称为保留区域。

因此新生代里实际可用的内存空间占90%。

判断对象是否存活的算法

1. 引用计数法

原理是给对象添加一个引用计数器,每当对象被引用一次就加1,引用失效时就减1。当为0的时候就判断对象不会再被引用。 该算法的优点在于实现简单效率高,而当出现循环引用的问题时,即存在两个对象互相引用而它们已经不会再被其它引用时,导致计数一直不会为0就无法进行回收。

2. 可达性分析算法

它的基本思路是通过一个称为"GC Roots"的对象为起始点,搜索所经过的路径为引用链。当一个对象到GC Roots没有任何引用跟它连接,则证明对象是不可用的。该算法有效地解决了循环引用的问题。

需要注意的是,即使是找到了不可达的对象,也不是一定要马上回收,还可以抢救一下。

要真正宣告对象死亡需经过两个过程。

1.可达性分析后没有发现引用链

2.查看对象是否有finalize方法,如果有重写且在方法内完成自救[比如再建立引用],还是可以抢救一下,注意这边一个类的finalize只执行一次,这就会出现一样的代码第一次自救成功第二次失败的情况。
[如果类重写finalize且还没调用过,会将这个对象放到一个叫做F-Queue的序列里,这边finalize不承诺一定会执行,这么做是因为如果里面死循环的话可能会时F-Queue队列处于等待,严重会导致内存崩溃,这是我们不希望看到的。]

Tracing GC的根本思路就是: 给定一个集合的引用作为根出发,通过引用关系遍历对象图,能被遍历到的(可到达的)对象就被判定为存活,其余对象(也就是没有被遍历到的)就自然被判定为死亡。注意再注意:tracing GC的本质是通过找出所有活对象来把其余空间认定为“无用”,而不是找出所有死掉的对象并回收它们占用的空间。GC roots这组引用是tracing GC的起点。要实现语义正确的tracing GC,就必须要能完整枚举出所有的GC roots,否则就可能会漏扫描应该存活的对象,导致GC错误回收了这些被漏扫的活对象。

可被当作GC Roots的对象

①虚拟机栈(栈桢中的本地变量表)中的引用的对象,就是平时所指的java对象,存放在堆中。

②方法区中的类静态属性引用的对象,一般指被static修饰引用的对象,加载类的时候就加载到内存中。

③方法区中的常量引用的对象。---通常指声明为final的常量值

④本地方法栈中JNI(native方法)引用的对象

⑤Java虚拟机内部的引用。如基本数据类型对应的Class对象,常驻的异常对象(NullPointExcetion、OutOfMemoryError等),还有系统加载器

⑥ 所有被同步锁(synchronied关键字)持有的对象。 ⑦ 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

引用的分类

1. 强引用

普遍存在的引用赋值,即类似Object obj = new Object();任何情况下,只要这种强引用的关系还在,垃圾收集器就永远不会回收掉被引用的对象。

2. 软引用

描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。JDk1.2版后提供了SoftReference类来实现软引用。

3. 弱引用

弱引用也是来描述那些非必须对象,但它的强度会比软引用更弱一些,被软引用的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论内存是否足够,都会回收掉只被弱引用关联的对象。JDK1.2版后提供了WeakReference类来实现弱引用。

4. 虚引用

一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。虚引用的唯一目的只是为了能在这个对象被垃圾收集器回收时收到一个系统通知。JDk1.2版后提供了PhantomReference类来实现虚引用。

三大垃圾收集算法

1. 标记-清除算法
2. 复制算法
3. 标记-整理算法

1. 标记-清除算法

老年代采用标记-清除或标记-整理算法。

概念: 标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象;然后在清除阶段,清除所有未被标记的对象。

Tip:

  1. 标记的过程其实就是,遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活对象。
  2. 清除的过程就是将遍历堆中的所有对象,将没有标记的对象全部清除掉。
  3. 也就是说,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。
  4. 之所以要在停止程序运行后再去执行标记/清除算法,,原因是为了防止在标记过程中,程序新建了一个对象,导致该对象错过了标记阶段。在清除阶段来临时,该新建对象就会因未被标记而莫名清除掉。
  5. 因此我们可以知道该算法实际上是有个很大的缺点的。当需要遍历的堆越大,它所暂停的时间就越长。可见其效率是非常低的。

2. 复制算法

新生代采用复制算法回收垃圾。该算法在存活对象比例很少的情况下非常高效

概念: 将原有的内存空间分为两块,每次只使用其中一块。在垃圾回收时,将正在使用的内存中的存活对象全部复制到未使用的内存块中。之后,清除正在使用的内存块中的所有对象,交换两个内存块的角色,完成垃圾回收。

复制算法的缺点是在于空间的浪费。因为复制算法每次都是使用一半的内存进行对象的存放,另一半内存作为保留区域。这样做就相当于浪费了50%的内存空间。

而在java虚拟机中,由于新生代中的对象98%都是"朝生夕死"(即存活不了多久),故其内存不需要按照1:1的比例进行划分。而是将内存划分为一个Eden区和两个survivor区,其比例为8:1:1。每次使用时都会留下一个survivor区作为保留区域。当回收时,将Eden区和from survivor区存活的对象一次性复制到to survivor区中。这样做的一个好处是只有10%的空间浪费,90%的内存空间都将用于存放新生对象。

但是我们没有办法保证每一次回收的存活对象都不大于10%,故我们规定,当保留区域survivor区域不够用时,需依赖老年代的内存区域,即把大对象直接放入老年代。

每次从From servivor到To servivor移动时都存活的对象,年龄就加1,当年龄到达15(默认配置是15)时,升级为老年代。

3. 标记-整理算法

老年代采用的回收算法为标记-整理算法。

概念: 标记-整理算法适用于对象存活较多的场合,如老年代。它以标记-清除为基础做了进一步的优化,与标记-清除类似,标记-整理也是先从根节点遍历,对可达对象进行标记;但之后它并不是简单的清除未被标记的对象,而是将所有的存活对象压缩到内存的一端;之后清除边界外的所有空间。

标记: 遍历GC Roots对象,将可达对象进行标记。

整理: 移动所有存活的对象,且按照内存地址依次排列,然后将末端内存地址以后的内存全部回收。因此第二阶段才称为整理阶段。

标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。但是标记/整理算法唯一的缺点就是效率也不高。

由于它不仅要标记所有存活对象,而且还要整理所有存活对象的引用地址。从效率上来说,标记-整理算法要低于复制算法。

三个算法的区别比较: (>表示前者要优于后者,=表示两者效果一样)

(1)效率:复制算法>标记/整理算法>标记/清除算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。

(2)内存整齐度:复制算法=标记/整理算法>标记/清除算法。

(3)内存利用率:标记/整理算法=标记/清除算法>复制算法。

4. 分代收集算法

当前商业虚拟机的GC都是采用“分代收集算法”。该算法实际就是根据对象存活周期的不同将内存划分为几块。一般把java堆中分为新生代和老年代。短命对象归为新生代,长命对象归为老年代。

该算法则根据该特点对不同对象采用不同的收集算法。对于少量存活的新生代中,采用复制算法。对于大量存活的老年代中,由于对象存活率较高,没有额外空间对他进行分配担保,故适合采用标记-清除或标记-整理算法。

Minor GC与 Full GC

  1. Minor GC(Young GC)指的是年轻代gc回收的过程

  2. Major GC(Old Gc)指的是老年代gc回收的过程。

  3. Full GC指的是收集整个Java堆和方法区的垃圾收集。

  4. 触发条件

    Minor GC触发条件

    当Eden区满时,会触发Minor GC。

    Full GC触发条件:

    (1)调用System.gc时,系统建议执行Full GC,但是不必然执行。

    (2)老年代空间不足时。

    (3)方法区空间不足时。

    (4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存

    (5)由Eden区、From survivor区向To survivor区复制时,对象大小大于To survivor可用内存,则把该对象转存为老年代,且老年代的可用内存小于该对象的大小。

垃圾收集器

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是具体的实现。jvm会结合针对不同的场景及用户的配置使用不同的收集器。

年轻代收集器 : Serial、ParNew、Parallel Scavenge

老年代收集器 : Serial Old、Parallel Old、CMS收集器

特殊收集器 : G1收集器[新型,不在年轻、老年代范畴内]

image

垃圾收集器,连线代表可结合使用。

新生代收集器

1. Serial收集器

Serial是单线程收集器,它只能使用一条线程进行收集工作,在收集的时候还必须停掉其它线程,等待收集工作完成后其它线程才能继续工作。 优点在于对于Client模式下的jvm来说是个好的选择,适用于单核CPU。缺点是收集时暂停其它线程,对多线程来说浪费资源。

2. ParNew收集器

可以看作是Serial收集器的升级版,因为它支持多线程,而且收集算法、Stop The World、回收策略和Serial一样。其实就是可以有多个GC线程并发运行,它是HotSpot第一个真正意义上实现并发的收集器。默认开启的线程数与当前cpu数量相同,可以通过-XX:ParallelGCThreads来控制垃圾收集线程的数量。

优点:

1.支持多线程,多核CPU下可以充分的利用CPU资源

2.运行在Server模式下新生代首选的收集器【重点是因为新生代的这几个收集器只有它和Serial可以配合CMS收集器一起使用】

缺点: 在单核下表现不会比Serial好,由于在单核能利用多核的优势,在线程收集过程中可能会出现频繁上下文切换,导致额外的开销。

3. Parallel Scavenge

采用复制算法的收集器,和ParNew一样支持多线程。

但是该收集器重点关心的是吞吐量【吞吐量 = 代码运行时间 / (代码运行时间 + 垃圾收集时间) 如果代码运行100min垃圾收集1min,则为99%】

对于用户界面,适合使用GC停顿时间短,不然因为卡顿导致交互界面卡顿将很影响用户体验。

对于后台,高吞吐量可以高效率的利用cpu尽快完成程序运算任务,适合后台运算

Parallel Scavenge注重吞吐量,所以也成为"吞吐量优先"收集器。

老年代收集器

1. Serial Old

和新生代的Serial一样为单线程,Serial的老年代版本,不过它采用"标记-整理算法",这个模式主要是给Client模式下的JVM使用。

如果是Server模式有两大用途

1.jdk5前和Parallel Scavenge搭配使用,jdk5前也只有这个老年代收集器可以和它搭配。

2.作为CMS收集器的后备。
2. Parallel Old

支持多线程,Parallel Scavenge的老年版本,jdk6开始出现, 采用"标记-整理算法"【老年代的收集器大都采用此算法】

Parallel Old的出现结合Parallel Scavenge,真正的形成“吞吐量优先”的收集器组合。

3. CMS

CMS收集器(Concurrent Mark Sweep)是以一种获取最短回收停顿时间为目标的收集器。【重视响应,可以带来好的用户体验,被sun称为并发低停顿收集器

启用CMS:-XX:+UseConcMarkSweepGC

CMS采用的是"标记-清除"(Mark Sweep)算法,而且是支持并发(Concurrent)的。

它的运作分为4个阶段

1.初始标记:标记一下GC Roots能直接关联到的对象,速度很快;

2.并发标记:GC Roots Tarcing过程,即可达性分析;

3.重新标记:为了修正因并发标记期间用户程序运作而产生变动的那一部分对象的标记记录,会有些许停顿,时间上一般 初始标记 < 重新标记 < 并发标记

4.并发清除

以上初始标记和重新标记需要stw(stop-the-world,停掉其它运行java线程)

之所以说CMS的用户体验好,是因为CMS收集器的内存回收工作是可以和用户线程一起并发执行。

总体上可以说CMS是款很优秀的垃圾收集器,但它也有着缺点

  1. cms垃圾收集器对于多核cpu来说能很好地发挥优势,由于cms默认配置启动的时候垃圾线程数为(cpu数量+3)/4,它的性能很容易受cpu核数的影响。当cpu数量少时比如为2核,则此时需分一半给cms运作,这对于计算机压力大时会很大程度对计算机性能产生负面影响。
  2. cms无法处理浮动垃圾,可能导致Concurrent Mode Failure(并发模式故障)而触发full GC。
  3. 由于cms采用标记-清除算法,因此就会存在垃圾碎片的问题,为了解决这个问题cms提供了 ==-XX:+UseCMSCompactAtFullCollection选项,这个选项相当于一个开关【默认开启】,用于CMS顶不住要进行full GC时开启内存碎片合并,内存整理的过程是无法并发的,且开启这个选项会影响性能(比如停顿时间变长)

浮动垃圾:由于cms在运作时用户线程也在运作,程序运行时可能会产生新的垃圾,这里产生的垃圾就是浮动垃圾,cms无法档当次处理,得等到下次才行。

4. G1收集器

G1(garbage first)指尽可能多收垃圾,避免full gc的发生。它解决了CMS产生空间碎片等一系列缺陷,是用于替代cms功能更为强大的新型收集器。

摘自甲骨文:适用于 Java HotSpot VM 的低暂停、服务器风格的分代式垃圾回收器。G1 GC 使用并发和并行阶段实现其目标暂停时间,并保持良好的吞吐量。当 G1 GC 确定有必要进行垃圾回收时,它会先收集存活数据最少的区域(垃圾优先)

g1的特别之处在于它强化了分区,弱化了分代的概念,是区域化、增量式的收集器,它不属于新生代也不属于老年代收集器。
用到的算法为标记-清理、复制算法

jdk1.7,1.8都是默认关闭的。 开启选项 ==-XX:+UseG1GC== , 比如在tomcat的catania.sh启动参数加上。

默认垃圾收集器:

jdk1.7和jdk1.8 默认垃圾收集器: Parallel Scavenge(新生代)+Parallel Old(老年代)

jdk1.9 默认垃圾收集器G1

-XX:+PrintCommandLineFlagsjvm参数可查看默认设置收集器类型

-XX:+PrintGCDetails亦可通过打印的GC日志的新生代、老年代名称判断


参考文章

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

深入理解JVM-内存模型(jmm)和GC

GC为什么要分代

java堆内存--新生代与老生代

java虚拟机详解4---GC算法与种类