1. 缓冲区错误:ByteBuffer、position 与 limit
异步读写方法与对象 ByteBuffer 配合工作。与普通数组不同,缓冲区有一个“内部光标”——位置(position)与上界(limit),它们决定哪些字节会被读取或写入。如果管理这些属性不当,你可能得不到期望的结果,甚至会破坏业务逻辑。
代码中是什么样的?
错误用法示例:
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer buf) {
// 哎呀!试图直接从缓冲区读取字符串:
String str = new String(buf.array()); // 这是不正确的!
// ...
}
// ...
});
哪里有问题?
- 读取后缓冲区处于“写入”模式:position 指向已读数据末尾,而 limit 指向缓冲区容量。如果立刻从缓冲区读取,你会得到一堆垃圾数据(会取到所有 1024 字节,即便实际只读了 10 字节)。
- 调用 buf.array() 会返回整个底层数组,而不仅是已读取部分。此外,对于直接缓冲区(allocated via ByteBuffer.allocateDirect)array() 会抛出 UnsupportedOperationException。
正确做法?
在从缓冲区读取数据之前,需要调用 buffer.flip()——这会把缓冲区切换到“读取”模式:
public void completed(Integer result, ByteBuffer buf) {
buf.flip(); // 此时 position = 0,limit = 已读取的字节数
String str = StandardCharsets.UTF_8.decode(buf).toString();
// ... 处理字符串
}
重复使用缓冲区
如果你想在下一次操作中复用缓冲区,处理完数据后别忘了调用 buffer.clear() 或 buffer.compact():
- clear() —— 完全“重置”边界:position=0,limit=capacity(),旧数据视为无效。
- compact() —— 将未读取的字节保留在缓冲区开头,并让缓冲区进入可继续写入的状态。
细节:如果 result 等于 -1,说明到达文件末尾——无需额外处理。
2. 并发访问:竞态与不一致
AsynchronousFileChannel 允许并行发起多次操作。但如果不加控制,很容易得到“损坏”的数据,甚至导致程序崩溃。
问题 1:同时向同一个缓冲区读取
// 两次同时读取到同一个缓冲区
channel.read(buffer, 0, buffer, handler1);
channel.read(buffer, 1024, buffer, handler2);
两次读取都写入同一个缓冲区!如果几乎同时完成,缓冲区内容将无法预测。
问题 2:同时写入同一文件
如果两个线程同时写入同一文件区域,最终结果取决于哪次操作先完成。这是典型的竞态(race condition),可能导致数据损坏。
如何避免?
- 为每个异步操作使用独立缓冲区(ByteBuffer)——线程不会互相“踩内存”。
- 不要对同一文件区间启动并行写入;拆分偏移或对访问进行同步。
- 如果需要严格顺序,仅在上一操作完成后再启动下一次——例如在 CompletionHandler 的 completed(...) 中发起。
3. 资源泄漏:忘记关闭通道
异步通道是系统资源。如果不关闭(channel.close()),文件会在系统中保持“占用”,可能引起内存泄漏;在 Windows 上还会导致文件被其他程序锁定。
典型错误:
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, ...);
// ... 发起了操作
// 忘记在所有操作完成后调用 channel.close()!
正确做法?
使用 try-with-resources,并务必在退出代码块前等待所有操作完成:
CountDownLatch latch = new CountDownLatch(1);
try (AsynchronousFileChannel channel =
AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buf = ByteBuffer.allocate(4096);
channel.read(buf, 0, buf, new CompletionHandler<Integer, ByteBuffer>() {
@Override public void completed(Integer r, ByteBuffer b) {
// 处理...
latch.countDown();
}
@Override public void failed(Throwable ex, ByteBuffer b) {
ex.printStackTrace();
latch.countDown();
}
});
latch.await(); // 等待异步操作完成
}
// 将自动关闭
4. 异常处理:在 CompletionHandler 中忽略错误
在异步代码中,错误不会“冒泡”到主线程——它们会进入接口 CompletionHandler 的方法 failed(...)。如果你没有实现它或留空,错误就会悄然消失,程序将表现异常。
“隐形”错误示例:
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer buf) {
// ... 处理结果
}
@Override
public void failed(Throwable exc, ByteBuffer buf) {
// 糟糕,空实现!错误丢失了
}
});
正确做法?
@Override
public void failed(Throwable exc, ByteBuffer buf) {
System.err.println("读取文件时出错:" + exc.getMessage());
exc.printStackTrace();
// 可选:关闭通道、更新指标、通知用户等
}
5. 丢失对 Future/CompletionHandler 的引用
如果你通过 Future 启动了异步操作却忘记保存引用,你将无法取消操作或等待其完成。类似地,如果使用 CompletionHandler 却没有对所有操作的完成进行同步,程序可能会过早结束。
示例:
channel.read(buffer, 0, buffer, handler); // handler 为匿名对象,未被保存
// 程序立即结束,未等待读取完成
正确做法?
- 使用 Future<Integer> 时:保存引用,并在需要时调用 future.get() 或 future.cancel(true)。
- 使用 CompletionHandler 时:采用同步机制(CountDownLatch、Semaphore),并在关闭通道/程序前正确等待所有操作完成。
6. 编码相关错误
读写文本文件需要正确处理字符编码。如果“硬把”字节当作字符串来读,可能出现乱码或丢失部分数据,尤其是在分块读取文件时。
问题:
// 以 1024 字节为一块读取文件,然后直接转为字符串
String chunk = new String(buffer.array(), "UTF-8");
如果一个字符被拆分在两个缓冲区之间(例如某个 UTF-8 字符的一个字节落在上一个缓冲区末尾,其余字节在下一个缓冲区开头),你会得到不正确的字符或解码错误。
正确做法? 使用 CharsetDecoder,并在两次读取之间保存未完成字符的“残留”:
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
ByteBuffer byteBuf = ByteBuffer.allocate(4096);
CharBuffer charBuf = CharBuffer.allocate(4096);
// 每次 completed(...):
byteBuf.flip();
CoderResult cr = decoder.decode(byteBuf, charBuf, false); // false 表示尚未到达输入末尾
if (cr.isError()) {
cr.throwException();
}
byteBuf.compact();
charBuf.flip();
String text = charBuf.toString();
charBuf.clear();
// 当不再有输入时:
decoder.flush(charBuf);
7. 程序过早退出
异步操作在其他线程中执行。如果主线程早于所有操作完成就结束,程序会在未得到结果时终止。
示例:
// 启动异步读取
channel.read(buffer, 0, buffer, handler);
// 主线程随即结束——程序退出,操作尚未完成
正确做法?
使用 CountDownLatch、Semaphore,或者至少使用 Thread.sleep(...)(演示用)来等待所有操作完成:
CountDownLatch latch = new CountDownLatch(1);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override public void completed(Integer result, ByteBuffer buf) { /* ... */ latch.countDown(); }
@Override public void failed(Throwable exc, ByteBuffer buf) { /* ... */ latch.countDown(); }
});
latch.await(); // 等待操作完成
8. 与 ExecutorService 的不当集成
AsynchronousFileChannel 允许为事件处理指定自定义的 ExecutorService。如果你传入的线程池线程数很少,甚至只有一个,那么所有操作会顺序执行而非并行;如果线程池过大,则会产生额外的上下文切换开销。
示例:
ExecutorService executor = Executors.newSingleThreadExecutor();
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, options, executor);
// 所有异步操作本质上都会变成同步!
正确做法?
- 根据实际负载与并发操作数选择线程池大小。
- 多数场景可使用 ForkJoinPool.commonPool() 或 Executors.newCachedThreadPool()。
- 请记住,传入的 ExecutorService 管理的是回调(completed/failed)的执行线程,而非磁盘 I/O 本身。
GO TO FULL VERSION