NIO新功能Top 10(下)

7: 字节擦试

如果你曾经处理过跨平台问题,你可能会担心之前示例中的字节顺序。CharBuffer视图会将字节按照16比特一组排列好,但是哪边是高字节哪边是低字节呢?字节的组织顺序就是我们平常所说的“端”。靠前的字节存储在低地址称为“大端”;相反,靠后的字节存在前面就是小端。

缓冲区-大端

图5:缓冲区-大端

缓冲区-小端

图6:缓冲区-小端

前面的例子中,16比特的Unicode字符到底是存储为小端(UTF-16LE)还是大端(UTF-16LE)呢?实际上它们能够以任何一种方式存储,因此我们需要知道这个缓冲区视图是怎样从字节映射到字符。

每一个缓冲区对象都具有字节顺序。除了ByteBuffer任何视图的字节顺序都是只读的,而ByteBuffer对象可以随时改变字节顺序。设置字节顺序会对所有基于ByteBuffer对象创建的视图的字节顺序产生影响。因此,如果我们知道文件中Unicode数据用小端法被编码为UTF-16LE,我们可以在创建CharBuffer前这样设置ByteBuffer的字节顺序:

byteBuffer.order (ByteOrder.LITTLE_ENDIAN);

CharBuffer charBuffer = byteBuffer.asCharBuffer();

新创建的缓冲区视图继承了ByteBuffer的字节顺序设置,之后如果改变ByteBuffer字节顺序不会对该视图产生影响。初始ByteBuffer字节序总是被设置为大端,而不考虑本地硬件平台上的字节序。

如果我们不知道文件中Unicode的字节序呢?如果该文件是用可移植的UTF-16编码,文件的头两个字节会包含字节序的标识(如果是直接编码为UTF-16LE或者UTF-16BE,你就得事先知道字节序)。如果测试过字节序标识,你需要在创建CharBuffer视图前设置合适的字节序。

一个ByteBuffer对象的字节序还会影响数据元素视图的字节擦拭(getInt()、getLong()、getFloat()等等)。缓冲区字节序设置在调用时会影响字节如何组合成返回值,或者破坏缓冲区存储。

6:直接缓冲区

封装在缓冲区中数据元素可以采用下列存储方式的一种:通过分配创建一个缓冲区对象的私有数组,或者包装你提供的数组,或者以直接缓冲区的方式存储在JVM内存堆以外的本地内存空间中。当你调用ByteBuffer.allocateDirect()创建一个直接缓冲区时,会分配本地系统内存并且用一个缓冲区对象来包装它。

直接缓冲区的主要用途是用做通道(channel)I/O。通道实现能够用直接缓冲区的本地内存空间来设置系统级I/O操作。这是一个强大的新功能,也是NIO效率的关键。虽然这些I/O函数是底层操作不能直接使用,但是你可以利用通道的直接缓冲区功能提升效率。

一些新的JNI方法也能够用本地内存来保存缓冲区数据。这是第一次让Java对象能够访问用本地代码分配的内存空间。1.4之前的本地代码能够在JVM堆上访问数据(谨慎的说——有许多限制),但是Java代码不能够访问本地代码分配的内存。

现在,不仅JNI代码能够定位Java使用ByteBuffer.allocateDirect()创建的本地内存空间地址,而且它还能够分配内存(例如:使用malloc)并且通过回调在JVM中把这个内存空间包装成新的ByteBuffer对象(JNI中方法是NewDirectByteBuffer())。

让人真正兴奋的地方是ByteBuffer对象能够包装任何本地代码的内存地址,甚至JVM以外的地址空间。一个例子是创建直接ByteBuffer对象来封装显卡的内存。这样的缓冲区允许纯Java代码直接读写显卡内存,不用做系统调用或者缓冲区拷贝。完全用Java编写显示驱动!你需要的仅仅是使用JNI来访问显示内存并且返回一个ByteBuffer对象。在NIO之前这是不可能完成的。

5:内存映射文件

