Earth Guardian

You are not LATE!You are not EARLY!

0%

Java NIO 基础

NIO: Non-blocking IO,是指 jdk1.4 及以上版本里提供的 New IONIO 弥补了原来的 I/O 的不足,它在标准 Java 代码中提供了高速的、面向块的 I/O,所有数据都是用缓冲区处理的。

基本概念

NIO 将最耗时的 I/O 操作(即填充和提取缓冲区)转移回操作系统,因而可以极大地提高速度。原来的 I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。

流和块的区别

  • I/O
    面向流的 I/O 系统一次一个字节地处理数据。一个输入流产生一个字节的数据,一个输出流消费一个字节的数据。为流式数据创建过滤器非常容易,链接几个过滤器,以便每个过滤器只负责单个复杂处理机制的一部分,这样也是相对简单的。但是面向流的 I/O 通常相当慢。
  • I/O
    面向块的 I/O 系统以块的形式处理数据。每一个操作都在异步中产生或者消费一个数据块。按块处理数据比按(流式的)字节处理数据要快得多。但是面向块的操作较复杂。

NIO 的核心对象

  • Channel
    通道:是对原 I/O 包中的流的模拟。Channel 是一个对象,所有数据必须通过它读取和写入。默认是阻塞模式。
  • Buffer
    缓冲区:实质上是一个容器对象,所有的 NIO 数据都是通过它来处理的。发送给一个通道的所有对象都必须首先放到缓冲区中;同样从通道中读取的任何数据都要读到缓冲区中。

Channel

数据交互

永远不会将字节直接写入通道,或者直接从通道中读字节。通道中的数据总是要先读到一个 Buffer,或者总是要从一个 Buffer 中写入。

通道类型

通道与流的不同之处在于通道是双向的,而流是单向的(一个流必须是 InputStream 或者 OutputStream 的子类)。通道可以用于读、写或者同时用于读写。
因为它们是双向的,所以通道可以比流更好地反映底层操作系统的真实情况。特别是在 UNIX 模型中,底层操作系统通道是双向的。

常用 Channel

  • FileChannel
    从文件中读写数据,无法设置为非阻塞模式,它总是运行在阻塞模式下。
  • DatagramChannel
    通过 UDP 读写网络中的数据。
  • SocketChannel
    通过 TCP 读写网络中的数据。
  • ServerSocketChannel
    服务端,可以监听新进来的 TCP 连接。

分散/聚集 Scatter/Gather I/O

分散/聚集 Scatter/Gather I/O:用于描述从通道中读取或者写入到通道的操作,但是使用多个而不是单个缓冲区来保存数据的读写方法。
一个分散的读取就像一个常规通道读取,只不过它是将数据读到一个缓冲区数组中而不是读到单个缓冲区中。同样地,一个聚集写入是向缓冲区数组而不是向单个缓冲区写入数据。分散/聚集 I/O 对于将数据流划分为单独的部分很有用,这有助于实现复杂的数据格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface ScatteringByteChannel
extends ReadableByteChannel {
public long read(ByteBuffer[] dsts, int offset, int length)
throws IOException;
public long read(ByteBuffer[] dsts) throws IOException;
}

public interface GatheringByteChannel
extends WritableByteChannel {
public long write(ByteBuffer[] srcs, int offset, int length)
throws IOException;
public long write(ByteBuffer[] srcs) throws IOException;
}
  • 分散读
    分散读取会自动找到有空间接受数据的第一个缓冲区,在这个缓冲区填满后,才能移动到下一个缓冲区。在实际运用中,适合消息长度固定的例子。如网络应用程序中,每一个消息被划分为固定长度的头部和固定长度的正文。可以创建一个刚好可以容纳头部的缓冲区和另一个刚好可以容难正文的缓冲区。使用分散读取来向它们读入消息时,头部和正文将整齐地划分到这两个缓冲区中。
  • 聚集写
    聚集写对于把一组单独的缓冲区中组成单个数据流写入同一个通道。将数据写入到通道中时,注意缓冲区只有 positionlimit之间的数据才会被写入,所以在实际运用中,可以写入长度不固定的消息。为了与上面的消息例子保持一致,可以使用聚集写入来自动将网络消息的各个部分组装为单个数据流,以便跨越网络传输消息。

