大家好,我是会编程的吕洞宾。今天咱们不炼丹,改聊聊Java里那些让人又爱又恨的I/O(输入/输出)操作。别怕,我保证用最“接地气”的方式,带你捋清楚从老派的I/O流到炫酷的新I/O系统。咱们的目标是:看完这篇,你写I/O代码时能像吕洞宾御剑飞行一样潇洒!

一、 I/O流:Java世界的“水管工”

想象一下,数据就像水。I/O流就是连接水源(数据源)和水池(你的程序)的一根根水管。Java提供了琳琅满目的“水管”,咱们分门别类瞧瞧。

1. 各种InputStream和OutputStream类型

这是最基础的“水管”,负责处理字节(原始数据)。

  • 特征:它们是抽象类,所有字节输入/输出流的爸爸。
  • 常见儿子们
    • FileInputStream / FileOutputStream: 从文件读字节,往文件写字节。最常用,就像你家入户的总水管。
    • ByteArrayInputStream / ByteArrayOutputStream: 把内存中的字节数组当成流来处理。适合在内存里折腾数据,不用真写文件。
    • BufferedInputStream / BufferedOutputStream: 给其他流套个“缓冲层”。没有它,读一个字节就从硬盘拿一次,慢得像滴水;有了它,一次读一大块放内存里,用的时候从内存取,快如自来水!强烈建议给文件流套上它
    • DataInputStream / DataOutputStream: 能直接读写Java基本数据类型(int, double等)和String。它们给字节流穿上了“理解Java语言”的外衣。
    • ObjectInputStream / ObjectOutputStream: 更狠,能直接读写整个Java对象(需要对象实现Serializable接口)。相当于能把一个“包裹”(对象)整个塞进水管或取出来。
  • 优缺点
    • 优点:概念简单,适合处理所有二进制数据(如图片、视频)。
    • 缺点:直接操作字节,处理文本时很麻烦(需要考虑编码,一个中文字符可能占多个字节)。

举例与反例

// 正例:用缓冲流高效拷贝文件
try (FileInputStream fis = new FileInputStream("source.jpg");
     BufferedInputStream bis = new BufferedInputStream(fis);
     FileOutputStream fos = new FileOutputStream("copy.jpg");
     BufferedOutputStream bos = new BufferedOutputStream(fos)) {
    byte[] buffer = new byte[1024];
    int bytesRead;
    while ((bytesRead = bis.read(buffer)) != -1) {
        bos.write(buffer, 0, bytesRead);
    }
}

// 反例:不用缓冲流,一个字节一个字节读(慢到怀疑人生)
try (FileInputStream fis = new FileInputStream("bigFile.txt");
     FileOutputStream fos = new FileOutputStream("output.txt")) {
    int b;
    while ((b = fis.read()) != -1) { // 每次只读一个字节!
        fos.write(b);
    }
}
// **原因**:每次`read()`和`write()`都是系统调用,涉及用户态和内核态切换,极其耗时。对于大文件,时间可能是缓冲流的几十上百倍。

2. 添加属性和有用的接口

有时候光有水不够,还得知道水的温度、压力。这就是“装饰器模式”在I/O流里的应用。

  • 核心接口/类
    • FilterInputStream / FilterOutputStream: 是装饰器模式的“插座”。上面提到的BufferedInputStreamDataInputStream都是它的子类。
    • PushbackInputStream: 可以把读出来的一个字节“推回去”,下次再读。就像你看书,看了一眼觉得没懂,可以把视线挪回去重新看。
  • 特征:通过层层包装,给基础流添加新功能。组合灵活,功能强大。
  • 优缺点
    • 优点:符合开闭原则,可以动态地给流添加功能。
    • 缺点:创建流对象时可能需要多层嵌套,代码看起来有点绕。

举例

