Java 与 C++ 间有一堵由动态内存分配和垃圾收集技术所围成的墙, 外面的人想进来, 里面的人却想出去
概述
GC 需要完成的 3 件事情
哪些内存需要回收
什么时候进行回收
怎么进行回收
意义 目前动态内存分配和垃圾手记技术已经很成熟, 一切似乎已经进入自动化时代, 为什么我们还要去了解 GC 和动态内存分配呢? 答案很简单: 当出现内存泄露内存溢出问题时, 当垃圾回收成为系统达到更高并发量的瓶颈时, 了解这些自动化技术就显得很有必要
前章回顾 前章介绍了 Java 运行时内存的各个区域, 其中程序计数器虚拟机栈本地方法栈都是随线程而生, 随线程而灭, 栈的栈帧随方法的调用而入栈, 随方法的完成而出栈每一个栈帧中分配的内存大小在编译期就明确可知, 因此这几个区域的内存分配和回收都具有确定性, 所以这几个区域不需要过多考虑内存回收的问题, 因为方法或线程结束时, 内存也随之跟着回收而 Java 堆和方法区不一样, 因为只有程序处于运行期间才能知道会创建哪些对象, 这部分内存的分配和回收是动态的垃圾收集所关注的也是这部分内存, 一下提到的内存都指这一部分内存
对象已死吗
引用计数法
给对象添加一个引用计数器, 每当对象被引用时, 计数器值加 1, 当引用失效时, 计数器值减 1, 当计数器值为 0 时, 说明对象没有被其他地方引用, 即对象已死客观地说, 引用计数法 (Reference Counting) 的实现简单, 判断效率也很高, 但是, 主流的 Java 虚拟机都没有采用引用计数法来判断对象是否已死, 因为它有一个致命问题 - 无法解决对象间相互引用的问题
代码展示:
可达性分析法
基本思路: 通过一系列的称为 GC Roots 的对象作为起始点, 从这些起始点往下搜索, 搜索走过的路径称为引用链, 当一个对象和 GC Roots 没有任何引用链时(即 GC Roots 到这个对象是不可达的), 说明对象是无用的
在 Java 中可作为 GC Roots 的对象有下面几种:
虚拟机栈 (栈帧中的本地变量表) 中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
本地方法栈中引用的对象
引用的四种类型
强引用
软引用
弱引用
虚引用
对象死亡的历程
可达性分析法中不可达的对象也不是非死不可的, 而是出于缓刑阶段要宣告一个对象的死亡至少要经过两次标记过程: 当经过可达性分析后发现对象与 GC Roots 不可达, 那么它会被第一次标记并且进行一次刷选, 刷选的条件是此兑对象是否有必要执行 finalize 方法当对象没有覆盖 finalize 方法或对象的 finalize 方法已经被虚拟机执行过, 这两种情况都会被视为不需要执行 finalize 方法
如果这个对象有必要执行 finalize 方法, 那么对象会被放在 F-Queue 的队列中, 并且会被由 Java 虚拟机自动创建的低优先级的 Finalizer 线程去执行 finalize 方法是对象最后一次逃脱死亡的机会, 在 finalize 方法后, GC 将会对对象进行第二次标记如果对象在 finalize 方法中成功拯救自己, 那么在第二次标记时会被移出回收集合, 否则就真的被回收了
代码展示:
- package com.whut.java;
- /**
- * User: Chunguang Li
- * Date: 2018/3/8
- * Email: 1192126986@foxmail.com
- */
- /**
- * 代码演示了两点:
- * 1. 对象可以在 GC 时自救
- * 2. 自救的机会只有一次, 因为一个对象的 finalize 方法只会被 JVM 调用一次
- */
- public class FinalizeEscapeGC {
- public static FinalizeEscapeGC finalizeEscapeGC = null;
- public void isAlive(){
- System.out.println("i still alive...");
- }
- @Override
- protected void finalize() throws Throwable {
- super.finalize();
- System.out.println("execute finalize method...");
- }
- public static void main(String[] args) throws InterruptedException {
- finalizeEscapeGC = new FinalizeEscapeGC();
- finalizeEscapeGC = null;
- // 显示调用 gc
- System.gc();
- // 第一次自救
- // 因为 Finalizer 线程优先级很低, 需要暂停 0.5 秒时间等待 Finalizer 线程执行对象的 finalize 方法
- Thread.sleep(500);
- if (finalizeEscapeGC != null){
- finalizeEscapeGC.isAlive();
- }else {
- System.out.println("i am dead...");
- }
- finalizeEscapeGC = null;
- System.gc();
- // 自救失败
- Thread.sleep(500);
- if (finalizeEscapeGC != null){
- finalizeEscapeGC.isAlive();
- }else {
- System.out.println("i am dead...");
- }
- }
- }
回收方法区
很多人认为方法区 (虚拟机中的永久代) 是没有垃圾回收的, Java 虚拟机规范也确实说过不要求虚拟机在方法区实现圾回收, 因为方法区的垃圾收集效率很低
方法区的垃圾收集主要回收两部分内容: 废弃常量和无用的类
回收废弃常量 回收废弃常量与回收 Java 堆中的对象类似, 以常量池中的字面量的回收为例: 如果 abc 字符串存储在常量池中, 其他地方没有任何对象引用常量池中的 abc 常量, 那么进行垃圾回收时 abc 常量会被清理出常量池常量池中的其他类 (接口) 方法字段的符号引用也与此类似
无用的类 判断无用的类比废弃常量条件苛刻得多必须满足一下三个条件:
该类的所有实例都已被回收
加载该类的 ClassLoader 已被回收
该类对应的 java.lang.Class 对象没有在任何地方呗引用, 无法在任何地方通过反射访问该类
垃圾收集算法
标记 - 清除算法
最基础的收集算法
工作原理 算法主要分为两个阶段 - 标记和清除: 首先标记出所有需要回收的对象, 标记完成后统一进行清除
缺点
效率问题: 标记和清除两个过程效率都不高
空间问题: 对象清除后会产生大量不连续的空间碎片, 当需要分配给大对象分配较大的内存空间时会因为找不到足够的连续空间而不得不提前触发下一次垃圾收集
复制算法
为解决效率问题, 复制算法出现了: 它将内存空间分为大小相等的两块区域, 每次只使用其中一块, 当进行垃圾收集时, 将这块区域中还存活的对象复制到另一块, 然后将这一块内存回收这样就不会产生内存碎片的问题这种算法实现简单, 运行高效, 只是代价是每次只能使用内存的一半, 代价过高
现在的商用虚拟机都采用这种收集算法回收新生代内存根 IBM 公司的研究表明, 新生代中的内存对象 98% 是朝生夕死的, 所以不需要按照 1:1 的比例来划分内存空间, 而是将内存分为一块较大的 Eden 区域, 两块较小的 Survivor 区域每次只使用一块 Eden 区域和一块 Survivor 区域, 当进行垃圾收集时, 将 Eden 区域和 Survivor 区域仍然存活的对象复制到另一块 Survivor 区域, 然后将 Eden 区域和使用过的 Survivor 区域清除 HotSpot 虚拟机默认的 Eden 和 Survivor 区域大小比例为 8:1, 这样只会浪费 10% 的内存
标记 - 整理算法
复制算法在对象成活率较低的新生代比较适用, 而对于对象成活率较高的老年代就需要进行较多的复制操作, 效率明显会减低所以针对老年代的特点, 提出了标记 - 整理算法: 标记清除过程仍然与标记清除算法一样, 只是在清除后将存活的对象都向一端移动
分代收集算法
当前商业的虚拟机的垃圾收集算法都采用分代收集算法: 根据对象存活周期的不同将内存划分为几块, 一般把 Java 堆分为新生代和老年代, 再根据各个年代的特点选择合适的收集算法
在新生代中, 对象存活率低, 适合使用复制算法, 而老年代对象的存活率高, 适合使用标记 - 清除算法或标记 - 整理算法
垃圾收集器
收集算法是内存回收的方法论, 那么收集器就是收集算法的实现
Serial 收集器 - 新生代收集器
Serial 收集器是最基本最悠久的收集器这个收集器是一个单线程收集器, 在它进行垃圾收集时, 必须停掉所有其他的工作线程, 然后以一条收集线程进行垃圾收集, 直到收集工作结束, 才可以恢复其他工作线程这对于许多应用是难以接受的但是对 Client(客户端)模式的虚拟机来说, Serial 收集器是一个不错的选择, 因为在桌面端应用, 分配给虚拟机的内存不会太大, 收集几十兆到几百兆的新生代内存停顿时间完全可以控制在几十毫秒
ParNew 收集器 - 新生代收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本, 除了使用多协调线程进行垃圾收集外, 其余的 Serial 收集器完全一样 ParNew 收集器在单 CPU 或 CPU 数量少的环境中性能不会有比 Serial 收集器更好的结果, 但是随着 CPU 数量的增多, 它 GC 时对 CPU 资源的的有效利用还是很有好处的, 所以它是许多运行在 Server 模式下的虚拟机的首先新生代收集器
Parallel Scavenge 收集器 - 新生代收集器
它看上去似乎与 ParNew 一样, 但是它的目标是达到一个可控制的吞吐量 (吞吐量 = 运行用户代码时间 / (运行用户代码时间 + GC 时间)) 停顿时间越短, 就越适合与用户交互的程序, 因为良好的响应时间可以提高用户的体验, 而吞吐量则可以高效利用 CPU 时间尽快完成程序的计算任务, 主要适合在后台运算而需要交互任务
Parallel Scavenge 收集器提供了两个参数用于控制吞吐量:
最大垃圾收集停顿时间:-XX:MAxGCPauseMillis
设置吞吐量大小:-XX:GCTimeRatio
Serial Old 收集器 - 老年代收集器
Serial Old 收集器是 Serial 收集器的老年代版本, 同样是一个单线程收集器, 使用标记 - 整理算法
Parallel Old 收集器 - 老年代收集器
Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本, 使用多线程和标记 - 整理算法, 主要配合 Parallel Scavenge 收集器组成吞吐量优先组合
CMS 收集器 - 老年代收集器
CMS(Concurrent Mark Sweep)是一款以获取最短回收停顿时间为目的的收集器 CMS 非常适合 B/S 系统服务端的 Java 应用, 因为这类应用尤其注重服务的响应时间, 希望系统的停顿时间越短 CMS 是基于标记 - 清除算法的运作流程分为 4 个部分:
初始标记: 标记 GC Roots 能关联到的对象, 速度很快
并发标记: 进行 GC Roots Tracing
重新标记: 为了修改并发标记期间因程序继续运行而导致标记产生变动的对象的标记
并发清除 初始标记和重新标记仍需要 Stop The World, 而并发标记和并发清除可以与用户线程一起并发工作 CMS 的主要特点是: 并发收集低停顿
来源: https://juejin.im/post/5aa0e8176fb9a028d663be1e