我们不能失去信仰

我们在这个世界上不停地奔跑...

0%

JVM内存模型

运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把所管理的内存划分为若干个不同的数据区域。Java 虚拟机所管理的内存包括以下几个运行时数据区域:

image-20190227172713322

Class文件通过类加载器进行加载后,不同的数据分布在不同的区域,然后经过执行引擎来进行执行。

一个线程的图示

image-20190227191910281

通过一个例子来分析一个线程的运行

可以使用 javap 进行反汇编 class 文件。

源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.enjoyms.study;

public class Test2 {

public static void main(String[] args) {
int result;
result = add();
System.out.println(result);
}

public static int add() {
int a;
int b;
int c;
a = 3;
b = 6;
c = (a + b) * 10;
return c;
}
}

通过 sublime 打开 Test2.class 文件如下:

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
cafe babe 0000 0037 0029 0a00 0600 1b0a
0005 001c 0900 1d00 1e0a 001f 0020 0700
2107 0022 0100 063c 696e 6974 3e01 0003
2829 5601 0004 436f 6465 0100 0f4c 696e
654e 756d 6265 7254 6162 6c65 0100 124c
6f63 616c 5661 7269 6162 6c65 5461 626c
6501 0004 7468 6973 0100 194c 636f 6d2f
656e 6a6f 796d 732f 7374 7564 792f 5465
7374 323b 0100 046d 6169 6e01 0016 285b
4c6a 6176 612f 6c61 6e67 2f53 7472 696e
673b 2956 0100 0461 7267 7301 0013 5b4c
6a61 7661 2f6c 616e 672f 5374 7269 6e67
3b01 0006 7265 7375 6c74 0100 0149 0100
0361 6464 0100 0328 2949 0100 0161 0100
0162 0100 0163 0100 0a53 6f75 7263 6546
696c 6501 000a 5465 7374 322e 6a61 7661
0c00 0700 080c 0014 0015 0700 230c 0024
0025 0700 260c 0027 0028 0100 1763 6f6d
2f65 6e6a 6f79 6d73 2f73 7475 6479 2f54
6573 7432 0100 106a 6176 612f 6c61 6e67
2f4f 626a 6563 7401 0010 6a61 7661 2f6c
616e 672f 5379 7374 656d 0100 036f 7574
0100 154c 6a61 7661 2f69 6f2f 5072 696e
7453 7472 6561 6d3b 0100 136a 6176 612f
696f 2f50 7269 6e74 5374 7265 616d 0100
0770 7269 6e74 6c6e 0100 0428 4929 5600
2100 0500 0600 0000 0000 0300 0100 0700
0800 0100 0900 0000 2f00 0100 0100 0000
052a b700 01b1 0000 0002 000a 0000 0006
0001 0000 0003 000b 0000 000c 0001 0000
0005 000c 000d 0000 0009 000e 000f 0001
0009 0000 0048 0002 0002 0000 000c b800
023c b200 031b b600 04b1 0000 0002 000a
0000 000e 0003 0000 0007 0004 0008 000b
0009 000b 0000 0016 0002 0000 000c 0010
0011 0000 0004 0008 0012 0013 0001 0009
0014 0015 0001 0009 0000 0058 0002 0003
0000 000e 063b 1006 3c1a 1b60 100a 683d
1cac 0000 0002 000a 0000 0012 0004 0000
000f 0002 0010 0005 0011 000c 0012 000b
0000 0020 0003 0002 000c 0016 0013 0000
0005 0009 0017 0013 0001 000c 0002 0018
0013 0002 0001 0019 0000 0002 001a

可以使用 javap 命令进行反编译成 JVM 指令。

javap -c Test2.class >> Test2.txt

生成的 Test2.txt 如下:

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

Compiled from "Test2.java"
public class com.enjoyms.study.Test2 {
public com.enjoyms.study.Test2();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public static void main(java.lang.String[]);
Code:
0: invokestatic #2 // Method add:()I
3: istore_1
4: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
7: iload_1
8: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
11: return

public static int add();
Code:
0: iconst_3
1: istore_0
2: bipush 6
4: istore_1
5: iload_0
6: iload_1
7: iadd
8: bipush 10
10: imul
11: istore_2
12: iload_2
13: ireturn
}

Code 部分的指令可以通过查询 JVM 指令集 来获取指令的含义。

简要执行图:

image-20190301160421783

局部变量表

