说到 Java 虚拟机不得不提的一个词就是 “垃圾回收”(GC,Garbage Collection),而垃圾回收的执行速度则影响着整个程序的执行效率,所以我们需要知道更多关于垃圾回收的具体执行细节,以便为我们选择合适的垃圾回收器提供理论支持。
我们本课时的面试题是,如何判断一个对象是否“死亡”?垃圾回收的算法有哪些?
#### 典型回答
垃圾回收器首先要做的就是,判断一个对象是存活状态还是死亡状态,死亡的对象将会被标识为垃圾数据并等待收集器进行清除。
判断一个对象是否为死亡状态的常用算法有两个:引用计数器算法和可达性分析算法。
引用计数算法(Reference Counting) 属于垃圾收集器最早的实现算法了,它是指在创建对象时关联一个与之相对应的计数器,当此对象被使用时加 1,相反销毁时 -1。当此计数器为 0 时,则表示此对象未使用,可以被垃圾收集器回收。
引用计数算法的优缺点很明显,其优点是垃圾回收比较及时,实时性比较高,只要对象计数器为 0,则可以直接进行回收操作;而缺点是无法解决循环引用的问题,比如以下代码:
```
class CustomOne {
private CustomTwo two;
public CustomTwo getCustomTwo() {
return two;
}
public void setCustomTwo(CustomTwo two) {
this.two = two;
}
}
class CustomTwo {
private CustomOne one;
public CustomOne getCustomOne() {
return one;
}
public void setCustomOne(CustomOne one) {
this.one = one;
}
}
public class RefCountingTest {
public static void main(String[] args) {
CustomOne one = new CustomOne();
CustomTwo two = new CustomTwo();
one.setCustomTwo(two);
two.setCustomOne(one);
one = null;
two = null;
}
}
```
即使 one 和 two 都为 null,但因为循环引用的问题,两个对象都不能被垃圾收集器所回收。
可达性分析算法(Reachability Analysis) 是目前商业系统中所采用的判断对象死亡的常用算法,它是指从对象的起点(GC Roots)开始向下搜索,如果对象到 GC Roots 没有任何引用链相连时,也就是说此对象到 GC Roots 不可达时,则表示此对象可以被垃圾回收器所回收,如下图所示:
![](https://img.kancloud.cn/5d/a2/5da23dd52f2a63f54f3a8d004ff2a985_1290x928.png)
当确定了对象的状态之后(存活还是死亡)接下来就是进行垃圾回收了,垃圾回收的常见算法有以下几个:
* 标记-清除算法;
* 标记-复制算法;
*
标记-整理算法。
标记-清除(Mark-Sweep)算法属于最早的垃圾回收算法,它是由标记阶段和清除阶段构成的。标记阶段会给所有的存活对象做上标记,而清除阶段会把没有被标记的死亡对象进行回收。而标记的判断方法就是前面讲的引用计数算法和可达性分析算法。
标记-清除算法的执行流程如下图所示:
![](https://img.kancloud.cn/93/56/9356294fe62ffa76ceb548d29c62ee6c_832x802.png)
从上图可以看出,标记-清除算法有一个最大的问题就是会产生内存空间的碎片化问题,也就是说标记-清除算法执行完成之后会产生大量的不连续内存,这样当程序需要分配一个大对象时,因为没有足够的连续内存而导致需要提前触发一次垃圾回收动作。
标记-复制算法是标记-清除算法的一个升级,使用它可以有效地解决内存碎片化的问题。它是指将内存分为大小相同的两块区域,每次只使用其中的一块区域,这样在进行垃圾回收时就可以直接将存活的东西复制到新的内存上,然后再把另一块内存全部清理掉。这样就不会产生内存碎片的问题了,其执行流程如下图所示:
![](https://img.kancloud.cn/24/53/2453b405d8c72b15060a2088a6d57414_828x750.png)
标记-复制的算法虽然可以解决内存碎片的问题,但同时也带来了新的问题。因为需要将内存分为大小相同的两块内存,那么内存的实际可用量其实只有原来的一半,这样此算法导致了内存的可用率大幅降低了。
标记-整理算法的诞生晚于标记-清除算法和标记-复制算法,它也是由两个阶段组成的:标记阶段和整理阶段。其中标记阶段和标记-清除算法的标记阶段一样,不同的是后面的一个阶段,标记-整理算法的后一个阶段不是直接对内存进行清除,而是把所有存活的对象移动到内存的一端,然后把另一端的所有死亡对象全部清除,执行流程图如下图所示:
![](https://img.kancloud.cn/4f/46/4f465adcb98355541223bb4f02385237_830x774.png)
#### 考点分析
本题目考察的是关于垃圾收集的一些理论算法问题,都属于概念性的问题,只要深入理解之后还是挺容易记忆的。和此知识点相关的面试题还有这些:
* Java 中可作为 GC Roots 的对象有哪些?
* 说一下死亡对象的判断细节?
#### 知识扩展
* [ ] GC Roots
在 Java 中可以作为 GC Roots 的对象,主要包含以下几个:
* 所有被同步锁持有的对象,比如被 synchronize 持有的对象;
* 字符串常量池里的引用(String Table);
* 类型为引用类型的静态变量;
* 虚拟机栈中引用对象;
* 本地方法栈中的引用对象。
* [ ] 死亡对象判断
当使用可达性分析判断一个对象不可达时,并不会直接标识这个对象为死亡状态,而是先将它标记为“待死亡”状态再进行一次校验。校验的内容就是此对象是否重写了 finalize() 方法,如果该对象重写了 finalize() 方法,那么这个对象将会被存入到 F-Queue 队列中,等待 JVM 的 Finalizer 线程去执行重写的 finalize() 方法,在这个方法中如果此对象将自己赋值给某个类变量时,则表示此对象已经被引用了。因此不能被标识为死亡状态,其他情况则会被标识为死亡状态。
以上流程对应的示例代码如下:
```
public class FinalizeTest {
// 需要状态判断的对象
public static FinalizeTest Hook = null;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("执行了 finalize 方法");
FinalizeTest.Hook = this;
}
public static void main(String[] args) throws InterruptedException {
Hook = new FinalizeTest();
// 卸载对象,第一次执行 finalize()
Hook = null;
System.gc();
Thread.sleep(500); // 等待 finalize() 执行
if (Hook != null) {
System.out.println("存活状态");
} else {
System.out.println("死亡状态");
}
// 卸载对象,与上一次代码完全相同
Hook = null;
System.gc();
Thread.sleep(500); // 等待 finalize() 执行
if (Hook != null) {
System.out.println("存活状态");
} else {
System.out.println("死亡状态");
}
}
}
```
上述代码的执行结果为:
```
执行了 finalize 方法
存活状态
死亡状态
```
从结果可以看出,卸载了两次对象,第一次执行了 finalize() 方法,成功地把自己从待死亡状态拉了回来;而第二次同样的代码却没有执行 finalize() 方法,从而被确认为了死亡状态,这是因为任何对象的 finalize() 方法都只会被系统调用一次。
虽然可以从 finalize() 方法中把自己从死亡状态“拯救”出来,但是不建议这样做,因为所有对象的 finalize() 方法只会执行一次。因此同样的代码可能产生的结果是不同的,这样就给程序的执行带来了很大的不确定性。
#### 小结
本课时讲了对象状态判断的两种算法:引用计数算法和可达性分析算法。其中引用计数算法无法解决循环引用的问题,因此对于绝大多数的商业系统来说使用的都是可达性分析算法;同时还讲了垃圾回收的三种算法:标记-清除算法、标记-复制算法、标记-整理算法,其中,标记-清除算法会带来内存碎片的问题,而标记-复制算法会降低内存的利用率。所以,标记-整理算法算是一个不错的方案。
#### 课后问答
* 1、这个文档是不是有点问题?上面说的GC roots,在知识扩展,哪些可以作为GC roots对象时,却是CG root
讲师回复: 检查了,文稿写的是 GC Roots
* 2、GC Roots 的对象: 字符串常量池里的引用 这个怎么理解?字符串常量池不都是一些字符串常量吗?垃圾回收算法 为什么没有讲到 分代垃圾回收算法呢?
编辑回复: Java 中有两个不常用的概念——字面量和符号引用,可以配合其他资料看看哈。本专栏会讲一些比较常见的知识点,但整个 Java 体系还有很多的内容,可以配合其他资料一起看哦。
* 3、讲师你好,1、怎样判断对象到 GC Roots 没有任何引用链相连呢?在代码中是怎样体现呢?2、垃圾回收的·fullGc是否是判断没有相连后才进行回收,而Mingor GC只是用来标记GC的次数吗?还是?3、强弱软虚四种引用在分别是怎样判断和GC Roots判断没有任何相连的呢?困扰了很多,但是看书没看懂,期待回复
讲师回复: 目前主流的虚拟机都是采用 GC Roots Tracing 的算法来实现跟引用查找的,该算法的核心算法是从 GC Roots 对象作为起始点,利用数学中图论知识,图中可达对象便是存活对象,而不可达对象则是需要回收的垃圾内存,这个不做 JVM 开发建议了解就可以了。 垃圾回收要看具体的垃圾回收器,可以看下 25 课时回收的执行流程,那个里面有答案哦。
- 前言
- 开篇词
- 开篇词:大厂技术面试“潜规则”
- 模块一:Java 基础
- 第01讲:String 的特点是什么?它有哪些重要的方法?
- 第02讲:HashMap 底层实现原理是什么?JDK8 做了哪些优化?
- 第03讲:线程的状态有哪些?它是如何工作的?
- 第04讲:详解 ThreadPoolExecutor 的参数含义及源码执行流程?
- 第05讲:synchronized 和 ReentrantLock 的实现原理是什么?它们有什么区别?
- 第06讲:谈谈你对锁的理解?如何手动模拟一个死锁?
- 第07讲:深克隆和浅克隆有什么区别?它的实现方式有哪些?
- 第08讲:动态代理是如何实现的?JDK Proxy 和 CGLib 有什么区别?
- 第09讲:如何实现本地缓存和分布式缓存?
- 第10讲:如何手写一个消息队列和延迟消息队列?
- 模块二:热门框架
- 第11讲:底层源码分析 Spring 的核心功能和执行流程?(上)
- 第12讲:底层源码分析 Spring 的核心功能和执行流程?(下)
- 第13讲:MyBatis 使用了哪些设计模式?在源码中是如何体现的?
- 第14讲:SpringBoot 有哪些优点?它和 Spring 有什么区别?
- 第15讲:MQ 有什么作用?你都用过哪些 MQ 中间件?
- 模块三:数据库相关
- 第16讲:MySQL 的运行机制是什么?它有哪些引擎?
- 第17讲:MySQL 的优化方案有哪些?
- 第18讲:关系型数据和文档型数据库有什么区别?
- 第19讲:Redis 的过期策略和内存淘汰机制有什么区别?
- 第20讲:Redis 怎样实现的分布式锁?
- 第21讲:Redis 中如何实现的消息队列?实现的方式有几种?
- 第22讲:Redis 是如何实现高可用的?
- 模块四:Java 进阶
- 第23讲:说一下 JVM 的内存布局和运行原理?
- 第24讲:垃圾回收算法有哪些?
- 第25讲:你用过哪些垃圾回收器?它们有什么区别?
- 第26讲:生产环境如何排除和优化 JVM?
- 第27讲:单例的实现方式有几种?它们有什么优缺点?
- 第28讲:你知道哪些设计模式?分别对应的应用场景有哪些?
- 第29讲:红黑树和平衡二叉树有什么区别?
- 第30讲:你知道哪些算法?讲一下它的内部实现过程?
- 模块五:加分项
- 第31讲:如何保证接口的幂等性?常见的实现方案有哪些?
- 第32讲:TCP 为什么需要三次握手?
- 第33讲:Nginx 的负载均衡模式有哪些?它的实现原理是什么?
- 第34讲:Docker 有什么优点?使用时需要注意什么问题?
- 彩蛋
- 彩蛋:如何提高面试成功率?