JVM作为运行Java程序的平台,我们Java程序员必须要去了解它。JVM 能涉及非常庞大的一块知识体系, 比如内存结构、 垃圾回收、 类加载、 性能调优、 JVM 自身优化技术、 执行引擎、 类文件结构、 监控工具等。但是在所有的知识体系中, 都或多或少跟内存结构有一定的关系:比如垃圾回收回收的就是内存、 类加载加载到的地方也是内存、 性能优化也涉及到内存优化、 执行引擎与内存密不可分、 类文件结构与内存的设计有关系, 监控工具也会监控内存。 所以内存结构处于 JVM 中核心位置。 也是属于我们入门 JVM 学习的最好的选择。同时 JVM 是一个虚拟化的操作系统, 所以除了要虚拟指令之外, 最重要的一个事情就是需要虚拟化内存, 这个虚拟化内存就是我们马上要讲到的 JVM 的内存区域。
JVM内存结构可以分为五个模块加上一个直接内存。其中这些模块又可以分为两个大类,线程共享区域和线程私有区域。
线程共享区域:
- 堆内存:JVM 上最大的内存区域, 我们申请的几乎所有的对象, 都是在这里存储的。
- 方法区:JVM的逻辑划分,不同版本有不同实现。主要是用来存放已被虚拟机加载的类相关信息, 包括类信息、 静态变量、 常量、 运行时常量池、 字符串常量池等。
线程私有区域:
- 虚拟机栈:JVM 运行过程中存储当前线程运行方法所需的数据, 指令、 返回地址。
- 本地方法栈:Java程序调用底层C/C++函数库。
- 程序计数器:当前线程执行的字节码的行号指示器。
直接内存:又叫堆外内存
,它不是虚拟机运行时数据区的一部分,但是虚拟机部分逻辑会用到直接内存。
我们就从线程私有的区域开始讲起吧!
虚拟机栈
它的数据结构如其名,栈是一种FILO(先进后出)的数据结构,它的声明周期和线程息息相关,它的作用就是存储当前线程运行java方法所需的数据、指令、返回地址。(虚拟机栈在Java程序员口中简称栈
,为了方便下文就简称栈)
JDK1.8官方指定栈的默认大小为1M,程序运行时可以用 -Xss命令指定大小
栈的结构:
栈帧:在一个线程里,每当调用一个方法就会创建一个栈帧,并入栈,当方法执行完以后进行出栈
例如:在java代码中A()方法中调用了B()方法,B()方法中又调用了C()方法。那么线程执行A()时,创建一个栈帧入栈,执行B()方法时又创建一个栈帧入栈…,当C()方法执行完毕出栈,紧跟着B()结束也跟着出栈,直至栈底的栈帧出栈宣告完毕,此时线程也跟着消亡。
栈的组成元素时栈帧,那么栈帧里面长什么样子呢?我们刚说到,栈是拿来存数据, 指令、 返回地址的,那么这些东西肯定是存在栈帧中了,我们来看看栈帧的内部结构:
- 局部变量表:顾名思义它是一张表来存数据的,而且是局部变量的数据。局部变量表中存储的数据是一个32位长度的数据,比如我们常见的8大基本类型变量,如果是double和long则使用32位高低位来标识,如果是对象,那么就存储对象的堆内存地址。
- 操作数栈:顾名思义它的内存结构也是一个栈结构(先进后出),它的作用就是存储方法运行时执行引擎需要计算的数据。
- 动态链接:解决符号引用相关问题(后续类加载机制时解析)。
- 返回地址:方法执行完毕需要将程序计数器中的地址作为返回,便于后续栈帧执行。
光说概念是不是很枯燥,我们写段代码,通过反汇编来瞅瞅
/**
* @author Minor
*/
public class Demo1 {
public int test() {
int a = 10;
int b = 20;
int c = (a+b)*2;
return c;
}
}
首先我们定义了一个非常普通的java方法test(),内部定义两个变量a,b然后计算他们的和再乘以2,赋值给c然后返回。我们先用javac命令编译一下得到Demo1.class文件,然后用javap -c命令查看这个class文件的反汇编指令代码,
为了方便,我直接在反汇编指令里面写注释:
wangzhi@wangzhideMacBook-Pro ~/Desktop/JavaBase/src/com/company/base javap -c Demo1
警告: 二进制文件Demo1包含com.company.base.Demo1
Compiled from "Demo1.java"
public class com.company.base.Demo1 {
public com.company.base.Demo1();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public int test(); // 我们java代码里定义的test()方法
Code: // 字节码指令
0: bipush 10 // 将常量10压入操作数栈
2: istore_1 // 将操作数栈的值10存储到局部变量表下标为1的位置
3: bipush 20 // 将常量20压入操作数栈
5: istore_2 // 将操作数栈的值10存储到局部变量表下标为2的位置
6: iload_1 // 将局部变量表下标为1的变量压入操作数栈
7: iload_2 // 将局部变量表下标为2的变量压入操作数栈
8: iadd // 加法运算,将操作数栈里的值进行求和
9: iconst_2 // 将值为2的常量压入操作数栈
10: imul // 乘法运算
11: istore_3 // 将操作数栈的值存储到局部变量表下标为3的位置
12: iload_3 // 将局部变量表下标为3的变量压入操作数栈
13: ireturn // 方法返回
}
class指令集参考表:[https://cloud.tencent.com/developer/article/1333540]
(https://cloud.tencent.com/developer/article/1333540)
我们可以看到,一个简单的方法,解释成jvm指令时变得很复杂,但是每一步缺逻辑清晰。注意一点istore_n指令表示将操作数栈里的值存入局部变量表的下标n的位置。iconst_n表示将n常量压入操作数栈,常量是几,n就是几。
细心的小伙伴注意到了,当操作数栈存储常量10的时候,为什么存储的是局部变量表下标为1的位置。其实java代码底层对方法的调用有一个this,代表当前对象,所以局部变量表index[0]号位置是当前对象this的引用。
有一点需要注意的是,栈这个数据结构是先入后出,如果一个线程不断地入栈而没有出栈,就会造成栈溢出错误StackOverflowException,比如递归操作控制不当就会发生异常。
程序计数器
程序计数器是用来记录线程执行字节码的行号地址,因为现代计算机的工作模式基于CPU的时间片轮转机制,线程在执行程序的时候难免会遇到CPU调度问题,此时就需要一个地方来存储线程当前执行的位置。显然,每个线程各自独立,都有属于自己一份的程序计数器。由于结构简单,功能单一,程序计数器也是JVM内存模型中唯一不会发生内存溢出的地方。需要值得注意的点是当java线程在执行本地方法(native修饰的方法)时,程序计数器并不会记录执行位置,因为操作系统层面也有一个程序计数器,本地方法依靠它去记录。
本地方法栈
本地方发栈顾名思义也是一个栈结构,和虚拟机栈类似。虚拟机栈用于控制java方法的调用,而本地方法栈控制本地方法的调用。