Go To My HomePage

JVM详解

一、JDK&JRE&JVM之间的关系

JDK&JRE&JVM

  • JDK包含JREJVMJRE包含JVM
  • javac用于编译java代码到字节码文件
  • 使用java命令启动JVM,字节码最终运行在JVM
  • 【少】JavaFX是一种富客户端技术,用于替换flash和Swing程序
  • 【少】Java Web Start第一次运行需要借助浏览器,之后生成快捷方式当web站点更新是程序也更新,效果类似Swing

二、JVM虚拟机结构和厂商

JVM-struct

  • Sun Classic VM

    第一款商用的Java虚拟机,伴随着JDK1.0发布,只能使用纯解释器方式来执行java代码,只能外挂JIT编译器(just-in-time compilation)进行编译java代码,但是解释器和JIT编译器不能同时执行。存在较大性能问题。

  • Exact VM

    Exact VM全称为Exact Memory Management【准确式内存管理】,编译器和解释器可以混合工作以及两级及时编译器。但是只能在Solaris平台使用。JDK1.2时发布。

  • HotSpotVM

    从JDK1.3开始运用至今的HotSpot虚拟机(Oracle JDK和Open JDK)。加入热点代码探测技术。

  • KVM

    面对移动设备和嵌入式设备的kilobyte虚拟机,运行速度较慢、简单、轻量、高度可移植。

  • JRockit

    Bea公司(2008年被Oracle收购)开发的JRockit虚拟机,被称为当时世界上最快的Java虚拟机,专注于服务端应用。JDK7时部分功能被整合到HotSpot。不包含解释器实现,只有即时编译器。

  • J9

    IBM开发类似HotSpotVM虚拟机桌面、服务器、嵌入式端都支持。IBM内部使用。

  • Dalvik

    安卓操作系统所使用的虚拟机,并非是一个Java虚拟机,未遵循Java规范,基于寄存器架构【其它PC虚拟机基于栈架构】,不能执行.class文件。可以通过.class文件转化为.dex文件运行在dalvik上,开发的语法也为Java,也可使用Java API。

  • MicorsoftVM

    只能在windows平台下运行,最终因为法律问题被SUN公司禁止使用。

  • TaobaoVM

    根据HotSpotVM深度定制,在淘宝内部使用,对硬件依赖性高。

  • Azul VM和 Liquid VM

    Azul VM和BEA Liquid VM是一类运行在特定硬件平台的专有虚拟机,是”高性能”虚拟机。

    • Azul VM是Azul Systems公司在HotSpot基础上进行大量改进,运行于Azul Systems公司的专有硬件Vega系统上的Java虚拟机。每个Azul VM实例都可以管理至少数十个CPU和数百GB内存的硬件资源,并提供在巨大内存范围内实现可控的GC时间的垃圾收集器、为专有硬件优化的线程调度等优秀特性。

    • Liquid VM即是现在的JRockit VE(Virtual Edition),由Bea公司开发,可以直接运行在自家Hypervisor系统上的JRockit VM的虚拟化版本,Liquid VM不需要操作系统的支持,或者说它自己本身实现了一个专用操作系统的必要功能,如文件系统、网络支持等。由虚拟机越过通用操作系统直接控制硬件可以获得更好的性能,如在线程调度时,不需要再进行内核态/用户态的切换等,这样可以最大限度地发挥硬件的能力,提升程序的执行性能。

三、JVM内存管理

memory-struct

程序计数器

​ 程序计数器是一块比较小的内存空间,可以看作当前线程字节码所执行的行号指示器。属于线程独占区。如果线程执行的是java方法,则计数器的值是正在执行的字节码指令的地址。如果线程执行的是native方法,则计数器的值为undefined。


虚拟机栈

虚拟机栈描述的是Java方法执行的动态内存模型

  • 栈帧:每个方法执行都会创建一个栈帧,栈帧伴随着方法的创建到执行完成,用于存储局部变量表、操作数栈、动态链接、方法出口等
  • 局部变量表:用于保存方法的参数及局部变量,局部变量表内存空间大小在编译时期固定,在运行过程中不会改变局部变量表的大小
  • 操作数栈:主要保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间

本地方法栈

​ 本地方法栈为虚拟机执行Native服务,结构和虚拟机栈完全相同。HotSpotVM将虚拟机栈和本地方法栈放在一起管理不做区分。


Java堆

​ 存放对象的实例、垃圾收集器管理的主要区域。


方法区

​ 属于线程共享区,存储了虚拟机加载的类信息【版本、字段、方法、接口】、运行时常量池【字面量和符号引用】、静态变量、即时编译器编译后的代码等数据。在HotSpot中方法区是使用永久代实现的,所以永久代等于方法区。这里很少进行垃圾回收。

从Java8开始HotSpots使用元空间取代了永久代永久代物理是是堆的一部分而空间属于本地内存。元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中。


直接内存【并非JVM规范定义的区域,不属于虚拟机运行时内存的一部分】

​ JDK1.4为了弥补IO缺陷引入NIO,运行直接在堆外分配内存,不受JVM制约,由操作系统分配。

