1. 首页
  2. >
  3. 编程技术
  4. >
  5. Java

深入理解Java文件读写的底层实现

继 《Java文件的简单读写、随机读写、NIO读写与使用MappedByteBuffer读写》,本篇通过调用Linux OS文件操作系统函数实现copy命令以加深我们对Java文件读写底层实现的理解。

本篇内容包括:

  • 文件操作系统函数
  • 实战:实现文件拷贝命令
  • 实战:使用mmap实现文件拷贝命令

文件操作系统函数

本篇将介绍的函数有:openclosewritelseekreadmmapmsync。使用Linux下系统调用函数来进行对文件的操作需要导入头文件<fcntl.h><unistd.h>,安装gcc后这两个头文件位置在/usr/include/目录下。mmap例外,使用mmap需要导入<sys/mman.h>头文件,该文件位置在/usr/include/sys目录下。

  • <fcntl.h>:定义了open函数;
  • <unistd.h>:定义了writelseekreadclose函数;
  • <sys/mman.h>:定义了mmapmsync函数。

open函数

open函数用于打开一个文件获取文件句柄,当文件不存在时支持创建文件再打开文件。

int open(const char* filename,int flag,int mode); 

该方法执行成功会返回文件描述符(文件句柄),失败则返回-1,参数说明如下:

  • filename:要打开的文件的文件名,含路径;
  • flag:指定文件的打开方式,如只读,只写、读写;
  • mode:如果需要在文件不存在时创建文件,那么该参数就需要填写,指定文件对用户开放的权限。

flag参数的可取值如下:

  • O_RDONLY:只读;
  • O_WRONLY:只写;
  • O_RDWR:读写;

(以上这三个参数只能选择其中一个,但可以与下列多个参数通过or运算组合使用)

  • O_CREAT:当文件不存在时创建文件,此时OPEN函数会用到函数的第三个参数(mode);
  • O_EXCL:如果指定了O_CREAT,而文件存在,则open会执行出错返回-1;
  • O_APPEND:文件以追加方式打开,每次进行写操作文件偏移量都会被设置到文件末尾;
  • O_NOCTTY:当文件名指向一个终端设备,它将不再是进程控制的终端;
  • O_TRUNC:如果文件存在,则文件将被截断,即长度为0;
  • O_NONBLOCK/O_NDELAY:当文件以非阻塞方式打开后,对于open及随后对文件的操作,都会及时返回而无需进程等待,这对于普通文件和目录文件没有作用,但是对于管道等进程间通信的操作很有用;
  • O_SYNC:文件以同步I/O方式打开,任何写操作都会使进程阻塞,直到写操作完成为止。

上一篇文章笔者有提到,就是我们创建FileOutputStream时,如果不指定appendtrue,那么会导致文件内容被清空,原因是FileOutputStream构造函数调用了open函数,open函数判断append函数如果为false,则flags会组合O_TRUNC(如果文件存在,则文件将被截断,即长度为0),因此会导致文件被清空,源码如下。

JNIEXPORT void JNICALL Java_java_io_FileOutputStream_open0(JNIEnv *env, jobject this,                                     jstring path, jboolean append) {     fileOpen(env, this, path, fos_fd,              O_WRONLY | O_CREAT | (append ? O_APPEND : O_TRUNC)); } 

model可取的值如下,支持或(or)运算:

  • S_IRUSR:文件属主有读权限;
  • S_IWUSR:文件属主有写权限;
  • S_IXUSR:文件属主有执行权限;
  • S_IRWXU:文件属主有读写执行权限;
  • S_IRGRP:文件属组有读权限;
  • S_IWGRP:文件属组有写权限;
  • S_IXGRP:文件属组有执行权限;
  • S_IRWXG:文件属组有读写执行权限;
  • S_IROTH:其它用户有读权限;
  • S_IWOTH:其它用户有写权限;
  • S_IXOTH:其它用户有执行权限;
  • S_IRWXO:其它用户有读写执行权限。

close函数

close函数用于关闭已打开的文件。

int close(int fd); 

方法执行成功返回0,失败返回-1,参数说明如下:

  • fd:文件描述符,由open函数返回获得。

read函数

read函数用于读取文件数据到内存中。

int read(int fd,void * buff,int count); 

方法执行成功返回本次真正读取到的字节数,读取到文件末尾返回0,出现异常返回-1,参数说明如下:

  • fd:文件描述符,由open函数返回;
  • buff:用于存放从文件中读取的内容的内存缓存;
  • count:指定想要读取的长度。

write函数

write函数用于向文件中写入数据。

int write(int fd,void* buff,int count); 

方法执行成功返回本次真正写入文件的字节数,出现异步返回-1,参数说明如下:

  • fd:文件描述符,由open函数返回;
  • buff:存放要写入文件中的内容的内存缓存;
  • count:指定想要写入的数据的长度。

