ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JVM类加载子系统与运行时内存结构全解析

JVM类加载子系统与运行时内存结构全解析 1. 类加载子系统JVM 的前置处理器1.1 类加载到底做了什么很多人学 JVM 第一个接触的就是类加载子系统这六个字但真正被问到时能讲清楚的不多。我先说个结论类加载子系统的作用就是把编译好的 .class 字节码文件从磁盘或者网络、内存等任意源头读取进来经过一串处理之后变成 JVM 能直接使用的运行时结构。它不是一个加载完就结束的模块而是从字节码到对象实例之间那条必经之路。我之前带过几个新人经常有这种场景Java 代码明明写得没问题编译也过了一运行报 ClassNotFoundException第一反应是去检查依赖结果查了半天发现是类加载器层级不对。这种问题如果不理解类加载子系统的运作方式排查起来会非常痛苦因为你看到的错误信息和真正的根源往往隔着好几层。类加载机制包含三个大的阶段加载、连接、初始化。连接阶段内部又细分为验证、准备、解析三步。面试里追问的什么双亲委派全盘负责缓存机制全都是在这个流程上衍生出来的规则。这套机制的设计初衷很朴素让 JVM 以统一、安全、可靠的方式把类变成可执行状态同时保证核心类库不被篡改。什么场景下需要深入理解类加载子系统我列几个常见的排查 ClassNotFoundException、NoClassDefFoundError、LinkageError 这类 JVM 层报错做应用容器比如 Tomcat 这类 Web 容器的类加载隔离热部署、插件化框架、动态代理等运行时生成和加载类的场景理解 JVM 调优时为什么 PermGen/Metaspace 会满面试这个最现实后面会专门讲简单说类加载子系统决定了你的类从哪来、怎么验证、怎么初始化而内存结构决定了类进来之后住哪儿、怎么分区管理。两者一前一后是理解 JVM 底层行为的两块基石。1.2 三个默认类加载器之间的辈分关系JVM 自带了三个类加载器层级非常清晰我习惯用一个家庭类比来理解启动类加载器Bootstrap ClassLoader祖辈。负责加载 JDK 核心类库比如$JAVA_HOME/jre/libJDK 8或$JAVA_HOME/jmodsJDK 9 以后中的核心类像 java.lang、java.util、java.io 这些。它比较特殊不是 Java 类而是由 C 实现的在 Java 代码里拿到它的引用会得到 null。平台类加载器Platform ClassLoader父辈。JDK 8 里叫扩展类加载器Extension ClassLoader负责加载一些扩展目录下的类。JDK 9 模块化之后改名为平台类加载器加载一些 JDK 内部的模块类。应用程序类加载器Application ClassLoader我们这一辈。也叫系统类加载器负责加载 classpath也就是我们日常配的 CLASSPATH 环境变量或工程依赖路径下的所有类。我们写的业务代码绝大多数由它加载。这里有一个在 JDK 9 前后的重要变化老的扩展类加载器被移除了取而代之的是平台类加载器并且加载逻辑从基于目录变成了基于模块。新手如果还在网上看 JDK 8 时代的老资料会看到启动、扩展、应用这种说法但在 JDK 11、JDK 17 上跑看到的实际对象名是不同的。这个细节在面试里是一个非常容易暴露基础是否扎实的点。有一个非常直观的验证方法。写一段代码打印当前类的类加载器链public class ClassLoaderDemo { public static void main(String[] args) { ClassLoader app ClassLoaderDemo.class.getClassLoader(); System.out.println(Application ClassLoader: app); System.out.println(Parent: app.getParent()); System.out.println(Grand Parent: app.getParent().getParent()); } }JDK 8 下运行输出大概是应用类加载器是 sun.misc.Launcher$AppClassLoader父级是 sun.misc.Launcher$ExtClassLoader祖父级是 null因为启动类加载器是 C 写的Java 层拿不到引用。JDK 11 下运行父级就变成了 jdk.internal.loader.ClassLoaders$PlatformClassLoader。一行代码两个 JDK 版本输出差异直接反映出 JVM 版本的演进这个细节建议自己跑一遍记住。1.3 双亲委派模型为什么要先让爹来双亲委派模型是类加载子系统最重要的规则简单讲就一句话当一个类加载器收到类加载请求时它不会自己去加载而是先把请求委派给父加载器逐层向上直到启动类加载器只有当父加载器反馈自己无法加载时子加载器才会尝试自己加载。注意我上面说的是父加载器这里的父子关系不是继承关系而是组合关系通过 ClassLoader 对象里的 parent 字段维护。所以正确的说法是父加载器不是父类。为什么这么设计核心就四个字避免重复。假设我们自己写了一个 java.lang.String如果不用双亲委派应用类加载器直接自己加载了那 JVM 里就会出现两份 String一份是 JDK 自带的一份是自定义的。程序里引用 String 到底用哪个完全没有一致性整个类型体系就崩了。再往深一层说双亲委派还有一个安全上的作用。沙箱安全机制依赖于核心类必须由启动类加载器加载。如果在 JDK 类库中注册一个恶意类由启动类加载器加载后就会获得核心库的信任级别。双亲委派保证了核心 API 类不会被应用层覆盖这也是为什么即使你写了一个 java.lang.Integer在正常的双亲委派机制下也永远不会被加载。这里我给一个在实际排查中最常见的误区很多初学者以为双亲委派会让代码层层向上查找所以加载会很慢。实际上每一级的加载器通常都会先检查自己已经加载过的类缓存命中就直接返回根本没到找文件这一步。JVM 对同一个类加载器实例加载过的类是有缓存记录的下一次请求同一个类直接拿缓存不会再走一遍完整流程。2. 从字节码到可用实例类加载五阶段拆解2.1 加载阶段字节流从哪里来加载阶段是整个类加载过程的第一步做三件事通过一个类的全限定名获取定义此类的二进制字节流将字节流所代表的静态存储结构转化为方法区的运行时数据结构在内存中生成一个代表这个类的 java.lang.Class 对象作为方法区这个类的各种数据的访问入口。这里第一件事获取二进制字节流就没规定死来源。我们日常最常见的是文件系统里的 .class 文件但 JVM 规范允许从任何地方读取比如 ZIP/JAR 包这就是大部分框架和应用服务器依赖的形态、网络流Applet 时代的典型用法、运行时动态生成Spring 的 CGLIB、JDK 动态代理、数据库读取、加密文件读取等。这个开放性非常重要。很多框架就是靠自定义类加载器改变字节流来源来实现各种高级特性的。比如热部署插件每次更新都重新创建一个类加载器指向新的 JAR 文件路径旧的类加载器连同它加载过的类就慢慢被回收掉。从这个角度看加载阶段是整个类加载过程中最开放的一步也是框架玩出花样的地方。加载阶段完成后类的字节流已经变成 JVM 内部的数据结构存放在方法区或者叫 Metaspace 中对应的结构同时生成了一个 Class 对象。注意这个 Class 对象存放在堆里和我们 new 出来的普通对象一样是 Java 对象。所以一个类被加载之后可以简单理解为方法区放的是类型信息堆里放的是Class 对象。2.2 验证与准备安全检查和内存分配验证阶段说白了就是 JVM 在收货时做的一次安全检查。字节码本身是二进制数据如果不检查就执行恶意构造的字节码可以直接让 JVM 崩溃或者绕过安全限制。验证包括文件格式验证、元数据验证、字节码验证、符号引用验证四个方面。文件格式验证看的是魔数是不是 0xCAFEBABE、版本号是否支持元数据验证看的是类是否有父类、是否继承了被 final 修饰的类、抽象方法是否都实现了字节码验证是整个验证阶段最复杂的部分通过数据流分析和控制流分析确定语义合法符号引用验证发生在解析阶段看引用的类、字段、方法是否真的存在、是否有权限访问。验证阶段在实际排错中的意义绝大多数 ClassFormatError、VerifyError 都是这个阶段抛出来的。我见过一个项目打包时没有执行 clean旧的 class 文件和新的源码混在一起运行后出现 VerifyError排查了好久最后发现是字节码版本不匹配。所以建议编译时顺手开启-Xverify:all虽然会拖慢启动速度但排查复杂问题的时候能帮你早点暴露问题。过了验证之后是准备阶段这个阶段是为类变量static 修饰的变量分配内存并设置初始值。这里有两个关键点需要反复强调第一准备阶段分配的是类变量不是实例变量。实例变量要等到对象实例化时才会在堆里分配。第二初始值一般是零值不是代码里写的那个值。比如private static int count 100;在准备阶段 count 的值是 0等到初始化阶段调用clinit方法时才会赋成 100。但有个例外如果类变量是常量final 修饰编译阶段就会生成 ConstantValue 属性准备阶段直接赋成指定值跳过零值这一步。2.3 解析与初始化符号引用变成直接引用解析阶段是 JVM 将常量池内的符号引用替换为直接引用的过程。这里两个名词稍微解释一下符号引用是一组字面量比如一个类的全限定名、字段名和描述符、方法名和描述符它没有指向实际内存地址直接引用则是可以直接指向目标的引用比如指向方法区的指针、偏移量或句柄。有个比较关键的点是解析阶段不一定要在初始化之前完成。JVM 规范允许在某些情况下延迟解析比如一个方法里引用到的类可以在方法第一次被调用时才去解析。这就是为什么有些时候加载一个类并不会把它的所有依赖都递归加载进来只有当真正用到某个符号引用的时候才会触发解析和后续的链接动作。初始化阶段是整个类加载过程的最后一步真正开始执行类中定义的 Java 代码。这一步的核心是执行clinit方法也就是类构造器方法。clinit方法由编译器自动生成收集类中所有类变量的赋值动作和静态代码块中的语句合并而成收集顺序和在源代码中出现的顺序一致。有几个关于clinit的细节值得关注clinit和实例构造器init不同它不需要显式调用父类的clinitJVM 会保证父类的clinit在子类之前执行如果一个类没有静态变量赋值也没有静态代码块编译器就不会生成clinit方法接口和类的clinit执行时机有一点不同接口在初始化时并不要求父接口先完成初始化只有真正使用到父接口里面定义的默认方法时才会触发虚拟机会保证一个类的clinit方法在多线程环境下被正确加锁和同步什么情况下会触发类的初始化JVM 规范里有一套明确的主动引用场景最常见的有这么几种遇到 new、getstatic、putstatic、invokestatic 字节码指令时使用 java.lang.reflect 对类进行反射调用时初始化子类时父类还没初始化则先初始化父类JVM 启动时初始化包含 main 方法的主类。这些是面试常考点建议背下来。2.4 一个实际案例从加载到实例化的完整链路我拿一个最常见的最小例子来串一下整个流程。假设有这样一个类public class Student { private static String SCHOOL_NAME 第一中学; static { System.out.println(Student static block invoked); } public String getName() { return Tom; } }当 main 方法里执行Student stu new Student()时JVM 做了什么检查方法区中是否存在 Student 类的类型信息。如果不存在触发类加载流程应用类加载器收到加载请求先检查自己缓存没找到委派给父加载器一路委派到启动类加载器启动类加载器在核心类库中找不到 Student逐层返回应用类加载器在自己的加载路径下找到 Student.class执行加载加载完成后进入验证、准备阶段。准备阶段为 SCHOOL_NAME 分配内存并设为零值解析阶段处理 Student 类引用到的其他类的符号引用初始化阶段执行clinitSCHOOL_NAME 被赋值为第一中学打印 Student static block invokednew指令在堆中分配内存执行init方法创建实例这个过程每一步都有对应的可观测手段。比如启动时加-XX:TraceClassLoading可以打印所有类加载的信息-verbose:class也是类似效果。实测中当你想确认一个类到底是由哪个加载器加载的、是否被加载过用这些 JVM 参数直接看输出是最高效的方式。3. 运行时内存结构类的落地空间3.1 线程私有区域程序计数器与虚拟机栈说完了类的加载接下来看类加载完成后住在哪。JVM 的内存结构我习惯把它分成两拨线程私有的和线程共享的。线程私有的区域随线程的创建而创建、随线程的消亡而回收不需要垃圾回收器操心。程序计数器Program Counter Register也叫 PC 寄存器是一块很小的内存空间可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令的。这里有一个有意思的点如果是执行 native 方法程序计数器的值是空Undefined因为 native 方法不是 Java 字节码无法通过解释器按行执行。这也是 Java 虚拟机规范里唯一一个没有规定 OutOfMemoryError 情况的区域因为它不涉及内存动态分配。虚拟机栈Java Virtual Machine Stack描述的是 Java 方法执行的线程内存模型每个方法被执行的时候JVM 会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法从调用到执行完成的过程就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。局部变量表是最常被问到的部分它存放了方法参数和方法内部定义的局部变量。容量以变量槽Slot为最小单位long 和 double 类型需要占用两个连续的槽位其余类型占一个槽位。注意局部变量表在准备阶段就会被分配好空间即使代码里某个变量作用域已经结束了只要栈帧还在这些槽位就一直被占用——这也是为什么在一个大方法里如果不再使用的大对象还活着引用着就可能造成不必要的内存滞留。最常见的溢出异常是 StackOverflowError当线程请求的栈深度大于虚拟机允许的最大深度时就会抛出。我遇到过一个真实的案例一个递归方法没有写终止条件线上日志里出现大量 StackOverflowError导致整个服务不可用。排查方式其实很简单异常堆栈会直接打出栈深度和溢出的线程看堆栈信息基本就能定位到是哪个方法的递归调用出问题。另外注意虚拟机栈在动态扩展时如果无法申请到足够内存也会抛 OutOfMemoryError但这通常发生在极端场景。3.2 线程共享区域堆和方法区堆Heap是 JVM 内存管理的核心区域也是垃圾收集器的主要工作区域几乎所有对象实例和数组都在这里分配。堆在物理上可以是不连续的内存空间逻辑上连续即可。随着 JVM 的发展堆的划分越来越精细新生代Young Generation和老年代Old Generation新生代里又分 Eden 区、From Survivor、To Survivor。不同对象的分配策略不同。绝大多数小对象直接在 Eden 区分配大对象比如超长数组可能直接进入老年代经过多轮 Minor GCMinor GC 是针对新生代的垃圾回收仍然存活的对象会晋升到老年代。这些策略都是可以通过 JVM 参数调整的实际调优的时候会根据应用特性调整新生代大小的比例和晋升阈值。针对堆内存的调优参数最常用的几组-Xms堆初始大小-Xmx堆最大大小-Xmn新生代大小-XX:SurvivorRatioEden 区和 Survivor 区的比例默认 8:1:1-XX:MaxTenuringThreshold对象晋升老年代的年龄阈值方法区Method Area在 JDK 8 之前由永久代PermGen实现JDK 8 开始改为元空间Metaspace。方法区存放的是类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。很多人容易混淆方法区和堆简单理解堆里放的是对象方法区里放的是类本身的结构信息。JDK 8 为什么要把永久代改成元空间几个原因永久代的大小很难确定满了就抛 OutOfMemoryError: PermGen space而且永久代大小还要受限于堆内存永久代在垃圾收集上和年轻代、老年代耦合容易导致 Full GC 性能问题。改成元空间后元空间使用本地内存默认情况下只受系统可用内存限制大小设置更灵活也让永久代的调优变得相对简单。这里要给一个我曾经踩过的坑Metaspace 虽然默认不受限制但生产环境一定要配置-XX:MaxMetaspaceSize不然一旦代码中有动态生成类或者大量反射类Metaspace 会被撑爆最终导致整台机器内存耗尽而不是优雅地抛出 OutOfMemoryError。3.3 运行时常量池与直接内存运行时常量池是方法区的一部分存放编译期间生成的各种字面量与符号引用。每一个类加载到 JVM 后它的运行时常量池就占据了方法区的一块内存。这里有一个很重要的细节Java 7 以后运行时常量池中的字符串常量池被移到了堆中。也就是说字符串字面量被解析后实际存储的位置是堆里的字符串常量池。这个移动对实际生产有很大影响。在 JDK 7 之前大量使用 String.intern() 会有永久代溢出的风险JDK 8 之后字符串常量池在堆里由 GC 统一管理反而缓解了这个问题。很多面试题会问String 的 intern 方法有什么作用字符串常量池在哪个区域答案的关键就在这个变化里。直接内存Direct Memory不是 JVM 运行时数据区的一部分它是 Java NIO 引入的基于通道与缓冲区的 I/O 方式可以使用 native 函数库直接分配堆外内存然后通过存储在堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样做的好处是避免在 Java 堆和 native 堆之间来回复制数据大幅度提高 I/O 性能。但直接内存的回收机制比较特殊它不依赖常规的 GC而是依赖于 Cleaner 机制或 System.gc() 来触发这在某些场景下会造成延迟回收。如果使用 NIO 大量使用直接内存配置-XX:MaxDirectMemorySize限制大小是很有必要的。我在 Netty 的服务端调优中就遇到过因为直接内存没设上限IO 线程频繁报 OutOfMemoryError 的情况。3.4 内存结构面试高频问题整理这块内容是网上搜索热度最高的部分我整理几个面试里会被反复拷打的问题和答题要点。JVM 内存模型和 JVM 内存结构有什么区别这是一个非常经典的陷阱题。JVM 内存结构是运行时数据区的划分即堆、栈、方法区、程序计数器、本地方法栈而 JVM 内存模型Java Memory ModelJMM是 Java 并发编程中定义的一套规范规定了线程和主内存之间怎么交互、变量怎么保证可见性和有序性核心概念是主内存、工作内存、volatile 关键字、Happens-Before 规则。一个是空间划分一个是并发规则千万别混。对象在堆里是怎么分配的先尝试在栈上分配如果开启了逃逸分析并且对象没有逃逸出方法失败则判断对象大小是否可以直接进入老年代否则往 Eden 区分配Eden 不够触发 Minor GC 后仍放不下就进老年代。这个流程看几遍不如在线上用 jstat 观察一次 GC 日志来得直观。什么情况会触发 Full GC常见的触发条件老年代空间不足、元空间不足、调用 System.gc()、CMS 的并发模式失败、堆内存分配大对象失败等。实际排查时看 GC 日志里的 cause 字段最直接能明确告诉你 Full GC 是因为什么被触发的。4. 常见问题与排查实录4.1 内存溢出 OOM 排查实录OOM 是 JVM 应用里最让人头疼的问题之一。从我的经验看排查看 GC 日志永远是最快的第一步。老牌工具 jstat 可以直接查看 JVM 里的 GC 情况和各个内存区域的使用量jstat -gcutil pid 1000 10这条命令会每隔 1 秒输出一次 GC 信息共输出 10 次。重点看 Old 区的使用率如果曲线持续爬升且每次 Full GC 后回收效果不明显说明有对象引用没被释放可能存在内存泄漏。这时配合 jmap 生成堆转储文件jmap -dump:formatb,fileheap.hprof pid然后用 Eclipse MAT 或者 JProfiler 打开分析找支配树里占用最大的对象定位到具体的业务类。我见过一个非常典型的案例一个定时任务每次执行都会往一个静态 Map 里 put 数据但永远不会删除导致堆内存持续增长最终一周后触发频繁 Full GC接口大面积超时。用 MAT 一看就发现大量同一个业务对象堆积在静态集合里问题根源一目了然。排查堆 OOM 可以总结为四个步骤确认 OOM 类型日志里会明确打印是 Java heap space 还是 Metaspace 还是 Direct buffer memory→ 用 jstat 观察 GC 情况 → 用 jmap 抓 dump → 用 MAT 分析对象引用链。4.2 类加载冲突与 NoClassDefFoundErrorNoClassDefFoundError 和 ClassNotFoundException 是两码事但经常被混为一谈。ClassNotFoundException 是运行时明确找不到指定的类通常是类路径配置有问题NoClassDefFoundError 则是类在编译时存在、运行时加载失败比如类初始化时抛异常导致加载中断或者类依赖的另一个类缺失。实际项目里最常见的情况是依赖冲突同一个类在 classpath 里有多个版本不同的类加载器加载了不同的版本。Spring Boot 应用里用mvn dependency:tree检查依赖树是解决大部分依赖冲突的第一步。另外容器类应用里父加载器和子加载器加载到同一个类的不同版本也会出现 LinkageError 或 AbstractMethodError这种问题最常见于 Tomcat 部署多个应用时公共库和应用内嵌库版本不一致的场景。排查这类问题的基本思路确认错误信息的完整堆栈 → 确认是哪个类、哪个加载器加载的 → 检查 classpath 里是否有多个版本的 jar → 最终通过依赖管理工具排除冲突版本。4.3 双亲委派的实际应用与打破之前说的都是标准双亲委派机制但现实中有很多框架是打破这个模型的。最典型的就是 SPIService Provider Interface机制。JDBC 的 DriverManager 是 Bootstrap 加载器加载的但真正实现 driver 接口的类比如 MySQL 的 com.mysql.cj.jdbc.Driver却放在应用 classpath 下由应用类加载器加载。如果严格遵守双亲委派Bootstrap 加载器根本加载不到这个类JDBC 就废了。SPI 的解决办法是引入线程上下文类加载器Thread Context ClassLoader。DriverManager 通过Thread.currentThread().getContextClassLoader()拿到应用类加载器用它来加载具体的实现类这样就绕过了双亲委派。Tomcat 的类加载器体系也打破了双亲委派它的 webapp 类加载器会优先加载应用自身的类然后再委派给父加载器这样才能做到多个应用之间类的隔离和独立热部署。想看类的实际加载来源启动时加-XX:TraceClassLoading是最直接的。它会打印每一行类名 来源 jar信息对排查类从哪个 jar 加载的这种问题特别有用。4.4 我的 JVM 排查常用命令清单最后把我平时最常用的一套命令分享出来遇到问题可以直接抄作业命令作用jps -l查看当前系统的 Java 进程和主类jstat -gcutil pid 1000观察 GC 情况和内存占用jmap -heap pid查看堆内存配置和当前使用情况jmap -dump:formatb,filexx.hprof pid导出堆转储文件jstack pid导出线程快照排查死锁和线程阻塞jinfo pid查看 JVM 参数和系统属性-XX:TraceClassLoading -XX:TraceClassUnloading启动参数追踪类加载和卸载-verbose:class输出类加载信息每次排查完记得把堆转储文件和 GC 日志存好它们是复盘的第一手材料。另外不建议在生产环境高峰期执行 jmap dump因为 dump 过程中会触发 Stop The World对在线服务影响较大能安排在低峰期就安排在低峰期。我自己的习惯是所有 Java 服务启动参数里统一加上-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/path/gc.log让 GC 日志一直开着。磁盘开销很小但关键时刻能救命。有一次线上告警内存飙升我连上服务器第一件事就是翻 GC 日志五分钟就定位到是某个接口产生的大对象列表把老年代打满了而相邻的新上线的另一个服务因为没开 GC 日志排查了整整一个下午。最后再分享一个实战中总结的小技巧遇到 JVM 相关的疑难杂症不要急着调参数先动手收集事实——GC 日志、堆转储、线程快照、类加载追踪数据摆出来之后大部分问题的原因已经自己浮出水面了。JVM 这套机制虽然复杂但每一步都有迹可循掌握了类加载子系统和内存结构这两条主线无论是排查线上故障还是应对面试心里都会踏实很多。
返回列表