JVM面试题(一)
# 1、 类加载过程以及加载机制
类加载器的种类:
启动类加载器(Bootstrap ClassLoader),是虚拟机自身的一部分,用来加载Java_HOME/lib/目录中的,或者被 -Xbootclasspath 参数所指定的路径中并且被虚拟机识别的类库;
其他类加载器:
扩展类加载器(Extension ClassLoader):负责加载\lib\ext目录或Java. ext. dirs系统变量指定的路径中的所有类库;
应用程序类加载器(Application ClassLoader)。负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。

类加载器 就是根据指定全限定名称将class文件加载到JVM内存,转为Class对象。
加载过程:
加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载
验证 -> 准备 -> 解析 这三步合起来称为 连接
1.加载
加载简单来说分为三步。
第一步:获取二进制字节流也就是上面的class文件。
第二步:将静态的存储结构转换为方法区中的运行时数据结构。
第三步:生成一个对象放入java堆中,做为对方法区的引用。
(类的加载就是将class文件中的二进制数据读取到内存中,然后将该字节流所代表的静态数据结构转化为方法区中运行的数据结构,并且在堆内存中生成一个java.lang.Class对象作为访问方法区数据结构的入口)
2.验证
验证主要是检验如下的几项是否正确
class文件的表示(魔数),class文件的版本号,class文件的每个部分是否正确(字段表、方法表等),验证常量池(常量类型、常量类型数据结构是否正确,utf-8是否标准),元数据验证(父类验证,继承验证,final验证),字节码(指令)验证,符号引用验证(是否能根据符号找到对应的字段、表、方法等)
如果一项不对,就会验证失败。
3.准备
准备阶段为类变量分配内存 和设置类变量初始化。这个过程中,只对static类变量进行内存分配,这个时候只是分配内存,没有进行复制,所有的类变量都是初始化值。如果是final的话,会直接对应到常量池中。会在准备阶段直接赋值。
4.解析
解析阶段是读符号引用进行解析。将符号引用解析为直接引用(指向目标的指针或者偏移量)。主要涉及到的解析有类,接口,字段,方法等。
5.初始化
类初始化就是执行类中定义的Java程序代码(或者说是字节码),从字节码层面来说,初始化阶段是执行类构造器<clinit>()方法的过程。包括static{ } 代码块的语句执行和static变量赋值
6.使用
使用阶段就是使用这个class。
7.卸载
卸载阶段就是不在使用,将class给卸载。
# 2、Java内存模型
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

主内存:java虚拟机规定所有的变量(不是程序中的变量)都必须在主内存中产生,为了方便理解,可以认为是堆区。
工作内存:java虚拟机中每个线程都有自己的工作内存,该内存是线程私有的为了方便理解,可以认为是虚拟机栈。
Java内存模型的详细介绍,可参考:https://rain.baimuxym.cn/article/15
# 3、 Java内存区域(Java内存结构)
Java虚拟机在运行程序时把其自动管理的内存划分为以下几个区域。这个区域里的一些数据在JVM启动的时候创建,在JVM退出的时候销毁。而其他的数据依赖于每一个线程,在线程创建时创建,在线程退出时销毁。
# Java内存区域:


