查漏补缺 Java 系列 - JVM 垃圾回收

查漏补缺 Java 系列 - JVM 垃圾回收

正如一间房子,房子的主人肯定需要在客人来的时候进行一些收拾,让家里显得更加的大,而让客人有更好的体验。JVM 也是一样,你想想,JVM 是不是也要不定时的清理下内存区域空间以便能让更多的线程有更好的执行体验?那主人在进行清理的时候,至少要知道要对哪个区域进行清理吧?要知道在哪个区域清理哪些垃圾吧?清理的过程是用拖把还是用扫把,要有个选择吧?你想想 JVM 是不是也应该是类似的?

JVM 应该回收哪个区域的垃圾?

上一篇,我们介绍了 jvm 中一些基础概念,包括运行时内存区域。其中我们知道 jvm 规范中规定了5个运行时区域,一器两栈一堆一区。程序计数器,Java 虚拟机栈,本地方法栈,堆,方法区【运行时常量池(方法区内部)】;

JVM 这些区域里面我们可以分两类,一类需要重点关注 GC,一点可以不需要 GC。根据它们的生命周期可以看到虚拟机栈、本地方法栈、程序计数器这三个线程私有的区域,他们的生命周期跟线程的生命周期保持一样,他们的内存分配和回收时间是可以确定的,所以基本上用不上 GC;而方法区,堆他们需要在程序实际运行时才能知道需要创建哪些对象分配多大的空间,所以这部分是重点要关注的 GC 区域;

我们根据分配的时机来确定了需要进行垃圾回收的两个区域:堆,方法区。但是在 jvm 规范中对方法区进行了一个定义,也就是这块区域逻辑上属于堆,但是它可以不用 GC(上篇文章我有说到)。因此我们将重点放到堆(Heap)的回收中。

好了,我们现在知道哪个区域需要进行 GC 了,那么 JVM 又如何知道哪些是垃圾需要清理,哪些不是垃圾不需要清理了?因为堆主要是用来存放对象的,我们也可以当做,jvm 应该怎么判读一个对象是不是应该被回收?

JVM 怎么判断是不是要回收的对象?

判断一个对象是不是应该被回收,通常是认为这个对象是否存活(有用)。判断的方式一般可以选择下面两种方法:1,引用计数法;2,可达性分析法。

引用计数法就是给对象加一个引用计数器,每一次被引用到就加1,解除引用就减1。如果为 0 那就认为是需要被回收的,不能再继续使用了。这种方法效率高,一般情况下其实还不错,但是有个隐藏的问题,那就是对象之间的循环引用问题。

什么是循环引用呢?看下面的代码你就能很好地理解了:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CircularReference {
public Object instance = null;

public static void main(String[] args) {
CircularReference objA = new CircularReference();
CircularReference objB = new CircularReference();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;

}
}

另一种方式就是可达性分析法,可达性分析就是从一个起始点出发(GC Roots),从这个节点往下搜索节点,两个点之间的路径叫做引用链条(Reference Chain)。当一个对象到 GC Roots 之间没有任何一条引用链,我们判断这个节点是可回收的对象。

可达性的图类似于下面这样:

2020-04-05-14-59-07

可达性分析可以解决循环引用的问题,你看上图的 a,b两个对象。从 GC Root 到 a,b 两者间是没有引用链的,所以是可以认为a,b 可以回收。但是 a,b 一定会回收吗?可达性分析的过程中,会要进行两次标记才能确定对象是否可以被回收,第一次可达性分析中,会将没有引用链的对象进行第一次标记并且进行一次筛选。筛选的条件主要是看对象是否有必要执行finalize()方法,如果对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用了,虚拟机认为这两种情况都属于“没有必要执行”。所以在被回收前,他们还是有一次机会进行“自救”的。如果第二次标记的时候对象还是没有引用链,那么就真的被回收了。

虚拟机进行了一轮筛选后会将有必要执行 finalize()方法的对象发到一个 F-Queuen 的队列中,然后创建一个 Finalizer 线程去执行它。这里要注意虚拟机会触发执行它,但不承偌会等待其运行完,这样做的目的是为了防止 finalize() 方法执行缓慢,或者死循环,这样会导致 F-queuen 里面的所有对象都会处于永久等待状态,也可能导致整个内存回收系统崩溃。

这是两种常用的判断对象是否可以进行回收的方法。

