前言
ladies and gentleman , 你们好😊 ,我是羡羡 , 在前面我们对类加载, 运行时数据区 ,执行引擎等作了详细的介绍 , 这节我们来看另一重点 : 垃圾回收.
一个运行中的程序, 产生的对象是大量的, 如果对象不被继续使用, 就会成为垃圾, 最后越堆越多, 最后占满内存, 所以我们要对这些垃圾进行回收,保持程序的正常运行
目录
垃圾回收概述
垃圾回收是java的招牌能力 ,极大的提高了开发效率, java是自动化的垃圾回收, 其他语言有的则需要程序员手动回收 , 那么什么是垃圾呢?
垃圾是指在运行程序中没有任何引用指向的对象,这个对象就是需要被回收的垃圾。如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。
在早期C/C++时代, 需要手动回收垃圾, 如果一旦疏忽, 还会导致内存泄漏问题
这里引出了两个名词 , 内存溢出和内存泄漏, 先来解释这两个意思
内存溢出和内存泄漏
内存溢出 : 内存被占满, 内存不够用了
内存泄漏 : 程序中存在不被使用的对象(但GC无法判定它们为垃圾), GC(垃圾回收)无法去收集清理它们, 这就导致这块空间一直被占用, 无法释放出来,这就是内存泄漏
一些提供 close() 的对象等, 例如在JDBC中 Connection 没有去关闭等, 这样的越积越多就导致内存泄漏问题 , 内存泄漏越来越多最终会导致内存溢出问题(泄漏逐渐蚕食内存)
实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”。
在单例模式下, 单例的生命周期和程序一样长,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生
那么GC主要关心哪块区域的收集呢?
在前面运行时数据区我们说过 , 可以总结如下 : 频繁回收新生区, 较少回收老年区 , 基本不收集方法区(元空间)
垃圾回收算法
在垃圾回收时, 分为两个阶段, 标记阶段和回收阶段 , 这两个阶段使用了不同的算法思想来区分垃圾 , 我们来依次论述
标记阶段
想要清除垃圾, 我们先得了解什么是垃圾 , 那么如何来判断一个对象是否是垃圾呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时, 就已经是垃圾了
标记阶段有两种算法 : 引用计数算法和可达性分析算法
引用计数算法
这个算法思想比较简单 , 就是使用一个计数器, 如果有一个引用指向这个对象, 那么计数器就加1 , 引用失效计数器就减一 . 计数器为0 则表示该对象可回收
但是这个算法有一个严重的问题, 此问题也导致我们现如今已不再使用此算法 : 无法处理循环依赖问题 , 这是一个致命缺陷 , 什么是循环依赖问题呢 ?
如图 , 引用P 指向对象A , 而对象A又指向对象B, 对象B又指向对象C , 对象C继续指向对象A, 此时将引用 P 置null , 此时这三个对象形成了依赖闭环, 但都没有直接的引用去指向它们, 这时如果采用引用计数算法, 这三个都不为0 , 也就无法被回收,出现内存泄漏问题
所以在这种条件下, 我们提出了
可达性分析算法(根搜索算法、追踪性垃圾收集)
相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题 , 防止内存泄漏的发生。
基本思路如下 :
1.可达性分析算法是以根对象(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
2.使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
3.如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡, 可以标记为垃圾对象。
4.在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
也就是从根对象往下开始搜索 , 如果目标对象不存在引用链 ,则判断可以回收
public static void main(String[] args) {
List<Integer> list = new ArrayList();
while(true){
list.add(new Random().nextInt());
}
}
以上程序, list指向的对象作为根对象, 死循环生成的每个随机数都存在引用链, 所以此程序最终会导致内存溢出
public static void main(String[] args) {
while(true){
Random r = new Random();
}
}
上面的虽然存在引用指向, 但每次循环引用都会改变指向, 也就不存在引用链 , 所以每次都会被回收, 不会导致无法回收的问题
那么哪些对象可以作为根对象呢?
1.虚拟机栈中引用的对象 比如:各个线程被调用的方法中使用到的参数、局部变量等。
2.本地方法栈内 JNI(通常说的本地方法)引用的对象
3.方法区中类静态属性引用的对象,比如:Java 类的引用类型静态变量
4.方法区中常量引用的对象,比如:字符串常量池(StringTable)里的引用
5.所有被同步锁 synchronized 持有的对象
6.Java 虚拟机内部的引用。
7. 基 本 数 据 类 型 对 应 的 Class 对 象 , 一些常驻 的 异 常 对 象 ( 如 :NullPointerException、OutofMemoryError),系统类加载器。
总结就是 : 栈, 方法区 , 字符串常量池等地方对堆空间进行引用的,都可以作为 GC Roots 进行可达性分析
以上可作为根对象的都有这样的特点 : 活跃 , 不可变,存活时间长, 在程序中至关重要. 例如: 静态成员等, 同步锁持有的对象等, 这些都是不能被随意回收的
另外在可达性分析算法枚举根节点(root 对象)时会产生STW(Stop-the-World) , 关于STW , 我们下面来介绍
STW(Stop-the-World)
指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。
我们再次回到上面的问题 , 执行可达性分析算法为什么需要停顿所有java执行线程呢(STW)?
因为对象的状态是不停变化了 , 如果在我们确定哪个对象是垃圾的时候, 此对象的状态还在不停变化时, 这样是没法分析的 , 此时我们去分析能保持一致性的一个快照(某一时间点的执行状态) ,从而得到一个比较准确的结果
需要注意的是, STW是无法避免的 , 和采用哪款GC也无关, 我们只能去尽量减少停顿的时间,STW 是 JVM 在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。
了解了这些, 接着我们来看回收阶段的算法
回收阶段
当GC识别了垃圾之后, 接着就是垃圾回收了, 这里采用了 3 种不同的算法,接着来介绍
标记 - 清除算法
顾名思义, 包括两个阶段 : 标记和清除, 不过此处的标记和垃圾标记阶段的标记可是不同的
标记:Collector 从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header 中记录为可达对象。(注意:标记的是被引用的对象,也就是可达对象,并非标记的是即将被清除的垃圾对象)。
清除:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收。
图示如下 :
这里也需要注意, 此清除也不是简单的清除, 发现了垃圾对象后, 会先维护一个空列表用来记录垃圾的地址, 下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放(也就是覆盖原有的地址)。
标记 - 清除算法比较基础容易理解, 另外它也有很多缺点 , 例如效率不高, GC时存在STW . 另外可以注意到, 这样做会造成空间不是连续的(空间碎片化) , 此时就需要一个空列表来记录这些地址
复制算法
为了解决标记 - 清除算法在效率方面的缺陷 , 复制算法采用将内存按容量划分的方式, 划分成大小相等的两块 , 每次只使用其中的一块. 算法思想如下 :
将正在使用的存活对象全部复制到另一块未被使用空间 , 摆放整齐 , 然后清空此空间所有对象
复制算法优点是 : 简单高效, 不会出现"碎片"问题
缺点当然也很明显 : 需要两倍的内存空间 , 开销较大 , 另外GC如果采用 G1 垃圾回收器的话 , 它将空间拆成了很多份, 如果采用复制算法, 还需要维护各区之间的关系
对于复制算法的思想而言, 如果对老年区采用此算法, 老年区对象较多,存活周期较长, 这时效率就会有点低 , 所以复制算法大多用于 young 区, 幸存者0 区和幸存者1 区之间的相互转换中
标记 - 压缩算法
上面我们说过, 复制算法相对于老年区来说, 效率就有点低了 , 所以针对老年区的回收, 就采用了标记 - 压缩算法 , 标记 - 清除算法虽然也可以应用于老年区, 但是效率低下, 容易产生内存碎片
算法思想 :
第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间。
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法 , 标记- 压缩是移动式的 , 将对象在内存中依次排列比维护一个空列表少了不少开销(如果对象排列整齐,当我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可)
优点 : 相对于标记 -清除算法避免了内存碎片化 , 相对于复制算法, 避免开辟额外的空间
缺点 : 从效率上来说是不如复制算法的 , 移动时, 如果存在对象相互引用, 则需要调整引用的位置, 另外移动过程中也会有STW
三种算法的比较
复制算法是效率最高的 , 但是花费空间最大
标记 - 压缩算法虽然较为兼顾 , 但效率也变低, 比标记- 清除多了个整理内存的过程, 比复制算法多了标记的过程
总结
到此关于 jvm 的大部分已经讲述完了, 在后续会再补充两个部分 : 对象的finalize() 方法机制和对象的引用 ,感谢您的阅读与关注 ,谢谢 !!!