前言
GC 中文直译垃圾回收, 是一种回收内存空间避免内存泄漏的机制. 当 JVM 内存紧张, 通过执行 GC 有效回收内存, 转而分配给新对象从而实现内存的再利用. JVM GC 机制虽然无需开发主动参与, 减轻不少工作量, 但是某些情况下, 自动 GC 将会导致系统性能下降, 响应变慢, 所以这就需要我们提前了解掌握 GC 机制. 当面对这种情况时, 才能从容不迫的解决问题. 另外 GC 机制也是 Java 面试高频考题, 了解掌握 GC 是一项必备技能.
学习 GC , 首先我们解决三个问题:
什么是垃圾
在哪里回收垃圾
怎么回收垃圾
什么是垃圾
我们先来看一段简单的代码.
上面代码通过将字符串对象转化成字节数组, 然后写入本地文件. 方法一旦开始执行, 就将会在分配一定内存给新建的对象, 然后将引用告诉了 str , bytes 变量. 等到方法执行完毕, 方法内部局部变量紧接将就会被销毁. 但是这样仅仅销毁了局部变量, 却没有带走内存上这些实际的对象. 这类不再起作用, 没有被引用的对象, 将其归类为垃圾.
在偌大的内存上存活着无数对象, GC 之前需要准确将这些对象标记出来, 分为存活对象与垃圾对象. 这个过程一旦少标记, 那就只能等待下次 GC 标记, 再回收, 这样将会影响 GC 效率. 另外决不能错标记, 将正常存活对象标记为垃圾. 一旦回收正常存活的对象, 可能就会引起程序各种崩溃.
目前有两种算法可以用来标记:
引用计数法
可达性分析法
引用计数法
引用计数法通过在对象头分配一个字段, 用来存储该对象引用计数. 一旦该对象被其他对象引用, 计数加 1. 如果这个引用失效, 计数减 1. 当引用计数值为 0 时, 代表这个对象已不再被引用, 可以被回收.
如上图所示, 当 str 引用堆中对象时, 计数值增加为 1. 当 str 变为 null 时, 既不再引用该对象, 计数值减 1. 此时该对象就可以被 GC 回收.
引用计数法只需要判断计数值, 所以实现比较简单, 这个过程也比较高效. 但是存在一个很严重的问题, 无法解决对象循环引用问题.
从上图可以看到, a , b 不再引用堆中对象, 导致计数减一. 此时两个对象内部还存在互相引用, 计数值不为 0, 此时 GC 没办法回收该对象.
可达性分析法
这个算法首先需要按照规则查找当前活跃的引用, 将其称为 GC Roots . 接着将 GC Roots 作为根节点出发, 遍历对象引用关系图, 将可以遍历 (可达) 的对象标记为存活, 其余对象当做无用对象.
注意这里是是 引用 , 而不是对象.
从上图可以看到, 绿色对象虽然存在循环引用, 但是由于这些对象不能被 GC Roots 遍历到, 所以将会被回收.
可以被当做 GC Roots 活跃引用包括但不限于以下引用:
方法中局部变量
静态变量, 常量
JNI handles
....
在哪里回收垃圾
还记得刚开始接触 Java 时, 只知道堆栈, 对象实例分配在堆中, 方法中局部变量位于栈中. 实际上 JVM 内存区域划分更加细致, 分为:
堆
方法区
虚拟机栈
本地方法栈
程序计数器
如图所示, 我们将内存划分为线程私有与线程共享的区域. 方法区与堆都是线程共享的区域, 这两部分占用 JVM 大部分内存, 剩下三个小弟将会跟线程绑定, 随着线程消亡, 自动将会被 JVM 回收.
堆
堆应该是大家最熟悉的一块区域, 几乎所有对象实例都将会在此出生, 通常也是虚拟机上占用内存最大一块区域, 简直就是 JVM 内存中的大哥大. 堆内存内部也不是简简单单一块而已, 目前将会根据分代算法, 将堆分代, 不同对象位于不同区域. 这一点我们下文再详细了解.
方法区
方法区将会保存已被虚拟上加载的类信息, 常量, 静态变量, 字节码等信息, 堆上的对象正式通过方法区这些信息, 才能正确创建出来.
栈
虚拟机栈栈由一系列栈帧组成, 每个栈帧其实代表一个方法, 栈帧中将会保存一个方法的局部变量表, 方法出入口信息, 操作栈等. 每当调用一个方法, 就将会把这个栈帧压入栈中, 执行结束, 出栈.
本地方法栈与虚拟机栈比较类似, 最大区别在于, 虚拟机栈执行的 Java 方法, 而本地方法栈将会用来执行 Native 方法服务. 下面方法就会在本地方法栈中执行.
- public static native void arraycopy(Object src, int srcPos,
- Object dest, int destPos,
- int length);
程序计数器
程序计算器可以说是这几块区域占用最小的一部分, 但是功能却十分重要. Java 源代码通过编译变成字节码, 然后被 JVM 载入运行之后, 将会变成一条条指令, 而程序计数器的工作就是告诉当前线程下一条需要执行指令. 这样即使发生了线程切换, 等待恢复的时候, 当前线程依然知道接下去要执行的指令.
怎么回收
目前主流 GC 算法主要分为三种:
标记 - 清除算法
复制算法
标记 - 整理算法
标记 - 清除算法
这是一个最为基础也是最容易实现的算法, 主要实现步骤分为两步: 标记, 清除.
标记: 通过上述 GC Roots 标记出可达对象.
清除: 清理 未标记对象 .
ps: 这个图着实难画啊....
可以看到经过这个算法回收之后, 虽然堆空间被清理出来, 但是也产生很多 空间碎片 . 这就会导致一个新对象根据堆剩余容量计算, 看起来是可以分配, 但是实际分配过程, 由于没有连续内存, 导致虚拟机感知到内存不足, 又不得不提前再次触发 GC .
可能这里你就会有疑惑, 为什么对象需要分配一块连续的内存?
这里引用一下 R 神 @RednaxelaFX https://www.zhihu.com/people/rednaxelafx 答案.
另外这个算法还有一个不足: 标记与清除效率比较低. 这就竟会导致 GC 占用时间过长, 影响正常程序使用.
复制算法
为了解决上述效率问题, 诞生复制算法. 这个算法将可用内存分为两块, 每次只使用其中一块, 当这一块内存使用完毕, 触发 GC , 将会把存活的对象依次复制到另外一块上, 然后再把已使用过的内存一次性清理.
这个算法每次只需要操作一半内存, GC 回收之后也不存在任何空间碎片, 新对象内存分配时只需要移动堆顶指针, 按顺序分配内存即可, 实现简单, 运行高效. 但是这个算法闲置一半内存空间, 空间利用效率不高.
PS: 复制算法以空间换时间, 两者不可兼得
另外对象存活率也会影响复制算法效率. 如果对象大部分都是朝生夕死, 只需要移动少量存活对象, 就能腾出大部分空间. 反而如果对象存活率高, 这就需要进行较多的复制操作, 回收之后也并没有多余内存, 这就可能导致频繁触发 GC .
针对这种存活时间长的对象, 就需要使用标记 - 整理算法.
标记 - 整理算法
标记 - 整理算法可以说是标记 - 清除算法的改进版, 改进了清除导致的空间碎片问题. 这个算法分为两步:
GC Roots
虽然标记 - 整理算法解决了标记 - 清除算法空间碎片问题, 也完整利用整个内存空间, 但是这个算法问题效率并不高. 相较于标记 - 清除算法, 标记 - 整理算法多增加整理这一步, 所以该算法效率还低于标记 - 清除算法.
分代收集算法
从上面三种 GC 算法可以看到, 并没有一种空间与时间效率都是比较完美的算法, 所以只能做的是综合利用各种算法特点将其作用到不用的内存区域.
目前商业虚拟机根据对象存活周期不同划分内存区域, 一般分为新生代, 老年代. 新对象一般情况都会优先分配在新生代, 新生代对象若存活时间大于一定阈值之后, 将会移到至老年代. 新生代的对象都是短命鬼, 老年代的对象都是长寿先生.
新生代每次 GC 之后都可以回收大批量对象, 所以比较适合复制算法, 只需要付出少量复制存活对象的成本. 这里内存划分并没有按照 1:1 划分, 默认将会按照 8:1:1 划分成 Eden 与两块 Survivor 空间. 每次使用 Eden 与一块 Survivor 空间, 这样我们只是闲置 10% 内存空间. 不过我们每次回收并不能保证存活对象小于 10%, 在这种情况下就需要依靠老年代的内存分配担保. 当 Survivor 空间并不能保存剩余存活对象, 就将这些对象通过分配担保进制移动至老年代.
老年代中对象存活率将会特别高, 且没有额外空间进行分配担保, 所以并不适合复制算法, 所以需要使用标记 - 清除或标记 - 整理算法.
随便聊聊
最近又到一年一次大考的时候, 不得不又拿起周志明『深入 Java 虚拟机』重新学习. 还记得第一次翻看这本书的时候, 大半内容看不懂, 看完也很快就忘了. 然后过了一段时间, 又重新拿起此书, 这次比上次好, 也已经能看小大半了. 最近跟一些小伙伴聊天, 发现他们都是看这本书学习 JVM , 不得不说这本书真是一本神书. 最近『深入 Java 虚拟机』第三版即将上架开售, 有需要的小伙伴可以考虑入手了.
好了 , GC 机制就就总结到这里, 下一篇我们来聊聊 JVM 常用 GC 回收器.
帮助链接
GC Roots
Java 虚拟机详解 04----GC 算法和种类 https://www.cnblogs.com/qianguyihao/p/4744233.html
深入 Java 虚拟机
来源: http://www.tuicool.com/articles/bIBfaiI