Featured image of post Jvm

Jvm

JVM 学习笔记

1. JVM 概述

JVM 是 Java Virtual Machine 的缩写,它是一个虚构出来的计算机,一种规范。Java 虚拟机(JVM)是 Java 程序运行的核心组件,负责将 Java 字节码转换为机器码并执行。JVM 提供了跨平台的能力,使得 Java 程序能够“一次编写,到处运行”。JVM 其实就类似于一台小电脑运行在 windows 或者 linux 这些操作系统环境下即可。它直接和操作系统进行交互,与硬件不直接交互,而操作系统可以帮我们完成和硬件进行交互的工作。

1.1 Java文件是如何被运行的

比如我们现在写了一个 HelloWorld.java 好了,那这个 HelloWorld.java 抛开所有东西不谈,就类似于一个文本文件。

JVM 是不认识文本文件的,所以它需要进行 编译 ,让其成为一个它会读二进制文件的 HelloWorld.class

类加载器

如果 JVM 想要执行这个 .class 文件,需要将其装进一个 类加载器 中,它就像一个搬运工一样,会把所有的 .class 文件全部搬进 JVM 里面来。

image-20241225200416564

方法区

方法区 是用于存放类似于元数据信息方面的数据的,比如类信息,常量,静态变量,编译后代码···等

类加载器将 .class 文件搬过来就是先丢到这一块上

主要放了一些存储的数据,比如对象实例,数组···等,它和方法区都同属于 线程共享区域 。也就是说它们都是 线程不安全 的。

这是我们的代码运行空间。我们编写的每一个方法都会放到 里面运行。

我们会听说过 本地方法栈 或者 本地方法接口 这两个名词,不过我们基本不会涉及这两块的内容,它俩底层是使用 C 来进行工作的,和 Java 没有太大的关系。

程序计数器

主要就是完成一个加载工作,类似于一个指针一样的,指向下一行我们需要执行的代码。和栈一样,都是 线程独享 的,就是说每一个线程都会有自己对应的一块区域而不会存在并发和多线程的问题。

image-20241225200637102

Java 文件经过编译后变成 .class 字节码文件。

字节码文件通过类加载器被搬运到 JVM 虚拟机中。

虚拟机主要的 5 大块:方法区,堆都为线程共享区域,有线程安全问题,栈和本地方法栈和计数器都是独享区域,不存在线程安全问题,而 JVM 的调优主要就是围绕堆,栈两大块进行。

2. JVM 的核心原理

2.1 类加载机制

2.1.1 类加载过程

  1. 加载(Loading)

    • 通过类加载器将 .class 文件加载到内存中。
    • 类加载器根据类的全限定名查找字节码文件,并将其转换为 JVM 内部的类对象。
    • 加载阶段是类加载的第一步,后续的验证、准备、解析和初始化都依赖于加载的结果。
  2. 验证(Verification)

    • 确保字节码符合 JVM 规范,防止恶意代码。
    • 验证的内容包括:
      • 文件格式验证:检查字节码文件是否符合 JVM 规范。
      • 元数据验证:检查类的元数据是否符合 Java 语言规范。
      • 字节码验证:检查字节码是否合法,是否存在栈溢出、类型不匹配等问题。
      • 符号引用验证:确保符号引用能够正确解析。
  3. 准备(Preparation)

    • 为静态变量分配内存并设置默认值。
    • 例如,static int value = 123; 在准备阶段,value 会被初始化为 0,而不是 123
    • 如果静态变量是常量(final),则会在准备阶段直接赋值。
  4. 解析(Resolution)

    • 将符号引用转换为直接引用。
    • 符号引用是类、方法、字段的名称和描述符,直接引用是内存地址或偏移量。
    • 解析阶段可能触发其他类的加载。
  5. 初始化(Initialization)

    • 执行静态代码块和静态变量的赋值。
    • 初始化阶段是类加载的最后一步,只有当类被主动使用时才会触发。
    • 例如,static { value = 123; } 会在初始化阶段执行。

    其中验证,准备,解析三个部分统称为连接

    类加载器的层级结构:

    加载一个 Class 类的顺序也是有优先级的,类加载器从最底层开始往上的顺序是这样的

    1. Bootstrap Class Loader:加载核心 Java 类库(如 java.lang.*),由 JVM 实现,通常用 C/C++ 编写。
    2. Extension Class Loader:加载扩展类库(jre/lib/ext 目录下的类)。
    3. Application Class Loader:加载应用程序类路径(Classpath)中的类。

