内存模型

Posted by lily's blog on September 21, 2024

参考文章:JVM内存模型

JVM内存模型

JVM内存模型图

栈stack

栈中的内容

在 Java 虚拟机(JVM)中,栈帧是用于管理方法调用的结构,其中包含几个重要的组成部分,包括局部变量表、操作数栈、动态链接和方法出口。以下是它们的定义和作用:

1. 操作数栈

(Operand Stack)**:

  • 操作数栈是栈帧中的一个区域,用于存储方法执行过程中产生的中间结果。每当执行指令时,操作数栈用于保存操作数和计算结果
  • 例如,在执行加法操作时,两个操作数会被推入操作数栈,计算完成后,结果会被推回栈中。

2. 动态链接

(Dynamic Linking)**:

  • 动态链接是指在运行时将方法调用与具体实现进行关联的过程。每个栈帧中包含一个指向方法区中方法的引用,这样可以在方法调用时动态地找到对应的方法实现。
  • 这使得 Java 支持多态性和动态绑定,允许在运行时决定调用哪个方法。

3.方法出口

(Method Exit)**:

  • 方法出口是指在方法执行完毕后,如何返回到调用该方法的位置。栈帧中保存了返回地址(即调用该方法的下一条指令的位置),以便在方法执行完成后能够正确返回。
  • 当一个方法执行完毕时,栈帧会被弹出,控制权会返回到调用该方法的地方。

    4. 局部变量表

本地方法栈

本地方法栈中的内容

native 关键字使 Java 能够与其他编程语言的代码进行交互,从而扩展 Java 的功能和性能 本地方法栈主要用于支持 Java 调用本地方法(Native Method),这些方法通常是用其他语言(如 C 或 C++)编写的。以下是本地方法栈中存放的一些方法引用以及实际例子:

  1. 本地方法的引用
    • 本地方法栈会存放调用的本地方法的引用,这些方法是通过 Java Native Interface (JNI) 进行连接的。
  2. 参数和返回值
    • 在调用本地方法时,方法的参数和返回值也会在本地方法栈中进行管理。
  3. 异常处理信息
    • 如果本地方法抛出异常,相关的异常处理信息也会在本地方法栈中保存。

实际例子

  1. JNI 示例
    • 假设我们有一个 Java 方法需要调用一个用 C 语言实现的本地方法:
      public class NativeExample {
          static {
              System.loadLibrary("nativeLib"); // 加载本地库
          }
      
          public native int computeSum(int a, int b); // 声明本地方法
      }
      
    • 在这个例子中,computeSum 是一个本地方法,调用时会在本地方法栈中存放该方法的引用,以及参数 ab 的值。
  2. 文件操作
    • 另一个常见的例子是文件操作,Java 可能会调用本地方法来直接与操作系统交互,例如读取文件:
      public native void readFile(String path);
      
    • 当调用 readFile 方法时,本地方法栈会保存 path 参数的引用,并处理与文件系统的交互。
  3. 图形处理
    • Java 可能调用本地方法来执行图形处理任务,例如使用 OpenGL:
      public native void renderGraphics();
      
    • 这里,renderGraphics 方法可能会在本地方法栈中使用 OpenGL 的调用来渲染图形。

总结

本地方法栈的主要作用是管理 Java 与非 Java 代码之间的交互,确保参数传递、返回值处理和异常管理的正确性。通过 JNI,Java 可以利用底层系统的功能,从而实现更高效的操作。

程序计数器

存放执行的字节码指令的地址

为什么程序计数器不会OOM?

程序计数器(Program Counter Register)是 Java 虚拟机(JVM)中的一个重要内存区域,它的主要作用是记录当前线程所执行的字节码的地址。以下是程序计数器不会导致内存溢出(Out of Memory, OOM)的原因:

  1. 内存使用量小

程序计数器的内存使用量非常小,通常只有几百字节(在 32 位和 64 位系统中都相对较小)。因为它只用来存储当前线程执行的字节码指令的地址,所以即使有很多线程,程序计数器的总内存占用也不会很高。

  1. 每个线程独立

每个线程都有自己的程序计数器,这意味着每个线程的计数器是独立的,不会相互影响。即使创建了大量线程,每个线程的程序计数器依然保持独立,且其内存需求相对较小。

  1. 不需要动态分配

程序计数器的内存分配是在 JVM 启动时完成的,而不是在运行时动态分配的。这意味着它不受 Java 堆内存或其他动态内存区域的限制,因此不会因为内存不足而导致 OOM。

  1. GC 不管的内存区域

