程序员:必须得深入理解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 类库的好文
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值