打印流与数据流 | JavaSE

打印流与数据流

一、学习目标

完成本章后,你应该能够:

  • 能够解释打印流解决了什么问题。
  • 能够区分 PrintStreamPrintWriter
  • 能够使用 print()println()printf() 输出各种类型的数据。
  • 能够理解 System.out 为什么可以直接调用 println()
  • 能够使用打印流生成日志、报表等文本文件。
  • 能够解释数据流解决了什么问题。
  • 能够使用 DataOutputStream 按 Java 数据类型写出数据。
  • 能够使用 DataInputStream 按相同顺序和类型读取数据。
  • 能够解释为什么数据流生成的文件通常不是普通文本文件。
  • 能够区分“打印数据的文本表示”和“保存数据本身的二进制表示”。

二、核心知识

2.1 为什么需要打印流

前面使用字符输出流写数据:

Writer writer = new FileWriter("data.txt");

writer.write("Java");
writer.write("\n");
writer.write("100");

如果要写:

int age = 20;
double score = 95.5;
boolean passed = true;

不能直接:

writer.write(age);

然后期望文件中出现:

20

因为 Writer.write(int) 中的 int 参数表示的是:

要写出的字符编码值。

因此如果我们想把各种 Java 数据:

int
double
boolean
char
String
Object

方便地转换成:

人类可以直接阅读的文本

Java 提供了:

打印流(Print Stream / Print Writer)

核心目的就是:

方便地打印各种数据值的文本表示。


2.2 Java 中两种主要打印流

Java IO 中主要学习:

PrintStream
PrintWriter

二者都支持:

print()
println()
printf()
format()

但是它们所属体系不同。

| 类 | 所属体系 | 特点 | | ------------- | ---------- | ------------------- | | PrintStream | 字节输出流 | 继承 OutputStream | | PrintWriter | 字符输出流 | 继承 Writer |

继承关系可以简化为:

OutputStream
    ↑
FilterOutputStream
    ↑
PrintStream

以及:

Writer
  ↑
PrintWriter

所以:

PrintStream 本质上还是字节输出流。

PrintWriter 本质上是字符输出流。


2.3 打印流到底“打印”的是什么

假设:

int age = 20;

使用:

ps.println(age);

写入文件后得到的是类似:

20

也就是说:

int 20
   ↓
转换成文本表示
   ↓
"20"
   ↓
编码
   ↓
字节
   ↓
文件

所以打印流特别适合:

日志
报表
配置输出
调试信息
控制台信息
人类可读文本

2.4 System.out 本身就是 PrintStream

我们从 Java 第一节课就在使用:

System.out.println("Hello World");

现在终于可以解释它了。

System.out 的类型就是:

PrintStream

所以:

System.out.println()

本质上就是在使用:

打印流

可以理解成:

System
  ↓
out
  ↓
PrintStream
  ↓
标准输出设备
  ↓
控制台

所以 println() 并不是 Java 语法。

它只是:

PrintStream 类提供的普通方法。


2.5 为什么还需要数据流

打印流解决:

把各种数据转换成人类可读文本

但假如需求是:

int age = 20;
double score = 95.5;
boolean passed = true;

我希望保存以后还能:

按照 int 读取
按照 double 读取
按照 boolean 读取

而不是全部变成字符串。

这时就需要:

DataOutputStream
DataInputStream

它们属于:

数据输入输出流(Data Streams)

核心能力:

Java 基本数据类型
        ↓
   二进制形式
        ↓
      文件

读取时:

文件中的二进制数据
        ↓
按照原始数据类型恢复
        ↓
Java 基本数据类型

2.6 数据流的两个核心类

DataOutputStream

作用:

将 Java 基本数据类型按照规定的二进制格式写入底层输出流。

例如:

writeByte()
writeBoolean()
writeShort()
writeChar()
writeInt()
writeLong()
writeFloat()
writeDouble()
writeUTF()

DataInputStream

作用:

从底层输入流中读取二进制数据,并恢复成对应 Java 数据类型。

例如:

