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

本贴最后更新于 1894 天前,其中的信息可能已经事过境迁

前言

  研究 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 有更细的划分,见下图:
JVMpng
  可以看到绿色部分数据区,是线程共享的,浅蓝色部分区域是线程隔离的,也就是线程独享。
  程序计数器区:区域不大,主要用于记录当前线程所执行的字节码的行号。多线程执行的时候是通过时间片轮转的方式来执行的,同一时间,处理器的一个核只能执行一个线程中的指令,因此为了线程切换后能恢复到正确的执行位置,开辟了这块区域,并且是线程独享。此区域是唯一一个没有 OOM 异常的区域
  虚拟机栈:也就是大家常说的栈,此处区域是线程私有的,生命周期与线程的生命周期相同。栈的结构,跟 JVM 的字节码执行息息相关,之前文章里面写过 JVM 内存模型(JMM),可以参考 https://blog.junxworks.cn/articles/2018/11/07/1541570451190.html 这篇文章。栈描述的是 java 方法执行的内存模型:每个方法在执行的时候都会创建一个栈帧(stack frame),栈帧是很关键的一个词,说起来可能很多人不理解,但是用过 eclipse debug 功能的都清楚,debug 的时候能看到指定线程当前所执行的方法与其局部变量,如下图:debugpng
可以看到当前线程执行的方法,选中方法过后,能查看指定的局部变量,上面只是一个可视化过后的栈帧信息,栈帧主要包含以下区域:局部变量表、操作数栈、动态链接、方法出口等。所有 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 编译过后的内容,里面包含了常量池信息,可以参考一下,如下图所示:
png
  直接内存:java NIO 里面引入了直接内存的概念,通过 DirectByteBuffer 引用直接内存,避免了数据在内存中拷贝的问题,或者是避免影响 GC 时间,直接内存就是绕过 JVM 的堆,直接采用系统的物理内存来进行操作。直接内存如何回收?记得当初有个面试官问过我这个问题,我没有答上来。后来一查,JVM 是通过回收申请堆外内存的对象,来回收堆外内存的,内部有实现一套机制。底层是通过 Unsafe 去操作的,没有经验的同学请不要随意在生产环境中尝试。

java 对象的访问

  上面写了一下关于 JVM 内存区域划分,其实了解这些区域是非常有必要的,下面画一张图,来了解一下线程执行的时候,是通过什么方式,来定位一个对象的方法或者属性的。如下图所示:
javapng
从上图可以看到,线程执行的时候,从局部变量表中,找到对象的引用,通过引用,找到对象的类型指针,通过类型指针,去访问方法区中的类型数据,例如加载对象的方法。

java 方法的执行

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

局部变量表

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

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

将上面这个类编译完成后,通过 javap 命令查看其 class 类的 testMethod 方法信息:
testMethodpng
可以看到其中的局部变量表,这个表在 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++。
testMethodpng
i=i++ 对应的编译过后的指令,是什么呢?是 method 的 code 区前三行,如下所示:

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

下面画几个图来解释一下这个问题。
1png
iload_1,意思是将局部变量表中,slot 1 这个值压到操作数栈顶,假设入参 i 等于 1,那么这条指令将 1 写入到操作数栈顶位置。
2png
iinc 1, 1 这条指令的意思是,将 slot 1 的这个变量加 1,即局部变量表中 slot 1 的值变成了 2。
3png
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 的指令是怎么执行的:
ireturnpng
可以看到,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;
		}
	}

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

        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 对象的方法调用,静态分派和动态分派,单分派和多分派等等,希望以后有机会可以再带来一些干货。

  • JVM

    JVM(Java Virtual Machine)Java 虚拟机是一个微型操作系统,有自己的硬件构架体系,还有相应的指令系统。能够识别 Java 独特的 .class 文件(字节码),能够将这些文件中的信息读取出来,使得 Java 程序只需要生成 Java 虚拟机上的字节码后就能在不同操作系统平台上进行运行。

    180 引用 • 120 回帖 • 2 关注
  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3165 引用 • 8206 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...