基本面试都会用到,假如面试官来一句,说说你对Java垃圾回收机制的了解,如果你没概念,基本凉了,一些大厂最后面也基本会问这个问题,一般是为了帮你定级。
系列文章分3个部分
一、概念
这里说的GC回收,指的是 Java 堆的地方,这是一篇你能看懂 Java JVM 文章 中,我们知道了程序计算器,虚拟机栈和本地方法栈都是随线程开启,随线程关闭的,因此这几块区域的内存分配和回收都具备确定性。而Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有程序在运行时,才知道创建了哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的就是这部分内存。 而 GC 关注的也就3个点
- 哪些对象需要回收
- 什么时候回收
- 如何回收
二、哪些对象需要回收
怎么判断对象是“存活” 的,还是已经"死亡"呢?主要有以下方法:
2.1 引用计算算法
给对象添加一个引用计算器,每当有一个地方引用它,则加1,当引用失效,则减1;任何时刻计算器为0的对象就是不可能再被使用的。但它很难解决对象之间相互循环引用的问题,所以主流的Java虚拟机都没有采用这种算法。
2.2 可达性分析算法
通过一系列的 "GC Roots" 的对象作为起始点,从这些起始点开始向下搜索,搜索走过的路径被称为引用链(Reference Chain),当一个对象到GC Roots 没有任何引用链项链,即GC Roots 不可达,则证明此对象是不可用的,如下图(java 虚拟机第三版)
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中的类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI (Native方法)引用的对象
关于这几个区的介绍,这是一篇你能看懂 Java JVM 。
引用判断
无论是引用计数算法,还是可达性分析算法,对象是否存货都跟 "引用" (reference)有关,在JDK1.2 之后,引用可分为以下4个
- 强引用:直接 new ,如 new Object(); 只要这类强引用还在,对象就不会回收
- 软引用:(SoftReference类)用来描述一些还有用但非必需的对象;当将来发生内存溢出之前,系统会把这些有软引用的对象列入回收范围中进行二次回收,如果这次回收还没有足够的内存,则内存溢出报异常;
- 弱引用:(WakeReference类) 被弱引用的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器开始工作时,无论内存是否足够,都会对这些对象进行回收。
- 虚引用(幽灵引用/幻影引用):(PhantomReference类) 一个虚引用的对象,它的唯一目的就是能在这个对象被收集器回收时收到一个对象实例,即一个对象是否有虚引用,完全不会对其生存时间构成影响。
三、什么时候回收
在可达性分析算法不可达的对象,也不一定"非死不可',它会经历两次标记,一是当不可达 GC Roots 时,标记一次并筛选,筛选的条件是该对象是否是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或者已经执行过 finalize 方法时,则认为此对象会被回收。如下图
3.2 回收方法区
上面都是对 Java 堆进行回收,虽说 Java 堆 可以回收70%~95%的空间,但方法区同样可以回收一些资源,方法区主要回收两个部分废弃常量和无用的类。 废弃常量: 当前系统没有任何一个 String 对象引用这个 "abc" 的常量池,也没有其他地方引用了这个字面量,这时可以判断这个常量是可以废弃回收的;其他常量池中的接口,字段的符号引用也以此类似 无用的类: 无用类的回收,需要满足三个条件
- 是该类所有的实例都已经被回收,也就是Java堆中部存在实例
- 加载该类的 ClassLoader 已经被回收
- 对应的 java.lang.class 对象没有再任何地方被引用,也无法通过反射拿到该类 当然,这里跟Java堆一样,也只是 "可以回收"了,是否对类进行回收,可以对虚拟机的参数进行设置,这里就不细讲了。
四、如何回收
关于对象的回收,就涉及到 垃圾收集算法了。可以参考 JAVA 垃圾回收机制(二) --- GC回收具体实现 第二节
4.1 分代算法
在说明这些回收机制之前,我想说明以前就听说过的 新生代和老年代的问题。那么这个分代算法是怎么回事呢? 首先先要理解,新生代和老年代都是一个内存空间,由参数配置,只是可以根据算法,决定对象是在新生代还是在老年代的内存区域!!! 一块内存可以分为3个区域,一个 Eden 和两个 Survivor 区,当对象在 Eden 创建,并经理了第一次 GC 之后仍然存活,并且能被 survivor 区容纳的话,将移到 survivor 区;对象在Survivor 区中“熬过”一次,年龄增加1,当增加到 15 岁(默认,这个阈值可以通过 -XX:MaxTenuringThreshold 设置),就会晋升成老年代的对象。 从这里来看,可以得到两个结论
- 新生代:对象少,垃圾多
- 老年代:对象多,垃圾少 (毕竟经历了10几次GC的老油条)
为什么是划分成一个 Eden 和 两个 Survivor 呢,可以查看这篇文章: blog.csdn.net/qq_35181209…
4.2 标记-清除算法
这里的标记指的是对象进过前面第三章我们介绍的那样,已经可以判定就是可以回收的意思;这个算法首先标记处所有需要回收的对象,在标记完成之后统一回收所有被标记的对象。 看似美好,实则不然,主要有以下两个缺点:
- 效率问题:标记和清除两个效率都不高
- 空间问题:标记清除后,会产生大量空间碎片,在大对象需要分配空间时,找不到内存,从而又触发 GC 操作。
标记和清除的执行过程如下图:
4.3 复制算法
复制算法可以分为等比例的和8:2两种
4.3.1 1:1 比例
基于 标记-清除算法的效率问题,复制算法出现的。 这种算法是把内存分为相等的两块,一块用来存储对象,当GC操作后,把还存留的对象移动到未存对象的那块内存区域,再把使用过的内存清掉。这样每次都对整个半区进行内存回收,就不用担心内存碎片的问题了。执行过程如下图:
4.3.2 8:1 比例
由于上面的 1:1 的铺张浪费,基本主流的 Java 虚拟机都是采用 一个 Eden 和 两个 Survivor 空间,这也是上面说的新生代和老年代的区分。因为新生代的对象 98% 都是"朝生夕死"的,每次都是用块个 Eden 和 一块 Survivor 空间,每次GC之后,还存活在 Eden 和 Survivor 空间的对象会被移动到另外一块Survivor上,并清掉 Eden 和刚才用过的 Survivor 空间。当Survivor 控件不够用时,还得依赖其他内存(这里指老年代),进行分配担保。即存活下来的内存不够放新生代上,只有移动到老年代的内存空间上。 举个简单例子:假如虚拟机中设置了新生代的内存大小为10M,老年代的也为10M,Eden 和 Survivor 为 8:1 的关系,那么Eden就只有8M,Survivor 为1M;下面创建4个对象
public static void test(){
// 假设 _1MB 字符创为 1MB
byte[] b1 = new byte[2*_1MB];
byte[] b2 = new byte[2*_1MB];
byte[] b3 = new byte[2*_1MB];
byte[] b4 = new byte[4*_1MB];
}
复制代码
当执行 test() ,当要分配 b4 对象时,会执行一次 Minor GC,原因是 Dden 才 6M,被b1,b2,b3填充之后,已经没有数据去填充 b4了,就会触发 GC,而b4没办法,只有移动到老年代的内存区域了。
那我们可以得到,** 对象存活率较高的情况下,效率较低;如果不想浪费 50% 的控件,就需要额外的控件进行分配担保,也就是内存跑到老年代去了,那这个对象没进行GC就跑到老年代去了,那肯定是很占内存的** 所以就有了 标记-整理算法
4.4 标记-整理算法
标记整理算法,是针对老年代的。它与标记-清除算法一样,分两个步骤
- 标记那些被引用的对象
- 将被标记的对象移动按顺序移动到一端,然后清除掉可回收的对象
五、总结 (分代收集算法)
在商业的虚拟机中,都采用 分代收集算法,根据对象存活周期将内存划分为几块,即新生代和老年代; 在新生代中,每次都有大量对象死去,只有少量活着,就选用复制算法;而老年代的对象存活率比较高,没有额外空间对它进行分配担保,就必须使用 “标记-整理”或者“标记-清理” 进行回收。
至此,Java 回收机制(一) -- 对象回收与算法初识就讲完啦。。。