JVM 面试
JVM 运行时内存区域划分是怎样的?
-
程序计数器:记录当前线程执行的字节码指令的地址,是线程私有的。
-
Java 虚拟机栈:每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,是线程私有的。
-
本地方法栈:与 Java 虚拟机栈类似,用于执行本地方法,是线程私有的。
-
堆:用于存储对象实例,是线程共享的。
-
方法区:用于存储已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,是线程共享的。在 JDK 1.8 中,方法区被元空间(Metaspace)取代,元空间使用本地内存。
常见的 GC 回收算法及其含义是什么?
-
标记 - 清除算法:分为标记和清除两个阶段。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。该算法会产生大量的内存碎片。
-
复制算法:将内存分为大小相等的两块,每次只使用其中一块。当这块内存满了时,将存活的对象复制到另一块内存中,然后清除原来的内存。该算法不会产生内存碎片,但会浪费一半的内存空间。
-
标记 - 整理算法:在标记 - 清除算法的基础上,增加了整理的步骤。在标记阶段标记出所有需要回收的对象,清除阶段将存活的对象向一端移动,然后清除边界以外的内存,解决了内存碎片的问题。
-
分代收集算法:根据对象的存活周期将内存划分为新生代和老年代。新生代采用复制算法,老年代采用标记 - 清除或标记 - 整理算法。
什么是类加载器?
类加载器是负责将字节码文件加载到 JVM 中,并将其转换为 Class 对象的组件。Java 中有三种类型的类加载器:
-
启动类加载器(Bootstrap ClassLoader):负责加载 Java 核心类库,如 rt.jar,是用 C++ 实现的,无法被 Java 程序直接引用。
-
扩展类加载器(Extension ClassLoader):负责加载 Java 的扩展类库,如 jre/lib/ext 目录下的类库。
-
应用程序类加载器(Application ClassLoader):负责加载应用程序的类,如 classpath 下的类。
什么是双亲委派模型机制?
当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子类加载器才会尝试自己去加载。
一个类的生命周期是怎样的?类是如何加载到 JVM 中的?
-
生命周期:加载、验证、准备、解析、初始化、使用、卸载。
-
加载过程:
-
加载:通过类加载器将字节码文件加载到内存中,生成一个 Class 对象。
-
验证:验证字节码文件的正确性,确保其符合 Java 虚拟机规范,包括文件格式验证、元数据验证、字节码验证和符号引用验证等步骤,防止恶意字节码对 JVM 造成危害。
-
准备:为类的静态变量分配内存,并设置默认初始值,如为整型变量赋 0,引用类型赋 null 。
-
解析:将常量池中的符号引用替换为直接引用,也就是把类、接口、字段和方法的符号引用转换为具体内存地址的直接引用,便于在运行时快速定位和访问这些元素。
-
初始化:执行类构造器
<clinit>()
方法,对静态变量进行显式赋值和执行静态代码块中的代码。<clinit>()
方法是由编译器自动收集类中的所有静态变量的赋值动作和静态代码块中的语句合并产生的,并且在多线程环境下,JVM 会确保<clinit>()
方法的线程安全性,一个类的<clinit>()
方法在多线程环境下只会被执行一次。
-
说说类加载的过程?
类加载过程包含上述生命周期中的加载、验证、准备、解析、初始化这几个阶段:加载阶段通过类加载器查找并读取字节码文件,将其转化为内存中的 Class 对象;验证阶段全方位检查字节码的合法性和安全性;准备阶段为静态变量分配内存并赋予初始默认值;解析阶段将常量池中的符号引用替换为直接引用;初始化阶段执行<clinit>()
方法,完成静态变量的显式赋值和静态代码块的执行。例如,当我们首次使用一个自定义类时,JVM 会按照这个顺序逐步完成类的加载,确保类在使用前已经被正确加载和初始化。
什么是强引用、软引用、弱引用、虚引用?
-
强引用:最常见的引用类型,通过
new
关键字创建对象时就是强引用。只要强引用存在,垃圾回收器就不会回收被引用的对象。例如Object obj = new Object();
,只要obj
引用存在,对应的Object
对象就不会被回收,哪怕内存不足也不会回收,可能导致 OOM。 -
软引用:通过
SoftReference
类实现,在内存充足时,不会被回收;当内存不足时,会被回收。常用于实现内存敏感的缓存,比如缓存图片等大对象,在内存不够时,系统会优先回收这些软引用指向的对象,避免 OOM。 -
弱引用:通过
WeakReference
类实现,无论内存是否充足,只要垃圾回收器扫描到,就会回收被弱引用指向的对象。常用于解决内存泄漏问题,例如在HashMap
中,如果使用强引用作为key
,当key
不再使用但仍被HashMap
引用时,可能导致内存泄漏,而使用弱引用作为key
,在key
不再被其他地方引用时,垃圾回收器会回收它,避免内存泄漏。 -
虚引用:通过
PhantomReference
类实现,也叫幻影引用,它对对象的生命周期没有影响,无法通过虚引用来获取对象实例,主要用于在对象被回收时收到一个系统通知,例如用于管理堆外内存资源,当对象被回收时,可通过虚引用关联的引用队列来触发堆外内存的释放操作。
Minor GC 与 Full GC 分别在什么时候发生?
-
Minor GC:发生在新生代,当新生代的 Eden 区满了,无法存放新创建的对象时,就会触发 Minor GC。它会回收新生代中不再被引用的对象,由于新生代对象大多 “朝生夕灭”,所以 Minor GC 的频率较高,但回收速度相对较快。
-
Full GC:发生在老年代,常见的触发场景有老年代空间不足、方法区空间不足、显式调用
System.gc()
(不过System.gc()
只是建议 JVM 进行 Full GC,JVM 不一定会立即执行)、大对象直接进入老年代且老年代空间不够时,以及在进行 Minor GC 时,动态年龄判断发现 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,若老年代空间不足就会触发 Full GC。Full GC 会对整个堆(包括新生代和老年代)和方法区进行垃圾回收,回收速度相对较慢,因为老年代中的对象存活时间长,垃圾回收的成本更高。
什么时候触发 Full GC?
除了上述提到的老年代空间不足、方法区空间不足、显式调用System.gc()
、大对象直接进入老年代且老年代空间不够、Minor GC 时动态年龄判断导致老年代空间不足等情况外,还有以下情况:当 JVM 的堆内存使用率达到一定阈值(可通过参数设置,如-XX:HeapDumpOnOutOfMemoryError
结合-XX:OnOutOfMemoryError
等参数配合监控和触发相关操作),可能会触发 Full GC;在使用 CMS(Concurrent Mark Sweep)垃圾回收器时,如果在并发标记和清理阶段出现 Concurrent Mode Failure,即 CMS 在垃圾回收过程中,应用程序又产生了大量垃圾,导致老年代剩余空间无法容纳新的垃圾对象,也会触发 Full GC。
Java 中的大对象如何进行存储?
大对象通常指需要大量连续内存空间的对象,如大数组。在 Java 中,大对象一般会直接分配到老年代。因为新生代的空间相对较小,且使用复制算法,频繁地在新生代分配和回收大对象可能会导致大量的内存复制操作,影响性能。而老年代空间较大,并且采用标记 - 清除或标记 - 整理算法,更适合存储大对象。不过,当老年代空间不足时,就可能触发 Full GC 来回收老年代空间,以容纳大对象。此外,可以通过调整 JVM 参数(如-XX:PretenureSizeThreshold
)来设置大对象直接进入老年代的阈值,当对象大小超过该阈值时,就直接在老年代分配内存。
为什么新生代内存需要有两个 Survivor 区?
新生代采用复制算法进行垃圾回收,两个 Survivor 区(一般称为 From Survivor 和 To Survivor)的设计是为了实现高效的垃圾回收。在每次 Minor GC 时,Eden 区和 From Survivor 区中存活的对象会被复制到 To Survivor 区,然后清空 Eden 区和 From Survivor 区。下次 Minor GC 时,From Survivor 区和 To Survivor 区的角色互换,即原来的 To Survivor 区变为 From Survivor 区,原来的 From Survivor 区变为 To Survivor 区。这样设计的好处是:一方面,避免了像标记 - 清除算法那样产生内存碎片;另一方面,通过复制存活对象,使得存活时间长的对象逐步晋升到老年代,因为每次复制时,对象的年龄(在 Survivor 区经历一次 Minor GC,年龄就加 1)会增加,当年龄达到一定阈值(默认为 15)时,对象就会被晋升到老年代,从而保证新生代的空间能够高效地利用,提高垃圾回收的效率。