让我们用一种特殊的ByteBuffer——MappedByteBuffer来继续讨论用任意内存空间包装ByteBuffer对象的主题。在大多数操作系统上,可以通过mmap系统调用(或者相似的操作)在一个已打开的文件描述符上做内存映射文件。调用mmap返回一个指向内存段的指针,实际上代表文件的内容。从内存区域获取数据实际上就是从相应文件的偏移位置处返回数据。而修改则会将文件从内存空间写入磁盘。

内存映射介绍

图7:内存映射介绍

内存映射文件有两个比很大好处。首先,“内存”通常不占用虚拟内存空间,或者更为确切的说一个文件映射在磁盘上备份的虚拟内存空间。这就意味着不需要为映射文件分配正式的页空间,因为这里的分页区域就是文件自身。如果你使用传统的方式打开文件并读入内存会消耗相应数量的页空间,因为你正把数据拷贝到内存。其次,多个相同的文件映射共享相同的虚地址空间。理论上,对于一个500M的文件100个不同的进程可以建立100个映射,每个进程在内存中都有整个500M的数据但系统总的内存消耗不变(注:即仍然只占用500M)。文件片段会作为引用被带入内存并与RAM竞争,但是页空间不会消耗。

在图7中,用户空间的其他进程通过相同的文件系统缓存(因此也是磁盘上相同的文件)会映射到同一块物理内存空间。每个进程都会看到其他进程对这块空间所做的改变。这可以用作持久化以及共享内存。操作系统随着虚拟内存子系统行为的改变而改变,所以你的性能也会跟着变。

调用map方法会在一个打开的FileChannel对象上创建MappedByteBuffer实例。MappedByteBuffer类有一组管理缓存和刷新更新文件的附加方法。

在NIO之前,一定得采用平台相关的本地代码来做内存映射文件。现在可以用任何纯Java程序来使用内存映射,操作简单并且可移植。

4:分散读和聚集写

下面是一段大家非常熟悉的代码:

byte [] byteArray = new byte [100];
...
int bytesRead = fileInputStream.read (byteArray);

这段代码会从流向一个字节数组读入数据。下面是采用ByteBuffer和FileChannel对象(把例子变为NIO)的等价读操作。

ByteBuffer byteBuffer = ByteBuffer.allocate (100);
...
int bytesRead = fileChannel.read (byteBuffer);

下面是一些常用模式:

ByteBuffer header = ByteBuffer.allocate (32);
ByteBuffer colorMap = ByteBuffer (256 * 3)
ByteBuffer imageBody = ByteBuffer (640 * 480);

fileChannel.read (header);
fileChannel.read (colorMap);
fileChannel.read (imageBody);

这段代码中三个独立的read()加载一个假定的图片文件,它工作得很好。但是难道就不能在一个read请求中告诉它把前32字节放在header缓冲区,接下来的768字节放到colorMap缓冲区,最后的放到imageBody中吗?

没问题,这很容易做到。大多数NIO通道支持分散/聚集,即向量I/O。分散读上面的缓冲区可以这样做:

ByteBuffer [] scatterBuffers = { header, colorMap, imageBody };

fileChannel.read (scatterBuffers);

这段代码用缓冲区数组来代替传递单个缓冲区对象。通道按顺序填充每个缓冲区直到所有缓冲区满或者没有数据可读为止。聚集写也是以类似的方式完成,数据从列表中的每个缓冲区中顺序取出来发送到通道就好像顺序写入一样。

当读写数据划分为固定大小的、逻辑上不同的段时,分散读和聚集写可以带来实际的性能提升。通过传入一组缓冲区列表来优化整个传输(例如用多个CPU)减少系统调用。

聚集写可以组合几个缓冲区中的结果。例如,一个HTTP响应会用一个只读缓冲区包含对每个响应相同的静态header、一些为特别响应而准备的动态缓冲区以及作为响应体用来连接的文件的MappedByteBuffer对象。一个给定的缓冲区可能会在一个或多个列表、同一个缓冲区的多个视图中出现。

3:直接通道传输

