Linux 虚拟内存、Java直接内存和内存映射

Linux虚拟内存

在现代操作系统中,多任务已是标配。多任务并行,大大提升了 CPU 利用率,但却引出了多个进程对内存操作的冲突问题,虚拟内存概念的提出就是为了解决这个问题。

在这里插入图片描述

上图是虚拟内存最简单也是最直观的解释。

操作系统有一块物理内存(中间的部分),有两个进程(实际会更多)P1 和 P2,操作系统偷偷地分别告诉 P1 和 P2,我的整个内存都是你的,随便用,管够。可事实上呢,操作系统只是给它们画了个大饼,这些内存说是都给了 P1 和 P2,实际上只给了它们一个序号而已。只有当 P1 和 P2 真正开始使用这些内存时,系统才开始使用辗转挪移,拼凑出各个块给进程用,P2 以为自己在用 A 内存,实际上已经被系统悄悄重定向到真正的 B 去了,甚至,当 P1 和 P2 共用了 C 内存,他们也不知道。

操作系统的这种欺骗进程的手段,就是虚拟内存。对 P1 和 P2 等进程来说,它们都以为自己占用了整个内存,而自己使用的物理内存的哪段地址,它们并不知道也无需关心。

分页和页表

虚拟内存是操作系统里的概念,对操作系统来说,虚拟内存就是一张张的对照表,P1 获取 A 内存里的数据时应该去物理内存的 A 地址找,而找 B 内存里的数据应该去物理内存的 C 地址。

我们知道系统里的基本单位都是 Byte 字节,如果将每一个虚拟内存的 Byte 都对应到物理内存的地址,每个条目最少需要 8字节(32位虚拟地址->32位物理地址),在 4G 内存的情况下,就需要 32GB 的空间来存放对照表,那么这张表就大得真正的物理地址也放不下了,于是操作系统引入了 页(Page)的概念。

在系统启动时,操作系统将整个物理内存以 4K 为单位,划分为各个页。之后进行内存分配时,都以页为单位,那么虚拟内存页对应物理内存页的映射表就大大减小了,4G 内存,只需要 8M 的映射表即可,一些进程没有使用到的虚拟内存,也并不需要保存映射关系,而且Linux 还为大内存设计了多级页表,可以进一页减少了内存消耗。操作系统虚拟内存到物理内存的映射表,就被称为页表

内存寻址和分配

我们知道通过虚拟内存机制,每个进程都以为自己占用了全部内存,进程访问内存时,操作系统都会把进程提供的虚拟内存地址转换为物理地址,再去对应的物理地址上获取数据。CPU 中有一种硬件,内存管理单元 MMU(Memory Management Unit)专门用来将翻译虚拟内存地址。CPU 还为页表寻址设置了缓存策略,由于程序的局部性,其缓存命中率能达到 98%。

以上情况是页表内存在虚拟地址到物理地址的映射,而如果进程访问的物理地址还没有被分配,系统则会产生一个缺页中断,在中断处理时,系统切到内核态为进程虚拟地址分配物理地址。

注意,通常程序第一次调用 操作系统公共函数库分配的内存地址都是虚拟内存地址,此时并没有分配具体的物理内存,当程序第一次使用这个虚拟内存地址的时候,发现对应的地址在物理内存中不存在,则产生缺页中断。

并且,需要访问的内存含有多少个页,则产生多少次缺页中断(一页一页的映射和访问)

虚拟内存空间分布