readByte()
readBoolean()
readShort()
readChar()
readInt()
readLong()
readFloat()
readDouble()
readUTF()

二者通常配套:

DataOutputStream
        ↓
      写出
        ↓
二进制文件
        ↓
      读取
        ↓
DataInputStream

三、使用方法

3.1 PrintStream 基础使用

最简单:

import java.io.PrintStream;

public class PrintStreamDemo {
    public static void main(String[] args) throws Exception {

        try (PrintStream ps = new PrintStream("data.txt")) {

            ps.println(97);
            ps.println('A');
            ps.println("Java");
            ps.println(true);
            ps.println(99.9);
        }
    }
}

文件内容类似:

97
A
Java
true
99.9

注意:

ps.println(97);

写入的不是:

ASCII 97 对应的字符 a

而是:

97

因为这是:

println(int)

它打印的是整数值的文本表示。

这正是打印流和普通:

OutputStream.write(int)

最容易混淆的地方。


3.2 print() 与 println()

print()

打印数据,但不主动结束当前行:

ps.print("Java");
ps.print("MySQL");
ps.print("Redis");

结果:

JavaMySQLRedis

println()

打印数据并结束当前行:

ps.println("Java");
ps.println("MySQL");
ps.println("Redis");

结果:

Java
MySQL
Redis

所以:

print
=
打印

println
=
打印 + 换行

3.3 printf() 格式化输出

打印流还支持:

printf()

例如:

String name = "张三";
int age = 20;
double score = 95.567;

ps.printf("姓名:%s,年龄:%d,成绩:%.2f%n",
        name, age, score);

结果:

姓名:张三,年龄:20,成绩:95.57

常见格式:

| 格式 | 含义 | | ------ | ------------ | | %s | 字符串 | | %d | 十进制整数 | | %f | 浮点数 | | %.2f | 保留两位小数 | | %n | 平台相关换行 |

这使打印流非常适合:

报表
日志
成绩单
表格化文本

3.4 PrintStream 追加写入

如果直接:

new PrintStream("log.txt")

已有文件通常会被覆盖。

如果希望追加:

PrintStream ps =
        new PrintStream(
                new FileOutputStream("log.txt", true)
        );

例如:

import java.io.FileOutputStream;
import java.io.PrintStream;

public class LogDemo {

    public static void main(String[] args) throws Exception {

        try (
                PrintStream ps =
                        new PrintStream(
                                new FileOutputStream("log.txt", true)
                        )
        ) {

            ps.println("用户登录成功");
        }
    }
}

这里体现了我们前面反复学习的:

包装流思想

结构:

PrintStream
     ↓
FileOutputStream
     ↓
log.txt

3.5 PrintWriter

PrintWriterPrintStream 的打印接口非常相似:

import java.io.PrintWriter;

public class PrintWriterDemo {

    public static void main(String[] args) throws Exception {

        try (PrintWriter writer =
                     new PrintWriter("report.txt")) {

            writer.println("Java 学习报告");
            writer.println(100);
            writer.println(95.5);

            writer.printf("完成度:%.1f%%%n", 87.5);
        }
    }
}

3.6 PrintStream 与 PrintWriter 怎么选

简单理解:

PrintStream
→ 字节输出体系

PrintWriter
→ 字符输出体系

如果主要目标是:

输出文本

PrintWriter 的语义更加直接。

如果已经拥有:

OutputStream

或者使用:

System.out
System.err

那么经常会接触 PrintStream

实际开发中,不要机械理解成:

PrintWriter 一定比 PrintStream 好

应该根据:

底层目标
+
数据类型
+
现有 API

进行选择。


3.7 PrintWriter 包装其他字符流

例如:

PrintWriter writer =
        new PrintWriter(
                new BufferedWriter(
                        new FileWriter("result.txt")
                )
        );

可以理解成:

PrintWriter
负责:
方便打印各种数据

↓     

BufferedWriter
负责:
字符缓冲

↓

FileWriter
负责:
写文件

于是:

打印能力
+
缓冲能力
+
文件输出能力