Buffer

缓冲区实质上是一个数组,通常它是一个字节数组,也可以使用其他类型的数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。本质上是读写数据的这块内存被包装成 NIO Buffer 对象,并提供了一组方法,用来方便的访问该块内存。最常用的缓冲区类型是 ByteBuffer,可以在其底层字节数组上进行字节的获取和设置 get/set
每一种基本 Java 类型(除了 Boolean )都有一种缓冲区类型:

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

状态变量

状态变量是每一个读/写操作后,记录缓冲区的状态。通过记录和跟踪这些变化,缓冲区能够内部地管理自己的资源。缓冲区有两种模式:读模式和写模式。如下三个变量,记录了缓冲区在这两个模式下的状态:
0039-buffers-modes.png

  • capacity
    可以储存在缓冲区中的最大数据容量。
  • position
    写模式:表示当前位置,初始化为 0,最大值为 capacity - 1
    读模式:从该位置开始读,在缓冲区从写模式切换到读模式时被重置为 0。
  • limit
    写模式:表示最多能往缓冲区写入多少数据,写模式下 limit = capacity
    读模式:表示最多能读到多少数据。写模式切换为读模式时,limit 被设置为写模式下的 position,而 position 会被重置为 0,即能读到之前写入的所有数据。

其中:position <= limit <= capacity。读写模式切换方法 flip() 的源码:

1
2
3
4
5
6
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}

缓冲区使用的基本步骤

  • 写入数据到 Buffer
  • 调用 flip() 方法,切换到读模式
  • Buffer 中读取数据
  • 调用 clear() 方法或者 compact() 方法,切换到写模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RandomAccessFile aFile = new RandomAccessFile("nio-data.txt", "rw");
FileChannel inChannel = aFile.getChannel();

//create buffer with capacity of 48 bytes
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf); //read into buffer.
System.out.println("bytesRead = " + bytesRead);

while (bytesRead != -1) {
buf.flip(); //make buffer ready for read
while (buf.hasRemaining()) {
System.out.print((char) buf.get()); // read 1 byte at a time
}
buf.clear(); //make buffer ready for writing
bytesRead = inChannel.read(buf);
}
aFile.close();

常见 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 缓冲区写模式切换到读模式
public Buffer flip(){...}
// 清空缓冲区
public Buffer clear(){...}
// 清除已经读过的数据,任何未读的数据都被移到缓冲区的起始处
// 新写入的数据将放到缓冲区未读数据的后面
public abstract ByteBuffer compact();
// 读取数据
public abstract byte get();
public abstract byte get(int index);
public ByteBuffer get(byte[] dst, int offset, int length) {...}
public ByteBuffer get(byte[] dst) {...}
// 写入数据
public abstract ByteBuffer put(byte b);
public abstract ByteBuffer put(int index, byte b);
public ByteBuffer put(ByteBuffer src) {...}
public ByteBuffer put(byte[] src, int offset, int length) {...}
public final ByteBuffer put(byte[] src) {...}
// 重读缓冲区
public final Buffer rewind() {...}
// 标记及重置回标记处
public final Buffer mark() {...}
public final Buffer reset() {...}
// 判断相等
public boolean equals(Object ob) {...}
// 比较剩余元素
public int compareTo(ByteBuffer that) {...}
// 将缓冲区转换为只读,这个方法返回一个与原缓冲区完全相同的缓冲区(并与其共享数据)
// 只不过它是只读的,不能将只读的缓冲区转换为可写的缓冲区
public abstract ByteBuffer asReadOnlyBuffer();

分片

片:是缓冲区的子缓冲区,根据现有的缓冲区,从当前位置创建一个子缓冲区,这个新创建的缓冲区与原来的缓冲区共享同一个底层数据数组。
public abstract ByteBuffer slice();

读写示例

读和写是 I/O 的基本过程。从一个通道中读取很简单:只需创建一个缓冲区,然后让通道将数据读到这个缓冲区中;写入也相当简单:创建一个缓冲区,用数据填充它,然后让通道用这些数据来执行写入操作。在 NIO 系统中,任何时候执行一个读写操作,都是通道和缓冲区的读写交互。

  • FileInputStream 获取 Channel
  • 创建 Buffer
  • 将数据从 Channel 读到 Buffer