你是不是没有注意到,当你需要把数据拷贝到文件或者从文件拷贝出来时总是一遍又一遍地写同样的拷贝循环?总是一样的故事:读一块数据到缓冲区,然后立即写回到某个地方。你并没用用这些数据做什么事。但是为什么要一遍又一遍的把它们拿出来又放回去呢?为什么要重复发明轮子呢?

这里有一个想法,只需要告诉某些类“把数据从这个文件移动到另一个”或者“把所有从那个socket出来的数据写到这个文件”,难道这种方式不是更好吗?好吧,感谢“直接缓冲区传输”奇迹,现在你可以这么做了。

public abstract class FileChannel
extends AbstractChannel
implements ByteChannel, GatheringByteChannel, ScatteringByteChannel
{
   // This is a partial API listing

   public abstract long transferTo (long position, long count, 
      WritableByteChannel target)

   public abstract long transferFrom (ReadableByteChannel src,	
      long position, long count)
}

通道传输让你连接两个通道,这样数据可以直接从一个传到另一个而无需你进行任何干预。因为transferTo()和transferFrom()方法属于FileChannel类,FileChannel对象必须是一个通道传输的源或者目的(例如,你不能从一个socket传到另一个socket)。但是传输的另一端必须是合适的ReadableByteChannel或者WritableByteChannel。

基于操作系统提供的支持,整个通道传输能够在内核中完成。这不仅缓解了繁重的拷贝工作,而且绕过了JVM!底层系统调用万岁!甚至在操作系统内核不支持的情况下,利用这些方法同样可以把你从写拷贝循环中拯救出来。还有个好处,用本地代码或者其他优化来移动数据肯定要比你自己写的Java代码要快。最好的事情就是:不写代码就不会有bug。

2:非阻塞套接字

传统的Java I/O模型缺少非阻塞I/O从一开始就已经惹眼了,最后终于有了NIO。从SelectableChannel继承的Channel类可以用configureBlocking替换成为非阻塞模式。在J2SE1.4中只有套接字通道(SocketChannel, ServerSocketChannel和DatagramChannel)可以换为非阻塞模式,而FileChannel不能设为非阻塞模式。

当通道设置为非阻塞时,read()或者write()调用不管有没有传输数据总是立即返回。这样线程总是可以无停滞地检查数据是否已经准备好。

ByteBuffer buffer = ByteBuffer.allocate (1024);
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking (false);

...

while (true) {
   ...
   if (socketChannel.read (buffer) != 0) {
      processInput (buffer);
   }
...
}

上面的代码代表了一个典型的轮询过程:非阻塞读不断的尝试,如果数据被读取了,就会处理它。从read()调用返回0表示没有可用的数据,线程通过主循环不断的滚动,在每次循环时做该做的事。

1:多路复用I/O

女士们先生们,接下来就是NIO10大新功能之首。

上一节示例代码中用轮询来确定在非阻塞通道上输入已经就绪。虽然有许多适合的场景,但是通常轮询并不十分有效。如果在处理循环主要是做别的事情并周期性的检查输入时,轮询也许是一个适合的选择。

但是如果应用程序的主要目的是响应许多不同连接上的输入(例如web服务器),轮询就不合适了。在响应式的应用中,你需要快速的轮转。然而快速轮询只会毫无意义地消耗大量的CPU指令周期并产生大量无效的I/O请求。I/O请求会差生系统调用,系统调用造成上下文切换,而上下文切换是相当费时的操作

当我们使用单线程来管理多个I/O通道时,就是所谓的多路复用I/O。对于多路复用,你希望管理线程一直阻塞,直到其中一个通道上有输入可用。但是,我们刚刚不还在为非阻塞I/O而手舞足蹈吗?我们之前就有阻塞I/O了,这个是……?

问题在于使用传统的阻塞模型,单线程是不能多路复用一组I/O流的。如果没有非阻塞模型,当线程尝试在套接字上读但没有数据时就会导致线程被阻塞,这样线程就没法处理其他流的可用数据了。综合起来会因为一个空闲流而将所服务的其他流挂起。

