参考文章: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++)编写的。以下是本地方法栈中存放的一些方法引用以及实际例子:
- 本地方法的引用:
- 本地方法栈会存放调用的本地方法的引用,这些方法是通过 Java Native Interface (JNI) 进行连接的。
- 参数和返回值:
- 在调用本地方法时,方法的参数和返回值也会在本地方法栈中进行管理。
- 异常处理信息:
- 如果本地方法抛出异常,相关的异常处理信息也会在本地方法栈中保存。
实际例子
- JNI 示例:
- 假设我们有一个 Java 方法需要调用一个用 C 语言实现的本地方法:
public class NativeExample { static { System.loadLibrary("nativeLib"); // 加载本地库 } public native int computeSum(int a, int b); // 声明本地方法 }
- 在这个例子中,
computeSum
是一个本地方法,调用时会在本地方法栈中存放该方法的引用,以及参数a
和b
的值。
- 假设我们有一个 Java 方法需要调用一个用 C 语言实现的本地方法:
- 文件操作:
- 另一个常见的例子是文件操作,Java 可能会调用本地方法来直接与操作系统交互,例如读取文件:
public native void readFile(String path);
- 当调用
readFile
方法时,本地方法栈会保存path
参数的引用,并处理与文件系统的交互。
- 另一个常见的例子是文件操作,Java 可能会调用本地方法来直接与操作系统交互,例如读取文件:
- 图形处理:
- Java 可能调用本地方法来执行图形处理任务,例如使用 OpenGL:
public native void renderGraphics();
- 这里,
renderGraphics
方法可能会在本地方法栈中使用 OpenGL 的调用来渲染图形。
- Java 可能调用本地方法来执行图形处理任务,例如使用 OpenGL:
总结
本地方法栈的主要作用是管理 Java 与非 Java 代码之间的交互,确保参数传递、返回值处理和异常管理的正确性。通过 JNI,Java 可以利用底层系统的功能,从而实现更高效的操作。
程序计数器
存放执行的字节码指令的地址
为什么程序计数器不会OOM?
程序计数器(Program Counter Register)是 Java 虚拟机(JVM)中的一个重要内存区域,它的主要作用是记录当前线程所执行的字节码的地址。以下是程序计数器不会导致内存溢出(Out of Memory, OOM)的原因:
- 内存使用量小
程序计数器的内存使用量非常小,通常只有几百字节(在 32 位和 64 位系统中都相对较小)。因为它只用来存储当前线程执行的字节码指令的地址,所以即使有很多线程,程序计数器的总内存占用也不会很高。
- 每个线程独立
每个线程都有自己的程序计数器,这意味着每个线程的计数器是独立的,不会相互影响。即使创建了大量线程,每个线程的程序计数器依然保持独立,且其内存需求相对较小。
- 不需要动态分配
程序计数器的内存分配是在 JVM 启动时完成的,而不是在运行时动态分配的。这意味着它不受 Java 堆内存或其他动态内存区域的限制,因此不会因为内存不足而导致 OOM。
- GC 不管的内存区域
程序计数器并不参与垃圾回收(Garbage Collection),因为它的生命周期与线程相同。只要线程存在,程序计数器就会存在,并且其内存不会被回收或重新分配。
总结 由于程序计数器的内存占用极小、每个线程独立、内存分配方式以及不参与垃圾回收等特性,使得程序计数器几乎不可能导致内存溢出。因此,它被认为是唯一一个不会发生 OOM 的内存区域。
程序计数器在方法调用过程中的变化
程序计数器的内容在方法调用时会被重置为目标方法的字节码位置。 即:在从方法A内部调用方法B时,程序计数器的内容是被重置为指向方法B的字节码起始位置,而不是在原来的基础上新增的方法B的字节码。 在方法调用时,原来的位置会被保存到新的调用栈中的栈帧中。每当一个方法被调用时,JVM会为该方法创建一个新的栈帧,并在这个栈帧中保存一些关键信息,包括:
- 局部变量表:用于存储方法参数和局部变量。
- 操作数栈:用于存储计算过程中的临时数据。
- 动态链接:用于支持方法的动态解析。
- 方法返回地址:也就是“当前指令的保存”,用于在方法执行完毕后返回到调用该方法的位置。
具体步骤
- 方法A调用方法B:
- 当
methodA
调用methodB
时,JVM会创建一个新的栈帧来存储methodB
的相关信息。 - 在创建
methodB
的栈帧时,JVM会将methodA
中调用methodB
的指令地址(即程序计数器的当前值)保存到methodB
的栈帧中。
- 当
- 程序计数器更新:
- 当前指令的保存:在调用方法B之前,程序计数器会记录方法A中当前指令的位置保存到
methodB
的栈帧中,以便在方法B执行完毕后能够返回到正确的位置。 - 重置为方法B的位置:程序计数器的值会被更新为方法B的起始位置,指向方法B的字节码。
- 程序计数器的内容会被重置为
methodB
的字节码起始位置,指向methodB
的指令。
- 当前指令的保存:在调用方法B之前,程序计数器会记录方法A中当前指令的位置保存到
- 方法B执行:
methodB
执行完毕后,JVM会根据methodB
的栈帧中的返回地址(即之前保存的methodA
中调用methodB
的指令地址)来恢复程序计数器的值。
- 返回到方法A:
- 程序计数器恢复到原来的位置,继续执行
methodA
中的后续指令。
- 程序计数器恢复到原来的位置,继续执行
总结
因此,原来的位置被保存到调用栈的栈帧中,通过返回地址实现方法调用的正确返回。这种机制确保了方法调用和返回的逻辑能够正常进行。
堆
JVM中内存分配得最大的一块区域,当对象被实例化后会被存存储到堆中,GC也发生在堆中[[2024-09-21-JVM中的垃圾回收机制]]。 堆唯一的作用就是存放对象(不同线程的对象都会存放在这里),但是并不是所有的对象都会被存放在堆中,例如String字符串的对象[[2024-09-21-JVM中String对象内存]]
方法区
简介
方法区可以理解为一种JVM存放类元信息的一种规范,类似于接口的概念,而不管是永久代或是元空间都是一种对这个概念的实现。
用于存放类的元数据的,当【类加载器把类加载到内存当中】的【内存】就是指方法区。方法区可以理解为存放类的一些元数据,例如类属性等。 方法区主要用来存放类的版本,字段,方法,接口、常量池和运行时常量池。 常量池里存储着字面量和符号引用。
方法区中包括什么
- 常量池
- 静态变量
- 运行时常量池
- 类信息:类名称,访问修饰符,父类与接口
- 方法的字节码文件
- 类和方法的符号引用
- 常量池缓存
符号引用是什么
- 符号引用是指在编译时使用字符串(如类名、方法名和字段名等)来表示某个类、方法或字段,而不是直接使用内存地址。这种引用方式在类的加载和链接过程中被解析为直接引用。
- 符号引用通常包含以下信息:
- 类名:表示引用的类。
- 方法名:表示引用的方法。
- 字段名:表示引用的字段。
- 描述符:用于描述方法的参数和返回值类型。
- 记忆:System::println();
元数据的内存划分
方法区中方法的执行过程
- 符号引用的解析调用
如果是初次加载该类:
- 类加载:类加载器使用双亲委派模式将已经编译好的Java字节码文件加载到方法区当中
- 方法区查找:在方法区中查找这个符号引用的实际地址
- 链接:解析为直接引用,调用这个方法的实际地址,并缓存该地址
【备注】方法的字节码文件通常是编译后的
.class
文件,存储在Java的类路径中(如JAR文件、目录等)。当JVM加载类时,它会读取这些字节码文件并将其存储在方法区中。
-
栈帧的创建 在调用方法前JVM虚拟机会给线程的栈中分配一个栈帧,其中包括局部变量表、操作数栈、动态链接、方法出口等
-
方法的实际调用 程序计数器会更新对应的字节码运行位置,线程会执行对应的字节码文件,执行方法时会包括变量的读取、操作数栈的操作、对象的创建、方法的调用等
- 结果返回 如果有返回值会返回给上一个方法,并销毁这个栈帧
方法区和栈的对比
- 存放内容的性质:
- 方法区:主要存放类的结构信息,包括类的版本、字段、方法、接口、常量池和运行时常量池等。这些信息是与类本身相关的,属于类的元数据。
- 栈区:主要存放方法的局部变量、操作数栈、动态链接和方法返回地址等。栈中的信息是与方法的执行过程直接相关的,是临时的、动态的。
- 生命周期:
- 方法区:类的信息在类被加载时存入方法区,直到类被卸载之前,这些信息是相对稳定的。
- 栈区:栈中的数据随着方法的调用和返回而变化,生命周期较短,方法执行完毕后,栈中的数据会被清除。
- 线程共享:
- 方法区:可以被多个线程共享,所有线程都可以访问同一份类信息。
- 栈区:每个线程都有自己的栈,栈中的数据是线程私有的,不同线程之间无法直接共享。
总结来说,方法区存放的是类的静态信息,而栈区存放的是方法的动态执行信息,两者在性质、生命周期和线程共享方面都有显著的区别。
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();
}
}
内存存储分析
- 方法区:
- 当
MyClass
类被加载时,类的结构信息(如字段value
、构造方法MyClass(int value)
和方法display()
)会被存放在方法区。这些信息是共享的,所有线程都可以访问。
- 当
- 堆区:
- 当
thread1
创建MyClass
的实例obj1
时,new MyClass(10)
这行代码会在堆中分配内存,存储obj1
的对象。 - 同样,当
thread2
创建MyClass
的实例obj2
时,new MyClass(20)
也会在堆中分配内存,存储obj2
的对象。 - 这两个对象
obj1
和obj2
在堆中是独立的,尽管它们的类信息在方法区是共享的。
- 当
- 栈区:
- 每个线程都有自己的栈。当
thread1
调用obj1.display()
时,obj1
的引用会被存储在thread1
的栈中。 - 同样,
thread2
在调用obj2.display()
时,obj2
的引用会被存储在thread2
的栈中。 - 此时,栈中存储的是对堆中对象的引用,而不是对象本身。
- 每个线程都有自己的栈。当
总结
- 方法区:存储
MyClass
的结构信息(共享)。 - 堆区:存储
obj1
和obj2
两个对象的实际数据(每个线程独有)。 - 栈区:存储
thread1
和thread2
中对obj1
和obj2
的引用(每个线程独有)。
这样,通过这个例子,你可以清楚地看到在不同的内存区域中存储了哪些数据,以及它们之间的关系。
Java内存模型
简介
java内存模型定义了变量的访问规则,确保在多线程情况下也可以保证程序执行的原子性、有序性、可见性。 Java内存模型,JMM屏蔽了操作系统和硬件的差异,使得在各种平台下的并发效果都能保持一致。
原子性
原子性指的是某个变量的操作是不可分割的,要么都完全执行,要么完全不执行。在java中变量的单一操作(读写操作)是原子性的,但复合操作不是。 例如count++操作,在操作系统中并不是原子性的,它需要先读数,+1,再返回,如果在这过程中遇到了线程的切换,当前线程的上下文信息就会暂时保存到PCB当中去,且这个count++操作会被中断,
可见性
确保一个线程中对共享变量的修改可以及时的被其他线程看到。可以使用volatile关键字、synchronized块以及concurrent包下的其他方法来实现。 volatile关键字使得其他线程强制读取共享区的变量,而不是读取自己的缓存。
有序性
有序性保证了代码的执行顺序和书写顺序是一致的。在类加载的时候Java内存模型会允许编译器对指令顺序进行优化重排,java中提供了一些机制保证在多线程环境下执行顺序的有序性,提供了例如volatile变量和synchronized块。
实例说明多线程环境下的三特性
当然可以!下面我将通过实际的例子来说明Java内存模型如何通过volatile
、synchronized
块以及其他并发工具来保证多线程环境下的原子性、可见性和有序性。
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。
- 线程A读取
- 最终,
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
方法中,a
和b
的写入顺序是有序的。即使在多线程环境中,使用synchronized
可以确保write
方法中的操作不会被重排序,保证了有序性。
失去原子性和有序性
- 如果
write
方法没有使用synchronized
关键字,多个线程同时调用这个方法时,会导致原子性和有序性的问题。 a = 1
和b = 2
这两个操作可能会被重排序,或者在一个线程写入时,另一个线程可能会读取到不一致的状态。例如:- 线程A执行
a = 1
,然后被中断。 - 线程B执行
read
,此时a
已经被设置为1,但b
仍然是0。 - 线程B打印的结果可能是
a: 1, b: 0
,这就是不一致的状态。
- 线程A执行
- 由于没有
synchronized
,也无法保证在write
方法执行完成前,其他线程不能访问这个方法,这可能导致数据竞争(data race)和不可预期的结果。总结
- 原子性:通过
synchronized
确保对共享变量的操作是不可分割的。 - 可见性:通过
volatile
确保一个线程对变量的修改能够被其他线程看到。 - 有序性:通过
synchronized
确保代码执行的顺序与其书写顺序一致,避免了重排序带来的问题。
这些特性结合在一起,使得Java能够在多线程环境中安全地访问和操作共享资源。