深入理解java虚拟机内存模型

JVM 整体架构

简单回顾

例如我们写了一个简单的HelloWorld程序,我们想让他运行起来,首先需要javac命令,把他编译成字节码文件,然后通过java命令放到java虚拟机里面去运行,这里如果我们将代码放到windows系统运行或者放到Linux系统下去运行,其实底层执行的是机器码(0101010),不同的操作系统底层的机器码不同,例如我们将代码放到windows操作系统上执行它的机器码是0101,如果我们再放到Linux上它的机器码有可能是1010,不同的操作系统即便我们写的代码相同,但是不同的操作系统下机器码很有可能是不同的;因为机器码是跟硬件相关的也跟操作系统有这密切的关系;这也是我们在不同的操作系统下需要下载不同的JDK的原因;不同的JDK对JVM有不同的实现;最终说白了,这个java代码怎么会变成不同的机器码实际上通过不同的JDK里面的JVM的实现帮我们在最终在执行的时候 翻译成了从属于这个操作系统的机器码;

image.png

JVM的组成

image.png

例如这个有个Math类,我们通过javac命令生成字节码(Math.class)文件,然后执行java Math.class命令,一旦我们执行这条命令,java虚拟机就开始工作了,java虚拟机通过其中的一个子系统(类装载子系统),类装载子系统将字节码文件装载到虚拟机的另外一个组成部分也就是java虚拟机运行时数据区,然后通过执行引擎执行;

类装载子系统,运行时数据区,执行引擎,这三部分是java虚拟机的组成部分;其中堆,栈,方法区只是运行时的数据区(内存模型);

大家都知道堆,栈,本地方法,方法区等这些概念;例如说堆,当我们new Object(),最先都是放到我们的堆里面;堆基本上都是放我们一些新生成的一些对象,后面细说堆;下面我们说下一下栈(栈);

栈(线程)

public class Math {

public static final Integer CONSTANT = 666;

public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}

public static void main(String[] args) {
Math math = new Math();
int compute = math.compute();
System.out.println(compute);
}
}

这里有一个Math类,在主方法中,有math变量,在compute()方法中有a,b,c,这些变量;主线程开始运行,java虚拟机就好为这些变量分配内存空间; 所以说栈的主要用处就是存放我们的局部变量;

下面用图来解释一下:

在用图来解释之前我们我们先了解一下栈帧,程序运行的时候java虚拟机会给每一个线程在运行时数据区单独分配一个独立的内存空间,在栈里面也会分配一块块独立的内存空间,这一块块独立的内存空间就叫栈帧,那栈帧到底是干嘛用的呢?我们结合上面的程序来解释一下;刚刚我们说到当main线程运行时,java虚拟机会给main线程分配一大块内存空间(栈);运行到Math math=new Math();时会有一个math局部变量需要存放;线程继续运行运行compute()方法,又有几个局部变量(a,b,c)需要分配内存空间,当运行到math时,会在栈(线程)中分配出一小块内存空间出来用来存放math变量,这个分配出来的就是栈帧,当运行到compute()方法是有会开辟出一小块空间来存放其中的局部变量(a,b,c),这一块栈帧是对应compute方法的栈帧区域;一个方法对应一块栈帧内存区域;

image.png

我们在大学期间也学习过数据结构,数据结构中也有栈这个名词,first in last out (FILO 先进后出),是不是想起来了, 那这个我们讲到的栈和大学里学到数据结构中的栈有什么关联呢?无论是compute栈帧还是main栈帧,其实就一块内存空间,用户存放局部变量的,那这个存储结构就是用到这个栈的数据结构来存储的;就是first in last out;我们来看为什么是这样;

我们是不是先运行main方法;这个时候main栈帧区域入栈,接下来运行compute()方法,compute栈帧区域入栈,compute()方法执行完之后,然后回到main方法中继续执行System.out.println(compute);,这就体现到后运行的方法先结束,结束之后出栈,释放内存;

那下面说一下栈帧中都有写什么?

image.png

局部变量表上面我们已经说过了,就是存放局部变量的;那操作数栈,动态链接,方法出口都是些什么呢,下面我们就来介绍一下;

