大厂必问的JVM面试题
Posted 程序员大彬
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了大厂必问的JVM面试题相关的知识,希望对你有一定的参考价值。
本文目录:
- 讲一下JVM内存结构?
- 程序计数器
- 虚拟机栈
- 本地方法栈
- 堆
- 方法区
- 运行时常量池
- 直接内存
- Java对象的定位方式
- 说一下堆栈的区别?
- 什么情况下会发生栈溢出?
- 类文件结构
- 什么是类加载?类加载的过程?
- 什么是双亲委派模型?
- 为什么需要双亲委派模型?
- 什么是类加载器,类加载器有哪些?
- 类的实例化顺序?
- 如何判断一个对象是否存活?
- 可作为GC Roots的对象有哪些?
- 什么情况下类会被卸载?
- 强引用、软引用、弱引用、虚引用是什么,有什么区别?
- Minor GC 和 Full GC的区别?
- 内存的分配策略?
- Full GC 的触发条件?
- 垃圾回收算法有哪些?
- 有哪些垃圾回收器?
- 常用的 JVM 调优的命令都有哪些?
- 对象头了解吗?
- 如何排查 OOM 的问题?
- GC是什么?为什么要GC?
讲一下JVM内存结构?
JVM内存结构分为5大区域,程序计数器、虚拟机栈、本地方法栈、堆、方法区。
程序计数器
线程私有的,作为当前线程的行号指示器,用于记录当前虚拟机正在执行的线程指令地址。程序计数器主要有两个作用:
- 当前线程所执行的字节码的行号指示器,通过它实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,当线程被切换回来的时候能够知道它上次执行的位置。
程序计数器是唯一一个不会出现 OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
虚拟机栈
Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。每一次函数调用都会有一个对应的栈帧被压入虚拟机栈,每一个函数调用结束后,都会有一个栈帧被弹出。 局部变量表是用于存放方法参数和方法内的局部变量。 每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,在方法调用过程中,会进行动态链接,将这个符号引用转化为直接引用。
- 部分符号引用在类加载阶段的时候就转化为直接引用,这种转化就是静态链接
- 部分符号引用在运行期间转化为直接引用,这种转化就是动态链接
Java 虚拟机栈也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。Java 虚拟机栈会出现两种错误:StackOverFlowError
和 OutOfMemoryError
。
可以通过 -Xss
参数来指定每个线程的虚拟机栈内存大小:
java -Xss2M
本地方法栈
虚拟机栈为虚拟机执行 Java
方法服务,而本地方法栈则为虚拟机使用到的 Native
方法服务。Native
方法一般是用其它语言(C、C++等)编写的。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
堆
堆用于存放对象实例,是垃圾收集器管理的主要区域,因此也被称作GC
堆。堆可以细分为:新生代(Eden
空间、From Survivor
、To Survivor
空间)和老年代。
通过 -Xms
设定程序启动时占用内存大小,通过-Xmx
设定程序运行期间最大可占用的内存大小。如果程序运行需要占用更多的内存,超出了这个设置值,就会抛出OutOfMemory
异常。
java -Xms1M -Xmx2M
方法区
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。 对方法区进行垃圾回收的主要目标是对常量池的回收和对类的卸载。
永久代
方法区是 JVM 的规范,而永久代PermGen
是方法区的一种实现方式,并且只有 HotSpot
有永久代。对于其他类型的虚拟机,如JRockit
没有永久代。由于方法区主要存储类的相关信息,所以对于动态生成类的场景比较容易出现永久代的内存溢出。
元空间
JDK 1.8 的时候,HotSpot
的永久代被彻底移除了,使用元空间替代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。两者最大的区别在于:元空间并不在虚拟机中,而是使用直接内存。
为什么要将永久代替换为元空间呢?
永久代内存受限于 JVM 可用内存,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是相比永久代内存溢出的概率更小。
运行时常量池
运行时常量池是方法区的一部分,在类加载之后,会将编译器生成的各种字面量和符号引号放到运行时常量池。在运行期间动态生成的常量,如 String 类的 intern()方法,也会被放入运行时常量池。
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。 NIO的Buffer提供了DirectBuffer,可以直接访问系统物理内存,避免堆内内存到堆外内存的数据拷贝操作,提高效率。DirectBuffer直接分配在物理内存中,并不占用堆空间,其可申请的最大内存受操作系统限制,不受最大堆内存的限制。 直接内存的读写操作比堆内存快,可以提升程序I/O操作的性能。通常在I/O通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到直接内存。
Java对象的定位方式
Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有使用句柄和直接指针两种:
- 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
- 直接指针。reference 中存储的直接就是对象的地址。对象包含到对象类型数据的指针,通过这个指针可以访问对象类型数据。使用直接指针访问方式最大的好处就是访问对象速度快,它节省了一次指针定位的时间开销,虚拟机hotspot主要是使用直接指针来访问对象。
说一下堆栈的区别?
- 堆的物理地址分配是不连续的,性能较慢;栈的物理地址分配是连续的,性能相对较快。
- 堆存放的是对象的实例和数组;栈存放的是局部变量,操作数栈,返回结果等。
- 堆是线程共享的;栈是线程私有的。
什么情况下会发生栈溢出?
- 当线程请求的栈深度超过了虚拟机允许的最大深度时,会抛出
StackOverFlowError
异常。这种情况通常是因为方法递归没终止条件。 - 新建线程的时候没有足够的内存去创建对应的虚拟机栈,虚拟机会抛出
OutOfMemoryError
异常。比如线程启动过多就会出现这种情况。
类文件结构
Class 文件结构如下:
ClassFile
u4 magic; //类文件的标志
u2 minor_version;//小版本号
u2 major_version;//大版本号
u2 constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池
u2 access_flags;//类的访问标记
u2 this_class;//当前类的索引
u2 super_class;//父类
u2 interfaces_count;//接口
u2 interfaces[interfaces_count];//一个类可以实现多个接口
u2 fields_count;//字段属性
field_info fields[fields_count];//一个类会可以有个字段
u2 methods_count;//方法数量
method_info methods[methods_count];//一个类可以有个多个方法
u2 attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合
主要参数如下:
魔数:class
文件标志。
文件版本:高版本的 Java 虚拟机可以执行低版本编译器生成的类文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的类文件。
常量池:存放字面量和符号引用。字面量类似于 Java 的常量,如字符串,声明为final
的常量值等。符号引用包含三类:类和接口的全限定名,方法的名称和描述符,字段的名称和描述符。
访问标志:识别类或者接口的访问信息,比如这个Class
是类还是接口,是否为 public
或者 abstract
类型等等。
当前类的索引:类索引用于确定这个类的全限定名。
什么是类加载?类加载的过程?
类的加载指的是将类的class
文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个此类的对象,通过这个对象可以访问到方法区对应的类信息。
加载
- 通过类的全限定名获取定义此类的二进制字节流
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表该类的
Class
对象,作为方法区类信息的访问入口
验证
确保Class文件的字节流中包含的信息符合虚拟机规范,保证在运行后不会危害虚拟机自身的安全。主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证。
准备
为类变量分配内存并设置类变量初始值的阶段。
解析
虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用用于描述目标,直接引用直接指向目标的地址。
初始化
开始执行类中定义的Java
代码,初始化阶段是调用类构造器的过程。
什么是双亲委派模型?
一个类加载器收到一个类的加载请求时,它首先不会自己尝试去加载它,而是把这个请求委派给父类加载器去完成,这样层层委派,因此所有的加载请求最终都会传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。
双亲委派模型的具体实现代码在 java.lang.ClassLoader
中,此类的 loadClass()
方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出 ClassNotFoundException
,此时尝试自己去加载。源码如下:
public abstract class ClassLoader
// The parent class loader for delegation
private final ClassLoader parent;
public Class<?> loadClass(String name) throws ClassNotFoundException
return loadClass(name, false);
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
synchronized (getClassLoadingLock(name))
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null)
try
if (parent != null)
c = parent.loadClass(name, false);
else
c = findBootstrapClassOrNull(name);
catch (ClassNotFoundException e)
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
if (c == null)
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);
if (resolve)
resolveClass(c);
return c;
protected Class<?> findClass(String name) throws ClassNotFoundException
throw new ClassNotFoundException(name);
为什么需要双亲委派模型?
双亲委派模型的好处:可以防止内存中出现多份同样的字节码。如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object
的同名类并放在ClassPath
中,多个类加载器都去加载这个类到内存中,系统中将会出现多个不同的Object
类,那么类之间的比较结果及类的唯一性将无法保证。
什么是类加载器,类加载器有哪些?
- 实现通过类的全限定名获取该类的二进制字节流的代码块叫做类加载器。
主要有一下四种类加载器:
- 启动类加载器:用来加载 Java 核心类库,无法被 Java 程序直接引用。
- 扩展类加载器:它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
- 系统类加载器:它根据应用的类路径来加载 Java 类。可通过
ClassLoader.getSystemClassLoader()
获取它。 - 自定义类加载器:通过继承
java.lang.ClassLoader
类的方式实现。
类的实例化顺序?
- 父类中的
static
代码块,当前类的static
代码块 - 父类的普通代码块
- 父类的构造函数
- 当前类普通代码块
- 当前类的构造函数
如何判断一个对象是否存活?
对堆垃圾回收前的第一步就是要判断那些对象已经死亡(即不再被任何途径引用的对象)。判断对象是否存活有两种方法:引用计数法和可达性分析。
引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。
这种方法很难解决对象之间相互循环引用的问题。比如下面的代码,obj1
和 obj2
互相引用,这种情况下,引用计数器的值都是1,不会被垃圾回收。
public class ReferenceCount
Object instance = null;
public static void main(String[] args)
ReferenceCount obj1 = new ReferenceCount();
ReferenceCount obj2 = new ReferenceCount();
obj1.instance = obj2;
obj2.instance = obj1;
<面试必过系列>2021最新JVM大厂必问面试题附解答