四、Java的对象

对象创建过程

obejct-create-proceess

对象内存分配方式

指针碰撞

​ 假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为指针碰撞

空闲列表

​ 如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表

具体是使用空闲列表还是指针碰撞需要根据内存状况决定

多线程内存分配管理

​ 不管使用哪种对象内存分配方式,在多线程环境时,如果一个线程正在给A对象分配内存,指针还没有来的及修改,其它为B对象分配内存的线程,而且还是引用这之前的指针指向,这样会带来分配问题。于是有两种处理方式。

分配时加锁

​ 堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的。

TLAB【线程本地分配缓存区】

​ 为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配。

​ JVM在内存新生代Eden Space中开辟了一小块线程私有的区域称作TLAB。默认设定为占用Eden Space的1%。

对象的结构

object-struct

对象头

  • _mark:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,称之为“MarkWord”
  • _klass:指向类元数据的指针
  • _length:数组长度(只有数组对象有)

实例数据

​ 存储在程序代码中所定义的各种类型的字段内容

对齐填充

​ 帮助对象凑满8字节的倍数,不一定存在

对象访问方式

句柄访问

​ 使用句柄访问。Java堆中将会划分出一块内存来作为句柄池,reference中存储的是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。

handler-access

直接指针访问

​ 使用直接指针访问。reference中存储的就是对象的地址。

point-access

HotSpot是用直接指针访问方式进行对象访问的。

五、垃圾回收

鉴定垃圾对象

determine-survival

引用计数法

​ 在对象中添加一个引用计数器,当有地方释放这个引用时,对象上存储的引用计数减一,但是当出现互相引用时【对象实例3和对象实例5】引用计数依旧不为0,无法对其进行垃圾回收

可达性分析

​ 该算法的核心算法是从GC Roots对象作为起始点,如果对象不可达到GC Root则认为此对象是要回收的对象。可作为GC Roots的对象

  • 虚拟机栈的局部变量所引用的对象
  • 本地方法栈的JNI所引用的对象
  • 方法区的静态变量和常量所引用的对象

回收策略

标记-清除算法

Mark-Sweep

​ 算法分为标记清除两个阶段:先标记出所有需要回收的对象,完成后统一回收掉所有被标记的对象。

​ 缺陷:①标记和清除过程的效率不高;②极易造成空间碎片问题

复制收集算法

​ 复制收集算法将可用内存按容量划分为大小相等的两块,survivor区每次只使用其中的一块。当这一块的内存用完了,就将edensurvivor还存活着的对象复制到另外一块survivor上面,然后再把已使用过的内存空间一次清理掉。 这样内存分配时也就不用考虑内存碎片等复杂情况,实现简单运行高效。长期存活的对象移入oldGen区这里面的对象很少执行垃圾回收。

标记-整理算法

Mark-Compact

​ 当预估能回收的对象并不多时(例如老年代)采用复制收集算法就要执行较多的复制操作,效率将会变低且还浪费了大量空间,所以此时进行复制收集算法不明智。采用标记-整理算法较为合适,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。且下次在此空间继续分配内存可以使用指针碰撞法提高速度。

分代收集算法

​ 把Java堆分为新生代和老年代,根据各个年代的特点采用最适当的收集算法。新生代每次垃圾收集时都有大量对象需要回收那就选用复制算法。而老年代中因为对象存活率高就使用标记-清理标记-整理算法进行回收。

GC分类

Minor GC:清理年轻代,不会影响到永久代

Major GC :清理老年代,Major GC大部分由Minor GC触发

Full GC :清理整个堆空间—包括年轻代、老年代和永久代

垃圾回收器

针对新生代的垃圾回收器

Serial收集器【复制收集算法】

  • JDK1.3之前回收新生代内存的唯一选择
  • 单线程的垃圾收集器,单CPU环境下效果较好

serial

ParNew【复制收集算法】

  • Serial收集器的多线程版本
  • ParNew收集器是许多运行多CPU模式下的虚拟机中首选的新生代收集器
  • 它是除了Serial收集器外,唯一一个能与CMS收集器配合工作的

ParNew

Parallel Scavenge【复制收集算法】

  • 达到一个可控制的吞吐量有GC自适应调节策略

Paraller-Scavenge

针对老年代的垃圾回收器

Serial Old 收集器【标记-整理算法】

  • 单线程垃圾收集器

Serial-Old

Parallel Old 收集器【标记-整理算法】

  • Parallel Scavenge的老年版本

Parallel-Old

cms 收集器【标记-清除算法】

  • 一种以获取最短回收停顿时间为目标的收集器

cms

[1]初始标记:标记GC Roots能直接到的对象。速度很快但是仍存在Stop The World问题。

[2]并发标记:进行GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行。

[3]重新标记:为了修正并发标记期间因用户程序继续运行发生改变的对象的记录。仍然存在Stop The World。

[4]并发清除:对标记的对象进行清除回收。

特殊收集器

G1收集器

G1

