[Java] FileOutputStream 原理(Windo

2022-09-17  本文已影响0人  小艾咪

前言

前几天帮公司新人解决了一个多线程问题,问题很简单。这位同事想用多线程提高文件写入速度结果使用同一个文件在多个线程创建的多个 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

上一篇下一篇

猜你喜欢

热点阅读