我们看上的代码是看不出什么的,想了解这些就要了解一下字节码,我们打开Math.class文件发现好像我们看不懂;但肯定是有他的含义的;

image.png

我们使用另外一种方式把它打开看看,执行javap -c Math.class或者javap -c Math.class > Math.txt,将内容输出到Math.txt文件中;

Compiled from "Math.java"
public class cn.haoxiaoyong.spire.Math {
public static final java.lang.Integer CONSTANT;

public cn.haoxiaoyong.spire.Math();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public int compute();
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn

public static void main(java.lang.String[]);
Code:
0: new #2 // class cn/haoxiaoyong/spire/Math
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method compute:()I
12: istore_2
13: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
16: iload_2
17: invokevirtual #6 // Method java/io/PrintStream.println:(I)V
20: return

static {};
Code:
0: sipush 666
3: invokestatic #7 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
6: putstatic #8 // Field CONSTANT:Ljava/lang/Integer;
9: return
}

想要看懂这些也不是很难,搭配着JVM指令手册看;这里给出了下载地址: jvm指令手册提取码(rscy),我们看到上面有compute()方法,main方法等…其实虚拟机执行就上面的那些代码,而不是我们自己编写的java代码;下面大致分析一下compute()方法中的参数都是什么意思;

iconst_1: 将int型(1)推送至栈顶,

istore_1: 将栈顶int型数值存入第二个本地变量,(istore_0,istore_1,istore_2,istore_3 它们分别表示第0,1,2,3个本地整形变量)

我们结合代码:

public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}

上面两个指令代表的意思就是,将1这个值赋值给第一个局部变量a,在执行这两行指令之前是这样的;

image.png

执行了这两行指令之后是这样的;

image.png

这就是虚拟机底层的运作过程以及内存分配情况;下面的指令跟着指令手册去看,其实都是这个道理;以此类推:

image.png

我们再看下第五行指令码:

iload_1: 从局部变量1中装载int类型值

局部变量就是上面我们图中的a=1这块小内存,把这块小内存中的值装载到操作数栈中;

image.png

iload_2:从局部变量2中装载int类型的值

image.png

这样是不是对操作数栈有点理解了,是不是有点像中转站;然后看下一个指令:

iadd:执行int类型的加法

执行这行指令码的时候2和1会从操作数栈中弹出执行相加操作,然后将结果重新放入到操作数栈;

image.png

bipush: 将一个8位带符号整数压入栈

结合代码也就是 将10 压到操作数栈中;也就是在3的上方开辟一个小小空间放入10这个变量;(这里就不画图了)

imul:执行int类型的乘法

image.png

类似于上面的操作然后将结果重新放入到操作数栈;

istore_3:将int类型的值存入局部变量3

当运行到istore_3这个指令的时候,会在局部变量表里再开辟一块小空间存放我们代码中的局部变量c;

image.png

iload_3:将30这个值放到操作数栈里面,然后执行ireturn指令将结果返回到主方法中;

现在局部变量表和操作数栈都已经很清楚了,接下来还有动态连接,和方法出口;

下面咱们先讲解方法出口,当上面的ireturn指令将结果返回到主方法中时,返回到哪个位置是又程序计数器标识的;也就是说当程序执行math.compute()这个方法的时候,程序计数器已经将这个位置记录下来了,并且存放到了当前栈帧中(栈帧-compute())的方法出口中;现在方法出口也理解了吧!

下面就要说到动态链接了,要想理解动态链接还要理解一下下面的几个概念,我们一一道来:

首先看我们的主线程main方法,main方法中有Math math = new Math();,其中math这个也是局部变量,但是这个局部变量和compute()方法中的局部变量有所不同,不同的是,math局部变量所对应的是一个new Math(),也就是对应的是一个对象,大家都知道开始也介绍到对象是放到堆中的,那这两个math一个是局部变量一个是对象,一个放到了mian栈帧的局部变量表中(往大了说就放到了栈中),一个放到了堆中,那这两个是不是有关联,那关联了什么呢?地址值,也可以说是引用,也就是说局部变量表中math存放了堆中math(对象)的地址值;

说着可能不是很好理解,那我们就用图来介绍一下:

image.png

