探究Java虚拟机栈

前言

Java 虚拟机的内存模型分为两部分:一部分是线程共享的,包括 Java 堆和方法区;另一部分是线程私有的,包括虚拟机栈和本地方法栈,以及程序计数器这一小部分内存。今天我就 Java 虚拟机栈做一些比较浅的探究。

熟悉 Java 的同学应该都知道了,JVM 是基于栈的。但是这个“栈” 具体指的是什么?难道就是虚拟机栈?想要回答这个问题我们先要从虚拟机栈的结构谈起。

虚拟机栈

何为虚拟机栈
虚拟机栈的栈元素是栈帧,当有一个方法被调用时,代表这个方法的栈帧入栈;当这个方法返回时,其栈帧出栈。因此,虚拟机栈中栈帧的入栈顺序就是方法调用顺序。什么是栈帧呢?栈帧可以理解为一个方法的运行空间。它主要由两部分构成,一部分是局部变量表,方法中定义的局部变量以及方法的参数就存放在这张表中;另一部分是操作数栈,用来存放操作数。我们知道,Java 程序编译之后就变成了一条条字节码指令,其形式类似汇编,但和汇编有不同之处:汇编指令的操作数存放在数据段和寄存器中,可通过存储器或寄存器寻址找到需要的操作数;而 Java 字节码指令的操作数存放在操作数栈中,当执行某条带 n 个操作数的指令时,就从栈顶取 n 个操作数,然后把指令的计算结果(如果有的话)入栈。因此,当我们说 JVM 执行引擎是基于栈的时候,其中的“栈”指的就是操作数栈。举个简单的例子对比下汇编指令和 Java 字节码指令的执行过程,比如计算 1 + 2,在汇编指令是这样的:

mov ax, 1 ;把 1 放入寄存器 ax
add ax, 2 ;用 ax 的内容和 2 相加后存入 ax
而 JVM 的字节码指令是这样的:

iconst_1 //把整数 1 压入操作数栈
iconst_2 //把整数 2 压入操作数栈
iadd //栈顶的两个数相加后出栈,结果入栈
由于操作数栈是内存空间,所以字节码指令不必担心不同机器上寄存器以及机器指令的差别,从而做到了平台无关。

注意,局部变量表中的变量不可直接使用,如需使用必须通过相关指令将其加载至操作数栈中作为操作数使用。比如有一个方法 void foo(),其中的代码为:int a = 1 + 2; int b = a + 3;,编译为字节码指令就是这样的:

iconst_1 //把整数 1 压入操作数栈
iconst_2 //把整数 2 压入操作数栈
iadd //栈顶的两个数出栈后相加,结果入栈;实际上前三步会被编译器优化为:iconst_3
istore_1 //把栈顶的内容放入局部变量表中索引为 1 的 slot 中,也就是 a 对应的空间中
iload_1 // 把局部变量表索引为 1 的 slot 中存放的变量值(3)加载至操作数栈
iconst_3
iadd //栈顶的两个数出栈后相加,结果入栈
istore_2 // 把栈顶的内容放入局部变量表中索引为 2 的 slot 中,也就是 b 对应的空间中
return // 方法返回指令,回到调用点
需要说明的是,局部变量表以及操作数栈的容量的最大值在编译时就已经确定了,运行时不会改变。并且局部变量表的空间是可以复用的,例如,当指令的位置超出了局部变量表中某个变量 a 的作用域时,如果有新的局部变量 b 要被定义,b 就会覆盖 a 在局部变量表的空间。

盗用别人的图以让大家对虚拟机栈有个直观的认识(其中小字体 Stack 指的的是虚拟机栈,Frame 是栈帧,Local variables 是局部变量表,Operand Stack 是操作数栈):

由虚拟机栈引出的问题

看完上面的代码大家可能会有几点疑惑:什么是 slot?那些指令是什么意思?为什么 a 对应的 slot 的索引值不是从零开始的,它明明是第一个定义的变量啊?

对于这些问题我们一个个来解决。

什么是 slot
首先什么是 slot?slot 是局部变量表中的空间单位,虚拟机规范中有规定,对于 32 位之内的数据,用一个 slot 来存放,如 int,short,float 等;对于 64 位的数据用连续的两个 slot 来存放,如 long,double 等。引用类型的变量 JVM 并没有规定其长度,它可能是 32 位,也有可能是 64 位的,所以既有可能占一个 slot,也有可能占两个 slot。

JVM 字节码指令
第二个问题,那些指令是什么意思?

指令格式

