当前位置: 首页 > article >正文

Java虚拟机——JVM高级特性与最佳实践

深入理解 Java 虚拟机
——JVM 高级特性与最佳实践
前言
2 版与第 1 版的区别
本书面向的读者
如何阅读本书
语言约定
内容特色
参考资料
勘误和支持
致谢
第一部分 走近 Java
1 章 走近 Java
1.1 概述
1.2 Java 技术体系
1.3 Java 发展史
1.4 Java 虚拟机发展史
1.4.1 Sun Classic/Exact VM
1.4.2 Sun HotSpot VM
1.4.3 Sun Mobile-Embedded VM/Meta-Circular VM
1.4.4 BEA JRockit/IBM J9 VM
1.4.5 Azul VM/BEA Liquid VM
1.4.6 Apache Harmony/Google Android Dalvik VM
1.4.7 Microsoft JVM 及其他
1.5 展望 Java 技术的未来
1.5.1 模块化
1.5.2 混合语言
1.5.3 多核并行
1.5.4 进一步丰富语法
1.5.5 64 位虚拟机
1.6 实战:自己编译 JDK
1.6.1 获取 JDK 源码
1.6.2 系统需求
1.6.3 构建编译环境
1.6.4 进行编译
1.6.5 IDE 工具中进行源码调试
1.7 本章小结
第二部分 自动内存管理机制
2 Java 内存区域与内存溢出异常
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.4 实战: OutOfMemoryError 异常
2.4.1 Java 堆溢出
2.4.2 虚拟机栈和本地方法栈溢出
2.4.3 方法区和运行时常量池溢出
2.4.4 本机直接内存溢出
2.5 本章小结
3 章 垃圾收集器与内存分配策略
3.1 概述
3.2 对象已死吗
3.2.1 引用计数算法
3.2.2 可达性分析算法
3.2.3 再谈引用
3.2.4 生存还是死亡
3.2.5 回收方法区
3.3 垃圾收集算法
3.3.1 标记 - 清除算法
3.3.2 复制算法
3.3.3 标记 - 整理算法
3.3.4 分代收集算法
3.4 HotSpot 的算法实现
3.4.1 枚举根节点
3.4.2 安全点
3.4.3 安全区域
3.5 垃圾收集器
3.5.1 Serial 收集器
3.5.2 ParNew 收集器
3.5.3 Parallel Scavenge 收集器
3.5.4 Serial Old 收集器
3.5.5 Parallel Old 收集器
3.5.6 CMS 收集器
3.5.7 G1 收集器
3.5.8 理解 GC 日志
3.5.9 垃圾收集器参数总结
3.6 内存分配与回收策略
3.6.1 对象优先在 Eden 分配
3.6.2 大对象直接进入老年代
3.6.3 长期存活的对象将进入老年代
3.6.4 动态对象年龄判定 3.6.5 空间分配担保
3.7 本章小结
4 章 虚拟机性能监控与故障处理工具
4.1 概述
4.2 JDK 的命令行工具
4.2.1 jps :虚拟机进程状况工具
4.2.2 jstat :虚拟机统计信息监视工具
4.2.3 jinfo Java 配置信息工具
4.2.4 jmap Java 内存映像工具
4.2.5 jhat :虚拟机堆转储快照分析工具
4.2.6 jstack Java 堆栈跟踪工具
4.2.7 HSDIS JIT 生成代码反汇编
4.3 JDK 的可视化工具
4.3.1 JConsole Java 监视与管理控制台
4.3.2 VisualVM :多合一故障处理工具
4.4 本章小结
5 章 调优案例分析与实战
5.1 概述
5.2 案例分析
5.2.1 高性能硬件上的程序部署策略
5.2.2 集群间同步导致的内存溢出
5.2.3 堆外内存导致的溢出错误
5.2.4 外部命令导致系统缓慢
5.2.5 服务器 JVM 进程崩溃
5.2.6 不恰当数据结构导致内存占用过大
5.2.7 Windows 虚拟内存导致的长时间停顿
5.3 实战: Eclipse 运行速度调优
5.3.1 调优前的程序运行状态
5.3.2 升级 JDK 1.6 的性能变化及兼容问题
5.3.3 编译时间和类加载时间的优化
5.3.4 调整内存设置控制垃圾收集频率
5.3.5 选择收集器降低延迟
5.4 本章小结
第三部分 虚拟机执行子系统
6 章 类文件结构
6.1 概述
6.2 无关性的基石
6.3 Class 类文件的结构
6.3.1 魔数与 Class 文件的版本
6.3.2 常量池
6.3.3 访问标志
6.3.4 类索引、父类索引与接口索引集合
6.3.5 字段表集合
6.3.6 方法表集合
6.3.7 属性表集合 6.4 字节码指令简介
6.4.1 字节码与数据类型
6.4.2 加载和存储指令
6.4.3 运算指令
6.4.4 类型转换指令
6.4.5 对象创建与访问指令
6.4.6 操作数栈管理指令
6.4.7 控制转移指令
6.4.8 方法调用和返回指令
6.4.9 异常处理指令
6.4.10 同步指令
6.5 公有设计和私有实现
6.6 Class 文件结构的发展
6.7 本章小结
7 章 虚拟机类加载机制
7.1 概述
7.2 类加载的时机
7.3 类加载的过程
7.3.1 加载
7.3.2 验证
7.3.3 准备
7.3.4 解析
7.3.5 初始化
7.4 类加载器
7.4.1 类与类加载器
7.4.2 双亲委派模型
7.4.3 破坏双亲委派模型
7.5 本章小结
8 章 虚拟机字节码执行引擎
8.1 概述
8.2 运行时栈帧结构
8.2.1 局部变量表
8.2.2 操作数栈
8.2.3 动态连接
8.2.4 方法返回地址
8.2.5 附加信息
8.3 方法调用
8.3.1 解析
8.3.2 分派
8.3.3 动态类型语言支持
8.4 基于栈的字节码解释执行引擎
8.4.1 解释执行
8.4.2 基于栈的指令集与基于寄存器的指令集
8.4.3 基于栈的解释器执行过程
8.5 本章小结 9 章 类加载及执行子系统的案例与实战
9.1 概述
9.2 案例分析
9.2.1 Tomcat :正统的类加载器架构
9.2.2 OSGi :灵活的类加载器架构
9.2.3 字节码生成技术与动态代理的实现
9.2.4 Retrotranslator :跨越 JDK 版本
9.3 实战:自己动手实现远程执行功能
9.3.1 目标
9.3.2 思路
9.3.3 实现
9.3.4 验证
9.4 本章小结
第四部分 程序编译与代码优化
10 章 早期(编译期)优化
10.1 概述
10.2 Javac 编译器
10.2.1 Javac 的源码与调试
10.2.2 解析与填充符号表
10.2.3 注解处理器
10.2.4 语义分析与字节码生成
10.3 Java 语法糖的味道
10.3.1 泛型与类型擦除
10.3.2 自动装箱、拆箱与遍历循环
10.3.3 条件编译
10.4 实战:插入式注解处理器
10.4.1 实战目标
10.4.2 代码实现
10.4.3 运行与测试
10.4.4 其他应用案例
10.5 本章小结
11 章 晚期(运行期)优化
11.1 概述
11.2 HotSpot 虚拟机内的即时编译器
11.2.1 解释器与编译器
11.2.2 编译对象与触发条件
11.2.3 编译过程
11.2.4 查看及分析即时编译结果
11.3 编译优化技术
11.3.1 优化技术概览
11.3.2 公共子表达式消除
11.3.3 数组边界检查消除
11.3.4 方法内联
11.3.5 逃逸分析
11.4 Java C/C++ 的编译器对比 11.5 本章小结
第五部分 高效并发
12 Java 内存模型与线程
12.1 概述
12.2 硬件的效率与一致性
12.3 Java 内存模型
12.3.1 主内存与工作内存
12.3.2 内存间交互操作
12.3.3 对于 volatile 型变量的特殊规则
12.3.4 对于 long double 型变量的特殊规则
12.3.5 原子性、可见性与有序性
12.3.6 先行发生原则
12.4 Java 与线程
12.4.1 线程的实现
12.4.2 Java 线程调度
12.4.3 状态转换
12.5 本章小结
13 章 线程安全与锁优化
13.1 概述
13.2 线程安全
13.2.1 Java 语言中的线程安全
13.2.2 线程安全的实现方法
13.3 锁优化
13.3.1 自旋锁与自适应自旋
13.3.2 锁消除
13.3.3 锁粗化
13.3.4 轻量级锁
13.3.5 偏向锁
13.4 本章小结
附录
附录 A 编译 Windows 版的 OpenJDK
A.1 获取 JDK 源码
A.2 系统需求
A.3 构建编译环境
A.4 准备依赖项
A.5 进行编译
附录 B 虚拟机字节码指令表
附录 C HotSpot 虚拟机主要参数表
C.1 内存管理参数
C.2 即时编译参数
C.3 类型加载参数
C.4 多线程相关参数
C.5 性能参数
C.6 调试参数
附录 D 对象查询语言( OQL )简介 D.1 SELECT 子句
D.2 FROM 子句
D.3 WHERE 子句
D.4 属性访问器
D.5 OQL 语言的 BNF 范式
附录 E JDK 历史版本轨迹 前言
Java 是目前用户最多、使用范围最广的软件开发技术之一。 Java 的技术体系主要由支撑
Java 程序运行的虚拟机、提供各开发领域接口支持的 Java
API Java 编程语言及许多第三方
Java 框架(如 Spring Struts 等)构成。在国内,有关 Java API Java 语言语法及第三方框架的
技术资料和书籍非常丰富,相比之下,有关 Java 虚拟机的资料却显得异常贫乏。
这种状况在很大程度上是由 Java 开发技术本身的一个重要优点导致的:在虚拟机层面隐
藏了底层技术的复杂性以及机器与操作系统的差异性。运行程序的物理机器的情况千差万
别,而 Java 虚拟机则在千差万别的物理机上建立了统一的运行平台,实现了在任意一台虚拟
机上编译的程序都能在任何一台虚拟机上正常运行。这一极大优势使得 Java 应用的开发比传
C/C++ 应用的开发更高效和快捷,程序员可以把主要精力集中在具体业务逻辑上,而不是
物理硬件的兼容性上。在一般情况下,一个程序员只要了解了必要的 Java API Java 语法,
以及学习适当的第三方开发框架,就已经基本能满足日常开发的需要了,虚拟机会在用户不
知不觉中完成对硬件平台的兼容及对内存等资源的管理工作。因此,了解虚拟机的运作并不
是一般开发人员必须掌握的知识。
然而,凡事都具备两面性。随着 Java 技术的不断发展,它被应用于越来越多的领域之
中。其中一些领域,如电力、金融、通信等,对程序的性能、稳定性和可扩展性方面都有极
高的要求。程序很可能在 10 个人同时使用时完全正常,但是在 10000 个人同时使用时就会缓
慢、死锁,甚至崩溃。毫无疑问,要满足 10000 个人同时使用需要更高性能的物理硬件,但
是在绝大多数情况下,提升硬件效能无法等比例地提升程序的运作性能和并发能力,甚至可
能对程序运作状况完全没有任何改善。这里面有 Java 虚拟机的原因:为了达到给所有硬件提
供一致的虚拟平台的目的,牺牲了一些与硬件相关的性能特性。更重要的是人为原因:如果
开发人员不了解虚拟机一些技术特性的运行原理,就无法写出最适合虚拟机运行和自优化的
代码。
其实,目前商用的高性能 Java 虚拟机都提供了相当多的优化特性和调节手段,用于满足
应用程序在实际生产环境中对性能和稳定性的要求。如果只是为了入门学习,让程序在自己
的机器上正常运行,那么这些特性可以说是可有可无的;如果用于生产开发,尤其是企业级
生产开发,就迫切需要开发人员中至少有一部分人对虚拟机的特性及调节方法具有很清晰的
认识,所以在 Java 开发体系中,对架构师、系统调优师、高级程序员等角色的需求一直都非
常大。学习虚拟机中各种自动运作特性的原理也成为了 Java 程序员成长道路上必然会接触到
的一课。本书可以使读者以一种相对轻松的方式学习虚拟机的运作原理,对 Java 程序员的成
长也有较大的帮助。 2 版与第 1 版的区别
JDK 1.7 2011 7 28 日正式发布,相对于 2006 年发布的 JDK 1.6 ,新版的 JDK 有了许多
新的特性和改进。本书的第 2 版也相应地进行了修改和升级,把讲解的技术平台从 JDK 1.6
升至 JDK
1.7 。例如,增加了对 JDK
1.7 中最新的 G1 收集器,以及 JDK
1.7 JSR-292
InvokeDynamic (对非 Java 语言的调用支持)的分析讲解等内容。
在第 1 版出版后,笔者收到了许多热心读者的反馈意见,部分读者提出 OpenJDK 开源已
久,第 1 版却很少有直接分析 OpenJDK 源码的内容,有点 视宝山而不见 的感觉。因此,在
本书第 2 版中,笔者特别加强了对这部分内容的讲解,其中在第 1 章中就介绍了如何分析、调
OpenJDK 源码等。在本书后续章节中,不少关于功能点的讲解都直接使用 OpenJDK 中的
HotSpot 源码或者 JIT 编译器生成的本地代码作为论据。
如何把 Java 虚拟机原理中许多理论性很强的知识、特性应用于实践开发,是本书贯穿始
终的主旨。由于笔者希望在本书第 2 版中进一步加强知识的实践性,因此增加了许多对处理
JVM 常见问题技能的讲解,包括如何分析 GC 日志、如何分析 JIT 编译器代码优化过程和生成
代码等。并且,在第 1 版的基础上,第 2 版中进一步增加了若干处理 JVM 问题的实践案例供读
者参考。
另外,本书第 2 版还修正了第 1 版中多处错误的、有歧义的和不完整的描述。有关勘误信
息,可以参考第 1 版的勘误页面( http://icyfenix.iteye.com/blog/1119214 )。 本书面向的读者
1 )使用 Java 技术体系的中、高级开发人员
Java 虚拟机作为中、高级开发人员必须修炼的知识,有着较高的学习门槛,本书可作为
学习虚拟机的优秀教材。
2 )系统调优师
系统调优师是近几年才兴起的职业,本书中的大量案例、代码和调优实战将会对系统调
优师的日常工作有直接的帮助。
3 )系统架构师
保障系统的性能、并发和伸缩等能力是系统架构师的主要职责之一,而这部分与虚拟机
的运作密不可分,本书可以作为他们制定应用系统底层框架的参考资料。 如何阅读本书
本书一共分为五个部分:走近 Java 、自动内存管理机制、虚拟机执行子系统、程序编译
与代码优化、高效并发。各部分基本上是互相独立的,没有必然的前后依赖关系,读者可以
从任何一个感兴趣的专题开始阅读,但是每个部分中的各个章节间有先后顺序。
本书并没有假设读者在 Java 领域具备很专业的技术水平,因此在保证逻辑准确的前提
下,尽量用通俗的语言和案例讲述虚拟机中与开发的关系最为密切的内容。当然,学习虚拟
机技术本身就需要读者有一定的基础,且本书的读者定位是中、高级程序员,因此本书假设
读者自己了解一些常用的开发框架、 Java API Java 语法等基础知识。
笔者希望读者在阅读本书的同时,把本书中的实践内容亲自验证一遍,其中用到的代码
清单可以从华章网站( http://www.hzbook.com )下载。
语言约定
本书在语言和技术上有如下约定:
本书中提到 HotSpot JRockit 虚拟机、 WebLogic 服务器等产品的所有者时,仍然使用 Sun
BEA 公司的名称,实际上, BEA Sun 分别于 2008 年和 2009 年被 Oracle 公司收购,现在已经
不存在这两个商标了,但毫无疑问的是,它们都是在 Java 领域中做出过卓越贡献的、值得程
序员纪念的公司。
JDK 1.5 版本开始,在官方的正式文档与宣传资料中已经不再使用类似 “JDK 1.5” 的名
称,只有程序员内部使用的开发版本号( Developer Version ,例如 java-version 的输出)才继
续沿用 1.5 1.6 1.7 的版本号,而公开版本号( Product Version )则改为 JDK 5 JDK 6 JDK
7 的命名方式,为了行文一致,本书所有场合统一采用开发版本号的命名方式。
由于版面关系,本书中的许多示例代码都没有遵循最优的代码编写风格,如使用的流没
有关闭流等,请读者在阅读时注意这一点。
如果没有特殊说明,本书中所有讨论都是以 Sun JDK 1.7 为技术平台的。不过如果有某个
特性在各个版本间的变化较大,一般都会说明它在各个版本间的差异。 内容特色
第一部分 走近 Java
本书的第一部分为后文的讲解建立了良好的基础。尽管了解 Java 技术的来龙去脉,以及
编译自己的 OpenJDK 对于读者理解 Java 虚拟机并不是必需的,但是这些准备过程可以为走近
Java 技术和 Java 虚拟机提供很好的引导。第一部分只有第 1 章:
1 章 介绍了 Java 技术体系的过去、现在和未来的一些发展趋势,并介绍了如何独立
地编译一个 OpenJDK 7
第二部分 自动内存管理机制
因为程序员把内存控制的权力交给了 Java 虚拟机,所以可以在编码的时候享受自动内存
管理的诸多优势,不过也正是这个原因,一旦出现内存泄漏和溢出方面的问题,如果不了解
虚拟机是怎样使用内存的,那么排查错误将会成为一项异常艰难的工作。第二部分包括第 2
5 章:
2 章 讲解了虚拟机中内存是如何划分的,以及哪部分区域、什么样的代码和操作可
能导致内存溢出异常,并讲解了各个区域出现内存溢出异常的常见原因。
3 章 分析了垃圾收集的算法和 JDK 1.7 中提供的几款垃圾收集器的特点及运作原理。
通过代码实例验证了 Java 虚拟机中自动内存分配及回收的主要规则。
4 章 介绍了随 JDK 发布的 6 个命令行工具与两个可视化的故障处理工具的使用方法。
5 章 与读者分享了几个比较有代表性的实际案例,还准备了一个所有开发人员都
亲身实战 的练习,读者可通过实践来获得故障处理和调优的经验。
第三部分 虚拟机执行子系统
执行子系统是虚拟机中必不可少的组成部分,了解了虚拟机如何执行程序,才能写出更
优秀的代码。第三部分包括第 6 9 章:
6 章 讲解了 Class 文件结构中的各个组成部分,以及每个部分的定义、数据结构和使
用方法,以实战的方式演示了 Class 文件的数据是如何存储和访问的。
7 章 介绍了类加载过程的 加载 验证 准备 解析 初始化 ”5 个阶段中虚
拟机分别执行了哪些动作,还介绍了类加载器的工作原理及其对虚拟机的意义。
8 章 分析了虚拟机在执行代码时如何找到正确的方法,如何执行方法内的字节码,
以及执行代码时涉及的内存结构。
9 章 通过 4 个类加载及执行子系统的案例,分享了使用类加载器和处理字节码的一些
值得欣赏和借鉴的思路,并通过一个实战练习来加深对前面理论知识的理解。
第四部分 程序编译与代码优化 Java 程序从源码编译成字节码和从字节码编译成本地机器码的这两个过程,合并起来其
实就等同于一个传统编译器所执行的编译过程。第四部分包括第 10 11 章:
10 章 分析了 Java 语言中泛型、主动装箱和拆箱、条件编译等多种语法糖的前因后
果,并通过实战演示了如何使用插入式注解处理器来实现一个检查程序命名规范的编译器插
件。
11 章 讲解了虚拟机的热点探测方法、 HotSpot 的即时编译器、编译触发条件,以及
如何从虚拟机外部观察和分析 JIT 编译的数据和结果,此外,还讲解了几种常见的编译优化
技术。
第五部分 高效并发
Java 语言和虚拟机提供了原生的、完善的多线程支持,这使得它天生就适合开发多线程
并发的应用程序。不过我们不能期望系统来完成所有并发相关的处理,了解并发的内幕也是
成为一个高级程序员不可缺少的课程。第五部分包括第 12 13 章:
12 章 讲解了虚拟机 Java 内存模型的结构及操作,以及原子性、可见性和有序性在
Java 内存模型中的体现,介绍了先行发生原则的规则及使用,还了解了线程在 Java 语言中是
如何实现的。
13 章 介绍了线程安全涉及的概念和分类、同步实现的方式及虚拟机的底层运作原
理,并且介绍了虚拟机实现高效并发所采取的一系列锁优化措施。 参考资料
本书名为 深入理解 Java 虚拟机 ,但要想深入理解虚拟机,仅凭一本书肯定是远远不够
的,读者可以通过以下信息找到更多关于 Java 虚拟机方面的资料。我在写作此书的时候,也
从下面这些参考资料中获得了很大的帮助。
1 )书籍
The Java Virtual Machine Specification,Java SE 7 Edition
[1]
要学习虚拟机,无论如何都必须掌握 “Java 虚拟机规范 。这本书的概念和细节描述与 Sun
的早期虚拟机( Sun Classic VM )高度吻合,不过,随着技术的发展,高性能虚拟机真正的
细节实现方式已经渐渐与虚拟机规范所描述的差距越来越大,如果只能选择一本参考书来了
解虚拟机,那我推荐这本书。此书的 Java SE 7 版在 2011 7 月出版发行,这是自 1999 年发布
的《 Java 虚拟机规范(第 2 版)》以来的第一次版本更新。笔者对 Java SE 7 版的全文进行了
翻译,并与原书一样在网上免费发布了全文 PDF
[2]
The Java Language Specification,Java SE 7 Edition
[3]
虽然虚拟机并不是 Java 语言专有的,但是了解 Java 语言的各种细节规定对理解虚拟机的
行为也是很有帮助的,它与上一本《 Java 虚拟机规范》都是 Sun 官方出品的书籍,而且这本
书还是由 Java 之父 James Gosling 亲自执笔撰写的。这本书也与《 Java 虚拟机规范》一样,可
以在官方网站完全免费下载到全文 PDF ,但暂时没有中文译本,《 Java 语言规范(第 3 版)》
2005 7 月由机械工业出版社引进出版。
Oracle JRockit The Definitive Guide
Oracle
JRockit 权威指南》, 2010 7 月出版,国内也没有(可能是尚未)引进这本
书,它是由 JRockit 的两位资深开发人员(其中一位还是 JRockit
Mission
Control 团队的
TeamLeader )撰写的 JRockit 虚拟机高级使用指南。虽然 JRockit 的用户量可能不如 HotSpot 多,
但也是目前最流行的三大商业虚拟机之一,并且不同虚拟机中的很多实现思路都是可以对比
参照的。这本书是了解现代高性能虚拟机很好的参考资料。
Inside the Java 2 Virtual Machine,Second Edition
《深入 Java 虚拟机(第 2 版)》, 2000 1 月出版, 2003 年由机械工业出版社出版其中文
译本。在相当长的时间里,这本书是唯一的一本关于 Java 虚拟机的中文图书。
Java Performance
Java
Performance 》是 “The
Java” 系列(许多人都读过该系列中最出名的《 Effective
Java 》)图书中最新的一本, 2011 10 月出版,暂时没有中文版。这本书并非全部都围绕
Java 虚拟机(只有第 3 4 7 章直接与 Java 虚拟机相关),而是从操作系统到基于 Java 的上层
程序性能度量和调优的全面介绍,其中涉及 Java 虚拟机的内容具备一定的深度和可实践性。
2 )网站资源 高级语言虚拟机圈子: http://hllvm.group.iteye.com/
里面有一些国内关于虚拟机的讨论,并不只限于 JVM ,而是涉及对所有的高级语言虚拟
机( High-Level Language Virtual Machine )的讨论,但该网站建立在 ITEye 上,自然还是以讨
Java 虚拟机为主。圈主 RednaxelaFX (莫枢)的博客( http://rednaxelafx.iteye.com/ )是另外
一个非常有价值的虚拟机及编译原理等资料的分享园地。
HotSpot Internals https://wikis.oracle.com/display/HotSpotInternals/Home
一个关于 OpenJDK Wiki 网站,许多文章都由 JDK 的开发团队编写,更新较慢,但是仍
然有很高的参考价值。
The HotSpot Group http://openjdk.java.net/groups/hotspot/
HotSpot 组群,包含虚拟机开发、编译器、垃圾收集和运行时 4 个邮件组,其中有关于
HotSpot 虚拟机的最新讨论。
[1] 官方地址: http://docs.oracle.com/javase/specs/jvms/se7/jvms7.pdf
[2] 官方地址: http://docs.oracle.com/javase/specs/jls/se7/jls7.pdf
[3] 中文译本地址: http://icyfenix.iteye.com/blog/1256329 勘误和支持
在本书交稿的时候,我并不像想象中的那样兴奋或放松,写作之时那种 战战兢兢、如
履薄冰 的感觉依然萦绕在心头。在每一章、每一节落笔之时,我都在考虑如何才能把各个
知识点更有条理地讲述出来,同时也在担心会不会由于自己理解有偏差而误导了读者。由于
写作水平和写作时间所限,书中难免存在不妥之处,所以特地开通了一个读者邮箱
understandingjvm@gmail.com )与大家交流,大家如有任何意见或建议欢迎与我联系。相信
写书与写程序一样,作品一定都是不完美的,因为不完美,我们才有不断追求完美的动力。
本书第 2 版的勘误,将会在作者的博客( http://icyfenix.iteye.com/ )中发布。欢迎读者在
博客上留言。 致谢
首先要感谢我的家人,在本书写作期间全靠他们对我的悉心照顾,才让我能够全身心地
投入到写作之中,而无后顾之忧。
同时要感谢我的工作单位远光软件,公司为我提供了宝贵的工作、学习和实践的环境,
书中的许多知识点都来自于工作中的实践;也感谢与我一起工作的同事们,非常荣幸能与你
们一起在这个富有激情的团队中共同奋斗。
还要感谢 Oracle 公司虚拟机团队的莫枢,在百忙之中抽空审阅了本书,提出了许多宝贵
的建议和意见。
最后,感谢机械工业出版社华章公司的编辑,本书能够顺利出版离不开他们的敬业精神
和一丝不苟的工作态度。
周志明 第一部分 走近 Java
1 章 走近 Java 1 章 走近 Java
世界上并没有完美的程序,但我们并不因此而沮丧,因为写程序本来就是一个不断追求
完美的过程。
1.1 概述
Java 不仅仅是一门编程语言,还是一个由一系列计算机软件和规范形成的技术体系,这
个技术体系提供了完整的用于软件开发和跨平台部署的支持环境,并广泛应用于嵌入式系
统、移动终端、企业服务器、大型机等各种场合,如图 1-1 所示。时至今日, Java 技术体系已
经吸引了 900 多万软件开发者,这是全球最大的软件开发团队。使用 Java 的设备多达几十亿
台,其中包括 11 亿多台个人计算机、 30 亿部移动电话及其他手持设备、数量众多的智能卡,
以及大量机顶盒、导航系统和其他设备
[1]
1-1 Java 技术的广泛应用
Java 能获得如此广泛的认可,除了它拥有一门结构严谨、面向对象的编程语言之外,还
有许多不可忽视的优点:它摆脱了硬件平台的束缚,实现了 一次编写,到处运行 的理想;
它提供了一个相对安全的内存管理和访问机制,避免了绝大部分的内存泄露和指针越界问
题;它实现了热点代码检测和运行时编译及优化,这使得 Java 应用能随着运行时间的增加而
获得更高的性能;它有一套完善的应用程序接口,还有无数来自商业机构和开源社区的第三
方类库来帮助它实现各种各样的功能 ……Java 所带来的这些好处使程序的开发效率得到了很
大的提升。作为一名 Java 程序员,在编写程序时除了尽情发挥 Java 的各种优势外,还应该去
了解和思考一下 Java 技术体系中这些技术特性是如何实现的。认识这些技术运作的本质,是
自己思考 程序这样写好不好 的基础和前提。当我们在使用一种技术时,如果不再依赖书本 和他人就能得到这些问题的答案,那才算上升到了 不惑 的境界。
本书将与读者一起分析 Java 技术中最重要的那些特性的实现原理。在本章中,我们将重
点介绍 Java 技术体系内容以及 Java 的历史、现在和未来的发展趋势。
[1] 这些数据是 Java 的广告词,它们来源于: http://www.java.com/zh_CN/about/ 1.2 Java 技术体系
从广义上讲, Clojure JRuby Groovy 等运行于 Java 虚拟机上的语言及其相关的程序都
属于 Java 技术体系中的一员。如果仅从传统意义上来看, Sun 官方所定义的 Java 技术体系包括
以下几个组成部分:
Java 程序设计语言
各种硬件平台上的 Java 虚拟机
Class 文件格式
Java API 类库
来自商业机构和开源社区的第三方 Java 类库
我们可以把 Java 程序设计语言、 Java 虚拟机、 Java
API 类库这三部分统称为 JDK Java
Development Kit ), JDK 是用于支持 Java 程序开发的最小环境,在后面的内容中,为了讲解
方便,有一些地方会以 JDK 来代替整个 Java 技术体系。另外,可以把 Java
API 类库中的 Java
SE API 子集
[1]
Java 虚拟机这两部分统称为 JRE Java Runtime Environment ), JRE 是支持 Java
程序运行的标准环境。图 1-2 展示了 Java 技术体系所包含的内容,以及 JDK JRE 所涵盖的范
围。 1-2 Java 技术体系所包含的内容
[2]
以上是根据各个组成部分的功能来进行划分的,如果按照技术所服务的领域来划分,或
者说按照 Java 技术关注的重点业务领域来划分, Java 技术体系可以分为 4 个平台,分别为:
Java Card :支持一些 Java 小程序( Applets )运行在小内存设备(如智能卡)上的平台。
Java ME Micro Edition ):支持 Java 程序运行在移动终端(手机、 PDA )上的平台,对
Java API 有所精简,并加入了针对移动终端的支持,这个版本以前称为 J2ME
Java SE Standard Edition ):支持面向桌面级应用(如 Windows 下的应用程序)的 Java
平台,提供了完整的 Java 核心 API ,这个版本以前称为 J2SE
Java EE Enterprise Edition ):支持使用多层架构的企业应用(如 ERP CRM 应用)的
Java 平台,除了提供 Java SE API 外,还对其做了大量的扩充
[3]
并提供了相关的部署支持,这
个版本以前称为 J2EE
[1] JDK 1.7 Java SE API 范围: http://download.oracle.com/javase/7/docs/api/
[2] 图片来源: http://download.oracle.com/javase/7/docs/
[3] 这些扩展一般以 javax.* 作为包名,而以 java.* 为包名的包都是 Java SE API 的核心包,但由
于历史原因,一部分曾经是扩展包的 API 后来进入了核心包,因此核心包中也包含了不少
javax.* 的包名。 1.3 Java 发展史
从第一个 Java 版本诞生到现在已经有 18 年的时间了。沧海桑田一瞬间,转眼 18 年过去
了,在图 1-3 所展示的时间线中,我们看到 JDK 已经发展到了 1.7 版。在这 18 年里还诞生了无
数和 Java 相关的产品、技术和标准。现在让我们走入时间隧道,从孕育 Java 语言的时代开
始,再来回顾一下 Java 的发展轨迹和历史变迁。
1-3 Java 技术发展的时间线
1991 4 月,由 James Gosling 博士领导的绿色计划( Green Project )开始启动,此计划的
目的是开发一种能够在各种消费性电子产品(如机顶盒、冰箱、收音机等)上运行的程序架
构。这个计划的产品就是 Java 语言的前身: Oak (橡树)。 Oak 当时在消费品市场上并不算成
功,但随着 1995 年互联网潮流的兴起, Oak 迅速找到了最适合自己发展的市场定位并蜕变成
Java 语言。
1995 5 23 日, Oak 语言改名为 Java ,并且在 SunWorld 大会上正式发布 Java 1.0 版本。
Java 语言第一次提出了 “Write Once,Run Anywhere” 的口号。
1996 1 23 日, JDK 1.0 发布, Java 语言有了第一个正式版本的运行环境。 JDK 1.0 提供
了一个纯解释执行的 Java 虚拟机实现( Sun Classic VM )。 JDK 1.0 版本的代表技术包括:
Java 虚拟机、 Applet AWT 等。
1996 4 月, 10 个最主要的操作系统供应商申明将在其产品中嵌入 Java 技术。同年 9 月,
已有大约 8.3 万个网页应用了 Java 技术来制作。在 1996 5 月底, Sun 公司于美国旧金山举行了
首届 JavaOne 大会,从此 JavaOne 成为全世界数百万 Java 语言开发者每年一度的技术盛会。
1997 2 19 日, Sun 公司发布了 JDK
1.1 Java 技术的一些最基础的支撑点(如 JDBC
等)都是在 JDK
1.1 版本中发布的, JDK
1.1 版的技术代表有: JAR 文件格式、 JDBC
JavaBeans RMI Java 语法也有了一定的发展,如内部类( Inner
Class )和反射
Reflection )都是在这个时候出现的。 直到 1999 4 8 日, JDK 1.1 一共发布了 1.1.0 1.1.8 九个版本。从 1.1.4 之后,每个 JDK
本都有一个自己的名字(工程代号),分别为: JDK 1.1.4-Sparkler (宝石)、 JDK 1.1.5-
Pumpkin (南瓜)、 JDK 1.1.6-Abigail (阿比盖尔,女子名)、 JDK 1.1.7-Brutus (布鲁图,古
罗马政治家和将军)和 JDK 1.1.8-Chelsea (切尔西,城市名)。
1998 12 4 日, JDK 迎来了一个里程碑式的版本 JDK 1.2 ,工程代号为 Playground (竞技
场), Sun 在这个版本中把 Java 技术体系拆分为 3 个方向,分别是面向桌面应用开发的
J2SE Java 2 Platform,Standard Edition )、面向企业级开发的 J2EE Java 2 Platform,Enterprise
Edition )和面向手机等移动终端开发的 J2ME Java 2 Platform,Micro Edition )。在这个版本
中出现的代表性技术非常多,如 EJB Java Plug-in Java IDL Swing 等,并且这个版本中
Java 虚拟机第一次内置了 JIT Just In Time )编译器( JDK 1.2 中曾并存过 3 个虚拟机, Classic
VM HotSpot VM Exact VM ,其中 Exact VM 只在 Solaris 平台出现过;后面两个虚拟机都是
内置 JIT 编译器的,而之前版本所带的 Classic VM 只能以外挂的形式使用 JIT 编译器)。在语
言和 API 级别上, Java 添加了 strictfp 关键字与现在 Java 编码之中极为常用的一系列 Collections
集合类。在 1999 3 月和 7 月,分别有 JDK 1.2.1 JDK 1.2.2 两个小版本发布。
1999 4 27 日, HotSpot 虚拟机发布, HotSpot 最初由一家名为 “Longview
Technologies” 的小公司开发,因为 HotSpot 的优异表现,这家公司在 1997 年被 Sun 公司收购
了。 HotSpot 虚拟机发布时是作为 JDK 1.2 的附加程序提供的,后来它成为了 JDK 1.3 及之后所
有版本的 Sun JDK 的默认虚拟机。
2000 5 8 日,工程代号为 Kestrel (美洲红隼)的 JDK 1.3 发布, JDK 1.3 相对于 JDK 1.2
的改进主要表现在一些类库上(如数学运算和新的 Timer API 等), JNDI 服务从 JDK 1.3 开始
被作为一项平台级服务提供(以前 JNDI 仅仅是一项扩展),使用 CORBA IIOP 来实现 RMI
通信协议,等等。这个版本还对 Java 2D 做了很多改进,提供了大量新的 Java 2D API ,并且
新添加了 JavaSound 类库。 JDK 1.3 1 个修正版本 JDK 1.3.1 ,工程代号为 Ladybird (瓢虫),
2001 5 17 日发布。
自从 JDK 1.3 开始, Sun 维持了一个习惯:大约每隔两年发布一个 JDK 的主版本,以动物
命名,期间发布的各个修正版本则以昆虫作为工程名称。
2002 2 13 日, JDK 1.4 发布,工程代号为 Merlin (灰背隼)。 JDK 1.4 Java 真正走向
成熟的一个版本, Compaq Fujitsu SAS Symbian IBM 等著名公司都有参与甚至实现自己
独立的 JDK 1.4 。哪怕是在十多年后的今天,仍然有许多主流应用( Spring Hibernate Struts
等)能直接运行在 JDK 1.4 之上,或者继续发布能运行在 JDK 1.4 上的版本。 JDK 1.4 同样发布
了很多新的技术特性,如正则表达式、异常链、 NIO 、日志类、 XML 解析器和 XSLT 转换器
等。 JDK
1.4 有两个后续修正版: 2002 9 16 日发布的工程代号为 Grasshopper (蚱蜢)的
JDK 1.4.1 2003 6 26 日发布的工程代号为 Mantis (螳螂)的 JDK 1.4.2
2002 年前后还发生了一件与 Java 没有直接关系,但事实上对 Java 的发展进程影响很大的
事件,那就是微软公司的 .NET Framework 发布了。这个无论是技术实现上还是目标用户上都
Java 有很多相近之处的技术平台给 Java 带来了很多讨论、比较和竞争, .NET 平台和 Java
台之间声势浩大的孰优孰劣的论战到目前为止都在继续。
2004 9 30 日, JDK 1.5
[1]
发布,工程代号 Tiger (老虎)。从 JDK 1.2 以来, Java 在语法
层面上的变换一直很小,而 JDK 1.5 Java 语法易用性上做出了非常大的改进。例如,自动装 箱、泛型、动态注解、枚举、可变长参数、遍历循环( foreach 循环)等语法特性都是在 JDK
1.5 中加入的。在虚拟机和 API 层面上,这个版本改进了 Java 的内存模型( Java
Memory
Model,JMM )、提供了 java.util.concurrent 并发包等。另外, JDK
1.5 是官方声明可以支持
Windows 9x 平台的最后一个 JDK 版本。
2006 12 11 日, JDK 1.6 发布,工程代号 Mustang (野马)。在这个版本中, Sun 终结了
JDK 1.2 开始已经有 8 年历史的 J2EE J2SE J2ME 的命名方式,启用 Java SE 6 Java EE
6 Java
ME
6 的命名方式。 JDK
1.6 的改进包括:提供动态语言支持(通过内置 Mozilla
JavaScript Rhino 引擎实现)、提供编译 API 和微型 HTTP 服务器 API 等。同时,这个版本对 Java
虚拟机内部做了大量改进,包括锁与同步、垃圾收集、类加载等方面的算法都有相当多的改
动。
2006 11 13 日的 JavaOne 大会上, Sun 公司宣布最终会将 Java 开源,并在随后的一年
多时间内,陆续将 JDK 的各个部分在 GPL v2 GNU General Public License v2 )协议下公开了
源码,并建立了 OpenJDK 组织对这些源码进行独立管理。除了极少量的产权代码
Encumbered Code ,这部分代码大多是 Sun 本身也无权限进行开源处理的)外, OpenJDK
乎包括了 Sun JDK 的全部代码, OpenJDK 的质量主管曾经表示,在 JDK 1.7 中, Sun JDK
OpenJDK 除了代码文件头的版权注释之外,代码基本上完全一样,所以 OpenJDK 7 Sun JDK
1.7 本质上就是同一套代码库开发的产品。
JDK 1.6 发布以后,由于代码复杂性的增加、 JDK 开源、开发 JavaFX 、经济危机及 Sun
购案等原因, Sun JDK 发展以外的事情上耗费了很多资源, JDK 的更新没有再维持两年发布
一个主版本的发展速度。 JDK 1.6 到目前为止一共发布了 37 Update 版本,最新的版本为 Java
SE 6 Update 37 ,于 2012 10 16 日发布。
2009 2 19 日,工程代号为 Dolphin (海豚)的 JDK 1.7 完成了其第一个里程碑版本。根
JDK 1.7 的功能规划,一共设置了 10 个里程碑。最后一个里程碑版本原计划于 2010 9 9
日结束,但由于各种原因, JDK 1.7 最终无法按计划完成。
JDK 1.7 最开始的功能规划来看,它本应是一个包含许多重要改进的 JDK 版本,其中的
Lambda 项目( Lambda 表达式、函数式编程)、 Jigsaw 项目(虚拟机模块化支持)、动态语言
支持、 GarbageFirst 收集器和 Coin 项目(语言细节进化)等子项目对于 Java 业界都会产生深远
的影响。在 JDK 1.7 开发期间, Sun 公司由于相继在技术竞争和商业竞争中都陷入泥潭,公司
的股票市值跌至仅有高峰时期的 3% ,已无力推动 JDK 1.7 的研发工作按正常计划进行。为了
尽快结束 JDK 1.7 长期 跳票 的问题, Oracle 公司收购 Sun 公司后不久便宣布将实行 “B 计划
大幅裁剪了 JDK 1.7 预定目标,以便保证 JDK 1.7 的正式版能够于 2011 7 28 日准时发布。 “B
计划 把不能按时完成的 Lambda 项目、 Jigsaw 项目和 Coin 项目的部分改进延迟到 JDK
1.8
中。最终, JDK 1.7 的主要改进包括:提供新的 G1 收集器( G1 在发布时依然处于 Experimental
状态,直至 2012 4 月的 Update
4 中才正式 转正 )、加强对非 Java 语言的调用支持( JSR-
292 ,这项特性到目前为止依然没有完全实现定型)、升级类加载架构等。
到目前为止, JDK 1.7 已经发布了 9 Update 版本,最新的 Java SE 7 Update 9 2012 10
16 日发布。从 Java SE 7 Update 4 起, Oracle 开始支持 Mac OS X 操作系统,并在 Update 6 中达
到完全支持的程度,同时,在 Update 6 中还对 ARM 指令集架构提供了支持。至此,官方提供
JDK 可以运行于 Windows (不含 Windows
9x )、 Linux Solaris Mac
OS 平台上,支持
ARM x86 x64 Sparc 指令集架构类型。 2009 4 20 日, Oracle 公司宣布正式以 74 亿美元的价格收购 Sun 公司, Java 商标从此正
式归 Oracle 所有( Java 语言本身并不属于哪间公司所有,它由 JCP 组织进行管理,尽管 JCP
要是由 Sun 公司或者说 Oracle 公司所领导的)。由于此前 Oracle 公司已经收购了另外一家大型
的中间件企业 BEA 公司,在完成对 Sun 公司的收购之后, Oracle 公司分别从 BEA Sun 中取得
了目前三大商业虚拟机的其中两个: JRockit HotSpot,Oracle 公司宣布在未来 1 2 年的时间
内,将把这两个优秀的虚拟机互相取长补短,最终合二为一
[2]
。可以预见在不久的将
来, Java 虚拟机技术将会产生相当巨大的变化。
根据 Oracle 官方提供的信息, JDK 1.8 的第一个正式版本将于 2013 9 月发布, JDK 1.8
会提供在 JDK
1.7 中规划过,但最终未能在 JDK
1.7 中发布的特性,即 Lambda 表达式、
Jigsaw (很不幸,随后 Oracle 公司又宣布 Jigsaw JDK
1.8 中依然无法完成,需要延至 JDK
1.9 )和 JDK 1.7 中未实现的一部分 Coin 等。
2011 年的 JavaOne 大会上, Oracle 公司还提到了 JDK 1.9 的长远规划,希望未来的 Java
拟机能够管理数以 GB 计的 Java 堆,能够更高效地与本地代码集成,并且令 Java 虚拟机运行时
尽可能少人工干预,能够自动调节。
[1] JDK 1.5 版本开始,官方在正式文档与宣传上已经不再使用类似 JDK 1.5 的命名,只有在
程序员内部使用的开发版本号( Developer Version ,例如 java-version 的输出)中才继续沿用
1.5 1.6 1.7 的版本号,而公开版本号( Product Version )则改为 JDK 5 JDK 6 JDK 7 的命
名方式,本书为了行文一致,所有场合统一采用开发版本号的命名方式。
[2] “HotRockit” 项目的相关介绍: http://hirt.se/presentations/WhatToExpect.ppt 1.4 Java 虚拟机发展史
上一节我们从整个 Java 技术的角度观察了 Java 技术的发展,许多 Java 程序员都会潜意识
地把它与 Sun 公司的 HotSpot 虚拟机等同看待,也许还有一些程序员会注意到 BEA
JRockit
IBM J9 ,但对 JVM 的认识不仅仅只有这些。
1996 年初 Sun 公司发布的 JDK 1.0 中所包含的 Sun Classic VM 到今天,曾经涌现、湮灭过
许多或经典或优秀或有特色的虚拟机实现,在这一节中,我们先暂且把代码与技术放下,一
起来回顾一下 Java 虚拟机家族的发展轨迹和历史变迁。
1.4.1 Sun Classic/Exact VM
以今天的视角来看, Sun Classic VM 的技术可能很原始,这款虚拟机的使命也早已终
结。但仅凭它 世界上第一款商用 Java 虚拟机 的头衔,就足够有让历史记住它的理由。
1996 1 23 日, Sun 公司发布 JDK 1.0 Java 语言首次拥有了商用的正式运行环境,这个
JDK 中所带的虚拟机就是 Classic VM 。这款虚拟机只能使用纯解释器方式来执行 Java 代码,
如果要使用 JIT 编译器,就必须进行外挂。但是假如外挂了 JIT 编译器, JIT 编译器就完全接管
了虚拟机的执行系统,解释器便不再工作了。用户在这款虚拟机上执行 java-version 命令,将
会看到类似下面这行输出:
java version"1.2.2"
Classic VM build JDK-1.2.2-001 green threads,sunwjit
其中的 “sunwjit” 就是 Sun 提供的外挂编译器,其他类似的外挂编译器还有 Symantec JIT
shuJIT 等。由于解释器和编译器不能配合工作,这就意味着如果要使用编译器执行,编译器
就不得不对每一个方法、每一行代码都进行编译,而无论它们执行的频率是否具有编译的价
值。基于程序响应时间的压力,这些编译器根本不敢应用编译耗时稍高的优化技术,因此这
个阶段的虚拟机即使用了 JIT 编译器输出本地代码,执行效率也和传统的 C/C++ 程序有很大差
距, “Java 语言很慢 的形象就是在这时候开始在用户心中树立起来的。
Sun 的虚拟机团队努力去解决 Classic VM 所面临的各种问题,提升运行效率。在 JDK 1.2
时,曾在 Solaris 平台上发布过一款名为 Exact VM 的虚拟机,它的执行系统已经具备现代高性
能虚拟机的雏形:如两级即时编译器、编译器与解释器混合工作模式等。 Exact VM 因它使用
准确式内存管理( Exact Memory Management ,也可以叫 Non-Conservative/Accurate Memory
Management )而得名,即虚拟机可以知道内存中某个位置的数据具体是什么类型。譬如内存
中有一个 32 位的整数 123456 ,它到底是一个 reference 类型指向 123456 的内存地址还是一个数
值为 123456 的整数,虚拟机将有能力分辨出来,这样才能在 GC (垃圾收集)的时候准确判
断堆上的数据是否还可能被使用。由于使用了准确式内存管理, Exact
VM 可以抛弃以前
Classic VM 基于 handler 的对象查找方式(原因是进行 GC 后对象将可能会被移动位置,如果将
地址为 123456 的对象移动到 654321 ,在没有明确信息表明内存中哪些数据是 reference 的前提
下,虚拟机是不敢把内存中所有为 123456 的值改成 654321 的,所以要使用句柄来保持
reference 值的稳定),这样每次定位对象都少了一次间接查找的开销,提升执行性能。
虽然 Exact VM 的技术相对 Classic VM 来说先进了许多,但是在商业应用上只存在了很短
暂的时间就被更为优秀的 HotSpot VM 所取代,甚至还没有来得及发布 Windows Linux 平台下 的商用版本。而 Classic VM 的生命周期则相对长了许多,它在 JDK 1.2 之前是 Sun JDK 中唯一
的虚拟机,在 JDK 1.2 时,它与 HotSpot VM 并存,但默认使用的是 Classic VM (用户可用 java
hotspot 参数切换至 HotSpot VM ),而在 JDK 1.3 时, HotSpot VM 成为默认虚拟机,但 Classic
VM 仍作为虚拟机的 备用选择 发布(使用 java-classic 参数切换),直到 JDK
1.4 的时
候, Classic VM 才完全退出商用虚拟机的历史舞台,与 Exact VM 一起进入了 Sun Labs Research
VM 之中。 1.4.2 Sun HotSpot VM
提起 HotSpot VM ,相信所有 Java 程序员都知道,它是 Sun JDK OpenJDK 中所带的虚拟
机,也是目前使用范围最广的 Java 虚拟机。但不一定所有人都知道的是,这个目前看起
血统纯正 的虚拟机在最初并非由 Sun 公司开发,而是由一家名为 “Longview
Technologies” 的小公司设计的;甚至这个虚拟机最初并非是为 Java 语言而开发的,它来源于
Strongtalk VM ,而这款虚拟机中相当多的技术又是来源于一款支持 Self 语言实现 达到 C 语言
50% 以上的执行效率 的目标而设计的虚拟机, Sun 公司注意到了这款虚拟机在 JIT 编译上有许
多优秀的理念和实际效果,在 1997 年收购了 Longview Technologies 公司,从而获得了 HotSpot
VM
HotSpot
VM 既继承了 Sun 之前两款商用虚拟机的优点(如前面提到的准确式内存管
理),也有许多自己新的技术优势,如它名称中的 HotSpot 指的就是它的热点代码探测技术
(其实两个 VM 基本上是同时期的独立产品, HotSpot 还稍早一些, HotSpot 一开始就是准确式
GC ,而 Exact VM 之中也有与 HotSpot 几乎一样的热点探测。为了 Exact VM HotSpot VM 哪个
成为 Sun 主要支持的 VM 产品,在 Sun 公司内部还有过争论, HotSpot 打败 Exact 并不能算技术上
的胜利), HotSpot
VM 的热点代码探测能力可以通过执行计数器找出最具有编译价值的代
码,然后通知 JIT 编译器以方法为单位进行编译。如果一个方法被频繁调用,或方法中有效
循环次数很多,将会分别触发标准编译和 OSR (栈上替换)编译动作。通过编译器与解释器
恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待
本地代码输出才能执行程序,即时编译的时间压力也相对减小,这样有助于引入更多的代码
优化技术,输出质量更高的本地代码。
2006 年的 JavaOne 大会上, Sun 公司宣布最终会把 Java 开源,并在随后的一年,陆续将
JDK 的各个部分(其中当然也包括了 HotSpot VM )在 GPL 协议下公开了源码,并在此基础上
建立了 OpenJDK 。这样, HotSpot VM 便成为了 Sun JDK OpenJDK 两个实现极度接近的 JDK
目的共同虚拟机。
2008 年和 2009 年, Oracle 公司分别收购了 BEA 公司和 Sun 公司,这样 Oracle 就同时拥有
了两款优秀的 Java 虚拟机: JRockit VM HotSpot VM Oracle 公司宣布在不久的将来(大约
应在发布 JDK 8 的时候)会完成这两款虚拟机的整合工作,使之优势互补。整合的方式大致
上是在 HotSpot 的基础上,移植 JRockit 的优秀特性,譬如使用 JRockit 的垃圾回收器与
MissionControl 服务,使用 HotSpot JIT 编译器与混合的运行时系统。 1.4.3 Sun Mobile-Embedded VM/Meta-Circular VM
Sun 公司所研发的虚拟机可不仅有前面介绍的服务器、桌面领域的商用虚拟机,除此之
外, Sun 公司面对移动和嵌入式市场,也发布过虚拟机产品,另外还有一类虚拟机,在设计
之初就没抱有商用的目的,仅仅是用于研究、验证某种技术和观点,又或者是作为一些规范
的标准实现。这些虚拟机对于大部分不从事相关领域开发的 Java 程序员来说可能比较陌生。
Sun 公司发布的其他 Java 虚拟机有:
1 KVM
KVM 中的 K “Kilobyte” 的意思,它强调简单、轻量、高度可移植,但是运行速度比较
慢。在 Android iOS 等智能手机操作系统出现前曾经在手机平台上得到非常广泛的应用。
2 CDC/CLDC HotSpot Implementation
CDC/CLDC 全称是 Connected Limited Device Configuration ,在 JSR-139/JSR-218 规范中
进行定义,它希望在手机、电子书、 PDA 等设备上建立统一的 Java 编程接口,而 CDC-HI VM
CLDC-HI VM 则是它们的一组参考实现。 CDC/CLDC 是整个 Java ME 的重要支柱,但从目前
Android iOS 二分天下的移动数字设备市场看来,在这个领域中, Sun 的虚拟机所面临的局
面远不如服务器和桌面领域乐观。
3 Squawk VM
Squawk
VM Sun 公司开发,运行于 Sun SPOT Sun Small Programmable Object
Technology ,一种手持的 WiFi 设备),也曾经运用于 Java Card 。这是一个 Java 代码比重很高
的嵌入式虚拟机实现,其中诸如类加载器、字节码验证器、垃圾收集器、解释器、编译器和
线程调度都是 Java 语言本身完成的,仅仅靠 C 语言来编写设备 I/O 和必要的本地代码。
4 JavaInJava
JavaInJava Sun 公司于 1997 年~ 1998 年间研发的一个实验室性质的虚拟机,从名字就可
以看出,它试图以 Java 语言来实现 Java 语言本身的运行环境,既所谓的 元循环 Meta
Circular ,是指使用语言自身来实现其运行环境)。它必须运行在另外一个宿主虚拟机之
上,内部没有 JIT 编译器,代码只能以解释模式执行。在 20 世纪末主流 Java 虚拟机都未能很好
解决性能问题的时代,开发这种项目,其执行速度可想而知。
5 Maxine VM
Maxine VM 和上面的 JavaInJava 非常相似,它也是一个几乎全部以 Java 代码实现(只有用
于启动 JVM 的加载器使用 C 语言编写)的元循环 Java 虚拟机。这个项目于 2005 年开始,到现
在仍然在发展之中,比起 JavaInJava,Maxine VM 就显得 靠谱 很多,它有先进的 JIT 编译器和
垃圾收集器(但没有解释器),可在宿主模式或独立模式下执行,其执行效率已经接近了
HotSpot Client VM 的水平。 1.4.4 BEA JRockit/IBM J9 VM
前面介绍了 Sun 公司的各种虚拟机,除了 Sun 公司以外,其他组织、公司也研发过不少虚
拟机实现,其中规模最大、最著名的就是 BEA IBM 公司了。
JRockit
VM 曾经号称 世界上速度最快的 Java 虚拟机 (广告词,貌似 J9 VM 也这样说
过),它是 BEA 公司在 2002 年从 Appeal Virtual Machines 公司收购的虚拟机。 BEA 公司将其发
展为一款专门为服务器硬件和服务器端应用场景高度优化的虚拟机,由于专注于服务器端应
用,它可以不太关注程序启动速度,因此 JRockit 内部不包含解析器实现,全部代码都靠即时
编译器编译后执行。除此之外, JRockit 的垃圾收集器和 MissionControl 服务套件等部分的实
现,在众多 Java 虚拟机中也一直处于领先水平。
IBM J9 VM 并不是 IBM 公司唯一的 Java 虚拟机,不过是目前其主力发展的 Java 虚拟机。
IBM J9 VM 原本是内部开发代号,正式名称是 “IBM Technology for Java Virtual Machine” ,简
IT4J ,只是这个名字太拗口了一点,普及程度不如 J9 J9 VM 最初是由 IBM Ottawa 实验室
一个名为 SmallTalk 的虚拟机扩展而来的,当时这个虚拟机有一个 bug 是由 8k 值定义错误引起
的,工程师花了很长时间终于发现并解决了这个错误,此后这个版本的虚拟机就称为 K8 了,
后来扩展出支持 Java 的虚拟机就被称为 J9 了。与 BEA JRockit 专注于服务器端应用不同, IBM
J9 的市场定位与 Sun HotSpot 比较接近,它是一款设计上从服务器端到桌面应用再到嵌入式都
全面考虑的多用途虚拟机, J9 的开发目的是作为 IBM 公司各种 Java 产品的执行平台,它的主
要市场是和 IBM 产品(如 IBM WebSphere 等)搭配以及在 IBM AIX z/OS 这些平台上部署 Java
应用。 1.4.5 Azul VM/BEA Liquid VM
我们平时所提及的 高性能 Java 虚拟机 一般是指 HotSpot JRockit J9 这类在通用平台上
运行的商用虚拟机,但其实 Azul VM BEA Liquid VM 这类特定硬件平台专有的虚拟机才
高性能 的武器。
Azul VM Azul Systems 公司在 HotSpot 基础上进行大量改进,运行于 Azul Systems 公司的
专有硬件 Vega 系统上的 Java 虚拟机,每个 Azul VM 实例都可以管理至少数十个 CPU 和数百 GB
内存的硬件资源,并提供在巨大内存范围内实现可控的 GC 时间的垃圾收集器、为专有硬件
优化的线程调度等优秀特性。在 2010 年, Azul Systems 公司开始从硬件转向软件,发布了自
己的 Zing JVM ,可以在通用 x86 平台上提供接近于 Vega 系统的特性。
Liquid VM 即是现在的 JRockit VE Virtual Edition ),它是 BEA 公司开发的,可以直接运
行在自家 Hypervisor 系统上的 JRockit VM 的虚拟化版本, Liquid VM 不需要操作系统的支持,
或者说它自己本身实现了一个专用操作系统的必要功能,如文件系统、网络支持等。由虚拟
机越过通用操作系统直接控制硬件可以获得很多好处,如在线程调度时,不需要再进行内核
/ 用户态的切换等,这样可以最大限度地发挥硬件的能力,提升 Java 程序的执行性能。 1.4.6 Apache Harmony/Google Android Dalvik VM
这节介绍的 Harmony VM Dalvik VM 只能称做 虚拟机 ,而不能称做 “Java 虚拟机 ,但
是这两款虚拟机(以及所代表的技术体系)对最近几年的 Java 世界产生了非常大的影响和挑
战,甚至有些悲观的评论家认为成熟的 Java 生态系统有崩溃的可能。
Apache Harmony 是一个 Apache 软件基金会旗下以 Apache License 协议开源的实际兼容于
JDK 1.5 JDK 1.6 Java 程序运行平台,这个介绍相当拗口。它包含自己的虚拟机和 Java 库,
用户可以在上面运行 Eclipse Tomcat Maven 等常见的 Java 程序,但是它没有通过 TCK
证,所以我们不得不用那么一长串拗口的语言来介绍它,而不能用一句 “Apache JDK” 来说
明。如果一个公司要宣布自己的运行平台 兼容于 Java 语言 ,那就必须要通过
TCK Technology Compatibility Kit )的兼容性测试。 Apache 基金会曾要求 Sun 公司提供 TCK
使用授权,但是一直遭到拒绝,直到 Oracle 公司收购了 Sun 公司之后,双方关系越闹越僵,最
终导致 Apache 愤然退出 JCP Java Community Process )组织,这是目前为止 Java 社区最严重
的一次 分裂
Sun JDK 开源形成 OpenJDK 之后, Apache Harmony 开源的优势被极大地削弱,甚至连
Harmony 项目的最大参与者 IBM 公司也宣布辞去 Harmony 项目管理主席的职位,并参与
OpenJDK 项目的开发。虽然 Harmony 没有经过真正大规模的商业运用,但是它的许多代码
(基本上是 Java 库部分的代码)被吸纳进 IBM JDK 7 实现及 Google Android SDK 之中,尤其
是对 Android 的发展起到了很大的推动作用。
说到 Android ,这个时下最热门的移动数码设备平台在最近几年间的发展过程中所取得
的成果已经远远超越了 Java ME 在过去十多年所获得的成果, Android Java 语言真正走进了
移动数码设备领域,只是走的并非 Sun 公司原本想象的那一条路。
Dalvik VM Android 平台的核心组成部分之一,它的名字来源于冰岛一个名为 Dalvik
小渔村。 Dalvik VM 并不是一个 Java 虚拟机,它没有遵循 Java 虚拟机规范,不能直接执行 Java
Class 文件,使用的是寄存器架构而不是 JVM 中常见的栈架构。但是它与 Java 又有着千丝万
缕的联系,它执行的 dex Dalvik Executable )文件可以通过 Class 文件转化而来,使用 Java
法编写应用程序,可以直接使用大部分的 Java API 等。目前 Dalvik VM 随着 Android 一起处于
迅猛发展阶段,在 Android 2.2 中已提供即时编译器实现,在执行性能上有了很大的提高。 1.4.7 Microsoft JVM 及其他
在十几年的 Java 虚拟机发展过程中,除去上面介绍的那些被大规模商业应用过的 Java
拟机外,还有许多虚拟机是不为人知的或者曾经 绚丽 过但最终湮灭的。我们以其中微软公
司的 JVM 为例来介绍一下。
也许 Java 程序员听起来可能会觉得惊讶,微软公司曾经是 Java 技术的铁杆支持者(也必
须承认,与 Sun 公司争夺 Java 的控制权,令 Java 从跨平台技术变为绑定在 Windows 上的技术是
微软公司的主要目的)。在 Java 语言诞生的初期( 1996 年~ 1998 年,以 JDK
1.2 发布为分
界),它的主要应用之一是在浏览器中运行 Java
Applets 程序,微软公司为了在 IE3 中支持
Java Applets 应用而开发了自己的 Java 虚拟机,虽然这款虚拟机只有 Windows 平台的版本,却
是当时 Windows 下性能最好的 Java 虚拟机,它在 1997 年和 1998 年连续两年获得了《 PC
Magazine 》杂志的 编辑选择奖 。但好景不长,在 1997 10 月, Sun 公司正式以侵犯商标、不
正当竞争等罪名控告微软公司,在随后对微软公司的垄断调查之中,这款虚拟机也曾作为证
据之一被呈送法庭。这场官司的结果是微软公司赔偿 2000 万美金给 Sun 公司(最终微软公司
因垄断赔偿给 Sun 公司的总金额高达 10 亿美元),承诺终止其 Java 虚拟机的发展,并逐步在
产品中移除 Java 虚拟机相关功能。具有讽刺意味的是,到最后在 Windows XP SP3 Java 虚拟
机被完全抹去的时候, Sun 公司却又到处登报希望微软公司不要这样做
[1]
Windows
XP 高级
产品经理 Jim
Cullinan 称: 我们花费了 3 年的时间和 Sun 打官司,当时他们试图阻止我们在
Windows 中支持 Java ,现在我们这样做了,可他们又在抱怨,这太具有讽刺意味了。
我们试想一下,如果当年 Sun 公司没有起诉微软公司,微软公司继续保持着对 Java 技术
的热情,那 Java 的世界会变得怎么样呢? .NET 技术是否会发展起来?但历史是没有假设的。
其他在本节中没有介绍到的 Java 虚拟机还有(当然,应该还有很多笔者所不知道的):
JamVM.
cacaovm.
SableVM.
Kaffe.
Jelatine JVM.
NanoVM.
MRP.
Moxie JVM.
Jikes RVM.
[1] Sun 公司在《纽约时报》、《圣约瑟商业新闻》和《华尔街周刊》上刊登了整页的广告,
在广告词中 Sun 公司号召消费者 要求微软公司继续在其 Windows XP 系统包括 Java 平台 1.5 展望 Java 技术的未来
2005 年, Java 语言诞生 10 周年的 SunOne 技术大会上, Java 语言之父 James Gosling 做了
一场题为 “Java 技术下一个十年 的演讲。笔者不具备 James
Gosling 博士那样高屋建瓴的视
角,这里仅从 Java 平台中几个新生的但已经开始展现出蓬勃之势的技术发展点来看一下后续
1 2 JDK 版本内的一些很有希望的技术重点。
1.5.1 模块化
模块化是解决应用系统与技术平台越来越复杂、越来越庞大问题的一个重要途径。无论
是开发人员还是产品最终用户,都不希望为了系统中一小块的功能而不得不下载、安装、部
署及维护整套庞大的系统。站在整个软件工业化的高度来看,模块化是建立各种功能的标准
件的前提。最近几年 OSGi 技术的迅速发展、各个厂商在 JCP 中对模块化规范的激烈斗争
[1]
都能充分说明模块化技术的迫切和重要。
在未来的 Java 平台中,很可能会对模块化提出语法层面的支持。早在 2007 年, Sun 公司
就提出过 JSR-277 Java 模块系统( Java Module System ),试图建立 Java 平台的模块化标准,
但受挫于以 IBM 公司为主导提交的 JSR-291 Java
SE 动态组件支持( Dynamic
Component
Support for Java SE ,这实际就是 OSGi R4.1 )。由于模块化规范主导权的重要性, Sun 公司不
能接受一个无法由它控制的规范,在整个 Java SE 6 期间都拒绝把任何模块化技术内置到 JDK
之中。在 Java SE 7 发展初期, Sun 公司再次提交了一个新的规范请求文档 JSR-294 Java 编程
语言中的改进模块性支持( Improved Modularity Support in the Java Programming Language ),
尽管这个 JSR 仍然没有通过,但是 Sun 公司已经独立于 JCP 专家组在 OpenJDK 里建立了一个名
Jigsaw (拼图)的子项目来推动这个规范在 Java 平台中转变为具体的实现。 Java 的模块化
之争目前还没有结束, OSGi 已经发布到 R5.0 版本,而 Jigsaw Java 7 延迟至 Java 8 ,在 2012
7 月又不得不宣布推迟到 Java 9 中发布,从这点看来, Sun 在这场战争中处于劣势,但无论胜
利者是哪一方, Java 模块化已经成为一项无法阻挡的变革潮流。
[1] 如果读者对 Java 模块化之争感兴趣,可以阅读笔者的另外一本书《深入理解 OSGi
Equinox 原理、应用与最佳实践》的第 1 章。 1.5.2 混合语言
当单一的 Java 开发已经无法满足当前软件的复杂需求时,越来越多基于 Java 虚拟机的语
言开发被应用到软件项目中, Java 平台上的多语言混合编程正成为主流,每种语言都可以针
对自己擅长的方面更好地解决问题。试想一下,在一个项目之中,并行处理用 Clojure 语言编
写,展示层使用 JRuby/Rails ,中间层则是 Java ,每个应用层都将使用不同的编程语言来完
成,而且,接口对每一层的开发者都是透明的,各种语言之间的交互不存在任何困难,就像
使用自己语言的原生 API 一样方便
[1]
,因为它们最终都运行在一个虚拟机之上。
在最近的几年里, Clojure JRuby Groovy 等新生语言的使用人数不断增长,而运行在
Java 虚拟机( JVM )之上的语言数量也在迅速膨胀,图 1-4 中列举了其中的一部分。这两点证
明混合编程在我们身边已经有所应用并被广泛认可。通过特定领域的语言去解决特定领域的
问题是当前软件开发应对日趋复杂的项目需求的一个方向。
1-4 可以运行在 JVM 之上的语言
[2] 除了催生出大量的新语言外,许多已经有很长历史的程序语言也出现了基于 Java 虚拟机
实现的版本,这样使得混合编程对许多以前使用其他语言的 程序员也具备相当大的吸引
力,软件企业投入了大量资本的现有代码资产也能很好地保护起来。表 1-1 中列举了常见语
言的 JVM 实现版本。
对这些运行于 Java 虚拟机之上、 Java 之外的语言,来自系统级的、底层的支持正在迅速
增强,以 JSR-292 为核心的一系列项目和功能改进(如 Da Vinci Machine 项目、 Nashorn 引擎、
InvokeDynamic 指令、 java.lang.invoke 包等),推动 Java 虚拟机从 “Java 语言的虚拟机 多语
言虚拟机 的方向发展。
[1] 在同一个虚拟机上运行的其他语言与 Java 之间的交互一般都比较容易,但非 Java 语言之间
的交互一般都比较烦琐。 dynalang 项目( http://dynalang.sourceforge.net/ )就是为了解决这个问
题而出现的。
[2] 图片来源: http://www.Slideshare.net/josebetomex/oow-2009-towards-a-universal-vm 1.5.3 多核并行
如今, CPU 硬件的发展方向已经从高频率转变为多核心,随着多核时代的来临,软件开
发越来越关注并行编程的领域。早在 JDK 1.5 就已经引入 java.util.concurrent 包实现了一个粗粒
度的并发框架。而 JDK 1.7 中加入的 java.util.concurrent.forkjoin 包则是对这个框架的一次重要
扩充。 Fork/Join 模式是处理并行编程的一个经典方法,如图 1-5 所示。虽然不能解决所有的问
题,但是在此模式的适用范围之内,能够轻松地利用多个 CPU 核心提供的计算资源来协作完
成一个复杂的计算任务。通过利用 Fork/Join 模式,我们能够更加顺畅地过渡到多核时代。
1-5 Fork/Join 模式示意图
[1]
Java 8 中,将会提供 Lambda 支持,这将会极大改善目前 Java 语言不适合函数式编程的
现状(目前 Java 语言使用函数式编程并不是不可以,只是会显得很臃肿),函数式编程的一
个重要优点就是这样的程序天然地适合并行运行,这对 Java 语言在多核时代继续保持主流语
言的地位有很大帮助。
另外,在并行计算中必须提及的还有 OpenJDK 的子项目 Sumatra
[2]
,目前显卡的算术运算
能力、并行能力已经远远超过了 CPU ,在图形领域以外发掘显卡的潜力是近几年计算机发展
的方向之一,例如 C 语言的 CUDA Sumatra 项目就是为 Java 提供使用 GPU Graphics
Processing Units )和 APU Accelerated Processing Units )运算能力的工具,以后它将会直接提
Java 语言层面的 API ,或者为 Lambda 和其他 JVM 语言提供底层的并行运算支持。
JDK 外围,也出现了专为满足并行计算需求的计算框架,如 Apache Hadoop
Map/Reduce ,这是一个简单易懂的并行框架,能够运行在由上千个商用机器组成的大型集群
上,并且能以一种可靠的容错方式并行处理 TB 级别的数据集。另外,还出现了诸如 Scala
Clojure Erlang 等天生就具备并行计算能力的语言。
[1] 图片来源: http://www.ibm.com/developerworks/cn/java/j-lo-forkjoin/
[2] Sumatra 项目主页: http://openjdk.java.net/projects/sumatra/ 1.5.4 进一步丰富语法
Java 5 曾经对 Java 语法进行了一次扩充,这次扩充加入了自动装箱、泛型、动态注解、
枚举、可变长参数、遍历循环等语法,使得 Java 语言的精确性和易用性有了很大的进步。在
Java 7 (由于进度压力,许多改进已推迟至 Java 8 )中,对 Java 语法进行了另一次大规模的扩
充。 Sun (已被 Oracle 收购)专门为改进 Java 语法在 OpenJDK 中建立了 Coin 子项目
[1]
来统一处
理对 Java 语法的细节修改,如二进制数的原生支持、在 switch 语句中支持字符串、 <>
作符、异常处理的改进、简化变长参数方法调用、面向资源的 try-catch-finally 语句等都是在
Coin 项目之中提交的内容。
除了 Coin 项目之外,在 JSR-335 Lambda Expressions for the Java TM Programming
Language )中定义的 Lambda 表达式
[2]
也将对 Java 的语法和语言习惯产生很大的影响,面向函数
方式的编程可能会成为主流。
[1] Coin 项目主页: http://wikis.sun.com/display/ProjectCoin/Home
[2] Lambda 项目主页: http://openjdk.java.net/projects/lambda/ 1.5.5 64 位虚拟机
在几年之前,主流的 CPU 就开始支持 64 位架构了。 Java 虚拟机也在很早之前就推出了支
64 位系统的版本。但 Java 程序运行在 64 位虚拟机上需要付出比较大的额外代价:首先是内
存问题,由于指针膨胀和各种数据类型对齐补白的原因,运行于 64 位系统上的 Java 应用需要
消耗更多的内存,通常要比 32 位系统额外增加 10% 30% 的内存消耗;其次,多个机构的测
试结果显示, 64 位虚拟机的运行速度在各个测试项中几乎全面落后于 32 位虚拟机,两者大约
15% 左右的性能差距。
但是在 Java EE 方面,企业级应用经常需要使用超过 4GB 的内存,对于 64 位虚拟机的需求
是非常迫切的,但由于上述原因,许多企业应用都仍然选择使用虚拟集群等方式继续在 32
虚拟机中进行部署。 Sun 也注意到了这些问题,并做出了一些改善,在 JDK 1.6 Update 14
后,提供了普通对象指针压缩功能( -XX +UseCompressedOops ,这个参数不建议显式设
置,建议维持默认由虚拟机的 Ergonomics 机制自动开启),在执行代码时,动态植入压缩指
令以节省内存消耗,但是开启压缩指针会增加执行代码数量,因为所有在 Java 堆里的、指向
Java 堆内对象的指针都会被压缩,这些指针的访问就需要更多的代码才可以实现,而且并不
只是读写字段才受影响,在实例方法调用、子类型检查等操作中也受影响,因为对象实例指
向对象类型的引用也被压缩了。随着硬件的进一步发展,计算机终究会完全过渡到 64 位的时
代,这是一件毫无疑问的事情,主流的虚拟机应用也终究会从 32 位发展至 64 位,而虚拟机对
64 位的支持也将会进一步完善。 1.6 实战:自己编译 JDK
想要一探 JDK 内部的实现机制,最便捷的路径之一就是自己编译一套 JDK ,通过阅读和
跟踪调试 JDK 源码去了解 Java 技术体系的原理,虽然门槛会高一点,但肯定会比阅读各种书
籍、文章更加贴近本质。另外, JDK 中的很多底层方法都是本地化( Native )的,需要跟踪
这些方法的运作或对 JDK 进行 Hack 的时候,都需要自己编译一套 JDK
现在网络上有不少开源的 JDK 实现可以供我们选择,如 Apache Harmony OpenJDK 等。
考虑到 Sun 系列的 JDK 是现在使用得最广泛的 JDK 版本,笔者选择了 OpenJDK 进行这次编译实
战。
1.6.1 获取 JDK 源码
首先要先明确 OpenJDK Sun/OracleJDK 之间,以及 OpenJDK 6 OpenJDK 7 OpenJDK
7u OpenJDK 8 等项目之间是什么关系,这有助于确定接下来编译要使用的 JDK 版本和源码
分支。
从前面介绍的 Java 发展史中我们了解到 OpenJDK Sun 2006 年末把 Java 开源而形成的项
目,这里的 开源 是通常意义上的源码开放形式,即源码是可被复用的,例如 IcedTea
[1]
UltraViolet
[2]
都是从 OpenJDK 源码衍生出的发行版。但如果仅从 开源 字面意义(开放可阅读
的源码)上看,其实 Sun JDK 1.5 之后就开始以 Java Research License JRL )的形式公布过
Java 源码,主要用于研究人员阅读( JRL 许可证的开放源码至 JDK 1.6 Update 23 为止)。把这
JRL 许可证形式的 Sun/OracleJDK 源码和对应版本的 OpenJDK 源码进行比较,发现除了文件
头的版权注释之外,其余代码基本上都是相同的,只有字体渲染部分存在一点差异, Oracle
JDK 采用了商业实现,而 OpenJDK 使用的是开源的 FreeType 。当然, 相同 是建立在两者共
有的组件基础上的, Oracle JDK 中还会存在一些 Open JDK 没有的、商用闭源的功能,例如从
JRockit 移植改造而来的 Java Flight Recorder 。预计以后 JRockit MissionControl 移植到 HotSpot
之后,也会以 Oracle JDK 专有、闭源的形式提供。
Oracle 的项目发布经理 Joe Darcy OSCON 2011 上对两者关系的介绍
[3]
也证实了 OpenJDK
7 Oracle JDK 7 在程序上是非常接近的,两者共用了大量相同的代码(如图 1-6 所示,注意
图中提示了两者共同代码的占比要远高于图形上看到的比例),所以我们编译的 OpenJDK
基本上可以认为性能、功能和执行逻辑上都和官方的 Oracle JDK 是一致的。 1-6 OpenJDK Oracle JDK 之间的关系
再来看一下 OpenJDK 6 OpenJDK 7 OpenJDK 7u OpenJDK 8 这几个项目之间的关系,
从图 1-7 (依然是从 Joe Darcy OSCON 2011 演示稿中截取的图片)来看, OpenJDK 7 是始于
JDK 6 时期,当时 JDK 6 JDK 6 Update 1 已经发布, JDK 7 已经开始研发了,所以 OpenJDK 7
是直接基于正在研发的 JDK 7 源码建立的。但考虑到 OpenJDK 7 的状况在当时还不适合实际生
产部署,因此在 OpenJDK 7 Build 20 的基础上建立了 OpenJDK 6 分支,剥离掉 JDK 7 新功能的
代码,形成一个可以通过 TCK 6 测试的独立分支。
1-7 OpenJDK 6 OpenJDK 7 OpenJDK 7u OpenJDK 8 之间的关系 2012 7 月, JDK 7 正式发布,在 OpenJDK 中也同步建立了 OpenJDK 7 Update 项目对 JDK 7
进行更新升级,以及 OpenJDK 8 项目开始下一个 JDK 大版本的研发。按照开发习惯,新的功
能或 Bug 修复通常是在最新分支上进行的,当功能或修复在最新分支上稳定之后会同步到其
他老版本的维护分支上。
OpenJDK 6 OpenJDK 7 OpenJDK 7u OpenJDK 8 的源码都可以在它们相应的网页上找
到,在本次编译实践中,笔者选用的项目是 OpenJDK 7u ,版本为 7u6
获取 OpenJDK 源码有两种方式,其中一种是通过 Mercurial 代码版本管理工具从 Repository
中直接取得源码( Repository 地址: http://hg.openjdk.java.net/jdk7u/jdk7u ),获取过程如以下
代码所示。
hg clone http://hg.openjdk.java.net/jdk7u/jdk7u-dev
cd jdk7u-dev
chmod 755 get_source.sh
./get_source.sh
这是最直接的方式,从版本管理中看变更轨迹比看 Release Note 效果更好。但不足之处
是速度太慢,虽然代码总容量只有 300 MB 左右,但是文件数量太多,在笔者的网络下全部
复制到本地需要数小时。另外,考虑到 Mercurial 不如 Git SVN ClearCase CVS 之类的版本
控制工具那样普及,对于一般读者,建议采用第二种方式,即直接下载官方打包好的源码
包,读者可以从 Source Bundle Releases 页面(地址: http://jdk7.java.net/source.html )取得打包
好的源码,到本地直接解压即可。一般来说,源码包大概一至两个月左右会更新一次,虽然
不够及时,但比起从 Mercurial 复制代码的确方便和快捷许多。笔者下载的是 OpenJDK
7
Update 6 Build b21 版源码包, 2012 8 28 日发布,大概 99MB ,解压后约为 339MB
[1] IcedTea http://icedtea.classpath.org/wiki/Main_Page
[2] UltraViolet https://www.reservoir.com/ q=uvform/form
[3] 全文地址: https://blogs.oracle.com/darcy/resource/OSCON/oscon2011_OpenJDKState.pdf 1.6.2 系统需求
如果可能,笔者建议尽量在 Linux MacOS Solaris 上构建 OpenJDK ,这要比在 Windows
平台上容易得多,本章实战中笔者将以 Ubuntu 10.10 MacOS X 10.8.2 为例进行构建。如果读
者一定要在 Windows 平台上完成编译,可参考本书附录 A ,该附录是本书第一版中介绍如何
Windows 下编译 OpenJDK
6 的例子,原有的部分内容现在已经过时了(例如安装 Plug
分),但还是有一定参考意义,因此笔者没有把它删除掉,而是移到附录之中。
无论在什么平台下进行编译,都建议读者认真阅读一遍源码中的 README-builds.html
档(无论在 OpenJDK 网站上还是在下载的源码包中都有这份文档),因为编译过程中需要注
意的细节非常多。虽然不至于像文档上所描述的 “Building the source code for the JDK requires
a high level of technical expertise.Sun provides the source code primarily for technical experts who
want to conduct research. (编译 JDK 需要很高的专业技术, Sun 提供 JDK 源码是为了技术专家进
行研究之用) 那么夸张,但是如果读者是第一次编译,那有可能会在一些小问题上耗费许
多时间。
在本次编译中采用的是 64 位操作系统,编译的也是 64 位的 OpenJDK ,如果需要编译 32
版本,那建议在 32 位操作系统上进行。在官方文档上写到编译 OpenJDK 至少需要 512MB 的内
存和 600MB 的磁盘空间。 512MB 的内存也许能凑合使用,不过 600MB 的磁盘空间估计仅是指
存放 OpenJDK 源码所需的空间,要完成编译, 600MB 肯定是无论如何都不够的,光输出的编
译结果就有近 3GB (因为有很多中间文件,以及会编译出不同优化级别( Product Debug
FastDebug 等)的虚拟机),建议读者至少保证 5GB 以上的空余磁盘。
对系统的最后一点要求就是所有的文件,包括源码和依赖项目,都不要放在包含中文的
目录里面,这样做不是一定不可以,只是没有必要给自己找麻烦。 1.6.3 构建编译环境
MacOS
[1]
Linux 上构建 OpenJDK 编译环境比较简单(相对于 Windows 来说),对于 Mac
OS ,需要安装最新版本的 XCode Command Line Tools for XCode ,在 Apple Developer 网站
https://developer.apple.com/ )上可以免费下载,这两个 SDK 包提供了 OpenJDK 所需的编译
器以及 Makefile 中用到的外部命令。另外,还要准备一个 6u14 以上版本的 JDK ,因为 OpenJDK
的各个组成部分( Hotspot JDK API JAXWS JAXP…… )有的是使用 C++ 编写的,更多的
代码则是使用 Java 自身实现的,因此编译这些 Java 代码需要用到一个可用的 JDK ,官方称这
JDK “Bootstrap JDK” 。如果编译 OpenJDK 7 Bootstrap JDK 必须使用 JDK6 Update 14 或之
后的版本,笔者选用的是 JDK7 Update 4 。最后需要下载一个 1.7.1 以上版本的 Apache Ant ,用
于执行 Java 编译代码中的 Ant 脚本。
对于 Linux 来说,所需要准备的依赖与 Mac OS 差不多, Bootstrap JDK Ant 都是一样的,
Mac OS GCC 编译器来源于 XCode SDK ,而 Ubuntu GCC 应该是默认安装好的,需要确保
版本为 4.3 以上,如果没有找到 GCC ,安装 binutils 即可,在 Ubuntu 10.10 下编译 OpenJDK 7u4
需的依赖可以使用以下命令一次安装完成。
sudo apt-get install build-essential gawk m4 openjdk-6-jdk
libasound2-dev libcups2-dev libxrender-dev xorg-dev xutils-dev
x11proto-print-dev binutils libmotif3 libmotif-dev ant
[1] 注意,只有在 OpenJDK 7u4 和之后的版本才能编译出 Mac OS 系统下的 JDK 包,之前的版本
虽然在源码和编译脚本中也包含了 Mac OS 目录,但是尚未完善。 1.6.4 进行编译
现在需要下载的编译环境和依赖项目都准备齐全了,最后我们还需要对系统的环境变量
做一些简单设置以便编译能够顺利通过。 OpenJDK 在编译时读取的环境变量有很多,但大多
都有默认值,必须设置的只有两个: LANG ALT_BOOTDIR ,前者是设定语言选项,必须
设置为:
export LANG=C
否则,在编译结束前的验证阶段会出现一个 HashTable 内的空指针异常。另外一个
ALT_BOOTDIR 参数是前面提到的 Bootstrap JDK ,在 Mac OS 上笔者设为以下路径,其他操作
系统读者对应调整即可。
export ALT_BOOTDIR=/Library/Java/JavaVirtualMachines/jdk1.7.0_04.jdk/Contents/Home
另外,如果读者之前设置了 JAVA_HOME CLASSPATH 两个环境变量,在编译之前必
须取消,否则在 Makefile 脚本中检查到有这两个变量存在,会有警告提示。
unset JAVA_HOME
unset CLASSPATH
其他环境变量笔者就不再一一介绍了,代码清单 1-1 给出笔者自己常用的编译 Shell
本,读者可以参考变量注释中的内容。
代码清单 1-1 环境变量设置
# 语言选项,这个必须设置,否则编译好后会出现一个 HashTable NPE
export LANG=C
#Bootstrap JDK 的安装路径。必须设置
export ALT
_
BOOTDIR=/Library/Java/JavaVirtualMachines/jdk1.7.0
_
04.jdk/Contents/Home
# 允许自动下载依赖
export ALLOW
_
DOWNLOADS=true
# 并行编译的线程数,设置为和 CPU 内核数量一致即可
export HOTSPOT_BUILD_JOBS=6
export ALT
_
PARALLEL
_
COMPILE
_
JOBS=6
# 比较本次 build 出来的映像与先前版本的差异。这对我们来说没有意义,
# 必须设置为 false ,否则 sanity 检查会报缺少先前版本 JDK 的映像的错误提示。
# 如果已经设置 dev 或者 DEV_ONLY=true ,这个不显式设置也行
export SKIP
_
COMPARE
_
IMAGES=true
# 使用预编译头文件,不加这个编译会更慢一些
export USE
_
PRECOMPILED
_
HEADER=true
# 要编译的内容
export BUILD_LANGTOOLS=true
#export BUILD_JAXP=false
#export BUILD_JAXWS=false
#export BUILD_CORBA=false
export BUILD_HOTSPOT=true
export BUILD
_
JDK=true
# 要编译的版本
#export SKIP_DEBUG_BUILD=false
#export SKIP_FASTDEBUG_BUILD=true
#export DEBUG
_
NAME=debug
# 把它设置为 false 可以避开 javaws 和浏览器 Java 插件之类的部分的 build
BUILD
_
DEPLOY=false
# 把它设置为 false 就不会 build 出安装包。因为安装包里有些奇怪的依赖,
# 但即便不 build 出它也已经能得到完整的 JDK 映像,所以还是别 build 它好了
BUILD
_
INSTALL=false
# 编译结果所存放的路径
export ALT
_
OUTPUTDIR=/Users/IcyFenix/Develop/JVM/jdkBuild/openjdk
_
7u4/build
# 这两个环境变量必须去掉,不然会有很诡异的事情发生(我没有具体查过这些 " 诡异的
# 事情 " Makefile 脚本检查到有这 2 个变量就会提示警告)
unset JAVA_HOME
unset CLASSPATH
make 2 >& 1|tee $ALT_OUTPUTDIR/build.log
全部设置结束之后,可以输入 make sanity 来检查我们前面所做的设置是否全部正确。如
果一切顺利,那么几秒钟之后会有类似代码清单 1-2 所示的输出。
代码清单 1-2 make sanity 检查 /Develop/JVM/jdkBuild/openjdk_7u4$make sanity
Build Machine Information
build machine=IcyFenix-RMBP.local
Build Directory Structure
CWD=/Users/IcyFenix/Develop/JVM/jdkBuild/openjdk_7u4
TOPDIR=.
LANGTOOLS_TOPDIR=./langtools
JAXP_TOPDIR=./jaxp
JAXWS_TOPDIR=./jaxws
CORBA_TOPDIR=./corba
HOTSPOT_TOPDIR=./hotspot
JDK_TOPDIR=./jdk
Build Directives
BUILD_LANGTOOLS=true
BUILD_JAXP=true
BUILD_JAXWS=true
BUILD_CORBA=true
BUILD_HOTSPOT=true
BUILD_JDK=true
DEBUG_CLASSFILES=
DEBUG
_
BINARIES=
…… 因篇幅关系,中间省略了大量的输出内容 ……
OpenJDK-specific settings
FREETYPE_HEADERS_PATH=/usr/X11R6/include
ALT_FREETYPE_HEADERS_PATH=
FREETYPE_LIB_PATH=/usr/X11R6/lib
ALT_FREETYPE_LIB_PATH=
Previous JDK Settings
PREVIOUS_RELEASE_PATH=USING-PREVIOUS_RELEASE_IMAGE
ALT_PREVIOUS_RELEASE_PATH=
PREVIOUS_JDK_VERSION=1.6.0
ALT_PREVIOUS_JDK_VERSION=
PREVIOUS_JDK_FILE=
ALT_PREVIOUS_JDK_FILE=
PREVIOUS_JRE_FILE=
ALT_PREVIOUS_JRE_FILE=
PREVIOUS_RELEASE_IMAGE=/Library/Java/JavaVirtualMachines/jdk1.7.0_04.jdk/Contents/Home
ALT_PREVIOUS_RELEASE_IMAGE=
Sanity check passed.
Makefile Sanity 检查过程输出了编译所需的所有环境变量,如果看到 “Sanity
check
passed.” ,说明检查过程通过了,可以输入 “make” 执行整个 OpenJDK 编译( make 不加参数,
默认编译 make all ),笔者使用 Core i7 3720QM/16GB RAM MacBook 机器,启动 6 条编译线
程,全量编译整个 OpenJDK 大概需 20 分钟,编译结束后,将输出类似下面的日志清单所示内
容。如果读者之前已经全量编译过,只修改了少量文件,增量编译可以在数十秒内完成。
#--Build times----------
Target all_product_build
Start 2012-12-13 17 12 19
End 2012-12-13 17 31 07
00 01 19 corba
00 01 15 hotspot
00 00 14 jaxp
00 7 21 jaxws
00 8 11 jdk
00 00 28 langtools
00 18 48 TOTAL
-------------------------
编译完成之后,进入 OpenJDK 源码下的 build/j2sdk-image 目录(或者 build-debug build
fastdebug 这两个目录),这是整个 JDK 的完整编译结果,复制到 JAVA_HOME 目录,就可以
作为一个完整的 JDK 使用,编译出来的虚拟机,在 -version 命令中带有用户的机器名。
./java-version
openjdk version"1.7.0-internal-fastdebug"
O p e n J D K R u n t i m e E n v i r o n m e n t b u i l d 1.7.0-i n t e r n a l-f a s t d e b u g-icyfenix_2012_12_24_15_57-b00
OpenJDK 64-Bit Server VM build 23.0-b21-fastdebug,mixed mode
在大多数时候,如果我们并不关心 JDK HotSpot 虚拟机以外的内容,只想单独编译
HotSpot 虚拟机的话(例如调试虚拟机时,每次改动程序都执行整个 OpenJDK Makefile ,速
度肯定受不了),那么使用 hotspot/make 目录下的 Makefile 进行替换即可,其他参数设置与前
面是一致的,这时候虚拟机的输出结果存放在 build/hotspot/outputdir/bsd_amd64_compiler2
[1]
中,进入后可以见到以下几个目录。
0 drwxr-xr-x 15 IcyFenix staff 510B 12 13 17 24 debug
0 drwxr-xr-x 15 IcyFenix staff 510B 12 13 17 24 fastdebug
0 drwxr-xr-x 15 IcyFenix staff 510B 12 13 17 25 generated
0 drwxr-xr-x 15 IcyFenix staff 510B 12 13 17 24 jvmg
0 drwxr-xr-x 15 IcyFenix staff 510B 12 13 17 24 optimized 0 drwxr-xr-x 584 IcyFenix staff 19K 12 13 17 25 product
0 drwxr-xr-x 15 IcyFenix staff 510B 12 13 17 24 profiled
这些目录对应了不同的优化级别,优化级别越高,性能自然就越好,但是输出代码与源
码的差距就越大,难于调试,具体哪个目录有内容,取决于 make 命令后面的参数。
在编译结束之后、运行虚拟机之前,还要手工编辑目录下的 env.sh 文件,这个文件由编
译脚本自动产生,用于设置虚拟机的环境变量,里面已经发布了 “JAVA_HOME
CLASSPATH HOTSPOT_BUILD_USER”3 个环境变量,还需要增加一
“LD_LIBRARY_PATH” ,内容如下:
LD_LIBRARY_PATH=. ${JAVA_HOME}/jre/lib/amd64/native_threads ${JAVA_HOME}/jre/lib/amd64
export LD_LIBRARY_PATH
然后执行以下命令启动虚拟机(这时的启动器名为 gamma ),输出版本号。
../env.sh
./gamma-version
Using java runtime at /Library/Java/JavaVirtualMachines/jdk1.7.0_04.jdk/Contents/Home/jre
java version"1.7.0_04"
Java TM SE Runtime Environment build 1.7.0_04-b21
OpenJDK 64-Bit Server VM build 23.0-b21 mixed mode
看到自己编译的虚拟机成功运行起来,很有成就感吧!
[1] 在不同机器上,最后一个目录名称会有所差别, bsd 表示 Mac
OS 系统(内核为
FreeBSD ), amd64 表示是 64 JDK 32 位是 x86 ), compiler2 表示是 Server VM Client VM
示是 compiler1 )。 1.6.5 IDE 工具中进行源码调试
在阅读 OpenJDK 源码的过程中,经常需要运行、调试程序来帮助理解。我们现在已经可
以编译出一个调试版本 HotSpot 虚拟机,禁用优化,并带有符号信息,这样就可以使用 GDB
来进行调试了。据笔者了解,许多对虚拟机了解比较深的开发人员确实就是直接使用 GDB
VIM 编辑器来开发、修改 HotSpot 的,不过相信大部分读者更倾向于在 IDE 环境而不是纯文本
GDB 下阅读、跟踪 HotSpot 源码,因此这节就简单介绍一下 如何在 IDE 中进行 HotSpot 源码
调试
首先,到 NetBeans 网站( http://netbeans.org/ )上下载最新版的 NetBeans ,下载时选择支持
C/C++ 开发的那个版本。安装后,新建一个项目,选择 基于现有源代码的 C/C++ 项目 ,在
源码文件夹中填入 OpenJDK 目录下 hotspot 目录的路径,在下面的单选按钮中选择 定制 ,如
1-8 所示,然后单击 下一步 按钮。
1-8 NetBeans 中创建 HotSpot 项目( 1
接着,在 指定构建代码的方法 中选择 使用现有的 makefile” ,并填入 Makefile 文件的路
径(在 hotspot/make 目录下),如图 1-9 所示。单击 下一步 按钮,将 构建命令 修改为以下 内容:
${MAKE}-f Makefile clean jvmg
ALT_BOOTDIR=/Library/Java/JavaVirtualMachines/jdk1.7.0_04.jdk/Contents/Home ARCH_DATA_MODEL=64 LANG=C
1-9 NetBeans 中创建 HotSpot 项目( 2
OpenJDK
7u4 源码 Makefile 在终端运行时能正确获取到系统指令集架构为 64 位,但在
NetBeans 中却没有取得正确的值,误认为是 32 位,因此这里必须使用 ARCH_DATA_MODEL
参数明确指定为 64 位。另外两个参数 ALT_BOOTDIR LANG 的作用前面已经介绍过。单
完成 按钮, HotSpot 项目就这样导入到 NetBeans 中了。
不过,这时候 HotSpot 还运行不起来,因为 NetBeans 根本不知道编译出来的结果放在哪
里、哪个程序是虚拟机的入口等,这些内容都需要明确告知 NetBeans 。在 HotSpot 工程上单击
右键,在弹出的快捷菜单中选择 属性 ,在弹出的对话框中找到 运行 选项,设置运行命令
为:
/Users/IcyFenix/Develop/JVM/jdkBuild/openjdk_7u4/hotspot/build/bsd/bsd_amd64_compiler2/jvmg/gamma Queens 上面的 Queens Makefile 脚本自动产生的一段解八皇后问题的 Java 程序,用于测试虚拟
机,这里笔者直接拿来用了,读者完全可以将它替换为自己的 Java 程序。
读者在调试 Java 代码执行时,如果要跟踪具体 Java 代码在虚拟机中是如何执行的,也许
会觉得无从下手,因为目前在 HotSpot 主流的操作系统上,都采用模板解释器来执行字节
码,它与 JIT 编译器一样,最终执行的汇编代码都是运行期间产生的,无法直接设置断点,
所以 HotSpot 增加了以下参数来方便开发人员调试解释器。
-XX +TraceBytecodes-XX StopInterpreterAt= n
这组参数的作用是当遇到序号为< n >的字节码指令时,便会中断程序执行,进入断点
调试。在调试解释器部分代码时,把这两个参数加到 gamma 后面即可。
最后,还需要在 环境 窗口中设置环境变量,也就是前面 env.sh 脚本所设置的那几个环
境变量,如图 1-10 所示。
1-10 NetBeans 中创建 HotSpot 项目( 3
完成以上配置之后,一个可修改、编译、调试的 HotSpot 工程就完全建立起来了,启动 器的执行入口是 java.c main ()方法,读者可以设置断点单步跟踪,如图 1-11 所示。
1-11 NetBeans 中创建 HotSpot 项目( 4
由于 HotSpot 的源码比较长, C/C++ 文件数量也很多,为了便于读者阅读,所以代码清单
1-3 给出了各个目录中代码的主要用途,供读者参考。
代码清单 1-3 HotSpot 源码结构
[1] [1] 该目录结构由 RednaxelaFX 整理: http://hllvm.group.iteye.com/group/topic/26998 1.7 本章小结
本章介绍了 Java 技术体系的过去、现在以及未来的一些发展趋势,并通过实战介绍了如
何自己来独立编译一个 OpenJDK 7 。作为全书的引言部分,本章建立了后文研究所必需的环
境。在了解 Java 技术的来龙去脉后,后面章节将分为 4 部分去介绍 Java 在内存管理、 Class
件结构与执行引擎、编译器优化及多线程并发方面的实现原理。 第二部分 自动内存管理机制
2 Java 内存区域与内存溢出异常
3 章 垃圾收集器与内存分配策略
4 章 虚拟机性能监控与故障处理工具
5 章 调优案例分析与实战 2 Java 内存区域与内存溢出异常
Java C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的 高墙 ,墙外面的人想
进去,墙里面的人却想出来。
2.1 概述
对于从事 C C++ 程序开发的开发人员来说,在内存管理领域,他们既是拥有最高权力
皇帝 又是从事最基础工作的 劳动人民 ”—— 既拥有每一个对象的 所有权 ,又担负着每
一个对象生命开始到终结的维护责任。
对于 Java 程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个 new
作去写配对的 delete/free 代码,不容易出现内存泄漏和内存溢出问题,由虚拟机管理内存这
一切看起来都很美好。不过,也正是因为 Java 程序员把内存控制的权力交给了 Java 虚拟机,
一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误
将会成为一项异常艰难的工作。
本章是第二部分的第 1 章,笔者将从概念上介绍 Java 虚拟机内存的各个区域,讲解这些
区域的作用、服务对象以及其中可能产生的问题,这是翻越虚拟机内存管理这堵围墙的第一
步。 2.2 运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区
域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而
存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据《 Java 虚拟机规范( Java
SE 7 版)》的规定, Java 虚拟机所管理的内存将会包括以下几个运行时数据区域,如图 2-1
示。
2-1 Java 虚拟机运行时数据区
2.2.1 程序计数器
程序计数器( Program Counter Register )是一块较小的内存空间,它可以看作是当前线
程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能
会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选
取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需
要依赖这个计数器来完成。
由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,
在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线
程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立
的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为 线程私
的内存。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指
令的地址;如果正在执行的是 Native 方法,这个计数器值则为空( Undefined )。此内存区域
是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。 2.2.2 Java 虚拟机栈
与程序计数器一样, Java 虚拟机栈( Java Virtual Machine Stacks )也是线程私有的,它的
生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时
都会创建一个栈帧( Stack Frame
[1]
)用于存储局部变量表、操作数栈、动态链接、方法出口
等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出
栈的过程。
经常有人把 Java 内存区分为堆内存( Heap )和栈内存( Stack ),这种分法比较粗
糙, Java 内存区域的划分实际上远比这复杂。这种划分方式的流行只能说明大多数程序员最
关注的、与对象内存分配关系最密切的内存区域是这两块。其中所指的 笔者在后面会专
门讲述,而所指的 就是现在讲的虚拟机栈,或者说是虚拟机栈中局部变量表部分。
局部变量表存放了编译期可知的各种基本数据类型( boolean byte char short int
float long double )、对象引用( reference 类型,它不等同于对象本身,可能是一个指向对
象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和
returnAddress 类型(指向了一条字节码指令的地址)。
其中 64 位长度的 long double 类型的数据会占用 2 个局部变量空间( Slot ),其余的数据
类型只占用 1 个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这
个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变
量表的大小。
Java 虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚
拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展(当前大部
分的 Java 虚拟机都可动态扩展,只不过 Java 虚拟机规范中也允许固定长度的虚拟机栈),如
果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
[1] 栈帧是方法运行时的基础数据结构,在本书的第 8 章中会对帧进行详细讲解。 2.2.3 本地方法栈
本地方法栈( Native Method Stack )与虚拟机栈所发挥的作用是非常相似的,它们之间
的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚
拟机使用到的 Native 方法服务。在虚拟机规范中对本地方法栈中方法使用的语言、使用方式
与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如
Sun HotSpot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法
栈区域也会抛出 StackOverflowError OutOfMemoryError 异常。 2.2.4 Java
对于大多数应用来说, Java 堆( Java Heap )是 Java 虚拟机所管理的内存中最大的一块。
Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就
是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在 Java 虚拟机规范中的描
述是:所有的对象实例以及数组都要在堆上分配
[1]
,但是随着 JIT 编译器的发展与逃逸分析技
术逐渐成熟,栈上分配、标量替换
[2]
优化技术将会导致一些微妙的变化发生,所有的对象都
分配在堆上也渐渐变得不是那么 绝对 了。
Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做 “GC Garbage
Collected Heap ,幸好国内没翻译成 垃圾堆 )。从内存回收的角度来看,由于现在收集器基
本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有
Eden 空间、 From Survivor 空间、 To Survivor 空间等。从内存分配的角度来看,线程共享的
Java 堆中可能划分出多个线程私有的分配缓冲区( Thread Local Allocation Buffer,TLAB )。不
过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划
分的目的是为了更好地回收内存,或者更快地分配内存。在本章中,我们仅仅针对内存区域
的作用进行讨论, Java 堆中的上述各个区域的分配、回收等细节将是第 3 章的主题。
根据 Java 虚拟机规范的规定, Java 堆可以处于物理上不连续的内存空间中,只要逻辑上
是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是
可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx -Xms 控制)。如
果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError
常。
[1] Java 虚拟机规范中的原文: The heap is the runtime data area from which memory for all class
instances and arrays is allocated
[2] 逃逸分析与标量替换的相关内容,参见第 11 章相关内容。 2.2.5 方法区
方法区( Method Area )与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚
拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规
范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap (非堆),目的应
该是与 Java 堆区分开来。
对于习惯在 HotSpot 虚拟机上开发、部署程序的开发者来说,很多人都更愿意把方法区
称为 永久代 Permanent Generation ),本质上两者并不等价,仅仅是因为 HotSpot 虚拟机的
设计团队选择把 GC 分代收集扩展至方法区,或者说使用永久代来实现方法区而已,这样
HotSpot 的垃圾收集器可以像管理 Java 堆一样管理这部分内存,能够省去专门为方法区编写内
存管理代码的工作。对于其他虚拟机(如 BEA JRockit IBM J9 等)来说是不存在永久代的概
念的。原则上,如何实现方法区属于虚拟机实现细节,不受虚拟机规范约束,但使用永久代
来实现方法区,现在看来并不是一个好主意,因为这样更容易遇到内存溢出问题(永久代
-XX MaxPermSize 的上限, J9 JRockit 只要没有触碰到进程可用内存的上限,例如 32 位系
统中的 4GB ,就不会出现问题),而且有极少数方法(例如 String.intern ())会因这个原因
导致不同虚拟机下有不同的表现。因此,对于 HotSpot 虚拟机,根据官方发布的路线图信
息,现在也有放弃永久代并逐步改为采用 Native Memory 来实现方法区的规划了
[1]
,在目前已
经发布的 JDK 1.7 HotSpot 中,已经把原本放在永久代的字符串常量池移出。
Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以
选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个
区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样 永久 存在了。这区
域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回
成绩 比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确
实是必要的。在 Sun 公司的 BUG 列表中,曾出现过的若干个严重的 BUG 就是由于低版本的
HotSpot 虚拟机对此区域未完全回收而导致内存泄漏。
根据 Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出
OutOfMemoryError 异常。
[1] JEP 122-Remove the Permanent Generation http://openjdk.java.net/jeps/122 2.2.6 运行时常量池
运行时常量池( Runtime Constant Pool )是方法区的一部分。 Class 文件中除了有类的版
本、字段、方法、接口等描述信息外,还有一项信息是常量池( Constant Pool Table ),用于
存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常
量池中存放。
Java 虚拟机对 Class 文件每一部分(自然也包括常量池)的格式都有严格规定,每一个字
节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行,但对于运行
时常量池, Java 虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自
己的需要来实现这个内存区域。不过,一般来说,除了保存 Class 文件中描述的符号引用外,
还会把翻译出来的直接引用也存储在运行时常量池中
[1]
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性, Java 语言并不
要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方
法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较
多的便是 String 类的 intern ()方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申
请到内存时会抛出 OutOfMemoryError 异常。
[1] 关于 Class 文件格式和符号引用等概念可参见第 6 章。 2.2.7 直接内存
直接内存( Direct Memory )并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规
范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError
异常出现,所以我们放到这里一起讲解。
JDK 1.4 中新加入了 NIO New Input/Output )类,引入了一种基于通道( Channel )与缓
冲区( Buffer )的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储
Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著
提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是
会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限
制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略
直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),
从而导致动态扩展时出现 OutOfMemoryError 异常。 2.3 HotSpot 虚拟机对象探秘
介绍完 Java 虚拟机的运行时数据区之后,我们大致知道了虚拟机内存的概况,读者了解
了内存中放了些什么后,也许就会想更进一步了解这些虚拟机内存中的数据的其他细节,譬
如它们是如何创建、如何布局以及如何访问的。对于这样涉及细节的问题,必须把讨论范围
限定在具体的虚拟机和集中在某一个内存区域上才有意义。基于实用优先的原则,笔者以常
用的虚拟机 HotSpot 和常用的内存区域 Java 堆为例,深入探讨 HotSpot 虚拟机在 Java 堆中对象分
配、布局和访问的全过程。
2.3.1 对象的创建
Java 是一门面向对象的编程语言,在 Java 程序运行过程中无时无刻都有对象被创建出
来。在语言层面上,创建对象(例如克隆、反序列化)通常仅仅是一个 new 关键字而已,而
在虚拟机中,对象(文中讨论的对象限于普通 Java 对象,不包括数组和 Class 对象等)的创建
又是怎样一个过程呢?
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一
个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没
有,那必须先执行相应的类加载过程,本书第 7 章将探讨这部分内容的细节。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类
加载完成后便可完全确定(如何确定将在 2.3.2 节中介绍),为对象分配空间的任务等同于把
一块确定大小的内存从 Java 堆中划分出来。假设 Java 堆中内存是绝对规整的,所有用过的内
存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配
内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称
指针碰撞 Bump the Pointer )。如果 Java 堆中的内存并不是规整的,已使用的内存和空
闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记
录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,
并更新列表上的记录,这种分配方式称为 空闲列表 Free
List )。选择哪种分配方式由
Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决
定。因此,在使用 Serial ParNew 等带 Compact 过程的收集器时,系统采用的分配算法是指针
碰撞,而使用 CMS 这种基于 Mark-Sweep 算法的收集器时,通常采用空闲列表。
除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常
频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,
可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来
分配内存的情况。解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理
—— 实际上虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性;另一种是把内存分
配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内
存,称为本地线程分配缓冲( Thread Local Allocation Buffer,TLAB )。哪个线程要分配内
存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定。
虚拟机是否使用 TLAB ,可以通过 -XX +/-UseTLAB 参数来设定。
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),
如果使用 TLAB ,这一工作过程也可以提前至 TLAB 分配时进行。这一步操作保证了对象的实 例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应
的零值。
接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找
到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对
象头( Object Header )之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对
象头会有不同的设置方式。关于对象头的具体内容,稍后再做详细介绍。
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java
序的视角来看,对象创建才刚刚开始 —— init >方法还没有执行,所有的字段都还为零。
所以,一般来说(由字节码中是否跟随 invokespecial 指令所决定),执行 new 指令之后会接着
执行< init >方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完
全产生出来。
下面的代码清单 2-1 HotSpot 虚拟机 bytecodeInterpreter.cpp 中的代码片段(这个解释器实
现很少有机会实际使用,因为大部分平台上都使用模板解释器;当代码通过 JIT 编译器执行
时差异就更大了。不过,这段代码用于了解 HotSpot 的运作过程是没有什么问题的)。
代码清单 2-1 HotSpot 解释器的代码片段
// 确保常量池中存放的是已解释的类
if (! constants- tag
_
at index .is
_
unresolved
_
klass ()) {
// 断言确保是 klassOop instanceKlassOop (这部分下一节介绍)
oop entry= klassOop *constants- obj_at_addr index );
assert entry- is_klass (), "Should be resolved klass" );
klassOop k_entry= klassOop entry
assert k_entry- klass_part () - oop_is_instance (), "Should be instanceKlass" );
instanceKlass * ik= instanceKlass* k
_
entry- klass
_
part ();
// 确保对象所属类型已经经过初始化阶段
if ik- is_initialized ()&& ik- can_be_fastpath_allocated ())
{
// 取对象长度
size_t obj_size=ik- size_helper ();
oop result=NULL
// 记录是否需要将对象所有字段置零值
bool need
_
zero= ZeroTLAB
// 是否在 TLAB 中分配对象
if UseTLAB {
result= oop THREAD- tlab () .allocate obj_size );
}
if result==NULL {
need
_
zero=true
// 直接在 eden 中分配对象
retry
HeapWord * compare_to=*Universe heap () - top_addr ();
HeapWord * new
_
top=compare
_
to+obj
_
size
/*cmpxchg x86 中的 CAS 指令,这里是一个 C++ 方法,通过 CAS 方式分配空间,如果并发失败,
转到 retry 中重试,直至成功分配为止 */
if new_top =*Universe heap () - end_addr ()) {
if Atomic cmpxchg_ptr new_top,Universe heap () - top_addr (), compare_to )! =compare_to {
goto retry
}
result= oop compare_to
}
}
if result =NULL {
// 如果需要,则为对象初始化零值
if need_zero {
HeapWord * to_zero= HeapWord* result+sizeof oopDesc /oopSize
obj_size-=sizeof oopDesc /oopSize
if obj_size 0 {
memset to_zero 0 obj_size * HeapWordSize );
}
}
// 根据是否启用偏向锁来设置对象头信息
if UseBiasedLocking {
result- set_mark ik- prototype_header ());
}else{
result- set_mark markOopDesc prototype ());
}
result- set_klass_gap 0 );
result- set
_
klass k
_
entry );
// 将对象引用入栈,继续执行下一条指令
SET_STACK_OBJECT result 0 );
UPDATE_PC_AND_TOS_AND_CONTINUE 3 1 );
}
}
} 2.3.2 对象的内存布局
HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头( Header )、
实例数据( Instance Data )和对齐填充( Padding )。
HotSpot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,
如哈希码( HashCode )、 GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 、偏向时
间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32bit
64bit ,官方称它为 “Mark Word” 。对象需要存储的运行时数据很多,其实已经超出了 32 位、
64 Bitmap 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储
成本,考虑到虚拟机的空间效率, Mark Word 被设计成一个非固定的数据结构以便在极小的
空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如,在 32 位的
HotSpot 虚拟机中,如果对象处于未被锁定的状态下,那么 Mark Word 32bit 空间中的 25bit
于存储对象哈希码, 4bit 用于存储对象分代年龄, 2bit 用于存储锁标志位, 1bit 固定为 0 ,而在
其他状态(轻量级锁定、重量级锁定、 GC 标记、可偏向)下对象的存储内容见表 2-1
对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指
针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型
指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,这点将在 2.3.3 节讨论。
另外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因
为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中
却无法确定数组的大小。
代码清单 2-2 HotSpot 虚拟机 markOop.cpp 中的代码(注释)片段,它描述了 32bit Mark
Word 的存储状态。
代码清单 2-2 markOop.cpp 片段
//Bit-format of an object header most significant first,big endian layout below ):
//32 bits
//--------
//hash 25------------ |age 4 biased_lock 1 lock 2 normal object
//JavaThread* 23 epoch 2 age 4 biased_lock 1 lock 2 biased object
//size 32------------------------------------------ | CMS free block
//PromotedObject* 29---------- |promo_bits 3----- | CMS promoted object
接下来的实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类
型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。这部分 的存储顺序会受到虚拟机分配策略参数( FieldsAllocationStyle )和字段在 Java 源码中定义顺
序的影响。 HotSpot 虚拟机默认的分配策略为 longs/doubles ints shorts/chars
bytes/booleans oops Ordinary Object Pointers ),从分配策略中可以看出,相同宽度的字段
总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之
前。如果 CompactFields 参数值为 true (默认为 true ),那么子类之中较窄的变量也可能会插入
到父类变量的空隙之中。
第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。
由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说,
就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数( 1 倍或者 2 倍),
因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。 2.3.3 对象的访问定位
建立对象是为了使用对象,我们的 Java 程序需要通过栈上的 reference 数据来操作堆上的
具体对象。由于 reference 类型在 Java 虚拟机规范中只规定了一个指向对象的引用,并没有定
义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是
取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种。
如果使用句柄访问的话,那么 Java 堆中将会划分出一块内存来作为句柄池, reference
存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信
息,如图 2-2 所示。
2-2 通过句柄访问对象
如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的
相关信息,而 reference 中存储的直接就是对象地址,如图 2-3 所示。 2-3 通过直接指针访问对象
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳
定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中
的实例数据指针,而 reference 本身不需要修改。
使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,
由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成
本。就本书讨论的主要虚拟机 Sun HotSpot 而言,它是使用第二种方式进行对象访问的,但从
整个软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见。 2.4 实战: OutOfMemoryError 异常
Java 虚拟机规范的描述中,除了程序计数器外,虚拟机内存的其他几个运行时区域都
有发生 OutOfMemoryError (下文称 OOM )异常的可能,本节将通过若干实例来验证异常发生
的场景(代码清单 2-3 ~代码清单 2-9 的几段简单代码),并且会初步介绍几个与内存相关的
最基本的虚拟机参数。
本节内容的目的有两个:第一,通过代码验证 Java 虚拟机规范中描述的各个运行时区域
存储的内容;第二,希望读者在工作中遇到实际的内存溢出异常时,能根据异常的信息快速
判断是哪个区域的内存溢出,知道什么样的代码可能会导致这些区域内存溢出,以及出现这
些异常后该如何处理。
下文代码的开头都注释了执行时所需要设置的虚拟机启动参数(注释中 “VM Args” 后面
跟着的参数),这些参数对实验的结果有直接影响,读者调试代码的时候千万不要忽略。如
果读者使用控制台命令来执行程序,那直接跟在 Java 命令之后书写就可以。如果读者使用
Eclipse IDE ,则可以参考图 2-4 Debug/Run 页签中的设置。
2-4 Eclipse Debug 页签中设置虚拟机参数 下文的代码都是基于 Sun 公司的 HotSpot 虚拟机运行的,对于不同公司的不同版本的虚拟
机,参数和程序运行的结果可能会有所差别。
2.4.1 Java 堆溢出
Java 堆用于存储对象实例,只要不断地创建对象,并且保证 GC Roots 到对象之间有可达
路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生
内存溢出异常。
代码清单 2-3 中代码限制 Java 堆的大小为 20MB ,不可扩展(将堆的最小值 -Xms 参数与最
大值 -Xmx 参数设置为一样即可避免堆自动扩展),通过参数 -XX
+HeapDumpOnOutOfMemoryError 可以让虚拟机在出现内存溢出异常时 Dump 出当前的内存堆
转储快照以便事后进行分析
[1]
代码清单 2-3 Java 堆内存溢出异常测试
/**
*VM Args -Xms20m-Xmx20m-XX +HeapDumpOnOutOfMemoryError
*@author zzm
*/
public class HeapOOM{
static class OOMObject{
}
public static void main String[]args {
List OOMObject list=new ArrayList OOMObject >();
while true {
list.add new OOMObject ());
}
}
}
运行结果:
java.lang.OutOfMemoryError Java heap space
Dumping heap to java_pid3404.hprof……
Heap dump file created[22045981 bytes in 0.663 secs]
Java 堆内存的 OOM 异常是实际应用中常见的内存溢出异常情况。当出现 Java 堆内存溢出
时,异常堆栈信息 “java.lang.OutOfMemoryError” 会跟着进一步提示 “Java heap space”
要解决这个区域的异常,一般的手段是先通过内存映像分析工具(如 Eclipse
Memory
Analyzer )对 Dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也
就是要先分清楚到底是出现了内存泄漏( Memory
Leak )还是内存溢出( Memory
Overflow )。图 2-5 显示了使用 Eclipse Memory Analyzer 打开的堆转储快照文件。 2-5 使用 Eclipse Memory Analyzer 打开的堆转储快照文件
如果是内存泄露,可进一步通过工具查看泄露对象到 GC Roots 的引用链。于是就能找到
泄露对象是通过怎样的路径与 GC Roots 相关联并导致垃圾收集器无法自动回收它们的。掌握
了泄露对象的类型信息及 GC
Roots 引用链的信息,就可以比较准确地定位出泄露代码的位
置。
如果不存在泄露,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚
拟机的堆参数( -Xmx -Xms ),与机器物理内存对比看是否还可以调大,从代码上检查是
否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消
耗。
以上是处理 Java 堆内存问题的简单思路,处理这些问题所需要的知识、工具与经验是后
3 章的主题。
[1] 关于堆转储快照文件分析方面的内容,可参见第 4 章。 2.4.2 虚拟机栈和本地方法栈溢出
由于在 HotSpot 虚拟机中并不区分虚拟机栈和本地方法栈,因此,对于 HotSpot 来说,虽
-Xoss 参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只由 -Xss 参数设定。
关于虚拟机栈和本地方法栈,在 Java 虚拟机规范中描述了两种异常:
如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。
这里把异常分成两种情况,看似更加严谨,但却存在着一些互相重叠的地方:当栈空间
无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情
的两种描述而已。
在笔者的实验中,将实验范围限制于单线程中的操作,尝试了下面两种方法均无法让虚
拟机产生 OutOfMemoryError 异常,尝试的结果都是获得 StackOverflowError 异常,测试代码如
代码清单 2-4 所示。
使用 -Xss 参数减少栈内存容量。结果:抛出 StackOverflowError 异常,异常出现时输出的
堆栈深度相应缩小。
定义了大量的本地变量,增大此方法帧中本地变量表的长度。结果:抛出
StackOverflowError 异常时输出的堆栈深度相应缩小。
代码清单 2-4 虚拟机栈和本地方法栈 OOM 测试(仅作为第 1 点测试程序)
/**
*VM Args -Xss128k
*@author zzm
*/
public class JavaVMStackSOF{
private int stackLength=1
public void stackLeak () {
stackLength++
stackLeak ();
}
public static void main String[]args throws Throwable{
JavaVMStackSOF oom=new JavaVMStackSOF ();
try{
oom.stackLeak ();
}catch Throwable e {
System.out.println "stack length "+oom.stackLength );
throw e
}
}
}
运行结果:
stack length 2402
Exception in thread"main"java.lang.StackOverflowError
at org.fenixsoft.oom.VMStackSOF.leak VMStackSOF.java 20
at org.fenixsoft.oom.VMStackSOF.leak VMStackSOF.java 21
at org.fenixsoft.oom.VMStackSOF.leak VMStackSOF.java 21
…… 后续异常堆栈信息省略
实验结果表明:在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无
法分配的时候,虚拟机抛出的都是 StackOverflowError 异常。
如果测试时不限于单线程,通过不断地建立线程的方式倒是可以产生内存溢出异常,如
代码清单 2-5 所示。但是这样产生的内存溢出异常与栈空间是否足够大并不存在任何联系, 或者准确地说,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出
异常。
其实原因不难理解,操作系统分配给每个进程的内存是有限制的,譬如 32 位的 Windows
限制为 2GB 。虚拟机提供了参数来控制 Java 堆和方法区的这两部分内存的最大值。剩余的内
存为 2GB (操作系统限制)减去 Xmx (最大堆容量),再减去 MaxPermSize (最大方法区容
量),程序计数器消耗内存很小,可以忽略掉。如果虚拟机进程本身耗费的内存不计算在
内,剩下的内存就由虚拟机栈和本地方法栈 瓜分 了。每个线程分配到的栈容量越大,可以
建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。
这一点读者需要在开发多线程的应用时特别注意,出现 StackOverflowError 异常时有错误
堆栈可以阅读,相对来说,比较容易找到问题的所在。而且,如果使用虚拟机默认参数,栈
深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说在大多数情
况下)达到 1000 2000 完全没有问题,对于正常的方法调用(包括递归),这个深度应该完
全够用了。但是,如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换 64 位虚
拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。如果没有这方面的
处理经验,这种通过 减少内存 的手段来解决内存溢出的方式会比较难以想到。
代码清单 2-5 创建线程导致内存溢出异常
/**
*VM Args -Xss2M (这时候不妨设置大些)
*@author zzm
*/
public class JavaVMStackOOM{
private void dontStop () {
while true {
}
}
public void stackLeakByThread () {
while true {
Thread thread=new Thread new Runnable () {
@Override
public void run () {
dontStop ();
}
} );
thread.start ();
}
}
public static void main String[]args throws Throwable{
JavaVMStackOOM oom=new JavaVMStackOOM ();
oom.stackLeakByThread ();
}
}
注意 特别提示一下,如果读者要尝试运行上面这段代码,记得要先保存当前的工作。
由于在 Windows 平台的虚拟机中, Java 的线程是映射到操作系统的内核线程上的
[1]
,因此上述
代码执行时有较大的风险,可能会导致操作系统假死。
运行结果:
Exception in thread"main"java.lang.OutOfMemoryError unable to create new native thread
[1] 关于虚拟机线程实现方面的内容可以参考本书第 12 章。 2.4.3 方法区和运行时常量池溢出
由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。前
面提到 JDK 1.7 开始逐步 去永久代 的事情,在此就以测试代码观察一下这件事对程序的实际
影响。
String.intern ()是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等
于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包
含的字符串添加到常量池中,并且返回此 String 对象的引用。在 JDK 1.6 及之前的版本中,由
于常量池分配在永久代内,我们可以通过 -XX PermSize -XX MaxPermSize 限制方法区大
小,从而间接限制其中常量池的容量,如代码清单 2-6 所示。
代码清单 2-6 运行时常量池导致的内存溢出异常
/**
*VM Args -XX PermSize=10M-XX MaxPermSize=10M
*@author zzm
*/
public class RuntimeConstantPoolOOM{
public static void main String[]args {
// 使用 List 保持着常量池引用,避免 Full GC 回收常量池行为
List String list=new ArrayList String >();
//10MB PermSize integer 范围内足够产生 OOM
int i=0
while true {
list.add String.valueOf i++ .intern ());
}
}
}
运行结果:
Exception in thread"main"java.lang.OutOfMemoryError PermGen space
at java.lang.String.intern Native Method
at org.fenixsoft.oom.RuntimeConstantPoolOOM.main RuntimeConstantPoolOOM.java 18
从运行结果中可以看到,运行时常量池溢出,在 OutOfMemoryError 后面跟随的提示信息
“PermGen
space” ,说明运行时常量池属于方法区( HotSpot 虚拟机中的永久代)的一部
分。
而使用 JDK 1.7 运行这段程序就不会得到相同的结果, while 循环将一直进行下去。关于
这个字符串常量池的实现问题,还可以引申出一个更有意思的影响,如代码清单 2-7 所示。
代码清单 2-7 String.intern ()返回引用的测试
public class RuntimeConstantPoolOOM{
public static void main String[]args {
public static void main String[]args {
String str1=new StringBuilder " 计算机 " .append " 软件 " .toString ();
System.out.println str1.intern () ==str1 );
String str2=new StringBuilder "ja" .append "va" .toString ();
System.out.println str2.intern () ==str2 );
}
}
}
这段代码在 JDK 1.6 中运行,会得到两个 false ,而在 JDK 1.7 中运行,会得到一个 true 和一
false 。产生差异的原因是:在 JDK 1.6 中, intern ()方法会把首次遇到的字符串实例复制
到永久代中,返回的也是永久代中这个字符串实例的引用,而由 StringBuilder 创建的字符串
实例在 Java 堆上,所以必然不是同一个引用,将返回 false 。而 JDK
1.7 (以及部分其他虚拟
机,例如 JRockit )的 intern ()实现不会再复制实例,只是在常量池中记录首次出现的实例 引用,因此 intern ()返回的引用和由 StringBuilder 创建的那个字符串实例是同一个。对 str2
较返回 false 是因为 “java” 这个字符串在执行 StringBuilder.toString ()之前已经出现过,字符串
常量池中已经有它的引用了,不符合 首次出现 的原则,而 计算机软件 这个字符串则是首
次出现的,因此返回 true
方法区用于存放 Class 的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述
等。对于这些区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。虽
然直接使用 Java SE API 也可以动态产生类(如反射时的 GeneratedConstructorAccessor 和动态
代理等),但在本次实验中操作起来比较麻烦。在代码清单 2-8 中,笔者借助 CGLib
[1]
直接操
作字节码运行时生成了大量的动态类。
值得特别注意的是,我们在这个例子中模拟的场景并非纯粹是一个实验,这样的应用经
常会出现在实际应用中:当前的很多主流框架,如 Spring Hibernate ,在对类进行增强时,
都会使用到 CGLib 这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的
Class 可以加载入内存。另外, JVM 上的动态语言(例如 Groovy 等)通常都会持续创建类来实
现语言的动态性,随着这类语言的流行,也越来越容易遇到与代码清单 2-8 相似的溢出场
景。
代码清单 2-8 借助 CGLib 使方法区出现内存溢出异常
/**
*VM Args -XX PermSize=10M-XX MaxPermSize=10M
*@author zzm
*/
public class JavaMethodAreaOOM{
public static void main String[]args {
while true {
Enhancer enhancer=new Enhancer ();
enhancer.setSuperclass OOMObject.class );
enhancer.setUseCache false );
enhancer.setCallback new MethodInterceptor () {
public Object intercept Object obj,Method method,Object[]args,MethodProxy proxy throws Throwable{
return proxy.invokeSuper obj,args );
}
} );
enhancer.create ();
}
}
static class OOMObject{
}
}
运行结果:
Caused by java.lang.OutOfMemoryError PermGen space
at java.lang.ClassLoader.defineClass1 Native Method
at java.lang.ClassLoader.defineClassCond ClassLoader.java 632
at java.lang.ClassLoader.defineClass ClassLoader.java 616
……8 more
方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是
比较苛刻的。在经常动态生成大量 Class 的应用中,需要特别注意类的回收状况。这类场景除
了上面提到的程序使用了 CGLib 字节码增强和动态语言之外,常见的还有:大量 JSP 或动态产
JSP 文件的应用( JSP 第一次运行时需要编译为 Java 类)、基于 OSGi 的应用(即使是同一个
类文件,被不同的加载器加载也会视为不同的类)等。
[1] CGLib 开源项目: http://cglib.sourceforge.net/ 2.4.4 本机直接内存溢出
DirectMemory 容量可通过 -XX MaxDirectMemorySize 指定,如果不指定,则默认与 Java
堆最大值( -Xmx 指定)一样,代码清单 2-9 越过了 DirectByteBuffer 类,直接通过反射获取
Unsafe 实例进行内存分配( Unsafe 类的 getUnsafe ()方法限制了只有引导类加载器才会返回
实例,也就是设计者希望只有 rt.jar 中的类才能使用 Unsafe 的功能)。因为,虽然使用
DirectByteBuffer 分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申
请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方
法是 unsafe.allocateMemory ()。
代码清单 2-9 使用 unsafe 分配本机内存
/**
*VM Args -Xmx20M-XX MaxDirectMemorySize=10M
*@author zzm
*/
public class DirectMemoryOOM{
private static final int_1MB=1024*1024
public static void main String[]args throws Exception{
Field unsafeField=Unsafe.class.getDeclaredFields () [0]
unsafeField.setAccessible true );
Unsafe unsafe= Unsafe unsafeField.get null );
while true {
unsafe.allocateMemory _1MB );
}
}
}
运行结果:
Exception in thread"main"java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory Native Method
at org.fenixsoft.oom.DMOOM.main DMOOM.java 20
DirectMemory 导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见明显
的异常,如果读者发现 OOM 之后 Dump 文件很小,而程序中又直接或间接使用了 NIO ,那就
可以考虑检查一下是不是这方面的原因。 2.5 本章小结
通过本章的学习,我们明白了虚拟机中的内存是如何划分的,哪部分区域、什么样的代
码和操作可能导致内存溢出异常。虽然 Java 有垃圾收集机制,但内存溢出异常离我们仍然并
不遥远,本章只是讲解了各个区域出现内存溢出异常的原因,第 3 章将详细讲解 Java 垃圾收
集机制为了避免内存溢出异常的出现都做了哪些努力。 3 章 垃圾收集器与内存分配策略
Java C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的 高墙 ,墙外面的人想
进去,墙里面的人却想出来。
3.1 概述
说起垃圾收集( Garbage Collection,GC ),大部分人都把这项技术当做 Java 语言的伴生产
物。事实上, GC 的历史比 Java 久远, 1960 年诞生于 MIT Lisp 是第一门真正使用内存动态分
配和垃圾收集技术的语言。当 Lisp 还在胚胎时期时,人们就在思考 GC 需要完成的 3 件事情:
哪些内存需要回收?
什么时候回收?
如何回收?
经过半个多世纪的发展,目前内存的动态分配与内存回收技术已经相当成熟,一切看起
来都进入了 自动化 时代,那为什么我们还要去了解 GC 和内存分配呢?答案很简单:当需
要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我
们就需要对这些 自动化 的技术实施必要的监控和调节。
把时间从半个多世纪以前拨回到现在,回到我们熟悉的 Java 语言。第 2 章介绍了 Java 内存
运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生,随
线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个
栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由 JIT 编译器
进行一些优化,但在本章基于概念模型的讨论中,大体上可以认为是编译期可知的),因此
这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问
题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而 Java 堆和方法区则不一
样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也
可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配
和回收都是动态的,垃圾收集器所关注的是这部分内存,本章后续讨论中的 内存 分配与回
收也仅指这一部分内存。 3.2 对象已死吗
在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一
件事情就是要确定这些对象之中哪些还 存活 着,哪些已经 死去 (即不可能再被任何途径
使用的对象)。
3.2.1 引用计数算法
很多教科书判断对象是否存活的算法是这样的:给对象中添加一个引用计数器,每当有
一个地方引用它时,计数器值就加 1 ;当引用失效时,计数器值就减 1 ;任何时刻计数器为 0
的对象就是不可能再被使用的。作者面试过很多的应届生和一些有多年工作经验的开发人
员,他们对于这个问题给予的都是这个答案。
客观地说,引用计数算法( Reference Counting )的实现简单,判定效率也很高,在大部
分情况下它都是一个不错的算法,也有一些比较著名的应用案例,例如微软公司的
COM Component Object Model )技术、使用 ActionScript 3 FlashPlayer Python 语言和在游
戏脚本领域被广泛应用的 Squirrel 中都使用了引用计数算法进行内存管理。但是,至少主流
Java 虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象
之间相互循环引用的问题。
举个简单的例子,请看代码清单 3-1 中的 testGC ()方法:对象 objA objB 都有字段
instance ,赋值令 objA.instance=objB objB.instance=objA ,除此之外,这两个对象再无任何引
用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引
用计数都不为 0 ,于是引用计数算法无法通知 GC 收集器回收它们。
代码清单 3-1 引用计数算法的缺陷
/**
*testGC ()方法执行后, objA objB 会不会被 GC 呢?
*@author zzm
*/
public class ReferenceCountingGC{
public Object instance=null
private static final int_1MB=1024*1024
/**
* 这个成员属性的唯一意义就是占点内存,以便能在 GC 日志中看清楚是否被回收过
*/
private byte[]bigSize=new byte[2*_1MB]
public static void testGC () {
ReferenceCountingGC objA=new ReferenceCountingGC ();
ReferenceCountingGC objB=new ReferenceCountingGC ();
objA.instance=objB
objB.instance=objA
objA=null
objB=null
// 假设在这行发生 GC,objA objB 是否能被回收?
System.gc ();
}
}
运行结果:
[F u l l G C S y s t e m [T e n u r e d 0 K- 2 1 0 K 1 0 2 4 0 K ), 0.0 1 4 9 1 4 2 s e c s]4603K- 210K 19456K ), [Perm 2999K-
2999K 21248K ] 0.0150007 secs][Times user=0.01 sys=0.00 real=0.02 secs]
Heap
def new generation total 9216K,used 82K[0x00000000055e0000 0x0000000005fe0000 0x0000000005fe0000
Eden space 8192K 1%used[0x00000000055e0000 0x00000000055f4850 0x0000000005de0000
from space 1024K 0%used[0x0000000005de0000 0x0000000005de0000 0x0000000005ee0000
to space 1024K 0%used[0x0000000005ee0000 0x0000000005ee0000 0x0000000005fe0000
tenured generation total 10240K,used 210K[0x0000000005fe0000 0x00000000069e0000 0x00000000069e0000
the space 10240K 2%used[0x0000000005fe0000 0x0000000006014a18 0x0000000006014c00 0x00000000069e0000
compacting perm gen total 21248K,used 3016K[0x00000000069e0000 0x0000000007ea0000 0x000000000bde0000
the space 21248K 14%used[0x00000000069e0000 0x0000000006cd2398 0x0000000006cd2400 0x0000000007ea0000
No shared spaces configured. 从运行结果中可以清楚看到, GC 日志中包含 “4603K- 210K” ,意味着虚拟机并没有因
为这两个对象互相引用就不回收它们,这也从侧面说明虚拟机并不是通过引用计数算法来判
断对象是否存活的。 3.2.2 可达性分析算法
在主流的商用程序语言( Java C# ,甚至包括前面提到的古老的 Lisp )的主流实现中,
都是称通过可达性分析( Reachability Analysis )来判定对象是否存活的。这个算法的基本思
路就是通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所
走过的路径称为引用链( Reference Chain ),当一个对象到 GC Roots 没有任何引用链相连
(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。如
3-1 所示,对象 object 5 object 6 object 7 虽然互相有关联,但是它们到 GC Roots 是不可达
的,所以它们将会被判定为是可回收的对象。
3-1 可达性分析算法判定对象是否可回收
Java 语言中,可作为 GC Roots 的对象包括下面几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中 JNI (即一般说的 Native 方法)引用的对象。 3.2.3 再谈引用
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引
用链是否可达,判定对象是否存活都与 引用 有关。在 JDK 1.2 以前, Java 中的引用的定义很
传统:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块
内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用
或者没有被引用两种状态,对于如何描述一些 食之无味,弃之可惜 的对象就显得无能为
力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存
空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这
样的应用场景。
JDK
1.2 之后, Java 对引用的概念进行了扩充,将引用分为强引用( Strong
Reference )、软引用( Soft
Reference )、弱引用( Weak
Reference )、虚引用( Phantom
Reference 4 种,这 4 种引用强度依次逐渐减弱。
强引用就是指在程序代码之中普遍存在的,类似 “Object obj=new Object () 这类的引
用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将
要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回
收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 之后,提供了 SoftReference 类来实
现软引用。
弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的
对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,
都会回收掉只被弱引用关联的对象。在 JDK
1.2 之后,提供了 WeakReference 类来实现弱引
用。
虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引
用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一
个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在
JDK 1.2 之后,提供了 PhantomReference 类来实现虚引用。 3.2.4 生存还是死亡
即使在可达性分析算法中不可达的对象,也并非是 非死不可 的,这时候它们暂时处
缓刑 阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达
性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,
筛选的条件是此对象是否有必要执行 finalize ()方法。当对象没有覆盖 finalize ()方法,或
finalize ()方法已经被虚拟机调用过,虚拟机将这两种情况都视为 没有必要执行
如果这个对象被判定为有必要执行 finalize ()方法,那么这个对象将会放置在一个叫做
F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行
它。这里所谓的 执行 是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做
的原因是,如果一个对象在 finalize ()方法中执行缓慢,或者发生了死循环(更极端的情
况),将很可能会导致 F-Queue 队列中其他对象永久处于等待,甚至导致整个内存回收系统
崩溃。 finalize ()方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象
进行第二次小规模的标记,如果对象要在 finalize ()中成功拯救自己 —— 只要重新与引用链
上的任何一个对象建立关联即可,譬如把自己( this 关键字)赋值给某个类变量或者对象的
成员变量,那在第二次标记时它将被移除出 即将回收 的集合;如果对象这时候还没有逃
脱,那基本上它就真的被回收了。从代码清单 3-2 中我们可以看到一个对象的 finalize ()被
执行,但是它仍然可以存活。
代码清单 3-2 一次对象自我拯救的演示
/**
* 此代码演示了两点:
*1. 对象可以在被 GC 时自我拯救。
*2. 这种自救的机会只有一次,因为一个对象的 finalize ()方法最多只会被系统自动调用一次
*@author zzm
*/
public class FinalizeEscapeGC{
public static FinalizeEscapeGC SAVE_HOOK=null
public void isAlive () {
System.out.println "yes,i am still alive :) " );
}
@Override
protected void finalize () throws Throwable{
super.finalize ();
System.out.println "finalize mehtod executed " );
FinalizeEscapeGC.SAVE_HOOK=this
}
public static void main String[]args throws Throwable{
SAVE HOOK=new FinalizeEscapeGC ();
// 对象第一次成功拯救自己
_
SAVE_HOOK=null
System.gc ();
// 因为 finalize 方法优先级很低,所以暂停 0.5 秒以等待它
Thread.sleep 500 );
if SAVE_HOOK =null {
SAVE_HOOK.isAlive ();
}else{
System.out.println "no,i am dead :( " );
}
// 下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK=null
System.gc ();
// 因为 finalize 方法优先级很低,所以暂停 0.5 秒以等待它
Thread.sleep 500 );
if SAVE_HOOK =null {
SAVE_HOOK.isAlive ();
}else{
System.out.println "no,i am dead :( " );
}
}
}
运行结果:
finalize mehtod executed
yes,i am still alive :)
no,i am dead :(
从代码清单 3-2 的运行结果可以看出, SAVE_HOOK 对象的 finalize ()方法确实被 GC
集器触发过,并且在被收集前成功逃脱了。 另外一个值得注意的地方是,代码中有两段完全一样的代码片段,执行结果却是一次逃
脱成功,一次失败,这是因为任何一个对象的 finalize ()方法都只会被系统自动调用一次,
如果对象面临下一次回收,它的 finalize ()方法不会被再次执行,因此第二段代码的自救行
动失败了。
需要特别说明的是,上面关于对象死亡时 finalize ()方法的描述可能带有悲情的艺术色
彩,笔者并不鼓励大家使用这种方法来拯救对象。相反,笔者建议大家尽量避免使用它,因
为它不是 C/C++ 中的析构函数,而是 Java 刚诞生时为了使 C/C++ 程序员更容易接受它所做出的
一个妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。有些教材中描
述它适合做 关闭外部资源 之类的工作,这完全是对这个方法用途的一种自我安慰。
finalize ()能做的所有工作,使用 try-finally 或者其他方式都可以做得更好、更及时,所以笔
者建议大家完全可以忘掉 Java 语言中有这个方法的存在。 3.2.5 回收方法区
很多人认为方法区(或者 HotSpot 虚拟机中的永久代)是没有垃圾收集的, Java 虚拟机规
范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集
性价比 一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以
回收 70% 95% 的空间,而永久代的垃圾收集效率远低于此。
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收
Java 堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串 “abc” 已经进入了
常量池中,但是当前系统没有任何一个 String 对象是叫做 “abc” 的,换句话说,就是没有任何
String 对象引用常量池中的 “abc” 常量,也没有其他地方引用了这个字面量,如果这时发生内
存回收,而且必要的话,这个 “abc” 常量就会被系统清理出常量池。常量池中的其他类(接
口)、方法、字段的符号引用也与此类似。
判定一个常量是否是 废弃常量 比较简单,而要判定一个类是否是 无用的类 的条件则
相对苛刻许多。类需要同时满足下面 3 个条件才能算是 无用的类
该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
加载该类的 ClassLoader 已经被回收。
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该
类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是 可以 ,而并不是
和对象一样,不使用了就必然会回收。是否对类进行回收, HotSpot 虚拟机提供了 -Xnoclassgc
参数进行控制,还可以使用 -verbose class 以及 -XX +TraceClassLoading -XX
+TraceClassUnLoading 查看类加载和卸载信息,其中 -verbose class -XX
+TraceClassLoading 可以在 Product 版的虚拟机中使用, -XX +TraceClassUnLoading 参数需要
FastDebug 版的虚拟机支持。
在大量使用反射、动态代理、 CGLib ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁
自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。 3.3 垃圾收集算法
由于垃圾收集算法的实现涉及大量的程序细节,而且各个平台的虚拟机操作内存的方法
又各不相同,因此本节不打算过多地讨论算法的实现,只是介绍几种算法的思想及其发展过
程。
3.3.1 标记 - 清除算法
最基础的收集算法是 标记 - 清除 Mark-Sweep )算法,如同它的名字一样,算法分
标记 清除 两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有
被标记的对象,它的标记过程其实在前一节讲述对象标记判定时已经介绍过了。之所以说它
是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到
的。它的主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是
空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程
序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾
收集动作。标记 清除算法的执行过程如图 3-2 所示。
3-2 “ 标记 - 清除 算法示意图 3.3.2 复制算法
为了解决效率问题,一种称为 复制 Copying )的收集算法出现了,它将可用内存按容
量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着
的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是
对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指
针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原
来的一半,未免太高了一点。复制算法的执行过程如图 3-3 所示。
3-3 复制算法示意图
现在的商业虚拟机都采用这种收集算法来回收新生代, IBM 公司的专门研究表明,新生
代中的对象 98% 朝生夕死 的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存
分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor
[1]
当回收时,将 Eden Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最
后清理掉 Eden 和刚才用过的 Survivor 空间。 HotSpot 虚拟机默认 Eden Survivor 的大小比例是
8:1 ,也就是每次新生代中可用内存空间为整个新生代容量的 90% 80%+10% ),只有 10%
的内存会被 浪费 。当然, 98% 的对象可回收只是一般场景下的数据,我们没有办法保证每
次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里
指老年代)进行分配担保( Handle Promotion )。
内存的分配担保就好比我们去银行借款,如果我们信誉很好,在 98% 的情况下都能按时
偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保
证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。内存的分配担保也
一样,如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象时,
这些对象将直接通过分配担保机制进入老年代。关于对新生代进行分配担保的内容,在本章 稍后在讲解垃圾收集器执行规则时还会再详细讲解。
[1] 这里需要说明一下,在 HotSpot 中的这种分代方式从最初就是这种布局,与 IBM 的研究并
没有什么实际联系。本书列举 IBM 的研究只是为了说明这种分代布局的意义所在。 3.3.3 标记 - 整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的
是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中
所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种 标记 - 整理 Mark-Compact )算法,标记过程
仍然与 标记 - 清除 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存
活的对象都向一端移动,然后直接清理掉端边界以外的内存, 标记 - 整理 算法的示意图如
3-4 所示。
3-4 “ 标记 - 整理 算法示意图 3.3.4 分代收集算法
当前商业虚拟机的垃圾收集都采用 分代收集 Generational Collection )算法,这种算
法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把 Java
分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代
中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付
出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间
对它进行分配担保,就必须使用 标记 清理 或者 标记 整理 算法来进行回收。 3.4 HotSpot 的算法实现
3.2 节和 3.3 节从理论上介绍了对象存活判定算法和垃圾收集算法,而在 HotSpot 虚拟机
上实现这些算法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行。
3.4.1 枚举根节点
从可达性分析中从 GC Roots 节点找引用链这个操作为例,可作为 GC Roots 的节点主要在
全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现
在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时
间。
另外,可达性分析对执行时间的敏感还体现在 GC 停顿上,因为这项分析工作必须在一
个能确保一致性的快照中进行 —— 这里 一致性 的意思是指在整个分析期间整个执行系统看
起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情
况,该点不满足的话分析结果准确性就无法得到保证。这点是导致 GC 进行时必须停顿所有
Java 执行线程( Sun 将这件事情称为 “Stop The World” )的其中一个重要原因,即使是在号称
(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。
由于目前的主流 Java 虚拟机使用的都是准确式 GC (这个概念在第 1 章介绍 Exact
VM
Classic VM 的改进时讲过),所以当执行系统停顿下来后,并不需要一个不漏地检查完所有
执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。在
HotSpot 的实现中,是使用一组称为 OopMap 的数据结构来达到这个目的的,在类加载完成的
时候, HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来,在 JIT 编译过程中,也
会在特定的位置记录下栈和寄存器中哪些位置是引用。这样, GC 在扫描时就可以直接得知
这些信息了。下面的代码清单 3-3 HotSpot Client VM 生成的一段 String.hashCode ()方法的
本地代码,可以看到在 0x026eb7a9 处的 call 指令有 OopMap 记录,它指明了 EBX 寄存器和栈中
偏移量为 16 的内存区域中各有一个普通对象指针( Ordinary Object Pointer )的引用,有效范
围为从 call 指令开始直到 0x026eb730 (指令流的起始位置) +142 OopMap 记录的偏移
量) =0x026eb7be ,即 hlt 指令为止。
代码清单 3-3 String.hashCode ()方法编译后的本地代码
[Verified Entry Point]
0x026eb730 mov%eax -0x8000 %esp
……
ImplicitNullCheckStub slow case
0x026eb7a9 call 0x026e83e0
OopMap{ebx=Oop[16]=Oop off=142}
*caload
-java.lang.String hashCode@48 line 1489
{runtime_call}
0x026eb7ae push$0x83c5c18
{external_word}
0x026eb7b3 call 0x026eb7b8
0x026eb7b8 pusha
0x026eb7b9 call 0x0822bec0 {runtime_call}
0x026eb7be hlt 3.4.2 安全点
OopMap 的协助下, HotSpot 可以快速且准确地完成 GC Roots 枚举,但一个很现实的问
题随之而来:可能导致引用关系变化,或者说 OopMap 内容变化的指令非常多,如果为每一
条指令都生成对应的 OopMap ,那将会需要大量的额外空间,这样 GC 的空间成本将会变得很
高。
实际上, HotSpot 也的确没有为每条指令都生成 OopMap ,前面已经提到,只是在 特定的
位置 记录了这些信息,这些位置称为安全点( Safepoint ),即程序执行时并非在所有地方都
能停顿下来开始 GC ,只有在到达安全点时才能暂停。 Safepoint 的选定既不能太少以致于让
GC 等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以,安全点的选定基
本上是以程序 是否具有让程序长时间执行的特征 为标准进行选定的 —— 因为每条指令执行
的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行, 长时间
执行 的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有
这些功能的指令才会产生 Safepoint
对于 Sefepoint ,另一个需要考虑的问题是如何在 GC 发生时让所有线程(这里不包括执行
JNI 调用的线程)都 到最近的安全点上再停顿下来。这里有两种方案可供选择:抢先式
中断( Preemptive Suspension )和主动式中断( Voluntary Suspension ),其中抢先式中断不需
要线程的执行代码主动去配合,在 GC 发生时,首先把所有线程全部中断,如果发现有线程
中断的地方不在安全点上,就恢复线程,让它 到安全点上。现在几乎没有虚拟机实现采
用抢先式中断来暂停线程从而响应 GC 事件。
而主动式中断的思想是当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单地设
置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。
轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。下面代码清
3-4 中的 test 指令是 HotSpot 生成的轮询指令,当需要暂停线程时,虚拟机把 0x160100 的内存
页设置为不可读,线程执行到 test 指令时就会产生一个自陷异常信号,在预先注册的异常处
理器中暂停线程实现等待,这样一条汇编指令便完成安全点轮询和触发线程中断。
代码清单 3-4 轮询指令
0x01b6d627 call 0x01b2b210 OopMap{[60]=Oop off=460}
*invokeinterface size
-Client1 main@113 line 23
{virtual_call}
0x01b6d62c nop
OopMap{[60]=Oop off=461}
*if_icmplt
-Client1 main@118 line 23
0x01b6d62d test%eax 0x160100 {poll}
0x01b6d633 mov 0x50 %esp ), %esi
0x01b6d637 cmp%eax %esi 3.4.3 安全区域
使用 Safepoint 似乎已经完美地解决了如何进入 GC 的问题,但实际情况却并不一定。
Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC Safepoint 。但是,
程序 不执行 的时候呢?所谓的程序不执行就是没有分配 CPU 时间,典型的例子就是线程处
Sleep 状态或者 Blocked 状态,这时候线程无法响应 JVM 的中断请求, 到安全的地方去
中断挂起, JVM 也显然不太可能等待线程重新被分配 CPU 时间。对于这种情况,就需要安全
区域( Safe Region )来解决。
安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方
开始 GC 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safepoint
在线程执行到 Safe Region 中的代码时,首先标识自己已经进入了 Safe Region ,那样,当
在这段时间里 JVM 要发起 GC 时,就不用管标识自己为 Safe Region 状态的线程了。在线程要离
Safe Region 时,它要检查系统是否已经完成了根节点枚举(或者是整个 GC 过程),如果完
成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开 Safe
Region 的信号为
止。
到此,笔者简要地介绍了 HotSpot 虚拟机如何去发起内存回收的问题,但是虚拟机如何
具体地进行内存回收动作仍然未涉及,因为内存回收如何进行是由虚拟机所采用的 GC 收集
器决定的,而通常虚拟机中往往不止有一种 GC 收集器。下面继续来看 HotSpot 中有哪些 GC
集器。 3.5 垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。 Java
虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚
拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应
用特点和要求组合出各个年代所使用的收集器。这里讨论的收集器基于 JDK 1.7 Update 14
后的 HotSpot 虚拟机(在这个版本中正式提供了商用的 G1 收集器,之前 G1 仍处于实验状
态),这个虚拟机包含的所有收集器如图 3-5 所示。
3-5 HotSpot 虚拟机的垃圾收集器
[1]
3-5 展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们
可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。接下
来笔者将逐一介绍这些收集器的特性、基本原理和使用场景,并重点分析 CMS G1 这两款
相对复杂的收集器,了解它们的部分运作细节。
在介绍这些收集器各自的特性之前,我们先来明确一个观点:虽然我们是在对各个收集
器进行比较,但并非为了挑选出一个最好的收集器。因为直到现在为止还没有最好的收集器
出现,更加没有万能的收集器,所以我们选择的只是对具体应用最合适的收集器。这点不需
要多加解释就能证明:如果有一种放之四海皆准、任何场景下都适用的完美收集器存在,那
HotSpot 虚拟机就没必要实现那么多不同的收集器了。
3.5.1 Serial 收集器 Serial 收集器是最基本、发展历史最悠久的收集器,曾经(在 JDK 1.3.1 之前)是虚拟机
新生代收集的唯一选择。大家看名字就会知道,这个收集器是一个单线程的收集器,但它
单线程 的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,
更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。 “Stop
The World” 这个名字也许听起来很酷,但这项工作实际上是由虚拟机在后台自动发起和自动
完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难
以接受的。读者不妨试想一下,要是你的计算机每运行一个小时就会暂停响应 5 分钟,你会
有什么样的心情?图 3-6 示意了 Serial/Serial Old 收集器的运行过程。
3-6 Serial/Serial Old 收集器运行示意图
对于 “Stop The World” 带给用户的不良体验,虚拟机的设计者们表示完全理解,但也表
示非常委屈: 你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上或者房间
外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完? 这确实是一个合情合理
的矛盾,虽然垃圾收集这项工作听起来和打扫房间属于一个性质的,但实际上肯定还要比打
扫房间复杂得多啊!
JDK 1.3 开始,一直到现在最新的 JDK 1.7 HotSpot 虚拟机开发团队为消除或者减少工
作线程因内存回收而导致停顿的努力一直在进行着,从 Serial 收集器到 Parallel 收集器,再到
Concurrent Mark Sweep CMS )乃至 GC 收集器的最前沿成果 Garbage First G1 )收集器,我
们看到了一个个越来越优秀(也越来越复杂)的收集器的出现,用户线程的停顿时间在不断
缩短,但是仍然没有办法完全消除(这里暂不包括 RTSJ 中的收集器)。寻找更优秀的垃圾收
集器的工作仍在继续!
写到这里,笔者似乎已经把 Serial 收集器描述成一个 老而无用、食之无味弃之可惜
鸡肋了,但实际上到现在为止,它依然是虚拟机运行在 Client 模式下的默认新生代收集器。
它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比),对于限定单个
CPU 的环境来说, Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最
高的单线程收集效率。在用户的桌面应用场景中,分配给虚拟机管理的内存一般来说不会很
大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本上不会再
大了),停顿时间完全可以控制在几十毫秒最多一百多毫秒以内,只要不是频繁发生,这点
停顿是可以接受的。所以, Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的
选择。
[1] 图片来源: http://blogs.sun.com/jonthecollector/entry/our_collectors 3.5.2 ParNew 收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之
外,其余行为包括 Serial 收集器可用的所有控制参数(例如: -XX SurvivorRatio -XX
PretenureSizeThreshold -XX HandlePromotionFailure 等)、收集算法、 Stop The World 、对
象分配规则、回收策略等都与 Serial 收集器完全一样,在实现上,这两种收集器也共用了相
当多的代码。 ParNew 收集器的工作过程如图 3-7 所示。
3-7 ParNew/Serial Old 收集器运行示意图
ParNew 收集器除了多线程收集之外,其他与 Serial 收集器相比并没有太多创新之处,但
它却是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但
很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。在 JDK 1.5
期, HotSpot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器 ——CMS
集器( Concurrent Mark Sweep ,本节稍后将详细介绍这款收集器),这款收集器是 HotSpot
拟机中第一款真正意义上的并发( Concurrent )收集器,它第一次实现了让垃圾收集线程与
用户线程(基本上)同时工作,用前面那个例子的话来说,就是做到了在你的妈妈打扫房间
的时候你还能一边往地上扔纸屑。
不幸的是, CMS 作为老年代的收集器,却无法与 JDK
1.4.0 中已经存在的新生代收集器
Parallel Scavenge 配合工作
[1]
,所以在 JDK 1.5 中使用 CMS 来收集老年代的时候,新生代只能选
ParNew 或者 Serial 收集器中的一个。 ParNew 收集器也是使用 -XX +UseConcMarkSweepGC
选项后的默认新生代收集器,也可以使用 -XX +UseParNewGC 选项来强制指定它。
ParNew 收集器在单 CPU 的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在
线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百地保
证可以超越 Serial 收集器。当然,随着可以使用的 CPU 的数量的增加,它对于 GC 时系统资源
的有效利用还是很有好处的。它默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多
(譬如 32 个,现在 CPU 动辄就 4 核加超线程,服务器超过 32 个逻辑 CPU 的情况越来越多了)的
环境下,可以使用 -XX ParallelGCThreads 参数来限制垃圾收集的线程数。
注意 从 ParNew 收集器开始,后面还会接触到几款并发和并行的收集器。在大家可能
产生疑惑之前,有必要先解释两个名词:并发和并行。这两个名词都是并发编程中的概念,
在谈论垃圾收集器的上下文语境中,它们可以解释如下。
并行( Parallel ):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状
态。 并发( Concurrent ):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能
会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个 CPU 上。
[1] Parallel Scavenge 收集器及后面提到的 G1 收集器都没有使用传统的 GC 收集器代码框架,而
另外独立实现,其余几种收集器则共用了部分的框架代码,详细内容可参考:
http://blogs.sun.com/jonthecollector/entry/our_collectors 3.5.3 Parallel Scavenge 收集器
Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行
的多线程收集器 …… 看上去和 ParNew 都一样,那它有什么特别之处呢?
Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同, CMS 等收集器的关注点
是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到
一个可控制的吞吐量( Throughput )。所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU
消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间),虚
拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高
吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不
需要太多交互的任务。
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集
停顿时间的 -XX MaxGCPauseMillis 参数以及直接设置吞吐量大小的 -XX GCTimeRatio
数。
MaxGCPauseMillis 参数允许的值是一个大于 0 的毫秒数,收集器将尽可能地保证内存回
收花费的时间不超过设定值。不过大家不要认为如果把这个参数的值设置得稍小一点就能使
得系统的垃圾收集速度变得更快, GC 停顿时间缩短是以牺牲吞吐量和新生代空间来换取
的:系统把新生代调小一些,收集 300MB 新生代肯定比收集 500MB 快吧,这也直接导致垃圾
收集发生得更频繁一些,原来 10 秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次、每
次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。
GCTimeRatio 参数的值应当是一个大于 0 且小于 100 的整数,也就是垃圾收集时间占总时
间的比率,相当于是吞吐量的倒数。如果把此参数设置为 19 ,那允许的最大 GC 时间就占总
时间的 5% (即 1/ 1+19 )),默认值为 99 ,就是允许最大 1% (即 1/ 1+99 ))的垃圾收集
时间。
由于与吞吐量关系密切, Parallel Scavenge 收集器也经常称为 吞吐量优先 收集器。除上
述两个参数之外, Parallel Scavenge 收集器还有一个参数 -XX +UseAdaptiveSizePolicy 值得关
注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小( -Xmn )、
Eden Survivor 区的比例( -XX SurvivorRatio )、晋升老年代对象年龄( -XX
PretenureSizeThreshold )等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信
息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为 GC
自适应的调节策略( GC Ergonomics
[1]
。如果读者对于收集器运作原来不太了解,手工优化
存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理的调优任务
交给虚拟机去完成将是一个不错的选择。只需要把基本的内存数据设置好(如 -Xmx 设置最大
堆),然后使用 MaxGCPauseMillis 参数(更关注最大停顿时间)或 GCTimeRatio (更关注吞
吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自
适应调节策略也是 Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别。
[1] 官方介绍: http://download.oracle.com/javase/1.5.0/docs/guide/vm/gc-ergonomics.html 3.5.4 Serial Old 收集器
Serial
Old Serial 收集器的老年代版本,它同样是一个单线程收集器,使用 标记 -
算法。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。如果在 Server 模式
下,那么它主要还有两大用途:一种用途是在 JDK 1.5 以及之前的版本中与 Parallel Scavenge
收集器搭配使用
[1]
,另一种用途就是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent
Mode Failure 时使用。这两点都将在后面的内容中详细讲解。 Serial Old 收集器的工作过程如
3-8 所示。
3-8 Serial/Serial Old 收集器运行示意图
[1] 需要说明一下, Parallel Scavenge 收集器架构中本身有 PS MarkSweep 收集器来进行老年代
收集,并非直接使用了 Serial Old 收集器,但是这个 PS MarkSweep 收集器与 Serial Old 的实现
非常接近,所以在官方的许多资料中都是直接以 Serial Old 代替 PS MarkSweep 进行讲解,这
里笔者也采用这种方式。 3.5.5 Parallel Old 收集器
Parallel Old Parallel Scavenge 收集器的老年代版本,使用多线程和 标记 - 整理 算法。
这个收集器是在 JDK 1.6 中才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直
处于比较尴尬的状态。原因是,如果新生代选择了 Parallel
Scavenge 收集器,老年代除了
Serial Old PS MarkSweep )收集器外别无选择(还记得上面说过 Parallel Scavenge 收集器无
法与 CMS 收集器配合工作吗?)。由于老年代 Serial
Old 收集器在服务端应用性能上的
,使用了 Parallel Scavenge 收集器也未必能在整体应用上获得吞吐量最大化的效果,由于
单线程的老年代收集中无法充分利用服务器多 CPU 的处理能力,在老年代很大而且硬件比较
高级的环境中,这种组合的吞吐量甚至还不一定有 ParNew CMS 的组合 给力
直到 Parallel
Old 收集器出现后, 吞吐量优先 收集器终于有了比较名副其实的应用组
合,在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge Parallel Old
收集器。 Parallel Old 收集器的工作过程如图 3-9 所示。
3-9 Parallel Scavenge/Parallel Old 收集器运行示意图 3.5.6 CMS 收集器
CMS Concurrent Mark Sweep )收集器是一种以获取最短回收停顿时间为目标的收集
器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重
视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。 CMS 收集器就非常
符合这类应用的需求。
从名字(包含 “Mark Sweep” )上就可以看出, CMS 收集器是基于 标记 清除 算法实现
的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为 4 个步骤,包括:
初始标记( CMS initial mark
并发标记( CMS concurrent mark
重新标记( CMS remark
并发清除( CMS concurrent sweep
其中,初始标记、重新标记这两个步骤仍然需要 “Stop The World” 。初始标记仅仅只是
标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC RootsTracing
的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变
动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远
比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起
工作,所以,从总体上来说, CMS 收集器的内存回收过程是与用户线程一起并发执行的。通
过图 3-10 可以比较清楚地看到 CMS 收集器的运作步骤中并发和需要停顿的时间。
3-10 Concurrent Mark Sweep 收集器运行示意图
CMS 是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停
顿, Sun 公司的一些官方文档中也称之为并发低停顿收集器( Concurrent
Low
Pause
Collector )。但是 CMS 还远达不到完美的程度,它有以下 3 个明显的缺点:
CMS 收集器对 CPU 资源非常敏感。其实,面向并发设计的程序都对 CPU 资源比较敏感。
在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说 CPU
源)而导致应用程序变慢,总吞吐量会降低。 CMS 默认启动的回收线程数是( CPU 数量
+3 /4 ,也就是当 CPU 4 个以上时,并发回收时垃圾收集线程不少于 25% CPU 资源,并且 随着 CPU 数量的增加而下降。但是当 CPU 不足 4 个(譬如 2 个)时, CMS 对用户程序的影响就
可能变得很大,如果本来 CPU 负载就比较大,还分出一半的运算能力去执行收集器线程,就
可能导致用户程序的执行速度忽然降低了 50% ,其实也让人无法接受。为了应付这种情况,
虚拟机提供了一种称为 增量式并发收集器 Incremental Concurrent Mark Sweep/i-CMS )的
CMS 收集器变种,所做的事情和单 CPU 年代 PC 机操作系统使用抢占式来模拟多任务机制的思
想一样,就是在并发标记、清理的时候让 GC 线程、用户线程交替运行,尽量减少 GC 线程的
独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得少一些,
也就是速度下降没有那么明显。实践证明,增量时的 CMS 收集器效果很一般,在目前版本
中, i-CMS 已经被声明为 “deprecated” ,即不再提倡用户使用。
CMS 收集器无法处理浮动垃圾( Floating
Garbage ),可能出现 “Concurrent
Mode
Failure” 失败而导致另一次 Full GC 的产生。由于 CMS 并发清理阶段用户线程还在运行着,伴
随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后, CMS 无法
在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为 浮动垃
。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间
给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进
行收集,需要预留一部分空间提供并发收集时的程序运作使用。在 JDK
1.5 的默认设置
下, CMS 收集器当老年代使用了 68% 的空间后就会被激活,这是一个偏保守的设置,如果在
应用中老年代增长不是太快,可以适当调高参数 -XX CMSInitiatingOccupancyFraction 的值来
提高触发百分比,以便降低内存回收次数从而获取更好的性能,在 JDK 1.6 中, CMS 收集器
的启动阈值已经提升至 92% 。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一
“Concurrent Mode Failure” 失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来
重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数 -XX CM
SInitiatingOccupancyFraction 设置得太高很容易导致大量 “Concurrent Mode Failure” 失败,性能
反而降低。
还有最后一个缺点,在本节开头说过, CMS 是一款基于 标记 清除 算法实现的收集
器,如果读者对前面这种算法介绍还有印象的话,就可能想到这意味着收集结束时会有大量
空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有
很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 Full
GC 。为了解决这个问题, CMS 收集器提供了一个 -XX +UseCMSCompactAtFullCollection
关参数(默认就是开启的),用于在 CMS 收集器顶不住要进行 FullGC 时开启内存碎片的合并
整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。
虚拟机设计者还提供了另外一个参数 -XX CMSFullGCsBeforeCompaction ,这个参数是用于
设置执行多少次不压缩的 Full GC 后,跟着来一次带压缩的(默认值为 0 ,表示每次进入 Full
GC 时都进行碎片整理)。 3.5.7 G1 收集器
G1 Garbage-First )收集器是当今收集器技术发展的最前沿成果之一,早在 JDK 1.7 刚刚
确立项目目标, Sun 公司给出的 JDK 1.7 RoadMap 里面,它就被视为 JDK 1.7 HotSpot 虚拟机
的一个重要进化特征。从 JDK 6u14 中开始就有 Early Access 版本的 G1 收集器供开发人员实
验、试用,由此开始 G1 收集器的 “Experimental” 状态持续了数年时间,直至 JDK 7u4 Sun
司才认为它达到足够成熟的商用程度,移除了 “Experimental” 的标识。
G1 是一款面向服务端应用的垃圾收集器。 HotSpot 开发团队赋予它的使命是(在比较长
期的)未来可以替换掉 JDK 1.5 中发布的 CMS 收集器。与其他 GC 收集器相比, G1 具备如下特
点。
并行与并发: G1 能充分利用多 CPU 、多核环境下的硬件优势,使用多个 CPU CPU 或者
CPU 核心)来缩短 Stop-The-World 停顿的时间,部分其他收集器原本需要停顿 Java 线程执行的
GC 动作, G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
分代收集:与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其
他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建的对象和已
经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。
空间整合:与 CMS 标记 清理 算法不同, G1 从整体来看是基于 标记 整理 算法实
现的收集器,从局部(两个 Region 之间)上来看是基于 复制 算法实现的,但无论如何,这
两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种
特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一
GC
可预测的停顿:这是 G1 相对于 CMS 的另一大优势,降低停顿时间是 G1 CMS 共同的关
注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一
个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒,这几乎已经是实
Java RTSJ )的垃圾收集器的特征了。
G1 之前的其他收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这
样。使用 G1 收集器时, Java 堆的内存布局就与其他收集器有很大差别,它将整个 Java 堆划分
为多个大小相等的独立区域( Region ),虽然还保留有新生代和老年代的概念,但新生代和
老年代不再是物理隔离的了,它们都是一部分 Region (不需要连续)的集合。
G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java
堆中进行全区域的垃圾收集。 G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的
空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时
间,优先回收价值最大的 Region (这也就是 Garbage-First 名称的来由)。这种使用 Region 划分
内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高
的收集效率。
G1 把内存 化整为零 的思路,理解起来似乎很容易,但其中的实现细节却远远没有想象
中那样简单,否则也不会从 2004 Sun 实验室发表第一篇 G1 的论文开始直到今天(将近 10
时间)才开发出 G1 的商用版。笔者以一个细节为例:把 Java 堆分为多个 Region 后,垃圾收集 是否就真的能以 Region 为单位进行了?听起来顺理成章,再仔细想想就很容易发现问题所
在: Region 不可能是孤立的。一个对象分配在某个 Region 中,它并非只能被本 Region 中的其
他对象引用,而是可以与整个 Java 堆任意的对象发生引用关系。那在做可达性判定确定对象
是否存活的时候,岂不是还得扫描整个 Java 堆才能保证准确性?这个问题其实并非在 G1 中才
有,只是在 G1 中更加突出而已。在以前的分代收集中,新生代的规模一般都比老年代要小许
多,新生代的收集也比老年代要频繁许多,那回收新生代中的对象时也面临相同的问题,如
果回收新生代时也不得不同时扫描老年代的话,那么 Minor GC 的效率可能下降不少。
G1 收集器中, Region 之间的对象引用以及其他收集器中的新生代与老年代之间的对象
引用,虚拟机都是使用 Remembered Set 来避免全堆扫描的。 G1 中每个 Region 都有一个与之对
应的 Remembered Set ,虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个
Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代
的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过
CardTable 把相关引用信息记录到被引用对象所属的 Region Remembered Set 之中。当进行内
存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗
漏。
如果不计算维护 Remembered Set 的操作, G1 收集器的运作大致可划分为以下几个步骤:
初始标记( Initial Marking
并发标记( Concurrent Marking
最终标记( Final Marking
筛选回收( Live Data Counting and Evacuation
CMS 收集器运作过程熟悉的读者,一定已经发现 G1 的前几个步骤的运作过程和 CMS
有很多相似之处。初始标记阶段仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改
TAMS Next Top at Mark Start )的值,让下一阶段用户程序并发运行时,能在正确可用的
Region 中创建新对象,这阶段需要停顿线程,但耗时很短。并发标记阶段是从 GC Root 开始
对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执
行。而最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动
的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最
终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线
程,但是可并行执行。最后在筛选回收阶段首先对各个 Region 的回收价值和成本进行排序,
根据用户所期望的 GC 停顿时间来制定回收计划,从 Sun 公司透露出来的信息来看,这个阶段
其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region ,时间是用户可控
制的,而且停顿用户线程将大幅提高收集效率。通过图 3-11 可以比较清楚地看到 G1 收集器的
运作步骤中并发和需要停顿的阶段。 3-11 G1 收集器运行示意图
由于目前 G1 成熟版本的发布时间还很短, G1 收集器几乎可以说还没有经过实际应用的
考验,网络上关于 G1 收集器的性能测试也非常贫乏,到目前为止,笔者还没有搜索到有关的
生产环境下的性能测试报告。强调 生产环境下的测试报告 是因为对于垃圾收集器来说,仅
仅通过简单的 Java 代码写个 Microbenchmark 程序来创建、移除 Java 对象,再用 -XX
+PrintGCDetails 等参数来查看 GC 日志是很难做到准确衡量其性能的。因此,关于 G1 收集器的
性能部分,笔者引用了 Sun 实验室的论文《 Garbage-First Garbage Collection 》中的一段测试数
据。
Sun 给出的 Benchmark 的执行硬件为 Sun V880 服务器( 8×750MHz UltraSPARC III CPU
32G 内存、 Solaris
10 操作系统)。执行软件有两个,分别为 SPECjbb (模拟商业数据库应
用,堆中存活对象约为 165MB ,结果反映吐量和最长事务处理时间)和 telco (模拟电话应答
服务应用,堆中存活对象约为 100MB ,结果反映系统能支持的最大吞吐量)。为了便于对
比,还收集了一组使用 ParNew+CMS 收集器的测试数据。所有测试都配置为与 CPU 数量相同
8 GC 线程。
在反应停顿时间的软实时目标( Soft Real-Time Goal )测试中,横向是两个测试软件的
时间片段配置,单位是毫秒,以( X/Y )的形式表示,代表在 Y 毫秒内最大允许 GC 时间为 X
毫秒(对于 CMS 收集器,无法直接指定这个目标,通过调整分代大小的方式大致模拟)。纵
向是两个软件在对应配置和不同的 Java 堆容量下的测试结果, V% avgV% wV% 分别代表
的含义如下。
V% :表示测试过程中,软实时目标失败的概率,软实时目标失败即某个时间片段中实
GC 时间超过了允许的最大 GC 时间。
avgV% :表示在所有实际 GC 时间超标的时间片段里,实际 GC 时间超过最大 GC 时间的平
均百分比(实际 GC 时间减去允许最大 GC 时间,再除以总时间片段)。
wV% :表示在测试结果最差的时间片段里,实际 GC 时间占用执行时间的百分比。
测试结果见表 3-1 从表 3-1 所示的结果可见,对于 telco 来说,软实时目标失败的概率控制在 0.5% 0.7%
间, SPECjbb 就要差一些,但也控制在 2% 5% 之间,概率随着( X/Y )的比值减小而增加。
另一方面,失败时超出允许 GC 时间的比值随着总时间片段增加而变小(分母变大了),在
100/200 )、 512MB 的配置下, G1 收集器出现了某些时间片段下 100% 时间在进行 GC 的最坏
情况。而相比之下, CMS 收集器的测试结果就要差很多, 3 Java 堆容量下都出现了 100%
间进行 GC 的情况。
在吞吐量测试中,测试数据取 3 SPECjbb 15 telco 的平均结果如图 3-12 所示。在
SPECjbb 的应用下,各种配置下的 G1 收集器表现出了一致的行为,吞吐量看起来只与允许最
GC 时间成正比关系,而在 telco 的应用中,不同配置对吞吐量的影响则显得很微弱。与
CMS 收集器的吞吐量对比可以看到,在 SPECjbb 测试中,在堆容量超过 768MB 时, CMS 收集
器有 5% 10% 的优势,而在 telco 测试中, CMS 的优势则要小一些,只有 3% 4% 左右。 3-12 吞吐量测试结果
在更大规模的生产环境下,笔者引用一段在 StackOverflow.com 上看到的经验与读者分
享: 我在一个真实的、较大规模的应用程序中使用过 G1 :大约分配有 60 70GB 内存,存活
对象大约在 20 50GB 之间。服务器运行 Linux 操作系统, JDK 版本为 6u22 G1 PS/PS Old
比,最大的好处是停顿时间更加可控、可预测,如果我在 PS 中设置一个很低的最大允许 GC
时间,譬如期望 50 毫秒内完成 GC -XX MaxGCPauseMillis=50 ),但在 65GB Java 堆下有
可能得到的直接结果是一次长达 30 秒至 2 分钟的漫长的 Stop-The-World 过程;而 G1 CMS
比,虽然它们都立足于低停顿时间, CMS 仍然是我现在的选择,但是随着 Oracle G1 的持续
改进,我相信 G1 会是最终的胜利者。如果你现在采用的收集器没有出现问题,那就没有任何
理由现在去选择 G1 ,如果你的应用追求低停顿,那 G1 现在已经可以作为一个可尝试的选
择,如果你的应用追求吞吐量,那 G1 并不会为你带来什么特别的好处 3.5.8 理解 GC 日志
阅读 GC 日志是处理 Java 虚拟机内存问题的基础技能,它只是一些人为确定的规则,没有
太多技术含量。在本书的第 1 版中没有专门讲解如何阅读分析 GC 日志,为此作者收到许多读
者来信,反映对此感到困惑,因此专门增加本节内容来讲解如何理解 GC 日志。
每一种收集器的日志形式都是由它们自身的实现所决定的,换而言之,每个收集器的日
志格式都可以不一样。但虚拟机设计者为了方便用户阅读,将各个收集器的日志都维持一定
的共性,例如以下两段典型的 GC 日志:
33.125 [GC[DefNew 3324K- 152K 3712K ), 0.0025925 secs]3324K- 152K 11904K ), 0.0031680 secs]
1 0 0.6 6 7 [F u l l G C[T e n u r e d 0 K- 2 1 0 K 1 0 2 4 0 K ), 0.0 1 4 9 1 4 2 s e c s]4603K- 210K 19456K ), [Perm 2999K-
2999K 21248K ] 0.0150007 secs][Times user=0.01 sys=0.00 real=0.02 secs]
最前面的数字 “33.125 “100.667 代表了 GC 发生的时间,这个数字的含义是从 Java
虚拟机启动以来经过的秒数。
GC 日志开头的 “[GC” “[Full GC” 说明了这次垃圾收集的停顿类型,而不是用来区分新
生代 GC 还是老年代 GC 的。如果有 “Full” ,说明这次 GC 是发生了 Stop-The-World 的,例如下面
这段新生代收集器 ParNew 的日志也会出现 “[Full GC” (这一般是因为出现了分配担保失败之
类的问题,所以才导致 STW )。如果是调用 System.gc ()方法所触发的收集,那么在这里将
显示 “[Full GC System
[Full GC 283.736 [ParNew 261599K- 261599K 261952K ), 0.0000288 secs]
接下来的 “[DefNew” “[Tenured” “[Perm” 表示 GC 发生的区域,这里显示的区域名称与
使用的 GC 收集器是密切相关的,例如上面样例所使用的 Serial 收集器中的新生代名为 “Default
New
Generation” ,所以显示的是 “[DefNew” 。如果是 ParNew 收集器,新生代名称就会变
“[ParNew” ,意为 “Parallel New Generation” 。如果采用 Parallel Scavenge 收集器,那它配套
的新生代称为 “PSYoungGen” ,老年代和永久代同理,名称也是由收集器决定的。
后面方括号内部的 “3324K- 152K 3712K 含义是 “GC 前该内存区域已使用容量 -
GC 后该内存区域已使用容量(该内存区域总容量) 。而在方括号之外的 “3324K-
152K 11904K 表示 “GC Java 堆已使用容量 - GC Java 堆已使用容量( Java 堆总容
量)
再往后, “0.0025925 secs” 表示该内存区域 GC 所占用的时间,单位是秒。有的收集器会
给出更具体的时间数据,如 “[Times user=0.01 sys=0.00 real=0.02 secs]” ,这里面的 user
sys real Linux time 命令所输出的时间含义一致,分别代表用户态消耗的 CPU 时间、内核
态消耗的 CPU 事件和操作从开始到结束所经过的墙钟时间( Wall Clock Time )。 CPU 时间与
墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘 I/O 、等待线程阻
塞,而 CPU 时间不包括这些耗时,但当系统有多 CPU 或者多核的话,多线程操作会叠加这些
CPU 时间,所以读者看到 user sys 时间超过 real 时间是完全正常的。 3.5.9 垃圾收集器参数总结
JDK 1.7 中的各种垃圾收集器到此已全部介绍完毕,在描述过程中提到了很多虚拟机非
稳定的运行参数,在表 3-2 中整理了这些参数供读者实践时参考。 3.6 内存分配与回收策略
Java 技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对
象分配内存以及回收分配给对象的内存。关于回收内存这一点,我们已经使用了大量篇幅去
介绍虚拟机中的垃圾收集器体系以及运作原理,现在我们再一起来探讨一下给对象分配内存
的那点事儿。
对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过 JIT 编译后被拆散为标
量类型并间接地栈上分配
[1]
),对象主要分配在新生代的 Eden 区上,如果启动了本地线程分
配缓冲,将按线程优先在 TLAB 上分配。少数情况下也可能会直接分配在老年代中,分配的
规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟
机中与内存相关的参数的设置。
接下来我们将会讲解几条最普遍的内存分配规则,并通过代码去验证这些规则。本节下
面的代码在测试时使用 Client 模式虚拟机运行,没有手工指定收集器组合,换句话说,验证
的是在使用 Serial/Serial Old 收集器下( ParNew/Serial Old 收集器组合的规则也基本一致)的
内存分配和回收的策略。读者不妨根据自己项目中使用的收集器写一些程序去验证一下使用
其他几种收集器的内存分配策略。
3.6.1 对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟
机将发起一次 Minor GC
虚拟机提供了 -XX +PrintGCDetails 这个收集器日志参数,告诉虚拟机在发生垃圾收集
行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。在实际
应用中,内存回收日志一般是打印到文件后通过日志工具进行分析,不过本实验的日志并不
多,直接阅读就能看得很清楚。
代码清单 3-5 testAllocation ()方法中,尝试分配 3 2MB 大小和 1 4MB 大小的对象,
在运行时通过 -Xms20M -Xmx20M -Xmn10M 3 个参数限制了 Java 堆大小为 20MB ,不可扩
展,其中 10MB 分配给新生代,剩下的 10MB 分配给老年代。 -XX SurvivorRatio=8 决定了新
生代中 Eden 区与一个 Survivor 区的空间比例是 8:1 ,从输出的结果也可以清晰地看到 “eden
space
8192K from
space
1024K to
space
1024K” 的信息,新生代总可用空间为
9216KB Eden +1 Survivor 区的总容量)。
执行 testAllocation ()中分配 allocation4 对象的语句时会发生一次 Minor GC ,这次 GC
结果是新生代 6651KB 变为 148KB ,而总内存占用量则几乎没有减少(因为 allocation1
allocation2 allocation3 三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。这次
GC 发生的原因是给 allocation4 分配内存的时候,发现 Eden 已经被占用了 6MB ,剩余空间已不
足以分配 allocation4 所需的 4MB 内存,因此发生 Minor GC GC 期间虚拟机又发现已有的 3
2MB 大小的对象全部无法放入 Survivor 空间( Survivor 空间只有 1MB 大小),所以只好通过分
配担保机制提前转移到老年代去。
这次 GC 结束后, 4MB allocation4 对象顺利分配在 Eden 中,因此程序执行完的结果是
Eden 占用 4MB (被 allocation4 占用), Survivor 空闲,老年代被占用 6MB (被 allocation1 allocation2 allocation3 占用)。通过 GC 日志可以证实这一点。
注意 作者多次提到的 Minor GC Full GC 有什么不一样吗?
新生代 GC Minor GC ):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝
生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
老年代 GC Major GC/Full GC ):指发生在老年代的 GC ,出现了 Major GC ,经常会伴
随至少一次的 Minor GC (但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行
Major GC 的策略选择过程)。 Major GC 的速度一般会比 Minor GC 10 倍以上。
代码清单 3-5 新生代 Minor GC
private static final int_1MB=1024*1024
/**
*VM 参数: -verbose gc-Xms20M-Xmx20M-Xmn10M-XX +PrintGCDetails
-XX SurvivorRatio=8
*/
public static void testAllocation () {
byte[]allocation1 allocation2 allocation3 allocation4
allocation1=new byte[2*_1MB]
allocation2=new byte[2*_1MB]
allocation3=new byte[2*
_
1MB]
allocation4=new byte[4*
_
1MB] // 出现一次 Minor GC
}
运行结果:
[GC[DefNew 6651K- 148K 9216K ), 0.0070106 secs]6651K- 6292K 19456K ),
0.0070426 secs][Times user=0.00 sys=0.00 real=0.00 secs]
Heap
def new generation total 9216K,used 4326K[0x029d0000 0x033d0000 0x033d0000
eden space 8192K 51%used[0x029d0000 0x02de4828 0x031d0000
from space 1024K 14%used[0x032d0000 0x032f5370 0x033d0000
to space 1024K 0%used[0x031d0000 0x031d0000 0x032d0000
tenured generation total 10240K,used 6144K[0x033d0000 0x03dd0000 0x03dd0000
the space 10240K 60%used[0x033d0000 0x039d0030 0x039d0200 0x03dd0000
compacting perm gen total 12288K,used 2114K[0x03dd0000 0x049d0000 0x07dd0000
the space 12288K 17%used[0x03dd0000 0x03fe0998 0x03fe0a00 0x049d0000
No shared spaces configured.
[1] JIT 即时编译器相关优化可参见第 11 章。 3.6.2 大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长
的字符串以及数组(笔者列出的例子中的 byte[] 数组就是典型的大对象)。大对象对虚拟机
的内存分配来说就是一个坏消息(替 Java 虚拟机抱怨一句,比遇到一个大对象更加坏的消息
就是遇到一群 朝生夕灭 短命大对象 ,写程序的时候应当避免),经常出现大对象容易
导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来 安置 它们。
虚拟机提供了一个 -XX PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老
年代分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制(复习
一下:新生代采用复制算法收集内存)。
执行代码清单 3-6 中的 testPretenureSizeThreshold ()方法后,我们看到 Eden 空间几乎没有
被使用,而老年代的 10MB 空间被使用了 40% ,也就是 4MB allocation 对象直接就分配在老
年代中,这是因为 PretenureSizeThreshold 被设置为 3MB (就是 3145728 ,这个参数不能像 -Xmx
之类的参数一样直接写 3MB ),因此超过 3MB 的对象都会直接在老年代进行分配。注意
PretenureSizeThreshold 参数只对 Serial ParNew 两款收集器有效, Parallel Scavenge 收集器不
认识这个参数, Parallel
Scavenge 收集器一般并不需要设置。如果遇到必须使用此参数的场
合,可以考虑 ParNew CMS 的收集器组合。
代码清单 3-6 大对象直接进入老年代
private static final int_1MB=1024*1024
/**
*VM 参数: -verbose gc-Xms20M-Xmx20M-Xmn10M-XX +PrintGCDetails-XX SurvivorRatio=8
*-XX PretenureSizeThreshold=3145728
*/
public static void testPretenureSizeThreshold () {
byte[]allocation
allocation=new byte[4*_1MB] // 直接分配在老年代中
}
运行结果:
Heap
def new generation total 9216K,used 671K[0x029d0000 0x033d0000 0x033d0000
eden space 8192K 8%used[0x029d0000 0x02a77e98 0x031d0000
from space 1024K 0%used[0x031d0000 0x031d0000 0x032d0000
to space 1024K 0%used[0x032d0000 0x032d0000 0x033d0000
tenured generation total 10240K,used 4096K[0x033d0000 0x03dd0000 0x03dd0000
the space 10240K 40%used[0x033d0000 0x037d0010 0x037d0200 0x03dd0000
compacting perm gen total 12288K,used 2107K[0x03dd0000 0x049d0000 0x07dd0000
the space 12288K 17%used[0x03dd0000 0x03fdefd0 0x03fdf000 0x049d0000
No shared spaces configured. 3.6.3 长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象
应放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对
象年龄( Age )计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被
Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1 。对象在 Survivor 区中
熬过 一次 Minor GC ,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就
将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX
MaxTenuringThreshold 设置。
读者可以试试分别以 -XX MaxTenuringThreshold=1 -XX MaxTenuringThreshold=15
种设置来执行代码清单 3-7 中的 testTenuringThreshold ()方法,此方法中的 allocation1 对象需
256KB 内存, Survivor 空间可以容纳。当 MaxTenuringThreshold=1 时, allocation1 对象在第二
GC 发生时进入老年代,新生代已使用的内存 GC 后非常干净地变成 0KB 。而
MaxTenuringThreshold=15 时,第二次 GC 发生后, allocation1 对象则还留在新生代 Survivor
间,这时新生代仍然有 404KB 被占用。
代码清单 3-7 长期存活的对象进入老年代
private static final int_1MB=1024*1024
/**
*VM 参数: -verbose gc-Xms20M-Xmx20M-Xmn10M-XX +PrintGCDetails-XX SurvivorRatio=8-XX MaxTenuringThreshold=1
*-XX +PrintTenuringDistribution
*/
@SuppressWarnings "unused"
public static void testTenuringThreshold () {
byte[]allocation1 allocation2 allocation3
allocation1=new byte[ 1MB/4]
// 什么时候进入老年代取决于 XX MaxTenuringThreshold 设置
_
allocation2=new byte[4*_1MB]
allocation3=new byte[4*_1MB]
allocation3=null
allocation3=new byte[4*_1MB]
}
MaxTenuringThreshold=1 参数来运行的结果:
[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 1 max 1
-age 1 414664 bytes 414664 total
4859K- 404K 9216K ), 0.0065012 secs]4859K- 4500K 19456K ), 0.0065283 secs][Times user=0.02 sys=0.00 real=0.02 secs]
[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 1 max 1
4500K- 0K 9216K ), 0.0009253 secs]8596K- 4500K 19456K ), 0.0009458 secs][Times user=0.00 sys=0.00 real=0.00 secs]
Heap
def new generation total 9216K,used 4178K[0x029d0000 0x033d0000 0x033d0000
eden space 8192K 51%used[0x029d0000 0x02de4828 0x031d0000
from space 1024K 0%used[0x031d0000 0x031d0000 0x032d0000
to space 1024K 0%used[0x032d0000 0x032d0000 0x033d0000
tenured generation total 10240K,used 4500K[0x033d0000 0x03dd0000 0x03dd0000
the space 10240K 43%used[0x033d0000 0x03835348 0x03835400 0x03dd0000
compacting perm gen total 12288K,used 2114K[0x03dd0000 0x049d0000 0x07dd0000
the space 12288K 17%used[0x03dd0000 0x03fe0998 0x03fe0a00 0x049d0000
No shared spaces configured.
MaxTenuringThreshold=15 参数来运行的结果:
[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 15 max 15
-age 1 414664 bytes 414664 total
4859K- 404K 9216K ), 0.0049637 secs]4859K- 4500K 19456K ), 0.0049932 secs][Times user=0.00 sys=0.00 real=0.00 secs]
[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 15 max 15
-age 2 414520 bytes 414520 total
4500K- 404K 9216K ), 0.0008091 secs]8596K- 4500K 19456K ), 0.0008305 secs][Times user=0.00 sys=0.00 real=0.00 secs]
Heap
def new generation total 9216K,used 4582K[0x029d0000 0x033d0000 0x033d0000
eden space 8192K 51%used[0x029d0000 0x02de4828 0x031d0000
from space 1024K 39%used[0x031d0000 0x03235338 0x032d0000
to space 1024K 0%used[0x032d0000 0x032d0000 0x033d0000
tenured generation total 10240K,used 4096K[0x033d0000 0x03dd0000 0x03dd0000
the space 10240K 40%used[0x033d0000 0x037d0010 0x037d0200 0x03dd0000
compacting perm gen total 12288K,used 2114K[0x03dd0000 0x049d0000 0x07dd0000
the space 12288K 17%used[0x03dd0000 0x03fe0998 0x03fe0a00 0x049d0000
No shared spaces configured. 3.6.4 动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到
MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总
和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等
MaxTenuringThreshold 中要求的年龄。
执行代码清单 3-8 中的 testTenuringThreshold2 ()方法,并设置 -XX
MaxTenuringThreshold=15 ,会发现运行结果中 Survivor 的空间占用仍然为 0% ,而老年代比预
期增加了 6% ,也就是说, allocation1 allocation2 对象都直接进入了老年代,而没有等到 15
岁的临界年龄。因为这两个对象加起来已经到达了 512KB ,并且它们是同年的,满足同年对
象达到 Survivor 空间的一半规则。我们只要注释掉其中一个对象 new 操作,就会发现另外一个
就不会晋升到老年代中去了。
代码清单 3-8 动态对象年龄判定
private static final int_1MB=1024*1024
/**
*VM 参数: -verbose gc-Xms20M-Xmx20M-Xmn10M-XX +PrintGCDetails-XX SurvivorRatio=8-XX MaxTenuringThreshold=15
*-XX +PrintTenuringDistribution
*/
@SuppressWarnings "unused"
public static void testTenuringThreshold2 () {
byte[]allocation1 allocation2 allocation3 allocation4
allocation1=new byte[ 1MB/4]
//allocation1+allocation2 大于 survivo 空间一半
_
allocation2=new byte[_1MB/4]
allocation3=new byte[4*_1MB]
allocation4=new byte[4*_1MB]
allocation4=null
allocation4=new byte[4*_1MB]
}
运行结果:
[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 1 max 15
-age 1 676824 bytes 676824 total
5115K- 660K 9216K ), 0.0050136 secs]5115K- 4756K 19456K ), 0.0050443 secs][Times user=0.00 sys=0.01 real=0.01 secs]
[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 15 max 15
4756K- 0K 9216K ), 0.0010571 secs]8852K- 4756K 19456K ), 0.0011009 secs][Times user=0.00 sys=0.00 real=0.00 secs]
Heap
def new generation total 9216K,used 4178K[0x029d0000 0x033d0000 0x033d0000
eden space 8192K 51%used[0x029d0000 0x02de4828 0x031d0000
from space 1024K 0%used[0x031d0000 0x031d0000 0x032d0000
to space 1024K 0%used[0x032d0000 0x032d0000 0x033d0000
tenured generation total 10240K,used 4756K[0x033d0000 0x03dd0000 0x03dd0000
the space 10240K 46%used[0x033d0000 0x038753e8 0x03875400 0x03dd0000
compacting perm gen total 12288K,used 2114K[0x03dd0000 0x049d0000 0x07dd0000
the space 12288K 17%used[0x03dd0000 0x03fe09a0 0x03fe0a00 0x049d0000
No shared spaces configured. 3.6.5 空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有
对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机
会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代
最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行
一次 Minor GC ,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置
不允许冒险,那这时也要改为进行一次 Full GC
下面解释一下 冒险 是冒了什么风险,前面提到过,新生代使用复制收集算法,但为了
内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor
GC 后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要
老年代进行分配担保,把 Survivor 无法容纳的对象直接进入老年代。与生活中的贷款担保类
似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多
少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋
升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进
Full GC 来让老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次 Minor GC 存活
后的对象突增,远远高于平均值的话,依然会导致担保失败( Handle Promotion Failure )。
如果出现了 HandlePromotionFailure 失败,那就只好在失败后重新发起一次 Full GC 。虽然担保
失败时绕的圈子是最大的,但大部分情况下都还是会将 HandlePromotionFailure 开关打开,避
Full GC 过于频繁,参见代码清单 3-9 ,请读者在 JDK 6 Update 24 之前的版本中运行测试。
代码清单 3-9 空间分配担保
private static final int_1MB=1024*1024
/**
*VM 参数: -Xms20M-Xmx20M-Xmn10M-XX +PrintGCDetails-XX SurvivorRatio=8-XX -HandlePromotionFailure
*/
@SuppressWarnings "unused"
public static void testHandlePromotion () {
byte[]allocation1 allocation2 allocation3 allocation4 allocation5 allocation6 allocation7
allocation1=new byte[2*_1MB]
allocation2=new byte[2*_1MB]
allocation3=new byte[2*_1MB]
allocation1=null
allocation4=new byte[2*_1MB]
allocation5=new byte[2*_1MB]
allocation6=new byte[2*_1MB]
allocation4=null
allocation5=null
allocation6=null
allocation7=new byte[2*_1MB]
}
HandlePromotionFailure=false 参数来运行的结果:
[GC[DefNew 6651K- 148K 9216K ), 0.0078936 secs]6651K- 4244K 19456K ), 0.0079192 secs][Times user=0.00 sys=0.02 real=0.02 secs]
[G C[D e f N e w 6 3 7 8 K- 6 3 7 8 K 9 2 1 6 K ), 0.0 0 0 0 2 0 6 s e c s][T e n u r e d 4096K- 4244K 10240K ), 0.0042901 secs]10474K-
4244K 19456K ), [Perm 2104K- 2104K 12288K ] 0.0043613 secs][Times user=0.00 sys=0.00 real=0.00 secs]
HandlePromotionFailure=true 参数来运行的结果:
[GC[DefNew 6651K- 148K 9216K ), 0.0054913 secs]6651K- 4244K 19456K ), 0.0055327 secs][Times user=0.00 sys=0.00 real=0.00 secs]
[GC[DefNew 6378K- 148K 9216K ), 0.0006584 secs]10474K- 4244K 19456K ), 0.0006857 secs][Times user=0.00 sys=0.00 real=0.00 secs]
JDK 6 Update 24 之后,这个测试结果会有差异, HandlePromotionFailure 参数不会再影 响到虚拟机的空间分配担保策略,观察 OpenJDK 中的源码变化(见代码清单 3-10 ),虽然源
码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它。 JDK 6 Update
24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就
会进行 Minor GC ,否则将进行 Full GC
代码清单 3-10 HotSpot 中空间分配检查的代码片段
bool TenuredGeneration promotion_attempt_is_safe size_t
max
_
promotion
_
in
_
bytes const{
// 老年代最大可用的连续空间
size
_
t available=max
_
contiguous
_
available ();
// 每次晋升到老年代的平均大小
size
_
t av
_
promo= size
_
t gc
_
stats () - avg
_
promoted () - padded
_
average ();
// 老年代可用空间是否大于平均晋升大小,或者老年代可用空间是否大于当此 GC 时新生代所有对象容量
bool res= available =av_promo || available =
max_promotion_in_bytes );
return res
} 3.7 本章小结
本章介绍了垃圾收集的算法、几款 JDK 1.7 中提供的垃圾收集器特点以及运作原理。通
过代码实例验证了 Java 虚拟机中自动内存分配及回收的主要规则。
内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一,虚拟
机之所以提供多种不同的收集器以及提供大量的调节参数,是因为只有根据实际应用需求、
实现方式选择最优的收集方式才能获取最高的性能。没有固定收集器、参数组合,也没有最
优的调优方法,虚拟机也就没有什么必然的内存回收行为。因此,学习虚拟机内存知识,如
果要到实践调优阶段,那么必须了解每个具体收集器的行为、优势和劣势、调节参数。在接
下来的两章中,作者将会介绍内存分析的工具和调优的一些具体案例。 4 章 虚拟机性能监控与故障处理工具
Java C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的 高墙 ,墙外面的人想
进去,墙里面的人却想出来。
4.1 概述
经过前面两章对于虚拟机内存分配与回收技术各方面的介绍,相信读者已经建立了一套
比较完整的理论基础。理论总是作为指导实践的工具,能把这些知识应用到实际工作中才是
我们的最终目的。接下来的两章,我们将从实践的角度去了解虚拟机内存管理的世界。
给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处
理数据的手段。这里说的数据包括:运行日志、异常堆栈、 GC 日志、线程快照
threaddump/javacore 文件)、堆转储快照( heapdump/hprof 文件)等。经常使用适当的虚拟
机监控和分析的工具可以加快我们分析数据、定位解决问题的速度,但在学习工具前,也应
当意识到工具永远都是知识技能的一层包装,没有什么工具是 秘密武器 ,不可能学会了就
能包治百病。 4.2 JDK 的命令行工具
Java 开发人员肯定都知道 JDK bin 目录中有 “java.exe” “javac.exe” 这两个命令行工具,
但并非所有程序员都了解过 JDK bin 目录之中其他命令行程序的作用。每逢 JDK 更新版本之
时, bin 目录下命令行工具的数量和功能总会不知不觉地增加和增强。 bin 目录的内容如图 4-1
所示。
在本章中,笔者将介绍这些工具的其中一部分,主要包括用于监视虚拟机和故障处理的
工具。这些故障处理工具被 Sun 公司作为 礼物 附赠给 JDK 的使用者,并在软件的使用说明中
把它们声明为 没有技术支持并且是实验性质的 unsupported and experimental
[1]
的产品,但
事实上,这些工具都非常稳定而且功能强大,能在处理应用程序性能问题、定位故障时发挥
很大的作用。
4-1 Sun JDK 中的工具目录
说起 JDK 的工具,比较细心的读者,可能会注意到这些工具的程序体积都异常小巧。假
如以前没注意到,现在不妨再看看图 4-1 中的最后一列 大小 ,几乎所有工具的体积基本上
都稳定在 27KB 左右。并非 JDK 开发团队刻意把它们制作得如此精炼来炫耀编程水平,而是因
为这些命令行工具大多数是 jdk/lib/tools.jar 类库的一层薄包装而已,它们主要的功能代码是
tools 类库中实现的。读者把图 4-1 和图 4-2 两张图片对比一下就可以看得很清楚。 假如读者使用的是 Linux 版本的 JDK ,还会发现这些工具中很多甚至就是由 Shell 脚本直接
写成的,可以用 vim 直接打开它们。
JDK 开发团队选择采用 Java 代码来实现这些监控工具是有特别用意的:当应用程序部署
到生产环境后,无论是直接接触物理服务器还是远程 Telnet 到服务器上都可能会受到限制。
借助 tools.jar 类库里面的接口,我们可以直接在应用程序中实现功能强大的监控分析功能
[2]
4-2 tools.jar 包的内部状况
需要特别说明的是,本章介绍的工具全部基于 Windows 平台下的 JDK 1.6 Update 21 ,如
JDK 版本、操作系统不同,工具所支持的功能可能会有较大差别。大部分工具在 JDK 1.5
就已经提供,但为了避免运行环境带来的差异和兼容性问题,建议读者使用 JDK 1.6 来验证
本章介绍的内容,因为 JDK 1.6 的工具可以正常兼容运行于 JDK 1.5 的虚拟机之上的程序,反
之则不一定。表 4-1 中说明了 JDK 主要命令行监控工具的用途。
注意 如果读者在工作中需要监控运行于 JDK 1.5 的虚拟机之上的程序,在程序启动时
请添加参数 “-Dcom.sun.management.jmxremote” 开启 JMX 管理功能,否则由于部分工具都是基
JMX (包括 4.3 节介绍的可视化工具),它们都将会无法使用,如果被监控程序运行于 JDK
1.6 的虚拟机之上,那 JMX 管理默认是开启的,虚拟机启动时无须再添加任何参数。 4.2.1 jps :虚拟机进程状况工具
JDK 的很多小工具的名字都参考了 UNIX 命令的命名方式, jps JVM
Process
Status
Tool )是其中的典型。除了名字像 UNIX ps 命令之外,它的功能也和 ps 命令类似:可以列出
正在运行的虚拟机进程,并显示虚拟机执行主类( Main Class,main ()函数所在的类)名称
以及这些进程的本地虚拟机唯一 ID Local Virtual Machine Identifier,LVMID )。虽然功能比较
单一,但它是使用频率最高的 JDK 命令行工具,因为其他的 JDK 工具大多需要输入它查询到
LVMID 来确定要监控的是哪一个虚拟机进程。对于本地虚拟机进程来说, LVMID 与操作系
统的进程 ID Process Identifier,PID )是一致的,使用 Windows 的任务管理器或者 UNIX ps
令也可以查询到虚拟机进程的 LVMID ,但如果同时启动了多个虚拟机进程,无法根据进程名
称定位时,那就只能依赖 jps 命令显示主类的功能才能区分了。
jsp 命令格式:
jps[options][hostid]
jps 执行样例:
D \Develop\Java\jdk1.6.0_21\bin jps-l
2388 D \Develop\glassfish\bin\..\modules\admin-cli.jar
2764 com.sun.enterprise.glassfish.bootstrap.ASMain
3788 sun.tools.jps.Jps
jps 可以通过 RMI 协议查询开启了 RMI 服务的远程虚拟机进程状态, hostid RMI 注册表中
注册的主机名。 jps 的其他常用选项见表 4-2 [1] http://download.oracle.com/javase/6/docs/technotes/tools/index.html
[2] tools.jar 中的类库不属于 Java 的标准 API ,如果引入这个类库,就意味着用户的程序只能运
行于 Sun
Hotspot (或一些从 Sun 公司购买了 JDK 的源码 License 的虚拟机,如 IBM
J9 BEA
JRockit )上面,或者在部署程序时需要一起部署 tools.jar 4.2.2 jstat :虚拟机统计信息监视工具
jstat JVM Statistics Monitoring Tool )是用于监视虚拟机各种运行状态信息的命令行工
具。它可以显示本地或者远程
[1]
虚拟机进程中的类装载、内存、垃圾收集、 JIT 编译等运行数
据,在没有 GUI 图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟
机性能问题的首选工具。
jstat 命令格式为:
jstat[option vmid[interval[s|ms][count]]]
对于命令格式中的 VMID LVMID 需要特别说明一下:如果是本地虚拟机进程, VMID
LVMID 是一致的,如果是远程虚拟机进程,那 VMID 的格式应当是:
[protocol ][//]lvmid[@hostname[ port]/servername]
参数 interval count 代表查询间隔和次数,如果省略这两个参数,说明只查询一次。假设
需要每 250 毫秒查询一次进程 2764 垃圾收集状况,一共查询 20 次,那命令应当是:
jstat-gc 2764 250 20
选项 option 代表着用户希望查询的虚拟机信息,主要分为 3 类:类装载、垃圾收集、运行
期编译状况,具体选项及作用请参考表 4-3 中的描述。 jstat 监视选项众多,囿于版面原因无法逐一演示,这里仅举监视一台刚刚启动的
GlassFish v3 服务器的内存状况的例子来演示如何查看监视结果。监视参数与输出结果如代码
清单 4-1 所示。
代码清单 4-1 jstat 执行样例
D \Develop\Java\jdk1.6.0_21\bin jstat-gcutil 2764
S0 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% 的空间,两个
Survivor 区( S0 S1 ,表示 Survivor0 Survivor1 )里面都是空的,老年代( O ,表示 Old )和
永久代( P ,表示 Permanent )则分别使用了 41.42% 47.20% 的空间。程序运行以来共发生
Minor GC YGC ,表示 Young GC 16 次,总耗时 0.105 秒,发生 Full GC FGC ,表示 Full
GC 3 次, Full GC 总耗时( FGCT ,表示 Full GC Time )为 0.472 秒,所有 GC 总耗时( GCT
表示 GC Time )为 0.577 秒。
使用 jstat 工具在纯文本状态下监视虚拟机状态的变化,确实不如后面将会提到的
VisualVM 等可视化的监视工具直接以图表展现那样直观。但许多服务器管理员都习惯了在文
本控制台中工作,直接在控制台中使用 jstat 命令依然是一种常用的监控方式。
[1] 需要远程主机提供 RMI 支持, Sun 提供的 jstatd 工具可以很方便地建立远程 RMI 服务器。 4.2.3 jinfo Java 配置信息工具
jinfo Configuration Info for Java )的作用是实时地查看和调整虚拟机各项参数。使用 jps
命令的 -v 参数可以查看虚拟机启动时显式指定的参数列表,但如果想知道未被显式指定的参
数的系统默认值,除了去找资料外,就只能使用 jinfo -flag 选项进行查询了(如果只限于
JDK 1.6 或以上版本的话,使用 java-XX +PrintFlagsFinal 查看参数默认值也是一个很好的选
择), jinfo 还可以使用 -sysprops 选项把虚拟机进程的 System.getProperties ()的内容打印出
来。这个命令在 JDK
1.5 时期已经随着 Linux 版的 JDK 发布,当时只提供了信息查询的功
能, JDK
1.6 之后, jinfo Windows Linux 平台都有提供,并且加入了运行期修改参数的能
力,可以使用 -flag[+|-]name 或者 -flag
name=value 修改一部分运行期可写的虚拟机参数值。
JDK 1.6 中, jinfo 对于 Windows 平台功能仍然有较大限制,只提供了最基本的 -flag 选项。
jinfo 命令格式:
jinfo[option]pid
执行样例:查询 CMSInitiatingOccupancyFraction 参数值。
C \ jinfo-flag CMSInitiatingOccupancyFraction 1444
-XX CMSInitiatingOccupancyFraction=85 4.2.4 jmap Java 内存映像工具
jmap Memory Map for Java )命令用于生成堆转储快照(一般称为 heapdump dump
件)。如果不使用 jmap 命令,要想获取 Java 堆转储快照,还有一些比较 暴力 的手段:譬如
在第 2 章中用过的 -XX +HeapDumpOnOutOfMemoryError 参数,可以让虚拟机在 OOM 异常出
现之后自动生成 dump 文件,通过 -XX +HeapDumpOnCtrlBreak 参数则可以使用 [Ctrl]+[Break]
键让虚拟机生成 dump 文件,又或者在 Linux 系统下通过 Kill-3 命令发送进程退出信号 吓唬
下虚拟机,也能拿到 dump 文件。
jmap 的作用并不仅仅是为了获取 dump 文件,它还可以查询 finalize 执行队列、 Java 堆和永
久代的详细信息,如空间使用率、当前用的是哪种收集器等。
jinfo 命令一样, jmap 有不少功能在 Windows 平台下都是受限的,除了生成 dump 文件的 -
dump 选项和用于查看每个类的实例、空间占用统计的 -histo 选项在所有操作系统都提供之
外,其余选项都只能在 Linux/Solaris 下使用。
jmap 命令格式:
jmap[option]vmid
option 选项的合法值与具体含义见表 4-4
代码清单 4-2 是使用 jmap 生成一个正在运行的 Eclipse dump 快照文件的例子,例子中的
3500 是通过 jps 命令查询到的 LVMID
代码清单 4-2 使用 jmap 生成 dump 文件 C \Users\IcyFenix jmap-dump format=b,file=eclipse.bin 3500
Dumping heap to C \Users\IcyFenix\eclipse.bin……
Heap dump file created 4.2.5 jhat :虚拟机堆转储快照分析工具
Sun JDK 提供 jhat JVM Heap Analysis Tool )命令与 jmap 搭配使用,来分析 jmap 生成的堆
转储快照。 jhat 内置了一个微型的 HTTP/HTML 服务器,生成 dump 文件的分析结果后,可以在
浏览器中查看。不过实事求是地说,在实际工作中,除非笔者手上真的没有别的工具可用,
否则一般都不会去直接使用 jhat 命令来分析 dump 文件,主要原因有二:一是一般不会在部署
应用程序的服务器上直接分析 dump 文件,即使可以这样做,也会尽量将 dump 文件复制到其
他机器
[1]
上进行分析,因为分析工作是一个耗时而且消耗硬件资源的过程,既然都要在其他
机器进行,就没有必要受到命令行工具的限制了;另一个原因是 jhat 的分析功能相对来说比
较简陋,后文将会介绍到的 VisualVM ,以及专业用于分析 dump 文件的 Eclipse
Memory
Analyzer IBM HeapAnalyzer
[2]
等工具,都能实现比 jhat 更强大更专业的分析功能。代码清单 4-
3 演示了使用 jhat 分析 4.2.4 节中采用 jmap 生成的 Eclipse IDE 的内存快照文件。
代码清单 4-3 使用 jhat 分析 dump 文件
C \Users\IcyFenix jhat eclipse.bin
Reading from eclipse.bin……
Dump file created Fri Nov 19 22 07 21 CST 2010
Snapshot read,resolving……
Resolving 1225951 objects……
Chasing references,expect 245 dots……
Eliminating duplicate references……
Snapshot resolved.
Started HTTP server on port 7000
Server is ready.
屏幕显示 “Server is ready.” 的提示后,用户在浏览器中键入 http://localhost 7000/ 就可以
看到分析结果,如图 4-3 所示。 4-3 jhat 的分析结果
分析结果默认是以包为单位进行分组显示,分析内存泄漏问题主要会使用到其中
“Heap Histogram” (与 jmap-histo 功能一样)与 OQL 页签的功能,前者可以找到内存中总容
量最大的对象,后者是标准的对象查询语言,使用类似 SQL 的语法对内存中的对象进行查询
统计,读者若对 OQL 有兴趣的话,可以参考本书附录 D 的介绍。
[1] 用于分析的机器一般也是服务器,由于加载 dump 快照文件需要比生成 dump 更大的内存,
所以一般在 64 JDK 、大内存的服务器上进行。
[2] IBM HeapAnalyzer 用于分析 IBM J9 虚拟机生成的映像文件,各个虚拟机产生的映像文件格
式并不一致,所以分析工具也不能通用。 4.2.6 jstack Java 堆栈跟踪工具
jstack Stack Trace for Java )命令用于生成虚拟机当前时刻的线程快照(一般称为
threaddump 或者 javacore 文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈
的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循
环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因。线程出现停顿
的时候通过 jstack 来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些
什么事情,或者等待着什么资源。
jstack 命令格式:
jstack[option]vmid
option 选项的合法值与具体含义见表 4-5
代码清单 4-4 是使用 jstack 查看 Eclipse 线程堆栈的例子,例子中的 3500 是通过 jps 命令查询
到的 LVMID
代码清单 4-4 使用 jstack 查看线程堆栈(部分结果)
C \Users\IcyFenix jstack-l 3500
2010-11-19 23 11 26
Full thread dump Java HotSpot TM 64-Bit Server VM 17.1-b03 mixed mode ):
"[ThreadPool Manager]-Idle Thread"daemon prio=6 tid=0x0000000039dd4000 nid=0xf50 in Object.wait () [0x000000003c96f000]
java.lang.Thread.State WAITING on object monitor
at java.lang.Object.wait Native Method
-waiting on 0x0000000016bdcc60 >( a org.eclipse.equinox.internal.util.impl.tpt.threadpool.Executor
at java.lang.Object.wait Object.java 485
at org.eclipse.equinox.internal.util.impl.tpt.threadpool.Executor.run Executor.java 106
-locked 0x0000000016bdcc60 >( a org.eclipse.equinox.internal.util.impl.tpt.threadpool.Executor
Locked ownable synchronizers
-None
JDK
1.5 中, java.lang.Thread 类新增了一个 getAllStackTraces ()方法用于获取虚拟机
中所有线程的 StackTraceElement 对象。使用这个方法可以通过简单的几行代码就完成 jstack
大部分功能,在实际项目中不妨调用这个方法做个管理员页面,可以随时使用浏览器来查看
线程堆栈,如代码清单 4-5 所示,这是笔者的一个小经验。
代码清单 4-5 查看线程状况的 JSP 页面
%@page import="java.util.Map"%
html
head
title >服务器线程信息< /title
/head
body
pre
%
for Map.Entry Thread,StackTraceElement[] stackTrace Thread.
getAllStackTraces () .entrySet ()) {
Thread thread= Thread stackTrace.getKey ();
StackTraceElement[]stack= StackTraceElement[] stackTrace.getValue (); if thread.equals Thread.currentThread ())) {
continue
}
out.print "\n 线程: "+thread.getName () +"\n" );
for StackTraceElement element stack {
out.print "\t"+element+"\n" );
}
}
%
/pre
/body
/html 4.2.7 HSDIS JIT 生成代码反汇编
Java 虚拟机规范中,详细描述了虚拟机指令集中每条指令的执行过程、执行前后对操
作数栈、局部变量表的影响等细节。这些细节描述与 Sun 的早期虚拟机( Sun Classic VM )高
度吻合,但随着技术的发展,高性能虚拟机真正的细节实现方式已经渐渐与虚拟机规范所描
述的内容产生了越来越大的差距,虚拟机规范中的描述逐渐成了虚拟机实现的 概念模
”—— 即实现只能保证规范描述等效。基于这个原因,我们分析程序的执行语义问题(虚
拟机做了什么)时,在字节码层面上分析完全可行,但分析程序的执行行为问题(虚拟机是
怎样做的、性能如何)时,在字节码层面上分析就没有什么意义了,需要通过其他方式解
决。
分析程序如何执行,通过软件调试工具( GDB Windbg 等)来断点调试是最常见的手
段,但是这样的调试方式在 Java 虚拟机中会遇到很大困难,因为大量执行代码是通过 JIT 编译
器动态生成到 CodeBuffer 中的,没有很简单的手段来处理这种混合模式的调试(不过相信虚
拟机开发团队内部肯定是有内部工具的)。因此,不得不通过一些特别的手段来解决问题,
基于这种背景,本节的主角 ——HSDIS 插件就正式登场了。
HSDIS 是一个 Sun 官方推荐的 HotSpot 虚拟机 JIT 编译代码的反汇编插件,它包含在 HotSpot
虚拟机的源码之中,但没有提供编译后的程序。在 Project Kenai 的网站
[1]
也可以下载到单独的
源码。它的作用是让 HotSpot -XX +PrintAssembly 指令调用它来把动态生成的本地代码还
原为汇编代码输出,同时还生成了大量非常有价值的注释,这样我们就可以通过输出的代码
来分析问题。读者可以根据自己的操作系统和 CPU 类型从 Project Kenai 的网站上下载编译好
的插件,直接放到 JDK_HOME/jre/bin/client JDK_HOME/jre/bin/server 目录中即可。如果没
有找到所需操作系统(譬如 Windows 的就没有)的成品,那就得自己使用源码编译一下
[2]
还需要注意的是,如果读者使用的是 Debug 或者 FastDebug 版的 HotSpot ,那可以直接通
-XX +PrintAssembly 指令使用插件;如果使用的是 Product 版的 HotSpot ,那还要额外加入
一个 -XX +UnlockDiagnosticVMOptions 参数。笔者以代码清单 4-6 中的简单测试代码为例演
示一下这个插件的使用。
代码清单 4-6 测试代码
public class Bar{
int a=1
static int b=2
public int sum int c {
return a+b+c
}
public static void main String[]args {
new Bar () .sum 3 );
}
}
编译这段代码,并使用以下命令执行。
java-XX +PrintAssembly-Xcomp-XX CompileCommand=dontinline *Bar.sum-XX Compi leCommand=compileonly *Bar.sum test.Bar
其中,参数 -Xcomp 是让虚拟机以编译模式执行代码,这样代码可以 偷懒 ,不需要执行
足够次数来预热就能触发 JIT 编译
[3]
。两个 -XX CompileCommand 意思是让编译器不要内联
sum ()并且只编译 sum (), -XX +PrintAssembly 就是输出反汇编内容。如果一切顺利的
话,那么屏幕上会出现类似下面代码清单 4-7 所示的内容。 代码清单 4-7 测试代码
[Disassembling for mach='i386']
[Entry Point]
[Constants]
#{method}'sum'' I I'in'test/Bar'
#this ecx='test/Bar'
#parm0 edx=int
#[sp+0x20] sp of caller
……
0x01cac407 cmp 0x4 %ecx ), %eax
0x01cac40a jne 0x01c6b050 {runtime_call}
[Verified Entry Point]
0x01cac410 mov%eax -0x8000 %esp
0x01cac417 push%ebp
0x01cac418 sub$0x18 %esp *aload_0
-test.Bar sum@0 line 8
block B0[0 10]
0x01cac41b mov 0x8 %ecx ), %eax *getfield a
-test.Bar sum@1 line 8
0x01cac41e mov$0x3d2fad8 %esi {oop a
'java/lang/Class'='test/Bar' }
0x01cac423 mov 0x68 %esi ), %esi *getstatic b
-test.Bar sum@4 line 8
0x01cac426 add%esi %eax
0x01cac428 add%edx %eax
0x01cac42a add$0x18 %esp
0x01cac42d pop%ebp
0x01cac42e test%eax 0x2b0100 {poll_return}
0x01cac434 ret
上段代码并不多,下面一句句进行说明。
1 mov%eax -0x8000 %esp ):检查栈溢。
2 push%ebp :保存上一栈帧基址。
3 sub$0x18 %esp :给新帧分配空间。
4 mov 0x8 %ecx ), %eax :取实例变量 a ,这里 0x8 %ecx )就是 ecx+0x8 的意思,前
“[Constants]” 节中提示了 “this ecx='test/Bar'” ,即 ecx 寄存器中放的就是 this 对象的地址。偏
0x8 是越过 this 对象的对象头,之后就是实例变量 a 的内存位置。这次是访问 “Java 中的数
据。
5 mov$0x3d2fad8 %esi :取 test.Bar 在方法区的指针。
6 mov 0x68 %esi ), %esi :取类变量 b ,这次是访问 方法区 中的数据。
7 add%esi %eax add%edx %eax :做两次加法,求 a+b+c 的值,前面的代码把 a 放在
eax 中,把 b 放在 esi 中,而 c [Constants] 中提示了, “parm0 edx=int” ,说明 c edx 中。
8 add$0x18 %esp :撤销栈帧。
9 pop%ebp :恢复上一栈帧。
10 test%eax 0x2b0100 :轮询方法返回处的 SafePoint
11 ret :方法返回。
[1] Project Kenai http://kenai.com/projects/base-hsdis
[2] HLLVM 圈子中有已编译好的: http://hllvm.group.iteye.com/
[3] -Xcomp 在较新的 HotSpot 中被移除了,如果读者的虚拟机无法使用这个参数,请加个循环
预热代码,触发 JIT 编译。 4.3 JDK 的可视化工具
JDK 中除了提供大量的命令行工具外,还有两个功能强大的可视化工具: JConsole
VisualVM ,这两个工具是 JDK 的正式成员,没有被贴上 “unsupported and experimental” 的标
签。
其中 JConsole 是在 JDK
1.5 时期就已经提供的虚拟机监控工具,而 VisualVM JDK 1.6
Update7 中才首次发布,现在已经成为 Sun Oracle )主力推动的多合一故障处理工具
[1]
,并且
已经从 JDK 中分离出来成为可以独立发展的开源项目。
为了避免本节的讲解成为对软件说明文档的简单翻译,笔者准备了一些代码样例,都是
笔者特意编写的 反面教材 。后面将会使用这两款工具去监控、分析这几段代码存在的问
题,算是本节简单的实战分析。读者可以把在可视化工具观察到的数据、现象,与前面两章
中讲解的理论知识互相印证。
4.3.1 JConsole Java 监视与管理控制台
JConsole Java Monitoring and Management Console )是一种基于 JMX 的可视化监视、管
理工具。它管理部分的功能是针对 JMX MBean 进行管理,由于 MBean 可以使用代码、中间件
服务器的管理控制台或者所有符合 JMX 规范的软件进行访问,所以本节将会着重介绍
JConsole 监视部分的功能。
1. 启动 JConsole
通过 JDK/bin 目录下的 “jconsole.exe” 启动 JConsole 后,将自动搜索出本机运行的所有虚拟
机进程,不需要用户自己再使用 jps 来查询了,如图 4-4 所示。双击选择其中一个进程即可开
始监控,也可以使用下面的 远程进程 功能来连接远程服务器,对远程虚拟机进行监控。 4-4 JConsole 连接页面
从图 4-4 可以看出,笔者的机器现在运行了 Eclipse JConsole MonitoringTest 三个本地虚
拟机进程,其中 MonitoringTest 就是笔者准备的 反面教材 代码之一。双击它进入 JConsole
界面,可以看到主界面里共包括 概述 内存 线程 “VM 摘要 “MBean”6
页签,如图 4-5 所示。 4-5 JConsole 主界面
概述 页签显示的是整个虚拟机主要运行数据的概览,其中包括 堆内存使用情况
线程 “CPU 使用情况 ”4 种信息的曲线图,这些曲线图是后面 内存 线程
页签的信息汇总,具体内容将在后面介绍。
2. 内存监控
内存 页签相当于可视化的 jstat 命令,用于监视受收集器管理的虚拟机内存( Java 堆和
永久代)的变化趋势。我们通过运行代码清单 4-8 中的代码来体验一下它的监视功能。运行
时设置的虚拟机参数为: -Xms100m-Xmx100m-XX +UseSerialGC ,这段代码的作用是以
64KB/50 毫秒的速度往 Java 堆中填充数据,一共填充 1000 次,使用 JConsole 内存 页签进行
监视,观察曲线和柱状指示图的变化。
代码清单 4-8 JConsole 监视代码 /**
* 内存占位符对象,一个 OOMObject 大约占 64KB
*/
static class OOMObject{
public byte[]placeholder=new byte[64*1024]
}
public static void fillHeap int num throws InterruptedException{
List OOMObject list=new ArrayList OOMObject >();
for int i=0 i num i++ {
// 稍作延时,令监视曲线的变化更加明显
Thread.sleep 50 );
list.add new OOMObject ());
}
System.gc ();
}
public static void main String[]args throws Exception{
fillHeap 1000 );
}
程序运行后,在 内存 页签中可以看到内存池 Eden 区的运行趋势呈现折线状,如图 4-6
所示。而监视范围扩大至整个堆后,会发现曲线是一条向上增长的平滑曲线。并且从柱状图
可以看出,在 1000 次循环执行结束,运行了 System.gc ()后,虽然整个新生代 Eden
Survivor 区都基本被清空了,但是代表老年代的柱状图仍然保持峰值状态,说明被填充进堆
中的数据在 System.gc ()方法执行之后仍然存活。笔者的分析到此为止,现提两个小问题供
读者思考一下,答案稍后给出。
1 )虚拟机启动参数只限制了 Java 堆为 100MB ,没有指定 -Xmn 参数,能否从监控图中估
计出新生代有多大?
2 )为何执行了 System.gc ()之后,图 4-6 中代表老年代的柱状图仍然显示峰值状态,代
码需要如何调整才能让 System.gc ()回收掉填充到堆中的对象? 4-6 Eden 区内存变化状况
问题 1 答案:图 4-6 显示 Eden 空间为 27 328KB ,因为没有设置 -XX SurvivorRadio 参数,
所以 Eden Survivor 空间比例为默认值 8:1 ,整个新生代空间大约为 27
328KB×125%=34
160KB
问题 2 答案:执行完 System.gc ()之后,空间未能回收是因为 List OOMObject list 对象
仍然存活, fillHeap ()方法仍然没有退出,因此 list 对象在 System.gc ()执行时仍然处于作
用域之内
[2]
。如果把 System.gc ()移动到 fillHeap ()方法外调用就可以回收掉全部内存。
3. 线程监控
如果上面的 内存 页签相当于可视化的 jstat 命令的话, 线程 页签的功能相当于可视化
jstack 命令,遇到线程停顿时可以使用这个页签进行监控分析。前面讲解 jstack 命令的时候
提到过线程长时间停顿的主要原因主要有:等待外部资源(数据库连接、网络资源、设备资 源等)、死循环、锁等待(活锁和死锁)。通过代码清单 4-9 分别演示一下这几种情况。
代码清单 4-9 线程等待演示代码
/**
* 线程死循环演示
*/
public static void createBusyThread () {
Thread thread=new Thread new Runnable () {
@Override
public void run () {
while true // 41
}
} "testBusyThread" );
thread.start ();
}
/**
* 线程锁等待演示
*/
public static void createLockThread final Object lock {
Thread thread=new Thread new Runnable () {
@Override
public void run () {
synchronized lock {
try{
lock.wait ();
}catch InterruptedException e {
e.printStackTrace ();
}
}
}
} "testLockThread" );
thread.start ();
}
public static void main String[]args throws Exception{
BufferedReader br=new BufferedReader new InputStreamReader System.in ));
br.readLine ();
createBusyThread ();
br.readLine ();
Object obj=new Object ();
createLockThread obj );
}
程序运行后,首先在 线程 页签中选择 main 线程,如图 4-7 所示。堆栈追踪显示
BufferedReader readBytes 方法中等待 System.in 的键盘输入,这时线程为 Runnable
态, Runnable 状态的线程会被分配运行时间,但 readBytes 方法检查到流没有更新时会立刻归
还执行令牌,这种等待只消耗很小的 CPU 资源。
4-7 main 线程
接着监控 testBusyThread 线程,如图 4-8 所示, testBusyThread 线程一直在执行空循环,从
堆栈追踪中看到一直在 MonitoringTest.java 代码的 41 行停留, 41 行为: while true )。这时候
线程为 Runnable 状态,而且没有归还线程执行令牌的动作,会在空循环上用尽全部执行时间
直到线程切换,这种等待会消耗较多的 CPU 资源。 4-8 testBusyThread 线程
4-9 显示 testLockThread 线程在等待着 lock 对象的 notify notifyAll 方法的出现,线程这时
候处于 WAITING 状态,在被唤醒前不会被分配执行时间。
4-9 testLockThread 线程
testLockThread 线程正在处于正常的活锁等待,只要 lock 对象的 notify ()或 notifyAll ()
方法被调用,这个线程便能激活以继续执行。代码清单 4-10 演示了一个无法再被激活的死锁
等待。
代码清单 4-10 死锁代码样例
/**
* 线程死锁等待演示
*/
static class SynAddRunalbe implements Runnable{
int a,b
public SynAddRunalbe int a,int b {
this.a=a
this.b=b
}
@Override
public void run () {
synchronized Integer.valueOf a )) {
synchronized Integer.valueOf b )) {
System.out.println a+b );
}
}
}
}
public static void main String[]args {
for int i=0 i 100 i++ {
new Thread new SynAddRunalbe 1 2 )) .start ();
new Thread new SynAddRunalbe 2 1 )) .start (); }
}
这段代码开了 200 个线程去分别计算 1+2 以及 2+1 的值,其实 for 循环是可省略的,两个线
程也可能会导致死锁,不过那样概率太小,需要尝试运行很多次才能看到效果。一般的话,
for 循环的版本最多运行 2 3 次就会遇到线程死锁,程序无法结束。造成死锁的原因是
Integer.valueOf ()方法基于减少对象创建次数和节省内存的考虑, [-128 127] 之间的数字会
被缓存
[3]
,当 valueOf ()方法传入参数在这个范围之内,将直接返回缓存中的对象。也就是
说,代码中调用了 200 Integer.valueOf ()方法一共就只返回了两个不同的对象。假如在某
个线程的两个 synchronized 块之间发生了一次线程切换,那就会出现线程 A 等着被线程 B 持有
Integer.valueOf 1 ),线程 B 又等着被线程 A 持有的 Integer.valueOf 2 ),结果出现大家都
跑不下去的情景。
出现线程死锁之后,点击 JConsole 线程面板的 检测到死锁 按钮,将出现一个新的
页签,如图 4-10 所示。
4-10 线程死锁
4-10 中很清晰地显示了线程 Thread-43 在等待一个被线程 Thread-12 持有 Integer 对象,而
点击线程 Thread-12 则显示它也在等待一个 Integer 对象,被线程 Thread-43 持有,这样两个线程
就互相卡住,都不存在等到锁释放的希望了。
[1] VisualVM 官方站点: https://visualvm.dev.java.net/
[2] 准确地说,只有在虚拟机使用解释器执行的时候, 在作用域之内 才能保证它不会被回
收,因为这里的回收还涉及局部变量表 Slot 复用、即时编译器介入时机等问题,具体读者可
参考第 8 章中关于局部变量表内存回收的例子。
[3] 默认值,实际值取决于 java.lang.Integer.IntegerCache.high 参数的设置。 4.3.2 VisualVM :多合一故障处理工具
VisualVM All-in-One Java Troubleshooting Tool )是到目前为止随 JDK 发布的功能最强大
的运行监视和故障处理程序,并且可以预见在未来一段时间内都是官方主力发展的虚拟机故
障处理工具。官方在 VisualVM 的软件说明中写上了 “All-in-One” 的描述字样,预示着它除了
运行监视、故障处理外,还提供了很多其他方面的功能。如性能分析
Profiling ), VisualVM 的性能分析功能甚至比起 JProfiler YourKit 等专业且收费的 Profiling
工具都不会逊色多少,而且 VisualVM 的还有一个很大的优点:不需要被监视的程序基于特殊
Agent 运行,因此它对应用程序的实际性能的影响很小,使得它可以直接应用在生产环境
中。这个优点是 JProfiler YourKit 等工具无法与之媲美的。
1.VisualVM 兼容范围与插件安装
VisualVM 基于 NetBeans 平台开发,因此它一开始就具备了插件扩展功能的特性,通过插
件扩展支持, VisualVM 可以做到:
显示虚拟机进程以及进程的配置、环境信息( jps jinfo )。
监视应用程序的 CPU GC 、堆、方法区以及线程的信息( jstat jstack )。
dump 以及分析堆转储快照( jmap jhat )。
方法级的程序运行性能分析,找出被调用最多、运行时间最长的方法。
离线程序快照:收集程序的运行时配置、线程 dump 、内存 dump 等信息建立一个快照,
可以将快照发送开发者处进行 Bug 反馈。
其他 plugins 的无限的可能性 ……
VisualVM JDK 1.6 update 7 中才首次出现,但并不意味着它只能监控运行于 JDK 1.6
的程序,它具备很强的向下兼容能力,甚至能向下兼容至近 10 年前发布的 JDK 1.4.2 平台
[1]
这对无数已经处于实施、维护的项目很有意义。当然,并非所有功能都能完美地向下兼容,
主要特性的兼容性见表 4-6 首次启动 VisualVM 后,读者先不必着急找应用程序进行监测,因为现在 VisualVM 还没有
加载任何插件,虽然基本的监视、线程面板的功能主程序都以默认插件的形式提供了,但是
不给 VisualVM 装任何扩展插件,就相当于放弃了它最精华的功能,和没有安装任何应用软件
操作系统差不多。
插件可以进行手工安装,在相关网站
[2]
上下载 *.nbm 包后,点击 工具 ”→“ 插件 ”→“ 已下
菜单,然后在弹出的对话框中指定 nbm 包路径便可进行安装,插件安装后存放在
JDK_HOME/lib/visualvm/visualvm 中。不过手工安装并不常用,使用 VisualVM 的自动安装功
能已经可以找到大多数所需的插件,在有网络连接的环境下,点击 工具 ”→“ 插件菜单 ,弹
出如图 4-11 所示的插件页签,在页签的 可用插件 中列举了当前版本 VisualVM 可以使用的插
件,选中插件后在右边窗口将显示这个插件的基本信息,如开发者、版本、功能描述等。 4-11 VisualVM 插件页签
大家可以根据自己的工作需要和兴趣选择合适的插件,然后点击安装按钮,弹出如图 4-
12 所示的下载进度窗口,跟着提示操作即可完成安装。 4-12 VisualVM 插件安装过程
安装完插件,选择一个需要监视的程序就进入程序的主界面了,如图 4-13 所示。根据读
者选择安装插件数量的不同,看到的页签可能和图 4-13 中的有所不同。 4-13 VisualVM 主界面
VisualVM 概述 监视 线程 “MBeans” 的功能与前面介绍的 JConsole 差别不
大,读者根据上文内容类比使用即可,下面挑选几个特色功能、插件进行介绍。
2. 生成、浏览堆转储快照
VisualVM 中生成 dump 文件有两种方式,可以执行下列任一操作:
应用程序 窗口中右键单击应用程序节点,然后选择 Dump”
应用程序 窗口中双击应用程序节点以打开应用程序标签,然后在 监视 标签中单
Dump”
生成了 dump 文件之后,应用程序页签将在该堆的应用程序下增加一个以 [heapdump] 开头
的子节点,并且在主页签中打开了该转储快照,如图 4-14 所示。如果需要把 dump 文件保存或
发送出去,要在 heapdump 节点上右键选择 另存为 菜单,否则当 VisualVM 关闭时,生成的
dump 文件会被当做临时文件删除掉。要打开一个已经存在的 dump 文件,通过文件菜单中
装入 功能,选择硬盘上的 dump 文件即可。 4-14 浏览 dump 文件
从堆页签中的 摘要 面板可以看到应用程序 dump 时的运行时参数、
System.getProperties ()的内容、线程堆栈等信息, 面板则是以类为统计口径统计类的实
例数量、容量信息, 实例 面板不能直接使用,因为不能确定用户想查看哪个类的实例,所
以需要通过 面板进入,在 中选择一个关心的类后双击鼠标,即可在 实例 里面看见
此类中 500 个实例的具体属性信息。 “OQL 控制台 面板中就是运行 OQL 查询语句的,同 jhat
介绍的 OQL 功能一样。如果需要了解具体 OQL 语法和使用,可参见本书附录 D 的内容。
3. 分析程序性能
Profiler 页签中, VisualVM 提供了程序运行期间方法级的 CPU 执行时间分析以及内存分
析,做 Profiling 分析肯定会对程序运行性能有比较大的影响,所以一般不在生产环境中使用
这项功能。
要开始分析,先选择 “CPU” 内存 按钮中的一个,然后切换到应用程序中对程序进行
操作, VisualVM 会记录到这段时间中应用程序执行过的方法。如果是 CPU 分析,将会统计每
个方法的执行次数、执行耗时;如果是内存分析,则会统计每个方法关联的对象数以及这些
对象所占的空间。分析结束后,点击 停止 按钮结束监控过程,如图 4-15 所示。 4-15 对应用程序进行 CPU 执行时间分析
注意 在 JDK 1.5 之后,在 Client 模式下的虚拟机加入并且自动开启了类共享 —— 这是一
个在多虚拟机进程中共享 rt.jar 中类数据以提高加载速度和节省内存的优化,而根据相关 Bug
报告的反映, VisualVM Profiler 功能可能会因为类共享而导致被监视的应用程序崩溃,所以
读者进行 Profiling 前,最好在被监视程序中使用 -Xshare off 参数来关闭类共享优化。
4-15 中是对 Eclipse IDE 一段操作的录制和分析结果,读者分析自己的应用程序时,可
以根据实际业务的复杂程度与方法的时间、调用次数做比较,找到最有优化价值的方法。
4.BTrace 动态日志跟踪
BTrace
[3]
是一个很 有趣 VisualVM 插件,本身也是可以独立运行的程序。它的作用是
在不停止目标程序运行的前提下,通过 HotSpot 虚拟机的 HotSwap 技术

