深入探讨Java JVM的代码执行原理

  |   0 评论   |   0 浏览

前言

  研究JVM已经很长时间了,一直疏于总结,一是JVM本身很复杂,感觉无从下手,二是懒。所以趁最近有空,赶紧打开电脑,拿起我的keyboard,趁懒癌没发作,赶紧记录一下这一路的心得体会。刚开始接触JVM的时候,感觉是非常非常非常吃力的,有点深奥,很多专业的词汇根本不懂什么意思,所以有兴趣的同学,推荐一本书,周志明写的《深入理解Java虚拟机》,这本是看过的写JVM最棒的书,而且是国人写的,介绍非常详细,第一版和第二版我都买过,如果要敲开JVM的大门,强烈建议磕的头破血流也要读完这本书。
  言归正传,上面提到研究JVM很长一段时间了,到底多久呢?5-6年吧,刚工作4年左右就开始注意到JVM这个东西,那时候真是一根筋,觉得要了解java,一定要着手去研究JVM,事实证明没有错,而且现在很多基于JVM平台的语言,例如Scala、Groovy、JRudy、Jython、Clojure、Kotlin、Rhino、Ceylon等,所以说吃JVM这块骨头是没错的。上一篇文章讲过JVM的垃圾回收机制,参考https://blog.junxworks.cn/articles/2018/09/17/1537152072254.html,时隔整整两个月,没有写JVM相关的文章,觉得该提笔再战,总结一下自己对JVM的理解。

JVM的内存区域划分

  JVM内存区域其实还是比较大的一块话题,但是我觉得有必要先了解一下,首先要清楚JVM内部的区域是如何划分的,都存了哪些数据,对象是怎么引用的,方法是存储在哪里的,再来说如何执行的问题。说到JVM内存区域划分,很多人可能会想到堆和栈。没错,堆和栈的确是比较大的两块区域,但是JVM有更细的划分,见下图:
1598254746219982848.png

  可以看到绿色部分数据区,是线程共享的,浅蓝色部分区域是线程隔离的,也就是线程独享。
  程序计数器区:区域不大,主要用于记录当前线程所执行的字节码的行号。多线程执行的时候是通过CPU时间片轮转的方式来执行的,同一时间,处理器的一个核只能执行一个线程中的指令,因此为了线程切换后能恢复到正确的执行位置,开辟了这块区域,并且是线程独享。此区域是唯一一个没有OOM异常的区域
  虚拟机栈:也就是大家常说的栈,此处区域是线程私有的,生命周期与线程的生命周期相同。栈的结构,跟JVM的字节码执行息息相关,之前文章里面写过JVM内存模型(JMM),可以参考https://blog.junxworks.cn/articles/2018/11/07/1541570451190.html这篇文章。栈描述的是java方法执行的内存模型:每个方法在执行的时候都会创建一个栈帧(stack frame),栈帧是很关键的一个词,说起来可能很多人不理解,但是用过eclipse debug功能的都清楚,debug的时候能看到指定线程当前所执行的方法与其局部变量,如下图:
1598255085715337216.png
可以看到当前线程执行的方法,选中方法过后,能查看指定的局部变量,上面只是一个可视化过后的栈帧信息,栈帧主要包含以下区域:局部变量表、操作数栈、动态链接、方法出口等。所有java方法的执行,都是一个栈帧在虚拟机栈中入栈和出栈的过程。提示:栈帧是JVM方法执行的最小逻辑单元。这个下面会详细介绍。每个栈帧也会占用栈的内存空间,因此如果遇到很深的递归方法,容易抛出StackOverflowError,因为JVM为每个线程分配的栈空间是有限的(参考JVM的-Xss参数)。
  本地方法栈:效果跟虚拟机栈差不多,只是本地方法栈只用于执行native方法,调用过c程序的同学可能会比较熟悉。
  :这个是大家最熟悉的区域,也是刚接触java就了解的区域,这块区域的主要目的就是存放对象实例,所有对象的实例都是在堆上分配的内存空间,这块区域是所有线程共享的。随着jit编译的优化与逃逸分析技术的成熟,以后对象所在的区域分配不一定是直接在堆上,栈上分配也成为可能。堆是目前主要执行GC回收的区域。会如果分配对象遇到内存不够,会抛出OOM异常。
  方法区:这个区域可能刚接触java的人不太熟悉,这块是用来存放JVM加载的class信息、static变量、常量、jit编译过后的代码。Java7之前,大家叫这块区域为PermSpace,永久代,之所以叫永久代,是因为一般不会对这块区域做内存回收,但也不是绝对,classloader被销毁后,class也是会被卸载回收掉的。以前这块区域是要手动设置大小,到java8之后,这块区域没了,改成叫metaspace,可以动态伸缩的区域,通过监控JVM的gc回收或者是用jstat的gcutil命令可以看到,metaspace所占空间都是99%。
  运行时常量池:这块其实是方法区中的一部分,主要存放字面量和符号引用。通过javap -verbose xxx.class命令,可以查看一个class编译过后的内容,里面包含了常量池信息,可以参考一下,如下图所示:
1598255599865704448.png
  直接内存:java NIO里面引入了直接内存的概念,通过DirectByteBuffer引用直接内存,避免了数据在内存中拷贝的问题,或者是避免影响GC时间,直接内存就是绕过JVM的堆,直接采用系统的物理内存来进行操作。直接内存如何回收?记得当初有个面试官问过我这个问题,我没有答上来。后来一查,JVM是通过回收申请堆外内存的对象,来回收堆外内存的,内部有实现一套机制。底层是通过Unsafe去操作的,没有经验的同学请不要随意在生产环境中尝试。

java对象的访问

  上面写了一下关于JVM内存区域划分,其实了解这些区域是非常有必要的,下面画一张图,来了解一下线程执行的时候,是通过什么方式,来定位一个对象的方法或者属性的。如下图所示:
1598255732934193152.png

从上图可以看到,线程执行的时候,从局部变量表中,找到对象的引用,通过引用,找到对象的类型指针,通过类型指针,去访问方法区中的类型数据,例如加载对象的方法。

java方法的执行

  上面有讲到JVM的内存区域划分,与对象的访问,经典面试题里面有这么一道题,问“一个对象(两个属性,四个方法)实例化100次,现在内存中的存储状态,几个对象,几个属性,几个方法。”。如果了解JVM的内存结构划分以及其作用的话,这道题其实并不难。首先对象是在堆中进行分配的,实例化100次,那么就有100个对象,属性field和方法method,这个是class类本身的东西,是存放在方法区的,跟具体的实例对象无关,所以属性依然是两个,方法依然是四个。如果是属性变成属性值呢?那么还应该区分属性是静态值还是非静态,是变量还是常量,静态值和常量都是在方法区中分配,数量跟class本身的数量一样,变量是在堆中分配,数量跟对象数量相关。这个是关于JVM内存结构划分的,那么一个对象的method到底是如何被调用执行的呢?上面有提到过栈帧stack frame,一个对象的方法执行,一定是通过栈帧的方式被线程加载的,每一个方法从开始执行到返回结构,都对应着一个栈帧在虚拟机栈里面入栈和出栈的过程。下面这张图描述了一下栈帧的结构:
1598255854942302208.png
  栈帧主要由四个部分组成,局部变量表、操作数栈、动态链接、方法返回地址,还有一些附加的信息。

局部变量表

  这个是用来在线程栈上存储局部变量的一个table,主要存method的入参以及method内部定义的局部变量。下面以一段简单的代码为例:

public class JunxworksTest {
	public int testMethod(int i) {
		i = i++;
		int x = i;
		++x;
		return x;
	}
}

将上面这个类编译完成后,通过javap命令查看其class类的testMethod方法信息:
1598256359064088576.png

可以看到其中的局部变量表,这个表在class被编译的时候就已经确定了,首先第一个slot(全称是variable slot,简称slot)是this关键字变量,指向对象本身,现在终于明白this关键字是怎么实现的了吧?另外slot1是入参int i,slot2是局部变量int x。局部变量表一共支持8种类型的数据,分别是boolean、byte、char、short、int、float、reference和returnAddress(returnAddress目前用的非常少,好像以前是用来做异常处理的,现在已经由异常表代替),注意,并没有原始类型long、double,因为这两个是64位的,通过两个连续的32位slot组成,访问64位的数据也是读连续两个slot。那么reference是啥?这个就是大家熟悉的对象的引用。

操作数栈

  操作数栈是用来做指令计算的,所有计算的数据都是在这个栈上入栈->操作->出栈。这个既神秘又陌生的概念,其实很多人可能遇到过,以前看到过或者经历过一些面试的人,如果面试官要考你的基础知识,很可能问你一道题,那就是“i=i++”的题,这道题就是跟操作数栈有关的题,如果你之前了解过这块,那么能够很轻松的回答上来,下面还是通过简单的方式来了解一下操作数栈,及其作用。依然是上面这段简单的代码以及class类信息截图:

public class JunxworksTest {
	public int testMethod(int i) {
		i = i++;
		int x = i;
		++x;
		return x;
	}
}

我们可以看到,method的第一段代码就是i=i++。
1598256537636581376.png

i=i++对应的编译过后的指令,是什么呢?是method的code区前三行,如下所示:

0: iload_1
1: iinc          1, 1
4: istore_1