我们不需要告诉通道要读多少数据到缓冲区中,每一个缓冲区都有内部统计机制,它会跟踪已经读了多少数据以及还有多少空间可以容纳更多的数据。

  • FileOutputStream 获取 Channel
  • 创建 Buffer,并填充被写的数据
  • 将数据从 Buffer 写入 Channel

这里同样不需要告诉通道要写入多数据,缓冲区的内部统计机制会跟踪它包含多少数据以及还有多少数据要写入。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FileInputStream fin = new FileInputStream( "readandshow.txt" );
FileChannel fc = fin.getChannel();
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
fc.read( buffer );

FileOutputStream fout = new FileOutputStream( "writesomebytes.txt" );
FileChannel fc = fout.getChannel();
ByteBuffer buffer = ByteBuffer.allocate( 1024 );

for (int i=0; i<message.length; ++i) {
buffer.put( message[i] );
}
buffer.flip();
fc.write( buffer );

FileChannel

FileChannel 是一个连接到文件的通道,可以通过文件通道读写文件。一般情况下,无法直接打开一个 FileChannel,需要通过使用 InputStream, OutputStream, RandomAccessFile 来获取 FileChannel 实例。

1
2
3
4
5
6
7
8
FileInputStream fin = new FileInputStream( "readandshow.txt" );
FileChannel fc = fin.getChannel();

FileOutputStream fout = new FileOutputStream( "writesomebytes.txt" );
FileChannel fc = fout.getChannel();

RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
FileChannel inChannel = aFile.getChannel();

常见 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 读写
public abstract int read(ByteBuffer dst) throws IOException;
public abstract long read(ByteBuffer[] dsts, int offset, int length)
throws IOException;
public final long read(ByteBuffer[] dsts) throws IOException {...}
public abstract int write(ByteBuffer src) throws IOException;
public abstract long write(ByteBuffer[] srcs, int offset, int length)
throws IOException;
public final long write(ByteBuffer[] srcs) throws IOException {...}
// 关闭通道
public final void close() throws IOException {...}

// 通道当前 position,在特定位置读写
public abstract long position() throws IOException;
public abstract FileChannel position(long newPosition) throws IOException;
public abstract int read(ByteBuffer dst, long position) throws IOException;
public abstract int write(ByteBuffer src, long position) throws IOException;

// 通道文件的大小
public abstract long size() throws IOException;
// 截取文件
public abstract FileChannel truncate(long size) throws IOException;
// 将通道里尚未写入磁盘的数据,强制写到磁盘上
public abstract void force(boolean metaData) throws IOException;

通道之间数据传输

如果两个通道中有一个是 FileChannel,那你可以直接将数据从一个通道中传输到另外一个通道。

1
2
3
4
public abstract long transferTo(long position, long count, WritableByteChannel target)
throws IOException;
public abstract long transferFrom(ReadableByteChannel src, long position, long count)
throws IOException;

参数解析:

  • WritableByteChannel
    可写通道,即目标通道,将当前通道数据传输到目标通道。
  • ReadableByteChannel
    可读通道,即源通道,源通道数据传输到当前通道。
  • position
    文件传输开始的位置。
  • count
    传输的最大字节数。

示例:

1
2
3
4
5
6
7
8
9
10
11
RandomAccessFile fromFile = new RandomAccessFile("fromFile.txt", "rw");
FileChannel fromChannel = fromFile.getChannel();
RandomAccessFile toFile = new RandomAccessFile("toFile.txt", "rw");
FileChannel toChannel = toFile.getChannel();

long position = 0;
long count = fromChannel.size();

toChannel.transferFrom(position, count, fromChannel);
// 或者使用 transferTo
// fromChannel.transferTo(position, count, toChannel);

文件锁

文件锁定是一个复杂的操作,特别不同的操作系统是以不同的方式来实现锁的,为了尽可能保持代码的可移植性:

  • 只使用排它锁
  • 将所有的锁视为劝告式的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 文件锁,只能通过 FileChannel.java 来获取
