UML软件工程组织

Java 2标准版 1.4的新I/O功能
John Zukowski著 来源:Sun

回到2000年1月,那时许多人还在讨论2000年是世纪元年还是世纪末年,作为经过 认可的一个Java Specification Request(JSR,Java规范请求),JSR 51诞生了。这 个JSR的名称是用于Java平台的新I/O API。许多人以为新的功能只 是提供非独占式I/O操作。实际上,引入到Java TM 2平台标准版(J2SE)1.4 Beta版的新特性, 包括许多其他新的有趣的特点。这些API除了肯定对套接字和文件都支持可伸缩的I/O 操作之外,还包括用于模式匹配的正则表达式软件包,用于字符集转换的编码和解码 器,以及改进的文件系统支持,比如文件锁定和内存映射。这四项新特性在本文中都 会介绍。

注意: Java本地接口(JNI)为了支持新的I/O操作所作的变更不在本文论述之列。 有关这些变动的信息,请参见本文结尾的 资源一节

缓存

从最简单的开始,最复杂的放在最后,首先要介绍的改进是java.nio 软件包中的Buffer类集。缓存提供了一种把原始数据元素保存在内存中 容件的机制。你可以这样认为,用DataInputStream/DataOutputStream 的组合把一个固定长度的字节数组包装起来,然后只能读写一种数据类型,比如 charintdouble。有7种这样的缓存可 用:

  • ByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer

ByteBuffer实际上支持其他6种类型的读写,其他类型则与类型相 关。为了演示缓存的使用,下一小节把String转换为CharBuffer,每次读取一个字 符。用wrap方法把String转换为CharBuffer,然后用get方法获取每个字母。

CharBuffer buff = CharBuffer.wrap(args[0]);
for (int i=0, n=buff.length(); i<n; i++) {
System.out.println(buff.get());
}

在使用缓存时,必须认识到大小和位置值存在差异,这一点要小心。 length实际上是非标准的,特别是对CharBuffer来说。 它本身并没有错,但它其实报告的是剩余的长度,所以如果当前位置不在起始处, 报告的长度就不是缓存的长度,而是缓存中剩余字符的个数。换言之,上面的循环 也可以写成下面这样。

CharBuffer buff = CharBuffer.wrap(args[0]);
for (int i=0; buff.length() > 0; i++) {
System.out.println(buff.get());
}

回到不同的大小和位置值,这四个值称为标记、指针、边界和容量:

  • 标记--可用mark方法设置的位置,这种方法可用于指针的复位,<=位置, >=0
  • 指针--缓存内的当前读/写位置,<= 边界
  • 边界--第一个不能读的元素的下标,<= 容量
  • 容量--缓存的大小,>= 边界

在对缓存进行读写时,指针是必须时刻关心的重要信息。例如,如果想把刚写入 的内容读出来,必须把指针移动到开始读取的地方,否则读取时就会超出边界,得 到的结果没有意义。这时flip方法就很方便,它可以把边界设置为当 前位置,再把当前位置设为0。你还可以对缓存进行rewind操作,保持 当前边界不变,而把指针移回到0。例如,从下面一小节中删去flip调 用,将返回一个空格,假定最初在缓存中没有放入任何值。

buff.put('a');
buff.flip();
buff.get();

上面演示的wrap机制是非直接缓存的一个例子。用 allocate方法也可以创建非直接缓存和改变其大小,其本质是把数据 包装成一个数组。稍微多付出一点代价的话,还可以用allocateDirect 方法创建连续的内存块,这也称为直接缓存。直接缓存依赖系统自身的I/O操作来优 化访问操作。

映射文件

直接的ByteBuffer有一种特殊形式,称为MappedByteBuffer 。这个类表示一个映射到文件的字节缓存。为了把文件映射到MappedByteBuffer ,首先必须获得一个用于文件的通道。channel表示通向可以执行I/O操作的某 事物的连接,比如管道、套接字或文件。就FileChannel而言,可以通 过getChannel方法,从FileInputStreamFileOutputStreamRandomAccessFile获得一个通道。 有了通道之后,用map把它映射到缓存,并指定要映射的文件的模式和 部分。可以用下列FileChannel.MapMode常数之一打开文件通道: 只 读(READ_ONLY),私有/写时复制(PRIVATE)或读-写 (READ_WRITE)。

