文章目录
这一节将来讨论NIO中最后一个组件,Channel。并讨论一个重要的问题, IO到底会不会丢数据。
一、Channel与流的区别
在传统的标准IO下,java程序需要进行一些IO操作,例如读写文件,都只能通过流来进行。而在JDK1.4版本以后,java的NIO包中提供了另外一种增强版的IO操作方式, Channel。首先看下Channel的定义。在java.nio.channels.Channel类的开头部分,有一段注释详细解释了Channel。我把他简单翻译一下。
Channel是一种用于IO操作的连接。一个Channel代表了一个与外部设备的开放连接。这些外部设备包含硬件设备、一个文件,一个网络的socket或者是一个支持同时提供一个或多个不同的IO操作的编程组件。
Channel有两种状态, open或者close。Channel在创建时,是open状态,而一旦关闭了之后,就一直保持close状态。对于close状态的channel,所有IO操作都会抛出一个ClosedChannelException异常。
一般来说, Channel以及他的子接口与子实现类,都要设计成多线程安全的。
所以,Channel可以简单认为是一种更安全,更高效的流操作。关于Channel和Stream的区别,简单总结如下:
1、Stream不支持异步操作,而Channel支持。
2、Stream访问数据只能支持单向访问,所以在JVM中总是有不同的inputStream,outputStream。 而Channel可以进行双向数据传输。
3、Stream可以直接访问目标数据,而Channel必须结合Buffer使用。数据总是先读到一个Buffer,或者总是要从一个Buffer写入。
4、Channel相比Stream性能更高。
Channel的实现类非常多,在Java开发中,由于不需要与硬件打交道,所以常用的Channel也就针对网络的NetWorkChannel和针对文件的FileChannel。
NetWorkChannel是一个针对网络的Channel接口。主要是支持TCP和UDP两种协议。而其中,对于TCP协议,之前NIO的各种示例代码都是基于TCP协议的。对于UDP协议,常用的是DatagramChannel。
UDP协议只需要发送数据,没有复杂的确认,所以相对会比较简单。 一个最简单的示例如下:
// 服务端
public class UDPServer {
public static void main(String[] args) throws IOException {
final DatagramChannel channel = DatagramChannel.open();
channel.bind(new InetSocketAddress(9999));
final ByteBuffer buffer = ByteBuffer.allocate(1024);
while (true){
System.out.println("=================");
buffer.clear();
channel.receive(buffer);
String message = new String(buffer.array());
System.out.println("received from client "+message);
}
}
}
// 客户端
public class UDPClient {
public static void main(String[] args) throws IOException {
final DatagramChannel channel = DatagramChannel.open();
channel.connect(new InetSocketAddress("localhost",9999));
String message = "Hello Server. sended from Client ,time ="+System.currentTimeMillis();
final ByteBuffer buffer = ByteBuffer.allocate(1024);
buffer.put(message.getBytes());
buffer.flip();
final int sended = channel.send(buffer, new InetSocketAddress("localhost", 9999));
System.out.println("已发送数据 大小 "+sended);
}
}
另外,对于服务端,也同样可以使用多路复用机制监听多个客户端。
public class UDPServer2 {
public static void main(String[] args) throws IOException {
final DatagramChannel channel = DatagramChannel.open();
channel.configureBlocking(false);
channel.bind(new InetSocketAddress(9999));
final Selector selector = Selector.open();
channel.register(selector, SelectionKey.OP_READ);
while (selector.select() > 0){
final Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()){
final SelectionKey key = iterator.next();
if(key.isReadable()){
final ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.receive(buffer);
buffer.flip();
System.out.println("received from client "+new String(buffer.array()));
buffer.clear();
}
}
iterator.remove();
}
}
}
下面重点来讨论这个FileChannel。这也是我们平常接触最多的一个Channel。
二、从文件读写看IO到底会不会丢数据
1、神奇的文件丢失现象。
我们先用流的方式,写一个简单的文件写入程序。
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
public class FileIODemo1 {
public static void main(String[] args) throws IOException {
File f = new File("/root/test1.txt");
if(!f.exists()){
f.createNewFile();
}
FileOutputStream fis = new FileOutputStream(f);
for (int i = 0; i < 100; i++) {
fis.write("a".getBytes("utf-8"));
fis.flush();//每写一个字符就刷一次盘。
}
fis.close();
}
}
然后在本机上使用Vmare搭建一台Linux虚拟机。将代码上传到Linux机器中,javac 编译, java执行。 执行之前,把/root/text1.txt文件给删掉(如果有的话)。执行完成后,可以查看到在/root/test1.txt中写入了很多个a。
这个时候,不做任何操作,将vmare虚拟机直接强制断电。

然后将虚拟机重启。如果你的速度比较快,大概率下,你会发现之前明明已经生成了的test1.txt不见了。

