JVM 面经
1、什么是 JVM?
JVM 就是 Java 虚拟机,它是 Java 实现跨平台的基石。程序运行之前,需要先通过编译器将 Java 源代码文件编译成 Java 字节码文件;程序运行时,JVM 会对字节码文件进行逐行解释,翻译成机器码指令,交给对应的操作系统执行。这样就实现了 Java 一次编译,处处运行的特性。
1.1 说说 JVM 的其他特性?
- JVM 可以自动管理内存,通过垃圾回收器回收不再使用的对象并释放内存空间。
- JVM 包含一个即时编译器 JIT,它可以在运行时将热点代码缓存到 codeCache 中,下次执行的时候不用再一行一行的解释,而是直接执行缓存后的机器码,执行效率会大幅提高。
- 任何可以通过 Java 编译的语言,比如说 Groovy、Kotlin、Scala 等,都可以在 JVM 上运行。
1.2 为什么要学习 JVM ?
学习 JVM 可以帮助我们开发者更好地优化程序性能、避免内存问题。比如:了解 JVM 的内存模型和垃圾回收机制,可以帮助我们更合理地配置内存、减少 GC 停顿。掌握 JVM 的类加载机制可以帮助我们排查类加载冲突或异常。JVM 还提供了很多调试和监控工具,可以帮助我们分析内存和线程的使用情况,从而解决内存溢出内存泄露等问题。
2、说说 JVM 的组织架构
JVM 大致可以划分为三个部分:类加载器、运行时数据区和执行引擎。
- 类加载器:负责从文件系统、网络或其他来源加载 Class 文件,将 Class 文件中的二进制数据读入到内存当中。
- 运行时数据区:JVM 在执行 Java 程序时,需要在内存中分配空间来处理各种数据,这些内存区域按照 Java 虚拟机规范可以划分为方法区、堆、虚拟机栈、本地方法栈和程序计数器。
- 执行引擎:JVM 的心脏,负责执行字节码。它包括解释器、JIT 编译器和垃圾回收器。
3、能说一下 JVM 的内存区域吗?
按照 Java 虚拟机规范,JVM 的内存区域可以细分为方法区、堆、虚拟机栈、本地方法栈和程序计数器。其中方法区和堆是线程共享的,虚拟机栈、本地方法栈和程序计数器是线程私有的。
3.1 介绍一下方法区?
方法区并不真实存在,是 Java 虚拟机规范中的一个逻辑概念,用于存储已被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等。在 HotSpot 虚拟机中,方法区的实现称为永久代 PermGen,但在 Java 8 及之后的版本中,已经被元空间 Metaspace 所替代。
3.2 介绍一下 Java 堆?
堆是 JVM 中最大的一块内存区域,被所有线程共享,在 JVM 启动时创建,主要用来存储 new 出来的对象。Java 中几乎所有的对象都会在堆中分配,堆也是垃圾收集器管理的目标区域。
从内存回收的角度来看,由于垃圾收集器大部分都是基于分代收集理论设计的,所以堆又被细分为新生代、老年代、Eden空间、From Survivor空间、To Survivor空间等。
从 JDK 7 开始,JVM 默认开启了逃逸分析,意味着如果某些方法中的对象引用没有被返回或者没有在方法体外使用,也就是未逃逸出去,那么对象可以直接在栈上分配内存。
3.3 介绍一下 Java 虚拟机栈?
Java 虚拟机栈的生命周期与线程相同。当线程执行一个方法时,会创建一个对应的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,然后栈帧会被压入虚拟机栈中。当方法执行完毕后,栈帧会从虚拟机栈中移除。
3.4 介绍一下本地方法栈?
本地方法栈与虚拟机栈相似,区别在于虚拟机栈是为 JVM 执行 Java 编写的方法服务的,而本地方法栈是为 Java 调用本地 native 方法服务的,通常由 C/C++ 编写。
3.5 介绍一下本地方法栈的运行场景?
当 Java 应用需要与操作系统底层或硬件交互时,通常会用到本地方法栈。比如调用操作系统的特定功能,如内存管理、文件操作、系统时间、系统调用等。
3.6 native 方法解释一下?
native 方法是在 Java 中通过 native 关键字声明的,用于调用非 Java 语言,如 C/C++ 编写的代码。Java 可以通过 JNI,也就是 Java Native Interface 与底层系统、硬件设备、或者本地库进行交互。
3.7 介绍一下程序计数器?
程序计数器也被称为 PC 寄存器,是一块较小的内存空间。它可以看作是当前线程所执行的字节码行号指示器。
3.8 变量存在堆栈的什么位置?
对于局部变量,它存储在当前方法栈帧中的局部变量表中。当方法执行完毕,栈帧被回收,局部变量也会被释放。对于静态变量来说,它存储在 Java 虚拟机规范中的方法区中,在 Java 7 中是永久代,在 Java 8 及以后是元空间。
4、说一下 JDK 1.6、1.7、1.8 内存区域的变化?
- JDK 1.6 使用永久代来实现方法区。
- JDK 1.7 依然是永久带,但是将字符串常量池、静态变量存放到了堆上。
- JDK 1.8 直接在内存中划出了一块区域,叫元空间,来取代之前放在 JVM 内存中的永久代,并将运行时常量池、类常量池都移动到了元空间。
5、为什么使用元空间替代永久代?
因为永久代受到 JVM 内存大小的限制,会导致 Java 应用程序出现内存溢出的问题。
6、对象创建的过程了解吗?
当我们使用 new 关键字创建一个对象时,JVM 首先会检查 new 指令的参数是否能在常量池中定位到类的符号引用,然后检查这个符号引用代表的类是否已被加载、解析和初始化。如果没有,就先执行类加载。如果已经加载,JVM 会为对象分配内存完成初始化,比如数值类型的成员变量初始值是 0,布尔类型是 false,对象类型是 null。接下来,会设置对象头,里面包含了对象是哪个类的实例、对象的哈希码、对象的 GC 分代年龄等信息。最后,JVM 会执行构造方法 完成赋值操作,将成员变量赋值为预期的值,比如 int age = 18,这样一个对象就创建完成了。
6.1 对象的销毁过程了解吗?
当对象不再被任何引用指向时,就会变成垃圾。垃圾收集器会通过可达性分析算法判断对象是否存活,如果对象不可达,就会被回收。垃圾收集器通过标记清除、标记复制、标记整理等算法来回收内存,释放对象占用的内存空间。
7、堆内存是如何分配的?
在堆中为对象分配内存时,主要使用两种策略:指针碰撞和空闲列表。指针碰撞适用于管理简单、碎片化较少的内存区域,如年轻代;而空闲列表适用于内存碎片化较严重或对象大小差异较大的场景如老年代。
- 指针碰撞:假设堆内存是一个连续的空间,被分为两个部分,一部分是已经被使用的内存,另一部分是未被使用的内存。在分配内存时,Java 虚拟机会维护一个指针,指向下一个可用的内存地址,每次分配内存时,只需要将指针向后移动一段距离,如果没有发生碰撞,就将这段内存分配给对象实例。
- 空闲列表:JVM 会维护一个列表,记录堆中所有未占用的内存块,每个内存块都记录有大小和地址信息。当有新的对象请求内存时,JVM 会遍历空闲列表,寻找足够大的空间来存放新对象。分配后,如果选中的内存块未被完全利用,剩余的部分会作为一个新的内存块加入到空闲列表中。
8、new 对象时,堆会发生抢占吗?
会发生抢占。new 对象时,指针会向右移动一个对象大小的距离,假如一个线程 A 正在给 String 对象 分配内存,另一个线程 B 同时为 ArrayList 对象分配内存,两个线程就发生了抢占。
8.1 JVM 怎么解决堆内存分配的竞争问题?
为了解决堆内存分配的竞争问题,JVM 为每个线程保留了一小块内存空间,被称为 TLAB,也就是线程本地分配缓冲区,用于存放该线程分配的对象。当线程需要分配对象时,直接从 TLAB 中分配。只有当 TLAB 用尽或对象太大需要直接在堆中分配时,才会使用全局分配指针。
9、能说一下对象的内存布局吗?
对象的内存布局是由 Java 虚拟机规范定义的,拿我们常用的 HotSpot 来说吧。对象在内存中包括三部分:对象头、实例数据和对齐填充。
9.1 说说对象头的作用?
对象头是对象存储在内存中的元信息,包含了:Mark Word、类型指针等信息。Mark Word 存储了对象的运行时状态信息,包括锁、哈希值、GC 标记等。在 64 位操作系统下占 8 个字节。类型指针指向对象所属类的元数据,也就是 Class 对象,用来支持多态、方法调用等功能。在开启压缩指针的情况下占 4 个字节,否则占 8 个字节。除此之外,如果对象是数组类型,还会有一个额外的数组长度字段。占 4 个字节。
9.2 实例数据了解吗?
实例数据是对象实际的字段值,也就是成员变量的值,按照字段在类中声明的顺序存储。JVM 会对这些数据进行对齐 / 重排,以提高内存访问速度。
9.3 对齐填充了解吗?
由于 JVM 的内存模型要求对象的起始地址是 8 字节对齐(64 位 JVM 中),因此对象的总大小必须是 8 字节的倍数。如果对象头和实例数据的总长度不是 8 的倍数,JVM 会通过填充额外的字节来对齐。比如说,如果对象头 + 实例数据 = 14 字节,则需要填充 2 个字节,使总长度变为 16 字节。
9.4 为什么非要进行 8 字节对齐呢?
因为 CPU 进行内存访问时,一次寻址的指针大小是 8 字节,正好是 L1 缓存行的大小。如果不进行内存对齐,则可能出现跨缓存行访问,导致额外的缓存行加载,CPU 的访问效率就会降低。8 字节对齐,是一种以空间换时间的方案。
9.5 new Object() 对象的内存大小是多少?
一般来说,目前的操作系统都是 64 位的,并且 JDK 8 中的压缩指针是默认开启的,因此在 64 位的 JVM 上,new Object()的大小是 16 字节(12 字节的对象头 + 4 字节的对齐填充)。
假如 MyObject 对象有三个成员变量,分别是 int、long 和 byte 类型,那么它们占用的内存大小分别是 4 字节、8 字节和 1 字节。考虑到对齐填充,MyObject 对象的总大小为 12 + 4 + 8 + 1 + 7(填充)= 32 字节。
9.6 对象的引用(类型指针)大小了解吗?
在 64 位 JVM 上,未开启压缩指针时,对象引用占用 8 字节;开启压缩指针时,对象引用会被压缩到 4 字节。HotSpot 虚拟机默认是开启压缩指针的。
10、JVM 怎么访问对象的?
主流的方式有两种:句柄和直接指针。句柄是通过中间的句柄表来定位对象的,优点是对象被移动时只需要修改句柄表中的指针,而不需要修改对象引用本身。直接指针是通过引用直接存储对象的内存地址的,因为对象的实例数据和类型信息都存储在堆中固定的内存区域。直接指针访问的优点是访问速度更快,因为少了一次句柄的寻址操作。缺点是如果对象在内存中移动,引用需要更新为新的地址。HotSpot 虚拟机主要使用直接指针来进行对象访问。