🧑 博主简介:CSDN博客专家,历代文学网(PC端可以访问:https://literature.sinhy.com/#/?__c=1000,移动端可微信小程序搜索“历代文学”)总架构师,
15年
工作经验,精通Java编程
,高并发设计
,Springboot和微服务
,熟悉Linux
,ESXI虚拟化
以及云原生Docker和K8s
,热衷于探索科技的边界,并将理论知识转化为实际应用。保持对新技术的好奇心,乐于分享所学,希望通过我的实践经历和见解,启发他人的创新思维。在这里,我希望能与志同道合的朋友交流探讨,共同进步,一起在技术的世界里不断学习成长。
技术合作请加本人wx(注明来自csdn):foreast_sea
Java虚拟机面试题:内存管理(中)
1. 什么是指针碰撞?什么是空闲列表?
在堆内存分配对象时,主要使用两种策略:指针碰撞和空闲列表。
①、指针碰撞(Bump the Pointer)
假设堆内存是一个连续的空间,分为两个部分,一部分是已经被使用的内存,另一部分是未被使用的内存。
在分配内存时,Java 虚拟机维护一个指针,指向下一个可用的内存地址,每次分配内存时,只需要将指针向后移动(碰撞)一段距离,然后将这段内存分配给对象实例即可。
②、空闲列表(Free List)
JVM 维护一个列表,记录堆中所有未占用的内存块,每个空间块都记录了大小和地址信息。
当有新的对象请求内存时,JVM 会遍历空闲列表,寻找足够大的空间来存放新对象。
分配后,如果选中的空闲块未被完全利用,剩余的部分会作为一个新的空闲块加入到空闲列表中。
指针碰撞适用于管理简单、碎片化较少的内存区域(如年轻代),而空闲列表适用于内存碎片化较严重或对象大小差异较大的场景(如老年代)。
2. JVM 里 new 对象时,堆会发生抢占吗?JVM 是怎么设计来保证线程安全的?
会,假设 JVM 虚拟机上,每一次 new 对象时,指针就会向右移动一个对象 size 的距离,一个线程正在给 A 对象分配内存,指针还没有来的及修改,另一个为 B 对象分配内存的线程,又引用了这个指针来分配内存,这就发生了抢占。
有两种可选方案来解决这个问题:
-
采用 CAS 分配重试的方式来保证更新操作的原子性
-
每个线程在 Java 堆中预先分配一小块内存,也就是本地线程分配缓冲(Thread Local Allocation
Buffer,TLAB),要分配内存的线程,先在本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
3. 能说一下对象的内存布局吗?
在 Java 中,对象的内存布局是由 Java 虚拟机规范定义的,但具体的实现细节可能因不同的 JVM 实现(如 HotSpot、OpenJ9 等)而异。
在 HotSpot 中,对象在堆内存中的存储布局可以划分为三个部分:对象头(Object Header)、实例数据(Instance Data)和对齐填充(Padding)。
①、对象头是每个对象都有的,包含三部分主要信息:
- 标记字(
Mark Word
):包含了对象自身的运行时数据,如哈希码(HashCode)、垃圾回收分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 等信息。在 64 位操作系统下占 8 个字节,32 位操作系统下占 4 个字节。 - 类型指针(
Class Pointer
):指向对象所属类的元数据的指针,JVM 通过这个指针来确定对象的类。在开启了压缩指针的情况下,这个指针可以被压缩。在开启指针压缩的情况下占 4 个字节,否则占 8 个字节。 - 数组长度(
Array Length
):如果对象是数组类型,还会有一个额外的数组长度字段。占 4 个字节。
注意,启用压缩指针(-XX:+UseCompressedOops
)可以减少对象头中类型指针的大小,从而减少对象总体大小,提高内存利用率。
可以通过 java -XX:+PrintFlagsFinal -version | grep UseCompressedOops
命令来查看当前 JVM 是否开启了压缩指针。
如果压缩指针开启,会看到类似以下的输出,其中 bool UseCompressedOops 的值为 true。
在 JDK 8 中,压缩指针默认是开启的,以减少 64 位应用中对象引用的内存占用。
②、实例数据存储了对象的具体信息,即在类中定义的各种字段数据(不包括由父类继承的字段)。这部分的大小取决于对象的属性和它们的类型(如 int、long、引用类型等)。JVM 会对这些数据进行对齐,以确保高效的访问速度。
③、对齐填充,为了使对象的总大小是 8 字节的倍数(这在大多数现代计算机体系结构中是最优访问边界),JVM 可能会在对象末尾添加一些填充。这部分是为了满足内存对齐的需求,并不包含任何具体的数据。
为什么非要进行 8 字节对齐呢?
这是因为 CPU 进行内存访问时,一次寻址的指针大小是 8 字节,正好是 L1 缓存行的大小。如果不进行内存对齐,则可能出现跨缓存行访问,导致额外的缓存行加载,降低了 CPU 的访问效率。
比如说上图中 obj1 占 6 个字节,由于没有对齐,导致这一行缓存中多了 2 个字节 obj2 的数据,当 CPU 访问 obj2 的时候,就会导致缓存行的刷新,这就是缓存行污染。
也就说,8 字节对齐,是为了效率的提高,以空间换时间的一种方案。固然你还能够 16 字节对齐,可是 8 字节是最优选择。
Object a = new object()的大小
一般来说,对象的大小是由对象头、实例数据和对齐填充三个部分组成的。
- 对象头的大小在 32 位 JVM 上是 8 字节,在 64 位 JVM 上是 16 字节(如果开启了压缩指针,就是 12 字节)。
- 实例数据的大小取决于对象的属性和它们的类型。对于
new Object()
来说,Object 类本身没有实例字段,因此这部分可能非常小或者为零。 - 对齐填充的大小取决于对象头和实例数据的大小,以确保对象的总大小是 8 字节的倍数。
一般来说,目前的操作系统都是 64 位的,并且 JDK 8 中的压缩指针是默认开启的,因此在 64 位 JVM 上,new Object()
的大小是 16 字节(12 字节的对象头 + 4 字节的对齐填充)。
为了确认我们的推理,我们可以使用 JOL 工具来查看对象的内存布局:
JOL 全称为 Java Object Layout,是分析 JVM 中对象布局的工具,该工具大量使用了 Unsafe、JVMTI 来解码布局情况。
第一步,在 pom.xml 中引入 JOL 依赖:
<dependency><groupId>org.openjdk.jol</groupId><artifactId>jol-core</artifactId><version>0.9</version>
</dependency>
第二步,使用 JOL 编写代码示例:
public class JOLSample {public static void main(String[] args) {// 打印JVM详细信息(可选)System.out.println(VM.current().details());// 创建Object实例Object obj = new Object();// 打印Object实例的内存布局String layout = ClassLayout.parseInstance(obj).toPrintable();System.out.println(layout);}
}
第三步,运行代码,查看输出结果:
可以看到有 OFFSET、SIZE、TYPE DESCRIPTION、VALUE 这几个名词头,它们的含义分别是
- OFFSET:偏移地址,单位字节;
- SIZE:占用的内存大小,单位字节;
- TYPE DESCRIPTION:类型描述,其中 object header 为对象头;
- VALUE:对应内存中当前存储的值,二进制 32 位;
从上面的结果能看到对象头是 12 个字节,还有 4 个字节的 padding,一共 16 个字节。我们的推理是正确的。
对象引用占多少大小?
在 64 位 JVM 上,未开启压缩指针时,对象引用占用 8 字节;开启压缩指针时,对象引用可被压缩到 4 字节。
而 HotSpot JVM 默认开启了压缩指针,因此在 64 位 JVM 上,对象引用占用 4 字节。
我们可以通过下面这个例子来验证一下:
class ReferenceSizeExample {private static class ReferenceHolder {Object reference;}public static void main(String[] args) {System.out.println(VM.current().details());System.out.println(ClassLayout.parseClass(ReferenceHolder.class).toPrintable());}
}
运行代码,查看输出结果:
ReferenceHolder.reference 字段位于偏移量 12,大小为 4 字节。这表明在当前的 JVM 配置下(64 位 JVM 且压缩指针开启),对象引用占用的内存大小为 4 字节。
4. 对象怎么访问定位?
Java 程序会通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在《Java 虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:
- 如果使用句柄访问的话,Java 堆中将可能会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如图所示:
- 如果使用直接指针访问的话,Java 堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference 中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如图所示:
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。
HotSpot 虚拟机主要使用直接指针来进行对象访问。
5. 说一下对象有哪几种引用?
四种,分别是强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。
强引用是 Java 中最常见的引用类型。使用 new 关键字赋值的引用就是强引用,只要强引用关联着对象,垃圾收集器就不会回收这部分对象。
String str = new String("沉默王二");
软引用是一种相对较弱的引用类型,可以通过 SoftReference 类实现。软引用对象在内存不足时才会被回收。
SoftReference<String> softRef = new SoftReference<>(new String("沉默王二"));
弱引用可以通过 WeakReference 类实现。弱引用对象在下一次垃圾回收时会被回收,不论内存是否充足。
WeakReference<String> weakRef = new WeakReference<>(new String("沉默王二"));
虚引用可以通过 PhantomReference 类实现。虚引用对象在任何时候都可能被回收。主要用于跟踪对象被垃圾回收的状态,可以用于管理直接内存。
PhantomReference<String> phantomRef = new PhantomReference<>(new String("沉默王二"), new ReferenceQueue<>());
6. Java 堆的内存分区了解吗?
Java 堆被划分为新生代和老年代两个区域。
新生代又被划分为 Eden 空间和两个 Survivor 空间(From 和 To)。
- Eden 空间:大多数新创建的对象会被分配到 Eden 空间中。当 Eden 区填满时,会触发一次轻量级的垃圾回收(Minor GC),清除不再使用的对象。
- Survivor 空间:每次 Minor GC 后,仍然存活的对象会从 Eden 区或 From 区复制到 To 区。From 和 To 区可以交替使用。
对象在新生代中经历多次 GC 后,如果仍然存活,会被移动到老年代。
7. 说一下新生代的区域划分?
新生代的垃圾收集主要采用标记-复制算法,因为新生代的存活对象比较少,每次复制少量的存活对象效率比较高。
基于这种算法,虚拟机将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间。默认 Eden 和 Survivor 的大小比例是 8∶1。
8. 对象什么时候会进入老年代?
对象通常会先在年轻代中分配,然后随着时间的推移和垃圾收集的处理,某些满足条件的对象会进入到老年代中。
①、长期存活的对象将进入老年代
对象在年轻代中存活足够长的时间(即经过足够多的垃圾回收周期)后,会晋升到老年代。
每次 GC 未被回收的对象,其年龄会增加。当对象的年龄超过一个特定阈值(默认通常是 15),它就会被移动到老年代。这个年龄阈值可以通过 JVM 参数-XX:MaxTenuringThreshold
来设置。
②、大对象直接进入老年代
为了避免在年轻代中频繁复制大对象,JVM 提供了一种策略,允许大对象直接在老年代中分配。
这些是所谓的“大对象”,其大小超过了预设的阈值(由 JVM 参数-XX:PretenureSizeThreshold
控制)。直接在老年代分配可以减少在年轻代和老年代之间的数据复制。
③、动态对象年龄判定
除了固定的年龄阈值,还会根据各个年龄段对象的存活大小和内存空间等因素动态调整对象的晋升策略。
比如说,在 Survivor 空间中相同年龄的所有对象大小总和大于 Survivor 空间的一半,那么年龄大于或等于该年龄的对象就可以直接进入老年代。
9. 什么是 Stop The World ? 什么是 OopMap ?什么是安全点?
进行垃圾回收的过程中,会涉及对象的移动。为了保证对象引用更新的正确性,必须暂停所有的用户线程,像这样的停顿,虚拟机设计者形象描述为Stop The World
。也简称为 STW。
在 HotSpot 中,有个数据结构(映射表)称为OopMap
。一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,记录到 OopMap。在即时编译过程中,也会在特定的位置
生成 OopMap,记录下栈上和寄存器里哪些位置是引用。
这些特定的位置主要在:
-
1.循环的末尾(非 counted 循环)
-
2.方法临返回前 / 调用方法的 call 指令后
-
3.可能抛异常的位置
这些位置就叫作安全点(safepoint)。 用户程序执行时并非在代码指令流的任意位置都能够在停顿下来开始垃圾收集,而是必须是执行到安全点才能够暂停。
用通俗的比喻,假如老王去拉车,车上东西很重,老王累的汗流浃背,但是老王不能在上坡或者下坡休息,只能在平地上停下来擦擦汗,喝口水。