从系统IO与标准IO看JAVA IO
首先了解一下系统 I/O 与标准 I/O 的区别:
系统I/O,又称文件I/O,或是内核态I/O,引用文件的方式是通过文件描述符,一个文件对应一个文件描述符。一个文件描述符用一个非负整数表示,0、1、2系统默认表示标准输入、标准输出、标准错误,某些UNIX系统规定了描述符的上限值OPEN_MAX,这些常量都定义在头文件<unistd.h>中。当读或写一个文件时,使用open或create系统调用返回的文件描述符标识该文件,并将其作为参数传递给read或write系统调用。
标准I/O,又叫用户态I/O,引用文件的方式则是通过文件流(stream),一般用fopen和freopen函数打开一个流,返回一个指向FILE对象的指针,其他函数如果要引用这个流,则将FILE指针作为参数传递。一个进程预定义了三个流,并且这三个流自动被进程使用,它们是标准输入流、标准输出流和标准出错流,这三个流和系统I/O所规定的三个文件描述符所引用的文件相同。当读或写一个文件时,不像系统I/O,仅定义了read和write两个系统调用函数,标准I/O定义了多个函数,程序员可以根据自己的需求灵活使用。这些函数可以分为每次一个字符的I/O,每次一行的I/O和直接I/O(或者二进制I/O、一次一个对象I/O、面向记录的I/O、面向结构的I/O)。
一个 fopen 的使用样例:
#include <stdio.h> #include <stdlib.h> #define N 100 int main() { FILE *fp; char str[N + 1]; //判断文件是否打开失败 if ( (fp = fopen("d:\\demo.txt", "rt")) == NULL ) { puts("Fail to open file!"); exit(0); } //循环读取文件的每一行数据 while( fgets(str, N, fp) != NULL ) { printf("%s", str); } //操作结束后关闭文件 fclose(fp); return 0; }
fp 为文件偏移位置,每次 fgets 调用都会更新该位置,以便下次调用使用。这也是 ”流“ 只能顺序操作的原因。直到遇到一个 EOF(文件结束标志) ,操作结束。
系统I/O效率受限于read、write系统调用的次数,而系统调用次数则又受限于内核缓冲区的大小,即BUFFSIZE,通过设置不同的BUFFSIZE,系统CPU时间是不同的,其最小值出现在BUFFSIZE=4096处,原因是测试所采用的是Linux ext2文件系统,其块长为4096字节,也即缓冲区所能申请到的最大缓冲区大小,我们把4096字节看做是本次最佳I/O长度。如果继续扩大缓冲区大小,对此时间几乎没有影响。
为什么BUFFSIZE设置为4096个字节的时候,System CPU time最小呢? 因为,read函数读入4096个字节,正好是一个块,如果大于或者小于4096,那么就越块了,这样read函数会阻塞在那里,等着文件系统去翻下一个block,这样就耽误了时间,这个时间就是由于read阻塞而浪费的系统CPU时间。这个也是很多地方讲的所谓的“4K对齐”吧。
说到这里再提一下操作系统中数据块的概念:数据块是操作系统中文件系统的概念,因为正是操作系统内的文件系统这块负责处理文件的I/O等各种操作。文件系统不可能针对扇区进行寻址,那样的话需要维护的地址表过于庞大。所以,在文件系统级别,文件系统对自己可寻址的硬盘块进行了重新的定义,这个定义是在格式化的时候确定下来的,它必须是扇区大小的整数倍。这个在文件系统级别定义的硬盘块就是OS space allocation block size。在windows中这个叫做簇,在其他操作系统中叫做block(块)。一个块内只能存储一个文件,比如定义块为2k,那么5k的要占用3个块。操作系统的上层是应用,当应用发来I/O请求要读取一个文件时,操作系统收到请求后,首先在文件系统的地址表中找到这个文件对应硬盘上的块地址。然后,对每一个块产生一个I/O请求,发送到驱动模块处理。
所以,对于系统I/O操作,一个最大的问题就是:需要人为控制缓存的大小及最佳I/O长度的选择,另外就是系统调用与普通函数调用相比通常需要花费更多的时间,因为系统调用具体内核要执行这样的操作:1)内核捕获调用,2)检查系统调用参数的有效性,3)在用户空间和内核空间之间传输数据。
因此,引入标准I/O的目的就是为了通过标准I/O缓存来避免BUFFSIZE选择不当而带来的频繁的系统调用。根据用户不同的需求,选择不同的I/O函数,然后根据不同的缓存类型,自动调用malloc等缓存分配函数分配合适的缓存,等分配的缓存满之后,再调用系统I/O从标准I/O缓存向内核缓存拷贝数据,这样就进一步减少了系统调用的次数。
但是不同的标准I/O函数,不同的缓存类型也会带来不同的效率。如上图,当选择系统最佳I/O长度,即BUFFSIZE的大小和文件系统的块长一致,可以得到最佳的时间。当选用标准I/O函数时,每次一个字符函数fgetc、fputc和每次一行函数fgets、fputs函数相比要花费较多的CPU时间,而每次单个字节调用系统I/O则花费更多的时间,如果是一个100M的文件,则要执行大概2亿次函数调用,也就引起2亿次系统调用(从用户缓冲区到内核缓冲区,再到磁盘),而fgetc版本也执行了2亿次函数调用,但只引起了大约25222次系统调用,所以,时间就大大减少了。
综合以上,标准I/O函数虽然基于系统I/O实现,但很大程度上减少了系统调用的次数,而且不用人为关心缓冲区大小的选择,整体上提高了I/O的效率。另外,标准I/O提供了多种缓存类型,方便程序员根据不同的应用需求选择不同的缓存要求,提高了编程的灵活性,当选择无缓存时,就相当于直接调用系统I/O。
在 JAVA 中,缓冲流的默认缓冲区大小为 8192 字节。上线提到 Linux ext2文件系统块长为4096字节,此时缓冲区设置为4096字节可以使系统I/O效率最大化。JAVA 属于应用层,其考虑很可能是适配系统 I/O 的最佳缓冲区大小。
我们看一下 linux 系统 IO 的 read 函数描述,从一个打开的文件读取字节:
#include <unistd.h> ssize_t read(int fd, void *buf, size_t count);
返回值:成功返回读取的字节数,出错返回-1并设置errno,如果在调read之前已到达文件末尾,则这次read返回 0 ,参数 count 是请求读取的字节数,读上来的数据保存在缓冲区buf中,同时文件的当前读写位置向后移。注意这个读写位置和使用C标准I/O库时的读写位置有可能不同,这个读写位置是记在内核中的,而使用C标准I/O库时的读写位置是用户空间I/O缓冲区中的位置。比如用fgetc读一个字节,fgetc有可能从内核中预读1024个字节到I/O缓冲区中,再返回第一个字节,这时该文件在内核中记录的读写位置是1024,而在FILE结构体中记录的读写位置是1。注意返回值类型是ssize_t,表示有符号的size_t,这样既可以返回正的字节数、0(表示到达文件末尾)也可以返回负值-1(表示出错)。read函数返回时,返回值说明了buf中前多少个字节是刚读上来的。有些情况下,实际读到的字节数(返回值)会小于请求读的字节数count,例如:读常规文件时,在读到count个字节之前已到达文件末尾。例如,距文件末尾还有30个字节而请求读100个字节,则read返回30,下次read将返回0。
从上面的 read 函数可以看出,进行 read 系统调用时可以指定读取字节的长度,当然这个长度是由内核指定的。在从指定大小缓冲区读取数据时,可以使用 while 循环直到 read 函数返回 -1 或 0。每次读取时,我们尽最大可能将缓冲区填满,处理缓冲区数据,处理完成后再次进行系统调用。
系统函数每次尽量将内核缓冲区填满,JAVA IO每次尽量将用户缓冲区填满,我们在读取一个文件时,并不是一次性将文件读入内存的,而是按缓冲区大小来进行分组的系统调用,这也是为什么 JAVA 中的 read 要放在 while 循环中。其本质是基于系统调用打开一个流,只能依次向后读取,读取位置由系统函数记录,而应用层只需要在发起系统调用时,按缓冲区大小(或者没有缓冲区按字节读)传入要读取的字节数,至于将文件完整的读取出来,还需要我们在应用层编程时自行处理。
我们来看 JAVA 中 read 与 read0 的 native 实现,摘自 https://blog.csdn.net/zuoxiaolong8810/article/details/9974525:
JNIEXPORT jint JNICALL Java_java_io_FileInputStream_read(JNIEnv *env, jobject this) { return readSingle(env, this, fis_fd);//每一个本地的实例方法默认的两个参数,JNI环境与对象的实例 } JNIEXPORT jint JNICALL Java_java_io_FileInputStream_readBytes(JNIEnv *env, jobject this, jbyteArray bytes, jint off, jint len) {//除了前两个参数,后三个就是readBytes方法传递进来的,字节数组、起始位置、长度三个参数 return readBytes(env, this, bytes, off, len, fis_fd); }
/* env和this参数就不再解释了 fid就是FileInputStream类中fd属性的内存地址偏移量 通过fid和this实例可以获取FileInputStream类中fd属性的内存地址 */ jint readSingle(JNIEnv *env, jobject this, jfieldID fid) { jint nread;//存储读取后返回的结果值 char ret;//存储读取出来的字符 FD fd = GET_FD(this, fid);//这个获取到的FD其实就是之前handle属性的值,也就是文件的句柄 if (fd == -1) { JNU_ThrowIOException(env, "Stream Closed"); return -1;//如果文件句柄等于-1,说明文件流已关闭 } nread = (jint)IO_Read(fd, &ret, 1);//读取一个字符,并且赋给ret变量 //以下根据返回的int值判断读取的结果 if (nread == 0) { /* EOF */ return -1;//代表流已到末尾,返回-1 } else if (nread == JVM_IO_ERR) { /* error */ JNU_ThrowIOExceptionWithLastError(env, "Read error");//IO错误 } else if (nread == JVM_IO_INTR) { JNU_ThrowByName(env, "java/io/InterruptedIOException", NULL);//被打断 } return ret & 0xFF;//与0xFF做按位的与运算,去除高于8位bit的位 }
/* fd就是handle属性的值 buf是收取读取内容的数组 len是读取的长度,可以看到,这个参数传进来的是1 函数返回的值代表的是实际读取的字符长度 */ JNIEXPORT size_t handleRead(jlong fd, void *buf, jint len) { DWORD read = 0; BOOL result = 0; HANDLE h = (HANDLE)fd; if (h == INVALID_HANDLE_VALUE) {//如果句柄是无效的,则返回-1 return -1; } //都是WIN API的函数,可以百度搜索它的作用与参数详解,理解它并不难 result = ReadFile(h, /* File handle to read */ //文件句柄 buf, /* address to put data */ //存放数据的地址 len, /* number of bytes to read */ //要读取的长度 &read, /* number of bytes read */ //实际读取的长度 NULL); /* no overlapped struct */ //只有对文件进行重叠操作时才需要传值 if (result == 0) {//如果没读取出来东西,则判断是到了文件末尾返回0,还是报错了返回-1 int error = GetLastError(); if (error == ERROR_BROKEN_PIPE) { return 0; /* EOF */ } return -1; } return read; }