​ G1垃圾收集器并没有将内存按照连续内存地址分为新生代、老年代。而是分成了一个个的区域【Region】,采用了分代与分区算法。这些区域大小不固定,根据回收的情况进行评估,到达阀值视为老年代。在进行一次垃圾回收之后,会进行日志收集确定分代划分和是否进行混合清理(新生代和老年代一起清理)。

  • 并行性:G1回收期间可以多线程同时工作
  • 并发性:G1拥有与应用程序交替执行的能力,部分工作可与应用程序同时执行,在整个GC期间不会完全阻塞应用程序
  • 分代GC:依旧分为新生代和老年代,新生代依然有eden,from和to
  • 空间整理:G1在垃圾回收过程中,不会像CMS那样在若干次GC后需要进行碎片整理,G1采用了有效复制对象方式
  • 可预见性:由于分区的原因,G1可以只选取部分区域进行回收,缩小了回收的范围,提高性能

G1的内存结构如下所示:

G1-struct

JDK11之后G1触发FULL GC时可并行处理,以前只能串行处理

各代HotSpot(Server模式下)默认垃圾收集器

  • jdk1.7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
  • jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
  • jdk1.9 默认垃圾收集器G1

六、内存分配

分配策略

  • 对象优先分配到Eden区

  • 大对象直接分配到老年代

  • 长期存活对象分配到老年代

  • 空间分配担保

    存入对象时发现年轻代空间不足,将判断老年代最大可用的连续空间是否大于当前年起代所有对象

    • 满足,minor gc是安全的,可以进行minor gc
    • 不满足,虚拟机查看HandlePromotionFailure参数
      • true会继续检测[老年代最大可用的连续空间]是否大于[历次晋升到老年代对象的平均大小]。若大于,将尝试进行一次minor gc,若失败,则重新进行一次full gc
      • false则不允许冒险,要进行full gc

栈上分配和逃逸分析

在JDK7(包括)之后完全支持栈上分配和逃逸分析。

逃逸分析

​ 如果某个方法之内创建的实例只在方法内被使用,方法结束之后没有任何对象引用它【即可被GC回收】,这样的对象叫做未发生逃逸对象。如果某个方法之内创建的实例在方法结束之后有对象引用它【即不可被GC回收】,这样的对象叫做逃逸对象。

栈上分配

​ 如果一个对象未发生逃逸则这个对象的生命周期只存在一个方法体内,这样的对象可以直接在栈上分配提高效率,也方便回收【方法结束,栈销毁,未发生逃逸也就随着销毁】

七、虚拟机工具

JPS

显示当前所有java进程pid相关信息

参数 解释
-q 只显示pid,不显示class名称,jar文件名和传递给main方法的参数
-l 输出应用程序main class的完整package名或者应用程序的jar文件完整路径名
-m 输出传递给main方法的参数
-v 输出传递给JVM的参数
-V 隐藏输出传递给JVM的参数

jstat

对Java应用程序的资源和性能进行监控,包括了对类的装载、内存、jit编译和垃圾回收状况的监控。

用法: jstat [option] [pid] [ []]

  • interval - 间隔的时间
  • count - 统计次数
参数 解释
-t 打印时打印时间戳
-class 显示ClassLoad的相关信息
-compiler 显示JIT编译的相关信息
-gc 显示和gc相关的堆信息
-gccapacity 显示各个代的容量以及使用情况
-gcmetacapacity 显示metaspace的大小
-gcnew 显示年轻代信息
-gcnewcapacity 显示年轻代大小和使用情况
-gcold 显示老年代和永久代的信息
-gcoldcapacity 显示老年代的大小
-gcutil 显示垃圾收集信息
-gccause 显示垃圾收集信息并显示最后一次垃圾回收的诱因
-printcompilation 输出JIT编译的方法信息

jinfo

用于实时查看和调整虚拟机参数

用法:jinfo [option] [pid]

参数 解释
-flag [name] 输出对应名称的参数
-flag [[+/-]name] 开启或者关闭对应名称的参数
-flag [name=value] 设定对应名称的参数
-flags 输出全部的参数
-sysprops 输出系统属性

jmap

用于生成java程序的dump文件, 以及查看堆内对象的统计信息、ClassLoader的信息以及finalizer队列

用法:jmap [option] [pid]

参数 解释
-histo[:live] 显示堆中对象的统计信息
-clstats 打印类加载器信息
-finalizerinfo 显示在F-Queue队列等待Finalizer线程执行finalizer方法的对象
-dump:live,format=b,file=heap.bin 内存转储dump数据,过程中会暂停应用

jhat

分析内存dump文件,会占用大量CPU和内存生成报告,默认最后会启动一个web服务端口7000,可访问查看相应指标信息,但是很少使用,不够直观灵活。


jstack

查看生成线程状态信息

用法:jstack [option] [pid]

参数 解释
-l 长列表. 打印关于锁的附加信息

jconsole

提供了内存、线程、线程死锁检测、类、VM概要等GUI查看功能

jconsole


VisualVM

JDK8之后被移除默认的JDK安装包,需要单独下载,官方地址

八、Class文件

概述

