
用Java语言读取文件:常见方法与异常处理示例
在 Java 中读取文件,常见选择包括 Files.readString、Files.readAllLines、BufferedReader、Files.lines、Scanner 和字节流。它们都能把文件内容带入程序,但在数据大小、处理方式、字符编码和资源管理方面并不相同。小文件可以一次读入;较大的文本文件通常应逐行或分块处理;图片、压缩包等二进制数据则应按字节读取。
本文示例使用 Java 11 或更高版本,统一明确指定 UTF-8 编码,并展示常见异常的处理方式。选择方法前,先确认文件是文本还是二进制、文件大小是否可控,以及程序能否接受把全部内容保存在内存中。
一、读取文件前需要确认的事项
1. 文本文件还是二进制文件
文本文件需要经过字符集解码,Java 字符串由 UTF-16 字符构成,读入时必须把文件字节按某种字符集转换为字符。对于 UTF-8 文件,应显式使用 StandardCharsets.UTF_8。如果省略字符集而使用依赖平台默认值的接口,同一份文件可能在不同操作系统或运行环境中被解码成不同结果。
图片、音频、压缩包、可执行文件等应作为字节处理。把这类数据按文本解码,可能造成内容损坏;反过来,把文本文件当作任意字节也会失去字符层面的便利。若文本文件的实际编码未知,不能仅凭文件扩展名可靠推断,需要由文件格式约定、外部元数据或专门的检测流程确定。
2. 文件大小和内存占用
Files.readString、Files.readAllLines 和 Files.readAllBytes 都会将文件整体读入内存。它们适合大小明确、可以一次处理的文件,但 API 不会替应用程序设定一个通用的安全大小上限。大文件可能占用大量堆内存,最终导致 OutOfMemoryError。如果文件大小不可信,优先考虑流式读取,并按业务需求设置读取上限。
流式读取通常能避免同时保留整份文件,但不代表内存占用必然很小:例如,把每一行继续放进集合,仍可能逐渐占满内存。应让数据尽可能在读取过程中完成处理,而不是不加限制地累积。
3. 路径与异常
相对路径是相对于进程的当前工作目录解析的,并不必然相对于 Java 源文件所在目录。运行 IDE、命令行程序或服务时,当前工作目录可能不同。可用 Path.toAbsolutePath() 查看实际路径;生产程序通常应从配置中取得数据目录,并在启动时验证。
文件操作可能抛出 IOException。常见子类包括 NoSuchFileException(路径不存在)、AccessDeniedException(权限不足或访问被拒绝)和 FileSystemException(文件系统操作失败)。具体异常类型取决于操作系统和文件系统,程序不应假设所有环境都只会抛出某一个子类。解码格式错误、无效路径及安全策略限制也可能表现为其他异常。
二、小型文本文件:整体读取
使用 Files.readString
Java 11 引入了 Files.readString。它会将文件内容读成一个字符串,适合配置文件、提示词、小型文本模板等确实需要整体使用的场景。以下程序会创建示例文件并读取它,可保存为 ReadStringExample.java 运行。
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class ReadStringExample {
public static void main(String[] args) {
Path path = Path.of("example.txt");
try {
String content = Files.readString(path, StandardCharsets.UTF_8);
System.out.println(content);
} catch (IOException e) {
System.err.println("读取文件失败:" + path.toAbsolutePath());
System.err.println(e.getMessage());
}
}
}
运行前应在当前工作目录准备好 UTF-8 编码的 example.txt。若文件不存在,通常会得到 NoSuchFileException;若进程没有读取权限,则可能得到 AccessDeniedException。示例捕获 IOException,适用于只需统一报告文件读取失败的简单程序。若调用方需要采取不同恢复措施,可针对具体异常分别处理,但仍应保留通用的 IOException 处理。
整体读取有一个重要边界:读取开始前检查文件大小并不能保证读取期间文件不会变化,也不能代替内存预算。对大小不可控或来自外部的文件,不要仅靠 Files.size 判断后就放心调用 readString。检查与读取之间文件可能被替换或增长,而且大小检查本身也可能失败。
使用 Files.readAllLines
如果需要逐行访问,但文件本身不大,可以使用 Files.readAllLines。它会返回一个包含所有行的 List<String>,因此列表及字符串都需要留在内存中,直到不再使用列表。它适合数据量受控、需要多次遍历或按行随机访问的文件,不适合无限增长的日志或大型数据集。
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class ReadAllLinesExample {
public static void main(String[] args) {
Path path = Path.of("example.txt");
try {
List<String> lines =
Files.readAllLines(path, StandardCharsets.UTF_8);
for (int i = 0; i < lines.size(); i++) {
System.out.println((i + 1) + ": " + lines.get(i));
}
} catch (IOException e) {
System.err.println("无法读取:" + path.toAbsolutePath());
e.printStackTrace();
}
}
}
行尾分隔符不会作为每个字符串的一部分保留。空文件会得到空列表;末尾是否存在换行符,通常不能通过读取结果中的“额外空行”可靠判断。如果程序必须保留原始字节、精确区分换行形式,或需要处理文件末尾的字节细节,应考虑字节读取,而不是只依靠按行 API。
三、大型文本文件:逐行读取
使用 BufferedReader 和 Files.newBufferedReader
处理大文本文件时,常见做法是逐行读取并立即处理。Files.newBufferedReader 返回的读取器使用缓冲,可以减少频繁的底层读取操作。使用 try-with-resources 能确保读取器在正常结束或异常发生时关闭。
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class BufferedReadExample {
public static void main(String[] args) {
Path path = Path.of("server.log");
try (BufferedReader reader =
Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
String line;
long lineNumber = 0;
while ((line = reader.readLine()) != null) {
lineNumber++;
processLine(lineNumber, line);
}
} catch (IOException e) {
System.err.println("读取日志时发生 I/O 错误:"
+ path.toAbsolutePath());
e.printStackTrace();
}
}
private static void processLine(long lineNumber, String line) {
if (line.contains("ERROR")) {
System.out.println(lineNumber + ": " + line);
}
}
}
readLine() 返回不含行终止符的字符串;读到文件末尾时返回 null。上例通过单个循环读取并处理每行,不会把整份日志保存在内存中。需要注意,单行本身仍可能非常长,且业务代码中的集合、缓存或字符串拼接仍会消耗内存。若输入可能包含极长的单行,应根据格式要求进一步限制行长度或改用固定大小字符缓冲区。
如果希望边读边统计、过滤或写入另一个文件,通常把这些工作放在 processLine 一类的处理函数中。不要为了“流式处理”而在循环中把所有行追加到一个列表;那会抵消逐行读取的内存优势。
使用 Files.lines
Files.lines 返回一个 Stream<String>,适合将按行处理写成流操作。它与普通的纯内存集合流不同:该流关联着打开的文件资源,必须关闭。最直接的方式是把它放进 try-with-resources。
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
public class LinesStreamExample {
public static void main(String[] args) {
Path path = Path.of("server.log");
try (Stream<String> lines =
Files.lines(path, StandardCharsets.UTF_8)) {
long errorCount = lines
.filter(line -> line.contains("ERROR"))
.count();
System.out.println("错误行数:" + errorCount);
} catch (IOException e) {
System.err.println("读取或处理文件失败:"
+ path.toAbsolutePath());
e.printStackTrace();
}
}
}
终止操作(例如 count())执行后,try-with-resources 会关闭流。不要仅仅因为变量类型是 Stream 就省略关闭动作。若在流处理期间发生读取异常,异常可能以 UncheckedIOException 的形式出现;而打开文件时仍可能直接抛出 IOException。不需要流式表达式时,使用 BufferedReader 往往更直观,也更容易在循环中处理可检查异常。
流操作默认不应依赖副作用顺序,例如在 map 中修改共享状态。是否使用并行流也要结合任务特点评估:文件读取、行长度和处理逻辑都会影响表现;并行化并不自动带来更低内存占用或更高速度。
四、需要逐个令牌解析:Scanner
Scanner 可以按空白或自定义分隔符扫描文本,也提供 nextInt 等类型解析方法。它适合简单的命令行输入、结构简单的小文件或教学示例。若处理大文件、格式复杂的 CSV,或对性能和错误位置有明确要求,通常应使用更适合该格式的解析器,而不是把 Scanner 当作通用高速文件读取器。
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Scanner;
public class ScannerExample {
public static void main(String[] args) {
Path path = Path.of("numbers.txt");
try (Scanner scanner =
new Scanner(path, StandardCharsets.UTF_8)) {
long sum = 0;
int count = 0;
while (scanner.hasNext()) {
if (scanner.hasNextInt()) {
sum += scanner.nextInt();
count++;
} else {
String invalidToken = scanner.next();
System.err.println("跳过非整数内容:" + invalidToken);
}
}
System.out.println("有效整数个数:" + count);
System.out.println("总和:" + sum);
} catch (IOException e) {
System.err.println("打开或读取文件失败:"
+ path.toAbsolutePath());
e.printStackTrace();
}
}
}
该示例适用于以空白分隔整数的 numbers.txt。hasNextInt() 会检查下一个令牌能否按当前扫描器的规则解析为整数;解析失败时,示例用 next() 消耗无效令牌,避免循环停留在同一处。实际程序还应决定无效数据是跳过、记录错误还是直接终止。若整数可能超出 int 范围,检查和读取方法也应改为适当的长整型解析方式。
Scanner 会执行令牌分割和类型转换,默认分隔规则是空白。它不等同于 CSV 解析器:CSV 可能包含引号、转义符和分隔符嵌套规则,简单按逗号拆分会产生错误结果。
五、二进制文件:读取字节
对于小型二进制文件,可使用 Files.readAllBytes 一次性读入 byte[]。它适合文件大小已知且有合理上限的场景,例如读取一张尺寸受控的小图片。返回的字节数组会占用与文件内容相应的内存,此外,后续解码或复制还可能产生额外内存开销。
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class ReadBytesExample {
public static void main(String[] args) {
Path path = Path.of("sample.bin");
try {
byte[] data = Files.readAllBytes(path);
System.out.println("读取字节数:" + data.length);
// 在这里把 data 交给相应的二进制格式解析器。
} catch (IOException e) {
System.err.println("读取二进制文件失败:"
+ path.toAbsolutePath());
e.printStackTrace();
}
}
}
Files.readAllBytes 没有提供“最多读取多少字节”的参数,也不会替应用验证文件格式。若文件来自不可信来源,不应将它直接整体读入内存;应采用有明确上限的流式读取,并在解析时校验格式、长度和业务约束。
处理较大的二进制文件时,可以通过 InputStream 分块读取。下例演示将输入复制到输出流,并统计处理的字节数。缓冲区大小是程序选择的工作缓冲区,不是对输入文件大小的限制;代码把读取出的内容写入目标文件,因此实际使用时也应确认目标路径、存储空间和覆盖策略。
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
public class CopyLargeFileExample {
public static void main(String[] args) {
Path source = Path.of("large.bin");
Path target = Path.of("large-copy.bin");
byte[] buffer = new byte[8192];
long total = 0;
try (InputStream in = Files.newInputStream(source);
OutputStream out = Files.newOutputStream(
target,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {
int count;
while ((count = in.read(buffer)) != -1) {
out.write(buffer, 0, count);
total += count;
}
System.out.println("已复制字节数:" + total);
} catch (IOException e) {
System.err.println("文件复制失败");
e.printStackTrace();
}
}
}
示例使用 TRUNCATE_EXISTING,意味着目标文件已存在时会被截断。若业务不能覆盖已有文件,应重新选择打开选项,例如使用 CREATE_NEW 并处理目标已存在的情况。输入和输出都放在同一个 try-with-resources 中,即使读写中途抛出异常,也会尝试关闭两者。
六、异常处理的实用做法
捕获能够处理的异常
最简单且常见的边界是捕获 IOException,在命令行程序中输出上下文并报告失败;在服务程序中则可以把错误转换成符合接口约定的响应,同时记录日志。日志应包含有用的路径或业务标识,但不要因为图省事就吞掉异常,也不要只记录“出错了”而完全丢失原因。
try {
String text = Files.readString(path, StandardCharsets.UTF_8);
use(text);
} catch (IOException e) {
logger.error("读取配置文件失败,path={}", path.toAbsolutePath(), e);
throw new IllegalStateException("无法加载配置文件", e);
}
上例展示的是常见服务端日志写法,要求项目中已有相应的日志框架。异常链中的原始异常应保留为 cause,便于定位问题。不要捕获 Exception 后不加判断地返回空字符串或空列表:这样可能把“文件不存在”“权限错误”和“文件为空”混为一谈,使程序继续以错误状态运行。
不要用 exists 检查替代异常处理
先调用 Files.exists(path) 再读取,看似能提前处理文件不存在,但检查和读取之间文件可能被删除、替换或权限改变,读取操作依然可能失败。检查结果本身也可能是无法确定的状态。若目标是“尝试读取,并对失败作出响应”,直接执行读取并处理异常通常更稳妥。若业务确实需要预检查,可把它当作提示或验证步骤,而不能视为读取必定成功的保证。
文件解码错误与数据校验
文件按指定字符集读取时,输入字节必须能按该字符集解码。格式不匹配或内容无效可能导致解码相关错误,具体表现与所使用的 API 和解码器策略有关。不要把替换字符出现与“文件已正确解码”画等号;如果格式要求严格,应显式配置解码器的错误处理策略,并验证读取后的内容是否符合业务格式。
字符编码只是字节到字符的转换规则,不负责验证文件是不是合法配置、日志、JSON 或其他格式。文本读取成功后仍要做语法和业务校验,例如检查必填字段、数字范围和允许的取值。解析失败与文件 I/O 失败是不同类别的问题,通常应分别报告。
七、路径安全与不可信输入
如果路径由用户、请求参数或外部数据决定,不能只凭字符串拼接就认为文件位于指定目录内。例如,把用户输入直接拼到 /data/uploads/ 后面,攻击者可能利用 ../ 等路径片段访问其他位置。应先定义受信任的基础目录,再规范化解析后的路径并检查其是否仍在允许目录之下。
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class SafePathExample {
public static Path resolveInside(Path baseDirectory, String userInput)
throws IOException {
Path base = baseDirectory.toAbsolutePath().normalize();
Path candidate = base.resolve(userInput).normalize();
if (!candidate.startsWith(base)) {
throw new SecurityException("路径超出允许目录");
}
return candidate;
}
public static void main(String[] args) throws IOException {
Path base = Paths.get("data");
Path file = resolveInside(base, "reports/today.txt");
String content = Files.readString(
file,
java.nio.charset.StandardCharsets.UTF_8);
System.out.println(content);
}
}
normalize 会在路径层面消除可处理的冗余部分,startsWith 则按路径组件检查是否仍在基础路径之下;不能用普通字符串前缀代替路径判断,因为例如 /safe-other 也可能以 /safe 开头。此示例是路径边界检查的基础示意,不是适用于所有威胁模型的完整沙箱。
还要考虑符号链接:候选路径中的链接可能指向基础目录以外。若安全要求包括防止通过符号链接逃逸,仅对规范化后的路径做字符串或词法路径检查并不足够;应结合部署环境的目录权限、符号链接策略以及文件系统层面的安全操作来设计。检查路径和打开文件之间也存在并发替换的可能,敏感场景不能把一次路径检查当成不可绕过的安全保证。
路径安全只解决“打开哪个位置”的一部分问题。对于不可信文件,还应考虑最大文件大小、读取超时或取消机制、解压缩后的尺寸限制、格式解析器的资源消耗以及文件权限。标准文件读取 API 不会自动提供这些业务级限制。
八、常见错误与排查清单
- 使用了错误的工作目录:先输出
path.toAbsolutePath(),确认程序实际寻找的位置;不要假设相对路径以项目根目录为基准。 - 字符出现乱码:确认文件真实编码,并在读写两端使用一致的字符集;不要把“改成 UTF-8”当作对任意旧文件都有效的修复。
- 大文件导致内存压力:避免
readString、readAllLines和readAllBytes整体加载不受控数据;逐行或分块处理,并检查业务逻辑是否仍在累积全部内容。 - 资源没有关闭:文件读取器、输入流、输出流,以及
Files.lines返回的流,都应通过try-with-resources管理。 - 把空文件和读取失败混淆:空文件可能是有效输入;读取失败应通过异常表达,不应简单返回空内容来掩盖错误。
- 把文本读取用于二进制数据:二进制文件应按字节读取;不能假设任意字节都能无损转换为字符串。
- 解析格式过于简单:按空格拆分并不等于解析 CSV、JSON 或其他结构化格式;应使用符合格式规范的解析器,并校验数据。
- 路径检查不完整:避免直接信任用户提供的路径;词法规范化不自动解决符号链接、并发替换和权限配置问题。
九、如何选择读取方式
| 需求 | 常用方式 | 主要边界 |
|---|---|---|
| 读取大小受控的文本并整体使用 | Files.readString | 内容整体进入内存;Java 11 及以上 |
| 读取小型文本并多次访问各行 | Files.readAllLines | 所有行保存在列表中 |
| 逐行处理大型文本 | Files.newBufferedReader | 需要关闭读取器;单行仍可能很大 |
| 以流操作过滤或统计行 | Files.lines | 返回的流必须关闭;处理异常可能以非检查异常出现 |
| 按令牌扫描简单文本 | Scanner | 默认按空白分隔,不适合作为通用高性能格式解析器 |
| 整体读取大小受控的二进制文件 | Files.readAllBytes | 文件字节全部进入内存;无内建业务大小上限 |
| 复制或处理大型二进制文件 | InputStream 分块读取 | 缓冲区大小不等于输入文件大小限制;仍需校验输入和输出策略 |
实际项目中,读取方式应由数据大小、格式和处理目标共同决定。小型、可信且确实需要整体使用的文本,可以用 Files.readString;需要逐行处理时,用 BufferedReader 或正确关闭的 Files.lines;二进制内容则按字节读取。无论选择哪一种,都应明确字符集、用 try-with-resources 关闭资源、处理 I/O 异常,并对不可信输入设置符合业务需要的大小与格式限制。









