JVM 基础面试题总结
JVM 的主要作用是什么?
JVM 就是 Java Virtual Machine(Java虚拟机)的缩写,JVM 屏蔽了与具体操作系统平台相关的信息,使 Java 程序只需生成在 Java 虚拟机上运行的目标代码 (字节码),就可以在不同的平台上运行。
请你描述一下 Java 的内存区域?
JVM 在执行 Java 程序的过程中会把它管理的内存分为若干个不同的区域,这些组成部分有些是线程私有的,有些则是线程共享的,Java 内存区域也叫做运行时数据区,它的具体划分如下:
-
虚拟机栈
: Java 虚拟机栈是线程私有的数据区,Java 虚拟机栈的生命周期与线程相同,虚拟机栈也是局部变量的存储位置。方法在执行过程中,会在虚拟机栈中创建一个栈帧(stack frame)
。每个方法执行的过程就对应了一个入栈和出栈的过程。
-
本地方法栈
: 本地方法栈也是线程私有的数据区,本地方法栈存储的区域主要是 Java 中使用native
关键字修饰的方法所存储的区域。 -
-
方法区
:方法区是各个线程共享的内存区域,它用于存储虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码等数据。 -
堆
:堆是线程共享的数据区,堆是 JVM 中最大的一块存储区域,所有的对象实例都会分配在堆上。JDK 1.7后,字符串常量池从永久代中剥离出来,存放在堆中。堆空间的内存分配(默认情况下):
命令行上执行如下命令,会查看默认的 JVM 参数。
java -XX:+PrintFlagsFinal -version
输出的内容非常多,但是只有两行能够反映出上面的内存分配结果
-
eden 区:8/10 的年轻代空间 -
survivor 0 : 1/10 的年轻代空间 -
survivor 1 : 1/10 的年轻代空间 -
老年代 :三分之二的堆空间 -
年轻代 :三分之一的堆空间 -
运行时常量池
:运行时常量池又被称为Runtime Constant Pool
,这块区域是方法区的一部分,它的名字非常有意思,通常被称为非堆
。它并不要求常量一定只有在编译期才能产生,也就是并非编译期间将常量放在常量池中,运行期间也可以将新的常量放入常量池中,String 的 intern 方法就是一个典型的例子。
请你描述一下 Java 中的类加载机制?
Java 虚拟机负责把描述类的数据从 Class 文件加载到系统内存中,并对类的数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称之为 Java 的类加载机制
。
一个类从被加载到虚拟机内存开始,到卸载出内存为止,一共会经历下面这些过程。
类加载机制一共有五个步骤,分别是加载、链接、初始化、使用和卸载阶段,这五个阶段的顺序是确定的。
其中链接阶段会细分成三个阶段,分别是验证、准备、解析阶段,这三个阶段的顺序是不确定的,这三个阶段通常交互进行。解析阶段通常会在初始化之后再开始,这是为了支持 Java 语言的运行时绑定特性(也被称为动态绑定
)。
下面我们就来聊一下这几个过程。
加载
关于什么时候开始加载这个过程,《Java 虚拟机规范》并没有强制约束,所以这一点我们可以自由实现。加载是整个类加载过程的第一个阶段,在这个阶段,Java 虚拟机需要完成三件事情:
-
通过一个类的全限定名来获取定义此类的二进制字节流。 -
将这个字节流表示的一种存储结构转换为运行时数据区中方法区的数据结构。 -
在内存中生成一个 Class 对象,这个对象就代表了这个数据结构的访问入口。
《Java 虚拟机规范》并未规定全限定名是如何获取的,所以现在业界有很多获取全限定名的方式:
-
从 ZIP 包中读取,最终会改变为 JAR、EAR、WAR 格式。 -
从网络中获取,最常见的应用就是 Web Applet。 -
运行时动态生成,使用最多的就是动态代理技术。 -
由其他文件生成,比如 JSP 应用场景,由 JSP 文件生成对应的 Class 文件。 -
从数据库中读取,这种场景就比较小了。 -
可以从加密文件中获取,这是典型的防止 Class 文件被反编译的保护措施。
加载阶段既可以使用虚拟机内置的引导类加载器来完成,也可以使用用户自定义的类加载器来完成。程序员可以通过自己定义类加载器来控制字节流的访问方式。
数组的加载不需要通过类加载器来创建,它是直接在内存中分配,但是数组的元素类型(数组去掉所有维度的类型)最终还是要靠类加载器来完成加载。
验证
加载过后的下一个阶段就是验证,因为我们上一步讲到在内存中生成了一个 Class 对象,这个对象是访问其代表数据结构的入口,所以这一步验证的工作就是确保 Class 文件的字节流中的内容符合《Java 虚拟机规范》中的要求,保证这些信息被当作代码运行后,它不会威胁到虚拟机的安全。
验证阶段主要分为四个阶段的检验:
-
文件格式验证。 -
元数据验证。 -
字节码验证。 -
符号引用验证。
文件格式验证
这一阶段可能会包含下面这些验证点:
-
魔数是否以 0xCAFEBABE
开头。 -
主、次版本号是否在当前 Java 虚拟机接受范围之内。 -
常量池的常量中是否有不支持的常量类型。 -
指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。 -
CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据。 -
Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息。
实际上验证点远远不止有这些,上面这些只是从 HotSpot 源码中摘抄的一小段内容。
元数据验证
这一阶段主要是对字节码描述的信息进行语义分析,以确保描述的信息符合《Java 语言规范》,验证点包括
-
验证的类是否有父类(除了 Object 类之外,所有的类都应该有父类)。 -
要验证类的父类是否继承了不允许继承的类。 -
如果这个类不是抽象类,那么这个类是否实现了父类或者接口中要求的所有方法。 -
是否覆盖了 final 字段,是否出现了不符合规定的重载等。
需要记住这一阶段只是对《Java 语言规范》的验证。
字节码验证
字节码验证阶段是最复杂的一个阶段,这个阶段主要是确定程序语意是否合法、是否是符合逻辑的。这个阶段主要是对类的方法体(Class 文件中的 Code 属性)进行校验分析。这部分验证包括
-
确保操作数栈的数据类型和实际执行时的数据类型是否一致。 -
保证任何跳转指令不会跳出到方法体外的字节码指令上。 -
保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,但是不能把父类数据类型赋值给子类等诸如此不安全的类型转换。 -
其他验证。
如果没有通过字节码验证,就说明验证出问题。但是不一定通过了字节码验证,就能保证程序是安全的。
符号引用验证
最后一个阶段的校验行为发生在虚拟机将符号引用转换为直接引用的时候,这个转化将在连接的第三个阶段,即解析阶段中发生。符号引用验证可以看作是对类自身以外的各类信息进行匹配性校验,这个验证主要包括
-
符号引用中的字符串全限定名是否能找到对应的类。 -
指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。 -
符号引用的类、字段方法的可访问性是否可被当前类所访问。 -
其他验证。
这一阶段主要是确保解析行为能否正常执行,如果无法通过符号引用验证,就会出现类似 IllegalAccessError
、NoSuchFieldError
、NoSuchMethodError
等错误。
验证阶段对于虚拟机来说非常重要,如果能通过验证,就说明你的程序在运行时不会产生任何影响。
准备
准备阶段是为类中的变量分配内存并设置其初始值的阶段,这些变量所使用的内存都应当在方法区中进行分配,在 JDK 7 之前,HotSpot 使用永久代来实现方法区,是符合这种逻辑概念的。而在 JDK 8 之后,变量则会随着 Class 对象一起存放在 Java 堆中。
下面通常情况下的基本类型和引用类型的初始值
除了"通常情况"下,还有一些"例外情况",如果类字段属性中存在 ConstantValue
属性,那就这个变量值在初始阶段就会初始化为 ConstantValue 属性所指定的初始值,比如
public static final int value = "666";
编译时就会把 value 的值设置为 666。
解析
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程。
-
符号引用
:符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用和虚拟机的布局无关。 -
直接引用
:直接引用可以直接指向目标的指针、相对便宜量或者一个能间接定位到目标的句柄。直接引用和虚拟机的布局是相关的,不同的虚拟机对于相同的符号引用所翻译出来的直接引用一般是不同的。如果有了直接引用,那么直接引用的目标一定被加载到了内存中。
这样说你可能还有点不明白,我再换一种说法:
《Java 虚拟机规范》并未规定解析阶段发生的时间,只要求了在 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield 和 putstatic 这 17 个用于操作符号引用的字节码指令之前,先对所使用的符号引用进行解析。
解析也分为四个步骤
-
类或接口的解析 -
字段解析 -
方法解析 -
接口方法解析
初始化
初始化是类加载过程的最后一个步骤,在之前的阶段中,都是由 Java 虚拟机占主导作用,但是到了这一步,却把主动权移交给应用程序。
对于初始化阶段,《Java 虚拟机规范》严格规定了只有下面这六种情况下才会触发类的初始化。
-
在遇到 new、getstatic、putstatic 或者 invokestatic 这四条字节码指令时,如果没有进行过初始化,那么首先触发初始化。通过这四个字节码的名称可以判断,这四条字节码其实就两个场景,调用 new 关键字的时候进行初始化、读取或者设置一个静态字段的时候、调用静态方法的时候。 -
在初始化类的时候,如果父类还没有初始化,那么就需要先对父类进行初始化。 -
在使用 java.lang.reflect 包的方法进行反射调用的时候。 -
当虚拟机启动时,用户需要指定执行主类的时候,说白了就是虚拟机会先初始化 main 方法这个类。 -
在使用 JDK 7 新加入的动态语言支持时,如果一个 jafva.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getstatic、REF_putstatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,需要先对其进行初始化。 -
当一个接口中定义了 JDK 8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
其实上面只有前四个大家需要知道就好了,后面两个比较冷门。
如果说要回答类加载的话,其实聊到这里已经可以了,但是为了完整性,我们索性把后面两个过程也来聊一聊。
使用
这个阶段没什么可说的,就是初始化之后的代码由 JVM 来动态调用执行。
卸载
当代表一个类的 Class 对象不再被引用,那么 Class 对象的生命周期就结束了,对应的在方法区中的数据也会被卸载。
⚠️但是需要注意一点:JVM 自带的类加载器装载的类,是不会卸载的,由用户自定义的类加载器加载的类是可以卸载的。
在 JVM 中,对象是如何创建的?
如果要回答对象是怎么创建的,我们一般想到的回答是直接 new
出来就行了,这个回答不仅局限于编程中,也融入在我们生活中的方方面面。
但是遇到面试的时候你只回答一个"new 出来就行了"显然是不行的,因为面试更趋向于让你解释当程序执行到 new 这条指令时,它的背后发生了什么。
所以你需要从 JVM 的角度来解释这件事情。
当虚拟机遇到一个 new 指令时(其实就是字节码),首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已经被加载、解析和初始化。
因为此时很可能不知道具体的类是什么,所以这里使用的是符号引用。
如果发现这个类没有经过上面类加载的过程,那么就执行相应的类加载过程。
类检查完成后,接下来虚拟机将会为新生对象分配内存,对象所需的大小在类加载完成后便可确定(我会在下面的面试题中介绍)。
分配内存相当于是把一块固定的内存块从堆中划分出来。划分出来之后,虚拟机会将分配到的内存空间都初始化为零值,如果使用了 TLAB
(本地线程分配缓冲),这一项初始化工作可以提前在 TLAB 分配时进行。这一步操作保证了对象实例字段在 Java 代码中可以不赋值就能直接使用。
接下来,Java 虚拟机还会对对象进行必要的设置,比如确定对象是哪个类的实例、对象的 hashcode、对象的 gc 分代年龄信息。这些信息存放在对象的对象头(Object Header)中。
如果上面的工作都做完后,从虚拟机的角度来说,一个新的对象就创建完毕了;但是对于程序员来说,对象创建才刚刚开始,因为构造函数,即 Class 文件中的 <init>()
方法还没有执行,所有字段都为默认的零值。new 指令之后才会执行 <init>()
方法,然后按照程序员的意愿对对象进行初始化,这样一个对象才可能被完整的构造出来。
内存分配方式有哪些呢?
在类加载完成后,虚拟机需要为新生对象分配内存,为对象分配内存相当于是把一块确定的区域从堆中划分出来,这就涉及到一个问题,要划分的堆区是否规整。
假设 Java 堆中内存是规整的,所有使用过的内存放在一边,未使用的内存放在一边,中间放着一个指针,这个指针为分界指示器。那么为新对象分配内存空间就相当于是把指针向空闲的空间挪动对象大小相等的距离,这种内存分配方式叫做指针碰撞(Bump The Pointer)
。
如果 Java 堆中的内存并不是规整的,已经被使用的内存和未被使用的内存相互交错在一起,这种情况下就没有办法使用指针碰撞,这里就要使用另外一种记录内存使用的方式:空闲列表(Free List)
,空闲列表维护了一个列表,这个列表记录了哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
所以,上述两种分配方式选择哪个,取决于 Java 堆是否规整来决定。在一些垃圾收集器的实现中,Serial、ParNew 等带压缩整理过程的收集器,使用的是指针碰撞;而使用 CMS 这种基于清除算法的收集器时,使用的是空闲列表,具体的垃圾收集器我们后面会聊到。
请你说一下对象的内存布局?
在 hotspot
虚拟机中,对象在内存中的布局分为三块区域:
-
对象头(Header)
-
实例数据(Instance Data)
-
对齐填充(Padding)
这三块区域的内存分布如下图所示
我们来详细介绍一下上面对象中的内容。
对象头 Header
对象头 Header 主要包含 MarkWord 和对象指针 Klass Pointer,如果是数组的话,还要包含数组的长度。
在 32 位的虚拟机中 MarkWord ,Klass Pointer 和数组长度分别占用 32 位,也就是 4 字节。
如果是 64 位虚拟机的话,MarkWord ,Klass Pointer 和数组长度分别占用 64 位,也就是 8 字节。
在 32 位虚拟机和 64 位虚拟机的 Mark Word 所占用的字节大小不一样,32 位虚拟机的 Mark Word 和 Klass Pointer 分别占用 32 bits 的字节,而 64 位虚拟机的 Mark Word 和 Klass Pointer 占用了64 bits 的字节,下面我们以 32 位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的。
用中文翻译过来就是
-
无状态也就是 无锁
的时候,对象头开辟 25 bit 的空间用来存储对象的 hashcode ,4 bit 用于存放分代年龄,1 bit 用来存放是否偏向锁的标识位,2 bit 用来存放锁标识位为 01。 -
偏向锁
中划分更细,还是开辟 25 bit 的空间,其中 23 bit 用来存放线程ID,2bit 用来存放 epoch,4bit 存放分代年龄,1 bit 存放是否偏向锁标识, 0 表示无锁,1 表示偏向锁,锁的标识位还是 01。 -
轻量级锁
中直接开辟 30 bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为 00。 -
重量级锁
中和轻量级锁一样,30 bit 的空间用来存放指向重量级锁的指针,2 bit 存放锁的标识位,为 11 -
GC标记
开辟 30 bit 的内存空间却没有占用,2 bit 空间存放锁标志位为 11。
其中无锁和偏向锁的锁标志位都是 01,只是在前面的 1 bit 区分了这是无锁状态还是偏向锁状态。
关于为什么这么分配的内存,我们可以从 OpenJDK
中的markOop.hpp类中的枚举窥出端倪
来解释一下
-
age_bits 就是我们说的分代回收的标识,占用4字节 -
lock_bits 是锁的标志位,占用2个字节 -
biased_lock_bits 是是否偏向锁的标识,占用1个字节。 -
max_hash_bits 是针对无锁计算的 hashcode 占用字节数量,如果是 32 位虚拟机,就是 32 - 4 - 2 -1 = 25 byte,如果是 64 位虚拟机,64 - 4 - 2 - 1 = 57 byte,但是会有 25 字节未使用,所以 64 位的 hashcode 占用 31 byte。 -
hash_bits 是针对 64 位虚拟机来说,如果最大字节数大于 31,则取 31,否则取真实的字节数 -
cms_bits 我觉得应该是不是 64 位虚拟机就占用 0 byte,是 64 位就占用 1byte -
epoch_bits 就是 epoch 所占用的字节大小,2 字节。
在上面的虚拟机对象头分配表中,我们可以看到有几种锁的状态:无锁(无状态),偏向锁,轻量级锁,重量级锁,其中轻量级锁和偏向锁是 JDK1.6 中对 synchronized 锁进行优化后新增加的,其目的就是为了大大优化锁的性能,所以在 JDK 1.6 中,使用 synchronized 的开销也没那么大了。其实从锁有无锁定来讲,还是只有无锁和重量级锁,偏向锁和轻量级锁的出现就是增加了锁的获取性能而已,并没有出现新的锁。
所以我们的重点放在对 synchronized 重量级锁的研究上,当 monitor 被某个线程持有后,它就会处于锁定状态。在 HotSpot 虚拟机中,monitor 的底层代码是由 ObjectMonitor
实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++ 实现的)
这段 C++ 中需要注意几个属性:_WaitSet 、 _EntryList 和 _Owner,每个等待获取锁的线程都会被封装称为 ObjectWaiter
对象。
_Owner 是指向了 ObjectMonitor 对象的线程,而 _WaitSet 和 _EntryList 就是用来保存每个线程的列表。
那么这两个列表有什么区别呢?这个问题我和你聊一下锁的获取流程你就清楚了。
锁的两个列表
当多个线程同时访问某段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 之后,就会进入 _Owner 区域,并把 ObjectMonitor 对象的 _Owner 指向为当前线程,并使 _count + 1,如果调用了释放锁(比如 wait)的操作,就会释放当前持有的 monitor ,owner = null, _count - 1,同时这个线程会进入到 _WaitSet 列表中等待被唤醒。如果当前线程执行完毕后也会释放 monitor 锁,只不过此时不会进入 _WaitSet 列表了,而是直接复位 _count 的值。
Klass Pointer 表示的是类型指针,也就是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据 Instance Data
实例数据部分是对象真正存储的有效信息,也是代码中定义的各个字段的字节大小,比如一个 byte 占 1 个字节,一个 int 占用 4 个字节。
对齐 Padding
对象访问定位的方式有哪些?
我们创建一个对象的目的当然就是为了使用它,但是,一个对象被创建出来之后,在 JVM 中是如何访问这个对象的呢?一般有两种方式:通过句柄访问和 通过直接指针访问。
使用直接指针来访问速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因为这类的开销也是值得优化的地方。
上面聊到了对象的两种数据,一种是对象的实例数据,这没什么好说的,就是对象实例字段的数据,一种是对象的类型数据,这个数据说的是对象的类型、父类、实现的接口和方法等。
如何判断对象已经死亡?
我们大家知道,基本上所有的对象都在堆中分布,当我们不再使用对象的时候,垃圾收集器会对无用对象进行回收♻️,那么 JVM 是如何判断哪些对象已经是"无用对象"的呢?
这里有两种判断方式,首先我们先来说第一种:引用计数法。
引用计数法的判断标准是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就会加一;当引用失效时,计数器的值就会减一;只要任何时刻计数器为零的对象就是不会再被使用的对象。虽然这种判断方式非常简单粗暴,但是往往很有用,不过,在 Java 领域,主流的 Hotspot 虚拟机实现并没有采用这种方式,因为引用计数法不能解决对象之间的循环引用问题。
循环引用问题简单来讲就是两个对象之间互相依赖着对方,除此之外,再无其他引用,这样虚拟机无法判断引用是否为零从而进行垃圾回收操作。
还有一种判断对象无用的方法就是可达性分析算法。
当前主流的 JVM 都采用了可达性分析算法来进行判断,这个算法的基本思路就是通过一系列被称为GC Roots
的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径被称为引用链
(Reference Chain),如果某个对象到 GC Roots 之间没有任何引用链相连接,或者说从 GC Roots 到这个对象不可达时,则证明此这个对象是无用对象,需要被垃圾回收。
这种引用方式如下
如上图所示,从枚举根节点 GC Roots 开始进行遍历,object 1 、2、3、4 是存在引用关系的对象,而 object 5、6、7 之间虽然有关联,但是它们到 GC Roots 之间是不可达的,所以被认为是可以回收的对象。
在 Java 技术体系中,可以作为 GC Roots 进行检索的对象主要有
-
在虚拟机栈(栈帧中的本地变量表)中引用的对象。
-
方法区中类静态属性引用的对象,比如 Java 类的引用类型静态变量。
-
方法区中常量引用的对象,比如字符串常量池中的引用。
-
在本地方法栈中 JNI 引用的对象。
-
JVM 内部的引用,比如基本数据类型对应的 Class 对象,一些异常对象比如 NullPointerException、OutOfMemoryError 等,还有系统类加载器。
-
所有被 synchronized 持有的对象。
-
还有一些 JVM 内部的比如 JMXBean、JVMTI 中注册的回调,本地代码缓存等。
-
根据用户所选的垃圾收集器以及当前回收的内存区域的不同,还可能会有一些对象临时加入,共同构成 GC Roots 集合。
虽然我们上面提到了两种判断对象回收的方法,但无论是引用计数法还是判断 GC Roots 都离不开引用
这一层关系。
这里涉及到到强引用、软引用、弱引用、虚引用的引用关系,你可以阅读作者的这一篇文章
如何判断一个不再使用的类?
判断一个类型属于"不再使用的类"需要满足下面这三个条件
-
这个类所有的实例已经被回收,也就是 Java 堆中不存在该类及其任何这个类字累的实例 -
加载这个类的类加载器已经被回收,但是类加载器一般很难会被回收,除非这个类加载器是为了这个目的设计的,比如 OSGI、JSP 的重加载等,否则通常很难达成。 -
这个类对应的 Class 对象没有任何地方被引用,无法在任何时刻通过反射访问这个类的属性和方法。
虚拟机允许对满足上面这三个条件的无用类进行回收操作。