BIO = Blocking I/O(同步阻塞 I/O)。当线程发起 I/O 调用后,如果数据未就绪,调用不会返回,线程被操作系统挂起、原地干等 —— 注意是“挂起不占 CPU”,不是“耗时长”。Java 里 BIO 的落地 = java.io 流 + java.net 阻塞式 Socket。典型场景是网络:客户端 socket.getInputStream().read() 在对端不发数据时一直挂着,服务端 accept() 也阻塞等连接 —— 两个阻塞点叠加,导致经典 BIO 服务端只能"一个连接一个线程”,这就是它在高并发下崩掉的根源,也是下一节 NIO 登场的原因。
在正式进入阻塞式案例学习前,先铺垫一些 IO 基础。IO 指的是程序与外部数据源/目的地之间的数据交换,分为输入流和输出流。参照系是程序(JVM)本身:
常见的使用案例:
FileInputStream/FileOutputStream、FileReader/FileWriterByteArray*Stream、StringReader/StringWritersocket.getInputStream() 返回的就是 InputStream Socket 本身在 java.net 包System.in(InputStream)、System.out(PrintStream) Scanner 的默认来源另外 Socket 并不是 java.io的类,但它暴露的接口就是 InputStream —— 这意味着“从网络读”和“从文件读”在代码层面是同一套 API,缓冲流、装饰器可同用。流的抽象屏蔽了设备差异,这也是 java.io设计里让人感觉惊喜的一笔。当然这只是理论,等我敲完代码再对比下可能感觉会更深。
字符流、字节流在学习初期让我感觉非常混沌。为什么要有字节流和字符流?谁在前谁在后?我该如何梳理并进行高效的运用?
结果显而易见,字节流在前。JDK 1.0只有字节流,物理世界里所有 I/O 设备 —— 磁盘、网卡、键盘 —— 读写的最小单位都是字节,这就出现了一个问题,读取出来的字节都是010101这种电信号,需要程序员对照着UTF-8或者GBK编码表手动的去翻译,流程包括必须知道编码->手动解码->处理边界,很容易出现乱码。另外程序中处理最多的是文本而不是字节。
于是JDK1.1推出了 Reader/Writer字符流,对于字节流来说,字符流的出现简直是救世主,因为你不需要手动解码了,你用 FileReader指定UTF-8编码调用 reader.read(),系统会自动帮你“翻译”,比如 E4 BD A0这三个字节合在一起就是“你”它不会像字节流那样把三个字节拆开给你。而且字符流有缓冲池,底层虽然还是用字节去硬盘里拿数据但 Reader内部有一个默认8192字符大小的缓冲区。它每次从硬盘读字节时,会尽可能多地读然后偷偷在后台把字节凑成完整的汉字放进缓冲区。当你调用 read()时,它直接从缓冲区把拼好的“你”字给你,绝不给你半截字节
所以得出一个结论,字符流 = 字节流 + 内置编码解码器 + 自动处理多字节字符边界的缓冲区,这也就能明白了为什么字符流的入参都是字节流。其实这样记就简单多了:
任何字符流(Reader)如果想从硬盘读“文字”,它第一步必须依赖一个字节流(InputStream)去硬盘里把“字节”读回来,然后字符流再在内存里把这些字节“翻译”成文字。
new InputStreamReader(InputStream in) —— 把网络传过来的字节翻译成文字 new OutputStreamWriter(OutputStream out) —— 把内存里的文字翻译成字节发出去
如果理解了这个点,那相当于抓住了java io的七寸。
我先用三个例子来验证IO读写外部文件,首先是写入一个大数据量测试数据文件,后面对比加入缓冲之后的读取文件的速度;
java private static final String filePath = "D:\\claude-code\\javaReview\\data\\file.txt";
private static final String BigDataFilePath = "D:\\claude-code\\javaReview\\data\\bigFile.txt";
private static final int TEST_DATA_SIZE = 2 * 1024 * 1024; // 2MB测试数据
/**
* 使用字节流写入测试文件(2MB的文本数据)
*/
private static void testPrepareTestFile(){
File file = new File(BigDataFilePath);
if(file.exists()){
System.out.println("测试文件已存在,跳过生成。");
return;
}
System.out.println("正在生成 2MB 测试文件...");
try (OutputStream os = new FileOutputStream(file)) {
BufferedOutputStream bos = new BufferedOutputStream(os); //套一层缓冲
String line = "这是一段测试文本,用来验证IO缓冲的性能差距。1234567890\n";
byte[] bytes = line.getBytes(StandardCharsets.UTF_8);
long written = 0;
while (written<TEST_DATA_SIZE){
bos.write(bytes);
written += bytes.length;
}
System.out.println("测试文件生成完成!文件大小: " + file.length() / 1024 / 1024 + "MB");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* 方式1 :不加缓冲,每次读取一个字节
* @return 时间差
*/
private static long testWithoutBuffer(){
System.out.println("测试1:不加缓冲,每次read()1个字节");
long start = System.currentTimeMillis();
try (InputStream is = new FileInputStream(BigDataFilePath)) {
int data;
int count = 0;
while ((data = is.read())!=-1){
count++;
}
System.out.println("读取了"+count+"个字节");
long end = System.currentTimeMillis();
long cost = end - start;
System.out.println(" → 读取了 " + count + " 个字节");
return cost;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* 方式2 : 加缓冲,每次还是读取一个字节
* @return
*/
private static long testWithBuffer(){
System.out.println("测试2:加缓冲,每次read()1个字节");
long start = System.currentTimeMillis();
try (InputStream is = new FileInputStream(BigDataFilePath)) {
BufferedInputStream bis = new BufferedInputStream(is); //套上缓冲
int data;
long count = 0;
while ((data = bis.read())!=-1){
count++;
}
System.out.println(" → 读取了 " + count + " 个字节");
} catch (IOException e) {
throw new RuntimeException(e);
}
long end = System.currentTimeMillis();
long cost = end - start;
System.out.println("耗时: " + cost + " ms");
return cost;
}
/**
* 方式3 :缓冲+批量读取
* @return
*/
private static long testWithBatchBuffer(){
System.out.println("测试3:缓冲+批量读取,每次read(byte[])批量读取 8192 字节");
long start = System.currentTimeMillis();
try (InputStream is = new FileInputStream(BigDataFilePath)) {
BufferedInputStream bis = new BufferedInputStream(is); //套上缓冲
byte[] buffer = new byte[8192]; //8kb缓冲区
int bytesRead;
long totalBytes = 0;
while ((bytesRead= bis.read(buffer))!=-1){
totalBytes +=bytesRead;
}
System.out.println(" → 读取了 " + totalBytes + " 个字节");
} catch (IOException e) {
throw new RuntimeException(e);
}
long end = System.currentTimeMillis();
long cost = end - start;
System.out.println("耗时: " + cost + " ms");
return cost;
}
执行结果
powershell测试文件已存在,跳过生成。 测试1:不加缓冲,每次read()1个字节 读取了2089924个字节 耗时: 5922 ms 测试2:加缓冲,每次read()1个字节 → 读取了 2089924 个字节 耗时: 16 ms 测试3:缓冲+批量读取,每次read(byte[])批量读取 8192 字节 → 读取了 2089924 个字节 耗时: 1 ms
对比测试1和测试2,为什么 BufferedInputStream 能让速度提升 370 倍?
无缓冲逐字节读耗时 5922ms,加一层 BufferedInputStream 后骤降至 16ms。其中的关键在于操作系统与 JVM 之间的交互次数,以及缓冲区域与目标区域(比如数组)的交互次数。
1.为什么 read() 逐字节读这么慢?
每次 FileInputStream.read() 都会触发一次 native 方法调用 → 操作系统 系统调用 → 从磁盘读取 一个字节 → 返回。2MB 文件约 209 万个字节,意味着 209 万次系统调用。系统调用涉及用户态/内核态切换,单次虽微秒级,但 200 万次累加就是秒级耗时。
2.BufferedInputStream 做了什么?
它内部维护了一个 8KB 的字节数组缓冲区。当调用 bis.read() 时:
如果缓冲区有数据,直接从内存返回 —— 0 次系统调用
如果缓冲区空了,才触发一次底层 FileInputStream.read(byte[]),一次性读满 8KB —— 1 次系统调用换回 8192 个字节,209 万次系统调用 → 约 209万 ÷ 8192 = 256,这就是 370 倍差距。
3.那为什么批量 read(byte[]) 更快(1ms)?
即使有了 BufferedInputStream,bis.read() 每次仍返回一个字节,虽然系统调用少了,但 JVM 层面仍有 209 万次方法调用和字节复制。而 bis.read(byte[]) 一次性将 8192 个字节拷贝到目标数组,循环次数从 209 万次降到 256 次,JVM 方法调用开销也被压缩。
前面提到过字节流在读写的时候会有一些问题比如:
new String(bytes, charset);每次写入,都要 string.getBytes(charset);a.字符流解决编码信息丢失:
java// 写:构造时绑定编码,后续直接写字符串
try (FileWriter fw = new FileWriter("test.txt", StandardCharsets.UTF_8)) {
fw.write("你好世界");
}
// 读:构造时绑定编码,后续直接读字符
try (FileReader fr = new FileReader("test.txt", StandardCharsets.UTF_8)) {
char[] buffer = new char[1024];
int len = fr.read(buffer);
System.out.println(new String(buffer, 0, len)); // 输出: 你好世界 ✅
}
编码信息被绑定在流对象,从JVM层面来讲也就是堆上,真正的被JVM所记住,读取时不需要手动的new String(bytes, charset),编码变成了“对象的状态或者说属性”;
b.字符流解决多字节字符边界处理问题:
java/**
* 准备测试文件:包含中文字符
* '你' = E4 BD A0(3字节),'好' = E5 A5 BD(3字节),'世' = E4 B8 96(3字节),'界' = E7 95 8C(3字节)
*/
private static void prepareTestFile() throws IOException {
try (FileWriter fw = new FileWriter("boundary_test.txt", StandardCharsets.UTF_8)) {
fw.write("你好世界"); // 12个字节(4个汉字 × 3字节)
}
}
/**
* 直接对比:一个文件,两种读法
*/
private static void testDirectComparison() throws IOException {
String filePath = "boundary_test.txt";
System.out.println("=== 字节流:每次读取 1 个字节 ===");
try (InputStream is = new FileInputStream(filePath)) {
int b;
int count = 0;
while ((b = is.read()) != -1) {
count++;
System.out.printf("读取第 %d 个字节: 0x%02X\n", count, b);
}
System.out.println("总共读取: " + count + " 个字节");
}
System.out.println("\n=== 字符流:每次读取 1 个字符 ===");
try (Reader reader = new FileReader(filePath, StandardCharsets.UTF_8)) {
int ch;
int count = 0;
while ((ch = reader.read()) != -1) {
count++;
System.out.printf("读取第 %d 个字符: '%c'\n", count, (char) ch);
}
System.out.println("总共读取: " + count + " 个字符");
// 输出: 4 个字符
}
}
shell输出结果 === 字节流:每次读取 1 个字节 === 读取第 1 个字节: 0xE4 读取第 2 个字节: 0xBD 读取第 3 个字节: 0xA0 读取第 4 个字节: 0xE5 读取第 5 个字节: 0xA5 读取第 6 个字节: 0xBD 读取第 7 个字节: 0xE4 读取第 8 个字节: 0xB8 读取第 9 个字节: 0x96 读取第 10 个字节: 0xE7 读取第 11 个字节: 0x95 读取第 12 个字节: 0x8C 总共读取: 12 个字节 === 字符流:每次读取 1 个字符 === 读取第 1 个字符: '你' 读取第 2 个字符: '好' 读取第 3 个字符: '世' 读取第 4 个字符: '界' 总共读取: 4 个字符
这样对比一目了然: 同一个文件,字节流读出了 12 个"碎片",字符流读出了 4 个"完整字符"。
c. 字符流解决字节流每次处理都要重复编码/解码逻辑,这个主要是处理重复代码本质上和问题一很相似
java/**
* 用字节流复制文本文件
* 注意:到处都在写 getBytes() 和 new String()
*/
public static void copyByByteStream(String src, String dest) throws IOException {
// 读:字节 → 字符串,要 new String()
try (InputStream is = new FileInputStream(src)) {
byte[] bytes = is.readAllBytes();
String content = new String(bytes, StandardCharsets.UTF_8); // 手动解码
// 写:字符串 → 字节,要 getBytes()
try (OutputStream os = new FileOutputStream(dest)) {
os.write(content.getBytes(StandardCharsets.UTF_8)); // 手动编码
}
}
}
/**
* 用字符流复制文本文件
* 注意:没有任何 getBytes() 和 new String()
*/
public static void copyByCharStream(String src, String dest) throws IOException {
try (Reader reader = new FileReader(src, StandardCharsets.UTF_8);
Writer writer = new FileWriter(dest, StandardCharsets.UTF_8)) {
char[] buffer = new char[8192];
int len;
while ((len = reader.read(buffer)) != -1) {
writer.write(buffer, 0, len); // 直接写字符数组,不用手动转换
}
}
}
常用的IO操作类
1.ByteArrayOutPutStream、ByteArrayInputStream
java /**
* 测试把多个片段拼成一个完整的字节数组
* @throws IOException
*/
private static void testByteArrayOutputStream() throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
//写数据,把多个片段拼成一个完整的字节数组
bos.write(65); //表示A
bos.write("hello".getBytes(StandardCharsets.UTF_8));
bos.write("world".getBytes(StandardCharsets.UTF_8));
byte[] result = bos.toByteArray();
System.out.println(new String(result));
String str = bos.toString(StandardCharsets.UTF_8);
System.out.println(str);
//写入到文件当中
bos.writeTo(new FileOutputStream(filePath));
}
/**
* 模拟文件复制
*/
private static void testCopy() throws IOException {
//准备源数据
String source = "Hello, ByteArrayStream!";
byte[] srcData = source.getBytes(StandardCharsets.UTF_8);
// 从内存读取
ByteArrayInputStream bais = new ByteArrayInputStream(srcData);
// 写入内存(目标)
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// 复制
byte[] buffer = new byte[1024];
int len;
while ((len = bais.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
// 模拟输出结果
System.out.println(baos.toString(StandardCharsets.UTF_8));
}
2.StringReader,StringWriter
java private static void testStringReader(){
StringWriter sw = new StringWriter();
//用字符流的方式写入字符串
sw.write("第一行\n");
sw.write("第二行\n");
sw.write("第三行");
//也可以配合 PrintWriter 使用
PrintWriter pw = new PrintWriter(sw);
pw.println("这是用PrintWriter写的");
pw.printf("格式化数字:%d\n", 123);
pw.close(); // 注意关闭 PrintWriter,但 StringWriter 不用关
// 最终取出完整的字符串
String result = sw.toString();
System.out.println(result);
// 也可以获取内部的 StringBuffer
StringBuffer sb = sw.getBuffer();
System.out.println("StringBuffer内容:" + sb.toString());
}
/**
* 测试模板替换
*/
private static void testTemplateProcessor() throws IOException {
// 模板内容(可以来自配置文件、数据库等)
String template = """
尊敬的 {name}:
您的订单 {orderId} 已发货,预计 {days} 天内送达。
感谢您的购买!
""";
// 用 StringReader 读取模板
StringReader sr = new StringReader(template);
BufferedReader br = new BufferedReader(sr);
// 用 StringWriter 构建结果
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
// 逐行处理替换
String line;
while ((line = br.readLine()) != null) {
line = line.replace("{name}", "李四")
.replace("{orderId}", "20240830-001")
.replace("{days}", "3");
pw.println(line);
}
pw.close();
// 输出结果
System.out.println(sw.toString());
}
线程是CPU工作的最小单元,它们之间既能相互协作,也能相互竞争。那么,如何实现线程间的通信呢?这里我们从IO的角度,来看看Java如何让线程通过IO进行通信。
javapublic static void main(String[] args) throws IOException {
// 创建管道:一个输出端,一个输入端
PipedInputStream ins = new PipedInputStream();
PipedOutputStream ops = new PipedOutputStream(ins);
//生产者写入消息到管道流
Thread producer =new Thread(() -> {
try (PrintWriter pw = new PrintWriter(ops)) {
pw.println("Hello 我是生产者");
pw.println("This is a message through pipe.");
}
});
//消费者读取
Thread consumer = new Thread(() -> {
try (BufferedReader br = new BufferedReader(new InputStreamReader(ins))) {
String line;
while ((line = br.readLine())!=null){
System.out.println("消费者收到:" + line);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
});
producer.start();
consumer.start();
}
两个线程完全不知道对方的存在,只是通过"读/写同一个管道流"来通信,IO API 被直接用来做线程间通信,数据在内存中流转,不涉及任何物理设备
Java 中的 Socket 本质上是 TCP 协议在应用层的一次面向对象封装。TCP 通信需要明确目标主机的 IP 地址和端口号,同样,建立 Socket 连接也离不开这两个网络坐标。对于服务端而言,其核心流程是通过 ServerSocket 向操作系统申请占用一个指定端口,随后调用 accept() 方法进入阻塞等待状态——这本质上是一次系统级调用,线程在此挂起,直到 TCP 三次握手成功、客户端连接到达,才会被唤醒并继续执行。正是这种“阻塞监听”模型,奠定了 Java 传统 BIO 通信的基础。
一个简单的案例,客户端像服务端发送消息:
java/**
* 服务端
*/
private static final int PORT = 9527;
public static void main(String[] args) {
System.out.println("========== 服务器启动 ==========");
System.out.println("监听端口: " + PORT);
ServerSocket socket = new ServerSocket(PORT);
try {
while (true){
Socket accept = socket.accept();
SocketAddress remoteSocketAddress = accept.getRemoteSocketAddress();
System.out.println("新链接:"+remoteSocketAddress.toString());
//broadcast(remoteSocketAddress,accept); 广播
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(accept.getInputStream()));
String line = "";
while ((line=bufferedReader.readLine())!=null){
System.out.println("收到客户端消息:"+line);
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
java/**
* 客户端
*/
private static final int PORT = 9527;
public static void main(String[] args) {
try {
Socket socket = new Socket("127.0.0.1",PORT);
//获取键盘输入
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
System.out.print("请输入: ");
PrintWriter out = new PrintWriter(socket.getOutputStream(),true);
String line = "";
while ((line=bufferedReader.readLine())!=null){
//发送给服务端
out.println(line);
}
} catch (IOException e) {
System.out.println("断开链接");
throw new RuntimeException(e);
}
}
在服务端加一个广播的功能,在客户端加一个接收服务端消息的功能
java /**
* 广播消息给所有在线链接
*/
private static void broadcast(SocketAddress address,Socket socket) throws IOException {
String line = "新链接"+address+"已连接";
PrintWriter printWriter = new PrintWriter(socket.getOutputStream(), true);
printWriter.println(line);
}
java public static void main(String[] args) {
try {
Socket socket = new Socket("127.0.0.1",PORT);
//读取服务器信息
BufferedReader serverReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
ExecutorService executorService = Executors.newSingleThreadExecutor();
executorService.submit(()->{
String readMessage = "";
try {
while ((readMessage = serverReader.readLine())!=null){
System.out.println(readMessage);
}
}catch (Exception e){
}
});
//从键盘获取信息发送给服务端
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
System.out.println("请输入: ");
PrintWriter out = new PrintWriter(socket.getOutputStream(),true);
String line = "";
while ((line=bufferedReader.readLine())!=null){
out.println(line);
}
} catch (IOException e) {
System.out.println("断开链接");
throw new RuntimeException(e);
}
}
客户端控制台:
shell请输入: 新链接/127.0.0.1:49219已连接 你好
服务端控制台:
shell========== 服务器启动 ========== 监听端口: 9527 新链接:/127.0.0.1:49219 收到客户端消息:你好
从上面的测试可以看出,想要提升 I/O 性能,几乎离不开 BufferedInputStream / BufferedReader 这类缓冲流。这正是 Java I/O 设计中装饰器模式的典型应用: 功能与数据源分离:缓冲、加密、压缩等增强逻辑,与底层数据来源(文件、网络、内存)完全解耦 自由组合:想要缓冲就套 BufferedXXX,想要加密就再套一层 CipherInputStream,想要压缩就继续套 GZIPInputStream——每一层只负责一件事,层层叠加,按需组装 如下图(插入装饰器模式图):
┌─────────────────────┐ │ InputStream │ ← 统一抽象 └──────────┬──────────┘ ┌───────────┬─────────┼─────────┬───────────┐ ↓ ↓ ↓ ↓ ↓ FileInputStream ByteArray Socket Piped ... ← 数据源(叶子节点) ↑ ↑ ↑ ↑ └───────────┴─────────┼─────────┘ ↓ ┌─────────────────────┐ │ BufferedInputStream │ ← 装饰器(增强) └──────────┬──────────┘ ↓ ┌─────────────────────┐ │ CipherInputStream │ ← 装饰器(增强) └──────────┬──────────┘ ↓ 你想要的任意组合
一句话来说就是:**装饰器模式是一种结构型设计模式,它允许在不修改对象结构的情况下,动态地为对象添加
写完这篇博客,回头再看 Java I/O,感受和几年前初学的时候完全不一样了。当时是为了应付面试硬背概念、API,现在更多在思考“为什么这么设计”。几个比较深的感触,记录一下。
以前老是记混什么时候用 InputStream、什么时候用 Reader,经过这轮回看,心里总算有了杆秤:
对于文件是图片、视频、音频、或者序列化后的二进制数据,字节流。因为这些内容根本没有“字符”这个概念,用字符流反而会破坏数据。字节流是万能的,什么都能读,只是读出来的是 bytes,得自己处理。
纯文本文件,或者要逐行读取日志、JSON、配置文件,对应字符流;
它最妙的地方在于:你不需要知道数据是从哪里来的。
给文件读加缓冲,和给 Socket 网络流加缓冲,写法一模一样——都是 new BufferedInputStream(whateverInputStream)。缓冲层只关心“我前面有个 InputStream”,它不关心这个 InputStream 是来自硬盘、内存、网络还是另一个线程。加密、压缩、序列化这些增强功能也是一样,想加哪层加哪层,每一层各司其职,互不干扰。
这让我对 Java 的设计模式产生了浓厚的兴趣——一个好的架构设计,是真的能让程序员少掉很多头发。后面我打算系统地把常用的设计模式再过一遍,结合源码去理解。
关于Socket,有两层感受是交织在一起的。
第一层是赞叹:Java 对 TCP 的封装真的太方便了。ServerSocket 和 Socket 把三次握手、端口绑定、数据收发这些底层细节全部屏蔽掉了,配合 I/O 流的装饰器模式,几行代码就能搭一个能用的服务端。这对于应用层开发者来说,是极大的解放。
第二层其实是疑惑:accept()和read()一阻塞,每条连接就得霸着一个线程,在高并发下服务器根本招架不住。于是我了解到了NIO,发现它很好的弥补了这一点,让我想起字节流和字符流:前者处理文本时要手动编解码,对开发者极不友好,于是后者应运而生。 这种从I/O到并发,从反射到模块化,JDK已走到26,二十多年来Java可能一直在循环做着同一件事:解决旧问题,暴露新问题,再被下一代版本化解。可能这就是它的生命力吧,如今AI时代,Java又会变成什么样呢?


本文作者:Yulu
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!