少女祈祷中...
NEKO
知识库

java io 面试要点

一、字节流与字符流

Q1:字节流和字符流的区别是什么?

答:

对比项

字节流

字符流

基类

InputStream / OutputStream

Reader / Writer

处理单位

字节(8 bit)

字符(16 bit,Java char)

编码处理

不处理编码,需自行转换

内部使用编码表处理

适用场景

图片、音频、视频、二进制文件

文本文件、字符数据

缓冲

通常需自行包装 Buffered

本身常带缓冲

核心区别:字节流面向二进制,字符流面向文本并处理编码。


Q2:为什么需要字符流?字节流不能处理文本吗?

答: 字节流可以读写文本,但需要手动处理编码转换,容易出错:

// 字节流读文本,需手动解码
InputStream in = new FileInputStream("a.txt");
byte[] buf = new byte[1024];
int len = in.read(buf);
String s = new String(buf, 0, len, StandardCharsets.UTF_8);

字符流封装了编码解码:

Reader reader = new InputStreamReader(
    new FileInputStream("a.txt"), StandardCharsets.UTF_8);

字符流 = 字节流 + 编码表,是字节流的包装。


Q3:InputStreamReader 和 OutputStreamWriter 的作用是什么?

答: 它们是字节流和字符流之间的桥梁:

// 字节流 → 字符流
Reader r = new InputStreamReader(
    new FileInputStream("a.txt"), "UTF-8");

// 字符流 → 字节流
Writer w = new OutputStreamWriter(
    new FileOutputStream("a.txt"), "UTF-8");
  • InputStreamReader:把字节按指定编码解码成字符

  • OutputStreamWriter:把字符按指定编码编码成字节


Q4:FileReader / FileWriter 有什么坑?

答: 它们使用平台默认编码,跨平台会乱码:

// 不推荐,编码依赖系统
FileReader fr = new FileReader("a.txt");

// 推荐,显式指定编码
Reader r = new InputStreamReader(
    new FileInputStream("a.txt"), StandardCharsets.UTF_8);

在中文 Windows 上是 GBK,Linux 上通常是 UTF-8,容易出问题。


Q5:什么是缓冲流?为什么需要它?

答: 缓冲流(BufferedInputStream、BufferedReader 等)在内存中维护缓冲区,减少系统调用次数:

// 无缓冲:每次 read 都可能触发系统调用
InputStream in = new FileInputStream("a.txt");

// 有缓冲:一次读 8KB,减少 IO 次数
InputStream in = new BufferedInputStream(
    new FileInputStream("a.txt"), 8192);

核心价值:磁盘/网络 IO 是慢操作,缓冲能显著提升吞吐量。


Q6:flush() 和 close() 有什么区别?

答:

  • flush():强制把缓冲区数据写入目标,流仍可继续使用

  • close():先 flush,再释放资源,之后流不可用

Writer w = new BufferedWriter(new FileWriter("a.txt"));
w.write("hello");
w.flush();  // 立即写入,不关闭
w.write("world");
w.close();  // 关闭并释放

注意:close() 通常隐含 flush(),但某些包装流不保证。


Q7:如何用字节流读取一行?和 BufferedReader.readLine() 有何不同?

答: 字节流没有 readLine(),需要自己实现:

public static String readLine(InputStream in) throws IOException {
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    int b;
    while ((b = in.read()) != -1) {
        if (b == '\n') break;
        if (b != '\r') bos.write(b);
    }
    return bos.size() == 0 && b == -1 ? null 
        : bos.toString("UTF-8");
}

BufferedReader.readLine() 内部就是类似逻辑,但按字符处理,自动处理 \r\n。


二、BIO / NIO / AIO

Q8:BIO、NIO、AIO 分别是什么?

答:

模型

全称

特点

BIO

Blocking IO

同步阻塞,一连接一线程

NIO

Non-blocking IO

同步非阻塞,基于多路复用

AIO

Asynchronous IO

异步非阻塞,基于回调

核心区别:

  • BIO:read() 阻塞直到有数据

  • NIO:read() 无数据立即返回,用 Selector 管理多个连接

  • AIO:read() 立即返回,数据就绪后回调通知


Q9:BIO 的模型是怎样的?有什么问题?

答: 典型的一请求一线程模型:

ServerSocket server = new ServerSocket(8080);
while (true) {
    Socket socket = server.accept(); // 阻塞
    new Thread(() -> {
        InputStream in = socket.getInputStream();
        byte[] buf = new byte[1024];
        in.read(buf); // 阻塞
        // 处理...
    }).start();
}

问题:

  • 每个连接一个线程,线程开销大

  • 连接数多时线程爆炸

  • accept() 和 read() 都阻塞,CPU 利用率低


Q10:NIO 的三大核心组件是什么?

答:

  1. Channel:双向通道,可读可写

  2. Buffer:数据容器,读写都经过 Buffer

  3. Selector:多路复用器,一个线程管理多个 Channel

Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
    selector.select(); // 阻塞直到有事件
    Set<SelectionKey> keys = selector.selectedKeys();
    // 处理事件...
}