(一)、 方法区(Method Area)
方法区是在JVM中是所有线程所共享的,它存储每个类的结构,例如 **运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化和接口初始化中使用的特殊方法。**它是随着JVM的启动而启动的,在逻辑上方法区是属于堆的一部分,在JVM的规范中并没有强制性的要求方法区进行垃圾回收,而且方法区的大小可以固定也可以对其进行扩展,并没有要求方法区的内存空间是连续的,但是在方法区内存不够的时候会OutOfMemoryError
(二)、堆(Heap)
堆与方法区一样是所有线程所共享的,也是随着JVM的启动而启动,它是为所有**类实例或数组分配内存的运行时数据区域。**该区域中的对象的堆存储由垃圾回收机制回收。堆可以是固定大小的,也可以根据计算的要求进行扩展,如果没有必要使用更大的堆,则可以缩小。堆的内存同样不需要是连续的。但是在堆内存不够的时候会 OutOfMemoryError
所有通过new方法分配的对象都存在堆中
(三)、虚拟机栈(Java Virtual Machine Stacks)
虚拟机栈是每个线程独有的,随着线程的创建而存在,线程结束而死亡。它存储的是局部变量表、操作数栈、动态链接和方法的出口等信息。但是在虚拟机栈内存不够的时候会OutOfMemoryError,在线程运行中需要更大的虚拟机栈时会出现StackOverFlowError.
对象的引用在虚拟机栈
(四)、本地方法栈(Native Method Stacks)
本地方法栈允许java程序调用底层封装的C语言编写的方法实现,如果在线程需要使用本地方法栈的时候,JVM会为每个线程创建一个。与虚拟机栈一样在一些情况下会出现OutOfMemoryError或者StackOverFlowError。native方法的含义:该方法的实现由非java语言实现,比如C。
(五)、程序计数器(Program Counter Register)
由于JVM可以并发执行线程,因此会存在线程之间的切换,而这个时候就程序计数器会记录下当前程序执行到的位置,以便在其他线程执行完毕后,恢复现场继续执行。JVM会为每个线程分配一个程序计数器,与线程的生命周期相同。
(六) 运行时常量池(Runtime Constant Pool )
运行时常量池是类文件中常量池表的运行时形式,它包含编译时已知的常量信息、必须在运行时解析的方法和字段引用等信息。
Java内存区域,详细可参考:https://rain.baimuxym.cn/article/14
# 4、Java 中堆和栈有什么区别
JVM 中堆和栈属于不同的内存区域,使用目的也不同。栈常用于保存方法帧和局部变量,而对象总是在堆上分配。栈通常都比堆小,也不会在多个线程之间共享,而堆被整个 JVM 的所有线程共享。
栈是一块和线程紧密相关的内存区域。每个线程都有自己的栈内存,用于存储本地变量,方法参数和栈调用,一个线程中存储的变量对其它线程是不可见的。而堆是所有线程共享的一片公用内存区域。对象都在堆里创建,为了提升效率线程会从堆中弄一个缓存到自己的栈,如果多个线程使用该变量就可能引发问题,这时volatile 变量就可以发挥作用了,它要求线程从主存中读取变量的值。
# 5、栈帧都有哪些数据?
见上图:

