JVM 基础浅析

35 阅读13分钟

一、 JVM内存结构(运行时数据区)

这是JVM在执行Java程序时会使用的内存区域划分,每个区域都有特定的用途和生命周期。

  • 程序计数器(Program Counter Register)

    • 作用:当前线程所执行的字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复都依赖它。
    • 特性线程私有,生命周期与线程相同。是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域。
  • Java虚拟机栈(Java Virtual Machine Stacks)

    • 作用:描述Java方法执行的内存模型。每个方法执行时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

    • 局部变量表:存放基本数据类型对象引用reference类型,不等同于对象本身)和returnAddress类型。

    • 异常

      • StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度(如无限递归)。
      • OutOfMemoryError:如果栈可以动态扩展,但扩展时无法申请到足够内存。
  • 本地方法栈(Native Method Stack)

    • 作用:为JVM调用的Native方法服务。其具体行为由虚拟机本地实现决定。
  • Java堆(Java Heap)

    • 作用几乎所有的对象实例和数组都在这里分配内存,是垃圾收集器管理的主要区域(GC堆)。
    • 划分:从GC角度可分为新生代老年代;更细致的有Eden、Survivor区等。
    • 特性线程共享,在虚拟机启动时创建,是内存中最大的一块
    • 异常OutOfMemoryError,当堆中没有内存完成实例分配,并且堆也无法再扩展时抛出。
  • 方法区(Method Area)

    • 作用:存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
    • 演进:在JDK 8之前,它常被称为“永久代”(PermGen),是堆的一部分。从JDK 8开始,永久代被彻底移除,改为使用本地内存实现的“元空间”(Metaspace
    • 异常:如果方法区无法满足新的内存分配需求,将抛出OutOfMemoryError(如加载过多类、大量动态生成类)。
  • 运行时常量池(Runtime Constant Pool)

    • 作用:方法区的一部分,用于存放编译期生成的各种字面量和符号引用,具有动态性(运行时也可将新常量放入池中,如String.intern())。

二、Java内存模型(JMM)

JMM是一组规则和规范,定义了程序中各变量(实例字段、静态字段、数组元素)的访问方式。它围绕多线程编程中的原子性、可见性、有序性三大问题展开。

  • 核心概念:主内存与工作内存

    • 主内存:所有共享变量都存储在主内存中。
    • 工作内存:每个线程都有一个私有的工作内存,保存了该线程使用到的变量的主内存副本
    • 交互规则:线程对变量的所有操作(读/写)都必须在工作内存中进行,不能直接读写主内存变量。不同线程间无法直接访问对方的工作内存,变量值传递必须通过主内存完成。这正是可见性问题的根本原因。
  • 三大特性与JMM的解决方案

    • 原子性

      • 问题:一个或多个操作要么全部执行且过程不被中断,要么都不执行。i++这样的非原子操作在多线程下会出错。
      • JMM保障:对基本数据类型的访问读写(除long/double的非原子性协定外)是原子的。更复杂的原子性需要通过synchronizedLock保证。
    • 可见性

      • 问题:一个线程修改了共享变量的值,其他线程能够立即看到这个修改。

      • JMM方案

        • volatile:保证修改后强制立即刷新到主内存,并使其他线程的工作内存中该变量缓存失效,必须从主内存重新读取。
        • synchronized:在解锁前,必须将变量同步回主内存。
        • final:被final修饰的字段,在构造器初始化完成后,其他线程就能看到其值(前提是没有this引用逃逸)。
    • 有序性

      • 问题:程序执行的顺序可能并非代码书写的顺序,因为编译器指令重排处理器乱序执行会优化性能。

      • JMM方案happens-before原则。它定义了操作间的偏序关系,保证前一个操作的结果对后一个操作可见

        • 常见规则

          1. 程序次序规则:一个线程内,写在前面的操作happens-before后面的操作。
          2. volatile规则:对一个volatile变量的写操作happens-before后续对它的读操作。
          3. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • volatile关键字的深度理解
    volatile是JMM中轻量级的同步机制,它保证了可见性禁止指令重排,但不保证原子性

    • 内存语义

      • :当写一个volatile变量时,JMM会将该线程本地内存中的共享变量值立刻刷新到主内存
      • :当读一个volatile变量时,JMM会使该线程的本地内存无效,从主内存中重新读取共享变量。
    • 内存屏障:为了实现上述语义,编译器会在volatile读写操作前后插入特定的内存屏障,防止指令重排序,并强制刷新缓存。

三、垃圾回收的核心问题与基础

1. 核心目标与矛盾

垃圾回收器始终在平衡三个核心目标:

  • 低延迟:单次GC停顿时间短,避免“卡顿”。
  • 高吞吐量:GC总时间占程序总运行时间的比例要低。
  • 内存效率:避免浪费过多内存空间。

这三者如同“不可能三角” ,优化一方往往会牺牲另一方。例如,为了极低延迟(如ZGC的<1ms),可能会牺牲一些吞吐量或占用更多内存。

2. 判定对象“死亡”

  • 可达性分析算法:这是JVM的主流算法。它定义了一系列  “GC Roots”  作为起点(如虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象等),向下搜索,形成引用链。不在任何引用链上的对象,即被认为是“不可达”的,可以被回收。
  • 引用类型:Java将引用分为四类,让开发者能一定程度影响GC行为。
引用类型被GC回收时机常见用途
强引用 (Object obj = new Object())永不回收(宁可抛出OutOfMemoryError绝大部分普通对象引用。
软引用 (SoftReference)内存不足时,在OOM抛出会被回收。实现内存敏感的缓存(如图片缓存),缓存数据在内存紧张时自动释放。
弱引用 (WeakReference)下一次GC发生时,无论内存是否充足,都会回收。维护非强制性关联,如WeakHashMap、某些监控场景。
虚引用 (PhantomReference)随时可能被回收,无法通过它取得对象用于在对象被回收时收到一个系统通知(与ReferenceQueue配合)。

四、经典垃圾收集算法

1. 标记-清除

  • 过程:分为“标记”和“清除”两个阶段。首先标记出所有需要回收的对象,然后统一回收。
  • 缺点产生内存碎片。碎片太多可能导致后续无法分配大对象,从而提前触发另一次GC。

2. 复制

  • 过程:将内存划分为大小相等的两块,每次只使用其中一块。当这一块用完了,就将还存活的对象复制到另一块上,然后一次性清理掉已使用的内存。
  • 优点:实现简单,运行高效,没有内存碎片
  • 缺点可用内存缩小为原来的一半,代价高昂。
  • 应用:是新生代回收的主流算法。在新生代中,将内存分为一个较大的Eden区和两个较小的Survivor区(S0S1),每次使用Eden和一个Survivor。回收时,将EdenSurvivor中存活的对象复制到另一个Survivor。这样只有10%的内存(一个Survivor)被“浪费”。

3. 标记-整理

  • 过程:标记过程与“标记-清除”一样,但后续不是直接清理,而是让所有存活的对象都向内存的一端移动,然后直接清理掉边界以外的内存。
  • 优点避免了内存碎片,也无需浪费一半空间
  • 缺点:移动对象并更新引用地址是一个开销较大的操作,且需要暂停用户线程
  • 应用:适合老年代的回收算法。

4. 分代收集理论

现代商用虚拟机都采用此理论,基于一个弱分代假说:绝大多数对象都是朝生夕死的

  • 新生代:新对象在此分配。回收非常频繁,使用复制算法,速度极快。该次回收称为  “Minor GC”或“Young GC”
  • 老年代:在新生代中经历过多次GC(默认15次)依然存活的对象,会晋升到老年代。此外,大对象(如长数组)也可能直接进入老年代。回收不那么频繁,使用标记-清除标记-整理算法。该次回收称为  “Major GC”或“Full GC” ,通常伴随至少一次Minor GC,且停顿时间很长

五、类加载机制

类加载机制是JVM将类的字节码数据从静态存储(如.class文件)转换为运行时内存中的java.lang.Class对象的整个过程。它是Java动态性(如动态加载、热部署)的基石,也是理解插件化、热修复等技术的关键。

类的生命周期包含加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中加载、验证、准备、解析、初始化这五个阶段统称为  “类加载”

1. 加载

  • 任务:通过一个类的全限定名来获取定义此类的二进制字节流,并将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构,最后在堆中生成一个代表该类的java.lang.Class对象,作为方法区这个类各种数据的访问入口。

  • 关键点

    • 来源多样:可以从ZIP/JAR/WAR包、网络、运行时计算生成(动态代理)、其他文件(JSP)等获取。
    • 数组类特殊:数组类本身不通过类加载器创建,由JVM直接创建,但其元素类型最终需要类加载器加载。

2. 验证

  • 目的:确保字节流符合JVM规范,保证不会危害虚拟机安全。

  • 阶段

    • 文件格式验证:验证字节流是否符合Class文件格式规范(如魔数、版本号)。
    • 元数据验证:对类的元数据信息进行语义校验(如是否有父类、是否继承了不允许继承的类)。
    • 字节码验证:通过数据流和控制流分析,确定程序语义是合法、符合逻辑的。
    • 符号引用验证:发生在解析阶段,验证符号引用能否转化为直接引用。

3. 准备

  • 任务:为类变量(被static修饰的变量)在方法区中分配内存,并设置初始零值
  • 关键区别
public static int value = 123; // 准备阶段后 value = 0, 初始化阶段才变为123
public static final int CONSTANT = 456; // 准备阶段后 CONSTANT = 456 (常量)

4. 解析

  • 任务:将常量池内的符号引用替换为直接引用

    • 符号引用:一组符号来描述所引用的目标,与内存布局无关。
    • 直接引用:直接指向目标的指针、相对偏移量或能间接定位到目标的句柄。

5. 初始化

  • 任务:执行类构造器<clinit>()方法的过程。<clinit>()方法由编译器自动收集类中所有类变量的赋值动作静态语句块中的语句合并产生。

  • 触发时机(有且只有以下六种情况,称为对一个类的“主动引用”):

    1. 遇到newgetstaticputstaticinvokestatic这四条字节码指令。
    2. 使用java.lang.reflect包的方法对类进行反射调用时。
    3. 当初始化一个类时,如果其父类还未初始化,则先触发其父类的初始化。
    4. 虚拟机启动时,用户指定的主类(包含main方法的类)会被初始化。
    5. 当使用JDK7+的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic等句柄,且该句柄对应的类未初始化。
    6. 当一个接口定义了JDK8新加入的默认方法时,如果该接口的实现类被初始化,则该接口要在其之前被初始化。

六、类加载器与双亲委派模型

1. 类加载器的层次结构

加载器加载路径/目标说明
启动类加载器 (Bootstrap ClassLoader)\lib目录下的核心库(如rt.jarC++实现,是JVM的一部分。不继承java.lang.ClassLoader
扩展类加载器 (Extension ClassLoader)\lib\ext目录或java.ext.dirs系统变量指定路径Java实现,是sun.misc.Launcher$ExtClassLoader的实例。
应用程序类加载器 (Application ClassLoader)用户类路径(ClassPath)也称为系统类加载器,是sun.misc.Launcher$AppClassLoader的实例。默认的类加载器
自定义类加载器用户自定义路径继承ClassLoader类,实现findClass方法。

2. 双亲委派模型 工作流程与原理

// 简化逻辑
protected Class<?> loadClass(String name, boolean resolve) {
    synchronized (getClassLoadingLock(name)) {
        // 1. 检查是否已加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            if (parent != null) {
                // 2. 递归调用父加载器
                c = parent.loadClass(name, false);
            } else {
                // 3. 父加载器为空,则委派给启动类加载器
                c = findBootstrapClassOrNull(name);
            }
            if (c == null) {
                // 4. 父加载器都无法完成,则调用自己的findClass
                c = findClass(name);
            }
        }
        return c;
    }
}
  • 核心思想:“向上委派,向下加载”。一个类加载器收到请求后,首先不会自己尝试加载,而是递归地委派给父加载器去完成。只有当父加载器反馈无法完成时,子加载器才会尝试自己加载。

  • 核心作用

    1. 保证类全局唯一:防止同一个类被不同的类加载器重复加载,避免混乱。
    2. 保护程序安全:防止核心API(如java.lang.Object)被用户自定义的类篡改。因为核心类永远由启动类加载器最先加载。

七、Android的类加载机制

Android的类加载器是Dalvik/ART虚拟机的一部分,结构与标准JVM类似但服务于.dex文件。

加载器加载目标说明
BootClassLoaderAndroid Framework的核心类库(如android.*java.*)。内部类,单例。相当于Java的Bootstrap和Extension加载器。
PathClassLoader已安装的APK内部的classes.dex文件。应用默认的类加载器,用于加载自身的类。
DexClassLoader文件系统目录加载包含.dex的JAR/APK/ZIP文件。插件化、热修复、动态加载的基石。它允许从非安装路径加载代码。

Android类加载的核心流程同样遵循“双亲委派”,但在loadClass中,Android的BaseDexClassLoader还会通过一个DexPathList来查找.dex文件,这是与标准JVM的显著区别。

1. Class.forName() vs ClassLoader.loadClass()

  • Class.forName() 会执行类的初始化(触发<clinit>())。
  • ClassLoader.loadClass() 只进行加载,不会初始化(除非传入resolve参数为true)。

2. 内存中类的唯一性:一个类由它的全限定名加载它的类加载器共同决定。即使是同一个.class文件,被两个不同的自定义类加载器加载,在JVM看来也是两个不同的类,会导致类型转换异常(ClassCastException)。

3. 自定义类加载器步骤

  • 继承ClassLoader
  • 重写findClass(String name)方法(不要重写loadClass,否则会破坏双亲委派)。
  • 在该方法中,根据name找到类的字节码,并调用defineClass()将其转换为Class对象。

理解类加载机制,是从“会写Java代码”到“理解Java程序如何运行”的关键跃升。它不仅解答了“类从何而来”的问题,更是实现高级特性(如热修复、模块化)的根本。