JVM 核心内容

发布于:2025-07-25 ⋅ 阅读:(18) ⋅ 点赞:(0)

JVM 类加载机制详解

1. 什么是类加载

类加载(Class Loading)是指JVM将类的字节码(.class 文件)加载到内存,并为之创建Class对象的过程。Java 程序运行时,只有被加载到内存中的类才能被使用。

2. 类加载的生命周期

JVM对类的处理分为以下几个阶段:

  1. 加载
    • 通过类的全限定名(包名+类名)查找并加载 class 文件的字节流到内存。
    • 生成对应的 java.lang.Class 对象。     
  2. 验证: 校验字节码文件的正确性、安全性。
  3. 准备:为类的静态变量分配内存,并设置默认初始值(不会执行静态代码块)
  4. 解析:将常量池中的符号引用替换为直接引用(如方法、字段等的内存地址)
  5. 初始化:执行类的静态初始化块和静态变量的初始化赋值
  6. 使用: 类被真正使用(如实例化、调用静态方法等)
  7. 卸载:类被垃圾回收,Class对象被回收(很少发生,通常是自定义ClassLoader加载的类才会被卸载)

3. 类加载器(ClassLoader)

JVM 通过类加载器来实现类的加载。类加载器有分层结构,主要有三种:

1. 启动类加载器(Bootstrap ClassLoader)

  • 加载 JDK 的核心类库($JAVA_HOME/lib 下的类,如 rt.jar)。
  • 由 C++ 实现,JVM 自己的一部分。

2. 扩展类加载器(Extension ClassLoader)

  • 加载 JDK 扩展目录($JAVA_HOME/lib/ext)下的类。

3. 应用类加载器(Application ClassLoader)

  • 加载用户 classpath 下的类(开发者写的代码)。

4. 自定义类加载器

  • 用户可以继承 java.lang.ClassLoader 实现自己的类加载逻辑。
  • 自定义类加载器默认还是会先走双亲委派模型(即先让父加载器尝试加载)。
  • 只有父加载器找不到时,才会调用你重写的 findClass。

4. 双亲委派模型(Parent Delegation Model)

核心思想:

类加载请求会先委托给父加载器,只有父加载器找不到,才由当前加载器尝试加载。

流程:

  1. 当前类加载器收到加载请求。
  2. 先让父加载器尝试加载。
  3. 父加载器再往上递归,直到 Bootstrap ClassLoader。
  4. 如果父加载器都找不到,才由当前加载器加载。

优点:

  • 避免重复加载。
  • 保证核心类库的安全性(比如你不能伪造 java.lang.String)。

代码:

protected Class<?> loadClass(String name, boolean resolve) {
    synchronized (getClassLoadingLock(name)) {
        // 1. 检查类是否已加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) {
                    // 2. 委派给父加载器
                    c = parent.loadClass(name, false);
                } else {
                    // 3. 没有父加载器则使用启动类加载器
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {}
            
            if (c == null) {
                // 4. 父加载器无法加载时自己尝试加载
                c = findClass(name);
            }
        }
        return c;
    }
}

5. 代码示例

public class Test {
    static {
        System.out.println("Test类被初始化");
    }
    public static void main(String[] args) throws Exception {
        Class<?> clazz = Class.forName("Test"); // 触发类加载和初始化
    }
}

6. 常见面试/考点

  1. 什么时候会触发类初始化?:
  • new 对象、调用静态方法/字段、反射、子类初始化会先初始化父类等。
  1. 如何自定义类加载器?:   
1. 继承java.lang.ClassLoader  
2. 重写findClass()方法  
3. 调用defineClass()方法将字节数组转换为Class对象  
 
public class MyClassLoader extends ClassLoader {
    private String classPath;

    public MyClassLoader(String classPath) {
        this.classPath = classPath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 1. 构造 class 文件的绝对路径
        String fileName = classPath + "/" + name.replace('.', '/') + ".class";
        try {
            // 2. 读取 class 文件的字节码
            byte[] classBytes = Files.readAllBytes(Paths.get(fileName));
            // 3. 调用 defineClass,把字节码转换为 Class 对象
            return defineClass(name, classBytes, 0, classBytes.length);
        } catch (IOException e) {
            throw new ClassNotFoundException("类未找到: " + name, e);
        }
    }
}
  1. 双亲委派模型的好处?:

1. 安全性

  • 保证 Java 核心类库不会被篡改或伪造。

  • 比如:你不能自己写一个 java.lang.String 类并被应用加载器加载,因为加载请求会被委托给 Bootstrap ClassLoader(引导类加载器),只有它能加载核心类库。

2. 避免类的重复加载

  • 每个类只会被加载一次(由同一个类加载器)。

  • 如果父加载器已经加载过某个类,子加载器不会重复加载,节省内存,避免冲突。

3. 保证类的一致性

  • 保证同一个类在 JVM 中的唯一性(由“类的全限定名+加载它的类加载器”唯一确定)。

  • 避免出现“同名不同类”的问题,防止类型转换异常(ClassCastException)。

4. 易于维护和扩展

  • 类加载器之间职责分明,结构清晰。

  • 应用开发者只需关注自己的类加载器,不用担心核心类库的加载细节。

5. 有利于模块化和隔离

  • 不同的类加载器可以加载不同来源的类,实现模块隔离(如 Tomcat、OSGi、J2EE 容器等)

JVM 内存模型

1. 什么是 Java 内存模型?

Java 内存模型(JMM, Java Memory Model)是 Java 虚拟机规范中定义的一套关于多线程读写共享变量的规则和约定,它规定了:

  • 变量的存储方式
  • 线程之间如何可见和交互这些变量
  • 如何保证并发下的可见性、有序性和原子性

JMM 的核心目标是屏蔽各种硬件和操作系统的内存访问差异,让 Java 程序在不同平台下有一致的并发语义。

2. JMM 的主要内容

1. 主内存与工作内存

  • 主内存(Main Memory):所有线程共享的内存区域,Java 中的实例变量、静态变量都存储在这里。
  • 工作内存(Working Memory):每个线程私有的内存区域,存储了该线程使用到的变量的主内存副本。

线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接操作主内存。

2. 线程间通信

  • 一个线程对共享变量的修改,必须刷新回主内存,其他线程才能看到。
  • 线程间变量的可见性依赖于主内存的同步。

3. JMM 的三大特性

1. 原子性(Atomicity)

  • 一个操作要么全部完成,要么全部不做,不会被线程切换中断。
  • Java 基本数据类型的读写是原子的(long 和 double 除外,JDK8 以后也保证了原子性)。

2. 可见性(Visibility)

  • 一个线程对共享变量的修改,能及时被其他线程看到。
  • 关键字 volatile、synchronized、final 都能保证可见性。

3. 有序性(Ordering)

  • 程序执行的顺序按照代码的先后顺序执行(编译器和 CPU 可能会优化重排序)。
  • synchronized 和 volatile 可以部分禁止重排序。

4. JMM 的“八大操作”

JMM 定义了线程与主内存之间的交互有8种原子操作:

  • lock(锁定)
  • unlock(解锁)
  • read(读取主内存到工作内存)
  • load(把read的值放入工作内存变量副本)
  • use(工作内存变量用于计算)
  • assign(将计算结果赋值给工作内存变量)
  • store(把工作内存变量写回主内存)
  • write(将store的值写入主内存变量)

5. 关键字与 JMM

  • volatile:保证可见性和禁止指令重排序(部分有序性),但不保证原子性。
  • synchronized:保证原子性、可见性和有序性。
  • final:保证初始化后的可见性。

6. 经典问题

  • 可见性问题:一个线程修改了变量,另一个线程看不到。
public class VisibilityDemo {
    static boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (running) {
                // do something
            }
            System.out.println("Thread stopped.");
        });
        t.start();

        Thread.sleep(1000);
        running = false; // 主线程修改变量
        System.out.println("Main thread set running = false");
    }
}
/**
可能结果:
主线程已经把 running 设为 false,但子线程可能永远不会停止,因为它看不到主线程的修改(JVM 缓存了变量)。
解决方法:
用 volatile 修饰变量:static volatile boolean running = true;
**/
  • 有序性问题:指令重排序导致并发 bug。
