JAVA 虚拟机

深入理解JAVA虚拟机

一. 自动内存管理

1. Java 内存区域与内存溢出异常

1.1 运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存空间划分为几个不同的数据区域,这些区域有各自的用途,它们的创建和销毁时间各有不同。

运行时数据区域划分:

image-20230613101519913

程序计数器

一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器,即当前正在执行的字节码指令。

为了保证多个线程之间独立不受其他线程影响,每个线程都具有一个独立的程序计数器,独立存储,我们称这类内存区域为“线程私有”的内存。

  • 线程执行 Java 方法:程序计数器指向正在执行的虚拟机字节码指令的地址
  • 线程执行本地(native)方法:程序计数器值为空,这是唯一一个在《Java 虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域
Java 虚拟机栈

线程私有,生命周期与线程相同。

虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法从被调用到执行完毕的过程,对应着一个栈帧从入栈到出栈的过程。

局部变量表中的存储空间以局部变量槽来表示,其中64位的 long 和 double 类型占用两个变量槽,其它数据类型只占用1个。局部变量表所需内存空间在编译期间完成分配,方法所需的局部变量空间大小是完全确定的,在方法运行期间不会改变局部变量表的大小。

  • StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度时抛出异常
  • OutOfMemoryError:Java 虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存空间时抛出异常
本地方法栈

类似Java 虚拟机栈,只不过是为本地方法(native)服务。

Java 堆

虚拟机所管理内存中最大的一块。

堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的是存放对象实例。

堆是垃圾收集器管理的内存区域,因而也被称为“GC堆”。它可以处于物理上不连续的内存空间中,但在逻辑上应该被视为连续的,其实现可以是固定大小的,也可以是可扩展的。

方法区

线程共享,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

方法区常被称作“永久代”,其实“永久代”只是实现方法区的一种方式,有许多类型的虚拟机就不使用“永久代”的方式实现方法区,因此二者不能等同。

运行时常量池

方法区的一部分,存放 Class 文件中的常量池表中的信息,具备动态性,即运行期间也可以添加新的常量进入常量池中,如 String 类的 intern 方法。

直接内存

不属于虚拟机运行时数据区,也不是《Java 虚拟机规范》中定义的内存区域。

可以作为 Java 虚拟机运行时对数据交换的补充,即利用这部分空间进行异步操作,提高性能。


1.2 HotSpot 虚拟机对象探秘

1.2.1 对象的创建

在语言层面上,创建对象通常仅仅是一个 new 关键字而已,但虚拟机是如何处理创建对象的过程呢?

当 JVM 遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,则必须先执行相应的类加载过程。类加载检查通过后,便是类加载,待其完成后其所需内存空间大小便可完全确定,并在堆中为其分配内存。

内存分配是一个很很频繁的操作,在并发情况下不是线程安全的,可能会出现多个线程争用同一部分内存空间。

解决方法:一种是对分配内存空间的操作进行同步操作–采用 CAS 配上失败重试的方式保证操作的原子性;一种是为每个线程预先分配一小块内存,称为本地线程分配缓冲(TLAB),然后线程在缓存区内分配内存,只有当本地缓存区用完了,分配新的缓冲区时才需要同步锁定。

上述步骤完成后,JVM 还需要对对象进行必要的设置,比如类的元信息、对象的哈希码等,这些存储在对象的对象头中。

最后,一个新的对象便产生了,但其时还未进行初始化操作(init)。只有执行完初始化操作后,一个真正可用的对象才算被完全构造出来。

1.2.2 对象的内存布局

存储布局:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)
对象头

对象头部分包括两类信息。

“Mark Word”:存储对象自身的运行时数据。

另一部分是类型指针,即对象指向它的类型元数据的指针,通过该指针来确定该对象是哪个类的实例。

实例数据

对象真正存储的有效信息,即在程序代码中定义的各种类型的字段内容。

对齐填充

并非必然存在,仅起到占位符作用。

1.2.3 对象的访问定位

Java 程序会通过栈上的 reference 数据来操作堆上的具体对象。

对象访问的两种方式:

  • 句柄访问:堆中划分一块内存作为句柄池,reference存储的就是对象的句柄地址。
  • 直接指针访问:reference存储的就是对象地址

句柄访问的优点是在对象被移动是只需要改变句柄指针,而无需改变reference;直接指针访问的优点是速度更快,节省了一次间接访问的开销。


2. 垃圾收集器与内存分配策略

2.1 对象已死?

垃圾收集器在对对象实例进行回收前,需要判断对象是否“存活”。

2.1.1 引用计数算法

算法核心:对每个对象维护一个引用计数器,每当有一个地方引用它时,计数器值加一:当引用失效时,计数器值减一;任何时刻计数器为零的对象就是不可能再被使用的。

Java 并不采用该算法来管理内存,因为它很难解决循环引用等问题。

2.1.2 可达性分析算法

当前主流商用程序语言都是通过可达性分析算法来判定对象是否存活的。

算法核心:通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索路径称为“引用链”,如果某个对象到GC Roots没有任何引用链相连,则证明此对象不可能再被使用。

2.1.3 再谈引用

引用的分类:

  • 强引用:强引用关系若存在,垃圾收集器就永远不会回收被引用的对象。
  • 软引用:描述一些还有用,但非必须的对象。在系统将要发生内存溢出前,会将软引用对象进行回收,若回收后,还没有足够内存,才会抛出异常。SoftReference 类可以实现软引用。
  • 弱引用:与软引用类似,但强度更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生为止,无论内存是否足够,都会被回收。WeakReference 类可实现弱引用。
  • 虚引用:最弱的引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得对象实例。其唯一目的是在这个对象被回收时收到一个系统通知。PhantomReference 类可实现虚引用。
2.1.4 生存还是死亡?

即使在算法中被判定为不可达,其仍有“存活”机会。

宣告一个对象死亡至少要经历两次标记过程,第一次即为算法判定。被标记后,会进行一次筛选,判断是否有必要执行对象的 finalize 方法,若该方法未被覆盖或已被执行过,则该对象即被宣告死亡;否则将其放入F-Queue队列中。

虚拟机会自动建立低调度优先级的Finalizer线程去执行F-Queue队列中对象的finalize方法,只要对象在finalize方法中能重新与引用链上的一个对象建立关联,它便会被移出“即将回收”的集合,即逃脱;否则基本就被宣告死亡了。

finalize方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序。官方已经不建议使用该方法了。

2.1.5 回收方法区

方法区回收条件苛刻,其收集器实现难度很高。

主要回收两部分内容:

  • 废弃的常量:判断其是否还被引用即可
  • 不再使用的类型:判断条件较为苛刻
    • 该类所有实例(即包括了其派生子类)已经被回收
    • 加载该类的类加载器已经被回收
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

2.2 垃圾收集算法

2.2.1 分代收集理论

分代假说:

  • 弱分代假说:绝大多数对象都是朝生夕灭的
  • 强分代假说:熬过越多次垃圾收集过程的对象越难以消亡

