java7核心技术与最佳实践


深入理解Java 7 ——核心技术与最佳实践 成富 著 ISBN:978-7-111-38039-9 本书纸版由机械工业出版社于2012年出版,电子版由华章分社(北京华章图文信息有限公司)全球范围内制作与发行。 版权所有,侵权必究 客服热线:+ 86-10-68995265 客服信箱:service@bbbvip.com 官方网址:www.hzmedia.com.cn 新浪微博 @研发书局 腾讯微博 @yanfabook 目 录 前言 为什么要写这本书 读者对象及如何阅读本书 勘误和支持 致谢 Java的挑战与展望 第1章 Java 7语法新特性 1.1 Coin项目介绍 1.2 在switch语句中使用字符串 1.2.1 基本用法 1.2.2 实现原理 1.2.3 枚举类型 1.3 数值字面量的改进 1.3.1 二进制整数字面量 1.3.2 在数值字面量中使用下划线 1.4 优化的异常处理 1.4.1 异常的基础知识 1.4.2 创建自己的异常 1.4.3 处理异常 1.4.4 Java 7的异常处理新特性 1.5 try-with-resources语句 1.6 优化变长参数的方法调用 1.7 小结 第2章 Java语言的动态性 2.1 脚本语言支持API 2.1.1 脚本引擎 2.1.2 语言绑定 2.1.3 脚本执行上下文 2.1.4 脚本的编译 2.1.5 方法调用 2.1.6 使用案例 2.2 反射API 2.2.1 获取构造方法 2.2.2 获取域 2.2.3 获取方法 2.2.4 操作数组 2.2.5 访问权限与异常处理 2.3 动态代理 2.3.1 基本使用方式 2.3.2 使用案例 2.4 动态语言支持 2.4.1 Java语言与Java虚拟机 2.4.2 方法句柄 2.4.3 invokedynamic指令 2.5 小结 第3章 Java I/O 3.1 流 3.1.1 基本输入流 3.1.2 基本输出流 3.1.3 输入流的复用 3.1.4 过滤输入输出流 3.1.5 其他输入输出流 3.1.6 字符流 3.2 缓冲区 3.2.1 基本用法 3.2.2 字节缓冲区 3.2.3 缓冲区视图 3.3 通道 3.3.1 文件通道 3.3.2 套接字通道 3.4 NIO.2 3.4.1 文件系统访问 3.4.2 zip/jar文件系统 3.4.3 异步I/O通道 3.4.4 套接字通道绑定与配置 3.4.5 IP组播通道 3.5 使用案例 3.6 小结 第4章 国际化与本地化 4.1 国际化概述 4.2 Unicode 4.2.1 Unicode编码格式 4.2.2 其他字符集 4.2.3 Java与Unicode 4.3 Java中的编码实践 4.3.1 Java NIO中的编码器和解码器 4.3.2 乱码问题详解 4.4 区域设置 4.4.1 IETF BCP 47 4.4.2 资源包 4.4.3 日期和时间 4.4.4 数字和货币 4.4.5 消息文本 4.4.6 默认区域设置的类别 4.4.7 字符串比较 4.5 国际化与本地化基本实践 4.6 小结 第5章 图形用户界面 5.1 Java图形用户界面概述 5.2 AWT 5.2.1 重要组件类 5.2.2 任意形状的窗口 5.2.3 半透明窗口 5.2.4 组件混合 5.3 Swing 5.3.1 重要组件类 5.3.2 JLayer组件和LayerUI类 5.4 事件处理与线程安全性 5.4.1 事件处理 5.4.2 事件分发线程 5.4.3 SwingWorker类 5.4.4 SecondaryLoop接口 5.5 界面绘制 5.5.1 AWT中的界面绘制 5.5.2 Swing中的绘制 5.6 可插拔式外观样式 5.7 JavaFX 5.7.1 场景图 5.7.2 变换 5.7.3 动画效果 5.7.4 FXML 5.7.5 CSS外观描述 5.7.6 Web引擎与网页显示 5.8 使用案例 5.9 小结 第6章 Java 7其他重要更新 6.1 关系数据库访问 6.1.1 使用try-with-resources语句 6.1.2 数据库查询的默认模式 6.1.3 数据库连接超时时间与终止 6.1.4 语句自动关闭 6.1.5 RowSet实现提供者 6.2 java.lang包的更新 6.2.1 基本类型的包装类 6.2.2 进程使用 6.2.3 Thread类的更新 6.3 Java实用工具类 6.3.1 对象操作 6.3.2 正则表达式 6.3.3 压缩文件处理 6.4 JavaBeans组件 6.4.1 获取组件信息 6.4.2 执行语句和表达式 6.4.3 持久化 6.5 小结 第7章 Java虚拟机 7.1 虚拟机基本概念 7.2 内存管理 7.3 引用类型 7.3.1 强引用 7.3.2 引用类型基本概念 7.3.3 软引用 7.3.4 弱引用 7.3.5 幽灵引用 7.3.6 引用队列 7.4 Java本地接口 7.4.1 JNI基本用法 7.4.2 Java程序中集成C/C++代码 7.4.3 在C/C++程序中启动Java虚拟机 7.5 HotSpot虚拟机 7.5.1 字节代码执行 7.5.2 垃圾回收 7.5.3 启动参数 7.5.4 分析工具 7.5.5 Java虚拟机工具接口 7.6 小结 第8章 Java源代码和字节代码操作 8.1 Java字节代码格式 8.1.1 基本格式 8.1.2 常量池的结构 8.1.3 属性 8.2 动态编译Java源代码 8.2.1 使用javac工具 8.2.2 Java编译器API 8.2.3 使用Eclipse JDT编译器 8.3 字节代码增强 8.3.1 使用ASM 8.3.2 增强代理 8.4 注解 8.4.1 注解类型 8.4.2 创建注解类型 8.4.3 使用注解类型 8.4.4 处理注解 8.5 使用案例 8.6 小结 第9章 Java类加载器 9.1 类加载器概述 9.2 类加载器的层次结构与代理模式 9.3 创建类加载器 9.4 类加载器的隔离作用 9.5 线程上下文类加载器 9.6 Class.forName方法 9.7 加载资源 9.8 Web应用中的类加载器 9.9 OSGi中的类加载器 9.9.1 OSGi基本的类加载器机制 9.9.2 Equinox框架的类加载实现机制 9.9.3 Equinox框架嵌入到Web容器中 9.10 小结 第10章 对象生命周期 10.1 Java类的链接 10.2 Java类的初始化 10.3 对象的创建与初始化 10.4 对象终止 10.5 对象复制 10.6 对象序列化 10.6.1 默认的对象序列化 10.6.2 自定义对象序列化 10.6.3 对象替换 10.6.4 版本更新 10.6.5 安全性 10.6.6 使用Externalizable接口 10.7 小结 第11章 多线程与并发编程实践 11.1 多线程 11.1.1 可见性 11.1.2 Java内存模型 11.1.3 volatile关键词 11.1.4 final关键词 11.1.5 原子操作 11.2 基本线程同步方式 11.2.1 synchronized关键词 11.2.2 Object类的wait、notify和notifyAll方法 11.3 使用Thread类 11.3.1 线程状态 11.3.2 线程中断 11.3.3 线程等待、睡眠和让步 11.4 非阻塞方式 11.5 高级实用工具 11.5.1 高级同步机制 11.5.2 底层同步器 11.5.3 高级同步对象 11.5.4 数据结构 11.5.5 任务执行 11.6 Java SE 7新特性 11.6.1 轻量级任务执行框架fork/join 11.6.2 多阶段线程同步工具 11.7 ThreadLocal类 11.8 小结 第12章 Java泛型 12.1 泛型基本概念 12.2 类型擦除 12.3 上界和下界 12.4 通配符 12.5 泛型与数组 12.6 类型系统 12.7 覆写与重载 12.7.1 覆写对方法类型签名的要求 12.7.2 覆写对返回值类型的要求 12.7.3 覆写对异常声明的要求 12.7.4 重载 12.8 类型推断和<>操作符 12.9 泛型与反射API 12.10 使用案例 12.11 小结 第13章 Java安全 13.1 Java安全概述 13.2 用户认证 13.2.1 主体、身份标识与凭证 13.2.2 登录 13.3 权限控制 13.3.1 权限、策略与保护域 13.3.2 访问控制权限 13.3.3 特权动作 13.3.4 访问控制上下文 13.3.5 守卫对象 13.4 加密与解密、报文摘要和数字签名 13.4.1 Java密码框架 13.4.2 加密与解密 13.4.3 报文摘要 13.4.4 数字签名 13.5 安全套接字连接 13.5.1 SSL协议 13.5.2 HTTPS 13.6 使用案例 13.7 小结 第14章 超越Java 7 14.1 lambda表达式 14.1.1 函数式接口 14.1.2 lambda表达式的语法 14.1.3 目标类型 14.1.4 词法作用域 14.1.5 方法引用 14.1.6 接口的默认方法 14.2 Java平台模块化 14.3 Java SE 8的其他更新 14.4 小结 附录A OpenJDK 附录B Java简史 前言 为什么要写这本书 我最早开始接触Java语言是在大学的时候。当时除了用Java开发一些小程序之外,就是用Struts框架开发Web应用。在后来的实习和工作 中,我对Java的使用和理解更加深入,逐渐涉及Java相关的各种不同技术。使用Java语言的一个深刻体会是:Java语言虽然上手容易,但是要 真正掌握并不容易。 Java语言对开发人员屏蔽了一些与底层实现相关的细节,但是仍然有很多内容对开发人员来说是很复杂的,这些内容恰好是容易出现错误 的地方。我在工作中就经常遇到与类加载器和垃圾回收相关的问题。在解决这些问题的过程中,我积累了一些经验,遇到类似的问题可以很快 地找到问题的根源。同时,在解决这些实际问题的过程中,我意识到虽然可以解决某些具体的问题,但是并没有真正理解这些问题的解决办法 背后所蕴含的基本原理,仍然还只是处于一个“知其然,不知其所以然”的状态。于是我开始阅读Java相关的基础资料,包括Java语言规范、 Java虚拟机规范、Java类库的源代码和其他在线资料等。在阅读的基础上,编写小程序进行测试和试验。通过阅读和实践,我对Java平台中的 一些基本概念有了更加深入的理解。从2010年开始,我对积累的相关知识进行了整理,在InfoQ中文站的“Java深度历险”专栏上发表出来,受 到了一定的关注。 2011年7月,在时隔数年之后,Java的一个重大版本Java SE 7发布了。在这个新的版本中,Java平台增加了很多新的特性。在Java虚拟机 方面,invokedynamic指令的加入使虚拟机上的动态语言的性能得到很大的提升。这使得开发人员可以享受到动态语言带来的在提高生产效率方 面的好处。在Java语言方面,语言本身的进一步简化,使开发人员编写代码的效率更高。在Java类库方面,新的IO库和同步实用工具类为开发 人员提供了更多实用的功能。从另外一个角度来说,Java SE 7是Oracle公司收购Sun公司之后发布的第一个Java版本,从侧面反映出了Oracle 公司对Java社区的领导力,可以继续推动Java平台向前发展。这可以打消企业和社区对于Oracle公司领导力的顾虑。Java SE 7的发布也证明了 基于JCP和OpenJDK的社区驱动模式可以很好地推动Java向前发展。 随着新版本的发布,肯定会有越来越多的开发人员想尝试使用Java SE 7中的新特性,毕竟开发者社区对这个新版本期待了太长的时间。在 Java程序中使用这些新特性,可以提高代码质量,提升工作效率。Java平台的每个版本都致力于提高Java程序的运行性能。随着新版本的发 布,企业都应该考虑把Java程序的运行平台升级到最新的Java SE 7,这样可以享受到性能提升所带来的好处。对于新的Java程序开发,推荐使 用Java SE 7作为标准的运行平台。本书将Java SE 7中的新特性介绍和对Java平台的深入探讨结合起来,让读者既可以了解最新版本的Java平 台的新特性,又可以对Java平台的底层细节有更加深入的理解。 读者对象及如何阅读本书 本书面向的主要读者是具备一定Java基础的开发人员和在校学生。本书中不涉及Java的基本语法,因此不适合Java初学者阅读。如果只对 Java SE 7中的新特性感兴趣,可以阅读第1章到第6章;如果对Java中的特定主题感兴趣,可以根据目录有选择地阅读。另外,第1章到第6章虽 然以Java SE 7的新特性介绍为主,但是其中也穿插了对相关内容的深入探讨。 本书可分为三大部分: 第一部分为Java SE 7新特性介绍,从第1章到第6章。这部分详细地介绍了Java SE 7中新增的重要特性。在对新特性的介绍中,也包含了 对Java平台相关内容的详细介绍。 第二部分为Java SE 7的深入探讨,从第7章到第13章。这部分着重讲解了Java平台上的底层实现,并对一些重要的特性进行了深入探讨。 这个部分所涉及的内容包括Java虚拟机、Java源代码和字节代码操作、Java类加载器、对象生命周期、多线程与并发编程实践、Java泛型和 Java安全。 第三部分为Java SE 8的内容展望,即第14章。这部分简要介绍了Java SE 8中将要增加的新特性。 本书还通过两个附录对OpenJDK(附录A)和Java语言的历史(附录B)进行了简要的介绍。 勘误和支持 由于作者的水平有限,编写时间仓促,书中难免会出现一些错误或者不准确的地方,恳请读者批评指正。如果您有更多的宝贵意见,欢迎 发送邮件至邮箱alexcheng1982@gmail.com,也可以通过微博(http://weibo.com/alexcheng1982)与我取得联系。期待能够得到您的真挚反 馈。 本书官方微群:http://q.weibo.com/943166。 本书中的源代码请登录华章公司的网站(http://www.hzbook.com)本书页面进行下载。 致谢 感谢InfoQ中文站和InfoQ编辑张凯峰先生。这本书能够面世,得益于我在InfoQ中文站的“Java深度历险”专栏上发表的文章。 感谢机械工业出版社华章公司的编辑杨福川和姜影的辛勤工作,使得这本书能够最终顺利完成。 感谢家人和朋友对我的支持与帮助! Java的挑战与展望 从Java语言出现到现在的16年间,在Java语言本身发展演化的同时,整个软件开发行业也在发生着巨大的变化。新的软件开发思想和程序 设计语言层出不穷。虽然Java语言一直是最流行的程序设计语言之一,但它也面临着来自其他编程语言的冲击。这其中主要是互联网应用发展 所带来的动态语言的影响。 Java是静态强类型语言。这种特性使Java编译器在编译时就可以发现非常多的类型错误,而不会让这些错误在运行时才暴露出来。对于构 建一个稳定而安全的应用来说,这是一个很大的优势,但是这种静态的类型检查也限制了开发人员编写代码时的创造性和灵活性。 Web 2. 0概念的出现和互联网应用的发展,为新语言的流行创造了契机。Ruby语言凭借着杀手级应用Ruby on Rails一举蹿红,而Google的 Web应用开发平台Google App Engine最初也只支持Python一种语言,甚至流行的JavaScript语言也借助于node.js和Aptana Jaxer等平台在服务 器端开发中占据了一席之地。这些语言的共同特征是动态类型与灵活自由的语法。开发人员一旦掌握了这些语言,开发效率会非常高。在这一 点上,Java语言繁琐的语法就显得缺乏吸引力。Java语言也受到来自同样运行在Java虚拟机上的其他语言的挑战。这些语言包括Groovy、 Scala、JRuby和Jython等。任何语言,只要它生成的字节代码符合Java字节代码规范,就可以在Java虚拟机上运行。前面提到的这些Java虚拟 机上的语言既具有简洁优雅的语法,又能充分利用已有的Java虚拟机资源,相对于Java语言本身来说,非常具有竞争力。 基于前面的这些现状,在社区中有人悲观地预言:Java已死,COBOL式的死亡。COBOL这门诞生于20世纪50年代末的编程语言,已经被诸多 机构和个人论证为已经死亡的语言。实际上,COBOL语言仍然在银行、金融和会计等商业应用领域占据着主导地位。只要这些应用存在,COBOL 语言就不会消亡。Java语言也是如此。只要运行在Java平台上的应用还存在,Java语言就能一直生存下去。事实上,现在仍然有许多公司和个 人在向Java平台投资。这些投资既包括投入资金和人力来开发基于Java平台的应用,也包括投入时间来学习Java平台的相关技术。 当然,Java平台也有不足之处,其中最明显的是整个Java平台的复杂性。最早在JDK 1.0发布的时候,只有几百个Java类,而现在的Java 6 已经包括Java SE、Java EE和Java ME等多个版本,所包含的Java类多达数千个。对于普通开发者来说,完全理解和熟悉如此庞大的类库的难度 非常大。在日常的开发过程中,经常可以看到开发者在重复实现某些功能,而这些功能在Java类库中已经存在,只是不被人知道而已。除了庞 大的类库之外,Java语言的语法本身也缺乏足够的灵活性,实现某些功能所需的代码量可能是其他语言的几倍。另外一个复杂性体现在Web应用 开发方面。一个完整的Java EE应用程序要求程序员掌握和理解的概念太多,要使用的库也非常多。这点从市面上到处可见的以Java Web应用开 发和Struts、Spring及Hibernate等框架为内容的图书上就可以看出来。虽然新出现的Grails和Play框架等都试图降低这个复杂度,但是这些新 的框架的流行仍然需要足够长的时间。 对于Java语言的未来,我们有理由相信Java平台会一直发展下去。其中很重要的依据是Java平台的开放性。依托JCP和OpenJDK项目,Java 平台不仅在语言规范这个层次上有健康的开放管理流程,也有与之对应的参考实现。Java语言有着人数众多的开发者社区,每年有非常多新的 开发者学习和使用Java。大量的开发者使用Java语言开发各种不同类型的应用。在社区中可以看到很多提供不同功能的类库和框架。Java虚拟 机已经被安装到数以十亿计的不同类型的设备上,包括服务器、个人计算机、移动设备和智能卡等。依托庞大的社区和数量众多的运行平 台,Java语言的发展前景是非常乐观的。 对于Java平台来说,未来的发展将侧重于以下几个重要的方面。第一个方面是提高开发人员的生产效率。由于Java语言的静态强类型特 性,使用Java语言编写的程序代码一般比较繁琐,包含了过多不必要的语法元素,这在一定程度上降低了开发人员的生产效率。大量的时间被 浪费在语言本身上,而不是真正需要的业务逻辑上。从另外一个角度来说,Java语言的这种严谨性,对于复杂应用的团队开发是大有好处的, 有利于构建健壮的应用。Java语言需要在这两者之间达到一个平衡。Java语言的一个发展趋势是在可能的范围内降低语言本身的语法复杂度。 从J2SE 5.0中增强的for循环,到JavaSE 7中的try-with-resources语句和<>操作符,再到Java SE 8中引入的lambda表达式,Java正在不断 地简化自身的语法。 第二个方面是提高性能。Java平台的性能一直为开发人员所诟病,这主要是因为Java虚拟机这个中间层次的存在。随着硬件技术的发展, 越来越多的硬件平台采用了多核CPU和多CPU的架构。应用程序应该充分利用这些资源来提高程序的运行性能。Java平台需要帮助开发人员更好 地实现这个目标。Java SE 7中的fork/join框架是一个高效的任务执行框架。Java SE 8对集合类框架和相关API做了增强,以支持对批量数据 进行自动的并行处理。 第三个方面是模块化。一直以来,Java平台所包含的各种功能不同的类库是一个统一的整体。在一个程序的运行过程中,很多类库其实是 不需要的。比如对于一个服务器端运行的程序来说,Swing用户界面组件库通常是不需要的。模块化的含义是把Java平台提供的类库划分成不同 的相互依赖的模块,程序可以根据需要选择运行时所依赖的模块,只有被选择的模块才会在运行时被加载。模块化的实现不仅可以应用到Java 平台本身,也可以应用到Java应用程序的开发中,OpenJDK中的Jigsaw项目提供了这种模块化的支持。 第1章 Java 7语法新特性 前面介绍Java所面临的挑战时就提到了Java语言的语法过于复杂的问题。与其他动态语言相比,利用Java语言所编写出来的代码不够简洁 和直接。Java语言一直在不断改进自身的语法,以满足开发人员的需求。最大的改动发生在J2SE 5.0版本中。泛型、增强的for循环、基本类型 的自动装箱和拆箱机制、枚举类型、参数长度可变的方法、静态引入(import static)和注解等都是在这个版本中添加的。随后的Java SE 6 并没有增加新的语法特性,而Java SE 7又增加了一些语法新特性。本章将会着重介绍这些新特性。 OpenJDK中的Coin项目(Project Coin)的目的就是为了收集对Java语言的语法进行增强的建议。最终有6个语法新特性被加入到了Java 7 中。这些语法新特性涉及switch语句、整数字面量、异常处理、泛型、资源处理和参数长度可变方法的调用等。 下面将对新特性进行具体的介绍。每节是独立的,读者可以有选择地阅读自己感兴趣的特性的相关章节。需要注意的是,Java 7中与泛型 相关的语法新特性将在专门介绍泛型的第12章中介绍。 1.1 Coin项目介绍 在介绍具体的新特性之前,有必要介绍一下Coin项目。OpenJDK中的Coin项目的目的是维护对Java语言所做的语法增强。在Coin项目开始之 初,曾经广泛地向社区征求提议。在短短的一个月时间内就收到了近70条提议。最后有9条提议被列入考虑之中。在这9条提议中,有6条成为 Java 7的一部分,剩下的2条提议会在Java 8中重新考虑,还有1条提议被移到其他项目中实现。这6条被接纳的提议除了本章会介绍的在switch 语句中使用字符串、数值字面量的改进、优化的异常处理、try-with-resources语句和优化变长参数的方法调用之外,还包括第12章中会介绍 的简化泛型类创建的“<>”操作符。在Java 8中考虑的2条提议则分别是集合类字面量和为List和Map提供类似数组的按序号的访问方式。 和其他对Java平台所做的修改一样,Coin项目所建议的修改也需要通过JCP来完成。这些改动以JSR 334(Small Enhancements to the JavaTMProgramming Language)的形式提交到JCP。 1.2 在switch语句中使用字符串 对于switch语句,开发人员应该都不陌生。大部分编程语言中都有类似的语法结构,用来根据某个表达式的值选择要执行的语句块。对于 switch语句中的条件表达式类型,不同编程语言所提供的支持是不一样的。对于Java语言来说,在Java 7之前,switch语句中的条件表达式的 类型只能是与整数类型兼容的类型,包括基本类型char、byte、short和int,与这些基本类型对应的封装类Character、Byte、Short和 Integer,还有枚举类型。这样的限制降低了语言的灵活性,使开发人员在需要根据其他类型的表达式来进行条件选择时,不得不增加额外的代 码来绕过这个限制。为此,Java 7放宽了这个限制,额外增加了一种可以在switch语句中使用的表达式类型,那就是很常见的字符串,即 String类型。 1.2.1 基本用法 在基于Java 7的代码中使用这个新特性非常简单,因为这个新特性并没有改变switch的语法含义,只是多了一种开发人员可以选择的条件 判断的数据类型。但是这个简单的新特性却带来了重大的影响,因为根据字符串进行条件判断在开发中是很常见的。 考虑这样一个应用情景,在程序中需要根据用户的性别来生成合适的称谓,比如男性就使用“×××先生”,女性就使用“×××女 士”。判断条件的类型可以是字符串,如“男”表示男性,“女”表示女性。不过这在Java 7之前的switch语句中是行不通的,之前只能添加 额外的代码先将字符串转换成整数类型。而在Java 7中就可以根据字符串进行条件判断,如下面的代码清单1-1所示。 代码清单1-1 在switch语句中使用字符串的示例 public class Title{ public String generate(String name, String gender){ String title=""; switch(gender){ case"男": title=name+"先生"; break; case"女": title=name+"女士"; break; default: title=name; } return title; } } 在上面的代码中,Title类的generate方法中的switch语句以传入的字符串参数gender作为判断条件,在对应的case子句中使用的是字符串 常量。 注意 在switch语句中,表达式的值不能是null,否则会在运行时抛出NullPointerException。在case子句中也不能使用null,否则会出 现编译错误。 根据switch语句的语法要求,其case子句的值是不能重复的。这个要求对字符串类型的条件表达式同样适用。不过对于字符串来说,这种 重复值的检查还有一个特殊之处,那就是Java代码中的字符串可以包含Unicode转义字符。重复值的检查是在Java编译器对Java源代码进行相关 的词法转换之后才进行的。这个词法转换过程中包括了对Unicode转义字符的处理。也就是说,有些case子句的值虽然在源代码中看起来是不同 的,但是经词法转换后是一样的,这就会造成编译错误。代码清单1-2给出了一个例子。 代码清单1-2 switch语句的case子句包含重复值的示例 public class TitleDuplicate{ public String generate(String name, String gender){ String title=""; switch(gender){ case"男": break; case"\u7537": break; } return title; } } 在上面的代码中,类TitleDuplicate是无法通过编译的。这是因为其中的switch语句中的两个case子句所使用的值“男”和“\u7537”在 经过词法转换之后变成一样的。“\u7537”是“男”的Unicode转义字符形式。 1.2.2 实现原理 在讨论了switch语句中字符串表达式的用法之后,下面来看看这个新特性是怎么实现的。实际上,这个新特性是在编译器这个层次上实现 的。而在Java虚拟机和字节代码这个层次上,还是只支持在switch语句中使用与整数类型兼容的类型。这么做的目的是为了减少这个特性所影 响的范围,以降低实现的代价。在编译器层次实现的含义是,虽然开发人员在Java源代码的switch语句中使用了字符串类型,但是在编译的过 程中,编译器会根据源代码的含义来进行转换,将字符串类型转换成与整数类型兼容的格式。不同的Java编译器可能采用不同的方式来完成这 个转换,并采用不同的优化策略。举例来说,如果switch语句中只包含一个case子句,那么可以简单地将其转换成一个if语句。如果switch语 句中包含一个case子句和一个default子句,那么可以将其转换成if-else语句。而对于最复杂的情况,即switch语句中包含多个case子句的情 况,也可以转换成Java 7之前的switch语句,只不过使用字符串的哈希值作为switch语句的表达式的值。 为了探究OpenJDK中的Java编译器使用的是什么样的转换方式,需要一个名为JAD的工具。这个工具可以把Java的类文件反编译成Java源代 码。在对编译生成Title类的class文件使用了JAD之后,所得到的内容如代码清单1-3所示。 代码清单1-3 包含switch语句的Java类文件反编译之后的结果 public class Title { public String generate(String name, String gender) { String title=""; String s=gender; byte byte0=-1; switch(s.hashCode()) { case 30007: if(s.equals("\u7537")) byte0=0; break; case 22899: if(s.equals("\u5973")) byte0=1; break; } switch(byte0) { case 0://'\0' title=(new StringBuilder()).append(name).append("\u5148\u751F"). toString(); break; case 1://'\001' title=(new StringBuilder()).append(name).append("\u5973\u58EB"). toString(); break; default: title=name; break; } return title; } } 从上面的代码中可以看出,原来用在switch语句中的字符串被替换成了对应的哈希值,而case子句的值也被换成了原来字符串常量的哈希 值。经过这样的转换,Java虚拟机所看到的仍然是与整数类型兼容的类型。在这里值得注意的是,在case子句对应的语句块中仍然需要使用 String的equals方法来进行字符串比较。这是因为哈希函数在映射的时候可能存在冲突,多个字符串的哈希值可能是一样的。进行字符串比较 是为了保证转换之后的代码逻辑与之前完全一样。 1.2.3 枚举类型 以笔者的个人观点来看,Java 7引入的这个新特性虽然为开发人员提供了方便,但是比较容易被误用,造成代码的可维护性问题。提到这 一点就必须要说一下Java SE 5.0中引入的枚举类型。switch语句的一个典型的应用就是在多个枚举值之间进行选择。比如代码清单1-1中的性 别枚举值“男”和“女”,或者是一个星期中的每一天。在Java SE 5.0之前,一般的做法是使用一个整数来为这些枚举值编号,比如0表 示“男”,1表示“女”。在switch语句中使用这个整数编码来进行判断。这种做法的弊端有很多,比如不是类型安全的、没有名称空间、可维 护性差和不够直观等。Joshua Bloch最早在他的《Effective Java》一书中提出了一种类型安全的枚举类型的实现方式。这种方式在J2SE 5.0 中被引入到标准库,就是现在的enum关键字。 Java语言中的枚举类型的最大优势在于它是一个完整的Java类,除了定义其中包含的枚举值之外,还可以包含任意的方法和域,以及实现 任意的接口。这使得枚举类型可以很好地与其他Java类进行交互。在涉及多个枚举值的情况下,都应该优先使用枚举类型。 在Java 7之前,也就是switch语句还不支持使用字符串表达式类型时,如果要枚举的值本身都是字符串,使用枚举类型是唯一的选择。而 在Java 7中,由于switch语句增加了对字符串条件表达式的支持,一些开发人员会选择放弃枚举类型而直接在case子句中用字符串常量来列出 各个枚举值。这种方式虽然简单和直接,但是会带来维护上的麻烦,尤其是这样的switch语句在程序的多个地方出现的时候。在程序中多次出 现字符串常量总是一个不好的现象,而使用枚举类型就可以避免这种情况。 对此,笔者的建议是,如果代码中有多个地方使用switch语句来枚举字符串,就考虑用枚举类型进行替换。 1.3 数值字面量的改进 在编程语言中,字面量(literal)指的是在源代码中直接表示的一个固定的值。绝大部分编程语言都支持在源代码中使用基本类型字面 量,包括整数、浮点数、字符串和布尔值等。少数编程语言支持复杂类型的字面量,如数组和对象等。Java语言只支持基本类型的字面量。 Java 7中对数值类型字面量进行了增强,包括对整数和浮点数字面量的增强。 1.3.1 二进制整数字面量 在Java源代码中使用整数字面量的时候,可以指定所使用的进制。在Java 7之前,所支持的进制包括十进制、八进制和十六进制。十进制 是默认使用的进制。八进制是用在整数字面量之前添加“0”来表示的,而十六进制则是用在整数字面量之前添加“0x”或“0X”来表示的。 Java 7中增加了一种可以在字面量中使用的进制,即二进制。二进制整数字面量是通过在数字前面添加“0b”或“0B”来表示的,如代码清单 1-4所示。 代码清单1-4 二进制整数字面量的示例 import static java.lang.System.out; public class BinaryIntegralLiteral{ public void display(){ out.println(0b001001);//输出9 out.println(0B001110);//输出14 } } 这种新的二进制字面量的表示方式使得在源代码中使用二进制数据变得更加简单,不再需要先手动将数据转换成对应的八/十/十六进制的 数值。 1.3.2 在数值字面量中使用下划线 如果Java源代码中有一个很长的数值字面量,开发人员在阅读这段代码时需要很费力地去分辨数字的位数,以知道其所代表的数值大小。 在现实生活中,当遇到很长的数字的时候,我们采取的是分段分隔的方式。比如数字500000,我们通常会写成500,000,即每三位数字用逗号 分隔。利用这种方式就可以很快知道数值的大小。这种做法的理念被加入到了Java 7中,不过用的不是逗号,而是下划线“_”。 在Java 7中,数值字面量,不管是整数还是浮点数,都允许在数字之间插入任意多个下划线。这些下划线不会对字面量的数值产生影响, 其目的主要是方便阅读。一些典型的用法包括每三位数字插入一个下划线来分隔,以及多行数值的对齐,如代码清单1-5所示。 代码清单1-5 在数值字面量中使用下划线的示例 import static java.lang.System.out; public class Underscore{ public void display(){ out.println(1_500_000);//输出1500000 double value1=5_6.3_4; int value2=89_3___1; out.println(value1);//输出56.34 out.println(value2);//输出8931 } } 虽然下划线在数值字面量中的应用非常灵活,但有些情况是不允许出现的。最基本的原则是下划线只能出现在数字中间,也就是说前后都 必须是数字。所以“_100”、“120_”、“0b_101”、“0x_da0”这样的使用方式都是非法的,无法通过编译。这样限制的动机在于降低实现 的复杂度。有了这个限制之后,Java编译器只需要在扫描源代码的时候,将所发现的数字中间的下划线直接删除就可以了。这样就和没有使用 下划线的形式是相同的。如果不添加这个限制,那么编译器需要进行语法分析才能做出判断。比如“_100”可能是一个整数字面量100,也可能 一个变量名称。这就要求编译器的实现做出更加复杂的改动。 1.4 优化的异常处理 这一节将要介绍的是Java语言中的异常处理。相信大部分开发人员对于Java语言中使用try-catch-finally语句块进行异常处理的基本方式 都有所了解。异常处理以一种简洁的方式表示了程序中可能出现的错误,以及应对这些错误的处理方式。适当地使用异常处理技术,可以提高 代码的可靠性、可维护性和可读性。但是如果使用不当,就会产生相反的效果。比如虽然一个方法声明了会抛出某个异常,但是使用这个方法 的代码在异常发生的时候,却只能捕获完异常之后就直接忽略它,无法做其他的处理。而为了能够通过编译,又不得不加上catch语句。这势必 会造成冗余无用的代码,同时给出不适当的异常设计的一个信号。类似这种错误使用异常的例子在日常开发中还有很多。在Java标准库中同样 也有设计失败的异常处理的例子。 Java 7对异常处理做了两个重要的改动:一个是支持在一个catch子句中同时捕获多个异常,另外一个是在捕获并重新抛出异常时的异常类 型更加精确。本节的内容并不限于介绍Java 7中关于异常处理的这两个新特性,还会围绕整个异常处理进行展开。这样安排的目的是帮助读者 深入理解与Java的异常处理相关的内容。 1.4.1 异常的基础知识 Java语言中基本的异常处理是围绕try-catch-finally、throws和throw这几个关键词展开的。具体来说,throws用来声明一个方法可能抛 出的异常,对方法体中可能抛出的异常都要进行声明;throw用来在遇到错误的时候抛出一个具体的异常;try-catch-finally则用来捕获异常 并进行处理。Java中的异常有受检异常和非受检异常两类。 1.受检异常和非受检异常 在异常处理的时候,都会接触到受检异常(checked exception)和非受检异常(unchecked exception)这两种异常类型。非受检异常指 的是java.lang.RuntimeException和java.lang.Error类及其子类,所有其他的异常类都称为受检异常。两种类型的异常在作用上并没有差别, 唯一的差别就在于使用受检异常时的合法性要在编译时刻由编译器来检查。正因为如此,受检异常在使用的时候需要比非受检异常更多的代码 来避免编译错误。 一直以来,关于在程序中到底是该使用受检异常还是非受检异常,开发者之间一直存在着争议,毕竟两类异常都各有优缺点。受检异常的 特点在于它强制要求开发人员在代码中进行显式的声明和捕获,否则就会产生编译错误。这种限制从好的方面来说,可以防止开发人员意外地 忽略某些出错的情况,因为编译器不允许出现未被处理的受检异常;从不好的方面来说,受检异常对程序中的设计提出了更高的要求。不恰当 地使用受检异常,会使代码中充斥着大量没有实际作用、只是为了通过编译而添加的代码。而非受检异常的特点是,如果不捕获异常,不会产 生编译错误,异常会在运行时刻才被抛出。非受检异常的好处是可以去掉一些不需要的异常处理代码,而不好之处是开发人员可能忽略某些应 该处理的异常。一个典型的例子是把字符串转换成数字时会发生java.lang.NumberFormatException异常,忽略该异常可能导致一个错误的输入 就造成整个程序退出。 目前的主流意见是,最好优先使用非受检异常。 2.异常声明是API的一部分 这一条提示主要是针对受检异常的。在一个公开方法的声明中使用throws关键词来声明其可能抛出的异常的时候,这些异常就成为这个公 开方法的一部分,属于开放API。在维护这个公开API的时候,这些异常有可能会对API的演化造成阻碍,使得编写代码时不得不考虑向后兼容性 的问题。 如果公开方法声明了会抛出一个受检异常,那么这个API的使用者肯定已经使用了try-catch-finally来处理这个异常。如果在后面的版本 更新中,发现该API抛出这个异常是不合适的,也不能直接把这个异常的声明删除。因为这样会造成之前的API使用者的代码无法通过编译。 因此,对于API的设计者来说,谨慎考虑每个公开方法所声明的异常是很有必要的。因为一旦加了异常声明,在很长的一段时间内都无法甩 掉它。这也是为什么推荐使用非受检异常的一个重要原因,非受检异常不需要声明就可以直接抛出。但是对于一个方法会抛出的非受检异常, 也需要在文档中进行说明。 1.4.2 创建自己的异常 和程序中的其他部分一样,异常部分也需要经过仔细的考虑和设计。开发人员一般会花费大量的精力对程序的主要功能部分进行设计,而 忽略对于异常的设计。这会对程序的整体架构造成影响。在对异常部分进行设计的时候,考虑下面几个建议。 1.精心设计异常的层次结构 一般来说,一个程序中应该要有自己的异常类的层次结构。如果只打算使用非受检异常,至少需要一个继承自RuntimeException的异常 类。如果还需要使用受检异常,还要有另外一个继承自Exception的异常类。如果程序中可能出现的异常情况比较多,应该在不同的抽象层次上 定义相关的异常,并形成一个完整的层次结构。这个异常的层次结构与程序本身的类层次结构是相对应的。不同抽象层次上的代码应该只声明 抛出同一层次上的相关异常。 比如一个典型的Web应用按照自顶向下的顺序一般分成展现层、服务层和数据访问层。与之对应的异常也应该按照这个层次结构来进行划 分。数据访问层的代码应该只声明抛出与访问数据相关的异常,如数据库连接和操作相关的异常。这么做的好处是工作于某个抽象层次上的开 发人员不需要去了解其他层次上的细节。比如服务层开发人员会调用数据访问层的代码,他只需要关心数据访问可能出现异常即可,而并不需 要去关心这是一个数据库访问异常,还是一个文件系统访问异常。这种抽象层次的划分对系统的演化是比较重要的。假如系统以后不再使用数 据库作为数据访问的实现,服务层的异常处理逻辑也不会受到影响。 一般来说,对于程序中可能出现的各种错误,都需要声明一个异常类与之对应。有些开发人员会选择一个大而全的异常类来表示各种不同 类型的错误,利用这个异常的消息来区分不同的错误。比如声明一个异常类BaseException,不管是数据访问错误还是用户输入的数据格式不 对,都会抛出同一个异常,只是使用的消息内容不同。当采用这种异常设计方式的时候,异常的处理者只能根据异常消息字符串的内容来判断 具体的错误类型。如果异常的处理者只是简单地进行日志记录或重新抛出此异常,这种方式并没有太大的问题。如果异常的处理者需要解析异 常的消息格式来判断具体类型,那么这种方式就是不可取的,应该换成不同的异常类。 采用这种异常层次结构会遇到的一个常见的异常处理模式是包装异常。包装异常的目的在于使异常只出现在其所对应的抽象层次上。当一 个异常抛出的时候,如果没有被捕获到,就会一直沿着调用栈往上传递,直到被上层方法捕获或是最终由Java虚拟机来处理。这种传递方式会 使这个异常跨越多个抽象层次的边界,使得上层代码看到不需要关注的底层异常。为此,在一个异常要跨越抽象层次边界的时候,需要进行包 装。包装之后的异常才是上层代码需要关注的。 对一个异常进行包装是一件非常简单的事情。从JDK 1.4开始,所有异常的基类java.lang.Throwable就支持在构造方法中传入另外一个异 常作为参数。而这个参数所表示的异常被包装在新的异常中,可以通过getCause方法来获取。代码清单1-6给出了一个异常包装的示例,即把底 层的IOException包装成更为抽象的DataAccessException。使用DataAccessGateway类的上层代码只需要知道DataAccessException即可,并不 需要知道IOException的存在。 代码清单1-6 使用异常包装技术的示例 public class DataAccessGateway{ public void load()throws DataAccessException{ try{ FileInputStream input=new FileInputStream("data.txt"); } catch(IOException e){ throw new DataAccessException(e); } } } 在使用异常包装的时候,一个典型的做法就是为每个层次定义一个基本的异常类。这个层次的所有公开方法在声明异常的时候都使用这个 异常类。所有这个层次中出现的底层异常都被包装成这个异常。 2.异常类中包含足够的信息 异常存在的一个很重要的意义在于,当错误发生的时候,调用者可以对错误进行处理,从产生的错误中恢复。为了方便调用者处理这些异 常,每个异常中都需要包含尽量丰富的信息。异常不应该只说明某个错误发生了,还应该给出相关的信息。异常类是完整的Java类,因此在其 中添加所需的域和方法是一件很简单的事情。 考虑下面一个场景,当用户进行支付的时候,如果他的当前余额不足以完成支付,那么在所抛出的异常信息中,可以包含当前所需的金 额、余额和其中的差额等信息。这样异常处理者就可以提供给用户更加具体的出错信息以及更加明确的解决方案。 3.异常与错误提示 对于与用户进行交互的程序来说,需要适当区分异常与展示给用户的错误提示。通常来说,异常指的是程序的内部错误。与异常相关的信 息,主要是供开发人员调试时使用的。这些信息对于最终用户来说是没有意义的。一般来说,普通用户除了重新执行出错的操作之外,没有其 他应对办法。因此,程序需要保证在直接与用户交互的代码层次上,捕获所有的异常,并生成相应的错误提示。比如在一个servlet中,要确保 在产生HTTP响应的时候捕获全部的异常,以避免用户看到一个包含异常堆栈信息的错误页面。 有些开发人员会直接将异常自带的消息作为给用户的错误提示。这个时候需要注意异常消息的国际化问题。只需要把异常与Java中的 java.util.ResourceBundle结合起来,就可以很容易地实现异常消息的国际化。代码清单1-7给出了一个支持国际化异常消息的异常类的基类 LocalizedException。 代码清单1-7 支持国际化异常消息的异常类的基类 public abstract class LocalizedException extends Exception{ private static final String DEFAULT_BASE_NAME="com/java7book/chapter1/ exception/java7/messages"; private String baseName=DEFAULT_BASE_NAME; protected ResourceBundle resourceBundle; private String messageKey; public LocalizedException(String messageKey){ this.messageKey=messageKey; initResourceBundle(); } public LocalizedException(String messageKey, String baseName){ this.messageKey=messageKey; this.baseName=baseName; initResourceBundle(); } private void initResourceBundle(){ resourceBundle=ResourceBundle.getBundle(baseName); } protected void setBaseName(String baseName){ this.baseName=baseName; } protected void setMessageKey(String key){ messageKey=key; } public abstract String getLocalizedMessage(); public String getMessage(){ return getLocalizedMessage(); } protected String format(Object……args){ String message=resourceBundle.getString(messageKey); return MessageFormat.format(message, args); } } 在使用的时候,每个需要国际化的异常类只需要继承LocalizedException,并实现getLocalizedMessage方法即可。代码清单1-8是之前提 到的支付余额不足时抛出的异常类。在子类的构造方法中指定异常消息在消息资源文件中对应的名称。使用format方法可以对消息进行格式 化。 代码清单1-8 继承自支持国际化异常消息的异常类的子类 public class InsufficientBalanceException extends LocalizedException{ private BigDecimal requested; private BigDecimal balance; private BigDecimal shortage; public InsufficientBalanceException(BigDecimal requested, BigDecimal balance){ super("INSUFFICIENT_BALANCE_EXCEPTION"); this.requested=requested; this.balance=balance; this.shortage=requested.subtract(balance); } public String getLocalizedMessage(){ return format(balance, requested, shortage); } } 1.4.3 处理异常 处理异常的基本思路也比较简单。一般来说就两种选择:处理或是不处理。如果某个异常在当前的调用栈层次上是可以处理和应该处理 的,那么就应该直接处理掉;如果不能处理,或者不适合在这个层次上处理,就可以选择不理会该异常,而让它自行往更上层的调用栈上传 递。如果当前的代码位于抽象层次的边界,就需要首先捕获该异常,重新包装之后,再往上传递。 决定是否在某个方法中处理一个异常需要判断从异常中恢复的方式是否合理。比如一个方法要从文件中读取配置信息,进行文件操作时可 能抛出IOException。当出现异常的时候,如果可以采取的恢复措施是使用默认值,那么在这个方法中处理IOException就是合理的。而在同样 的场景中,如果某些配置项没有合法的默认值,必须要手工设置一个值,那么读取文件时出现的IOException就不应该在这个方法中处理。 在确定了需要对异常进行处理之后,按照程序本身的逻辑来处理即可。下面将要介绍的是一个处理异常时容易忽略的问题——消失的异 常。 开发人员对异常处理的try-catch-finally语句块都比较熟悉。如果在try语句块中抛出了异常,在控制权转移到调用栈上一层代码之 前,finally语句块中的语句也会执行。但是finally语句块在执行的过程中,也可能会抛出异常。如果finally语句块也抛出了异常,那么这个 异常会往上传递,而之前try语句块中的那个异常就丢失了。代码清单1-9给出了一个示例,try语句块会抛出NumberFormatException,而在 finally语句块中会抛出ArithmeticException。对这个方法的使用者来说,他最终看到的只是finally语句块中抛出的ArithmeticException, 而try语句中抛出的NumberFormatException消失不见了。 代码清单1-9 异常消失的示例 public class DisappearedException{ public void show()throws BaseException{ try{ Integer.parseInt("Hello");} catch(NumberFormatException nfe){ throw new BaseException(nfe);}finally{ try{ int result=2/0; }catch(ArithmeticException ae){ throw new BaseException(ae);} }} } 其实这样的例子在日常开发中也是比较常见的。比如在打开一个文件进行读取的时候,肯定需要用try-catch语句块来捕获其中的 IOException,并且在finally语句块中关闭文件输入流。在关闭输入流的时候可能会抛出异常,造成之前在读取文件时产生的异常丢失。还有 一个典型的情况发生在数据库操作的时候,在finally语句块中关闭数据库连接。由于之前产生的异常丢失,开发人员可能无法准确定位异常的 发生位置,造成错误的判断。 对这种问题的解决办法一般有两种,一种是抛出try语句块中产生的原始异常,忽略在finally语句块中产生的异常。这么做的出发点是try 语句块中的异常才是问题的根源。另外一种是把产生的异常都记录下来。这么做的好处是不会丢失任何异常。在Java 7之前,这种做法需要实 现自己的异常类,而在Java 7中,已经对Throwable类进行了修改以支持这种情况。 第一种做法的实现方式如代码清单1-10所示。 代码清单1-10 抛出try语句块中产生的原始异常的示例 public class ReadFile{ public void read(String filename)throws BaseException{ FileInputStream input=null; IOException readException=null; try{ input=new FileInputStream(filename); }catch(IOException ex){ readException=ex; }finally{ if(input!=null){ try{ input.close(); }catch(IOException ex){ if(readException==null){ readException=ex; } } } if(readException!=null){ throw new BaseException(readException); } } } } 第二种做法需要利用Java 7中为Throwable类增加的addSuppressed方法。当一个异常被抛出的时候,可能有其他异常因为该异常而被抑制 住,从而无法正常抛出。这时可以通过addSuppressed方法把这些被抑制的方法记录下来。被抑制的异常会出现在抛出的异常的堆栈信息中,也 可以通过getSuppressed方法来获取这些异常。这样做的好处是不会丢失任何异常,方便开发人员进行调试。代码清单1-11给出了使用 addSuppressed方法记录异常的示例。 代码清单1-11 使用addSuppressed方法记录异常的示例 public class ReadFile{ public void read(String filename)throws IOException{ FileInputStream input=null; IOException readException=null; try{ input=new FileInputStream(filename); }catch(IOException ex){ readException=ex; }finally{ if(input!=null){ try{ input.close(); }catch(IOException ex){ if(readException!=null){ readException.addSuppressed(ex); } else{ readException=ex; } } } if(readException!=null){ throw readException; } } } } 这种做法的关键在于把finally语句中产生的异常通过addSuppressed方法加到try语句产生的异常中。 1.4.4 Java 7的异常处理新特性 下面详细介绍Java 7中引入的与异常处理相关的新特性。 1.一个catch子句捕获多个异常 在Java 7之前的异常处理语法中,一个catch子句只能捕获一类异常。在要处理的异常种类很多时这种限制会很麻烦。每一种异常都需要添 加一个catch子句,而且这些catch子句中的处理逻辑可能都是相同的,从而会造成代码重复。虽然可以在catch子句中通过这些异常的基类来捕 获所有的异常,比如使用Exception作为捕获的类型,但是这要求对这些不同的异常所做的处理是相同的。另外也可能会捕获到某些不应该被捕 获的非受检异常。而在某些情况下,代码重复是不可避免的。比如某个方法可能抛出4种不同的异常,其中有2种异常使用相同的处理方式,另 外2种异常的处理方式也相同,但是不同于前面的2种异常。这势必会在catch子句中包含重复的代码。 对于这种情况,Java 7改进了catch子句的语法,允许在其中指定多种异常,每个异常类型之间使用“|”来分隔,如代码清单1-12所示。 ExceptionThrower类的manyExceptions方法会抛出ExceptionA、ExceptionB和ExceptionC三种异常,其中对ExceptionA和ExceptionB采用一种 处理方式,对ExceptionC采用另外一种处理方式。 代码清单1-12 在catch子句中指定多种异常 public class ExceptionHandler{ public void handle(){ ExceptionThrower thrower=new ExceptionThrower(); try{ thrower.manyExceptions(); }catch(ExceptionA|ExceptionB ab){ }catch(ExceptionC c){ } } } 这种新的处理方式使上面提出的问题得到了很好的解决。需要注意的是,在catch子句中声明捕获的这些异常类中,不能出现重复的类型, 也不允许其中的某个异常是另外一个异常的子类,否则会出现编译错误。如果在catch子句中声明了多个异常类,那么异常参数的具体类型是所 有这些异常类型的最小上界。 关于一个catch子句中的异常类型不能出现其中一个是另外一个的子类的情况,实际上涉及捕获多个异常的内部实现方式。比如在代码清单 1-13中,虽然NumberFormat-Exception是RuntimeException的子类,但是这段代码是可以通过编译的。 代码清单1-13 catch子句中声明异常的顺序的正确示例 public void testSequence(){ try{ Integer.parseInt("Hello"); } catch(NumberFormatException|RuntimeException e){} } 但是如果把catch子句中两个异常的声明位置调换一下,就会出现编译错误。代码清单1-14会产生编译错误。 代码清单1-14 catch子句中声明异常的顺序的错误示例 public void testSequenceError(){ try{ Integer.parseInt("Hello"); } catch(RuntimeException|NumberFormatException e){} } 原因在于,编译器的做法其实是把捕获多个异常的catch子句转换成了多个catch子句,在每个catch子句中捕获一个异常。代码清单1-14中 的testSequenceError方法实际上相当于代码清单1-15。这段代码显然是不能通过编译的,因为在上一个catch子句中已经捕获了 RuntimeException,在下一个catch子句中无法再捕获其子类异常。 代码清单1-15 代码清单1-14中异常捕获的等价形式 public void testSequenceError(){ try{ Integer.parseInt("Hello"); } catch(RuntimeException e){} catch(NumberFormatException e){} } 关于catch子句中异常参数的具体类型,可以参看代码清单1-16。这里catch子句的异常类型包括ExceptionASub1和ExceptionASub2,因此 参数“e”的具体类型是ExceptionASub1和ExceptionASub2在类继承层次结构上的最小祖先类,即ExceptionA,在catch子句中可以调用 ExceptionA中的方法。因为所有的异常都是Exception类的后代,所以这样一个最小的上界总是会存在的。 代码清单1-16 catch子句中异常参数的具体类型 public void testCatchType(){ try{ throwException(); } catch(ExceptionASub1|ExceptionASub2 e){ e.methodInExceptionA(); } } 2.更加精确的异常抛出 在进行异常处理的时候,如果遇到当前代码无法处理的异常,应该把异常重新抛出,交由调用栈的上层代码来处理。在重新抛出异常的时 候,需要判断异常的类型。Java 7对重新抛出异常时的异常类型做了更加精确的判断,以保证抛出的异常的确是可以被抛出的。这个改进初看 起来会让人有点费解,因为从语义上来说,不能被抛出来的异常是不会被重新抛出的。但是在Java 7之前,Java编译器并不能做出精确的判 断,因此会存在一些隐含的不正确的情况。在Java 7中,如果一个catch子句的异常类型参数在catch代码块中没有被修改,而这个异常又被重 新抛出,编译器会知道这个重新被抛出的异常肯定是try语句块中可以抛出的异常,同时也是没有被之前的catch子句捕获的异常。代码清单1- 17给出了一个精确的异常抛出的例子来说明Java 7之前的编译器和Java 7编译器不一样的行为。 代码清单1-17 精确的异常抛出的示例 public class PreciseThrowUse{ public void testThrow()throws ExceptionA{ try{ throw new ExceptionASub2(); } catch(ExceptionA e){ try{ throw e; } catch(ExceptionASub1 e2){//编译错误 } } } } 在上面的代码中,异常类ExceptionASub1和ExceptionASub2都是ExceptionA的子类,而且这两者之间并没有继承关系。方法testThrow中首 先抛出了ExceptionASub2异常,通过第一个catch子句捕获之后重新抛出。在这里,Java编译器可以准确知道变量e表示的异常类型是 ExceptionASub2,接下来的第二个catch子句试图捕获ExceptionASub1类型的异常,这显然是不可能的,因此会产生编译错误。上面的代码在 Java 6编译器上是可以通过编译的。对于Java 6编译器来说,第二个try子句中抛出的异常类型是前一个catch子句中声明的ExceptionA类型, 因此在第二个catch子句中尝试捕获ExceptionA的子类型ExceptionASub1是合法的。 1.5 try-with-resources语句 这一节将要介绍的是Java 7中引入的使用try语句进行资源管理的新用法。这一节的内容与上一节介绍的异常处理的关系比较密切。比如 1.4.3节中介绍的Throwable中的新方法addSuppressed就是为try-with-resources语句添加的。对于资源管理,大多数开发人员都知道的一条原 则是:谁申请,谁释放。这些资源涉及操作系统中的主存、磁盘文件、网络连接和数据库连接等。凡是数量有限的、需要申请和释放的实体, 都应该纳入到资源管理的范围中来。 对于C++程序员来说,程序的内存管理是他们的一项职责。他们需要保证每一块申请的内存都在正确的时候得到了释放。要么在构造函数中 申请,在析构函数中释放;要么使用类似智能指针一样的结构来实现资源管理。Java语言把内存管理的任务交给了Java虚拟机,通过自动垃圾 回收机制减少了开发人员的很多工作。但是像输入输出流和数据库连接这样的资源,还是需要开发人员手动释放。 在使用资源的时候,有可能会抛出各种异常,比如读取磁盘文件和访问数据库时都可能出现各种不同的异常。而资源管理的一个要求就是 不管操作是否成功,所申请的资源都要被正确释放。1.4.3节的代码清单1-10就是资源管理的经典案例,即通过try-catch-finally语句块的 finally语句进行资源释放操作。这种方式虽然比较易懂,但是其中包含的冗余代码比较多。 为了简化这种典型的应用,Java 7对try语句进行了增强,使它可以支持对资源进行管理,保证资源总是被正确释放。代码清单1-18给出了 一个读取磁盘文件内容的示例。 代码清单1-18 读取磁盘文件内容的示例 public class ResourceBasicUsage{ public String readFile(String path)throws IOException{ try(BufferedReader reader=new BufferedReader(new FileReader(path))){ StringBuilder builder=new StringBuilder(); String line=null; while((line=reader.readLine())!=null){ builder.append(line); builder.append(String.format("%n")); } return builder.toString(); } } } 上面的代码并不需要使用finally语句来保证打开的流被正确关闭,这是自动完成的。相对于传统的使用finally语句的做法,这种方式要 简单得多。开发人员只需要关心使用资源的业务逻辑即可。资源的申请是在try子句中进行的,而资源的释放则是自动完成的。在使用try- with-resources语句的时候,异常可能发生在try语句中,也可能发生在释放资源时。如果资源初始化时或try语句中出现异常,而释放资源的 操作正常执行,try语句中的异常会被抛出;如果try语句和释放资源都出现了异常,那么最终抛出的异常是try语句中出现的异常,在释放资源 时出现的异常会作为被抑制的异常添加进去,即通过Throwable.addSuppressed方法来实现。 能够被try语句所管理的资源需要满足一个条件,那就是其Java类要实现java.lang.AutoCloseable接口,否则会出现编译错误。当需要释 放资源的时候,该接口的close方法会被自动调用。Java类库中已有不少接口或类继承或实现了这个接口,使得它们可以用在try语句中。在这 些已有的常见接口或类中,最常用的就是与I/O操作和数据库相关的接口。与I/O相关的java.io.Closeable继承了AutoCloseable,而与数据库 相关的java.sql.Connection、java.sql.ResultSet和java.sql.Statement也继承了该接口。如果希望自己开发的类也能利用try语句的自动化 资源管理,只需要实现AutoCloseable接口即可。代码清单1-19给出了一个自定义资源的使用示例,在close方法中可以添加所需要的资源释放 逻辑。 代码清单1-19 自定义资源使用AutoCloseable接口的示例 public class CustomResource implements AutoCloseable{ public void close()throws Exception{ System.out.println("进行资源释放。"); } public void useCustomResource()throws Exception{ try(CustomResource resource=new CustomResource()){ System.out.println("使用资源。"); } } } 除了对单个资源进行管理之外,try-with-resources还可以对多个资源进行管理。代码清单1-20给出了try-with-resources语句同时管理 两个资源的例子,即经典的文件内容复制操作。 代码清单1-20 使用try-with-resources语句管理两个资源的示例 public class MultipleResourcesUsage{ public void copyFile(String fromPath, String toPath)throws IOException{ try(InputStream input=new FileInputStream(fromPath); OutputStream output=new FileOutputStream(toPath)){ byte[]buffer=new byte[8192]; int len=-1; while((len=input.read(buffer))!=-1){ output.write(buffer,0,len); } } } } 当对多个资源进行管理的时候,在释放每个资源时都可能会产生异常。所有这些异常都会被加到资源初始化异常或try语句块中抛出的异常 的被抑制异常列表中。 在try-with-resource语句中也可以使用catch和finally子句。在catch子句中可以捕获try语句块和释放资源时可能发生的各种异常。 1.6 优化变长参数的方法调用 J2SE 5. 0中引入的一个新特性就是允许在方法声明中使用可变长度的参数。一个方法的最后一个形式参数可以被指定为代表任意多个相同 类型的参数。在调用的时候,这些参数是以数组的形式来传递的。在方法体中也可以按照数组的方式来引用这些参数。代码清单1-21给出了一 个简单的示例,对多个整数进行求和。可以用类似sum(1,2,3)这样的形式来调用此方法。 代码清单1-21 变长参数方法的示例 public int sum(int……args){ int result=0; for(int value:args){ result+=value; } return result; } 可变长度的参数在实际开发中可以简化方法的调用方式。但是在Java 7之前,如果可变长度的参数与泛型一起使用会遇到一个麻烦,就是 编译器产生的警告过多。比如代码清单1-22中给出的方法。 代码清单1-22 使用泛型的变长参数方法产生编译器警告的示例 public static<T>T useVarargs(T……args){ return args.length>0?args[0]:null; } 如果参数传递的是不可具体化(non-reifiable)的类型,如List<String>这样的泛型类型,会产生警告信息。每一次调用该方法,都会 产生警告信息。比如在Java 7之前的编译器上编译代码清单1-23中的代码,编译器会给出警告信息。如果希望禁止这个警告信息,需要使用 @SuppressWarnings("unchecked")注解来声明。 代码清单1-23 调用使用泛型的变长参数方法的示例 VarargsWarning.useVarargs(new ArrayList<String>()); 这其中的原因是可变长度的方法参数的实际值是通过数组来传递的,而数组中存储的是不可具体化的泛型类对象,自身存在类型安全问 题。因此编译器会给出相应的警告信息。关于泛型的内容,本书的第12章会详细介绍,这里就不再赘述。 这样的警告信息在使用Java标准类库中的java.util.Arrays类的asList和java.util.Collections类的addAll方法中也会遇到。建议开发人 员每次使用方法时都抑制编译器的警告信息,并不是一个好主意。为了解决这个问题,Java 7引入了一个新的注解@SafeVarargs。如果开发人 员确信某个使用了可变长度参数的方法,在与泛型类一起使用时不会出现类型安全问题,就可以用这个注解进行声明。在使用了这个注解之 后,编译器遇到类似的情况,就不会再给出相关的警告信息,如代码清单1-24所示。 代码清单1-24 使用@SafeVarargs注解抑制编译器警告的示例 @SafeVarargs public static<T>T useVarargs(T……args){ return args.length>0?args[0]:null; } @SafeVarargs注解只能用在参数长度可变的方法或构造方法上,且方法必须声明为static或final,否则会出现编译错误。一个方法使用 @SafeVarargs注解的前提是,开发人员必须确保这个方法的实现中对泛型类型参数的处理不会引发类型安全问题。 1.7 小结 本章内容主要围绕Java 7中通过Coin项目添加的语法新特性展开。这次在Java 7中对语法所做的改进,是自J2SE 5.0版本以来比较大的一 次。在这几个新特性中,最值得在日常开发中使用的是在switch语句中使用字符串、在一个catch子句中捕获多个异常,以及利用try-with- resources语句管理资源。数值字面量和参数长度可变方法的调用方式的改进,也可以为开发人员带来便利。需要提醒的是,在switch语句中使 用字符串时要谨慎。在多数时候,使用枚举类型是一个更好的做法。善用这些新特性,可以减少程序中的冗余代码,提高开发效率。 第2章 Java语言的动态性 Java语言是一种静态类型的编程语言。静态类型的含义是指在编译时进行类型检查。Java源代码中的每个变量的类型都需要显式地进行声 明。所有变量、方法的参数和返回值的类型在程序运行之前就必须是已知的。Java语言的这种静态类型特性使编译器可以在编译时执行大量的 检查来发现代码中明显的类型错误,不过这样的话,代码中会包含很多不必要的类型声明,使代码不够简洁和灵活。与静态类型语言相对应的 是动态类型语言,如JavaScript和Ruby等。动态类型语言的类型检查在运行时进行。源代码中不需要显式地声明类型。去掉了类型声明之后, 使用动态类型语言编写的代码更加简洁。近年来,动态类型语言的流行也反映了语言中动态性的重要性。适当的动态性对于提高开发的效率是 有帮助的,可以减少开发人员需要编写的代码量。 对于使用Java的开发人员来说,学习一门新的动态类型语言的代价可能比较高,因为从一门新语言的入门到将其真正运用到实践中的时间 可能比较长。熟悉Java的开发人员还是都希望用Java来解决问题。实际上,Java语言本身对动态性的支持也有很多。这里的动态性指的不是类 型上的,而是使用方式上的。这些动态性可以在一些对灵活性要求比较高的场合发挥作用。反射API就是一个很好的例子,它提供了在运行时根 据方法名称查找并调用方法的能力。随着版本的更新,Java语言本身也在不断地提高对动态性和灵活性的支持。 本章将围绕Java语言的动态性来展开,所涉及的内容既有Java 7中的新特性,又有之前版本中就有的功能。集中在这一章进行介绍的目的 是使读者对相关知识有一个全面的了解。本章所介绍的内容都属于Java的标准API,不需要了解字节代码等底层细节。这一章的内容分成4个部 分:首先介绍Java 6中引入的脚本语言支持API,接着介绍可以在运行时检查程序内部结构和直接调用方法的反射API,然后对可以在运行时实 现接口的动态代理进行讲解,最后是本章的重点,即Java 7中引入的在Java虚拟机级别实现的动态语言支持和方法句柄。 2.1 脚本语言支持API 随着Java平台的流行,很多脚本语言(scripting language)都可以运行在Java虚拟机上,其中比较流行的有JavaScript、JRuby、Jython 和Groovy等。相对Java语言来说,脚本语言由于其灵活性很强,非常适合在某些情况下使用,比如描述应用中复杂多变的业务逻辑,并在应用 运行过程中进行动态修改;为应用提供一种领域特定语言(Domain-specific Language, DSL),供没有技术背景的普通用户使用;作为应用中 各个组件之间的“胶水”,快速进行组件之间的整合;快速开发出应用的原型系统,从而迅速获取用户反馈,并进行改进;帮助开发人员快速 编写测试用例等。对于这些场景,如果使用Java来开发,会事倍功半。 对于这些运行在Java虚拟机平台上的脚本语言来说,并不需要为它们准备额外的运行环境,直接复用已有的Java虚拟机环境即可。这就节 省了在运行环境上所需的成本投入。在应用开发中使用脚本语言,实际上是“多语言开发”的一种很好的实践,即根据应用的需求和语言本身 的特性来选择最合适的编程语言,以快速高效地解决应用中的某一部分问题。多种不同语言实现的组件结合起来,就形成了最终的完整应用程 序。比如一个应用,可以用Groovy来编写用户界面,用Java编写核心业务逻辑,用Ruby来进行数据处理。不同语言编写的代码可以同时运行在 同一个Java虚拟机之上。这些脚本语言与Java语言之间的交互,是由脚本语言支持API来完成的。 JSR 223(Scripting for the JavaTMPlatform)中规范了在Java虚拟机上运行的脚本语言与Java程序之间的交互方式。JSR 223是Java SE 6的一部分,在Java标准API中的包是javax.script。下面将详细介绍与脚本语言支持API相关的内容。 2.1.1 脚本引擎 一段脚本的执行需要由该脚本语言对应的脚本引擎来完成。一个Java程序可以选择同时包含多种脚本语言的执行引擎,这完全由程序的需 求来决定。程序中所用到的脚本语言,都需要有相应的脚本引擎。JSR 223中定义了脚本引擎的注册和查找机制。这对于脚本引擎的实现者来说 是需要了解的。而一般的开发人员只需要了解如何通过脚本引擎管理器来获取对应语言的脚本引擎即可,并不需要了解脚本引擎的注册机制。 Java SE 6中自带了JavaScript语言的脚本引擎,是基于Mozilla的Rhino来实现的。对于其他的脚本语言,则需要下载对应的脚本引擎的库并放 到程序的类路径中。一般只要放在类路径中,脚本引擎就可以被应用程序发现并使用。 首先介绍脚本引擎的一般用法。在代码清单2-1中,首先创建一个脚本引擎管理器javax.script.ScriptEngineManager对象,再通过管理器 来查找所需的JavaScript脚本引擎,最后通过脚本引擎来执行JavaScript代码。示例中的JavaScript代码做的事情很简单,只输出了字符 串“Hello!”。JavaScript代码中的println是Rhino引擎额外提供的方法,相当于Java中的System.out.println方法。 代码清单2-1 脚本引擎的一般用法 public void greet()throws ScriptException{ ScriptEngineManager manager=new ScriptEngineManager(); ScriptEngine engine=manager.getEngineByName("JavaScript"); if(engine==null){ throw new RuntimeException("找不到JavaScript语言执行引擎。"); } engine.eval("println('Hello!');"); } 上面的代码中是通过脚本引擎的名称进行查找的。实际上,脚本引擎管理器共支持三种查找脚本引擎的方式,分别通过名称、文件扩展名 和MIME类型来完成。比如对于同样的JavaScript语言引擎,还可以通过getEngineByExtension("js")和 getEngineByMimeType("text/javascript")来查找到。得到脚本引擎ScriptEngine的对象之后,通过其eval方法可以执行一段代码,并返回 这段代码的执行结果。这是最基本的通过脚本引擎来解释执行一段脚本的实现方式。 2.1.2 语言绑定 脚本语言支持API的一个很大优势在于它规范了Java语言与脚本语言之间的交互方式,使Java语言编写的程序可以与脚本之间进行双向的方 法调用和数据传递。方法调用的方式会在2.1.5小节中介绍。数据传递是通过语言绑定对象来完成的。所谓的语言绑定对象就是一个简单的哈希 表,用来存放和获取需要共享的数据。所有数据都对应这个哈希表中的一个条目,是简单的名值对。接口javax.script.Bindings定义了语言绑 定对象的接口,它继承自java.util.Map接口。一个脚本引擎在执行过程中可能会使用多个语言绑定对象。不同语言绑定对象的作用域不同。在 默认情况下,脚本引擎会提供多个语言绑定对象,用来存放在执行过程中产生的全局对象等。ScriptEngine类提供了put和get方法对脚本引擎 中特定作用域的默认语言绑定对象进行操作。程序可以直接使用这个默认的语言绑定对象,也可以使用自己的语言绑定对象。在脚本语言的执 行过程中,可以将语言绑定对象看成是一个额外的变量映射表。在解析变量值的时候,语言绑定对象中的名称也会被考虑在内。脚本执行过程 中产生的全局变量等内容,会出现在语言绑定对象中。通过这种方式,就完成了Java与脚本语言之间的双向数据传递。 在代码清单2-2中,首先通过ScriptEngine的put方法向脚本引擎默认的语言绑定对象中添加了一个名为“name”的字符串,接着在脚本中 直接根据名称来引用这个对象。同样,在脚本中创建的全局变量“message”也可以通过ScriptEngine的get方法来获取。这样就实现了Java程 序与脚本之间的双向数据传递。数据传递过程中的类型转换是由脚本引擎来完成的,转换规则取决于具体的脚本语言的语法。 代码清单2-2 脚本引擎默认的语言绑定对象的示例 public void useDefaultBinding()throws ScriptException{ ScriptEngine engine=getJavaScriptEngine(); engine.put("name","Alex"); engine.eval("var message='Hello,'+name;"); engine.eval("println(message);"); Object obj=engine.get("message"); System.out.println(obj); } 在大多数情况下,使用ScriptEngine的put和get方法就足够了。如果仅使用put和get方法,语言绑定对象本身对于开发人员来说是透明 的。在某些情况下,需要使用程序自己的语言绑定对象,比如语言绑定对象中包含了程序自己独有的数据。如果希望使用自己的语言绑定对 象,可以调用脚本引擎的createBindings方法或创建一个javax.script.SimpleBindings对象,并传递给脚本引擎的eval方法,如代码清单2-3 所示。 代码清单2-3 自定义语言绑定对象的示例 public void useCustomBinding()throws ScriptException{ ScriptEngine engine=getJavaScriptEngine(); Bindings bindings=new SimpleBindings(); bindings.put("hobby","playing games"); engine.eval("println('I like'+hobby);",bindings); } 通过eval方法传递的语言绑定对象,仅在当前eval调用中生效,并不会改变引擎默认的语言绑定对象。 2.1.3 脚本执行上下文 与脚本引擎执行相关的另外一个重要的接口是javax.script.ScriptContext,其中包含脚本引擎执行过程中的相关上下文信息,可以与 Java EE中servlet规范中的javax.servlet.ServletContext接口来进行类比。脚本引擎通过此上下文对象来获取与脚本执行相关的信息,也允 许开发人员通过此对象来配置脚本引擎的行为。该上下文对象中主要包含以下3类信息。 1.输入与输出 首先介绍与脚本输入和输出相关的配置信息,其中包括脚本在执行中用来读取数据输入的java.io.Reader对象以及输出正确内容和出错信 息的java.io.Writer对象。在默认情况下,脚本的输入输出都发生在标准控制台中。如果希望把脚本的输出写入到文件中,可以使用代码清单 2-4中的代码。通过setWriter方法把脚本的输出重定向到一个文件中。通过ScriptContext的setReader和setErrorWriter方法可以分别设置脚 本执行时的数据输入来源和产生错误时出错信息的输出目的。 代码清单2-4 把脚本运行时的输出写入到文件中的示例 public void scriptToFile()throws IOException, ScriptException{ ScriptEngine engine=getJavaScriptEngine(); ScriptContext context=engine.getContext(); context.setWriter(new FileWriter("output.txt")); engine.eval("println('Hello World!');"); } 2.自定义属性 下面介绍执行上下文中包含的自定义属性。ScriptContext中也有与ServletContext中类似的获取和设置属性的方法,即setAttribute和 getAttribute。所不同的是,ScriptContext中的属性是有作用域之分的。不同作用域的区别在于查找属性时的顺序不同。每个作用域都以一个 对应的整数表示其查找顺序。该整数值越小,说明查找时的顺序越优先。优先级高的作用域中的属性会隐藏优先级低的作用域中的同名属性。 因此,设置属性时需要显式地指定所在的作用域。在获取属性的时候,既可以选择在指定的作用域中查找,也可以选择根据作用域优先级自动 进行查找。 不过脚本执行上下文实现中包含的作用域是固定的,开发人员不能随意定义自己的作用域。通过ScriptContext的getScopes方法可以得到 所有可用的作用域列表。ScriptContext中预先定义了两个作用域:常量ScriptContext.ENGINE_SCOPE表示的作用域对应的是当前的脚本引擎, 而ScriptContext.GLOBAL_SCOPE表示的作用域对应的是从同一引擎工厂中创建出来的所有脚本引擎对象。前者的优先级较高。代码清单2-5给出 了作用域影响同名属性查找的一个示例。ENGINE_SCOPE中的属性“name”隐藏了GLOBAL_SCOPE中的同名属性。 代码清单2-5 作用域影响同名属性查找的示例 public void scriptContextAttribute(){ ScriptEngine engine=getJavaScriptEngine(); ScriptContext context=engine.getContext(); context.setAttribute("name","Alex",ScriptContext.GLOBAL_SCOPE); context.setAttribute("name","Bob",ScriptContext.ENGINE_SCOPE); context.getAttribute("name");//值为Bob } 3.语言绑定对象 脚本执行上下文中的最后一类信息是语言绑定对象。语言绑定对象也是与作用域相对应的。同样的作用域优先级顺序对语言绑定对象也适 用。这样的优先级顺序会对脚本执行时的变量解析产生影响。比如在代码清单2-6中,两个不同的语言绑定对象中都有名称为“name”的对象, 而在脚本的执行过程中,作用域ENGINE_SCOPE的语言绑定对象的优先级较高,因此变量“name”的值是“Bob”。 代码清单2-6 语言绑定对象的优先级顺序的示例 public void scriptContextBindings()throws ScriptException{ ScriptEngine engine=getJavaScriptEngine(); ScriptContext context=engine.getContext(); Bindings bindings1=engine.createBindings(); bindings1.put("name","Alex"); context.setBindings(bindings1,ScriptContext.GLOBAL_SCOPE); Bindings bindings2=engine.createBindings(); bindings2.put("name","Bob"); context.setBindings(bindings2,ScriptContext.ENGINE_SCOPE); engine.eval("println(name);"); } 通过ScriptContext的setBindings方法设置的语言绑定对象会影响到ScriptEngine在执行脚本时的变量解析。ScriptEngine的put和get方 法所操作的实际上就是ScriptContext中作用域为ENGINE_SCOPE的语言绑定对象。在代码清单2-7中,从ScriptContext中得到语言绑定对象之 后,可以直接对这个对象进行操作。如果在ScriptEngine的eval方法中没有指明所使用的语言绑定对象,实际上起作用的是ScriptContext中作 用域为ENGINE_SCOPE的语言绑定对象。 代码清单2-7 通过脚本执行上下文获取语言绑定对象的示例 public void useScriptContextValues()throws ScriptException{ ScriptEngine engine=getJavaScriptEngine(); ScriptContext context=engine.getContext(); Bindings bindings=context.getBindings(ScriptContext.ENGINE_SCOPE); bindings.put("name","Alex"); engine.eval("println(name);");//输出Alex } 上一小节介绍的自定义属性实际上也保存在语言绑定对象中。在代码清单2-8中,不直接操作语言绑定对象本身,而是通过ScriptContext 的setAttribute来向语言绑定对象中添加数据。所添加的数据在脚本执行时也同样是可见的。 代码清单2-8 自定义属性保存在语言绑定对象中的示例 public void attributeInBindings()throws ScriptException{ ScriptEngine engine=getJavaScriptEngine(); ScriptContext context=engine.getContext(); context.setAttribute("name","Alex",ScriptContext.GLOBAL_SCOPE); engine.eval("println(name);");//输出为Alex } 2.1.4 脚本的编译 脚本语言一般是解释执行的。脚本引擎在运行时需要先解析脚本之后再执行。一般来说,通过解释执行的方式来运行脚本的速度比编译之 后再运行会慢一些。当一段脚本需要被多次重复执行时,可以先对脚本进行编译。编译之后的脚本在执行时不需要重复解析,可以提高执行效 率。不是所有的脚本引擎都支持对脚本进行编译。如果脚本引擎支持这一特性,它会实现javax.script.Compilable接口来声明这一点。脚本引 擎的使用者可以利用这个能力来提高需要多次执行的脚本的运行效率。Java SE中自带的JavaScript脚本引擎是支持对脚本进行编译的。 在代码清单2-9中,Compilable接口的compile方法用来对脚本代码进行编译,编译的结果用javax.script.CompiledScript来表示。由于不 是所有的脚本引擎都支持Compilable接口,因此这里需要用instanceof进行判断。在run方法中,通过CompiledScript的eval方法就可以执行脚 本。代码中把一段脚本重复执行了100次,以此说明编译完的脚本在重复执行时的性能优势。 代码清单2-9 进行脚本编译的示例 public CompiledScript compile(String scriptText)throws ScriptException{ ScriptEngine engine=getJavaScriptEngine(); if(engine instanceof Compilable){ CompiledScript script=((Compilable)engine).compile(scriptText); return script; } return null; } public void run(String scriptText)throws ScriptException{ CompiledScript script=compile(scriptText); if(script==null){ return; } for(int i=0;i<100;i++){ script.eval(); } } CompiledScript的eval方法所接受的参数与ScriptEngine的eval方法是相同的。 2.1.5 方法调用 在脚本中,最常见和最实用的就是方法。有些脚本引擎允许使用者单独调用脚本中的某个方法。支持这种方法调用方式的脚本引擎可以实 现javax.script.Invocable接口。通过Invocable接口可以调用脚本中的顶层方法,也可以调用对象中的成员方法。如果脚本中顶层方法或对象 中的成员方法实现了Java中的接口,可以通过Invocable接口中的方法来获取脚本中相应的Java接口的实现对象。这样就可以在Java语言中定义 接口,在脚本中实现接口。程序中使用该接口的其他部分代码并不知道接口是由脚本来实现的。与Compilable接口一样,ScriptEngine对于 Invocable接口的实现也是可选的。 代码清单2-10通过Invocable接口的invokeFunction来调用脚本中的顶层方法,调用时的参数会被传递给脚本中的方法。因为Java SE自带 的JavaScript脚本引擎实现了Invocable接口,所以这里省去了对引擎是否实现了Invocable接口的判断。 代码清单2-10 在Java中调用脚本中顶层方法的示例 public void invokeFunction()throws ScriptException, NoSuchMethodException{ ScriptEngine engine=getJavaScriptEngine(); String scriptText="function greet(name){println('Hello,'+name);}"; engine.eval(scriptText); Invocable invocable=(Invocable)engine; invocable.invokeFunction("greet","Alex"); } 如果被调用的方法是脚本中对象的成员方法,就需要使用invokeMethod方法,如代码清单2-11所示。代码中的getGreeting方法是属于对象 obj的,在调用的时候需要把这个对象作为参数传递进去。 代码清单2-11 在Java中调用脚本中对象的成员方法的示例 public void invokeMethod()throws ScriptException, NoSuchMethodException{ ScriptEngine engine=getJavaScriptEngine(); String scriptText="var obj={getGreeting:function(name){return'Hello,'+name;}};"; engine.eval(scriptText); Invocable invocable=(Invocable)engine; Object scope=engine.get("obj"); Object result=invocable.invokeMethod(scope,"getGreeting","Alex"); System.out.println(result); } 方法invokeMethod与方法invokeFunction的用法差不多,区别在于invokeMethod要指定包含待调用方法的对象。 在有些脚本引擎中,可以在Java语言中定义接口,并在脚本中编写接口的实现。这样程序中的其他部分可以只同Java接口交互,并不需要 关心接口是由什么方式来实现的。在代码清单2-12中,Greet是用Java定义的接口,其中包含一个getGreeting方法。在脚本中实现这个接口。 通过getInterface方法可以得到由脚本实现的这个接口的对象,并调用其中的方法。 代码清单2-12 在脚本中实现Java接口的示例 public void useInterface()throws ScriptException{ ScriptEngine engine=getJavaScriptEngine(); String scriptText="function getGreeting(name){return'Hello,'+name;}"; engine.eval(scriptText); Invocable invocable=(Invocable)engine; Greet greet=invocable.getInterface(Greet.class); System.out.println(greet.getGreeting("Alex")); } 代码清单2-12中的接口的实现是由脚本中的顶层方法来完成的。同样的,也可以由脚本中对象的成员方法来实现。对于这种情 况,getInterface方法的另外一种重载形式可以接受一个额外的参数来指定接口实现所在的对象。 2.1.6 使用案例 由于脚本语言的语法简单和灵活,非常适于没有或只有少量编程背景的用户来使用。这些用户可以通过脚本语言来定制程序的业务逻辑和 用户界面等。通过脚本语言,可以在程序的易用性和灵活性之间达到一个比较好的平衡。比如脚本语言Lua就被广泛应用在游戏开发中,用来对 游戏的内部行为和用户界面进行定制。 下面通过一个具体的案例来说明如何使用脚本语言。这个案例也和游戏有关。一般的游戏都会自带一个控制台,允许用户输入命令来对游 戏本身进行修改。这里展示的是一个通过JavaScript来进行游戏配置的案例。这个游戏控制台的实现被大大简化了。运行时在一个线程中等待 用户输入命令。对输入的命令进行适当处理之后就交给JavaScript脚本引擎来执行。这里的GameConfig表示的是与游戏有关的配置信息,可以 通过JavaScript语言在控制台中进行修改。代码清单2-13给出了这个简易的游戏控制台的基本实现。GameConfig的getScriptBindings方法返回 的语言绑定对象中包含的是对于游戏控制台可见的配置项。比如,用户在控制台输入“config.screenWidth=300”就可以直接修改GameConfig 中的screenWidth域的值。 代码清单2-13 使用脚本引擎实现的游戏控制台 public class GameConsole extends JsScriptRunner implements Runnable{ private GameConfig config; public GameConsole(GameConfig config){ this.config=config; } public void executeCommand(String command)throws ScriptException{ ScriptEngine engine=getJavaScriptEngine(); if(command.indexOf("println")==-1){ command="println("+command+");"; } engine.eval(command, config.getScriptBindings()); } public void run(){ Scanner scanner=new Scanner(System.in); while(true){ String line=scanner.nextLine(); if("quit".equals(line)){ break; } try{ executeCommand(line); }catch(ScriptException ex){ System.err.println("错误的命令!"); } } } } 通过这个简易的控制台,用户对脚本语言所做的配置修改,对于通过Java语言来实现的其他组件来说是立即生效的。 2.2 反射API 2.1节介绍的Java脚本语言支持API是通过引入其他脚本语言来增强Java平台的动态性,而这一节将要介绍的反射API则是Java语言本身提供 的动态支持。通过反射API可以获取Java程序在运行时刻的内部结构,比如Java类中包含的构造方法、域和方法等元素,并可以与这些元素进行 交互。通过反射API, Java语言也可以实现很多动态语言所支持的实用而又简洁的功能。下面先通过一个示例来为读者提供一个反射API的直观 印象。 按照一般的面向对象的设计思路,一个对象的内部状态都应该通过相应的方法来改变,而不是直接去修改属性的值。一般Java类中的属性 设置和获取方法的命名都遵循JavaBeans规范中的要求,即利用setXxx和getXxx这样的方法声明。因此可以实现一个实用工具类来完成对任意对 象的属性设置和获取的操作,只要设置和获取属性的方法满足JavaBeans规范。具体的实现方式可以通过与动态语言进行对比来分别介绍。用 JavaScript语言来实现这样功能,如代码清单2-14所示。限于篇幅,代码中省略了应有的类型检查和错误处理。 代码清单2-14 设置任意对象的属性值的JavaScript实现 function invokeSetter(obj, property, value){ var funcName="set"+property.substring(0,1).toUpperCase()+property.substring(1); obj[funcName](value); } var obj={ value:0, setValue:function(val){this.value=val;} }; invokeSetter(obj,"value",5); 上面的代码只是属性设置方法的示例。代码的逻辑也并不复杂,首先把要设置的属性名称按照JavaBeans规范转换成对应的方法名称,如设 置属性“value”的方法名称为“setValue”。由于JavaScript语言本身的特性,方法也是对象的属性,因此可以直接获取到方法后再进行调 用。 代码清单2-15给出了使用反射API的Java实现。从代码量上来说,与JavaScript的实现差别并不算大。基本的实现思路也比较直接,先从对 象的类中查找到方法,再用传入的参数调用此方法。这个静态方法可以作为一个实用工具方法在程序中使用。 代码清单2-15 使用反射API设置对象的属性值的示例 public class ReflectSetter{ public static void invokeSetter(Object obj, String field, Object value)throws NoSuchMethodException, InvocationTargetException, IllegalAccessException{ String methodName="set"+field.substring(0,1).toUpperCase()+field. substring(1); Class<?>clazz=obj.getClass(); Method method=clazz.getMethod(methodName, value.getClass()); method.invoke(obj, value); } } 从上面的示例可以看出,通过反射API, Java语言也可以应用在很多对灵活性要求很高的场景中。从根本上来说,反射API实际上定义了一 种功能提供者和使用者之间的松散契约。以方法调用为例,按照Java语言的一般做法,在调用方法的时候,在代码中首先需要一个对象的变量 作为调用的接收者,再把方法的名称直接写在代码中。方法的名称不可能是变量。编译器会检查这个对象中是否确实有待调用的方法,如果没 有就会出现编译错误。这种一般的做法,是提供者和使用者之间的一种紧密的契约,由编译器来保证其合法性。而使用反射API,两者的契约只 需要建立在名称和参数类型这个层次上就足够了。方法名称可以是变量,参数值也可以动态生成。调用的合法性由开发人员自己保证。如果方 法调用不是合法的,相关的异常会在运行时抛出。 反射API的一个重要的使用场合是要调用的方法或者要操作的域的名称按照某种规律变化的时候。一个典型的场景就是在Servlet中用HTTP 请求的参数值来填充领域对象。比如在用户注册的时候,包含在HTTP请求中的用户所填写的相关信息,需要被填充到程序中定义好的领域对象 中。只需要利用Servlet提供的API遍历请求中的所有参数,然后用代码清单2-15中给出的invokeSetter方法设置领域对象中与参数名称相对应 的属性的值即可。另外一个场景是在数据库操作中,从SQL查询结果集中创建并填充领域对象。数据库的列名和领域对象的属性名称也存在着类 似的对应关系。 反射API在为Java程序带来灵活性的同时,也产生了额外的性能代价。由于反射API的实现机制,对于相同的操作,比如调用同一个方法, 用反射API来动态实现比直接在源代码中编写的方式大概慢一到两个数量级。随着Java虚拟机实现的改进,反射API的性能已经有了非常大的提 升。但是这种性能的差距是客观存在的。因此,在某些对性能要求比较高的应用中,要慎用反射API。 2.2.1 获取构造方法 通过反射API可以获取到Java类中的构造方法。通过构造方法可以在运行时动态地创建Java对象,而不只是通过new操作符来进行创建。在 得到Class类的对象之后,可以通过其中的方法来获取构造方法。相关的方法有4个,其中getConstructors用来获取所有的公开构造方法的列 表,getConstructor则根据参数类型来获取公开的构造方法。另外两个对应方法getDeclaredConstructors和getDeclaredConstructor的作用类 似,只不过它们会获取类中真正声明的构造方法,而忽略从父类中继承下来的构造方法。得到了表示构造方法的 java.lang.reflect.Constructor对象之后,就可以获取关于构造方法的更多信息,以及通过newInstance方法创建出新的对象。 一般的构造方法的获取和使用并没有什么特殊之处,需要特别说明的是对参数长度可变的构造方法和嵌套类(nested class)的构造方法 的使用。 如果构造方法声明了长度可变的参数,在获取构造方法的时候,要使用对应的数组类型的Class对象。这是因为长度可变的参数实际上是通 过数组来实现的。如代码清单2-16所示,类VarargsConstructor的构造方法包含String类型的可变长度参数,在调用getDeclaredConstructor 方法的时候,需要使用String[].class,否则会找不到该构造方法。在调用newInstance的时候,要把作为实际参数的字符串数组先转换成为 Object类型,这是为了避免方法调用时的歧义。这样编译器就知道把这个字符串数组作为一个可变长度的参数来传递。 代码清单2-16 使用反射API获取参数长度可变的构造方法 public class VarargsConstructor{ public VarargsConstructor(String……names){} } public void useVarargsConstructor()throws Exception{ Constructor<VarargsConstructor>constructor=VarargsConstructor.class.getDeclaredConstructor(String[].class); constructor.newInstance((Object)new String[]{"A","B","C"}); } 对嵌套类的构造方法的获取,需要区分静态和非静态两种情况,即是否在声明嵌套类的时候使用static关键词。静态的嵌套类并没有特别 之处,按照一般的方式来使用即可。而对于非静态嵌套类来说,其特殊之处在于它的对象实例中都有一个隐含的对象引用,指向包含它的外部 类对象。也正是这个隐含的对象引用的存在,使非静态嵌套类中的代码可以直接引用外部类中包含的私有域和方法。因此,在获取非静态嵌套 类的构造方法的时候,类型参数列表的第一个值必须是外部类的Class对象。如代码清单2-17所示,静态嵌套类StaticNestedClass的使用并没 有特殊之处。在获取到非静态嵌套类NestedClass的构造方法之后,用newInstance创建新对象,此时第一个参数就是其外部对象的引用this, 与调用getDeclaredConstructor方法时的第一个参数相对应。 代码清单2-17 使用反射API获取嵌套类的构造方法 static class StaticNestedClass{ public StaticNestedClass(String name){} } class NestedClass{ public NestedClass(int count){} } public void useNestedClassConstructor()throws Exception{ Constructor<Static Nested Class>sncc=Static Nested Class.class.getDeclaredConstructor(String.class); sncc.newInstance("Alex"); Constructor<NestedClass>ncc=NestedClass.class.getDeclaredConstructor(Const ructorUsage.class, int.class); NestedClass ic=ncc.newInstance(this,3); } 2.2.2 获取域 除了可以获取2.2.1节提到的构造方法之外,还可以通过反射API获取类中的域(field)。通过反射API可以获取到类中公开的静态域和对 象中的实例域。得到表示域的java.lang.reflect.Field类的对象之后,就可以获取和设置域的值。与上面的构造方法类似,Class类中也有4个 方法用来获取域,分别是getFields、getField、getDeclaredFields和getDeclaredField,其含义与获取构造方法的4个方法类似。代码清单2- 18给出了获取和使用静态域和实例域的示例,两者的区别在于使用静态域时不需要提供具体的对象实例,使用null即可。Field类中除了操作 Object的get和set方法之外,还有操作基本类型的对应方法,包括getBoolean/setBoolean、getByte/setByte、getChar/setChar、 getDouble/setDouble、getFloat/setFloat、getInt/setInt和getLong/setLong等。 代码清单2-18 使用反射API获取和使用静态域和实例域 public void useField()throws Exception{ Field fieldCount=FieldContainer.class.getDeclaredField("count"); fieldCount.set(null,3); Field fieldName=FieldContainer.class.getDeclaredField("name"); FieldContainer fieldContainer=new FieldContainer(); fieldName.set(fieldContainer,"Bob"); } 总的来说,对域的获取和设置都比较简单。但是只能对类中的公开域进行操作。私有域没有办法通过反射API获取到,也无法进行操作。 2.2.3 获取方法 最后一个可以通过反射API获取的元素是方法,这也是最常使用反射API的场景,即获取到一个对象中的方法,并在运行时调用该方法。与 之前提到的构造方法和域类似,Class类中也有4个方法用来获取方法,分别是getMethods、getMethod、getDeclaredMethods和 getDeclaredMethod。这4个方法的含义类似于获取构造方法和域的对应方法。在得到了表示方法的java.lang.reflect.Method类的对象之后, 就可以查询该方法的详细信息,比如方法的参数和返回值的类型等。最重要的是可以通过invoke方法来传入实际参数并调用该方法。代码清单 2-19中分别给出了获取和调用对象中的公开和私有方法的示例。需要注意的是,在调用私有方法之前,需要先调用Method类的setAccessible方 法来设置可以访问的权限。 代码清单2-19 使用反射API获取和使用公开和私有方法 public void useMethod()throws Exception{ MethodContainer mc=new MethodContainer(); Method public Method=Method Container.class.getDeclaredMethod("publicMethod"); publicMethod.invoke(mc); Method private Method=Method Container.class.getDeclaredMethod("privateMethod"); privateMethod.setAccessible(true); privateMethod.invoke(mc); } 与构造方法和域不同的是,通过反射API可以获取到类中的私有方法。 2.2.4 操作数组 使用反射API对数组进行操作的方式不同于一般的Java对象,是通过专门的java.lang.reflect.Array这个实用工具类来实现的。Array类中 提供的方法包括创建数组和操作数组中的元素。如代码清单2-20所示,newInstance方法用来创建新的数组,第一个参数是数组中元素的类型, 后面的参数是数组的维度信息。比如names是一个长度为10的一维String数组。matrix1是一个3×3×3的三维数组。由于matrix2的元素类型是 int[],虽然在创建时只声明了两个维度,但是它实际上也是一个三维数组。 代码清单2-20 使用反射API操作数组 public void useArray(){ String[]names=(String[])Array.newInstance(String.class,10); names[0]="Hello"; Array.set(names,1,"World"); String str=(String)Array.get(names,0); int[][][]matrix1=(int[][][])Array.newInstance(int.class,3,3,3); matrix1[0][0][0]=1; int[][][]matrix2=(int[][][])Array.newInstance(int[].class,3,4); matrix2[0][0]=new int[10]; matrix2[0][1]=new int[3]; matrix2[0][0][1]=1; } 2.2.5 访问权限与异常处理 使用反射API的一个重要好处是可以绕过Java语言中默认的访问控制权限。比如在正常的代码中,一个类的对象是不能访问在另外一个类中 声明的私有方法的,但是通过反射API可以做到这一点,具体的做法如代码清单2-19所示。Constructor、Field和Method都继承自 java.lang.reflect.AccessibleObject,其中的方法setAccessible可以用来设置是否绕开默认的权限检查。 在利用invoke方法来调用方法时,如果方法本身抛出了异常,invoke方法会抛出InvocationTargetException异常来表示这种情况。在捕获 到InvocationTargetException异常的时候,通过InvocationTargetException异常的getCause方法可以获取到真正的异常信息,帮助进行调 试。 值得一提的是,Java 7为所有与反射操作相关的异常类添加了一个新的父类java.lang.ReflectiveOperationException。在处理与反射相 关的异常的时候,可以直接捕获这个新的异常。而在Java 7之前,这些异常是需要分别捕获的。 2.3 动态代理 这一节要介绍Java语言支持动态性的另外一个方面,即动态代理(dynamic proxy)机制。这个名称中的“代理”会让人很容易联想到设计 模式中的代理模式。实际上,使用动态代理机制,不但可以实现代理模式,还可以实现装饰器和适配器模式。通过使用动态代理,可以在运行 时动态创建出同时实现多个Java接口的代理类及其对象实例。当客户代码通过这些被代理的接口来访问其中的方法时,相关的调用信息会被传 递给代理中的一个特殊对象进行处理,处理的结果作为方法调用的结果返回。动态代理的这种实现机制,属于代理模式的基本用法。客户代码 看到的只是接口,具体的逻辑被封装在代理的实现中。 动态代理机制的强大之处在于可以在运行时动态实现多个接口,而不需要在源代码中通过implements关键词来声明。同时,动态代理把对 接口中方法调用的处理逻辑交给开发人员,让开发人员可以灵活处理。通过动态代理可以实现面向方面编程(AOP)中常见的方法拦截功能。 2.3.1 基本使用方式 使用动态代理时只需要理解两个要素即可:第一个是要代理的接口,另外一个是处理接口方法调用的 java.lang.reflect.InvocationHandler接口。动态代理只支持对接口提供代理,一般的Java类是不行的。如果要代理的接口不是公开的,那么 被代理的接口和创建动态代理的代码必须在同一个包中。在创建动态代理的时候,需要提供InvocationHandler接口的实现,以处理实际的调 用。在进行处理的时候可以得到表示实际调用方法的Method对象和调用的实际参数列表。代码清单2-21给出了一个简单的InvocationHandler接 口的实现类。InvocationHandler接口只有一个需要实现的方法invoke。当客户代码调用被代理的接口中的方法时,invoke方法就会被调用,而 代理对象、所调用方法的Method对象和实际参数列表都会作为invoke方法的参数。在下面invoke方法的实现代码中,只是简单地通过Java的日 志API记录下方法调用的相关信息,再调用原始的方法,并返回结果。 代码清单2-21 InvocationHandler接口的实现类的示例 public class LoggingInvocationHandler implements InvocationHandler{ private static final Logger LOGGER=Logger.getLogger(LoggingInvocationHandl er.class); private Object receiverObject; public LoggingInvocationHandler(Object object){ this.receiverObject=object; } public Object invoke(Object proxy, Method method, Object[]args)throws Throwable{ LOGGER.log(Level.INFO,"调用方法"+method.getName()+";参数为"+Arrays.deepToString(args)); return method.invoke(receiverObject, args); } } 在有了InvocationHandler接口的实现之后,就可以创建和使用动态代理,代码清单2-22给出了一个示例。创建动态代理时需要一个 InvocationHandler接口的实现,这里用到的是上面的LoggingInvocationHandler类的实例。动态代理的创建是由java.lang.reflect.Proxy类 的静态方法newProxyInstance来完成的。创建时需要提供类加载器实例、被代理的接口列表以及InvocationHandler接口的实现。在创建完成之 后,需要通过类型转换把代理对象转换成被代理的某个接口来使用。 代码清单2-22 创建和使用动态代理的示例 public static void useProxy(){ String str="Hello World"; LoggingInvocationHandler handler=new LoggingInvocationHandler(str); ClassLoader cl=SimpleProxy.class.getClassLoader(); Comparable obj=(Comparable)Proxy.newProxyInstance(cl, new Class[]{Comparable.class},handler); obj.compareTo("Good"); } 在上面的代码中,当通过代理对象的Comparable接口来调用其中的方法时,这个调用会被传递给LoggingInvocationHandler中的invoke方 法。代理对象本身obj、所调用的方法compareTo对应的Method对象,以及实际参数字符串“Good”都会作为参数传递过去。在输出相关的日志 信息之后,原始的compareTo方法会被执行。而invoke方法的执行结果则被作为方法调用“obj.compareTo("Good")”的返回结果。 虽然LoggingInvocationHandler类只是简单地记录了日志,并没有改变方法的实际执行,但是实际上,在InvocationHandler接口的invoke 方法中可以实现各种各样复杂的逻辑。比如对实际调用的参数进行变换,或是改变实际调用的方法,还可以对调用的返回结果进行修改。开发 人员可以根据自己的需要,添加感兴趣的业务逻辑。这实际上就是AOP中常用的方法拦截,即拦截一个方法调用,以在其上附加所需的业务逻 人员可以根据自己的需要,添加感兴趣的业务逻辑。这实际上就是AOP中常用的方法拦截,即拦截一个方法调用,以在其上附加所需的业务逻 辑。InvocationHandler很适合于封装一些横切(cross-cutting)的代码逻辑,包括日志、参数检查与校验、异常处理和返回值归一化等。 一般来说,在创建一个动态代理的InvocationHandler实例的时候,需要把原始的方法调用的接收者对象也传入进去,以方便执行原始的方 法调用。这可以在创建InvocationHandler的时候,通过构造方法来传递。在大多数情况下,代理对象只会实现一个Java接口。对于这种情况, 可以结合泛型来开发一个通用的工厂方法,以创建代理对象。在代码清单2-23中,工厂方法makeProxy为任何接口及其实现类创建代理。 代码清单2-23 为任何接口及其实现类创建代理的工厂方法 public static<T>T makeProxy(Class<T>intf, final T object){ LoggingInvocationHandler handler=new LoggingInvocationHandler(object); ClassLoader cl=object.getClass().getClassLoader(); return(T)Proxy.newProxyInstance(cl, new Class<?>[]{intf},handler); } 上面的通用工厂方法的使用方式如代码清单2-24所示。 代码清单2-24 创建代理对象的工厂方法的使用示例 public static void useGenericProxy(){ String str="Hello World"; Comparable proxy=makeProxy(Comparable.class, str); proxy.compareTo("Good"); List<String>list=new ArrayList<String>(); list=makeProxy(List.class, list); list.add("Hello"); } 在这里需要注意的是,通过Proxy.newProxyInstance创建出来的代理对象只能转换成它所实现的接口类型,而不能转换成接口的具体实现 类。这是因为动态代理只对接口起作用。 上面的示例代码都只代理了一个接口,如果希望代理多个接口,只需要传入多个接口类即可。所得到的代理对象可以被类型转换成这些接 口中的任何一个。如果希望直接代理某个类所实现的所有接口,可以参考代码清单2-25中的做法。代码清单2-25中的proxyAll方法并没有对创 建的代理对象进行类型转换,而是直接返回给调用者。这是为了让调用者可以灵活操作,允许它们根据需要转换成不同的接口。比如,如果传 入的是String类的对象实例,则调用者可以将其转换成String类所实现的Comparable或是CharSequence接口。 代码清单2-25 代理某个类所实现的所有接口 public static Object proxyAll(final Object object){ LoggingInvocationHandler handler=new LoggingInvocationHandler(object); ClassLoader cl=object.getClass().getClassLoader(); Class<?>[]interfaces=object.getClass().getInterfaces(); return Proxy.newProxyInstance(cl, interfaces, handler); } 上面介绍的是通过Proxy.newProxyInstance方法来直接创建动态代理对象,实际上这是一个快速创建代理对象的捷径。还可以通过 Proxy.getProxyClass方法来首先获取到代理类。得到的代理类实现了被代理的接口。通过Proxy.newProxyInstance方法得到的代理对象实际上 是通过反射API调用代理类的构造方法来得到的。代理类的构造方法只有一个参数,即前面提到的InvocationHandler接口的实现。对于一个 Java类,可以通过Proxy.isProxy方法来判断是否为代理类。 当同时代理多个接口时,这些接口在代理类创建时的排列顺序就显得尤为重要。即便是同样的接口,不同的排列顺序所产生的代理类也是 不同的。实际上,对于相同排列的接口类型,其对应的代理类只会被创建一次。创建完成之后就会被缓存起来。之后的创建请求得到的是缓存 的代理类。强调接口的排列顺序的一个重要原因是,这个顺序会对接口中声明类型相同的方法的选择产生影响。如果多个接口中都存在声明类 型相同的方法,那么在调用方法时,排列顺序中最先出现的接口中的方法会被选择。代码清单2-26中给出了被代理的接口中包含声明类型相同 的方法的情况。在这里并没有使用Proxy.newProxyInstance方法来直接创建代理对象,而是先通过Proxy.getProxyClass来创建代理类,再使用 反射API来创建代理类的对象。 代码清单2-26 被代理的接口中包含声明类型相同的方法的示例 public void proxyMultipleInterfaces()throws Throwable{ List<String>receiverObj=new ArrayList<String>(); ClassLoader cl=MultipleInterfacesProxy.class.getClassLoader(); LoggingInvocationHandler handler=new LoggingInvocationHandler(receiverObj); Class<?>proxyClass=Proxy.getProxyClass(cl, new Class<?>[]{List.class, Set.class}); Object proxy=proxyClass.getConstructor(new Class[]{InvocationHandler.class}); newInstance(new Object[]{handler}); List list=(List)proxy; list.add("Hello"); Set set=(Set)proxy; set.add("World"); } 在上面代码中,代理类代理了java.util.List和java.util.Set两个接口,所以下面两个对代理对象进行类型转换的操作都会成功。而 LoggingInvocationHandler中实际调用的接收者receiverObj其实是一个java.util.ArrayList对象,并没有实现Set接口。但是上面代码中的 set.add("World")语句并不会出现错误。这是因为创建代理类时,List接口出现在Set接口的前面。当调用add方法的时候,实际上调用的是 List接口中的方法,而与转换之后的接口类型Set无关。如果把List和Set接口在创建代理类时的顺序调换一下,再运行代码就会出现错误。因 为调换之后实际调用的是Set接口中的add方法,而实际的调用接收者并没有实现Set接口,所以会出现类型错误。 注意 在通过动态代理对象来调用Object类中声明的equals、hashCode和toString等方法的时候,这个调用也会被传递给 InvocationHandler中的invoke方法。 动态代理的关键就是上面提到的InvocationHandler接口,通过它可以添加动态的方法调用逻辑。从InvocationHandler的invoke方法可以 看出,动态代理在方法调用上额外添加一个新的抽象层次,使开发人员有机会在方法调用发生时和实际的调用执行之间,添加自己的代码逻 辑。如果希望根据调用方法的名称和参数的不同,实现不同的逻辑,可以考虑使用动态代理,以减少代码重复。 2.3.2 使用案例 下面用一个完整的案例来说明动态代理在实际开发中的作用。在实际开发中,我们会遇到的一个具体问题就是程序的版本更新。在开发新 版本的时候,一方面要考虑与旧版本的兼容,另一方面又希望能够修复旧版本中存在的一些设计上的问题。动态代理可以帮助平衡这两方面的 需求。 比如在程序中有一个接口用来生成显示给用户的问候语。最开始设计的时候,这个接口GreetV1就一个方法greet,它接收2个参数,分别是 用户的姓名和性别,如代码清单2-27所示。 代码清单2-27 早期版本的GreetV1接口的定义 public interface GreetV1{ String greet(String name, String gender)throws GreetException; } 在开发新版本的时候,发现这个接口的设计不太合理,希望把方法的参数改为一个,表示用户名即可,姓名和性别可以通过进一步的查找 来完成。另外,也不希望方法抛出受检异常,希望使用更为方便的非受检异常。基于上面的考虑,就有了新的接口定义GreetV2,如代码清单2- 28所示。 代码清单2-28 新版本的GreetV2接口的定义 public interface GreetV2{ String greet(String username); } 这个新接口比旧接口更加简单和实用,同时新定义了相关的非受检异常GreetRuntimeException。以后的代码中都应该使用这个新的接口, 同时使用旧接口的代码也要能够继续使用。这就是动态代理可以发挥作用的地方。通过动态代理,可以把实现旧接口GreetV1的对象实例转换成 可以通过新接口GreetV2来调用。这样既保证了对新接口的使用,又使旧接口的实现可以继续存在。这实际上是通过动态代理来实现适配器设计 模式的。实现这样的动态代理的关键就在于适配两个接口的InvocationHandler的实现,完整的实现如代码清单2-29所示。 代码清单2-29 完成接口适配的InvocationHandler接口的实现 public class GreetAdapter implements InvocationHandler{ private GreetV1 greetV1; public GreetAdapter(GreetV1 greetV1){ this.greetV1=greetV1; } public Object invoke(Object proxy, Method method, Object[]args)throws Throwable{ String methodName=method.getName(); if("greet".equals(methodName)){ String username=(String)args[0]; String name=findName(username); String gender=findGender(username); try{ Method greetMethodV1=GreetV1.class.getMethod(methodName, new Class<?>[]{String.class, String.class}); return greetMethodV1.invoke(greetV1,new Object[]{name, gender}); }catch(InvocationTargetException e){ Throwable cause=e.getCause(); if(cause!=null&&cause instanceof GreetException){ throw new GreetRuntimeException(cause); } throw e; } }else{ return method.invoke(greetV1,args); } } private String findGender(String username){ return Math.random()>0.5?username:null; } private String findName(String username){ return username; } } 由于动态代理把GreetV1接口的实现对象适配到GreetV2接口上,因此需要有一个已有的GreetV1接口的对象作为调用的接收者,通过构造方 法传递即可实现。在invoke方法中,首先通过检查调用的方法的名称来判断是否为GreetV1中已有的greet方法。这是因为其他方法的调用也会 被传入到invoke方法中,而这些方法是不需要被处理的。如果调用的是greet方法,则说明这次调用需要代理给GreetV1接口的实现对象来完 成。因为GreetV1和GreetV2接口中的greet方法的参数不匹配,需要先进行转换。在GreetAdapter中使用了两个方法来通过传入的用户名查找对 应的姓名和性别。接着通过反射API获取GreetV1接口中的greet方法,再把转换之后的参数传入以进行方法调用,最后返回调用的结果。在调用 的时候,GreetV1接口中的greet方法可能会抛出受检异常GreetException,因此需要捕获这个异常,并重新包装成非受检异常 RuntimeGreetException之后再次抛出。因为GreetV1的接口实现在参数gender的值为null时会抛出GreetException。这里就通过生成随机数的 方式来模拟出错的情况。 对于每一个GreetV1接口的实现,都可以通过一个工厂方法转换成可以通过GreetV2接口来使用的新对象。工厂方法如代码清单2-30所示。 代码清单2-30 进行对象转换的工厂方法 public class GreetFactory{ public static GreetV2 adaptGreet(GreetV1 greet){ GreetAdapter adapter=new GreetAdapter(greet); ClassLoader cl=greet.getClass().getClassLoader(); return(GreetV2)Proxy.newProxyInstance(cl, new Class<?>[]{GreetV2.class},adapter); } } 在实际的使用中,如果遇到GreetV1接口的实现,只需要将调用GreetFactory的adaptGreet方法转换成GreetV2接口,再按照GreetV2接口的 方式来使用即可。GreetV1接口可以继续在遗留代码中使用。 2.4 动态语言支持 这节将要介绍的是Java 7中的一个重要的新特性。这个新特性的特殊之处在于它是对Java虚拟机规范的修改,而不是对Java语言规范的修 改。从这个角度来说,这个改动会比之前介绍的Java 7新特性更加复杂,对Java平台的影响也更加深远。这个新特性增强了Java虚拟机中对方 法调用的支持。虽然这个特性的直接受益者是Java平台上的动态语言的编译器,但是它对一般应用程序也有重大的影响,最直接的就是提供了 比反射API更加强大的动态方法调用能力。本节将会详细介绍这个Java 7的重要新特性,所涉及的内容包括Java虚拟机中新的方法调用指令 invokedynamic,以及Java SE 7核心库中的java.lang.invoke包。这一个新特性对应的修改内容包含在JSR 292(Supporting Dynamically Typed Languages on the JavaTMPlatform)中。 2.4.1 Java语言与Java虚拟机 在介绍新特性之前,首先需要简单介绍一下Java虚拟机。Java虚拟机本身并不知道Java语言的存在,它只理解Java字节代码格式,即class 文件。一个class文件包含了Java虚拟机规范中所定义的指令和符号表。Java虚拟机只是负责执行class文件中包含的指令。而这些class文件可 以由Java语言的编译器生成,也可以由其他编程语言的编译器生成,还可以通过工具来手动生成。只要class文件的格式是符合规范的,Java虚 拟机就能正确执行它。 Java虚拟机的存在实际上是在底层操作系统和应用程序之间添加了一个新的抽象层次。对于一种编程语言来说,可以选择直接把源代码编 译成目标平台上的机器代码。这种做法无疑是效率最高的。但是所带来的问题是生成的二进制内容无法兼容不同平台,且实现的复杂度也很 高。如果存在某种虚拟机,事情就会变得简单很多。首先虚拟机提供了一个抽象层次,屏蔽了底层系统的差别,其所暴露的接口是规范而统一 的,可以真正实现“编写一次,到处运行”的目标。另外,虚拟机会提供很多编程语言所需要的运行时支持能力,包括内存管理、安全机制、 并发控制、标准库和工具等。最后,使用一个已有的虚拟机作为运行平台,使编程语言的使用者可以复用与这个虚拟机平台相关的已有资产, 包括相关的工具、集成开发环境和开发经验等。这有利于编程语言本身的推广和普及。 正因为如此,已经有非常多的编程语言支持把Java虚拟机作为目标运行平台。也就是说,这些语言的编译器支持把源代码编译成Java字节 代码,其中比较主流的语言包括Java、Scala、JRuby、Groovy、Jython、PHP、C#、JavaScript、Tcl和Lisp等。这其中最主流的还是Java语言 本身。 前面虽然说到Java虚拟机并不关心字节代码是由哪种编程语言产生的,但是Java语言作为Java虚拟机上的第一个也是最重要的一种语言, 它对Java虚拟机规范本身所产生的影响是最大的。事实上,Java虚拟机上的很多特性,是为了配合Java语言而产生的。Java语言作为一门静态 类型的编程语言,也影响了Java虚拟机本身的动态性。随着越来越多的动态类型编程语言将Java虚拟机作为运行平台,而Java虚拟机本身又缺 乏对动态性的支持,所以会对这些动态类型语言的实现产生比较大的阻碍。当然,动态类型语言的实现者总是能找到方法绕开Java虚拟机中的 各种限制,这样做所带来的后果就是复杂度比较高,性能也会受到影响。Java 7中的动态语言支持,就是在Java虚拟机规范这个层次上进行修 改,使Java虚拟机对于动态类型编程语言来说更加友好,性能也更好。 JSR 292中包含的相关改动涉及应用程序运行中最常见的方法调用。具体来说主要是两个部分,一个是Java标准库中新的方法调用API,另 外一个是Java虚拟机规范中新的invokedynamic指令。在下面的内容中,先介绍相关的Java API,因为对一般的开发者来说,这个是用得最多 的。随后也会对invokedynamic指令进行具体的介绍。首先从方法句柄开始介绍。 2.4.2 方法句柄 方法句柄(method handle)是JSR 292中引入的一个重要概念,它是对Java中方法、构造方法和域的一个强类型的可执行的引用。这也是 句柄这个词的含义所在。通过方法句柄可以直接调用该句柄所引用的底层方法。从作用上来说,方法句柄的作用类似于2.2节中提到的反射API 中的Method类,但是方法句柄的功能更强大、使用更灵活、性能也更好。实际上,方法句柄和反射API也是可以协同使用的,下面会具体介绍。 在Java标准库中,方法句柄是由java.lang.invoke.MethodHandle类来表示的。 1.方法句柄的类型 对于一个方法句柄来说,它的类型完全由它的参数类型和返回值类型来确定,而与它所引用的底层方法的名称和所在的类没有关系。比如 引用String类的length方法和Integer类的intValue方法的方法句柄的类型就是一样的,因为这两个方法都没有参数,而且返回值类型都是 int。 在得到一个方法句柄,即MethodHandle类的对象之后,可以通过其type方法来查看其类型。该方法的返回值是一个 java.lang.invoke.MethodType类的对象。MethodType类的所有对象实例都是不可变的,类似于String类。所有对MethodType类对象的修改,都 会产生一个新的MethodType类对象。两个MethodType类对象是否相等,只取决于它们所包含的参数类型和返回值类型是否完全一致。 MethodType类的对象实例只能通过MethodType类中的静态工厂方法来创建。这样的工厂方法有三类。第一类是通过指定参数和返回值的类 型来创建MethodType,这主要是使用methodType方法的多种重载形式。使用这些方法的时候,至少需要指定返回值类型,而参数类型则可以是0 到多个。返回值类型总是出现在methodType方法参数列表的第一个,后面紧接着的是0到多个参数的类型。类型都是由Class类的对象来指定 的。如果返回值类型是void,可以用void.class或java.lang.Void.class来声明。代码清单2-31中给出了使用methodType方法的几个示例。每 个MethodType声明上以注释的方式给出了与之相匹配的String类中的一个方法。这里值得一提的是,最后一个methodType方法调用中使用了另 外一个MethodType的参数类型作为当前MethodType类对象的参数类型。 代码清单2-31 MethodType类中的methodType方法的使用示例 public void generateMethodTypes(){ //String.length() MethodType mt1=MethodType.methodType(int.class); //String.concat(String str) MethodType mt2=MethodType.methodType(String.class, String.class); //String.getChars(int srcBegin, int srcEnd, char[]dst, int dstBegin) MethodType mt3=MethodType.methodType(void.class, int.class, int.class, char[].class, int.class); //String.startsWith(String prefix) MethodType mt4=MethodType.methodType(boolean.class, mt2); } 除了显式地指定返回值和参数的类型之外,还可以生成通用的MethodType类型,即返回值和所有参数的类型都是Object类。这是通过静态 工厂方法genericMethodType来创建的。方法genericMethodType有两种重载形式:第一种形式只需要指明方法类型中包含的Object类型的参数 个数即可,而第二种形式可以提供一个额外的参数来说明是否在参数列表的后面添加一个Object[]类型的参数。在代码清单2-32中,mt1有3个 类型为Object的参数,而mt2有2个类型为Object的参数和后面的Object[]类型参数。 代码清单2-32 生成通用MethodType类型的示例 public void generateGenericMethodTypes(){ MethodType mt1=MethodType.genericMethodType(3); MethodType mt2=MethodType.genericMethodType(2,true); } 最后介绍的一个工厂方法是比较复杂的fromMethodDescriptorString。这个方法允许开发人员指定方法类型在字节代码中的表示形式作为 创建MethodType时的参数。这个方法的复杂之处在于字节代码中的方法类型格式不是很好理解。比如代码清单2-31中的String.getChars方法的 类型在字节代码中的表示形式是“(II[CI)V”。不过这种格式比逐个声明返回值和参数类型的做法更加简洁,适合于对Java字节代码格式比 较熟悉的开发人员。在代码清单2-33中,“(Ljava/lang/String;)Ljava/lang/String;”所表示的方法类型是返回值和参数类型都是 java.lang.String,相当于使用MethodType.methodType(String.class, String.class)。 代码清单2-33 使用方法类型在字节代码中的表示形式来创建MethodType public void generateMethodTypesFromDescriptor(){ ClassLoader cl=this.getClass().getClassLoader(); String descriptor="(Ljava/lang/String;)Ljava/lang/String;"; MethodType mt1=MethodType.fromMethodDescriptorString(descriptor, cl); } 在使用fromMethodDescriptorString方法的时候,需要指定一个类加载器。该类加载器用来加载方法类型表达式中出现的Java类。如果不 指定,默认使用系统类加载器。 在通过工厂方法创建出MethodType类的对象实例之后,可以对其进行进一步修改。这些修改都围绕返回值和参数类型展开。所有这些修改 方法都返回另外一个新的MethodType对象。代码清单2-34给出了对MethodType中的返回值和参数类型进行修改的示例代码。基本的修改操作包 括改变返回值类型、添加和插入新参数、删除已有参数和修改已有参数的类型等。在每个修改方法上以注释形式给出修改之后的类型,括号里 面是参数类型列表,外面是返回值类型。 代码清单2-34 对MethodType中的返回值和参数类型进行修改的示例 public void changeMethodType(){ //(int, int)String MethodType mt=MethodType.methodType(String.class, int.class, int.class); //(int, int, float)String mt=mt.appendParameterTypes(float.class); //(int, double, long, int, float)String mt=mt.insertParameterTypes(1,double.class, long.class); //(int, double, int, float)String mt=mt.dropParameterTypes(2,3); //(int, double, String, float)String mt=mt.changeParameterType(2,String.class); //(int, double, String, float)void mt=mt.changeReturnType(void.class); } 除了上面这几个精确修改返回值和参数的类型的方法之外,MethodType还有几个可以一次性对返回值和所有参数的类型进行处理的方法。 代码清单2-35给出了这几个方法的使用示例,其中wrap和unwrap用来在基本类型及其包装类型之间进行转换,generic方法把所有返回值和参数 类型都变成Object类型,而erase只把引用类型变成Object,并不处理基本类型。修改之后的方法类型同样以注释的形式给出。 代码清单2-35 一次性修改MethodType中的返回值和所有参数的类型的示例 public void wrapAndGeneric(){ //(int, double)Integer MethodType mt=MethodType.methodType(Integer.class, int.class, double.class); //(Integer, Double)Integer MethodType wrapped=mt.wrap(); //(int, double)int MethodType unwrapped=mt.unwrap(); //(Object, Object)Object MethodType generic=mt.generic(); //(int, double)Object MethodType erased=mt.erase(); } 由于每个对MethodType对象进行修改的方法的返回值都是一个新的MethodType对象,可以很容易地通过方法级联来简化代码。 2.方法句柄的调用 在获取到了一个方法句柄之后,最直接的使用方法就是调用它所引用的底层方法。在这点上,方法句柄的使用类似于反射API中的Method 类。但是方法句柄在调用时所提供的灵活性是Method类中的invoke方法所不能比的。 最直接的调用一个方法句柄的做法是通过invokeExact方法实现的。这个方法与直接调用底层方法是完全一样的。invokeExact方法的参数 依次是作为方法接收者的对象和调用时候的实际参数列表。比如在代码清单2-36中,先获取String类中substring的方法句柄,再通过 invokeExact来进行调用。这种调用方式就相当于直接调用"Hello World".substring(1,3)。关于方法句柄的获取,下一节会具体介绍。 代码清单2-36 使用invokeExact方法调用方法句柄 public void invokeExact()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodType type=MethodType.methodType(String.class, int.class, int.class); MethodHandle mh=lookup.findVirtual(String.class,"substring",type); String str=(String)mh.invokeExact("Hello World",1,3); System.out.println(str); } 在这里强调一下静态方法和一般方法之间的区别。静态方法在调用时是不需要指定方法的接收对象的,而一般的方法则是需要的。如果方 法句柄mh所引用的是java.lang.Math类中的静态方法min,那么直接通过mh.invokeExact(3,4)就可以调用该方法。 注意 invokeExact方法在调用的时候要求严格的类型匹配,方法的返回值类型也是在考虑范围之内的。代码清单2-36中的方法句柄所引用 的substring方法的返回值类型是String,因此在使用invokeExact方法进行调用时,需要在前面加上强制类型转换,以声明返回值的类型。如 果去掉这个类型转换,而直接赋值给一个Object类型的变量,在调用的时候会抛出异常,因为invokeExact会认为方法的返回值类型是Object。 去掉类型转换但是不进行赋值操作也是错误的,因为invokeExact会认为方法的返回值类型是void,也不同于方法句柄要求的String类型的返回 值。 与invokeExact所要求的类型精确匹配不同的是,invoke方法允许更加松散的调用方式。它会尝试在调用的时候进行返回值和参数类型的转 换工作。这是通过MethodHandle类的asType方法来完成的。asType方法的作用是把当前的方法句柄适配到新的MethodType上,并产生一个新的 方法句柄。当方法句柄在调用时的类型与其声明的类型完全一致的时候,调用invoke等同于调用invokeExact;否则,invoke会先调用asType方 法来尝试适配到调用时的类型。如果适配成功,调用可以继续;否则会抛出相关的异常。这种灵活的适配机制,使invoke方法成为在绝大多数 情况下都应该使用的方法句柄调用方式。 进行类型适配的基本规则是比对返回值类型和每个参数的类型是否都可以相互匹配。只要返回值类型或某个参数的类型无法完成匹配,那 么整个适配过程就是失败的。从待转换的源类型S到目标类型T匹配成功的基本原则如下: 1)可以通过Java的类型转换来完成,一般是从子类转换成父类,接口的实现类转换成接口,比如从String类转换到Object类。 2)可以通过基本类型的转换来完成,只能进行类型范围的扩大,比如从int类型转换到long类型。 3)可以通过基本类型的自动装箱和拆箱机制来完成,比如从int类型到Integer类型。 4)如果S有返回值类型,而T的返回值是void, S的返回值会被丢弃。 5)如果S的返回值是void,而T的返回值是引用类型,T的返回值会是null。 6)如果S的返回值是void,而T的返回值是基本类型,T的返回值会是0。 满足上面规则时进行两个方法类型之间的转换是会成功的。对于invoke方法的具体使用,只需要把代码清单2-36中的invokeExact方法换成 invoke即可,并不需要做太多的介绍。 最后一种调用方式是使用invokeWithArguments。该方法在调用时可以指定任意多个Object类型的参数。完整的调用方式是首先根据传入的 实际参数的个数,通过MethodType的genericMethodType方法得到一个返回值和参数类型都是Object的新方法类型。再把原始的方法句柄通过 asType转换后得到一个新的方法句柄。最后通过新方法句柄的invokeExact方法来完成调用。这个方法相对于invokeExact和invoke的优势在 于,它可以通过Java反射API被正常获取和调用,而invokeExact和invoke不可以这样。它可以作为反射API和方法句柄之间的桥梁。 3.参数长度可变的方法句柄 在方法句柄中,所引用的底层方法中包含长度可变的参数是一种比较特殊的情况。虽然最后一个长度可变的参数实际上是一个数组,但是 仍然可以简化方法调用时的语法。对于这种特殊的情况,方法句柄也提供了相关的处理能力,主要是一些转换的方法,允许在可变长度的参数 和数组类型的参数之间互相转换,以方便开发人员根据需求选择最适合的调用语法。 MethodHandle中第一个与长度可变参数相关的方法是asVarargsCollector。它的作用是把原始的方法句柄中的最后一个数组类型的参数转 换成对应类型的可变长度参数。如代码清单2-37所示,方法normalMethod的最后一个参数是int类型的数组,引用它的方法句柄在通过 asVarargsCollector方法转换之后,得到的新方法句柄在调用时就可以使用长度可变参数的语法格式,而不需要使用原始的数组形式。在实际 的调用中,int类型的参数3、4和5组成的数组被传入到了normalMethod的参数args中。 代码清单2-37 asVarargsCollector方法的使用示例 public void normalMethod(String arg1,int arg2,int[]arg3){ } public void asVarargsCollector()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodHandle mh=lookup.findVirtual(Varargs.class,"normalMethod",MethodType.methodType(void.class, String.class, int.class, int[].class)); mh=mh.asVarargsCollector(int[].class); mh.invoke(this,"Hello",2,3,4,5); } 第二个方法asCollector的作用与asVarargsCollector类似,不同的是该方法只会把指定数量的参数收集到原始方法句柄所对应的底层方法 的数组类型参数中,而不像asVarargsCollector那样可以收集任意数量的参数。如代码清单2-38所示,还是以引用normalMethod的方法句柄为 例,asCollector方法调用时的指定参数为2,即只有2个参数会被收集到整数类型数组中。在实际的调用中,int类型的参数3和4组成的数组被 传入到了normalMethod的参数args中。 代码清单2-38 asCollector方法的使用示例 public void asCollector()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodHandle mh=lookup.findVirtual(Varargs.class,"normalMethod",MethodType.methodType(void.class, String.class, int.class, int[].class)); mh=mh.asCollector(int[].class,2); mh.invoke(this,"Hello",2,3,4); } 上面的两个方法把数组类型参数转换为长度可变的参数,自然还有与之对应的执行反方向转换的方法。代码清单2-39给出的asSpreader方 法就把长度可变的参数转换成数组类型的参数。转换之后的新方法句柄在调用时使用数组作为参数,而数组中的元素会被按顺序分配给原始方 法句柄中的各个参数。在实际的调用中,toBeSpreaded方法所接受到的参数arg2、arg3和arg4的值分别是3、4和5。 代码清单2-39 asSpreader方法的使用示例 public void toBeSpreaded(String arg1,int arg2,int arg3,int arg4){ } public void asSpreader()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodHandle mh=lookup.findVirtual(Varargs.class,"toBeSpreaded",MethodType.methodType(void.class, String.class, int.class, int.class, int.class)); mh=mh.asSpreader(int[].class,3); mh.invoke(this,"Hello",new int[]{3,4,5}); } 最后一个方法asFixedArity是把参数长度可变的方法转换成参数长度不变的方法。经过这样的转换之后,最后一个长度可变的参数实际上 就变成了对应的数组类型。在调用方法句柄的时候,就只能使用数组来进行参数传递。如代码清单2-40所示,asFixedArity会把引用参数长度 可变方法varargsMethod的原始方法句柄转换成固定长度参数的方法句柄。 代码清单2-40 asFixedArity方法的使用示例 public void varargsMethod(String arg1,int……args){ } public void asFixedArity()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodHandle mh=lookup.findVirtual(Varargs.class,"varargsMethod",MethodType.methodType(void.class, String.class, int[].class)); mh=mh.asFixedArity(); mh.invoke(this,"Hello",new int[]{2,4}); } 4.参数绑定 在前面介绍过,如果方法句柄在调用时引用的底层方法不是静态的,调用的第一个参数应该是该方法调用的接收者。这个参数的值一般在 调用时指定,也可以事先进行绑定。通过MethodHandle的bindTo方法可以预先绑定底层方法的调用接收者,而在实际调用的时候,只需要传入 实际参数即可,不需要再指定方法的接收者。代码清单2-41给出了对引用String类的length方法的方法句柄的两种调用方式:第一种没有进行 绑定,调用时需要传入length方法的接收者;第二种方法预先绑定了一个String类的对象,因此调用时不需要再指定。 代码清单2-41 参数绑定的基本用法 public void bindTo()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodHandle mh=lookup.findVirtual(String.class,"length",MethodType.methodType(int.class)); int len=(int)mh.invoke("Hello");//值为5 mh=mh.bindTo("Hello World"); len=(int)mh.invoke();//值为11 } 这种预先绑定参数的方式的灵活性在于它允许开发人员只公开某个方法,而不公开该方法所在的对象。开发人员只需要找到对应的方法句 柄,并把适合的对象绑定到方法句柄上,客户代码就可以只获取到方法本身,而不会知道包含此方法的对象。绑定之后的方法句柄本身就可以 在任何地方直接运行。 实际上,MethodHandle的bindTo方法只是绑定方法句柄的第一个参数而已,并不要求这个参数一定表示方法调用的接收者。对于一个 MethodHandle,可以多次使用bindTo方法来为其中的多个参数绑定值。代码清单2-42给出了多次绑定的一个示例。方法句柄所引用的底层方法 是String类中的indexOf方法,同时为方法句柄的前两个参数分别绑定了具体的值。 代码清单2-42 多次参数绑定的示例 public void multipleBindTo()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodHandle mh=lookup.findVirtual(String.class,"indexOf",MethodType.methodType(int.class, String.class, int.class)); mh=mh.bindTo("Hello").bindTo("l"); System.out.println(mh.invoke(2));//值为2} 需要注意的是,在进行参数绑定的时候,只能对引用类型的参数进行绑定。无法为int和float这样的基本类型绑定值。对于包含基本类型 参数的方法句柄,可以先使用wrap方法把方法类型中的基本类型转换成对应的包装类,再通过方法句柄的asType将其转换成新的句柄。转换之 后的新句柄就可以通过bindTo来进行绑定,如代码清单2-43所示。 代码清单2-43 基本类型参数的绑定方式 MethodHandle mh=lookup.findVirtual(String.class,"substring",MethodType.methodType(String.class, int.class, int.class)); mh=mh.asType(mh.type().wrap()); mh=mh.bindTo("Hello World").bindTo(3); System.out.println(mh.invoke(5));//值为“lo” 5.获取方法句柄 获取方法句柄最直接的做法是从一个类中已有的方法中转换而来,得到的方法句柄直接引用这个底层方法。在之前的示例中都是通过这种 方式来获取方法句柄的。方法句柄可以按照与反射API类似的做法,从已有的类中根据一定的条件进行查找。与反射API不同的是,方法句柄并 不区分构造方法、方法和域,而是统一转换成MethodHandle对象。对于域来说,获取到的是用来获取和设置该域的值的方法句柄。 方法句柄的查找是通过java.lang.invoke.MethodHandles.Lookup类来完成的。在查找之前,需要通过调用MethodHandles.lookup方法获取 到一个MethodHandles.Lookup类的对象。MethodHandles.Lookup类提供了一些方法以根据不同的条件进行查找。代码清单2-44以String类为例 说明了查找构造方法和一般方法的示例。方法findConstructor用来查找类中的构造方法,需要指定返回值和参数类型,即MethodType对象。而 findVirtual和findStatic则用来查找一般方法和静态方法,除了表示方法的返回值和参数类型的MethodType对象之外,还需要指定方法的名 称。 代码清单2-44 查找构造方法、一般方法和静态方法的方法句柄的示例 public void lookupMethod()throws NoSuchMethodException, IllegalAccessException{MethodHandles.Lookup lookup=MethodHandles.lookup(); //构造方法 lookup.findConstructor(String.class, MethodType.methodType(void.class, byte[].class)); //String.substring lookup.findVirtual(String.class,"substring",MethodType.methodType(String.class, int.class, int.class)); //String.format lookup.findStatic(String.class,"format",MethodType.methodType(String.class, String.class, Object[].class)); } 除了上面3种类型的方法之外,还有一个findSpecial方法用来查找类中的特殊方法,主要是类中的私有方法。代码清单2-45给出了 findSpecial的使用示例,Method-HandleLookup是lookupSpecial方法所在的类,而privateMethod是该类中的一个私有方法。由于访问的是类 的私有方法,从访问控制的角度出发,进行方法查找的类需要具备访问私有方法的权限。 代码清单2-45 查找类中特殊方法的方法句柄的示例 public Method Handle lookup Special()throws No Such Method Exception, Illegal Access Exception, Throwable{ Method Handles.Lookup lookup=Method Handles.lookup(); Method Handlemh=lookup.find Special(Method Handle Lookup.class, "private Method",Method Type.metho dType(void.class),Method Handle Lookup. class); return mh; } 从上面的代码中可以看到,findSpecial方法比之前的findVirtual和findStatic等方法多了一个参数。这个额外的参数用来指定私有方法 被调用时所使用的类。提供这个类的原因是为了满足对私有方法的访问控制的要求。当方法句柄被调用时,指定的调用类必须具备访问私有方 法的权限,否则会出现无法访问的错误。 除了类中本来就存在的方法之外,对域的处理也是通过相应的获取和设置域的值的方法句柄来完成的。代码清单2-46说明了如何查找到类 中的静态域和一般域所对应的获取和设置的方法句柄。在查找的时候只需要提供域所在的类的Class对象、域的名称和类型即可。 代码清单2-46 查找类中的静态域和一般域对应的获取和设置的方法句柄的示例 public void lookupFieldAccessor()throws NoSuchFieldException, Illegal-AccessException{ MethodHandles.Lookup lookup=MethodHandles.lookup(); lookup.findGetter(Sample.class,"name",String.class); lookup.findSetter(Sample.class,"name",String.class); lookup.findStaticGetter(Sample.class,"value",int.class); lookup.findStaticSetter(Sample.class,"value",int.class); } 对于静态域来说,调用其对应的获取和设置值的方法句柄时,并不需要提供调用的接收者对象作为参数。而对于一般域来说,该对象在调 用时是必需的。 除了直接在某个类中进行查找之外,还可以从通过反射API得到的Constructor、Field和Method等对象中获得方法句柄。如代码清单2-47所 示,首先通过反射API得到表示构造方法的Constructor对象,再通过unreflectConstructor方法就可以得到其对应的一个方法句柄;而通过 unreflect方法可以将Method类对象转换成方法句柄。对于私有方法,则需要使用unreflectSpecial来进行转换,同样也需要提供一个作用与 findSpecial中参数相同的额外参数;对于Field类的对象来说,通过unreflectGetter和unreflectSetter就可以得到获取和设置其值的方法句 柄。 代码清单2-47 通过反射API获取方法句柄的示例 public void unreflect()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); Constructor constructor=String.class.getConstructor(byte[].class); lookup.unreflectConstructor(constructor); Method method=String.class.getMethod("substring",int.class, int.class); lookup.unreflect(method); Method privateMethod=ReflectMethodHandle.class.getDeclaredMethod("privateMe thod"); lookup.unreflectSpecial(privateMethod, ReflectMethodHandle.class); Field field=ReflectMethodHandle.class.getField("name"); lookup.unreflectGetter(field); lookup.unreflectSetter(field); } 除了通过在Java类中进行查找来获取方法句柄外,还可以通过java.lang.invoke.MethodHandles中提供的一些静态工厂方法来创建一些通 用的方法句柄。 第一个方法是用来对数组进行操作的,即得到可以用来获取和设置数组中元素的值的方法句柄。这些工厂方法的作用等价于2.2.4节介绍的 反射API中的java.lang.reflect.Array类中的静态方法。如代码清单2-48所示,MethodHandles的arrayElementGetter和arrayElementSetter方 法分别用来得到获取和设置数组元素的值的方法句柄。调用这些方法句柄就可以对数组进行操作。 代码清单2-48 获取和设置数组中元素的值的方法句柄的使用示例 public void arrayHandles()throws Throwable{ int[]array=new int[]{1,2,3,4,5}; MethodHandle setter=MethodHandles.arrayElementSetter(int[].class); setter.invoke(array,3,6); MethodHandle getter=MethodHandles.arrayElementGetter(int[].class); int value=(int)getter.invoke(array,3);//值为6 } MethodHandles中的静态方法identity的作用是通过它所生成的方法句柄,在每次调用的时候,总是返回其输入参数的值。如代码清单2-49 所示,在使用identity方法的时候只需要传入方法句柄的唯一参数的类型即可,该方法句柄的返回值类型和参数类型是相同的。 代码清单2-49 MethodHandles类的identity方法的使用示例 public void identity()throws Throwable{ MethodHandle mh=MethodHandles.identity(String.class); String value=(String)mh.invoke("Hello");//值为"Hello" } 而方法constant的作用则更加简单,在生成的时候指定一个常量值,以后这个方法句柄被调用的时候,总是返回这个常量值,在调用时也 不需要提供任何参数。这个方法提供了一种把一个常量值转换成方法句柄的方式,如下面的代码所示。在调用constant方法的时候,只需要提 供常量的类型和值即可。 代码清单2-50 MethodHandles类的constant方法的使用示例 public void constant()throws Throwable{ MethodHandle mh=MethodHandles.constant(String.class,"Hello"); String value=(String)mh.invoke();//值为"Hello" } MethodHandles类中的identity方法和constant方法的作用类似于在开发中用到的“空对象(Null object)”模式的应用。在使用方法句 柄的某些场合中,如果没有合适的方法句柄对象,可能不允许直接用null来替换,这个时候可以通过这两个方法来生成简单无害的方法句柄对 象作为替代。 6.方法句柄变换 方法句柄的强大之处在于可以对它进行各种不同的变换操作。这些变换操作包括对方法句柄的返回值和参数的处理等,同时这些单个的变 换操作可以组合起来,形成复杂的变换过程。所有的这些变换方法都是MethodHandles类中的静态方法。这些方法一般接受一个已有的方法句柄 对象作为变换的来源,而方法的返回值则是变换操作之后得到的新的方法句柄。下面的内容中经常出现的“原始方法句柄”表示的是变换之前 的方法句柄,而“新方法句柄”则表示变换之后的方法句柄。 首先介绍对参数进行处理的变换方法。在调用变换之后的新方法句柄时,调用时的参数值会经过一定的变换操作之后,再传递给原始的方 法句柄来完成具体的执行。 第一个方法dropArguments可以在一个方法句柄的参数中添加一些无用的参数。这些参数虽然在实际调用时不会被使用,但是它们可以使变 换之后的方法句柄的参数类型格式符合某些所需的特定模式。这也是这种变换方式的主要应用场景。 如代码清单2-51所示,原始的方法句柄mhOld引用的是String类中的substring方法,其类型是String类的返回值加上两个int类型的参数。 在调用dropArguments方法的时候,第一个参数表示待变换的方法句柄,第二个参数指定的是要添加的新参数类型在原始参数列表中的起始位 置,其后的多个参数类型将被添加到参数列表中。新的方法句柄mhNew的参数类型变为float、String、String、int和int,而在实际调用时, 前面两个参数的值会被忽略掉。可以把这些多余的参数理解成特殊调用模式所需要的占位符。 代码清单2-51 dropArguments方法的使用示例 public void dropArguments()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodType type=MethodType.methodType(String.class, int.class, int.class); MethodHandle mhOld=lookup.findVirtual(String.class,"substring",type); String value=(String)mhOld.invoke("Hello",2,3); MethodHandle mhNew=MethodHandles.dropArguments(mhOld,0,float.class, String.class); value=(String)mhNew.invoke(0.5f,"Ignore","Hello",2,3); } 第二个方法insertArguments的作用与本小节前面提到的MethodHandle的bindTo方法类似,但是此方法的功能更加强大。这个方法可以同时 为方法句柄中的多个参数预先绑定具体的值。在得到的新方法句柄中,已经绑定了具体值的参数不再需要提供,也不会出现在参数列表中。 在代码清单2-52中,方法句柄mhOld所表示的底层方法是String类中的concat方法。在调用insertArguments方法的时候,与上面的 dropArguments方法类似,从第二个参数所指定的参数列表中的位置开始,用其后的可变长度的参数的值作为预设值,分别绑定到对应的参数 上。在这里把mhOld的第二个参数的值预设成了固定值“--”,其作用是在调用新方法句柄时,只需要传入一个参数即可,相当于总是与“-- ”进行字符串连接操作,即使用“--”作为后缀。由于有一个参数被预先设置了值,因此mhNew在调用时只需要一个参数即可。如果预先绑定的 是方法句柄mhOld的第一个参数,那就相当于用字符串“--”来连接各种不同的字符串,即为字符串添加“--”作为前缀。如果 insertArguments方法调用时指定了多个绑定值,会按照第二个参数指定的起始位置,依次进行绑定。 代码清单2-52 insertArguments方法的使用示例 public void insertArguments()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodType type=MethodType.methodType(String.class, String.class); MethodHandle mhOld=lookup.findVirtual(String.class,"concat",type); String value=(String)mhOld.invoke("Hello","World"); MethodHandle mhNew=MethodHandles.insertArguments(mhOld,1,"--"); value=(String)mhNew.invoke("Hello");//值为“Hello--” } 第三个方法filterArguments的作用是可以对方法句柄调用时的参数进行预处理,再把预处理的结果作为实际调用时的参数。预处理的过程 是通过其他的方法句柄来完成的。可以对一个或多个参数指定用来进行处理的方法句柄。代码清单2-53给出了filterArguments方法的使用示 例。要执行的原始方法句柄所引用的是Math类中的max方法,而在实际调用时传入的却是两个字符串类型的参数。中间的参数预处理是通过方法 句柄mhGetLength来完成的,该方法句柄的作用是获得字符串的长度。这样就可以把字符串类型的参数转换成原始方法句柄所需要的整数类型。 完成预处理之后,将处理的结果交给原始方法句柄来完成调用。 代码清单2-53 filterArguments方法的使用示例 public void filterArguments()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodType type=MethodType.methodType(int.class, int.class, int.class); MethodHandle mhGetLength=lookup.findVirtual(String.class,"length",MethodType.methodType(int.class)); MethodHandle mhTarget=lookup.findStatic(Math.class,"max",type); MethodHandle mhNew=MethodHandles.filterArguments(mhTarget,0,mhGetLength, mhGetLength); int value=(int)mhNew.invoke("Hello","New World");//值为9 } 在使用filterArguments的时候,第二个参数和后面的可变长度的方法句柄参数是配合起来使用的。第二个参数指定的是进行预处理的方法 句柄需要处理的参数在参数列表中的起始位置。紧跟在后面的是一系列对应的完成参数预处理的方法句柄。方法句柄与它要处理的参数是一一 对应的。如果希望跳过某些参数不进行处理,可以使用null作为方法句柄的值。在进行预处理的时候,要注意预处理方法句柄和原始方法句柄 之间的类型匹配。如果预处理方法句柄用于对某个参数进行处理,那么该方法句柄只能有一个参数,而且参数的类型必须匹配所要处理的参数 的类型;其返回值类型需要匹配原始方法句柄中对应的参数类型。只有类型匹配,才能用方法句柄对实际传入的参数进行预处理,再把预处理 的结果作为原始方法句柄调用时的参数来使用。 第四个方法foldArguments的作用与filterArguments很类似,都是用来对参数进行预处理的。不同之处在于,foldArguments对参数进行预 处理之后的结果,不是替换掉原始的参数值,而是添加到原始参数列表的前面,作为一个新的参数。当然,如果参数预处理的返回值是void, 则不会添加新的参数。另外,参数预处理是由一个方法句柄完成的,而不是像filterArguments那样可以由多个方法句柄来完成。这个方法句柄 会负责处理根据它的类型确定的所有可用参数。下面先看一下具体的使用示例。代码清单2-54中原始的方法句柄引用的是静态方法 targetMethod,而用来对参数进行预处理的方法句柄mhCombiner引用的是Math类中的max方法。变换之后的新方法句柄mhResult在被调用时,两 个参数3和4首先被传递给句柄mhCombiner所引用的Math.max方法,返回值是4。这个返回值被添加到原始调用参数列表的前面,即得到新的参数 列表4、3、4。这个新的参数列表会在调用时被传递给原始方法句柄mhTarget所引用的targetMethod方法。 代码清单2-54 foldArguments方法的使用示例 public static int targetMethod(int arg1,int arg2,int arg3){ return arg1; } public void foldArguments()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodType typeCombiner=MethodType.methodType(int.class, int.class, int.class); MethodHandle mhCombiner=lookup.findStatic(Math.class,"max",typeCombiner); MethodType typeTarget=MethodType.methodType(int.class, int.class, int.class, int.class); MethodHandle mhTarget=lookup.findStatic(Transform.class,"targetMethod",typeTarget); MethodHandle mhResult=MethodHandles.foldArguments(mhTarget, mhCombiner); int value=(int)mhResult.invoke(3,4);//输出为4 } 进行参数预处理的方法句柄会根据其类型中参数的个数N,从实际调用的参数列表中获取前面N个参数作为它需要处理的参数。如果预处理 的方法句柄有返回值,返回值的类型需要与原始方法句柄的第一个参数的类型匹配。这是因为返回值会被作为调用原始方法句柄时的第一个参 数来使用。 第五个方法permuteArguments的作用是对调用时的参数顺序进行重新排列,再传递给原始的方法句柄来完成调用。这种排列既可以是真正 意义上的全排列,即所有的参数都在重新排列之后的顺序中出现;也可以是仅出现部分参数,没有出现的参数将被忽略;还可以重复某些参 数,让这些参数在实际调用中出现多次。代码清单2-55给出了一个对参数进行完全排列的示例。代码中的原始方法句柄mhCompare所引用的是 Integer类中的compare方法。当使用参数3和4进行调用的时候,返回值是-1。通过permuteArguments方法把参数的排列顺序进行颠倒,得到了 新的方法句柄mhNew。再用同样的参数调用方法句柄mhNew时,返回结果就变成了1,因为传递给底层compare方法的实际调用参数变成了4和3。 新方法句柄mhDuplicateArgs在通过permuteArguments方法进行变换的时候,重复了第二个参数,因此传递给底层compare方法的实际调用参数 是4和4,返回的结果是0。 代码清单2-55 permuteArguments方法的使用示例 public void permuteArguments()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodType type=MethodType.methodType(int.class, int.class, int.class); MethodHandle mhCompare=lookup.findStatic(Integer.class,"compare",type); int value=(int)mhCompare.invoke(3,4);//值为-1 MethodHandle mhNew=MethodHandles.permuteArguments(mhCompare, type,1,0); value=(int)mhNew.invoke(3,4);//值为1 MethodHandle mhDuplicateArgs=MethodHandles.permuteArguments(mhCompare, type,1,1); value=(int)mhDuplicateArgs.invoke(3,4);//值为0 } 在这里还要着重介绍一下permuteArguments方法的参数。第二个参数表示的是重新排列完成之后的新方法句柄的类型。紧接着的是多个用 来表示新的排列顺序的整数。这些整数的个数必须与原始句柄的参数个数相同。整数出现的位置及其值就表示了在排列顺序上的对应关系。比 如在上面的代码中,创建方法句柄mhNew的第一个整数参数是1,这就表示调用原始方法句柄时的第一个参数的值实际上是调用新方法句柄时的 第二个参数(编号从0开始,1表示第二个)。 第六个方法catchException与原始方法句柄调用时的异常处理有关。可以通过该方法为原始方法句柄指定处理特定异常的方法句柄。如果 原始方法句柄的调用正常完成,则返回其结果;如果出现了特定的异常,则处理异常的方法句柄会被调用。通过该方法可以实现通用的异常处 理逻辑。可以对程序中可能出现的异常都提供一个进行处理的方法句柄,再通过catchException方法来封装原始的方法句柄。 如代码清单2-56所示,原始的方法句柄mhParseInt所引用的是Integer类中的parseInt方法,这个方法在字符串无法被解析成数字时会抛出 java.lang.Number-FormatException。用来进行异常处理的方法句柄是mhHandler,它引用了当前类中的handleException方法。通过 catchException得到的新方法句柄mh在被调用时,如果抛出了NumberFormatException,则会调用handleException方法。 代码清单2-56 catchException方法的使用示例 public int handleException(Exception e, String str){ System.out.println(e.getMessage()); return 0; } public void catchExceptions()throws Throwable{ MethodHandles.Lookup lookup=Method Handles.lookup(); MethodType typeTarget=MethodType.methodType(int.class, String.class); MethodHandle mhParseInt=lookup.find Static(Integer.class,"parseInt",typeTarget); MethodType typeHandler=Method Type.method Type(int.class, Exception.class, String.class); Method Handlemh Handler=lookup.find Virtual(Transform.class,"handle Exception",type Handler).bindTo(this); Method Handlemh=Method Handles.catch Exception(mhParseInt, Number Format Exception.class, mhHandler); mh.invoke("Hello"); } 在这里需要注意几个细节:原始方法句柄和异常处理方法句柄的返回值类型必须是相同的,这是因为当产生异常的时候,异常处理方法句 柄的返回值会作为调用的结果;而在两个方法句柄的参数方面,异常处理方法句柄的第一个参数是它所处理的异常类型,其他参数与原始方法 句柄的参数相同。在异常处理方法句柄被调用的时候,其对应的底层方法可以得到原始方法句柄调用时的实际参数值。在上面的例子中,当 handleException方法被调用的时候,参数e的值是NumberFormatException类的对象,参数str的值是原始的调用值“Hello”;在获得异常处理 方法句柄的时候,使用了bindTo方法。这是因为通过findVirtual找到的方法句柄的第一个参数类型表示的是方法调用的接收者,这与 catchException要求的第一个参数必须是异常类型的约束不相符,因此通过bindTo方法来为第一个参数预先绑定值。这样就可以得到所需的正 确的方法句柄。当然,如果异常处理方法句柄所引用的是静态方法,就不存在这个问题。 最后一个在对方法句柄进行变换时与参数相关的方法是guardWithTest。这个方法可以实现在方法句柄这个层次上的条件判断的语义,相当 于if-else语句。使用guardWithTest时需要提供3个不同的方法句柄:第一个方法句柄用来进行条件判断,而剩下的两个方法句柄则分别在条件 成立和不成立的时候被调用。用来进行条件判断的方法句柄的返回值类型必须是布尔型,而另外两个方法句柄的类型则必须一致,同时也是生 成的新方法句柄的类型。 如代码清单2-57所示,进行条件判断的方法句柄mhTest引用的是静态guardTest方法,在条件成立和不成立的时候调用的方法句柄则分别引 用了Math类中的max方法和min方法。由于guardTest方法的返回值是随机为true或false的,所以两个方法句柄的调用也是随机选择的。 代码清单2-57 guardWithTest方法的使用示例 public static boolean guardTest(){ return Math.random()>0.5; } public void guardWithTest()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodHandle mhTest=lookup.findStatic(Transform.class,"guardTest",MethodType.methodType(boolean.class)); MethodType type=MethodType.methodType(int.class, int.class, int.class); MethodHandle mhTarget=lookup.findStatic(Math.class,"max",type); MethodHandle mhFallback=lookup.findStatic(Math.class,"min",type); MethodHandle mh=MethodHandles.guardWithTest(mhTest, mhTarget, mhFallback); int value=(int)mh.invoke(3,5);//值随机为3或5 } 除了可以在变换的时候对方法句柄的参数进行处理之外,还可以对方法句柄被调用后的返回值进行修改。对返回值进行处理是通过 filterReturnValue方法来实现的。原始的方法句柄被调用之后的结果会被传递给另外一个方法句柄进行再次处理,处理之后的结果被返回给调 用者。代码清单2-58展示了filterReturnValue的用法。原始的方法句柄mhSubstring所引用的是String类的substring方法,对返回值进行处理 的方法句柄mhUpperCase所引用的是String类的toUpperCase方法。通过filterReturnValue方法得到的新方法句柄的运行效果是将调用 substring得到的子字符串转换成大写的形式。 代码清单2-58 filterReturnValue方法的使用示例 public void filterReturnValue()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodHandle mhSubstring=lookup.findVirtual(String.class,"substring",MethodType.methodType(String.class, int.class)); MethodHandle mhUpperCase=lookup.findVirtual(String.class,"toUpperCase",MethodType.methodType(String.class)); MethodHandle mh=MethodHandles.filterReturnValue(mhSubstring, mhUpperCase); String str=(String)mh.invoke("Hello World",5);//输出WORLD } 7.特殊方法句柄 在有些情况下,可能会需要对一组类型相同的方法句柄进行同样的变换操作。这个时候与其对所有的方法句柄都进行重复变换,不如创建 出一个可以用来调用其他方法句柄的方法句柄。这种特殊的方法句柄的invoke方法或invokeExact方法被调用的时候,可以指定另外一个类型匹 配的方法句柄作为实际调用的方法句柄。因为调用方法句柄时可以使用invoke和invokeExact两种方法,对应有两种创建这种特殊的方法句柄的 方式,分别通过MethodHandles类的invoker和exactInvoker实现。两个方法都接受一个MethodType对象作为被调用的方法句柄的类型参数,两 者的区别只在于调用时候的行为是类似于invoke还是invokeExact。 代码清单2-59给出了invoker方法的使用示例。首先invoker方法句柄可以调用的方法句柄类型的返回值类型为String,加上3个类型分别为 Object、int和int的参数。两个被调用的方法句柄,其中一个引用的是String类中的substring方法,另外一个引用的是当前类中的testMethod 方法。这两个方法都可以通过invoke方法来正确调用。 代码清单2-59 invoker方法的使用示例 public void invoker()throws Throwable{ MethodType typeInvoker=MethodType.methodType(String.class, Object.class, int.class, int.class); MethodHandle invoker=MethodHandles.invoker(typeInvoker); MethodType typeFind=MethodType.methodType(String.class, int.class, int.class); MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodHandle mh1=lookup.findVirtual(String.class,"substring",typeFind); MethodHandle mh2=lookup.findVirtual(InvokerUsage.class,"testMethod",typeFind); String result=(String)invoker.invoke(mh1,"Hello",2,3); result=(String)invoker.invoke(mh2,this,2,3); } 而exactInvoker的使用与invoker非常类似,这里就不举例说明了。 上面提到了使用invoker和exactInvoker的一个重要好处就是在对这个方法句柄进行变换之后,所得到的新方法句柄在调用其他方法句柄的 时候,这些变换操作都会被自动地引用,而不需要对每个所调用的方法句柄再单独应用。如代码清单2-60所示,通过filterReturnValue为通过 exactInvoker得到的方法句柄添加变换操作,当调用方法句柄mh1的时候,这个变换会被自动应用,使作为调用结果的字符串自动变成大写形 式。 代码清单2-60 invoker和exactInvoker对方法句柄变换的影响 public void invokerTransform()throws Throwable{ MethodType typeInvoker=MethodType.methodType(String.class, String.class, int.class, int.class); MethodHandle invoker=MethodHandles.exactInvoker(typeInvoker); MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodHandle mhUpperCase=lookup.findVirtual(String.class,"toUpperCase",MethodType.methodType(String.class)); invoker=MethodHandles.filterReturnValue(invoker, mhUpperCase); MethodType typeFind=MethodType.methodType(String.class, int.class, int.class); MethodHandle mh1=lookup.findVirtual(String.class,"substring",typeFind); String result=(String)invoker.invoke(mh1,"Hello",1,4);//值为“ELL” } 通过invoker方法和exactInvoker方法得到的方法句柄被称为“元方法句柄”,具有调用其他方法句柄的能力。 8.使用方法句柄实现接口 2.3节介绍的动态代理机制可以在运行时为多个接口动态创建实现类,并拦截通过接口进行的方法调用。方法句柄也具备动态实现一个接口 的能力。这是通过java.lang.invoke.MethodHandleProxies类中的静态方法asInterfaceInstance来实现的。不过通过方法句柄来实现接口所受 的限制比较多。首先该接口必须是公开的,其次该接口只能包含一个名称唯一的方法。这样限制是因为只有一个方法句柄用来处理方法调用。 调用asInterfaceInstance方法时需要两个参数,第一个参数是要实现的接口类,第二个参数是处理方法调用逻辑的方法句柄对象。方法的返回 值是一个实现了该接口的对象。当调用接口的方法时,这个调用会被代理给方法句柄来完成。方法句柄的返回值作为接口调用的返回值。接口 的方法类型与方法句柄的类型必须是兼容的,否则会出现异常。 代码清单2-61是使用方法句柄实现接口的示例。被代理的接口是java.lang.Runnable,其中仅包含一个run方法。实现接口的方法句柄引用 的是当前类中的doSomething方法。在调用asInterfaceInstance之后得到的Runnable接口的实现对象被用来创建一个新的线程。该线程运行之 后发现doSomething方法会被调用。这是由于当Runnable接口的run方法被调用的时候,方法句柄mh也会被调用。 代码清单2-61 使用方法句柄实现接口的示例 public void doSomething(){ System.out.println("WORK"); } public void useMethodHandleProxy()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodHandle mh=lookup.findVirtual(UseMethodHandleProxies.class,"doSomething",MethodType.methodType(void.class)); mh=mh.bindTo(this); Runnable runnable=MethodHandleProxies.asInterfaceInstance(Runnable.class, mh); new Thread(runnable).start(); } 通过方法句柄来实现接口的优势在于不需要新建额外的Java类,只需要复用已有的方法即可。在上面的示例中,任何已有的不带参数和返 回值的方法都可以用来实现Runnable接口。需要注意的是,要求接口所包含的方法的名称唯一,不考虑Object类中的方法。实际的方法个数可 能不止一个,可能包含同一方法的不同重载形式。 9.访问控制权限 在通过查找已有类中的方法得到方法句柄时,要受限于Java语言中已有的访问控制权限。方法句柄与反射API在访问控制权限上的一个重要 区别在于,在每次调用反射API的Method类的invoke方法的时候都需要检查访问控制权限,而方法句柄只在查找的时候需要进行检查。只要在查 找过程中不出现问题,方法句柄在使用中就不会出现与访问控制权限相关的问题。这种实现方式也使方法句柄在调用时的性能要优于Method 类。 之前介绍过,通过MethodHandles.Lookup类的方法可以查找类中已有的方法以得到MethodHandle对象。而MethodHandles.Lookup类的对象 本身则是通过MethodHandles类的静态方法lookup得到的。在Lookup对象被创建的时候,会记录下当前所在的类(称为查找类)。只要查找类能 够访问某个方法或域,就可以通过Lookup的方法来查找到对应的方法句柄。代码清单2-62给出了一个访问控制权限相关的示例。AccessControl 类中的accessControl方法返回了引用其中私有方法privateMethod的方法句柄。由于当前查找类可以访问该私有方法,因此查找过程是成功 的。其他类通过调用accessControl得到的方法句柄就可以调用这个私有方法。虽然其他类不能直接访问AccessControl类中的私有方法,但是 在调用方法句柄的时候不会进行访问控制权限检查,因此对方法句柄的调用可以成功进行。 代码清单2-62 方法句柄查找时的访问控制权限 public class AccessControl{ private void privateMethod(){ System.out.println("PRIVATE"); } public MethodHandle accessControl()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodHandle mh=lookup.findSpecial(AccessControl.class,"privateMethod",MethodType.methodType(void.class),AccessControl.class); mh=mh.bindTo(this); return mh; } } 10.交换点 交换点是在多线程环境下控制方法句柄的一个开关。这个开关只有两个状态:有效和无效。交换点初始时处于有效状态,一旦从有效状态 变到无效状态,就无法再继续改变状态。也就是说,只允许发生一次状态改变。这种状态变化是全局和即时生效的。使用同一个交换点的多个 线程会即时观察到状态变化。交换点用java.lang.invoke.SwitchPoint类来表示。通过SwitchPoint对象的guardWithTest方法可以设置在交换 点的不同状态下调用不同的方法句柄。这个方法的作用类似于MethodHandles类中的guardWithTest方法,只不过少了用来进行条件判断的方法 句柄,只有条件成立和不成立时分别调用的方法句柄。这是因为选择哪个方法句柄来执行是由交换点的有效状态来决定的,不需要额外的条件 判断。 在代码清单2-63中,在调用guardWithTest方法的时候指定在交换点有效的时候调用方法句柄mhMin,而在无效的时候则调用mhMax。 guardWithTest方法的返回值是一个新的方法句柄mhNew。交换点在初始时处于有效状态,因此mhNew在第一次调用时使用的是mhMin,结果为3。 在通过invalidateAll方法把交换点设成无效状态之后,再次调用mhNew时实际调用的方法句柄就变成了mhMax,结果为4。 代码清单2-63 交换点的使用示例 public void useSwitchPoint()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodType type=MethodType.methodType(int.class, int.class, int.class); MethodHandle mhMax=lookup.findStatic(Math.class,"max",type); MethodHandle mhMin=lookup.findStatic(Math.class,"min",type); SwitchPoint sp=new SwitchPoint(); MethodHandle mhNew=sp.guardWithTest(mhMin, mhMax); mhNew.invoke(3,4);//值为3 SwitchPoint.invalidateAll(new SwitchPoint[]{sp}); mhNew.invoke(3,4);//值为4 } 交换点的一个重要作用是在多线程环境下使用,可以在多个线程中共享同一个交换点对象。当某个线程的交换点状态改变之后,其他线程 所使用的guardWithTest方法返回的方法句柄的调用行为就会发生变化。 11.使用方法句柄进行函数式编程 通过上面章节对方法句柄的详细介绍可以看出,方法句柄是一个非常灵活的对方法进行操作的轻量级结构。方法句柄的作用类似于在某些 语言中出现的函数指针(function pointer)。在程序中,方法句柄可以在对象之间自由传递,不受访问控制权限的限制。方法句柄的这种特 性,使得在Java语言中也可以进行函数式编程。下面通过几个具体的示例来进行说明。 第一个示例是对数组进行操作。数组作为一个常见的数据结构,有的编程语言提供了对它进行复杂操作的功能。这些功能中比较常见的是 forEach、map和reduce操作等。这些操作的语义并不复杂,forEach是对数组中的每个元素都依次执行某个操作,而map则是把原始数组按照一 定的转换过程变成一个新的数组,reduce是把一个数组按照某种规则变成单个元素。这些操作在其他语言中可能比较好实现,而在Java语言 中,则需要引入一些接口,由此带来的是繁琐的实现和冗余的代码。有了方法句柄之后,这个实现就变得简单多了。代码清单2-64给出了使用 方法句柄的forEach、map和reduce方法的实现。对数组中元素的处理是由一个方法句柄来完成的。对这个方法句柄只有类型的要求,并不限制 它所引用的底层方法所在的类或名称。 代码清单2-64 使用方法句柄实现数组操作的示例 private static final MethodType typeCallback=MethodType.methodType(Object.class, Object.class, int.class); public static void forEach(Object[]array, MethodHandle handle)throws Throwable{ for(int i=0,len=array.length;i<len;i++){ handle.invoke(array[i],i); } } public static Object[]map(Object[]array, MethodHandle handle)throws Throwable{ Object[]result=new Object[array.length]; for(int i=0,len=array.length;i<len;i++){ result[i]=handle.invoke(array[i],i); } return result; } public static Object reduce(Object[]array, Object initalValue, MethodHandle handle)throws Throwable{ Object result=initalValue; for(int i=0,len=array.length;i<len;i++){ result=handle.invoke(result, array[i]); } return result; } 第二个例子是方法的柯里化(currying)。柯里化的含义是对一个方法的参数值进行预先设置之后,得到一个新的方法。比如一个做加法 运算的方法,本来有两个参数,通过柯里化把其中一个参数的值设为5之后,得到的新方法就只有一个参数。新方法的运行结果是用5加上这个 唯一的参数的值。通过MethodHandles类中的insertArguments方法可以很容易地实现方法句柄的柯里化。代码清单2-65给出了相关的实现。方 法curry负责把一个方法句柄的第一个参数的值设为指定值;add方法就是一般的加法操作;add5方法对引用add的方法句柄进行柯里化,得到新 的方法句柄,再调用此方法句柄。 代码清单2-65 使用方法句柄实现的柯里化 public static MethodHandle curry(MethodHandle handle, int value){ return MethodHandles.insertArguments(handle,0,value); } public static int add(int a, int b){ return a+b; } public static int add5(int a)throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodType type=MethodType.methodType(int.class, int.class, int.class); MethodHandle mhAdd=lookup.findStatic(Curry.class,"add",type); MethodHandle mh=curry(mhAdd,5); return(int)mh.invoke(a); } 上面给出的这两个示例所实现的功能虽然比较简单,但是反映出了方法句柄在使用时的极大灵活性。配合方法句柄支持的变换操作,可以 实现很多有趣和实用的功能。 2.4.3 invokedynamic指令 在详细介绍了java.lang.invoke包中与方法句柄相关的API之后,现在要深入到Java虚拟机的指令层次。本节将要介绍的是JSR 292中引入 的新的方法调用指令invokedynamic。这个新指令的引入,为Java虚拟机平台上动态语言的开发带来了福音。动态语言的实现者终于可以在灵活 性、复杂性和性能这几个要素之间找到一个很好的平衡。利用invokedynamic指令,可以通过简单高效的方式实现灵活性很强的方法调用。 方法调用是Java虚拟机执行过程中最常见也是最重要的指令,控制着程序的具体执行流程。在Java 7之前,Java虚拟机中包含了4种方法调 用指令,分别是invokestatic、invokespecial、invokevirtual和invokeinterface。这4种指令分别适用于不同的方法调用场景,都可以在 Java语言的源代码中找到相应的产生模式。下面先介绍这4种方法调用指令。 1.普通方法调用指令 在本章的开始部分中提到了一个现象,那就是Java字节代码规范受Java语言的影响很深。因此虽然本节介绍的主体是Java字节代码规范中 的方法调用指令,但是仍然免不了用Java语言的语法来做类比。代码清单2-66给出了Java程序中可能会出现的几种方法调用形式。 代码清单2-66 Java程序中的方法调用形式 public interface SampleInterface{ void sampleMethodInInterface(); } public class Sample implements SampleInterface{ public void sampleMethodInInterface(){} public void normalMethod(){} public static void staticSampleMethod(){} } public class MethodInvokeTypes{ public void invoke(){ SampleInterface sample=new Sample(); sample.sampleMethodInInterface(); Sample newSample=new Sample(); newSample.normalMethod(); Sample.staticSampleMethod(); } } 上面代码中包含一个接口SampleInterface,以及这个接口的实现类Sample。类Sample中除了实现SampleInterface接口所需要的 sampleMethodInInterface方法之外,还包含一个一般的公开方法normalMethod和一个静态方法staticSampleMethod。类MethodInvokeTypes的 invoke方法中既有通过构造方法创建新的对象,又有对接口中方法和类中的一般方法和静态方法的调用。这实际上就代表了Java语言中所提供 的4种方法调用形式。而在Java字节代码规范中,同样有4种方法调用指令与这4种调用方式相对应。 为了探究这4种方法调用指令,需要查看包含Java字节代码的class文件的内容。在这里需要使用JDK中自带的javap工具来完成。比如代码 清单2-66中的MethodInvokeTypes类编译出来的class文件,可以通过在类文件所在目录下使用javap-verbose MethodInvokeTypes命令来显示 class文件的内容。一个class文件中包含的内容很多,javap工具也会输出很多内容。本书第8章会详细介绍Java字节代码的格式,这里只介绍 与方法调用相关的指令。图2-1给出了在javap输出中与方法调用相关的部分。 图 2-1 通过javap工具查看方法调用相关的字节代码内容 按照Java源代码中的顺序来看,第一个方法调用指令是invokespecial,这个指令是用来调用类的构造方法、父类的方法(通过super)和 私有方法的。这里的invokespecial指令调用的是Sample类的构造方法,对应源代码中的“new Sample()”。第二个方法调用指令是 invokeinterface,这个指令是通过接口来调用方法的。这里的invokeinterface指令调用的是SampleInterface接口中的 sampleMethodInInterface方法。第三个方法调用指令是invokevirtual,这个指令是用来调用类中的一般方法的。所调用的实际方法取决于调 用接收者对象的运行时类型。这里的invokevirtual指令调用的是Sample类中的normalMethod方法。最后一个方法调用指令是invokestatic,这 个指令用来调用类中的静态方法。这里调用的是Sample类的staticSampleMethod方法。 在这4个方法调用指令中,除了invokestatic之外,都需要一个调用的接收者。因为静态方法是在类中定义的,并不需要一个具体的对象实 例作为接收者。其他3个调用指令的接收者就是方法调用表达式的“.”之前的对象。这些接收者有一个静态的类型,也就是在编译时刻确定的 调用对象的类型。对于invokespecial和invokevirtual来说,这个静态类型就是接收者对象的类型;而对于invokeinterface来说,这个静态类 型就是接口的类型。而在运行时刻,接收者会有一个动态类型。这个动态类型可能和编译时刻确定的静态类型一样,也可能不一样。这其中的 原因是存在运行时的方法派发。如果这个动态类型和静态类型不一样,那么该动态类型肯定是静态类型的子类型,可能是静态类型所表示的 Java类的子类或所表示的接口的实现类。在代码清单2-67中,hashCode方法的接收者的静态类型是Object类,但是其在运行时刻的动态类型是 String类,所调用的是String类中定义的hashCode方法。 代码清单2-67 静态类型与动态类型的区别 String str="Hello World"; Object obj=str; System.out.println(obj.hashCode()); 在这里需要说明的是,这4种普通的方法调用指令只支持方法调用时的单派发(single dispatch),也就是说实际调用时对方法的选择只 会根据调用的接收者不同而有所不同,不受其他因素的影响。这种单派发只对invokevirtual和invokeinterface两种指令有效。由于类继承和 接口实现机制的存在,实际的方法调用接收者可能是声明时类型的子类是接口的实现类,取决于运行时刻的动态类型。而另外的invokestatic 和invokespecial指令的调用接收者都是固定的,是无法在运行时改变的。如果按照方法句柄的方式,把方法的调用者也抽象成方法调用时的一 个参数,就可以知道单派发方式实际上只根据方法调用时的第一个参数来进行方法的分配。有些编程语言需要支持多派发,也就是说在方法调 用时会根据多个参数的值来选择要具体执行的方法。多派发在某些情况下会使代码编写起来更加简单。 2.invokedynamic指令简介 上一节说明了Java字节代码规范中的4种普通的方法调用指令。这4种方法调用指令的特点是在Java语言中有相应的语法形式与其对应,同 时灵活性比较低。下面从典型的方法调用流程来说明灵活性不足体现在什么地方。 当Java虚拟机执行方法调用的时候,需要确定下面4个要素。 1)名称:要调用的方法的名称一般是由开发人员在源代码中指定的符号名称。这个名称同样会出现在编译之后的字节代码中。 2)链接:链接包含了要调用方法的类。这一步有可能会涉及类的加载。 3)选择:选择要调用的方法。在类中根据方法名称和参数选择要调用的方法。 4)适配:调用者和接收者对调用的方式达成一致,即对方法的类型声明达成共识。 确定了上面4个要素之后,Java虚拟机会把控制权转移到被调用的方法中,并把调用时的实际参数传递过去。 再结合图2-1给出的4种调用指令来看这4个要素是如何体现的:所有这4种调用指令中的方法名称都是直接在字节代码中固定下来的,从 Java源代码中直接映射过来。而链接的过程也由Java虚拟机来统一处理。唯一可能变化的就是方法的选择和适配。对于invokestatic来说,方 法的选择是固定的,总是调用声明了此静态方法的类中的方法。另外方法的声明也必须是完全匹配的。对于invokespecial来说,由于它所调用 的方法只有当前类的对象才有权限调用,因此它的方法选择也是固定的。而对于invokevirtual和invokeinterface来说,由于类继承和接口实 现的存在,它们的方法选择是不固定的,但是仅限于根据调用的接收者类型来进行选择,即上面提到的单派发机制。 比如代码清单2-67中的hashCode方法的调用,实际方法的选择取决于调用的接收者。如果接收者是一个Object类型的对象,那么调用的是 Object类中的方法;如果接收者是一个String类型的对象,那么调用的是String类中的方法;如果另外一个类继承自Object类,但是没有覆写 hashCode方法,那么调用的还是Object类中的方法。 从方法适配的角度来说,只有接收者一方能进行适配,而且只能缩小类型的范围。比如在调用一个类中的方法的时候,接收者可以换成该 类的子类的对象,但是不能换成父类的对象。 Java 7中新引入invokedynamic指令的目的就是弥补已有的4个方法调用指令的不足,提供更加强大的灵活性。既可以方便Java虚拟机上动 态语言的编译器开发人员,也适应于对方法调用灵活性要求较高的一般应用。 新指令invokedynamic在多个方面解放了方法调用,还是通过上面给出的方法调用4个要素来说明: 1)在方法的名称方面,不一定是符合Java命名规范的字符串,可以任意指定。方法的调用者和提供者也不需要在方法名称上达成一致。实 际上,上一节介绍的方法句柄就已经把方法的名称剥离出去了。 2)提供了更加灵活的链接方式。一个方法调用所实际调用的方法可以在运行时再确定。这就相当于把链接操作推迟到了运行时,而不是必 须在编译时就确定下来。对于一个已经链接好的方法调用,也可以重新进行链接,让它指向另外的方法。 3)在方法选择方面,不再是只能在方法调用的接收者上进行派发,而是可以考虑所有调用时的参数,即支持方法的多派发。 4)在调用之前,可以对参数进行各种不同的处理,包括类型转换、添加和删除参数、收集和分发可变长度参数等。在2.4.2节中已经介绍 过这些变换操作了。 新的invokedynamic指令需要与2.4.2节中介绍的方法句柄结合起来使用。该指令的灵活性在很大程度上取决于方法句柄的灵活性。对于 invokedynamic指令来说,在Java源代码中是没有直接的对应产生方式的。这也是invokedynamic指令的新颖之处。它是一个完全的Java字节代 码规范中的指令。传统的Java编译器并不会帮开发人员生成invokedynamic指令。为了利用invokedynamic指令,需要开发人员自己来生成包含 这个指令的Java字节代码。因为这个指令本来就是设计给动态语言的编译器使用的,所以这种限制也是合理的。对于一般的程序来说,如果希 望使用这个指令,就需要使用操作Java字节代码的工具来完成。本书第8章会详细介绍如何对字节代码进行操作。这里不再详细介绍工具的用 法,读者只需要理解最终生成的字节代码中所包含的内容就可以了。 在字节代码中每个出现的invokedynamic指令都成为一个动态调用点(dynamic call site)。每个动态调用点在初始化的时候,都处于未 链接的状态。在这个时候,这个动态调用点并没有被指定要调用的实际方法。 当Java虚拟机要执行invokedynamic指令时,首先需要链接其对应的动态调用点。在链接的时候,Java虚拟机会先调用一个启动方法 (bootstrap method)。这个启动方法的返回值是java.lang.invoke.CallSite类的对象。在通过启动方法得到了CallSite之后,通过这个 CallSite对象的getTarget方法可以获取到实际要调用的目标方法句柄。有了方法句柄之后,对这个动态调用点的调用,实际上是代理给方法句 柄来完成的。也就是说,对invokedynamic指令的调用实际上就等价于对方法句柄的调用,具体来说是被转换成对方法句柄的invoke方法的调 用。 3.动态调用点 Java 7中提供了三种类型的动态调用点CallSite的实现,分别是java.lang.invoke.ConstantCallSite、 java.lang.invoke.MutableCallSite和java.lang.invoke.VolatileCallSite。这些CallSite实现的不同之处在于所对应的目标方法句柄的特性 不同。ConstantCallSite所表示的调用点绑定的是一个固定的方法句柄,一旦链接之后,就无法修改;MutableCallSite所表示的调用点则允许 在运行时动态修改其目标方法句柄,即可以重新链接到新的方法句柄上;而VolatileCallSite的作用与MutableCallSite类似,不同的是它适用 于多线程情况,用来保证对于目标方法句柄所做的修改能够被其他线程看到。这也是名称中volatile的含义所在,类似于Java中的volatile关 键词的作用。 虽然CallSite一般同invokedynamic指令结合起来使用,但是在Java代码中也可以通过调用CallSite的dynamicInvoker方法来获取一个方法 句柄。调用这个方法句柄就相当于执行invokedynamic指令。通过此方法可以预先对CallSite进行测试,以保证字节代码中的invokedynamic指 令的行为是正确的,毕竟在生成的字节代码中进行调试是一件很麻烦的事情。下面介绍CallSite时会先通过dynamicInvoker方法在Java程序中 直接试验CallSite的使用。 首先介绍ConstantCallSite的使用。ConstantCallSite要求在创建的时候就指定其链接到的目标方法句柄。每次该调用点被调用的时候, 总是会执行对应的目标方法句柄。在代码清单2-68中,创建了一个ConstantCallSite并指定目标方法句柄为引用String类中的substring方法。 代码清单2-68 ConstantCallSite的使用示例 public void useConstantCallSite()throws Throwable{ MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodType type=MethodType.methodType(String.class, int.class, int.class); MethodHandle mh=lookup.findVirtual(String.class,"substring",type); ConstantCallSite callSite=new ConstantCallSite(mh); MethodHandle invoker=callSite.dynamicInvoker(); String result=(String)invoker.invoke("Hello",2,3); } 接下来的MutableCallSite则允许对其所关联的目标方法句柄进行修改。修改操作是通过setTarget方法来完成的。在创建MutableCallSite 的时候,既可以指定一个方法类型MethodType,又可以指定一个初始的方法句柄。如果像下面代码中那样指定方法类型,则通过setTarget设置 的方法句柄都必须有同样的方法类型。如果创建时指定的是初始的方法句柄,则之后设置的其他方法句柄的类型也必须与初始的方法句柄相 同。MutableCallSite对象中的目标方法句柄的类型总是固定的。下面的代码通过setTarget方法把目标方法句柄分别设置为Math类中的max和 min方法,在调用MutableCallSite时可以得到不同的结果。 代码清单2-69 MutableCallSite的使用示例 public void useMutableCallSite()throws Throwable{ MethodType type=MethodType.methodType(int.class, int.class, int.class); MutableCallSite callSite=new MutableCallSite(type); MethodHandle invoker=callSite.dynamicInvoker(); MethodHandles.Lookup lookup=MethodHandles.lookup(); MethodHandle mhMax=lookup.findStatic(Math.class,"max",type); MethodHandle mhMin=lookup.findStatic(Math.class,"min",type); callSite.setTarget(mhMax); int result=(int)invoker.invoke(3,5);//值为5 callSite.setTarget(mhMin); result=(int)invoker.invoke(3,5);//值为3 } 需要考虑的是多线程情况下的可见性问题。有可能在一个线程中对MutableCallSite的目标方法句柄做了修改,而在另外一个线程中不能及 时看到这个变化。对于这种情况,MutableCallSite提供了一个静态方法syncAll来强制要求各个线程中MutableCallSite的使用者立即获取最新 的目标方法句柄。该方法接收一个MutableCallSite类型的数组作为参数。 如果一个目标方法句柄可变的调用点被设计为在多线程的情况下使用,可以直接使用VolatileCallSite,而不使用MutableCallSite。当使 用VolatileCallSite的时候,每当目标方法句柄发生变化的时候,其他线程会自动看到这个变化。这与Java中volatile关键词的语义是一样 的。这比使用MutableCallSite再加上syncAll方法要简单得多。除了这一点之外,VolatileCallSite的作用与MutableCallSite完全相同。 4.invokedynamic指令实战 下面将要介绍invokedynamic指令在Java字节代码中的具体使用方式。由于涉及字节代码的生成,这里使用了ASM工具[1]。暂时不会对ASM 工具的使用做过多的介绍,在第8章中会进行详细介绍。首先需要提供invokedynamic指令所需的启动方法,如代码清单2-70所示。 代码清单2-70 invokedynamic指令的启动方法 public class ToUpperCase{ public static CallSite bootstrap(Lookup lookup, String name, MethodType type, String value)throws Exception{ MethodHandle mh=lookup.findVirtual(String.class,"toUpperCase",MethodType.methodType(String.class)).bindTo(value); return new ConstantCallSite(mh); } } 该启动方法是一个普通的Java类中的方法。该方法的类型声明可以是多种格式。返回值必须是CallSite,而参数则允许多种形式。在典型 情况下,前面的3个参数分别是进行方法查找的MethodHandles.Lookup对象、方法的名称和方法的类型MethodType。这3个参数之后的其他参数 都会被传递给CallSite对应的方法句柄。在上面的代码中,使用了一个ConstantCallSite,而该调用点所绑定的方法句柄引用的底层方法是 String类中的toUpperCase方法。启动方法bootstrap接收一个额外的参数value。这个参数被预先绑定给方法句柄。因此当该方法句柄被调用的 时候,不需要额外的参数,而返回结果是对参数value表示的字符串调用toUpperCase方法的结果。 有了启动方法之后,就需要在字节代码中生成invokedynamic指令。代码清单2-71给出的程序会产生一个新的Java类文件 ToUpperCaseMain.class。通过java命令可以运行该类文件,输出结果是“HELLO”。 代码清单2-71 生成使用invokedynamic指令的字节代码 public class ToUpperCaseGenerator{ private static final MethodHandle BSM= new MethodHandle(MH_INVOKESTATIC, ToUpperCase.class.getName().replace('.','/'), "bootstrap", MethodType.methodType( CallSite.class, Lookup.class, String.class, MethodType.class, String.class).toMethodDescriptorString()); public static void main(String[]args)throws IOException{ ClassWriter cw=new ClassWriter(ClassWriter.COMPUTE_FRAMES); cw.visit(V1_7,ACC_PUBLIC|ACC_SUPER,"ToUpperCaseMain",null,"java/lang/Object",null); MethodVisitor mv=cw.visitMethod(ACC_PUBLIC|ACC_STATIC,"main","([Ljava/lang/String;)V",null, null); mv.visitCode(); mv.visitFieldInsn(GETSTATIC,"java/lang/System","out","Ljava/io/PrintStream;"); mv.visitInvokeDynamicInsn("toUpperCase","()Ljava/lang/String;",BSM,"Hello"); mv.visitMethodInsn(INVOKEVIRTUAL,"java/io/PrintStream","println","(Ljava/lang/String;)V"); mv.visitInsn(RETURN); mv.visitMaxs(0,0); mv.visitEnd(); cw.visitEnd(); Files.write(Paths.get("ToUpperCaseMain.class"),cw.toByteArray()); } } 上面的代码中包含了大量使用ASM工具的代码,这里只需要关心的是“mv.visit InvokeDynamicInsn("toUpperCase","()Ljava/lang/String;",BSM,"Hello");”这行代码。这行代码是用来在字节代码中生成 invokedynamic指令的。在调用的时候传入了方法的名称、方法句柄的类型、对应的启动方法和额外的参数“Hello”。在invokedynamic指令被 执行的时候,会先调用对应的启动方法,即代码清单2-70中的bootstrap方法。bootstrap方法的返回值是一个ConstantCallSite的对象。接着 从该ConstantCallSite对象中通过getTarget方法获取目标方法句柄,最后再调用此方法句柄。在调用visitInvokeDynamicInsn方法时提供了一 个额外的参数“Hello”。这个参数会被传递给bootstrap方法的最后一个参数value,用来创建目标方法句柄。当目标方法句柄被调用的时候, 返回的结果是把参数“Hello”转换成大写形式之后的值“HELLO”。 从上面这个简单的示例可以看出,invokedynamic指令是如何与方法句柄结合起来使用的。上面的示例只使用了最简单的 ConstantCallSite。复杂的示例包括根据参数的值确定需要返回的CallSite对象,或是对已有的MutableCallSite对象的目标方法句柄进行修改 等。 [1]ASM工具的官方网站是http://asm.ow2.org/。 2.5 小结 单纯从编程语言的角度来说,静态类型语言和动态类型语言都有各自的优劣势。静态类型语言所提供的强大的编译时刻类型检查能力,可 以尽可能早地发现程序中存在的类型错误。其严谨的语法对初学者来说更加容易理解。而动态类型语言所能提供的是更加灵活和简洁的语法。 这通常意味着更少的代码量和更易理解的代码逻辑,同时也对开发人员提出了更高的要求。考虑到这些优劣势,开发人员可以根据应用开发的 需要来灵活选择。编程语言不应该对开发人员的这种选择造成阻碍。开发人员也可以在一个应用的开发中使用多种不同的编程语言来开发不同 的组件。 Java语言虽然是静态类型语言,但是它也提供了足够多的动态性来应对灵活性要求较高的场景。这些动态性体现在本章介绍的脚本语言支 持API、反射API、动态代理和Java 7中通过JSR 292引入的动态语言支持上。基于Java平台的开发人员可以选择各种不同的方式来应对灵活性的 要求。可以选择把灵活性和动态性完全交给脚本语言去解决,再通过脚本语言支持API集成到主Java程序中;也可以通过反射API在运行时刻动 态地调用方法;当需要对接口中的方法调用进行拦截时,动态代理是一个很好的选择;JSR 292引入的方法句柄在灵活性上要远胜于反射API中 的Method类。在方法句柄上的各种变换操作足以应付多种常见的需求。 第3章 Java I/O 绝大多数应用程序在运行过程中都会进行两种类型的计算:一种是占用CPU时间的计算,另外一种是与数据输入/输出(I/O)相关的计算。 在这两种计算中,一般是与I/O相关的计算所花费的时间占较大的比重。这其中的主要原因是在进行I/O操作时,一般需要竞争操作系统中有限 的资源,或是需要等待速度较慢的外部设备完成其操作,从而造成I/O相关的计算所等待的时间较长。从性能优化的角度出发,提升I/O相关操 作的性能会对应用程序的整体性能产生比较大的帮助。 Java平台提供了丰富的标准类库来满足应用程序中可能出现的与I/O操作相关的需求。这些标准类库本身也在不断发展,从最初的java.io 包,到JDK 1.4中的新IO支持(JSR 51:New I/O APIs for the JavaTMPlatform, NIO),再到Java 7中的NIO的补充类库NIO.2(JSR 203: More New I/O APIs for the JavaTMPlatform)。Java平台对I/O的支持功能越发强大。很多之前需要开发人员来编写的功能,在现在的标准库 中都有了实现。不过开发人员需要额外的时间来学习这些新API的使用。 对于I/O操作来说,其根本的作用在于传输数据。输入和输出指的仅是数据的流向,实际传输是通过某些具体的媒介来完成的。这其中最主 要的媒介是文件系统和网络连接。这两种传输方式也在Java I/O中受到了良好的支持。从不同的抽象层次来看I/O操作,所得到的API是不同 的。最早的java.io包把I/O操作抽象成数据的流动,进而有了流(stream)的概念。在Java NIO中,则把I/O操作抽象成端到端的一个数据连 接,这就有了通道(channel)的概念。不同的抽象层次对开发人员所暴露的复杂度是不同的。推荐开发人员使用Java NIO中新的通道的概念。 在下面的内容中会详细介绍流和通道相关的内容。 3.1 流 流是Java中最早提供的对I/O操作的抽象,从JDK 1.0就存在了。流把I/O操作抽象成数据的流动。流所代表的是流动中的数据。对于传输的 数据来说,除了最底层的字节表示外,还支持不同的抽象表示方式。对一个计算机程序来说,其数据的最终表现形式都是0或1的比特值。程序 一般不直接处理单个的比特值,而是处理由8个比特组成的字节。不管是内存中的数据、磁盘上的数据,还是通过网络传输的数据,其基本格式 都是一系列的字节。所不同的是,不同的程序对这一系列的字节有不同的解释方式。将一组字节按照特定的方式进行解释,就形成了编程语言 中的不同的基本类型。比如在Java语言中,4个字节可以表示一个整数(int)或是单精度浮点数(float),而8个字节可以表示一个长整数 (long)或是双精度浮点数(double)。单纯的一个字节序列可以有多种不同的解释方式。比如一个16个字节的数组,既可以解释成4个整数, 也可以解释成2个双精度浮点数。不同的解释所表示的语义是完全不同的。这种解释工作是由应用程序自己来完成的,编程语言的类库一般会提 供相关的支持。 Java中最基本的流是在字节这个层次上进行操作的。也就是说基本的流只负责在来源和目的之间传输字节,并不负责对字节的含义进行解 释。在基本的字节流基础上,Java也提供了一些过滤流(filter stream)的实现。这些过滤流实际上是基本字节流上的一个封装,在其上增加 了不同的处理能力,如基本类型与字节序列之间的转换等。这些过滤流对开发人员的接口更加友好,可以自动完成很多转换工作。 3.1.1 基本输入流 最基本的I/O流是java.io包中的抽象类java.io.InputStream和java.io.OutputStream。由于流的相关API设计得比较早,因此并没有采用 现在流行的面向接口编程的思路,而是采用了抽象类。新的I/O相关的API则大量使用了接口。如果流的实现只对使用者暴露字节这个层次的细 节,则可以直接继承InputStream或OutputStream类,并提供自己额外的能力。 输入流InputStream类中包含两类功能:一类是与读取流中字节数据相关的功能,另一类则是流的控制功能。读取流中的字节通过read方法 来完成。该方法有3种重载形式:第一种形式不带任何参数,每次读取一个字节并返回;第二种形式使用字节数组作为缓冲区,用读取到的字节 数据填充缓冲区;最后一种形式需要提供作为缓冲区使用的字节数组和数组中的起始位置和长度,读取到的字节数据被填充到缓冲区的指定位 置上。这3种形式中,第一种是声明为abstract的,必须由子类来实现。而对于另外两种,InputStream类有自己的默认实现,通过循环调用第 一种形式的read方法来填充缓冲区。 最常见的读取InputStream类的对象中的数据的方式是创建一个字节数组作为缓冲区,然后循环读取,直到read方法返回-1或抛出 java.io.IOException异常。read方法的返回值是每次调用中成功读取的字节数。在读取数据的过程中,对read方法的调用是阻塞的。当流中没 有数据可用时,对read方法的调用需要等待。这种阻塞式的特性可能会成为应用中的性能瓶颈。如果不使用字节数组作为缓冲区,read方法一 次只能读入一个字节。在提供缓冲区的情况下,虽然InputStream类也只是以循环的方式每次读取一个字节来填充缓冲区,但是InputStream类 的子类一般会为接受缓冲区作为参数的read方法提供更加高效的实现。这也是为什么使用缓冲区的重要原因。 从流本身所代表的抽象层次出发,它表示的是一个流动的字节流,如流水一样。正因为如此,流中所包含的字节一旦流过去,就无法再重 新使用。从这个角度出发,对一般的输入流所能做的操作就只是顺序地读取,直到流的末尾或中间出现读取错误。当然,不同的输入流可能也 支持额外的控制操作,因此InputStream类中也包含了相应的方法来允许其子类进行选择性地覆写。这也是采用抽象类的设计方式所带来的弊 端。所有可能会用到的方法都需要在抽象类中进行声明。 第一个最直接的功能是关闭流,通过close方法来完成。在Java 7中,应该尽量通过1.5节介绍的try-with-resources语句来使用流,可以 避免显式调用close方法。 第二个流控制功能是跳过指定数目的字节,相当于把流中的当前读取位置往后移动若干个字节。这个功能是通过skip方法来实现的。由于 跳过若干个字节后,可能就已经到达了流的末尾,因此skip方法并不总能正确跳过指定数目的字节。调用者应该检查skip方法的返回值来获取 实际跳过的字节数。并不是所有InputStream类的子类都支持skip方法。 第三个流控制功能是流的标记(mark)与重置(reset)。标记与重置配合起来使用,可以实现流中部分内容的重复读取,而不会像一般的 读取操作那样,数据流过去之后就无法再次读取。简单来说,标记操作负责在流的当前读取位置做一个记号。当进行重置操作时,流的当前读 取位置会被移动到上次标记的位置,这样就可以从上次标记位置开始再次进行读取操作。不是所有的流都支持标记功能,因此在使用mark方法 来标记当前位置之前,需要通过markSupported方法来判断当前流的实现是否支持标记功能。在使用mark方法进行标记时,需要指定一个整数来 表示允许重复读取的字节数。例如,标记时使用的是“mark(1024)”,那么在调用了reset之后,就只能从之前标记的位置开始再次重复读取 最多1024字节。一般的内部实现方式是在标记之后把读取到的字节先保存起来。当重置之后,再调用read方法读取的就是之前保存的数据。 除了上面介绍的流控制方法之外,InputStream类的最后一个方法是available。这个方法与前面提到的InputStream类的对象进行读取操作 时的阻塞特性相关。当read方法被调用,且当前流中并没有立即可用的数据时,这个调用操作会被阻塞,直到当前流成功地完成数据的准备为 止。而available方法的作用在于告诉流的使用者,在不产生阻塞的情况下,当前流中还有多少字节可供读取。如果每次只读取调用available 方法获取到的字节数,那么读取操作肯定不会被阻塞。这种非阻塞的特性在某些场合可能是很有作用的,比如在读取一个大文件的同时对文件 的内容进行处理,如果每次读取时都不发生阻塞,就可以比较好地平衡数据读取和处理的时间。 3.1.2 基本输出流 与InputStream类相对应的OutputStream类表示的是基本的输出流,用来把数据从程序中输出到其他地方。基本的OutputStream类的对象也 是在字节这个层次上进行操作的。其中最主要的是写入数据的write方法。同InputStream类中的read方法一样,write方法也有3种类似的重载 形式,可以每次写入一个字节,也可以写入一个字节数组中的全部或部分内容。 而在流的控制方面,OutputStream类除了关闭流的close方法之外,还有一个flush方法用来强制要求OutputStream类的对象对暂时保存在 内部缓冲区中的内容立即进行实际的写入操作。有些OutputStream类的子类会在内部维护一个缓冲区,通过write方法写入的数据会被首先存放 在这个缓冲区中,然后在某个合适的时机再一次性地执行已缓冲的内容的实际写入操作。这种实现方式的出发点是为了性能考虑,减少实际的 写入操作次数。在通常的使用场景中,OutputStream类的对象的使用者一般不需要直接调用flush方法来保证内部缓冲区的数据被成功写入。这 是因为当OutputStream类的对象的内部的缓冲区满了之后,会自动执行实际的写入操作。同时在OutputStream类的对象被关闭时,flush方法一 般也会被自动调用。 3.1.3 输入流的复用 输入流的复用其实有些自我矛盾的应用场景。一方面,在实际应用中,很多需要提供输入数据的API都使用InputStream类作为其参数的类 型,比如XML文档的解析API就是一个典型的例子。同时很多数据的提供者允许使用者通过InputStream类的对象的方式来读取其数据,比如通过 java.net.HttpURLConnection类的对象打开一个网络连接之后,可以得到用来读取其中数据的InputStream类的对象。如果每个这样的数据源仅 有一个接收者,处理起来比较简单;如果有多个接收者,那么就有些复杂。主要的原因在于,从另一个方面出发,按照流本身所代表的抽象含 义,数据一旦流过去,就无法被再次使用。如果直接把一个InputStream类的对象传递给一个接收者使用之后再传递给另外一个接收者,后者不 能读取到流中的任何数据,因为流的当前读取位置已经到了末尾。这其实可以理解成数据的使用方式和数据本身的区别。InputStream类表示的 是数据的使用方式,而并不是数据本身。两者的根本区别在于每个InputStream类的对象作为Java虚拟机中的对象,是有其内部状态的,无法被 简单地复用;而纯粹的数据本身是无状态的。在实际开发中,需要复用一个输入流的场景是比较多的。比如,通过HTTP连接获取到的XML文档的 输入流,可能既要进行合法性检验,又要解析文档内容,还有可能要保存到磁盘中。这些操作都需要直接接收同一个输入流。 对于现实应用中存在的对输入流复用的需求,基本上来说有两种方式可以解决:第一种是利用输入流提供的标记和重置的控制能力,第二 种则是把输入流转换成数据来使用。 对于第一种解决方案来说,需要用到java.io包中的InputStream类的子类java.io.BufferedInputStream。正如这个类名的含义一 样,BufferedInputStream类在InputStream类的基础上使用内部的缓冲区来提升性能,同时提供了对标记和重置的支持。BufferedInputStream 类属于过滤流的一种,在创建时需要传入一个已有的InputStream类的对象作为参数。BufferedInputStream类的对象则在这个已有的 InputStream类的对象的基础上提供额外的增强能力。 使用BufferedInputStream类之后,对流进行复用的过程就变得简单清楚。只需要在流开始的地方进行标记,当一个接收者读取完流中的内 容之后,再进行重置即可。重置完成之后,流的当前读取位置又回到了流的开始,就可以再次使用。代码清单3-1中给出了一个示例。对于一个 InputStream类的子类的对象来说,如果它本来就支持标记,那么不再需要用BufferedInputStream类进行包装。在使用流之前,首先调用mark 方法来进行标记。这里设置的标记在重置之后允许读取的字节数是整数的最大值,即Integer.MAX_VALUE,这是为了能够复用整个流的全部内 容。当流的接收者使用完流之后,需要显式地调用markUsed方法来发出通知,以完成对流的重置。 代码清单3-1 使用BufferedInputStream类进行流复用的示例 public class StreamReuse{ private InputStream input; public StreamReuse(InputStream input){ if(!input.markSupported()){ this.input=new BufferedInputStream(input); }else{ this.input=input; } } public InputStream getInputStream(){ input.mark(Integer.MAX_VALUE); return input; } public void markUsed()throws IOException{ input.reset(); } } 第二种复用流的方案是直接把流中的全部数据读取到一个字节数组中。在不同的流的接收者之间的数据传递都是通过这个字节数组来完成 的,而不再使用原始的InputStream类的对象。从一个字节数组得到一个InputStream类的对象是很容易的事情,只需要从该字节数组上创建一 个java.io.ByteArrayInputStream类的对象就可以了。完整的实现如代码清单3-2所示。在创建SavedStream类的对象时,作为参数传递的 InputStream类的对象中的数据首先被写入到一个java.io.ByteArrayOutputStream类的对象中,再把得到的字节数组保存下来。 代码清单3-2 通过保存流的数据进行流复用的示例 public class SavedStream{ private InputStream input; private byte[]data=new byte[0]; public SavedStream(InputStream input)throws IOException{ this.input=input; save(); } private void save()throws IOException{ ByteArrayOutputStream output=new ByteArrayOutputStream(); byte[]buffer=new byte[1024]; int len=-1; while((len=input.read(buffer))!=-1){ output.write(buffer,0,len); } data=output.toByteArray(); } public InputStream getInputStream(){ return new ByteArrayInputStream(data); } } 实际上,这两种复用流的做法在实现上的思路是一样的,都是预先把要复用的数据保存起来。BufferedInputStream类在内部有一个自己的 字节数组来维护标记位置之后可供读取的内容,与第二种做法中的字节数组的作用是一样的。 3.1.4 过滤输入输出流 在基本的输入输出流之上,java.io包还提供了多种功能更强的过滤输入输出流。这些过滤流所提供的增强能力各不相同,比如前面提到的 BufferedInputStream类和BufferedOutputStream类使用了内部的缓冲区来提高读写操作时的性能。另外一组过滤流DataInputStream类和 DataOutputStream类在基本的字节流基础上提供了对读取和写入Java基本类型的支持。如果使用基本的字节流来操作Java中基本的整数、浮点 数和字符串等类型的数据,需要开发人员自己完成这些数据类型与字节数组之间的转换工作。这个转换工作是平台相关的,并不是非常简单就 能完成的。比如在读取和写入时需要考虑字节顺序,大端表示(big-endian)和小端表示(little-endian)的差别是很大的。在使用 DataInputStream类时,可以通过readInt、readFloat和readUTF等方法来读取基本数据类型;在使用DataOutputStream类时,可以通过 writeInt、writeFloat和writeUTF等方法来进行相应的写入操作。为了保证数据的正确性,对同样类型数据的写入和读取操作需要配对完成, 这也是数据的提供者和消费者之间的契约。 除了读写基本数据类型的DataInputStream类和DataOutputStream类之外,ObjectInputStream类和ObjectOutputStream类在基本数据类型 的基础上增加了读写Java对象的支持。可以把一个Java对象的内部状态写入到输出流中,还可以从输入流中直接创建Java对象。这是一种实用 的对象持久化的实现方式。在第10章介绍Java对象序列化时,会深入讨论ObjectInputStream类和ObjectOutputStream类的使用。 在有些时候,当从输入流中读取了某些数据之后,希望把这些数据又放回输入流中,以便下次可以重新读取。这种重复读取的动机与3.1.3 节中提到的流的复用并不相同。将数据放回输入流是为了实现流的前瞻功能。有些情况下,在处理流的时候需要查看流中当前剩下的内容以确 定是否继续读取。如果不符合条件,就不能继续读取。为了查看这些内容,需要先读取到这些内容。但是一旦读取过了,再次读取的时候就无 法获取到这些内容了,相当于有些内容丢失了。过滤流java.io.PushbackInputStream类可以解决这个问题,其中最关键的方法是unread,这个 方法可以把一个或多个字节放回输入流中,下次再读取时,会首先读取被放回去的内容。 3.1.5 其他输入输出流 在java.io包中,还有一些实用的输入输出流的实现,比如进行文件读写操作的FileInputStream类和FileOutputStream类,作为字节数组 和流之间的桥梁的ByteArrayInputStream类和ByteArrayOutputStream类。这两对输入输出流的使用比较简单,这里不再赘述。 使用过UNIX和Linux操作系统的开发人员对使用命令行工具时可用的管道操作符(“|”)可能都不陌生。每个命令行工具都接收一定的输 入数据,完成处理之后再产生相应的输出结果。通过管道操作符可以把一个命令行工具的输出作为另一个工具的输入,从而使它们级联起来, 可以简洁地实现复杂的功能。Java中的java.io.PipedInputStream类和java.io.PipedOutputStream类就是这样一对通过管道方式连接在一起的 输入和输出流。一个PipedInputStream类的对象和一个PipedOutputStream类的对象连接在一起之后,通过PipedOutputStream类的对象所写入 的数据可以在PipedInputStream类的对象中读取到。两者的连接既可以通过构造方法来完成,也可以通过connect方法来实现。从设计的角度来 说,这实际上实现了典型的数据生产者-消费者模式。不过需要注意的是,使用PipedInputStream类和PipedOutputStream类的对象要在不同的 线程之中,否则容易出现死锁的问题。 另外一个特殊的输入流是java.io.SequenceInputStream类。它可以把多个输入流按顺序连接起来,形成一个完整的输入流。调用 SequenceInputStream类的对象的read方法会依次读取底层输入流中的内容。当一个输入流中的内容读取完毕之后,再换下一个输入流进行读 取,直到所有底层输入流都读取完毕。SequenceInputStream类的作用相当于多个InputStream类的对象的连接操作。 3.1.6 字符流 前面介绍的基本InputStream类和OutputStream类以及包装它们的各种过滤流实现都是在字节层次上操作的。字节流主要由机器来处理。而 对于程序的用户来说,他们更愿意看到直接可读的字符。在java.io包中还有一组类用来处理字符流,即java.io.Reader类和java.io.Writer类 及其子类。这些字符流处理的是字符类型,而不是字节类型。字符流适合用于处理程序中包含的文本类型的内容。 创建一个字符流的最常见做法是通过一个字节流InputStream类或OutputStream类的对象进行创建,对应的是InputStreamReader类和 OutputStreamWriter类。在从字节流转换成字符流时,需要指定字符的编码格式。关于字符的编码和解码,在第4章会有详细的介绍。如果编码 格式错误,会产生包含乱码的字符串。在创建InputStreamReader类和OutputStreamWriter类的对象时,总是应该显式地指定一个字符编码格 式。如果不指定,使用的是底层Java平台的默认编码格式。这可能造成程序在不同运行平台上的兼容性问题。字符流也可以从String类的对象 中创建,即使用java.io.StringReader类和java.io.StringWriter类进行创建;还可以从字符数组中创建,即使用java.io.CharArrayReader类 和java.io.CharArrayWriter类进行创建。java.io.BufferedReader类和java.io.BufferedWriter类用来为已有的Reader类和Writer类的对象提 供内部缓冲区的支持,以提高读写操作的性能。 在使用字符流处理文本内容时,要注意那些在内容中声明了编码格式的文本格式。这其中最典型的例子是XML文档。XML文档一般通过处理 指令来声明其内容的编码格式,如处理指令“<?xml version="1.0"encoding="UTF-8"?>”声明了文档使用的是UTF-8编码格式。当处理指令 中不包含编码格式声明时,XML处理器有自己的一套编码格式识别算法。对于这种类型的文档,不应该使用字符流来处理,而是应该使用字节 流。使用字节流时不会对原始数据造成影响,能够保证文档处理时的正确性。 在开发中经常会遇到的一个场景是把InputStream类的对象中的内容转换成一个String类的对象。对于这种情况,典型的做法是从 InputStream类的对象中创建一个对应的InputStreamReader类的对象,并循环读取到一个char数组中,再把char数组的内容添加到一个 java.lang.StringBuilder类的对象中,最后把StringBuilder类的对象转换成一个String类的对象即可。如果希望提高性能,可以使用一个 BufferedReader类的对象来包装InputStreamReader类的对象,再调用readLine方法每次读取一行数据。使用java.util.Scanner类也可以完成 类似的转换。还可以使用Apache Commons IO库中的org.apache.commons.io.IOUtils类的toString方法。 3.2 缓冲区 从前面对Java I/O中流的概念和实现的介绍可以看出,Java中流的实现采用了一种简单而朴素的做法,即以字节流为基础,在字节流上再 通过过滤流来满足不同的需要。对于开发人员来说,流加上字节数组的使用方式的抽象层次较低,使用起来比较繁琐。这其中比较麻烦的一点 在于字节数组的长度是不可变的。一旦创建了某个长度的字节数组,当数据过多以至于超出数组长度的限制时,需要开发人员自己来进行管 理,比如重新创建一个新的长度更大的数组,然后再把之前的数据复制进去。一种可行的做法是利用ByteArrayOutputStream类,不断地向 ByteArrayOutputStream类的对象中写入数据,写入完成之后,用它的toByteArray方法可以得到一个字节数组。但这种做法缺乏足够的灵活 性,性能也比较差。更好的做法是使用Java NIO中新的缓冲区实现。 3.2.1 基本用法 Java NIO中的缓冲区在某些特性上类似于Java中的基本类型的数组(如字节数组或整型数组等),比如缓冲区中的数据排列是线性的,缓 冲区的空间也是有限的。不过两者的差别也是显著的,最主要的区别在于缓冲区所提供的功能远比数组丰富得多,而且也支持存储类型异构的 数据。要理解缓冲区的使用,就需要理解缓冲区的3个状态变量,分别是容量(capacity)、读写限制(limit)和读写位置(position)。使 用缓冲区时产生的错误,绝大多数都源自错误地理解了这3个变量的含义。 首先最容易理解的状态变量是缓冲区的容量。容量指的是缓冲区的额定大小。容量是在创建缓冲区时指定的,无法在创建后更改。在任何 时候缓冲区中的数据总数都不可能超过容量。第二个变量是读写限制,表示的是在缓冲区中进行读写操作时的最大允许位置。比如对于一个容 量为32的缓冲区来说,如果设置其读写限制的值是16,那么就只有前半个缓冲区在读写时是可用的。如果希望后半个缓冲区也能进行读写操 作,就必须把读写限制设置为32。最后一个变量是读写位置,表示的是当前进行读写操作时的位置。当在缓冲区中进行相对读写操作时,在这 个位置上进行。对于这3个变量,除了只能获取容量外,读写限制和读写位置都有相应的获取和设置的方法,分别是limit和position,其中不 带参数的重载形式用来获取值,而带参数的形式用来设置值。对于一个缓冲区来说,它的当前可以使用的范围是在读取位置和读取限制之间的 这一段区域。通过remaining方法可以获取到这段可用范围的长度。 缓冲区同样也支持标记和重置的特性,类似于前面提到的流的标记和重置。当调用mark方法时,会在当前的读写位置上设置一个标记。在 调用reset方法之后,会使得读写位置回到上一次mark方法设置的位置上。进行标记时的位置不能超过当前的读写位置。如果通过position方法 重新设置了读写位置,而使之设置的标记的位置超出了新的读写位置的范围,那么该标记就会失效。在任何时候,缓冲区中的各个状态变量之 间满足关系“0<=标记位置<=读写位置<=读写限制<=容量”。 Java NIO中的java.nio.Buffer类是所有不同数据类型的缓冲区的父类。一般来说,通过调用缓冲区对象的limit和position方法就可以满 足大部分的需求。不过对于某些常见的场景,Buffer类也提供了快捷的方法。当复用一个已有的缓冲区时,如果希望向缓冲区中写入数据,可 以调用clear方法,该方法会把读写限制设为缓冲区的容量,同时把读写位置设为0;当需要读取一个缓冲区中的数据时,可以调用flip方法, 该方法会把读写限制设为当前的读写位置,再把读写位置设为0,这样可以保证缓冲区中的全部数据都可以被读取;当希望重新读取缓冲区中的 数据的时候,可以调用rewind方法,该方法不会改变读写限制,但是会把读写位置设为0。在后面的示例代码中,可以看到这几个方法在缓冲区 读写操作时的使用模式。熟悉这些模式,就会避免出现一些常见的错误。 缓冲区进行的读写操作分成两类:一类是根据当前读写位置进行的相对读写操作,另外一类是根据在缓冲区中的绝对位置进行的读写操 作。两者的差别在于相对读写会改变当前读写位置,而绝对读写则不会。 3.2.2 字节缓冲区 对于Java中的基本类型,除了布尔类型之外,都有对应的缓冲区实现,用来存储此类型的数据。布尔类型的缓冲区可以很容易地用字节类 型来替代。这些缓冲区类型中最重要的实现类是java.nio.ByteBuffer类。在ByteBuffer类中,除了可以对基本的字节进行操作之外,还可以操 作其他基本类型的数据。对于字节数据,除了每次操作单个字节之外,还支持批量式处理一个字节数组。代码清单3-3中给出了使用ByteBuffer 类的示例。在创建ByteBuffer类的对象时,只能通过其静态工厂方法allocate来分配新空间,或者通过wrap方法来包装一个已有的字节数组。 在创建ByteBuffer类的对象时需要指定缓冲区的容量。在创建完成之后,可以通过put方法向缓冲区中添加数据,而get方法则从其中读取数 据。进行绝对读写操作的put和get方法的第一个参数都是读写位置的序号。需要注意的是,这个序号是根据字节数来进行计算的。如果在写入 数据时使用的是类似putChar或putLong这样的操作基本数据类型的方法,在通过相应的getChar或getLong方法来读取的时候,需要开发人员自 己来计算起始字节的位置。如果计算错误,那么得到的就不是当时写入的数据。如果ByteBuffer类的对象中存放的是不同类型的数据,那么这 种计算是无法避免的。如果ByteBuffer类的对象中存放的是相同的类型,可以考虑使用对应类型的缓冲区实现类,或是从ByteBuffer类的对象 中创建相应的视图。 代码清单3-3 ByteBuffer类的使用示例 public void useByteBuffer(){ ByteBuffer buffer=ByteBuffer.allocate(32); buffer.put((byte)1); buffer.put(new byte[3]); buffer.putChar('A'); buffer.putFloat(0.0f); buffer.putLong(10,100L); buffer.getChar(4);//值为'A' } 由于ByteBuffer类支持对基本数据类型的处理,因此必须要考虑字节顺序。同样的字节序列按照不同的顺序去解释,所得到的结果是不同 的。java.nio.ByteOrder类中定义了两种最基本的字节顺序:BIG_ENDIAN对应的大端表示和LITTLE_ENDIAN对应的小端表示。大端表示的含义是 字节序列中高位在前,而小端表示则正好相反。ByteOrder类中的静态方法nativeOrder可以获取到底层操作系统平台采用的字节顺序。 ByteBuffer类的对象默认使用的是大端表示。代码清单3-4中给出了一个通过修改字节顺序来改变基本类型的值的示例。 代码清单3-4 字节缓冲区的字节顺序 public void byteOrder(){ ByteBuffer buffer=ByteBuffer.allocate(4); buffer.putInt(1); buffer.order(ByteOrder.LITTLE_ENDIAN); buffer.getInt(0);//值为16777216 } ByteBuffer类支持的另外一个操作是压缩。压缩操作的一个典型的应用场景是把ByteBuffer类的对象作为数据传输时的缓冲区来使用。例 如,有一个发送者不断地向ByteBuffer类的对象中填充数据,而另外一个接收者从相同的ByteBuffer类的对象中不断地获取数据。发送者可能 用数据填充满了ByteBuffer类的对象中读写限制范围之内的全部可用空间,而接收者却暂时只读取了ByteBuffer类的对象中的一部分数据。接 收者在完成读取之后要使用compact方法进行压缩操作。压缩操作就是把ByteBuffer类的对象中当前读写位置到读写限制范围内的数据复制到内 部存储空间中的最前面,然后再把读写位置移动到紧接着复制完成的数据的下一个位置,读写限制也被设置成ByteBuffer类的对象的容量。经 过压缩之后,发送者的下次写入操作就不会覆盖接收者还没读取的内容,而接收者每次总是可以从ByteBuffer类的对象的起始位置进行读取。 代码清单3-5中给出了压缩操作的使用示例。 代码清单3-5 字节缓冲区的压缩操作的示例 public void compact(){ ByteBuffer buffer=ByteBuffer.allocate(32); buffer.put(new byte[16]); buffer.flip(); buffer.getInt();//当前读取位置为4 buffer.compact(); int pos=buffer.position();//值为12 } 在代码清单3-5中,经过put、flip和getInt等方法调用之后,ByteBuffer类的对象的当前读取位置是4,而读取限制是16,所以在压缩的时 候,没有被读取的12个字节会被复制到ByteBuffer类的对象的内部存储空间的开头位置,同时当前读取位置变为被复制的字节数,即12。 ByteBuffer类的实现分为直接缓冲区和非直接缓冲区两种。在直接缓冲区中进行I/O操作时,会尽可能避免多余的数据复制,而直接使用操 作系统底层的I/O操作来完成。这样可以提升读写操作时的性能,不过也带来了额外的创建和销毁时的代价。直接缓冲区一般是常驻内存的,会 增加程序的内存开销,所以直接缓冲区一般只用在对性能要求很高的情况中。通过ByteBuffer类的静态方法allocateDirect可以创建新的直接 缓冲区,这类似于allocate方法的使用。 3.2.3 缓冲区视图 ByteBuffer类的另外一个常见的使用方式是在一个已有的ByteBuffer类的对象上创建出各种不同的视图。这些视图和它所基于的 ByteBuffer类的对象共享同样的存储空间,但是提供额外的实用功能。在功能上,ByteBuffer类的视图与它所基于的ByteBuffer类的对象之间 的关系类似于3.1.4节介绍的过滤流和它所包装的流的关系。正因为这种共享存储空间的特性,在视图中对数据所做的修改会反映在原始的 ByteBuffer类的对象中。 最常见的ByteBuffer类的视图是转换成对基本数据类型进行操作的缓冲区对象。这些缓冲区包括CharBuffer、ShortBuffer、IntBuffer、 LongBuffer、FloatBuffer和DoubleBuffer等Java类。从这些缓冲区的类名就可以知道所操作的数据类型。ByteBuffer类提供了对应的方法来完 成相应的转换,如asIntBuffer方法在当前ByteBuffer类的对象的基础上创建一个新的IntBuffer类的视图。新创建的视图和原始的ByteBuffer 类的对象所共享的不一定是全部的空间,而只是从ByteBuffer类的对象中的当前读写位置到读写限制之间的可用空间。在这个空间范围内,不 论是在ByteBuffer类的对象中还是在作为视图的新缓冲区中,对数据所做的修改,对另一个来说都是可见的。除了数据本身之外,两者的读写 位置、读写限制和标记位置等都是相互独立的。在代码清单3-6中,创建视图的时候,两者所共享的是序号4~32的空间。在IntBuffer类的对象 中所做的修改,对于原始的ByteBuffer类的对象也是可见的。ByteBuffer类的基本数据类型视图在开发中的使用场景比较多,这主要是因为很 多I/O相关的API都使用ByteBuffer类作为参数类型,而ByteBuffer类的视图可以很方便地对内容进行操作。 代码清单3-6 字节缓冲区的视图 public void viewBuffer(){ ByteBuffer buffer=ByteBuffer.allocate(32); buffer.putInt(1);//读取位置为4 IntBuffer intBuffer=buffer.asIntBuffer(); intBuffer.put(2); int value=buffer.getInt();//值为2 } 除了基本类型的缓冲区视图之外,另外一类视图是类型相同的ByteBuffer类的对象。通过slice方法可以创建一个当前ByteBuffer类的对象 的视图,其行为类似于通过asIntBuffer方法创建的视图,只不过得到的是ByteBuffer类的对象。而duplicate方法则用来完全复制一个 ByteBuffer类的对象。这个方法与slice方法的区别在于,duplicate方法并不考虑ByteBuffer类的对象的当前读写位置和读写限制,只是简单 地全部复制。方法asReadOnlyBuffer的行为类似于duplicate方法,只不过得到的ByteBuffer类的对象是只读的,不能执行写入操作。 对于其他基本类型的缓冲区来说,除了通过ByteBuffer类的视图来创建之外,也可以通过对应类的allocate方法来直接创建。这些缓冲区 类与ByteBuffer类的最显著区别在于,其中的容量、读写位置和读写限制都是根据基本类型的个数来计算的,而不是根据字节数计算的。如通 过“IntBuffer.allocate(32)”创建的整型缓冲区的容量是32个整数,而不是32个字节。另外,不能直接创建出除ByteBuffer类之外的其他 类型的直接缓冲区,只能先创建ByteBuffer类型的直接缓冲区,再创建相应的基本类型的视图。 3.3 通道 通道是Java NIO对I/O操作提供的另外一种新的抽象方式。通道不是从I/O操作所处理的数据这个层次上去抽象,而是表示为一个已经建立 好的到支持I/O操作的实体的连接。这个连接一旦建立,就可以在这个连接上进行各种I/O操作。在通道上所进行的I/O操作的类型取决于通道的 特性,一般的操作包括数据的读取和写入等。在Java NIO中,不同的实体有不同的通道实现,比如文件通道和网络通道等。通道在进行读写操 作时使用的都是3.2节介绍的新的缓冲区的实现,而不是字节数组。 Java NIO中的通道都实现了java.nio.channels.Channel接口。Channel接口本身很简单,只有关闭通道的close方法和判断通道是否被打开 的isOpen方法。由于Channel接口继承了java.lang.AutoCloseable接口,通道的所有实现对象都可以用在try-with-resources语句中,方便了 对通道的使用。从API设计的角度来说,Java NIO更多地采用了面向接口的设计思路,很多功能都被抽象到不同的接口中。 对于一个支持读取操作的通道来说,应该实现java.nio.channels.ReadableByte-Channel接口。这个接口只有一个read方法来读取数据。 读取的时候把数据读取到一个ByteBuffer类的对象中。在读取的时候,数据的填充从ByteBuffer类的对象的当前读写位置开始,直到写入到读 写限制所指定的位置为止。类似的java.nio.channels.WritableByteChannel接口用来进行数据的写入。该接口的write方法也使用ByteBuffer 类的对象作为参数。写入时的数据来源也与ReadableByteChannel接口中read方法类似,取决于ByteBuffer类的对象中的可用字节。 有些通道除了支持读写操作之外,还支持移动读写操作的位置。这种通道一般实现java.nio.channels.SeekableByteChannel接口,该接口 中的position方法用来获取和设置当前的读写位置,而truncate方法则将通道对应的实体截断。如果调用truncate方法时指定的新的长度值小 于实体的当前长度,那么实体被截断成指定的新长度。另外的一个方法size可以获取实体的当前长度。 另外一个实用的通道接口是java.nio.channels.ScatteringByteChannel。这个接口的read方法不同于ReadableByteChannel接口中的read 方法,支持使用一个ByteBuffer类的对象的数组作为参数。在进行读取操作时,从通道对应的实体中得到的数据被依次写入这些ByteBuffer类 的对象中。向每个ByteBuffer类的对象中写入的字节数是该ByteBuffer类的对象中当前可用的字节数。与ScatteringByteBuffer接口对应的是 java.nio.channels.GatheringByteBuffer接口,这个接口用来将多个ByteBuffer类的对象包含的数据同时写入到通道中。 3.3.1 文件通道 文件是I/O操作的一个常见实体。与文件实体对应的表示文件通道的java.nio.channels.FileChannel类也是一个功能强大的通道实现。 FileChannel类除了可以进行读写操作之外,还实现了前面介绍的大部分接口,最主要的是实现了SeekableByteChannel接口。除了这些接口之 外,FileChannel类还提供了一些与文件操作相关的特有功能。这些功能会在后面进行介绍。 1.基本用法 在使用文件通道之前,首先需要获取到一个FileChannel类的对象。FileChannel类的对象既可以通过直接打开文件的方式来创建,也可以 从已有的流中得到。通过直接打开文件来创建文件通道的方式是Java 7中新增的。代码清单3-7给出了一个简单的打开文件通道并写入数据的示 例。FileChannel类的open方法用来打开一个新的文件通道。调用时的第一个参数是要打开的文件的路径,第二个参数是打开文件时的选项。不 同的选项会对通道的能力产生影响。比如,当一个文件通道以只读的方式打开时,就不能通过write方法来写入数据。 代码清单3-7 打开文件通道并写入数据的示例 public void openAndWrite()throws IOException{ File Channel channel=File Channel.open(Paths.get("my.txt"),StandardOpenOption.CREATE, StandardOpenOption.WRITE); ByteBuffer buffer=ByteBuffer.allocate(64); buffer.putChar('A').flip(); channel.write(buffer); } 在打开文件通道时可以选择的选项有很多,其中最常见的是读取和写入模式的选择,分别通过java.nio.file.StandardOpenOption枚举类 型中的READ和WRITE来声明。CREATE表示当目标文件不存在时,需要创建一个新文件;而CREATE_NEW同样会创建新文件,区别在于如果文件已经 存在,则会产生错误;APPEND表示对文件的写入操作总是发生在文件的末尾处,即在文件的末尾添加新内容;当声明了TRUNCATE_EXISTING选项 时,如果文件已经存在,那么它的内容将被清空;DELETE_ON_CLOSE用在需要创建临时文件的时候。声明了这个选项之后,当文件通道关闭 时,Java虚拟机会尽力尝试去删除这个文件。 另外一种创建文件通道的方式是从已有的FileInputStream类、FileOutputStream类和RandomAccessFile类的对象中得到。这3个类都有一 个getChannel方法来获取对应的FileChannel类的对象,所得到的FileChannel类的对象的能力取决于其来源流的特征。对InputStream类的对象 来说,它所得到的FileChannel类的对象是只读的,而FileOutputStream类的对象所得到的通道是可写的,RandomAccessFile类的对象所得到的 通道的能力则取决于文件打开时的选项。 对于FileChannel类所实现的来自SeekableByteChannel、ScatteringByteChannel和GatheringByteChannel接口的方法,这里不再赘述。下 面要介绍的是FileChannel类中独有的方法。首先介绍在文件通道的任意位置进行读写的能力。调用ReadableByteChannel接口中的read方法和 WritableByteChannel接口中的write方法都只能进行相对读写操作。而对于FileChannel类来说,得益于文件本身的特性,可以在任意绝对位置 进行读写操作,只需额外传入一个参数来指定读写的位置即可。在代码清单3-8中,对于一个新创建的文件,同样可以指定任意的写入位置。文 件的大小会根据写入的位置自动变化。 代码清单3-8 对文件通道的绝对位置进行读写操作的示例 public void readWriteAbsolute()throws IOException{ FileChannel channel=FileChannel.open(Paths.get("absolute.txt"),StandardOpenOption.READ, StandardOpenOption.CREATE, StandardOpenOption.WRITE); ByteBuffer writeBuffer=ByteBuffer.allocate(4).putChar('A').putChar('B'); writeBuffer.flip(); channel.write(writeBuffer,1024); ByteBuffer readBuffer=ByteBuffer.allocate(2); channel.read(readBuffer,1026); readBuffer.flip(); char result=readBuffer.getChar();//值为'B' } 2.文件数据传输 在使用文件进行I/O操作时的一些典型场景包括把来自其他实体的数据写入文件中,以及把文件中的内容读取到其他实体中,按照通道的概 念来说,就是文件通道和其他通道之间的数据传输。对于这种常见的需求,FileChannel类提供了transferFrom和transferTo方法用来快速地传 输数据,其中transferFrom方法把来自一个实现了ReadableByteChannel接口的通道中的数据写入文件通道中,而transferTo方法则把当前文件 通道的数据传输到一个实现了WritableByteChannel接口的通道中。在进行这两种方式的数据传输时都可以指定当前文件通道中的传输的起始位 置和数据长度。 使用FileChannel类中的这两个数据传输方法比传统的使用缓冲区进行循环读取的做法要简单,性能也更好。这主要是因为这两个方法在实 现中尽可能地使用了底层操作系统的支持。比如,当需要通过HTTP协议来获取一个网页的内容并保存在文件中时,可以使用代码清单3-9中的代 码实现。 代码清单3-9 使用文件通道保存网页内容的示例 public void loadWebPage(String url)throws IOException{ try(FileChannel destChannel=FileChannel.open(Paths.get("content.txt"),StandardOpenOption.WRITE, StandardOpenOption.CREATE)){ InputStream input=new URL(url).openStream(); ReadableByteChannel srcChannel=Channels.newChannel(input); destChannel.transferFrom(srcChannel,0,Integer.MAX_VALUE); } } 在代码清单3-9中,打开一个HTTP连接并获取到其对应的数据输入流之后,可以将其转换成一个通道,最后通过transferFrom方法来把通道 中的内容写入文件中。这里使用了try-with-resources语句来简化对通道的使用。 文件通道中的这两个传输方法的另一个重要的好处是可以简洁和高效地实现文件的复制。在前面的章节中介绍过使用字节数组作为缓冲区 的文件复制操作的基本实现方式。如果采用传统的循环读取的方式,使用新的ByteBuffer类会比字节数组简单一些,如代码清单3-10所示。使 用ByteBuffer类的时候并不需要记录每次实际读取的字节数,但是要注意flip和compact方法的使用。 代码清单3-10 使用字节缓冲区进行文件复制操作的示例 public void copyUseByteBuffer()throws IOException{ ByteBuffer buffer=ByteBuffer.allocateDirect(32*1024); try(FileChannel src=FileChannel.open(Paths.get(srcFilename),StandardOpenOption.READ); FileChannel dest=FileChannel.open(Paths.get(destFilename),StandardOpenOption.WRITE, StandardOpenOption.CREATE)){ while(src.read(buffer)>0||buffer.position()!=0){ buffer.flip(); dest.write(buffer); buffer.compact(); } } } 如果使用FileChannel类中的传输方法来实现,代码就更加简单了,如代码清单3-11所示,进行复制的逻辑只需要一行代码即可。 代码清单3-11 使用文件通道进行文件复制的示例 public void copyUseChannelTransfer()throws IOException{ try(FileChannel src=FileChannel.open(Paths.get(srcFilename), StandardOpenOption.READ); FileChannel dest=FileChannel.open(Paths.get(destFilename),StandardOpenOption.WRITE, StandardOpenOption.CREATE)){ src.transferTo(0,src.size(),dest); } } 3.内存映射文件 在对大文件进行操作时,性能问题一直比较难处理。通过操作系统的内存映射文件支持,可以比较快速地对大文件进行操作。内存映射文 件的原理在于把系统的内存地址映射到要操作的文件上。读取这些内存地址就相当于读取文件的内容,而改变这些内存地址的值就相当于修改 文件中的内容。被映射到内存地址上的文件在使用上类似于操作系统中使用的虚拟内存文件。通过内存映射的方式对文件进行操作时,不再需 要通过I/O操作来完成,而是直接通过内存地址访问操作来完成,这就大大提高了操作文件的性能,因为I/O操作比访问内存地址要慢得多。 FileChannel类的map方法可以把一个文件的全部或部分内容映射到内存中,所得到的是一个ByteBuffer类的子类MappedByteBuffer的对 象,程序只需要对这个MappedByteBuffer类的对象进行操作即可。对这个MappedByteBuffer类的对象所做的修改会自动同步到文件内容中。代 码清单3-12给出了使用文件通道的内存映射功能的一个示例。在进行内存映射时需要指定映射的模式,一共有3种可用的模式,由 FileChannel.MapMode这个枚举类型来表示:READ_ONLY表示只能对映射之后的MappedByteBuffer类的对象进行读取操作;READ_WRITE表示是可 读可写的;而PRIVATE的含义是通过MappedByteBuffer类的对象所做的修改不会被同步到文件中,而是被同步到一个私有的复本中。这些修改对 其他同样映射了该文件的程序是不可见的。如果希望对MappedByteBuffer类的对象所做的修改被立即同步到文件中,可以使用force方法。 代码清单3-12 内存映射文件的使用示例 public void mapFile()throws IOException{ try(FileChannel channel=FileChannel.open(Paths.get("src.data"),StandardOpenOption.READ, StandardOpenOption.WRITE)){ MappedByteBuffer buffer=channel.map(FileChannel.MapMode.READ_WRITE,0,channel.size()); byte b=buffer.get(1024*1024); buffer.put(5*1024*1024,b); buffer.force(); } } 如果希望更加高效地处理映射到内存中的文件,把文件的内容加载到物理内存中是一个好办法。通过MappedByteBuffer类的load方法可以 把该缓冲区所对应的文件内容加载到物理内存中,以提高文件操作时的性能。由于物理内存的容量受限,不太可能直接把一个大文件的全部内 容一次性地加载到物理内存中。可以每次只映射文件的部分内容,把这部分内容完全加载到物理内存中进行处理。完成处理之后,再映射其他 部分的内容。 由于I/O操作一般比较耗时,出于性能考虑,很多操作在操作系统内部都是使用缓存的。在程序中通过文件通道API所做的修改不一定会立 即同步到文件系统中。如果在没有同步之前发生了程序错误,可能导致所做的修改丢失。因此,在执行完某些重要文件内容的更新操作之后, 应该调用FileChannel类的force方法来强制要求把这些更新同步到底层文件中。可以强制同步的更新有两类,一类是文件的数据本身的更新, 另一类是文件的元数据的更新。在使用force方法时,可以通过参数来声明是否在同步数据的更新时也同步元数据的更新。 4.锁定文件 当需要在多个程序之间进行数据交换时,文件通常是一种很好的选择。一个程序把产生的输出保存在指定的文件中,另外一个程序进行读 取即可。双方只需要在文件的格式上达成一致就可以了,内部逻辑的实现都是独立的。但是在这种情况下,对这个文件的访问操作容易产生冲 突,而且对两个独立的应用程序来说,也没有什么比较好的方式来实现操作的同步。对于这种情况,最好的办法是对文件进行加锁。在一个程 序完成操作之前,阻止另外一个程序对该文件的访问。通过FileChannel类的lock和tryLock方法可以对当前文件通道所对应的文件进行加锁。 加锁时既可以选择锁定文件的全部内容,也可以锁定指定的范围区间中的部分内容。lock和tryLock两个方法的区别在于lock方法是阻塞式的, 而tryLock方法则不是。当成功加锁之后,会得到一个FileLock类对象。在完成对锁定文件的操作之后,通过FileLock类的release方法可以解 除锁定状态,允许其他程序来访问。FileLock类表示的锁分共享锁和排它锁两类。共享锁不允许其他程序获取到与当前锁定范围相重叠的排它 锁,而获取共享锁是允许的;排它锁不允许其他程序获取到与锁定范围相重叠的共享锁和排它锁。如果调用FileLock类的对象的isShared方法 的返回值为true,则表明是一个共享锁,否则是排它锁。 注意 对FileLock类表示的共享锁和排它锁的限制只发生在待锁定的文件范围与当前已有锁的范围发生重叠的时候。不同程序可以同时在 一个文件上加上自己的排它锁,只要这些锁的锁定范围不互相重叠即可。 一个系统可能由C++和Java等不同编程语言所编写的不同组件组成,这些组件可能共享一个配置文件。当Java程序要更新配置文件的时候, 可以先锁定该文件,再进行更新。这样可以保证更新时内容的完整性。代码清单3-13给出了一个示例的使用模板。 代码清单3-13 锁定文件的示例 public void updateWithLock()throws IOException{ try(FileChannel channel=FileChannel.open(Paths.get("settings.config"),StandardOpenOption.READ, StandardOpenOption.WRITE); FileLock lock=channel.lock()){ //更新文件内容 } } 对文件进行加锁操作的主体是当前的Java虚拟机,也就是说这个加锁的功能用来协同当前Java虚拟机上运行的Java程序和操作系统上运行 的其他程序。对于Java虚拟机上运行的多线程程序,不能用这种机制来协同不同线程对文件的访问。 3.3.2 套接字通道 除了文件之外,另外一个在应用开发中经常需要处理的I/O实体是网络连接。Java从JDK 1.0开始就有了处理网络连接的相关API,包含在 java.net包中。这其中比较重要的是套接字连接的相关API。通过套接字API可以很容易地实现自己的网络服务器和客户端程序。 如果需要实现一个网络客户端程序,只需要先创建一个java.net.Socket类的对象,再连接到远程服务器。当连接成功之后,可以从Socket 类的对象中获取到套接字连接对应的输入流和输出流。通过输入流可以得到服务器端发送的数据,而通过输出流可以向服务器端发送数据。对 服务器端程序来说,则可以创建一个java.net.ServerSocket类的对象并使用其accept方法在指定的端口进行监听。调用方法accept时会处于阻 塞状态,等待客户端程序的连接请求。当有新的连接建立时,accept方法会返回一个与连接发起者进行通信时所使用的Socket类的对象。 从上面的简要描述可以看出,java.net包中的套接字的相关实现在使用时是比较简单的,所包含的概念也并不多,且结合了已有的I/O操作 中流的概念。开发人员通过类比文件操作的方式,可以很容易理解套接字的使用。实际上,java.net包中的套接字实现的最大问题不是来自于 API本身,而是出于性能方面的考虑。Socket类和ServerSocket类中提供的与建立连接和数据传输相关的方法都是阻塞式的,也就是说,如果操 作没有完成,当前线程会处于等待状态。比如,通过调用Socket类的connect方法连接远程服务器时,如果由于网络的原因,连接一直没有办法 成功建立,那么connect方法会一直阻塞下去,直到连接建立成功或出现超时错误。在这段等待时间内,其他代码是无法继续执行的。相对于同 样是阻塞式的文件操作来说,网络操作的这个特性所带来的问题更为严重,这是因为网络操作的延迟远比文件操作中的延迟要长得多,而且影 响网络操作速度的因素也更多。 为了提高网络服务器和客户端的性能和吞吐量,采用多线程的方式就成了解决这个问题的“银弹”。以服务器的实现为例,在一般情况 下,会有一个线程专门用来调用ServerSocket类的accept方法来监听连接请求。一旦有新的连接建立,就会创建一个新的线程来专门处理这个 请求。这种一个请求对应一个线程的方式,显然并不适合服务器负载压力比较大的情况,因为每个线程都要占用资源,创建线程也是有代价 的。对此,一般又会引入线程池的实现,以能够复用已有的线程,从而减少每次都要新创建线程所带来的代价。采用多线程的方式确实能解决 问题。当某个线程由于等待网络操作而阻塞时,其他线程还可以继续执行,整体的性能和吞吐量得到了提高。不过多线程方式的问题在于它太 复杂了,而且容易出现非常多的隐含错误,多线程的相关内容会在第11章中进行讨论。 为了解决这些与网络操作相关的问题,Java NIO提供了非阻塞式和多路复用的套接字连接,并在Java 7中又进行了改进。与文件操作一 样,网络操作也被抽象成通道的概念,接口java.nio.channels.NetworkChannel表示的是一个套接字所对应的通道。 1.阻塞式套接字通道 与Socket类和ServerSocket类相对应,Java NIO中也提供了SocketChannel类和ServerSocketChannel类两种不同的套接字通道实现。这两 种通道都支持阻塞和非阻塞两种模式。阻塞模式的使用简单,但是性能不是很好;非阻塞模式则正好相反。开发人员可以根据自己的需要来选 择合适的模式。一般来说,低负载的程序可以选择阻塞模式,实现起来简单且性能足够好。代码清单3-9中给出的保存网页内容的示例程序,也 可以通过SocketChannel类来实现,如代码清单3-14所示。先通过SocketChannel类的open方法来打开对远程服务器的连接。如果在调用open方 法时提供了远程服务器的地址作为参数,那么open方法会直接调用connect方法进行连接;否则还需要显式地调用connect方法进行连接。在默 认情况下,open方法的调用是阻塞式的。当连接成功之后,就可以向套接字通道中写入数据。这里写入的是HTTP请求信息。写入完成之后可以 进行读取操作,读取服务器端返回的HTTP响应的内容。这里把通道的内容传输到文件中。从这里可以看出通道相对于流的灵活性,是它不再需 要显式地去获取输入流或输出流的对象,而是可以直接进行读写操作。 代码清单3-14 阻塞式客户端套接字的使用示例 public void loadWebPageUseSocket()throws IOException{ try(FileChannel destChannel=FileChannel.open(Paths.get("content.txt"),StandardOpenOption.WRITE, StandardOpenOption.CREATE); SocketChannel sc=SocketChannel.open(new InetSocketAddress("www.baidu.com",80))){ String request="GET/HTTP/1.1\r\n\r\nHost:www.baidu.com\r\n\r\n"; ByteBuffer header=ByteBuffer.wrap(request.getBytes("UTF-8")); sc.write(header); destChannel.transferFrom(sc,0,Integer.MAX_VALUE); } } 对于ServerSocketChannel类的阻塞模式的使用也比较直接。代码清单3-15给出了一个简单的使用ServerSocketChannel类的服务器端程序 的示例。通过open方法打开一个新的套接字通道。当通道打开之后,需要通过调用bind方法将其绑定到某个地址上。这里绑定到了本机的10800 端口上。绑定成功之后,就可以在这个端口上监听客户端的连接请求。ServerSocketChannel类的accept方法会阻塞直到有新的连接发生。当有 新的连接建立时,可以通过从accept方法得到的SocketChannel类的对象来与发起连接的客户端进行数据传输。这里只简单地发送一个字符串给 客户端之后就关闭连接。 代码清单3-15 阻塞式服务器端套接字的使用示例 public void startSimpleServer()throws IOException{ ServerSocketChannel channel=ServerSocketChannel.open(); channel.bind(new InetSocketAddress("localhost",10800)); while(true){ try(SocketChannel sc=channel.accept()){ sc.write(ByteBuffer.wrap("Hello".getBytes("UTF-8"))); } } } 套接字通道的阻塞模式总体来说与java.net包中的Socket类和ServerSocket类的使用方式非常类似,区别在于使用了NIO中新的通道的概 念。 2.多路复用套接字通道 如果程序对网络操作的并发性和吞吐量的要求比较高,那么阻塞式的套接字通道就不能比较简单地满足程序的需求。这时比较好的办法是 通过非阻塞式的套接字通道实现多路复用或者使用NIO.2中的异步套接字通道。 套接字通道的多路复用的思想比较简单,通过一个专门的选择器(selector)来同时对多个套接字通道进行监听。当其中的某些套接字通 道上有它感兴趣的事件发生时,这些通道会变为可用的状态,可以在选择器的选择操作中被选中。选择器通过一次选择操作可以获取这些被选 中的通道的列表,然后根据所发生的事件类型分别进行处理。这种基于选择器的做法的优势在于可以同时管理多个套接字通道,而且可用通道 的选择一般是通过操作系统提供的底层系统调用来实现的,性能也比较高。 多路复用的实现方式的核心是选择器,即java.nio.channels.Selector类的对象。非阻塞式的套接字通道可以通过register方法注册到某 个Selector类的对象上,以声明由该Selector类的对象来管理当前这个套接字通道。在进行注册时,需要提供一个套接字通道感兴趣的事件的 列表。这些事件包括连接完成、接收到新连接请求、有数据可读和可以写入数据等。这些事件定义在java.nio.channels.SelectionKey类中。 在完成注册之后,可以调用Selector类的对象的select方法来进行选择。选择操作完成之后,可以从Selector类的对象中得到一个可用的套接 字通道的列表。对于这个列表中的套接字通道来说,至少有一个它注册时声明的感兴趣的事件发生了。接着就可以根据事件的类型来进行相应 的处理。一个套接字通道只有在通过configureBlocking方法设置为非阻塞模式之后,才能被注册到选择器上。套接字通道在非阻塞模式下的读 取和写入操作与阻塞模式下差别很大,在使用时需要格外注意。比如在进行读取操作时,非阻塞式套接字通道的read方法只会读取当时立即可 以获取的数据,而不会等待数据的到来。因此,有可能在一次read方法调用中没有读取到任何数据。 下面用一个完整的示例来说明Selector类和非阻塞式SocketChannel类如何结合起来使用。示例的场景仍然是代码清单3-9中给出的通过套 接字连接来下载一个网页的内容,只不过需求变成同时下载多个网页的内容。如果用传统的阻塞式套接字通道的方式,那么可以启动多个线程 来完成。这里要介绍的是在一个线程中使用Selector类的做法。完整的实现如代码清单3-16所示。通过代码中LoadWebPageUseSelector类的 load方法就可以下载多个网页的内容到本地。 代码清单3-16 选择器的使用示例 public class LoadWebPageUseSelector{ public void load(Set<URL>urls)throws IOException{ Map<SocketAddress, String>mapping=urlToSocketAddress(urls); Selector selector=Selector.open(); for(SocketAddress address:mapping.keySet()){ register(selector, address); } int finished=0,total=mapping.size(); ByteBuffer buffer=ByteBuffer.allocate(32*1024); int len=-1; while(finished<total){ selector.select(); Iterator<SelectionKey>iterator=selector.selectedKeys().iterator(); while(iterator.hasNext()){ SelectionKey key=iterator.next(); iterator.remove(); if(key.isValid()&&key.isReadable()){ if(key.isValid()&&key.isReadable()){ SocketChannel channel=(SocketChannel)key.channel(); InetSocketAddress address=(InetSocketAddress)channel.getRemoteAddress(); String filename=address.getHostName()+".txt"; FileChannel destChannel=FileChannel.open(Paths.get(filename),StandardOpenOption.APPEND, StandardOpenOption.CREATE); buffer.clear(); while((len=channel.read(buffer))>0||buffer.position() !=0){ buffer.flip(); destChannel.write(buffer); buffer.compact(); } if(len==-1){ finished++; key.cancel(); } }else if(key.isValid()&&key.isConnectable()){ SocketChannel channel=(SocketChannel)key.channel(); boolean success=channel.finishConnect(); if(!success){ finished++; key.cancel(); }else{ InetSocketAddress address=(InetSocketAddress)channel.getRemoteAddress(); String path=mapping.get(address); String request="GET"+path+"HTTP/1.0\r\n\r\nHost:"+address.getHostString()+"\r\n\r\n"; ByteBuffer header=Byte Buffer.wrap(request.getBytes("UTF-8")); channel.write(header); } } } } } private void register(Selector selector, SocketAddress address)throws IOException{ SocketChannel channel=SocketChannel.open(); channel.configureBlocking(false); channel.connect(address); channel.register(selector, SelectionKey.OP_CONNECT|SelectionKey.OP_READ); } private Map<SocketAddress, String>urlToSocketAddress(Set<URL>urls){ Map<SocketAddress, String>mapping=new HashMap<>(); for(URL url:urls){ int port=url.getPort()!=-1?url.getPort():url.getDefaultPort(); SocketAddress address=new InetSocketAddress(url.getHost(),port); String path=url.getPath(); if(url.getQuery()!=null){ path=path+"?"+url.getQuery(); } mapping.put(address, path); } return mapping; } } 在使用选择器之前,首先需要创建它。通过Selector类的open方法可以创建一个新的选择器。有了选择器之后,下一步是把套接字通道注 册到选择器上,这一步在私有方法register中完成。在register方法中,会首先创建连接HTTP服务器的套接字通道,并通过configureBlocking 方法将通道设置成非阻塞模式,最后再注册到选择器上。在注册时要指定套接字通道感兴趣的事件。由于程序只需要连接远程服务器并进行读 取操作,这里指定了套接字通道只对连接完成(OP_CONNECT)和通道有数据可读(OP_READ)两种事件感兴趣。套接字通道注册完成之后,下一 步就是调用Selector类的对象的select方法来进行通道选择操作。直接调用select方法是会阻塞的,直到所监听的套接字通道中至少有一个它 们所感兴趣的事件发生为止。在执行完select方法之后,通过调用selectedKeys方法可以获取到表示被选中的通道的SelectionKey类的对象的 集合。每个SelectionKey类的对象与一个被监听的通道相对应。接下来的操作就是对选中的每一个SelectionKey类的对象所发生的是什么类型 的事件进行判断,再进行相应的处理。 以连接完成的事件来说,这次连接可能成功也可能失败。通过SocketChannel类的对象的finishConnect方法可以完成连接,同时判断连接 是否成功建立。如果连接建立失败,那么通过SelectionKey类的对象的cancel方法可以取消选择器对此通道的管理;如果连接建立成功,那么 应该向通道中写入HTTP的请求头信息,相当于向HTTP服务器发送HTTP请求。当HTTP服务器返回网页内容时,套接字通道会变成可读的状态。这 个状态会在下一次调用select方法时被选中。在通道处于可读时的处理逻辑是读取通道中的数据,并写入到文件中。对于一个通道来说,由于 数据是持续不断地传输的,所以通道可能多次处于可读的状态。如果read方法的返回值为0,说明本次没有数据可读,不需要额外的操作;如果 read方法返回值为-1,说明该通道的所有数据已经读取完毕,应该通过对应的SelectionKey类的对象的cancel方法取消对此通道的监听。 代码清单3-16的示例虽然没有使用多线程,但是如果在运行时输出相关调试信息,会发现来自不同服务器的数据的读取操作是交错进行 的。这是因为当某个通道的数据暂时还在传输中时,可能另外一个通道的数据已经准备就绪,可以进行读取了。通过这种多路复用的特性,使 程序尽可能地利用网络操作本身的特性来提高性能和吞吐量,而不是依靠多线程带来的并发性。 3.4 NIO.2 Java NIO在Java I/O库的基础上增加了通道的概念,提供了I/O操作的性能,使用起来也更加简单。Java 7中的I/O库得到了进一步增强, 称为NIO.2。NIO.2中包含的主要内容包括文件系统访问和异步I/O通道。 3.4.1 文件系统访问 3.3.1节介绍了文件通道相关的内容。相对于传统的基于java.io.File类的文件操作来说,文件通道在某些方面更加简单和高效,但还是有 一些与文件相关的操作需要依靠File类来完成,比如列出某个目录下的所有文件的操作。File类中的操作存在一些不方便开发人员使用的地 方,对此,Java 7加强了文件操作相关的功能,即新的java.nio.file包,其所提供的新功能包括文件路径的抽象、文件目录列表流、文件目录 树遍历、文件属性和文件变化监视服务等。 1.文件路径的抽象 在使用java.io.File类来操作文件时,需要以字符串的方式指定文件的路径。虽然文件路径本身最终的表现形式是字符串,但是直接利用 字符串来进行与路径相关的操作时,丢失了路径本身的语义。比如,一个路径中可能包含多个子部分,每个部分表示一个目录或是文件,如果 希望把两个路径连接起来得到一个新的路径,传统的做法是进行字符串相加。这种做法不是类型安全的,要正确无误地实现也不是那么容易。 NIO.2中引入的java.nio.file.Path接口作为文件系统中路径的一个抽象,很好地解决了这个问题。Path接口除了带来了类型安全的好处之外, 还提供了操作路径的很多实用方法,使开发人员不再需要编写很多重复的代码。类型安全的重要性是很明显的。在有了Path接口之后,就不会 在一个需要使用文件路径的地方将一个随意的字符串作为参数传递进去。在使用String类的对象来表示文件路径时,这种错误是很可能发生 的。 Path接口相当于将一个字符串表示的文件路径重新细分,使之赋有语义。以一个典型的文件路径“C:\foo\bar\myfile.txt”为例,如果 得到了它对应的Path接口的实现,那么Path接口实现对象中的“根”指的是“C:\”,可以通过getRoot方法来获取,路径中通过路径分隔符隔 开的每一个部分都成为其中的名称元素。该路径有3个名称元素,分别是表示目录名或文件名的“foo”、“bar”和“myfile.txt”,可以通过 getNameCount获取到名称元素的总数,通过getName来获取单个名称元素;在路径中距离根最远的名称元素,称为该路径的文件名,可以通过 getFileName方法来获取,这里的值是“myfile.txt”;通过getParent可以获取到当前路径的父路径,这里的值是“C:\foo\bar”。 有了Path接口之后,对文件路径进行的很多操作变得很简单,不再需要依靠复杂的字符串操作。代码清单3-17给出了通过Path接口操作文 件路径的示例,每个方法调用的后面用注释的形式给出了运行结果。Path接口中resolve方法的作用相当于把当前路径当成父目录,而把参数中 的路径当成子目录或是其中的文件,进行解析之后得到一个新路径;resolveSibling方法的作用与resolve方法类似,只不过把当前路径的父目 录当成解析时的父目录;relativize方法的作用与resolve方法正好相反,用来计算当前路径相对于参数中给出的路径的相对路径;subpath方 法用来获取当前路径的子路径,参数中的序号表示的是路径中名称元素的序号;startsWith和endsWith方法用来判断当前路径是否以参数中的 路径开始或结尾。在一般的路径中,“.”和“..”分别用来表示当前目录和上一级目录。通过normalize方法可以去掉路径中 的“.”和“..”。所有这些方法的返回值都是Path接口的实现对象,因此这些方法可以很容易地级联起来。 代码清单3-17 Path接口的使用示例 public void usePath(){ Path path1=Paths.get("folder1","sub1"); Path path2=Paths.get("folder2","sub2"); path1.resolve(path2);//folder1\sub1\folder2\sub2 path1.resolveSibling(path2);//folder1\folder2\sub2 path1.relativize(path2);//..\..\folder2\sub2 path1.subpath(0,1);//folder1 path1.startsWith(path2);//false path1.endsWith(path2);//false Paths.get("folder1/./../folder2/my.text").normalize();//folder2\my.text } 2.文件目录列表流 当需要列出一个目录下的子目录和文件时,传统的做法是使用File类中的list或listFiles方法。不过这两个方法在目录中包含的文件数量 很多的时候,性能比较差。NIO.2中引入了一个新的接口java.nio.file.DirectoryStream来支持这种遍历操作。DirectoryStream接口继承了 java.lang.Iterable接口,使DirectoryStream接口的实现对象可以直接在增强的for循环中使用。DirectoryStream接口的优势在于它渐进式地 遍历文件,每次只读取一定数量的内容,从而可以降低遍历时的开销。在实际的使用中,DirectoryStream接口的实现对象是通过 java.nio.file.Files类的工厂方法来创建的。在创建时,可以指定遍历时的过滤条件,即满足何种条件的目录和文件才会被包括进来。指定遍 历条件时既可以使用DirectoryStream.Filter接口实现类的对象,也可以使用字符串来表示简单的模式。代码清单3-18中给出了遍历当前目录 下所有的“.java”文件的示例。 代码清单3-18 目录列表流的使用示例 public void listFiles()throws IOException{ Path path=Paths.get(""); try(DirectoryStream<Path>stream=Files.newDirectoryStream(path,"*.java")){ for(Path entry:stream){ //使用entry } } } 如果希望程序自己来遍历DirectoryStream接口实现对象中的条目,可以通过iterator方法获取到DirectoryStream接口实现对象的迭代器 对象。不过iterator方法只能调用一次,得到唯一的一个迭代器对象。如果在遍历过程中,目录中的文件发生了变化,这种变化可能会被迭代 器捕获到,也可能不会。程序不应该依赖迭代器来发现这些变化,更好的做法是使用目录监视服务。 3.文件目录树遍历 DirectoryStream接口只能遍历当前目录下的直接子目录或文件,并不会递归地遍历子目录下的子目录。如果希望对整个目录树进行遍历, 需要使用java.nio.file.FileVisitor 接口。FileVisitor接口是典型的访问者模式的实现。在这个接口中定义了4个方法,分别表示对目录树的不同访问动作。首先是visitFile 方法,它表示正在访问某个文件;其次是visitFileFailed方法,它表示访问某个文件时出现了错误;接着是preVisit-Directory方法,它表示 在访问一个目录中包含的子目录和文件之前被调用;最后是postVisitDirectory方法,它表示在访问一个目录的全部子目录中的内容之后被调 用。这4个方法都返回java.nio.file.FileVisitResult枚举类型,用来声明整个遍历过程的下一步动作。在这些枚举值中:CONTINUE表示继续 进行正常的遍历过程;SKIP_SIBLINGS表示跳过当前目录或文件的兄弟节点;SKIP_SUBTREE表示不再遍历当前目录中的内容;TERMINATE表示立 即结束整个遍历过程。通过实现这4个方法并根据情况返回相应的遍历动作,程序可以很容易地控制整个遍历过程,并在遍历中对整个目录树进 行修改。 下面通过一个具体的示例来进行说明。在开发过程中我们有时候会使用Subversion作为源代码配置管理的工具。Subversion会在其管理的 目录下面添加“.svn”子目录来保存其所需的元数据。如果直接把整个目录复制给其他人,会发现“.svn”目录也包含在其中。这个目录是没 有必要存在的。代码清单3-19给出了一个遍历某个目录并清除其中包含的“.svn”目录的FileVisitor接口的实现。SvnInfoCleanVisitor类并 没有直接实现FileVisitor接口,而是继承自java.nio.file.SimpleFileVisitor类。SimpleFileVisitor类是一个简单的FileVisitor接口的适 配器。通过继承SimpleFileVisitor类的方式可以不必实现FileVisitor接口中的全部方法。在进行遍历的过程中,如果遇到一个名称 为“.svn”的目录,则说明需要将此目录下的所有子目录和文件删除。由于“.svn”目录中的文件是只读的,因此在删除文件时,需要先取消 对只读属性的设置,再进行删除。在进行删除操作时,需要先删除目录中包含的文件,再删除该目录。 代码清单3-19 删除Subversion元数据的目录遍历方式 public class SvnInfoCleanVisitor extends SimpleFileVisitor<Path>{ private boolean cleanMark=false; private boolean isSvnFolder(Path dir){ return".svn".equals(dir.getFileName().toString()); } public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)throws IOException{ if(isSvnFolder(dir)){ cleanMark=true; } return FileVisitResult.CONTINUE; } public FileVisitResult postVisitDirectory(Path dir, IOException e)throws IOException{ if(e==null&&cleanMark){ Files.delete(dir); if(isSvnFolder(dir)){ cleanMark=false; } } return FileVisitResult.CONTINUE; } public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)throws IOException{ if(cleanMark){ Files.setAttribute(file,"dos:readonly",false); Files.delete(file); } return FileVisitResult.CONTINUE; } } 4.文件属性 文件属性是文件除了本身的数据之外的元数据。常见的属性包括是否只读、是否为隐藏文件、上次访问时间和所有者信息等。在Java 7之 前,Java标准库只有少量与文件属性相关的方法,主要在File类中。这些方法的功能比较弱,而且也不够系统。在Java 7中,NIO.2提供了专门 的java.nio.file.attribute包来对文件属性进行处理。由于不同操作系统上的文件系统对文件属性的支持是不同的,NIO.2对文件属性进行了 抽象,采用了文件属性视图的概念。每个属性视图中包含了可以从这个视图中获取和设置的各种属性。不同的视图所包含的属性是不一样的。 每个属性视图都有自己的名称。 接口java.nio.file.attribute.AttributeView是所有属性视图的父接口。AttributeView的子接口 java.nio.file.attribute.FileAttributeView表示的是文件的属性视图。FileAttributeView接口表示的属性视图分为两类,一类是由 java.nio.file.attribute.BasicFileAttributeView接口表示的包含基本文件属性的视图,另外一类是由 java.nio.file.attribute.FileOwnerAttributeView接口表示的包含文件所有者信息的属性视图。调用BasicFileAttributeView接口的 readAttributes方法可以获取到表示文件基本属性的java.nio.file.attribute.BasicFileAttributes接口的实现对象。BasicFileAttributes 接口中包含了类型安全的方法,这些方法用来获取不同文件系统中的通用属性,包括创建时间(creationTime)、上次访问时间 (lastAccessTime)、上次修改时间(lastModifiedTime)、是否是目录(isDirectory)、是否为普通文件(isRegularFile)、是否为符号 链接(isSymbolicLink)和文件大小(size)等。FileOwnerAttributeView接口的getOwner和setOwner方法可以用来获取和设置文件的所有者 信息。 Windows操作系统中的文件系统一般使用遗留的“DOS”文件属性视图,由java.nio.file.attribute.DosFileAttributeView接口来表示。 DosFileAttributeView接口中包含的属性有是否为只读文件(readonly)、是否为隐藏文件(hidden)、是否为系统文件(system)和是否为 归档文件(archive)等。DosFileAttributeView接口中包含了设置这些属性的方法。如果需要获取属性的值,先通过readAttributes方法获取 到java.nio.file.attribute.DosFileAttributes接口的实现对象,再调用该实现对象中的对应方法来获取属性值。与DosFileAttributeView接 口相对应的是java.nio.file.attribute.PosixFileAttributeView接口,表示UNIX和Linux系统使用的POSIX文件属性视图。 PosixFileAttributeView接口中包含的属性有所有者信息(group)和权限信息(permissions)。PosixFileAttributeView接口的使用方式类 似于DosFileAttributeView接口,使用PosixFileAttributeView接口本身提供的方法来进行属性设置;通过readAttributes方法获取 java.nio.file.attribute.PosixFileAttributes接口的实现对象来读取属性的值。 上面介绍的属性视图相关的表示方式都是接口。实际的获取和设置文件属性的操作是通过Files类中的静态方法来完成的。Files类提供的 与文件属性相关的方法比较多,分成获取和设置属性两类。获取属性的方式有两种:一种是获取FileAttributeView接口或 BasicFileAttributes接口的实现对象后,再调用相应的方法来获取属性的值;另外一种是直接指定属性的名称来获取相应的值。Files类中的 getFileAttributeView方法用来获取FileAttributeView接口的实现对象,在调用时需要指定所要获取的属性视图的类名。代码清单3-20通过 getFileAttributeView方法获取DosFileAttributeView接口中包含的文件的“是否只读”属性的值。 代码清单3-20 文件属性视图的使用示例 public void useFileAttributeView()throws IOException{ Path path=Paths.get("content.txt"); DosFile Attribute View view=Files.getFile Attribute View(path, DosFileAttributeView.class); if(view!=null){ DosFileAttributes attrs=view.readAttributes(); System.out.println(attrs.isReadOnly()); } } 在代码清单3-20中,获取到DosFileAttributeView接口的实现对象之后,还需要调用其readAttributes方法来获取具体的包含属性的对 象。从简化使用的角度出发,Files类中提供了readAttributes方法来直接获取特定类型的BasicFileAttributes接口的实现对象。 Files类中的readAttributes和getAttribute方法可以根据属性名称来获取对应的值。不同之处在于readAttributes方法可以批量获取一组 属性的值,而getAttribute方法只能获取一个属性的值。使用这两个方法时需要指定文件的路径及属性的名称。属性的名称是带名称空间的, 其前缀是属性所在的属性视图的名称,比如“DOS”文件属性视图中的“是否为隐藏文件”属性的完整名称是“dos:hidden”。在不带前缀的 情况下,默认属性来自基本属性视图。在调用readAttributes方法时,多个属性名称之间用逗号分隔即可。代码清单3-21给出了一个通过检查 文件的上次修改时间来判断文件是否需要更新的示例,文件的上次修改时间对应的属性名称是“lastModifiedTime”。由 于“lastModifiedTime”属性在基本属性视图中,因此使用时不需要添加视图名称作为前缀。文件属性中的创建时间、上次修改时间和上次访 问时间都是由java.nio.file.attribute.FileTime类的对象来表示的。 代码清单3-21 获取文件的上次修改时间的示例 public boolean checkUpdatesRequired(Path path, int intervalInMillis)throws IOException{ FileTime lastModifiedTime=(FileTime)Files.getAttribute(path,"lastModifiedTime"); long now=System.currentTimeMillis(); return now-lastModifiedTime.toMillis()>intervalInMillis; } 在设置文件属性值时,也有两种对应的方式:一种是使用Files类的getFile-AttributeView方法获取到FileAttributeView接口的实现对象 之后,通过该对象提供的方法来进行设置;另外一种是调用Files的setAttribute方法,设置时使用的是属性名称。Files类中也提供了一些快 捷的方法来获取和设置常见文件属性的值,比如getOwner/setOwner和getLastModifiedTime/setLastModifiedTime等实用方法。 5.监视目录变化 在实际开发中可能会需要监视某个目录下的文件所发生的变化,比如支持热部署的Web容器需要监视某个特定目录下是否出现新的待部署的 Web应用的归档文件。另外一个场景是程序的输入来自某个特定目录下面的文本文件,要求每出现一个文件就立即进行处理。在Java 7之前,这 种目录监视功能需要开发人员自己来实现。一般的做法是:在一个独立的线程中使用File类的listFiles方法来定时检查目录中的内容,并与之 前的内容进行比较,从而判断是否有新的文件出现,文件内容是否被修改或被删除。NIO.2中提供了新的目录监视服务,使用该服务可以在指定 目录中的子目录或文件被创建、更新或删除时得到事件通知。基于这些通知,程序可以进行相应的处理。 目录监视服务的使用方式类似于3.3.2节介绍的非阻塞式套接字通道与选择器的使用方式,被监视的对象要实现java.nio.file.Watchable 接口,并通过register方法注册到表示监视服务的java.nio.file.WatchService接口的实现对象上,注册时需要指定被监视对象感兴趣的事件 类型。注册成功之后,调用者可以得到一个表示这次注册行为的java.nio.file.WatchKey接口的实现对象,其作用类似于SelectionKey类。通 过WatchKey接口可以获取在对应的被监视对象上所产生的事件。每个事件用java.nio.file.WatchEvent接口来表示。与Selector类中的select 方法一样,WatchService接口也提供了类似的方法来获取当前所有被监视的对象上的可用事件。查询的方式也分成阻塞式和非阻塞式两种:阻 塞式方式使用的是take方法,而非阻塞式方式使用的是poll方法。查询结果的返回值是WatchKey接口的实现对象。调用WatchKey接口的 pollEvents方法可以得到对应被监视对象上所发生的所有事件。 代码清单3-22中的代码会监视当前的工作目录,当有新的文件被创建时,输出该文件的大小。WatchService接口的实现对象是由工厂方法 创建的,需要从表示文件系统的java.nio.file.FileSystem类的对象中得到。目前,唯一可以被监视的对象只有Path接口的实现对象。可以被 监视的事件包括创建或重命名(ENTRY_CREATE)、更新(ENTRY_MODIFY)和删除(ENTRY_DELETE)。这些事件定义在 java.nio.file.StandardWatchEventKinds类中。当有事件发生时,通过对应的WatchKey接口的实现对象的pollEvents方法获取所有的事件。 WatchEvent接口的context方法的返回值表示的是事件的上下文信息。在与目录内容变化相关的事件中,上下文信息是一个Path接口的实现对 象,表示的是产生事件的文件路径相对于被监视路径的相对路径,因此需要使用Path接口的resolve方法来得到完整的路径。在处理完一个 WatchKey接口实现对象中的全部事件之后,需要通过reset方法来进行重置。只有在重置之后,新产生的同类事件才有可能从WatchService接口 实现对象中再次获取。 代码清单3-22 目录监视服务的使用示例 public void calculate()throws IOException, InterruptedException{ WatchService service=FileSystems.getDefault().newWatchService(); Path path=Paths.get("").toAbsolutePath(); path.register(service, StandardWatchEventKinds.ENTRY_CREATE); while(true){ WatchKey key=service.take(); for(WatchEvent<?>event:key.pollEvents()){ Path createdPath=(Path)event.context(); createdPath=path.resolve(createdPath); long size=Files.size(createdPath); System.out.println(createdPath+"==>"+size); } key.reset(); } } 如果希望取消对一个目录的监视,只需要调用对应WatchKey接口实现对象的cancel方法即可。 6.文件操作的实用方法 在程序中进行文件操作时,经常会使用一些通用操作。Files类中提供了一系列的静态方法,可以满足很多常见的需求。在前面给出的示例 代码中,大量用到了Files类。 Files类中提供了创建目录和文件的功能。Files类中提供的方法既可以创建目录和一般文件,也可以创建符号连接,还可以创建临时目录 和临时文件。在创建时可以指定新目录和文件的属性。Files类还提供了复制文件的功能,既支持从一个InputStream类的对象中读入数据到一 个文件,也支持从一个文件中读取数据并写入到一个OutputStream类的对象中,还支持两个文件之间的数据传递。这个功能类似于3.3.1节介绍 的文件通道的数据传输功能。在文件读写方面,Files类支持一次性读入文件的所有字节或所有行,也支持把一个字节数组和一组字符串写入到 文件中。除了这些之外,Files类对文件的删除和移动也提供了支持。所有这些操作在指定目录或文件时都是使用Path接口来表示的。代码清单 3-23中给出了Files类中部分实用方法的使用示例。 代码清单3-23 文件操作的实用方法的使用示例 public void manipulateFiles()throws IOException{ Path newFile=Files.createFile(Paths.get("new.txt").toAbsolutePath()); List<String>content=new ArrayList<String>(); content.add("Hello"); content.add("World"); Files.write(newFile, content, Charset.forName("UTF-8")); Files.size(newFile); byte[]bytes=Files.readAllBytes(newFile); ByteArrayOutputStream output=new ByteArrayOutputStream(); Files.copy(newFile, output); Files.delete(newFile); } 在Files中,除了有直接操作文件的方法之外,还有把文件转换成各种不同形式的方法,比如,newInputStream和newOutputStream方法可 以分别得到一个文件对应的InputStream类的对象和OutputStream类的对象;newBufferedReader和newBufferedWriter方法可以得到文件对应的 BufferedReader类的对象和BufferedWriter类的对象;newByteChannel可以得到一个实现SeekableByteChannel接口的通道对象。 3.4.2 zip/jar文件系统 NIO. 2的一个重要新特性是允许开发人员创建自定义的文件系统实现。在Java 7之前,对文件系统的操作只能使用由Java标准库提供的基 于底层操作系统支持的默认实现。Java标准库中的与文件相关的抽象,如File类,都是基于此默认实现的。在有些情况下,默认的文件系统实 现不能满足要求,在这种情况下虽然可以开发出自己的文件系统,但是在使用时并没有已有的文件系统那么直接和自然。 NIO. 2把对文件系统的表示抽象出来,形成java.nio.file.FileSystem接口。如果默认的文件系统实现不能满足要求,可以通过实现此接 口来添加自定义的实现,如创建基于内存的文件系统,或者创建分布式的文件系统。在FileSystem接口被引入之后,使用文件系统的代码不需 要关心文件系统的底层实现细节,只需要通过Java标准库的相关API来操作即可。 除了实现FileSystem接口之外,自定义文件系统的实现还需要实现java.nio.file.spi.FileSystemProvider接口,把自定义的文件系统实 现注册到Java平台中。每个文件系统都有一个对应的URI模式作为该文件系统的标识符,比如,默认的文件系统的URI模式是“file”。 FileSystemProvider接口的getScheme方法返回的是该模式的值。FileSystemProvider接口的newFileSystem方法用来创建新的文件系统实现, 即FileSystem接口的实现对象。在newFileSystem方法的实现中,需要根据作为参数传入的URI或路径创建出对应的FileSystem接口的实现对 象。FileSystemProvider接口的实现类以标准的服务提供者接口方式进行注册,所对应的服务名称 是“java.nio.file.spi.FileSystemProvider”。通过FileSystemProvider接口的installedProviders方法可以获取程序中当前可用的 FileSystemProvider接口实现类的列表。 对于使用者来说,可以通过java.nio.file.FileSystems类中的静态工厂方法来获取或创建FileSystem接口的实现对象,其中getDefault方 法用来获取默认的文件系统实现。通常的默认文件系统实现是基于底层操作系统上的文件系统的,可以通过系统参 数“java.nio.file.spi.DefaultFileSystemProvider”来设置默认的文件系统实现的Java类名。FileSystems类中的getFileSystem方法根据 URI来获取对应的FileSystem类的对象,而newFileSystem方法用来创建新的FileSystem类的对象。 Java标准库中包含了两种文件系统的实现:一种是默认的基于底层操作系统的文件系统的实现,另外一种是NIO.2中新增的操作zip和jar文 件的文件系统。Java 7之前处理zip和jar等压缩文件时使用的是java.util.zip包和java.util.jar包中的Java类。这两个包中的Java类使用起 来并不灵活。API的用法不同于一般的文件操作,比如向一个已经存在的zip文件中添加一个新文件的需求,通过java.util.zip包中的API来实 现的代码如代码清单3-24所示。基本的实现思路是先创建一个临时文件作为中转,把zip文件中已有的内容重新复制,再添加新的文件。 代码清单3-24 向已有的zip文件中添加新文件的传统做法 public void addFileToZip(File zipFile, File fileToAdd)throws IOException{ File tempFile=File.createTempFile(zipFile.getName(),null); tempFile.delete(); zipFile.renameTo(tempFile); try(ZipInputStream input=new ZipInputStream(new FileInputStream(tempFile)); ZipOut put Stream out put=newZip Output Stream(new FileOutputStream(zipFile))){ ZipEntry entry=input.getNextEntry(); byte[]buf=new byte[8192]; while(entry!=null){ String name=entry.getName(); if(!name.equals(fileToAdd.getName())){ output.putNextEntry(new ZipEntry(name)); int len=0; while((len=input.read(buf))>0){ output.write(buf,0,len); } } entry=input.getNextEntry(); } try(InputStream newFileInput=new FileInputStream(fileToAdd)){ output.putNextEntry(new ZipEntry(fileToAdd.getName())); int len=0; while((len=newFileInput.read(buf))>0){ output.write(buf,0,len); } output.closeEntry(); } } tempFile.delete(); } 如果使用NIO.2中新增的zip/jar文件系统,同样的需求可以通过更加简洁的方式来实现。这种实现方式是把一个zip/jar文件看成一个独立 的文件系统,进而使用Java提供的与各种文件操作相关的API。创建基于zip和jar文件的文件系统的方式有两种:一种是使用模式为“jar”的 URI来调用FileSystems类的newFileSystem方法;另一种是使用Path接口的实现对象来调用newFileSystem方法。如果文件路径的后缀 是“.zip”或“.jar”,会自动创建对应的zip/jar文件系统实现。得到对应的FileSystem类的对象之后,可以使用FileSystem类和Files类中 的方法来对文件进行操作。代码清单3-25使用zip/jar文件系统来实现与代码清单3-24同样的需求。相比较而言,代码清单3-25中的逻辑非常简 洁清晰,核心的代码只有一行,即调用Files类的copy方法来完成文件的复制操作。同样,可以使用Files类中的createDirectory方法在 zip/jar文件中创建新的目录,还可以使用delete方法来删除zip/jar文件中已有的目录或文件。 代码清单3-25 基于zip/jar文件系统实现的添加新文件到已有zip文件的做法 public void addFileToZip2(File zipFile, File fileToAdd)throws IOException{ Map<String, String>env=new HashMap<>(); env.put("create","true"); try(FileSystem fs=FileSystems.newFileSystem(URI.create("jar:"+zipFile.toURI()),env)){ Path pathToAddFile=fileToAdd.toPath(); Path pathInZipfile=fs.getPath("/"+fileToAdd.getName()); Files.copy(pathToAddFile, pathInZipfile, StandardCopyOption.REPLACE_EXISTING); } } 3.4.3 异步I/O通道 NIO. 2中引入了新的异步通道的概念,并提供了异步文件通道和异步套接字通道的实现。这两种异步通道在功能上类似于前面介绍过的一 般通道,只是对应功能的使用方式不相同。对于文件通道来说,一般的操作,如读取和写入,都是同步进行的,调用者会处于阻塞状态,以等 待相应操作的完成。而对于套接字通道来说,阻塞式套接字通道的使用方式与文件通道相同,而非阻塞式套接字通道的使用方式则依靠选择器 来完成。异步通道一般提供两种使用方式:一种是通过Java同步工具包中的java.util.concurrent.Future类的对象来表示异步操作的结果;另 外一种是在执行操作时传入一个java.nio.channels.CompletionHandler接口的实现对象作为操作完成时的回调方法。这两种使用方式的区别只 在于调用者通过何种方式来使用异步操作的结果。在使用Future类的对象时,要求调用者在合适的时机显式地通过Future类的对象的get方法来 得到实际的操作结果;而在使用CompletionHandler接口时,实际的调用结果作为回调方法的参数来给出。 下面先通过一个异步文件通道来介绍使用Future类的对象的做法。异步文件通道由java.nio.channels.AsynchronousFileChannel类来表 示。如代码清单3-26所示,打开一个异步文件通道的方式与使用FileChannel类的做法相似,也是通过open方法来完成的。对文件通道的读取和 写入也是通过对应的read和write方法来完成的。所不同的是read和write方法要么返回一个Future类的对象,要么要求传入一个 CompletionHandler接口的实现对象作为回调方法。这里用的是write方法返回的Future类的对象。在调用write方法之后,程序可以执行其他的 操作,然后再调用Future类的对象的get方法来获取write操作的执行结果。如果操作执行成功,get方法会返回实际写入的字符数;如果执行失 败,会抛出java.util.concurrent.ExecutionException异常。 代码清单3-26 向异步文件通道中写入数据的示例 public voidasync Write()throws IO Exception, Execution Exception, InterruptedException{ AsynchronousFileChannel channel=AsynchronousFileChannel.open(Paths.get("large.bin"),StandardOpenOption.CREATE, StandardOpenOption.WRITE); ByteBuffer buffer=ByteBuffer.allocate(32*1024*1024); Future<Integer>result=channel.write(buffer,0); //其他操作 Integer len=result.get(); } 这里需要注意的是,异步文件通道并不支持FileChannel类所提供的相对读写操作。在异步文件通道中并没有当前读写位置的概念,因此所 有的read和write方法在调用时都必须显式地指定读写操作的位置。 异步套接字通道AsynchronousSocketChannel和AsynchronousServerSocketChannel类分别对应一般的SocketChannel和 ServerSocketChannel类。代码清单3-27给出了使用AsynchronousServerSocketChannel类和CompletionHandler接口的示例。与 ServerSocketChannel类相同的是,accept方法用来接受来自客户端的连接。不过,当有新连接建立时会调用CompletionHandler接口实现对象 中的completed方法;当出现错误时,会调用failed方法。值得一提的是accept方法的第一个参数,该参数可以是一个任意类型的对象,称为调 用时的“附件对象”。附件对象在accept方法调用时传入,可以在CompletionHandler接口的实现对象中从completed和failed方法的参数中获 取,这样就可以进行数据的传递。使用CompletionHandler接口的方法都支持使用附件对象来传递数据。 代码清单3-27 异步套接字通道的使用示例 public void startAsyncSimpleServer()throws IOException{ Asynchronous Channel Groupgroup=Asynchronous Channel Group.withFixedThreadPool(10,Executors.defaultThreadFactory()); final AsynchronousServerSocketChannel serverChannel= Asynchronous Server Socket Channel.open(group).bind(new InetSocketAddress(10080)); serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>(){ public void completed(AsynchronousSocketChannel clientChannel, Void attachement){ serverChannel.accept(null, this); //使用clientChannel } public void failed(Throwable throwable, Void attachement){ //错误处理 } }); } 异步通道在处理I/O请求时,需要使用一个java.nio.channels.AsynchronousChannel-Group类的对象。AsynchronousChannelGroup类的对 象表示的是一个异步通道的分组,每一个分组都有一个线程池与之关联,这个线程池中的线程用来处理I/O事件。多个异步通道可以共享一个分 组的线程池资源。调用AsynchronousSocketChannel和AsynchronousServerSocketChannel类的open方法打开异步套接字通道时,可以传入一个 AsynchronousChannelGroup类的对象作为所使用的分组。如果调用open方法时没有传入AsynchronousChannelGroup类的对象,默认使用系统提 供的分组。需要注意的是,系统分组对应的线程池中的线程是守护线程。如果代码清单3-27中没有显式使用AsynchronousChannelGroup类的对 象,程序启动之后会很快退出,因为系统分组使用的守护线程不会阻止虚拟机退出。 创建AsynchronousChannelGroup类的对象需要使用AsynchronousChannelGroup类中的静态工厂方法withFixedThreadPool、 withCachedThreadPool或withThreadPool。创建出来的AsynchronousChannelGroup类对象需要被显式关闭,否则虚拟机不会退出。关闭的方式 类似于停止java.util.concurrent.ExecutorService接口表示的任务执行服务的做法。第11章将对ExecutorService接口进行详细介绍,可以参 考相应的关闭方式来关闭AsynchronousChannelGroup类的对象。 3.4.4 套接字通道绑定与配置 NIO. 2中新增了一个接口java.nio.channels.NetworkChannel。所有与套接字相关的通道的接口和实现类都继承或实现了NetworkChannel 接口。NetworkChannel接口是连接网络套接字的通道的抽象表示。NetworkChannel接口提供了套接字通道的绑定与配置功能。 套接字通道的绑定是把套接字绑定到本机的一个地址上。在Java 7之前,通过ServerSocketChannel类的open方法打开一个套接字通道之 后,新创建的通道处于未绑定的状态。需要调用ServerSocketChannel类的对象的socket方法先得到该通道对应的底层ServerSocket类的对象, 再调用该对象的bind方法进行绑定。利用NetworkChannel接口中bind方法可以直接进行套接字通道的绑定,使用起来简便了很多。调用bind方 法时需要提供表示套接字地址的java.net.SocketAddress类的对象。如果传入值为null,套接字通道会绑定在一个自动分配的地址上。通过 NetworkChannel接口的getLocalAddress方法可以获取当前套接字通道的实际绑定地址。 NetworkChannel接口的另外一组方法用来对套接字通道进行配置。不同的套接字通道可能提供一些配置项来允许使用者配置其行为。通过 NetworkChannel接口的supportedOptions方法可以获取套接字通道对象所支持的配置项集合。配置项是java.net.SocketOption接口的实现对 象。SocketOption接口的name和type方法分别用来获取配置项的名称和值类型。在java.net.StandardSocketOptions类中定义了一些标准的配 置项,如SO_REUSEADDR用来配置是否允许重用已有的套接字地址。NetworkChannel接口的setOption和getOption方法分别用来设置或获取配置 项的值。 3.4.5 IP组播通道 通过IP协议的组播(multicasting)支持可以将数据报文传输给属于同一分组的多个主机。当不同主机上的程序都需要接收相同的数据报 文时,使用IP组播是最自然的方式。每一条组播消息都会被属于特定分组的所有主机接收到。每个组播分组都有一个对应的标识符,该标识符 是一个D类IP地址,范围在“224.0.0.1”和“239.255.255.255”之间。进行组播的程序可以选择这个范围之内的任意一个IP地址作为分组的标 识符。主机可以选择加入某个组播分组来接收所有发送给该分组的消息。 NIO. 2中新增了java.nio.channels.MulticastChannel接口来表示支持IP组播的网络通道。已有的java.nio.channels.DatagramChannel类 实现了MulticastChannel接口。调用MulticastChannel接口的join方法可以使当前通道加入到由参数指定的组播分组中,可以接收发送给该分 组的消息。调用join方法的返回值是java.nio.channels.MembershipKey类的对象,表示该通道在组播分组中的成员身份。MembershipKey类的 作用类似于前面介绍的SelectionKey类,可以通过MembershipKey类的对象来进行管理。当不再需要接收分组中的消息时,使用MembershipKey 类的对象的drop方法可以将对应的通道移出分组;当底层操作系统提供的IP协议的实现支持对组播消息的发送来源进行过滤时,可以使用block 方法来阻止接收来自特定地址的消息,而unblock方法用来解除阻止。 下面通过一个具体示例来说明IP组播通道的使用方式。在网络中有一个主机定时向特定组播分组广播当前的时间,相关的实现如代码清单 3-28所示。通过DatagramChannel类的open方法创建新的数据报文通道并绑定之后,调用send方法向标识符为“224.0.0.2”的分组中发送消 息。 代码清单3-28 进行组播的服务器端实现 public class TimeServer{ public void start()throws IOException{ DatagramChannel dc=DatagramChannel.open(StandardProtocolFamily.INET).bind(null); InetAddress group=InetAddress.getByName("224.0.0.2"); int port=5000; while(true){ try{ Thread.sleep(2000); }catch(InterruptedException e){ break; } String str=(new Date()).toString(); dc.send(ByteBuffer.wrap(str.getBytes()),new InetSocketAddress(group, port)); } } } 相应的接收消息的实现如代码清单3-29所示。在使用DatagramChannel类的open方法创建新的数据报文通道时,需要显式指定与所使用的组 播分组地址相对应的IP协议的版本。另外,需要使用setOption方法把配置项StandardSocketOptions.SO_REUSEADDR的值设为true,允许组播分 组的不同成员绑定到相同的地址上。调用DatagramChannel类的join方法加入分组之后,可以用receive方法接收数据并进行处理。 代码清单3-29 接收组播消息的客户端实现 public class TimeClient{ public void start()throws IOException{ NetworkInterface ni=NetworkInterface.getByName("eth1"); int port=5000; try(DatagramChannel dc=DatagramChannel.open(StandardProtocolFamily.INET) .setOption(StandardSocketOptions.SO_REUSEADDR, true) .bind(new InetSocketAddress(port)) .setOption(StandardSocketOptions.IP_MULTICAST_IF, ni)){ InetAddress group=InetAddress.getByName("224.0.0.1"); MembershipKey key=dc.join(group, ni); ByteBuffer buffer=ByteBuffer.allocate(1024); dc.receive(buffer); buffer.flip(); byte[]data=new byte[buffer.limit()]; buffer.get(data); String str=new String(data); System.out.println(str);//输出时间 key.drop(); } } } 3.5 使用案例 下面通过一个具体的案例来说明如何使用Java中的I/O相关的功能。这个案例开发的是一个简单的基于文件系统中静态文件的HTTP服务器。 在实现中需要用到文件和网络I/O操作相关的API。基本的实现方式是把HTTP请求中的路径映射为文件系统中对应文件的路径,再把文件的内容 作为HTTP请求的响应。在对HTTP请求的处理中,需要对一些常见的出错情况进行处理,如文件找不到的情况。HTTP响应中也需要包含正确的 HTTP头信息来指明文件的内容类型。 完整的服务器实现如代码清单3-30所示。处理HTTP请求时采用了异步套接字通道,并用一个包含10个线程的线程池来处理请求。对于每个 HTTP请求,先读取客户端发送的请求的具体信息,从中提取出请求对应的路径,再把HTTP请求中的路径与服务器所管理的文件的根目录合并在 一起,得到完整的文件路径。如果找不到对应的文件,服务器返回404错误;如果找到对应的文件,在HTTP响应中先输出HTTP头信息,再通过 Files类的copy方法把文件输入流中包含的数据直接复制到AsynchronousSocketChannel类的对象所对应的输出流中。这一步相当于把HTTP请求 头和内容都返回给客户端。如果在读取文件内容时出错,服务器返回500错误。 代码清单3-30 基于文件系统中静态文件的HTTP服务器 public class StaticFileHttpServer{ private static final Logger LOGGER=Logger.getLogger(StaticFileHttpServer.class.getName()); private static final Pattern PATH_EXTRACTOR=Pattern.compile("GET(.*?)HTTP"); private static final String INDEX_PAGE="index.html"; public void start(final Path root)throws IOException{ Asynchronous Channel Groupgroup=Asynchronous Channel Group.with Fixed ThreadPool(10,Executors.default Thread Factory()); final Asynchronous Server Socket Channel server Channel=Asynchronous Server Socket Channel.open(group).bind(new InetSocket Address(10080)); server Channel.accept(null, new Completion Handler<Asynchronous Socket Channel, Void>(){ public void completed(AsynchronousSocketChannel clientChannel, Void attachement){ serverChannel.accept(null, this); try{ ByteBuffer buffer=ByteBuffer.allocate(1024); clientChannel.read(buffer).get(); buffer.flip(); String request=new String(buffer.array()); String requestPath=extractPath(request); Path filePath=getFilePath(root, requestPath); if(!Files.exists(filePath)){ String error404=generateErrorResponse(404,"Not Found");clientChannel.write(ByteBuffer.wrap(error404.getBytes())); return; } LOGGER.log(Level.INFO,"处理请求:{0}",requestPath); String header=generateFileContentResponseHeader(filePath); clientChannel.write(ByteBuffer.wrap(header.getBytes())).get(); Files.copy(filePath, Channels.newOutputStream(clientChannel)); }catch(Exception e){ String error=generateErrorResponse(500,"Internal Server Error"); clientChannel.write(ByteBuffer.wrap(error.getBytes())); LOGGER.log(Level.SEVERE, e.getMessage(),e); }finally{ try{ clientChannel.close(); }catch(IOException e){ LOGGER.log(Level.WARNING, e.getMessage(),e); } } } public void failed(Throwable throwable, Void attachement){ LOGGER.log(Level.SEVERE, throwable.getMessage(),throwable); } }); LOGGER.log(Level.INFO,"服务器已经启动,文件根目录为:"+root); } private String extractPath(String request){ Matcher matcher=PATH_EXTRACTOR.matcher(request); if(matcher.find()){ return matcher.group(1); } return null; } private Path getFilePath(Path root, String requestPath){ if(requestPath==null||"/".equals(requestPath)){ requestPath=INDEX_PAGE; } if(requestPath.startsWith("/")){ requestPath=requestPath.substring(1); } int pos=requestPath.indexOf("?"); if(pos!=-1){ requestPath=requestPath.substring(0,pos); } return root.resolve(requestPath); } private String getContentType(Path filePath)throws IOException{ return Files.probeContentType(filePath); } private String generateFileContentResponseHeader(Path filePath)throws IOException{ StringBuilder builder=new StringBuilder(); builder.append("HTTP/1.1 200 OK\r\n"); builder.append("Content-Type:"); builder.append(getContentType(filePath)); builder.append("\r\n"); builder.append("Content-Length:"+Files.size(filePath)+"\r\n"); builder.append("\r\n"); return builder.toString(); } private String generateErrorResponse(int statusCode, String message){ StringBuilder builder=new StringBuilder(); builder.append("HTTP/1.1"+statusCode+""+message+"\r\n"); builder.append("Content-Type:text/plain\r\n"); builder.append("Content-Length:"+message.length()+"\r\n"); builder.append("\r\n"); builder.append(message); return builder.toString(); } } 3.6 小结 I/O操作一直是程序开发中的重要组成部分。高效的I/O操作实现也是很多开发人员所追求的目标。从概念层次来说,I/O操作所表示的抽象 含义并不复杂,只是把数据从一个地方传输到另外一个地方。但是,不同的传输实体本身的特征会使在其上进行的I/O操作有各自不同的特 点,I/O操作也需要根据这些实体的特征来做出相应的调整。本章主要侧重于介绍Java I/O操作中的底层抽象和重要API的使用。如果程序是基 于Java 7来构建的,从通道开始着手是一个很好的选择,对于流则尽量少使用通道。了解Java 7增加的异步套接字通道和文件操作方面的新功 能,可以避免在开发中重复地发明一些实际上用不到的“轮子”,使用标准库通常总是一个更好的选择。 在开发高性能网络应用方面,Java提供的标准库所支持的抽象层次过低,并不适合一般的开发人员直接使用通道。过多的底层细节和性能 调优会耗费开发人员大量的精力,选用一个已有的网络应用开发库是一种更好的选择。Apache MINA[1]和JBoss Netty[2]都是不错的库,可以作 为开发的基础。 [1]Apache MINA库的网址是http://mina.apache.org/。 [2]JBoss Netty库的网址是http://www.jboss.org/netty。 第4章 国际化与本地化 提到应用程序的国际化和本地化,可能大多数开发人员不是特别熟悉,或者觉得与自己的日常开发关系并不大。实际上,很多开发人员一 直对国际化和本地化存在着不少误解,其中最常见的一种观点是,如果一个程序只面向某种特定的区域设置(locale)中的用户,那就不需要 提供国际化的支持。比如,某个程序只是针对中国用户开发的,那么在源程序中直接使用中文字符串也有可取之处。这种观点似乎有它的道 理,但是实际上国际化更多地表示的是一种程序应该具有的能力。程序都应该具有国际化的能力,只不过可以根据自己的需要选择对哪些区域 设置提供本地化支持,而不是因为仅打算支持一种本地化区域设置,就放弃国际化的能力。另外的一种观点是国际化只需要把程序中出现的用 户会看到的字符串都提取出来,然后加上翻译就可以了。虽然展示给用户的消息的国际化是实现国际化的重要部分,但并不是全部。除了消息 之外,日期和时间、数字和货币等内容也是需要提供国际化支持的。任何不是随便写着玩的程序,都应该具备相关的国际化的能力。 本章将要对Java提供的国际化和本地化支持进行详细介绍。从实际开发的角度来说,为Java程序添加国际化的支持并不是一件很困难的事 情。Java平台已经为开发人员提供了很多的抽象和简化。本章会介绍使用Java标准库的基本实践方式。除了与实际开发相关的部分之外,本章 还会占用一些篇幅来介绍国际化底层的相关知识,尤其在Unicode和字符编码方面会着重介绍。Java 7中新增的对区域设置的支持也是重点内 容。 4.1 国际化概述 关于国际化的动机,大家应该都比较清楚。世界上很多国家或地区都有自己的语言和文字。一个程序要与普通用户进行交互,就应该提供 目标用户所能理解的语言文本。同样,每个国家或地区也有自己惯用的日期、时间、数字以及货币的格式,程序应该使用用户惯用的格式来显 示这些内容。 每种语言都是由一系列的字符按照某种规则组合而成的。这些字符是语言在发展过程中形成的。这些字符之间可能有内在的排列顺序关 系,如英文的26个字母的顺序;也可能只是一个无序的集合,如中文的汉字。在计算机程序中处理输入数据和输出结果的时候,总是免不了与 这些字符打交道。如何在计算机程序中表示这些字符,就成了一个需要解决的问题。由于计算机内部对数据的表示都是0和1的序列,表示这些 字符的问题实际上就等价于如何在字符与0和1的序列之间建立一个映射关系。 通常的做法是先对由语言中所有字符组成的字符集(character set)按照一定的规则进行编码。最直接的编码方式是为每个字符在给定的 范围之内分配一个整数序号。这个整数序号被称为该字符的代码点(code point)。比如一个字符集中包含1000个字符,就可以把这1000个字 符分别编号为从1到1000。对于一个字符集来说,其中每个字符的代码点的分配方式是固定的,一般由相关的标准化组织来制定。在经过这种编 码之后,之前的字符集就变成了编码字符集(coded character set)。目前存在非常多的编码字符集,开发人员熟悉的包括ASCII、Unicode、 GBK和GB2312等。以ASCII为例,英文字母、数字以及常见的符号都在ASCII中有相应的编码,如字母“A”的编码是65,而“a”的编码则是97。 当计算机程序处理以ASCII来编码的文本的时候,如果识别出来的编码是65,则认为这是一个字母“A”。在进行输出的时候,用户就会看到相 应形状的字符出现。 有了编码字符集之后,下一步要做的就是把字符的代码点映射到计算机中。最直接的映射方式是一一映射,即把字符的代码点直接映射到 对应的数字。比如ASCII中的字母“A”就用整数65来表示。这种映射方式直接而易懂,实现起来也很简单,但是并没有考虑字符集中的字符总 数和字符的使用频率。不同编码字符集中所包含的字符个数是大不相同的,如ASCII就只有128个字符,而Unicode中可使用的字符数多达1 114 112个。如果使用一一映射的方式,对于ASCII来说,只需要7位就足够了;而对于Unicode来说,则最少需要21位来表示。对于包含字符较多的 编码字符集,一一映射的方式带来的问题是造成存储空间上的浪费。以Unicode为例,Unicode字符集包含ASCII字符集中的所有字符。如果使用 一一映射,每个Unicode字符都需要使用21位来表示。如果一段Unicode文本只包含ASCII字符,使用ASCII编码就可以完全表示;而使用一一映 射方式的Unicode编码所需的空间是使用ASCII编码的3倍。在实际的使用中,ASCII字符的使用频率要远高于其他Unicode字符。所以这种一一映 射的方式对于Unicode这样庞大的编码字符集来说并不可取。 从节省空间和提高使用效率的角度出发,对于Unicode这样的编码字符集,更好的办法是采用多个代码单元(code unit)来表示一个字 符。一个代码单元是编码时使用的最小单元。每个字符所使用的代码单元个数是不确定的。如果采用8位作为代码单元,那么Unicode中的ASCII 字符就可以直接用8位来表示;其他语言中的常用字符可以用16位来表示;另外一些比较少见的字符,可以使用更多的位来表示。通过这种划分 方式,既可以保证常用字符所占用的存储空间尽可能少,又不会对表达能力产生影响。 4.2 Unicode 不同的国家和地区通常会有自己特定的编码字符集,而且以包含某种语言相关的字符为主。这使得支持国际化的工作变得复杂,因为需要 同时考虑非常多的编码字符集。这些编码字符集的字符的代码点各不相同,为了提高效率也需要使用不同的代码单元来进行映射,有可能不同 的编码字符集用相同的编码来表示不同的字符,或是用不同的编码来表示相同的字符。对于平台和应用开发人员来说,理解这些编码字符集并 在程序中正确地使用,所要花费的代价太高。 Unicode就是为了解决这个问题而出现的。Unicode的目标是能够包括世界上所有语言的文字。以目前最新的Unicode 6.0来说,它已经包含 了来自93种语言的超过109 000个字符。现在Unicode的开发和维护工作由1991年成立的Unicode联盟负责。该联盟的成员包括了大部分重量级的 信息技术公司。Unicode除了在各种主流操作系统和应用平台上得到应用之外,同时也与国际标准化组织(ISO)的ISO/IEC 10646标准保持完全 同步。从这两个方面来说,Unicode应该是开发国际化应用程序时的编码字符集的最佳选择。随着Unicode本身的发展,越来越多的语言将被包 括进来。除了语言中的字符之外,Unicode还包含了各种常见的符号。 4.2.1 Unicode编码格式 由于Unicode中包含的字符非常多,另外为了尽可能地保持与已有编码字符集的兼容性,Unicode中对字符的编码格式比较复杂。Unicode一 共定义了1 114 112个代码点,值的范围从0到0x10FFFF。在下面对代码点的说明中,都是以16进制的方式来表示的,并在前面加上“U+”。这 一百多万个代码点中目前使用的只有10%左右,大部分代码点目前还没有被使用,可以在以后的版本中被分配给新添加的语言的字符。Unicode 中的代码点被分成17个区域,每个区域为一个平面(plane)。每个平面中包含65 536个字符。第1个平面的代码点的范围是0到U+FFFF,第2个 平面的代码点的范围是U+10000到U+1FFFF,依次类推,最后一个平面的代码点的范围是U+F0000到U+10FFFF。也就是说代码点的后四位(65 536 个值)总是从0变化到0xFFFF,而前两位则在0到0x10之间变化,即总计17个平面。代码点的前两位表明了代码点所在的平面的编号。在这17个 平面中,目前只有前4个和后3个已经被使用了或被保留,中间的10个平面尚处于未分配的状态。 在Unicode的这17个平面中,最常见的是第1个平面,即代码点范围是0到U+FFFF。这个平面被称为基本多语言平面(Basic Multilingual Plane, BMP),其中包含了最常见的语言中的字符和大量的符号。第2个平面称为补充多语言平面(Supplementary Multilingual Plane, SMP),其中主要包含一些不常见的语言中的字符以及音乐与数学符号。第3个平面称为补充表意文字平面(Supplementary Ideographic Plane, SIP),其中包含的是Unicode所定义的统一的汉字的表意符号。第4个平面称为第三表意文字平面(Tertiary Ideographic Plane, TIP),被保留用来包含其他的表意文字,目前其中还没有定义任何字符。第5到第14个平面目前处于未分配的状态,也没有被保留使用。第15 个平面称为补充特殊用途平面(Supplementary Special-purpose Plane, SSP),其中主要包含非图形化的字符。第16和第17个平面称为私有 使用区域平面(Private Use Area planes, PUA)。这两个平面中的代码点所对应的字符不由Unicode联盟来规定,而允许第三方自行定义。相 当于允许第三方在Unicode的框架之内开发自己的字符编码格式。 在说明了Unicode编码字符集中代码点的定义方式之后,下面来看看如何把这些代码点映射到计算机程序中。Unicode没有采用一一映射的 方式,而是把字符的代码点分别映射到多个代码单元上。Unicode规范中定义的映射方式分成Unicode传输格式(Unicode Transformation Format, UTF)编码和统一字符集(Universal Character Set, UCS)编码两种。这两种映射方式根据代码单元的位数的不同,又有不同的变 体,比如UTF-8、UTF-16和UTF-32就分别使用8位、16位和32位来表示单个代码单元;而UCS-2和UCS-4则分别使用2个字节和4个字节来表示单个 代码单元。这些映射方式中最常见的是UTF编码格式的两种变体UTF-8和UTF-16,而UCS编码格式用得比较少。 1.UTF-8 UTF-8编码格式应该是最为开发人员所熟悉的Unicode编码格式。有很多图书和教程都告诉开发人员在编写网页的时候应该使用UTF-8编码, 在创建数据库的时候也需要使用UTF-8编码。很多开发人员都认为使用UTF-8编码是解决程序中各种乱码问题的终极解决方案,不过经过本章的 详细介绍之后,开发人员应该会对乱码问题有更加深刻的认识,而不是仅了解这样一个结论。UTF-8编码格式能够流行是有原因的。UTF-8是一 种变长的编码格式,其中每个代码单元是8位。一个Unicode字符的代码点会被映射到1到6个代码单元。这种变长编码格式的一个重要的好处是 可以减少所需要的存储空间。对于Unicode中的常见字符,仅用一个代码单元就可以表示。另外,UTF-8编码的前128个字符与ASCII编码是完全 一致的。也就是说一段用ASCII编码的文本也是一段合法的UTF-8编码的文本。考虑到ASCII编码的流行程度,保持UTF-8编码与ASCII编码的这种 兼容性,有利于UTF-8的推广。 由于UTF-8是变长编码的,它对不同范围内的Unicode代码点的编码方式是不同的,所以UTF-8的编码和解码方式有点复杂。不过UTF-8编码 格式本身设计得非常精巧。[1]表4-1给出了具体的编码分配方式,其中的“x”表示的是可以用来编码的位。 如表4-1所示,首先是用1个字节来表示的Unicode字符。为了保持与ASCII的兼容性,这个字节中的高位始终是0,仅用剩下的7位来表示字 符,因此可以表示的Unicode中的字符的代码点范围是0~U+007F,对应的编码之后的代码值是0~0x007F。接着是用2个字节表示的Unicode字 符。在这两个字节中,前一个字节的前3位固定为110,后一个字节的前2位固定为10,剩下的11位用来编码。这两个字节可以表示的代码点范围 是U+0080~U+07FF。进行编码就是把代码点的11位按照从高到低的顺序分成5位和6位两个部分,分别放在第一个和第二个字节中的剩余部分, 这样就得到了对应的UTF-8编码。对于由3个字节表示的Unicode代码点来说,第一个字节的前4位固定为1110,后面两个字节的前2位固定为10。 这样3个字节总共剩下16位用来编码,可以表示的代码点范围是U+0800~U+FFFF。利用3个字节进行编码的方式与利用2个字节的做法相似,即把 16位按照从高到低的顺序分配到各个字节的剩余位中。对于剩下的分别用4、5和6个字节来表示的Unicode代码点来说,基本的编码方式是相似 的。从上面对不同字节数的编码方式的分析中可以看出编码时的规律。如果使用的字节数多于一个,那么第一个字节中的高位固定为多个1后面 加上一个0,其中1的个数等于使用的字节数。而除了第一个字节之外的其他字节的高位都固定以二进制的“10”开头。利用这种编码方式可以 很容易地对一个UTF-8编码的字节序列进行解码。只需要查看第一个字节的高位中第一个0之前的1的个数,就可以知道整个编码序列由几个字节 组成。同样的,当遇到一个字节的时候,只需要查看其前2位就可以知道该字节在UTF-8编码之后的字节序列中的位置:如果第一位是0,说明这 是一个与ASCII兼容的编码;如果前两位都是1,说明这是一个多字节编码的起始字节;如果前两位是10,说明这是一个多字节编码的后续字 节,只需要往前查找就可以找到整个编码序列的起始字节。 2.UTF-16 除了UTF-8之外,另一个常用的Unicode编码格式是UTF-16。与UTF-8不同的是,UTF-16中的每个代码单元是16位。每个Unicode代码点会被 映射到1或2个16位的代码单元上。在使用UTF-16的时候,最多只需要4字节就可以表示所有的字符;而在使用UTF-8的时候,则最多需要6字节。 这是因为UTF-8中有很多位的值是固定的,不能用在编码中。就UTF-8和UTF-16的使用场景来说,UTF-8更多是作为字符传输时的编码格式,而 UTF-16则更多是作为字符在系统中的内部表示方式。这是因为UTF-8编码格式可以减少传输时所需的字节数,而UTF-16使用起来相对简单。使用 UTF-16对Unicode中的代码点进行编码的过程也比较复杂。 对于Unicode的BMP中的65 536个字符,这些字符是直接将代码点一一映射到16位整数值上的。BMP中的代码点只需要一个代码单元就可以表 示。对于不在BMP中的代码点,由于其数值范围已经超过了16位所能表示的范围,所以需要两个16位的代码单元才能表示。这两个代码单元被称 为一个代理项对(surrogate pair)。由于不在BMP中的代码点的范围是U+10000~U+10FFFF,对这些代码点的具体的映射规则是:用代码点的 数值减去0x10000,这样就得到了0~0xFFFFF范围内的20位的数值。用这20位中的前10位加上0xD800之后作为代理项对的高位代理,得到的值的 范围是0xD800~0xDBFF;再把这20位中剩下的10位加上0xDC00之后作为代理项对的低位代理,得到的值的范围是0xDC00~0xDFFF。把高位代理 和低位代理拼接起来,就得到了由两个16位值组成的代码点的UTF-16的表示形式。代码清单4-1给出了一个UTF-16的示意编码过程。 代码清单4-1 UTF-16的示意编码过程 public char[]encode(int codePoint){ if((codePoint>=0&&codePoint<=0xD7FF)||(codePoint>=0xE000&&codePoint<=0xFFFF)){ return new char[]{(char)codePoint}; }else{ codePoint=codePoint-0x10000; int high=(codePoint>>10)+0xD800; int low=(codePoint&0x3FF)+0xDC00; return new char[]{(char)high,(char)low}; } } 值得一提的是,在Unicode编码字符集中,U+D800~U+DFFF中间的代码点是没有定义字符的。这么做就是为UTF-16编码考虑的。经过这样的 设计,对于编码之后的字节序列,以16位为一个单元来看,仅使用一个16位的BMP中字符的编码和使用两个16位的字符编码中的高位和低位代理 项,这三者的数值范围是互相不重叠的。这种不重叠的设计,使得在解码的时候可以很容易地判断一个字节序列中不同代码点所对应的字节范 围。比如对一个16位值来说,如果其数值在0~0xD7FF或0xE000~0xFFFF之间,就说明这是一个BMP中的字符的编码,使用这16位来解码就足够 了;如果数值范围在0xD800~0xDBFF之间,就说明这是一个代理项对的高位代理,应该再往后查看16位;如果数值范围在0xDC00~0xDFFF之 间,就说明这是一个代理项对的低位代理,应该再往前查看16位。 UTF-16和UTF-8编码格式都具有“自我同步”的特性。这种特性的含义是,一个代码点对应的编码后的字节序列的起点和终点位置只需要查 看当前代码点就可以得出来。也就是说,在一段文本被编码之后的字节序列中,任意指定一个字节位置,最多只需要查看一个代码点对应的字 节序列长度的字节数,就可以找到这个字节所属的代码点所对应的完整字节序列。这种特性的优势使解码过程变得比较简单,可以很容易地把 一个完整的字节序列切分成不同的小段,每个小段对应一个代码点。 由于UTF-16通过2个或4个字节来编码一个代码点,因此这些字节之间的顺序就变得有意义了。这就是第3章介绍过的字节顺序的含义。同样 的UTF-16编码,按照大端表示(big-endian)和小端表示(little-endian)所解释出来的字符是不一样的。对于这种情况,UTF-16允许在一段 编码之后的字节序列的最前面使用字节顺序标记(byte order mark, BOM)来说明解码时应该使用的字节顺序。字节顺序标记是一个特殊的 Unicode代码点U+FEFF,所表示的字符含义是宽度为零的不断行的空格(ZERO-WIDTH NON-BREAKING SPACE)。当解码器进行解码时,它会根据 底层平台的字节顺序去尝试解码。如果解码之后的结果是0xFEFF,则说明不需要改变字节顺序,直接使用底层平台的字节顺序即可;如果解码 之后的结果是0xFFFE,即两个字节的顺序发生了调换,由于U+FFFE在Unicode规范中不对应任何字符,因此实际上产生了解码错误。出现这个错 误就说明解码时的字节顺序不对,应该把字节调换之后再进行解码。解码器就会在每次读取两个字节的时候,先把顺序颠倒一下,再进行解 码。除了使用BOM之外,另外一种指定字节顺序的做法是在编码格式上给出具体的声明:UTF-16BE说明采用的是大端表示的UTF-16编码,而UTF- 16LE说明采用的是小端表示的UTF-16编码。 UTF-16、UTF-16BE和UTF-16LE在处理字节顺序标记时的行为是不同的。对于UTF-16BE和UTF-16LE来说,在编码的时候,不会输出字节顺序 标记,因为编码格式的名称就指明了字节顺序;在解码的时候,会把字节顺序标记当成其对应的普通Unicode字符,即代码点为U+FEFF的字符。 而对于UTF-16来说,在编码的时候总是使用大端表示并输出字节顺序标记;在解码的时候则根据字节顺序标记进行判断,如果没有就默认为大 端表示。 3.UTF-32与UCS-2 另外一个使用得比较少的编码格式是UTF-32。UTF-32总是使用32位来编码一个Unicode。由于32位对于Unicode的代码点范围来说完全足够 了,因此UTF-32是一种定长编码格式,不同于UTF-8和UTF-16的变长编码格式。Unicode代码点与UTF-32的32位之间是一一映射的关系。UTF-32 的优势在于支持对代码点的随机查找,这也是其定长编码带来的好处。比如一段文本中包含了128个Unicode字符,那么在经UTF-32编码之后的 字节序列中,只需要定位到第13个字节,就找到了第4个字符的起始位置。而对于UTF-8和UTF-16来说,则只能进行顺序查找,因为每个代码点 所对应的字节数是不相同的。UTF-32的最大缺点在于对存储空间的浪费,这也是前面说过的一一映射的编码方式的共同缺点。因为这个缺点, 在实际的开发中很难见到UTF-32的影子。一般的开发人员也不需要特别关注UTF-32编码格式。 还有一个可能会遇到的Unicode编码格式是UCS-2。UCS-2是在国际标准ISO/IEC 10646中定义的,是UTF-16的一个子集。UCS-2总是使用2字 节来表示一个Unicode编码。因此它只能对BMP中的代码点进行编码。在UCS-2的基础上进行扩充,增加了对非BMP中代码点的支持之后,就得到 了UTF-16编码。UCS-2编码会出现在一些规范和应用平台的早期版本中,现在基本都改为使用UTF-16。 [1]UTF-8格式的主要设计者之一是1973年图灵奖获得者之一的Ken Thompson,他因设计UNIX而闻名。 4.2.2 其他字符集 除了Unicode之外,还存在很多其他的编码字符集。如前面提到过的使用7位的包含128个字符的ASCII编码。ASCII编码主要是为英语环境而 开发的。另外一个常见的字符集是ISO 8859-1,这个字符集中主要包含了绝大部分的西欧语言中的字符。ISO 8859其实是一个包含了16个部分 的字符编码规范,其中以第一部分,即ISO 8859-1最为常用。字符集ISO 8859-1也是通过HTTP协议获取到的文本类型的文档的默认编码格式。 在中文编码方面,最常见的是GB2312、GBK和GB18030这三种字符集,其中GB2312是最早的国家规范,而GBK在GB2312的基础上进行了扩 展,GB18030则是最新的国家规范。 4.2.3 Java与Unicode Java平台从诞生之初就对Unicode提供了支持。Java 7支持最新的Unicode 6.0规范。Java语言中的字符最早用的是UCS-2编码格式。因此在 设计之初,字符类型char是由2字节来表示的,只能表示Unicode中BMP中的字符。后来从J2SE 5.0开始支持UTF-16,从而可以表示Unicode中BMP 之外的其他字符。也就是说,如果是BMP中的字符,一个char类型单元就足够了;如果是不在BMP中的字符,则需要由两个char类型单元通过代 理项对的方式来实现,而且这两个char类型单元要连续出现。这样带来的问题是,Java语言中的char类型与Unicode中的字符不再是一一对应的 关系。如果得到了一个长度为3的char类型的数组,那么其中可能包含2个或3个Unicode字符。如果在Java语言的设计之初就采用长度为4字节的 char类型,就不会出现这种不一致的情况。当然,这些问题在设计之初也是不可能全部预计到的,否则也不会有类似“千年虫”这样的问题出 现。 从J2SE 5.0开始,Java中也可以用int类型来表示Unicode中的代码点。使用int类型的时候只会用到对应的32位中的后21位,前11位值全部 为0。这实际上相当于使用了UTF-32编码格式。同时,java.lang.Character类也增加了与Unicode代码点和UTF-16编码相关的实用方法。代码清 单4-2给出了Character类的使用示例,其中codePointAt方法可以获取到一个java.lang.CharSequence接口的实现对象或一个char类型数组中从 给定位置开始的字符的Unicode代码点。这个方法可以处理代理项对。如果当前位置的char类型的值是在UTF-16的代理项对的高位的范围之内, 同时接下来的char类型的值也在代理项对的低位的范围之内,会把这两个char按照UTF-16格式解码之后再得到其代码点的值。而 isBmpCodePoint方法可以判断一个代码点是否在BMP之内。对于非BMP中的字符,可以通过highSurrogate和lowSurrogate方法来分别得到其对应 的代理项对的高位和低位的char值。 代码清单4-2 Character类的使用示例 public void useCharacter(){ String str="你好"; int codePoint=Character.codePointAt(str,0);//值为20320 Character.isBmpCodePoint(codePoint);//值为true int smpCodePoint=0x12367; Character.isSupplementaryCodePoint(smpCodePoint);//值为true Character.charCount(smpCodePoint);//值为2 char high=Character.highSurrogate(smpCodePoint); char low=Character.lowSurrogate(smpCodePoint); } 4.3 Java中的编码实践 对Java程序来说,由于Java平台内部统一使用UTF-16的编码格式,因此在单个程序内部并不会存在编码问题。真正的编码问题发生在程序 与外界发生字符数据传递的时候。可能的交互情况包括用户输入数据到程序中,其他程序产生的输入文件被当前程序所使用,程序传递参数给 操作系统底层方法调用,以及程序产生输出文件等。也就是说,当一个字节序列跨越当前程序的边界,而且这个字节序列需要当成字符来处理 的时候,就可能产生编码的问题,因为这些字节所表示的字符会因编码格式的不同而千差万别。而在程序内部,总是可以使用统一的编码格式 来处理字符,这也是Java程序中会产生各种乱码问题的原因。基本的解决思路也很简单,那就是在程序与外界进行输入输出的边界严格控制字 符的编码格式,确保这些字符以正确的UTF-16格式进入到程序内部。 以乱码问题较多的Web应用为例进行说明。一个Web应用通常都允许用户输入一些字符来与应用进行交互。这些字符通过HTTP协议来传输, 在发送的时候就会有一个自己的编码格式。如果希望把用户输入的字符保存到数据库之中,那么还需要考虑数据库本身存储时使用的编码格 式。最后把HTML页面通过HTTP协议传输给浏览器的时候,也需要指定一个合适的编码。浏览器会根据此编码来对页面进行处理。在上面这些环 节中,如果有对编码处理不当的地方,都可能造成用户看到的不是他想要的内容,而是一堆无法识别的字符。 由于存在非常多的编码字符集,每个字符集主要面向的国家和地区也不尽相同。不同的Java虚拟机实现可以选择支持不同的字符集编码方 式。但是,在这些编码方式中,ASCII、ISO 8859-1、UTF-8、UTF-16、UTF-16BE和UTF-16LE是虚拟机必须支持的,也就是说虚拟机肯定支持这6 种编码格式的编码和解码。由于Java平台原生使用的是UTF-16编码,因此这里的编码和解码的概念,指的是UTF-16编码的Unicode字符与该字符 在不同字符集中表示方式之间的转换。如果Java虚拟机的实现支持GB18030,而需要处理的字符串是“你好”,那么使用GB18030进行编码的含 义是把“你好”对应的UTF-16编码格式的字节序列0x4F60597D转换成GB18030中同样表示“你好”的字节序列0xC4E3BAC3;而解码的过程正好相 反。如果需要进行编码转换的两种格式都不是UTF-16,就需要使用UTF-16作为中间格式。 4.3.1 Java NIO中的编码器和解码器 在J2SE 1.4之前,Java提供的对不同字符集的编码和解码的支持比较少,主要是通过String类的getBytes方法和构造方法来完成相应的转 换。比如希望把字符串从UTF-16编码变成GB18030编码,可以执行代码“str.getBytes("GB18030")”,返回的结果是一个字节数组;同样, 如果已有一个字节数组和它的编码格式,可以通过String类的构造方法来创建一个String类的对象,如代码“new String(data,"GB18030")”可以把GB18030编码的字节数组data转换成UTF-16格式的内部表示。J2SE 1.4在引入NIO的时候,提供了对字符集 的更多支持能力,即新的java.nio.charset包。这其中最重要的是表示字符集的java.nio.charset.Charset类及进行编码和解码的 java.nio.charset.CharsetEncoder和java.nio.charset.CharsetDecoder类。Java中的字符集(charset)实际上由上面介绍的编码字符集和相 关的映射方式两部分所组成。 开始使用Charset类之前需要得到Charset类的对象,一般来说有4种方法可以使用。如果希望使用Java虚拟机都支持的字符集,可以从 java.nio.charset.Standard-Charsets类的公开静态变量中得到,如StandardCharsets.UTF_16表示UTF-16字符集;如果希望使用Java虚拟机平 台默认的字符集,可以使用Charset类的defaultCharset方法。这个默认字符集是在Java虚拟机启动时得到的,一般取决于底层操作系统的区域 设置;还可以通过Charset类的availableCharsets方法得到当前Java虚拟机上支持的所有字符集,再从中进行选择;最后可以根据字符集的名 称来通过Charset类的forName方法进行创建。 得到了Charset类的对象之后的典型用法是创建出对应的编码器和解码器对象。通过newEncoder方法可以创建一个CharsetEncoder类的对 象,而通过newDecoder方法可以创建出一个CharsetDecoder类的对象。CharsetEncoder和CharsetDecoder类在进行编码和解码的时候都是有内 部状态的,由一系列相关的动作来完成整个编码或解码过程。下面以CharsetEncoder类的使用为例进行说明。 在使用CharsetEncoder类的对象之前,除非这个CharsetEncoder类的对象是新创建的,否则一般需要调用reset方法来重置其内部状态。接 着就需要多次调用encode方法来进行编码。encode方法有3个参数:第一个参数是包含待编码字符的CharBuffer类的对象,第二个参数是存放编 码之后的结果的ByteBuffer类的对象,最后一个参数用来表示是否还有更多的输入内容。在多次的encode方法调用之后,需要调用flush方法来 清空CharsetEncoder类的对象的内部缓冲区。在进行编码时,encode方法的返回值是java.nio.charset.CoderResult类的对象,用来表示本次 编码过程的执行结果。一般来说有4种不同的结果:第一种用CoderResult.UNDERFLOW来表示,说明编码的输入缓冲区中的内容已经被完全耗 尽,或是需要额外的输入才能继续当前的编码过程;第二种用CoderResult.OVERFLOW来表示,说明编码的输出缓冲区中没有足够的空间来存放 编码之后的结果;第三种结果是输入缓冲区中的字符不是合法的UTF-16格式,可以通过CoderResult的isMalformed方法来判断;第四种结果是 输入缓冲区中的某些字符在当前字符集中无法表示,可以通过CoderResult的isUnmappable方法来判断。 CharsetEncoder类的对象的调用者应该根据encode方法的执行结果来采取不同的处理方式。对于返回值为CoderResult.UNDERFLOW的情况, 应该在向输入缓冲区中添加更多的内容之后,再次调用encode方法;对于返回值为CoderResult.OVERFLOW的情况,应该读取输出缓冲区中的内 容以腾出更多的空间供下次encode方法调用来使用。对于后面两种返回值结果来说,一般有3种处理方式,定义在 java.nio.charset.CodingErrorAction类中。这3种处理方式分别是报告(CodingErrorAction.REPORT)、替换 (CodingErrorAction.REPLACE)和忽略(CodingErrorAction.IGNORE)。报告的含义是指通过encode方法的返回值来说明错误的信息;替换的 含义是用特定的字节数组来替换无效或无法映射的字符;忽略的含义则是指直接跳过输入中引起错误的字符。通过CharsetEncoder类的 onMalformedInput和onUnmappableCharacter方法可以设置当CharsetEncoder类的对象遇到无效或无法映射的字符时的处理行为,参数就是上面 说到的CodingErrorAction类的对象。如果采用替换的做法,用来进行替换的字节数组的值可以通过CharsetEncoder类的对象的replaceWith方 法来改变,而通过replacement方法也可以得到当前的替换值。 先用一个简单的例子来说明CharsetEncoder的用法。代码清单4-3中将字符“你好”编码成UTF-8格式,并输出相应的字节数组。这段代码 的作用与“"你好".getBytes("UTF-8")”是相同的。 代码清单4-3 CharsetEncoder类的使用示例 public void simpleEncode(){ Charset charset=StandardCharsets.UTF_8; CharsetEncoder encoder=charset.newEncoder(); CharBuffer inputBuffer=CharBuffer.allocate(256); inputBuffer.put("你好").flip(); ByteBuffer outputBuffer=ByteBuffer.allocate(256); encoder.encode(inputBuffer, outputBuffer, true); encoder.flush(outputBuffer); outputBytes(outputBuffer);//值为0xE4BDA0E5A5BD } 代码清单4-3只是简单地用encode方法来编码一小段文本,所以并没有考虑前面提到的输入和输出缓冲区的数据的问题。下面考虑一个更加 复杂的例子,即对一个文件进行完整的编码过程。示例用的文件是一个保存的UTF-8编码的网页,通过编码之后转换成用GB18030来编码。编码 之后的网页,如果直接用浏览器打开就会出现乱码。这个时候手动切换浏览器采用的编码格式为GB18030就可以正确地显示了。这也验证了编码 的过程是正确无误的。如代码清单4-4所示,在创建出GB18030编码格式的编码器之后,首先把编码器遇到无效和无法映射的字符的处理行为设 成“忽略”,避免在encode方法中产生这两种错误,从而不需要在代码中进行相应的处理。接着对于输入文件中的每一行,用一个CharBuffer 类的对象封装之后作为encode方法的输入。每次调用encode方法之后检查调用结果,如果是CoderResult.OVERFLOW,说明输出缓冲区已满,应 该把结果写入到输出文件中;如果是CoderResult.UNDERFLOW,说明输入缓冲区中的字符已经被编码完毕,可以开始下一行的编码工作。所有行 都遍历完成之后,还需要调用一次encode方法并指示已经没有其他的输入。最后调用flush方法来清空内部缓冲区。在这里需要注意的是flush 方法的返回值有可能是CoderResult.UNDERFLOW或CoderResult.OVERFLOW。如果是CoderResult.UNDERFLOW,则说明清空操作成功完成;如果是 CoderResult.OVERFLOW,则说明调用flush方法时传入的缓冲区不够大,应该创建一个更大的ByteBuffer类的对象并重新调用flush方法。 代码清单4-4 对整个文件进行编码的示例 public void encodeFile()throws IOException{ Charset charset=Charset.forName("GB18030"); CharsetEncoder encoder=charset.newEncoder(); encoder.onMalformedInput(CodingErrorAction.IGNORE); encoder.onUnmappableCharacter(CodingErrorAction.IGNORE); ByteBuffer outputBuffer=ByteBuffer.allocate(128); List<String>lines=Files.read All Lines(Paths.get("test.htm"),StandardCharsets.UTF_8); try(FileChannel destChannel=FileChannel.open(Paths.get("result.htm"),StandardOpenOption.CREATE, StandardOpenOption.WRITE)){ for(String line:lines){ CharBuffer charBuffer=CharBuffer.wrap(line); while(true){ CoderResult result=encoder.encode(charBuffer, outputBuffer, false); if(result.isOverflow()){ writeToChannel(destChannel, outputBuffer); }else if(result.isUnderflow()){ break; } } } writeToChannel(destChannel, outputBuffer); encoder.encode(CharBuffer.allocate(0),outputBuffer, true); CoderResult result=encoder.flush(outputBuffer); if(result.isOverflow()){ ByteBuffer newBuffer=ByteBuffer.allocate(1024); encoder.flush(newBuffer); writeToChannel(destChannel, newBuffer); } else{ writeToChannel(destChannel, outputBuffer); } } } private void writeToChannel(WritableByteChannel channel, ByteBuffer buffer)throws IOException{ buffer.flip(); channel.write(buffer); buffer.compact(); } 如果不需要像代码清单4-4那样考虑完整的编码过程,可以直接使用encode方法的另外一个重载形式,即使用一个CharBuffer类的对象作为 参数表示输入字符,而返回一个ByteBuffer类的对象作为编码之后的结果。这个方法在内部实现了一套完整的编码过程,开发人员不需要考虑 过多的底层细节。编码过程中如果出现错误,会抛出java.nio.charset.CharacterCodingException异常。 解码器CharsetDecoder类的使用可以完全将编码的过程反过来。解码是通过decode方法来实现的,其中的流程类似编码,分成重置、循环 解码和清空内部缓冲区这3个步骤。唯一的区别在于decode方法调用时的输入变成了ByteBuffer类的对象,而输出则变成了CharBuffer类的对 象。CharsetDecoder类的具体使用可以类比上面介绍的CharsetEncoder类,这里不再赘述。关于CharsetDecoder类需要额外提到的一点是,有 些解码器支持编码的自动检测,也就是说这些编码器并不是固定地只能支持一种编码格式的解码。通过isAutoDetecting方法可以判断一个 CharsetDecoder类的对象是否支持编码的自动检测。当解码器完成对某些字节的处理之后,就有可能已经检测出字符集。通过 isCharsetDetected可以判断是否已经检测出字符集。一旦检测出来之后,可以通过detectedCharset方法来获取检测出来的字符集。 现在的程序一般都使用Unicode作为内部的字符集,很多应用开发平台都直接支持Unicode。但是还是存在某些遗留应用只支持某种特定的 字符集,或有些应用在设计上就只使用某个固定的字符集。比如只面向英语用户的程序,可以选择使用ASCII作为其字符集。当为这样的程序提 供输入的时候,需要先对Unicode字符串进行过滤,去掉其中不能被目标程序识别的字符。通过编码器和解码器可以实现对字符的过滤,具体的 做法是,指示CharsetDecoder类的对象在解码过程中遇到无法映射的字符时直接忽略即可。代码清单4-5给出了一个示例,对字符串按照ISO 8859-1字符集进行过滤。经过过滤后的字符串不会包含无法通过ISO 8859-1字符集表示的字符。 代码清单4-5 字符串过滤的示例 public String filter(String str)throws CharacterCodingException{ Charset charset=StandardCharsets.ISO_8859_1; CharsetDecoder decoder=charset.newDecoder(); CharsetEncoder encoder=charset.newEncoder(); encoder.onUnmappableCharacter(CodingErrorAction.IGNORE); CharBuffer buffer=CharBuffer.wrap(str); ByteBuffer byteBuffer=encoder.encode(buffer); CharBuffer result=decoder.decode(byteBuffer); return result.toString(); } public void useFilter()throws CharacterCodingException{ String result=filter("你好,123世界!");//值为123 } 4.3.2 乱码问题详解 在Java程序中,一个常见的与编码相关的问题就是乱码问题,尤其是与中文相关的乱码问题。中文乱码问题和Java类路径(CLASSPATH)的 问题一样,是很多刚接触Java的开发人员都会遇到的问题。经过前面的介绍,读者应该对Java中的编码格式有了一定的了解。所谓的乱码,指 的是某种编码格式产生的字节序列被错误地用另外一种不兼容的编码格式进行解码,得到的结果通常是一些奇怪的字符。乱码的问题通常发生 在字符串在Java程序的边界之间传递的时候,尤其是在字符串输入的时候。当一个字符串传入Java程序的时候,如果没有正确地指定其使用的 编码格式,Java程序可能采用错误的编码格式进行解码,因此得到了错误的UTF-16字节序列。而在Java程序产生输出的时候,问题则相对较 少。Java程序总是可以使用UTF-16或UTF-8作为其输出内容的编码格式。从应用的类别来说,桌面应用的乱码情况比较少。这主要是因为接收数 据输入的一般是Java平台提供的用户界面组件。对于Web应用来说,出现乱码问题的情况就比较多。这主要是因为不同平台上的不同浏览器在发 送字符串数据给服务器的时候,所采用的编码格式可能各不相同,不同的Web应用开发平台所提供的编码支持能力也不尽相同,这使得问题变得 更加复杂。 下面着重对Web应用中的乱码问题进行比较深入的讨论。通常来说,用户的输入会出现在Web应用的两个地方:一个是作为Web应用的URL的 一部分,比如对一个搜索引擎来说,用户提交的搜索关键词通常是直接出现在进行搜索的URL中的;另一个是出现在HTML表单提交的数据中,比 如在一个新用户注册页面中,用户输入的相关信息会被以表单提交的方式传输到Web应用中。从HTTP请求的角度来说,前者对应的是GET请求, 而后者对应的是POST请求。用户提供的这些信息都是以字符的形式输入的,通过HTTP协议传输之后被Web应用的后台接收到。 首先讨论HTTP GET请求。在GET请求中,相关的信息都是作为URL的一部分而出现的。在目前的Web应用开发实践中,使用有意义的URL被认 为是一个很好的实践。比如对一个博客网站来说,一个常见的做法是把每篇文章的标题作为该文章的URL的一部分。这种做法带来的最直接的好 处是对搜索引擎更加友好,在搜索结果中的排名比较靠前。因为搜索引擎通常会对URL中出现的内容赋予比较高的权重。对于只包含ASCII字符 的内容来说,作为URL的一部分并不是一件困难的事情。不过对于ASCII之外的字符,如中文字符来说,则只有经过正确的编码之后才能出现在 URL之中。 在与URL相关的规范RFC 3986[1]中对URL中允许出现的字符做了详细的规定。允许出现在URL中的字符分成两大类:保留字符和非保留字 符。保留字符是指在某些情况下有特殊含义的字符,包括“!”、“*”、“'”、“(”、“)”、“;”、“:”、“@”、“&”、 “=”、“+”、“$”、“,”、“/”、“?”、“#”、“[”和“]”。这些保留字符的含义各不相同,比如“/”用来分隔URL中的路 径,“?”表示URL中查询字符串的开始,“&”用来分隔查询字符串中的不同参数等。非保留字符则是不具备特殊含义的字符,包括大小写英 文字母、0到9的数字、“-”、“_”、“.”和“~”。对于保留字符来说,如果只希望表示这个字符本身,而忽略其特殊含义,需要对这个字 符进行编码。URL中使用的编码格式是“百分号编码格式”,也就是将保留字符对应的ASCII编码值的二进制形式转换成相应的十六进制方式之 后,再加上“%”作为前缀。例如,当希望在查询字符串中把“?”作为某个参数的值的一部分的时候,就需要对它进行百分号编码,因为这个 时候没有用到“?”的特殊含义。“?”的ASCII编码是63,对应的十六进制形式是3F,因此百分号编码的结果是“%3F”。除了非保留字符之外 的其他字符,要出现在URL中,都需要经过百分号编码。一个特例是空格,由于空格出现得比较频繁,除了可以使用其百分号编码格 式“%20”之外,也可以用“+”字符来代替。这样的好处是可以减少URL的长度。 对于ASCII字符集之外的其他字符来说,RFC 3986规范的要求是这些字符应该先通过UTF-8编码格式得到其对应的字节序列,再对这个字节 序列中的每个字节都使用百分号编码格式。比如中文字符“你好”,经过UTF-8编码之后的字节序列是“0xE4BDA0E5A5BD”。如果出现在URL 中,则应该使用“%E4%BD%A0%E5%A5%BD”的形式。使用UTF-8是一种常见的做法,却不是唯一的做法。不同的Web应用有可能采用不同的编码格 式,这取决于Web应用本身,因为这些URL最终是由应用本身来处理的。Java提供了java.net.URLEncoder类,根据不同的编码方式对字符串进行 百分号编码,以在构造URL时使用。以百度搜索引擎为例,在百度的搜索URL中对输入的关键词用GB2312进行编码,所以当需要构造URL时,可以 使用代码清单4-6中的代码实现。 代码清单4-6 URL编码方式 String url="http://www.baidu.com/s?wd="+URLEncoder.encode(keyword,"GB18030"); 如果采用了错误的编码方式,百度搜索引擎的服务器在按照GB2312进行解码的时候,就会变成无法识别的字符。不过这种错误情况一般只 发生在直接在外部程序中构造URL的时候,如一些网页抓取程序。一般用户都是直接单击页面内的超链接来访问新页面的。这些URL都是Web应用 自己生成的,不存在这个问题。 在服务器端处理GET请求的时候,一般底层应用服务器会提供相关的URL解码功能,只需要对应用服务器进行配置即可。比如Tomcat是通过 URIEncoding来进行配置的。如果希望自己来处理URL的解码,可以使用java.net.URLDecoder类的decode方法。 在通过页面中的HTML表单来提交数据的时候容易产生问题。从浏览器提交来的字符可能会经过多个不同层次的编码转换,每个环节都可能 产生错误。表单提交的数据一般是用“application/x-www-form-urlencoded”作为其内容类型。正如类型名称中的“urlencoded”所表示的含 义一样,表单中的内容也是以类似GET请求中的URL的编码方式来进行编码的。对于其中包含的字符,也使用百分号编码的方式。而在编码的时 候使用的字符集应该在POST请求的内容类型中显式地声明。比如在用GB18030进行编码的时候,就应该使用“application/x-www-form- urlencoded;charset=gb18030”作为POST请求的HTTP头“Content-Type”的值。 浏览器在对表单内容进行编码的时候使用的字符集由当前页面的字符集来确定。当前页面的字符集的确定取决于下面几个因素:返回页面 的HTTP响应中的“Content-Type”头中给出的字符集,如“text/html;charset=utf-8”;页面中通过<meta>标签声明的字符集;用户通过 浏览器的界面手动选择的字符集。如果确定的字符集是UTF-8,那么在表单提交的时候,非ASCII字符会在以UTF-8编码之后转换成百分数编码形 式发送给服务器。通过HTML中<form>元素的“accept-charset”属性可以设置与页面编码不同的专供表单提交使用的编码格式。不过需要注 意浏览器兼容性问题,不同的浏览器对于这个属性的支持是不同的。但是浏览器在完成编码之后,一般不会把所用的字符集声明出来。大部分 浏览器都只是用“application/x-www-form-urlencoded”作为HTTP头“Content-Type”的值,而不会显式地使用“application/x-www-form- urlencoded;charset=utf8”这样的格式。这也是造成乱码问题的一个很重要的原因。 将编码之后的数据发送到服务器之后,服务器端的Java程序一般不直接处理HTTP请求,而是依靠底层的框架来完成。一般的Java Web应用 都基于servlet规范进行开发。HTTP请求的入口一般是某个servlet实现类中的doGet、doPost或doPut方法。在这几个方法中通过以参数方式传 入的HttpServletRequest类的对象就可以获取到浏览器端发送过来的数据。对于GET和内容类型为“application/x-www-form-urlencoded”的 POST请求,都可以通过HttpServletRequest类的对象的getParameter方法来得到参数的值。对于POST或PUT请求,还可以通过getInputStream来 得到用来读取请求中的内容的java.io.InputStream类的对象。如果读取到的是InputStream类的对象,处理起来相对容易一些,因为是字节 流。而通过getParameter方法得到的是String类的对象,这其中就涉及编码的问题。如果没有在“Content-Type”头中显式指定,根据相关的 规范,ISO 8859-1是默认的编码格式。也就是说,虽然包含HTML表单的网页使用了UTF-8作为编码格式,浏览器也按照UTF-8的格式提交了数 据,如果服务器或代码中没有经过正确的设置,底层实现将使用默认的ISO 8859-1进行处理,这样就会产生乱码问题。 对于新开发的Web应用来说,推荐在整个应用的各个层次上都使用UTF-8作为统一的编码格式。即便不使用UTF-8,编码格式也应该统一为一 种。所有的HTML页面都通过<meta>标签来声明使用UTF-8作为编码格式。也需要将文件本身的编码设置为UTF-8。同时对服务器进行配置以发 送正确的HTTP响应头信息。JSP页面则通过“<%@page pageEncoding="UTF-8"%>”来声明页面的编码格式。而在服务器端,通过 HttpServletRequest类的setCharacterEncoding方法来显式地设置解析时使用的编码格式。常用的做法是通过一个过滤器 javax.servlet.Filter接口的实现来对所有请求的HttpServletRequest类的对象进行设置。代码清单4-7给出了一个简单的实现,基本的思路 是,如果请求中没有指定编码格式,就设置为默认的UTF-8格式。 代码清单4-7 设置编码格式的过滤器 public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException{ if(request.getCharacterEncoding()==null){ request.setCharacterEncoding("UTF-8"); } chain.doFilter(request, response); } 在某些情况下,并不能要求对所有的页面都使用同样的UTF-8编码格式。这种情况在处理遗留系统的时候比较常见,比如需要把一个遗留系 统的Web前端对接到新的服务器端。这就会造成新旧两种Web前端使用不同编码格式的情况,而同一个后台要能够对它们的请求做出正确处理。 为了能够在这两种情况下都正确地进行编码,需要在请求中显式地指明所用的编码格式。由于浏览器不会直接在表单提交中添加编码格式的标 识,最直接的做法就是自己加上一个额外的查询参数,如“encoding=GB18030”。在过滤器中就可以通过检查这个参数的值来设置相应的编码 格式。不过要注意的是,不能直接通过HttpServletRequest类的getParameter方法来获取这个参数的值,因为getParameter方法在执行的过程 中就已经对请求中的内容进行了解码处理,之后再通过HttpServletRequest类的setCharacterEncoding方法进行设置就没有意义了,得到的仍 然是乱码。一种可行的解决办法就是由程序自己来解析URL中的查询字符串,从中得到编码格式的参数值,从而可以解决这个问题。如果请求不 是由浏览器本身来发送,而是通过浏览器中的XMLHttpRequest对象或是Java程序来发出的,就具备了直接修改HTTP头的能力。这个时候,就可 以使用正确的“Content-Type”头来指明编码格式了。 [1]规范的具体内容见:http://www.ietf.org/rfc/rfc3986.txt。 4.4 区域设置 如果程序需要提供国际化的支持,就要求程序对用户的区域设置信息是敏感的。也就是说,程序在输出相关信息给用户的时候,应该考虑 当前用户所在的地理位置区域。比如当用户所在地区为中国大陆的时候,程序应该采用简体中文作为信息的显示语言,并在日期和时间、数字 和货币等显示的格式上也符合中国大陆的用户的使用习惯。通常来说,操作系统会提供相关的功能来允许用户设置其所在的区域。Java虚拟机 也会以底层操作系统的区域设置作为其默认的设置。Java中与区域设置相关的API都可以根据Java虚拟机默认的或是程序指定的区域设置来产生 对应的输出。通过操作系统或其他方式识别出来的用户的区域设置信息不一定是准确的,程序一般都应该提供允许用户自由切换区域设置的功 能。 Java中的区域设置是通过java.util.Locale类的对象来表示的。由于区域设置本身的复杂性,Locale类的对象中所包含的信息是比较多 的。这些信息的目的在于准确地区分不同的区域,同时又以便于使用者理解的方式组织起来。在Java 7之前,Locale只包含了语言名称、国家 或地区名称以及变体信息。从Java 7开始,Java中的区域设置支持了IETF BCP 47(Tags for Identifying Languages)[1]这一最佳实践,相 关的API也做了修订和增强。 4.4.1 IETF BCP 47 用户的区域设置可以用一个标识符来表示。程序通过这个标识符来确定应该采用什么样的方式来把信息呈现给用户。一般来说,一个区域 设置标识符至少应该包括语言标识符和国家或地区的标识符两部分,还可能包含一个变体信息。比如英文的语言标识符是“en”,而美国的标 识符是“US”。所以对于生活在美国的英语使用者来说,他们的区域设置就同时包含“en”和“US”。这也是Java中的Locale类的对象最开始 的时候包含这3种信息的原因。不同的操作系统平台实现可能采用不同的标识符格式,但是这些基本信息都是存在的,只是表示的方式不同。为 了增强互操作性,IETF BCP 47定义了一个语言标签的格式规范,这个标签可以作为区域设置的标识符的格式。从Java 7开始,Java平台开始使 用BCP 47的格式作为其区域设置标识符的基本格式,之前的格式也予以保留以保证与早期版本的兼容性。新的Java程序应该使用BCP 47的格式 来表示其区域设置,Java也提供了相关的API可以很方便地创建出这种格式的Locale类的对象。 IETF BCP 47中定义的语言标签只是一个简单的字符串,可以附加在一段信息上,用来声明这段信息所使用的语言。这个字符串由几个部分 组成,每个部分之间用“-”分隔。每个部分被称为一个子标签,其格式和含义是不相同的,可以很容易地进行解析。即便某个部分中包含的内 容本身可能是无意义的,也可以正确地进行解析。比如包含国家或地区标识符的那部分内容可能并不对应于任何实际存在的国家或地区,但是 仍然可以被正确地分析出来。这么设计的好处在于可以很好地应对以后的变化。 第一部分是语言的名称,如“zh”、“en”和“fr”等。这个部分可以是2到8个字母。最常使用的是2或3个字母的ISO 639规范定义的语言 名称。在主要语言名称的后面可以加上扩展的子语言名称。这通常是因为某些语言存在一些使用广泛的子语言。如中文中的粤语就可以用“zh- yue”来表示。第二部分是语言的书写格式。这个部分是在ISO 15924中定义的4个字符长度的符号。第三部分是国家或地区的名称,这个部分可 以是ISO 3166-1定义的2个字母的编号或是UN M.49定义的3个数字的编号。第四部分是变体信息,用来描述语言或其方言的变体。以字母开头的 变体信息的长度至少是5,而以数字开头的变体信息的长度至少是4。第五部分是语言的扩展。这些扩展用来描述语言的一些附加信息。每个扩 展由两个部分组成:第一部分是单个字母的键,第二部分则是2到8个字母或数字组成的值。最后一部分是私有标记。这个部分以字母“x”开 始,后面跟着1到8个字母或数字。 从上面对语言标签的描述中可以看出,语言标签以及基于它的Locale类的对象更多的时候只是一个标识符,它本身并不包含具体的本地化 的能力。支持本地化的方法应该接受一个Locale类的对象作为参数,根据这个Locale类的对象所表示的区域来正确地产生相应的输出。 在Java 7之前,只有两种创建Locale类的对象的方式,一种是使用它的构造方法,另外一种是使用Locale类中预先定义的常用区域设置。 Java 7新增了两种与BCP 47相关的构造方式。一种是用静态方法forLanguageTag来把一个符合BCP 47的语言标签字符串转换成Locale类的对 象。另外一种则是使用新的Locale.Builder类来设置语言标签的各个部分,并最终产生一个完整的Locale类的对象。代码清单4-8给出了一个 Locale.Builder类的使用示例,注意其中方法级联的使用。 代码清单4-8 Locale.Builder类的使用示例 public void useLocaleBuilder(){ Locale locale=new Locale.Builder().setLanguage("zh").setRegion("CN").setExtension('m',"myext").build(); String tag=locale.toLanguageTag();//值为“zh-CN-m-myext” } [1]IETF BCP47规范的网址是:http://tools.ietf.org/html/bep47。 4.4.2 资源包 资源包(resource bundle)应该是开发人员最熟悉的对Java程序进行国际化的方式。通常的做法是把程序中要显示给用户的消息文本都抽 取出来,保存在属性文件中。这样的属性文件通常有一组,包含的内容是相似的,只是每个属性文件分别对应于一个特定的区域设置。资源包 在使用方式上类似于一个java.util.Map接口,即其中所包含的是键值对的列表。从属性文件中创建出来的资源包自动地包含文件中声明的键值 对作为其内容。使用者只需要根据当前的区域设置获取到对应的资源包对象,再从其中根据当前要显示的消息的键来获取消息的内容并显示出 来即可。 Java中的java.util.ResourceBundle类用来表示一个资源包。每个资源包都有两个重要的属性:一个是它的基本名称,用来区分不同的资 源包;另外一个是资源包对应的Locale类的对象。基本名称相同的所有资源包所包含的键是相同的,区别在于键的值是否经过本地化处理,以 对应于不同的Locale类的对象所表示的区域设置。前面提到的基于属性文件的资源包只是资源包的一种形式,由 java.util.PropertyResourceBundle类来表示,其中只能包含字符串作为键的值;另外一种形式是直接继承自ResourceBundle类的Java类,其 中可以包含任意Java对象作为键的值。这两种形式其实是统一的,对于属性文件,在获取的时候会自动从文件中创建PropertyResourceBundle 类的对象。在程序中使用资源包的时候,总是使用ResourceBundle类及其子类的对象。 如果希望创建自己的ResourceBundle类的实现,可以直接继承ResourceBundle类,或使用java.util.ListResourceBundle类。在直接继承 ResourceBundle类时,只需要实现用来遍历其中包含的所有键的getKeys方法,以及根据键来获取对应值的handleGetObject方法即可。 ListResourceBundle类则提供了一种基于二维数组的快速创建ResourceBundle类的对象的实现。这个二维数组的每一行表示一条记录,而对应 的第一列和第二列分别是键和值。只需要继承此类,实现其中的getContents方法来返回一个包含了全部数据的二维数组即可。 ListResourceBundle类非常适合在内存中创建和维护资源包或是作为其他ResourceBundle类格式的包装容器。 在创建了程序中所需的属性文件或ResourceBundle类的子类之后,下一步是找到适合于当前用户的区域设置的ResourceBundle类的对象。 这是通过ResourceBundle的getBundle方法的各种不同的重载方式来实现的。这些不同的实现方式分成两类:第一类是Java 6之前的基于资源包 的基本名称、Locale类的对象和类加载器对象的查找方式;第二类是Java 6中新增的通过ResourceBundle.Control类的对象来控制查找过程的 查找方式。 第一种查找方式的关键在于根据资源包的基本名称和Locale类的对象来生成一个资源包对应的Java类和属性文件的名称查找序列。这个名 称查找序列会考虑到Locale类的对象中的语言、书写方式、国家或地区,以及变体等信息,其基本的思想是优先查找最具体的资源包,如果找 不到,就尝试查找通用一些的资源包。如果在尝试了指定的Locale类的对象之后,仍然无法找到对应的资源包,这个过程会以当前Java虚拟机 的默认的Locale类的对象为目标,再重复执行一次。如果仍然无法找到,就会抛出java.util.MissingResourceException异常。例如,假设基 本名称是“Messages”,所用的Locale类的对象的语言和国家或地区分别是“en”和“US”,那么对应的名称查找序列是:Messages_en_US、 Messages_en和Messages。对于这些候选名称,首先尝试通过指定的类加载器来查找并加载对应名称的Java类,如果这个过程成功并且该Java类 可以转换成ResourceBundle类,就创建该类的一个实例,作为查找到的结果。如果查找Java类的过程失败,接着会尝试查找属性文件。由于基 本名称中可能带有名称空间,对应的属性文件名称是把候选名称中的“.”替换成“/”之后的名称,再通过类加载器的getResource方法来进行 查找。如果找到属性文件,会以此文件作为输入来创建一个PropertyResourceBundle类的对象作为结果。 ResourceBundle类的对象本身也是存在一定层次结构的。一个ResourceBundle类的对象有可能存在一个父ResourceBundle类的对象。子 ResourceBundle类的对象中会包含父ResourceBundle类的对象中定义的键值对。这种层次关系只有在资源包的查找过程中才会建立。根据上面 提到的查找时的候选名称序列,出现在后面的查找到的ResourceBundle类的对象是之前的ResourceBundle类的对象的父亲。在查找过程中,会 对所有能够成功创建的ResourceBundle类的对象建立这种层次结构关系。代码清单4-9给出了资源包的查找过程和基本的使用方式。对于同一基 本名称的资源包,代码中分别使用Java类和属性文件来提供对应不同区域设置的本地化内容。查找到ResourceBundle类的对象之后,通过其 getString方法来获取本地化的内容。 代码清单4-9 资源包的查找过程和基本的使用方式 //Java类定义的ResourceBundle public class Messages_en_US extends ListResourceBundle{ public Object[][]getContents(){ return new Object[][]{ {"GREETING","Hello!"}, {"THANK_YOU","Thank you!"} }; } } //属性文件 GREETING=你好! THANK_YOU=谢谢! //使用ResourceBundle的代码 public void useResourceBundle(){ String baseName="com.java7book.chapter4.resourcebundle.Messages"; ResourceBundle bundleEn=ResourceBundle.getBundle(baseName, Locale.US); bundleEn.getString("GREETING");//值为“Hello!” ResourceBundle bundleCn=ResourceBundle.getBundle(baseName, Locale.CHINA); bundleCn.getString("THANK_YOU");//值为“谢谢!” } 在第一种查找过程中,开发人员所能控制的地方很少。为了满足开发人员的需求,Java 6中引入了ResourceBundle.Control类来允许开发 人员对ResourceBundle的查找过程进行复杂的定制,同时也对查找过程进行了增强。开发人员可以通过继承ResourceBundle.Control类的方式 来定制自己所需的行为。实际上,从Java 6之后,第一种查找方式的内部实现也用了ResourceBundle.Control类,只不过用的是该类的默认实 现,以符合Java 6之前的查找过程。在ResourceBundle.Control类中可以定制的部分包括以下几个方面: 首先是ResourceBundle类的对象的类型。通过ResourceBundle.Control类的getFormats方法可以返回对于给定的基本名称应该要查找的 ResourceBundle类的对象的格式名称。前面提到的Java类和属性文件等两种类型的名称分别是“java.class”和“java.properties”。开发人 员可以选择只查找这两种基本格式中的一种,或者使用自定义的格式名称。 第二个可以定制的是在对指定的Locale类的对象进行查找时要搜索的Locale类的对象的列表。这个Locale类的对象的列表的作用等价于第 一种查找方式中的候选名称列表。通过覆写getCandidateLocales方法来为每个基本名称和指定的Locale类的对象提供一组Locale类的对象作为 候选名称。 第三个可以定制的是当通过给定的Locale类的对象找不到资源包时应该要尝试使用的Locale类的对象。第一种查找方式在这种情况下使用 的是Java虚拟机的默认Locale类的对象。通过覆写getFallbackLocale方法可以改变这种行为,以合适的Locale类的对象来替代。 第四个可以定制的是ResourceBundle类的对象的缓存行为。在默认情况下,ResourceBundle类的对象在查找完成并设置好父 ResourceBundle类的对象之后会被缓存起来。当下次再查找的时候,会直接使用缓存中的对象。通过第一种查找方式无法对缓存进行控制,而 ResourceBundle.Control类则提供了相关的缓存控制机制。可以为在缓存中的ResourceBundle类的对象指定一个存活时间(time to live)。 通过覆写getTimeToLive方法可指定这个时间。如果在调用getBundle方法的时候发现缓存中的ResourceBundle类的对象已经超过了其存活时 间,会调用ResourceBundle.Control类的needsReload方法来判断是否需要重新创建。如果需要,getBundle方法会重新查找并创建新的 ResourceBundle类的对象;否则还是会使用缓存中的对象并更新其存活时间。这个缓存机制与HTTP协议中的缓存机制是类似的。如果 ResourceBundle类的对象还有存活时间,是不会通过needsReload方法来进行检查的。使用ResourceBundle类的clearCache方法可以清空缓存的 ResourceBundle类的对象。 最后是通过newBundle方法来实际创建ResourceBundle类的对象。默认的实现只是提供了对Java类和属性文件的处理。如果采用的是自己的 资源包格式名称,需要在这个方法中添加相应的创建逻辑。 在使用了ResourceBundle.Control类的对象的情况下,资源包的查找过程与采用第一种查找方式相比,整体的流程是相似的。只不过在某 些关键步骤上getBundle方法会调用ResourceBundle.Control类中的特定方法来确定下一步的处理方式。在最开始的时候,getBundle方法会首 先检查缓存。接着是通过getFormats方法来确定要查找的资源包的格式。然后通过getCandidateLocales方法来得到要搜索的Locale类的对象列 表。列表中的Locale类的对象都会通过newBundle方法来尝试创建新的ResourceBundle类的对象。如果对上述Locale类的对象的尝试都失败,会 通过getFallbackLocale方法来得到一个新的替代Locale类的对象,并再次尝试上面的查找过程。 下面通过一个具体的示例来说明ResourceBundle.Control类的用法和如何创建自己的资源包类型。前面提到过Java平台本身就支持Java类 文件和属性文件两种资源包类型,而且Java类需要继承自ResourceBundle类。这里要创建的是一种基于任意Java类的资源包类型。这个Java类 中公开的静态方法的名称作为资源包中的键,而方法调用的返回结果则作为键对应的值。使用第2章中介绍的反射API实现起来并不复杂。首先 代码清单4-10中给出的ReflectiveResourceBundle类是自定义的资源包实现,其中的getKeys方法的实现是通过反射API得到当前类中所包含的 所有不带参数的公开静态方法,而handleGetObject方法则根据键的值找到对应的方法,在执行方法调用之后返回结果。之所以使用静态方法, 是因为在调用的时候不需要提供额外的接收者对象作为参数。 代码清单4-10 自定义的资源包实现 public class ReflectiveResourceBundle extends ResourceBundle{ private Class clazz; public ReflectiveResourceBundle(Class clazz){ this.clazz=clazz; } public Object handleGetObject(String key){ if(key==null){ throw new NullPointerException(); } try{ Method method=clazz.getMethod(key); if(method==null){ return null; } return method.invoke(null); }catch(Exception ex){ return null; } } public Enumeration<String>getKeys(){ Vector<String>result=new Vector<String>(); Method[]methods=clazz.getMethods(); for(Method method:methods){ int mod=method.getModifiers(); if(Modifier.isStatic(mod)&&Modifier.isPublic(mod)&&method.getParameterTypes().length==0){ result.add(method.getName()); } } return result.elements(); } } 接下来就是提供对应的ResourceBundle.Control类,如代码清单4-11所示。这里使用了“reflection”作为新资源包类型的名称。而在 newBundle方法的实现中,只需要根据基本名称加载对应的Java类,再把该类对应的Class类的对象封装在ReflectiveResourceBundle类的对象 中即可。 代码清单4-11 ResourceBundle.Control类的自定义子类 public class ReflectiveResourceBundleControl extends ResourceBundle.Control{ public List<String>getFormats(String baseName){ if(baseName==null){ throw new NullPointerException(); } return Arrays.asList("reflection"); } public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload)throws IllegalAccessException, InstantiationException, IOException{ if(baseName==null||locale==null ||format==null||loader==null){ throw new NullPointerException(); } ResourceBundle bundle=null; if(format.equals("reflection")){ String bundleName=toBundleName(baseName, locale); try{ Class<?>clazz=loader.loadClass(bundleName); return new ReflectiveResourceBundle(clazz); }catch(ClassNotFoundException ex){ return bundle; } } return bundle; } } 接着是以Java类的方式声明不同区域设置对应的资源包所包含的内容。每条记录对应一个类中的公开静态方法。代码清单4-12是一个简单 的示例,其中只包含一个键“greet”,而该键的值是随机变化的。 代码清单4-12 具体的资源包中所包含的内容 public class ReflectiveMessages_zh_CN{ public static String greet(){ return"你好,"+(Math.random()>0.5?"先生":"女士"); } } 最后是在实际中的应用,在代码清单4-13中可以看到,使用方式并没有很大的区别,只是在调用getBundle方法时多了一个 ReflectiveResourceBundleControl类的对象而已。 代码清单4-13 自定义资源包实现的使用 public void useReflectiveResourceBundle(){ String baseName="com.java7book.chapter4.resourcebundle.ReflectiveMessages"; ReflectiveResourceBundleControl control=new ReflectiveResourceBundleContr ol(); ResourceBundle bundle=ResourceBundle.getBundle(baseName, Locale.CHINA, control); Object value=bundle.getObject("greet"); } 得到了ResourceBundle类的对象之后,对它的使用主要是通过getString和getObject两个方法。对基于属性文件的ResourceBundle类的对 象来说,只能通过getString方法来获取字符串格式的内容;而对于基于Java类的ResourceBundle类的对象来说,getObject方法也是可以使用 的。在根据键来查找的时候,会考虑当前ResourceBundle类的对象在层次结构上的所有父ResourceBundle类的对象。如果从ResourceBundle类 的对象中得到的字符串中包含占位符,可以通过下面介绍的消息格式化来进行处理。 在日常的开发中,基于属性文件的ResourceBundle类的对象的使用是最多的。在Java 6之前,属性文件对应的PropertyResourceBundle类 的对象只能从InputStream类的对象中创建,而且对应的属性文件只能采用ISO 8859-1的编码格式。如果使用其他编码,会导致其中的内容无法 识别。因此,无法在属性文件中直接使用非ISO 8859-1字符集中的字符。JDK自带的native2ascii工具可以把其他编码格式的属性文件转换成 ISO 8859-1的格式。一般的做法是原始的属性文件本身使用UTF-8编码,由开发人员直接来修改。在构建过程中通过脚本的方式(如Apache Ant)调用native2ascii工具完成编码的转换。从Java 6开始,PropertyResourceBundle类也可以从java.io.Reader类的对象中创建,这就为其 他编码格式的属性文件提供了便利。基于Java 6及其以后版本的程序利用从Reader类的对象创建的这种方式,可以简化构建的过程。代码清单 4-14是一个使用了这种思想的ResourceBundle.Control类的子类。具体做法是,如果资源包的类型是“java.properties”,即表示属性文件, 就从得到的InputStream类的对象中利用指定的编码格式创建一个Reader类的对象,再从该Reader类的对象中得到PropertyResourceBundle类的 对象。 代码清单4-14 使用Reader类读取属性文件的ResourceBundle.Control类的子类 public class BetterResourceControl extends ResourceBundle.Control{ private String encoding="UTF-8"; public BetterResourceControl(String encoding){ if(encoding!=null){ this.encoding=encoding; } } public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload)throws IllegalAccessException, InstantiationException, IOException{ if("java.properties".equals(format)){ String bundleName=toBundleName(baseName, locale); String resourceName=toResourceName(bundleName,"properties"); InputStream stream=null; if(reload){ URL url=loader.getResource(resourceName); if(url!=null){ URLConnection connection=url.openConnection(); if(connection!=null){ connection.setUseCaches(false); stream=connection.getInputStream(); } } }else{ stream=loader.getResourceAsStream(resourceName); } BufferedReader reader=new BufferedReader(new InputStream-Reader(stream, encoding)); return new PropertyResourceBundle(reader); } return super.newBundle(baseName, locale, format, loader, reload); } } 在程序中,可以把ResourceBundle.Control类的使用隐藏起来,提供一个封装好的工厂方法,如代码清单4-15所示。这样程序的其他部分 只需要用UTF-8格式来编写属性文件,并用这个工厂方法来加载即可。 代码清单4-15 使用工厂方法封装对ResourceBundle.Control类的使用 public class ResourceBundleLoader{ public static ResourceBundle load(String baseName, Locale locale){ BetterResourceControl control=new BetterResourceControl(null); return ResourceBundle.getBundle(baseName, locale, control); } } 通过这种方式,就省去了对native2ascii工具的使用,简化了构建过程。下面要介绍的是Java提供的对日期和时间、货币和数字以及消息 的格式化和解析的支持。类java.text.Format及其子类用来完成相应的格式化和解析操作。格式化是通过format方法来实现,即把一个对象转 换成字符串类型;而解析则通过parseObject方法来实现,即把一个字符串转换成对象。除了标准库中提供的格式化支持之外,程序也可以通过 继承Format类来开发针对特定对象的格式化实现。 4.4.3 日期和时间 除了用户界面中显示的消息文本之外,日期和时间也是需要根据区域设置进行处理的重要内容。不同国家和地区所惯用的日期和时间的格 式都不尽相同。日期和时间中除了通用的阿拉伯数字之外,一般还包含对应语言中的相关字符。这些字符也需要进行本地化。Java程序内部一 般使用java.util.Date和java.util.Calendar类来处理日期和时间。当把这些对象的值输出到用户界面上的时候,需要考虑与区域设置相关的 格式化操作。这一般是通过java.text.DateFormat类来完成的。DateFormat类对日期和时间的处理能力有格式化和解析两种,用来在Date类的 对象和字符串形式之间进行转换。转换的时候都会考虑区域设置。 DateFormat类的对象是通过工厂方法来创建的,可以选择只处理日期、只处理时间以及两者都处理的DateFormat类的对象,分别通过 DateFormat类的静态方法getDateInstance、getTimeInstance和getDateTimeInstance来创建。在创建的时候,可以指定所用的格式和对应的 Locale类的对象。格式共有SHORT、MEDIUM、LONG和FULL四种,按照包含的信息量从少到多进行排列。 得到DateFormat类的对象之后最直接的做法是通过format方法把一个Date类的对象转换成字符串,以及通过parse方法把一个字符串转换成 Date类的对象。在通过format方法进行格式化的时候,有时候需要单独提取出格式化之后的内容中的一部分,比如只希望得到格式化之后的日 期中关于星期的部分,这时可以使用parse方法的另外一种重载形式,需要用到java.text.FieldPosition类。简单地说,日期和时间格式化之 后的结果字符串由多个部分组成,通过FieldPosition类可以跟踪感兴趣的部分在最终生成的字符串中的起始和结束位置。代码清单4-16给出了 一个示例,在创建FieldPosition类的对象的时候就指定了要记录的是日期中的星期信息,使用了DateFormat.DAY_OF_WEEK_FIELD来进行声明。 在格式化完成之后,就可以从保存结果的StringBuffer类的对象中根据FieldPosition类的对象中保存的位置得到所需要的信息。在DateFormat 类中,日期和时间格式中的不同类型的组成部分都有相关的常量定义。使用这些常量定义可以选择跟踪日期和时间中的其他组成部分,如使用 DateFormat.YEAR_FIELD可以跟踪年份信息。 代码清单4-16 跟踪格式化结果中不同部分字符串的位置的示例 public void trackFormatPosition(){ DateFormat format=DateFormat.getDateInstance(DateFormat.FULL); Date date=new Date(); StringBuffer result=new StringBuffer(); FieldPosition dayField=new FieldPosition(DateFormat.DAY_OF_WEEK_FIELD); format.format(date, result, dayField); String day=result.substring(dayField.getBeginIndex(),dayField. getEndIndex());//值为星期几的本地化形式 } 同样的,在通过parse方法进行解析的时候,也可以传入一个java.text.ParsePosition类的对象来表示在字符串中的解析位置。在解析之 前,ParsePosition类的对象可以用来表示解析的起始位置;而在解析之后,该ParsePosition类的对象中可以得到解析成功之后的下一个位 置。代码清单4-17给出了一个示例,如果要解析的字符串中包含日期和时间的部分不是从起始位置开始的,就需要通过ParsePosition类的对象 来指定起始的位置,否则无法解析。在解析完成之后,可以通过当前ParsePosition类对象的getIndex方法来获取输入字符串中的当前位置。 代码清单4-17 设置解析时的起始位置的示例 public void parseWithPosition(){ DateFormat format=DateFormat.getDateInstance(DateFormat.FULL); Date date=new Date(); String dateStr=format.format(date); String prefix="==START=="; String toParse=prefix+dateStr+"==END=="; ParsePosition position=new ParsePosition(prefix.length()); Date d=format.parse(toParse, position); int index=position.getIndex(); } 如果希望对日期和时间的格式化进行更加精细的定制,可以使用DateFormat类的子类SimpleDateFormat。SimpleDateFormat类允许使用一 个自定义的模式来指定格式化的输出结果。在模式中,不同的字符表示日期和时间的不同组成部分。常见的字符有表示年份的“y”,表示月份 的“M”,表示月份中日子的“d”,表示小时数的“H”,表示分钟数的“m”和表示秒数的“s”。例如,模式“yyyy-MM-dd”的输出结果可能 是“2011-07-12”,“HH:mm:ss”的输出结果可能是“13:23:03”。 4.4.4 数字和货币 数字和货币的格式化也是国际化中的重要组成部分。不同国家和地区在数字中小数点的位置、是否使用数字间的分隔符,以及使用的数字 符号等方面都有所不同。而在货币方面,货币的符号也有所不同。Java也提供了与区域设置相关的数字和货币的格式化和解析功能,这个功能 通过java.text.NumberFormat类来实现。 NumberFormat类的使用与上一节介绍的DateFormat类的用法很类似,也要通过工厂方法来创建。可以创建的NumberFormat类的对象总共有4 种:getNumberInstance方法得到一个通用的数字格式化对象,getIntegerInstance方法得到一个处理整数的格式化对象,getPercentInstance 方法得到一个处理百分数形式的数字的格式化对象,getCurrencyInstance方法得到一个处理货币的格式化对象。每个NumberFormat类的对象同 样有format和parse方法来分别格式化和解析数字,FieldPosition和ParsePosition类也同样可以使用。除了常规的选项之外,NumberFormat类 还支持对格式化和解析时的行为进行定制,包括定制格式化之后的数字中整数和小数的位数、是否对数字进行分组,以及对数字进行四舍五入 的方式等。代码清单4-18给出了NumberFormat类的使用示例,其中设置了数字在格式化时的整数和小数部分的最少位数。 代码清单4-18 格式化和解析数字的示例 public void formatAndParseNumber()throws ParseException{ NumberFormat format=NumberFormat.getNumberInstance(); double num=100.5; format.setMinimumFractionDigits(3); format.setMinimumIntegerDigits(5); format.format(num);//值为00,100.500 String numStr="523.34"; format.setParseIntegerOnly(true); format.parse(numStr);//值为523 } NumberFormat类本身对数字所做的格式化和解析功能比较有限。如果希望更多地定制数字的格式,可以使用NumberFormat类的子类 java.text.DecimalFormat。实际上,通过NumberFormat类的工厂方法所得到的通常都是DecimalFormat类的对象。可以进行强制类型转换之后 再使用DecimalFormat类中的方法。除此之外,还可以直接创建DecimalFormat类的对象。DecimalFormat类的好处之一在于可以用字符串的方式 来声明格式化的模式。代码清单4-19给出了使用DecimalFormat类的示例,通过applyPattern方法可以直接设置格式化的模式。 代码清单4-19 DecimalFormat类的使用示例 public void useDecimalFormat(){ NumberFormat format=NumberFormat.getNumberInstance(); DecimalFormat df=null; if(format instanceof DecimalFormat){ df=(DecimalFormat)format; } else{ df=new DecimalFormat(); } df.applyPattern("000.###"); df.format(3.14);//输出为003.14 } 另外一个可用的类是java.text.ChoiceFormat,它可以用来实现根据数字值的大小来应用不同的格式化模式。一个典型的应用场景是某些 语言中的单复数所用的形式不同,需要根据数字值的大小来使用不同的文本。例如,英语中的单词“apple”的复数形式是“apples”,在使用 时要使用正确的形式,如“one apple”和“three apples”。在创建ChoiceFormat类的对象时需要指定一个double类型的数组作为进行数值比 较时的各个区间的端点值,以及与这些区间对应的格式化字符串。当格式化的数字的值落在某个区间上的时候,就应用这个区间对应的格式化 模式。代码清单4-20给出了一个示例,其中的ChoiceFormat类的对象在创建的时候指定了3个数字值,实际上创建了4个区间。每个区间都是左 闭右开的,也就说,如果值等于这些数字,属于左边的数字值。ChoiceFormat类的nextDouble和previousDouble方法分别用来得到大于给定参 数的最小的和小于给定参数的最大的双精度浮点数。这两个方法的返回结果通常分别作为最右边和最左边的区间的端点值。如果数字值落在最 左边或最右边的区间中,则分别使用第一个和最后一个格式化字符串。在代码清单4-20中,只有值为1的情况会对应第二个格式化字符串,任何 大于1的值都会对应第三个格式化字符串。 代码清单4-20 ChoiceFormat类的使用示例 public void useChoiceFormat(){ ChoiceFormat format=new ChoiceFormat(new double[]{0,1,ChoiceFormat.nextDouble(1)}, new String[]{"no people","person","people"}); int count=2; String msg=count+""+format.format(count);//值为2 people } 在创建ChoiceFormat类的对象时并没有利用Locale类的对象,而是直接使用了普通的字符串。实际上,ChoiceFormat类本身并不处理与区 域设置相关的内容,通常需要与资源包配合起来使用。利用从资源包中得到的字符串作为创建时的格式化模式来使用。 4.4.5 消息文本 这里要介绍的是对字符串进行格式化和解析的java.text.MessageFormat类。之所以到此才介绍,是因为MessageFormat类在本身的模式之 中允许使用上面介绍的DateFormat、NumberFormat和ChoiceFormat类的对象作为其内部的子模式。当一个字符串中包含日期、时间和数字的时 候,就可以利用MessageFormat类的这个特性。MessageFormat类的重点在于其使用的模式,可以在构造方法中提供,也可以通过applyPattern 方法来改变。如果使用某个模式的MessageFormat类的对象只打算使用一次,可以用MessageFormat类中的静态方法format来快速根据某个模式 进行格式化,不需要单独创建新的对象。在每个模式中除了直接显示的字符串之外,一般都包含在运行时刻进行替换的实际参数的占位符。在 进行消息国际化时的一个基本原则是避免在代码中进行一段消息的连接操作,要把这整段消息都放在资源包中,由MessageFormat类来处理。例 如,要生成类似“你好,张三,今天是星期五”这样的消息内容,其中的姓名和日期是可变的。不推荐用字符串拼接的做法把消息中的几个部 分连接起来,而推荐把这一段消息都放在资源包中,写成“你好,{0},今天是{1}”这样的带参数占位符的形式。这其中的重要原因是不同语 言的行文顺序是不同的。如果按照字符串相加的做法,某些语言就无法翻译成通顺的句子。 根据上面的示例,在MessageFormat类的模式中,参数占位符是通过在“{}”中包含参数的序号的方式来表示的。这些序号对应的是调用 format方法进行格式化时提供的实际参数的数组中的位置,以及调用parse方法所得到的返回值的数组中的位置。对于每个参数,还可以进一步 声明格式化的具体类型和模式。格式化类型的可选值有number、date、time、choice,而对应的模式则既可以是标准模式,又可以是自定义模 式。例如,对number类型来说,既可以使用NumberFormat类支持的标准模式integer、currency和percent,又可以使用自定义的模式;对于 date和time类型来说,可以使用的标准模式是DateFormat类支持的short、medium、long和full;而对于choice类型来说,则只支持自定义的模 式。代码清单4-21给出了MessageFormat类的使用示例,一共定义了3个数字类型的参数,前两个使用的是标准模式integer和currency,而第三 个使用的是自定义的模式。 代码清单4-21 MessageFormat类的使用示例 public void formatWithNumber(){ String pattern="购买了{0,number, integer}件商品,单价为{1,number, currency},合计:{2,number,\u00A4#,###.##}"; MessageFormat format=new MessageFormat(pattern); int count=3; double price=1599.3; double total=price*count; format.format(new Object[]{count, price, total}); } 除了通过MessageFormat类的模式字符串来声明内部所使用的子模式之外,也可以通过MessageFormat类的方法来进行设置。如果程序中提 供了自己的Format类的子类实现,则可以通过这种方式来进行设置。与设置相关的一共有4个方法,区别在于是设置一个还是多个Format类的对 象,以及是根据Format类的对象在格式化模式中的出现顺序还是参数顺序。考虑格式化模式“共有{1}人参加会议,其中{0}来自本部门”,其 中定义了两个参数,不过第二个参数出现在第一个的前面。如果希望按照在模式中实际参数值的顺序来设置两个参数对应的Format类的对象, 可以使用setFormatsByArgumentIndex方法;如果只希望设置单个参数的Format类的对象,可以使用setFormatByArgumentIndex方法;如果希望 按照在模式中的出现顺序来设置,可以相应地使用setFormats和setFormat方法。由于表达同样语义的字符串在经过翻译之后,其中参数的出现 位置可能发生变化,因此推荐的做法是基于参数值的顺序进行设置。 把程序中的文本都抽取出来放到资源文件中,这对于程序本身的可维护性也是很有帮助的。有些文本可能会在程序中出现多次,如果每次 都是直接在源代码中使用,当文本发生变化的时候,需要在多个地方进行修改。如果文本存放在资源文件中,引用时使用的都是抽象的固定的 键,对应的内容可以随意修改,而且只需要修改属性文件这一个地方即可。 4.4.6 默认区域设置的类别 在程序的国际化实现中,一般都使用当前Java虚拟机默认的区域设置信息。这个默认的Locale类的对象对用户来说通常是最合适的选择。 Java 7对默认的Locale类的对象进行了更进一步地细分,划分成不同的类别。这些类别定义在Locale.Category这个枚举类型中,目前包括 DISPLAY和FORMAT两种。类别为DISPLAY的Locale类的对象在显示用户界面时会作为默认的区域设置,而类别为FORMAT的Locale类的对象则在格 式化日期和时间、数字和货币的时候被作为默认的区域设置。同时Locale类中的getDefault和setDefault方法也支持使用Locale.Category枚举 类型中的值作为参数来获取和设置对应类别的默认Locale类的对象。 提供不同类别的默认Locale类的对象的动机在于为程序提供更多的灵活性。因为在某些情况下,显示用户界面需要的区域设置与格式化日 期和时间等用的区域设置可能是不相同的。由于默认区域设置的类别在Java 7中才引入,对于习惯了Java 7之前的只有一种默认区域设置的开 发人员来说,容易产生错误。 以笔者的Windows XP系统为例,它本身的系统语言是英语,但是区域设置是中国。当通过getDefault方法来获取默认区域设置的时候,会 发现DISPLAY类别的区域设置是en_US,而FORMAT类别的区域设置则是zh_CN。如果在默认的区域设置下使用MessageFormat类,会发现一个有趣 的现象,如下面的代码清单4-22所示,通过MessageFormat类的对象格式化之后的文本中同时包含了英文和中文,类似“Hello,张三.Today is 11-8-6下午5:17.”。这是因为ResourceBundle类在查找资源包时找到的是DISPLAY类别的默认区域设置对应的属性文件,而在格式化日期的时 候,使用的则是FORMAT类别的默认区域设置,两者是不同的。 代码清单4-22 区域设置类别的使用示例 public void useLocaleCategory(){ ResourceBundle bundle=ResourceBundle.getBundle("messages"); String str=bundle.getString("GREETING"); String msg=MessageFormat.format(str, new Object[]{"张三",new Date()}); } 如果希望统一不同类别的区域设置,可以在程序启动之初通过setDefault方法把两个类别的默认区域设置设置为相同的值。 4.4.7 字符串比较 另外一个开发人员了解得比较少的需要进行本地化处理的是字符串的比较操作。对于英语来说,字符串的比较一般是按照字典顺序进行 的。而在其他语言中,则有自己的字符串比较规则。当需要与区域设置相关的字符串进行比较操作时,应该使用java.text.Collator类来完 成。 Collator的类和前面提到的DateFormat和NumberFormat类比较类似,也是通过一个工厂方法getInstance来得到一个具体的对象。通过 Collator类的compare方法可以比较大小,返回值的含义与Java中常见的基于java.util.Comparable接口的比较操作是相同的。另外也提供 equals方法来判断两个字符串在当前的区域设置下是否相等。 对Collator类来说,在进行排序时需要考虑的一个因素是如何处理不同语言中的字符的各种变体。在有些语言中,某些字符虽然表现形式 不同,但是在排序的语义上却是相同的。通过设置Collator类的对象的分解模式可以定制对变体的处理行为。这些不同的行为是由Collator类 中的常量来定义的:NO_DECOMPOSITION表示不做任何处理,CANONICAL_DECOMPOSITION表示按照Unicode中定义的规范变体来进行处 理,FULL_DECOMPOSITION表示不仅考虑Unicode中的规范变体,也考虑其中的兼容性变体。规范变体在Unicode中指的是互为变体的字符。最常 见的变体形式是某些语言中的同一发音的不同声调由不同的字符来表示。这些不同的声调在比较的时候是没意义的。如果使用的是 NO_DECOMPOSITION模式,会认为不同声调的字符是不同的,这会造成错误的结果。对于包含声调的语言,采用CANONICAL_DECOMPOSITION或 FULL_DECOMPOSITION才会得到正确的结果。通过setDecomposition方法可以改变Collator类的对象所使用的分解模式。 另外一个需要在比较时考虑的因素是差异的不同粒度。比如,从不同角度来说,可以认为大写字母“A”和小写字母“a”是不同的,也可 以认为是相同的。Collator类中定义了4种不同的差异粒度,按照从粗到细的顺序分别由常量PRIMARY、SECONDARY、TERTIARY和IDENTICAL表 示。这4种粒度的具体含义与具体的区域设置相关的。当通过setStrength方法把粒度设置在某个值上的时候,比较时只会考虑该粒度及其之上 的差异。代码清单4-23给出了粒度对比较结果的影响。 代码清单4-23 字符串比较时的粒度选择对结果的影响 public void useCollator(){ Collator collator=Collator.getInstance(Locale.US); collator.setStrength(Collator.PRIMARY); collator.compare("abc","ABC");//值为0,认为是相等 collator.setStrength(Collator.IDENTICAL); collator.compare("abc","ABC");//值为-1 } 4.5 国际化与本地化基本实践 为Java程序添加国际化的支持,只需要遵循一定的模式即可,更多的是执行按部就班的过程。下面对这个过程进行具体介绍,并说明其中 一些需要注意的地方。 第一步是提取出程序中需要本地化的内容。这些内容包括前面介绍的用户界面上的消息文本、日期和时间、数字和货币,以及字符串比较 操作等。这一步可以在程序开发的早期开始进行,即在代码编写过程中就进行提取;也可以在程序开发的后期进行。比较推荐的做法是在程序 开发的后期进行。这主要是因为在程序开发过程中,本地化的内容可能还在不断变化,过早进行相关内容的提取,可能造成工作量的浪费。 由于消息文本是最常见的需要本地化的内容,很多集成开发环境(IDE),如Eclipse和NetBeans,都提供了相应的功能来实现快速提取。 利用IDE的支持,可以提高提取工作的效率。在源代码中,某些字符串字面量并不需要进行本地化。应该把这些字符串字面量重构为Java类中的 字符串常量,以区别于其他需要本地化的字符串字面量。对于除消息文本之外的其他需要本地化的内容,需要开发人员手动识别。 第二步是添加国际化的能力。这一步需要对程序的代码进行修改。对于消息文本,把直接使用字符串字面量的方式替换成从资源包中根据 指定的键来读取对应的值。从源代码中提取出来的文本内容被保存到一个属性文件中。一般来说,一个程序使用一个属性文件即可。对于复杂 的程序,其中的每个组件可以有属于自己的独立的属性文件。开发人员需要负责为每条文本指定一个有意义的键的名称。在源代码中有可能使 用了字符串拼接的方式来生成一段长文本,对于这种情况,需要把多个字符串字面量提取成资源包中的单条记录。代码清单4-24给出了一个未 经本地化处理的Java类。 代码清单4-24 未经本地化处理的Java类 public class NormalGreetings{ public String greet(String name){ return"Hello,"+name+".Today is"+new Date().toString()+"."; } } 由于代码清单4-24中实际上只包含一条消息,在进行提取时,需要对多个字符串字面量进行合并。在属性文件中只包含一条记录, 即“GREETINGS=Hello,{0}.Today is{1}.”。下一步需要创建相关的辅助Java类来负责从资源包中读取消息文本。代码清单4-25中给出了辅助 Java类的示例。在Messages类的静态代码块中,调用ResourceBundle类的getBundle方法来加载指定基本名称的资源包。如果找不到,则用一个 空的ListResourceBundle类的对象来代替。Messages类的get方法用来根据消息文本的键和进行格式化的实际参数值来得到最终显示的文本。如 果在资源包中找不到给定的键,则返回一个特殊形式的文本内容来进行声明。 代码清单4-25 从资源包中读取消息文本的辅助Java类 public class Messages{ private static ResourceBundle bundle; static{ try{ bundle=ResourceBundle.getBundle("com.java7book.chapter4.demo.Messages",LocaleHolder.get()); }catch(MissingResourceException e){ e.printStackTrace(); bundle=new ListResourceBundle(){ protected Object[][]getContents(){ return new Object[0][0]; } }; } } public static String get(String key, Object……args){ try{ String value=bundle.getString(key); return MessageFormat.format(value, args); } catch(MissingResourceException e){ return"!"+key; } } } 在调用ResourceBundle类的getBundle方法时,总是应该显式地指定一个Locale类的对象,而不是依靠Java平台的默认区域设置。这是因为 默认区域设置可能随着底层操作系统的变化而发生改变,可能会对程序的行为产生影响。一个比较好的做法是把应该使用的Locale类的对象保 存在ThreadLocal类的对象中,与当前的运行线程绑定在一起。这种方式尤其适合于Web应用。 对于除消息文本之外的其他需要本地化的内容,在代码中使用对应的Format类的子类对象来进行格式化。具体的使用方式参考前面对 DateFormat和NumberFormat类的介绍。 在选择程序使用的区域设置时,通常依次考虑三种情况。第一种情况是用户显式指定的区域设置。程序应该提供相关的功能供用户手动设 置使用的区域设置。如果用户进行了选择,那么应该使用用户所选择的区域设置。第二种情况是自动检测区域设置。这情况通常发生在Web应用 中。在HTTP请求中,可以通过“Accept-Language”头来指定所要求使用的区域设置。Web服务器需要处理“Accept-Language”头,并返回正确 的响应。第三种情况是使用虚拟机的默认设置。使用Locale类的getDefault方法可以获取默认设置。 第三步是进行内容的本地化。这主要是针对消息文本的。这一步通常需要由专门的翻译人员对包含消息文本的属性文件的内容进行翻译。 翻译之后的属性文件的名称需要进行修改,以表明所对应的区域设置,如“Messages_en_US.properties”文件名表示的是针对区域设 置“en_US”的属性文件。 最后一步是进行相关的测试。首先把程序的区域设置修改成待测试的值,再查看用户界面上的内容。检查用户界面上的消息文本、日期和 时间,以及数字和货币的显示方式是否正确。对于Web应用来说,通过修改浏览器的设置可以改变所发送的“Accept-Language”HTTP头的值。 通过这种方式来测试Web页面的展示结果是否正确。如果程序在选择区域设置时使用了虚拟机的默认区域设置,可以通过虚拟机的启动参 数“user.language”、“user.country”、“user.script”和“user.variant”来修改默认的区域设置,比如,使用“-Duser.country=US- Duser.language=en”可以把默认的区域设置修改为“en_US”。 4.6 小结 从实际开发的角度来说,Java程序的国际化并不是一件很麻烦的事情。大多数时候要做的只是利用集成开发环境(IDE)提供的向导功能, 把源代码中的硬编码字符串提取出来,放到某个属性文件中,再由专门的人员进行翻译即可。IDE的功能已经完善到让开发人员不用理会过多的 底层细节。而除了消息文本之外,对日期和时间以及数字和货币等类型来说,也多半只需要利用提供的工厂方法进行格式化即可。更多的时候 要认识到国际化是软件开发中的重要一环,而不是忽略它。 本章虽然主要介绍了Java 7中提供的与区域设置以及格式化相关的标准API,但是本章开始时介绍的字符的编码和解码等相关的背景知识也 很重要。了解Unicode字符集和UTF-8等编码格式可以帮助开发人员在遇到一些棘手问题时知道从何处着手进行解决。 第5章 图形用户界面 与Java在服务器端,尤其是Web应用开发领域的成功相比,Java在桌面应用开发中的地位一直比较尴尬,没有发展起来。很少有面向普通用 户的桌面应用是基于Java平台开发的。Java在桌面应用上比较成功的案例是集成开发环境(IDE)的实现,包括Eclipse和NetBeans等。Java在 桌面应用开发中没能流行的原因有很多,其中一个重要的原因在于Java桌面应用的运行离不开Java运行环境(Java Runtime Environment, JRE)的支持,JRE在应用本身之外,需要安装在用户的操作系统之上,这就增加了用户安装和使用的难度。有些Java桌面应用选择把JRE打包在 一起,不过这样又会造成安装包过大的问题。还有一个原因是性能方面的影响,由于存在Java虚拟机这样一个中间层,Java桌面应用的性能通 常要比原生代码编写的应用差一些,这会对普通用户的使用体验带来不好的影响。 当然,Java桌面应用也并非一无是处。Java平台通过Java虚拟机实现的“编写一次,到处运行”的特性在很多场合都是很有价值的。只需 要维护一份代码,就可以在不同的操作系统平台上运行功能相同的桌面应用。进行Java开发也可以充分利用Java平台上丰富的社区资源,包括 文档、示例和开源类库等。局域网内部使用的桌面应用很适合于用Java来开发,比如公司内部的管理系统等。桌面应用可以提供比Web应用更强 的交互能力。而在内部网中,部署相关的问题也比较好解决。如果既要开发跨平台的桌面应用程序,又能比较好的解决部署的问题,那么Java 平台是一个不错的选择。 Java的用户界面组件库在使用上比较简单,只需要熟悉相关组件的API即可。限于篇幅,本章内容不会深入到桌面应用开发的具体细节,不 会介绍具体的用户界面组件的用法,只是侧重在Java用户界面库中比较复杂和难以理解的部分,以及Java 7中增加的与图形用户界面相关的新 特性。最后着重介绍了Java平台的下一代桌面应用开发技术JavaFX。 5.1 Java图形用户界面概述 Java平台的图形用户界面库与其他编程语言平台所提供的图形用户界面库相比,总体上来说大同小异。图形用户界面库通常包含3个要素: 组件、布局和事件。图形用户界面库可以看成是一些用户界面组件的集合。不同的组件提供不同的交互能力。开发人员通过某种布局方式把这 些组件排列起来以构建用户界面,与用户进行交互。交互的基本模式是典型的事件驱动方式。用户的各种动作触发相应的事件,而事件的处理 器则实现对应的业务逻辑。Java平台上的图形用户界面库也由这3个要素组成。不同图形用户界面库的区别主要在所使用的编程语言、组件的丰 富性和布局的灵活性上。在Java平台上,实际上存在几种可选的图形用户界面库。 ·AWT 在Java语言刚诞生的时候,它提供的图形用户界面库叫做抽象窗口工具箱(Abstract Window Toolkit, AWT)。AWT在底层操作系统提供的 原生图形用户界面的基础上,提供了一个新的抽象。这样实现的目的是为了让用户界面也能完全跨越不同的平台,为开发人员屏蔽底层实现的 细节。这个抽象层所做的只是根据当前的操作系统平台创建组件对应的原生控件,再把组件上的方法调用直接代理给原生控件。对于每一个AWT 组件,在后台都有一个原生控件与其对应。这个原生控件被称为AWT组件的对等体(peer)。这种对应关系使AWT组件的运行时开销比较大;另 外在资源管理方面也比较麻烦,要处理原生控件的资源释放问题。AWT组件的外观渲染实际上是由其对等体来完成的,因此同一个AWT组件在不 同操作系统平台上的外观是不同的,这取决于当前操作系统的外观样式。同样,同一个基于AWT的桌面应用在不同的操作系统平台上的外观是不 同的,符合当前操作系统的外观样式。 AWT作为Java平台上最早的图形用户界面库,它所包含的内容是比较完备的。首先是一组常用的用户界面组件,包括按钮、文本框、菜单和 窗口等。这些构成了应用的用户界面的基础。其次是事件处理系统,用来处理系统中发生的事件及响应用户的动作。最后是一些辅助功能,包 括布局管理、鼠标和键盘支持、剪贴板访问、拖放的支持、访问系统托盘等。有了这些功能,就可以开发出跨平台的桌面应用。总的来说,AWT 以一种相对简单的办法实现了跨平台的用户界面。 ·Swing AWT虽然为Java的桌面应用开发创造了一个良好的开端,但是它所提供的功能还是比较有限的,难以应付一些复杂的应用需求,也缺少一些 常用的复杂组件。另外AWT开发的应用只能采用与底层操作系统相似的外观风格,无法方便地进行外观定制。JDK 1.2中引入的Swing用户界面库 从不同方面解决了AWT中的问题。 与AWT直接利用底层操作系统的原生控件的做法不同,Swing的用户界面组件是由Java平台自己绘制出来的,也就是说,用户看到的组件界 面其实是通过Java提供的二维图形绘制功能(Java 2D)画出来的。不过Swing用户界面仍然是基于AWT的。一个完整的Swing用户界面是在一个 空白的AWT组件上绘制的。从这点来看,Swing所消耗的系统资源要低于AWT,因为一个Swing用户界面只需要使用一个操作系统提供的原生控 件。一般把Swing的用户界面组件称为轻量级组件,而将AWT的用户界面组件称为重量级组件。另外,由于Swing组件是Java平台自己进行绘制, 可以对组件的外观进行完善的自定义。除了Swing自带的几个可选的样式风格之外,应用也可以完全定制自己的样式风格。而在用户界面组件方 面,Swing也提供了更加丰富的组件,包括一些常见的复杂组件,如表格和树形控件等。在设计方面,Swing也更加成熟,很多组件都采用了模 型-视图-控制器(Model-Viewer-Controller, MVC)设计模式,层次更清晰,使用起来更容易。新开发的桌面应用都推荐使用Swing,而不是 AWT。 ·SWT 标准小部件工具箱(Standard Widget Toolkit, SWT)是开发Java桌面应用时可以使用的另外一个图形用户界面库,也是Swing的有力竞争 对手之一。SWT最突出的使用是在Eclipse中,它是Eclipse使用的底层图形用户界面框架。SWT的实现方式与AWT类似,也采用创建底层操作系统 中的原生控件的做法。不过SWT比AWT更进一步,提供了与Swing相匹敌的丰富的组件库。JFace在SWT的基础上又提供了更多实用的组件。 从功能和性能等方面来说,并不存在Swing和SWT中的一个一定强于另外一个的说法。两者各有各的优势和不足,也都可以满足日常的开发 需求。不过两者的API风格有比较大的区别,组件的使用方式也有较大不同。对于开发人员来说,两者唯一的区别仅在于已经熟悉或打算熟悉哪 种API风格而已。熟悉SWT和JFace的一个额外好处是在开发Eclipse插件时会更加得心应手。 SWT不是Java平台默认支持的用户界面库,需要下载额外的第三方库,因此本章没有对SWT进行过多的介绍。 ·JavaFX AWT和Swing一直以来是基于Java平台的桌面应用的基本开发框架。AWT和Swing适合开发传统的数据驱动的桌面应用,如信息管理系统等。 这类应用大多只使用AWT和Swing中已有的组件,在数据展现和交互模式上也比较简单。现代的桌面应用对交互性提出了更高的要求,这些新的 应用一般大量使用图片、音频和视频等多媒体内容。界面组件库中的通用组件不能满足应用的需求,因此,应用一般会大量使用自定义的组 件。这类应用中比较典型的有多媒体应用和游戏等。AWT和Swing不适于这类应用的开发。JavaFX的作用是推动Java桌面开发继续向前发展,以 满足现代桌面应用的开发需求。 JavaFX的第一个版本在2007年发布。它的最初目标是开发富互联网应用程序(Rich Internet Application, RIA)。JavaFX在早期是与 Adobe的Flex及微软的Silverlight相互竞争的。受限于JRE的部署状况,JavaFX的表现一直差强人意。Oracle收购了Sun之后,投入了大量的精 力对JavaFX进行推广和更新。JavaFX 2.0在2011年10月正式发布,它调整了JavaFX中的很多概念,并且重新设计和实现了很多重要组件。目 前,在运行基于JavaFX开发的程序时,需要在JRE之外安装额外的JavaFX运行时(JavaFX runtime)环境支持。不过,JavaFX运行时环境支持计 划在Java SE 7 Update 2中与JRE共同安装。安装JRE 7 Update 2的用户将可以直接运行JavaFX程序。JavaFX 3.0也正在开发中,将作为Java SE 8的一个组成部分。在以后的开发中,AWT和Swing将会逐渐淡出桌面应用开发人员的视野,JavaFX将成为Java平台上主流的图形用户界面开 发库。 总的来说,使用Java平台开发桌面应用并不是一件复杂的事情。大多数时候,你会发现开发的过程比较程式化,基本上就是创建组件、设 置其属性和绑定事件处理器这几步。不过也确实有一些容易出错的地方,后面会进行介绍。 5.2 AWT AWT作为Java中最早的图形用户界面库,具有了与底层操作系统进行交互的能力。即便后来的Swing用Java自己的方式来绘制组件,也还要 依赖AWT与底层操作系统进行交互。从设计的角度来说,AWT的组件库采用的是基于类继承的层次结构。每一个组件都是java.awt.Component类 的对象。开发人员既可以使用AWT中的标准组件,也可以开发自己的组件。Component类中定义了操作组件的各种方法,包括常见属性(如组件 的大小、位置、字体和颜色等)的获取和设置,事件监听器的处理,以及组件状态的改变等。关于这些方法的使用,通过方法名称和API文档就 可以了解到具体的细节,这里不再赘述。 5.2.1 重要组件类 Component类的一个重要子类是java.awt.Container。顾名思义,Container类的对象用来包含其他AWT中的Component类的对象,是一个通 用的容器。通过Container类的add和remove方法可以向容器中添加和删除组件。Component类的对象(尤其是Container类)的一个重要内部属 性是该组件的有效状态。一个组件上发生与布局相关的变化之后,这个组件就会被置为无效状态。这些与布局相关的变化包括:组件的大小发 生变化,Container类的对象中添加或删除了子组件。当一个组件被置为无效状态的时候,它的层次结构树上的所有祖先组件都会被置为无效状 态。通过Component类的isValid方法可以检查一个组件当前是否处于有效状态。对于处于无效状态的组件,需要调用其validate方法来对包含 的所有子组件重新进行布局,以恢复到有效的状态。由于validate方法会处理所有的子组件,因此一般会比较耗时。从性能的角度出发,最好 对组件进行批量修改之后,再调用一次validate方法来重新布局。如果希望直接把某个组件设成无效的状态,可以使用invalidate方法。 考虑到validate方法的性能问题,Java 7把Swing中原有的有效性验证根组件(validate root)的概念扩展到了AWT中。在之前的实现中, 如果一个组件被置为无效状态,那么从该组件的层次结构树向上,直到界面的顶层组件,这条路径上的所有组件都会被置为无效状态。在使用 validate方法的时候要考虑所有无效的组件及其子组件,这无疑对性能影响较大。有效性验证根组件指的是某些特定类型的组件。AWT中 Container类及其子类所表示的组件都可以作为有效性验证根组件。这些组件的子组件的无效状态不会影响到有效性验证根组件的父组件。也就 是说,在沿着层次结构树往上设置父组件的无效状态的时候,如果发现父组件是有效性验证根组件,就不需要再把有效性验证根组件的父组件 也置为无效状态了,这样就可以降低无效状态的影响范围。一个组件是否为有效性验证根组件可以通过isValidateRoot方法来判断。AWT中最常 见的有效性验证根组件是java.awt.Window类。Swing中的javax.swing.JScrollPane类也是有效性验证根组件。在使用了有效性验证根组件的情 况下,可以通过revalidate方法来使一个组件树快速地重新恢复到有效状态,因为revalidate会把有效性验证根组件考虑在内。不过为了与早 期版本保持兼容性,有效性验证根组件并不是默认启用的,需要通过将Java系统参数“java.awt.smartInvalidate”设为“true”来启用它。 在需要恢复一个组件树到有效状态的时候,开发人员应该使用revalidate方法而不是validate方法,因为revalidate方法的性能要优于 validate方法。 在使用AWT的程序界面中,通常都要一个java.awt.Frame类的对象作为顶层的窗口。Frame类的作用等同于在其他程序中所看到的窗口,可 以包含标题栏、边框和菜单。类java.awt.Dialog表示的是一般程序中常见的对话框,可以是模态或非模态的。Window类是Frame类和Dialog类 的父类。一个Window类所表示的组件不能包含边框和菜单。在创建Window类的对象时,需要指定另外一个已有的Frame或Window类的对象作为该 组件的所有者。Window类适合于创建程序中的各种不同的自定义窗口。 5.2.2 任意形状的窗口 在桌面应用中,窗口的形状一般以矩形为主。这也是大多数用户所需要的窗口形状。在某些特定的情况下,可能会需要使用其他形状的窗 口,其目的也是为了提高用户体验。对于一个音乐播放器应用来说,如果播放窗口的形状是连在一起的多个圆形,类似多个圆形按钮拼接在一 起,就可以更加地贴近用户的使用习惯。在Java 7中,AWT的Window类中新增了用来获取和设置其窗口形状的方法,分别是getShape和 setShape。设置窗口形状时使用的参数是AWT中表示几何形状的java.awt.Shape接口的实现对象。AWT中本身提供了很多Shape接口的实现类,用 来表示常见的图形,包括弧线、椭圆、多边形、矩形和圆角矩形等。开发人员还可以通过实现Shape接口来创建独特的形状,这为在桌面应用中 使用任意形状的窗口提供了极大的便利。 把一个窗口的形状设置为给定的Shape接口的实现对象之后,该窗口只有在该形状范围之内的区域才是可见的,在其他区域都是不可见的。 设置任意形状的窗口需要底层操作系统的支持,最主要的是支持将界面上的每个像素都设置为完全透明或完全不透明。另外,要求窗口不能包 含装饰元素,如标题栏和边框。窗口也不能处于最大化的状态。满足这三个条件之后,就可以设置窗口的形状了。如果想恢复原来的默认形 状,只需要传入null作为调用setShape方法的参数即可。 代码清单5-1给出了任意形状窗口的一个示例,其中把窗口的形状设置成椭圆。代码中使用Window类的子类Frame来创建一个顶层窗口,方 法setUndecorated用来去掉窗口包含的装饰元素,Ellipse2D.Float类则表示一个椭圆。 代码清单5-1 任意形状窗口的示例 public void createShapedWindow(){ Frame frame=new Frame(); frame.setUndecorated(true); Shape shape=new Ellipse2D.Float(0,0,400,300); frame.setShape(shape); Label label=new Label("Hello World!"); frame.add(label); frame.setSize(400,300); frame.setVisible(true); } 5.2.3 半透明窗口 除了任意形状的窗口之外,Java 7支持的另外一个窗口特殊效果是窗口的半透明化。窗口的半透明化也是很多桌面应用会采用的做法。这 种做法既可以带来不错的视觉效果,又可以在特定的情况下帮助用户更好地使用窗口。比如,当用户在一个文档编辑器的当前文档中进行查找 的时候,可以把查找窗口设成半透明的。这样用户可以同时看到文档中的内容和查找窗口,方便进行比对和再次查找。与设置窗口形状类似的 是,Window类中有一组方法用来获取和设置窗口的透明度,分别是getOpacity和setOpacity。透明度的区间是0到1,0表示完全透明,而1则表 示完全不透明。 实现半透明窗口要求底层操作系统支持为窗口中包含的像素设置透明度值,还要求窗口不包含装饰元素以及不能处于最大化的状态。后两 点要求与设置窗口形状时一样。而第一点要求的差别在于,设置窗口形状时只要求支持每个像素的透明度值能够被设置为0或1即可,而设置半 透明窗口时要求每个像素的透明度值都能够被设置为0到1区间内的任意值。代码清单5-2给出了半透明窗口的一个示例。在示例中,创建了一个 Swing的滑动条组件。当滑动条的值发生变化的时候,整个窗口的透明度也会发生改变。 代码清单5-2 半透明窗口的示例 public void createTranslucentWindow(){ final Frame frame=new Frame(); frame.setUndecorated(true); frame.setSize(400,300); final JSlider slider=new JSlider(0,100,80); slider.addChangeListener(new ChangeListener(){ public void stateChanged(ChangeEvent e){ frame.setOpacity(slider.getValue()/100.0f); } }); frame.add(slider); frame.setOpacity(0.8f); frame.setVisible(true); } 5.2.4 组件混合 前面提到过,每个AWT组件都存在一个与之对应的底层操作系统上的原生控件,一般称之为重量级组件;而Swing是自己绘制用户界面的内 容,一般称之为轻量级组件。在Java 6 Update 12之前,在同一个用户界面中同时混用重量级和轻量级组件,会产生显示上的问题。这主要是 由组件在Z轴上的覆盖顺序造成的,所产生的结果是AWT组件始终出现在Swing组件的前面。这个问题在Java 6 Update 12中得到了修正。从而使 混用AWT和Swing组件不再存在显示上的问题。 从实现的角度上来说,Swing组件也不能完全脱离AWT而存在。在Swing中,所有组件的父类javax.swing.JComponent继承自AWT中的 Container类。虽然Swing组件的界面是自行绘制的,但是也需要一个包围它的AWT组件,因为Swing组件依赖AWT与底层的操作系统进行交互。 Swing的做法相当于:AWT提供一个完全空白的窗口,Swing的组件直接在这个窗口上进行绘制。用户界面要与底层操作系统进行交互时,通过这 个AWT窗口来完成即可。所以对一个完全使用Swing组件来创建的用户界面来说,它的顶层窗口一定会通过AWT与一个原生控件相对应。 虽然混用AWT和Swing组件不再存在显示上的问题,但仍然推荐应用程序只选用Swing或AWT中的一种来作为图形界面组件库,尤其推荐使用 Swing。新开发的桌面程序应该考虑使用JavaFX。 5.3 Swing Swing组件库中的核心类是JComponent。Swing中所有的非顶层窗口的组件类都继承自JComponent类,而顶层窗口组件则继承自AWT中的对应 组件。Swing中组件的类名都以“J”作为前缀,以区别于AWT中同样功能的组件。相对于AWT来说,Swing组件库的内容更加丰富,性能也更好。 5.3.1 重要组件类 Swing中的顶层窗口组件需要继承自AWT中的相关组件以支持与底层操作系统的交互。Swing中共有4种顶层窗口组件,分别是javax.swing包 中的JFrame、JWindow、JDialog和JApplet。前面提到过,这些顶层窗口组件实际上是为Swing的用户界面提供绘制的区域,这个绘制区域本身 又是用javax.swing.JRootPane类的对象来表示的。每个顶层窗口组件都只有唯一的一个直接子组件,是一个JRootPane类的对象。对窗口中内 容的操作也在这个JRootPane类的对象上进行。一个JRootPane类的对象所表示的组件由两个部分组成。第一部分是作为内容区域之上的遮罩的 一个组件(glass pane)。这个组件覆盖在其他组件之上,大小与当前JRootPane组件一样,可以用来拦截鼠标和键盘事件。在默认情况下,这 个组件是不可见的。第二部分是一个javax.swing.JLayeredPane类的对象。在这个JLayeredPane组件中又包含了两个部分:第一部分是作为菜 单的javax.swing.JMenuBar类的对象。菜单不是必须存在的。第二部分是作为内容区域的Container类的对象。内容区域是必须存在的。对于一 个JRootPane组件来说,最重要的是其中包含的内容区域。内容区域中应该包含一个用户界面中所有非菜单的组件。当通过一个顶层窗口组件的 add方法来添加组件的时候,默认这个组件实际上被添加到了内容区域中。同样,在设置一个顶层窗口组件的布局格式的时候,实际上设置的是 内容区域的布局格式。顶层窗口组件类都实现了javax.swing.RootPaneContainer接口。通过RootPaneContainer接口中的方法可以对JRootPane 组件进行操作,如调用getContentPane方法可以获取内容区域组件,并对这个组件进行操作。 JRootPane的遮罩组件可以用来拦截顶层窗口组件中包含内容区域的JLayeredPane组件上的鼠标和键盘事件,也可以绘制其他内容来盖住 JLayeredPane中的组件。当需要暂时屏蔽用户对内容区域的操作时,可以使用这个遮罩组件。JRootPane的遮罩组件可以是任何继承自 JComponent类的组件。比如,当程序启动的时候,某些组件可能还没有完全初始化,这时可以把遮罩组件显示出来,给出相关的提示并屏蔽用 户的使用。代码清单5-3给出了一个示例。LoadingPane类是一个普通的JComponent的子类,它在界面的正中显示字符,同时捕获所有的鼠标事 件但不做任何处理。 代码清单5-3 作为遮罩组件的JComponent的子类 public class LoadingPane extends JComponent{ public LoadingPane(){ addMouseListener(new MouseAdapter(){}); } public void paintComponent(Graphics g){ g.drawString("加载中……",getWidth()/2,getHeight()/2); } } 下一步是把LoadingPane类的对象作为遮罩组件添加到某个JRootPane组件上。代码清单5-4给出了示例的做法。其中关键的是要把遮罩组件 设置为可见,使它可以发挥作用。程序运行起来之后,会发现内容区域上的组件都无法接收到鼠标事件,因为这些事件已经被LoadingPane拦截 并处理了。 代码清单5-4 把遮罩组件添加到JRootPane组件 LoadingPane pane=new LoadingPane(); frame.setGlassPane(pane); pane.setVisible(true) 代码清单5-3中的LoadingPane类只是一个简单的遮罩组件的实现。可以根据需要开发出更加复杂的实现,比如只拦截某些组件上的事件, 或者实现复杂的图形绘制功能。 5.3.2 JLayer组件和LayerUI类 Java 7引入了一个新的用户界面组件javax.swing.JLayer,可以把它看成覆盖在一个已有组件上的图层。JLayer组件可以对这个已有的组 件进行装饰,也可以监听这个组件上发生的事件。JLayer组件可以把对已有组件所做的处理抽象出来,由专门的类来管理,并应用到其他组件 上去。比如,希望监听任何组件上的鼠标单击事件,可以把监听事件的这部分逻辑抽象到一个具体的类中。当需要这种行为的时候,只需要对 已有的组件应用这个类的行为即可。JLayer组件本身只负责与已有的组件进行绑定,真正的处理工作交由javax.swing.plaf.LayerUI类来完 成。LayerUI类中封装了对组件所做的处理行为。在得到一个LayerUI类的对象之后,可以通过JLayer类的对象将其应用到不同的组件上。 LayerUI类主要可以完成两类操作:一类是对已有组件的界面进行修改,另外一类是对已有组件上产生的事件进行处理。在LayerUI类中可 以通过覆写paint方法来对已有组件的外观进行修改。代码清单5-5给出了一个LayerUI类的实现。这个LayerUI类所封装是一种通用的高亮显示 一个组件的行为。在这个实现中,首先调用父类的paint方法来进行正常的界面绘制,再在组件的边界上绘制一个红色的边框。 代码清单5-5 高亮显示组件的LayerUI类的实现 public class HighlightLayerUI extends LayerUI{ public void paint(Graphics g, JComponent c){ super.paint(g, c); g.setColor(Color.red); g.drawRect(0,0,c.getWidth()-1,c.getHeight()-1); } } 有了LayerUI类之后,下一步是创建一个JLayer类对象并把LayerUI类对象与一个已有的组件关联起来。这一步并不复杂,只需要用一个已 有的组件和LayerUI类的对象作为参数来创建一个新的JLayer类的对象,再把该JLayer类的对象像其他组件一样添加到界面中即可。这种使用方 式相当于用JLayer类的对象对已有的组件进行包装。代码清单5-6给出了JLayer类的一个使用示例。这个例子展示了JLayer类的一种实用的应用 场景,可以在运行时动态地改变已有组件的外观。当用户的鼠标移动到某个按钮上时,通过JLayer类的对象的setUI方法为这个按钮添加代码清 单5-5中的HighlightLayerUI类所封装的行为,完成后会在按钮上加上一个红色的边框;而在鼠标移开的时候,则为按钮设置了默认的LayerUI 类的实现,按钮就恢复了默认的外观。这种动态改变外观的能力可以使程序在某些情况下变得更加灵活。 代码清单5-6 高亮显示组件的LayerUI类的使用示例 public void useHighlight(){ final JFrame frame=new JFrame(); frame.setSize(400,300); frame.setLayout(new GridLayout(2,1)); frame.add(new JLabel("标签")); JButton button=new JButton("按钮"); final JLayer<?extends Component>layer=new JLayer<>(button); final LayerUI layerUI=new HighlightLayerUI(); final LayerUI defaultUI=new LayerUI(); frame.add(layer); button.addMouseListener(new MouseAdapter(){ public void mouseEntered(MouseEvent e){ layer.setUI(layerUI); } public void mouseExited(MouseEvent e){ layer.setUI(defaultUI); } }); frame.setVisible(true); } 除了改变外观之外,JLayer类还可以处理它所包装的组件上的事件。当组件上发生事件的时候,LayerUI类的对象可以得到通知,可以获取 到该事件的相关信息。通过这个功能,可以监听组件上发生的事件。代码清单5-7给出了一个示例,LayerUI类的installUI方法在LayerUI类的 对象被配置到一个JLayer类的对象上时会被调用。在这个方法中,通过JLayer类的setLayerEventMask方法设置了所感兴趣的事件类型,这里设 置了只对鼠标相关的事件感兴趣。而uninstallUI方法则在取消JLayer类的对象与对应的LayerUI类的对象的关联时被调用。在这个方法中,要 取消在installUI方法中设置的感兴趣的事件类型,并恢复为默认类型。对于相关的事件,在LayerUI类中有相应的方法来进行处理,比如 processMouseEvent方法用来处理鼠标事件,processKeyEvent方法用来处理键盘事件。只要在installUI方法中设置了对某类事件感兴趣,当事 件发生的时候,LayerUI类中的相关事件处理方法就会被调用。 代码清单5-7 使用LayerUI类来监听组件上发生的事件 public class MouseMonitorLayerUI extends LayerUI{ public void installUI(JComponent c){ super.installUI(c); JLayer layer=(JLayer)c; layer.setLayerEventMask(AWTEvent.MOUSE_EVENT_MASK); } public void uninstallUI(JComponent c){ super.uninstallUI(c); JLayer layer=(JLayer)c; layer.setLayerEventMask(0); } public void processMouseEvent(MouseEvent e, JLayer l){ System.out.println(e.paramString()); } } 需要注意的是,LayerUI类的对象只能在对应JLayer类的对象所包装的组件上的事件发生时得到通知,并不能拦截事件的发生,所以 LayerUI类一般用来实现监视相关的功能。 5.4 事件处理与线程安全性 AWT和Swing都是采用经典的事件处理模型。系统中的内部动作和用户的操作都会被抽象成为对应的事件,放入到一个事件队列中。这个队 列中的事件会被依次进行处理。 5.4.1 事件处理 在AWT中的事件用java.awt.AWTEvent类来表示,而事件队列则由java.awt.EventQueue类来表示。EventQueue类中包含了对AWTEvent类的对 象的处理方法,包括发布、获取、查看和分发事件等。事件队列的基本处理思路是:对于队列中当前存在的事件,按照顺序逐个调用 dispatchEvent方法来分发这些事件。在分发的过程中就包括了对事件的处理,只不过实际的处理可能是由其他对象来完成的。 事件队列的一个实用功能是可以往其中发布自定义的事件,由事件队列来处理。代码清单5-8中给出了一个自定义事件的使用示例。当需要 创建一个任意类型的自定义事件的时候,可以实现java.awt.ActiveEvent接口。当一个ActiveEvent接口的实现对象被分发时,其dispatch方法 会被调用,相当于对该事件进行处理。通过EventQueue类的postEvent方法可以向事件队列中添加事件。由于EventQueue类要求队列中的事件对 象都继承自AWTEvent类,所以自定义的事件类也需要继承自AWTEvent类。在一个AWTEvent类中需要包含两个要素:第一个是产生事件的源对 象,可以是任意的Java对象;第二个是事件类型的标识符,它的值需要大于AWT为内部事件保留的最大标识符AWTEvent.RESERVED_ID_MAX,以避 免与AWT定义的内部事件产生冲突。 代码清单5-8 自定义事件的使用示例 public class UseEventQueue{ private static class MyEvent extends AWTEvent implements ActiveEvent{ private String content; public MyEvent(String content){ super(content, AWTEvent.RESERVED_ID_MAX+1); this.content=content; } public void dispatch(){ System.out.println("字符串长度为:"+content.length()); } } public void useEventQueue(){ Frame frame=new Frame(); frame.setVisible(true); Toolkit toolkit=Toolkit.getDefaultToolkit(); EventQueue queue=toolkit.getSystemEventQueue(); queue.postEvent(new MyEvent("Hello")); } } 除了自定义的事件之外,在AWT和Swing中,使用更多的是与组件相关的事件。EventQueue类在通过dispatchEvent方法来分发事件时,会根 据事件的源对象类型和事件本身的类型来确定所采取的动作。比如前面提到的ActiveEvent类型的事件,处理的方式是直接调用该类型对象的 dispatch方法;而对于源对象类型为AWT中的Component类和MenuComponent类的对象的事件来说,处理的方式是直接调用源对象的 dispatchEvent方法,也就是说,对这类事件的处理由组件自己来完成;对于其他类型的事件,EventQueue类会直接忽略。除了使用系统提供的 EventQueue类的对象之外,也可以自己创建EventQueue类的对象并使用。通过java.awt.Toolkit类可以与系统提供的事件队列进行交互。 当事件发生之后,AWT和Swing中对事件的处理采用了经典的监听者设计模式。在Component类及其子类中都有类 似“add×××Listener(×××Listener)”这样的方法,用来对组件上相关事件添加处理器。比如,对组件上的鼠标事件感兴趣,可以通过 addMouseListener方法来添加一个java.awt.event.MouseListener接口的实现对象。这样当组件上与鼠标相关的事件发生的时候,预先声明的 处理器的对应方法就会被调用。这种事件处理模式在其他编程语言的用户界面组件库以及Web开发中也经常用到,开发人员应该都比较熟悉。 ×××Listener接口中通常包含了与某个主题相关的一系列需要实现的方法,比如MouseListener接口包括了鼠标单击、按下、释放、进入和离 开等具体事件的处理方法。如果只对接口中的某几个方法感兴趣,那么可以继承自与该接口对应的×××Adapter类,并覆写其中的部分相关方 法即可。结合前面提到的EventQueue类的实现,不难得出AWT中事件处理的基本流程。 当事件发生的时候,事件被添加到系统的事件队列中。该事件中包含了产生事件的源对象和相关的元数据。事件队列按照顺序依次对其中 包含的事件进行处理。在处理的时候,如果发现这是一个Component类的对象上发生的事件,直接调用Component类的对象的dispatchEvent方 法。而dispatchEvent方法会调用当前Component类的对象上已经注册的事件监听器中的方法,从而完成对事件的处理。对于一个Component类的 对象,可以添加同一类事件的多个监听器。这些监听器中的方法被调用的先后顺序是不确定的。程序的处理逻辑不应该依赖特定的监听器调用 顺序。各个监听器之间应该互相独立。 5.4.2 事件分发线程 AWT和Swing中对用户界面组件的事件处理都是单线程的。对于每个事件队列,都有一个专门的线程负责进行事件的分发和处理。在分发事 件的时候,事件对应的处理方法也是在这个线程中被调用的。这就要求在一个事件的处理方法中包含的逻辑应该尽可能地少,可以在较短的时 间内完成,否则可能造成队列中的其他事件长时间处于等待状态,给用户的感觉就是程序的用户界面在较长的时间内失去了响应。比如,当用 户界面的窗口从最小化状态恢复的时候,需要重新绘制界面,系统会在事件队列中添加一个新的重新绘制的事件来指示各组件重新绘制自身的 界面。如果当前事件队列中已经有一个事件正在处理,而这个事件的处理又很耗时,那么这个新的重新绘制的事件在较长的时间内不会被分 发,会导致用户界面成为一片空白,没有任何内容。 从线程的安全性方面来说,AWT和Swing的大部分API都不是线程安全的,这包括绝大部分操作用户界面的API。如果多个线程同时对界面进 行更新,可能会造成界面的显示问题。实际上,要实现一个线程安全的用户界面组件库是一件很困难的事情。AWT和Swing没有选择花费大量的 精力去实现线程的安全性,而是采取了一种简单的做法,即要求所有与界面相关的操作都在单一的线程中进行。这种单线程的界面更新方式自 然可以避免线程安全性的问题,这个线程就是前面提到的事件队列对应的事件分发线程(Event Dispatch Thread, EDT)。 在大多数情况下,事件分发线程都在后台工作,开发人员也不需要了解它的存在。与事件分发线程产生联系的一个典型场景是需要执行长 时间任务的时候。如果一个任务的执行时间很长,那么由事件分发线程来处理就不合适,因为这样会导致其无法及时处理其他事件。合理的做 法是由另外一个工作线程来处理。不过要注意的是,如果在处理中需要更新用户界面,应该交由事件分发线程来处理,而不是由当前工作线程 处理。这么做的目的是为了保证与界面相关的操作都在同一个事件分发线程中执行,避免线程安全性的问题。 AWT和Swing都提供了相关的方法来在事件分发线程中执行更新界面的操作。代码清单5-9给出了一个AWT中事件分发线程的使用示例。示例 中使用一个后台线程来计算π的值,计算出结果后显示在一个标签组件中。因为需要在事件分发线程中执行界面相关的操作,对 java.awt.Label类的setText方法的调用需要封装在EventQueue类的invokeLater方法中。方法invokeLater参数中的java.lang.Runnable接口的 实现对象会在系统默认事件队列对应的分发线程中执行。与invokeLater方法作用相似的方法是invokeAndWait,两者的区别在于:invokeLater 方法只是把Runnable接口实现对象放入事件队列中就立刻返回,实际的执行需要等待队列中的其他事件处理完毕,所以是一个异步操作;而 invokeAndWait方法会等待队列中的所有其他事件及Runnable接口实现对象的run方法执行完成之后才返回,所以是一个同步操作。开发人员可 以根据需要选择异步方式还是同步方式。 代码清单5-9 AWT中事件分发线程的使用示例 public class CalculatePi{ public void calculate(){ Frame frame=new Frame(); Label label=new Label(); frame.add(label); frame.setSize(400,300); frame.setVisible(true); new CalculateThread(label).start(); } private static class CalculateThread extends Thread{ double sum=0.0,term, sign=1.0; int N=30*1000*1000; private Label label; public CalculateThread(Label label){ this.label=label; } public void run(){ for(int k=0;k<N;k++){ term=1.0/(2.0*k+1.0); sum=sum+sign*term; if(k%(N/100)==0){ EventQueue.invokeLater(new Runnable(){ public void run(){ label.setText(Double.toString(4*sum)); } }); } sign=-sign; } } } } Swing中也有与AWT中的EventQueue类的invokeLater和invokeAndWait方法功能相同的方法,这些方法在javax.swing.SwingUtilities类 中。SwingUtilities类中对应的方法的名称也是invokeLater和invokeAndWait,调用时的行为也是相同的。对于一个使用Swing的桌面应用来 说,应该把创建用户界面相关的逻辑都封装在SwingUtilities的invokeLater方法中。如代码清单5-10所示,程序的main方法应该对创建用户界 面的createUI的方法的调用进行封装,以确保该方法在事件分发线程中执行。 代码清单5-10 SwingUtilities类的invokeLater方法的使用示例 public static void main(String[]args){ SwingUtilities.invokeLater(new Runnable(){ public void run(){ createUI(); } }); } 5.4.3 SwingWorker类 在Swing中,如果要实现后台运行的工作线程,更好的做法是使用Java 6中引入的javax.swing.SwingWorker类。SwingWorker类的作用是把 工作线程的任务和用户界面的更新这两者做了一个良好的切分。对于SwingWorker类的对象中的方法,一部分在工作线程中被调用,而另外一部 分则在事件分发线程中被调用。开发人员只需要按照SwingWorker类的约定实现这些方法即可,不需要考虑这些方法所对应的线程的含义。 SwingWorker类会负责保证这些方法在正确的线程中被调用。 SwingWorker类中唯一需要实现的方法是doInBackground。这个方法用来执行具体的工作,是在工作线程中运行的。方法doInBackground的 返回值是工作线程的执行结果。如果希望得到doInBackground方法的执行结果,可以调用SwingWorker类的get方法。这个方法会阻塞直到 doInBackground方法执行结束。一般来说,get方法会在事件分发线程中被调用。在这种情况下,会无法处理事件队列中的其他事件,使程序的 界面暂时处于没有响应的状态。因此,如果需要在事件分发线程中等待工作线程的完成,比较好的做法是在等待过程中弹出一个模态对话框来 提示用户。 在任务的执行过程中,可能会需要在用户界面上进行更新来显示任务的执行进度,比如更新进度条的指示值。一般通过3种做法来实现进度 信息在工作线程和事件分发线程之间的传递。第一种做法是通过SwingWorker类的setProgress方法来更新任务的执行进度。进度的值从0到 100,非常适合直接供进度条组件来使用。第二种做法是使用publish和process方法,其中publish方法是在工作线程中被调用的,用来发布任 务执行过程中产生的中间结果;process方法是在事件分发线程中被调用的,利用publish方法产生的中间结果来更新用户界面。通过publish方 法发布的数据会在process方法调用时的实际参数中得到。第三种做法是使用自定义的属性变化事件。在SwingWorker类中可以通过 firePropertyChange方法来发布属性变化的事件。可以在事件分发线程中通过SwingWorker类的对象的addPropertyChangeListener方法来添加 属性变化事件的处理方法。实际上,第一种做法中提到的setProgress方法也是以属性变化的方式来实现的,只不过用的是预定义的属性名 称“progress”。 完成doInBackground方法之后,done方法会在事件分发线程中被调用,用来在任务完成之后进行界面的更新。自定义的SwingWorker类如果 需要在任务完成之后更新界面,应该直接覆写done方法。如果希望取消一个任务的执行,可以使用SwingWorker类的cancel方法,不过cancel方 法的成功完成,要求doInBackground方法在执行的过程中不时地通过isCancelled方法来检测是否发出了取消的请求,以取消任务。如果在 doInBackground方法的实现中没有处理取消请求的相关逻辑,调用cancel方法实际上是不起作用的。一个设计良好的doInBackground方法的实 现,应该允许用户随时取消任务的执行。在创建了SwingWorker类的对象之后,通过它的execute方法就可以启动其任务的执行。 代码清单5-11给出了SwingWorker类的一个示例,用来模拟网络上远程文件的下载过程。在doInBackground方法中通过setProgress方法来 更新下载的进度,同时通过publish方法把当前的下载速度发布出去。而在事件分发线程中,通过SwingWorker类的addPropertyChangeListener 方法来检查“process”属性的变化,并更新进度条组件的显示。在SwingWorker类的process方法中,则把publish方法发布出来的下载速度的 值显示在标签上。在process方法被调用的时候,可能会收到多次publish方法调用的结果,程序应该根据需要从结果列表中选择合适的数据来 显示。 代码清单5-11 SwingWorker类的使用示例 public class UseSwingWorker{ public void downloadFile(){ JFrame frame=new JFrame(); final JProgressBar progressBar=new JProgressBar(); frame.add(progressBar, BorderLayout.NORTH); final JLabel label=new JLabel(); frame.add(label, BorderLayout.CENTER); DownloadWorker worker=new DownloadWorker(label); worker.addPropertyChangeListener(new PropertyChangeListener(){ public void propertyChange(PropertyChangeEvent evt){ if("progress".equals(evt.getPropertyName())){ progressBar.setValue((Integer)evt.getNewValue()); } } }); worker.execute(); frame.setSize(400,300); frame.setVisible(true); } private static class DownloadWorker extends SwingWorker<String, Double>{ private JLabel label; public DownloadWorker(JLabel label){ this.label=label; } public String doInBackground()throws Exception{ Random random=new Random(); for(int i=0;i<100;i++){ Thread.sleep(random.nextInt(1000)); setProgress(i+1); publish(random.nextDouble()*30); } return"<Path>"; } protected void process(List<Double>chunks){ Double speed=chunks.get(chunks.size()-1); label.setText(MessageFormat.format("下载速度:{0,number,#.##}kb/s",speed)); } } } 5.4.4 SecondaryLoop接口 在介绍SwingWorker类的时候曾经提到,如果希望当前线程等待SwingWorker类的对象的工作线程运行完成,可以调用get方法,该方法会阻 塞当前线程直到SwingWorker类的对象中的任务完成或被取消。如果调用get方法的线程是事件分发线程,那么会造成用户界面失去响应,有时 候确实需要等待某个任务完成才能进行下一步操作。为了解决这种同步调用和事件分发线程处理之间的矛盾,Java 7对AWT中的事件处理进行了 增强,添加一个额外的事件队列。这个新的事件队列既可以阻塞当前线程以等待操作完成,又可以继续处理事件队列中的事件,并且不会造成 界面失去响应。这个新的事件队列由java.awt.SecondaryLoop接口来表示,通过EventQueue类的对象的createSecondaryLoop方法来创建具体的 实例。在创建出SecondaryLoop接口的实现对象之后,可以调用该对象的enter方法来阻塞当前线程并进入新的事件处理循环中;当要等待的操 作完成之后,可以调用exit方法来退出这个新的事件处理循环,同时恢复被enter方法阻塞的线程的执行。 在需要等待操作完成的时候,都应该使用SecondaryLoop接口,它可以避免用户界面失去响应的问题。代码清单5-12给出了一个示例,通过 一个线程来模拟需要等待完成的操作。在线程启动之后,通过SecondaryLoop接口的enter方法来阻塞事件分发线程;而在工作线程完成任务之 后,通过SecondaryLoop接口的exit方法来恢复事件分发线程的执行。在工作线程的运行过程中,用户界面产生的其他事件仍然可以得到处理。 代码清单5-12 SecondaryLoop接口的使用示例 private static class WorkerThread extends Thread{ private SecondaryLoop loop; public WorkerThread(SecondaryLoop loop){ this.loop=loop; } public void run(){ try{ Thread.sleep(5000); }catch(InterruptedException ex){ } loop.exit(); } } public void useLoop(){ JFrame frame=new JFrame(); frame.setSize(400,300); frame.addMouseListener(new MouseAdapter(){ public void mouseClicked(MouseEvent e){ EventQueue queue=Toolkit.getDefaultToolkit().getSystemEventQueue(); SecondaryLoop loop=queue.createSecondaryLoop(); WorkerThread thread=new WorkerThread(loop); thread.start(); loop.enter(); } }); frame.setVisible(true); } 5.5 界面绘制 用户界面组件库中的组件都要在屏幕上绘制之后显示给用户。Java平台上的AWT和Swing也不例外。在Swing还没出现之前,AWT并不需要负 责管理组件的界面绘制工作,因为这些是由操作系统底层的原生控件负责完成的。在Swing出现之后,由于Swing组件的界面是自己绘制组件 的,因此需要对绘制的过程进行管理,而作为Swing基础的AWT也需要有相应的实现。 5.5.1 AWT中的界面绘制 AWT中的界面绘制围绕着Component类中的paint方法展开。这个方法用来绘制AWT组件的界面。当系统认为某个组件需要绘制的时候,就会 调用该组件的paint方法,并传入预先配置好的java.awt.Graphics类的对象作为参数。在paint方法的实现中,需要利用这个Graphics类的对象 所提供的方法来完成绘制。 有两类情况会造成paint方法被调用。第一类是由系统根据组件的状态来决定的,可能造成paint方法被调用的情况包括:组件第一次通过 setVisible方法设置为在屏幕上可见的时候,组件的大小发生改变的时候,以及组件的部分区域需要重新绘制的时候。第二类情况是程序本身 通过repaint方法来要求组件重新进行绘制,这通常是因为程序的内部状态发生了改变,组件需要重新绘制以反映这些变化。调用repaint方法 也会使得paint方法被调用。程序不应该直接调用paint方法,而是通过调用repaint方法来发出通知,由系统来调用paint方法。 在paint方法中进行界面绘制时需要考虑的一个重要属性是本次绘制时的剪辑区域(clip bounds)。通过作为paint方法实际参数的 Graphics类的对象的getClipBounds方法可以获取这个剪辑区域。剪辑区域的含义是只有这个区域内的部分界面需要重新绘制,其余部分则不需 要。如果是系统产生的paint方法调用,剪辑区域的范围由系统来确定;如果是程序通过repaint方法产生的paint方法调用,剪辑区域可以通过 调用repaint方法时的参数来指定。在paint方法的实现中,应该考虑到剪辑区域的存在,只重新绘制剪辑区域即可,而不是在任何情况下都把 组件的全部区域重新绘制。考虑剪辑区域的重要好处是可以提高界面绘制时的性能。在程序中调用repaint方法的时候,应该首先计算出需要重 新绘制的区域的范围,再把该区域作为调用时的参数传入。如果使用不带参数的repaint方法,则说明组件的全部区域都需要重新绘制。 5.5.2 Swing中的绘制 Swing中的界面绘制继承了AWT中界面绘制的特性,又有自己的增强功能。在Swing组件中同样可以利用paint方法绘制组件的界面。不过 Swing对paint方法重新进行了细分,分成了3个具体的方法,分别是paintComponent、paintBorder和paintChildren。从这3个方法的名称可以 看出,它们分别用来绘制组件自身的区域、边框和子组件。在一般情况下,Swing组件只需要覆写paintComponent方法来添加与绘制自身界面相 关的逻辑。另外两个方法使用默认实现即可。 为了提高界面绘制时的性能,Swing默认启用了双缓冲的技术。在使用双缓冲的时候,界面的绘制是在一个屏幕显示范围之外的图形上下文 中进行的。等全部绘制工作完成之后,再把该图形上下文中的内容一次性复制到当前可见的显示区域中。这种技术的好处在于,界面的更新是 平滑进行的,可以一次性完成整个界面的更新,用户看不到更新的中间结果。 Swing中组件绘制的一个额外属性是组件的透明性。有些组件在绘制的过程中可能会不占满组件的全部可用空间,比如可能只绘制了一个组 件的左半部分,而空着右半部分不用。对于这空着的右半部分,系统需要把它变为透明的,而让该组件下方的组件的部分内容显示出来。这就 要求系统进行计算来找到底层的组件,然后先绘制底层的组件,再绘制位于其上方的当前组件,这会对系统的绘制性能产生比较大的影响。为 此,JComponent类定义了一个“opaque”的属性来声明此组件在透明性上的特征。如果通过setOpaque方法设置了属性“opaque”的值 为“true”,就说明该组件在绘制的时候会占满全部可用的空间,系统不需要额外的工作来使底层组件可见,否则系统需要按照自底向上的方 式逐个重新绘制相关组件。对于组件的实现者来说,尽量把“opaque”的属性设为“true”,同时在绘制时使用组件的全部可用空间。 在绘制的时候,另一个棘手的问题是处理组件之间互相重叠的情况。当互相重叠的组件中有一个需要重新绘制时,与它重叠的其他组件的 部分区域也可能需要被重新绘制。组件之间的重叠关系有可能很复杂。系统在确定哪些重叠组件需要重新绘制时,需要花费大量的时间进行计 算。如果组件能够额外提供一些与重叠相关的信息,计算的时间就可以大大减少。如果组件的直接子组件之间不会互相重叠,在计算时就可以 更加高效。如果组件的实现者确实能够保证该组件的直接子组件都不会互相重叠,可以通过覆写isOptimizedDrawingEnabled方法并返 回“true”来表明这一点。系统可以利用这一特征来提高重叠计算时的速度。 前面提到的属性“opaque”和方法“isoptimizedDrawingEnabled”都可以看成组件与Swing系统之间的契约。Swing系统可以利用这两个属 性来更快地完成界面的绘制工作。而对于组件的实现者来说,要保证所声明的属性的值与其内部的实现是一致的,否则可能出现显示问题。 5.6 可插拔式外观样式 Swing相对于AWT的一个重要优势是Swing允许开发人员定制其组件的外观样式,而AWT只能使用底层操作系统提供的组件外观样式。Swing的 这个特性为开发人员在美化程序外观样式时提供了足够的灵活性。Swing所提供的这个特性被称为可插拔式外观样式(pluggable look-and- feel, PLAF)。Swing也默认提供了一些外观样式供开发人员选择,这其中包括默认使用的“metal”样式和Java 7中新增的“nimbus”样式。 除了这些在不同平台上相同的外观样式之外,还包括不同平台上特有的外观样式。程序外观样式的管理由javax.swing.UIManager类来完成。通 过UIManager类可以为程序切换不同的外观样式。 代码清单5-13给出了一个切换外观样式的示例。在示例中,通过UIManager类的getInstalledLookAndFeels方法可以获取当前Java平台上所 有可用的外观样式的信息。每种信息由UIManager.LookAndFeelInfo类的对象来表示。通过UIManager类的setLookAndFeel方法可以设置所要使 用的外观样式。设置时的参数既可以是一个已有的javax.swing.LookAndFeel类的对象,也可以是外观样式实现类的名称。这里使用的是类的名 称。在完成设置之后,需要通过SwingUtilities类的updateComponentTreeUI方法来通知界面上的所有组件更新自己的外观样式。如果不使用这 个方法,可能会出现界面上组件的外观样式不一致的情况。在示例中,每当通过下拉列表选择一个外观样式之后,整个程序的外观样式会随之 发生变化。 代码清单5-13 切换Swing界面的外观样式的示例 public void selectPlaf(){ final JFrame frame=new JFrame(); UIManager.LookAndFeelInfo[]lafs=UIManager.getInstalledLookAndFeels(); JComboBox combo=new JComboBox(lafs); combo.addItemListener(new ItemListener(){ public void itemStateChanged(ItemEvent e){ if(ItemEvent.SELECTED==e.getStateChange()){ UIManager.LookAndFeelInfo info=(UIManager.LookAndFeelInfo)e.getItem(); try{ UIManager.setLookAndFeel(info.getClassName()); SwingUtilities.updateComponentTreeUI(frame); }catch(Exception ex){ } } } }); frame.add(combo, BorderLayout.NORTH); frame.add(new JButton("按钮"),BorderLayout.SOUTH); frame.setSize(400,300); frame.setVisible(true); } 如果希望基于Swing开发的程序的界面像AWT一样在不同的操作系统平台上使用当前平台的默认外观样式,可以通过UIManager类的 getSystemLookAndFeelClassName方法来得到当前平台的默认外观样式的名称,再通过setLookAndFeel方法进行设置即可。 Swing为了实现可插拔的外观样式,采用了一种用户界面代理(UI delegate)的设计思路。当一个Swing用户界面组件在进行绘制时,实际 的绘制工作是代理给另外的一个对象来完成的。通过这种职责分离方式,使得同一个组件的外观样式可以根据用户界面代理的不同而发生变 化。Swing中所有的用户界面代理类都继承自javax.swing.plaf.ComponentUI类。每个组件都有其对应的用户界面代理类来负责绘制其外观,比 如与javax.swing.JButton类对应的代理类是javax.swing.plaf.ButtonUI类。有两种做法可以把一个组件对象和一个用户界面代理对象关联起 来:一种是通过组件对象的setUI方法,另外一种是通过用户界面代理对象的installUI方法。前面介绍JLayer和LayerUI类时就提到了这两种做 法。从这里可以看出,每个样式外观其实是一组用户界面代理类的集合。这些用户界面代理类分别为Swing中的不同组件提供了对应的外观样 式。 如果使用setUI方法来设置组件对应的代理对象,就要求对每个组件的对象实例进行设置。如果需要对某一类型组件的所有对象实例都应用 某种外观样式,更好的选择是使用自定义的外观样式实现。实现自定义的外观样式很简单,只需要继承自LookAndFeel类并实现相应的方法,然 后再通过UIManager类来进行设置即可。如果只是修改少数组件的外观,那么更好的选择是继承已有的 javax.swing.plaf.basic.BasicLookAndFeel类。BasicLookAndFeel类可以作为实现自定义的外观样式的基础。比如,希望把界面上所有的标签 都改成黑底白字的显示方式,可以继承自javax.swing.plaf.basic.BasicLabelUI类,并提供自己的绘制逻辑,如代码清单5-14所示。 代码清单5-14 自定义标签的外观样式的示例 public class MyLabelUI extends BasicLabelUI{ public static ComponentUI createUI(JComponent c){ return new MyLabelUI(); } public void paint(Graphics g, JComponent c){ g.setColor(Color.BLACK); g.fillRect(0,0,c.getWidth(),c.getHeight()); g.setColor(Color.WHITE); JLabel label=(JLabel)c; g.drawString(label.getText(),0,label.getHeight()/2); } } 通过覆写用户界面代理类的paint方法可以控制组件的外观样式。从paint方法的JComponent类型的参数可以获取与用户界面代理对象相关 联的组件,从而获得所需的信息。代码清单5-14获取了标签的文本。需要注意的是,自定义的界面代理类都需要覆写createUI这个静态方法以 创建出正确的界面代理对象。下一步是创建程序自己的LookAndFeel类,如代码清单5-15所示。程序自己的MyLookAndFeel类选择继承 BasicLookAndFeel类以减少实现的工作量。受限于篇幅,代码清单5-15中省略了MyLookAndFeel类的一些方法,这些方法都只是用来提供自定义 外观样式的元数据,作用并不大。这里只列出了比较重要的initClassDefaults方法。在这个方法中,通过修改参数中的 javax.swing.UIDefaults类的对象把标签组件对应的用户界面代理的Java类名设成了MyLabelUI类。经过这样的设置,在创建标签组件 时,MyLabelUI类的对象会作为默认的界面代理对象,从而成功地应用自定义的外观样式。如果还需要修改其他类型组件的外观样式,只要按照 相同的方式进行设置即可。 代码清单5-15 启用自定义外观样式的示例 public class MyLookAndFeel extends BasicLookAndFeel{ public void initClassDefaults(UIDefaults table){ super.initClassDefaults(table); table.putDefaults(new Object[]{"LabelUI","com.java7book.chapter5.plaf.mylaf.MyLabelUI"}); } } 在程序中,只需要在最开始的时候通过UIManager类设置使用MyLookAndFeel类作为外观样式即可。 5.7 JavaFX JavaFX是利用Java开发桌面应用的发展方向。前面提到,JavaFX将与Java平台本身进行更加深度的集成,JavaFX把Java平台变成了一个开 发富客户端应用(Rich Client Platform, RCP)的良好平台。使用JavaFX可以开发出功能强大和交互性强的桌面应用。JavaFX应用也可以通过 类似Java Applet的方式运行在浏览器中。本节将对JavaFX 2.0进行比较具体的介绍。 5.7.1 场景图 JavaFX应用程序的编写方式与AWT及Swing有很大不同。JavaFX用了更加形象的方式来描述用户界面及其变化。JavaFX的这种方式类似于戏 剧表演,戏剧表演在一个舞台上进行,一部剧可由多幕组成,每一幕的内容各不相同,完成当前一幕的表演之后,会切换到下一幕。在JavaFX 中,javafx.stage.Stage类的作用类似于戏剧表演时的舞台,是一个顶层容器,用来包含其他的界面组件。而javafx.scene.Scene类的作用类 似于戏剧表演中的不同幕,表示程序运行时的不同场景。通过Stage类的setScene方法可以切换显示不同的场景。每个场景中可以包含以树形结 构组织的多个节点,称之为场景图(scene graph)。JavaFX把用户界面上的基本图形元素和用户界面组件两类元素进行了统一,统称为节点, 用javafx.scene.Node类来表示。矩形、椭圆、按钮或表格,都是用户界面上的节点,可以用相似的方式来处理。 下面通过一个简单的示例来说明JavaFX程序的基本结构。代码清单5-16中的JavaFX程序实现在界面上显示一个按钮和标签。当单击按钮 时,标签上的文本会变为“Hello World!”。JavaFX程序的主Java类需要继承自javafx.application.Application类。Application类负责管 理JavaFX程序的生命周期。在Application类中定义了与生命周期相关的方法。在Application类的对象被创建出来之后,会调用init方法进行 JavaFX程序的初始化工作;接着start方法会被调用。运行时环境负责创建一个Stage类的对象,并作为start方法调用时的实际参数。在start 方法中创建界面所需的Scene类的对象,并设置到Stage类的对象上,使该Scene类的对象作为程序的当前界面场景。Scene类的对象包含了界面 所需的其他组件。程序继续运行,直到程序的最后一个窗口被关闭或程序调用javafx.application.Platform类的exit方法显式地结束运行。最 后调用stop方法来进行适当的资源释放和清理工作。程序只需要覆写Application类中的对应方法即可。在主Java类的main方法中调用 Application类的静态方法launch来启动程序运行。 代码清单5-16 JavaFX应用程序的基本结构 public class JavaFXHelloworld extends Application{ public static void main(String[]args){ launch(args); } public void start(Stage primaryStage){ primaryStage.setTitle("JavaFX Sample"); Button button=new Button(); button.setText("Button"); final Label label=new Label(); button.setOnAction(new EventHandler<ActionEvent>(){ public void handle(ActionEvent event){ label.setText("Hello World!"); } }); BorderPane pane=new BorderPane(); pane.setTop(button); pane.setBottom(label); Group root=new Group(pane); primaryStage.setScene(new Scene(root,400,300)); primaryStage.show(); } } JavaFX也使用了与AWT和Swing相似的线程处理方式。在JavaFX程序启动时,运行时环境创建一个新的线程来执行Application类的对象的 start方法。创建Stage和Scene类的对象的操作,以及对界面上节点的修改,都需要在这个线程中进行。这个线程被称为JavaFX的应用线程。通 过Platform类的isFxApplicationThread方法可以判断当前线程是否为JavaFX的应用线程。在JavaFX程序中,同样可以使用工作线程在后台执行 任务。调用Platform类的runLater方法可以在应用线程中进行界面的更新操作。这个方法的作用类似于前面提到的EventQueue和 SwingUtilities类中的invokeLater方法。 在场景图中可以使用的节点都是Node类的对象。节点大致可分为三类:第一类是常见的用户界面控件,继承自 javafx.scene.control.Control类;第二类是几何图形形状,继承自javafx.scene.shape.Shape类;第三类是与多媒体相关的类,包括显示图 片的javafx.scene.image.ImageView类以及播放音频和视频文件的javafx.scene.media.MediaView类。JavaFX还提供了对图表绘制的支持,包 括javafx.scene.chart.Chart类及其子类。 JavaFX 2. 0中的图形渲染引擎Prism可以借助底层操作系统上的DirectX和OpenGL支持来进行界面渲染,并利用硬件平台提供的加速能力来 提升界面绘制时的性能。这种方式要优于Swing在绘制界面时使用的基于Java 2D的软件渲染方式。因此JavaFX程序的性能相对于Swing有大幅度 的提升。 5.7.2 变换 JavaFX提供了对用户界面组件进行变换的支持。通过变换能力,可以改变组件的大小和位置。所有Node类的子类对象都可以进行变换。 JavaFX提供了四种不同的变换方式,定义在javafx.scene.transform包中。第一种变换方式是平移,即沿着X、Y和Z轴平行移动。平移变换由 javafx.scene.transform.Translate类完成。在创建Translate类的对象时,要指定在X、Y和Z轴上的平移量。第二种变换方式是旋转,即以某 个点作为中心,旋转指定的角度。旋转变换由javafx.scene.transform.Rotate类完成。在创建Rotate类的对象时,要指定旋转的角度、旋转中 心点的坐标和旋转使用的坐标轴。第三种变换是缩放,即以某个点为中心,沿着X、Y和Z轴放大或缩小。缩放变换由 javafx.scene.transform.Scale类表示。在创建Scale类的对象时,要指定缩放的中心点,以及在X、Y和Z轴上的缩放比例。第四种变换是切 变,即对X和Y轴进行旋转,使X轴和Y轴不再互相垂直。切变变换由javafx.scene.transform.Shear类表示。在创建Shear类的对象时,要指定变 换的起始点和在X和Y轴上的切变系数。 当需要进行变换时,可以通过Node类的getTransforms方法获取该对象上的已有的变换的集合,再向该集合中添加新的变换对象即可。代码 清单5-17给出了一个对矩形进行四种变换的示例。 代码清单5-17 对矩阵进行变换的示例 Rectangle rect=new Rectangle(200,100); rect.getTransforms().add(new Translate(100,50)); rect.getTransforms().add(new Rotate(30,100,50)); rect.getTransforms().add(new Scale(2,1.5)); rect.getTransforms().add(new Shear(0.5,0)); 5.7.3 动画效果 JavaFX提供了对动画效果的支持,可以实现各种不同的动画效果。每个动画效果由多个帧组成。每一帧按照设定的频率进行播放。 javafx.animation.Animation类是所有动画效果的父类。通过Animation类的play、pause和stop方法可以开始、暂停和停止动画效果的运行。 JavaFX提供了两种类型的动画效果,一种是基于值的自动变化的,另外一种是基于时间线和关键帧的,这两种方式的区别在于动画效果中包含 的帧的定义方式不同。 第一种方式需要继承javafx.animation.Transition类并覆写interpolate方法。在创建Transition类的子类对象之后,系统会根据动画效 果的持续时间自动计算所需的帧数。在播放每一帧时,interpolate方法会被调用,调用时的参数是当前的动画播放进度,范围在0.0到1.0之 间。具体的进度由系统通过不同的插值算法来计算。javafx.animation.Interpolator类的子类表示不同的插值计算算法。通过Transition类的 setInterpolator方法可以改变使用的插值算法。比较常用的是由Interpolator.LINEAR表示的线性插值方式。通过Transition类可以实现各种 不同的动画效果,比如,实现一个矩形在界面上沿斜线运动,可以使用代码清单5-18中的实现方式。通过setCycleDuration方法把动画效果的 周期设为3秒。在interpolate方法的实现中,根据当前的播放进度,通过修改X轴和Y轴坐标的方式把矩形移动到相应的位置上。方法 setCycleCount用来设置动画周期的播放次数,Animation.INDEFINITE的含义是无限执行。如果调用setAutoReverse方法并使用“true”作为参 数,动画在下一个周期会改变播放的方向。 代码清单5-18 基于值的自动变化的动画效果的示例 final Rectangle rect=new Rectangle(200,100); final double distanceX=400; final double distanceY=300; final Animation animation=new Transition(){ { setCycleDuration(Duration.millis(3000)); } protected void interpolate(double frac){ rect.setX(distanceX*frac); rect.setY(distanceY*frac); } }; animation.setCycleCount(Animation.INDEFINITE); animation.setAutoReverse(true); animation.play(); JavaFX中提供了一些实现常见动画效果的Transition类的子类。为了实现代码清单5-18中的动画效果,更好的做法是使用 javafx.animation.TranslateTransition类。 相对于使用Transition类的做法,第二种做法允许开发人员使用更加灵活的方式为JavaFX中组件的任何属性添加动画效果,这种做法基于 javafx.animation.Timeline类来实现。Timeline类表示的是一条时间线,其中包含多个关键帧,即javafx.animation.KeyFrame类的对象。每 个KeyFrame类的对象都对应时间线上的某个时间点,以及在这个时间点上属性应该具有的值。属性的值由javafx.animation.KeyValue类的对象 来表示。每个KeyFrame类的对象可以包含多个KeyValue类的对象,这些对象表示该关键帧所定义的多个属性的值。在指明了关键帧之 后,JavaFX会自动使用插值算法来计算出实现整个动画效果所需的其他帧。这种实现方式的核心在于由开发人员定义整个动画效果中的关键 点,由系统负责完成整个动画效果。 代码清单5-19给出了使用Timeline类实现动画效果的示例。在创建Timeline类的对象时,指定了两个关键帧。这两个关键帧对矩形的X轴坐 标和填充颜色两个属性添加了动画效果。第一个关键帧对应的时间点是第2秒,此时对属性值的要求是,当动画效果运行到这一帧时,矩形的X 轴坐标是100,而填充颜色的值是Color.RED。第二个关键帧对应的时间点是第4秒,此时对属性值的要求是,矩形的X轴坐标是200,而填充颜色 的值是Color.WHITE。由于没有指定时间点为第0秒时的关键帧,当动画开始播放时,会以矩形中这两个属性的当前值作为起始值。在整个动画 播放过程中,其他帧上的这两个属性的值由系统自动计算。动画的播放效果是,界面上矩形的位置会沿着X轴从坐标0移动到200,同时矩形的填 充颜色先从白色逐渐变为红色,再逐渐变回白色。 代码清单5-19 基于时间线和关键帧的动画效果的示例 final Rectangle rect=new Rectangle(200,100,Color.WHITE); final Animation animation=new Timeline( new KeyFrame(Duration.seconds(2),new KeyValue(rect.xProperty(),100),new KeyValue(rect.fillProperty(),Color.RED)), new KeyFrame(Duration.seconds(4),new KeyValue(rect.xProperty(),200),new new KeyFrame(Duration.seconds(4),new KeyValue(rect.xProperty(),200),new KeyValue(rect.fillProperty(),Color.WHITE)) ); animation.setCycleCount(Animation.INDEFINITE); animation.play(); 5.7.4 FXML 有些JavaFX程序的界面比较复杂,包含非常多的元素。创建这类界面的代码非常繁琐,可读性差,也很难进行维护。JavaFX允许开发人员 使用FXML语言来描述程序的界面。FXML使用的是XML的语法。可以从FXML文档中创建出用户界面。使用FXML的好处是界面上组件之间的层次结构 非常清晰,易于更新和维护。不熟悉Java的界面设计人员也可以用FXML来描述界面。 在JavaFX程序中,使用FXML有三个步骤。第一步是使用FXML的语法格式来描述程序的用户界面;第二步是通过javafx.fxml.FXMLLoader类 加载一个FXML文档,并得到相应的用户界面的对象;第三步是在JavaFX程序中直接使用得到的组件对象。 FXML的基本语法比较简单。FXML中的元素可以表示对象实例、属性或代码块。对于对象实例,元素的名称是对应的Java类的名称,而元素 的属性用来设置对象的属性值。对象的某些属性比较复杂,不能用简单的XML属性来描述,只能用子元素来表示。代码清单5-20给出了一个用 FXML描述JavaFX程序界面的示例。处理指令“import”类似Java中的import语句。FXML文档中的元素“<VBox>”、“<HBox>”、“< Button>”和“<Label>”等用来创建对应的对象。简单的属性,如VBox类的spacing,可以直接在XML元素的属性上设置;复杂的属性,如 VBox类的对象所包含的子组件children,可以通过子元素来设置。 代码清单5-20 使用FXML描述JavaFX程序界面的示例 <?xml version="1.0"encoding="UTF-8"?> <?import java.lang.*?> <?import javafx.scene.*?> <?import javafx.collections.*?> <?import javafx.scene.control.*?> <?import javafx.scene.layout.*?> <VBox id="main"spacing="20"prefHeight="300"prefWidth="400"xmlns:fx="http://javafx.com/fxml"fx: controller="javafx.fxml.Form"> <children> <HBox spacing="10"> <children> <Label text="Name:"/> <TextField fx:id="name"promptText="Enter your name"/> </children> </HBox> <HBox spacing="10"> <children> <Label text="Gender:"/> <ListView fx:id="gender"> <items> <FXCollections fx:factory="observableArrayList"> <String fx:value="Male"/> <String fx:value="Female"/> </FXCollections> </items> </ListView> </children> </HBox> <HBox> <children> <Button id="button"text="Say"onAction="#say"fx:id="button"/> <Label id="message"fx:id="message"/> </children> </HBox> </children> </VBox> 每个FXML文档可以与一个Java对象关联起来,这个Java对象被称为FXML文档的控制器。通过FXML文档根元素的“fx:controller”属性来 声明控制器的Java类名。在FXML文档中可以引用控制器对象中的方法。具体的格式是在方法名称上加上“#”作为前缀。代码清单5-20中的“< Button>”元素的“onAction”属性引用了控制器对象中的“say”方法。 创建出FXML文档之后,可以在JavaFX程序中通过FXMLLoader类来使用它。代码清单5-21给出了相关的示例。FXMLLoader类的load方法可以 从不同的来源加载FXML文档,并创建出FXML文档中声明的组件对象。这个创建出来的组件对象可以在JavaFX程序中自由使用。 代码清单5-21 在JavaFX程序中加载FXML文档的示例 public class JavafxFxml extends Application{ public static void main(String[]args){ launch(args); } public void start(Stage stage)throws Exception{ Parent root=FXMLLoader.load(getClass().getResource("Form.fxml")); stage.setScene(new Scene(root)); stage.show(); } } 5.7.5 CSS外观描述 用户对桌面应用外观的要求越来越高,很多应用都提供了更换界面皮肤的功能来满足用户的需求。从前面对AWT和Swing的介绍可以知 道,AWT无法改变组件的外观,而Swing则需要通过编写Java代码的方式来实现。JavaFX采用了一种更加先进的做法,即允许使用CSS来改变用户 界面的外观。引入CSS支持所带来的影响是巨大的:首先,对用户界面的修改变得很容易,只需要修改CSS代码即可,不需要重新进行编译;其 次,熟悉CSS用户界面的设计人员可以直接修改程序的外观,而不需要依靠Java开发人员来编写Java代码;最后,JavaFX和Web应用的外观样式 声明可以混杂在一起来使用。 JavaFX中使用的CSS语法基于W3C的CSS 2.1规范,并添加了部分CSS 3的内容。鉴于JavaFX程序本身的特性,所使用的CSS语法中某些属性的 含义不同于在Web应用中的含义。JavaFX程序使用的CSS样式表中的所有属性都以“-fx-”作为前缀,以区别于一般的CSS属性。 有两种方式来使用CSS设置程序的外观样式。第一种方式是使用Node类的setStyle方法直接设置节点的CSS样式声明,这种做法适合于修改 单个具体的节点的样式,比如,当需要把某个矩形对象rect的填充颜色设为红色,可以使用“rect.setStyle("-fx-fill:#FF0000");”。 第二种方式是把样式声明提取到单独的CSS文件中,并通过Scene类的对象来应用CSS文件。如代码清单5-22所示,在Scene类的对象中添加了新 的CSS文件“main.css”,在“main.css”中定义了相关的样式规则。为了在CSS文件中能够引用界面上的组件,一般需要使用setId方法为组件 指定一个标识符。“main.css”中的样式声明是“#rect{-fx-fill:#ff0000;}”,把标识符为“rect”的节点的填充颜色设置为红色。 代码清单5-22 使用CSS描述JavaFX程序外观的示例 Group root=new Group(); Rectangle rect=new Rectangle(100,50); rect.setId("rect"); root.getChildren().add(rect); Scene scene=new Scene(root,300,250); scene.getStylesheets().add("main.css"); 如果在CSS文件中定义了CSS类,可以在代码中为组件添加CSS类,例如,“rect.getStyleClass().add("myRect")”为rect对象添加了 名称为“myRect”的CSS类。 5.7.6 Web引擎与网页显示 JavaFX提供了用户界面组件来显示Web页面及对页面的内容进行操纵。JavaFX使用的网页显示引擎基于Webkit内核,支持HTML5的新特性。 在JavaFX程序中可以访问Web引擎组件中显示的网页的DOM结构和执行JavaScript代码。这个组件相当于一个内嵌的浏览器,它使一种新的结合 Java和HTML的部署方式成为可能。具体的做法是:程序的主体内容是使用HTML、JavaScript和CSS来编写的Web页面,并且可以利用HTML5的新特 性。程序的外层由JavaFX来编写,通过一个Web引擎组件来显示作为主体的Web页面。用户在使用时运行的是JavaFX程序。这样的好处是在编写 Web应用时限制了用户使用的浏览器类型,不需要过多考虑浏览器兼容性问题,同时可以利用Webkit内核的新特性。越来越多的Web应用使用了 HTML5的新特性。如果用户直接通过浏览器来访问,此时的浏览器可能并不支持这些新特性。这一方面会影响用户体验,另外一方面也使程序的 实现变得更加复杂。采用这种JavaFX与HTML集成的部署方式之后,用户只需要运行JavaFX程序即可,不需要考虑浏览器相关的问题。 Web引擎组件由javafx.scene.web.WebEngine和javafx.scene.web.WebView类来表示。WebEngine类负责管理网页以及与网页中的内容进行 交互;WebView类则负责显示网页的内容,可以被当做场景上的普通节点来使用。代码清单5-23给出了使用Web引擎组件进行网页自动化测试的 一个示例。待测试的网页是一个用户登录页面。用户输入用户名和密码之后,如果验证成功,会跳转到相应的页面。通过Web引擎组件可以对这 个测试过程进行自动化。先创建一个WebView类的对象,并将其添加到界面场景中。然后通过WebView类的对象的getEngine方法可以获取所关联 的WebEngine类的对象。WebEngine类的load方法可以用来加载一个网页并显示。网页的加载过程是异步执行的。通过WebEngine类的 getLoadWorker方法可以获取到一个用来监视加载过程进度的javafx.concurrent.Worker接口的实现对象。Worker接口的workDone属性表示当前 的加载进度。在该属性上添加一个变化监听器,可以监视当前的网页加载进度。网页加载完成之后,通过WebEngine类的getDocument方法可以 获取表示当前页面DOM结构的org.w3c.dom.Document接口的实现对象。可以使用该Document接口的实现对象对网页内容进行操作。在代码清单5- 23中设置了登录页面上两个<input>元素的值,用来模拟输入用户名和密码。WebEngine类的executeScript方法可以用来执行一段JavaScript 代码。代码清单5-23通过JavaScript代码的方式来提交表单。如果用户验证正确,提交表单后,应该跳转到一个新的页面。WebEngine类的 location属性可以用来监视内嵌浏览器中网页地址的变化。可以通过这种方式来验证网页是否发生了跳转。 代码清单5-23 使用Web引擎组件进行网页自动化测试的示例 public class JavafxWeb extends Application{ public static void main(String[]args){ launch(args); } public void start(Stage primaryStage){ primaryStage.setTitle("Web Test"); WebView view=new WebView(); primaryStage.setScene(new Scene(view,800,600)); primaryStage.show(); final WebEngine engine=view.getEngine(); String url="http://localhost/test.html"; engine.load(url); final Worker worker=engine.getLoadWorker(); worker.workDoneProperty().addListener(new ChangeListener<Number>(){ public void changed(ObservableValue<?extends Number>observable, Number oldValue, Number newValue){ if(newValue.intValue()==100){ worker.workDoneProperty().removeListener(this); engine.locationProperty().addListener(new ChangeListener-<String>(){ public void changed(ObservableValue<?extends String>observable, String oldValue, String newValue){ System.out.println(newValue);//检查地址是否正确 } }); Document doc=engine.getDocument(); Element nameElem=doc.getElementById("name"); nameElem.setAttribute("value","alex"); Element passwordElem=doc.getElementById("password"); passwordElem.setAttribute("value","password"); String script="document.getElementById('form').submit();"; engine.executeScript(script); } } }); } } 5.8 使用案例 本章的案例主要介绍如何把Swing和JavaFX集成起来使用。在目前的Java桌面应用中,除了使用SWT之外,绝大部分是利用Swing来开发的。 在使用JavaFX开发新的桌面应用时,免不了要与遗留的Swing应用进行整合。在一个已有的Swing程序中嵌入JavaFX组件是一件比较容易的事 情。JavaFX提供了javafx.embed.swing.JFXPanel类,可以显示JavaFX中的场景。JFXPanel类继承自JComponent类,因此可以在Swing程序中使 用,同时可以通过setScene方法来设置JavaFX中表示场景的Scene类的对象。 在集成Swing和JavaFX时,要注意界面更新线程的使用。Swing中的界面更新操作都需要在事件分发线程中进行,而JavaFX中的界面更新操 作需要在JavaFX应用线程中进行。这两个线程是不一样的。需要使用SwingUtilities类的invokeLater方法和Platform类的runLater方法来在正 确的线程中执行界面更新操作。 本案例开发的是一个简单的MP3播放器,实现根据MP3文件的URL来进行播放的功能。由于JavaFX提供了良好的对音频文件播放的支持,因此 音乐播放这部分工作由JavaFX来实现,剩下的界面显示由Swing来实现。代码清单5-24给出了案例的完整实现。方法initAndShowUI用来创建整 个Swing界面,对该方法的调用需要封装在SwingUtilities类的invokeLater方法的调用中。方法initPanel用来创建JFXPanel类的对象中包含的 界面组件,对该方法的调用需要封装在Platform类的runLater方法的调用中。 代码清单5-24 集成JavaFX和Swing的MP3播放器 public class JavafxPlayer{ public static void main(String[]args){ SwingUtilities.invokeLater(new Runnable(){ public void run(){ initAndShowUI(); } }); } private static void initAndShowUI(){ JFrame frame=new JFrame("Music Player"); final JFXPanel fxPanel=new JFXPanel(); frame.add(fxPanel, BorderLayout.NORTH); final JLabel label=new JLabel(); frame.add(label, BorderLayout.CENTER); frame.setSize(400,200); frame.setVisible(true); Platform.runLater(new Runnable(){ public void run(){ initPanel(fxPanel, label); } }); } private static void initPanel(JFXPanel fxPanel, final JLabel label){ HBox box=new HBox(10); Button play=new Button("Play"); play.setMinWidth(100); Media media=new Media("http://localhost/test.mp3"); final MediaPlayer player=new MediaPlayer(media); play.setOnAction(new EventHandler<ActionEvent>(){ public void handle(ActionEvent t){ player.play(); SwingUtilities.invokeLater(new Runnable(){ public void run(){ label.setText(player.getMedia().getSource()); } }); } }); box.getChildren().addAll(play); fxPanel.setScene(new Scene(box,400,50)); } } 5.9 小结 使用Java开发桌面应用并不是一件很困难的事情。Java语言本身的特性及用户界面库的设计方式,使开发人员上手更容易,要精通也并不 困难。本章的内容主要围绕Java 7中AWT和Swing的新特性展开,同时介绍了AWT和Swing中一些比较复杂的概念,包括事件分发线程、界面绘制 和可插拔式外观样式等。理解这些概念可以避免一些开发中会出现的错误。而对于AWT和Swing中组件的具体使用,并没有进行详细介绍,相关 的组件说明可以从API文档中找到。本章着重介绍了JavaFX 2.0。作为Java平台上桌面应用的未来发展方向,了解JavaFX是很有必要的。 对于基于Java平台的桌面应用开发,开发人员可能会希望借助框架来简化具体的开发工作。实际上,早在2006年,相关框架的设计与实现 工作就已经开始了。这个被称为Swing应用框架(Swing application framework)的框架由JSR 296规范来定义。原来的计划是JSR 296会成为 Java 7的一部分,后来由于时间原因而被迫放弃。JSR 296有可能会最终出现在Java 8中。因此,在Java 7中并不存在标准的Swing应用开发框 架。开发人员只能从已有的一些框架中做出选择。一些不错的候选框架包括:Better Swing Application Framework(BSAF)[1]、Guice Utilities&Tools Set(GUTS)[2]和Spring Rich Client[3]。 [1]http://kenai.com/projects/bsaf. [2]http://kenai.com/projects/guts. [3]http://spring-rich-c.sourceforge.net/. 第6章 Java 7其他重要更新 本书前面几章从不同的方面介绍了Java 7中比较重要的更新,这些更新的介绍不是独立进行,而是围绕Java平台上的某个特定主题展开。 在对这些主题进行介绍的时候,既介绍了Java 7之前已有的概念,又介绍了Java 7的新特性,目的在于让读者全面了解一个主题各个方面的相 关知识。本章则以Java 7中的其他重要更新为主要内容,同时也包括了其他相对较小却值得关注的改动。 在内容组织方式上,本章相对比较松散,每一节围绕一个小的主题展开。对于某些小改动,合并在一个小节里面进行介绍。在具体的内容 上,以Java 7的新特性为主,并适当介绍相关的背景知识。 6.1 关系数据库访问 关系数据库访问一直是Java平台的重要功能,尤其对Web应用开发来说,大多数Web应用都是由关系数据库来驱动的。Java平台的数据库访 问方式由JDBC(Java Data Base Connectivity)规范来定义。JDBC规范本身也在不断更新,以适应数据库技术的发展,同时满足开发人员的使 用需求。Java 7在数据库访问方面的重要更新是增加了对JDBC 4.1规范的支持,Java 6支持的仅是JDBC 4.0规范。相对于JDBC 4.0来说,JDBC 4.1规范又引入了一些新的特性,这些特性都可以在Java 7中使用。需要注意的是,不同数据库实现的JDBC驱动对JDBC 4.1规范的支持程度是不 相同的。特定的数据库实现有可能暂时还不支持某些新特性。JDBC 4.1规范所更新的特性在java.sql和javax.sql包中都有,下面会分别进行介 绍。 6.1.1 使用try-with-resources语句 在第1章介绍了Java 7通过新增的try-with-resources语句来管理Java平台上的各种资源。与数据库访问相关的各种资源,包括数据库连 接、查询语句和结果集等,都可以用try-with-resources语句来进行管理。通过try-with-resources语句可以去掉数据库操作时对close方法的 调用,大大降低了代码的复杂性。在Java 7中,java.sql包中的java.sql.Connection、java.sql.Statement和java.sql.ResultSet接口都继承 了java.lang.AutoCloseable接口,以支持由try-with-resources语句来管理。代码清单6-1给出了使用try-with-resources语句进行数据库操 作的示例。可以把对Connection、Statement和ResultSet的创建都封装在try-with-resources语句中,这样就不用考虑这些对象的关闭操作。 代码清单6-1 用try-with-resources语句进行数据库操作的示例 public void dbOperation()throws SQLException{ try(Connection connection=DriverManager.getConnection("jdbc:derby://localhost/java7book"); Statement stmt=connection.createStatement(); ResultSet rs=stmt.executeQuery("SELECT*FROM book")){ while(rs.next()){ System.out.println(rs.getString("name")); } } } 6.1.2 数据库查询的默认模式 大部分关系数据库系统都支持为数据库中包含的表和其他对象创建一个额外的名称空间,即模式(schema)。一个模式中可以包含表、视 图以及其他对象。不同模式中可以有名称相同的表或视图,而同一模式中不允许有名称相同的对象存在。通过SQL语句“CREATE SCHEMA”可以 创建新的模式。当访问某个模式中包含的表或视图等对象时,需要使用模式名称作为前缀来访问,否则无法找到相应的对象。比如,对于一个 名为“DEMO_SCHEMA”的模式中的“author”表,在SQL语句中应该使用“DEMO_SCHEMA.author”来引用。如果每次都要加上模式名称作为前 缀,那么使用起来会比较麻烦。JDBC 4.1为Connection接口添加了一对新的方法getSchema和setSchema,用来获取和设置数据库操作时使用的 默认模式名称。当通过setSchema进行设置之后,在SQL语句中就不再需要使用模式名称作为前缀了。代码清单6-2给出了使用setSchema方法的 示例。一般来说,如果希望使用setSchema方法,那么应该在从Connection接口中创建出来的Statement对象被使用之前进行设置,否则可能会 造成默认的模式无法生效。 代码清单6-2 setSchema方法的使用示例 public void setSchema()throws SQLException{ try(Connection connection=DriverManager.getConnection("jdbc:derby://localhost/java7book")){ connection.setSchema("DEMO_SCHEMA"); try(Statement stmt=connection.createStatement(); ResultSet rs=stmt.executeQuery("SELECT*FROM author")){ while(rs.next()){ System.out.println(rs.getString("name")); } } } } 6.1.3 数据库连接超时时间与终止 在使用数据库连接时会遇到的一个问题是,网络连接出现问题造成数据库连接中断。当数据库连接中断时,可能会造成发出数据库操作命 令的线程在较长时间内处于等待状态。具体的等待时间取决于TCP连接的超时时间设置。这个超时时间一般会长达几分钟。也就是说,在数据库 操作命令发出之后,可能需要等待几分钟才能得知数据库连接已经中断了。对于一个数据库应用来说,几分钟的等待时间过长,为此,JDBC 4.1添加了对数据库连接超时的处理方式,主要是在Connection接口中新增了setNetworkTimeout和abort两个方法。 Connection接口的setNetworkTimeout方法用来设置通过此数据库连接进行数据库操作时的超时等待时间。如果远程数据库没有在给定的时 间内返回操作结果,那么会认为该连接已经关闭,无法再继续使用,处于等待状态的数据库操作方法则会抛出SQLException异常来表示这种超 时的情况。对一个Connection接口的实现对象设置超时等待时间之后,会影响到从该Connection接口的实现对象中创建的其他对象,主要包括 Statement和java.sql.PreparedStatement接口的实现对象。通过Statement和PreparedStatement接口的实现对象执行的查询操作也适用于 Connection接口的实现对象上设置的超时等待时间。可以多次调用setNetworkTimeout方法,以对不同的情况设置不同的超时时间。对于同一个 Connection接口的实现对象,如果预计某些查询操作比较耗时,可以把超时等待时间设置为一个较大的值,等操作完成之后,再恢复为一个对 大多数操作都适用的较小值。 与setNetworkTimeout相关的方法abort用来强制关闭一个数据库连接。当调用一个Connection接口的实现对象的abort方法时,这个数据库 连接会被标记为关闭状态,同时与该连接相关的资源都会被释放,另外,当前正在使用该连接的方法会抛出SQLException异常。从作用上 讲,abort方法类似于Connection接口中已有的close方法,但是两者的使用场景不同:close方法一般是由Connection接口的使用者来调用的, 当一个使用者完成数据库操作之后,可以通过close方法来关闭此数据库连接;而abort方法一般由数据库连接的管理者来调用,如果发生了前 面提到的由于网络问题造成数据库连接中断的情况,连接的管理者在检测到问题发生之后,可以调用abort方法来强制终止此连接,该连接的使 用者也不需要等待数据库操作的完成即可进入到SQLException异常的处理阶段。 setNetworkTimeout和abort方法都接收一个java.util.concurrent.Executor接口的实现对象作为参数。这个Executor接口的实现对象用来 执行方法调用中可能会出现的相关任务。对于abort方法来说,在终止数据库连接的过程中需要释放相关的资源。释放资源的相关任务由该 Executor接口的实现对象来执行。当abort方法返回之后,Executor接口的实现对象可能仍在继续执行相关的任务。代码清单6-3给出了abort方 法的使用示例。为了查看在abort方法调用过程中执行的相关任务,这里使用了一个自定义的java.util.concurrent.ThreadPoolExecutor类的 实现。在任务被执行之前,会在控制台输出相关的提示信息。在运行过程中可以发现,在调用abort方法之后,有新的任务被添加到Executor接 口的实现对象中执行。 代码清单6-3 abort方法的使用示例 public class AbortConnection{ public void abortConnection()throws SQLException{ Connection connection=DriverManager.getConnection("jdbc:derby://localhost/java7book"); ThreadPoolExecutor executor=new DebugExecutorService(2,10,60,TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable> ()); connection.abort(executor); executor.shutdown(); try{ executor.awaitTermination(5,TimeUnit.MINUTES); }catch(InterruptedException e){ e.printStackTrace(); } } private static class DebugExecutorService extends ThreadPoolExecutor{ public DebugExecutorService(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue< Runnable>workQueue){ super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue); } public void beforeExecute(Thread t, Runnable r){ System.out.println("清理任务:"+r.getClass()); super.beforeExecute(t, r); } } } 6.1.4 语句自动关闭 在JDBC 4.1之前,当使用一个Statement接口的实现对象进行查询,得到表示结果集的ResultSet接口的实现对象并完成处理之后,需要显 式地通过close方法来关闭ResultSet和Statement接口的实现对象以释放资源。使用Java 7的try-with-resources语句可以简化这个操作,而更 好的办法是使用JDBC 4.1为Statement接口添加的自动关闭功能。在调用了Statement接口的closeOnCompletion方法之后,表明当依赖此 Statement接口的实现对象的所有结果集都被关闭之后,该Statement接口的实现对象也被自动关闭。通过这种方式,开发人员只需显式地关闭 结果集即可,不需要考虑Statement接口的实现对象本身的关闭问题。这个新特性在Statement接口的实现对象作为参数在多个方法中传递时尤 为实用。当代码中的多个地方都使用了相同的Statement接口的实现对象来打开结果集时,以前需要设计好由哪个方法来关闭该Statement接口 的实现对象,而使用这个自动关闭功能之后,每个方法只需要关闭该方法内部打开的结果集即可,不需要考虑Statement接口的实现对象自身的 关闭问题。 6.1.5 RowSet实现提供者 javax. sql.RowSet接口是JDBC 2.0引入的表格式数据的抽象表示形式。RowSet接口继承自ResultSet接口,并在ResultSet接口的基础上添 加了属性设置和变化通知相关的功能。RowSet接口是符合JavaBeans组件规范的,可以与其他JavaBeans组件协同使用。RowSet接口的使用者并 不需要显式地管理数据库连接,只需要把数据库相关的连接信息以属性的方式设置到RowSet接口的实现对象中即可。RowSet接口的实现会负责 处理数据库连接的相关工作。RowSet接口有5个不同的子接口,每个子接口实现所适应的场景不同。每个子接口对应的具体实现类也有不少,分 别来自不同的提供者。在Java 7之前并没有一个规范的方式来管理RowSet接口的实现对象的创建,开发人员需要直接根据实现类的类名来创建 新的RowSet接口的实现对象。这种直接依赖具体类而不是接口的做法,不利于代码的移植和维护。 Java 7对RowSet接口的实现对象的创建做了更新,采用了Java标准的服务提供者接口(Service Provider Interface, SPI)机制。使用者 通过工厂方法来创建具体的RowSet接口的实现对象,而工厂对象本身由RowSet实现的提供者注册到Java平台上。具体的工厂对象的查找和创建 是由javax.sql.rowset.RowSetProvider类的静态方法来完成的,具体的工厂对象则实现javax.sql.rowset.RowSetFactory接口。 RowSetFactory接口提供了5种不同RowSet接口实现的创建方法。代码清单6-4给出了使用javax.sql.rowset.JdbcRowSet实现的示例,从中可以 看到RowSet接口的基本用法,数据库连接字符串和SQL语句都是以属性的方式来进行设置的。 代码清单6-4 使用工厂方法创建RowSet接口的实现对象的示例 public void useRowSet()throws SQLException{ RowSetFactory rsFactory=RowSetProvider.newFactory(); try(JdbcRowSet jrs=rsFactory.createJdbcRowSet()){ jrs.setUrl("jdbc:derby://localhost/java7book"); jrs.setCommand("SELECT*FROM book"); jrs.execute(); jrs.absolute(1); jrs.updateString("name","New book"); jrs.updateRow(); } } 除了上面介绍的几个比较重要的更新之外,JDBC 4.1还有一些比较小的更新。这些小更新包括:在使用ResultSet中的getObject方法时, 可以直接把结果类型的Java类作为参数传递进去,而不需要在调用之后再进行强制类型转换。比如,之前的调用方式 是“(String)rs.getObject(1)”,新的调用方式是“rs.getObject(1,String.class)”;表示数据库中元数据的 java.sql.DatabaseMetaData接口中新增了getPseudoColumns方法来获取数据库表中包含的伪列(pseudo column)和隐藏列(hidden column) 的元数据。伪列指的是不在数据库表结构中定义但可以在查询中获取的列。不同数据库实现所支持的伪列是不同的,比如Oracle数据库允许在 查询中使用伪列“ROWNUM”来获取当前行的行号,类似的伪列还有获取当前日期和时间戳的“SYSDATE”和“SYSTIMESTAMP”。隐藏列通常用来 保存一些与数据库表相关的内部信息。getPseudoColumns方法的返回值是ResultSet接口的实现对象,其中的每一行都包含了查找到的伪列和隐 藏列的名称、数据类型、大小等信息。如果通过Statement接口的setQueryTimeout方法设置了数据库查询的超时等待时间,那么当操作超时的 时候,会抛出更加具体的java.sql.SQLTimeoutException异常,而不是通用的SQLException异常。 在使用JDBC 4.1时,需要检测所增加的新特性是否已经被关系数据库实现支持。不同的关系数据库实现在对JDBC规范的支持上可能有所不 同。在使用JDBC 4.1的新特性之前,先查看一下所使用的数据库和驱动的相关信息,以确定对JDBC 4.1规范的支持程度。 6.2 java.lang包的更新 Java 7对java.lang包的更新也比较多,主要涉及基本类型的包装类、进程使用和线程类。下面分别具体介绍。 6.2.1 基本类型的包装类 Java 7对基本类型的包装类做了一些更新,以更好地满足日常的开发需求。第一个更新是在基本类型的比较方面,Boolean、Byte、 Short、Integer、Long和Character类都添加了一个比较两个基本类型值的静态compare方法,比如Long类的compare方法可以用来比较两个long 类型的值。这个compare方法更多是作为“语法糖衣”而存在的,可以简化进行基本类型数值比较时的代码。在Java 7之前,如果需要对两个 int数值x和y进行比较,一般的做法是使用代码“Integer.value(x).compareTo(Integer.value(y))”,而在Java 7中可以直接使 用“Integer.compare(x, y)”。 对于字符串内部化(string interning)技术,开发人员可能并不陌生,采用这种技术是常见的优化策略,可以提高字符串比较时的性 能,是一种典型的空间换时间的做法。Java也采用了这种技术。在Java中,包含相同字符的字符串字面量引用的是相同的内部对象。String类 也提供了intern方法来返回与当前字符串内容相同但已经被包含在内部缓存中的对象引用。在对被内部缓存的字符串进行比较时,可以直接使 用“==”操作符,而不需要用更加耗时的equals方法。比如,在代码清单6-5中,第一个字符串比较的结果为true,第二个字符串比较的结果为 false,第三个字符串比较的结果为true。第二个字符串比较如果使用equals方法进行,那么结果也是true。 代码清单6-5 字符串内部化的示例 public void stringIntern(){ boolean value1="Hello"=="Hello"; boolean value2=(new String("Hello")=="Hello"); boolean value3=(new String("Hello").intern()=="Hello"); } Java 7把这种内部化机制扩大到了-128到127之间的数字。根据Java语言规范,对于-128到127范围内的short类型和int类型,以及\u0000 到\u007f范围内的char类型,它们对应的包装类对象始终指向相同的对象,即通过“==”进行判断时的结果为true。为了满足这个要 求,Byte、Short、Integer类的valueOf方法对于-128到127范围内的值,以及Character类的valueOf方法对于0到127范围内的值,都会返回内 部缓存的对象。如代码清单6-6所示,在使用了内部缓存的情况下,由于第一个比较操作的数值是在-128到127之间,因此Integer类的valueOf 方法返回的是同一个缓存对象,value1的值为true;而第二个比较操作的数值超出了默认的缓存范围,因此valueOf方法返回的是两个不同的对 象,value2的值为false。 代码清单6-6 Integer类的内部化的示例 public void numberCache(){ boolean value1=Integer.valueOf(3)==Integer.valueOf(3); boolean value2=Integer.valueOf(129)==Integer.valueOf(129); } 如果希望缓存更多的值,可以通过Java虚拟机启动参数“java.lang.Integer.Integer-Cache.high”来进行设置。例如,使用“- Djava.lang.Integer.IntegerCache.high=256”之后,数值缓存的范围就变成了-128到256,再次运行代码清单6-6会发现,value2的值变成 true,因为129处于修改之后的缓存范围之内。 6.2.2 进程使用 在Java程序中可能需要调用底层操作系统上的其他程序,Java标准API提供了创建底层操作系统上运行的进程的能力,只需要传入正确的命 令和相关的参数,就可以启动一个进程。在进程启动之后,可以从Java程序向进程提供输入数据,以及读取进程运行过程中产生的输出数据。 对于在Java程序中启动其他进程这个任务来说,最重要的是输入和输出的处理。通常的做法是把Java程序的内部运行结果作为输入传递给一个 新创建的进程,然后等待进程执行完成。在得到进程输出的运行结果之后,再继续下面的处理。通过这种方式,底层操作系统上的其他进程可 以很好地与Java程序集成起来。 在Java 7之前,对进程的输入和输出进行处理的方式比较有限,只支持管道式的方式。进程的输入对Java程序来说是一个输出流,程序向 这个输出流中写入的数据会通过管道传递给进程。同样的,进程的输出对于Java程序来说是一个输入流,通过读取此输入流的内容获得进程的 输出。标准的创建新进程的过程是使用java.lang.ProcessBuilder类来设置新进程的属性,然后通过start方法来启动进程的执行。 ProcessBuilder类的start方法的返回值是一个表示进程的java.lang.Process类的对象。通过Process类的getOutputStream方法可以得到向进 程写入数据的输出流,而通过getInputStream和getErrorStream方法可以分别得到包含进程正常执行和出错时输出内容的输入流。代码清单6-7 给出了创建进程的示例。示例中启动了Windows上的命令行工具来执行“netstat-a”命令,并把结果保存到一个文件中。 代码清单6-7 创建进程的示例 public void startProcessNormal()throws IOException{ ProcessBuilder pb= new ProcessBuilder("cmd.exe","/c","netstat","-a"); Process process=pb.start(); InputStream input=process.getInputStream(); Files.copy(input, Paths.get("netstat.txt"),StandardCopyOption.REPLACE_EXISTING); } 使用管道的方式在某些情况下显得不够灵活,因此Java 7对进程的输入和输出处理进行了更新,增加了另外的两种处理方式。第一种是继 承式,即新创建进程的输入和输出与当前的Java进程相同。第二种是基于文件式,即把文件作为进程输入的来源和输出的目的地。代码清单6-8 给出了继承式的一个示例,其中启动的进程通过Windows上的命令行工具来执行dir命令,通过ProcessBuilder类的redirectOutput方法把进程 的输出设置为继承自父进程,运行的结果就是dir命令的输出内容,会显示在Java程序默认的输出控制台中。 代码清单6-8 进程的输入和输出的继承式处理方式的示例 public void dir()throws IOException{ ProcessBuilder pb= new ProcessBuilder("cmd.exe","/c","dir"); pb.redirectOutput(Redirect.INHERIT); pb.start(); } 如果希望把进程的输入或输出改为文件,那么可以使用ProcessBuilder类中的redirectInput和redirectOut方法的其他重载形式。代码清 单6-9给出了基于文件式处理方式的示例。示例中通过一个文件来保存进程的输出内容,只需要把一个java.io.File类的对象作为 redirectOutput方法的参数即可。这种做法在实现上相当于通过管道方式读取输入流来获取进程的输出,然后将其写入一个文件中。不过以标 准API的方式给出的实现,显然比程序自己来实现要好。 代码清单6-9 进程的输入和输出的文件式处理方式的示例 public void listProcesses()throws IOException{ ProcessBuilder pb= new ProcessBuilder("wmic","process"); File output=Paths.get("tasks.txt").toFile(); pb.redirectOutput(output); pb.start(); } 从API的角度来说,Java 7通过新增的ProcessBuilder.Redirect类对进程的输入和输出重定向方式进行了统一。ProcessBuilder.Redirect 类提供了两种直接使用的重定向类型,一种是Java 7之前就有的管道式,用ProcessBuilder.Redirect.PIPE来表示;另一种是前面介绍的继承 式,用ProcessBuilder.Redirect.INHERIT来表示。其余3种方式都是与文件相关的,在使用时都需要一个File类的对象作为参数。 ProcessBuilder.Redirect.from表示从一个文件中读取内容作为输入,ProcessBuilder.Redirect.to表示把输出写入一个文件 中,ProcessBuilder.Redirect.appendTo表示把输出的内容添加到一个已有的文件中。 在通过ProcessBuilder类的redirectInput和redirectOutput方法将输入和输出重定向之后,如果新的输入源和输出目标不是默认的管道方 式,那么就无法访问所创建进程的Process类的对象中的对应流。例如,通过redirectInput方法把进程的输入重定向到某个文件之后,Process 类的对象的getOutputStream方法返回的是一个空的输出流,调用该输出流的write方法总是会抛出IOException异常;通过redirectOutput和 redirectError方法把进程的正常和错误的输出重定向到某个文件之后,Process类的对象的getInputStream和getErrorStream方法返回的是一 个空的输入流,调用该输入流的read方法总是会返回-1。因此,如果在ProcessBuilder类的对象中对进程的输入或输出进行了重定向,那么相 应的Process类的对象的使用者一定要了解这一点,以免造成使用错误。 6.2.3 Thread类的更新 Java 7对于表示线程的类java.lang.Thread也做了一些更新,这些更新主要明确了Thread类的对象在某些情况下的行为,并且去掉了之前 使用中比较模糊的和设计不合理的部分。首先将Thread类的clone方法改为总是抛出CloneNotSupportedException异常,这是因为对一个Thread 类的对象进行克隆是没有意义的。Java 7显式地禁止了对Thread类对象的克隆操作。其次,在Java 7之前,Thread类的join方法和sleep方法可 以接收一个long类型的参数表示等待的时间,但是并没有定义当这个参数值为负数时的处理方式。Java 7中规定:如果这两个方法的等待时间 参数的值为负数,则会抛出IllegalArgumentException异常。 另外一个明确了参数处理行为的是Thread类的构造方法。在创建Thread类的对象时可以使用的参数包括:表示Thread类的对象所在线程组 的java.lang.ThreadGroup类的对象,表示需要运行的任务的java.lang.Runnable接口的实现对象,以及表示线程名称的String类的对象。如果 传入的ThreadGroup类的对象为null,那么会先尝试调用当前配置好的安全管理器(java.lang.SecurityManager类的对象)的getThreadGroup 方法来获取ThreadGroup类的对象;如果没有配置安全管理器或getThreadGroup方法也返回null,那么会使用当前线程所在线程组的 ThreadGroup类的对象;如果传入的Runnable接口的实现对象为null,那么会调用Thread类的对象本身的run方法;如果传入的线程名称是 null,会抛出NullPointerException异常。 在调用Thread类的setContextClassLoader方法来设置线程上下文类加载器时,如果传入的参数为null,则表明使用的是系统类加载器。如 果无法使用系统类加载器,就使用启动类加载器。同样的,如果当前线程上下文类加载器是系统类加载器或启动类加载器,那么 getContextClassLoader方法的返回值是null。关于类加载器的更多介绍,请见第9章。 6.3 Java实用工具类 Java中包含实用工具类的包java.util在日常开发中使用得非常频繁,Java 7对这个包中的不少内容进行了更新。 6.3.1 对象操作 在java.util包中新增了一个用来操作对象的工具类java.util.Objects。Objects类中包含的都是静态方法,通过这些方法可以快速对对象 进行操作。 在进行两个对象的比较操作时,可以使用Objects类的compare方法。一般来说,进行对象比较是先由Java类实现java.lang.Comparable接 口,再通过compareTo方法来进行比较。如果对集合中的元素进行排序,那么还会用到java.util.Comparator接口的实现。Objects类中的 compare方法可以将两个对象通过特定的Comparator接口的实现对象来进行比较。代码清单6-10中给出了一个简单的对Long对象进行比较的 Comparator接口的实现,以及Objects类的compare方法的使用示例。 代码清单6-10 Objects类的compare方法的使用示例 private static class ReverseComparator implements Comparator<Long>{ public int compare(Long num1,Long num2){ return num2.compareTo(num1); } } public void compare(){ int value1=Objects.compare(1L,2L, new ReverseComparator()); } 判断对象相等的方式一般是调用Object类的equals方法,如判断两个对象a和b是否相等,可以使用代码“a.equals(b)”。Objects类的 equals方法可以直接判断两个对象是否相等,如“Objects.equals(a, b)”。此方法的一个好处是会对null值进行处理。如果直接调用一个 对象的equals方法,需要先判断这个对象是否为null,而使用Objects类的equals方法则不需要。如果Objects类的equals方法调用时的两个参 数的值都是null,则判断结果是true;而如果只有一个参数为null,则判断结果是false;如果两个参数都不为null,则调用第一个参数的 equals方法来进行判断。Objects类中与equals方法作用相似的是deepEquals方法,利用该方法也可以对两个对象进行相等性判断。与equals方 法不同的是,如果deepEquals方法的两个参数都是数组,则会调用java.util.Arrays类的deepEquals来进行比较。Arrays类的deepEquals方法 在进行数组比较时,会考虑数组中的所有元素的相等性。在其他情况下,deepEquals方法和equals方法的作用是相同的。代码清单6-11给出了 示例,其中value1和value2的值都为false。 代码清单6-11 Objects类的equals方法的使用示例 public void equals(){ boolean value1=Objects.equals(new Object(),new Object()); Object[]array1=new Object[]{"Hello",1,1.0}; Object[]array2=new Object[]{"Hello",1,1.5}; boolean value2=Objects.deepEquals(array1,array2); } Objects类中的hashCode方法可以用来获取对象的哈希值。如果参数为null,那么返回值是0;否则返回值是参数对象的hashCode方法的返 回结果。如果需要计算一组对象的哈希值,那么可以使用Objects类的hash方法。Objects类的hash方法的实现使用的是Arrays类中hashCode方 法。需要注意的是,在调用hash方法时传入单个对象作为参数的返回结果,与使用同样的参数调用hashCode方法的结果并不相同。如代码清单 6-12所示,hashCode1和hashCode3的值是不相同的,Objects类的hash方法有自己的计算方式,不同于hashCode方法。 代码清单6-12 Objects类的hash和hashCode方法的使用示例 public void hash(){ int hashCode1=Objects.hashCode("Hello"); int hashCode2=Objects.hash("Hello","World"); int hashCode3=Objects.hash("Hello"); } Objects中还有一组用于获取对象的字符串表示的toString方法的不同重载形式。Objects的toString方法在参数为null时返回值 是“null”,而在其他情况下相当于调用参数对象的toString方法。如果希望在参数为null时返回给定的内容作为提示信息,那么可以使用 toString方法的另外一个重载形式,即通过一个额外的参数来指定参数值为null时的返回结果。如代码清单6-13所示,str1和str2的值分别 是“Hello”和“空对象”。 代码清单6-13 Objects类的toString方法的使用示例 public void useToString(){ String str1=Objects.toString("Hello"); String str2=Objects.toString(null,"空对象"); } 6.3.2 正则表达式 在日常开发中处理文本内容时经常会用到正则表达式。通过正则表达式可以简洁地解决一些常见的问题。Java 7对java.util.regex包中的 内容进行了更新,主要包括以下几个方面。 1.支持命名捕获分组 捕获分组(capturing group)在使用正则表达式从文本中提取满足某种模式的部分字符时非常有用。通过把感兴趣的字符串的模式封装在 捕获分组中,可以在匹配之后很容易地获取这些内容。另外捕获分组也可以在正则表达式中以后向引用(back reference)的方式来直接使 用,以表示相同的模式。在Java 7之前,对捕获分组的引用只支持使用表示出现顺序的数字形式。这体现在java.util.Matcher类的group方法 只接受int类型作为参数,后向引用的语法也仅支持类似“\1”这样的形式。如果一个正则表达式中包含很多捕获分组,那么开发人员需要清楚 每个数字所代表的捕获分组的含义,这对于代码的编写者和阅读者来说都是一件很麻烦的事情。Java 7引入的命名捕获分组可以很好地解决这 个问题。通过为每个捕获分组添加一个有意义的名字,使开发人员可以很容易地明白每个分组所表示的含义,这比使用无意义的数字要方便得 多。 代码清单6-14给出了通过命名捕获分组来匹配字符串并提取内容时的用法。待匹配的字符串是一个URL,其中通过路径的不同部分来表示查 询参数的名称和值。这种采用路径而不是查询字符串来指明参数的方式,在目前的Web开发中比较常见。在正则表达式的模式中,为提取每个参 数内容的捕获分组都指定了一个有意义的名字。当匹配完成之后,可以通过Matcher类的group方法来获取每个捕获分组的内容,参数是在模式 中指定的名字。采用“?<>”的格式为一个捕获分组命名,“<>”中的内容是名称。名称必须由大小写英文字母和数字组成,同时第一个字 符必须是字母。 代码清单6-14 通过命名捕获分组来匹配字符串并提取内容的示例 public void namedCapturingGroup(){ String url="http://www.example.org/uid/alex/docid/1/title/MyFirstBlog"; Pattern pattern=Pattern.compile("^.*/uid/(?<uid>.*)/docid/(?<docid>.*)/title/(?<title>.*)"); Matcher matcher=pattern.matcher(url); if(matcher.matches()){ String uid=matcher.group("uid");//值为alex String docId=matcher.group("docid");//值为1 String title=matcher.group("title");//值为MyFirstBlog } } 捕获分组的名称也可以用在正则表达式之中,用来替换使用数字来进行后向引用的做法。代码清单6-15给出了一个示例,用正则表达式来 查找重复出现的数字。在正则表达式中引用命名捕获分组使用的语法是“\k<>”,“<>”中是之前定义的捕获分组的名称。 代码清单6-15 捕获分组的名称作为后向引用的示例 public void repeatPattern(){ String str="123-123-12-456-456"; Pattern pattern=Pattern.compile("(?<num>\\d+)-\\k<num>"); Matcher matcher=pattern.matcher(str); while(matcher.find()){ String repeat=matcher.group("num"); } } 2.使用“\x”表示Unicode中的代码点 在正则表达式中,要引用Unicode字符时,可以通过“\u×××”的形式,如“\u0030”表示数字“0”。通过这种方式可以表示某些无法 在源代码中直接出现的字符,比如无法输入汉字的开发人员可以通过这种方式来表示中文字符。第4章介绍Unicode时提到过,如果一个Unicode 字符不在基本多语言平面(BMP)中,那么要在Java中以代理项对的方式出现,在转换成正则表达式中使用“\u”形式的时候,则需要两个相邻 的字符。这样既不够直观,使用起来也不方便。Java 7对正则表达式新增了“\x”来直接表示Unicode中的代码点。“\x”的使用方式 与“\u”类似,只不过允许表示的范围更广,如Unicode代码点U+1011F可以直接表示成“\x1011F”。 3.新标记Pattern.UNICODE_CHARACTER_CLASS 在通过Pattern类的compile方法对正则表达式进行编译时,可以指定多个标记。这些标记可以控制匹配时的行为。常见的标记包括设置匹 配时不区分大小写的Pattern.CASE_INSENSITIVE,以及设置“.”匹配包括换行符在内的所有字符的Pattern.DOTALL。Java 7中添加了一个额外 的标记Pattern.UNICODE_CHARACTER_CLASS来设置使用Unicode版本的预定义字符类和POSIX字符类。以“\d”这个预定义字符类为例,在默认情 况下,只会匹配0到9的字符,如果启用了UNICODE_CHARACTER_CLASS标记,“\d”会匹配Unicode规范中所定义的所有属于数字类别的字符,不 只是0到9的数字,也会包括其他语言中的数字字符。代码清单6-16给出了一个示例,使用“(\d+)”模式来匹配字符串中的数字。示例中输入 的字符串是“100 100”,其中包含了一般的数字“100”和全角数字“100”。在两个Pattern类的对象中,第一个Pattern类的对象在编译时没 有使用UNICODE_CHARACTER_CLASS标记,只能匹配一般的数字“100”,而第二个Pattern类对象可以匹配整个字符串。 代码清单6-16 UNICODE_CHARACTER_CLASS标记的使用示例 public void useUnicodeCharacterClass(){ String str="100 100"; Pattern pattern=Pattern.compile("(\\d+)"); Matcher matcher=pattern.matcher(str); if(matcher.find()){ String digit=matcher.group(1);//值为100 } pattern=Pattern.compile("(\\d+)",Pattern.UNICODE_CHARACTER_CLASS); matcher=pattern.matcher(str); if(matcher.find()){ String digit=matcher.group(1);//值为100 100 } } 受到UNICODE_CHARACTER_CLASS标记影响的字符类除了“\d”之外,还包括“\s”、“\w”、“\p{Lower}”、 “\p{Upper}”和“\p{Punct}”等。 4.指定Unicode字符使用的书写格式 Java 7中另外一个与正则表达式相关的更新也与Unicode相关。在Java 7之前,正则表达式在匹配Unicode字符串时允许指定Unicode字符所 在的区块和类别,而在Java 7中还允许指定Unicode字符使用的书写格式(script)。在指定书写格式时使用的是“\p”,如代码清单6-17所 示。“\p{script=Han}”的含义是匹配字符串中书写格式为汉字的Unicode字符,因此正则表达式的执行结果是原始字符串中的汉字“你好”。 代码清单6-17 指定Unicode字符使用的书写格式的示例 public void matchScript(){ String str="abc你好123"; Pattern pattern=Pattern.compile("(\\p{script=Han}+)"); Matcher matcher=pattern.matcher(str); if(matcher.find()){ String hans=matcher.group(1);//值为“你好” } } 在匹配时可以使用的合法书写格式名称都定义在枚举类型Character.UnicodeScript中。 6.3.3 压缩文件处理 Java标准库中的java.util.zip包用于对压缩文件进行处理。Java 7对这一部分功能的更新也比较多。在对文件进行压缩时,允许选择压缩 时缓存的中间结果的输出方式,这体现在java.util.zip.Deflater类的deflate方法增加了一个参数来表示不同的输出方式。默认的方式是 Deflater.NO_FLUSH,该方式由压缩者来确定输出缓存的中间结果的具体时机。在这种方式下,压缩者可以自由决定缓存中数据的大小,因此通 常可以获得最佳的性能。第二种输出方式是Deflater.SYNC_FLUSH,这种方式在每次调用deflate方法时自动清空内部缓冲区,把压缩的中间结 果输出。这种方式的好处在于,如果有解压缩程序正在等待压缩之后的输出结果,及时地清空缓冲区可以让解压缩程序更早地开始工作,有利 于提高解压缩程序的工作效率。不过这种方式会对压缩性能产生影响。最后一种输出方式是Deflater.FULL_FLUSH,这种方式除了清空缓冲区之 外,同时还重置压缩者的内部状态。如果解压缩的程序发现压缩的结果不正确,可以使用此方式来调用deflate方法,要求压缩者重新进行压缩 操作。这种方式对性能的影响最大,只在必要时才使用。 与Deflater类对应的java.util.zip.DeflaterOutputStream类的对象在创建时增加了一个参数syncFlush,用来表示对Deflater类的对象所 缓存的中间结果的处理方式。如果syncFlush的值为true,那么在调用DeflaterOutputStream类的对象的flush方法来清空其本身的内部缓冲区 之前,会先按照SYNC_FLUSH的方式清空对应的Deflater类的对象所缓存的中间结果。 在Java 7之前,压缩文件中的文件名和注释都使用默认编码格式UTF-8。这种UTF-8格式可能造成通过Java压缩的文件无法被其他工具打 开。为了解决这个问题,Java 7允许在创建压缩文件时显式地指定文件名和注释所用的字符集。这体现在java.util.zip.ZipFile、 java.util.zip.ZipInputStream和java.util.zip.ZipOutputStream类的构造方法中都增加了一个java.nio.charset.Charset类的对象作为参 数。这个Charset类的对象就表明了压缩文件中文件名和注释所用的编码字符集。通过Java产生的压缩文件中也包含了相关的元数据,用来表示 压缩时的文件名和注释所使用的字符集。 除了上面这些比较大的改动之外,还有一些相关的小改动,包括:Java 7支持大于4 GB的压缩文件的处理;ZipFile类实现了 java.io.Closeable接口,从而可以在try-with-resources语句中使用;ZipFile类多了一个方法getComment,用来获取在创建压缩文件时通过 ZipOutputStream类的setComment方法所添加的文件注释;如果java.util.zip.GZIPInputStream类在处理压缩文件时遇到了格式不正确的压缩 文件,会抛出更加具体的java.util.zip.ZipException异常。 在集合类方面,java.util.Collections类增加了两个新的方法emptyIterator和emtpy-Enumeration,用来返回空的迭代器对象和列举对 象。 6.4 JavaBeans组件 JavaBeans是Java平台上的组件模型。要在Java平台上创建可复用的组件,应该遵循JavaBeans的规范。对于JavaBeans组件,开发人员比较 熟悉的是Java EE中的EJB(Enterprise JavaBeans),以及Java类中遵循JavaBeans命名规范的属性获取和设置的方法。JavaBeans的强大之处 在于以规范的组件模型作为基础,可以通过工具很方便地进行单个组件的自定义和多个组件的组装等操作。对于所有遵循JavaBeans规范的组 件,都可以通过工具以统一的方式来进行操作。 符合JavaBeans规范的每个组件都包含3类信息,分别是属性、方法和事件。属性指的是一个组件暴露出来的外观或行为上的特征。可以通 过改变属性的值来定制组件的外观或行为。JavaBeans组件的方法与一般的Java方法并没有区别,可以在其他组件中调用。事件是组件之间进行 交互的方式。某个组件可以发布事件,而另外的组件可以在这个事件上注册监听器。 6.4.1 获取组件信息 一个JavaBeans组件可以通过java.beans.Introspector类来获取组件中的属性、方法和事件的信息,使用的是Introspector类中的静态方 法getBeanInfo。该方法的返回值是包含了组件相关信息的java.beans.BeanInfo接口的实现对象。获取组件信息的方式可能有两种,一种是开 发人员自己提供的BeanInfo接口的实现类,另外一种是由系统通过反射API来自动发现组件中的信息。对于第一种方式,系统会根据固定的名称 模式来查找组件对应的BeanInfo接口的实现类。比如,对于类名为“com.java7book.My”的组件,查找类名 为“com.java7book.MyBeanInfo”的BeanInfo接口的实现类作为组件的信息来源。如果没有找到相关实现类,会使用第二种方式,即通过反射 API来发现相关信息。这两种方式的一个重要区别在于,在找到BeanInfo接口的实现类之后,不会再继续查找该组件的父类来获取信息;而通过 反射API的方式则会沿着继承层次结构树一直向上查找父类中的相关信息。 对于具体的组件信息的获取过程,getBeanInfo方法也提供了不同的重载方式供开发人员进行配置。可以配置的内容主要有两个,一个是在 获取过程中包含哪些类中的信息。前面提到过,组件的父类中的信息有可能被包含进来,如果不希望包含组件的某些父类中的信息,那么可以 指明终止组件信息获取过程的类名。当沿着继承层次结构树向上获取时,如果遇到指定的终止类,就停止继续获取。另外一个可配置的内容是 对找到的BeanInfo接口实现类的处理方式。在getBeanInfo方法中允许设置3种不同的处理方式,对应的参数值分别是使用所有BeanInfo接口实 现类的USE_ALL_BEANINFO、忽略组件类对应的BeanInfo接口实现类的IGNORE_IMMEDIATE_BEANINFO和忽略包括组件类的父类在内的所有BeanInfo 接口实现类的IGNORE_ALL_BEANINFO。 通过这两种配置方式,可以很好地控制组件信息的获取过程。不过在Java 7之前,这两种配置方式不能同时使用,只能使用其中一种。 Java 7添加了额外的getBeanInfo的重载方式。代码清单6-18给出了getBeanInfo方法的使用示例。MyBean是要获取信息的组件类,ParentBean 是MyBean的父类。getBeanInfo方法调用表明获取信息时不考虑ParentBean类的信息,同时忽略所有的BeanInfo接口的实现类。 代码清单6-18 getBeanInfo方法的使用示例 public void introspect()throws IntrospectionException{ BeanInfo beanInfo=Introspector.getBeanInfo(MyBean.class, ParentBean.class, Introspector.IGNORE_ALL_BEANINFO); outputBeanInfo(beanInfo); } 6.4.2 执行语句和表达式 JavaBeans组件也提供了动态执行语句和表达式的能力,主要是为了方便工具的使用者以类似脚本语言的方式来对组件进行操作,比如调用 一个组件对象myBean的open方法的语句可以直接写成“myBean.open()”。语句和表达式的区别在于,语句不关心具体的执行结果,而表达式 则会把执行结果记录下来。语句和表达式分别用java.beans.Statement和java.beans.Expression类来表示。Expression类继承自Statement 类,并添加了获取和设置执行结果的方法。Statement类的execute方法用来执行语句。在Java 7中,Expression类增加了缓存执行结果的功 能。当通过Expression类的getValue来获取执行结果时,如果execute方法之前没有被调用过,则会先调用execute方法,再返回结果,同时也 会把执行结果记录下来。代码清单6-19给出了Expression类的使用示例。在创建Expression类的对象时,需要提供目标对象、方法名称和方法 的调用参数。如果再次调用getValue方法,只会得到缓存中的执行结果,execute方法不会被再次调用。 代码清单6-19 Expression类的使用示例 public void executeExpression()throws Exception{ Expression expr=new Expression(new MyObject(),"greet",new Object[]{"alex"}); expr.execute(); Object result=expr.getValue(); } 6.4.3 持久化 JavaBeans组件在其属性发生变化之后,可以被持久化,以保存组件的内部状态。之后在需要时可以把保存的内容再次读取出来,并恢复组 件的内部状态。JavaBeans组件的持久化依赖的是Java标准的对象序列化机制。一般来说,可以把组件的内部状态以流的二进制形式保存,或者 保存成XML文件。以流的形式进行持久化时使用的是Java中的java.io.ObjectInputStream和java.io.ObjectOutputStream类,而在以XML文件作 为持久化形式时,使用的是java.beans.XMLEncoder和java.beans.XMLDecoder类。 Java 7对将JavaBeans组件保存成XML文档的功能做了更新,在XMLEncoder类中增加了构造方法,可以更加精细地控制保存时的行为。Java 7之前的XMLEncoder构造方法只接受一个java.io.OutputStream类的对象作为参数,表示保存内容的输出流。而Java 7新增的构造方法中添加了 额外的3个参数,分别是输出时使用的字符集、是否输出XML处理指令声明和整个XML文档的缩进空格数。允许指定输出时使用的字符集主要是为 了满足不同的编码格式需求,而另外两个参数是为了使输出的XML文档内容可以被嵌入到其他XML文档中。代码清单6-20给出了XMLEncoder类的 新构造方法的使用示例。如果XMLEncoder类的对象的输出要作为其他XML文档的一部分,第3个参数的值应该为false,另外第4个参数应该是正 确的缩进空格数,以保持整个XML文档的良好缩进格式。 代码清单6-20 XMLEncoder类的新的构造方法的使用示例 public void xmlEncode()throws IOException{ OutputStream output=Files.newOutputStream(Paths.get("result.xml"),StandardOpenOption.CREATE_NEW); try(XMLEncoder encoder=new XMLEncoder(output, StandardCharsets.UTF_8.name(),true,0)){ encoder.writeObject(new MyBean()); } } 用于读取XMLEncoder类的输出文档的XMLDecoder类也有一些更新,主要是提供了更好的对SAX解析方式的支持。XMLDecoder类新增了一个接 受org.xml.sax.InputSource类型参数的构造方法。在Java 7之前,创建XMLDecoder类的对象时,只能使用InputStream类的对象来表示解析时 的输入数据。而InputSource类则提供了更加丰富的方式来表示输入数据,除了InputStream类的对象之外,还支持使用标识符和 java.io.Reader类的对象。 6.5 小结 本章主要围绕Java 7对标准库API所做的更新来展开,涉及数据库操作、java.lang包、java.util包和JavaBeans组件等方面。在这些更新 中,数据库操作相关的对象都可以用在try-with-resources语句中,有利于提高代码的简洁性,新增的setNetworkTimeout和abort方法可以解 决由于网络连接问题造成的查询操作等待时间过长的问题;而在java.lang包中,ProcessBuilder类新增的对进程输入和输出进行重定向的能 力,方便开发人员处理进程的输入和输出;在java.util包中,Objects是很实用的工具类,对正则表达式在命名捕获分组和Unicode支持上的更 新,方便了开发人员的使用;在JavaBeans组件方面则增强了组件信息的获取过程、Expression类的使用方式和XMLEncoder类在输出用于内嵌的 XML文档时的能力。 Java 7还有其他一些小的更新,限于篇幅,不可能逐一进行介绍。总的来说,Java 7除了添加新的功能方便开发人员之外,还更新了大量 文档以澄清之前版本中容易产生误解的地方,另外还对之前比较模糊的行为添加了明确的定义,前面提到的Thread类的更新就是很好的例子。 第7章 Java虚拟机 Java虚拟机是Java语言能够取得成功的重要因素之一。Java语言在推出之时的一个口号是“编写一次,到处运行(write once, run anywhere)”。这个特性使Java语言相对同时代的其他编程语言更具吸引力,而正是Java虚拟机使这个特性成为可能。Java源代码经过编译器 编译之后,得到以类文件(.class文件)形式出现的字节代码。只要字节代码的版本与当前Java虚拟机的版本是兼容的,这些字节代码可以在 任何平台上的Java虚拟机上执行。Java虚拟机的作用是为Java程序屏蔽底层操作系统的细节,提供一个统一的运行平台。第2章已经对Java虚拟 机进行了简单的介绍,本章将进行更加深入的讨论。 对于Java虚拟机本身,不同的开发人员所关心的角度可能并不相同。有的开发人员从事的是Java虚拟机本身的开发或虚拟机级别的性能优 化工作,从这个角度出发要求对Java虚拟机的实现有比较深入的了解,不过这类开发人员的数量相对较少;较大一部分开发人员对Java虚拟机 的了解仅限于知道它的存在而已,在绝大多数时候,这些开发人员都在Java语言这个层次上工作。本书要介绍的主要内容并不是Java虚拟机, 而是Java语言,因此并不会涉及Java虚拟机本身的实现相关的底层细节等内容。之所以仍然安排一章来介绍Java虚拟机,是为了帮助开发人员 更好地使用Java虚拟机。Java语言也提供了一些与Java虚拟机进行交互的能力。这些能力也是本章中的内容之一。本章的内容可以看成是Java 开发人员所应该了解的Java虚拟机的相关内容的一个汇总。 7.1 虚拟机基本概念 “编写一次,到处运行”是Java语言吸引开发人员的重要原因之一。用Java语言编写的程序,可以在任何平台上运行,只需要在操作系统 之上安装Java运行环境即可。某些编程语言的开发平台中并没有虚拟机的概念,而是通过直接从源代码生成目标操作系统上的二进制文件来运 行。不同操作系统上的二进制文件是无法兼容的。以C/C++语言为例,在Windows平台上编译C/C++源代码所生成的可执行文件,无法在Linux平 台上运行。当需要使用程序的时候,用户要么直接下载其操作系统对应的可执行文件,要么根据源代码自行编译和链接来得到可执行文件。对 程序的开发人员来说,如果程序需要支持不同的操作系统平台,则需要做很多工作来确保程序在不同的操作系统平台上都可以正常工作,其中 包括对程序进行修改以适应不同的操作系统平台。虽然有一些类库可以帮助解决这个问题,但是开发人员的任务量仍然比较大。 Java平台早在开发时就引入了虚拟机的概念。Java程序不是由操作系统以可执行文件的形式直接运行的,而是运行在Java虚拟机中的。在 运行Java程序的时候,需要指定一个主Java类。Java虚拟机在启动之后,会从主Java类的main方法开始执行。当main方法执行结束之后,Java 虚拟机会自动终止。每个Java虚拟机在运行时是底层操作系统上的一个独立的进程。比如,当在Windows操作系统上运行Java程序的时候,可以 从任务管理器的进程列表中看到名为“java.exe”或“javaw.exe”的进程,这些就是Java虚拟机的进程。 虚拟机的作用主要有两个:一个是为应用程序屏蔽底层操作系统的细节,另外一个则是为应用程序提供必要的运行时的支持能力。不同的 操作系统在实现上存在很多差异。跨平台的应用程序需要自己来考虑这些不同,并在代码中进行处理。这通常意味着更长的开发时间和更高的 维护成本,也意味着需要更多熟悉不同平台的开发人员。很多程序都采用多线程的方式来提高性能。在创建线程时,Windows平台上的API与 Linux上的相关API就存在很大不同,在程序中需要通过不同的分支代码来进行处理。虚拟机的作用就在于处理这些细节,为程序提供统一的接 口。同样的线程操作,在Java语言中可以通过抽象的java.lang.Thread类来完成。而Thread类在不同平台上实现的不同,则由虚拟机来负责处 理。除此之外,虚拟机为在其上运行的应用程序提供了必要的支持。以Java虚拟机为例,这些支持包括基本类型和操作符、对象模型、Unicode 支持、动态链接、垃圾回收器、内存模型和访问控制等。虚拟机所提供的这些功能,是Java程序运行时的基础。在Java语言中可以与这些功能 进行交互,比如,第4章中介绍的Java语言中的Unicode相关的内容。其他的相关内容也会在之后的章节中进行介绍。简而言之,虚拟机的作用 相当于一个简化后的操作系统,它所提供的功能不仅丰富,而且规范、统一。 随着Java语言的流行,在编程语言开发平台中使用虚拟机也成了一种被广泛认可的做法。微软的.NET框架也采用了类似的架构。.NET架构 中的虚拟机被称为通用语言运行环境(Common Language Runtime, CLR)。.NET平台支持的各种编程语言的代码,如C#和VB.NET,会先被编译 成CLR上的中间语言(Common Intermediate Language, CIL)的形式,类似于Java中的字节代码。CIL再由CLR来运行。这种使用模式与Java的 做法是非常类似的。 7.2 内存管理 运行的程序总要与内存进行交互。内存作为操作系统中的重要资源,对它的分配和释放进行管理是一项非常重要的工作。对于某些编程语 言,内存管理的工作由开发人员来处理,C和C++语言是这类编程语言的典型代表。以C++为例,当程序通过new操作符创建新的对象之后,就会 分配相应的内存资源。当程序不再需要这些对象的时候,需要在代码中将其显式释放,一般通过delete操作符来完成。内存分配和释放操作的 正确性,由开发人员来保证,这在很大程度上取决于开发人员的经验和技能。实际上,在使用这些编程语言开发的程序中,与内存分配和释放 相关的问题是比较多的。 第一个常见的问题是产生悬挂引用(dangling reference)。悬挂引用指的是对某个对象的引用实际上指向一个错误的内存地址。比如程 序中某部分代码引用了由另外一部分代码创建的对象,在代码的运行过程中,这个被引用的对象被删除,它所占用的内存也被释放。随后这部 分内存被重新分配给另外的对象使用,而之前的代码可能仍然保存着对原始对象的引用。当代码仍然通过这个旧的引用尝试访问对象的时候, 就会出现错误,因为这个旧的引用所对应的内存地址中的内容已经发生了变化。如果这个新的内存地址被分配给另外的进程,这次访问请求就 可能造成程序退出。 第二个常见的问题是产生内存泄露。造成这个问题的原因是某些对象所占用的内存没有被释放,又没有对象引用指向这些内存。这样就会 导致这部分内存对程序来说既不可用,又无法被释放,因为在缺乏对象引用的情况下,程序无法对这部分内存进行任何操作。 对于需要进行显式内存管理的编程语言来说,开发人员通常需要遵循某些最佳实践或利用相关工具来进行正确的内存管理。比如C++语言中 的最佳实践一般在构造方法中分配内存,在析构方法中释放内存。利用C++中的智能指针也可以进行自动内存释放。不管采用何种方式,都对开 发人员提出了比较高的要求,也会造成程序中存在很多与内存管理相关的潜在问题。 因为显式内存管理比较容易出现错误,所以不少编程语言采用了自动内存管理机制。自动内存管理的含义是运行平台会提供专门的组件来 进行内存的管理工作。这个组件通常被称为垃圾回收器(garbage collector, GC)。垃圾回收器不仅负责内存的回收,还负责内存的分配。这 些编程语言通常也不提供直接的API来进行内存的分配和释放,内存的分配是隐式进行的,在创建新对象时自动分配所需的内存,而对象所占用 的内存不需要在程序中显式释放,由垃圾回收器自动进行处理。Java语言是使用自动内存管理机制的典型代表。在Java中有用来创建对象的new 操作符,但是没有对应的delete操作符。 Java平台上的内存管理由垃圾回收器负责完成。垃圾回收器属于Java虚拟机的一部分。当Java程序在虚拟机中运行时,垃圾回收器也在运 行。垃圾回收器负责管理虚拟机中的内存。它的职责很简单,即负责内存的分配和释放。在程序运行过程中创建新对象时,垃圾回收器要在虚 拟机可用的内存中找到一块合适的内存区域供此对象来使用。当不再需要某一个对象时,垃圾回收器会负责回收该对象占用的内存空间,以供 之后的内存分配所用。虽然从描述上来说,垃圾回收器的功能并不复杂,但是要实现一个实用的垃圾回收器并非易事,最重要的是处理好垃圾 回收器与运行的程序之间的关系。 当Java虚拟机启动并运行某个程序之后,它所能使用的内存总量的上限通常是固定的。在程序刚开始运行的时候,虚拟机中的大部分内存 都处于空闲可用的状态。随着程序的运行,不断有空闲的内存区域被分配给程序运行所需的对象来使用。经过一段时间之后,虚拟机的内存大 概就可以分成三类:第一类是当前仍然处于空闲状态的可用内存;第二类是正在被程序使用的内存;第三类是程序已经不再使用的内存,已经 不再需要其中包含的数据内容。第二类和第三类内存的区别在于其所属的对象是否处于活动状态。一个对象处于活动状态的含义是指在当前运 行的程序中还存在指向该对象的引用。如果没有引用指向一个对象,那么说明该对象无法被运行的程序所使用,它所占用的内存会被当成垃圾 来回收。 随着程序的不断运行,虚拟机的内存中可用的空闲空间越来越少,垃圾越来越多。这时就需要运行垃圾回收器来回收内存中的垃圾区域, 将其转换成可用的区域,以供下次内存分配时使用。垃圾回收器运行一次之后,程序中的部分垃圾区域会被正确回收。Java虚拟机中的垃圾回 收器是运行在一个独立的线程中的,它会根据当前虚拟机中的内存状态,决定在什么时候进行垃圾回收工作。每次垃圾回收时所处理的内存区 域的范围也是不同的。垃圾回收器的具体运行时间和频率无法事先预计,取决于垃圾回收器的实现算法。不同的虚拟机实现中的垃圾回收算法 也有所不同。 由于垃圾回收线程和当前应用程序同时在Java虚拟机中运行,因此当前运行程序会受到垃圾回收器的影响。不同的垃圾回收器实现算法对 程序的影响也不相同。应该根据程序的特性来选择与之相匹配的垃圾回收器实现算法。 在垃圾回收器的实现方式中,通常有很多因素需要考虑和权衡,其中与当前运行程序相关的是垃圾回收器的运行方式。一般来说有并发运 行和暂停执行两种。并发运行的含义是指垃圾回收器与程序同时运行,垃圾回收器在绝大部分时候不会影响程序的运行。而暂停执行的方式是 垃圾回收器在运行时,程序的运行被暂停。并发运行的方式对程序影响较小,但是对垃圾回收器的实现要求较高,实现起来也更复杂。在回收 的过程中,由于程序仍然在运行,因此内存的状态也在不断发生变化。垃圾回收器需要花费更多的运行时间和内存空间来处理这种情况。暂停 执行的做法实现起来比较简单,因为在回收的过程中,内存的状态是固定不变的。但是暂停会对程序产生比较大的影响,用户也可能会感觉到 程序运行时的停顿。 虽然大多数时候垃圾回收器的运行时间和频率是无法预计的,但是程序仍然可以在特定的时机建议垃圾回收器进行回收工作,通过 System.gc方法就可以建议垃圾回收器立即运行。不过在这种情况下,垃圾回收器也可能选择不运行。 7.3 引用类型 在Java程序中可以通过System.gc方法来建议垃圾回收器立即进行回收工作。除此之外,Java程序本身与垃圾回收器能够进行的直接交互比 较有限。垃圾回收器在进行回收时并不了解运行程序的具体特征。因此,在某些情况下,垃圾回收器可能并不能够按照程序所期望的方式工 作。比如,一个图像处理程序可能同时打开多个图像文件进行编辑,而同一时刻只有一张图片处于编辑状态。当同时打开的图片过多时,程序 所占用的内存空间会变大,垃圾回收器无法回收这些处于活动状态的对象所占用的内存,而使虚拟机中的可用内存不断减少。当垃圾回收器无 法找到可用的空闲内存时,创建新对象的操作会抛出java.lang.OutOfMemoryError错误,导致虚拟机退出。而就这个图像处理程序的特征来 说,当可用内存不足的时候,可以把那些当前不处于编辑状态的图像所占用的内存释放掉,这样的垃圾回收操作对这个程序来说是合理的。同 样,其他的程序也可能有类似的情况。 对于以上情况,Java程序需要通过一种方式把其中的对象在内存需求方面的特征传达给垃圾回收器。垃圾回收器根据对象的特征可以更好 地进行回收。比如,在虚拟机可用内存不足的时候,释放程序中更多的内存。这种传达方式是通过Java中对象的引用类型来实现的。在程序的 运行过程中,对于同一个对象,可能存在多个指向它的引用。如果不再有引用指向一个对象,那么这个对象会成为垃圾回收的候选目标。Java 语言中存在不同的对象引用类型。不同类型的引用对垃圾回收器的含义是不同的。 7.3.1 强引用 在Java程序中,最常见的引用类型是强引用(strong reference),它也是默认的引用类型。当在Java语言中使用new操作符创建一个新的 对象,并将其赋值给一个变量的时候,这个变量就成为指向该对象的一个强引用。在前面提到过,判断一个对象是否存活的标准为是否存在指 向这个对象的引用。垃圾回收器可能采取不同的算法来判断对象的引用是否存在。一个常见的做法是使用引用计数器。当有新的引用指向某个 对象时,把该计数器的值加1;当一个引用失效时,就把该计数器的值减1。例如,显式地把一个引用某个对象的变量的值设为null,该对象的 引用计数器的值会减1。当一个对象的引用计数器的值变为0的时候,说明不存在任何指向该对象的引用,该对象可以被垃圾回收器回收。引用 计数器的原理比较简单,但是实现起来需要编译器的支持,另外使用引用计数器不能解决循环引用孤岛的回收问题。比如,3个对象之间互相存 在引用关系,但是并不存在指向这3个对象的其他引用,这三个对象实际上就成为了内存区域中的一个孤岛。这3个对象的引用计数器的值都不 为0,因此无法通过引用计数器的方式来回收。 由于引用计数器存在无法处理“孤岛”的问题,Java虚拟机的垃圾回收器没有采用这种做法,而是采取跟踪对象引用的做法。这种做法会 从虚拟机内存中的某些存活对象开始,递归检查这些对象所引用的其他对象,直到找到不引用其他对象的对象为止。在这个过程中所发现的所 有对象都会被标记为存活的,而其他对象则是可以被回收的。这个遍历过程的起始对象是一个集合,称之为根集合。根集合中一般包含系统 类、程序寄存器、JNI全局引用、静态变量和线程的当前活动栈中的变量所指向的对象等。可以将这个跟踪过程看成是基于引用关系的树的遍 历。在跟踪过程中发现的存活对象被称为可达的(reachable)。将从遍历的根节点到当前存活对象的路径称为可达路径。这条路径的边对应的 是对象之间的引用。如果一个对象的可达路径中只包含强引用,则把这个对象称为强引用可达的(strongly reachable)。程序中的大多数存 活对象都是强引用可达的。 对于垃圾回收器来说,强引用的存在会阻止一个对象被回收。在垃圾回收器遍历对象引用并进行标记之后,如果一个对象是强引用可达 的,那么这个对象不会作为垃圾回收的候选。因为该对象仍然被程序所使用,回收其内存显然是一个错误的做法。虽然由于垃圾回收器的存 在,Java虚拟机中并不存在真正意义上的内存泄露,但是某些错误的用法会对程序中所能使用的内存空间造成影响。这些情况可以看成是另外 一种意义上的内存泄露,这些内存泄露的发生也都和强引用的使用有关。 通常来说,这种意义上的内存泄露有两种情况:一种是虚拟机中存在程序无法使用的内存区域。这些内存区域被程序中一些无法使用的存 活对象占用。这些对象由于存在隐式的强引用,无法对其进行垃圾回收。但是在程序的正常运行过程中,这些对象也无法被使用。造成这种问 题的原因通常是程序编写时的逻辑错误。另一种情况是程序中存在大量存活时间过长的对象。这些对象的存活时间长于使用它们的对象。在正 常情况下,这些对象在引用它们的对象被回收之后,也应该被回收,但是由于某些程序中的错误而没有被回收。这些对象无法被回收,仍然占 据着虚拟机中的内存资源。时间长了,虚拟机会因为没有足够的内存分配给新的对象而抛出OutOfMemoryError错误。下面会通过示例对这两种 情况进行具体的说明。 对于第一种情况,代码清单7-1中给出了一个简单的先进先出队列的实现。它在内部使用了一个java.util.List接口的实现对象来保存队列 中的对象。向队列中添加的新对象会被直接放在List接口实现对象的末尾。队列的队首位置则由内部变量topIndex来维护。每次有对象被移出 队列时,topIndex的值会增加1。这个队列实现的问题在于出队列的方法只简单地改变了topIndex的值,并没有把对象从队列中删除。在经过若 干次队列操作之后,topIndex的值会逐渐变大。变量backendList所指向的对象中包含的序号小于topIndex的对象无法被队列的使用者通过正常 的方式来访问。由于backendList所指向的对象仍然包含指向这些对象的强引用,因此这些对象也无法被垃圾回收,这些对象占用的内存就成为 虚拟机中无法使用的区域。 代码清单7-1 产生内存泄露的先进先出队列的实现 public class LeakingQueue<T>{ private List<T>backendList=new ArrayList<T>(); private int topIndex=0; public void enqueue(T value){ backendList.add(value); } public T dequeue(){ T result=backendList.get(topIndex); topIndex++; return result; } } 第二种情况的典型情景发生在使用基于内存实现的缓存的时候。代码清单7-2中给出了一个示例。利用calculate方法进行实际运算所需的 时间可能比较长,因此使用了一个java.util.HashMap类的对象来保存之前计算的结果。在calculate方法被调用时,会先检查缓存中是否已经 存在之前计算出来的结果,这样可以避免重复的计算,进而提高性能。不过这种做法延长了计算结果对象的存活时间。在不使用缓存的情况 下,在calculate方法的调用者获得计算结果对象,并完成对该对象的使用之后,就可以对该对象进行垃圾回收。当使用了缓存之后,计算结果 对象的存活时间就变得至少和用来进行缓存的HashMap类的对象一样长。因为HashMap类的对象有其所包含的所有计算结果对象的引用,所以, 只要HashMap类的对象无法被回收,其中所包含的计算结果对象也无法被回收。在calculate方法被多次调用之后,缓存中包含的对象会越来越 多,导致占用的内存越来越大,而程序中其他部分可用的内存则越来越少。 代码清单7-2 使用缓存造成对象存活时间过长的示例 public class Calculator{ private Map<String, Object>cache=new HashMap<String, Object>(); public Object calculate(String expr){ if(cache.containsKey(expr)){ return cache.get(expr); } Object result=doCalculate(expr); cache.put(expr, result); return result; } private Object doCalculate(String expr){ return new Object(); } } 从前面两个示例中可以看出,强引用所提供的与垃圾回收器的交互功能非常有限。当强引用存在的时候,所指向的对象无法被垃圾回收。 为了增强程序与垃圾回收器的交互能力,JDK 1.2引入了java.lang.ref包,提供了3种新的引用类型,分别是软引用、弱引用和幽灵引用。这些 引用类型除了可以引用对象之外,还可以在不同程度上影响垃圾回收器对被引用对象的处理行为。 7.3.2 引用类型基本概念 所有的引用类型都是java.lang.ref.Reference类的子类。Reference类中包含了所有引用类型公用的方法。对一个Reference类的对象来 说,在创建的时候都需要提供一个它所指向的对象。引用一个对象的通常做法如代码清单7-3所示,变量obj引用了一个新创建的Object类的对 象,这相当于在该对象上添加了一个强引用。 代码清单7-3 引用对象的通常做法 Object obj=new Object(); 如果使用引用类型,相关的实现如代码清单7-4所示。通过创建一个Reference子类java.lang.ref.SoftReference类的对象ref,就可以在 变量obj所引用的对象上添加一个软引用。在创建SoftReference类的对象时,要把所指向的对象作为构造方法的参数传递进去。需要注意的 是,在创建了新的软引用之后,要显式地把之前创建的对象上的强引用清除,这也是代码清单7-4中“obj=null;”语句的作用。在清除了强引 用之后,指向新创建的Object类的对象的引用就只有SoftReference类的对象ref。如果在程序中没有清除之前的强引用,添加其他引用类型实 际上是毫无作用的。因为只要强引用存在,该对象就无法被垃圾回收器处理。 代码清单7-4 引用类型使用示例 Object obj=new Object(); SoftReference<Object>ref=new SoftReference<Object>(obj); obj=null; 使用了引用类型之后,对象之间的引用关系也发生了变化。在代码清单7-3中,通过obj持有对Object类的对象的强引用;而在代码清单7-4 中,程序持有的是对SoftReference类的对象ref的强引用,ref再通过软引用来指向Object类的对象。这种引用关系的改变,使对Object类的对 象进行垃圾回收变为可能。 在创建引用对象时的一种错误做法如代码清单7-5所示。这种做法的问题在于没有使用强引用先指向待引用的对象。垃圾回收器可能随时进 行内存的回收工作。可能出现的一种情况是:在SoftReference类的对象创建出来之后,垃圾回收器正好回收了SoftReference类的对象所指向 的对象,这会使该引用对象实际上毫无作用。 代码清单7-5 使用引用类型的错误用法 SoftReference<Object>ref=new SoftReference<Object>(new Object()); Reference类的get方法用来获取所引用的实际对象。如果引用关系已经被清除,该方法的返回值为null。因此在使用get方法时,需要检查 返回值是否为null,并分别使用不同的代码逻辑。Reference类的clear方法用来清除引用关系。当在某个时间点上不再需要继续引用Reference 类的对象所指向的对象时,可以使用clear方法来清除这个引用。当clear方法被调用之后,无法通过get方法获取所引用的对象,一般由垃圾回 收器或程序本身在合适的时机调用clear方法来清除引用。Reference类中的另外两个方法enqueue和isEnqueued都与引用队列相关,分别用来把 当前引用对象放入对应的引用队列中,以及判断当前引用对象是否已经被放入队列中。 在使用get方法的时候,需要确保有强引用指向get方法的返回值,否则可能会出现错误。代码清单7-6给出了错误的用法,虽然先检查了 get方法的返回值是否为null,但是obj变量所指向的对象仍有可能为null。这是因为垃圾回收器可能在if判断语句之后运行,回收了引用对象 所指向的对象,使下一次的get方法调用返回null。 代码清单7-6 使用get方法的错误示例 if(ref.get()!=null){ Object obj=ref.get(); } 引用队列是一个包含了引用类型对象的队列,按照先进先出的方式来操作。引用队列由类java.lang.ref.ReferenceQueue来表示。 ReferenceQueue类中提供了从队列中获取引用对象的方法,但是并没有向队列中添加对象的方法。添加引用对象到队列中的操作由垃圾回收器 自动完成或通过Reference类的enqueue方法来完成。从引用队列中获取对象有两种方式,分别是阻塞式和非阻塞式的。阻塞式的方式是通过 remove方法来实现的,该方法会阻塞当前线程直到从队列中获取到一个引用对象为止;非阻塞式的方式则通过poll方法来实现。如果当前队列 中有引用对象,poll方法会返回队列中的第一个对象,否则直接返回null。在创建Reference类对象时,可以提供一个额外的引用队列的对象作 为参数,这样可以把引用对象和该引用队列关联起来。垃圾回收器会在合适的时间点上把引用对象放入到对应的队列中。代码清单7-7给出了引 用队列的使用示例。 代码清单7-7 引用队列的使用示例 Object obj=new Object(); ReferenceQueue queue=new ReferenceQueue(); SoftReference<Object>ref=new SoftReference<Object>(obj, queue); obj=null; 7.3.3 软引用 软引用(soft reference)在强度上弱于强引用,用java.lang.ref.SoftReference类来表示。如果一个对象不是强引用可达的,同时可以 通过软引用来访问,那么将这个对象称为软引用可达(softly reachable)。软引用所要传递给垃圾回收器的信息是:软引用所指向的对象是 可以在需要的时候被回收的。垃圾回收器会保证在抛出OutOfMemoryError错误之前,回收掉所有软引用可达的对象。通过软引用,垃圾回收器 就可以在内存不足时释放软引用可达的对象所占的内存空间。程序所要做的是保证软引用可达的对象被垃圾回收器回收之后,程序也能正常工 作。在之前介绍的图像编辑器打开文件的示例中,可以用强引用指向当前正在编辑的图片,而用软引用指向不处于编辑状态的其他已经打开的 图片。这样当图像编辑器程序的内存不足时,垃圾回收器可以释放不处于编辑状态的图片所占的内存空间。当程序中需要引用占用内存比较大 的对象时,可以考虑使用软引用来指向该对象。 下面通过一个示例来具体说明软引用的用法。代码清单7-8中的FileEditor类用来对多个文件进行编辑。出于性能方面的考虑,FileEditor 类的对象会在内部缓存之前已经打开过的文件的数据内容,以方便用户在同时打开的多个文件之间进行快速切换。同时打开的文件过多会占用 比较多的内存资源。因此,表示文件数据的FileData类使用软引用来指向包含文件数据的byte[]对象。当虚拟机的内存不足时,这些byte[]对 象可以被垃圾回收器释放。这里需要注意FileData类的getData方法的实现中对软引用的使用方式。通过get方法获取软引用所指向的对象之 后,需要判断这个对象是否还存活。如果get方法的返回值为null,那么说明对该对象的引用已经被清空,应该重新创建出相关的对象。 代码清单7-8 使用软引用的文件编辑器 public class FileEditor{ private static class FileData{ private Path filePath; private SoftReference<byte[]>dataRef; public FileData(Path filePath){ this.filePath=filePath; this.dataRef=new SoftReference<byte[]>(new byte[0]); } public Path getPath(){ return filePath; } public byte[]getData()throws IOException{ byte[]dataArray=dataRef.get(); if(dataArray==null||dataArray.length==0){ dataArray=readFile(); dataRef=new SoftReference<byte[]>(dataArray); dataArray=null; } return dataRef.get(); } private byte[]readFile()throws IOException{ return Files.readAllBytes(filePath); } } private FileData currentFileData; Private Map<Path, FileData>openedFiles=new HashMap<>(); public void switchTo(String filePath){ Path path=Paths.get(filePath).toAbsolutePath(); if(openedFiles.containsKey(path)){ currentFileData=openedFiles.get(path); }else{ currentFileData=new FileData(path); openedFiles.put(path, currentFileData); } } public void useFile()throws IOException{ if(currentFileData!=null){ System.out.println(String.format("当前文件%1$s的大小 为%2$d",currentFileData.getPath(),currentFileData.getData().length)); } } } 为了测试代码清单7-8中软引用在实际运行中的效果,使用FileEditor类的对象依次打开某个目录下包含的大小各异的多个文件,同时通过 虚拟机的启动参数“-Xmx”把虚拟机所用的堆内存的最大值设置为一个相对较小的值。在运行时会发现,虽然虚拟机可用堆内存的最大值远小 于所处理的所有文件的大小的总和,但是程序在运行中也不会抛出OutOfMemoryError错误。这是因为当虚拟机中内存不足时,软引用指向的 byte[]对象会被释放,从而可以腾出内存空间供之后的文件操作使用。如果使用强引用指向byte[]对象,在打开目录下的部分文件之后,就会 出现OutOfMemoryError错误,这是因为虚拟机的堆内存不足以容纳全部文件的内容,而垃圾回收器又无法释放强引用指向的byte[]对象。 7.3.4 弱引用 弱引用(weak reference)在强度上弱于软引用,用java.lang.ref.WeakReference类来表示。弱引用传递给垃圾回收器的信息是:在判断 一个对象是否存活时,可以不考虑弱引用的存在。比如,指向一个对象的既有一个强引用,又有多个弱引用,当该强引用被移除之后,这个对 象就可以被垃圾回收器回收,可以忽略指向该对象的弱引用。弱引用的存在不会影响垃圾回收器回收一个对象,还提供了一种方式可以引用到 这个对象。如果一个对象既不是强引用可达,也不是软引用可达,同时可以通过弱引用来访问,则将该对象称为弱引用可达(weakly reachable)。 弱引用的重要作用是解决对象的存活时间过长的问题。在程序中,一个对象的实际存活时间应该与它的逻辑存活时间一样。从逻辑上来 说,一个对象应该在某个方法调用完成之后就不再需要,可以对其进行垃圾回收。但是,如果仍然有其他的强引用存在,该对象的实际存活时 间会长于逻辑存活时间,直到其他的强引用不再存在。这样的对象在程序中过多出现会导致虚拟机的内存占用率上升,最后产生 OutOfMemoryError错误。要解决这样的问题,需要小心注意管理对象上的强引用。当不再需要引用一个对象时,显式地清除这些强引用。不过 这会对开发人员提出更高的要求,代码编写起来也更加复杂。更好的办法是使用弱引用来代替强引用来引用这些对象。这样既可以引用对象, 又可以避免强引用带来的问题。 比较典型的例子是在哈希表中使用弱引用。在代码清单7-9中,通过BookKeeper类来对图书及其借阅者进行管理。在内部实现中使用了一个 HashMap类的对象来保存图书及其借阅者之间的对应关系。由于HashMap类的对象具有对所包含的键和值的对象的强引用,这会使Book类和User 类对象的存活时间变得至少和HashMap类的对象本身一样长。当HashMap类的对象本身还存活时,其中所包含的Book类和User类的对象都无法被 垃圾回收器回收。 代码清单7-9 HashMap类造成对象存活时间过长的示例 public class BookKeeper{ private Map<Book, Set<User>>books=new HashMap<>(); public void borrowBook(Book book, User user){ Set<User>users=null; if(books.containsKey(book)){ users=books.get(book); } else{ users=new HashSet<User>(); books.put(book, users); } users.add(user); } public void returnBook(Book book, User user){ if(books.containsKey(book)){ Set<User>users=books.get(book); users.remove(user); } } } 解决这个问题的做法是使用弱引用来指向这些对象,而不是使用默认的强引用。因为这样使用Map接口的情况很多,Java标准库提供了 java.util.WeakHashMap类来满足这种常见的需求。WeakHashMap类使用弱引用来指向其中所包含的键,而键对应的值对象仍然由强引用来指 向。使用弱引用的好处是WeakHashMap类的对象本身对其中包含的键的弱引用不会影响键对象的垃圾回收。当键对象不存在其他类型更强的引用 时,键对象会被从WeakHashMap类的对象中删除。对于代码清单7-9中存在的问题,只需要把books对应的Map接口的实现类换成WeakHashMap类即 可。不过WeakHashMap类的对象中包含的值对象仍然由强引用来指向,因此不能在值对象中包含键对象的引用。这种循环引用会导致键对象无法 被垃圾回收器回收。如果觉得对于值对象使用强引用不合适,可以在添加到WeakHashMap类的对象之前用一个WeakReference类的对象来包装 它。 7.3.5 幽灵引用 幽灵引用(phantom reference)是强度最弱的一种引用类型,用java.lang.ref.PhantomReference类来表示。幽灵引用的主要目的是在一 个对象所占的内存被实际回收之前得到通知,从而可以进行一些相关的清理工作。幽灵引用在使用方式上与之前介绍的两种引用类型有很大不 同:首先幽灵引用在创建时必须提供一个引用队列作为参数;其次幽灵引用对象的get方法总是返回null,因此无法通过幽灵引用来获取被引用 的对象。 幽灵引用在使用的时候只能通过引用队列来操作。幽灵引用的最大优势在于引用对象被添加到队列中的时机。Java语言提供了对象终止 (finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。Object类提供了finalize方法来添加自定义的销毁逻辑。如果 一个类有特殊的销毁逻辑,可以覆写finalize方法。从功能上来说,finalize方法与C++中的析构函数比较相似,但是Java采用的是基于垃圾回 收器的自动内存管理机制,所以finalize方法在本质上不同于C++中的析构函数。当垃圾回收器发现没有引用指向一个对象时,会调用这个对象 的finalize方法。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。由于finalize方法的存在,虚 拟机中的对象一般处于三种可能的状态。第一种是可达状态,当有引用指向该对象时,该对象处于可达状态。根据引用类型的不同,有可能处 于强引用可达、软引用可达或弱引用可达状态。第二种是可复活状态,如果对象的类覆写了finalize方法,则对象有可能处于该状态。虽然垃 圾回收器是在对象没有引用的情况下才调用其finalize方法,但是在finalize方法的实现中可能为当前对象添加新的引用。因此在finalize方 法运行完成之后,垃圾回收器需要重新检查该对象的引用。如果发现新的引用,那么对象会回到可达状态,相当于该对象被复活;否则对象会 变成不可达状态。当对象从可复活状态变成可达状态之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize方法不会被再次调 用,对象会直接变成不可达状态,也就是说,一个对象的finalize方法只会被调用一次。第三种是不可达状态,在这个状态下,垃圾回收器可 以自由地释放对象所占用的内存空间。对象终止机制在第10章进行详细介绍。 软引用和弱引用在其可达状态达到时就可能被添加到对应的引用队列中。也就是说,当一个对象变成软引用可达或弱引用可达的时候,指 向这个对象的引用对象就可能被添加到引用队列中。在添加到队列之前,垃圾回收器会清除掉这个引用对象的引用关系。当软引用和弱引用进 入队列之后,对象的finalize方法可能还没有被调用。在finalize方法执行之后,该对象有可能重新回到可达状态。如果该对象回到了可达状 态,而指向该对象的软引用或弱引用对象的引用关系已经被清除,那么就无法再通过引用对象来查找这个对象。而幽灵引用则不同,只有在对 象的finalize方法被运行之后,幽灵引用才会被添加到队列中。与软引用和弱引用不同的是,幽灵引用在被添加到队列之前,垃圾回收器不会 自动清除其引用关系,需要通过clear方法来显式地清除。当幽灵引用被清除之后,对象就进入了不可达状态,垃圾回收器可以回收其内存。当 幽灵引用被添加到队列之后,由于PhantomReference类的get方法总是返回null,程序也不能对幽灵引用所指向的对象进行任何操作。这就避免 了finalize方法可能会出现的对象复活的问题。幽灵引用是作为一个通知机制而存在的。程序应该在得到通知之后进行与当前对象相关的清理 工作。 代码清单7-10给出了幽灵引用对象的使用示例。在类ReferencedObject中覆写finalize方法来提供自定义的销毁逻辑。这里只是简单地在 控制台输出提示信息。在使用幽灵引用队列时,通过队列的poll方法来进行轮询。如果队列为空,就通过System.gc方法来建议垃圾回收器运 行。运行示例之后会发现finalize方法中输出的消息总是最早出现,这说明当幽灵引用进入队列之后,finalize方法已经被运行过了。如果改 用软引用或弱引用来进行相同试验,会发现多次运行的结果并不一致,这是因为软引用和弱引用进入队列的时机和finalize方法的调用之间并 没有必然的先后关系。 代码清单7-10 幽灵引用对象的使用示例 public class UseReferenceQueue{ private static class ReferencedObject{ protected void finalize()throws Throwable{ System.out.println("finalize方法被调用。"); super.finalize(); } } public void phantomReferenceQueue(){ ReferenceQueue<ReferencedObject>queue=new ReferenceQueue<>(); ReferencedObject obj=new ReferencedObject(); PhantomReference<ReferencedObject>phantomRef=new PhantomReference<Refe rencedObject>(obj, queue); obj=null; Reference<?extends ReferencedObject>ref=null; while((ref=queue.poll())==null){ System.gc(); } phantomRef.clear(); System.out.println(ref==phantomRef);//值为true System.out.println("幽灵引用被清除。"); } } 如果希望在一个对象的内存被回收之前进行某些清理工作,那么相对于使用finalize方法来说,使用幽灵引用是更好的选择。幽灵引用避 免了finalize方法可能造成对象复活的问题,减少了开发时可能出现的错误。不过幽灵引用的使用比finalize方法要复杂得多。最主要的问题 是从引用队列中获取幽灵引用之后,无法获取其指向的对象,也就无法对这个对象进行操作。幽灵引用本身只作为一个通知机制存在,必须存 在其他指向此对象的引用。因此,相对于使用幽灵引用,开发人员更倾向于谨慎使用finalize方法。只要finalize方法的实现避免了对象复活 的问题,就不失为一个不错的选择。 一个比较实际的幽灵引用的应用是在虚拟机内存总量受限的情况下。当内存总量受限时,可能需要等待一个占用内存空间较大的对象被回 收之后再申请新的内存空间。通过这种方式,可以把程序中某部分占用的内存控制在一定的范围之内。代码清单7-11给出了一个示例。类 PhantomAllocator用来分配一个字节数组供调用者使用。当每次分配新的字节数组时,会先确保之前分配的内存空间被成功释放。在每次分配 新的字节数组之前,引用队列的remove方法会处于阻塞状态,直到有新的引用对象被添加到队列中。remove方法返回之后,之前的字节数组的 内存已经可以被释放,通过调用System.gc方法要求垃圾回收器马上回收这些内存。等内存回收之后再创建新的字节数组,创建一个幽灵引用指 向新的字节数组,并与引用队列关联起来。 代码清单7-11 使用幽灵引用的内存分配方式 public class PhantomAllocator{ private byte[]data=null; private ReferenceQueue<byte[]>queue=new ReferenceQueue<byte[]>(); private Reference<?extends byte[]>ref=null; public byte[]get(int size){ if(ref==null){ data=new byte[size]; ref=new PhantomReference<byte[]>(data, queue); } else{ data=null; System.gc(); try{ ref=queue.remove(); ref.clear(); ref=null; System.gc(); data=new byte[size]; ref=new PhantomReference<byte[]>(data, queue); }catch(InterruptedException e){ e.printStackTrace(); } } return data; } } 在上面的代码中,通过“data=null”来清除PhantomAllocator类对象本身对字节数组的强引用。在进行测试的时候,可以进行多次字节数 组分配操作,同时使用工具来观察程序所占用的堆内存的情况。实际的运行结果是,程序的堆内存的占用量的峰值会维持在一个相对稳定的 值。 7.3.6 引用队列 在前面的小节中对引用队列做了一些简要的说明。引用队列的主要作用是作为一个通知机制。当对象的可达状态发生变化时,如果程序希 望得到通知,可以使用引用队列。当从引用队列中获取了引用对象之后,不可能再获取所指向的具体对象。对于软引用和弱引用来说,在被放 入队列之前,它们的引用关系就已经被清除了;而幽灵引用的get方法总是返回null。下面通过分析Java标准库中的WeakHashMap类来说明引用 队列在实际中的应用。 WeakHashMap类的实现中的Entry类表示哈希表中包含的条目。Entry类继承自WeakReference类,这样就可以比较方便地处理从引用队列中 获取的引用对象,因为引用对象本身代表了哈希表中的条目。Entry类中也包含了与条目相关的基本信息,包括键对象、值对象和引用队列的引 用等。Entry类作为一个弱引用,指向的是条目的键对象,而值对象仍然由强引用来指向。在程序的运行过程中,WeakHashMap类的对象中的某 些条目的键对象可能变成了弱引用可达的状态。垃圾回收器会清除这些弱引用并将其放入WeakHashMap类的对象的引用队列中。WeakHashMap类 的对象需要在合适的时机检查这个队列中包含的引用对象。由于引用对象本身就是Entry类的对象,因此可以直接把引用对象从WeakHashMap类 的对象中删除。 删除引用队列中的条目是通过WeakHashMap类中的私有方法expungeStaleEntries来完成的。代码清单7-12给出了核心的方法实现,即通过 poll方法检查队列中的引用对象,并将该引用对象转换成Entry类的对象来处理。在WeakHashMap类中,大部分涉及条目的方法的实现都会直接 或间接地调用expungeStaleEntries方法来处理WeakHashMap类的对象中引用队列所包含的条目。这也是expungeStaleEntries方法使用poll来检 查引用队列的原因。由于对expungeStaleEntries方法的调用会比较频繁,会对WeakHashMap类中正常操作的性能产生影响,因此使用非阻塞式 的poll方法是个更好的选择。 代码清单7-12 在WeakHashMap类中删除可被回收的条目的示例 private void expungeStaleEntries(){ for(Object x;(x=queue.poll())!=null;){ synchronized(queue){ Entry<K, V>e=(Entry<K, V>)x; } } } 鉴于WeakHashMap类的实现机制,当其中包含的条目的键对象变成弱引用可达之后,在下一次对WeakHashMap类的对象进行操作时,这些键 对象对应的条目才会被删除,所以必须注意这种情况,如果对WeakHashMap类的对象的操作比较少,那么使用WeakHashMap类的对象也会出现某 些键对象的存活时间过长的情况。 7.4 Java本地接口 Java虚拟机为Java开发人员屏蔽了底层的实现细节,使得开发人员不用考虑底层操作系统的差异性。不过在某些应用程序中,免不了要直 接与底层操作系统上的原生代码进行交互。Java本地接口(Java Native Interface, JNI)的作用是提供一个标准的方式让Java程序通过虚拟 机与原生代码进行交互。与原生代码进行交互的动机主要有下面几个。 第一个动机是从性能的角度出发。Java语言从其运行速度上来说,在大多数方面是慢于底层操作系统上原生的C和C++等语言的。这主要是 由于Java虚拟机这个中间层次的存在。如果完全用Java语言实现的性能无法达到程序的预期要求,可以选择把部分重要且耗时的代码用C或 C++来实现。 第二个动机是满足某些特殊的需求。Java平台提供的标准类库的功能很强大,包括了在开发中可能遇到的大部分功能。但是仍然有一些功 能无法用标准API来实现,主要是一些与底层硬件平台直接交互的功能。Java虚拟机没有把这一部分功能暴露给运行在其上的程序。如果需要这 方面的功能,那么只能使用原生代码来进行开发。 最后一个动机是与已有的使用原生代码编写的程序之间进行集成。如果Java程序需要与底层操作系统上由C和C++语言开发的程序进行交 互,那么可以使用JNI。 7.4.1 JNI基本用法 JNI所包含的内容比较复杂,下面通过几个具体的示例来介绍JNI的使用。JNI的一个重要使用场景是提高程序的性能相关。当对程序中关键 部分的性能要求比较高的时候,可以使用C和C++代码来实现。使用原生代码实现的方法被称为原生方法,由native关键词来声明。如代码清单 7-13所示,sqrt方法由原生代码来实现。在NativeMath类的静态初始化块中通过System.loadLibrary方法加载名为NativeMath的原生代码库。 这个原生代码库中包含了sqrt方法的实现。原生方法与Java接口中的方法或抽象类中的抽象方法一样,只包含方法声明,没有相关的实现。程 序中的其他部分可以用正常的方法调用原生方法,比如参数传递和返回值使用等都与正常的方法相同。当虚拟机在执行原生方法时,会尝试在 已经加载的原生代码库中查找原生方法的对应实现。在查找到对应的实现方法之后,虚拟机会负责进行参数传递、实际方法调用和返回值传递 等工作。 代码清单7-13 包含原生方法的Java类 public class NativeMath{ static{ System.loadLibrary("NativeMath"); } public native double sqrt(double value); } 在编写了Java源代码之后,下一步要编写实现原生方法的C/C++代码。Java提供的命令行工具javah根据Java源代码生成C/C++代码所需的头 文件。对于原生方法,头文件中会包含相关的方法声明与其对应。代码清单7-14给出了生成的头文件的内容。在这个头文件中,先引用JDK中 include目录下的jni.h文件。这个由JDK提供的头文件中包含了实现原生方法的C/C++代码中所需的类型和方法声明。原生方法sqrt对应的 C/C++方法是Java_com_java7book_chapter7_jni_NativeMath_sqrt。这个方法的名称是在“Java_”的前缀后加上类名和方法名而得到的。在方 法的参数方面,原生方法中声明的参数会被自动映射到用来实现的C/C++方法中。C/C++方法中最前面两个参数是标准的。第一个参数是指向 JNIEnv接口的指针,通过这个指针可以访问包含一系列JNI方法的方法表。在原生方法的实现中需要利用这些JNI方法来访问和操作虚拟机中的 数据结构。第二个参数取决于原生方法的类型。对于类中的实例方法,这个参数表示的是方法调用时的当前对象,相当于this关键词的含义; 对于类中的静态方法来说,这个参数表示的是方法所在的Java类的对象。这两个标准参数之后是原生方法中映射过来的参数。 代码清单7-14 通过javah工具生成的JNI头文件 #include<jni.h> #ifndef_Included_com_java7book_chapter7_jni_NativeMath #define_Included_com_java7book_chapter7_jni_NativeMath #ifdef__cplusplus extern"C"{ #endif /* *Class:com_java7book_chapter7_jni_NativeMath *Method:sqrt *Signature:(D)D */ JNIEXPORT jdouble JNICALL Java_com_java7book_chapter7_jni_NativeMath_sqrt(JNIEnv*,jobject, jdouble); #ifdef__cplusplus } #endif #endif Java与C/C++的语法不同,在参数的类型上需要进行一定的映射。对于Java中的基本类型,如int和float等,在原生方法的实现中,是直接 映射到对应的类型上的。例如,Java中double类型的对应类型是jdouble。而Java中的引用类型则映射到一个C/C++中的指针上。这个指针所指 向的内容是虚拟机内部的结构,其具体的内容对使用者来说是不透明的。原生方法的实现代码利用JNI提供的方法来对这个指针所指向的结构进 行操作。 代码清单7-13中的Java源程序只用到了基本类型double,其对应的C/C++方法的实现比较简单,只需要调用C/C++中的sqrt方法即可。代码 清单7-15给出了相应的C++实现。 代码清单7-15 NativeMath类的sqrt方法的C++实现 #include"com_java7book_chapter7_jni_NativeMath.h" #include<math.h> JNIEXPORT jdouble JNICALL Java_com_java7book_chapter7_jni_NativeMath_sqrt(JNIEnv*env, jobject obj, jdouble value){ return sqrt(value); } 在编写了相应的C++程序之后,可以使用C++编译器来进行编译和链接,得到所需的原生代码库文件。在编译时需要把JDK中的include目录 添加到编译器的搜索路径中,否则无法找到对应的类型声明。在Windows平台上,编译和链接的结果是动态链接库DLL文件。这个DLL文件的名称 要与System.loadLibrary方法中使用的名称相同。在运行Java程序时,需要通过启动参数“-Djava.library.path”来指定DLL文件所在的目 录,使System.loadLibrary方法可以找到所需的DLL文件。 在使用JNI时最容易遇到的问题是出现java.lang.UnsatisfiedLinkError错误。造成这个错误的原因通常有两个:第一个原因是找不到包含 原生方法实现的代码库,比如找不到DLL文件。在出现这个错误时,通常只需要使用虚拟机启动参数“-Djava.library.path”显式指定代码库 所在的目录即可。如果不显式指定,那么虚拟机会在某些预设目录中进行搜索。第二个原因是无法在代码库中找到原生方法对应的实现方法。 一般来说,使用javah工具从Java源代码中生成的头文件中包含了对应方法的正确声明。只需要在C/C++源代码中复制该方法的声明即可。如果 发生了无法找到对应方法的情况,那么很可能是C/C++编译器在编译和链接时改变了所生成的方法的名称。C/C++编译器的这个特性被称为名称 装饰(name decoration),其主要目的是解决相同名称的编程实体的解析问题。不同的名称空间中可能包含相同名称的实体,如名称相同的方 法。在进行链接的时候,链接器需要足够的信息来区分这些名称相同的实体。典型的做法是在生成的实体名称上添加一些额外的信息作为装饰 来进行区分。比如,在Windows平台上使用MinGW的GCC编译器对代码清单7-15中的C++代码进行编译和链接之后,所得到的DLL文件中的 Java_com_java7book_chapter7_jni_NativeMath_sqrt方法的实际名称是Java_com_java7book_chapter7_jni_NativeMath_sqrt@8。方法名称的 后缀“@8”表示的是方法的所有参数所占用的字节数。方法名称的不一致造成虚拟机在运行Java程序时找不到原生方法的对应实现,因此出现 UnsatisfiedLinkError错误。对于方法名称后多余的@后缀,只要在使用GCC编译器时加上参数“-Wl,--kill-at”就可以将其去掉。在产生 UnsatisfiedLinkError错误时,可以从错误的详细信息中得到具体的说明。如果是由于第二个原因造成的,可以通过工具来查看代码库中方法 的名称来定位问题的原因。比如在Windows平台上,可以使用DLL Export[1]Viewer来查看DLL中导出的方法的详细信息。 代码清单7-15只说明了Java中的基本类型在JNI中的使用方式。当原生方法的声明中包含了Java标准库的类或自定义的类时,利用C/C++来 实现的方式会有所不同。比如,在NativeMath类中添加一个新的原生方法size,其声明如代码清单7-16所示。方法size的参数的类型是自定义 的表示矩形的Rectangle类,该类中包含了用来获取矩形区域的宽度和高度的getWidth和getHeight方法。 代码清单7-16 NativeMath类中size方法的声明 public native double size(Rectangle rectangle); 再次使用javah工具来更新头文件之后,原生方法size的C++实现如代码清单7-17所示。先使用JNIEnv中的GetObjectClass方法查找到参数 rectangle对象所对应的Java类,再通过GetMethodID方法找到Rectangle类中包含的getWidth和getHeight方法。最后通过CallDoubleMethod方 法来调用这两个方法,可以得到进行计算所需的矩形的宽度和高度的值。 代码清单7-17 NativeMath类中size方法的实现 JNIEXPORT jdouble JNICALL Java_com_java7book_chapter7_jni_NativeMath_size(JNIEnv*env, jobject obj, jobject rectangle){ jclass cls=env->GetObjectClass(rectangle); jmethodID getWidthMid=env->GetMethodID(cls,"getWidth","()D"); double width=env->CallDoubleMethod(rectangle, getWidthMid); jmethodID getHeightMid=env->GetMethodID(cls,"getHeight","()D"); double height=env->CallDoubleMethod(rectangle, getHeightMid); return width*height; } 从代码清单7-17中可以看出,对参数对象的使用方式类似于在Java中使用反射API来操作对象。由于jobject表示的只是一个不透明的结 构,因此所有的操作都需要以反射的方式来进行。在JNIEnv中,GetObjectClass方法的作用相当于Java中Object类中的getClass方 法,GetMethodID方法的作用相当于Java中Class类的getMethod方法,只不过在查找时使用的方式不同。GetMethodID方法要求提供方法类型在 字节代码中的表现形式作为查找的条件,例如,“()D”表示不包含参数,返回值类型为double。CallDoubleMethod方法的作用相当于Java中 Method类的invoke方法。在JNIEnv中,对于Java中返回值类型、方法类型和调用方式不同的方法,都有与之对应的调用方法,比如调用Java中 返回值类型为boolean的静态方法应该使用CallStaticBooleanMethod方法。 [1]DLL Export Viewer工具的网址是:http://www.nirsoft.net/utils/dll_export_viewer.html。 7.4.2 Java程序中集成C/C++代码 使用JNI的另外一个常见的情景是与已有的C/C++程序进行集成。在编写Java程序之前,就已经有了可以使用的原生代码库。这个原生代码 库可能是程序的一部分,也可能是底层操作系统自带的。这些原生代码库的特点是在实现的时候并没有考虑与Java虚拟机的集成,因此也没有 使用与JNI相关的内容。在使用这样的原生代码库时,可能会需要一个中间的原生代码库作为桥梁。这个原生代码库作为Java程序中原生方法的 实现,负责实际调用时的参数类型转换和返回值传递等工作。考虑实现一个Java程序,希望使用底层操作系统上原生的消息提示对话框作为给 用户提示信息的方式。使用第5章介绍的AWT或Swing用户界面库做到这一点并不难。不过下面介绍的是如何使用JNI来实现这个功能。以Windows 平台为例,系统动态链接库user32.dll中包含的MessageBox方法可以提供所需的功能。 要使用user32.dll中的方法,一种做法是使用另外一个原生代码库作为桥梁。这种使用方式类似于上面介绍的第一种使用JNI的方式,即先 对原生方法进行声明,再通过javah工具生成头文件,最后利用C++代码编写相关的实现。包含原生方法的Java类的基本声明如代码清单7-18所 示。 代码清单7-18 消息提示Java类的基本声明 public class MessageBox{ static{ System.loadLibrary("MessageBox"); } public native int show(String text, String caption); } 相关的C++代码实现如代码清单7-19所示,其中也显示了JNI的原生方法实现中字符串的使用方式。Java中原生方法声明中的String类型会 被转换成JNI中的jstring类型。在C++代码中,需要先通过JNIEnv中的GetStringUTFChars方法把jstring类型转换成C++中可以使用的char类 型。转换之后可以直接调用user32.dll中的MessageBox方法。需要格外注意的是,在使用完从jstring类型转换后的char类型的字符串之后,需 要通过ReleaseStringUTFChars方法来释放相关的内存,否则会出现内存泄露。这是因为C++中并没有Java语言中的自动内存管理机制。 代码清单7-19 消息提示的C++实现 JNIEXPORT jint JNICALL Java_com_java7book_chapter7_jni_MessageBox_show(JNIEnv*env, jobject obj, jstring text, jstring caption){ const char*text_str=env->GetStringUTFChars(text, NULL); const char*caption_str=env->GetStringUTFChars(caption, NULL); int result=MessageBox(0,text_str, caption_str, MB_OK|MB_ICONINFORMATION); env->ReleaseStringUTFChars(text, text_str); env->ReleaseStringUTFChars(caption, caption_str); return result; } 这种方式的不足之处在于开发人员不但需要了解C/C++语言,还需要了解JNI实现中的类型转换等细节。对于纯Java背景的开发人员来说, 要调用一个已有代码库中的方法,可以使用JNA(Java Native Access)库[1]。JNA库简化了对原生代码库的调用方式,使用纯Java就可以实现 对代码库的调用。JNA在实现方式上采用了代理设计模式。对于一个原生代码库,JNA可以创建出相应的代理对象。该代理对象中包含了原生代 码库中已有方法所对应的Java方法,在程序中使用此代理对象即可。对代理对象中方法的调用会在进行自动类型转换之后传递给原生代码库中 的相应方法,调用的返回结果也在类型转换后被正确返回。在对原生代码库中的方法进行调用时,方法查找和类型转换等操作都是由JNA负责完 成的,对程序代码是透明的。 在JNA中,可以用继承自com.sun.jna.Library的接口来表示原生代码库。接口中的每个方法都对应原生代码库中的方法。接口中的方法声 明需要与原生方法库中的方法相匹配。代码清单7-20中的User32Library接口表示Windows平台上的user32.dll。在接口中只声明了程序中所需 的MessageBoxA方法,此方法对应于user32.dll中的MessageBoxA方法。在user32.dll中,MessageBoxA方法的声明是int MessageBoxA(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType),在映射到Java接口中的方法之后,HWND类型映射到com.sun.jna.Pointer类,其 他的参数类型直接映射到Java中的基本类型。在得到表示原生代码库的Java接口之后,可以使用JNA中的com.sun.jna.Native类的loadLibrary 方法来得到一个实现此接口的代理对象。在调用原生方法代码库的时候直接使用代理对象即可。 代码清单7-20 表示user32.dll的User32Library接口 public interface User32Library extends Library{ User32Library INSTANCE=(User32Library)Native.loadLibrary("user32",User32Library.class); int MessageBoxA(Pointer handle, String text, String caption, int type); } 代码清单7-21给出了JNA代理对象的使用方式。对于与代码清单7-18中类型相同的show方法,使用JNA提供的代理对象的实现如代码清单7- 21所示。调用的方式很简单,直接使用User32Library接口中的MessageBoxA方法即可。最后一个参数0x40的含义是代码清单7-19中 的“MB_OK|MB_ICONINFORMATION”表达式的实际值。这里的show方法不需要声明为原生方法,只是一个普通的Java方法。 代码清单7-21 使用JNA的消息提示 public class MessageBoxJna{ public int show(String text, String caption){ return User32Library.INSTANCE.MessageBoxA(null, text, caption,0x40); } } 在使用JNA时,开发人员不需要直接编写C/C++代码,也不需要生成额外的原生代码库来调用已有的原生代码库中的方法。JNA所提供的代理 对象可以负责完成相关的工作。JNA的不足之处在于调用原生代码库中的方法时的性能会受到一定的影响。另外在传递参数时,也不能使用在 C/C++头文件中定义的常量。 注意 原生代码库中的某些方法可能是宏定义,如user32.dll中的MessageBox方法实际上是一个宏定义。实际存在的方法是MessageBoxA和 MessageBoxW,分别对应使用ASCII和Unicode编码的情况。JNA无法直接翻译宏定义,需要手动选择正确的方法。如果在调用的时候出现找不到 方法的错误,可以查询原生代码库的头文件或文档说明,以检查该方法名称是否为宏定义。 如果可以找到原生代码库对应的头文件,那么可以使用JNAerator工具[2]从头文件中生成JNA中Library的子接口的代码,其中包含了头文 件中的全部方法。使用JNAerator工具的好处是可以避免手动映射过程中的错误,更加高效。 [1]JNA项目的网址是https://github.com/twall/jna。 [2]JNAerator工具的网址是http://code.google.com/p/jnaerator/。 7.4.3 在C/C++程序中启动Java虚拟机 上面介绍的这两个使用JNI的场景都是以Java程序为主体的。用户使用的是Java程序,只是程序的部分组件由原生代码来实现。下面介绍的 第三个场景以C/C++程序为主体,把Java虚拟机嵌入到原生代码中。通过这种方式,可以在C/C++程序中调用由Java编写的组件。实际上,Java 虚拟机本身是通过原生代码来实现的,要在C/C++程序中调用并非难事。在下面的示例中,C++程序需要获取一个网页的内容之后再进行处理。 由于Java程序中包含的网络相关的类库比较丰富,获取网页的功能由Java程序来实现,而对网页内容的处理则由C++代码来完成。对此,创建了 一个Java类com.java7book.chapter7.jni.WebPageDownloader,其中的静态方法getContent用来根据网页的URL返回其中的内容。在C++程序中 启动Java虚拟机如代码清单7-22所示,方法JNI_CreateJavaVM用来创建并启动一个Java虚拟机。在创建时需要提供虚拟机的启动参数。这里只 提供了Java类文件所在的路径,用于查找所需的Java类。Java虚拟机创建成功之后,JavaVM表示虚拟机对象,可以利用JNIEnv中的方法来查找 Java类并调用其中的方法。在使用完虚拟机之后,需要通过DestroyJavaVM方法来销毁虚拟机。在链接C++代码时需要把JDK的lib目录添加到搜 索路径中。 代码清单7-22 在C++程序中启动Java虚拟机 #include<jni.h> int main() { JNIEnv*env; JavaVM*jvm; JavaVMInitArgs vm_args; JavaVMOption options[1]; options[0].optionString="-Djava.class.path=C:\\java7\\code\\chapter7\\bin"; vm_args.version=JNI_VERSION_1_6; vm_args.options=options; vm_args.nOptions=1; vm_args.ignoreUnrecognized=0; jint res; res=JNI_CreateJavaVM(&jvm,(void**)&env,&vm_args); if(res<0) { return res; } jclass clsDownloader=env->FindClass("com/java7book/chapter7/jni/WebPageDownloader"); jmethodID midGetContent=env-> GetStaticMethodID(clsDownloader,"getContent","(Ljava/lang/String;)Ljava/lang/String;"); jstring content=(jstring)env->CallStaticObjectMethod(clsDownloader, midGetContent, env-> NewStringUTF("http://www.baidu.com")); if(env->ExceptionOccurred()) { printf("Error occurs when downloading content."); jvm->DestroyJavaVM(); return-1; } const char*text_str=env->GetStringUTFChars(content, NULL); printf(text_str); jvm->DestroyJavaVM(); } 上面的代码也说明了C++代码对Java代码中产生的异常的处理方式。在C++代码中,可以通过JNIEnv中的ExceptionOccurred方法来检查Java 代码中是否产生了异常,如果产生了异常,那么会执行相应的异常处理逻辑。 7.5 HotSpot虚拟机 目前有不同厂商或机构开发的Java虚拟机实现。所有这些虚拟机实现都遵守Java虚拟机规范,但是所适用的情况有所不同。Java SE 7的 OpenJDK实现使用的是Oracle的HotSpot虚拟机。HotSpot虚拟机从JDK 1.3开始是Sun提供的默认虚拟机实现。大部分开发人员在使用Java SE 7 时都使用默认虚拟机。本节会介绍HotSpot虚拟机的相关细节,主要目的是帮助开发人员更好地了解和使用HotSpot虚拟机。 7.5.1 字节代码执行 Java语言的源代码在经过Java编译器的编译之后,被转换成Java字节代码。虚拟机在执行字节代码时一般采用的是即时编译的方式,即所 谓的Just-in-time(JIT)编译方式。虚拟机会在运行过程中把字节代码中的指令直接转换成底层操作系统平台上的原生指令。由于虚拟机所理 解的只是Java的字节代码格式,因此这样的转换是必需的。不过JIT编译方式有一些性能方面的问题,会降低程序的执行效率。HotSpot虚拟机 采用了自适应的优化技术来解决JIT编译方式的性能问题。这项优化技术的关键是利用程序运行中的热点(hot spot),这也是HotSpot虚拟机 名称的由来。 程序运行过程中的一个重要特征是程序局部性,即在程序的运行过程中,小部分代码会占据比较多的运行时间。这小部分的代码被称为程 序运行中的热点。这也是“80-20原则”的体现,程序中20%的代码会占据80%的运行时间。如果把这重要的20%的代码的优化工作做好,就可以 节省大量的时间。在程序刚开始运行的时候,HotSpot虚拟机会分析程序的字节代码,以找出其中的热点,并对这些热点进行复杂的优化工作。 随着程序的运行,其中的热点可能会发生变化。虚拟机会随时监控程序的运行状态,以追踪其中的热点。利用热点的好处在于不需要对程序中 的所有代码都进行复杂的优化,这样可以把时间用在对重要代码的优化上,减少代码优化的时间开销。 HotSpot虚拟机的另外一个优化措施是方法内联。虚拟机在运行过程中的大部分时间都花费在方法调用上。方法内联的作用是把被调用的方 法中的代码直接内联到调用的地方。通过这种方式可以减少方法调用,同时为虚拟机提供更多可以优化的代码。 7.5.2 垃圾回收 垃圾回收器对虚拟机上运行的Java程序有着非常重要的作用,也会对程序的性能产生不同的影响。垃圾回收器在实现上要考虑的因素非常 多,并不存在一个完美的算法能够适合不同Java程序的运行情况。垃圾回收器的实现算法更多的是对各种不同因素的权衡和取舍,而权衡的依 据是程序本身的特性和需求。为了适合不同的程序的运行情况,HotSpot虚拟机提供了多种不同的垃圾回收算法。这些算法的具体细节虽然各不 相同,但是都采用了分代回收的方式。 1.分代回收方式 分代回收是垃圾回收中的一种常见算法。这种算法的特点是把内存划分成不同的世代(generation),分别对应虚拟机中存活时间不同的 对象。进行这种划分的依据是从对象存活时间得出的统计规律。在一般程序的运行过程中,大部分对象的存活时间比较短。比如,在一个方法 内部创建的局部变量,方法执行完并退出之后,这些变量所引用的对象的内存就不再需要,可以被回收。只有少量对象存活的时间会比较长, 还有极少数对象的存活时间与程序本身一样长。对于存活时间不同的对象,可以采用不同的回收策略。对于包含存活时间较短的对象的内存空 间,其中所包含的存活对象较少,可被回收的内存区域较多,而且状态变化比较快,因此,对这个世代的内存进行回收的频率比较高,速度比 较快。而对于存活时间较长的对象,回收的频率可以比较低。 在HotSpot虚拟机中,一般把内存划分成3个世代:年轻、年老和永久世代。大部分对象所需内存的分配是在年轻世代区域进行的。当垃圾 回收器运行时,年轻世代中的很多对象可能已经不再存活,可以直接被回收。而有些对象可能仍然处于存活状态。某些对象可能在经过若干个 垃圾回收操作之后,仍然处于存活状态。对于这些仍处于存活状态的对象,垃圾回收器会把这些对象移动到年老世代的内存区域。永久世代中 包含的是Java虚拟机自身运行所需的对象。年轻世代被进一步划分成伊甸园(eden)和两个存活区(survivor space)。大部分对象的内存分 配都是在伊甸园中进行的。由于伊甸园的内存空间较小,因此某些所需内存较大的对象无法直接在伊甸园中进行分配,而直接在年老世代中进 行。两个存活区中总有一个是空白的。在对年轻世代进行垃圾回收时,先把伊甸园中的存活对象复制到当前空白的存活区中,接着对另外一个 非空白存活区中的存活对象进行处理。如果对象的存活时间较短,那么同样将其复制到空白的存活区中;如果对象存活时间已经较长,那么将 其复制到年老世代区域。在复制到空白的存活区的过程中,如果发现该存活区已满,就把这些存活对象直接复制到年老世代区域。经过这两次 复制之后,就可以把伊甸园和非空白存活区中的内容直接全部清空。因为这两个区域中的对象要么不再存活,要么已经被复制到了其他内存区 域中。在完成垃圾回收之后,下次的内存分配可以继续从空白的伊甸园开始进行,两个存活区的作用也发生了交换。 对于年老和永久世代内存区域,通常采用另外一种回收算法。这种算法分3个具体的步骤,其名称也来源于这3个步骤,称为标记-清除-压 缩(mark-sweep-compact)算法。第一个步骤的作用是扫描整个内存区域,把当前仍然存活的对象标记出来;第二个步骤的作用则是清理内存 区域,清除垃圾;第三个步骤的作用是压缩整个内存区域,把存活对象所占的内存都移动到内存区域的起始位置,使内存中可用区域是连续 的。经过压缩之后,在年老和永久世代中进行内存分配就变得很容易,只需从可用区域的开头位置进行分配即可。 2.解决永久世代内存不足 垃圾回收器在每次回收操作时所处理的内存世代区域并不相同。一次较小的回收操作只会对年轻世代进行回收处理,一次较大的回收操作 会处理年老世代,而完全的回收操作会对整个内存区域进行处理。这些回收操作的运行频率也并不相同。在垃圾回收器的运行过程中,最常回 收的是年轻世代的内存区域;对于年老世代的内存区域的回收操作要少得多;而对于永久世代来说,回收的操作就更少。永久世代中存放的一 般是虚拟机运行所需的元数据,包括加载的Java类等。如果程序中加载的类比较多,可能会造成永久世代的空间不够,而出现 OutOfMemoryError错误。错误的提示信息一般是“java.lang.OutOfMemoryError:PermGen space”。如果出现这样的错误,可以通过启动参 数“-XX:MaxPermSize”为永久世代指定一个更大的内存容量。不过需要注意的是,虚拟机对字符串的内部化处理,有可能会造成永久世代的 内存不足。 由于Java中的String类的对象是不可变的,为了提高字符串比较时的性能,Java提供了一种字符串内部化(intern)的机制。这种机制的 实现方式是在虚拟机中缓存String类的对象,当需要使用包含相同字符串的String类对象的时候,可以直接使用缓存中的对象。这样只需要简 单地使用“==”操作符就可以比较两个String对象是否相等,而不需要使用更加耗时的equals方法。虚拟机会对Java源代码中的字符串字面量 进行内部化处理,同时也可以使用String类的intern方法来得到一个缓存的String类的对象。这种内部化机制在Java 7中被用到了数值型的基 本类型上,具体的细节可以参见第6章。 不过需要注意的一点是,不同虚拟机在实现字符串内部化机制时有很大不同。在一些虚拟机实现中,所缓存的String对象是保存在永久世 代中的。如果使用了太多的内部化String对象,对象又都处于被引用的状态,就会导致永久世代的内存不足,出现OutOfMemoryError错误。永 久世代的内存容量一般都比较小,比较容易出现内存不足的问题。在代码清单7-23中,通过循环来不断地生成包含随机内容的字符串,并调用 String类的intern方法来缓存这些字符串。使用List接口的作用是保持对创建出来的String对象的引用,防止被垃圾回收器处理。 代码清单7-23 由于字符串内部化机制造成内存不足的示例 public class StringIntern{ private List<String>list=new ArrayList<String>(); public void useInternString(){ Random random=new Random(); for(int i=0;i<200;i++){ char[]data=new char[128*1024]; for(int j=0;j<data.length;j++){ data[j]=(char)random.nextInt(32768); } list.add((new String(data)).intern()); } } } 上面的代码在不同的虚拟机上的运行结果不同。在OpenJDK的Java 7虚拟机上不会出现OutOfMemoryError错误。通过检查垃圾回收器的输出 信息可以发现,大部分的内存占用发生在年老世代中,而不是永久世代中。在JDK 6更新21的虚拟机中运行时,会出现由于永久世代内存不足而 造成的OutOfMemoryError错误。因此,如果程序中使用了大量的字符串字面量或是String类的intern方法,有可能会产生兼容性的问题。程序 在某个虚拟机上可以正确运行,换到另外一个虚拟机上可能会出现OutOfMemoryError错误。如果字符串是由用户或其他程序提供的,那么一定 不要调用这些字符串对象的intern方法,因为有可能会使程序被恶意攻击,比如一个Web应用接受用户提供的字符串作为输入。在通过servlet 请求获取表示参数值的字符串对象之后,不应该调用该字符串对象的intern方法。如果调用了intern方法,攻击者可以通过发送一些内容很长 的字符串的方式进行攻击,当虚拟机尝试缓存这个对象时,可能会因为OutOfMemoryError错误而退出。 另外一个会造成永久世代内存不足的原因是加载的Java类过多。虚拟机中当前加载的类的元数据是保存在永久世代中的。如果加载的类过 多,会导致永久世代内存不足而引发OutOfMemoryError错误。代码清单7-24给出了一个示例,通过ASM工具创建出一个简单的Java类的字节代 码,将其保存在一个字节数组中。由于LoadClass类继承自Java标准库中的java.lang.ClassLoader类,可以直接调用defineClass方法从包含字 节代码的数组中得到表示Java类的Class类的对象。在调用了defineClass方法之后,Java类的元数据被保存在永久世代中。运行之后会发现, 当加载的类的数量达到一定值时,虚拟机会抛出OutOfMemoryError错误来说明永久世代区域内存不足。 代码清单7-24 由于加载的Java类过多造成内存不足的示例 public class LoadClass extends ClassLoader{ public void loadManyClasses(){ int num=50000; String classNamePrefix="ManyClass"; for(int i=0;i<num;i++){ String className=classNamePrefix+i; createAndLoadClass(className); } } private void createAndLoadClass(String className){ ClassWriter cw=new ClassWriter(ClassWriter.COMPUTE_FRAMES); cw.visit(V1_7,ACC_PUBLIC|ACC_SUPER, className, null,"java/lang/Object",null); cw.visitEnd(); byte[]classData=cw.toByteArray(); this.defineClass(className, classData,0,classData.length); } } 3.选择垃圾回收算法 以上介绍的是分代回收的方式,HotSpot虚拟机提供的垃圾回收算法都使用了这种方式,不过在其他方面仍然有很多不同。在为程序选择合 适的垃圾回收算法时,需要综合考虑多个因素。 第一个需要考虑的因素是使用串行或并行的回收方式。如果虚拟机运行的硬件平台只有一个CPU,那么垃圾回收工作只能由这个CPU按照串 行的方式来完成。如果存在多个CPU的情况,那么可以利用这些CPU并行地进行回收工作。 第二个需要考虑的因素是对回收过程中发现的存活对象的处理方式。第一种做法是进行压缩,即把存活对象移动到内存区域的一端,使内 存中的空闲区域变成连续的。这样的好处是分配内存时的速度会非常快,只需要从空闲区域的端点开始计算,判断是否有足够的空闲区域满足 分配请求也会很容易。不足之处是需要花费额外的时间来进行存活对象的移动操作。第二种做法是不进行压缩,即存活对象仍然被保留在原始 位置。这样的好处是垃圾回收操作可以很快完成。不足之处是分配内存的过程会比较慢,这是因为内存中的可用空间不是连续的,需要逐个查 找可用内存块来寻找满足当前分配请求的空闲块。第三种做法是进行复制,即把存活对象复制到另外一个区域之中。这样做的好处是在复制操 作完成之后,之前的内存区域就变成了全部可用的内存空间,内存分配速度会很快。不足之处是需要花费额外的时间来进行复制操作。总的来 说,这三种方式都要求在垃圾回收操作所花费的时间及后续的内存分配操作所花费的时间这两者之间进行权衡。 理解这些不同的因素及如何在这些因素之间进行取舍,对大多数用户和开发人员来说是一件很困难的事情,因此,对虚拟机的默认垃圾回 收机制的选择就显得尤为重要。绝大多数程序在运行时都不会对默认的垃圾回收机制进行修改。 HotSpot虚拟机会根据硬件平台的能力来选择最适合的垃圾回收方式。硬件平台根据其性能被划分成服务器级别和非服务器级别两类。服务 器级别指的是有两个或两个以上的CPU以及2 GB以上物理内存的硬件平台。对于服务器级别的机器,默认使用并行回收方式,堆内存大小的初始 值和最大值分别是物理内存的1/64和1/4;对于非服务器级别的机器,默认使用串行回收方式,堆内存大小的初始值和默认值分别是4 MB和64 MB。 如果虚拟机默认的垃圾回收的相关设置不能满足要求,可以通过修改启动参数的方式进行调整。可以使用的启动参数比较多,不过对于一 般用户来说,最简单的启动参数是指明垃圾回收机制所要达到的目标。这种目标驱动的方式对于用户来说更加直接。垃圾回收机制的目标包括 降低回收时造成的程序的停顿时间、提高吞吐量和降低程序的内存占用量等。第一个目标是降低由于垃圾回收造成的程序的停顿时间。在进行 垃圾回收时,一个不可避免的问题是当前运行程序的停顿。过长的停顿时间会对程序造成比较大的影响。某些对实时性要求很高的程序更加不 允许出现较长时间的停顿。如果对停顿时间有严格的要求,那么可以通过虚拟机的启动参数“-XX:MaxGCPauseMillis”来指定最长的停顿时 间。虚拟机会调整垃圾回收器的其他参数来保证这个目标得以满足。第二个目标是提高程序运行的吞吐量。吞吐量指的是虚拟机花费在垃圾回 收操作上的时间和程序实际运行时间的比例。虚拟机应该保证把尽可能多的时间花费在程序运行上,同时保证正常的垃圾回收操作不受影响。 通过启动参数“-XX:GCTimeRatio”可以指定垃圾回收时间所占的比例,如使用“-XX:GCTimeRatio=49”就指明垃圾回收时间所占的比例是 1/(1+49)=2%。最后一个目标是降低虚拟机占用的内存总量。当前面两个目标满足时,垃圾回收器会降低堆内存的大小,直到出现了前面两个 目标不满足的情况。这样的作用是在满足前两个目标的前提下,尽可能地减少程序所占用的内存。 如果默认的垃圾回收算法和目标驱动的配置方式都不能满足需求,可以通过启动参数直接指定所要使用的垃圾回收算法。这种配置方式要 求对垃圾回收算法有比较深入的了解,一般只建议有经验的开发人员使用。 第一种可用的垃圾回收方式是串行回收,这也是非服务器级别的硬件平台上的默认回收方式。可以通过启动参数“-XX:+UseSerialGC”来 显式指定。 第二种可用的垃圾回收方式是并行回收,这也是服务器级别的硬件平台上的默认回收方式。可以通过启动参数“-XX:+UseParallelGC”来 显式地指定。并行回收方式相对于串行回收方式的改进体现在对年轻世代进行回收时会利用多个CPU来并行完成,可以减少垃圾回收操作造成的 停顿时间和整体的回收操作所占用的时间,提高吞吐量。而对于年老世代,并行回收的方式与串行回收的方式是一样的。 由于并行回收的方式对于年老世代并没有采用并行的做法,因此性能会受到一定的影响。并行压缩回收方式改进了这一点,对于年老世代 的回收也采用并行的处理方式,可以通过启动参数“-XX:+UseParallelOldGC”来使用这种回收方式。这种回收方式会把年老世代的内存区域 划分成若干个固定大小的子区域。对每个子区域以并行的方式同时进行存活对象的标记工作。由于标记工作是并行进行的,执行的效率会比较 高。在完成标记工作之后,下一步是找出这些子区域中需要进行压缩操作的部分。这是因为某些子区域中存活对象所占的比例可能比较大,不 需要进行压缩。不需要压缩的子区域一般都出现在年老世代的某一端。找到了需要进行压缩的子区域之后,就可以通过复制和移动存活对象等 操作来压缩子区域,使存活对象密度较高的子区域都出现在年老世代内存区域的某一端,方便后续的内存分配。 如果希望程序运行时因垃圾回收操作而造成的停顿时间比较短,那么可以使用并发标记清除的回收方式。这种回收方式对年轻世代的做法 和并行回收方式一样,而对于年老世代的处理方式则比较复杂,分成几个具体的阶段。第一个阶段是初始的标记阶段。这个阶段会先暂停程序 的运行,再标记出从程序的代码中直接可达的存活对象。完成这个阶段之后,程序可以继续运行。与此同时,垃圾回收器会从上一阶段标记出 的存活对象出发,递归地标记可达的其他存活对象。这个标记过程与程序的运行并发进行。完成这个并发的标记阶段之后,下一个阶段会再次 暂停程序的运行。这是因为在上一个标记阶段,由于程序仍然在运行,对象的存活状态可能已经发生了变化。这一个阶段会再次暂停程序,并 对这些发生变化的对象重新进行标记。重新标记完成之后,就可以继续程序的运行,同时并发地对标记过程中发现的垃圾进行回收处理。这种 回收方式所造成的程序停顿时间比较短,但是会要求比较大的堆内存。另外,这种回收方式不会进行内存区域的压缩操作,使得后续的内存分 配操作比较耗时。通过启动参数“-XX:+UseConcMarkSweepGC”可以指定使用这种回收方式。 Java 7中添加了一个新的垃圾回收方式,即垃圾优先方式(garbage first),简称为G1。G1所采用的回收方式不同于之前已有的其他回收 算法。G1也采用了把内存区域划分成不同世代的做法,对年轻世代进行更加频繁的回收操作。但是G1并没有把年轻世代和年老世代所占用的内 存区域从物理上分隔开来,而是把整个堆内存划分成大小相同的子区域,每个子区域的内容可以属于年轻世代或年老世代。垃圾回收的过程是 先找出需要进行回收的子区域和接收待回收子区域中存活对象的其他子区域。回收的过程是并行进行的,把待回收子区域中的存活对象移动到 用来接收的其他子区域中,再把原始的子区域清空。回收操作比较多地发生在年轻世代的子区域中,对于年老世代的子区域也会顺带进行处 理。启用G1回收器需要使用参数“-XX:+UnlockExperimentalVMOptions-XX:+UseG1GC”来完成。G1的主要目标是替换前面介绍的并发标记清 除回收方式。 7.5.3 启动参数 HotSpot虚拟机在启动时可以指定很多不同的参数。这些参数中有些是标准参数,是所有平台上的虚拟机都支持的。另外一些则是非标准的 或试验性质的参数,不是所有平台上的虚拟机都支持。这些试验性质的参数的功能可能并不稳定,会在后续的版本中发生变化。 HotSpot虚拟机支持的标准参数并不是很多:“-classpath”和“-cp”都可以用来指定程序运行时的类路径(classpath);“-D”用来设 置系统属性的值;“-ea”和“-da”分别用来启用和禁用程序中的断言;“-esa”和“-dsa”分别用来启用和禁用系统类中的断言;“- verbose”用来要求虚拟机输出一些详细的信息,如“-verbose:class”用来输出加载Java类时的信息,“-verbose:gc”用来在垃圾回收器 运行时输出相关信息,“-verbose:jni”用来在使用原生方法时输出相关信息。 在启动参数中以“-X”开头的参数是非标准参数,不是所有平台上的虚拟机都支持。这些参数中比较常见的是设置虚拟机堆内存大小的参 数,其中“-Xms”用来设置堆内存的初始值,“-Xmx”用来设置堆内存的最大值。 以“-XX”开头的参数的实现不是很稳定,一般不推荐使用。在前面介绍虚拟机的垃圾回收机制时,启用特定垃圾回收方式的参数都是 以“-XX”开头的,说明对这些参数的使用需要特别注意。这些参数根据数据类型的不同可以分成三类:第一类是布尔型的参数,可以通过“- XX:+<option>”的形式来打开,通过“-XX:-<option>”的形式来关闭。第二类是数值型的参数,可以通过“-XX:<option>=<number >”的形式来指定。在指定数值大小时可以使用“k”、“m”和“g”等单位,分别表示千、兆和千兆。最后一类是字符串类型的参数,可以通 过“-XX:<option>=<string>”的形式来指定。 根据作用的不同大致可以将这些参数分成三类。第一类是与虚拟机行为相关的。前面介绍的指定垃圾回收方式的参数都属于这一类。除此 之外,还包括一些有用的参数,如“-XX:+DisableExplicitGC”用来禁止通过System.gc方法来显式要求运行垃圾回收器。如果不希望程序直 接影响垃圾回收器的运行,那么可以打开此功能。“-XX:+ScavengeBeforeFullGC”用来要求垃圾回收器在对整个内存空间进行回收之前,先 回收年轻世代的内存空间。这个功能默认是打开的。“-XX:+UseGCOverheadLimit”用来限制虚拟机花费在垃圾回收上的时间。如果虚拟机把 大量的时间花费在垃圾回收上,就说明虚拟机的内存过小,不足以支持程序的运行。如果这样的情况持续发生一段时间,虚拟机会抛出 OutOfMemoryError错误。这个功能默认是打开的,如果不希望存在这样的限制,可以关闭此功能。 第二类参数是与性能优化相关的。这些参数主要用来处理字符串的优化,以及调整虚拟机堆内存各个部分的大小。下面介绍一下这些参数 中比较重要的几个。“-XX:+AggressiveOpts”用来启用在当前虚拟机版本中处于试验性质的优化策略,这些优化策略有可能在以后的虚拟机 版本中被默认启用。启用这些优化策略通常会提升程序的运行性能,但是有可能造成一些程序运行时的错误。“-XX:+UseStringCache”用来 启用对经常使用的字符串进行缓存的功能,可以提升性能。“-XX:+UseCompressedStrings”用来启用对字符串的压缩处理。如果字符串中仅 包含ASCII字符,会使用byte[]而不是char[]来表示字符串,这样可以节省占用的内存空间。“-XX:+OptimizeStringConcat”用来启用对字符 串连接操作的优化功能。“-XX:NewRatio”用来指定年轻世代和年老世代所占的内存的比例。“-XX:NewSize”用来指定年轻世代所占的内存 大小。“-XX:MaxPermSize”用来指定永久世代所占内存的最大值。 第三类参数是与程序调试相关的,可以控制虚拟机在运行时输出一些调试信息。这些参数中比较重要的包括:“-XX:+CITime”用来控制 输出JIT编译器所花费的时间;“-XX:+PrintGC”用来控制当垃圾回收器运行时输出与回收操作相关的信息;“-XX:+PrintGCDetails”的作 用类似于“-XX:+PrintGC”,只是输出的信息更加详细;“-XX:+PrintGCTimeStamps”也用来输出与垃圾回收相关的信息,只不过会在信息 中包含时间戳;“-XX:+TraceClassLoading”用来控制Java类被加载时输出相关的调试信息;“-XX:+TraceClassUnloading”用来控制Java 类被卸载时输出相关的调试信息。 7.5.4 分析工具 在大多数时候,Java程序本身并不需要对Java虚拟机有太多的了解。但是程序在运行过程中可能出现一些与虚拟机相关的错误,比如内存 不足或线程死锁等问题。当出现这样的问题时,需要通过一些相关的工具来对虚拟机进行分析,找出问题的原因。对某些程序来说,可能需要 对虚拟机的一些性能指标进行监视,以避免一些潜在的问题。Java虚拟机所提供的分析工具比较丰富,下面逐一进行介绍。 1.命令行和图形化工具 首先可以使用的工具是虚拟机在运行过程中输出的相关调试信息。在上一节中介绍了可以通过启动参数来控制虚拟机输出与垃圾回收和类 加载相关的调试信息。 其次可以使用的是JDK中自带的命令行工具。第一组工具jmap和jhat用来对共享对象映射关系和堆内存进行分析。工具jmap可用于查看正在 运行的Java程序、虚拟机的核心转储(core dump)文件,以及远程调试服务器上的共享对象的映射关系和堆内存的占用情况。使用jmap工具 时,对于正在运行的Java程序,需要指定Java进程的标识符;对于虚拟机的核心转储文件,需要指定文件的路径;对于远程调试服务器,需要 指定服务器的主机名或IP地址。在使用jmap时,可以指定一些选项来声明需要查看的内容。下面对jmap工具的介绍都以查看正在运行的Java程 序为例来进行。 如果不指定任何选项,那么默认的行为是在控制台输出程序中共享对象的映射关系。对于每个虚拟机加载的共享对象,输出它在内存中的 起始地址、映射空间的大小和共享对象所在的文件路径。在Windows操作系统中,被共享的对象通常是虚拟机所加载的操作系统中的DLL文件。 如果添加“-heap”选项,那么会在控制台输出Java程序当前的堆内存占用情况的详细信息,其中包括所用的垃圾回收方式、堆内存的参数配置 信息,以及当前堆内存中各个世代的不同区域的内存占用情况。如果添加“-finalizerinfo”选项,那么会在控制台输出Java程序中当前正在 等待终止的对象的数量。添加“-histo”选项会在控制台输出堆内存占用情况的统计数据,对于每个Java类,输出其实例对象的个数和占用的 内存。当使用“-histo:live”选项时,只会统计当前存活对象的信息。选项“-permstat”用来在控制台输出永久世代中包含的数据的统计信 息。该信息主要由两部分组成,第一部分是被内部化的字符串的个数和占用的内存空间大小,第二部分是与类加载器相关的内容,包括每个类 加载器已经加载的类的个数和占用的空间。选项“-dump”可以把当前程序的堆内存信息转储到文件中,再用jhat工具进行查看。 工具jhat用来对虚拟机堆内存的转储文件进行分析。其独特之处在于它会启动一个Web服务器。开发人员可以通过浏览器来查看转储文件中 的各种内容。这种网页形式的查看方式既方便又直观。在使用jhat时需要指定转储文件的路径。在jhat工具运行起来后,可以通过默认的7000 端口来访问Web界面。有多种方式可以得到堆内存的转储文件,如使用jmap、jconsole和hprof等工具,也可以在启动虚拟机时指定参数“-XX: +He apDumpOnOutOfMemoryError”。当虚拟机出现OutOfMemoryError错误时,会自动生成堆内存的转储文件。 比如,使用“jmap-dump:format=b, file=heap.bin 2456”命令可以把进程标识符为2456的虚拟机的堆内存转储到heap.bin文件中,再使 用“jhat heap.bin”命令就可以启动jhat工具。通过浏览器就可以查看相关的信息,其中包括所加载的所有Java类、每个Java类的实例对象、 堆中内存分布的统计数据和对象终止的情况等。对于每个对象实例,可以列出其中包含的数据成员。在对象的可达性方面,可以列出从一个对 象实例可达的其他对象,还可以列出引用了一个对象实例的其他对象。前面介绍过,虚拟机在判断对象是否存活时从一些根对象开始遍历。使 用jhat可以查看从根对象到当前对象的引用关系路径。通过这个功能可以发现程序中存在的内存泄露问题。如果发现某个应该被回收的对象仍 然出现在内存中,可以检查它的引用关系路径,从而可以发现是由于哪个对象的引用关系仍然存在而造成当前对象无法被回收。 在jhat的Web界面中可以使用一种对象查询语言(Object Query Language, OQL)来查询堆内存的转储文件中的对象。OQL语言的语法类似 SQL,按照“select……from……where”的结构来组织,其中表达式使用的是JavaScript语法。select子句中的内容是生成查询结果的 JavaScript表达式,而from子句中的内容是待查询对象所在的Java类的全名,where子句中的内容则是用来进行过滤的条件表达式。例如,OQL 语句“select s from java.lang.String s where s.count>=200”用来查询所有长度大于等于200的字符串对象。 除了堆内存的分析工具之外,还有其他一些工具可供使用,包括用来列出所有Java虚拟机进程标识符的jps命令行工具,查看虚拟机中线程 堆栈信息的jstack工具,查看虚拟机中系统属性值和启动参数的jinfo工具,以及显示虚拟机运行性能相关指数的jstat工具。 以上介绍的这些工具都是命令行工具,通常把输出结果直接显示在控制台。JDK也提供了图形化界面的工具来监控虚拟机的状态。第一个可 用的工具是jconsole。通过jconsole可以连接到正在运行的本地或远程Java程序上,获取程序所在虚拟机的内存、Java类和线程的相关信息。 这些信息以图形化的方式显示出来,并且实时更新。 从前面的介绍中可以看到,JDK所包含的工具种类繁多,所提供的功能也不同。这么多的工具分开使用也不方便。从JDK 6更新7开始,新的 图形化工具Java VisualVM被加入进来,通过jvisualvm命令可以启动这个工具。VisualVM的作用在于把各种不同工具的功能整合到了一起,用 一个统一的图形化的方式展现出来。除此之外,VisualVM本身是一个可扩展的平台,可以利用社区贡献的插件来增强VisualVM本身对虚拟机的 监控能力。VisualVM除了可以查看虚拟机的各种信息之外,还可以进行程序的性能取样和剖析,找出影响性能的瓶颈所在。 2.JMX 上面介绍的这些工具都是独立运行的,并没有提供相关的API接口供程序使用。从Java SE 5.0开始,Java平台提供了一套完整的API来对运 行的Java程序及虚拟机本身进行监控和管理。这一套API由多个部分组成,其中最重要的组成部分是Java管理扩展(Java Management Extensions, JMX)API。JMX API是Java平台上进行资源监控和管理的标准API,可以用来对应用程序、设备、服务和Java虚拟机本身进行监控 和管理。JMX API在很多情况下都可以发挥作用,包括在运行时动态获取和更新程序的配置信息、收集程序运行过程中的统计数据以及当程序内 部状态发生变化或出现错误时发出相关通知等。JMX API的基础概念是MBean。一个MBean表示的是可以被管理的命名资源。每个MBean都提供一 个管理接口允许第三方来使用。这个管理接口的内容包括可以获取和修改值的命名属性、可以调用的命名方法,以及可以发出的事件通知。 MBean本身只是一个接口,需要由被监控和管理的资源提供相关的实现。所有的MBean实现被注册到MBean服务器上。使用者通过名称在 MBean服务器上查找所需MBean的实现。在得到了实现对象之后,可以通过MBean的管理接口来调用其中的方法。Java虚拟机本身提供了一个 MBean服务器,可以在其上注册相关MBean。与Java虚拟机本身的监控和管理相关的MBean都注册在该MBean服务器上。相关MBean的接口都在 java.lang.management包中定义,可以监控和管理的资源包括虚拟机中的缓冲区、类加载系统、代码编译、垃圾回收器、内存、底层操作系 统、虚拟机运行时、线程和日志记录器等。通过java.lang.management.ManagementFactory类中的工厂方法可以得到所需的管理不同资源的 MBean接口的实现,比如对线程的监控和管理是由接口java.lang.management.ThreadMXBean来表示的。如果需要获取当前虚拟机上的活动线程 的数目,可以通过ManagementFactory类中的getThreadMXBean方法先得到ThreadMXBean接口的实现对象,再调用该对象的getThreadCount方 法。对监控和管理其他资源的MBean的使用也是类似的。 利用对虚拟机平台的监控和管理的能力,可以根据虚拟机的运行情况来动态调整程序本身的运行逻辑。比如,某个程序中提供了后台运行 的定时备份能力,程序中有一个线程在后台运行,并定期把程序中的重要数据备份到磁盘上,整个备份过程需要占用一定的内存空间,当虚拟 机的内存空间不足时,备份过程应该暂停运行以免出现OutOfMemoryError错误而导致虚拟机退出,通过使用监控和管理虚拟机内存的 java.lang.management.MemoryPoolMXBean接口就可以实现这样的功能。代码清单7-25给出了后台备份线程的实现示例。虚拟机通常把内存划分 成多个区域,典型的是划分成多个世代。ManagementFactory类的getMemoryPoolMXBeans方法返回的是所有内存区域的列表。每个区域都有对应 的进行监控和管理的MemoryPoolMXBean接口的实现对象。这里所关心的是年老世代所在的内存区域,因此使用的是名为“Tenured Bean”的 MemoryPoolMXBean接口的实现对象。通过setUsageThreshold方法可以设置内存使用量的阈值。当超过这个阈值时,isUsageThresholdExceeded 方法会返回true,说明剩余内存量已经不足。 代码清单7-25 考虑内存剩余量的备份任务的示例 public class BackupTaskRunnable implements Runnable{ private MemoryPoolMXBean poolBean; public BackupTaskRunnable(){ init(); } private void init(){ List<MemoryPoolMXBean>beans=ManagementFactory.getMemoryPoolMXBeans(); for(MemoryPoolMXBean bean:beans){ if("Tenured Gen".equals(bean.getName())){ poolBean=bean; break; } } poolBean.setUsageThreshold(10*1024*1024); } public void run(){ while(true){ if(poolBean.isUsageThresholdExceeded()){ System.out.println("内存不足,暂停备份任务。"); }else{ System.out.println("执行备份任务。"); } try{ Thread.sleep(1000); }catch(InterruptedException e){ e.printStackTrace(); } } } } 上面的代码使用的是轮询的方式来判断内存使用量是否超过阈值。还有一种做法是使用MBean的事件监听机制。某些MBean在内部状态发生 变化或出现错误的时候,会产生相应的事件通知。MBean的使用者可以在事件通知上注册监听器。当事件发生的时候,所注册的监听器方法会被 调用。这种方式类似于Web和桌面应用开发中的用户界面组件上的事件处理方式。 与虚拟机内存相关的java.lang.management.MemoryMXBean可以在内存区域的占用空间大小超过指定阈值时发出事件通知。对此事件感兴趣 的程序只需要实现javax.management.NotificationListener接口并注册到MemoryMXBean对象上即可。代码清单7-26给出了相关的代码实现。 代码清单7-26 使用事件监听机制的内存监控示例 private static class MemoryListener implements NotificationListener{ public void handleNotification(Notification notification, Object handback){ String type=notification.getType(); if(type.equals(MemoryNotificationInfo.MEMORY_THRESHOLD_EXCEEDED)){System.out.println("内存占用量超过阈值。"); } } } public void addListener(){ MemoryMXBean mbean=ManagementFactory.getMemoryMXBean(); NotificationEmitter emitter=(NotificationEmitter)mbean; MemoryListener listener=new MemoryListener(); emitter.addNotificationListener(listener, null, null); } 7.5.5 Java虚拟机工具接口 前面介绍了使用虚拟机中JMX API中的MBean来对虚拟机进行监控和管理。不过可以通过MBean来进行监控和管理的内容只是虚拟机所提供的 功能中的很小一部分,其中大部分功能是无法通过Java API来访问的。这主要是因为对于运行在虚拟机上的Java程序来说,其他的监控和管理 功能可以使用的场景非常少。虚拟机提供的所有监控和管理功能,虽然不能通过Java API来直接使用,但是可以通过C/C++原生代码来使用。 J2SE 5.0对虚拟机中与监控和管理相关的功能进行整合,形成了标准的Java虚拟机工具接口(Java Virtual Machine Tools Interface, JVM TI)。 1.基本使用 与JMX API不同,JVM TI主要是供开发、调试和监控工具使用的,并不是针对普通的应用程序而设计的。使用JVM TI可以查看和控制当前在 虚拟机上运行的程序的状态。通过使用JVM TI,可以开发出各种面向虚拟机上运行程序的工具,实现包括程序调试、性能分析、运行状态监 控、线程分析和代码覆盖率分析等功能。不过这些工具只能使用原生代码来实现。 使用JVM TI开发的工具被称为虚拟机上的代理程序(agent)。在虚拟机启动时,代理程序也会被加载和运行。当Java程序在虚拟机上运行 时,代理程序可以通过JVM TI查看和控制Java程序中的相关状态。当虚拟机退出时,代理程序也会相应退出。代理程序和虚拟机在同一个进程 中运行。代理程序本身是底层操作系统平台上的一个原生代码库,例如在Windows平台上是一个DLL文件。在虚拟机启动时,通过参数“- agentlib”或“-agentpath”来指定原生代码库的名称或绝对路径。如果指定的是代码库的名称,则由虚拟机根据名称自动进行搜索。在指定 代理程序名称或路径的时候,可以指定额外的配置参数。例如,“-agentlib:myAgent=option1,option2”指明了代理程序的名称是 myAgent,同时传入两个额外的参数option1和option2。 2.使用案例 下面通过一个具体的案例来说明JVM TI的用法。这个案例中的工具的作用是统计Java程序在运行过程中不同方法的调用次数。方法调用次 数的信息对于分析程序中的性能瓶颈是很重要的。代理程序可以通过两种方式来使用JVM TI提供的功能。第一种是方法调用,用来进行直接的 状态查询和更新;第二种是注册事件监听器,用来在虚拟机中特定的事件发生时执行相关的逻辑。对于这个案例来说,主要采用的是注册事件 监听器的方式。当虚拟机开始执行某个方法时,会产生相关的事件,只需要在这个事件的处理方法中根据当前方法的名称进行统计即可。 在代理程序的C++代码中需要添加对JDK中include目录下的jvmti.h头文件的引用。这个头文件中包含JVM TI中的类型声明和方法定义。代 理程序被加载之后,其中的Agent_OnLoad方法会被调用。Agent_OnLoad方法也是代理程序运行的起点。Agent_OnLoad方法的声明是固定的,如 代码清单7-27所示。JavaVM类型的参数表示的是当前的虚拟机对象,而参数options则是之前提到的通过虚拟机启动参数传递给代理程序的额外 参数。在Agent_OnLoad方法被调用时,虚拟机本身的初始化还没有完成,因此在Agent_OnLoad方法中所能执行的操作是有限的。通常在 Agent_OnLoad方法中注册事件监听器。 虚拟机上的所有事件默认是禁用的。要启用某个事件,通常要三步:首先启用某些事件所依赖的虚拟机本身的支持能力。JVM TI中所规范 的监控和管理的功能并不是在所有虚拟机实现上都可用的。这些功能分为必需和可选两类。所有虚拟机都应该实现必需的功能,而可选功能在 某些虚拟机上可能并不支持。本案例所需的在进入方法调用时产生的事件JVMTI_EVENT_METHOD_ENTRY是一个可选功能。对于可选功能,需要通 过JVM TI的AddCapabilities方法显式地启用。在启用了事件相关的功能之后,接着需要启用该事件。只需要使用SetEventNotificationMode方 法把事件的状态设为JVMTI_ENABLE即可。最后一步是注册事件的处理方法。事件发生时的回调方法由JVM TI中的jvmtiEventCallbacks结构来表 示,在其中可以设置所监听的各种事件对应的回调方法。本案例只对JVMTI_EVENT_METHOD_ENTRY事件感兴趣,因此只通过MethodEntry属性设置 了该事件的回调方法,其值是一个函数指针。在填充完jvmtiEventCallbacks结构之后,使用SetEventCallbacks方法来注册事件监听器。 代码清单7-27 代理程序的Agent_OnLoad方法 JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM*jvm, char*options, void*reserved) { jvmtiEnv*jvmti; jvm->GetEnv((void**)&jvmti, JVMTI_VERSION_1_0); jvmtiCapabilities capa; memset(&capa,0,sizeof(jvmtiCapabilities)); capa.can_generate_method_entry_events=1; jvmti->AddCapabilities(&capa); jvmtiEventCallbacks callbacks; callbacks.MethodEntry=&Method_Entry; jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_METHOD_ENTRY, NULL); jvmti->SetEventCallbacks(&callbacks,(jint)sizeof(callbacks)); return JNI_OK; } 代码清单7-27中的jvmtiEnv类型的变量表示的是JVM TI的运行环境。代理程序通过此变量与JVM TI进行交互。对于方法进入事件 JVMTI_EVENT_METHOD_ENTRY的处理函数如代码清单7-28所示。当虚拟机调用某个方法的时候,代理程序中的Method_Entry函数会被调用。通过 函数的参数可以获取表示当前调用方法的jmethodID对象。Method_Entry函数中的逻辑比较简单,通过jvmtiEnv提供的功能来获取方法所在的类 的名称和方法的名称,并把统计数据保存在一个哈希表中。这里需要注意的一点是,通过调用jvmtiEnv中方法所得到的字符串,都需要在使用 完毕之后调用Deallocate方法来释放。 代码清单7-28 方法进入事件处理函数 map<string, int>methodCountMap; void JNICALL Method_Entry(jvmtiEnv*jvmti_env, JNIEnv*jni_env, jthread thread, jmethodID method) { char*method_name; jclass cls; char*class_signature; jvmti_env->GetMethodName(method,&method_name, NULL, NULL); jvmti_env->GetMethodDeclaringClass(method,&cls); jvmti_env->GetClassSignature(cls,&class_signature, NULL); char name[256]={0}; strcpy(name, class_signature); strcat(name, method_name); methodCountMap[name]++; jvmti_env->Deallocate((unsigned char*)method_name); jvmti_env->Deallocate((unsigned char*)class_signature); } 与Agent_OnLoad方法相对应,当虚拟机完成程序的执行并准备退出之前,会调用代理程序中的Agent_OnUnload方法。代码清单7-29给出了 案例中的Agent_OnUnload方法的实现,其作用是把统计出来的方法调用次数的数据输出到一个文件中。 代码清单7-29 代理程序的Agent_OnUnload方法 JNIEXPORT void JNICALL Agent_OnUnload(JavaVM*vm) { ofstream file; file.open("C:\\method_trace.txt",ios:out); for(map<string, int>:iterator iter=methodCountMap.begin();iter!=methodCountMap.end();++iter) { file<<(*iter).first<<"\t"<<(*iter).second<<endl; } file.close(); } 在编译和链接C++程序之后,可以得到代理程序的原生代码库。在启动虚拟机执行任何Java程序的时候都可以加载该代理程序。当程序运行 完毕之后,可以从输出的文件中看到程序中所有方法的调用次数的统计信息。 7.6 小结 Java虚拟机作为一个运行平台,为Java程序提供了一个简洁和统一的运行环境。一般开发人员需要对虚拟机有适度的了解。适度的含义是 既不被虚拟机的底层实现细节所干扰,又可以有效地利用虚拟机本身的能力。本章所介绍的内容围绕这样的主题展开,即介绍Java开发人员应 该知道的虚拟机的相关内容。Java中的引用类型可以让程序与垃圾回收器进行交互,把程序在对象内存管理中的需求传递给垃圾回收器。JNI可 以让Java程序和用C/C++语言编写的原生代码之间进行交互,不仅可以提高性能,还可以进行应用集成。HotSpot虚拟机作为Java SE 7的 OpenJDK实现中的默认虚拟机,其特征值得开发人员进行必要的了解。本章对HotSpot虚拟机的垃圾回收机制、启动参数和分析工具等重要内容 进行了介绍。 第8章 Java源代码和字节代码操作 经过前面章节的介绍,相信读者对Java源代码、Java字节代码和Java虚拟机之间的关系有了一定的了解。一般的流程是这样的:由开发人 员编写Java源代码,再通过Java编译器编译成字节代码,最后由虚拟机来运行。通过JDK中的命令行工具javac可以启动编译器来编译Java源代 码,以生成字节代码。使用命令行工具java或javaw可以启动虚拟机来运行字节代码。如果使用的是集成开发环境(IDE),那么相关的步骤更 加简单,只需要通过IDE的用户界面来操作即可。字节代码作为一个中间层次,为Java平台增加了很多的灵活性。字节代码的格式是公开的。通 过工具可以绕开Java编译器直接生成字节代码,也可以对已有的字节代码进行修改。通过操作字节代码,可以实现很多强大的功能,并用简洁 的方式解决复杂的问题。 前面提到的从Java源代码到字节代码再到虚拟机运行的过程中,其中的每一步都有不同的实现方式。比如,可以不使用命令行工具javac, 而直接在运行时动态编译Java源代码;字节代码可以不通过编译器来生成,而是使用工具来动态创建;在字节代码被虚拟机执行之前,可以通 过修改字节代码的内容来改变程序的行为。Java开发人员对Java源代码的语法应该都很熟悉,下面先从开发人员可能比较陌生的字节代码格式 开始介绍。 8.1 Java字节代码格式 大多数开发人员对Java字节代码的格式可能会有点陌生。字节代码一般出现在Java源代码编译之后生成的class文件中。每个class文件中 包含了单个类或接口的定义。Java源文件中的内部类会被编译到单独的class文件中。实际上,字节代码并不是只存在于class文件中,还可以 通过网络从远程服务器下载,或者由程序在运行时动态生成。所以,字节代码更加准确的说法是包含单个Java类或接口定义的字节流,通常用 byte[]来表示。 Java字节代码是一种二进制格式,其具体的格式在Java虚拟机规范中定义。使用二进制编辑器打开一个class文件,可以看到字节代码的内 容。要理解字节代码格式,可以参考对应的Java源代码的组织结构。一个Java类从源代码的角度来说,包含类本身的信息及类中包含的域和方 法的信息。字节代码中也包含同样的信息,并且以松散的结构进行组织。为了节省空间,字节代码对Java类中常量的存储进行了优化。了解字 节代码的格式,是对字节代码进行操作的基础。工具无法为开发人员屏蔽与字节代码相关的所有细节。为了避免涉及过多的细节,本节只对重 要的内容进行介绍。在介绍字节代码格式的时候,采用的是Java虚拟机规范中的描述方式。 在介绍字节代码的格式之前,先说明一下Java中的类或接口、域和方法等在字节代码中的表现形式。在Java源代码中引用一个类或接口 时,使用的是类似“com.java7book.chapter8.Sample”这种形式的包含名称空间的全名。通过使用import语句,可以省略类或接口所在的包的 名称。而在字节代码中,始终使用全名,并且把全名中的“.”替换成“/”,即类似“com/java7book/chapter8/Sample”这样的形式。对于域 和方法来说,在字节代码中使用描述符来说明其类型。对于域来说,其类型可能是Java的基本类型、对象类型或数组类型。基本类型在字节代 码中用一个字符来表示:byte、char、double、float、int、long、short和boolean类型对应的字符分别是B、C、D、F、I、J、S和Z。对象类 型的表示方式是在全名上加“L”前缀和“;”后缀。例如,一个String类型的域的描述符是“Ljava/lang/String;”。数组类型的表示形式 是在其元素类型之前加上“[”作为前缀。“[”的个数表示数组的维度。例如,一个double类型的二维数组的描述符是“[[D”。对于一个方法 来说,它的描述符取决于参数和返回值的类型,基本形式是“(参数类型)返回值类型”,参数和返回值类型的表示方式与域相同。如果返回 值是void,则用“V”表示。例如,方法声明“int calculate(String str)”的类型描述符是“(Ljava/lang/String;)I”。除了类型描 述符之外,类、域和方法还可能包含类型签名信息。类型签名是在Java SE 5.0中随着泛型的加入而被添加到字节代码中的,其目的是在运行时 也能通过反射API获取泛型相关的信息。第12章会对泛型进行详细介绍。从前面的介绍中可以看出,字节代码中对各种名称的表示方式有些奇 怪,这主要是历史原因造成的。 8.1.1 基本格式 字节代码是一个连续的字节流,其中每个部分所表示的含义是不同的。在进行解析时,需要识别出表示不同内容的字节数据之间的边界。 这些数据内容可以分成定长和不定长两类。对于定长的内容来说,只需要根据长度依次读取即可;对于不定长的内容来说,会在数据的最前面 给出其长度,以进行读取。为了方便介绍,定长数据的类型用u1、u2和u4等来表示,分别表示1字节、2字节和4字节。多字节的字节顺序采用大 端表示。字节代码中数据的整体分布如代码清单8-1所示,其中cp_info、field_info、method_info和attribute_info是表示常量池中常量、 域、方法和属性的子结构,有自己内部的格式。 代码清单8-1 字节代码的基本格式 u4 魔法数 u2 小版本号 u2 大版本号 u2 常量池中常量的个数再加1 cp_info 常量池内容的数组 u2 访问控制标记和属性修饰符 u2 当前类或接口信息的常量池序号 u2 父类或父接口信息的常量池序号 u2 实现接口的个数 u2 实现接口名称的常量池序号 u2 域的个数 field_info 包含域信息的数组 u2 方法的个数 method_info 包含方法信息的数组 u2 属性的个数 attribute_info 包含属性信息的数组 下面对代码清单8-1中的内容进行详细介绍。字节代码的前4字节是魔法数(magic number),用作字节代码格式的标识符。魔法数的值固 定为0xCAFEBABE,英文含义为“咖啡宝贝”,正好与Java名称的来源相对应。不少二进制格式在起始位置都使用类似这样的标识符。[1]魔法数 的作用是可以快速判断一个字节序列是否为合法的字节代码。 紧接着的4字节表示的是字节代码的版本号。前2字节表示小版本号,后2字节表示大版本号。由JDK 7编译器生成的字节代码的版本号是 51.0,对应的4字节的值是0x00000033。Java 6和Java SE 5.0对应的字节代码的版本号分别是50.0和49.0。每个虚拟机有固定的所支持的字节 代码的版本范围。当虚拟机运行它所不支持的版本的字节代码时,会抛出java.lang.UnsupportedClassVersionError错误。如果不能从其他途 径得到字节代码的版本,可以通过查看字节代码的第5到第8字节的值来确定。 接下来的2字节表示常量池(constant pool)中常量的个数再加1。常量池中所包含的是Java中基本类型和字符串常量值、类和接口的名称 及域的名称等。每个常量的类型和所占用的字节数是不同的。这些常量被字节代码中的其他部分所引用。相同的常量在常量池中只会出现一 次。可以将常量池看成是常量的一个查找表。在引用的时候,只需要指定常量在常量池中的序号即可。在常量池的个数之后,紧接着是由 cp_info结构表示的每个常量的具体定义。 接下来的2字节表示的是类或接口的访问控制标记和属性修饰符。每个标记或修饰符对应一个比特位。只需要检查这2字节中特定的比特位 是否为1,就可以判断标记和修饰符是否生效。对这两个字节使用比特位与操作可以进行快速判断。其中常见的标记与修饰符包括:ACC_PUBLIC 对应于public声明,值为0x0001;ACC_FINAL对应于final声明,值为0x0010;ACC_INTERFACE表明是接口而不是一般类,值为0x0200; ACC_ABSTRACT对应于abstract声明,值为0x4000;ACC_SYNTHETIC声明由编译器生成而不在源代码中出现,值为0x1000;ACC_ANNOTATION声明是 一个注解类型,值为0x2000;ACC_ENUM声明是一个枚举类型,值为0x4000。这些标记和修饰符的设置需要遵循Java语言的规范。比如,在Java 中,不能将一个接口声明为final。因此在字节代码中,ACC_INTERFACE和ACC_FINAL这两个标记或修饰符不能同时被设置。 接下来的2字节表示的是当前Java接口或类的信息在常量池中的序号。同样,接下来的2字节表示的是当前Java接口或类的父类或父接口信 息在常量池中的序号。如果当前类是java.lang.Object,则这两个字节的值为0,因为Object类是唯一没有父类的Java类。如果字节代码表示的 是接口,那么对应的父类只能是Object类。 接下来的字节代码的格式就比较简单了,依次表示的是当前接口或类所继承或实现的接口的信息,以及包含的域、方法和属性的信息。由 于实现的接口、域、方法和属性都可能存在多个,因此,在字节代码中表示时,先用2字节表示元素个数,紧接着是包含所有元素信息的数组。 对于实现的接口的数组,其中的每个元素都是常量池中的序号;而对于域、方法和属性来说,每个元素有自己独特的结构,即代码清单8-1中的 field_info、method_info和attribute_info结构。 [1]ZIP格式文件的起始两个字节的值为“PK”,是该格式的发明者Phil Katz姓名的首字母缩写。 8.1.2 常量池的结构 下面介绍定义常量池中的常量的cp_info结构。每个常量定义的起始字节的值标明了常量的类型。我们将该字节称为标签。在这个字节之后 是包含常量内容的若干个字节。先介绍Java基本类型常量的定义方式。字节代码中只包含基本类型int、long、float和double的对应表示,其 他基本类型都可以用int来表示。在这4种类型中,int和float类型的常量的内容是在标签后紧跟包含数据的4个字节;long和double类型的常量 则在标签后紧跟8个字节。值得注意的是,long和double类型的常量会占用常量池中的两个位置。在进行计算时需要考虑这一点。 常量CONSTANT_Utf8_info表示的是一个使用修改后的UTF-8格式表示的字符串序列。在标签之后的两个字节表示序列的长度,紧接着是序列 的内容。这种类型的常量在字节代码中的出现次数比较多。表示String类型的常量CONSTANT_String_info直接引用CONSTANT_Utf8_info常量, 只包含一个对应的常量池中的序号。常量CONSTANT_Class_info表示类或接口,在标签后接着的是类或接口的全名对应的CONSTANT_Utf8_info常 量的序号。对于类或接口中包含的域和方法,由两类常量来共同表示:第一类常量CONSTANT_NameAndType_info表示域和方法的名称和类型,分 别由两个CONSTANT_Utf8_info常量来表示;第二类常量表示域和方法与类或接口的对应关系。常量CONSTANT_Fieldref_info、 CONSTANT_Methodref_info和CONSTANT_InterfaceMethodref_info分别表示域、类中的方法和接口中的方法。这三种常量的结构是相似的,在标 签之后分别是表示所在类或接口的CONSTANT_Class_info常量和表示名称与类型的CONSTANT_NameAndType_info常量的序号。从上面对常量池中 常量的说明可以看出,常量之间存在复杂的引用关系。通过这种引用关系,可以最大限度地复用已有的内容,避免重复。 表示域的field_info结构的格式如代码清单8-2所示。 代码清单8-2 表示域的field_info结构的格式 u2 访问控制标记和属性修饰符 u2 名称的常量的序号 u2 类型描述符的常量的序号 u2 属性的个数 attribute_info 包含属性信息的数组 域的访问控制标记和属性修饰符的表示方式与类或接口中的表示方式很相似,只是所能使用的访问控制标记和修饰符的种类不同。域的名 称和类型描述符的表示方式与常量CONSTANT_NameAndType_info是相同的。域的属性是在个数后接着一个包含attribute_info结构的数组。表示 方法的method_info结构的格式与代码清单8-2中的field_info的格式是相同的。 8.1.3 属性 前面介绍的结构只能表示类、域和方法的最基本信息。Java语法结构中的其他信息都由属性来表示。属性从本质上来说只是一个简单的名 值对。这一点从attribute_info结构的格式可以看出来。在attribute_info结构中,开始的两个字节表示属性名称对应的CONSTANT_Utf8_info 常量的序号,接下来的4字节表示属性值的字节数组的长度,随后是属性值的字节数组。这种简单的结构使得attribute_info可以表示任何类型 的属性。Java字节代码规范预先定义了一些属性的名称及其内部结构。随着Java语言的发展,不断有新的预定义属性被加入进来。例如,J2SE 5.0中引入了表示泛型信息的Signature属性,Java 7中引入了表示invokedynamic指令对应的启动方法的BootstrapMethods属性。除了这些预定 义的标准属性之外,不同的虚拟机实现可以提供自己独有的属性。虚拟机会自动忽略所有无法识别的属性。 8.2 动态编译Java源代码 把Java源代码编译成字节代码的过程通常是由Java编译器来完成的。编译和运行是两个独立的过程。编译所得到的字节代码将作为程序的 分发方式。字节代码的运行则可能在不同平台上的虚拟机上进行。通过动态编译Java源代码,可以把编译和运行两个过程统一起来,都在运行 时完成。这为程序增加了在运行时动态修改其行为的能力。第9章介绍的类加载器也可以实现类似的功能,但类加载器所操纵的对象是字节代 码,而动态编译所操纵的对象是Java源代码。对于开发人员来说,使用源代码比字节代码要简单很多。 动态编译Java源代码的使用场景并不复杂。对于一个Java源文件,在运行时使用API编译出字节代码,再使用类加载器加载到虚拟机中运 行。运行时一般使用Java反射API。Java源文件的内容可以来自磁盘文件,也可以来自远程服务器。任何满足Java语法要求的字符流都可以作为 动态编译的输入。编译之后的字节代码可以保存在磁盘上,也可以保存在内存中。Java平台提供了多种方式来动态编译源代码。这些方式的优 缺点各不相同,可以在不同的场景中使用。下面对这些方式进行具体的介绍。 8.2.1 使用javac工具 使用javac工具是最直接、最简单的动态编译Java源代码的方式。虽然javac大多数时候是作为命令行工具来使用的,但是通过Java标准库 提供的创建底层操作系统上进程的能力,可以在运行时动态调用javac工具来编译源代码。第6章对创建并启动进程做了详细的说明。代码清单 8-3给出了相关实现的示例。类JavacCompiler中compile方法的参数src和output分别表示作为输入的Java源代码的路径和编译之后产生的class 文件的输出路径。 代码清单8-3 在程序中使用javac工具编译Java源代码 public class JavacCompiler{ public void compile(Path src, Path output)throws CompileException{ ProcessBuilder pb= new ProcessBuilder("javac.exe",src.toString(),"-d",output.toString()); try{ pb.start(); }catch(IOException e){ throw new CompileException(e); } } } 使用javac工具的不足之处在于输入和输出都只能以文件形式存在。这也是javac工具本身的使用方式。对于字节流形式的输入内容,需要 保存在磁盘上再进行编译。对于输出的字节代码,类加载器也需要支持从磁盘文件加载的方式。除此之外,使用外部进程方式的性能也比较 低。 除了以外部进程方式来调用之外,javac工具也提供了编程接口供程序直接调用。这种方式相对于外部进程方式,可移植性更好、性能更 优。对源代码进行编译操作由类com.sun.tools.javac.Main中的静态方法compile完成。调用javac工具时的参数以String数组的形式传递给 compile方法。除此之外,还可以使用一个java.io.PrintWriter类型的参数来指定编译器的输出位置。代码清单8-4给出了使用javac工具API的 示例。编译器的输出信息保存在一个文件中,方便查看。 代码清单8-4 使用javac工具API编译Java源代码 public class JavacAPICompiler{ public void compile(Path src, Path output)throws CompileException{ String[]args=new String[]{src.toString(),"-d",output.toString()}; try{ PrintWriter out=new PrintWriter(Paths.get("output.txt").toFile()); com.sun.tools.javac.Main.compile(args, out); }catch(FileNotFoundException e){ throw new CompileException(e); } } } 8.2.2 Java编译器API 使用javac工具的一个问题是其API是Oracle的私有实现,这点从API的包名com.sun.*可以看出来。私有API的接口和实现可能发生变化,从 而造成后期维护上的问题。从Java SE 6开始,Java编译器相关的API以JSR 199(JavaTMCompiler API)的形式规范下来,Java编译器API采用 了Java平台标准的服务提供者接口的定义方式。编译器API中仅包含接口声明,对应的实现由平台实现者提供。使用者通过工厂方法或服务加载 器来查找具体的实现。相关的方法调用都通过接口来完成。Java平台当前的编译器提供了编译器API的默认实现,可以直接使用。通过编译器 API可以对编译过程进行更加精细的控制。 先通过与代码清单8-3和代码清单8-4两个示例相同的编译源文件的方式来说明编译器API的基本用法,如代码清单8-5所示。编译器API中的 编译器由javax.tools.JavaCompiler接口表示。首先要得到JavaCompiler接口的实现。通过javax.tools.ToolProvider类的 getSystemJavaCompiler方法可以得到当前Java平台上默认的编译器实现。一般来说,使用这个默认实现就足够了。在编译器API中对所操作的 对象来源进行了抽象,用javax.tools.FileObject接口表示。虽然从名称上看,FileObject接口表示的是文件对象,但是实际上它是一个数据 来源的抽象表示,不仅包括磁盘文件,也包括内存中的对象和数据库中的数据等。FileObject接口中的方法主要用来获取数据来源的信息和进 行读写操作等。接口javax.tools.JavaFileObject继承自FileObject接口,用来表示Java的源代码和字节代码文件。在编译过程中对Java源代 码和字节代码文件的管理是由javax.tools.JavaFileManager接口的实现对象来完成的。JavaFileManager接口提供的方法所操作的目标是抽象 的路径。通过JavaFileManager接口不仅可以得到某个路径对应的FileObject接口和JavaFileObject接口的实现对象,还可以根据条件列出某个 路径下包含的文件对应的JavaFileObject接口的实现对象。如果Java源代码保存在磁盘上,那么可以使用基于java.io.File接口实现的 javax.tools.StandardJavaFileManager接口。通过StandardJavaFileManager接口可以从文件名或File类的对象中创建JavaFileObject接口的 实现对象。在一般情况下,使用StandardJavaFileManager接口进行文件管理就足够了。通过JavaCompiler类的对象的getStandardFileManager 方法可以得到一个StandardJavaFileManager接口的实现对象。 具体的编译过程是首先调用JavaCompiler类的对象的getTask方法得到一个表示编译任务的JavaCompiler.CompilationTask类的对象,再调 用此对象的call方法来执行此任务。创建编译任务的getTask方法的参数比较多,首先是用来输出编译器信息的java.io.Writer类的对象;其次 是管理源代码和字节代码文件的JavaFileManager接口的实现对象;接着是处理编译过程中诊断信息的javax.tools.DiagnosticListener接口的 实现对象;最后3个参数都是java.lang.Iterable类型的对象,分别表示用来遍历编译时的选项、字节代码文件路径和源文件路径的迭代器。 代码清单8-5 Java编译器API的使用示例 public class JavaCompilerAPICompiler{ public void compile(Path src, Path output)throws IOException{ JavaCompiler compiler=ToolProvider.getSystemJavaCompiler(); try(Standard Java File Manager file Manager=compiler. getStandardFileManager(null, null, null)){ Iterable<?extends JavaFileObject>compilationUnits=fileManager.getJavaFileObjects(src.toFile()); Iterable<String>options=Arrays.asList("-d",output.toString()); CompilationTask task=compiler.getTask(null, fileManager, null, options, null, compilationUnits); boolean result=task.call(); } } } 编译器API相对于javac工具的一个重要优势在于Java源代码的存在不限于文件形式。JavaFileObject接口的实现对象可以作为源代码的来 源。在实现JavaFileObject接口时,比较好的做法是继承已有的javax.tools.SimpleJavaFileObject类,从而减少实现的代价。最常见的需求 是允许编译器使用字符串作为源代码的表现形式,免去了使用文件作为中间形式的麻烦。代码清单8-6给出的StringSourceJavaFileObject类表 示从字符串创建出的JavaFileObject接口的实现对象,在创建时传入类名和内容即可。 代码清单8-6 字符串形式的Java源代码表示方式 public class StringSourceJavaFileObject extends SimpleJavaFileObject{ private String content; public StringSourceJavaFileObject(String name, String content){ super(URI.create("string:///"+name.replace('.','/')+Kind.SOURCE.extension),Kind.SOURCE); this.content=content; } public CharSequence getCharContent(boolean ignoreEncodingErrors)throws IOException{ return content; } } 使用StringSourceJavaFileObject类之后,可以对任意包含Java源代码的字符串进行编译。这为编译带来了很多灵活性。举例来说,编写 一个Java程序来计算带括号的四则运算表达式的值,实现的方式可以有很多。比较传统的做法是对表达式进行语法解析,模拟计算过程来得到 结果。如果考虑到括号的存在和操作符之间的优先级顺序等问题,那么采用这种做法的实现并不简单,而且容易出错。另外一种做法是使用第2 章介绍的脚本语言支持API,把表达式当成一个JavaScript语句,通过eval方法得到结果。还有一种做法是使用Java编译器API,实现起来也会 比较简单。基本的思路是把表达式作为一个Java方法的内容,得到一个Java源文件,编译此源文件之后得到字节代码,再使用类加载器加载字 节代码到虚拟机中,最后通过反射API调用其中的方法,得到计算结果。代码清单8-7给出了这种计算方式的完整实现。 代码清单8-7 使用Java编译器API的表达式求值方式 public class Calculator extends ClassLoader{ public double calculate(String expr)throws Exception{ String className="CalculatorMain"; String methodName="calculate"; String source="public class"+className+"{public static double"+methodName+"(){return"+expr+";}}"; JavaCompiler compiler=ToolProvider.getSystemJavaCompiler(); StandardJavaFileManager fileManager=compiler.getStandardFileManager(null, null, null); JavaFileObject sourceObject=new StringSourceJavaFileObject(className, source); Iterable<?extends Java File Object>file Objects=Arrays.asList(sourceObject); Path output=Files.createTempDirectory("calculator"); Iterable<String>options=Arrays.asList("-d",output.toString()); CompilationTask task=compiler.getTask(null, fileManager, null, options, null, fileObjects); boolean result=task.call(); if(result){ byte[]classData=Files.readAllBytes(Paths.get(output.toString(),className+".class")); Class<?>clazz=defineClass(className, classData,0,classData.length); Method method=clazz.getMethod(methodName); Object value=method.invoke(null); return(Double)value; } else{ throw new Exception("无法识别的表达式。"); } } } 在代码清单8-7中,先根据传入的表达式的内容创建Java源代码。比如,表达式的内容是“(3+2)*5”,所得到的Java源代码的内容如代 码清单8-8所示。只需要编译其中的Java类,并通过反射API调用其calculate方法,就可以得到表达式的计算结果。 代码清单8-8 动态生成的Java源代码的内容示例 public class CalculatorMain{ public static double calculate(){ return(3+2)*5; } } 调用编译器API的方式与代码清单8-5很相似,区别在于不是通过StandardJava-FileManager接口的实现对象从文件路径中得到 JavaFileObject接口的实现对象,而是使用StringSourceJavaFileObject类的对象作为编译器的源文件来源。编译之后的字节代码保存在磁盘 上的临时目录中。编译成功之后,从class文件中得到字节代码的内容,再使用类加载器从字节代码中定义出Java类。得到Java类之后,通过反 射API来调用类中包含的calculate方法,得到表达式的计算结果。 8.2.3 使用Eclipse JDT编译器 Java编译器API虽然为编译Java源代码提供了规范的API,但是它的能力很有限。使用编译器API除了在源代码方面可以使用不同的来源之 外,与编译相关的其他设置仍需要由各种选项来表示。编译器API所提供的对开发人员的接口也并不友好。比如,当编译过程出现错误的时候, 只能依靠DiagnosticListener接口获得一些简单的错误信息。 Eclipse IDE中使用的是Eclipse自己开发的Java编译器。该Java编译器是Eclipse Java开发工具(Java development tools, JDT)的一部 分。JDT的Java编译器相对于Java编译器API来说,所提供的编程接口更加丰富,使用起来也相对复杂。通过JDT编译器完成一个基本的编译过 程,需要提供多个接口的实现。当要求对编译过程进行更加复杂的定制时,JDT编译器是个不错的选择。在使用JDT编译器之前,需要下载JDT的 二进制分发包。只需要使用分发包中的org.eclipse.jdt.core库。JDT编译器相关的Java类都在org.eclipse.jdt.internal.compiler包中。包 名中的“internal”说明此包是Eclipse内部使用的,一般不推荐直接使用。这里使用内部包的原因是JDT编译器并没有直接的公开API,而是与 Eclipse平台的项目构建机制结合在一起,由构建机制自动触发。为了可以在程序中直接使用,必须利用内部API。下面从基本编译过程所需的 接口开始介绍。 要介绍的第一个接口是org.eclipse.jdt.internal.compiler.env.ICompilationUnit,表示编译器所操作的编译单元,指的是Java源代 码。ICompilationUnit接口的作用类似于之前介绍的JavaFileObject接口,其实现类需要提供与源代码相关的信息,包括源代码对应的Java文 件的名称、类型的名称、所在的Java包的名称以及源文件的内容。代码清单8-9中给出了与代码清单8-6中StringSourceJavaFileObject类相似 的基于字符串的编译单元的实现。创建StringCompilationUnit类的对象时需要提供Java类的全名和源代码的内容。 代码清单8-9 基于字符串的编译单元的实现类 public class StringCompilationUnit implements ICompilationUnit{ private String fileName; private char[]typeName; private char[][]packageName; private String content; public StringCompilationUnit(String className, String content){ this.content=content; if(className.contains("$")){ className=className.substring(0,className.indexOf("$")); } fileName=className.replace('.','/')+".java"; int pos=className.lastIndexOf('.'); if(pos>0){ typeName=className.substring(pos+1).toCharArray(); }else{ typeName=className.toCharArray(); } String[]names=className.split("\\."); packageName=new char[names.length-1][]; for(int i=0;i<packageName.length;i++){ packageName[i]=names[i].toCharArray(); } } public char[]getFileName(){ return fileName.toCharArray(); } public char[]getContents(){ return content.toCharArray(); } public char[]getMainTypeName(){ return typeName; } public char[][]getPackageName(){ return packageName; } } 与JDT编译器相关的第二个接口是org.eclipse.jdt.internal.compiler.env.IName-Environment,表示编译过程中使用的名称环境。编译 器在编译过程中使用该接口的方法来查找所遇到的类型、编译单元和包的定义。比如,编译器在遇到java.lang.Object类型的时候,会调用 INameEnvironment接口中的findType方法来查找该类型的定义。如果找不到相关的定义,编译过程会出现错误。INameEnvironment接口的 findType方法接受类型的全名作为参数,返回表示查找结果的org.eclipse.jdt.internal.compiler.env.NameEnvironmentAnswer类的对象。 NameEnvironmentAnswer类的对象表示的结果可以是类型的字节代码、对应的编译单元或未解析的源代码。这三种不同形式的结果为编译时查找 类型的定义提供了足够的灵活性。比如类A在编译时依赖于类B,而类B的源代码可能是运行时动态生成的字符串。对于这种情况,在findType方 法的实现中,当需要查找的类名为B时返回一个表示代码清单8-9中编译单元的NameEnvironmentAnswer类的对象即可。代码清单8-10给出了 INameEnvironment接口实现类中的一个findType方法。如果可以获取Java类型对应的字节代码,可以从字节代码数据中创建出 org.eclipse.jdt.internal.compiler.classfmt.ClassFileReader类的对象,并作为结果返回。 代码清单8-10 INameEnvironment接口实现类中的findType方法 private NameEnvironmentAnswer findType(String name){ byte[]bytes=getClassDefinition(name); if(bytes==null){ return null; } try{ ClassFileReader classFileReader=new ClassFileReader(bytes, name.toCharArray(),true); return new NameEnvironmentAnswer(classFileReader, null); }catch(ClassFormatException e){ e.printStackTrace(); } return null; } 最后一个重要的接口是org.eclipse.jdt.internal.compiler.ICompilerRequestor,用来对编译的结果进行处理。该接口只有一个方法 acceptResult,参数是一个org.eclipse.jdt.internal.compiler.CompilationResult类的对象。CompilationResult类中包含了与编译结果相 关的各种具体信息,包括编译之后的字节代码和出现的错误等。代码清单8-11中给出了完整的编译过程的示例。首先创建一个 ICompilerRequestor接口的实现对象来处理编译的结果。出于演示的目的,代码清单8-11中并没有对编译生成的字节代码进行额外的操作,对 错误的处理也比较简单。类org.eclipse.jdt.internal.compiler.ClassFile表示包含字节代码的class文件。JDT中的编译器由类 org.eclipse.jdt.internal.compiler.Compiler表示。为了创建出Compiler类的对象,除了INameEnvironment接口和ICompilerRequestor接口 的实现类外,还需要另外几个接口的实现类。不过这几个接口可以使用JDT提供的默认实现类。类 org.eclipse.jdt.internal.compiler.impl.CompilerOptions表示编译时的选项,其中包含一系列可供配置的公开域。接口 org.eclipse.jdt.internal.compiler.IErrorHandlingPolicy表示的是在编译中遇到错误时的处理策略。处理策略包括两个选择:一个选择为 是否在遇到第一个错误时就终止整个编译过程,另外一个选择为是否带着发现的错误继续进行编译。接口 org.eclipse.jdt.internal.compiler.IProblemFactory用来在编译出现错误时生成具体的错误信息。由IProblemFactory接口的实现对象创建 的错误可以从CompilationResult类的对象中得到。创建出Compiler类的对象之后,可以调用其compile方法对一组ICompilationUnit接口的实 现对象进行处理。代码清单8-11中编译的是以字符串形式出现的源代码。 代码清单8-11 使用JDT编译器进行编译的示例 public void compile(String code){ ICompilerRequestor compilerRequestor=new ICompilerRequestor(){ public void acceptResult(CompilationResult result){ if(result.hasErrors()){ System.out.println("出现编译错误。"); } ClassFile[]clazzFiles=result.getClassFiles(); ClassFile clazzFile=clazzFiles[0]; //使用编译之后的字节代码 } }; IErrorHandlingPolicy policy=DefaultErrorHandlingPolicies.exitOnFirstError(); IProblemFactory problemFactory=new DefaultProblemFactory(); INameEnvironment nameEnvironment=new BasicNameEnvironment(); CompilerOptions options=new CompilerOptions(); Compiler jdtCompiler=new Compiler(nameEnvironment, policy, options, compilerRequestor, problemFactory); ICompilationUnit[]compilationUnits=new StringCompilationUnit[]{new StringCompilationUnit("Main",code)}; jdtCompiler.compile(compilationUnits); } JDT编译器由于其功能强大、使用复杂,一般只用在开发工具或框架中。Java Web开发框架Play[1]在内部使用JDT编译器来对Web应用的 Java源代码进行编译,同时结合类加载器来实现对Java源代码的修改即时生效,不需要重启服务器。 [1]Play框架的网址是http://www.playframework.org/。 8.3 字节代码增强 上一节介绍的动态编译Java源代码方式的目的在于产生新的字节代码,其输入是Java源代码。在某些情况下,可能无法获得相关的源代 码,而只得到二进制的字节代码。如果希望对字节代码进行处理,需要用到字节代码增强技术。字节代码增强的含义是对已有的Java字节代码 进行修改,从而改变其运行时的行为。Java字节代码格式的开放性使这种增强方式成为可能。增强的工作可以在程序运行之前或运行时完成。 在程序运行之前的做法是先对程序的字节代码进行处理,得到修改后的字节代码,再由虚拟机来运行。通常是在基本的编译过程完成之后,再 使用工具对编译之后的字节代码进行处理。在程序运行时的做法是在字节代码被虚拟机加载之前对字节代码进行修改。通常由增强代理或类加 载器来完成修改工作。一般来说,自己开发的程序适合使用第一种做法,原因是可以避免运行时修改所带来的开销。而框架一般使用第二种做 法,原因是框架本身无法在运行之前进行任何处理,只能在运行时进行处理。有些框架则两种做法都支持,如Apache OpenJPA既提供了Ant任务 对使用OpenJPA的程序在运行之前进行处理,又可以通过增强代理在运行时进行。 字节代码增强技术在很多场景中都有应用。使用它可以灵活高效地解决某些问题,比如典型的AOP编程中的方法拦截功能。还可以利用字节 代码增强技术进行代码生成。由于Java字节代码的格式是公开和规范的,对字节代码进行操作并不是一件复杂的事。不过字节代码格式本身比 较复杂,最好借助工具的支持来进行修改。有不少工具可以用于操作字节代码,如OpenJPA使用的serp[1]和Play框架使用的Javassist[2]。下面 要介绍的操纵字节代码的工具是ASM,它在AspectJ、JRuby和Jython等框架中都有使用。 8.3.1 使用ASM ASM是一个轻量级的Java字节代码操作工具。ASM为字节代码中的各种结构和数据提供了一种面向对象的表示方式,使开发人员不需要了解 常量池等具体细节,就可以很方便地创建新的字节代码或对已有的字节代码进行修改。ASM所提供的API在设计上比较贴近于字节代码的原始格 式,这使得ASM操作字节代码的性能比较高,同时也要求使用者对字节代码格式有比较深入的了解。尤其在用ASM生成方法体的字节代码时,需 要使用者对Java虚拟机的指令有比较详细的了解。不过,ASM也提供了一些工具帮助开发人员查看字节代码的内容,以及生成相关的使用ASM的 代码。 从之前对字节代码格式的介绍中可以看出,字节代码实际上采用了一种松散的树形组织结构。类中包含域、方法和属性,而域和方法又有 各自具体的结构。对字节代码的处理,可以与同样是树形结构的XML文档的处理方式进行类比。XML文档通常有SAX和DOM两种处理方式。SAX是基 于事件的,而DOM是基于文档的树形结构的。ASM的API可以与XML文档的两种处理API相对应。ASM中基本的字节代码处理API是基于事件的,类似 于SAX。当在处理过程中遇到特定的结构时,会产生对应的事件。通过对事件的处理来操作字节代码。在具体的实现上,采用了访问者模式对树 形结构进行遍历。在事件API的基础上,ASM也提供了类似于DOM的树形API。这两种API的优缺点可以同样类比SAX和DOM。 ASM对于字节代码的操作有3个典型的场景,分别是字节代码的读取、生成和修改。读取的含义是指查看一个已有的字节代码中的内容,适 合进行相关的代码分析;生成的含义是指从零开始生成一个Java类或接口的字节代码,由虚拟机来运行,适合代码生成;修改的含义则是指对 已有的字节代码进行修改,适合功能增强。 1.读取字节代码 在介绍对字节代码的读取之前,先介绍ASM中的核心接口org.objectweb.asm.ClassVisitor。正如ClassVisitor接口的名称所表示的含义一 样,这个接口是对字节代码中的类或接口信息进行访问的访问者。该接口中所包含的方法用于对类中包含的不同部分进行访问,比如 visitField方法可以访问一个域的信息,visitMethod方法访问一个方法的信息。读取字节代码的操作由ClassVisitor接口的实现对象和 org.objectweb.asm.ClassReader类的对象结合起来完成。ClassReader类可以读取包含字节代码的字节流,也可以根据类名来查找并读取。 ClassReader类的accept方法可以接受一个ClassVisitor接口的实现对象作为参数。当ClassReader类的对象在读取过程中遇到类中的不同结构 时,会调用ClassVisitor接口的实现对象中的相关方法。比如读取一个方法的时候,ClassVisitor接口的实现对象中的visitMethod方法会被调 用,调用时的实际参数中包含了当前方法的相关信息。如果从事件的角度来看,ClassReader类的对象是事件的生产者,ClassVisitor接口的实 现对象是事件的消费者。事件的具体来源是已有的字节代码,对事件的处理逻辑则由开发人员提供。 下面通过一个具体的示例来介绍ClassVisitor接口和ClassReader类的使用方式。这个示例要实现的功能很简单,统计一个类中包含的方法 的个数。完整的实现如代码清单8-12所示。先介绍一下ClassVisitor接口中的方法。visit方法表示的是访问类的基本信息,包括版本号、访问 控制标记和修饰符、名称、类型签名、父类名称和实现的接口名称等。这些信息是与字节代码格式中的内容直接对应的。visitInnerClass方法 表示的是访问类中包含的内部类的信息。visitOuterClass方法表示的是访问当前类的外部类的信息。visitSource方法表示的是访问类对应的 源文件的信息。visitAttribute方法表示的是访问属性的信息。visitField方法表示的是访问类中的一个域,参数中包含了域的基本信息。如 果需要继续访问域中包含的详细信息,那么需要返回一个org.objectweb.asm.FieldVisitor接口的实现对象。visitAnnotation方法表示的是访 问类中的注解的信息。如果需要继续访问注解的详细信息,那么需要返回一个org.objectweb.asm.AnnotationVisitor接口的实现对象。 visitMethod方法表示的是访问类中包含的方法的信息。同样,可以返回一个org.objectweb.asm.MethodVisitor接口的实现对象来访问方法的 具体信息。从这些方法的名称和参数可以看出,ASM的ClassVisitor接口采用了与字节代码进行直接对应的方式。熟悉字节代码格式的开发人员 很容易进行对应。使用访问者模式的实现也很清晰。对域、注解和方法的处理比较特殊,这是因为它们包含的信息比较复杂,需要另外的访问 者接口来表示。 代码清单8-12 统计方法个数的ClassVisitor接口的实现 public class MethodCounter implements ClassVisitor{ private int count=0; public void visit(int version, int access, String name, String signature, String superName, String[]interfaces){ } public AnnotationVisitor visitAnnotation(String desc, boolean visible){ return null; } public void visitAttribute(Attribute attr){ } public void visitEnd(){ } public FieldVisitor visitField(int access, String name, String desc, String signature, Object value){ return null; } public void visitInnerClass(String name, String outerName, String innerName, int access){ } public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[]exceptions){ count++; return null; } public void visitOuterClass(String owner, String name, String desc){ } public void visitSource(String source, String debug){ } public int getCount(){ return count; } public static void main(String[]args)throws IOException{ ClassReader reader=new ClassReader("java.lang.String"); MethodCounter counter=new MethodCounter(); reader.accept(counter,0); System.out.println(counter.getCount()); } } 代码清单8-12中的示例只对类中包含的方法感兴趣,因此只在visitMethod方法中添加了具体实现。在visitMethod方法的实现中直接修改 计数器的值。由于不需要获取方法的详细信息,因此不需要使用自定义的MethodVisitor接口的实现对象,直接返回null即可。在使用时,由 ClassReader类的对象负责读取String类的字节代码,通过accept方法交由MethodCounter类的对象来处理。 2.生成字节代码 在有些情况下,可能需要从零开始生成一个类或接口对应的字节代码,比如生成程序运行所需的辅助代码。ASM中与ClassReader类对应的 org.objectweb.asm.ClassWriter类可以用来创建Java类或接口的字节代码。ClassWriter类实现了ClassVisitor接口。这也是ASM设计中比较巧 妙的一个地方。ClassVisitor接口不仅可以访问字节代码中的内容,还可以用来生成相关的内容。在进行访问时,ClassVisitor接口中的 visit、visitField和visitMethod等方法是被动调用的,用来通知在字节代码中发现了对应的结构,而相关的信息作为方法调用时的实际参数 来传递。在生成字节代码时,ClassVisitor接口中的方法由使用者来调用。调用者提供的参数作为字节代码中相关结构的值。从事件的角度来 看,ClassVisitor接口不仅可以作为事件的消费者,还可以作为事件的生产者。 在第2章介绍Java语言的动态性时提到,很多动态类型语言可以生成Java字节代码,并且在Java虚拟机上执行这些字节代码。这些语言直接 生成所需的字节代码,而不需要通过Java编译器。下面通过设计一门小的语言来说明ASM工具生成字节代码的用处。这门语言是DRAW,用来进行 简单的图形绘制,只包含MOVETO和LINETO两条指令,分别表示移动到某个位置,以及从当前位置画线到另外一个位置。DRAW语言可以看成是领 域特定语言(Domain Specific Language, DSL)的一个简单示例。代码清单8-13给出了DRAW语言的示例代码,其功能是绘制一个三角形。 代码清单8-13 DRAW语言的示例代码 MOVETO 30 30 LINETO 30 100 LINETO 70 100 LINETO 30 30 使用DRAW语言来编写绘制图形时的指令,这些指令对于普通用户来说是容易理解的。要把DRAW语言的代码转换成可以在虚拟机上运行的字 节代码才能让用户看到绘制的结果。利用Java平台中的Java 2D API可以进行图形绘制。与代码清单8-13中的DRAW语言代码对应的Java代码如代 码清单8-14所示。 代码清单8-14 与DRAW语言对应的Java代码 public class DrawingComponent extends Component{ public void paint(Graphics g){ g.drawLine(30,30,30,100); g.drawLine(30,100,70,100); g.drawLine(70,100,30,30); } } 从实现的角度来说,一种做法是把DRAW语言的代码转换成Java源代码,再使用之前介绍的动态编译技术来进行编译。这种做法的不足之处 在于不够直接,性能会受到一定程度的影响。更好的做法是直接从DRAW语言的代码中生成Java字节代码。生成一个Java类对应的字节代码并不 是一件容易的事情。在字节代码中所要考虑的细节比源代码要多不少。一般的做法是对所需要生成的字节代码进行逆向处理。ASM提供了两组工 具类,可以对生成字节代码的操作提供辅助。第一组类用来以直观的方式输出字节代码中的内容,方便开发人员查看字节代码中各部分的细 节。这组类中比较重要的是org.objectweb.asm.util.TraceClassVisitor类,用来输出Java类的信息。第二组类用来产生生成字节代码所需的 使用ASM的源代码。这组类中比较重要的是org.objectweb.asm.util.ASMifierClassVisitor类。开发人员可以先编写与所要生成的字节代码相 对应的Java代码,再编译此Java代码得到字节代码。对于字节代码使用ASMifierClassVisitor类可以得到使用ASM进行字节代码生成时应该编写 的Java代码。这些Java代码可以作为具体实现的基础。 先使用ASMifierClassVisitor类对代码清单8-14中的DrawingComponent类产生的字节代码进行处理,得到基本的使用ASM的Java代码。再以 此Java代码为基础,得到完整的字节代码生成的实现,如代码清单8-15所示。在DrawingCodeGenerator类中,创建了一个ClassWriter类的对 象。在创建时使用了标记ClassWriter.COMPUTE_MAXS,表明由ClassWriter类的对象自动计算栈大小和局部变量个数的最大值。如果不使用此标 记,在调用MethodVisitor接口的实现对象的visitMaxs方法时,需要手动计算这两个值。如果计算出错,字节代码无法正确运行。字节代码生 成的基本逻辑是先生成字节代码中类的基本信息,再生成paint方法的内容,最后根据DRAW语言代码的内容生成paint方法中调用drawLine方法 的相关指令代码。 代码清单8-15 生成DRAW语言对应的字节代码 public class DrawingCodeGenerator implements Opcodes{ private ClassWriter writer=new ClassWriter(ClassWriter.COMPUTE_MAXS); private MethodVisitor mv=null; private int currentX=0; private int currentY=0; public byte[]generate(String sourceCode)throws IOException{ generateClassInfo(); generatePaintMethod(sourceCode); writer.visitEnd(); return writer.toByteArray(); } private void generateClassInfo(){ writer.visit(V1_7,ACC_PUBLIC+ACC_SUPER,"com/java7book/chapter8/asm/DrawingComponent",null,"java/awt/Component",null); mv=writer.visitMethod(ACC_PUBLIC,"<init>","()V",null, null); mv.visitCode(); mv.visitVarInsn(ALOAD,0); mv.visitMethodInsn(INVOKESPECIAL,"java/awt/Component","<init>","()V"); mv.visitInsn(RETURN); mv.visitMaxs(1,1); mv.visitEnd(); } private void generatePaintMethod(String sourceCode)throws IOException{ mv=writer.visitMethod(ACC_PUBLIC,"paint","(Ljava/awt/Graphics;)V",null, null); mv.visitCode(); BufferedReader reader=new BufferedReader(new StringReader(sourceCode)); String line=null; while((line=reader.readLine())!=null){ if(line.startsWith("MOVETO")){ handleMoveTo(line); } else if(line.startsWith("LINETO")){ handleLineTo(line); } } mv.visitInsn(RETURN); mv.visitMaxs(0,0); mv.visitEnd(); } private void handleMoveTo(String line){ String pos=line.substring("MOVETO".length()); String[]parts=pos.split(""); currentX=Integer.parseInt(parts[0]); currentY=Integer.parseInt(parts[1]); } private void handleLineTo(String line){ String pos=line.substring("LINETO".length()); String[]parts=pos.split(""); int x=Integer.parseInt(parts[0]); int y=Integer.parseInt(parts[1]); mv.visitVarInsn(ALOAD,1); mv.visitIntInsn(BIPUSH, currentX); mv.visitIntInsn(BIPUSH, currentY); mv.visitIntInsn(BIPUSH, x); mv.visitIntInsn(BIPUSH, y); mv.visitMethodInsn(INVOKEVIRTUAL,"java/awt/Graphics","drawLine","(IIII)V"); currentX=x; currentY=y; } } 3.修改字节代码 使用ASM的另外一个场景是对已有的字节代码进行修改。通常由ClassReader类、ClassWriter类和org.objectweb.asm.ClassAdapter类来共 同完成。ClassAdapter类实现了ClassVisitor接口,可以作为ClassReader类和ClassWriter类之间的桥梁。ClassReader类的对象在读取字节代 码时,把ClassAdapter类的对象作为它所产生的事件的消费者。当事件产生时,ClassAdapter类的对象中的相关方法会被调用。ClassAdapter 类的对象在创建时,需要指定另外一个ClassVisitor接口的实现对象作为参数。这个ClassVisitor接口的实现作为事件的消费者。当 ClassAdapter类的对象中的方法被调用时,默认的行为是直接调用所封装的ClassVisitor接口的实现对象中的对应方法。这相当于把 ClassReader类的对象所产生的事件原封不动地传递到作为消费者的ClassVisitor接口的实现对象中。如果需要进行修改操作,可以覆写 ClassAdapter类中的对应方法来改变相关的逻辑。例如,如果希望删除原始字节代码中的名为“test”的方法,可以在ClassAdapter类的 visitMethod方法的实现中检查当前方法的名称。如果名称为“test”,则直接返回null,不把该事件传递给ClassAdapter类的对象封装的 ClassVisitor接口的实现对象。在这种情况下,被封装的ClassVisitor接口的实现对象就看不到这个方法,相当于这个方法被删除。 ClassAdapter类中的所有方法都可以通过被覆写的方式来实现对原始字节代码的修改。 下面通过一个具体的示例来进行说明。该示例的场景是在原始的Java类中添加静态域来跟踪该Java类被创建出来的对象实例的个数。在原 始的Java类实现中并没有这样的能力。出于调试的目的,可以修改原始类的字节代码,添加这样的功能。从实现的角度来说,只需要在类中添 加一个公开的静态变量作为计数器,并在构造方法中修改计数器的值即可。每次构造方法被调用时,计数器的值会加1。从源代码的角度来讲, 进行这样的修改并不困难,但修改已有的字节代码需要比较复杂的实现。主要的复杂性体现在如何找到正确的添加方式,否则会造成字节代码 无法运行的后果。 比较合适的做法是先编写修改之后的字节代码对应的Java源代码,再将其编译成字节代码。使用ASM提供的工具来比较修改前后的字节代码 的差异。通过这些差异,可以知道要做出的修改。代码清单8-16中给出了完整的示例。在覆写ClassAdapter类的visit方法的实现中,通过 visitField方法创建出保存对象实例数量的instanceCount域。在visitMethod方法的实现中,如果当前方法的名称是“<init>”,即构造方 法,则返回一个继承自MethodAdapter类的UpdateInstanceCounterAdapter类的对象。在UpdateInstanceCounterAdapter类中通过覆写 visitInsn方法来添加相关的指令。如果当前指令代码是与方法返回或异常抛出相关的,说明当前指令是方法返回前的最后一条指令。在此指令 之前添加修改类静态域instanceCount值的指令。 代码清单8-16 记录Java类的对象实例个数的字节代码修改方式 public class InstanceCounter extends ClassAdapter implements Opcodes{ private static class UpdateInstanceCounterAdapter extends MethodAdapter implements Opcodes{ private String className; public UpdateInstanceCounterAdapter(String className, MethodVisitor mv){ super(mv); this.className=className; } public void visitInsn(int opcode){ if((opcode>=IRETURN&&opcode<=RETURN)||opcode==ATHROW){ mv.visitFieldInsn(GETSTATIC, className,"instanceCount","I"); mv.visitInsn(ICONST_1); mv.visitInsn(IADD); mv.visitFieldInsn(PUTSTATIC, className,"instanceCount","I"); } mv.visitInsn(opcode); } public void visitMaxs(int maxStack, int maxLocals){ mv.visitMaxs(maxStack+2,maxLocals); } } private String className; public InstanceCounter(ClassVisitor cv){ super(cv); } public void visit(int version, int access, String name, String signature, String superName, String[]interfaces){ cv.visit(version, access, name, signature, superName, interfaces); className=name; FieldVisitor fv=cv.visitField(ACC_PUBLIC+ACC_STATIC,"instanceCount","I",null, null); fv.visitEnd(); } public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[]exceptions){ MethodVisitor mv=cv.visitMethod(access, name, desc, signature, exceptions); if("<init>".equals(name)){ mv=new UpdateInstanceCounterAdapter(className, mv); } return mv; } public static void main(String[]args)throws IOException{ ClassReader reader=new ClassReader("com.java7book.chapter8.asm.CreatedObject"); ClassWriter writer=new ClassWriter(0); InstanceCounter counter=new InstanceCounter(writer); reader.accept(counter,0); byte[]byteCode=writer.toByteArray(); Files.write(Paths.get("bin","com","java7book","chapter8","asm","CreatedObject.class"),byteCode); } } 在运行时,创建一个ClassReader类的对象来读取原始的字节代码,同时创建一个ClassWriter类的对象来输出修改之后的字节代码。进行 修改操作的InstanceCounter类使用ClassWriter类的对象作为参数。ClassReader类的对象产生的事件在经过InstanceCounter类的对象处理之 后,被传递给ClassWriter类的对象。由ClassWriter类的对象负责修改后的字节代码的生成。 4.树形API 除了基本的事件API之外,ASM中也有树形API。树形API把字节代码的信息读取到内存中,用不同的对象来表示。树形API相对于事件API来 说,所需的内存更多,运行速度也更慢。不过在某些情况下,树形API有自己的优势。对字节代码进行的某些操作可能需要获取一些字节代码相 关的全局信息。这些信息通过事件API是不能在一次操作中获取的,因为这些信息只有在处理全部结束之后才能统计出来,所以,事件API至少 需要两次处理才能完成。第一次收集信息,第二次进行实际的处理。而树形API由于已经把全部信息读入内存,因此获取全局信息是很容易的。 同样是计算类中包含的方法的个数,相对于代码清单8-12中给出的使用事件API的实现来说,使用树形API则简单很多,如代码清单8-17所 示。 代码清单8-17 使用树形API计算方法个数 public class TreeMethodCounter{ public int count(String className)throws IOException{ ClassReader reader=new ClassReader(className); ClassNode cn=new ClassNode(); reader.accept(cn,0); return cn.methods!=null?cn.methods.size():0; } public static void main(String[]args)throws IOException{ TreeMethodCounter counter=new TreeMethodCounter(); int count=counter.count("java.lang.String"); System.out.println(count); } } ASM中树形API的Java类都在org.objectweb.asm.tree包中。对于字节代码中出现的各种结构,都有Java类与之对应。如ClassNode类表示一 个Java类或接口,FieldNode类和MethodNode类分别表示域和方法。ClassNode类实现了ClassVisitor接口。使用ClassNode类的对象访问一个 ClassReader类的对象读取的字节代码,可以得到字节代码在内存中的完整表示形式。通过ClassNode类的对象可以访问字节代码中包含的各种 结构,如ClassNode类的对象的methods域表示的是包含所有方法的MethodNode类的对象的列表。 [1]serp工具的网址是http://serp.sourceforge.net/。 [2]Javassist工具的网址是http://www.javassist.org/。 8.3.2 增强代理 ASM工具的一种典型用法是在字节代码被使用之前进行处理。一般的做法是在程序的构建过程中,在源代码被编译之后,再运行相关的程序 来对字节代码进行处理。如果需要在运行时进行处理,可以使用类加载器来完成。不过类加载器的方式相对比较复杂。一种更简单的做法是使 用J2SE 5.0中引入的java.lang.instrument包提供的API。java.lang.instrument.Instrumentation接口中实现了一种在运行时对类的字节代码 进行转换的能力。 1.增强代理的基本用法 对字节代码的转换操作由特殊的代理程序来完成。代理程序是一个jar包。在该jar包的清单文件中定义了启动代理的Java类名称。不同的 虚拟机实现提供的启动代理的方式不尽相同,一般有两种实现方法。第一种做法是通过虚拟机的启动参数指定代理程序jar包的路径。所用的参 数是“-javaagent”,如“-javaagent:myAgent.jar”。对于这种方式,代理程序的jar包的清单文件中要包含Premain-Class的属性,用来指 定一个Java类。该类中必须包含一个premain方法。在虚拟机启动之后,代理程序类中的premain方法会被调用,然后才是程序的主Java类的 main方法被调用。在premain方法的参数中可以得到Instrumentation接口的实现对象。第二种做法是在虚拟机运行主程序之后,再启动代理程 序。这类代理程序的jar包的清单文件中要由Agent-Class属性指明代理类的名称。当代理类被加载之后,虚拟机会尝试调用类中的agentmain方 法。该方法的参数类型与premain相同。下面的介绍都是针对第一种方式进行的。 在代理类的premain或agentmain方法中可以获取作为参数传递的Instrumentation接口的实现。该接口中的重要方法是addTransformer,用 来添加java.lang.instrument.ClassFileTransformer接口的实现对象。ClassFileTransformer接口只有一个方法transform,用来对字节代码 进行处理,返回处理的结果。代码清单8-18中的TraceTransformer类的作用是对字节代码中的方法进行处理,在方法的开始处插入额外的指 令,用System.out方法输出当前方法的名称。经过TraceTransformer类的转换之后,类中的方法在被调用时都会先输出方法的名称。对字节代 码修改时使用了ASM工具。 代码清单8-18 追踪方法调用的字节代码转换代理 public class TraceTransformer implements ClassFileTransformer{ public byte[]transform(ClassLoader loader, String className, Class<?>classBeingRedefined, ProtectionDomain protectionDomain, byte[]classfileBuffer)throws IllegalClassFormatException{ ClassReader reader=new ClassReader(classfileBuffer); ClassWriter writer=new ClassWriter(0); ClassAdapter adapter=new ClassAdapter(writer){ public MethodVisitor visitMethod(int access, final String name, String desc, String signature, String[]exceptions){ MethodVisitor mv=cv.visitMethod(access, name, desc, signature, exceptions); return new MethodAdapter(mv){ public void visitCode(){ mv.visitCode(); mv.visitFieldInsn(Opcodes.GETSTATIC,"java/lang/System","out","Ljava/io/PrintStream;"); mv.visitLdcInsn("进入方法:"+name); mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL,"java/io/PrintStream","println","(Ljava/lang/String;)V"); } public void visitMaxs(int maxStack, int maxLocals){ mv.visitMaxs(maxStack+2,maxLocals); } }; } }; reader.accept(adapter,0); return writer.toByteArray(); } } 接下来需要把TraceTransformer类添加到代理程序中。代理程序可以是任何Java类。对于在虚拟机启动时运行的代理程序,只要包含 premain方法即可。代码清单8-19给出了代理程序的实现。在premain方法中调用addTransformer方法添加了代码清单8-18中的 TraceTransformer类的对象,通过该对象来进行相应的字节代码转换操作。把TraceAgent类和相应的清单文件打包在一个jar文件中。在虚拟机 启动时添加相关的启动参数来使用此jar文件作为代理程序。 代码清单8-19 追踪方法调用的代理程序 public class TraceAgent{ public static void premain(String args, Instrumentation inst){ inst.addTransformer(new TraceTransformer()); } } 2.字节代码的重新转换 有两种类型的字节代码转换器,一种是允许重新进行转换的,另外一种是不允许重新进行转换的。这两种类型通过Instrumentation接口的 addTransformer方法的调用方式来区分。在调用addTransformer方法时可以指定一个额外的参数来声明是否允许进行重新转换。对于使用 addTransformer方法添加的转换器,在通过类加载器的defineClass方法进行定义或使用Instrumentation接口的redefineClasses方法重新进行 定义时,转换器的transform方法都会被调用。当类已经被加载后,可以通过Instrumentation接口的retransformClasses方法来重新进行转 换。在重新转换的过程中,只有允许进行重新转换的转换器类的transform方法才会被调用。通过addTransformer方法可以添加多个转换器。在 转换过程中,这些转换器按照添加时的顺序级联起来。前一个转换器的输出是下一个转换器的输入。如果转换器不需要对某个类的字节代码进 行处理,那么transform方法直接返回null即可。 不是所有虚拟机的实现都支持对类的字节代码进行重定义和重新转换操作,也就是说Instrumentation接口的redefineClasses和 retransformClasses方法不一定起作用。需要通过Instrumentation接口的isRedefineClassesSupported和isRetransform-ClassesSupported方 法来分别检查虚拟机对于重定义和重新转换的支持能力。除了虚拟机支持之外,代理程序也需要在其jar包的清单文件中声明是否需要启用重定 义和重新转换类的功能。这是通过清单文件中的Can-Redefine-Classes和Can-Retransform-Classes属性来表示的。这两个属性的默认值都为 false,需要显式地启用。这两个条件需要同时满足才能完成重定义和重新转换的操作。 由于重定义和重新转换的支持,可以在运行时动态改变类的行为。代码清单8-19中的TraceAgent在所有类被加载之前都会进行转换操作。 如果希望在运行时进行转换操作,可以使用代码清单8-20中的代理程序。在RetransformClassesAgent的premain方法中把Instrumentation接口 的实现对象保存下来。当程序调用enableTrace方法时,会添加代码清单8-18中的TraceTransformer转换器,再对ToBeTraced类进行转换操作。 转换操作完成之后,ToBeTraced类的行为会即时发生改变。在disableTrace方法的实现中,先把TraceTransformer转换器从列表中删除,再重 新进行转换。其结果是ToBeTraced类恢复到了最初的状态。重新转换是必须进行的,否则ToBeTraced类的定义不会更新。 代码清单8-20 使用重新转换操作的代理程序 public class RetransformClassesAgent{ static Instrumentation instrumentation; static TraceTransformer traceTransformer=new TraceTransformer(); public static void premain(String args, Instrumentation inst){ instrumentation=inst; } public static void enableTrace(){ if(instrumentation.isRetransformClassesSupported()){ instrumentation.addTransformer(traceTransformer, true); try{ instrumentation.retransformClasses(ToBeTraced.class); }catch(UnmodifiableClassException e){ e.printStackTrace(); } } } public static void disableTrace(){ if(instrumentation.isRetransformClassesSupported()){ instrumentation.removeTransformer(traceTransformer); try{ instrumentation.retransformClasses(ToBeTraced.class); }catch(UnmodifiableClassException e){ e.printStackTrace(); } } } } 通过代码清单8-21中给出的方式来使用ToBeTraced类。在method1方法被调用时,ToBeTraced类还没有被转换,不会有调试信息输出。随后 调用RetransformClassesAgent类的enableTrace方法进行转换。接下来调用method2方法时会输出相关的调试信息。在disableTrace方法被调用 之后,再次对ToBeTraced类进行转换。再次转换完成后,调用method3方法时不再输出调试信息。 代码清单8-21 使用重新转换的代理程序的测试示例 ToBeTraced traced=new ToBeTraced(); traced.method1(); RetransformClassesAgent.enableTrace(); traced.method2(); RetransformClassesAgent.disableTrace(); traced.method3(); 每次转换操作都从类最初的字节代码开始,也就是说,在没有改变转换器列表的情况下,多次调用retransformClasses方法的结果是一样 的,并不会出现同一转换操作被应用多次的情况。这也是disableTrace方法实现的基础。 8.4 注解 注解(annotation)是J2SE 5.0引入的对Java所做的重大修改之一。注解的含义可以理解为Java源代码中的元数据。提到源代码中的元数 据,注释(comment)是最为开发人员所熟悉的一种形式。注释用来描述源代码中类、域和方法的作用等。注解与注释的最大不同在于注解会影 响源代码的行为,注释则不会。在编译器对源代码进行处理时,注释会被直接删除,而注解则可能保留在字节代码中。在程序中,另外一种常 见的元数据形式是以文件形式存在的XML、JSON或YAML文档等。这些元数据中有些是供用户使用的,这类元数据无法用注解来替代。还有些元数 据是供开发人员使用的,用来配置第三方库的行为,这类元数据可以用注解来替代,而且使用注解会更加方便。 注解在第三方库中得到广泛的应用,主要是相对于其他配置方式来说,注解有着突出的优点。注解与源代码紧密结合在一起,而其他配置 方式则依赖与源代码分开的外部文件。以Java EE开发中常见的对象关系映射为例。当需要声明一个Java类为可被持久化的实体时,如果使用外 部配置文件,那么通常需要在该配置文件中指明该Java类的全名,以及一些持久化相关的属性。当Java类发生变化时,配置文件中的相应部分 也需要更新。Java类和配置文件之间的同步,需要由开发人员自己来维护。在实际开发中,很容易出现不同步的情况,造成错误。例如,在重 构过程中修改了Java类的名称,但是配置文件并没有进行相应的更新。而在使用注解时,只需在Java类中添加相应的注解来包含配置信息即 可。所有的修改都在一个Java类的源代码中完成。这种统一性使开发人员更容易管理代码中的变化。 8.4.1 注解类型 在创建和使用注解之前,要先对注解类型有一定的了解。注解类型是一种特殊的接口,在声明时使用的是“@interface”而不 是“interface”。注解类型不能添加泛型声明,也不能使用extends关键词来继承自另外的接口。所有注解类型都继承自 java.lang.annotation.Annotation接口,但是这个继承关系是隐式的。Annotation接口的作用是为注解类型提供一些公用方法,包括常用的 equals、hashCode和toString方法。除此之外,Annotation接口的annotationType方法可以返回当前的注解类型。需要注意的是Annotation接 口本身并不是注解类型。直接继承自Annotation接口并不能创建新的注解类型。已有注解类型的子类或子接口也不是注解类型。创建注解类型 必须通过“@interface”声明。除了在继承关系上的限制之外,注解类型与普通的接口没有其他区别。 一个注解类型中可以包含多个元素。每个元素都可以看成是注解中包含的配置项,通过方法声明的形式来定义。这些方法的声明中不能有 任何形式参数或类型参数,也不能有抛出受检异常的throws声明。方法的名称是元素的名称,方法的返回值类型决定了元素的类型。方法的返 回值类型只能是基本类型、String类型、Class类型、枚举类型、注解类型和数组类型。数组类型中元素的类型只能是非数组类型,不支持多维 数组。注解类型中可以没有任何元素,这种注解类型被称为标记注解类型,是作标记用的。如果注解类型中只有一个元素,那么这个元素的名 称应该根据惯例使用value。使用value的好处是可以简化注解的使用方式,可以为注解类型中的元素设定默认值,通过在方法声明之后的 default关键词来指定。 Java标准库中提供了一些可以直接使用的注解类型。这些注解类型有两类:一类是配置注解类型本身行为的元注解,另外一类是与Java语 言某些特性相关的一般注解。先从元注解说起。 元注解java.lang.annotation.Target的作用是配置注解类型可以应用的程序元素。Java源程序中的很多元素都可以添加注解。某些注解类 型有其适用的程序元素的类型。比如某些注解类型只对方法有意义,可以通过Target元注解来声明这一点。如果开发人员错误地把该注解应用 在域上,那么编译器会产生相关的错误。如果不通过Target元注解来声明,则注解类型可以应用在任何程序元素上。Target注解只有一个可配 置元素,是枚举类型java.lang.annotation.ElementType的数组。ElementType中的值包括TYPE、ANNOTATION_TYPE、PACKAGE、CONSTRUCTOR、 FIELD、METHOD、PARAMETER和LOCAL_VARIABLE,分别表示类型声明、注解类型声明、包声明、构造方法声明、域声明、方法声明、方法参数声 明和方法中局部变量声明。值相同的ElementType枚举类型不能在同一个Target注解的声明中出现多次。 另一个元注解是java.lang.annotation.Retention,表示注解的保留策略。源代码中声明的注解可以有不同的保留策略。这些不同的策略 由枚举类型java.lang.annotation.RetentionPolicy来表示。总共有三种不同的策略,分别是SOURCE、CLASS和RUNTIME。如果保留策略是 SOURCE,则注解声明不会出现在字节代码中;如果保留策略是CLASS或RUNTIME,注解声明会出现在字节代码中,注解是声明在方法中的局部变 量上的情况除外。局部变量上的注解在任何情况下都不会保留在字节代码中。如果保留策略是RUNTIME,那么注解声明在运行时是可用的,可以 通过反射API来获取注解的相关信息。注解类型应该选择合适的保留策略。默认的策略是CLASS。 第三个元注解是java.lang.annotation.Inherited,表示注解类型对应的注解声明是被继承的。对于可以被继承的注解类型的注解声明, 在进行查询时,如果当前类没有直接声明此注解,会继续查询其父类,直到找到该注解声明或查询到Object类。Inherited是一个标记注解,不 包含配置元素。Inherited注解类型对除类型声明之外的其他程序元素不起作用。 除了元注解之外,Java标准库还提供了几个一般的注解,主要与Java语法相关。第一个是java.lang.Override,用来表示一个方法声明覆 写其父类型中的对应方法。Override注解的作用是避免由于开发人员没有正确区分方法重载和覆写而带来的错误。当需要覆写一个方法的时 候,可以在方法声明上添加Override注解。如果这个方法实际上并没有覆写父类型中的方法,而是进行了重载,那么编译器会产生相应的错误 信息。在代码清单8-22中,User类的equals方法的本意是覆写Object类的equals方法,提供自己的对象比较方式的实现,而实际上User类中的 equals方法并没有进行覆写,而是提供了equals方法的另外一种重载形式,这是在编程过程中很难发现的错误。 代码清单8-22 错误的方法覆写方式 public class User{ public boolean equals(User user){ //比较操作 } } 如果代码清单8-22中的equals方法加上了Override注解,编译器会给出错误信息,开发人员会意识到这个错误,并根据需要做出相应的修 改。 另外一个一般注解是java.lang.Deprecated,用来声明一个程序元素已经被废弃,不应该继续使用。当使用已经被废弃的程序元素时,编 译器会产生相关的错误信息。注解java.lang.SuppressWarnings用来阻止编译器产生某些编译错误。 8.4.2 创建注解类型 除了标准库提供的注解类型外,开发人员可以根据需要创建程序中要用的注解类型。创建注解类型一般有两个要素:第一个是确定注解中 包含的元素,即可能的配置选项;另外一个是确定注解类型的元注解。代码清单8-23给出了一个名为Author的注解类型,用来表示作者信息。 该注解类型中的配置元素包括作者的姓名、电子邮件地址和是否启用电子邮件通知,其中enableEmailNotification通过default关键词声明默 认值为true。在该注解类型上,通过@Target声明了该注解类型只适用于源代码中的类型声明,通过@Retention声明了该注解只保留在源代码 中,不会在字节代码中出现。 代码清单8-23 注解类型示例 @Target({ElementType.TYPE}) @Retention(RetentionPolicy.SOURCE) public@interface Author{ String name(); String email(); boolean enableEmailNotification()default true; } 在注解类型的声明中,除了表示配置元素的方法之外,还可以包含常量声明、类和接口声明、枚举类型声明及注解类型声明。代码清单8- 24给出了在注解类型声明中使用枚举类型的示例。由于注解类型Employee只包含一个元素,使用value作为该元素的名称。 代码清单8-24 注解类型声明中使用枚举类型的示例 @Target(ElementType.TYPE) public@interface Employee{ enum EMPLOYEE_TYPE{REGULAR, CONTRACT}; EMPLOYEE_TYPE value(); } 8.4.3 使用注解类型 注解类型的使用并不复杂,只需要在源代码的程序元素上以“@”加注解类型名称的方式进行声明即可。如果注解类型中包含了元素,则需 要对所有不包含默认值的元素都指定一个值。比如,要把代码清单8-23中的注解类型Author应用到一个Java类上,可以使用代码清单8-25中给 出的方式。Author中的元素name和email没有默认值,需要在使用注解类型时显式指定。对于包含默认值的配置元素也可以指定值。 代码清单8-25 注解类型的使用示例 @Author(name="Alex",email="alex@example.org") public class MyClass{ } 如果注解类型中只包含一个元素,且这个元素的名称是value,那么在使用注解类型时可以省略元素的名称。比如,代码清单8-24中的 Employee注解类型可以按照代码清单8-26中的方式来使用。代码清单8-26中的用法“@Employee(EMPLOYEE_TYPE.REGULAR)”等价 于“@Employee(value=EMPLOYEE_TYPE.REGULAR)”,只是使用上更加简洁。只有当注解类型中使用value作为唯一的配置元素的名称时,才能 使用这种简洁的写法。 代码清单8-26 注解类型的简洁使用示例 @Employee(EMPLOYEE_TYPE.REGULAR) public class AnotherClass{ } 如果注解类型中包含类型为数组的配置元素,那么在使用时有一些特殊的地方要注意。比如,代码清单8-27的注解类型WithArrayValue中 包含一个类型为Class<?>[]的元素。 代码清单8-27 包含数组类型的注解类型声明 @Target(ElementType.TYPE) public@interface WithArrayValue{ String name(); Class<?>[]appliedTo(); } 使用该注解类型的方式如代码清单8-28所示。对于数组类型的配置元素,使用“{}”来包含数组中具体的值。如果数组中的值只有一个, 那么可以省略掉“{}”。注解声明“@WithArrayValue(name="Test",appliedTo={String.class})”和“@WithArray- Value(name="Test",appliedTo=String.class)”在作用上是等价的。 代码清单8-28 包含数组类型的注解类型的使用示例 @WithArrayValue(name="Test",appliedTo={String.class, Integer.class}) public class ArrayClass{ } 对于标记注解,只需要声明注解类型的名称即可。在使用注解时,善用这些简化的方式可以减少代码量。 8.4.4 处理注解 当注解被添加到Java源代码中之后,并不会自动产生作用。某些注解甚至不会在字节代码中出现。创建和使用注解只完成了第一步,更重 要的是如何对注解进行处理。在一般情况下,创建和处理注解是Java标准库和第三方库应该做的事情,开发人员只需要使用注解即可。以前面 提到的Override注解为例,Override注解类型是Java标准库提供的,对它的处理由编译器负责。开发人员只需要在方法声明上添加@Override的 声明来使用它即可。编译器会负责在编译时检查该方法是否的确覆写了父类型中的方法。如果程序中创建了特有的注解,就需要对它进行处 理。了解处理注解的方式,对于框架和类库的开发人员来说尤其重要。在一般应用程序开发中也可能需要处理注解。 处理注解通常有两种方式:一种是在编译时,另外一种是在运行时。对于保留策略为SOURCE的注解类型来说,编译时是唯一的处理方式。 如果注解声明在运行时仍然保留,那么可以通过反射API动态地处理。 在编译时处理注解的工作由编译器来完成。对于Java标准库中的Override、SuppressWarnings和Deprecated等注解,编译器知道如何进行 处理。而对于程序中自定义的注解类型,则需要程序提供相应的处理器,由编译器在编译时调用。从注解类型被引入的J2SE 5.0到Java 7,自 定义注解类型的处理方式发生了很多变化。J2SE 5.0使用的是apt工具和Oracle私有的Mirror API来进行处理。Java SE 6引入了可插拔式注解 处理机制及相应的javax.annotation.processing和javax.lang.model包,另外javac工具也提供了对注解处理的支持,不再需要使用apt工具。 Java SE 7则把J2SE 5.0中的apt工具和对应的Mirror API声明为被废弃的,不推荐使用。本节主要介绍的是Java SE 6中引入的新的注解处理机 制,也是推荐使用的注解处理方式。 1.可插拔式注解处理机制 Java SE 6中通过JSR 269(Pluggable Annotation Processing API)对注解处理机制进行了标准化。JSR 269主要包括两个部分,一部分 是进行注解处理的javax.annotation.processing包,另一部分是对程序的静态结构进行建模的javax.lang.model包。这两部分API是相辅相成 的:javax.annotation.processing包用来完成实际的注解处理,在处理过程中需要了解程序中被注解的元素的信息。这部分信息由 javax.lang.model包来表示。对javax.lang.model包的介绍会在说明javax.annotation.processing包时穿插进行。 JSR 269的注解处理机制的重要特征是采用可插拔的设计方式。由底层的工具提供基本的框架和运行环境,开发人员编写的注解处理功能作 为插件嵌入到此框架之中,并利用框架提供的功能完成所需的处理。对于注解的处理分多轮来进行。在每轮处理中,注解处理器会对源代码和 字节代码文件中发现的部分注解进行处理,并可能产生新的源代码和字节代码文件。上一轮处理的输出作为下一轮处理的输入,按照顺序来完 成。第一轮处理的输入由运行编译器时的参数来确定。 所有的注解处理器都需要实现javax.annotation.processing.Processor接口。注解处理器一般选择继承自 javax.annotation.processing.AbstractProcessor类。所有实现Processor接口的类需要提供一个公开的不带参数的构造方法。注解处理框架 会使用此构造方法来创建处理器的对象实例。实例创建完成后,Processor接口的init方法会被调用,以完成处理器的初始化。init方法声明中 包含类型为javax.annotation.processing.ProcessingEnvironment接口的参数。ProcessingEnvironment接口表示注解处理时的运行环境,提 供了一些方法来创建新文件、报告错误以及获取其他实用工具类。注解处理器的实现对象通过ProcessingEnvironment接口与框架进行交互。在 调用init方法时,ProcessingEnvironment接口的实现对象由框架提供。一般把此对象保留下来,供后续操作使用。接着框架调用Processor接 口中的方法来获取一些与注解处理器相关的信息,这些方法包括获取处理器支持的注解类型的getSupportedAnnotationTypes方法、处理器支持 的最高源代码版本号的getSupportedSourceVersion方法和处理器所能识别的选项名称的getSupportedOptions方法。这些方法对一个处理器实 例只会调用一次。在每轮注解处理过程中,框架会根据当前源代码中具有的注解声明和每个处理器所能处理的注解类型来确定由哪些处理器来 处理。被选中的处理器的process方法会被调用。在调用时,process方法的第一个参数表示的是待处理的注解类型,第二个参数表示的是可以 用来获取本轮处理的相关环境信息的javax.annotation.processing.RoundEnvironment接口的实现对象。当没有可供处理的注解声明或是其他 匹配的处理器时,本轮处理结束。当所有轮处理都完成时,整个注解处理过程结束。 下面通过一个简单的示例开始对注解处理器的介绍。需要处理的注解是代码清单8-23中的Author。Author注解被添加在Java源代码中的类 或接口上,提供类或接口的作者的相关信息。对Author注解进行处理的目的统计每个作者所创建的类或接口的数量。代码清单8-29中给出了完 整的处理器实现。AuthorProcessor类继承自AbstractProcessor类。AbstractProcessor类对Processor接口的大部分方法都提供了默认实现, 继承者只需要实现process方法即可。在AbstractProcessor类的init方法中,把之后的处理中需要用到的ProcessingEnvironment接口的实现对 象保存在受保护的域processingEnv中,AbstractProcessor类的子类可以直接使用该域来引用这个对象。在AuthorProcessor类上添加了注解 javax.annotation.processing.SupportedSourceVersion和javax.annotation.processing.SupportedAnnotationTypes。AbstractProcessor类 可以处理这些注解,并作为Processor接口中getSupportedSourceVersion和getSupportedAnnotationTypes方法的实现。同样 AbstractProcessor类还支持javax.annotation.processing.SupportedOptions注解作为getSupportedOptions方法的返回值。 AbstractProcessor类的这个能力简化了对注解处理器所支持的最高源代码版本号、所支持的注解类型和所能识别的额外选项这三个配置的处 理。对AuthorProcessor类来说,支持的最高源代码版本是7,而支持的注解类型只有一种,即com.java7book.chapter8.annotation.Author。 在声明所支持的注解类型时,可以使用通配符“*”。单个“*”字符表示可以处理所有的注解类型。 在process方法的实现中,通过RoundEnvironment接口可以获取与本轮处理相关的信息。其中,通过getRootElements方法可以获取前一轮 处理完成之后的可供处理的程序元素集合。程序元素由javax.lang.model.element.Element接口表示。Java源代码中包含的各种静态结构都由 此接口或其子接口来表示。在RoundEnvironment接口提供的方法中,比较常用的是getElementsAnnotatedWith方法,用来获取包含指定注解类 型声明的元素的集合。对于每个包含注解类型声明的元素,使用getAnnotationMirrors方法可以得到它所包含的所有注解。每个注解用 javax.lang.model.element.AnnotationMirror接口表示。遍历所有这些注解,根据名称找到其中的Author注解。通过AnnotationMirror接口的 getElementValues方法可以获取注解中包含的所有配置元素的值。遍历这些配置元素,可以找到元素name的值,即作者的姓名,之后即可对作 者姓名进行统计。 由于注解处理的过程可能要经过多轮来完成,因此一个处理器的process方法会被多次调用。如果在某轮处理中,一个处理器被调用了,那 么在后续的每轮处理中,即便没有注解可供该处理器来处理,也会调用该处理器。在process方法中可以通过RoundEnvironment接口的 processingOver方法进行判断。在一轮处理中,如果processingOver方法返回true,则说明这是处理的最后一轮。如果本轮的处理过程依赖前 一轮的执行结果,那么通过RoundEnvironment接口的errorRaised方法可以判断前一轮的处理是否出现错误。需要注意process方法的返回值的 使用。如果返回值为true,说明对这些注解类型的处理由当前处理器独占完成,其他的处理器不会再进行处理;如果返回值为false,则其他处 理器仍然有机会进行处理。如果某个处理器声明的注解处理类型是“*”,同时又在process方法中返回true,则相当于独占处理了所有的注解 类型。其他处理器都不会得到处理的机会。 代码清单8-29 Author注解的处理器 @SupportedSourceVersion(SourceVersion.RELEASE_7) @SupportedAnnotationTypes("com.java7book.chapter8.annotation.Author") public class AuthorProcessor extends AbstractProcessor{ private Map<String, Integer>countMap=new HashMap<>(); private TypeElement authorElement; public synchronized void init(ProcessingEnvironment processingEnv){ super.init(processingEnv); Elements elementUtils=processingEnv.getElementUtils(); authorElement=elementUtils.getTypeElement("com.java7book.chapter8.annotation.Author"); } public boolean process(Set<?extends TypeElement>annotations, RoundEnvironment roundEnv){ Set<?extends Element>elements=roundEnv.getElementsAnnotatedWith(autho rElement); for(Element element:elements){ processAuthor(element); } if(roundEnv.processingOver()){ for(Map.Entry<String, Integer>entry:countMap.entrySet()){ System.out.println(entry.getKey()+"==>"+entry.getValue()); } } return true; } private void processAuthor(Element element){ List<?extends Annotation Mirror>annotations=element.getAnnotationMirrors(); for(AnnotationMirror mirror:annotations){ if(mirror.getAnnotationType().asElement().equals(authorElement)){ String name=(String)getAnnotationValue(mirror,"name"); if(!countMap.containsKey(name)){ countMap.put(name,1); } else{ countMap.put(name, countMap.get(name)+1); } } } } private Object getAnnotationValue(AnnotationMirror mirror, String name){ Map<?extends ExecutableElement,?extends AnnotationValue>values=mirror.getElementValues(); for(Map.Entry<?extends ExecutableElement,?extends AnnotationValue>entry:values.entrySet()){ ExecutableElement elem=entry.getKey(); AnnotationValue value=entry.getValue(); String elemName=elem.getSimpleName().toString(); if(name.equals(elemName)){ return value.getValue(); } } return null; } } 在得到包含注解的Element接口的实现对象之后,有两种方式可以获取该对象上包含的注解。一种方式是使用代码清单8-29中给出的 getAnnotationMirrors方法。该方法返回的是所有注解的列表,需要遍历该列表进一步找到所需的注解。该方法返回注解列表中包含直接出现 在元素中的注解,并不包含继承下来的注解。另一种方式是使用Element接口的getAnnotation方法。该方法接受一个注解类型的Class类的对象 作为参数,返回元素上对应此类型的注解。通过这个方法得到的注解可能是直接出现的,也可能是继承而来的。这两个方法的显著区别在于注 解信息的来源不同:getAnnotationMirrors方法使用的是源代码中出现的程序静态结构中的信息,getAnnotation方法使用的则是运行时通过反 射得到的信息。尤其要注意这两个方法在处理注解中Class和Class[]类型的配置元素上的区别。如果注解中元素的类型是Class或Class[],那 么通过getAnnotation方法得到的注解中元素的值不能直接使用。任何尝试使用得到的Class和Class[]类型的对象的操作,都会抛出异常。这是 因为缺乏加载对应Java类所需的信息,无法定义出对应的Class类的对象。而通过getAnnotationMirrors方法可以得到相关的信息。每个Java类 由javax.lang.model.type.TypeMirror接口来表示。 注解处理器的运行由Java编译器来完成。可以通过两种方式来声明在编译时要运行的注解处理器。一种方式是通过javac命令行工具的“- processor”参数来指定注解处理器的类名。另外一种方式是使用注解处理器的自动发现机制。编译器在编译时的类路径(CLASSPATH)中查找 路径名为“META-INF/services/javax.annotation.processing.Processor”的文件。这个文件中的每一行都包含要运行的注解处理器的类名。 除了类路径外,还可以通过编译器的“-processorpath”参数来显式指定查找路径。如果不希望进行注解处理,那么可以使用“-proc: none”参数来禁用。 2.创建和修改源代码 之前介绍的注解处理器只是从注解中提取出所需的相关信息,并没有对Java源代码本身做出修改。实际上,在注解处理器中既可以创建新 的Java源文件、字节代码文件和资源文件,又可以对已有的源代码进行修改。创建新文件的操作由从Processing-Environment接口得到的 javax.annotation.processing.Filer接口的实现对象来完成。对已有源代码的修改则由Java编译器提供的内部API来完成。 下面先介绍如何生成一个新的Java源代码文件。代码清单8-30给出了在注解处理器中生成新Java源代码的示例。其中,filer是在init方法 中通过ProcessingEnvironment接口的getFiler方法得到的Filer接口的实现对象。通过Filer接口的createSourceFile方法创建一个新的 JavaFileObject接口的实现对象,表示一个新的Java源文件。不过创建完成后,JavaFileObject接口的实现对象所表示的文件内容是空的。通 过JavaFileObject接口的openOutputStream方法得到输出文件内容所需的OutputStream类的对象,再把源文件的内容写入即可。这里只写入了 一个简单的Java类的源代码内容。 代码清单8-30 注解处理器中生成源代码的示例 private void generateSource()throws IOException{ String packageName="com.java7book.chapter8.annotation"; String className="HelloWorld"; String fullName=packageName+"."+className; JavaFileObject javaFile=filer.createSourceFile(fullName,(Element[])null); Writer writer=new OutputStreamWriter(javaFile.openOutputStream(),"UTF-8"); String source=getSource(packageName, className); writer.write(source); writer.close(); } private String getSource(String packageName, String className){ StringBuilder builder=new StringBuilder(); builder.append("package"+packageName+";"); builder.append("public class"+className+"{}"); return builder.toString(); } 新的Java源文件被创建出来之后,Java编译器会对新文件进行编译。由于注解处理发生在编译之前,创建新的Java源文件的做法适合于自 动代码生成的场景。在已有的代码中通过注解的方式来添加元数据,确定代码生成的逻辑。在注解处理过程中完成代码的生成。这种自动的方 式解决了手工生成可能带来的变化不一致的问题。不过,由于生成的代码在编译之前对于程序中的其他部分来说是未知的,因此需要某些机制 来保证这些代码可以被已有的代码使用。一般是在生成的代码中调用已有代码中的逻辑。 下面介绍如何对已有的源代码进行修改。对源代码进行修改需要分析源代码的结构,得到对应的抽象语法树,再对其中的元素进行修改。 OpenJDK中使用的Java编译器的源代码是开放的,可以在Java程序中使用Java编译器所提供的API来对Java源代码进行分析。相关的API 在“com.sun.tools.javac”包中。由于编译器的实现不是Java标准的一部分,它的API可能在不同版本之间发生变化,因此使用时要考虑这一 点。也可以不对源代码进行语法分析,而是把源文件当成字符串来进行处理。这种做法的优势是简单易用,不需要附加库的支持,但是有可能 出现语法错误。下面的介绍中使用的是OpenJDK中Java编译器的API。 先介绍一下示例的背景。Java语言提供了不同的访问控制级别,包括public、private和protected等。一般Java类内部的域和方法使用 private来修饰,外部的Java类无法进行访问。这种做法的好处是提高了Java类的封装性,但是在进行测试的时候,无法对这些私有方法进行直 接测试,只能通过Java类的公开方法进行间接测试。从方便测试的角度出发,可以把原始代码中的私有域和方法修改成公开的。修改之后的代 码只为测试所用。 为了允许把代码中的私有域和方法的声明修改为公开的,需要增加一个新的注解类型Visible。该注解可以添加在需要进行转换的Java类 上。完整的注解处理器的实现如代码清单8-31所示。通过编译器的API,可以得到表示程序元素的Element接口的实现对象所对应的 com.sun.tools.javac.tree.JCTree类的对象。JCTree类的对象是一个树形结构,可以在上面添加访问者来进行处理。而 com.sun.tools.javac.tree.TreeTranslator类是一个用来进行源代码转换的访问者实现。VisibilityTranslator类所实现的是在Java源代码中 遇到域声明时,把它的访问控制修饰符的值直接设置为1,即public声明对应的标记值。经过这样的处理之后,源代码中的域都变成可以公开访 问的。 代码清单8-31 把域的声明修改为public的注解处理器 @SupportedSourceVersion(SourceVersion.RELEASE_7) @SupportedAnnotationTypes("com.java7book.chapter8.annotation.Visible") public class VisibilityProcessor extends AbstractProcessor{ private TypeElement visibleElement; private Trees trees; private TreeMaker treeMaker; public synchronized void init(ProcessingEnvironment processingEnv){ super.init(processingEnv); trees=Trees.instance(processingEnv); Context context=((JavacProcessingEnvironment)processingEnv).getContext(); treeMaker=TreeMaker.instance(context); Elements elementUtils=processingEnv.getElementUtils(); visibleElement=elementUtils.getTypeElement("com.java7book.chapter8.annotation.Visible"); } public boolean process(Set<?extends TypeElement>annotations, RoundEnvironment roundEnv){ if(!roundEnv.processingOver()){ Set<?extends Element>elements=roundEnv.getElementsAnnotatedWith(v isibleElement); for(Element element:elements){ JCTree tree=(JCTree)trees.getTree(element); TreeTranslator visitor=new VisibilityTranslator(); tree.accept(visitor); } } return true; } private class VisibilityTranslator extends TreeTranslator{ public void visitVarDef(JCVariableDecl def){ super.visitVarDef(def); JCVariableDecl pub=treeMaker.VarDef(treeMaker.Modifiers(1),def.name, def.vartype, def.init); result=pub; } } } 3.使用反射API处理注解 如果不在编译之前对注解进行处理,那么另外一种做法是可以在运行时通过反射API来利用注解中提供的信息。在反射API 中,java.lang.reflect.AnnotatedElement接口表示包含注解的元素。java.lang包中的Class和Package,以及java.lang.reflect包中的 Constructor、Field和Method都实现了AnnotatedElement接口。AnnotatedElement接口中的getAnnotations方法用来获取当前元素上的所有注 解,包括直接出现的和继承而来的,而getDeclaredAnnotations方法只返回直接出现的注解。使用getAnnotation方法可以根据注解类型来查找 相应的注解声明。在得到注解之后,可以调用其中的方法来获得包含的配置元素的值。 一个典型的使用场景是把注解、反射API和动态代理结合起来使用。注解用来设置程序在运行时的行为,反射API用来解析注解,而动态代 理负责应用具体的行为。相对于动态修改Java源代码或字节代码的方式来说,这种做法的实现都包括在程序的源代码中,开发和调试比较简 单。 例如,在一个企业的员工管理系统中,对访问控制权限有比较严格的要求,某些操作只能由特定角色的用户来完成。如给员工加薪的操 作,只能由具有“经理”角色的用户来完成。这种访问控制的限制一般是分级进行的,除了前端界面显示和后台服务接口需要有相应的权限检 查逻辑之外,进行实际操作的业务逻辑实现代码也要有相应的检查逻辑。 从实现的角度来说,访问控制属于程序中横切的非功能性需求,可以由专门的人员负责开发。业务逻辑的实现代码只需要以声明的方式来 描述访问控制的需求即可。使用代码清单8-32中的Role注解类型可以指定调用一个方法时,当前用户要具备的角色。Role类型的保留策略要设 置为RUNTIME,这样可以在运行时通过反射API来查找到该注解的信息。 代码清单8-32 声明方法调用时所需用户角色的注解类型 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public@interface Role{ String[]value(); } 代码清单8-33在对员工信息进行操作的EmployeeInfoManager接口的updateSalary方法中添加了Role注解,声明调用此方法时,当前用户需 要具有“manager”角色。 代码清单8-33 修改员工薪酬的接口 public interface EmployeeInfoManager{ @Role("manager") public void updateSalary(); } 在EmployeeInfoManager接口的实现类中并不需要考虑访问控制的问题,具体的访问控制是由动态代理来负责的。代码清单8-34中的 AccessInvocationHandler类负责进行调用时的检查。如果方法中添加了Role注解,则获取此注解中包含的值,即调用此方法所应具备的角色。 通过与程序中保存的当前用户的角色进行比较,可以判断该调用是否应该进行。使用者通过工厂方法得到EmployeeInfoManager接口的实现对 象,该对象是一个封装了访问控制权限检查逻辑的动态代理对象。 代码清单8-34 用来获取修改员工薪酬的代理对象的工厂类 public class EmployeeInfoManagerFactory{ private static class AccessInvocationHandler<T>implements InvocationHandler{ private final T targetObject; public AccessInvocationHandler(T targetObject){ this.targetObject=targetObject; } public Object invoke(Object proxy, Method method, Object[]args)throws Throwable{ Role annotation=method.getAnnotation(Role.class); if(annotation!=null){ String[]roles=annotation.value(); String currentRole=AccessManager.getCurrentUserRole(); if(!Arrays.asList(roles).contains(currentRole)){ throw new RuntimeException("没有调用此方法的权限。"); } } return method.invoke(targetObject, args); } } public static EmployeeInfoManager getManager(){ EmployeeInfoManager instance=new DefaultEmployeeInfoManager(); return(EmployeeInfoManager)Proxy.newProxyInstance(instance.getClass().getClassLoader(),new Class<?> []{EmployeeInfoManager.class},new AccessInvocationHandler<EmployeeInfoManager>(instance)); } } 8.5 使用案例 下面通过一个具体的案例把字节代码操作、源代码生成和注解处理等技术结合起来,形成完整的解决方案。这个案例涉及Java程序的国际 化问题,有关Java程序国际化的知识在第4章中已经做了介绍。国际化实现最主要的问题是需要在代码中将ResourceBundle类的使用与属性文件 内容保持同步。如果添加了新的字符串,那么需要同时更新Java代码和属性文件。如果对属性文件中字符串的键做了修改,也需要对Java代码 进行相应的修改。一个改动会涉及程序中的多个部分。通过本章介绍的技术,可以把这些变化统一到一个地方,即Java源代码本身中。基本的 实现思路是在Java源代码中通过注解的形式声明所有需要国际化的字符串在属性文件中的键和值,由注解处理器负责收集所有的字符串,动态 生成属性文件和相应的使用ResourceBundle类的Java代码,再通过字节代码修改,在程序中添加调用ResourceBundle类的字节代码。开发人员 只需要使用注解在源代码中声明需要国际化的字符串即可,其他的工作都是自动完成的。 代码清单8-35给出了一个包含需要国际化的字符串的Java类的示例。由于注解无法添加在源代码中方法内部的字符串常量上,需要把这些 字符串提取出来,包装在一个方法中,然后在方法中添加注解。方法getTestMessage的作用只是为了封装字符串,它的返回值并不重要,但是 该方法必须包含一个可变长度的Object类型的参数,表示构建字符串时的参数。注解类型Message的作用是声明方法所封装的字符串在属性文件 中的键和值,注解类型MessageBundle的作用是声明类使用的属性文件对应的资源包的名称。 代码清单8-35 使用注解进行国际化字符串声明的代码示例 @MessageBundle("Messages") public class DemoClass{ public void output(){ System.out.println(getTestMessage("Hello")); } @Message(key="TEST_MESSAGE",value="This is a test message.%1$s") public String getTestMessage(Object……args){ return""; } } 在相应的注解处理器实现中,扫描所有的MessageBundle和Message注解,把相关的信息收集起来。接着生成属性文件和使用 ResourceBundle类的Java文件。创建属性文件使用的是Filer接口中的createResource方法。与代码清单8-30中创建Java文件的做法相似,得到 FileObject接口的实现对象之后,再向对应的输出流中写入数据。使用ResourceBundle类的Java文件也通过自动的方式生成,所生成的Java文 件如代码清单8-36所示。在Messages类中,加载了对应的属性文件,并提供getMessage方法从属性文件中获取字符串。 代码清单8-36 使用ResourceBundle类的Java源代码 public class Messages{ private static ResourceBundle bundle; static{ bundle=ResourceBundle.getBundle("com.java7book.chapter8.annotation.i18n.Messages"); } public static String getMessage(String key, Object……args){ String message=bundle.getString(key); return String.format(message, args); } } 最后一步是对包含Message注解的方法的字节代码进行修改,使这些方法不再简单地返回空字符串,而是调用代码清单8-36中Messages类的 getMessage方法来返回实际的字符串。这一步操作在编译之后由ASM工具来完成。代码清单8-37给出了修改方法的字节代码的实现。这里使用的 是ASM的树形API。先把当前方法对应的MethodNode类的对象中包含的指令全部删除,再添加调用Messages类中方法的指令。 代码清单8-37 修改返回国际化字符串的方法的字节代码 private static void updateMethodNode(MethodNode mn, String key){ InsnList instructions=mn.instructions; Iterator iterator=instructions.iterator(); while(iterator.hasNext()){ iterator.next(); iterator.remove(); } instructions.add(new LdcInsnNode(key)); instructions.add(new VarInsnNode(ALOAD,1)); instructions.add(new MethodInsnNode(INVOKESTATIC,"com/java7book/chapter8/annotation/i18n/Messages","getMessage","(Ljava/lang/String; [Ljava/lang/Object;)Ljava/lang/String;")); instructions.add(new InsnNode(ARETURN)); mn.maxStack=2; mn.maxLocals=2; } 在经过这些处理之后,当代码清单8-35中的getTestMessage方法被调用时,所执行的代码指令是调用代码清单8-36中的Messages类的 getMessage方法。而getMessage方法会根据国际化字符串在属性文件中的键的名称来查找对应的值,并作为getTestMessage方法的返回值。 8.6 小结 Java源代码和字节代码是Java开发中的重要资源。相对于源代码,字节代码的格式对一般开发人员来说更加陌生。本章先从字节代码的格 式开始介绍。在编译Java源代码时,除了使用JDK自带的javac命令行工具之外,还可以使用Java编译器API和Eclipse JDT编译器。在对字节代 码进行处理时,介绍了如何使用ASM工具来读取、生成和修改字节代码。通过使用增强代理机制,可以在运行时动态修改字节代码。注解是一种 在源代码中添加元数据的方式。本章对如何处理注解进行了详细介绍。最后通过一个使用案例介绍了字节代码操作、源代码生成和注解处理等 技术的综合运用。 第9章 Java类加载器 在得到了Java程序的字节代码之后,需要通过一种方式把字节代码加载到Java虚拟机中运行。Java选择了一种更加灵活和开放的方式来实 现这个加载过程,即类加载器(class loader)。Java是随着互联网的发展而流行起来的编程语言。在Java产生的早期,Applet可以说是Java 平台的“杀手级应用”。Applet的特点是将字节代码存放在远程服务器上,浏览器在运行Applet时需要从远程服务器下载字节代码后再运行, 因此需要一种新的机制来允许从远程服务器加载字节代码。这种新的机制就是类加载器。类加载器机制是Java平台的一个重要创新,它的出现 带来了Java平台的很多新特性。 类加载器最根本的作用只有一个,即从包含字节代码的字节流中定义出虚拟机中的Class类的对象。得到Class类的对象之后,一个Java类 就可以在虚拟机中自由使用,包括创建新的对象或调用类中的静态方法。除了定义类这个最根本的功能之外,类加载器的其他功能都是围绕如 何找到类的字节代码展开的。类加载器也是Java平台中比较复杂难懂的一部分,尤其被使用在Web容器和OSGi环境中的时候。 9.1 类加载器概述 Java中的类加载器并不是以特殊的方式实现的。类加载器本身也是Java类。Java标准库中的java.lang.ClassLoader类是所有由Java代码创 建的类加载器的父类。通过调用类加载器中的loadClass方法可以加载Java类。由于类加载器本身也是Java类,因此类加载器自身的Java类需要 由另外的类加载器来加载。注意,类加载器只有自身被加载到虚拟机中之后才能加载其他的Java类。这似乎是一个无法解决的循环问题。实际 上,Java平台提供了一个启动类加载器(bootstrap class loader),由原生代码来实现。启动类加载器负责加载Java自身的核心类到虚拟机 中。在启动类加载器完成初始的加载工作之后,其他继承自ClassLoader类的类加载器可以正常工作。 一个Java类被加载之后,可以通过其对应的Class类的对象的getClassLoader方法来获取加载它的类加载器的对象。如果通过继承 ClassLoader类实现自己的类加载器,那么可以直接创建出类加载器类的对象。ClassLoader类提供的方法比较多,除了加载Java类之外,还能 加载相关的资源文件,比如图片和属性文件等。ClassLoader类中的一部分方法是声明为受保护的,只能在子类中使用。这些受保护的方法主要 是为创建自定义的类加载器而提供的。在使用ClassLoader类的对象来加载Java类时,使用loadClass方法即可。该方法的参数是Java类的二进 制名称,返回值是表示该Java类的Class类的对象。代码清单9-1给出了ClassLoader类的loadClass方法的使用示例。在代码中使用当前Java类 的类加载器来加载java.lang.String类。通过这种方式加载的Class类的对象与直接使用java.lang.String.class是一样的。 代码清单9-1 ClassLoader类的loadClass方法的使用示例 public void loadClass()throws Exception{ ClassLoader current=getClass().getClassLoader(); Class<?>clazz=current.loadClass("java.lang.String"); Object str=clazz.newInstance(); System.out.println(str.getClass());//输出java.lang.String } Java平台上的类加载器大概可以分成两类:启动类加载器和用户自定义的类加载器。两者的区别在于启动类加载器是由原生代码实现的, 而用户自定义的类加载器继承自ClassLoader类。在用户自定义的类加载器中,一部分类加载器由Java平台默认提供,另外一部分由程序自己创 建。Java平台默认提供的用户自定义类加载器有两个:第一个是扩展类加载器(extension class loader),用来从特定的路径加载Java平台 的扩展库;第二个是系统类加载器(system class loader),又称应用类加载器(application class loader),它的作用是根据应用程序运 行时的类路径(CLASSPATH)来加载Java类。如果程序中没有使用其他自定义的类加载器,则程序本身的Java类都由系统类加载器负责加载。通 过ClassLoader类的静态方法getSystemClassLoader可以得到系统类加载器对象。通过系统类加载器对象的getParent方法可以得到扩展类加载 器对象。 前面提到过,类加载器的根本作用是从字节代码中定义出表示Java类的Class类的对象。这个定义过程由ClassLoader类中的defineClass方 法来实现。如果一个Java类是由某个类加载器对象的defineClass方法定义的,则称这个类加载器对象是该Java类的定义类加载器(defining class loader)。当使用类加载器对象的loadClass方法来加载一个Java类时,称这个类加载器对象为该Java类的初始类加载器(initiating class loader)。一个Java类的初始类加载器和定义类加载器不一定相同。初始类加载器可能把实际的加载工作代理给其他类加载器,由后者 完成定义Java类的工作。初始类加载器和定义类加载器之间的关系是:一个Java类的定义类加载器是该类所引用的其他Java类的初始类加载 器。在通过调用类加载器对象的loadClass方法进行类的加载,并成功得到一个Class类的对象之后,虚拟机会把类加载器对象和它加载的Class 类的对象之间的关联记录下来。在加载Java类时,虚拟机会先检查是否存在与当前类加载器对象关联在一起的名称相同的Class类的对象。如果 存在,那么直接使用该Class类的对象,而不会重新进行加载。 9.2 类加载器的层次结构与代理模式 类加载器的一个重要特征是所有类加载器对象都可以有一个作为其双亲的类加载器对象。通过ClassLoader类的getParent方法可以获取双 亲类加载器对象。ClassLoader类提供的构造方法允许在创建时指定类加载器对象的双亲类加载器对象。不过ClassLoader类本身是抽象的,无 法直接创建出ClassLoader类本身的对象。自定义类加载器的Java类继承自ClassLoader类。在自定义类加载器的构造方法中应该调用父类 ClassLoader类的构造方法,并指定双亲类加载器对象的值。如果在创建时不指定双亲类加载器,则默认双亲类加载器是系统类加载器。如果当 前类加载器对象的双亲类加载器是启动类加载器,那么有些虚拟机实现会选择让getParent方法返回null,从而无法获取启动类加载器的对象引 用。虚拟机中的类加载器对象按照这种方式组织起来,形成一个树状层次结构。如果程序中大量使用自定义类加载器,这个层次结构会比较复 杂。类加载器也可以选择没有双亲类加载器。 如果不使用自定义类加载器,则一个Java程序运行时的类加载器通常有3个层次,由之前介绍过的Java平台上默认提供的类加载器形成,从 根节点开始依次是启动类加载器、扩展类加载器和系统类加载器。代码清单9-2给出了查看这些类加载器的层次结构的示例。 代码清单9-2 查看类加载器的层次结构的示例 public void displayParents(){ ClassLoader current=getClass().getClassLoader(); while(current!=null){ System.out.println(current.toString()); current=current.getParent(); } } 在Java SE 7的OpenJDK实现中运行此代码,输出的信息如代码清单9-3所示。输出的第一个类加载器对象是加载应用程序的系统类加载器, 由sun.misc.Launcher$AppClassLoader类表示。输出的第二个类加载器对象是扩展类加载器,由sun.misc.Launcher$ExtClassLoader类表示, 它是系统类加载器的双亲类加载器。由于扩展类加载器的双亲类加载器是启动类加载器,getParent方法返回为null,因此在代码清单9-3中没 有输出。 代码清单9-3 查看类加载器的层次结构的输出结果 sun.misc.Launcher$AppClassLoader@177b3cd sun.misc.Launcher$ExtClassLoader@1bd7848 类加载器在加载Java类时通常使用代理模式。代理模式指的是一个类加载器对象既可以自己完成Java类的定义工作,也可以代理给其他的 类加载器对象来完成。在ClassLoader类的默认实现中,当类加载器对象需要加载一个Java类或资源时,会先把加载请求代理给双亲类加载器对 象来完成。只有在双亲类加载器对象无法找到Java类或资源时,才由当前类加载器对象进行处理。这种代理关系会沿着类加载器对象的层次结 构树一直向上传递,直到成功加载一个类。在加载类的过程中,依靠双亲类加载器对象的原因是有些类的加载只有双亲类加载器对象才能完 成。在程序运行过程中,从类加载器层次结构树的根节点开始,不断有新的类加载器对象被添加进来。对于之后添加的类加载器对象来说,加 载类时所需的一些信息对当前类加载器对象来说是不可见的。对于这样的类的加载,只能代理给双亲类加载器对象来完成。不过当前类加载器 对象可以选择在不同的时机把加载请求代理给双亲类加载器对象。ClassLoader类的默认实现使用的是双亲优先的策略,即先由双亲类加载器对 象尝试加载,找不到的情况下再由当前类加载器对象来尝试加载。程序可以根据需要采用当前类加载器优先的策略,即先由当前类加载器尝试 加载,找不到的情况下再代理给双亲类加载器对象尝试加载;或者根据要加载的Java类的名称采取不同的查找策略。 在代码清单9-4中,NoParentClassLoader类在构造方法中设置双亲类加载器为null,在testLoad方法中尝试加载当前包中的一个Java类, 由于没有双亲类加载器对象可以代理加载类的请求,因此加载过程失败。 代码清单9-4 没有设置双亲类加载器对象的类加载器 public class NoParentClassLoader extends ClassLoader{ public NoParentClassLoader(){ super(null); } public void testLoad()throws ClassNotFoundException{ Class<?>clazz=loadClass("com.java7book.chapter9.ToLoad"); } } 9.3 创建类加载器 大部分Java程序在运行时并不需要使用自己的类加载器,依靠Java平台提供的3个类加载器就足够了。在绝大多数时候,也只有系统类加载 器发挥作用。如果程序对加载类的方式有特殊的要求,就要创建自己的类加载器。这样的实际应用场景有很多,主要可以分成两类:一类是对 Java类的字节代码进行特殊的查找和处理,如Java类的字节代码存放在磁盘上特定的位置或远程服务器上,或者字节代码的数据经过了加密处 理;另一类是利用类加载器产生的隔离特性来满足特殊的需求。 创建自定义类加载器只需继承ClassLoader类即可,可以选择覆写其中的某些方法来实现自定义的类加载逻辑。ClassLoader类中的 defineClass方法用来从字节代码中定义出表示Java类的Class类的对象。由于定义Java类涉及虚拟机的核心功能,从安全的角度出 发,defineClass方法被声明为final,不能由ClassLoader类的子类来覆写。一般来说,defineClass方法是由原生代码实现的。在ClassLoader 类中包含了不少声明为protected的方法,这些方法是创建自定义类加载器的基础。自定义类加载器通过覆写这些方法来实现特殊的功能。 第一个方法是loadClass。这个方法与ClassLoader类公开的loadClass方法同名,但是多一个表示是否对加载的类进行链接操作的参数。在 这个声明为protected的loadClass方法中封装了默认的双亲类加载器优先的代理模式的实现。默认的实现流程是进行下面的查找过程:先通过 findLoadedClass方法来检查该Java类是否已经被加载过,如果已经被加载过了,就直接返回之前加载过的Class类的对象;接着通过getParent 方法得到双亲类加载器对象,再调用双亲类加载器对象的loadClass方法,这一步是代理模式生效的地方,如果getParent方法返回为null,则 使用启动类加载器来进行加载;最后调用findClass方法由当前类加载器对象进行查找。这3步依次进行,如果在其中某一步的查找过程中找到 了Java类的定义,就返回定义的Class类的对象作为loadClass方法的返回值。如果尝试所有的步骤后仍然找不到Java类的定义,loadClass方法 就会抛出java.lang.ClassNotFoundException异常。如果调用loadClass方法的第二个参数值为true,即需要对找到的类进行链接操作,则 loadClass方法会调用resolveClass方法进行链接。 第二个方法是findLoadedClass。在类加载的过程中,虚拟机会记录下已经加载的Java类的初始类加载器。在findLoadedClass方法的实现 中,会查找已经加载的Java类,比较这些Java类的初始类加载器对象和要加载的Java类的名称。如果某个已经加载的Java类的初始类加载器是 当前类加载器对象,同时类的名称也与要加载的类的名称相同,就把该Java类对应的Class类的对象作为结果返回。 第三个方法是findClass。默认情况下,当通过代理模式无法使用双亲类加载器对象成功加载Java类时,findClass方法被调用。这个方法 主要用来封装当前类加载器对象自己的类加载逻辑。在一般的自定义类加载器中只需要覆写此方法即可。只有在需要改变默认的双亲优先代理 模式的类加载器中才需要覆写loadClass方法。ClassLoader类中的findClass方法只是简单抛出ClassNotFoundException异常。 最后一个方法是resolveClass。这个方法的作用是链接一个定义好的Class类的对象。链接的具体过程将在第10章进行介绍。 下面通过几个具体的示例来说明如何创建自定义类加载器。第一个示例是从磁盘上的特定目录加载Java类的字节代码的类加载器。代码清 单9-5中给出了完整的实现代码。在创建FileSystemClassLoader类的对象时,需要指定一个路径作为Java类字节代码所在的目录。在findClass 方法的实现中,先把要加载的类名转换成对应的class文件的路径,再读取class文件以得到字节代码的内容,最后通过defineClass方法来定义 Class类的对象。 代码清单9-5 从文件系统加载字节代码的类加载器 public class FileSystemClassLoader extends ClassLoader{ private Path path; public FileSystemClassLoader(Path path){ this.path=path; } protected Class<?>findClass(String name)throws ClassNotFoundException{ try{ byte[]classData=getClassData(name); return defineClass(name, classData,0,classData.length); }catch(IOException e){ throw new ClassNotFoundException(); } } private byte[]getClassData(String className)throws IOException{ Path classFilePath=classNameToPath(className); return Files.readAllBytes(classFilePath); } private Path classNameToPath(String className){ return path.resolve(className.replace('.',File.separatorChar)+".class"); } } FileSystemClassLoader类只是简单地读取了字节代码的内容,实际上可以在读取字节代码之后,调用defineClass方法之前进行很多操 作。例如,如果字节代码的内容是经过加密的,就需要在调用defineClass方法之前进行解密操作。另外,可以对字节代码应用第8章介绍的字 节代码增强技术来处理。 除了通过磁盘文件或网络方式加载已有的字节代码之外,还可以在类加载器中即时生成所需的字节代码。使用第8章介绍的ASM工具可以很 容易地实现这个功能。代码清单9-6中给出了一个动态生成字节代码的类加载器的实现。在GreetingClassLoader类的findClass方法中,使用 ASM工具生成了类的字节代码。在生成的Java类的构造方法中,添加了对System.out方法的调用来输出提示信息。 代码清单9-6 动态生成字节代码的类加载器 public class GreetingClassLoader extends ClassLoader implements Opcodes{ private String message; public GreetingClassLoader(String message){ this.message=message; } protected Class<?>findClass(String name)throws ClassNotFoundException{ byte[]classData=generateClassData(name); return defineClass(name, classData,0,classData.length); } private byte[]generateClassData(String className){ className=className.replaceAll("\\.","/"); ClassWriter writer=new ClassWriter(0); writer.visit(V1_7,ACC_PUBLIC+ACC_SUPER, className, null,"java/lang/Object",null); MethodVisitor mv=writer.visitMethod(Opcodes.ACC_PUBLIC,"<init>","()V",null, null); mv.visitCode(); mv.visitVarInsn(ALOAD,0); mv.visitMethodInsn(INVOKESPECIAL,"java/lang/Object","<init>","()V"); mv.visitFieldInsn(Opcodes.GETSTATIC,"java/lang/System","out","Ljava/io/PrintStream;"); mv.visitLdcInsn(message); mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL,"java/io/PrintStream","println","(Ljava/lang/String;)V"); mv.visitInsn(RETURN); mv.visitMaxs(2,1); mv.visitEnd(); writer.visitEnd(); return writer.toByteArray(); } } 前面介绍的两个示例并没有覆写ClassLoader类中的loadClass方法中的逻辑,而是覆写了findClass方法。在这两个示例中,ClassLoader 类默认使用的双亲优先的代理模式是有效的。如果希望改变这种默认的代理模式,那么可以覆写loadClass方法。在某些情况下,可能需要优先 使用当前类加载器对象进行查找,再考虑使用双亲类加载器对象进行查找。代码清单9-7给出了这种当前类加载器对象优先的代理模式的实现。 在ParentLastClassLoader类的loadClass方法中仍然先通过findLoadedClass方法来查找已经加载的Java类。这一步通常是必需的。接着调用 findClass方法优先由当前类加载器对象进行查找。最后再代理给双亲类加载器对象来进行查找。 代码清单9-7 当前类加载器对象优先的类加载器 public class ParentLastClassLoader extends ClassLoader{ protected Class<?>loadClass(String name, boolean resolve)throws ClassNotFoundException{ Class<?>clazz=findLoadedClass(name); if(clazz!=null){ return clazz; } clazz=findClass(name); if(clazz!=null){ return clazz; } ClassLoader parent=getParent(); if(parent!=null){ return parent.loadClass(name); } else{ return super.loadClass(name, resolve); } } } 9.4 类加载器的隔离作用 类加载器的一个重要特性是为它所加载的Java类创建了隔离空间,相当于添加一个新的名称空间。要理解这一点,先来说明Java虚拟机如 何判断两个Java类是否相等,即两个Class类的对象是否相等。如果两个对象的类不同,并且两个类之间不存在父类型与子类型的关系,那么在 它们之间进行赋值等操作会抛出java.lang.ClassCastException异常。Java虚拟机需要根据两个条件进行判断:一个是Class类的对象表示的 Java类的全名是否相同,另一个是Class类的对象的定义类加载器对象是否相同。这两个条件缺一不可。第一个条件很容易理解,如果类的名称 不相同,那么它们不可能表示同一个类。第二个条件则比较难理解。在进行Class类的对象的相等性判断中,它们的定义类加载器是非常重要 的。相同的字节代码如果由不同的类加载器对象来加载并定义,所得到的Class类的对象是不相等的。 下面通过一个具体的示例来说明类加载器对Class类的对象的相等性的影响。代码清单9-8给出了一个简单的Java类Sample。Sample类中包 含了一个setSample方法。在这个方法中,把作为参数传入的Object类的对象强制类型转换成Sample类的对象。如果类型转换时出现 ClassCastException异常,就说明参数对象的Java类与当前的Sample类不相等。 代码清单9-8 用来说明Class类的对象相等性判断方式的示例Java类 public class Sample{ private Sample obj; public void setSample(Object obj){ this.obj=obj; } } 对Sample类进行编译之后,将得到的class文件放在某个目录中,用代码清单9-5中的FileSystemClassLoader类的对象进行加载,如代码清 单9-9所示。测试方式是使用两个不同的FileSystemClassLoader类的对象来分别加载Sample类的字节代码,并定义出对应的Java类。然后从 Java类中创建出新的对象,用一个对象作为参数调用另外一个对象上的setSample方法。实际运行的结果是抛出ClassCastException异常。虽然 是从同样的字节代码中创建出来的同名Java类,但是由于定义它们的类加载器对象不同,这两个Class类的对象仍然是不相等的。 代码清单9-9 Class类的对象的相等性测试 public class ClassIdentity{ public void test()throws Exception{ Path path=Paths.get("classData"); FileSystemClassLoader fscl1=new FileSystemClassLoader(path); FileSystemClassLoader fscl2=new FileSystemClassLoader(path); String className="com.java7book.chapter9.Sample"; Class<?>class1=fscl1.loadClass(className); Object obj1=class1.newInstance(); Class<?>class2=fscl2.loadClass(className); Object obj2=class2.newInstance(); Method setSampleMethod=class1.getMethod("setSample",java.lang.Object.class); setSampleMethod.invoke(obj1,obj2);//抛出ClassCastException异常 } } 在很多场合都可以利用类加载器的这个特性为虚拟机中同名的Java类创建一个隔离的名称空间,使同名的Java类可以在虚拟机中共存。同 名Java类需要共存的一个典型场景是程序的版本更新。一个程序可能存在多个不同的版本。用户既希望使用新版本的程序,又希望基于旧版本 的代码可以继续运行。这就要求两个版本的Java类在虚拟机中同时存在。如果不使用自定义类加载器来划分名称空间,就只能让新旧版本的 Java类使用不同的名称,比如在正常的类名后添加类似“V1”和“V2”等后缀进行标识。这种做法使用起来很不方便。使用自定义的类加载器 就可以仍然使用相同名称的Java类,在实现时使用不同的类加载器对象来进行加载不同版本的Java类。 在一般的程序版本更新中,会保持接口不变,只修改接口的后台实现。如果接口发生变化,那么客户端代码要做出比较大的修改。这里以 接口不变的版本更新为例进行说明。代码清单9-10中给出了一个简单的接口Versionized。 代码清单9-10 用来说明版本更新方式的接口示例 public interface Versionized{ String getVersion(); } 该接口的实现可能随着版本更新而发生变化。客户端代码通过一个工厂方法来获取该接口的具体实现。代码清单9-11给出了这个工厂方法 的实现。在工厂方法中不是简单地创建出所需的具体实现的对象,而是创建一个新的类加载器对象。由类加载器对象先加载Java类,再创建出 相应的接口实现对象。在具体的实现中,不同版本的Java类的字节代码存放在不同的路径中。使用代码清单9-5中的FileSystemClassLoader类 的对象来加载所需版本的Java类。 代码清单9-11 获取接口实现对象的工厂方法 public class ServiceFactory{ public static Versionized getService(String className, String version)throws Exception{ Path path=Paths.get("service",version); FileSystemClassLoader loader=new FileSystemClassLoader(path); Class<?>clazz=loader.loadClass(className); return(Versionized)clazz.newInstance(); } } 代码清单9-12中给出了代码清单9-11中getService方法的使用方式。Versionized接口的不同版本的实现使用相同的Java类名。由于类加载 器带来的隔离作用,这些同名的Java类可以共存在虚拟机中。用户可以根据所需的版本号找到对应的Java类。 代码清单9-12 不同版本的接口实现对象的使用示例 public class ServiceConsumer{ public void consume()throws Exception{ String serviceName="com.java7book.chapter9.SampleService"; Versionized v1=ServiceFactory.getService(serviceName,"v1"); Versionized v2=ServiceFactory.getService(serviceName,"v2"); } } 9.5 线程上下文类加载器 线程上下文类加载器(context class loader)是在J2SE 1.2中引入的概念。Java中表示线程的java.lang.Thread类中有两个方法用来获 取和设置当前线程的上下文类加载器。这两个方法分别是getContextClassLoader和setContextClassLoader,其中setContextClassLoader方法 接受一个ClassLoader类的对象作为参数。当前线程中运行的代码可以使用该线程的上下文类加载器来加载Java类和资源文件。如果一个线程在 创建之后没有显式地设置其上下文类加载器的值,则使用其父线程的上下文类加载器对象作为自身的上下文类加载器对象。程序启动时的第一 个线程的上下文类加载器默认是Java平台的系统类加载器对象。因此,在默认情况下,通过当前线程的getContextClassLoader方法获取的类加 载器对象和使用当前类的getClassLoader方法得到的类加载器对象是相同的,二者均为系统类加载器。 线程上下文类加载器提供了一种直接的方式在程序的各部分之间共享ClassLoader类的对象。当在程序中需要使用类加载器来加载类或资源 时,有几种做法可以获取需要使用的ClassLoader类的对象。第一种做法是创建新的ClassLoader类的对象,这种做法使用的场合比较少。第二 种做法是使用加载当前Java类的ClassLoader类的对象。第三种做法是使用Java平台提供的系统类加载器或扩展类加载器。这三种做法都无法简 单地满足某些场景的需要。比如,存在两个互相关联的Java类A和B,这两个类必须由同一个类加载器对象来加载,否则会出现内部错误。满足 这个需求的做法是用加载类A的ClassLoader类的对象去加载类B。如果使用前面提到的做法,就需要提供额外的方式在加载类A和类B的代码之间 传递ClassLoader类的对象。这会带来附加的复杂性。只要加载类A和类B的代码在同一个线程中运行,使用线程上下文类加载器是最简单的做 法。在加载类A时,获取当前使用的ClassLoader类的对象,调用当前线程对象的setContextClassLoader方法把线程上下文类加载器设置为该 ClassLoader类的对象。在加载类B的时候,通过当前线程对象的getContextClassLoader方法来得到之前保存的ClassLoader类的对象,再进行 加载即可。 线程上下文类加载器的重要作用是解决Java平台的服务提供者接口(service provider interface, SPI)带来的类加载相关的问题。Java 平台上的很多规范都是以SPI的形式出现的,比如数据库访问规范JDBC和XML处理规范JAXP等。这些SPI的特点是Java平台只提供接口和部分辅助 Java类,接口的具体实现由规范的实现者提供。以JDBC规范为例来说,相关的接口声明在java.sql和javax.sql包中,例如 java.sql.Connection接口表示一个数据库连接。而Connection接口的具体实现类由数据库驱动来提供。SQL Server、MySQL和Apache Derby等 数据库都有自己对应的JDBC接口的实现类。SPI接口通常会提供一些工厂方法来创建接口的具体实现对象。在第2章介绍Java的脚本语言支持API 时提到的javax.script.ScriptEngineManager类就是一个典型的例子。ScriptEngineManager类用来管理当前程序中可用的脚本执行引擎。脚本 语言开发者可以实现javax.script.ScriptEngine接口,使开发人员可以通过脚本语言支持API来使用这种脚本语言。当开发人员下载了该脚本 语言对应的ScriptEngine接口的实现,并将其添加到程序的类路径(CLASSPATH)中之后,新的脚本执行引擎对ScriptEngineManager类来说必 须是可见的,从而允许开发人员通过工厂方法得到对应的ScriptEngine接口的实现对象。脚本引擎在注册时只提供了类名。 ScriptEngineManager类的对象为了能够提供ScriptEngine接口的具体实现对象,需要加载对应的Java类并创建出新的对象。 这里存在的问题是使用代理模式无法完成SPI实现类的加载。SPI接口相关的类本身作为Java标准库的一部分,是由启动类加载器来加载 的。也就是说,加载ScriptEngineManager类的是启动类加载器。在ScriptEngineManager类的实现中需要查找程序的CLASSPATH来找到SPI的实 现类,并创建出相应的对象。程序的CLASSPATH中的类一般是由系统类加载器负责加载的,启动类加载器无法完成相关的加载工作。而启动类加 载器又无法把加载的工作代理给系统类加载器来完成,因为启动类加载器是系统类加载器的祖先。在理论上,可以在启动类加载器内部保存一 个系统类加载器对象的引用,在加载SPI实现类时代理给系统类加载器来完成。但是在某些情况下,SPI实现类可能并不出现在CLASSPATH中,而 是需要由自定义的类加载器对象来完成加载。启动类加载器是无法处理这种情况的。 为了解决这个问题,ScriptEngineManager类的对象在加载SPI实现类时,使用的是线程上下文类加载器。在默认情况下,程序运行时的线 程上下文类加载器是系统类加载器,这样可以加载CLASSPATH中出现的SPI实现类。如果需要使用自定义类加载器来加载SPI实现类,可以把当前 线程的上下文类加载器设置成能够加载到SPI实现类的类加载器对象。如果在程序运行中改变了线程上下文类加载器的值,可能会造成SPI的实 现类无法加载。 9.6 Class.forName方法 熟悉Java EE开发的开发人员对于Class.forName方法应该并不陌生。在Java EE开发中,Class.forName方法的一个典型应用是加载使用 JDBC操作数据库时的数据库驱动。比如,当需要加载Apache Derby数据库的嵌入式驱动时,可以使用代码 Class.forName("org.apache.derby.jdbc.EmbeddedDriver")。不过这种做法从JDBC 4.0开始就不再需要了,因为java.sql.DriverManager类 支持了使用服务发现机制来自动查找可用的数据库驱动。 Class. forName方法的作用是根据Java类的名称得到对应的Class类的对象。该方法有两种重载形式。第一种是使用3个参数的复杂形式。3 个参数依次表示Java类的名称、是否初始化Java类,以及用于加载Java类的类加载器对象。如果第3个参数的值为null,则使用启动类加载器来 进行加载。第二种是只使用一个参数的简单形式,相当于第一种重载形式中的第2个和第3个参数的值分别是true和 this.getClass().getClassLoader()。 Class. forName方法与ClassLoader类的重要区别在于Class.forName方法可以初始化Java类,而ClassLoader类的对象是不行的。这也是 Class.forName方法的优势所在。初始化Java类意味着Java类中的静态变量会被初始化,同时静态代码块也会被执行。代码清单9-13给出了一个 简单的包含静态代码块的Java类。在该类被初始化时,会在控制台输出提示信息。 代码清单9-13 包含静态代码块的示例Java类 public class ClassForNameTest{ static{ System.out.println("初始化"); } } 代码清单9-14展示了分别使用Class.forName方法和ClassLoader类来加载代码清单9-13中的ClassForNameTest类时的不同之处。在调用 Class.forName方法时会输出与初始化类相关的提示信息,而调用ClassLoader类的loadClass方法则不会。 代码清单9-14 Class.forName方法与ClassLoader类在加载类时的不同之处 public void classForNameVsLoader()throws ClassNotFoundException{ String className="com.java7book.chapter9.ClassForNameTest"; Class<?>clazz1=Class.forName(className); ClassLoader loader=this.getClass().getClassLoader(); Class<?>clazz2=loader.loadClass(className); } 这也是为什么在JDBC 4.0之前需要使用Class.forName方法来加载数据库驱动的Java类的原因。在不支持驱动的自动发现之前,在数据库驱 动类的静态代码块中可以添加必要的驱动注册和初始化的逻辑,在驱动的Java类被初始化时,完成相关的处理。 9.7 加载资源 类加载器除了可以加载Java类之外,还可以加载与Java类相关的资源文件,如文本文件、图片文件和音频文件等。Java程序在运行过程中 需要访问这些资源文件的内容。每个资源文件通过名称来标识。资源名称可以由多个部分组成,每个部分之间用“/”分隔,如一个图片文件的 资源名称可以是“resources/images/logo.gif”。资源名称的表示形式是平台无关的,由具体的实现负责把平台无关的抽象资源名称映射到实 际的资源保存方式上。如果资源是保存在文件系统上的,通常是映射到资源文件的路径,而资源名称中每个用“/”分隔的部分代表一个目录。 这些资源文件通常与class文件保存在同一个目录下,或者同一个jar包中。 使用类加载器来加载资源的好处是可以解决资源文件存放时路径不固定的问题。比如,一个Java类在运行时需要读取保存配置信息的属性 文件的内容,如果使用Java的文件操作API来读取,就要知道属性文件的绝对路径。Java类的class文件与属性文件的相对位置虽然固定,但是 它们所在的绝对路径是不确定的。通过文件操作API无法保证总是能正确地找到文件,通过类加载器提供的加载资源的方法则可以正确地加载到 相关的资源。 ClassLoader类中负责加载资源的方法是getResource和getResourceAsStream。根据资源名称,前者得到表示资源路径的java.net.URL类的 对象,后者得到用来读取资源内容的java.io.InputStream类的对象。从实现上来说,getResourceAsStream方法在内部先调用getResource方法 得到URL类的对象,再调用该对象的openStream方法获取InputStream类的对象。一般对资源进行读取操作时,getResourceAsStream方法的使用 频率较高。在进行查找时,getResource方法会先检查当前类加载器的双亲类加载器是否为null。如果不为null,则调用双亲类加载器的 getResource方法来进行查找;如果为null,则通过启动类加载器来查找。如果找不到对应的资源,则调用ClassLoader类的findResource方法 进行查找。这种实现方式类似于ClassLoader类在加载Java类时默认的双亲优先的代理模式。在ClassLoader类中声明为protected的 findResource方法的作用类似于findClass方法。如果类加载器有自定义的资源查找机制,那么需要覆写此方法。 除了getResource方法之外,ClassLoader类中还有一个相关的getResources方法。这个方法会根据资源名称返回所有具有该名称的资源文 件。该方法的返回值是一个可以遍历所有查找结果的java.util.Enumeration类的对象。在实现上,getResources方法的机制与getResource方 法是一样的,都是先通过双亲类加载器进行查找,只不过getResources方法会完成整个查找过程来搜索所有满足条件的资源,而不是像 getResource方法一样查找到第一个满足条件的结果就返回。ClassLoader类中也有与findResource方法作用相似的findResources方法,用来与 getResources方法配合使用。在某些情况下,可能会需要查找在不同位置上的所有同名资源,此时可以使用getResources方法。 当需要使用系统类加载器来加载资源时,可以直接使用ClassLoader类中的静态方法getSystemResource、getSystemResourceAsStream和 getSystemResources。这3个方法在实现上是先得到系统类加载器,再调用系统类加载器的对应方法来进行加载。如果当前系统类加载器为 null,则通过启动类加载器来进行加载。代码清单9-15给出了使用类加载器来加载属性文件的示例。 代码清单9-15 使用类加载器来加载属性文件的示例 public class LoadResource{ public Properties loadConfig()throws IOException{ ClassLoader loader=this.getClass().getClassLoader(); InputStream input=loader.getResourceAsStream("com/java7book/chapter9/config.properties"); if(input==null){ throw new IOException("找不到配置文件。"); } Properties props=new Properties(); props.load(input); return props; } } 在使用类加载器的getResource和getResourceAsStream方法时,需要注意的是使用正确的资源名称。代码清单9-15给出了资源名称的通常 表示形式,即根据资源文件所在的Java包名来确定。配置文件config.properties在com.java7book.chapter9包中,把包名中的“.”变 成“/”之后再加上文件的文件名就得到了资源名称。资源名称需要根据资源文件所在的包名进行调整。 除了使用ClassLoader类中的方法来加载资源之外,Class类中也有相关的方法来加载资源。Class类中的方法与ClassLoader类中的方法在 名称上是相同的,分别是getResource和getResourceAsStream。Class类中的这两个方法的实现是这样的,先通过getClassLoader方法得到加载 当前类的ClassLoader类的对象,再通过ClassLoader类的对象的对应方法来进行加载。如果getClassLoader方法的返回值为null,则使用 ClassLoader类中的getSystemResource和getSystemResourceAsStream方法来进行加载,相当于使用系统类加载器来进行加载。不过Class类中 的方法在调用ClassLoader类的对应方法之前,会进行资源名称的转换。如果资源名称以“/”开头,则会去掉开头的“/”;否则自动在资源名 称前加上Class类的对象所在的包的名称。对于代码清单9-15的示例,如果使用代码“this.getClass().getResourceAsStream”来进行加 载,那么可以直接使用资源名称“config.properties”,而不需要加上前面的包名,包名的添加工作由Class类来完成。使用Class类中的加载 资源的方法比使用ClassLoader类中的相关方法要实用一些。在重构的过程中,可能对包含资源文件的包名进行了修改。如果使用ClassLoader 类中的方法,则需要手动修改加载时使用的资源名称,而使用Class类中的方法则不需要进行修改。 9.8 Web应用中的类加载器 类加载器在基于Java EE技术实现的Web容器中有着非常重要的作用。在一个Web容器中通常运行着很多个Web应用。这些应用之间是互相隔 离的,互不影响,但又都依赖于Web容器提供的功能,运行在同一个Java虚拟机之上。这种受管理的隔离方式是通过类加载器来实现的。典型的 做法是每个Web应用使用自己的类加载器对象来加载应用中包含的Java类和资源。不同的Web应用中相同名称的Java类可以共存于虚拟机中。比 较典型的场景是对Web应用中使用的第三方库的处理。不同Web应用在开发中可能使用同样的第三方库,但是使用的库的版本可能不同。如果没 有进行隔离,那么所有Web应用都会引用某一个版本的库中的Java类。某些应用可能会因为使用了错误版本的库而出现错误。 在Java EE中的Servlet规范中给出了Web应用的类加载器的实现方式的推荐做法,即对默认的双亲优先的代理模式进行修改,改为使用当前 类加载器优先的方式。这种做法的出发点是解决Web应用中的第三方库和容器本身使用的第三方库的冲突问题。如果采用双亲优先的方式,那么 容器中提供的第三方库的Java类会被优先加载。Web应用可能使用了同样的库,但是版本与容器提供的并不兼容,这会造成Web应用出现错误。 通过提高Web应用本身的Java类和第三方库的优先级,可以避免这个问题。一般的Web容器都遵循Servlet规范中的推荐做法。有些应用服务器允 许Web应用在双亲优先和当前类加载器优先这两种策略中进行选择。 下面通过具体的Apache Tomcat 7.0示例分析来说明Web容器中类加载器是如何工作的。Tomcat是一个使用很广泛的Web容器,也是开放源代 码的。Tomcat中的Web应用对应的类加载器是org.apache.catalina.loader.WebappClassLoader类的对象。WebappClassLoader类继承自标准库 中的java.net.URLClassLoader类。在类加载器中,最重要的是loadClass方法的实现。WebappClassLoader类中loadClass方法的实现按照下面 几个步骤尝试加载Java类。 1)调用findLoadedClass来查找该Java类是否已经被加载过。一般的类加载器都会进行这样的检查,可以避免不必要的查找过程。 2)调用系统类加载器的loadClass方法来尝试加载类。这是为了避免Web应用覆盖Java标准库中的类。 3)WebappClassLoader类不一定会把加载类的请求代理给双亲类加载器。在两种情况下会进行代理。第一种情况是使用setDelegate方法把 代理模式打开之后。在默认情况下代理模式是关闭的。第二种情况是要加载的Java类的名称满足一定的条件,比如名称以“javax.servlet”开 头的Servlet API相关的Java类是由双亲类加载器来加载的。 4)调用findClass方法来查找Web应用本身的Java类。 5)如果在第3)步中没有把加载类的请求代理给双亲类加载器,则在这一步中进行。从第4)和第5)步的顺序可以看 出,WebappClassLoader类使用的是当前类加载器优先的策略。 对于Web应用本身的Java类,Tomcat是按照划分成不同仓库的方式来进行管理的。每个仓库表示一个Java类的存放位置。仓库分成外部仓库 和内部仓库两种。每个外部仓库对应一个URL类的对象,表示一个加载类时的查找路径。由于WebappClassLoader类继承自URLClassLoader类, 通过URLClassLoader类中的addURL方法可以添加新的外部仓库。内部仓库则指的是Web应用本身的WEB-INF目录下的classes和lib目录。这两个 标准目录分别用来存放Web应用的class文件和使用的第三方库的jar包。WebappClassLoader类的对象可以配置在查找时是否优先外部仓库。在 WebappClassLoader类的findClass方法中的查找过程如下: 1)如果存在外部仓库并且配置了优先查找外部仓库,则先调用双亲类加载器对象的findClass方法进行查找。 2)在内部仓库中进行查找。 3)如果存在外部仓库并且配置了不优先查找外部仓库,则说明第1)步没有执行。此时调用双亲类加载器对象的findClass方法来进行查 找。 在Web应用开发中,每个Web应用自己的Java类文件和使用的库的jar包,分别放在WEB-INF目录下的classes和lib目录下。多个应用共享的 Java类文件和jar包,则放在Web容器指定的由所有Web应用共享的目录下面。通过这种标准的方式,可以避免一些常见的类加载相关的错误。 9.9 OSGi中的类加载器 OSGi是Java平台上的动态模块系统,它为开发人员提供了面向服务和基于组件的运行环境,并提供标准的方式来管理软件的生命周期。 OSGi已经被部署在很多产品上,在开源社区也得到了广泛的支持。Eclipse平台是基于OSGi技术来构建的。在OSGi技术的实现中,类加载器扮演 了非常重要的作用。 9.9.1 OSGi基本的类加载器机制 OSGi中最基本的组成部分是模块(bundle)。每个模块作为一个独立的组件,完成特定的功能,并对外提供服务。每个模块由Java类和所 需的资源文件组成,以jar包的形式出现。每个模块既可以是服务的提供者,又可以是服务的消费者。从服务提供者的角度来说,一个模块中的 Java类可以被其他模块使用;从服务消费者的角度来说,一个模块为了完成其功能,可能需要使用其他模块提供的Java类。对于一个模块中包 含的Java类来说,一部分Java类作为对外提供的服务,对其他模块是可见的;而另外一部分Java类则作为模块的内部实现,对其他模块是不可 见的。每个模块的Java类相当于存在于一个受管理的隔离空间中。对这个隔离空间的管理由OSGi实现中的类加载器来完成。 下面通过3个相互依赖的模块来说明OSGi中类加载器机制的作用。这3个模块的作用是实现并使用一个简单的计算器程序。其中 calculator.common模块中包含实现通用功能的Java类,calculator.impl模块中包含计算器程序的具体实现类,calculator.user模块中包含使 用计算器程序的Java类。从依赖关系上说,calculator.impl模块依赖于calculator.common, calculator.user模块依赖于calculator.impl。 OSGi中每个模块使用清单文件声明自己所依赖的需要导入的来自其他模块的Java包的名称,也可以声明自己提供出来的可供其他模块使用的 Java包的名称。代码清单9-16给出了calculator.impl模块的清单文件中与依赖关系相关的部分。 代码清单9-16 OSGi模块的清单文件中与依赖关系相关的部分 Export-Package:com.java7book.calculator.impl Import-Package:com.java7book.calculator.common 在清单文件中通过Import-Package属性声明了calculator.impl模块需要使用com.java7book.calculator.common包,通过Export-Package 属性声明了calculator.impl模块内部的com.java7book.calculator.impl包对其他模块是可见的。在声明了依赖关系之后,calculator.impl模 块中的代码可以使用com.java7book.calculator.common包中的Java类。 9.9.2 Equinox框架的类加载实现机制 目前存在不少OSGi规范的实现,这里使用Eclipse Equinox作为介绍时的OSGi实现,运行环境在Eclipse IDE中。每个模块在运行时都会有 一个对应的类加载器对象。由这个类加载器对象负责加载模块本身包含的Java类和资源。Eclipse Equinox在启动模块时使用 org.eclipse.osgi.internal.loader.BundleLoader类的对象来加载一个模块。每个模块都有自己对应的BundleLoader类的对象。BundleLoader 类的对象中都封装了一个类加载器对象来加载模块中的Java类。这个类加载器对象需要实现 org.eclipse.osgi.framework.adaptor.BundleClassLoader接口,并且继承自ClassLoader类。默认的类加载器实现类是 org.eclipse.osgi.internal.baseadaptor.DefaultClassLoader。DefaultClassLoader类的对象在创建时需要提供一个 org.eclipse.osgi.framework.adaptor.ClassLoaderDelegate接口的实现对象。ClassLoaderDelegate接口表示的是实际完成类加载工作的代理 对象,其中声明了用来加载Java类的findClass方法。DefaultClassLoader类在其loadClass方法的实现中,只是简单地把加载请求代理给其中 包含的ClassLoaderDelegate接口的实现对象的findClass方法。DefaultClassLoader类的对象本身并不会尝试去加载类,也不像其他类加载器 一样会把加载请求代理给双亲类加载器对象。这是另外一种形式的代理模式。BundleLoader类本身实现了ClassLoaderDelegate接口,所以 BundleLoader类既负责创建加载Java类时使用的BundleClassLoader接口的实现,又负责完成具体的类加载任务。当模块中的代码需要加载Java 类时,可以通过代码“this.getClass().getClassLoader()”来得到加载当前类的类加载器对象。 BundleLoader类中负责加载Java类的findClass方法中封装了比较复杂的Java类的查找机制。对于以“java.”开头的Java类,会直接代理 给双亲类加载器对象来完成。对于其他的Java类,则在模块内部和所导入的包中进行查找。基本的类查找步骤如下: 1)检查要加载的Java类是否出现在被配置为由双亲类加载器对象负责加载的包名列表中。如果是,则直接代理给双亲类加载器对象来完 成。通过OSGi框架的属性“org.osgi.framework.bootdelegation”可以配置这个列表中包含的包名。这个步骤的作用是允许某些Java类被双亲 类加载器对象来加载。 2)搜索该模块中通过Import-Package属性声明导入的Java包的列表。如果找到,由提供该Java包的模块对应的BundleLoader类的对象负责 该Java类的加载。 3)搜索该模块中通过Require-Bundle属性声明所依赖的其他模块的列表。如果找到可以提供该Java类的模块,则由该模块对应的 BundleLoader类的对象来负责加载该Java类。 4)在模块内部包含的Java类中进行查找。 5)搜索模块中通过DynamicImport-Package属性声明动态导入的Java包列表。如果找到,则由提供该Java包的模块对应的BundleLoader类 的对象负责加载该Java类。 6)如果仍然找不到Java类,在某些情况下代理给双亲类加载器对象进行加载。 从上面的类加载流程可以看出,BundleLoader类在尝试加载Java类时主要依赖两个外部来源来代理类加载的请求,一个是 BundleClassLoader接口实现对象的双亲类加载器,另外一个是其他模块对应的BundleLoader类的对象。BundleClassLoader接口的实现类可以 选择不同的双亲类加载器。可以通过OSGi框架的属性配置使用不同的双亲类加载器选择方式。默认的双亲类加载器是Java平台的启动类加载 器。还可以选择使用加载OSGi框架本身的类加载器对象、系统类加载器或扩展类加载器作为BundleClassLoader接口的实现对象的双亲类加载 器。模块calculator.impl导入了calculator.common导出的com.java7book.calculator.common包。当在calculator.impl模块中使用 com.java7book.calculator.common包中的Java类时,实际的类加载工作是由calculator.common模块对应的BundleLoader类的对象来完成的。 通过这种代理模式,在一个模块中只有通过Export-Package属性声明的Java包才对其他模块可见。如果一个模块A中的某个Java包没有包含在 Export-Package属性声明的列表中,而另外一个模块B又试图引用该Java包中的类,模块B会找不到对应的类。因为该Java类没有出现在模块A的 Export-Package属性声明的Java包列表中,不会考虑代理给模块A来进行查找,从而无法找到该Java类。 在OSGi运行环境中,如果在模块中使用SPI的实现类,可能会出现问题,因为SPI的代码中通常使用线程上下文类加载器来加载SPI接口的实 现类。在一般的运行环境中,SPI的实现类是可以从程序运行时的CLASSPATH中找到的。但是在OSGi运行环境中,SPI的实现类一般作为模块所依 赖的库出现在模块本身的CLASSPATH中。如果在程序的其他部分对当前线程的上下文类加载器进行了修改,有可能造成SPI的实现类无法被加 载。这个时候可以使用当前线程的setContextClassLoader方法把线程上下文类加载器设置成加载模块Java类的类加载器对象,从而保证可以加 载模块的CLASSPATH中的SPI实现的Java类。代码清单9-17给出了通过修改线程上下文类加载器来加载SPI实现类的一般做法。在完成加载之后, 要把线程上下文类加载器的值恢复为之前的值,以免对程序的其他部分造成影响。 代码清单9-17 通过修改线程上下文类加载器来加载SPI实现类的一般做法 ClassLoader oldContextLoader=Thread.currentThread().getContextClassLoader(); Thread.currentThread().setContextClassLoader(this.getClass().getClassLoader()); //加载SPI实现类 Thread.currentThread().setContextClassLoader(oldContextLoader); 在OSGi中使用这种类加载器的实现方式,可以对一个模块中的Java类按照所在的包设置其可见性,从而带来了访问控制上的灵活性。不过 这种方式也可能在实际开发中带来一些麻烦,尤其在使用第三方库或嵌入到其他容器中时。某些第三方库或容器可能同样使用了复杂的类加载 器实现,会与OSGi框架本身的类加载器实现交织在一起,造成难以解决的问题。在OSGi模块中使用第三方库时,可以考虑下面的建议: 1)如果一个库只被一个模块使用,把该库的jar包放在模块中,并在模块清单文件中使用Bundle-ClassPath属性进行声明。 2)如果一个库被多个模块共用,则可以为这个库创建一个新的模块。对于库中的Java包,如果其他模块会用到,那么在清单文件中通过 Export-Package属性进行声明。其他模块只需要通过Import-Package属性声明所需要的包即可。 3)如果出现了找不到Java类的情况,则检查当前线程的上下文类加载器是否正确。一般可以通过把当前线程的上下文类加载器设置为模块 的类加载器对象来解决这个问题。 9.9.3 Equinox框架嵌入到Web容器中 Eclipse Equinox框架支持被嵌入到Web容器中来使用。可以在servlet容器中启动一个Equinox框架,用来处理servlet请求。通过这种方 式,既可以把OSGi技术应用到复杂的Web应用开发中,又可以复用已有的servlet容器的相关资产。这种实现方式是通过一个特殊的servlet来处 理请求,并转发给Equinox框架来处理。当在servlet容器中嵌入Equinox框架时,会出现一些与类加载器相关的问题。这是因为servlet容器和 OSGi框架内部都采用了比较复杂的类加载器实现,当它们两个在一起共同使用时,会出现无法加载Java类的情况。 以之前提到的计算器程序中的OSGi模块为例,把这些模块嵌入到servlet容器中来运行。在计算器程序的内部实现中,使用脚本语言支持 API来进行运算表达式的求值。使用的脚本语言不是Java平台默认支持的JavaScript,而是Ruby。为了能够使用Ruby语言,在下载了JRuby的库 之后,将其放在合适的位置以被脚本语言支持API所识别。脚本语言支持API使用线程上下文类加载器来查找不同脚本引擎的实现类。因此JRuby 库的jar包需要放在可以被线程上下文类加载器查找到的位置。在一般情况下,只需要把jar包放在OSGi模块的某个目录下,并在清单文件中通 过Bundle-ClassPath属性声明即可。不过在这个示例中,除了OSGi框架之外,Web应用的其他部分也需要使用JRuby库,这就要求在Web应用的 WEB-INF下的lib目录中也要有JRuby的jar包。在同一个Web应用中包含两个相同的jar包会在后期的维护中带来麻烦。更好的做法是把JRuby的 jar包放在Web应用的WEB-INF下的lib目录中,在OSGi模块中引用这个jar包,这要求在OSGi模块运行时的线程上下文类加载器能够加载到 servlet容器中lib目录中的jar包。可以在脚本语言支持API的ScriptEngineManager类的对象查找到可用的脚本引擎之前,把线程上下文类加载 器设置成加载Web应用的类加载器对象,这样就可以查找到WEB-INF下的lib目录中的jar包。 首先需要获取Web应用的当前类加载器。Web应用的类加载器对象用来加载servlet实现类,只需要通过servlet实现类的getClassLoader方 法就可以获取。Equinox框架使用org.eclipse.equinox.servletbridge.BridgeServlet类的对象来接受HTTP请求并转发给OSGi框架中的servlet 来处理。为了能够在OSGi模块中使用Web应用的类加载器对象,需要继承BridgeServlet类并提供保存ClassLoader类的对象的功能。代码清单9- 18给出了对应实现的代码。在CustomBridgeServlet类的service方法实现中,在处理servlet请求之前,把Web应用的类加载器对象保存在表示 HTTP请求的HttpServletRequest类的对象中,以便可以在后续的代码实现中使用。 代码清单9-18 自定义的BridgeServlet类的子类 public class CustomBridgeServlet extends BridgeServlet{ protected void service(HttpServletRequest req, HttpServletResponse resp)throws ServletException, IOException{ req.setAttribute("WebappClassLoader",this.getClass().getClassLoader()); super.service(req, resp); } } 代码清单9-19给出了处理表达式计算请求的实际servlet类的代码。在实现中,先从HttpServletRequest类的对象中得到之前保存的Web应 用的类加载器对象,接着把线程的上下文类加载器设置为该类加载器对象。经过这样的设置之后,ScriptEngineManager类的对象就可以正确地 查找到Ruby语言的脚本执行引擎的实现类。在完成查找之后,需要把当前线程的上下文类加载器恢复为之前的值,以确保不影响后面代码的使 用。 代码清单9-19 处理表达式计算请求的servlet的代码 public class CalculatorServlet extends HttpServlet{ public void doGet(HttpServletRequest req, HttpServletResponse resp)throws ServletException, IOException{ Thread currentThread=Thread.currentThread(); ClassLoader oldContextLoader=currentThread.getContextClassLoader(); ClassLoader webappLoader=(ClassLoader)req.getAttribute("WebappClassLoa der"); currentThread.setContextClassLoader(webappLoader); ScriptEngineManager manager=new ScriptEngineManager(); ScriptEngine engine=manager.getEngineByExtension("rb"); currentThread.setContextClassLoader(oldContextLoader); if(engine==null){ resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); return; } String expr=req.getParameter("expr"); try{ Object result=engine.eval(expr); resp.getWriter().write(result.toString()); }catch(ScriptException e){ throw new ServletException(e); } } } 9.10 小结 类加载器是Java语言中的一个重要概念,为Java带来了很多灵活性。在对Java字节代码的处理中,类加载器是重要的一环。通过自定义类 加载器可以实现Java字节代码的增强、加密和生成等功能。通过类加载器可以在虚拟机中划分同名Java类的隔离空间,这一特性在很多框架中 都得到了实际的应用。线程上下文类加载器可以把类加载器和当前线程结合起来,一般用来加载SPI接口的实现类。本章对Web容器和OSGi框架 中类加载器的使用方式做了说明,使读者可以了解相关的知识。 第10章 对象生命周期 Java语言是一门面向对象的编程语言。除了基本类型之外,在运行的Java程序中出现的都是对象。从J2SE 5.0开始,通过基本类型的自动 装箱/拆箱机制,基本类型也可以通过自动的方式与程序中的其他对象进行交互。Java中的所有类都继承自java.lang.Object类,所有的对象都 是Object类的实例。给定一个Java类,如果满足其构造方法的访问控制要求,那么使用new操作符可以创建出该类的对象。对象创建完成后,可 以与其他对象进行交互来完成程序要求的功能。当不再使用对象时,可以由Java平台的垃圾回收器来回收对象所占用的内存空间。一般对象都 会经历从创建到使用再到销毁的过程。 一个对象的完整生命周期涉及Java平台的很多相关技术。在创建一个Java类的对象之前,需要先由虚拟机加载该Java类(类加载的过程在 第9章进行了介绍)。在Java类被加载之后,还需要对该Java类进行链接和初始化。初始化完成之后,才能创建出该Java类的新的对象实例。对 象也有自己的初始化过程,主要通过调用对应Java类的特定构造方法来完成。当不再有引用指向一个对象时,这个对象成为垃圾回收器的候 选。对象所占用的内存空间会在合适的时机被垃圾回收器回收。对象终止机制提供了一种方式在对象被回收之前进行清理工作。当需要复制一 个对象时,可以使用Object类的clone方法。如果需要将对象的状态持久化,可以使用对象序列化机制来得到一个方便存储的字节流。本章的内 容围绕这些对象生命周期中可能涉及的相关技术展开。这些技术虽然并不复杂,但是存在很多容易误解和出错的地方。 10.1 Java类的链接 Java虚拟机运行时会在内部维护所有可用Java类的相关信息。虚拟机刚启动时,内部只包含Java核心类的相关信息。随着程序的运行,不 断有新的Java类被加载到虚拟机中,变为可用状态。Java类被加载之后,经过链接和初始化就可以在虚拟机中使用了。链接的过程是把加载的 Java类的字节代码中包含的信息与虚拟机的内部信息进行合并,使Java类的代码可以被执行。链接的过程由3个子步骤组成,分别是验证、准备 和解析。在链接过程中,会对Java类的直接父类或父接口进行验证和准备,但是对类中形式引用的解析是可选的。 验证是用来确保Java类的字节代码表示在结构上是完全正确的。验证过程有可能会导致其他Java类或接口被加载。如果验证过程中发现字 节代码的格式不正确,会抛出java.lang.VerifyError错误。通过Java编译器生成的字节代码通常不会出现验证错误。使用ASM等工具生成的字 节代码可能会出现格式不正确的情况。 准备过程会创建Java类中的静态域,并将这些域的值设为默认值。在准备过程中并不会执行代码。准备过程中的一个重要环节是保证类加 载时的类型安全。在链接过程中,可能有两个不同的类加载器对象同时开始加载一个Java类。在加载过程中,这两个类加载器对象也会分别加 载Java类中的域和方法的参数和返回值引用的其他Java类。从类型安全的角度来说,不应该出现一个方法的参数类型对应的Java类,以及返回 值类型对应的Java类由不同的类加载器对象来定义的情况。在准备过程中,当虚拟机中的某个类加载器对象开始加载某个类时,虚拟机会把该 类加载器对象记录为该Java类的初始类加载器。记录完成后,虚拟机会马上进行一次检查。如果发现刚才的加载操作导致类型安全约束被破 坏,则类加载过程不能进行。虚拟机会抛出java.lang.LinkageError错误。 解析过程是处理所加载的Java类中包含的形式引用。在一个Java类中会包含对其他类或接口的形式引用,包括它的父类、所实现的接口、 方法的形式参数和返回值的Java类等。这些形式引用对应的Java类都被正确加载之后,当前Java类才能正常工作。在Java类中可能包含了对其 他类中方法的调用,对于这些方法调用,在解析过程中需要检查所调用的方法确实存在。 在解析过程中会遇到的一个问题是如何处理复杂的引用关系图。Java类之间的引用关系可能非常复杂。在解析一个Java类的过程中,可能 导致其他的Java类被加载和解析,从而导致更多的Java类被加载和解析。通常可以利用两种策略来处理这种情况:一种是提前解析,即在链接 时递归地对依赖的所有形式引用都进行解析,这种做法的缺点是性能比较差;另外一种策略是延迟解析,即只在真正需要一个形式引用时才进 行解析,也就是说,如果一个Java类只是被引用,没有在程序运行中被真正用到,这个类就不会被解析。这种做法解决了提前解析方式性能较 差的问题。不同的虚拟机实现可能采取不同的策略,不管采用哪种策略,都不会对程序的正确性造成影响。 代码清单10-1中给出了一个测试虚拟机的类解析策略的Java程序。类LazyLink中引用了类ToBeLinked,但是没有创建ToBeLinked类的对象 或引用类的静态域或方法。在Java SE 7的OpenJDK实现中,先把ToBeLinked类的字节代码删掉,再运行LazyLink类,程序并没有抛出错误,而 是可以正确运行。这说明OpenJDK采用了延迟解析的策略。虽然LazyLink类引用了ToBeLinked类,但是在最开始的时候,ToBeLinked类只是作为 一个形式引用存在。在LazyLink类的运行过程中并没有实际用到ToBeLinked类,因此ToBeLinked类不会被加载和解析。所以即便ToBeLinked类 的字节代码不存在,程序运行也不会出现错误。 代码清单10-1 测试虚拟机的类解析策略的示例程序 public class LazyLink{ public static void main(String[]args){ ToBeLinked toBeLinked=null; System.out.println("使用延迟解析。"); } } 如果把代码清单10-1中的“ToBeLinked toBeLinked=null;”改成“ToBeLinked toBe-Linked=new ToBeLinked();”,再按照相同的方 式运行,则会抛出异常。这是由于程序运行中需要创建ToBeLinked类的对象,因此需要把ToBeLinked类加载到虚拟机中并进行链接。 10.2 Java类的初始化 当一个Java类第一次被真正使用时,虚拟机会对该Java类进行初始化。初始化的主要工作是执行Java类中的静态代码块和初始化静态域。 在初始化过程中,Java类中的静态代码块和静态域会按照在代码中出现的顺序依次执行初始化。在当前Java类被初始化之前,它的直接父类也 会被初始化,但是该Java类所实现的接口并不会被初始化。对一个接口来说,当其被初始化时,它的父接口不会被初始化。 在初始化的过程中,静态代码块和静态域的出现顺序很重要。虚拟机会严格按照在源代码中的出现顺序来执行初始化操作。代码清单10-2 展示了静态域的初始化顺序可能带来的问题。静态域X的值最初是20,所以Y的值是40。虽然随后通过静态代码块把X的值设成了30,但是Y的值 不会发生变化,仍然是40。 代码清单10-2 静态域的初始化顺序的示例 public class StaticOrder{ public static int X=20; public static int Y=2*X; static{ X=30; } public static void main(String[]args){ System.out.println(Y);//输出40 } } 需要注意的是,当访问一个Java类或接口中的静态域时,只有真正声明这个域的类或接口才会被初始化。代码清单10-3给出了一个类继承 时的静态域的初始化示例。在类A中声明了静态域value,类B继承自类A。通过B.value可以直接访问类A中声明的静态域value。虽然引用时使用 的是类B,但是由于value是在类A中声明的,因此访问B.value只会使类A被初始化,类B不会被初始化。 代码清单10-3 类继承时的静态域的初始化示例 class A{ static int value=100; static{ System.out.println("类A初始化。"); } } class B extends A{ static{ System.out.println("类B初始化。"); } } public class StaticFieldInit{ public static void main(String[]args){ System.out.println(B.value); } } 有很多不同的原因会使一个Java类被初始化。下面列出了可能造成类被初始化的操作。 1)创建一个Java类的实例对象。比如调用“MyClass obj=new MyClass()”语句时,类MyClass会被初始化。 2)调用一个Java类中的静态方法。比如myMethod是MyClass类中的静态方法,调用myMethod方法会使MyClass类被初始化。 3)为类或接口中的静态域赋值。比如myField是MyClass类中的静态域,调用“MyClass.myField=10”会使MyClass类被初始化。 4)访问类或接口中声明的静态域,并且该域的值不是常值变量。常值变量是声明为final的Java基本类型或String类型的变量,使用编译 时常量来初始化。比如,代码“private static final String name="Alex";”中的域name的值是常值变量。如果类MyClass中的静态域 myField的值不是常值变量,访问myField域的操作会使类MyClass被初始化。 5)在一个顶层Java类中执行assert语句也会使该Java类被初始化。 6)调用Class类和反射API中进行反射操作的方法也会初始化Java类。 10.3 对象的创建与初始化 在Java语言中通过new操作符可以创建出一个Java类的实例对象。除了Object类之外,其他Java类都至少有一个父类。在没有通过extends 显式声明时,Object类是默认的父类。在Java类中可以通过构造方法添加对象初始化时的逻辑。在创建对象时,父类和祖先类的初始化逻辑会 被依次执行。实际的初始化流程是先沿着继承层次结构树往上传递,完成部分初始化工作。到达Object类之后,再沿着层次结构树向下,完成 其余的初始化工作,最后回到最初的Java类。任何一个步骤出现错误,都会导致对象创建失败。 在进行实际的对象创建之前,需要为要创建的对象分配内存空间。所需要的内存空间大小取决于Java类及其父类和祖先类包含的所有实例 域的数量和类型。如果内存空间不足,则创建过程会抛出OutOfMemoryError错误。如果内存分配成功,则把新创建的对象的所有实例域都设为 默认值,包括Java类本身声明的及父类声明的。这个新创建的对象并不能直接使用,因为类的构造方法还没有被调用。 类的构造方法的调用过程分成三步。第一步是调用父类的构造方法。对父类构造方法的调用分成显式和隐式两种。显式调用是通过super关 键词来完成的。如果没有显式地添加对super关键词的调用,则由编译器自动生成相关的代码。第二步是初始化类中实例域的值,按照实例域的 出现顺序依次初始化。第三步是执行类的构造方法中的其他代码,完成最终的初始化工作。由于在第一步中会调用父类的构造方法,实际的执 行流程会先跳转到父类的构造方法,再沿着继承层次结构树依次往上跳转,直到到达Object类。由于Object类没有父类,不再继续向上传递, 而是进行后两步操作。整个过程是一个典型的递归调用过程。 通过代码清单10-4的示例来说明构造方法的调用过程。Animal类是Dog类的父类。在创建Dog类的对象时,Dog类的构造方法先被调用。在 Dog类的构造方法中使用参数值“4”调用父类Animal的构造方法。在Animal类的构造方法的最开始,会再调用Animal类的父类Object类的构造 方法。由于Object类没有父类,不再需要继续调用父类的构造方法,而是执行自身的构造方法的逻辑。Object类的构造方法执行完成之后,调 用流程回到Animal类。会先对Animal类中实例域进行初始化,使legs变量被初始化为0。接着Animal类的构造方法中的其他代码被调用,legs变 量的值被设置为4。接着调用流程转入到Dog类的构造方法中,先把实例变量name的值初始化为“<default>”,然后调用Dog类的构造方法中 的其他代码。此时就完成了Dog类的对象的创建过程。 代码清单10-4 说明构造方法调用顺序的示例 class Animal{ int legs=0; Animal(int legs){ this.legs=legs; } } class Dog extends Animal{ String name="<default>"; Dog(){ super(4); } } public class NewObject{ public static void main(String[]args){ Dog dog=new Dog(); System.out.println(dog.legs);//输出值为4 } } 在编写构造方法时,要注意不要在构造方法中调用可以被子类覆写的方法。这是因为如果子类覆写了该方法,那么在初始化过程中进行到 调用父类的构造方法时,父类的构造方法所调用的是子类所覆写的方法,而此时子类的构造方法中的代码并没有被执行,对象仍处于初始化的 过程中。这时调用子类的方法,很容易出现错误。代码清单10-5中给出了一个示例,在父类Parent的构造方法中调用getCount方法,在子类 Child中覆写了此方法并返回在Child类的构造方法中传入的值。这两个类初看起来似乎并没有问题,但是当试图创建Child类的实例时,会出现 除零错误。这是因为在执行Parent类的构造方法时使用的是Child类中的getCount方法,而此时Child类的实例域count还没有被初始化,它的值 是0。 代码清单10-5 在父类构造方法中调用子类方法的错误示例 class Parent{ public Parent(){ int average=30/getCount(); } protected int getCount(){ return 4; } } class Child extends Parent{ private int count; public Child(int count){ this.count=count; } public int getCount(){ return count; } } public class BadConstructor{ public void test(){ Child child=new Child(5); } } 10.4 对象终止 对象创建完成后,使用一段时间就可能不再需要了。如果没有引用指向一个对象,说明该对象可以被销毁。在创建和使用对象的过程中, 可能申请了相关的资源,在对象被销毁之前,这些资源要被正确地释放。资源分成内存资源和非内存资源两类。内存资源指的是对象中实例域 所占据的内存空间。由于Java采用了自动内存管理机制,对象占用的内存资源的回收由垃圾回收器自动完成,不需要开发人员显式地进行内存 释放。非内存资源指的是程序运行时申请的其他系统资源,包括打开的文件、打开的套接字连接和数据库连接等。这些非内存资源需要由程序 显式地进行释放。 与C++语言进行对比可以发现,在Java中,对这两种资源的释放操作是分开处理的,而在C++语言中,对这两种资源的释放方式是统一的, 都在析构方法中进行。在Java中,没有析构方法的概念,同时,对非内存资源的释放又无法以自动的方式来进行,因此,Java引入了对象终止 机制(finalization)来解决非内存资源的释放问题。但是由于设计上的各种问题和功能上的局限性,对象终止机制并没有发挥应有的作用。 1.finalize方法的基本用法 如果一个Java类的对象有自定义的销毁逻辑,那么可以覆写Object类的finalize方法并在finalize方法中添加相关的逻辑。在一个对象的 内存空间被垃圾回收器回收之前,该对象的finalize方法会被调用。finalize方法中的这一段处理逻辑称为对象的终止器(finalizer)。Java 的对象终止机制看起来比较有用,但是在实际的程序中并不实用。最主要的原因是Java语言规范并没有对finalize方法的调用时间进行明确的 规定,只是规定finalize方法一定在对象的内存空间被垃圾回收器回收之前运行。第7章介绍垃圾回收器时提到过,垃圾回收器的运行时间是不 固定的,因此一个对象被回收的时间也是不确定的。这双重的不确定性导致无从得知finalize方法的具体运行时间。这就意味着如果把非内存 资源的释放操作放在finalize方法中,那么该资源的实际释放时间是不固定的,从而可能产生与时间相关的错误。如果在某个时间点上 finalize方法碰巧被执行了,那么程序的行为是正确的;如果finalize方法没有被执行,则可能资源没被正确释放。这种随机错误显然是不能 出现在程序中的。 代码清单10-6中给出了运行finalize方法的示例。RunFinalize类通过覆写finalize方法提供了自定义的对象终止逻辑。如果finalize方法 被运行了,那么会在控制台输出提示信息。在代码运行时首先创建了一个RunFinalize类的对象,然后显式地把对象引用设为null,使该对象可 以被垃圾回收。如果程序运行到此处就结束,那么会发现finalize方法并没有被运行。这是因为垃圾回收器并没有运行,也就不可能调用对象 的finalize方法。在后面的代码中通过System.gc方法来建议垃圾回收器运行并等待一段时间。通过这种方式可以增加垃圾回收器运行的几率, 也使创建的RunFinalize类的对象有机会被回收。添加这样的逻辑之后,finalize方法被运行的几率大大增加。 代码清单10-6 运行finalize方法的示例 public class RunFinalize{ protected void finalize()throws Throwable{ System.out.println("运行finalize方法。"); super.finalize(); } public static void main(String[]args)throws InterruptedException{ RunFinalize runFinalize=new RunFinalize(); runFinalize=null; for(int i=0;i<10;i++){ System.gc(); Thread.sleep(100); } } } 需要注意的是,虽然通过System.gc方法可以增加垃圾回收器运行的几率,进而增加finalize方法被运行的几率,但是finalize方法的实际 运行时间仍然是不能保证的。代码清单10-6只是为了说明finalize方法的运行时机与垃圾回收器的关系,不应该作为实际程序中的处理方式。 2.finalize方法与资源释放 在实际的程序中,不应该仅依靠对象的finalize方法来进行非内存资源的释放。例如,在一个对象的使用过程中打开了多个文件,如果把 文件的关闭操作放在finalize方法中进行,则可能会出现问题。代码清单10-7中给出了一个错误使用finalize方法的示例。在类FileHolder的 构造方法中传入一个要打开的文件的路径,在open方法中打开该文件得到一个InputStream类的对象。需要对打开的文件执行正确的关闭操作。 FileHolder类把InputStream类的对象的关闭操作放在了finalize方法中。这样做时finalize方法的运行时间是不确定的,有可能出现的情况 是,程序中存在大量FileHolder类的对象,而这些对象的finalize方法都没有被调用,导致大量处于打开状态的文件没有被关闭。操作系统对 同时打开的文件数量是有限制的,大量打开文件会造成程序运行出现严重问题。 代码清单10-7 错误使用finalize方法来关闭文件的示例 //错误的finalize使用 public class FileHolder{ private Path path; private InputStream inputStream; public FileHolder(Path path){ this.path=path; } public void open()throws IOException{ this.inputStream=Files.newInputStream(path, StandardOpenOption.WRITE); } protected void finalize()throws Throwable{ if(inputStream!=null){ inputStream.close(); } super.finalize(); } } 正确释放非内存资源的做法应该是在类中添加显式释放资源的方法,由对象的使用者负责调用。Java类可以实现Java 7中新增的 java.lang.AutoCloseable接口,进而可以通过调用close方法或者使用更加简便的try-with-resources语句来进行资源释放。对于代码清单10- 7中的FileHolder类,正确的做法是实现AutoCloseable接口,并在close方法中关闭InputStream类的对象。提供显式的资源释放方法相当于把 资源释放的职责交给了对象的使用者。这要求使用者在编写代码时注意在合适的时机释放所申请的非内存资源。在添加了显式的资源释放方法 之后,也可以在finalize方法中添加对这个方法的调用。这样做的好处是,即使对象的使用者忘记调用释放资源的方法,也可能有机会释放这 个资源。 3.实现正确的finalize方法 在finalize方法的声明中,finalize方法可能抛出任何类型的异常。如果finalize方法在执行时出现异常,则抛出的异常会直接被忽略, 对finalize方法的调用也马上终止。由于finalize方法是由虚拟机来直接调用的,因此无法在代码中捕获finalize方法中抛出的异常,也不会 有异常的堆栈信息被输出。 在自定义的finalize方法的实现中,总是应该调用父类的finalize方法。这是因为在对象创建时,会依次调用父类的构造方法来完成对象 的初始化。与之相对应的是,在对象被回收之前,父类的终止逻辑也要被调用。但是与构造方法不同的是,父类的finalize方法不会被自动调 用。因此,在finalize方法的实现中,先编写当前类的终止逻辑,再通过super.finalize()来调用父类的finalize方法。由于在Object类中 定义了finalize方法,因此super.finalize()的调用总是合法的。为了保证父类的finalize方法总是被调用,需要把当前类的终止逻辑封装 在一个try-finally结构中。不管当前类的终止逻辑是否成功完成,父类的finalize方法始终会被调用。 如果当前Java类通过覆写finalize方法添加了相关的对象终止逻辑,同时该类的子类也覆写了finalize方法,则子类的finalize方法应该 调用父类的finalize方法。但是由于子类的实现不由当前Java类控制,因此可能会因为开发人员的错误而造成当前类的finalize方法没有被调 用。为了避免这种情况,可以使用一种被称为“终止器守卫者(finalizer guardian)”的模式。如代码清单10-8所示,WithFinalizer类有自 定义的对象终止逻辑,但是这些代码没有添加在WithFinalizer类的finalize方法中,而添加在WithFinalizer类的一个实例域guardian的 finalize方法中。当WithFinalizer类的对象可以被回收时,guardian对象也同样可以被回收。此时guardian对象的finalize方法会被调用,完 成WithFinalizer类的对象的终止逻辑。当使用这种模式时,即便WithFinalizer类的子类没有调用super.finalize方法,WithFinalizer类的对 象也能被正确终止。 代码清单10-8“终止器守卫者(finalizer guardian)”模式的示例 public class WithFinalizer{ private final Object guardian=new Object(){ protected void finalize()throws Throwable{ //WithFinalizer类的对象终止实现 super.finalize(); } }; } 在finalize方法的实现中要避免创建对当前对象的新的引用,不论是直接引用还是间接引用,都要避免。创建新的引用会造成当前对象从 没有引用的状态又回到有引用的状态。不过当对象再次变成没有引用的状态时,该对象的finalize方法不会被再次调用。一个对象的finalize 方法只会被调用一次。 10.5 对象复制 在程序运行过程中,可能会需要复制一个对象。例如,根据防御式编程(defensive programming)的实践,当一个方法将一个内部使用的 对象返回给调用者时,最好先把该对象复制一份,将复制的对象返回给调用者。如果不进行复制,则调用者和当前对象使用的是同一个对象。 如果调用者对这个对象进行了修改,那么会影响当前对象的内部状态。对象复制功能还可以在其他场景下发挥作用。Object类中的clone方法和 java.lang.Cloneable接口用来提供标准的对象复制功能。 要实现对象复制功能,需要Object类的clone方法和Cloneable接口配合使用。Cloneable是一个不包含任何方法的标记接口,它的作用是作 为复制功能相关的标记。如果一个类实现了Cloneable接口,就说明可以通过Object类的clone方法提供的默认实现来对该类的实例对象包含的 域进行复制。如果调用clone方法的对象的Java类没有实现Cloneable接口,那么clone方法会直接抛出java.lang.CloneNotSupportedException 异常。代码清单10-9给出了一个简单的可以进行复制操作的Java类。如果删去CloneableObject类对Cloneable接口的实现,则代码运行时会抛 出CloneNotSupportedException异常。按照惯例,实现了Cloneable接口的Java类需要提供一个公开的clone方法来覆写Object类中的clone方 法,这是因为Object类中的clone方法被声明为受保护的,如果不进行覆写,外部的对象无法访问到clone方法。 代码清单10-9 可以进行复制操作的Java类的示例 public class CloneableObject implements Cloneable{ public Object clone(){ try{ return super.clone(); }catch(CloneNotSupportedException e){ throw new Error(e);//不会发生该异常 } } public static void main(String[]args){ CloneableObject obj=new CloneableObject(); obj.clone(); } } Java中的对象复制操作的这种设计,不太符合一般的习惯做法。一般认为在Cloneable接口中有一个clone方法,如果Java类需要提供复制 功能,就实现Cloneable接口并编写对应的clone方法的实现。但是Java的实现并不是这样的。即便一个Java类实现了Cloneable接口,也不表示 可以调用该类的clone方法进行复制操作,一种可能是该Java类并没有提供公开的clone方法,因此无法调用clone方法。 Object类的clone方法复制对象的做法是对当前对象中所有的实例域进行逐一复制。先创建一个新的对象,再把新对象中所有的实例域的值 初始化成原始对象中对应域的当前值。该方法一般是使用原生代码实现的。代码清单10-10给出了使用Object类的clone方法的示例。 ToBeCloned类的clone方法直接通过Object类的clone方法来实现。ToBeCloned类包含一个int类型的实例域value。在cloneObject方法中先创建 了一个ToBeCloned类的对象obj。在调用对象obj的clone方法时,先创建一个ToBeCloned类的对象,再把该对象中实例域value的值初始化为对 象obj中value的当前值。这个新创建的ToBeCloned类的对象clonedObj,就是复制对象obj之后的结果。 代码清单10-10 Object类的clone方法的使用示例 class ToBeCloned implements Cloneable{ private int value=0; public void setValue(int value){ this.value=value; } public int getValue(){ return this.value; } public Object clone(){ try{ return super.clone(); }catch(CloneNotSupportedException e){ throw new Error(e); } } } public class SimpleClone{ public void cloneObject(){ ToBeCloned obj=new ToBeCloned(); obj.setValue(1); ToBeCloned clonedObj=(ToBeCloned)obj.clone(); System.out.println(clonedObj.getValue()); } } 可以将Object类的clone方法的实现看成是为原始对象创建了一个浅拷贝。如果对象中只包含值为基本类型或不可变对象的域,浅拷贝就足 够了。如果对象中某些域的值为可变对象,浅拷贝就不能满足需求。因为所复制出来的对象的域与原始对象的域使用相同的对象引用,指向的 是同一个对象,相当于在两个对象中对同一个对象进行处理,会产生潜在的问题。代码清单10-11给出了一个浅拷贝可能带来问题的示例。类 MutableObject中包含一个实例域counter,是Counter类的对象。Counter类的对象是可变的,有自己不同的内部状态值value。类 MutableObject的clone方法只是简单地复用了Object类中的clone方法。在进行复制操作之后,原始对象obj和复制出来的新对象clonedObj内部 的counter域都指向对象obj中的Counter类的对象,所以虽然只有一次对clonedObj对象的increase方法的调用,但是clonedObj对象的getValue 方法的返回值却为3,这是因为通过对象obj的increase方法所做的修改同样影响了clonedObj对象。 代码清单10-11 浅拷贝可能带来的问题的示例 class Counter{ private int value=0; public void increase(){ value++; } public int getValue(){ return value; } } class MutableObject implements Cloneable{ private Counter counter=new Counter(); public void increase(){ counter.increase(); } public int getValue(){ return counter.getValue(); } public Object clone(){ try{ return super.clone(); }catch(CloneNotSupportedException e){ throw new Error(e); } } } public class MutableObjectClone{ public void cloneObject(){ MutableObject obj=new MutableObject(); obj.increase(); MutableObject clonedObj=(MutableObject)obj.clone(); clonedObj.increase(); obj.increase(); System.out.println(clonedObj.getValue());//值为3 } } 要解决这个浅拷贝的问题,就要提供自己的深拷贝的实现。虽然Object类的clone方法已经不能满足需求,但是仍然可以作为实现的基础。 Object类的clone方法已经对类中的基本类型和不可变对象的域进行了处理,只要在这基础上添加对可变对象的域的处理即可。要对代码清单 10-11中的类进行修改,先要让Counter类实现Cloneable接口,并提供对应的公开的clone方法。由于Counter类中只包含一个int类型的域,因 此可以直接调用Object类中的clone方法。而对于MutableObject类中的clone方法,将其修改成代码清单10-12中的实现。在这个实现中,先调 用Object类的clone方法得到复制之后的对象obj作为基础,再对MutableObject类中的可变对象的域counter进行处理。使用clone方法对原始的 counter对象进行复制,再修改对象obj中counter域的值,使之指向复制出来的counter对象。经过这样的修改,obj对象中的counter域引用的 是一个新的Counter类的对象。 代码清单10-12 深拷贝的实现方式的示例 public Object clone(){ MutableObject obj; try{ obj=(MutableObject)super.clone(); obj.counter=(Counter)counter.clone(); return obj; }catch(CloneNotSupportedException e){ throw new Error(e); } } 这种深拷贝操作要求对当前对象的实例域所引用的可变对象都以递归的方式进行复制。其中所涉及的每个对象的类都应该实现Cloneable接 口,并提供正确的clone方法的实现。 进行对象复制的另外一个做法是使用复制构造方法,即用一个已有的对象去构造另外一个对象。复制构造方法相对于clone方法来说更加容 易使用,也不容易出错。如果一个Java类需要提供公开API来进行复制操作,使用复制构造方法是更好的选择。复制构造方法的一个好处是可以 在功能相似而类型不同的对象之间传递数据。可以在Java的集合类框架中看到相关的示例。比如java.util.ArrayList的复制构造方法可以接受 任何java.util.Collection接口的实现对象作为参数值。可以很容易地把一个java.util.HashSet类的对象转换成ArrayList类的对象。使用 clone方法是无法做到这一点的。 代码清单10-13中的User类除了一般的构造方法之外,还提供了一个复制构造方法。当需要复制一个已有的User类的对象时,可以使用已有 的对象作为参数来调用复制构造方法。创建出来的新对象就是已有对象的副本。 代码清单10-13 包含复制构造方法的Java类的示例 public class User{ private String name; private String email; public User(String name, String email){ this.name=name; this.email=email; } public User(User user){ this.name=user.getName(); this.email=user.getEmail(); } public String getName(){ return this.name; } public String getEmail(){ return this.email; } } 10.6 对象序列化 在程序的运行过程中,活动对象的状态保存在内存中。内存中的数据是非持久化的。当虚拟机终止时,内存中对象的信息就会丢失。能够 持久化保存对象数据的方式有很多。典型的方式是使用文件系统或数据库。持久化对象时通常涉及自定义存储格式。使用文件存储时可以基于 标准的文件格式,如XML、JSON和CSV等,也可以使用自定义的文本或二进制格式。使用数据库存储时需要定义数据库的表结构。定义了存储格 式之后,在保存和读取操作时,需要在活动对象的内部状态和存储格式之间互相转换。 代码清单10-14给出了一个简单的Java类User,表示程序中的用户。User类中只有两个String类型的域name和email。在持久化User类的对 象时,可以选择使用基于XML的文件格式,即定义两个子元素来分别包含name和email的值;也可以创建一个数据库表,包含对应于两个域的 列。在保存时,使用User类的对象的公开方法获取两个域的值,再根据存储格式使用文件操作API或数据库操作API来写入数据。在读取时,先 获取保存的两个域的值,再使用User类的构造方法得到对应的对象。 代码清单10-14 说明对象持久化机制的示例Java类 public class User{ private String name; private String email; public User(String name, String email){ this.name=name; this.email=email; } public String getName(){ return this.name; } public String getEmail(){ return this.email; } } 在持久化实现中,自定义存储格式的工作量相对较小,比较复杂的是保存和读取操作时的数据转换。数据转换这一步通常涉及与持久化实 现方式相关的外部API,如XML、JSON处理API和数据库操作API等。代码清单10-14中的User类的内部结构相对简单,实现的工作量比较小。某些 Java类的内部结构比较复杂,包含对其他Java类的对象的引用,对象之间通过引用关系形成一个复杂的对象图。在保存和读取过程中都需要遍 历整个对象图,对其中包含的所有对象进行处理。如果使用数据库作为存储方式,那么可以利用对象关系映射(Object-relational mapping, ORM)技术来减少实现的工作量。 从简化实现的角度出发,可以使用Java语言内建的对象持久化方式,即对象序列化(object serialization)机制。对象序列化用来在虚 拟机中的活动对象和字节流之间进行转换。序列化机制通常包括两个过程:第一个是序列化(serialization),把活动对象的内部状态转换成 一个字节流;第二个是反序列化(deserialization),从一个字节流中得到可以直接使用的Java对象。这两个过程对应于保存和读取两个不同 的操作。序列化操作可以作为持久化实现的基础。通过序列化得到的字节流可以保存在文件中,也可以作为二进制数据保存在数据库中。序列 化的好处是为开发人员省去了繁琐的数据转换操作,所有的数据转换都由Java平台来完成,并提供相应的扩展方式允许开发人员进行自定义。 10.6.1 默认的对象序列化 Java平台提供了良好的默认序列化实现。在此基础上实现基本的对象序列化是比较简单的。与10.5节介绍的对象复制功能一样,在使用 Java平台的默认序列化实现之前,需要先通过接口声明启用对象序列化功能。待序列化的Java类只需实现java.io.Serializable接口即可启用 这个功能。Serializable仅是一个标记接口,并不包含任何需要实现的具体方法。实现该接口的目的是声明该Java类的对象是可以被序列化 的。对于支持序列化的Java类的对象,可以使用java.io.ObjectOuputStream类和java.io.ObjectInputStream类的对象来完成Java对象与字节 流之间的相互转换。 ObjectOuputStream类是一个Java I/O库中标准的过滤输出流的实现。在创建ObjectOuputStream类的对象时,需要提供另外一个 OutputStream类的对象作为实际的输出目的。在ObjectOuputStream类中包含一系列以“write”作为名称前缀的方法用于写入基本类型的值和 对象到输出流中,其中writeObject方法用于把一个Java对象写入到输出流中。 代码清单10-15给出了把User类的对象写入到文件中的示例代码。只需要从文件输出流中创建一个ObjectOuputStream类的对象,再使用 writeObject方法进行写入即可。得到的“user.bin”文件采用了Java平台定义的二进制格式。开发人员并不需要关心格式的具体细节。任何 OutputStream类的对象都可以作为序列化时的输出目标。除了写入到文件中之外,还可以通过套接字连接进行网络传输。 代码清单10-15 写入Java对象的内容到文件中 public class WriteUser{ public void write(User user)throws IOException{ Path path=Paths.get("user.bin"); try(ObjectOutputStream output=new ObjectOutputStream(Files.newOutputStream(path))){ output.writeObject(user); } } public static void main(String[]args)throws IOException{ WriteUser writeUser=new WriteUser(); User user=new User("Alex","alex@example.org"); writeUser.write(user); } } 对象序列化之后得到的字节流可以通过不同的方式进行分发,比如文件复制或网络传输。在得到字节流之后,可以通过反序列化方式从字 节流中得到Java对象。反序列化时使用的是与ObjectOuputStream类相对应的ObjectInputStream类。在创建ObjectInputStream类的对象时需要 提供一个InputStream类的对象作为参数。该InputStream类的对象用于读取包含序列化操作结果的字节流。ObjectInputStream类中包含一系列 以“read”作为名称前缀的方法来从输入流中读取其中包含的基本类型数据和对象,其中readObject方法用于读取一个Java对象。读取到的 Java对象中实例域的值由字节流中保存的值来确定。代码清单10-16给出了读取代码清单10-15中产生的“user.bin”文件包含的Java对象的示 例。 代码清单10-16 从文件中读取Java对象 public class ReadUser{ public User readUser()throws IOException, ClassNotFoundException{ Path path=Paths.get("user.bin"); try(ObjectInput Stream input=new Object Input Stream(Files.newInputStream(path))){ User user=(User)input.readObject(); return user; } } public static void main(String[]args)throws ClassNotFoundException, IOException{ ReadUser readUser=new ReadUser(); User user=readUser.readUser(); System.out.println(user.getName()); } } 在写入和读取时,虽然提供的参数或得到的返回值是单个Java对象,但是实际上操纵的是一个对象图。该对象图中包括当前对象所引用的 其他对象,以及这些对象所引用的另外的对象。Java的序列化机制会自动遍历整个对象图并依次进行处理。在写入过程中,如果传递给 ObjectOutputStream类的对象的writeObject方法的Java对象并没有实现Serializable接口,那么writeObject方法会抛出 java.io.NotSerializableException异常。而对于Java对象中的域所引用的其他对象,如果这些对象本身并没有实现Serializable接口,那么 这个域不会出现在序列化之后的字节流中。 在默认的序列化实现中,Java对象中的非静态和非瞬时域都会被自动包括进来,而与域的可见性声明没有关系。这可能会导致某些不应该 出现的域被包含在序列化之后的字节流中,比如密码等隐私信息对应的域,进而造成隐私信息的泄露,成为程序中的安全隐患。针对这种情 况,一种解决办法是把不希望被序列化的域声明为瞬时的,即使用transient关键词。另外一种做法是添加一个serialPersistentFields域来声 明序列化时要包含的域。比如对于代码清单10-14中的User类,如果不希望email域出现在序列化之后的字节流中,可以使用代码清单10-17中的 声明方式,只包含name域。在使用serialPersistentFields时,它的名称和类型声明需要严格按照代码清单10-17给出的形式。如果名称或类型 声明不对,则无法生效。具体来说,serialPersistentFields域的值是一个java.io.ObjectStreamField类型的数组,数组中的每个 ObjectStreamField类的对象表示一个包含在序列化过程中的域。 代码清单10-17 声明序列化中需要包含的域 private static final ObjectStreamField[]serialPersistentFields={new ObjectStreamField("name",String.class)}; 10.6.2 自定义对象序列化 默认的对象序列化机制虽然使用简单,但是存在的问题也比较多。其中最重要的问题是默认的序列化机制依赖于Java类的内部实现,而不 是公开接口。随着程序的版本更新,公开接口基本上不会发生变化,而内部的实现可能发生很多变化。内部实现的变化会导致旧版本的Java对 象序列化之后的字节流无法被重新转换成新版本的Java对象。这与通常的“面向接口编程”的实践方式是相背离的。 比如,代码清单10-14中的User类需要增加获取用户年龄的功能,从公开API的角度来说,需要增加一个getAge方法获取用户的年龄。早期 版本的实现是用一个int类型的域age来直接保存用户的年龄数据,在构造方法中设置该域的值。在后来的版本更新中,发现直接保存年龄的做 法并不合适,改为根据出生日期自动计算出年龄。于是把原来实现中的age域删除,增加一个java.util.Date类型的域birthDate,并修改 getAge方法的内部实现。在这个版本更新过程中,类的公开接口并没有变化,但内部实现发生了改变。如果某个文件中保存的是旧版本的对象 序列化之后的字节流,那么在通过ObjectInputStream类的readObject方法读取之后得到的新版本的对象中,域birthDate的值是null。这是因 为旧版本的序列化结果中只有age域的值,域birthDate的值被设为默认值null。默认的序列化机制并不理解age域和birthDate域之间的关系, 只会根据域的名称和类型来进行赋值。 为了解决版本更新带来的序列化格式不兼容的问题,需要为Java类定义自己的序列化格式,而不是简单地使用默认格式。这就要求对Java 类的职责及可能会发生变化的地方进行缜密的思考与设计,定义好序列化后的格式中要包含的数据。这个格式在之后的版本更新中应该是相对 稳定的。完成设计之后,通过序列化机制提供的扩展方式来编写相关的逻辑。 通过在Java类中添加特定的方法来编写自定义的序列化实现逻辑。自定义的序列化逻辑由两个配对的方法来实现,分别是writeObject和 readObject方法。当使用ObjectOutputStream类的对象进行序列化时,如果Java类中定义了writeObject方法,那么会调用该方法来完成实际的 写入操作;当使用ObjectInputStream类的对象进行反序列化时,如果Java类中定义了readObject方法,那么会调用该方法进行实际的读取操 作。这两个方法一般同时出现,但是所包含的逻辑正好相反。对writeObject和readObject方法的类型声明有严格的要求。不满足要求的方法不 会在序列化时被调用。 以之前提到的在User类中添加表示年龄的域的需求为例来说明自定义序列化格式的用法。经过设计,在序列化后的格式中,只需要包含年 龄的值即可。代码清单10-18给出了新的NewUser类的代码。NewUser类中的writeObject方法中包含的是序列化时的逻辑。在writeObject方法被 调用时,当前进行对象写入操作的ObjectOutputStream类的对象会作为参数传入。使用该ObjectOutputStream类的对象中的方法可以写入任何 所需的内容。在writeObject方法中一般先使用defaultWriteObject方法来执行默认的写入操作,即写入非静态和非瞬时的域。这样可以提高代 码的灵活性。接着把age域的值通过writeInt方法写入流中。与writeObject方法相对应的readObject方法中包含的是反序列化时的逻辑。在 readObject方法被调用时,当前进行读取操作的ObjectInputStream类的对象会作为参数传入。使用该ObjectInputStream类的对象来读取流中 的内容,并初始化对象中的对应实例域。在readObject方法中一般先使用defaultReadObject方法来读取非静态和非瞬时域。在writeObject方 法和readObject方法中的操作是相对应的,按照写入时的顺序来进行读取。 代码清单10-18 自定义序列化机制 public class NewUser implements Serializable{ private static final long serialVersionUID=1L; private transient int age; public NewUser(int age){ this.age=age; } public int getAge(){ return this.age; } private void writeObject(ObjectOutputStream output)throws IOException{ output.defaultWriteObject(); output.writeInt(getAge()); } private void readObject(ObjectInputStream input)throws IOException, ClassNotFoundException{ input.defaultReadObject(); int age=input.readInt(); this.age=age; } } 代码清单10-18中的writeObject方法和readObject方法的实现都比较简单。实际上,可以在writeObject方法和readObject方法中添加比较 复杂的逻辑,包括修改域的值或添加额外的数据等。如果在自定义的writeObject方法和readObject方法中对某个域的数据进行了处理,一般把 该域声明为transient,这样defaultWriteObject方法和defaultReadObject方法就不会处理这个域,避免默认实现带来的不兼容性问题。 使用自定义序列化格式可以解决之前提到的由于版本更新造成的序列化内容不兼容的问题。代码清单10-19给出了新版本的NewUser类。新 版本的NewUser类使用了Date类型表示出生日期。为了保持序列化格式的兼容性,在序列化之前需要把birthDate域转换成年龄,在反序列化之 后要进行相反的操作。 代码清单10-19 版本更新后的序列化机制的实现 public class NewUser implements Serializable{ private static final long serialVersionUID=1L; private transient Date birthDate; public NewUser(Date birthDate){ this.birthDate=birthDate; } public int getAge(){ return dateToAge(birthDate); } private Date ageToDate(int age){ //年龄转换成日期 } private int dateToAge(Date date){ //日期转换成年龄 } private void writeObject(ObjectOutputStream output)throws IOException{ output.defaultWriteObject(); int age=dateToAge(birthDate); output.writeInt(age); } private void readObject(ObjectInputStream input)throws IOException, ClassNotFoundException{ input.defaultReadObject(); int age=input.readInt(); this.birthDate=ageToDate(age); } } 通过ObjectInputStream类的readObject方法可以从字节流中得到一个Java对象。不过对象的创建并不是通过一般的方式来完成的,对应的 Java类的构造方法没有被调用,而不少Java类在其构造方法中有相关的对象初始化的逻辑。通过反序列化得到的对象由于没有调用构造方法, 其内部的完整性约束可能被破坏,因此在readObject方法中需要确保对象在返回之前经过了完整的初始化。 10.6.3 对象替换 在进行反序列化操作时,会创建出新的Java对象。从某种意义上来说,反序列化的作用相当于一个构造方法。有些Java类对于其对象实例 有自己的管理逻辑,例如使用单例模式时,要求某个类在虚拟机中只有一个实例。对于这样的Java类,在反序列化时,需要对得到的对象进行 处理,以保证序列化机制不会破坏Java类本身的约束,否则,每进行一次反序列化操作,都会创建一个新的对象,就破坏了单例模式要求的约 束条件。在其他情况下,可能需要在序列化时使用另外一个对象来代替当前对象,原因可能是当前对象中包含了一些不需要被序列化的域,比 如这些域是从另外一个域派生而来的。对于这种情况,也可以通过把域声明为transient或使用自定义的writeObject方法和readObject方法来 实现。但是如果这样的逻辑比较复杂,可以考虑封装在一个Java类中。另外还可以隐藏实际的类层次结构。比如类A中的某个域引用了类B,在 正常的序列化过程中,类A和类B都会出现在序列化之后的字节流中,如果希望在字节流中隐藏类B,可以用另外一个类的对象来代替类B的对 象。 在序列化时进行的对象替换操作由一组对应的writeReplace方法和readResolve方法来完成。在writeReplace方法中可以根据当前对象创建 一个新的对象作为替代。这个替代对象被写入到ObjectOutputStream类的对象中。同样的,在readResolve方法中,可以对读取出来的对象进行 转换,把转换的结果作为反序列化的结果返回。 通过一个示例来说明,在一个订单管理系统中,使用Order类的对象来表示实际的订单。在Order类中引用了User类,用来表示该订单的客 户信息。如果使用默认的序列化机制,在Order类的对象被序列化时,其引用的User类的对象也会被序列化。为了满足使用的需求,需要把 Order类的对象序列化后的字节流通过网络的方式来传输。通过网络传输可能会带来一些安全隐患,比如序列化后的内容被第三方窃取,造成客 户信息的泄露。比较好的做法是为Order类定义一个专门的传输格式,只包含尽可能少的信息。进行序列化时,实际上使用的是专门的传输对 象。代码清单10-20给出了Order类的代码。在Order类的writeReplace方法中,从当前对象中创建出一个传输时使用的OrderTO类的对象。 代码清单10-20 序列化时进行对象替换的示例 public class Order implements Serializable{ private User user; private String id; public Order(String id, User user){ this.id=id; this.user=user; } public String getId(){ return this.id; } private Object writeReplace()throws ObjectStreamException{ return new OrderTO(this); } } 代码清单10-21给出了OrderTO类的实现。OrderTO类本身使用了默认的序列化格式,只包含OrderTO类中表示订单编号的orderId域的内容。 在调用readResolve方法时,基本的反序列化操作已经完成,orderId域已经被初始化为正确的值。在readResolve方法中通过相关的查找逻辑根 据域orderId的值得到对应的Order类的对象。把Order类的对象作为readResolve方法的返回值,即反序列化的最终结果。对调用者来说,替换 对象的存在是透明的。 代码清单10-21 替换对象的Java类 public class OrderTO implements Serializable{ private String orderId; public OrderTO(Order order){ this.orderId=order.getId(); } private Object readResolve()throws ObjectStreamException{ return OrderGateway.getOrder(orderId); } } 经过对象替换之后,序列化之后的字节流中只包含订单的编号,并没有其他额外的信息。因此,即便被第三方窃取,也不会出现信息泄 露。 10.6.4 版本更新 在介绍自定义序列化格式时提到了由于版本更新带来的序列化格式不兼容的问题。版本更新所带来的不兼容问题是程序开发中的常见问 题。版本升级之后,可能造成之前的程序无法正确运行。不过,如果程序中使用了序列化机制,兼容性的问题就会更多。旧版本的Java对象序 列化后的字节流通常会保存在磁盘文件或数据库之中。这些持久存储中的数据的保存时间一般都很长,而且都记录了程序运行的历史数据。当 版本更新之后,要求这些历史数据仍然是可用的。程序在更新过程中应该尽可能保证序列化格式的兼容性。但是在有些情况下,可能无法做到 完全兼容,可以选择从某些版本开始不再与之前的版本保持兼容。一般来说,在新的版本中添加域不会产生什么问题,而删去一些域或改变已 有域的行为则不行。 在使用ObjectInputStream类的对象读取一个旧版本的Java对象的序列化结果时,会尝试在当前虚拟机中查找其Java类的定义。如果找不到 类的定义,就尝试加载该Java类,被加载的有可能是新版本的Java类。需要一种方式来判断旧版本的序列化内容是否与当前的Java类兼容。如 果不兼容,那么读取操作会直接失败。这种兼容性的判断是通过Java类中的全局唯一标识符serialVersionUID来实现的。当Java类实现了 Serializable接口时,需要声明该Java类唯一的序列化版本号。这个版本号会被包括在序列化后的字节流中。在进行读取时,会比较从字节流 中得到的版本号与对应Java类中声明的版本号是否一致,以确定两者是否兼容。只有在版本一致时,序列化操作才能完成。代码清单10-18和代 码清单10-19中的两个NewUser类的serialVersionUID域的值均为1,说明这两个类在序列化格式上是兼容的。如果NewUser类的后续更新造成了 序列化格式不再兼容,那么需要把serialVersionUID域设置成另外一个不同的值。 如果Java类实现了Serializable接口,但是没有显式地声明serialVersionUID域,那么虚拟机会根据Java类的各种元素的特征计算出一个 散列值,作为serialVersionUID域的值。默认生成的serialVersionUID域的值会随着Java类的更新而发生变化。如果使用默认的序列化格式, 那么默认生成的serialVersionUID域是合理的。如果使用自定义的序列化格式,则通常要显式地声明serialVersionUID域的值。可以通过Java 提供的serialver命令行工具生成一个Java类的serialVersionUID域的值。在Eclipse中,如果Java类实现了Serializable接口,那么Eclipse会 提示并辅助生成这个serialVersionUID域。 10.6.5 安全性 在使用了对象序列化机制之后,在虚拟机中运行的活动对象的内部状态被持久化,可以通过网络及其他方式进行传输。这会使一些内部数 据存在暴露的风险。对象序列化后的字节流的格式是固定的,可以很容易地从中分析出相关的数据。为了避免产生安全方面的问题,应该从多 个方面着手解决。 第一个方面是根据信息隐藏的原则,尽可能地减少序列化结果中包含的信息。所包含的信息越少,泄露之后造成的影响就越小。通过自定 义的序列化格式和对象替换可以实现这一点。 第二个方面是对包含序列化结果的字节流进行保护。措施主要是加密和解密。可以对序列化后的字节流的整体使用各种加密算法进行处 理。在反序列化之前再使用相应的解密算法进行处理即可。加密和解密也可以在序列化的过程中进行。在Java类的writeObject方法中,可以在 写入域的值之前,先进行加密操作。在readObject方法中,在读取域的值之后,先进行解密操作,再赋值给对象中的域。这两种方式可以结合 起来形成复杂的加密方案。 第三个方面是在从字节流中进行反序列化操作时进行数据完整性验证。在使用ObjectInputStream类的对象进行读取操作时,输入字节流的 内容有可能已经被篡改,因此在Java类的readObject方法中需要添加相应的验证代码来检查完整性是否被破坏,比如验证域的值是否为null, 以及域的值是否在合理的范围之内等。在readObject方法中的处理适合于对单个Java类的对象进行验证。要对一个完整的对象图进行验证,可 以通过ObjectInputStream类的registerValidation方法添加java.io.ObjectInputValidation接口的实现对象,进而添加完整的对象验证逻 辑。通常的做法是在readObject方法中处理完所有域之后,再添加一个ObjectInputValidation接口的实现对象来进行完整的验证。验证通常在 引用关系根节点的Java类的readObject方法中进行。代码清单10-22给出了NewUser类的对象的验证方法的示例。类UserValidator完成具体的验 证工作,检查age域的值是否合法。如果验证中发现了错误,则直接抛出java.io.InvalidObjectException异常。在readObject方法中,对作为 参数传入的ObjectInputStream类的对象添加UserValidator类的对象来执行验证逻辑。 代码清单10-22 对象完整性验证的示例 private void readObject(ObjectInputStream input)throws IOException, ClassNotFoundException{ input.defaultReadObject(); int age=input.readInt(); this.birthDate=ageToDate(age); input.registerValidation(new UserValidator(this),0); } private static class UserValidator implements ObjectInputValidation{ private NewUser user; public UserValidator(NewUser user){ this.user=user; } public void validateObject()throws InvalidObjectException{ if(user.getAge()<0){ throw new InvalidObjectException("非法的年龄数值。"); } } } 10.6.6 使用Externalizable接口 Java类只需要简单地实现标记接口Serializable就可以使用Java平台提供的默认序列化机制。如果希望进行自定义的序列化处理,那么可 以在Java类中添加writeObject方法和readObject方法。虽然可以进行自定义,但是完整的序列化过程仍然是由Java平台来控制的,无法修改序 列化之后的二进制格式。如果希望对序列化的过程进行完全的控制,那么可以实现java.io.Externalizable接口。Externalizable接口继承自 Serializable接口,包含writeExternal和readExternal两个方法。在使用ObjectOutputStream类的对象写入Java对象时,如果该对象的Java类 实现了Externalizable接口,则writeExternal方法会被调用;在使用ObjectInputStream类的对象读取Java对象时,如果实现了 Externalizable接口,则readExternal方法会被调用。方法writeExternal和readExternal的作用类似于自定义序列化操作时使用的 writeObject和readObject方法,只不过这两个方法是在Externalizable接口中显式声明的,更容易被开发人员所理解。代码清单10-23给出了 一个实现了Externalizable接口的Java类的示例。在writeExternal方法中,使用作为参数传入的java.io.ObjectOutput类的对象来写入数据; 在readExternal方法中,则使用作为参数传入的java.io.ObjectInput类的对象来读取数据并进行赋值。 代码清单10-23 Externalizable接口的使用示例 public class ExternalizableUser implements Externalizable{ private String name; private String email; public ExternalizableUser(){ } public ExternalizableUser(String name, String email){ this.name=name; this.email=email; } public String getName(){ return this.name; } public String getEmail(){ return this.email; } public void writeExternal(ObjectOutput out)throws IOException{ out.writeUTF(getName()); out.writeUTF(getEmail()); } public void readExternal(ObjectInput in)throws IOException, ClassNotFoundException{ name=in.readUTF(); email=in.readUTF(); } } 实现Externalizable接口的Java类必须具备一个不带参数的公开构造方法。在反序列化的过程中,如果对象的Java类实现了 Externalizable接口,则先调用不带参数的公开构造方法得到一个新的对象,再在此对象上调用readExternal方法。 10.7 小结 本章的主要内容是关于Java对象生命周期的各个不同阶段的技术细节的。在对象被创建之前,对象的Java类需要被正确地加载、链接和初 始化。在对象的创建过程中,会调用当前类和父类的构造方法来完成对象本身的初始化工作。当不再需要对象时,可以将其销毁。可以通过对 象复制机制来复制一个Java对象。当需要保存对象的内部状态时,可以将对象序列化机制和持久化技术结合起来使用。 在使用这些技术时会遇到的一个问题是已有的技术不太符合一般的惯例,容易造成开发人员的误解。比如Cloneable接口中并没有声明 clone方法,而是通过声明Cloneable接口来改变Object类中的clone方法的行为。在自定义序列化操作时,很多方法的使用都是隐式的,如 writeObject、readObject、writeReplace和readResolve方法等。为了正确使用序列化机制,开发人员需要了解这些隐含方法的使用。 第11章 多线程与并发编程实践 提到使用Java开发多线程程序,很多开发人员可能都认为是一个非常艰巨的任务。虽然有非常多的书籍或在线资料来介绍Java多线程开发 相关的知识,但是开发出正确的多线程程序对开发人员来说仍然充满挑战。Java多线程开发中主要有三个困难。首先是编写正确的程序很难。 不论是Java中的基本原语,还是java.util.concurrent包中的辅助工具类,要正确使用都不容易。其次是很难测试程序是否正确。在实际运行 时,多个线程中的代码的执行顺序是无法确定的。程序在很多次运行时都不出现错误,并不表示该程序就是完全正确的。最后是在程序出现问 题时很难调试。进行调试的一个先决条件是找到确定的重现错误的步骤。一个多线程程序可能在连续正常运行很长时间后突然出现与多线程相 关的错误。在修改代码之后,即便程序运行了很长时间都没出现问题,也不能保证问题已经被修复。 对于Java多线程开发中的困难也并非“无计可施”。两个重要的武器是基础知识和实用工具。善用实用工具可以提高多线程程序的开发效 率,避免常见的错误;而掌握基础知识是解决特定问题和正确使用工具的基础。本章围绕这两方面展开,大致上可以划分成三个部分:第一个 部分是与多线程相关的基础知识,主要从Java虚拟机的层次进行介绍;第二个部分是基本的线程同步方式,主要介绍锁机制和Object类中的 wait、notify和notifyAll等方法;第三个部分是java.util.concurrent包中提供的抽象层次更高的实用工具,以及Java SE 7中新增的内容。 11.1 多线程 在操作系统中,两个比较容易混淆的概念是进程(process)和线程(thread)。操作系统中的进程是一个计算机程序的运行实例。计算机 程序中包含了需要执行的指令,而进程则表示正在执行的指令。对同一个计算机程序可以创建多个进程。这些进程的运行状态各不相同。进程 一般作为资源的组织单位。进程有自己独立的地址空间,包含程序内容和数据。不同进程的地址空间是互相隔离的。进程拥有各种资源和状态 信息,包括打开的文件、子进程和信号处理器等。线程表示的是程序的执行流程,是CPU调度执行的基本单位。线程有自己的程序计数器、寄存 器、堆栈和帧等。同一进程中的线程共用相同的地址空间,同时共享进程所拥有的内存和其他资源。 引入线程的主要动机在于提高程序的运行性能。在一个程序中主要存在使用CPU和I/O操作的两类计算。I/O操作相对CPU运算来说比较耗 时,而且很多都是阻塞式的。当一个线程所执行的I/O操作被阻塞时,同一进程中的其他线程可以使用CPU来进行计算。在资源允许时,多个线 程可以同时进行I/O操作。这种方式提高了操作系统中资源的使用效率,进而提高了程序的运行性能。线程的概念在主流操作系统和编程语言中 都得到了支持。不同操作系统和编程语言中的线程的使用方式有很大的区别。这对于开发跨平台的多线程程序来说是一个不小的挑战。Java平 台通过Java虚拟机解决了跨平台的问题,使由相同API开发的多线程程序在不同平台上都能够正确运行。 Java标准库提供了与进程和线程相关的API。第6章具体介绍了表示进程的java.lang.Process类和创建进程的java.lang.ProcessBuilder类 的使用方式。表示线程的是java.lang.Thread类。在虚拟机启动之后,通常只有一个普通线程来运行程序的代码。这个线程用来启动主Java类 的main方法的运行。程序在运行时可以根据需要创建新的线程并启动线程的运行。除了普通线程之外,还有一类线程是守护线程(daemon thread)。守护线程一般在后台运行,提供程序运行时所需的服务。当虚拟机中运行的所有线程都是守护线程时,虚拟机终止运行。 线程表示的是一段程序的执行过程。线程中最重要的部分是所要执行的代码逻辑。有两种方式可以创建线程。第一种方式是继承Thread类 并覆写run方法,在run方法中包含线程的执行逻辑。第二种方式是实现java.lang.Runnable接口,并在Thread类的构造方法中传入Runnable接 口的实现对象。使用这种方式创建的Thread类的对象的run方法中,调用的实际是Runnable接口中的run方法。在创建出Thread类的对象之后, 通过调用start方法启动线程的运行。当线程的代码逻辑执行完毕之后,线程会自动结束。 Java线程API的具体实现由底层的Java虚拟机来负责提供。为了更好地理解线程API的使用及多线程开发,需要对虚拟机内部的相关机制有 一定的了解。下面的内容围绕Java语言规范中定义的Java内存模型展开,涉及可见性问题的基本概念以及相关的volatile和final关键词。 11.1.1 可见性 在一个多线程程序中,多个线程通过共同协作来完成指定的任务。在协作的过程中,线程之间需要进行数据交换以协调各自的状态。同一 个进程中的各个线程通过共享内存的方式来进行通信。由于这些线程共享所在进程的地址空间,因此都可以自由访问所需的内存位置。互相协 作的线程之间对共享的内存位置达成一致。一个线程在适当的时候修改该内存位置的值,另外一个线程在后续的操作中通过读取相同内存位置 来得到修改后的值。Java中的代码无法直接操作内存,而是通过不同类型的变量来间接访问。比如线程A和线程B共同协作完成某项任务,线程B 需要等待线程A完成其任务之后才能继续运行,两个线程可以使用一个boolean类型的变量来协调状态。当线程A完成任务之后,修改该变量的值 为true,以通知线程B。线程B在运行时,如果读取到该变量的值为true,就开始执行自身的操作。使用共享内存方式在多线程程序中可能造成 可见性相关的问题,即一个线程所做的修改对于其他的线程并不可见,导致其他的线程仍然使用错误的值。比如线程B看不到线程A对该boolean 类型变量所做的修改,造成线程B一直等待下去。 每个线程在运行过程中所做的事情是按照代码中编写的逻辑来执行对应的虚拟机字节代码指令。Thread类或Runnable接口实现类中的run方 法所包含的代码就是线程要执行的指令序列的来源。对于一个单线程程序来说,可见性的含义是很容易理解和验证的。在代码执行过程中,如 果先改变一个变量的值,再读取该变量的值,那么所读取的值是上次写入操作所写入的值。也就是说前面的写入操作的结果对后面的读取操作 是肯定可见的。这个在单线程程序中显而易见的特征,在多线程程序中则不一定成立。如果不使用某些互斥或同步机制,则不能保证一个线程 所写入的值对另外一个线程是可见的。如果可见性条件不能成立,那么程序在运行中就会出现问题。 代码清单11-1给出了一个简单的Java类IdGenerator,用来生成唯一的标识符。在单线程程序中,每次对getNext方法的调用都可以保证得 到一个不重复的值。对于一个IdGenerator类的对象来说,域value的初始值为0。每调用一次getNext方法,value的值会加1。每次调用后的 value的新值,对后续的调用都是可见的。 代码清单11-1 生成唯一标识符的Java类 public class IdGenerator{ private int value=0; public int getNext(){ return value++; } } 如果在多线程程序中使用IdGenerator类的对象,则不能保证每次调用getNext方法所返回的值是不重复的。在多线程程序中,可以运行的 各个线程之间相互竞争CPU时间来获取运行的机会。一般CPU采用时间片轮转等不同算法来对线程进行调度。当调度发生时,当前正在运行的线 程被暂停运行。CPU进行上下文切换,开始运行其他线程的代码。在上下文切换时,之前运行线程的当前状态会被记录下来,以便在下次被调度 时从上次被中断的地方继续运行。CPU进行调度的时机是不可预知的,可能发生在当前运行线程的任何两条指令的执行间隙。代码清单11-1中的 getNext方法虽然只有一行代码,但是对应于7条字节代码指令,具体的指令序列如代码清单11-2所示。 代码清单11-2 IdGenerator类的getNext方法的指令序列 aload_0 dup getfield#12 dup_x1 iconst_1 iadd putfield#12 这里对代码清单11-2中的指令序列进行简单说明:aload_0指令的含义是把第一个局部变量加载到操作数堆栈中,这里的第一个局部变量是 this;dup指令的含义是把操作数堆栈中的栈顶元素进行复制;getfield指令的含义是获取操作数堆栈中栈顶元素表示的对象中的指定域的值, 并将其压入堆栈中;dup_x1指令的含义是复制操作数堆栈中的栈顶元素,并将其插入到距离栈顶两个元素的位置上;iconst_1指令的含义是把 常量1压入栈中;iadd指令的含义是从操作数堆栈中弹出两个元素,进行整数加法操作,再把结果压入栈中;putfield指令的含义是从操作数堆 栈中依次弹出要设置的域的值和所在的对象的引用,进行域的赋值操作。这个指令序列的作用是先读取域value的值,再把该值加上1,最后把 所得的新值赋值给域value。 当多个线程共享同一个IdGenerator类的对象时,可能某个线程在执行getNext方法对应的7条指令的过程中CPU进行了线程切换,该线程的 运行被暂停,而另外一个共享了相同IdGenerator类的对象的线程获得了运行的机会。多个线程执行getNext方法的实际指令序列是交织在一起 的。考虑下面的线程运行情况:该IdGenerator类的对象的域value的值当前为1。线程A执行到了getfield指令,把获取的域value的值1压入操 作数堆栈中。接着线程A暂停运行,线程B获得了运行的机会。当线程B执行getfield指令时,所看到的域value的值也是1。线程B继续执行,把 域value的值更新为2之后,返回域value之前的值1作为结果。然后线程A再次获得运行的机会,继续从上次暂停的指令开始运行。由于当前操作 数堆栈中的值为1,线程A仍然使用这个值来继续运行,把域value的值更新为2,而返回的结果同样为1。在这个情况下,线程A和线程B使用的是 值相同的标识符,出现了错误。这是因为线程B对IdGenerator类的对象所做的修改并没有被线程A所看到。线程A仍然使用的是它上次读取操作 时获取的旧值。 除了多个线程的实际执行顺序之外,还有其他一些原因会造成与可见性相关的问题。目前CPU一般采用层次结构的多级缓存的架构。有的 CPU提供了L1、L2和L3三级缓存。现在硬件平台多采用多核CPU或多个CPU。当CPU需要读取主存中某个位置的数据时,会依次检查各级缓存中是 否存在对应的数据。如果有,直接从缓存中读取。这比从主存中读取速度快很多。在进行写入时,数据先被写入缓存中,之后在某个特定的时 间被写回主存中。不同的CPU可能采用不同的写入策略,如写穿透或写返回等。由于缓存的存在,在某些时间点上,缓存中的数据与主存中的数 据可能是不一致的。某个线程所执行的写入操作的新值当前可能在CPU的缓存中,还没有被写回到主存中,这时另外一个线程的读取操作所读取 的可能还是主存中的旧值。这样的问题主要出现在包含多核CPU或多个CPU的操作系统中。另外一个造成与可见性相关的问题的原因是代码重 排。出于性能的考虑,编译器在编译时可能会对生成的字节代码中的指令顺序进行重新排列,以优化指令的执行顺序。CPU也可能改变指令的执 行顺序。指令顺序的重新排列在单线程程序中通常不会产生问题,但是在多线程程序中可能产生与可见性相关的问题。 这些可见性相关的问题与底层硬件平台和操作系统密切相关。Java平台的做法是提供一个抽象的模型来描述多线程程序中变量的访问语 义,提供相关的API来允许开发人员定义线程之间的交互方式,最后由Java虚拟机和编译器来保证该抽象模型的语义在不同平台上都有一致的行 为。开发人员只需要正确使用Java平台提供的API,就可以开发出线程安全的多线程程序。 11.1.2 Java内存模型 Java内存模型(Java Memory Model)描述了程序中共享变量的关系以及在主存中写入和读取这些变量值的底层细节。Java内存模型作为一 个抽象模型,只关注主存中的共享变量。只关注主存可以对开发人员屏蔽CPU缓存等细节,简化内存模型自身的定义。对象的实例域、静态域和 数组元素存储在堆内存中,可以被多个线程共享。这些变量是内存模型需要关注的内容。局部变量、方法的形式参数和异常处理时的异常参数 都是不被共享的,不会受到内存模型的影响。当对共享变量的两个访问中至少有一个是写入操作时,这两个访问存在冲突。两个互相冲突的访 问的执行结果由内存模型来确定。 在一个单线程程序的运行过程中,指令的执行结果是可预期的。编译器有可能会对某些指令进行重排,但这些操作不会影响程序的行为。 在一个多线程程序中,线程所执行的动作可以分成两类:一类是线程内部的动作,比如操作方法中的局部变量等;另外一类是线程之间的动 作。线程之间的动作是由一个线程产生的动作,同时可以被另外一个线程检测到或受到另外一个线程的直接影响。线程之间的动作包括读取和 写入共享变量以及加锁和解锁等同步操作。线程内部的动作不会产生可见性相关的问题,因此内存模型只考虑线程之间的动作。 在一个线程的运行过程中,如果假设该线程处于隔离状态,即其他线程都不存在时,那么该线程所产生的所有线程之间的动作会按照某个 顺序来执行。这个执行顺序由程序内部的代码逻辑来确定,称为程序顺序(program order)。在多个线程同时运行时,所产生的线程间的动作 会交织在一起,形成实际的执行顺序。如果这个实际的执行顺序与每个线程隔离运行时的程序顺序保持一致,同时每个读取操作所看到的都是 最近一次写入操作的执行结果,则称这个执行顺序是具备顺序一致性的。顺序一致性是一个非常严格的约束。如果Java内存模型要求动作的执 行顺序满足顺序一致性的要求,则很多CPU和编译器进行的优化措施都是不允许的。 线程间的动作中的很大一部分是同步动作。如果只考虑同步动作,则将这些动作的执行顺序称为同步顺序(synchronization order)。同 步动作的出现在动作之间形成了同步关系。同步关系定义了同步动作之间的先后顺序。这种顺序是强制的。以加锁动作为例,在成功加锁之 前,对应的锁要被释放,否则加锁动作无法成功。所以成功的加锁动作必然在某个解锁动作之后。常见的同步关系如下所示,其中“A与B保持 同步”的含义是A必然发生在B之前。 1)在一个监视器对象上的解锁动作与相同对象上后续成功的加锁动作保持同步。 2)对一个声明为volatile的变量的写入动作与同一变量的后续读取动作保持同步。 3)启动一个线程的动作与该线程执行的第一个动作保持同步。 4)向线程中共享变量写入默认值的动作与该线程执行的第一个动作保持同步。这种同步关系的含义是在线程运行之前,该线程所使用的全 部对象从概念上说已经被创建出来,并且对象中的变量被赋值为默认值。这种同步关系的含义是保证线程所看到的变量的值是确定的。变量的 值可能是根据变量类型确定的默认值,也可能是其他线程所设置的值,但不可能是其他值。 5)线程A运行时的最后一个动作与另外一个线程中任何可以检测到线程A终止的动作保持同步。 6)如果线程A中断线程B,那么线程A的中断动作与任何其他线程检测到线程B处于被中断的状态的动作保持同步。 除了程序顺序和同步顺序之外,还有一种更加实用的顺序,即“在之前发生(happens-before)”顺序。如果一个动作按照“在之前发 生”的顺序发生在另外一个动作之前,那么前一个动作的执行结果在多线程程序中对后一个动作是肯定可见的。“在之前发生”顺序的情况包 括: 1)如果两个动作A和B在一个线程中执行,同时在程序顺序中A出现在B之前,则A在B之前发生。 2)一个对象的构造方法的结束在该对象的finalize方法运行之前发生。 3)如果动作A和动作B保持同步,则A在B之前发生。 4)如果动作A在动作B之前发生,同时动作B在动作C之前发生,则A在C之前发生。也就是说,“在之前发生”顺序是传递性的。 如果程序中存在对共享变量的互相冲突的访问,且这些访问没有通过“在之前发生”顺序来正确排列时,那么程序中存在数据竞争(data race)。数据竞争的存在是多线程程序运行时产生错误的根源。编写多线程程序时的首要任务是找出并解决程序中存在的数据竞争。代码清单 11-1中的getNext方法在调用时存在数据竞争。不同线程都需要读写共享变量value的值,但是这些访问动作没有定义“在之前发生”顺序,因 此程序运行可能出现错误。如果程序中不存在数据竞争,则程序的任何执行方式都具备顺序一致性。开发人员需要做的是利用Java平台提供的 支持来消除程序中的数据竞争,这样就可以保证程序的正确性。不需要考虑CPU和编译器可能进行的指令重排,因为Java虚拟机和编译器会确保 这些指令重排不会影响程序的正确性。 11.1.3 volatile关键词 关键词volatile用来对共享变量的访问进行同步。对一个volatile变量的上一次写入操作和下一次读取操作之间存在“在之前发生”的顺 序。也就是说,上一次写入操作的结果对下一次读取操作是肯定可见的。在写入volatile变量值之后,CPU缓存中的内容会被写回主存;在读取 volatile变量时,CPU缓存中的对应内容被置为失效状态,重新从主存中进行读取。将变量声明为volatile相当于为单个变量的读取和写入添加 了同步操作。但是volatile在使用时不需要利用锁机制,因此性能要优于synchronized关键词。关键词volatile的主要作用是确保对一个变量 的修改被正确地传播到其他线程中。最常使用volatile变量的一个场景是把作为循环结束的判断条件的变量声明为volatile。比如有两个线程A 和B,线程A在一个循环中不断进行处理,线程B负责向线程A发送停止处理的信号。代码清单11-3中给出了这样的示例。线程A调用了Worker类的 对象的work方法,开始执行具体的任务。在适当的时候,线程B会调用同一Worker类的对象的setDone方法来声明终止任务的执行。把done变量 声明为volatile是很重要的。只有这样才能保证线程B对done变量所做的修改对于线程A的后续读取操作是可见的。否则,线程A可能由于无法看 到done变量值的变化而一直运行下去。 代码清单11-3 使用volatile变量作为循环结束的判断条件 public class Worker{ private volatile boolean done; public void setDone(boolean done){ this.done=done; } public void work(){ while(!done){ //执行任务 } } } 虽然volatile关键词使用简单,但是由于在实现时没有锁机制的存在,volatile关键词的适用场景是受限的。比如,对于代码清单11-1中 的IdGenerator类,如果只是把域value声明为volatile,这样是不够的,仍然会出现问题。这是因为写入的value的正确值依赖于value的当前 值,而当前值有可能是不正确的。代码清单11-3中要写入变量done的新值与该变量的当前值没有关系,使用volatile就足够了。 11.1.4 final关键词 在Java语言中,final关键词有很多语义,比如声明为final的类无法被继承,声明为final的方法无法在子类中被覆写。从内存模型的角度 来说,final关键词最重要的语义是声明一个域的值只能被初始化一次。在初始化之后,该域的值无法被修改。在多线程程序开发中,final域 通常用来实现不可变对象(immutable object)。当对象中的共享变量的值不可能发生变化时,在多线程访问时不会出现问题,也就不需要使 用线程之间的同步机制来进行处理。在Java标准库中最常用的不可变对象是String类的对象。在多线程程序中,应该尽可能地使用不可变对 象,以避免使用同步机制。代码清单11-4给出了不可变对象的类的声明方式。把类中所有域声明为final,并在构造方法中进行初始化。 代码清单11-4 不可变对象的类的声明方式 public class User{ private final String name; private final String email; public User(String name, String email){ this.name=name; this.email=email; } } 在构造方法成功完成之前,要确保正在创建的对象的引用不会被其他线程访问到,否则,其他线程可能看到部分创建完成的对象。代码清 单11-5给出了一个错误的示例。在WrongUser类的构造方法中,把当前对象的引用赋值给UserHolder类的静态变量user,会导致使用UserHolder 类的线程看到尚未创建完成的WrongUser类的对象。该对象中包含的变量可能没有被初始化成正确的值。 代码清单11-5 在构造方法中添加当前对象的引用的错误示例 public class WrongUser{ private final String name; public WrongUser(String name){ UserHolder.user=this; this.name=name; } } 如果一个线程是在对象的构造方法成功完成之后才通过该对象的引用来进行访问的,那么该线程肯定可以看到对象中的final域被初始化之 后的值。如果域没有被声明为final,则构造方法完成之后,其他线程不一定可以看到这个域被初始化之后的值,而有可能看到域的默认值。由 于final域具有这些特征,编译器对final域的处理是很灵活的。这些域可能被随意地与其他代码进行重新排列。在代码执行时,final域的值可 以被保存在寄存器中,而不用从主存中频繁重新读取。 11.1.5 原子操作 在介绍代码清单11-1中的IdGenerator类的getNext方法时提到过,Java代码中的一条语句,可能实际上对应的是多条字节代码指令。CPU在 一条指令的执行过程中不会进行线程调度和上下文切换,但是在两条指令的执行间隙,可能发生线程切换。如果代码清单11-1中 的“value++”语句由一条CPU指令来完成,就不存在多线程访问时可能出现的问题。 在Java中,对于非long型和double型的域的读取和写入操作是原子操作。对象引用的读取和写入操作也是原子操作。比如读取一个int类型 的域时,该域对应的内存地址中的32位的内容会被完整地读取,在读取过程中不会被其他线程所打断。在进行写入操作时也不会被打断。在写 入非volatile的long型和double型的域的值时,分成两次操作来完成。一个long型或double型的域的长度是64位,每次写入32位。在一个线程 写入了long型或double型的域的前32位之后,在写入后32位之前,另外一个线程有可能访问这个域的值,从而读取只完成部分写入操作的错误 值。因此在多线程程序中使用long型和double型的共享变量时,需要把变量声明为volatile,以保证读取和写入操作的完整性。 11.2 基本线程同步方式 对于多线程程序中存在的数据竞争,需要利用Java平台提供的同步机制来确保对共享变量的访问存在合适的“在之前发生”的顺序。Java 平台提供的基本同步方式包括synchronized关键词和Object类中提供的wait、notify和notifyAll方法。 11.2.1 synchronized关键词 synchronized关键词应该是最为开发人员所熟悉的多线程开发时可以使用的结构,是基本的线程同步方式之一。synchronized关键词可以 添加在方法或代码块之上。声明为synchronized的方法或代码块在同一时刻只能有一个线程允许访问。如果当前已经有线程正在访问 synchronized方法或代码块,那么其他试图访问该方法或代码块的线程会处于等待状态。这种互斥性使该方法或代码块中的代码逻辑实际上成 为一个原子操作。从“在之前发生”顺序的角度更容易理解synchronized关键词的含义。所有的Java对象都有一个与之关联的监视器对象 (monitor),允许线程在该监视器对象上进行加锁和解锁操作。每个synchronized关键词在使用时都与一个监视器对象相对应。对于声明为 synchronized的方法,静态方法对应的监视器对象是所在Java类对应的Class类的对象所关联的监视器对象,而实例方法使用的是当前对象实例 所关联的监视器对象。对于synchronized代码块,对应的监视器对象是synchronized代码块声明中的对象所关联的监视器对象。 在一个线程允许执行方法或代码块之前,需要先获取对应的监视器对象上的锁。在执行完成之后,该线程所持有的锁会被自动释放。根 据“在之前发生”顺序,上一次的解锁操作肯定在下一次的成功加锁操作之前发生。由于解锁操作是上一次线程在synchronized方法或代码块 中执行的最后一个动作,而加锁操作是下一次线程执行synchronized方法或代码块时的第一个动作,所以上一次线程运行时对共享变量所做的 修改对下一次线程中的动作是肯定可见的。这是开发人员使用synchronized关键词时可以得到的保证。Java虚拟机和编译器会负责完成实际的 工作。当锁被释放时,对共享变量的修改会从CPU缓存中直接写回到主存中;当锁被获取时,CPU的缓存中的内容被置为无效的状态,从主存中 重新读取共享变量的值。当有线程在执行synchronized方法或代码块时,其他线程由于无法获取锁而处于等待状态,不会影响当前线程的运 行。编译器在处理synchronized方法或代码块时,不会把其中包含的代码移动到synchronized方法或代码块之外,从而避免了由于代码重排而 造成的问题。 synchronized关键词的使用比较简单。代码清单11-6给出了对代码清单11-1中IdGenerator类的修改方式。类SynchronizedIdGenerator中 的getNext方法使用了synchronized来声明,而getNextV2方法则包含了一个synchronized代码块。两个方法的作用是相同的。由于getNext方法 是一个对象的实例方法,因此在同步时使用的监视器对象是当前对象实例所关联的监视器对象。而getNextV2方法中的synchronized代码块声明 中也同样使用了当前对象实例this。 代码清单11-6 使用synchronized生成唯一标识符的Java类 public class SynchronizedIdGenerator{ private int value=0; public synchronized int getNext(){ return value++; } public int getNextV2(){ synchronized(this){ return value++; } } } 在使用synchronized时有一个错误倾向,那就是被synchronized所保护的代码过多,比如一个方法中只有少数几行代码访问共享变量,却 把整个方法声明为synchronized。这么做虽然不会对程序的正确性造成影响,但是会影响程序的性能。正确的做法是把方法中需要同步的代码 用synchronized代码块包围即可。 11.2.2 Object类的wait、notify和notifyAll方法 使用synchronized关键词主要用来实现线程之间的互斥,即同一时刻只有一个线程允许执行特定的代码。通过互斥的方式来保证多个线程 访问共享变量时的正确性。除了互斥访问之外,线程之间也需要通过协作的方式来完成某些任务。比如在典型的生产者-消费者场景中,生产者 线程在缓冲区已满时需要等待,等消费者线程从缓冲区中取走数据之后,再进行生产;消费者线程在缓冲区已空时需要等待,等生产者线程向 缓冲区中放入数据之后,再进行消费。这种协作方式可以抽象为等待-通知机制。在线程运行时,可能需要满足某些逻辑条件才能继续进行。当 线程所要求的条件不满足时,线程进入等待状态,等待由于其他线程的运行而使条件得到满足;其他线程则负责在合适的时机发出通知来唤醒 处于等待状态的线程,表明等待线程所要求的条件已经满足。这是一种在多线程程序中经常会遇到的典型场景,即判断是否满足条件之后再选 择继续运行。可以用代码清单11-3中的while循环和volatile变量来处理这个场景。但是这种做法的本质是让线程处于忙等待的状态,并通过轮 询的方式来判断条件是否满足。处于忙等待状态的线程仍然需要占用CPU时间,会对性能造成影响。更好的做法是使用Object类提供的wait、 notify和notifyAll方法。 由于wait方法是在Object类中定义的,因此可以调用任何Java对象的wait方法。使用wait方法的关键在于理解调用wait方法的含义。Java 中的每个对象除了有与之关联的监视器对象之外,还有一个与之关联的包含线程的等待集合。在调用wait方法时,该方法调用的接收者所关联 的监视器对象是所使用的监视器对象,同时wait方法所影响的是执行wait方法调用的当前线程。成功调用wait方法的先决条件是当前线程获取 到监视器对象上的锁。如果没有锁,则直接抛出java.lang.IllegalMonitorStateException异常,wait方法调用失败;如果有锁,那么当前线 程被添加到对象所关联的等待集合中,并释放其持有的监视器对象上的锁。当前线程被阻塞,无法继续执行,直到被从对象所关联的等待集合 中移除。 由于wait方法的成功调用需要当前线程持有监视器对象上的锁,因此wait方法的调用需要放在使用synchronized关键词声明的方法或代码 块中。当执行wait方法时,当前线程已经进入了synchronized关键词所声明的互斥块中,已经持有所需的锁。在synchronized方法或代码块中 使用的监视器对象必须是wait方法调用的接收者所关联的监视器对象。 通过调用wait方法进入的等待状态分成无超时和有超时两种。如果线程处于有超时的等待状态,那么线程除了可以被主动唤醒而离开等待 状态之外,设定的超时时间过去后也会自动离开等待状态。在设定超时时间时可以指定毫秒数和纳秒数。 wait方法的作用是使当前线程进入等待状态,对应的notify和notifyAll方法用来通知线程离开等待状态。调用一个对象的notify方法会从 该对象关联的等待集合中选择一个线程来唤醒。被唤醒的线程可以和其他线程竞争运行的机会。与notify方法相对应的notifyAll方法会唤醒对 象关联的等待集合中的所有线程。而notify方法所唤醒的线程的选择由虚拟机实现来决定,不能保证一个对象所关联的等待集合中的线程按照 所期望的顺序被唤醒。很可能一个线程被唤醒之后,发现它所要求的条件并没有满足,而重新进入等待状态,而真正需要被唤醒的线程却仍然 处于等待集合中。因此,当等待集合中可能包含多个线程时,一般使用notifyAll方法。不过notifyAll方法会导致线程在没有必要的情况下被 唤醒,之后又马上进入等待状态,因此会造成一定的性能影响,不过可以保证程序的正确性。与wait方法相同,notify和notifyAll方法在调用 时都要求当前线程拥有方法调用接收者所关联的监视器对象上的锁。当线程被唤醒之后,由于在调用wait方法时已经释放了之前所持有的监视 器对象上的锁,线程需要重新竞争锁来获得继续运行wait方法调用完成之后的代码的机会。 需要注意的一个情况是,处于某个对象所关联的等待集合中的线程可能被意外唤醒。这种唤醒不是以开发人员可以预期的方式发生的,而 是由底层操作系统和虚拟机内部实现所产生的非正常行为。这种意外的唤醒无法避免,需要开发人员来处理。以生产者-消费者场景为例,当缓 冲区已满时,生产者线程处于等待状态。消费者线程在从缓冲区中取走数据之后,唤醒生产者线程。但是,当生产者线程被唤醒时,并不意味 着缓冲区肯定是不满的,因为生产者线程有可能在缓冲区已满时被意外唤醒。因此,通常要把wait方法的调用包含在一个循环中。循环的条件 是线程可以继续执行需要满足的逻辑条件。如果线程继续执行的逻辑条件不满足,那么线程应该再次调用wait方法来重新进入等待状态。代码 清单11-7给出了wait方法的一般使用方式。 代码清单11-7 把wait方法调用置于循环之中 synchronized(obj){ while(/*逻辑条件不满足*/){ obj.wait(); } //条件满足 } 11.3 使用Thread类 作为线程的跨平台抽象,Thread类中提供的方法并不多。这主要是因为不同平台上的线程实现差别很大,很难提供一个完整的抽象。在JDK 最早的版本中,Thread类提供了对线程进行控制的一些方法,包括stop、suspend、resume和destroy等,分别用来停止、暂停和继续线程的执 行及销毁一个线程。这些方法在随后的版本中被声明为废弃的,因为这些方法在实现上是不安全的,使用时可能会造成对象损坏和出现线程死 锁的问题。这也从一个侧面说明了跨平台线程实现的复杂性。通过start方法启动一个线程的运行之后,应该让线程运行完其所包含的全部代码 之后自动结束,而不需要通过stop方法强制终止一个线程。要暂停或继续一个线程的执行,可以使用Object类的wait和notify方法提供的线程 等待和唤醒机制。 11.3.1 线程状态 在一个Thread类的对象被创建出来之后,它可能处于不同的状态中。进行与线程相关的不同操作可能导致该Thread类的对象所处的状态发 生变化。不同的线程状态由枚举类型Thread.State来表示,可以通过Thread类的getState方法来得到。Thread.State只表示虚拟机中线程的状 态,并不表示对应的操作系统上的线程的状态。Thread.State中包含的线程状态有以下几种。 1)NEW:线程刚被创建出来。一个新创建的Thread类的对象处于此状态中。 2)RUNNABLE:线程处于可运行的状态。该线程有可能正在运行,也有可能在等待其他操作系统中的资源。 3)BLOCKED:线程在等待一个监视器对象上的锁时,处于此状态。当一个线程尝试执行声明为synchronized的方法或代码块,又无法获取 对应的锁时,处于BLOCKED状态。 4)WAITING:调用某些方法会使当前线程进入等待状态。这个等待没有超时时间。处于这个状态的线程等待其他线程执行特定的操作来使 当前线程退出等待状态。 5)TIMED_WAITING:该状态类似于WAITING,但是增加了指定的超时时间。当超时时间过去,如果线程等待的条件仍然没有发生,那么线程 也会退出等待状态。 6)TERMINATED:线程的运行已经终止。 线程在同一时刻只能处于上述六种状态中的一种。了解线程的状态可以为调试提供帮助。 11.3.2 线程中断 线程中断是线程之间的一种通信方式。通过一个线程对应的Thread类的对象的interrupt方法可以向该线程发出中断请求。根据线程当前所 处的状态不同,中断一个线程会产生不同的效果。在一般的情况下,中断一个线程会在线程对应的Thread类的对象上设置一个标记。该标记用 来记录当前的中断状态。通过Thread类的isInterrupted方法可以查询此标记来判断是否有中断请求发生。当线程A通过调用线程B的interrupt 方法来发出中断请求时,线程A再向线程B发出一个信号。线程B应该在方便的时候来处理这个中断请求,当然这不是必须的。一个线程可以选择 忽略所有或部分中断请求。 Object类的wait方法及Thread类的join方法和sleep方法都会抛出受检异常java.lang.InterruptedException。在调用这3个方法及其重载 形式时,必须捕获InterruptedException异常并进行处理。当线程由于调用这3个方法而进入等待状态时,通过interrupt方法中断该线程会导 致该线程离开等待状态。对于wait方法调用来说,线程需要在重新获取到监视器对象上的锁之后才能抛出InterruptedException异常,并执行 对InterruptedException异常的处理逻辑。 线程中断的一个典型应用场景是实现可取消的任务。有些线程在执行任务时会使用一个无限循环来重复执行。这时需要一种方式来结束线 程的运行。一种做法是使用之前介绍的volatile变量作为结束标记;另外一种做法是向线程发出中断请求。仍然以生产者-消费者场景为例,对 于生产者线程来说,只要缓冲区不为空,就会不断运行,产生新的数据。可以在循环条件中加上对当前线程对应的Thread类的对象的 isInterrupted方法的调用,用来检查是否收到了中断请求。如果收到了中断请求,可以终止执行。 对InterruptedException异常需要谨慎地进行处理。在大多数时候,开发人员只是为了能够通过编译,简单地捕获InterruptedException 异常,而不进行任何处理。这种做法通常是不合适的。当线程由于调用wait、join和sleep方法进入等待状态时,如果收到中断请求,线程就会 进入InterruptedException异常的处理逻辑。如果在当前层次上处理InterruptedException异常是合理的,就直接进行处理;否则应该把 InterruptedException异常重新抛出,由调用者进行处理。在InterruptedException异常发生时,当前线程对应的Thread类的对象内部的中断 标记会被清空,相当于该中断请求已经被InterruptedException异常的处理逻辑处理了。如果在当前InterruptedException异常的处理代码中 不适合处理该异常,又无法把该异常重新抛出,则需要通过interrupt方法来重新中断该线程。这样就保存了当前线程曾经被中断过的状态信 息,可以让后续代码来处理该中断请求。 Thread类中还有一个与线程中断相关的方法interrupted。该方法不但可以判断当前线程是否被中断,还可以清除线程内部的中断标记。如 果调用interrupted方法的返回值为true,说明该线程曾经被中断过。在interrupted方法调用完成之后,线程内部的中断标记已经被清空。 如果一个线程当前处于某个对象所关联的等待集合中,那么中断该线程或发出唤醒通知都可以使该线程从等待集合中被移除。如果两者同 时发生,那么具体的运行结果取决于两者的实际发生顺序,由虚拟机实现来确定。通过notify方法发出的唤醒请求不会被线程中断所影响。当 调用某个对象上的notify方法时,至少有一个线程被唤醒,或者所有的线程都被中断,唤醒请求不会丢失。如果一个线程被选为唤醒的对象, 而该线程同时又被中断,并且虚拟机选择让该线程中断,那么等待集合中的另外一个线程必须被唤醒。 11.3.3 线程等待、睡眠和让步 Thread类的join方法提供了一种简单的同步方式,允许当前线程等待另外一个线程运行结束。根据之前对同步关系的介绍,如果线程A通过 调用线程B的join方法等待线程B运行结束,那么在线程B中对共享变量所做的修改对于线程A是肯定可见的。一般的做法是,在线程A中创建并启 动线程B之后,线程A执行另外的一些操作,接着调用join方法等待线程B完成。线程B和线程A通过修改共享变量的方式来进行交互。代码清单 11-8中给出了join方法的一般使用方式。 代码清单11-8 通过join方法等待线程运行结束 public void useJoin(){ Thread thread=new Thread(){ public void run(){ try{ Thread.sleep(5000); }catch(InterruptedException e){ e.printStackTrace(); } } }; thread.start();//启动线程 //执行其他操作 try{ thread.join();//等待线程运行结束 }catch(InterruptedException e){ e.printStackTrace(); } } Thread类的静态方法sleep可以让当前线程进入睡眠状态一段时间。在睡眠状态下,线程的代码执行会暂停,但是线程不会释放所持有的 锁。因此不要把对sleep方法的调用放在synchronized方法或代码块中,否则会造成其他等待获取锁的线程长时间处于等待状态。 如果当前线程因为某些原因无法继续执行,那么可以使用yield方法来尝试让出所占用的CPU资源,让其他线程获得运行的机会。调用yield 方法对操作系统上的调度器来说是一个信号,但是调度器不一定会立即进行线程切换。调用yield方法可以使线程切换更加频繁,从而让某些与 多线程相关的错误更容易暴露出来。在实际开发中调用yield方法可以作为进行测试的一个辅助手段。 11.4 非阻塞方式 线程之间的同步机制的核心是监视器对象上的锁。不同线程之间通过竞争锁的所有权来获得执行某些特定代码的机会。锁机制的目的是保 证多线程程序运行时的正确性,但是会对性能造成很大的影响。锁机制在运行时的代价很高,涉及CPU和内存方面的很多处理。对于一个声明为 synchronized的方法,有多个线程竞争对应监视器对象上的锁来获取执行该方法的机会。如果其中某一个线程成功获取了对象上的锁,则其他 尝试获取锁的线程会处于等待状态。基于锁机制的实现方式在很大程度上限制了多线程程序的吞吐量和性能,而且会带来死锁和优先级倒置等 问题。 死锁问题指的是两个线程分别持有另外一个线程所需要获取的锁,同时又希望获取另外一个线程已经持有的锁。每个线程都由于等待对方 释放锁而处于等待状态,因此无法释放自己持有的锁来让对方运行。这样造成的结果是两个线程都无法运行。优先级倒置问题指的是线程的优 先级由于锁机制而无法成功应用。有可能因为低优先级的线程持有锁的时间过长,导致高优先级的线程长时间处于等待状态。 锁机制对于多线程程序性能的影响主要来自于其所带来的线程阻塞问题。如果能够不阻塞线程,又能保证多线程程序的正确性,那无疑是 更好的一种机制。在程序中,对共享变量的使用一般遵循一定的模式,即由读取、修改和写入三步组成。在读取步骤中读取共享变量的当前 值,在修改步骤中根据变量的当前值进行某些修改操作,最后在写入步骤中把修改之后的结果作为共享变量的新值写入。比如代码清单11-1中 的getNext方法对共享变量value的使用就遵循这样的模式。先读取value的当前值,把该值加上1之后作为新值,最后把新值写入value中。类似 的使用方式还有很多。在这三步执行过程中,随时可能发生线程切换,造成不同线程看到变量value的不同值,从而产生错误。锁机制的做法是 把这三步变成一个原子操作,从而解决了这个问题。如果读取、修改和写入这三步合起来在CPU上形成一个原子操作,那么CPU在执行这三步时 不会发生线程切换,也就不会造成数据不一致的问题。 目前CPU基本都提供了相关的指令来实现读取、修改和写入这三步的原子操作。比较常见的指令名称是“比较和替换(compare and swap, CAS)”。这个指令会先比较某个内存地址的当前值是不是指定的旧值。如果是,就用指定的新值来替换它;否则就什么也不做。指令的返回结 果是内存地址的当前值。CAS指令的这种执行方式的含义在于:如果这个内存地址的当前值还是上次访问时的旧值,就说明从上次访问到这次访 问的时间间隔内,没有其他线程对该内存地址进行过修改,可以安全地进行修改操作;否则说明有其他线程进行了修改,当前线程所持有的值 已经不再有效,需要使用内存地址的当前值重新进行计算,并在适当的时机再次使用CAS指令进行更新。通过CAS指令可以实现不依赖锁机制的 非阻塞算法。一般的做法是把对CAS指令的调用放在一个无限循环中。如果当前循环没能完成修改操作,就不断进行尝试,总会在某个时机 上,CAS指令成功完成修改。 Java平台利用了CPU提供的CAS指令来实现非阻塞操作。相关的API在java.util.concurrent.atomic包中。需要注意的是,不是所有的CPU都 支持CAS或类似功能的指令。在某些平台上,java.util.concurrent.atomic包的实现仍然是通过内部的锁机制来实现的。 java.util.concurrent.atomic包中提供的Java类分成三类,每一类Java类提供不同的功能。 第一类是支持以原子操作来进行更新的数据类型的Java类,包括AtomicBoolean、AtomicInteger、AtomicLong和AtomicReference类,分别 用于对布尔类型、整数、长整数和对象引用进行更新。在内存模型相关的语义上,这4个类的对象类似于volatile变量。这4个类所包含的方法 不尽相同,但是都与值的获取和设置相关。其中最重要的方法是compareAndSet,用来实现CAS指令的语义。调用compareAndSet方法时需要提供 两个参数,第一个是所期望的对象的旧值,第二个是希望设置成的对象的新值。如果对象的当前值与所期望的旧值相同,则把当前值设置为新 值。从compareAndSet方法的返回值可以判断设置是否成功。在内存模型语义上,compareAndSet方法相当于读取volatile变量的当前值后再写 入新值,不过是作为一个原子操作而完成的。与compareAndSet方法功能相同的是weakCompareAndSet方法。与compareAndSet方法相 比,weakCompareAndSet方法的性能要好一些,但是所提供的语义比较弱。使用weakCompareAndSet方法只能保证对当前变量的修改对于其他线 程是可见的,但不能保证在调用weakCompareAndSet方法之前对其他变量的修改也是对于其他线程可见的。这点与volatile关键词的语义是不同 的。所以weakCompareAndSet方法只适合于用来对值不依赖于其他变量的变量进行修改,如计数器。这4个类中的get和set方法分别用来直接获 取和设置变量的值,相当于读取和写入volatile变量的值。这4个类中最后一个相同的方法是lazySet。这个方法与set方法类似,但是允许编译 器把对lazySet方法的调用与后面的指令进行重排,因此对值的设置操作有可能被推迟。代码清单11-9给出了一个使用AtomicInteger类实现的 线程安全的标识符生成器的示例。AtomicInteger类提供了getAndIncrement方法,相当于代码清单11-1中的value++,不过这个方法是线程安全 和非阻塞的。 代码清单11-9 使用AtomicInteger类实现的生成唯一标识符的Java类 public class AtomicIdGenerator{ private final AtomicInteger counter=new AtomicInteger(0); public int getNext(){ return counter.getAndIncrement(); } } 代码清单11-10给出了getAndIncrement方法的内部实现方式,这也是compare-AndSet方法的一般使用模式。由于compareAndSet方法不一定 能够成功完成,因此需要把对compareAndSet方法的调用包装在一个无限循环中,不断进行调用,直到compareAndSet方法调用成功为止。 代码清单11-10 AtomicInteger类的getAndIncrement的内部实现 public final int getAndIncrement(){ for(;){ int current=get(); int next=current+1; if(compareAndSet(current, next)) return current; } } 第二类是提供对数组类型的变量进行处理的Java类。把数组对象的引用变量声明为volatile只能保证对这个引用变量本身的修改对其他线 程是可见的,但是不涉及数组中所包含的元素。AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray类把volatile的语义扩展到了 数组元素的访问中。这三个类的使用方式类似于对应的操作单个变量的类,只不过在方法调用中多了一个参数来指定要操作的数组元素的序 号。 最后一类Java类通过反射的方式对任何对象中包含的volatile变量使用compareAndSet方法进行修改。AtomicIntegerFieldUpdater、 AtomicLongFieldUpdater和AtomicReferenceFieldUpdater类分别用来对声明为volatile的int型、long型和对象引用类型的变量进行修改。这 三个类提供了一种方式把compareAndSet方法的功能扩展到在任何Java类中声明为volatile的域上。这三个类在使用上虽然灵活,但是在原子操 作上所提供的语义较弱,因为除了这三个类的对象之外,还可能有其他对象以其他方式对声明为volatile的域进行修改。代码清单11-11给出了 AtomicReferenceFieldUpdater类的使用示例。在对TreeNode类中的volatile域parent进行更新时,通过一个固定的 AtomicReferenceFieldUpdater类的对象的compareAndSet方法来完成。AtomicReferenceFieldUpdater类提供了一个静态工厂方法newUpdater, 这个静态工厂方法根据包含volatile域的Java类的类型、域本身的类型和域的名称这3个参数来创建用来更新域的值的 AtomicReferenceFieldUpdater类的对象。AtomicReferenceFieldUpdater类的对象中包含的方法与对应的AtomicReference类是相同的。如果程 序中的Java类需要用到compareAndSet方法提供的语义,那么可以利用这三个类。 代码清单11-11 更新volatile对象引用AtomicReferenceFieldUpdater类的使用示例 public class TreeNode{ private volatile TreeNode parent; private static final AtomicReferenceFieldUpdater<TreeNode, TreeNode> parentUpdater=AtomicReferenceFieldUpdater.newUpdater(TreeNode.class, TreeNode.class,"parent"); public boolean compareAndSetParent(TreeNode expect, TreeNode update){ return parentUpdater.compareAndSet(this, expect, update); } } 从整体的角度来说,java.util.concurrent.atomic包中的Java类属于比较底层的实现,一般作为java.util.concurrent包中很多非阻塞的 数据结构的实现基础。实际上使用比较多的主要是AtomicBoolean、AtomicInteger、AtomicLong和AtomicReference这4个类。在实现线程安全 的计数器时,AtomicInteger和AtomicLong类是最佳的选择。 11.5 高级实用工具 在Java平台出现之后的很长一段时间内,开发多线程程序只能使用Java平台提供的synchronized和volatile关键词,以及Object类中的 wait、notify和notifyAll等方法。这些关键词和方法的抽象层次比较低,在程序开发中使用起来比较繁琐,而且容易产生错误。在多线程开发 中,线程之间的交互方式存在某些固定的模式,比如常见的生产者-消费者和读者-写者模式。如果可以把这些模式抽象成高层的API,那么使用 起来会非常方便。J2SE 5.0引入的java.util.concurrent包为多线程程序开发提供了高层的API,可以满足日常开发中常见的需求。 11.5.1 高级同步机制 虽然非阻塞方式的性能优于阻塞式方式,但是并非所有场景都采用非阻塞式的实现方式,在很多情况下仍然需要使用基于锁机制的阻塞式 实现方式。Java中基本的阻塞式实现方式是基于synchronized关键词和Object类的wait、notify和notifyAll方法。通过synchronized关键词可 以获取监视器对象上的锁,不过这种锁的获取和释放都是隐式进行的,由线程在执行synchronized方法或代码块时自动完成。使用 synchronized关键词的问题在于加锁的范围是固定的,无法把锁在对象之间进行传递,使用起来并不灵活。不过它的好处是使用起来简单,不 容易出现错误。如果需要使用更加灵活的锁机制,可以使用java.util.concurrent.locks包中提供的API。 java. util.concurrent.locks包中的重要接口之一是Lock。Lock接口表示的是一个锁,可以通过其中的lock方法来获取锁,而unlock方法 用来释放锁。使用Lock接口的代码需要保证锁总是被释放。一般把unlock方法的调用放在finally代码块中。Lock接口提供了几种不同的获取锁 的方式。使用lock方法获取锁的方式类似于synchronized关键词。如果调用lock方法时无法获取锁,那么当前线程会处于等待状态,直到成功 获取锁。与lock方法相似的lockInterruptibly方法允许当前线程在等待获取锁的过程中被中断。所以调用lockInterruptibly方法时要处理 InterruptedException异常。除了通过阻塞式的获取锁外,Lock接口也提供了tryLock方法以非阻塞的方式获取锁。如果在调用tryLock方法时 无法获取锁,那么tryLock方法只返回false,不会阻塞当前线程。利用tryLock方法的另外一种重载形式可以指定超时时间。如果指定了超时时 间,当无法获取锁时,当前线程会处于阻塞状态,但是等待的时间不会超过指定的超时时间,同时线程也是可以被中断的。 另外一个与锁相关的接口是ReadWriteLock。ReadWriteLock接口实际上表示的是两个锁,一个是读取操作使用的共享锁,另外一个是写入 操作使用的排他锁。可以通过ReadWriteLock接口的readLock和writeLock方法来获取表示对应的锁的Lock接口的实现对象。ReadWriteLock接口 适合于解决与常见的读者-写者问题类似的场景。在没有线程进行写入操作时,进行读取操作的多个线程都可以获取读取锁;而进行写入操作的 线程只有在获取写入锁之后才能进行写入操作。多个线程可以同时进行读取操作,但是同一时刻只允许一个线程进行写入操作。ReadWriteLock 接口可以在很多情况下提高多线程程序的性能。在大多数情况下,对一个数据结构的读取操作的次数要远多于写入操作的次数。ReadWriteLock 接口允许多个线程同时进行读取操作,这样可以提高使用该数据结构时的吞吐量。如果对数据结构的访问模式不满足这种特性,比如有较多的 写入操作,则使用ReadWriteLock接口会降低性能。 在java.util.concurrent.locks包中提供了Lock接口和ReadWriteLock接口的基本实现,分别是ReentrantLock类和 ReentrantReadWriteLock类。这两个实现类的共同特征是可重入性,即允许一个线程多次获取同一个锁。ReentrantLock类的对象可以有一个所 有者线程,表示上一次成功获取该锁,但还没有释放锁的线程。ReentrantLock类的对象同时保存了所有者线程在该对象上的加锁次数。通过 getHoldCount方法可以获取当前的加锁次数。如果ReentrantLock类的对象当前没有所有者线程,则当前线程获取锁的操作会成功,加锁次数为 1。在随后的操作中,当前线程可以再次获取该锁,这也是可重入的含义所在。每次加锁操作会使加锁次数增加1,而每一次调用unlock方法释 放锁会使加锁次数减1。当加锁次数变为0时,该锁会被释放,可以被其他线程获取。代码清单11-12给出了使用ReentrantLock类实现的标识符 生成器的代码示例。代码中的getNext方法也给出了Lock接口的基本使用方式。开始时使用lock方法来加锁,接着把所要执行的操作放在try- finally代码块中,在finally代码块中通过unlock方法来释放锁。 代码清单11-12 使用ReentrantLock类实现的生成唯一标识符的Java类 public class LockIdGenerator{ private final ReentrantLock lock=new ReentrantLock(); private int value=0; public int getNext(){ lock.lock(); try{ return value++; }finally{ lock.unlock(); } } } 在创建ReentrantLock类的对象时,可以通过一个额外的boolean类型的参数来声明使用更加公平的加锁机制。在使用锁机制时会遇到的一 个问题是线程的饥饿问题。当多个线程同时竞争某个锁时,可能有的线程一直无法成功获取锁,一直处于无法运行的状态。线程饥饿是有些程 序应该避免的问题。如果在创建ReentrantLock类的对象时添加了额外的参数true,则ReentrantLock类的对象会使用相对公平的锁分配策略。 当锁处于可被获取状态时,在由于尝试获取该锁而处于等待状态的线程中,等待时间最长的线程会成功获取这个锁。这就避免了线程的饥饿问 题。不过需要注意的是不带参数的tryLock方法会忽略公平模式的设置。ReentrantReadWriteLock类是ReadWriteLock接口的可重入的实现类。 可重入锁的优势在于减少了锁在各个线程之间的传递次数,可以提高程序的吞吐量。这里以线程安全的散列表的实现为例来进行说明。在 散列表实现中,每个对内部状态进行修改的公开方法都由锁来保护。在方法执行之前都要求线程获取锁,在方法调用完成之后,锁被释放。在 一段程序的执行过程中,通常会多次使用同一个散列表对象上的方法。如果在散列表对象中使用的是可重入的锁,则当前线程只有在调用第一 个方法时需要与其他线程竞争锁。在成功加锁之后,后续调用的方法可以通过重入的方式来快速获取锁,这就降低了加锁的代价,锁的所有者 也没有发生改变。如果锁的实现不是可重入的,那么线程在每次调用方法时都需要与其他线程竞争锁的所有权。如果没能获取锁,则当前线程 需要等待,直到再次获取锁。在这种情况下,整段代码执行中的很大一部分时间都花费在加锁与解锁操作上。为了提高程序整体的吞吐量,应 该尽可能使用可重入的锁。 Lock接口替代synchronized关键词,相对应的Condition接口替代Object类的wait、notify和notifyAll方法。就如同使用wait、notify和 notifyAll方法时不能脱离synchronized关键词一样,使用Condition接口时也需要与一个对应的Lock接口的实现对象关联起来。通过Lock接口 的newCondition方法可以创建新的Condition接口的实现对象。在调用Condition接口的方法之前,也需要使用Lock接口的方法来获取锁。 Condition接口提供了多个类似Object类的wait方法的方法,最基本的是await方法,调用该方法会使当前线程进入等待状态,直到被唤醒 或被中断。另外一种await方法的重载形式可以指定超时时间。方法awaitNanos以纳秒数为单位指定超时时间,该方法的返回值是剩余等待时间 的估计值。类似的awaitUntil方法也可以指定超时时间,只不过指定的不是要经过的时间,而是超时发生的时间点,参数是一个 java.util.Date类的对象。前面几种等待方法都会响应其他线程发出的中断请求,而awaitUninterruptibly方法则不会处理中断请求。如果线 程通过调用awaitUninterruptibly方法进入等待状态,那么,当收到中断请求时,线程仍然会继续处于等待状态,直到被唤醒。当线程从 awaitUninterruptibly方法返回时,其内部的中断标记会被设置,以表明曾经有中断请求发生。与Object类的wait方法相同,当线程由于调用 await等方法进入等待状态时,会释放其持有的锁。 与Condition接口中的等待方法相对应的是signal和signalAll方法,相当于Object类中的notify和notifyAll方法。这两个方法的含义与 notify和notifyAll方法是相同的。代码清单11-13给出了Lock接口和Condition接口的一般使用方式,类似于代码清单11-7中对Object类的wait 方法的使用方式。 代码清单11-13 Lock接口和Condition接口的一般使用方式 Lock lock=new ReentrantLock(); Condition condition=lock.newCondition(); lock.lock(); try{ while(/*逻辑条件不满足*/){ condition.await(); } }finally{ lock.unlock(); } 11.5.2 底层同步器 在一个多线程程序中,线程之间可能存在多种不同的同步方式。除了Java标准库提供的同步方式之外,程序中特有的同步方式需要由开发 人员自己来实现。在这些同步方式中,一类比较常见的需求是对有限个共享资源的同步访问,多线程程序中的很多场景都可以抽象成这类同步 方式。比如对某个监视器对象的互斥访问,实际上是多个线程在竞争唯一的一个资源。如果系统中安装了两个打印机,那么需要进行打印操作 的多个线程相互竞争这两个资源。当多个线程在等待同一个资源时,从公平的角度出发,这些线程会被放入到一个先入先出(FIFO)的队列 中。当资源变成可用时,处于队首的线程会获取该资源。 如果程序中的同步方式可以抽象成对有限个资源的同步访问,那么可以使用java.util.concurrent.locks包中的 AbstractQueuedSynchronizer类和AbstractQueued-LongSynchronizer类作为实现的基础。这两个类的作用是相同的,只不过Abstract- QueuedSynchronizer类在内部使用一个int类型的变量来维护内部状态,而AbstractQueuedLongSynchronizer类使用long类型的变量。可以将这 个内部变量理解成共享资源的个数。通过getState、setState和compareAndSetState这3个方法来更新这个内部变量的值。在下面的介绍中使用 AbstractQueuedSynchronizer类来说明。 AbstractQueuedSynchronizer类是声明为abstract的,因此需要继承并覆写其中包含的部分方法后才能使用。通常的做法是把 AbstractQueuedSynchronizer类的子类作为一个Java类的内部类。外部的Java类提供具体的同步方式,而Abstract-QueuedSynchronizer类的对 象则作为实现的基础。实际使用AbstractQueuedSynchronizer类需要经过几个步骤。首先确定如何把需要同步访问的资源映射到Abstract- QueuedSynchronizer类的内部变量上。一般用内部变量的值表示当前可用资源的数量。接着选择使用排他模式或共享模式。共享模式允许多个 线程同时获取所管理的资源,而排他模式在同一时刻只允许一个线程获取资源。选择好模式之后,继承自AbstractQueuedSynchronizer类的子 类需要实现对应的资源获取和释放方法。使用排他模式时需要实现的方法是tryAcquire和tryRelease,而使用共享模式时对应的方法是 tryAcquireShared和tryReleaseShared。在这些方法的实现中,使用getState、setState和compareAndSetState这3个方法来修改内部变量的 值,以此来反映资源的状态。在对资源进行同步访问的Java类中需要创建一个AbstractQueuedSynchronizer类的子类的对象,并通过该对象来 完成实际的资源获取和释放的同步操作。 代码清单11-14给出了一个基于AbstractQueuedSynchronizer类实现的对资源进行管理的SimpleResourceManager类。内部类 InnerSynchronizer继承自AbstractQueued-Synchronizer类并覆写了tryAcquireShared和tryReleaseShared方法,说明对资源的访问使用的是 共享模式,直接用内部变量的值表示资源的当前可用数量。在这两个方法的实现中,通过getState方法获取当前的状态值,并根据需要使用 compareAndSetState方法来更新获取和释放操作之后的新状态值。在外部类中对资源进行访问的方法只是简单地调用InnerSynchronizer类的对 象上的适当方法来完成的。 代码清单11-14 基于AbstractQueuedSynchronizer类的资源管理实现 public class SimpleResourceManager{ private final InnerSynchronizer synchronizer; private static class InnerSynchronizer extends AbstractQueuedSynchronizer{ InnerSynchronizer(int numOfResources){ setState(numOfResources); } protected int tryAcquireShared(int acquires){ for(;){ int available=getState(); int remaining=available-acquires; if(remaining<0|| compareAndSetState(available, remaining)){ return remaining; } } } protected boolean tryReleaseShared(int releases){ for(;){ int available=getState(); int next=available+releases; if(compareAndSetState(available, next)){ return true; } } } } public SimpleResourceManager(int numOfResources){ synchronizer=new InnerSynchronizer(numOfResources); } public void acquire()throws InterruptedException{ synchronizer.acquireSharedInterruptibly(1); } public void release(){ synchronizer.releaseShared(1); } } 11.5.3 高级同步对象 使用java.util.concurrent.atomic包和java.util.concurrent.locks包提供的Java类可以满足基本的互斥和同步访问的需求,但是这些 Java类的抽象层次较低,使用起来比较复杂。对于一般的程序来说,更简单的做法是使用java.util.concurrent包中的高级同步对象。 本节主要介绍的是java.util.concurrent包提供的满足常见需求的高级同步对象,包括进行资源管理的信号量、协调不同线程运行顺序的 倒数闸门和循环屏障,以及进行数据交换的对象交换器等。如果程序中的多个线程之间的交互方式满足这些高级同步对象所适用的场景,那么 使用这些同步对象可以大大提高开发效率。 1.信号量 对于信号量的概念,开发人员可能并不陌生。信号量在操作系统中一般用来管理数量有限的资源。每类资源有一个对应的信号量。信号量 的值表示资源的可用数量。在使用资源时,要先从该信号量上获取一个使用许可。成功获取许可之后,资源的可用数减1。在持有许可期间,使 用者可以对获取的资源进行操作。完成对资源的使用之后,需要在信号量上释放一个使用许可,资源的可用数加1,允许其他使用者获取资源。 当资源可用数为0的时候,需要获取资源的线程以阻塞的方式来等待资源变为可用,或者过段时间之后再检查资源是否变为可用。代码清单11- 14中的SimpleResourceManager类实际上是信号量的一个简单实现。如果需要在程序中使用信号量,更好的方式是直接使用 java.util.concurrent.Semaphore类。在创建Semaphore类的对象时指定资源的可用数,通过acquire方法以阻塞式的方式获取许可,而 tryAcquire方法以非阻塞式的方式来获取许可。当需要释放许可时,使用release方法。Semaphore类也支持同时获取和释放多个资源的许可。 通过acquire方法获取许可的过程是可以被中断的。如果不希望被中断,那么可以使用acquireUninterruptibly方法。 代码清单11-15给出了使用Semaphore类管理程序中可用的打印机的示例。在创建PrinterManager类的对象时,需要指定程序中所有可用 Printer类的对象的集合。根据可用的Printer类的对象的数目创建出Semaphore类的对象。Semaphore类也支持在分配许可时使用公平模式,通 过把构造方法的第二个参数值设为true来使用该模式。在公平模式下,当资源可用时,等待线程按照调用acquire方法申请资源的顺序依次获取 许可。在进行资源管理时,一般使用公平模式,以避免造成线程饥饿问题。需要注意的是访问内部变量printers的方法都通过synchronized关 键词声明为同步的。这是因为Semaphore类只是一个资源数量的抽象表示,并不负责管理资源对象本身,可能有多个线程同时获取到资源使用许 可,因此需要使用同步机制避免数据竞争。 代码清单11-15 使用信号量管理资源的示例 public class PrinterManager{ private final Semaphore semaphore; private final List<Printer>printers=new ArrayList<>(); public PrinterManager(Collection<?extends Printer>printers){ this.printers.addAll(printers); this.semaphore=new Semaphore(this.printers.size(),true); } public Printer acquirePrinter()throws InterruptedException{ semaphore.acquire(); return getAvailablePrinter(); } public void releasePrinter(Printer printer){ putBackPrinter(printer); semaphore.release(); } private synchronized Printer getAvailablePrinter(){ Printer result=printers.get(0); printers.remove(0); return result; } private synchronized void putBackPrinter(Printer printer){ printers.add(printer); } } 2.倒数闸门 在多个线程进行协作时,一个常见的情景是一个线程需要等待另外的线程完成某些任务之后才能继续进行。在这种情况下,可以使用 java.util.concurrent.CountDownLatch类。CountDownLatch类相当于多个线程等待开启的一个闸门。只有在其他线程完成任务之后,闸门才会 打开,等待的线程才能运行。在创建CountDownLatch类的对象时需要指定等待完成的任务数目。一个CountDownLatch类的对象被执行任务的线 程和等待任务完成的线程所共享。当执行任务的线程完成其任务时,调用countDown方法来使待完成的任务数量减1。等待任务完成的线程通过 调用await方法进入阻塞状态直到待完成的任务数量变为0。当所有任务都完成时,等待任务完成的线程会从await方法返回,可以继续执行后续 的代码。CountDownLatch类的对象的使用是一次性的。一旦待完成的任务数量变为0,再调用await方法就不再阻塞当前线程,而是立即返回。 CountDownLatch类在很多场景下都可以得到应用。比如在使用多线程方式下载一个文件时,可以有一个主线程和若干个下载线程。在某个 下载线程完成部分内容的下载任务时,调用countDown方法来进行声明,主线程则调用await方法等待所有部分下载完成。代码清单11-16给出了 使用CountDownLatch类的一个示例。每个线程负责获取一个网页的内容并计算其内容的大小。当所有网页的大小都计算完成之后,由主线程对 网页内容大小进行排序。在GetSizeWorker类的run方法中,当任务完成之后,通过countDown方法发出通知;在主线程中通过await方法来等待 所有使用GetSizeWorker类的线程完成运行。 代码清单11-16 倒数闸门的使用示例 public class PageSizeSorter{ private static final ConcurrentHashMap<String, Integer>sizeMap=new ConcurrentHashMap<>(); private static class GetSizeWorker implements Runnable{ private final String urlString; private final CountDownLatch signal; public GetSizeWorker(String urlString, CountDownLatch signal){ this.urlString=urlString; this.signal=signal; } public void run(){ try{ InputStream is=new URL(urlString).openStream(); int size=IOUtils.toByteArray(is).length; sizeMap.put(urlString, size); }catch(IOException e){ sizeMap.put(urlString,-1); }finally{ signal.countDown(); } } } private void sort(){ List<Entry<String, Integer>>list=new ArrayList<>(sizeMap.entrySet()); Collections.sort(list, new Comparator<Entry<String, Integer>>(){ public int compare(Entry<String, Integer>o1, Entry<String, Integer>o2){ return Integer.compare(o2.getValue(),o1.getValue()); } }); System.out.println(Arrays.deepToString(list.toArray())); } public void sortPageSize(Collection<String>urls)throws InterruptedException{ CountDownLatch sortSignal=new CountDownLatch(urls.size()); for(String url:urls){ new Thread(new GetSizeWorker(url, sortSignal)).start(); } sortSignal.await(); sort(); } } 3.循环屏障 循环屏障在作用上类似于倒数闸门,不过有几个显著的不同点。首先循环屏障不像倒数闸门一样是一次性的,可以循环使用。其次使用循 环屏障的线程之间是互相平等的,彼此都需要等待对方完成。当一个线程完成自己的任务之后,等待其他线程完成。当所有线程都完成任务之 后,所有线程才可以继续运行。当线程之间需要再次进行互相等待时,可以复用同一个循环屏障。 类java.util.concurrent.CyclicBarrier用来表示循环屏障。CyclicBarrier类的对象在创建时需要指定使用该对象的线程数目,还可以指 定一个Runnable接口的实现对象作为每次所有线程之间完成相互等待之后执行的动作。当最后一个线程完成任务之后,在所有的线程继续执行 之前,这个Runnable接口的实现对象会被运行。如果这些线程之间需要更新一些共享的内部状态,可以利用这个Runnable接口的实现对象来进 行处理。每个线程在完成任务之后,通过调用await方法进入等待状态。等所有线程都调用了await方法之后,处于等待状态的线程都可以继续 执行。在所有参与线程中,只要有一个在等待过程中被中断、出现超时或是其他错误,整个循环屏障会失效。所有处于等待状态的其他线程会 抛出java.util.concurrent.BrokenBarrierException异常而结束。 代码清单11-17中给出了使用多个线程来查找质数的示例。每个线程负责查找给定数字区间范围内的质数。线程之间使用CyclicBarrier类 的对象进行同步。当所有线程都完成一轮计算之后,会调用创建CyclicBarrier类的对象时提供的Runnable接口的实现。在这个Runnable接口实 现中,检查当前已经找到的质数数目是否足够。如果已经足够,则设置标记变量done的值为true;否则,所有线程继续运行,在下一个区间中 进行查找。这里需要注意的是,如果在调用await方法中出现异常,所有的线程都会结束。因此需要正确设置done的值,使得主线程在计算线程 出错时也能正确处理。 代码清单11-17 使用多个线程来查找质数的示例 public class PrimeNumber{ private static final int TOTAL_COUNT=5000; private static final int RANGE_LENGTH=200; private static final int WORKER_NUMBER=5; private static volatile boolean done=false; private static int rangeCount=0; private static final List<Long>results=new ArrayList<Long>(); private static final CyclicBarrier barrier=new CyclicBarrier(WORKER_NUMBER, new Runnable()){ public void run(){ if(results.size()>=TOTAL_COUNT){ done=true; } } }); private static class PrimeFinder implements Runnable{ public void run(){ while(!done){ int range=getNextRange(); long start=range*RANGE_LENGTH; long end=(range+1)*RANGE_LENGTH; for(long i=start;i<end;i++){ if(isPrime(i)){ updateResult(i); } } try{ barrier.await(); }catch(InterruptedException|BrokenBarrierException e){ done=true; } } } } private synchronized static void updateResult(long value){ results.add(value); } private synchronized static int getNextRange(){ return rangeCount++; } private static boolean isPrime(long number){ //省略判断是否为质数的代码 } public void calculate(){ for(int i=0;i<WORKER_NUMBER;i++){ new Thread(new PrimeFinder()).start(); } while(!done){ } //计算完成 } } 4.对象交换器 对象交换器适合于两个线程需要进行数据交换的场景。在某些情况下,两个线程对于共享的对象有不同的处理逻辑。在两个线程都完成处 理之后,处理的结果对象被交换给另外一个线程,由另外一个线程继续进行处理。类java.util.concurrent.Exchanger的作用是提供对这种对 象交换能力的支持。两个线程共享一个Exchanger类的对象。一个线程完成对数据的处理之后,调用Exchanger类的exchange方法把处理之后的 数据作为参数发送给另外一个线程。而exchange方法的返回结果是另外一个线程所提供的相同类型的对象。如果另外一个线程尚未完成对数据 的处理,那么exchange方法会使当前线程进入等待状态,直到另外一个线程也调用了exchange方法来进行数据交换。 代码清单11-18给出了Exchanger类的使用示例。Sender和Receiver类分别负责准备各自的数据。任何一方完成准备之后,调用exchange方 法来启动交换。等两者都完成任务之后,exchange方法会返回,两个线程得到了对方提供的对象。 代码清单11-18 对象交换器的使用示例 public class SendAndReceiver{ private final Exchanger<StringBuilder>exchanger=new Exchanger<String-Builder>(); private class Sender implements Runnable{ public void run(){ try{ StringBuilder content=new StringBuilder("Hello"); content=exchanger.exchange(content); }catch(InterruptedException e){ Thread.currentThread().interrupt(); } } } private class Receiver implements Runnable{ public void run(){ try{ StringBuilder content=new StringBuilder("World"); content=exchanger.exchange(content); }catch(InterruptedException e){ Thread.currentThread().interrupt(); } } } public void exchange(){ new Thread(new Sender()).start(); new Thread(new Receiver()).start(); } } 11.5.4 数据结构 java. util.concurrent包中也提供了一些适合多线程程序使用的高性能数据结构,包括队列和集合类对象等。 1.队列 队列是多线程程序中比较常用的一种数据结构。多个线程对同一个队列进行操作,通过队列来进行数据传递。java.util.concurrent包中 的BlockingQueue接口表示的是线程安全的阻塞式队列。BlockingQueue接口本身封装了生产者-消费者场景所需的语义。当队列已满时,向队列 中添加数据的方法会阻塞当前线程;当队列已空时,从队列中获取数据的方法会阻塞当前线程。使用BlockingQueue接口可以非常容易地实现生 产者-消费者场景,只需要让生产者和消费者线程共享一个BlockingQueue接口的实现对象,并分别调用不同的方法即可。线程之间的具体同步 机制由BlockingQueue接口的实现类负责处理。BlockingQueue接口中的方法支持阻塞式和非阻塞式两种方式。阻塞式的方式通过put方法向队列 添加数据,通过take方法从队列中获取数据。非阻塞方式则分别通过offer和poll方法来完成。Java标准库中提供的BlockingQueue接口的实现 包括基于数组的固定元素个数的ArrayBlockingQueue类和基于链表结构的不固定元素个数的LinkedBlockingQueue类。开发人员可以根据需要选 择合适的实现类。 与BlockingQueue接口功能相似的是BlockingDeque接口,不过BlockingDeque接口表示的是可以对头尾都进行添加和删除操作的双向队列。 BlockingDeque接口中的方法分成两类,分别在队首和队尾进行操作。标准库中只提供了BlockingDeque接口的一种基于链表的实现—— LinkedBlockingDeque类。 BlockingQueue和BlockingDeque接口的实现类都是阻塞式队列。如果队列中所包含的元素数量没有限制,可以使用ConcurrentLinkedQueue 和ConcurrentLinkedDeque类。这两个类在实现中使用了非阻塞式算法,避免了使用锁机制,性能比较好。 2.集合类 另外一部分实用的Java类是线程安全的集合类。在多线程程序中,如果共享变量是集合类的对象,则不适合直接使用java.util包中已有的 集合类。这些集合类有些不是线程安全的,有些在多线程环境下性能比较差。更好的选择是使用java.util.concurrent包中的集合类。 当需要使用散列表时,可以使用ConcurrentMap接口的实现类。ConcurrentMap接口继承自java.util.Map接口,增添了几个方法。这几个方 法虽然包含了条件判断,但都是原子操作。调用者不需要考虑同步的问题。其中putIfAbsent方法只有在散列表中不包含给定的键时,才会把给 定的值放入散列表中;remove方法在散列表中包含给定键和值的条目时,删除该条目;replace方法有两种重载形式,第一种形式是在散列表中 包含给定的键时,用指定的新值替换原来的值,另外一种形式是在调用时需要指定键、期望的旧值和要设置的新值。当散列表中给定键对应的 值与期望的旧值相等时,用给定的新值进行替换。这种形式的replace方法的含义相当于compareAndSet方法。 ConcurrentMap接口的基本实现是ConcurrentHashMap类。在创建Concurrent-HashMap类的对象时,一般使用默认的不带任何参数的构造方 法。不过考虑到ConcurrentHashMap在多线程程序中的性能问题,如果可以预先估算一些数值,那么可以提高ConcurrentHashMap的使用性能。 第一个要考虑的因素是ConcurrentHashMap类的对象中可能包含的条目个数。因为在ConcurrentHashMap类的实现中动态调整所能包含的条目数 量的操作比较耗时,如果可以事先指定一个相对合理的初始大小,那么可以避免不必要的调整大小的操作。第二个要考虑的因素是同时进行更 新操作的线程数。ConcurrentHashMap类在实现中会根据这个估计的线程数把内部的空间划分成对应数量的部分。从理论上来说,这些线程可以 分别在不同的部分同时进行更新操作,而不会互相冲突。如果设置的线程数过大或过小,会对性能造成一定的影响。如果不显式指定,那么使 用的默认值是16。这个默认值对某些场景来说是不合适的。比如,在很多情况下,只有一个线程进行更新操作,其他线程只进行读取操作,这 时把值设为1可以提高性能。 需要注意的是,ConcurrentHashMap类的对象在使用时,读取操作和更新操作可能会重叠在一起。比如,putAll方法会把一组条目添加到散 列表中。在完成对某一个条目的添加之后,并发进行的读取操作就可以获取新添加的条目,此时putAll方法可能还在进行其他条目的添加操 作。通过ConcurrentHashMap接口的方法可以获取散列表中包含的键和值的集合。当从这些集合中创建出迭代器对象之后,迭代器对象只与集合 保持弱一致性。通过迭代器可以访问在其创建时集合中包含的元素,但是不一定可以反映出迭代器创建之后散列表所发生的变化。在使用迭代 器的方法时不会抛出java.util.ConcurrentModificationException异常。 如果需要在多线程程序中使用java.util.List接口的实现类,可以使用CopyOnWrite-ArrayList类。在CopyOnWriteArrayList类的实现中, 所有对列表的更新操作都会重新创建一个底层数组的副本,并使用这个副本来存储数据。对列表的更新操作是加锁的,而读取操作是不加锁 的。通过复制的方式避免了可能产生的数据竞争,但是带来了额外的开销。一般对List接口的读取操作要多于更新操作,因此采用这种方式是 比较合理的。如果在使用中发现对List接口实现对象的更新操作相对较多,则不适合使用CopyOnWriteArrayList类。通过 CopyOnWriteArrayList类的iterator方法得到的迭代器对象只反映迭代器创建时列表的状态。如果在创建迭代器之后,对 CopyOnWriteArrayList类的对象进行了更新操作,在更新之后,CopyOnWriteArrayList类的对象使用了新的底层数组,而之前创建的迭代器仍 然引用的是旧的底层数组。 11.5.5 任务执行 线程最基本的用法是在程序的主线程之外执行其他的任务。最简单的做法是创建一个表示线程的Thread类的对象,再调用start方法启动该 线程的运行。这种做法虽然简单,但要求开发人员对创建出来的线程进行维护,当线程较多时,会带来不小的维护成本。在线程较多时,一般 的做法是创建一个线程池来进行统一管理,同时降低重复创建线程的开销。在Java早期版本中,开发人员需要自己开发线程池的实现,或者利 用第三方库。在J2SE 5.0中,Java标准库的java.util.concurrent包提供了丰富的用来管理线程和执行任务的实现。 首先介绍在执行任务时会用到的几个比较重要的接口。在J2SE 5.0之前,Runnable接口用来表示一个可执行的任务。不过Runnable接口有 一些局限性,主要是受限于run方法的类型签名。这些局限性通过Callable接口来解决。Callable接口只有一个方法call。这个call方法可以有 返回值,同时可以抛出受检异常。这两点是Callable接口相对于Runnable接口的优势。不过Callable接口的实现对象不能通过直接创建Thread 类的对象的方式来执行,而需要使用java.util.concurrent包提供的任务执行API。 当需要异步执行一个任务时,一般的做法是把任务的执行封装在一个线程中。如果需要获取任务的执行结果,则要求在主线程和任务执行 线程之间进行同步和数据传递。Future接口简化了任务的异步执行。Future接口可以作为异步操作的一个抽象。调用Future接口的get方法可以 获取异步任务的执行结果。如果任务没有执行完,那么调用get方法的线程会处于等待状态,直到任务完成或被取消。如果希望取消一个任务的 执行,那么可以调用cancel方法。 有些任务对执行时的调度方式有一定的要求,这些任务不在提交之后就立即执行,而是需要等待一段时间。Delayed接口用来声明任务在调 度方式上的这种需求。Delayed接口中的getDelay方法用来返回当前剩余的延迟时间。当getDelay方法的返回值不大于0时,说明所延迟的时间 已经过去,应该调度并执行该任务。 把Runnable、Future和Delayed接口组合起来,可以形成具备组合功能的新接口。RunnableFuture接口继承自Runnable接口和Future接口。 当来自Runnable接口中的run方法成功执行之后,相当于Future接口表示的异步任务已经成功完成,可以通过get方法来获取运行的结果。 ScheduledFuture接口继承自Delayed接口和Future接口,表示一个可以进行调度的异步操作。RunnableScheduledFuture接口则同时继承自 Runnable、Delayed和Future接口。RunnableScheduledFuture接口的实现对象是可调度的,同时通过run方法的成功运行来表示异步操作的完 成。RunnableScheduledFuture接口中包含的方法isPeriodic用来表明该异步操作是否可以被重复调度执行。 上面介绍的接口都是用来描述任务本身的,与任务的执行没有关系。在执行任务时,可以手动创建Thread类的对象,不过更好的做法是使 用java.util.concurrent包中提供的与任务执行相关的接口和实现。最基本的接口是Executor,其中的execute方法用来执行一个Runnable接口 的实现对象。不同的Executor接口实现可能采取不同的任务执行策略。Executor接口所提供的任务执行功能比较弱,只能处理Runnable接口。 ExecutorService接口继承自Executor接口,并提供了更多实用的功能,其中,第一项重要功能是对任务的管理。使用ExecutorService接口的 submit方法可以把Callable接口和Runnable接口的实现对象作为任务来提交,得到一个Future接口的实现对象作为返回值。通过该Future接口 的实现对象可以获取任务的执行结果或取消任务。第二项功能是任务的批量执行。通过invokeAll和invokeAny方法可以同时提交多个Callable 接口的实现对象。调用invokeAll方法之后,会等待所有的任务都执行完成,返回值是一个包含每个任务对应的Future接口实现对象的列表,从 中可以获取每个任务的运行结果。调用invokeAny方法之后,任何一个任务成功完成后,都会把该任务的执行结果返回给调用者。最后一项功能 是任务执行服务的关闭。当不再需要使用ExecutorService接口的实现对象时,可以调用shutdown或shutdownNow方法来关闭服务。两者的区别 在于,shutdown方法只是不再允许新的任务被提交,在shutdown方法被调用之前提交的任务仍然可以继续运行;而shutdownNow方法会试图终止 正在运行的和等待运行的任务,并返回已经提交但没有被运行的任务的列表。这两个方法都不会等待服务真正关闭,只是发出关闭请求。通过 调用ExecutorService接口的awaitTermination方法可以使当前线程在一定时间内等待服务完成关闭。在shutdownNow方法中强制终止任务时, 通常的做法是向线程发出中断请求,因此确保提交的任务实现了正确的中断处理逻辑,能够在收到中断请求时进行必要的清理工作并结束任 务。 如果需要对任务的执行方式进行调度,可以使用继承自ExecutorService接口的ScheduledExecutorService接口。 ScheduledExecutorService接口支持任务的延迟执行和定期执行。可以执行的任务由Callable或Runnable接口来表示。通过schedule方法可以 调度一个任务在延迟若干时间之后再执行;而scheduleAtFixedRate方法则调度一个任务在初始的延迟时间后,每隔一段时间重复执行,在下一 次执行开始时,上一次执行可能还没有结束;scheduleWithFixedDelay方法与scheduleAtFixedRate方法的作用类似,只不过 scheduleWithFixedDelay方法是在上一次任务执行完成之后,经过给定的间隔时间再开始下一次的执行。所以scheduleAtFixedRate方法可能造 成任务执行时的重叠,而scheduleWithFixedDelay方法则不会。这三个方法的返回值都是ScheduledFuture接口的实现对象。 在java.util.concurrent包中提供了这些任务执行接口的默认实现。在大多数情况下,使用默认实现已经足够好。这些默认实现也提供了 比较多的配置项,允许开发人员进行自定义。通过Executors类的静态工厂方法可以创建出ExecutorService和ScheduledExecutorService接口 的实现对象,比如调用newFixedThreadPool方法可以创建出一个使用固定数量的线程池来执行任务的ExecutorService接口的实现对象。 代码清单11-19给出了一个利用ExecutorService接口的使用多线程方式下载文件的Java类FileDownloader。ExecutorService接口的实现对 象使用了10个线程来处理下载请求。在进行文件下载时,通过submit方法提交一个新的Callable接口的实现对象到ExecutorService中。当不再 使用FileDownloader类的对象时,应该使用close方法来关闭其中包含的ExecutorService接口的实现对象,否则,虚拟机不会退出,所占用的 内存也不会释放。在close方法的实现中,首先使用shutdown方法来发出关闭请求,此时新的任务不会被接受。接着使用awaitTermination方法 来等待一段时间,使正在执行和等待执行的任务有机会可以完成。如果这些任务超过给定的时间仍没有完成,那么使用shutdownNow方法来强制 结束。在调用shutdownNow方法之后,再使用awaitTermination方法来等待另外一段时间,使被强制结束的任务可以完成必要的清理工作。 代码清单11-19 ExecutorService接口的使用示例 public class FileDownloader{ private final ExecutorService executor=Executors.newFixedThreadPool(10); public boolean download(final URL url, final Path path){ Future<Path>future=executor.submit(new Callable<Path>(){ public Path call(){ try{ InputStream is=url.openStream(); Files.copy(is, path, StandardCopyOption.REPLACE_EXISTING); return path; }catch(IOException e){ return null; } } }); try{ return future.get()!=null?true:false; }catch(InterruptedException|ExecutionException e){ return false; } } public void close(){ executor.shutdown(); try{ if(!executor.awaitTermination(3,TimeUnit.MINUTES)){ executor.shutdownNow(); executor.awaitTermination(1,TimeUnit.MINUTES); } }catch(InterruptedException e){ executor.shutdownNow(); Thread.currentThread().interrupt(); } } } 在使用ExecutorService接口时,通过submit方法提交任务,并得到一个Future接口的实现对象来获取任务的执行结果。在有些情况下,任 务的提交者和任务执行结果的使用者是程序中的不同部分。如果使用ExecutorService接口,就需要把得到的Future接口的对象在不同部分之间 进行传递。更好的做法是使用CompletionService接口。程序中的不同部分可以共享CompletionService接口的实现对象。任务的提交者通过 submit方法提交表示任务的Runnable和Callable接口的实现对象,而任务执行结果的使用者可以通过take或poll方法获取表示执行结果的 Future接口的实现对象。两个方法的区别在于take是阻塞式的,而poll是非阻塞式的。ExecutorCompletionService类是Java标准库提供的 CompletionService接口的实现类。在创建ExecutorCompletionService类的对象时需要提供一个Executor接口的实现对象作为参数,用来进行 实际的任务执行。 11.6 Java SE 7新特性 Java SE 7对java.util.concurrent包进行了更新,增加了新的轻量级任务执行框架fork/join和多阶段线程同步工具。 11.6.1 轻量级任务执行框架fork/join Java SE 7对java.util.concurrent包的一个重要更新是增加了一个新的轻量级任务执行框架,一般称为fork/join框架。这个框架的目的 主要是更好地利用底层平台上的多核CPU和多处理器来进行并行处理,解决问题时通常使用分治(divide and conquer)算法或map/reduce算法 来进行。这个框架的名称来源于使用时的两个基本操作fork和join,可以类比于map/reduce中的map和reduce操作。fork操作的作用是把一个大 的问题划分成若干个较小的问题。这个划分过程一般是递归进行的,直到得到可以直接进行计算的粒度合适的子问题。在划分时,需要恰当地 选取子问题的大小。太大的子问题不利于通过并行方式来提高性能,而太小的子问题则会带来较大的额外开销。每个子问题在计算完成之后, 可以得到关于整个问题的部分解。join操作的作用是把这些部分解收集并组织起来,得到最终的完整解。调用join操作的过程也可能是递归进 行的,与fork操作相对应。 相对于一般的线程池实现,fork/join框架的优势体现在对其中包含的任务的处理方式上。在一般的线程池中,如果一个线程正在执行的任 务由于某些原因无法继续运行,那么该线程会处于等待状态。而在fork/join框架实现中,如果某个子问题由于等待另外一个子问题的完成而无 法继续运行,那么处理该子问题的线程会主动寻找其他尚未运行的子问题来执行。这种方式减少了线程的等待时间,提高了性能。为了 fork/join框架能够高效运行,在每个子问题的实现中应该避免使用synchronized关键词或其他方式来进行同步,也不应该使用阻塞式I/O操作 或过多地访问共享变量。在理想情况下,每个子问题的实现中都应该只进行CPU相关的计算,并且只使用每个问题的内部对象。唯一的同步应该 只发生在子问题和创建它的父问题之间。 一个由fork/join框架执行的任务由ForkJoinTask类表示。ForkJoinTask类实现了Future接口,可以按照Future接口的方式来使用。在 ForkJoinTask类中最重要的两个方法是fork和join,其中fork方法用来以异步方式启动任务的执行,而join方法则等待任务完成并返回执行的 结果。在创建自己的任务时,最好不要直接继承自ForkJoinTask类,而要继承自ForkJoinTask类的子类RecursiveTask或RecursiveAction类。 两者的区别在于RecursiveTask类表示的任务可以返回结果,而RecursiveAction类不行。 在fork/join框架中,任务的执行由ForkJoinPool类的对象来完成。ForkJoinPool类实现了ExecutorService接口,除了执行ForkJoinTask 类的对象之外,还可以使用一般的Callable和Runnable接口来表示任务。在ForkJoinPool类的对象中执行的任务大致可以分成两类:一类是通 过execute、invoke或submit方法直接提交的任务;另外一类是ForkJoinTask类的对象在执行过程中产生的子任务,并通过fork方法来运行。一 般的做法是表示整个问题的ForkJoinTask类的对象用第一类形式提交,而在执行过程中产生的子任务并不需要进行处理,ForkJoinPool类的对 象会负责子任务的执行。 代码清单11-20给出了使用fork/join框架查找数组中的最大值的示例。类MaxValueTask表示的是进行查找的任务,继承自RecursiveTask 类。每个查找任务都在一定的数组序号区间范围内进行。如果当前区间范围较大,则将其划分成两个子区间,对每个子区间创建子任务来进行 查找,递归进行这个过程,直到范围区间足够小,再在这个区间中进行顺序比较来查找最大值。通过fork方法来启动子任务,join方法用来获 取子任务的执行结果。在子任务执行完成之后,再把得到的部分结果合并到最终的结果中。这是一种典型的使用分治算法的实现方式。 代码清单11-20 fork/join框架的使用示例 public class MaxValue{ private static final int RANGE_LENGTH=2000; private final ForkJoinPool forkJoinPool=new ForkJoinPool(); private static class MaxValueTask extends RecursiveTask<Long>{ private final long[]array; private final int start; private final int end; MaxValueTask(long[]array, int start, int end){ this.array=array; this.start=start; this.end=end; } protected Long compute(){ long max=Long.MIN_VALUE; if(end-start<=RANGE_LENGTH){ for(int i=start;i<end;i++){ if(array[i]>max){ max=array[i]; } } } else{ int mid=(start+end)/2; MaxValueTask lowTask=new MaxValueTask(array, start, mid); MaxValueTask highTask=new MaxValueTask(array, mid, end); lowTask.fork(); highTask.fork(); max=Math.max(max, lowTask.join()); max=Math.max(max, highTask.join()); } return max; } } public void calculate(long[]array){ MaxValueTask task=new MaxValueTask(array,0,array.length); Long result=forkJoinPool.invoke(task); System.out.println(result); } } 代码清单11-20的目的是给出fork/join框架的使用示例。单从性能方面来说,代码清单11-20中的实现方式的效率要低于直接对整个数组进 行顺序比较的实现方式。这主要是因为多线程所带来的额外开销过大。在实际中,fork/join框架发挥作用的场合是很多的,比如在一个目录包 含的所有文本文件中搜索某个关键词时,可以为每个文件创建一个子任务。这种实现方式的性能要优于单线程的查找方式。如果相关的功能可 以用递归和分治算法来解决,就适合使用fork/join框架。 11.6.2 多阶段线程同步工具 Phaser类是Java SE 7中新增的一个实用同步工具,所提供的功能和灵活性比之前介绍的倒数闸门和循环屏障要强很多。在fork/join框架 中的子任务之间要进行同步时,应该优先使用Phaser类的对象。Phaser类的特点是把多个线程协作执行的任务划分成多个阶段(phase),在每 个阶段上都可以有任意个参与者参与。线程可以随时注册并参与到某个阶段的执行中来。当一个阶段中所有的线程都成功完成之后,Phaser类 的对象会自动进入下一个阶段。如此循环下去,直到Phaser类的对象中不再包含任何参与者。此时,Phaser类的对象的运行自动结束。 在Phaser类的对象被创建出来之后,其初始的阶段编号为0。在Phaser类的构造方法中可以指定初始的参与者的个数,也可以在创建出来之 后,使用register方法或bulkRegister方法来动态添加一个或多个参与者。当某个参与者完成其任务之后,调用arrive方法来进行声明。有的 参与者完成一次执行之后就不再继续参与,可以调用arriveAndDeregister方法在声明完成之后取消自己的注册。如果参与者在完成之后需要等 待其他参与者完成,那么可以使用arriveAndAwaitAdvance方法。调用arriveAndAwaitAdvance方法的线程会阻塞,直到Phaser类的对象成功进 入下一阶段。当需要等待Phaser类的对象进入下一个阶段时,可以使用awaitAdvance和awaitAdvanceInterruptibly方法。两个方法的参数是当 前的阶段编号。使用awaitAdvanceInterruptibly方法时可以设置超时时间和处理中断请求。 当某个阶段中的所有参与者都完成任务之后,Phaser类的对象的onAdvance方法会被调用,可以通过覆写此方法来添加自定义的处理逻辑。 该方法的作用类似于在创建表示循环屏障的CyclicBarrier类的对象时使用的Runnable接口的实现对象。如果onAdvance方法的返回值为true, 则该Phaser类的对象会被终止。默认的实现是在参与者的数量为0时终止该Phaser类的对象。可以通过覆写onAdvance方法来使用不同的终止逻 辑。要强制终止一个Phaser类的对象,可以使用forceTermination方法。 Phaser类的一个重要特征是多个Phaser类的对象可以组织成树形结构。这种树形结构正好与fork/join框架中子任务可能形成的树形结构相 对应。Phaser类提供了一个构造方法来指定当前对象的父对象。当一个子Phaser类的对象中的参与者个数大于0时,它会被自动注册到父对象 中;当参与者个数为0时,它会被自动从父对象中解除注册。 Phaser类的功能很强大,在实际中可以替代CountDownLatch类和Cyclic-Barrier类。代码清单11-21给出了Phaser类的使用示例。该示例的 Java类WebPageImageDownloader用来下载一个网页中包含的图片文件。整个下载过程由多个线程共同参与,并分成几个阶段。第一个阶段是由 主线程负责下载页面的内容,并分析其中包含的图片文件的链接地址。在创建Phaser类的对象时,声明初始的参与者个数为1,即只有主线程参 与。对每个图片文件使用单独的线程来下载。在创建图片下载线程时,通过register方法把每个下载线程注册到Phaser类的对象中。在每个图 片下载线程的run方法中先通过arriveAndAwaitAdvance方法等待其他线程创建完成。由于主线程也是参与者,同样需要通过调用 arriveAndAwaitAdvance方法来进行等待。等所有线程都完成创建之后,Phaser类的对象进入下一个阶段。下载图片文件的线程开始进行下载, 而主线程则通过第二个arriveAndAwaitAdvance方法等待所有下载线程完成运行。每个下载线程在完成任务之后,通过arriveAndDeregister方 法进行声明,同时解除在Phaser类的对象上的注册。当所有下载线程都完成并解除注册之后,主线程成为Phaser类的对象中唯一的参与者。主 线程最后通过arriveAndDeregister方法来解除注册,Phaser类的对象由于不存在任何参与者而自动关闭。 代码清单11-21 Phaser类的使用示例 public class WebPageImageDownloader{ private final Phaser phaser=new Phaser(1); private final Pattern imageUrlPattern=Pattern.compile("src=['\"]?(.*?(\\.jpg|\\.gif|\\.png))['\"]?[\\s >]+",Pattern.CASE_INSENSITIVE); public void download(URL url, final Path path, Charset charset)throws IOException{ if(charset==null){ charset=StandardCharsets.UTF_8; } String content=getContent(url, charset); List<URL>imageUrls=extractImageUrls(content); for(final URL imageUrl:imageUrls){ phaser.register(); new Thread(){ public void run(){ phaser.arriveAndAwaitAdvance(); try{ InputStream is=imageUrl.openStream(); Files.copy(is, getSavedPath(path, imageUrl),StandardCopyOption.REPLACE_EXISTING); }catch(IOException e){ e.printStackTrace(); }finally{ phaser.arriveAndDeregister(); } } }.start(); } phaser.arriveAndAwaitAdvance(); phaser.arriveAndAwaitAdvance(); phaser.arriveAndDeregister(); } private String getContent(URL url, Charset charset)throws IOException{ InputStream is=url.openStream(); return IOUtils.toString(new InputStreamReader(is, charset.name())); } private List<URL>extractImageUrls(String content){ List<URL>result=new ArrayList<URL>(); Matcher matcher=imageUrlPattern.matcher(content); while(matcher.find()){ try{ result.add(new URL(matcher.group(1))); }catch(MalformedURLException e){ //忽略 } } return result; } private Path getSavedPath(Path parent, URL url){ //省略获取图片保存路径的代码 } } 11.7 ThreadLocal类 当多个线程需要同时访问一个共享变量时,不可避免地产生数据竞争。通常的做法是利用之前介绍的同步机制来解决这个问题。另外一种 做法是使用线程局部变量,即java.lang.ThreadLocal类。在不同的线程访问一个ThreadLocal类的对象时,所访问和修改的是每个线程各自独 立的对象,相当于这个对象是线程的一个私有对象。一个线程无法访问其他线程内部的私有对象,因此也不存在数据竞争的问题。通过使用 ThreadLocal类,可以快速地把一个非线程安全的对象转换成线程安全的对象。 代码清单11-22中给出了ThreadLocal类的使用示例。在ThreadLocal类的对象中保存的是代码清单11-1中IdGenerator类的对象。 ThreadLocal类提供了对其中包含的对象进行处理的方法,get和set方法分别用来获取和设置当前线程中包含的对象的值,而remove方法用来删 除对象的值。一般的用法是覆写ThreadLocal类中的initialValue方法来提供对象的初始值。如果没有通过set方法来设置对象的值,那么在第 一次调用get方法时会通过initialValue方法来获取对象的初始值。代码清单11-22也给出了ThreadLocal类的一般使用方式。创建一个 ThreadLocal类的匿名子类并覆写initialValue方法,并把对ThreadLocal类的对象的使用封装在另外一个类中。程序的其他部分使用这个外部 类的方法来获取被ThreadLocal类的对象所管理的对象。不同的线程调用ThreadLocalIdGenerator类的getNext方法时,所使用的是这个线程所 对应的私有的IdGenerator类的对象,不同线程使用的是不同的对象。 代码清单11-22 ThreadLocal类的使用示例 public class ThreadLocalIdGenerator{ private static final ThreadLocal<IdGenerator>idGenerator=new ThreadLocal<IdGenerator>(){ protected IdGenerator initialValue(){ return new IdGenerator(); } }; public static int getNext(){ return idGenerator.get().getNext(); } } ThreadLocal类的另外一个作用是创建线程唯一的对象。某些对象的创建比较耗时,可以把对象的创建逻辑封装在ThreadLocal类的 initialValue方法中。当通过get方法获取时,所有线程都可以得到唯一的一个对象。在同一个线程中运行的代码访问的都是同一个对象。在有 些情况下,一个对象在代码中的各个部分都需要用到,传统的做法是把这个对象作为参数在代码之间进行传递。这种方式需要改变方法的类型 声明。如果所有使用这个对象的代码都在同一个线程中运行,可以把该对象封装在一个ThreadLocal类的对象中,这样使用起来会非常方便。 ThreadLocal类适合用在Web应用的servlet中。一个servlet请求一般由单一的线程来处理。可以在开始处理请求时把某些全局对象保存到 ThreadLocal类的对象中,并在后续的处理中使用,这些全局对象包括servlet请求对象、数据库连接和配置信息等。 在一个多线程程序中,如果需要生成随机数,应该使用Java SE 7中新增的java.util.concurrent.ThreadLocalRandom类。 ThreadLocalRandom类中的随机数生成器是使用ThreadLocal类来实现的,避免了使用java.util.Random对象可能带来的竞争问题,可以获得更 佳的性能。 11.8 小结 在程序开发中使用多线程技术是一项复杂的任务,但是在很多情况下,多线程技术是必须使用的。以桌面程序来说,如果所有耗时的操作 都在事件分发线程中完成,程序的界面会出现失去响应的情况。在实际开发中,对于多线程协作的场景,可以先对线程之间的交互模式进行抽 象,并在java.util.concurrent包中寻找合适的同步方式与之对应。比如经典的生产者-消费者和读者-写者场景,使用java.util.concurrent 包中提供的高层API可以很容易地实现。优先使用高层API,应该是Java多线程开发中的首要原则。如果已有的高层API无法满足需求,那么应该 使用java.util.concurrent.atomic和java.util.concurrent.locks包提供的中层API来创建程序自己的同步API。而synchronized和volatile关 键词,以及Object类中的wait、notify和notifyAll等低层API应该是最后才考虑的。 从理解和掌握多线程开发的角度出发,应该是按照从Java内存模型、低层API、中层API到高层API的顺序来进行学习,这也是本章对应内容 的排列顺序;而在实际的开发中,应该按照相反的顺序来考虑这些API的使用。 第12章 Java泛型 在编程语言中,泛型是一个常见的特性。在主流编程语言(如Java、C++和C#)中都可以看到类似的语言特性。在程序中通常只对固定类型 的对象进行操作。有些代码可以对多种不同类型的对象进行操作。实际使用的类型在代码中只是以参数形式出现的占位符,在具体实例化时, 用实际类型替代其中的类型占位符,这种方式被称为泛型编程(generic programming)。泛型适用于对抽象类型进行处理,可以有效地减少代 码重复,通过一份代码即可对不同类型的对象进行操作。典型的例子是处理集合相关的数据结构。对于这些数据结构可以进行一些抽象的操 作,如排序和反转等。这些操作只与数据结构的特征相关,与其中包含的对象的类型无关。在实现这些操作时,对象的类型用占位符来表示即 可,并不影响操作的实现逻辑。使用者可以根据需要指定实际的类型。 Java语言发展到J2SE 5.0才引入了泛型的特性。泛型的主要动机是为了实现类型安全的集合类。除此之外,泛型还可以用来创建处理抽象 类型的新类型。本章的内容围绕Java中的泛型展开,涉及的内容包括泛型的基本概念、底层实现和使用方式等。 12.1 泛型基本概念 引入泛型的主要动机是让开发人员更安全地使用Java标准库中的集合类,尽早地发现一些代码中包含的潜在错误。Java的集合类框架在JDK 1.2中被添加到Java标准库中,其中包含了常用的java.util.List、java.util.Map和java.util.Set等接口及其实现类。在J2SE 5.0引入泛型之 前,Java中的集合类对象实际上是异构类型对象的集合。为了能够存放任何类型的对象,集合类中的元素的类型统一为Object类。在存放元素 时,不论对象的实际类型如何,都可以将其保存到集合中。在读取元素时,需要对得到的对象进行强制类型转换,转换成对象的实际类型。使 用强制类型转换的问题在于,运行时才可能发现类型不兼容的情况,由java.lang.ClassCastException异常来表示。运行时才发现的错误的处 理代价比编译时发现的错误的处理代价要高得多,应该尽可能早地发现这些错误。 比如,在创建了一个List接口的实现类ArrayList的对象之后,可以向该对象中添加任何类型的对象。该ArrayList类的对象中可以同时包 含String类和Number类的对象,这通常不是程序中所需要的行为。集合中通常包含的是同构类型的对象,但是Java语言并没有提供相应的机制 来阻止向一个集合类的对象中添加不正确类型的对象,开发人员也不能在代码中表明集合类的对象中应该包含的对象类型。向一个集合类的对 象中添加不兼容类型的对象,在编译时并不会出错。在运行时,当程序使用不正确类型的对象时,会在进行强制类型转换时抛出 ClassCastException异常。 为了实现类型安全的集合类,J2SE 5.0引入了泛型的语言特性。泛型中包含的具体内容比较多,主要包括泛型类型和泛型方法的声明和实 例化。泛型的引入也对Java标准库中的很多API造成了影响。泛型类型与一般类型的区别在于,泛型类型有形式类型参数(type parameter), 可以在泛型类型被实例化时替换成实际的具体类型(type argument)。先从一个具体的示例说起。代码清单12-1中的ObjectHolder类是一个简 单的泛型类,用来保存特定类型的对象引用。与一般的类型不同,泛型类型声明中的“<T>”用来表示形式类型参数T。形式类型参数T可以用 在泛型类型实现的代码中。 代码清单12-1 声明泛型类型的示例 public class ObjectHolder<T>{ private T obj; public T getObject(){ return obj; } public void setObject(T obj){ this.obj=obj; } } 泛型类的使用与一般的Java类并没有太大的区别,只是需要为其中声明的形式类型参数指定实际的类型。代码清单12-2给出了泛型类 ObjectHolder的基本使用方式。形式类型参数T在实例化泛型类型时被替换成实际类型String。 代码清单12-2 使用泛型类型的示例 ObjectHolder<String>holder=new ObjectHolder<String>(); holder.setObject("Hello"); String str=holder.getObject(); 在创建出泛型类的对象之后,该对象在使用时的类型是受限的,比如代码清单12-2中的holder对象,在调用setObject方法时,参数的类型 只能是String类型;getObject方法的返回值也是String类型。如果不使用泛型来实现类似的功能,那么setObject方法的参数声明只能是 Object类。同一对象的某个使用者可能在调用setObject方法时传入一个String类的对象,而另外一个使用者可能错误地认为其中包含的是 Number类的对象,在调用getObject方法之后进行强制类型转换。这样在运行时会出现ClassCastException异常。如果希望只保存String类的对 象,在不使用泛型的情况下,需要把setObject方法的参数和getObject方法的返回值都声明为String类型。这样在保存其他类型的对象时,需 要创建对应类型的新的Java类。这些Java类的内部逻辑是相同的,造成了不必要的代码重复。通过使用泛型,只需要一个Java类就可以表示不 同类型的对象。 结合代码清单12-1和代码清单12-2中对泛型类的声明和使用方式,对泛型相关的基本概念进行具体说明。如果在一个类型中使用了形式类 型参数,则称该类型为泛型类型(generic type)。代码清单12-1中的ObjectHolder类是一个泛型类。泛型类型可以被实例化。在实例化之 后,泛型类型声明中的形式类型参数被替换成实际的类型。实例化之后的泛型类型被称为参数化类型(parameterized type)。代码清单12-2 中的ObjectHolder<String>是一种参数化类型。对于同一个泛型类型来说,可能的参数化类型的数量非常多。根据使用的实际类型,参数化 类型分为两类:一类是不带通配符的类型,另外一类是带通配符的类型。通配符(wildcard)“?”的作用是表示一组类型的集合,可以匹配特 定范围内的类型。在使