直播: 近二十载从业老兵谈金融科技赋能的探索与实践
本文主要教书在 java 虚拟机垃圾回收机制中, 如何判断对象是否存活和图解垃圾回收算法.
一, 概述
对于 java 程序员来说, 多少听过 GC, 垃圾回收机制这些名词. 不过到底什么是垃圾回收, 哪些是垃圾, 怎么进行回收呢? 本文将会给出答案.
二, 垃圾回收机制
垃圾回收(英语: Garbage Collection, 缩写为 GC), 在计算机科学中是一种自动的存储器管理机制. 当一个计算机上的动态存储器不再需要时, 就应该予以释放, 以让出存储器, 这种存储器资源管理, 称为垃圾回收.
为了方便大家理解, 我就画了一个形象的图, 一家饭店有好多桌子 (连续的内存区域), 顾客(对象) 来店里吔饭, 但是这些顾客很社会, 自己不会吃完了就走, 得让店家往外面赶. 以前是老板娘来干这活 (手动释放内存), 现在引进了吃完饭滚蛋机器人(垃圾回收机制) 来叫吃完的顾客滚蛋.
产生: 首先, 垃圾回收并不是 java 的伴生产物. 最早使用垃圾回收的语言是 1960 年诞生的 Lisp, 垃圾回收器的目的是减轻程序员的负担, 同时也减少程序员犯错的机会. 现在, 经过半个多世纪的发展, 目前垃圾回收技术已经相当成熟, 并且大多数语言都支持垃圾回收, 例如 Python,Erlang,C#,Java 等.
为什么要了解 GC 和内存分配?
当我们需要排查各种内存泄漏, 内存溢出, 当垃圾收集成为系统达到高并发的瓶颈时, 就需要对这种自动化技术进行监督和调节.(吃完饭滚蛋机器人也不是万能的, 也需要老板娘来调节机器人参数)
三, 哪些内存需要回收
首先, 我们知道程序计数器, 虚拟机栈, 本地方法栈这三个区域是线程私有的, 它们是与线程同生共死的; 栈帧是伴随着方法执行进栈, 方法结束出栈, 在类结构确定后, 每个栈帧占多大内存基本确定. 所以这几个区域并不需要进行管理.
然后, java 堆和方法区是内存共享的, 一个接口有多个实现类, 不同的类需要的内存可能不同, 一个方法的不同的分支需要的内存可能不同. 我们只有在系统运行时才能确定需要创建哪些对象, 这里是垃圾回收器的主战场.
垃圾收集策略
引用计数算法(Reference Counting)
给对象添加一个计数器, 每当一个地方引用它时, 计数器就加 1, 引用失效是就减 1. 当计数器为 0 时, 这个对象就不会就不会再被使用了 -- 对象死亡.
引用计数算法实现容易, 效率很不错, 在 Python,Ruby 等语言都使用了这种算法. 但是主流 java 虚拟机并没有使用这种算法来管理内存, 因为无法解决对象的循环引用问题.
- public class ReferenceCounting {
- public static void main(String[] args) {
- Dog dog1 = new Dog();
- Dog dog2 = new Dog();
- // 狗 1 和狗 2 对象之间互相引用
- dog1.setSon(dog2);
- dog2.setSon(dog1);
- // 将两个对象的引用设置为空
- dog1 = null;
- dog2 = null;
- System.gc();
- }
- }
- class Dog {
- private Dog son;
- public Dog getSon() {
- return son;
- }
- public void setSon(Dog son) {
- this.son = son;
- }
- }
在启动参数里设置 - XX:+PrintGCDetails 这个参数, 打印日志
[GC 7926K->480K(502784K), 0.0023280 secs] [Full GC 480K->316K(502784K), 0.0098820 secs]
可已清楚的看到尽管两个对象互相引用, 但仍被回收, 所以 hotspot 并不是引用计数算法算法.
跟踪收集器(Tracing garbage collection)
目前主流的虚拟机 java,C# 都是使用 Tracing garbage collection 来判断对象是否存活的, 以致于当人们提到垃圾回收时就会想到 Tracing garbage collection.
基本思想: 定义一些 GC Roots 的对象为起始点, 追踪对象是否能通过一个引用链 (a chain of references ) 达到这些确定的 GC Roots 对象上, 那些无法达到这些跟对象 (root object) 的对象将被视为已死亡. 这种算法实际实现会复杂多变.
开始画图, 现在我们设置 GC Roots, 有面的碗和点菜单. 那些碗里是空的在点菜单上还没名字的人会被标记为绿色, 存活下来的有, 左上角碗里有面的人, 等上面的非单身狗, 整整齐齐一家人虽然左右两个都是空面, 点菜单上也没有, 但是缺被中间的人引用, 而中间的人恰好碗里有面! 这就是 "追踪吃完饭不走的人方法".
在 java 中, 会设置如下对象为 GC Roots:
虚拟机栈 (栈帧的本地变量表) 中引用的对象: 也就是局部变量引用的对象
方法区中类静态属性引用的对象: public static Dog dog= new Dog();
方法区中常量引用对象: public static final HashMap map = new HashMap();
本地方法栈 JNI 中引用的对象.
可达性分析算法(Reachability analysis):
如果大家读过周志明老师的深入了解 java 虚拟机一定会知道可达性分析这个名词, 也就是这里的 Tracing garbage collection. 开始我以为是两种不同的叫法, 不过我使用 google 搜索 Reachability analysis 时并每有找到和垃圾回收相关的信息, 百度查到的可达性分析算法基本全部出自深入了解 java 虚拟机 wiki 百科里对可达性分析的描述是用于确定分布式系统可以达到全局状态. 而 java 的垃圾回收策略是 Tracing garbage collection. 所以我怀疑可能是深入了解 java 虚拟机用错了名词.
逃逸分析(Escape analysis)
逃逸分析将对象堆上分配 (heap allocations) 转到栈上分配(Stack allocations), 从而减少很多垃圾回收的工作. 在编译时判定在函数内分配的对象是否被外部方法或线程调用, 如果没有则会将对象分配到栈中, 减少垃圾回收工作.
引用
在 jdk1.2 之后, java 对引用的概念进行了扩充, 将引用分为了强引用 (Strong Reference), 软引用(Soft Reference), 弱引用(Weak Reference), 虚引用(Phantom Reference) 四种.
强引用就是指在程序代码之中普遍存在的, 类似 "Object obj = new Object()" 这类的引用, 只要强引用还存在, 垃圾收集器永远不会回收掉被引用的对象
软引用是用来描述一些还有用但并非必需的对象, 对于软引用关联着的对象, 在系统将要发生内存溢出异常之前, 将会把这些对象列进回收范围进行第二次回收. 如果这次回收还没有足够的内存, 才会抛出内存溢出异常. 在 JDK1.2 之后, 提供了 SoftReference 类来实现软引用
弱引用也是用来描述非必需对象的, 但是它的强度比软引用更弱一些, 被弱引用关联的对象, 只能生存到下一次垃圾收集发生之前. 当垃圾收集器工作时, 无论当前内存是否足够, 都会回收掉只被弱引用关联的对象. 在 JDK1.2 之后, 提供了 WeakReference 类来实现弱引用
虚引用也成为幽灵引用或者幻影引用, 它是最弱的一中引用关系. 一个对象是否有虚引用的存在, 完全不会对其生存时间构成影响, 也无法通过虚引用来取得一个对象实例. 为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知. 在 JDK1.2 之后, 提供给了 PhantomReference 类来实现虚引用
一个可以被遗忘的关键字 --finalize
当一个决定一个对象是否需要被回收时需要经历两个标记过程. 第一次是追踪对象是否与 GC Roots 相连, 如果没有进行标记, 第二次是判断对象未重写 finalize 方法, 或者 finalize 方法已经被调用过, 此时对象彻底死亡.
finalize 方法如果重写且未被调用会将对象放到一个低优先级甚至不执行的队列 F-Queue 中, 之后调用对象的 finalize 方法, 如果在方法中对象被 GC Roots 引用, 对象自救成功. 但是 F-Queue 可能不会执行, 所以这种子救方法并这可靠. 有些教程推荐 finallize 来释放资源, 那为什么不用 try-finally 来做呢?
这个关键字可以忘记了.
四, 垃圾收集算法
标记 - 清除 (Mark-Sweep) 算法
标记清除算法包括两个阶段, 首先标记出需要回收的对象(标记方法就在上面), 在标记完成后, 统一回收所有被标记的对象. 标记清楚算法是一所有垃圾回收算法的基础, 后续算法都是根据其不足进行改新.
缺点:
效率低, 标记和清除两个过程效率都不高;
空间零碎, 标记清楚之后会产生大量吧连续的内存碎片, 空间碎片太多, 当有大对象需要分配空间时会提前触发 gc.
空桌子是未使用的内存, 被绿色标记的是可以清除的对象, 这是清除前的状态, 整整齐齐一家人是比较大的对象需要占据连续的区域.
这是清除之后的状态, 内存碎片太多, 当分配比较大的整整齐齐一家人时就会提前触发新的 GC.
复制 (Copying) 算法
为了解决效率问题, 出现了复制算法, 可以将内存划分为大小相等的两块, 每次只使用其中一块, 当这块内存用完将存活的对象复制到另一块内存上去, 将使用过的内存一次清除掉. 这种算法效率高, 但太浪费空间.
如上图所示, 现在使用下半部分内存. 当清理时把未被标记的复制到上面的内存, 然后一次清除下半部分内存.
现在商业虚拟机大多都采用这种算法来回收新生代. 但并不是按照 1:1 来分配内存的, 因为 IBM 做过专门研究, 在新生代中对象 98% 都是朝生幕死的.
将内存划分为一块较大的 Eden 空间和两块较小的 Survivor 空间. 每次使用 Eden 和其中一块 Survivor, 回收时将存活的对象复制到另一块 Survivor 中, 清除 Eden 和被使用的 Survivor. 一般 Eden,Survivor1,Survivor2 比例为 8:1:1, 这样只有 10% 的内存会被浪费.
这里如果将 Eden 翻译为伊甸, 对象出生的地方, Survivor 幸存者, 回收后幸存的对象, 会比较好理解吧.
如果回收后对象对象真的超过了 10%,Survivor 空间不够时, 需要依赖其他内存 (老年代) 进行分配担保(Handle Promotion).
标记整理算法
复制收集算法并不适用于对象存活率较高的情况. 当对象存活过多, 需要复制的对象就会变多, 效率将会下降. 而且如果不想浪费 50% 的空间, 就需要利用额外的空间进行分配担保, 所以老年代并不适用这种算法.
根据老年代的特点, 有人提出的标记整理算法, 将对象标记后, 会将存活的对象都向一端移动, 然后直接清楚掉边界以外的内存.
这个是回收之前
这个是回收之后
分代收集算法
这种算法是指根据对象的存活周期将内存划分为几块, 一般是把 java 堆分为新生代和老年代. 对于每次垃圾收集都有大量对象死亡的新生代, 采用复制算法; 对于存活代高, 又没有额外空间担保的老年代采用标记 - 清楚或标记 - 清理算法.
增量收集器
序将所拥有的内存空间分成若干分区. 程序运行所需的存储对象会分布在这些分区中, 每次只对其中一个分区进行回收操作, 从而避免程序全部运行线程暂停来进行回收, 允许部分线程在不影响回收行为而保持运行, 并且降低回收时间, 增加程序响应速度.
五, 总结
本文介绍了什么是垃圾回收, java 虚拟机的垃圾回收策略, 包括引用计数法, 追踪垃圾回收和逃逸分析, 又用饭店的形式介绍了几种垃圾回收算法, 包括标记 - 清除, 复制算法, 标记 - 整理算法.
来源: http://virtual.51cto.com/art/202004/615041.htm