从位运算表达式中看JVM的栈帧设计

Posted Javaesandyou

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从位运算表达式中看JVM的栈帧设计相关的知识,希望对你有一定的参考价值。

最近接盘了公司的分布式文件存储系统,其底层不出意外的采用FastDFS以及HBase作为存储中间件,在熟悉代码的时候,对FastDFS客户端的部分代码产生了疑惑,如果你看完没有疑惑就没必要继续往下阅读了,关掉页面左转,刷刷沸点,摸摸鱼不香吗?

如下图所示这是一个将字节数组转换为long的函数, 格式为big-endian(大端)

FastDFS的协议头中有8个字节用来标识数据包的长度,此函数就用于获取数据包的长度

初看觉得这就是普通的移位操作没有任何疑惑,再细看发现不少问题

  • 为什么对正负数区别对待
  • 为什么值为负数的时候要先加上负数再移动位数呢?

要解决这个问题,先简单回顾一下二进制的知识。

0x01 二进制编码

上大学时,总觉得老师讲的很无聊,上课时候总是从书包里掏出其他技术书籍来看,我总是坐在后排最靠近窗户的VIP座位,自然发现不了我在开小差,但有句话我记住了

如果以后你们以后打算继续从事这一行,你们现在欠下的技术债,总是要还的

如今,他应验了。

但凡谈及二进制,有符号数和无符号数的话题就不得不说道说道了,但是由于Java中不存在无符号数,因此重点谈一下 有符号数 的表示方法。

对于如何表示有符号数,通常有以下几种二进制编码方案

  • 反码
  • 原码
  • 补码

反码和原码的表示方法都有一个奇怪的熟悉,那就是对于数字0有两种不同的编码方式。这两种表示方法都有一个奇怪的属性,把[00..0]都解释为+0,而-0在 在原码中表示[10..0],在反码中表示为[11...1]. 但是几乎所有的现代的机器都使用补码来表示有符号数,包括Java。

引用自《CSAPP》

anyway,反码和原码并不是讨论的重点,重点看一下补码是怎么一回事.

对于一个补码,其最高位用来表示正负,为0为正数, 为1则为负数.

一个严谨的补码定义如下

还是引自《CSAPP》

  • 向量指的是二进制编码的数据,如x6指的就是二进制编码中第6位的值,x只可能取1或0
  • 通过此公式我们可以将补码转为对应的十进制数

如以下例子

0x02 你确定byte真的只占一个字节吗?

如下代码所示, 对一个byte变量进行简单的位运算操作并将其值赋值给另一个byte变量时,编译器会提示 从int转换到byte可能会有损失

这有可能的是语法层面的限制, 又或许有其他原因呢?

毕竟鲁迅说过,最终所有问题都会追溯到底层设计。

既然鲁迅发话了,就让我们来瞅一眼位运算在字节码层面是如何实现的

Java代码与字节码代码的对应的关系如下图所示

本次代码涉及到的指令不多,咱先简单介绍一下

字节码指令作用iconst_1将 int 值1推入操作数栈iconst_5将 int 值5推入操作数栈istore_1对操作数栈执行出栈操作,将返回的值赋值本地给变量表的第一个元素,此值必须是intiload_1将本地变量表的第一个元素推入操作数栈,此时该值位于操作数栈顶ishl从操作数栈中出战两个元素val1, val2,将val1左移val2位,val1和val2类型必须为int,并将结果入栈(保存到栈顶)istore_2对操作数栈执行出栈操作,将返回的值赋值本地给变量表的第二个元素,此值必须是int

操作数栈和本地变量表是啥玩意咱先暂且不论(下文再谈),但根据字节码指令来分析的话,不难得出结论,你以为你用的是byte实际上在JVM的视角来说你用的是int.

这也就是意味 byte a =-5 的实际上的二进制补码是 
11111111111111111111111111111011 ,我们的目的是将 111111011 左移N位让其回到原来的位置. 此时,如果不对负数进行处理的情况下将byte数组还原为long则必然会遇到与原数据不一致的情况,对于此种情况只需要将其与0xFF进行与运算即可获取到原数据

11111111 11111111 11111111 11111011 
& 
00000000 00000000  00000000 11111111
=
00000000 00000000  00000000 11111011
复制代码

经过如此操作再对其进行移位操作就可以将数据正确的还原到原本的位置,皆大欢喜.

上文中 256+bs[offset] 实际上等效于 bs[offset] & 0xFF

为什么会等效呢? 如果你熟悉二进制加法其实很简单,在此咱们先简单回顾一下二进制加法的规则

  • 1 + 0 = 1+ 0 = 0
  • 0 + 0 = 0
  • 1 + 1 = 10

256=00000000 00000000 00000001 00000000 

+

bs[offset]=11111111 11111111 11111111 11111011 

即可得到 000000000000000000000000 11111011 等效于 bs[offset] & 0xFF

那么为什么此处要用加法来实现这种操作呢?

呃...也许是个人喜好吧,又或者加法效率比较高?有不同看法欢迎在评论区指出哈

