跳到主要内容IO 流为什么只能读取一次?从底层原理到 Web 实战 | 极客日志Javajava
IO 流为什么只能读取一次?从底层原理到 Web 实战
IO 流通常只能读取一次,因为底层维护位置指针,读取后指针移动或数据被消耗。网络流和文件流尤其如此。解决方案包括使用支持 mark/reset 的内存流(如 ByteArrayInputStream),或在 Web 开发中通过包装请求流(如 CachedBodyHttpServletRequest)将数据缓存到内存中实现多次读取。需注意内存占用与 IO 性能的权衡,小请求体适合缓存,大请求体建议流式处理。
随缘25 浏览 IO 流为什么只能读取一次?从底层原理到 Web 实战
引言:一个让无数开发者困惑的问题
在 Web 开发中,你是否遇到过这样的场景:
@RestController
public class UserController {
@PostMapping("/user")
public String createUser(@RequestBody User user) {
return "success";
}
}
明明在过滤器中已经读取过请求体了:
@WebFilter("/*")
public class LogFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
InputStream is = request.getInputStream();
String body = IOUtils.toString(is);
chain.doFilter(request, response);
}
}
问题:为什么过滤器读取后,Controller 就收不到数据了?
答案是:IO 流通常只能被读取一次。本文将深入剖析这一现象背后的原理,并提供解决方案。
1. IO 流的本质:顺序读取的'磁带'
1.1 位置指针(Position Pointer)
所有基于流的读取操作都维护着一个位置指针:
public abstract class {
IOException;
}
InputStream
public
abstract
int
read
()
throws
- 读取两次后:字节 1、字节 2 已读,指针指向字节 3。
- 读取一次后:字节 1 已读,指针指向字节 2。
- 初始状态:指针指向字节 1。
1.2 读取过程模拟
public class StreamReadSimulation {
public static void main(String[] args) throws IOException {
byte[] data = {65, 66, 67, 68};
ByteArrayInputStream stream = new ByteArrayInputStream(data);
System.out.println("第 1 次读取:" + stream.read());
System.out.println("第 2 次读取:" + stream.read());
System.out.println("第 3 次读取:" + stream.read());
System.out.println("第 4 次读取:" + stream.read());
System.out.println("第 5 次读取:" + stream.read());
System.out.println("第 6 次读取:" + stream.read());
}
}
第 1 次读取:65
第 2 次读取:66
第 3 次读取:67
第 4 次读取:68
第 5 次读取:-1
第 6 次读取:-1
1.3 为什么设计成只能读一次?
| 数据源类型 | 为什么只能读一次 | 类比 |
|---|
| 网络流 | 数据是实时传输的,TCP 缓冲区数据读取后即丢弃 | 直播流,无法回放 |
| 文件流 | 底层是操作系统文件句柄,顺序读取效率最高 | 磁带播放器 |
| 控制台流 | 用户输入是一次性的 | 一次性对话 |
2. 深入源码:InputStream 的 read 机制
2.1 核心方法分析
public abstract class InputStream implements Closeable {
public abstract int read() throws IOException;
public int read(byte b[], int off, int len) throws IOException {
if (b == null) {
throw new NullPointerException();
} else if (off < 0 || len < 0 || len > b.length - off) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return 0;
}
int c = read();
if (c == -1) {
return -1;
}
b[off] = (byte) c;
int i = 1;
try {
for (; i < len; i++) {
c = read();
if (c == -1) {
break;
}
b[off + i] = (byte) c;
}
} catch (IOException ee) {}
return i;
}
public long skip(long n) throws IOException {
long remaining = n;
while (remaining > 0) {
if (read() == -1) {
break;
}
remaining--;
}
return n - remaining;
}
}
2.2 FileInputStream 的实现
public class FileInputStream extends InputStream {
private final FileDescriptor fd;
private native int read0() throws IOException;
@Override
public int read() throws IOException {
return read0();
}
}
底层原理:操作系统内核维护着每个打开文件的文件偏移量,每次读取后自动增加。
2.3 SocketInputStream 的实现
class SocketInputStream extends FileInputStream {
@Override
public int read() throws IOException {
return super.read();
}
}
3. 例外情况:支持重置的流
3.1 ByteArrayInputStream 支持重置
public class MarkResetExample {
public static void main(String[] args) throws IOException {
byte[] data = "Hello World".getBytes();
ByteArrayInputStream bais = new ByteArrayInputStream(data);
System.out.println("是否支持 mark/reset: " + bais.markSupported());
bais.mark(0);
byte[] first = new byte[5];
bais.read(first);
System.out.println("第一次读取:" + new String(first));
bais.reset();
byte[] second = new byte[5];
bais.read(second);
System.out.println("第二次读取:" + new String(second));
}
}
是否支持 mark/reset: true
第一次读取:Hello
第二次读取:Hello
3.2 mark/reset 原理
public class ByteArrayInputStream extends InputStream {
protected byte buf[];
protected int pos;
protected int mark;
@Override
public void mark(int readAheadLimit) {
mark = pos;
}
@Override
public void reset() {
pos = mark;
}
@Override
public boolean markSupported() {
return true;
}
}
内存数组 buf 中,pos 和 mark 控制读取位置。
3.3 常见流的支持情况
| 流类型 | 是否支持 mark | 原因 |
|---|
| ByteArrayInputStream | ✅ 支持 | 数据在内存中,可重复读取 |
| BufferedInputStream | ✅ 支持 | 内部有缓冲区 |
| FileInputStream | ❌ 不支持 | 依赖操作系统文件指针 |
| SocketInputStream | ❌ 不支持 | 网络数据实时传输 |
| System.in | ❌ 不支持 | 控制台输入一次性的 |
4. 实战:Web 请求体的多次读取
4.1 问题重现
@WebFilter("/*")
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
String body = readBody(req.getInputStream());
System.out.println("请求体:" + body);
chain.doFilter(request, response);
}
private String readBody(InputStream is) throws IOException {
ByteArrayOutputStream result = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int length;
while ((length = is.read(buffer)) != -1) {
result.write(buffer, 0, length);
}
return result.toString();
}
}
4.2 解决方案:包装请求
public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
private final byte[] cachedBody;
public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
this.cachedBody = readBody(request.getInputStream());
}
private byte[] readBody(InputStream is) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int read;
while ((read = is.read(buffer)) != -1) {
baos.write(buffer, 0, read);
}
return baos.toByteArray();
}
@Override
public ServletInputStream getInputStream() throws IOException {
return new CachedBodyServletInputStream(this.cachedBody);
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
}
class CachedBodyServletInputStream extends ServletInputStream {
private final ByteArrayInputStream inputStream;
public CachedBodyServletInputStream(byte[] cachedBody) {
this.inputStream = new ByteArrayInputStream(cachedBody);
}
@Override
public int read() throws IOException {
return inputStream.read();
}
@Override
public boolean isFinished() {
return inputStream.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener listener) {
}
}
4.3 过滤器中使用包装类
@WebFilter("/*")
public class CachingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest(req);
System.out.println("过滤器第 1 次读取:" + IOUtils.toString(cachedRequest.getInputStream()));
System.out.println("过滤器第 2 次读取:" + IOUtils.toString(cachedRequest.getInputStream()));
chain.doFilter(cachedRequest, response);
}
}
4.4 Spring 框架的解决方案
Spring 提供了 ContentCachingRequestWrapper:
@WebFilter
public class SpringCachingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(request);
chain.doFilter(wrapper, response);
byte[] body = wrapper.getContentAsByteArray();
}
}
5. 高级技巧:包装流的多种实现
5.1 实现可重复读的 InputStream
public class RepeatableInputStream extends InputStream {
private final byte[] data;
private int position;
private int markPosition;
public RepeatableInputStream(byte[] data) {
this.data = data;
this.position = 0;
}
public RepeatableInputStream(InputStream is) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
this.data = baos.toByteArray();
this.position = 0;
}
@Override
public int read() throws IOException {
if (position >= data.length) {
return -1;
}
return data[position++] & 0xFF;
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
if (b == null) {
throw new NullPointerException();
}
if (off < 0 || len < 0 || len > b.length - off) {
throw new IndexOutOfBoundsException();
}
if (position >= data.length) {
return -1;
}
int available = data.length - position;
int toRead = Math.min(len, available);
System.arraycopy(data, position, b, off, toRead);
position += toRead;
return toRead;
}
@Override
public long skip(long n) throws IOException {
int available = data.length - position;
int toSkip = (int) Math.min(n, available);
position += toSkip;
return toSkip;
}
@Override
public int available() throws IOException {
return data.length - position;
}
@Override
public boolean markSupported() {
return true;
}
@Override
public void mark(int readlimit) {
markPosition = position;
}
@Override
public void reset() throws IOException {
position = markPosition;
}
}
5.2 使用示例
public class RepeatableStreamDemo {
public static void main(String[] args) throws IOException {
InputStream original = new FileInputStream("test.txt");
RepeatableInputStream repeatable = new RepeatableInputStream(original);
System.out.println("第 1 次读取:" + IOUtils.toString(repeatable, "UTF-8"));
repeatable.reset();
System.out.println("第 2 次读取:" + IOUtils.toString(repeatable, "UTF-8"));
}
}
6. 性能考虑与最佳实践
6.1 内存 vs IO 的权衡
| 方案 | 优点 | 缺点 | 适用场景 |
|---|
| 直接读取 | 内存占用小 | 只能读一次 | 大型文件流式处理 |
| 缓存到内存 | 可多次读取 | 内存占用大 | 小请求体(<1MB) |
| 缓存到磁盘 | 可多次读取 | IO 开销大 | 超大文件需重复处理 |
6.2 Web 应用中的最佳实践
@Component
public class RequestBodyCacheAdvice {
private static final int MAX_CACHE_SIZE = 1024 * 1024;
public HttpServletRequest wrapIfNeeded(HttpServletRequest request) {
if (isSmallRequest(request)) {
return new CachedBodyHttpServletRequest(request);
}
return request;
}
private boolean isSmallRequest(HttpServletRequest request) {
String contentLength = request.getHeader("Content-Length");
if (contentLength != null) {
try {
return Integer.parseInt(contentLength) <= MAX_CACHE_SIZE;
} catch (NumberFormatException e) {
return false;
}
}
return false;
}
}
6.3 性能对比
public class PerformanceTest {
public static void main(String[] args) throws IOException {
byte[] data = new byte[1024 * 1024];
new Random().nextBytes(data);
ByteArrayInputStream bais = new ByteArrayInputStream(data);
long start = System.nanoTime();
readFully(bais);
long directTime = System.nanoTime() - start;
ByteArrayInputStream bais2 = new ByteArrayInputStream(data);
byte[] cached = readFully(bais2);
start = System.nanoTime();
for (int i = 0; i < 10; i++) {
ByteArrayInputStream cachedStream = new ByteArrayInputStream(cached);
readFully(cachedStream);
}
long cachedTime = System.nanoTime() - start;
System.out.println("直接读取:" + directTime / 1_000_000 + "ms");
System.out.println("缓存后读取 10 次:" + cachedTime / 1_000_000 + "ms");
}
private static byte[] readFully(InputStream is) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
return baos.toByteArray();
}
}
总结:IO 流读取的本质
| 概念 | 类比 | 原因 |
|---|
| 位置指针 | 磁带机的磁头 | 操作系统和网络协议栈的设计 |
| 顺序读取 | 一次性吸管 | 数据源的实时性要求 |
| mark/reset | 书签 | 仅适用于内存数据源 |
| 包装缓存 | 录像回放 | 通过内存存储实现多次读取 |
- 流是顺序的:设计如此,符合底层 IO 模型
- 消费即消失:网络流、文件流都是'一次性的'
- 内存流可重置:只有基于内存的流支持重复读取
- Web 请求体只能读一次:需要多次读取时,必须缓存
'IO 流就像一条河流,你无法两次踏入同一条河流。但你可以建一个水库(缓存),让河水反复利用。'
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online