下面是从文件创建读-写MappedByteBuffer 的基本过程:

String filename = ...;
FileInputStream input = new 
FileInputStream(filename);
FileChannel channel = input.getChannel();
int fileLength = (int)channel.size();
MappedByteBuffer buffer = 
channel.map(FileChannel.MapMode.READ_ONLY, 0, 
fileLength);

你可以从java.nio.channels软件包中找到与通道有关的类。

创建MappedByteBuffer之后,可以像访问任何其他 ByteBuffer那样访问它。不过在这个具体例子中,它是只读的,所以 任何put(写入)的尝试都会产生一个异常,在本例中将产生 NonWritableChannelException。如果需要把这些字节当作字符,必须 使用转换字符集把ByteBuffer转换为CharBuffer。用 Charset类指定这个字符集。然后通过CharsetDecoder类 对文件内容进行解码。CharsetEncoder所做的事与此相反。

// ISO-8859-1  is ISO Latin Alphabet #1
Charset charset = Charset.forName("ISO-8859-1");
CharsetDecoder decoder = charset.newDecoder();
CharBuffer charBuffer = decoder.decode(buffer);

这些类可以在java.nio.channels软件包中找到。

正则表达式

把输入文件映射到CharBuffer之后,就可以对文件内容进行模式 匹配了。假设要对文件分别运行grepwc,也就是进行 正则表达式匹配和统计字数。这正是java.util.regex 软件包派上用 场,PatternMatcher类大显身手的地方。

Pattern类提供了一整套用于匹配正则表达式的结构。提供的模板 通常都是String。有关模板的完整详情,请参见这个类的文档。下面 是一些简单的例子:

  • 行模板,即任意个数的字符,后跟回车或换行符: .*\r?\n or .*$
  • 数字序列: [0-9]* or \d*
  • 控制字符 {cntrl}
  • 一个大写或小写的US-ASCII字符,后跟空格,再后面跟标点: [\p{Lower}\p{Upper}]\s\p{Punct}

注意:很不幸,当处理字符序列的任何对象查看字符缓存时(这对正则表达式而 言是必须的),J2SE 1.4的beta 3版会出错。有关这个问题的描述请参见 问题列表 。更不幸的是,这意味着你不能用模板匹配器来每次读取一个单词或一 行。

有关正则表达式库的其他信息,请参见 资源一节中引用的文章正则表达式和Java编程语言

套接字通道

介绍完文件通道,我们再来看读出和写入套接字连接的通道。这些通道可以以独占 或非独占的方式使用。在独占方式下,只需把调用改成connectaccept,视其为客户机或是服务器而定。在非独占方式下,没有等效的 调用。

用于处理基本的套接字读写的新类是:java.net软件包中的 InetSocketAddress类,它指定要连接到何处,java.nio.channels 软件包中的SocketChannel 类,它进行实际的读写操作。

InetSocketAddress进行连接,非常类似于Socket 类的用法。你要做的一切只是提供主机和端口:

String host = ...;
InetSocketAddress socketAddress = new 
InetSocketAddress(host, 80);

拥有InetSocketAddress之后,生活为之一变。和读套接字输入流、 写套接字输出流的做法不同,你要做的是打开一个SocketChannel,把 它连接到InetSocketAddress

SocketChannel channel = SocketChannel.open();
channel.connect(socketAddress);

连上之后,就可以用ByteBuffer对象对该通道进行读写了。举例来 说,在CharsetEncoder的帮助下,你可以在CharBuffer 中包装一个String,用来发送一个HTTP请求:

Charset charset = Charset.forName("ISO-8859-1");
CharsetEncoder encoder = charset.newEncoder();
String request = "GET / \r\n\r\n";
channel.write(encoder.encode(CharBuffer.wrap(request)));

然后就可以从这个通道读入响应了。因为这个HTTP请求的响应是文本,所以还需要 通过CharsetDecoder把它转换成CharBuffer。只创建和 启动一个CharBuffer,就可以不断重用这个对象,以避免在多次读取时 不必要的碎片收集:

ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
CharBuffer charBuffer = CharBuffer.allocate(1024);
while ((channel.read(buffer)) != -1) {
buffer.flip();
decoder.decode(buffer, charBuffer, false);
charBuffer.flip();
System.out.println(charBuffer);
buffer.clear();
charBuffer.clear();
}