// 组合使用:一个能缓冲、能回推、还能读基本类型的输入流
try (DataInputStream dis = new DataInputStream(
                          new PushbackInputStream(
                          new BufferedInputStream(
                          new FileInputStream("data.bin"), 1024), 8))) {
    // 现在dis可以高效地(缓冲)、灵活地(回推)、方便地(读基本类型)读取文件了
    int anInt = dis.readInt();
}

3. 各种Reader和Writer

字节流处理文本太费劲?Reader和Writer来救场!它们是处理字符(文本)的“水管”,核心是解决了字符编码问题。

  • 特征:它们是抽象类,内部会自动处理字符编码(如UTF-8, GBK)。
  • 常见类型
    • InputStreamReader / OutputStreamWriter: 桥梁!能把字节流转换成字符流。构造时需要指定字符集(Charset),比如StandardCharsets.UTF_8
    • FileReader / FileWriter: 读写文本文件的便捷类。注意:它们使用平台默认编码,容易导致跨平台乱码。更推荐用InputStreamReaderOutputStreamWriter指定明确编码
    • BufferedReader / BufferedWriter: 和字节缓冲流一样,给字符流加缓冲,并且BufferedReader有个超好用的readLine()方法,能一次读一行。
    • PrintWriter: 我们最爱的System.out就是它!提供了print(), println(), printf()等格式输出方法,比原始的Writer方便太多。
  • 优缺点
    • 优点:专为文本设计,无需手动处理编码转换,提供了方便的文本操作方法。
    • 缺点:只能处理文本,不能处理二进制数据。

举例与反例

// 正例:用指定编码安全地读取文本文件
try (BufferedReader reader = new BufferedReader(
                             new InputStreamReader(
                             new FileInputStream("text.txt"), StandardCharsets.UTF_8))) {
    String line;
    while ((line = reader.readLine()) != null) {
        System.out.println(line);
    }
}

// 反例:使用FileReader,依赖默认编码(可能导致乱码)
try (FileReader fr = new FileReader("text.txt")) {
    // 如果文件是UTF-8编码,但系统默认是GBK,这里读出来就是乱码
}
// **原因**:`FileReader`无法指定编码,其行为取决于`file.encoding`系统属性,不可控。

4. 自成一家的RandomAccessFile

这位是I/O界的“异类”。它不像流那样只能顺序读/写,它可以像数组一样,随机访问文件的任何位置。

  • 特征
    • 同时实现了DataInputDataOutput接口,既能读也能写。
    • 通过seek(long pos)方法,可以把文件指针跳到任意位置。
    • 构造时需要指定模式("r"只读,"rw"读写等)。
  • 优缺点
    • 优点:随机访问,非常适合修改文件中间某部分内容,或者实现数据库索引等结构。
    • 缺点:API相对底层和古老,不如NIO的FileChannel+MappedByteBuffer组合强大和高效。

举例

// 在文件末尾追加内容
try (RandomAccessFile raf = new RandomAccessFile("log.txt", "rw")) {
    raf.seek(raf.length()); // 跳到文件末尾
    raf.writeBytes("\nNew log entry at " + new Date());
}

5. I/O流的典型用法

总结一下,经典I/O流的使用有个黄金模式:

  1. 嵌套装饰:从最底层的节点流(如FileInputStream)开始,根据需求用装饰流(如BufferedInputStream, DataInputStream)一层层包装。
  2. 使用Try-With-Resources:所有流都实现了AutoCloseable接口,用try-with-resources语法确保流一定会被关闭,防止资源泄漏。
  3. 缓冲区是王道:处理文件或网络数据时,务必使用缓冲流。
// 典型模板:读取一个UTF-8编码的文本文件,并打印行号
try (BufferedReader br = new BufferedReader(
                        new InputStreamReader(
                        new FileInputStream("input.txt"), StandardCharsets.UTF_8))) {
    String line;
    int lineNum = 1;
    while ((line = br.readLine()) != null) {
        System.out.println(lineNum++ + ": " + line);
    }
} catch (IOException e) {
    e.printStackTrace();
}

