在Java編程中,對於一些文件的使用往往需要主動釋放,比如InputStream
,OutputStream
,SocketChannel
等等,那麼有沒有想過爲什麼要主動釋放這些資源?難道GC回收時不會釋放嗎?本文主要是對這一系列問題分析解答。(本文所使用的環境默認爲Linux)
應用是如何操作文件的?
在Linux系統中有一種一切皆文件的說法,無論是真實的文件,還是網絡的Socket連接,或者是掛載的磁盤等等,操作系統所規定只要內核纔有權限操作這些文件,應用的文件操作則必須委託操作系統內核來執行,這也是常說的內核態與用戶態。那麼在內核與應用之間就需要有一個關聯關係,來標識用戶所要操作的文件,在Linux下就是文件描述符。換句話說文件描述符的存在是爲應用程序與基礎操作系統之間的交互提供了通用接口。 引用網上一張圖片
那麼由圖可知以下特性:
- 每一個進程有自己的文件描述符表
- 不同的描述符可能指向同一個文件,文件描述符這個數字只是針對當前進程有意義。
Java是如何操作文件的?
在Java中對文件的操作都是通過FileDescriptor
,然後JNI調用對應的C代碼,在調用系統函數來進行操作,下面會詳細分析下具體實現方式。
FileInputStream的創建
在Java中打開一個文件一般使用FileInputStream
,其主要屬性字段如下:
清單1:FileInputStream的屬性字段
// 文件描述符 private final FileDescriptor fd; // 文件路徑 private final String path; // 文件Channel,後面再說 private FileChannel channel = null; // 文件關閉鎖 private final Object closeLock = new Object(); // 文件關閉標識 private volatile boolean closed = false;
其中FileDescriptor
文件描述符就是Java與操作系統之間關於文件的連接,那麼FileDescriptor fd;
是在什麼時候賦值的呢?這裏取自YuKai’s blog相關內容
清單2:FileInputStream打開一個文件
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(); fd.incrementAndGetUseCount(); this.path = name; open(name); } static { initIDs(); }
注意到initIDs()
這個靜態方法:
清單3:FileInputStream initIDs方法
jfieldID fis_fd; /* id for jobject 'fd' in java.io.FileInputStream */ JNIEXPORT void JNICALL Java_java_io_FileInputStream_initIDs(JNIEnv *env, jclass fdClass) { fis_fd = (*env)->GetFieldID(env, fdClass, "fd", "Ljava/io/FileDescriptor;"); }
在FileInputStream
類加載階段,fis_fd
就被初始化了,fid_fd
相當於是FileInputStream.fd
字段的一個內存偏移量,便於在必要時操作內存給它賦值。
看一下FileDescriptor
的實例化過程:
清單4:FileDescriptor實例化過程
public /**/ FileDescriptor() { fd = -1; handle = -1; useCount = new AtomicInteger(); } static { initIDs(); } // initIDs()方法對應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"); }
FileDescriptor
也有一個initIDs
,他和FileInputStream.initIDs
的方法類似,把設置IO_fd_fdID
爲FileDescriptor.fd
字段的內存偏移量。
接下來再看FileInputStream
構造函數中的open(name)
方法,字面上看,這個方法打開了一個文件,他也是一個本地方法,open方法直接調用了fileOpen方法,fileOpen方法如下:
清單5:FileInputStream打開文件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 = handleOpen(ps, flags, 0666); if (fd != -1) { // 設置文件描述符 SET_FD(this, fd, fid); } else { throwFileNotFoundException(env, path); } } END_PLATFORM_STRING(env, ps); } // 因爲initIDs方法拿到了對應字段的引用,因此這裏直接設置文件描述符 #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))
打開一個文件本質上是調用操作系統指令,然後獲取一個文件操作符整數,再設置到對應的Java變量上,那麼接下來的讀取寫入關閉等等都是通過文件描述符來調用系統命令處理。
FileChannel創建
FileChannel
的創建依賴於FileDescriptor
,其本質仍然是對文件操作符的處理,不過在處理方式上使用零拷貝等技術加速對文件的操作
清單6:FileChannel的創建
public FileChannel getChannel() { synchronized (this) { if (channel == null) { channel = FileChannelImpl.open(fd, path, true, false, this); } return channel; } }
Socket的創建
在SocketChannelImpl
中,socket的建立最終返回的也是FileDescriptor
,然後應用程序的操作都會通過FileDescriptor
映射到對應的socket上。
清單7:SocketChannel的創建
SocketChannelImpl(SelectorProvider var1) throws IOException { super(var1); this.fd = Net.socket(true); this.fdVal = IOUtil.fdVal(this.fd); this.state = 0; }
沒有主動關閉文件的後果
由上面的分析可以得出,Java中對文件的操作本質都是獲取文件操作符在調用系統命令處理,關閉文件本質上也是調用C提供的close(fd)
方法,如下代碼所示:
清單8:JDK關閉一個文件
void fileClose(JNIEnv *env, jobject this, jfieldID fid) { FD fd = GET_FD(this, fid); if (fd == -1) { return; } // 設置Java對象的fd爲-1 SET_FD(this, -1, fid); // 對於標準輸入,輸出,錯誤不關閉,指向/dev/null 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); } // 調用close(fd)方法關閉 } else if (close(fd) == -1) { JNU_ThrowIOExceptionWithLastError(env, "close failed"); } }
那麼不關閉有什麼後果呢?
- 不關閉就造成文件描述符無法釋放,屬於一種系統文件的浪費
- 不關閉可能造成對文件的寫入丟失,寫入有可能存在緩存區,沒有關閉並且沒有主動flush到具體的文件上,則可能造成丟失。
- 如果該文件被文件鎖獨佔,那麼就會造成其他線程無法操作該文件。
- Too many open files錯誤,操作系統針對一個進程分配的文件描述符表是有限大小的,因此打開不釋放可能造成該表溢出。
對象被GC後文件會被關閉嗎?
答案是不確定,GC理論上管理的是內存中的對象,並不會理會文件文件,並且GC具有不確定性。在Java中對象被釋放之前會調用finalize()
方法,因此JDK的一些實現會在該方法中加入關閉操作,比如FileInputStream
,這是JDK對程序員可能犯不關閉文件的一種補償操作。
清單9:FileInputStream的finalize實現
protected void finalize() throws IOException { if ((fd != null) && (fd != FileDescriptor.in)) { /* if fd is shared, the references in FileDescriptor * will ensure that finalizer is only called when * safe to do so. All references using the fd have * become unreachable. We can call close() */ close(); } }
因此最好的做法是養成用完文件就關閉的好習慣,對於Java來說自然是放在finally
塊中關閉最爲可靠,依賴GC去關閉是相當不可靠的做法。