如果正常关机是不会出现文件丢失现象的,只有强制断电才会出现。原因会在后面分析。
在这个小程序中,明明多次调用了flush方法,将文件内容刷到磁盘上了,为什么还是会丢数据呢?
那还是要到系统底层来找原因。 我们先用strace指令,生成这个程序的系统调用日志。
strace -ff -o f1 java FileIODemo1
但是这时候一头扎进去看系统调用,是很难看出个原因的,只能看到,有很多次的write系统调用,每次写入一个a。

2、使用Channel保证数据不丢失。
接下来,我们用FileChannel来试一下,做另一个同样功能的小程序。
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class FileIODemo3 {
public static void main(String[] args) throws IOException {
File f = new File("/root/test2.txt");
if(!f.exists()){
f.createNewFile();
}
RandomAccessFile raf = new RandomAccessFile(f,"rw");
final FileChannel fc = raf.getChannel();
fc.map(FileChannel.MapMode.READ_WRITE,0,5);
final ByteBuffer byteBuffer = ByteBuffer.allocate(100);
for (int i = 0; i < 100; i++) {
byteBuffer.put(i,(byte)'a');
}
System.out.println(byteBuffer.toString());
fc.position(0).write(byteBuffer);
fc.force(true); //只调用一次force,进行刷盘。
fc.close();
raf.close();
}
}
同样的方式再次测试。你会发现,无论怎么测试,都无法重现出之前的文件丢失现象。
为什么会这样呢?我们同样打印strace系统调用日志,来对比一下就能看到原因了。

