需求

通道是 Java NIO 的核心内容之一,在使用上,通道需和缓存类(ByteBuffer)配合完成读写等操作。

与传统的流式 IO 中数据单向流动不同,通道中的数据可以双向流动。

通道既可以读,也可以写。

这里我们举个例子说明一下,我们可以把通道看做水管,把缓存看做水塔,把文件看做水库,把水看做数据。

当从磁盘中将文件数据读取到缓存中时,就是从水库向水塔里抽水。

当然,从磁盘里读取数据并不会将读取的部分从磁盘里删除,但从水库里抽水,则水库里的水量在无补充的情况下确实变少了。

当然,这只是一个小问题,大家不要扣这个细节哈,继续往下说。

当水塔中存储了水之后,我们可以用这些水烧饭,浇花等,这就相当于处理缓存的数据。

过了一段时间后,水塔需要进行清洗。这个时候需要把水塔里的水放回水库中,这就相当于向磁盘中写入数据。

通过这里例子,大家应该知道通道是什么了,以及有什么用。既然知道了,那么我们继续往下看。

Java NIO 出现在 JDK 1.4 中,由于 NIO 效率高于传统的 IO,所以 Sun 公司从底层对传统 IO 的实现进行了修改。

修改的方式就是在保证兼容性的情况下,使用 NIO 重构 IO 的方法实现,无形中提高了传统 IO 的效率。

基本操作

通道类型分为两种,一种是面向文件的,另一种是面向网络的。

基本类

具体的类声明如下:

FileChannel

DatagramChannel

SocketChannel

ServerSocketChannel

正如上列表,NIO 通道涵盖了文件 IO,TCP 和 UDP 网络 IO 等通道类型。

本文我们先来说说文件通道。

创建通道

FileChannel 是一个用于连接文件的通道,通过该通道,既可以从文件中读取,也可以向文件中写入数据。

与 SocketChannel 不同,FileChannel 无法设置为非阻塞模式,这意味着它只能运行在阻塞模式下。

在使用FileChannel 之前,需要先打开它。

由于 FileChannel 是一个抽象类,所以不能通过直接创建而来。

必须通过像 InputStream、OutputStream 或 RandomAccessFile 等实例获取一个 FileChannel 实例。

  [java]
1
2
3
4
5
6
7
8
FileInputStream fis = new FileInputStream(FILE_PATH); FileChannel channel = fis.getChannel(); FileOutputStream fos = new FileOutputStream(FILE_PATH); FileChannel channel = fis.getChannel(); RandomAccessFile raf = new RandomAccessFile(FILE_PATH , "rw"); FileChannel channel = raf.getChannel();

读写操作

读写操作比较简单,这里直接上代码了。

下面的代码会先向文件中写入数据,然后再将写入的数据读出来并打印。

代码如下:

  [java]
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
31
32
33
34
// 获取管道 RandomAccessFile raf = new RandomAccessFile(FILE_PATH, "rw"); FileChannel rafChannel = raf.getChannel(); // 准备数据 String data = "新数据,时间: " + System.currentTimeMillis(); System.out.println("原数据:\n" + " " + data); ByteBuffer buffer = ByteBuffer.allocate(128); buffer.clear(); buffer.put(data.getBytes()); buffer.flip(); // 写入数据 rafChannel.write(buffer); rafChannel.close(); raf.close(); // 重新打开管道 raf = new RandomAccessFile(FILE_PATH, "rw"); rafChannel = raf.getChannel(); // 读取刚刚写入的数据 buffer.clear(); rafChannel.read(buffer); // 打印读取出的数据 buffer.flip(); byte[] bytes = new byte[buffer.limit()]; buffer.get(bytes); System.out.println("读取到的数据:\n" + " " + new String(bytes)); rafChannel.close(); raf.close();

数据转移操作

我们有时需要将一个文件中的内容复制到另一个文件中去,最容易想到的做法是利用传统的 IO 将源文件中的内容读取到内存中,然后再往目标文件中写入。

