Java 虚拟机(英语:Java Virtual Machine,缩写为JVM)屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行,通过对中央处理器(CPU)所执行的软件实现,实现能执行编译过的Java程序码

Java 内存区域

程序计数器

程序计数器是一块较小内存空间,可以看作是当前线程所执行的字节码的行号指示器,在虚拟机的概念模型中,字节码解释器就是通过改变计数器的值选取下一条需要执行的字节码执行,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖该计数器完成

多线程是通过线程轮流切换并分配处理器执行时间的方式实现的,同一时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令,为了保证线程切换后,能够恢复到正确的执行位置,每条线程都有一个独立的程序计数器,这类内存区域为线程私有的内存。

线程如果执行的是 Java 方法,该计数器记录的是正在执行的虚拟机字节码指令地址,如果是 Native 方法计数器值则为空(Undefined),该内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域

Java 虚拟机栈

Java Virtual Machine Stacks,该栈也是线程私有的,生命周期和线程相同。虚拟机栈描述的是 Java 方法执行的内存模式:每个方法都会在执行的同时创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息,方法从调用直至执行完成的过程,对应着一个栈帧在虚拟机栈中入栈到出栈的过程,经常提到的栈内存就是说的虚拟机栈,或者说是虚拟机栈中的局部变量表部分

局部变量表存放了编译期可知的各种基础数据类型reference类型returnAddress类型,其中64位长度的 long 和 double 类型的数据会占用2个局部变量空间,其余只占用1个,局部变量表所需内存空间在编译期间分配完成,进入一个方法时,该方法需要在帧中分配多大局部变量空间已经确定,在方法运行期间不会改变局部变量表的大小

在 Java 虚拟机规范中,对该区域规定了两种异常状况:

  • 线程请求的栈深度大于虚拟机允许的深度,将抛出 StackOverFlowError 异常
  • 如果虚拟机栈支持动态扩展,扩展时无法申请到足够内存,将抛出 OutOfMemoryError 异常

本地方法栈

Native method stack,虚拟机栈为虚拟机执行的 Java 方法服务,本地方法栈为 Native 方法服务,sun hotspot虚拟机就把这两个栈区域合二为一,该栈同样会抛出虚拟机栈的两个异常

Java堆

Java heap,Java虚拟机管理的最大的一个区域,用来存放对象实例,被所有线程共享。

Java虚拟机规范规定:所有对象实例和数组都要在堆上分配。随着栈上分配和标量替换等虚拟机优化技术的出现,堆上分配的说法也不是那么绝对了

Java堆是GC管理的最主要区域之一,堆又分为新生代和老年代,在GC使用Copying算法时,新生代又分为Edan space,From survivor space,To survivor space

Java堆可以处于物理上不连续,但逻辑上连续的内存空间,就像电脑磁盘空间一样

可以通过以下参数扩展虚拟机内存:

  • Xms-初始化堆大小
  • Xmx-java heap最大值
  • Xmn-年轻代堆大小
  • Xss-每个线程的栈大小

如果堆中没有足够的内存空间分配给实例,并且无法扩展时将会抛出 OutOfMemoryError 异常

方法区

Method area,为了和 Java 堆区分,它还可以叫做Non-Heap(非堆)。各个线程共享的内存区域,用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译出的代码等

该区域还有一个常叫的名字:永久代(Permanent Generation),这种叫法不太准确,本质上两者并不等价,在HotSpot虚拟机为了可以像管理 Java 堆一样管理该区域,使用GC分代收集中永久代实现方法区,省去了编写内存管理代码的工作。

-XX:MaxPermSize 可以指定永久代的内存上限,在JDK8之前的HotSpot虚拟机中,32位机器默认的永久代的大小为64M,64位的机器则为85M。永久代的垃圾回收和老年代的垃圾回收是绑定的,一旦其中一个区域被占满,这两个区都要进行垃圾回收。但是有一个明显的问题,由于我们可以设置永久代的大小,一旦类的元数据超过了设定的大小,程序就会耗尽内存,并出现内存溢出错误