被组合到了一起。

这就是 Java IO 的典型设计方式。


四、数据流的使用

4.1 DataOutputStream

创建方式:

DataOutputStream dos =
        new DataOutputStream(
                new FileOutputStream("data.bin")
        );

完整案例:

import java.io.DataOutputStream;
import java.io.FileOutputStream;

public class DataOutputDemo {

    public static void main(String[] args) throws Exception {

        try (
                DataOutputStream dos =
                        new DataOutputStream(
                                new FileOutputStream("data.bin")
                        )
        ) {

            dos.writeByte(34);
            dos.writeUTF("你好");
            dos.writeInt(3665);
            dos.writeDouble(9.9);
            dos.writeBoolean(true);
        }
    }
}

这里并不是把数据写成:

34
你好
3665
9.9
true

这种普通文本。

而是:

byte
String
int
double
boolean

分别按照数据流规定的二进制格式写入。

因此通常把文件命名成:

data.bin
data.dat

比:

data.txt

更加符合语义。


4.2 DataInputStream

读取:

import java.io.DataInputStream;
import java.io.FileInputStream;

public class DataInputDemo {

    public static void main(String[] args) throws Exception {

        try (
                DataInputStream dis =
                        new DataInputStream(
                                new FileInputStream("data.bin")
                        )
        ) {

            byte value1 = dis.readByte();
            String value2 = dis.readUTF();
            int value3 = dis.readInt();
            double value4 = dis.readDouble();
            boolean value5 = dis.readBoolean();

            System.out.println(value1);
            System.out.println(value2);
            System.out.println(value3);
            System.out.println(value4);
            System.out.println(value5);
        }
    }
}

输出:

34
你好
3665
9.9
true

4.3 最重要规则:读写顺序必须一致

假设写入:

dos.writeInt(100);
dos.writeDouble(95.5);
dos.writeBoolean(true);

那么读取必须对应:

dis.readInt();
dis.readDouble();
dis.readBoolean();

结构:

写入:

int
 ↓
double
 ↓
boolean


读取:

int
 ↓
double
 ↓
boolean

如果错误写成:

dis.readDouble();
dis.readInt();
dis.readBoolean();

程序不会知道:

“这里本来应该是 int。”

因为文件中本质只有:

连续的 byte

Java 不会自动在每段数据前附加:

这里是 int
这里是 double

这种类型标签。


4.4 为什么错误顺序会导致错误结果

假设:

dos.writeInt(100);

int 占:

4 byte

随后:

dos.writeDouble(95.5);

double 占:

8 byte

文件大致:

[ int 的4字节 ][ double 的8字节 ]

如果读取时第一步:

dis.readDouble();

它会直接取:

8 byte

于是:

int 的4字节
+
double 前4字节

被错误解释成一个 double

从这一刻起:

整个读取边界全部错位

所以数据流最重要的规则:

写什么类型,就必须按相同顺序读什么类型。


4.5 DataOutputStream 常用 API

| 写入方法 | 对应类型 | | ---------------- | -------- | | writeBoolean() | boolean | | writeByte() | byte | | writeShort() | short | | writeChar() | char | | writeInt() | int | | writeLong() | long | | writeFloat() | float | | writeDouble() | double | | writeUTF() | String |

读取:

| 读取方法 | 返回类型 | | --------------- | -------- | | readBoolean() | boolean | | readByte() | byte | | readShort() | short | | readChar() | char | | readInt() | int | | readLong() | long | | readFloat() | float | | readDouble() | double | | readUTF() | String |

你会发现:

writeXxx
↔
readXxx

基本一一对应。


五、原理与进阶

5.1 打印流与数据流最核心的区别

假设:

int value = 97;

PrintStream

ps.print(value);

保存的是:

"97"

也就是:

数值 97 的文本表示。


DataOutputStream

dos.writeInt(value);

保存的是:

int 97 对应的 4 个二进制字节。

因此:

PrintStream
97
 ↓
"97"
 ↓
字符编码
 ↓
文件


DataOutputStream
97
 ↓
