前言
前幾天幫公司新人解決了一個多線程問題,問題很簡單。這位同事想用多線程提高文件寫入速度結(jié)果使用同一個文件在多個線程創(chuàng)建的多個 FileOutPutStream 然后使用這個FileOutPutStream 分別向文件中寫入內(nèi)容,可想而知結(jié)果肯定是不正確的。
問題雖然簡單但其實(shí)排查的過程也并非一帆風(fēng)順,究其原因可能還是自己對API不夠熟悉,當(dāng)問題發(fā)生時沒能堅(jiān)信自己的理論導(dǎo)致方向逐漸跑偏浪費(fèi)了很多時間镜沽,所以解決問題后我就對FileOutputStream 進(jìn)行了更為深入的研究。
正文
首先我寫了一個demo復(fù)現(xiàn)當(dāng)時的問題demo如下:
File file = new File("E:/Tmp/1.txt");
new Thread(()->{
try(FileOutputStream outputStream = new FileOutputStream(file);) {
for (int i = 0; i < 1000_0; i++) {
outputStream.write("Thread 1 write 1\n".getBytes(StandardCharsets.UTF_8));
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}).start();
new Thread(()->{
try(FileOutputStream outputStream = new FileOutputStream(file);) {
for (int i = 0; i < 1000_0; i++) {
outputStream.write("Thread 2 write 2\n".getBytes(StandardCharsets.UTF_8));
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}).start();
運(yùn)行結(jié)果: 文本文件中交替出現(xiàn) Thread 1 write 1
和 Thread 2 write 2
在 Debug 的過程當(dāng)中可以發(fā)現(xiàn) FileOutputStream 中有一個屬性名為 FileDescriptor
直譯即為文件描述符,此時可以猜測他應(yīng)該會是輸出流的關(guān)鍵贱田。
我們可以繼續(xù)深入到FileOutputStream 類中尋找
FileDescriptor
是合適被創(chuàng)建的根據(jù)構(gòu)造方法我們很容易找到缅茉,在其中一個構(gòu)造方法中找到
public FileOutputStream(File file, boolean append)
throws FileNotFoundException
{
String name = (file != null ? file.getPath() : null);
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkWrite(name);
}
if (name == null) {
throw new NullPointerException();
}
if (file.isInvalid()) {
throw new FileNotFoundException("Invalid file path");
}
// ------------------------------------------------------------------------------------
// ----------------------------在此處創(chuàng)建文件描述符--------------------------------------
// ------------------------------------------------------------------------------------
this.fd = new FileDescriptor();
fd.attach(this);
this.append = append;
this.path = name;
open(name, append);
}
但此處的 FileDescriptor
并沒有完全初始化完成,對比上文截圖可以發(fā)現(xiàn)其handle屬性并沒有被賦值(后文可以知道它一定是一個大于零的整數(shù))
繼續(xù)Debug 不難看到當(dāng)調(diào)用
open
函數(shù)后handle被賦值男摧,所以查看open
函數(shù)代碼,open
會繼續(xù)調(diào)用open0
方法蔬墩,此時到達(dá)native函數(shù)。
private native void open0(String name, boolean append) throws FileNotFoundException;
注意:低版本Jdk 可能沒有 open0
調(diào)用過程 open
即為 native函數(shù)所以見到直接調(diào)用 native open
也是正常的
接下來需要下載 jdk 源碼(這里下載的是 openjdk 的源碼這些基礎(chǔ)類庫的實(shí)現(xiàn)jdk和 openjdk 基本不會有差別)
jdk8u60 下載地址
其他版本可進(jìn)入openjdk自行選擇下載
Git用戶也可使用Git
git clone -b jdk8-b120 https://github.com/openjdk/jdk.git
下載完畢后使用任意IDE打開耗拓,這里我使用VS Code拇颅,定位到src\windows\native\java\io\FileOutputStream_md.c文件,對應(yīng)的 c 代碼如下
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));
}
可以看到open0
調(diào)用的是 fileOpen
函數(shù),繼續(xù)查看fileOpen
函數(shù)(VS Code 需要安裝 C/C++ Extension 才可以函數(shù)導(dǎo)航)該函數(shù)位于src\windows\native\java\io\io_util_md.c
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(env, path, flags)
FD
winFileHandleOpen(JNIEnv *env, jstring path, int flags)
{
// 準(zhǔn)備 CreateFileW 函數(shù)參數(shù)
// 訪問權(quán)限
const DWORD access =
(flags & O_WRONLY) ? GENERIC_WRITE :
(flags & O_RDWR) ? (GENERIC_READ | GENERIC_WRITE) :
GENERIC_READ;
//共享模式
const DWORD sharing =
FILE_SHARE_READ | FILE_SHARE_WRITE;
//該 文件\設(shè)備是否存在
const DWORD disposition =
/* Note: O_TRUNC overrides O_CREAT */
(flags & O_TRUNC) ? CREATE_ALWAYS :
(flags & O_CREAT) ? OPEN_ALWAYS :
OPEN_EXISTING;
const DWORD maybeWriteThrough =
(flags & (O_SYNC | O_DSYNC)) ?
FILE_FLAG_WRITE_THROUGH :
FILE_ATTRIBUTE_NORMAL;
const DWORD maybeDeleteOnClose =
(flags & O_TEMPORARY) ?
FILE_FLAG_DELETE_ON_CLOSE :
FILE_ATTRIBUTE_NORMAL;
//文件的屬性和操作標(biāo)志位乔询,例如是否為壓縮文件樟插,是否隱藏,是否在釋放資源時自動刪除等
const DWORD flagsAndAttributes = maybeWriteThrough | maybeDeleteOnClose;
HANDLE h = NULL;
WCHAR *pathbuf = pathToNTPath(env, path, JNI_TRUE);
if (pathbuf == NULL) {
/* Exception already pending */
return -1;
}
// ------------------------------------------------------------------------------------
// ------------------------------------關(guān)鍵--------------------------------------------
// ------------------------------------------------------------------------------------
h = CreateFileW(
pathbuf, /* Wide char path name */
access, /* Read and/or write permission */
sharing, /* File sharing flags */
NULL, /* Security attributes */
disposition, /* creation disposition */
flagsAndAttributes, /* flags and attributes */
NULL);
free(pathbuf);
if (h == INVALID_HANDLE_VALUE) {
throwFileNotFoundException(env, path);
return -1;
}
return (jlong) h;
}
到這里我們可以看到該函數(shù)調(diào)用 CreateFileW
函數(shù)從函數(shù)名看來是創(chuàng)建了一個文件竿刁。實(shí)際上它是Windows API中的一個函數(shù)感興趣的可以看下CreateFileW API黄锤,鏈接指向的是 Windows系統(tǒng) API CreateFileW 函數(shù)的文檔。該函數(shù)的作用是打開一個 文件或者 IO設(shè)備并返回一個句柄(句柄是 Windows編程中的一個概念它可以指代 窗口食拜、資源鸵熟、文件等)通過該句柄我們就可以訪問該句柄指向的資源了也就是我們的文件。
其中幾個重要的參數(shù)我也在上文中進(jìn)行了注釋负甸。重點(diǎn)看下 sharing
const DWORD sharing =
FILE_SHARE_READ | FILE_SHARE_WRITE;
他是一個 64 bit 數(shù)據(jù) 每個bit代表不同的模式流强,不同模式間可共存,例如可以同時共享寫和共享讀以下為該參數(shù)的可選值扎抄自微軟官網(wǎng)文檔(就是上邊 CreateFileW API的鏈接)
Value | Meaning |
---|---|
0 0x00000000 | Prevents subsequent open operations on a file or device if they request delete, read, or write access. |
FILE_SHARE_DELETE 0x00000004 | Enables subsequent open operations on a file or device to request delete access. Otherwise, no process can open the file or device if it requests delete access. If this flag is not specified, but the file or device has been opened for delete access, the function fails. Note Delete access allows both delete and rename operations. |
FILE_SHARE_READ 0x00000001 | Enables subsequent open operations on a file or device to request read access. Otherwise, no process can open the file or device if it requests read access. If this flag is not specified, but the file or device has been opened for read access, the function fails. |
FILE_SHARE_WRITE 0x00000002 | Enables subsequent open operations on a file or device to request write access. Otherwise, no process can open the file or device if it requests write access. If this flag is not specified, but the file or device has been opened for write access or has a file mapping with write access, the function fails. |
我們可以看到共享寫和共享讀是寫死的呻待,每個IO流都是默認(rèn)共享讀寫的打月,這也就解釋了為什么我們可以在不同線程使用同一個文件創(chuàng)建多個 FileOutputStream
也許我們都會碰到用其他軟件打開文件并沒關(guān)閉的情況下我們開發(fā)過程中的默寫操作是會失敗的,典型的當(dāng)我們使用壓縮軟件打開maven構(gòu)建的 jar 包時執(zhí)行 maven clean
是會失敗的蚕捉。我想這可能是因?yàn)樗麄兇蜷_文件的方式是非共享模式奏篙,當(dāng)然可能不是 CreateFileW 函數(shù)還可能是 CreateFIle 也是Windows API 中的一個函數(shù)且和 CreateFileW 有著類似的方法簽名
最后可以看到該函數(shù)返回的即使 CreateFileW
創(chuàng)建的句柄,所以讓我們回到 fileOpen
看看句柄返回后如何處理
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);
}
}
當(dāng)句柄創(chuàng)建成功即大于零時鱼冀,會執(zhí)行 SET_FD(this, h, fid);
代碼段报破,還記得上文中說過 FileDescriptor
中 handle 一定會大于零的整數(shù)嗎,此處已經(jīng)初露端倪了千绪。繼續(xù)定位到SET_FD
/*
* Macros to set/get fd from the java.io.FileDescriptor.
* If GetObjectField returns null, SET_FD will stop and GET_FD
* will simply return -1 to avoid crashing VM.
*/
#define SET_FD(this, fd, fid) \
if ((*env)->GetObjectField(env, (this), (fid)) != NULL) \
(*env)->SetLongField(env, (*env)->GetObjectField(env, (this), (fid)), IO_handle_fdID, (fd))
此處是一個宏定義展開的等效形式如下
if((*env)->GetObjectField(env, (this), (fid)) != NULL)
{
(*env)->SetLongField(env, (*env)->GetObjectField(env, (this), (fid)), IO_handle_fdID, (fd))
}
通過注釋也可以知道其功能是為Java 中 Class 實(shí)例的某個屬性賦值充易,轉(zhuǎn)到 IO_handle_fdID
的定義
/* field id for jlong 'handle' in java.io.FileDescriptor */
jfieldID IO_handle_fdID;
注釋同樣標(biāo)注的很清楚此屬性代表了 FileDescriptor
實(shí)例 handle 的屬性ID。接下來我們回到 Java 中定位到 FileDescriptor,可以很輕松找到如下代碼
static {
initIDs();
}
initIDs
也是一個native方法
private static native void initIDs();
所以按照相似的方法定位到src\windows\native\java\io\FileDescriptor_md.c
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"));
}
到這里可以看到 IO_handle_fdID
的初始化過程荸型。到這里我們就完全可以解釋 open0
是如何為每個OutputStream 的文件描述符 fd屬性的handle屬性進(jìn)行賦值的盹靴。
- 調(diào)用fileOpen()
- fileOpen 調(diào)用 winFileHandleOpen
- winFileHandleOpen 解析參數(shù)并調(diào)用Windows API CreateFileW 并返回文件句柄給
- fileOpen中判斷文件句柄是否合法若合法,將文件句柄通過SetLongField寫入到該FileOutputStream的 fd屬性(即那個文件描述符)的handle屬性
好了瑞妇,至此算是大致了解了 FileOutputStream 的創(chuàng)建過程稿静,其本質(zhì)是打開了一個文件或IO設(shè)備并保存了打開文件\設(shè)備的句柄 handle
那接下來就可以探究 FileOutputStream 是如何向這個打開的文件寫入數(shù)據(jù)的
回到 FileOutputStream 查看幾個 write API 很容易可以看到最終調(diào)用的總會是一下兩個函數(shù)之一
private native void write(int b, boolean append) throws IOException;
private native void writeBytes(byte b[], int off, int len, boolean append) throws IOException;
按照同樣的方法找到 native 函數(shù)在 jdk 中的定義,定位到jdk\src\windows\native\java\io\FileOutputStream_md.c
JNIEXPORT void JNICALL
Java_java_io_FileOutputStream_write(JNIEnv *env, jobject this, jint byte, jboolean append) {
writeSingle(env, this, byte, append, fos_fd);
}
找到 writeSingle
代碼,文件位置*jdk\src\share\native\java\io\io_util.c
void
writeSingle(JNIEnv *env, jobject this, jint byte, jboolean append, jfieldID fid) {
// Discard the 24 high-order bits of byte. See OutputStream#write(int)
char c = (char) byte;
jint n;
FD fd = GET_FD(this, fid);
if (fd == -1) {
JNU_ThrowIOException(env, "Stream Closed");
return;
}
if (append == JNI_TRUE) {
n = IO_Append(fd, &c, 1);
} else {
n = IO_Write(fd, &c, 1);
}
if (n == -1) {
JNU_ThrowIOExceptionWithLastError(env, "Write error");
}
}
可以看到首先會將傳入進(jìn)來的數(shù)據(jù)轉(zhuǎn)換為 char 類型(只保留低8位數(shù)據(jù))然后關(guān)鍵的一步FD fd = GET_FD(this, fid);
取出上文構(gòu)造open0 中寫入到類信息中的文件句柄。GET_FD 也是一個宏定義
#define GET_FD(this, fid) \
(*env)->GetObjectField(env, (this), (fid)) == NULL ? \
-1 : (*env)->GetIntField(env, (*env)->GetObjectField(env, (this), (fid)), IO_fd_fdID)
等效宏展開
(*env)->GetObjectField(env, (this), (fid)) == NULL ? -1 : (*env)->GetIntField(env, (*env)->GetObjectField(env, (this), (fid)), IO_fd_fdID)
隨后判斷打開文件時指定的打開模式是否為追加模式辕狰,單個參數(shù) FileOutputStream 的構(gòu)造方法默認(rèn)以復(fù)寫模式打開 IO 流
public FileOutputStream(File file) throws FileNotFoundException {
this(file, false);
}
如果想使用追加模式打開 IO 流可使用 FileOutputStream 的另一個重載構(gòu)造
public FileOutputStream(File file, boolean append)throws FileNotFoundException
可以看到單參數(shù)構(gòu)造調(diào)用的就是此構(gòu)造且指定 append 為 false
繼續(xù)看 writeSingle 函數(shù)改备,確定了文件打開模式后可分別執(zhí)行 n = IO_Append(fd, &c, 1);
和 n = IO_Write(fd, &c, 1);
代碼段。
IO_Append 是一個宏定義
#define IO_Append handleAppend
所以繼續(xù)查看 handleAppend定義蔓倍,這里注意 jdk 中可能會對不同的操作系統(tǒng)有不同的實(shí)現(xiàn)悬钳,代碼閱讀工具自動導(dǎo)航可能會導(dǎo)航錯,導(dǎo)航到 solaris 系統(tǒng)看到的會是 #define IO_Append handleWrite
選擇 Windows 系統(tǒng)實(shí)現(xiàn) jdk\src\solaris\native\java\io\io_util_md.h
jint handleAppend(FD fd, const void *buf, jint len) {
return writeInternal(fd, buf, len, JNI_TRUE);
}
handleWrite
會調(diào)用 writeInternal
繼續(xù)查看 writeInterna
定義
static jint writeInternal(FD fd, const void *buf, jint len, jboolean append)
{
BOOL result = 0;
DWORD written = 0;
HANDLE h = (HANDLE)fd;
if (h != INVALID_HANDLE_VALUE) {
OVERLAPPED ov;
LPOVERLAPPED lpOv;
if (append == JNI_TRUE) {
ov.Offset = (DWORD)0xFFFFFFFF;
ov.OffsetHigh = (DWORD)0xFFFFFFFF;
ov.hEvent = NULL;
lpOv = &ov;
} else {
lpOv = NULL;
}
result = WriteFile(h, /* File handle to write */
buf, /* pointers to the buffers */
len, /* number of bytes to write */
&written, /* receives number of bytes written */
lpOv); /* overlapped struct */
}
if ((h == INVALID_HANDLE_VALUE) || (result == 0)) {
return -1;
}
return (jint)written;
}
這里可以看到 最終調(diào)用 WriteFile API 寫入數(shù)據(jù)偶翅,WriteFile 文檔 WriteFile 通過文檔得知當(dāng)使用追加模式寫入時 LPOVERLAPPED 的 Offset 和 OffsetHigh 要設(shè)置為 0xFFFFFFFF
To write to the end of file, specify both the Offset and OffsetHigh members of the OVERLAPPED structure as 0xFFFFFFFF. This is functionally equivalent to previously calling the CreateFile function to open hFile using FILE_APPEND_DATA access.
WriteFile 的第一個參數(shù) h 即為文件描述符默勾,至此寫入過程大致也比較清晰了。另一個 writeBytes 實(shí)現(xiàn)會比 write 更加復(fù)雜聚谁。
void
writeBytes(JNIEnv *env, jobject this, jbyteArray bytes,
jint off, jint len, jboolean append, jfieldID fid)
{
jint n;
char stackBuf[BUF_SIZE];
char *buf = NULL;
FD fd;
if (IS_NULL(bytes)) {
JNU_ThrowNullPointerException(env, NULL);
return;
}
if (outOfBounds(env, off, len, bytes)) {
JNU_ThrowByName(env, "java/lang/IndexOutOfBoundsException", NULL);
return;
}
if (len == 0) {
return;
} else if (len > BUF_SIZE) {
buf = malloc(len);
if (buf == NULL) {
JNU_ThrowOutOfMemoryError(env, NULL);
return;
}
} else {
buf = stackBuf;
}
(*env)->GetByteArrayRegion(env, bytes, off, len, (jbyte *)buf);
if (!(*env)->ExceptionOccurred(env)) {
off = 0;
while (len > 0) {
fd = GET_FD(this, fid);
if (fd == -1) {
JNU_ThrowIOException(env, "Stream Closed");
break;
}
if (append == JNI_TRUE) {
n = IO_Append(fd, buf+off, len);
} else {
n = IO_Write(fd, buf+off, len);
}
if (n == -1) {
JNU_ThrowIOExceptionWithLastError(env, "Write error");
break;
}
off += n;
len -= n;
}
}
if (buf != stackBuf) {
free(buf);
}
}
writeBytes
會多出不少邊界檢測代碼母剥,同時源碼中還可以看見老朋友 java/lang/IndexOutOfBoundsException
但最終 會執(zhí)行同樣的邏輯,獲取 class 實(shí)例中句柄,根據(jù)append 選擇是 IO_Append
還是 IO_Wrtie
這兩個實(shí)現(xiàn)一樣區(qū)別在于最后調(diào)用 writeInternal
最后一個參數(shù)是 ture 還是 fase (1 或 0)
至此寫入操作流程也比較清晰了形导,關(guān)閉操作就不詳細(xì)展開了环疼。感興趣的可以查看 close0 對應(yīng)源碼最終調(diào)用 Window API CloseHandle
關(guān)閉句柄。
完結(jié)撒花6涓G乇!
最后:大幻夢森羅萬象狂氣斷罪眼~
搬家驗(yàn)證:3e70d467-3718-47b6-a6bb-e1dd84a2f145