文章目录

  • 1. 走近Java
    • 1.1 概述
    • 1.2 Java的技术体系
    • 1.3 Java的发展史
    • 1.4 Java虚拟机家族
      • 1.4.1 虚拟机始祖:Sun Classic/Exact VM
      • 1.4.2 武林盟主:HotSpot VM
      • 1.4.3 展望Java技术的未来
  • 2. 自动内存管理
    • 2.1 概述
    • 2.2 运行时数据区
      • 2.2.1 程序计数器
      • 2.2.2 Java虚拟机栈
      • 2.2.3 本地方法栈
      • 2.2.4 Java堆
      • 2.2.5 方法区
      • 2.2.6 运行时常量池
      • 2.2.7 直接内存
    • 2.3 HotSpot虚拟机对象探秘
      • 2.3.1 对象的创建
      • 2.3.2 对象的内存布局
      • 2.3.3 对象的访问定位
      • 2.3.4 常见OutOfMemoryError异常
        • 2.3.4.1 Java堆溢出
        • 2.3.4.2 虚拟机栈和本地方法栈溢出
        • 2.3.4.3 方法区和运行时常量池溢出
        • 2.3.4.4 本机直接内存溢出
    • 2.4 垃圾收集器与内存分配策略
      • 2.4.1 对象已死?
        • 2.4.1.1 引用计数法
        • 2.4.1.2 可达性分析算法
        • 2.4.1.3 再谈一谈引用(强软弱虚)
        • 2.4.1.4 生存还是死亡?
        • 2.4.1.5 回收方法区
      • 2.4.2 垃圾回收算法
        • 2.4.2.1 标记清除-算法
        • 2.4.2.2 标记复制-算法
        • 2.4.2.3 标记整理-算法
      • 2.4.3 HotSpot的算法细节实现
        • 2.4.3.1 根节点枚举
        • 2.4.3.2 安全点
        • 2.4.3.3 安全区域
        • 2.4.3.4 记忆集和卡表
        • 2.4.3.5 写屏障
        • 2.4.3.6 并发的可达性分析
      • 2.4.4 经典的垃圾回收器
        • 2.4.4.1 Serial收集器
        • 2.4.4.2 ParNew收集器
        • 2.4.4.3 Parallel Scavenge收集器
        • 2.4.4.4 Serial Old 收集器
        • 2.4.4.5 Parallel Old 收集器
        • 2.4.4.6 CMS 收集器
        • 2.4.4.7 Garbage First 收集器
        • 2.4.4.8 低延迟垃圾 收集器
        • 2.4.4.9 垃圾回收器 参数总结
      • 2.4.5 内存分配与回收策略
        • 2.4.5.1 对象优先在Eden分配
        • 2.4.5.2 大对象直接进入老年代
        • 2.4.5.3 长期存活的对象将进入老年代
        • 2.4.5.4 动态对象年龄判定
        • 2.4.5.5 空间分配担保
    • 2.5 虚拟机性能监控、故障处理工具
      • 2.5.1 基础故障处理工具
        • 2.5.1.1 jps:虚拟机进程状况工具
        • 2.5.1.2 jstat:虚拟机统计信息监视工具
        • 2.5.1.3 jinfo: Java配置信息工具
        • 2.5.1.4 jmap: Java内存映像工具
        • 2.5.1.5 jhat:虚拟机堆转储快照分析工具
        • 2.5.1.6 jstack: Java堆栈跟踪工具
        • 2.5.1.7 基础工具总结
  • 3. 虚拟机执行子系统
    • 3.1 类文件结构
    • 3.2 Class类文件的结构
      • 3.2.1 魔数与Class文件的版本
      • 3.2.2 常量池
      • 3.2.3 访问标志
      • 3.2.4 类索引、父类索引与接口索引集合
      • 3.2.5 字段表集合
      • 3.2.6 方法表集合
      • 3.2.7 属性表集合
      • 3.2.8 Code属性
    • 3.3 字节码指令简介
      • 3.3.1 字节码与数据类型
      • 3.3.2 加载和存储指令
      • 3.3.3 运算指令
      • 3.3.4 类型转换指令
      • 3.3.5 对象创建与访问指令
      • 3.3.6 操作数栈管理指令
      • 3.3.7 控制转移指令
      • 3.3.8 方法调用和返回指令
      • 3.3.9 异常处理指令
      • 3.3.10 同步指令
      • 3.3.11 公有设计,私有实现
      • 3.3.12 Class文件结构的发展
    • 3.4 虚拟机类加载机制
      • 3.4.1 类加载的时机
      • 3.4.2 类加载的过程
        • 3.4.2.1 加载
        • 3.4.2.2 验证
          • 3.4.2.2.1 文件格式验证
          • 3.4.2.2.2 元数据验证
          • 3.4.2.2.3 字节码验证
          • 3.4.2.2.4 符号引用验证
        • 3.4.2.3 准备
        • 3.4.2.4 解析
          • 3.4.2.4.1 类或接口的解析
          • 3.4.2.4.2 字段解析
          • 3.4.2.4.3 方法解析
          • 3.4.2.4.3 接口方法解析
          • 3.4.2.4.4 初始化
          • 3.4.2.4.5 类加载器
      • 3.4.3 类与类加载器
        • 3.4.3.1 双亲委派模型
        • 3.4.3.2 破坏双亲委派模型
        • 3.4.3.3 Java模块化系统
          • 3.4.3.3.1 模块的兼容性
          • 3.4.3.3.2 模块化下的类加载器
    • 3.5 虚拟机字节码执行引擎
      • 3.5.1 概述
      • 3.5.2 运行时栈帧结构
        • 3.5.2.1 局部变量表
        • 3.5.2.2 操作数栈
        • 3.5.2.3 动态连接
        • 3.5.2.4 方法返回地址
      • 3.5.3 方法调用
        • 3.5.3.1 解析
          • 3.5.3.1.1 分派-静态分派
          • 3.5.3.1.2 分派-动态分派
          • 3.5.3.1.3 分派-单分派与多分派
          • 3.5.3.1.4 虚拟机动态分派的实现
      • 3.5.4 动态类型语言支持
        • 3.5.4.1 动态类型语言
        • 3.5.4.2 Java与动态类型
        • 3.5.4.3 java.lang.invoke包
        • 3.5.4.4 invokedynamic指令
      • 3.5.5 基于栈的字节码解释执行引擎
        • 3.5.5.1 解释执行
        • 3.5.5.2 基于栈的指令集与基于寄存器的指令集
        • 3.5.5.2 基于栈的解释器执行过程
      • 3.5.6 类加载及执行子系统的案例与实战
        • 3.5.6.1 概述
        • 3.5.6.2 案例
          • 3.5.6.2.1 正统的类加载器架构
          • 3.5.6.2.2 OSGi:灵活的类加载器架构
          • 3.5.6.2.3 字节码生成技术与动态代理的实现
          • 3.5.6.2.4 Backport工具:Java的时光机器
  • 4. 程序编译与代码优化
    • 4.1 概述
    • 4.2 Java编译器
      • 4.2.1 Javac的源码与调试
      • 4.2.2 解析与填充符号表
        • 4.2.2.1 词法、语法分析
        • 4.2.2.2 填充符号表
      • 4.2.3 注解处理器
      • 4.2.4 语义分析与字节码生成
    • 4.3 后端编译与优化
      • 4.3.1 概述
      • 4.3.2 即时编译器
      • 4.3.3 解释器与编译器
        • 4.3.3.1 编译对象与触发条件
        • 4.3.3.2 编译过程
        • 4.3.3.3 提前编译器
        • 4.3.3.4 提前编译的优劣得失
      • 4.3.4 编译器优化技术
      • 4.3.5 优化技术概览
        • 4.3.5.1 方法内联
        • 4.3.5.2 逃逸分析
        • 4.3.5.3 公共子表达式消除
  • 5. Java内存模型与线程
    • 5.1 概述
    • 5.2 硬件的效率与一致性
    • 5.3 Java内存模型
    • 5.4 主内存与工作内存
      • 5.4.1 内存间交互操作
      • 5.4.2 对于volatile型变量的特殊规则
        • 5.4.2.1 针对long和double型变量的特殊规则
        • 5.4.2.2 原子性、可见性与有序性
          • 5.4.2.2.1 原子性(Atomicity)
          • 5.4.2.2.2 可见性(Visibility)
          • 5.4.2.2.3 有序性(Ordering)
    • 5.5 Java与线程
      • 5.5.1 线程的实现
        • 5.5.1.1 内核线程实现
        • 5.5.1.2 用户线程实现
        • 5.5.1.3 混合实现
      • 5.5.2 Java线程的实现
        • 5.5.2.1 Java线程调度
        • 5.5.2.2 状态转换
        • 5.5.2.3 Java与协程
        • 5.5.2.4 内核线程的局限
        • 5.5.2.5 协程的复苏
      • 5.5.3 线程安全与锁优化
        • 5.5.3.1 线程安全
        • 5.5.3.2 Java语言中的线程安全
          • 5.5.3.2.1 不可变性
          • 5.5.3.2.2 绝对线程安全
          • 5.5.3.2.3 相对线程安全
          • 5.5.3.2.4 线程兼容
          • 5.5.3.2.5 线程对立
          • 5.5.3.2.6 线程安全的实现方法-互斥同步
          • 5.5.3.2.7 线程安全的实现方法-非阻塞同步
          • 5.5.3.2.8 线程安全的实现方法-无同步方案
  • 6. 附录
    • 6.1 获取openJdk源码

1. 走近Java

1.1 概述

Java不仅仅是一门编程语言,它还是一个由一系列计算机软件和规范组成的技术体系,这个技术体系提供了完整的用于软件开发和跨平台部署的支持环境,并广泛应用于嵌入式系统、移动终端、企业服务器、大型机等多种场合,如下图所示。

Java能获得如此广泛的认可,除了它拥有一门结构严谨、面向对象的编程语言之外,还有许多不可忽视的优点:

  • 它摆脱了硬件平台的束缚,实现了一次编写,到处运行的理想;
  • 它提供了一种相对安全的内存管理和访问机制,避免了绝大部分内存泄漏和指针越界问题;
  • 它实现了热点代码检测和运行时编译及优化,这使得Java应用能随着运行时间的增长而获得更高的性能;
  • 它有一套完善的应用程序接口,还有无数来自商业机构和开源社区的第三方类库来帮助用户实现各种各样的功能……

1.2 Java的技术体系

从广义上讲,Kotlin、Clojure、JRuby、Groovy等运行于Java虚拟机上的编程语言及其相关的程序都属于Java技术体系中的一员。
如果仅从传统意义上来看,JCP官方所定义的Java技术体系包括了以下几个组成部分:

  • Java程序设计语言
  • 各种硬件平台上的Java虚拟机实现
  • Class文件格式
  • Java类库API
  • 来自商业机构和开源社区的第三方Java类库

Java程序设计语言、Java虚拟机、Java类库这三部分统称为JDK(Java Development Kit)

JDK是用于支持Java程序开发的最小环境,可以把Java类库API中的Java SE ApI子集和Java虚拟机这两部分统称为JRE(Java Runtime Environment),JRE是支持Java程序运行的标准环境。
如图所示:

以上是根据Java各个组成部分的功能来进行划分

如果按照技术所服务的领域来划分,那Java技术体系可以分为以下四条主要的产品线:

  • Java Card:支持Java小程序(Apolets)运行在小内存设备(如智能卡)上的平台。
  • Java ME(Micro Edition):支持Java程序运行在移动终端(手机、PDA)上的平台,对JavaAPl有所精简,并加入了移动终端的针对性支持,这条产品线在JDK6以前被称为J2ME。有一点读者请勿混淆,现在在智能手机上非常流行的、主要使用Java语言开发程序的Android并不属于Java ME。
  • Java SE(Standard Edition):支持面向桌面级应用(如Windows下的应用程序)的Java平台,提供了完整的Java核心API,这条产品线在JDK6以前被称为J2SE。
  • Java EE(Enterprise Edition):支持使用多层架构的企业应用(如ERP、MIS、CRM应用)的Java平台,除了提供JavaSE API外,还对其做了大量有针对性的扩充,并提供了相关的部署支持,这条产品线在JDK6以前被称为J2EE,在JDK10以后被0racle放弃,捐献给Eclipse基金会管理,此后被称为Jakarta EE。

1.3 Java的发展史


从Java的第一个版本诞生到现在已经有二十余年的时间,现在再来回顾一下Java的发展轨迹:

  • 1991年4月,由James Gosling博士领导的绿色计划(Green Project)开始启动,此计划最初的目标是开发一种能够在各种消费性电子产品(如机顶盒、冰箱、收音机等)上运行的程序架构。这个计划的产品就是java语言的前身:Oak(得名于James Gosling办公室外的一棵橡树)。Oak当时在消费品市场上并不算成功,但随着1995年互联网潮流的兴起,Oak迅速找到了最适合自己发展的市场定位并蜕变成为Java语言。

  • 1995年5月23日,Oak语言改名为Java,并且在SunWorld大会上正式发布Java 1.0版本。Java语言第一次提出了“Write Once,Run Anywhere”的口号。

  • 1996年1月23日,JDK1.0发布,Java语言有了第一个正式版本的运行环境。JDK1.0提供了一个纯解释执行的Java虚拟机实现(Sun Classic VM)。JDK1.0版本的代表技术包括:Java虚拟机、Applet、AWT等。

  • 1996年4月,十个最主要的操作系统和计算机供应商声明将在其产品中嵌入Java技术。同年9月,已有大约8.3万个网页应用了Java技术来制作。在1996年5月底,Sun于美国旧金山举行了首届JavaOne大会,从此JavaOne成为全世界数百万Java语言开发者每年一度的技术盛会。

  • 1997年2月19日,Sun公司发布了J DK1.1,Java里许多最基础的技术支撑点(如JDBC等)都是在JDK1.1版本中提出的,JDK1.1版的技术代表有:JAR文件格式、JDBC、JavaBeans、RMI等。Java语言的语法也有了一定的增强,如内部类(Inner Class)和反射(Reflection)都是在这时候出现的。

  • 直到1999年4月8日,JDK1.1一共发布了1.1.0至1.1.8这9个版本。从1.1.4以后,每个JDK版本都有一个属于自己的名字(工程代号),分别为:JDK1.1.4-Sparkler(宝石)、JDK1.1.5-Pumpkin(南瓜)、JDK1.1.6-Abigail(阿比盖尔,女子名)、JDK1.1.7-Brutus(布鲁图,古罗马政治家和将军)和JDK 1.1.8-Chelsea(切尔西,城市名)。

  • 1998年12月4日,JDK迎来了一个里程碑式的重要版本:工程代号为Playground(竞技场)的JDK1.2,Sun在这个版本中把Java技术体系拆分为三个方向,分别是面向桌面应用开发的J2SE(Java 2Platform,Standard Edition)、面向企业级开发的J2EE(Java 2 Platform,Enterprise Edition)和面向手机等移动终端开发的J2ME(Java2Platform,Micro Edition)。在这个版本中出现的代表性技术非常多,如EJB、Java Plugin、Java IDL、Swing等,并且这个版本中Java虚拟机第一次内置了JIT(Just In Time)即时编译器(JDK1.2中曾并存过三个虚拟机,Classic VM、HotSpot VM和Exact VM,其中Exact VM只在Solaris平台出现过;后面两款虚拟机都是内置了JIT即时编译器的,而之前版本所带的Classic VM只能以外挂的形式使用即时编译器)。在语言和API层面上,Java添加了strictfp关键字,Java类库添加了现在Java编码之中极为常用的一系列Collections集合类等。在1999年3月和7月,分别有JDK1.2.1和JDK1.2.2两个小升级版本发布。

  • 1999年4月27日,HotSpot虚拟机诞生。HotSpot最初由一家名为“Long-view Technologies”的小公司开发,由于HotSpot的优异表现,这家公司在1997年被Sun公司收购。HotSpot虚拟机刚发布时是作为JDK1.2的附加程序提供的,后来它成为JDK1.3及之后所有JDK版本的默认Java虚拟机

  • 2000年5月8日,工程代号为Kestrel(美洲红隼)的JDK1.3发布。相对于JDK1.2,JDK1.3的改进主要体现在Java类库上(如数学运算和新的Timer API等),JNDI服务从JDK1.3开始被作为一项平台级服务提供(以前JNDI仅仅是一项扩展服务),使用CORBAIIOP来实现RMI的通信协议,等等。这个版本还对Java2D做了很多改进,提供了大量新的Java2DAPI,并且新添加了JavaSound类库。JDK1.3有1个修正版本JDK1.3.1,工程代号为Ladybird(瓢虫),于2001年5月17日发布。

  • 自从JDK1.3开始,Sun公司维持着稳定的研发节奏:大约每隔两年发布一个JDK的主版本,以动物命名,期间发布的各个修正版本则以昆虫作为工程代号。

  • 2002年2月13日,JDK1.4发布,工程代号为Merlin(灰背隼)。JDK1.4是标志着Java真正走向成熟的一个版本,Compaq、Fujitsu、SAS、Symbian、IBM等著名公司都有参与功能规划,甚至实现自己独立发行的JDK1.4。哪怕是在近二十年后的今天,仍然有一些主流应用能直接运行在JDK1.4之上,或者继续发布能运行在1.4上的版本。JDK1.4同样带来了很多新的技术特性,如正则表达式、异常链、NIO、日志类、XML解析器和XSLT转换器,等等。JDK1.4有两个后续修正版:2002年9月16日发布的工程代号为Grasshopper(蚱蜢)的JDK1.4.1与2003年6月26日发布的工程 代号为Mantis(螳螂)的JDK1.4.2。

  • 2002年前后还发生了一件与Java没有直接关系,但事实上对Java的发展进程影响很大的事件,就是微软的.NET Framework发布。这个无论是技术实现还是目标用户上都与Java有很多相近之处的技术平台给Java带来了很多讨论、比较与竞争,.NET平台和Java平台之间声势浩大的孰优孰劣的论战到今天为止都仍然没有完全平息。

  • 2004年9月30日,JDK5发布,工程代号为Tiger(老虎)。Sun公司从这个版本开始放弃了谦逊的“JDK1.x”的命名方式,将产品版本号修改成了“JDKx”。从JDK1.2以来,Java在语法层面上的变动一直很小,而JDK5在Java语法易用性上做出了非常大的改进。如:自动装箱、泛型、动态注解、枚举、可变长参数、遍历循环(foreach循环)等语法特性都是在JDK5中加入的。在虚拟机和API层面上,这个版本改进了Java的内存模型(Java Memory Model,JMM)、提供了java.util.concurrent并发包等。另外,JDK5是官方声明可以支持Windows 9x操作系统的最后一个JDK版本。

  • 2006年12月11日,JDK6发布,工程代号为Mustang(野马)。在这个版本中,Sun公司终结了从JDK1.2开始已经有八年历史的J2EE、J2SE、J2ME的产品线命名方式,启用Java EE6、Java SE6、JavaME6的新命名来代替。JDK6的改进包括:提供初步的动态语言支持(通过内置Mozilla JavaScript Rhino引擎实现)、提供编译期注解处理器和微型HTTP服务器API,等等。同时,这个版本对Java虚拟机内部做了大量改进,包括锁与同步、垃圾收集、类加载等方面的实现都有相当多的改动。

  • 在2006年11月13日的JavaOne大会上,Sun公司宣布计划要把Java开源,在随后的一年多时间内,它陆续地将JDK的各个部分在GPLv2(GNU General Public License v2)协议下公开了源码,并建立了OpenJDK组织对这些源码进行独立管理。除了极少量的产权代码(Encumbered Code,这部分代码所有权不属于Sun公司,Sun本身也无权进行开源处理)外,OpenJDK几乎拥有了当时SunJDK7的全部代码,OpenJDK的质量主管曾经表示在JDK7中,SunJDK和OpenJDK除了代码文件头的版权注释之外,代码几乎是完全一样的,所以OpenJDK7与SunJDK7本质上就是同一套代码库出来的产品。

  • JDK6发布以后,由于代码复杂性的增加、Java开源、开发JavaFX、世界经济危机及Oracle对Sun的收购案等原因,Sun公司在发展Java以外的事情上耗费了太多精力和资源,JDK的更新没有能够继续维持两年发布一个主版本的研发速度,这导致了JDK6的生命周期异常的长,一共发布了211个更新升级补丁,最后的版本为Java SE6Update 211于2018年10月18日发布。

  • 2009年2月19日,工程代号为Dolphin(海豚)的JDK7完成了其第一个里程碑版本。按照JDK7最初的功能规划,一共会设置十个里程碑。最后一个里程碑版本原计划定于2010年9月9日结束,但由于各种原因,JDK7最终无法按计划完成。

  • 从JDK7最原始的功能清单来看,它本应是一个包含许多重要改进的JDK版本,其中规划的子项目都为Java业界翘首以盼,包括:

    • Lambda项目:支持Lambda表达式,支持函数式编程。
    • Jigsaw项目:虚拟机层面的模块化支持。
    • 动态语言支持:Java是静态语言,为其他运行在Java虚拟机上的动态语言提供支持。
    • Garbage First收集器。Coin项目:Java语法细节进化。
  • 令人惋惜的是,在JDK7开发期间,Sun公司相继在技术竞争和商业竞争中陷入泥潭,公司的股票市值跌至仅有高峰时期的3%,已无力推动JDK7的研发工作按计划继续进行。为了尽快结束JDK7长期跳票的问题,Oracle收购Sun公司后随即宣布马上实行“B计划”,大幅裁剪了JDK7预定目标,以保证JDK7的正式版能够于2011年7月28日准时发布。“B计划”的主要措施是把不能按时完成的Lambda项目、Jigsaw项目和Coin项目的部分改进延迟到JDK8之中。最终,JDK7包含的改进有:提供新的G1收集器(G1在发布时依然处于Experimental状态,直至2012年4月的Update4中才正式商用)、加强对非Java语言的调用支持(JSR-292,这项特性在到JDK11还有改动)、可并行的类加载架构等。

  • Oracle公司接手了JDK开发工作以后,迅速展现出了完全不同于Sun时期的、极具商业化的处事风格。面对Java中使用最广泛而又一直免费的Java SE产品线,Oracle很快定义了一套新的Java SE Support产品计划,把JDK的更新支持作为一项商业服务。JDK7发布的前80个更新仍然免费面向所有用户提供,但后续的其他更新包,用户但只能从“将Java SE升级到Java SE Support”与“将JDK7升级到最新版本”两个选项里挑一个。JDK7计划维护至2022年,迄今(面向付费用户)已发布了超过两百个更新补丁,最新版本为JDK7 Update221。

  • 对于JDK7,还有一点值得提起的是,从JDK7update4起,JavaSE的核心功能正式开始为MacOSX操作系统提供支持,并在JDK7Uupdate6中达到所有功能与MacOSX完全兼容的程度;同时,JDK7Update6还对ARM指令集架构提供了支持。至此,官方提供的JDK可以运行于Windows(不含Windows 9x)、Linux、Solaris和Mac OSX操作系统上,支持ARM、×86、×86-64和SPARC指令集架构,JDK7也是可以支持Windows Xp操作系统的最后一个版本。

  • 2009年4月20日,Oracle宣布正式以74亿美元的价格收购市值曾超过2000亿美元的Sun公司,传奇的Sun Microsystems从此落幕成为历史,Java商标正式划归Oracle所有(Java语言本身并不属于哪间公司所有,它由JCP组织进行管理,尽管在JCP中Sun及后来的Oracle的话语权很大)。由于此前Oracle已经收购了另外一家大型的中间件企业BEA公司,当完成对Sun公司的收购之后,Oracle分别从BEA和Sun手中取得了世界三大商用虚拟机的其中两个:JRockit和HotSpot。当时Oracle宣布要在未来一至两年的时间内,把这两个优秀的java虚拟机合二为一。两者合并的结果只能说差强人意,JRockit的监控工具Java Mission Control被移植到了HotSpot,作为收费功能提供给购买了Java SEAdvanced产品计划的用户,其他功能由于两者架构的差异性明显,HotSpot能够直接借鉴融合的功能寥寥无几。

  • JDK8的第一个正式版本原定于2013年9月发布,最终还是跳票到了2014年3月18日,尽管仍然是没有赶上正点,但比起JDK7那种以年作为计时单位、直接把公司跳崩的研发状况已是大有改善。为了保证日后JDK研发能更顺利地进行,从JDK8开始,Oracle启用JEP(JDK Enhancement Proposals)来定义和管理纳入新版JDK发布范围的功能特性。JDK8提供了那些曾在JDK7中规划过,但最终未能在JDK7中完成的功能,主要包括:

    • JEP126:对Lambda表达式的支持,这让Java语言拥有了流畅的函数式表达能力。
    • JEP104:内置Nashorn JavaScript引擎的支持。
    • JEP150:新的时间、日期API。
    • JEP122:彻底移除HotSpot的永久代。
  • JDK9的故事…哈哈有兴趣可以探索

1.4 Java虚拟机家族

许多Java程序员都会潜意识地把Java虚拟机与Oracle JDK的HotSpot虚拟机等同看待,也许还有一些程序员会注意到BEAJRockit和IBMJ9虚拟机,但绝大多数人对Java虚拟机的认识就仅限于此了。从1996年初Sun发布的JDK1.0中包含的Sun Classic虚拟机到今天,曾经涌现、湮灭过许多或经典,或优秀,或有特色,或有争议的虚拟机实现,在这一节中,我们仍先把代码与技术放下,一起来回顾Java虚拟机家族的发展轨迹和历史变迁。

1.4.1 虚拟机始祖:Sun Classic/Exact VM

以今天的视角来看,Sun Classic虚拟机的技术已经相当原始,这款虚拟机的使命也早已终结。但仅凭它“世界上第一款商用Java虚拟机”的头衔,就足够有令历史记住它的理由。

  • 1996年1月23日,Sun发布JDK1.0,Java语言首次拥有了商用的正式运行环境,这个JDK中所带的虚拟机就是CIassic VM。这款虚拟机只能使用纯解释器方式来执行Java代码,如果要使用即时编译器那就必须进行外挂,但是假如外挂了即时编译器的话,即时编译器就会完全接管虚拟机的执行系统,解释器便不能再工作了。在JDK1.2及之前,用户用Classic虚拟机执行java -version命令,将会看到类似下面这行的输出:
java version"1.2.21"
Classic VM(build JDK-1.2.2-001, green threads, sunwjit)
  • 其中的“sunwjit”(Sun WorkshopJ IT)就是Sun提供的外挂编译器,其他类似的外挂编译器还有Symantec JIT和shuJIT等。由于解释器和编译器不能配合工作,这就意味着如果要使用编译执行,编译器就不得不对每一个方法、每一行代码都进行编译,而无论它们执行的频率是否具有编译的价值。基于程序响应时间的压力,这些编译器根本不敢应用编译耗时稍高的优化技术,因此这个阶段的虚拟机虽然用了即时编译器输出本地代码,其执行效率也和传统的C/C++程序有很大差距,“Java语言很慢”的印象就是在这阶段开始在用户心中树立起来的。
  • Sun的虚拟机团队努力去解决Classic虚拟机所面临的各种问题,提升运行效率,在JDK1.2时,曾在Solaris平台上发布过一款名为ExactVM的虚拟机,它的编译执行系统已经具备现代高性能虚拟机雏形,如热点探测、两级即时编译器、编译器与解释器混合工作模式等。
  • Exact VM因它使用准确式内存管理(Exact Memory Management,也可以叫Non-Con-servative/Accurate Mem-ory Management)而得名。准确式内存管理是指虚拟机可以知道内存中某个位置的数据具体是什么类型。譬如内存中有一个32bt的整数123456,虚拟机将有能力分辨出它到底是一个指向了123456的内存地址的引用类型还是一个数值为123456的整数,准确分辨出哪些内存是引用类型,这也是在垃圾收集时准确判断堆上的数据是否还可能被使用的前提。由于使用了准确式内存管理,Exact VM可以抛弃掉以前Classic VM基于句柄(Handle)的对象查找方式(原因是垃圾收集后对象将可能会被移动位置,如果地址为123456的对象移动到654321,在没有明确信息表明内存中哪些数据是引用类型的前提下,那虚拟机肯定是不敢把内存中所有为123456的值改成654321的,所以要使用句柄来保持引用值的稳定),这样每次定位对象都少了一次间接查找的开销,显著提升执行性能。
  • 虽然Exact VM的技术相对Classic VM来说先进了许多,但是它的命运显得十分英雄气短,在商业应用上只存在了很短暂的时间就被外部引进的HotSpot VM所取代,甚至还没有来得及发布Windows和Linux平台下的商用版本。而Classic VM的生命周期则相对要长不少,它在JDK1.2之前是JDK中唯一的虚拟机,在JDK1.2时,它与HotSpotVM并存,但默认是使用Classic VM(用户可用java -hotspot参数切换至HotSpot VM),而在JDK1.3时,HotSpot VM成为默认虚拟机,它仍作为虚拟机的“备用选择”发布(使用java-classic参数切换),直到JDK1.4的时候,Classic VM才完全退出商用虚拟机的历史舞台,与Exact VM一起进入了Sun Labs Research VM之中。

1.4.2 武林盟主:HotSpot VM

  • 相信所有Java程序员都听说过HotSpot虚拟机,它是Sun/OracleJDK和OpenJDK中的默认Java虚拟机,也是目前使用范围最广的Java虚拟机。但不一定所有人都知道的是,这个在今天看起来“血统纯正”的虚拟机在最初并非由Sun公司所开发,而是由一家名为“Longview Technologies”的小公司设计;甚至这个虚拟机最初并非是为Java语言而研发的,它来源于Strongtalk虚拟机,而这款虚拟机中相当多的技术又是来源于一款为支持Self语言实现“达到C语言50%以上的执行效率”的目标而设计的Self虚拟机,最终甚至可以追溯到20世纪80年代中期开发的BerkeleySmalltalk上。Sun公司注意到这款虚拟机在即时编译等多个方面有着优秀的理念和实际成果,在1997年收购了Longview Technologies公司,从而获得了HotSpot虚拟机。

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

    • 解释: 编译器和解释器的作用和区别

      • Java编译器:将Java源文件,也就是.java文件编译成字节码.class文件(二进制字节码文件)

      • Java解释器:就是把java虚拟机上运行的.class字节码解释成机器指令,让CPU识别运行。Java解释器用来解释执行Java编译器编译后的.class文件。

      • 当程序需要首次启动和执行的时候,解释器可以首次发挥作用,一行一行代码的直接转义执行,但是效率低下。当多次调用方法或者循环体的时候,JIT(即时编译器)就可以发挥作用,把越来越多的代码编译成本地机器码,之后可以获得更高的效率

        JIT是即时编译器 – 在执行本机之前,将给定的字节码指令序列编译为运行时的机器码。以方法为单位,一次性的将整个方法的字节码编译成本地机器码,机器码供CPU运行。它的主要目的是在性能上做大量的优化。

  • 2006年,Sun陆续将SunJDK的各个部分在GPLv2协议下开放了源码,形成了Open-JDK项目,其中当然也包括HotSpot虚拟机。HotSpot从此成为Sun/OracleJDK和OpenJDK两个实现极度接近的JDK项目的共同虚拟机。Oracle收购Sun以后,建立了HotRockit项目来把原来BEAJRockit中的优秀特性融合到HotSpot之中。到了2014年的JDK8时期,里面的HotSpot就已是两者融合的结果,HotSpot在这个过程里移除掉永久代,吸收了JRockit的Java Mission Contro监控工具等功能。

  • 得益于Sun/OracleJDK在Java应用中的统治地位,HotSpot理所当然地成为全世界使用最广泛的Java虚拟机,是虚拟机家族中毫无争议的“武林盟主”。

1.4.3 展望Java技术的未来

今天的Java正处于机遇与挑战并存的时期,Java未来能否继续壮大发展,某种程度上取决于如何应对当下已出现的挑战,本文将按照这个脉络来组织,向读者介绍现在仍处于Oracle Labs中的GraalVM、Valhalla、Amber、Loom、Panama等面向未来的研究项目。

  • 无语言倾向
    网上每隔一段时间就能见到几条“未来X语言将会取代Java”的新闻,此处“x”可以用Kotlin、Golang、Dart、JavaScript、Python等各种编程语言来代入。这大概就是长期占据编程语言榜单第一位健的烦恼,天下第一总避免不了挑战者相伴。
    如果Java有拟人化的思维,它应该从来没有惧怕过被哪一门语言所取代,Java“天下第一”的底气不在于语法多么先进好用,而是来自它庞大的用户群和极其成熟的软件生态,这在朝夕之间难以撼动。不过,既然有那么多新、旧编程语言的兴起躁动,说明必然有其需求动力所在,譬如互联网之于JavaScript、人工智能之于Python,微服务风潮之于Golang等。大家都清楚不太可能有哪门语言能在每一个领域都尽占优势,Java已是距离这个目标最接近的选项,但若“天下第一”还要百尺竿头更进一步的话,似乎就只能忘掉Java语言本身,踏入无招胜有招的境界。
    2018年4月,Oracle Labs新公开了一项黑科技:Graal VM,从它的口号“Run Programs Faster Anywhere”就能感觉到一颗蓬勃的野心,这句话显然是与1995年Java刚诞生时的“Write Once,Run Anywhere”在遥相呼应。Graal VM被官方称为“universal V M”和“polyglot VM”,这是一个在HotSpot虚拟机基础上增强而成的跨语言全栈虚拟机,可以作为“任何语言”的运行平台使用,这里“任何语言”包括了Java、Scala、Groovy、Kotlin等基于Java虚拟机之上的语言,还包括了C、C++、Rust等基于LLVM的语言,同时支持其他像JavaScript、Ruby、Python和R语言等。Graal VM可以无额外开销地混合使用这些编程语言,支持不同语言中混用对方的接口和对象,也能够支持这些语言使用已经编写好的本地库文件。

    Graal VM的基本工作原理是将这些语言的源代码(例如JavaScript)或源代码编译后的中间格式(例如LLVM字节码)通过解释器转换为能被Graal VM接受的中间表示(Intermediate Representation,IR),譬如设计一个解释器专门对LLVM输出的字节码进行转换来支持C和C++语言,这个过程称为程序特化(Specialized,也常被称为Partial Ev-aluation)。Graal VM提供了Truffle工具集来快速构建面向一种新语言的解释器,并用它构建了一个称为Sulong的高性能LLVM字节码解释器。
    从更严格的角度来看,Graal VM才是真正意义上与物理计算机相对应的高级语言虚拟机,理由是它与物理硬件的指令集一样,做到了只与机器特性相关而不与某种高级语言特性相关。Oracle Labs的研究总监Thomas Wuerthinger在接受InfoQ采访时谈到:“随着GraalVM1.0的发布,我们已经证明了拥有高性能的多语言虚拟机是可能的,并且实现这个目标的最佳方式不是通过类似Java虚拟机和微软CLR那样带有语言特性的字节码。”对于一些本来就不以速度见长的语言运行环境,由于GraalVM本身能够对输入的中间表示进行自动优化,在运行时还能进行即时编译优化,因此使用Graal VM实现往往能够获得比原生编译器更优秀的执行效率,譬如Graal.js要优于Node.js,Graal.Python要优于CPtyhon,TruffleRuby要优于Ruby MRI,FastR要优于R语言等。
    对Java而言,Graal VM本来就是在HotSpot基础上诞生的,天生就可作为一套完整的符合JavaSE8标准的Java虚拟机来使用。它和标准的HotSpot的差异主要在即时编译器上,其执行效率、编译质量目前与标准版的HotSpot相比也是互有胜负。但现在Oracle Labs和美国大学里面的研究院所做的最新即时编译技术的研究全部都迁移至基于Graal VM之上进行了,其发展潜力令人期待。如果Java语言或者HotSpot虚拟机真的有被取代的一天,那从现在看来Graal VM是希望最大的一个候选项,这场革命很可能会在Java使用者没有明显感觉的情况下悄然而来,Java世界所有的软件生态都没有发生丝毫变化,但天下第一的位置已经悄然更迭。
  • 新一代即时编译器
    对需要长时间运行的应用来说,由于经过充分预热,热点代码会被HotSpot的探测机制准确定位捕获,并将其编译为物理硬件可直接执行的机器码,在这类应用中Java的运行效率很大程度上取决于即时编译器所输出的代码质量。
    HotSpot虚拟机中含有两个即时编译器,分别是编译耗时短但输出代码优化程度较低的客户端编译器(简称为C1)以及编译耗时长但输出代码优化质量也更高的服务端编译器(简称为C2),通常它们会在分层编译机制下与解释器互相配合来共同构成HotSpot虚拟机的执行子系统
    自JDK10起,HotSpot中又加入了一个全新的即时编译器:Graal编译器,看名字就可以联想到它是来自于前一节提到的Graal VM。Graal编译器是以C2编译器替代者的身份登场的。C2的历史已经非常长了,可以追溯到Cliff Click大神读博士期间的作品,这个由C++写成的编译器尽管目前依然效果拔群,但已经复杂到连Cliff Click本人都不愿意继续维护的程度。而Graal编译器本身就是由Java语言写成,实现时又刻意与C2采用了同一种名为“SeaofNodes”的高级中间表示(High IR)形式,使其能够更容易借鉴C2的优点。Graal编译器比C2编译器晚了足足二十年面世,有着极其充沛的后发优势,在保持输出相近质量的编译代码的同时,开发效率和扩展性上都要显著优于C2编译器,这决定了C2编译器中优秀的代码优化技术可以轻易地移植到Graal编译器上,但是反过来Graal编译器中行之有效的优化在C2编译器里实现起来则异常艰难。这种情况下,Graal的编译效果短短几年间迅速追平了C2,甚至某些测试项中开始逐渐反超C2编译器。Graal能够做比C2更加复杂的优化,如“部分逃逸分析”(Partial Escape Analysis),也拥有比C2更容易使用激进预测性优化(Aggressive Speculative Op timization)的策略,支持自定义的预测性假设等。
    今天的Graal编译器尚且年幼,还未经过足够多的实践验证,所以仍然带着“实验状态”的标签,需要用开关参数去激活,这让笔者不禁联想起JDK1.3时代,HotSpot虚拟机刚刚横空出世时的场景,同样也是需要用开关激活,也是作为Classic虚拟机的替代品的一段历史。
    Graal编译器未来的前途可期,作为Java虚拟机执行代码的最新引擎,它的持续改进,会同时为HotSpot与Graal VM注入更快更强的驱动力。

2. 自动内存管理

2.1 概述

  • 对于从事C、C++程序开发的开发人员来说,在内存管理领域,他们既是拥有最高权力的“皇帝”,又是从事最基础工作的劳动人民——既拥有每一个对象的“所有权”,又担负着每一个对象生命从开始到终结的维护责任。
  • 对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题,看起来由虚拟机管理内存一切都很美好。不过,也正是因为Java程序员把控制内存的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题将会成为一项异常艰难的工作。
    本小节将从概念上介绍Java虚拟机内存的各个区域,讲解这些区域的作用、服务对象以及其中可能产生的问题,这也是翻越虚拟机内存管理这堵围墙的第一步。

2.2 运行时数据区

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域,如下图所示:

2.2.1 程序计数器

  • 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
  • 由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存
  • 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;
  • 如果正在执行的是本地(Native)方法,这个计数器值则应为空(undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域;

2.2.2 Java虚拟机栈

  • 与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
  • 经常有人把Java内存区域笼统地划分为堆内存(Heap)和栈内存(Stack),这种划分方式直接继承自传统的C、C++程序的内存布局结构,在Java语言里就显得有些粗糙了,实际的内存区域划分要比这更复杂。不过这种划分方式的流行也间接说明了程序员最关注的、与对象内存分配关系最密切的区域是“堆”和“栈”两块。其中,“堆”在稍后笔者会专门讲述,而“栈”通常就是指这里讲的虚拟机栈,或者更多的情况下只是指虚拟机栈中局部变量表部分。局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
  • 这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。请读者注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照1个变量槽占用32个比特、64个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。
  • 在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常

2.2.3 本地方法栈

  • 本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和Out OfMemoryError异常。

2.2.4 Java堆

  • 对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。在《Java虚拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配”,而这里笔者写的“几乎”是指从实现角度来看,随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。
  • Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”(Garbage Collected Heap,幸好国内没翻译成“垃圾堆”)。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现“新生代”“老年代”“永久代”“Eden空间”“FromSurvivor空间”“To Survivor空间”等名词,这些概念在后续章节中还会反复登场亮相,在这里笔者想先说明的是这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》里对Java堆的进一步细致划分。不少资料上经常写着类似于“Java虚拟机的堆内存分为新生代、老年代、永久代、Eden、Survivor……”这样的内容。
    在十年之前(以G1收集器的出现为分界),作为业界绝对主流的HotSpot虚拟机,它内部的垃圾收集器全部都基于“经典分代”来设计,需要新生代、老年代收集器搭配才能工作,在这种背景下,上述说法还算是不会产生太大歧义。但是到了今天,垃圾收集器技术与十年前已不可同日而语,HotSpot里面也出现了不采用分代设计的新垃圾收集器,再按照上面的提法就有很多需要商榷的地方了;
  • 如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。在本章中,我们仅仅针对内存区域的作用进行讨论,Java堆中的上述各个区域的分配、回收等细节将会是下一章的主题。
  • 根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。
    Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

2.2.5 方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。说到方法区,不得不提一下“永久代”这个概念,尤其是在JDK8以前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(PermanentGeneration),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是对于其他虚拟机实现,譬如BEA JRockit、IBMJ9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。但现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了Java应用更容易遇到内存溢出的问题(永久代有-XX:MaxpermSize的上限,即使不设置也有默认大小,而J9和Rockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题),而且有极少数方法(例如String::intern())会因永久代的原因而导致不同虚拟机下有不同的表现。当Oracle收购BEA获得了JRockit的所有权后,准备把JRockit中的优秀功能,譬如Java Mission Control管理工具,移植到HotSpot虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。考虑到HotSpot未来的发展,在JDK6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了JDK7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替,把JDK7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。
《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

2.2.6 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

Java虚拟机对于Class文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池,《Java虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域,不过一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutofMemoryError异常。

2.2.7 直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。

在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/0方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。

这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。 显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

2.3 HotSpot虚拟机对象探秘

介绍完Java虚拟机的运行时数据区域之后,我们大致明白了Java虚拟机内存模型的概况,相信读者了解过内存中放了什么,也许就会更进一步想了解这些虚拟机内存中数据的其他细节,譬如它们是如何创建、如何布局以及如何访问的。对于这样涉及细节的问题,必须把讨论范围限定在具体的虚拟机和集中在某一个内存区域上才有意义。基于实用优先的原则,笔者以最常用的虚拟机HotSpot和最常用的内存区域Java堆为例,深入探讨一下HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。

2.3.1 对象的创建

Java是一门面向对象的编程语言,Java程序运行过程中无时无刻都有对象被创建出来。在语言层面上,创建对象通常(例外:复制、反序列化)仅仅是一个new关键字而已,而在虚拟机中,对象(文中讨论的对象限于普通Java对象,不包括数组和Class对象等)的创建又是怎样一个过程呢?

当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程;

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来

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

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

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。

除如何划分可用空间之外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
解决这个问题有两种可选方案:

  • 一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性
  • 另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。

这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——构造函数,即CIass文件中的<init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说(由字节码流中new指令后面是否跟随invok-especial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行<init>()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

2.3.2 对象的内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
HotSpot虚拟机对象的对象头部分包括两类信息。

  • 第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳
    这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”。
    对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。
    例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向);
  • 对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。
如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。

对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。
由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

2.3.3 对象的访问定位

创建对象自然是为了后续使用该对象,我们的Java程序会通过栈上的reference数据来操作堆上的具体对象
由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄直接指针两种:

如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息;
其结构如图所示。

如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如图所示。

这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。

使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就本讨论的主要虚拟机HotSpot而言,它主要使用第二种方式进行对象访问(有例外情况,如果使用了Shenandoah收集器的话也会有一次额外的转发),但从整个软件开发的范围来看,在各种语言、框架中使用句柄来访问的情况也十分常见。

2.3.4 常见OutOfMemoryError异常

在《Java虚拟机规范》的规定里,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(下文称OOM)异常的可能;

2.3.4.1 Java堆溢出

Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。

  • 限制Java堆的大小不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展),通过参数-XX:+HeapDumpOnOutOf-MemoryError可以让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析。举个案例如下所示:
import java.util.ArrayList;/*** VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError** @author wql* @date 2023/5/6 21:33*/
public class HeapOOM {static class OOMObject {}public static void main(String[] args) {ArrayList<OOMObject> objects = new ArrayList<>();while (true) {objects.add(new OOMObject());}}
}

结果如下:

D:\working\Jdk8\bin\java.exe -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:61238,suspend=y,server=n -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError -javaagent:C:\Users\wangqinglong01\AppData\Local\JetBrains\IntelliJIdea2022.1\captureAgent\debugger-agent.jar -Dfile.encoding=UTF-8 -classpath "D:\working\Jdk8\jre\lib\charsets.jar;D:\working\Jdk8\jre\lib\deploy.jar;D:\working\Jdk8\jre\lib\ext\access-bridge-32.jar;D:\working\Jdk8\jre\lib\ext\cldrdata.jar;D:\working\Jdk8\jre\lib\ext\dnsns.jar;D:\working\Jdk8\jre\lib\ext\jaccess.jar;D:\working\Jdk8\jre\lib\ext\jfxrt.jar;D:\working\Jdk8\jre\lib\ext\localedata.jar;D:\working\Jdk8\jre\lib\ext\nashorn.jar;D:\working\Jdk8\jre\lib\ext\sunec.jar;D:\working\Jdk8\jre\lib\ext\sunjce_provider.jar;D:\working\Jdk8\jre\lib\ext\sunmscapi.jar;D:\working\Jdk8\jre\lib\ext\sunpkcs11.jar;D:\working\Jdk8\jre\lib\ext\zipfs.jar;D:\working\Jdk8\jre\lib\javaws.jar;D:\working\Jdk8\jre\lib\jce.jar;D:\working\Jdk8\jre\lib\jfr.jar;D:\working\Jdk8\jre\lib\jfxswt.jar;D:\working\Jdk8\jre\lib\jsse.jar;D:\working\Jdk8\jre\lib\management-agent.jar;D:\working\Jdk8\jre\lib\plugin.jar;D:\working\Jdk8\jre\lib\resources.jar;D:\working\Jdk8\jre\lib\rt.jar;D:\code\demo-2023\out\production\demo-2023;D:\working\idea2022\IntelliJ IDEA 2022.1.4\lib\idea_rt.jar" com.ooo.HeapOOM
Connected to the target VM, address: '127.0.0.1:61238', transport: 'socket'
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid13144.hprof ...
Heap dump file created [26687608 bytes in 0.096 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap spaceat java.util.Arrays.copyOf(Arrays.java:3210)at java.util.Arrays.copyOf(Arrays.java:3181)at java.util.ArrayList.grow(ArrayList.java:267)at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:241)at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:233)at java.util.ArrayList.add(ArrayList.java:464)at com.ooo.HeapOOM.main(HeapOOM.java:20)
  • Java堆内存的OutOfMemoryError异常是实际应用中最常见的内存溢出异常情况。出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟随进一步提示“Java heap space”。要解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析。
  • 第一步首先应确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。可以使用第三方工具打开的堆转储快照文件。如果是内存泄漏,可进一步通过工具查看泄漏对象到GCRoots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GCRoots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它到GCRoots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内存泄漏的代码的具体位置。
  • 如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

以上是处理Java堆内存问题的简略思路;

2.3.4.2 虚拟机栈和本地方法栈溢出

由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,Xoss参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss参数来设定。关于虚拟机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常

对于不同版本的Java虚拟机和不同的操作系统,栈容量最小值可能会有所限制,这主要取决于操作系统内存分页大小。譬如参数-Xss128k可以正常用于32位Windows系充下的JDK6,但是如果用于64位Windws系统下的JDK11,则会提示栈容量最小不能低于180K,而在Linux下这个值则可能是228K,如果低于这个最小很制,HotSpot虚拟器启动时会给出如下提示:

The Java thread stack size sp ecified is too small.Specify at least 228k

出现StackOverflowError异常时,会有明确错误堆栈可供分析,相对而言比较容易定位到问题所在。如果使用HotSpot虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说大多数情况下)到达1000~2000是完全没有问题,对于正常的方法调用(包括不能做尾递归优化的递归调用),这个深度应该完全够用了。但是,如果是建立过多线程导致的内存溢出,在不能减少线程数量或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。这种通过“减少内存”的手段来解决内存溢出的方式,如果没有这方面处理经验,一般比较难以想到,这一点读者需要在开发32位系统的多线程应用时注意。

2.3.4.3 方法区和运行时常量池溢出

由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。前面曾经提到HotSpot从JDK7开始逐步“去永久代”的计划,并在JDK8中完全使用元空间来代替永久代的背景故事;

String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。在JDK6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可间接限制其中常量池的容量;

案例1:运行时常量池导致的内存溢出(ps: 演示的时候请使用jdk6)


/*** VM Args: -XX:PermSize=6M -XX:MaxPermSize=6M** @author wql* @date 2023/5/6 21:33*/
public class RuntimeConstantPoolOOM {public static void main(String[] args) {HashSet<String> set = new HashSet<>();short i = 0;while (true){set.add(String.valueOf(i++).intern());}}
}

结果如下所示:

Exception in thread "main"java. Lang. OutOfMemoryError:
Per mGen space at java. lang. String. inter n(Native Method)...

从运行结果中可以看到,运行时常量池溢出时,在OutOfMemoryEror异常后面跟随的提示信息是“PermGen space”,说明运行时常量池的确是属于方法区(即JDK6的HotSpot虚拟机中的永久代)的一部分。
而使用JDK7或更高版本的JDK来运行这段程序并不会得到相同的结果,无论是在JDK7中继续使用
-XX:MaxPermSize参数或者在JDK8及以上版本使用-XX:MaxMeta-spaceSize参数把方法区容量同样限制在6MB,也都不会重现JDK6中的溢出异常,循环将一直进行下去,永不停歇。出现这种变化,是因为自JDK7起,原本存放在永久代的字符串常量池被移至Java堆之中,所以在JDK7及以上版本,限制方法区的容量对该测试用例来说是毫无意义;

我们再来看看方法区的其他部分的内容,方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这部分区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出为止。
虽然直接使用Java SE API也可以动态产生类(如反射时的GeneratedConstructorAccessor和动态代理等);

在JDK8以后,永久代便完全退出了历史舞台,元空间作为其替代者登场。在默认设置下,前面列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了

  • -XX:MaxMetaspaceSize
    设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。
  • -XX:MetaspaceSize
    指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。
  • -XX:MinMetaspaceFreeRatio
    作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有一XX:Max-MetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比;

2.3.4.4 本机直接内存溢出

直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致,越过了DirectByteBuffer类直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getunsafe()方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准类库里面的类才能使用unsafe的功能,在JDK10时才将unsafe的部分功能通过VarHandle开放给外部使用),因为虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是Unsafe::allocateMemory()。

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

说起垃圾收集(Garbage Collection,下文简称GC),有不少人把这项技术当作Java语言的伴生产物。事实上,垃圾收集的历史远远比Java久远,在1960年诞生于麻省理工学院的Lisp是第一门开始使用内存动态分配和垃圾收集技术的语言。当Lisp还在胚胎时期时,其作者John McCarthy就思考过垃圾收集需要完成的三件事情:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

经过半个世纪的发展,今天的内存动态分配与内存回收技术已经相当成熟,一切看起来都进入了“自动化”时代,那为什么我们还要去了解垃圾收集和内存分配?答案很简单:当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。
把时间从大半个世纪以前拨回到现在,舞台也回到我们熟悉的Java语言。上面已经介绍了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法楼3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。

而Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竞会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理;

2.4.1 对象已死?

在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)了。

2.4.1.1 引用计数法

很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

客观地说,引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。也有一些比较著名的应用案例,例如微软COM(Component Object Model)技术、使用ActionScript 3的FlashPlayer、Python语言以及在游戏脚本领域得到许多应用的Squirrel中都使用了引用计数算法进行内存管理。但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。

2.4.1.2 可达性分析算法

当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GCRoots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GCRoots到这个对象不可达时,则证明此对象是不可能再被使用的。

如图所示,对象object5、object6、object7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象。

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象
    譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象
    譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象
    譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用
    如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepit-on、Out0fMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMX-Bean、JVMTI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GCRoots集合。

譬如后文将会提到的分代收集和局部回收(Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GCRoots集合中去,才能保证可达性分析的正确性。

2.4.1.3 再谈一谈引用(强软弱虚)

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。

在JDK1.2版之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。

这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显得无能为味,弃之可惜”的对象就显得无能为力。譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象一一很多系统的缓存功能都符合这样的应用场景。

在JDK1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

  • 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值
    即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用,但非必须的对象。
    只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2版之后提供了SoftReference类来实现软引用。
  • 弱引用也是用来描述那些非必须对象
    -但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2版之后提供了WeakReference类来实现弱引用。
  • 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。
    一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2版之后提供了PhantomReference类来实现虚引用。

2.4.1.4 生存还是死亡?

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程

如果对象在进行可达性分析后发现没有与GCRoots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。

  • 假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
  • 如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。
    这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束
    这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。
    finalize()方法是对象逃脱死亡命运的最后一次机会,

稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己—只要重新与引用链上的任何一个对象建立关联即可
譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的fimalize()方法不会被再次执行;

2.4.1.5 回收方法区

有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载),方法区垃圾收集的“性价比”通常也是比较低的:
在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收团于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。

方法区的垃圾收集主要回收两部分内容:废弃的常量不再使用的类型
回收废弃常量与回收Java堆中的对象非常类似。
举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。
常量池中其他类(接口)、方法、字段的符号引用也与此类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景
    如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。
关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,
还可以使用-verbose:class以及-XX:+TraceClass-Loading、-XX:+TraceClassunLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,
XX:+TraceClassunLoading参数需要FastDebug版的虚拟机支持。
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

2.4.2 垃圾回收算法

垃圾收集算法的实现涉及大量的程序细节,且各个平台的虚拟机操作内存的方法都有差异,在本节中我们暂不过多讨论算法实现,只重点介绍分代收集理论和几种算法思想及其发展过程。如果读者对其中的理论细节感兴趣,推荐阅读Richard Jones撰写的《垃圾回收算法手册》的第2~4章的相关内容;

  • 分代收集理论
    当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

    • 弱分代假说(Weak Generational Hypothesis)
      绝大多数对象都是朝生夕灭的。
    • 强分代假说(Strong Generatio-nal Hypothesis)
      熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域–因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。

把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。
顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。如果读者有兴趣阅读HotSpot虚拟机源码的话,会发现里面存在着一些名为“*Generation”的实现,如“DefNewGeneration”和“ParNewGeneration”等,这些就是HotSpot的“分代式垃圾收集器框架;

假如要现在进行一次只局限于新生代区域内的收集(MinorGC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GCRoots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:

  • 跨代引用假说(Intergenerational Reference Hypothesis)
    跨代引用相对于同代引用来说仅占极少数。

这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。
举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。
此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
一些名词解释:

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

    • 新生代收集(Minor GC/Young GC)
      指目标只是新生代的垃圾收集。
    • 老年代收集(Major GC/0ldG C)
      指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
    • 混合收集(MixedGC)
      指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
    • 整堆收集(FullGC)
      收集整个Java堆和方法区的垃圾收集。

2.4.2.1 标记清除-算法

最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,在1960年由Lisp之父John McCarthy所提出。
如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
标记过程就是对象是否属于垃圾的判定过程;
之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。
它的主要缺点有两个:

  • 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  • 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-清除算法的执行过程如图所示。

2.4.2.2 标记复制-算法

标记-复制算法常被简称为复制算法。
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

标记-复制算法的执行过程如图所示。

现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代
IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释一新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1:1的比例来划分新生代的内存空间。

在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”。

HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。
Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。

发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survi-vor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活

因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次MinorGC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)

内存的分配担保好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有什么风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

2.4.2.3 标记整理-算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,“标记-整理”算法的示意图如图所示。

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:

如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”。

但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器内存访问器来解决。

譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。

此语境中,吞吐量的实质是赋值器(Mutator,可以理解为使用垃圾收集的用户程序,本文为便于理解,多数地方用“用户程序”或“用户线程”代替)与收集器的效率总和。即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。

HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,这也从侧面印证这点。

另外,还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法;

2.4.3 HotSpot的算法细节实现

2.4.3.1 根节点枚举

我们以可达性分析算法中从GCRoots集合找引用链这个操作作为介绍虚拟机高效实现的第一个例子。固定可作为GCRoots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情,现在Java应用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,若要逐个检查以这里为起源的引用肯定得消耗不少时间。

迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰

现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行,这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。

这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的CMS、G1、ZGC等收集器,枚举根节点时也是必须要停顿的

由于目前主流Java虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。

在HotSpot的解决方案里,是使用一组称为Oop-Map的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。

2.4.3.2 安全点

在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。

实际上HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)。

有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。

安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点

对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括执行中但没有分配处理器时间调用的线程)都跑到最近的安全点,然后停顿下来。

这里有两种方案可供选择:

  • 抢先式中断(Preemptive Suspension)
    抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
  • 主动式中断(Voluntary Suspension)
    而主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。
    轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

2.4.3.3 安全区域

使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况却并不一定。
安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。
但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。
对于这种情况,就必须引入安全区域(Safe Region)来解决。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。
当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

2.4.3.4 记忆集和卡表

讲解分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GCRoots扫描范围。
事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(PartialGC)行为的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临相同的问题,因此我们有必要进一步理清记忆集的原理和实现方式,以便更好地理解。

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构
如果我们不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构;

这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。
而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。

那设计者在实现记忆集的时候,便可以选择更为粗扩的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:

  • 字长精度
    每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  • 对象精度
    每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度
    每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式,一些资料中甚至直接把它和记忆集混为一谈。

前面定义中提到记忆集其实是一种“抽象”的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。
卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等
关于卡表与记忆集的关系,读者不妨按照Java语言中HashMap与Map的关系来类比理解。

卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机确实也是这样做的。以下这行代码是HotSpot默认的卡表标记逻辑:

CARD_TABLE [this address>>9]=0;

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。

一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。
那如果卡表标识内存区域的起始地址是0×0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000 ~ 0×01FF、0x0200 ~ 0×03FF、0X0400~0×05FF的卡页内存块,如图所示。

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。
在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

2.4.3.5 写屏障

我们已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。

卡表元素何时变脏的答案是很明确的–有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。

但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?
假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;

但在编译执行的场景中呢?
经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。

在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。
先请读者注意将这里提到的“写屏障”,以及后面在低延迟收集器中会提到的“读屏障”与解决并发乱序执行问题中的“内存屏障”区分开来,避免混淆。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。

在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(post-Write Barrier)

HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障;

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。

除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。
伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题

假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏;

在JDK7之后,HotSpot虚拟机增加了一个新的参数-XX:+useCondCardMark,用来决定是否开启卡表更新的条件判断。
开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

2.4.3.6 并发的可达性分析

曾经提到了当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。在根节点枚举这个步骤中,由于GCRoots相比起整个Java堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。可从GCRoots再继续往下遍历对象图,这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,这听起来是理所当然的事情。

要知道包含“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够削减这部分停顿时间的话,那收益也将会是系统性的。

想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?为了能解释清楚这个问题,我们引入三色标记(Tricolor Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

关于可达性分析的扫描过程,读者不妨发挥一下想象力,把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。

但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误,下面演示了这样的致命错误具体是如何产生的。

并发出现”对象消失“ 问题的示意图

Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。
由此分别产生了两种解决方案:增量更新(Incremental up date)和原始快照(Snapshot At The Be ginning,SATB)。

  • 增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
  • 原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。
在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的G1、Shenandoah则是用原始快照来实现

2.4.4 经典的垃圾回收器

如果说收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。
《Java虚拟机规范》中对垃圾收集器应该如何实现并没有做出任何规定,因此不同的厂商、不同版本的虚拟机所包含的垃圾收集器都可能会有很大差别,不同的虚拟机一般也都会提供各种参数供用户根据自己的应用特点和要求组合出各个内存分代所使用的收集器。

本节标题中“经典”二字并非情怀,它其实是讨论范围的限定语,这里讨论的是在JDK7Update4之后(在这个版本中正式提供了商用的G1收集器,此前G1仍处于实验状态)、JDK11正式发布之前,OracleJDK中的HotSpot虚拟机所包含的全部可用的垃圾收集器。使用“经典”二字是为了与几款目前仍处于实验状态,但执行效果上有革命性改进的高性能低延迟收集器区分开来,这些经典的收集器尽管已经算不上是最先进的技术,但它们曾在实践中千锤百炼,足够成熟,基本上可认为是现在到未来两、三年内,能够在商用生产环境上放心使用的全部垃圾收集器了。各款经典收集器之间的关系如图所示。

HotSpot虚拟机的垃圾回收器

图展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,
图中收集器所处的区域,则表示它是属于新生代收集器或是老年代收集器。

在介绍这些收集器各自的特性之前,让我们先来明确一个观点:虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来,虽然垃圾收集器的技术在不断进步,但直到现在还没有最好的收集器出现,更加不存在“万能”的收集器,所以我们选择的只是对具体应用最合适的收集器。

这点不需要多加论述就能证明:如果有一种放之四海皆准、任何场景下都适用的完美收集器存在,HotSpot虚拟机完全没必要实现那么多种不同的收集器了。

2.4.4.1 Serial收集器

Serial收集器是最基础、历史最悠久的收集器

曾经(在JDK1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择。
大家只看名字就能够猜到,这个收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

“Stop The World”这个词语也许听起来很酷,但这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是不能接受的。

读者不妨试想一下,要是你的电脑每运行一个小时就会暂停响应五分钟,你会有什么样的心情?
下图示意了Serial/Serial Old收集器的运行过程。

对于“Stop The World”带给用户的恶劣体验,早期HotSpot虚拟机的设计者们表示完全理解,但也同时表示,非常委屈:“你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上或者房间外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完?”这确实是一个合情合理的矛盾,虽然垃圾收集这项工作听起来和打扫房间属于一个工种,但实际上肯定还要比打扫房间复杂得多!

从JDK1.3开始,一直到现在最新的JDK20,HotSpot虚拟机开发团队为消除或者降低用户线程因垃圾收集而导致停顿的努力一直持续进行着,从Serial收集器到Parallel收集器,再到Concurrent Mark Sweep(CMS)和Garbage First(G1)收集器,最终至现在垃圾收集器的最前沿成果Shenandoah和ZGC等,

我们看到了一个个越来越构思精巧,越来越优秀,也越来越复杂的垃圾收集器不断涌现,用户线程的停顿时间在持续缩短,但是仍然没有办法彻底消除(这里不去讨论RTSJ中的收集器),探索更优秀垃圾收集器的工作仍在继续。

写到这里,笔者似乎已经把Serial收集器描述成一个最早出现,但目前已经老而无用,食之无味,弃之可惜的“鸡肋”了

但事实上,迄今为止,它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比)

  • 对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;
  • 对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以
Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择

2.4.4.2 ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本

除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数
(例如:-XX:SurvivorRatio、-XX:Pr etenureSize Threshold、-XX:HandlepromotionFailure等)、
收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

ParNew收集器的工作过程如下图示。

ParNew收集器除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK7之前的遗留系统中首选的新生代收集器

其中有一个与功能、性能无关但其实很重要的原因是:除了Seril收集器外,目前只有它能与CMS收集器配合工作

在JDK5发布时,HotSpot推出了一款在强交互应用中几乎可称为具有划时代意义的垃圾收集器——CMS收集器。这款收集器是HotSpot虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。

遗憾的是,CMS作为老年代的收集器,却无法与JDK1.4.0中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。

ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweepGC选项)的默认新生代收集器,也可以使用-XX:+/-useparNewGC选项来强制指定或者禁用它。

可以说直到CMS的出现才巩固了parNew的地位,但成也萧何败也萧何,随着垃圾收集器技术的不断改进,更先进的G1收集器带着CMS继承者和替代者的光环登场。
G1是一个面向全堆的收集器,不再需要其他新生代收集器的配合工作。所以自JDK9开始,ParNew加CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了。

官方希望它能完全被G1所取代,甚至还取消了ParNew加Serial Old以及Serial加CMS这两组收集器组合的支持(其实原本也很少人这样使用),并直接取消了-XX:+useParNewGC参数,这意味着ParNew和CMS从此只能互相搭配使用,再也没有其他收集器能够和它们配合了。

读者也可以理解为从此以后,ParNew合并入CMS,成为它专门处理新生代的组成部分。ParNew可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器。

parNew收集器在单核心处理器的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百保证超越Serial收集器。

当然,随着可以被使用的处理器核心数量的增加,ParNew对于垃圾收集时系统资源的高效利用还是很有好处的。
它默认开启的收集线程数与处理器核心数量相同,在处理器核心非常多(譬如32个,现在CPu都是多核加超线程设计,服务器达到或超过32个逻辑核心的情况非常普遍)的环境中,
可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

注意:
从ParNew收集器开始,后面还将会接触到若干款涉及“并发”和“并行”概念的收集器。
在大家可能产生疑惑之前,有必要先解释清楚这两个名词。并行和并发都是并发编程中的专业名词,在谈论垃圾收集器的上下文语境中,它们可以理解为:

  • 并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
  • 并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

2.4.4.3 Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器
也是能够并行收集的多线程收集器……

Parallel Scavenge的诸多特性从表面上看和ParNew非常相似,那它有什么特别之处呢?
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。

所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:

如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;
而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:Ma XGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

  • -XX:MaxGCPauseMillis参数允许的值是一个大于0的毫秒数
    收集器将尽力保证内存回收花费的时间不超过用户设定值。
    不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
  • -XX:GCTimeRatio参数的值则应当是一个大于0小于100的整数
    也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。
    譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。

由于与吞吐量关系密切,ParallelScavenge收集器也经常被称作“吞吐量优先收集器”。

除上述两个参数之外,Parallel Scavenge收集器还有一个参数-XX:+useAdaptiveSizepolicy值得我们关注。
这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GCErgonomics)。

如果读者对于收集器运作不太了解,手工优化存在困难的话,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成也许是一个很不错的选择。只需要把基本的内存数据设置好(如-Xmx设置最大堆),
然后使用-XX:MaxGCPauseMills参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性

2.4.4.4 Serial Old 收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。

如果在服务端模式下,它也可能有两种用途:

  • 一种是在JDK5以及之前的版本中与Parallel Scavenge收集器搭配使用
  • 另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

Serial Old收集器的工作过程如图所示。

2.4.4.5 Parallel Old 收集器

Parallel Old 是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

这个收集器是直到JDK6时才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于相当尴尬的状态原因是:

如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(pS MarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作。
由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用Parallel Scavenge收集器也未必能在整体上获得吞吐量最大化的效果。
同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一定比ParNew加CMS的组合来得优秀。直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。

ParallelOld收集器的工作过程如图所示

2.4.4.6 CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

目前很大一部分的Ja va应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。
CMS收集器就非常符合这类应用的需求。

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

  • 初始标记(CMS initial mark)
    初始标记仅仅只是标记一下GCRoots能直接关联到的对象,速度很快;
  • 并发标记(CMS concurrent mark)
    并发标记阶段就是从GCRoots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;
  • 重新标记(CMS remark)
    而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(详见上面中关于增量更新的讲解),这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;
  • 并发清除(CMS concurrent sweep)
    最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

其中初始标记、重新标记这两个步骤仍然需要“Stop The World"

由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作
所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

通过图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的阶段。

CMS是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,一些官方公开文档里面也称之为“并发低停顿收集器”(Concurrent Low Pause Collector)。
CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度;
至少有以下三个明显的缺点:

  • 首先,CMS收集器对处理器资源非常敏感。
    事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。
    CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。
    但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。
    如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。为了缓解这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的CMS收集器变种
    所做的事情和以前单核处理器年代PC机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样,是在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。
    实践证明增量式的CMS收集器效果很一般,从JDK7开始,i-CMS模式已经被声明为“deprecated”,即已过时不再提倡用户使用,到JDK9发布后i-CMS模式被完全废弃。
  • 然后,由于CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Concurrent Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。
    在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。
    同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。
    在JDK5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,
    可以适当调高参数-XX:CMSInitiatingOccu-pa ncyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。
    到了JDK6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。
    所以参数-XX:CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。
  • 最后CMS是一款基于“标记-清除”算法实现的收集器,就可能想到这意味着收集结束时会有大量空间碎片产生。
    空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次FullGC的情况。
    为了解决这个问题,CMS收集器提供了一个 -XX:+useCMS-CompactAtFullCollection开关参数(默认是开启的,此参数从JDK9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的。
    这样空间碎片问题是解决了,但停顿时间又会变长
    因此虚拟机设计者们还提供了另外一个参数-XX:CMSFulIGCsBefore-Compaction(此参数从JDK9开始废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。

2.4.4.7 Garbage First 收集器

Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。

早在JDK7刚刚确立项目目标、Oracle公司制定的JDK7RoadMap里面,G1收集器就被视作JDK7中HotSpot虚拟机的一项重要进化特征。
从JDK6 Update 14开始就有Early Access版本的G1收集器供开发人员实验和试用,但由此开始G1收集器的“实验状态”(Experimental)持续了数年时间,直至JDK7Update4,oracle才认为它达到足够成熟的商用程度,移除了“Experimental”的标识;
到了JDK8Update40的时候,G1提供并发的类卸载的支持,补全了其计划功能的最后一块拼图。
这个版本以后的G1收集器才被0racle官方称为“全功能的垃圾收集器”(Fully-Featured Garbage Collector)。

G1是一款主要面向服务端应用的垃圾收集器。
HotSpot开发团队最初赋予它的期望是(在比较长期的)未来可以替换掉JDK5中发布的CMS收集器。
现在这个期望目标已经实现过半了,JDK9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器。如果对JDK9及以上版本的HotSpot虚拟机使用参数-XX:+useConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃:

Java HotSpot(TM)64-Bit Server VM warning:
Option UseConcMarkSweepGC was deprecated in version 9.0
and will likely b e removed in a future release.

但作为一款曾被广泛运用过的收集器,经过多个版本的开发迭代后,CMS(以及之前几款收集器)的代码与HotSpot的内存管理、执行、编译、监控等子系统都有千丝万缕的联系,这是历史原因导致的,并不符合职责分离的设计原则。为此,规划JDK10功能目标时,HotSpot虚拟机提出了“统一垃圾收集器接口”,将内存回收的“行为”与“实现”进行分离,CMS以及其他收集器都重构成基于这套接口的一种实现。以此为基础,日后要移除或者加入某一款收集器,都会变得容易许多,风险也可以控制,这算是在为CMS退出历史舞台铺下最后的道路了。

作为CMS收集器的替代者和继承人,设计者们希望做出一款能够建立起“停顿时间模型”(Pause Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎已经是实时Java(RTSJ)的中软实时垃圾收集器特征了。

那具体要怎么做才能实现这个目标呢?首先要有一个思想上的改变,在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式

G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。
虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:

G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region中还有一类特殊的Humon-gous区域,专门用来存储大对象。
G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humon-gous Region作为老年代的一部分来进行看待,如图所示。

虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。

G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收是“Garbage First”名字的由来

使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

G1将堆内存“化整为零”的“解题思路”,看起来似乎没有太多令人惊讶之处,也完全不难理解,但其中的实现细节可是远远没有想象中那么简单,否则就不会从2004年Sun实验室发表第一篇关于G1的论文后一直拖到2012年4月JDK7Update4发布,用将近10年时间才倒腾出能够商用的G1收集器来。
G1收集器至少有(不限于)以下这些关键的细节问题需要妥善解决:

  • 譬如,将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?

    • 解决的思路我们已经知道(见上文):使用记忆集避免全堆作为GCRoots扫描,但在G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。
      G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。
  • 譬如,在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
    • 这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,该问题的解决办法笔者已经抽出独立小节来讲解过:CMS收集器采用增量更新算法实现,而G1收集器则是通过原始快照(SATB)算法来实现的
      此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分 出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。
      G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中的“Concurrent Mode Failure”失败会导致FullGC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“StopThe World”。
  • 譬如,怎样建立起可靠的停顿预测模型?
    • 用户通过一XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但G1收集器要怎么做才能满足用户的期望呢?
      G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。
      这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

如果我们不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1收集器的运作过程大致可划分为以下四个步骤:

  • 初始标记(Initial Marking):
    仅仅只是标记一下GCRoots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 并发标记(Concurrent Marking):
    从GCRoot开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
  • 最终标记(Final Marking):
    对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  • 筛选回收(Live Data Counting and Evacuation):
    负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

从上述阶段的描述可以看出,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的

换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望从Oracle官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。

另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。通过图可以比较清楚地看到G1收集器的运作步骤中并发和需要停顿的阶段。

毫无疑问,可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。

不过,这里设置的“期望值”必须是符合实际的,不能异想天开,毕竟G1是要冻结用户线程来复制对象的,这个停顿时间再怎么低也得有个限度。它默认的停顿目标为两百毫秒,一般来说,回收阶段占到几十到一百甚至接近两百毫秒都很正常,但如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发FullGC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率(Allocation Rate),而不追求一次把整个Java堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。这种新的收集器设计思路从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。

G1收集器常会被拿来与CMS收集器互相比较,毕竟它们都非常关注停顿时间的控制,官方资料中将它们两个并称为“The Mostly Concurrent Collectors”。

在未来,G1收集器最终还是要取代CMS的,而当下它们两者并存的时间里,分个高低优劣就无可避免。相比CMS,G1的优点有很多,暂且不论可以指定最大停顿时间、分Region的内存布局、按收益动态确定回收集这些创新性设计带来的红利,单从最传统的算法理论上看,G1也更有发展潜力。

与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。
这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。

不过,G1相对于CMS仍然不是占全方位、压倒性优势的,从它出现几年仍不能在所有应用场景中代替CMS就可以得知这个结论。
比起CMS,G1的弱项也可以列举出不少,如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。

就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;

相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。
在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。

相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。

以上的优缺点对比仅仅是针对G1和CMS两款垃圾收集器单独某方面的实现细节的定性分析,通常我们说哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较。按照笔者的实践经验,目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不同应用需要量体裁衣地实际测试才能得出最合适的结论,随着HotSpot的开发者对G1的不断优化,也会让对比结果继续向G1倾斜。

2.4.4.8 低延迟垃圾 收集器

HotSpot的垃圾收集器从Serial发展到CMS再到G1,经历了逾二十年时间,经过了数百上千万台服务器上的应用实践,已经被淬炼得相当成熟了,不过它们距离“完美”还是很遥远。怎样的收集器才算是“完美”呢?这听起来像是一道主观题,其实不然,完美难以实现,但是我们确实可以把它客观描述出来。

衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角”。三者总体的表现会随技术进步而越来越好,但是要在这三个方面同时具有卓越表现的“完美”收集器是极其困难甚至是不可能的,一款优秀的收集器通常最多可以同时达成其中的两项。

在内存占用、吞吐量和延迟这三项指标里,延迟的重要性日益凸显,越发备受关注。其原因是随着计算机硬件的发展、性能的提升,我们越来越能容忍收集器多占用一点点内存;硬件性能增长,对软件系统的处理能力是有直接助益的,硬件的规格和性能越高,也有助于降低收集器运行时对应用程序的影响,换句话说,吞吐量会更高。但对延迟则不是这样,硬件规格提升,准确地说是内存的扩大,对延迟反而会带来负面的效果,这点也是很符合直观思维的:虚拟机要回收完整的1TB的堆内存,毫无疑问要比回收1GB的堆内存耗费更多时间。由此,我们就不难理解为何延迟会成为垃圾收集器最被重视的性能指标了。现在我们来观察一下现在已接触过的垃圾收集器的停顿状况,如图所示。

图中浅色阶段表示必须挂起用户线程,深色表示收集器线程与用户线程是并发工作的。

由图可见,在CMS和G1之前的全部收集器,其工作的所有步骤都会产生“Stop The World”式的停顿;CMS和G1分别使用增量更新和原始快照技术,实现了标记阶段的并发,不会因管理的堆内存变大,要标记的对象变多而导致停顿时间随之增长。但是对于标记阶段之后的处理,仍未得到妥善解决。CMS使用标记-清除算法,虽然避免了整理阶段收集器带来的停顿,但是清理上避免不了空间碎片的产生,随着空间碎片不断淤积最终依然逃不过“Stop The World”的命运。G1虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的。

读者肯定也从图中注意到了,最后的两款收集器,Shenandoah和ZGC,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。

实际上,它们都可以在任意可管理的(譬如现在ZGC只能管理4TB以内的堆)堆容量下,实现垃圾收集的停顿都不超过十毫秒,这种以前听起来是天方夜谭、匪夷所思的目标。这两款目前仍处于实验状态的收集器,被官方命名为“低延迟垃圾收集器”((Low-Latency Garbage Collector或者Low-Pause-Time Garbage Collector)。

2.4.4.9 垃圾回收器 参数总结

HotSpot虚拟机中的各种垃圾收集器(JDK8之前)到此全部介绍完毕,在描述过程中提到了很多虚拟机非稳定的运行参数,下面整理了这些参数,供读者实践时参考。

2.4.5 内存分配与回收策略

Java技术体系的自动内存管理,最根本的目标是自动化地解决两个问题:自动给对象分配内存以及自动回收分配给对象的内存;

对象的内存分配,从概念上讲,应该都是在堆上分配(而实际上也有可能经过即时编译后被拆散为标量类型并间接地在栈上分配)。

在经典分代的设计下,新生对象通常会分配在新生代中,少数情况下(例如对象大小超过一定阈值)也可能会直接分配在老年代。
对象分配的规则并不是固定的,《Java虚拟机规范》并未规定新对象的创建和存储细节,这取决于虚拟机当前使用的是哪一种垃圾收集器,以及虚拟机中与内存相关的参数的设定。

2.4.5.1 对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次新生代Minor GC。HotSpot虚拟机提供了-XX:+PrinteGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。
在实际的问题排查中,收集器日志常会打印到文件后通过工具进行分析;
-XX:Survivor-Ratio=8决定了新生代中Eden区与一个Survivor区的空间比例是8:1

2.4.5.2 大对象直接进入老年代

大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。

大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,我们写程序的时候应注意避免。

在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。

注意:

  • -XX:PretenureSizeThreshold参数只对Serial和ParNew两款新生代收集器有效,HotSpot的其他新生代收集器,如Parallel Scavenge并不支持这个参数。如果必须使用此参数进行调优,可考虑ParNew加CMS的收集器组合。

2.4.5.3 长期存活的对象将进入老年代

HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,
可以通过参数-XX:MaxTenuringThreshold设置(由于对象头内对象年龄占4位,最大15,所以即使配置20最大还是采用15)。

2.4.5.4 动态对象年龄判定

为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄
必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,
无须等到-XX:MaxTenuringThreshold中要求的年龄。

2.4.5.5 空间分配担保

在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。
如果不成立,则虚拟机会先查看-XX:HandlepromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);
如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,
如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;
如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次FullGC。

解释一下“冒险”是冒了什么风险:前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在MinorGC后仍然存活的情况–最极端的情况就是内存回收后新生代中所有对象都存活,需要老年代进行分配担保,把Survivor无法容纳的对象直接送入老年代;

这与生活中贷款担保类似。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,但一共有多少对象会在这次回收中活下来在实际完成内存回收之前是无法明确知道的,所以只能取之前每一次回收晋升到老年代对象容量的平均大小作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

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

给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处理数据的手段。这里说的数据包括但不限于异常堆栈、虚拟机运行日志、垃圾收集器日志、线程快照(threaddump/javacore文件)、堆转储快照(heapdump/hprof文件)等。

恰当地使用虚拟机故障处理、分析的工具可以提升我们分析数据、定位并解决问题的效率,但我们在学习工具前,也应当意识到工具永远都是知识技能的一层包装,没有什么工具是“秘密武器”,拥有了就能“包治百病”。

2.5.1 基础故障处理工具

Java开发人员肯定都知道JDK的bin目录中有java.exe、javac.exe这两个命令行工具,但并非所有程序员都了解过JDK的bin目录下其他各种小工具的作用。
随着JDK版本的更迭,这些小工具的数量和功能也在不知不觉地增加与增强。除了编译和运行Java程序外,打包、部署、签名、调试、监控、运维等各种场景都可能会用到它们,这些工具如图所示。

在本章,笔者将介绍这些工具中的一部分,主要是用于监视虚拟机运行状态和进行故障处理的工具。这些故障处理工具并不单纯是被Oracle公司作为“礼物”附赠给JDK的使用者

根据软件可用性和授权的不同,可以把它们划分成三类:

  • 商业授权工具:主要是JMC(Java Mission Control)及它要使用到的JFR(Java Flight Recorder),JMC这个原本来自于JRockit的运维监控套件从JDK7Update40开始就被集成到0racleJDK中,JDK11之前都无须独立下载,但是在商业环境中使用它则是要付费的。
  • 正式支持工具:这一类工具属于被长期支持的工具,不同平台、不同版本的JDK之间,这类工具可能会略有差异,但是不会出现某一个工具突然消失的情况等;
  • 实验性工具:这一类工具在它们的使用说明中被声明为“没有技术支持,并且是实验性质的”(Unsup-ported and Experimental)产品,日后可能会转正,也可能会在某个JDK版本中无声无息地消失。但事实上它们通常都非常稳定而且功能强大,也能在处理应用程序性能问题、定位故障时发挥很大的作用。

读者如果比较细心的话,还可能会注意到这些工具程序大多数体积都异常小。假如之前没注意到,现在不妨再看看图中的最后一列“大小”,各个工具的体积基本上都稳定在21KB左右。并非JDK开发团队刻意把它们制作得如此精炼、统一,而是因为这些命令行工具大多仅是一层薄包装而已,真正的功能代码是实现在JDK的工具类库中的,读者把两张图片对比一下就可以看得很清楚。假如读者使用的是Linux版本的JDK,还可以发现这些工具中不少是由Shel脚本直接写成,可以用文本编辑器打开并编辑修改它们。

JDK开发团队选择采用Java语言本身来实现这些故障处理工具是有特别用意的:当应用程序部署到生产环境后,无论是人工物理接触到服务器还是远程Telnet到服务器上都可能会受到限制。借助这些工具类库里面的接口和实现代码,开发者可以选择直接在应用程序中提供功能强大的监控分析功能。

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

jps(JVM Process Status Tool)可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(LVMID,Local Virtual Machine Identifier)。

虽然功能比较单一,但它绝对是使用频率最高的JDK命令行工具,因为其他的JDK工具大多需要输入它查询到的LVMID来确定要监控的是哪一个虚拟机进程。

对于本地虚拟机进程来说,LVMID与操作系统的进程ID(PID,Process Identifier)是一致的,使用Win-
dows的任务管理器或者UNIX的ps命令也可以查询到虚拟机进程的LVMID,但如果同时启动了多个虚拟机进程,无法根据进程名称定位时,那就必须依赖jps命令显示主类的功能才能区分了。

jps命令格式:

jps[options][hostid]

jps执行样例:

jps-12388
D:\Develop\glassfish\bin\..\m odules\admin-cli.jar
2764 com.sun.enterprise.glass fish.bootstrap.ASMain
3788 sun.tools.jps.Jps jps

还可以通过RMI协议查询开启了RMI服务的远程虚拟机进程状态,参数hostid为RMI注册表中注册的主机名;
Jps的其他主要选项:

选项 作用
-q 只输出LVMID,省略主类的名称
-m 输出虚拟机进程启动时传递给主类main()函数的参数
-l 输出主类的全名,如果进程执行的是JAR包,则输出JAR路径
-v 输出虚拟机进程启动时的JVM参数

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

jstat(JVM Statistics Monitoring Tool)是用于监视虚拟机各种运行状态信息的命令行工具

它可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据;

jstat命令格式为:

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

对于命令格式中的VMID与LVMID需要特别说明一下:

  • 如果是本地虚拟机进程,VMID与LVMID是一致的;
  • 如果是远程虚拟机进程,那VMID的格式应当是:
[protocol:][//]lvmid[@hostnam e[:port]/servername]

参数nterval和count代表查询间隔和次数,如果省略这2个参数,说明只查询一次。假设需要每250毫秒查询一次进程2764垃圾收集状况,一共查询20次,那命令应当是:

jstat-gc 2764 250 20

选项option代表用户希望查询的虚拟机信息,主要分为三类:类加载、垃圾收集、运行期编译状况。
Jstat 主要命令如下:

案例如下:

jstat -gcutil  2764
SO    S1    E      O      P     YGC  YGCT   FGC   FGCT     GCT
0.00  0.00  6.20  41.42  47.20  16   0.105   3    0.472   0.577

查询结果表明:这台服务器的新生代Eden区(E,表示Eden)使用了6.2%的空间,2个Survivor区(S0、S1,表示Su rvivor0、Survivor1)里面都是空的,老年代(O,表示Old)和永久代(P,表示Permanent)则分别使用了41.42%和47.20%的空间。程序运行以来共发生Minor GC(YGC,表示Young GC)16次,总耗时0.105秒;发生Full GC(FGC,表示F ull GC)3次,总耗时(FGCT,表示FullGC Time)为0.472秒;所有GC总耗时(GCT,表示GCTime)为0.577秒。

2.5.1.3 jinfo: Java配置信息工具

jinfo(Configuration Info for Java)的作用是实时查看和调整虚拟机各项参数

使用jps命令的-v参数可以查看虚拟机启动时显式指定的参数列表,但如果想知道未被显式指定的参数的系统默认值,除了去找资料外,就只能使用jinfo的-flag选项进行查询了(如果只限于JDK6或以上版本的话,使用java -XX:+PrintFlagsFinal查看参数默认值也是一个很好的选择)。

jinfo还可以使用-sysprops选项把虚拟机进程的System.getproperties()的内容打印出来。
这个命令在JDK5时期已经随着Linux版的JDK发布,当时只提供了信息查询的功能,JDK6之后,jinfo在Windows和Lin ux平台都有提供,并且加入了在运行期修改部分参数值的能力(可以使用-flag[+I-]name 或者-flag name=value在运行期修改一部分运行期可写的虚拟机参数值)。
在JDK6中,jinfo对于Windows平台功能仍然有较大限制,只提供了最基本的-flag选项。

jinfo命令格式:

jinfo [option] pid

执行样例:查询CMSInitiatingOccupancyFraction参数值

jinfo-flag CMSInitiatingoccupancy Fraction 1444
-XX:CMSInitiatingoccupancyFra ction=85

2.5.1.4 jmap: Java内存映像工具

jmap(Memory Map for Java)命令用于生成堆转储快照(一般称为heapdump或dump文件)

如果不使用jmap命令,要想获取Java堆转储快照也还有一些比较“暴力”的手段:譬如在之前用过的-XX:+HeapDumpOnOutOfMemoryError参数,可以让虚拟机在内存溢出异常出现之后自动生成堆转储快照文件,通过-XX:+HeapDumpOnCtrIBreak参数则可以使用[Ctrl]+[Break]键让虚拟机生成堆转储快照文件,又或者在Linux系统下通过Kill-3命令发送进程退出信号“恐吓”一下虚拟机,也能顺利拿到堆转储快照。

jmap的作用并不仅仅是为了获取堆转储快照,它还可以查询finalize执行队列、Java堆和方法区的详细信息,如空间使用率、当前用的是哪种收集器等。
和info命令一样,jmap有部分功能在Windows平台下是受限的,除了生成堆转储快照的-dump选项和用于查看每个类的实例、空间占用统计的-histo选项在所有操作系统中都可以使用之外,其余选项都只能在Linux/Solaris中使用。

jmap命令格式:

jmap [option] vmid

option选项的合法值与具体含义如表所示。
jmap工具主要选项

选项 作用
-dump 生成Java堆转储快照。格式为-dump:[live,]format=b,file=filename,其中live子参数说明是否只dump出存活的对象
-finalizerinfo 显示在F-Queue中等待Finalizer线程执行finalize方法的对象,只在Linux/Solaris平台下有效
-heap 显示Java堆详细信息,如使用哪种回收器、参数配置、分代状况等,只在Linux/Solaris平台下有效
-histo 显示堆中对象统计信息,包括类,实列数量,合计容量
-permstat 以ClassLoader为统计口径显示永久代内存状态,只在Linux/Solaris平台下有效
-F 当虚拟机进程对-dump选项没响应时,可使用这个选项强制生成dump快照,只在Linux/Solaris平台下有效

2.5.1.5 jhat:虚拟机堆转储快照分析工具

JDK提供jhat(JVM Heap Analysis T ool)命令与jmap搭配使用,来分析map生成的堆转储快照。

jhat内置了一个微型的HTTP/Web服务器,生成堆转储快照的分析结果后,可以在浏览器中查看。
不过实事求是地说,在实际工作中,除非手上真的没有别的工具可用,否则多数人是不会直接使用jhat命令来分析堆转储快照文件的,主要原因有两个方面。

  • 一是一般不会在部署应用程序的服务器上直接分析堆转储快照,即使可以这样做,也会尽量将堆转储快照文件复制到其他机器上进行分析,因为分析工作是一个耗时而且极为耗费硬件资源的过程,既然都要在其他机器上进行,就没有必要再受命令行工具的限制了。
  • 另外一个原因是jnat的分析功能相对来说比较简陋,后文将会介绍到的VisualVM,以及专业用于分析堆转储快照文件的Eclipse Me mory Analyzer、IBM HeapAnalyzer等工具,都能实现比jhat更强大专业的分析功能。

2.5.1.6 jstack: Java堆栈跟踪工具

jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。

线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的目的通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等,都是导致线程长时间停顿的常见原因。线程出现停顿时通过jstack来查看各个线程的调用堆栈,就可以获知没有响应的线程到底在后台做些什么事情,或者等待着什么资源。

jstack命令格式:

jstack [option] vmid

option选项的合法值与具体含义如表所示。
Jstack工具的主要选项

选项 作用
-F 当正常输出的请求不被响应时,强制输出线程堆栈
-l 除堆栈外,显示关于锁的附加信息
-m 如果调用到本地方法的话,可以显示C/C++的堆栈

从JDK5起,java.lang.Thread类新增了一个getAlIStackTraces()方法用于获取虚拟机中所有线程的StackTraceElement对象。使用这个方法可以通过简单的几行代码完成jstack的大部分功能,在实际项目中不妨调用这个方法做个管理员页面,可以随时使用浏览器来查看线程堆栈,这也算是笔者的一个小经验。

2.5.1.7 基础工具总结

  • 基础工具:用于支持基本的程序创建和运行
  • 安全工具:用于程序签名、设置安全测试等
选项 作用
keytool 管理密钥库和证书。主要用于获取或缓存Kerberos协议的票据授权票据。允许用户 查看本地凭据缓存和密钥表中的条目(用于Kerberos协议)
jarsigner 生成并验证JAR签名
policytool 管理策略文件的GUI工具,用于管理用户策略文件(.java.policy),在JDK10中被移除
  • 国际化工具:用于创建本地语言文件

  • 远程方法调用工具:用于跨Web或网络的服务交互

选项 作用
rmic JavaRMI编译器,为使用JRMP或IIOp协议的远程对象生成Stub、Skeleton和Tie类,也用于生成OMG IDL
rmiregisrty 远程对象注册表服务,用于在当前主机的指定端口上创建并启动一个远程对象注册表
rmid 启动激活系统守护进程,允许在虚拟机中注册或激活对象
serialver 生成并返回指定类的序列化版本ID
  • Java IDL与RMI-11OP工具:在JDK11中结束了十余年的CORBA支持,这些工具不再提供
选项 作用
tnameserv 提供对命名服务的访问
idlj IDL转Java编译器(IDL-to-Ja va Compiler),生成映射OMG IDL接口的Java源文件,并启用以Java编程语言编写的使用CORBA功能的应用程序的Java源文件。IDL意即接口定义语言(Interface Definition Language)
ordb 对象请求代理守护进程(Object Request Broker Daemon),提供从客户端查找和调用CORBA环境服务端上的持久化对象的功能。使用ORBD代替瞬态命名服务tmuneserV。ORBD包括瞬态命名服务orbd和持久命名服务。ORBD工具集成了服务器管理器、互操作命名服务和引导名称服务器的功能。当客户端想进行服务器时定位、注册和激活功能时,可以与servertool一起使用
servertool 为应用程序注册、注销、启动和关闭服务器提供易用的接口
  • 部署工具:用于程序打包、发布和部署
选项 作用
javapackager 打包、签名Java和JavaFX应用程序,在JDK11中被移除
pack200 用Java GZIP压缩器将JAR文件转换为压缩的Pack200文件。压缩的压缩文件是高度压缩的JAR,可以直接部署,节省带宽并减少下载时间
unpack200 将Pack200生成的打包文件解压提取为JAR文件
  • Java Web Start工具
选项 作用
javaws 启动Java Web Start并设置各种选项的工具。在JDK11中被移除
  • 性能监控和故障处理工具:用于监控分析Java虚拟机运行信息,排查问题

  • WebService工具:与CORBA一起在JDK11中被移除

选项 作用
schemagen 用于XML绑定的Schema生成器,用于生成XMLSchema文件
wsgen XML Web Service 2.0的Java wsge API,生成用于JAX-WS Web Service的JAX-WS便携式产物
wsimport XML Web Service 2.0的Java API,主要用于根据服务端发布的WSDL文件生成客户端
xjc 主要用于根据XML Schema文件生成对应的Java类
  • REPL和脚本工具
选项 作用
jshell 基于Java的Shell REPL(Rea-d-Eval-Print Loop)交互工具
jjs 对Nashoin引擎的调用入口。Nashorn是基于Java实现的一个轻量级高性能JavaScript运行环境
jrunscript Java命令行脚本外壳工具(C jrun-ommand Line Script Shell),主要用于解释执行JavaScript、Groovy、Ruby等脚本语言
  • 可视化故障处理工具
    这类工具主要包括JConsole、JHSDB、VisualVM和JMC四个。其中,JConsole是最古老,早在JD K5时期就已经存在的虚拟机监控工具,而JHSDB虽然名义上是JDK9中才正式提供,但之前已经以sa-jdi.jar包里面的HSDB(可视化工具)和CLHSDB(命令行工具)的形式存在了很长一段时间。它们两个都是JDK的正式成员随着JDK一同发布,无须独立下载,使用也是完全免费的。
    Java Mission Control,曾经是大名鼎鼎的来自BEA公司的图形化诊断工具,随着BEA公司被Oracle收购,它便被融合进OracleJDK之中。在JDK7update40时开始随JDK一起发布,后来Java SEAdvanced产品线建立,Oracle明确区分了Oracle OpenJDK和OracleJDK的差别,JMC从JDK11开始又被移除出JDK。虽然在2018年Oracle将JMC开源并交付给OpenJDK组织进行管理,但开源并不意味着免费使用,IMC需要与HotSpot内部的“飞行记录仪”(Java Fli ght Recorder,JFR)配合才能工作,而在JDK11以前,JFR的开启必须解锁Or-acleJDK的商业特性支持(使用JCMD的VM.unlock_commercial_features或启动时加入-XX:+UnlockCommercialFeatures参数),所以这项功能在生产环境中仍然是需要付费才能使用的商业特性。

    • JHSDB:基于服务性代理的调试工具
      JHSDB是一款基于服务性代理(Serviceability Agent,SA)实现的进程外调试工具。服务性代理是HotSpot虚拟机中一组用于映射Java虚拟机运行信息的、主要基于Java语言(含少量JI代码)实现的API集合。服务性代理以HotSpot内部的数据结构为参照物进行设计,把这些C++的数据抽象出Java模型对象,相当于HotSpot的C++代码的一个镜像。通过服务性代理的API,可以在一个独立的Java虚拟机的进程里分析其他HotSpot虚拟机的内部数据,或者从HotSpot虚拟机进程内存中dump出来的转储快照里还原出它的运行状态细节。服务性代理的工作原理跟Linux上的GDB或者Windows上的Windbg是相似的。

      首先点击菜单中的Tools->Heap Parameters,结果如图所示,因为笔者的运行参数中指定了使用的是Serial收集器,图中我们看到了典型的Serial的分代内存布局,Heap Parameters窗口中清楚列出了新生代的Eden、S1、S2和老年代的容量(单位为字节)以及它们的虚拟内存地址起止范围。

    • JConsole:Java监视与管理控制台
      JConsole(Java Monitoring and Management Console)是一款基于JMX(Java Manage-ment Extensions)的可视化监视、管理工具。它的主要功能是通过JMX的MBean(Managed Bean)对系统进行信息收集和参数动态调整。JMX是一种开放性的技术,不仅可以用在虚拟机本身的管理上,还可以运行于虚拟机之上的软件中,典型的如中间件大多也基于JMX来实现管理与监控。虚拟机对JMXMBean的访问也是完全开放的,可以使用代码调用API、支持J MX协议的管理控制台,或者其他符合J MX规范的软件进行访问。

      通过JDK/bin目录下的jconsole.exe启动Console后,会自动搜索出本机运行的所有虚拟机进程,而不需要用户自己使用jps来查询,如图所示。双击选择其中一个进程便可进入主界面开始监控。JMX支持跨服务器的管理,也可以使用下面的“远程进程”功能来连接远程服务器,对远程虚拟机进行监控。
      图中可以看到笔者的机器现在运行了Eclipse、Console、MonitoringTest三个本地虚拟机进程,这里Monit-oringTest是笔者准备的“反面教材”代码之一。双击它进入JConsole主界面,可以看到主界面里共包括“概述”“内存”“线程”“类”“VM摘要”“MBean”六个页签,如图所示。

      “概述”页签里显示的是整个虚拟机主要运行数据的概览信息,包括“堆内存使用情况”“线程”“类”“CPU使用情况”四项信息的曲线图,这些曲线图是后面“内存”“线程”“类”页签的信息汇总。
      “内存”页签的作用相当于可视化的jstat命令,用于监视被收集器管理的虚拟机内存(被收集器直接管理的J ava堆和被间接管理的方法区)的变化趋势。
      “线程”页签的功能就相当于可视化的jstack命令了,遇到线程停顿的时候可以使用这个页签的功能进行分析。前面讲解jstack命令时提到线程长时间停顿的主要原因有等待外部资源(数据库连接、网络资源、设备资源等)、死循环、锁等待等,

    • VisuaVM:多合-故障处理工具
      VisuaVM(All-in-One Java Troubleshooting Tool)是功能最强大的运行监视和故障处理程序之一,曾经在很长一段时间内是Oracle官方主力发展的虚拟机故障处理工具。Oracle曾在VisualVM的软件说明中写上了“All-in-One”的字样,预示着它除了常规的运行监视、故障处理外,还将提供其他方面的能力,譬如性能分析(Profiling)。VisuaIVM的性能分析功能比起JProfiler、YourKit等专业且收费的Profiling工具都不追多让。而且相比这些第三方工具,VisualVM还有一个很大的优点:不需要被监视的程序基于特殊Agent去运行,因此它的通用性很强,对应用程序实际性能的影响也较小,使得它可以直接应用在生产环境中。这个优点是JProfiler、YourKit等工具无法与之媲美的。

3. 虚拟机执行子系统

3.1 类文件结构

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。

简单概述:
曾记得在第一堂计算机程序课上老师就讲过:“计算机只认识0和1,所以我们写的程序需要被编译器翻译成由0和1构成的二进制格式才能被计算机执行。”十多年过去了,今天的计算机仍然只能识别0和1,但由于最近十年内虚拟机以及大量建立在虚拟机之上的程序语言如雨后春笋般出现并蓬勃发展,把我们编写的程序编译成二进制本地机器码(Native Code)已不再是唯一的选择,越来越多的程序语言选择了与操作系统和机器指令集无关的、平台中立的格式作为程序编译后的存储格式。

如果全世界所有计算机的指令集就只有×86一种,操作系统就只有Win-dows一种,那也许就不会有Java语言的出现。Java在刚刚诞生之时曾经提出过一个非常著名的宣传口号“一次编写,到处运行(Write Once,Run Anywhere)”,这句话充分表达了当时软件开发人员对冲破平台界限的渴求。

在每时每刻都充满竞争的IT业界,不可能只有Wintel存在,我们也不希望出现只有Wintel而没有竞争者的世界,各种不同的硬件体系结构、各种不同的操作系统肯定将会长期并存发展。“与平台无关”的理想最终只有实现在操作系统以上的应用层:Oracle公司以及其他虚拟机发行商发布过许多可以运行在各种不同硬件平台和操作系统上的Java虚拟机,这些虚拟机都可以载入和执行同一种平台无关的字节码,从而实现了程序的“一次编写,到处运行”。

各种不同平台的Java虚拟机,以及所有平台都统一支持的程序存储格式——字节码(Byte Code)是构成平台无关性的基石,但本节标题中笔者刻意省略了“平台”二字,那是因为笔者注意到虚拟机的另外一种中立特性一一语言无关性正在越来越被开发者所重视。

直到今天,或许还有相当一部分程序员认为Java虚拟机执行Java程序是一件理所当然和天经地义的事情。
但在Java技术发展之初,设计者们就曾经考虑过并实现了让其他语言运行在Java虚拟机之上的可能性,他们在发布规范文档的时候,也刻意把Java的规范拆分成了《Java语言规范》(The Java Language Specification)及《Java虚拟机规范》(The Java Virtual Machine Specification)两部分。

并且早在1997年发表的第一版《Java虚拟机规范》中就曾经承诺过:“在未来,我们会对Java虚拟机进行适当的扩展,以便更好地支持其他语言运行于Java虚拟机之上”(In the future,we will consider bo-
unded extensions to the Java virtual ma chine to provide better support for other languages)。

Java虚拟机发展到今天,尤其是在2018年,基于HotSpot扩展而来的GraaVM公开之后,当年的虚拟机设计者们已经基本兑现了这个承诺。时至今日,商业企业和开源机构已经在Java语言之外发展出一大批运行在Java虚拟机之上的语言,如Kotlin、Clojure、Groovy、JRuby、JPython、Scala等。相比起基数庞大的Java程序员群体,使用过这些语言的开发者可能还不是特别多,但是听说过的人肯定已经不少,随着时间的推移,谁能保证日后Java虚拟机在语言无关性上的优势不会赶上甚至超越它在平台无关性上的优势呢?
实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集、符号表以及若干其他辅助信息。

基于安全方面的考虑,《Java虚拟机规范》中要求在CIass文件必须应用许多强制性的语法和结构化约束,但图灵完备的字节码格式,保证了任意一门功能性语言都可以表示为一个能被Java虚拟机所接受的有效的Class文件。

作为一个通用的、与机器无关的执行平台,任何其他语言的实现者都可以将Java虚拟机作为他们语言的运行基础,以Class文件作为他们产品的交付媒介。例如,使用Java编译器可以把Java代码编译为存储字节码的Class文件,使用JRuby等其他语言的编译器一样可以把它们的源程序代码编译成Class文件。虚拟机丝毫不关心Class的来源是什么语言,它与程序语言之间的关系如图所示。

Java语言中的各种语法、关键字、常量变量和运算符号的语义最终都会由多条字节码指令组合来表达,这决定了字节码指令所能提供的语言描述能力必须比Java语言本身更加强大才行。

因此,有一些Java语言本身无法有效支持的语言特性并不代表在字节码中也无法有效表达出来,这为其他程序语言实现一些有别于Java的语言特性提供了发挥空间。

3.2 Class类文件的结构

解析Class文件的数据结构是本章的最主要内容。笔者曾经在前言中阐述过本文的写作风格:力求在保证逻辑准确的前提下,用尽量通俗的语言和案例去讲述虚拟机中与开发关系最为密切的内容。但是,对文件格式、结构方面的学习,有点类似于“读字典”,读者阅读本章时,大概会不可避免地感到比较枯燥,但这部分内容又是Java虚拟机的重要基础之一,是了解虚拟机的必经之路,如果想比较深入地学习虚拟机相关知识,这部分是无法回避的。

Java技术能够一直保持着非常良好的向后兼容性,Class文件结构的稳定功不可没,任何一门程序语言能够获得商业上的成功,都不可能去做升级版本后,旧版本编译的产品就不再能够运行这种事情。

本章所讲述的关于Class文件结构的内容,绝大部分都是在第一版的《Java虚拟机规范》(1997年发布,对应于JDK1.2时代的Java虚拟机)中就已经定义好的,内容虽然古老,但时至今日,Java发展经历了十余个大版本、无数小更新,那时定义的CIass文件格式的各项细节几乎没有出现任何改变。

尽管不同版本的《Java虚拟机规范》对Class文件格式进行了几次更新,但基本上只是在原有结构基础上新增内容、扩充功能,并未对已定义的内容做出修改。

注意:
任何一个Class文件都对应着唯一的一个类或接口的定义信息,但是反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)。

本章中,笔者只是通俗地将任意一个有效的类或接口所应当满足的格式称为“Class文件格式”,实际上它完全不需要以磁盘文件的形式存在。

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

根据《Java虚拟机规范》的规定,C lass文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”。后面的解析都要以这两种数据类型为基础,所以这里笔者必须先解释清楚这两个概念:

  • 无符号数属于基本的数据类型,以ul、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值;
  • 是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性地以“info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视作是一张表,这张表由表所示的数据项按严格顺序排列构成;

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

笔者需要再强调一次,Class的结构不像XML等描述语言,由于它没有任何分隔符号,所以在表中的数据项,无论是顺序还是数量,甚至于数据存储的字节序(Byte Or dering,Class文件中字节序为Big-Endi-
an)这样的细节,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,全部都不允许改变。

3.2.1 魔数与Class文件的版本

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

不仅是Class文件,很多文件格式标准中都有使用魔数来进行身份识别的习惯,譬如图片格式,如GIF或者JPEG等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全考虑,因为文件扩展名可以随意改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过而且不会引起混淆。Class文件的魔数取得很有“浪漫气息”,值为0xCAFEBABE(咖啡宝贝)。

这个魔数值在Java还被称作“Oak”语言的时候(大约是1991年前后)就已经确定下来了。它还有一段很有趣的历史,据Java开发小组最初的关键成员Patrick Naughton所说:“我们一直在寻找一些好玩的、容易记忆的东西,选择0xCAFEBABE是因为它象征着著名咖啡品牌Peet’s Coffee深受欢迎的Baristas咖啡。”这个魔数似乎也预示着日后“Java”这个商标名称的出现。

紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。

Java的版本号是从45开始的,JDK1.1之后的每个JDK大版本发布主版本号向上加1(JDK1.0 ~ 1.1使用了45.0~45.3的版本号),高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,因为《Java虚拟机规范》在Class文件校验部分明确要求了即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。
例如,JDK1.1能支持版本号为45.0 ~ 45.65535的Class文件,无法执行版本号为46.0以上的Class文件,而JDK1.2则能支持45.0~46.65535的Class文件。目前最新的JDK版本为13,可生成的Class文件主版本号最大值为57.0。

3.2.2 常量池

紧接着主、次版本号之后的是常量池入口,常量池可以比喻为Class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一,另外,它还是在Class文件中第一个出现的表类型数据项目。

由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。

与Java中语言习惯不同,这个容量计数是从1而不是0开始的,如图所示,常量池容量(偏移地址:0×00000008)为十六进制数0×0016,即十进制的22,这就代表常量池中有21项常量,索引值范围为1~21。在Class文件格式规范制定之时,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于,如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为0来表示。

Class文件结构中只有常量池的容量计数是从1开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都与一般习惯相同,是从0开始。

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。
字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。
而符号引用则属于编译原理方面的概念,主要包括下面几类常量:

  • 被模块导出或者开放的包(Package)
  • 类和接口的全限定名(Fully Qu alified Name)
  • 字段的名称和描述符(Descriptor)
  • 方法的名称和描述符
  • 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
  • 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)

Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

常量池中每一项常量都是一个表,最初常量表中共有11种结构各不相同的表结构数据,后来为了更好地支持动态语言调用,额外增加了4种动态语言相关的常量,为了支持Java模块化系统(Jigsaw),又加入了CONSTANT_Module_info 和CONSTANT_Package_info两个常量,所以截至JDK13,常量表中分别有17种不同类型的常量。

这17类表都有一个共同的特点,表结构起始的第一位是个u1类型的标志位(tag,取值见表中标志列),代表着当前常量属于哪种常量类型。17种常量类型所代表的具体含义如表所示。


之所以说常量池是最烦琐的数据,是因为这17种常量类型各自有着完全独立的数据结构,两两之间并没有什么共性和联系。

在JDK的bin目录中,Oracle公司已经为我们准备好一个专门用于分析Class文件字节码的工具:javap。

常量池中的17种数据类型的结构总表


3.2.3 访问标志

在常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final;等等。具体的标志位以及标志的含义见表。

access_flags中一共有16个标志位可以使用,当前只定义了其中9个,没有使用到的标志位要求一律为零。

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

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定该类型的继承关系。

类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。
由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。

接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements关键字(如果这个Class文件表示的是一个接口,则应当是extends关键字)后的接口顺序从左到右排列在接口索引集合中。

类索引、父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引用两个u2类型的索引值表示,它们各自指向一个类型为CoNSTANT_Class_info的类描述符常量,
通过CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_utf8_info类型的常量中的全限定名字符串。

对于接口索引集合,入口的第一项u2类型的数据为接口计数器(interf-aces_count),表示索引表的容量。如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节。

3.2.5 字段表集合

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

Java语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。
读者可以回忆一下在Java语言中描述一个字段可以包含哪些信息。
字段可以包括的修饰符有字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称。

上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫做什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。表中列出了字段表的最终格式。

字段修饰符放在access_flags项目中,它与类中的access_flags项目是非常类似的,都是一个u2的数据类型,其中可以设置的标志位和含义如表所示。

很明显,由于语法规则的约束,AC C_PuBLIC、ACC_PRIVATE、ACC_PROTECTED三个标志最多只能选择其一,A CC_FINAL、ACC_VOLATILE不能同时选择。接口之中的字段必须有ACC_PUBLIC、ACC_STATIC、ACC_FINAL标志,这些都是由Java本身的语言规则所导致的。

跟随access_flags标志的是两项索引值:name_index和descriptor_index。它们都是对常量池项的引用,分别代表着字段的简单名称以及字段和方法的描述符。现在需要解释一下“简单名称”“描述符”以及前面出现过多次的“全限定名”这三种特殊字符串的概念。

全限定名和简单名称很好理解,以代码为例,“org/fenixsoft/clazz/TestClass”是这个类的全限定名,仅仅是把类全名中的“.”替换成了“/”而已,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”号表示全限定名结束。简单名称则就是指没有类型和参数修饰的方法或者字段名称,这个类中的inc()方法和m字段的简单名称分别就是“inc”和“m”。

相比于全限定名和简单名称,方法和字段的描述符就要复杂一些。描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示,详见表

注:void类型在《Java虚拟机规范》之中单独列出为“VoidDescriptor”,笔者为了结构统一,将其列在基本数据类型中一起描述。对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个定义为“java.lang.String[][]”类型的二维数组将被记录成“[[Ljava/lang/String;”,一个整型数组“int[]”将被记录成“[I”。

用描述符来描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法void inc()的描述符为“()V”,方法java.lang.String toString()的描述符为“()Ljava/lang/St-ring;”,方法int indexOf(char[]source,int sourceOffset,int sourceCount,cha-r[]target,int targetOfset,int targetCount,int fromIndex)的描述符为“([CII[CIII)I"。

字段表所包含的固定数据项目到descriptor_index为止就全部结束了,不过在descrip-tor_index之后跟随着一个属性表集合,用于存储一些额外的信息,字段表可以在属性表中附加描述零至多项的额外信息。对于本例中的字段m,它的属性表计数器为0,也就是没有需要额外描述的信息,但是,如果将字段m的声明改为“final statici ntm=123;”,那就可能会存在一项名称为ConstantValue的属性,其值指向常量123。

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

3.2.6 方法表集合

如果理解了上一节关于字段表的内容,那本节关于方法表的内容将会变得很简单。

Class文件存储格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样,依次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项,如表所示。这些数据项目的含义也与字段表中的非常类似,仅在访问标志和属性表集合的可选项中有所区别。

因为volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有了ACC_VOLATILE标志和ACC_TRANSIENT标志。与之相对,sy nchronized、native、strictfp和abstract关键字可以修饰方法,方法表的访问标志中也相应地增加了ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP和ACC_ABSTRACT标志。对于方法表,所有标志位及其取值可参见表。

行文至此,也许有的读者会产生疑问,方法的定义可以通过访问标志、名称索引、描述符索引来表达清楚,但方法里面的代码去哪里了?方法里的Java代码,经过Javac编译器编译成字节码指令之后,存放在方法属性表集合中一个名为“Code”的属性里面,属性表作为Class文件格式中最具扩展性的一种数据项目;

与字段表集合相对应地,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样地,有可能会出现由编译器自动添加的方法,最常见的便是类构造器“()”方法和实例构造器“()”方法圈。在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名。

特征签名是指一个方法中各个参数在常量池中的字段符号引用的集合,也正是因为返回值不会包含在特征签名之中,所以Java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。但是在Class文件格式之中,特征签名的范围明显要更大一些,只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个Class文件中的。

3.2.7 属性表集合

属性表(attribute_info)在前面的讲解之中已经出现过数次,Class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息。

与Class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格顺序,并且《Java虚拟机规范》允许只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识的属性。为了能正确解析Class文件,《Java虚拟机规范》最初只预定义了9项所有Java虚拟机实现都应当能识别的属性,而在最新的《Java虚拟机规范》的JavaSE12版本中,预定义属性已经增加到29项,这些属性具体见表。后文中将对这些属性中的关键的、常用的部分进行讲解。

虚拟机规范预定义的属性:


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

3.2.8 Code属性

Java程序方法体里面的代码经过Javac编译器处理之后,最终变为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽象类中的方法就不存在Code属性,如果方法表有Code属性存在,那么它的结构将如表所示。

atribute_name_index是一项指向CONSTANT_utf8_info型常量的索引,此常量值固定为“Code”,它代表了该属性的属性名称,attribute_length指示了属性值的长度,由于属性名称索引与属性长度一共为6个字节,所以属性值的长度固定为整个属性表长度减去6个字节。

max_stack代表了操作数栈(Operand Stack)深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧(Stack Frame)中的操作栈深度。

max_locals代表了局部变量表所需的存储空间。在这里,max_locals的单位是变量槽(Slot),变量槽是虚拟机为局部变量分配内存所使用的最小单位。对于byte、char、float、int、short、bo olean和returnAddress等长度不超过32位的数据类型,每个局部变量占用一个变量槽,而double和long这两种64位的数据类型则需要两个变量槽来存放。方法参数(包括实例方法中的隐藏参数“this”)、显式异常处理程序的参数(Exception Handler Parameter,就是try-catch语句中catch块中所定义的异常)、方法体中定义的局部变量都需要依赖局部变量表来存放。注意,并不是在方法中用了多少个局部变量,就把这些局部变量所占变量槽数量之和作为max_locals的值,操作数栈和局部变量表直接决定一个该方法的栈帧所耗费的内存,不必要的操作数栈深度和变量槽数量会造成内存的浪费。Java虚拟机的做法是将局部变量表中的变量槽进行重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的变量槽可以被其他局部变量所使用,Javac编译器会根据变量的作用域来分配变量槽给各个变量使用,根据同时生存的最大局部变量数量和类型计算出max_locals的大小。

code_length和code用来存储Java源程序编译后生成的字节码指令。code_length代表字节码长度,code是用于存储字节码指令的一系列字节流。既然叫字节码指令,那顾名思义每个指令就是一个u1类型的单字节,当虚拟机读取到code中的一个字节码时,就可以对应找出这个字节码代表的是什么指令,并且可以知道这条指令后面是否需要跟随参数,以及后续的参数应当如何解析。我们知道一个ul数据类型的取值范围为0x00 ~ 0xFF,对应十进制的0~255,也就是一共可以表达256条指令。目前,《Java虚拟机规范》已经定义了其中约200条编码值对应的指令含义,编码与指令之间的对应关系可查阅本文的附录C“虚拟机字节码指令表”。

关于code_length,有一件值得注意的事情,虽然它是一个u4类型的长度值,理论上最大值可以达到2的32次幂,但是《Java虚拟机规范》中明确限制了一个方法不允许超过65535条字节码指令,即它实际只使用了u2的长度,如果超过这个限制,Javac编译器就会拒绝编译。一般来讲,编写Java代码时只要不是刻意去编写一个超级长的方法来为难编译器,是不太可能超过这个最大值的限制的。但是,某些特殊情况,例如在编译一个很复杂的JSP文件时,某些JSP编译器会把JSP内容和页面输出的信息归并于一个方法之中,就有可能因为方法生成字节码超长的原因而导致编译失败。

Code属性是Class文件中最重要的一个属性,如果把一个Java程序中的信息分为代码(Code,方法体里面的Ja va代码)和元数据(Metadata,包括类、字段、方法定义及其他信息)两部分,那么在整个Class文件里,Code属性用于描述代码,所有的其他数据项目都用于描述元数据。了解Code属性是学习后面两章关于字节码执行引擎内容的必要基础,能直接阅读字节码也是工作中分析Java代码语义问题的必要工具和基本技能;

在字节码指令之后的是这个方法的显式异常处理表(下文简称“异常表”)集合,异常表对于Code属性来说并不是必须存在的,如果存在异常表,那它的格式应如表所示,包含四个字段,这些字段的含义为:如果当字节码从第start_pc行到第end_pc行之间(不含第end_pc行)出现了类型为catch_type或者其子类的异常(catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第handler_pc行继续处理。当catch_type的值为0时,代表任意异常情况都需要转到handler_pc处进行处理。

异常表实际上是Java代码的一部分,尽管字节码中有最初为处理异常而设计的跳转指令,但《Java虚拟机规范》中明确要求Java语言的编译器应当选择使用异常表而不是通过跳转指令来实现Java异常及finally处理机制;

  • Exceptions属性
    这里的Exceptions属性是在方法表中与Code属性平级的一项属性,读者不要与前面刚刚讲解完的异常表产生混淆。Exceptions属性的作用是列举出方法中可能抛出的受查异常(Checked Excepitons),也就是方法描述时在throws关键字后面列举的异常。它的结构见表

    此属性中的number_of_exceptions项表示方法可能抛出number_of_exceptions种受查异常,每一种受查异常使用一个exception_index_table项表示;exception_index_table是一个指向常量池中CONSTANT_Class_info型常量的索引,代表了该受查异常的类型。

  • LineNlumberTable属性
    LinelumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。它并不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中使用-g:none或-g:lines选项来取消或要求生成这项信息。如果选择不生成LineNumberTable属性,对程序运行产生的最主要影响就是当抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候,也无法按照源码行来设置断点。LineNumberTable属性的结构如表所示。

  • LocalVariableTable及LocalVariableTypeTable属性
    LocalVariableTable属性用于描述栈帧中局部变量表的变量与Java源码中定义的变量之间的关系,它也不是运行时必需的属性,但默认会生成到CI ass文件之中,可以在Javac中使用-g:n one或-g:vars选项来取消或要求生成这项信息。如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所有的参数名称都将会丢失,譬如IDE将会使用诸如arg0、arg1之类的占位符代替原有的参数名,这对程序运行没有影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获得参数值。LocalVariableTable属性的结构如表

  • SourceFile及SourceDebugExtension属性
    SourceFile属性用于记录生成这个Class文件的源码文件名称。这个属性也是可选的,可以使用Javac的-g:none或-g:source选项来关闭或要求生成这项信息。在Java中,对于大多数的类来说,类名和文件名是一致的,但是有一些特殊情况(如内部类)例外。如果不生成这项属性,当抛出异常时,堆栈中将不会显示出错代码所属的文件名。这个属性是一个定长的属性,其结构如表所示。

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

  • InnerClasses属性
    InnerClasses属性用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那编译器将会为它以及它所包含的内部类生成InnerClasses属性。InnerClasses属性的结构如表所示。

  • Deprecated及Synthetic属性
    Deprecated和Synthetic两个属性都属于标志类型的布尔属性,只存在有和没有的区别,没有属性值的概念。Deprecated属性用于表示某个类、字段或者方法,已经被程序作者定为不再推荐使用,它可以通过代码中使用“@deprecated”注解进行设置。Synthetic属性代表此字段或者方法并不是由Java源码直接产生的,而是由编译器自行添加的,在JDK5之后,标识一个类、字段或者方法是编译器自动产生的,也可以设置它们访问标志中的ACC_SYNTHETIC标志位。

  • StackMapTable属性
    StackMapTable属性在JDK6增加到Class文件规范之中,它是一个相当复杂的变长属性,位于Code属性的属性表中。这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用,目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。

  • Signature属性
    Signature属性在JDK5增加到Class文件规范之中,它是一个可选的定长属性,可以出现于类、字段表和方法表结构的属性表中。在JDK5里面大幅增强了Java语言的语法,在此之后,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variable)或参数化类型(Parameterized Type),则Signature属性会为它记录泛型签名信息。

  • BootstrapMethods属性
    BootstrapMethods属性在JDK7时增加到Class文件规范之中,它是一个复杂的变长属性,位于类文件的属性表中。这个属性用于保存invokedynamic指令引用的引导方法限定符。
    根据《Java虚拟机规范》(从JavaS E7版起)的规定,如果某个类文件结构的常量池中曾经出现过CONSTANT_I nvokeDynamic_info类型的常量,那么这个类文件的属性表中必须存在一个明确的BootstrapMethods属性,另外,即使CONSTANT_InvokeDynamic_info类型的常量在常量池中出现过多次,类文件的属性表中最多也只能有一个BootstrapMethods属性。

  • MethodParameters属性
    MethodParameters是在JDK8时新加入到Class文件格式中的,它是一个用在方法表中的变长属性。Methodparameters的作用是记录方法的各个形参名称和信息。

  • 模块化相关属性
    DK9的一个重量级功能是Java的模块化功能,因为模块描述文件(module-info.java)最终是要编译成一个独立的Class文件来存储的,所以,Class文件格式也扩展了Module、ModulePackages和ModuleMainClass三个属性用于支持Java模块化相关功能。Module属性是一个非常复杂的变长属性,除了表示该模块的名称、版本、标志信息以外,还存储了这个模块requires、exports、opens、uses和provides定义的全部内容;

3.3 字节码指令简介

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

由于Java虚拟机采用面向操作数栈而不是面向寄存器的架构(这两种架构的执行过程、区别和影响将在后面中探讨),所以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。

字节码指令集可算是一种具有鲜明特点、优势和劣势均很突出的指令集架构,由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不能够超过256条;又由于Class文件格式放弃了编译后代码的操作数长度对齐,这就意味着虚拟机在处理那些超过一个字节的数据时,不得不在运行时从字节中重建出具体数据的结构,譬如要将一个16位长度的无符号整数使用两个无符号字节存储起来(假设将它们命名为byte1和byte2),那它们的值应该是这样的:

(byte1<<8)| byte2

这种操作在某种程度上会导致解释执行字节码时将损失一些性能,但这样做的优势也同样明显:放弃了操作数长度对齐,就意味着可以省略掉大量的填充和间隔符号;用一个字节来代表操作码,也是为了尽可能获得短小精干的编译代码。这种追求尽可能小数据量、高传输效率的设计是由Java语言设计之初主要面向网络、智能家电的技术背景所决定的,并一直沿用至今。

如果不考虑异常处理的话,那Java虚拟机的解释器可以使用下面这段伪代码作为最基本的执行模型来理解,这个执行模型虽然很简单,但依然可以有效正确地工作:

do{自动计算PC寄存器的值加1;根据PC寄存器指示的位置,从字节码流中取出操作码;
if(字节码存在操作数)从字节码流中取出操作数;执行操作码所定义的操作;
}while(字节码流长度>0);

3.3.1 字节码与数据类型

在Java虚拟机的指令集中,大多数指令都包含其操作所对应的数据类型信息。
举个例子,iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在Class文件中它们必须拥有各自独立的操作码。
对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。也有一些指令的助记符中没有明确指明操作类型的字母,例如arraylength指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。还有另外一些指令,例如无条件跳转指令goto则是与数据类型无关的指令。

因为Java虚拟机的操作码长度只有一字节,所以包含了数据类型的操作码就为指令集的设计带来了很大的压力:如果每一种与数据类型相关的指令都支持Java虚拟机所有运行时数据类型的话,那么指令的数量恐怕就会超出一字节所能表示的数量范围了。因此,Java虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持它,换句话说,指令集将会被故意设计成非完全独立的。(《Java虚拟机规范》中把这种特性称为“Not Or-thogonal”,即并非每种数据类型和每一种操作都有对应的指令。)有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可被支持的类型。

表列举了Java虚拟机所支持的与数据类型相关的字节码指令,通过使用数据类型列所代表的特殊字符替换opcode列的指令模板中的T,就可以得到一个具体的字节码指令。如果在表中指令模板与数据类型两列共同确定的格为空,则说明虚拟机不支持对这种数据类型执行这项操作。例如load指令有操作int类型的iload,但是没有操作byte类型的同类指令。


请注意,从表中看来,大部分指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。编译器会在编译期或运行期将b yte和short类型的数据带符号扩展(Sign-Extend)为相应的int类型数据,将b oolean和char类型数据零位扩展(Zero-Extend)为相应的int类型数据。与之类似,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。因此,大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的对int类型作为运算类型(Computational Type)来进行的;

3.3.2 加载和存储指令

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

  • 将一个局部变量加载到操作栈:iload、iload_<n>、lload、lloa-d_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>
  • 将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、Is tore、Istore_<n>、fstore、fstore_< n>、dstore、dstore_<n>、astore、as tore_<n>
  • 将一个常量加载到操作数栈:bi push、sipush、ldc、ldc_w、ldc2_W、aconst_null、iconst_m1、iconst_<i>、Iconst_<l>、fconst_<f>、dconst_<d>
  • 扩充局部变量表的访问索引的指令:wide

存储数据的操作数栈和局部变量表主要由加载和存储指令进行操作,除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。

上面所列举的指令助记符中,有一部分是以尖括号结尾的(例如iload_<n〉),这些指令助记符实际上代表了一组指令(例如iload_<n〉,它代表了iload_0、iload_1、iload_2和iload_3这几条指令)。

这几组指令都是某个带有一个操作数的通用指令(例如ioad)的特殊形式,对于这几组特殊指令,它们省略掉了显式的操作数,不需要进行取操作数的动作,因为实际上操作数就隐含在指令中。除了这点不同以外,它们的语义与原生的通用指令是完全一致的(例如iload_0的语义与操作数为0时的iload指令语义完全一致)。这种指令表示方法,在和《Java虚拟机规范》中都是通用的。

3.3.3 运算指令

算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
大体上运算指令可以分为两种:对整型数据进行运算的指令与对浮点型数据进行运算的指令。
整数与浮点数的算术指令在溢出和被零除的时候也有各自不同的行为表现。
无论是哪种算术指令,均是使用Java虚拟机的算术类型来进行计算的,换句话说是不存在直接支持byte、short、char和boolean类型的算术指令,对于上述几种数据的运算,应使用操作int类型的指令代替。
所有的算术指令包括:

  • 加法指令:iadd、ladd、fadd、dadd
  • 减法指令:isub、lsub、fsub、dsub
  • 乘法指令:imul、lmul、fmul、dmul
  • 除法指令:idiv、ldiv、fdiv、ddiv
  • 求余指令:irem、Irem、frem、drem
  • 取反指令:ineg、Ineg、fneg、dneg
  • 位移指令:ishl、ishr、iushr、Ishl、Ishr、lushr
  • 按位或指令:ior、lor
  • 按位与指令:iand、land
  • 按位异或指令:ixor、lxor
  • 局部变量自增指令:iinc
  • 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、Icmp

Java虚拟机的指令集直接支持了在《Java语言规范》中描述的各种对整数及浮点数操作。数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能会是一个负数,这种数学上不可能出现的溢出现象,对于程序员来说是很容易理解的,但其实《Java虚拟机规范》中并没有明确定义过整型数据溢出具体会得到什么计算结果,仅规定了在处理整型数据时,只有除法指令(idiv和ldiv)以及求余指令(irem和Irem)中当出现除数为零时会导致虚拟机抛出ArithmeticException异常,其余任何整型数运算场景都不应该抛出运行时异常。

《Java虚拟机规范》要求虚拟机实现在处理浮点数时,必须严格遵循IE-EE754规范中所规定行为和限制,也就是说Java虚拟机必须完全支持IEEE754中定义的“非正规浮点数值”(Denormalized Floating-Point Number)和“逐级下溢”(Gradual underflow)的运算规则。

这些规则将会使某些数值算法处理起来变得明确,不会出现模棱两可的困境。
譬如以上规则要求Java虚拟机在进行浮点数运算时,所有的运算结果都必须舍入到适当的精度,非精确的结果必须舍入为可被表示的最接近的精确值;如果有两种可表示的形式与该值一样接近,那将优先选择最低有效位为零的。这种舍入模式也是I EEE754规范中的默认舍入模式,称为向最接近数舍入模式。

而在把浮点数转换为整数时,Java虚拟机使用IEEE754标准中的向零舍入模式,这种模式的舍入结果会导致数字被截断,所有小数部分的有效字节都会被丢弃掉。向零舍入模式将在目标数值类型中选择一个最接近,但是不大于原值的数字来作为最精确的舍入结果。

另外,Java虚拟机在处理浮点数运算时,不会抛出任何运行时异常(这里所讲的是Java语言中的异常,请读者勿与IEEE754规范中的浮点异常互相混淆,IEEE754的浮点异常是一种运算信号),当一个操作产生溢出时,将会使用有符号的无穷大来表示;如果某个操作结果没有明确的数学定义的话,将会使用NaN(Not a Number)值来表示。所有使用NaN值作为操作数的算术操作,结果都会返回NaN。

在对long类型数值进行比较时,Java虚拟机采用带符号的比较方式,而对浮点数值进行比较时(dcmpg、dcmpI、fcmpg、fcmpl),虚拟机会采用IEEE754规范所定义的无信号比较(Nonsign-aling Comparison)方式进行。

3.3.4 类型转换指令

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

Java虚拟机直接支持(即转换时无须显式的转换指令)以下数值类型的宽化类型转换(Widening Numeric Conversion,即小范围类型向大范围类型的安全转换):

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

与之相对的,处理窄化类型转换(Narrowing Numeric Conversion)时,就必须显式地使用转换指令来完成,这些转换指令包括i2b、i2c、i2s、I2i、f2i、f2l、d2i、d21和d2f。窄化类型转换可能会导致转换结果产生不同的正负号、不同的数量级的情况,转换过程很可能会导致数值的精度丢失。

在将int或long类型窄化转换为整数类型T的时候,转换过程仅仅是简单丢弃除最低位N字节以外的内容,N是类型T的数据类型长度,这将可能导致转换结果与输入值有不同的正负号。

对于了解计算机数值存储和表示的程序员来说这点很容易理解,因为原来符号位处于数值的最高位,高位被丢弃之后,转换结果的符号就取决于低N字节的首位了。
Java虚拟机将一个浮点值窄化转换为整数类型T(T限于int或long类型之一)的时候,必须遵循以下转换规则:

  • 如果浮点值是NaN,那转换结果就是int或long类型的0。
  • 如果浮点值不是无穷大的话,浮点值使用IEEE754的向零舍入模式取整,获得整数值v。如果v在目标类型T(int或long)的表示范围之类,那转换结果就是v;否则,将根据v的符号,转换为T所能表示的最大或者最小正数。

从double类型到float类型做窄化转换的过程与IEEE754中定义的一致,通过IEEE754向最接近数舍入模式舍入得到一个可以使用float类型表示的数字。

如果转换结果的绝对值太小、无法使用float来表示的话,将返回float类型的正负零;如果转换结果的绝对值太大、无法使用float来表示的话,将返回float类型的正负无穷大。对于double类型的NaN值将按规定转换为float类型的NaN值。
尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是《Java虚拟机规范》中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常。

3.3.5 对象创建与访问指令

虽然类实例和数组都是对象,但J ava虚拟机对类实例和数组的创建与操作使用了不同的字节码指令(在下一章会讲到数组和普通类的类型创建过程是不同的)。

对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令包括:

  • 创建类实例的指令:new
  • 创建数组的指令:newarray、an ewarray、multianewarray
  • 访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)的指令:getfield、putfield、getstatic、putstatic
  • 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
  • 取数组长度的指令:arraylengt
  • 检查类实例类型的指令:instanceof、checkcast

3.3.6 操作数栈管理指令

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

  • 将操作数栈的栈顶一个或两个元素出栈:pop、pop2
  • 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_×1、dup2_x1、dup_×2、dup2_×2
  • 将栈最顶端的两个数值互换:swap

3.3.7 控制转移指令

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

  • 条件分支:ifeq、iflt、ifle、i fne、ifgt、ifge、ifnull、ifnonnul-1、if_icmpeq、if_icmpne、if_icmp-It、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne
  • 复合条件分支:tableswitch、lo okupswitch
  • 无条件分支:goto、goto_w、js r、jsr_w、ret

在Java虚拟机中有专门的指令集用来处理int和reference类型的条件分支比较操作,为了可以无须明显标识一个数据的值是否null,也有专门的指令用来检测null值。

与前面算术运算的规则一致,对于boolean类型、byte类型、char类型和s hort类型的条件分支比较操作,都使用int类型的比较指令来完成,而对于long类型、float类型和double类型的条件分支比较操作,则会先执行相应类型的比较运算指令(dcmpg、dcmpl、fcmpg、fcmpl、lcmp),运算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转。由于各种类型的比较最终都会转化为int类型的比较操作,int类型比较是否方便、完善就显得尤为重要,而Java虚拟机提供的int类型的条件分支指令是最为丰富、强大的。

3.3.8 方法调用和返回指令

方法调用(分派、执行过程)将在第8章具体讲解,这里仅列举以下五条指令用于方法调用:

  • invokevirtual指令:
    用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
  • invokeinterface指令:
    用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
  • invokespecial指令:
    用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
  • invokestatic指令:
    用于调用类静态方法(static方法)。
  • invokedynamic指令:
    用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。前面四条调用指令的分派逻辑都固化在Java虚拟机内部,用户无法改变,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

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

3.3.9 异常处理指令

在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现,除了用throw语句显式抛出异常的情况之外,《Java虚拟机规范》还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。例如前面介绍整数运算中,当除数为零时,虚拟机会在idiv或ldiv指令中抛出ArithmeticException异常。

而在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(很久之前曾经使用jsr和ret指令来实现,现在已经不用了),而是采用异常表来完成。

3.3.10 同步指令

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,更常见的是直接将它称为“锁”)来实现的。

方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。

虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。

在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitor-enter和monitorexit两条指令来
支持synchronized关键字的语义,正确实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持;

3.3.11 公有设计,私有实现

《Java虚拟机规范》描绘了Java虚拟机应有的共同程序存储格式:Class文件格式以及字节码指令集。这些内容与硬件、操作系统和具体的Java虚拟机实现之间是完全独立的,虚拟机实现者可能更愿意把它们看作程序在各种Java平台实现之间互相安全地交互的手段。

理解公有设计与私有实现之间的分界线是非常有必要的,任何一款Java虚拟机实现都必须能够读取Class文件并精确实现包含在其中的Java虚拟机代码的语义。

拿着《Java虚拟机规范》一成不变地逐字实现其中要求的内容当然是一种可行的途径,但一个优秀的虚拟机实现,在满足《Java虚拟机规范》的约束下对具体实现做出修改和优化也是完全可行的,并且《Java虚拟机规范》中明确鼓励实现者这样去做。

只要优化以后Class文件依然可以被正确读取,并且包含在其中的语义能得到完整保持,那实现者就可以选择以任何方式去实现这些语义,虚拟机在后台如何处理Class文件完全是实现者自己的事情,只要它在外部接口上看起来与规范描述的一致即可

虚拟机实现者可以使用这种伸缩性来让Java虚拟机获得更高的性能、更低的内存消耗或者更好的可移植性,选择哪种特性取决于Java虚拟机实现的目标和关注点是什么,虚拟机实现的方式主要有以下两种:

  • 将输入的Java虚拟机代码在加载时或执行时翻译成另一种虚拟机的指令集;
  • 将输入的Java虚拟机代码在加载时或执行时翻译成宿主机处理程序的本地指令集(即即时编译器代码生成技术)。

精确定义的虚拟机行为和目标文件格式,不应当对虚拟机实现者的创造性产生太多的限制,Java虚拟机是被设计成可以允许有众多不同的实现,并且各种实现可以在保持兼容性的同时提供不同的新的、有趣的解决方案。

3.3.12 Class文件结构的发展

Class文件结构自《Java虚拟机规范》初版订立以来,已经有超过二十年的历史。这二十多年间,Java技术体系有了翻天覆地的改变,JDK的版本号已经从1.0提升到了13。相对于语言、API以及Java技术体系中其他方面的变化,Class文件结构一直处于一个相对比较稳定的状态,Class文件的主体结构、字节码指令的语义和数量几乎没有出现过变动,所有对Class文件格式的改进,都集中在访问标志、属性表这些设计上原本就是可扩展的数据结构中添加新内容。

如果以《Java虚拟机规范(第2版》(对应于JDK1.4,是Java2的奠基版本)为基准进行比较的话,在后续Class文件格式的发展过程中,访问标志新加入了ACC_SYNTHETIC、ACC_ANNOTATION、ACC_ENUM、ACC_BRIDGE、ACC_VARARGS共五个标志。属性表集合中,在JDK5到JDK12发展过程中一共增加了20项新属性,这些属性大部分是用于支持Java中许多新出现的语言特性,如枚举、变长参数、泛型、动态注解等。还有一些是为了支持性能改进和调试信息,譬如JDK6的新类型校验器的StackMapTable属性和对非Java代码调试中用到的SourceDebugExtension属性。

Class文件格式所具备的平台中立(不依赖于特定硬件及操作系统)、紧凑、稳定和可扩展的特点,是Java技术体系实现平台无关、语言无关两项特性的重要支柱。

3.4 虚拟机类加载机制

上一章我们学习了Class文件存储格式的具体细节,在Class文件中描述的各类信息,最终都需要加载到虚拟机中之后才能被运行和使用。而虚拟机如何加载这些Class文件,Class文件中的信息进入到虚拟机后会发生什么变化,这些都是本章将要讲解的内容。

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

这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为Java应用提供了极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。例如,编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类,用户可以通过Java预置的或自定义类加载器,让某个本地的应用程序在运行时从网络或其他地方上加载一个二进制流作为其程序代码的一部分。这种动态组装应用的方式目前已广泛应用于J ava程序之中,从最基础的Applet、JSP到相对复杂的OSGi技术,都依赖着Java语言运行期类加载才得以诞生。

为了避免语言表达中可能产生的偏差,在正式开始本章以前,笔者先设立两个语言上的约定:

  • 第一,在实际情况中,每个Class文件都有代表着Java语言中的一个类或接口的可能,后文中直接对“类型”的描述都同时蕴含着类和接口的可能性,而需要对类和接口分开描述的场景,笔者会特别指明;
  • 第二,与前面介绍Class文件格式时的约定一致,本章所提到的“Class文件”也并非特指某个存在于具体磁盘中的文件,而应当是一串二进制字节流,无论其以何种形式存在,包括但不限于磁盘文件、网络、数据库、内存或者动态产生等。

3.4.1 类加载的时机

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initializati-
on)、使用(using)和卸载(unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如图所示(类的生命周期)。

图中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。请注意,这里笔者写的是按部就班地“开始”,而不是按部就班地“进行”或按部就班地“完成”,强调这点是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

关于在什么情况下需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  • 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:

    • 使用new关键字实例化对象的时候。
    • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
    • 调用一个类型的静态方法的时候。
  • 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
  • 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  • 当使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  • 当一个接口中定义了JDK8新加入的默认方法(被defaut关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

对于这六种会触发类型进行初始化的场景,《Java虚拟机规范》中使用了一个非常强烈的限定语——“有且只有”,这六种场景中的行为称为对一个类型进行主动引用
除此之外,所有引用类型的方式都不会触发初始化,称为被动引用

被动引用 demo1:通过子类引用父类的静态字段,不会导致子类初始化

/*** @author wql* @date 2023/5/7 22:28*/
public class SuperClass {static {System.out.println("SuperClass init!");}public static int value = 123;
}
/*** @author wql* @date 2023/5/7 22:29*/
public class SubClass extends SuperClass {static {System.out.println("SubClass init!");}
}
/*** @author wql* @date 2023/5/7 22:30*/
public class Test01 {public static void main(String[] args) {System.out.println(SubClass.value);}
}

输出结果:

Connected to the target VM, address: '127.0.0.1:51941', transport: 'socket'
SuperClass init!
123
Disconnected from the target VM, address: '127.0.0.1:51941', transport: 'socket'

上述代码运行之后,只会输出“SuperClass init!”,而不会输出“SubClass init!”。对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
至于是否要触发子类的加载和验证阶段,在《Java虚拟机规范》中并未明确规定,所以这点取决于虚拟机的具体实现。对于HotSpot虚拟机来说,可通过-XX:+TraceClassLoading参数观察到此操作是会导致子类加载的;

被动引用 demo2:通过数组定义来引用类,不会触发此类的初始化

/*** @author wql* @date 2023/5/7 22:30*/
public class Test01 {public static void main(String[] args) {SuperClass[] superClasses = new SuperClass[10];}
}

输出结果:

Connected to the target VM, address: '127.0.0.1:52251', transport: 'socket'
Disconnected from the target VM, address: '127.0.0.1:52251', transport: 'socket'

为了节省版面,这段代码复用了代码中的SuperClass,运行之后发现没有输出“SuperClass init!”,说明并没有触发类org.fenixsoft.classloading.SuperClass的初始化阶段。

但是这段代码里面触发了另一个名为“[L-com.ooo.SuperClass”的类的初始化阶段,对于用户代码来说,这并不是一个合法的类型名称,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令newarray触发。这个类代表了一个元素类型为com.ooo.SuperClass的一维数组,数组中应有的属性和方法(用户可直接使用的只有被修饰为public的length属性和clone()方法)都实现在这个类里。Java语言中对数组的访问要比C/C++相对安全,很大程度上就是因为这个类包装了数组元素的访问,而C/C++中则是直接翻译为对数组指针的移动。在Java语言里,当检查到发生数组越界时会抛出java.lang.ArrayI-ndexOutOfBoundsException异常,避免了直接造成非法内存访问。

被动引用 demo2:常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

/*** @author wql* @date 2023/5/7 22:42*/
public class ConstClass {static {System.out.println("ConstClass init ");}public static final String HELLO_WORLD = "helloWorld";
}
/*** @author wql* @date 2023/5/7 22:30*/
public class Test01 {public static void main(String[] args) {System.out.println(ConstClass.HELLO_WORLD);}
}

输出结果:

Connected to the target VM, address: '127.0.0.1:52588', transport: 'socket'
helloWorld
Disconnected from the target VM, address: '127.0.0.1:52588', transport: 'socket'

上述代码运行之后,也没有输出“ConstClass init!”,这是因为虽然在Java源码中确实引用了ConstClass类的常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello world”直接存储在NotInitialization类的常量池中,以后NotInitialization对常量ConstClass.HELLOWORLD的引用,实际都被转化为NotInitialization类对自身常量池的引用了。也就是说,实际上NotInitialization的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译成Class文件后就已不存在任何联系了。

接口的加载过程与类加载过程稍有不同,针对接口需要做一些特殊说明:接口也有初始化过程,这点与类是一致的,上面的代码都是用静态语句块“static{}”来输出初始化信息的,而接口中不能使用“static{}”语句块,但编译器仍然会为接口生成“<clinit>()”类构造器,用于初始化接口中所定义的成员变量。
接口与类真正有所区别的是前面讲述的六种“有且仅有”需要触发初始化场景中的第三种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化

3.4.2 类加载的过程

接下来我们会详细了解Java虚拟机中类加载的全过程,即加载、验证、准备、解析和初始化这五个阶段所执行的具体动作。

3.4.2.1 加载

“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段,希望读者没有混淆这两个看起来很相似的名词。在加载阶段,Java虚拟机需要完成以下三件事情:

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

《Java虚拟机规范》对这三点要求其实并不是特别具体,留给虚拟机实现与Java应用的灵活度都是相当大的。

例如“通过一个类的全限定名来获取定义此类的二进制字节流”这条规则,它并没有指明二进制字节流必须得从某个Class文件中获取,确切地说是根本没有指明要从哪里获取、如何获取。仅仅这一点空隙,Java虚拟机的使用者们就可以在加载阶段搭构建出一个相当开放广阔的舞台,Java发展历程中,充满创造力的开发人员则在这个舞台上玩出了各种花样,许多举足轻重的Java技术都建立在这一基础之上,例如:

  • 从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础;
  • 从网络中获取,这种场景最典型的应用就是Web Applet;
  • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass()来为特定接口生成形式为“*Proxy”的代理类的二进制字节流;
  • 由其他文件生成,典型场景是JS P应用,由JSP文件生成对应的Class文件;
  • 从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群的分发
  • 可以从加密文件中获取,这是典型的防Class文件被反编译的保护措施,通过加载时解密Class文件来保障程序运行逻辑不被窥探。

相对于类加载过程的其他阶段,非数组类型的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的阶段。

加载阶段既可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的findClass()或loadClass()方法),实现根据自己的想法来赋予应用程序获取运行代码的动态性。

对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(Element Type,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载,一个数组类(下面简称为C)创建过程遵循以下规则:

  • 如果数组的组件类型(Component Tyoe,指的是数组去掉一个维度的类型,注意和前面的元素类型区分开来)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组C将被标识在加载该组件类型的类加载器的类名称空间上(这点很重要,一个类型必须与类加载器一起确定唯一性);
  • 如果数组的组件类型不是引用类型(例如int[]数组的组件类型为int),Java虚拟机将会把数组C标记为与引导类加载器关联;
  • 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类和接口访问到。

加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实现自行定义,《Java虚拟机规范》未规定此区域的具体数据结构。

类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。

3.4.2.2 验证

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

Java语言本身是相对安全的编程语言(起码对于C/C++来说是相对安全的),使用纯粹的Java代码无法做到诸如访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果尝试这样去做了,编译器会毫不留情地抛出异常、拒绝编译。但前面也曾说过,Class文件并不一定只能由Java源码编译而来,它可以使用包括靠键盘0和1直接在二进制编辑器中敲出Class文件在内的任何途径产生。

上述Java代码无法做到的事情在字节码层面上都是可以实现的,至少语义上是可以表达出来的。Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是Java虚拟机保护自身的一项必要措施。

验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从代码量和耗费的执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。

但是《Java虚拟机规范》的早期版本(第1、2版)对这个阶段的检验指导是相当模糊和笼统的,规范中仅列举了一些对Class文件格式的静态和结构化的约束,要求虚拟机验证到输入的字节流如不符合Class文件格式的约束,就应当抛出一个java.lang.VerifyError异常或其子类异常,但具体应当检查哪些内容、如何检查、何时进行检查等,都没有足够具体的要求和明确的说明。

直到2011年《Java虚拟机规范(JavaSE7版)》出版,规范中大幅增加了验证过程的描述(篇幅从不到10页增加到130页),这时验证阶段的约束和验证规则才变得具体起来。受篇幅所限,本文中无法逐条规则去讲解,但从整体上看,验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证元数据验证字节码验证符号引用验证

3.4.2.2.1 文件格式验证

第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
这一阶段可能包括下面这些验证点:

  • 是否以魔数0XCAFEBABE开头;
  • 主、次版本号是否在当前Java虚拟机接受范围之内;
  • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志);
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量;
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据;
  • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息;

实际上第一阶段的验证点还远不止这些,上面所列的只是从HotSpot虚拟机源码中摘抄的一小部分内容,该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。

这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。

3.4.2.2.2 元数据验证

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,这个阶段可能包括的验证点如下:

  • 这个类是否有父类(除了java.la ng.0bject之外,所有的类都应当有父类);
  • 这个类的父类是否继承了不允许被继承的类(被final修饰的类);
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法;
  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等);

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

3.4.2.2.3 字节码验证

第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,例如:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况;
  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上;
  • 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的;

如果一个类型中有方法体的字节码没有通过字节码验证,那它肯定是有问题的;但如果一个方法体通过了字节码验证,也仍然不能保证它一定就是安全的。即使字节码验证阶段中进行了再大量、再严密的检查,也依然不能保证这一点。

这里涉及了离散数学中一个很著名的问题–“停机问题”(Halting Problem),即不能通过程序准确地检查出程序是否能在有限的时间之内结束运行。在我们讨论字节码校验的上下文语境里,通俗一点的解释是通过程序去校验程序逻辑是无法做到绝对准确的,不可能用程序来准确判定一段程序是否存在Bug。

由于数据流分析和控制流分析的高度复杂性,Java虚拟机的设计团队为了避免过多的执行时间消耗在字节码验证阶段中,在JDK6之后的Javac编译器和Java虚拟机里进行了一项联合优化,把尽可能多的校验辅助措施挪到Javac编译器里进行。

具体做法是给方法体Code属性的属性表中新增加了一项名为“StackMapTable”的新属性,这项属性描述了方法体所有的基本块(Basic Block,指按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java虚拟机就不需要根据程序推导这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可。这样就将字节码验证的类型推导转变为类型检查,从而节省了大量校验时间。

理论上StackMapTable属性也存在错误或被篡改的可能,所以是否有可能在恶意篡改了Code属性的同时,也生成相应的StackMapTable属性来骗过虚拟机的类型校验,则是虚拟机设计者们需要仔细思考的问题。

JDK6的HotSpot虚拟机中提供了-XX:-UseSplitVerifier选项来关闭掉这项优化,或者使用参数-XX:+FailOverToOldVerifier要求在类型校验失败的时候退回到旧的类型推导方式进行校验。而到了JDK7之后,尽管虚拟机中仍然保留着类型推导验证器的代码,但是对于主版本号大于50(对应JDK6)的Cl ass文件,使用类型检查来完成数据流分析校验则是唯一的选择,不允许再退回到原来的类型推导的校验方式。

3.4.2.2.4 符号引用验证

最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段一解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
  • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
  • 符号引用中的类、字段、方法的可访问性(private、protected、public、)是否可被当前类访问。

符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError的子类异常,典型的如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

3.4.2.3 准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了;

关于准备阶段,还有两个容易产生混淆的概念笔者需要着重强调,首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次是这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:

public static int value=123

那变量value在准备阶段过后的初始值为0而不是123,因为这时尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作要到类的初始化阶段才会被执行。表列出了Java中所有基本数据类型的零值。

数据类型 零值 数据类型 零值
int 0 boolean false
long 0L float 0.0f
short (short)0 double 0.0d
char ‘\u0000’ reference null
bute (byte)0

上面提到在“通常情况”下初始值是零值,那言外之意是相对的会有某些“特殊情况”:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值,假设上面类变量value的定义修改为:

public static final int value =123

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

3.4.2.4 解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用已经出现过多次,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,那解析阶段中所说的直接引用与符号引用又有什么关联呢?

  • 符号引用(Symbolic References):
    符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
  • 直接引用(Direct References):
    直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在;

《Java虚拟机规范》之中并未规定解析阶段发生的具体时间,只要求了在执行ane-warray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic这17个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现可以根据需要来自行判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。

类似地,对方法或者字段的访问,也会在解析阶段中对它们的可访问性(public、protected、private、
<package>)进行检查,至于其中的约束规则已经是ava语言的基本常识,笔者就不再赘述了。

对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。无论是否真正执行了多次解析动作,Java虚拟机都需要保证的是在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能够成功;同样地,如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪怕这个请求的
符号在后来已成功加载进Java虚拟机内存之中。

不过对于invokedynamic指令,上面的规则就不成立了。当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令也同样生效。

因为invokedynamic指令的目的本来就是用于动态语言支持,它对应的引用称为“动态调用点限定符(Dynamically-Computed Call Site Specifier)”,这里“动态”的含义是指必须等到程序实际运行到这条指令时,解析动作才能进行。相对地,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有开始执行代码时就提前进行解析。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_M ethodHandle_info、CONSTANT_Dyna-mic_info和CONSTANT_InvokeDynamic_info 8种常量类型。

下面笔者将讲解前4种引用的解析过程,对于后4种,它们都和动态语言支持密切相关,由于Java语言本身是一门静态类型语言,在没有讲解清楚invokedynamic指令的语意之前,我们很难将它们直观地和现意之前,我们很难将它们直观地和现在的Java语言语法对应上,因此笔者将延后到介绍动态语言调用时一起分析讲解。

3.4.2.4.1 类或接口的解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:

  • 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。
  • 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,那将会按照第一点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表该数组维度和元素的数组对象。
  • 如果上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.llegalAccessError异常。

针对上面第3点访问权限验证,在JDK9引入了模块化以后,一个public类型也不再意味着程序任何位置都有它的访问权限,我们还必须检查模块间的访问权限。
如果我们说一个D拥有C的访问权限,那就意味着以下3条规则中至少有其中一条成立:

  • 被访问类C是public的,并且与访问类D处于同一个模块。
  • 被访问类C是public的,不与访问类D处于同一个模块,但是被访问类C的模块允许被访问类D的模块进行访问。
  • 被访问类C不是public的,但是它与访问类D处于同一个包中。

在后续涉及可访问性时,都必须考虑模块间访问权限隔离的约束,即以上列举的3条规则,这些内容在后面就不再复述了。

3.4.2.4.2 字段解析

要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。
如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。

如果解析成功完成,那把这个字段所属的类或接口用C表示,《Java虚拟机规范》要求按照如下步骤对C进行后续字段的搜索:

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

如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常。

以上解析规则能够确保Java虚拟机获得字段唯一的解析结果,但在实际情况中,Javac编译器往往会采取比上述规范更加严格一些的约束,譬如有一个同名字段同时出现在某个类的接口和父类当中,或者同时在自己或父类的多个接口中出现,按照解析规则仍是可以确定唯一的访问字段,但Javac编译器就可能直接拒绝其编译为Class文件。

3.4.2.4.3 方法解析

方法解析的第一个步骤与字段解析一样,也是需要先解析出方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,那么我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的方法搜索:

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

最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常。

3.4.2.4.3 接口方法解析

接口方法也是需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果
解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:

  • 与类的方法解析相反,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那么就直接抛出java.lang.IncompatibleClassChangeError异常。
  • 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束
  • 否则,在接口C的父接口中递归查找,直到java.lang.Object类(接口方法的查找范围也会包括Object类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  • 对于规则3,由于Java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找,《Java虚拟机规范》中并没有进一步规则约束应该返回哪一个接口方法。但与之前字段查找类似地,不同发行商实现的Ja-vac编译器有可能会按照更严格的约束拒绝编译这种代码来避免不确定性。
  • 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。在JDK9之前,Java接口中的所有方法都默认是public的,也没有模块化的访问约束,所以不存在访问权限的问题,接口方法的符号解析就不可能抛出java.lang.IllegalAccessError异常。

在JDK9中增加了接口的静态私有方法,也有了模块化的访问约束,所以从JDK9起,接口方法的访问也完全有可能因访问权限控制而出现java.lang.IegalAccessError异常。

3.4.2.4.4 初始化

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器<clinit>()方法的过程。<clinit>()并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物,但我们非常有必要了解这个方法具体是如何产生的,以及<clinit>()方法执行过程中各种可能会影响程序运行行为的细节,这部分比起其他类加载过程更贴近于普通的程序开发人员的实际工作。

  • <clinit>方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
  • <clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()方法不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的()方法已经执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()方法的类型肯定是java.lang.0bject。
  • 由于父类的<clinit>方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作,如代码中,字段B的值将会是2而不是1。

  • <clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法;
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
  • Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行完毕<clinit>()方法。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
3.4.2.4.5 类加载器

Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。

类加载器可以说是Java语言的一项创新,它是早期Java语言能够快速流行的重要原因之一。类加载器最初是为了满足Java Applet的需求而设计出来的,在今天用在浏览器上的Java Applet技术基本上已经被淘汰,但类加载器却在类层次划分、OSGi、程序热部署、代码加密等领域大放异彩,成为Java技术体系中一块重要的基石,可谓是失之桑榆,收之东隅。

3.4.3 类与类加载器

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等各种情况。如果没有注意到类加载器的影响,在某些情况下可能会产生具有迷惑性的结果,代码中演示了不同的类加载器对instanceof关键字运算的结果的影响。
代码演示:

import java.io.InputStream;/*** 类加载器 与 instanceof 关键字演示** @author wql* @date 2023/5/7 22:30*/
public class ClassLoaderTest {public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {ClassLoader classLoader = new ClassLoader() {@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {try {String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";InputStream is = getClass().getResourceAsStream(fileName);if (is == null) {return super.loadClass(name);}byte[] b = new byte[is.available()];is.read(b);return defineClass(name, b, 0, b.length);} catch (Exception e) {throw new ClassNotFoundException(name);}}};Object obj = classLoader.loadClass("com.ooo.ClassLoaderTest").newInstance();System.out.println(obj.getClass());System.out.println(obj instanceof com.ooo.ClassLoaderTest);}
}

结果如下所示:

代码中构造了一个简单的类加载器,尽管它极为简陋,但是对于这个演示来说已经足够。
它可以加载与自己在同一路径下的Class文件,我们使用这个类加载器去加载了一个名为“com.ooo.ClassLoaderTest”的类,并实例化了这个类的对象。

两行输出结果中,从第一行可以看到这个对象确实是类com.ooo.ClassLoaderTest实例化出来的,但在第二行的输出中却发现这个对象与类com.ooo.ClassLoaderTest做所属类型检查的时候返回了false。这是因为Java虚拟机中同时存在了两个ClassLoaderTest类,一个是由虚拟机的应用程序类加载器所加载的,另外一个是由我们自定义的类加载器加载的,虽然它们都来自同一个Class文件,但在Java虚拟机中仍然是两个互相独立的类,做对象所属类型检查时的结果自然为false。

3.4.3.1 双亲委派模型

站在Java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现曾,是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader

站在Java开发人员的角度来看,类加载器就应当划分得更细致一些。
自JDK1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构,尽管这套架构在Java模块化系统出现后有了一些调整变动,但依然未改变其主体结构;

本节内容将针对JDK8及之前版本的Java来介绍什么是三层类加载器,以及什么是双亲委派模型。对于这个时期的Java应用,绝大多数Java程序都会使用到以下3个系统提供的类加载器来进行加载。

  • 启动类加载器(Bootstrap Class Loader):前面已经介绍过,这个类加载器负责加载存放在<JAVA_HOME>lib目录,或者被一Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可,代码展示的就是java.lang.ClassLoader.getClassLoader()方法的代码片段,其中的注释和代码实现都明确地说明了以null值来代表引导类加载器的约定规则。
  • 扩展类加载器(Extension Class Loader):这个类加载器是在类sun.m isc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。根据“扩展类加载器”这个名称,就可以推断出这是一种Java系统类库的扩展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展JavaSE的功能,在JDK9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。
  • 应用程序类加载器(Application Class Loader):这个类加载器由sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystemClassloader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载
    用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载

    JDK9之前的Java应用都是由这三种类加载器互相配合来完成加载的,如果用户认为有必要,还可以加入自定义的类加载器来进行拓展,典型的如增加除了磁盘位置之外的Class文件来源,或者通过类加载器实现类的隔离、重载等功能。
    这些类加载器之间的协作关系“通常”会如图所示。图中展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

读者可能注意到前面描述这种类加载器协作关系时,笔者专门用双引号强调这是“通常”的协作关系。类加载器的双亲委派模型在JDK1.2时期被引入,并被广泛应用于此后几乎所有的Java程序中,但它并不是一个具有强制性约束力的模型,而是Java设计者们推荐给开发者的一种类加载器实现的最佳实践。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。

例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。

如果读者有兴趣的话,可以尝试去写一个与rt.jar类库中已有类重名的Java类,将会发现它可以正常编译,但永远无法被加载运行。

双亲委派模型对于保证Java程序的稳定运作极为重要,但它的实现却异常简单,用以实现双亲委派的代码只有短短十余行,全部集中在java.lang.ClassLoader的loadClass()方法之中;

这段代码的逻辑清晰易懂:先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载。

3.4.3.2 破坏双亲委派模型

上文提到过双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。在Java的世界中大部分的类加载器都遵循这个模型,但也有例外的情况,直到Java模块化出现为止,双亲委派模型主要出现过3次较大规模“被破坏”的情况。

  • 双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前一一即JDK1.2面世以前的“远古”时代。由于双亲委派模型在JDK1.2之后才被引入,但是类加载器的概念和抽象类ja va.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK1.2之后的java.lang.ClassLoader中添加一个新的protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在loadClass()中编写代码。上节我们已经分析过loadClass()方法,双亲委派的具体逻辑就实现在这里面,按照loadClass()方法的逻辑,如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。
  • 双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?
    这并非是不可能出现的事情,一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器来完成加载(在JDK1.3时加入到rt.jar的),肯定属于Java中很基础的类型了。但JNDI存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口(Service Provider Interface,SPI)的代码,现在问题来了,启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContext-ClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。有了线程上下文类加载器,程序就可以做一些“舞弊”的事情了。JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码,这是一种父加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、JDBC、JCE、JAXB和JBI等。不过,当SPI的服务提供者多于一个的时候,代码就只能根据具体提供者的类型来硬编码判断,为了消除这种极不优雅的实现方式,在JDK6时,JDK提供了java.util.ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式,这才算是给SPI的加载提供了一种相对合理的解决方案。
  • 双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。说白了就是希望Java应用程序能像我们的电脑外设那样,接上鼠标、u盘,不用重启机器就能立即使用,鼠标有问题或要升级就换个鼠标,不用关机也不用重启。对于个人电脑来说,重启一次其实没有什么大不了的,但对于一些生产系统来说,关机重启一次可能就要被列为生产事故,这种情况下热部署就对软件开发者,尤其是大型系统或企业级软件开发者具有很大的吸引力。
    早在2008年,在Java社区关于模块化规范的第一场战役里,由Sun/Oracle公司所提出的JSR-294、JSR-277规范提案就曾败给以IBM公司主导的JSR-291(即OSGiR4.2)提案。尽管Sun/Oracle并不甘心就此失去Java模块化的主导权,随即又再拿出Jigsaw项目迎战,但此时OSGi已经站稳脚跟,成为业界“事实上”的Java模块化标准。曾经在很长一段时间内,IBM凭借着oSGi广泛应用基础让Jigsaw吃尽苦头,其影响一直持续到Jigsaw随JDK9面世才算告一段落。而且即使Jigsaw现在已经是Java的标准功能了,它仍需小心翼翼地避开OSGi运行期动态热部署上的优势,仅局限于静态地解决模块间封装隔离和访问控制的问题,现在我们先来简单看一看OSGi是如何通过类加载器实现热部署的。
    OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在0SGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:

    • 将以java.*开头的类,委派给父类加载器加载。
    • 否则,将委派列表名单内的类,委派给父类加载器加载。
    • 否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
    • 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
    • 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
    • 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
    • 否则,类查找失败。

上面的查找顺序中只有开头两点仍然符合双亲委派模型的原则,其余的类查找都是在平级的类加载器中进行的,关于OSGi的其他内容,笔者就不再展开了。

本节中笔者虽然使用了“被破坏”这个词来形容上述不符合双亲委派模型原则的行为,但这里“被破坏”并不一定是带有贬义的。只要有明确的目的和充分的理由,突破旧有原则无疑是一种创新。正如OSGi中的类加载器的设计不符合传统的双亲委派的类加载器架构,且业界对其为了实现热部署而带来的额外的高复杂度还存在不少争议,但对这方面有了解的技术人员基本还是能达成一个共识,认为OSGi中对类加载器的运用是值得学习的,完全弄懂了OSGi的实现,就算是掌握了类加载器的精粹。

3.4.3.3 Java模块化系统

在JDK9中引入的Java模块化系统(Java Platform Module System,JPMS)是对Java技术的一次重要升级,为了能够实现模块化的关键目标——可配置的封装隔离机制,Java虚拟机对类加载架构也做出了相应的变动调整,才使模块化系统得以顺利地运作。JDK9的模块不仅仅像之前的JAR包那样只是简单地充当代码的容器,除了代码外,Java的模块定义还包含以下内容:

  • 依赖其他模块的列表。
  • 导出的包列表,即其他模块可以使用的列表。
  • 开放的包列表,即其他模块可反射访问模块的列表。·使用的服务列表。
  • 提供服务的实现列表。

可配置的封装隔离机制首先要解决JDK9之前基于类路径(ClassPath)来查找依赖的可靠性问题。此前,如果类路径中缺失了运行时依赖的类型,那就只能等程序运行到发生该类型的加载、链接时才会报出运行的异常。而在JDK9以后,如果启用了模块化进行封装,模块就可以声明对其他模块的显式依赖,这样Java虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备,如有缺失那就直接启动失败,从而避免了很大一部分由于类型依赖而引发的运行时异常。

可配置的封装隔离机制还解决了原来类路径上跨JAR文件的public类型的可访问性问题。JDK9中的public类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性控制,必须明确声明其中哪一些public的类型可以被其他哪一些模块访问,这种访问控制也主要是在类加载过程中完成的,具体内容笔者在前文对解析阶段的讲解中已经介绍过。

3.4.3.3.1 模块的兼容性

为了使可配置的封装隔离机制能够兼容传统的类路径查找机制,JDK9提出了与“类路径”(ClassPath)相对应的“模块路径”(ModulePath)的概念。简单来说,就是某个类库到底是模块还是传统的JAR包,只取决于它存放在哪种路径上。只要是放在类路径上的JAR文件,无论其中是否包含模块化信息(是否包含了module-info.class文件),它都会被当作传统的JAR包来对待;相应地,只要放在模块路径上的JA R文件,即使没有使用JMOD后缀,甚至说其中并不包含module-info.class文件,它也仍然会被当作一个模块来对待。

模块化系统将按照以下规则来保证使用传统类路径依赖的Java程序可以不经修改地直接运行在JDK9及以后的Java版本上,即使这些版本的JDK已经使用模块来封装了JavaSE的标准类库,模块化系统的这套规则也仍然保证了传统程序可以访问到所有标准类库模块中导出的包。

  • JAR文件在类路径的访问规则:所有类路径下的JAR文件及其他资源文件,都被视为自动打包在一个匿名模块(Unnamed Module)里,这个匿名模块几乎是没有任何隔离的,它可以看到和使用类路径上所有的包、JDK系统模块中所有的导出包,以及模块路径上所有模块中导出的包。
  • 模块在模块路径的访问规则:模块路径下的具名模块(Named Module)只能访问到它依赖定义中列明依赖的模块和包,匿名模块里所有的内容对具名模块来说都是不可见的,即具名模块看不见传统JAR包的内容。
  • JAR文件在模块路径的访问规则:如果把一个传统的、不包含模块定义的JAR文件放置到模块路径中,它就会变成一个自动模块(Automatic Module)。尽管不包含module-info.class,但自动模块将默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自己所有的包。

以上3条规则保证了即使Java应用依然使用传统的类路径,升级到JDK9对应用来说几乎(类加载器上的变动还是可能会导致少许可见的影响,将在下节介绍)不会有任何感觉,项目也不需要专门为了升级JDK版本而去把传统JAR包升级成模块。
除了向后兼容性外,随着JDK9模块化系统的引入,更值得关注的是它本身面临的模块间的管理和兼容性问题:如果同一个模块发行了多个不同的版本,那只能由开发者在编译打包时人工选择好正确版本的模块来保证依赖的正确性。Java模块化系统目前不支持在模块定义中加入版本号来管理和约束依赖,本身也不支持多版本号的概念和版本选择功能。前面这句话引来过很多的非议,但它确实是Oracle官方对模块化系统的明确的目标说明。我们不论是在Java命令、Java类库的API抑或是《Java虚拟机规范》定义的Class文件格式里都能轻易地找到证据,表明模块版本应是编译、加载、运行期间都可以使用的。譬如输入“java–list-modules”,会得到明确带着版本号的模块列表:

java.base@12.0.1
java.compilere12.0.1
java.datatransfere12.0.1
java.desktope12.0.1
java.instrument@12.0.1
java.logginge12.0.1

在JDK9时加入Class文件格式的M odule属性,里面有module_version_index这样的字段,用户可以在编译时使用“javac–module-version”来指定模块版本,在Java类库API中也存在java.lang.module.ModuleDescriptor.Version这样的接口可以在运行时获取到模块的版本号。这一切迹象都证明了Java模块化系统对版本号的支持本可以不局限在编译期。而官方却在Jigsaw的规范文件、JavaOne大会的宣讲和与专家的讨论列表中,都反复强调“JPMS的目的不是代替OSGi”,“JPMS不支持模块版本”这样的话语,如图所示。

Oracle给出的理由是希望维持一个足够简单的模块化系统,避免技术过于复杂。但结合JCP执行委员会关于的Jigsaw投票中Oracle与IBM、RedHat的激烈冲突,实在很难让人信服这种设计只是单纯地基于技术原因,而不是厂家之间互相博弈妥协的结果。Jigsaw仿佛在刻意地给OSGi让出一块生存空间,以换取IBM支持或者说不去反对Jigsaw,其代价就是几乎宣告Java模块化系统不可能拥有像OSGi那样支持多版本模块并存、支持运行时热替换、热部署模块的能力,可这却往往是一个应用进行模块化的最大驱动力所在。如果要在JDK9之后实现这种目的,就只能将OSGi和JPMS混合使用,如图所示,这无疑带来了更高的复杂度。模块的运行时部署、替换能力没有内置在Java模块化系统和Java虚拟机之中,仍然必须通过类加载器去实现,实在不得不说是一个缺憾。

其实Java虚拟机内置的VMTI接口(java.lang.instrument.Instrumentation)提供了一定程度的运行时修改类的能力(RedefineClass、RetransformClass),但这种修改能力会受到很多限制,不可能直接用来实现OSGi那样的热替换和多版本并存,用在IntelliJ IDE、Eclipse这些IDE上做HotSwap(是指IDE编辑方法的代码后不需要重启即可生效)倒是非常的合适。也曾经有一个研究性项目Dynamic Code Evolution VM(D ECVM)探索过在虚拟机内部支持运行时类型替换的可行性,允许任意修改已加载到内存中的Class,并不损失任何性能,但可惜已经很久没有更新了,最新版只支持到Jdk7;

3.4.3.3.2 模块化下的类加载器

为了保证兼容性,JDK9并没有从根本上动摇从JDK1.2以来运行了二十年之久的三层类加载器架构以及双亲委派模型。但是为了模块化系统的顺利施行,模块化下的类加载器仍然发生了一些应该被注意到变动,主要包括以下几个方面。

  • 首先,是扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。这其实是一个很顺理成章的变动,既然整个JDK都基于模块化进行构建(原来的rt.jar和tooIs.jar被拆分成数十个JMOD文件),其中的Java类库就已天然地满足了可扩展的需求,那自然无须再保留<JAVA_H OME>\lib\ext目录,此前使用这个目录或者java.ext.dirs系统变量来扩展JDK功能的机制已经没有继续存在的价值了,用来加载这部分类库的扩展类加载器也完成了它的历史使命。类似地,在新版的JDK中也取消了〈JAVA_HOME>\jre目录,因为随时可以组合构建出程序运行所需的JRE来,譬如假设我们只使用java.base模块中的类型,那么随时可以通过以下命令打包出一个“J RE”:
  jlink -p $JAVA_HOME/jmods --add-modules java.base--output jre

其次,平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader,如果有程序直接依赖了这种继承关系,或者依赖了URLClassLoader类的特定方法,那代码很可能会在JDK9及更高版本的JDK中崩溃。现在启动类加载器、平台类加载器、应用程序类加载器全都继承于jdk.internal.loader.BuiltinClassLoader,在BuiltinClassLoader中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。两者的前后变化如图所示。

jdk9之前的类加载器继承架构

jdk9之后的类加载器继承架构

另外,读者可能已经注意到图中有“BootClassLoader”存在,启动类加载器现在是在Java虚拟机内部和Java类库共同协作实现的类加载器,尽管有了BootClassLoader这样的Java类,但为了与之前的代码保持兼容,所有在获取启动类加载器的场景(譬如Object.class.getClassLoader())中仍然会返回nul来代替,而不会得到BootClassLoader的实例。

jdk9后的类加载器委派关系

最后,JDK9中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了变动。当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,也许这可以算是对双亲委派的第四次破坏。在JDK9以后的三层类加载器的架构如图7-7所示,请读者对照图进行比较。在Java模块化系统明确规定了三个类加载器负责各自加载的模块,即前面所说的归属关系,如下所示。

  • 启动类加载器负责加载的模块:

    • java.base
      java.security.sasl
      java.datatransfer
      java.xmL
      java.desktop
      jdk.httpserver
      java.instrument
      jdk.internal.vm.ci
      java.logging
      jdk.management
      java.management
      jdk.management.agent
      java.management.rmi
      jdk.naming.rmi
      java.naming
      jdk.net
      java.prefs
      jdk.sctp
      java.rmi
      jdk.unsupported
  • 平台类加载器负责加载的模块:

    • java.activation*
      jdk.accessibility
      java.compiler*
      jdk.charsets
      java.corba大
      jdk.crypto.cryptoki
      java.scripting
      jdk.crypto.ec
      java.se
      jdk.dynalink
      java.se.ee
      jdk.incubator.httpclient
      java.security.jgss
      jdk.internal.vm.compiler大
      java.smartcardio
      jdk.jsobject
      java.sql
      jdk.localedata
      java.sql.rowset
      jdk.naming.dns
      java.transaction*
      jdk.scripting.nashorn
      java.xmL.bind*
      jdk.security.auth
      java.xml.crypto
      jdk.security.jgss java.xmL.Ws*
      jdk.xml.dom
      java.xml.ws.annotation*
      jdk.zipfs
  • 应用程序类加载器负责加载的模块:

    • jdk.aot
      jdk.jdeps
      jdk.attach
      jdk.jdi
      jdk.compiler
      jdk.jdwp.agent
      jdk.editpad
      jdk.jlink
      jdk.hotspot.agent
      jdk.jshel1
      jdk.internal.ed
      jdk.jstatd
      jdk.internal.jvmstat
      jdk.pack
      jdk.internal.le
      jdk.policytool
      jdk.internal.opt
      jdk.rmic
      jdk.jartool
      jdk.scripting.nashorn.shell
      jdk.javadoc jdk.xmL.bind*
      jdk.jcmd jdk.xml.Ws*
      jdk.jconsole

3.5 虚拟机字节码执行引擎

3.5.1 概述

执行引擎是Java虚拟机核心的组成部分之一。“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。

在《Java虚拟机规范》中制定了Java虚拟机字节码执行引擎的概念模型,这个概念模型成为各大发行商的Java虚拟机执行引擎的统一外观(Facade)。

在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,还可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎。

但从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果,本章将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。

3.5.2 运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(VirtualM achine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。
在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性之中曾。换言之,一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。

一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻、同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(Current Stack Frame),与这个栈帧所关联的方法被称为“当前方法”(Current Method)。

执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图所示。图所示的就是虚拟机栈和栈帧的总体结构,接下来,我们将会详细了解栈帧中的局部变量表、操作数栈、动态连接、方法返回地址等各个部分的作用和数据结构。

3.5.2.1 局部变量表

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

局部变量表的容量以变量槽(Variable Slot)为最小单位,《Java虚拟机规范》中并没有明确指出一个变量槽应占用的内存空间大小,只是很有导向性地说到每个变量槽都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存储,但这种描述与明确指出“每个变量槽应占用32位长度的内存空间”是有本质差别的,它允许变量槽的长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化,保证了即使在64位虚拟机中使用了64位的物理内存空间去实现一个变量槽,虚拟机仍要使用对齐和补白的手段让变量槽在外观上看起来与32位虚拟机中的一致。

既然前面提到了Java虚拟机的数据类型,在此对它们再简单介绍一下。

一个变量槽可以存放一个32位以内的数据类型,Java中占用不超过32位存储空间的数据类型有boolean、byte、char、short、int、float、reference和retunAddress这8种类型。

前面6种不需要多加解释,读者可以按照java语言中对应数据类型的概念去理解它们(仅是这样理解而已,Java语言和Java虚拟机中的基本数据类型是存在本质差别的;

而第7种reference类型表示对一个对象实例的引用,《Java虚拟机规范》既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但是一般来说,虚拟机实现至少都应当能通过这个引用做到两件事情,一是从根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引,二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则将无法实现《Java语言规范》中定义的语法约定。

8种returnAddress类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址,某些很古老的Java虚拟机曾经使用这几条指令来实现异常处理时的跳转,但现在也已经全部改为采用异常表来代替了。
对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。
Java语言中明确的64位的数据类型只有long和double两种。这里把long和double数据类型分割存储的做法与“long和double的非原子性协定”中允许把一次long和double数据类型读写分割为两次32位读写的做法有些类似,读者阅读到关于Java内存模型的内容时可以进行对比。不过,由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。
Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽,如果访问的是64位数据类型的变量,则说明会同时使用第N和N+1两个变量槽。对于两个相邻的共同存放一个64位数据的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个,《Java虚拟机规范》中明确要求了如果遇到进行这种操作的字节码序列,虚拟机就应该在类加载的校验阶段中抛出异常。

当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。

为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。不过,这样的设计除了节省栈帧空间以外,还会伴随有少量额外的副作用,例如在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为;

关于局部变量表,还有一点可能会对实际开发产生影响,就是局部变量不像前面介绍的类变量那样存在“
准备阶段”。通过之前的学习,我们已经知道类的字段变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。因此即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值,不会产生歧义。但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值,那它是完全不能使用的。所以不要认为Java中任何情况下都存在诸如整型变量默认为0、布尔型变量默认为false等这样的默认值规则。

3.5.2.2 操作数栈

操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。

同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。Javac编译器的数据流分析工作保证了在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。

譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。
举个例子,例如整数加法的字节码指令iadd,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int值出栈并相加,然后将相加的结果重新入栈。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上面的iadd指令为例,这个指令只能用于整型数的加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int型,不能出现一个long和一个float使用iadd命令相加的情况;

另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了,重叠的过程如图所示。

两个栈帧之间数据共享:

Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。后文会对基于栈的代码执行过程进行更详细的讲解,介绍它与更常见的基于寄存器的执行引擎有哪些差别。

3.5.2.3 动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。通过讲解,我们知道Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接

3.5.2.4 方法返回地址

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

  • 第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为“正常调用完成”(Normal Method Invocation Completion)。
  • 另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常调用完成(Abrupt Method Invocation Completion)”。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。

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

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。笔者这里写的“可能”是由于这是基于概念模型的讨论,只有具体到某一款Java虚拟机实现,会执行哪些操作才能确定下来。

3.5.3 方法调用

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。
在程序运行时,进行方法调用是最普遍、最频繁的操作之一,但之前已经讲过,Class文件的编译过程中不包含传统程序语言编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

3.5.3.1 解析

承接前面关于方法调用的话题,所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析(Resolution)
在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。

调用不同类型的方法,字节码指令集里设计了不同的指令。
在Java虚拟机支持以下5条方法调用字节码指令,分别是:

  • invokestatic。用于调用静态方法。
  • invokespecial。用于调用实例构造器<init>()方法、私有方法和父类中的方法。
  • invokevirtual。用于调用所有的虚方法。
  • invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。
  • invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方法就被称为“虚方法”(Virtual Method)。代码演示了一种常见的解析调用的例子,该样例中,静态方法sayHello()只可能属于类型StaticResolution,没有任何途径可以覆盖或隐藏这个方法。

使用javap 反编译

使用javap命令查看这段程序对应的字节码,会发现的确是通过invokestatic命令来调用sayHello()方法,而且其调用的方法版本已经在编译时就明确以常量池项的形式固化在字节码指令的参数之中;

Java中的非虚方法除了使用invokestatic、invokespecial调用的方法之外还有一种,就是被final修饰的实例方法。虽然由于历史设计的原因,final方法是使用invokevirtual指令来调用的,但是因为它也无法被覆盖,没有其他版本的可能,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。在《Java语言规范》中明确定义了被fimal修饰的方法是一种非虚方法。

解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。而另一种主要的方法调用形式:分派(Dispatch)调用则要复杂许多,它可能是静态的也可能是动态的,按照分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况,下面我们来看看虚拟机中的方法分派是如何进行的。

3.5.3.1.1 分派-静态分派

众所周知,Java是一门面向对象的程序语言,因为Java具备面向对象的3个基本特征:继承、封装和多态。本节讲解的分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟机之中是如何实现的,这里的实现当然不是语法上该如何写,我们关心的依然是虚拟机如何确定正确的目标方法。

  • 静态分派
    在开始讲解静态分派前,笔者先声明一点,“分派”(Dispatch)这个词本身就具有动态性,一般不应用在静态语境之中,这部分原本在英文原版的《Java虚拟机规范》和《Java语言规范》里的说法都是“Method Overload Resolution”,但部分其他外文资料和国内翻译的许多中文资料都将这种行为称为“静态分派”,所以笔者在此特别说明一下,以免读者阅读英文资料时遇到这两种说法产生疑惑。
    为了解释静态分派和重载(Overl-oad),笔者准备了一段经常出现在面试题中的程序代码,读者不妨先看一遍,想一下程序的输出结果是什么。后面的话题将围绕这个类的方法来编写重载代码,以分析虚拟机和编译器确定方法版本的过程。程序如代码

    结果如下:

    代码实际上是在考验阅读者对重载的理解程度,相信对Java稍有经验的程序员看完程序后都能得出正确的运行结果,但为什么虚拟机会选择执行参数类型为Human的重载版本呢?在解决这个问题之前,我们先通过如下代码来定义两个关键概念:

    我们把上面代码中的“Human”称为变量的“静态类型”(Static Type),或者叫“外观类型”(Apparent Type),后面的“Man”则被称为变量的“实际类型”(Actual Type)或者叫“运行时类型”(Runtime Type)。静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

解释清楚了静态类型与实际类型的概念,我们就把话题再转回到代码清单的样例代码中。main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象“demoTest”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中故意定义了两个静态类型相同,而实际类型不同的变量,但虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因。

需要注意Javac编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一”的,往往只能确定一个“相对更合适的”版本。这种模糊的结论在由0和1构成的计算机世界中算是个比较稀罕的事件,产生这种模糊结论的主要原因是字面量天生的模糊性,它不需要定义,所以字面量就没有显式的静态类型,它的静态类型只能通过语言、语法的规则去理解和推断。

重载方法优先匹配级

/*** @author wql* @date 2023/5/7 22:30*/
public class DemoTest {public static void sayHello(Object obj) {System.out.println("Hello Object");}public static void sayHello(int arg) {System.out.println("Hello int");}public static void sayHello(long arg) {System.out.println("Hello long");}public static void sayHello(Character arg) {System.out.println("Hello character");}public static void sayHello(char arg) {System.out.println("Hello char");}public static void sayHello(char... arg) {System.out.println("Hello char...");}public static void sayHello(Serializable... arg) {System.out.println("Hello serializable");}public static void main(String[] args) {sayHello('a');}
}

上面的代码运行后会输出:

这很好理解,’a’是一个char类型的数据,自然会寻找参数类型为char的重载方法,如果注释掉sayHello(char arg)方法,那输出会变为:

这个输出可能会让人摸不着头脑,一个字符或数字与序列化有什么关系?出现hello Serializable,是因为java.lang.Serializable是java.lang.Character类实现的一个接口,当自动装箱之后发现还是找不到装箱类,但是找到了装箱类所实现的接口类型,所以紧接着又发生一次自动转型。char可以转型成int,但是Character是绝对不会转型为Integer的,它只能安全地转型为它实现的接口或父类。Character还实现了另外一个接口java.lang.Comparable<Character>,如果同时出现两个参数分别为Serializable和Comparable<Character>的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动
转型为哪种类型,会提示“类型模糊”(Type Ambiguous),并拒绝编译。程序必须在调用时显式地指定字面量的静态类型,如:sayHello((Comparable<Character>)'a'),才能编译通过。但是如果读者愿意花费一点时间,绕过Javac编译器,自己去构造出表达相同语义的字节码,将会发现这是能够通过Ja-va虚拟机的类加载校验,而且能够被J ava虚拟机正常执行的,但是会选择Ser ializable还是Comparable<Character〉的重载方法则并不能事先确定,这是《Java虚拟机规范》所允许的;

下面继续注释掉sayHello(Serializable arg)方法,输出会变为:

这时是char装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接上层的优先级越低。即使方法调用传入的参数值为null时,这个规则仍然适用。我们把sayHello(Object arg)也注释掉,输出将会变为:

7个重载方法已经被注释得只剩1个了,可见变长参数的重载优先级是最低的,这时候字符’a’被当作了一个char[]数组的元素。笔者使用的是char类型的变长参数,读者在验证时还可以选择int类型、Character类型、Object类型等的变长参数重载来把上面的过程重新折腾一遍。但是要注意的是,有一些在单个参数中能成立的自动转型,如char转型为int,在变长参数中是不成立的;

代码演示了编译期间选择静态分派目标的过程,这个过程也是Java语言实现方法重载的本质。演示所用的这段程序无疑是属于很极端的例子,除了用作面试题为难求职者之外,在实际工作中几乎不可能存在任何有价值的用途,笔者拿来做演示仅仅是用于讲解重载时目标方法选择的过程,对绝大多数下进行这样极端的重载都可算作真正的“关于茴香豆的苗有几种写法的研究”。无论对重载的认识有多么深刻,一个合格的程序员都不应该在实际应用中写这种晦涩的重载代码。

另外还有一点读者可能比较容易混淆:笔者讲述的解析与分派这两者之间的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。例如前面说过静态方法会在编译期确定、在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。

3.5.3.1.2 分派-动态分派

了解了静态分派,我们接下来看一下Java语言里动态分派的实现过程,它与Java语言多态性的另外一个重要体现—重写(Override)有着很密切的关联。我们还是用前面的Man和Woman一起sayHello的例子来讲解动态分派,请看代码。

public class DemoTest {static abstract class Human{protected abstract void sayHello();}static class Man extends Human{@Overrideprotected void sayHello() {System.out.println("man say hello");}}static class Woman extends Human{@Overrideprotected void sayHello() {System.out.println("woman say hello");}}public static void main(String[] args) {Human man = new Man();Human woman = new Woman();man.sayHello();woman.sayHello();man = new Woman();man.sayHello();}}

结果如下:

这个运行结果相信不会出乎任何人的意料,对于习惯了面向对象思维的Java程序员们会觉得这是完全理所当然的结论。我们现在的问题还是和前面的一样,Java虚拟机是如何判断应该调用哪个方法的?

显然这里选择调用的方法版本是不可能再根据静态类型来决定的,因为静态类型同样都是Human的两个变量man和woman在调用sayHello()方法时产生了不同的行为,甚至变量man在两次调用中还执行了两个不同的方法。导致这个现象的原因很明显,是因为这两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派方法执行版本的呢?我们使用javap命令输出这段代码的字节码,尝试从中寻找答案,输出结果如代码所示。

D:\working\Jdk8\bin\javap.exe -c com.ooo.DemoTest
Compiled from "DemoTest.java"
public class com.ooo.DemoTest {public com.ooo.DemoTest();Code:0: aload_01: invokespecial #1                  // Method java/lang/Object."<init>":()V4: returnpublic static void main(java.lang.String[]);Code:0: new           #2                  // class com/ooo/DemoTest$Man3: dup4: invokespecial #3                  // Method com/ooo/DemoTest$Man."<init>":()V7: astore_18: new           #4                  // class com/ooo/DemoTest$Woman11: dup12: invokespecial #5                  // Method com/ooo/DemoTest$Woman."<init>":()V15: astore_216: aload_117: invokevirtual #6                  // Method com/ooo/DemoTest$Human.sayHello:()V20: aload_221: invokevirtual #6                  // Method com/ooo/DemoTest$Human.sayHello:()V24: new           #4                  // class com/ooo/DemoTest$Woman27: dup28: invokespecial #5                  // Method com/ooo/DemoTest$Woman."<init>":()V31: astore_132: aload_133: invokevirtual #6                  // Method com/ooo/DemoTest$Human.sayHello:()V36: return
}Process finished with exit code 0

0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表的变量槽中,这些动作实际对应了Java源码中的这两行:

接下来的16~21行是关键部分,16和20行的aload指令分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);
17和21行是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)都完全一样,但是这两句指令最终执行的目标方法并不相同。那看来解决问题的关键还必须从invokevirtual指令本身入手,要弄清楚它是如何确定调用方法版本、如何实现多态查找来着手分析才行。根据《Java虚拟机规范》,invokevirtual指令的运行时解析过程大致分为以下几步:

  • 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
  • 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.Illegal/AccessError异常。
  • 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
  • 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

正是因为invokevirtul指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派

既然这种多态性的根源在于虚方法调用指令invokevirtual的执行逻辑,那自然我们得出的结论就只会对方法有效,对字段是无效的,因为字段不使用这条指令。事实上,在Java里面只有虚方法存在,字段永远不可能是虚的,换句话说,字段永远不参与多态,哪个类的方法访问某个名字的字段时,该名字指的就是这个类能看到的那个字段。当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。

输出两句都是“I am Son”,这是因为Son类在创建的时候,首先隐式调用了Father的构造函数,而Father构造函数中对showMeTheMoney()的调用是一次虚方法调用,实际执行的版本是Son::showMeTheMoney()方法,所以输出的是“I amSon”,这点经过前面的分析相信读者是没有疑问的了。而这时候虽然父类的money字段已经被初始化成2了,但Son::showMeTheMoney(
)方法中访问的却是子类的money字段,这时候结果自然还是0,因为它要到子类的构造函数执行时才会被初始化。main()的最后一句通过静态类型访问到了父类中的money,输出了2。

3.5.3.1.3 分派-单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,这个定义最早应该来源于著名的《Java与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派多分派两种。

  • 单分派是根据一个宗量对目标方法进行选择
  • 多分派则是根据多于一个宗量对目标方法进行选择。

单分派和多分派的定义读起来拗口,从字面上看也比较抽象,不过对照着实例看并不难理解其含义,代码中举了一个Father和Son一起来做出“一个艰难的决定”的例子。

public class DemoTest {static class QQ{}static class _360{}public static class Father{public void hardChoice(QQ arg){System.out.println("father choose QQ");}public void hardChoice(_360 arg){System.out.println("father choose _360");}}public static class Son extends Father{public void hardChoice(QQ arg){System.out.println("Son choose QQ");}public void hardChoice(_360 arg){System.out.println("Son choose _360");}}public static void main(String[] args) {Father father = new Father();Father son = new Son();father.hardChoice(new _360());son.hardChoice(new QQ());}
}

输出结果是:

在main()里调用了两次hardChoice()方法,这两次hardChoice()方法的选择结果在程序输出中已经显示得很清楚了。我们关注的首先是编译阶段中编译器的选择过程,也就是静态分派的过程。这时候选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtul指令,两条指令的参数分别为常量池中指向Father::hardChoice(360)及Father::hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

再看看运行阶段中虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice(new QQ())”这行代码时,更准确地说,是在执行这行代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”
还是“奇瑞QQ”,因为这时候参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有该方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

根据上述论证的结果,我们可以总结一句:如今(直至编写的Java12和预览版的Java13)的Java语言是一门静态多分派、动态单分派的语言。强调“如今的Java语言”是因为这个结论未必会恒久不变,C#在3.0及之前的版本与Java一样是动态单分派语言,但在C#4.0中引入了dynamic类型后,就可以很方便地实现动态多分派。JDK10时Java语法中新出现var关键字,但请读者切勿将其与C#中的dynamic类型混淆,事实上Java的var与C#的var才是相对应的特性,它们与dynamic有着本质的区别:var是在编译时根据声明语句中赋值符右侧的表达式类型来静态地推断类型,这本质是一种语法糖;而dynamic在编译时完全不关心类型是什么,等到运行的时候再进行类型判断。

Java语言中与C#的dynamic类型功能相对接近(只是接近,并不是对等的)的应该是在JDK9时通过JEP276引入的jd k.dynalink模块,使用jdk.dynalink可以实现在表达式中使用动态类型,Ja-vac编译器会将这些动态类型的操作翻译为invokedynamic指令的调用点。
按照目前Java语言的发展趋势,它并没有直接变为动态语言的迹象,而是通过内置动态语言(如JavaScript)执行引擎、加强与其他Java虚拟机上动态语言交互能力的方式来间接地满足动态性的需求。但是作为多种语言共同执行平台的ava虚拟机层面上则不是如此,早在JDK7中实现的JSR-292健里面就已经开始提供对动态语言的方法调用支持了,JDK7中新增的invo-kedynamic指令也成为最复杂的一条方法调用的字节码指令;

3.5.3.1.4 虚拟机动态分派的实现

前面介绍的分派过程,作为对Java虚拟机概念模型的解释基本上已经足够了,它已经解决了虚拟机在分派中“会做什么”这个问题。但如果问Jav a虚拟机“具体如何做到”的,答案则可能因各种虚拟机的实现不同会有些差别。

动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,因此,Java虚拟机实现基于执行性能的考虑,真正运行时一般不会如此频繁地去反复搜索类型元数据。面对这种情况,一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表(VirtualMethod Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。我们先看看代码清单所对应的虚方法表结构示例,如图所示。

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。在图中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。

上文中笔者提到了查虚方法表是分派调用的一种优化手段,由于Java对象里面的方法默认(即不使用final修饰)就是虚方法,虚拟机除了使用虚方法表之外,为了进一步提高性能,还会使用类型继承关系分析(Class Hierarchy Analysis,CHA)、守护内联(Guarded Inlining)、内联缓存(Inline Cache)等多种非稳定的激进优化来争取更大的性能空间;

3.5.4 动态类型语言支持

Java虚拟机的字节码指令集的数量自从Sun公司的第一款Java虚拟机问世至今,二十余年间只新增过一条指令,它就是随着JDK7的发布的字节码首位新成员——invokedynamic指令。
这条新增加的指令是JDK7的项目目标:实现动态类型语言(Dynamically Typed Language)支持而进行的改进之一,也是为JDK8里可以顺利实现Lambda表达式而做的技术储备。在本节中,我们将详细了解动态语言支持这项特性出现的前因后果和它的意义与价值。

3.5.4.1 动态类型语言

在介绍Java虚拟机的动态类型语言支持之前,我们要先弄明白动态类型语言是什么?它与Java语言、Java虚拟机有什么关系?了解Java虚拟机提供动态类型语言支持的技术背景,对理解这个语言特性是非常有必要的。何谓动态类型语言?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的,满足这个特征的语言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk、Tcl,等等。那相对地,在编译期就进行类型检查过程的语言,譬如C++和Java等就是最常用的静态类型语言。

如果读者觉得上面的定义过于概念化,那我们不妨通过两个例子以最浅显的方式来说明什么是“类型检查”
和什么叫“在编译期还是在运行期进行”。首先看下面这段简单的Java代码,思考一下它是否能正常编译和运行?

上面这段Java代码能够正常编译,但运行的时候会出现NegativeArraySizeException异常。在《Java虚拟机规范》中明确规定了NegativeArraySizeException是一个运行时异常(RuntimeE xception),通俗一点说,运行时异常就是指只要代码不执行到这一行就不会出现问题。与运行时异常相对应的概念是连接时异常,例如很常见的NoClassDefFoundError便属于连接时异常,即使导致连接时异常的代码放在一条根本无法被执行到的路径分支上,类加载时也照样会抛出异常。

不过,在C语言里,语义相同的代码就会在编译期就直接报错,而不是等到运行时才出现异常:

由此看来,一门语言的哪一种检查行为要在运行期进行,哪一种检查要在编译期进行并没有什么必然的因果逻辑关系,关键是在语言规范中人为设立的约定。
解答了什么是“连接时、运行时”,笔者再举一个例子来解释什么是“类型检查”,例如下面这一句再普通不过的代码:

虽然正在的每一位读者都能看懂这行代码要做什么,但对于计算机来讲,这一行“没头没尾”的代码是无法执行的,它需要一个具体的上下文中(譬如程序语言是什么、obj是什么类型)才有讨论的意义。
现在先假设这行代码是在Java语言中,并且变量obj的静态类型为java.i o.PrintStream,那变量obj的实际类型就必须是PrintStream的子类(实现了PrintStream接口的类)才是合法的。否则,哪怕obj属于一个确实包含有printIn(String)方法相同签名方法的类型,但只要它与PrintStream接口没有继承关系,代码依然不可能运行一—因为类型检查不合法。

但是相同的代码在ECMAScript(JavaScript)中情况则不一样,无论obj具体是何种类型,无论其继承关系如何,只要这种类型的方法定义中确实包含有printin(String)方法,能够找到相同签名的方法,调用便可成功。产生这种差别产生的根本原因是Java语言在编译期间却已将printin(String)方法完整的符号引用(本例中为一项CONSTANT_InterfaceMethodref_info常量)生成出来,并作为方法调用指令的参数存储到Class文件中,例如下面这个样子:

这个符号引用包含了该方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用。
而ECMAScript等动态类型语言与Java有一个核心的差异就是变量obj本身并没有类型,变量obj的值才具有类型,所以编译器在编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型(即方法接收者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个核心特征。
了解了动态类型和静态类型语言的区别后,也许读者的下一个问题就是动态、静态类型语言两者谁更好,或者谁更加先进呢?这种比较不会有确切答案,它们都有自己的优点,选择哪种语言是需要权衡的事情。静态类型语言能够在编译期确定变量类型,最显著的好处是编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题就能在编码时被及时发现,利于稳定性及让项目容易达到更大的规模。而动态类型语言在运行期才确定类型,这可以为开发人员提供极大的灵活性,某些在静态类型语言中要花大量雁肿代码来实现的功能,由动态类型语言去做可能会很清晰简洁,清晰简洁通常也就意味着开发效率的提升。

3.5.4.2 Java与动态类型

现在我们回到本节的主题,来看看Java语言、Java虚拟机与动态类型语言之间有什么关系。Java虚拟机毫无疑问是Java语言的运行平台,但它的使命并不限于此,早在1997年出版的《Java虚拟机规范》第1版中就规划了这样一个愿景:“在未来,我们会对Java虚拟机进行适当的扩展,以便更好地支持其他语言运行于Java虚拟机之上。

”而目前确实已经有许多动态类型语言运行于Java虚拟机之上了,如Clojure、Groovy、Jython和JRuby等,能够在同一个虚拟机之上可以实现静态类型语言的严谨与动态类型语言的灵活,这的确是一件很美妙的事情。

但遗憾的是Java虚拟机层面对动态类型语言的支持一直都还有所欠缺,主要表现在方法调用方面:JDK7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info 或者CONSTA-NT_InterfaceMethodref_info常量),前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定方法的接收者。这样,在Java虚拟机上实现的动态类型语言就不得不使用“曲线救国”的方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型
的适配)来实现,但这样势必会让动态类型语言实现的复杂度增加,也会带来额外的性能和内存开销。内存开销是很显而易见的,方法调用产生的那一大堆的动态类就摆在那里。而其中最严重的性能瓶颈是在于动态类型方法调用时,由于无法确定调用对象的静态类型,而导致的方法内联无法有效进行。在第11章里我们会讲到方法内联的重要性,它是其他优化措施的基础,也可以说是最重要的一项优化。尽管也可以想一些办法(譬如调用点缓存)尽量缓解支持动态语言而导致的性能下降,但这种改善毕竟不是本质的。譬如有类似以下代码:

在动态类型语言下这样的代码是没有问题,但由于在运行时arrays中的元素可以是任意类型,即使它们的类型中都有sayHello()方法,也肯定无法在编译优化的时候就确定具体sayHello()的代码在哪里,编译器只能不停编译它所遇见的每一个sayHello()方法,并缓存起来供执行时选择、调用和内联,如果arrays数组中不同类型的对象很多,就势必会对内联缓存产生很大的压力,缓存的大小总是有限的,类型信息的不确定性导致了缓存内容不断被失效和更新,先前优化过的方法也可能被不断替换而无法重复使用。所以这种动态类型方法调用的底层问题终归是应当在Java虚拟机层次上去解决才最合适。因此,在Java虚拟机层面上提供动态类型的直接支持就成为Java平台发展必须解决的问题,这便是JDK7时JSR-292提案中invokedynamic指令以及java.lang.invoke包出现的技术背景。

3.5.4.3 java.lang.invoke包

JDK7时新加入的java.lang.invoke包曾是JSR292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为“方法句柄”(Method Handle)。这个表达听起来也不好懂?那不妨把方法句柄与C/C++中的函数指针(Function Pointer),或者C#里面的委派(Delegate)互相类比一下来理解。举个例子,如果我们要实现一个带谓词(谓词就是由外部传入的排序时比较大小的动作)的排序函数,在C/C++中的常用做法是把谓词定义为函数,用函数指针来把谓词传递到排序方法,像这样:

但在Java语言中做不到这一点,没有办法单独把一个函数作为参数进行传递。普遍的做法是设计一个带有compare()方法的Comparator接口,以实现这个接口的对象作为参数,例如Java类库中的Collections::sort()方法就是这样定义的:

不过,在拥有方法句柄之后,Java语言也可以拥有类似于函数指针或者委托的方法别名这样的工具了。代码演示了方法句柄的基本用法,无论obj是何种类型(临时定义的CIassA抑或是实现PrintStream接口的实现类System.out),都可以正确调用到printin()方法。

3.5.4.4 invokedynamic指令

一开始就提到了JDK7为了更好地支持动态类型语言,引入了第五条方法调用的字节码指令invokedynamic,之后却一直没有再提起它,甚至把代码使用MethodHandle的示例代码反编译后也完全找不到invokedynamic的身影,这实在与invokedynamic作为Java诞生以来唯一一条新加入的字节码指令的地位不相符,那么invokedynamic到底有什么应用呢?
某种意义上可以说invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条“invoke*”指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(广义的用户,包含其他程序语言的设计者)有更高的自由度。而且,它们两者的思路也是可类比的,都是为了达成同一个目的,只是一个用上层代码和API来实现,另一个用字节码和Class中其他属性、常量来完成。因此,如果前面MethodHandle的例子看懂了,相信读者理解invokedynamic指令并不困难。
每一处含有invokedynamic指令的位置都被称作“动态调用点(DynamicallyComputed Call Site)”,这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK7时新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,该方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。引导方法是有固定的参数,并且返回值规定是java.lang.invoke.CallSite对象,这个对象代表了真正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用到要执行的目标方法上。

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

关于Java虚拟机是如何调用方法、进行版本选择的内容已经全部讲解完毕,从本节开始,我们来探讨虚拟机是如何执行方法里面的字节码指令的。
概述中曾提到过,许多Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,在本节中,我们将会分析在概念模型下的Java虚拟机解释执行字节码时,其执行引擎是如何工作的。笔者在本章多次强调了“概念模型”,是因为实际的虚拟机实现,譬如HotSpot的模板解释器工作的时候,并不是按照下文中的动作一板一眼地进行机械式计算,而是动态产生每条字节码对应的汇编代码来运行,这与概念模型中执行过程的差异很大,但是结果却能保证是一致的。

3.5.5.1 解释执行

Java语言经常被人们定位为“解释执行”的语言,在Java初生的JDK1.0时代,这种定义还算是比较准确的,但当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事。再后来,Java也发展出可以直接生成本地代码的编译器(如Jaotc、GCJ,ExcelsiorJ ET),而C/C++语言也出现了通过解释器执行的版本(如CINT),这时候再笼统地说“解释执行”,对于整个Java语言来说就成了几乎是没有意义的概念,只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较合理确切。

无论是解释还是编译,也无论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解,然后获得执行能力。大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过图8中的各个步骤。如果读者对大学编译原理的相关课程还有印象的话,很容易就会发现图中下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程;而中间的那条分支,自然就是解释执行的过程。

编译过程:

如今,基于物理机、Java虚拟机,或者是非Java的其他高级语言虚拟机(HLLVM)的代码执行过程,大体上都会遵循这种符合现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树(Abstract Syntax Tree,AST)。对于一门具体语言的实现来说,词法、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言。又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的JavaScript执行引擎。

在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。

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

Javac编译器输出的字节码指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是×86的二地址指令集,如果说得更通俗一些就是现在我们主流PC机中物理硬件直接支持的指令集架构,这些指令依赖寄存器进行工作。那么,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?

iconst_1
iconst_1
iadd
istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个变量槽中。这种指令流中的指令通常都是不带参数的,使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈之中。而如果用基于寄存器的指令集,那程序可能会是这个样子:

mov eax,1
add eax,1

mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。这种二地址指令是×86指令集中的主流,每个指令都包含两个单独的输入参数,依赖于寄存器来访问和存储数据。

了解了基于栈的指令集与基于寄存器的指令集的区别后,读者可能会有个进一步的疑问,这两套指令集谁更好一些呢?
应该说,既然两套指令集会同时并存和发展,那肯定是各有优势的,如果有一套指令集全面优于另外一套的话,就是直接替代而不存在选择的问题了。

基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。例如现在32位80×86体系的处理器能提供了8个32位的寄存器,而ARMV6体系的处理器(在智能手机、数码设备中相当流行的一种处理器)则提供了30个32位的通用寄存器,其中前16个在用户模式中可以使用。如果使用栈架构的指令集,用户程序不会直接用到这些寄存器,那就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更简单一些。栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。
栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些,所有主流物理机的指令集都是寄存器架构也从侧面印证了这点。不过这里的执行速度是要局限在解释执行的状态下,如果经过即时编译器输出成物理机上的汇编指令流,那就与虚拟机采用哪种指令集架构没有什么关系了。
在解释执行时,栈架构指令集的代码虽然紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构来得更多,因为出栈、入栈操作本身就产生了相当大量的指令。更重要的是栈实现在内存中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的优化方法,把最常用的操作映射到寄存器中避免直接内存访问,但这也只是优化措施而不是解决本质问题的方法。因此由于指令数量和内存访问的原因,导致了栈架构指令集的执行速度会相对慢上一点。

3.5.5.2 基于栈的解释器执行过程

关于栈架构执行引擎的必要前置知识已经全部讲解完毕了,本节笔者准备了一段Java代码,以便向读者实际展示在虚拟机里字节码是如何执行的。前面笔者曾经举过一个计算“1+1”的例子,那种小学一年级的算数题目显然太过简单了,给聪明的读者练习的题目起码……嗯,笔者准备的是四则运算加减乘除法,大概能达到三年级左右的数学水平,请看代码清单。代码清单一段简单的算术代码

这段代码从Java语言的角度没有任何谈论的必要,直接使用javap命令看看它的字节码指令,如代码清单所示。

javap提示这段代码需要深度为2的操作数栈和4个变量槽的局部变量空间,笔者就根据这些信息画了图共7张图片,来描述代码清单执行过程中的代码、操作数栈和局部变量表的变化情况。

执行偏移地址为0的指令的情况

首先,执行偏移地址为0的指令,Bipush指令的作用是将单字节的整型常量值(-128~127)推入操作数栈顶,跟随有一个参数,指明推送的常量值,这里是100。

执行偏移地址为1的指令的情况

执行偏移地址为2的指令,istore_1指令的作用是将操作数栈顶的整型值出栈并存放到第1个局部变量槽中。后续4条指令(直到偏移为11的指令为止)都是做一样的事情,也就是在对应代码中把变量a、b、c赋值为100、200、300

执行偏移为11的指令情况:

执行偏移地址为11的指令,iloa-d_1指令的作用是将局部变量表第1个变量槽中的整型值复制到操作数栈顶。
执行偏移地址为12的指令的情况:

执行偏移地址为12的指令,iload_2指令的执行过程与iload_1类似,把第2个变量槽的整型值入栈。画出这个指令的图示主要是为了显示下一条iadd指令执行前的堆栈状况。

执行偏移地址为13的指令的情况

执行偏移地址为13的指令,iadd指令的作用是将操作数栈中头两个栈顶元素出栈,做整型加法,然后把结果重新入栈。在iadd指令执行完毕后,栈中原有的100和200被出栈,它们的和300被重新入栈。

执行偏移地址为14的指令的情况:

执行偏移地址为14的指令,iloa-d_3指令把存放在第3个局部变量槽中的300入栈到操作数栈中。这时操作数栈为两个整数300。下一条指令imul是将操作数栈中头两个栈顶元素出栈,做整型乘法,然后把结果重新入栈,与iadd完全类似,所以笔者省略图示。

执行偏移地址为16的指令的情况:

执行偏移地址为16的指令,ireturn指令是方法返回指令之一,它将结束方法执行并将操作数栈顶的整型值返回给该方法的调用者。到此为止,这段方法执行结束。

再次强调上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做出一系列优化来提高性能,实际的运作过程并不会完全符合概念模型的描述。更确切地说,实际情况会和上面描述的概念模型差距非常大,差距产生的根本原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化,即使解释器中也不是按照字节码指令去逐条执行的。例如在HotSpot虚拟机中,就有很多以“fast_”开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,即时编译器的优化手段则更是花样繁多。

不过我们从这段程序的执行中也可以看出栈结构指令集的一般运行过程,整个运算过程的中间变量都以操作数栈的出栈、入栈为信息交换途径,符合我们在前面分析的特点。

3.5.6 类加载及执行子系统的案例与实战

3.5.6.1 概述

在Class文件格式与执行引擎这部分里,用户的程序能直接参与的内容并不太多,Class文件以何种格式存储,类型何时加载、如何连接,以及虚拟机如何执行字节码指令等都是由虚拟机直接控制的行为,用户程序无法对其进行改变。能通过程序进行操作的,主要是字节码生成与类加载器这两部分的功能,但仅仅在如何处理这两点上,就已经出现了许多值得欣赏和借鉴的思路,这些思路后来成为许多常用功能和程序实现的基础。在本章中,我们将看一下前面所学的知识在实际开发之中是如何应用的。

3.5.6.2 案例

在案例分析部分,笔者准备了4个例子,关于类加载器和字节码的案例各有两个。并且这两个领域的案例中又各有一个案例是大多数Java开发人员都使用过的工具或技术,另外一个案例虽然不一定每个人都使用过,但却能特别精彩地演绎出这个领域中的技术特性。希望后面的案例能引起读者的思考,并给读者的日常工作带来灵感。

3.5.6.2.1 正统的类加载器架构

主流的Java Web服务器,如Tomcat、Jetty、WebLogic、WebSphere或其他笔者没有列举的服务器,都实现了自己定义的类加载器,而且一般还都不止一个。因为一个功能健全的Web服务器,都要解决如下的这些问题:

  • 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离。这是最基本的需求,两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求每个类库在一个服务器中只能有一份,服务器应当能够保证两个独立应用程序的类库可以互相独立使用。
  • 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以互相共享。这个需求与前面一点正好相反,但是也很常见,例如用户可能有10个使用Spring组织的应用程序部署在同一台服务器上,如果把10份Spring分别存放在各个应用程序的隔离目录中,将会是很大的资源浪费——这主要倒不是浪费磁盘空间的问题,而是指类库在使用时都要被加载到服务器内存,如果类库不能共享,虚拟机的方法区就会很容易出现过度膨胀的风险。
  • 服务器需要尽可能地保证自身的安全不受部署的Web应用程序影响。目前,有许多主流的Java Web服务器自身也是使用Java语言来实现的。因此服务器本身也有类库依赖的问题,一般来说,基于安全考虑,服务器所使用的类库应该与应用程序的类库互相独立。
  • 支持JSP应用的Web服务器,十有八九都需要支持HotSwap功能。我们知道JSP文件最终要被编译成Java的Cl ass文件才能被虚拟机执行,但JSP文件由于其纯文本存储的特性,被运行时修改的概率远大于第三方类库或程序自己的Class文件。而且ASP、PHP和JSP这些网页应用也把修改后无须重启作为一个很大的“优势”来看待,因此“主流”的Web服务器都会支持JSP生成类的热替换,当然也有“非主流”的,如运行在生产模式(Production Mode)下的Weblogic服务器默认就不会处理JSP文件的变化。

由于存在上述问题,在部署Web应用时,单独的一个ClassPath就不能满足需求了,所以各种Web服务器都不约而同地提供了好几个有着不同含义的ClassPath路径供用户存放第三方类库,这些路径一般会以“lib”或“classes”命名。被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常每一个目录都会有一个相应的自定义类加载器去加载放置在里面的Java类库。现在笔者就以Tomcat服务器为例,与读者一同分析Tomcat具体是如何规划用户类库结构和类加载器的。

在Tomcat目录结构中,可以设置3组目录(/common/*、/server/和/shared/,但默认不一定是开放的,可能只有/lib/目录存在)用于存放Java类库,另外还应该加上Web应用程序自身的“/WEB-INF/”目录,一共4组。把Java类库放置在这4组目录中,每一组都有独立的含义,分别是:

  • 放置在/common目录中。类库可被Tomcat和所有的Web应用程序共同使用。
  • 放置在/server目录中。类库可被Tomcat使用,对所有的Web应用程序都不可见。
  • 放置在/shared目录中。类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见。
  • 放置在/WlebApp/WEB-INF目录中。类库仅仅可以被该Web应用程序使用,对Tomcat和其他Web应用程序都不可见。

为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,其关系如图所示。

灰色背景的3个类加载器是JDK(以JDK9之前经典的三层类加载器为例)默认提供的类加载器,这3个加载器的作用在之前文章中已经介绍过了。而Common类加载器、Catalina类加载器(也称为Server类加载器)、Shared类加载器和Webapp类加载器则是Tomcat自己定义的类加载器,它们分别加载/c-
ommon/、/server/、/shared/*和/WebApp/WEB-INF/*中的Java类库。其中WebApp类加载器和JSP类加载器通常还会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个JasperLoader类加载器。

从图的委派关系中可以看出,Common类加载器能加载的类都可以被Catalina类加载器和Shared类加载器使用,而Catalina类加载器和Shared类加载器自己能加载的类则与对方相互隔离。WebApp类加载器可以使用Shared类加载器加载到的类,但各个WebApp类加载器实例之间相互隔离。而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个Class文件,它存在的目的就是为了被丢弃:当服务器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的JSP类加载器来实现JSP文件的HotSwap功能。

本例中的类加载结构在Tomcat 6以前是它默认的类加载器结构,在Tomcat6及之后的版本简化了默认的目录结构,只有指定了tomcat/conf/catalina.properties配置文件的server.loader和share.loader项后才会真正建立Catalina类加载器和Shared类加载器的实例,否则会用到这两个类加载器的地方都会Common类加载器的实例代替,而默认的配置文件中并没有设置这两个loader项,所以Tomcat6之后也顺理成章地把/common、/server和/shared 这3个目录默认合并到一起变成1个/ib目录,这个目录里的类库相当于以前/common目录中类库的作用,是Tomcat的开发团队为了简化大多数的部署场景所做的一项易用性改进。如果默认设置不能满足需要,用户可以通过修改配置文件指定server.loader和share.loader的方式重新启用原来完整的加载器架构。

Tomcat加载器的实现清晰易懂,并且采用了官方推荐的“正统”的使用类加载器的方式。如果读者阅读完上面的案例后,毫不费力就能完全理解Tomcat设计团队这样布置加载器架构的用意,这就说明你已经大致掌握了类加载器“主流”的使用方式,那么笔者不妨再提一个问题让各位读者思考一下:前面曾经提到过一个场景,如果有10个Web应用程序都是用Spring来进行组织和管理的话,可以把Spring放到Common或Shared目录下让这些程序共享。Spring要对用户程序的类进行管理,自然要能访问到用户程序的类,而用户的程序显然是放在/WebApp/WEB-INF目录中的。那么被Common类加载器或Shared类加载器加载的Spring如何访问并不在其加载范围内的用户程序呢?如果你读懂了相关内容,相信回答这个问题一定会毫不费力。

3.5.6.2.2 OSGi:灵活的类加载器架构

曾经在Java程序社区中流传着这么一个观点:“学习JavaEE规范,推荐去看JBoss源码;学习类加载器的知识,就推荐去看OSGi源码。”尽管“Java EE规范”和“类加载器的知识”并不是一个对等的概念,不过,既然这个观点能在部分程序员群体中流传开来,也从侧面说明了OSGi对类加载器的运用确实有其独到之处。

OSGi(Open Service Gateway Initiative)是OSGi联盟(OSGi Aliance)制订的一个基于Java语言的动态模块化规范(在JDK9引入的JPMS是静态的模块系统),这个规范最初由IBM、爱立信等公司联合发起,在早期连Sun公司都有参与。目的是使服务提供商通过住宅网关为各种家用智能设备提供服务,后来这个规范在Java的其他技术领域也有相当不错的发展,现在已经成为Java世界中“事实上”的动态模块化标准,并且已经有了Equinox、Felix等成熟的实现。根据OSGi联盟主页上的宣传资料,OSGi现在的重点应用在智慧城市、智慧农业、工业4.0这些地方,而在传统Java程序员中最知名的应用案例可能就数EclipseIDE了,另外,还有许多大型的软件平台和中间件服务器都基于或声明将会基于OSGi规范来实现,如IBMJazz平台、GlassFish服务器、JBoss OSGi等。OSGi中的每个模块(称为Bundle)
与普通的Java类库区别并不太大,两者一般都以JAR格式进行封装,并且内部存储的都是Java的Package和Class。但是一个Bundle可以声明它所依赖的Package(通过Import-Package描述),也可以声明它允许导出发布的Package(通过Export-Package描述)。在OSGi里面,Bundle之间的依赖关系从传统的上层模块依赖底层模块转变为平级模块之间的依赖,而且类库的可见性能得到非常精确的控制,一个模块里只有被Export过的Package才可能被外界访问,其他的Package和Class将会被隐藏起来。

以上这些静态的模块化特性原本也是OSGi的核心需求之一,不过它和后来出现的Java的模块化系统互相重叠了,所以OSGi现在着重向动态模块化系统的方向发展。在今天,通常引入OSGi的主要理由是基于OSGi架构的程序很可能(只是很可能,并不是一定会,需要考虑热插拔后的内存管理、上下文状态维护问题等复杂因素)会实现模块级的热插拔功能,当程序升级更新或调试除错时,可以只停用、重新安装然后启用程序的其中一部分,这对大型软件、企业级程序开发来说是一个非常有诱惑力的特性,譬如Eclipse中安装、卸载、更新插件而不需要重启动,就使用到了这种特性。

OSGi之所以能有上述诱人的特点,必须要归功于它灵活的类加载器架构。OSGi的Bundle类加载器之间只有规则,没有固定的委派关系。例如,某个Bundle声明了一个它依赖的Package,如果有其他Bundle声明了发布这个package后,那么所有对这个Package的类加载动作都会委派给发布它的Bundle类加载器去完成。不涉及某个具体的Package时,各个Bundle加载器都是平级的关系,只有具体使用到某个Package和Class的时候,才会根据Package导入导出定义来构造Bundle间的委派和依赖。

另外,一个Bundle类加载器为其他Bundle提供服务时,会根据Export-Package列表严格控制访问范围。如果一个类存在于Bundle的类库中但是没有被Export,那么这个Bundle的类加载器能找到这个类,但不会提供给其他Bundle使用,而且OSGi框架也不会把其他Bundle的类加载请求分配给这个Bundle来处理。

我们可以举一个更具体些的简单例子来解释上面的规则,假设存在BundleA、Bundle B、BundleC3个模块,并且这3个Bundle定义的依赖关系如下所示。

  • Bundle A:声明发布了packageA,依赖了java.*的包;
  • Bundle B:声明依赖了packageA和packageC,同时也依赖了java.*的包;
  • Bundle C:声明发布了packageC,依赖了packageA。

那么,这3个Bundle之间的类加载器及父类加载器之间的关系如图所示。

OSGi的类加载器架构:

由于没有涉及具体的OSGi实现,图中的类加载器都没有指明具体的加载器实现,它只是一个体现了加载器之间关系的概念模型,并且只是体现了OSGi中最简单的加载器委派关系。一般来说,在OSGi里,加载一个类可能发生的查找行为和委派关系会远远比图中显示的复杂,类加载时可能进行的查找规则如下:

  • 以java.*开头的类,委派给父类加载器加载。
  • 否则,委派列表名单内的类,委派给父类加载器加载。
  • 否则,lmport列表中的类,委派给Export这个类的Bundle的类加载器加载。
  • 否则,查找当前Bundle的Class-path,使用自己的类加载器加载。·
  • 否则,查找是否在自己的Fragment Bundle中,如果是则委派给Fragment Bundle的类加载器加载。
  • 否则,查找Dynamic lmport列表的Bundle,委派给对应Bundle的类加载器加载。
  • 否则,类查找失败。

从图中还可以看出,在OSGi中,加载器之间的关系不再是双亲委派模型的树形结构,而是已经进一步发展成一种更为复杂的、运行时才能确定的网状结构。这种网状的类加载器架构在带来更优秀的灵活性的同时,也可能会产生许多新的隐患。笔者曾经参与过将一个非OSGi的大型系统向Equinox OSGi平台迁移的项目,由于项目规模和历史原因,代码模块之间的依赖关系错综复杂,勉强分离出各个模块的Bundle后,发现在高并发环境下经常出现死锁。我们很容易就找到了死锁的原因:如果出现了BundleA依赖Bundle B的Package B,而Bundle B又依赖了BundleA的PackageA,这两个Bundle进行类加载时就有很高的概率发生死锁。具体情况是当Bundle A 加载PackageB的类时,首先需要锁定当前类加载器的实例对象(java.lang.ClassLoader.loadClass()是一个同步方法),然后把请求委派给BundleB的加载器处理,但如果这时BundleB也正好想加载PackageA的类,它会先锁定自己的加载器再去请求BundleA的加载器处理,这样两个加载器都在等待对方处理自己的请求,而对方处理完之前自己又一直处于同步锁定的状态,因此它们就互相死锁,永远无法完成加载请求了。Equinox的Bug List中有不少关于这类问题的Bug,也提供了一个以牺牲性能为代价的解决方案——用户可以启osgi.classloader.singleThreadLo-
ads参数来按单线程串行化的方式强制进行类加载动作。在JDK7时才终于出现了JDK层面的解决方案,类加载器架构进行了一次专门的升级,在ClassLoader中增加了registerAsParallelCapable方法对可并行的类加载进行注册声明,把锁的级别从ClassLoader对象本身,降低为要加载的类名这个级别,目的是从底层避免以上这类死锁出现的可能。

总体来说,OSGi描绘了一个很美好的模块化开发的目标,而且定义了实现这个目标所需的各种服务,同时也有成熟框架对其提供实现支持。对于单个虚拟机下的应用,从开发初期就建立在OSGi上是一个很不错的选择,这样便于约束依赖。但并非所有的应用都适合采用OSGi作为基础架构,OSGi在提供强大功能的同时,也引入了额外而且非常高的复杂度,带来了额外的风险。

3.5.6.2.3 字节码生成技术与动态代理的实现

“字节码生成”并不是什么高深的技术,读者在看到“字节码生成”这个标题时也先不必去想诸如Javassist、CGLib、ASM之类的字节码类库,因为JDK里面的Javac命令就是字节码生成技术的“老祖宗”,并且Javac也是一个由java语言写成的程序,它的代码存放在openJDK的jdk.compiler\share\classes\c om\sun\tools\javac目录中。要深入从java源码到字节码编译过程,阅读javac的源码是个很好的途径,不过Javac对于我们这个例子来说太过庞大了。在Java世界里面除了Javac和字节码类库外,使用到字节码生成的例子比比皆是,如Web服务器中的JSP编译器,编译时织入的Aop框架,还有很常用的动态代理技术,甚至在使用反射的时候虚拟机都有可能会在运行时生成字节码来提高执行速度。

我们选择其中相对简单的动态代理技术来讲解字节码生成技术是如何影响程序运作的。相信许多Java开发人员都使用过动态代理,即使没有直接使用过java.lang.reflect.Proxy或实现过java.lang.reflect.InvocationHandler接口,应该也用过Spring来做过Bean的组织管理。如果使用过Spring,那大多数情况应该已经不知不觉地用到动态代理了,因为如果Bean是面向接口编程,那么在Spring内部都是通过动态代理的方式来对Bean进行增强的。动态代理中所说的“动态”,是针对使用Java代码实际编写了代理类的“静态”代理而言的,它的优势不在于省去了编写代理类那一点编码工作量,而是实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系后,就可以很灵活地重用于不同的应用场景之中。
代码清单演示了一个最简单的动态代理的用法,原始的代码逻辑是打印一句“hello world”,代理类的逻辑是在原始类方法执行前打印一句“welcome”。我们先看一下代码,然后再分析JDK是如何做到的。

动态代理的简单演示:

public class DemoTest {interface IHello {void sayHello();}static class Hello implements IHello {@Overridepublic void sayHello() {System.out.println("Hello World");}}static class DynamicProxy implements InvocationHandler {Object originalObj;Object bind(Object originalObj) {this.originalObj = originalObj;return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass().getInterfaces(), this);}@Overridepublic Object invoke(Object proxy, Method method, Object[] args) throws Throwable {System.out.println("welcome");return method.invoke(originalObj, args);}}public static void main(String[] args) {IHello bind = (IHello) new DynamicProxy().bind(new Hello());bind.sayHello();}}

运行结果如下:

在上述代码里,唯一的“黑匣子”就是Proxy::newProxyInstance()方法,除此之外再没有任何特殊之处。这个方法返回一个实现了IHello的接口,并且代理了new Hello()实例行为的对象。
跟踪这个方法的源码,可以看到程序进行过验证、优化、缓存、同步、生成字节码、显式类加载等操作,前面的步骤并不是我们关注的重点,这里只分析它最后调用sun.misc.ProxyGenerator::
generateProxyClass()方法来完成生成字节码的动作,这个方法会在运行时产生一个描述代理类的字节码byte[]数组。如果想看一看这个在运行时产生的代理类中写了些什么,可以在main()方法中加入下面这句:

        System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");

加入这句代码后再次运行程序,磁盘中将会产生一个名为“$Proxy0.class”的代理类Class文件,反编译后可以看见如代码清单所示的内容:

package com.ooo;import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;final class $Proxy0 extends Proxy implements DemoTest.IHello {private static Method m1;private static Method m3;private static Method m2;private static Method m0;public $Proxy0(InvocationHandler var1) throws  {super(var1);}public final boolean equals(Object var1) throws  {try {return (Boolean)super.h.invoke(this, m1, new Object[]{var1});} catch (RuntimeException | Error var3) {throw var3;} catch (Throwable var4) {throw new UndeclaredThrowableException(var4);}}public final void sayHello() throws  {try {super.h.invoke(this, m3, (Object[])null);} catch (RuntimeException | Error var2) {throw var2;} catch (Throwable var3) {throw new UndeclaredThrowableException(var3);}}public final String toString() throws  {try {return (String)super.h.invoke(this, m2, (Object[])null);} catch (RuntimeException | Error var2) {throw var2;} catch (Throwable var3) {throw new UndeclaredThrowableException(var3);}}public final int hashCode() throws  {try {return (Integer)super.h.invoke(this, m0, (Object[])null);} catch (RuntimeException | Error var2) {throw var2;} catch (Throwable var3) {throw new UndeclaredThrowableException(var3);}}static {try {m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));m3 = Class.forName("com.ooo.DemoTest$IHello").getMethod("sayHello");m2 = Class.forName("java.lang.Object").getMethod("toString");m0 = Class.forName("java.lang.Object").getMethod("hashCode");} catch (NoSuchMethodException var2) {throw new NoSuchMethodError(var2.getMessage());} catch (ClassNotFoundException var3) {throw new NoClassDefFoundError(var3.getMessage());}}
}

这个代理类的实现代码也很简单,它为传入接口中的每一个方法,以及从java.lang.Object中继承来的equals()、hashCode()、toString()方法都生成了对应的实现,并且统一调用了InvocationHandler对象的invoke()方法(代码中的“this.h”就是父类Proxy中保存的InvocationHandler实例变量)来实现这些方法的内容,各个方法的区别不过是传入的参数和Method对象有所不同而已,所以无论调用动态代理的哪一个方法,实际上都是在执行InvocationHandler::invoke()中的代理逻辑。
这个例子中并没有讲到generateProxyClass()方法具体是如何产生代理类“$proxy0.class”的字节码的,大致的生成过程其实就是根据Class文件的格式规范去拼装字节码,但是在实际开发中,以字节为单位直接拼装出字节码的应用场合很少见,这种生成方式也只能产生一些高度模板化的代码。对于用户的程序代码来说,如果有要大量操作字节码的需求,还是使用封装好的字节码类库比较合适。如果读者对动态代理的字节码拼装过程确实很感兴趣,可以在OpenJDK的java.base\share\classes\java\lang\reflect目录下找到sun.misc.ProxyGenerator的源码。

3.5.6.2.4 Backport工具:Java的时光机器

一般来说,以“做项目”为主的软件公司比较容易更新技术,在下一个项目中换一个技术框架、升级到最时髦的JDK版本,甚至把Java换成C#、Golang来开发都是有可能的。但是当公司发展壮大,技术有所积累,逐渐成为以“做产品”为主的软件公司后,自主选择技术的权利就会逐渐丧失,因为之前积累的代码和技术都是用真金白银砸出来的,一个稳健的团队也不会随意地改变底层的技术。然而在飞速发展的程序设计领域,新技术总是日新月异层出不穷,偏偏这些新技术又如鲜花之于蜜蜂一样,对程序员们散发着天然的吸引力。
在Java世界里,每一次JDK大版本的发布,都会伴随着规模不等或大或小的技术革新,而对Java程序编写习惯改变最大的,肯定是那些对Java语法做出重大改变的版本,譬如JDK5时加入的自动装箱、泛型、动态注解、枚举、变长参数、遍历循环(foreach循环);譬如JDK8时加入的Lambda表达式、StreamAPI、接口默认方法等。事实上在没有这些语法特性的年代,Java程序也照样能写,但是现在回头看来,上述每一种语法的改进几乎都是“必不可少”的,如同用惯了32寸液晶、4K分辨率显示器的程序员,就很难再在19寸显示器、1080p分辨率的显示器上编写代码了。但假如公司“不幸”因为要保护现有投资、维持程序结构稳定等,必须使用JDK5或者JDK8以前的版本呢?

幸好,我们没有办法把19寸显示器变成32寸的,但却可以跨越JK版本之间的沟壑,把高版本JDK中编写的代码放到低版本JDK环境中去部署使用。为了解决这个问题,一种名为“Java逆向移植”的工具(Java Backporting Tools)应运而生,Retrotranslator和Retrolambda是这类工具中的杰出代表。
Retrotranslator的作用是将JDK5编译出来的Class文件转变为可以在JDK1.4或1.3上部署的版本,它能很好地支持自动装箱、泛型、动态注解、枚举、变长参数、遍历循环、静态导入这些语法特性,甚至还可以支持JDK5中新增的集合改进、并发包及对泛型、注解等的反射操作。Retrolambda的作用与Retrotranslator是类似的,目标是将JDK8的Lambda表达式和try-resources语法转变为可以在JDK5、JDK6、JDK7中使用的形式,同时也对接口默认方法提供了有限度的支持。
了解了Retrotranslator和Retrolambda这种逆向移植工具的作用以后,相信读者更关心的是它是怎样做到的?
要想知道Backporting工具如何在旧版本JDK中模拟新版本JDK的功能,首先要搞清楚JDK升级中会提供哪些新的功能。JDK的每次升级新增的功能大致可以分为以下五类:

  • 对Java类库ApI的代码增强。譬如JDK1.2时代引入的java.util.Collections等一系列集合类,在JDK5时代引入的java.util.concurrent并发包、在JDK7时引入的java.lang.invoke包,等等。
  • 在前端编译器层面做的改进。这种改进被称作语法糖,如自动装箱拆箱,实际上就是Javac编译器在程序中使用到包装对象的地方自动插入了很多Integer.valueOf()、Float.valueOf()之类的代码;变长参数在编译之后就被自动转化成了一个数组来完成参数传递;泛型的信息则在编译阶段就已经被擦除掉了(但是在元数据中还保留着),相应的地方被编译器自动插入了类型转换代码。
  • 需要在字节码中进行支持的改动。如JDK7里面新加入的语法特性一一动态语言支持,就需要在虚拟机中新增一条invokedynamic字节码指令来实现相关的调用功能。不过字节码指令集一直处于相对稳定的状态,这种要在字节码层面直接进行的改动是比较少见的。
  • 需要在JDK整体结构层面进行支持的改进,典型的如JDK9时引入的J ava模块化系统,它就涉及了JDK结构、Java语法、类加载和连接过程、Java虚拟机等多个层面。
  • 集中在虚拟机内部的改进。如JDK5中实现的JSR-133规范重新定义的Java内存模型(Java Memory Model,JMM),以及在JDK7、JDK11、JDK12中新增的G1、ZGC和Shenandoah收集器之类的改动,这种改动对于程序员编写代码基本是透明的,只会在程序运行时产生影响。

上述的5类新功能中,逆向移植工具能比较完美地模拟了前两类,从第3类开始就逐步深入地涉及了直接在虚拟机内部实现的改进了,这些功能一般要么是逆向移植工具完全无能为力,要么是不能完整地或者在比较良好的运行效率上完成全部模拟。想想这也挺合理的,如果在语法糖和类库层面可以完美解决的问题,Java虚拟机设计团队也没有必要舍近求远地改动处于JDK底层的虚拟机嘛。在能够较好模拟的前两类功能中,第一类模拟相对更容易实现一些,如JDK5引入的java.util.concurrent包,实际是由多线程编程的大师Doug Lea开发的一套并发包,在JDK5出现之前就已经存在(那时候名字叫作dl.util.c-
oncurrent,引入JDK时由作者和JDK开发团队共同进行了一些改进),所以要在旧的JDK中支持这部分功能,以独立类库的方式便可实现。Retrotranslator中就附带了一个名叫“backport-util-concurrent.jar”的类库(由另一个名为“Backport to JSR166”的项目所提供)来代替JDK5的并发包。
至于第二类JDK在编译阶段进行处理的那些改进,Retrotranslator则是使用ASM框架直接对字节码进行处理。由于组成Class文件的字节码指令数量并没有改变,所以无论是JK1.3、JDK1.4还是JDK5,能用字节码表达的语义范围应该是一致的。当然,肯定不会是简单地把Class的文件版本号从49.0改回48.0就能解决问题了,虽然字节码指令的数量没有变化,但是元数据信息和一些语法支持的内容还是要做相应的修改。以枚举为例,尽管在JDK5中增加了enum关键字,但是Class文件常量池CONSTANT_Class_info类型常量并没有发生任何语义变化,仍然是代表一个类或接口的符号引用,没有加入枚举,也没有增加过“CONSTANT_Enum_i nfo”之类的“枚举符号引用”常量。所以使用enum关键字定义常量,尽管从Java语法上看起来与使用class关键字定义类、使用interface关键字定义接口是同一层次的,但实际上这是由Javac编译器做出来的假象,从字节码的角度来看,枚举仅仅是一个继承于java.lang.Enum、自动生成了values()和valueOf()方法的普通Java类而已。
Retrotranslator对枚举所做的主要处理就是把枚举类的父类从“java.lang.Enum”替换为它运行时类库中包含的“net.sf.retrotranslator.runtime.java.l ang.Enum_”,然后再在类和字段的访问标志中抹去ACC_ENUM标志位。当然,这只是处理的总体思路,具体的实现要比上面说的复杂得多。可以想象
既然两个父类实现都不一样,values()和valueOf()的方法自然需要重写,常量池需要引入大量新的来自父类的符号引用,这些都是实现细节。图是一个使用JDK5编译的枚举类与被Retrotranslator转换处理后的字节码的对比图。

Retrotranslator处理前后的枚举类字节码对比

用Retrolambda模拟JDK8的Lambda表达式属于涉及字节码改动的第三类情况,Java为支持Lambda会用到新的invokedynamic字节码指令,但幸好这并不是必须的,只是基于效率的考量。在JDK8之前,Lambda表达式就已经被其他运行在Java虚拟机的编程语言(如Scala)广泛使用了,那时候是怎么生成字节码的现在照着做就是,不使用invokedynamic,除了牺牲一点效率外,可行性方面并没有太大的障碍。
Retrolambda的Backport过程实质上就是生成一组匿名内部类来代替Lambda,里面会做一些优化措施,譬如采用单例来保证无状态的Lambda表达式不会重复创建匿名类的对象。有一些Java IDE工具,如IntelliJ IDEA和Eclipse里会包含将此过程反过来使用的功能特性,在低版本Java里把匿名内部类显示成Lambda语法的样子,实际存在磁盘上的源码还是匿名内部类形式的,只是在IDE里可以把它显示为Lam-
bda表达式的语法,让人阅读起来比较简洁而已。

4. 程序编译与代码优化

4.1 概述

在Java技术下谈“编译期”而没有具体上下文语境的话,其实是一句很含糊的表述,因为它可能是指一个前端编译器(叫“编译器的前端”更准确一些)把。java文件转变成.class文件的过程;也可能是指Java虚拟机的即时编译器(常称JIT编译器,Just In TimeCompiler)运行期把字节码转变成本地机器码的过程;还可能是指使用静态的提前编译器(常称AOT编译器,Ahead Of Time Compiler)直接把程序编译成与目标机器指令集相关的二进制代码的过程。下面笔者列举了这3类编译过程里一些比较有代表性的编译器产品:

  • 前端编译器:JDK的Javac、Ecl-ipse JDT中的增量式编译器(ECJ);
  • 即时编译器:HotSpot虚拟机的C1、C2编译器,Graal编译器。
  • 提前编译器:JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET。

这3类过程中最符合普通程序员对Java程序编译认知的应该是第一类,本章标题中的“前端”指的也是这种由前端编译器完成的编译行为。在本章后续的讨论里,笔者提到的全部“编译期”和“编译器”都仅限于第一类编译过程,我们会把第二、三类编译过程留到后面去讨论。限制了“编译期”的范围后,我们对于“优化”二字的定义也需要放宽一些,因为Javac这类前端编译器对代码的运行效率几乎没有任何优化措施可言(在JDK1.3之后,Javac的-O优化参数就不再有意义),哪怕是编译器真的采取了优化措施也不会产生什么实质的效果。因为Java虚拟机设计团队选择把对性能的优化全部集中到运行期的即时编译器中,这样可以让那些不是由Javac产生的Class文件(如JRuby、Groovy等语言的Class文件)也同样能享受到编译器优化措施所带来的性能红利。但是,如果把“优化”的定义放宽,把对开发阶段的优化也计算进来的话,Javac确实是做了许多针对Java语言编码过程的优化措施来降低程序员的编码复杂度、提高编码效率。相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖字节码或者Java虚拟机的底层改进来支持。我们可以这样认为,Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;而前端编译器在编译期的优化过程,则是支撑着程序员的编码效率和语言使用者的幸福感的提高。

4.2 Java编译器

分析源码是了解一项技术的实现内幕最彻底的手段,Javac编译器不像HotSpot虚拟机那样使用C++语言(包含少量C语言)实现,它本身就是一个由Java语言编写的程序,这为纯Java的程序员了解它的编译过程带来了很大的便利。

4.2.1 Javac的源码与调试

在JDK6以前,Javac并不属于标准Java SEAPI的一部分,它实现代码单独存放在tools.jar中,要在程序中使用的话就必须把这个库放到类路径上。
在JDK6发布时通过了JSR199编译器A PI的提案,使得Javac编译器的实现代码晋升成为标准Java类库之一,它的源码就改为放在JDK_SRC_HOME/langtools/src/share/classes/com/sun/tools/javac中。到了JDK9时,整个JDK所有的Java类库都采用模块化进行重构划分,Javac编译器就被挪到了jdk.compiler模块(路径为:JDK_SRC_HOME/src/jdk.compiler/share/classes/com/sun/tools/javac)里面。虽然程序代码的内容基本没有变化,但由于本节的主题是源码解析,不可避免地会涉及大量的路径和包名,这就要选定JDK版本来讨论了,本次笔者将会以JDK9之前的代码结构来进行讲解。
Javac编译器除了JDK自身的标准类库外,就只引用了JDK_SRC_HOME/langtools/src/share/classes/com/sun/*里面的代码,所以我们的代码编译环境建立时基本无须处理依赖关系,相当简单便捷。以EclipseIDE作为开发工具为例,先建立一个名为“Compiler_j-
avac”的Java工程,然后把JDK_SRC_H OME/langtools/src/share/classes/com/sun/*目录下的源文件全部复制到工程的源码目录中查看;

《Java虚拟机规范》中严格定义了Class文件格式的各种细节,可是对如何把Java源码编译为Class文件却描述得相当宽松。规范里尽管有专门的一章名为“Compiling for the Java Virtual Machine”,但这章也仅仅是以举例的形式来介绍怎样的Java代码应该被转换为怎样的字节码,并没有使用编译原理中常用的描述工具(如文法、生成式等)来对Java源码编译过程加以约束。这是给了Java前端编译器较大的实现灵活性,但也导致Class文件编译过程在某种程度上是与具体的JDK或编译器实现相关的,譬如在一些极端情况下,可能会出现某些代码在Javac编译器可以编译,但是ECJ编译器就不可以编译的问题(反过来也有可能,后文中将会给出一些这样的例子)。

从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下所示。

  • 准备过程:初始化插入式注解处理器。
  • 解析与填充符号表过程,包括:
    • 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
    • 填充符号表。产生符号地址和符号信息。
  • 插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段,本章的实战部分会设计一个插入式注解处理器来影响Javac的编译行为。
  • 分析与字节码生成过程,包括:
    • 标注检查。对语法的静态信息进行检查。
    • 数据流及控制流分析。对程序动态运行过程进行检查。
    • 解语法糖。将简化代码编写的语法糖还原为原有的形式。
    • 字节码生成。将前面各个步骤所生成的信息转化成字节码。

上述3个处理过程里,执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转回到之前的解析、填充符号表的过程中重新处理这些新符号,从总体来看,三者之间的关系与交互顺序如图所示。
Javac的编译过程:

我们可以把上述处理过程对应到代码中,Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler类,上述3个过程的代码逻辑集中在这个类的compile()和compile2()方法里,其中主体代码如图所示,整个编译过程主要的处理由图中标注的8个方法
来完成。

javac编译过程的主体代码:

4.2.2 解析与填充符号表

解析过程由图中的parseFiles()方法来完成,解析过程包括了经典程序编译原理中的词法分析和语法分析两个步骤。

4.2.2.1 词法、语法分析

词法分析是将源代码的字符流转变为标记(Token)集合的过程,单个字符是程序编写时的最小元素,但标记才是编译时的最小元素。关键字、变量名、字面量、运算符都可以作为标记,如“int a=b+2”这句代码中就包含了6个标记,分别是int、a、=、b、+、2,虽然关键字int由3个字符构成,但是它只是一个独立的标记,不可以再拆分。在Javac的源码中,词法分析过程由com.sun.tools.javac.parser.Scanner类来实现。

语法分析是根据标记序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,抽象语法树的每一个节点都代表着程序代码中的一个语法结构(Syntax Co-nstruct),例如包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构。

4.2.2.2 填充符号表

完成了语法分析和词法分析之后,下一个阶段是对符号表进行填充的过程,也就是图中enterTrees()方法(图1中注释的过程1.2)要做的事情。符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构,读者可以把它类比想象成哈希表中键值对的存储形式(实际上符号表不一定是哈希表实现,可以是有序符号表、树状符号表、栈结构符号表等各种形式)。符号表中所登记的信息在编译的不同阶段都要被用到。譬如在语义分析的过程中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的声明是否一致)和产生中间代码,在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的直接依据。

在Javac源代码中,填充符号表的过程由com.sun.tools.javac.comp.Enter类实现,该过程的产出物是一个待处理列表,其中包含了每一个编译单元的抽象语法树的顶级节点,以及package-info.java(如果存在的话)的顶级节点。

4.2.3 注解处理器

JDK5之后,Java语言提供了对注解(Annotations)的支持,注解在设计上原本是与普通的Java代码一样,都只会在程序运行期间发挥作用的。但在JDK6中又提出并通过了JSR-269提案,该提案设计了一组被称为“插入式注解处理器”的标准API,可以提前至编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程。我们可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改
为止,每一次循环过程称为一个轮次(Round),这也就对应着图的那个回环过程。

有了编译器注解处理的标准API后,程序员的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件中被访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。只要有足够的创意,程序员能使用插入式注解处理器来实现许多原本只能在编码中由人工完成的事情。譬如java著名的编码效率工具Lombok,它可以通过注解来实现自动产生getter/setter方法、进行空置检查、生成受查异常表、产生equals()和hashCode()方法,等等,帮助开发人员消除Java的冗长代码,这些都是依赖插入式注解处理器来实现的,本章最后会设计一个如何使用插入式注解处理器的简单实战。
在Javac源码中,插入式注解处理器的初始化过程是在initPorcessAnnotations()方法中完成的,而它的执行过程则是在processAnnotations()方法中完成。这个方法会判断是否还有新的注解处理器需要执行,如果有的话,通过com.sun.tools.javac.processing.JavacProcessing Environment类的doProces-
sing()方法来生成一个新的JavaCompiler对象,对编译的后续步骤进行处理。

4.2.4 语义分析与字节码生成

经过语法分析之后,编译器获得了程序代码的抽象语法树表示,抽象语法树能够表示一个结构正确的源程序,但无法保证源程序的语义是符合逻辑的。而语义分析的主要任务则是对结构上正确的源程序进行上下文相关性质的检查,譬如进行类型检查、控制流检查、数据流检查,等等。举个简单的例子,假设有如下3个变量定义语句:

int a=1;
boolean b=false;
char c=2;

后续可能出现的赋值运算:

int  d=a+c;
int  d=b+c;
char d=a+c;

后续代码中如果出现了如上3种赋值运算的话,那它们都能构成结构正确的抽象语法树,但是只有第一种的写法在语义上是没有错误的,能够通过检查和编译。其余两种在Java语言中是不合逻辑的,无法编译(是否合乎语义逻辑必须限定在具体的语言与具体的上下文环境之中才有意义。如在C语言中,a、b、c的上下文定义不变,第二、三种写法都是可以被正确编译的)。我们编码时经常能在IDE中看到由红线标注的错误提示,其中绝大部分都是来源于语义分析阶段的检查结果。

  • 标注检查
    Javac在编译过程中,语义分析过程可分为标注检查和数据及控制流分析两个步骤;
  • 数据及控制流分析
    数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。编译时期的数据及控制流分析与类加载时的数据及控制流分析的目的基本上可以看作是一致的,但校验范围会有所区别,有一些校验项只有在编译期或运行期才能进行。
  • 解语法糖
    语法糖(Syntactic Sugar),也称糖衣语法,是由英国计算机科学家Peter J.Landin发明的一种编程术语,指的是在计算机语言中添加的某种语法,这种语法对语言的编译结果和功能并没有实际影响,但是却能更方便程序员使用该语言。通常来说使用语法糖能够减少代码量、增加程序的可读性,从而减少程序代码出错的机会。
    Java中最常见的语法糖包括了前面提到过的泛型(其他语言中泛型并不一定都是语法糖实现,如C#的泛型就是直接由CLR支持的)、变长参数、自动装箱拆箱,等等,Java虚拟机运行时并不直接支持这些语法,它们在编译阶段被还原回原始的基础语法结构,这个过程就称为解语法糖。Java的这些语法糖是如何实现的、被分解后会是什么样子,都将详细讲述。
    在Javac的源码中,解语法糖的过程由desugar()方法触发,在com.sun.tools.javac.comp.TransTypes类和com.sun.tools.javac.comp.Lower类中完成。
  • 字节码生成
    字节码生成是Javac编译过程的最后一个阶段,在Javac源码里面由com.s un.tools.javac.jvm.Gen类来完成。字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。

4.3 后端编译与优化

4.3.1 概述

如果我们把字节码看作是程序语言的一种中间表示形式(Intermediate Representation,IR)的话,那编译器无论在何时、在何种状态下把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,它都可以视为整个编译过程的后端。

如果读者阅读过第2版,可能会发现本章的标题已经从“运行期编译与优化”悄然改成了“后端编译与优化”,这是因为在2012年的Java世界里,虽然提前编译(Ahead Of Time,AOT)早已有所应用,但相对而言,即时编译(Just In Time,JIT)才是占绝对主流的编译形式。

不过,最近几年编译技术发展出现了一些微妙的变化,提前编译不仅逐渐被主流JDK所支持,而且在Java编译技术的前沿研究中又重新成了一个热门的话题,所以再继续只提“运行期”和“即时编译”就显得不够全面了,在本章中它们两者都是主角。无论是提前编译器抑或即时编译器,都不是Java虚拟机必需的组成部分,《Java虚拟机规范》中从来没有规定过虚拟机内部必须要包含这些编译器,更没有限定或指导这些编译器应该如何去实现。
但是,后端编译器编译性能的好坏、代码优化质量的高低却是衡量一款商用虚拟机优秀与否的关键指标之一,它们也是商业Java虚拟机中的核心,是最能体现技术水平与价值的功能。在本章中,我们将走进Java虚拟机的内部,探索后端编译器的运作过程和原理。

既然《Java虚拟机规范》没有具体的约束规则去限制后端编译器应该如何实现,那这部分功能就完全是与虚拟机具体实现相关的内容,如无特殊说明,本章中所提及的即时编译器都是特指HotSpot虚拟机内置的即时编译器,虚拟机也是特指HotSpot虚拟机。
不过,本章虽然有大量的内容涉及了特定的虚拟机和编译器的实现层面,但主流Java虚拟机中后端编译器的行为会有很多相似相通之处,因此对其他虚拟机来说也具备一定的类比参考价值。

4.3.2 即时编译器

目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)里,Java程序最初都是通过解释(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(HotSpot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器

本节我们将会了解HotSpot虚拟机内的即时编译器的运作过程,此外,我们还将解决以下几个问题:

  • 为何HotSpot虚拟机要使用解释器与即时编译器并存的架构?
  • 为何HotSpot虚拟机要实现两个(或三个)不同的即时编译器?·程序何时使用解释器执行?何时使用编译器执行?
  • 哪些程序代码会被编译为本地代码?如何编译本地代码?
  • 如何从外部观察到即时编译器的编译过程和编译结果?

4.3.3 解释器与编译器

尽管并不是所有的Java虚拟机都采用解释器与编译器并存的运行架构,但目前主流的商用Java虚拟机,譬如HotSpot、OpenJ9等,内部都同时包含解释器与编译器,解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。
当程序运行环境中内存资源限制较大,可以使用解释执行节约内存(如部分嵌入式系统中和大部分的JavaCard应用中就只有解释器的存在),反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时后备的“逃生门”(如果情况允许,HotSpot虚拟机中也会采用不进行激进优化的客户端编译器充当“逃生门”的角色),让编译器根据概率选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”(uncommon Trap)时可以通过逆优化(Deoptimization)退回到解释状态继续执行,因此在整个Java虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作,其交互关系如图所示。

HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为C1编译器和C2编译器(部分资料和JDK源码中C2也叫Opto编译器),第三个是在JDK10时才出现的、长期目标是代替C2的Graal编译器。Graal编译器目前还处于实验状态,本章将安排出专门的小节对它讲解与实战,在本节里,我们将重点关注传统的C1、C2编译器的工作过程。

在分层编译(Tiered Compilation)的工作模式出现以前,HotSpot虚拟机通常是采用解释器与其中一个编译器直接搭配的方式工作,程序使用哪个编译器,只取决于虚拟机运行的模式,HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在客户端模式还是服务端模式。

无论采用的编译器是客户端编译器还是服务端编译器,解释器与编译器搭配使用的方式在虚拟机中被称为“混合模式”(Mixed Mode),用户也可以使用参数“-Xint”强制虚拟机运行于“解释模式”(Interpreted Mode),这时候编译器完全不介入工作,全部代码都使用解释方式执行。另外,也可以使用参数“-Xcomp”强制虚拟机运行于“编译模式”(Compiled Mode),这时候将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。可以通过虚拟机的“-version”命令的输出结果显示出这三种模式;

由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。

为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机在编译子系统中加入了分层编译的功能,分层编译的概念其实很早就已经提出,但直到JDK6时期才被初步实现,后来一直处于改进阶段,最终在JDK7的服务端模式虚拟机中作为默认编译策略被开启。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:

  • 第0层。程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
  • 第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
  • 第2层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
  • 第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
  • 第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
  • 第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。各层次编译之间的交互、转换关系如图所示。
实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。

分层编译的交互关系:

4.3.3.1 编译对象与触发条件

在本章概述中提到了在运行过程中会被即时编译器编译的目标是“热点代码”,这里所指的热点代码主要有两类,包括:

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

前者很好理解,一个方法被调用得多了,方法体内代码执行的次数自然就多,它成为“热点代码”是理所当然的。而后者则是为了解决当一个方法只被调用过一次或少量的几次,但是方法体内部存在循环次数较多的循环体,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码”。

对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。

第一种情况,由于是依靠方法调用触发的编译,那编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的即时编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,热点只是方法的一部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条字节码指令开始执行)会稍有不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。
这种编译方式因为编译发生在方法执行的过程中,因此被很形象地称为“栈上替换”(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。

读者可能还会有疑问,在上面的描述里,无论是“多次执行的方法”,还是“多次执行的代码块”,所谓“多次”只定性不定量,并不是一个具体严谨的用语,那到底多少次才算“多次”呢?还有一个问题,就是Java虚拟机是如何统计某个方法或某段代码被执行过多少次的呢?解决了这两个问题,也就解答了即时编译被触发的条件。
要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”(Hot Spot Code De tection),其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点探测判定方式有两种,分别是:

  • 基于采样的热点探测(SampleBased Hot Spot Code Detection)。采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
  • 基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。

这两种探测手段在商用Java虚拟机中都有使用到,譬如J9用过第一种采样热点探测,而在HotSpot虚拟机中使用的是第二种基于计数器的热点探测方法,为了实现热点计数,HotSpot为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,“回边”的意思就是指在循环边界往回跳转)。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。

我们首先来看看方法调用计数器。顾名思义,这个计数器就是用于统计方法被调用的次数,它的默认阈值在客户端模式下是1500次,在服务端模式下是10000次,这个阈值可以通过虚拟机参数-XX:Compile Threshold来人为设定。当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法的调用计数器值加一,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。一旦已超过阀值的话,将会向即时编译器提交一个该方法的代码编译请求。

如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了,整个即时编译的交互过程如图所示。

在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time),进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-useCounterDecay来关闭热度
衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。另外还可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

方法调用计数器触发即时编译:

现在我们再来看看另外一个计数器——回边计数器,它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为“回边(BackEdge)”,很显然建立回边计数器统计的目的是为了触发栈上的替换编译。关于回边计数器的阈值,虽然HotSpot虚拟机也提供了一个类似于方法调用计数器阈值-XX:CompileThreshold的参数-XX:BackEdgeThreshold供用户设置,但是当前的HotSpot虚拟机实际上并未使用此参数,我们必须设置另外一个参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值,其计算公式有如下两种。

  • 虚拟机运行在客户端模式下,回边计数器阈值计算公式为:方法调用计数器阈值(-XX:CompileThreshold)乘以OSR比率(-XX:OnStackReplacePercentage)除以100。其中-XX:onStackReplacePercentage默认值为933,如果都取默认值,那客户端模式虚拟机的回边计数器的阈值为13995。
  • 虚拟机运行在服务端模式下,回边计数器阈值的计算公式为:方法调用计数器阀值(-XX:CompileThre-
    shold)乘以(0SR比率(-XX:onStackReplacePercentage)减去解释器监控比率(-XX:InterpreterProfilePercentage)的差值)除以100。其中-X X:OnStack ReplacePercentage默认值为140,-XX:InterpreterProfilePercentage默认值为33,如果都取默认值,那服务端模式虚拟机回边计数器的阈值为10700。

当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整个执行过程如图所示。
回边计数器触发即时编译:

与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
最后还要提醒一点,图都仅仅是描述了客户端模式虚拟机的即时编译方式,对于服务端模式虚拟机来说,执行情况会比上面描述还要复杂一些。从理论上了解过编译对象和编译触发条件后,我们还可以从HotSpot虚拟机的源码中简单观察一下这两个计数器,在MehtodOop.hpp(一个methodOop对象代表了一个Java方法)中,定义了Java方法在虚拟机中的内存布局;

4.3.3.2 编译过程

在默认条件下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在编译器还未完成编译之前,都仍然将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译,后台编译被禁止后,当达到触发即时编译的条件时,执行线程向虚拟机提交编译请求以后将会一直阻塞等待,直到编译过程完成再开始执行编译器输出的本地代码。
那在后台执行编译的过程中,编译器具体会做什么事情呢?服务端编译器和客户端编译器的编译过程是有所差别的。对于客户端编译器来说,它是一个相对简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。

在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表示)。HIR使用静态单分配(Static Single Assignment,SSA)的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器已经会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成。

在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示),而在此之前会在HIR上完成
另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。
最后的阶段是在平台相关的后端使用线性扫描算法(Linear Scan Regis-ter Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。客户端编译器大致的执行过程如图所示。
Client Compiler 架构:

而服务端编译器则是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器,几乎能达到GNU C++编译器使用-02参数时的优化强度。它会执行大部分经典的优化动作,如:无用代码消除(Dead Code Elimination)、循环展开(Loop unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(Basic Block Reordering)等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除(Null Check Elimination,不过并非所有的空值检查消除都是依赖编译
器优化的,有一些是代码运行过程中自动优化了)等。另外,还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)等,本章的下半部分将会挑选上述的一部分优化手段进行分析讲解,在此就先不做展开。服务端编译采用的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构(如RISC)上的大寄存器集合。以即时编译的标准来看,服务端编译器无疑是比较缓慢的,但它的编译速度依然远远超过传统的静态优化编译器,而且它相对于客户端编译器编译输出的代码质量有很大提高,可以大幅减少本地代码的执行时间,从而抵消掉额外的编译时间开销,所以也有很多非服务端的应用选择使用服务端模式的HotSpot虚拟机来运行。

在本节中出现了许多编译原理和代码优化中的概念名词,没有这方面基础的读者,可能阅读起来会感觉到很抽象、很理论化。有这种感觉并不奇怪,一方面,即时编译过程本来就是一个虚拟机中最能体现技术水平也是最复杂的部分,很难在几页纸的篇幅中介绍得面面俱到;另一方面,这个过程对Java开发者来说是完全透明的,程序员平时无法感知它的存在。

4.3.3.3 提前编译器

提前编译在Java技术体系中并不是新事物。1996年JDK1.0发布,Java有了正式的运行环境,第一个可以使用外挂即时编译器的Java版本是1996年7月发布的JDK1.0.2,而Java提前编译器的诞生并没有比这晚多少。仅几个月后,IBM公司就推出了第一款用于Java语言的提前编译器(IBM High Perform-
ance Compiler for Java)。在1998年,GNu组织公布了著名的GCC家族(GNU Co mpiler Collection)的新成员GNU Compiler for Java(GCJ,2018年从GCC家族中除名),这也是一款Java的提前编译器
,而且曾经被广泛应用。在OpenJDK流行起来之前,各种Linux发行版带的Java实现通常就是GCJ。

但是提前编译很快又在Java世界里沉寂了下来,因为当时Java的一个核心优势是平台中立性,其宣传口号是“一次编译,到处运行”,这与平台相关的提前编译在理念上就是直接冲突的。GCJ出现之后在长达15年的时间里,提前编译这条故事线上基本就再没有什么大的新闻和进展了。类似的状况一直持续至2013年,直到在Android的世界里,剑走偏锋使用提前编译的ART(Android Runtime)横空出世。ART一诞生马上就把使用即时编译的Dalvik虚拟机按在地上使劲蹂躏,仅经过Android4.4一个版本的短暂交锋之后,AR T就迅速终结了Dalvik的性命,把它从Android系统里扫地出门。

4.3.3.4 提前编译的优劣得失

本节希望同时向读者展示出一枚硬币的两面,解释清楚提前编译相对于即时编译有什么优势,又有什么不足,还有即时编译器有没有办法得到这些优势,需要付出哪些努力等。现在提前编译产品和对其的研究有着两条明显的分支,一条分支是做与传统C、C++编译器类似的,在程序运行之前把程序代码编译成机器码的静态翻译工作;另外一条分支是把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器其他Java进程使用)时直接把它加载进来使用。

我们先来说第一条,这是传统的提前编译应用形式,它在Java中存在的价值直指即时编译的最大弱点:即时编译要占用程序运行时间和运算资源。即使现在先进的即时编译器已经足够快,以至于能够容忍相当高的优化复杂度了(譬如Azul公司基于LLVM的Falcon JIT,就能够以相当于Clang-O3的优化级别进行即时编译;又譬如OpenJ9的即时编译器Testarossa,它的静态版本同时也作为C、C++语言的提前编译器使用,优化的复杂度自然也支持得非常高);即使现在先进的即时编译器架构有了分层编译的支持,可以先用快速但低质量的即时编译器为高质量的即时编译器争取出更多编译时间,但是,无论如何,即时编译消耗的时间都是原本可用于程序运行的时间,消耗的运算资源都是原本可用于程序运行的资源,这个约束从未减弱,更不会消失,始终是悬在即时编译头顶的达摩克利斯之剑。

这里举个更具体的例子来帮助读者理解这种约束:在编译过程中最耗时的优化措施之一是通过“过程间分
析”(Inter-Procedural Analysis,IPA,也经常被称为全程序分析,即Whole Program Analysis)来获得诸如某个程序点上某个变量的值是否一定为常量、某段代码块是否永远不可能被使用、在某个点调用的某个虚方法是否只能有单一版本等的分析结论。这些信息对生成高质量的优化代码有着极为巨大的价值,但是要精确(譬如对流敏感、对路径敏感、对上下文敏感、对字段敏感)得到这些信息,必须在全程序范围内做大量极耗时的计算工作,目前所有常见的Java虚拟机对过程间分析的支持都相当有限,要么借助大规模的方法内联来打通方法间的隔阂,以过程内分析(Intra-Procedural Analysis,只考虑过程内部语句,不考虑过程调用的分析)来模拟过程间分析的部分效果;要么借助可假设的激进优化,不求得到精确的结果,只求按照最可能的状况来优化,有问题再退回来解析执行。但如果是在程序运行之前进行的静态编译,这些耗时的优化就可以放心大胆地进行了,譬如Graal VM中的Substrate VM,在创建本地镜像的时候,就会采取许多原本在HotSpot即时编译中并不会做的全程序优化措施以获得更好的运行时性能,反正做镜像阶段慢一点并没有什么大影响。同理,这也是ART打败Dalvik的主要武器之一,连副作用也是相似的。在Android5.0和6.0版本,安装一个稍微大一点的Android应用都是按分钟来计时的,以至于从Android7.0版本起重新启用了解释执行和即时编译(但这已与Dalvik无关,它彻底凉透了),等空闲时系统再在后台自动进行提前编译。关于提前编译的第二条路径,本质是给即时编译器做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热后才能到达最高性能的问题。这种提前编译被称为动态提前编译(DynamicAOT)或者索性就大大方方地直接叫即时编译缓存(JIT Caching)。在目前的Java技术体系里,这条路径的提前编译已经完全被主流的商用J DK支持。在商业应用中,这条路径最早出现在JDK6版本的IBMJ9虚拟机上,那时候在它的CDS(Class Data Sharing)功能的缓存中就有一块是即时编译缓存③。不过这个缓存和CDS缓存一样是虚拟机运行时自动生成的,直接来源于J9的即时编译器,而且为了进程兼容性,很多激进优化都不能肆意运用,所以编译输出的代码质量反而要低于即时编译器。真正引起业界普遍关注的是OpenJDK/OracleJDK9中所带的Jaotc提前编译器,这是一个基于Gral编译器实现的新工具,目的是让用户可以针对目标机器,为应用程序进行提前编译。HotSpot运行时可以直接加载这些编译的结果,实现加快程序启动速度,减少程序达到全速运行状态所需时间的目的。这里面确实有比较大的优化价值,试想一下,各种Ja va应用最起码会用到Java的标准类库,如java.base等模块,如果能够将这个类库提前编译好,并进行比较高质量的优化,显然能够节约不少应用运行时的编译成本。关于这点,我们将在下一节做一个简单的实战练习,而在此要说明的是,这的确是很好的想法,但实际应用起来并不是那么容易,原因是这种提前编译方式不仅要和目标机器相关,甚至还必须与HotSpot虚拟机的运行时参数绑定。譬如虚拟机运行时采用了不同的垃圾收集器,这原本就需要即时编译子系统的配合(典型的如生成内存屏障代码,见第3章相关介绍)才能正确工作,要做提前编译的话,自然也要把这些配合的工作平移过去。至于前面提到过的提前编译破坏平台中立性、字节膨胀等缺点当然还存在,这里就不重复了。尽管还有许多困难,但提前编译无疑已经成为一种极限榨取性能(启动、响应速度)的手段,且被官方JDK关注,相信日后会更加灵活、更加容易使用,就如已经相当成熟的CDS(AppCDS需要用户参与)功能那样,几乎不需要用户介入,可自动完成。
最后,我们还要思考一个问题:提前编译的代码输出质量,一定会比即时编译更高吗?提前编译因为没有执行时间和资源限制的压力,能够毫无顾忌地使用重负载的优化手段,这当然是一个极大的优势,但即时编译难道就没有能与其竞争的强项了吗?当然是有的,尽管即时编译在时间和运算资源方面的劣势是无法忽视的,但其依然有自己的优势。接下来便要开始即时编译器的绝地反击了,笔者将简要介绍三种即时编译器相对于提前编译器的天然优势。
首先,是性能分析制导优化(Profile-Guided Optimization,pGO)。上一节介绍HotSpot的即时编译器时就多次提及在解释器或者客户端编译器运行过程中,会不断收集性能监控信息,譬如某个程序点抽象类通常会是什么实际类型、条件判断通常会走哪条分支、方法调用通常会选择哪个版本、循环通常会进行多少次等,这些数据一般在静态分析时是无法得到的,或者不可能存在确定且唯一的解,最多只能依照一些启发性的条件去进行猜测。但在动态运行时却能看出它们具有非常明显的偏好性。如果一个条件分支的某一条路径执行特别频繁,而其他路径鲜有问津,那就可以把热的代码集中放到一起,集中优化和分配更好的资源(分支预测、寄存器、缓存等)给它;

其次,是激进预测性优化(Aggressive Speculative Optimization),这也已经成为很多即时编译优化措施的基础。静态优化无论如何都必须保证优化后所有的程序外部可见影响(不仅仅是执行结果)与优化前是等效的,不然优化之后会导致程序报错或者结果不对,若出现这种情况,则速度再快也是没有价值的。然而,相对于提前编译来说,即时编译的策略就可以不必这样保守,如果性能监控信息能够支持它做出一些正确的可能性很大但无法保证绝对正确的预测判断,就已经可以大胆地按照高概率的假设进行优化,万一真的走到罕见分支上,大不了退回到低级编译器甚至解释器上去执行,并不会出现无法挽救的后果。只要出错概率足够低,这样的优化往往能够大幅度降低目标程序的复杂度,输出运行速度非常高的代码。譬如在Java语言中,默认方法都是虚方法调用,部分C、C++程序员(甚至一些老旧教材)会说虚方法是不能内联的,但如果Java虚拟机真的遇到虚方法就去查虚表而不做内联的话,Java技术可能就已经因性能问题而被淘汰很多年了。实际上虚拟机会通过类继承关系分析等一系列激进的猜测去做去虚拟化(Devitalization),以保证绝大部分有内联价值的虚方法都可以顺利内联。内联是最基础的一项优化措施,本章稍后还会对专门的Java虚拟机具体如何做虚方法内联进行详细讲解。

最后,是链接时优化(Link-Time Op timization,LTO),Java语言天生就是动态链接的,一个个Class文件在运行期被加载到虚拟机内存当中,然后在即时编译器里产生优化后的本地代码,这类事情在Java程序员眼里看起来毫无违和之处。但如果类似的场景出现在使用提前编译的语言和程序上,譬如C、C++的程序要调用某个动态链接库的某个方法,就会出现很明显的边界隔阂,还难以优化。这是因为主程序与动态链接库的代码在它们编译时是完全独立的,两者各自编译、优化自己的代码。这些代码的作者、编译的时间,以及编译器甚至很可能都是不同的,当出现跨链接库边界的调用时,那些理论上应该要做的优化——譬如做对调用方法的内联,就会执行起来相当的困难。如果刚才说的虚方法内联让C、C++程序员理解还算比较能够接受的话(其实C++编译器也可以通过一些技巧来做到虚方法内联),那这种跨越动态链接库的方法内联在他们眼里可能就近乎于离经叛道了(但实际上依然是可行的)。

经过以上的讨论,读者应该能够理解提前编译器的价值与优势所在了,但忽略具体的应用场景就说它是万能的银弹,那肯定是有失偏颇的,提前编译有它的应用场景,也有它的弱项与不足,相信未来很长一段时间内,即时编译和提前编译都会是Java后端编译技术的共同主角。

4.3.4 编译器优化技术

经过前面对即时编译、提前编译的讲解,读者应该已经建立起一个认知:编译器的目标虽然是做由程序代码翻译为本地机器码的工作,但其实难点并不在于能不能成功翻译出机器码,输出代码优化质量的高低才是决定编译器优秀与否的关键。在本章之前的内容里出现过许多优化措施的专业名词,有一些是编译原理中的基础知识,譬如方法内联,只要是计算机专业毕业的读者至少都有初步的概念;但也有一些专业性比较强的名词,譬如逃逸分析,可能不少读者只听名字很难想象出来这个优化会做什么事情。本节将介绍几种HotSpot虚拟机的即时编译器在生成代码时采用的代码优化技术,以小见大,见微知著,让读者对编译器代码优化有整体理解。

4.3.5 优化技术概览

OpenJDK的官方Wiki上,HotSpot虚拟机设计团队列出了一个相对比较全面的、即时编译器中采用的优化技术列表,如表所示,其中有不少经典编译器的优化手段,也有许多针对Java语言,或者说针对运行在Java虚拟机上的所有语言进行的优化。本节先对这些技术进行概览,在后面几节中,将挑选若干最重要或最典型的优化,与读者一起看看优化前后的代码发生了怎样的变化。


上述的优化技术看起来很多,而且名字看起来大多显得有点“高深莫测”,实际上要实现这些优化确实有不小的难度,但大部分优化技术理解起来都并不困难,为了消除读者对这些优化技术的陌生感,笔者举一个最简单的例子:通过大家熟悉的Java代码变化来展示其中几种优化技术是如何发挥作用的。不过首先需要明确一点,即时编译器对这些代码优化变换是建立在代码的中间表示或者是机器码之上的,绝不是直接在Java源码上去做的,这里只是笔者为了方便讲解,使用了Java语言的语法来表示这些优化技术所发挥的作用。

接下来,笔者挑选了四项有代表性的优化技术,与大家一起观察它们是如何运作的。它们分别是:

  • 最重要的优化技术之一:方法内联。
  • 最前沿的优化技术之一:逃逸分析。
  • 语言无关的经典优化技术之一:公共子表达式消除。
  • 语言相关的经典优化技术之一:数组边界检查消除。

4.3.5.1 方法内联

在前面的讲解中,我们多次提到方法内联,说它是编译器最重要的优化手段,甚至都可以不加上“之一”。内联被业内戏称为优化之母,因为除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础,代码清单所示的简单例子就揭示了内联对其他优化手段的巨大价值:没有内联,多数其他优化都无法有效进行。例子里testInline()方法的内部全部是无用的代码,但如果不做内联,后续即使进行了无用代码消除的优化,也无法发现任何“Dead Code”的存在。如果分开来看,foo()和testInline()两个方法里面的操作都有可能是有意义的。

方法内联的优化行为理解起来是没有任何困难的,不过就是把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免发生真实的方法调用而已。但实际上Java虚拟机中的内联过程却远没有想象中容易,甚至如果不是即时编译器做了一些特殊的努力,按照经典编译原理的优化理论,大多数的Java方法都无法进行内联。

无法内联的原因其实在第8章中讲解Java方法解析和分派调用的时候就已经解释过:只有使用invokespecial指令调用的私有方法、实例构造器、父类方法和使用invokestatic指令调用的静态方法才会在编译期进行解析。除了上述四种方法之外(最多再除去被final修饰的方法这种特殊情况,尽管它使用invokevirtual指令调用,但也是非虚方法,《Java语言规范》中明确说明了这点),其他的Java方法调用都必须在运行时进行方法接收者的多态选择,它们都有可能存在多于一个版本的方法接收者,简而言之,Java语言中默认的实例方法是虚方法。

对于一个虚方法,编译器静态地去做内联的时候很难确定应该使用哪个方法版本,以将代码清单中所示b.get()直接内联为b.value为例,如果不依赖上下文,是无法确定b的实际类型是什么的。假如有ParentB和SubB是两个具有继承关系的父子类型,并且子类重写了父类的get()方法,那么b.get()是执行父类的get()方法还是子类的get()方法,这应该是根据实际类型动态分派的,而实际类型必须在实际运行到这一行代码时才能确定,编译器很难在编译时得出绝对准确的结论。

更糟糕的情况是,由于Java提倡使用面向对象的方式进行编程,而Java对象的方法默认就是虚方法,可以说Java间接鼓励了程序员使用大量的虚方法来实现程序逻辑。根据上面的分析可知,内联与虚方法之间会产生“矛盾”,那是不是为了提高执行性能,就应该默认给每个方法都使用final关键字去修饰呢?C和C++语言的确是这样做的,默认的方法是非虚方法,如果需要用到多态,就用virtual关键字来修饰,但Java选择了在虚拟机中解决这个问题。

为了解决虚方法的内联问题,Java虚拟机首先引入了一种名为类型继承关系分析(Class Hierarchy Analysis,CHA)的技术,这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。这样,编译器在进行内联时就会分不同情况采取不同的处理:如果是非虚方法,那么直接进行内联就可以了,这种的内联是有百分百安全保障的;如果遇到虚方法,则会向CHA查询此方法在当前程序状态下是否真的有多个目标版本可供选择,如果查询到只有一个版本,那就可以假设“应用程序的全貌就是现在运行的这个样子”来进行内联,这种内联被称为守护内联(Guarded Inlining)。不过由于Java程序是动态连接的,说不准什么时候就会加载到新的类型从而改变CHA结论,因此这种内联属于激进预测性优化,必须预留好“逃生门”,即当假设条件不成立时的“退路”(Slow Path)。

假如在程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。如果加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译。

假如向CHA查询出来的结果是该方法确实有多个版本的目标方法可供选择,那即时编译器还将进行最后一次努力,使用内联缓存(Inline Cache)的方式来缩减方法调用的开销。这种状态下方法调用是真正发生了的,但是比起直接查虚方法表还是要快一些。内联缓存是一个建立在目标方法正常入口之前的缓存,它的工作原理大致为:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。如果以后进来的每次调用的方法接收者版本都是一样的,那么这时它就是一种单态内联缓存(Monomorphic Inline Cache)。通过该缓存来调用,比用不内联的非虚方法调用,仅多了一次类型判断的开销而已。但如果真的出现方法接收者不一致的情况,就说明程序用到了虚方法的多态特性,这时候会退化成超多态内联缓存(Megamorphic Inline Cache),其开销相当于真正查找虚方法表来进行方法分派。

所以说,在多数情况下Java虚拟机进行的方法内联都是一种激进优化。事实上,激进优化的应用在高性能的Java虚拟机中比比皆是,极为常见。除了方法内联之外,对于出现概率很小(通过经验数据或解释器收集到的性能监控信息确定概率大小)的隐式异常、使用概率很小的分支等都可以被激进优化“移除”,如果真的出现了小概率事件,这时才会从“逃生门”回到解释状态重新执行。

4.3.5.2 逃逸分析

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化,如:

  • 栈上分配(Stack Allocations):
    在Java虚拟机中,Java堆上分配创建对象的内存空间几乎是Java程序员都知道的常识,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸。
  • 标量替换(Scalar Replacement):
    若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),Java中的对象就是典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
  • 同步消除(Synchronization Eli mination):
    线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉。

关于逃逸分析的研究论文早在1999年就已经发表,但直到JDK6,HotSpot才开始支持初步的逃逸分析,而且到现在这项优化技术尚未足够成熟,仍有很大的改进余地。不成熟的原因主要是逃逸分析的计算成本非常高,甚至不能保证逃逸分析带来的性能收益会高于它的消耗。如果要百分之百准确地判断一个对象是否会逃逸,需要进行一系列复杂的数据流敏感的过程间分析,才能确定程序各个分支执行时对此对象的影响。前面介绍即时编译、提前编译优劣势时提到了过程间分析这种大压力的分析算法正是即时编译的弱项。可以试想一下,如果逃逸分析完毕后发现几乎找不到几个不逃逸的对象,那这些运行期耗用的时间就白白浪费了,所以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成分析。

C和C++语言里面原生就支持了栈上分配(不使用new操作符即可),而C#也支持值类型,可以很自然地做到标量替换(但并不会对引用类型做这种优化)。在灵活运用栈内存方面,确实是Java的一个弱项。在现在仍处于实验阶段的Valhalla项目里,设计了新的inline关键字用于定义Java的内联类型,目的是实现与C#中值类型相对标的功能。有了这个标识与约束,以后逃逸分析做起来就会简单很多。

4.3.5.3 公共子表达式消除

公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术,它的含义是:如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)。

如果这段代码交给Javac编译器则不会进行任何优化,那生成的代码将如代码清单所示,是完全遵照Java源码的写法直译而成的。

当这段代码进入虚拟机即时编译器后,它将进行如下优化:编译器检测到cb与bc是一样的表达式,而且在计算期间b与c的值是不变的。
因此这条表达式就可能被视为:

这时候,编译器还可能(取决于哪种虚拟机的编译器以及具体的上下文而定)进行另外一种优化一—代数化简(Algebraic Simplification),在E本来就有乘法运算的前提下,把表达式变为:

表达式进行变换之后,再计算起来就可以节省一些时间了。如果读者还对其他的经典编译优化技术感兴趣,可以参考《编译原理》(俗称龙书)中的相关章节。

5. Java内存模型与线程

并发处理的广泛应用是Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类压榨计算机运算能力的最有力武器。

5.1 概述

多任务处理在现代计算机操作系统中几乎已是一项必备的功能了。在许多场景下,让计算机同时去做几件事情,不仅是因为计算机的运算能力强大了,还有一个很重要的原因是计算机的运算速度与它的存储和通信子系统的速度差距太大,大量的时间都花费在磁盘I/O、网络通信或者数据库访问上。如果不希望处理器在大部分时间里都处于等待其他资源的空闲状态,就必须使用一些手段去把处理器的运算能力“压榨”出来,否则就会造成很大的性能浪费,而让计算机同时处理几项任务则是最容易想到,也被证明是非常有效的“压榨”手段。

除了充分利用计算机处理器的能力外,一个服务端要同时对多个客户端提供服务,则是另一个更具体的并发应用场景。衡量一个服务性能的高低好坏,每秒事务处理数(TransactionsPer Second,TPS)是重要的指标之一,它代表着一秒内服务端平均能响应的请求总数,而TPS值与程序的并发能力又有非常密切的关系。对于计算量相同的任务,程序线程并发协调得越有条不紊,效率自然就会越高;反之,线程之间频繁争用数据,互相阻塞甚至死锁,将会大大降低程序的并发能力。

服务端的应用是Java语言最擅长的领域之一,这个领域的应用占了Java应用中最大的一块份额,不过如何写好并发应用程序却又是服务端程序开发的难点之一,处理好并发方面的问题通常需要更多的编码经验来支持。幸好Java语言和虚拟机提供了许多工具,把并发编程的门槛降低了不少。各种中间件服务器、各类框架也都努力地替程序员隐藏尽可能多的线程并发细节,使得程序员在编码时能更关注业务逻辑,而不是花费大部分时间去关注此服务会同时被多少人调用、如何处理数据争用、协调硬件资源。但是无论语言、中间件和框架再如何先进,开发人员都不应期望它们能独立完成所有并发处理的事情,了解并发的内幕仍然是成为一个高级程序员不可缺少的课程。

“高效并发”是本讲解Java虚拟机的最后一个部分,将会向读者介绍虚拟机如何实现多线程、多线程之间由于共享和竞争数据而导致的一系列问题及解决方案。

5.2 硬件的效率与一致性

在正式讲解Java虚拟机并发相关的知识之前,我们先花费一点时间去了解一下物理计算机中的并发问题。物理机遇到的并发问题与虚拟机中的情况有很多相似之处,物理机对并发的处理方案对虚拟机的实现也有相当大的参考意义。
“让计算机并发执行若干个运算任务”与“更充分地利用计算机处理器的效能”之间的因果关系,看起来理所当然,实际上它们之间的关系并没有想象中那么简单,其中一个重要的复杂性的来源是绝大多数的运算任务都不可能只靠处理器“计算”就能完成。处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个I/O操作就是很难消除的(无法仅靠寄存器来完成所有运算任务)。由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来更高的
复杂度,它引入了一个新的问题:缓存一致性(Cache Coherence)。在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),这种系统称为共享内存多核系统(Shared Memory Multiprocessors System),如图所示。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。如果真的发生这种情况,那同步回到主内存时该以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protoco1)、MOSI、Synapse、Firefly及Dragon Protocol等。从本章开始,我们将会频繁见到“内存模型”一词,它可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型,并且与这里介绍的内存访问操作及硬件的缓存访问操作具有高度的可类比性。

除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有指令重排序(Instruction Reo-
rder)优化。

5.3 Java内存模型

《Java虚拟机规范》中曾试图定义一种“Java内存模型”(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。在此之前,主流程序语言(如C和C++等)直接使用物理硬件和操作系统的内存模型。因此,由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,所以在某些场景下必须针对不同的平台来编写程序。

定义Java内存模型并非一件容易的事情,这个模型必须定义得足够严谨,才能让Java的并发内存访问操作不会产生歧义;但是也必须定义得足够宽松,使得虚拟机的实现能有足够的自由空间去利用硬件的各种特性(寄存器、高速缓存和指令集中某些特有的指令)来获取更好的执行速度。经过长时间的验证和修补,直至JDK5(实现了JSR-133)发布后,Java内存模型才终于成熟、完善起来了。

5.4 主内存与工作内存

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。
此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。为了获得更好的执行效能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器是否要进行调整代码执行顺序这类优化措施。

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分)。
每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本健,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图所示,注意与图进行对比。

这里所讲的主内存、工作内存与所讲的Java内存区域中的Java堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本上是没有任何关系的。如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

5.4.1 内存间交互操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的;

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。也就是说read与load之间、store与write之间是可插入其他指令的,如对主内存中的变量a、b进行访问时,一种可能出现的顺序是read a、read b、load b、load a。除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它最近的as sign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assi-
    gn)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

这8种内存访问操作以及上述规则限定,再加上稍后会介绍的专门针对volatile的一些特殊规定,就已经能准确地描述出Java程序中哪些内存访问操作在并发下才是安全的。这种定义相当严谨,但也是极为烦琐,实践起来更是无比麻烦。可能部分读者阅读到这里已经对多线程开发产生恐惧感了,后来Java设计团队大概也意识到了这个问题,将Java内存模型的操作简化为read、write、lock和unlock四种,但这只是语言描述上的等价化简,Java内存模型的基础设计并未改变,即使是这四种操作,对于普通用户来说阅读使用起来仍然并不方便。不过读者对此无须过分担忧,除了进行虚拟机开发的团队外,大概没有其他开发人员会以这种方式来思考并发问题,我们只需要理解Java内存模型的定义即可;

5.4.2 对于volatile型变量的特殊规则

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,但是它并不容易被正确、完整地理解,以至于许多程序员都习惯去避免使用它,遇到需要处理多线程数据竞争问题的时候一律使用synchronized来进行同步。了解volatile变量的语义对后面理解多线程操作的其他特性很有意义,在本节中我们将多花费一些篇幅介绍v olatile到底意味着什么。
Java内存模型为volatile专门定义了一些特殊的访问规则,在介绍这些比较拗口的规则定义之前,先用一些不那么正式,但通俗易懂的语言来介绍一下这个关键字的作用。

当一个变量被定义成volatile之后,它将具备两项特性:第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再对主内存进行读取操作,新变量值才会对线程B可见。

关于volatile变量的可见性,经常会被开发人员误解,他们会误以为下面的描述是正确的:“volatile变量对所有线程是立即可见的,对volatile变量所有的写操作都能立刻反映到其他线程之中。换句话说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是线程安全的”。这句话的论据部分并没有错,但是由其论据并不能得出“基于volatile变量的运算在并发下是线程安全的”这样的结论。volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的;

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized、java.util.concurrent中的锁或原子类)来保证原子性:

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束。

使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在同一个线程的方法执行过程中无法感知到这点,这就是Java内存模型中描述的所谓“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)。

指令重排序是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理。但并不是说指令任意重排,处理器必须能正确处理指令依赖情况保障程序能得出正确的执行结果。

解决了volatile的语义问题,再来看看在众多保障并发安全的工具中选用volatile的意义——它能让我们的代码比使用其他的同步工具更快吗?在某些情况下,volatile的同步机制的性能确实要优于锁(使用synchronized关键字或java.util.concurrent包里面的锁),但是由于虚拟机对锁实行的许多消除和优化,使得我们很难确切地说v olatile就会比synchronized快上多少。如果让volatile自己与自己比较,那可以确定一个原则:volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过即便如此,大多数场景下volatile的总开销仍然要比锁来得更低。

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

Java内存模型要求lock、unlock、rea d、load、assign、use、store、write这八种操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的oad、store、rea d和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”(Non-Atomic Treatment of double a nd long Variables)。

如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的代表了“半个变量”的数值。不过这种读取到“半个变量”的情况是非常罕见的,经过实际测试,在目前主流平台下商用的64位Java虚拟机中并不会出现非原子性访问行为,但是对于32位的Java虚拟机,譬如比较常用的32位×86平台下的HotSpot虚拟机,对long类型的数据确实存在非原子性访问的风险。从JDK9起,HotSpot增加了一个实验性的参数-XX:+AlwaysAtomicAccesses(这是JEP188对Java内存模型更新的一部分内容)来约束虚拟机对所有数据类型进行原子性的访问。而针对double类型,由于现代中央处理器中一般都包含专门用于处理浮点数据的浮点运算器(Floating Pointu nit,Fpu),用来专门处理单、双精度的浮点数据,所以哪怕是32位虚拟机中通常也不会出现非原子性访问的问题,实际测试也证实了这一点。笔者的看法是,在实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写代码时一般不需要因为这个原因刻意把用到的long和double变量专门声明为volatile。

5.4.2.2 原子性、可见性与有序性

5.4.2.2.1 原子性(Atomicity)

由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子性协定,读者只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)。
如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unl ock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。

5.4.2.2.2 可见性(Visibility)

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。上文在讲解volatile变量的时候我们已详细讨论过这一点。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得的。而final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见final字段的值。

5.4.2.2.3 有序性(Ordering)

Java内存模型的有序性在前面讲解volatile时也比较详细地讨论过了,Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-Serial Se mantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

5.5 Java与线程

并发不一定要依赖多线程(如PHP中很常见的多进程并发),但是在Java里面谈论并发,基本上都与线程脱不开关系。既然本书探讨的是Java虚拟机的特性,那讲到Java线程,我们就从Java线程在虚拟机中的实现开始讲起。

5.5.1 线程的实现

我们知道,线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。目前线程是Java里面进行处理器资源调度的最基本单位,不过如果日后L oom项目能成功为Java引入纤程(Fiber)的话,可能就会改变这一点。

主流的操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经调用过start()方法且还未结束的java.lang.Thread类的实例就代表着一个线程。我们注意到Thread类与大部分的Java类库API有着显著差别,它的所有关键方法都被声明为Native。在Java类库API中,一个Native方法往往就意味着这个方法没有使用或无法使用平台无关的手段来实现(当然也可能是为了执行效率而使用Native方法,不过通常最高效率的手段也就是平台相关的手段)。正因为这个原因,本节的标题被定为“线程的实现”而不是“Java线程的实现”,在稍后介绍的实现方式中,我们也先把Java的技术背景放下,以一个通用的应用程序的角度来看看线程是如何实现的。
实现线程主要有三种方式:

  • 使用内核线程实现(1:1实现)
  • 使用用户线程实现(1:N实现)
  • 使用用户线程加轻量级进程混合实现(N:M实现)。

5.5.1.1 内核线程实现

使用内核线程实现的方式也被称为1:1实现。内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核(Multi-Threads Kernel)。

程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型,如图所示。

由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程在系统调用中被阻塞了,也不会影响整个进程继续工作。轻量级进程也具有它的局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(user Mode)和内核态(Kernel Mode)中来回切换。其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。

5.5.1.2 用户线程实现

使用用户线程实现的方式被称为1:N实现。广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程(user Thread,UT)的一种,因此从这个定义上看,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此效率会受到限制,并不具备通常意义上的用户线程的优点。

而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型,如图所示。

用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至有些是不可能实现的。因为使用用户线程实现的程序通常都比较复杂,除了有明确的需求外(譬如以前在不支持多线程的操作系统中的多线程程序、需要支持大规模线程数量的应用),一般的应用程序都不倾向使用用户线程。Java、Ruby等语言都曾经使用过用户线程,最终又都放弃了使用它。但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang、Erlang等,使得用户线程的使用率有所回升。

5.5.1.3 混合实现

线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式,被称为N:M实现。在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是N:M的关系,如图所示,这种就是多对多的线程模型。

许多UNIX系列的操作系统,如Solaris、HP-UX等都提供了M:N的线程模型实现。在这些操作系统上的应用也相对更容易应用M:N的线程模型。

5.5.2 Java线程的实现

Java线程如何实现并不受Java虚拟机规范的约束,这是一个与具体虚拟机相关的话题。Java线程在早期的CI assic虚拟机上(JDK1.2以前),是基于一种被称为“绿色线程”(Green Threads)的用户线程实现的,但从JDK1.3起,“主流”平台上的“主流”商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型。
以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的。

前面强调是两个“主流”,那就说明肯定还有例外的情况,这里举两个比较著名的例子,一个是用于Java ME的CLDC HotSpot Implementation(CLDC-HI,介绍可见第1章)。它同时支持两种线程模型,默认使用1:N由用户线程实现的线程模型,所有Java线程都映射到一个内核线程上;不过它也可以使用另一种特殊的混合模型,Java线程仍然全部映射到一个内核线程上,但当Java线程要执行一个阻塞调用时,CLDC-HI会为该调用单独开一个内核线程,并且调度执行其他Java线程,等到那个阻塞调用完成之后再重新调度之前的Java线程继续执行。

另外一个例子是在Solaris平台的HotSpot虚拟机,由于操作系统的线程特性本来就可以同时支持1:1(通过Bound Threads 或Alternate Libthread实现)及N:M(通过LWP/Thread Based Sy nchronization实现)的线程模型,因此S olaris版的HotSpot也对应提供了两个平台专有的虚拟机参数,即-XX:+use-
LWPSynchronization(默认值)和-XX:+useBoundThreads来明确指定虚拟机使用哪种线程模型。
操作系统支持怎样的线程模型,在很大程度上会影响上面的Java虚拟机的线程是怎样映射的,这一点在不同的平台上很难达成一致,因此《Java虚拟机规范》中才不去限定Java线程需要使用哪种线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对Java程序的编码和运行过程来说,这些差异都是完全透明的。

5.5.2.1 Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式(Cooperative Thr-eads-Scheduling)线程调度抢占式(P reemptive Threads-Scheduling)线程调度
如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么线程同步的问题。Lua语言中的“协同例程”就是这类实现。它的坏处也很明显:线程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。很久以前的Windows 3.×系统就是使用协同式来实现多进程多任务的,那是相当不稳定的,只要有一个进程坚持不让出处理器执行时间,就可能会导致整个系统崩溃。

如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。譬如在Java中,有Thread::yield()方法可以主动让出执行时间,但是如果想要主动获取执行时间,线程本身是没有什么办法的。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程甚至整个系统阻塞的问题。Java使用的线程调度方式就是抢占式调度。与前面所说的Windows3.x的例子相对,在Windows9x/NT内核中就是使用抢占式来实现多进程的,当一个进程出了问题,我们还可以使用任务管理器把这个进程杀掉,而不至于导致系统崩溃。

虽然说Java线程调度是系统自动完成的,但是我们仍然可以“建议”操作系统给某些线程多分配一点执行时间,另外的一些线程则可以少分配一点一—这项操作是通过设置线程优先级来完成的。Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)。在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。

不过,线程优先级并不是一项稳定的调节手段,很显然因为主流虚拟机上的Java线程是被映射到系统的原生线程上来实现的,所以线程调度最终还是由操作系统说了算。尽管现代的操作系统基本都提供线程优先级的概念,但是并不见得能与Java线程的优先级一一对应,如Solaris中线程有2147483648(2的31次幂)种优先级,但Wi ndows中就只有七种优先级。如果操作系统的优先级比Java线程优先级更多,那问题还比较好处理,中间留出一点空位就是了,但对于比Java线程优先级少的系统,就不得不出现几个线程优先级对应到同一个操作系统优先级的情况了。表显示了Java线程优先级与Windows线程优先级之间的对应关系,Windows平台的虚拟机中使用了除THREAD_PRIORITY_IDLE之外的其余6种线程优先级,因此在Windows下设置线程优先级为1和2、3和4、6和7、8和9的效果是完全相同的。

Java线程优先级与Windows线程优先级之间的对应关系:


线程优先级并不是一项稳定的调节手段,这不仅仅体现在某些操作系统上不同的优先级实际会变得相同这一点上,还有其他情况让我们不能过于依赖线程优先级:优先级可能会被系统自行改变,例如在Windows系统中存在一个叫“优先级推进器”的功能(Priority Boosting,当然它可以被关掉)
,大致作用是当系统发现一个线程被执行得特别频繁时,可能会越过线程优先级去为它分配执行时间,从而减少因为线程频繁切换而带来的性能损耗。因此,我们并不能在程序中通过优先级来完全准确判断一组状态都为Re ady的线程将会先执行哪一个。

5.5.2.2 状态转换

Java语言定义了6种线程状态,在任意一个时间点中,一个线程只能有且只有其中的一种状态,并且可以通过特定的方法在不同状态之间转换。
这6种状态分别是:

  • 新建(New):创建后尚未启动的线程处于这种状态。

  • 运行(Runnable):包括操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。

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

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

    • Thread::sleep()方法;
    • 设置了Timeout参数的Object::wait()方法;
    • 设置了Timeout参数的Thread:join()方法;
    • LockSupport::parkNancs()方法;
    • LockSupport:parkUntil()方法。
  • 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。

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

5.5.2.3 Java与协程

在Java时代的早期,Java语言抽象出来隐藏了各种操作系统线程差异性的统一线程接口,这曾经是它区别于其他编程语言的一大优势。在此基础上,涌现过无数多线程的应用与框架,譬如在网页访问时,HTTP请求可以直接与ServletAPI中的一条处理线程绑定在一起,以“一对一服务”的方式处理由浏览器发来的信息。语言与框架已经自动屏蔽了相当多同步和并发的复杂性,对于普通开发者而言,几乎不需要专门针对多线程进行学习训练就能完成一般的并发任务。时至今日,这种便捷的并发编程方式和同步的机制依然在有效地运作着,但是在某些场景下,却也已经显现出了疲态。

5.5.2.4 内核线程的局限

笔者可以通过一个具体场景来解释目前Java线程面临的困境。今天对W eb应用的服务要求,不论是在请求数量上还是在复杂度上,与十多年前相比已不可同日而语,这一方面是源于业务量的增长,另一方面来自于为了应对业务复杂化而不断进行的服务细分。现代B/S系统中一次对外部业务请求的响应,往往需要分布在不同机器上的大量服务共同协作来实现,这种服务细分的架构在减少单个服务复杂度、增加复用性的同时,也不可避免地增加了服务的数量,缩短了留给每个服务的响应时间。这要求每一个服务都必须在极短的时间内完成计算,这样组合多个服务的总耗时才不会太长;也要求每一个服务提供者都要能同时处理数量更庞大的请求,这样才不会出现请求由于某个服务被阻塞而出现等待。

Java目前的并发编程机制就与上述架构趋势产生了一些矛盾,1:1的内核线程模型是如今Java虚拟机线程实现的主流选择,但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限。以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本也是无伤大雅的,但现在在每个请求本身的执行时间变得很短、数量变得很多的前提下,用户线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费。

传统的Java Web服务器的线程池的容量通常在几十个到两百之间,当程序员把数以百万计的请求往线程池里面灌时,系统即使能处理得过来,但其中的切换损耗也是相当可观的。现实的需求在迫使Java去研究新的解决方案,同大家又开始怀念以前绿色线程的种种好处,绿色线程已随着Classic虚拟机的消失而被尘封到历史之中,它还会有重现天日的一天吗?

5.5.2.5 协程的复苏

经过前面对不同线程实现方式的铺垫介绍,我们已经明白了各种线程实现方式的优缺点,所以多数读者看到笔者写“因为映射到了系统的内核线程中,所以切换调度成本会比较高昂”时并不会觉得有什么问题,但相信还是有一部分治学特别严谨的读者会提问:为什么内核线程调度切换起来成本就要更高?

内核线程的调度成本主要来自于用户态与核心态之间的状态转换,而这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本。请读者试想以下场景,假设发生了这样一次线程切换:

处理器要去执行线程A的程序代码时,并不是仅有代码程序就能跑得起来,程序是数据与代码的组合体,代码执行时还必须要有上下文数据的支撑。而这里说的“上下文”,以程序员的角度来看,是方法调用过程中的各种局部的变量与资源;以线程的角度来看,是方法的调用栈中存储的各类信息;而以操作系统和硬件的角度来看,则是存储在内存、缓存和寄存器中的一个个具体数值。物理硬件的各种存储设备和寄存器是被操作系统内所有线程共享的资源,当中断发生,从线程A切换到线程B去执行之前,操作系统首先要把线程A的上下文数据妥善保管好,然后把寄存器、内存分页等恢复到线程B挂起时候的状态,这样线程B被重新激活后才能仿佛从来没有被挂起过。这种保护和恢复现场的工作,免不了涉及一系列数据在各种寄存器、缓存中的来回拷贝,当然不可能是一种轻量级的操作。

如果说内核线程的切换开销是来自于保护和恢复现场的成本,那如果改为采用用户线程,这部分开销就能够省略掉吗?答案是“不能”。但是,一旦把保护、恢复现场及调度的工作从操作系统交到程序员手上,那我们就可以打开脑洞,通过玩出很多新的花样来缩减这些开销。

有一些古老的操作系统(譬如DO-S)是单人单工作业形式的,天生就不支持多线程,自然也不会有多个调用栈这样的基础设施。而早在那样的蛮荒时代,就已经出现了今天被称为栈纠缠(Stack Twine)的、由用户自己模拟多线程、自己保护恢复现场的工作模式。其大致的原理是通过在内存里划出一片额外空间来模拟调用栈,只要其他“线程”中方法压栈、退栈时遵守规则,不破坏这片空间即可,这样多段代码执行时就会像相互缠绕着一样,非常形象。到后来,操作系统开始提供多线程的支持,靠应用自己模拟多线程的做法自然是变少了许多,但也并没有完全消失,而是演化为用户线程继续存在。由于最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling)的,所以它有了一个别名—“
协程”(Coroutine)。又由于这时候的协程会完整地做调用栈的保护、恢复工作,所以今天也被称为“有栈协程”(Stackful Coroutine),起这样的名字是为了便于跟后来的“无栈协程”(Stackless Coroutine)区分开。无栈协程不是本节的主角,不过还是可以简单提一下它的典型应用,即各种语言中的await、async、yield这类关键字。无栈协程本质上是一种有限状态机,状态保存在闭包里,自然比有栈协程恢复调用栈要轻量得多,但功能也相对更有限。

协程的主要优势是轻量,无论是有栈协程还是无栈协程,都要比传统内核线程要轻量得多。如果进行量化的话,那么如果不显式设置-Xss或-XX:ThreadStackSize,则在64位Linux上HotSpot的线程栈容量默认是1MB,此外内核数据结构(Kernel Data Structures)还会额外消耗16KB内存。与之相对的,一个协程的栈通常在几百个字节到几KB之间,所以Java虚拟机里线程池容量达到两百就已经不算小了,而很多支持协程的应用中,同时并存的协程数量可数以十万计。

协程当然也有它的局限,需要在应用层面实现的内容(调用栈、调度器这些)特别多,这个缺点就不赘述了。除此之外,协程在最初,甚至在今天很多语言和框架中会被设计成协同式调度,这样在语言运行平台或者框架上的调度器就可以做得非常简单。不过有不少资料上显示,既然取了“协程”这样的名字,它们之间就一定以协同调度的方式工作。笔者并没有查证到这种“规定”的出处,只能说这种提法在今天太过狭隘了,非协同式、可自定义调度的协程的例子并不少见;

5.5.3 线程安全与锁优化

5.5.3.1 线程安全

“线程安全”这个名称,相信稍有经验的程序员都听说过,甚至在代码编写和走查的时候可能还会经常挂在嘴边,但是如何找到一个不太拗口的概念来定义线程安全却不是一件容易的事情。笔者尝试在网上搜索它的概念,找到的是类似于“如果一个对象可以安全地被多个线程同时使用,那它就是线程安全的”这样的定义—一并不能说它不正确,但是它没有丝毫可操作性,无法从中获取到任何有用的信息。

笔者认为《Java并发编程实战(Java Concurrency In Practice)》的作者Bri an Goetz为“线程安全”做出了一个比较恰当的定义:“当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。”

这个定义就很严谨而且有可操作性,它要求线程安全的代码都必须具备一个共同特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程下的调用问题,更无须自己实现任何措施来保证多线程环境下的正确调用。这点听起来简单,但其实并不容易做到,在许多场景中,我们都会将这个定义弱化一些。如果把“调用这个对象的行为”限定为“单次调用”,这个定义的其他描述能够成立的话,那么就已经可以称它是线程安全了

5.5.3.2 Java语言中的线程安全

我们已经有了线程安全的一个可操作的定义,那接下来就讨论一下:在Java语言中,线程安全具体是如何体现的?有哪些操作是线程安全的?我们这里讨论的线程安全,将以多个线程之间存在共享数据访问为前提。因为如果根本不存在多线程,又或者一段代码根本不会与其他线程共享数据,那么从线程安全的角度上看,程序是串行执行还是多线程执行对它来说是没有什么区别的。

为了更深入地理解线程安全,在这里我们可以不把线程安全当作一个非真即假的二元排他选项来看待,而是按照线程安全的“安全程度”由强至弱来排序,我们可以将Java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

5.5.3.2.1 不可变性

在Java语言里面(特指JDK5以后,即Java内存模型被修正之后的Java语言),不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。在第10章里我们讲解“final关键字带来的可见性”时曾经提到过这一点:只要一个不可变的对象被正确地构建出来(即没有发生this引用逃逸的情况),那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态。“不可变”带来的安全性是最直接、最纯粹的。

Java语言中,如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,由于Java语言目前暂时还没有提供值类型的支持,那就需要对象自行保证其行为不会对其状态产生任何影响才行。如果读者没想明白这句话所指的意思,不妨类比java.lang.String类的对象实例,它是一个典型的不可变对象,用户调用它的substring()、replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象;

保证对象行为不影响自己状态的途径有很多种,最简单的一种就是把对象里面带有状态的变量都声明为fimal,这样在构造函数结束之后,它就是不可变的,例如代码清单中所示的java.lang.Integer构造函数,它通过将内部状态变量value定义为final来保障状态不变。

在Java类库API中符合不可变要求的类型,除了上面提到的String之外,常用的还有枚举类型及java.lang.Number的部分子类,如Long和Double等数值包装类型、BigInteger和BigDecimal等大数据类型。但同为Number子类型的原子类AtomicInteger和AtomicLong则是可变的,读者不妨看看这两个原子类的源码,想一想为什么它们要设计成可变的。

5.5.3.2.2 绝对线程安全

绝对的线程安全能够完全满足Bri an Goetz给出的线程安全的定义,这个定义其实是很严格的,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”可能需要付出非常高昂的,甚至不切实际的代价。
在JavaAPI中标注自己是线程安全的类,大多数都不是绝对的线程安全。我们可以通过JavaAPI中一个不是“绝对线程安全”的“线程安全类型”来看看这个语境里的“绝对”究竞是什么意思。

如果说java.util.Vector是一个线程安全的容器,相信所有的Java程序员对此都不会有异议,因为它的add()、get()和size()等方法都是被synchronized修饰的,尽管这样效率不高,但保证了具备原子性、可见性和有序性。

5.5.3.2.3 相对线程安全

相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

在Java语言中,大部分声称线程安全的类都属于这种类型,例如Vector、Hash Table、Collections的synchronizedCollection()方法包装的集合等。

5.5.3.2.4 线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。我们平常说一个类不是线程安全的,通常就是指这种情况。Java类库API中大部分的类都是线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。

5.5.3.2.5 线程对立

线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。由于Java语言天生就支持多线程的特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。
一个线程对立的例子是Thread类的suspend()和resume()方法。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险——假如suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要产生死锁了。也正是这个原因,suspend()和resume()方法都已经被声明废弃了。常见的线程对立的操作还有System.setIn()、Sytem.setOut()和System.runFinalizersOnExit()
等。

5.5.3.2.6 线程安全的实现方法-互斥同步

互斥同步(Mutual Exclusion&Synchronization)是一种最常见也是最主要的并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。因此在“互斥同步”这四个字里面,互斥是因,同步是果;互斥是方法,同步是目的。

在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构(Block Structured)的同步语法。synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个re ference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。

根据《Java虚拟机规范》的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。
从功能上看,根据以上《Java虚拟机规范》对monitorenter和monitorexit的行为描述,我们可以得出两个关于s ynchronized的直接推论,这是使用它时需特别注意的:

  • 被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。
  • 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。

从执行成本的角度看,持有锁是一个重量级(Heavy-Weight)的操作。我们知道了在主流Java虚拟机实现中,Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。尤其是对于代码特别简单的同步块(譬如被synchronized修饰的getter()或setter()方法),状态转换消耗的时间甚至会比用户代码本身执行的时间还要长。因此才说,synchronized是Java语言中一个重量级的操作,有经验的程序员都只会在确实必要的情况下才使用这种操作。而虚拟机本身也会进行一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,以避免频繁地切入核心态之中。稍后我们会专门介绍Java虚拟机锁优化的措施。

从上面的介绍中我们可以看到syn chronized的局限性,除了synchronized关键字以外,自JDK5起(实现了JSR166),Java类库中新提供了java.util.concurrent包(下文称J.u.C包),其中的java.util.concurrent.locks.Lock接口便成了Java的另一种全新的互斥同步手段。基于Lock接口,用户能够以非块结构(Non-Block Structured)来实现互斥同步,从而摆脱了语言特性的束缚,改为在类库层面去实现同步,这也为日后扩展出不同调度算法、不同特征、不同性能、不同语义的各种锁提供了广阔
的空间。

重入锁(ReentrantLock)是Lock接口最常见的一种实现,顾名思义,它与synchronized一样是可重入曾的。
在基本用法上,ReentrantLock也与synchronized很相似,只是代码写法上稍有区别而已。不过,ReentrantLock与sy nchronized相比增加了一些高级功能,主要有以下三项:等待可中断、可实现公平锁及锁可以绑定多个条件。

  • 等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助;
  • 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。不过一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量。
  • 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁;而ReentrantLock则无须这样做,多次调用newCondition()方法即可。

根据上面的讨论,ReentrantLock在功能上是synchronized的超集,在性能上又至少不会弱于synchronized,那synchronized修饰符是否应该被直接抛弃,不再使用了呢?当然不是,基于以下理由,笔者仍然推荐在synchronized与ReentrantLock都可满足需要时优先使用synchronized:

  • synchronized是在Java语法层面的同步,足够清晰,也足够简单。每个Java程序员都熟悉synchronized,但J.U.C中的Lock接口则并非如此。因此在只需要基础的同步功能时,更推荐synchronized。
  • Lock应该确保在finally块中释放锁,否则一旦受同步保护的代码块中抛出异常,则有可能永远不会释放持有的锁。这一点必须由程序员自己来保证,而使用synchronized的话则可以由Java虚拟机来确保即使出现异常,锁也能被自动释放。
  • 尽管在JDK5时代ReentrantLock曾经在性能上领先过synchronized,但这已经是十多年之前的胜利了。从长远来看,Java虚拟机更容易针对syn chronized来进行优化,因为Java虚拟机可以在线程和对象的元数据中记录s ynchronized中锁的相关信息,而使用J.U.C中的Lock的话,Java虚拟机是很难得知具体哪些锁对象是由特定线程锁持有的。
5.5.3.2.7 线程安全的实现方法-非阻塞同步

互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步(Blocking Synchronization)。从解决问题的方式上看,互斥同步属于一种悲观的并发策略,其总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享的数据是否真的会出现竞争,它都会进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁),这将会导致用户态到核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。随着硬件指令集的发展,我们已经有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronization),使用这种措施的代码也常被称为无锁(Lock-Free)编程。

为什么笔者说使用乐观并发策略需要“硬件指令集的发展”?因为我们必须要求操作和冲突检测这两个步骤具备原子性。靠什么来保证原子性?如果这里再使用互斥同步来保证就完全失去意义了,所以我们只能靠硬件来实现这件事情,硬件保证某些从语义上看起来需要多次操作的行为可以只通过一条处理器指令就能完成,这类指令常用的有:

  • 测试并设置(Test-and-Set);
  • 获取并增加(Fetch-and-lncrement);
  • 交换(Swap);
  • 比较并交换(Compare-and-Swa p,下文称CAS);
  • 加载链接/条件储存(Load-Linked/Store-Conditional,下文称LL/S-C)。

其中,前面的三条是20世纪就已经存在于大多数指令集之中的处理器指令,后面的两条是现代处理器新增的,而且这两条指令的目的和功能也是类似的。在IA64、×86指令集中有用c mpxchg指令完成的CAS功能,在SPAR-C-TSO中也有用casa指令实现的,而在ARM和PowerPC架构下,则需要使用一对ldrex/strex指令来完成LL/SC的功能。因为Java里最终暴露出来的是CAS操作,所以我们以CAS指令为例进行讲解。

CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。

在JDK5之后,Java类库中才开始使用CAS操作,该操作由 sun.misc.unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。HotSpot虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器CAS指令,没有方法调用的过程,或者可以认为是无条件内联进去了。不过由于unsafe类在设计上就不是提供给用户程序调用的类(Unsafe::getunsafe()的代码中限制了只有启动类加载器(Bootstrap ClassLoader)加载的Class才能访问它),因此在JDK9之前只有Java类库可以使用CAS,譬如J.U.C包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了unsafe类的CAS操作来实现。而如果用户程序也有使用CAS操作的需求,那要么就采用反射手段突破unsafe的访问限制,要么就只能通过Java类库A pI来间接使用它。直到JDK9之后,Java类库才在VarHandle类里开放了面向用户程序使用的CAS操作。

5.5.3.2.8 线程安全的实现方法-无同步方案

要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系。同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的,笔者简单介绍其中的两类。

可重入代码(Reentrant Code):这种代码又称纯代码(Pure Code),是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。在特指多线程的上下文语境里(不涉及信号量等因素),我们可以认为可重入代码是线程安全代码的一个真子集,这意味着相对线程安全来说,可重入性是更为基础的特性,它可以保证代码线程安全,即所有可重入的代码都是线程安全的,但并非所有的线程安全的代码都是可重入的。

可重入代码有一些共同的特征,例如,不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等。我们可以通过一个比较简单的原则来判断代码是否具备可重入性:如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

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

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程限制在一个线程中消费完,其中最重要的一种应用实例就是经典Web交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多Web服务端应用都可以使用线程本地存储来解决线程安全问题。

Java语言中,如果一个变量要被多线程访问,可以使用volatile关键字将它声明为“易变的”;如果一个变量只要被某个线程独享,Java中就没有类似C++中__declspec(thread)这样的关键字去修饰,不过我们还是可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocaIHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量;

  • 锁优化
    高效并发是从JDK5升级到JDK6后一项重要的改进项,HotSpot虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(L ock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率。

  • 自旋锁与自适应自旋
    前面我们讨论互斥同步的时候,提到了互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。现在绝大多数的个人电脑和服务器都是多路(核)处理器系统,如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
    自旋锁在JDK1.4.2中就已经引入,只不过默认是关闭的,可以使用-X X:+useSpinning参数来开启,在JDK6中就已经改为默认开启了。自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次,用户也可以使用参数-XX:PreBlockSpin来自行更改。
    不过无论是默认值还是用户指定的自旋次数,对整个Java虚拟机中所有的锁来说都是相同的。在JDK6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了。

  • 锁消除
    锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持(第11章已经讲解过逃逸分析技术),如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。
    也许读者会有疑问,变量是否逃逸,对于虚拟机来说是需要使用复杂的过程间分析才能确定的,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下还要求同步呢?这个问题的答案是:有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中出现的频繁程度也许超过了大部分读者的想象。
    我们也知道,由于String是一个不可变的类,对字符串的连接操作总是通过生成新的String对象来进行的,因此Javac编译器会对String连接做自动优化。在JDK5之前,字符串加法会转化为StringBuffer对象的连续append()操作,在JDK5及以后的版本中,会转化为StringBuilder对象的连续append()操作。
    每个StringBuffer.append()方法中都有一个同步块,锁就是sb对象。虚拟机观察变量sb,经过逃逸分析后会发现它的动态作用域被限制在concatString()方法内部。也就是sb的所有引用都永远不会逃逸到concatString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地消除掉。在解释执行时这里仍然会加锁,但在经过服务端编译器的即时编译之后,这段代码就会忽略所有的同步措施而直接执行。

  • 锁粗化
    原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小一—只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。
    大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
    连续的append()方法就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,就是扩展到第一个append()操作之前直至最后一个append()操作之后,这样只需要加锁一次就可以了。

  • 轻量级锁
    轻量级锁是JDK6时加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。不过,需要强调一点,轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。要理解轻量级锁,以及后面会讲到的偏向锁的原理和运作过程,必须要对HotSpot虚拟机对象的内存布局(尤其是对象头部分)有所了解。HotSpot虚拟机的对象头(Object Header)分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GCAge)等。这部分数据的长度在32位和64位的Java虚拟机中分别会占用32个或64个比特,官方称它为“Mark Word”。这部分是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。这些对象内存布局的详细内容,我们已经学习过,在此不再赘述,只针对锁的角度做进一步细化。
    由于对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到Java虚拟机的空间使用效率,Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,对象未被锁定的状态下,Mark Word的32个比特空间里的25个比特将用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,还有1个比特固定为0(这表示未进入偏向模式)。对象除了未被锁定的正常状态外,还有轻量级锁定、重量级锁定、GC标记、可偏向等几种不同状态,这些状态下对象头的存储内容如表所示。

    我们简单回顾了对象的内存布局后,接下来就可以介绍轻量级锁的工作过程了:在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word),这时候线程堆栈与对象头的状态如图所示。

    然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock R ecord的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。
    这时候线程堆栈与对象头的状态如图所示。
    如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。

    上面描述的是轻量级锁的加锁过程,它的解锁过程也同样是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。
    轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

  • 轻量级锁
    偏向锁也是JDK6中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
    如果读者理解了前面轻量级锁中关于对象头Mark Word与线程之间的操作过程,那偏向锁的原理就会很容易理解。假设当前虚拟机启用了偏向锁(启用参数-XX:+useBiased Locking,这是自JDK6起HotSpot虚拟机的默认值),那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CA S操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。
    一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去执行。偏向锁、轻量级锁的状态转化及对象Mark Word的关系如图所示。

    细心的读者看到这里可能会发现一个问题:当对象进入偏向状态的时候,Mark Word大部分的空间(23个比特)都用于存储持有锁的线程ID了,这部分空间占用了原有存储对象哈希码的位置,那原来对象的哈希码怎么办呢?
    在Java语言里面一个对象如果计算过哈希码,就应该一直保持该值不变(强烈推荐但不强制,因为用户可以重载hashCode()方法按自己的意愿返回哈希码),否则很多依赖对象哈希码的API都可能存在出错风险。而作为绝大多数对象哈希码来源的Object::hashCode()方法,返回的是对象的一致性哈希码(Identity Hash Code),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为“01”)下的Mark Word,其中自然可以存储原来的哈希码。
    偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡(Trade Off)性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数-XX:-UseBiasedLocking来禁止偏向锁优化反而可以提升性能。

6. 附录

6.1 获取openJdk源码

首先确定要使用的JDK版本,OpenJDK6和OpenJDK7都是开源的,源码都可以在它们的主页(http://openjdk.jgva.net/)上找到。OpenJDK6的源码其实是从OpenJDK7的某个基线中引出的,然后剥离JDK1.7相关的代码,从而得到一份可以通过TCK6的JDK1.6实现,因此直接编译OpenJDK7会更加“
原汁原味”一些,其实这两个版本的编译过程差异并不大。
获取源码有两种方式:

  • 一是通过Mercurial代码版本管理工具从Repository中直接取得源码(Repository地址:http://hg.openjdk.jgva.net/jdk7/jdkZ),这是最直接的方式,从版本管理中看变更轨迹比看什么Release Note都来得实在,不过太麻烦了一些,尤其是Mercurial远不如SVN、ClearCase或CVS之类的版本控制工具那样普及;
  • 另外一种就是直接下载官方打包好的源码包了,可以从Source Releases页面(地址:http://download.jgva.net/openjdk/jgk7/)取得打包好的源码,一般来说大概一个月左右会更新一次,虽然不够及时,但的确方便了许多。笔者下载的是OpenJDK 7 Early Access Source Build b121版,2010年12月9日发布的,大概81.7MB,解压出来约308MB。

Java-Jvm-07-深入浅出Jvm浅记相关推荐

  1. 【深入Java虚拟机JVM 07】JVM如何判断对象已死

    说明:文章所有内容均摘自<深入理解Java虚拟机:JVM高级特性与最佳实践(第二版)> 在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些 ...

  2. JAVA基础+集合+多线程+JVM

    1. Java 基础 1.1. 面向对象和面向过程的区别 面向过程性能比面向对象高. 因为类调用时需要实例化,开销比较大,比较消耗资源,所以当性能是最重要的考量因素的时候 等一般采用面向过程开发.但是 ...

  3. jvm系列(八):jvm知识点总览-高级Java工程师面试必备

    在江湖中要练就绝世武功必须内外兼备,精妙的招式和深厚的内功,武功的基础是内功.对于武功低(就像江南七怪)的人,招式更重要,因为他们不能靠内功直接去伤人,只能靠招式,利刃上优势来取胜了,但是练到高手之后 ...

  4. 直接内存与元空间_深入浅出 JVM 内存管理

    Java岗位面试,JVM是对程序员基本功考察,通常会问你对JVM了解吗? 可以分几部分回答这个问题,首先JVM内存划分 | JVM垃圾回收的含义 | 有哪些GC算法 以及年轻代和老年代各自特点等等.1 ...

  5. 深入浅出JVM调优,看完你就懂

    深入浅出JVM调优 基本概念: JVM把内存区分为堆区(heap).栈区(stack)和方法区(method).由于本文主要讲解JVM调优,因此我们可以简单的理解为,JVM中的堆区中存放的是实际的对象 ...

  6. java stw_快速理解Java垃圾回收和jvm中的stw

    Java中Stop-The-World机制简称STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外).Java中一种全局暂停现象,全局停顿,所有Java代码停 ...

  7. Java工程师必备:JVM原理、内存模型、调优、垃圾回收机制、面试题

    在如今这个时间和知识都是碎片化的时代,C站根据C1-C4认证的成长路径,进行知识细化整理,形成系统化的知识图谱. 通过调研一线互联网大厂的招聘JD,小编对标C站能力认证要求,为大家整理了系列技术干货合 ...

  8. 好程序员Java教程分享之jvm篇

    好程序员java教程分享之jvm篇,在前面的文章中,介绍了JVM内存模型分为:堆区.虚拟机栈.方法区.本地方法区和程序计数器,其中堆区是JVM中最大的一块内存区域,在Java中的所有对象实例都保存在此 ...

  9. Java 内存模型和 JVM 内存结构真不是一回事

    这两个概念估计有不少人会混淆,它们都可以说是 JVM 规范的一部分,但真不是一回事!它们描述和解决的是不同问题,简单来说, Java 内存模型,描述的是多线程允许的行为 JVM 内存结构,描述的是线程 ...

  10. java 利用ManagementFactory获取jvm,os的一些信息--转

    原文地址:http://blog.csdn.net/dream_broken/article/details/49759043 想了解下某个Java项目的运行时jvm的情况,可以使用一些监控工具,比如 ...

最新文章

  1. 黑马程序员5 多线程
  2. 怎么把圆角变直角_明式圆角柜的还原(下)——信民木工DIY达人大赛作品
  3. iOS tabBarController 初始化就执行了 viewDidLoad
  4. 20应用统计考研复试要点(part2)--统计学
  5. java 为文件及文件夹添加权限
  6. 针对新手的Java EE7和Maven项目–第5部分–使用Arquillian / Wildfly 8进行单元测试
  7. 【渝粤题库】国家开放大学2021春2072人文社会科学基础(A)答案
  8. linux系统用户迁移
  9. jdom.jar 下载
  10. 匈牙利算法python实现
  11. HTKbook翻译之第十二章网络、词典及语言模型
  12. 微信小程序版本管理使用gitee
  13. PLC控制电动机的顺序启动逆序停止
  14. 虚拟机安装linux黑屏一个光标,虚拟机启动后黑屏只剩一个光标
  15. 短视频行业内情分析-今抖云创
  16. 为什么现在台式计算机无光驱,为何现在大部分电脑(笔记本和台式机)都取消了光驱?...
  17. 【信号与系统】(一 )信号与系统概述——信号的基本概念与分类
  18. 第十二届蓝桥杯电子类嵌入式设计与开发省、国赛经验分享
  19. 渗透测试安全检测漏洞
  20. ATPG之STIL格式说明

热门文章

  1. CAPWAP基础原理
  2. 链表反转的四种方法(栈、头插法、三指针法、递归法)
  3. 多目标规划(数学建模)
  4. Linux 操作系统 Centos7.6安装教程详细Linux系统安装磁盘分区IP配置SSH互信NTP时间服务器关闭图形界面关闭防火墙配置hosts文件
  5. IXP2400忽悠了谁
  6. 创新实训——飞讯(二)
  7. 营业执照的五大误区,企业千万不要踩雷
  8. 一个屌丝程序员的青春(三零八)
  9. 【高等数学笔记】f(x)=(x-1)(x-2)²(x-3)³(x-4)⁴的拐点是?
  10. 48MW双馈风电机组并网仿真模型 机端由24台2MW双馈风机构成48MW风电场,出口电压690v,经升压变压器及线路阻抗连接至120kv交流电网