用Java语言读取文件:常见方法与异常处理示例

资讯3天前发布 jdkjadmin
0 00
用Java语言读取文件:常见方法与异常处理示例

用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 异常,并对不可信输入设置符合业务需要的大小与格式限制。

© 版权声明

相关文章