lseek函数

lseek函数用于将文件读写指针移动到文件的指定偏移量位置处。

int lseek(int fd,int offset,int seek_flag); 

方法执行成功返回文件新的偏移量,出现异常返回-1,参数说明如下:

  • fd:文件描述符,由open函数返回;
  • offset:可取负数,负数代表向前偏移|offset|个字节,正数代表向后偏移|offset|个字节;
  • seek_flagseek模式,相对文件开始、相对结尾、相对当前位置。

seek_flag可取值如下:

  • SEEK_SEK:将偏移量设置为文件开始位置之后的offset个字节;
  • SEEK_CUR:将偏移量设置为当前位置之后的offset个字节;
  • SEEK_END:将偏移量设置为当前文件长度加上offset个字节。

mmap函数

void * mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); 

该方法执行成功返回可用的内存首地址,失败则返回-1,各参数说明如下:

  • addr:映射区的开始地址,一般传NULL
  • length:映射区长度,与offset一起理解就是从文件的某个位置开始,映射length个字节;
  • prot:期望的内存保护标志,不能与文件的打开模式冲突;
  • flags:指定映射对象的类型,映射选项和映射页是否可以共享;
  • fd:文件描述符,使用open打开文件获取;
  • offset:偏移量,从文件的什么位置开始映射。

prot可取值如下,可以通过or运算组合在一起:

  • PROT_EXEC:页内容可以被执行;
  • PROT_READ:页内容可以被读取;
  • PROT_WRITE:页可以被写入;
  • PROT_NONE:页不可访问。

Java只支持使用PROT_READ或者PROT_READPROT_WRITE组合。

flags可取值也很多,可以通过or运算符组合在一起,但由于Java只使用了MAP_SHAREDMAP_PRIVATE,因此这里只介绍这两种取值:

  • MAP_SHARED:与其它所有映射这个文件的进程共享映射空间,对共享区的写入文件实际上不会被更新,直到msync被调用才输出到文件;
  • MAP_PRIVATE:建立一个写入时拷贝的私有映射,内存区域的写入不会影响到原文件,这个标志和MAP_SHARED标志是互斥的,只能使用其中一个。

JavaFileChannelmmap方法实际调用的navite方法的源码如下:

JNIEXPORT jlong JNICALL Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,                                      jint prot, jlong off, jlong len) {     void *mapAddress = 0;     jobject fdo = (*env)->GetObjectField(env, this, chan_fd);     // 获取文件句柄     jint fd = fdval(env, fdo);     int protections = 0;     int flags = 0;     // 解析port和flags取值     if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) {         protections = PROT_READ;         flags = MAP_SHARED;     } else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) {         protections = PROT_WRITE | PROT_READ;         flags = MAP_SHARED;     } else if (prot == sun_nio_ch_FileChannelImpl_MAP_PV) {         protections =  PROT_WRITE | PROT_READ;         flags = MAP_PRIVATE;     }     // 调用mmap方法     mapAddress = mmap64(         0,                    /* Let OS decide location */         len,                  /* Number of bytes to map */         protections,          /* File permissions */         flags,                /* Changes are shared */         fd,                   /* File descriptor of mapped file */         off);                 /* Offset into file */     // 映射失败     if (mapAddress == MAP_FAILED) {         ......      }     return ((jlong) (unsigned long) mapAddress); } 

Java_sun_nio_ch_FileChannelImpl_map0方法源码可以看出,Java仅支持使用的flagsMAP_SHAREDMAP_PRIVATE,支持使用的protPROT_READ或者PROT_WRITE "+" PROT_READ

msync函数

如果采用内存映射文件的方式读写文件,需要调用msync确保修改的内容完全同步到硬盘之上。

int msync(void *addr, size_t length, int flags) 
  • addrmmap映射的内存需要进行同步的内存块的起始地址;
  • length:需要同步的内存块的长度;
  • flags:可取值为MS_SYNC(同步等待)、MS_ASYNC(立即返回)、MS_INVALIDATE(使内存中的数据无效)。

msync需要指定同步的地址区间,如此细粒度的控制似乎比fsync更加高效,因为应用程序通常知道自己的脏页位置。(但实际上Linux kernel能够很快地找出文件的脏页,使得fsync只会同步文件的修改内容。)

JavaMappedByteBuffer提供的force方法底层就是调用msync方法,如下源码所示。

JNIEXPORT void JNICALL Java_java_nio_MappedByteBuffer_force0(JNIEnv *env, jobject obj, jobject fdo,                                       jlong address, jlong len) {     void* a = (void *)jlong_to_ptr(address);     int result = msync(a, (size_t)len, MS_SYNC);     if (result == -1) {         JNU_ThrowIOExceptionWithLastError(env, "msync failed");     } } 

实战:实现文件拷贝命令

编写一个c程序实现对文件的拷贝,指定源文件拷贝输出到指定目标文件,并将程序所在目录加入到系统环境变量中,实现自定义文件拷贝命令。

首先是实现打开文件与关闭文件,代码如下。

#include <stdio. h> #include <fcntl.h> // 定义每次拷贝的字节长度 #define ONE_LINE_SIZE 100  void closeFile(int fd);  // 以只读方式打开一个文件 // 第一个参数:包含文件路径的文件名,这里应该传源文件名,即被拷贝的文件名 // return返回文件标识符 int openReadFile(char* fileName){     int fd open(fileName, O_RDONLY, S_IRWXU);     if(fd==-1){         printf(" open read file error !\n");     }else{         printf("open read file success! \n");     }     return fd; }  // 以只写方式打开一个文件,文件不存在则创建,如果文件存在则写入的内容将会覆盖原有的 // 第一个参数:包含文件路径的文件名,这里应该传自标文件茗 // return返回文件标识符 int openWriteFile(char* fileName){     int fd= open(fileName, o WRONLY IO CREAT, S IRWXU):     if(fd!=-1){         printf ("open write file success! \n")     }else{         printf ("open write file error!\n");     }     return fd; }  //关闭文件 //第一个参数:文件描述符 void closeFile(int fd){     if(fd==-1){       return;     }    if(close(fd)==0){       printf("file close success! \n");    }else{       printf( "file close fail! \n");    } } 
  • openReadFile方法以只读方式打开文件,文件不存在也不要创建,因为拷贝文件如果源文件不存在那就没有继续拷贝的必要了;
  • openWriteFile方法以只写方式打开文件,如果文件不存在需要先创建,第三个参数指定该文件只属于当前创建它的用户,对于当前用户有读、写、执行权限;
  • closeFile方法用于关闭已打开的文件,也可以不手动关闭,因为该程序执行完成后系统会自动关闭。

接着实现文件的拷贝逻辑,从原文件读取数据写入目标文件,代码如下。

// 声明拷贝方法 void copy(char* sFILeName, char* dFileName); // 声明执行读写的方法 void copyFile(int sfd, int dfd);  // 第一个参数:源文件名 // 第二个参数:目标文件名 void copy (char* sFileName, char* dFileName){     int sfd openReadFile(sfileName);     if(sfd==-1){       printf("copy fail!\n");       return;     }     int dfd openWriteFile(dFileName);     if(dfd==-1){        printf("copy fail!\n");        closeFile(sfd);        return;     }     copyFile(sfd, dfd);     closeFile(sfd);     closeFile(dfd); } 

copy方法主要实现文件的打开和关闭,在文件打开出错的情况下不执行真正的文件拷贝操作,如果指定的两个文件都打开成功,那么将返回的文件描述符传递给copyFile方法实现真正的拷贝动作。

真正实现拷贝逻辑的copyFile方法代码如下。

// 真正执行拷贝的方法 // 第一个参数:源文件的文件句柄 // 第二个参数:目标文件的文件句柄 void copyFile(int sfd, int dfd) {     char buff[ONE_LINE_SIZE+1];     int sLseekOffset = 0;     int dLseekOffset = 0;     int rLength;     int countSize=0;     char flag = 1;     // 出头开始     (void)lseek(sfd, O, SEEK_SEK);     (void)lseek(dfd, O, SEEK_SEK);     // 循环读文件     while(-1 != (rLength = read(sfd, buff, ONE_LINE_ SIZE))){         if(rLength==0){             flag=0;             break;         }         countSize += rLength;         // 写到另一个文件         int writeSize = write(dfd, buff, rLength);         if(writeSize != rLength){             flag=0;             printf( "copy fail! read or write error !\n");             break;         }         dLseekOffset = lseek(dfd, 0, SEEK_CUR);         sLseekOffset = lseek(sfd, 0, SEEK_CUR);         if(sLseekOffset != dLseekOffset) {             flag=0;             printf("copy fail! seek error!\n");             break;         }         printf("current copy byte size %d, s_seek is %d, d_seek is %d, count copy byte size is %d\n, rLength, sLseekOffset, dLseekOffset, countSize);     }     if(flag == 1) {         printf( "file copy success!\n");     } } 

其中rLength用于保存每次调用read方法实际读取的字节数,如果它的值为0,说明读取到了文件末尾,如果值为-1说明读取出错,不管是读取到文件尾还是读取出错都应该停止循环。

lseek(dfd,0,SEEK_CUR)lseek(sdf,0,SEEK_CUR)并没有改变文件的偏移量,只是用来获取当期文件相对文件开始位置的偏移量,因为readwrite执行完后都会将文件偏移量往后移动读取或写入的字节数,所以不需要手动调用lseek改变文件偏移量。

buff用于保存读取的文件的内容,然后再将读取的内容写入到新文件中,循环该操作直到读取到的字节数为0就正常完成了拷贝。

程序入口方法的实现代码如下。

// 关于参数说明: // 例如:filecopy test1.txt test2.txt // argc=3 // argv[0]:filecopy命令的字符串 // argv[1]:源文件名 // argv[2]:目标文件名 int main(int argc, char* argv[]){     switch(argc)[         case 3:            copy (argv[1], argv[2]);            break;         default:            printf("input parameters error !\n");            break;     }     return 0; } 

main方法主要做的事情是判断参数的完整性,参数不完整程序执行失败,当参数完整时调用copy方法进一步判断输入的参数是否有效。如果参数完整那么传入main方法的参数argc等于3argv的长度为3,第一个参数为可执行文件的文件名,第二个参数必须是指定要拷贝的文件的文件名(包含路径信息,可以是绝对路径也可以是相对路径),第三个参数是新的文件名即保存拷贝内容的文件的文件名,要求同第二个参数。

程序编写完成后,首先使用gcc将源程序编译成可执行文件:

gcc filecopy.c -o ./filecopy 
  • 这里的filecopy.cc源程序代码文件名。

接着使用export命令为系统添加一个临时的环境变量:

# 由于笔者将编译后的可执行文件filecopy保存在“/home/wujiuye/桌面/c_projects/linux/file”下 export PATH:$PATH:/home/wujiuye/桌面/c_projects/linux/file 
  • 注意:因为只是测试,所以只使用临时环境变量,该变量会在系统重启时失效。如果想要这个命令永久可用,只需要把临时的环境变量改成永久生效的就可以了,就是在/etc/profile文件中添加环境变量。

现在我们就可以像使用系统命令一样来使用自己写的文件拷贝程序了。

将终端关闭,重新打开后进入tmp目录,随便找一个文件测试,这里我找了一个图片文件进行测试,输入:

filecopy qt-trayicon-ns2416.png news.png 

运行过程及结果如下图,其中qt-trayicon-ns2416.png是要拷贝的文件名,news.png为通过拷贝生成的新文件的文件名。

深入理解Java文件读写的底层实现

此时使用文件管理器进入到tmp目录下可以看到拷贝成功后的news.png文件,如下图。

深入理解Java文件读写的底层实现

实战:使用mmap实现文件拷贝命令

拷贝命令需要实现给定一个原文件,创建一个目标文件,通过mmap将原文件数据拷贝到目标文件。程序完整代码如下。

#include <fcntl.h> #include <sys/mman.h> #include <unistd.h> #include <string.h> #include <stdio.h>  int main(int argc, char *argv[]) {     if (argc != 3) { return -1; }     // 打开文件获取文件句柄     int sfd, dfd;     if ((sfd = open(argv[1], O_RDONLY, S_IRWXU)) < 0) { return -1; }     if ((dfd = open(argv[2], O_RDWR | O_CREAT | O_TRUNC, S_IRWXU | S_IRWXG | S_IRWXO)) < 0) { return -1; }     // 获取原文件的大小     int size = lseek(sfd, 0, SEEK_END);     lseek(sfd, 0, SEEK_SET);     // 设置文件大小     truncate(argv[2], size);     // mmap映射     void *src, *dst;     if ((src = mmap(0, size, PROT_READ, MAP_SHARED, sfd, 0)) == MAP_FAILED) { return -1; }     if ((dst = mmap(0, size, PROT_READ | PROT_WRITE, MAP_SHARED, dfd, 0)) == MAP_FAILED) { return -1; }     // 关闭文件     close(sfd);     close(dfd);     // 直接内存拷贝     memcpy(dst, src, size);     // 执行同步操作     msync(dst, size, MS_SYNC);     return 0; } 

此例中用到的其它c方法说明:

  • truncate方法:该方法会将参数path指定的文件大小改为参数length指定的大小,如果原来的文件大小比参数length大,则超过的部分会被删除,例如需清空文件时可将length设置为0,该方法执行成功则返回0,失败返回-1
  • memcpy方法:内存拷贝,参数1为目标内存起始地址,参数2为原数据内存起始地址,参数3为数据长度。

该程序第一步是使用open系统调用打开文件获取文件句柄;第二步是通过lseek计算获取文件大小(这是笔者参照JavaRandomAccessFile类的length方法获取文件大小的实现);第三步是改变目标文件的大小,让目标文件的大小与原文件大小一样;第四步是关闭文件,映射成功后就已经不需要用到文件句柄了;第五步是直接内存拷贝,将原文件映射的内存拷贝到目标文件映射的内存;最后调用msync同步内存更改到文件中。