在Java的世界中,这个问题的解决方案就是让每个活跃的流有一个线程。当一个流上面的数据可用时相应的线程就会醒来,读取并处理数据,然后再度阻塞在read()方法直到有更多数据。实际上这种处理是有效的,但是并不具备扩展性。线程(重量级实现)乘以相同速率的套接字(相对轻量级)。我们可以用池技术或者是复用线程(更复杂而且代码需要调试)来降低线程创建的压力,但是主要问题是当线程数量太大时线程调度器将面临压力。JVM线程管理机制被设计为处理几十个而不是成百上千个线程。甚至空闲线程也会一定程度上拖慢速度。如果多个处理流的线程仅有一个通用的数据处理对象就会形成瓶颈,每个流一个线程的方式会将并发问题复杂化。

使用多路复用大量套接字的正确的方式是读选择器(NIO Selector类)。相比轮询或者每个流一个线程选择器是最好的方式,因为只需要一个线程就能够简单的监控大量的套接字。当任何流上有数据时(读的部分)线程也能够选择阻塞唤醒的方式(我们又回到了阻塞),并且精确的接收就绪流上来的信息(选择部分)。

读选择器建立在非阻塞模式上,所以只有通道是非阻塞模式它才会工作。如果你喜欢,还可以让实际的选择处理也变成非阻塞的。重点是Selector对象会帮助你卖力地检查大量通道的状态,你只需要操作选择的结果而不用亲自去管理每一个通道。

你创建一个Selector实例,然后在其上注册一个或者多个非阻塞通道,然后声明你感兴趣的是什么事件。下面是一个选择器的例子。在这个例子中,同一个循环里传入ServerSocketChannel对象的连接作为活动套接字连接来提供服务(更完整的例子在我的书中)。

ServerSocketChannel serverChannel = ServerSocketChannel.open();
Selector selector = Selector.open();

serverChannel.socket().bind (new InetSocketAddress (port));
serverChannel.configureBlocking (false);
serverChannel.register (selector, SelectionKey.OP_ACCEPT);

while (true) {
   selector.select();

   Iterator it = selector.selectedKeys().iterator();

   while (it.hasNext()) {
      SelectionKey key = (SelectionKey) it.next();

      if (key.isAcceptable()) {
         ServerSocketChannel server = (ServerSocketChannel) key.channel();
         SocketChannel channel = server.accept();

         channel.configureBlocking (false);
         channel.register (selector, SelectionKey.OP_READ);
      }

      if (key.isReadable()) {
         readDataFromSocket (key);
      }

      it.remove();
   }
}

这种方式远比每个线程一个套接字简单且更具有扩展性,而且也更容易写和调试代码。最重要的是,它大量减少了服务和管理大量套接字的工作。相比NIO中的其他新特性,选择器更多的让操作系统来代理它繁重的工作。这就减少了JVM大量的工作,由于JVM不再花时间而是让操作系统来做这些事,所以能够释放内存和CPU资源并支持大规模扩展。

总结

好了,以上就是可以用J2SE1.4中的NIO来做之前Java不能完成的10件事。但是这并不是完整的清单,还有着更多其它内容我没有提到,比如自定义字符集编码、管道、异步套接字连接、写拷贝映射文件等等。我需要整本书才能讲完这些内容。;-)

我希望这些简短的回顾会让你对NIO可以做什么有更多的了解,以及你可以用NIO在你的项目中做什么。NIO不会取代传统I/O。不要简单把你的代码换成NIO,而是在你设计新应用时刻想着NIO。你会发现,当你可以熟练运用NIO时它会令人非常振奋。

 

原文链接: onjava 翻译: ImportNew.com - 李维
译文链接: http://www.importnew.com/4852.html
[ 转载请保留原文出处、译者和译文链接。]

关于作者: 李维

——我是程序员。 ——哦,程先生! ——客气了,叫我序员就好。(新浪微博:@idiot_fox

查看李维的更多文章 >>



相关文章

发表评论

Comment form

(*) 表示必填项

还没有评论。

跳到底部
返回顶部