【JVM深度解析】字节码指令和存储引擎

本文涉及的产品
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
简介: 字节码指令属于Class文件那个位置?常写的代码后的字节码你知道多少?Integer127的缓存能不能变?...不懂?一文带你深入浅出了解字节码指令和Java存储引擎

 本文思维导图:

image.gif编辑

字节码指令

字节码指令属于方法表中的内容:

方法表,是一个表结构,表中每个成员必须是method_ info 数据结构,用于表示当前类或者接口的某个方法的完整描述,方法表见Class文件结构 面试官:说说你了解Class文件结构?_小明java问道之路的博客-CSDN博客

Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。

由于限制了 Java 虚拟机操作码的长度为一个字节(即 0~255),这意味着指令集的操作码总数不可能超过 256 条。

大多数的指令都包含了其操作所对应的数据类型信息。例如:iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据。大部分的指令都没有支持整数类型 byte、char 和 short,甚至没有任何指令支持 boolean 类型。大多数对于 boolean、byte、short 和 char 类型数据的操作,实际上都是使用相应的 int 类型作为运算类型

阅读字节码作为了解 Java  虚拟机的基础技能,有需要的话可以去掌握常见指令。字节码助记码解释地址:[三] java虚拟机 JVM字节码 指令集 bytecode 操作码 指令分类用法 助记符 - 腾讯云开发者社区-腾讯云

加载和存储指令

用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括如下内容。

将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。

将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。

将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。

扩充局部变量表的访问索引的指令:wide。

运算或算术指令

用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。

加法指令:iadd、ladd、fadd、dadd。

减法指令:isub、lsub、fsub、dsub。

乘法指令:imul、lmul、fmul、dmul 等等

类型转换指令

可以将两种不同的数值类型进行相互转换,Java 虚拟机直接支持以下数值类型的宽化类型转换(即小范围类型向大范围类型的安全转换):

int 类型到 long、float 或者 double 类型。

long 类型到 float、double 类型。

float 类型到 double 类型。

处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换指令来完成,这些转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l 和d2f。

创建类实例的指令

new。

创建数组的指令

newarray、anewarray、multianewarray。

访问字段指令

getfield、putfield、getstatic、putstatic。

数组存取相关指令

把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。

将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。

取数组长度的指令:arraylength。

检查类实例类型的指令

instanceof、checkcast。

操作数栈管理指令

如同操作一个普通数据结构中的堆栈那样,Java 虚拟机提供了一些用于直接操作操作数栈的指令,包括:将操作数栈的栈顶一个或两个元素出栈:pop、pop2。

复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。

将栈最顶端的两个数值互换:swap。

控制转移指令

控制转移指令可以让 Java 虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,从概念模型上理解,可以认为控制转移指令就是在有条件或无条件地修改 PC 寄存器的值。控制转移指令如下。

条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq 和 if_acmpne。

复合条件分支:tableswitch、lookupswitch。

无条件分支:goto、goto_w、jsr、jsr_w、ret。

方法调用指令

invokevirtual 指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是 Java 语言中最常见的方法分派方式。

invokeinterface 指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。

invokespecial 指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。

invokestatic 指令用于调用类方法(static 方法)。

invokedynamic 指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面 4 条调用指令的分派逻辑都固化在 Java 虚拟机内部,而invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。

方法调用指令与数据类型无关。

方法返回指令

是根据返回值的类型区分的,包括 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 和 areturn,另外还有一条 return 指令供声明为 void 的方法、实例初始化方法以及类和接口的类初始化方法使用。

异常处理指令

在 Java 程序中显式抛出异常的操作(throw 语句)都由 athrow 指令来实现

同步指令

有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义

字节码指令——异常处理

每个时刻正在执行的当前方法就是虚拟机栈顶的栈桢。方法的执行就对应着栈帧在虚拟机栈中入栈和出栈的过程。

当一个方法执行完,要返回,那么有两种情况,一种是正常,另外一种是异常。

完成出口(返回地址):

正常返回:(调用程序计数器中的地址作为返回)

三步曲:

恢复上层方法的局部变量表和操作数栈、

把返回值(如果有的话)压入调用者栈帧的操作数栈中、

调整程序计数器的值以指向方法调用指令后面的一条指令、

异常的话:(通过异常处理表<非栈帧中的>来确定)

异常机制