我们举个例子子说明:
假设现在你要堆家里进行大扫除,你需要整理一些垃圾扔掉,你应该怎么做?

如果你在家里做过大扫除,你肯定需要对物品进行判断是不是应该进行清理,你判断的方式可能是两种:1. 物品是属于谁的(开玩笑,要是你把女朋友的口红扔了试试?);2. 物品还能不能用?

假设你跟你女朋友住一起,在打扫客厅的时候看到了一个空的薯片桶盒,你会想这是谁吃的,自己没吃过,问了女朋友,她也说没吃过。好了,反正就是没人认啦,然后这个盒子对于你是否有用了?至少对于大部分人来说是没用了(废物回收者除外),所以你就判断它是垃圾了。对吧。这是不是有点类似于引用计数法?有人用就+1,没人用就-1,为 0 就回收啊。

然后,你在打扫沙发的时候发现了一个未拆封的礼品盒,你确认这不是你的。然后你问你女朋友,这是不是她的?她说,这不是她的,但是这是她给她闺蜜买的礼物。这个时候你可以认为这个礼物属于女朋友闺蜜,女朋友闺蜜和女朋友有关联。你看这是不是可达?这个礼品盒(对象)和GC ROOT(女朋友)之间是不是有个引用链?那这个肯定就不是垃圾了。对吧。

JVM 该怎么回收垃圾了?

刚也说了,垃圾已经找出来了,但是现在就是要清理垃圾了。你把客厅的垃圾都扫出来了,总不可能就放在哪里不管了吧?

在 jvm 中有四个主要的垃圾收集算法:

  1. 标记-清除算法;
  2. 复制算法;
  3. 标记-整理算法;
  4. 分代收集算法;

标记-清除算法

先根据可达性分析标记出哪些需要进行回收,然后将对象进行回收。大致步骤:

  1. 标记垃圾;
  2. 直接回收垃圾;

2020-04-06-18-26-50

看图片就知道,实现的步骤很简单,就是标记,然后清除掉。这样的方式很简单暴力,但是也带来了问题,那就是不连续的内存段,导致空间浪费。空间浪费的原因就是它造成了很多碎片内存,你想啊,你要一个 3M 的连续内存空间,但是内存里面都是分散的 1M 的空间,你说气不气人?白白浪费了,不持家啊~~

复制算法

复制算法的流程大致如下:

  1. 标记哪些是可回收的,哪些是使用中的;
  2. 将使用中的对象复制到内存的另一半空闲中;
  3. 清理掉需要回收的原来的一半内存;

2020-04-06-18-21-37

复制算法的思路很清晰,就是把一块内存劈成两半,然后一半内存满了,就把这一半内存中有用的全部移动到另一个内存空间,再把原来满的一半全部清了。这种算法其实很好啊,你看就解决了内存不足的问题(可以回收),也解决了内存碎片的问题(复制的时候是连续的)。就是有个不好,明明有 100M 内存,但是你就是只能用 50M,你说气人不气人。另外复制算法带来了一个隐藏的问题,如果使用的一半内存中有 90% 的对象都是需要用到的,那可是要复制 90% 的对象到另一半中啊。你说怎么办?看下面的另一个算法

标记-整理算法

如果复制的内容过多,那我们是不是可以不复制了?标记整理算法就可以这样。它的原理就是我把所有需要使用到的对象都往一边移动,然后移动到最后一个对象的时候,作为边界,把边界之后的全部删除掉,这样是不是就可以了?

2020-04-06-18-43-10

这样算法就是明显的使用的空间大了,但是如果是很多对象都存活着的话,那也是移动效率有点慢。

分代收集算法

分代收集算法它其实是一种组合算法,我们可以看到上面的几个优缺点,标记清除有简单性,但是可能造成内存碎片;复制算法做到了内存的连续性,但是可能有大量复制(存货对象过多)而且内存利用率不太好;标记-整理算法看起来很不错,但是如果是只有少量的需要垃圾回收的对象了?那么移动每个对象就变成了很麻烦的操作。所以分代搜集算法就是在这种情况下产生的,既然其它算法都有好处,那我可不可以将一块大内存,划分为几块大小不一样的内存,然后每块内存里面采用不同的算法?这在虚拟机的实现过程中也是有这种考量的,比如 hotspot 虚拟机实现中就有下面的一个对象存活时间分析图:

2020-04-06-21-08-37