/**
可能结果:
按照直觉,x 和 y 至少有一个应该是 1,但实际可能都为 0。
这是因为 a=1 和 x=b 可能被重排序,b=1 和 y=a 也可能被重排序,导致两个线程都在对方赋值前读取了初始值。
解决方法:
用 volatile 或 synchronized 保证有序性。
*/
public class ReorderDemo {
    static int a = 0, b = 0;
    static int x = 0, y = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100000; i++) {
            a = b = x = y = 0;
            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
            });
            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            if (x == 0 && y == 0) {
                System.out.println("出现了 x=0, y=0");
                break;
            }
        }
    }
}
  • 原子性问题:多个线程同时操作同一个变量,导致数据不一致。
/**
期望结果:
1000 * 1000 = 1,000,000
实际结果:
通常远小于 1,000,000,因为 count++ 不是原子操作,多个线程同时读写导致丢失。
解决方法:
用 synchronized 或 AtomicInteger 替代。
*/
public class AtomicityDemo {
    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[1000];
        for (int i = 0; i < 1000; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    count++; // 非原子操作
                }
            });
            threads[i].start();
        }
        for (Thread t : threads) t.join();
        System.out.println("count = " + count);
    }
}

重排序

1. 什么是重排序(Reordering)

重排序是指:为了提高程序执行效率,编译器和处理器在不影响单线程语义的前提下,可以对指令的执行顺序进行优化调整。

  • 编译器优化重排序:编译器在生成字节码或机器码时改变指令顺序。
  • 处理器重排序:CPU 在执行时,为了流水线、乱序执行等优化,也可能改变指令实际执行顺序。

注意:重排序不会影响单线程程序的最终结果,但在多线程环境下,可能导致线程间的可见性和有序性问题,产生并发 bug。

2. 什么是 happen-before(先行发生)原则

happen-before 是 Java 内存模型(JMM)中定义的多线程间操作的有序性规则,用来保证某些操作的结果对其他线程可见。

核心含义:

如果操作A happen-before 操作B,那么A的结果对B可见,且A的执行顺序在B之前。

happen-before 的主要规则

  1. 程序顺序规则

  • 在一个线程内,代码的执行顺序,前面的操作 happen-before 后面的操作。
  1. 监视器锁规则(synchronized)

  • 对一个锁的解锁 happen-before 之后对同一个锁的加锁。
  1. volatile 变量规则

  • 对一个 volatile 变量的写操作 happen-before 后面对同一个变量的读操作。
  1. 传递性

  • 如果A happen-before B,B happen-before C,则A happen-before C。
  1. 线程启动规则

  • 线程A启动线程B(即调用B.start()),则A中对共享变量的修改对B可见。
  1. 线程终结规则

  • 线程A等待线程B结束(如B.join()),则B中对共享变量的修改对A可见。
  1. 线程中断规则

  • 对线程interrupt() happen-before 检测到中断(isInterrupted())。
  1. 对象终结规则        

  • 对象的构造函数执行结束 happen-before 该对象的 finalize() 方法开始

3. 重排序与 happen-before 的关系

  • 重排序可能导致多线程下的可见性和有序性问题。
  • happen-before 规则定义了哪些操作之间必须保证“先行发生”,JVM 和编译器必须禁止这些操作之间的重排序。

4. volatile 的作用

  • volatile 关键字可以禁止对该变量的读写操作与前后的普通操作发生重排序。
  • 保证写入 volatile 变量之前的操作,对其他线程可见。

5. 面试常用例子

双重检查锁(DCL)单例模式:

/**
*
2. new Singleton() 实际做了什么?
instance = new Singleton(); 这行代码不是原子操作,它大致可以分为三步:
分配内存:为 Singleton 分配内存空间
调用构造方法:初始化 Singleton 对象
将 instance 指向分配的内存地址(此时 instance 不为 null)

3. 指令重排序的风险
JVM 和 CPU 为了优化性能,可能会对上述三步进行重排序,比如:
步骤1(分配内存)
步骤3(将 instance 指向内存地址)
步骤2(调用构造方法初始化)
也就是说,instance 已经不为 null,但对象还没初始化完成

4. 多线程下的危险场景
假设线程A和线程B同时进入getInstance():
线程A进入,发现instance == null,进入同步块。
线程A执行到instance = new Singleton();,发生了重排序,先把 instance 指向了内存地址(步骤3),但对象还没初始化(步骤2还没执行)。
线程B进入,发现instance != null,直接返回 instance。
线程B拿到的是一个还没初始化完成的对象,使用时就会出错(比如成员变量为默认值,甚至抛出异常)。

5. 解决办法
用 volatile 修饰 instance:
*
*/
public class Singleton {
//volatile 保证 instance 的写操作和读操作之间有 happen-before 关系,防止重排序导致的“半初始化”问题。
    private static volatile Singleton instance;
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized(Singleton.class) {
                if (instance == null) {
                    instance = new Singleton(); // 可能发生指令重排序
                }
            }
        }
        return instance;
    }
}

6. 总结

  • 重排序:编译器/CPU 为优化性能而调整指令顺序。
  • happen-before:JMM 规定的多线程操作间的有序性约束,保证数据可见性和正确性。
  • volatile/synchronized 等关键字可以建立 happen-before 关系,防止重排序带来的并发问题。

volatile

volatile 修饰的变量可以防止特定的重排序,但不是所有重排序都被禁止。

1. volatile 的“禁止重排序”语义

  • 写操作:对一个 volatile 变量的写操作,之前的所有操作在内存语义上都不能被重排序到 volatile 写之后。
  • 读操作:对一个 volatile 变量的读操作,之后的所有操作在内存语义上都不能被重排序到 volatile 读之前。

简化理解:

  • volatile 写之前的操作不能被重排序到 volatile 写之后。
  • volatile 读之后的操作不能被重排序到 volatile 读之前。

2. volatile 不能禁止的重排序

  • volatile 变量之间的操作,JVM 只保证可见性和部分有序性。
  • 普通变量之间的操作,如果没有和 volatile 变量的读写发生依赖,仍然可能被重排序。

3. 例子说明

int a = 0;
volatile int v = 0; //
a = 1; //这里,a = 1 不能被重排序到 v = 1 之后
v = 1;




volatile int v = 0;
int a = 0; //这里,a = 1 不能被重排序到 v = 1 之前
v = 1;
a = 1;

4. volatile 不能保证原子性

  • volatile 只能保证可见性和部分有序性,不能保证原子性。
  • 例如:count++ 不是原子操作,即使 count 是 volatile 修饰的,也不能保证线程安全。

5. 总结

  • volatile 变量的读写会建立“内存屏障”,禁止特定的重排序。
  • 但不是所有重排序都被禁止,普通变量之间的操作仍可能被重排序。
  • volatile 主要用于保证多线程下的可见性和部分有序性,不是万能的并发同步工具。
  • volatile 只保证与它相关的操作的有序性,普通变量之间如果没有和 volatile 变量的读写依赖,仍然可能被重排序。
  • 这就是为什么在并发编程中,不能仅靠 volatile 保证所有操作的顺序。
  • 普通变量之间的操作,如果没有和 volatile 变量的读写发生依赖,仍然可能被重排序。
  • public class VolatileReorderDemo {
        int a = 0;
        int b = 0;
        volatile int v = 0;
        /**
    JMM 只保证:a = 1 和 b = 2 这两句不会被重排序到 v = 3 之后。
    但a = 1 和 b = 2 之间的顺序,JVM 和 CPU 仍然可以重排序(比如先执行 b = 2,再执行 a = 1),因为它们之间没有和 volatile 变量的依赖。
    */
        public void writer() {
            a = 1;        // 1
            b = 2;        // 2
            v = 3;        // 3
        }
    }
    //有依赖时不会重排序
    public void writer() {
        a = 1;
        v = 2;      // volatile 写
        b = a;      // 依赖 a
    }


网站公告

今日签到

点亮在社区的每一天
去签到