深入理解IO系统及操作
前言
在入行软件开发行业后的很长一段时间里,我本人对计算机IO操作都没有很清晰的了解,只知道一些基本操作和浮于表面的理解。但这不符合我刨根问底的风格,于是最近专门集中时间把IO系统及操作这块知识系统的学习和掌握一遍。
IO 系统
首先理解I/O,输入输出是在计算机的主存和外部设备(磁盘、网络、终端)之间复制数据的过程。输入操作是从 I/O 设备复制数据到主存,而输出操作是从主存复制数据到 I/O 设备。
计算机系统控制IO的四种方式
- 程序控制
- 中断方式
- DMA方式(直接内存访问)
- 管道
Unix IO
Unix I/O 会使用以下几个函数
//打开文件. 若成功返回新的文件描述符,若错误返回-1
int open(char *filename, int flags, mode_t mode);
//关闭文件. 若成功返回0,若错误返回-1
int close(int fd);
//读取数据到内存. 若成功则为读的字节数,若失败则为-1
ssize_t read(int fd, void *buf, size_t n);
//写入数据到外部. 若成功则为写的字节数,若失败则为-1
ssize_t write(int fd, const void *buf, size_t n);
//手动定位到文件的某个位置
lseek
注:
ssize_t 被定义为有符号的long 类型,即 signed long , 因为返回值可能存在 -1; size_t 被定义为无符号的 long类型 unsigned long;
Unix I/O 函数被称为不带缓存的I/O,不带缓存意味着每一次函数调用都会调用内核的系统调用。
在内核中,每一个打开的文件都用文件描述符表示。
标准 IO
C 语言定义了一组高级别的输入输出函数,称为标准I/O 库。
为程序员提供了 Unix I/O 的较高级别的替代,标准I/O 库提供了打开和关闭文件的函数(fopen 和 fclose),读写字节的函数(fread 和 fwrite),读写字符串的函数(fgets 和 fputs),以及复杂格式化的I/O函数(scanf 和 printf)。
标准 I/O 库是基于Unix I/O 实现的。提供了一组强大的高级I/O 接口。对于大多数应用程序而言,标准I/O 更简单,是优于 Unix I/O 的选择。
流和文件 FILE
**标准I/O 库将一个打开的文件抽象化为一个流。 **流(指向 FILE 类型的指针)是对文件描述符和流缓冲区的抽象。流缓冲区的目的是使开销较高的 Unix IO 系统调用的次数尽可能的减少,提高系统效率。
举个栗子,一个程序反复调用标准 I/O 的 getc 函数,每次调用返回文件的下一个字符。当第一次调用getc 时,I/O 库通过调用一次 read 函数来填充流缓冲区,然后将流缓冲区的第一个字符返回给应用程序,只要缓冲区还有未读的字节,接下来对getc 的多次调用都是直接从流缓冲区中读取数据,而不是系统调用。
FILE 结构是在 stdio.h 中定义的,不要把 FILE 理解为磁盘上的数据文件,它是一个结构体,用于访问一个流。每一个流都有一个相应的 FILE 与之关联。
默认情况下, I/O流操作是进行缓冲的,绝大多数的IO操作都是完全基于缓冲的。C 程序打开文件时,将文件的全部内容或部分内容加载到缓冲区,并返回一个指向 FILE 结构的指针,接下来对文件的所以操作,都是基于缓冲的。
stdin | stdout | stderr
对于程序员而言,一个流就是一个指向 FILE 类型的指针。每个ANSI C 程序开始时默认都会打开3个流: stdin, stdout ,stderr ,分别对应标准输入,标准输出和错误输出。
#define stdin (__acrt_iob_func(0))
#define stdout (__acrt_iob_func(1))
#define stderr (__acrt_iob_func(2))
C 语言同样为程序打开的3个标准流提供相应的 I/O 函数,printf 用于向标准输出流中写入信息,perror 函数用于向错误输出流中写入信息,scanf 用于向标准输入流中写入信息;因为3个标准流都是默认打开的,所以使用 printf 、scanf 等函数操作标准流不需要使用 fopen 函数再打开流。
//打印错误信息
void perror(char const *message);
注:在网络套接字上不要使用标准I/O 函数来进行输入输出。
打开和关闭流
fopen 函数用于打开流,并把一个流与这个文件相关联.
//打开流
FILE *fopen(char const *name, char const *mode);
两个参数都是字符串。name 是打开的文件或设备的名字。mode 参数用于指定流是只读、只写还是可读可写,以及它是字符流还是字节流。下面列出mode 常用格式
读取 | 写入 | 添加 | |
---|---|---|---|
文本 | “r” | “w” | “a” |
二进制 | “rb” | “wb” | “ab” |
mode 以 r 、w 或 a 开头,分别表示打开的流用于读取、写入还是添加。如果一个文件打开是用于读取的,那么它必须是原先已经存在的;如果一个用于写入的文件原先已经存在,那么原来的内容将会被删除,也就是覆盖写入,如果原先不存在,那么就会创建一个新文件;如果一个用于添加的文件原先就存在,那么原先的内容并不会删除,会以追加的方式写入,如果原先不存在,那么将会创建。无论是那种情况,数据只能从文件的尾部写入。
如果 fopen 函数执行成功,则返回一个指向 FILE结构的指针,代表新创建的流。如果执行失败,就会返回一个 NULL 指针。
特别注意:
应该始终检查 fopen 函数的返回值!如果函数失败,会返回一个NULL值。
fclose 函数用于关闭流:
//关闭流
int fclose(FILE *f);
对于输出流,fclose 函数在关闭流之前会刷新缓冲区。如果执行成功,则返回0,否则返回 EOF。
注:常量值 EOF(End Of File)表示文件结尾的意思。
字符I/O
//从指定流中读取单个字符
int fgetc(FILE *stream);
int getc(FILE *stream);
//从标准输入流中读取单个字符
int getchar(void);
需要操作的流作为参数传递给 getc 和 fgetc,而 getchar 只能从标准输入流中读取。每个函数从流中读取下一个字符,并把它作为函数返回值进行返回。如果流中没有更多字符,函数就返回常量值 EOF。
注: 虽然是从输入流中读取字符,但返回值却不是字符类型 char ,而是整型 int.
//把单个字符写入到指定流中
int fputc(int character, FILE *stream);
int putc(int character, FILE *stream);
//把单个字符写入到标准输出流中
int putchar(int character);
注:
fgetc 和 fputc 是真正的函数,但 getc、getchar与 putc、putchar 都是通过 #define 指令定义的宏。这个区别实际上不必太看重,结果相差甚微。
撤销字符I/O
当逐个读取字符时, 你通常不知道下一个字符是什么, 如果读到的字符不符合你的预期, 而又不想丢弃,则可以使用 ungetc 退回到字符流中.
int ungetc(int character, FILE *stream);
未格式化的行I/O (字符串)
//从指定流中读取字符并复制到buffer中;
char *fgets(char *buffer, int buffer_size, FILE *stream);
char *gets(char *buffer);
//从标准输入流中读取字符
int fputs(char const *buffer, FILE *stream);
int puts(char const *buffer);
fgets 从指定流中读取字符并复制到buffer中。当它读到第一个换行符并存储到缓冲区,或缓冲区内的字符数达到 buffer_size-1 个时,就会停止读取。 这种情况下,并不会丢失数据,因为下次调用fgets 将从流中的当前位置开始读取。
二进制I/O
//从指定流中读二进制数据
size_t fread(void *buffer, size_t size, size_t count, FILE *stream);
//写二进制数据到指定流中
size_t fwrite(void *buffer, size_t size, size_t count, FILE *stream);
buffer 是一个指向用于保存数据的内存位置的指针,size 是缓冲区每个元素的字节数,count 是读取或写入的元素数,stream 是输入流或输出流。函数的返回值是实际读取或写入的元素的个数,而并非字节数。
字节流要比字符流操作效率高,因为每个值的位直接从流中读取或向流中写入,不需要任何转换。
刷新和定位函数
刷新函数很有用,在输出流中调用 fflush 会立即把缓冲区内的数据写出,不管它是否已经写满。
int fflush(FILE *stream);
调用 fflush 函数可以确保调试信息实际打印出来, 而不是保存在缓冲区中直到以后才打印.
默认情况下,IO流是顺序读取的。但是你可以通过在读取或写入之前定位到一个不同的位置实现随机IO操作。fseek 函数允许你指定文件中的一个位置,它用一个偏移量表示。ftell 函数返回文件的当前位置。rewind 函数返回到文件的起始位置。
//返回流的当前位置
long ftell(FILE *stream);
//定位到流的某个位置
int fseek(FILE *stream, long offset, int from);
//定位到流的起始位置
void rewind(FILE *stream);
ftell 函数返回流的当前位置,这个位置是下一个读取或写入的位置与文件起始位置的偏移量.在字节流中,这个值就是当前位置与起始位置之间的字节数。
fseek 的 offset参数表示偏移量,很好理解,from 参数有三个可选值:SEEK_SET、SEEK_CUR、SEEK_END,分别表示从流的起始位置开始偏移, 从流的当前位置开始偏移,以及从流的尾部位置开始偏移。offset 可正可负, 正值表示向尾部方向偏移, 负值表示向起始位置偏移。
rewind 函数则直接定位到流的起始位置。
改变缓冲方式
标准I/O提供三种类型的缓冲方式:
- 全缓冲
- 行缓冲
- 不缓冲
//
void setbuf(FILE *stream, char *buf);
//
int setvbuf(FILE *stream, char *buf, int mode, size_t size);
注意,setbuf 和 setvbuf 这两个函数只有当指定的流打开但还没有进行任何其他操作的情况下才能被调用。
setbuf 设置了另一个数组,用于对流进行缓冲,这个数组的字符长度必须为 BUFSIZ (它在stdio.h 中定义)。为一个流手动指定缓冲区会防止IO库为它动态分配一个缓冲区,如果buf 设置为 NULL,那么将关闭该流的所有缓冲方式。
setvbuf 函数更为通用, mode 参数用于指定缓冲的类型。_IOFBF 指定一个完全缓冲的流; _IONBF 指定一个不缓冲的流; _IOLBF 指定一个行缓冲的流; 所谓行缓存,是指每当一个换行符写入到缓冲区时,缓冲区便进行刷新。
临时文件
tempfile 函数返回一个与一个临时相关联的流,当流关闭后, 这个文件被自动删除.
FILE *tmpfile(void);
IO模型
讲IO模型前,必须了解操作系统的用户态与内核态以及两个状态之间的切换,这里简单讲下。
用户态与内核态
首先说明为什么会有两种状态呢?主要是因为操作系统不允许用户应用程序直接操作硬件资源(IO、内存、网络、磁盘等),进行了权限控制,只有操作系统内核代码才可以操作,目的是为了保证系统的安全和健壮。
硬件层面,CPU 厂商将CPU指令集按照权限进行了分类,如 Inter 把CPU指令集的权限由高到低分为4级:ring 0、ring 1、ring 2、ring 3;其中 ring 0 权限最高,可以使用所有 C P U 指令集
,ring 3 权限最低,仅能使用常规 C P U 指令集
,不能使用操作硬件资源的 C P U 指令集
,比如 I O
读写、网卡访问、申请内存都不行,Linux系统仅采用ring 0 和 ring 3 这2个权限,也就是人们所说的与内核态与用户态。