int 二进制表示
 ↓
文件

这就是两类流最重要的区别。


5.2 文本格式和二进制格式

可以进一步理解成:

文本格式

例如:

1001,张三,95.5

优点:

人类可读
容易查看
容易编辑
跨语言交换方便

缺点:

需要解析字符串
某些数据体积可能更大
类型信息需要协议约定

二进制格式

例如:

DataOutputStream

写出的数据。

优点:

可以按照基本数据类型直接读写
格式紧凑
不需要先转换成人类文本

缺点:

人类无法直接阅读
必须严格遵守数据格式协议
顺序或类型错误容易导致解析失败

5.3 writeUTF() 不是普通文本 UTF-8 文件写入

这是一个很重要的进阶点。

dos.writeUTF("你好");

不要简单理解成:

“使用普通 UTF-8 把你好写入 txt。”

DataOutputStream.writeUTF() 使用的是:

modified UTF-8

也就是:

修改版 UTF-8(Modified UTF-8)

而且它还会携带用于读取字符串长度的信息。

对应:

DataInputStream.readUTF()

进行恢复。

所以:

writeUTF()

和:

Files.writeString(
        path,
        text,
        StandardCharsets.UTF_8
)

不是同一种文本文件格式。

如果目标是普通 UTF-8 文本文件:

优先使用字符流 / Files

而不是:

DataOutputStream.writeUTF()

5.4 PrintStream 的异常处理特点

普通输出流很多方法:

write()
flush()

可能抛出:

IOException

PrintStream 的设计比较特殊:

打印方法本身不会把 IOException 直接向调用者抛出,而是记录内部错误状态。

可以通过:

ps.checkError()

判断是否发生错误。

PrintWriter 也采用类似设计。

这也是为什么:

System.out.println(...)

几乎从来不用:

try-catch IOException

5.5 自动刷新 autoFlush

PrintStreamPrintWriter 都支持某些带:

boolean autoFlush

参数的构造器。

例如:

PrintWriter writer =
        new PrintWriter(
                outputStream,
                true
        );

true 表示开启自动刷新。

不过二者触发自动刷新的细节并不完全一致。

学习阶段核心先掌握:

自动刷新不是“任何一次 print 都立即 flush”。

因此不能把:

autoFlush = true

理解成:

完全没有缓冲

六、实践应用

6.1 使用 PrintWriter 生成成绩单

import java.io.PrintWriter;

public class StudentReportDemo {

    public static void main(String[] args) throws Exception {

        try (PrintWriter writer =
                     new PrintWriter("students.txt")) {

            writer.println("学生成绩表");
            writer.println("------------------------");

            writer.printf("%-10s %-8s %-8s%n",
                    "姓名", "Java", "MySQL");

            writer.printf("%-10s %-8d %-8d%n",
                    "张三", 95, 90);

            writer.printf("%-10s %-8d %-8d%n",
                    "李四", 88, 93);
        }
    }
}

核心价值:

结构化格式
+
人类可读
+
方便导出

6.2 使用打印流写日志

try (
        PrintWriter writer =
                new PrintWriter(
                        new FileOutputStream(
                                "app.log",
                                true
                        )
                )
) {

    writer.println("用户登录:zhangsan");
}

当然,真实企业项目通常会使用:

Logback
Log4j2
SLF4J

等专业日志框架。

这里只是用来理解:

日志最终也是一种输出。


6.3 保存游戏状态

假设一个小游戏有:

int level = 8;
double hp = 72.5;
boolean bossDefeated = false;
String playerName = "星雨";

可以:

try (
        DataOutputStream dos =
                new DataOutputStream(
                        new FileOutputStream("save.dat")
                )
) {

    dos.writeUTF(playerName);
    dos.writeInt(level);
    dos.writeDouble(hp);
    dos.writeBoolean(bossDefeated);
}

读取:

try (
        DataInputStream dis =
                new DataInputStream(
                        new FileInputStream("save.dat")
                )
) {

    String playerName = dis.readUTF();
    int level = dis.readInt();
    double hp = dis.readDouble();
    boolean bossDefeated = dis.readBoolean();
}