局部变量表是用来存储变量的, 操作数栈是从局部变量表里取数据,然后提供数据进行运算的。

关于栈帧的执行顺序可以看这一篇文章,上图的顺序不代表真实的顺序。栈帧函数调用讲解

动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。因为 Class 文件常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候直接转化为直接引用,这种转化称为静态解析。另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。

符号引用和直接引用:可以粗略理解为符号引用的对象并不能直接使用,需要转化为直接引用才可以使用。

方法出口

方法退出有两种方式,一种是正常退出,一种是异常退出,无论采用何种方式退出,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助它的上层方法的执行状态。

程序计数器

程序计数器是一块较小的内存空间,它的作用是记录当前线程执行的字节码的行号。Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的。为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储。属于线程私有内存。

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

如上面的图,在将 .class 文件反编译成 JVM指令集,每一个 code 下面的指令都有编号,程序计数器就是保存当前执行到的行号,以便下次执行知道从哪里开始执行。

虚拟机栈

Java 虚拟机栈为线程私有的,它的生命周期与线程相同。每个方法被执行的时候回创建一个栈帧。用于存储局部变量表、操作栈、动态链接,方法出口等信息。每一个方法被调用知道执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

栈帧中的局部变量表:

存放了编译期可知的八种基本数据类型、对象引用和 returnAddress 类型(指向一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要的栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

此区域规定了两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOVerflowError 异常。如果虚拟机栈的大小可以动态扩展,但是无法申请到足够的内存将会抛出 OutOfMemoryError 异常。

本地方法栈

本地方法栈与虚拟机栈发挥的作用非常相似,区别是虚拟机栈执行的是 Java 方法,而本地栈则是虚拟机使用到的 Native 方法服务。同样,本地方法栈区域也会抛出相同的异常。JDK 默认的虚拟机 HotSpot 虚拟机 直接就把本地方法栈和虚拟机栈合二为一。

方法区

方法区属于线程共享的内存区域。它存储已被虚拟机加载的类的信息、常量、静态变量、即时编译器编译后的代码等数据。方法区被描述为堆的一个逻辑部分

关于如何理解 ”方法区被描述为堆的一个逻辑部分“下面 Java 堆会说。

虽然 Java虚拟机规范把方法区描述为堆的一个逻辑不笨,但是它却又一个别名叫做 Non-Heap(非堆),目的应该是与 Java堆区分开来。

垃圾收集行为在这个区域是比较少出现的。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。

运行时常量池

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

除了保存 Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于Class 文件常量池的另一个重要特征是具备动态性,Java语言并不要求常量一定只能在编译期产生,也就是并非预置Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被用的比较多的便是 String 类的 intern() 方法。

当常量池内存无法再申请得到时,会抛出 OutOfMemoryError 异常。

Java 堆

Java堆 一般是 Java 虚拟机所管理的内存中最大的一块,并且是线程共享的一块内存区域,在虚拟机启动时创建。

此区域的唯一目的就是存放对象实例,几乎所有的对象实例及数组都要在堆上分配。

Java堆 也是垃圾收集器管理的主要区域。

下图是堆的划分图:(JDK 1.8 及以上),划分区域是为了更好的配合垃圾回收算法。

image-20190227181711297

Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。堆无法扩展时,会抛出 OutOfMemoryError 异常。

在 JDK1.8 以前,是不存在 MetaData 的, 称为 永久代,永久代所用的内存是堆的内存,而 MetaData 用的内存是直接内存,区别就是 JDK 1.8 实现了方法区的信息与 Java堆 内存隔离开来。

方法区是一个逻辑抽象的概念,而主要实现,和方法区里存储的信息是在 MetaData(元空间 ) 里面进行实现的。(以前的实现实在永久代实现的)

方法区、永久代、MetaData 三者并不能混为一谈。

直接内存

直接内存不属于虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁使用,而且也可能导致 OutOfMemoryError 异常。

如在 JDK1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用来进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

本机直接内存的分配不会受到 Java 堆大小限制。

对象访问的方式

主流的访问方式有两种:使用句柄和直接指针。

image-20190228002727341

通过句柄访问对象

如果使用句柄访问方式,Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。

使用句柄访问方式的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集移动对象是非常普遍的行为)时只需要改变句柄中的实例数据指针,而 reference 本身不需要被修改。

image-20190228002955122

通过直接指针访问对象。

使用直接指针访问方式的最大好处是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。HotSpot 使用的是 直接指针访问对象。

参考 《深入理解Java虚拟机》