JVM知识点整理
JVM知识点整理
JVM的组成?
- 类加载子系统 Class Loader
- 运行时数据区 Runtime Data Area
- 方法区 Metthod Area
- 堆 Heap
- 虚拟机栈 Stack
- 本地方法栈 Native Method
- 程序计数器 PC Register
- 执行引擎 Execution Engine
- 本地方法接口 Native Interface
什么是类加载?
类加载就是JMVM将.class文件加载到内存中,经过验证和准备,并最终生成对应的class对象(即类的元信息)
类加载的整个具体过程是什么样的?
类加载的流程为:加载(类加载器负责),链接和初始化(JVM完成)
链接还可以拆分为:验证、准备、解析
加载:将二进制流读入内存中,生成一个Class对象
具体流程: 1. 通过类的全限定名读取类的二进制流
2. 将字节流所代表的类转化后存在方法区(类的元数据信息) 特别说明:存在方法区中我们称之为“永久代”,而在JDK8之后为“元空间”,使用的是本地内存而不是JVM内存
3. 在堆区创建一个 java.lang.class 对象,它作为“桥梁”,维护了一个指向方法区类元数据的引用,是访问方法区类元信息的唯一入口(桥梁)
链接:
- 验证:JVM会去校验class文件格式及class文件二进制流中所包含的信息是不是符合虚拟机规范的约束。
- 准备:为静态变量(类变量)赋初始值,也即为它们在方法区划分内存空间
- 解析:将符号引用转为直接引用。在每一个.class文件中有一个常量池,其中存储了类名、方法名、字段名等。符号引用:类的全限定名,字段名+描述符等;直接引用:指向对应目标的内存地址。
初始化
对静态变量进行赋值操作,执行
方法,完成静态变量的初始化和静态块的执行
JVM什么时候会对类进行加载?
类被首次使用:
- 创建对象
- 访问静态变量
- 调用静态方法
- Class.forName(“com.xxx.xxx”)
java采用懒加载策略,即并不是在程序启动时一次性加载所有类。
类加载器
类加载就是专门负责类加载流程中的加载这个阶段的主体组件
加载这个阶段有意被放在JVM之外实现,以便于让用户自己决定如何获取所需类
JVM根据职能的不同,设计了以下四种类加载器:
- 引导类加载器(BostStrap ClassLOader)
- 由C/C++语言实现,嵌套在虚拟机内部,用来加载java核心库中的类
- 扩展类加载器(Extension ClassLoader)
- 应用程序类加载器(Application ClassLoader)
- 自定义类加载器(User ClassLoader)
对于某一些特殊的类加载需求,用户可以通过继承ClassLoader实现自定义的类加载器,通过自定义类加载器,可以在以下的需求场景使用:
1、隔离类,如类路径冲突。
2、防反编译加密Class文件。
3、扩展类的加载源。
代码示例:
1 | public class CustomClassLoader extends ClassLoader { |
类在虚拟机中的唯一性
在JVM中,每个类的唯一性由类和类加载器两个因素共同决定。
双亲委派机制
JVM对class文件是按需加载,在加载class的过程,如果当前类加载器存在父类加载器,都会将加载请求先委派给父类加载器,这种任务委派方式被称为双亲委派。
优点:
- 避免全限定名相同的类被重复加载,导致程序异常
- 保护程序,防止核心API库被篡改
SPI打破双亲委派机制
SPI像是Java的一种”插件机制”。
它允许我们:
- 定义一个接口(比如 JDBC 中的
java.sql.Driver
) - 然后不同厂商(比如 MySQL、PostgreSQL)去实现它
- JVM 在运行时自动“发现”并加载这些实现类,而不是需要手动写死
new MySQLDriver()
。
SPI的实现利用了 META-INF/services 目录
- 你在里面放一个以接口全类名命名的文件,比如
META-INF/services/java.sql.Driver
- 文件内容就是你写的实现类的全类名,比如
com.mysql.jdbc.Driver
ServiceLoader
工具类会在运行时读取 META-INF/services
目录下的配置文件,加载并实例化这些类。
SPI 的接口,比如 java.sql.Driver
是由Bootstrap ClassLoader加载的。它的实现类是放在我们的项目里面的,由Application ClassLoader 加载。按照双亲委派的原则:高级的类加载器是获取不到它的子级加载器加载的类,所以JVM就没办法直接从Bootstrap 里加载 MySQL 的驱动实现了。
总结原因:
Bootstrap 去加载实现类 → 看不到 App 的内容 → 加载失败
如何解决?
通过设置线程上下文类加载器,我们可以让 运行在 BootstrapClassLoader 加载的类(如 JDK 中的 SPI 框架代码),使用指定的类加载器(通常是 ApplicationClassLoader)去加载接口的实现类,从而打破双亲委派模型的限制,实现 SPI 的灵活插件化机制。
程序计数器
程序计数器(也称PC寄存器)是线程私有地一块很小的内存区域,存储了要执行的下一条指令的地址。
作用:
保证CPU的处理器切换线程执行时,等待的线程恢复执行之后能回到正确的位置继续执行
虚拟机栈
虚拟机栈是线程执行java程序时,处理java中方法的内存区域,是线程私有的。
作用
- 每个线程私有,生命周期与线程一致。
- 用于方法调用时维护执行上下文。
- 每调用一个方法,JVM 就会为它在栈中分配一个栈帧(Stack Frame)。
栈帧结构
- 局部变量****表(方法的参数、局部变量)
- 操作数栈(计算用的临时区域)
- 动态链接(常量池引用,支持方法调用)
- 返回地址(方法结束后返回到哪里)
运行原理
线程执行java方法时,会实行入栈操作,向虚拟机栈压入一个栈帧,线程当前正在执行的方法对应栈顶栈帧,当前方法执行完成后会将当前的结果传给下一个栈帧,之后无论时候抛出异常都会进行出栈操作。
参数大小设置
虚拟栈的大小可以通过-Xss参数设置
默认大小为1MB
异常
- StackOverflowError
原因:线程请求的栈深度超过虚拟机所允许的最大深度
常见场景:
递归调用没有终止条件或条件错误,导致无限递归。
方法调用层级太深
- OutofMemoryError
原因:不断创建新的线程,当创建线程时没有足够的内存去创建对应的虚拟机栈
常见场景:
开启了过多线程(如线程池或死循环创建线程)。
单线程栈空间设置得太大,内存不足以容纳足够多线程。
局部变量表
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的最大容量在编译期间就已经确定,保存在字节码文件的Code属性的locals里面,并且在运行期间也不会改变。
它本质上是一个 变量槽(Slot)数组,每个槽大小为 32 位(4 字节)
操作数栈
操作数栈是方法调用时存放中间计算结果和操作数的地方,类似于计算器的寄存器。
特点:
- 大小固定:操作数栈的最大深度(即栈的大小)在编译期间就已经确定。
- 存储类型和局部变量表一致
- 基于栈的执行引擎:JVM 的解释执行引擎是基于栈的。
动态链接
动态链接(Dynamic Linking)是指在方法调用时,将方法符号引用(常量池中的方法符号)解析为具体的内存地址的过程。 它是在方法调用时完成的,因此称为动态。
动态链接主要存放方法的符号引用或方法调用****指令,用于在方法调用时进行符号解析。
返回地址
方法返回地址(return Address)是栈帧的最后一块区域,存放了调用该方法的程序计数器的值。
本地方法栈
本地方法栈(Native Method Stack)是 JVM 中用于支持本地方法调用的栈。
作用: 为使用本地方法接口(JNI)调用本地方法(如 C/C++ 方法)提供栈空间。
Java堆
堆区(Heap区)是JVM运行时数据区占用内存最大的一块区域,每一个JVM进程只存在一个堆区,它在JVM启动时被创建,它在内存中不一定连续,但是在逻辑上连续。
所有的对象实例和数组在运行时都存储在堆上,而他们的引用被保存在虚拟机栈当中,当方法结束,这些实例不会立即被清楚,而是等待垃圾回收
堆区的组成
堆区的组成分为年轻代和老年代,其中年轻代又分为伊甸区和幸存者区,幸存者区又分为S1和S2.
各区域的作用:
伊甸区(Eden):存放大部分新创建对象。
幸存区(Survivor):存放Minor GC之后,Eden区和幸存者区本身没有被回收的对象。
老年代(Old):存放Minor GC之后且年龄计数器达到15依然存活的对象,Major GC和Full GC之后仍然存活的对象。
Eden区
大部分对象都是朝生夕死,所以对象首先会在新生代 Eden 区中进行分配,当 Eden 区没有足够空间进行分配时,JVM 会发起一次 Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快。
在Minor GC 之后,Eden 区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到 Survivor 的 From 区,如果 From 区不够,则直接进入 To 区
Survivor区
其中S0 和 S1 是两个对等的 Survivor 区,互为备份区。
作用: 减少直接晋升到老年代的概率,避免频繁进入老年代。
Old区
只有在 Major GC 的时候才会进行清理,每次 GC 都会触发“Stop-The-World”。内存越大,STW 的时间也越长,所以内存也不仅仅是越大就越好。
特别说明
- 大对象
- 大对象指需要大量连续内存空间的对象,这部分对象不管是不是“朝生夕死”,都会直接进到老年代。这样做主v要是为了避免在 Eden 区及 2 个 Survivor 区之间发生大量的内存复制。
- 长期存活对象
虚拟机给每个对象定义了一个对象年龄(Age)计数器。正常情况下对象会不断的在 Survivor 的 From 区与 To 区之间移动,每移动一次,年龄就增加一岁,到15岁就会被转移到老年代。
这里的15可以更改:-XX:MaxTenuringThreshold=15
- 动态对象年龄
- JVM 并不强制要求对象年龄必须到 15 岁才会放入老年区,如果 Survivor 空间中某个年龄段及以上的对象总大小超过了 Survivor 空间的一半,那么该年龄段及以上年龄段的所有对象都会在下一次垃圾回收时被晋升到老年代
参数设置
堆的内存大小默认是物理内存的1/64,最大物理内存的1/4。
- -Xms: 设置初始堆内存,如-Xms64m
- -Xmx: 设置最大堆内存,如-Xmx64m
- -Xmn: 设置年轻代内存,如-Xmx32m
垃圾回收
垃圾回收(Garbage Collection , GC),就是释放垃圾啊占用的空间,防止内存爆掉。
垃圾判断算法
- 引用计数算法
- 可达性算法
引用计数算法
是通过在对象头中分配一个空间来保存该对象被引用的次数(Reference Count)。
如果被其他对象引用,则它的计数加1,如果引用被删除,那么它的引用计数减1,当为0时,就会被回收。
注意:
- 引用计数法将垃圾回收分摊到了整个应用程序时,而不是集中在垃圾回收时,因此不算严格意义上的”Stop-The-World”的垃圾回收机制。
- 无法解决循环依赖的问题
可达性算法
其基本思路是通过GC Roots作为起点,然后向下搜索,搜索走过的路径为Reference Chain(引用链) ,当一个对象到GC Roots之间没有任何引用相连时,即到该节点不可达,证明该节点是需要垃圾收集的。
GC Roots是指在垃圾回收时作为根节点的特殊对象,任何能通过引用链(Reference Chain)直接或间接从这些根对象到达的对象都不会被回收。包括以下几种:
- 虚拟机栈中的引用(方法的参数、局部变量等)
- 本地方法栈中 JNI 的引用
- 类静态变量
- 运行时常量池中的常量(String 或 Class 类型)
垃圾收集算法
标记垃圾算法
是最基础的一种垃圾回收算法,它分为两部分,先把内存区域中的这些垃圾进行标记,哪些属于可回收的标记出来(可达性分析法),然后将这些垃圾进行清理
清理掉的垃圾就变成可使用的空闲空间,等待再次被使用。但是可能造成内存分布碎片,当需要分配较大对象时,容易因没有足够的连续内存而不得不提前触发新一轮的垃圾收集。
复制算法
复制算法(Copying)是在标记清除算法上演化而来的,用于解决标记清除算法的内存碎片问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。
垃圾回收时将活着的对象从一半复制到另一半,清楚原区域的所有对象
标记整理算法
标记过程仍然与标记清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域。
内存变动频繁,效率较差
分代收集算法
这严格来说并不是一种思想或理论,而是融合以上三种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳。
根据对象存活周期的不同会将内存划分为几块,一般是把 Java 堆分为新生代和老年代,
在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,所以选用复制算法,只需付出少量存活对象的复制成本就可以完成收集。
在老年代中,因为对象的存活率较高、没有额外空间对它进行担保,就使用标记清理或者标记整理清理算法来进行回收。
垃圾收集器
JVM 提供了多种垃圾收集器(Garbage Collector, GC),主要根据不同代的特点来选择合适的收集器。
新生代收集器
- Serial收集器:
- 单线程收集器,适合小型应用和单处理器环境
- 触发 Stop-The-World (STW) 操作,所有应用线程在GC时暂停
- 适用场景:适用于单线程应用和客户端模式。
- ParNew收集器
- 是Serial的多线程版本,能够并行进行垃圾收集
- 与CMS收集器配合使用时,通常会使用ParNew进行新生代收集器
- 适用场景:适用于多处理器环境,通常配合CMS收集器使用
- Parallel收集器(吞吐量优先)
- 也成为“吞吐量收集器”,追求最大化CPU时间的利用率
- 并行处理新生代垃圾回收,适合大规模后台任务处理,注重吞吐量而非延迟
- 使用场景:适用于大规模运算密集型后台任务,适合堆吞吐量要求较高的场景
老年代垃圾收集器
- Serial Old 收集器:
- Serial 收集器的老年代版本,使用标记-整理(Mark-Compact)算法进行垃圾回收。
- 适用场景:适合单线程环境和低内存使用常见,通常配合Serial收集器一起使用。
- Parrallel Old 收集器
- Parallel Scavenge 收集器的老年代版本,使用多线程并行标记-整理算法。
- 适用场景:适合大规模并行计算的场景,适用于高吞吐量要求的任务。
- CMS(Concurrent Mark-Sweep)收集器
- 并发标记-清除收集器,追求低延迟,减少 GC 停顿时间。
- 使用并发标记和清除算法,适合对响应时间有较高要求的应用。
- 缺点:可能会产生内存碎片,并且在并发阶段可能会发生Concurrent Mode Failure,导致 Full GC。
- 适用场景:适用于对响应时间要求高的应用,如 Web 服务和电商平台。
- G1 (Garbage First收集器)
- 设计用于取代 CMS 的低延迟垃圾收集器,能够提供可预测的停顿时间。
- 通过分区来管理内存,并在垃圾收集时优先处理最有价值的区域,避免了CMS 的内存碎片问题。
- 适用场景:适合大内存、多 CPU 服务器应用,尤其在延迟和响应时间敏感的场景中表现出色。
- ZGC (Z Garbage Collector 收集器)
- 低停顿、高吞吐量的垃圾收集器,停顿时间一般不会超过 10 毫秒。
- 适用场景:适用于需要管理大堆内存且对低延迟要求极高的应用。
JIT
Java为了提升运行时的性能,JVM引入了JIT,也就是即时编译(Just In Time)技术
JIT和解释器一样,都是JVM 执行引擎(Execution Engine)中协同工作的两种字节码执行方式。
当某部分的代码被频繁执行时,JIT 会将这些热点代码编译为机器码,以此来提高程序的执行效率。
为什么JIT能提高程序的执行效率:
解释器在执行程序时,对于每一条字节码指令,都需要进行一次解释过程,然后执行相应的机器指令。这个过程在每次执行时都会重复进行,因为解释器不会记住之前的解释结果。
与此相对,JIT会将频繁执行的字节码编译成机器码,这个过程只发生一次。一旦字节码被编译为机器码,之后每次执行这部分代码时,直接执行对应的机器码,无需再次编译。
此外,JIT生成的机器码更加接近底层,能够更有效地利用 CPU 和内存等资源,同时,JIT 能够在运行时根据实际情况对代码进行优化(如内联、循环展开、分支预测优化等),这些优化是在机器码级别上进行的,可以显著提升执行效率。
怎么样才会被认为热点代码:
JVM 中有一个阈值,当方法或者代码块的在一定时间内的调用次数超过这个阈值时就会被认定为热点代码,然后编译存入 codeCache 中。当下次执行时,再遇到这段代码,就会从 codeCache 中直接读取机器码,然后执行,以此来提升程序运行的性能。
JVM的编译器(编译为机器码)
编译器分类:
在 Java 中,编译器可以按照工作阶段和方式进行分类,主要分为三类:
- 前端编译器 前端编译器负责将 Java 源文件(*.java) 转换为 字节码文件(.class)。
- 典型示例:JDK 中的 Javac 编译器,Eclipse JDT 中的 增量式编译器。
- 即时编译器(****JIT 编译器) 即时编译器在程序运行时,将字节码(.class 文件中的中间代码)转换为本地机器码,以提高程序的执行效率。
- 常见示例:HotSpot 虚拟机中的 C1 和 C2 编译器,Graal 编译器。
- JIT 编译器的分类:
- Client Compiler(C1 编译器):注重局部启动速度和快速优化,适合客户端应用。
- Server Compiler(C2 编译器):注重全局优化和高性能,适合长时间运行的服务端应用。
- 在分层编译模式出现前,是否使用 C1 或 C2 取决于虚拟机的运行模式(客户端模式或服务端模式)。
- 可以在启动时通过 -client 或 -server 参数进行手动指定,也可以让虚拟机根据系统环境自动选择。
- Graal 编译器:在 JDK 10 引入,作为 C2 的潜在替代者,进一步提升优化性能。
- 提前编译器(****AOT 编译器) 提前编译器在程序运行前,将 Java 程序直接编译为目标机器的****二进制代码,免去运行时的编译过程。
- 典型示例:JDK 中的 jaotc 工具、GUN Compiler for Java (GCJ)、Excelsior JET。
HotSpot 虚拟机中的编译器
HotSpot JVM 集成了三种即时编译器:
- 客户端编译器 (Client Compiler):简称 C1,主要用于快速启动和轻量级优化。
- 服务端编译器 (Server Compiler):简称 C2,主要用于深度优化和高效运行,也称为 Opto 编译器。
- Graal 编译器:在 JDK 10 引入,旨在替代 C2,提供更高性能和可扩展性。
在分层编译模式下,HotSpot 可以根据方法的热度(调用频率)来动态选择 C1 或 C2 进行编译。较冷的方法由 C1 编译,较热的方法由 C2 编译,从而在启动速度和性能优化之间取得平衡。
JIT的触发条件
JIT 并不会一开始就对所有代码进行编译,而是通过热点探测来决定是否进行即时编译。
热点探测
即时编译器编译的目标是 “热点代码”,它主要分为以下两类:
- 被多次调用的方法。
- 被多次执行循环体。这里指的是一个方法只被少量调用过,但方法体内部存在循环次数较多的循环体,此时也认为是热点代码。但编译器编译的仍然是循环体所在的方法,而不会单独编译循环体。
判断某段代码是否是热点代码的行为称为 “热点探测” (Hot Spot Code Detection),主流的热点探测方法有以下两种:
- 基于采样的热点探测 (Sample Based Hot Spot Code Detection) :采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那么就认为它是 “热点方法”。
- 基于计数的热点探测 (Counter Based Hot Spot Code Detection) :采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是 “热点方法”
分层编译
要编译出优化程度越高的代码通常都需要越长的编译时间,为了在程序启动速度与运行效率之间达到最佳平衡,HotSpot 在编译子系统中加入了分层编译(Tiered Compilation):
- 第 0 层:程序纯解释执行,并且解释器不开启性能监控功能;
- 第 1 层:使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能;
- 第 2 层:仍然使用客户端编译执行,仅开启方法及回边次数统计等有限的性能监控;
- 第 3 层:仍然使用客户端编译执行,开启全部性能监控;
- 第 4 层:使用服务端编译器将字节码编译为本地代码,其耗时更长,并且会根据性能监控信息进行一些不可靠的激进优化。
以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。
面试题补充
什么是JVM中的AOT (Ahead-Of-Time,预编译)
Java 的 AOT(Ahead-Of-Time,预编译)是一种在程序运行之前,将 Java 字节码直接编译为本地机器码的技术。
JIT 是在 Java 运行时将一些代码编译成机器码,而 AOT 则是在代码运行之前就编译成机器码,也就是提前编译。
提前编译的好处是减少运行时编译的开销,且减少程序启动所需的编译时间,提高启动速度。
特点:
启动速度快: 由于编译在运行前已完成,程序启动时无需进行****即时编译,因此启动速度较快。
运行效率有差异: AOT 编译得到的是静态优化代码,而 JIT 可以基于运行时的动态分析进行优化,因此 AOT 的代码在长期运行中的效率可能不如 JIT。
什么是TLAB?
TLAB 全称为 Thread-Local Allocation Buffer,即线程本地分配缓冲区。 它是 JVM 中用于提高对象****分配效率的一种内存分配技术,属于 Eden 区 的一种内存分配机制。
在 Java 中,对象的分配通常发生在堆(Heap)中,堆是线程共享的。如果每个线程在分配对象时都直接操作堆,必然会面临并发冲突,导致加锁和性能开销。
TLAB 就是为了解决这个问题,通过为每个线程分配一小块独立的内存区域,实现快速对象分配。
编译执行和解释执行的区别是什么,JVM采用的是什么?
编译执行:是指代码在运行之前,将源代码一次性翻译为机器代码,生成可执行文件,然后直接运行。
- 优点:
- 运行速度快(无解释过程)
- 错误在编译时即可发现
- 代码经过静态优化,可以充分利用硬件特性
- 缺点:
- 跨平台困难: 不同平台需要重新编译。
- 灵活性不足: 修改代码后需要重新编译才能生效。
解释执行:指在程序运行时,通过解释器逐行读取、翻译和执行源代码,而不是一次性翻译为机器码。
- 优点:
- 高灵活性:修改代码后无需重新编译
- 跨平台性强:解释器适配不同平台
- 开发效率高:可以快速调试和测试
- 缺点:
- 运行速度慢:逐行解释造成了运行时开销
- 错误检测延迟:语法错误可能在运行时才暴露
JVM的执行模式:混合模式
结合了编译执行和解释执行的优点。
JVM 最初设计为解释型****虚拟机,逐行解释执行 Java 字节码。
后来为了提升性能,引入了 JIT(Just-In-Time****)编译,将热点代码转换为本地机器码,减少解释执行的开销。
JVM有哪几种情况会产生OOM(内存溢出)?
OOM 类型 | 异常信息 | 常见原因 | 解决方法 |
---|---|---|---|
堆空间溢出 | Java heap space |
对象过多、内存泄漏 | 调整堆大小、优化代码 |
GC开销超限 | GC overhead limit exceeded |
频繁 GC,回收率低 | 增大堆、减少垃圾对象 |
元空间溢出 | Metaspace |
类加载过多 | 增大元空间、减少动态加载 |
直接内存溢出 | Direct buffer memory |
NIO 分配内存不足 | 增大直接内存、手动释放 |
栈溢出 | StackOverflowError |
递归过深、栈空间小 | 增大栈、优化递归 |
无法创建新线程 | Unable to create new native thread |
线程数过多、系统内存不足 | 调整线程数、修改系统配置 |