​ Class文件是一组以8位字节为基础单位的二进制流,在当遇到8位字节以上数据项时,则按照高位在前的方式分割成若干个8位以上字节进行分别存储。各个数据项目按照严格顺序紧凑的排列在Class文件之中,中间没有添加任何分隔符,整个Class文件中存储的内容几乎全部都是程序运行的必要数据,没有空隙存在。

​ Class文件中只有两种数据类型,分别是无符号数和表

Class文件结构

顺序依次为:

①魔数[4字节]

②Class文件版本[4字节]

③常量池[长度不固定]

④访问标志[2字节]

⑤类索引[2字节]、父类索引[2字节]、接口索引集合[长度不固定]

⑥字段表集合[长度不固定]

⑦方法表集合[长度不固定]

⑧属性表集合[长度不固定]

长度 含义 数量
u4 magic 1
u2 minor_version 1
u2 major_version 1
u2 constant_pool_count 1
cp_info constant_pool constant_pool_count - 1
u2 access_flags 1
u2 this_class 1
u2 super_class 1
u2 interfaces_count 1
u2 interfaces interfaces_count
u2 fields_count 1
field_info fields fields_count
u2 methods_count 1
method_info methods methods_count
u2 attribute_count 1
attribute_info attributes attributes_count

魔数

用于区分是否是Class文件,占四个字节,十六进制表示值为CA FE BA BE


Class文件版本

占4字节为字节码版本,为小版本号minor_version[2字节]和主版本号major_version[2字节]的组合


常量池

长度不固定,先用两个字节描述长度,真实长度为描述长度减一【0位置代表无引用,它也占一位】

常量池的组成:

  • 常量池计数器(constant_pool_count):占用前两个字节,记录着常量池的组成元素个数

  • 常量池项(cp_info):元素信息,一共constant_pool_count-1个数量

    cp_info结构如下:

    cp_info {
      ul tag;
      ul info[];
    }
    

常量池数量是从1开始计数的并非从0开始,所有元素真实个数需要减一,将第0项常量空出来是为了满足执向常量池的索引数据在特定情况下表达“不引用任何一个常量池项”的含义

cp_info的类型:

cp-info

常量类型
CONSTANT_CLASS 7
CONSTANT_Fieldref 9
CONSTANT_Methodref 10
CONSTANT_InterfaceMethodref 11
CONSTANT_String 8
CONSTANT_Integer 3
CONSTANT_Float 4
CONSTANT_Long 5
CONSTANT_Double 6
CONSTANT_NameAndType 12
CONSTANT_Utf8 1
CONSTANT_MethodHandle 15
CONSTANT_MethodType 16
CONSTANT_InvokeDynamic 18

可使用javap -verbose [name].class查看常量池内容


访问标志

access_flages占有两个字节,没有使用到的标志为要求一律为0

标志名称 标志值 含义
ACC_PUBLIC 0x00 01 是否为public类型
ACC_FINAL 0x00 10 是否被声明为final,只有类可以设置
ACC_SUPER 0x00 20 是否允许使用invoke special字节码指令的新语义
ACC_INTERFACE 0x02 00 标志这是一个接口
ACC_ABSTRACT 0x04 00 是否为abstract类型,对于接口或者抽象类来说标志值为真
ACC_SYNTHETIC 0x10 00 标志这个类并非由用户代码产生
ACC_ANNOTATION 0x20 00 标志这是一个注解
ACC_ENUM 0x40 00 标志这是一个枚举

可使用javap -verbose [name].class查看访问标志


类索引、父类索引、接口索引集合

类索引:占用两个字节,指向常量池中的引用

父类索引:占用两个字节,指向常量池中的引用

接口索引集合:统计个数interfaces_count占用两个字节,interfaces占用interfaces_count*2个字节,每个interfaces指向常量池中的引用


字段表集合

字段表【field_info】用于描述类中声明的变量,但是不包括在方法内部声明的局部变量

字段表的结构:

长度 名称 数量
u2 access_flags 1
u2 name_index 1
u2 descriptor_index 1
u2 attributes_count 1
attribute_info attributes attributes_count

access_flags访问标志值:

标志名称 标志值 含义
ACC_PUBLIC 0x00 01 字段是否为public
ACC_PRIVATE 0x00 02 字段是否为private
ACC_PROTECTED 0x00 04 字段是否为protected
ACC_STATIC 0x00 08 字段是否为static
ACC_FINAL 0x00 10 字段是否为final
ACC_VOLATILE 0x00 40 字段是否为volatile
ACC_TRANSTENT 0x00 80 字段是否为transient
ACC_SYNCHETIC 0x10 00 字段是否为由编译器自动产生
ACC_ENUM 0x40 00 字段是否为enum

属性表集合

在class文件,字段表,方法表都可以携带自己的属性表集合用于描述某些场景专有的信息

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

字节码指令

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

​ 部分指令有类型区分:【i:integer】、【l:long】、【f:float】、【d:double】、【a:reference】