# 6、双亲委派模型是什么,为什么要使用双亲委派模型,如何打破双亲委派模型?
1)双亲委派模型
如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即ClassNotFoundException),子加载器才会尝试自己去加载。
2)为什么需要双亲委派模型?
在这里,先想一下,如果没有双亲委派,那么用户是不是可以自己定义一个java.lang.Object的同名类,java.lang.String的同名类,并把它放到ClassPath中,那么类之间的比较结果及类的唯一性将无法保证,因此,为什么需要双亲委派模型?防止内存中出现多份同样的字节码
3)怎么打破双亲委派模型?
打破双亲委派机制则不仅要继承ClassLoader类,还要重写loadClass和findClass方法。
# 7、常用的jvm参数有什么?
如果面试官问你怎么看参数:
你可以回答 使用-XX:+PrintFlagsFinal参数可以看到参数的默认值。
常用参数:
-Xms300m 起始内存(堆大小)设置为300m
-Xmx 最大内存
-Xmn 新生代内存
-Xss 栈大小。 就是创建线程后,分配给每一个线程的内存大小
-XX:NewRatio=n:设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4
-XX:SurvivorRatio=n:年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5
-XX:MaxPermSize=n:设置持久代大小
收集器设置:
-XX:+UseSerialGC:设置串行收集器
-XX:+UseParallelGC:设置并行收集器
-XX:+UseParalledlOldGC:设置并行年老代收集器
-XX:+UseConcMarkSweepGC:设置并发收集器
垃圾回收统计信息:
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-Xloggc:filename
并行收集器设置:
-XX:ParallelGCThreads=n:设置并行收集器收集时使用的CPU数。并行收集线程数。
-XX:MaxGCPauseMillis=n:设置并行收集最大暂停时间
-XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)
并发收集器设置:
-XX:+CMSIncrementalMode:设置为增量模式。适用于单CPU情况。
-XX:ParallelGCThreads=n:设置并发收集器年轻代收集方式为并行收集时,使用的CPU数。并行收集线程数。
Xmx、Xms、Xmn、MetaspaceSize 这几个是关键,一定要记住。
# 8、强引用、软引用、弱引用、虚引用的区别
| 引用类型 | 被垃圾回收时间 | 用途 | 生存时间 |
|---|---|---|---|
| 强引用 | 从来不会 | 对象的一般状态 | JVM停止运行时终止 |
| 软引用 | 当内存不足时 | 对象缓存 | 内存不足时终止 |
| 弱引用 | 正常垃圾回收时 | 对象缓存 | 垃圾回收后终止 |
| 虚引用 | 正常垃圾回收时 | 跟踪对象的垃圾回收 | 垃圾回收后终止 |
强引用:
在一个方法的内部有一个强引用,这个引用保存在栈中,而真正的引用内容(Object)保存在堆中。当这个方法运行完成后就会退出方法栈,则引用内容的引用不存在,这个Object会被回收。
强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它,除非把它设置为null,则gc认为该对象不存在引用(要看gc算法)
软引用:
如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
弱引用:
弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
# 9、常用的垃圾回收算法有哪些?
Java的垃圾回收机制主要用来自动管理堆内存,回收不再使用的对象,防止内存泄漏。其核心工作流程可以概括为以下三步:
- 标记(Mark) :垃圾回收器会从一组称为 "GC Roots" 的根对象(如静态变量、活跃栈帧中的局部变量等)开始,遍历所有能被访问到的对象,并标记为存活对象。无法被访问到的对象则被视为垃圾。
- 清除(Sweep) :垃圾回收器会清理掉那些在标记阶段被判定为垃圾的对象。
- 压缩(Compact,可选) :为了解决内存碎片问题,有些垃圾回收器在清除后还会移动存活对象,使它们紧凑地排列在内存的一端,从而留出连续的空闲内存
常用的垃圾回收算法:
1、标记清除算法( Mark-Sweep ) 最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间,最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。
2、 复制算法(copying ) 为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉。这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。
3、 标记压缩算法(Mark-Compact)
结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。
4、分代收集算法
把堆内存分为新生代和老年代,新生代又分为 Eden 区、From Survivor 和 To Survivor。一般新生代中的对象基本上都是朝生夕灭的,每次只有少量对象存活,因此采用复制算法,只需要复制那些少量存活的对象就可以完成垃圾收集;老年代中的对象存活率较高,就采用标记-清除和标记-整理算法来进行回收。
新创建的对象首先在Eden区分配,当Eden区满时,会触发一次Minor GC,存活的对象会被移动到Survivor区,年龄增长到一定程度后(默认为15岁)会晋升到老年代。当老年代空间不足时,则会触发Full GC
# 分代垃圾回收器工作过程:
分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的 1/3,老生代的默认占比是 2/3。
新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程如下:
- 把 Eden + From Survivor 存活的对象放入 To Survivor 区;
- 清空 Eden 和 From Survivor 分区;
- From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。
每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代。
老生代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。
# 10、垃圾回收器有哪些?
- Serial收集器,串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。
- SerialOld,老年代收集器,单线程收集器,单线程,将废弃的对象干掉,只留幸存 的对象。
- ParNew收集器,ParNew收集器其实就是Serial收集器的多线程版本。
- Parallel收集器,Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。最高效率的利用CPU。
- Parallel Old 收集器,Parallel Old是Parallel Scavenge收集器的老年代版本,将幸存的对象复制到预先准备好的区域。
- CMS收集器,CMS(Concurrent Mark Sweep),致力于获取最短回收停顿时间,使用标记-清除算法,多线程,优点是并发收集(用户线程可以和GC线程同时工作),停顿小。
- G1收集器,G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。
总结:
- 新生代回收器:Serial、ParNew、Parallel Scavenge
- 老年代回收器:Serial Old、Parallel Old、CMS
- 整堆回收器:G1
| 收集器 | 串行/并行/并发 | 收集目标 | 算法 | 优点 | 缺点 |
|---|---|---|---|---|---|
| Serial New | 串行 | 新生代 | 复制 | 单CPU下效率高 | 产生较长时间的停顿 |
| Serial Old | 串行 | 老年代 | 标记-整理 | 单CPU下效率高 | 产生较长时间的停顿 |
| Parallel New | 并行 | 新生代 | 复制 | 单CPU下效率高,可设置可控参数 | 产生较长时间的停顿 |
| Parallel Scavenge | 并行 | 新生代 | 复制 | 最高效率的利用CPU,高吞吐 | 产生较长时间的停顿 |
| Parallel Old | 并行 | 老年代 | 标记-整理 | 多CPU下效率高 | 产生较长时间的停顿 |
| CMS | 并发 | 老年代 | 标记-清除 | 最小的停顿时间 | 容易产生内存碎片 |
| G1 | 并发 | 新生代+老年代 | 标记-整理 | 空间整合,解决内存碎片问题 |
像jdk8 就是使用的 Parallel GC算法,也称为吞吐量收集器。它是一个分代收集器。新生代使用Parallel Scavenge收集器,采用复制算法。老年代使用Parallel Old收集器,采用标记-整理算法,Parallel GC在垃圾回收时会暂停所有应用线程(Stop-The-World, STW),并使用多个GC线程并行地进行垃圾回收。它的设计目标是达到较高的吞吐量(即应用程序运行时间占总时间的比率),适合后台运算、批处理等不太关心单个暂停长度的场景。
像jdk11,jdk17使用的是G1(Garbage-First) 垃圾收集器,相比于Parallel GC,G1的设计目标更侧重于在保证高吞吐量的同时,尽可能减少停顿时间,适用于大内存和多核CPU的环境。