likes
comments
collection
share

文件操作小结

作者站长头像
站长
· 阅读数 15

这篇文章对之前做过的数据归档文件的相关功能整理。

文件写入

为提供相对较高性能的文件读写操作,这里果断选择了 NIO 对文件的操作,因为业务背景需要数据的安全落盘。这里主要采用 ByteBuffer 与 FileChannel 的组合,下面是代码片段示例:

public static void write(String file, String content) throws IOException {
    ByteBuffer writeBuffer = ByteBuffer.allocate(4096);
    int cap = buffer.capacity();
    try (FileChannel fileChannel = FileChannel.open(Path.of(file), StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.READ)) {
        byte[] tmp = content.getBytes(StandardCharsets.UTF_8);
        for (int i = 0; i < tmp.length; i = i + cap) {  
			if (tmp.length < i + cap) {
				buffer.put(tmp, i, tmp.length - i);
			} else {
				buffer.put(tmp, i, cap);
			}
			buffer.flip();
			fileChannel.write(buffer);
			buffer.compact();
        }
        fileChannel.force(false);
    } finally {
        buffer.clear();
    }
}

ByteBuffer

在上面的代码(基于JDK11)片段中,我们使用 ByteBuffer 作为待读写数据的载体才能够配合 FileChannel 一起使用。如果是 JDK8 获取 FileChannel 可以采用 new RandomAccessFile(new File("xx"), "rw").getChannel() 。在讲 ByteBuffer 初始化之前,我们需要先对数据单位有一个明确的概念。

KB 不是 kb

我们常看到的 kb 单位对应 kilobits ,而 KB 单位对应 kilobyte。Java 中的 1 byte 对应 8 bits,所以 1 KB(1024 byte) = 8kb (8196 bits)。包括mb、MB等也是一样的,为方便记忆,我们只需要记住小写的 b 表示 bits,而大写的 B 表示 byte 即可。

接下来初始化采用 allocate() 方法,容量是 4096,因为 ByteBuffer 底层数据结构是 byte 数组,再结合上面的知识,我们这里创建了 4KB 大小的 Buffer。具体大小需要根据实际测试进行调整,普遍的说法是 4KB 的整数倍会发挥最大性能优势。

为什么是 4KB 的整数倍呢?大致就是, 操作系统一次 I/O 操作会以 I/O 块为单位进行操作,这个 I/O 块的默认大小是 4KB。但是这个数值并不严谨,它受操作系统,磁盘等因素影响,所以需要实际测试后调整。

初始化

另一种初始化的方式是通过 wrap() 对已存在 byte 数组进行包装,应用场景会略有不同,两者区别如下代码片段所示:

public static ByteBuffer allocate(int capacity) {
    if (capacity < 0)
        throw createCapacityException(capacity);
    return new HeapByteBuffer(capacity, capacity);
}

HeapByteBuffer(int cap, int lim) {
    super(-1, 0, lim, cap, new byte[cap], 0)
}

public static ByteBuffer wrap(byte[] array, int offset, int length) {
    try {
        return new HeapByteBuffer(array, offset, length);
    } catch (IllegalArgumentException x) {
        throw new IndexOutOfBoundsException();
    }
}

HeapByteBuffer(byte[] buf, int off, int len) {
    super(-1, off, off + len, buf.length, buf, 0)
}

最终调用的都是 Buffer(int mark, int pos, int lim, int cap) 这个初始化方法,该方法也揭示了 ByteBuffer 的基本属性:

  • position:表示下一个读写操作的起始位置,可通过 position() 方法获取;
  • limit:表示下一个读写操作的最大位置,可通过 limit() 方法获取;
  • capacity:表示容量,可通过 capacity() 方法获取;
  • mark:自定义标记位置;

上述4个属性的关系始终满足:mark <= position <= limit <= capacity。在初始化后ByteBuffer的内部结构如下图所示: 文件操作小结

ByteBuffer 操作及属性变化