Linux 使用虚拟地址空间,大大增加了进程的寻址空间,由低地址到高地址分别为:

  1. 只读段:该部分空间只能读,不可写;(包括:代码段、rodata 段(C常量字符串和#define定义的常量) )
  2. 数据段:保存全局变量、静态变量的空间;
  3. 堆 :就是平时所说的动态内存, malloc/new 大部分都来源于此。其中堆顶的位置可通过函数 brksbrk 进行动态调整。(brksbrk是系统调用,malloc是C函数库提供的API。)
  4. 文件映射区域 :如动态库、共享内存等映射物理空间的内存,一般是 mmap 函数所分配的虚拟地址空间。
  5. 栈:用于维护函数调用的上下文空间,一般为 8M ,可通过 ulimit –s 查看。
  6. 内核虚拟空间:用户代码不可见的内存区域,由内核管理(页表就存放在内核虚拟空间)。 下图是 32 位系统典型的虚拟地址空间分布(来自《深入理解计算机系统》)。

在这里插入图片描述

malloc和free是如何分配和释放内存?

如何查看进程发生缺页中断的次数?
用ps -o majflt,minflt -C program命令查看。
majflt代表major fault,中文名叫大错误,minflt代表minor fault,中文名叫小错误。
这两个数值表示一个进程自启动以来所发生的缺页中断的次数。

发成缺页中断后,执行了那些操作?
当一个进程发生缺页中断的时候,进程会陷入内核态,执行以下操作:

  1. 检查要访问的虚拟地址是否合法

  2. 查找/分配一个物理页(这说明是一页一页分配,如果连续1M内存访问都缺页,那么就中断1M/4k 次)

  3. 填充物理页内容(读取磁盘,或者直接置0,或者啥也不干)

  4. 建立映射关系(虚拟地址到物理地址)
    重新执行发生缺页中断的那条指令 如果第3步,需要读取磁盘,那么这次缺页中断就是majflt,否则就是minflt。

内存分配的原理

从操作系统角度来看,进程分配内存有两种方式,分别由两个系统调用完成:brk和mmap(不考虑共享内存)。

  1. brk是将数据段(.data)的最高地址指针_edata往高地址推;
  2. mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。

这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。

在标准C库中,提供了malloc/free函数分配释放内存,这两个函数底层是由brk,mmap,munmap这些系统调用实现的。

一个具体的内存分配例子图解

malloc(brk,sbrk)和mmap分配内存方式的比较

既然堆内碎片不能直接释放,导致疑似“内存泄露”问题,为什么 malloc 不全部使用 mmap 来实现呢(mmap分配的内存可以会通过 munmap 进行 free ,实现真正释放)?而是仅仅对于大于 128k 的大块内存才使用 mmap ?

其实,进程向 OS 申请和释放地址空间的接口 sbrk/mmap/munmap 都是系统调用,频繁调用系统调用都比较消耗系统资源的。并且, mmap 申请的内存被 munmap 后,重新申请会产生更多的缺页中断。例如使用 mmap 分配 1M 空间,第一次调用产生了大量缺页中断 (1M/4K 次 ) ,当munmap 后再次分配 1M 空间,会再次产生大量缺页中断。缺页中断是内核行为,会导致内核态CPU消耗较大。另外,如果使用 mmap 分配小内存,会导致地址空间的分片更多,内核的管理负担更大。

同时堆是一个连续空间,并且堆内碎片由于没有归还 OS ,如果可重用碎片,再次访问该内存很可能不需产生任何系统调用和缺页中断,这将大大降低 CPU 的消耗。 因此, glibc 的 malloc 实现中,充分考虑了 sbrk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128k) 才使用 mmap 获得地址空间,也可通过 mallopt(M_MMAP_THRESHOLD, ) 来修改这个临界值。

总结来说,brk分配内存和sbrk释放内存,是在虚拟地址空间的堆的最高地址往上推,但是这种方式需要连续的管理内存,也就是brk连续分配了 A,B,C三块内存后,如果free 了B,那么B其实是不会释放的(也就是不会调用sbrk),而是处于一种 内存泄漏或者内存碎片的情况,而free释放了C之后,发现C和B总共在最高地址空闲超过128k了,则执行内存紧缩(此时执行sbrk),将B和C的实际内存释放以及地址指针回撤。

而mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。可以被直接开辟和释放(mmap,munmap),但是由于mmap可能导致大量的缺页中断,并且如果用mmap创建小内存会产生很多的内存分片导致难以管理

所以mmap好在会直接的开辟和创建内存,不会产生内存泄漏和碎片,不好在会产生很多缺页中断或者内存分片

brk在对于小内存上,释放的时候可能产生内存碎片导致内存泄漏(浪费),但是对于一些可重用的碎片,即再次申请一个大小等于那个碎片大小的内存,则可以直接返回,并且因为页表都是现成的,物理内存也开辟好了,则不会产生任何的系统调用和缺页中断。(malloc和free并不一定会执行brk或者sbrk,对于可重用的内存,则不会执行系统调用brk,对于连续的内存,释放中间的块,也不会调用sbrk,只有一整块内存紧缩时才会调用sbrk)

Linux内存管理的基本思想之一,是只有在真正访问一个地址的时候才建立这个地址的物理映射。

mmap系统调用实现了更有用的动态内存分配功能,可以将一个磁盘文件的全部或部分内容映射到用户空间中,进程读写文件的操作变成了读写内存的操作。

Java 中的直接内存

三个场景

场景一:将一个文件通过网络发送出去

传统方式

java传统方法的调用如下

File.read(fileDesc, buf, len);

Socket.send(socket, buf, len);

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NtGR1QB8-1585815455152)(/Users/lvchentao/Desktop/cloudPoint/学习随笔/java/70.png)]

