初步理解 JVM:类加载机制、内存结构与核心运行原理
Java 之所以能够“一次编写到处运行”核心原因之一就是 JVM。Java 源代码经过编译后生成 .class 字节码文件JVM 负责加载字节码、解释或编译执行、管理内存、执行垃圾回收并提供线程、安全、异常等运行时能力。理解 JVM是为了在实际开发中解决诸如类冲突、类找不到、版本冲突内存溢出、内存泄漏GC 频繁、接口抖动线程安全、可见性问题应用启动慢、运行慢、吞吐低本文将从以下几个方面系统介绍 JVMJVM 类加载机制双亲委派模型JVM 运行时内存结构Java 对象创建过程垃圾回收机制Java 内存模型 JMMJVM 常见参数与问题排查JVM 重要知识点总结一、JVM 整体工作流程其中类加载器负责把 .class 文件加载进 JVM运行时数据区负责存储程序运行时需要的数据执行引擎负责执行字节码垃圾回收器负责自动管理内存。二、JVM 类加载机制1. 什么是类加载类加载指的是 JVM 把 .class 字节码文件加载到内存中并对其进行校验、转换、初始化使其最终成为 JVM 可以直接使用的 Java 类型。类加载的完整生命周期如下加载 - 验证 - 准备 - 解析 - 初始化其中验证、准备、解析统称为连接 Linking。2. 加载阶段 Loading加载阶段主要完成三件事通过类的全限定名获取该类的二进制字节流。将字节流中的静态存储结构转换为方法区中的运行时数据结构。在堆中生成一个 java.lang.Class 对象作为访问这个类的入口。例如Class? clazz User.class;这里的 clazz 就是 JVM 为 User 类生成的 Class 对象。需要注意的是.class 字节流不一定来自本地文件也可以来自jar 包网络动态代理生成运行时动态编译自定义类加载器生成这也是 Java 能够支持热部署、插件化、动态代理等能力的重要基础。3. 验证阶段 Verification验证阶段的目标是确保字节码文件是安全、合法、符合 JVM 规范的。主要包括文件格式验证元数据验证字节码验证符号引用验证例如JVM 会检查.class 文件魔数是否正确版本号是否被当前 JVM 支持是否继承了不允许继承的类方法调用是否合法操作数栈使用是否正确验证阶段非常重要因为 JVM 不能信任外部传入的字节码。即使不是由 Java 编译器生成的字节码只要符合 JVM 规范也可以被执行。4. 准备阶段 Preparation准备阶段会为类变量也就是 static 变量分配内存并设置默认初始值。例如public class User { public static int age 18; }在准备阶段age 的值不是 18而是默认值 0。真正赋值为 18发生在初始化阶段。再看一个例子public class User { public static int age 18; public static final int count 10; }对于 static final 修饰的编译期常量count 可能在准备阶段就被赋值为 10。常见默认值如下5. 解析阶段 Resolution解析阶段的作用是将常量池中的符号引用转换为直接引用。什么是符号引用可以简单理解为“用名字描述目标”。6. 初始化阶段 Initialization初始化阶段才是真正执行 Java 代码的阶段。JVM 会执行类构造器方法 clinit()它由以下内容合并生成静态变量显式赋值静态代码块public class User { static int age 18; static { age 20; } }执行初始化后age 的最终值是 20。clinit() 方法由编译器自动生成程序员不能直接编写。三、类加载器与双亲委派模型1. 类加载器分类JVM 中常见类加载器如下Bootstrap ClassLoaderExtension ClassLoaderApplication ClassLoader在 Java 9 之后模块化机制引入后Extension ClassLoader 被 Platform ClassLoader 替代。2. Bootstrap ClassLoader启动类加载器负责加载 Java 核心类库例如它通常由 C/C 实现不是普通 Java 类。所以执行System.out.println(String.class.getClassLoader());可能输出null这个 null 并不是没有类加载器而是表示它由 Bootstrap ClassLoader 加载3. Platform ClassLoader / Extension ClassLoaderJava 8 中叫 Extension ClassLoader负责加载扩展类库。Java 9 之后叫 Platform ClassLoader负责加载平台相关模块。4. Application ClassLoader应用类加载器负责加载 classpath 下的业务类。我们自己写的大多数 Java 类默认都是由 Application ClassLoader 加载的。System.out.println(User.class.getClassLoader());通常会输出类似jdk.internal.loader.ClassLoaders$AppClassLoader5. 双亲委派模型双亲委派模型的核心思想是一个类加载器收到类加载请求后不会立即自己加载而是先委托给父类加载器。只有父类加载器无法加载时子类加载器才会尝试自己加载。流程如下6. 为什么需要双亲委派双亲委派主要有两个好处第一保证核心类库安全假如我们自己写一个类package java.lang; public class String { }如果没有双亲委派那么这个伪造的 String 类可能会替代 JDK 自带的 String 类造成严重安全问题。有了双亲委派后java.lang.String 会优先由 Bootstrap ClassLoader 加载应用程序无法随意替换核心类库。第二避免类重复加载同一个类如果被多个类加载器重复加载就会在 JVM 中形成多个不同的类型。JVM 判断两个类是否相同不仅看类的全限定名还看加载它的类加载器。也就是说类唯一性 类全限定名 类加载器四、JVM 运行时内存结构JVM 运行时数据区是 JVM 管理内存的核心区域。按照线程是否共享可以分为两类线程共享- 堆- 方法区线程私有- 程序计数器- Java 虚拟机栈- 本地方法栈1. 程序计数器程序计数器是一块很小的内存区域用来记录当前线程正在执行的字节码指令地址。它有两个特点线程私有。是 JVM 规范中唯一一个不会出现 OutOfMemoryError 的区域。为什么需要程序计数器因为 Java 是多线程的线程之间会频繁切换。线程切换回来后JVM 需要知道这个线程上次执行到哪里了。2. Java 虚拟机栈Java 虚拟机栈也是线程私有的它描述的是 Java 方法执行的内存模型。每个方法执行时JVM 会创建一个栈帧。一个栈帧主要包含局部变量表操作数栈动态链接方法返回地址方法调用过程可以理解为栈帧入栈和出栈常见异常StackOverflowErrorOutOfMemoryError3. 本地方法栈本地方法栈服务于 Native 方法也就是使用 native 关键字修饰的方法。例如public native int hashCode();本地方法通常由 C/C 实现。4. 堆 Heap堆是 JVM 中最大的一块内存区域用于存放对象实例和数组。几乎所有对象都在堆上分配。堆是线程共享的也是垃圾回收器重点管理的区域。常见异常java.lang.OutOfMemoryError: Java heap space常见异常 java.lang.OutOfMemoryError: Java heap space堆通常可以进一步分为对象通常先分配在 Eden 区经过多次 Minor GC 后仍然存活的对象会进入老年代。5. 方法区 Method Area方法区用于存储类相关信息例如类元信息常量静态变量即时编译后的代码缓存在不同 JDK 版本中方法区的具体实现不同元空间使用的是本地内存而不是 JVM 堆内存。常见异常java.lang.OutOfMemoryError: Metaspace五、Java 对象创建过程当我们执行User user new User();JVM 并不是简单地“创建一个对象”而是经历了多个步骤。1. 类加载检查JVM 首先检查 User 类是否已经被加载、解析、初始化。如果没有就先执行类加载过程。2. 分配内存类加载完成后JVM 会根据类的信息确定对象需要的内存大小然后在堆中分配内存。3. 初始化零值内存分配完成后JVM 会把对象的字段设置为默认零值。例如int - 0 boolean - false 引用类型 - null4. 设置对象头对象头中会存储一些运行时信息例如哈希码GC 分代年龄锁状态标志类型指针对象头是 synchronized 锁升级、GC 判断对象年龄等机制的基础。5. 执行构造方法最后执行对象的构造方法也就是 init() 方法。此时对象才真正按照我们代码中的逻辑完成初始化。六、垃圾回收机制 GCJava 程序员不需要手动释放对象内存因为 JVM 提供了自动垃圾回收机制。但自动并不等于不用理解。线上很多性能问题本质都和 GC 有关。1. 如何判断对象是否可以回收常见有两种思路引用计数法给对象维护一个引用计数器。有引用指向它计数加一引用失效计数减一。计数为零说明可以回收。缺点是无法解决循环引用问题。a.ref b; b.ref a;即使 a 和 b 已经不再被外部访问它们的引用计数仍然不为零。因此主流 JVM 不使用单纯的引用计数法判断对象是否存活。可达性分析算法JVM 主要使用可达性分析算法。它从一组称为 GC Roots 的对象出发向下搜索引用链。如果一个对象到 GC Roots 没有任何引用链相连就说明它不可达可以被回收。常见 GC Roots 包括虚拟机栈中引用的对象方法区中静态变量引用的对象方法区中常量引用的对象本地方法栈中 JNI 引用的对象正在运行的线程对象被 synchronized 锁持有的对象2. Java 引用类型强引用最常见的引用。User user new User();只要强引用存在对象就不会被回收。软引用内存不足时才会被回收。适合做缓存。SoftReferenceUser ref new SoftReference(new User());弱引用只要发生 GC就可能被回收。WeakReferenceUser ref new WeakReference(new User());ThreadLocalMap 中的 key 就是弱引用。3. 常见垃圾回收算法标记-清除先标记存活对象再清除未标记对象。缺点会产生内存碎片清理效率不稳定标记-复制把内存分为两块每次只使用一块。GC 时把存活对象复制到另一块然后清空原区域。优点简单高效没有内存碎片缺点浪费一部分内存新生代的 Survivor 区常用类似思路。标记-整理先标记存活对象然后把存活对象向一端移动再清理边界外的内存。优点没有内存碎片适合老年代。分代收集JVM 根据对象存活时间将堆分为新生代和老年代。大多数对象朝生夕死因此新生代 GC 比较频繁长期存活的对象进入老年代老年代 GC 频率较低。4. 常见垃圾回收器不同 JDK 版本默认 GC 有差异。常见垃圾回收器包括5. Minor GC、Major GC、Full GCMinor GC发生在新生代频率较高速度通常较快。Major GC一般指老年代 GC不同资料中定义可能略有差异。Full GC回收整个 Java 堆和方法区成本较高可能造成较长时间停顿。线上系统应尽量避免频繁 Full GC。七、JVM 常见问题与排查思路1. StackOverflowError常见原因无限递归方法调用链过深单个线程栈空间太小示例public void recursion() {recursion();}可以通过 -Xss 调整线程栈大小-Xss1m2. Java heap space表示堆内存不足。常见原因创建大量对象集合无限增长缓存没有淘汰策略内存泄漏相关参数-Xms 初始堆大小-Xmx 最大堆大小示例-Xms512m-Xmx512m3. Metaspace OOM常见原因动态生成大量类频繁部署导致类加载器无法卸载CGLIB、动态代理滥用相关参数-XX:MetaspaceSize128m-XX:MaxMetaspaceSize256m4. GC 频繁可能原因堆设置过小对象创建速度太快老年代空间不足内存泄漏大对象频繁分配排查工具VisualVMArthas