歡迎光臨
每天分享高質量文章

JDK 原始碼閱讀 : FileDescriptor

(點選上方公眾號,可快速關註)


來源:木杉的部落格 ,

imushan.com/2018/05/29/java/language/JDK原始碼閱讀-FileDescriptor/

作業系統使用檔案描述符來指代一個開啟的檔案,對檔案的讀寫操作,都需要檔案描述符作為引數。Java雖然在設計上使用了抽象程度更高的流來作為檔案操作的模型,但是底層依然要使用檔案描述符與作業系統互動,而Java世界裡檔案描述符的對應類就是FileDescriptor。

Java檔案操作的三個類:FileIntputStream,FileOutputStream,RandomAccessFile,開啟這些類的原始碼可以看到都有一個FileDescriptor成員變數。

註:本文使用的JDK版本為8。

FileDescriptor與檔案描述符

作業系統中的檔案描述符本質上是一個非負整數,其中0,1,2固定為標準輸入,標準輸出,標準錯誤輸出,程式接下來開啟的檔案使用當前行程中最小的可用的檔案描述符號碼,比如3。

檔案描述符本身就是一個整數,所以FileDescriptor的核心職責就是儲存這個數字:

public final class FileDescriptor {

    private int fd;

}

但是檔案描述符是無法在Java程式碼裡設定的,因為FileDescriptor只有私有和無參的建構式:

public FileDescriptor() {

    fd = -1;

}

private FileDescriptor(int fd) {

    this.fd = fd;

}

那Java是在何時會設定FileDescriptor的fd欄位呢?這要結合FileIntputStream,FileOutputStream,RandomAccessFile的程式碼來看了。

我們以FileInputStream為例,首先,FileInputStream有一個FileDescriptor成員變數:

public class FileInputStream extends InputStream

{

    private final FileDescriptor fd;

在FileInputStream實體化時,會新建FileDescriptor實體,並使用fd.attach(this)關聯FileInputStream實體與FileDescriptor實體,這是為了日後關閉檔案描述符做準備。

public FileInputStream(File file) throws FileNotFoundException {

    String name = (file != null ? file.getPath() : null);

    fd = new FileDescriptor();

    fd.attach(this);

    path = name;

    open(name);

}

private void open(String name) throws FileNotFoundException {

    open0(name);

}

private native void open0(String name) throws FileNotFoundException;

但是上面的程式碼也沒有對FileDescriptor#fd進行賦值,實際上Java層面無法對他賦值,真正的邏輯是在FileInputStream#open0這個native方法中,這就要下載JDK的原始碼來看了:

// /jdk/src/share/native/java/io/FileInputStream.c

JNIEXPORT void JNICALL

Java_java_io_FileInputStream_open(JNIEnv *env, jobject this, jstring path) {

    fileOpen(env, this, path, fis_fd, O_RDONLY);

}

// /jdk/src/solaris/native/java/io/io_util_md.c

void

fileOpen(JNIEnv *env, jobject this, jstring path, jfieldID fid, int flags)

{

    WITH_PLATFORM_STRING(env, path, ps) {

        FD fd;

#if defined(__linux__) || defined(_ALLBSD_SOURCE)

        /* Remove trailing slashes, since the kernel won’t */

        char *p = (char *)ps + strlen(ps) – 1;

        while ((p > ps) && (*p == ‘/’))

            *p– = ‘\0’;

#endif

        fd = JVM_Open(ps, flags, 0666); // 開啟檔案拿到檔案描述符

        if (fd >= 0) {

            SET_FD(this, fd, fid); // 非負整數認為是正確的檔案描述符,設定到fd欄位

        } else {

            throwFileNotFoundException(env, path);  // 負數認為是不正確檔案描述符,丟擲FileNotFoundException異常

        }

    } END_PLATFORM_STRING(env, ps);

}

可以看到JDK的JNI程式碼中,使用JVM_Open開啟檔案,得到檔案描述符,而JVM_Open已經不是JDK的方法了,而是JVM提供的方法,所以我們需要在hotspot中尋找其實現:

// /hotspot/src/share/vm/prims/jvm.cpp

JVM_LEAF(jint, JVM_Open(const char *fname, jint flags, jint mode))

  JVMWrapper2(“JVM_Open (%s)”, fname);

