java原理实现: 从虚拟机到源码

java原理实现: 从虚拟机到源码

Sun公司与其他组织发布了许多可以运行在各种不同平台上的虚拟机, 这些虚拟机都可以编译和执行同一种平台无关的字节码, 从而实现了"一次编写, 到处运行".

了解Java内存

​ Java内存是由虚拟机自动管理的, 当时也会发生内存溢出异常. 为了在发内存溢出时不至于束手无策, 还是需要了解 Java 怎么管理内存的.

a. Java内存划分

​ Java内存划分按照各自的用途以及创建和销毁的时间. 优点区域随着虚拟机进程的启动而存在, 有的依赖用户线程的启动和结束而建立和销毁.

b. 跟随线程的区域

程序计数器

​ 程序计数器可以看做是当前线程执行的字节码的行号指示器. 它是通过改变计数器的值来选择下一条需要执行的字节码指令. 是程序能够按照逻辑执行各种操作的关键.

​ 每个线程都有自己的程序计数器, 同一时刻一个处理器只会执行一个独立的程序计数器. 各条线程之间的计数器互不影响, 独立存储. 可以看做是 "线程私有" 的内存.

 

Java虚拟机栈

​ 每个线程拥有独立的Java虚拟机栈. 它的生命周期与线程相同.

​ 虚拟机栈描述的是Java方法 执行的内存模型. 每个方法在执行的时候都会创建一个栈帧( Stack Frame 栈框架), 存储了表: 局部变量表 (详见书籍8.2章), 操作数栈(记录操作次数), 动态链接(指向运行时常量池, 取出该栈帧所属方法的部分常量), 方法出口等信息. 在虚拟机运行的时候用到.

局部变量表: 

存放了在编译期可知的各种基本数据类型(虚拟机在编译代码时就确定了方法变量的数量和类型等), 包括8种基本数据类型和对象引用( Reference 类型, 可能是一个指针,指向对象起始地址, 或是指向一个代表对象的句柄或其他与此对象相关的地址位置) 和 returnAddress 类型(指向一条字节码指令的地址).

​ 64位的 long 和 double 类型的数据占用了2个局部变量空间(Slot, 可看作空间单位), 其他类型占1个Slot.

所需的空间在编译期分配完成,

当进入一个方法时, 这个方法需要在帧中分配多大的局部变量空间是完全确定的. 运行期间也不会改变. 因为这里 跟方法是否执行无关, 仅记录了方法的特征. 每当方法需要的时候就从局部变量表中读取.

​ 编译期发生了哪些事(后面会有详细解释): Java虚拟机按照规范, 把代码按照固定的 Class 文件格式解析成二进制文件. Class文件中描述了类, 方法, 属性, 接口等各种信息. 需要提前加载的常量会提前加载, 整个过程包含了验证, 分配内存等操作.

​ 栈深: 如果创建时线程请求的栈深度大于虚拟机允许的深度, 将抛出 StackOverflowError 异常; 大部分虚拟机允许动态扩展, 扩展时如果无法申请到足够的内存, 就会抛出 OutOfMemoryError 异常.

本地方法栈

与虚拟机栈非常的相似, 是虚拟机的一部分. 只不过是为虚拟机的Native方法服务. 虚拟机栈是为了执行Java方法(已翻译成字节码)服务.

同样会抛出 StackOverflowError 和 OutOfMemoryError 异常.

HotSpot直接把本地方法栈和虚拟机栈合二为一.

c. 随虚拟机启动的区域

Java堆

只用来存放对象实例. 因此一般是所有内存中最大的一块. 它被所有线程共享. 几乎所有的对象实例都要在这里分配内存. 垃圾收集器也主要管理这一块. 因此也成 "GC堆" (Garbage Collected Heap). 还可细分为新生代和老年代. 具体在后面解释

内存分配:

优化内存分配的目的是为了更好的回收内存, 或者更快的分配内存.

Java堆可以是不连续的, 容量对大部分虚拟机来说都是按照可动态扩展来实现. 当堆中没有足够内存完成实例分配, 并且也无法再扩展时, 将会抛出 OutOfMemoryError 异常.

方法区(非堆)

用于存储已被虚拟机加载的类的信息, 常量, 静态变量, 即时编译器编译后的代码等数据. 虽然Java虚拟机规范描述为属于堆的一个逻辑部分, 但是有个别名叫 Non-Heap (非堆).

在HotSpot中, 使用永久代的GC方法管理方法区. 实际并不等价于永久代. 而且较其他虚拟机更容易产生内存溢出问题.

这个区域的垃圾回收主要针对常量池的回收和类型的卸载. 回收效果难以令人满意, 尤其是类型卸载的条件相当苛刻.

运行时常量池 :

方法区的一部分. 编译成的Class文件中有一项信息就是常量池 (Constant Pool Table). 用于存放编译期生成的各种字面量和符号引用. 这部分内容在类加载后会进入方法区的运行时常量池中存放.

Java并不要求常量一定只有编译期才能产生(Class文件常量池的内容), 运行期间也可能将新的常量放入常量池中. 比如 Java 代码中字符串的连接.

当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常.

d. 直接内存

直接内存 (Direct Memory) 不属于虚拟机运行时数据区的一部分, 也不是Java虚拟机规范中定义的内存区域. 但是也被频繁使用, 同样会导致 OutOfMemoryError 异常.

JDK1.4加入了NIO (New Input/Output) 操作, 引入了一种基于通道 (Channel) 与缓冲区 (Buffer) 的 IO方式. NIO操作需要用到Direct Memory内存, 通过存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作. 避免了在 Java堆 和 Native堆中来回复制数据.

因为用到内存, 必然受本机内存影响. 如果把内存都分配给虚拟机, 用到直接内存时就会抛出 OutOfMemoryError 异常. 尤其是大量使用 NIO的程序.

虚拟机做了哪些工作?

编译

​ 虚拟机编译器把写好的代码编译为存储字节码的Class文件.

​ 虚拟机不和任何语言绑定, 它只与"Class" 文件 <一种特定的二进制文件格式> 关联. Class文件中包含Java虚拟机的指令集和符号表以及其他辅助信息. 代码中的各种变量,关键字,运算符的语义最终都是由多条字节码命令组合而成. 所以一些Java本身无法有效支持的语言特性不代表字节码本身无法有效支持.

​ 释疑: 什么是Java编译, 编译期, 编译时都做了哪些事情?

执行

​ 虚拟机根据Class文件执行各种指令, 完成所有操作.

n. new 一个对象:

​ 分配内存, 通过指针碰撞, 或者空闲列表. 考虑到线程安全, 一种是对分配内存空间的操作进行同步处理--实际上虚拟机采用CAS(Compare And Swap 比较并操作)和失败重试的方式保证更新的原子性, 另一种是每个线程在java堆中预先分配一小块内存空间, 称为本地内存分配缓冲(Thread Local Allocation Buffer, TLAB). 某个线程需要内存, 就从所属的TLAB上分配. 只有TLAB用完, 并分配新的TLAB时, 才需要同步锁定.