下面画几个图来解释一下这个问题。
1598257039757684736.png
iload_1,意思是将局部变量表中,slot 1这个值压到操作数栈顶,假设入参i等于1,那么这条指令将1写入到操作数栈顶位置。
1598257131713605632.png
iinc 1, 1 这条指令的意思是,将slot 1的这个变量加1,即局部变量表中slot 1的值变成了2。
1598257197497069568.png
istore_1,将操作数栈的栈顶值写入到局部变量表中slot 1的位置,即slot 1的值变成了1。
这就是为啥最后i等于1,而不是2的原因,指令将操作数栈顶的值回写到了slot 1,把2覆盖了。
  虚拟机的运算指令有很多种,iinc只是局部变量自增指令,可能用来举例操作数栈的栈上计算不太合适,不过这块用这个例子有两个原因,一是这个问题比较经典,很多初学者很困扰。二是这个问题涉及的底层还是比较深,是一个好的理解操作数栈的例子。当然虚拟机的其他运算指令,这个可以单独做成一个章节来讲解,内容很多,这里不赘述。

动态链接

  个人觉得这块不太好理解,得明白java对象的方法定位原理,即如何去确定该调用那个java对象的method?很多人可能要说,这还不简单,看代码呗。其实这里要分清楚方法调用和方法执行,方法调用只是确定具体调用哪个方法,而方法执行是需要加载具体的方法逻辑,涉及到具体的运算过程。Java的class编译是不包含方法连接的,一切方法都是只存储的符号引用,这块既带来了强大的灵活性,也带来了很大的复杂性。例如一个类的static方法,这个是编译时候已知的,能够通过静态解析的方式来确定调用的方法,而一些对继承的方法进行重写,或者是接口实现的方法,需要在运行时候来确定到底调用关系。像这种每次只能在运行期间才能转化为直接引用的方法引用,被称为动态链接Dynamic Linking。

方法返回地址

  方法调用了,但是怎么算结束呢?也就是这个方法在执行的时候,是通过什么样的机制来告诉JVM,方法结束了。这里有两种方式,来确定一个方法是否执行完毕,第一种,就是正常退出的方式,即执行引擎遇到了任意的一个方法返回的指令,这时候方法返回值会被传递给上层调用者。第二种就是异常退出。一般情况下,方法正常(非异常)返回的时候,调用者(也是其他方法)的程序计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器的值。方法本身的退出过程,就是栈帧的出栈过程,可能会执行恢复上层栈帧的局部变量表和操作数栈,将返回值(如果有)压入调用者的操作数栈,沿着程序计数器行数继续执行之前的指令。

经典面试题分析

  之前遇到过关于方法返回值的面试题,也是一个很经典的题,在熟悉上面的内容后,可以很清楚的知道答案,看一下下面的代码:

public int testMethod() {
		int i = 1;
		try {
			throw new RuntimeException();
		} catch (Exception e) {
			return i++;
		} finally {
			++i;
			return i;
		}
	}

这个方法到底返回哪个?同样,我们可以查看编译过后的类信息,看一下JVM的指令是怎么执行的:
1598257581825339392.png
可以看到,cache异常处理中,i自增运算过后,并没有ireturn指令,而是直接转向了finally块中,进行i的自增运算,最后在进行的ireturn指令,也就是最终结果是3,而不是1。ireturn的作用就是结束方法的执行并且将操作数栈栈顶的值返回给调用者。相同的代码,我们修改一下,将finally块中的return去掉,我们再看一下:

public int testMethod() {
		int i = 1;
		try {
			throw new RuntimeException();
		} catch (Exception e) {
			return i++;
		} finally {
			++i;
		}
	}

这个编译过后的指令为:
1598257746682458112.png
从上面图中看到下面一段指令:

11: iload_1
        12: iinc          1, 1
        15: istore        4
        17: iinc          1, 1
        20: iload         4
        22: ireturn

iload_1先加载slot1的int型数据到栈顶,iinc局部变量表slot 1自增1,istore 将栈顶值(1)写入局部变量表slot 4,iinc将局部变量表slot 1的值自增1,iload将局部变量表slot 4的值压入栈顶,ireturn将栈顶值(1)返回给调用者。

总结

  JVM底层这块很复杂,东西很多,需要花很多时间去理解,总结了一下学习经验,JVM这块知识点,如果不能理解,或者是平时工作中很少用到的,要经常回顾,如果遇到学习起来困难的地方,那么就采用死记硬背的方式,在脑子里面过一下,经常去回顾一下,以后还是会有惊喜的。本人经验有限,很多知识点不能面面俱到,很多点还是值得去学习,像java对象的解析这块,如何去定位一个java对象的方法调用,静态分派和动态分派,单分派和多分派等等,希望以后有机会可以再带来一些干货。


标题:深入探讨Java JVM的代码执行原理
作者:michael
地址:https://blog.junxworks.cn/articles/2018/11/16/1542343185749.html