目录
一.简单介绍JVM
二.什么是类加载器
三.Java的类加载步骤
四.双亲委派原则
五.JVM的内存划分
六.垃圾回收算法
1.标记-清除算法
2.复制算法
3.标记-整理算法
4.分代算法
七.垃圾回收器
八.什么是Full GC
九.ZGC
十.内存溢出和内存泄露
本专栏是博主收集的一些面试问题。
专栏:http://t.csdnimg.cn/iVera
一.简单介绍JVM
JVM是Java虚拟机,是Java程序运行的环境,就是
.java
源文件编译为.class
字节码文件并执行。这个过程分为2个阶段:
编译阶段(Compile Time):
- 当你编写 Java 源代码文件(
.java
文件)时,代码会经过 Java 编译器(例如javac
命令)进行编译。- Java 编译器将
.java
文件编译成字节码文件(.class
文件),这些文件包含了与原始代码功能等效的字节码指令。运行阶段(Runtime):
- 一旦编译完成,JVM 接管这些
.class
文件并执行其中的字节码。- JVM 是一个虚拟机,它负责将字节码解释成机器码并执行,或者通过即时编译(JIT,Just-In-Time Compilation)将字节码直接编译成本地机器代码,以提高执行效率。
优点:JVM 并不直接运行
.java
源文件,而是运行经过编译后的.class
字节码文件。这种分离的设计使得 Java 具有跨平台性
JVM的执行流程如下:
- 程序在执行之前先要把 Java 代码转换成字节码(class 文件),JVM 首先需要把字节码通过一定的方式类装载器(ClassLoader) 把文件加载到内存中运行时数据区(Runtime Data Area);
- 但字节码文件是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器,也就是 JVM 的执行引擎(Execution Engine)会将字节码翻译成底层系统指令再交由 CPU 去执行;
- 在执行的过程中,也需要调用其他语言的接口,如通过调用本地库接口
二.什么是类加载器
在上面,我们可以发现类装载器的作用:将文件加载在内存的运行数据区中。那么类加载器又是什么?
解答:在某些教材里,二者是同一个概念,只是名字不一样。负责加载类文件并生成对应的 Class 对象。
类加载器分为五种:
-
启动类加载器(Bootstrap Class Loader):
- 这是Java虚拟机的一部分,负责加载Java的核心类,如
java.lang
包中的类。它是虚拟机实现的一部分,不是Java语言本身的一部分。 - 由于是虚拟机的一部分,没有具体的Java对象来表示,通常不由Java代码直接调用。
- 这是Java虚拟机的一部分,负责加载Java的核心类,如
-
扩展类加载器(Extension Class Loader):
- 扩展类加载器是
sun.misc.Launcher$ExtClassLoader
的实例,负责加载Java扩展类库,即位于$JAVA_HOME/jre/lib/ext
目录中的类库和通过系统属性java.ext.dirs
指定的其他目录中的类库。
- 扩展类加载器是
-
应用程序类加载器(Application Class Loader):
- 应用程序类加载器是
sun.misc.Launcher$AppClassLoader
的实例,负责加载应用程序classpath上指定的类。这些类包括应用程序自己的类,以及应用程序依赖的第三方库的类。
- 应用程序类加载器是
-
自定义类加载器:
- 开发人员可以通过扩展
java.lang.ClassLoader
类来实现自定义的类加载器。自定义类加载器可以加载非标准位置的类文件,比如从数据库、网络等来源加载类。
- 开发人员可以通过扩展
-
平台类加载器(Platform Class Loader):
- 平台类加载器是Java 9引入的一种类加载器,用于加载模块路径中的类。它是应用程序类加载器的后继,负责加载模块系统中的类。
注:通常情况下,开发人员在编写Java应用程序时,主要会和应用程序类加载器打交道,因为应用程序类加载器负责加载应用程序的主要类和依赖库。
三.Java的类加载步骤
此处我们使用一个例子来具体表现出类加载的机制:
public class Example {static {System.out.println("Example class is initialized.");}public static void sayHello() {System.out.println("Hello from Example class!");}
}
1.加载(Loading):
- 类加载的第一个阶段是加载。在加载阶段,类加载器负责查找并加载类的二进制数据。加载阶段是通过类的全限定名来完成的。加载阶段完成后,JVM 将使用这些数据创建一个代表该类的
Class
对象。查找并加载:程序中的某处代码首次引用
Example
类时,比如调用Example.sayHello()
方法,Java虚拟机的类加载器就会开始工作。搜索并加载Example.class
文件的二进制数据。全限定名:一个类的完整名称,包括包名(package)在内。
创建 Class 对象:
Example
类的二进制数据被加载到内存中,Java虚拟机会使用这些数据来创建一个Class
对象
2.链接(Linking):
链接阶段包括三个步骤:验证(Verification)、准备(Preparation)、解析(Resolution)。
- 验证:确保加载的类符合Java语言规范,包括字节码格式、语义等方面的验证。
- 准备:为类的静态变量分配内存,并设置默认初始值。
- 解析:将常量池中的符号引用替换为直接引用,即将类、方法、字段的引用解析为实际的内存地址。
3.初始化(Initialization):
- 初始化阶段是类加载过程的最后一步。在初始化阶段,JVM会执行类构造器
<clinit>
方法的代码,这个方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的。- 类初始化时机包括:
- 创建类的实例
- 访问类的静态变量(除了常量)
- 调用类的静态方法
总结:执行类的初始化代码,包括静态变量赋值和静态代码块的执行。
四.双亲委派原则
Java 类加载器的一种工作机制。双亲委派模型是一种类加载器的委派机制,它要求除了顶层的启动类加载器(Bootstrap ClassLoader)之外,每一个类加载器在接到类加载请求时,都先将加载任务委托给它的父类加载器去完成。只有当父加载器无法找到对应的类时,子加载器才会尝试自己去加载。
优点:
- 避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了。
- 更安全:使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户自己提供的因此安全性就不能得到保证了。
五.JVM的内存划分
在之前,我们可以发现字节码在JVM运行数据区当中运行,那么如何分化的呢?例如全局变量、局部变量、方法、类等?
Java虚拟机规范》中将 JVM 运行时数据区域划分为以下 5 部分:
- 程序计数器(Program Counter Register):用于记录当前线程执行的字节码指令地址,是线程私有的,线程切换不会影响程序计数器的值。
- Java 虚拟机栈(Java Virtual Machine Stacks):用于存储方法执行时的局部变量表、操作数栈、动态链接、方法出口等信息,也是线程私有的。每个方法在执行时都会创建一个栈帧,栈帧包含了方法的局部变量表、操作数栈等信息。
- 本地方法栈(Native Method Stack):与 Java 虚拟机栈类似,用于存储本地方法的信息。为执行Native方法提供服务。例如:获取时间戳:System.currentTimeMillis()
- Java 堆(Java Heap):用于存储对象实例和数组,是 JVM 中最大的一块内存区域,它是所有线程共享的。堆通常被划分为年轻代和老年代,以支持垃圾回收机制。
- 年轻代(Young Generation):用于存放新创建的对象。年轻代又分为 Eden 区和两个 Survivor 区(通常是一个 From 区和一个 To 区),对象首先被分配在 Eden 区,经过垃圾回收后存活的对象会被移到 Survivor 区,经过多次回收后仍然存活的对象会晋升到老年代。
- 老年代(Old Generation):用于存放存活时间较长的对象。老年代主要存放长时间存活的对象或从年轻代晋升过来的对象。
- 方法区(Methed Area):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区也是所有线程共享的。
- 常量池:运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量和符号引用。
堆存储:对象的实例化和数组;
栈存储:基本数据类型的变量及对象的引用(即对象在堆中的地址)。
六.垃圾回收算法
垃圾收集:第一,先识别和标记死亡对象;第二,垃圾回收器使用合理的垃圾回收算法回收垃圾。
常见的垃圾回收算法:
1.标记-清除算法
早期的垃圾回收算法,由标记阶段和清除阶段构成的。标记阶段会给所有的存活对象做上标记,而清除阶段会把没有被标记的死亡对象进行回收。
缺点:产生内存空间的碎片化问题,也就是说标记-清除算法执行完成之后会产生大量的不连续内存。
2.复制算法
将内存分为大小相同的两块区域,每次只使用其中的一块区域,这样在进行垃圾回收时就可以直接将存活的东西复制到新的内存上,然后再把另一块内存全部清理掉。
缺点:因为需要将内存分为大小相同的两块内存,那么内存的实际可用量其实只有原来的一半,这样此算法导致了内存的可用率大幅降低了。
优点:执行效率高,没有内存碎片的问题。
3.标记-整理算法
由两个阶段组成的:标记阶段和整理阶段。其中标记阶段和标记-清除算法的标记阶段一样,不同的是后面的一个阶段,把所有存活的对象移动到内存的一端,然后把另一端的所有死亡对象全部清除。
优点:解决了内存碎片问题,比复制算法空间利用率高。
缺点:因为有局部对象移动,所以效率不是很高。
4.分代算法
分代算法并不能是某种具体的算法,而是一种策略,我们就姑且称它为分代算法吧。目前 HotSpot 虚拟机使用的就是此算法,在 HotSpot 虚拟机中将垃圾回收区域堆划分为两个模块:新生代和老生代。
为什么分两种呢?
答:对象分为两种,绝大多数对象都是朝生夕灭的,也就是用完一次之后就不用了,而剩下一小部分对象是要重复使用多次的,将不同的对象划分到不同的区域,不同的区域使用不同的算法进行垃圾回收,这样可以大大提高 Java 虚拟机的工作效率
新生代使用的是效率最高的复制算法;而老生代使用的是标记-清除或标记-整理算法
小结:标记-清除算法效率较高,但存在内存碎片;复制算法效率最高,也没有内存碎片,但内存利用率不高,而标记-整理算法效率不算高,但不存在内存碎片,并且不存在内存利用率的问题;而 HotSpot 在 JDK 8 之前使用的是分代算法(分代策略),将垃圾回收区域分为新生代和老生代,新生代采用复制算法,老生代采用标记-清除或标记-整理算法。
七.垃圾回收器
执行垃圾回收过程的组件,它负责扫描堆内存中的对象,标记出哪些对象是活动的(仍然被引用)和哪些对象是垃圾(不再被引用),然后清除未被引用的垃圾对象以释放内存空间。
常见的垃圾回收器包括:
- Serial收集器(Serial Garbage Collector):适用于单线程环境,采用简单、单线程的垃圾回收算法,主要用于客户端应用。
- ParNew:多线程的垃圾回收器(Serial 的多线程版本);
- Parallel收集器(Parallel Garbage Collector):吞吐量优先的垃圾回收器【JDK8 默认的垃圾回收器】
- CMS收集器(Concurrent Mark-Sweep Garbage Collector):通过减少垃圾回收时的停顿时间来优化,适合需要更短回收暂停时间的应用。
- G1收集器(Garbage-First Garbage Collector):基于区域化垃圾收集的方式,将堆内存划分为多个区域,以提供更可控的回收性能。
- ZGC:停顿时间超短(不超过 10ms)的情况下尽量提高垃圾回收吞吐量的垃圾收集器【JDK 15 之后默认的垃圾回收器】。
八.什么是Full GC
Full GC:Java虚拟机执行的一种完整的、全面的垃圾回收过程。对年轻代和老年代(以及永久代或元数据区)中的所有对象进行回收。
Full GC通常具有以下特征:
- 涉及整个堆内存:Full GC会清理整个Java堆中的所有区域,包括新生代和老年代,以及永久代(在JDK8之前)或元空间(在JDK8及以后)。
- 造成较长的停顿时间:由于全堆的垃圾回收需要扫描整个堆空间并清理不再使用的对象,因此它可能导致较长的暂停时间,影响应用程序的响应性能。
- 不频繁发生:由于其影响性能的停顿时间较长,Full GC通常会尽量减少触发次数,尽量通过局部垃圾回收(如Minor GC)来清理不再使用的对象
Full GC发生的情况:
- 显式触发:通过调用 System.gc() 方法显式触发垃圾回收。虽然调用该方法只是向 JVM 发出建议,但在某些情况下,JVM 可能会选择执行 Full GC。
- 老年代空间不足:当老年代空间不足时,无法进行对象的分配,会触发 Full GC。此时,Full GC 的目标是回收老年代中的无效对象,以释放空间供新的对象分配。
- 永久代或元数据区空间不足:在使用永久代(Java 8 之前)或元数据区(Java 8 及之后)存储类的元数据信息时,如果空间不足,会触发 Full GC。
九.ZGC
ZGC(Z Garbage Collector)是一种低延迟的垃圾回收器,是 JDK 11 引入的一项垃圾回收技术。它主要针对大内存、多核心的应用场景,旨在减少垃圾回收带来的停顿时间。
核心技术:
- 并发标记:ZGC 采用增量式并发标记算法来实现并发垃圾回收。它不会在标记阶段产生长时间停顿,可以与用户线程并发运行。
- 粉碎压缩:ZGC 采用粉碎压缩算法来避免产生内存碎片。它会将内存按照特定大小(例如 2MB)分为多个区域,然后将存活对象连续放置在相邻区域,释放掉边界外的内存空间。这可以最大限度减少内存碎片。
- 直接内存映射:ZGC 会直接映射内存空间,而不需要进行内存分配。这可以避免统计堆内存碎片情况所带来的性能消耗。
- 微任务:ZGC 采用了微任务(Microtasks)机制来增量完成垃圾回收工作,从而不会产生长时间停顿。它会将总工作分割为多个微任务,这些微任务会在安全点(Safepoint)之间执行。
- 可扩展的堆内存:ZGC 不需要指定最小堆(Xmn)和最大堆(Xmx)大小,它可以跟踪堆内存变化并根据需要动态调整堆空间大小。这使得 ZGC 可以支持将近 4TB 的堆内存。
- 可插拔组件:ZGC 是一个独立的 GC 组件,它不依赖于 Gradle 等构建工具,可以与不同的工具或框架一起使用,这增强了其可移植性。
十.内存溢出和内存泄露
内存溢出:在程序运行过程中申请的内存超出了可用内存资源的情况,导致无法继续分配所需的内存,从而引发异常。
内存泄露:在程序中无意中保留了不再需要的对象引用,导致这些对象无法被垃圾回收机制回收,进而占用了不必要的内存空间。
注:内存泄露会导致内存溢出的情况!
解决方案:
- 对于内存溢出,可以通过增加可用内存、调整程序逻辑、优化资源使用等方式来解决。
- 而对于内存泄漏,需要通过检查和修复对象引用管理问题,确保不再使用的对象能够被垃圾回收机制正确释放。