这是一个从磁盘文件中读取并且通过Socket写出的过程,对应的系统调用如下。

read(file, tmp_buf, len);
write(socket, tmp_buf, len);

程序使用read()系统调用,系统由用户态转换为内核态,磁盘中的数据由DMA(Direct memory access)的方式读取到内核读缓冲区(kernel buffer)。DMA过程中CPU不需要参与数据的读写,而是DMA处理器直接将硬盘数据通过总线传输到内存中。
系统由内核态转为用户态,当程序要读的数据已经完全存入内核读缓冲区以后,程序会将数据由内核读缓冲区,写入到用户缓冲区,这个过程需要CPU参与数据的读写。
程序使用write()系统调用,系统由用户态切换到内核态,数据从用户缓冲区写入到网络缓冲区(Socket Buffer),这个过程需要CPU参与数据的读写。
系统由内核态切换到用户态,网络缓冲区的数据通过DMA的方式传输到网卡的驱动(存储缓冲区)中(protocol engine)
可以看到,普通的拷贝过程经历了四次内核态和用户态的切换(上下文切换),两次CPU从内存中进行数据的读写过程,这种拷贝过程相对来说比较消耗系统资源。

java mmap

底层mmap的实现对应到Java层中FileChannelmap方法,但FileChannel实际上是一个抽象类,它的具体实现是FileChannelImpl

public MappedByteBuffer map(MapMode mode, long position, long size)

FileChannelImpl中map关键代码片段

int pagePosition = (int)(position % allocationGranularity);
long mapPosition = position - pagePosition;
long mapSize = size + pagePosition;
try {
    // If no exception was thrown from map0, the address is valid
    addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError x) {
    // An OutOfMemoryError may indicate that we've exhausted memory
    // so force gc and re-attempt map
    System.gc();
    try {
        Thread.sleep(100);
    } catch (InterruptedException y) {
        Thread.currentThread().interrupt();
    }
    try {
        addr = map0(imode, mapPosition, mapSize);
    } catch (OutOfMemoryError y) {
        // After a second OOME, fail
        throw new IOException("Map failed", y);
    }
}

该方法中调用了map0方法,但是map0方法是一个native方法

private native long map0(int prot, long position, long length)throws IOException;

map0方法的源代码位于openjdk中FileChannelImpl.c文件中(省略代码。。。)

我们可以发现map0最后调用了mmap64,这东东貌似与mmap很像呀,其实它是mmap的一个宏定义

#define mmap64 mmap

到此为止,我们就明白了FileChannel.map实际上是调用的linux中的mmap来实现文件映射的,不过到这里并未结束,我们回到FileChannelImple.map中调用map0的地方

addr = map0(imode, mapPosition, mapSize);

文件映射成功后返回映射的起始地址addr

后续使用这个addr和其他参数,反射生成了DirectByteBuffer实现类,所以最终创建的实例是DirectByteBuffer,但是返回的是MappedByteBuffer,MappedByteBuffer是DirectByteBuffer的父类。

也就是说,java mmap中最终在java堆中创建的对象是一个DirectByteBuffer,但是其系统调用发起的是mmap

总结:FileChannel.map是java层的提供的文件映射方法,最终返回的是MappedByteBuffer类,MappedByteBuffer类是Java层提供给开发人员对文件映射内存访问和操作的统一视图,它封装了基地址addr、映射的数据size、文件描述符mfd、内存回收时的回调Unmapper

这种方式有两次DMA copy,和一次的CPU copy,但是用户态到内核态的切换(上下文切换)依旧有四次

在这里插入图片描述

