八股文笔记 #4 Java JVM
内存模型
1# 介绍一下 JVM 的内存模型
在 JDK 8 中,JVM 的运行时内存主要分为以下几个部分:程序计数器、虚拟机栈、本地方法栈、堆、方法区(元空间),以及不属于规范规定但常用的直接内存。
- 程序计数器:可以看作是当前线程所执行的字节码的行号指示器,用于存储当前线程正在执行的 Java 方法的 JVM 指令地址。如果线程执行的是 Native 方法,计数器值为 null。是唯一一个在 Java 虚拟机规范中没有规定任何
OutOfMemoryError
情况的区域,生命周期与线程相同。 - Java 虚拟机栈:每个线程都有自己独立的 Java 虚拟机栈,生命周期与线程相同。每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。可能会抛出
StackOverflowError
和OutOfMemoryError
异常。 - 本地方法栈:与 Java 虚拟机栈类似,主要为虚拟机使用到的 Native 方法服务,在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。本地方法执行时也会创建栈帧,同样可能出现
StackOverflowError
和OutOfMemoryError
两种错误。 - Java 堆:是 JVM 中最大的一块内存区域,被所有线程共享,在虚拟机启动时创建,用于存放对象实例。从内存回收角度,堆被划分为新生代和老年代,新生代又分为 Eden 区和两个 Survivor 区(From Survivor 和 To Survivor)。如果在堆中没有内存完成实例分配,并且堆也无法扩展时会抛出
OutOfMemoryError
异常。 - 方法区(元空间):在 JDK 1.8 及以后的版本中,方法区被元空间取代,使用本地内存。用于存储已被虚拟机加载的类信息、常量、静态变量等数据。虽然方法区被描述为堆的逻辑部分,但有 “非堆” 的别名。方法区可以选择不实现垃圾收集,内存不足时会抛出
OutOfMemoryError
异常。 - 运行时常量池:是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,具有动态性,运行时也可将新的常量放入池中。当无法申请到足够内存时,会抛出
OutOfMemoryError
异常。 - 直接内存:不属于 JVM 运行时数据区的一部分,通过 NIO 类引入,是一种堆外内存,可以显著提高 I/O 性能。直接内存的使用受到本机总内存的限制,若分配不当,可能导致
OutOfMemoryError
异常。
类初始化和类加载
1# 创建对象的过程?
在Java中创建对象的过程包括以下几个步骤:
- 类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
- 分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
- 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
- 进行必要设置,比如对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- 执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始——构造函数,即class文件中的方法还没有执行,所有的字段都还为零,对象需要的其他资源和状态信息还没有按照预定的意图构造好。所以一般来说,执行 new 指令之后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全被构造出来。
2# 讲一下类加载过程?
类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括以下 7 个阶段:
加载:通过类的全限定名(包名 + 类名),获取到该类的.class文件的二进制字节流,将二进制字节流所代表的静态存储结构,转化为方法区运行时的数据结构,在内存中生成一个代表该类的 Java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
连接
:验证、准备、解析 3 个阶段统称为连接。
- 验证:确保class文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class类的正确性,不会危害到虚拟机的安全。验证阶段大致会完成以下四个阶段的检验动作:文件格式校验、元数据验证、字节码验证、符号引用验证
- 准备:为类中的静态字段分配内存,并设置默认的初始值,比如int类型初始值是0。被final修饰的static字段不会设置,因为final在编译的时候就分配了
- 解析:解析阶段是虚拟机将常量池的「符号引用」直接替换为「直接引用」的过程。符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候可以无歧义地定位到目标即可。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用, 那引用的目标必定已经存在在内存中了。
初始化:初始化是整个类加载过程的最后一个阶段,初始化阶段简单来说就是执行类的构造器方法(() ),要注意的是这里的构造器方法()并不是开发者写的,而是编译器自动生成的。
使用:使用类或者创建对象
卸载:一个类要被JVM卸载,条件非常苛刻,
需要同时满足以下三点:
- 该类所有的实例都已经被回收:这是最显而易见的前提。如果堆中还存在这个类的任何一个实例对象,那么定义这个对象的Class对象肯定不能被卸载。
- 加载该类的 ClassLoader 已经被回收:这是最关键也是最难满足的条件。类与其加载器是双向绑定的共生关系。一个类由哪个类加载器加载,这个信息是存储在Class对象里的。要卸载一个类,必须先卸载加载它的类加载器。
- 类对应的 Java.lang.Class 对象没有任何地方被引用:不能在任何地方通过反射(如静态字段、全局变量)、静态变量、JNI等途径引用到这个Class对象。一旦这个Class对象还存在强引用,GC就不会回收它,那么这个类也就不会被卸载。
垃圾回收
1# 垃圾回收是什么?如何触发垃圾回收?
垃圾回收是 JVM 提供的一种 自动内存管理机制。它会自动识别并释放程序中 不再被引用的对象 所占用的堆内存,从而避免内存泄漏和手动管理内存带来的错误。
换句话说,GC 的作用就是让开发者不用像 C/C++ 那样手动释放内存,而是把内存清理工作交给 JVM 来完成。
垃圾回收的触发时机
- 内存不足:当 JVM 检测到堆空间不足以分配新对象时,会自动触发垃圾回收。
- 手动请求:开发者可以调用
System.gc()
或Runtime.getRuntime().gc()
来“建议”JVM 执行垃圾回收。但这只是建议,是否立即执行取决于 JVM。 - JVM 参数控制:运行应用时可以通过参数(如
-Xmx
设置最大堆大小,-Xms
设置初始堆大小)来影响垃圾回收的频率和行为。 - 内部策略:垃圾收集器内部会根据对象创建速率、内存使用情况等设定阈值,当条件达到时触发回收。
如何判断对象是垃圾?
JVM 需要判断哪些对象已经“不可达”,从而可以被回收。常见的两种方法是:
- 引用计数法(Reference Counting)
- 原理:为对象维护一个引用计数器,每当有一个引用指向它时计数 +1,引用失效时计数 -1。当计数变为 0 时,对象被认为不再可用,可以回收。
- 缺点:无法处理循环引用。例如对象 A 引用 B,B 又引用 A,但它们已经不再被其他地方使用时,引用计数仍大于 0,导致无法被回收。
- 可达性分析算法(Reachability Analysis)
这是 Java 虚拟机主要采用的垃圾判断方式。
- 原理:从一组被称为 GC Roots 的对象出发,沿着引用链向下搜索,能被 GC Roots 直接或间接关联到的对象都是“存活的”。如果某个对象和 GC Roots 之间没有任何引用链相连,则被视为“不可达”,可以被回收。
- GC Roots 包括:
- 虚拟机栈(栈帧中本地变量表)中的引用对象
- 方法区中类的静态属性引用的对象
- 本地方法栈中 JNI(Native 方法)引用的对象
- 活跃线程对象
八股文笔记 #2 Java 集合
八股文笔记 #1 Java 基础
再好的项目,也敌不过 HashMap 的 resize 过程没讲清楚