image.gif编辑

如果你熟悉 Java 语言,那么对上面的异常继承体系一定不会陌生,其中,Error 和 RuntimeException 是非检查型异常(Unchecked Exception),也就是不需要 catch 语句去捕获的异常;而其他异常,则需要程序员手动去处理。

异常表

在 synchronized 生成的字节码中,其实包含两条 monitorexit 指令,是为了保证所有的异常条件,都能够退出。d

/**
 * @author macfmc
 * @date 2020/8/22-13:31
 */
public class SynchronizedDemo {
    synchronized void m1() {
        System.out.println("m1");
    }
    static synchronized void m2() {
        System.out.println("m2");
    }
    final Object lock = new Object();
    void doLock() {
        synchronized (lock) {
            System.out.println("Lock");
        }
    }
}

image.gif

可以看到,编译后的字节码,带有一个叫 Exception table 的异常表,里面的每一行数据,都是一个异常处理器:

一、from 指定字节码索引的开始位置

二、to 指定字节码索引的结束位置

三、target 异常处理的起始位置

四、type 异常类型

image.gif编辑

也就是说,只要在 from 和 to 之间发生了异常,就会跳转到 target 所指定的位置。我可以看到,第一条 monitorexit(16)在异常表第一条的范围中,如果异常,能够跳转到第 20 行,第二条 monitorexit(22)在异常表第二条的范围中,如果异常,能够跳转到第 20 行。

Finally

通常我们在做一些文件读取的时候,都会在 finally 代码块中关闭流,以避免内存的溢出。关于这个场景,我们再分析一下下面这段代码的异常表。

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
/**
 * @author macfmc
 * @date 2020/8/22-13:54
 */
public class StreamDemo {
    public void read() {
        InputStream in = null;
        try {
            in = new FileInputStream("A.java");
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } finally {
            if (null != in) {
                try {
                    in.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

image.gif

上面的代码,捕获了一个 FileNotFoundException 异常,然后在 finally 中捕获了 IOException 异常。当我们分析字节码的时候,却发现了一个有意思的地方:IOException 足足出现了三次。

image.gif编辑

Java 编译器使用了一种比较傻的方式来组织 finally 的字节码,它分别在 try、catch 的正常执行路径上,复制一份 finally 代码,追加在正常执行逻辑的后面;同时,再复制一份到其他异常执行逻辑的出口处。

再看一个例子,这段代码不报错的原因,都可以在字节码中找到答案:程序的字节码,可以看到,异常之后,直接跳转到序号 9 了。

/**
 * @author macfmc
 * @date 2020/8/22-14:17
 */
public class NoError {
    public static void main(String[] args) {
        NoError noError = new NoError();
        noError.read();
    }
    public int read() {
        try {
            return 13 / 0;
        } finally {
            return 1;
        }
    }
}

image.gif

image.gif编辑

字节码指令——装箱拆箱

装箱拆箱

Java 中有 8 种基本类型,但鉴于 Java 面向对象的特点,它们同样有着对应的 8 个包装类型,比如 int 和 Integer,包装类型的值可以为 null(基本类型没有 null 值,而数据库的表中普遍存在 null 值。 所以实体类中所有属性均应采用封装类型),很多时候,它们都能够相互赋值。

/**
 * 装箱拆箱字节码分析
 *
 * @author macfmc
 * @date 2020/8/22-14:27
 */
public class Box {
    public Integer cal() {
        Integer a = 1000;
        int b = a * 10;
        return b;
    }
}

image.gif

image.gif编辑

通过观察字节码,我们发现:(Java 中的自动装箱拆箱的底层实现)

1、在进行乘法运算的时候,调用了 Integer.intValue 方法来获取基本类型的值。

2、赋值操作使用的是 Integer.valueOf 方法。

3、在方法返回的时候,再次使用了 Integer.valueOf 方法对结果进行了包装。

IntegerCache

但这里有一个陷阱问题,我们继续跟踪 Integer.valueOf 方法,这个 IntegerCache,缓存了 low 和 high 之间的 Integer 对象。

image.gif编辑

image.gif编辑

一般情况下,缓存是的-128 到 127 之间的值,但是可以通过 -XX:AutoBoxCacheMax 来修改上限。

下面是一道经典的面试题,请考虑一下运行代码后,会输出什么结果?

public class BoxCache {
        public static void main(String[] args) {
            Integer n1 = 123;
            Integer n2 = 123;
            Integer n3 = 128;
            Integer n4 = 128;
            System.out.println(n1 == n2);
            System.out.println(n3 == n4);
        }
    }

image.gif

结果:true false。因为缓存的原因。(在缓存范围内的值,返回的是同一个缓存值,不在的话,每次都是 new 出来的)

当我加上 VM 参数 -XX:AutoBoxCacheMax=256 执行时,结果是 true,ture,扩大缓存范围,第二个为 true 原因就在于此。

字节码指令——数组

其实,数组是 JVM 内置的一种对象类型,这个对象同样是继承的 Object 类。我们使用代码来理解一下

public class ArrayDemo {
        int getValue() {
            int[] arr = new int[]{111, 222, 333, 444};
            return arr[2];
        }
        int getLength(int[] arr) {
            return arr.length;
        }
    }

image.gif

image.gif编辑

数组创建

可以看到,新建数组的代码,被编译成了 newarray 指令image.gif编辑

数组里的初始内容,被顺序编译成了一系列指令放入:

一、sipush 将一个短整型常量值推送至栈顶;

二、iastore 将栈顶 int 型数值存入指定数组的指定索引位置。image.gif编辑

具体操作:

1、 iconst_0,常量 0,入操作数栈

2、 sipush 将一个常量 1111 加载到操作数栈

3、 将栈顶 int 型数值存入数组的 0 索引位置

为了支持多种类型,从操作数栈存储到数组,有更多的指令:bastore、castore、sastore、iastore、lastore、fastore、dastore、aastore。

数组访问

数组元素的访问,是通过第 28 ~ 30 行代码来实现的:

一、aload_1 将第二个引用类型本地变量推送至栈顶,这里是生成的数组;

二、iconst_2 将 int 型 2 推送至栈顶;

三、iaload 将 int 型数组指定索引的值推送至栈顶。image.gif编辑

获取数组的长度,是由字节码指令 arraylength 来完成的。

image.gif编辑获取数组长度的指令 arraylength

字节码指令——foreach

无论是 Java 的数组,还是 List,都可以使用 foreach 语句进行遍历,虽然在语言层面它们的表现形式是一致的,但实际实现的方法并不同。

数组:它将代码解释成了传统的变量方式,即 for(int i;i<length;i++) 的形式。

List :的它实际是把 list 对象进行迭代并遍历的,在循环中,使用了 Iterator.next() 方法。

使用 jd-gui 等反编译工具,可以看到实际生成的代码

字节码指令——注解

无论是类的注解,还是方法注解,都是由一个叫做 RuntimeInvisibleAnnotations 的结构来存储的,

而参数的存储,是由 RuntimeInvisibleParameterAnotations 来保证的

image.gif编辑

字节码指令总结

Java 的特性非常多,这里不再一一列出,但都可以使用这种简单的方式,从字节码层面分析了它的原理,一窥究竟。

比如异常的处理、finally 块的执行顺序;以及隐藏的装箱拆箱和 foreach 语法糖的底层实现。还有字节码指令,可能有几千行,看起来很吓人,但执行速度几乎都是纳秒级别的。Java 的无数框架,包括 JDK,也不会为了优化这种性能对代码进行限制。了解其原理,但不要舍本逐末,比如减少一次 Java 线程的上下文切换,就比你优化几千个装箱拆箱动作,速度来的更快一些。


相关文章
|
27天前
|
Java
轻松上手Java字节码编辑:IDEA插件VisualClassBytes全方位解析
本插件VisualClassBytes可修改class字节码,包括class信息、字段信息、内部类,常量池和方法等。
78 6
|
2月前
|
存储 SQL 小程序
JVM知识体系学习五:Java Runtime Data Area and JVM Instruction (java运行时数据区域和java指令(大约200多条,这里就将一些简单的指令和学习))
这篇文章详细介绍了Java虚拟机(JVM)的运行时数据区域和JVM指令集,包括程序计数器、虚拟机栈、本地方法栈、直接内存、方法区和堆,以及栈帧的组成部分和执行流程。
36 2
JVM知识体系学习五:Java Runtime Data Area and JVM Instruction (java运行时数据区域和java指令(大约200多条,这里就将一些简单的指令和学习))
|
2月前
|
SQL 缓存 Java
JVM知识体系学习三:class文件初始化过程、硬件层数据一致性(硬件层)、缓存行、指令乱序执行问题、如何保证不乱序(volatile等)
这篇文章详细介绍了JVM中类文件的初始化过程、硬件层面的数据一致性问题、缓存行和伪共享、指令乱序执行问题,以及如何通过`volatile`关键字和`synchronized`关键字来保证数据的有序性和可见性。
33 3
|
2月前
|
存储 安全 Java
JVM锁的膨胀过程与锁内存变化解析
在Java虚拟机(JVM)中,锁机制是确保多线程环境下数据一致性和线程安全的重要手段。随着线程对共享资源的竞争程度不同,JVM中的锁会经历从低级到高级的膨胀过程,以适应不同的并发场景。本文将深入探讨JVM锁的膨胀过程,以及锁在内存中的变化。
44 1
|
2月前
|
JSON JavaScript 前端开发
Javaweb中Vue指令的详细解析与应用
Vue指令提供了一种高效、声明式的编码方式,使得开发者可以更专注于数据和业务逻辑,而不是DOM操作的细节。通过熟练使用Vue指令,可以极大地提高开发效率和项目的可维护性。
21 3
|
3月前
|
存储 算法 Java
深入解析 Java 虚拟机:内存区域、类加载与垃圾回收机制
本文介绍了 JVM 的内存区域划分、类加载过程及垃圾回收机制。内存区域包括程序计数器、堆、栈和元数据区,每个区域存储不同类型的数据。类加载过程涉及加载、验证、准备、解析和初始化五个步骤。垃圾回收机制主要在堆内存进行,通过可达性分析识别垃圾对象,并采用标记-清除、复制和标记-整理等算法进行回收。此外,还介绍了 CMS 和 G1 等垃圾回收器的特点。
122 0
深入解析 Java 虚拟机:内存区域、类加载与垃圾回收机制
|
3月前
|
JavaScript 前端开发 UED
Javaweb中Vue指令的详细解析与应用
Vue指令是Vue框架中非常强大的特性之一,它提供了一种简洁、高效的方式来增强HTML元素和组件的功能。通过合理使用这些指令,可以使你的JavaWeb应用更加响应用户的操作,提高交互性和用户体验。而且,通过创建自定义指令,你可以进一步扩展Vue的功能,使其更贴合你的应用需求。
21 1
|
4月前
|
C# 开发者 Windows
震撼发布:全面解析WPF中的打印功能——从基础设置到高级定制,带你一步步实现直接打印文档的完整流程,让你的WPF应用程序瞬间升级,掌握这一技能,轻松应对各种打印需求,彻底告别打印难题!
【8月更文挑战第31天】打印功能在许多WPF应用中不可或缺,尤其在需要生成纸质文档时。WPF提供了强大的打印支持,通过`PrintDialog`等类简化了打印集成。本文将详细介绍如何在WPF应用中实现直接打印文档的功能,并通过具体示例代码展示其实现过程。
377 0
|
4月前
|
缓存 前端开发 Java
浅析JVM invokedynamic指令与Java Lambda语法
【8月更文挑战第27天】在Java的演进历程中,invokedynamic指令的引入和Lambda表达式的出现无疑是两大重要里程碑。它们不仅深刻改变了Java的开发模式和性能表现,还极大地推动了Java在函数式编程和动态语言支持方面的进步。本文将从技术角度浅析JVM中的invokedynamic指令及其与Java Lambda语法的紧密联系。
62 0
|
4月前
|
运维 监控 Java
【JVM 调优秘籍】实战指南:JVM 调优参数全解析,让 Java 应用程序性能飙升!
【8月更文挑战第24天】本文通过一个大型在线零售平台的例子,深入探讨了Java虚拟机(JVM)性能调优的关键技术。面对应用响应延迟的问题,文章详细介绍了几种常用的JVM参数调整策略,包括堆内存大小、年轻代配置、垃圾回收器的选择及日志记录等。通过具体实践(如设置`-Xms`, `-Xmx`, `-XX:NewRatio`, `-XX:+UseParallelGC`等),成功降低了高峰期的响应时间,提高了系统的整体性能与稳定性。案例展示了合理配置JVM参数的重要性及其对解决实际问题的有效性。
112 0

推荐镜像

更多