​ 分配完成后, 虚拟机要将分配到的内存空间都初始化为零值(不包括对象头. 如果使用TLAB, 则在TLAB分配时进行). 这一步操作保证了对象的实例字段在java代码中不需要赋初值就直接使用. 程序能访问这些字段的数据类型所对应的零值.

​ 接下来, 虚拟机要对对象进行必要的设置, 这些信息存放在对象头(Object Header)之中. 根据虚拟机当前的状态不同, 对象头会有不同的设置方式.

​ 此时, 对象的创建对虚拟机来说已经完成了. 但是对程序来说所有的字段都还为零, 还要执行<init>方法. 一般来说, 执行new指令之后都会紧接着执行<init>方法(详见.class文件的方法表), 把对象初始化, 这是一个真正的对象才被new出来.

内存管理

虚拟机在运行的同时进行内存管理. 当内存已满, 就会进行GC(Garbage Collection).

虚拟机如何管理内存:

​ java内存运行时, 程序计数器, 虚拟机栈, 本地方法栈3个区域随线程而生, 随线程而灭. 每一个栈帧中分配多少内存基本上是在类结构确定下来的时候就是已知的. 因此这几个区域的内存分配和回收都具备确定性. 随着方法结束或者线程结束时, 内存自认就跟着回收了. 而Java堆和方法区则不一样. 一个接口的多个实现类需要的内存可能不一样, 一个方法的多个分支需要的内存也不一样. 只有在程序运行期间才能知道会创建哪些对象. 这部分内存的分配和回收都是动态的. GC也主要关注这部分内存.

a. 什么时候引发GC

​ 新生代(Eden)没有足够的空间分配, 虚拟机将发起一次Minor GC(新生代GC, 特性: 非常频繁, 速度快).

​ 大对象(典型: 很长的字符串,数组)的问题: 经常出现大对象容易导致内存还有不少空间时就提前触发GC以获取足够的连续空间. 可以设置大于某个值的对象直接进入老年代, 避免在Eden区和两个Survivor区来回的复制(复制算法).

晋升老年代: 活过多次GC, 或相同年龄的所有对象占用空间很大, 大于等于这个年龄的对象直接进入老年代, 或者大对象.

​ 空间分配担保:

​ 在发起Minor GC之前, 虚拟机会先检查老年代的最大可用的连续空间是否大于新生代的所有对象总空间. 如果成立, 则Minor GC是安全的, 不成立, 则带有风险. 如果允许担保失败, 就会进行尝试一次Minor GC, 否则改为Full GC(老年代GC 比Minor GC慢10倍以上).

​ 分配担保: 新生代内存已满, 需要老年代进行分配担保, 把之前每一次回收晋升到老年代对象容量的平均大小值作为经验值, 与老年代的剩余空间进行比较, 决定是否进行Full GC来让老年代腾出更多空间. 如果担保失败, 只能在失败后发起一次Full GC.

b. 判断对象是否死亡: 可达性分析算法和引用计数法

1. 引用计数法:

原理: 给对象添加一个引用计数器, 每当有一个地方引用它时, 计数器加一, 当引用失效时, 引用就减一. 任何时刻计数器都为0的对象就是不可使用的.

优点: 实现简单, 判定效率高.

缺点: 很难解决对象之间的循环引用的问题.(两个对象相互引用)

  1. 可达性分析算法:

原理: 通过一系列的成为"GC Roots"的对象作为起始点, 从这些节点开始向下搜索, 搜索所走过的路径称为引用链(Reference Chain), 当一个对象到GC Roots没有任何引用链(图论中称为从GC Roots到这个对象不可达)时, 证明此对象是不可达的.

3. 延伸:

可作为GC Roots的对象分为几种:

虚拟机栈(栈帧中的本地变量表)中引用 的对象

  1. 方法区中类静态属性引用的对象(static 属性引用的对象)
  2. 方法区中常量引用的对象
  3. 本地方法栈中 JNI (Native 方法)引用的对象

引用也分为强引用, 软引用, 弱引用, 虚引用, 用于内存充足时保留部分某些对象

c. 方法区的回收:

方法区(或者HotSpot虚拟机中的永久代) 中进行垃圾回收性价比很低. 在堆中, 尤其是新生代, 常规一次GC可以回收75% ~ 95%的空间. 永久代的效率远低于此.

永久代的GC主要回收: 废弃常量 和 无用的类

断一个常量是否是废弃常量比较简单, 但是判定类是否无用就苛刻的多. 需要满足以下3个条件:

  1. 该类所有的实例对象都被回收
  2. 加载该类的类加载器(ClassLoader) 已经被回收
  3. 该类对应的**java.lang.Class对象没有在任何地方被引用, 无法在任何地通过反射访问该类的方法.在大量使用反射, 动态代理, CGLib等ByteCode框架, 动态生成JSP页面以及OSGi这类需要频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能, 以保证永久代不会溢出

d. 垃圾收集算法:

算法分类:

  1. 标记-清除算法

分为 "标记" 和 "清除" 两个阶段. 首先标记所有需要回收的对象, 在标记后统一回收. 不足: 效率问题: 标记和清除的效率都不高 空间问题: 清除之后会产生大量不连续的内存碎片. 空间碎片太多可能会导致以后分配较大对象时没有足够的连续空间, 而不得不提前触发另一次GC.

  1. 复制算法

新生代中, 98%的对象是朝生夕死的, 这个区域采用的算法是将存活的对象一次复制到另一块单独的内存中(称为Survivor), 之后清空其余内存. 再一次GC时, 同样复制到另一块Survivor中, 之后清理掉Eden和用过的Survivor. 考虑到存活的对象很少, 并且为了减少内存的浪费, HopSpot默认的内存分配比例是8 : 1 : 1, 这样只有10%的内存没有使用.

​ 当Survivor内存不够用时, 需要依赖其他内存(指老年代)进行分配担保(Handle Promotion)

  1. 标记-整理算法

​ 在老年代中, 在回收的对象存活率较高时就要进行较多的复制操作, 效率低. 更关键的是如果不想浪费大量的内存空间, 就要有额外的内存空间进行分配担保, 以应对内存中100%的对象都生存的极端情况. 因此一般采用标记-整理算法. 先进行标记, 之后把所有存活的对象往内存的一端移动, 最后清理边界外的空间.

  1. 分代收集算法

​ 分代收集算法为了在不同的内存区域采用不同的内存回收策略. 根据对象的生存周期, 把内存分为几块. 一般把Java堆分为新生代和老年代. 新生代中采用复制算法, 老年代中采用 标记-清理 或 标记-整理 算法.

分区收集:

​ 将内存分为多个大小相等的独立区域(Region), 新生代和老年代都是一部分Region(不需要连续)的集合, 根据垃圾回收价值, 维护一个优先列表, 优先回收价值最大的区域(Garbage-First).