这两个假说奠定了垃圾收集器的设计原则:收集器应将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储。

根据这两个假说,设计者将堆划分为了“新生代”和“老年代”两个部分。然而,这两个部分最终还是出现了问题–“跨代引用”,即新生代与老年代的相互引用,导致了垃圾收集时的性能负担。

  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数

基于此假说,新生代上建立一个数据结构–“记忆集”,这个结构把老年代划分为若干小块,标识出老年代的哪一块会存在跨代引用,在Minor GC时,只有包含跨代引用的的对象才会在老年代中扫描判断是否回收,通过空间换取了时间开销。

GC分类:

2.2.2 标记 - 清除算法

算法核心:标记所有需要回收/存活的对象,标记完成后,统一回收被标记/未被标记的对象。

两个缺点:

  • 执行效率不稳定
  • 内存空间碎片化

2.2.3 标记 - 复制算法

算法核心:将内存容量划分为大小相等的两块,每次只使用其中一块,当本块使用完了,就将活着的对象复制到另一块上面,然后把本块一次清理掉。

优点:对于多数对象可回收的情况,无需标记大量对象,节省性能开销。

缺点:若多数对象存活,则需要复制大量对象,造成巨大开销;并且只能使用一半内存,造成空间浪费。

这种算法多用于回收新生代。

2.2.4 标记 - 整理算法

针对老年代存活对象较多的情况,一般不采取“标记-复制算法”。

算法核心:标记过程通“标记-清除算法”,但后续步骤不是对可回收对象进行清理,而是将存活对象移到内存空间一端,然后直接清理掉边界以外的内存。

该算法是移动式的,而“标记-清除算法”是非移动式的。

2.3 经典垃圾收集器

2.3.1 Serial 收集器

最基础、历史最悠久的垃圾收集器,曾经是新生代收集器的唯一选择。

特点:

  • 单线程:在使用一个处理器或一个线程进行垃圾收集
  • “Stop The World”:垃圾收集时,必须暂停其它所有工作线程,直到收集结束
  • 额外内存消耗小
  • 单线程收集效率高
  • 多应用于客户端模式下的虚拟机

2.3.2 ParNew 收集器

实质上时Serial收集器的多线程并行版本,多应用于服务端模式下的虚拟机。

2.3.3 Parallel Scavenge 收集器

常被称作“吞吐量优先收集器”,其关注点是吞吐量,即用户代码时间与程序总时间的比值,CMS等收集器的关注点则是尽可能地缩短垃圾收集时用户线程的停顿时间。

与ParNew收集器的不同:

  • 关注吞吐量
  • 自适应调节策略
2.3.4 Serial Old 收集器

Serial 收集器的老年代版本,特性同Serial收集器。

2.3.5 Parallel Old 收集器

2.3.6 CMS 收集器

CMS(Concurrent Mark Sweep)收集器,也被称作“并发低停顿收集器”,是一种以获取最短回收停顿时间为目标的收集器。

运作过程:

  • 初始标记:标记GC Roots能直接关联到的对象,速度快,需要“Stop The World”
  • 并发标记:从GC Roots的直接关联对象开始遍历对象图,耗时较长,但无需停顿用户线程,可以与垃圾收集线程并发运行
  • 重新标记:修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,时间长于初始标记,短于并发标记,需要“Stop The World”
  • 并发清除:清理标记阶段判断已经死亡的对象,可以与用户线程同时并发

2.3.7 Garbage First 收集器

简称G1,“全功能收集器”,面向局部收集的设计思路和基于Region的内存布局形式。

Mixed GC模式:目标范围不再是新生代、老生代或整堆,堆内存中的任何部分均可组成回收集(CSet)。

G1将Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间或老年代空间。

Region中还有一类特殊的Humongous区域,用来存储大对象,G1认为只要对象大小超过了一个Region容量一半的对象即可判定为大对象。

G1以Region作为单次回收的最小单元,并跟踪各个Region里面的垃圾堆积的“价值”大小,维护一个优先队列,每次回收价值最大的那些Region。

工作流程:

2.4 低延迟垃圾收集器

衡量垃圾收集器的三项重要指标:

  • 内存占用
  • 吞吐量
  • 延迟

2.4.1 Shenandoah 收集器

与G1的不同:

  • G1的回收阶段可以多线程并行,却无法与用户线程并发,而Shenandoah可以做到两者兼顾
  • Shenandoah默认不采用分代收集
  • 摒弃G1中的记忆集,使用连接矩阵来记录跨Region的引用关系

九个阶段:

  • 初始标记
  • 并发标记
  • 最终标记
  • 并发清理:用于清理一个存活对象都没有的Region
  • 并发回收:将存活对象复制到其它未被使用的Region中
  • 初始引用更新:将所有指向旧对象的引用修正到复制后的新地址,这个操作就是引用更新。初始化阶段其实就是设置一个线程集合点,时间很短。
  • 并发引用更新:真正开始进行引用更新操作
  • 最终引用更新:修正GC Roots中的引用
  • 并发清理:回收集中所有Region都没有对象存活,再调用一次并发清理回收内存空间

2.4.2 ZGC 收集器

与G1、Shenandoah的Region不同的是,ZGC的Region具有动态性–动态创建和销毁。x64平台下,可以支持大、中、小三类容量。

运作过程:

  • 并发标记
  • 并发预备重分配
  • 并发重分配
  • 并发重映射

3. 虚拟机性能监控、故障处理工具

3.1 基础故障处理工具

3.1.1 jps:虚拟机进程状况工具

列出正在运行的虚拟机进程,并显示虚拟机执行主类名称以及这些进程的本地虚拟机唯一ID。

命令格式:

1
jps [ options ] [ hostid ]

3.1.2 jstat:虚拟机统计信息监视工具

监视虚拟机各种运行状态信息的命令行工具。

命令格式:

1
jstat [ option vmid [interval[s|ms] [count]] ]

3.1.3 jinfo: Java 配置信息工具

实时查看和调整虚拟机各项参数。

命令格式:

1
jinfo [ option ] pid
3.1.4 jmap:Java 内存映像工具

用于生成堆转储快照。

命令格式:

1
jmap [ option ] vmid

jhat:虚拟机堆转储分析工具,配套jmap使用。

3.1.5 jstack:Java 堆栈跟踪工具

生成虚拟机当前时刻的线程快照。

命令格式:

1
jstack [ option ] vmid

3.2 可视化故障处理工具

3.2.1 JHSDB: 基于服务性代理的调试工具

3.2.2 JConsole:Java 监视与管理控制台
3.2.3 VisualVM:多合 - 故障处理工具
3.2.4 Java Mission Control:可持续在线的监控工具

二. 虚拟机执行子系统

1. 类文件结构

Java 虚拟机的语言无关性

1.1 Class 类文件的结构

Class 文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构只包含两种数据类型:

  • 无符号数:基本数据类型
  • 表:多个无符号数或其它表作为数据项构成的复合数据类型

1.1.1 魔数与 Class 文件的版本

每个 Class 文件的头4个字节被称为魔数。它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。

