[Java] FileOutputStream 原理(Windows)

前言

前几天帮公司新人解决了一个多线程问题,问题很简单。这位同事想用多线程提高文件写入速度结果使用同一个文件在多个线程创建的多个 FileOutPutStream 然后使用这个FileOutPutStream 分别向文件中写入内容,可想而知结果肯定是不正确的。

问题虽然简单但其实排查的过程也并非一帆风顺,究其原因可能还是自己对API不够熟悉,当问题发生时没能坚信自己的理论导致方向逐渐跑偏浪费了很多时间,所以解决问题后我就对FileOutputStream 进行了更为深入的研究。

正文

首先我写了一个demo复现当时的问题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();

运行结果: 文本文件中交替出现 Thread 1 write 1Thread 2 write 2
在 Debug 的过程当中可以发现 FileOutputStream 中有一个属性名为 FileDescriptor 直译即为文件描述符,此时可以猜测他应该会是输出流的关键。


我们可以继续深入到FileOutputStream 类中寻找 FileDescriptor 是合适被创建的根据构造方法我们很容易找到,在其中一个构造方法中找到

   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");
        }
       //  ------------------------------------------------------------------------------------
       //  ----------------------------在此处创建文件描述符--------------------------------------
       //  ------------------------------------------------------------------------------------
        this.fd = new FileDescriptor();
        fd.attach(this);
        this.append = append;
        this.path = name;

        open(name, append);
    }

但此处的 FileDescriptor 并没有完全初始化完成,对比上文截图可以发现其handle属性并没有被赋值(后文可以知道它一定是一个大于零的整数)


继续Debug 不难看到当调用open函数后handle被赋值,所以查看open函数代码,open会继续调用open0方法,此时到达native函数。

    private native void open0(String name, boolean append) throws FileNotFoundException;

注意:低版本Jdk 可能没有 open0 调用过程 open即为 native函数所以见到直接调用 native open 也是正常的

接下来需要下载 jdk 源码(这里下载的是 openjdk 的源码这些基础类库的实现jdk和 openjdk 基本不会有差别)
jdk8u60 下载地址
其他版本可进入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文件,对应的 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 调用的是 fileOpen函数,继续查看fileOpen函数(VS Code 需要安装 C/C++ Extension 才可以函数导航)该函数位于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)
{
    // 准备 CreateFileW 函数参数
    // 访问权限
    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;
    //该 文件\设备是否存在
    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;
    //文件的属性和操作标志位,例如是否为压缩文件,是否隐藏,是否在释放资源时自动删除等
    const DWORD flagsAndAttributes = maybeWriteThrough | maybeDeleteOnClose;
    HANDLE h = NULL;

    WCHAR *pathbuf = pathToNTPath(env, path, JNI_TRUE);
    if (pathbuf == NULL) {
        /* Exception already pending */
        return -1;
    }
       //  ------------------------------------------------------------------------------------
       //  ------------------------------------关键--------------------------------------------
       //  ------------------------------------------------------------------------------------
    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;
}

到这里我们可以看到该函数调用 CreateFileW 函数从函数名看来是创建了一个文件。实际上它是Windows API中的一个函数感兴趣的可以看下CreateFileW API,链接指向的是 Windows系统 API CreateFileW 函数的文档。该函数的作用是打开一个 文件或者 IO设备并返回一个句柄(句柄是 Windows编程中的一个概念它可以指代 窗口、资源、文件等)通过该句柄我们就可以访问该句柄指向的资源了也就是我们的文件。

其中几个重要的参数我也在上文中进行了注释。重点看下 sharing

 const DWORD sharing =
        FILE_SHARE_READ | FILE_SHARE_WRITE;

