JVM(一)_类加载系统和字节码

本文详细介绍了Java虚拟机(JVM)的工作原理,包括类加载系统、运行时数据区、执行引擎、性能监控与调优等方面。JVM通过类加载系统加载字节码文件,然后在运行时数据区中管理内存,执行引擎处理这些数据。文章还讨论了不同的虚拟机实现,如HotSpot VM,以及其在性能优化方面的特点。此外,还介绍了字节码指令集和异常处理机制,帮助读者更深入地理解Java程序的运行机制。
摘要由CSDN通过智能技术生成

不定期补充、修正、更新;欢迎大家讨论和指正

本文主要根据尚硅谷的视频学习,建议移步观看,其他参考资料会在使用时贴出链接
尚硅谷宋红康JVM全套教程(详解java虚拟机)
由于JVM的知识是互相穿插的,比如学习字节码会接触到运行时数据区的知识,学习堆区又会接触到GC的知识,所以建议先看视频对JVM有个完整的概念,本文只是作为学习笔记来复习,不适合入门学习。

JVM官方文档
The Java® Virtual Machine SpecificationJava SE 8 Edition

JVM(一)_类加载系统和字节码
JVM(二)_运行时数据区
JVM(三)_执行引擎
JVM(四)_性能监控与调优

前言

虚拟机(Virtual Machine)指通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统。在实体计算机中能够完成的工作在虚拟机中都能够实现。在计算机中创建虚拟机时,需要将实体机的部分硬盘和内存容量作为虚拟机的硬盘和内存容量。每个虚拟机都有独立的CMOS、硬盘和操作系统,可以像使用实体机一样对虚拟机进行操作。

广义上来看,我们可以将具屏蔽底层细节,专注本层功能的都视为虚拟机,比如计算机组成原理的多级层次结构的计算机结构,我们可以把M4(高级语言机器)视为具有高级语言编译功能的机器,但M4并不是实际的机器,只是人们感到存在的一台高级语言功能的机器。同理,Word、Excel也可以视为处理文字、表格等的虚拟机。
在这里插入图片描述
狭义上,虚拟机可以分为系统虚拟机、程序虚拟机。

系统虚拟机是指通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统,是对物理计算机的仿真,比如VMware、Visual Box等,这样就可以在Windows上运行Linux等系统,虽然看上去我们操作另一个系统,实际上是在操作软件。

程序虚拟机是专门为了某个计算机应用而设计的,比如要学的JVM。

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
在这里插入图片描述
在这里插入图片描述

如果问世界上最好的语言是什么,各语言的程序员肯定吵得不可开交,但是最好的虚拟机,毫无疑问是JVM。
JVM从Java7开始就不只是服务Java语言,其他语言只要符合JSR-292规范,由编译器编译成JVM规范的字节码文件,就都能在JVM上运行。
因此Java平台多语言混合编程正成为主流,各个领域使用不同的语言,比如一个项目中,并行处理用Clojure编写,展示层用JRuby/Ralis,中间层用Java,各语言的交互不成问题,最终都在JVM上执行。

在这里插入图片描述

主要虚拟机

(我也不太了解,都是照抄视频中的,以后有机会学习再详细讲讲)

  1. Sun Classic VM
    1996年在java1.0由sun公司发布,是世界上第一款商用的java虚拟机。
    该虚拟机内部只提供了解释器,性能差(这也是造成Java运行效率比C/C++差固有印象的原因,现在的虚拟机一般是解释器和即时编译器(JIT)搭配执行),如果需要JIT,就需要外挂,但是解释器和即时编译器不能同时工作。该虚拟机在JDK1.4时候时被淘汰。

  2. Exact VM
    为了解决上一个虚拟机解释器和JIT不能同时工作的问题,JDK1.2时,sun提供了此虚拟机
    该虚拟机主要提供了准确式内存管理功能(Exact Memory Management :虚拟机知道内存中某个位置的数据是什么类型)、热点探测、编译器与解释器混合工作模式,是现代高性能虚拟机的雏形。
    但只在Solaris平台短暂使用,因为很快就被后来的HotSpot虚拟机取代。

  3. Hotspot VM
    最初由一家小公司Longview Technologizes设计,1997年被sun收购,2009年,sun公司被甲骨文oracle收购,JDK1.3时候,HotSpot成为默认虚拟机。是目前三大主流商用虚拟机之一,占绝对的主导地位,也是在此学习的虚拟机。
    HotSpot的名字就是他的热点代码探测技术,通过计数器找到最具编译价值代码,触发即时编译或栈上替换通过编译器与解释器协同工作,在优化响应时间和最佳执行性能中取得平衡。

  4. JRockit
    三大商用虚拟机之一,由BEA公司开发,专注服务器端应用,因为不太关注程序启动速度,所以JRockit内部不包括解析器实现,全部代码靠即时编译器编译后执行,因此也是目前世界上最快的JVM(因为都是编译器工作),在2008年BEA被Oracle收购(世界五百强不是吹的,Java就不说了,旗下的Oracle,Mysql数据库都是它家的)。

  5. IBM J9
    三大商用虚拟机之一,IBM Technology for java Virtual Machine 简称IT4J,内部代号J9,市场定位与HotSpot接近,服务器端、桌面应用,嵌入式等多用途,广泛应用于IBM的各种Java产品。号称速度最快,因为在自家平台测试,和IOS一样,与自家产品高度契合,当然效率也高。2017年开源,命名位OpenJ9,交给Eclipse基金会管理。

  6. Graal Vm
    2018年4月,Oracle Labs新公开了一项黑科技:Graal VM,从它的口号“Run Programs Faster Anywhere”就能感觉到一颗蓬勃的野心,这句话显然是与1995年Java刚诞生时的“Write Once,Run Anywhere”在遥相呼应。
    Graal VM被官方称为“Universal VM”和“Polyglot VM”,这是一个在HotSpot虚拟机基础上增强而成的跨语言全栈虚拟机,可以作为“任何语言”的运行平台使用,这里“任何语言”包括了Java、Scala、Groovy、Kotlin等基于Java虚拟机之上的语言,还包括了C、C++、Rust等基于LLVM的语言,同时支持其他像JavaScript、Ruby、Python和R语言等等。Graal VM可以无额外开销地混合使用这些编程语言,支持不同语言中混用对方的接口和对象,也能够支持这些语言使用已经编写好的本地库文件。
    Graal Vm野心极大,有一统所有虚拟机的目标,如果HotSpot被取代,最有可能的就是这款虚拟机,让我们拭目以待。

除了以上虚拟机,还有KVM、CDC、Azul VM、Liquid VM、Apache Harmony、Microsoft VM、TaobaoVM、Dalvik VM等

HotSpot

提起HotSpot VM,相信所有Java程序员都知道,它是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。
但不一定所有人都知道的是,这个目前看起来“血统纯正”的虚拟机在最初并非由Sun公司开发,而是由一家名为“Longview Technologies”的小公司设计的;
甚至这个虚拟机最初并非是为Java语言而开发的,它来源于Strongtalk VM
而这款虚拟机中相当多的技术又是来源于一款支持Self语言实现“达到C语言50%以上的执行效率”的目标而设计的虚拟机,
Sun公司注意到了这款虚拟机在JIT编译上有许多优秀的理念和实际效果,在1997年收购了Longview Technologies公司,从而获得了HotSpot VM。

HotSpot VM既继承了Sun之前两款商用虚拟机的优点(如前面提到的准确式内存管理),也有许多自己新的技术优势,
如它名称中的HotSpot指的就是它的热点代码探测技术(其实两个VM基本上是同时期的独立产品,HotSpot还稍早一些,HotSpot一开始就是准确式GC,
而Exact VM之中也有与HotSpot几乎一样的热点探测。
为了Exact VM和HotSpot VM哪个成为Sun主要支持的VM产品,在Sun公司内部还有过争论,HotSpot打败Exact并不能算技术上的胜利),
HotSpot VM的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知JIT编译器以方法为单位进行编译。
如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准编译和OSR(栈上替换)编译动作。
通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,
即时编译的时间压力也相对减小,这样有助于引入更多的代码优化技术,输出质量更高的本地代码。

在2006年的JavaOne大会上,Sun公司宣布最终会把Java开源,并在随后的一年,陆续将JDK的各个部分(其中当然也包括了HotSpot VM)在GPL协议下公开了源码,
并在此基础上建立了OpenJDK。这样,HotSpot VM便成为了Sun JDK和OpenJDK两个实现极度接近的JDK项目的共同虚拟机。

在2008年和2009年,Oracle公司分别收购了BEA公司和Sun公司,这样Oracle就同时拥有了两款优秀的Java虚拟机:JRockit VM和HotSpot VM。
Oracle公司宣布在不久的将来(大约应在发布JDK 8的时候)会完成这两款虚拟机的整合工作,使之优势互补。
整合的方式大致上是在HotSpot的基础上,移植JRockit的优秀特性,譬如使用JRockit的垃圾回收器与MissionControl服务,
使用HotSpot的JIT编译器与混合的运行时系统。–摘自《深入理解Java虚拟机:JVM高级特性与最佳实践》

