Day59 | Java I/O之字节流解读
在前一篇文章中,我们沿着Java I/O的演进路线大致的梳理了Java中 I/O的发展。
今天,我们来看看Java I/O体系的鼻祖--InputStream和OutputStream。
他们俩是Java里所有字节操作的基础。
在开始讲之前,我们首先要清楚,字节流,顾名思义,他只处理原始字节。
不管是图片、视频,还是可执行文件,在字节流看来都一样,都只是0和1组成的数据流。
一、InputStream核心API
首先,InputStream是一个抽象类。是Java I/O 体系里所有字节输入流的抽象基类。
我们不能直接new InputStream(),只能用他的具体子类,比如我们前文中用到的FileInputStream。

InputStream里定义了所有输入流都必须具备的核心方法,我们主要看三个:
int read()
这个方法是从输入流里读取一个字节。
如果读取成功,会返回0到255之间的一个int值,表示这个字节。如果已经到达流的末尾(EOF, End of File),会返回-1。
示例:
package com.lazy.snail.day59;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* @ClassName InputStreamDemo1
* @Description TODO
* @Author lazysnail
* @Date 2025/11/11 13:59
* @Version 1.0
*/
public class InputStreamDemo1 {
public static void main(String[] args) {
InputStream inputStream = null;
try {
inputStream = new FileInputStream("C:\\Users\\lazysnail\\Desktop\\test.txt");
int byteData = inputStream.read();
if (byteData != -1) {
System.out.println("读取的字节值: " + byteData);
System.out.println("对应的字符: " + (char) byteData);
} else {
System.out.println("已到达文件末尾");
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
我们新建一个空的test.txt文件,里面没有任何内容。
在22行打上断点,进行调试时,会发现读取到的int值是-1,也就是EOF。

当我们在txt文件输入数字“1”,再进行Debug时,读取到的第一个int值是49。

为什么明明文件里是1,读取出来的是49?
因为我们在txt里面写入1时,实际存储的是字符'1',不是数值1。
我们使用UltraEdit打开test.txt后切换到16进制显示,显示的是16进制的31,对应的ASCII码就是49。

如果我们把txt的内容改成'懒'这个中文字。
然后把读取数据那里修改成:
while ((byteData = inputStream.read()) != -1) {
System.out.println("读取的字节值: " + byteData);
}
我们会得到三个int值:

表示'懒'这个中文字,使用了三个字节来表示。把三个整数转换成二进制,实际就是在计算机上存储的懒这个字的数据。
那我们怎么知道的呢?我们倒推一下:
首先用记事本直接打开test.txt,在右下角有一个编码格式UTF-8。

UTF-8编码的编码是有规则的:
1字节字符:0xxxxxxx
2字节字符:110xxxxx 10xxxxxx
3字节字符:1110xxxx 10xxxxxx 10xxxxxx
4字节字符:11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
我们的中文一般都是三字节字符,也就是三个字节表示一个中文字符。
用UltraEdit打开test.txt后切换到16进制显示

转换成2进制就是:11100110 10000111 10010010
然后根据UTF-8的3字节字符编码规则进行提取:
1110xxxx 10xxxxxx 10xxxxxx
11100110 10000111 10010010
0110 000111 010010
01100001 11010010
把这个提取的二进制转换成16进制:0x61D2
这个得到的16进制就叫Unicode码点,然后我们去这里面找:
https://www.unicode.org/charts/PDF/U4E00.pdf
就能找到对应的中文字:

所以当我们使用文本软件保存字符时,字符先是以Unicode码点形式存在于内存里面。
当我们保存时,文本软件需要选择编码:

然后文本软件调用编码器,把Unicode转换成UTF-8字节,最后把字节序列写到文件里。
int read(byte b[])
这个方法是尝试从输入流里读取数据,然后尽力用这些读取的数据填满b数组。
他的返回值是实际读取的字节数,不一定是b数组的长度,当然返回值是-1表示读到头了。
还是用test.txt的例子,稍微改一下:
byte[] buffer = new byte[2];
while ((byteData = inputStream.read(buffer)) != -1) {
System.out.println("读取到几个字节: " + byteData);
for (int i = 0; i < byteData; i++) {
System.out.println("读取到的字节: " + buffer[i]);
}
}
但是读取到的字节居然跟之前单字节读取的结果不一样了:

这是因为单字节的read方法返回的就是int类型,其实JVM帮我们把byte转换成了无符号整数值。
而read(byte b[])中放进buffer中的是原始的byte。

注意这里调用的是FileInputStream这个具体实现类中的read(byte b[])方法。
要把结果转换成无符号整数直接改成:
System.out.println("读取到的字节: " + (buffer[i] & 0xFF));

close()
这个方法就是关闭输入流,释放跟输入流关联的系统资源。
比如FileInputStream的close方法,底层调用的是操作系统的close()系统调用。
目的就是告诉操作系统,我们用完了,其他人可以用这个资源了。
二、OutputStream核心API
跟InputStream对应,OutputStream也是一个抽象类,他是所有字节输出流的抽象基类。他定义了往目标(如文件、网络、内存)写入字节数据的标准方法。
同样看下几个重要的方法:
write(int b)
这个方法会把一个指定的字节写到输出流。
要注意的是Java中int是4个字节32位。当使用write(int b)时,只会把b的低8位写到输出流。
package com.lazy.snail.day59;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
/**
* @ClassName OutputStreamDemo1
* @Description TODO
* @Author lazysnail
* @Date 2025/11/11 17:15
* @Version 1.0
*/
public class OutputStreamDemo1 {
public static void main(String[] args) {
OutputStream os = null;
try {
os = new FileOutputStream("C:\\Users\\lazysnail\\Desktop\\test.txt");
int value = 4673;
os.write(value);
} catch (IOException e) {
e.printStackTrace();
} finally {
if (os != null) {
try {
os.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
}
}
代码中用write写入了一个整数4673,他的二进制是:00000000 00000000 00010010 01000001
低8位也就是01000001,直接去ASCII表找,理论上应该往文件里写一个A字符。

实际写入的也是A字符。

write(byte b[])
这是把字节数组b中的内容都写到输出流。
write(byte b[], int off, int len)
这是我们在I/O复制操作里最常用、最精确的方法。他从buffer数组里,提取从offset(偏移量)开始、总共 length(长度)个字节,并把他们写到输出流。
flush()
这个方法用来刷出缓冲区。OutputStream本身是没有缓冲区的,但是他的子类,比如BufferedOutputStream、ObjectOutputStream为了提高性能,都在Java内部维护了一个缓冲区。
使用flush()会强制要求Java把这些内部缓冲区的数据,立马写到底层的流,然后触发系统调用。
需要注意的是,flush()不会保证数据一定写入物理磁盘,他只是把数据从Java的用户态缓冲区交给了操作系统的内核态缓冲区。
close()
这个方法跟之前输入流的关闭方法一样,也是为了释放资源。close()方法在真正关闭流之前,会自动调用一次 flush()。
三、字节流实践
3.1 缓冲流
在前文中,我们知道I/O的性能瓶颈就在于系统调用。像上面我们直接操作FileInputStream一个字节一个字节的读写,肯定效率不高。BufferedInputStream自带了缓冲区,性能就有了提升。
先看一下BufferedInputStream的继承关系:

可以看出BufferedInputStream继承了FilterInputStream,而FilterInputStream又继承了InputStream。
我们可以使用这种形式构建缓冲输入流:
InputStream in = new BufferedInputStream(
new FileInputStream("test.txt")
);
new FileInputStream("test.txt") 创建了一个基础的、提供基本文件读取功能的FileInputStream对象。他直接操作磁盘文件,每次读取都可能涉及底层的系统调用,效率比较低。
new BufferedInputStream(...) 接收上一步创建的FileInputStream对象作为参数。
BufferedInputStream把这个FileInputStream包装起来。他内部维护了一个缓冲区(默认8KB)。
最后,把整个BufferedInputStream对象赋值给InputStream这个父类引用。从外部in的角度看,他只知道自己是InputStream,但他实际的行为已经被BufferedInputStream增强了。
其实这个过程涉及到一个设计模式--装饰器模式。
我们在不修改现有类,比如FileInputStream的情况下,通过包装或者叫装饰,来扩展新的功能,比如BufferedInputStream的缓冲功能。
下面是使用BufferedInputStream的执行流程:

3.2 资源关闭
之前我们有提到不管是InputStream还是OutputStream,在使用完毕之后,都需要调用close() 方法来释放底层的系统资源。
在JDK1.7之前,我们需要try-catch来捕获I/O异常,还需要finally来确保close()能被调用。但是close()本身也可能抛出IOException,所以finally内部还需要一个嵌套的try-catch。
以前的代码一般是这样写的:
package com.lazy.snail.day59;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* @ClassName InputStreamDemo3
* @Description TODO
* @Author lazysnail
* @Date 2025/11/12 11:25
* @Version 1.0
*/
public class InputStreamDemo3 {
public static void main(String[] args) {
InputStream in = null;
try {
in = new FileInputStream("test.txt");
int b;
while ((b = in.read()) != -1) {
// 业务逻辑
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
如果使用过新版本的JDK,再看这段代码,肯定会觉得非常臃肿,确实也是这样。
JDK1.7后,引入了一个java.lang.AutoCloseable接口,java.io.Closeable继承了他,所有I/O流都实现了这个接口。
所有实现了AutoCloseable接口的资源,都可以放在try()的括号里进行初始化。这就是try-with-resources语法。
现代的写法是这样的:
package com.lazy.snail.day59;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* @ClassName InputStreamDemo4
* @Description TODO
* @Author lazysnail
* @Date 2025/11/12 11:30
* @Version 1.0
*/
public class InputStreamDemo4 {
public static void main(String[] args) {
try (InputStream in = new FileInputStream("test.txt")) {
int b;
while ((b = in.read()) != -1) {
// ...
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
比起之前的代码,看着简单多了。
这种写法中,在try块结束的时候,不管是不是发生异常,JVM都会自动、安全地帮我们调用in.close()。
缓冲流也可以用这样的写法:
package com.lazy.snail.day59;
import java.io.*;
/**
* @ClassName BufferedStreamDemo
* @Description TODO
* @Author lazysnail
* @Date 2025/11/12 11:34
* @Version 1.0
*/
public class BufferedStreamDemo {
public static void main(String[] args) {
try (InputStream in = new BufferedInputStream(new FileInputStream("in.txt"));
OutputStream out = new BufferedOutputStream(new FileOutputStream("out.txt")))
{
// I/O 逻辑
} catch (IOException e) {
e.printStackTrace();
}
}
}
示例代码中的in和out都会被自动关闭,关闭的顺序跟声明顺序相反。
结语
本文我们了解了InputStream和OutputStream的核心API。
也了解了可以通过装饰器模式在不修改原有类的情况下对这个类进行增强。
还了解了以前和现在的资源关闭方式。
下一篇预告
Day60 | Java IO之字符流详解
如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!
更多推荐


所有评论(0)