如果你写过哪怕一行 Java 代码,你十有八九敲过System.out.println()。这行语句陪着无数人度过了初学阶段,但真被问到“System.out到底是什么”的时候,很多人会愣一下。答案其实很明确:它是java.io.PrintStream的一个实例,一个被 JVM 在启动阶段封装好的打印流对象。
PrintStream是我们日常接触最多的 Java I/O 类之一,却也是最容易被忽视的一个。大多数人的使用停留在“能输出就行”,至于它为什么吞异常、为什么print(97)和write(97)结果完全不同、为什么乱码问题总出现在它身上、autoFlush到底什么时候生效——这些细节,普通教程往往一笔带过。这篇文章我把PrintStream从构造方法到源码行为、从方法体系到实战场景,完完整整拆开讲一遍。无论你是刚学 I/O 的初学者,还是想搞清楚System.out内部机制的老开发,看完这篇应该都能有收获。
1. PrintStream 是谁:类关系、构造方法与源码初探
1.1 从 System.out 开始认识 PrintStream
先看继承关系。PrintStream的完整链路是:
java.io.OutputStream→java.io.FilterOutputStream→java.io.PrintStream
FilterOutputStream本身是个“包装流”的基类,它内部持有一个OutputStream out字段,所有写操作最终都委托给这个被包装的底层流。PrintStream在FilterOutputStream之上,又额外做了一层“打印能力”的加工:把 int、double、Object、char[] 等各种类型统统“翻译”成文本字节,然后用近乎“温和”的方式写到目标流中。
System.out之所以是PrintStream,是因为 JVM 启动时会把标准输出设备(一般是控制台、终端或 IDE 的 Console 面板)封装成一个输出流对象,再在外面包上PrintStream实例,最后赋给System.out。这也是为什么你直接打印一个对象时看到的是类的全限定名@哈希值这种字符串,而不是什么二进制数据——因为PrintStream默认就喜欢把一切转成字符串再输出。
这里有个关键点要记住:PrintStream是字节流,它继承自OutputStream体系,而不是Writer体系。这意味着它内部处理的最小单位是byte,而不是char。虽然它提供了很多“字符友好的打印方法”,但本质上所有字符串最终都要转成字节数组才能写出去。
1.2 PrintStream 的构造方法:从 OutputStream 到文件名
PrintStream的构造方法挺多,但核心思路只有两个方向:直接包装一个现成的OutputStream,或者传入文件名/File对象让它在内部帮你创建底层流。
| 构造方式 | 说明 | 适用场景 |
|---|---|---|
PrintStream(OutputStream out) | 最基础的构造,包装任意输出流 | 包装文件流、Socket 流、ByteArrayOutputStream 等 |
PrintStream(OutputStream out, boolean autoFlush) | 指定是否开启自动刷新 | 希望每次 println 后自动 flush 时用 |
PrintStream(OutputStream out, boolean autoFlush, String encoding) | 指定字符集名称,构造时会抛UnsupportedEncodingException | 需要统一编码写文件时,最常用 |
PrintStream(OutputStream out, boolean autoFlush, Charset charset) | JDK 10 引入,直接传Charset对象 | 现代写法,避免字符串编码名拼错 |
PrintStream(String fileName) | 直接指定文件名,内部创建FileOutputStream | 快速写文件,默认 UTF-8(JDK 18 以后),但会覆盖原文件 |
PrintStream(String fileName, String csn) | 文件名 + 字符集名 | 老版本写文件的常见姿势 |
PrintStream(File file) | 直接传File对象 | 适合配合已有的 File 对象 |
PrintStream(File file, String csn) | File + 字符集名 | 同样需要捕获异常 |
注意一个容易忽略的点:直接传String文件名或File对象的构造方法,底层默认用new FileOutputStream(name),也就是非追加模式,每次运行都会把文件内容清空重写。如果你需要追加写入,还是要自己先创建new FileOutputStream(file, true),再包成PrintStream。这一点在写日志文件时常被坑到,后面实操部分我会再强调。
另外FileNotFoundException和UnsupportedEncodingException是受检异常,构造PrintStream的时候编译器会要求你处理。很多人第一次写new PrintStream(new FileOutputStream("a.txt"))时都被这个 try-catch 烦过,但这是 Java 在提醒你:文件路径可能不存在,字符集名称可能写错,不能装作不知道。
1.3 源码视角:为什么 PrintStream 不抛 IOException
这是PrintStream和其他OutputStream子类最大的行为差异。普通流写数据失败时,比如磁盘满了、管道断了、Socket 关闭了,标准做法是抛IOException。但PrintStream不这么干。
print(String s)内部逻辑大致是:
public void print(String s) { if (s == null) { s = "null"; } write(s.getBytes()); }而write(byte[] buf, int off, int len)内部:
public void write(byte[] buf, int off, int len) { try { synchronized (this) { ensureOpen(); out.write(buf, off, len); } } catch (IOException x) { trouble = true; } }看到了吧,IOException被 catch 住之后,没有抛出去,只是把内部一个布尔字段trouble置为true。要检查是否发生过错误,只能调用checkError()方法:
public boolean checkError() { if (out != null) flush(); return trouble; }这种设计是刻意的。因为println这种方法的返回值是void,如果每次打印都强制要求处理IOException,那代码里到处都要写 try-catch,极其难看。代价就是:错误被沉默了。如果你写文件时磁盘满了,PrintStream不会让你感知到任何异常,你的程序可能“顺利”跑完,但数据一个字都没落盘。所以使用PrintStream写重要数据时,一定记得主动调用checkError()检查状态,或者干脆换用会正常抛异常的BufferedWriter、OutputStreamWriter。
2. print、println、write、printf:方法体系与经典考题
2.1 print 与 println 的整套重载
PrintStream的打印方法覆盖了所有常见类型,堪称“重载全家桶”:
| 方法 | 内部行为 |
|---|---|
print(boolean b) | 输出"true"或"false"字符串字节 |
print(char c) | 输出该字符的字节,注意是一个字符 |
print(int i) | 先把 i 转成十进制字符串,再输出 |
print(long l) | 同上,转字符串 |
print(float f) | String.valueOf(f)后输出 |
print(double d) | 同上 |
print(char[] s) | 直接把字符数组转字节输出,不走字符串拼接 |
print(String s) | null时输出"null",非 null 时getBytes()后输出 |
print(Object obj) | 调用String.valueOf(obj) |
println就是在对应print方法后面再加一个换行。本质上是print(xxx)+newLine(),而newLine()写的是系统属性line.separator指定的换行符。
这里有个非常经典、面试常考的区分点:print(97)和write(97)的结果完全不同。
PrintStream ps = new PrintStream(new FileOutputStream("diff.txt"), true, "UTF-8"); ps.write(65); // 文件里只有一个字节 0x41,用文本打开是字母 A ps.print(65); // 文件里是字符 '6' 和 '5' 的字节,也就是 0x36 0x35,用文本打开是 "65"原因在上面表格里已经写了:print(int)走的是String.valueOf(65)→"65"→ 转字节;而write(int)是原生字节输出,65就是0x41。一个是“人类可读文本”,一个是“原始二进制”,完全两码事。很多新手写二进制协议时,顺手用print写出数字,结果对方收到的是一串 ASCII 文本,整个协议直接崩掉,就是这个原因。
还有一个容易踩的坑是null字面量的重载选择。直接写ps.println(null)会编译报错,因为println(char[])和println(String)两个重载的“具体程度”相同,编译器无法判断你要调哪一个。但如果先声明String s = null; ps.println(s);,就能正常选中println(String),运行输出"null"。
2.2 write 方法族:PrintStream 里最容易搞混的底层操作
PrintStream的write方法族其实很朴素:
write(int b):写一个字节write(byte[] buf):写整个字节数组write(byte[] buf, int off, int len):写字节数组的一部分
需要特别说明的是,PrintStream没有公开的write(char[])方法。你在print(char[] chars)内部能看到它调了一个包私有/私有的write(char[], off, len),但那是内部实现,对外不可见。所以写出ps.write(charArray)是无法通过编译的。很多人把PrintStream默认当成“字符友好输出流”,下意识以为它有write(char[]),实际并没有。要写字符数组,老老实实用ps.print(charArray)或者换Writer体系。
另外注意write(byte[])系列在autoFlush = false时是不会自动 flush的。JVM 控制台打印看到数据立刻出现,是因为System.out本身开启了 autoFlush,而且每次 println 都有换行动作。如果你手动构造一个PrintStream包装FileOutputStream,调用ps.write(bytes)之后既不 flush 也不 close,数据就只躺在系统缓冲区里,文件里啥也没有。这个问题我见过太多次了。
2.3 printf/format:PrintStream 也能格式化输出
JDK 1.5 之后,PrintStream加入了printf和format方法,内部委托给java.util.Formatter实现。所以System.out.printf和String.format的占位符规则完全一致,比如:
System.out.printf("订单号:%s 金额:%.2f 状态:%s%n", "A1001", 99.5, "已支付");输出:
订单号:A1001 金额:99.50 状态:已支付注意两个细节。第一,printf和format完全等价,printf是个“昵称”,没有额外语义。第二,格式串里的%n是Formatter识别的平台无关换行符,它会根据当前操作系统替换成\n或\r\n,而\n只是普通字符。跨平台场景写%n更稳,尤其在 Windows 上写文件时,能避免换行符不统一的尴尬。
3. 关键机制拆解:autoFlush、字符集、异常屏蔽
3.1 autoFlush 到底什么时候触发
autoFlush是个容易被误解的参数。字面上叫“自动刷新”,但它的触发点远没有你想的那么广泛。PrintStream的自动刷新只发生在三种情况下:
println()方法被调用时(即写入换行)printf/format格式化输出完成时- 显式调用
flush()或close()时
而write(int)、write(byte[])、print()(不带换行)这些操作,在autoFlush = true时不会自动刷新。源码里关键在newLine():
private void newLine() { if (!autoFlush) { return; } ... flush(); }也就是说,autoFlush的真正含义更接近“每次输出一行后自动冲一次水”,而不是“我写任何字节都立刻刷出去”。拿马桶冲水做类比:autoFlush = true等于有人设定“每次小便完必须冲水”,但是丢一张纸进去(write(byte[]))不会触发冲水。
System.out和System.err都是autoFlush = true的PrintStream。所以你平时敲System.out.println时代码里的就地刷新是无感的。但自己手动创建PrintStream时,autoFlush默认是false,别想当然。
3.2 字符集:中文乱码的头号来源
PrintStream的字符集问题,比Writer体系复杂得多,根源在于它本身是字节流。当你调用print("中文")时,内部执行的是"中文".getBytes(),而getBytes()不传参时使用的字符集是JVM 默认字符集。
JVM 默认字符集在不同平台不一样:长期以来 Windows 上通常是 GBK 或 MS936,Linux 和 macOS 上通常是 UTF-8。JDK 18 之后(JEP 400)默认字符集统一为 UTF-8,但老版本的项目仍然是“跑在不同的机器上,行为各不相同”。
同样一段代码:
PrintStream ps = new PrintStream(new FileOutputStream("log.txt")); ps.println("中文日志");在 Linux 上写出的文件是 UTF-8 编码,在 Windows 上写出的是 GBK 编码。如果两个系统之间互相读取这个文件,或者你用 UTF-8 的编辑器去打开 Windows 上生成的日志,中文就是一团乱码。
所以但凡你用PrintStream写文件、写网络协议,只要内容可能包含非 ASCII 字符,就必须显式指定字符集,别依赖默认值:
PrintStream ps = new PrintStream( new FileOutputStream("log.txt"), true, "UTF-8" );从 JDK 10 开始还能直接传Charset对象,更安全:
PrintStream ps = new PrintStream( new FileOutputStream("log.txt"), true, StandardCharsets.UTF_8 );这里顺带提一嘴控制台的中文乱码:System.out.println不乱码的前提是 JVM 输出字节的编码和控制台展示用的编码一致。IDE 控制台通常默认 UTF-8,而 Windows 原生 CMD 窗口是 GBK,两边不一致就会出现“打印到 IDE 正常、CMD 里全乱”的经典场景。解决方法不是改PrintStream,而是统一控制台的代码页(比如chcp 65001切到 UTF-8),或者在启动参数里指定-Dfile.encoding=UTF-8让 JVM 输出一致。
3.3 异常都被“吞”了,怎么发现出错
前面讲过,PrintStream的所有写方法都会捕获IOException并设置trouble = true。养成习惯:凡是数据不能丢的场景,写完后要主动检查checkError()。
PrintStream ps = new PrintStream( new FileOutputStream("report.txt"), true, "UTF-8" ); ps.println("统计结果:100条"); ps.flush(); if (ps.checkError()) { // 到这里说明数据没有完整写成功,必须做补偿处理 throw new RuntimeException("report 写入失败"); }checkError()在返回前会调用一次flush(),确认真正的错误状态。它返回true就代表底层流发生过IOException。在这个机制下,“有没有可能数据写了一半就失败了但程序毫无感知”的答案是:有可能,而且PrintStream不会告诉你写到哪一步停的。所以业务数据落盘,我个人的建议是优先考虑BufferedWriter+OutputStreamWriter,或者Files.newBufferedWriter,它们至少会正常抛异常;PrintStream更适合日志、调试输出这类“丢了不致命”的场景。
4. 和 PrintWriter、OutputStreamWriter 怎么选
4.1 三者的血缘关系与设计差异
Java 里“文本输出”相关的类不少,初学者最常碰到的三个就是PrintStream、PrintWriter、OutputStreamWriter。它们仨的定位差异非常明显:
| 对比项 | PrintStream | PrintWriter | OutputStreamWriter |
|---|---|---|---|
| 所属体系 | 字节流(OutputStream) | 字符流(Writer) | 字节流 → 字符流的桥接器 |
| 写入单位 | byte,字符串需要转字节 | char,直接按字符处理 | char,直接按字符处理 |
| 字符集控制 | 构造时指定,JDK 10+ 支持 Charset | 构造时指定,Windows 老版本默认依赖平台 | 构造时指定,必须显式传 Charset 才能可靠 |
| 异常行为 | 吞 IOException,用 checkError() 检查 | 吞 IOException,用 checkError() 检查 | 正常抛 IOException |
| 自动刷新 | 支持,触发点是 println/printf/format | 支持,触发点是 println 等 | 不支持,需要手动 flush |
| 格式化方法 | printf/format | printf/format | 无 |
| 典型角色 | System.out,日志、调试输出 | Servlet 的 response.getWriter(),文本文件写入 | 需要精确控制字符集且希望抛异常的中间层 |
OutputStreamWriter是“做翻译”的桥梁:接收OutputStream,按指定字符集把char编码成byte写下去。正因为它忠实地把编码问题暴露给开发者,所以用它时字符集是显式确定的,不会像PrintStream一样悄悄依赖getBytes()的默认行为。
4.2 实际选型建议
选型没有绝对标准,但根据我实际项目的经验,可以给几条相对稳妥的推荐:
- 写纯文本文件、日志文件,并且要求异常透明:优先
Files.newBufferedWriter(path, charset)或OutputStreamWriter。真出问题会抛IOException,代码不会假装成功。 - 写文本但追求方便,且接受“异常被吞掉”:
PrintWriter。它在Writer体系内,字符集明确指定后比PrintStream自然得多,中文更不容易踩轮子。实际上很多老框架内部的日志输出就是PrintWriter包着BufferedWriter。 - 底层目标流本身是 OutputStream,需要文本和二进制混合写:选
PrintStream。比如你要向 Socket 先写一段 HTTP 行,再写二进制资源,用一个PrintStream包住 Socket 输出流是操作最顺畅的。 - Console / 系统级输出:直接用
System.out,它已经是PrintStream了,老实说我基本没自己再去包一层的需求。 - 网络协议中的文本响应:HTTP/1.1 响应文本行其实用
PrintWriter更合适,但很多人图省事直接new PrintStream(socket.getOutputStream(), true, "UTF-8"),问题也不大,只要记住关闭顺序的坑(下文实战部分会讲)。
5. 实操案例:把 PrintStream 用在文件日志与网络响应中
5.1 日志落盘工具类:一个可直接用的封装
我先写一个简单的文件日志工具。需求很朴素:线程安全、追加写入、指定 UTF-8、每次写入刷盘、检查错误状态。直接用PrintStream实现:
import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.PrintStream; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; public class FileLogger { private final PrintStream ps; public FileLogger(String file, boolean append, String charset) throws FileNotFoundException { // 追加模式通过 FileOutputStream 的构造参数控制 this.ps = new PrintStream(new FileOutputStream(file, append), true, charset); } public synchronized void log(String level, String message) { ps.printf("[%s] [%s] %s%n", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")), level, message); // 写入后主动检查错误,一旦失败立即暴露,避免数据丢得无声无息 if (ps.checkError()) { System.err.println("日志写入失败: " + message); } } public synchronized void close() { ps.close(); } public static void main(String[] args) throws Exception { FileLogger logger = new FileLogger("app.log", true, "UTF-8"); logger.log("INFO", "应用启动"); logger.log("ERROR", "数据库连接超时,重试次数=3"); logger.close(); System.out.println("日志写入完成"); } }几个关键点拆解:
new FileOutputStream(file, append)的第二个参数true实现了追加写入,这是PrintStream(String)构造做不到的。- 构造时指定
"UTF-8",彻底避免跨平台默认字符集不一致的问题。 autoFlush = true保证每次printf后面的%n触发 flush,日志不会滞留在缓冲区里。抢在程序崩溃前落盘的愿望,靠这一条能实现大半。checkError()在每行日志后调用,稍微有点性能开销,但对大多数低频日志系统完全可接受;如果追求极致性能,可以每批量写完后统一检查一次。- 方法加
synchronized是因为PrintStream虽然单次方法内部有锁,但“格式化 + 检查错误”是组合操作,自己不加锁无法保证多线程下日志行完整。
5.2 用 PrintStream 写一个最简 HTTP 响应
PrintStream在网络编程里最常见的用途之一,是直接包 Socket 输出流写 HTTP 响应。下面是一个单线程、处理一次请求的最简服务端:
import java.io.PrintStream; import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; public class MiniHttpServer { public static void main(String[] args) throws Exception { ServerSocket server = new ServerSocket(8080); System.out.println("listening on 8080 ..."); Socket socket = server.accept(); PrintStream out = new PrintStream(socket.getOutputStream(), true, "UTF-8"); String body = "<html><body><h1>Hello</h1></body></html>"; byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); out.println("HTTP/1.1 200 OK"); out.println("Content-Type: text/html; charset=UTF-8"); out.println("Content-Length: " + bodyBytes.length); out.println(); // 空行分隔响应头和响应体 out.write(bodyBytes, 0, bodyBytes.length); out.flush(); // 不要调用 out.close():close 会连带关闭 socket 输出流,从而关闭整个连接 socket.shutdownOutput(); socket.close(); server.close(); } }这段代码里有几个非常实战的坑:
第一个坑是Content-Length。如果body是中文,body.length()是字符数,不是字节数,HTTP 协议要求的是字节长度。所以上面代码先getBytes(StandardCharsets.UTF_8)再用bodyBytes.length,这是最稳妥的写法。
第二个坑是关闭顺序。PrintStream.close()会调用底层OutputStream.close(),进而关闭 Socket 输出流,最终会把整个 TCP 连接一起关掉。如果你只是想结束本次响应,后面还要复用连接(HTTP keep-alive 场景),就不能 close,而要调socket.shutdownOutput(),只关闭输出方向,保留连接等待下一轮。这个区别不搞清楚,写高并发服务时连接数会莫名其妙飙升。
第三个坑,write(bodyBytes)和print(body)的区别。响应体是文本,两者在这个例子里效果差不多,但既然我们已经手动算了字节长度,用write直接写字节数组,语义更严谨——响应体和“打印文本”不同,它是协议数据,不是给人看的调试输出。
6. 高频问题与排错经验速查
6.1 问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 文件里中文全部是“?”或乱码 | 构造 PrintStream 时没指定字符集,默认用了平台编码;读取端和写入端编码不一致 | 构造时显式"UTF-8",同时读取时也用 UTF-8 |
| 写完文件但内容为空 | 忘记flush()或close(),数据仍在缓冲区 | 用autoFlush = true,或写完后主动 flush |
| 程序“正常”结束但数据没写全 | PrintStream把IOException吞了,trouble标志为 true | 调用checkError()检查;重要数据换成会抛异常的 Writer |
| 第二次启动程序日志文件被覆盖 | 用了PrintStream(String fileName)这种非追加构造 | 改用new FileOutputStream(file, true)创建底层流 |
ps.println(null)编译报错 | println(char[])与println(String)重载歧义 | 先声明变量再传:String s = null; ps.println(s); |
print(65)写出的不是字符 A | print(int)会把数字转成字符串文本 | 想写原始字节用write(65) |
System.out.println不显示在控制台 | 有代码调用了System.setOut(...)把输出重定向了 | 查看代码里是否重定向过;保留原 PrintStream 引用以便恢复 |
| Socket 流写完响应后连接被莫名关闭 | 调用了PrintStream.close(),底层 Socket 输出流被连带关闭 | 用shutdownOutput()代替 close() |
| CMD 窗口中文乱码但 IDE 正常 | JVM 输出编码(UTF-8)与 CMD 代码页(GBK)不一致 | CMD 执行chcp 65001,或统一-Dfile.encoding=UTF-8 |
| 多线程同时 println 时偶尔内容错乱 | 单个 println 是 synchronized 的,但多行组合输出不被保护 | 外层自行加锁,或拼接成单行字符串再输出 |
6.2 排查乱码和丢数据的思路
乱码问题我踩了太多次,总结一套排查顺序,基本能覆盖九成场景。先看写入端用了什么字符集把字符转成字节:PrintStream不指定就是String.getBytes(),也就是 JVM 默认字符集;再看存储载体有没有做二次转码:比如中间经过数据库 JDBC 驱动、消息队列、Shell 重定向,都可能改变字节;最后看读取端用哪个字符集解码:文本编辑器、程序读文件、浏览器,各自的默认字符集可能都不一样。三段字符集只要有一段对不上,就有乱码风险。
丢数据问题的排查更快。先确认有没有显式或隐式 flush:写文件是不是只看PrintStream构造参数,忘了缓冲;再确认有没有人调用close():不 close 虽然进程结束时系统会回收,但中途崩溃必然丢;最后确认异常有没有被吞:checkError()是否为 true。这三步查完,大部分“数据神秘失踪”都能定位。
我自己的习惯是:凡是用PrintStream写“不可丢”的数据,无论日志还是业务文件,写完一批就flush()一次,然后立刻checkError()。听起来啰嗦,但这样的代码在出问题时的反馈速度,远比闷头写几千行后才发现半条没落盘的代价小得多。
6.3 个人经验小结
用PrintStream时间长了,最大的感悟是:别因为它“用起来顺手”就忽略它的底层脾气。它本质上是个字节流、吞异常、依赖默认字符集,这三条加起来足够制造各种表面上莫名其妙的 bug。如果你项目里的文本输出只是为了人看、丢了不致命,那PrintStream怎么方便怎么来,指定好字符集、记着一定 flush,基本就够了。如果数据是要给下游系统消费的、丢了要赔钱的,我会换掉它,BufferedWriter或者OutputStreamWriter能正常抛异常,出了问题至少不会装傻。
下次再看到那行熟悉的System.out.println(),你至少知道它背后是一个怎样的对象了。