一段简单的 Java 程序,Test.java:
class Test {
public static void main(String[] args) {
System.out.println("Hello, world");
}
}
首先使用 javac
命令编译成 Test.class:
javac Test.java
然后通过 java
命令执行:
java Test
输出如下:
Hello, world
Java 程序在运行过程中,首先会将 .java 文件编译成 .class 字节码文件,Java 程序访问 Java 类时,会使用 ClassLoader 将 .class 文件加载到 JVM (Java visual machine)内存中。
JVM 中的内存可以划分为两大块:线程间共享区域和线程私有区域。线程间共享区域又分为堆、方法区,线程私有区域分为虚拟机栈、本地方法栈、程序计数器。
Java 内存模型一、堆
堆(Heap)是 JVM 所管理的内存中最大的一块,它的唯一目的就是存放对象实例。几乎所有的对象实例都是在堆上分配的,因此它也是 GC(Garbage Collector)管理的主要区域。因为堆是线程间共享的,所以分配在堆上的对象如果被多个线程同时访问,需要考虑线程安全问题。
堆中的内存可以划分为新生代和老年代,新生代又分为 Eden 区和 Survivor 区,在 GC 回收时,不同区域会采用不同的回收策略。
1.1 OutOfMemoryError
理论上,虚拟机栈、堆、方法区都有可能发生 OOM(OutOfMemoryError),但大多数情况下是发生在堆中。如以下代码:
import java.util.ArrayList;
import java.util.List;
class Test {
public static void main(String[] args) {
List<Test> tests = new ArrayList<>();
while (true) {
tests.add(new Test());
}
}
}
我们在一个死循环中不断 new 出对象,这会不断占用堆中的内存,当堆内存不够时,必然产生 OOM:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.Arrays.copyOf(Arrays.java:3721)
at java.base/java.util.Arrays.copyOf(Arrays.java:3690)
at java.base/java.util.ArrayList.grow(ArrayList.java:237)
at java.base/java.util.ArrayList.grow(ArrayList.java:242)
at java.base/java.util.ArrayList.add(ArrayList.java:485)
at java.base/java.util.ArrayList.add(ArrayList.java:498)
at Test.main(Test.java:8)
二、方法区
方法区(Method Area)主要用来存储 JVM 中已经加载的类信息(版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码和数据。
方法区只是 JVM 规范中规定的一块区域,并不是实际实现。HotSpot 在 JDK 1.7 之前使用 “永久区”(Perm 区)来实现方法区,在 JDK 1.8 之后 “永久区”就被移除了,取而代之的是一种叫做“元空间”(metaspace)的实现方式。
三、虚拟机栈
虚拟机栈用来描述 Java 方法执行的内存模型,每个方法被执行的时候,JVM 都会在线程中为这个方法创建一个栈帧。所有的栈帧都被放在虚拟机栈中。
3.1 栈帧
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的一种数据结构。
一个线程包含多个栈帧,每个栈帧内部主要包含局部变量表、操作数栈、动态链接、返回地址。
3.1.1 局部变量表
顾名思义,局部变量表用于存储方法内部创建的局部变量,包括调用方法时传递过来的实参。在 .java 文件被编译成 .class 文件时,就会在方法的 Code 属性表中的 max_locals 数据项中,确定该方法需要分配的最大局部变量表的容量。
比如,我们创建一个 Test.java 文件:
class Test {
public static int add(int k) {
int i = 1;
int j = 2;
return i + j + k;
}
}
先使用 javac Test.java
编译出 .class 文件,然后使用 javap -v Test.class
查看字节码:
class Test
minor version: 0
major version: 56
flags: (0x0020) ACC_SUPER
this_class: #2 // Test
super_class: #3 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #3.#12 // java/lang/Object."<init>":()V
#2 = Class #13 // Test
#3 = Class #14 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 add
#9 = Utf8 (I)I
#10 = Utf8 SourceFile
#11 = Utf8 Test.java
#12 = NameAndType #4:#5 // "<init>":()V
#13 = Utf8 Test
#14 = Utf8 java/lang/Object
{
Test();
descriptor: ()V
flags: (0x0000)
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public static int add(int);
descriptor: (I)I
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: iload_0
8: iadd
9: ireturn
LineNumberTable:
line 3: 0
line 4: 2
line 5: 4
}
SourceFile: "Test.java"
其中的 locals=3
就表示局部变量表的长度是 3,也就是说经过编译后,局部变量表的长度已经确定是 3,分别保存参数 i、j、k。
3.1.2 操作数栈
操作数栈(Operand Stack)也常被称为操作栈,与局部变量表一样,操作数栈的最大深度在编译后就确定了。对应上例中的 stack=2
,栈中的元素可以是任意 Java 数据类型。
当一个方法刚刚开始执行时,操作数栈是空的,在方法执行过程中,会有各种字节码指令被压入和弹出操作数栈。
在上例中,各指令的含义如下:
- iconst_1 将常量 1 压入操作数栈。int 取 -1~5 时采用 iconst,否则取 -128~127 时采用 bipush,否则取 -32768~32767 时采用 sipush 指令,否则采用 ldc 指令。
- istore_1 将操作数栈顶元素出栈,放入局部变量表索引为 1 的位置
- iload_1 将局部变量表中索引为 1 的值压入操作数栈
- iadd 将操作数栈栈顶的两个元素出栈,相加后重新压入栈顶
- ireturn 指令,将操作数栈顶的元素返回给上层方法。至此方法执行完毕,局部变量表和操作数栈也会相继被销毁。
3.1.3 动态链接
在一个 .class 文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其所在内存地址中的直接引用,而符号引用存在于方法区中。
Java 虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的就是为了支持方法调用过程中的动态链接(Dynamic Linking)。
3.1.4 返回地址
当一个方法执行后,有两种方式退出:
- 正常退出:代码执行完成或者遇到 return 等方法返回指令
- 异常退出:执行时遇到未处理的异常
无论哪种方式退出,方法退出后都需要返回到方法被调用的位置,程序才能继续执行(比如发生异常时,可以在上层调用处再 catch 此异常)。“返回地址”就是用来记录当前方法返回地址的。
3.1.5 StackOverflowError
当方法无限递归调用时,就会出现 StackOverflowError。如以下代码:
class Test {
public static void main(String[] args) {
main(null);
}
}
运行时就会出现以下错误:
Exception in thread "main" java.lang.StackOverflowError
at Test.main(Test.java:3)
at Test.main(Test.java:3)
at Test.main(Test.java:3)
...
前文说到,每运行一个方法,线程中都会创建一个栈帧。在这个递归调用中,每个方法都没有运行结束,所以每个方法都不会退出,所以每个创建出的栈帧都不会被销毁,最终必然导致 StackOverflowError。
四、本地方法栈
本地方法栈(Native Method Stack)和虚拟机栈基本相同,只不过是针对 native 方法,在 HotSpot 中已经将本地方法栈和虚拟机栈合二为一了。
五、程序计数器
Java 程序是多线程的,CPU 可以在多个线程中分配执行时间片段。当某一个线程被挂起时,需要记录当前线程正在执行的位置,以便 CPU 重新执行此线程时,知道从何处开始。程序计数器就是用来记录当前线程正在执行的位置的。
程序计数器中没有规定 OOM 异常,当一个线程正在执行 Java 方法时,程序计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行 native 方法,这个计数器的值为空(Undefined)。
Java 内存模型详情
网友评论