内存管理是虚拟机中的一个重要命题,当JVM接手了内存管理的事宜之后,相较于使用C++来开发时的手动控制内存,Java降低了开发者的门槛,也提高了程序的可维护性,那么,JVM究竟是如何对内存进行管理的?JVM首先需要对内存进行抽象和分区
注意:不要混淆内存区域和内存模型的概念
JVM内存管理分区
将连续的内存抽象为不同作用的内存区域,这并不是JVM的首创,在操作系统层面本身也已经将内存进行了抽象划分,而Java的内存区域划分,相当于是更加上层的,存在于用户空间内的封装,它的划分很简单,分为以下五个部分:
- 程序计数器
- 虚拟机栈
- 本地方法栈
- 堆
- 方法区
按照线程共享、线程隔离进行分类:
因为从逻辑上可以将上面提到的五个部分,分为被所有线程共享的内存区域和仅被当前线程独占的内存区域,其中
线程共享区域包括:堆和方法区,
线程独占的区域包括:程序计数器、虚拟机栈、本地方法栈。
也就是说当你在写程序时,需要判断当前读写的数据是存在于哪类内存区域的,如果是存在共享区域,那么就要考虑是否存在线程安全问题。如果存在独占的程序区域,那么就可以打消这种顾虑,然而,学过Java的同学应该都知道,当我们在写代码的时候,对象都是在堆上分配的,所以当出现并发读写对象的情况,就需要考虑线程安全性,而比如方法内部局部变量,这些都是分配在虚拟机栈上的,仅供当前线程独占,所以就不用考虑线程安全性。
程序计数器
在硬件层面程序计数器是一种寄存器,他用来存放指令地址,提供给处理器执行,在JVM 这种软件层面,程序计数器也是一样的作用,它用来存储字节码的指令地址,提供给执行引擎去取指执行,可以这么认为,这两种程序计数器分别存在于硬件与软件中,实现方式不一样,但是设计思想是类似的。
问题:明白了JVM中程序计数器是做什么的,那么在程序运行时,我们能不能监控到程序计数器的值?
答案是不能。因为虚拟机没有向外暴露查询程序计数器值的接口,但是我们可以从侧面的角度去进行观察,比如下面这个demo,然后使用javap进行反编译得到可读性好一点的字节码
java源码
public class ProgramCounterTest {
public static void main(String[] args) {
int a = 0;
while (a < 10) {
a++;
}
System.out.println(a);
}
}
字节码(javac + javap)
C:\Users\Administrator\Desktop>javap -c -l ProgramCounterTest.class
Compiled from "ProgramCounterTest.java"
public class com.example.demo0413.test.ProgramCounterTest {
public com.example.demo0413.test.ProgramCounterTest();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
Code:
stack=2,locals=2,args_size=1
0: iconst_0
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpge 14
8: iinc 1, 1
11: goto 2
14: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
17: iload_1
18: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
21: return
LineNumberTable:
line 5: 0
line 6: 2
line 7: 8
line 9: 14
line 10: 21
}
可以看到第一列的数字代表了字节码指令之间的偏移量,叫做 bytecode index,这其实就是程序计数器所需要读取的数据,看bytecode index 为11的这行,指令为goto,操作数为2,代表了回到index为2的那行指令,这里就体现出了源码中的循环逻辑,也体现出了程序计数器的工作方式。
虚拟机栈
虚拟机栈这个名字乍一听可能让人觉得有点难以理解,我们可以换一个称呼,叫做 Java方法栈,对应后面需要介绍的本地方法栈。
问题:什么是 Java方法栈?
程序执行的过程对应方法的调用,而方法的调用实际上对应着 栈帧的入栈出栈。比如我们写这样一段代码
public void funcA() {
int a = 1;
funcB(1);
}
public void funcB(int arg) {
}
运行时,程序会先调用 A方法,那么 A方法封装成“栈帧”入栈,由于 A方法调用了 B方法,那么 B方法接着被封装为栈帧,入栈,然后,先执行 B方法中的逻辑,等于 B栈帧出栈,然后执行 A方法,等于 A方法出栈,可能有的同学在写递归的时候稍不留神,将会出现栈溢出的异常情况,原因是没有编写适当的递归退出条件,导致无限量的栈帧入栈,超出了方法栈的最大深度。所以就抛出了 StackOverFlow的异常,我觉得这里有三点需要注意:
- 栈帧
- 栈帧的生成时机
- 栈帧的构成
栈帧
这个概念,目前可以简单地将其当作方法调用的一种封装。
栈帧的生成时机
,在编译期间,无法确定 Java方法栈的深度,因为栈帧的生成,是根据程序运行时的实际情况来决定的。这是动态的,比如你写了藏有 StackOverFlow的递归代码,编译器是无法检查出这种异常的。
栈帧的组成
,在编译期间,由于每一个方法的源码都是确定的,而栈帧是根据方法调用来产生的,那么可以猜想栈帧内部的一些元素是可以确定的。比如说有多少个局部变量,存储局部变量所需要的空间,而有一些元素的是无法确定的,比如说改方法与其他方法之间的动态链接。
栈帧
栈帧中主要存在四种结构,局部变量表,操作数栈,动态链接,返回地址,这几种结构和我们上面的猜想也差不多,详细介绍下这四种结构。
- 局部变量表
- 操作数栈
- 动态链接
- 返回地址
局部变量表:
1.局部变量表的完整概念
- 主要存储方法的参数、定义在方法内的局部变量,包括基本数据类型(8大),对象的引用类型,返回地址。
- 局部变量表中存储的基本单元为变量槽(Slot),32位(4字节)以内的数据类型占一个slot,64位的占两个slot。
- 局部变量表是一个数字数组,byte、short、char都会被转化为int,boolean类型也会被转化为int,0代表false,1代表true
- 局部变量表的大小是在编译期间决定下来的,所以在运行时它的大小是不会变的。
- 局部变量表中含有直接或者间接只想的引用类型变量时,不会被垃圾回收处理。
栈帧是通过方法源码来生成的,当调用该方法时呢,传入方法的参数类型,局部变量类型,这些在源码中都是已经确定的,既然数量与类型能够确定,那么需要占用的存储空间也就能够确定,怎么进行存储呢?这里在局部变量表中,通过4字节的slot来存储。
示例:
public static void main(String[] args) {
int a = 0;
int b = 1;
int c = a + b;
}
字节码 (javac+javap)
javac -g:vars ProgramCounterTest.java
javap -verbose ProgramCounterTest.class
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: iconst_0
1: istore_1
2: iconst_1
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
2 7 1 a I
4 5 2 b I
8 1 3 c I
在LocalVariableTable这一栏中,我们可以看到局部变量表,其中参数args占用了index为0的slot,并且声明了签名为String,剩下的三个局部变量abc,分别占用其余三个slot,签名都为int,接下来我们来看操作数栈,
操作数栈
在操作系统层面的操作数是计算机指令的一部分。而这里的操作数栈 是JVM层面的,但作用是相似的,顾名思义,这里的操作数栈就是一个用来存储操作数的栈,这里的操作数大部分就是方法内的变量,那为什么需要使用操作数栈堆操作数进行入栈出栈操作,主要有两个作用,第一点就是存储操作数,这里的操作数指的是,变量以及中间结果。第二点就是操作数栈能够方便指令顺序读取操作数,虚拟机的执行引擎在执行字节码指令的时候呢,会通过当前指令类型,从操作数栈中取出栈顶的操作数进行计算,然后再将计算结果入栈,继续执行后续的指令,我们写一个简单的例子检验一下。
源码:
public static void main(String[] args) {
int n = 1;
int m = 2;
int sum = n + m;
}
字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return
LineNumberTable:
line 5: 0
line 6: 2
line 7: 4
line 8: 8
可以看到bytecode index为 4 和5的两行,对应的字节码指令是iload,iload的含义就是将int类型的操作数压栈,所以4和5两行,其实就是将m和n两个变量压栈,接着是iadd这个指令,它就是取出栈顶的两个操作数,进行求和计算并将计算结果压入栈中,接着就是istore这个指令,它就是将栈顶的操作数存入局部变量表中。
字节码指令对照表
指令 | 含义 |
---|---|
iload | int型变量进栈 |
istore | 栈顶int数值存入局部变量表 |
iadd | 弹出栈顶两个操作数,并将求和的int值压入栈中 |
对于操作数栈,还隐藏着另外一个小问题,上面演示的例子中只有一个栈帧,如果虚拟机栈中存在多个栈帧,我们可以想象,先执行完的方法的返回值,需要被当作后执行方法的变量。
源码:
public void funcA() {
int a=funcB();
}
public int funcB() {
int n = 1;
int m = 2;
return n+m;
}
在运行的时候,虚拟机栈中应该会出现两个栈帧,我们这里称为a和b, 首先执行栈帧B,我们可以想象n和m将会作为两个操作数入栈,通过求和字节码指令计算结果,并将计算结果存入局部变量表,那这个中间结果又将会成为栈帧A的操作数,所以,需要再从栈帧B的局部变量表中将该值复制进入栈帧A的操作数栈,这样做当然可以,但是JVM做了一些优化。在JVM的实现中,将两个栈帧的一部分重叠,让下面栈帧的操作数栈和上面栈帧的部分局部变量表重叠在一起,这样,在进行方法调用时可以共享一部分数据,而无需进行额外的参数复制传递,这算是一个优化的细节。
动态链接
动态链接其实我们在类加载那部分说过,大家知道OOP的主要特性之一就是多态,而Java中的多态就是通过栈帧中的动态链接来实现的,一句话概念:
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。
Java在类加载过程中,有一个步骤叫做‘连接’,在这个步骤中,JVM将会将class对象(存储在方法去的运行时常量池中)中的部分符号以用替换为直接引用。连接是将部分符号引用替换为直接引用,为什么是部分?
因为对于有些方法JVM能判断这些方法所在的具体类型,所以就可以放心大胆的对方法进行连接,这叫做静态解析
而对于有些方法因为多态的存在,无法在类加载阶段就确定被调用的具体类型,只能在运行时真正产生调用的时候,根据实际的类型信息来进行连接,这就叫做动态连接。
我们用一个简单的例子来说明一下:
源码:
public class A {
private B b;
public void funcA() {
b.funcB();
}
}
public abstract class B {
public abstract int funcB() {
}
}
由于B为抽象类,所以类A的加载阶段无法确定B的具体实现类,在运行时呢,当方法a中调用方法b时,需要先查询栈帧B在运行时常量池中的符号引用,然后根据当前具体的类型信息进行动态链接。
返回地址
第一种,方法正常执行完成返回,
第二种,方法执行期间遇到了异常情况,返回
正常返回的情况:若方法正常返回,代表栈帧执行完成,栈帧在退出虚拟机的时候,需要把返回信息共享给其上层的操作数栈,同时修改程序计数器的值,让程序能够继续执行下去,
若异常返回:需要通过额外的异常处理器表来进行处理,其他和程序计数器相关的逻辑与正常情况类似。
栈帧是虚拟机栈中的主要内容。
- 局部变量表
- 操作数栈
- 动态链接
- 返回地址
- 其他附加信息
本地方法栈
Java方法栈和本地方法栈的区别:
本地方法栈,不是使用java实现的函数。往往是由C、C++来编写。和操作系统相关性比较强的底层函数。
本地方法栈就是用来支持本地方法的调用逻辑的。