图地址为:distribution_lifetimes

可以看到如果以 x 轴为对象的生命周期,y 轴为对象内存分配空间。会发现很对的对象其实都是只有很短的生命周期,换通俗点说,就是很多对象可能就是朝生夕死;换人来说的话:吾朝闻道夕死可矣;换“对象(instance)”来说的话:活已经干完了,要杀要剐你看着办吧。总之你要知道,很多对象的生命周期都是很短的,所以如果不采用分代收集的话,那么对于一整块内存来说,最后做 GC 这个工作量会很大。

主流的虚拟机就开始想了个办法,既然一整块去收集集工作量大,那我可不可以让这个 GC 的时间提早?我把内存划分为几个区域,然后每个区域分代去回收内存是不是效率就高了?于是就有了我们现在的主流,根据对象的存活年龄来分代。

2020-04-06-21-46-15

对图中的几个区域做下说明:

virtual:在初始化的时候会预留一块内存区域,这个区域除非是必要时候,不然不会分配实际物理内存;
Eden 区:年轻代中的 eden 区,对象开始分配空间的主区域,young gc 的主区域;
s0:年轻代中的幸存者区域之一,结合我们说的复制算法,eden 区的内存复制到这里;
s1:年轻代中的幸存者区域之一,跟 s0 的作用一样;
Tenured:永久代/老年代,存放幸存者达到一定年龄的对象;

空间比:Eden:s0:s1 = 8:1:1

这里需要说明一下 s0,s1 两个区域,这两个区域的作用不仅仅是将 eden 区存活的幸存对象复制保存下来,他们自己本身也会进行 gc,比如 s0 里面满了,那么下次年轻代 gc (又叫minor gc)的时候,s0 中存活的对象复制到 s1 中;再下次年轻代 gc 的时候,s1 又把自己本身存活的对象复制到 s0 中,这样每经过一次 minor gc,对象的年龄就+1,达到我们设定的阈值比如 15,就会进入到永久代。

GC 过程

minor GC

我们看下一个对象的从创建到最后升级到永久代的过程(多次的 minorGC):

  1. 先在 eden 区创建一个对象:

2020-04-06-22-09-02

  1. Eden 区要满了,准备触发 minorGC:

2020-04-06-22-09-53

  1. 触发一次 minorGC 将 eden 区的一部分存活对象移动到 s0 中:

object-minor-gc-1

  1. 触发下一次 minorGC,将其一定到另一个 s1 中:

object-minor-gc-2

  1. 如果到了我们设定的阈值,就晋升到永久代:

object-minor-gc-3

如果有一个对象需要大量的连续内存时,这种特殊情况下对象的创建不会在 Eden 区,而是直接在老年代。因为如果放在 eden 区,minorGC 进入到 s0、s1 会导致这块很快就空间满了,空间满了就会又开始 gc,那不是费时费力吗,所以直接放到老年代。

Major GC(Full GC)

上面讲的是 minorGC,那么我们可以想一下,如果在对象到了我们设定的阈值,需要进入永久代了,我们是不是应该要先检查下当前永久代代的空间是不是还够年轻代进行 minorGC?这个叫做空间分配保障。那么怎么保障?如果当前永久代剩余空间大于年轻代的总空间,那肯定没问题,你 minorGC 继续。如果发现当前空间小与年轻代总空间,虚拟机会去查一下HandlePromotionFailure是否允许担保。如果设置为允许,那么怎么办?虚拟机按照以前的 minorGC 晋升到永久代的平均大小,如果大,就 minorGC,如果小就进行 FullGC。

如果永久代也满了,那么 FullGC 肯定也会触发了。

FullGC 的过程是堆整个堆进行全量 GC,同时回收年轻代和永久代,会导致一个STW(Stop The World),很好理解啊,我都要进行全家大扫除了,你给我说,你还要继续嗑瓜子??

好了,今天写到这里,前面讲了内存区域,今天讲了垃圾标识和常见的算法。明天继续写安全点和常见的几个垃圾收集器。之后再写 GC 日志分析,发生 OOM 线上怎么调试。

本文标题:查漏补缺 Java 系列 - JVM 垃圾回收

文章作者:陈志军

发布时间:2020-04-05 12:50:01

原始链接:http://chenzhijun.me/2020/04/05/what-is-garbage-to-need-collecter/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

坚持原创技术分享,您的支持将鼓励我继续创作!