用户态到内核态切换的场景:
- 系统调用
- 异常
- 中断

其中系统调用是用户态进程主动切换到内核态,要求操作系统资源。异常和中断属于用户态被动切换到内核态,异常是当CPU执行用户代码时,发生了没有预知的异常导致切换到内核态处理;中断是CPU执行用户代码时,外围设备完成了用户请求,向CPU发出相应的中断信号,从而被动切换到内核态。
用户态到内核态的切换代价大,主要体现在:
- 应用程序通过系统调用从用户态切换到内核态的开销;
- CPU 响应外围设备中断的开销;
- 数据从外围设备拷贝到内核空间(内存);
- 数据从内核空间拷贝到用户空间;
- 系统调用返回时内核态切换到用户态的开销;
UNIX 下的五种 I/O 模型
下面是《UNIX网络编程》中分类的五种 I/O 模型:
- 阻塞式 I/O
- 非阻塞式 I/O
- I/O 多路复用(select、poll、epoll)
- 信号驱动式 I/O
- 异步 I/O
注:外围设备(网卡、磁盘、键盘鼠标、触屏等)与主存之间进行数据交换的场景,都属于IO操作,不过网络IO与其他IO场景确实不同,网络IO比其他场景的IO更加复杂,要处理的细节更多,所以总是将网络IO单独研究和讨论,函数库也会针对网络IO提供单独的接口和优化。
因为操作系统有用户态和内核态两种状态,一般将网络IO的整个流程分为两个阶段:
数据准备阶段:
这一阶段,网络数据包到达网卡,通过DMA方式拷贝到内存,然后通过内核线程处理到内核Socket 缓存中;
数据拷贝阶段:
内核Socket 缓存在内核空间中,需要拷贝到用户空间,才能被应用程序读取。
阻塞与非阻塞
阻塞与非阻塞的区别在于第一阶段。
阻塞IO:如果内核Socket 缓存中一直没有数据,用户线程则一直阻塞等待,直到有数据。
同步与异步
前四种都是同步IO模型,即都会同步阻塞在在第二阶段(内核数据拷贝到用户空间);
信号驱动式 I/O 与 异步IO的主要区别在于:
信号驱动式 I/O 由内核通知何时可以开始一个IO操作,而异步IO由内核通知IO操作何时已经完成。
阻塞IO模型
非阻塞IO模型
epoll 多路复用
IO 多路复用中的多路指的是多个连接,复用指的是复用一个线程。Linux平台提供了三种系统调用:select、poll、epoll,三者都是实现 IO多路复用的机制。
epoll 是Linux 内核提供的高效的 I/O事件通知机制(2.5.44 版本开始引入,2.6版本广泛使用),设计的目的是取代已有的select 和 poll。epoll 多路复用IO模型 可以轻松解决 C10K(单机并发1W个网络连接)问题,配合线程池,C100K也不是问题。
目前采用最多的是epoll 多路复用IO模型,Nginx 服务以及Java高性能网络框架 Netty都采用该形式实现。
Java IO
Java 语言是由C/C++ 发展而来的,很多底层的实现(如 native方法)也都是C语言直接实现的。Java I/O的原理和C标准函数库是一样的,也分为字节流(二进制流)和字符流(文本流),区别在于C 是面向过程的语言,重点放在函数以及入参选项上,而Java是面向对象的语言,Java中一切皆对象,所以很多概念都是用一个个对象和接口来表示的。如 C语言调用 fopen() 函数打开一个流,而Java则是初始化一个对象打开一个流(如 BufferedInputStream),像 read ,write 等其他操作区别不是很大,但凡是打开的流最后都需要关闭,从而释放系统资源。
Java I/O 接口概览图如下:

Java I/O 包中用到的设计模式:
Java中的输入流和输出流是高度对应的,如缓冲流 BufferedInputStream 和 BufferedOutputStream; java.io 包中大量运用装饰者设计模式,在读io 包源码前最好了解下装饰者模式。FilterInputStream 和 FilterOutputStream 是装饰者模式的抽象基类。
此外,从字节流转换成字符流还使用到适配器模式,InputStreamReader 和 OutputStreamWriter,
FileDescriptor 文件描述符
字节流
InputStream
字节流的顶级父类是 InputStream 和 OutputStream 。
InputStream 抽象类的全部方法、接口如下:
public abstract class InputStream implements Closeable {
public abstract int read() throws IOException;
public int read(byte b[]) throws IOException{...}
public int read(byte b[], int off, int len) throws IOException {...}
public long skip(long n) throws IOException {...}
public int available() throws IOException { return 0; }
public void close() throws IOException {}
public synchronized void mark(int readlimit) {}
public synchronized void reset() throws IOException {...}
public boolean markSupported() { return false;}
}
read 方法:
//从输入流中读入下一个字节,读到的字节值以int形式返回(0-255); 如果由于到达流末尾而没有可用字节时,返回-1;
int read();
/*
* 从输入流中读取一些字节并将它们存储到缓冲数组b中.实际读取的字节数以整数形式返回。
* 如果数组b的长度为零,则不读取任何字节并返回 0;
* 如果由于流位于文件末尾而没有可用字节,则返回值 -1;否则,至少读取一个字节并将其存储到 b 中。
* 参数:b 缓冲数组;此方法等同于 read(b,0,b.length);
*/
int read(byte b[]);
/* 从输入流中读取最多len个字节到一个字节数组中;实际读取的字节数以证书形式返回;
* 如果 len 为零,则不读取任何字节并返回 0;
* 如果由于流位于文件末尾而没有可用字节,则返回值 -1;否则,至少读取一个字节并将其存储到 b 中。
* 参数:b 缓冲数组;off 读入数组b的起始偏移量;len 读取数据的最大长度;
*/
int read(byte b[], int off, int len)
下面的例子,app.war 是一个270M大小的本地文件。
public static void testPerf() throws IOException {
FileInputStream fileInputStream = new FileInputStream("E:/code/build/libs/app.war");
byte[] buf = new byte[1024];
long start = System.currentTimeMillis();
while(fileInputStream.read(buf)!= -1) {
//do nothing
}
long end = System.currentTimeMillis();
System.out.println("耗时(毫秒): "+(end -start));
}
结论:
当不使用缓冲区数组(使用 read()方法一次读一个字节),读完数据的耗时为 572247毫秒(约10分钟!);
当缓冲区数组的大小设置为 64 字节时,平均耗时 9300 毫秒;
当缓冲区数组的大小设置为 1024 字节时,平均耗时640毫秒;
当缓冲区数组的大小设置为 2048 字节时,平均耗时366毫秒;
当缓冲区数组的大小设置为 8192字节时,平均耗时144毫秒;
可以得出结论,read()方法一次读一个字节,没有使用缓存,每读取一个字节都会产出操作系统内核态与用户态的一次切换,这个操作是比较耗时的;而使用了缓存数组后,每次则是批量读取字节,大大减少了操作系统内核态与用户态的切换次数,因此效率大大提升。这和SQL查询数据库是一个道理,同样是读取1000条数据,每次读取一条,读1000次的总耗时要比一次读取1000条的总耗时大得多,这也是因为最耗时的操作系统状态切换的次数大大减少了。
其他方法:
/*
* 跳过并丢弃此输入流中的 n 字节数据。
* 返回实际跳过的字节数。
*/
long skip(long n);
/*
* 返回可以从该输入流中读取(或跳过)的字节数(估计值)
* 这个方法应该被子类覆盖。
*/
int available();
/*
* 关闭此输入流并释放与该流关联的所有系统资源
*/
void close();
mark 和 reset 方法:
/*
* 标记此输入流中的当前位置。 对 reset 方法的后续调用将此流重新定位到最后标记的位置,以便后续读取重新读取相同的字节。
* 但是,如果在调用 reset 之前从流中读取的字节数超过 readlimit 字节,则流根本不需要记住任何数据。
* 参数readlimit: 标记位置失效前可读取的最大字节数。
*/
synchronized void mark(int readlimit);
/*
* 将此流重新定位到最后一次在此输入流上调用标记方法时的位置。
*/
synchronized void reset();
/*
* 测试此输入流是否支持 mark 和 reset 方法。
* 如果此流实例支持 markand reset 方法,则返回 true; 否则返回 false。
*/
boolean markSupported();
OutputStream
OutputStream 抽象类的全部方法如下:
public abstract class OutputStream implements Closeable, Flushable {
public abstract void write(int b) throws IOException;
public void write(byte b[]) throws IOException {...}
public void write(byte b[], int off, int len) throws IOException {...}
public void flush() throws IOException {}
public void close() throws IOException {}
}
write() 方法:
/*
* 将一个字节的值写入到输出流中;
* 要写入的字节是参数 b 的低八位。 b 的高 24 位被忽略。
*/
void write(int b);
/*
* 将指定字节数组中的 b.length 个字节写入此输出流中;
* 该方法的效果等同于 write(b, 0, b.length);
*/
void write(byte b[]);
/*
* 将指定字节数组中的 len 个字节从偏移量 off 处开始顺序写入此输出流。
* 元素 b[off] 是写入的第一个字节, b[off+len-1] 是此操作写入的最后一个字节。
*/
void write(byte b[], int off, int len);
flush() 方法:
/*
*
*/
void flush();
下面是一个简单的往文件里写值的例子:
public static void testFileOutputStream() throws IOException {
FileOutputStream fos = new FileOutputStream("E:/code/build/libs/test.txt",true); //追加方式写入
String srcData = "abcdefg";
fos.write(srcData.getBytes());
fos.flush();
fos.close();
}
字节数组流
ByteArrayInputStream
缓冲流
BufferedInputStream 为流提供缓冲功能
文件流
FileInputStream
File 文件
File 类表示一个文件或目录,构造方法里传入文件的路径。注意Java中定义的 File 类与C语言中定义的 FILE 结构不是一个概念,FILE 结构更加抽象,它表示的是一个打开的流,而 Java中的File类表示的更加具象化,它仅仅表示一个文件或目录。
读写随机访问文件
使用 RandomAccessFile 可以在文件中带出移动,并修改文件中的某个值。使用 RandomAccessFile 时,你必须知道文件的排版,这样才能正确的操作它。RandomAccessFile 拥有读取基本数据类型和UTF-8 字符串的各种方法。
RandomAccessFile 的seek() 函数用于定位到文件的某个位置(与C 标准函数库中seek() 函数一致),
临时文件
使用 File 类的静态方法创建临时文件:
File tempFile = File.createTempFile("temp", ".tmp", null);
注: 三个参数分别为 临时文件的前缀, 后缀, 存放目录, 后两个参数可以null , 第一个参数的长度不可以小于3;
Files 工具类
JDK1.7 在 java.nio
包中引入 Files 工具类 ,提供了很多实用的方法.
对象流
对象流(ObjectInputStream)主要用于对象序列化和反序列化场景。
public class Person implements Serializable {
private String name;
private Integer age;
public Person(String name, Integer age) {
this.name = name;
this.age = age;
}
}
public class Demo {
public void writeObject(String fileName) {
try(
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(fileName));
){
Person person = new Person("jackpot", 26);
oos.writeObject(person);
} catch (Exception e) {
e.printStackTrace();
}
}
public void readObject(String fileName) {
try(
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(fileName));
){
Person person = (Person)ois.readObject();
} catch (Exception e) {
e.printStackTrace();
}
}
}
数据流
DataInputStream
Socket 流
Socket 流用于Socket 通信时获取流。
public class Socket implements java.io.Closeable {
public InputStream getInputStream() throws IOException {...}
public OutputStream getOutputStream() throws IOException {...}
}
服务器端:(ServerSocket)
public static void testServerSocket() throws IOException {
ServerSocket ss = new ServerSocket(8888);
System.out.println("启动服务器....");
while(true) {
Socket s = ss.accept();
System.out.println("客户端:"+s.getInetAddress().getLocalHost()+"已连接到服务器");
BufferedReader br = new BufferedReader(new InputStreamReader(s.getInputStream()));
//读取客户端发送来的消息
String mess = br.readLine();
System.out.println("客户端消息:"+mess);
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(s.getOutputStream()));
bw.write(mess+"\n");
bw.flush();
}
}
客户端:(Socket)
public static void clientSocket() throws IOException {
Socket s = new Socket("127.0.0.1",8888);
OutputStream os = s.getOutputStream();
String mess = "测试客户端和服务器通信,服务器接收到消息返回到客户端";
os.write(mess.getBytes());
os.flush(); //向服务器端发送一条消息
InputStream is = s.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(is));
String mess2 = br.readLine();
System.out.println("服务器:"+mess2); //读取服务器返回的消息
}
Servlet 流
public interface ServletRequest {
public ServletInputStream getInputStream() throws IOException;
}
public interface ServletResponse {
public ServletOutputStream getOutputStream() throws IOException;
}
管道流
PipedInputStream
管道输入流应该连接到管道输出流,PipedInputStream 提供的数据写入到 PipedOutputStream,管道输入流包含一块缓冲区(默认1024字节),通常情况下,一个线程从 PipedInputStream 读取数据到缓冲区,另一个线程把缓冲区的数据写到PipedOutputStream 。
使用管道流前必须理解多线程,因为管道流用于任务之间的通信。
字符流
字符流 使用 Reader 和 Writer 接口表示。
Reader
public abstract class Reader implements Readable, Closeable {
protected Object lock;
protected Reader() { this.lock = this;}
protected Reader(Object lock) { this.lock = lock;}
public int read() throws IOException {...}
/** 返回读取的字符数,或者-1(如果已到达流的末尾)*/
public int read(CharBuffer target) throws IOException{...}
public int read(char cbuf[]) throws IOException {...}
public abstract int read(char cbuf[], int off, int len) throws IOException;
public long skip(long n) throws IOException {...}
public boolean markSupported() {
return false;
}
public void mark(int readAheadLimit) throws IOException {
throw new IOException("mark() not supported");
}
public void reset() throws IOException {
throw new IOException("reset() not supported");
}
public abstract void close() throws IOException;
}
字符输入流的主要具体实现:
FilterReader
BufferedReader
StringReader
CharArrayReader
PrintReader
PipedReader
InputStreamReader
Writer
FilterWriter
BufferedWriter
StringWriter
CharArrayWriter
PrintWriter
PipedWriter
OutputStreamWriter
适配器 InputStreamReader
适配器 InputStreamReader 可以将 InputStream 转换成 Reader ,OutputStreamWriter 可以将 OutputStream 转换成 Writer。
public class InputStreamReader extends Reader {
public InputStreamReader(InputStream in) {
super(in);
//...
}
}
BufferedReader
public class BufferedReader extends Reader {
private static int defaultCharBufferSize = 8192;
public BufferedReader(Reader in) {
this(in, defaultCharBufferSize);
}
public BufferedReader(Reader in, int sz) {
super(in);
this.in = in;
cb = new char[sz];
}
String readLine(boolean ignoreLF) throws IOException {...}
}
关闭流
但凡是打开的流,最后都需要去关闭,从而释放系统资源。
try-catch-finally
在JDK1.7之前,关闭流使用 try-catch-finally,在 finally 块中写各种流的非空判断和关闭逻辑,代码像如下这样:
public class Demo {
public static void main(String[] args) {
BufferedInputStream bin = null;
BufferedOutputStream bout = null;
try {
bin = new BufferedInputStream(new FileInputStream(new File("in.txt")));
bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")));
int b;
while ((b = bin.read()) != -1) {
bout.write(b);
}
}
catch (IOException e) {
e.printStackTrace();
}
finally {
if (bin != null) {
try {
bin.close();
}
catch (IOException e) {
e.printStackTrace();
}
finally {
if (bout != null) {
try {
bout.close();
}
catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
}
}
可以看到,在 finally 块中写关闭流的逻辑还是很繁琐的,这一点在JDK1.7 有了新变化改善这一状况。
try-with-resources 语句
JDK1.7 给 Java程序员带来了福利, 新增 try-with-resources 语句,凡是在 try () 括号中声明的资源,都会自动关闭,前提是声明的变量必须实现 AutoCloseable 接口。看下面的写法是不是很清爽:
public class Demo {
public static void main(String[] args) {
try (
BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File("in.txt")));
BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")));
){
int b;
while ((b = bin.read()) != -1) {
bout.write(b);
}
}
catch (IOException e) {
e.printStackTrace();
}
}
}
try-with-resources 语句其实是Java的语法糖,编译之后的代码依然是 try-catch-finally ,只不过程序员不需要手动关闭资源了,但实际编译后的代码与原先并无差异。
三个系统标准流
同 ANSI C 程序一样,程序开始前会初始化出三个标准流:标准输入流,标准输出流和错误输出流。三个标准流定义在 System 类中。
public final class System {
public final static InputStream in = null;
public final static PrintStream out = null;
public final static PrintStream err = null;
private static void initializeSystemClass() {
FileInputStream fdIn = new FileInputStream(FileDescriptor.in);
FileOutputStream fdOut = new FileOutputStream(FileDescriptor.out);
FileOutputStream fdErr = new FileOutputStream(FileDescriptor.err);
setIn0(new BufferedInputStream(fdIn));
setOut0(newPrintStream(fdOut, props.getProperty("sun.stdout.encoding")));
setErr0(newPrintStream(fdErr, props.getProperty("sun.stderr.encoding")));
}
private static native void setIn0(InputStream in);
private static native void setOut0(PrintStream out);
private static native void setErr0(PrintStream err);
}
System.in 是Java 应用的标准输入流,System.out 是标准输出流,System.err 是错误输出流。
Java NIO
Java NIO(Non-blocking IO)是 Java 1.4中(2002年)针对网络传输效率优化的新功能,并在Java 1.7 发布NIO 2,增加对文件IO的拓展,Java NIO API提供在java.nio包中,设计 NIO 类库的目的在于提高IO效率。
Java NIO 为非阻塞IO和IO多路复用提供了支持,底层调用操作系统提供的 Poll 和 EPoll(Linux平台)。
Poll 和 EPoll 在JDK中的引入时间:
在 JRE1.4.0_02 (2002年)版本中,Linux平台的 Selector实现为 PollSelectorImpl, (位置:…\jre\lib\rt.jar\sun\nio\ch),并没有关于Epoll 的实现,因为同年Epoll 才开始在Linux中使用,并没有广泛推广,直到JDK1.5.0_10 (2006年12月)才添加由Linux2.6内核提供的epoll I/O事件通知机制,此时在JDK(Linux版)的库中多了 EPollSelectorImpl 以及EPoll 相关的class 文件。
NIO API的设计中包含三个核心组件:
- Channel 通道
- Buffer 缓冲
- Selector 选择器
NIO 的所有读写操作都是基于通道和缓冲的,都会从一个 Channel 开始,数据可以从Channel 读到Buffer 中,也可以从Buffer写到Channel 中。

Java NIO 设计中, Selector允许一个线程便处理多个并发的连接(通道):

Channel 通道
通道与流有些相似,但又不同于流。相似之处在于都是往通道或流中读取和写入数据。不同之处如下:
- 流是单向的,只能往输入流中读取数据或输出流中写入数据;而通道是双向的,既可以读取数据,也可以写入数据。
- 通道可以异步的读和写;
- 必须基于缓冲:通道的数据总是先读到buffer,或从buffer 写入到通道;
重要的通道实现:
- SocketChannel
- ServerSocketChannel
- DatagramChannel
- FileChannel
SocketChannel : 能通过TCP读写网络中的数据
ServerSocketChannel:TCP服务端网络IO,可以监听TCP连接,像Web服务器那样,对每个新的连接都会创建一个SocketChannel;
DatagramChannel: 能通过UDP 读写网络中的数据
FileChannel : 从文件中读写数据
FileChannel
FileChannel无法设置为非阻塞模式,它总是运行在阻塞模式下。
打开FileChannel
在使用FileChannel之前,必须先打开它。但是,我们无法直接打开一个FileChannel,需要通过使用一个InputStream、OutputStream或RandomAccessFile来获取一个FileChannel实例。下面是通过RandomAccessFile打开FileChannel的示例:
RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
FileChannel inChannel = aFile.getChannel();
从FileChannel读取数据
ByteBuffer buf = ByteBuffer.allocate(1024);
int bytesRead = inChannel.read(buf);
向FileChannel写数据
public void test(){
String newData = "I'm Jackpot!;
ByteBuffer buf = ByteBuffer.allocate(1024);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
while(buf.hasRemaining()) {
channel.write(buf);
}
}
注意:
FileChannel.write()是在while循环中调用的。因为无法保证write()方法一次能向FileChannel写入多少字节,因此需要重复调用write()方法,直到Buffer中已经没有尚未写入通道的字节。
关闭FileChannel
用完 FileChannel 后必须将其关闭:
channel.close();
通道的其他方法:
//强制将文件数据和元数据(权限信息等)写到磁盘上
channel.force(true);
//返回该实例所关联文件的大小
long fileSize = channel.size();
SocketChannel
网络事件:
- 连接就绪
- 接收就绪
- 读就绪
- 写就绪
4 个网络事件在 SelectionKey 类中分别定义了: OP_READ、OP_WRITE、OP_CONNECT、OP_ACCEPT;
ServerSocketChannel
Buffer 缓冲
Buffer 是个抽象类,它有7个实现子类,表示的都是基本数据类型
主要缓冲实现类:
- ByteBuffer
- CharBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
这些buffer 提供了IO操作的常用数据类型:byte、 char、 short、 int、 long 、float、 double。
下面是一个使用FileChannel读取数据到Buffer中的示例:
public static void testFileChannel() throws IOException {
RandomAccessFile aFile = new RandomAccessFile("E:/code/build/libs/test.txt", "rw");
FileChannel inChannel = aFile.getChannel();
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf);
while (bytesRead != -1) {
System.out.println("Read " + bytesRead);
buf.flip();
while (buf.hasRemaining()) {
System.out.print((char) buf.get());
}
buf.clear();
bytesRead = inChannel.read(buf);
}
aFile.close();
}
内存映射文件 MappedByteBuffer
使用 MappedByteBuffer,我们可以一次性读取很大的文件,因为只有一部分放入到内存,文件的其他部分被交换了出去,这个功能是由底层操作系统支持的。
Selector 选择器
NIO 提供选择器的概念,这是一个可以监视多个通道的对象。
Selector的实现是SelectorImpl, 然后SelectorImpl又将职责委托给了各个具体的平台,Linux是EpollSelectorImpl,windows是WindowsSelectorImpl,MacOSX是KQueueSelectorImpl ;
Selector 选择器用于使用单个线程处理多个通道。
Selector API:
public abstract class Selector implements Closeable {
/** 创建并打开一个选择器 */
public static Selector open() throws IOException {
return SelectorProvider.provider().openSelector();
}
/** 选择器是否打开 */
public abstract boolean isOpen();
/**
* 选择一组键,其对应的通道已准备好进行 I/O 操作。
* 该方法执行非阻塞选择操作。如果自上一次选择操作以来没有通道变得可选择,则此方法立即返回零。
* 返回键的数量,可能为零;
*/
public abstract int selectNow() throws IOException;
/**
* 选择一组键,其对应的通道已准备好进行 I/O 操作。
* 此方法执行阻塞选择操作。它仅在至少选择一个通道、调用此选择器的唤醒方法或当前线程被中断(以先到者为准)后才返回。
* 返回键的数量,可能为零;
*/
public abstract int select() throws IOException;
/**
* 选择一组键,其对应的通道已准备好进行 I/O 操作。
* 此方法执行阻塞选择操作。它仅在至少选择一个通道、调用此选择器的唤醒方法、当前线程被中断或给定的超时期限到期后返回。
* 此方法不提供实时保证:它通过调用 Object.wait(long) 方法来安排超时。
* 返回键的数量,可能为零;
*/
public abstract int select(long timeout) throws IOException;
/** 获取选择器的键集合 */
public abstract Set<SelectionKey> keys();
/** 获取选择器已选择的键集合 */
public abstract Set<SelectionKey> selectedKeys();
/**
* 唤醒当前阻塞的选择器。
* 如果另一个线程当前在调用 select() 或 select(long) 方法时被阻塞,则该调用将立即返回。
* 如果当前没有选择操作正在进行,则这些方法之一的下一次调用将立即返回。
*/
public abstract Selector wakeup();
/** 关闭选择器 */
public abstract void close() throws IOException;
}
创建 Selector
Selector selector = Selector.open();
向Selector注册通道
channel.configureBlocking(false);
SelectionKey key = channel.register(selector,Selectionkey.OP_READ);
与Selector一起使用时,Channel必须处于非阻塞模式下。这意味着不能将FileChannel与Selector一起使用,因为FileChannel不能切换到非阻塞模式。而套接字通道都可以。
Pipe 管道
Java NIO 管道是2个线程之间的单向数据连接。Pipe
有一个source通道和一个sink通道。数据会被写到sink通道,从source通道读取。
这里是Pipe原理的图示:

创建管道
Pipe pipe = Pipe.open();
向管道写数据
要向管道写数据,需要访问sink通道:
Pipe.SinkChannel sinkChannel = pipe.sink();
往SinkChannel通道中写数据:
public void test(){
String newData = "I'm Jackpot!;
ByteBuffer buf = ByteBuffer.allocate(1024);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
while(buf.hasRemaining()) {
sinkChannel.write(buf);
}
}
从管道读取数据
从读取管道的数据,需要访问source通道:
Pipe.SourceChannel sourceChannel = pipe.source();
读数据
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = sourceChannel.read(buf);
IO 与 NIO
NIO可让你只使用一个(或几个)线程管理多个通道(网络连接或文件),但付出的代价是解析数据可能会比从一个阻塞流中读取数据更复杂。
NIO 并不能完全取代传统IO,不同的场景下有各种的优势。
如果需要管理同时打开的成千上万个连接,这些连接每次只是发送少量的数据,例如聊天服务器,实现NIO的服务器可能是一个优势。
如果你有少量的连接使用非常高的带宽,一次发送大量的数据,也许典型的IO服务器实现可能非常契合。
Netty 框架
Netty 是一款基于NIO 开发的网络通信框架,可以快速地开发协议服务器和客户端等网络应用程序。
IO线程模型
- Reactor 设计模式 (同步I/O)
- Proactor 设计模式(异步I/O)
Proactor模型跟Reactor模型的本质区别是异步I/O和同步I/O的区别,即底层I/O实现。

Reactor模型依赖的同步I/O需要不断检查事件发生,然后拷贝数据处理,而Proactor模型使用的异步I/O只需等待系统通知,直接处理内核拷贝过来的数据,显然使用异步I/O的Proactor模型性能更高,但Proactor 并不是当前主流的服务端网络编程模型,原因是Linux下的AIO API 还没有像同步I/O那样能够覆盖和支持更多场景,还没成熟到被广泛使用。
Reactor 模型
本文主要讲解Reactor 模型,无论是C++、Java还是Go 编写的网络框架,绝大多数都是基于Reactor模型进行设计和开发,Reactor模型基于事件驱动,特别适合处理海量的I/O事件,Reactor 也是当前最流行的服务端网络编程模型。
Nginx 服务、Java高性能网络框架 Netty、Redis 等也都采用Reactor 模型实现。
Reactor 主要有三种实现方式:
- 单Reactor 单线程
- 单Reactor 多线程(线程池)
- 主从Reactor 多线程(线程池)
Redis使用单Reactor单进程的模型。
多个Reactor在多个单独的线/进程中运行,MainReactor负责处理建立连接事件,交给它的Acceptor处理,处理完了,它再分配连接给SubReactor;SubReactor则处理这个连接后续的读写事件,SubReactor自己调用EventHandlers做事情。这种实现看起来职责就很明确,可以方便通过增加SubReactor数量来充分利用CPU资源。

README
作者:银法王
参考:
《深入理解计算机系统》第三版
《C和指针》
oracle Java doc文档 (Java nio)
JDK8 源码
修改记录:
2019-11-30 米开 第一次修订
2022-01-11 米开 完善Java IO、NIO相关接口描述