Java中文件操作功能小结
作者:HAibiiin
文件写入
为提供相对较高性能的文件读写操作,这里果断选择了 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 这一进程级别的文件锁,它不能够对同一虚拟机内多个线程对文件的访问提供锁的能力。而且该锁的具体实现逻辑和操作系统有强相关。
到此这篇关于Java中文件操作功能小结的文章就介绍到这了,更多相关Java文件操作内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!