Q11:NIO 为什么比 BIO 高效?

答:

  • BIO:一个连接一个线程,线程上下文切换开销大

  • NIO:一个线程管理多个连接(多路复用),连接数不受线程数限制

本质:NIO 用少量线程 + 事件驱动,替代大量阻塞线程。

但注意:NIO 编程复杂,且若业务处理慢,仍会阻塞 Selector 线程。


Q12:AIO 和 NIO 的区别是什么?

答:

对比项

NIO

AIO

IO 模型

同步非阻塞

异步非阻塞

通知时机

数据就绪时通知(需自己读)

数据读取完成时通知

编程方式

Selector 轮询

回调 / Future

底层

epoll(Linux)

IOCP(Windows)

// AIO 示例
AsynchronousSocketChannel channel = 
    AsynchronousSocketChannel.open();
channel.read(buffer, null, new CompletionHandler<Integer, Object>() {
    @Override
    public void completed(Integer result, Object attachment) {
        // 读完成后回调
    }
    @Override
    public void failed(Throwable exc, Object attachment) {}
});

注意:Linux 上 AIO 底层仍是 epoll 模拟,性能优势不明显,Netty 也用 NIO 而非 AIO。


Q13:Netty 为什么选择 NIO 而不是 AIO?

答:

  • Linux 原生 AIO 不成熟,JDK AIO 底层用 epoll 模拟

  • AIO 回调编程模型复杂,调试困难

  • NIO + epoll 在多路复用场景下性能足够

  • Netty 通过 Reactor 模式封装 NIO,兼顾性能和可控性


三、Channel / Buffer / Selector

Q14:Channel 和 Stream 有什么区别?

答:

对比项

Stream

Channel

方向

单向(读或写)

双向(可读可写)

阻塞

阻塞

可非阻塞

配合

直接读写

必须配合 Buffer

多路复用

不支持

支持 Selector

// Stream:单向
InputStream in = ...;  // 只能读
OutputStream out = ...; // 只能写

// Channel:双向
SocketChannel ch = ...;
ch.read(buffer);
ch.write(buffer);

Q15:Buffer 的核心属性有哪些?

答: 四个核心属性:

属性

含义

capacity

容量,创建后不变

position

下一个读写位置

limit

读写上限

mark

标记位置,用于 reset

关键方法:

  • flip():写模式 → 读模式(limit=position, position=0)

  • clear():读模式 → 写模式(position=0, limit=capacity)

  • rewind():position=0,可重读

  • compact():把未读数据移到头部,准备继续写


Q16:为什么 Buffer 需要 flip()?

答: 因为 Buffer 读写共用 position:

ByteBuffer buf = ByteBuffer.allocate(1024);
buf.put("hello".getBytes()); // position=5

// 不 flip 直接读,position=5,读不到数据
buf.flip(); // limit=5, position=0
while (buf.hasRemaining()) {
    System.out.print((char) buf.get());
}

flip 的本质:把"写到的位置"变成"读的上限",position 归零。


Q17:直接缓冲区(DirectBuffer)和堆缓冲区(HeapBuffer)有什么区别?

答:

对比项

HeapBuffer

DirectBuffer

内存位置

JVM 堆

堆外(本地内存)

分配/释放

快

慢

IO 性能

需拷贝到堆外

零拷贝,直接 IO

管理

GC 管理

手动/Cleaner

适用

普通数据操作

网络/文件 IO

ByteBuffer heap = ByteBuffer.allocate(1024);      // 堆
ByteBuffer direct = ByteBuffer.allocateDirect(1024); // 堆外

DirectBuffer 适合长期、频繁 IO 场景,但创建销毁成本高。


Q18:Selector 的工作原理是什么?

答: Selector 基于操作系统的多路复用(Linux epoll、Windows select):

  1. 把 Channel 注册到 Selector,关注特定事件

  2. select() 阻塞等待事件就绪

  3. 返回就绪的 SelectionKey 集合

  4. 遍历处理事件

ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
    selector.select();
    Iterator<SelectionKey> it = 
        selector.selectedKeys().iterator();
    while (it.hasNext()) {
        SelectionKey key = it.next();
        if (key.isAcceptable()) { /* 处理连接 */ }
        if (key.isReadable())  { /* 处理读 */ }
        it.remove(); // 必须移除
    }
}

Q19:Selector 支持哪些事件?

答: 四种:

事件

常量

说明

连接就绪

OP_CONNECT

客户端连接成功

接受就绪

OP_ACCEPT

服务端可接受连接

读就绪

OP_READ

有数据可读

写就绪

OP_WRITE

可写入数据

注意:OP_WRITE 通常不常注册,因为大部分时间可写,会导致忙轮询。


Q20:为什么处理完事件后要 it.remove()?

答: selectedKeys() 返回的集合不会自动清除,处理完必须手动移除,否则下次 select() 会重复处理:

Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> it = keys.iterator();
while (it.hasNext()) {
    SelectionKey key = it.next();
    // 处理...
    it.remove(); // 关键
}