在我们程序开发中会有很多这中情况,栈中的局部变量指向堆中的对象,那这样栈和堆是不是就有了联系!

方法区

方法区在JDK1.8的有名称叫元空间;在JDK1.8之前具体的名称叫永久代或者说叫持久代,字节码文件主要加载到方法区,但是不能简简单单的理解为直接加载到方法区,他是会将字节码文件做一系列的操作,例如:拆分,解析,加载,连接等一系列的操作,最终会变成一些类元信息,方法区其实常放的是:常量,静态变量,类元信息;类元信息可以理解为:类的组成部分,例如我们Math类有哪些方法组成,接口,常量等;

image.png

下面我们假设在主方法main中,还有一个对象例如:

public static void main(String[] args) {
Math math = new Math();
int compute = math.compute();
Math math2=new Math();
int compute2 = math2.compute();
System.out.println(compute);
}
image.png

那math和math2对象是不是由同一个类模板new 出来的!那是不是都是执行同一个compute()方法;也就是执行同一个指令码,也就是元信息也是一样的;

大家都知道对象头是对象的组成部分之一

image.png

上图中都是对象头中所包含的东西,重点看Klass Pointer(类型指针),当执行Math math=new Math(),也就是在new 对象的时候,就会在这个对象的对象头里面放一个地址(类型指针),放的就是对象所属的那个类的类元信息地址,目的就是说我要知道这个对象是由什么类new 出来的;当执行compute()方法时我很方便的就能找到对应的类元信息或者说是对应的指令码;

image.png

我们绕了一大圈,还没有说动态链接到底是什么,理解了上面的东西,下面我们就可以很好理解什么事动态链接了:动态链接也是一块内存区域,里面也放了一些值,这些值是什么东西呢?当我们的math在执行compute()这个方法的时候(math.compute())的时候,我是不是要知道这个compute()方法所对应的指令码放到哪里了,这个动态链接里面其实就是放的compute()方法它对应的那个指令码在内存里面的位置;

这里有点不好理解,我们换一种方式来说:

我们用javap -v Math.class > Math.txt来生成一个更详细的指令码; 因为内容太多就不全部贴出来了;大家可以执行以下看下里面的内容;

main方法:

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class cn/haoxiaoyong/spire/Math
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method compute:()I
12: istore_2
13: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
16: iload_2
17: invokevirtual #6 // Method java/io/PrintStream.println:(I)V
20: return

当main方法执行到9: invokevirtual #4 // Method compute:()I这一行的时候,我们看到有个#4,那我们去常量池中(Constant pool)看看#4代表的什么:

#4 = Methodref #2.#36 // cn/haoxiaoyong/spire/Math.compute:()I

#4是方法引用(Methodref),对应的是#2.#36,我们看后面的注释也能看明白,为了证明我们去看看#2代表什么

#2 = Class #35 // cn/haoxiaoyong/spire/Math

#2它代表的是一个类,这个类是哪个类呢,我们看看#35就知道了:

#35 = Utf8 cn/haoxiaoyong/spire/Math

其实就是我们的Math类;那我们在看看#36是什么东西

#36 = NameAndType #19:#20 // compute:()I

其他的#19和#20我们就不需要看了,看后面的注解就明白了其实就是compute()方法,两个结合起来就是Math.compute:()