由此可以看出FileChannel相比FileOutputStream做的优化。一是将多次的write系统调用整合成一次系统调用,从而减少了系统调用的次数。二是,force方法触发了一次fsync的系统调用,而FileOutputStream的flush方法,却并没有这个系统调用。
对于第一个优化,很显然,FileChannel通过本地缓存,将多次要写入的数据整合成一次write系统调用,性能得到了提高。其实,这种缓存功能在BufferedOutputStream中一样存在。这也是在众多带缓存功能的流的作用。有兴趣可以自己写个BufferedOutputStream的小程序,也来试一下。
对于第二个优化,write这个系统调用,只是将数据写入到操作系统的page cache中,而并没有实际写入到硬盘。操作系统如果正常运行,page cache最终是会写入到硬盘中的,例如正常关机,或者运行一段时间。但是,像强行断电这种非正常的情况,就会造成page cache来不及写入到硬盘中。重启之后,文件就丢失了。
三、Page Cache机制解读
page cache,中文名称为页高速缓冲存储器。这是操作系统内核中一个内存级别的缓存机制。当CPU要访问外部磁盘上的文件时,需要先将文件内容从磁盘拷贝到内存中缓存起来,以加快磁盘文件的读取。但是内存毕竟有限,如果遇到大的文件,超过空闲内存的大小,就无法缓存了。在Linux操作系统中,就会以4K为单位来组织内存。这样4K大小的一个内存块就称为一个页page。例如在Linux上使用vi打开一个文件,那vi程序会把这个文件的所有内容,都以页为单位,从硬盘上一次性加载到page cache中。这样,以后再次打开文件,就不用去硬盘中找了。而我们之前调试到的mmap系统调用,就允许程序只缓存文件中要用到的一部分内容,而不用全部缓存,当然,同样是以页为单位的。
在Linux操作系统下,有多种方式可以查看页缓存。
1、整体查看页缓存,可以通过文件/proc/meninfo查看。
[root@192-168-65-174 ~]# cat /proc/meminfo
MemTotal: 16266172 kB
.....
Cached: 923724 kB
.....
Dirty: 32 kB
Writeback: 0 kB
.....
Mapped: 133032 kB
.....
2、如果要查看某一个文件的页缓存情况,就稍微麻烦点。需要使用一个需要安装的指令pcstate来查看。 该命令需要手动安装,具体可以查看github仓库 https://github.com/tobert/pcstat 。
atobey@brak ~ $ pcstat testfile3
|-----------+----------------+------------+-----------+---------|
| Name | Size | Pages | Cached | Percent |
|-----------+----------------+------------+-----------+---------|
| LICENSE | 11323 | 3 | 0 | 000.000 |
| README.md | 6768 | 2 | 2 | 100.000 |
| pcstat | 3065456 | 749 | 749 | 100.000 |
| pcstat.go | 9687 | 3 | 3 | 100.000 |
| testfile3 | 102401024 | 25001 | 60 | 000.240 |
|-----------+----------------+------------+-----------+---------|
这是在读取文件的时候,页缓存的机制还比较好理解。但是在写数据的时候,问题就比较复杂了。
由于CPU的运行速度非常快,所以CPU在执行指令时,通常只能与缓存进行交互,而不适合直接操作像磁盘、网卡这样的硬件。也因此,在进行文件写入时,操作系统也是先写入到page cache中,缓存起来,然后再往硬件写入。这时就会带来一些问题。
page cache页缓存的写入与真正磁盘的写入是有时间差的。所以,总是存在一种可能,应用程序将数据都写入到了page cache中,但是却没有真正写入磁盘。例如像我们刚才的实验,使用FileOutputStream,往磁盘中写入一个新的文件。在应用程序看来,文件是写入成功了。ls、cat、less这些指令也是应用程序,我们使用这些指令也能看到文件确实写入到磁盘了。但是实际上,文件的内容还只在page cache中,并没有写入到硬盘。所以虚拟机一断电后,这个文件就丢失了。在很多高可用的场景,这就会造成数据的丢失。而这种数据丢失,在应用程序上,是很难感知到的。这也是所有重要的开源软件、应用程序需要面对的问题。
在正常情况下,操作系统有稳定的机制保证page cache缓存内的数据最终都会正常写入到硬件。例如,正常关机时,操作系统就会统一将页缓存写入硬件。而在操作系统运行过程中,对于有数据修改的page页,操作系统会将他标记成脏页(dirty page)。当脏页在所有页缓存中所占的比例达到一个阈值时,操作系统也会触发页缓存的写入操作。这些都是不需要应用程序参与的。
但是,考虑到高可用的场景,情况就变得复杂了。操作系统可能因为一些非正常的情况突然终止。而内存中的数据,一断电就会丢失掉了。所以,内核中提供了这样一个系统调用, fsync,允许应用程序强制触发页缓存的写入机制。例如在我们的实验过程中看到, FileChannel的force方法,实际上就是触发了一次fsync的系统调用。而由此再去观察FileOutputStream的flush操作,会发现这个flush方法是个空方法,并没有进行任何系统调用。所以才会造成数据丢失的情况。
所以,这也就引出了一个非常纠结的问题,即 任何应用程序都不可能保证数据100%的不丢失。一方面,为了性能考虑,应用程序不可能每写入一点数据就调用fsync。这样的性能损耗是无法接受的。另一方面,在考虑到操作系统各种非正常情况下,应用程序也不可能保证每一次fsync系统调用都能够成功。因为应用程序发出指令,到CPU真正执行,这中间也是有时间差的。而IO数据的可靠性,就成了所有应用程序都必须综合平衡去考虑的问题。没有最好的方案,只有最适合的方案。
四、详解零拷贝
整个专题下来,你应该对零拷贝这个概念已经不陌生了。零拷贝是一种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术。使用零拷贝技术可以极大的提升特定应用程序的性能,尤其是对于各种IO频繁的操作,提升非常明显。非常多的开源软件中都大量使用了零拷贝技术来提升IO操作的性能。
在Java当中,提供了两种零拷贝的实现,都是基于FileChannel提供的。一种是mmap文件映射的方式,一种是使用sendfile的方式。相信在之前的分享过程当中,对于这些API你应该都很熟悉了。但是到底什么是零拷贝呢?这里的零,体现在什么地方?由于零拷贝其实是一个操作系统中的技术,因此,很多应用开发人员其实并不是很清楚,我们这里就来深入探究一下。
1、Channel与DMA 直接存储器访问
应用程序与磁盘之间的数据写入写出,都需要在用户态和内核态之间来回复制数据,内核态中的数据通过操作系统层面的IO接口,完成与磁盘的数据存取。在应用程序调用这些系统IOC接口时,由CPU完成一系列调度、任务分配,早先这些IO接口都是由CPU独立负责。所以,当发生大规模读写请求时,CPU的占用率很高。

之后,操作系统为了避免CPU完全被各种IO接口调用给占用,引入了DMA(直接存储器存储)。当应用程序对操作系统发出一个读写请求时,会由DMA先向CPU申请权限,申请到权限之后,内存地址空间与磁盘空间之间的IO操作全部由DMA来负。这样,在读写请求的过程中,CPU就不需要再参与,可以去做其他的事情。当然,DMA来独立完成数据在磁盘与内存空间中的来去,需要借助于DMA总线。但是当DMA总线过多时,大量的IO操作也会造成总线冲突,即也会影响最终的读写性能。

为了避免DMA总线冲突对性能的影响,后来就有了Channel通道的方式。Channel,它是一个完全独立的处理器。CPU是中央处理器,通道本身也是一个处理器,专门负责IO操作。既然是处理器,通道也有自己的IO命令,与CPU无关。他更适合于大型的IO操作,性能也更高。