从 JDK1.7 开始已经逐步移除永久代,在 JDK1.7 中存储在永久代的部分数据就已经转移到了Java Heap 或者是 Native Heap。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了native heap,字面量(interned strings)转移到了java heap,类的静态变量(class statics)转移到了java heap

在 JDK1.8 中,HotSpot 虚拟机已经没有 PermGen space 这个区域了,取而代之的是 Metaspace(元空间)

元空间

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:

  • -XX:MetaspaceSize:初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值
  • -XX:MaxMetaspaceSize:最大空间,默认是没有限制的

除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:

  • -XX:MinMetaspaceFreeRatio:在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
  • -XX:MaxMetaspaceFreeRatio:在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分,Class 文件除了有类的版本、字段、方法、接口等描述信息,还有一项信息是常量池,用于存放编译期生成的各种字面量符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

运行时常量池相对于 Class 文件常量池的一个重要特征就是具备动态性,Java 语言并非只有预置到 Class 文件常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量加到池中,这种特性被利用的比较多是 String 类的 intern() 方法(想要了解该方法的同学请戳这里

运行时常量池是方法区内的一部分,自然也收到了方法区大小的限制,无法申请到内存时同样也会抛出 OutOfMemoryError 异常

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域,这块区域也被频繁使用,也可能抛出 OutOfMemoryError 异常

JDK 1.4之后加入了NIO(new Input/Output)类,引入一种基于通道(channel)和缓冲区(Buffer)的 I/O 方式(想要了解 NIO 更加形象的介绍请戳这里),它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作,因为避免了在Java 堆和 Native 堆中来回复制数据,在一些情况下能够显著的提升性能

直接内存不会受到 Java 堆大小限制,但是会受到本机总内存大小以及处理器寻址空间的限制,在配置虚拟机参数时,如果忽略了直接内存,使各个内存区域总和物理内存限制,在动态扩展时就会出现 OutOfMemoryError 异常

基础数据类型

基础数据类型包括booleanbytecharshortintfloatlongdouble

reference类型

1
A a = new A();

该类型就是常见的引用类型,它并不等同于a对象本身, 可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或与此对象相关的位置

在程序设计中,句柄(handle)是一种特殊的智能指针。 当一个应用程序要引用其他系统(如数据库、操作系统)所管理的内存块或对象时,就要使用句柄

returnAddress类型


该类型指向一条虚拟机指令的操作码地址,与其他类型不同的是,该类型没有对应的 Java 语言类型

栈深度


可以简单的理解栈为数组,栈的深度,理解为数组的长度

字面量

字面量是指由文字所表示的取值

1
2
3
final int i = 100;
String s = "hello world";
// 100 和 "hello world" 都可以叫做字面量,i 是常量,s 是变量

符号引用

在JVM中,类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段

而解析阶段即是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用以一组符号描述所引用的对象,符号可以是任何形式的字面量,只要该组符号使用时具有唯一性并且能够无歧义定位到目标即可,符号引用与虚拟机的内存布局无关,引用的目标并不一定要加载到内存中,当一个 Java 类编译成 Class 文件时,Java 类中并不知道自己类中所引用的那些类或者接口的实际地址,因此只能使用符号引用代替,各种虚拟机内存布局可能不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式已经明确定义在 Java 虚拟机规范的 Class 文件格式中

举个例子,更直观的理解符号引用:

1
2
3
4
5
6
7
8
9
public interface K {
public static String str = "abc";
public static int i = new Random().nextInt();
}

public class Test {
public static String i = K.str;
public static int i = K.i;
}

使用 javac 命令把上面两个 Java 文件编译成 Class 文件,然后使用 javap 命令反编译和查看编译器编译后的字节码,加上 -verbose 参数可以输出栈大小,方法参数个数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# 进入文件所在目录
$ javac Test.java
$ javap -verbose Test

Classfile /Users/Kevin/IDEAProjects/Test/src/Test.class
Last modified 2017-6-27; size 348 bytes
MD5 checksum 740d6b24ac085239bccf8ed09980b232
Compiled from "Test.java"
public class Test
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #8.#20 // java/lang/Object."<init>":()V
#2 = Class #21 // K
#3 = String #22 // abc
#4 = Fieldref #7.#23 // Test.ss:Ljava/lang/String;
#5 = Fieldref #2.#24 // K.i:I
#6 = Fieldref #7.#25 // Test.ii:I
#7 = Class #26 // Test
#8 = Class #27 // java/lang/Object
#9 = Utf8 ss
#10 = Utf8 Ljava/lang/String;
#11 = Utf8 ii
#12 = Utf8 I
#13 = Utf8 <init>
#14 = Utf8 ()V
#15 = Utf8 Code
#16 = Utf8 LineNumberTable
#17 = Utf8 <clinit>
#18 = Utf8 SourceFile
#19 = Utf8 Test.java
#20 = NameAndType #13:#14 // "<init>":()V
#21 = Utf8 K
#22 = Utf8 abc
#23 = NameAndType #9:#10 // ss:Ljava/lang/String;
#24 = NameAndType #28:#12 // i:I
#25 = NameAndType #11:#12 // ii:I
#26 = Utf8 Test
#27 = Utf8 java/lang/Object
#28 = Utf8 i
{
public static java.lang.String ss;
descriptor: Ljava/lang/String;
flags: ACC_PUBLIC, ACC_STATIC

public static int ii;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC

public Test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 6: 0

static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: ldc #3 // String abc
2: putstatic #4 // Field ss:Ljava/lang/String;
5: getstatic #5 // Field K.i:I
8: putstatic #6 // Field ii:I
11: return
LineNumberTable:
line 7: 0
line 8: 5
}
SourceFile: "Test.java"

可以看到有很多信息,其中有一个 Constant pool 就是Class 文件常量池,下表列出了所有常量池标志的名字和值:

Entry Type Description
ONSTANT_Utf8 UTF-8 编码的 Unicode 字符串
CONSTANT_Integer int 字面量
CONSTANT_Float float 字面量
CONSTANT_Long long 字面量
CONSTANT_Double double 字面量
CONSTANT_Class class 或者 interface 符号引用
CONSTANT_String String 字面量
CONSTANT_Fieldref field 符号引用
CONSTANT_Methodref method 符号引用
CONSTANT_InterfaceMethodref interface 中 method 的符号引用
CONSTANT_NameAndType 方法和字段的一部分符号引用

每一个标志都有一个相对应的表,表名通过在标志后加上“_info”后缀来产生。例如,对应于CONSTANT_Class标志的表名为CONSTANT_Class_info,表名为CONSTANT_Utf8_info的表中存储着Unicode字符串的压缩形式

对应着此表再来看 javap -verbose Test.class 中的信息,看到在初始化 Test 中的 ii 和 ss 的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: ldc #3 // String abc
2: putstatic #4 // Field ss:Ljava/lang/String;
5: getstatic #5 // Field K.i:I
8: putstatic #6 // Field ii:I
11: return
LineNumberTable:
line 7: 0
line 8: 5

因为 K 接口中的 str 字段是在编译期就可以确定的常量值,所有当 Test 引用该常量的时候,虚拟机直接把 str 的 “abc” 直接复制了一份到到 Test 的常量池中,#3就是该值得位置,但是当初始化 Test 的 ii 变量时,从常量池#5项中取值,常量池的第5项是一个符号引用,指向K的i字段。i的值只能在运行的时候才能确定,在运行的时候会将符号引用解析为指向i内存地址的直接引用

处理器寻址空间

数据在存储器(RAM)中存放的数据是有规律的,CPU 取数据时需要知道数据的位置,此时需要一个个数据的挨个寻找,这种行为叫做寻址,但是 CPU 的能力始终有限,超出 CPU 能力范围的数据就取不到了,CPU 寻址最大范围就是寻址空间,单位是字节