不 remove 会导致事件重复处理、逻辑错误。


Q21:Selector 是线程安全的吗?

答: 不是。多个线程同时操作 Selector 会有问题:

  • register() 会阻塞在 select() 的线程

  • 推荐一个 Selector 由一个线程管理

  • Netty 用 EventLoop 保证每个 Selector 单线程处理

如需唤醒 select(),调用 selector.wakeup()。


四、零拷贝(Zero-Copy)

Q22:什么是零拷贝?

答: 零拷贝指减少或消除 CPU 在数据拷贝上的开销,让数据尽量不经过用户空间,直接在内核空间传输。

传统 IO 的 4 次拷贝 + 4 次上下文切换:

磁盘 → 内核缓冲区 → 用户缓冲区 → Socket 缓冲区 → 网卡
        (DMA)      (CPU拷贝)      (CPU拷贝)     (DMA)

零拷贝目标:减少 CPU 拷贝和上下文切换。


Q23:传统文件读取发送到网络,经历了几次拷贝?

答: 4 次拷贝、4 次上下文切换:

// 传统方式
byte[] buf = new byte[1024];
while ((len = fileInputStream.read(buf)) != -1) {
    socketOutputStream.write(buf, 0, len);
}

流程:

  1. DMA:磁盘 → 内核页缓存

  2. CPU:内核页缓存 → 用户缓冲区

  3. CPU:用户缓冲区 → Socket 缓冲区

  4. DMA:Socket 缓冲区 → 网卡


Q24:零拷贝有哪些实现方式?

答: 主要有四种:

方式

系统调用

拷贝次数

mmap + write

mmap() + write()

3 次

sendfile

sendfile()

3 次(Linux 2.4 前)

sendfile + DMA gather

sendfile()

2 次

splice

splice()

2 次


Q25:mmap + write 的原理是什么?

答:

MappedByteBuffer buf = 
    fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, size);
socketChannel.write(buf);
  • mmap() 把内核页缓存映射到用户空间

  • 用户空间和内核空间共享同一块内存

  • 省去"内核 → 用户"的拷贝

拷贝次数:磁盘 → 页缓存(DMA)→ Socket 缓冲区(CPU)→ 网卡(DMA),共 3 次。

缺点:mmap 有内存映射开销,且文件过大时映射成本高。


Q26:sendfile 的原理是什么?

答:

// Java 中通过 FileChannel.transferTo 使用
fileChannel.transferTo(position, count, socketChannel);

sendfile() 让数据不经过用户空间:

磁盘 → 内核页缓存 → Socket 缓冲区 → 网卡
       (DMA)        (CPU拷贝)      (DMA)

Linux 2.4 后,如果网卡支持 SG-DMA(Scatter-Gather DMA),可进一步省去 CPU 拷贝:

磁盘 → 内核页缓存 → 网卡
       (DMA)      (SG-DMA)

真正的零拷贝:2 次 DMA 拷贝,0 次 CPU 拷贝。


Q27:Java 中如何使用零拷贝?

答: 主要通过 FileChannel.transferTo() 和 transferFrom():

// 服务端发送文件
FileChannel fileChannel = new FileInputStream("a.txt").getChannel();
SocketChannel socketChannel = SocketChannel.open(
    new InetSocketAddress("localhost", 8080));

// 零拷贝发送
fileChannel.transferTo(0, fileChannel.size(), socketChannel);

底层:Linux 上用 sendfile(),Windows 上用 TransmitFile()。


Q28:Kafka 和 Netty 如何利用零拷贝?

答:

Kafka:

  • 用 transferTo() 把日志文件发送给消费者

  • 用 mmap() 读写索引文件

  • 大幅减少 CPU 拷贝和上下文切换

Netty:

  • 提供 FileRegion,底层用 FileChannel.transferTo()

  • 提供 CompositeByteBuf,逻辑合并多个 Buffer,避免拷贝

  • ByteBuf.slice() 共享底层内存,不拷贝

// Netty 零拷贝发送文件
FileRegion region = new DefaultFileRegion(
    new FileInputStream("a.txt").getChannel(), 0, size);
ctx.writeAndFlush(region);

Q29:零拷贝一定比传统 IO 快吗?

答: 不一定,要看场景:

  • 适合:大文件传输、静态资源服务、Kafka 日志推送

  • 不适合:需要对数据做修改(如加密、压缩),因为零拷贝数据不经过用户空间

  • 小文件:优势不明显,甚至因系统调用开销更慢

零拷贝的核心是减少 CPU 拷贝,不是万能优化。


Q30:什么是"广义零拷贝"?

答: 除了内核层面的零拷贝,还包括减少数据在内存中的复制:

  • Netty 的 CompositeByteBuf:逻辑合并多个 Buffer,不物理拷贝

  • ByteBuf.slice():共享底层数组,只改指针

  • Java 的 DirectByteBuffer:堆外内存,避免堆内到堆外的拷贝

  • 字符串常量池:复用已有对象

这些都属于广义的"减少拷贝"思想。

评论