​ 程序对Reference类型的数据进行写操作时, 会检查引用的对象是否处于不同的Region之中, 是则把相关引用信息记录到被引用对象所属的Region的Remembered Set, GC根节点的枚举范围加入Remembered Set即可保证不会对全堆扫描也不会有遗漏的引用.

e. Stop The World

​ 安全点:

​ 在使用GC Roots逐个检查内存中的引用. 为了能够保证一致性, 在整个分析期间所有执行过程被冻结在某个时间点上, 否则分析结果的准确性就无法保证. 即使在CMS(Concurrent Mark Sweep 并发标记清除)收集器中, 在枚举根节点时也是必须要停顿的.

​ 因为使用了准确式内存管理(虚拟机可以知道内存中某个位置的数据具体是什么类型), 因此虚拟机知道哪里存放着对象的引用. HotSpot中使用了OopMap数据结构, 在类加载完成(见之前的章节)时, HopSpot就把记录对象内某个偏移量对应的数据类型. 在 JIT 编译过程中也会在特定位置记录栈和寄存器中哪些位置是引用.

​ 由此, HopSpot可以快速且准确的完成GC Roots枚举. 但是导致OopMap内容变化的指令非常多, 如果为每一条指令都生成对应的OopMap 将会需要大量额外的内存空间, GC的时间成本会很高.

​ 实际上, HopSpot只在特定的位置记录了这些信息, 称为安全点(Safepoint). 当GC开始, 只有程序执行到最近的安全点时(包括具有方法调用, 循环跳转, 异常跳转等操作的指令)才会暂停下来.

​ 关于安全点及主动式中断: 当GC开始时, GC需要中断线程, 不直接对线程操作, 仅仅在某个安全点设置一个标志, 每个线程主动去轮询这个标志, 发现中断标志为真时就自己中断挂起. 或当创建对象需要分配内存的地方.

​ 另一个问题: 当线程本身是阻塞的, 没有被分配CPU时间, 线程无法响应JVM的中断请求, 走到安全点中断挂起. 这就需要安全区域(Safe Region). 安全区域指的是代码中引用关系不会发生变化的区域. GC发生时, 不管进入安全区域, 标识为Safe Region的线程. 并且在GC结束前, 该线程会一直停留在安全区域, 直到收到GC结束的通知才能离开.

f. 优化

​ 垃圾回收策略: 根据不同的程序应用不同的垃圾回收策略. 详细见各种垃圾收集器的原理及特性. 根据需要, 选择不同的垃圾回收策略. 主要的关注点: Stop The World时间和效率. 主要手段: 策略优化与并行并发.

Java类和方法如何加载

a. Class类文件结构

​ Class文件是一组以8字节为单位的二进制流. 各个数据项目严格按照顺序紧凑的排列在Class文件中, 中间没有任何分隔符.

​ Class文件格式采用类似C语言结构的伪结构来存储数据. 这种伪结构只有两种数据类型: 无符号数和表

​ 无符号数属于基本的数据类型. 以u1,u2,u4,u8分别代表1个字节, 2个字节, 4个字节,8个字节的无符号数.无符号数可以用来描述数字, 索引引用, 数量值或者按照UTF-8编码构成的字符串.

​ 表是由多个无符号数或其他表作为数据项构成的复合数据类型, 所有表都习惯的以"_info"结尾.

​ 表用于描述有层次关系的复合结构的数据. 整个Class文件其实就是一张表.

b. Class文件怎么描述数量不定的数据

​ 由于Class文件没有任何分隔符号, 因此无论是顺序和数量, 甚至于数据存储的字节序都是被严格限定的. 每一个字节代表的含义, 长度多少, 先后顺序都不允许改变.

​ 无论是符号还是表, 当需要描述同一类型但数量不定的多个数据时, 经常会使用一个前置的容量计数器加若干个连续的数据项的形式. 这时称这一系列连续的某一类型的数据为某一类型的集合.

c. Class文件的格式

​ 1.每个Class文件的头4个字节成为魔数, 0xCAFEBABE, 它的唯一作用是标识这是一个Class文件.

​ 2.紧跟着魔数的第5, 第6个字节是次版本号, 第7,8个字节是主版本号. 高版本的 JDK 能向下兼容低版本的版本的Class文件, 但不能运行以后版本的Class文件, 即使文件格式未发生任何变化.

​ 3.常量池

​ 紧接着是常量池入口. 它是Class文件存储的仓库, 也是与其他项目关联最多的数据类型, 还是Class文件中占用空间最大的项目之一.

​ 常量池头两个字节(u2类型的数据)代表常量池容量计数值. 它的计数是从1开始, 0有特殊用途(用于表达不引用任何一个常量池项目). 它是Class文件中唯一 以1开始计数的类型.

​ 常量池主要存储字面量 (Literal) 和符号引用 (Symbolic ?象征 Reference). 字面量接近Java中的常量. 符号引用属于编译原理方面的概念. 包括以下三类常量:

​ a. 类和接口的完全限定名(Fully Qualified Name)

​ b. 字段的名称和描述符(Descriptor)

​ c. 方法的名称和描述符

(总结: 类, 接口, 方法和字段的名字和描述符. 类创建或运行时解析,翻译到具体的内存地址中)

Java代码在进行Javac编译的时候进行动态连接. 当虚拟机运行时, 需要从Class文件的常量池中获取对应的符号引用 (完全限定名或名称+描述符), 再在类创建或运行时解析, 翻译到具体的内存地址中.

​ 常量池中的每一个常量都是一张表. JDK1.7中共有14张表, 代表了14个常量. 这14张表每个都有自己的结构.

​ 常量池中的会自动生成一些常量, 用来描述一些不方便用 "固定字节" 描述的内容. 譬如方法的返回值数什么, 有几个参数? 每个参数的类型是什么. 这将在后面的字段表(field_info), 方法表(method_info), 属性表(attribute_info)中用到.

​ 4.常量池之后紧接着2个字节带表访问标记(access_flags), 这个标志用于识别一些类或者接口层次的访问信息. 包括这个Class是类还是接口(代码中声明的class/interface); 是否定义为public类型; 是否为abstract类型, 如果是类, 是否被声明为final等. 还有枚举, 注解.

​ 5.类索引和父类索引与接口索引集合确定了这个类的继承关系.

​ 类索引和父类索引分别是类或父类的完全限定名, 接口索引集合中描述了这个类实现了哪些接口, 按照代码中写入的顺序排列在索引集合中.

​ 6.字段表集合描述接口或者类中声明的变量, 包括实例变量和类变量(static修饰的变量), 但是不包括类中声明的局部变量. 包含的信息有字段的作用域(public, private, protected修饰符), static修饰符, 此外还有transient修饰符(禁止序列化), 并发可见性(volatile, 是否强制从主内存读写). 上述信息都可以用布尔值在Class文件中表示是否存在. 而字段的名称, 字段类型(基本数据类型, 数组, 对象)这些都是无法固定的, 只能在标志位中引用常量池中的常量来表示. 在字段表的最后有一个属性表集合用于存储额外的信息.