程序计数器并不参与垃圾回收(Garbage Collection),因为它的生命周期与线程相同。只要线程存在,程序计数器就会存在,并且其内存不会被回收或重新分配。

总结 由于程序计数器的内存占用极小、每个线程独立、内存分配方式以及不参与垃圾回收等特性,使得程序计数器几乎不可能导致内存溢出。因此,它被认为是唯一一个不会发生 OOM 的内存区域。

程序计数器在方法调用过程中的变化

程序计数器的内容在方法调用时会被重置为目标方法的字节码位置。 即:在从方法A内部调用方法B时,程序计数器的内容是被重置为指向方法B的字节码起始位置,而不是在原来的基础上新增的方法B的字节码。 在方法调用时,原来的位置会被保存到新的调用栈中的栈帧中。每当一个方法被调用时,JVM会为该方法创建一个新的栈帧,并在这个栈帧中保存一些关键信息,包括:

  1. 局部变量表:用于存储方法参数和局部变量。
  2. 操作数栈:用于存储计算过程中的临时数据。
  3. 动态链接:用于支持方法的动态解析。
  4. 方法返回地址也就是“当前指令的保存”,用于在方法执行完毕后返回到调用该方法的位置

具体步骤

  1. 方法A调用方法B
    • methodA调用methodB时,JVM会创建一个新的栈帧来存储methodB的相关信息。
    • 在创建methodB的栈帧时,JVM会将methodA中调用methodB的指令地址(即程序计数器的当前值)保存到methodB的栈帧中
  2. 程序计数器更新
    • 当前指令的保存:在调用方法B之前,程序计数器会记录方法A中当前指令的位置保存到methodB的栈帧中,以便在方法B执行完毕后能够返回到正确的位置。
    • 重置为方法B的位置:程序计数器的值会被更新为方法B的起始位置,指向方法B的字节码。
    • 程序计数器的内容会被重置为methodB的字节码起始位置,指向methodB的指令。
  3. 方法B执行
    • methodB执行完毕后,JVM会根据methodB的栈帧中的返回地址(即之前保存的methodA中调用methodB的指令地址)来恢复程序计数器的值。
  4. 返回到方法A
    • 程序计数器恢复到原来的位置,继续执行methodA中的后续指令。

总结

因此,原来的位置被保存到调用栈的栈帧中,通过返回地址实现方法调用的正确返回。这种机制确保了方法调用和返回的逻辑能够正常进行。

JVM中内存分配得最大的一块区域,当对象被实例化后会被存存储到堆中,GC也发生在堆中[[2024-09-21-JVM中的垃圾回收机制]]。 堆唯一的作用就是存放对象(不同线程的对象都会存放在这里),但是并不是所有的对象都会被存放在堆中,例如String字符串的对象[[2024-09-21-JVM中String对象内存]]

方法区

简介

方法区可以理解为一种JVM存放类元信息的一种规范,类似于接口的概念,而不管是永久代或是元空间都是一种对这个概念的实现。

用于存放类的元数据的,当【类加载器把类加载到内存当中】的【内存】就是指方法区。方法区可以理解为存放类的一些元数据,例如类属性等。 方法区主要用来存放类的版本,字段,方法,接口、常量池和运行时常量池。 常量池里存储着字面量和符号引用

方法区中包括什么

  1. 常量池
  2. 静态变量
  3. 运行时常量池
  4. 类信息:类名称,访问修饰符,父类与接口
  5. 方法的字节码文件
  6. 类和方法的符号引用
  7. 常量池缓存 符号引用是什么
    • 符号引用是指在编译时使用字符串(如类名、方法名和字段名等)来表示某个类、方法或字段,而不是直接使用内存地址。这种引用方式在类的加载和链接过程中被解析为直接引用。
    • 符号引用通常包含以下信息:
    • 类名:表示引用的类。
    • 方法名:表示引用的方法。
    • 字段名:表示引用的字段。
    • 描述符:用于描述方法的参数和返回值类型。 - 记忆:System::println();

      元数据的内存划分

方法区中方法的执行过程

  1. 符号引用的解析调用 如果是初次加载该类:
    1. 类加载:类加载器使用双亲委派模式将已经编译好的Java字节码文件加载到方法区当中
    2. 方法区查找:在方法区中查找这个符号引用的实际地址
    3. 链接:解析为直接引用,调用这个方法的实际地址,并缓存该地址 【备注】方法的字节码文件通常是编译后的.class文件,存储在Java的类路径中(如JAR文件、目录等)。当JVM加载类时,它会读取这些字节码文件并将其存储在方法区中。
  2. 栈帧的创建 在调用方法前JVM虚拟机会给线程的栈中分配一个栈帧,其中包括局部变量表、操作数栈、动态链接、方法出口等

  3. 方法的实际调用 程序计数器会更新对应的字节码运行位置,线程会执行对应的字节码文件,执行方法时会包括变量的读取、操作数栈的操作、对象的创建、方法的调用等

  4. 结果返回 如果有返回值会返回给上一个方法,并销毁这个栈帧