0x03 字节码是如何执行的

前面铺垫了这么多,终于该回归标题了,否则就成了标题党.

我们知道JVM以方法作为最基本的执行单位,栈帧(StackFrame)则是支撑虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的本地变量表、操作数栈、动态连接和方法返回信息等数据。在编译的时候就已经确定了需要多深的操作数栈以及多大的本地变量表。本地变量表中存放着方法执行期间所用到变量.

以上文的moveBit方法为例,其代码如下所示,此方法有两个变量

那么在执行方法调用时,其操作数栈和本地变量表如下图所示

初看此图,你可能会有疑惑,为啥本地变量表里面还有this?实际上这个操作是编译器帮你做,你能在方法中使用this全赖于此.举个相反的例子,在Python的面向对象编程中,必须在方法的声明中明确传入self(this),才能通过self访问到类的数据, 不妨看看以下代码.

#!/usr/bin/python
# -*- coding: UTF-8 -*-
 
class Employee:
   '所有员工的基类'
   empCount = 0
 
   def __init__(self, name, salary):
      self.name = name
      self.salary = salary
      Employee.empCount += 1
   
   def displayCount(self):
     print "Total Employee %d" % Employee.empCount
 
   def displayEmployee(self):
      print "Name : ", self.name,  ", Salary: ", self.salary
复制代码

接下来,我们跟字节码走一遍,看看JVM是如何执行字节码的

iconst_1 将常量1推入操作数栈(push),执行完后操作数栈如下所示

istore_1 将操作数栈顶的元素出栈,赋值给本地变量表的第一个Slot

即 本地变量表[1] = 操作数栈.pop()

iload_1 将本地变表的第一个Slot值入栈,执行完后操作数栈如下所示

即 操作数栈.push(本地变量表[1])

iconst_5 将常量值5推入操作数栈,执行完后操作数栈如下所示

ishl 出栈两个元素执行左移位操作,将结果入栈即,执行完之后操作数栈如下所示

var1 = 操作数栈.pop();
var2 = 操作数栈.pop();
操作数栈.push(var2 << var1);
复制代码

istore_2 将操作数栈顶的元素出栈,赋值给本地变量表的第二个Slot

即 本地变量表[1] = 操作数栈.pop()

理解完操作数栈和本地变量表是如何互相搭配完成工作的之后,还有一个疑问没解决,从上面的分析可以看出本地变量表是以为Slot(槽位)作为基本分配单位的,那么问题来了本地变量表的一个Slot(槽位)占据多少空间呢?

这一点虚拟机规范尚未明确,但一般来说是4字节,也就是32位,对于64位(8字节)的数据则需要连续分配两个槽位.

考虑如下代码

其本地变量表如下图所示

0x04 一点疑惑

就我而言,由于学习过汇编的原因,了解JVM字节码执行原理时,用标题党的话来说就是震惊,没想到还有这种操作,JVM竟然是基于栈的虚拟机执行引擎,其特点就是进行数据运算的时候要先把数据出栈,执行完之后再将结果入栈,相反直觉.相反,寄存器的设计可以在寄存间直接进行数据运算,并将结果保存到寄存器.

但实际上性能并不低,本地变量表的设计和操作数栈都能很有效的利用CPU的高速缓存.

那有没有同汇编一样基于寄存器的执行引擎呢?

还真有,它经常作为内嵌的执行引擎引入到各大应用如Redis,nginx,没错它就是Lua.

重中之重!!面对JVM时,栈帧之局部变量表的重要性不用我来说吧!



并行每个线程下的栈都是私有的,因此每个线程都有自己各自的栈,并且每个栈里面都有很多栈帧,栈帧的大小主要由局部变量表和操作数栈决定的


1.栈帧的内部结构

每个栈帧中存储着:

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)(或表达式栈)
  • 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
  • 一些附加信息
    重中之重!!面对JVM时,栈帧之局部变量表的重要性不用我来说吧!_栈
    关注我日常更新分享Java基础编程技术!
    加入我的Q交流君样:816244058大家一起学习交流

2.局部变量表

2.1 什么是局部变量表

局部变量表也被称之为局部变量数组或本地变量表

定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量**,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress返回值类型。

由于局部变量表是建立在线程的栈上,是线程的私有数据,因此 不存在数据安全问题

局部变量表所需的容量大小是在编译期确定下来的 ,并保存在方法的Code属性的 ​​maximum local variables​​ 数据项中。在方法运行期间是不会改变局部变量表的大小的。

方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。

对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。

进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。

局部变量表中的变量只在当前方法调用中有效。

在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。

当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(​​boolean、byte、char、short、int、float、long、double​​)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和​​returnAddress​​类型(指向了一条字节码指令的地址)。

这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。请读者注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照1个变量槽占用32个比特、64个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。


在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展[2],当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

--------摘自《深入理解java虚拟机》


对于局部变量表所需的容量大小是在编译期确定下来的这句话可以通过看字节码文件

源代码:

public class Example 
public static void main(String[] args)
int a = 3;
a++;
testStatic();
System.out.println(a);



public static void testStatic()
Date date = new Date();
int count = 10;
System.out.println(count);

字节码:

重中之重!!面对JVM时,栈帧之局部变量表的重要性不用我来说吧!_栈_02

可以看到 locals=2 说明了局部变量表的大小为2 ,而这两个变量为data和count,所以说局部变量表所需的容量大小是在编译期确定下来的。(此时代码只是进行了编译还未运行)

通过使用jclasslib来看字节码,进行一些相关解释。

1.字节码行号

重中之重!!面对JVM时,栈帧之局部变量表的重要性不用我来说吧!_字节码_03

字节码中左边的数字表示的是有多少行字节码0~15也就是有16行。

2.方法异常信息表

重中之重!!面对JVM时,栈帧之局部变量表的重要性不用我来说吧!_局部变量_04

此为异常信息表,当前方法没有异常所以没有异常表。

关注我日常更新分享Java基础编程技术!

加入我的Q交流君样:816244058大家一起学习交流

4、行号表

Java代码的行号和字节码指令行号的对应关系

重中之重!!面对JVM时,栈帧之局部变量表的重要性不用我来说吧!_栈_05

5、生效行数和剩余有效行数(针对于字节码文件的行数)

重中之重!!面对JVM时,栈帧之局部变量表的重要性不用我来说吧!_栈_06

图中标记的地方表示的是该局部变量的作用域,初始PC(Start PC)为2表示该局部变量在字节码的第2行开始生效,字节码的第2行对应着java代码的第8行(由上一张图可知),而int a的定义是在第7行,可以得知 局部变量是从声明的下一行生效的 。

长度(Length)表示剩余有效行数,main方法字节码指令总共有16行,从2行开始生效,那么剩下就是16-2 =14。

描述符(Descriptor)第一行 [Ljava/lang/String 表示args的引用类型(String[]),第二行 I 表示的是a的引用类型(int)

2.2 关于Slot的理解

  • 参数值的存放总是从局部变量数组索引 0 的位置开始,到数组长度-1的索引结束。 局部变量表, 最基本的存储单元是Slot(变量槽)
    ,局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。
  • 在局部变量表里, 32位以内的类型只占用一个slot (包括returnAddress类型), 64位的类型占用两个slot(long和double)。
  • byte、short、char在储存前被转换为int,boolean也被转换为int,0表示false,非0表示true
  • long和double则占据两个slot
  • JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
  • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上
  • 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long或double类型变量)
  • 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。(this也相当于一个变量)
    重中之重!!面对JVM时,栈帧之局部变量表的重要性不用我来说吧!_java_07

2.3 代码示例

public class Example 
public int sum = 0;

public static void main(String[] args)

new Example().test();
关注我日常更新分享Java基础编程技术!
加入我的Q交流君样:816244058大家一起学习交流


public void test()
this.sum++;
double a = 3;
long b = 4;

重中之重!!面对JVM时,栈帧之局部变量表的重要性不用我来说吧!_jvm_08

  • 可以看到this存放在index = 0的位置
  • 64位的类型(long和double)占用两个slot,序号直接从1变成了3

注意:

  • this 不存在与 static 方法的局部变量表中,所以无法调用。
  • static 修饰的方法是属于类的, 该方法的调用者可能是一个类 ,而不是对象。 那么,如果使用的是类来 调用 而不是对象,则 this
    就无法指向合适的对象,所以 static 修饰的方法中不能使用 this

2.4 Slot的重复利用

栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明新的局部变量变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

public void test() 
int a = 0;

int b = 0;
b = a + 1;

//变量c使用之前已经销毁的变量b占据的slot的位置
int c = a + 1;

重中之重!!面对JVM时,栈帧之局部变量表的重要性不用我来说吧!_字节码_09

2.5 静态变量与局部变量的对比

变量的分类:

  • 按照数据类型分:① 基本数据类型 ② 引用数据类型
  • 按照在类中声明的位置分:
  • 成员变量:在使用前,都经历过默认初始化赋值
  • 类变量: linking的prepare阶段:给类变量默认赋值 —> initial阶段:给类变量显式赋值即静态代码块赋值
  • 实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
  • 局部变量:在使用前,必须要进行显式赋值!否则,编译不通过

变量的赋值:

  • 参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。
  • 我们知道成员变量有两次初始化的机会**,**第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。
  • 和类变量初始化不同的是, 局部变量表不存在系统初始化的过程 ,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。

补充说明

  • 在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
  • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。​​​


以上是关于从位运算表达式中看JVM的栈帧设计的主要内容,如果未能解决你的问题,请参考以下文章

JVM的内存区域划分

重中之重!!面对JVM时,栈帧之局部变量表的重要性不用我来说吧!

java9新特性-Stack Walking-当前线程栈信息

金三银四面试季节——Java 核心面试技术点-《JVM篇》

金三银四面试季节之Java 核心面试技术点 - JVM 小结

JVM的栈内存