关于JVM堆外内存的一切

Java中的对象都是在JVM堆中分配的,其好处在于开发者不用关心对象的回收。但有利必有弊,堆内内存主要有两个缺点:1.GC是有成本的,堆中的对象数量越多,GC的开销也会越大。2.使用堆内内存进行文件、网络的IO时,JVM会使用堆外内存做一次额外的中转,也就是会多一次内存拷贝。

和堆内内存相对应,堆外内存就是把内存对象分配在Java虚拟机堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。

我们先看下堆外内存的实现原理,再谈谈它的应用场景。

更多文章见个人博客:https://github.com/farmerjohngit/myblog

堆外内存的实现

Java中分配堆外内存的方式有两种,一是通过ByteBuffer.java#allocateDirect得到以一个DirectByteBuffer对象,二是直接调用Unsafe.java#allocateMemory分配内存,但Unsafe只能在JDK的代码中调用,一般不会直接使用该方法分配内存。

其中DirectByteBuffer也是用Unsafe去实现内存分配的,对堆内存的分配、读写、回收都做了封装。本篇文章的内容也是分析DirectByteBuffer的实现。

我们从堆外内存的分配回收、读写两个角度去分析DirectByteBuffer。

堆外内存的分配与回收

//ByteBuffer.java 
public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

ByteBuffer#allocateDirect中仅仅是创建了一个DirectByteBuffer对象,重点在DirectByteBuffer的构造方法中。

DirectByteBuffer(int cap) {                   // package-private
    //主要是调用ByteBuffer的构造方法,为字段赋值
    super(-1, 0, cap, cap);
    //如果是按页对齐,则还要加一个Page的大小;我们分析只pa为false的情况就好了
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    //预分配内存
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        //分配内存
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    //将分配的内存的所有值赋值为0
    unsafe.setMemory(base, size, (byte) 0);
    //为address赋值,address就是分配内存的起始地址,之后的数据读写都是以它作为基准
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        //pa为false的情况,address==base
        address = base;
    }
    //创建一个Cleaner,将this和一个Deallocator对象传进去
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;

}

DirectByteBuffer构造方法中还做了挺多事情的,总的来说分为几个步骤:

  1. 预分配内存
  2. 分配内存
  3. 将刚分配的内存空间初始化为0
  4. 创建一个cleaner对象,Cleaner对象的作用是当DirectByteBuffer对象被回收时,释放其对应的堆外内存

Java的堆外内存回收设计是这样的:当GC发现DirectByteBuffer对象变成垃圾时,会调用Cleaner#clean回收对应的堆外内存,一定程度上防止了内存泄露。当然,也可以手动的调用该方法,对堆外内存进行提前回收。

Cleaner的实现

我们先看下Cleaner#clean的实现:

public class Cleaner extends PhantomReference<Object> {
   ...
    private Cleaner(Object referent, Runnable thunk) {
        super(referent, dummyQueue);
        this.thunk = thunk;
    }
    public void clean() {
        if (remove(this)) {
            try {
                //thunk是一个Deallocator对象
                this.thunk.run();
            } catch (final Throwable var2) {
              ...
            }

        }
    }
}

private static class Deallocator
    implements Runnable
    {

        private static Unsafe unsafe = Unsafe.getUnsafe();

        private long address;
        private long size;
        private int capacity;

        private Deallocator(long address, long size, int capacity) {
            assert (address != 0);
            this.address = address;
            this.size = size;
            this.capacity = capacity;
        }

        public void run() {
            if (address == 0) {
                // Paranoia
                return;
            }
            //调用unsafe方法回收堆外内存
            unsafe.freeMemory(address);
            address = 0;
            Bits.unreserveMemory(size, capacity);
        }

    }

Cleaner继承自PhantomReference,关于虚引用的知识,可以看我之前写的文章

简单的说,就是当字段referent(也就是DirectByteBuffer对象)被回收时,会调用到Cleaner#clean方法,最终会调用到Deallocator#run进行堆外内存的回收。