方法区和栈的对比

  1. 存放内容的性质
    • 方法区:主要存放类的结构信息,包括类的版本、字段、方法、接口、常量池和运行时常量池等。这些信息是与类本身相关的,属于类的元数据。
    • 栈区:主要存放方法的局部变量、操作数栈、动态链接和方法返回地址等。栈中的信息是与方法的执行过程直接相关的,是临时的、动态的。
  2. 生命周期
    • 方法区:类的信息在类被加载时存入方法区,直到类被卸载之前,这些信息是相对稳定的。
    • 栈区:栈中的数据随着方法的调用和返回而变化,生命周期较短,方法执行完毕后,栈中的数据会被清除。
  3. 线程共享
    • 方法区:可以被多个线程共享,所有线程都可以访问同一份类信息。
    • 栈区:每个线程都有自己的栈,栈中的数据是线程私有的,不同线程之间无法直接共享。

总结来说,方法区存放的是类的静态信息,而栈区存放的是方法的动态执行信息,两者在性质、生命周期和线程共享方面都有显著的区别。

String类的存储

String类的实际存储在字符串常量池中。

当创建一个String对象

当使用String s = new String("abc")方法时

  • 如果字符串常量池中有abc字符串对象,在堆中新建string对象并把引用指向常量池中的abc字符串
  • 如果没有,在字符串常量池中创建abc字符串,创建string对象

类被加载后的内存分配

通过一个简单的例子来说明当一个类被加载后,两个线程如何创建对象并调用方法,以及这些数据在内存中的存储位置。

示例代码

class MyClass {
    private int value;

    public MyClass(int value) {
        this.value = value;
    }

    public void display() {
        System.out.println("Value: " + value);
    }
}

public class Main {
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            MyClass obj1 = new MyClass(10);
            obj1.display();
        });

        Thread thread2 = new Thread(() -> {
            MyClass obj2 = new MyClass(20);
            obj2.display();
        });

        thread1.start();
        thread2.start();
    }
}

内存存储分析

  1. 方法区
    • MyClass 类被加载时,类的结构信息(如字段 value、构造方法 MyClass(int value) 和方法 display())会被存放在方法区。这些信息是共享的,所有线程都可以访问。
  2. 堆区
    • thread1 创建 MyClass 的实例 obj1 时,new MyClass(10) 这行代码会在堆中分配内存,存储 obj1 的对象。
    • 同样,当 thread2 创建 MyClass 的实例 obj2 时,new MyClass(20) 也会在堆中分配内存,存储 obj2 的对象。
    • 这两个对象 obj1obj2 在堆中是独立的,尽管它们的类信息在方法区是共享的。
  3. 栈区
    • 每个线程都有自己的栈。当 thread1 调用 obj1.display() 时,obj1 的引用会被存储在 thread1 的栈中。
    • 同样,thread2 在调用 obj2.display() 时,obj2 的引用会被存储在 thread2 的栈中。
    • 此时,栈中存储的是对堆中对象的引用,而不是对象本身。

总结

  • 方法区:存储 MyClass 的结构信息(共享)。
  • 堆区:存储 obj1obj2 两个对象的实际数据(每个线程独有)。
  • 栈区:存储 thread1thread2 中对 obj1obj2 的引用(每个线程独有)。

这样,通过这个例子,你可以清楚地看到在不同的内存区域中存储了哪些数据,以及它们之间的关系。

Java内存模型

简介

java内存模型定义了变量的访问规则,确保在多线程情况下也可以保证程序执行的原子性、有序性、可见性。 Java内存模型,JMM屏蔽了操作系统和硬件的差异,使得在各种平台下的并发效果都能保持一致。

原子性

原子性指的是某个变量的操作是不可分割的,要么都完全执行,要么完全不执行。在java中变量的单一操作(读写操作)是原子性的,但复合操作不是。 例如count++操作,在操作系统中并不是原子性的,它需要先读数,+1,再返回,如果在这过程中遇到了线程的切换,当前线程的上下文信息就会暂时保存到PCB当中去,且这个count++操作会被中断,

可见性

确保一个线程中对共享变量的修改可以及时的被其他线程看到。可以使用volatile关键字、synchronized块以及concurrent包下的其他方法来实现。 volatile关键字使得其他线程强制读取共享区的变量,而不是读取自己的缓存。