2.1.2 双亲委派模型

  • 类加载器在加载类时,首先委托父类加载器尝试加载,只有在父类加载器无法加载时,才由自己加载。这样做的好处是,加载位于 rt.jar 包中的类时不管是哪个加载器加载,最终都会委托到 BootStrap ClassLoader 进行加载,这样保证了使用不同的类加载器得到的都是同一个结果
  • 优点:
    • 保证核心类库的安全性,避免用户自定义类替换核心类。
    • 避免重复加载,提高加载效率。

2.1.3 自定义类加载器

  • 可以通过继承 ClassLoader 类实现自定义类加载器。
  • 典型应用场景:
    • 热部署:动态加载修改后的类。
    • 隔离类加载:实现类加载的隔离,避免类冲突。
    • 加密类加载:加载加密的字节码文件。

2.2 运行时数据区

2.2.1 方法区(Method Area)

  • 存储类的元数据、常量、静态变量等。
  • 在 JDK 8 之前称为“永久代(PermGen)”,之后被“元空间(Metaspace)”取代。
  • 元空间使用本地内存,不再受 JVM 堆内存限制,减少了内存溢出的风险。
  • 主要存储:
    • 类的结构信息(如方法、字段、构造函数等)。
    • 运行时常量池(Runtime Constant Pool)。
    • 静态变量。

2.2.2 堆(Heap)

  • 存储对象实例和数组。
  • 是垃圾回收的主要区域。
  • 分为新生代(Young Generation)和老年代(Old Generation):
    • 新生代
      • 分为 Eden 区和两个 Survivor 区(From 和 To)。
      • 新创建的对象首先分配在 Eden 区。
      • 当 Eden 区满时,触发 Minor GC,存活的对象被移动到 Survivor 区。
      • 经过多次 Minor GC 后仍然存活的对象会被移动到老年代。
    • 老年代
      • 存储长期存活的对象。
      • 当老年代满时,触发 Full GC,回收整个堆内存。
  • 非堆内存则为永久代

2.2.3 栈(Stack)

是 Java 方法执行的内存模型。里面会对局部变量,动态链表,方法出口,栈的操作(入栈和出栈)进行存储,且线程独享。同时如果我们听到局部变量表,那也是在说虚拟机栈。

  • 每个线程拥有独立的栈,用于存储局部变量、方法调用和部分结果。
  • 栈帧(Stack Frame)是栈的基本单位,每个方法调用对应一个栈帧。
  • 栈帧包括:
    • 局部变量表:存储方法的局部变量。
    • 操作数栈:用于执行字节码指令。
    • 动态链接:指向运行时常量池的方法引用。
    • 方法返回地址:记录方法执行完毕后的返回地址。
  • 对于栈来说,不存在垃圾回收。只要程序运行结束,栈的空间自然就会释放了。栈的生命周期和所处的线程是一致的。

2.2.4 程序计数器(Program Counter Register)

  • 记录当前线程执行的字节码指令地址。
  • 线程私有,不会发生内存溢出。
  • 在多线程环境下,每个线程都有自己的程序计数器,用于记录线程的执行位置。

2.2.5 本地方法栈(Native Method Stack)

  • 为本地方法(Native Method)服务,与栈类似。
  • 本地方法是用其他语言(如 C/C++)编写的方法,通过 JNI(Java Native Interface)调用。

2.3 执行引擎

2.3.1 解释器(Interpreter)

  • 逐行解释字节码并执行。
  • 优点:启动速度快,适合短生命周期的应用。
  • 缺点:执行效率较低,适合开发环境或小型应用。