​ 字段表集合中不会列出超类或者父接口继承过来的字段, 当有可能列出代码中原来不存在的字段, 譬如在内部类中会自动添加指向外部类实例的字段. 另外Java中字段是无法重载的, 但是有的语言可以, 对字节码来说是允许的.

​ 7.方法表集合类似于字段表集合. 依次包括了访问标志(都是布尔值), 名称索引, 描述符索引, 属性表集合等项.

​ 至此, 我们已经有了从类的继承体系到方法名都有了, 我们写的方法内部代码在哪呢? 方法的Java代码经过编译器编译成字节码指令后, 存放在方法属性集合中一个名为Code的属性里面.

​ 如果父类中的方法没有被重写, 方法表中不会出现来自父类的方法信息. 但是会有编译期自动添加的方法: 典型的是类构造器"<clinit>"(class-init)和实例构造器"<init>"方法.

​ Java中同时存在仅仅返回值不同的两个方法是不合法的, 但是对Class文件来说不存在这个问题. 允许其他语言方法中存在这样的两个方法.

​ 8.属性表集合在之前已经提过多次, 在Class文件, 字段表, 方法表都可以携带自己的属性表集合, 以用以描述某些场景专有的信息.

​ 与其他数据项目不同, 属性表集合的限制宽松了一些. 不再要求各个属性表具有严格的顺序, 并且只要不与已有的属性名重复, 任何人实现的编译期都可以向属性表中写入自己定义的属性信息. Java虚拟机运行时会忽略它不认识的属性.

​ 虚拟机规范预定义的属性详见书籍.里面定义了许多类, 方法, 字段相关的属性. 对于每个属性的名称, 需要从常量池中引入一个CONSTANT_Utf8_info 类型的常量表示. 而属性值的结构完全是自定义的, 只需要通过一个u4的长度值去说明属性值所占用的位数.

​ 9.Code属性: Java程序代码在经过编译后, 转化为字节码指令存储在Code属性内. Code属性出现在方法表的属性集合之中. 但像接口或抽象类中的抽象方法就不存在Code属性.

​ Code属性之中包括了栈的最大深度, 局部变量表所需的存储空间, Java源程序编译后生成的字节码指令.