加载与存储指令

  • 将一个局部变量加载到操作数栈:[i/l/f/d/a]load
  • 将一个数值从操作数栈存储到局部变量表:[i/l/f/d/a]store
  • 将一个常量加载到操作数栈:
    • bipush 将取值为【-128~127】的常量入栈
    • sipush 将取值为【-32768~32767】的将常量入栈
    • ldc 将取值为【2147483648~2147483647】的将常量入栈
    • ldc_w 从由常量池中取出指的索引位的一个字长的值,然后将其压入栈
    • ldc2_w 从由常量池中取出指的索引位的两个字长的值,然后将其压入栈
    • aconst_null 将null对象引用压入栈
    • iconst_m1 将int类型且值为【-1】的压入栈
    • iconst_value 将int类型且值为【0、1、2、3、4、5】压入栈
    • lconst_value 将long类型且值为【0、1】压入栈
    • fconst_value 将float类型且值为【0、1、2】压入栈
    • dconst_value 将double类型且值为【0、1】压入栈
  • 扩充局部变量表的访问索引指令:wide

运算指令

  • 加法指令:[i/l/f/d]add
  • 减法指令:[i/l/f/d]sub
  • 乘法指令:[i/l/f/d]mul
  • 除法指令:[i/l/f/d]div
  • 取余指令:[i/l/f/d]rem
  • 取反指令:neg
  • 位移指令:ishl(逻辑左移)、ishr(逻辑右移)、iushr(算术右移)【其余类型不常见】
  • 按位或指令:ior【其余类型不常见】
  • 按位与指令:iand【其余类型不常见】
  • 按位异或指令:ixor【其余类型不常见】
  • 局部变量自增指令:iinc【其余类型执行压栈常量1再进行相加操作】

类型转换指令

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

分类:宽化类型处理和窄化类型处理

  • 宽化类型转换:i2li2fi2dl2fl2df2d
  • 窄化类型转换:l2if2id2if2ld2ld2f

对象创建与访问指令

  • 创建类的指令:new
  • 创建数组的指令:newarrayanewarraymultianewarray
  • 访问类的字段指令:getfieldputfieldgetstaticputstatic
  • 把数组元素加载到操作数栈:[b/c/s/i/l/f/d/a]aload
  • 将操作数栈的元素存储到数组:[b/c/s/i/l/f/d/a]astore
  • 取数组长度的指令:arraylength
  • 检查实例类型指令:instanceofcheckcast

操作数栈管理指令

  • 一个元素和两个元素出栈指令:poppop2
  • 复制栈顶一个或两个数值并将复制或双份复制值重新压入栈顶:dupdup2dup_x1dup_x2
  • 将栈顶的两个数值替换:swap

控制转移指令

​ 控制转移指令可以让JVM有条件或无条件地从指定的位置执行而不是继续下一条指令执行程序,可以认为控制转移指令就是在有条件或无条件地修改PC寄存器的值。

  • 条件分支:ifeqifltifleifneifgtifgeifnullifnonnullif_icmpeqif_icmpneif_icmpltif_icmpgtif_icmpleif_icmpgeif_acmpeqif_acmpne
  • 复合条件分支:tableswitchlookupswitch
  • 无条件分支:gotogoto_wjsrjsr_wret

方法调用指令

指令 解释
invokevirtual 指令用于调用对象的实例方法即非私有的实例方法,根据对象实际类型进行分派(虚方法分派)
invokeinterface 指令用于调用对象的接口方法,会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用
invokespecial 指令用于调用一些需要特殊处理的实例方法,包括初始化方法、私有方法和父类方法
invokestatic 指令用于调用类的static方法
invokedynamic JDK7之后支持,调用动态方法,在运行时动态解析出调用点限定符所引用的方法之后,调用该方法

方法返回指令

​ 返回指令是根据返回的数据类型进行区分的,有ireturn(返回值是boolean、byte、char、short和int)、lretrunfreturndreturnareturn还有返回voide、实例初始化、类和接口的初始化使用的return


异常处理指令

athrow用于显示抛出异常时(明确throw new RuntimeException和Exception均会),但是对于”i/0“这种未显示表明的抛出是不会使用athrow的。


同步指令

​ Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。

​ 方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的管程将在异常抛到同步方法之外时自动释放。

​ 同步一段指令集通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitorentermonitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持。

九、类加载机制

类加载过程

​ 一个Java对象的创建过程往往包括两个阶段:类初始化阶段类实例化阶段

​ 类的加载:代表jvm将java文件编译成class文件后,以二进制流的方式存放到运行时数据的方法区中,并在java的堆中创建一个java.lang.Class对象,用来指向存放在方法堆中的数据结构。且虚拟机加载Class文件是懒加载机制。

类的加载

  • 通过一个类的全限定名来获取定义此类的二进制字节流
    • 文件方式【class文件、jar文件】
    • 网络
    • 程序生成【动态代理】
    • 其它【jsp-会转换为servlet,数据库】
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的Class对象,作为这个类的各种数据的访问入口

类的验证