下面的程序把所有这些代码端连接起来,通过一个HTTP请求读取某个Web站点的主 页。可以放心地把输出保存到一个文件,以便将结果与用浏览器查看该页面相比较。

import java.io.*;
import java.net.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.charset.*;


public class ReadURL {
public static void main(String args[]) {
String host = args[0];
SocketChannel channel = null;

try {

// Setup
InetSocketAddress socketAddress = 
new InetSocketAddress(host, 80);
Charset charset = 
Charset.forName("ISO-8859-1");
CharsetDecoder decoder = 
charset.newDecoder();
CharsetEncoder encoder = 
charset.newEncoder();

// Allocate buffers
ByteBuffer buffer = 
ByteBuffer.allocateDirect(1024);
CharBuffer charBuffer = 
CharBuffer.allocate(1024);

// Connect
channel = SocketChannel.open();
channel.connect(socketAddress);

// Send request
String request = "GET / \r\n\r\n";
channel.write(encoder.encode(CharBuffer.wrap(request)));

// Read response
while ((channel.read(buffer)) != -1) {
buffer.flip();
// Decode buffer
decoder.decode(buffer, charBuffer, false);
// Display
charBuffer.flip();
System.out.println(charBuffer);
buffer.clear();
charBuffer.clear();
}
} catch (UnknownHostException e) {
System.err.println(e);
} catch (IOException e) {
System.err.println(e);
} finally {
if (channel != null) {
try {
channel.close();
} catch (IOException ignored) {
}
}
}
}
}

非独占式读取

现在到了有趣的部分,也就是人们最关心的新I/O软件包。你怎么配置非独占式 通道连接的?基本步骤是在打开的SocketChannel上调用 configureBlocking方法,传送一个false值。调用 connect方法后,该方法会立即返回。

String host = ...;
InetSocketAddress socketAddress = 
new InetSocketAddress(host, 80);
channel = SocketChannel.open();
channel.configureBlocking(false);
channel.connect(socketAddress);

有了非独占式通道,还必须考虑如何实际使用这个通道。SelectableChannelSocketChannel的一个例子。这些可选择的通道使用 Selector来工作。大体来说是这样,用Selector注册通 道,告诉Selector你对什么事件感兴趣,当这类事件发生时它会通知 你。

为了获得一个Selector实例,只需调用这个类的静态 open方法:

Selector selector = Selector.open();

通过通道的register方法,对Selector进行注册。在 SelectionKey类的字段中指定事件。在SocketChannel类 的例子中,可用的操作有OP_CONNECTOP_READOP_WRITE。所以,如果对读和连接操作感兴趣,就像下面这样注册:

channel.register(selector, 
SelectionKey.OP_CONNECT | SelectionKey.OP_READ);

到此为止,你只有等着选择器通知你,感兴趣的事件何时在已注册的通道上发生。 Selectorselect方法将阻塞,直至感兴趣的事件发生。 为了获知这一点,可以在它自己的线程中放一个while while (selector.select() > 0)循环,然后放心做其他的事情去,直到I/O事件 被处理。select方法在事件发生时返回,其中返回值是要处理的通道 个数。但是这个值并无实际意义。

一旦感兴趣的事件发生,你必须决定发生何事,并据此做出反应。对于这里用选择 器注册的通道,你已表示对OP_CONNECTOP_READ操作都 有兴趣,所以你知道它只能是这些事件之一。那么,你要做的就是通过 selectedKeys方法获得就绪对象的Set,如此反复进行。 Set中的元素是一个SelectionKey,检查它是两个感兴 趣事件中的isConnectable还是isReadable

下面是至此为止的循环基本框架:

while (selector.select(500) > 0) {
// Get set of ready objects
Set readyKeys = selector.selectedKeys();
Iterator readyItor = readyKeys.iterator();

// Walk through set
while (readyItor.hasNext()) {

// Get key from set
SelectionKey key = 
(SelectionKey)readyItor.next();

// Remove current entry
readyItor.remove();

// Get channel
SocketChannel keyChannel = 
(SocketChannel)key.channel();

if (key.isConnectable()) {

} else if (key.isReadable()) {

}
}
}