  //%note jvm_r6

  int result = os::open(fname, flags, mode);  // 呼叫os::open開啟檔案

  if (result >= 0) {

    return result;

  } else {

    switch(errno) {

      case EEXIST:

        return JVM_EEXIST;

      default:

        return -1;

    }

  }

JVM_END

// /hotspot/src/os/linux/vm/os_linux.cpp

int os::open(const char *path, int oflag, int mode) {

  if (strlen(path) > MAX_PATH – 1) {

    errno = ENAMETOOLONG;

    return -1;

  }

  int fd;

  int o_delete = (oflag & O_DELETE);

  oflag = oflag & ~O_DELETE;

  fd = ::open64(path, oflag, mode);  // 呼叫open64開啟檔案

  if (fd == -1) return -1;

  // 問開啟成功也可能是目錄,這裡還需要判斷是否開啟的是普通檔案

  {

    struct stat64 buf64;

    int ret = ::fstat64(fd, &buf64;);

    int st_mode = buf64.st_mode;

    if (ret != -1) {

      if ((st_mode & S_IFMT) == S_IFDIR) {

        errno = EISDIR;

        ::close(fd);

        return -1;

      }

    } else {

      ::close(fd);

      return -1;

    }

  }

#ifdef FD_CLOEXEC

    {

        int flags = ::fcntl(fd, F_GETFD);

        if (flags != -1)

            ::fcntl(fd, F_SETFD, flags | FD_CLOEXEC);

    }

#endif

  if (o_delete != 0) {

    ::unlink(path);

  }

  return fd;

}

可以看到JVM最後使用open64這個方法開啟檔案,網上對於open64這個資料還是很少的,我找到的是man page for open64 (all section 2) – Unix & Linux Commands,從中可以看出,open64是為了在32位環境開啟大檔案的系統呼叫,但是不是標誌的一部分。

https://www.unix.com/man-page/All/2/open64/

這裡的open不是我們以前學C語言時開啟檔案用的fopen函式,fopen是C標準庫裡的函式,而open不是,open是POSIX規範中的函式,是不帶緩衝的I/O,不帶緩衝的I/O相關的函式還有read,write,lseek,close,不帶緩衝指的是這些函式都呼叫核心中的一個系統呼叫,而C標準庫為了減少系統呼叫,使用了快取來減少read,write的記憶體呼叫。(參考《UNIX環境高階程式設計》)

透過上面的程式碼跟蹤,我們知道了FileInputStream#open是使用open系統呼叫來開啟檔案,得到檔案控制代碼,現在我們的問題要回到這個檔案控制代碼是如何最終設定到FileDescriptor#fd,我們來看/jdk/src/solaris/native/java/io/io_util_md.c:fileOpen的關鍵程式碼:

fd = handleOpen(ps, flags, 0666);

if (fd != -1) {

    SET_FD(this, fd, fid);

} else {

    throwFileNotFoundException(env, path);

}

如果檔案描述符fd正確,透過SET_FD這個紅設定到fid對應的成員變數上:

#define SET_FD(this, fd, fid) \

    if ((*env)->GetObjectField(env, (this), (fid)) != NULL) \

        (*env)->SetIntField(env, (*env)->GetObjectField(env, (this), (fid)),IO_fd_fdID, (fd))

SET_FD宏比較簡單,獲取FileInputStream上的fid這個欄位ID對應的欄位,然後設定這個欄位的IO_fd_fdID對應的欄位(FileDescriptor#fd)為檔案描述符。

那這個fid和IO_fd_fdID是哪裡來的呢?在/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;”);

}

Java_java_io_FileInputStream_initIDs對應FileInputStream中static塊呼叫的initIDs函式:

public class FileInputStream extends InputStream

{

    /* File Descriptor – handle to the open file */

    private final FileDescriptor fd;

    static {

        initIDs();

    }

    private static native void initIDs();

    // …

}

還有jdk/src/solaris/native/java/io/FileDescriptor_md.c開頭:

/* field id for jint ‘fd’ in java.io.FileDescriptor */

jfieldID IO_fd_fdID;

/**************************************************************

 * static methods to store field ID’s in initializers

 */

JNIEXPORT void JNICALL

Java_java_io_FileDescriptor_initIDs(JNIEnv *env, jclass fdClass) {

    IO_fd_fdID = (*env)->GetFieldID(env, fdClass, “fd”, “I”);

}