​ 验证是连接的第一步,但是并非是必须的。这一阶段的目的是为了确保Class文件的字流中包含的狺息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全包含:

  • 文件格式验证
    • 是否以魔数0xCAFFEBABE开头
    • 主次版本号是否在虚拟机处理范围内
    • 常量池中的常量是否有不被支持的常量类型
    • 指向常量的各种索引值是否有指向不存在的常量
  • 元数据验证
    • 这个类是否有父类
    • 这个类的父类是否继承不被允许的类(final修饰的类)
    • 如果这个类不是抽象类,是否实现了接口要求实现的方法
    • 类中的字段,方法是否与父类矛盾(例如出现不符合规则的方法重载)
  • 字节码验证
    • 保证任何时刻操作数栈的数据类型与指令代码序列能配合工作
    • 保证跳转指令不会跳转到方法体以外的字节码指令
    • 保证方法体中类型转换有效,如避免出现将父类对象赋值到子类数据类型上
  • 符号引用验证
    • 符号引用中通过字符串描述的全限定名是否能找到对应的类
    • 在指定类中是否存在符合方法的字段描述符号
    • 符号引用的类,字段,方法的访问性是否可以被当前类访问

类的准备

​ 准备阶段正式为类的静态变量分配内存并设置变量的初始值。这些变量使用的内存都将在方法区中进行分配。设置的初始值并非我们指定的值而是这种变量的默认值,但是如果是被final修饰的常量则会被初始为我们指定的值

类的解析

解析阶段是将常量池中的符号引用替换为直接引用的过程。在进行解析之前需要对符号引用进行解析,不同虚拟机实现可以根据需要判断到底是在类被加载器加载的时候对常量池的符号引用进行解析(也就是初始化之前),还是等到一个符号引用被使用之前进行解析(也就是在初始化之后)。如果一个符号引用进行多次解析请求,虚拟机中除了invokedynamic指令外,虚拟机可以对第一次解析的结果进行缓存(在运行时常量池中记录引用,并把常量标识为一解析状态),这样就避免了一个符号引用的多次解析。

类或者接口解析