整体结构

以下为HotSpot VM的大致结构,源代码编译成字节码文件(Class files,因此前面应还有一个编译过程),字节码文件通过类加载系统加载到JVM中,JVM所管理的内存为运行时数据区,执行引擎从运行时数据区获取数据,再通过执行引擎对这些数据进行处理,最终运行在操作系统上。
在这里插入图片描述
根据以上结构,对JVM分为四部分学习:

  1. 字节码和类加载系统
  2. 运行时数据区和本地方法接口
  3. 执行引擎
  4. 性能监控与调优

以下是更为详细的结构图

  • 类加载子系统(Class Loader SubSystem):由加载(Loading)->链接(Linking)->初始化(Initialization)三部分完成
  • 运行时数据区(Runtime Data Areas):包含方法区(Method Area)、堆区(Heap Area)、栈区(Stack Area)、PC寄存器(PC Registers)、本地方法栈(Native Method Stack)
  • 执行引擎(Execution Engine):解释器(Interpreter)、及时编译器(JIT Compiler)、分析器(Profiler)、垃圾回收器(Garbage Collection)

在这里插入图片描述

类加载器子系统

类加载子系统的工作是将源代码编译好的字节码文件经过处理后,加载到内存(JVM运行时数据区的方法区内)

由HotSpot结构图可知,类加载器子系统分为三个阶段

  • 加载Loading
  • 链接Linking
  • 初始化Intialization
    在这里插入图片描述

字节码文件,即以class为后缀的文件,最常见的就是从本地硬盘中读取,除此之外还有

  • 通过网络加载,如Web Applet
  • 从zip压缩包读取,如jar、war包
  • 运行时计算生成,使用最多的动态代理技术
  • 由其他文件生成,如JSP应用
  • 从专有数据库获取,比较少见
  • 从加密文件中获取,典型的防止Class被反编译的保护措施

值得注意的是,类加载器只负责class文件的加载,至于是否可以运行,则由执行引擎决定

加载

类加载器

加载过程由类加载器来完成,类加载器(java.lang.ClassLoader类)的基本职责就是根据一个指定的类的名称,找到或者生成其对应的字节码文件,将其加载到机器内存中,并在内存中生成Java类的原型,即 java.lang.Class类的一个实例,也叫做类模板对象,相当于一个快照,这样JVM在运行期间就能通过类模板获取该类中任意的信息,反射就是基于该基础完成的。除此之外,ClassLoader还负责加载 Java 应用所需的资源,如图像文件和配置文件等。

在JVM规范中类加载器分为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader),我们熟知的系统类加载器属于后者,这有点不符合我们的第一印象,一般来说自定义类加载器应该是程序中开发人员自定义的一类加载器,而系统类加载器是Java自带的。

这样划分是因为在JVM规范中,将所有派生于ClassLoader的类加载器都划分为自定义类加载器,另一种类加载器划分引导类加载器。
比如ExtClassLoader(扩展类加载器)和AppClassLoader(应用(系统)类加载器)都是自定义类加载器,在IDEA中Ctrl+H查看ClassLoader的继承树
在这里插入图片描述
在JDK14中并没有这两个类,更改的原因有机会再了解,如果找不到这两个类在项目结构中将JDK降低为8就行
在这里插入图片描述
在这里插入图片描述
无论怎么分类,我们只需要知道下图中三个类加载器即可,下图虽然似乎呈现的是继承关系,但实际不是,我们已经知道ExtClassLoader是继承于ClassLoader了,所以它们的关系属于上下级关系,稍后在双亲委派机制就可以看到

在这里插入图片描述
根据上图,我们一一地取出这些加载器
因为ExtClassLoader和AppClassLoader是内部类,不能直接获取,这里通过系统类加载器的getParent()方法获取(getParent()是获取其上级的方法,并不是获取其父类,命名有点迷惑)
在这里插入图片描述

在这里插入图片描述
从输出结果我们可以分析出几个信息

  1. 系统类加载器就是AppClassLoader,其实并没有SystemClassLoader这个类
  2. 引导类加载器获取不到,原因稍后会讲到
  3. 自定义加载器默认由系统类加载器来加载,仔细看systemClassLoader和myClassLoader的地址是相同的
    在这里插入图片描述

现在来详细讲解这三个类加载器

引导类加载器(Bootstrap ClassLoader)
该类使用C/C++实现,直接嵌套在JVM内部,所以在Java中当然获取不到,也没有父加载器。
它的职责是加载Java的核心库,用于提供JVM自身需要的类,ExtClassLoader和AppClassLoader虽然处于很上层,但作为类仍需要父加载器来加载,所以它们就由引导类加载器来加载
其加载的路径为JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path等,出于安全考虑引导类加载只加载包名为java、javax、sun等开头的类。
虽然在Java中不能获取到引导类加载器,但是可以知道它作用的路径
在这里插入图片描述
在这里插入图片描述
我们可以拿一个jar包来解压,比如jsse.jar,里面提供了两个类
在这里插入图片描述
获取其类加载器
在这里插入图片描述
这就间接证明了该类是由引导类加载器加载的
在这里插入图片描述
String类同样的也是作为核心类由引导类加载器加载
在这里插入图片描述
在这里插入图片描述

扩展类加载器(ExtClassLoader)
扩展类加载器派生于ClassLoader类,由引导类加载器加载,其职责是从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK安装木下的jre/lib/ext子目录下加载类库,如果用户的jar放在该目录下也会由扩展类加载器加载
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

系统类加载器(AppClassLoader,也叫应用类加载器)
同样派生于ClassLoader类,由扩展加载器加载
负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
该类是程序中默认的类加载器,一般java应用的类都是由它加载,通过ClassLoader.getSystemClassLoader()就可以获取到该类,上面已经演示过了。
在这里插入图片描述
在这里插入图片描述

双亲委派机制

双亲委派机制原理挺简单,就是在加载字节码文件时把任务先交给父类加载器处理,比如我们自定义的类,一般是系统类加载器来加载,但由其加载前先交给其上级即扩展类加载器,扩展类加载器也不能直接加载,还得交给其上级引导类加载器。
如果引导类加载器可以加载就由其加载,不能再把任务递交给扩展类加载器,后面就同理,下面的流程图可以很方便的理解。
在这里插入图片描述

JVM为什么要搞出这种机制呢,相信有些人曾经无意或有意在创建包时和系统核心库的包名相同,比如现在自定义一个String类,路径和核心库java.lang.String一致
在这里插入图片描述
为了展示输出效果,在自定义的String内添加静态代码块,如果自定义的String能得到加载,就会输出信息
在这里插入图片描述
在其他类下的main()下调用自定义String
在这里插入图片描述
事实上什么也没发生
在这里插入图片描述
如果直接在自定义类里调用main(),就直接报错了
在这里插入图片描述
这都证明自定义的String不会被加载,根据双亲委派机制的流程图和结合String作为核心类被引导类加载器加载,我们很容易知道原因,当引导类类加载器加载了java.lang.String这个类,就不会把任务递交给下级,所以自定义的String就不会被加载
在这里插入图片描述
所以双亲委派机制的优点主要有两个
1、防止重复加载同一个.class。通过委托去向上面问一问,加载过了,就不用再加载一遍。保证数据安全。
2、保证核心.class不能被篡改。通过委托方式,不会去篡改核心.class,即使篡改也不会去加载,即使加载也不会是同一个.class对象了。不同的加载器加载同一个.class也不是同一个Class对象。这样保证了Class执行安全。

  • 为啥不直接从引导类加载器开始加载?
  • 如何打破该机制?

链接

链接阶段又细分为三个阶段

  1. 验证(Verify)
  2. 准备(Prepare)
  3. 解析(Resolve)
    在这里插入图片描述
    验证阶段

验证阶段的目的在于确保class文件的字节流中包含信息符合当前虚拟机要求,保证被正确加载,防止恶意代码的攻击,不会危害虚拟机自身安全
主要分为以下几步:

在这里插入图片描述

文件格式的验证(实际上是在加载阶段进行):

  • 是否符合Class文件的规范,以及能够被当前版本的虚拟机处理。
  • 是否以0xCAFEBABE开头。
  • 主、次版本是否在当前虚拟机处理的范围之内。
  • 常量池的常量是否有不支持的常量类型。等等,主要的目的是保证输入的字节流能够正确的解析并存储于方法区之内,格式上符合一个java类型信息的要求。只有通过这个阶段的验证,虚拟机才会让字节流进入到方法区中进行存储,后面的验证都是直接操作在方法区之上,而不是直接操作字节流。

元数据验证:对字节码描述的信息进行语义分析,以保证其描述符合java语言规范的要求。
比如验证:

  • 这个类是否有父类,java中除了Object,其它的类必须存在父类,默认为Object。
  • 这个类是否extends了不被允许的类,如被final修饰的类。
  • 如果这个类不是抽象类,是否实现了其父类或接口之中需要实现的所有方法。
  • 类中的字段、方法是否与父类产生矛盾,如覆盖了父类的final字段和方法,覆盖不符合规则等等。