现在,有了 NIO,我们可以利用更方便快捷的方式去完成复制操作。

FileChannel 提供了一对数据转移方法 - transferFrom/transferTo,通过使用这两个方法,即可简化文件复制操作。

  [java]
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
31
32
33
34
35
36
37
public static void main(String[] args) throws IOException { 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(); // 将 fromFile 文件找那个的数据转移到 toFile 中去 System.out.println("before transfer: " + readChannel(toChannel)); fromChannel.transferTo(position, count, toChannel); System.out.println("after transfer : " + readChannel(toChannel)); fromChannel.close(); fromFile.close(); toChannel.close(); toFile.close(); } private static String readChannel(FileChannel channel) throws IOException { ByteBuffer buffer = ByteBuffer.allocate(32); buffer.clear(); // 将 channel 读取位置设为 0,也就是文件开始位置 channel.position(0); channel.read(buffer); // 再次将文件位置归零 channel.position(0); buffer.flip(); byte[] bytes = new byte[buffer.limit()]; buffer.get(bytes); return new String(bytes); }

通过上面的代码,我们可以明显感受到,利用 transferTo 减少了编码量。

那么为什么利用 transferTo 可以减少编码量呢?

说程序读取数据和写入文件的过程

在解答这个问题前,先来说说程序读取数据和写入文件的过程。

我们现在所使用的 PC 操作系统,将内存分为了内核空间和用户空间。

操作系统的内核和一些硬件的驱动程序就是运行在内核空间内,而用户空间就是我们自己写的程序所能运行的内存区域。

传统 IO

这里,当我们调用 read 从磁盘中读取数据时,内核会首先将数据读取到内核空间中,然后再将数据从内核空间复制到用户空间内。

也就是说,我们需要通过内核进行数据中转。同样,写入数据也是如此。

系统先从用户空间将数据拷贝到内核空间中,然后再由内核空间向磁盘写入。

channel 的方式

与上面的数据流向不同,FileChannel 的 transferTo 方法底层基于 sendfile64(Linux 平台下)系统调用实现。

sendfile64 会直接在内核空间内进行数据拷贝,免去了内核往用户空间拷贝,用户空间再往内核空间拷贝这两步操作,因此提高了效率。

通过上面的讲解,大家应该知道了 transferTo 和 transferFrom 的效率会高于传统的 read 和 write 在效率上的区别。

区别的原因在于免去了内核空间和用户空间的相互拷贝,虽然内存间拷贝的速度比较快,但涉及到大量的数据拷贝时,相互拷贝的带来的消耗是不应该被忽略的。

代码

讲完了背景知识,咱们再来看看 FileChannel 是怎样调用 sendfile64 这个函数的。

相关代码如下:

  [java]
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public long transferTo(long position, long count, WritableByteChannel target) throws IOException { // 省略一些代码 int icount = (int)Math.min(count, Integer.MAX_VALUE); if ((sz - position) < icount) icount = (int)(sz - position); long n; // Attempt a direct transfer, if the kernel supports it if ((n = transferToDirectly(position, icount, target)) >= 0) return n; // Attempt a mapped transfer, but only to trusted channel types if ((n = transferToTrustedChannel(position, icount, target)) >= 0) return n; // Slow path for untrusted targets return transferToArbitraryChannel(position, icount, target); } private long transferToDirectly(long position, int icount, WritableByteChannel target) throws IOException { // 省略一些代码 long n = -1; int ti = -1; try { begin(); ti = threads.add(); if (!isOpen()) return -1; do { n = transferTo0(thisFDVal, position, icount, targetFDVal); } while ((n == IOStatus.INTERRUPTED) && isOpen()); // 省略一些代码 return IOStatus.normalize(n); } finally { threads.remove(ti); end (n > -1); } }

从上面代码(transferToDirectly 方法可以在 openjdk/jdk/src/share/classes/sun/nio/ch/FileChannelImpl.java 中找到)中可以看得出 transferTo 的调用路径,先是调用 transferToDirectly,然后 transferToDirectly 再调用 transferTo0。

transferTo0 是 native 类型的方法,我们再去看看 transferTo0 是怎样实现的,其代码在openjdk/jdk/src/solaris/native/sun/nio/ch/FileChannelImpl.c中。

  [c]
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
JNIEXPORT jlong JNICALL Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this, jint srcFD, jlong position, jlong count, jint dstFD) { #if defined(__linux__) off64_t offset = (off64_t)position; jlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count); if (n < 0) { if (errno == EAGAIN) return IOS_UNAVAILABLE; if ((errno == EINVAL) && ((ssize_t)count >= 0)) return IOS_UNSUPPORTED_CASE; if (errno == EINTR) { return IOS_INTERRUPTED; } JNU_ThrowIOExceptionWithLastError(env, "Transfer failed"); return IOS_THROWN; } return n; // 其他平台的代码省略 #endif }

如上所示,transferTo0 最终调用了 sendfile64 函数,关于 sendfile64 这个系统调用的详细说明,请参考 man-page,这里就不展开说明了。

内存映射

内存映射这个概念源自操作系统,是指将一个文件映射到某一段虚拟内存(物理内存可能不连续)上去。

我们通过对这段虚拟内存的读写即可达到对文件的读写的效果,从而可以简化对文件的操作。

当然,这只是内存映射的一个优点。

内存映射还有其他的一些优点,比如两个进程映射同一个文件,可以实现进程间通信。

再比如,C 程序运行时需要 C 标准库支持,操作系统将 C 标准库放到了内存中,普通的 C 程序只需要将 C 标准库映射到自己的进程空间内就行了,从而可以降低内存占用。

以上简单介绍了内存映射的概念及作用,关于这方面的知识,建议大家去看《深入理解计算机系统》关于内存映射的章节,讲的很好。

Unix/Linux 操作系统内存映射的系统调用mmap,Java 在这个系统调用的基础上,封装了 Java 的内存映射方法。

这里我就不一步一步往下追踪了,大家有兴趣可以自己追踪一下 Java 封装的内存映射方法的调用栈。

用法

下面来简单的示例演示一下内存映射的用法:

  [java]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 从标准输入获取数据 Scanner sc = new Scanner(System.in); System.out.println("请输入:"); String str = sc.nextLine(); byte[] bytes = str.getBytes(); RandomAccessFile raf = new RandomAccessFile("map.txt", "rw"); FileChannel channel = raf.getChannel(); // 获取内存映射缓冲区,并向缓冲区写入数据 MappedByteBuffer mappedBuffer = channel.map(MapMode.READ_WRITE, 0, bytes.length); mappedBuffer.put(bytes); raf.close(); raf.close(); // 再次打开刚刚的文件,读取其中的内容 raf = new RandomAccessFile("map.txt", "rw"); channel = raf.getChannel(); System.out.println("\n文件内容:") System.out.println(readChannel(channel)); raf.close(); raf.close();

其他操作

FileChannel 还有一些其他的方法,这里通过一个表格来列举这些方法,就不一一展开说明了。

如下:

方法名 用途
position 返回或修改通道读写位置
size 获取通道所关联文件的大小
truncate 截断通道所关联的文件
force 强制将通道中的新数据刷新到文件中
close 关闭通道
lock 对通道文件进行加锁

MappedByteBuffer

MappedByteBuffer 是ByteBuffer的子类,因此它具备了 ByteBuffer的所有方法,但新添了force()将缓冲区的内容强制刷新到存储设备中去、load()将存储设备中的数据加载到内存中、 isLoaded()位置内存中的数据是否与存储设置上同步。

参考资料

Java中使用内存映射实现大文件上传实例

JAVA NIO 之文件通道