1. Chunking — 按块读取文件
正如我们在前一讲中讨论过的,chunking 允许按块处理文件,而不是把整个文件一次性载入内存。这在处理大体量数据时尤为重要。如果文件只有 10 MB,通常没问题——可以直接载入,并用任意方式处理。但如果文件达到 10 GB,而内存只有 8 GB,再加上浏览器开着几十个标签页、IDE 也在运行?尝试整体读取往往会以悲剧收场:OutOfMemoryError、程序卡死以及开发者的泪水。
这类超大文件在现实中很常见:一个月的服务器日志可能有数十 GB,大型 CSV 文件包含数百万行,而视频、压缩包与数据库转储更是体量惊人。
关键思想依然不变:不要“一次吃掉整头大象”,而是分块处理。正是 chunking 让我们能够安全高效地处理此类数据,把文件拆分为可控的部分。
再谈 chunking
Chunk(块)就是文件的一个固定大小的片段。与其一次性读完,我们例如每次读取 4 MB(或 64 KB、或 1 MB——视情况而定)。
原理:
- 打开文件读取流。
- 创建缓冲区——固定大小的字节数组。
- 循环从文件读入缓冲区,直到到达末尾。
- 每个“块”单独处理。
示例:按块复制大文件
假设我们有一个超大的文件需要复制。让我们写个程序,用“专业”的方式完成它。
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public class BigFileCopy {
public static void main(String[] args) throws IOException {
String source = "bigfile.dat";
String dest = "bigfile_copy.dat";
int bufferSize = 4 * 1024 * 1024; // 4 MB
try (FileInputStream in = new FileInputStream(source);
FileOutputStream out = new FileOutputStream(dest)) {
byte[] buffer = new byte[bufferSize];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
// 可以添加进度输出或数据处理
}
}
System.out.println("复制完成!");
}
}
在 Java 中处理文件通常使用标准流 FileInputStream 和 FileOutputStream。经验上使用约 4 MB 的缓冲区,对于现代磁盘已足够高效地进行读写。在循环中,程序按块读取文件并立即写入新文件,而不是把整个文件驻留在内存中。
这种方式可以节省内存,避免 OutOfMemoryError 之类的错误,并能处理几乎任意大小的文件,即便是 100 GB 甚至更大的文件。
2. 用 chunking 处理数据
任务往往不是简单复制文件,而是例如查找特定字符串、统计出现次数、替换内容等。
示例:在大型文本文件中搜索字符串
如果是文本文件,使用字符流并按行读取会更方便:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class BigFileSearch {
public static void main(String[] args) throws IOException {
String file = "biglog.txt";
String keyword = "ERROR";
int count = 0;
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.contains(keyword)) {
count++;
}
}
}
System.out.println("找到 " + count + " 行包含 ERROR");
}
}
为什么这对 GB 级文件也有效?
- BufferedReader 按块读取文件(默认缓冲区为 8 KB,但可以设置更大)。
- 任一时刻内存中只保留一行。
缓冲区大小:如何选择?
黄金法则:缓冲区太小——磁盘访问过多;太大——浪费内存。
- 对现代 HDD/SSD,通常 64 KB–4 MB 表现良好。
- 对网络存储或非常快速的 SSD——可以更大(8–16 MB)。
- 对文本文件——可以增大 BufferedReader 的缓冲区。
多做实验!用不同的缓冲区大小测量程序运行时间。有时增大缓冲区能带来 2–3 倍的加速,有时几乎没有影响。
3. 内存映射文件(memory-mapped files)
这究竟是什么?
Memory mapping 是一种通过操作系统机制将文件直接映射到进程内存的方式。在 Java 中可使用 MappedByteBuffer(来自 java.nio 包)。文件仿佛变成了一个巨大字节数组,可以直接操作,而无需显式地逐块读写。
这种方法对超大文件特别有用。操作系统会按需把文件的部分加载到内存中,而你可以像操作普通数组一样访问文件的任意位置。Memory-mapped files 提供了高性能的随机访问。例如,当需要快速地从文件的不同位置读取片段而无需整体加载时。
代码示例
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
public class MemoryMappedRead {
public static void main(String[] args) throws Exception {
String fileName = "bigfile.dat";
try (RandomAccessFile file = new RandomAccessFile(fileName, "r");
FileChannel channel = file.getChannel()) {
long fileSize = channel.size();
int chunkSize = 1024 * 1024 * 128; // 128 MB — 单个映射的大小
long position = 0;
while (position < fileSize) {
long size = Math.min(chunkSize, fileSize - position);
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, position, size);
// 像从数组一样从 buffer 读取数据
for (int i = 0; i < size; i++) {
byte b = buffer.get(i);
// 处理字节(例如查找特定值)
}
position += size;
}
}
System.out.println("通过 memory mapping 读取完成!");
}
}
RandomAccessFile 和 FileChannel 允许以较低层级访问文件。调用 channel.map 会把文件的一个片段映射到内存。数据访问通过 MappedByteBuffer 缓冲区完成。
memory mapping 的优点是什么?
- 对文件不同位置的随机访问非常快。
- 可以处理大于可用物理内存的文件(操作系统按需加载页面)。
- 被广泛用于现代数据库、索引、海量日志等场景。
缺点是什么?
- 并不总适合写入(尤其是网络文件系统)。
- 映射大小有限制(在 32 位 JVM 中单次映射通常至多 2 GB)。
- 如果忘记关闭文件,可能导致文件被占用而无法释放(尤其在 Windows)。
- 并非所有操作都会加速——若只是顺序读取,普通缓冲往往并不逊色。
4. 实用示例
示例 1:通过 memory mapping 在大文件中查找子串
假设有一个 10 GB 的文件,需要在其中查找某个字节序列(例如字符串 "SECRET")。
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
public class MemoryMappedSearch {
public static void main(String[] args) throws Exception {
String fileName = "hugefile.bin";
byte[] target = "SECRET".getBytes(StandardCharsets.UTF_8);
try (RandomAccessFile file = new RandomAccessFile(fileName, "r");
FileChannel channel = file.getChannel()) {
long fileSize = channel.size();
int chunkSize = 128 * 1024 * 1024; // 128 MB
long position = 0;
while (position < fileSize) {
long size = Math.min(chunkSize, fileSize - position);
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, position, size);
for (int i = 0; i < size - target.length; i++) {
boolean found = true;
for (int j = 0; j < target.length; j++) {
if (buffer.get(i + j) != target[j]) {
found = false;
break;
}
}
if (found) {
System.out.println("在位置 " + (position + i) + " 处找到");
// 可以停止或继续搜索
}
}
position += size;
}
}
}
}
注意:
如果子串可能跨越两个块,需要在块之间按目标序列的长度进行重叠。
5. 有用的细节
何时使用 chunking,何时使用 memory mapping?
- Chunking——适用于任何文件类型(文本、二进制、日志、压缩包),非常适合顺序处理。
- Memory mapping——在随机访问、大型索引、数据库、对超大文件的快速搜索等场景下非常高效。
如果不确定该选哪一个——先从 chunking 开始!Memory mapping 功力强大,但更“底层”,需要小心使用。
建议
- 使用 try-with-resources 自动关闭流与通道。
- 不要同时打开太多文件:操作系统对打开的文件描述符数量有上限。
- 避免映射过大的片段——可能导致错误(尤其在 32 位 JVM)。
- 并行处理时可以把文件拆分为多个块,在不同线程中处理(但注意不要把磁盘“打爆”,也不要超出内存限制)。
6. 处理大文件的常见错误
错误 №1:尝试把整个大文件加载到内存。
非常常见——尤其是新手。如果文件大于 1–2 GB,请使用 chunking 或按行读取,否则程序会因 OutOfMemoryError 崩溃。
错误 №2:缓冲区太小。
512 字节的缓冲并不是优化,而是对性能的慢性自杀。请使用至少 64 KB 以上的缓冲区。
错误 №3:忘记关闭流或通道。
文件描述符会一直占用,文件无法删除或释放,直到重启 JVM。请使用 try-with-resources。
错误 №4:不正确地使用 memory mapping。
如果文件在映射期间被其他进程修改,可能得到不一致的数据或出现错误。不要对频繁变化的文件使用 memory mapping。
错误 №5:搜索子串时未考虑块重叠。
若目标字符串可能出现在两个块的“接缝”处,务必在块之间按照该字符串的长度进行重叠。
GO TO FULL VERSION