他是一个 64 bit 数据 每个bit代表不同的模式,不同模式间可共存,例如可以同时共享写和共享读以下为该参数的可选值扎抄自微软官网文档(就是上边 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流都是默认共享读写的,这也就解释了为什么我们可以在不同线程使用同一个文件创建多个 FileOutputStream

也许我们都会碰到用其他软件打开文件并没关闭的情况下我们开发过程中的默写操作是会失败的,典型的当我们使用压缩软件打开maven构建的 jar 包时执行 maven clean 是会失败的。我想这可能是因为他们打开文件的方式是非共享模式,当然可能不是 CreateFileW 函数还可能是 CreateFIle 也是Windows API 中的一个函数且和 CreateFileW 有着类似的方法签名

最后可以看到该函数返回的即使 CreateFileW 创建的句柄,所以让我们回到 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);
    }
}

当句柄创建成功即大于零时,会执行 SET_FD(this, h, fid); 代码段,还记得上文中说过 FileDescriptor 中 handle 一定会大于零的整数吗,此处已经初露端倪了。继续定位到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 实例的某个属性赋值,转到 IO_handle_fdID 的定义

/* field id for jlong 'handle' in java.io.FileDescriptor */
jfieldID IO_handle_fdID;

注释同样标注的很清楚此属性代表了 FileDescriptor 实例 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属性进行赋值的。

  1. 调用fileOpen()
  2. fileOpen 调用 winFileHandleOpen
  3. winFileHandleOpen 解析参数并调用Windows API CreateFileW 并返回文件句柄给
  4. fileOpen中判断文件句柄是否合法若合法,将文件句柄通过SetLongField写入到该FileOutputStream的 fd属性(即那个文件描述符)的handle属性

好了,至此算是大致了解了 FileOutputStream 的创建过程,其本质是打开了一个文件或IO设备并保存了打开文件\设备的句柄 handle 那接下来就可以探究 FileOutputStream 是如何向这个打开的文件写入数据的

回到 FileOutputStream 查看几个 write API 很容易可以看到最终调用的总会是一下两个函数之一

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 函数在 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");
    }
}

可以看到首先会将传入进来的数据转换为 char 类型(只保留低8位数据)然后关键的一步FD fd = GET_FD(this, fid); 取出上文构造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)

随后判断打开文件时指定的打开模式是否为追加模式,单个参数 FileOutputStream 的构造方法默认以复写模式打开 IO 流

   public FileOutputStream(File file) throws FileNotFoundException {
        this(file, false);
    }

如果想使用追加模式打开 IO 流可使用 FileOutputStream 的另一个重载构造

public FileOutputStream(File file, boolean append)throws FileNotFoundException

可以看到单参数构造调用的就是此构造且指定 append 为 false
继续看 writeSingle 函数,确定了文件打开模式后可分别执行 n = IO_Append(fd, &c, 1);n = IO_Write(fd, &c, 1); 代码段。
IO_Append 是一个宏定义

#define IO_Append handleAppend

所以继续查看 handleAppend定义,这里注意 jdk 中可能会对不同的操作系统有不同的实现,代码阅读工具自动导航可能会导航错,导航到 solaris 系统看到的会是 #define IO_Append handleWrite 选择 Windows 系统实现 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 会调用 writeInternal 继续查看 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;
}

这里可以看到 最终调用 WriteFile API 写入数据,WriteFile 文档 WriteFile 通过文档得知当使用追加模式写入时 LPOVERLAPPED 的 Offset 和 OffsetHigh 要设置为 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 的第一个参数 h 即为文件描述符,至此写入过程大致也比较清晰了。另一个 writeBytes 实现会比 write 更加复杂。

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 但最终 会执行同样的逻辑,获取 class 实例中句柄,根据append 选择是 IO_Append 还是 IO_Wrtie 这两个实现一样区别在于最后调用 writeInternal 最后一个参数是 ture 还是 fase (1 或 0)

至此写入操作流程也比较清晰了,关闭操作就不详细展开了。感兴趣的可以查看 close0 对应源码最终调用 Window API CloseHandle 关闭句柄。

完结撒花!!!


最后:大幻梦森罗万象狂气断罪眼~

搬家验证:3e70d467-3718-47b6-a6bb-e1dd84a2f145

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,406评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,732评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,711评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,380评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,432评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,301评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,145评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,008评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,443评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,649评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,795评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,501评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,119评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,731评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,865评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,899评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,724评论 2 354