十九、JVM内存管理分析
一、JVM的运行过程
- JVM虚拟机我们可以把它当做一台虚拟出来的计算机,也有自己的内存管理如:堆、栈、方法区等。
-
JVM的作用是将字节码翻译成不同操作系统可以识别的机器码执行。
运行过程如下图:
image.png
(1)我们写的一个JAVA程序首先通过JDK的中JAVAC工具进行编译,编译成了.class文件,也就是我们说的字节码。
(2)JAVA类加载器(ClassLoader)将字节码载入到JVM的运行时数据区,也就是JVM中的一块内存区域。
(3)通过执行引擎调用操作系统接口解释执行或者JIT执行。 - 解释执行指的是JVM通过加载到的字节码进行翻译执行
- JIT指的是将热点代码直接翻译成机器码保存下来,方便下次直接执行。
区别是:前者启动快,但是运行速度慢,因为每次都要边解释边执行。而后者启动速度慢,但是执行速度快,因为需要将热点代码翻译成机器码保存下来,但是下次执行到热点代码的时候直接通过执行之前保存的机器码,因此速度比较快。 - 一般虚拟机是通过两种方式混合使用。
二、运行时数据区
字节码通过类加载器加载到了JVM运行时数据区,而运行时数据区又将内存划分了不同的区域。不同的身份将进入到不同的区域。如下图:
image.png
运行时数据区主要分为两大块:线程共享数据区和线程隔离数据区
其中右边白色部分包括虚拟机栈、本地方法栈、程序计数器是线程隔离数据区。左边灰色区域方法区、堆是线程共享数据区。
- 线程隔离数据区
指的是每一个线程都拥有自己的一块内存区域,就比如有两个线程A和B,他们各自拥有自己的虚拟机栈、本地方法栈、程序计数器。 - 线程共享区
指的是线程共享的区域,每个线程都共享了方法区和堆
1、线程共享数据区
1.1、程序计数器
指向了当前线程正在执行的字节码指令地址,换句话说就是记录当前字节码指令执行的位置。因为在操作系统中由于时间片轮转机制,当前的线程可能指令还没执行完就被切出去了,因此要通过程序计数器记录正在执行的位置,当线程重新获得时间片之后,在原来的位置继续执行。
1.2、虚拟机栈
存储当前线程运行方法所需要的数据、指令、返回地址。我们可以理解成一个线程就拥有一个虚拟机栈,而线程中的每一个方法就是一个栈帧。
1.2.1、栈帧
栈帧中又包含了:局部变量表、操作数栈、动态链接、 完成出口(返回地址)
如下图
image.png
线程中一个方法的执行就是在这些身份相互配合执行。我们将线程隔离数据区进行更细的划分如下图
image.png
我们看到一个虚拟机栈会有多个栈帧,每一个栈帧入栈出栈的过程就是一个方法执行的过程。栈帧中的局部变量表、操作数栈、动态链接、完成出口存放了方法执行过程的数据。下面我们来演示一个方法的执行对应字节码指令的执行。
- JAVA源代码
package com.it.test;
public class Test {
public static void main(String[] args) {
Test test = new Test();
test.function();
}
public int function() {
int a = 1;
int b = 3;
int c = (a + b) * 10;
return c;
}
}
- 反汇编后的字节码
D:\app_work_space\JavaHighSets\src\com\it\test>javap -c Test.class
Compiled from "Test.java"
public class com.it.test.Test {
public com.it.test.Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class com/it/test/Test
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method function:()I
12: pop
13: return
public int function();
Code:
0: iconst_1
1: istore_1
2: iconst_3
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
}
我们将源代码和反汇编后的代码合在一起进行对比,然后分析function方法的执行如何在虚拟机栈和程序计数器中体现的。
//源代码
public int function() {
int a = 1;
int b = 3;
int c = (a + b) * 10;
return c;
}
//反汇编后的代码
public int function();
Code:
0: iconst_1
1: istore_1
2: iconst_3
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
}
- code就是代码的意思
- 0、1、2...等等行号,我们可以认为是字节码指令执行到的位置,程序计数器存放的就是这些数据的地址,记录当前方法执行到的位置,因此程序计数器存放的数据较小,不会因为内存不足产生OOM异常。
0: iconst_1
将int 类型 1压入到操作数栈
1:istore_1
将操作数栈栈顶出栈,存入局部变量表下标为1的位置
以上两个步骤完成了int a =1;
2: iconst_3
将int类型 3压入到操作数栈
3:istore_2
将操作数栈栈顶出栈,存入局部变量表下标为2的位置
以上两个步骤完成了int b = 3;
4:iload_1
将局部变量表下标为1的位置的数据压入操作数栈
5:iload_2、
将局部变量表下标为2的位置的数据压入操作数栈
6: iadd
三部曲,
(1)将栈顶的两个数据出栈
(2)相加
(3)将结果压入到操作数栈
7: bipush
将int类型10压入到操作数栈
9:
imul三部曲
(1)将栈顶两个数据出栈
(2)相乘
(3)结果压入到操作数栈
10:istore_3
将栈顶数据存入到局部变量表下标为3的位置
11: iload_3
将局部变量表下标为3的位置的数据存到操作数栈,作为返回值
以上就完成了int c = (a + b) * 10
12: ireturn
将操作数栈栈顶的数据出栈返回
完成了rerturn z
最后方法执行完毕之后,栈帧就从Java虚拟机栈中出栈
1.3、Java虚拟机栈小结
Java虚拟机栈存放的是一个方法的执行过程、而每一个方法对应一个栈帧,每一个方法的执行过程就是栈帧入栈出栈的过程,而栈帧中又包含了局部变量表、操作数栈、动态链接、完成出口(返回地址)。这四个角色用于存放执行过程的数据。
(1)局部变量表
存方法内部的局部变量基本数据类型、以及局部对象类型变量的引用
(2)操作数栈
完成方法中数据的操作
(3)动态链接
Java语言中会有多肽,例如以下代码,Student和Teacher都继承了User,执行方法eat,在编译期间是没法知道是执行Student的eat方法还是Teacher的eat方法,所以在方法运行期间,Java虚拟机栈的栈帧中存放一个动态链接来确定执行谁的eat方法
public class User {
public void eat(){
}
public static void main(String[] args) {
User user = new Student();
user.eat();
user = new Teacher();
user.eat();
}
}
(4) 完成出口(返回地址)
例如以下代码, 在main方法中执行了function方法,当function放执行完毕之后,要回到main方法中继续执行,那具体要回到的地方是哪里呢?就是我们 System.out.println("你好。。。");这一行这里作为出口,所以这个出口就是存放在栈帧中的“完成出口”区域。
package com.it.test;
public class Test {
public static void main(String[] args) {
Test test = new Test();
test.function();
System.out.println("你好。。。");
System.out.println("哈哈。。。");
}
public int function() {
int a = 1;
int b = 3;
int c = (a + b) * 10;
return c;
}
}
1.4、本地方法栈
以上我们说到了线程隔离区中的程序计数器、虚拟机栈。下面我们来了解一下最后一个本地方法栈。
我们知道虚拟机栈存的是Java方法的执行过程所需的指令、数据、返回地址等,每一个方法的执行就是一个栈帧入栈出栈的过程。而本地方法的执行则对应了我们的本地方法栈。例如我们的hashCode方法就是本地方法。
public native int hashCode();
当JVM创建的线程调用了native方法之后,JVM不会为其在虚拟机栈中创建栈帧,而是简单的动态链接并直接调用native方法。
一般的虚拟机,Java虚拟机栈和本地方法栈都是合在一块区域。例如我们的HotSpot。
2、线程隔离数据区
在JVM运行时数据区中的线程隔离数据区主要包含了方法区、堆。其中我们说的常量池也是在方法区中。
2.1、方法区
方法区主要用于存放以下数据
(1)类的信息
我们知道当我们的Java源代码被编译成class之后,通过类加载器ClassLoader将class加载我们的运行时数据区中的方法区。
(2)常量
(3)静态变量
(4)即时编译后的代码
2.2、堆
堆中则存放了对象实例(几乎所有)、数组。
线程数据共享数据区之所以要分为方法区和堆,是因为方法区主要存放一些类、静态变量、常量等这些是比较难回收的。而在堆中存放的是对象、经常要动态创建,这样方便于回收。
2.2.1、Java堆大小参数的设置
-Xmx 堆内存可被分配的最大上限
-Xms 堆内存初始化分配的大大小
2.2.2、深刻理解运行时数据区
下面我们通过代码例子来深刻理解JVM运行时数据区
- 代码例子
package cn.enjoyedu.concurrent.cas;
public class JVMObject {
private final static String MAN_TYPE = "man";
private static String WOMAN_TYPE = "woman";
public static void main(String[] args) {
Teacher t1 = new Teacher();
t1.setName("小明");
t1.setSex(MAN_TYPE);
//15次垃圾回收
for (int i = 0; i <15 ; i++) {
System.gc();
}
Teacher t2 = new Teacher();
t2.setName("小红");
t2.setSex(WOMAN_TYPE);
try {
Thread.sleep(Integer.MAX_VALUE);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
static class Teacher {
String name;
String sex;
public String getSex() {
return sex;
}
public void setSex(String sex) {
this.sex = sex;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
}
- 运行时数据区结构图
我们通过代码分析图中数据的走向:
当我们以上的代码运行的时候,
(1)JVM向操作系统申请内存,然后根据堆、栈等各自设置的参数给它们分配内存
(2)ClassLoader类加载器将我们的Java源代码编译后的JVMObject.class和Teacher.class加载到我们的运行时数据区的方法区
image.png
(3)运行时数据区的数据再进行拆分,将我们静态变量、常量存放到方法区
image.png
(4)在main线程执行main方法,为main线程创建虚拟机栈、将main方法栈帧压入到虚拟机栈
image.png
(5)main方法创建t1对象实例,存放到堆中。它的引用存放到栈帧中的局部变量表
image.png
(6)t1对象设置属性,这里是调用了方法,就是栈帧的不断入栈,出栈的过程
(7)for循环15次进行GC
垃圾回收器在堆中回收15次,t1对象从堆中的新生代eden区,转到了老年代(Tenued)
(8)创建t2对象,和t1一样,一开始也存放到了堆中的新生代,引用存放到了局部变量表。
最后我们的运行时数据区的图如下:
3、小结
栈、堆、方法区
-
栈主要分为虚拟机栈和本地方法栈。本地方法栈主要用于动态连接我们的native方法,而Java虚拟机栈以栈帧的方式存储方法的调用过程,并存储了方法中的基本数据类型变量、对象的引用变量。当变量出了方法的作用域就会自动释放。一般的Java虚拟机中Java虚拟机栈和本地方法栈都是合二为一,我们简称为栈。
-
而堆主要用来存放Java的对象实例,无论是成员变量、局部变量还是类变量他们的对象实例都是存放在堆中
-
方法区主要用于存放class类信息、静态变量、常量。我们说的常量池也就是在方法区中。
线程独享和共享
栈和程序计数器是属于线程独享数据区,每一个线程都拥有自己的一个栈和程序计数器。
堆和方法区是属于线程共享数据区,线程可以共享访问这些数据区域。
空间大小
栈的内存要远远小于堆的内存,栈的深度也是有限制,可能发生StackOverFlowError。
栈溢出
例如下面的代码:
我们写了一个死的递归执行方法,由于一直递归执行方法,栈帧就会一直入栈。最终导致栈溢出java.lang.StackOverflowError。栈的具体深度是根据默认的配置以及自定义配置栈的大小。
package cn.enjoyedu.concurrent.cas;
public class MyTest {
public static void main(String[] args) {
test();
}
private static void test() {
test();
}
}
Exception in thread "main" java.lang.StackOverflowError
at cn.enjoyedu.concurrent.cas.MyTest.test(MyTest.java:9)
堆溢出
堆内存溢出值的是申请的内存超出了堆中的最大可分配内存,例如下面代码,申请的内存超出了堆最大分配内存,所以抛出了内存溢出异常OutOfMemoryError。
package cn.enjoyedu.concurrent.cas;
public class MyTest {
public static void main(String[] args) {
String[] s = new String[Integer.MAX_VALUE];
}
}
Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit
at cn.enjoyedu.concurrent.cas.MyTest.main(MyTest.java:5)