字节码验证:主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。主要对方法进行验证,防止方法在运行时,不会做出对虚拟机有危害的操作。例如:

  • 保证任意时刻操作数栈的数据类型与指令码序列都能够配合工作,不会出现类似,在操作栈放置了一个int类型的数据,使用时却按long类型来载入本地变量表中。
  • 保证跳转指令不会跳到方法体以外的字节码指令上。
  • 保证类型转换是有效的,例如把父类型赋值给子类型是安全的,子类型赋值给父类型就是不安全危险的。

解析:是虚拟机将常量池内的符号引用替换为直接引用的过程(实际上是在解析阶段进行)。
符号引用(与虚拟机无关,不同的虚拟机翻译出来必然相同):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要能够无歧义的定位到目标即可。符号引用的字面量形式明确定义在java虚拟机规范的Class文件中。
直接引用(与虚拟机有关,不同的虚拟机翻译出来一般不会相同):直接引用可以是直接指向目标的指针、相对偏移量或是一个能够间接定位到目标的句柄。
字段解析:JVM对字段的搜索:
若类本身包含该字段了简单名称和字段描述都和目标相符合的字段,直接返回此字段,查找结束。
若类本身找不到,则可以找它的父类和父接口,则返回字段引用。
若是一直没有找到则抛出java.lang.NoSuchFieldError异常。

JVM之验证机制

准备阶段

准备阶段负责为验证通过的类分配相应的内存空间并为静态变量设置初始值,如果静态变量显式赋了值,在初始化阶段才会给变量赋上。不同数据类型的变量其初始值见下表

类型默认初始值
byte(byte)0
short(short)0
int0
long0L
float0.0f
double0.0
char\u0000
booleanfalse
referencenull

如果静态变量被final修饰,因为其值在编译时就确定了,其值包含在字节码文件中,所以在准备阶段直接代码的值显式初始化。而对于普通的实例变量,准备阶段不会对此进行初始化。

解析阶段
解析阶段负责将常量池内的符号引用转换为直接引用,什么是常量池和符号引用,在后面的字节码文件会学习,所以这里没什么好讲的

初始化

初始化阶段是类加载阶段的最后一个阶段了,其作用是对类变量赋予正确的值,比如在链接阶段的准备中,静态变量即使显式赋了值,但只会赋予默认值,而在初始化阶段才会赋上声明的值

所有的类变量初始化语句和静态初始化语句都被Java编译器收集在一起,放在一个<clinit>()方法特殊方法里,对于类而言,该方法称为类初始化方法,对于接口而言,该方法称为接口初始化方法。并且这种方法只能被Java虚拟机调用,Java程序是无法调用的。

如果代码中有静态变量,可以重反编译结果找到<clinit>()方法(这个工具后面的分析字节码文件就会用到,这里看看就行)
在这里插入图片描述
对于非静态变量是不会生成<clinit>()方法的
在这里插入图片描述

字节码文件

字节码文件是源代码经过编译器编译(执行引擎也有编译器,有时为了区别,将源码编译成字节码文件的编译器称为前端编译器,执行引擎的编译器称为后端编译器)后生成的二进制的class文件(一个class文件都对应着唯一一个类或接口的定义信息),它的内容是JVM的指令和类的信息,并不像C/C++直接编译成在硬件上运行的机器码。

在分析字节码文件时,可以直接对生成的.class进行分析
直接打开的话会出现很多乱码,看不到太多有用的信息
在这里插入图片描述
需要要转换为十六进制格式显示,大家可以自己找合适的工具,这里使用Nodepad++文本编辑器打开,同时需要安装HEX-Editor的插件
在这里插入图片描述
通过十六进制转置后
在这里插入图片描述

直接根据字节码文件分析还是很麻烦,JDK提供javap工具进行字节码文件的反编译
在需要反编译的class文件下输入命令 javap -v .class文件
在这里插入图片描述
反编译的结果
在这里插入图片描述
下面来了解下javap常用的参数

  • h --help:查看帮助信息
  • version:javap所在JDK的版本信息
  • public:仅显示公共类和成员
  • protected:显示受保护的公共类和成员
  • p/private:除了显示公共类和成员,私有成员也显示,即显示所有类和成员
  • package:显示程序包/受保护的/公共类和成员(默认)
  • sysinfo:显示正在处理的类的系统信息(路径,大小,日期,MD5散列值,源文件名)
  • constans:显示静态最终常量
  • s:输出内部类型签名
  • l:输出行号和本地变量表
  • c:对代码进行反编译
  • v/verbose:输出附加信息(包括行号、本地变量表、反编译等详细信息)

一般用javap -v就行

jclasslib bytecode viewer工具也是常用的分析工具,在IDEA中就可以直接安装
在这里插入图片描述
在视图下就可以使用
在这里插入图片描述

在这里插入图片描述

结构

class文件的结构不像XML文件那样对内部数据比较宽松,比如标签与标签间可以有任意空格。由于它没有任何分割符号,所以class文件的数据项,无论是字节顺序还是数量,都是严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何都有规定。

class文件采用一种类似C语言结构体的方式进行数据存储,这种结构只有两种数据类型

  • 无符号数
    基本数据类型,以u1、u2、u4、u8来分别表示1个字节(8位)、2个字节等。无符号数可以来描述数字、索引引用、数量值或按照UTF-8编码构成字符串值。
  • 表:
    表是有多个无符号数或其他表作为数据项构成的复合数据类型,所有表都约定俗成地以“_info”为后缀
    表用于描述有层次关系地符合结构的数据,整个class文件本质上就是一张表。由于表没有固定长度,所以通常会在其前面加上个数字说明该表的长度。

了解这两种数据类型后,就可以看看class文件的结构了,官方文档把class文件结构分为如下这些部分
Java Virtual Machine Specification:Chapter 4. The class File Format
在这里插入图片描述
class文件的结构随着JVM的发展并不是一成不变的,但其基本结构和框架是十分稳定的,基本结构就是上图官方文档给出的,分为

魔数
在这里插入图片描述

class文件版本
在这里插入图片描述

常量池表(正如前面所说的,由于表没有固定长度,所以通常会在其前面加上个数字说明)
在这里插入图片描述

访问标志
在这里插入图片描述

索引集合
在这里插入图片描述

字段表
在这里插入图片描述

方法表
在这里插入图片描述

属性表
在这里插入图片描述

现在一一对这些结构进行学习

魔数

在这里插入图片描述
每个class文件开头的4个字节的无符号整数称为魔数(Magic Number),魔数值都是固定为0xCAFEBABE,即咖啡宝贝(程序员的浪漫doge,Java和咖啡有着谜之羁绊,图标也是咖啡,Java是印度尼西亚爪哇岛的英文名称,以盛产咖啡出名)
在这里插入图片描述
魔数的作用很简单,就是确定这个文件是否是一个能被虚拟机接收的有效合法的class文件,在类加载子系统的链接阶段中的验证阶段就提过文件格式验证。很多文件格式比如MP3,PNG格式的文件也采用固定文件头这种机制,就是为了简单地验证文件所属类型,而不是以后缀名来识别。

文件版本

紧接着魔数的4个字节存储的是class文件的版本号
在这里插入图片描述
前两个字节是副版本号minor_version,后两个字节为主版本号major_version
在这里插入图片描述
主版本和副版本号构成了class文件的格式版本号,版本号和Java编译器的对应关系如下表

主版本(十进制)副版本(十进制)编译器版本
4531.1
4601.2
4701.3
4801.4
………………

Java的版本号是从45开始的,JDK1.1之后的每个JDK大版本发布主版本号向上加1,以此类推(副版本号都是0,一开始还以为像Java 8.15,8是主版本,15是副版本,结果不是)

0x3a是十六进制,转换为十进制是58,即Java 14
在这里插入图片描述
版本太高,jclasslib没识别出来,Java 8的可以识别
在这里插入图片描述

常量池

常量池(Constant Pool)是class文件中最为重要,内容最丰富的区域之一,故名思意就是存放一堆常量的区域,严格来说是用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic References)。

Java有三大常量池:字符串常量池、class文件常量池、运行时常量池。而运行时常量池就是将class文件的常量池加载到JVM方法区后形成的区域,可以说常量池是Java的基石之一。

由于不同的类常量池的项数是不确定的,所以需要两个字节的无符号整数来声明常量池的项数
在这里插入图片描述
文件版本后紧接的两个字节就是常量池项数,0x68转换为十进制就是104
在这里插入图片描述
从jclasslib可以看到所有项数(注意常量池的下标索引是从1到constant_pool_count-1,下标从1开始而不是从0开始,这是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可用索引值 0 来表示)
在这里插入图片描述

也可以直接通过javap反编译查看
在这里插入图片描述
在这里插入图片描述
这里注意到常量池每一项的类型不一定相同
在这里插入图片描述

常量池项具体的类型见下表

类型标志(或标识)描述
CONSTANT_Utf8_info1UTF-8编码的字符串
CONSTANT_Integer_info3整型字面量
CONSTANT_Float_info4浮点型字面量
CONSTANT_Long_info5长整型字面量
CONSTANT_Double_info6双精度浮点型字面量
CONSTANT_Class_info7类或接口的符号引用
CONSTANT_String_info8字符串类型字面量
CONSTANT_Fieldref_info9字段的符号引用
CONSTANT_Methodref_info10类中方法的符号引用
CONSTANT_InterfaceMethodref_info11接口中方法的符号引用
CONSTANT_NameAndType_info12字段或方法的符号引用