// 阻塞锁,shared 为 true 表示共享锁,为 false 表示排他锁
public abstract FileLock lock(long position, long size, boolean shared)
throws IOException;
// 获取排他锁
public final FileLock lock() throws IOException {...}
// 非阻塞锁
public abstract FileLock tryLock(long position, long size, boolean shared)
throws IOException;
// 排他锁
public final FileLock tryLock() throws IOException {...}

// 文件锁
public abstract class FileLock implements AutoCloseable {
// 释放锁
public abstract void release() throws IOException;
public final void close() throws IOException {...}
...
}

将文件映射到内存

内存映射文件,是由一个文件到一块内存的映射。使用内存映射文件处理存储于磁盘上的文件时,将不必再对文件执行 I/O 操作,使得内存映射文件在处理大数据量的文件时能有很高的性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static class MapMode {
// Mode for a read-only mapping.
public static final MapMode READ_ONLY
= new MapMode("READ_ONLY");

// Mode for a read/write mapping.
public static final MapMode READ_WRITE
= new MapMode("READ_WRITE");

// Mode for a private (copy-on-write) mapping.
public static final MapMode PRIVATE
= new MapMode("PRIVATE");
...
}

// 返回一个缓冲区,可以高性能的读写这块区域
public abstract MappedByteBuffer map(MapMode mode, long position, long size)
throws IOException;

字符集

字符集用来把 Unicode 字符编码和其它字符编码互转。Charset 是十六位 Unicode 字符序列与字节序列之间的一个命名的映射。要读和写文本,我们要分别使用 CharsetDecoderCharsetEncoder,即编码器和解码器。CharsetDecoder 用于将逐位表示的一串字符转换为具体的 char 值,而 CharsetEncoder 用于将字符转换回位。
Java 实现都要求对以下字符编码提供完全的支持:

  • US-ASCII
  • ISO-8859-1
  • UTF-8
  • UTF-16BE
  • UTF-16LE
  • UTF-16

Pipe

NIO 管道是 2 个线程之间的单向数据连接。Pipe 有两个通道:

  • source 通道:数据从该通道读取
  • sink 通道:数据从该通道写入

0039-pipe-internals.png

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static abstract class SourceChannel
extends AbstractSelectableChannel
implements ReadableByteChannel, ScatteringByteChannel
{...}

public static abstract class SinkChannel
extends AbstractSelectableChannel
implements WritableByteChannel, GatheringByteChannel
{...}

// 打开一个管道
public static Pipe open() throws IOException {...}
// 获取 source 通道
public abstract SourceChannel source();
// 获取 sink 通道
public abstract SinkChannel sink();
}

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Pipe pipe = Pipe.open();
Pipe.SinkChannel sinkChannel = pipe.sink();
String newData = "New String to write to file..." + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());

buf.flip();

while(buf.hasRemaining()) {
sinkChannel.write(buf);
}

Pipe.SourceChannel sourceChannel = pipe.source();
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = sourceChannel.read(buf);

NIOIO 的比较

简述

  • IO:面向流,阻塞
  • NIO:面向缓冲区块操作,非阻塞

面向流和面向缓存区

  • 面向流
    面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。也不能前后移动流中的数据,如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。
  • 面向缓冲区
    数据读取到缓冲区中,并可在缓冲区中前后移动,但是维护好缓冲区的状态变量。

阻塞和非阻塞

  • 阻塞
    流是阻塞的,在 read, write 时线程被阻塞,直到有一些数据被读取或数据完全写入,该线程在此期间不能再干任何事情。
  • 非阻塞
    线程从通道读取或写入数据,但是当数据没准备好时直接返回不会阻塞。所以直至数据可用前,线程同时可以去做别的事情。线程通常将非阻塞 IO 的空闲时间用于在其它通道上执行 IO 操作,所以一个单独的线程现在可以管理多个输入和输出通道。

选择器

选择器允许单个线程监视多个通道,但是解析数据时会更复杂。常见应用为多连接但是传输少量数据,如聊天系统;或者少量连接大量数据用来做服务器。

参考文档

  1. Java 源码
  2. NIO 入门
  3. Java NIO系列教程