​ 每个指令长度为u1, 虚拟机明确规范了一个方法不允许超过2`16, 即65535条指令. 另一个问题是每个方法的参数列表至少为1, 因为Java在编译时自动添加了一个代表该对象实例的参数, 以便在方法内能够使用 this 关键字.

​ 在字节码指令之后是这个方法的显示异常处理表. 异常表实际上是Java代码的一部分, Java使用异常表而不是简单的跳转命令来实现Java异常和finally处理机制.

​ Code属性表中的指令执行过程详见书籍

​ 10.Exception属性是方法表中与Code属性平级的一项属性. 它的作用是列出方法中可能抛出的受检查(Checked Exception)异常, 也就是方法中throws关键字后面列举的异常.

​ 11.LineNumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系. 用来在程序出错时显示出错的行号, 也无法按照行号来设置断点. 不是必须的.

​ 12.LocalVarable Table属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系. 它也不是必须的. 如果没有这个属性, 最大的影响就是当其他人引用这个方法时, 关于所有参数名称的提示都会丢失. 而且在调试期间无法根据参数名称从上下文中获得参数值. 它的姐妹属性是LocalVariableTypeTable, 用于描述泛型.

​ 13.SourceFile属性用于记录生成这个Class文件的源码文件名称. 如果没有, 在异常时不会显示出错代码所属的文件名.

​ 14.ConstantValue属性的作用是通知虚拟机自动为静态变量赋值. 只有被static关键字修饰的变量(类变量)才能使用这个属性. 实例变量的赋值实在实例构造器<init>方法中赋值的. 类变量在使用ConstantValue属性 (final + static, 常量, 基本数据类型或String)或者<clinit>方法中 (static) 中赋值.

​ 15.InnerClasses属性用于记录内部类和宿主类的关联. 如果一个类中定义了内部类, 编译期将会为它以及它所包含的内部类生成InnerClasses属性.

​ 16.Deprecated及Synthetic

​ deprecated属性都属于标志类型的布尔属性, 已经不再推荐使用, 可以通过在代码中使用@deprecated注解进行设置.

​ Synthetic属性代表此字段或者方法不是由Java源码产生的, 而是由编译期自行添加的. 也可以设置它们访问标志中的ACC_SYNTHETIC标志位.

​ 17.StackMap Table属性位于Code属性表中. 这个属性会在类加载的字节码验证阶段(后面会讲解类的加载过程)被新类型检查验证器(Type Checker)使用, 目的在于代替以前比较消耗性能的基于数据流分析的类型推导器.

​ 通过在编译阶段就把一系列的验证类型(Verification Types)直接记录在Code属性表中, 通过检查这些验证类型判断字节码的行为逻辑是否合法.

​ 18.Signature属性可以出现于类, 字段表和方法表结构的属性表中, 用来记录泛型签名中的类型变量或参数化类型信息. 为了弥补Java的伪泛型的缺陷. 例如反射时无法获取泛型类型.

​ 19.BootstapMethods属性位于类文件的属性表中.

什么是字节码指令?

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

字节码与数据类型

​ 在Java虚拟机的指令集中, 大部分的指令都包含了其操作所对应的数据类型信息. 如iload 中 i -> int, fload中 f -> float等等.

​ short, byte, boolean, char4中类型的部分指令在运行时会转化成int指令(因为范围比int小, 无损转化).

a. 加载和存储指令

​ 加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输, 这类指令包含如下内容:

​ a. 将一个局部变量加载到操作栈: iload, iload_<n>等等 ----> 加载局部变量

​ b. 将一个数值从操作数栈存储到局部变量表中: istore, istore<n>等等 ---->存储局部变量

​ c. 将一个常量加载到操作数栈: bipush, sipush, ldc, ldc_w, ldc2_w等 ---->加载常量

​ d. 扩充局部变量表的访问索引的指令: wide ---->局部变量表增大索引容量

​ 存储数据的操作数栈和局部变量表主要就是有加载和存储指令进行操作.

b. 运算指令

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

​ 大体上分为对整形数据与浮点数据运算两种. byte, boolean, short, char使用int类型的指令代替.

​ 包括如下几种

​ a. 加法指令

​ b. 减法指令

​ c. 乘法指令

​ b. 除法指令

​ e. 求余指令

​ f. 取反指令

​ g. 位移指令

​ h. 按位 或 指令

​ i. 按位 与 指令

​ j. 按位 异或 指令

​ k. 局部变量自增指令

​ l. 比较指令

​ Java虚拟机要求在进行浮点数运算时, 所有的运算结果都必须舍入到适当的精度, 非精确的结果必须舍入为可被表示的最接近的精确值. 如果有两种可能, 将优先选择最低有效位为0的.

​ Java虚拟机在处理浮点数时不会抛出任何运行时异常. 当数据溢出时, 使用带符号的无穷大表示, 如果某个操作结果没有明确的数学定义将会用NaN值表示. 所有使用NaN运算的算术操作都会返回NaN.

c. 类型转换指令

​ 将两种不同类型的数据类型相互转换. Java虚拟机直接支持(自动进行)下列类型转换:

​ a. int类型到 long, float 或者 double类型

​ b. long 类型到 float, double类型

​ c. float 类型到 double类型

​ 这种小范围类型向大范围类型的安全转化称为宽化类型转换

​ 窄化类型转化必须显式的使用转化指令. 可能会产生符号变化, 数量级变化. 很可能会导致数值的精度丢失.

d. 对象创建与访问指令

​ 虽然类的实例和数组都是对象, 但是Java使用了不同的字节码指令.

​ 创建类实例的指令: new

​ 创建数组的指令: newarray, anewarray, multianewarray

​ 访问类字段 (static修饰) 和实例字段 (非static字段) : getfield, getstatic, putstatic

​ 把一个数组元素加载到操作数栈的指令: baload, caload, saload, i/l/f/daload aaload

​ 把一个操作数栈的值存储到数组元素中的指令: b/c/s/i/f/d/aastore

​ 获取数组长度: arraylength

​ 检查对象的类型: instanceof, checkcast

e. 操作数栈管理指令

​ 如同操作一个普通数据结构的堆栈那样, Java虚拟机提供了一些用于直接操作操作数栈的指令.

​ a. 将操作数栈的栈顶一个或两个元素出栈: pop, pop2

​ b. 赋值栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶: dup, dup2, dup_x1, dup2_x1, dupx2, dup2_x2

​ c. 将栈最顶端的两个数值互换: swap

f. 控制转移指令

​ 让虚拟机跳到指定位置指令继续执行程序.

​ 条件分支: ifeq. iflt, ifle, ifgt, ifnull, ifnonnull等等

​ 符合条件分支: tableswitch, lookupswitch

​ 无条件分支: goto, goto_w, jsr, jsr_w, ret

g. 方法调回和返回指令

​ 方法调用(分派, 执行过程)

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

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

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

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

​ invokedynamic指令: 用于在进行动态解析出调用点限定符所引用的方法, 并执行该方法.

​ 方法调用指令与数据类型无关, 方法返回指令是按照返回值的类型区分的, 包括ireturn, lreturn, areturn等. 另外还有一条return指令供声明为void的方法, 实例初始化方法以及类和借口的类初始化方法使用(可能是<init>)

g. 异常处理指令

​ 显式的抛出异常(throws) 都由athrow指令来实现.

​ 其他抛出异常的情况有很多.

​ 用 catch 处理异常不是由字节码实现的, 而是用异常表.

h. 同步指令

​ 方法同步和同步代码块的同步都是用管程(Monitor)来支持的. 方法的同步不需要字节码指令来控制, 它实现在方法调用和返回操作之中. 虚拟机从方法常量池的方法表中的ACC_SYNCHRONIZED访问标志得知是否为同步方法. 如果是, 执行线程就要先持有管程.

​ 同步代码块是由 monitorenter(管程进入) 和 monitorexit(管程退出) 两条指令来支持的.

 

很多时候, 原理不代表实践. 有时候它们之间的差别很大

详解虚拟机的类加载机制

与那些在编译时需要进行连接的语言不同, 在Java语言里面, 类型的加载, 连接和初始化都是在程序运行期间完成的. 虽然会令类在加载的时候稍微增加一些性能开销, 但是会为Java应用程序提供高度的灵活性. Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的.

类的加载时机:

类从被加载到虚拟机内存中开始, 到卸载出内存为止, 它的整个生命周期包括:

加载  -->  验证  -->  准备  -->  解析  --> 初始化  --> 使用  -->  卸载

加载, 验证, 准备, 初始化和卸载的5个阶段的顺序 (指的是开始时间) 是确定的.

解析时不一定, 在某些情况下可以在初始化阶段之后再开始. 这是为了支持JAVA语言的动态绑定 (也称运行时绑定).

 

加载的开始时间:

遇到new, getstatic, putstatic 或invokestatic 这4条字节码指令时.

其场景分别是: 使用new关键字... , 读取或设置一个类的静态属性 (被final修饰或已在编译期把结果放入常量池的静态字段除外), 以及调用一个类的静态方法的时候.

使用java.lang.reflect包的方法对类进行反射调用的时候.

当初始化一个类时, 发现其弗雷还没有进行初始化, 需要先进行父类的初始化.

当虚拟机启动时, 用户需要一个执行的主类 (包含main方法的类), 虚拟机会先初始化这个主类.

使用JDK7的动态语言支持时, 如果一个 java.lang.invoke.MethodHandle实例最后的解析结果是 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄, 并且句柄对应的类没有初始化, 需要先触发这个类的初始化.

注意: 当一个类需要初始化时, 要求其父类全部都已经初始化过了, 但是一个接口在初始化时, 并不要求其父类全部初始化. 只有当真正使用到父接口的时候 (如引用接口中定义的常量) 才会初始化.

 

类加载的过程

加载:

加载是类加载一个过程的阶段.

在加载阶段, 虚拟机需要完成一下三个事情:

  1. 通过一个类的完全限定名 (在常量池中) 来获取定义此类的二进制字节流.
  2. 将这个字节流转化成保存在方法区中的运行时数据结构.
  3. 在内存中生成一个代表这个类的 java.lang.Class对象, 作为方法区的这个类的各种数据的访问入口.

 

从何处获取类的二进制字节流?

  1. jar包, war包, ear包
  2. 网络中获取
  3. 本地磁盘 (反序列化)
  4. 运行时计算机生成 (动态代理)
  5. 由其他文件生成 (JSP文件)
  6. 从数据库中读取 (有些中间件服务器)
  7. ...

一个非数组的类的加载阶段既可以用系统提供的类加载器实现, 也可以用用户自定义的类加载器去完成. 开发人员可以通过定义自己的类加载器取控制字节流的获取方式. (即重写一个classLoader()方法, 如加载配置文件)

数组类的加载有所不同. 它是由虚拟机直接创建的. 但是跟类加载器也有密切关系. 因为数组中的元素 (Element) 是由类加载器创建的. 一个数组类的创建过程遵循以下规则:

  1. 如果数组的组件(指去掉一个维度后)类型是引用类型, 那就递归去加载这个组件类型. 数组将在加载该组件类型的类加载器的类名称空间上被标识(一个类必须与类加载器一起确定唯一性).
  2. 如果数组的组件类型不是引用类型, Java虚拟机将会把数组标记为与引导类加载器关联.
  3. 数组类的可见性与它的组件类型的可见性一致, 如果组件类型不是引用类型, 那数组类的可见性将默认为public.

加载完成后, 二进制流就按照虚拟机的格式存储在方法区 (包括类型, 方法, 参数等. 方法区中的数据存储格式由虚拟机定义). 然后在内存中实例化一个 java.lang.Class 类的对象(并没有明确在堆中. 对HotSpot而言, Class对象比较特殊, 虽然是对象, 但是存储在方法区里). 这个对象将作为程序访问方法区中的这些类型数据的外部接口.

验证

验证是连接阶段的第一步, 这一阶段的目的是为了确保Class文件的字节流中包含的信息符合虚拟机的要求, 并且不会危害虚拟机的安全.

从整体上看, 验证大致分为4个阶段的验证动作: 文件格式验证, 元数据验证, 字节码验证, 符号引用验证.

  1. 文件格式验证:

1.是否以魔数0xCAFEBABE开头

2.主次版本号是否在处理范围之内

3.常量池的常量中是否有不被支持的常量类型 (检查常量的tag标志)

4.指向常量的索引值中是否有指向不存在的常量或不符合类型的常量

5.Class文件中的各部分及文件本身是否有被删除或附加的其他信息

...

该阶段的验证是基于字节流的, 其他三个阶段是基于方法区的存储结构进行的.

  1. 元数据验证:

对字节码的语义进行分析, 可能包括的验证点:

  1. 这个类是否有父类
  2. 这个类的父类是否实现了不允许继承的类 (被final修饰的类)
  3. 类中的方法, 字段是否与父类产生矛盾 (例如覆盖父类的final字段, 出现不符合规则的重载, 方法名与参数类型一致但返回值不同等)

...

  1. 字节码验证:

这是整个过程中最复杂的一部分, 主要目的是为了确定程序语义是合法的, 符合逻辑的.

  1. 保证任何时刻操作数栈的数据类型与指令能够配合工作
  2. 保证指令不会跳转到方法体以外的字节码指令上
  3. 保证方法中的类型转换是有效的.

...

虽然经过了检验, 但是靠程序去校验程序逻辑是没办法保证程序是一定安全的. JDK1.6之后做了一些优化, 见方法表中Code属性表中的StackMapTable属性.

  1. 符号引用验证:

这一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候 (从字面量到内存实例), 这个转化动作将在连接的第三阶段--解析时发生. 符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配校验. 通常需要校验一下内容:

  1. 符号引用中通过字符串描述的完全限定名是否能找到对应的类
  2. 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段 (方法签名是否能够匹配)
  3. 符号引用中的类, 字段, 方法是否可被当前类访问

符号引用验证的目的是为了保证解析能够正确运行. 如果已经反复验证/使用过, 可以禁用这个阶段以缩短虚拟机类加载的时间.

准备

准备阶段是为类变量正式分配内存并设置类变量 (static变量, 不包括实例变量) 初始值的阶段. 这些变量所使用的内存都在方法区中进行分配 (老年代). 实例变量将在对象实例化时随着对象一起分配在Java堆中. 初始值是零值(除final static常量以外), 当程序被编译后, 存放于类构造器<clinit>()方法中, 真正赋值的动作将在初始化阶段才会执行.

解析

解析阶段是将常量池中的符号引用替换为直接引用的过程 (类, 方法, 属性的字面量最终转化为内存中的对象的过程).
符号引用 (Sysmbolic References): 符号引用以一组符号来描述所引用的目标.

直接引用 (Direct References): 直接引用可以是直接指向对象的指针, 相对偏移量或是一个能间接定位到目标的句柄. 直接引用于内存相关(直接就在内存上).

 在执行anewarray, checkcast, getfield, getstatic等16个用于操作符号引用的字节码的指令之前, 先对它们引用的符号进行解析. 所以虚拟机可以根据需要判断在类加载时解析还是在等到使用时再解析这个符号变量.

虚拟机可以对第一次解析的结果进行缓存, 在运行时常量池中记录直接引用(指针, 并标识为已解析).

Invokedynamic指令是动态解析指令, 只有在运行时到它时才会解析. 其他指令都是静态解析.

解析主要针对类, 方法, 字段, 类方法, 接口方法, 方法类型, 方法句柄和调用点限定符7类符号引用. 后3种是动态的.

  1. 类或接口的解析
  1. 如果类不是一个数组, 虚拟机将把完全限定名传递给所属类的加载器去加载
  2. 如果是一个数组, 并且数组的元素类型为对象, 将会按照第一点去加载.
  3. 如果之前没有任何异常, 将进行符号引用验证(该验证总是在解析后进行)
  1. 字段解析

先在字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析, 也就是字段所属的类或接口的符号引用 (解析其所属的类或接口)

  1. 如果所属类或接口中完全匹配目标字段, 返回这个字段的直接引用
  2. 否则, 若实现了接口, 在接口中查找.
  3. 否则, 如果C不是java.lang.Ojbect, 将会按照继承关系从下至上递归搜索其父类.
  4. 否则, 查找失败

最后进行验证.

如果在多个接口中出现或接口和父类中都有出现, 虚拟机可能会拒绝编译..

  1. 类方法解析

同样, 先解析出所属的类或接口

  1. 如果发现其所属的是接口, 抛出异常 (类方法和接口方法符号引用的常量类型定义是分开的/类和接口的方法签名分别处理)
  2. 在所属类中查找
  3. 在父类中递归查找
  4. 在实现的接口以及父接口中递归查找
  5. 否则失败

最后权限验证.

  1. 接口方法解析

\同类方法

  1. \与类方法相反
  2. 在所属接口中查找
  3. 在父接口中递归查找, 直到Object
  4. 否则失败

因为接口默认是public的, 所以不需要权限验证.

初始化

初始化是类加载的最后一个阶段. 开始执行类中定义的程序代码.

初始化是执行类构造器<clinit>方法的过程.

<clinit>方法是由编译期自动收集类中所有类变量的赋值行为和静态语句块中的语句合并产生的. 收集的顺序是在源码中出现的顺序. 因此静态语句块中只能访问定义在静态语句块之前的静态变量. 定义在之前的变量可以赋值, 但是不能访问. 比如i可以赋值, 不可以输出.

  1. <clinit>()方法与类的构造函数(<init>)方法不同, 不需要显示的调用父类构造器, 父类一定会在子类之前执行<clinit>方法. 因此Object是第一个执行<clinit>的类
  2. 由此, 父类的static块先于子类的static块执行.
  3. <clinit>对接口不是必须的
  4. 接口中不需要先执行父接口的<clinit>方法. 只有使用父接口中定义的变量时, 父接口才会初始化
  5. 同步时, 只会有一个线程执行<clinit>方法. 其他执行该<clinit>的线程会被阻塞, 并且<clinit>只能执行一次

类加载器

通过类的完全限定名来获取描述类的二进制流这个动作在Java虚拟机外部实现. 实现这个动作的代码模块就是类加载器. (输入流读取文件并创建类/方法/属性)

类加载器只用于实现类的加载动作. 对于任意一个类, 都需要加载它的类加载器一起保证唯一性.

每一个类加载器都有一个独立的类名称空间: 比较两个类是否相等, 只有这两个类是同一个类加载器加载 (通过完全限定名找到对应的类加载器... )的前提下才有意义. /其实是同时比较类和类加载器的完全限定名...不同的类加载器有不同的完全限定名

双亲委派模型:

虚拟机有自己的C++实现的启动类加载器.

从开发人员的角度, 类加载器主要用到一下3种系统提供的类加载器:

  1. 启动类加载器.
  2. 扩展类加载器 (加载类库), 开发人员可以直接使用.
  3. 应用程序类加载器. 用于用户类路径 (ClassPath /根路径)上指定的类库, 开发人员可以直接使用.

双亲委派模型: 除了顶层的启动类加载器外, 其他的类加载器都应有自己的父类加载器. 一般不以继承实现, 都是使用组合 (见Java设计模式) 来复用加载器的代码.

工作过程: 如果类加载器收到加载请求, 首先会委派给父类加载器, 直到顶层的启动类加载器. 只有当父加载器反馈无法加载时, 子加载器才会自己加载.

Object类无论哪一个类加载器来加载, 最终都还是最顶段的启动类加载器加载, 由此保证各种类加载器环境中Object都是一个类. 先检查是否已经加载过, 没有则掉用父类加载器, 父类为空, 默认使用启动类加载器, 如果加载失败, 再自己加载.

 

模块化热部署OSGi的类加载器结构

 

虚拟机字节码执行引擎

 

什么是执行引擎:

 

执行引擎是Java虚拟机最核心的组件之一, 虚拟机相对于物理机都有执行代码的能力, 区别是物理机的执行引擎是直接基于处理器, 硬件, 指令集和操作系统层面上的. 虚拟机执行引擎是由自己实现的, 因此可以有自己的指令集与执行引擎的结构体系. 并且能够执行不被硬件直接支持的指令集格式.

Java虚拟机规范中规定了虚拟机字节码执行引擎的概念模型. 在不同的虚拟机里面, 执行引擎可能有解释执行和编译执行两种选择, 也可以两种兼备, 甚至可能包含几个不同级别的编译器执行引擎.

运行时栈帧结构

栈帧 (Stack Frame) 是用于支持虚拟机进行方法调用和方法执行的数据结构. 它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素. 栈帧存储了方法的局部变量表, 操作数栈, 动态连接, 方法返回地址和一些额外的附加信息.

在编译程序代码的时候, 栈帧中需要多大的局部变量表, 需要多深的操作数栈就已经确定了. 并且写入到方法表的Code属性之中. 因此, 一个栈帧需要分配多少内存不会受到程序运行期变量数据的影响, 而仅仅取决于具体的虚拟机实现.

一个线程中的方法调用链可能很长, 很多方法都可能同时处于执行状态, 对于执行引擎来说, 在活动线程中, 只有位于栈顶的栈帧才是有效的, 称为当前栈帧 (Current Stack Frame), 与这个栈帧相关联的方法称为当前方法 (Current Method), 执行引擎的所有字节码指令都只针对当前栈帧进行操作.

局部变量表

局部变量表(Local Variable) 是一组变量值存储空间. 用于存放方法参数和方法内部定义的局部变量. 在Java程序编译为Class 文件时, 就在方法表的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量.

局部变量表的容量单位以变量槽 (Variable Slot)为最小单位. 一个Slot可以存放一个32位以内的数据类型. Java中占用32位以内的数据类型有boolean, byte, short, char, int,float, reference和returnAddress 8中类型. reference类型表示对一个对象实例的引用. reference需要做到2点: a. 从此引用中直接或间接的查找到对象在Java堆中的数据存放的起始地址索引. b. 此引用中直接或间接的查找到对象所属数据类型在方法区中存储的类型信息. 第8中已经很少见了.

对于64位的数据类型, 虚拟机会以高位对齐的方式为其分配两个连续的Slot空间. Java语言中明确的64的数据类型只有double和long类型 (reference类型可能是32位也可能是64位). 这种分割存储的做法类似long和double的非原子性协定中吧一次long和double类型的读写分为两次32位的读写的做法类似. 不过由于局部变量表建立在线程的堆栈上(随线程而生随线程而灭), 是线程私有的数据, 无论读写两个连续的Slot是否是线程安全的, 都不会引发线程安全问题.

虚拟机通过索引定位的方式使用局部变量表. 索引值的范围是从0 到局部变量表的最大的Slot数量. 如果访问的是32位数据类型的变量, 索引n 就代表了使用第n个Slot. 64位就是第n和n+1个. 对于两个相邻的存放一个64位数据的2个Slot, 不允许以任何方式单独访问其中一个. 异常情况会在类加载的验证阶段被发现.

在方法执行阶段, 虚拟机使用局部变量表把参数值传递到参数列表. 如果执行的是实例方法(非static方法), 局部变量表的0位Slot指代this, 指向当前方法所属对象. 其余参数按照源码中的顺序排列. 参数分配完毕后, 再根据方法体内定义的变量顺序和作用于分配其余的Slot.

 局部变量表Slot的复用: 方法体中定义的局部变量作用范围不一定覆盖整个方法, 如果当前字节码PC计数器的值已经超过了某个变量的作用域, 那这个变量对应的Slot就可以交给其他变量使用. 一些额外的副作用: GC收集. 局部变量表作为GC Roots的根节点一部分, 仍旧维护着对之前内存的引用. 解决办法是在把不使用的对象手动赋值为null. 但多数情况都不是必须的. 另一个影响就是局部变量不存在准备阶段, 无法赋初值.

操作数栈 (Operand Stack) 也成为操作数栈,  (LIFO) 后入先出. 同局部变量表一样, 操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中. 操作数栈的每一个元素可以是任意的Java数据类型. 包括long 和double. 32位数据类型所占的栈容量为1, 64位所占的容量为2.

当一个方法开始执行的时候, 这个方法的操作数栈是空的, 在方法的执行过程中会有各种字节码指令网操作数栈中写入和提取内容. 也就是出栈/入栈操作. 调用其他方法的时候是通过操作数栈来进行参数传递的.

在概念模型中, 两个栈帧作为虚拟机栈的元素, 是完全相互独立的. 但在大多虚拟机的实现里都会做一些优化处理. 令两个栈帧出现一部分重叠. 让下面两个栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起. 这样在进行方法调用时就可以公用一部分数据, 无需进行额外的参数赋值传递.

动态连接

每个栈帧都有一个指向位于运行时常量池中该栈帧所属方法的引用. 持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking). Class文件常量池中存有大量的符号引用.  字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数. 这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用. 这部分称为动态连接.

方法返回地址

当一个方法开始执行后, 只有两种方式可以退出这个方法. 第一种方式是执行引擎遇到任意一个方法返回的字节码指令, 这时候可能会有返回值传递给上层的方法调用者. 这种方式称为正常完成出口.

另一种是方法执行过程中抛出了异常, 并且这个异常没有在方法体内得到处理. 无论是Java虚拟机内部产生的异常, 还是代码使用athrow字节码指令产生的异常, 只要在本方法的异常表中没有匹配到异常处理器, 就会导致方法退出.

方法退出之后, 都需要返回方法调用的位置, 以运行下一个方法. 正常退出的时候, 栈帧会保存一些信息. 一般来说, 调用者的PC计数器的值作为返回地址. 栈帧中很可能会保存这个地址. 而当方法异常退出时, 返回地址由异常表来处理, 栈帧中一般不会保存这部分信息.

方法退出的过程等于出栈, 因此可能做的行为是: 恢复上层方法的局部变量表和操作数栈, 把返回值压入调用者栈帧的操作数栈中, 调整PC计数器的值指向下一个运行的指令.

附加信息

虚拟机可以增加一部分如调试信息的内容  

 

方法调用

方法调用不等于方法执行. 方法调用的唯一目的是确定 被调用方法的版本(调用哪一个方法). 在程序运行时, 进行方法调用是最频繁, 最普遍的操作. Class文件的编译过程中不包含传统编译的连接步骤, 一切方法调用在Class文件里面存储的都只是符号引用, 而不是方法在实际内存中的直接引用. 这给Java带来了强大的动态扩展能力, 但是也使得方法调用更加复杂, 需要在类加载期间甚至方法运行期才知道实际的内存地址.

解析

所有方法的调用在Class文件中都是一个符号引用. 在类加载解析阶段, 会将其中一部分转化为直接引用, 前提是调用目标在程序代码写好, 编译期进行编译时就必须确定下来, 这类方法的调用称为解析.

Java中编译期可知, 运行期不可变的方法主要包括静态方法和私有方法两大类. 前者与类直接关联, 后者在外部不可直接访问. 因此它们都不能通过重写其他版本.

Java的5条调用字节码指令:

  1. Invokestatic: 调用静态方法
  2. Invokespecial: 调用实例构造器<init>方法, 私有方法和父类方法
  3. Invokevirtual: 调用所有虚方法
  4. Invokeinterface: 调用接口方法, 会在运行时再确定一个实现此接口的对象
  5. Invokedynamic: 先在运行时解析出调用点限定符所引用的方法, 然后在执行该方法.

前4个方法都是固化在java虚拟机内部的, 而invokedynamic方法是由用户所设定的引导方法决定的.

除去静态方法, 私有方法, 构造方法, 父类方法4种以外都成为虚方法(除去final). final无法更改版本, 无需选择多态.

解析调用完全是一个静态的过程, 在编译期就完全确定. 在类加载的解析阶段就会把涉及的符号引用转化为直接引用. 分派 (Dispatch) 调用可能是静态的也可能是动态的. 分局分配的宗量数可分为单分派和多分派. 两两组合就成了4种情况.

分派

分派调用的过程展现了Java多态的一些最基本的体现. 如重载和重写在java中是如何实现的.

  1. 静态分派  >  重载

Human  man  =  new  Man();

Human称为变量的静态类型 (Static Type), 或者外观类型 (Apparent Type).

Man称为变量的实际类型 (Actual Type)

静态类型和实际类型都可能会发生变化. 区别是静态类型只在使用时发生, 变量本身的静态类型无法改变, 最终的静态类型在编译期就确定了.

实际类型变化的结果在运行期才可以确定. 编译器在编译程序时并不知道一个对象的实际类型是什么.

实际类型发生变化:

Human man = new Man();  ===>  man = new Woman();   //实际类型  Man ==> Woman

静态类型发生变化:

sr.sayHello ( (Man ) man ) ===> sr.sayHello ( Woman) man); //静态类型  Man ==> Woman

 

编译器在重载时通过参数的静态类型而不是实际类型作为判断依据. 并且静态类型是编译器可知的. 因此, 编译阶段会根据变量的参数类型选择重载版本.

所有依赖静态类型定位方法执行版本的分派动作称为静态分派. 典型是方法重载. 静态分派发生在编译阶段, 因此实际不是由虚拟机执行的. 但重载版本并不是唯一的, 而可能是更合适的.

  1. 动态分派  >  重写

原因要从invokevirtual指令的多态查找过程开始:

Invokevirtual的解析过程大致分为以下步骤:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型, 记作C

Human man = new Man() ==> 实际类型是Man, 先从Man开始寻找.

  1. 如果在类C中找到与常量中的描述符 (public void )和方法名都相符的方法, 则进行权限校验, 如果通过, 则返回此方法的直接引用. 如果不通过, 则抛异常.
  2. 否则, 按照继承关系从下往上依次对C的各个父类进行上一步的搜索

未在Man中找到则找Human

  1. 仍未找到, 抛出异常

 

  1. 单分派与多分派

方法的接收者与方法的参数统称为方法的宗量, 根据分派基于多少种宗量划分为单分派与多分派.

Java的静态分派基于静态类型和方法参数, 因此是静态多分派.

动态分派时, 编译期已确定目标方法的签名, 只基于方法接收者的实际类型. 因此是动态多分派.

  1. 虚拟机动态分配的实现

 多数虚拟机会通过建立虚方法表, 使用虚方法表来代替元数据查询以提高性能. 虚方法表中存放着各个方法的引用. 如果没有被子类重写, 引用地址和父类中的方法地址是一致的. 如果子类重写, 子类方法表中的地址会替换成子类版本的引用.

具有相同签名的方法, 在父类子类的虚方法表中都有同样的索引, 这样, 当类型变换时, 只需要变更查找的方法表, 就能按索引找出正确的地址.

方法表一般在类加载的连接阶段进行初始化, 类的变量初始值后该类的方法表也初始化完毕.

除此之外还会采用内联缓存 (Inline Cache) 和基于类型继承关系分析计数的守护内联 两种非稳定的激进优化来获取更高的性能.

动态语言的支持

TODO

 

 

基于栈的字节码解释执行引擎

解释执行与编译执行

解释执行: 通过解释器执行

编译执行: 通过即时编译器产生本地代码执行

在执行前先对程序源码进行词法分析 和语法分析处理, 把源码转化为抽象语法树. 对于词法分析, 语法分析, 优化器 和目标代码生成器都可以独立于执行引擎, 形成一个完整的编译期实现(C/C++). 也可以选择其中一部分作为一个独立的编译期(Java语言), 又或者全部封装到一个黑匣子(JavaScript).

Java语言中, Javac编译器完成了程序代码经过词法分析, 语法分析, 生成抽象语法树, 再遍历生成线型的字节码指令流的过程. 这部分在java虚拟机之外进行, 而解释器又在虚拟机内部, 因此Java的编译就是半独立的实现.

基于栈的指令集与基于寄存器的指令集

基于栈的指令集: 可移植, 易实现, 来回出栈/入栈, 代码紧凑, 所需指令多, 步骤多, 内存访问频繁, 速度较慢

基于寄存器的指令集: 速度快, 受硬件约束

 

 

猜你喜欢

转载自blog.csdn.net/secret_breathe/article/details/80886851
今日推荐