魔数后的4个字节存储的是 Class :5、6字节是次版本号,7、8是主版本号。

1.1.2 常量池

主、次版本号后是常量池入口。

常量池是 Class 文件结构中与其他项目关联最多的数据,通常也是占用 Class 文件空间最大的数据项目之一,还是在 Class 文件中第一个出现的表类型数据项目。

常量池中常量的数量是不固定的,所以其入口处需要放置一项u2类型的数据,代表常量池容量计数值,其值从1开始。Class 文件中仅有常量池的容量计数从1开始。

主要存放两大类常量:

  • 字面量:如文本字符串、final修饰字段等

  • 符号引用:

1.1.3 访问标志

常量池结束后紧接着的2个字节代表访问标志,用于识别一些类或接口层次的访问信息。

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

类索引和父类索引都是一个u2类型的数据,而接口索引集合是一组u2类型的数据的集合,由这三项数据来确定该类型的继承关系。

  • 类索引:确定该类的全限定名
  • 父类索引:确定该类父类的全限定名
  • 接口索引集合:实现的接口

1.1.5 字段表集合

字段表用于描述接口或类中声明的变量。

  • access_flags:

  • name_index: 字段简单名称

  • descriptor_index: 字段和方法的描述符

都是对常量池项的引用。

描述符