Cleaner是虚引用在JDK中的一个典型应用场景。

预分配内存

然后再看下DirectByteBuffer构造方法中的第二步,reserveMemory

    static void reserveMemory(long size, int cap) {
        //maxMemory代表最大堆外内存,也就是-XX:MaxDirectMemorySize指定的值
        if (!memoryLimitSet && VM.isBooted()) {
            maxMemory = VM.maxDirectMemory();
            memoryLimitSet = true;
        }

        //1.如果堆外内存还有空间,则直接返回
        if (tryReserveMemory(size, cap)) {
            return;
        }
        //走到这里说明堆外内存剩余空间已经不足了
        final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();

        //2.堆外内存进行回收,最终会调用到Cleaner#clean的方法。如果目前没有堆外内存可以回收则跳过该循环
        while (jlra.tryHandlePendingReference()) {
            //如果空闲的内存足够了,则return
            if (tryReserveMemory(size, cap)) {
                return;
            }
        }

       //3.主动触发一次GC,目的是触发老年代GC
        System.gc();

        //4.重复上面的过程
        boolean interrupted = false;
        try {
            long sleepTime = 1;
            int sleeps = 0;
            while (true) {
                if (tryReserveMemory(size, cap)) {
                    return;
                }
                if (sleeps >= MAX_SLEEPS) {
                    break;
                }
                if (!jlra.tryHandlePendingReference()) {
                    try {
                        Thread.sleep(sleepTime);
                        sleepTime <<= 1;
                        sleeps++;
                    } catch (InterruptedException e) {
                        interrupted = true;
                    }
                }
            }

            //5.超出指定的次数后,还是没有足够内存,则抛异常
            throw new OutOfMemoryError("Direct buffer memory");

        } finally {
            if (interrupted) {
                // don't swallow interrupts
                Thread.currentThread().interrupt();
            }
        }
    }
    
    private static boolean tryReserveMemory(long size, int cap) {
        //size和cap主要是page对齐的区别,这里我们把这两个值看作是相等的
        long totalCap;
        //totalCapacity代表通过DirectByteBuffer分配的堆外内存的大小
        //当已分配大小<=还剩下的堆外内存大小时,更新totalCapacity的值返回true
        while (cap <= maxMemory - (totalCap = totalCapacity.get())) {
            if (totalCapacity.compareAndSet(totalCap, totalCap + cap)) {
                reservedMemory.addAndGet(size);
                count.incrementAndGet();
                return true;
            }
        }
        //堆外内存不足,返回false
        return false;
    }

在创建一个新的DirecByteBuffer时,会先确认有没有足够的内存,如果没有的话,会通过一些手段回收一部分堆外内存,直到可用内存大于需要分配的内存。具体步骤如下:

  1. 如果可用堆外内存足够,则直接返回
  2. 调用tryHandlePendingReference方法回收已经变成垃圾的DirectByteBuffer对象对应的堆外内存,直到可用内存足够,或目前没有垃圾DirectByteBuffer对象
  3. 触发一次full gc,其主要目的是为了防止’冰山现象‘:一个DirectByteBuffer对象本身占用的内存很小,但是它可能引用了一块很大的堆外内存。如果DirectByteBuffer对象进入了老年代之后变成了垃圾,因为老年代GC一直没有触发,导致这块堆外内存也一直没有被回收。需要注意的是如果使用参数-XX:+DisableExplicitGC,那System.gc();是无效的
  4. 重复1,2步骤的流程,直到可用内存大于需要分配的内存
  5. 如果超出指定次数还没有回收到足够内存,则OOM

详细分析下第2步是如何回收垃圾的:tryHandlePendingReference最终调用到的是Reference#tryHandlePending方法,在之前的文章中有介绍过该方法

