CodeGym /课程 /JAVA 25 SELF /文本文件的异步处理

文本文件的异步处理

JAVA 25 SELF
第 56 级 , 课程 2
可用

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

当你按块读取或写入数据时,正确处理编码非常重要。字符可能被“拆分”在两个块之间,如果没有额外处理,就会出现字节混乱的问题。

为避免这种情况,请使用 CharsetDecoderCharsetEncoder

读取时调用 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())。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION