原创

ByteArrayOutputStream.reset 清空了长度,没交还大缓冲区

导出一次大文件以后,后续请求都很小,进程却仍保留一块大字节数组。代码每次都调用 ByteArrayOutputStream.reset(),看起来已经清空,为什么还会这样?

reset重置的是有效内容长度,便于下一次写入复用原有缓冲。它没有承诺把容量缩回初始值。复用减少了后续分配,也把一次峰值容量留在了持有该对象的作用域里。

Java 17允许通过子类访问受保护的缓冲字段。下面只为诊断暴露容量,不使用反射,也不依赖堆图里猜一块数组属于谁。

import java.io.ByteArrayOutputStream;
public class BufferRetention {
    static final class Inspectable extends ByteArrayOutputStream {
        Inspectable(int capacity) { super(capacity); }
        int capacity() { return buf.length; }
    }
    public static void main(String[] args) throws Exception {
        Inspectable out = new Inspectable(32);
        out.write(new byte[1024 * 1024]);
        int peak = out.capacity();
        out.reset();
        System.out.println("size_after_reset=" + out.size());
        System.out.println("capacity_retained=" + (out.capacity() == peak));
        out.write(7);
        byte[] copy = out.toByteArray(); copy[0] = 99;
        System.out.println("internal=" + out.toByteArray()[0]);
        out.close(); out.write(8);
        System.out.println("size_after_close_write=" + out.size());
    }
}

保存为 BufferRetention.java,运行 java BufferRetention.java:

size_after_reset=0
capacity_retained=true
internal=7
size_after_close_write=2

size 为零,只是“没有有效输出”

程序写入一兆字节以后记录容量,再reset。size()变成零,底层容量却不变。这个结果不属于泄漏本身:如果应用明确打算反复处理同样大的输出,保留容量可能正合适。

问题出现在生命周期不匹配。一个偶尔处理大对象的线程,若通过ThreadLocal永久保存这个流,峰值数组就可能跟着线程池长期存活。请求结束不再代表内存可以回收。多个工作线程各留一份,还要将单份容量乘以真实持有者数量,不能只看平时每个请求生成了多少字节。

因此应把策略写成可判断的规则:小缓冲可以复用,超过阈值就丢弃当前对象,下次按正常初始容量创建。阈值来自常见负载与并发预算,不存在适用于所有服务的固定数值。这个选择牺牲少量重新分配,换取峰值不被无限期带到低负载阶段。

toByteArray 给的是副本,调用时可能短暂同时持有两份内容

样本修改返回数组后,重新读取内部结果仍是原值,验证了两者没有共享可写内容。这种保护很有用,却意味着生成返回值时发生复制。若输出很大,内部缓冲和副本可能在一段时间内同时存在。

不要只根据最终文件大小设置内存预算。序列化中间对象、扩容时的新旧数组、返回副本以及后续编码,都可能同时出现。需要避免整个内容入内存时,应该重新设计输出路径,直接写到有明确生命周期的目标流,而不是期望换一种reset调用就能改变缓冲模型。

writeTo可以把有效内容写到另一个输出流,避免额外创建返回数组,但目标可能阻塞或失败,资源与错误协议仍要由调用者处理。它不意味着所有实现都零复制,更不表示已经减少到只持有一个小缓冲。

close 在这里也不是释放容量的开关

程序close后仍能继续写入,这是ByteArrayOutputStream的明确行为。它是内存输出流,关闭不改变后续方法可用性,不能借用文件流的直觉,以为close会把数组交还给操作系统。

若应用不再需要这份容量,需要结束对流对象及其缓冲的强引用,再让垃圾收集按运行时机制处理。即使数组后来变得不可达,进程RSS也未必立即下降;堆内空闲与操作系统回收是另一层观察问题,不能仅凭RSS未降就断定reset无效或GC失灵。

也不要为了更快回收在每次请求后调用System.gc。那会把局部缓冲策略变成整个进程的回收请求,代价与结果都不受这一个方法控制。显式限制保留者的容量和生命周期,更容易解释。

复用前先核对数据边界

reset不代表敏感字节被擦除,只代表这些位置不再属于有效输出。若场景要求擦除内存中的秘密,需要单独评估存储、复制和保留位置,不能把逻辑清空当作安全擦除承诺。

复用实例时还要保证没有跨请求共享可变状态。即使单个方法有同步,也不代表一串“reset、写若干段、取结果”的业务操作自动属于同一个请求;其他调用可能插在中间。通常让每次操作独占实例,比为这个序列补一层宽锁更清楚。

这个问题最终要量的是三件事:当前输出多长、对象保留多少容量、谁还持有它。把这三项分开,才能决定继续复用、设阈值淘汰,还是让输出根本不在内存里完整聚集。

正文到此结束
评论插件初始化中...
Loading...
本文目录