具体结构如下

在这里插入图片描述

从常量池项中可以看到常量池主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)

  • 字面量字面量就是我们所说的常量概念,如文本字符串、被声明为final的常量值等
  • 符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,而直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄。虚拟机在加载Class文件时才会进行动态链接,也就是说,Class文件中不会保存各个方法和字段的最终内存布局信息。因此,这些字段和方法的符号引用不经过转换是无法直接被虚拟机使用的。当虚拟机运行时,需要从常量池中获得对应的符号引用,再在类加载过程中的解析阶段将其替换为直接引用,并翻译到具体的内存地址中。

符号引用的具体类型有三种

  • 类和接口的全限定名称
  • 字段/变量/属性的简单名称和描述符
  • 方法的简单名称和描述符

全限定名
全限定名很容易理解,比如java/lang/String,仅仅是把包名的“.“替换成”/”,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”表示全限定名结束。
简单名称
简单名称是指没有类型和参数修饰的方法或者字段名称,比如类的add()方法和num字段的简单名称分别是add和num。
描述符
描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示,如下

  • B:byte
  • C:char
  • D:double
  • F:float
  • I:int
  • J:long
  • S:string
  • Z:boolean
  • V:void
  • L:对象类型,比如:Ljava/lang/Object;
  • [:数组类型,代表一维数组
    如下(如果没有重写toString方法,输出的就是 描述符@的格式)
    在这里插入图片描述
    在这里插入图片描述

根据常量池的规则,紧接在constant_pool_count后的应该就是常量池的第一项,而每一项的第一个字节标识该项的类型
在这里插入图片描述
0x0a即10,根据上表可知标识为10的类型为CONSTANT_Methodref_info,可以从jclasslib得到验证

在这里插入图片描述
从上表中查看CONSTANT_Methodref_info的结构,所以紧跟CONSTANT_Methodref_info后的两个字节是两个的索引项
在这里插入图片描述

在这里插入图片描述

在jclasslib可以看出这两个索引项最终所指向的类和描述符
在这里插入图片描述
这两个索引项结束后,后面紧跟的就是第二项的信息了,以此类推。有兴趣的同学可以自行跟着视频尚硅谷宋红康JVM全套教程(详解java虚拟机)P213进行后面的分析,为了方便后面直接根据jclasslib所分析的结果来学习。

补充说明,在JDK 1.7又加入了三个常量项类型来支持动态语言调用的,这里无需了解

类型标志(或标识)描述
CONSTANT_MethodHandle_info15表示方法句柄
CONSTANT_MethodType_info16标志方法类型
CONSTANT_InvokeDynamic_info18表示一个动态方法调用点

在这里插入图片描述

访问标志

在常量池后,紧跟的的是访问标志,用于识别类或接口访问信息,比如这个Class是类还是接口、是否定义为public类型、是否定义为abstract类型、如果是类的话,是否被声明为final等。
在这里插入图片描述
各种访问标志如下所示:

标志名称标志值含义
ACC_PUBLIC0x0001标志为public类型
ACC_FINAL0x0010标志被声明为final,只有类可以设置
ACC_SUPER0x0020标志允许使用invokespecial字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真。(使用增强的方法调用父类方法)
ACC_INTERFACE0x0200标志这是一个接口
ACC_ABSTRACT0x0400是否为abstract类型,对于接口或者抽象类来说,其标志值为真,其他类型为假
ACC_SYNTHETIC0x1000标志此类并非由用户代码产生(即:由编译器产生的类,没有源码对应)
ACC_ANNOTATION0x2000标志这是一个注解
ACC_ENUM0x4000标志这是一个枚举

类的访问权限通常为ACC_开头的常量
可以看到每个标志的标志值并不是连续的,因为一个类可以有多个标志修饰,设计成这样容易知道被哪些访问标志修饰。比如在class文件看到0x0021,很快就能根据表知道这是0x0020|0x0001的结果,即被ACC_PUBLIC和ACC_SUPER修饰。

虽然访问标志是紧跟在常量池后的,但反编译的结果为是直接放到前面的
在这里插入图片描述
如果一个class文件被设置了ACC_INTERFACE标志,表示class文件所对应的源文件是接口,那么同时也得设置ACC_ABSTRACT标志。同时它不能再设置ACC_FINAL、ACC_SUPER 或ACC_ENUM标志。
在这里插入图片描述
其他需要注意的点

  • ACC_SUPER标志用于确定类或接口里面的invokespecial指令使用的是哪一种执行语义。针对Java虚拟机指令集的编译器都应当设置这个标志。对于Java SE 8及后续版本来说,无论class文件中这个标志的实际值是什么,也不管class文件的版本号是多少,Java虚拟机都认为每个class文件均设置了ACC_SUPER标志。
    ACC_SUPER标志是为了向后兼容由旧Java编译器所编译的代码而设计的。目前的ACC_SUPER标志在由JDK1.0.2之前的编译器所生成的access_flags中是没有确定含义的,如果设置了该标志,那么Oracle的Java虚拟机实现会将其忽略。

  • ACC_SYNTHETIC标志意味着该类或接口是由编译器生成的,而不是由源代码生成的。

  • 注解类型必须设置ACC_ANNOTATION标志。如果设置了ACC_ANNOTATION标志,那么也必须设置ACC_INTERFACE标志。

  • ACC_ENUM标志表明该类或其父类为枚举类型。

索引集合

索引集合用来指定该类的类别、父类类别以及实现的接口
在这里插入图片描述

  • 类索引用于确定这个类的全限定名,如java/lang/String(并不是java.lang.String)。this_class的值必须是对常量池表中某项的一个有效索引值。常量池在这个索引处的成员必须为CONSTANT_Class_info类型结构体,该结构体表示这个class文件所定义的类或接口。
  • 父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为空,并且super_class指向的父类不能是final。
  • 接口索引集合用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。由于一个类可以实现多个接口,因此需要以数组形式保存多个接口的索引,表示接口的每个索引也是一个指向常量池的CONSTANT_Class(当然这里就必须是接口,而不是类)。

例如下面的类
在这里插入图片描述
jclasslib
在这里插入图片描述
在这里插入图片描述
javap反编译
在这里插入图片描述

字段表

字段(field)就是我们平时所说的属性,其实属性这词跟field不怎么沾边,很多人都这么叫就习惯了(还有后面学习的Garbage Collection,直译应该叫垃圾收集,但很多人都叫垃圾回收,其实差别还是蛮大的),为了区分后面的属性表,这里严格使用字段这个术语。

一个类的字段数量肯定是无法确定的,所以需要计数器fields_count说明数量
在这里插入图片描述

字段表用于描述接口或类中声明的变量。字段包括类级变量以及实例级变量,不包括方法内部、代码块内部声明的局部变量(这些变量存放在虚拟机栈的局部变量表内)。

字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。

它指向常量池索引集合,它描述了每个字段的完整信息。比如字段的标识符、访问修饰符(public、private或protected)、是类变量还是实例变量(static修饰符)、是否是常量(final修饰符)等。

字段表中的每一个成员都是field_info结构,也是表。它描述了每个字段的完整信息。比如字段的描述符、访问修饰符(public、private或protected)等,字段的描述符和字段名都存放在常量池中,只能引用常量池的常量来描述。
其结构如下

类型名称说明
u2access_flags访问标志
u2name_index字段名索引
u2descriptor_index描述符索引
u2attributes_count属性计数器
attribute_infoattributes属性集合
  • 访问标志
    就是修饰字段的关键字,比如常见的public、private等,如下
标志名称标志值说明
ACC_PUBLIC0x0001字段是否为public
ACC_PRIVATE0x0002字段是否为private
ACC_PROTECTED0x0004字段是否为protected
ACC_STATIC0x0008字段是否为static
ACC_FINAL0x0010字段是否为final
ACC_VOLATILE0x0040字段是否为volatile (多线程开发用得多)
ACC_TRANSTENT0x0080字段是否为transient
ACC_SYNCHETIC0x1000字段是否为由编译器自动产生
ACC_ENUM0x4000字段是否为enum
  • 字段名索引
    就是该字段的名称,存放在常量池中

  • 描述符索引
    描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte,char,double,float,int,long,short,boolean)及代表无返回值的void类型都用一个大写字符来表示,而对象则用字符L加对象的全限定名来表示。在常量池中就提过。

在这里插入图片描述
如下图,这里注意javap反编译默认是不显示被private修饰的字段的(加上-p/-private选项就行),同时也把常量池中的字段名和描述符直接获取输出了
在这里插入图片描述
而在jclasslib工具中,是可以看到private字段的,也能看到字段名和描述符所指向常量池的符号引用在这里插入图片描述
这里有一个疑惑,如果一个字段不被任何修饰符修饰,默认不是public还是protected吗
在这里插入图片描述
一个字段还可能拥有一些属性,用于存储更多的额外信息。比如初始化值、一些注释信息等。属性个数存放在attribute_count中,属性具体内容存放在attribute_info中,以_info为后缀的类型都是表,所以attribute_info的结构如下