2、零拷贝是怎么回事
我们必须知道,零拷贝完全是一个操作系统底层的技术,而所谓的上层应用,都只能调用,而无法实现零拷贝。所以,要真正理解零拷贝,必须深入操作系统。以一个典型的场景为例,一个文件下载的服务端应用程序,如果希望从本地磁盘上读取一个文件,然后通过socket连接,发送给客户端。后面会解释为什么大家总是拿这个场景来举例。
在这个场景下,操作系统首先需要将磁盘中的文件读取到内核态的页缓存,然后加载到用户态的应用程序中,这样服务端应用程序才能拿到文件的内容。而服务端应用程序往客户端发送文件内容时,也需要先将文件写入内核态的Socket缓冲区,然后才能通过Socket往客户端发送消息。整个过程中有四次文件拷贝。

而所谓零拷贝,主要任务就是要避免这个过程中的CPU拷贝,让CPU从这些繁重耗时的拷贝任务中解脱出来。这其中,硬件与页缓存之间的交互过程,已经可以通过DMA进行,不需要CPU参与,所以,零拷贝的重点就在于减少内核态与用户态之间的文件拷贝。
其中,mmap的方式就是在用户态不再保存文件内容,而只保存文件的映射。关于他的实现方式,在之前介绍NIO的Buffer组件时已经做了分享。

另一种零拷贝实现方式就是使用FileChannel的transfer方法。
sourceReadChannel.transferTo(0,sourceFile.length(),targetWriteChannel);
这个方法在操作系统层面是调用的一个sendFile系统调用。通过这个系统调用,可以在内核层直接完成文件内容的拷贝。
这里,站在应用程序的角度,其实并不知道sendfile是如何工作的,但是,其实在sendfile的底层,其实操作系统层面也在不断优化sendfile的实现。早期sendfile的实现机制其实还是依靠CPU进行页缓存与Socket缓冲区之间的拷贝。但是,在后期的不断改进中,sendfile优化了实现机制,在拷贝过程中,并不直接拷贝文件内容,而是只拷贝一个带有文件位置和长度信息的缓冲区文件描述符到socket缓冲区,这样就大大减少了需要传递的数据。而真实的文件内容,则交由DMA控制器,从页缓存中打包发送到socket中。

3、为什么很多的资料都喜欢拿磁盘到socket的示例来讲解零拷贝的示例呢?
我们不妨看看Linux上的系统调用说明:

在2.6.33之前的版本,sendfile系统调用的使用范围是有限制的,in_fd必须是一个可以mmap映射的fd,而out_fd则必须是一个socket套接字。但是从2.6.33版本之后,已经没有了换个限制。所以,关于零拷贝,你多看看网上的资料就会发现,老一点的资料都是在使用从磁盘到socket的示例来讲解零拷贝。不是因为这个示例有多特殊,而是因为sendfile系统调用就只能这么用。而如果要进行文件与文件之间的复制,老版本中可以使用一个splice系统调用来完成。当然随着内核的不断优化,现在sendfile已经没有了这个限制。
最后,对零拷贝做下总结
所谓零拷贝,其实并不是不拷贝,而是减少文件的拷贝次数。这里的零,更多的是体现在减少CPU的拷贝次数。
而mmap与sendfile两种机制,由于mmap还是需要用户态的参与,所以通常来说,对于映射文件的大小还是有点限制的,建议的映射文件大小不要超过1.5G。因此,RocketMQ开源框架设计的commitLog日志文件,都是以1G为固定大小。
而senfile机制,则是纯内核态的操作,不需要用户态的参与。所以文件大小的限制并不大。但是,要使用senfile,还是需要通过用户态来触发调度,此时,从用户态转为内核态的调用通知过程,系统性能的开销就比较重要了。因此,sendfile通常用于大文件的传输,最好是一次调度就能够把文件全部复制完。
五、进阶篇总结
在进阶篇中完成了对NIO三大核心对象的梳理与总结,这样能够更能理解IO与系统底层的关系。IO不同于其他一些上层模块,他与操作系统底层是息息相关的。这也解释了为什么我们经常听到NIO、AIO需要操作系统底层支持这样的说法。从操作系统底层来梳理BIO、NIO、AIO这些上层机制就会更清晰明了。
我们都知道,Netty只是对NIO的封装,但是到底如何进行的封装?这在java代码层面,其实能看到的内容是非常有限的。而通过对NIO底层的梳理,能够更清晰的理解到Netty工作的基础。如果能够使用同样的方式对Netty程序的运行机制梳理清楚,那Netty上层那些代码就不会再如一团乱麻了。当然,友情提醒,这还是有点难度的。
本文探讨了Java NIO中的Channel概念,比较了它与Stream的区别,特别是如何通过FileChannel确保文件操作数据不丢失,以及零拷贝技术的原理和应用场景。重点讲解了PageCache机制和为何磁盘到socket示例常用于零拷贝教学。

3354

被折叠的 条评论
为什么被折叠?



