JVM学习(二):垃圾回收

我刚工作的时候问一个前辈,我们能针对JVM做出什么样的优化。前辈说,我们系统现在的性能并不需要调优,用默认的配置就能满足现在的需求了。我又问,那你为什么要看JVM相关的书呢?前辈微微一笑,悠悠地来了句,为了面试。
玩笑归玩笑,不过事实上确实萌新程序员确实不需要在实际工作中进行JVM调优。一方面Java虚拟机的默认配置足够我们使用,另一方面功能强大的IDE让我们在编写代码的时候基本不需要考虑虚拟机的问题。那么抛开应付面试,我们为什么要学习JVM呢?
在我看来,学习无论从广度还是从深度上都应当超前于工作中的应用。就像科研一样,理论永远比实践走得要远(想想爱因斯坦1916年预测的引力波到2016年才被探测到)。这里不多说,增加知识储备肯定是好的。闲话少说,我们开始聊垃圾回收。

上一篇说到Java程序员不需要手动垃圾回收(GC),这是因为Java虚拟机已经帮我们自动完成了。那么为什么Java虚拟机知道哪个对象需要回收,何时需要回收,回收后如何重新分配呢?
对于程序计数器、虚拟机栈、本地方法栈这样的非线程共享的内存,它们的内存跟线程的生命周期相同,我们不用考虑这些内存的回收。此处垃圾回收指的是线程共享区域(堆和方法区)的内存回收。

哪些内存需要回收?

引用计数法

引用计数法是古老的辨别对象存亡的算法。引用计数法的方法是为每个对象添加一个引用计数器,统计引用对象的次数,如果引用次数为0,那么此对象则判断为死亡。
这样的判断方法有一个最大的弊端是,此种方法没有解决对象循环依赖的问题。例如对象A和对象B,两个相互引用。此时两个对象的引用计数器都不为0,那么这两个对象将永远不会被回收,这样就容易引起内存泄露问题。

可达性分析

Java虚拟机通常采用的是可达性分析算法,这个算法是将一系列GC Roots作为初始的存活对象集合。然后从这个集合出发将所有该集合引用到的对象加入到存活对象集合中。最终,未被加入到该集合的对象将被判定为死亡对象。如果你对判定算法感兴趣,可以参考我之前写的一篇文章如何判断一个图中是否存在环路

不过此方法也不是完美的解决办法。在多线程的环境下,假如一个在存活对象集合中的对象,在运行过程中其引用被删除,我们认为此对象应该被回收掉。但是可达性分析尚未完成,这时候就会出现漏报。漏报不会出现什么问题,因为下次可达性分析完成时就可以回收掉这个对象的内存。另一种情况是误报,一个对象没有在存活对象集合中,在被回收之前却被其他对象引用到了,这时候我们认为此对象是存活的。然而这时候Java虚拟机可能会回收掉这个对象,这就会引起很严重的问题。因此在合适的时机进行垃圾回收是很重要的。
GC Roots是指堆外指向堆内的引用,一般有如下几种:

  1. Java方法栈帧中的局部变量
  2. 已加载类的静态变量
  3. JNI handles
  4. 已启动尚未停止的Java线程

何时进行垃圾回收?

Stop-The-World

为了避免上述漏报误报的问题,在Java虚拟机中,垃圾回收是在某个时刻进行的,在此期间不会出现引用关系的变化,这段时间也叫做 Stop-the-world。在Stop-the-world期间,将会停止其他非垃圾回收线程的工作,直到完成垃圾回收。因此垃圾回收造成了停顿时间(GC Pause)。

在Java虚拟机运行的过程中,程序的引用更新是很难预期到,因此Stop-the-world并不是有均匀间隔时间的,而是通过安全点(Safepoint)检测机制来实现的。安全点检测是为了找出Java虚拟机堆栈不会更新的稳定状态,当所有线程都达到安全点的时候,才会允许Stop-the-world线程进行垃圾回收。

线程的稳定状态一般有:JNI执行本地代码、解释执行字节码、执行即时编译器生成的机器码和线程阻塞。

Java Native方法不会去调用Java对象或者调用Java方法,因此Java虚拟机堆栈不会出现变化,属于安全点。
解释执行的字节码,字节码与字节码之间皆可作为安全点。
即时编译生成的机器码是直接运行在机器上的,这部分的运行不受虚拟机的控制。因此生成机器码的时候要插入安全点检测,避免长时间等待导致的停顿。HotSpot虚拟机的做法是在生成代码的方法出口和非计数循环的循环回边处加入安全点检测。
阻塞的线程还处于Java虚拟机的线程管理之中,因此是安全点。

垃圾回收的方式

清除

最简单粗暴的方式就是清除(Sweep)。也就是把未在存活集合中的对象内存全部回收,记录在一个空闲内存列表。当新建对象的时候就从空闲内里列表中划去所需要的内存。
Sweep

由于Java堆中对象必须是连续的内存,因此这个方法有两个显而易见的缺点。一个是分配效率低下,每次分配内存时都要遍历空闲内存列表,找到符合大小的空闲内存块。第二个缺点是容易造成内存碎片,存在一种情况是内存空间足够,但是没有足够的连续内存为新的对象分配(这个可以参考操作系统中的内存管理,是相同的道理)。

压缩

压缩指的是对于存活对象的内存,移动到内存的起始点位置。可以想象每次回收的时候都需要移动内存,所以这种方法的性能开销很大。
Compact

复制

复制是把内存划分为两等分,分别用两个指针from和to维护。只用from指针指向的内存区域来分配内存。垃圾回收时,把存活的对象复制到to指向的内存区域中,然后交换from和to指向的内容。这种方法解决了内存碎片问题,缺点是堆空间的使用率大大降低(只能用一半)。
copy

总结

本篇讲了垃圾回收的基本原理。先是判断何种对象要回收,主要有引用计数法和可达性分析法。其次,讲了什么时候进行垃圾回收,主要有Stop-the-world以及安全点检测。最后是垃圾回收的方式,分别是清除、压缩和复制。其中清除会产生内存碎片,压缩机制有较大的算法开销,复制机制能解决内存碎片,但是堆空间使用率低下。

参考文章

极客时间——郑雨迪:深入拆解Java虚拟机

深入理解Java虚拟机:JVM高级特性与最佳实践

posted @ 2019-08-27 12:12  六层楼  阅读(291)  评论(0编辑  收藏  举报