10.3 NIO中的通道和缓冲区

NIO 中的缓冲区是对高性能 I/O 的一种低层抽象,为指定基本类型组成的线性序列提供容器。后面的示例都以处理 ByteBuffer 对象(最常见)为例。

10.3.1 ByteBuffer对象

ByteBuffer 对象是字节序列,理论上,在注重性能的场合中可以代替 byte[] 类型的数组。为了得到最好的性能,ByteBuffer 支持直接使用 JVM 所在平台提供的本地功能处理缓冲区。

这种方式叫作“直接缓冲区”,只要可能就会绕过 Java 堆内存。直接缓冲区在本地内存中分配,而不是在标准的 Java 堆内存中。而且,垃圾回收程序对待直接缓冲区的方式和普通的堆中 Java 对象不同。

若想创建 ByteBuffer 类型的直接缓冲区对象,可以调用工厂方法 allocateDirect()。除此之外,还有 allocate() 方法,用于创建堆中缓冲区,不过现实中不常使用。

创建字节缓冲区的第三种方式是打包现有的 byte[] 数组。这种方式创建的是堆中缓冲区,目的是以更符合面向对象的方式处理底层字节:

  1. ByteBuffer b = ByteBuffer.allocateDirect(65536);
  2. ByteBuffer b2 = ByteBuffer.allocate(4096);
  3. byte[] data = {1, 2, 3};
  4. ByteBuffer b3 = ByteBuffer.wrap(data);

字节缓冲区只能使用低层方式访问字节,因此开发者要手动处理细节,例如需要处理字节的字节顺序和 Java 整数基本类型的符号:

  1. b.order(ByteOrder.BIG_ENDIAN);
  2. int capacity = b.capacity();
  3. int position = b.position();
  4. int limit = b.limit();
  5. int remaining = b.remaining();
  6. boolean more = b.hasRemaining();

把数据存入缓冲区或从缓冲区中取出有两种操作方式:一种是单值操作,一次读写一个值;另一种是批量操作,一次读写一个 byte[] 数组或 ByteBuffer 对象,处理多个值(可能很多)。使用批量操作才能获得预期的性能提升:

  1. b.put((byte)42);
  2. b.putChar('x');
  3. b.putInt(0xcafebabe);
  4. b.put(data);
  5. b.put(b2);
  6. double d = b.getDouble();
  7. b.get(data, 0, data.length);

单值形式还支持直接处理缓冲区中绝对位置上的数据:

  1. b.put(0, (byte)9);

缓冲区这种抽象只存在于内存中,如果想影响外部世界(例如文件或网络),需要使用 Channel(通道)对象。Channel 接口在 java.nio.channels 包中定义,表示支持读写操作的实体连接。文件和套接字是两种常见的通道,不过我们要意识到,用于低延迟数据处理的自定义实现也属于通道。

通道在创建时处于打开状态,随后可以将其关闭。一旦关闭,就无法再打开。一般来说,通道要么可读要么可写,不能既可读又可写。若想理解通道,关键是要知道:

  • 从通道中读取数据时会把字节存入缓冲区

  • 把数据写入通道时会从缓冲区中读取字节

假如我们要计算一个大文件前 16M 数据片段的校验和:

  1. FileInputStream fis = getSomeStream();
  2. boolean fileOK = true;
  3. try (FileChannel fchan = fis.getChannel()) {
  4. ByteBuffer buffy = ByteBuffer.allocateDirect(16 * 1024 * 1024);
  5. while(fchan.read(buffy) != -1 || buffy.position() > 0 || fileOK) {
  6. fileOK = computeChecksum(buffy);
  7. buffy.compact();
  8. }
  9. } catch (IOException e) {
  10. System.out.println("Exception in I/O");
  11. }

上述代码会尽量使用本地 I/O,不会把字节大量复制进出 Java 堆内存。如果 computeChecksum() 方法实现得好,上述代码的性能就很高。

10.3.2 映射字节缓冲区

这是一种直接字节缓冲区,包含一个内存映射文件(或内存映射文件的一部分)。这种缓冲区由 FileChannel 对象创建,不过要注意,内存映射操作之后决不能使用 MappedByteBuffer 对象对应的 File 对象,否则会抛出异常。为了规避这种问题,我们可以使用处理资源的 try 语句,严格限制相关对象的作用域:

  1. try (RandomAccessFile raf =
  2. new RandomAccessFile(new File("input.txt"), "rw");
  3. FileChannel fc = raf.getChannel();) {
  4. MappedByteBuffer mbf =
  5. fc.map(FileChannel.MapMode.READ_WRITE, 0, fc.size());
  6. byte[] b = new byte[(int)fc.size()];
  7. mbf.get(b, 0, b.length);
  8. for (int i=0; i<fc.size(); i++) {
  9. b[i] = 0; // 这是一个副本,不会写入原文件
  10. }
  11. mbf.position(0);
  12. mbf.put(b); // 清空文件
  13. }

就算有了缓冲区,Java 在单个线程中同步执行大型 I/O 操作(例如,在文件系统之间传输 10G 数据)时还是会遇到一些限制。在 Java 7 之前,遇到这种操作时往往要自己编写多线程代码,而且要管理一个单独的线程执行后台复制操作。下面介绍 JDK 7 新添加的异步 I/O 功能。