背景
最近系统中的文件服务在处理MP4文件上传时报错,报错堆栈如下
java.lang.OutOfMemoryError: Direct buffer memory
at java.nio.Bits.reserveMemory(Bits.java:694)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
at sun.nio.ch.Util.getTemporaryDirectBuffer(Util.java:241)
at sun.nio.ch.IOUtil.read(IOUtil.java:195)
at sun.nio.ch.FileChannelImpl.read(FileChannelImpl.java:159)
at sun.nio.ch.ChannelInputStream.read(ChannelInputStream.java:65)
at sun.nio.ch.ChannelInputStream.read(ChannelInputStream.java:109)
at sun.nio.ch.ChannelInputStream.read(ChannelInputStream.java:103)
at com.google.common.io.ByteStreams.toByteArrayInternal(ByteStreams.java:181)
at com.google.common.io.ByteStreams.toByteArray(ByteStreams.java:221)
......
我们用到了aws-java-sdk-core这个jar包,将MP4文件存储到s3。
public boolean save(InputStream is, String mediaId) {
# 读取整个InputStream,转换成byte[],因为s3上传时需要知道对象的大小
byte[] buffer = ByteStreams.toByteArray(inputStream)
ByteArrayInputStream bais = new ByteArrayInputStream(buffer))
ObjectMetadata objectMeta = new ObjectMetadata();
objectMeta.setContentLength(buffer.length);
client.putObject(awsBucketName, mediaId, bais, objectMeta);
}
过程
首先审视代码疑点,观察是哪里在分配DirectByteBuffer
顺着堆栈发现2处疑点:
- com.google.common.io.ByteStreams#toByteArrayInternal
# 代码相对源码有少许修改,不过表达的意思是一样的
private static byte[] toByteArrayInternal(InputStream in, Queue<byte[]> bufs, int totalLen)
throws IOException {
# 起始使用8k的buffer去从in中拷贝数据,如果还有剩余未读取完的,每次大小扩一倍后,继续从in中复制数据,指导流结束
for (int bufSize = BUFFER_SIZE; totalLen < MAX_ARRAY_LEN; bufSize = bufSize*2) {
byte[] buf = new byte[bufSize];
bufs.add(buf);
int off = 0;
while (off < buf.length) {
int r = in.read(buf, off, buf.length - off);
if (r == -1) {
break;
}
off += r;
totalLen += r;
}
}
# 将Queue<byte[]> bufs中的buf整个成一个byte[]
return combineBuffers(bufs, MAX_ARRAY_LEN);
}
- sun.nio.ch.IOUtil#read(java.io.FileDescriptor, java.nio.ByteBuffer, long, sun.nio.ch.NativeDispatcher)
static int read(FileDescriptor fd, ByteBuffer dst, long position,
NativeDispatcher nd)
throws IOException
{
# 代码有删减,dst的大小就是ByteStreams中的byte[] buf
ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining());
try {
int n = readIntoNativeBuffer(fd, bb, position, nd);
bb.flip();
if (n > 0)
dst.put(bb);
return n;
} finally {
Util.offerFirstTemporaryDirectBuffer(bb);
}
}
上述代码大致逻辑是,多次分配buf去容纳InputStream中读取的数据(buf的大小每次乘以2),最后将所有的buf整合成一个byte[]。
InputStream是ChannelInputStream,属于java NIO中的类,读取数据时使用DirectByteBuffer拷贝数据已减少内存拷贝次数,具体参考java 堆外内存。
一般视频文件比较大,50MB左右,根据上述代码,最后一次读取InputStream时,会分配16MB以上的DirectByteBuffer。
因为每次分配DirectByteBuffer等于dst.remaining(),即buf的大小。
8k+16k+32k+64k+128k+… = 50MB
最后一次分配的大小粗略估计肯定是大于16MB的。
MaxDirectMemorySize默认大小是64MB,因此推测,同时上传3个大视频文件即可复现问题,结果被打脸,上环境查询后发现该参数我们有显示配置-XX:MaxDirectMemorySize=350m。
这得需要10个以上的大文件同时下载,才能复现问题,我们现网又没有这么大的并发,因此继续挖掘疑点。
确认第二个嫌疑点
sun.nio.ch.Util#getTemporaryDirectBuffer
private static ThreadLocal<BufferCache> bufferCache =
new ThreadLocal<BufferCache>()
{
@Override
protected BufferCache initialValue() {
return new BufferCache();
}
};
public static ByteBuffer getTemporaryDirectBuffer(int size) {
BufferCache cache = bufferCache.get();
ByteBuffer buf = cache.get(size);
if (buf != null) {
return buf;
} else {
if (!cache.isEmpty()) {
buf = cache.removeFirst();
free(buf);
}
return ByteBuffer.allocateDirect(size);
}
}
从上述代码可以看到DirectByteBuffer有缓存,而且每个线程一个缓存。因此推断系统运行一段时间后,bufferCache会缓存一部分DirectByteBuffer,然后突然来几个大文件,每个文件一次都要分配16MB以上的DirectByteBuffer使得DirectByteBuffer达到上限,分配失败。
复现方法
总结
sun.nio.ch.IOUtil#read(java.io.FileDescriptor, java.nio.ByteBuffer, long, sun.nio.ch.NativeDispatcher)
IOUtil读取文件内容时,会分配DirectByteBuffer作为字节容器,并且分配的DirectByteBuffer会被缓存下来。
static int read(FileDescriptor fd, ByteBuffer dst, long position,
NativeDispatcher nd)
throws IOException
{
ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining());
try {
int n = readIntoNativeBuffer(fd, bb, position, nd);
bb.flip();
if (n > 0)
dst.put(bb);
return n;
} finally {
Util.offerFirstTemporaryDirectBuffer(bb);
}
}
在类似于Tomcat这种容器环境下(Tomcat负责处理网络协议,并控制线程池驱动业务逻辑处理),我们自己的业务逻辑中,处理NIO调用时,要避免传入过大的字节数组,防止IOUtil缓存过大的DirectByteBuffer,造成溢出。
因为Tomcat使用线程池处理业务,这些线程都是不销毁的,因此绑定在线程上的ThreadLocal bufferCache以及其缓存的DirectByteBuffer不会被释放。
IOUtil引用的地方如下,使用这些类或方法时,要特别注意。