有序性

有序性保证了代码的执行顺序和书写顺序是一致的。在类加载的时候Java内存模型会允许编译器对指令顺序进行优化重排,java中提供了一些机制保证在多线程环境下执行顺序的有序性,提供了例如volatile变量和synchronized块。

实例说明多线程环境下的三特性

当然可以!下面我将通过实际的例子来说明Java内存模型如何通过volatilesynchronized块以及其他并发工具来保证多线程环境下的原子性、可见性和有序性。

1. 原子性

示例:使用 synchronized

class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });
        
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("Final count: " + counter.getCount()); // 应该是2000
    }
}

解释

  • increment方法被 synchronized修饰,确保同一时间只有一个线程可以执行这个方法,从而保证了对count变量的原子性操作。
    更新丢失
  • 如果increment方法没有被synchronized修饰,多个线程同时调用这个方法时,会出现竞态条件(race condition)。
  • count++实际上是一个复合操作,包括读取count的值、增加1、然后再写回。这三个步骤并不是原子的,可能会导致丢失更新的情况。例如:
    • 线程A读取count的值为0。
    • 线程B也读取count的值为0。
    • 线程A将count加1并写回1。
    • 线程B将count加1并写回1。
  • 最终,count的值应该是2,但由于没有同步,最终可能会得到1或者其他不正确的值。

    2. 可见性

示例:使用 volatile

class Flag {
    private volatile boolean flag = false;

    public void setFlag() {
        flag = true;
    }

    public boolean checkFlag() {
        return flag;
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Flag flag = new Flag();
        
        Thread t1 = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {}
            flag.setFlag();
            System.out.println("Flag set to true");
        });

        Thread t2 = new Thread(() -> {
            while (!flag.checkFlag()) {
                // 等待flag变为true
            }
            System.out.println("Flag is true");
        });
        
        t1.start();
        t2.start();
    }
}

解释

  • flag变量被声明为volatile,这确保了当一个线程修改flag的值时,其他线程能够立即看到这个变化,保证了可见性。
    不能及时看到更新
  • 如果flag没有被volatile修饰,可能会导致可见性问题。在多线程环境中,线程A对flag的修改可能不会被线程B立即看到。
  • 具体来说,线程B可能会从其本地缓存中读取flag的值,而不是从主内存中读取,导致它看到的值仍然是false,即使线程A已经将其设置为true关于类变量的说明
  • flag变量虽然是类变量(实例变量),但在多线程环境中,即使是类变量,编译器和处理器仍然可能对其进行优化,导致一个线程对变量的修改不会立即反映到其他线程中。
  • volatile关键字的作用在于,它强制所有线程从主内存中读取和写入该变量,确保了可见性。

总结

3. 有序性

示例:使用 synchronized

class SharedResource {
    private int a = 0;
    private int b = 0;

    public synchronized void write() {
        a = 1; // 先写a
        b = 2; // 后写b
    }

    public void read() {
        System.out.println("a: " + a + ", b: " + b);
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        SharedResource resource = new SharedResource();
        
        Thread writer = new Thread(() -> resource.write());
        Thread reader = new Thread(() -> resource.read());
        
        writer.start();
        Thread.sleep(100); // 确保writer先执行
        reader.start();
        
        writer.join();
        reader.join();
    }
}

解释

  • write方法中,ab的写入顺序是有序的。即使在多线程环境中,使用synchronized可以确保write方法中的操作不会被重排序,保证了有序性。
失去原子性和有序性
  • 如果write方法没有使用synchronized关键字,多个线程同时调用这个方法时,会导致原子性和有序性的问题
  • a = 1b = 2这两个操作可能会被重排序,或者在一个线程写入时,另一个线程可能会读取到不一致的状态。例如:
    • 线程A执行a = 1,然后被中断。
    • 线程B执行read,此时a已经被设置为1,但b仍然是0。
    • 线程B打印的结果可能是a: 1, b: 0,这就是不一致的状态。
  • 由于没有synchronized,也无法保证在write方法执行完成前,其他线程不能访问这个方法,这可能导致数据竞争(data race)和不可预期的结果。

    总结

  • 原子性:通过synchronized确保对共享变量的操作是不可分割的。
  • 可见性:通过volatile确保一个线程对变量的修改能够被其他线程看到。
  • 有序性:通过synchronized确保代码执行的顺序与其书写顺序一致,避免了重排序带来的问题。

这些特性结合在一起,使得Java能够在多线程环境中安全地访问和操作共享资源。