Java 虚拟机系列一:一文搞懂 JVM架构和运行时数据区java
Java 虚拟机系列二:垃圾收集机制详解,动图帮你理解程序员
上篇文章已经给你们介绍了 JVM 的架构和运行时数据区 (内存区域),本篇文章将给你们介绍 JVM 的重点内容——垃圾收集。众所周知,相比 C / C++ 等语言,Java 能够省去手动管理内存的繁琐操做,很大程度上解放了 Java 程序员的生产力,而这正是得益于 JVM 的垃圾收集机制和内存分配策略。咱们平时写程序时并感知不到这一点,可是若是是在生产环境中,JVM 的不一样配置对于服务器性能的影响是很是大的,因此掌握 JVM 调优是高级 Java 工程师的必备技能。正所谓“基础不牢,地动山摇”,在这以前咱们先来了解一下底层的 JVM 垃圾收集机制。面试
既然要介绍垃圾收集机制,就要搞清楚如下几个问题:算法
本文将按如下行文结构展开,对上述问题一一解答。数组
下面开始正文,仍是图文并茂的老配方,走起。缓存
先来回顾一下 JVM 的运行时数据区:安全
JVM 运行时数据区服务器
其中程序计数器、Java 虚拟机栈和本地方法栈都是线程私有的,与其对应的线程是共生关系,随线程而生,随线程而灭,栈中的栈帧也随着方法的进入和退出井井有理地进行入栈和出栈操做。因此这几个区域的内存分配和回收都是有很大肯定性的,在方法结束或线程结束时,内存也会随之释放,所以也就不须要考虑这几个区域的内存回收问题了。架构
而堆和方法区就不同了,Java 的对象几乎都是在堆上建立出来的,方法区则存储了被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据,方法区中的运行时常量池则存放了各类字面量与符号引用,上述的这些数据大部分都是在运行时才能肯定的,因此须要进行动态的内存管理。并发
还要说明一点,JVM 中的垃圾收集器的最主要的关注对象是 Java 堆,由于这里进行垃圾收集的“性价比”是最高的,尤为是在新生代 (后文对分代算法进行介绍) 中的垃圾收集,一次就能够回收 70% - 99% 的内存。而方法区因为垃圾收集断定条件,尤为是类型卸载的断定条件至关苛刻,其回收性价比是很是低的,所以有些垃圾收集器就干脆不支持或不彻底支持方法区的垃圾收集,好比 JDK 11 中的 ZGC 收集器就不支持类型卸载。
引用计数法的实现很简单,在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任什么时候刻计数器为零的对象就是不可能再被使用的。大部分状况下这个方法是能够发挥做用的,可是在存在循环引用的状况下,引用计数法就无能为力了。好比下面这种状况:
public class Student {
// friend 字段
public Student friend = null;
public static void test() {
Student a = new Student();
Student b = new Student();
a.friend = b;
b.friend = a;
a = null;
b = null;
System.gc();
}
}
复制代码
上述代码建立了 a 和 b 两个 Student 实例,并把它们各自的 friend 字段赋值为对方,除此以外,这两个对象再无任何引用,而后将它们都赋值为 null,在这种状况下,这两个对象已经不可能再被访问,可是它们由于互相引用着对方,致使它们的引用计数都不为零,引用计数算法也就没法回收它们。以下图所示:
循环引用
可是在 Java 程序中,a 和 b 是能够被回收的,由于 JVM 并无使用引用计数法断定对象是否可回收,而是采用了可达性分析法。
这个算法的基本思路就是经过一系列称为“GC Roots”的根对象做为起始节点集 (GC Root Set),从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链” (Reference Chain),若是某个对象到GC Roots间没有任何引用链相连,则说明此对象再也不被使用,也就能够被回收了。要进行可达性分析就须要先枚举根节点 (GC Roots),在枚举根节点过程当中,为防止对象的引用关系发生变化,须要暂停全部用户线程 (垃圾收集以外的线程),这种暂停所有用户线程的行为被称为 (Stop The World)。可达性分析法以下图所示:
可达性分析法
图中绿色的都是位于 GC Root Set 中的 GC Roots,全部与其有关联的对象都是可达的,被标记为蓝色,而全部与其没有任何关联的对象都是不可达的,被标记为灰色。即便是不可达对象,也并不是必定会被回收,若是该对象同时知足如下几个条件,那么它仍有“逃生”的可能:
finalize()
方法 (Object 类中的方法);finalize()
方法中将其自身连接到了引用链上;finalize()
方法 (由于 JVM 在收集可回收对象时会调用且仅调用一次该对象的finalize()
方法)。不过因为finalize()
方法的运行代价高昂,不肯定性大,且没法保证各个对象的调用顺序,因此并不推荐使用。那么 GC Roots 又是何方神圣呢?在 Java 语言中,固定可做为GC Roots的对象包括如下几种:
标记-清除算法的思想很简单,顾名思义,该算法的过程分为标记和清除两个阶段:首先标记出全部须要回收的对象,其中标记过程就是使用可达性分析法判断对象是否属于垃圾的过程。在标记完成后,统一回收掉全部被标记的对象,也能够反过来,标记存活的对象,统一回收全部未被标记的对象。示意图以下:
标记清除算法
这个算法虽然很简单,可是有两个明显的缺点:
标记-复制算法常简称复制算法,这一算法正好解决了标记-清除算法在面对大量可回收对象时执行效率低下的问题。其实现方法也很易懂:在可用内存中划分出两块大小相同的区域,每次只使用其中一块,另外一块保持空闲状态,第一块用完的时候,就把存活的对象所有复制到第二块区域,而后把第一块所有清空。以下图所示:
标记-复制算法
这个算法很适合用于对象存活率低的状况,由于它只关注存活对象而无需理会可回收对象,因此 JVM 中新生代的垃圾收集正是采用的这一算法。可是其缺点也很明显,每次都要浪费一半的内存,未免太过奢侈,不过新生代有更精细的内存划分,比较好地解决了这个问题,见下文。
这个算法完美解决了标记-清除算法的空间碎片化问题,其标记过程与“标记-清除”算法同样,但后续步骤不是直接对可回收对象进行清理,而是让全部存活的对象都向内存空间一端移动,而后直接清理掉边界之外的内存。
标记整理算法
这个算法虽然能够很好地解决空间碎片化问题,可是每次垃圾回收都要移动存活的对象,还要对引用这些对象的地方进行更新,对象移动的操做也须要全程暂停用户线程 (Stop The World)。
与其说是算法,不如说是理论。现在大多数虚拟机的实现版本都遵循了“分代收集”的理论进行设计,这个理论能够看做是经验之谈,由于开发人员在开发过程当中发现了 JVM 中存活对象的数量和它们的年龄之间有着某种规律,以下图:
JVM 中存活对象数量与年龄之间的关系
在此基础上,人们得出了如下假说:
根据这两个假说,能够把 JVM 的堆内存大体分为新生代和老年代,新生代对象大多存活时间短,每次回收时只关注如何保留少许存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间,因此这一区域通常采用标记-复制算法进行垃圾收集,频率比较高。而老年代则是一些难以消亡的对象,能够采用标记-清除和标记整理算法进行垃圾收集,频率能够低一些。
按照 Hotspot 虚拟机的实现,针对新生代和老年代的垃圾收集又分为不一样的类型,也有不一样的名词,以下:
部分收集 (Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
新生代收集 (Minor GC / Young GC):指目标只是新生代的垃圾收集。
老年代收集 (Major GC / Old GC):指目标只是老年代的垃圾收集,目前只有CMS收集器的并发收集阶段是单独收集老年代的行为。
混合收集 (Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集,目前只有G1收集器会有这种行为。
整堆收集 (Full GC):收集整个Java堆和方法区的垃圾收集。
人们常常会混淆 Major GC 和 Full GC,不过这也有情可原,由于这两种 GC 行为都包含了老年代的垃圾收集,而单独的老年代收集 (Major GC) 又比较少见,大多数状况下只要包含老年代收集,就会是整堆收集 (Full GC),不过仍是分得清楚一点比较好哈。
通过前面的铺垫,如今终于能够一窥 JVM 的内存分配和垃圾收集机制的真面目了。
JVM 堆内存划分
Java 堆是 JVM 所管理的内存中最大的一块,也是垃圾收集器的管理区域。大多数垃圾收集器都会将堆内存划分为上图所示的几个区域,总体分为新生代和老年代,比例为 1 : 2,新生代又进一步分为 Eden、From Survivor 和 To Survivor,比例为 8 : 1 : 1。不过请记住,不管是哪一个区域,存储的都只能是对象的实例,将Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存。
大多数状况下,对象优先在新生代 Eden 区中分配,当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。Eden、From Survivor 和 To Survivor 的比例为 8 : 1 : 1,之因此按这个比例是由于绝大多数对象都是朝生夕灭的,垃圾收集时 Eden 存活的对象数量不会太多,Survivor 空间小一点也足以容纳,每次新生代中可用内存空间为整个新生代容量的90% (Eden 的 80% 加上 To Survivor 的 10%),只有From Survivor 空间,即 10% 的新生代是会被“浪费”的。不会像原始的标记-复制算法那样浪费一半的内存空间。From Survivor 和 To Survivor 的空间并非固定的,而是在 S0 和 S1 之间动态转换的,第一次 Minor GC 时会选择 S1 做为 To Survivor,并将 Eden 中存活的对象复制到其中,并将对象的年龄加1,注意新生代使用的垃圾收集算法是标记-复制算法的改良版。下面是示意图,请注意其中第一步的变色是为了醒目,虚拟机只作了标记存活对象的操做。
第一次 Minor GC 示意图
在后续的 Minor GC 中,S0 和 S1会交替转化为 From Survivor 和 To Survivor,Eden 和 From Survivor 中的存活对象会复制到 To Survivor 中,并将年龄加 1。以下图所示:
后续 Minor GC 示意图
在如下这些状况下,对象会晋升到老年代。
长期存活对象将进入老年代
对象在 Survivor 区中每熬过一次Minor GC,年龄就增长1岁,当它的年龄增长到必定程度 (默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,能够经过参数 -XX:MaxTenuringThreshold 设置。
长期存活对象晋升老年代示意图
大对象能够直接进入老年代
对于大对象,尤为是很长的字符串,或者元素数量不少的数组,若是分配在 Eden 中,会很容易过早占满 Eden 空间致使 Minor GC,并且大对象在 Eden 和两个 Survivor 之间的来回复制也还会有很大的内存复制开销。因此咱们能够经过设置 -XX:PretenureSizeThreshold 的虚拟机参数让大对象直接进入老年代。
动态对象年龄判断
为了能更好地适应不一样程序的内存情况,HotSpot 虚拟机并非永远要求对象的年龄必须达到 -XX:MaxTenuringThreshold 才能晋升老年代,若是在 Survivor 空间中相同年龄全部对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就能够直接进入老年代,无须等到 -XX:MaxTenuringThreshold 中要求的年龄。
空间分配担保 (Handle Promotion)
当 Survivor 空间不足以容纳一次 Minor GC 以后存活的对象时,就须要依赖其余内存区域 (实际上大多数状况下就是老年代) 进行分配担保。在发生 Minor GC 以前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代全部对象总空间,若是这个条件成立,那这一次 Minor GC 能够确保是安全的。若是不成立,则虚拟机会先查看 - XX:HandlePromotionFailure 参数的设置值是否容许担保失败 (Handle Promotion Failure);若是容许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,若是大于,将尝试进行一次 Minor GC,尽管此次 Minor GC 是有风险的;若是小于,或者-XX: HandlePromotionFailure设置不容许冒险,那这时就要改成进行一次 Full GC。
本文介绍了 JVM 的垃圾收集机制,并用大量图片和动图来帮助你们理解,若有错误,欢迎指正。后续文章会继续介绍 JVM 中的各类垃圾收集器,包括最前沿的 ZGC 和 Shenandoah 收集器,是 JVM 领域的最新科技成果,敬请期待。
最后是参考文章: