一次现网问题定位-java.lang.OutOfMemoryError: Direct buffer memory

背景

最近系统中的文件服务在处理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处疑点:

  1. 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);
  }
  1. 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引用的地方如下,使用这些类或方法时,要特别注意。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值