IO 流
你的 Java 程序运行在内存中,但数据往往存储在硬盘文件里。当你需要读取一个配置文件、保存用户上传的图片、或者把日志写入磁盘时,就需要 IO(Input/Output)流来充当内存与外部世界之间的「桥梁」。
💡 IO 流解决的是**数据读写**问题。如果你还不了解如何在 Java 中表示文件路径、创建目录等操作,请先阅读「文件操作」。
本文你会学到 :
🗺️ IO 流类全景图——为什么有字节流和字符流两套体系,它们之间是什么关系
⚡ 节点流——字节节点流和字符节点流分别怎么用
🎨 装饰器模式如何让 IO 流具备了「即插即用」的扩展能力
🔧 包装流如何在节点流基础上叠加缓冲、编码转换、格式化输出、对象序列化等高级功能
🎲 RandomAccessFile——如何在文件的任意位置读写数据
🚀 NIO Channel + Buffer 模型与传统 IO 的区别
🖥️ 标准流、Scanner 和 Console——控制台交互的多种方式
🛡️ 如何用 try-with-resources 优雅地管理资源
🗂️ IO 流解决什么问题?
当你的程序需要和外部环境交换数据时(读文件、写网络、接收键盘输入),直接操作操作系统的文件描述符既繁琐又容易出错。Java 用「流」(Stream)这个抽象概念把数据的读写统一为:打开 → 读/写 → 关闭 ,不管数据来自文件、网络还是内存,操作方式都是一致的。
按方向分——输入流与输出流
方向是站在**内存(程序)**的视角来看的:
方向
含义
类比
输入流(Input)
数据从外部 → 内存
水龙头往杯子里倒水
输出流(Output)
数据从内存 → 外部
杯子往水池里倒水
按数据单位分——字节流与字符流
类型
数据单位
适用场景
顶级抽象类
字节流
8 位字节(byte)
所有文件(图片、视频、二进制)
InputStream / OutputStream
字符流
字符(char),按编码处理
文本文件(.txt、.java、.xml)
Reader / Writer
⚠️ 如果用字节流读文本文件,一个中文汉字占多个字节(UTF-8 下占 3 字节),读取不完整就会出现**乱码**。所以读写文本时优先选择字符流。
按功能分——节点流与包装流
这是理解 IO 流体系的**最关键分类**,也是本文的主线:
类型
特点
类比
节点流(Node Stream)
直接连接数据源或目标
水管直接接在水龙头上
包装流(Processing Stream)
包裹在节点流外面,增强功能
给水管加上过滤器、加压器
→ 这种「在已有功能上套一层新功能」的设计,正是装饰器模式的经典应用。后文会详细展开。
四大顶级抽象类
Java 的所有 IO 流都继承自这四个抽象类:
graph TD
IO["Java IO 流体系"] --> ByteIn["InputStream\n字节输入流"]
IO --> ByteOut["OutputStream\n字节输出流"]
IO --> CharIn["Reader\n字符输入流"]
IO --> CharOut["Writer\n字符输出流"]
classDef root fill:transparent,stroke:#539bf5,color:#adbac7,stroke-width:2px
classDef node fill:transparent,stroke:#768390,color:#adbac7,stroke-width:1px
class IO root
class ByteIn,ByteOut,CharIn,CharOut node
它们都实现了 Closeable 接口(因此可以用 try-with-resources 自动关闭),输出流还额外实现了 Flushable 接口(将缓冲区数据强制写出)。
🗺️ IO 流类全景图——为什么有这么多类?
初学 IO 时最头疼的问题是:为什么类这么多?FileInputStream、BufferedReader、InputStreamReader、PrintWriter…… 光名字就让人眼花缭乱。
这是因为 Java IO 库**经历了两次重大演进**:
Java 1.0 :只有字节流(InputStream / OutputStream)。所有数据都按字节处理,读取文本需要自己处理编码转换
Java 1.1 :为了更好地支持 Unicode 国际化,新增了字符流(Reader / Writer)。每个字节流类几乎都有一个对应的字符流版本
两套体系再加上「节点流 + 包装流」的装饰器设计,类的数量自然就翻倍了
→ 理解了这个演进脉络,你只需要掌握字节流体系,字符流就能举一反三。
类
功能
分类
ByteArrayInputStream
从内存字节数组读取
节点流
FileInputStream
从文件读取
节点流
PipedInputStream
从管道读取(线程间通信)
节点流
SequenceInputStream
将多个 InputStream 串联为一个流
节点流
FilterInputStream
所有过滤型包装流的基类
包装流(基类)
├─ BufferedInputStream
添加缓冲功能,减少磁盘 IO 次数
包装流
├─ DataInputStream
读取基本数据类型(int、double 等)
包装流
└─ PushbackInputStream
支持「退回」已读字节
包装流
ObjectInputStream
反序列化——将字节流还原为 Java 对象
包装流
字节流 OutputStream 家族
类
功能
分类
ByteArrayOutputStream
写入内存字节数组
节点流
FileOutputStream
写入文件
节点流
PipedOutputStream
写入管道(线程间通信)
节点流
FilterOutputStream
所有过滤型包装流的基类
包装流(基类)
├─ BufferedOutputStream
添加缓冲功能
包装流
├─ DataOutputStream
写入基本数据类型
包装流
└─ PrintStream
格式化输出(System.out 就是它)
包装流
ObjectOutputStream
序列化——将 Java 对象转为字节流
包装流
字节流 ↔ 字符流对应关系
Java 1.1 引入字符流时,几乎为每个字节流类都提供了一个对应的字符版本。下表展示了核心的对应关系:
字节流(Java 1.0)
字符流(Java 1.1)
说明
InputStream
Reader
输入流抽象基类
OutputStream
Writer
输出流抽象基类
FileInputStream
FileReader
文件读取
FileOutputStream
FileWriter
文件写入
ByteArrayInputStream
CharArrayReader
内存数组读取
ByteArrayOutputStream
CharArrayWriter
内存数组写入
—(无对应)
StringReader
从字符串读取
—(无对应)
StringWriter
写入到字符串
PipedInputStream
PipedReader
管道输入
PipedOutputStream
PipedWriter
管道输出
BufferedInputStream
BufferedReader
缓冲读取
BufferedOutputStream
BufferedWriter
缓冲写入
PrintStream
PrintWriter
格式化打印输出
—(无对应)
InputStreamReader
字节→字符的桥梁(指定编码)
—(无对应)
OutputStreamWriter
字符→字节的桥梁(指定编码)
💡 StringReader / StringWriter 和 InputStreamReader / OutputStreamWriter 是字符流独有的,在字节流中没有对应物。InputStreamReader 和 OutputStreamWriter 是连接两套体系的桥梁——后文「转换流」会详细介绍。
⚡ 节点流——如何直接读写数据源?
节点流是 IO 体系中最底层的流,它们**直接连接数据源**(文件、内存数组、管道等)。没有节点流,包装流就无从「包装」。
FileInputStream 是最基础的文件读取流,每次从文件中读取一个或多个字节。
一次读取一个字节
FileInputStream:逐字节读取 /**
* FileInputStream:一次读取一个字节
*/
@Test
void testFileInputStreamSingleByte () throws IOException {
// 从类路径获取测试文件
String path = getClass (). getClassLoader ()
. getResource ( "test-read.txt" ). getPath ();
try ( FileInputStream fis = new FileInputStream ( path )) {
int data ;
StringBuilder sb = new StringBuilder ();
// read() 返回 int(0~255),-1 表示读完
while (( data = fis . read ()) != - 1 ) {
sb . append (( char ) data );
}
System . out . println ( "逐字节读取结果: " + sb );
assertTrue ( sb . toString (). contains ( "Hello" ), "应包含 Hello" );
// ⚠️ 中文可能出现乱码,因为字节流不处理编码
}
}
read() 返回 int 而非 byte
read() 返回 int 类型(0~255),用 -1 表示文件读完。如果返回 byte,就无法区分「数据字节 -1」和「文件结束标志」了。
一次读取一个字节数组(推荐)
FileInputStream:按数组读取(推荐) /**
* FileInputStream:一次读取一个字节数组(推荐方式)
*/
@Test
void testFileInputStreamByteArray () throws IOException {
String path = getClass (). getClassLoader ()
. getResource ( "test-read.txt" ). getPath ();
try ( FileInputStream fis = new FileInputStream ( path )) {
byte [] buffer = new byte [ 1024 ] ; // 1KB 缓冲区
int bytesRead ;
StringBuilder sb = new StringBuilder ();
while (( bytesRead = fis . read ( buffer )) != - 1 ) {
// 注意用 bytesRead 控制实际读取长度
sb . append ( new String ( buffer , 0 , bytesRead ));
}
System . out . println ( "数组读取结果: " + sb );
assertTrue ( sb . toString (). contains ( "Hello" ), "应包含 Hello" );
}
}
FileOutputStream——按字节写入文件
FileOutputStream 用于将字节数据写入文件。构造方法的第二个参数 append 决定是覆盖还是追加:
FileOutputStream:覆盖写入与追加写入 /**
* FileOutputStream:覆盖写入和追加写入
*/
@Test
void testFileOutputStreamWrite ( @TempDir Path tempDir ) throws IOException {
File file = new File ( tempDir + "/output.txt" );
// 覆盖写入(默认模式)
try ( FileOutputStream fos = new FileOutputStream ( file )) {
fos . write ( "Hello, IO!" . getBytes ());
}
// 追加写入(第二个参数为 true)
try ( FileOutputStream fos = new FileOutputStream ( file , true )) {
fos . write ( "\n追加的内容" . getBytes ());
}
// 验证文件内容
try ( FileInputStream fis = new FileInputStream ( file )) {
String content = new String ( fis . readAllBytes ());
assertTrue ( content . contains ( "Hello, IO!" ), "应包含原始内容" );
assertTrue ( content . contains ( "追加的内容" ), "应包含追加内容" );
System . out . println ( "文件内容:\n" + content );
}
}
实战:用字节流拷贝文件
节点流最典型的应用——文件拷贝 。这种方式对任何类型的文件都有效(图片、视频、压缩包等):
字节流实现文件拷贝 /**
* 实战:用字节流拷贝文件
*/
@Test
void testFileCopy ( @TempDir Path tempDir ) throws IOException {
// 准备源文件
File src = new File ( tempDir + "/source.txt" );
try ( FileOutputStream fos = new FileOutputStream ( src )) {
fos . write ( "这是要拷贝的内容,包含中文和 English" . getBytes ());
}
// 执行拷贝
File dest = new File ( tempDir + "/copy.txt" );
try ( FileInputStream fis = new FileInputStream ( src );
FileOutputStream fos = new FileOutputStream ( dest )) {
byte [] buffer = new byte [ 1024 ] ;
int bytesRead ;
while (( bytesRead = fis . read ( buffer )) != - 1 ) {
fos . write ( buffer , 0 , bytesRead );
}
}
// 验证拷贝结果
assertTrue ( dest . exists (), "目标文件应存在" );
assertEquals ( src . length (), dest . length (), "文件大小应一致" );
System . out . println ( "拷贝成功,文件大小: " + dest . length () + " 字节" );
}
当数据源不是文件而是**内存中的字节数组**时,就用字节数组流。它不涉及磁盘操作,常用于单元测试、数据转换等场景。
ByteArray 流基本使用 /**
* ByteArrayInputStream / ByteArrayOutputStream:内存中的流
*/
@Test
void testByteArrayStream () throws IOException {
// ByteArrayOutputStream:写入内存字节数组
byte [] result ;
try ( ByteArrayOutputStream baos = new ByteArrayOutputStream ()) {
baos . write ( "Hello " . getBytes ());
baos . write ( "World" . getBytes ());
result = baos . toByteArray ();
}
assertEquals ( "Hello World" , new String ( result ));
// ByteArrayInputStream:从字节数组读取
try ( ByteArrayInputStream bais = new ByteArrayInputStream ( result )) {
int data ;
StringBuilder sb = new StringBuilder ();
while (( data = bais . read ()) != - 1 ) {
sb . append (( char ) data );
}
assertEquals ( "Hello World" , sb . toString ());
}
}
💡 ByteArrayOutputStream 配合 ObjectOutputStream 可以实现**对象的深克隆**——先序列化到内存字节数组,再反序列化回来,得到一个完全独立的副本。
管道流用于**两个线程之间**的数据传递:一个线程通过 PipedOutputStream 写入,另一个线程通过 PipedInputStream 读取。
管道流线程间通信 /**
* PipedInputStream / PipedOutputStream:线程间通信
*/
@Test
void testPipedStream () throws IOException , InterruptedException {
PipedInputStream pis = new PipedInputStream ();
PipedOutputStream pos = new PipedOutputStream ();
pis . connect ( pos ); // 建立管道连接
String message = "Hello from another thread!" ;
// 写入线程
Thread writer = new Thread (() -> {
try ( pos ) {
pos . write ( message . getBytes ());
} catch ( IOException e ) {
throw new RuntimeException ( e );
}
});
writer . start ();
// 读取线程(当前线程)
StringBuilder sb = new StringBuilder ();
try ( pis ) {
int data ;
while (( data = pis . read ()) != - 1 ) {
sb . append (( char ) data );
}
}
writer . join (); // 等待写入线程结束
assertEquals ( message , sb . toString ());
System . out . println ( "管道流接收到: " + sb );
}
⚠️ 管道流必须在**不同线程**中使用,否则会死锁。实际开发中更常用 BlockingQueue 等并发工具替代。
有时候你需要将多个文件或数据源按顺序拼接起来处理,就像把几段水管接成一根长管。SequenceInputStream 正是干这个的——它将多个 InputStream 串联成一个流,读完第一个自动切到第二个:
SequenceInputStream 串联多个流 /**
* SequenceInputStream:将多个流串联成一个流
*/
@Test
void testSequenceInputStream () throws IOException {
InputStream s1 = new ByteArrayInputStream ( "Hello " . getBytes ());
InputStream s2 = new ByteArrayInputStream ( "World" . getBytes ());
InputStream s3 = new ByteArrayInputStream ( "!" . getBytes ());
// 方式一:串联两个流
try ( SequenceInputStream sis = new SequenceInputStream ( s1 , s2 )) {
byte [] result = sis . readAllBytes ();
assertEquals ( "Hello World" , new String ( result ));
}
// 方式二:串联多个流(通过 Enumeration)
java . util . Vector < InputStream > streams = new java . util . Vector <> ();
streams . add ( new ByteArrayInputStream ( "A" . getBytes ()));
streams . add ( new ByteArrayInputStream ( "B" . getBytes ()));
streams . add ( new ByteArrayInputStream ( "C" . getBytes ()));
try ( SequenceInputStream sis = new SequenceInputStream ( streams . elements ())) {
assertEquals ( "ABC" , new String ( sis . readAllBytes ()));
}
}
字符节点流——字节节点流的字符版本
前面介绍的都是字节节点流。对于文本数据,Java 提供了对应的字符节点流版本,它们直接以 char 为单位读写,不会出现中文乱码问题。
CharArrayReader / CharArrayWriter——内存中的字符流
CharArrayReader / CharArrayWriter 是 ByteArrayInputStream / ByteArrayOutputStream 的字符版本,数据源是内存中的 char[] 数组:
CharArrayReader / CharArrayWriter 基本使用 /**
* CharArrayReader / CharArrayWriter:内存中的字符流
*/
@Test
void testCharArrayReaderWriter () throws IOException {
// CharArrayWriter:写入内存字符数组
char [] result ;
try ( CharArrayWriter caw = new CharArrayWriter ()) {
caw . write ( "你好," );
caw . write ( "世界!" );
result = caw . toCharArray ();
}
assertEquals ( "你好,世界!" , new String ( result ));
// CharArrayReader:从字符数组读取
try ( CharArrayReader car = new CharArrayReader ( result )) {
StringBuilder sb = new StringBuilder ();
int ch ;
while (( ch = car . read ()) != - 1 ) {
sb . append (( char ) ch );
}
assertEquals ( "你好,世界!" , sb . toString ());
}
}
StringReader / StringWriter——以字符串为数据源
StringReader 从一个 String 读取字符,StringWriter 将字符写入内部的 StringBuffer。它们在**单元测试**中特别有用——你不需要创建临时文件,直接用字符串模拟输入:
StringReader / StringWriter 基本使用 /**
* StringReader / StringWriter:以 String 为数据源的字符流
*/
@Test
void testStringReaderWriter () throws IOException {
// StringReader:从字符串读取
String source = "Java IO 流是个大家族\n字节流和字符流各有用武之地" ;
try ( StringReader sr = new StringReader ( source );
BufferedReader br = new BufferedReader ( sr )) {
// 包装成 BufferedReader 后可以按行读取
assertEquals ( "Java IO 流是个大家族" , br . readLine ());
assertEquals ( "字节流和字符流各有用武之地" , br . readLine ());
assertNull ( br . readLine ()); // 没有更多内容
}
// StringWriter:写入后得到字符串
try ( StringWriter sw = new StringWriter ()) {
sw . write ( "第一部分" );
sw . write ( " + " );
sw . write ( "第二部分" );
assertEquals ( "第一部分 + 第二部分" , sw . toString ());
}
}
💡 StringReader 经常被包装成 BufferedReader 来获得 readLine() 的按行读取能力。这再次体现了装饰器模式的威力——不管底层数据源是文件还是字符串,包装流的用法完全一致。
🎨 装饰器模式——包装流的设计思想
在学习包装流之前,先来理解它背后的设计模式。否则你可能会疑惑:为什么要把一个流「套」在另一个流外面,而不是直接继承一个功能更强的流?
如果只用继承会怎样?
假设我们需要给 FileInputStream 添加缓冲功能和加密功能:
FileInputStream
├── BufferedFileInputStream (加缓冲)
├── EncryptedFileInputStream (加加密)
├── BufferedEncryptedFileInputStream (缓冲 + 加密)
└── ...
每多一种功能组合,就要新建一个子类。如果再加上其他节点流(ByteArrayInputStream、PipedInputStream…),子类数量就会**爆炸式增长**——这就是所谓的「类爆炸」问题。
装饰器模式如何解决?
装饰器模式的核心思想:不通过继承,而是通过「包裹」来扩展功能 。
graph LR
A["节点流\nFileInputStream"] --> B["装饰器 1\nBufferedInputStream"]
B --> C["装饰器 2\nDataInputStream"]
classDef base fill:transparent,stroke:#539bf5,color:#adbac7,stroke-width:2px
classDef deco fill:transparent,stroke:#e3b341,color:#adbac7,stroke-width:1px
class A base
class B,C deco
就像套娃一样——最里面是节点流(数据来源),外面一层一层套上包装流(增强功能),想要什么功能就套什么,自由组合:
// 自由组合:文件流 → 缓冲 → 按行读取
BufferedReader br = new BufferedReader ( // 第二层:缓冲 + 按行读取
new InputStreamReader ( // 第一层:字节转字符(指定编码)
new FileInputStream ( "data.txt" ), // 最底层:节点流
StandardCharsets . UTF_8
)
);
IO 流中装饰器模式的体现
在 Java IO 源码中,所有包装流的构造方法都接收一个**同类型的父类流对象**——这正是装饰器的标志:
// BufferedInputStream 的构造方法——接收一个 InputStream
public BufferedInputStream ( InputStream in ) { ... }
// InputStreamReader 的构造方法——接收一个 InputStream
public InputStreamReader ( InputStream in , Charset cs ) { ... }
// DataInputStream 的构造方法——接收一个 InputStream
public DataInputStream ( InputStream in ) { ... }
这意味着**任何** InputStream 的子类(不管是 FileInputStream、ByteArrayInputStream 还是另一个包装流)都能被传入,实现灵活组合。
关闭包装流会自动关闭底层流
关闭最外层的包装流时,它会沿着链条依次关闭内部的流。所以你只需要关闭最外层即可,不必手动逐层关闭。
🔧 包装流——如何给节点流添加超能力?
理解了装饰器模式后,下面逐一介绍 Java IO 中最常用的几类包装流。
转换流——如何解决中文乱码?
当你用 FileInputStream(字节流)读取中文文本时,经常遇到乱码。这是因为字节流不关心编码,而中文在不同编码(UTF-8、GBK)下占的字节数不同。
转换流的作用就是在**字节流和字符流之间架桥**,同时指定编码:
graph LR
A["字节流\nInputStream"] -->|"指定编码\nUTF-8"| B["InputStreamReader\n字节→字符"]
B --> C["程序得到 char"]
D["程序写出 char"] --> E["OutputStreamWriter\n字符→字节"]
E -->|"指定编码\nUTF-8"| F["字节流\nOutputStream"]
classDef byte fill:transparent,stroke:#539bf5,color:#adbac7,stroke-width:2px
classDef convert fill:transparent,stroke:#e3b341,color:#adbac7,stroke-width:1px
classDef text fill:transparent,stroke:#57ab5a,color:#adbac7,stroke-width:1px
class A,F byte
class B,E convert
class C,D text
InputStreamReader 指定编码读取 /**
* 转换流:InputStreamReader 指定编码读取
*/
@Test
void testInputStreamReader ( @TempDir Path tempDir ) throws IOException {
// 先用 UTF-8 编码写入文件
File file = new File ( tempDir + "/utf8.txt" );
try ( OutputStreamWriter osw = new OutputStreamWriter (
new FileOutputStream ( file ), StandardCharsets . UTF_8 )) {
osw . write ( "你好,世界!Hello World!" );
}
// 用 InputStreamReader 指定 UTF-8 解码读取
try ( InputStreamReader isr = new InputStreamReader (
new FileInputStream ( file ), StandardCharsets . UTF_8 )) {
char [] buffer = new char [ 1024 ] ;
int charsRead = isr . read ( buffer );
String content = new String ( buffer , 0 , charsRead );
System . out . println ( "转换流读取: " + content );
assertTrue ( content . contains ( "你好" ), "应正确解码中文" );
}
}
OutputStreamWriter——写入时指定编码字符集
OutputStreamWriter 在上面的示例中已经展示——它在写入时指定编码字符集,将字符转为指定编码的字节写入底层 OutputStream。
FileReader / FileWriter——转换流的简化版
FileReader 和 FileWriter 本质上就是 InputStreamReader 和 OutputStreamWriter 的快捷写法,使用平台默认编码:
FileReader / FileWriter 基本使用 /**
* FileReader / FileWriter:转换流的简化版(使用平台默认编码)
*/
@Test
void testFileReaderWriter ( @TempDir Path tempDir ) throws IOException {
File file = new File ( tempDir + "/text.txt" );
// FileWriter 写入文本
try ( FileWriter fw = new FileWriter ( file )) {
fw . write ( "用 FileWriter 写入的文本\n" );
fw . write ( "第二行内容" );
}
// FileReader 读取文本
try ( FileReader fr = new FileReader ( file )) {
char [] buffer = new char [ 1024 ] ;
int charsRead = fr . read ( buffer );
String content = new String ( buffer , 0 , charsRead );
System . out . println ( "FileReader 读取:\n" + content );
assertTrue ( content . contains ( "FileWriter" ), "应包含写入内容" );
}
}
FileReader / FileWriter 的局限
它们使用平台默认编码,无法手动指定字符集。如果文件编码与平台不一致(比如在 Windows GBK 环境读 UTF-8 文件),仍然会乱码。此时必须用 InputStreamReader / OutputStreamWriter 显式指定编码。
Java 11 起,FileReader 和 FileWriter 新增了接收 Charset 参数的构造方法,可以直接指定编码。
缓冲流——如何提升读写性能?
节点流每次 read() / write() 都会触发一次系统调用(磁盘 IO),频率太高性能很差。缓冲流在内部维护一个**缓冲区**(默认 8KB),攒够一批数据后再一次性读写,大幅减少磁盘交互次数。
缓冲流类
包装对象
特殊能力
BufferedInputStream
InputStream
缓冲读取
BufferedOutputStream
OutputStream
缓冲写入
BufferedReader
Reader
缓冲读取 + readLine() 按行读取
BufferedWriter
Writer
缓冲写入 + newLine() 跨平台换行
缓冲流文件拷贝 /**
* 缓冲流:提升读写性能
*/
@Test
void testBufferedStreamCopy ( @TempDir Path tempDir ) throws IOException {
// 准备源文件
File src = new File ( tempDir + "/source.dat" );
try ( FileOutputStream fos = new FileOutputStream ( src )) {
byte [] data = new byte [ 10000 ] ; // 10KB 测试数据
for ( int i = 0 ; i < data . length ; i ++ ) {
data [ i ] = ( byte ) ( i % 256 );
}
fos . write ( data );
}
// 用缓冲流拷贝(性能远优于裸 FileInputStream)
File dest = new File ( tempDir + "/copy.dat" );
try ( BufferedInputStream bis = new BufferedInputStream (
new FileInputStream ( src ));
BufferedOutputStream bos = new BufferedOutputStream (
new FileOutputStream ( dest ))) {
byte [] buffer = new byte [ 1024 ] ;
int bytesRead ;
while (( bytesRead = bis . read ( buffer )) != - 1 ) {
bos . write ( buffer , 0 , bytesRead );
}
}
assertEquals ( src . length (), dest . length (), "拷贝后文件大小应一致" );
System . out . println ( "缓冲流拷贝完成,大小: " + dest . length () + " 字节" );
}
BufferedReader / BufferedWriter
BufferedReader 最常用的方法是 readLine()——按行读取文本,返回 null 表示读完:
BufferedReader 按行读取 /**
* BufferedReader:readLine() 按行读取
*/
@Test
void testBufferedReaderReadLine () throws IOException {
String path = getClass (). getClassLoader ()
. getResource ( "lines.txt" ). getPath ();
try ( BufferedReader br = new BufferedReader ( new FileReader ( path ))) {
String line ;
int lineNumber = 0 ;
while (( line = br . readLine ()) != null ) {
lineNumber ++ ;
System . out . printf ( "第 %d 行: %s%n" , lineNumber , line );
}
assertTrue ( lineNumber > 0 , "应至少读到一行" );
}
}
BufferedReader 还支持 mark() 和 reset()——在流中做标记,之后可以回退到标记位置重新读取:
mark / reset 回退读取 /**
* BufferedReader:mark() 和 reset() 实现回退读取
*/
@Test
void testBufferedReaderMarkReset () throws IOException {
String path = getClass (). getClassLoader ()
. getResource ( "lines.txt" ). getPath ();
try ( BufferedReader br = new BufferedReader ( new FileReader ( path ))) {
br . mark ( 1024 ); // 标记当前位置
String firstRead = br . readLine ();
System . out . println ( "第一次读: " + firstRead );
br . reset (); // 回退到 mark 位置
String secondRead = br . readLine ();
System . out . println ( "回退后再读: " + secondRead );
// 两次读到的应该是同一行
assertEquals ( firstRead , secondRead , "reset 后应重新读取同一行" );
}
}
数据流——如何读写基本数据类型?
普通的字节流只能读写 byte[],如果你想把 int、double、boolean 等基本数据类型直接写入文件,就需要 DataInputStream / DataOutputStream。
⚠️ 读取的顺序必须与写入的顺序完全一致 ,否则数据会错乱。
数据流读写基本类型 /**
* 数据流:读写基本数据类型
*/
@Test
void testDataStream ( @TempDir Path tempDir ) throws IOException {
File file = new File ( tempDir + "/data.bin" );
// 写入基本数据类型
try ( DataOutputStream dos = new DataOutputStream (
new FileOutputStream ( file ))) {
dos . writeInt ( 42 );
dos . writeDouble ( 3.14 );
dos . writeBoolean ( true );
dos . writeUTF ( "Hello 数据流" );
}
// 读取——顺序必须与写入一致!
try ( DataInputStream dis = new DataInputStream (
new FileInputStream ( file ))) {
int i = dis . readInt ();
double d = dis . readDouble ();
boolean b = dis . readBoolean ();
String s = dis . readUTF ();
assertEquals ( 42 , i );
assertEquals ( 3.14 , d , 0.001 );
assertTrue ( b );
assertEquals ( "Hello 数据流" , s );
System . out . printf ( "读取结果: int=%d, double=%.2f, boolean=%s, string=%s%n" ,
i , d , b , s );
}
}
对象流——如何保存 Java 对象?
数据流只能处理基本类型,如果你需要把整个 Java 对象保存到文件(或通过网络传输),就需要对象流——ObjectOutputStream(序列化)和 ObjectInputStream(反序列化)。
序列化与反序列化
序列化(Serialization):将 Java 对象 → 字节序列,写入文件或网络
反序列化(Deserialization):将字节序列 → Java 对象,从文件或网络还原
对象必须实现 Serializable 接口
可序列化的实体类 package com.luguosong.io ;
import java.io.Serial ;
import java.io.Serializable ;
/**
* 用于演示对象序列化的实体类
*/
public class User implements Serializable {
@Serial
private static final long serialVersionUID = 1L ;
private String name ;
private int age ;
// transient 字段不参与序列化
private transient String password ;
public User ( String name , int age , String password ) {
this . name = name ;
this . age = age ;
this . password = password ;
}
public String getName () {
return name ;
}
public int getAge () {
return age ;
}
public String getPassword () {
return password ;
}
@Override
public String toString () {
return "User{name='%s', age=%d, password='%s'}" . formatted ( name , age , password );
}
}
对象的序列化与反序列化 /**
* 对象流:序列化与反序列化 Java 对象
*/
@Test
void testObjectStream ( @TempDir Path tempDir ) throws IOException , ClassNotFoundException {
File file = new File ( tempDir + "/user.dat" );
// 序列化:将对象写入文件
User user = new User ( "张三" , 25 , "secret123" );
try ( ObjectOutputStream oos = new ObjectOutputStream (
new FileOutputStream ( file ))) {
oos . writeObject ( user );
}
// 反序列化:从文件还原对象
try ( ObjectInputStream ois = new ObjectInputStream (
new FileInputStream ( file ))) {
User restored = ( User ) ois . readObject ();
assertEquals ( "张三" , restored . getName ());
assertEquals ( 25 , restored . getAge ());
// transient 字段不参与序列化,还原后为 null
assertNull ( restored . getPassword (), "transient 字段应为 null" );
System . out . println ( "反序列化: " + restored );
}
}
serialVersionUID——版本兼容的关键
当你序列化一个对象写入文件后,如果后续修改了这个类(比如添加了新字段),反序列化时可能抛出 InvalidClassException。
这是因为编译器会根据类的结构自动生成 serialVersionUID,类一改,版本号就变了。解决方法是**手动指定版本号**:
public class User implements Serializable {
@Serial // Java 14+ 注解,帮助编译器检查序列化相关声明
private static final long serialVersionUID = 1L ;
// ...
}
手动指定后,即使类结构发生变化,只要 serialVersionUID 不变,反序列化就不会报错(新增的字段取默认值,删除的字段被忽略)。
transient 关键字
被 transient 修饰的字段**不会参与序列化**。适用于敏感信息(密码)或不需要持久化的临时数据。
打印流——System.out.println() 的真面目
你每天都在用的 System.out.println() 里的 out 其实就是一个 PrintStream 对象。打印流是一种特殊的输出包装流,提供了方便的 print() / println() 方法。
特性
PrintStream(字节)
PrintWriter(字符)
支持输出各种数据类型
✅
✅
自动换行(println)
✅
✅
自动编码
✅
✅
自动刷新
需构造时传入 autoFlush=true
❌(需手动 flush() 或构造时开启)
构造参数
OutputStream
OutputStream 或 Writer
PrintStream 输出重定向 /**
* 打印流:PrintStream 输出重定向
*/
@Test
void testPrintStream ( @TempDir Path tempDir ) throws IOException {
File file = new File ( tempDir + "/log.txt" );
// 保存原始 System.out
PrintStream originalOut = System . out ;
try ( PrintStream ps = new PrintStream ( new FileOutputStream ( file ))) {
// 将标准输出重定向到文件
System . setOut ( ps );
System . out . println ( "这句话写入文件而非控制台" );
System . out . println ( 42 );
System . out . println ( 3.14 );
} finally {
// 恢复原始 System.out
System . setOut ( originalOut );
}
// 验证文件内容
try ( FileInputStream fis = new FileInputStream ( file )) {
String content = new String ( fis . readAllBytes ());
assertTrue ( content . contains ( "这句话写入文件" ), "日志应写入文件" );
}
System . out . println ( "打印流重定向测试通过" );
}
除了 print() / println(),打印流还提供了 format() 方法(printf() 是它的别名),通过格式说明符控制输出格式:
说明符
含义
示例
输出
%d
十进制整数
format("%d", 42)
42
%f
浮点数
format("%.2f", 3.14159)
3.14
%s
字符串
format("%s", "hello")
hello
%n
跨平台换行
format("line1%nline2")
line1\nline2
%x
十六进制
format("%x", 255)
ff
%o
八进制
format("%o", 255)
377
%b
布尔值
format("%b", true)
true
常用格式控制 :
格式
含义
示例
输出
%10d
右对齐,宽度 10
format("\|%10d\|", 42)
\| 42\|
%-10s
左对齐,宽度 10
format("\|%-10s\|", "hi")
\|hi \|
%05d
补零,宽度 5
format("%05d", 42)
00042
%,d
千位分隔符
format(Locale.US, "%,d", 1234567)
1,234,567
格式化输出示例 /**
* 格式化输出:format() / printf() 和 String.format()
*/
@Test
void testFormatOutput () {
// %d 整数,%f 浮点数,%s 字符串,%n 换行
String result = String . format ( "姓名: %s, 年龄: %d, 成绩: %.1f" , "张三" , 25 , 92.567 );
assertEquals ( "姓名: 张三, 年龄: 25, 成绩: 92.6" , result );
// 宽度与对齐:%10d 右对齐补空格,%-10s 左对齐
String aligned = String . format ( "|%10d|%-10s|" , 42 , "hello" );
assertEquals ( "| 42|hello |" , aligned );
// 补零:%05d
assertEquals ( "00042" , String . format ( "%05d" , 42 ));
// 千位分隔符(需要 Locale)
String grouped = String . format ( Locale . US , "%,d" , 1234567 );
assertEquals ( "1,234,567" , grouped );
// 十六进制和八进制
assertEquals ( "ff" , String . format ( "%x" , 255 ));
assertEquals ( "377" , String . format ( "%o" , 255 ));
}
💡 String.format() 和 System.out.format() 使用完全相同的格式语法,区别是前者返回格式化后的字符串,后者直接输出到控制台。
压缩流——如何压缩和解压文件?
Java 内置了对 GZIP 和 ZIP 格式的支持,通过包装流实现:
GZIP 压缩与解压
GZIP 压缩与解压 /**
* 压缩流:GZIP 压缩与解压
*/
@Test
void testGzipStream ( @TempDir Path tempDir ) throws IOException {
File gzFile = new File ( tempDir + "/data.gz" );
String original = "这是一段需要压缩的文本数据。" . repeat ( 100 );
// GZIP 压缩
try ( GZIPOutputStream gos = new GZIPOutputStream (
new FileOutputStream ( gzFile ))) {
gos . write ( original . getBytes ( StandardCharsets . UTF_8 ));
}
// GZIP 解压——先收集所有字节,再转字符串(避免 UTF-8 多字节字符在缓冲区边界被截断)
ByteArrayOutputStream collector = new ByteArrayOutputStream ();
try ( GZIPInputStream gis = new GZIPInputStream (
new FileInputStream ( gzFile ))) {
byte [] buffer = new byte [ 1024 ] ;
int len ;
while (( len = gis . read ( buffer )) != - 1 ) {
collector . write ( buffer , 0 , len );
}
}
String decompressed = collector . toString ( StandardCharsets . UTF_8 );
assertEquals ( original , decompressed , "解压后应与原文一致" );
System . out . printf ( "原始大小: %d 字节, 压缩后: %d 字节, 压缩比: %.1f%%%n" ,
original . getBytes (). length , gzFile . length (),
( double ) gzFile . length () / original . getBytes (). length * 100 );
}
ZIP 格式
ZIP 比 GZIP 更复杂,它支持多个文件条目(ZipEntry),可以把多个文件打包成一个 .zip 文件。基本流程是:写入时为每个文件创建一个 ZipEntry,写完后关闭该条目;读取时逐个遍历条目:
// 打包多个文件为 ZIP
try ( ZipOutputStream zos = new ZipOutputStream (
new FileOutputStream ( "archive.zip" ))) {
// 添加第一个文件
zos . putNextEntry ( new ZipEntry ( "hello.txt" ));
zos . write ( "你好" . getBytes ( StandardCharsets . UTF_8 ));
zos . closeEntry ();
// 添加第二个文件
zos . putNextEntry ( new ZipEntry ( "world.txt" ));
zos . write ( "世界" . getBytes ( StandardCharsets . UTF_8 ));
zos . closeEntry ();
}
// 从 ZIP 中逐个读取
try ( ZipInputStream zis = new ZipInputStream (
new FileInputStream ( "archive.zip" ))) {
ZipEntry entry ;
while (( entry = zis . getNextEntry ()) != null ) {
System . out . println ( "文件: " + entry . getName ());
// 读取当前条目的内容...
zis . closeEntry ();
}
}
🎲 RandomAccessFile——如何随机读写文件?
前面介绍的所有流都是顺序访问的——只能从头读到尾,不能回头。但有些场景需要**跳到文件的任意位置**读写数据,比如数据库索引文件、大文件的局部修改等。
RandomAccessFile 就是为这种需求设计的。它不属于 InputStream / OutputStream 体系,而是一个**独立的类**,同时实现了 DataInput 和 DataOutput 接口,兼具读写能力。
核心方法
方法
说明
seek(long pos)
将文件指针移动到指定位置(字节偏移量)
getFilePointer()
获取当前文件指针位置
length()
获取文件长度
readInt() / writeInt()
读写 int(4 字节),类似 DataInputStream / DataOutputStream
readUTF() / writeUTF()
读写 UTF-8 编码的字符串
构造方法的第二个参数 mode 控制访问模式:"r" 只读、"rw" 读写、"rws" 读写+同步内容和元数据、"rwd" 读写+同步内容。
随机读写示例
RandomAccessFile 随机读写 /**
* RandomAccessFile:随机读写文件
*/
@Test
void testRandomAccessFile ( @TempDir Path tempDir ) throws IOException {
File file = tempDir . resolve ( "random.dat" ). toFile ();
// 写入数据:3 个 int(每个占 4 字节)
try ( RandomAccessFile raf = new RandomAccessFile ( file , "rw" )) {
raf . writeInt ( 100 ); // 位置 0~3
raf . writeInt ( 200 ); // 位置 4~7
raf . writeInt ( 300 ); // 位置 8~11
raf . writeUTF ( "你好" ); // 位置 12 开始,writeUTF 会先写 2 字节长度
}
// 随机读取:直接跳到第 2 个 int 的位置
try ( RandomAccessFile raf = new RandomAccessFile ( file , "r" )) {
raf . seek ( 4 ); // 跳过第 1 个 int(4 字节),定位到第 2 个
assertEquals ( 200 , raf . readInt ());
// 回到开头读取第 1 个
raf . seek ( 0 );
assertEquals ( 100 , raf . readInt ());
// 跳到第 3 个
raf . seek ( 8 );
assertEquals ( 300 , raf . readInt ());
// 读取字符串
assertEquals ( "你好" , raf . readUTF ());
}
// 随机修改:只改第 2 个 int,其他不动
try ( RandomAccessFile raf = new RandomAccessFile ( file , "rw" )) {
raf . seek ( 4 );
raf . writeInt ( 999 ); // 覆盖位置 4~7 的数据
}
// 验证修改结果
try ( RandomAccessFile raf = new RandomAccessFile ( file , "r" )) {
assertEquals ( 100 , raf . readInt ()); // 第 1 个未变
assertEquals ( 999 , raf . readInt ()); // 第 2 个已修改
assertEquals ( 300 , raf . readInt ()); // 第 3 个未变
}
}
💡 RandomAccessFile 使用 seek() 移动文件指针后,readXxx() / writeXxx() 就从该位置开始操作。这就像一个可以随意拨动的**磁带播放器**——你可以快进、倒带到任意位置。
⚠️ 使用 RandomAccessFile 时,你需要自己管理每条记录的位置和长度。如果写入的数据长度不固定(如字符串),定位起来会比较麻烦。所以它更适合**定长记录**的场景。
🚀 NIO——Channel 与 Buffer
前面介绍的所有 IO 类都基于流模型——数据像水流一样从头到尾顺序流过。Java 1.4 引入的 NIO(New I/O,java.nio 包)采用了完全不同的通道 + 缓冲区模型,目标只有一个:速度 。
→ 实际上,Java 1.4 之后「旧」IO 库已经用 NIO 重新实现过了,所以即使你不直接使用 NIO,也已经在享受它带来的性能提升。
流模型 vs 通道模型
传统 IO(流)
NIO(通道 + 缓冲区)
数据流向
单向(InputStream 只读,OutputStream 只写)
双向(Channel 可读可写)
数据操作
直接操作字节/字符
必须通过 Buffer 中转
阻塞模式
始终阻塞
支持非阻塞(用于网络 IO)
核心类比
水管(水从一头流到另一头)
矿井 + 手推车(Channel 是矿井,Buffer 是手推车)
ByteBuffer——核心数据容器
ByteBuffer 是 NIO 的核心——它是唯一能和 Channel 直接通信的缓冲区类型。理解它的关键在于 3 个指针:
指针
含义
position
下一个要读/写的位置
limit
可读/可写的边界
capacity
缓冲区总容量(不可变)
最重要的两个操作:
flip():写完切读——将 limit 设为当前 position,position 归零。调用后 Buffer 进入「可读状态」
clear():读完切写——将 position 归零,limit 恢复为 capacity。调用后 Buffer 进入「可写状态」
ByteBuffer 基本操作 /**
* ByteBuffer 基本操作:allocate / put / flip / get / clear
*/
@Test
void testByteBufferBasics () {
// 分配一个容量为 16 字节的缓冲区
ByteBuffer buf = ByteBuffer . allocate ( 16 );
// 初始状态:position=0, limit=capacity=16
assertEquals ( 0 , buf . position ());
assertEquals ( 16 , buf . limit ());
assertEquals ( 16 , buf . capacity ());
// 写入数据
buf . put (( byte ) 'H' );
buf . put (( byte ) 'i' );
assertEquals ( 2 , buf . position ()); // position 移动到 2
// flip():切换为读模式(limit=position, position=0)
buf . flip ();
assertEquals ( 0 , buf . position ());
assertEquals ( 2 , buf . limit ()); // limit 变为之前写入的位置
// 读取数据
assertEquals ( 'H' , ( char ) buf . get ());
assertEquals ( 'i' , ( char ) buf . get ());
assertFalse ( buf . hasRemaining ()); // 已读完
// clear():重置为写模式(position=0, limit=capacity)
buf . clear ();
assertEquals ( 0 , buf . position ());
assertEquals ( 16 , buf . limit ());
}
FileChannel——文件的 NIO 通道
FileChannel 通过传统 IO 流的 getChannel() 方法获取:
FileInputStream.getChannel() → 只读通道
FileOutputStream.getChannel() → 只写通道
RandomAccessFile.getChannel() → 读写通道
FileChannel + ByteBuffer 读写文件 /**
* FileChannel + ByteBuffer:读写文件
*/
@Test
void testFileChannelWriteRead ( @TempDir Path tempDir ) throws IOException {
File file = tempDir . resolve ( "nio-test.txt" ). toFile ();
String content = "你好,NIO!" ;
// 写入:FileOutputStream → FileChannel → ByteBuffer
try ( FileChannel fc = new FileOutputStream ( file ). getChannel ()) {
ByteBuffer buf = ByteBuffer . wrap ( content . getBytes ( StandardCharsets . UTF_8 ));
fc . write ( buf );
}
// 读取:FileInputStream → FileChannel → ByteBuffer
try ( FileChannel fc = new FileInputStream ( file ). getChannel ()) {
ByteBuffer buf = ByteBuffer . allocate (( int ) fc . size ());
fc . read ( buf ); // 数据写入 buffer
buf . flip (); // 切换为读模式
String result = StandardCharsets . UTF_8 . decode ( buf ). toString ();
assertEquals ( content , result );
}
}
transferTo——通道间直接传输
复制文件时,传统 IO 需要 Buffer 做中转(读进来 → 写出去)。NIO 的 transferTo() / transferFrom() 可以让两个 Channel 直接对接 ,省去中间 Buffer,操作系统层面可能使用零拷贝优化:
Channel 间直接传输 /**
* transferTo:通道间直接传输(零拷贝文件复制)
*/
@Test
void testChannelTransfer ( @TempDir Path tempDir ) throws IOException {
File src = tempDir . resolve ( "src.txt" ). toFile ();
File dest = tempDir . resolve ( "dest.txt" ). toFile ();
// 准备源文件
try ( FileOutputStream fos = new FileOutputStream ( src )) {
fos . write ( "Channel transfer demo" . getBytes ( StandardCharsets . UTF_8 ));
}
// 通道间直接传输,无需中间 Buffer
try ( FileChannel inCh = new FileInputStream ( src ). getChannel ();
FileChannel outCh = new FileOutputStream ( dest ). getChannel ()) {
inCh . transferTo ( 0 , inCh . size (), outCh );
}
// 验证
try ( FileChannel fc = new FileInputStream ( dest ). getChannel ()) {
ByteBuffer buf = ByteBuffer . allocate (( int ) fc . size ());
fc . read ( buf );
buf . flip ();
assertEquals ( "Channel transfer demo" ,
StandardCharsets . UTF_8 . decode ( buf ). toString ());
}
}
内存映射文件——把文件当数组操作
当文件非常大(几百 MB 甚至 GB)时,内存映射文件(Memory-mapped File)是最快的读写方式。它通过 FileChannel.map() 将文件的一段区域直接映射到内存,之后就可以像操作数组一样读写文件,由操作系统负责在内存和磁盘之间自动同步:
内存映射文件 /**
* 内存映射文件:把文件当数组操作
*/
@Test
void testMemoryMappedFile () throws IOException {
// MappedByteBuffer 在 Windows 上会锁定文件直到 GC,因此不使用 @TempDir
File file = File . createTempFile ( "nio-mapped-" , ".dat" );
file . deleteOnExit ();
int size = 1024 ; // 映射 1KB
// 写入:通过内存映射直接操作文件
try ( RandomAccessFile raf = new RandomAccessFile ( file , "rw" )) {
MappedByteBuffer mapped = raf . getChannel ()
. map ( FileChannel . MapMode . READ_WRITE , 0 , size );
// 像操作数组一样写入数据
for ( int i = 0 ; i < 26 ; i ++ ) {
mapped . put (( byte ) ( 'A' + i ));
}
// 强制将修改刷到磁盘
mapped . force ();
}
// 读取验证(不使用映射,避免再次锁定文件)
try ( FileInputStream fis = new FileInputStream ( file )) {
byte [] data = fis . readNBytes ( 26 );
assertEquals ( 'A' , ( char ) data [ 0 ] );
assertEquals ( 'Z' , ( char ) data [ 25 ] );
}
assertEquals ( size , file . length ());
}
⚠️ MappedByteBuffer 映射的内存区域由操作系统管理,Java 没有提供显式释放的 API。在 Windows 上,映射期间文件会被锁定,无法删除。因此内存映射更适合**长时间运行的服务端程序**,而非短生命周期的脚本。
视图缓冲区——用不同视角看 ByteBuffer
ByteBuffer 只能存字节,但通过 asIntBuffer()、asFloatBuffer() 等方法可以创建「视图缓冲区」,以更高级的类型操作底层字节数据——所有修改会直接反映到原始 ByteBuffer:
视图缓冲区 /**
* 视图缓冲区:通过不同视角操作 ByteBuffer
*/
@Test
void testByteBufferView () {
ByteBuffer bb = ByteBuffer . allocate ( 16 );
// 通过 IntBuffer 视图写入 int 值(每个 int 占 4 字节)
bb . asIntBuffer (). put ( new int [] { 42 , 100 , 999 , - 1 });
// 通过 IntBuffer 视图读取
assertEquals ( 42 , bb . getInt ( 0 )); // 位置 0
assertEquals ( 100 , bb . getInt ( 4 )); // 位置 4
assertEquals ( 999 , bb . getInt ( 8 )); // 位置 8
assertEquals ( - 1 , bb . getInt ( 12 )); // 位置 12
// 直接读取原始字节也能看到数据
assertEquals ( 16 , bb . limit ());
}
什么时候该用 NIO?
对于日常文件读写,传统 IO(特别是 BufferedReader / BufferedWriter)已经足够好用,代码也更简洁。NIO 更适合以下场景:
场景
原因
大文件处理 (GB 级)
内存映射文件避免一次性加载整个文件
高性能文件复制
transferTo() 零拷贝优化
网络编程 (Selector)
非阻塞 IO,一个线程处理多个连接
需要随机访问 + 高性能
Channel 的 position() + 内存映射
🖥️ 标准流——System.in / out / err
Java 预定义了三个标准流,它们在程序启动时就已初始化:
标准流
类型
默认目标
说明
System.in
InputStream
键盘(控制台输入)
标准输入流
System.out
PrintStream
控制台
标准输出流
System.err
PrintStream
控制台
标准错误流
最基本的控制台读取方式是用 BufferedReader 包装 System.in:
try ( BufferedReader br = new BufferedReader (
new InputStreamReader ( System . in ))) {
System . out . print ( "请输入你的名字:" );
String name = br . readLine ();
System . out . println ( "你好," + name );
}
→ 这种写法比较啰嗦,更方便的方式是使用后文介绍的 Scanner。
重定向标准流
标准流的目标可以被修改——这就是「重定向」:
// 将标准输出重定向到文件
System . setOut ( new PrintStream ( new FileOutputStream ( "output.log" )));
System . out . println ( "这句话不会出现在控制台,而是写入 output.log" );
// 将标准输入重定向为文件
System . setIn ( new FileInputStream ( "input.txt" ));
// 此后 System.in.read() 从文件读取,而非键盘
Console——安全的密码输入
System.console() 返回一个 Console 对象,它提供了一项 Scanner 做不到的功能——隐藏密码输入 :
方法
说明
readLine(String fmt, Object... args)
显示提示后读取一行
readPassword(String fmt, Object... args)
隐藏回显地读取密码,返回 char[]
format() / printf()
格式化输出
Console console = System . console ();
if ( console != null ) {
String username = console . readLine ( "用户名:" );
// readPassword() 返回 char[] 而非 String,用完后可立即覆盖清除
char [] password = console . readPassword ( "密码:" );
// 验证完成后,擦除密码
java . util . Arrays . fill ( password , ' ' );
}
⚠️ 在 IDE 和测试环境中 System.console() 返回 null(因为没有真正的终端),所以只能在命令行直接运行时使用。
💡 readPassword() 返回 char[] 而不是 String,是出于安全考虑——char[] 用完后可以立即覆盖清除,而 String 会留在字符串常量池中,直到被 GC 回收前都可能被内存转储读取。
📖 Scanner——如何方便地解析输入?
java.util.Scanner 是 Java 5 引入的通用输入解析器,它内部用**正则表达式**做分词,能直接解析出各种类型的数据。虽然最常见的用法是 new Scanner(System.in) 读控制台,但它能接受任何 InputStream、File、Path 或 Readable 作为输入源。
方法
说明
nextInt() / nextDouble() / nextBoolean()
读取下一个对应类型的值
nextLine()
读取整行(含空格)
hasNextInt() / hasNextLine()
判断是否还有下一个值
useDelimiter(pattern)
自定义分隔符(默认为空白字符)
基本类型解析
Scanner 基本类型解析 /**
* Scanner 基本用法:从字符串中解析不同数据类型
*/
@Test
void testScannerBasic () {
String input = "张三 25 3.14 true" ;
try ( Scanner scanner = new Scanner ( input )) {
String name = scanner . next (); // 读取字符串 token
int age = scanner . nextInt (); // 读取 int
double pi = scanner . nextDouble (); // 读取 double
boolean flag = scanner . nextBoolean (); // 读取 boolean
assertEquals ( "张三" , name );
assertEquals ( 25 , age );
assertEquals ( 3.14 , pi , 0.001 );
assertTrue ( flag );
}
}
逐行读取
Scanner 逐行读取 /**
* Scanner 按行读取 + hasNext 判断
*/
@Test
void testScannerLines () {
String input = "第一行\n第二行\n第三行" ;
StringBuilder result = new StringBuilder ();
try ( Scanner scanner = new Scanner ( input )) {
int lineNum = 0 ;
while ( scanner . hasNextLine ()) {
lineNum ++ ;
result . append ( lineNum ). append ( ": " ). append ( scanner . nextLine ()). append ( "\n" );
}
assertEquals ( 3 , lineNum );
}
System . out . println ( result );
}
自定义分隔符
Scanner 自定义分隔符 /**
* Scanner 自定义分隔符
*/
@Test
void testScannerDelimiter () {
// CSV 格式数据,用逗号分隔
String csv = "苹果,5.5,香蕉,3.2,橙子,8.0" ;
try ( Scanner scanner = new Scanner ( csv )) {
scanner . useDelimiter ( "," ); // 使用逗号作为分隔符
while ( scanner . hasNext ()) {
String name = scanner . next ();
double price = scanner . nextDouble ();
System . out . println ( name + " -> " + price + " 元" );
}
}
}
从文件扫描
Scanner 从文件扫描 /**
* Scanner 从文件读取
*/
@Test
void testScannerFromFile ( @TempDir Path tempDir ) throws IOException {
// 准备测试文件
File file = tempDir . resolve ( "scores.txt" ). toFile ();
try ( PrintWriter pw = new PrintWriter ( file )) {
pw . println ( "张三 90" );
pw . println ( "李四 85" );
pw . println ( "王五 95" );
}
// 从文件扫描
try ( Scanner scanner = new Scanner ( file )) {
int count = 0 ;
int total = 0 ;
while ( scanner . hasNext ()) {
String name = scanner . next ();
int score = scanner . nextInt ();
total += score ;
count ++ ;
}
assertEquals ( 3 , count );
assertEquals ( 270 , total );
System . out . println ( "平均分: " + ( total / count ));
}
}
💡 Scanner 实现了 AutoCloseable,使用 try-with-resources 关闭时会自动关闭底层的输入源。
🛡️ Try-With-Resources——如何优雅关闭资源?
IO 流使用完毕后必须关闭,否则会导致资源泄漏(文件句柄耗尽、内存泄漏等)。传统写法需要在 finally 中手动关闭,代码冗长且容易遗漏。
Java 7 引入的 try-with-resources 语法可以**自动关闭资源**——只要资源实现了 AutoCloseable(或其子接口 Closeable):
try-with-resources 自动关闭 /**
* try-with-resources:自动关闭资源
*/
@Test
void testTryWithResources ( @TempDir Path tempDir ) throws IOException {
File src = new File ( tempDir + "/src.txt" );
File dest = new File ( tempDir + "/dest.txt" );
// 先创建源文件
try ( FileWriter fw = new FileWriter ( src )) {
fw . write ( "try-with-resources 测试" );
}
// 多个资源用分号分隔,按声明逆序关闭
try ( FileInputStream fis = new FileInputStream ( src );
FileOutputStream fos = new FileOutputStream ( dest )) {
fos . write ( fis . readAllBytes ());
}
// 离开 try 块后,fos 先关闭,fis 后关闭
assertTrue ( dest . exists (), "目标文件应存在" );
System . out . println ( "try-with-resources 自动关闭资源成功" );
}
💡 可以在 try() 中声明多个资源,用分号分隔,它们会按照**声明的逆序**依次关闭:
try ( FileInputStream fis = new FileInputStream ( "src.txt" );
FileOutputStream fos = new FileOutputStream ( "dest.txt" )) {
// fos 先关闭,fis 后关闭
}
所有 IO 流都实现了 Closeable 接口,因此都可以使用 try-with-resources。
📋 Properties 文件——如何读取配置文件?
.properties 文件是 Java 中最常用的配置文件格式。java.util.Properties 类可以方便地读写这种 key=value 格式的文件。
方式一:Properties + FileReader
Properties + FileReader 读取配置 /**
* Properties 文件读取:方式一 Properties + FileReader
*/
@Test
void testPropertiesWithFileReader () throws IOException {
String path = getClass (). getClassLoader ()
. getResource ( "config.properties" ). getPath ();
Properties props = new Properties ();
try ( FileReader fr = new FileReader ( path )) {
props . load ( fr );
}
assertEquals ( "localhost" , props . getProperty ( "db.host" ));
assertEquals ( "3306" , props . getProperty ( "db.port" ));
// 提供默认值
assertEquals ( "utf8mb4" , props . getProperty ( "db.charset" , "utf8mb4" ));
System . out . println ( "Properties 读取: " + props );
}
方式二:ResourceBundle(类路径读取)
ResourceBundle 专门用于读取类路径下的 .properties 文件,更适合读取打包在 JAR 中的配置:
ResourceBundle 读取配置 /**
* Properties 文件读取:方式二 ResourceBundle(类路径)
*/
@Test
void testResourceBundle () {
// 自动在 classpath 中查找 config.properties(不带后缀)
ResourceBundle bundle = ResourceBundle . getBundle ( "config" );
assertEquals ( "localhost" , bundle . getString ( "db.host" ));
assertEquals ( "testdb" , bundle . getString ( "db.name" ));
System . out . println ( "ResourceBundle 读取 db.host: " + bundle . getString ( "db.host" ));
}
🧩 IO 流选型指南
面对这么多 IO 流,该怎么选?按照以下决策路径即可:
graph TD
Start["需要读写数据"] --> Q1{"数据类型?"}
Q1 -->|"二进制\n(图片/视频/压缩包)"| Q2{"需要缓冲?"}
Q1 -->|"文本"| Q3{"需要指定编码?"}
Q2 -->|"是"| A1["BufferedInputStream\nBufferedOutputStream"]
Q2 -->|"否"| A2["FileInputStream\nFileOutputStream"]
Q3 -->|"是"| A3["InputStreamReader\nOutputStreamWriter"]
Q3 -->|"否(默认编码即可)"| Q4{"需要按行读写?"}
Q4 -->|"是"| A4["BufferedReader\nBufferedWriter"]
Q4 -->|"否"| A5["FileReader\nFileWriter"]
classDef question fill:transparent,stroke:#e3b341,color:#adbac7,stroke-width:1px
classDef answer fill:transparent,stroke:#539bf5,color:#adbac7,stroke-width:2px
classDef start fill:transparent,stroke:#57ab5a,color:#adbac7,stroke-width:2px
class Start start
class Q1,Q2,Q3,Q4 question
class A1,A2,A3,A4,A5 answer
📝 简单记忆 :
场景
推荐组合
代码骨架
拷贝任意文件
BufferedInputStream + BufferedOutputStream
new BufferedInputStream(new FileInputStream(...))
读写文本(已知编码)
BufferedReader 包装 InputStreamReader
new BufferedReader(new InputStreamReader(fis, UTF_8))
读写文本(默认编码)
BufferedReader 包装 FileReader
new BufferedReader(new FileReader(...))
保存/读取基本类型
DataOutputStream 包装 BufferedOutputStream
new DataOutputStream(new BufferedOutputStream(fos))
保存/读取 Java 对象
ObjectOutputStream 包装 BufferedOutputStream
new ObjectOutputStream(new BufferedOutputStream(fos))
日志输出
PrintWriter 包装 BufferedWriter
new PrintWriter(new BufferedWriter(fw))
文件压缩
GZIPOutputStream / ZipOutputStream
new GZIPOutputStream(new FileOutputStream(...))
解析控制台/文件输入
Scanner
new Scanner(System.in) / new Scanner(path)
安全密码输入
Console
System.console().readPassword("密码:")
随机读写文件
RandomAccessFile
new RandomAccessFile(file, "rw")
大文件高性能读写
FileChannel + ByteBuffer
new FileInputStream(f).getChannel()
超大文件映射
MappedByteBuffer
channel.map(READ_WRITE, 0, size)
单元测试模拟输入
StringReader / CharArrayReader
new BufferedReader(new StringReader(testData))
💡 组合规律 :实际开发中很少直接使用裸节点流,典型的组合模式是 节点流 → 缓冲流 → 功能流,例如 FileOutputStream → BufferedOutputStream → DataOutputStream。缓冲层几乎总是值得加上。