http://www.kler.cn/a/460002.html

相关文章:

  • Servlet解析
  • 【开源免费】基于SpringBoot+Vue.JS保密信息学科平台(JAVA毕业设计)
  • 算法解析-经典150(双指针、滑动窗口)
  • SQL进阶技巧:如何计算相互连接的计算机组成的集合?
  • filebeat采集应用程序日志和多行匹配
  • 汇编基础DOSBox的使用
  • 第10章 初等数论
  • python修改ppt中的文字部分及插入图片
  • 【TG\SE二次开发】天工CAD二次开发-c++模板介绍
  • UniApp 路由导航详解
  • 【数据结构】非线性数据结构——图
  • Oracle复合索引规则指南
  • 大模型Weekly 03|OpenAI o3发布;DeepSeek-V3上线即开源!
  • 【Linux知识】exec命令行详解
  • 关于 覆铜与导线之间间距较小需要增加间距 的解决方法
  • MATLAB语言的计算机基础
  • 自学记录HarmonyOS Next Image API 13:图像处理与传输的开发实践
  • 大数据研究方向有哪些创新点
  • Go中的逃逸分析
  • JS async await fetch 捕获后端500错误详细信息
  • Visual Studio 中增加的AI功能
  • 【文献精读笔记】Explainability for Large Language Models: A Survey (大语言模型的可解释性综述)(一)
  • JS中Symbol (符号)数据类型详解和应用场景
  • Gemma2 2B 模型的model.safetensors.index.json文件解析
  • win版ffmpeg的安装和操作
  • 基于问卷调查数据的多元统计数据分析与预测(因子分析、对应分析与逻辑回归)