通过上图中结构为 ByteBuffer 初始化的结构,写文件需要向 buffer 中写入数据,ByteBuffer 提供了多个 put() 方法,调用 put() 相关方法之后,如下图所示向 buffer 写入 8 个byte的内容后,其内部结构主要是 position 指向了后续插入数据的位置: 文件操作小结 目前数据已经写入了 buffer 中,接下来需要通过 FileChannel 写入文件,年需要将数据从 buffer 中读出来。在调用 FileChannel 的 write() 方法之前,需要调用 buffer 的 flip()  方法,flip() 方法将标识属性变换为下图所示,也就是切换为读取模式,即 position 重置到 0,而 limit 移动到原 position 位置。这样从 position 读取到 limit 就是刚刚写入的数据: 文件操作小结 FileChannel 完成 write 操作后,即 buffer 内数据读取完,则 position 的位置会移动到 limit 所在位置。为保证数据的完整性,此时需要调用 buffer 的 compact() 方法将 position 到 limit 间未读取的数据移动到 buffer 的头部,开启新的一轮写入模式,调用方法后具体的属性关系如下图所示(下图中例子为数据读 3 个 byte 后调用compact() 效果,将 position 与 limit 间的数据移动到 buffer 的头部,并将 limit 移动到 capacity 的位置,position 移动到未读数据的末尾): 文件操作小结 最后在整个写文件的结尾,需要通过 FileChannel 的 force() 方法将数据强制刷盘,其实上面的所有操作只是将数据写入了 PageCache 中,具体何时落入磁盘由操作系统调度,而 force() 方法就是通知操作系统将 PageCache 的内容写入磁盘,这样才可以确保数据真正的持久化到磁盘中。

DirectByteBuffer

还有一种方式是通过 allocateDirect() 方法创建 DirectByteBuffer 采用对外内存,如果需要更高的性能,或者需要长期且大数据量的 I/O 操作可以采用这种方式。但一定要注意代码片段确保的 ((DirectBuffer) buffer).cleaner().clear() 对堆外内存进行回收(该方法在 JDK11 版本不可直接使用)。

如果不及时清理也会造成内存溢出。如下图所示,左侧为未调用 clear() 方法前的堆外内存使用情况,右侧为调用后的情况。同时可以配合JVM 参数 -XX:MaxDirectMemorySize 一起使用避免防止内存申请过大而导致进程被终止;

文件操作小结

文件读取

这里我们将文件读取的代码片段摘录如下,关于文件读取主要是注意中文字符的乱码问题,因为我们定义的 buffer 是有容量的,一个容量读满之后,可能一个中文字符并没有读取完整。因为一个中文字符可能需要 2-3 个 byte,有可能存在只读取 1 个 byte 的情况。

所以需要结合 CharBuffer 对未读取完整的中文字符进行缓冲。具体代码示例如下所示:

public static String read(String file) throws IOException {
    StringBuilder content = new StringBuilder();
    ByteBuffer buffer = ByteBuffer.allocate(4096);
	CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
	CharBuffer cb = CharBuffer.allocate(4096);
	try (FileChannel fileChannel = FileChannel.open(Path.of(file), StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.READ)) {
		while (fileChannel.read(buffer) != -1) {
			buffer.flip();
			//从ByteBuffer读取数据到CharBuffer,最后如果不是完整的字符position的位置不会移动
			//可以认为ByteBuffer中对应的字符未被读取
			decoder.decode(buffer, cb, false);
			cb.flip();
			content.append(cb, cb.position(), cb.limit());
			//将CharBuffer的position强制重制为0
			cb.rewind();
			buffer.compact();
		}
	} finally {
		cb.clear();
		buffer.clear();
	}
	return content.toString();
}

并发写入

FileChannel 的 read/write 操作均是线程安全的,但是因为我们不能保证数据被一次性写入,所以数据最终落在文件上会是混乱的片段。这里我们采用类似分区写的方式,每个线程负责写入一个分区文件,最后再执行合并操作。

同时这里介绍下 FileLock 这一进程级别的文件锁,它不能够对同一虚拟机内多个线程对文件的访问提供锁的能力。而且该锁的具体实现逻辑和操作系统有强相关。