removeremove方法的调用需要做一点解释。在处理通道的就绪集合 时,它们可能会发生变化。所以当正在处理某个通道时,应该将其删除。删除操作并 不会触发ConcurrentModificationException异常。这里还为 select调用设置了一个超时时长,这样如果无事可做,它不会永远等 下去。还有一个调用,它从这里的关键字中获取通道。每次操作都需要用到它。

对于这个程序范例,你正在做的事等价于读取一个HTTP连接,所以除了连接,还 需要发送初始化的HTTP请求。基本上,一旦知道连接已建立,给该站点的root发送一 个GET请求就可以了。当选择器报告说该通道可以连接时,也许连接工作尚未完成。 所以,你应该通过isConnectionPending随时检查连接是否挂起,如果 是的话就调用finishConnect。连上之后,就可以写入该通道了,但必 须使用ByteBuffer,而不是更熟悉的I/O流。

下面是连接代码:

// OUTSIDE WHILE LOOP
Charset charset = 
Charset.forName("ISO-8859-1");
CharsetEncoder encoder = charset.newEncoder();

// INSIDE if (channel.isConnectable())
// Finish connection
if (keyChannel.isConnectionPending()) {
keyChannel.finishConnect();
}

// Send request
String request = "GET / \r\n\r\n";
keyChannel.write
(encoder.encode(CharBuffer.wrap(request)));

读套接字通道类似于读文件通道。除了一点例外。在读取套接字时,缓存好像 不会满似的。这并不重要,因为你要读的是已经准备好的内容。

// OUTSIDE WHILE LOOP
CharsetDecoder decoder = charset.newDecoder();
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
CharBuffer charBuffer = CharBuffer.allocate(1024);

// INSIDE if (channel.isReadable())
// Read what's ready in response
keyChannel.read(buffer);
buffer.flip();

// Decode buffer
decoder.decode(buffer, charBuffer, false);

// Display
charBuffer.flip();
System.out.print(charBuffer);

// Clear for next pass
buffer.clear();
charBuffer.clear();

加入必要的异常处理代码后,你就有了一个自己的套接字阅读器。一定要在 finally子句中close(关闭)通道,以确保释放其资源, 即使在发生异常的时候。下面是完整的客户机代码:

import java.io.*;
import java.net.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.charset.*;
import java.util.*;


public class NonBlockingReadURL {
static Selector selector;

public static void main(String args[]) {
String host = args[0];
SocketChannel channel = null;

try {

// Setup
InetSocketAddress socketAddress = 
new InetSocketAddress(host, 80);
Charset charset = 
Charset.forName("ISO-8859-1");
CharsetDecoder decoder = 
charset.newDecoder();
CharsetEncoder encoder = 
charset.newEncoder();

// Allocate buffers
ByteBuffer buffer = 
ByteBuffer.allocateDirect(1024);
CharBuffer charBuffer = 
CharBuffer.allocate(1024);

// Connect
channel = SocketChannel.open();
channel.configureBlocking(false);
channel.connect(socketAddress);

// Open Selector
selector = Selector.open();

// Register interest in when connection
channel.register(selector, 
SelectionKey.OP_CONNECT | 
SelectionKey.OP_READ);

// Wait for something of interest to happen
while (selector.select(500) > 0) {
// Get set of ready objects
Set readyKeys = selector.selectedKeys();
Iterator readyItor = readyKeys.iterator();

// Walk through set
while (readyItor.hasNext()) {

// Get key from set
SelectionKey key = 
(SelectionKey)readyItor.next();

// Remove current entry
readyItor.remove();

// Get channel
SocketChannel keyChannel = 
(SocketChannel)key.channel();

if (key.isConnectable()) {

// Finish connection
if (keyChannel.isConnectionPending()) {
keyChannel.finishConnect();
}

// Send request
String request = 
 "GET / \r\n\r\n";
keyChannel.write(encoder.encode(
CharBuffer.wrap(request)));

} else if (key.isReadable()) {

// Read what's ready in response
keyChannel.read(buffer);
buffer.flip();

// Decode buffer
decoder.decode(buffer, 
charBuffer, false);

// Display
charBuffer.flip();
System.out.print(charBuffer);

// Clear for next pass
buffer.clear();
charBuffer.clear();

} else {
System.err.println("Ooops");
}
}
}
} catch (UnknownHostException e) {
System.err.println(e);
} catch (IOException e) {
System.err.println(e);
} finally {
if (channel != null) {
try {
channel.close();
} catch (IOException ignored) {
}
}
}
System.out.println();
}
}