首先我们要理解 Java 指令的格式,Java 的指令以字节为单位,也就是一个字节代表一条指令。比如 iconst_1 就是一条指令,它占一个字节,那么自然 Java 指令不会超过 256 条。实际上 Java 指令目前定义了 200 多条。指令虽然是一个字节,但是它也可以带自己的操作数。JVM 中有这样一条指令 putstatic,其作用是给特定的的静态字段赋值。但是给哪个字段赋值呢?仅仅通过这条指令并不能说明,那么只有通过操作数来指定了。紧跟在 putstatic 后面的两个字节就是它的操作数,这个操作数是一个索引值,指向运行时常量池中该静态字段对应的符号引用。由于符号引用包含了该字段的基本信息,如所属类、简单名称以及描述符,因此 putstatic 指令就知道是给哪个类的哪个字段赋值了。

指令的操作数分两种:一种是嵌入在指令中的,通常是指令字节后面的若干个字节;另一种是存放在操作数栈中的。为了区别,我们把前者叫做嵌入式操作数,把后者叫做栈内操作数。这两者的区别是:嵌入式操作数是在编译时就已经确定的,运行时不会改变,它和指令一样存放于类文件方法表的 Code 属性中;而操作数是运行时确定的,即程序在执行过程中动态生成的。拿 putstatic 指令来说,它有一个嵌入式操作数,该操作数是一个索引值(前面已经提到),它由两个字节组成,紧跟在 putstatic 对应的字节之后;同时它还有一个栈内操作数,位于操作数栈的栈顶,这个操作数就是要赋给静态字段的值,其对应的字节数根据静态字段的类型决定。如果静态字段的类型是 short、int、boolean、char 或者 byte,那么这个操作数就必须是 int 类型,即由栈顶的 4 个字节组成;如果是 float、double 或者 long 类型,那么操作数就是相应的类型,即由栈顶的 4 个、8 个 或者 8 个 字节组成;如果静态字段是引用类型,那么这个操作数的类型也必须是引用类型,即由栈顶的 8 个字节组成。

再举一个例子。iconst_<i> 代表了一个指令族,它的意思是把整数 i 放入操作数栈中,i 的范围是(m1, 0, 1, 2, 3, 4, 5),其中 m1 代表的是 -1。注意,这里的 i 并不是指令的操作数(即非嵌入式操作数,也非栈内操作数),如 iconst_1、iconst_2 和 iconst3 都是由一个字节组成的字节码指令。我们可以把 i 可以看作是指令的 “隐含操作数”,即指令本身就蕴含了操作数。如果整数 i 超过 [-1, 5] 这个范围,就不能用 iconst<i> 表示了,因为仅一个字节的字节码指令不可能蕴含所有的整数。此时就需要 bipush 这条指令了,这条指令有一个嵌入式操作数,由一个字节组成,用来表示要放入栈顶的那个整数,该整数放入栈顶时通过扩展符号位变为 32 位的整型。但是一个字节也表示不了所有的整数,如果整数值超过一个字节所能表示的范围,就只能通过 ldc 这条指令了,这条指令带有一个字节的嵌入式操作数,它代表的是一个指向运行时常量池中 Constant_Integer_info 类型常量的索引,通过索引的方式引用运行时常量池中的整数,再大的整数也不怕了。

阅读指令文档

授之以鱼不如授之以渔,在这里不可能将所有的指令都讲解一番,因此我和大家分享一下如何阅读 oracle 官网关于字节码指令的文档吧。文档的地址是:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html

我们拿 astore 指令来说: 关于它的文档描述如下:

astore 指令
说明和翻译:

第一行的粗体字是指令的名称;
Operation 是指令的功能:把引用存入本地变量中;
Format 是指令的格式:它的第一个字节是指令,名称为 astore,第二个字节是指令的嵌入式操作数,名称为 index;Forms 指的是指令的十进制(十六进制)码,astore 的十进制(十六进制)码是 58(0x3a);
Operation Stack 是指令执行前后的操作数栈的状态:第一行代表的是指令执行前操作数的状态,第二行是指令执行后操作数栈的状态,箭头是栈顶方向。astore 执行前栈顶是对象引用 objectRef,它是 astore 的栈内操作数,执行后 objectRef 被弹出并存入局部变量表中;
Description 是对这条指令的描述:index 是无符号字节,这个 index 必须指向当前栈帧的局部变量表的某个位置。操作数栈的栈顶的那个引用值必须是 returnAddress(方法返回地址)或者是 reference (对象引用)。这个引用会被弹出,其值会被存入局部变量表中索引为 index 的 slot 中;
Notes 是注意事项:实现 Java 中的 finally 子句时,astore 指令使用的操作数类型是一个 returnAddress,与 astore 对应的 aload 指令(将局部变量表的的引用值压栈)不能将类型为 returnAddress 类型的值加载到操作数栈,而只能是 reference 类型。aload 和 astore 这种不对称的设计是有意而为之的。astore 指令可以和 wide 指令配合使用以用无符号双字节类型的索引来获取局部变量表中的变量。