static boolean tryHandlePending(boolean waitForNotify) {
        Reference<Object> r;
        Cleaner c;
        try {
            synchronized (lock) {
                //pending由jvm gc时设置
                if (pending != null) {
                    r = pending;
                    // 如果是cleaner对象,则记录下来
                    c = r instanceof Cleaner ? (Cleaner) r : null;
                    // unlink 'r' from 'pending' chain
                    pending = r.discovered;
                    r.discovered = null;
                } else {
                    // waitForNotify传入的值为false
                    if (waitForNotify) {
                        lock.wait();
                    }
                    // 如果没有待回收的Reference对象,则返回false
                    return waitForNotify;
                }
            }
        } catch (OutOfMemoryError x) {
            ...
        } catch (InterruptedException x) {
           ...
        }

        // Fast path for cleaners
        if (c != null) {
            //调用clean方法
            c.clean();
            return true;
        }

        ...
        return true;
}

可以看到,tryHandlePendingReference的最终效果就是:如果有垃圾DirectBytebuffer对象,则调用对应的Cleaner#clean方法进行回收。clean方法在上面已经分析过了。

堆外内存的读写

public ByteBuffer put(byte x) {
       unsafe.putByte(ix(nextPutIndex()), ((x)));
       return this;
}

final int nextPutIndex() {                         
    if (position >= limit)
        throw new BufferOverflowException();
    return position++;
}

private long ix(int i) {
    return address + ((long)i << 0);
}

public byte get() {
    return ((unsafe.getByte(ix(nextGetIndex()))));
}

final int nextGetIndex() {                          // package-private
    if (position >= limit)
        throw new BufferUnderflowException();
    return position++;
}

读写的逻辑也比较简单,address就是构造方法中分配的native内存的起始地址。Unsafe的putByte/getByte都是native方法,就是写入值到某个地址/获取某个地址的值。

堆外内存的使用场景

适合长期存在或能复用的场景

堆外内存分配回收也是有开销的,所以适合长期存在的对象

适合注重稳定的场景

堆外内存能有效避免因GC导致的暂停问题。

适合简单对象的存储

因为堆外内存只能存储字节数组,所以对于复杂的DTO对象,每次存储/读取都需要序列化/反序列化,

适合注重IO效率的场景

用堆外内存读写文件性能更好

文件IO

关于堆外内存IO为什么有更好的性能这点展开一下。

BIO

BIO的文件写FileOutputStream#write最终会调用到native层的io_util.c#writeBytes方法

void
writeBytes(JNIEnv *env, jobject this, jbyteArray bytes,
           jint off, jint len, jboolean append, jfieldID fid)
{
    jint n;
    char stackBuf[BUF_SIZE];
    char *buf = NULL;
    FD fd;

    ...

    // 如果写入长度为0,直接返回0
    if (len == 0) {
        return;
    } else if (len > BUF_SIZE) {
        // 如果写入长度大于BUF_SIZE(8192),无法使用栈空间buffer
        // 需要调用malloc在堆空间申请buffer
        buf = malloc(len);
        if (buf == NULL) {
            JNU_ThrowOutOfMemoryError(env, NULL);
            return;
        }
    } else {
        buf = stackBuf;
    }

    // 复制Java传入的byte数组数据到C空间的buffer中
    (*env)->GetByteArrayRegion(env, bytes, off, len, (jbyte *)buf);
    
     if (!(*env)->ExceptionOccurred(env)) {
        off = 0;
        while (len > 0) {
            fd = GET_FD(this, fid);
            if (fd == -1) {
                JNU_ThrowIOException(env, "Stream Closed");
                break;
            }
            //写入到文件,这里传递的数组是我们新创建的buf
            if (append == JNI_TRUE) {
                n = (jint)IO_Append(fd, buf+off, len);
            } else {
                n = (jint)IO_Write(fd, buf+off, len);
            }
            if (n == JVM_IO_ERR) {
                JNU_ThrowIOExceptionWithLastError(env, "Write error");
                break;
            } else if (n == JVM_IO_INTR) {
                JNU_ThrowByName(env, "java/io/InterruptedIOException", NULL);
                break;
            }
            off += n;
            len -= n;
        }
    }
}

