前面我们说了一个class被加载到JVM中的具体过程,它包含 load,link,initialize。
在此之后,被加载到JVM后,将被执行引擎进行执行,这时候就涉及到了JVM运行时的一些数据区分布了。
运行时数据区分布
PC 程序计数器(Program Counter)
它用于存放指令位置。
虚拟机的运行,类似于这样的循环:
while( not end ) {
取PC中的位置,找到对应位置的指令;
执行该指令;
PC ++;
}
每一个JVM中的线程都有它自己的一个PC(程序计数器)
如果一个方法不是本地方法(非native的),这个PC将会记录当前处理到的JVM指令集的位置。
JVM Stacks(栈)
每个Java虚拟机线程都有一个私有的Java虚拟机堆栈,它与线程同时创建。
Java虚拟机堆栈存放着一个个栈帧。栈帧是啥?这是我们的重点,一会再说。
Heap(堆)
堆是线程之间共享的一块区域。
所有类实例和数组的内存都是从堆这里进行分配的。
Method Area(方法区)
方法区也是所有的JVM线程共享的一块区域。
它存储着所有的Class。
方法区可以认为是一个接口(一种规范),而对它的实现有2种:
1、Perm Space(永久代)
在JDK1.8之前,方法区的实现是它。 FGC几乎不会清理这块空间,且这块区域只能在JVM启动的时候进行参数指定,之后在运行时就不能变了
2、Meta Space(元空间)
在JDK1.8开始,方法区的实现是它。 可以被FGC回收,且直接依赖的是物理内存,大小调控比较灵活,不设定的话就默认是系统最大物理内存。
关于字符串常量
在JDK1.8之前,字符串常量是存放在永久代的。从1.8开始,字符串常量被转移到了堆中存储。
方法区中,还包含了一块区域,叫运行时常量池:
Run-Time Constant Pool(运行时常量池)
运行时常量池是class中常量池表的每个类或每个接口的运行时表示
Native Method Stacks(本地方法栈)
这个结构和JAVA方法栈相同,但是只在通过JNI调用一些本地方法(由C,C++编写的实现方法)时进行存储栈帧的。
Direct Memory(直接内存,也叫堆外内存)
为了提高IO传输效率,在JDK1.4之后,JVM可以直接访问的内核空间的内存 (OS 管理的内存)
NIO就是大量利用了这块内存来提高效率,这也是实现zero copy的基础。
一张图概述区域分布
这张图可以比较清晰的概述上面的分布,可以看到,每个线程独有自己的PC,方法栈,本地方法栈。 而共享堆区和方法区。
这是因为线程间切换,切换回来的时候需要使用PC来定位到之前线程指令执行到的位置。
栈帧
上面我们说到,在JVM中,每个线程会独享一个JAVA方法栈,而栈中会可能存在多个栈帧。
而这一个个栈帧怎么来的?
实际上,我们的每个方法都会分别对应一个栈帧。
一个栈帧存储着四部分的东西:
Local Variable Table(局部变量表)
一个方法内入参和声明的局部变量,都是存放到我们局部变量里。
如果一个方法是非静态方法,那么还额外会将“this”放入表中。
Operand Stacks(操作数栈)
方法代码块内的每一行代码都会被解释成一条或多条指令,放入到操作数栈当中。
Dynamic Linking(动态链接)
比如在a方法里调用了方法b,在代码块中被虚拟机解释成操作码后,这个操作码会指向了一个常量池中关于方法b的常量符号引用,如果这个符号引用没有被解析成直接引用过,则对它进行替换成直接引用。
参考文章:https://blog.csdn.net/qq_41813060/article/details/88379473
Return Address(返回地址)
a() -> b(),方法a调用了方法b, b方法的返回值放在什么地方,以及执行完b后,需要回到哪个位置继续执行。
通过程序来观察栈帧
public static void main(String[] args) {
int i = 10;
//i = i++;
i = ++i;
System.out.println(i);
}
public void test(String name){
int b=2;
}
我们依然是使用jclasslib来观察:
局部变量表
这个程序的静态main方法里的栈帧中局部变量表里应该存放了几个变量呢?
可以看到,是2个。 印证了我们说的,入参和定义的变量。
非静态的test方法呢?来看看:
有没有发现什么不同:除了入参和声明的变量之外,发现在0号位上多了一个名称为“this”的变量。
没错,实例方法在局部变量表会多一个this的引用。
操作数栈
这个明面上不太好直接显示出来,但我们可以从观察一个方法的指令开始入手:
写法1:
public static void main(String[] args) {
int i = 10;
i = i++;
System.out.println(i);
}
还是以main方法为例,观察它生成的操作指令:
0 bipush 10
2 istore_1
3 iload_1
4 iinc 1 by 1
7 istore_1
8 getstatic #2 <java/lang/System.out>
11 iload_1
12 invokevirtual #3 <java/io/PrintStream.println>
15 return
我们来一条条java语句和操作指令进行对应拆分讲解:
int i = 10;
//生成指令:
0 bipush 10 向操作数栈 压栈 byte 10 并将10转换为一个int类型的值
2 istore_1 将10出栈存入 为局部变量表为1号(i)的值
i = i++;
//生成指令:
3 iload_1 从局部变量表i中加载值压入操作栈,此时值为10,
4 iinc 1 by 1 对局部变量表的i的值+1,此时局部变量的i的值为11
7 istore_1 将操作栈中的10重新出栈存入局部变量的i 此时,局部变量的i值重新变为10
System.out.println(i);
//生成指令:
8 getstatic #2 <java/lang/System.out>
11 iload_1 从局部变量i中加载值压入操作栈,此时值为10
12 invokevirtual #3 <java/io/PrintStream.println>
15 return
:
写法2:
public static void main(String[] args) {
int i = 10;
i = ++i;
System.out.println(i);
}
生成指令:
0 bipush 10
2 istore_1
3 iinc 1 by 1
6 iload_1
7 istore_1
8 getstatic #2 <java/lang/System.out>
11 iload_1
12 invokevirtual #3 <java/io/PrintStream.println>
15 return
我们来一条条java语句和操作指令进行对应拆分讲解:
/**
* 以上的程序会输出的JVM操作指令如下:
* int i = 10;
* 0 bipush 10 向操作数栈 压栈 byte 10
* 2 istore_1 将10出栈存入 为局部变量为1号(i)的值
*
*
* i = ++i;
* 3 iinc 1 by 1 对局部变量的i的值+1,此时局部变量的i的值为11
* 6 iload_1 从局部变量i中加载值压入操作栈,此时值为11,
* 7 istore_1 将操作栈中的10重新出栈存入局部变量的i ,依旧是11
*
* System.out.println(i);
*
* 8 getstatic #2 <java/lang/System.out>
* 11 iload_1 从局部变量i中加载值压入操作栈,此时值为10
* 12 invokevirtual #3 <java/io/PrintStream.println>
* 15 return
*/
程序3:
程序4:
程序5:
程序6:
iadd,把栈顶两个int数弹出栈,进行运算,然后结果重新放入栈顶
程序7:
dup,复制一份栈顶的内容,然后压栈
这里dup的复制属于小细节,因为 invokespecial init这个指令 会调用构造方法,且消耗一个栈顶的引用,因此专门复制出一个用于调用构造使用,调用完毕后,原来的那个引用指向的对象就是初始化完毕的了。
这里也可以看到,一个对象的初始化是分成了好多步的。
还有个小细节:这里调用的构造方法可以看到是加了“”来I标识的,这说明是调用的一个实例方法构造。
除此之外,还有“”,这代表一个Class的静态初始化块的调用。
方法调用返回值
a()->b(),b方法会把返回值放于a方法的栈帧的栈顶
实例程序:
递归调用
方法的递归调用实际上也是一个栈帧一个栈帧垒上去。
每次方法都是一个栈帧,而递归实际和调用其他方法没什么不同,只不过就是调用自己方法,调用过程是一样的。
就上面这个程序来分析下这个过程:
我们只分析m方法的运行指令过程:
0 iload_1 //读取局部变量表中的1号变量压栈,因为这是个实例方法,所以0号是this,1号是入参 n
1 iconst_1 //将常量“1”压栈
2 if_icmpne 7 (+5) //将栈中放入的两个值出栈,并进行比较,如果不相同,则跳到指令7
5 iconst_1 //将常量“1”压栈
6 ireturn //将栈顶数返回
7 iload_1 //读取局部变量表中的1号变量压栈,因为这是个实例方法,所以0号是this,1号是入参 n
8 aload_0 //读取局部变量表中的0号变量压栈,因为这是个实例方法,所以0号是this ,以便后续调用它自身的m方法
9 iload_1 //读取局部变量表中的1号变量压栈,因为这是个实例方法,所以0号是this,1号是入参 n
10 iconst_1 //将常量“1”压栈
11 isub //将栈顶开始取2个数出栈,进行减法操作, 这里就是 n-1 并将结果压入栈顶
12 invokevirtual #4 <com/peng/jvm/c4_RuntimeDataAreaAndInstructionSet/Hello_04.m> //调用自身m方法,生成新栈帧,在新栈帧内执行完毕后,返回结果压入当前栈顶
15 imul //将栈顶开始取2个数出栈,进行乘法操作,并将结果压入栈顶
16 ireturn //将栈顶数返回
你可能观察到,这些指令的前面的索引号,似乎不是规律+1递增的,有的地方会跳好几个,这是因为有的指令自身比较大,存储需要多占了几个位置。
常用指令集
store
将栈顶值压入局部变量表
load
将局部变量表某个值压入栈顶
pop
从栈顶弹出某个值
mul
从栈中拿出2个数进行乘法运算
sub
从栈中拿出2个数进行除法运算
invoke方法:
InvokeStatic
调用静态方法的指令
public static void main(String[] args) {
m();
}
public static void m() {}
InvokeVirtual
调用实例方法的指令,自带多态,会根据栈中的this去调用对应的具体实现方法。
public static void main(String[] args) {
new T02_InvokeVirtual().m();
}
public void m() {}
InvokeInterface
以接口形式去调用方法的指令
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("hello");
}
InovkeSpecial
可以直接定位,不需要多态的方法
例如private 方法 , 构造方法
public static void main(String[] args) {
T03_InvokeSpecial t = new T03_InvokeSpecial();
t.n();
}
private void n() {}
InvokeDynamic
JVM最难的指令
lambda表达式或者反射或者其他动态语言scala kotlin,或者CGLib ASM,动态产生的class,会用到的指令
public static void main(String[] args) {
I i = C::n; //每次声明,java内部都会动态生成内部实现类
I i2 = C::n;//每次声明,java内部都会动态生成内部实现类
I i3 = C::n;//每次声明,java内部都会动态生成内部实现类
System.out.println(i.getClass());
System.out.println(i2.getClass());
System.out.println(i3.getClass());
//for(;;) {I j = C::n;} //但实际上,for循环中的lambda声明似乎会被优化,多次循环的lambda实际只会生成一个对象。
}
@FunctionalInterface
public interface I {
void m();
}
public static class C {
static void n() {
System.out.println("hello");
}
}
打印结果:
class com.peng.run_time_data_area.T05_InvokeDynamic$$Lambda$1/1078694789
class com.peng.run_time_data_area.T05_InvokeDynamic$$Lambda$2/1023892928
class com.peng.run_time_data_area.T05_InvokeDynamic$$Lambda$3/558638686
指令集分类
1、基于寄存器的指令集
Hotspot中的局部变量表类似于 JVM中的寄存器
2、基于栈的指令集
操作数栈其实类似于基于栈的指令集