在现代操作系统中,多任务已是标配。多任务并行,大大提升了 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 使用虚拟地址空间,大大增加了进程的寻址空间,由低地址到高地址分别为:
malloc/new
大部分都来源于此。其中堆顶的位置可通过函数 brk
和 sbrk
进行动态调整。(brk
和sbrk
是系统调用,malloc
是C函数库提供的API。)如何查看进程发生缺页中断的次数?
用ps -o majflt,minflt -C program命令查看。
majflt代表major fault,中文名叫大错误,minflt代表minor fault,中文名叫小错误。
这两个数值表示一个进程自启动以来所发生的缺页中断的次数。
发成缺页中断后,执行了那些操作?
当一个进程发生缺页中断的时候,进程会陷入内核态,执行以下操作:
检查要访问的虚拟地址是否合法
查找/分配一个物理页(这说明是一页一页分配,如果连续1M内存访问都缺页,那么就中断1M/4k 次)
填充物理页内容(读取磁盘,或者直接置0,或者啥也不干)
建立映射关系(虚拟地址到物理地址)
重新执行发生缺页中断的那条指令 如果第3步,需要读取磁盘,那么这次缺页中断就是majflt,否则就是minflt。
从操作系统角度来看,进程分配内存有两种方式,分别由两个系统调用完成:brk和mmap(不考虑共享内存)。
这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。
在标准C库中,提供了malloc/free函数分配释放内存,这两个函数底层是由brk,mmap,munmap这些系统调用实现的。
一个具体的内存分配例子图解
既然堆内碎片不能直接释放,导致疑似“内存泄露”问题,为什么 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传统方法的调用如下
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
这是一个从磁盘文件中读取并且通过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从内存中进行数据的读写过程,这种拷贝过程相对来说比较消耗系统资源。
底层mmap的实现对应到Java层中FileChannel
的map
方法,但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,但是用户态到内核态的切换(上下文切换)依旧有四次
底层sendfile的实现对应到Java层中FileChannel
的transferTo
方法,但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);
}
这段核心代码包含了我们所描述的三种数据传输方式:
为了让我们的主线更清晰,我们还是回归到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(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中的逻辑分为两个部分:
直接调用writeFromNativeBuffer
;先通过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;
同样的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调用时传递的直接内存地址来帮我们直接操作堆外内存,也就减少了我们正常方式中需要 将数据从进程内存拷贝到 内核内存的流程
而DirectByteBuffer
比HeapByteBuffer
的效率高,因为在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机制来销毁堆外内存,防止内存泄漏。
这两个在java层,都可以减少一次堆内数据到堆外内存的拷贝
在操作系统层,都减少了进程内存向内核内存的拷贝。
只不过他们底层的系统调用不同,而宏观上看MappedByteBuffer类是Java层提供给开发人员对文件映射内存访问和操作的统一视图,它适用于访问磁盘上文件的场景; 而DirectByteBuffer适用于Java应用层创建的直接内存;
引用:
Java零拷贝续系列