类型名称说明
u2attribute_name_index属性名索引
u4attribute_length属性长度
u2constantvalue_index常量值索引

被final修饰的字段有属性,其他修饰符的字段是没有的。比如上面的final int NUM = 100;可以看到其下面多了张表
在这里插入图片描述
对于常量字段,attribute_length值恒为2
在这里插入图片描述
字段表需要注意的点

  • 字段表集合中不会列出从父类或者实现的接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段。譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
  • 在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的。

方法表

方法表和字段表类似,用来描述类中方法的信息
在这里插入图片描述

  • 在字节码文件中,每一个method_info项都对应着一个类或者接口中的方法信息。比如方法的访问修饰符(public、private或protected),方法的返回值类型以及方法的参数信息等。
  • 如果这个方法不是抽象的或者不是native的,那么字节码中会体现出来。
  • 一方面,methods表只描述当前类或接口中声明的方法,不包括从父类或父接口继承的方法。另一方面,methods表有可能会出现由编译器自动添加的方法,最典型的便是编译器产生的方法信息(比如:类(接口)初始化方法< clinit >()和实例初始化方法< init >())。

method_info的结构和field_info的结构一样

类型名称说明
u2access_flags访问标志
u2name_index方法名索引
u2descriptor_index描述符索引
u2attributes_count属性计数器
attribute_infoattributes属性集合
  • 访问标志
标志名称标志值说明
ACC_PUBLIC0x0001public,方法可以从包外访问
ACC_PRIVATE0x0002private,方法只能本类访问
ACC_PROTECTED0x0004protected,方法在自身和子类可以访问
ACC_STATIC0x0008static,静态方法

在这里插入图片描述
构造器方法也属于方法,而一个类至少有一个空参构造器,所以没有显示声明反编译也能看见
在这里插入图片描述
同样的,被private修饰后通过javap也看不到,想看到就加-p,在jclasslib可以看到
在这里插入图片描述
jclasslib看的效果
在这里插入图片描述

在这里插入图片描述

方法表同样有属性表来描述附加信息,与字段的属性表不同的是,每个方法都有属性表,方法的属性表主要的项是Code,Code项仍有属性表,包含(LineNumberTable和LocalVariableTable),总之就是套娃
在这里插入图片描述
我们先从Code看起,以下内容,包括(LineNumberTable和LocalVariableTable)要好有运行时数据区虚拟机栈的基础才能更好的了解,建议移步学习
JVM(二)_运行时数据区

类型名称含义
u2attribute_name_index属性名索引
u4attribute_length属性长度
u2max_stack操作数栈深度的最大值
u2max_locals局部变量表所需的存储空间
u4code_length字节码指令的长度
u1code存储字节码指令
u2exception_table_length异常表长度
exception_infoexception_table异常表
u2attributes_count属性集合计数器
attribute_infoattributes属性集合

在这里插入图片描述
方法中没有没try-catch-finall块,所以异常表是空的,具体案例在稍后的字节码指令集——异常处理指令学习
在这里插入图片描述
在这里插入图片描述

操作数栈深度在jclasslib找不到,但可以从javap反编译结果看到(stack)

在这里插入图片描述
Code的attribute_info主要包含两项LineNumberTable和LocalVariableTable

LineNumberTable用于记录字节码指令偏移地址和源代码所在行数一一对应的关系,这个属性可以用来在调试的时候定位代码执行的行数。

LineNumberTable_attribute{
    u2 attribute_name_index;
    u4 attribute_length;
    u2 line_number_table_length;
    {
        u2 start_pc;//字节码指令偏移地址
        u2 line_number;//字节码指令在源码中所在的行号
    } line_number_table[line_number_table_length];
}

比如下面的代码
在这里插入图片描述
其生成的字节码指令
在这里插入图片描述
0地址的字节码指令,即iinc 1 by 1,对应源码中的17行,即a++。后面同理
在这里插入图片描述

LocalVariableTable局部变量表,局部变量表是可选变长属性,位于Code属性的属性表中。它被调试器用于确定方法在执行过程中局部变量的信息。在Code属性的属性表中,局部变量表属性可以按照任意顺序出现。Code属性中的每个局部变量最多只能有一个。

LocalVariableTable_attribute{
    u2 attribute_name_index;
    u4 attribute_length;
    u2 local_variable_table_length;
    {
        u2 start_pc;
        u2 length;
        u2 name_index;
        u2 descriptor_index;
        u2 index;
    } local_variable_table[local_variable_table_length];
}

  • Nr:工具自带方便看第几项的,并不是属性表的字段
  • Start PC:字节码指令偏移地址
  • Index:索引下标,如果一个方法不是静态方法,0位置默认是this,this作为隐含的变量指向调用该方法的对象
    在这里插入图片描述
    Length记录的是该变量所作用的字节码指令范围(Start PC到Start PC+Length,映射到源码具体行号去LineNumberTable查看),由于变量a和b都是作为形参传进来的,所以作用范围是整个方法
    在这里插入图片描述

在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名之中,因此Java语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。但在class文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个class文件中。
也就是说,尽管Java语法规范并不允许在一个类或者接口中声明多个方法签名相同的方法,但是和Java语法规范相反,字节码文件中却恰恰允许存放多个方法签名相同的方法,唯一的条件就是这些方法之间的返回值不能相同。

属性表

方法表之后的属性表,指的是class文件所携带的辅助信息,比如该class文件的源文件的名称。以及任何带有RetentionPolicy.CLASS 或者RetentionPolicy.RUNTIME的注解。这类信息通常被用于Java虚拟机的验证和运行,以及Java程序的调试,一般无须深入了解。
在这里插入图片描述
在字段的属性表和方法的属性表中, attribute_name_index和attribute_length相同,不同的是属性表的具体内容,所以属性表的通用结构可归纳为

类型名称含义
u2attribute_name_index属性名索引
u4attribute_length属性长度
u1info属性表

在字段的属性表我们使用了ConstantValue类型的属性,在方法的属性表使用了Code的属性,而Code的属性表又使用了LocalVariableTable和LineNumberTable属性。实际上属性表不只有以上几种属性,Java 8里面定义了23种属性
在这里插入图片描述

属性名称使用位置含义
Code方法表Java代码编译成的字节码指令
ConstantValue字段表final关键字定义的常量池
Deprecated类,方法,字段表被声明为deprecated的方法和字段
Exceptions方法表方法抛出的异常
EnclosingMethod类文件仅当一个类为局部类或者匿名类时才能拥有这个属性,这个属性用于标识这个类所在的外围方法
InnerClass类文件内部类列表
LineNumberTableCode属性Java源码的行号与字节码指令的对应关系
LocalVariableTableCode属性方法的局部变量描述
StackMapTableCode属性JDK1.6中新增的属性,供新的类型检查检验器和处理目标方法的局部变量和操作数有所需要的类是否匹配
Signature类,方法表,字段表用于支持泛型情况下的方法签名
SourceFile类文件记录源文件名称
SourceDebugExtension类文件用于存储额外的调试信息
Synthetic类,方法表,字段表标志方法或字段为编译器自动生成的
LocalVariableTypeTable很难过特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加
RuntimeVisibleAnnotations类,方法表,字段表为动态注解提供支持
RuntimeInvisibleAnnotations类,方法表,字段表用于指明哪些注解是运行时不可见的
RuntimeVisibleParameterAnnotation方法表作用与RuntimeVisibleAnnotations属性类似,只不过作用对象或方法
RuntimeInvisibleParameterAnnotation方法表作用与RuntimeInvisibleAnnotations属性类似,只不过作用对象或方法
AnnotationDefault方法表用于记录注解类元素的默认值
BootstrapMethods类文件用于保存invokeddynamic指令引用的引导方法限定符

有兴趣的可以看看下面的文章
class文件详解之属性
Class文件属性表集合详解

字节码指令集

首先明确的一点JVM是基于栈的指令集架构,HotSpot虚拟机中的任何操作都需要入栈和出栈的步骤,即使用栈来管理运行(虚拟机栈中的操作数栈),比如iload_1,表示的是从局部变量表索引为1的位置取出数据放入操作数栈中。

与之相对的就是基于寄存器的指令集架构,稍微有点汇编或者CPU知识的都应该了解。比如经典的x86指令集 mov bx,ax,表示的是将ax寄存器的值赋给bx寄存器

  • 可移植性上:栈式架构不需要硬件支持,移植性好,方便跨平台;寄存器架构完全依赖硬件,移植性差。
  • 设计上:栈式架构大多使用零地址指令方式分配,劈开了寄存器的分配难题,适用于资源受限的系统;寄存器架构的指令从零地址到四地址都有。
  • 速度上:栈式架构在内存中操作,寄存器架构的指令集直接由CPU执行,速度完胜。
  • 指令集上:完成相同的操作,栈式架构需要的指令更多,但是指令集小。