关键不是记代码。

而是意识到:

写入协议
=
String → int → double → boolean

读取协议
=
String → int → double → boolean

两边必须完全一致。


七、练习与验收

7.1 知识问答

  • [ ] Java 中两种主要打印流分别是什么?
  • [ ] PrintStream 属于字节流还是字符流?
  • [ ] PrintWriter 属于字节流还是字符流?
  • [ ] 打印流最大的价值是什么?
  • [ ] print()println() 有什么区别?
  • [ ] printf() 有什么作用?
  • [ ] System.out 的真实类型是什么?
  • [ ] OutputStream.write(97)PrintStream.println(97) 有什么本质区别?
  • [ ] DataInputStreamDataOutputStream 分别有什么作用?
  • [ ] 为什么数据流读写顺序必须一致?
  • [ ] writeInt() 后能否使用 readDouble() 对应读取?为什么?
  • [ ] 数据流写出的文件为什么通常不适合使用记事本查看?
  • [ ] 打印流与数据流在数据表示方式上有什么根本区别?
  • [ ] writeUTF() 写出的字符串是否等价于普通 UTF-8 文本文件?

7.2 代码阅读

不运行:

try (PrintStream ps = new PrintStream("a.txt")) {

    ps.println(97);
    ps.print('A');
    ps.print(true);
}

回答:

  • [ ] 文件中 97 是字符 a 还是字符串形式的 97
  • [ ] A 后面会不会自动换行?
  • [ ] true 会以什么形式写入?

阅读:

dos.writeInt(100);
dos.writeDouble(95.5);

int a = dis.readInt();
double b = dis.readDouble();

回答:

  • [ ] 为什么这样读取是正确的?
  • [ ] 如果交换两个 read 方法会发生什么?

7.3 手写代码

关闭 AI 自动补全,独立完成:

  • [ ] 使用 PrintStream 写入姓名、年龄、成绩。
  • [ ] 使用 PrintWriter 生成一个学生成绩表。
  • [ ] 使用 printf() 输出格式化成绩。
  • [ ] 使用追加模式向日志文件写入三条日志。
  • [ ] 使用 DataOutputStream 写出 String + int + double
  • [ ] 使用 DataInputStream 按相同顺序读取。
  • [ ] 保存一个简单游戏角色状态,再完整恢复。

7.4 Debug

下面代码:

try (
        DataOutputStream dos =
                new DataOutputStream(
                        new FileOutputStream("data.dat")
                )
) {
    dos.writeInt(100);
    dos.writeDouble(66.6);
}

读取:

try (
        DataInputStream dis =
                new DataInputStream(
                        new FileInputStream("data.dat")
                )
) {
    System.out.println(dis.readDouble());
    System.out.println(dis.readInt());
}

要求:

  • [ ] 判断逻辑是否正确。
  • [ ] 解释根本原因。
  • [ ] 修复读取代码。
  • [ ] 画出字节边界错位过程。

7.5 综合训练

开发一个:

学生成绩二进制存档程序

每名学生包含:

姓名 String
年龄 int
Java成绩 double
是否及格 boolean

要求:

  1. 使用 DataOutputStream 写入 3 名学生。
  2. 使用 DataInputStream 完整读取。
  3. 明确设计文件协议。
  4. 先写出:
字段顺序
字段类型
读写对应 API

例如:

name     → writeUTF   → readUTF
age      → writeInt   → readInt
score    → writeDouble→ readDouble
passed   → writeBoolean→readBoolean

再编写代码。

7.6 本章验收

不查看资料,能够口述:

PrintStream
PrintWriter
DataOutputStream
DataInputStream

四个类分别解决什么问题。

能够准确解释:

println(97)

和:

writeInt(97)

为什么完全不是一回事。

最后能够独立完成:

PrintWriter → 生成人类可读报表

DataOutputStream → 保存类型化二进制数据
DataInputStream  → 按相同协议恢复数据

达到以上标准,本章核心内容即掌握。