要把一个类或者接口的符号引用解析为直接引用,需要以下三个步骤

  • 如果该符号引用不是一个数组类型,那么虚拟机将会把该符号代表的全限定名称传递给类加载器去加载这个类。这个过程由于涉及验证过程所以可能会触发其他相关类的加载

  • 如果该符号引用是一个数组类型,并且该数组的元素类型是对象。我们知道符号引用是存在方法区的常量池中的,该符号引用的描述符会类似”[java/lang/Integer”的形式,将会按照上面的规则进行加载数组元素类型,如果描述符如前面假设的形式,需要加载的元素类型就是java.lang.Integer ,接着由虚拟机将会生成一个代表此数组对象的直接引用

  • 如果上面的步骤都没有出现异常,那么该符号引用已经在虚拟机中产生了一个直接引用,但是在解析完成之前需要对符号引用进行验证,主要是确认当前调用这个符号引用的类是否具有访问权限,如果没有访问权限将抛出java.lang.IllegalAccess异常

字段解析

对字段的解析需要首先对其所属的类进行解析,因为字段是属于类的,只有在正确解析得到其类的正确的直接引用才能继续对字段的解析。对字段的解析主要包括以下几个步骤:

  1. 如果该字段符号引用就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,解析结束
  2. 否则,如果在该符号的类实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果在接口中包含了简单名称和字段描述符都与目标相匹配的字段,那么久直接返回这个字段的直接引用,解析结束
  3. 否则,如果该符号所在的类不是Object类的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都相匹配的字段,那么直接返回这个字段的直接引用,解析结束
  4. 否则,解析失败,抛出java.lang.NoSuchFieldError异常

如果最终返回了这个字段的直接引用,就进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常

类方法解析

进行类方法的解析仍然需要先解析此类方法的类,在正确解析之后需要进行如下的步骤:

  1. 类方法和接口方法的符号引用是分开的,所以如果在类方法表中发现class_index(类中方法的符号引用)的索引是一个接口,那么会抛出java.lang.IncompatibleClassChangeError的异常
  2. 如果class_index的索引确实是一个类,那么在该类中查找是否有简单名称和描述符都与目标字段相匹配的方法,如果有的话就返回这个方法的直接引用,查找结束
  3. 否则,在该类的父类中递归查找是否具有简单名称和描述符都与目标字段相匹配的字段,如果有,则直接返回这个字段的直接引用,查找结束
  4. 否则,在这个类的接口以及它的父接口中递归查找,如果找到的话就说明这个方法是一个抽象类,查找结束,返回java.lang.AbstractMethodError异常
  5. 否则,查找失败,抛出java.lang.NoSuchMethodError异常

如果最终返回了直接引用,还需要对该符号引用进行权限验证,如果没有访问权限,就抛出java.lang.IllegalAccessError异常

接口方法解析

同类方法解析一样,也需要先解析出该方法的类或者接口的符号引用,如果解析成功,就进行下面的解析工作:

  1. 如果在接口方法表中发现class_index的索引是一个类而不是一个接口,那么也会抛出java.lang.IncompatibleClassChangeError的异常

  2. 否则,在该接口方法的所属的接口中查找是否具有简单名称和描述符都与目标字段相匹配的方法,如果有的话就直接返回这个方法的直接引用。

  3. 否则,在该接口以及其父接口中查找,直到Object类,如果找到则直接返回这个方法的直接引用

  4. 否则,查找失败

类的初始化

初始化时机

对于初始化阶段,虚拟机规范严格规定了有且只有5种情况必须对类进行初始化(加载、验证、准备自然需在此之前开始):

①遇到newgetstaticputstaticinvokestatic这四条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这四条指令的最常见的java代码场景是:

  • 使用new关键字实例化对象的时候

  • 读取或设置一个类的静态字段【被final修饰己在编译器把结果放入常量池的静态字段除外】

  • 调用一个类的静态方法的时候

②使用java.lang.reflect包的方法对类进行反射调用的时,如果类没有进行过初始化则需要先触发其初始化

③当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化

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

⑤当使用JDK7动态语言支持时,如果MethodHandle实例最后的解析结果REF_getstaticREF_putstaticREF_invokestatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化

不执行初始化的例子

  • 调用类常量【public static final】
  • 通过数组定义来引用类【Base[] car = new Base[5]】
  • 通过子类引用父类的静态字段【Base.ParentVariable】时,子类不会被初始化

初始化过程

​ 类初始化时类加载过程的最后一步,前面的类加载过程中,除了在加载阶段(类加载过程的一个阶段)应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的java程序代码。初始化阶段是执行类构造器<clinit>()方法的过程。

<clinit>()方法执行过程中可能会影响程序运行行为:

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但不能访问,属于非法向前引用

    public class Test {
        static {
            v = 3;
            System.out.println(v);		//编译报错,只能赋值不能访问
        }
        static int v = 1;
    }
    // 顺序执行最后v的值为"1"
    
  • <clinit>()方法与类的构造函数(即类的实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法一定是java.lang.Object

  • <clinit>()方法对于类(抽象类)或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法

  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法

  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁,同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。在实际应用中这种阻塞引起的问问往往是很隐蔽

十、类加载器

类加载器的种类

启动(Bootstrap)类加载器

​ 启动类加载器主要加载的是JVM自身需要的类,这个类加载使用C++语言实现的,是虚拟机自身的一部分,它负责将 /lib路径下的核心类库或-Xbootclasspath参数指定的路径下的jar包加载到内存中。它本身是虚拟机的一部分,所以它并不是一个JAVA类,也就是无法在java代码中获取它的引用。即Extension ClassLoader的代码中的parent为null

注意必由于虚拟机是按照文件名识别加载jar包的,如rt.jar,如果文件名不被虚拟机识别,即使把jar包丢到lib目录下也是没有作用的(出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类)

扩展(Extension)类加载器

​ 扩展类加载器是指Sun公司实现的sun.misc.Launcher$ExtClassLoader类,由Java语言实现的,是Launcher的静态内部类,它负责加载/lib/ext目录下或者由系统变量-Djava.ext.dir指定位路径中的类库,开发者可以直接使用标准扩展类加载器。

系统(System)类加载器

​ 也称应用程序加载器是指 Sun公司实现的sun.misc.Launcher$AppClassLoader。它负责加载系统类路径java -classpath-D java.class.path 指定路径下的类库,开发者可以直接使用系统类加载器,一般情况下该类加载是程序中默认的类加载器,通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器。

自定义(custom)类加载器

​ 自定义类加载器由第三方实现,建议满足双亲委派模式。

只有被同一个类加载器加载的类才会相等,相同的字节码被不同的类加载器加载的类不相同。

双亲委派模式

​ 双亲委派模式要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器【并非继承】采用组合关系来复用父类加载器的相关代码,类加载器间的关系如下:

Parents-Delegate

十一、虚拟机字节码执行引擎

运行时栈帧结构

栈:线程私有,生命周期跟线程相同,当创建一个线程时,同时会创建一个栈,栈的大小和深度都是固定的。

栈帧:一个栈中可以有多个栈帧,栈帧是栈的元素,栈帧随着方法的调用而创建,随着方法的结束而消亡。栈帧存储了方法的局部变量表操作数栈动态连接方法返回地址等信息。在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了。因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

局部变量表

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


操作数栈

​ 操作数栈和局部变量表在访问方式上存在着较大差异,操作数栈并非采用访问索引的方式来进行数据访问的,而是通过标准的入栈和出栈操作来完成一次数据访问。每一个操作数栈都会拥有一个明确的栈深度用于存储数值(max_stack)。


动态连接

​ 在类加载解析时无法确定的符号引用(应该转为直接引用)在运行时进行动态确定转换为直接引用的过程叫做动态连接,包括动态分派动态语言支持


方法返回地址

​ 返回调用完成都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。


附加信息

虚机规范中允许具的虚拟机实现增加一些规范里没有描述的到栈帧中。这部信息完全取决于虚拟机的实现。

方法调用

方法调用并不等同于方法的执行,方法调用阶段的唯一任务就是确定被调用方法的版本

解析调用

​ 在类加载的类解析阶段就已经调用方法的版本即在编译期已经确定了调用的方法,这种调用方式称之为解析调用。

​ 在Java中被final、private、static修饰的方法以及构造方法都是属于这种类型,在编译期间就可确定,对应编译出的字节码指令为invokestatic【调用静态方法】invokespecial:调用非静态私有方法和final方法、构造方法(包括super)


静态分派调用

public class StaticDispatch {
  static class Parent{
  }
  static class Child extends Parent{
  }
  public static void sayHello(Parent p){
    System.out.println("hello,parent");
  }
  public static void sayHello(Child c) {
    System.out.println("hello,child");
  }

  public static void main(String[] args) {
    //      静态类型      实际类型
    Parent  parent = new Parent();
    Parent  child  = new Child();
    sayHello(parent);    //hello,parent
    sayHello(child);     //hello,parent
  }
}

​ 我们把“Parent”称为变量的静态类型,后面的“Child”称为变量的实际类型,静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型在编译阶段可知依旧是invokespecial调用。而实际类型变化的结果在运行期才确定,编译器在编译期并不知道一个对象的实际类型是什么。

​ 静态分派的典型应用是方法重载。编译器在重载时是通过参数的静态类型而不是实际类型作为判定的依据。并且静态类型在编译期解析阶段可知,因此,Javac编译器会根据参数的静态类型决定使用哪个重载版本。


动态分派调用

public class DynamicDispatch {
  static class Parent{
    public  void sayHello(){
      System.out.println("hello,i am parent");
    }
  }
  static class Child extends Parent{
    @Override
    public void sayHello(){
      System.out.println("hello,i am child");
    }
  }
  public static void main(String[] args) {
    Parent  parent = new Parent();
    Parent  child  = new Child();
    parent.sayHello();  // hello,i am parent
    child.sayHello();   // hello,i am child
  }
}

​ 因为子类重写父类的方法,在运行阶段才可确定类型在方法调用时候jvm其实是使用了invokevirtual指令。java中采用oop-klass二分模型表示一个对象。klass保存着类的数据,在其中保存着方法表,方法表中保存着从父类继承下来,自己定义的所有方法,如果子类重写父类方法,那么在这个方法表中相同位置上的父类方法则会被覆盖。也就意味着从子类实例来说是无法找到父类该方法的。

动态类型语言支持

静态类型语言:在非运行阶段,变量的类型是可以确定的,也就是变量是有类型的。

动态类型语言:在非运行阶段,变量的类型是无法确定的,也就是变量是没类型的。但是值是有类型的,也就是运行期间可以确定变量的值类型。

JDK7新增对动态语言调用的支持invokedynamic,Java里没有函数指针无法在运行时动态调用方法。

import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;

public class MethodHandleTest {
  static class ClassA {
    public void println(String s) {
      System.out.println(s);
    }
  }
  
  public static void main(String[] args) throws Throwable {
    // 无论obj最终是哪个实现类,下面这句都能正确调用到println方法。 
    Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
        
    /**
      * MethodType:代表“一个方法”
      * 第一个参数 - 方法的返回值
      * 第一个参数 - 方法的具体参数
      */
    MethodType mt = MethodType.methodType(void.class, String.class);
      
    /**
      * lookup()方法来自于MethodHandles.lookup
      * 作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄。 
      * bindTo()用于绑定本地变量表的第一个元素this
      */
    MethodHandle methodHandle = lookup().findVirtual(obj.getClass(), "println", mt).bindTo(obj);
        
    // 调用方法
    methodHandle.invokeExact("Hello");
  }
}

附录

JIT编译(just-in-time compilation)

​ 在部分商用虚拟机中(如HotSpot),Java程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compile)。

​ 即时编译器并不是虚拟机必须的部分,Java虚拟机规范并没有规定Java虚拟机内必须要有即时编译器存在,更没有限定或指导即时编译器应该如何去实现。但是,即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,它也是虚拟机中最核心且最能体现虚拟机技术水平的部分。

​ JIT编译狭义来说是当某段代码即将第一次被执行时进行编译,因而叫“即时编译”。JIT编译是动态编译的一种特例。JIT编译一词后来被泛化,时常与动态编译等价。

符号引用&直接引用

符号引用(Symbolic References)

​ 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。

直接引用(Direct References)

(1)直接指向目标的指针【比如指向Class对象、类变量、类方法的直接引用可能是指向方法区的指针)

(2)相对偏移量【比如指向实例变量、实例方法的直接引用都是偏移量】

(3)一个能间接定位到目标的句柄

管程

  1. 管程可以看做一个软件模块,它是将共享的变量和对于这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,进程可以调用管程来实现进程级别的并发控制。
  2. 进程只能互斥得使用管程,即当一个进程使用管程时,另一个进程必须等待。当一个进程使用完管程后,它必须释放管程并唤醒等待管程的某一个进程。
  3. 在管程入口处的等待队列称为入口等待队列,由于进程会执行唤醒操作,因此可能有多个等待使用管程的队列,这样的队列称为紧急队列,它的优先级高于等待队列。