Java_java_io_FileDescriptor_initIDs對應FileDescriptor中static塊呼叫的initIDs函式:

public final class FileDescriptor {

    private int fd;

    static {

        initIDs();

    }

    /* This routine initializes JNI field offsets for the class */

    private static native void initIDs();

}

從程式碼可以看出這樣的一個流程:

  1. JVM載入FileDescriptor類,執行static塊中的程式碼

  2. 執行static塊中的程式碼時,執行initIDs本地方法

  3. initIDs本地方法只做了一件事情,就是獲取fd欄位ID,並儲存在IO_fd_fdID變數中

  4. JVM載入FileInputStream類,執行static塊中的程式碼

  5. 執行static塊中的程式碼時,執行initIDs本地方法

  6. initIDs本地方法只做了一件事情,就是獲取fd欄位ID,並儲存在fis_fd變數中

  7. 後續邏輯直接使用IO_fd_fdID和fis_fd

為什麼會有這樣一個奇怪的初始化過程呢,為什麼要專門弄一個initIDs方法來提前儲存欄位ID呢?這是因為特定類的欄位ID在一次Java程式的宣告週期中是不會變化的,而獲取欄位ID本身是一個比較耗時的過程,因為如果欄位是從父類繼承而來,JVM需要遍歷繼承樹來找到這個欄位,所以JNI程式碼的最佳實踐就是對使用到的欄位ID做快取。(參考使用 Java Native Interface 的最佳實踐

http://www.ibm.com/developerworks/cn/java/j-jni/index.html

標準輸入,標準輸出,標準錯誤輸出

標準輸入,標準輸出,標準錯誤輸出是所有作業系統都支援的,對於一個行程來說,檔案描述符0,1,2固定是標準輸入,標準輸出,標準錯誤輸出。

Java對標準輸入,標準輸出,標準錯誤輸出的支援也是透過FileDescriptor實現的,FileDescriptor中定義了in,out,err這三個靜態變數:

public static final FileDescriptor in = new FileDescriptor(0);

public static final FileDescriptor out = new FileDescriptor(1);

public static final FileDescriptor err = new FileDescriptor(2);

我們常用的System.out等,就是基於這三個封裝的:

public final class System {

    public final static InputStream in = null;

    public final static PrintStream out = null;

    public final static PrintStream err = null;

    /**

    * Initialize the system class.  Called after thread initialization.

    */

    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/out/err,構造發生在initializeSystemClass被呼叫時,但是in/out/err是被宣告為final的,如果宣告時和類構造時沒有賦值,是會報錯的,所以System在實現時,先設定為null,然後透過native方法來在執行時修改(學到了不少奇技淫巧。。),透過setIn0/setOut0/setErr0的註釋也可以說明這一點:

/*

 * The following three functions implement setter methods for

 * java.lang.System.{in, out, err}. They are natively implemented

 * because they violate the semantics of the language (i.e. set final

 * variable).

 */

JNIEXPORT void JNICALL

Java_java_lang_System_setIn0(JNIEnv *env, jclass cla, jobject stream)

{

    jfieldID fid =

        (*env)->GetStaticFieldID(env,cla,”in”,”Ljava/io/InputStream;”);

    if (fid == 0)

        return;

    (*env)->SetStaticObjectField(env,cla,fid,stream);

}

JNIEXPORT void JNICALL

Java_java_lang_System_setOut0(JNIEnv *env, jclass cla, jobject stream)

{

    jfieldID fid =

        (*env)->GetStaticFieldID(env,cla,”out”,”Ljava/io/PrintStream;”);

    if (fid == 0)

        return;

    (*env)->SetStaticObjectField(env,cla,fid,stream);

}

JNIEXPORT void JNICALL

Java_java_lang_System_setErr0(JNIEnv *env, jclass cla, jobject stream)

{

    jfieldID fid =

        (*env)->GetStaticFieldID(env,cla,”err”,”Ljava/io/PrintStream;”);

    if (fid == 0)

        return;

    (*env)->SetStaticObjectField(env,cla,fid,stream);

}

FileDescriptor關閉邏輯

FileDescriptor的程式碼不多,除了上面提到的fd成員變數,initIDs初始化構造方法,in/out/err三個標準描述符,只剩下attach和closeAll這兩個方法,這兩個方法和檔案描述符的關閉有關。

上文提到過,FileInputStream在實體化時,會新建FileDescriptor並呼叫FileDescriptor#attach方法系結檔案流與檔案描述符。

public FileInputStream(File file) throws FileNotFoundException {

    String name = (file != null ? file.getPath() : null);

    fd = new FileDescriptor();

    fd.attach(this);

    path = name;

    open(name);

}

FileDescriptor#attach實現如下:

synchronized void attach(Closeable c) {

    if (parent == null) {

        // first caller gets to do this

        parent = c;

    } else if (otherParents == null) {

        otherParents = new ArrayList<>();

        otherParents.add(parent);

        otherParents.add(c);

    } else {

        otherParents.add(c);

    }

}

如果FileDescriptor只和一個FileInputStream/FileOutputStream/RandomAccessFile有關聯,則只是簡單的儲存到parent成員中,如果有多個FileInputStream/FileOutputStream/RandomAccessFile有關聯,則所有關聯的Closeable都儲存到otherParents這個ArrayList中。

這裡其實有個細節,就是parent變數其實只在這個函式有用到,所以上面的邏輯完全可以寫成無論FileDescriptor和幾個Closeable物件有關聯,都直接儲存到otherParents這個ArrayList即可,但是極大的機率,一個FileDescriptor只會和一個FileInputStream/FileOutputStream/RandomAccessFile有關聯,只有使用者呼叫FileInputStream(FileDescriptor fdObj)這樣樣的建構式才會出現多個Closeable物件對應一個FileDescriptor的情況,這裡其實是做了最佳化,在大機率的情況下不新建ArrayList,減少一個物件的建立開銷。

接著看看FileInputStream如何進行關閉操作,如何關閉關聯的FileDescriptor:

public void close() throws IOException {

    synchronized (closeLock) {

        if (closed) {

            return;

        }

        closed = true;

    }

    if (channel != null) {

        channel.close();

    }

    fd.closeAll(new Closeable() {

        public void close() throws IOException {

            close0();

        }

    });

}

private native void close0() throws IOException;

首先透過鎖保證關閉流程不會被併發呼叫,設定成員closed為true,接著關閉關聯的Channel,這個以後分析NIO的時候再來說。接著就是關閉FileDescriptor了。

FileDescriptor沒有提供close方法,而是提供了一個closeAll方法:

synchronized void closeAll(Closeable releaser) throws IOException {

    if (!closed) {

        closed = true;

        IOException ioe = null;

        try (Closeable c = releaser) {

            if (otherParents != null) {

                for (Closeable referent : otherParents) {

                    try {

                        referent.close();

                    } catch(IOException x) {

                        if (ioe == null) {

                            ioe = x;

                        } else {

                            ioe.addSuppressed(x);

                        }

                    }

                }

            }

        } catch(IOException ex) {

            /*

             * If releaser close() throws IOException

             * add other exceptions as suppressed.

             */

            if (ioe != null)

                ex.addSuppressed(ioe);

            ioe = ex;

        } finally {

            if (ioe != null)

                throw ioe;

        }

    }

}

FileDescriptor的關閉流程有點繞,效果是會把關聯的Closeable物件(其實只可能是FileInputStream/FileOutputStream/RandomAccessFile,而這三個類的close方法實現是一模一樣的)通通都關閉掉(效果是這些物件的closed設定為true,關聯的Channel關閉,這樣這個物件就無法使用了),最後這些關聯的物件中,只會有一個物件的close0本地方法被呼叫,這個方法中呼叫系統呼叫close來真正關閉檔案描述符:

// /jdk/src/solaris/native/java/io/FileInputStream_md.c

JNIEXPORT void JNICALL

Java_java_io_FileInputStream_close0(JNIEnv *env, jobject this) {

    fileClose(env, this, fis_fd);

}

// /jdk/src/solaris/native/java/io/io_util_md.c

void fileClose(JNIEnv *env, jobject this, jfieldID fid)

{

    FD fd = GET_FD(this, fid);

    if (fd == -1) {

        return;

    }

    /* Set the fd to -1 before closing it so that the timing window

     * of other threads using the wrong fd (closed but recycled fd,

     * that gets re-opened with some other filename) is reduced.

     * Practically the chance of its occurance is low, however, we are

     * taking extra precaution over here.

     */

    SET_FD(this, -1, fid);

    // 嘗試關閉0,1,2檔案描述符,需要特殊的操作。首先這三個是不能關閉的,

    // 如果關閉的,後續開啟的檔案就會佔用這三個描述符,

    // 所以合理的做法是把要關閉的描述符指向/dev/null,實現關閉的效果

    // 不過Java程式碼中,正常是沒辦法關閉0,1,2檔案描述符的

    if (fd >= STDIN_FILENO && fd <= STDERR_FILENO) {

        int devnull = open(“/dev/null”, O_WRONLY);

        if (devnull < 0) {

            SET_FD(this, fd, fid); // restore fd

            JNU_ThrowIOExceptionWithLastError(env, “open /dev/null failed”);

        } else {

            dup2(devnull, fd);

            close(devnull);

        }

    } else if (close(fd) == -1) { // 關閉非0,1,2的檔案描述符只是呼叫close系統呼叫

        JNU_ThrowIOExceptionWithLastError(env, “close failed”);

    }

}

在回頭來討論一個問題,就是為什麼關閉一個FileInputStream/FileOutputStream/RandomAccessFile,就要把他關聯的檔案描述符所關聯的所有FileInputStream/FileOutputStream/RandomAccessFile物件都關閉呢?

這個可以看看FileInputStream#close的JavaDoc:

Closes this file input stream and releases any system resources

associated with the stream.

If this stream has an associated channel then the channel is closed

as well.

也就是說FileInputStream#close是會吧輸入/出流對應的系統資源關閉的,也就是輸入/出流對應的檔案描述符會被關閉,而如果這個檔案描述符還關聯這其他輸入/出流,如果檔案描述符都被關閉了,這些流自然也就不能用了,所以closeAll裡把這些關聯的流通通都關閉掉,使其不再可用。

總結

  • FileDescriptor的作用是儲存作業系統中的檔案描述符

  • FileDescriptor實體會被FileInputStream/FileOutputStream/RandomAccessFile持有,這三個類在開啟檔案時,在JNI程式碼中使用open系統呼叫開啟檔案,得到檔案描述符在JNI程式碼中設定到FileDescriptor的fd成員變數上

  • 關閉FileInputStream/FileOutputStream/RandomAccessFile時,會關閉底層對應的檔案描述符,如果此檔案描述符被多個FileInputStream/FileOutputStream/RandomAccessFile物件持有,則這些物件都會被關閉。關閉是檔案底層是透過呼叫close系統呼叫實現的。

參考資料

  • 《UNIX環境高階程式設計》

  • 每天進步一點點——Linux中的檔案描述符與開啟檔案之間的關係 – CSDN部落格

    https://blog.csdn.net/cywosp/article/details/38965239

  • UNIX再學習 – 檔案描述符 – CSDN部落格

    https://blog.csdn.net/qq_29350001/article/details/65437279

  • Linux探秘之使用者態與核心態 – aCloudDeveloper – 部落格園

    https://www.cnblogs.com/bakari/p/5520860.html

  • 關於核心態和使用者態切換開銷的測試 – fireworks – 部落格園

    https://www.cnblogs.com/sfireworks/p/4428972.html

  • 系統呼叫真正的效率瓶頸在哪裡? – 知乎

    https://www.zhihu.com/question/32043825

  • 使用 Java Native Interface 的最佳實踐

    http://www.ibm.com/developerworks/cn/java/j-jni/index.html

  • java – Why closing an Input Stream closes the associated File Descriptor as well, even the File Descriptor is shared among multiple streams ? – Stack Overflow

    https://stackoverflow.com/questions/34980241/why-closing-an-input-stream-closes-the-associated-file-descriptor-as-well-even

【關於投稿】


如果大家有原創好文投稿,請直接給公號傳送留言。


① 留言格式:
【投稿】+《 文章標題》+ 文章連結

② 示例:
【投稿】《不要自稱是程式員,我十多年的 IT 職場總結》:http://blog.jobbole.com/94148/

③ 最後請附上您的個人簡介哈~



看完本文有收穫?請轉發分享給更多人

關註「ImportNew」,提升Java技能

贊(0)

分享創造快樂