本文转载自微信公众号「飞天小牛肉」,作者小牛肉。转载本文请联系飞天小牛肉公众号。
举个例子:
User user = new User("Jack");
user 这个变量是存在栈中的对吧,name = Jack 的这个 User 对象是存在堆中的,创建对象自然是为了后续使用该对象,那么如何在堆中找到这个对象的具体位置呢(也称为对象的访问定位)?
对象的访问定位方式是由虚拟机 GC 的具体实现来决定的,保守式 GC 使用的对象访问定位方式是使用句柄访问,准确式 GC 使用的对象访问定位方式是直接指针访问。
这里出现了几个专有名词哈,下面我来一一解释 👇
老规矩,背诵版在文末。点击阅读原文可以直达我收录整理的各大厂面试真题
保守式 GC 与使用句柄访问
谈到垃圾回收必然离不开对象标记算法,众所周知,目前主流的对象标记算法就是可达性分析法,简单来说,可达性分析法是从 GC Roots 出发(注意是 GC Roots 说明是有多个 GC Root),当某个对象到 GC Roots 没有任何引用链时,则该对象判定为可回收对象。
那么什么东西可以能作为 GC Roots 呢:
- 在虚拟机栈中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
- 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象
- 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用
- ......
针对到对象的访问定位(从栈中变量定位堆中对象)这个问题,我们可以就取虚拟机栈(栈帧中的本地变量表)中引用的对象来说明。
经过上面的描述,问题已经简化成如何判断虚拟机栈中的数据存的是一个引用还是一个基本数据?
打个比方:
从图中可以看出,对于变量 a,JVM 在得到 a 的值后,肯定能够立刻判断出它不是一个引用,为什么?
因为引用是一个地址,JVM 中地址是 32 位的,也就是 8 位的 16 进制,很明显 a 是一个 4 位 16 进制,不能作为引用(这里的专业术语叫对齐检查)。
同时,JVM 对变量 d 也是能够立刻判断出它不是引用,因为 Java 堆的上下边界是知道的,如图中所标识的堆起始地址和最后地址,JVM 发现变量 d 的值早就超出了 Java 堆的边界,故认为它不是引用(这里专业术语叫做上下边界检查)。
接下来才是重点,对于变量 b(实际是一个引用) 和变量 c(实际就是一个 int 型变量),发现他们两个的值是一样的,于是 JVM 就不能判断了,在专业名称上,基于这种方式的 GC 就称为 “保守式 GC”,也称为不能识别指针和非指针的 GC。
这里要说明的是,虽然图中画了一个从变量 b 到对象 B 实例的一个箭头,但 JVM 肯定是不知道的,画个箭头只是方便我们分析的
起始,这种保守式 GC 的内存模型并不是上图所示这般简单。
我们试想,当执行 b = null 之后,对象 B 的实例就应该没有任何指向了对吧,此时它就是个垃圾,应该被回收掉。
但是 JVM 错误的认为变量 c 的值是一个引用,因为此时 JVM 很保守,担心会判断错误,所以只好认为 c 也是一个引用,这样,JVM 认为仍然有人在引用对象 B,所以不会回收对象 B。
这里似乎还看不出什么问题,不过就是因为模糊的检查,一些已经死掉的对象被误认为仍有地方引用他们,GC 也就自然不会回收他们,从而引起了无用的内存占用,造成资源浪费。仅此而已。
更严重的问题是,由于不知道疑似指针是否真的是指针,所以它们的值都不能改写。
比如上面保守式 GC 把 b 和 c 都看成是对象 B 实例的引用,一旦 B 这个对象实例移动了,那么 b 和 c 的引用值都应该修改,但如果 c 变量不是一个引用,而就单纯只是一个 int 型数据呢?
移动对象就意味着要修正指针,换言之,对象就不可移动了。这显然是不可能的,GC 过程肯定伴随存活对象的频繁移动。
有一种办法可以在使用保守式 GC 的同时支持对象的移动,那就是增加一个间接层,不直接通过指针来实现引用,而是添加一层 “句柄”(handle)在中间,所有引用先指到一个句柄池里,再从句柄池找到实际对象。这样,要移动对象的话,只要修改句柄池里的内容即可。
于是保守式 GC 真正的内存模型出来了:
通过上图,不难发现,在堆中增加了一个句柄池,当对象 B 的实例更改存放地址后,JVM 只要改变句柄值,而不用改变变量 b 和变量 c 的值,这样 JVM 就不用犯愁了,因为不论变量 c 是不是一个引用,之后用到 c 的地方,c 的值也没有发生变化,可以正常使用。
不过很显然,这样的话引用的访问速度也就降低了。
简单总结下保守式 GC,也称为不能识别指针和非指针的 GC,只能通过堆的上下边界检查和对齐检查去判断是否为一个引用。保守式 GC 有两个缺点:
- 伪引用,如同上面所说的,当 B = null 之后,本来 B 对象应该被当作垃圾回收掉的,但是有变量 c 这么个伪引用存在,JVM 不敢动手回收掉 B 对象
- 为了支持对象的移动,增加了中间层句柄池,栈中的所有引用都指向这个句柄池中的地址,然后再从句柄池中找到实际对象,但是这样占用了堆的空间并且降低了访问效率,需要两次才能访问到真正的对象。
1996 年 1 月 23 日,Sun 发布 JDK 1.0,Java 语言首次拥有了商用的正式运行环境,这个 JDK 中所带的虚拟机是 Classic VM,它采用的就是基于句柄的对象访问定位方式
准确式 GC 与直接指针访问
与保守式 GC 相对的就是准确式 GC,何为准确式 GC?
就是我们准确的知道,某个位置上面是否是指针,对于 Java 来说,就是知道内存中某个位置的数据具体是什么类型,譬如内存中有一个 32 bit 的整数 123456,虚拟机将有能力分辨出它到底是一个指向了 123456 的内存地址的引用类型还是一个数值为 123456 的整数,准确分辨出哪些内存是引用类型,这也是在垃圾收集时准确判断堆上的数据是否还可能被继续使用的前提。
实现这种要求的方法有很多种,在 Java 中实现的方式是:从外部记录下类型信息,存成映射表,在 HotSpot 中把这种映射表称之为 OopMap,不同的虚拟机名称可能不一样,简而言之,OopMap 就是存着一系列信息的数据结构。实现这种功能,需要虚拟机的解释器和 JIT 编译器支持,由他们来生成 OopMap。
现在主流的 Hotspot 虚拟机,都抛弃掉了以前 Classic VM 基于句柄(Handle)的对象查找方式,采用基于直接指针访问的方式,这样每次定位对象都少了一次间接查找的开销,显著提升执行性能
最后放上这道题的背诵版:
🥸 面试官:讲一下对象的访问定位的方式
😎 小牛肉:对象的访问定位方式是由虚拟机 GC 的具体实现来决定的,保守式 GC 使用的对象访问定位方式是使用句柄访问,准确式 GC 使用的对象访问定位方式是直接指针访问:
所谓保守式 GC 就是虚拟机无法识别指针和非指针,这会导致两个问题,一个就是一些已经死掉的对象无法被回收,占用内存;第二个就是对象无法移动,为了解决这个问题,在堆中引入了句柄池,所有引用先指到一个句柄池里,再从句柄池找到实际对象。这样,要移动对象的话,只要修改句柄池里的内容即可,虚拟机栈中存储的就是对象的句柄地址。这就是使用句柄访问,显然它多了一次间接查找的开销
所谓准确式 GC 就是虚拟机准确的知道内存中某个位置的数据具体是什么类型,具体的实现方式就是使用一个映射表 OopMap 记录下类型信息,虚拟机栈中存储的直接就是对象地址,这样就不需要多一次间接访问的开销了,这就是直接指针访问