浅谈一下垃圾回收机制 主要针对三个问题,总结一下就是谁是垃圾,什么时候处理垃圾,怎么处理垃圾。
- 哪些内存需要回收
- 什么时候回收
- 怎么回收
基于对象的内存模型和运行时数据区的特点,可以分为两类,全局生命区域和线程生命区域
线程生命区域: 针对程序计数器、虚拟机栈、本地方法栈,这些随着线程而生,随着线程而灭。所以不需要考虑太多的回收问题。当线程执行结束之后,内存也跟着回收了
全局生命区域: 针对方法区和堆内存。举个栗子: 一个接口的不同实现类,它所需要的内存可能是不一样的,一个方法的不同条件分支,所需要的内存也可能是不一样的。所以只有在程序运行期间,我们在能够得知创建哪些对象,创建多少对象以及需要多少内存。
针对这个全局生命区域,需要判断哪些对象已经死了? (即不可能会被任何途径使用的对象)
垃圾回收机制是怎么实现的
- 基于可达性分析, 链路追踪能否定位到这个对象, 可以 说明正常, 不行 则GC
引用计数器
实现原理 给每一个实例对象设置一个计数器, 实例化一次+1 ,当引用失效是就 - 1 ,当计数器=0的时候就代表没有任何一个地方有用到这个对象。
现在JVM不使用这种算法,效率不高,需要考虑很多额外的东西,比如单纯的使用计数器无法解决对象之间的循环引用而造成内存泄漏------《深入理解 JVM 虚拟机》。
可达性分析
通过 GCRoot,向下找引用关系,这个过程叫引用链。如果一个对象到 GcRoot 之间没有任何的引用链,就会被标记成不可达,代表着不可用。 下图的 object 5/6/7 都是不可达
在 Java 的技术体系中,可以固定被当做 GcRoot 使用的,有以下几种。
- 在栈中引用的对象,例如各个线程被调用的方法、堆栈使用的参数、局部变量、临时变量等
- 例如 Java 类引用的静态变量
- 方法区中常量引用的对象,如字符串常量池
- 本地方法栈中 JNI(Native 方法)引用的对象
- 类加载器,基本数据类型对应的 class,包括各种锁,以及被锁持有的对象。
人话: 可达性分析的是针对 Java 中任何的对象、引用、变量、class 等信息,组合成 N 个树,通过 Main 线程作为主树,查看这些所有的节点跟 Main 节点是否有关联,没有关联,则判定为 对象不可达
。
针对不同的垃圾收集器,都会有不同的局部回收特征,为了规避 GcRoot 包含的对象过多而过度膨胀,在实现中做了很多的优化和处理
根节点枚举
目前所有的收集器在根节点枚举这一步骤是都是必须暂停用户线程的,这一块和标记整理内存碎片面临的 “Stop The World” 是一样的。 通过可达性分析从 GC Roots 集合中找引用链的操作。
引用的类型
在 Java 中,将引用的类型分为了四种分别是强引用、软引用、弱引用和虚引用。
- 强引用: 通常在程序中所写的
new Object()
的这个new
其实就是强引用。只要这种引用关系还存在,垃圾收集器就永远不会回收被引用的对象。 - 软引用: 通常在程序中表示还有用,但非必须的对象。SoftRefresh 在 JVM 觉得当前内存还够用的情况下就允许你存在,在发生 OOM 之前进行回收。
- 弱引用: 通常在程序中表示能用到就用, WeakRefresh 表示比弱引用还要弱一些,只要垃圾收集器开始工作,就会把弱引用直接回收掉。
- 虚引用: 通常在程序中看不到它的身影,也没有办法通过虚引用来获取一个实例的对象。
基于可达性分析之后的“存活判断”
即使通过可达性分析,找出了某一个对象跟 GcRoot 已经没有了关联,此时也不是非死不可。在这个阶段可以理解成是 缓刑
期间 要真正的宣告对象死亡,还需要经过两次的标记,在可达性分析中是第一次标记。随后则进行一次筛选,判断当前这个对象有没有执行 finalize 方法,这个方法最多只会被调用 1 次 ,在执行完 finalize 之后可达性分析还是判定为不可达的情况下,才会进行垃圾回收
不推荐使用 finalize 去拯救一个对象,就应该直接让他去死。因为官方声明不推荐。 运行代价较高,不确定性强。
回收方法区
JDK 11 的 ZGC 收集器,不支持类卸载 方法区的垃圾回收相对堆内存,收益较低
针对方法区的内存垃圾回收主要针对两个方面
- 废弃的常量、符号引用等
- 不在使用的类型,如何判断一个类属于不再使用的类。
- 该类的所有实例都被回收,并且没有派生子类的实例
- 加载该类的类加载器已经被回收,这点其实很难做到,除非这些条件都被精心的设计过。例如 JSP 的重加载
- 该类的 class 对象没有在任何地方有引用,也不会出现通过反射获取这个类的情况。
垃圾回收算法
简单介绍分代收集理论,和几种算法实现思想。
算法收集的类型
- 引用计数: 在对象上打标记
- 追踪式: 全局查找
分代收集理论
- 弱分代假说: 绝大多数对象都是朝生夕灭的。
- 强分代假说: 熬过月多次垃圾收集过程的对象,就越难以消亡。 这两个分代假说说明: 收集器应该将堆内存分出不同的区域,根据回收对象的年龄分配到不同的区域中。
有了这些区域之后,才有了 minorGC
Major GC
FullGC
这些回收类型的划分。 针对不同的区域和存储对象的存亡特征,去进一步的匹配相应的垃圾回收算法,进而衍生出了 “标记复制”、“标记整理” 、“标记清除”等一系列的回收算法
- 跨代引用假说: 跨代引用相对于同代引用来说仅占极少数。(新生代引用了老年代的情况) 依据这个假说,不应在为了少数的新生代引用,而去扫描整个老年代的内存区域。 只在新生代上加一块数据结构,来记录每一个对象是否有引用老年代的数据(这个数据结构被称之为数据集) 这个结构会把老年代划分成多个小块,标记处老年代的哪块没存会存在跨代引用。此后发生 minor gc 时,只有包含了跨代引用的小块内存会被加入到 CGRoot 进行扫描。 优点: 比起收集时扫描整个老年代,会更快一点 缺点: 需要在对象改变引用关系时,额外的维护数据的正确性。
不同分代的名词
- 部分收集(Partial GC): 指目标不是完整收集整个 Java 堆的垃圾收集,其中又分为
- 新生代收集(Minor GC/Young GC): 指目标只是新生代的垃圾收集
- 老年代收集(Major GC/Old Gc): 指目标只是老年代的垃圾收集。
- 目前只有 CMS 收集器会有单独收集老年代的行为。
- 另外请注意 :“Major GC”这个说法有混淆,需按照上下文来理解到底指的是老年代的收集还是整个堆的收集
- 混合收集(Mixed GC): 指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有 G 1 收集器有这种行为
- 整堆收集(Full GC): 指目标是整个 Java 堆和方法区的垃圾收集
标记清除
最早出现,也是最基础的垃圾收集算法。后续很多算法的演进也都是基于标记清除的思想去升级的。 主要分为两个阶段
- 标记
- 首先标记出所有需要回收的对象(通过可达性分析),也可以反过来标记存活的对象。
- 统一回收所有未被标记的对象,(也可以反过来)
- 标记的过程就是对象是否属于垃圾的判定过程
- 清除
缺点:
- 执行效率不稳定: 如果 Java 堆中包含大量对象,而且大部分的对象需要被回收。它就必须进行大量的标记和清除的动作。导致标记和清除的两个过程执行效率都随着对象数量的增长而降低。
- 内存空间碎片化: 标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后得程序运行过程中需要分配比较大的对象时,没有办法找到足够的连续内存。进而触发另外一次垃圾收集的动作。
标记复制
在标记清除的基础上,提出了 “半可用区” 或者叫 “半区复制”的概念。
实现原理: 在标记完成后,会把可用的数据全部复制到另外的一个半区中,进行删除。在每一次的使用时都去使用另外一半空的内存。
缺点:
- 会产生大量的内存之间复制的开销。
- 只会使用一半的内存,产生空间浪费。
标记整理
标记的过程同标记清除一样,后续的步骤不是直接对可回收对象进行清理,而是让所有的存活对象都向内存的一端去移动,然后直接清理掉边界以外的内存。
和标记清除的区别就是前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的内存存活对象是一项优缺点并存的风险策略
缺点: 针对老年代这种每次回收都有大量的对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用才能进行。(Stop The World)
针对这种情况还有一种和稀泥的解决方案,也就是短期内我使用标记清除,容忍碎片化的内存,当碎片化的内存空间影响到了我正常分配大对象时,再去采用标记整理去收集一次。 (CMS 收集器的解决方案)
标记压缩
当一个对象经历 15 次 GC 都没有死, 那么就会进入老年代
MinorGC vs MajorGC
MinorGC : YoungGC
参考 深入理解 Java 虚拟机