对于使用指令多少,可以看看下面简单的代码
在这里插入图片描述
字节码指令共有9条

在这里插入图片描述
而汇编语言只需要

mov ax,1
add ax,2

JVM的指令由一个字节长度,代表某种特定操作含义的操作码(opcode)以及跟随其后的操作数(operand)所构成,如下图所示
操作数并不是必须的,很多指令不用操作数,如果指令中没有操作数就是零地址指令(比如aload_1),有操作数的就是一地址指令(比如invokevirtual #7),字节码指令都是零地址指令(主要)和一地址指令(汇编指令零地址的少,大多都是一地址和二地址指令)
在这里插入图片描述
由于字节码指令的操作码是一个字节,这表明字节码的种类至多有256种,并且有些指令对不同的数据类型是做区分的,这就要求指令中要包含其操作所对应的数据类型,如下。

  • i:int
  • l:long
  • s:short
  • b:byte
  • c:char
  • f:float
  • d:double

比如istore和dstore都是类似的操作,但是数据类型不同,这样需要学习的字节码就更少了。

也有一些指令的助记符没有明确操作类型,如arrayLength指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。

还有一些指令和数据类型是无关的,比如goto指令

大部分指令都不支持byte,char和short,甚至没有任何指令支持boolean类型。编译器会将byte、char的数据带符号扩展(Sign-Extend)为相应的int类型,将boolean和char类型的数据零扩展(Zero-Extend)为相应的int类型数据。

例如下面类型都不同的各个变量
在这里插入图片描述
其使用的指令都相同,并不区别数据类型
原因很简单,byte,short本身可以转换为数字,char类型对应的ASCII码也是数字,boolean只要将0视为false,非0视为true也能用数字表示,如果这些类型自己再有单独的指令,那单256种字节码指令指定是不够分配了。
在这里插入图片描述

总而言之,将字节码指令按功能分为以下几种来学习

  • 加载与存储指令
  • 运算指令
  • 类型转换指令
  • 对象的创建与访问指令
  • 方法调用与返回指令
  • 操作数栈管理指令
  • 比较控制指令
  • 异常处理指令
  • 同步控制指令

加载与存储指令

加载和存储指令用于将数据在栈帧的局部变量表和操作数栈之间来回传递,或直接将常量加载到操作数栈中的操作

先来了解加载指令,加载指令分为局部变量压栈指令和常量入栈指令。

局部变量压栈指令用于将局部变量表的数据加载到操作数栈中,可以用xload、xload_n指令,(其中,x可以是i、l、f、d、a,后面对于不同数据类型但操作完全一致的指令都用x来代表不同的数据类型;xload_n,n的取值范围为(0~3),即xload_0等,也是为了方便归纳,代表的是一类指令,而不是具体的指令)

xload n和xload_n是等价的,都是表示从局部变量表索引为n处的位置取出数据存放到操作数栈中。这样设计是因为一个字节码指令只占一个字节,而一个操作数占两个字节,对于经常操作的索引位置,可以将操作数去掉,仅用指令来表示,但是操作数隐含在里面,这样就可以节省空间。虽然xload_n的格式节省空间,但一直扩展字节码指令就不够分配了,所以n的范围仅0到3,比如iload_1;超过3的索引位置,就得显示声明操作数,比如iload 4。

常量入栈指令的功能是直接将常量压入操作数栈中,没有涉及局部变量表。根据数据类型和入栈内容不同,可以分为

  • const指令
  • push指令
  • ldc指令

可以认为这三类指令操作的数据范围呈依次增大

const指令系列:对于特定常量的入栈,入栈的常量隐含在指令本身里。比如iconst_0,就代表把0入栈。这类指令由

  • iconst_i:i从-1到5的整数,但是i为-1时,指令为iconst_m1
  • lconst_l:l从0到1的整数
  • fconst_f:f从0到2的整数
  • dconst_d:d从0或1的整数
  • aconst_null

push指令系列:主要包括bipush和sipush,它们的区别在于入栈的数据类型不同,bipush接收8位整数(即-128到127),sipush接收16位整数(即-32768到32767)。

ldc指令系列:如果以上指令都不能满足需求,就可以使用ldc指令,它可以接收一个字节的参数,该参数指向常量池中的int、float或String的索引,将指定的内容压入栈。如果需要接收两个字节的参数,需要用ldc_w;如果压入的元素是long或者double类型的数据,需要用ldc2_w指令。

在这里插入图片描述

最后是存储指令,存储指令就是局部变量压栈指令操作相反的指令,其功能是将操作数栈顶取出数据存放到局部变量表中。
其指令为xstore_n(同样的,x可以是i、l、f、d、a,n范围从0到3)

学习完以上三类指令后,我们来看具体的示例

在这里插入图片描述

int a = 1;
int b = 5;

iconst_1,将常量1入栈
istore_1,将栈顶数据,即1,放入局部变量表索引为1的位置,变量b同理

在这里插入图片描述

随后是int c = a+b;
iload_1 iload_2,分别从局部变量表中索引为1和2的位置取出数据放入栈顶
iadd,取出栈顶的两个数据相加
istore_3,将栈顶元素存储在局部变量表索引为3的位置

在这里插入图片描述
在这里插入图片描述

double d = 10.0;(对于double类型变量,如果数据不是0或1,只能使用ldc2_w指令)
ldc2_w,将常量池索引为13的常量入栈
dstore 4,将栈顶元素存储到局部变量表下标为4的位置

在这里插入图片描述
在这里插入图片描述

ldc,将常量池索引为15的常量入栈
astore 6,将栈顶元素存储到局部变量表下标为6的位置。(对于double和long类型的数据,在局部变量表中占两个slot,所以存放的下标为6而不是5)

在这里插入图片描述
在这里插入图片描述

运算指令

运算指令用于将两个操作数栈上的值进行某种运算,比如加减乘除,取反取模等等,并把结果重新压入操作数栈中。

JVM的运算模式可以分为:

  • 向最接近数舍入模式:JVM要求在进行浮点数计算时,所有的运算机构都必须舍入到适当的精度,非精确结果必须舍入为可被表示的最接近的精确值,如果有两种可表示的形式与该值一样接近,将优先选择最低有效位为零的数据。
  • 向零舍入模式:将浮点数转换为整数时,采用该模式,该模式将在目标数值类型中选择一个最接近但是不大于原值的数字作为最精确的舍入结果。

在数据运算时可能会导致溢出,例如两个很大的正整数相加可能结果是负数,但在JVM规范中并无明确数据溢出的具体结果,仅规定了除法指令和求余指令中当出现除数为0时导致JVM抛出ArithmeticException

所有运算指令包括

  • 加法指令:iadd、ladd、fadd、dadd
  • 减法指令:isub、lsub、fsub、dsub
  • 乘法指令:imul、lmul、fmul、dmul
  • 除法指令:idiv、ldiv、fdiv、ddiv
  • 求余指令:irem、lrem、frem、drem(余数:remainder)
  • 取反指令:ineg、lneg、fneg、dneg(取反:negation)
  • 自增指令:iinc
  • 位移指令:ishl、ishr、iushr、lshl、lshr、lushr(无符号:unsigned、位移:shift、左:left、右:right)
  • 按位或指令:ior、lor
  • 按位与指令:iand、land
  • 按位异或指令:ixor、lxor

对于自增指令和加法指令,其指令不同,但效果完全相同。

在这里插入图片描述
iinc 1 by 10,前者1是局部变量表的索引,后者10是自增的大小
在这里插入图片描述

这些指令没什么好讲的,但我们可以从字节码指令层面来看看经典的i++的一系列问题

首先是i++和++i,可以看到从其生成的字节码一致,所以i++和++i是等价的
在这里插入图片描述
接下来看看a = i++和a = ++i的区别
对于a = i++
其执行流程是:

  1. 从局部变量表取出i入栈
  2. 局部变量表中的i自增,栈顶的数据不随着局部变量的改变而改变,还是1
  3. 栈顶数据赋值给a

所以a的值为1
在这里插入图片描述
对于a = ++i;
其流程是

  1. 局部变量表中的i自增,i为2
  2. 从局部变量表取出i入栈,栈顶数据为2
  3. 栈顶数据赋值给a

所以a的值为2
在这里插入图片描述
同理i = i++和 i = ++i
在这里插入图片描述
在这里插入图片描述
再来看个更变态的,即使有JVM的基础相信很多人也会答错
在这里插入图片描述

首先要知道两条规则

  • 从左到右加载值依次压入操作数栈
  • 运算操作根据运算符优先级决定,其中自增操作高于乘法操作

根据上述两条规则,所以先向栈中压入i的值,即
在这里插入图片描述

操作数栈 = [1]
局部变量表 = {{i=1}}

因为只有i一个变量,所以i的值压入栈后就可以进行运算操作了,加法优先级和乘法优先级都低于自增操作,所以先进行++i操作,++i具体流程就跟上面一样了
在这里插入图片描述

操作数栈 = [1,2]
局部变量表 = {{i=2}}

同理i++操作
在这里插入图片描述

操作数栈 = [1,2,2]
局部变量表 = {{i=3}}

乘法优先级比加法高,先作乘法,栈顶两元素作乘法操作后结果压入栈中

操作数栈 = [1,4]
局部变量表 = {{i=3}}

在这里插入图片描述
加法操作

操作数栈 = [5]
局部变量表 = {{i=3}}

在这里插入图片描述
赋值给变量j

操作数栈 = []
局部变量表 = {{i=3},{j=5}}

在这里插入图片描述
所以结果就是这样了,如果觉得看不太明白可以看看该视频,P1就是尚硅谷经典Java面试题第一季(java面试精讲)
在这里插入图片描述
这里要特别注意这条规则:从左到右加载值依次压入操作数栈

如下把i放到后面,结果又大相径庭了
在这里插入图片描述
在这里插入图片描述

类型转换指令

类型转换用于将两种不同数值类型的数据进行相互转换,一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。

类型转换可以分为宽化类型转换和窄化类型转换。

宽化类型转化(widening numeric conversion,小范围类型向大范围类型转换),比如int数值赋给long变量。
宽化类型转化的指令可以分为

  • int to long/float/double:i2l、i2f、i2d(2和to的读音相等,类似的log4j,就是 log for java)
  • long to float/double:l2f、l2d
  • float to double:f2d

由于short、byte、boolean、char都可以转换为int,并且都可以完整表达,所以例如short宽化为long,用的也都是i2l指令。
在这里插入图片描述

宽化类型转换是不会因为超过目标类型最大值而丢失信息的,转换前后的值是精确相等的,比如从int转换为long。这很容易理解。

但如果是从int/long类型数值转换到float/double时,就有可能丢失最低有效位上的值,从而发生精度丢失。

在这里插入图片描述

在这里插入图片描述
这是因为浮点数采用的是IEEE 754浮点数运算标准(学过计组应该都接触过),该标准会根据原数值转换为最接近舍入模式所得到的正确整数值。

窄化类型转化(narrowing numeric conversion,大范围类型向小范围类型转换),比如long数值赋给int变量。窄化类型转换指令可以分为

  • int to byte/short/char:i2b、i2c、i2s
  • long to int:l2i
  • float to int/long:f2i、f2l
  • doube to int/long/float:d2i、d2l、d2f

窄化类型转换就很容易发生精度损失了,甚至导致结果具备不同的正负号(所以在编码时数据的窄化都需要强制类型转化)
例如int类型的128赋给byte就变成了-128,这两个数据简直天差地别。
具体原因是因为原码补码的问题,可以看看这篇文章
byte(128)为什么是-128?

在这里插入图片描述

对象创建与访问指令

对象创建与访问指令是一系列用于对象操作的的指令,可以细分为

  • 创建指令
  • 字段访问指令
  • 数组操作指令
  • 类型检查指令

创建指令
创建指令是十分常用的指令,new对象和创建数组都是用该类型的指令

  • new:创建类实例,接收一个指向常量池索引的操作数,执行完成后,将对象的引用压入栈
  • newarray:创建基本数据类型的数组
  • anewarray:创建引用类型数组
  • multianewarray:创建多维数组

创建对象(dup为复制指令,具体会在操作数栈管理指令学习,之所以要复制一份,是因为invokespecial和astore_1都需要消耗栈顶元素)
在这里插入图片描述

创建数组
在这里插入图片描述
字段访问指令
对象创建后,就可以通过字段访问指令获取对象实例或数组实例中的字段或者数组元素(就是我们平时说的属性、变量)
对象访问指令分为

  • 访问类字段(Static字段,或者称为类变量)的指令:getstatic、putstatic
  • 访问类实例字段(非Static字段、或者称为实例变量)的指令:getfield、putfield

例如下面的static修饰的变量,以及System.out变量(invokevirtual是方法调用指令,后面学习)
在这里插入图片描述
对于非静态类,当修改其字段/属性时使用了putfield指令,当使用字段/属性时使用了getfield指令
在这里插入图片描述

数组操作指令
数组操作指令也是用于加载和存储操作,但比普通的加载和存储指令复杂一些。加载指令为xastore,存储指令为xaload(注意区分xaload和aload)其中x可有以下数据类型

  • b:byte和boolean
  • c:char
  • s:short
  • i:int
  • l:long
  • f:float
  • d:double
  • a:reference

对于xastore操作,需要准备三个参数:值、索引、数组引用,因此在xatore在执行前,栈顶往下的数据应该依次为值、索引、数组引用。在执行xastore操作后,会从栈中弹出这三个值,赋给数组中索引的位置。

aload_1:从局部变量表索引为1的位置取出数据,由上可知就是数组的引用
iconst_4:向栈中加载常量4
bipush 10:向栈中加载常量10
iastore:弹出栈中三个元素,即10、4、数组引用。最终就是arr[4] = 10;

在这里插入图片描述

对于xaload操作,需要两个参数数组索引和数组引用,因此在xaload指令执行前,栈顶往下的数据应该依次为数组索引和数组引用。在执行xaload操作后,会从栈中弹出这两个值,加载数组中指定索引的值。

在这里插入图片描述
对于boolean类型和byte类型的数组,用的都是bastore指令
在这里插入图片描述

类型检查指令
类型检查指令用于检查两个不同类型的的数据是否可以强制转换。
在Java中有一个关键字instanceof,用来检测某个对象是不是另一个对象的实例,在指令集中同样有instanceof指令,且作用相同,它会将判断结果压入操作数栈中。
另一个指令时checkcast,用来检查类型强制转换是否可以进行,如果可以进行,那么checkcast不会改变操作数栈,否则它会抛出ClassCastException异常。

ifeq是比较指令,暂时先不用了解,总之如果结果为false就跳转到字节码编译地址为23的位置执行
在这里插入图片描述

方法调用与返回指令

在前面的学习或多或少使用过方法,所以这类指令也早就接触

对于方法调用,有以下指令

  • invokevirtual:用于调用对象的实例方法,Java中最常见的方法分派方式,根据对象的实际类型进行分派(虚方法分派),支持多态。
  • invokeinterface:用于调用接口方法, 它会在运行时搜索由特定对象所实现的这个接口方法,并找出适合的方法进行调用。
  • invokespecial:用于调用一些需要特殊处理的实例方法,包括实例初始化(构造器)、私有方法和父类方法,这些方法都是静态类型绑定的,不会在调用时进行动态派发。
  • invokestatic:用于调用类中的类方法(static方法),静态绑定。
  • invokedynamic:调用动态绑定的方法,是JDK 1.7后新加入的指令。用于运行时动态解析出调用点限定符所引用的方法,并执行该方法,这里不予学习。

invokevirtual:用于调用对象的实例方法,Java中最常见的方法分派方式,根据对象的实际类型进行分派(虚方法分派),支持多态。

在这里插入图片描述

invokeinterface:用于调用接口方法, 它会在运行时搜索由特定对象所实现的这个接口方法,并找出适合的方法进行调用。
在这里插入图片描述

invokespecial:用于调用一些需要特殊处理的实例方法,包括实例初始化(构造器)、私有方法和父类方法
在这里插入图片描述
invokestatic:用于调用类中的类方法(static方法),静态绑定
在这里插入图片描述

方法返回指令
方法在调用结束前,都需要进行返回。
在执行方法返回指令后,会将当前函数(栈帧)操作数栈的顶层元素弹出,并将这个元素压入调用者函数的操作数栈中,而当前栈帧操作数栈的数据会丢弃。
如果返回的是synchronized方法,那么还会执行隐含的monitorexit指令来退出临界区
方法返回指令可以分为:

  • void:return
  • int/boolean/byte/char/short:ireturn
  • long:lreturn
  • f:freturn
  • d:dreturn
  • reference:areturn

方法返回指令在前面的示例都能看到,这里就不演示了

操作数栈管理指令

虽然所有字节码指令都在操作栈,JVM还是提供了针对操作数栈的管理指令。

  • pop/pop2:将一个或两个slot大小的数据从栈顶弹出,并且直接废弃
  • dup、dup2、dup_x1、dup_x2、dup2_x2:复制栈顶一个或两个slot数据并将复制值或双份复制值压入栈顶。
  • swap:将栈顶两个slot数值交换位置;JVM没有提供交换两个64位数据类型(long、double)的指令
  • nop:非常特殊的指令,字节码位0x00,和汇编的nop一样,表示什么都不做,一般用于调试、占位。

对于dup系列的指令,dup和dup2直接复制栈顶数据并压入栈。dup和dup2分别复制的是一个slot大小和两个slot大小的数据。
比如dup,可以复制一个int或reference类型的数据
dup2,可以复制一个long/double,或2个int,或1个int+1个float类型的数据

对于带_x后缀的dup指令,表示复制栈顶

比较控制指令

程序流程离不开条件控制,JVM提供了一系列相关指令

  • 比较指令
  • 条件跳转指令
  • 比较条件跳转指令
  • 多条件分支跳转指令
  • 无条件跳转指令

比较指令应该算是运算指令,但是常和条件跳转指令配合使用,所以放在这里学习。
比较跳转指令会从弹出栈顶两个数据来比较,设栈顶数据为v2,下一个栈顶数据为v1,若v1=v2则压入0;若v1>v2则压入1;若v1<v2则压入-1。
比较指令分为

  • dcmpg(cmp:compare比较,d:double,)
  • dcmpl
  • fcmg(f:float)
  • fcmpl
  • lcmp(l:long)

double和float都有两种指令,这是因为double和float会遇到NaN值的情况,如果遇到NaN值,fcmpg会压入1,而fcmpl会压入-1,dcmpg和dcmpl同理,而long类型是不会发生NaN值的情况

在Java中,当一个操作产生溢出时,将会使用有带符号的无穷大表示,如果某个操作结果没有明确的数学定义的话,将会使用NaN值来表示,而且所有使用NaN值作为操作数的算术操作,结果都会返回NaN,如下。
在这里插入图片描述

条件跳转指令
条件跳转指令通常和比较指令结合使用。在条件跳转指令执行前,一般先用比较指令进行栈顶元素的准备,然后进行条件跳转。
条件跳转指令有

  • ifeq:if equal,栈顶数值为int类型的0时跳转
  • ifne:栈顶数值不为0时跳转
  • iflt:less than,栈顶数值小于0时跳转
  • ifle:less than or equal to,栈顶数值小于等于0时跳转
  • ifgt:greater than,栈顶数值大于0时跳转
  • ifge:greater than or equal to,栈顶数值大于等于0时跳转
  • ifnull:栈顶数值等于null时跳转
  • ifnonnull:栈顶数值不等于null时跳转

以上这些指令都接收两个字节的操作数,用于计算跳转指令的位置(16位符号整数作为当前位置的offset),如果预设条件不成立则执行跳转,否则继续执行下一条语句。
例如下面的示例,在判断a!=0时并不是直接使用ifne指令,而是使用相反ifeq来跳转到与a!=0相反的情况(即a==0),而a!=0的情况直接往下执行即可,这样编译器可以省略一些步骤,但是开发者看得会有点别扭。

在这里插入图片描述
在这里插入图片描述

比较条件跳转指令
比较条件跳转指令类似于比较指令和条件跳转指令的结合体,它将比较和跳转两个步骤合二为一。这类指令有

  • if_icmpeq:比较栈顶两int类型数值大小,当前者等于后者时跳转
  • if_icmpne:比较栈顶两int类型数值大小,当前者不等于后者时跳转
  • if_icmplt:比较栈顶两int类型数值大小,当前者小于后者时跳转
  • if_icmple:比较栈顶两int类型数值大小,当前者小于等于后者时跳转
  • if_icmpgt:比较栈顶两int类型数值大小,当前者大于后者时跳转
  • if_icmpge:比较栈顶两int类型数值大小,当前者大于等于后者时跳转
  • if_acmpeq:比较栈顶两引用类型数值,当结果相等时跳转
  • if_acmpne:比较栈顶两引用类型数值,当结果不相等时跳转

多条件分支跳转指令
该指令时专门为switch-case语句设计的,分为

  • tableswitch:case值连续的情况
  • lookupswitch:case值不连续的情况

tableswitch要求多个条件分支值时连续的,它内部只存放起始值和终止值,以及若干个跳转偏移量,通过给定操作数index,可以立即定位到跳转偏移量位置,因此效率比较高。

在这里插入图片描述

lookupswitch处理的是离散的case值,出于效率考虑,会将case-offset对按照case值大小排序。
可以看到源码case是乱序的,但字节码指令会帮助排好序
在这里插入图片描述

在JDK 7中,switch(index)的index可以是String类型,String毫无疑问肯定是使用lookupswitch指令,但如何排序呢?
实现也很简单,根据其hashcode值排序即可
在这里插入图片描述
无条件跳转指令
当执行到无条件跳转指令,就会直接跳转到偏移位置的字节码指令执行。

  • goto:无条件跳转
  • goto_w:无条件跳转(宽索引)
  • jsr:跳转至指定16位offset位置,并将jsr下一条指令地址压入栈
  • jsr_w:跳转至指定32位offset位置,并将jsr_w下一条指令地址压入栈
  • ret:返回至由指定的局部变量所给处的指令位置(一般与jsr、jsr_w联合使用)

jsr、jsr_w、ret用于try-catch-finally语句,但目前已被JVM废弃,而是用异常表来处理try-catch-finally块,因此这里不学习这三个指令。

goto指令在上面的示例就出现过,循环语句也是goto指令经常使用的场合
如果goto想要跳转的距离超过两个字节,则使用goto_w,该指令的接收四个字节的offset

在这里插入图片描述

异常处理指令

在Java显示抛出异常的操作(throw语句)都是由athrow指令来完成,JVM规范还规定了许多运行时异常会在其他JVM指令检测到异常情况时自动抛出。比如整数运算中,当除数为零时,JVM会在idiv/ldiv指令中抛出ArithmeticExpection。

正常情况下,操作数栈的入栈出栈都是一条条指令完成的,唯一的例外情况时在抛异常时,JVM会清除操作数栈上的所有内容,随后将具体的异常实例压入调用者操作数栈中。

如下图,athrow指令会抛出栈顶的具体异常对象
在这里插入图片描述
对于异常处理,前面说过,JVM已经抛弃了jsr、jsr_w、ret相关的指令,取而代之的是在方法表中的Code属性表项用异常表来处理try-catch、try-finally、try-catch-finally

异常表保存了每个异常处理信息,包括:

  • 起始位置:Start PC
  • 结束位置:End PC
  • PC寄存器记录的代码处理的偏移地址
  • 被捕获异常类在常量池中的索引

在这里插入图片描述

以下面代码为例
在这里插入图片描述

在这里插入图片描述
这部分字节码指令对应的是正常执行的情况,看看就行
在这里插入图片描述
根据异常表,可以得知异常表处理的都是为字节码偏移地址0~19范围的代码
在这里插入图片描述

通过LineNumberTable查看字节码指令和源码行数的映射关系,其实就是整个try-catch的代码(Start PC从生效位置开始记录,所以不是try所在的42行,而是43行)
在这里插入图片描述
对于第一个catch块,其执行异常的代码为偏移地址22的地址,如下图所示

在这里插入图片描述
首先是astore_1,即把引用类型的变量存放在局部变量表中索引为1的位置

从下表中看到索引为1的位置有三个数据,因为try-catch语句也算分支语句,所以实际上这该位置的数据并不会冲突
根据该异常的字节码指令偏移地址,所以取出的数据应为Start PC 23处的FileNotFoundException对象在这里插入图片描述
接着是aload_1,就是把刚才的FileNotFoundException对象入栈
24地址调用FileNotFoundException的printStackTrace()方法输出异常信息,最后跳转到33地址处(return),方法结束。
在这里插入图片描述
RuntimeException异常同理
在这里插入图片描述
如果异常处理以throws的方式向上抛,字节码指令是没有相关指令的

在这里插入图片描述
而是在方法表中的属性表项中新增Exceptions项来记录这些异常
在这里插入图片描述

同步控制指令

线程同步是多线程中重要的知识,这里不过多学习,有兴趣的可以看看
Java学习_多线程编程(上)

JVM提供了两个用于同步控制的指令:monitorenter和monitorexit(在多线程中monitor监视器就是俗称的锁)。这两条指令来支持synchronized关键字的语义。

当一个线程进入同步代码块时,它使用monitorenter指令请求进入,如果当前对象的监视器计数器为0,则允许进入;若为1,则判断持有当前监视器的线程是否为自己,如果是则进入,否则阻塞等待,直到对象的监视器计数器为0才能进入。

当线程退出同步块时,需要使用monitorexit声明退出,在JVM中,任何对象都有一个监视器与之相关联。用来判断对象是否被锁定,当监视器被持有后,对象处于锁定状态。

monitorenter和monitorexit在执行时,都需要在操作数栈顶压入对象,之后monitorenter和monitorexit的锁定和释放都是根据这个对象的监视器进行的,所以这个对象必须为各个线程共享,一种方法就是直接使用该类的字节码文件作为监视器
如下(由于dup指令,栈顶还存在一个监视器,所以monitorenter前不需要load,而monitorexit前需要将监视器load出来)
在这里插入图片描述
实际上,代码的执行到地址12处就结束了,但是后面还多出了很多指令,我们可以通过后面的athrow猜测出应该与异常有关
在这里插入图片描述
尽管在源码中并没有显示声明异常处理,但是还是生成了异常表
其实很容易理解,如果一个线程在同步代码块中发生异常没有释放锁,就会造成死锁问题,导致其他线程获取不到锁,就永远无法执行同步代码块的中的代码。
可以看到PC 7 ~ 12和15 ~ 18的指令的异常处理都是地址15的指令开始的,这也很好理解:7 ~ 12是同步代码块的代码,15 ~18间的代码发生异常就重新回到15执行,即一直循环直到锁成功释放。
在这里插入图片描述

值得注意的是,除了以上同步方式,还有一种方法级的同步,即在方法上使用synchronized关键字修饰,方法级同步生成的指令和普通的方法并无差异。
在这里插入图片描述
而是在该方法直接使用ACC_SYNCHRONIZED访问标志标识该方法时方法级同步
在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值