当虚拟机解析这些符号(#4,#2…)的时候,他要解析这些符号,他要把这些符号转换成对应的直接引用,直接引用就是compute()方法对应的指令码,那指令码也是静态的;例如上面的Math.txt中的指令码,当执行java这个命令的时候就会将指令码装载到方法区中去了,转进去之后这写指令码是不是就应该有一个入口的内存地址,那实际上java虚拟机在运行到math.compute()这行代码的时候,那实际上就会把我们的compute方法这个静态的符号通过对象头的类型指针指向Math类,通过这个类型指针找到这个Math类

程序计数器

到这里我们再来看看程序计数器是个什么东西?

程序计数器存储的值就表示着马上要执行的或者说正在执行的指令码对应的行号,上面的指令码都对应着一个数字

public int compute();
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
......

可以理解为,把这个行号当做指令码在内存里的那个位置,也就是那个指针,我们程序在运行过程中是在内存中运行,这每一行指令码它在内存里面都有个位置那我在执行某一行指令的时候我肯定要知道他的位置;通过这个行号就可以找这个行指令码在内存中的位置;但是需要注意程序计数器中存储的值不是自增的;

image.png

新new出来的对象大家都知道是放在了Eden区,也就是亚当和夏娃待的地方;如果堆内存我们分配了总大小为600M,老年代2/3的比例就是400M,整个年轻代就是200M,Eden区就是160M,两个Survivor区就是40M,无论多大都会有满的时候,当Eden区放满了之后,就会发生一次GC,但是这个GC叫minorGC(youngGC),是字节码执行引擎单独开启一个线程做这次minorGC,也就是垃圾收集,这个有个概念叫什么才是垃圾对象呢?

image.png

当主方法main线程执行完毕出栈,那对应的局部变量也就消失了,那两条红色的线也就断开了,math,和math2也就游离状态了,这种对象就称之为垃圾对象;不能总是占用堆的内存;所以需要回收!在这里又会涉及到一个概念叫GC Roots根;何为GC Roots根?

将GC Roots 对象作为起点,从这写节点开始向下搜索引用对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象

GC Roots根: 线程栈的本地变量,静态变量,本地方法栈的变量等等;

image.png

像main线程中的局部变量math,math2都是属于GC Roots的一种; 当主线程还没有执行结束的时候局部变量math,和math2都还在,这样无论math或者math2都能找到在堆中的对象,那这个对象如果有依赖其他对象或者常量,然后又会去找对应的常量,那这个常量有会去找它所依赖的对象,这个就会形成一个链,在这条链条上的所有对象都是存活的对象,有引用的对象;

下面我们说一下这写存活的对象在堆中是怎么个流程,

  • 当Eden区满了时候,会经过一次minor gc ,存活的对象会从Eden区转移到Survivor区中的From区,同时会将这个对象的分代年龄加1,(分代年龄存在对象头中)

  • 不断有对象向Eden区存放,当满的时候又会经过一次minor gc ,这次回收的空间不光是Eden区,还会回收From区中的对象,没有被回收的对象(依然存活的对象)就都会存放到Survivor区中的To区,当然分代年龄继续加1,新对象分代年龄为1,从From区转移过来的对象分代年龄就是2(因为做了2次minor gc);

  • 整个程序依然还在运行,Eden区又会被放满,又要做一次minor gc,这次也是同时回收两个区域的对象,分别是Eden区和To区,没有引用的对象被回收,有引用的对象依然存活(例如:线程池,项目启动加载的一些静态变量,spring bean等),这次存活的对象都会被放到From区,同时分代年龄继续加1;Form区和To区总有一块区域是空着的;

  • 就这样循环下去,两个区域(From区和To区)来回的复制拷贝,这就涉及到垃圾回收机制的复制算法,当分代年龄增长到15的时候,也就是经历了15次的minor gc,如果还是没有被回收掉(例如:线程池,项目启动加载的一些静态变量,spring bean等),就会放到老年代中;经历15次minor gc只是放到老年代条件的之一,还有一些大对象,Survivor区不够放,会直接放到老年代;那如果老年代都放慢了就会执行一次Full GC,Full GC和minor gc的区别就是,minor gc是单独开启的一个线程执行的,不影响程序的继续工作,而Full GC是整个程序都停止掉(STW)去执行Full GC;为什么整个程序都停止掉去执行这次的Full GC呢,是因为我们上面讲到的GC Roots根,咱们先假设他没有停止掉整个程序,GC Roots根一直在寻找他的依赖引用,最终形成一个链条,因为程序没有停止,很有可能这个GC Roots根会执行完毕弹出栈,这个整个链条就没有了GC Roots根,那这次的寻找不就白白的浪费了时间吗;

本地方法区

本地方法区其实就一些native method;例如:

private native Throwable fillInStackTrace(int dummy);这种方法我们在往下看就看不到了,以前在java没有崛起的时候,很多都是用c语言写的,所以需要去调用c语言编写的代码;所以就用到了这中方式;现在已经没有必要了,现在有很多的远程调用框架,例如:Thirft,RPC 等;