java的sendfile

底层sendfile的实现对应到Java层中FileChanneltransferTo方法,但FileChannel实际上是一个抽象类,它的具体实现是FileChannelImpl(该类的源码,需要在openjdk中查看)

public long transferTo(long position, long count, WritableByteChannel target)
复制代码

下面是transferTo的核心代码

public long transferTo(long position, long count,
                           WritableByteChannel target)
        throws IOException
{
    long n;

    // Attempt a direct transfer, if the kernel supports it
    if ((n = transferToDirectly(position, icount, target)) >= 0)
        return n;

    // Attempt a mapped transfer, but only to trusted channel types
    if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
        return n;

    // Slow path for untrusted targets
    return transferToArbitraryChannel(position, icount, target);
}

这段核心代码包含了我们所描述的三种数据传输方式:

  1. 如果系统支持sendfile的方式,那么transferToDirectly方法中会调用sendfile进行发送,这种方式效率是最高的;
  2. 否则transferToTrustedChannel中使用map的方式进行数据传输;
  3. 否则先将文件数据读到一个临时的DirectBuffer中,然后再将数据从这个临时的DirectBuffer写入到目标target中;

为了让我们的主线更清晰,我们还是回归到transferToDirectly的调用 transferToDirectly中调用transferTo0,它是一个native方法,截取核心代码片段如下:

JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
                                            jint srcFD,
                                            jlong position, jlong count,
                                            jint dstFD)
{
    result = sendfile(srcFD, dstFD, position, &numBytes, NULL, 0);                                          
}

可以发现transferTo最终调用的是sendfile系统接口。

在这里插入图片描述
依旧是系统调用sendfile()

sendfile(socket, file, len);

在 Linux 内核 2.4 及后期版本中,针对套接字缓冲区描述符做了相应调整,支持了DMA自带了收集功能,对于用户方面,用法还是一样的,但是内部操作已经发生了改变。

可以看到,这是真正意义上的零拷贝,因为其间CPU已经不参与数据的拷贝过程,当然这样的过程需要硬件的支持才能实现。

借助于硬件上的帮助,我们是可以办到的。之前我们是把页缓存的数据拷贝到socket缓存中,实际上,我们仅仅需要把缓冲区描述符传到socket缓冲区,再把数据长度传过去,这样DMA控制器直接将页缓存中的数据打包发送到网络中就可以了

系统调用sendfile()发起后,磁盘数据通过DMA方式读取到内核缓冲区,内核缓冲区中的数据通过DMA聚合网络缓冲区,然后一齐发送到网卡中。
可以看到在这种模式下,是没有一次CPU进行数据拷贝的,所以就做到了真正意义上的零拷贝(两次DMA copy,两次用户态和内核态切换)

总结:transferTo(带有DMA收集拷贝功能的sendfile)它与mmap的区别就是少了两次应用程序与内核之间上下文切换和1次CPU拷贝,但是它们的使用场景是不一样的:

  • transferTo:适用于应用程序无需对文件数据进行任何操作的场景;
  • map:适用于应用程序需要操作文件数据的场景;

场景二:将应用程序中的内存中的数据通过网络发送出去(非磁盘上的)

场景一与场景二的主要区别就是文件数据和内存中的数据的区别,现在我们需要去阅读以下Nio中ChannelSocket的源码, 同样的ChannelSocket的实现是SocketChannelImpl(也是在OpenJDK中),它的write方法有两个:

public int write(ByteBuffer buf)
public long write(ByteBuffer[] srcs, int offset, int length)    

我们主要关注public int write(ByteBuffer buf)方法

public int write(ByteBuffer buf) throws IOException {
        if (buf == null)
            throw new NullPointerException();
        synchronized (writeLock) {
            ensureWriteOpen();
            int n = 0;
            try {
                begin();
                synchronized (stateLock) {
                    if (!isOpen())
                        return 0;
                    writerThread = NativeThread.current();
                }
                for (;;) {
                    n = IOUtil.write(fd, buf, -1, nd);
                    if ((n == IOStatus.INTERRUPTED) && isOpen())
                        continue;
                    return IOStatus.normalize(n);
                }
            } finally {
                writerCleanup();
                end(n > 0 || (n == IOStatus.UNAVAILABLE));
                synchronized (stateLock) {
                    if ((n <= 0) && (!isOutputOpen))
                        throw new AsynchronousCloseException();
                }
                assert IOStatus.check(n);
            }
        }
    }