对于数组类型,每一维度将使用一个前置的”[“,如一个定义为”java.lang.String[][]“类型的二维数组将被记录为”[[Ljava/lang/String;“,一个整型数组将被记录为”[I”。

描述方法时:

字段表集合中不会列出从父类或者父接口中继承来的字段,但有可能出现代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字段。

1.1.6 方法表集合

用于对方法的描述。

方法里的Java代码,经过javac编译器编译成字节码指令之后,存放在方法属性表中一个名为“Code”的属性里面。

  • access_flags:

1.1.7 属性表集合

对于每一个属性,它的名称都要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。

  1. Code 属性

    存储 Java 程序方法体里面的代码经过编译后的字节码指令。

    包含了异常表,负责处理try、finally等异常处理操作,非必须存在。

  2. Exceptions 属性

    与Code属性平级,与异常表不同。列举方法中可能抛出的受查异常,即throws关键字后面列举出的异常。

  3. LineNumberTable 属性

    描述 Java 源码行号和字节码行号之间的对应关系。

    非运行时必需的属性,但默认会生成到Class文件中。若不生成,在进行调试时,无法按照源码行进行断点调试。

  4. LocalVariable及LocalVariableTypeTable属性

    LocalVariable属性用于描述栈帧中局部变量表的变量与 Java 源码中定义的变量之间的关系,非必需。

  5. SourceFile及SourceDebugExtension

    SourceFile属性用于记录生成这个Class文件的源码文件名称,可选。

    SourceDebugExtension用于存储额外的代码调试信息。

  6. ConstantValue属性

    通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量才可以使用这项属性。

  7. InnerClasses属性

    记录内部类与宿主类之间的关联。

  8. Deprecated及Synthetic属性

    标志类型的布尔属性,只存在有和没有之分,没有属性值的概念。

    Deprecated属性用于表示某个类、字段或者方法,已不再被推荐使用。

    Synthetic属性代表此字段或者方法并不是由Java源码直接产生的,而是由编译器自行添加的。

  9. StackMapTable属性

    类型推导验证器。

  10. Signature属性

    若代码中包含了类型变量或参数化类型,则该属性会为它记录泛型签名信息。

    Java 语言的泛型采用的是擦除法实现的伪泛型。

  11. BootstrapMethods属性

    保存invokedynamic指令引用的引导方法限定符。

  12. MethodParameters属性

    记录方法的各个形参名称和信息。

  13. 模块化属性

    用于实现模块化功能。

  14. 运行时注解相关属性

    RuntimeVisibleAnnotations变长属性记录类、字段或方法的声明上记录运行时可见注解,当我们使用反射API来获取类、字段或方法上的注解时,返回值就是通过该属性获取到的。


2. 虚拟机类加载机制

Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称为虚拟机的类加载机制。与那些在编译时需要进行连接的语言不同,在 Java 语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,为 Java 应用提供了极高的扩展性和灵活性。

2.1 类加载的时机

类的生命周期

2.2 类加载的过程

2.2.1 加载

加载阶段需要完成的事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

非数组类的加载:

  • 可通过虚拟机里内置的引导类加载器
  • 用户自定义的类加载器

数组类的加载:

本身不通过类加载器创建,而是由虚拟机在内存中动态构造的。但数组类的元素类型(数组去掉所有维度的类型)还是需要类加载器完成加载。

一个数组类(C)的创建过程遵循的规则:

  • 如果数组的组件类型(数组去掉一个维度的类型)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组C将被标识在加载该组件类型的类加载器的类名称空间上。

  • 如果数组的组件类型不是引用类型,虚拟机会把数组C标记为与引导类加载器关联。

  • 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为 public,可被所有的类和接口访问到。

加载阶段结束后,会在堆内存中实例化一个 java.lang.Class 类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。

2.2.2 验证

这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证四阶段:

  1. 文件格式验证

    验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。

    该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。

    只有通过该阶段,这段字节流才被允许进入方法区存储,后面的三个阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流。

  2. 元数据验证

    对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java 虚拟机规范》的要求。

    第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在与《Java 虚拟机规范》定义相悖的元数据信息。

  3. 字节码验证

    最复杂的阶段,通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。

    主要对类的方法体(即Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。

    即使通过了字节码验证,也无法确认程序是否能在有限的时间内结束运行–“停机问题”。

  4. 符号引用验证

    本阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段–解析阶段中发生。

    失败抛出java.lang.IncompatibleClassChangeError。

    主要校验该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。

    主要目的是确保解析行为能正常执行。

验证阶段并非必要执行的,若代码已经被反复使用和验证过没有问题,也可以关闭验证措施,缩短虚拟机类加载的时间。

2.2.3 准备

正式为类中定义的变量(静态变量,static修饰)分配内存并设置类变量初始值的阶段。

JDK7及之前,HotSpot使用永久代实现方法区时,这些变量所使用的内存即在方法区;JDK8 及以后,类变量则会随着 Class 对象一起存放在堆中。

本阶段进行内存分配的仅包括类变量,不包括实例变量。

通常情况:

1
public static int value = 123;

变量 value 在准备阶段过后的初始值为0而不是123,因为尚未执行初始化方法。

若类字段的字段属性表中存在ConstantValue属性,即:

1
public static final int value = 123;

编译时将会为value生成ConstantValue属性,本阶段value即被赋值为123。

2.2.4 解析

虚拟机将常量池内的符号引用替换为直接引用的过程。

  1. 类或接口的解析

    假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,将会有以下三个规则:

    • 如果C不是一个数组类型,虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C,若有异常,则失败。
    • 如果C是一个数组类型,并且数组的元素类型为对象,那将会按照规则1加载数组元素类型,接着由虚拟机生成一个代表该数组维度和元素的数组对象。
    • 如果上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限,若无,则抛出IllegalAccessError。
  2. 字段解析

    首先对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用,若该步解析完成,假设所属的类或接口为C,遵循以下4个步骤:

    • 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。
    • 否则,如果在C中实现了接口,则会按照继承关系从下往上递归搜索各个接口和它的父接口如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。
    • 否则,如果C不是java.lang.Object,则会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。
    • 否则,查找失败,抛出java.lang.NoSuchFieldError。

    如果查找过程成功返回了引用,将会对这个字段进行权限验证,若失败,抛出IllegalAccessError。

  3. 方法解析

    即类的方法解析,第一个步骤同字段解析,先解析方法表的class_index项中索引的方法所属的类或接口的符号引用,仍用C表示该类,后续步骤:

    • Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果C是接口的话,抛出IncompatibleClassChangeError异常。
    • 若通过第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,若有则返回直接引用,查找结束。
    • 否则,在类C的父类中进行递归查找是否有简单名称和描述符都与目标相匹配的方法,若有则返回直接引用,查找结束。
    • 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,若有,说明类C是一个抽象类,查找结束,抛出AbstractMethod异常。
    • 否则,宣告失败,抛出NoSuchMethodError。

    最后,若成功查找到,还需要进行权限验证。

  4. 接口方法解析

    步骤一同方法解析,设接口为C,后续步骤:

    • 如果C是类的话,抛出IncompatibleClassChangeError异常。
    • 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,若有则返回直接引用,查找结束。
    • 否则,在接口C的父接口中查找,做到java.lang.Object类为止(包括Object),若有简单名称和描述符都与目标相匹配的方法,若有则返回直接引用,查找结束。
    • 对于规则3,若C的不同父接口中存有多个匹配的方法,则返回其中一个并结束查找。
    • 否则,宣告失败,抛出NoSuchMethodError。
2.2.5 初始化

初始化阶段就是执行类构造器()方法的过程。()方法并不是在代码中直接编写的代码,而是javac编译器的自动生成物:

  • 由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,收集顺序是由语句在源文件中出现的顺序决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
  • 与类的构造函数不同,无需显示调用父类构造器虚拟机会保证在子类()方法执行前,父类的()方法已经执行完毕。因此在虚拟机中第一个被执行的()方法的类型肯定是java.lang.Object。
  • 对于类或接口来说不是必需的,若一个类中没有静态语句块,也没有对变量的赋值操作,那么可以不生成该方法。
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因而会生成该方法。但与类不同的是,它无需先执行父接口的()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外接口的实现类在初始化时,也一样不会执行接口的()方法。
  • 虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只有一个线程去执行这个类的()方法,其它线程都需要阻塞等待,直到活动线程执行完()方法。

2.3 类加载器

通过一个类的全限定名来获取描述该类的二进制字节流时在虚拟机外部顺序的,实现这个动作的代码成为“类加载器”。

2.3.1 类与类加载器

对于任意一个类,都必须由加载它的类加载器和该类本身共同确立在虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。

比较两个类是否“相等”,即使两个类来源于同一个Class文件,若类加载器不同,这两个类也不相同。

2.3.2 双亲委派模型

从虚拟机的角度看,存在两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader):由C++/Java实现,虚拟机的一部分
  • 其他类的类加载器:Java实现,独立于虚拟机外部,全部继承抽象类java.lang.ClassLoader

从Java开发人员的角度看,JDK 8 及之前版本的Java,三层类加载器:

  • 启动类加载器

    加载\lib目录下能够被虚拟机识别的类库。

    该加载器无法被Java程序直接引用,若需要使用该类加载器,只需在自定义类加载器时用null代替即可。

  • 扩展类加载器

    加载\lib\ext目录下的类库,是类库的扩展机制,可以直接在程序中使用。

  • 应用程序类加载器

    也被称为“系统类加载器”,ClassLoader.getSystemClassLoader()方法的默认返回值。

    用于加载用户类路径上所有的类库,可以在程序中直接使用。

双亲委派模型

模型的工作过程:

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把该请求委派给父类,以此类推,最终到达启动类加载器,只有当父类加载器反馈自己无法处理该请求时,子加载器才会尝试自己完成加载。

这能够保证如Object类最终都是由启动类加载器加载的,保证了Object的唯一性,否则每个类的Object都不同,程序将会一片混乱。

2.3.3 破坏双亲委派模型

三次破坏:

  • JDK1.2 以前,模型尚未出现,而类加载器已经存在,增添了一个新的protected方法findClass()。

  • 模型自身缺陷,越基础的类由越上层的类加载器加载,若基础类要调回下层类应用怎么办?

    引入线程上下文类加载器(Thread Context ClassLoader),可以通过Thread.setContextClassLoader()方法设置,若创建线程时未设置,则从父线程继承一个,若全局范围内都没有设置,则默认是应用程序类加载器。

    基础类可以通过该类加载器加载子类,解决上述问题。

  • 对程序动态性的追求如代码热替换(Hot Swap)、模块热部署(Hot Deployment)等

2.4 Java 模块化系统

模块化的关键目标–可配置的封装隔离机制。

2.4.1 模块的兼容性

访问规则:

2.4.2 模块化下的类加载器

扩展类加载器被平台类加载器取代。

类加载器结构变化


3. 虚拟机字节码执行引擎

3.1 运行时栈帧结构

Java 虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)是用于支持虚拟机进行方法调用和方法执行背后的数据结构,也是虚拟机运行时数据区中的虚拟机栈的栈元素。

栈帧包括局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。

对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,被称为“当前栈帧”,该栈帧所关联的方法被称作“当前方法”,执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。

3.1.1 局部变量表

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

局部变量表的容量以变量槽为最小单位,变量槽占用的内存空间因不同虚拟机的实现而异。

Java 虚拟机通过索引定位的方式使用局部变量表,索引从0开始。

当一个方法被调用时,虚拟机使用局部变量表来完成实参到形参的传递。如果执行的是实例方法,那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列。参数表分配完毕后,再按照方法体内部定义的变量顺序和作用域分配变量槽。

变量槽可重用。

3.1.2 操作数栈

常被称作操作栈,其最大深度由Code属性中的max_stacks数据项确定。

32位数据类型所占用的栈容量为1,64位数据类型所占用的栈容量为2。

Java 虚拟机的解释执行引擎被称为“基于栈的执行引擎”,“栈”就是操作数栈。

栈帧之间的数据共享:

3.1.3 动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。

静态解析:符号引用在类加载阶段或者第一次使用的时候就被转化为直接引用。

动态连接:符号引用在运行期才被转化为直接引用。

3.1.4 方法返回地址

当一个方法开始执行后,只有两种方式退出该方法:

  • 正常调用完成

    执行引擎遇到方法返回的字节码指令,根据返回指令返回

  • 异常调用完成

    方法执行过程中遇到了异常,且该异常在方法体内没有得到妥善处理。

    在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。

3.1.5 附加信息

3.2 方法调用

方法调用阶段唯一的任务就是确定被调用方法的版本,暂时还未涉及方法内部的具体运行过程。

两种形式:

  • 解析调用
  • 分派调用
3.2.1 解析

所有方法调用的目标方法在Class文件中都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,前提是:方法在程序真正运行前就有一个可确定的调用版本,且这个方法的调用版本在运行期是不可改变的。这类方法调用称为解析。

Java中符合“编译期可知,运行期不变”的方法主要有两类:

  • 静态方法:与类型直接关联
  • 私有方法:在外部不可被访问

这两类方法各自的特点决定了它们都不可能通过继承或别的方式重写出其它版本,因此适合在类加载阶段解析。

虚拟机支持的字节码指令:

只要能被invokestatic和invokespecial指令调用的方法,都可以在类加载阶段确定唯一的调用版本。在Java语言中符合这个条件的方法有静态方法、私有方法、实例构造器、父类方法,再加上final修饰的方法(使用invokevirtual指令)。这5种方法再类加载是就可以转化为直接引用,统称为“非虚方法”,其他方法被称为“虚方法”。

3.2.2 分派
1. 静态分派

例如:

1
Human man = new Man();

“Human”称为变量的“静态类型”,或者叫“外观类型”,后面的”Man“称为”实际类型“或者”运行时类型“。

静态类型的变化仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译时并不知道一个对象的实际类型是什么。

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。典型应用就是方法重载。

2. 动态分派

典型应用:方法重写。

本质是invokevirtual指令:

  • 确定操作数栈顶的第一个元素所指向的对象的实际类型,记作C
  • 寻找C中相匹配的方法,并进行权限校验,通过则返回直接引用
  • 否则,按照继承关系查找
  • 若未找到,抛出AbstractMethodError。

这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

字段不参与多态。

3. 单分派与多分派

方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。

Java 语言中的静态分派(接收者与方法参数共同决定)属于多分派,动态分派(仅由接收者决定)属于单分派。

4. 虚拟机动态分派的实现

动态分派执行非常频繁,而且在其版本选择过程中需要搜索合适的目标方法,消耗性能。基于执行性能考虑,真正运行时一般不会如此频繁地去反复搜索类型元数据。

优化手段:为类型在方法区建立一个虚方法表,与此对应,在invokeinterface执行时也会用到接口方法表,使用虚方法表索引代替元数据查找以提高性能。

虚方法表一般在类加载的连接阶段进行初始化,在准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。

3.3 动态类型语言支持

实现动态类型语言–invokedynamic。

3.3.1 动态类型语言
  • 动态类型语言:较为灵活,开发效率提高,类型检查的主体过程在运行期进行,如Python、JavaScript、Lua、PHP。
  • 静态类型语言:编译器可以提供全面严谨的类型检查,这样与数据类型有关的潜在问题在编码时就能及时发现,利于稳定性及让项目达到更大规模,类型检查的主体过程在编译期进行,如C、C++、Java。
3.3.2 java.lang.invoke 包

提供了一种新的动态确定目标方法的机制,称为”方法句柄“(Method Handle)。

方法句柄与反射的不同:

  • 本质都是模拟方法调用,但是Reflection模拟的是Java代码层次,而MethodHandle模拟的是字节码层次。
  • Reflection中的java.lang.reflect.Method包含的信息更多,Reflection重量级,MethdoHandle轻量级。
  • 虚拟机对字节码指令做的优化,理论上对MethodHandle也可行,而反射则不行。
  • Reflection设计目标是为了Java服务的,而MethodHandle则设计为虚拟机上的语言服务。
3.3.3 invokedynamic 指令

每一处含有invokedynamic指令的位置都被称作”动态调用点“,这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是CONSTANT_InvokeDynamic_info常量,从该常量可以得到3项信息:引导方法、方法类型和名称。

引导方法有固定参数,并且返回值是java.lang.invoke.CallSite对象,这个对象代表了真正要执行的目标方法调用。

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

执行程序的两种方式:

  • 解释执行(通过解释器执行)
  • 编译执行(通过即时编译器产生本地代码执行)
3.4.1 解释执行

编译过程

3.4.2 指令集
  • 基于栈的指令集:可移植,编译器实现更加简单,执行速度相对稍慢。
  • 基于寄存器的指令集

三. 程序编译与代码优化

1. 前端编译与优化

编译器:

  • 前端编译器:把*.java转化为*.class文件

  • 即时编译器(JIT):运行期执行,字节码转化为本地机器码

  • 提前编译器(AOT):直接把程序编译成与目标机器指令集相关的二进制代码的过程

1.1 Javac 编译器

1.1.1 Javac 的源码与调试

编译过程:

1.1.2 解析与填充符号表
1. 词法、语法分析

词法分析:将源代码的字符流转变为标记集合的过程,标记是编译时的最小元素。

语法分析:根据标记序列构造抽象语法树(AST)的过程。

生成语法树后,编译器就不会再对源码字符流进行操作,后续操作都建立在抽象语法树上。

2. 填充符号表

符号表是一组符号地址和符号信息构成的数据结构。

1.1.3 注解处理器

通过注解处理器对抽象语法树进行进一步修改。

1.1.4 语义分析与字节码生成
1. 标注检查

检查变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等。

检查过程还会顺便进行常量折叠的代码优化,如:

1
int a = 1 + 2;

抽象语法树上仍能看到“1”、“2”和“+”,但会标注“3”,这样就减少了运行期的工作量。

2. 数据及控制流分析

对程序上下文逻辑的进一步验证,检查局部变量在使用前是否有赋值、方法的每条路径是否有返回值等。

3. 解语法糖

将某些语法还原为原始的基础语法结构的过程称为解语法糖。

4. 字节码生成

将前面步骤生成的信息转化成字节码指令写到磁盘中,并进行少量的代码添加和转换工作,例如实例构造器()方法和类构造器()方法。

1.2 语法糖

1.2.1 泛型
1. Java 与 C# 的泛型

Java 选择的泛型实现方式叫作“类型擦除式泛型”,即泛型只存在于源码中,在编译后生成的字节码文件中,全部泛型都被替换为原来的裸类型。

C#的泛型实现方式式“具现化式泛型”,即在任何时期都是切实存在的,有着独立的虚方法表和类型数据。

2. 泛型的历史背景

两个选择:

  • 需要泛型化的类型,以前有的就保持不变,然后平行地加一套泛型化版本的新类型
  • 直接把已有的类型泛型化,既让所有需要泛型化的已有类型都原地泛型化,不添加任何平行于已有类型的泛型版

Java 起初尝试过第一个选择,但由于历史原因及流行程度等因素,最终确定第二个选择。

3. 类型擦除

裸类型:所有该类型泛型化实例的共同父类型。

实现裸类型的两种选择:

  • 由虚拟机自动地、真实地构造出ArrayList这样的类型,并且自动实现从ArrayList派生自ArrayList的继承关系来满足裸类型的定义。
  • 直接在编译时把ArrayList还原为ArrayList,只在元素访问、修改时自动插入一些强制类型转换和检查指令。

显然,Java 采用了第二种方式。

擦除式泛型弊端:

  • 不支持原生类型,因为int、long等类型无法与Object强制转换
  • 运行期无法获取到泛型类型信息,即无法得知参数化类型 T 究竟是什么类型

擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息(Signature属性),这也是我们在编码时通过反射手段取得参数化类型的根本依据。

1.2.2 自动装箱、拆箱与遍历循环

常用语法糖编译后的变化:

  • 泛型:擦除

  • 自动装箱:对应的包装方法,如Integer.valueOf()

  • 自动拆箱:对应的还原方法,如Integer.intValue()

  • 遍历循环(for-each):还原成迭代器实现,因此要求被遍历的类实现Iterable接口

  • 变长参数:封装成数组实现

自动装箱的陷阱:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class demo {
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
System.out.println(c == d);
System.out.println(e == f);
System.out.println(c == (a + b));
System.out.println(c.equals(a + b));
System.out.println(g == (a + b));
System.out.println(g.equals(a + b));
}
}

Integer的缓存池[-128,127]。

输出结果:

1
2
3
4
5
6
true
false
true
true
true
false
1.2.3 条件编译

C、C++使用预处理器指示符(#ifdef)完成条件编译。

Java 语言没有预处理器,进行条件编译的方法就是使用条件为常量的if或while语句。

这种条件编译方式必须遵循最基本的 Java 语法,只能写在方法体内部,因此它只能实现语句基本块(Block)级别的条件编译,而无法调整整个Java类的结构。


2. 后端编译与优化

2.1 即时编译器

Java 程序最初都是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”,为了提高其执行效率,在运行时,虚拟机会把这些代码编译成本地机器码,并尽可能地进行代码优化,运行时完成这个任务的后端编译器就称为即时编译器。

2.1.1 解释器与编译器

解释器与编译器的交互:

分层编译模式:

2.1.2 编译对象与触发条件

热点代码主要有两类:

  • 被多次调用的方法
  • 被多次执行的循环体

这两种情况编译的目标对象都是整个方法体,区别在于执行入口的不同,第二种情况在编译时会传入执行入口点字节码序号(BCI)。这种编译方式因为编译发生在方法执行的过程中,因此被很形象地称为“栈上替换”(OSR),即栈帧还在栈上,方法就被替换了。

要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”。

两种判定方式:

  • 基于采样的热点探测

    虚拟机周期性地检查各个线程的调用栈顶,若某个方法多次出现在栈顶,那么它即为“热点方法”。

    • 优点:实现简单高效,容易获取方法调用关系
    • 缺点:精确度低,容易受到线程阻塞或别的外界因素影响
  • 基于计数器的热点探测

    虚拟机为每个方法建立计数器,统计方法的执行次数,执行次数超过一定阈值就认为它是“热点方法”。

    • 优点:精确度高
    • 缺点:实现较复杂,不能直接获取到方法的调用关系

HotSpot虚拟机使用第二种方式,为每个方法准备了两类计数器:方法调用计数器和回边计数器(在循环边界往回跳转),阈值一旦溢出,就触发即时编译。

方法调用计数器

默认设置下,方法调用计数器统计的不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法被调用的次数。当超过一定的时间限度,若方法的调用次数仍然不足以即时编译,那么该方法的调用计数器就会减少一半,这个过程被称为方法调用计数器热度的衰减,而这段时间就被称为方法统计的半衰周期,进行热度衰减的动作是在虚拟机进行垃圾收集是顺带进行的。

回边计数器

与方法调用计数器的区别:

  • 没有热度衰减过程,统计的是绝对次数
  • 计数器溢出时,会将方法计数器的值也调整为溢出状态,这样下次再进入该方法时就会执行标准编译过程

2.1.3 编译过程
客户端编译器

三段式:

  • 一个平台独立的前端将字节码构造成一种高级中间代码表示(HIR)

    HIR使用静态单分配(SSA)形式来表示代码值,在此之前,编译器已经会在字节码上完成一部分基础优化,如方法内联、常量传播等。

  • 一个平台相关的后端从HIR中产生低级中间代码表示(LIR)

    在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等。

  • 平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码

服务端编译器

2.2 提前编译器

2.2.1 提前编译的优劣得失

提前编译的两条分支:

  • 传统提前编译:类似于C、C++编译器,在程序运行前把程序代码编译成机器码
  • 动态提前编译(即时编译缓存):把原本即时编译器在运行时要做的编译工作提前做好保存下来,下次运行这些代码时直接加载使用

提前编译的最大优势:没有执行时间和资源限制的压力。

即时编译的优势:

  • 性能分析制导优化
  • 激进预测性优化
  • 链接时优化

2.3 编译器优化技术

2.3.1 优化技术概览

方法内联在优化序列最靠前的位置,其好处:

  • 去除方法调用的成本(如查找方法版本、建立栈帧等)
  • 为其他优化建立良好的基础

冗余访问消除(共子表达式消除)

复写传播

无用代码消除(Dead Code)

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 优化前
y = b.get();
z = b.get();

// 方法内联优化后
y = b.value;
z = b.value;

// 冗余访问优化后
y = b.value;
z = y;

// 复写传播优化后
y = b.value;
y = y;

// 无用代码消除
y = b.value;

2.3.2 方法内联

将目标方法的代码原封不动地“复制”到发起调用的方法中,避免真实方法调用。

虚方法只有在运行时才知道具体类型,进而执行对应版本方法,因而方法内联有难度。

依托技术(CHA):类型继承关系分析技术

对方法内联:

  • 非虚方法直接内联

  • 虚方法使用CHA查询当前状态下是否有多个目标版本可选择,若当前状态下只有一个版本,就使用该版本,称为守护内联。这种内联属于激进预测性优化,必须预留“逃生门”

  • 若CHA查询出来的结果确实有多个版本,那么即时编译器会使用内联缓存来缩减方法调用的开销。这种状态下方法调用是真正发生了的,但是比起直接查虚方法表更快。

内联缓存的工作原理:

  • 未发生方法调用前,状态为空。
  • 第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。
  • 若以后每次调用的方法接收者版本都是一样的,那么它就是一种单态内联缓存,通过该缓存调用,比不用内联的非虚方法调用,仅多了一次类型判断的开销。
  • 否则,退化为超多态内联缓存,开销相当于真正查找虚方法表来进行方法分派。

方法内联是一种激进优化。

2.3.3 逃逸分析

基本原理:

根据逃逸程度进行优化:

  • 栈上分配

    如果对象不会逃逸出线程,那么可以在栈上分配该对象的内存空间,这样就可以随栈帧出栈而销毁,无需经过GC收集处理,减少性能消耗。

    支持方法逃逸,不支持线程逃逸。

  • 标量替换

    若一个数据已经无法再分解成更小的数据表示,如原始数据类型等,该数据就被称为标量。否则称为聚合量,如对象等。如果把一个 Java 对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。这样便无需创建整个对象,而只创建所需对象的成员变量,可以在栈上分配空间。

    不允许对象逃逸出方法范围内。

  • 同步消除

    线程同步本身比较耗时,如果逃逸分析能够确定一个变量不会逃逸出线程,那么这个变量的读写肯定不会有竞争,也就无需对该变量进行同步处理。

逃逸分析计算成本高,无法保证其性能收益会高于它的消耗。

2.3.4 公共子表达式消除

如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,没必要对其进行二次计算,只需要使用前面已经计算过的值代替即可。

  • 局部公共子表达式消除:仅限于程序基本块内
  • 全局公共子表达式消除:涵盖多个基本块
2.3.5 数组边界检查消除

若确定了代码中访问数组不会越界,那么之后便无需再进行边界检查,减少性能开销。


四. 高效并发

1. Java 内存模型与线程

每秒事务处理数(TPS):衡量服务性能的高低好坏,一秒内服务端平均能响应的请求总数。

1.1 硬件的效率与一致性

为了弥补计算机的存储设备与处理器的运算速度之间的差距,现代计算机系统引入了高速缓存来作为内存与处理器之间的缓冲。但随之产生了一个新问题:缓存一致性。在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,这种系统称为共享内存多核系统。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。

解决该问题的方法是让各个处理器都遵循一些协议,如MSI、MESI、MOSI等。

1.2 Java 内存模型

作用:屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

1.2.1 主内存与工作内存

Java 内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。

此处的变量指实例字段、静态字段和构成数组对象的元素,但是不包括局部变量和方法参数,因为后者是线程私有的。

Java 内存模型规定所有的变量都存放在主内存中(类比物理上的主内存),每条线程还有自己的工作内存(类比高速缓存)。

线程的工作内存中保存了该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而无法直接读写主内存的数据。不同线程间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需通过主内存来完成。

1.2.2 内存间交互操作

Java 内存模型通过 8 种操作,这 8 种操作都是原子的、不可再分的,来实现主内存与工作内存的交互:

需要遵循的规则:

1.2.3 对于 volatile 型变量的特殊性

volatile 是虚拟机提供的最轻量级的同步机制。

volatile 变量特性:

  • 保证此变量对所有线程的可见性,“可见性”是指当一条线程修改了该变量的值,新值对于其他线程来说是可以立即得知的。而普通变量在线程间的传递则需通过主内存来完成。

    volatile 变量在各个工作线程下是不存在一致性问题的,但是其运算在并发下一样是不安全的。

    若不符合以下两条规则,运算时仍然需要通过加锁(使用synchronized、java.util.concurrent中的锁或原子类)来保证原子性:

    • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
    • 变量不需要与其他的状态变量共同参与不变约束。
  • 禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

线程使用 volatile 的规则:

  • load后必须是use,use前必须为load。

    每次使用变量前必须先从主内存刷新最新的值,保证一致性。

  • assign后必须是store,store前必须为assign。

    每次修改变量后都必须立刻同步会主内存,保证可见性。

  • 若A表示线程T对变量V实施use或assign,F表示相关联的load或store,P是和F相应的对V的read或write;与此类似,B是线程T对变量W实施use或assign,G表示相关联的load或store,Q是和G相应的对W的read或write。如果A先于B,则P先于Q。

    保证该变量不被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。

1.2.4 针对 long 和 double 型变量的特殊规则

long 和 double 的非原子性协定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现自行选择是否要保证 64 位数据类型的load、store、read和write这四个操作的原子性。

1.2.5 原子性、可见性与有序性
  1. 原子性(Atomicity)

    基本数据类型的访问、读写都具备原子性,如果需要一个更大范围的原子性保证,可以使用synchronized。

  2. 可见性(Visibility)

    可见性是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

    Java 内存模型是通过在变量修改后将新值同步会主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile修饰的变量都是如此。区别在于volatile保证新值能够立即同步回主内存,以及每次使用前立即从主内存刷新,即保证了多线程操作时变量的可见性,而普通变量则无法保证这一点。

    synchronized和final也能实现可见性。

    • synchronized(同步块):对一个变量执行unlock操作之前,必须先把该变量同步回主内存中。
    • final:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去,那么在其他线程中就能看见finial字段的值。
  3. 有序性(Ordering)

    volatile和synchronized可以保证有序性。

    • volatile:本身禁止指令重排序。
    • synchronized:同一个时刻只允许一条线程对其进行lock操作。
1.2.6 先行发生原则

即操作发生的先后顺序。

“天然的”先行发生关系,有顺序性保障,无需任何同步器协助,可以直接使用:

时间先后顺序与先行发生原则之间基本没有因果关系,所以衡量并发安全问题的时候不要受时间顺序干扰,一切必须以先行发生原则为准。

1.3 Java 与线程

1.3.1 线程的实现

实现线程的三种方式:

  • 内核线程(1:1)
  • 用户线程(1:N)
  • 用户线程+轻量级进程(N:M)
1. 内核线程实现

内核线程(KLI)就是直接由操作系统内核支持的线程,由内核完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。

程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口–轻量级进程(LWP),就是我们通常意义上讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。

缺点:由于轻量级进程基于内核线程实现,所以各种线程操作都需要进行系统调用,而系统调用代价较高,需要在内核态与用户态来回切换,同时要占用一定的内核资源。

2. 用户线程实现

广义上讲,一个线程只要不是内核线程,就是用户线程的一种。

狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。

优点:不需要切换到内核态,性能消耗低。

缺点:所有线程操作都需要由用户程序自己去处理,操作难度高且复杂。

因为使用用户线程实现的程序通常都比较复杂,除了有明确需求外,一般的应用程序都不倾向使用用户线程。

Java、Ruby等语言都曾使用过用户线程,最终都放弃使用。Golang、Erlang等语言支持用户线程。

3. 混合实现

用户线程还是独立在用户空间中,轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。

4. Java 线程的实现

Java 线程如何实现并不受 Java 虚拟机规范的约束,有具体虚拟机实现。

“主流”平台上的”主流“商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型。

1.3.2 Java 线程调度

线程调度是指系统为线程分配处理器使用权的过程,调度方式主要有两种:

  • 协同式线程调度

    优点:实现简单,一般没有线程同步的问题。

    缺点:线程执行时间不可控制,可能由于一个线程导致整个进程阻塞。

  • 抢占式线程调度

    线程的执行时间是系统可控的,不会出现一个线程导致整个进程阻塞的问题。

    Java 使用的线程调度方式就是抢占式调度,通过线程优先级进行调节。

1.3.3 状态转换

Java 中的 6 种线程状态:

  • 新建(New):创建后尚未启动的线程。

  • 运行(Runnable):包括操作系统线程状态中的Running和Ready。

  • 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒,以下方法会使线程进入Waiting状态:

    • 没有设置 Timeout 参数的 Object::wait() 方法
    • 没有设置 Timeout 参数的 Thread::join() 方法
    • LockSupport::park() 方法
  • 限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无需等待被其他线程显式唤醒,在一定时间后它们会由系统自动唤醒。以下方法会让线程进入该状态:

    • Thread::sleep() 方法
    • 设置了 Timeout 参数的 Object::wait() 方法
    • 设置了 Timeout 参数的 Thread::join() 方法
    • LockSupport::parkNanos() 方法
    • LockSupport::parkUntil() 方法
  • 阻塞(Blocked):线程被阻塞了。与等待状态的区别:

    • 阻塞:等待着获取一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生。
    • 等待:等待一段时间,或者唤醒动作的发生。

    在程序等待进入同步区域的时候,线程将进入这种状态。

  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行。

1.4 Java 与协程

最初多数的用户线程是被设计成协同式调度的,所以有了一个别名–”协程“。又由于这时候的协程会完整地做调用栈的保护、恢复工作,所以也被称为”有栈协程“。

协程的主要优势是轻量,但是需要在应用层面实现的内容很多。


2. 线程安全与锁优化

2.1 线程安全

当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得这个正确的结果,那就称这个对象是线程安全的。

2.1.1 Java 语言中的线程安全

Java 语言中各种操作共享的数据分为五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

  1. 不可变

    不可变的对象一定是线程安全的,因为其无法被改变,因而线程访问时一定是可见的,无需任何线程安全保护措施。

    final 修饰的对象即不可变。

  2. 绝对线程安全

    绝对的线程安全能够完全满足线程安全的定义,但是其代价是十分高昂的。

    Java API 中标注线程安全的类,大多数都不是绝对的线程安全。

  3. 相对线程安全

    相对线程安全就是通常意义上的线程安全,它需要保证对这个对象单次的操作是线程安全的。

    Java 中大部分声明线程安全的类都属于这种类型,如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。

  4. 线程兼容

    线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。如ArrayList、HashMap等。

    我们平常说一个类不是线程安全的,通常就是指这种情况。

  5. 线程对立

    线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。

    Java 语言天生支持多线程,线程对立代码很少出现,而且通常是有害的。

2.1.2 线程安全的实现方法
1. 互斥同步

又称为阻塞同步,是一种悲观的并发策略。

同步:多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条(或者是一些)线程使用。

互斥是实现同步的一种手段,常见互斥实现方式:临界区、互斥量、信号量。

Java 中,最基本的互斥同步手段就是synchronized关键字。

synchronized关键字经过javac编译后,会在同步块前后形成monitorenter和monitorexit两个字节码指令,用来锁定和解锁。

执行monitorenter指令时,首先要获取对象的锁,若获取失败,当前线程被阻塞,直到请求锁定的对象被持有它的线程释放为止;否则,把该对象锁的计数器值加一,而在执行monitorexit指令时,计数器值减一,一旦计数器值等于0,锁即被释放。

注意两点:

持有锁是一个重量级操作,需要耗费较多性能。

除了synchronized关键字外,Java类库还提供了java.util.concurrent包,其中的java.util.concurrent.locks.Lock接口便成了另一种全新的互斥同步手段。

基于Lock接口,用户能够以非块结构来实现互斥同步。

重入锁(ReentrantLock)是Lock最常见的一种实现,是可重入锁。基本用法与synchronized类似。

重入锁增加了一些高级功能:

  • 等待可中断:正在等待的线程可以放弃等待持有锁的线程释放锁,改为处理其他事情。

  • 公平锁:多个线程在等待同一个锁时,必须按照锁的申请时间顺序来依次获得锁。

    非公平锁无法保证这一点,当锁被释放时,任何一个等待锁的线程都有机会获得锁。

    synchronized中的锁是非公平的,ReentrantLock默认也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁,但是性能会急剧下降。

  • 锁绑定多个条件:一个ReentrantLock对象可以同时绑定多个Condition对象。

    在synchronized中,锁对象的wait()和它的notify()或者notifyAll()方法配合可以实现一个隐含的条件,如果要和多于一个的条件关联,则需要额外添加一个锁;而ReentrantLock只需多次调用newCondition()方法即可。

2. 非阻塞同步

基于冲突检测的乐观并发策略,也就是不管风险,先进行操作,若没有其他线程争用共享数据,那么操作直接成功;若发生冲突,则采取其他补偿措施。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步,使用这种措施的代码也称为无锁编程。

比较并交换(Compare-and-Swap,CAS):CAS指令需要三个操作数,分别是内存位置(V)、旧的预期值(A)、新值(B)。当且仅当V符合A时,处理器才会用B更新V的值,否则不更新,该操作是原子的。

“ABA问题”:当一个对象值原本为A,后来被改为B,然后又被改为A,然后当前线程对其进行CAS操作,便会误认为该对象从来没有被更改过。

3. 无同步方案

同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,则无需任何同步措施,因此有些代码天生就是线程安全的。

  • 可重入代码

    又称为纯代码,是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。

    可重入代码是线程安全的,线程安全的代码不一定是可重入的。

  • 线程本地存储

    如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程内,这样,无须同步也能保证线程之间不出现数据争用的问题。

    每一个线程的Thread对象中都有一个ThreadLocalMap对象,以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值,通过ThreadLocal就可以找到对应的本地线程变量。

2.2 锁优化

2.2.1 自旋锁与自适应自旋

大多共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,只需让当前线程执行一个忙循环(自旋),等待持有锁的线程释放锁,这就是自旋锁。

自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,若锁被占用的时间很短,自旋等待的效果就很好;反之若锁被占用的时间很长,那么自旋线程只会白白消耗处理器资源,此时就应当使用传统的方式去挂起线程。

自适应自旋意味着自旋次数不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能成功,从而允许自旋等待更长时间。如果对于某个锁,自旋等待很少获取锁,那在以后要获取该锁时可能直接忽略自旋过程,避免浪费处理器资源。

2.2.2 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。主要判定依据来源于逃逸分析的数据支持。

2.2.3 锁粗化

原则上,在编写代码时,总是推荐将同步块的作用范围限制得尽量小。但是若一系列的连续操作都对同一对象进行反复加锁和解锁,即使没有线程竞争,频繁进行互斥同步操作也会导致不必要的性能损耗。

2.2.4 轻量级锁

轻量级锁加锁过程:

  • 在代码即将进入同步块时,若该对象未被锁定,虚拟机将在当前线程的栈帧中建立一个名为锁记录的空间,用于存储当前Mark Word的拷贝。

  • 然后虚拟机使用CAS操作将对象的Mark Word更新为指向Lock Record的指针。若成功,即代表该线程拥有该对象的锁。

  • 若失败,虚拟机实现检查对象的Mark Word是否指向当前线程的栈帧,若是,说明当前线程已经拥有该对象的锁,直接进入同步块继续执行即可,否则该锁对象已被其他线程占领。

  • 如果出现两条以上的线程占用同一个锁,那么轻量级锁必须要膨胀为重量级锁,此时Mark Word中指向的就是重量级锁的指针,后面等待锁的线程也必须进入阻塞状态。

2.2.5 偏向锁

偏向锁的目的是消除数据在无竞争情况下的同步原语。

轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。

“偏”指的是偏心,该锁偏向第一个获得它的线程,若该锁在接下来的执行过程中,一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。