程序员:必须得深入理解Java文件输入输出流和文件描述符
本文将深入理解文件描述符,并从 JDK 源码上分析文件描述符在文件输入输出流中的运用。
点个赞收藏下吧👍
特别声明,为避免重复造轮子,部分内容和图片摘自文末参考资料。本文仅限用于交流学习,严禁用于商业用途。
文件描述符是什么?
[1] 在Linux系统中一切皆可以看成是文件,文件又可分为:普通文件、目录文件、链接文件和设备文件。文件描述符(file descriptor)是内核为了高效管理已被打开的文件所创建的索引,其是一个非负整数(通常是小整数),用于指代被打开的文件,所有执行I/O操作的系统调用都通过文件描述符。程序刚刚启动的时候,0是标准输入,1是标准输出,2是标准错误。如果此时去打开一个新的文件,它的文件描述符会是3。POSIX标准要求每次打开文件时(含socket)必须使用当前进程中最小可用的文件描述符号码,因此,在网络通信过程中稍不注意就有可能造成串话。标准文件描述符图如下:
Linux 进程中的文件描述符
[2] 从 Linux 进程的数据结构也可以看出端倪:
struct task_struct {
// 进程状态
long state;
// 虚拟内存结构体
struct mm_struct *mm;
// 进程号
pid_t pid;
// 指向父进程的指针
struct task_struct __rcu *parent;
// 子进程列表
struct list_head children;
// 存放文件系统信息的指针
struct fs_struct *fs;
// 一个数组,包含该进程打开的文件指针
struct files_struct *files;
};
files 指针指向一个数组,这个数组里装着所有该进程打开的文件的指针。每个进程被创建时,files 的前三位被填入默认值,分别指向标准输入流、标准输出流、标准错误流。我们常说的「文件描述符」就是指这个文件指针数组的索引,所以程序的文件描述符默认情况下 0 是输入,1 是输出,2 是错误。如下图所示:
进程文件描述符所以,在 Linux 中的重定向、管道等操作,只不过是修改了进程的 files 数组前三位的指向。”一切皆文件“的设计思想使这些操作都变得非常优雅。
FileDescriptor
FileDescriptor 是文件描述符在 JVM 中的抽象。来看看内部结构:
public final class FileDescriptor {
// 文件描述符(就是上文所说的 files 数组下标)
private int fd;
// 该文件描述符所关联的实例(通常是输入输出流实例,比如 FileInputStream )
private Closeable parent;
private List<Closeable> otherParents;
private boolean closed;
// 标准输入
public static final FileDescriptor in = new FileDescriptor(0);
// 标准输出
public static final FileDescriptor out = new FileDescriptor(1);
// 标准错误
public static final FileDescriptor err = new FileDescriptor(2);
public boolean valid() {
return fd != -1;
}
/* This routine initializes JNI field offsets for the class */
private static native void initIDs();
static {
initIDs();
}
}
FileDescriptor 非常清晰,我们可以直接总结以下几点:
- FileDescriptor 与文件描述符一一对应,使用 fd 字段保存文件描述符;
- 单个 FileDescriptor 可以和多个 Closeable 关联(通常是输入输出流实例,比如 FileInputStream );
- FileDescriptor 内部有三个公开静态常量 in、out 和 err 分别代表标准输入、标准输出和标准错误,这仨通常用在 java.lang.System 中;
- 文件描述符 fd 通常为非负数;
initIDs
initIDs 方法用于初始化 fd 字段的 ID (我觉得可以理解为 fd 字段的指针)。这是一个 native 方法,可以在 JDK 源码里找到相应的 JNI 实现。以 Window 下的实现为例(因为 Window 的相对容易找到 = =):
/* field id for jint 'fd' in java.io.FileDescriptor */
jfieldID IO_fd_fdID;
/* field id for jlong 'handle' in java.io.FileDescriptor */
jfieldID IO_handle_fdID;
/**************************************************************
* static methods to store field IDs in initializers
*/
JNIEXPORT void JNICALL
Java_java_io_FileDescriptor_initIDs(JNIEnv *env, jclass fdClass) {
CHECK_NULL(IO_fd_fdID = (*env)->GetFieldID(env, fdClass, "fd", "I"));
CHECK_NULL(IO_handle_fdID = (*env)->GetFieldID(env, fdClass, "handle", "J"));
}
可见 fd 的字段ID被保存到了全局字段中,后续其他代码可以根据其字段ID来修改 fd 的值。字段ID在这里我理解为一个指针。这里还处理了 handle 字段,可能是版本问题,我没有在 JDK 里看到这个字段。
文件输入输出流
Java IO 里的文件输入输出流类有二:FileInputStream 和 FileOutputStream。两者的类图继承结构非常清晰:
文件输入输出流类图因为两者实现原理差不多,下边以 FileInputStream 展开探讨。
FileInputStream
先来看看 FileInputStream 的源码。
内部字段很少很简洁,详见注释:
public
class FileInputStream extends InputStream
/* 文件描述符对象**/
private final FileDescriptor fd;
/** 文件路径 **/
private final String path;
/** 可以操作读写文件的通道 **/
private FileChannel channel = null;
/** 关闭时用于并发控制的锁对象 **/
private final Object closeLock = new Object();
private volatile boolean closed = false;
}
还有一个与 FileDescriptor 类似的 initIDs 方法,也是用来设置内部的 fd 字段ID:
private static native void initIDs();
private native void close0() throws IOException;
static {
initIDs();
}
构造方法也挺简单的,关键是看看其中这个构造函数:
public FileInputStream(File file) throws FileNotFoundException {
String name = (file != null ? file.getPath() : null);
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkRead(name);
}
if (name == null) {
throw new NullPointerException();
}
if (file.isInvalid()) {
throw new FileNotFoundException("Invalid file path");
}
// 新建一个文件描述符对象
fd = new FileDescriptor();
// 将当前 FileInputStream 和该文件描述符关联起来
fd.attach(this);
path = name;
// 打开该文件
open(name);
}
构造函数中新建了一个文件描述符对象 fd,要记得这个对象的内部还有一个 long 类型的 fd 字段,默认初始化为 0L。而 fd 字段的初始化逻辑是在 open 方法。最终调用的是 native 方法:
/**
* Opens the specified file for reading.
* @param name the name of the file
*/
private native void open0(String name) throws FileNotFoundException;
题外话,这里提一下怎么通过 这个 native 方法找到对应的 C++ 实现:
- 下载相应版本的 JDK 源码;
- 找到 jdk/src/share/classes/java/io/FileInputStream.java;
- 执行 javah java.io.FileInputStream 便可以生成 Header文件 java_io_FileInputStream.h :
/* * Class: java_io_FileInputStream * Method: open0 * Signature: (Ljava/lang/String;)V */ JNIEXPORT void JNICALL Java_java_io_FileInputStream_open0 (JNIEnv *, jobject, jstring);
- 使用 C++ 方法名 Java_java_io_FileInputStream_open0 进行搜索便很快能找到对应的 C++ 方法实现;
我们查看 JDK 源码(jdk/src/share/native/java/io/FileInputStream.c),有以下代码:
jfieldID fis_fd; /* id for jobject 'fd' in java.io.FileInputStream */
/**************************************************************
* static methods to store field ID's in initializers
*/
JNIEXPORT void JNICALL
Java_java_io_FileInputStream_initIDs(JNIEnv *env, jclass fdClass) {
fis_fd = (*env)->GetFieldID(env, fdClass, "fd", "Ljava/io/FileDescriptor;");
}
/**************************************************************
* Input stream
*/
JNIEXPORT void JNICALL
Java_java_io_FileInputStream_open0(JNIEnv *env, jobject this, jstring path) {
fileOpen(env, this, path, fis_fd, O_RDONLY);
}
这里可以看到 initIDs 方法实现,其逻辑是将名为“fd”的 FileDescriptor 类型对象的字段ID保存到全局变量 fid中;
而 open0 方法调用了 fileOpen。在不同的操作系统上有不同的实现,以 Window 为例:
void
fileOpen(JNIEnv *env, jobject this, jstring path, jfieldID fid, int flags)
{
FD h = winFileHandleOpen(env, path, flags);
if (h >= 0) {
SET_FD(this, h, fid);
}
}
这里对展开 winFileHandleOpen 方法不感兴趣,大体意思就是调用 Window 的系统方法打开了文件,并返回了文件描述符 h。
然后调用 SET_FD 方法将文件描述符 h 设置到 fid 中。fid 就是 initIDs 所缓存的字段ID(理解为 fd 字段的指针)。
至此,FileInputStream 和文件描述符关联了起来。后续在 FileInputStream 上的读写,JVM 都可以通过其内部的 fd 字段非常方便地找到需要读写的文件!所以,FileInputStream 还支持指定文件描述符的构造形式:
FileOutputStream fdOut = new FileOutputStream(FileDescriptor.out);
这其实就是 System.out 的实现。
总结
我们学习了 Linux 系统的文件操作符概念,理解了”一切皆是文件“的设计理念。此外,还深入学习了文件输入输出流类的源码实现,探讨它们是怎么利用文件描述符和操作系统进行交互。希望大家有所收获!点个赞收藏下吧👍
参考资料
特别声明,本文部分段落摘自以下资料。
- [1] 《每天进步一点点——Linux中的文件描述符与打开文件之间的关系》
- [2] 《Linux 进程、线程、文件描述符的底层原理》
其他参考资料
- Java IO流之文件描述符FileDescriptor
- 文件描述符(File Descriptor)简介:这文章讲到文件描述符限制的相关命令,非常实用!
- 如何查找 jdk 中的 native 实现]:查看 JDK 源码必备技能
- 高级Java工程师必备 ----- 深入分析 Java IO (三):介绍Java IO 类库的好文