非独占式服务器

最后一段是让Web服务器使用NIO软件包。有了新的I/O能力,你可以创建一个不需 要每个连接建立一个线程的Web服务器。当然,对于长时间处理的任务,可以将线程 放入队列,但你要做的一切只是select,然后等到事件发生,而不必 让所有线程分别等待。

使用通道的服务器的基本设置包括:调用bindServerSocketChannel连接到InetSocketAddress

ServerSocketChannel channel = 
ServerSocketChannel.open();
channel.configureBlocking(false);
InetSocketAddress isa = 
new InetSocketAddress(port);
channel.socket().bind(isa);

其余事情几乎与客户机的读操作相同,只不过这次需要注册OP_ACCEPT 关键字,当选择器通知你事件发生时,检查是不是isAcceptable,然后 获取一个ServerSocketChannel而不是SocketChannel。 就这么简单。

以下代码范例只不过演示了这有多简单。它就是你的基本单线程服务器,对每个请 求送回一段预先录入的文本消息。只需用telnet连接到端口9999就能看 到响应。

import java.io.*;
import java.net.*;
import java.nio.*;
import java.nio.channels.*;
import java.util.*;

public class Server {
private static int port = 9999;
public static void main(String args[]) 
throws Exception {
Selector selector = Selector.open();

ServerSocketChannel channel = 
ServerSocketChannel.open();
channel.configureBlocking(false);
InetSocketAddress isa = new InetSocketAddress(port);
channel.socket().bind(isa);

// Register interest in when connection
channel.register(selector, SelectionKey.OP_ACCEPT);

// Wait for something of interest to happen
while (selector.select() > 0) {
// Get set of ready objects
Set readyKeys = selector.selectedKeys();
Iterator readyItor = readyKeys.iterator();

// Walk through set
while (readyItor.hasNext()) {

// Get key from set
SelectionKey key = 
(SelectionKey)readyItor.next();

// Remove current entry
readyItor.remove();

if (key.isAcceptable()) {
// Get channel
ServerSocketChannel keyChannel =
(ServerSocketChannel)key.channel();

// Get server socket
ServerSocket serverSocket = keyChannel.socket();

// Accept request
Socket socket = serverSocket.accept();

// Return canned message
PrintWriter out = new PrintWriter
(socket.getOutputStream(), true);
out.println("Hello, NIO");
out.close();
} else {
System.err.println("Ooops");
}

}
}
// Never ends
}
}

收到请求之后,从套接字获得通道,使其变为非独占,还是用选择器注册该通道。 这个框架只提供了Web服务器内NIO类的基本用法。有关创建多线程服务器的其他信息, 请参见 资源一节中引用的JavaWorld文章。

结论

J2SE 1.4 Beta版引入的新I/O特性,提供了用以提高程序性能的激动人心的新途 径。通过利用新功能的优点,程序不仅会更快,可缩放性也会大得多,因为你不必再 为每个连接建立一个线程这样的工作操心。在服务器端这一点尤为重要,它极大地提 升了可支持同时连接所可能达到的数量。

注意: 如果你看到JSR 51中的功能列表,就会发现里面提到支持扫描和格式化, 类似于C的printf。这项功能不在1.4 beta版中出现,而将保留到以后 的版本中。

资源

关于作者

John Zukowski在 JZ Ventures公司领导战略性Java咨询工作 他的最新著作是即将出版的 Java Collections Definitive Guide to Swing for Java 2(第二版)。请期待2002年出版的 Learn Java with JBuilder 6吧。John的联系方式是 mailto:jaz@zukowski.net?Subject=New I/O Article。.


版权所有:UML软件工程组织