二、 标准I/O:程序与世界的“标准对话窗口”

每个Java程序一出生,就拥有了三个“标准流”,它们连接着程序和外部的世界(通常是控制台)。

1. 从标准输入中读取

System.in是一个静态的InputStream对象。默认关联键盘输入。

// 从控制台读取一行输入(古老方式,麻烦)
BufferedReader stdin = new BufferedReader(new InputStreamReader(System.in));
System.out.print("Enter your name: ");
String name = stdin.readLine();

2. 将System.out转换为PrintWriter

System.out是一个PrintStream。但有时候我们需要它作为Writer来用(比如某些API要求Writer参数)。

// 将System.out包装成PrintWriter
PrintWriter out = new PrintWriter(System.out, true); // 第二个参数autoFlush设为true
out.println("Hello, this is from a PrintWriter!");
// 现在out拥有了和System.out一样的print/println方法,但本质是Writer

3. 标准I/O重定向

这是个很酷的功能!你可以让程序的输入不来自键盘,输出不显示在屏幕。

// 将标准输出重定向到一个文件
PrintStream originalOut = System.out;
try (PrintStream fileOut = new PrintStream(new FileOutputStream("output.log"))) {
    System.setOut(fileOut); // 从此以后,System.out.println的内容都进文件了
    System.out.println("This line goes to the file, not console!");
} finally {
    System.setOut(originalOut); // 恢复原状
}
System.out.println("Now back to console.");

应用场景:日志记录、捕获程序输出进行自动化测试等。

4. 进程控制

通过Runtime.exec()ProcessBuilder启动外部进程(另一个程序),然后可以通过获取该进程的输入、输出、错误流来和它交互。

// 执行系统命令并读取结果
Process process = new ProcessBuilder("ls", "-la").start();
try (BufferedReader reader = new BufferedReader(
                             new InputStreamReader(process.getInputStream()))) {
    reader.lines().forEach(System.out::println);
}
int exitCode = process.waitFor(); // 等待进程结束
System.out.println("Exited with code: " + exitCode);

这让你能在Java程序里调用命令行工具,非常强大。

三、 新I/O系统(NIO):为速度而生的“高速公路”

老I/O流是“流”,像水管,数据一个方向顺序流动。NIO(New I/O)的核心是通道(Channel)和缓冲区(Buffer),更像铁路和货车:数据(货物)被装进Buffer(货车),通过Channel(铁轨)运输,可以双向,可以非阻塞(异步),效率高得多。

1. 字节缓冲区 ByteBuffer

ByteBuffer是NIO的基石,一个可以装原生数据的容器。

  • 特征
    • 状态变量position(当前位置),limit(限制位置),capacity(容量)。理解这三个变量是玩转Buffer的关键!
    • 操作put()写数据,get()读数据,flip()切换为读模式,clear()/compact()清空或压缩后切换为写模式。
  • 典型流程
    1. 向Buffer写数据 (put) -> position移动。
    2. 写完,调用flip() -> limit移到当前positionposition归零,切换为读模式
    3. 从Buffer读数据 (get) -> position移动。
    4. 读完,调用clear()(丢弃所有数据)或compact()(保留未读数据)-> 切换回写模式,准备接收新数据。
ByteBuffer buf = ByteBuffer.allocate(48); // 分配一个48字节的Heap Buffer
// 写模式
buf.put((byte) 1);
buf.putInt(42);
buf.putChar('A');
// 切换为读模式
buf.flip();
// 读模式(必须按写入顺序和类型读!)
byte aByte = buf.get();
int anInt = buf.getInt();
char aChar = buf.getChar();

2. 转换数据

Charset类提供了字符和字节缓冲区转换的桥梁。

// 字符串 -> ByteBuffer
ByteBuffer buffer = StandardCharsets.UTF_8.encode("Hello NIO");
// ByteBuffer -> 字符串
CharBuffer charBuffer = StandardCharsets.UTF_8.decode(buffer);
String str = charBuffer.toString();

3. 获取基本类型 & 4. 视图缓冲区

ByteBuffer提供了一系列asXxxBuffer()方法来创建视图缓冲区。它们和原始ByteBuffer共享底层数据,但以不同的数据类型视角看待它。

ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 写入一些数据
byteBuffer.putInt(100);
byteBuffer.putDouble(3.14);
byteBuffer.flip();
// 创建IntBuffer视图,从当前位置开始
IntBuffer intBuffer = byteBuffer.asIntBuffer();
int firstInt = intBuffer.get(); // 读取100
// 注意:get()之后,原ByteBuffer的position也前进了4个字节(一个int的大小)
// 现在原ByteBuffer的position指向double数据的开始
DoubleBuffer doubleBuffer = byteBuffer.asDoubleBuffer();
double aDouble = doubleBuffer.get(); // 读取3.14

优点:操作方便,无需手动计算字节偏移量。注意:视图缓冲区会修改原缓冲区的position

5. 用缓冲区操纵数据

除了基本的put/get,Buffer还支持批量操作、相对/绝对位置操作、标记/重置等。

ByteBuffer buf = ByteBuffer.allocate(10);
// 相对操作 (position会自动移动)
buf.put(new byte[]{1,2,3});
// 绝对操作 (指定索引,不改变position)
buf.put(5, (byte) 99);
buf.flip();
byte[] dst = new byte[3];
buf.get(dst, 0, 3); // 批量读
buf.mark(); // 标记当前位置
buf.get(); // position移动
buf.reset(); // 重置到标记位置

6. 内存映射文件

这是NIO的大杀器!它允许你将一个文件或文件的一部分直接映射到内存中。这样操作文件就像操作内存数组一样快,因为操作系统会负责数据的缓存和同步。

try (RandomAccessFile file = new RandomAccessFile("huge.data", "rw");
     FileChannel channel = file.getChannel()) {
    // 将文件的前100字节映射到内存
    MappedByteBuffer mappedBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 100);
    // 直接像操作数组一样操作文件!
    for (int i = 0; i < mappedBuffer.limit(); i++) {
        byte b = mappedBuffer.get(i);
        mappedBuffer.put(i, (byte)(b + 1)); // 每个字节加1
    }
    // 无需显式关闭,修改会自动写回文件(由OS决定时机,也可force()强制写回)
}

适用场景:处理超大文件(如视频编辑、数据库文件),效率远超普通流。

7. 文件加锁

NIO提供了更细粒度的文件锁机制。

try (FileChannel channel = FileChannel.open(Paths.get("test.txt"), StandardOpenOption.WRITE)) {
    // 获取排他锁(写锁),如果文件已被其他进程锁定,这里会阻塞
    FileLock lock = channel.lock(); // 锁整个文件
    // FileLock lock = channel.lock(0, 10, false); // 锁文件的前10个字节(共享锁)
    try {
        // 安全地进行写操作...
        channel.write(ByteBuffer.wrap("Locked data".getBytes()));
    } finally {
        lock.release(); // 释放锁
    }
}

注意:Java的文件锁是**劝告式(advisory)**的,不是强制式(mandatory)。意思是它只阻止其他遵守此锁规则的Java程序(或明确检查此锁的程序)访问,对于不检查锁的程序(比如用普通文本编辑器打开)无效。


好了,各位道友,Java I/O的漫漫长路,咱们今天就逛到这。从朴实无华的字节流,到专精文本的字符流,再到高效灵活的NIO缓冲区与通道,每一样工具都有其用武之地。记住吕洞宾的忠告:

  • 处理文本,优先考虑Reader/Writer并明确指定编码
  • 读写文件,务必给流穿上“缓冲”的外衣
  • 追求性能,尤其是大文件或随机访问,勇敢拥抱NIO和内存映射

理解了这些,你在Java世界里处理数据输入输出,便能如吕洞宾的飞剑一般,指哪打哪,游刃有余。我们下回再见!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