GetByteArrayRegion其实就是对数组进行了一份拷贝,该函数的实现在jni.cpp宏定义中,找了很久才找到

//jni.cpp
JNI_ENTRY(void, \
jni_Get##Result##ArrayRegion(JNIEnv *env, ElementType##Array array, jsize start, \
             jsize len, ElementType *buf)) \
 ...
      int sc = TypeArrayKlass::cast(src->klass())->log2_element_size(); \
      //内存拷贝
      memcpy((u_char*) buf, \
             (u_char*) src->Tag##_at_addr(start), \
             len << sc);                          \
...
  } \
JNI_END

可以看到,传统的BIO,在native层真正写文件前,会在堆外内存(c分配的内存)中对字节数组拷贝一份,之后真正IO时,使用的是堆外的数组。要这样做的原因是

1.底层通过write、read、pwrite,pread函数进行系统调用时,需要传入buffer的起始地址和buffer count作为参数。如果使用java heap的话,我们知道jvm中buffer往往以byte[] 的形式存在,这是一个特殊的对象,由于java heap GC的存在,这里对象在堆中的位置往往会发生移动,移动后我们传入系统函数的地址参数就不是真正的buffer地址了,这样的话无论读写都会发生出错。而C Heap仅仅受Full GC的影响,相对来说地址稳定。

2.JVM规范中没有要求Java的byte[]必须是连续的内存空间,它往往受宿主语言的类型约束;而C Heap中我们分配的虚拟地址空间是可以连续的,而上述的系统调用要求我们使用连续的地址空间作为buffer。

以上内容来自于 知乎 ETIN的回答 https://www.zhihu.com/question/60892134/answer/182225677

BIO的文件读也一样,这里就不分析了。

NIO

NIO的文件写最终会调用到IOUtil#write

 static int write(FileDescriptor fd, ByteBuffer src, long position,
                     NativeDispatcher nd, Object lock)
        throws IOException
    {
        //如果是堆外内存,则直接写
        if (src instanceof DirectBuffer)
            return writeFromNativeBuffer(fd, src, position, nd, lock);

        // Substitute a native buffer
        int pos = src.position();
        int lim = src.limit();
        assert (pos <= lim);
        int rem = (pos <= lim ? lim - pos : 0);
        //创建一块堆外内存,并将数据赋值到堆外内存中去
        ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
        try {
            bb.put(src);
            bb.flip();
            // Do not update src until we see how many bytes were written
            src.position(pos);

            int n = writeFromNativeBuffer(fd, bb, position, nd, lock);
            if (n > 0) {
                // now update src
                src.position(pos + n);
            }
            return n;
        } finally {
            Util.offerFirstTemporaryDirectBuffer(bb);
        }
    }
    
    /**
     * 分配一片堆外内存
     */
    static ByteBuffer getTemporaryDirectBuffer(int size) {
        BufferCache cache = bufferCache.get();
        ByteBuffer buf = cache.get(size);
        if (buf != null) {
            return buf;
        } else {
            // No suitable buffer in the cache so we need to allocate a new
            // one. To avoid the cache growing then we remove the first
            // buffer from the cache and free it.
            if (!cache.isEmpty()) {
                buf = cache.removeFirst();
                free(buf);
            }
            return ByteBuffer.allocateDirect(size);
        }
    }

   

可以看到,NIO的文件写,对于堆内内存来说也是会有一次额外的内存拷贝的。

End

堆外内存的分析就到这里结束了,JVM为堆外内存做这么多处理,其主要原因也是因为Java毕竟不是像C这样的完全由开发者管理内存的语言。因此即使使用堆外内存了,JVM也希望能在合适的时候自动的对堆外内存进行回收。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 210,978评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 89,954评论 2 384
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,623评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,324评论 1 282
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,390评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,741评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,892评论 3 405
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,655评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,104评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,451评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,569评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,254评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,834评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,725评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,950评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,260评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,446评论 2 348

推荐阅读更多精彩内容