StAX – 解析XML文件
在千禧年左右,当 XML 第一次出现在很多 Java 开发人员面前时,有两种基本的解析 XML 文件的方法。SAX 解析器实际是由程序员对事件调用一系列回调方法的大型状态机。DOM 解析器将整个 XML 文档加入内存,并切割成离散的对象,它们连接在一起形成一个树。该树描述了文档的整个 XML Infoset 表示法。这两个解析器都有缺点:SAX 太低级,无法使用,DOM 代价太大,尤其对于大的 XML 文件 — 整个树成了一个庞然大物。
幸运的是,Java 开发人员找到第三种方法来解析 XML 文件,通过对文档建模成 “节点”,它们可以从文档流中一次取出一个,检查,然后处理或丢弃。这些 “节点” 的 “流” 提供了 SAX 和 DOM 的中间地带,名为 “Streaming API for XML”,或者叫做StAX。(此缩写用于区分新的 API 与原来的 SAX 解析器,它与此同名。)StAX 解析器后来包装到了 JDK 中,在 javax.xml.stream 包。
使用 StAX 相当简单:实例化 XMLEventReader,将它指向一个格式良好的 XML 文件,然后一次 “拉出” 一个节点(通常用 while 循环),查看。例如,在清单 1 中,列举出了 Ant 构造脚本中的所有目标:
清单 1. 只是让 StAX 指向目标
import java.io.*;
import javax.xml.namespace.QName;
import javax.xml.stream.*;
import javax.xml.stream.events.*;
import javax.xml.stream.util.*;
public class Targets
{
public static void main(String[] args)
throws Exception
{
for (String arg : args)
{
XMLEventReader xsr =
XMLInputFactory.newInstance()
.createXMLEventReader(new FileReader(arg));
while (xsr.hasNext())
{
XMLEvent evt = xsr.nextEvent();
switch (evt.getEventType())
{
case XMLEvent.START_ELEMENT:
{
StartElement se = evt.asStartElement();
if (se.getName().getLocalPart().equals("target"))
{
Attribute targetName =
se.getAttributeByName(new QName("name"));
// Found a target!
System.out.println(targetName.getValue());
}
break;
}
// Ignore everything else
}
}
}
}
}
StAX 解析器不会替换所有的 SAX 和 DOM 代码。但肯定会让某些任务容易些。尤其对完成不需要知道 XML 文档整个树结构的任务相当方便。
请注意,如果事件对象级别太高,无法使用,StAX 也有一个低级 API 在 XMLStreamReader 中。尽管也许没有阅读器有用,StAX 还有一个 XMLEventWriter,同样,还有一个 XMLStreamWriter 类用于 XML 输出。
ServiceLoader – 加载服务(获取接口的所有实现)
Java 开发人员经常希望将使用和创建组件的内容区分开来。这通常是通过创建一个描述组件动作的接口,并使用某种中介创建组件实例来完成的。很多开发人员使用 Spring 框架来完成,但还有其他的方法,它比 Spring 容器更轻量级。
java.util 的 ServiceLoader 类能读取隐藏在 JAR 文件中的配置文件,并找到接口的实现,然后使这些实现成为可选择的列表。例如,如果您需要一个私仆(personal-servant)组件来完成任务,您可以使用清单 2 中的代码来实现:
清单 2. IPersonalServant
public interface IPersonalServant
{
// Process a file of commands to the servant
public void process(java.io.File f) throws java.io.IOException;
public boolean can(String command);
}
can() 方法可让您确定所提供的私仆实现是否满足需求。清单 3 中的 ServiceLoader 的 IPersonalServant 列表基本上满足需求:
清单 3. IPersonalServant 行吗?
import java.io.*;
import java.util.*;
public class Servant
{
public static void main(String[] args)
throws IOException
{
ServiceLoader<IPersonalServant> servantLoader =
ServiceLoader.load(IPersonalServant.class);
IPersonalServant i = null;
for (IPersonalServant ii : servantLoader)
if (ii.can("fetch tea"))
i = ii;
if (i == null)
throw new IllegalArgumentException("No suitable servant found");
for (String arg : args)
{
i.process(new File(arg));
}
}
}
假设有此接口的实现,如清单 4:
清单 4. Jeeves 实现了 IPersonalServant
import java.io.*;
public class Jeeves
implements IPersonalServant
{
public void process(File f)
{
System.out.println("Very good, sir.");
}
public boolean can(String cmd)
{
if (cmd.equals("fetch tea"))
return true;
else
return false;
}
}
剩下的就是配置包含实现的 JAR 文件,让 ServiceLoader 能识别 — 这可能会非常棘手。JDK 想要 JAR 文件有一个 META-INF/services 目录,它包含一个文本文件,其文件名与接口类名完全匹配 — 本例中是 META-INF/services/IPersonalServant。接口类名的内容是实现的名称,每行一个,如清单 5:
清单 5. META-INF/services/IPersonalServant
Jeeves # comments are OK
幸运的是,Ant 构建系统(自 1.7.0 以来)包含一个对 jar 任务的服务标签,让这相对容易,见清单 6:
清单 6. Ant 构建的 IPersonalServant
<target name="serviceloader" depends="build">
<jar destfile="misc.jar" basedir="./classes">
<service type="IPersonalServant">
<provider classname="Jeeves" />
</service>
</jar>
</target>
这里,很容易调用 IPersonalServant,让它执行命令。然而,解析和执行这些命令可能会非常棘手。这又是另一个 “小线头”。
Scanner
有无数 Java 工具能帮助您构建解析器,很多函数语言已成功构建解析器函数库(解析器选择器)。但如果要解析的是逗号分隔值文件,或空格分隔文本文件,又怎么办呢?大多数工具用在此处就过于隆重了,而 String.split() 又不够。(对于正则表达式,请记住一句老话:“ 您有一个问题,用正则表达式解决。那您就有两个问题了。”)
Java 平台的 Scanner 类会是这些类中您好的选择。以轻量级文本解析器为目标,Scanner 提供了一个相对简单的 API,用于提取结构化文本,并放入强类型的部分。想象一下,如果您愿意,一组类似 DSL 的命令(源自 Terry Pratchett Discworld 小说)排列在文本文件中,如清单 7:
清单 7. Igor 的任务
fetch 1 head
fetch 3 eye
fetch 1 foot
attach foot to head
attach eye to head
admire
您,或者是本例中称为 Igor的私仆,能轻松使用 Scanner 解析这组违法命令,如清单 8 所示:
清单 8. Igor 的任务,由 Scanner 解析
import java.io.*;
import java.util.*;
public class Igor
implements IPersonalServant
{
public boolean can(String cmd)
{
if (cmd.equals("fetch body parts"))
return true;
if (cmd.equals("attach body parts"))
return true;
else
return false;
}
public void process(File commandFile)
throws FileNotFoundException
{
Scanner scanner = new Scanner(commandFile);
// Commands come in a verb/number/noun or verb form
while (scanner.hasNext())
{
String verb = scanner.next();
if (verb.equals("fetch"))
{
int num = scanner.nextInt();
String type = scanner.next();
fetch (num, type);
}
else if (verb.equals("attach"))
{
String item = scanner.next();
String to = scanner.next();
String target = scanner.next();
attach(item, target);
}
else if (verb.equals("admire"))
{
admire();
}
else
{
System.out.println("I don't know how to "
+ verb + ", marthter.");
}
}
}
public void fetch(int number, String type)
{
if (parts.get(type) == null)
{
System.out.println("Fetching " + number + " "
+ type + (number > 1 ? "s" : "") + ", marthter!");
parts.put(type, number);
}
else
{
System.out.println("Fetching " + number + " more "
+ type + (number > 1 ? "s" : "") + ", marthter!");
Integer currentTotal = parts.get(type);
parts.put(type, currentTotal + number);
}
System.out.println("We now have " + parts.toString());
}
public void attach(String item, String target)
{
System.out.println("Attaching the " + item + " to the " +
target + ", marthter!");
}
public void admire()
{
System.out.println("It'th quite the creathion, marthter");
}
private Map<String, Integer> parts = new HashMap<String, Integer>();
}
假设 Igor 已在 ServantLoader 中注册,可以很方便地将 can() 调用改得更实用,并重用前面的 Servant 代码,如清单 9 所示:
清单 9. Igor 做了什么
import java.io.*;
import java.util.*;
public class Servant
{
public static void main(String[] args)
throws IOException
{
ServiceLoader<IPersonalServant> servantLoader =
ServiceLoader.load(IPersonalServant.class);
IPersonalServant i = null;
for (IPersonalServant ii : servantLoader)
if (ii.can("fetch body parts"))
i = ii;
if (i == null)
throw new IllegalArgumentException("No suitable servant found");
for (String arg : args)
{
i.process(new File(arg));
}
}
}
真正 DSL 实现显然不会仅仅打印到标准输出流。我把追踪哪些部分、跟随哪些部分的细节留待给您(当然,还有忠诚的 Igor)。
Timer
java.util.Timer 和 TimerTask 类提供了方便、相对简单的方法可在定期或一次性延迟的基础上执行任务:
清单 10. 稍后执行
import java.util.*;
public class Later
{
public static void main(String[] args)
{
Timer t = new Timer("TimerThread");
t.schedule(new TimerTask() {
public void run() {
System.out.println("This is later");
System.exit(0);
}
}, 1 * 1000);
System.out.println("Exiting main()");
}
}
Timer 有许多 schedule() 重载,它们提示某一任务是一次性还是重复的,并且有一个启动的 TimerTask 实例。TimerTask 实际上是一个 Runnable(事实上,它实现了它),但还有另外两个方法:cancel() 用来取消任务,scheduledExecutionTime() 用来返回任务何时启动的近似值。
请注意 Timer 却创建了一个非守护线程在后台启动任务,因此在清单 10 中我需要调用 System.exit() 来取消任务。在长时间运行的程序中,好创建一个 Timer 守护线程(使用带有指示守护线程状态的参数的构造函数),从而它不会让 VM 活动。
这个类没什么神奇的,但它确实能帮助我们对后台启动的程序的目的了解得更清楚。它还能节省一些 Thread 代码,并作为轻量级 ScheduledExecutorService(对于还没准备好了解整个 java.util.concurrent 包的人来说)。
JavaSound
尽管在服务器端应用程序中不常出现,但 sound 对管理员有着有用的 “被动” 意义 — 它是恶作剧的好材料。尽管它很晚才出现在 Java 平台中,JavaSound API 终还是加入了核心运行时库,封装在 javax.sound * 包 — 其中一个包是 MIDI 文件,另一个是音频文件示例(如普遍的 .WAV 文件格式)。
JavaSound 的 “hello world” 是播放一个片段,如清单 11 所示:
清单 11. 再放一遍,Sam
public static void playClip(String audioFile)
{
try
{
AudioInputStream inputStream =
AudioSystem.getAudioInputStream(
this.getClass().getResourceAsStream(audioFile));
DataLine.Info info =
new DataLine.Info( Clip.class, audioInputStream.getFormat() );
Clip clip = (Clip) AudioSystem.getLine(info);
clip.addLineListener(new LineListener() {
public void update(LineEvent e) {
if (e.getType() == LineEvent.Type.STOP) {
synchronized(clip) {
clip.notify();
}
}
}
});
clip.open(audioInputStream);
clip.setFramePosition(0);
clip.start();
synchronized (clip) {
clip.wait();
}
clip.drain();
clip.close();
}
catch (Exception ex)
{
ex.printStackTrace();
}
}
大多数还是相当简单(至少 JavaSound 一样简单)。第一步是创建一个文件的 AudioInputStream 来播放。为了让此方法尽量与上下文无关,我们从加载类的 ClassLoader 中抓取文件作为 InputStream。(AudioSystem 还需要一个 File 或 String,如果提前知道声音文件的具体路径。)一旦完成, DataLine.Info 对象就提供给 AudioSystem,得到一个 Clip,这是播放音频片段简单的方法。(其他方法提供了对片段更多的控制 — 例如获取一个 SourceDataLine — 但对于 “播放” 来说,过于复杂)。
这里应该和对 AudioInputStream 调用 open() 一样简单。(“应该” 的意思是如果您没遇到下节描述的错误。)调用 start() 开始播放,drain() 等待播放完成,close() 释放音频线路。播放是在单独的线程进行,因此调用 stop() 将会停止播放,然后调用 start() 将会从播放暂停的地方重新开始;使用 setFramePosition(0) 重新定位到开始。
没声音?
JDK 5 发行版中有个讨厌的小错误:在有些平台上,对于一些短的音频片段,代码看上去运行正常,但就是 ... 没声音。显然媒体播放器在应该出现的位置之前触发了 STOP 事件。
这个错误 “无法修复”,但解决方法相当简单:注册一个 LineListener 来监听 STOP 事件,当触发时,调用片段对象的 notifyAll()。然后在 “调用者” 代码中,通过调用 wait() 等待片段完成(还调用 notifyAll())。在没出现错误的平台上,这些错误是多余的,在 Windows® 及有些 Linux® 版本上,会让程序员 “开心” 或 “愤怒”。
使用 Java 语言进行 Unicode 代理编程
原文地址:http://www.ibm.com/developerworks/cn/java/j-unicode/index.html#
早期 Java 版本使用 16 位 char 数据类型表示 Unicode 字符。这种设计方法有时比较合理,因为所有 Unicode 字符拥有的值都小于 65,535 (0xFFFF),可以通过 16 位表示。但是,Unicode 后来将大值增加到 1,114,111 (0x10FFFF)。由于 16 位太小,不能表示 Unicode version 3.1 中的所有 Unicode 字符,32 位值 — 称为码位(code point) — 被用于 UTF-32 编码模式。
但与 32 位值相比,16 位值的内存使用效率更高,因此 Unicode 引入了一个种新设计方法来允许继续使用 16 位值。UTF-16 中采用的这种设计方法分配 1,024 值给 16 位高代理(high surrogate),将另外的 1,024 值分配给 16 位低代理(low surrogate)。它使用一个高代理加上一个低代理 — 一个代理对(surrogate pair) — 来表示 65,536 (0x10000) 和 1,114,111 (0x10FFFF) 之间的 1,048,576 (0x100000) 值(1,024 和 1,024 的乘积)。
Java 1.5 保留了 char 类型的行为来表示 UTF-16 值(以便兼容现有程序),它实现了码位的概念来表示 UTF-32 值。这个扩展(根据 JSR 204:Unicode Supplementary Character Support 实现)不需要记住 Unicode 码位或转换算法的准确值 — 但理解代理 API 的正确用法很重要。
东亚国家和地区近年来增加了它们的字符集中的字符数量,以满足用户需求。这些标准包括来自中国的国家标准组织的 GB 18030 和来自日本的 JIS X 0213。因此,寻求遵守这些标准的程序更有必要支持 Unicode 代理对。本文解释相关 Java API 和编码选项,面向计划重新设计他们的软件,从只能使用 char 类型的字符转换为能够处理代理对的新版本的读者。
顺序访问
顺序访问是在 Java 语言中处理字符串的一个基本操作。在这种方法下,输入字符串中的每个字符从头至尾按顺序访问,或者有时从尾至头访问。本小节讨论使用顺序访问方法从一个字符串创建一个 32 位码位数组的 7 个技术示例,并估计它们的处理时间。
示例 1-1:基准测试(不支持代理对)
清单 1 将 16 位 char 类型值直接分配给 32 位码位值,完全没有考虑代理对:
清单 1. 不支持代理对
int[] toCodePointArray(String str) { // Example 1-1
int len = str.length(); // the length of str
int[] acp = new int[len]; // an array of code points
for (int i = 0, j = 0; i < len; i++) {
acp[j++] = str.charAt(i);
}
return acp;
}
尽管这个示例不支持代理对,但它提供了一个处理时间基准来比较后续顺序访问示例。
示例 1-2:使用 isSurrogatePair()
清单 2 使用 isSurrogatePair() 来计算代理对总数。计数之后,它分配足够的内存以便一个码位数组存储这个值。然后,它进入一个顺序访问循环,使用 isHighSurrogate() 和 isLowSurrogate() 确定每个代理对字符是高代理还是低代理。当它发现一个高代理后面带一个低代理时,它使用 toCodePoint() 将该代理对转换为一个码位值并将当前索引值增加 2。否则,它将这个 char 类型值直接分配给一个码位值并将当前索引值增加 1。这个示例的处理时间比 示例 1-1 长 1.38 倍。
清单 2. 有限支持
int[] toCodePointArray(String str) { // Example 1-2
int len = str.length(); // the length of str
int[] acp; // an array of code points
int surrogatePairCount = 0; // the count of surrogate pairs
for (int i = 1; i < len; i++) {
if (Character.isSurrogatePair(str.charAt(i - 1), str.charAt(i))) {
surrogatePairCount++;
i++;
}
}
acp = new int[len - surrogatePairCount];
for (int i = 0, j = 0; i < len; i++) {
char ch0 = str.charAt(i); // the current char
if (Character.isHighSurrogate(ch0) && i + 1 < len) {
char ch1 = str.charAt(i + 1); // the next char
if (Character.isLowSurrogate(ch1)) {
acp[j++] = Character.toCodePoint(ch0, ch1);
i++;
continue;
}
}
acp[j++] = ch0;
}
return acp;
}
清单 2 中更新软件的方法很幼稚。它比较麻烦,需要大量修改,使得生成的软件很脆弱且今后难以更改。具体而言,这些问题是:
需要计算码位的数量以分配足够的内存
很难获得字符串中的指定索引的正确码位值
很难为下一个处理步骤正确移动当前索引
一个改进后的算法出现在下一个示例中。
示例:基本支持
Java 1.5 提供了 codePointCount()、codePointAt() 和 offsetByCodePoints() 方法来分别处理 示例 1-2 的 3 个问题。清单 3 使用这些方法来改善这个算法的可读性:
清单 3. 基本支持
int[] toCodePointArray(String str) { // Example 1-3
int len = str.length(); // the length of str
int[] acp = new int[str.codePointCount(0, len)];
for (int i = 0, j = 0; i < len; i = str.offsetByCodePoints(i, 1)) {
acp[j++] = str.codePointAt(i);
}
return acp;
}
但是,清单 3 的处理时间比 清单 1 长 2.8 倍。
示例 1-4:使用 codePointBefore()
当 offsetByCodePoints() 接收一个负数作为第二个参数时,它就能计算一个距离字符串头的绝对偏移值。接下来,codePointBefore() 能够返回一个指定索引前面的码位值。这些方法用于清单 4 中从尾至头遍历字符串:
清单 4. 使用 codePointBefore() 的基本支持
int[] toCodePointArray(String str) { // Example 1-4
int len = str.length(); // the length of str
int[] acp = new int[str.codePointCount(0, len)];
int j = acp.length; // an index for acp
for (int i = len; i > 0; i = str.offsetByCodePoints(i, -1)) {
acp[--j] = str.codePointBefore(i);
}
return acp;
}
这个示例的处理时间 — 比 示例 1-1 长 2.72 倍 — 比 示例 1-3 快一些。通常,当您比较零而不是非零值时,JVM 中的代码大小要小一些,这有时会提高性能。但是,微小的改进可能不值得牺牲可读性。
示例 1-5:使用 charCount()
示例 1-3 和 1-4 提供基本的代理对支持。他们不需要任何临时变量,是健壮的编码方法。要获取更短的处理时间,使用 charCount() 而不是 offsetByCodePoints() 是有效的,但需要一个临时变量来存放码位值,如清单 5 所示:
清单 5. 使用 charCount() 的优化支持
int[] toCodePointArray(String str) { // Example 1-5
int len = str.length(); // the length of str
int[] acp = new int[str.codePointCount(0, len)];
int j = 0; // an index for acp
for (int i = 0, cp; i < len; i += Character.charCount(cp)) {
cp = str.codePointAt(i);
acp[j++] = cp;
}
return acp;
}
清单 5 的处理时间降低到比 示例 1-1 长 1.68 倍。
示例 1-6:访问一个 char 数组
清单 6 在使用 示例 1-5 中展示的优化的同时直接访问一个 char 类型数组:
清单 6. 使用一个 char 数组的优化支持
int[] toCodePointArray(String str) { // Example 1-6
char[] ach = str.toCharArray(); // a char array copied from str
int len = ach.length; // the length of ach
int[] acp = new int[Character.codePointCount(ach, 0, len)];
int j = 0; // an index for acp
for (int i = 0, cp; i < len; i += Character.charCount(cp)) {
cp = Character.codePointAt(ach, i);
acp[j++] = cp;
}
return acp;
}
char 数组是使用 toCharArray() 从字符串复制而来的。性能得到改善,因为对数组的直接访问比通过一个方法的间接访问要快。处理时间比 示例 1-1 长 1.51 倍。但是,当调用时,toCharArray() 需要一些开销来创建一个新数组并将数据复制到数组中。String 类提供的那些方便的方法也不能被使用。但是,这个算法在处理大量数据时有用。
示例 1-7:一个面向对象的算法
这个示例的面向对象算法使用 CharBuffer 类,如清单 7 所示:
清单 7. 使用 CharSequence 的面向对象算法
int[] toCodePointArray(String str) { // Example 1-7
CharBuffer cBuf = CharBuffer.wrap(str); // Buffer to wrap str
IntBuffer iBuf = IntBuffer.allocate( // Buffer to store code points
Character.codePointCount(cBuf, 0, cBuf.capacity()));
while (cBuf.remaining() > 0) {
int cp = Character.codePointAt(cBuf, 0); // the current code point
iBuf.put(cp);
cBuf.position(cBuf.position() + Character.charCount(cp));
}
return iBuf.array();
}
与前面的示例不同,清单 7 不需要一个索引来持有当前位置以便进行顺序访问。相反,CharBuffer 在内部跟踪当前位置。Character 类提供静态方法 codePointCount() 和 codePointAt(),它们能通过 CharSequence 接口处理 CharBuffer。CharBuffer 总是将当前位置设置为 CharSequence 的头。因此,当 codePointAt() 被调用时,第二个参数总是设置为 0。处理时间比 示例 1-1 长 2.15 倍。
处理时间比较
这些顺序访问示例的计时测试使用了一个包含 10,000 个代理对和 10,000 个非代理对的样例字符串。码位数组从这个字符串创建 10,000 次。测试环境包括:
OS:Microsoft Windows® XP Professional SP2
Java:IBM Java 1.5 SR7
CPU:Intel® Core 2 Duo CPU T8300 @ 2.40GHz
Memory:2.97GB RAM
表 1 展示了示例 1-1 到 1-7 的绝对和相对处理时间以及关联的 API:
表 1. 顺序访问示例的处理时间和 API
示例 说明 处理时间(毫秒) 与示例 1-1 的比率 API
1-1 不支持代理对 2031 1.00
1-2 有限支持 2797 1.38 Character 类:
static boolean isHighSurrogate(char ch)
static boolean isLowSurrogate(char ch)
static boolean isSurrogatePair(char high, char low)
static int toCodePoint(char high, char low)
1-3 基本支持 5687 2.80 String 类:
int codePointAt(int index)
int codePointCount(int begin, int end)
int offsetByCodePoints(int index, int cpOffset)
1-4 使用 codePointBefore() 的基本支持 5516 2.72 String 类:
int codePointBefore(int index)
1-5 使用 charCount() 的优化支持 3406 1.68 Character 类:
static int charCount(int cp)
1-6 使用一个 char 数组的优化支持 3062 1.51 Character 类:
static int codePointAt(char[] ach, int index)
static int codePointCount(char[] ach, int offset, int count)
1-7 使用 CharSequence 的面向对象方法 4360 2.15 Character 类:
static int codePointAt(CharSequence seq, int index)
static int codePointCount(CharSequence seq, int begin, int end)
随机访问
随机访问是直接访问一个字符串中的任意位置。当字符串被访问时,索引值基于 16 位 char 类型的单位。但是,如果一个字符串使用 32 位码位,那么它不能使用一个基于 32 位码位的单位的索引访问。必须使用 offsetByCodePoints() 来将码位的索引转换为 char 类型的索引。如果算法设计很糟糕,这会导致很差的性能,因为 offsetByCodePoints() 总是通过使用第二个参数从第一个参数计算字符串的内部。在这个小节中,我将比较三个示例,它们通过使用一个短单位来分割一个长字符串。
示例 2-1:基准测试(不支持代理对)
清单 8 展示如何使用一个宽度单位来分割一个字符串。这个基准测试留作后用,不支持代理对。
清单 8. 不支持代理对
String[] sliceString(String str, int width) { // Example 2-1
// It must be that "str != null && width > 0".
List<String> slices = new ArrayList<String>();
int len = str.length(); // (1) the length of str
int sliceLimit = len - width; // (2) Do not slice beyond here.
int pos = 0; // the current position per char type
while (pos < sliceLimit) {
int begin = pos; // (3)
int end = pos + width; // (4)
slices.add(str.substring(begin, end));
pos += width; // (5)
}
slices.add(str.substring(pos)); // (6)
return slices.toArray(new String[slices.size()]); }
sliceLimit 变量对分割位置有所限制,以避免在剩余的字符串不足以分割当前宽度单位时抛出一个 IndexOutOfBoundsException 实例。这种算法在当前位置超出 sliceLimit 时从 while 循环中跳出后再处理后的分割。
示例 2-2:使用一个码位索引
清单 9 展示了如何使用一个码位索引来随机访问一个字符串:
清单 9. 糟糕的性能
String[] sliceString(String str, int width) { // Example 2-2
// It must be that "str != null && width > 0".
List<String> slices = new ArrayList<String>();
int len = str.codePointCount(0, str.length()); // (1) code point count [Modified]
int sliceLimit = len - width; // (2) Do not slice beyond here.
int pos = 0; // the current position per code point
while (pos < sliceLimit) {
int begin = str.offsetByCodePoints(0, pos); // (3) [Modified]
int end = str.offsetByCodePoints(0, pos + width); // (4) [Modified]
slices.add(str.substring(begin, end));
pos += width; // (5)
}
slices.add(str.substring(str.offsetByCodePoints(0, pos))); // (6) [Modified]
return slices.toArray(new String[slices.size()]); }
清单 9 修改了 清单 8 中的几行。首先,在 Line (1) 中,length() 被 codePointCount() 替代。其次,在 Lines (3)、(4) 和 (6) 中,char 类型的索引通过 offsetByCodePoints() 用码位索引替代。
基本的算法流与 示例 2-1 中的看起来几乎一样。但处理时间根据字符串长度与示例 2-1 的比率同比增加,因为 offsetByCodePoints() 总是从字符串头到指定索引计算字符串内部。
示例 2-3:减少的处理时间
可以使用清单 10 中展示的方法来避免 示例 2-2 的性能问题:
清单 10. 改进的性能
String[] sliceString(String str, int width) { // Example 2-3
// It must be that "str != null && width > 0".
List<String> slices = new ArrayList<String>();
int len = str.length(); // (1) the length of str
int sliceLimit // (2) Do not slice beyond here. [Modified]
= (len >= width * 2 || str.codePointCount(0, len) > width)
? str.offsetByCodePoints(len, -width) : 0;
int pos = 0; // the current position per char type
while (pos < sliceLimit) {
int begin = pos; // (3)
int end = str.offsetByCodePoints(pos, width); // (4) [Modified]
slices.add(str.substring(begin, end));
pos = end; // (5) [Modified]
}
slices.add(str.substring(pos)); // (6)
return slices.toArray(new String[slices.size()]); }
首先,在 Line (2) 中,(清单 9 中的)表达式 len-width 被 offsetByCodePoints(len,-width) 替代。但是,当 width 的值大于码位的数量时,这会抛出一个 IndexOutOfBoundsException 实例。必须考虑边界条件以避免异常,使用一个带有 try/catch 异常处理程序的子句将是另一个解决方案。如果表达式 len>width*2 为 true,则可以安全地调用 offsetByCodePoints(),因为即使所有码位都被转换为代理对,码位的数量仍会超过 width 的值。或者,如果 codePointCount(0,len)>width 为 true,也可以安全地调用 offsetByCodePoints()。如果是其他情况,sliceLimit 必须设置为 0。
在 Line (4) 中,清单 9 中的表达式 pos + width 必须在 while 循环中使用 offsetByCodePoints(pos,width) 替换。需要计算的量位于 width 的值中,因为第一个参数指定当 width 的值。接下来,在 Line (5) 中,表达式 pos+=width 必须使用表达式 pos=end 替换。这避免两次调用 offsetByCodePoints() 来计算相同的索引。源代码可以被进一步修改以小化处理时间。
处理时间比较
图 1 和图 2 展示了示例 2-1、2-2 和 2-3 的处理时间。样例字符串包含相同数量的代理对和非代理对。当字符串的长度和 width 的值被更改时,样例字符串被切割 10,000 次。
图 1. 一个分段的常量宽度图 2. 分段的常量计数
示例 2-1 和 2-3 按照长度比例增加了它们的处理时间,但 示例 2-2 按照长度的平方比例增加了处理时间。当字符串长度和 width 的值增加而分段的数量固定时,示例 2-1 拥有一个常量处理时间,而示例 2-2 和 2-3 以 width 的值为比例增加了它们的处理时间。
信息 API
大多数处理代理的信息 API 拥有两种名称相同的方法。一种接收 16 位 char 类型参数,另一种接收 32 为码位参数。表 2 展示了每个 API 的返回值。第三列针对 U+53F1,第 4 列针对 U+20B9F,后一列针对 U+D842(即高代理),而 U+20B9F 被转换为 U+D842 加上 U+DF9F 的代理对。如果程序不能处理代理对,则值 U+D842 而不是 U+20B9F 将导致意想不到的结果(在表 2 中以粗斜体表示)。
表 2. 用于代理的信息 API
类 方法/构造函数 针对 U+53F1 的值 针对 U+20B9F 的值 针对 U+D842 的值
Character static byte getDirectionality(int cp) 0 0 0
static int getNumericValue(int cp) -1 -1 -1
static int getType(int cp) 5 5 19
static boolean isDefined(int cp) true true true
static boolean isDigit(int cp) false false false
static boolean isISOControl(int cp) false false false
static boolean isIdentifierIgnorable(int cp) false false false
static boolean isJavaIdentifierPart(int cp) true true false
static boolean isJavaIdentifierStart(int cp) true true false
static boolean isLetter(int cp) true true false
static boolean isLetterOrDigit(int cp) true true false
static boolean isLowerCase(int cp) false false false
static boolean isMirrored(int cp) false false false
static boolean isSpaceChar(int cp) false false false
static boolean isSupplementaryCodePoint(int cp) false true false
static boolean isTitleCase(int cp) false false false
static boolean isUnicodeIdentifierPart(int cp) true true false
static boolean isUnicodeIdentifierStart(int cp) true true false
static boolean isUpperCase(int cp) false false false
static boolean isValidCodePoint(int cp) true true true
static boolean isWhitespace(int cp) false false false
static int toLowerCase(int cp) (不可更改)
static int toTitleCase(int cp) (不可更改)
static int toUpperCase(int cp) (不可更改)
Character.UnicodeBlock Character.UnicodeBlock of(int cp) CJK_UNIFIED_IDEOGRAPHS CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B HIGH_SURROGATES
Font boolean canDisplay(int cp) (取决于 Font 实例)
FontMetrics int charWidth(int cp) (取决于 FontMetrics 实例)
String int indexOf(int cp) (取决于 String 实例)
int lastIndexOf(int cp) (取决于 String 实例)
其他 API
本小节介绍前面的小节中没有讨论的代理对相关 API。表 3 展示所有这些剩余的 API。所有代理对 API 都包含在表 1、2 和 3 中。
表 3. 其他代理 API
类 方法/构造函数
Character static int codePointAt(char[] ach, int index, int limit)
static int codePointBefore(char[] ach, int index)
static int codePointBefore(char[] ach, int index, int start)
static int codePointBefore(CharSequence seq, int index)
static int digit(int cp, int radix)
static int offsetByCodePoints(char[] ach, int start, int count, int index, int cpOffset)
static int offsetByCodePoints(CharSequence seq, int index, int cpOffset)
static char[] toChars(int cp)
static int toChars(int cp, char[] dst, int dstIndex)
String String(int[] acp, int offset, int count)
int indexOf(int cp, int fromIndex)
int lastIndexOf(int cp, int fromIndex)
StringBuffer StringBuffer appendCodePoint(int cp)
int codePointAt(int index)
int codePointBefore(int index)
int codePointCount(int beginIndex, int endIndex)
int offsetByCodePoints(int index, int cpOffset)
StringBuilder StringBuilder appendCodePoint(int cp)
int codePointAt(int index)
int codePointBefore(int index)
int codePointCount(int beginIndex, int endIndex)
int offsetByCodePoints(int index, int cpOffset)
IllegalFormatCodePointException IllegalFormatCodePointException(int cp)
int getCodePoint()
清单 11 展示了从一个码位创建一个字符串的 5 种方法。用于测试的码位是 U+53F1 和 U+20B9F,它们在一个字符串中重复了 100 亿次。清单 11 中的注释部分显示了处理时间:
清单 11. 从一个码位创建一个字符串的 5 种方法
int cp = 0x20b9f; // CJK Ideograph Extension B
String str1 = new String(new int[]{cp}, 0, 1); // processing time: 206ms
String str2 = new String(Character.toChars(cp)); // 187ms
String str3 = String.valueOf(Character.toChars(cp)); // 195ms
String str4 = new StringBuilder().appendCodePoint(cp).toString(); // 269ms
String str5 = String.format("%c", cp); // 3781ms
str1、str2、str3 和 str4 的处理时间没有明显不同。相反,创建 str5 花费的时间要长得多,因为它使用 String.format(),该方法支持基于本地和格式化信息的灵活输出。str5 方法应该只用于程序的末尾来输出文本。
结束语
Unicode 的每个新版本都包含了通过代理对表示的新定义的字符。东亚字符集标准并不是这样的字符的惟一来源。例如,移动电话中还需要支持 Emoji 字符(表情图释),还有各种古字符需要支持。您从本文收获的技术和性能分析将有助于您在您的 Java 应用程序中支持所有这些字符。
关于 JAR 您不知道的 5 件事
把它放在 JAR 中
通常,在源代码被编译之后,您需要构建一个 JAR 文件,使用 jar 命令行实用工具,或者,更常用的是 Ant jar 任务将 Java 代码(已经被包分离)收集到一个单独的集合中,过程简洁易懂,我不想在这做过多的说明,稍后将继续说明如何构建 JAR。现在,我只需要存档 Hello,这是一个独立控制台实用工具,对于执行打印消息到控制台这个任务十分有用。如清单 1 所示:
清单 1. 存档控制台实用工具
package com.tedneward.jars;
public class Hello
{
public static void main(String[] args)
{
System.out.println("Howdy!");
}
}
Hello 实用工具内容并不多,但是对于研究 JAR 文件却是一个很有用的 “脚手架”,我们先从执行此代码开始。
JAR 是可执行的
.NET 和 C++ 这类语言一直是 OS 友好的,只需要在命令行(helloWorld.exe)引用其名称,或在 GUI shell 中双击它的图标就可以启动应用程序。然而在 Java 编程中,启动器程序 — java — 将 JVM 引导入进程中,我们需要传递一个命令行参数(com.tedneward.Hello)指定想要启动的 main() 方法的类。
这些附加步骤使使用 Java 创建界面友好的应用程序更加困难。不仅终端用户需要在命令行输入所有参数(终端用户宁愿避开),而且极有可能使他或她操作失误以及返回一个难以理解的错误。
这个解决方案使 JAR 文件 “可执行” ,以致 Java 启动程序在执行 JAR 文件时,自动识别哪个类将要启动。我们所要做的是,将一个入口引入 JAR 文件清单文件(MANIFEST.MF 在 JAR 的 META-INF 子目录下),像这样:
清单 2. 展示入口点!
Main-Class: com.tedneward.jars.Hello
这个清单文件只是一个名值对。因为有时候清单文件很难处理回车和空格,然而在构建 JAR 时,使用 Ant 来生成清单文件是很容易的。在清单 3 中,使用 Ant jar 任务的 manifest 元素来指定清单文件:
清单 3. 构建我的入口点!
<target name="jar" depends="build">
<jar destfile="outapp.jar" basedir="classes">
<manifest>
<attribute name="Main-Class" value="com.tedneward.jars.Hello" />
</manifest>
</jar>
</target>
现在用户在执行 JAR 文件时需要做的就是通过 java -jar outapp.jar 在命令行上指定其文件名。就 GUI shell 来说,双击 JAR 文件即可。
JAR 可以包括依赖关系信息
似乎 Hello 实用工具已经展开,改变实现的需求已经出现。Spring 或 Guice 这类依赖项注入(DI)容器可以为我们处理许多细节,但是仍然有点小问题:修改代码使其含有 DI 容器的用法可能导致清单 4 所示的结果,如:
清单 4. Hello、Spring world!
package com.tedneward.jars;
import org.springframework.context.*;
import org.springframework.context.support.*;
public class Hello
{
public static void main(String[] args)
{
ApplicationContext appContext =
new FileSystemXmlApplicationContext("./app.xml");
ISpeak speaker = (ISpeak) appContext.getBean("speaker");
System.out.println(speaker.sayHello());
}
}
关于 Spring 的更多信息
这个技巧将帮助您熟悉依赖项注入和 Spring 框架。如果您需要温习其他主题,见 参考资料。
由于启动程序的 -jar 选项将覆盖 -classpath 命令行选项中的所有内容,因此运行这些代码时,Spring 必须是在 CLASSPATH 和 环境变量中。幸运的是,JAR 允许在清单文件中出现其他的 JAR 依赖项声明,这使得无需声明就可以隐式创建 CLASSPATH,如清单 5 所示:
清单 5. Hello、Spring CLASSPATH!
<target name="jar" depends="build">
<jar destfile="outapp.jar" basedir="classes">
<manifest>
<attribute name="Main-Class" value="com.tedneward.jars.Hello" />
<attribute name="Class-Path"
value="./lib/org.springframework.context-3.0.1.RELEASE-A.jar
./lib/org.springframework.core-3.0.1.RELEASE-A.jar
./lib/org.springframework.asm-3.0.1.RELEASE-A.jar
./lib/org.springframework.beans-3.0.1.RELEASE-A.jar
./lib/org.springframework.expression-3.0.1.RELEASE-A.jar
./lib/commons-logging-1.0.4.jar" />
</manifest>
</jar>
</target>
注意 Class-Path 属性包含一个与应用程序所依赖的 JAR 文件相关的引用。您可以将它写成一个绝对引用或者完全没有前缀。这种情况下,我们假设 JAR 文件同应用程序 JAR 在同一个目录下。
不幸的是,value 属性和 Ant Class-Path 属性必须出现在同一行,因为 JAR 清单文件不能处理多个 Class-Path 属性。因此,所有这些依赖项在清单文件中必须出现在一行。当然,这很难看,但为了使 java -jar outapp.jar 可用,还是值得的!
JAR 可以被隐式引用
如果有几个不同的命令行实用工具(或其他的应用程序)在使用 Spring 框架,可能更容易将 Spring JAR 文件放在公共位置,使所有实用工具能够引用。这样就避免了文件系统中到处都有 JAR 副本。Java 运行时 JAR 的公共位置,众所周知是 “扩展目录” ,默认位于 lib/ext 子目录,在 JRE 的安装位置之下。
JRE 是一个可定制的位置,但是在一个给定的 Java 环境中很少定制,以至于可以完全假设 lib/ext 是存储 JAR 的一个安全地方,以及它们将隐式地用于 Java 环境的 CLASSPATH 上。
Java 6 允许类路径通配符
为了避免庞大的 CLASSPATH 环境变量(Java 开发人员几年前就应该抛弃的)和/或命令行 -classpath 参数,Java 6 引入了类路径通配符 的概念。与其不得不启动参数中明确列出的每个 JAR 文件,还不如自己指定 lib/*,让所有 JAR 文件列在该目录下(不递归),在类路径中。
不幸的是,类路径通配符不适用于之前提到的 Class-Path 属性清单入口。但是这使得它更容易启动 Java 应用程序(包括服务器)开发人员任务,例如 code-gen 工具或分析工具。
JAR 有的不只是代码
Spring,就像许多 Java 生态系统一样,依赖于一个描述构建环境的配置文件,前面提到过,Spring 依赖于一个 app.xml 文件,此文件同 JAR 文件位于同一目录 — 但是开发人员在复制 JAR 文件的同时忘记复制配置文件,这太常见了!
一些配置文件可用 sysadmin 进行编辑,但是其中很大一部分(例如 Hibernate 映射)都位于 sysadmin 域之外,这将导致部署漏洞。一个合理的解决方案是将配置文件和代码封装在一起 — 这是可行的,因为 JAR 从根本上来说就是一个 “乔装的” ZIP 文件。 当构建一个 JAR 时,只需要在 Ant 任务或 jar 命令行包括一个配置文件即可。
JAR 也可以包含其他类型的文件,不仅仅是配置文件。例如,如果我的 SpeakEnglish 部件要访问一个属性文件,我可以进行如下设置,如清单 6 所示:
清单 6. 随机响应
package com.tedneward.jars;
import java.util.*;
public class SpeakEnglish
implements ISpeak
{
Properties responses = new Properties();
Random random = new Random();
public String sayHello()
{
// Pick a response at random
int which = random.nextInt(5);
return responses.getProperty("response." + which);
}
}
可以将 responses.properties 放入 JAR 文件,这意味着部署 JAR 文件时至少可以少考虑一个文件。这只需要在 JAR 步骤中包含 responses.properties 文件即可。
当您在 JAR 中存储属性之后,您可能想知道如何将它取回。如果所需要的数据与 JAR 文件在同一位置,正如前面的例子中提到的那样,不需要费心找出 JAR 文件的位置,使用 JarFile 对象就可将其打开。相反,可以使用类的 ClassLoader 找到它,像在 JAR 文件中寻找 “资源” 那样,使用 ClassLoader getResourceAsStream() 方法,如清单 7 所示:
清单 7. ClassLoader 定位资源
package com.tedneward.jars;
import java.util.*;
public class SpeakEnglish
implements ISpeak
{
Properties responses = new Properties();
// ...
public SpeakEnglish()
{
try
{
ClassLoader myCL = SpeakEnglish.class.getClassLoader();
responses.load(
myCL.getResourceAsStream(
"com/tedneward/jars/responses.properties"));
}
catch (Exception x)
{
x.printStackTrace();
}
}
// ...
}
您可以按照以上步骤寻找任何类型的资源:配置文件、审计文件、图形文件,等等。几乎任何文件类型都能被捆绑进 JAR 中,作为一个 InputStream 获取(通过 ClassLoader),并通过您喜欢的方式使用。
关于 Java 性能监控您不知道的 5 件事,第 1 部分
许多开发人员没有意识到从 Java 5 开始 JDK 中包含了一个分析器。JConsole(或者 Java 平台新版本,VisualVM)是一个内置分析器,它同 Java 编译器一样容易启动。如果是从命令行启动,使 JDK 在 PATH 上,运行 jconsole 即可。如果从 GUI shell 启动,找到 JDK 安装路径,打开 bin 文件夹,双击 jconsole。
当分析工具弹出时(取决于正在运行的 Java 版本以及正在运行的 Java 程序数量),可能会出现一个对话框,要求输入一个进程的 URL 来连接,也可能列出许多不同的本地 Java 进程(有时包含 JConsole 进程本身)来连接。
JConsole 或 VisualVM?
JConsole 从 Java 5 开始就随着 Java 平台版本一起发布,而 VisualVM 是在 NetBeans 基础上升级的一个分析器,在 Java 6 的更新版 12 中第一次发布。多数商店还没有更新到 Java 6 ,因此这篇文章主要介绍 JConsole 。然而,多数技巧和这两个分析器都有关。(注意:除了包含在 Java 6 中之外,VisualVM 还有一个独立版下载。下载 VisualVM,参见 参考资料。)
使用 JConsole 进行工作
在 Java 5 中,Java 进程并不是被设置为默认分析的,而是通过一个命令行参数 — -Dcom.sun.management.jmxremote — 在启动时告诉 Java 5 VM 打开连接,以便分析器可以找到它们;当进程被 JConsole 捡起时,您只能双击它开始分析。
分析器有自己的开销,因此好的办法就是花点时间来弄清是什么开销。发现 JConsole 开销简单的办法是,首先独自运行一个应用程序,然后在分析器下运行,并测量差异。(应用程序不能太大或者太小;我喜欢使用 JDK 附带的 SwingSet2 样本。)因此,我使用 -verbose:gc 尝试运行 SwingSet2 来查看垃圾收集清理,然后运行同一个应用程序并将 JConsole 分析器连接到它。当 JConsole 连接好了之后,一个稳定的 GC 清理流出现,否则不会出现。这就是分析器的性能开销。
远程连接进程
因为 Web 应用程序分析工具假设通过一个套接字进行连通性分析,您只需要进行少许配置来设置 JConsole(或者是基于 JVMTI 的分析器,就这点而言),监控/分析远程运行的应用程序。
如果 Tomcat 运行在一个名为 “webserve” 的机器上,且 JVM 已经启动了 JMX 并监听端口 9004,从 JConsole(或者任何 JMX 客户端)连接它需要一个 JMX URL “service:jmx:rmi:///jndi/rmi://webserver:9004/jmxrmi”。
基本上,要分析一个运行在远程数据中心的应用程序服务器,您所需要的仅仅是一个 JMX URL。更多关于使用 JMX 和 JConsole 远程监控和管理的信息,参见 参考资料。)
跟踪统计
发现应用程序代码中性能问题的常用响应多种多样,但也是可预测的。早期的 Java 编程人员对旧的 IDE 可能十分生气,并开始进行代码库中主要部分的代码复查,在源代码中寻找熟悉的 “红色标志”,像异步块、对象配额等等。随着编程经验的增加,开发人员可能会仔细研究 JVM 支持的 -X 标志,寻找优化垃圾收集器的方法。当然,对于新手,直接去 Google 查询,希望有其他人发现了 JVM 的神奇的 “make it go fast” 转换,避免重写代码。
从本质上来说,这些方法没什么错,但都是有风险的。对于一个性能问题有效的响应就是使用一个分析器 — 现在它们内置在 Java 平台 ,我们确实没有理由不这样做!
JConsole 有许多对收集统计数据有用的选项卡,包括:
Memory:在 JVM 垃圾收集器中针对各个堆跟踪活动。
Threads:在目标 JVM 中检查当前线程活动。
Classes:观察 VM 已加载类的总数。
这些选项卡(和相关的图表)都是由每个 Java 5 及更高版本 VM 在 JMX 服务器上注册的 JMX 对象提供的,是内置到 JVM 的。一个给定 JVM 中可用 bean 的完整清单在 MBeans 选项卡上列出,包括一些元数据和一个有限的用户界面来查看数据或执行操作。(然而,注册通知是在 JConsole 用户界面之外。)
使用统计数据
假设一个 Tomcat 进程死于 OutOfMemoryError。如果您想要弄清楚发生了什么,打开 JConsole,单击 Classes 选项卡,过一段时间查看一次类计数。如果数量稳定上升,您可以假设应用程序服务器或者您的代码某个地方有一个 ClassLoader 漏洞,不久之后将耗尽 PermGen 空间。如果需要更进一步的确认问题,请看 Memory 选项卡。
为离线分析创建一个堆转储
生产环境中一切都在快速地进行着,您可能没有时间花费在您的应用程序分析器上,相反地,您可以为 Java 环境中的每个事件照一个快照保存下来过后再看。在 JConsole 中您也可以这样做,在 VisualVM 中甚至会做得更好。
先找到 MBeans 选项卡,在其中打开 com.sun.management 节点,接着是 HotSpotDiagnostic 节点。现在,选择 Operations,注意右边面板中的 “dumpHeap” 按钮。如果您在第一个(“字符串”)输入框中向 dumpHeap 传递一个文件名来转储,它将为整个 JVM 堆照一个快照,并将其转储到那个文件。
稍后,您可以使用各种不同的商业分析器来分析文件,或者使用 VisualVM 分析快照。(记住,VisualVM 是在 Java 6 中可用的,且是单独下载的。)
作为一个分析器实用工具,JConsole 是极好的,但是还有更好的工具。一些分析插件附带分析器或者灵巧的用户界面,默认情况下比 JConsole 跟踪更多的数据。
JConsole 真正吸引人的是整个程序是用 “普通旧式 Java ” 编写的,这意味着任何 Java 开发人员都可以编写这样一个实用工具。事实上,JDK 其中甚至包括如何通过创建一个插件来定制 JConsole 的示例(参见 参考资料)。建立在 NetBeans 顶部的 VisualVM 进一步延伸了插件概念。
如果 JConsole(或者 VisualVM,或者其他任何工具)不符合您的需求,或者不能跟踪您想要跟踪的,或者不能按照您的方式跟踪,您可以编写属于自己的工具。如果您觉得 Java 代码很麻烦,Groovy 或 JRuby 或很多其他 JVM 语言都可以帮助您更快完成。
您真正需要的是一个快速而粗糙(quick-and-dirty)的由 JVM 连接的命令行工具,可以以您想要的方式确切地跟踪您感兴趣的数据。
关于 Java 性能监控您不知道的 5 件事,第 2 部分
全功能内置分析器,如 JConsole 和 VisualVM 的成本有时比它们的性能费用还要高 — 尤其是在生产软件上运行的系统中。因此,在聚焦 Java 性能监控的第 2 篇文章中,我将介绍 5 个命令行分析工具,使开发人员仅关注运行的 Java 进程的一个方面。
JDK 包括很多命令行实用程序,可以用于监控和管理 Java 应用程序性能。虽然大多数这类应用程序都被标注为 “实验型”,在技术上不受支持,但是它们很有用。有些甚至是特定用途工具的种子材料,可以使用 JVMTI 或 JDI(参见 参考资料)建立。
jps (sun.tools.jps)
很多命令行工具都要求您识别您希望监控的 Java 进程。这与监控本地操作系统进程、同样需要一个程序识别器的同类工具没有太大区别。
“VMID” 识别器与本地操作系统进程识别器(“pid”)并不总是相同的,这就是我们需要 JDK jps 实用程序的原因。
在 Java 进程中使用 jps
与配置 JDK 的大部分工具及本文中提及的所有工具一样,可执行 jps 通常是一个围绕 Java 类或执行大多数工作的类集的一个薄包装。在 Windows® 环境下,这些工具是 .exe 文件,使用 JNI Invocation API 直接调用上面提及的类;在 UNIX® 环境下,大多数工具是一个 shell 脚本的符号链接,该脚本采用指定的正确类名称开始一个普通启动程序。
如果您希望在 Java 进程中使用 jps(或者任何其他工具)的功能 — Ant 脚本 — 仅在每个工具的 “主” 类上调用 main() 相对容易。为了简化引用,类名称出现在每个工具名称之后的括号内。
jps — 名称反映了在大多数 UNIX 系统上发现的 ps 实用程序 — 告诉我们运行 Java 应用程序的 JVMID。顾名思义,jps 返回指定机器上运行的所有已发现的 Java 进程的 VMID。如果 jps 没有发现进程,并不意味着无法附加或研究 Java 进程,而只是意味着它并未宣传自己的可用性。
如果发现 Java 进程,jps 将列出启用它的命令行。这种区分 Java 进程的方法非常重要,因为只要涉及操作系统,所有的 Java 进程都被统称为 “java”。在大多数情况下,VMID 是值得注意的重要数字。
使用分析器开始
使用分析实用程序开始的简单方法是使用一个如在 demo/jfc/SwingSet2 中发现的 SwingSet2 演示一样的演示程序。这样就可以避免程序作为背景/监控程序运行时出现挂起的可能性。当您了解工具及其费用后,就可以在实际程序中进行试用。
加载演示应用程序后,运行 jps 并注意返回的 vmid。为了获得更好的效果,采用 -Dcom.sun.management.jmxremote 属性集启动 Java 进程。如果没有使用该设置,部分下列工具收集的部分数据可能不可用。
jstat (sun.tools.jstat)
jstat 实用程序可以用于收集各种各样不同的统计数据。jstat 统计数据被分类到 “选项” 中,这些选项在命令行中被指定作为第一参数。对于 JDK 1.6 来说,您可以通过采用命令 -options 运行 jstat 查看可用的选项清单。清单 1 中显示了部分选项:
清单 1. jstat 选项
-class
-compiler
-gc
-gccapacity
-gccause
-gcnew
-gcnewcapacity
-gcold
-gcoldcapacity
-gcpermcapacity
-gcutil
-printcompilation
实用程序的 JDK 记录(参见 参考资料)将告诉您清单 1 中每个选项返回的内容,但是其中大多数用于收集垃圾的收集器或者其部件的性能信息。-class 选项显示了加载及未加载的类(使其成为检测应用程序服务器或代码中 ClassLoader 泄露的重要实用程序,且 -compiler 和 -printcompilation 都显示了有关 Hotspot JIT 编译程序的信息。
默认情况下,jstat 在您核对信息时显示信息。如果您希望每隔一定时间拍摄快照,请在 -options 指令后以毫秒为单位指定间隔时间。jstat 将持续显示监控进程信息的快照。如果您希望 jstat 在终止前进行特定数量的快照,在间隔时间/时间值后指定该数字。
如果 5756 是几分钟前开始的运行 SwingSet2 程序的 VMID,那么下列命令将告诉 jstat 每 250 毫秒为 10 个佚代执行一次 gc 快照转储,然后停止:
jstat -gc 5756 250 10
请注意 Sun(现在的 Oracle)保留了在不进行任何预先通知的情况下更改各种选项的输出甚至是选项本身的权利。这是使用不受支持实用程序的缺点。请参看 Javadocs 了解 jstat 输出中每一列的全部细节。
jstack (sun.tools.jstack)
了解 Java 进程及其对应的执行线程内部发生的情况是一种常见的诊断挑战。例如,当一个应用程序突然停止进程时,很明显出现了资源耗尽,但是仅通过查看代码无法明确知道何处出现资源耗尽,且为什么会发生。
jstack 是一个可以返回在应用程序上运行的各种各样线程的一个完整转储的实用程序,您可以使用它查明问题。
采用期望进程的 VMID 运行 jstack 会产生一个堆转储。就这一点而言,jstack 与在控制台窗口内按 Ctrl-Break 键起同样的作用,在控制台窗口中,Java 进程正在运行或调用 VM 内每个 Thread 对象上的 Thread.getAllStackTraces() 或 Thread.dumpStack()。jstack 调用也转储关于在 VM 内运行的非 Java 线程的信息,这些线程作为 Thread 对象并不总是可用的。
jstack 的 -l 参数提供了一个较长的转储,包括关于每个 Java 线程持有锁的更多详细信息,因此发现(和 squash)死锁或可伸缩性 bug 是极其重要的。
jmap (sun.tools.jmap)
有时,您正在处理的问题是一个对象泄露,如一个 ArrayList (可能持有成千上万个对象)该释放时没有释放。另一个更普遍的问题是,看似从不会压缩的扩展堆,却有活跃的垃圾收集。
当您努力寻找一个对象泄露时,在指定时刻对堆及时进行拍照,然后审查其中内容非常有用。jmap 通过对堆拍摄快照来提供该功能的第一部分。然后您可以采用下一部分中描述的 jhat 实用程序分析堆数据。
与这里描述的其他所有实用程序一样,使用 jmap 非常简单。将 jmap 指向您希望拍快照的 Java 进程的 VMID,然后给予它部分参数,用来描述产生的结果文件。您要传递给 jmap 的选项包括转储文件的名称以及是否使用一个文本文件或二进制文件。二进制文件是有用的选项,但是只有当与某一种索引工具 结合使用时 — 通过十六进制值的文本手动操作数百兆字节不是好的方法。
随意看一下 Java 堆的更多信息,jmap 同样支持 -histo 选项。-histo 产生一个对象文本柱状图,现在在堆中大量引用,由特定类型消耗的字节总数分类。它同样给出了特定类型的总示例数量,支持部分原始计算,并猜测每个实例的相对成本。
不幸的是,jmap 没有像 jstat 一样的 period-and-max-count 选项,但是将 jmap(或 jmap.main())调用放入 shell 脚本或其他类的循环,周期性地拍摄快照相对简单。(事实上,这是加入 jmap 的一个好的扩展,不管是作为 OpenJDK 本身的源补丁,还是作为其他实用程序的扩展。)
jhat (com.sun.tools.hat.Main)
将堆转储至一个二进制文件后,您就可以使用 jhat 分析二进制堆转储文件。jhat 创建一个 HTTP/HTML 服务器,该服务器可以在浏览器中被浏览,提供一个关于堆的 object-by-object 视图,及时冻结。根据对象引用草率处理堆可能会非常可笑,您可以通过对总体混乱进行某种自动分析而获得更好的服务。幸运的是,jhat 支持 OQL 语法进行这样的分析。
例如,对所有含有超过 100 个字符的 String 运行 OQL 查询看起来如下:
select s from java.lang.String s where s.count >= 100
结果作为对象链接显示,然后展示该对象的完整内容,字段引用作为可以解除引用的其他链接的其他对象。OQL 查询同样可以调用对象的方法,将正则表达式作为查询的一部分,并使用内置查询工具。一种查询工具,referrers() 函数,显示了引用指定类型对象的所有引用。下面是寻找所有参考 File 对象的查询:
select referrers(f) from java.io.File f
您可以查找 OQL 的完整语法及其在 jhat 浏览器环境内 “OQL Help” 页面上的特性。将 jhat 与 OQL 相结合是对行为不当的堆进行对象调查的有效方法。
关于 Java Scripting API 您不知道的 5 件事
现在,许多 Java 开发人员都喜欢在 Java 平台中使用脚本语言,但是使用编译到 Java 字节码中的动态语言有时是不可行的。在某些情况中,直接编写一个 Java 应用程序的脚本 部分 或者在一个脚本中调用特定的 Java 对象是更快捷、更高效的方法。
这就是 javax.script 产生的原因了。Java Scripting API 是从 Java 6 开始引入的,它填补了便捷的小脚本语言和健壮的 Java 生态系统之间的鸿沟。通过使用 Java Scripting API,您就可以在您的 Java 代码中快速整合几乎所有的脚本语言,这使您能够在解决一些很小的问题时有更多可选择的方法。
使用 jrunscript 执行 JavaScript
每一个新的 Java 平台发布都会带来新的命令行工具集,它们位于 JDK 的 bin 目录。Java 6 也一样,其中 jrunscript 便是 Java 平台工具集中的一个不小的补充。
设想一个编写命令行脚本进行性能监控的简单问题。这个工具将借用 jmap(见本系列文章 前一篇文章 中的介绍),每 5 秒钟运行一个 Java 进程,从而了解进程的运行状况。一般情况下,我们会使用命令行 shell 脚本来完成这样的工作,但是这里的服务器应用程序部署在一些差别很大的平台上,包括 Windows® 和 Linux®。系统管理员将会发现编写能够同时运行在两个平台的 shell 脚本是很痛苦的。通常的做法是编写一个 Windows 批处理文件和一个 UNIX® shell 脚本,同时保证这两个文件同步更新。
但是,任何阅读过 The Pragmatic Programmer 的人都知道,这严重违反了 DRY (Don't Repeat Yourself) 原则,而且会产生许多缺陷和问题。我们真正希望的是编写一种与操作系统无关的脚本,它能够在所有的平台上运行。
当然,Java 语言是平台无关的,但是这里并不是需要使用 “系统” 语言的情况。我们需要的是一种脚本语言 — 如,JavaScript。
清单 1 显示的是我们所需要的简单 shell 脚本:
清单 1. periodic.js
while (true)
{
echo("Hello, world!");
}
由于经常与 Web 浏览器打交道,许多 Java 开发人员已经知道了 JavaScript(或 ECMAScript;JavaScript 是由 Netscape 开发的一种 ECMAScript 语言)。问题是,系统管理员要如何运行这个脚本?
当然,解决方法是 JDK 所带的 jrunscript 实用程序,如清单 2 所示:
清单 2. jrunscript
C:\developerWorks\5things-scripting\code\jssrc>jrunscript periodic.js
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!
...
注意,您也可以使用 for 循环按照指定的次数来循环执行这个脚本,然后才退出。基本上,jrunscript 能够让您执行 JavaScript 的所有操作。惟一不同的是它的运行环境不是浏览器,所以运行中不会有 DOM。因此,顶层的函数和对象稍微有些不同。
因为 Java 6 将 Rhino ECMAScript 引擎作为 JDK 的一部分,jrunscript 可以执行任何传递给它的 ECMAScript 代码,不管是一个文件(如此处所示)或是在更加交互式的 REPL(“Read-Evaluate-Print-Loop”)shell 环境。运行 jrunscript 就可以访问 REPL shell。
从脚本访问 Java 对象
能够编写 JavaScript/ECMAScript 代码是非常好的,但是我们不希望被迫重新编译我们在 Java 语言中使用的所有代码 — 这是违背我们初衷的。幸好,所有使用 Java Scripting API 引擎的代码都完全能够访问整个 Java 生态系统,因为本质上一切代码都还是 Java 字节码。所以,回到我们之前的问题,我们可以在 Java 平台上使用传统的 Runtime.exec() 调用来启动进程,如清单 3 所示:
清单 3. Runtime.exec() 启动 jmap
var p = java.lang.Runtime.getRuntime().exec("jmap", [ "-histo", arguments[0] ])
p.waitFor()
数组 arguments 是指向传递到这个函数参数的 ECMAScript 标准内置引用。在顶层的脚本环境中,则是传递给脚本本身的的参数数组(命令行参数)。所以,在清单 3 中,这个脚本预期接收一个参数,该参数包含要映射的 Java 进程的 VMID。
除此之外,我们可以利用本身为一个 Java 类的 jmap,然后直接调用它的 main() 方法,如清单 4 所示。有了这个方法,我们不需要 “传输” Process 对象的 in/out/err 流。
清单 4. JMap.main()
var args = [ "-histo", arguments[0] ]
Packages.sun.tools.jmap.JMap.main(args)
Packages 语法是一个 Rhino ECMAScript 标识,它指向已经 Rhino 内创建的位于核心 java.* 包之外的 Java 包。
从 Java 代码调用脚本
从脚本调用 Java 对象仅仅完成了一半的工作:Java 脚本环境也提供了从 Java 代码调用脚本的功能。这只需要实例化一个 ScriptEngine 对象,然后加载和评估脚本,如清单 5 所示:
清单 5. Java 平台的脚本调用
import java.io.*;
import javax.script.*;
public class App
{
public static void main(String[] args)
{
try
{
ScriptEngine engine =
new ScriptEngineManager().getEngineByName("javascript");
for (String arg : args)
{
FileReader fr = new FileReader(arg);
engine.eval(fr);
}
}
catch(IOException ioEx)
{
ioEx.printStackTrace();
}
catch(ScriptException scrEx)
{
scrEx.printStackTrace();
}
}
}
eval() 方法也可以直接操作一个 String,所以这个脚本不一定必须是文件系统的一个文件 — 它可以来自于数据库、用户输入,或者甚至可以基于环境和用户操作在应用程序中生成。
将 Java 对象绑定到脚本空间
仅仅调用一个脚本还不够:脚本通常会与 Java 环境中创建的对象进行交互。这时,Java 主机环境必须创建一些对象并将它们绑定,这样脚本就可以很容易找到和使用这些对象。这个过程是 ScriptContext 对象的任务,如清单 6 所示:
清单 6. 为脚本绑定对象
import java.io.*;
import javax.script.*;
public class App
{
public static void main(String[] args)
{
try
{
ScriptEngine engine =
new ScriptEngineManager().getEngineByName("javascript");
for (String arg : args)
{
Bindings bindings = new SimpleBindings();
bindings.put("author", new Person("Ted", "Neward", 39));
bindings.put("title", "5 Things You Didn't Know");
FileReader fr = new FileReader(arg);
engine.eval(fr, bindings);
}
}
catch(IOException ioEx)
{
ioEx.printStackTrace();
}
catch(ScriptException scrEx)
{
scrEx.printStackTrace();
}
}
}
访问所绑定的对象很简单 — 所绑定对象的名称是作为全局命名空间引入到脚本的,所以在 Rhino 中使用 Person 很简单,如清单 7 所示:
清单 7. 是谁撰写了本文?
println("Hello from inside scripting!")
println("author.firstName = " + author.firstName)
您可以看到,JavaBeans 样式的属性被简化为使用名称直接访问,这就好像它们是字段一样。
编译频繁使用的脚本
脚本语言的缺点一直存在于性能方面。其中的原因是,大多数情况下脚本语言是 “即时” 解译的,因而它在执行时会损失一些解析和验证文本的时间和 CPU 周期。运行在 JVM 的许多脚本语言终会将接收的代码转换为 Java 字节码,至少在脚本被第一次解析和验证时进行转换;在 Java 程序关闭时,这些即时编译的代码会消失。将频繁使用的脚本保持为字节码形式可以帮助提升可观的性能。
我们可以以一种很自然和有意义的方法使用 Java Scripting API。如果返回的 ScriptEngine 实现了 Compilable 接口,那么这个接口所编译的方法可用于将脚本(以一个 String 或一个 Reader 传递过来的)编译为一个 CompiledScript 实例,然后它可用于在 eval() 方法中使用不同的绑定重复地处理编译后的代码,如清单 8 所示:
清单 8. 编译解译后的代码
import java.io.*;
import javax.script.*;
public class App
{
public static void main(String[] args)
{
try
{
ScriptEngine engine =
new ScriptEngineManager().getEngineByName("javascript");
for (String arg : args)
{
Bindings bindings = new SimpleBindings();
bindings.put("author", new Person("Ted", "Neward", 39));
bindings.put("title", "5 Things You Didn't Know");
FileReader fr = new FileReader(arg);
if (engine instanceof Compilable)
{
System.out.println("Compiling....");
Compilable compEngine = (Compilable)engine;
CompiledScript cs = compEngine.compile(fr);
cs.eval(bindings);
}
else
engine.eval(fr, bindings);
}
}
catch(IOException ioEx)
{
ioEx.printStackTrace();
}
catch(ScriptException scrEx)
{
scrEx.printStackTrace();
}
}
}
在大多数情况中,CompiledScript 实例需要存储在一个长时间存储中(例如,servlet-context),这样才能避免一次次地重复编译相同的脚本。然而,如果脚本发生变化,您就需要创建一个新的 CompiledScript 来反映这个变化;一旦编译完成,CompiledScript 就不再执行原始的脚本文件内容。
Java 异常处理及其应用
Java 异常处理引出
假设您要编写一个 Java 程序,该程序读入用户输入的一行文本,并在终端显示该文本。
程序如下:
1 import java.io.*;
2 public class EchoInput {
3 public static void main(String args[]){
4 System.out.println("Enter text to echo:");
5 InputStreamReader isr = new InputStreamReader(System.in);
6 BufferedReader inputReader = new BufferedReader(isr);
7 String inputLine = inputReader.readLine();
8 System.out.println("Read:" + inputLine);
9 }
10 }
分析上面的代码,在 EchoInput 类中,第 3 行声明了 main 方法;第 4 行提示用户输入文本;第 5、6 行设置 BufferedReader 对像连接到 InputStreamReader,而 InputStreamReader 又连接到标准输入流 System.in;第 7 行读入一行文本;第 8 行用标准输出流 System.out 显示出该文本。
表面看来上面的程序没有问题,但实际上,EchoInput 类完全可能出现问题。要在调用第 7 行的 readLine 方法时正确读取输入,这几种假设都必须成立:假定键盘有效,键盘能与计算机正常通信;假定键盘数据可从操作系统传输到 Java 虚拟机,又从 Java 虚拟机传输 inputReader。
大多数情况下上述假设都成立,但不尽然。为此,Java 采用异常方法,以应对可能出现的错误,并采取步骤进行更正。在本例中,若试图编译以上代码,将看到以下信息:
Exception in thread "main" java.lang.Error: Unresolved compilation problem:
Unhandled exception type IOException
at EchoInput.main(EchoInput.java:7)
从中可以看到,第 7 行调用 readLine 方法可能出错:若果真如此,则产生 IOException 来记录故障。编译器错误是在告诉您,需要更改代码来解决这个潜在的问题。在 JDK API 文档中,可以看到同样的信息。我们可以看到 readLine 方法,如图 1 所示。
图 1. BufferedReader 类的 readLine 方法的 JDK API 文档
由图 1 可知,readLine 方法有时产生 IOException。如何处理潜在的故障?编译器需要“捕获”或“声明”IOException。
“捕获 (catch)”指当 readLine 方法产生错误时截获该错误,并处理和记录该问题。而“声明 (declare)”指错误可能引发 IOException,并通知调用该方法的任何代码:可能产生异常。
若要捕获异常,必须添加一个特殊的“处理代码块”,来接收和处理 IOException。于是程序改为如下:
1 import java.io.*;
2 public class EchoInputHandle {
3 public static void main(String args[]){
4 System.out.println("Enter text to echo:");
5 InputStreamReader isr = new InputStreamReader(System.in);
6 BufferedReader inputReader = new BufferedReader(isr);
7 try{
8 String inputLine = inputReader.readLine();
9 System.out.println("Read:" + inputLine);
10 }
11 catch(IOException exc){
12 System.out.println(“Exception encountered: ” + exc);
13 }
14 }
15 }
新添的代码块包含关键字 try 和 catch(第 7,10,11,13 行),表示要读取输入。若成功,则正常运行。若读取输入时错误,则捕获问题(由 IOException 对象表示),并采取相应措施。在本例,采用的处理方式是输出异常。
若不准备捕获 IOException,仅声明异常,则要特别指定 main 方法可能出错,而且特别说明可能产生 IOException。于是程序改为如下:
1 import java.io.*;
2 public class EchoInputDeclare {
3 public static void main(String args[]) throws IOException{
4 System.out.println("Enter text to echo:");
5 InputStreamReader isr = new InputStreamReader(System.in);
6 BufferedReader inputReader = new BufferedReader(isr);
7 String inputLine = inputReader.readLine();
8 System.out.println("Read:" + inputLine);
9 }
10 }
从上面的这个简单的例子中,我们可以看出异常处理在 Java 代码开发中不能被忽视。
Java 异常以及异常处理
可将 Java 异常看作是一类消息,它传送一些系统问题、故障及未按规定执行的动作的相关信息。异常包含信息,以将信息从应用程序的一部分发送到另一部分。
编译语言为何要处理异常?为何不在异常出现位置随时处理具体故障?因为有时候我们需要在系统中交流错误消息,以便按照统一的方式处理问题,有时是因为有若干处理问题的可能方式,但您不知道使用哪一种,此时,可将处理异常的任务委托给调用方法的代码。调用者通常更能了解问题来源的上下文,能更好的确定恢复方式。
图 2 是一个通用消息架构。
图 2. 通用消息架构
从上图可以看出,必定在运行的 Java 应用程序的一些类或对象中产生异常。出现故障时,“发送者”将产生异常对象。异常可能代表 Java 代码出现的问题,也可能是 JVM 的相应错误,或基础硬件或操作系统的错误。
异常本身表示消息,指发送者传给接收者的数据“负荷”。首先,异常基于类的类型来传输有用信息。很多情况下,基于异常的类既能识别故障本因并能更正问题。其次,异常还带有可能有用的数据(如属性)。
在处理异常时,消息必须有接收者;否则将无法处理产生异常的底层问题。
在上例中,异常“产生者”是读取文本行的 BufferedReader。在故障出现时,将在 readLine 方法中构建 IOException 对象。异常“接收者”是代码本身。EchoInputHandle 应用程序的 try-catch 结构中的 catch 块是异常的接收者,它以字符串形式输出异常,将问题记录下来。
Java 异常类的层次结构
在我们从总体上了解异常后,我们应该了解如何在 Java 应用程序中使用异常,即需要了解 Java 类的层次结构。图 3 是 Java 类的层次结构图。
图 3. Java 类的层次结构
在 Java 中,所有的异常都有一个共同的祖先 Throwable(可抛出)。Throwable 指定代码中可用异常传播机制通过 Java 应用程序传输的任何问题的共性。
Throwable 有两个重要的子类:Exception(异常)和 Error(错误),二者都是 Java 异常处理的重要子类,各自都包含大量子类。
Exception(异常)是应用程序中可能的可预测、可恢复问题。一般大多数异常表示中度到轻度的问题。异常一般是在特定环境下产生的,通常出现在代码的特定方法和操作中。在 EchoInput 类中,当试图调用 readLine 方法时,可能出现 IOException 异常。
Error(错误)表示运行应用程序中较严重问题。大多数错误与代码编写者执行的操作无关,而表示代码运行时 JVM(Java 虚拟机)出现的问题。例如,当 JVM 不再有继续执行操作所需的内存资源时,将出现 OutOfMemoryError。
Exception 类有一个重要的子类 RuntimeException。RuntimeException 类及其子类表示“JVM 常用操作”引发的错误。例如,若试图使用空值对象引用、除数为零或数组越界,则分别引发运行时异常(NullPointerException、ArithmeticException)和 ArrayIndexOutOfBoundException。
Java 异常的处理
在 Java 应用程序中,对异常的处理有两种方式:处理异常和声明异常。
处理异常:try、catch 和 finally
若要捕获异常,则必须在代码中添加异常处理器块。这种 Java 结构可能包含 3 个部分,
都有 Java 关键字。下面的例子中使用了 try-catch-finally 代码结构。
1 import java.io.*;
2 public class EchoInputTryCatchFinally {
3 public static void main(String args[]){
4 System.out.println("Enter text to echo:");
5 InputStreamReader isr = new InputStreamReader(System.in);
6 BufferedReader inputReader = new BufferedReader(isr);
7 try{
8 String inputLine = inputReader.readLine();
9 System.out.println("Read:" + inputLine);
10 }
11 catch(IOException exc){
12 System.out.println("Exception encountered: " + exc);
13 }
14 finally{
15 System.out.println("End. ");
16 }
17 }
18}
其中:
try 块:将一个或者多个语句放入 try 时,则表示这些语句可能抛出异常。编译器知道可能要发生异常,于是用一个特殊结构评估块内所有语句。
catch 块:当问题出现时,一种选择是定义代码块来处理问题,catch 块的目的便在于此。catch 块是 try 块所产生异常的接收者。基本原理是:一旦生成异常,则 try 块的执行中止,JVM 将查找相应的 JVM。
finally 块:还可以定义 finally 块,无论运行 try 块代码的结果如何,该块里面的代码一定运行。在常见的所有环境中,finally 块都将运行。无论 try 块是否运行完,无论是否产生异常,也无论是否在 catch 块中得到处理,finally 块都将执行。
try-catch-finally 规则:
必须在 try 之后添加 catch 或 finally 块。try 块后可同时接 catch 和 finally 块,但至少有一个块。
必须遵循块顺序:若代码同时使用 catch 和 finally 块,则必须将 catch 块放在 try 块之后。
catch 块与相应的异常类的类型相关。
一个 try 块可能有多个 catch 块。若如此,则执行第一个匹配块。
可嵌套 try-catch-finally 结构。
在 try-catch-finally 结构中,可重新抛出异常。
除了下列情况,总将执行 finally 做为结束:JVM 过早终止(调用 System.exit(int));在 finally 块中抛出一个未处理的异常;计算机断电、失火、或遭遇病毒攻击。
声明异常
若要声明异常,则必须将其添加到方法签名块的结束位置。下面是一个实例:
public void errorProneMethod(int input) throws java.io.IOException {
//Code for the method,including one or more method
//calls that may produce an IOException
}
这样,声明的异常将传给方法调用者,而且也通知了编译器:该方法的任何调用者必须遵守处理或声明规则。声明异常的规则如下:
必须声明方法可抛出的任何可检测异常(checked exception)。
非检测性异常(unchecked exception)不是必须的,可声明,也可不声明。
调用方法必须遵循任何可检测异常的处理和声明规则。若覆盖一个方法,则不能声明与覆盖方法不同的异常。声明的任何异常必须是被覆盖方法所声明异常的同类或子类。
Java 异常处理的分类
Java 异常可分为可检测异常,非检测异常和自定义异常。
可检测异常
可检测异常经编译器验证,对于声明抛出异常的任何方法,编译器将强制执行处理或声明规则,例如:sqlExecption 这个异常就是一个检测异常。你连接 JDBC 时,不捕捉这个异常,编译器就通不过,不允许编译。
非检测异常
非检测异常不遵循处理或声明规则。在产生此类异常时,不一定非要采取任何适当操作,编译器不会检查是否已解决了这样一个异常。例如:一个数组为 3 个长度,当你使用下标为3时,就会产生数组下标越界异常。这个异常 JVM 不会进行检测,要靠程序员来判断。有两个主要类定义非检测异常:RuntimeException 和 Error。
Error 子类属于非检测异常,因为无法预知它们的产生时间。若 Java 应用程序内存不足,则随时可能出现 OutOfMemoryError;起因一般不是应用程序的特殊调用,而是 JVM 自身的问题。另外,Error 一般表示应用程序无法解决的严重问题。
RuntimeException 类也属于非检测异常,因为普通 JVM 操作引发的运行时异常随时可能发生,此类异常一般是由特定操作引发。但这些操作在 Java 应用程序中会频繁出现。因此,它们不受编译器检查与处理或声明规则的限制。
自定义异常
自定义异常是为了表示应用程序的一些错误类型,为代码可能发生的一个或多个问题提供新含义。可以显示代码多个位置之间的错误的相似性,也可以区分代码运行时可能出现的相似问题的一个或者多个错误,或给出应用程序中一组错误的特定含义。例如,对队列进行操作时,有可能出现两种情况:空队列时试图删除一个元素;满队列时试图添加一个元素。则需要自定义两个异常来处理这两种情况。
Java 异常处理的原则和忌讳
Java 异常处理的原则
尽可能的处理异常
要尽可能的处理异常,如果条件确实不允许,无法在自己的代码中完成处理,就考虑声明异常。如果人为避免在代码中处理异常,仅作声明,则是一种错误和依赖的实践。
具体问题具体解决
异常的部分优点在于能为不同类型的问题提供不同的处理操作。有效异常处理的关键是识别特定故障场景,并开发解决此场景的特定相应行为。为了充分利用异常处理能力,需要为特定类型的问题构建特定的处理器块。
记录可能影响应用程序运行的异常
至少要采取一些永久的方式,记录下可能影响应用程序操作的异常。理想情况下,当然是在第一时间解决引发异常的基本问题。不过,无论采用哪种处理操作,一般总应记录下潜在的关键问题。别看这个操作很简单,但它可以帮助您用很少的时间来跟踪应用程序中复杂问题的起因。
根据情形将异常转化为业务上下文
若要通知一个应用程序特有的问题,有必要将应用程序转换为不同形式。若用业务特定状态表示异常,则代码更易维护。从某种意义上讲,无论何时将异常传到不同上下文(即另一技术层),都应将异常转换为对新上下文有意义的形式。
Java 异常处理的忌讳
一般不要忽略异常
在异常处理块中,一项危险的举动是“不加通告”地处理异常。如下例所示:
1 try{
2 Class.forName("business.domain.Customer");
3 }
4 catch (ClassNotFoundException exc){}
经常能够在代码块中看到类似的代码块。有人总喜欢在编写代码时简单快速地编写空处理器块,并“自我安慰地”宣称准备在“后期”添加恢复代码,但这个“后期”变成了“无期”。
这种做法有什么坏处?如果异常对应用程序的其他部分确实没有任何负面影响,这未尝不可。但事实往往并非如此,异常会扰乱应用程序的状态。此时,这样的代码无异于掩耳盗铃。
这种做法若影响较轻,则应用程序可能出现怪异行为。例如,应用程序设置的一个值不见了, 或 GUI 失效。若问题严重,则应用程序可能会出现重大问题,因为异常未记录原始故障点,难以处理,如重复的 NullPointerExceptions。
如果采取措施,记录了捕获的异常,则不可能遇到这个问题。实际上,除非确认异常对代码其余部分绝无影响,至少也要作记录。进一步讲,永远不要忽略问题;否则,风险很大,在后期会引发难以预料的后果。
不要使用覆盖式异常处理块
另一个危险的处理是覆盖式处理器(blanket handler)。该代码的基本结构如下:
1 try{
2 // …
3 }
4 catch(Exception e){
5 // …
6 }
使用覆盖式异常处理块有两个前提之一:
代码中只有一类问题。
这可能正确,但即便如此,也不应使用覆盖式异常处理,捕获更具体的异常形式有利物弊。
单个恢复操作始终适用。
这几乎绝对错误。几乎没有哪个方法能放之四海而皆准,能应对出现的任何问题。
分析下这样编写代码将发生的情况。只要方法不断抛出预期的异常集,则一切正常。但是,如果抛出了未预料到的异常,则无法看到要采取的操作。当覆盖式处理器对新异常类执行千篇一律的任务时,只能间接看到异常的处理结果。如果代码没有打印或记录语句,则根本看不到结果。
更糟糕的是,当代码发生变化时,覆盖式处理器将继续作用于所有新异常类型,并以相同方式处理所有类型。
一般不要把特定的异常转化为更通用的异常
将特定的异常转换为更通用异常时一种错误做法。一般而言,这将取消异常起初抛出时产生的上下文,在将异常传到系统的其他位置时,将更难处理。见下例:
1 try{
2 // Error-prone code
3 }
4 catch(IOException e){
5 String msg = "If you didn ’ t have a problem before,you do now!";
6 throw new Exception(msg);
7 }
因为没有原始异常的信息,所以处理器块无法确定问题的起因,也不知道如何更正问题。
不要处理能够避免的异常
对于有些异常类型,实际上根本不必处理。通常运行时异常属于此类范畴。在处理空指针或者数据索引等问题时,不必求助于异常处理。
Java 异常处理的应用实例
在定义银行类时,若取钱数大于余额时需要做异常处理。
定义一个异常类 insufficientFundsException。取钱(withdrawal)方法中可能产生异常,条件是余额小于取额。
处理异常在调用 withdrawal 的时候,因此 withdrawal 方法要声明抛出异常,由上一级方法调用。
异常类:
class InsufficientFundsExceptionextends Exception{
private Bank excepbank; // 银行对象
private double excepAmount; // 要取的钱
InsufficientFundsException(Bank ba, double dAmount)
{ excepbank=ba;
excepAmount=dAmount;
}
public String excepMessage(){
String str="The balance is"+excepbank.balance
+ "\n"+"The withdrawal was"+excepAmount;
return str;
}
}// 异常类
银行类:
class Bank{
double balance;// 存款数
Bank(double balance){this.balance=balance;}
public void deposite(double dAmount){
if(dAmount>0.0) balance+=dAmount;
}
public void withdrawal(double dAmount)
throws InsufficientFundsException{
if (balance<dAmount) throw new
InsufficientFundsException(this, dAmount);
balance=balance-dAmount;
}
public void showBalance(){
System.out.println("The balance is "+(int)balance);
}
}
前端调用:
public class ExceptionDemo{
public static void main(String args[]){
try{
Bank ba=new Bank(50);
ba.withdrawal(100);
System.out.println("Withdrawal successful!");
}catch(InsufficientFundsException e) {
System.out.println(e.toString());
System.out.println(e.excepMessage());
}
}
}
关于 JVM 命令行标志您不知道的 5 件事
DisableExplicitGC
我已记不清有多少次用户要求我就应用程序性能问题提供咨询了,其实只要跨代码快速运行 grep,就会发现清单 1 所示的问题 — 原始 java 性能反模式:
清单 1. System.gc();
// We just released a bunch of objects, so tell the stupid
// garbage collector to collect them already!
System.gc();
显式垃圾收集是一个非常糟糕的主意 — 就像将您和一个疯狂的斗牛犬锁在一个电话亭里。尽管调用的语法是依赖实现的,但如果您的 JVM 正在运行一个分代的垃圾回收器(大多数是)System.gc(); 强迫 VM 执行一个堆的 “全部清扫”,虽然有的没有必要。全部清扫比一个常规 GC 操作要昂贵好几个数量级,这只是个简单数学问题。
您可以不把我的话放在心上 — Sun 的工程师为这个特殊的人工错误提供一个 JVM 标志; -XX:+DisableExplicitGC 标志自动将 System.gc() 调用转换成一个空操作,为您提供运行代码的机会,您自己看看 System.gc() 对于整个 JVM 执行有害还是有利。
HeapDumpOnOutOfMemoryError
您有没有经历过这样的情况:JVM 不能使用,不断抛出 OutOfMemoryError,而您又不能为自己创建调试器来捕获它或查看出现了什么问题?像这类偶发和/或不确定的问题,通常使开发人员发疯。
买者自负
并不是任何 VM 都支持所有命令行标志,Sun/Oracle 的 VM 除外。查明一个标志是否被支持的好方法是试用它,看它是否正常工作。倘若这些标志在技术上是不支持的,那么,使用它们您要承担全部责任。如果这些标志中的任何一个使您的代码、您的数据、您的服务器或您的一切消失得无影无踪,我、Sun/Oracle 和 IBM® 都将不负责任。为以防万一,建议先在虚拟(非常生产)环境中实验。
在这个时刻您想要的是,在 JVM 消亡之际捕获堆的一个快照 — 正好 -XX:+HeapDumpOnOutOfMemoryError 命令可以完成这一操作。
运行该命令通知 JVM 拍摄一个 “堆转储快照”,并将其保存在一个文件中以便处理,通常使用 jhat 实用工具(我在 上一篇文章 中介绍过)。您可以使用相应的 -XX:HeapDumpPath 标志指定到保存文件的实际路径。(不管文件保存在哪,务必确保文件系统和/或 Java 流程必须要有权限配置,可以在其中写入。)
bootclasspath
定期将一个类放入类路径是很有帮助的,这类路径与库存 JRE 附带的类路径或者以某种方式扩展的 JRE 类路径略有不同。(新 Java Crypto API 提供商就是一个例子)。如果您想要扩展 JRE ,那么您定制的实现必须可以使用引导程序 ClassLoader,该引导程序可以加载 rt.jar 中的 java.lang.Object 及其所有相关文件。
尽管您可以 非法打开 rt.jar 并将您的定制实现或新数据包移入其中,但从技术上您就违反了您下载 JDK 时同意的协议了。
相反,使用 JVM 自己的 -Xbootclasspath 选项,以及皮肤 -Xbootclasspath/p 和 -Xbootclasspath/a。
-Xbootclasspath 使您可以设置完整的引导类路径(这通常包括一个对 rt.jar 的引用),以及一些其他 JDK 附带的(不是 rt.jar 的一部分)JAR 文件。-Xbootclasspath/p 将值前置到现有 bootclasspath 中,并将 -Xbootclasspath/a 附加到其中。
例如,如果您修改了库中的 java.lang.Integer,并将修改放在一个子路径 mods 下,那么 -Xbootclasspath/a mods 参数将新 Integer 放在默认的参数前面。
verbose
对于虚拟的或任何类型的 Java 应用程序,-verbose 是一个很有用的一级诊断使用程序。该标志有三个子标志:gc、class 和 jni。
开发人员尝试寻找是否 JVM 垃圾收集器发生故障或者导致性能低下,通常首先要做的就是执行 gc。不幸的是,解释 gc 输出很麻烦 — 足够写一本书。更糟糕的是,在命令行中打印的输出在不同的 Java 版本中或者不在不同的 JVM 中会发生改变,这使得正确解释变得更难。
一般来说,如果垃圾收集器是一个分代收集器(多数 “企业级” VMs 都是)。某种虚拟标志将会出现,来指出一个全部清扫 GC 通路;在 Sun JVM 中,标志在 GC 输出行的开始以 “[Full GC ...]” 形式出现。
想要诊断 ClassLoader 和/或不匹配的类冲突,class 可以帮上大忙。它不仅报告类何时加载,还报告类从何处加载,包括到 JAR 的路径(如果来自 JAR)。
jni 很少使用,除了使用 JNI 或本地库时。打开时,它将报告各种 JNI 事件,比如,本地库何时加载,方法何时弹回;再一次强调,在不同 JVM 版本中,输出会发生变化。
Command-line -X
我列出了 JVM 中提供的我喜欢的命令行选项,但是还有一些更多的需要您自己发现,运行命令行参数 -X,列出 JVM 提供的所有非标准(但大部分都是安全的)参数 — 例如:
-Xint,在解释模式下运行 JVM(对于测试 JIT 编译器实际上是否对您的代码起作用或者验证是否 JIT 编译器中有一个 bug,这都很有用)。
-Xloggc:,和 -verbose:gc 做同样的事,但是记录一个文件而不输出到命令行窗口。
JVM 命令行选项时常发生变化,因此,定期查看是一个好主意。甚至,您深夜盯着监控器和下午 5 点回家和妻子孩子吃顿晚饭,(或者在 Mass Effect 2 中消灭您的敌人,根据您的喜好),它们都是不一样的。
关于 java.util.concurrent 您不知道的 5 件事,第 1 部分
Concurrent Collections 是 Java™ 5 的巨大附加产品,但是在关于注释和泛型的争执中很多 Java 开发人员忽视了它们。此外(或者更老实地说),许多开发人员避免使用这个数据包,因为他们认为它一定很复杂,就像它所要解决的问题一样。
事实上,java.util.concurrent 包含许多类,能够有效解决普通的并发问题,无需复杂工序。阅读本文,了解 java.util.concurrent 类,比如 CopyOnWriteArrayList 和 BlockingQueue 如何帮助您解决多线程编程的棘手问题。
TimeUnit
尽管本质上 不是 Collections 类,但 java.util.concurrent.TimeUnit 枚举让代码更易读懂。使用 TimeUnit 将使用您的方法或 API 的开发人员从毫秒的 “暴政” 中解放出来。
TimeUnit 包括所有时间单位,从 MILLISECONDS 和 MICROSECONDS 到 DAYS 和 HOURS,这就意味着它能够处理一个开发人员所需的几乎所有的时间范围类型。同时,因为在列举上声明了转换方法,在时间加快时,将 HOURS 转换回 MILLISECONDS 甚至变得更容易。
CopyOnWriteArrayList
创建数组的全新副本是过于昂贵的操作,无论是从时间上,还是从内存开销上,因此在通常使用中很少考虑;开发人员往往求助于使用同步的 ArrayList。然而,这也是一个成本较高的选择,因为每当您跨集合内容进行迭代时,您就不得不同步所有操作,包括读和写,以此保证一致性。
这又让成本结构回到这样一个场景:需多读者都在读取 ArrayList,但是几乎没人会去修改它。
CopyOnWriteArrayList 是个巧妙的小宝贝,能解决这一问题。它的 Javadoc 将 CopyOnWriteArrayList 定义为一个 “ArrayList 的线程安全变体,在这个变体中所有易变操作(添加,设置等)可以通过复制全新的数组来实现”。
集合从内部将它的内容复制到一个没有修改的新数组,这样读者访问数组内容时就不会产生同步成本(因为他们从来不是在易变数据上操作)。
本质上讲,CopyOnWriteArrayList 很适合处理 ArrayList 经常让我们失败的这种场景:读取频繁,但很少有写操作的集合,例如 JavaBean 事件的 Listeners。
BlockingQueue
BlockingQueue 接口表示它是一个 Queue,意思是它的项以先入先出(FIFO)顺序存储。在特定顺序插入的项以相同的顺序检索 — 但是需要附加保证,从空队列检索一个项的任何尝试都会阻塞调用线程,直到这个项准备好被检索。同理,想要将一个项插入到满队列的尝试也会导致阻塞调用线程,直到队列的存储空间可用。
BlockingQueue 干净利落地解决了如何将一个线程收集的项“传递”给另一线程用于处理的问题,无需考虑同步问题。Java Tutorial 的 Guarded Blocks 试用版就是一个很好的例子。它构建一个单插槽绑定的缓存,当新的项可用,而且插槽也准备好接受新的项时,使用手动同步和 wait()/notifyAll() 在线程之间发信。(详见 Guarded Blocks 实现。)
尽管 Guarded Blocks 教程中的代码有效,但是它耗时久,混乱,而且也并非完全直观。退回到 Java 平台较早的时候,没错,Java 开发人员不得不纠缠于这种代码;但现在是 2010 年 — 情况难道没有改善?
清单 1 显示了 Guarded Blocks 代码的重写版,其中我使用了一个 ArrayBlockingQueue,而不是手写的 Drop。
清单 1. BlockingQueue
import java.util.*;
import java.util.concurrent.*;
class Producer
implements Runnable
{
private BlockingQueue<String> drop;
List<String> messages = Arrays.asList(
"Mares eat oats",
"Does eat oats",
"Little lambs eat ivy",
"Wouldn't you eat ivy too?");
public Producer(BlockingQueue<String> d) { this.drop = d; }
public void run()
{
try
{
for (String s : messages)
drop.put(s);
drop.put("DONE");
}
catch (InterruptedException intEx)
{
System.out.println("Interrupted! " +
"Last one out, turn out the lights!");
}
}
}
class Consumer
implements Runnable
{
private BlockingQueue<String> drop;
public Consumer(BlockingQueue<String> d) { this.drop = d; }
public void run()
{
try
{
String msg = null;
while (!((msg = drop.take()).equals("DONE")))
System.out.println(msg);
}
catch (InterruptedException intEx)
{
System.out.println("Interrupted! " +
"Last one out, turn out the lights!");
}
}
}
public class ABQApp
{
public static void main(String[] args)
{
BlockingQueue<String> drop = new ArrayBlockingQueue(1, true);
(new Thread(new Producer(drop))).start();
(new Thread(new Consumer(drop))).start();
}
}
ArrayBlockingQueue 还体现了“公平” — 意思是它为读取器和编写器提供线程先入先出访问。这种替代方法是一个更有效,但又冒穷尽部分线程风险的政策。(即,允许一些读取器在其他读取器锁定时运行效率更高,但是您可能会有读取器线程的流持续不断的风险,导致编写器无法进行工作。)
注意 Bug!
顺便说一句,如果您注意到 Guarded Blocks 包含一个重大 bug,那么您是对的 — 如果开发人员在 main() 中的 Drop 实例上同步,会出现什么情况呢?
BlockingQueue 还支持接收时间参数的方法,时间参数表明线程在返回信号故障以插入或者检索有关项之前需要阻塞的时间。这么做会避免非绑定的等待,这对一个生产系统是致命的,因为一个非绑定的等待会很容易导致需要重启的系统挂起。
ConcurrentMap
Map 有一个微妙的并发 bug,这个 bug 将许多不知情的 Java 开发人员引入歧途。ConcurrentMap 是容易的解决方案。
当一个 Map 被从多个线程访问时,通常使用 containsKey() 或者 get() 来查看给定键是否在存储键/值对之前出现。但是即使有一个同步的 Map,线程还是可以在这个过程中潜入,然后夺取对 Map 的控制权。问题是,在对 put() 的调用中,锁在 get() 开始时获取,然后在可以再次获取锁之前释放。它的结果是个竞争条件:这是两个线程之间的竞争,结果也会因谁先运行而不同。
如果两个线程几乎同时调用一个方法,两者都会进行测试,调用 put,在处理中丢失第一线程的值。幸运的是,ConcurrentMap 接口支持许多附加方法,它们设计用于在一个锁下进行两个任务:putIfAbsent(),例如,首先进行测试,然后仅当键没有存储在 Map 中时进行 put。
SynchronousQueues
根据 Javadoc,SynchronousQueue 是个有趣的东西:
这是一个阻塞队列,其中,每个插入操作必须等待另一个线程的对应移除操作,反之亦然。一个同步队列不具有任何内部容量,甚至不具有 1 的容量。
本质上讲,SynchronousQueue 是之前提过的 BlockingQueue 的又一实现。它给我们提供了在线程之间交换单一元素的极轻量级方法,使用 ArrayBlockingQueue 使用的阻塞语义。在清单 2 中,我重写了 清单 1 的代码,使用 SynchronousQueue 替代 ArrayBlockingQueue:
清单 2. SynchronousQueue
import java.util.*;
import java.util.concurrent.*;
class Producer
implements Runnable
{
private BlockingQueue<String> drop;
List<String> messages = Arrays.asList(
"Mares eat oats",
"Does eat oats",
"Little lambs eat ivy",
"Wouldn't you eat ivy too?");
public Producer(BlockingQueue<String> d) { this.drop = d; }
public void run()
{
try
{
for (String s : messages)
drop.put(s);
drop.put("DONE");
}
catch (InterruptedException intEx)
{
System.out.println("Interrupted! " +
"Last one out, turn out the lights!");
}
}
}
class Consumer
implements Runnable
{
private BlockingQueue<String> drop;
public Consumer(BlockingQueue<String> d) { this.drop = d; }
public void run()
{
try
{
String msg = null;
while (!((msg = drop.take()).equals("DONE")))
System.out.println(msg);
}
catch (InterruptedException intEx)
{
System.out.println("Interrupted! " +
"Last one out, turn out the lights!");
}
}
}
public class SynQApp
{
public static void main(String[] args)
{
BlockingQueue<String> drop = new SynchronousQueue<String>();
(new Thread(new Producer(drop))).start();
(new Thread(new Consumer(drop))).start();
}
}
实现代码看起来几乎相同,但是应用程序有额外获益:SynchronousQueue 允许在队列进行一个插入,只要有一个线程等着使用它。
在实践中,SynchronousQueue 类似于 Ada 和 CSP 等语言中可用的 “会合通道”。这些通道有时在其他环境中也称为 “连接”,这样的环境包括 .NET (见 参考资料)。
关于 java.util.concurrent 您不知道的 5 件事,第 2 部分
并发 Collections 提供了线程安全、经过良好调优的数据结构,简化了并发编程。然而,在一些情形下,开发人员需要更进一步,思考如何调节和/或限制线程执行。由于 java.util.concurrent 的总体目标是简化多线程编程,您可能希望该包包含同步实用程序,而它确实包含。
本文是 第 1 部分 的延续,将介绍几个比核心语言原语(监视器)更高级的同步结构,但它们还未包含在 Collection 类中。一旦您了解了这些锁和门的用途,使用它们将非常直观。
Semaphore
在一些企业系统中,开发人员经常需要限制未处理的特定资源请求(线程/操作)数量,事实上,限制有时候能够提高系统的吞吐量,因为它们减少了对特定资源的争用。尽管完全可以手动编写限制代码,但使用 Semaphore 类可以更轻松地完成此任务,它将帮您执行限制,如清单 1 所示:
清单 1. 使用 Semaphore 执行限制
import java.util.*;import java.util.concurrent.*;
public class SemApp
{
public static void main(String[] args)
{
Runnable limitedCall = new Runnable() {
final Random rand = new Random();
final Semaphore available = new Semaphore(3);
int count = 0;
public void run()
{
int time = rand.nextInt(15);
int num = count++;
try
{
available.acquire();
System.out.println("Executing " +
"long-running action for " +
time + " seconds... #" + num);
Thread.sleep(time * 1000);
System.out.println("Done with #" +
num + "!");
available.release();
}
catch (InterruptedException intEx)
{
intEx.printStackTrace();
}
}
};
for (int i=0; i<10; i++)
new Thread(limitedCall).start();
}
}
即使本例中的 10 个线程都在运行(您可以对运行 SemApp 的 Java 进程执行 jstack 来验证),但只有 3 个线程是活跃的。在一个信号计数器释放之前,其他 7 个线程都处于空闲状态。(实际上,Semaphore 类支持一次获取和释放多个 permit,但这不适用于本场景。)
CountDownLatch
如果 Semaphore 是允许一次进入一个(这可能会勾起一些流行夜总会的保安的记忆)线程的并发性类,那么 CountDownLatch 就像是赛马场的起跑门栅。此类持有所有空闲线程,直到满足特定条件,这时它将会一次释放所有这些线程。
清单 2. CountDownLatch:让我们去赛马吧!
import java.util.*;
import java.util.concurrent.*;
class Race
{
private Random rand = new Random();
private int distance = rand.nextInt(250);
private CountDownLatch start;
private CountDownLatch finish;
private List<String> horses = new ArrayList<String>();
public Race(String... names)
{
this.horses.addAll(Arrays.asList(names));
}
public void run()
throws InterruptedException
{
System.out.println("And the horses are stepping up to the gate...");
final CountDownLatch start = new CountDownLatch(1);
final CountDownLatch finish = new CountDownLatch(horses.size());
final List<String> places =
Collections.synchronizedList(new ArrayList<String>());
for (final String h : horses)
{
new Thread(new Runnable() {
public void run() {
try
{
System.out.println(h +
" stepping up to the gate...");
start.await();
int traveled = 0;
while (traveled < distance)
{
// In a 0-2 second period of time....
Thread.sleep(rand.nextInt(3) * 1000);
// ... a horse travels 0-14 lengths
traveled += rand.nextInt(15);
System.out.println(h +
" advanced to " + traveled + "!");
}
finish.countDown();
System.out.println(h +
" crossed the finish!");
places.add(h);
}
catch (InterruptedException intEx)
{
System.out.println("ABORTING RACE!!!");
intEx.printStackTrace();
}
}
}).start();
}
System.out.println("And... they're off!");
start.countDown();
finish.await();
System.out.println("And we have our winners!");
System.out.println(places.get(0) + " took the gold...");
System.out.println(places.get(1) + " got the silver...");
System.out.println("and " + places.get(2) + " took home the bronze.");
}
}
public class CDLApp
{
public static void main(String[] args)
throws InterruptedException, java.io.IOException
{
System.out.println("Prepping...");
Race r = new Race(
"Beverly Takes a Bath",
"RockerHorse",
"Phineas",
"Ferb",
"Tin Cup",
"I'm Faster Than a Monkey",
"Glue Factory Reject"
);
System.out.println("It's a race of " + r.getDistance() + " lengths");
System.out.println("Press Enter to run the race....");
System.in.read();
r.run();
}
}
注意,在 清单 2 中,CountDownLatch 有两个用途:首先,它同时释放所有线程,模拟马赛的起点,但随后会设置一个门闩模拟马赛的终点。这样,“主” 线程就可以输出结果。 为了让马赛有更多的输出注释,可以在赛场的 “转弯处” 和 “半程” 点,比如赛马跨过跑道的四分之一、二分之一和四分之三线时,添加 CountDownLatch。
Executor
清单 1 和 清单 2 中的示例都存在一个重要的缺陷,它们要求您直接创建 Thread 对象。这可以解决一些问题,因为在一些 JVM 中,创建 Thread 是一项重量型的操作,重用现有 Thread 比创建新线程要容易得多。而在另一些 JVM 中,情况正好相反:Thread 是轻量型的,可以在需要时很容易地新建一个线程。当然,如果 Murphy 拥有自己的解决办法(他通常都会拥有),那么您无论使用哪种方法对于您终将部署的平台都是不对的。
JSR-166 专家组(参见 参考资料)在一定程度上预测到了这一情形。Java 开发人员无需直接创建 Thread,他们引入了 Executor 接口,这是对创建新线程的一种抽象。如清单 3 所示,Executor 使您不必亲自对 Thread 对象执行 new 就能够创建新线程:
清单 3. Executor
Executor exec = getAnExecutorFromSomeplace();
exec.execute(new Runnable() { ... });
使用 Executor 的主要缺陷与我们在所有工厂中遇到的一样:工厂必须来自某个位置。不幸的是,与 CLR 不同,JVM 没有附带一个标准的 VM 级线程池。
Executor 类实际上 充当着一个提供 Executor 实现实例的共同位置,但它只有 new 方法(例如用于创建新线程池);它没有预先创建实例。所以您可以自行决定是否希望在代码中创建和使用 Executor 实例。(或者在某些情况下,您将能够使用所选的容器/平台提供的实例。)
ExecutorService 随时可以使用
尽管不必担心 Thread 来自何处,但 Executor 接口缺乏 Java 开发人员可能期望的某种功能,比如结束一个用于生成结果的线程并以非阻塞方式等待结果可用。(这是桌面应用程序的一个常见需求,用户将执行需要访问数据库的 UI 操作,然后如果该操作花费了很长时间,可能希望在它完成之前取消它。)
对于此问题,JSR-166 专家创建了一个更加有用的抽象(ExecutorService 接口),它将线程启动工厂建模为一个可集中控制的服务。例如,无需每执行一项任务就调用一次 execute(),ExecutorService 可以接受一组任务并返回一个表示每项任务的未来结果的未来列表。
ScheduledExecutorServices
尽管 ExecutorService 接口非常有用,但某些任务仍需要以计划方式执行,比如以确定的时间间隔或在特定时间执行给定的任务。这就是 ScheduledExecutorService 的应用范围,它扩展了 ExecutorService。
如果您的目标是创建一个每隔 5 秒跳一次的 “心跳” 命令,使用 ScheduledExecutorService 可以轻松实现,如清单 4 所示:
清单 4. ScheduledExecutorService 模拟心跳
import java.util.concurrent.*;
public class Ping
{
public static void main(String[] args)
{
ScheduledExecutorService ses =
Executors.newScheduledThreadPool(1);
Runnable pinger = new Runnable() {
public void run() {
System.out.println("PING!");
}
};
ses.scheduleAtFixedRate(pinger, 5, 5, TimeUnit.SECONDS);
}
}
这项功能怎么样?不用过于担心线程,不用过于担心用户希望取消心跳时会发生什么,也不用明确地将线程标记为前台或后台;只需将所有的计划细节留给 ScheduledExecutorService。
顺便说一下,如果用户希望取消心跳,scheduleAtFixedRate 调用将返回一个 ScheduledFuture 实例,它不仅封装了结果(如果有),还拥有一个 cancel 方法来关闭计划的操作。
Timeout 方法
为阻塞操作设置一个具体的超时值(以避免死锁)的能力是 java.util.concurrent 库相比起早期并发特性的一大进步,比如监控锁定。
这些方法几乎总是包含一个 int/TimeUnit 对,指示这些方法应该等待多长时间才释放控制权并将其返回给程序。它需要开发人员执行更多工作 — 如果没有获取锁,您将如何重新获取? — 但结果几乎总是正确的:更少的死锁和更加适合生产的代码。(关于编写生产就绪代码的更多信息,请参见 参考资料 中 Michael Nygard 编写的 Release It!。)
结束语
java.util.concurrent 包还包含了其他许多好用的实用程序,它们很好地扩展到了 Collections 之外,尤其是在 .locks 和 .atomic 包中。深入研究,您还将发现一些有用的控制结构,比如 CyclicBarrier 等。
与 Java 平台的许多其他方面一样,您无需费劲地查找可能非常有用的基础架构代码。在编写多线程代码时,请记住本文讨论的实用程序和 上一篇文章 中讨论的实用程序。
关于 Java Collections API 您不知道的 5 件事,第 1 部分
对于很多 Java 开发人员来说,Java Collections API 是标准 Java 数组及其所有缺点的一个非常需要的替代品。将 Collections 主要与 ArrayList 联系到一起本身没有错,但是对于那些有探索精神的人来说,这只是 Collections 的冰山一角。
Collections 比数组好
刚接触 Java 技术的开发人员可能不知道,Java 语言初包括数组,是为了应对上世纪 90 年代初期 C++ 开发人员对于性能方面的批评。从那时到现在,我们已经走过一段很长的路,如今,与 Java Collections 库相比,数组不再有性能优势。
例如,若要将数组的内容转储到一个字符串,需要迭代整个数组,然后将内容连接成一个 String;而 Collections 的实现都有一个可用的 toString() 实现。
除少数情况外,好的做法是尽快将遇到的任何数组转换成集合。于是问题来了,完成这种转换的容易的方式是什么?事实证明,Java Collections API 使这种转换变得容易,如清单 1 所示:
清单 1. ArrayToList
import java.util.*;
public class ArrayToList
{
public static void main(String[] args)
{
// This gives us nothing good
System.out.println(args);
// Convert args to a List of String
List<String> argList = Arrays.asList(args);
// Print them out
System.out.println(argList);
}
}
注意,返回的 List 是不可修改的,所以如果尝试向其中添加新元素将抛出一个 UnsupportedOperationException。
而且,由于 Arrays.asList() 使用 varargs 参数表示添加到 List 的元素,所以还可以使用它轻松地用以 new 新建的对象创建 List。
迭代的效率较低
将一个集合(特别是由数组转化而成的集合)的内容转移到另一个集合,或者从一个较大对象集合中移除一个较小对象集合,这些事情并不鲜见。
您也许很想对集合进行迭代,然后添加元素或移除找到的元素,但是不要这样做。
在此情况下,迭代有很大的缺点:
每次添加或移除元素后重新调整集合将非常低效。
每次在获取锁、执行操作和释放锁的过程中,都存在潜在的并发困境。
当添加或移除元素时,存取集合的其他线程会引起竞争条件。
可以通过使用 addAll 或 removeAll,传入包含要对其添加或移除元素的集合作为参数,来避免所有这些问题。
用 for 循环遍历任何 Iterable
Java 5 中加入 Java 语言的大的便利功能之一,增强的 for 循环,消除了使用 Java 集合的后一道障碍。
以前,开发人员必须手动获得一个 Iterator,使用 next() 获得 Iterator 指向的对象,并通过 hasNext() 检查是否还有更多可用对象。从 Java 5 开始,我们可以随意使用 for 循环的变种,它可以在幕后处理上述所有工作。
实际上,这个增强适用于实现 Iterable 接口的任何对象,而不仅仅是 Collections。
清单 2 显示通过 Iterator 提供 Person 对象的孩子列表的一种方法。 这里不是提供内部 List 的一个引用 (这使 Person 外的调用者可以为家庭增加孩子 — 而大多数父母并不希望如此),Person 类型实现 Iterable。这种方法还使得 for 循环可以遍历所有孩子。
清单 2. 增强的 for 循环:显示孩子
// Person.java
import java.util.*;
public class Person
implements Iterable<Person>
{
public Person(String fn, String ln, int a, Person... kids)
{
this.firstName = fn; this.lastName = ln; this.age = a;
for (Person child : kids)
children.add(child);
}
public String getFirstName() { return this.firstName; }
public String getLastName() { return this.lastName; }
public int getAge() { return this.age; }
public Iterator<Person> iterator() { return children.iterator(); }
public void setFirstName(String value) { this.firstName = value; }
public void setLastName(String value) { this.lastName = value; }
public void setAge(int value) { this.age = value; }
public String toString() {
return "[Person: " +
"firstName=" + firstName + " " +
"lastName=" + lastName + " " +
"age=" + age + "]";
}
private String firstName;
private String lastName;
private int age;
private List<Person> children = new ArrayList<Person>();
}
// App.java
public class App
{
public static void main(String[] args)
{
Person ted = new Person("Ted", "Neward", 39,
new Person("Michael", "Neward", 16),
new Person("Matthew", "Neward", 10));
// Iterate over the kids
for (Person kid : ted)
{
System.out.println(kid.getFirstName());
}
}
}
在域建模的时候,使用 Iterable 有一些明显的缺陷,因为通过 iterator() 方法只能那么 “隐晦” 地支持一个那样的对象集合。但是,如果孩子集合比较明显,Iterable 可以使针对域类型的编程更容易,更直观。
经典算法和定制算法
您是否曾想过以倒序遍历一个 Collection?对于这种情况,使用经典的 Java Collections 算法非常方便。
在上面的 清单 2 中,Person 的孩子是按照传入的顺序排列的;但是,现在要以相反的顺序列出他们。虽然可以编写另一个 for 循环,按相反顺序将每个对象插入到一个新的 ArrayList 中,但是 3、4 次重复这样做之后,就会觉得很麻烦。
在此情况下,清单 3 中的算法就有了用武之地:
清单 3. ReverseIterator
public class ReverseIterator
{
public static void main(String[] args)
{
Person ted = new Person("Ted", "Neward", 39,
new Person("Michael", "Neward", 16),
new Person("Matthew", "Neward", 10));
// Make a copy of the List
List<Person> kids = new ArrayList<Person>(ted.getChildren());
// Reverse it
Collections.reverse(kids);
// Display it
System.out.println(kids);
}
}
Collections 类有很多这样的 “算法”,它们被实现为静态方法,以 Collections 作为参数,提供独立于实现的针对整个集合的行为。
而且,由于很棒的 API 设计,我们不必完全受限于 Collections 类中提供的算法 — 例如,我喜欢不直接修改(传入的 Collection 的)内容的方法。所以,可以编写定制算法是一件很棒的事情,例如清单 4 就是一个这样的例子:
清单 4. ReverseIterator 使事情更简单
class MyCollections
{
public static <T> List<T> reverse(List<T> src)
{
List<T> results = new ArrayList<T>(src);
Collections.reverse(results);
return results;
}
}
扩展 Collections API
以上定制算法阐释了关于 Java Collections API 的一个终观点:它总是适合加以扩展和修改,以满足开发人员的特定目的。
例如,假设您需要 Person 类中的孩子总是按年龄排序。虽然可以编写代码一遍又一遍地对孩子排序(也许是使用 Collections.sort 方法),但是通过一个 Collection 类来自动排序要好得多。
实际上,您甚至可能不关心是否每次按固定的顺序将对象插入到 Collection 中(这正是 List 的基本原理)。您可能只是想让它们按一定的顺序排列。
java.util 中没有 Collection 类能满足这些需求,但是编写一个这样的类很简单。只需创建一个接口,用它描述 Collection 应该提供的抽象行为。对于 SortedCollection,它的作用完全是行为方面的。
清单 5. SortedCollection
public interface SortedCollection<E> extends Collection<E>
{
public Comparator<E> getComparator();
public void setComparator(Comparator<E> comp);
}
编写这个新接口的实现简直不值一提:
清单 6. ArraySortedCollection
import java.util.*;
public class ArraySortedCollection<E>
implements SortedCollection<E>, Iterable<E>
{
private Comparator<E> comparator;
private ArrayList<E> list;
public ArraySortedCollection(Comparator<E> c)
{
this.list = new ArrayList<E>();
this.comparator = c;
}
public ArraySortedCollection(Collection<? extends E> src, Comparator<E> c)
{
this.list = new ArrayList<E>(src);
this.comparator = c;
sortThis();
}
public Comparator<E> getComparator() { return comparator; }
public void setComparator(Comparator<E> cmp) { comparator = cmp; sortThis(); }
public boolean add(E e)
{ boolean r = list.add(e); sortThis(); return r; }
public boolean addAll(Collection<? extends E> ec)
{ boolean r = list.addAll(ec); sortThis(); return r; }
public boolean remove(Object o)
{ boolean r = list.remove(o); sortThis(); return r; }
public boolean removeAll(Collection<?> c)
{ boolean r = list.removeAll(c); sortThis(); return r; }
public boolean retainAll(Collection<?> ec)
{ boolean r = list.retainAll(ec); sortThis(); return r; }
public void clear() { list.clear(); }
public boolean contains(Object o) { return list.contains(o); }
public boolean containsAll(Collection <?> c) { return list.containsAll(c); }
public boolean isEmpty() { return list.isEmpty(); }
public Iterator<E> iterator() { return list.iterator(); }
public int size() { return list.size(); }
public Object[] toArray() { return list.toArray(); }
public <T> T[] toArray(T[] a) { return list.toArray(a); }
public boolean equals(Object o)
{
if (o == this)
return true;
if (o instanceof ArraySortedCollection)
{
ArraySortedCollection<E> rhs = (ArraySortedCollection<E>)o;
return this.list.equals(rhs.list);
}
return false;
}
public int hashCode()
{
return list.hashCode();
}
public String toString()
{
return list.toString();
}
private void sortThis()
{
Collections.sort(list, comparator);
}
}
这个实现非常简陋,编写时并没有考虑优化,显然还需要进行重构。但关键是 Java Collections API 从来无意将与集合相关的任何东西定死。它总是需要扩展,同时也鼓励扩展。
当然,有些扩展比较复杂,例如 java.util.concurrent 中引入的扩展。但是另一些则非常简单,只需编写一个定制算法,或者已有 Collection 类的简单的扩展。
扩展 Java Collections API 看上去很难,但是一旦开始着手,您会发现远不如想象的那样难。
关于 Java Collections API 您不知道的 5 件事,第 2 部分
java.util 中的 Collections 类旨在通过取代数组提高 Java 性能。如您在 第 1 部分 中了解到的,它们也是多变的,能够以各种方式定制和扩展,帮助实现优质、简洁的代码。
Collections 非常强大,但是很多变:使用它们要小心,滥用它们会带来风险。
List 不同于数组
Java 开发人员常常错误地认为 ArrayList 就是 Java 数组的替代品。Collections 由数组支持,在集合内随机查找内容时性能较好。与数组一样,集合使用整序数获取特定项。但集合不是数组的简单替代。
要明白数组与集合的区别需要弄清楚顺序 和位置 的不同。例如,List 是一个接口,它保存各个项被放入集合中的顺序,如清单 1 所示:
清单 1. 可变键值
import java.util.*;
public class OrderAndPosition
{
public static <T> void dumpArray(T[] array)
{
System.out.println("=============");
for (int i=0; i<array.length; i++)
System.out.println("Position " + i + ": " + array[i]);
}
public static <T> void dumpList(List<T> list)
{
System.out.println("=============");
for (int i=0; i<list.size(); i++)
System.out.println("Ordinal " + i + ": " + list.get(i));
}
public static void main(String[] args)
{
List<String> argList = new ArrayList<String>(Arrays.asList(args));
dumpArray(args);
args[1] = null;
dumpArray(args);
dumpList(argList);
argList.remove(1);
dumpList(argList);
}
}
当第三个元素从上面的 List 中被移除时,其 “后面” 的各项会上升填补空位。很显然,此集合行为与数组的行为不同(事实上,从数组中移除项与从 List 中移除它也不完全是一回事儿 — 从数组中 “移除” 项意味着要用新引用或 null 覆盖其索引槽)。
令人惊讶的 Iterator!
无疑 Java 开发人员很喜爱 Java 集合 Iterator,但是您后一次使用 Iterator 接口是什么时候的事情了?可以这么说,大部分时间我们只是将 Iterator 随意放到 for() 循环或加强 for() 循环中,然后就继续其他操作了。
但是进行深入研究后,您会发现 Iterator 实际上有两个十分有用的功能。
第一,Iterator 支持从源集合中安全地删除对象,只需在 Iterator 上调用 remove() 即可。这样做的好处是可以避免 ConcurrentModifiedException,这个异常顾名思意:当打开 Iterator 迭代集合时,同时又在对集合进行修改。有些集合不允许在迭代时删除或添加元素,但是调用 Iterator 的 remove() 方法是个安全的做法。
第二,Iterator 支持派生的(并且可能是更强大的)兄弟成员。ListIterator,只存在于 List 中,支持在迭代期间向 List 中添加或删除元素,并且可以在 List 中双向滚动。
双向滚动特别有用,尤其是在无处不在的 “滑动结果集” 操作中,因为结果集中只能显示从数据库或其他集合中获取的众多结果中的 10 个。它还可以用于 “反向遍历” 集合或列表,而无需每次都从前向后遍历。插入 ListIterator 比使用向下计数整数参数 List.get() “反向” 遍历 List 容易得多。
并非所有 Iterable 都来自集合
Ruby 和 Groovy 开发人员喜欢炫耀他们如何能迭代整个文本文件并通过一行代码将其内容输出到控制台。通常,他们会说在 Java 编程中完成同样的操作需要很多行代码:打开 FileReader,然后打开 BufferedReader,接着创建 while() 循环来调用 getLine(),直到它返回 null。当然,在 try/catch/finally 块中必须要完成这些操作,它要处理异常并在结束时关闭文件句柄。
这看起来像是一个没有意义的学术上的争论,但是它也有其自身的价值。
他们(包括相当一部分 Java 开发人员)不知道并不是所有 Iterable 都来自集合。Iterable 可以创建 Iterator,该迭代器知道如何凭空制造下一个元素,而不是从预先存在的 Collection 中盲目地处理:
清单 2. 迭代文件
// FileUtils.java
import java.io.*;
import java.util.*;
public class FileUtils
{
public static Iterable<String> readlines(String filename)
throws IOException
{
final FileReader fr = new FileReader(filename);
final BufferedReader br = new BufferedReader(fr);
return new Iterable<String>() {
public <code>Iterator</code><String> iterator() {
return new <code>Iterator</code><String>() {
public boolean hasNext() {
return line != null;
}
public String next() {
String retval = line;
line = getLine();
return retval;
}
public void remove() {
throw new UnsupportedOperationException();
}
String getLine() {
String line = null;
try {
line = br.readLine();
}
catch (IOException ioEx) {
line = null;
}
return line;
}
String line = getLine();
};
}
};
}
}
//DumpApp.java
import java.util.*;
public class DumpApp
{
public static void main(String[] args)
throws Exception
{
for (String line : FileUtils.readlines(args[0]))
System.out.println(line);
}
}
此方法的优势是不会在内存中保留整个内容,但是有一个警告就是,它不能 close() 底层文件句柄(每当 readLine() 返回 null 时就关闭文件句柄,可以修正这一问题,但是在 Iterator 没有结束时不能解决这个问题)。
注意可变的 hashCode()
Map 是很好的集合,为我们带来了在其他语言(比如 Perl)中经常可见的好用的键/值对集合。JDK 以 HashMap 的形式为我们提供了方便的 Map 实现,它在内部使用哈希表实现了对键的对应值的快速查找。但是这里也有一个小问题:支持哈希码的键依赖于可变字段的内容,这样容易产生 bug,即使耐心的 Java 开发人员也会被这些 bug 逼疯。
假设清单 3 中的 Person 对象有一个常见的 hashCode() (它使用 firstName、lastName 和 age 字段 — 所有字段都不是 final 字段 — 计算 hashCode()),对 Map 的 get() 调用会失败并返回 null:
清单 3. 可变 hashCode() 容易出现 bug
// Person.java
import java.util.*;
public class Person
implements Iterable<Person>
{
public Person(String fn, String ln, int a, Person... kids)
{
this.firstName = fn; this.lastName = ln; this.age = a;
for (Person kid : kids)
children.add(kid);
}
// ...
public void setFirstName(String value) { this.firstName = value; }
public void setLastName(String value) { this.lastName = value; }
public void setAge(int value) { this.age = value; }
public int hashCode() {
return firstName.hashCode() & lastName.hashCode() & age;
}
// ...
private String firstName;
private String lastName;
private int age;
private List<Person> children = new ArrayList<Person>();
}
// MissingHash.java
import java.util.*;
public class MissingHash
{
public static void main(String[] args)
{
Person p1 = new Person("Ted", "Neward", 39);
Person p2 = new Person("Charlotte", "Neward", 38);
System.out.println(p1.hashCode());
Map<Person, Person> map = new HashMap<Person, Person>();
map.put(p1, p2);
p1.setLastName("Finkelstein");
System.out.println(p1.hashCode());
System.out.println(map.get(p1));
}
}
很显然,这种方法很糟糕,但是解决方法也很简单:永远不要将可变对象类型用作 HashMap 中的键。
equals() 与 Comparable
在浏览 Javadoc 时,Java 开发人员常常会遇到 SortedSet 类型(它在 JDK 中唯一的实现是 TreeSet)。因为 SortedSet 是 java.util 包中唯一提供某种排序行为的 Collection,所以开发人员通常直接使用它而不会仔细地研究它。清单 4 展示了:
清单 4. SortedSet,我很高兴找到了它!
import java.util.*;
public class UsingSortedSet
{
public static void main(String[] args)
{
List<Person> persons = Arrays.asList(
new Person("Ted", "Neward", 39),
new Person("Ron", "Reynolds", 39),
new Person("Charlotte", "Neward", 38),
new Person("Matthew", "McCullough", 18)
);
SortedSet ss = new TreeSet(new Comparator<Person>() {
public int compare(Person lhs, Person rhs) {
return lhs.getLastName().compareTo(rhs.getLastName());
}
});
ss.addAll(perons);
System.out.println(ss);
}
}
使用上述代码一段时间后,可能会发现这个 Set 的核心特性之一:它不允许重复。该特性在 Set Javadoc 中进行了介绍。Set 是不包含重复元素的集合。更准确地说,set 不包含成对的 e1 和 e2 元素,因此如果 e1.equals(e2),那么多包含一个 null 元素。
但实际上似乎并非如此 — 尽管 清单 4 中没有相等的 Person 对象(根据 Person 的 equals() 实现),但在输出时只有三个对象出现在 TreeSet 中。
与 set 的有状态本质相反,TreeSet 要求对象直接实现 Comparable 或者在构造时传入 Comparator,它不使用 equals() 比较对象;它使用 Comparator/Comparable 的 compare 或 compareTo 方法。
因此存储在 Set 中的对象有两种方式确定相等性:大家常用的 equals() 方法和 Comparable/Comparator 方法,采用哪种方法取决于上下文。
更糟的是,简单的声明两者相等还不够,因为以排序为目的的比较不同于以相等性为目的的比较:可以想象一下按姓排序时两个 Person 相等,但是其内容却并不相同。
一定要明白 equals() 和 Comparable.compareTo() 两者之间的不同 — 实现 Set 时会返回 0。甚至在文档中也要明确两者的区别。
关于 Java 对象序列化您不知道的 5 件事
关于本系列
您觉得自己懂 Java 编程?事实上,大多数程序员对于 Java 平台都是浅尝则止,只学习了足以完成手头上任务的知识而已。在本 系列 中,Ted Neward 深入挖掘 Java 平台的核心功能,揭示一些鲜为人知的事实,帮助您解决棘手的编程挑战。
大约一年前,一个负责管理应用程序所有用户设置的开发人员,决定将用户设置存储在一个 Hashtable 中,然后将这个 Hashtable 序列化到磁盘,以便持久化。当用户更改设置时,便重新将 Hashtable 写到磁盘。
这是一个优雅的、开放式的设置系统,但是,当团队决定从 Hashtable 迁移到 Java Collections 库中的 HashMap 时,这个系统便面临崩溃。
Hashtable 和 HashMap 在磁盘上的格式是不相同、不兼容的。除非对每个持久化的用户设置运行某种类型的数据转换实用程序(极其庞大的任务),否则以后似乎只能一直用 Hashtable 作为应用程序的存储格式。
团队感到陷入僵局,但这只是因为他们不知道关于 Java 序列化的一个重要事实:Java 序列化允许随着时间的推移而改变类型。当我向他们展示如何自动进行序列化替换后,他们终于按计划完成了向 HashMap 的转变。
本文是本系列的第一篇文章,这个系列专门揭示关于 Java 平台的一些有用的小知识 — 这些小知识不易理解,但对于解决 Java 编程挑战迟早有用。
将 Java 对象序列化 API 作为开端是一个不错的选择,因为它从一开始就存在于 JDK 1.1 中。本文介绍的关于序列化的 5 件事情将说服您重新审视那些标准 Java API。
Java 序列化简介
Java 对象序列化是 JDK 1.1 中引入的一组开创性特性之一,用于作为一种将 Java 对象的状态转换为字节数组,以便存储或传输的机制,以后,仍可以将字节数组转换回 Java 对象原有的状态。
实际上,序列化的思想是 “冻结” 对象状态,传输对象状态(写到磁盘、通过网络传输等等),然后 “解冻” 状态,重新获得可用的 Java 对象。所有这些事情的发生有点像是魔术,这要归功于 ObjectInputStream/ObjectOutputStream 类、完全保真的元数据以及程序员愿意用 Serializable 标识接口标记他们的类,从而 “参与” 这个过程。
清单 1 显示一个实现 Serializable 的 Person 类。
清单 1. Serializable Person
package com.tedneward;
public class Person
implements java.io.Serializable
{
public Person(String fn, String ln, int a)
{
this.firstName = fn; this.lastName = ln; this.age = a;
}
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public int getAge() { return age; }
public Person getSpouse() { return spouse; }
public void setFirstName(String value) { firstName = value; }
public void setLastName(String value) { lastName = value; }
public void setAge(int value) { age = value; }
public void setSpouse(Person value) { spouse = value; }
public String toString()
{
return "[Person: firstName=" + firstName +
" lastName=" + lastName +
" age=" + age +
" spouse=" + spouse.getFirstName() +
"]";
}
private String firstName;
private String lastName;
private int age;
private Person spouse;
}
将 Person 序列化后,很容易将对象状态写到磁盘,然后重新读出它,下面的 JUnit 4 单元测试对此做了演示。
清单 2. 对 Person 进行反序列化
public class SerTest
{
@Test public void serializeToDisk()
{
try
{
com.tedneward.Person ted = new com.tedneward.Person("Ted", "Neward", 39);
com.tedneward.Person charl = new com.tedneward.Person("Charlotte",
"Neward", 38);
ted.setSpouse(charl); charl.setSpouse(ted);
FileOutputStream fos = new FileOutputStream("tempdata.ser");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(ted);
oos.close();
}
catch (Exception ex)
{
fail("Exception thrown during test: " + ex.toString());
}
try
{
FileInputStream fis = new FileInputStream("tempdata.ser");
ObjectInputStream ois = new ObjectInputStream(fis);
com.tedneward.Person ted = (com.tedneward.Person) ois.readObject();
ois.close();
assertEquals(ted.getFirstName(), "Ted");
assertEquals(ted.getSpouse().getFirstName(), "Charlotte");
// Clean up the file
new File("tempdata.ser").delete();
}
catch (Exception ex)
{
fail("Exception thrown during test: " + ex.toString());
}
}
}
到现在为止,还没有看到什么新鲜的或令人兴奋的事情,但是这是一个很好的出发点。我们将使用 Person 来发现您可能不知道的关于 Java 对象序列化 的 5 件事。
序列化允许重构
序列化允许一定数量的类变种,甚至重构之后也是如此,ObjectInputStream 仍可以很好地将其读出来。
Java Object Serialization 规范可以自动管理的关键任务是:
将新字段添加到类中
将字段从 static 改为非 static
将字段从 transient 改为非 transient
取决于所需的向后兼容程度,转换字段形式(从非 static 转换为 static 或从非 transient 转换为 transient)或者删除字段需要额外的消息传递。
重构序列化类
既然已经知道序列化允许重构,我们来看看当把新字段添加到 Person 类中时,会发生什么事情。
如清单 3 所示,PersonV2 在原先 Person 类的基础上引入一个表示性别的新字段。
清单 3. 将新字段添加到序列化的 Person 中
enum Gender
{
MALE, FEMALE
}
public class Person
implements java.io.Serializable
{
public Person(String fn, String ln, int a, Gender g)
{
this.firstName = fn; this.lastName = ln; this.age = a; this.gender = g;
}
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public Gender getGender() { return gender; }
public int getAge() { return age; }
public Person getSpouse() { return spouse; }
public void setFirstName(String value) { firstName = value; }
public void setLastName(String value) { lastName = value; }
public void setGender(Gender value) { gender = value; }
public void setAge(int value) { age = value; }
public void setSpouse(Person value) { spouse = value; }
public String toString()
{
return "[Person: firstName=" + firstName +
" lastName=" + lastName +
" gender=" + gender +
" age=" + age +
" spouse=" + spouse.getFirstName() +
"]";
}
private String firstName;
private String lastName;
private int age;
private Person spouse;
private Gender gender;
}
序列化使用一个 hash,该 hash 是根据给定源文件中几乎所有东西 — 方法名称、字段名称、字段类型、访问修改方法等 — 计算出来的,序列化将该 hash 值与序列化流中的 hash 值相比较。
为了使 Java 运行时相信两种类型实际上是一样的,第二版和随后版本的 Person 必须与第一版有相同的序列化版本 hash(存储为 private static final serialVersionUID 字段)。因此,我们需要 serialVersionUID 字段,它是通过对原始(或 V1)版本的 Person 类运行 JDK serialver 命令计算出的。
一旦有了 Person 的 serialVersionUID,不仅可以从原始对象 Person 的序列化数据创建 PersonV2 对象(当出现新字段时,新字段被设为缺省值,常见的是“null”),还可以反过来做:即从 PersonV2 的数据通过反序列化得到 Person,这毫不奇怪。
序列化并不安全
让 Java 开发人员诧异并感到不快的是,序列化二进制格式完全编写在文档中,并且完全可逆。实际上,只需将二进制序列化流的内容转储到控制台,就足以看清类是什么样子,以及它包含什么内容。
这对于安全性有着不良影响。例如,当通过 RMI 进行远程方法调用时,通过连接发送的对象中的任何 private 字段几乎都是以明文的方式出现在套接字流中,这显然容易招致哪怕简单的安全问题。
幸运的是,序列化允许 “hook” 序列化过程,并在序列化之前和反序列化之后保护(或模糊化)字段数据。可以通过在 Serializable 对象上提供一个 writeObject 方法来做到这一点。
模糊化序列化数据
假设 Person 类中的敏感数据是 age 字段。毕竟,女士忌谈年龄。我们可以在序列化之前模糊化该数据,将数位循环左移一位,然后在反序列化之后复位。(您可以开发更安全的算法,当前这个算法只是作为一个例子。)
为了 “hook” 序列化过程,我们将在 Person 上实现一个 writeObject 方法;为了 “hook” 反序列化过程,我们将在同一个类上实现一个 readObject 方法。重要的是这两个方法的细节要正确 — 如果访问修改方法、参数或名称不同于清单 4 中的内容,那么代码将不被察觉地失败,Person 的 age 将暴露。
清单 4. 模糊化序列化数据
public class Person
implements java.io.Serializable
{
public Person(String fn, String ln, int a)
{
this.firstName = fn; this.lastName = ln; this.age = a;
}
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public int getAge() { return age; }
public Person getSpouse() { return spouse; }
public void setFirstName(String value) { firstName = value; }
public void setLastName(String value) { lastName = value; }
public void setAge(int value) { age = value; }
public void setSpouse(Person value) { spouse = value; }
private void writeObject(java.io.ObjectOutputStream stream)
throws java.io.IOException
{
// "Encrypt"/obscure the sensitive data
age = age << 2;
stream.defaultWriteObject();
}
private void readObject(java.io.ObjectInputStream stream)
throws java.io.IOException, ClassNotFoundException
{
stream.defaultReadObject();
// "Decrypt"/de-obscure the sensitive data
age = age << 2;
}
public String toString()
{
return "[Person: firstName=" + firstName +
" lastName=" + lastName +
" age=" + age +
" spouse=" + (spouse!=null ? spouse.getFirstName() : "[null]") +
"]";
}
private String firstName;
private String lastName;
private int age;
private Person spouse;
}
如果需要查看被模糊化的数据,总是可以查看序列化数据流/文件。而且,由于该格式被完全文档化,即使不能访问类本身,也仍可以读取序列化流中的内容。
序列化的数据可以被签名和密封
上一个技巧假设您想模糊化序列化数据,而不是对其加密或者确保它不被修改。当然,通过使用 writeObject 和 readObject 可以实现密码加密和签名管理,但其实还有更好的方式。
如果需要对整个对象进行加密和签名,简单的是将它放在一个 javax.crypto.SealedObject 和/或 java.security.SignedObject 包装器中。两者都是可序列化的,所以将对象包装在 SealedObject 中可以围绕原对象创建一种 “包装盒”。必须有对称密钥才能解密,而且密钥必须单独管理。同样,也可以将 SignedObject 用于数据验证,并且对称密钥也必须单独管理。
结合使用这两种对象,便可以轻松地对序列化数据进行密封和签名,而不必强调关于数字签名验证或加密的细节。很简洁,是吧?
序列化允许将代理放在流中
很多情况下,类中包含一个核心数据元素,通过它可以派生或找到类中的其他字段。在此情况下,没有必要序列化整个对象。可以将字段标记为 transient,但是每当有方法访问一个字段时,类仍然必须显式地产生代码来检查它是否被初始化。
如果首要问题是序列化,那么好指定一个 flyweight 或代理放在流中。为原始 Person 提供一个 writeReplace 方法,可以序列化不同类型的对象来代替它。类似地,如果反序列化期间发现一个 readResolve 方法,那么将调用该方法,将替代对象提供给调用者。
打包和解包代理
writeReplace 和 readResolve 方法使 Person 类可以将它的所有数据(或其中的核心数据)打包到一个 PersonProxy 中,将它放入到一个流中,然后在反序列化时再进行解包。
清单 5. 你完整了我,我代替了你
class PersonProxy
implements java.io.Serializable
{
public PersonProxy(Person orig)
{
data = orig.getFirstName() + "," + orig.getLastName() + "," + orig.getAge();
if (orig.getSpouse() != null)
{
Person spouse = orig.getSpouse();
data = data + "," + spouse.getFirstName() + "," + spouse.getLastName() + ","
+ spouse.getAge();
}
}
public String data;
private Object readResolve()
throws java.io.ObjectStreamException
{
String[] pieces = data.split(",");
Person result = new Person(pieces[0], pieces[1], Integer.parseInt(pieces[2]));
if (pieces.length > 3)
{
result.setSpouse(new Person(pieces[3], pieces[4], Integer.parseInt
(pieces[5])));
result.getSpouse().setSpouse(result);
}
return result;
}
}
public class Person
implements java.io.Serializable
{
public Person(String fn, String ln, int a)
{
this.firstName = fn; this.lastName = ln; this.age = a;
}
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public int getAge() { return age; }
public Person getSpouse() { return spouse; }
private Object writeReplace()
throws java.io.ObjectStreamException
{
return new PersonProxy(this);
}
public void setFirstName(String value) { firstName = value; }
public void setLastName(String value) { lastName = value; }
public void setAge(int value) { age = value; }
public void setSpouse(Person value) { spouse = value; }
public String toString()
{
return "[Person: firstName=" + firstName +
" lastName=" + lastName +
" age=" + age +
" spouse=" + spouse.getFirstName() +
"]";
}
private String firstName;
private String lastName;
private int age;
private Person spouse;
}
注意,PersonProxy 必须跟踪 Person 的所有数据。这通常意味着代理需要是 Person 的一个内部类,以便能访问 private 字段。有时候,代理还需要追踪其他对象引用并手动序列化它们,例如 Person 的 spouse。
这种技巧是少数几种不需要读/写平衡的技巧之一。例如,一个类被重构成另一种类型后的版本可以提供一个 readResolve 方法,以便静默地将被序列化的对象转换成新类型。类似地,它可以采用 writeReplace 方法将旧类序列化成新版本。
信任,但要验证
认为序列化流中的数据总是与初写到流中的数据一致,这没有问题。但是,正如一位美国前总统所说的,“信任,但要验证”。
对于序列化的对象,这意味着验证字段,以确保在反序列化之后它们仍具有正确的值,“以防万一”。为此,可以实现 ObjectInputValidation 接口,并覆盖 validateObject() 方法。如果调用该方法时发现某处有错误,则抛出一个 InvalidObjectException。