局部变量表的第一个变量
从 Java 语言的层面讲,静态方法和实例方法的本质区别在于是否是对象所共享的。而从 JVM 的角度来看,方法(无论静态方法还是实例方法)其实都是对象共享的,实例变量才是对象私有的。对 JVM 而言,静态方法和实例方法的本质区别在于是否需要和具体对象关联:静态方法可以通过类名来调用,它不需要和具体对象关联;而实例方法必须通过对象来进行调用,它需要和具体对象关联。那么,实例方法和具体对象是如何产生关联的呢?其实很简单,编译器在编译时会将方法接收者作为一个隐含参数传入该实例方法,这个参数在方法中有一个很熟悉的名字,叫做 “this”。之所以实例方法可以访问该类的实例变量和其它实例方法,正是因为它有 “this” 这个隐含参数。举个例子,类 A 中的某个方法 b 需要访问实例变量 x,由于实例变量是对象私有的,如果 b 是静态方法,由于它没有具体对象的引用,它并不知道该访问哪个对象的实例变量 x;如果 b 是实例方法,通过隐含参数 this 就能确定要访问的实例变量是 this.x。那么,为什么静态方法也不能调用该类的实例方法呢?本质原因也是没有 this 引用。因为调用实例方法的前提是要传入一个隐含参数,实例方法本来就有这个引用,所以能够把它作为隐含参数传入另一个实例方法;静态方法没有 this 引用,无法给实例方法提供指向方法接收者的隐含参数,因此不能调用实例方法。

如果看懂了上面说的那些,第三个问题也就迎刃而解了。因为我们定义的方法是 void foo(),它是实例方法,因此会有一个指向具体对象的隐含参数 this,this 就存放在局部变量表的第一个位置,即存放在索引为 0 的 slot 中,又由于它的作用域从方法开始一直到方法结束,因此它在局部变量表中的位置不会被其他变量覆盖,从而使得我们在方法中定义的变量只能放在局部变量表后面的位置中。需要注意的是,如果方法有参数(非隐含参数),那么参数会按顺序紧接着 this 存放在局部变量表中,由于参数作用域也是整个方法体,所以方法中定义的局部变量就只能放在参数后面了。总的来说局部变量表中变量的存放顺序为: this(如果是实例方法)=> 参数(如果有的话)=> 定义的局部变量(如果有的话)。

感谢阅读

关于虚拟机栈就讲这么多了,Java 虚拟机是一个完整的知识体系,仅仅了解虚拟机栈是不够的,这里没有细讲的关于虚拟机的其它知识,如内存模型、运行时常量池、类加载模型等,还需读者自己学习掌握。这篇文章权起激发大家的学习 JVM 兴趣的作用,同时也作为个人的学习记录和知识总结。之后可能还会写些 JVM 其它方面的总结性文章和大家分享。由于个人水平和理解有限,如果有不对的地方还请大家不吝赐教,感谢阅读!

原文地址:http://blog.51cto.com/13842645/2307586

时间: 2024-10-10 00:52:52

探究Java虚拟机栈的相关文章

JAVA虚拟机栈