该方法的核心是IOUtil.write(fd, buf, -1, nd);

static int write(FileDescriptor fd, ByteBuffer src, long position,
                     NativeDispatcher nd)
        throws IOException
{
    if (src instanceof DirectBuffer)
        return writeFromNativeBuffer(fd, src, position, nd);

    // 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);
        if (n > 0) {
            // now update src
            src.position(pos + n);
        }
        return n;
    } finally {
        Util.offerFirstTemporaryDirectBuffer(bb);
    }
}

IOUtil.write中的逻辑分为两个部分:

  1. 如果src为DirectBuffer,那么就直接调用writeFromNativeBuffer
  2. 否则src为一个HeapBuffer,先通过getTemporaryDirectBuffer创建一个临时的DirectBuffer,然后将HeapBuffer中的数据拷贝到这个临时的DirectBuffer,最后再调用writeFromNativeBuffer发送数据
private static int writeFromNativeBuffer(FileDescriptor fd, ByteBuffer bb,
                                             long position, NativeDispatcher nd)
        throws IOException
{
    int pos = bb.position();
    int lim = bb.limit();
    assert (pos <= lim);
    int rem = (pos <= lim ? lim - pos : 0);

    int written = 0;
    if (rem == 0)
        return 0;
    if (position != -1) {
        written = nd.pwrite(fd,
                            ((DirectBuffer)bb).address() + pos,
                            rem, position);
    } else {
        written = nd.write(fd, ((DirectBuffer)bb).address() + pos, rem);
    }
    if (written > 0)
        bb.position(pos + written);
    return written;
}

调用nd.write,这个nd其实是SocketDispatcher,SocketDispatcher的write方法

int write(FileDescriptor fd, long address, int len) throws IOException {
        return FileDispatcherImpl.write0(fd, address, len);
}
//这里的address,就是直接内存的地址

最终调用了FileDescriptor的write0,write0是一个native方法

static native int write0(FileDescriptor fd, long address, int len)
        throws IOException;

场景三:从网络读数据到Java应用程序

同样的ChannelSocket的实现是SocketChannelImpl(也是在OpenJDK中),它的read方法也有两个:

public int read(ByteBuffer buf)
public long read(ByteBuffer[] dsts, int offset, int length)   

我们主要关注public int read(ByteBuffer buf)方法,该方法的核心是调用IOUtil.read方法:

n = IOUtil.read(fd, buf, -1, nd);

static int read(FileDescriptor fd, ByteBuffer dst, long position,
                    NativeDispatcher nd)
        throws IOException
    {
        if (dst.isReadOnly())
            throw new IllegalArgumentException("Read-only buffer");
        if (dst instanceof DirectBuffer)
            return readIntoNativeBuffer(fd, dst, position, nd);

        // Substitute a native buffer
        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);
        }
    }

我们发现它和write方法类似,中间都要经过DirectByteBuffer,不同的是它的方向是read(Linux->DirectByteBuffer->HeapByteBuffer)

同样的如果你去阅读FileChannelImpl的write和read方法,它们同样是调用IOUtil.write和IOUtil.read方法。

之所以要将场景二和场景三进行分析,是为了引出Java中的堆内内存HeapByteBuffer和堆外内存DirectByteBuffer。 通过上面的的分析,可以确定DirectByteBuffer比HeapByteBuffer的效率高,因为在SocketChannelImpl的分析中,它的拷贝少了一次。

为什么nio的实现中,如果发现缓冲区用的是HeapByteBuffer,必须要中转到DirectByteBuffer?

原因1是nio底层默认使用的是传地址系统调用的方式,也就是让内核访问一个地址,从而将地址上的数据发走,而heap数据如果要实现这个,则必须地址不变才行,会影响gc

那为什么不用传统的方式,如 Socket.getOutputStream,然后通过输出流写到内核发走呢?反正都是一次内存拷贝,只不过bio是从进程直接拷贝到内核了?

原因是 bio的实现除了要内核拷贝之外,还需要同步等待io结束,io相对于内存拷贝是一个较慢 的过程