2.3.2 即时编译器(JIT Compiler)

  • 将热点代码(频繁执行的代码)编译为机器码,提高执行效率。
  • 主要的 JIT 编译器:
    • C1 编译器:适用于客户端应用,编译速度快,优化程度较低。
    • C2 编译器:适用于服务器端应用,优化程度高,编译速度较慢。
  • JIT 编译器的工作流程:
    1. 监控代码执行频率,识别热点代码。
    2. 将热点代码编译为机器码。
    3. 替换解释器执行的字节码,直接执行机器码。

2.3.3 垃圾回收器(Garbage Collector)

  • 自动回收不再使用的对象,释放内存。
  • 常见的垃圾回收算法:
    • 标记-清除(Mark-Sweep)
      • 标记所有存活的对象,清除未标记的对象。
      • 缺点:产生内存碎片。
    • 标记-整理(Mark-Compact)
      • 标记所有存活的对象,将存活对象移动到内存的一端,清除剩余内存。
      • 优点:避免内存碎片。
    • 复制算法(Copying)
      • 将内存分为两块,每次只使用一块,将存活对象复制到另一块,清除当前块。
      • 优点:避免内存碎片,适合新生代。
    • 分代收集(Generational Collection)
      • 根据对象的生命周期将堆内存分为新生代和老年代,分别采用不同的垃圾回收算法。

3. JVM 在项目中的运用

3.1 JVM 调优

3.1.1 内存设置

  • 堆内存
    • -Xms:初始堆大小。
    • -Xmx:最大堆大小。
  • 新生代与老年代比例
    • -XX:NewRatio:新生代与老年代的比例。
    • -XX:SurvivorRatio:Eden 区与 Survivor 区的比例。

3.1.2 垃圾回收器选择

  • 串行垃圾回收器-XX:+UseSerialGC,适用于单核 CPU。
  • 并行垃圾回收器-XX:+UseParallelGC,适用于多核 CPU。
  • G1 垃圾回收器-XX:+UseG1GC,适用于大内存应用。

3.2 性能监控与问题排查

3.2.1 监控工具

  • jstat:监控 JVM 统计信息,如堆内存使用情况、垃圾回收次数等。
  • jmap:生成堆内存快照,分析内存占用。
  • jstack:生成线程快照,排查死锁或线程阻塞问题。
  • VisualVM:图形化工具,监控内存、线程、CPU 使用情况。

3.2.2 常见问题与解决方案

  • 内存泄漏:使用 jmap 生成堆转储文件,分析对象引用链,找到未释放的对象。
  • CPU 占用过高:使用 jstack 查看线程堆栈,定位高 CPU 占用的线程。
  • 频繁 Full GC:调整堆内存大小或优化垃圾回收器参数。

3.3 本地方法接口(JNI)

  • 用途:调用本地方法(如 C/C++ 代码),适用于需要高性能或调用现有库的场景。
  • 流程
    1. 在 Java 中声明本地方法。
    2. 使用 javah 生成头文件。
    3. 在 C/C++ 中实现本地方法。
    4. 将本地库加载到 JVM 中。

4. 实践建议

  1. 深入理解 JVM 原理:通过阅读官方文档和相关书籍,掌握 JVM 的核心概念和工作机制。
  2. 结合实际项目调优:根据应用场景调整 JVM 参数,监控性能指标,优化垃圾回收策略。
  3. 使用工具排查问题:熟练掌握 jstatjmapjstack 等工具,快速定位和解决性能问题。
  4. 关注 JVM 发展趋势:了解新版本 JVM 的特性和优化,如 ZGC、GraalVM 等。

5. 总结

JVM 是 Java 生态系统的核心,理解其原理和调优方法对于开发高性能、稳定的 Java 应用至关重要。通过深入学习 JVM 的内存管理、类加载机制、执行引擎等核心组件,并结合实际项目中的调优和问题排查,可以显著提升应用的性能和可靠性。

使用 Hugo 构建
主题 StackJimmy 设计