我同学最近在面试java的岗位, 这是他遇到的某些关于java的JVM中垃圾回收相关的部分的问题, 他来问我, 我特以此文章来解答.
公司 京东
base 北京
面试时间 2024年10月23日16:00:00
他跟我说, 面试官一上来就问了一个关于JVM的问题, 直接就给他难住了, 问题是 :
哪些情况下的对象会被垃圾回收机制处理掉?
- 我同学: 额 .. 这个我不太清楚, 应该是没有对象引用这个对象的时候, 就会被清理掉吧.
- 面试官: 确实如此, 但是我怎么知道 某个对象是否被其他对象引用?
- 我同学: 这个我知道(很自信), 使用的是可达性分析法, 如果一个对象存在与GCRoots的 某个节点的引用链中, 那么这个对象就不会被清理.
- 面试官: 那么有哪些可以作为GCRoot呢?
- 我同学: 吧唧吧唧(只答了几点, 没答全)
- 面试官: 那一个对象如果不存在关联的引用链, 那么是否会被清除?
- 我同学: 这个不太清楚....
其实, 你也知道, 这就是想问, 哪些对象会在下一次GC的时候, 被垃圾回收器回收掉. 本问题的本质就是想问你, 内存中的对象的状态变成什么样的时候, 才会被垃圾收集器认定为, 下次必拿下你.
在java中, 显然一个对象如果没有被其他任何对象引用, 那么它就是一个可以被回收的对象, 那么我怎么知道一个对象是否被其他对象引用?
- 思路一: 对象枚举搜索, 找到虚拟机中所有的对象, 然后逐个对象去扫描, 看里面有哪个对象引用了这个对象, 如果存在一个对象引用了当前对象, 那么就不需要回收, 否则标记为需要清除.
这个方法实现起来非常简单, 但是有一个非常严重的问题, 就是他需要扫描所有的对象, 每个对象都要扫描一次, 那么性能消耗是非常大的.
- 思路二 : 引用计数法, 给每一个对象添加一个引用计数器, 每当有地方引用这个对象的时候, 就给计数器+1, 引用失效的时候就-1, 通过这个计数器的引用值是否为0来判断是否为可回收对象.
这个方法也相对比较简单, 实现起来也比较简单, 仅仅只是需要占用些许内存空间, 判定的效率也非常高(只用去读取这个计数值, 就可以知道是否是需要回收), 但是这个算法有一个缺点, 就是无法判断相互引用的对象, 如下:
可以看到外部已经没有任何对象引用这个instance1和这个instance2了, 但是他们的引用计数值都不为0, 因此就会出现误判.
但是也不是说这种方法就不能使用, 只要做好额外的处理, 那么这种方法还是很高效的.
- 思路三: 可达性分析法, 当前主流的Java商用程序的内存管理子系统, 都是使用的可达性分析法, 具体思路就是, 借鉴于 枚举搜索法, 从一系列的GC Roots作为起点集, 从起点集中的结点开始往下扫描, 扫描走过的路径就被称为 "引用链", 如果这个对象扫描了GC Roots之后, 出现在一条相关的引用链上, 那么这个对象就不能被回收.
图中的绿色的引用关系就可以看作为一个引用链.
第三种思路, 就完美的解答了哪些对象会被标记为回收的对象: 利用可达性分析算法,虚拟机会将一些对象定义为 GC Roots,从 GC Roots 出发沿着引用链向下寻找,如果某个对象不能通过 GC Roots 寻找到,虚拟机就认为该对象可以被回收掉
但是这个问题接下来就是第二个问题, 什么是GC Roots? 哪些对象可以被看做是GC Roots?
在Java技术体系里面, 固定可作为GC Roots的对象包括以下几种:
- 虚拟机栈中的本地变量表中引用的对象(每一个线程都有一个私有的虚拟机栈, 虚拟机栈中每次调用一个方法就会创建一个栈帧并且插入到栈中, 这个栈帧中就包括一个变量表, 这个变量表中就可能包含局部变量和参数并且引用了这个对象)
- 我们知道在方法区中, 存储了类的静态属性, 这个静态属性也可能会引用某个对象.
- 方法区中的常量池亦可引用某个对象, 譬如字符串常量池(String Table)里的引用
- 本地方法栈同虚拟机栈, 只不过本地方法栈运行的是一些c/c++编写的代码, 这其中也可能包含对象的引用.
- 上述所说都是用户运行程序产生的数据, JVM本身内部也包含很多类对象, class对象, 包含对其他对象的引用.
- 当线程持有某个对象的锁时(即使用synchronized关键字修饰的代码块或方法), 该对象就被视为活跃的, 并且可能被其他线程通过锁机制进行访问. 因此, 这个对象在垃圾回收过程中不能被轻易回收, 否则可能会导致持有该锁的线程出现异常或死锁等问题
紧接着面试官就问了, 如果一个对象不存在于GCRoots的任何一个引用链, 那么下次垃圾回收, 它一定会被清除吗?
如果你用过ThreadLocal的话, 那么你就会知道, ThreadLocalMap中的ThreadLocal类型的key是弱引用, 如果被回收, 那么ThreadLocalMap中的对应Key将变为null, 但Value仍然保持强引用, 且无法被GC回收, 从而导致内存溢出(内存泄漏).
这里出现了一个关键字, 就是弱引用, 由弱引用, 可以引出Java的四大引用.
Java四大引用是怎么来的?
作为一个Java开发, 你应该知道, 有些对象不是缺了就不行, 但是也不是有就一定行, 我们上述讲解的Java, 抛开四大引用的概念, 就只有一个"引用"的概念, 也就是引用了就必然不会被回收, 但是事实上, 很多场景会出现内存不足的情况, 那么就需要把那些虽然引用了, 但是可以不要的对象给清理掉, 以节省内存空间, 因此就出现了四大引用的概念
四大引用介绍如下:
强引用:
- Java中最常见的引用类型,默认声明时使用的就是强引用。
- 只要存在强引用指向对象,垃圾回收器就永远不会回收该对象,即使内存不足也不会回收,而是直接抛出OutOfMemoryError错误。
- 强引用是导致Java内存泄漏的主要原因之一。
软引用:
- 用于描述一些还有用但非必需的对象。
- 当内存足够时,软引用对象不会被回收;当内存不足时,系统则会回收软引用对象。如果回收后仍然没有足够的内存空间,则会抛出内存溢出异常。
- 软引用非常适合实现内存敏感的缓存,如网页缓存、图片缓存等。
弱引用:
- 用于描述那些非必需的对象。
- 无论内存是否足够,只要JVM开始进行垃圾回收,那些被弱引用关联的对象都会被回收。
- 弱引用非常适合用于临时缓存或临时存储对象,也可以用于解决对象之间的循环引用问题,避免内存泄漏。
虚引用:
- 所有引用类型中最弱的一个。
- 一个对象是否有虚引用的存在,完全不会决定对象的生命周期。也无法通过虚引用来获取被引用的对象。
- 虚引用的主要作用是跟踪垃圾回收过程,在对象被收集器回收时收到一个系统通知。它必须和引用队列一起使用。
无论是哪一种引用, 只要是被标记为了清除(对象在进行可达性分析后发现没
有与GC Roots相连接的引用链), 就会被标记一次, 然后进行筛选, 筛选是否可以进行执行finalize()方法, 如果可以执行finalize()方法(该对象覆盖了Object的finalize()方法, 并且还未执行过finalize()方法), 这个对象就会被放入到一个F-Queue的队列中, 等待一个执行线程来执行这个finalize()方法
请注意这里的执行, 仅仅只是执行, 并不保证一定执行到代码的最后一行并返回, 因为覆盖的finalize()方法中包含用户代码, 如果代码逻辑出现问题, 会在一定程度上造成执行缓慢的情况, 甚至是死循环, 将很可能导致F-Queue队列中的其他对象永久处于等待.
只要在执行的过程中, 对象自我拯救成功: otherObjcet = this; 也就是将自己赋值给另外一个引用变量, 让其存在对应的引用链.
就可以逃脱此次的回收, 直到下一次回收(下一次回收因为finalize方法已经被执行了, 因此直接就会被回收, finalize在对象的生命周期中, 只能被执行一次)
自我拯救的代码如下:
public class FinalizeEscapeGC {public static FinalizeEscapeGC SAVE_HOOK = null;public void isAlive() {System.out.println("yes, i am still alive :)");}@Overrideprotected void finalize() throws Throwable {super.finalize();System.out.println("finalize method executed!");FinalizeEscapeGC.SAVE_HOOK = this;}
}
这样也就完美的回答了面试官的那个问题:
即使不可达,对象也不一定会被垃圾收集器回收,1)先判断对象是否有必要执行 finalize()
方法,对象必须重写 finalize()方法且没有被运行过。2)若有必要执行,会把对象放到一个
队列中,JVM 会开一个线程去回收它们,这是对象最后一次可以逃逸清理的机会。
请注意, 很多人认为这个 finalize方法可以用来资源释放, 请务必别这么做, 可以使用try-finally来平替, 因为finalize的执行是完全不可预知的, 作为资源最后释放的操作, 如果执行失败, 就可以出现资源泄漏的问题, 是非常严重的bug.
finalize方法仅仅只是因为历史原因, 向C++程序员做出的一种妥协.....