原文请见:https://mp.weixin.qq.com/s/wn_7qve6OCD0SpYILvcKtg面试
读者朋友,你好,我是 wingjay。算法
垃圾回收机制是 Java 很是重要的特性之一,也是面试题的常客。它让开发者无需关注空间的建立和释放,而是以守护进程的形式在后台自动回收垃圾。这样作不只提升了开发效率,更改善了内存的使用情况。数组
不少读者对于垃圾回收机制理解不到位,学了又忘。设计
所以,本文不只仅是对垃圾回收机制进行原理讲解,更重要的是,带着你来一块儿设计垃圾回收机制,让你真正搞懂垃圾回收的原理。对象
看文章以前,先抛出几个问题,你能够简单思考下:进程
什么是堆内存?内存
什么是垃圾?开发
有哪些方法回收这些垃圾?v8
为何要分代回收机制?get
什么是Java堆内存?
Java 堆是在 JVM 启动时建立的,主要用来维护运行时数据,如运行过程当中建立的对象和数组都是基于这块内存空间。Java 堆是很是重要的元素,若是咱们动态建立的对象没有获得及时回收,持续堆积,最后会致使堆空间被占满,内存溢出。
所以,Java 提供了一种垃圾回收机制,在后台建立一个守护进程。该进程会在内存紧张的时候自动跳出来,把堆空间的垃圾所有进行回收,从而保证程序的正常运行。
那什么是垃圾呢?
所谓“垃圾”,就是指全部再也不存活的对象。常见的判断是否存活有两种方法:引用计数法和可达性分析。
1. 引用计数法
为每个建立的对象分配一个引用计数器,用来存储该对象被引用的个数。当该个数为零,意味着没有人再使用这个对象,能够认为“对象死亡”。可是,这种方案存在严重的问题,就是没法检测“循环引用”:当两个对象互相引用,即时它俩都不被外界任何东西引用,它俩的计数都不为零,所以永远不会被回收。而实际上对于开发者而言,这两个对象已经彻底没有用处了。
所以,Java 里没有采用这样的方案来断定对象的“存活性”。
2. 可达性分析
这种方案是目前主流语言里采用的对象存活性判断方案。基本思路是把全部引用的对象想象成一棵树,从树的根结点 GC Roots 出发,持续遍历找出全部链接的树枝对象,这些对象则被称为“可达”对象,或称“存活”对象。其他的对象则被视为“死亡”的“不可达”对象,或称“垃圾”。
参考下图,object5, object6 和 object7 即是不可达对象,视为“死亡状态”,应该被垃圾回收器回收。
3. GC Roots 究竟指谁呢?
咱们能够猜想,GC Roots 自己必定是可达的,这样从它们出发遍历到的对象才能保证必定可达。那么,Java 里有哪些对象是必定可达呢?主要有如下四种:
虚拟机栈(帧栈中的本地变量表)中引用的对象。
方法区中静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中JNI引用的对象。
很多读者可能对这些 GC Roots 似懂非懂,这涉及到 JVM 自己的内存结构等等,将来的文章会再作深刻讲解。这里只要知道有这么几种类型的 GC Roots,每次垃圾回收器会从这些根结点开始遍历寻找全部可达节点。
有几种回收垃圾的方式呢?
上面已经知道,全部GC Roots不可达的对象都称为垃圾,参考下图,黑色的表示垃圾,灰色表示存活对象,绿色表示空白空间。
那么,咱们如何来回收这些垃圾呢?
1. 标记-清理
第一步,所谓“标记”就是利用可达性遍历堆内存,把“存活”对象和“垃圾”对象进行标记,获得的结果如上图;第二步,既然“垃圾”已经标记好了,那咱们再遍历一遍,把全部“垃圾”对象所占的空间直接清空便可。
结果以下:
这即是标记-清理方案,简单方便,可是容易产生内存碎片。
2. 标记-整理
既然上面的方法会产生内存碎片,那好,我在清理的时候,把全部存活对象扎堆到同一个地方,让它们待在一块儿,这样就没有内存碎片了。
结果以下:
这两种方案适合存活对象多、垃圾少的状况,它只须要清理掉少许的垃圾,而后挪动下存活对象就能够了。
3. 复制
这种方法比较粗暴,直接把堆内存分红两部分,一段时间内只容许在其中一块内存上进行分配,当这块内存被分配完后,则执行垃圾回收,把全部存活对象所有复制到另外一块内存上,当前内存则直接所有清空。
参考下图:
起初时只使用上面部分的内存,直到内存使用完毕,才进行垃圾回收,把全部存活对象搬到下半部分,并把上半部分进行清空。
这种作法不容易产生碎片,也简单粗暴;可是,它意味着你在一段时间内只能使用一部分的内存,超过这部份内存的话就意味着堆内存里频繁的复制清空。
这种方案适合存活对象少、垃圾多的状况,这样在复制时就不须要复制多少对象过去,多数垃圾直接被清空处理。
Java 的分代回收机制
上面咱们看到有至少三种方法来回收内存,那么 Java 里是如何选择利用这三种回收算法呢?是只用一种仍是三种都用呢?
1. Java 的堆结构
在选择回收算法前,咱们先来看一下 Java 堆的结构。
一块 Java 堆空间通常分红三部分,这三部分用来存储三类数据:
刚刚建立的对象。在代码运行时会持续不断地创造新的对象,这些新建立的对象会被统一放在一块儿。由于有不少局部变量等在新建立后很快会变成不可达的对象,快速死去,所以这块区域的特色是存活对象少,垃圾多。形象点描述这块区域为:新生代;
存活了一段时间的对象。这些对象早早就被建立了,并且一直活了下来。咱们把这些存活时间较长的对象放在一块儿,它们的特色是存活对象多,垃圾少。形象点描述这块区域为:老年代;
永久存在的对象。好比一些静态文件,这些对象的特色是不须要垃圾回收,永远存活。形象点描述这块区域为:永久代。(不过在 Java 8 里已经把永久代删除了,把这块内存空间给了元空间,关注公众号,后续文章再讲解。)
也就是说,常规的 Java 堆至少包括了 新生代 和 老年代 两块内存区域,并且这两块区域有很明显的特征:
新生代:存活对象少、垃圾多
老年代:存活对象多、垃圾少
结合新生代/老年代的存活对象特色和以前提过的几种垃圾回收算法,能够获得以下的回收方案。
2. 新生代-复制回收机制
对于新生代区域,因为每次 GC 都会有大量新对象死去,只有少许存活。所以采用复制回收算法,GC 时把少许的存活对象复制过去便可。
那么如何设计这个复制算法比较好呢?有如下几种方式:
思路1: 把内存均分红 1:1 两等份
以下图拆份内存。
每次只使用一半的内存,当这一半满了后,就进行垃圾回收,把存活的对象直接复制到另外一半内存,并清空当前一半的内存。
这种分法的缺陷是至关于只有一半的可用内存,对于新生代而言,新对象持续不断地被建立,若是只有一半可用内存,那显然要持续不断地进行垃圾回收工做,反而影响到了正常程序的运行,得不偿失。
思路2: 把内存按 9:1 分
既然上面的分法致使可用内存只剩一半,那么我作些调整,把 1:1 变成9:1,
最开始在 9 的内存区使用,当 9 快要满时,执行复制回收,把 9 内仍然存活的对象复制到 1 区,并清空 9 区。
这样看起来是比上面的方法好了,可是它存在比较严重的问题。
当咱们把 9 区存活对象复制到 1 区时,因为内存空间比例相差比较大,因此颇有可能 1 区放不满,此时就不得不把对象移到 老年区。而这就意味着,可能会有一部分 并不老 的 9 区对象因为 1 区放不下了而被放到了 老年区,可想而知,这破坏了 老年区 的规则。或者说,必定程度上的 老年区 并不必定全是 老年对象。
那应该如何才能把真正比较老的对象挪到 老年区 呢?
思路3: 把内存按 8:1:1 分
既然 9:1 有可能把年轻对象放到 老年区,那就换成 8:1:1,依次取名为 Eden、Survivor A、Survivor B区,其中Eden意为伊甸园,形容有不少新生对象在里面建立;Survivor区则为幸存者,即经历 GC 后仍然存活下来的对象。
那么,揭开神秘的面纱。真正的 JVM 工做原理以下,别眨眼:
首先,Eden 区最大,对外提供堆内存。当 Eden 区快要满了,则进行 Minor GC,把存活对象放入 Survivor A 区,清空 Eden 区;
Eden 区被清空后,继续对外提供堆内存;
当Eden 区再次被填满,此时对 Eden 区和 Survivor A区同时进行 Minor GC,把存活对象放入 Survivor B 区,同时清空 Eden 区和 Survivor A 区;
Eden 区继续对外提供堆内存,并重复上述过程,即在 Eden 区填满后,把Eden 区和某个 Survivor 区的存活对象放到另外一个 Survivor 区;
当某个 Survivor 区被填满,且仍有对象未被复制完毕时,或者某些对象在反复 Survive 15 次左右时,则把这部分剩余对象放到 Old 区;
当 Old 区也被填满时,进行 Major GC,对 Old 区进行垃圾回收。
注意:在真实的 JVM 环境里,能够经过参数 SurvivorRatio 手动配置Eden区和单个Survivor区的比例,默认为8。
那么,所谓的 Old 区垃圾回收,或称Major GC,应该如何执行呢?
3. 老年代-标记整理回收机制
根据上面咱们知道,老年代通常存放的是存活时间较久的对象,因此每一次 GC 时,存活对象比较较大,也就是说每次只有少部分对象被回收。
所以,根据不一样回收机制的特色,这里选择存活对象多,垃圾少的标记整理回收机制,仅仅经过少许地移动对象就能清理垃圾,并且不存在内存碎片化。
至此,咱们已经了解了 Java 堆内存的分代原理,并了解了不一样代根据各自特色采用了不一样的回收机制,即新生代采用回收机制,老年代采用标记整理机制。