在本文中, 我们将从概念模型的角度探讨 JVM 是如何回收对象, 包括 JVM 是如何判断一个对象已经死亡, 什么时候在哪里进行了垃圾回收, 垃圾回收有几种核心算法, 每个算法优劣是什么等.
为何需要 GC
Java 中的一个核心技术就是自动垃圾回收, 该技术使得程序员可以不用像写 C++ 一样手动分配和释放内存, 那么为何还需要我们去学习垃圾回收呢. 这里就要说到两个概念了.
内存泄露: 有已经不再使用的对象仍然占用着内存;
内存溢出: 已经没有足够的空间可以让 JVM 分配内存给对象了.
大量的内存泄露会引发内存溢出, 但内存溢出不一定是内存泄露引起的, 也可能是因为总共的内存空间就不够大, 而需要分配的对象太大导致.
学习垃圾回收的背后逻辑, 可以让我们在程序发生内存溢出的时候, 快速高效地排查出问题进行解决. 并且学习了 GC 的细节, 也有助于我们调节 JVM 的一些运行参数, 让系统达到更高的并发量.
对象的死亡
如果要销毁一个对象, 那么就需要确定该对象已经死亡, 只有这样才能够将该对象所占的内存空间进行释放. 那么 JVM 是如何判断一个对象已经死亡了呢.
引用计数法
引用计数法实现十分简单, 就是给每一个对象增加一个计数器, 每当有一个地方对其进行了引用就 +1, 当引用失效时就 -1, 如果计数器的值为 0, 则代表该对象已经不再被使用了, 可以对其回收了.
这种方式的最大优点就是实现简单, 判定效率高. 但其有一个致命的缺点就是 循环引用问题. 当两个对象互相引用, 但其实他们已经没有任何其他用处了. 此时因为彼此间还存在引用, 就会发生循环引用, 使用引用计数法就无法对其进行回收.
可达性分析算法
正是因为引用计数法那个致命的缺点, 因此主流的实现都是通过 可达性分析 来判断对象能否进行销毁. 其核心思想是 通过一系列称为 "GC Roots" 的节点来作为起始点, 从这些节点开始搜索, 这个搜索的轨迹被称为 "引用链", 如果一个对象没有包含在任何一个引用链中, 那么就判断该对象是无效的.
概念中说到是通过 GC Roots 来作为起始点, 那么哪些对象可以作为 GC Roots 呢.
虚拟机栈中引用的对象;
本地方法栈中引用的对象;
方法区中静态属性引用的对象;
方法区中常量引用的对象.
引用的区分
在判断对象能否被销毁的时候, 都使用到了 引用 这个词语, 说的是如果有被引用的, 那么就不销毁, 如果没有引用则将其进行销毁, 这种分别方式非黑即白, 太过强硬, 因此 JDK1.2 之后对引用的含义进行了扩充, 实现了多级回收的效果. 即在内存不紧张的时候, 有一些对象是可以进行保留的, 但如果内存紧张的时候, 就需要对其进行回收.
强引用: 我们平常在编程使用的引用都是这种, 普遍存在的引用, 只要是这个, 就不会被回收;
软引用: 有用但非必需. 在内存很紧张快要溢出的时候, 就会回收这些对象, 如果回收后还没有空余空间才会报内存溢出. 这种引用通常用来实现内存敏感的缓存;
弱引用: 比软引用更弱一些, 只能活到下一次垃圾回收前. 其实主要回收的就是这些内存;
虚引用: 虚引用不对对象生存时间造成影响, 也无法通过虚引用获得对象实例. 其存在的价值就是在对象收到回收的时候, 能够让系统做一些事. 应用场景为跟踪对象被 GC 的活动, 因为其被回收的时候系统会受到一条系统通知.
方法区的回收
前面说的都是对象的回收, 即对堆内存的回收, 但其实在方法区内也是有垃圾回收的. 在方法区内回收的内容主要是 废弃常量 和 无用的类.
其中废弃常量很好理解. 就是常量池中的一个常量已经没有任何对象引用它了, 即其已经没有价值了, 那么就会将其移出常量池, 回收其空间.
而对无用的类进行回收又是怎么理解的呢. 首先我们需要判断什么是无用的类. 一个类是无用的, 需要满足以下 3 点:
该类的实例都已经被回收了, 即堆中没有该类的实例对象
加载该类的 ClassLoader 已经被回收
无法通过反射访问该类, 即该类对应的 java.lang.Class 对象没有被调用
只有满足以上 3 点的类才可以被回收, 但其是否回收取决于 JVM 启动时的参数控制. JVM 可以在启动时设置不对类进行回收.
回收算法
上面我们已经明白了什么对象是可以回收的, 那么我们该如何针对这些对象进行回收呢. 回收前后内存空间又是如何布局的呢. 下面就让我们来看一下几个主流的 GC 算法.
标记 - 清除算法
标记 - 清除算法是最简单, 最基本的算法. 其本质就如同其名字一样, 分为 2 个步骤, 首先标记出所有需要清除的对象, 然后在回收阶段, 统一清除即可.
但其拥有两个严重的缺点. 一个是标记和清除阶段都不快, 效率很低; 另一个是其只是单纯的将无用的对象清除, 很容易造成大量的内存碎片, 如果内存碎片太多, 那么在分配大对象的时候, 就很容易造成内存不够的情况. 因此针对这些情况, 就出现了几个改进的优良版本.
标记 - 整理算法
标记 - 整理算法解决的是内存碎片的问题, 在标记阶段还是采取一样的解决方式, 但在下个阶段并不是直接清除掉无用对象, 而是先将有用的对象移到内存的一边, 然后直接回收掉分界线一边的对象, 这样就可以腾出许多规整的空间.
复制算法
标记 - 整理算法只是解决了内存碎片的问题, 但是效率问题还是一个痛点, 因此就有人提出了复制算法. 其将内存空间分为 2 部分, 每一次只使用其中一块, 当这一块的空间用完了, 就将存活的对象复制到另一边去, 然后将使用过的空间直接清理掉即可. 这种算法十分的高效, 也解决了内存碎片的问题.
但其将可用空间简单的划分为了 50-50, 代价十分的高昂. 不过经过研究表明, 新生代对象大部分都是朝生夕死的, 因此不需要按照 1:1 的比例来划分空间. 商用的虚拟机 HotSpot 就是默认将内存划分为 8:1:1, 即一块 Eden 区, 两个 Survivor 区, 在进行分配时, 将 Eden 和一个 Survivor 直接复制到另一个 Survivor 即可, 这样解决了复制算法空闲空间太大的问题, 又提高了 GC 的效率.
但是也正是因为这样的划分, Survivor 的内存空间是比较小的, 因此需要有一个其他内存进行分配担保, 确保大对象也能够进行内存分配, 这就老年代存在的价值之一. 当另一块 Survivor 没有足够空间放置对象时, 将会直接将对象分配至老年代. 而老年代采取的 GC 算法为标记 - 整理算法.
分代算法
经过上面 3 种算法的分析, 想必大家也想到了, 分代算法其实并不是一个新算法, 其只是根据前面算法的优劣将内存空间进行了划分, 对每个不同的空间采取不同的算法, 以便根据各个不同的年代采取不同的, 最适合的算法.
在 Java8 之前, 方法区称为永久代, 也如同堆空间一样被 GC 进行管理, 但在 Java8 之后, 这种实现方式被 MetaSpace 取代, 采用直接内存的方式来进行内存分配管理.
Hotspot 将内存划分为新生代和老年代. 新生代因为大部分的对象都是快节奏的, 因此采用复制算法来处理. 而老年代因为对象存活率高, 且已经没有额外空间对齐进行分配担保了, 因此采用标记 - 清理或标记 - 整理算法进行处理.
总结
在本文中, 我们介绍了什么是垃圾回收, 如何判断对象应该进行回收了, 以及回收逻辑的几个不同抽象模型. 在后面的文章, 我们将对算法的具体实现进行探讨, 了解当前业内主流的虚拟机实现, 看看在实际生产情况下, 不同的 垃圾收集器 的具体实现方式.
文章在公众号 "iceWang" 第一手更新, 有兴趣的朋友可以关注公众号, 第一时间看到笔者分享的各项知识点, 谢谢! 笔芯!
本系列文章主要借鉴自《深入分析 Javaweb 技术内幕》和《深入理解 Java 虚拟机 - JVM 高级特性与最佳实践》.
来源: https://www.cnblogs.com/JRookie/p/11212920.html