1. 分块读取文件:ByteBuffer 与编码
如今我们很少处理很小的文本文件。通常是巨大的服务器日志、报告、CSV 文件或以 GB 计的数据转储。因此,关键不只是读取文件,而是高效且不让应用“卡住”。
异步方式正是为此而生:它不会阻塞主线程——无论是界面还是服务端逻辑——允许并行读写大批数据,并在需要同时处理多个文件时让应用具备良好的可扩展性。
要点在于:异步 I/O 并不会让磁盘本身更快——没有魔法。它只是让你的程序在等待磁盘完成操作时不必“闲着”,可以利用这段时间去做别的事情。
异步读取如何工作?
异步通道(AsynchronousFileChannel)读取的不是字符串,而是字节块,放入 ByteBuffer 对象。这好比你搬运的是装有字母的箱子,而不是完整的单词。读取之后,你需要将这些字节转换为字符串——并且要考虑编码!
示例:按块异步读取文件
我们来写一个最简单的示例:按 4096 字节为一块异步读取文件,并将内容输出到控制台。
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
public class AsyncTextReadExample {
public static void main(String[] args) throws Exception {
Path path = Path.of("bigfile.txt");
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(4096);
int position = 0;
Future<Integer> future = channel.read(buffer, position);
while (future.get() > 0) {
buffer.flip();
// 将字节转换为字符串(UTF-8)
String chunk = StandardCharsets.UTF_8.decode(buffer).toString();
System.out.print(chunk);
buffer.clear();
position += chunk.getBytes(StandardCharsets.UTF_8).length;
future = channel.read(buffer, position);
}
}
}
}
要点:
- 我们按块(缓冲区)读取文件,而不是一次性读取整个文件。
- 读取后使用 Charset 将字节解码为字符串。
- 别忘了调用 buffer.clear()——否则下一次 read 不会生效!
为什么仅仅解码字节还不够?
问题在于,字符串可能在两个块之间“断开”,尤其当使用多字节编码(例如 "UTF-8")时。如果缓冲区的最后一个字节只是某个字符的一半,那么下一个块就会以该字符的“剩余部分”开始。没有专门的处理,你会得到乱码,甚至解码错误。
2. 将字节转换为字符串:处理断裂
行在块之间被拆分的问题
假设你有字符串 "你好\n世界\n",而缓冲区刚好结束在 "你","好\n世界\n" 落入下一个块。如果只是简单拼接字符串,可能会丢字符或得到不正确的结果。
解决方案:使用 CharsetDecoder
Java 提供了类 CharsetDecoder,它可以正确处理这种情况。它会“记住”尚未解码的字节,并在块的边界处正确还原字符。
CharsetDecoder 的使用示例
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.CharBuffer;
import java.nio.ByteBuffer;
CharsetDecoder decoder = Charset.forName("UTF-8").newDecoder();
ByteBuffer buffer = ... // 你的字节
CharBuffer charBuffer = CharBuffer.allocate(buffer.capacity());
decoder.decode(buffer, charBuffer, false);
// 现在 charBuffer 包含正确解码的字符
在真实场景中,你需要在多次读取之间保存“剩余”部分,并在解码时将其考虑进去。
3. 文本文件的异步写入
读取只是事情的一半。写入同样是按字节块进行的,需要先从字符串得到字节(编码)。
示例:将字符串异步写入文件
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
import java.nio.charset.StandardCharsets;
public class AsyncTextWriteExample {
public static void main(String[] args) throws Exception {
Path path = Path.of("output.txt");
String text = "你好,世界!\n";
ByteBuffer buffer = ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8));
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
Future<Integer> future = channel.write(buffer, 0);
// 为了演示等待完成(通常不必这样做!)
future.get();
System.out.println("数据已异步写入。");
}
}
}
说明: 在真实的异步场景中,不要在主线程调用 future.get()——这会把异步代码变成同步。更好的是使用 CompletionHandler(参见上一讲)。
4. 实战:异步读取大型文本文件并统计行数
我们来实现一个实际任务:异步读取大型文本文件并统计行数("\n" 的数量)。结果是在控制台打印行数。
使用 CompletionHandler 的示例
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.CharBuffer;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicLong;
public class AsyncLineCounter {
public static void main(String[] args) throws IOException {
Path path = Path.of("bigfile.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(4096);
AtomicLong position = new AtomicLong(0);
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
StringBuilder leftover = new StringBuilder();
AtomicLong lines = new AtomicLong(0);
channel.read(buffer, position.get(), null, new CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer result, Object attachment) {
if (result == -1) {
// 文件已读到末尾
if (leftover.length() > 0) lines.incrementAndGet();
System.out.println("文件中的行数: " + lines.get());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
return;
}
buffer.flip();
CharBuffer charBuffer = CharBuffer.allocate(buffer.remaining());
decoder.decode(buffer, charBuffer, false);
charBuffer.flip();
String chunk = leftover.toString() + charBuffer.toString();
leftover.setLength(0);
// 统计行数
int last = 0;
int idx;
while ((idx = chunk.indexOf('\n', last)) != -1) {
lines.incrementAndGet();
last = idx + 1;
}
// 剩余部分(最后一个 \n 之后的行片段)
if (last < chunk.length()) {
leftover.append(chunk.substring(last));
}
buffer.clear();
position.addAndGet(result);
channel.read(buffer, position.get(), null, this);
}
@Override
public void failed(Throwable exc, Object attachment) {
System.err.println("读取错误: " + exc.getMessage());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
});
// 为了避免程序过早结束(仅用于示例!)
try { Thread.sleep(2000); } catch (InterruptedException e) {}
}
}
- 我们使用 CompletionHandler 来实现真正的异步代码。
- 每次读取后,都使用 CharsetDecoder 对缓冲区进行解码。
- 没有以 "\n" 结尾的行的剩余部分会被带到下一个块中。
- 文件结束后,如果 leftover 里还有内容,也应计为一行。
- 为简化起见,示例“休眠” 2000 毫秒以等待异步操作完成(真实应用中通常不需要——通常有主循环或 UI)。
5. 将结果异步写入文件
假设我们想把结果(例如行数)写入一个新文件——同样采用异步方式。
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
public class AsyncWriteResult {
public static void main(String[] args) throws IOException {
String result = "文件中的行数: 12345\n";
ByteBuffer buffer = ByteBuffer.wrap(result.getBytes(StandardCharsets.UTF_8));
Path path = Path.of("result.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
path, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
channel.write(buffer, 0, null, new CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer written, Object attachment) {
System.out.println("结果已异步写入!");
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
@Override
public void failed(Throwable exc, Object attachment) {
System.err.println("写入错误: " + exc.getMessage());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
});
try { Thread.sleep(500); } catch (InterruptedException e) {}
}
}
6. 处理分块数据与编码的建议
跨块的部分行
如果一行被分到两个块之间,不要尝试手动“拼接”字节!请使用 CharsetDecoder,它会妥善处理缺失的字节,不会丢失任何字符。
处理不同编码
"UTF-8" 是现代应用的标准,但如果文件使用其他编码(例如 "Windows-1251" 或 "UTF-16"),请使用相应的 Charset:
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
Charset charset = Charset.forName("Windows-1251");
CharsetDecoder decoder = charset.newDecoder();
使用 CharsetDecoder 与 CharsetEncoder
当你按块读取或写入数据时,正确处理编码非常重要。字符可能被“拆分”在两个块之间,如果没有额外处理,就会出现字节混乱的问题。
为避免这种情况,请使用 CharsetDecoder 和 CharsetEncoder。
读取时调用 decode(ByteBuffer, CharBuffer, endOfInput),写入时调用 encode(CharBuffer, ByteBuffer, endOfInput)。
它们会确保即使字符被分隔在两个块之间,也能被正确组装并处理。
7. 异步处理文本文件的常见错误
错误 1:忽略行的剩余部分。 如果不在块之间保存行的“尾巴”,部分行可能丢失或被错误解码。
错误 2:错误使用缓冲区。 处理后忘记调用 buffer.clear()——下一次 read 可能不会执行,或者数据会不正确。
错误 3:使用了不合适的编码。 如果使用与文件写入时不同的 Charset 进行解码,可能会出现乱码,甚至报错。
错误 4:阻塞主线程。 如果你在 UI 线程中调用 future.get() 或 Thread.sleep(),就失去了异步的意义。请使用 CompletionHandler 和响应式方法。
错误 5:操作结束后未关闭通道。 所有操作结束后(即使发生错误),都应关闭通道(channel.close())。
GO TO FULL VERSION