JAVA虚拟机栈几个重要概念 java 虚拟机栈 = java virtual machine stacks = jvms 1.JVMS 线程私有,生命周期与线程相同 2.JVMS 描述的是JAVA方法执行的内存模型:JVMS在方法执行时会创建一个"栈帧" stack frame,用于记录局部变量表.操作数栈.动态链接.方法出口等,每一个方法从调用到完成的过程,就是一个栈帧在JVMS中入栈到出栈的过程 3.局部变量表存放基本数据类型.对象引用.returnAddress(字节码指令地址

Java虚拟机栈和本地方法栈

Java虚拟机栈的特征 线程私有 后进先出(LIFO)栈 存储栈帧,支持Java方法的调用.执行和退出 可能出现OutOfMemoryError异常和StackOverflowError异常 Java本地方法栈的特征 线程私有 后进先出(LIFO)栈 作用是支撑Native方法的调用.执行和退出 可能出现OutOfMemoryError异常和StackOverflowError异常 有一些虚拟机(如HotSpot)将Java虚拟机栈和本地方法栈合并实现 栈帧的概念和特征 Java虚拟机栈中存储的

java 虚拟机栈

与程序计数器一样,Java虚拟机栈也是线程私有的,他的生命周期与线程相同.虚拟机栈描述的是Java执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧用于存储局部变量表,操作栈,动态链接,方法出口等信息.每个方法被调用直至执行完成的过程,就对应一个虚拟机栈由入栈到出栈的过程. 局部变量表存放了编译期可知的各种基本数据类型(boolean,byte,char,short,int,long,double),对象引用(reference类型,他不同于对象本身,根据不同的虚拟机实现,它可能是一个指向

JVM 运行时数据区:程序计数器、Java 虚拟机栈和本地方法栈,方法区和堆

Java 虚拟机可以看作一台抽象的计算机,如同真实的计算机,它也有自己的指令集和运行时内存区域. Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存(运行时内存区域)划分为若干个不同的数据区域. 如下图所示: 一.程序计数器 Program Counter Register 定义:程序计数器是当前线程所执行字节码的行号指示器. 原因:Java 中的多线程是线程间轮流切换并需要 CPU 给予时间片的方式实现的.在任何一个确定的时刻,都只有一个线程在执行指令.为了线程间轮流切换后能够快

JVM运行时数据区--java虚拟机栈

虚拟机栈的背景 由于跨平台性的设计,java的指令都是根据栈来设计的.不同平台CPU架构不同,所以不能设计为基于寄存器的. 优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令. 内存中的堆与栈 栈是运行时的单位,而堆是存储的单位1.栈解决程序的运行问题,即程序如何执行,或者说如何处理数据.堆解决的是数据存储的问题,即数据怎么放.放在哪儿. 2.一般来讲,对象主要都是放在堆空间的,是运行时数据区比较大的一块 3.栈空间存放 基本数据类型的局部变量,以及引用数据类型

第二章:Java虚拟机栈和本地方法栈溢出

由于在Hotspot虚拟机中中不区分虚拟机栈和本地方法栈,因此通过-Xoss修改参数是无效的,可以通过修改-Xss设定. 如果线程请求的栈深度大于虚拟机允许的最大深度,将抛出StackOverflowError异常. 如果虚拟机在扩展栈时无法申请到足够的内存空间,将抛出OutOfMemoryError异常. 这两种异常有一些重叠的部分:当栈空间无法继续分配时,到底是内存太小,还是已经使用的栈空间过大,其本质只是对同一件事情的两种不同描述. 可以通过一下方法验证: 在使用-Xss参数减少栈内存容量

Java虚拟机:内存模型详解

版权声明:本文为博主原创文章,转载请注明出处,欢迎交流学习! 我们都知道,当虚拟机执行Java代码的时候,首先要把字节码文件加载到内存,那么这些类的信息都存放在内存中的哪个区域呢?当我们创建一个对象实例的时候,虚拟机要为对象分配内存,Java虚拟机又是如何配分内存的呢?这些都涉及到Java虚拟机的内存划分机制,今天我们就来探究一下Java虚拟机的内存模型. Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途以及创建和销毁的时间,有的区域随

深入理解java虚拟机-java内存区域与内存溢出异常

线程 运行数据区分为线程共享区和线程独占区 线程独占区的意思是每开启一个线程就会拥有一个线程独占区域 如下图所示: 程序计数器: 1)是一块较小的内存空间,可看做当前线程所执行的字节码的行号指示器 2)它处于线程独占区(每一个线程都会有自己的线程独占区) 3)如果线程执行的是java方法,这个计数器记录的就是正在执行的虚拟机字节码指令的地址,如果正在执行的是native方法,这个计数器的值为undefined 4) 此区域是唯一一个在java虚拟机规范中没有规定任何OutofMemory的情况的

Java虚拟机内存模型

1)程序计数器:线程私有 当线程数量超过CPU数量时,线程之间根据时间片轮询抢夺CPU资源,对于单核CPU来说,每一个时刻,只能有一个线程在运行,而其他线程必须被切换出去.因此,每个线程都有一个独立的程序计数器,用于记录下一条要运行的指令,各个线程之间的计数器互不影响,是一块线程的私有内存空间.当一个线程正在执行一个Java方法时,程序计数器记录正在执行的Java字节码地址,如果执行的是native方法,则计数器为空 2)Java虚拟机栈:线程私有 同Java线程同时间创建,用于保存方法的局部变