而HeapByteBuffer到DirectByteBuffer是进程自身内存的拷贝,甚至不用上下文切换,所以性能消耗可以接受。

注意:

其实nio中发出的可读事件或者可写事件,其实本身这只是个事件,返回了可读的socket

判断有事件后,我们调用read方法,其实这个方法仍然是阻塞的,所以这时候read系统调用的时候使用 一个直接内存,可以减少一步从内核到进程空间的内存拷贝。

原理同mmap很像,只是系统调用不同。

多路复用没有解决read系统调用的阻塞问题,因为这个调用从socket中读取数据到内存仍然是阻塞的,其实epoll的最大用处就是可以减少线程数,减少线程切换,只对有事件的fd 阻塞,是有的放矢。

总结

直接内存,或者堆外内存的本质是什么?

直接内存(通过malloc分配的),或者内存映射(mmap分配),其实在java中的对象都是DirectByteBuffer,但是底层的系统调用不一样

对于操作系统来说,他们分配的都是一个虚拟地址空间,并且通过页表映射到物理内存

而Java的 writeFromNative 或者readIntoNativeBuffer,本质是将一个 jvm进程的内存地址给内核操作,也就是说jvm发起了系统调用,内核由于权限最高,所以可以通过我们发起JNI调用时传递的直接内存地址来帮我们直接操作堆外内存,也就减少了我们正常方式中需要 将数据从进程内存拷贝到 内核内存的流程

DirectByteBufferHeapByteBuffer的效率高,因为在SocketChannelImpl的分析中,它的数据拷贝少了一次(默认都会创建直接内存用于数据传输)

为什么要使用直接内存

既然操作系统内核权限最高,为什么java不直接把堆内存中的数据地址给内核,让内核直接来操作,而是在HeapByteBuffer使用了一次拷贝,将数组从 堆内拷贝到 native memory?

如果要把一个Java里的 byte[] 对象的引用传给native代码,让native代码直接访问数组的内容的话,就必须要保证native代码在访问的时候这个 byte[] 对象不能被移动,也就是要被“pin”(钉)住。可惜HotSpot VM出于一些取舍而决定不实现单个对象层面的object pinning,要pin的话就得暂时禁用GC——也就等于把整个Java堆都给pin住。HotSpot VM对JNI的Critical系API就是这样实现的。这用起来就不那么顺手。

所以 Oracle/Sun JDK / OpenJDK 的这个地方就用了点绕弯的做法。它假设把 HeapByteBuffer 背后的 byte[] 里的内容拷贝一次是一个时间开销可以接受的操作,同时假设真正的I/O可能是一个很慢的操作。

于是它就先把 HeapByteBuffer 背后的 byte[] 的内容拷贝到一个 DirectByteBuffer 背后的native memory去,这个拷贝会涉及 sun.misc.Unsafe.copyMemory() 的调用,背后是类似 memcpy() 的实现。这个操作本质上是会在整个拷贝过程中暂时不允许发生GC的,虽然实现方式跟JNI的Critical系API不太一样。(具体来说是 Unsafe.copyMemory() 是HotSpot VM的一个intrinsic方法,中间没有safepoint所以GC无法发生)。

然后数据被拷贝到native memory之后就好办了,就去做真正的I/O,把 DirectByteBuffer 背后的native memory地址传给真正做I/O的函数。这边就不需要再去访问Java对象去读写要做I/O的数据了

而堆外内存在堆内是有一个对象来保留相关的基址,所以java中销毁DirectByteBuffer对象的时候,也可以通过 虚引用的Cleaner机制来销毁堆外内存,防止内存泄漏。

DirectByteBuffer和map都能减少一次数据的拷贝,它们有什么区别呢

这两个在java层,都可以减少一次堆内数据到堆外内存的拷贝

在操作系统层,都减少了进程内存向内核内存的拷贝。

只不过他们底层的系统调用不同,而宏观上看MappedByteBuffer类是Java层提供给开发人员对文件映射内存访问和操作的统一视图,它适用于访问磁盘上文件的场景; 而DirectByteBuffer适用于Java应用层创建的直接内存;

引用:
Java零拷贝续系列

发布了8 篇原创文章 · 获赞 4 · 访问量 2075

猜你喜欢

转载自blog.csdn.net/weixin_40864891/article/details/105272616
今日推荐