文章目录
本文通过一步步讲解预备知识最后引出零拷贝
DMA
DMA: direct memory access 直接内存拷贝(不使用 CPU)
1 要把内存数据发送到网卡然后发出去时:
没有DMA时候怎么办:CPU读内存数据到CPU的高速缓存,再写到网卡。这样就把CPU的速度拉低到和网卡一个速度。
有了DMA:把内存数据读到socket内核缓存区(CPU复制),CPU就不管了,告诉DMA开始接管。DMA开始把内核缓冲区的数据写到网卡。DMA读socket缓冲区,读到DMA缓冲区,然后写到网卡中。不停写到网卡。
DMA发送完后,DMA中断CPU,(这样CPU就知道socket内核缓冲区又空出来了),CPU从用户态切换到内核态,执行中断处理程序,将socket缓冲区阻塞的进程移回到运行队列。
比如要发送的数据是100k,但是内核缓冲区就50k,这样第二次50k也能发出去了。
2 读硬件时:
CPU检查内核缓冲区里是否有指定的数据,直接就可以读。如果没有,CPU就交给DMA,DMA负责把硬盘读到缓冲区,然后告诉CPU移动完了,然后把阻塞的进程再移动到运行队列。
3 状态切换:
- 用户空间:用户代码、用户堆栈
- 内核空间:内核代码、内核调度程序、进程描述符(内核堆栈、thread_info进程描述符)
- 进程描述符和用户的进程是一一对应的
- SYS_API:系统调用,如read、write
- 进程描述符:进程从用户态切换到内核态时,需要保存用户态时的上下文信息,比如:用户程序基地址,程序计数器、cpu cache、寄存器。。。方便程序从内核态切换回用户态时恢复现场。
- 内核堆栈:系统调用函数也是要创建变量的,这些变量在内核堆栈上分配,
系统调用
系统调用:比如用户想要读取硬盘上的文件,发起read调用,这个read只是内核态的库函数api,该库函数会发起系统调用。
该库函数里面有80中断,软中断,进程切换到内核态。
到cpu里存一个系统调用号(表示哪个系统函数,比如read)。把cpu的临时数据都保存到thread_info中(恢复到用户态时用),
然后执行80中断处理程序,找到刚刚存的系统调用号(比如read),先检查缓存中有没有对应的数据,没有就去磁盘中加载到内核缓冲区,然后从内核缓冲区拷贝到用户空间,
然后恢复到用户态,恢复现场,用户态就知道从哪继续执行。
内核缓冲区读写:
- 读:用户态切换到内核态,先看内核态缓冲区有没有,有就直接读到,没有就交给DMA去读。DMA控制器从磁盘、网卡、其他IO设备中读。CPU在这个期间可以执行其他进程。DMA加载到内核缓冲区后告诉CPU,CPU把数据拷贝到用户态,CPU把该进程从阻塞队列移到到运行队列。
- 写:缓存区满了之后,写操作阻塞,缓冲区有一个等待队列,记录阻塞的进程(java的轻量级进程),DMA把缓冲区数据写到网卡后告诉CPU,中断CPU,把该进程移动到运行队列。
虚拟内存:
物理内存:类似于大的数组,可以随机读取。
以前单核计算机时只需要保证保证不写内核空间即可。多核计算机引入多进程后,每个进程有自己的用户空间,得防止不能访问其他进程空间。所以引入了虚拟内存。进程分配了虚拟内存,CPU MMU单元可以帮助完成虚拟内存到物理内存的映射。虚拟内存可以大于真实物理内存,MMU可以把不常用的东西从物理内存放到磁盘上(swap区)。因为可以替换,所以不同进程的虚拟内存空间的地址可以映射到用一个物理内存。利用这个特性,可以把用户空间和内核空间的地址翻译为同一块物理内存地址,就可以减少拷贝,即零拷贝的技术。
零拷贝:
零拷贝基本介绍:
- 零拷贝是网络编程的关键,很多性能优化都离不开。
- 在 Java 程序中,常用的零拷贝有
mmap(内存映射)
和sendFile
。 - 另外我们看下 NIO 中如何使用零拷贝
零拷贝是为了不要在内核缓冲区和用户空间之间拷贝

传统IO
从硬盘读到网卡,从左下到右下。进程去读,先看内核缓冲区有没有,没有就告诉DMA去读到内核缓冲区,然后把进程放到内核的阻塞队列中,DMA读好后发起中断告诉CPU,CPU唤醒阻塞进程,从内核缓冲区读到用户数据缓冲区,然后再切换到内核态进程写操作,写到socket缓冲区后,告诉DMA把socket缓冲区的数据写到网卡。复制了4次,进程切换了4次。
- JVM发出read() 系统调用。
- OS上下文切换到内核模式(第一次上下文切换)并将数据从网卡或硬盘等通过DMA读取到内核空间缓冲区。(第一次拷贝:hardware ----> kernel buffer)
- OS内核然后将数据复制到用户空间缓冲区(第二次拷贝: kernel buffer ——> user buffer),然后read系统调用返回。而系统调用的返回又会导致一次内核空间到用户空间的上下文切换(第二次上下文切换)。
- JVM处理代码逻辑并发送write()系统调用。
- OS上下文切换到内核模式(第三次上下文切换)并从用户空间缓冲区复制数据到内核空间缓冲区(第三次拷贝: user buffer ——> kernel buffer)。
- write系统调用返回,导致内核空间到用户空间的再次上下文切换(第四次上下文切换)。将内核空间缓冲区中的数据写到hardware(第四次拷贝: kernel buffer ——> hardware)
总的来说,传统的I/O操作进行了4次用户空间与内核空间的上下文切换,以及4次数据拷贝。显然在这个用例中,从内核空间到用户空间内存的复制是完全不必要的,因为除了将数据转储到不同的buffer之外,我们没有做任何其他的事情。所以,我们能不能直接从hardware读取数据到kernel buffer后,再从kernel buffer写到目标地点不就好了。为了解决这种不必要的数据复制,操作系统出现了零拷贝的概念。
// Java 传统 IO 和 网络编程的一段代码
File file = new File("test.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");
byte[] arr = new byte[(int) file.length()];
raf.read(arr);
Socket socket = new ServerSocket(8080).accept();
socket.getOutputStream().write(arr);
注意,不同的操作系统对零拷贝的实现各不相同。在这里我们介绍linux下的零拷贝实现。
①mmap零拷贝
MMAP
:DMA把磁盘上的文件映射到内存,用户空间和内核空间共享同一块物理地址,这样就无需进程用户空间和内核空间的来回复制。写到网卡的时候,共享空间的内容拷贝到socket缓冲区(CPU复制),然后告诉DMA发送到网卡。3次复制(2次DMA,一次CPU复制)
mmap 通过内存映射,将 文件映射到内核缓冲区,同时, 用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户空间的拷贝次数。
- 发出mmap系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive ——> kernel buffer)。
- mmap系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。接着用户空间和内核空间共享这个缓冲区,而不需要将数据从内核空间拷贝到用户空间。因为用户空间和内核空间共享了这个缓冲区数据,所以用户空间就可以像在操作自己缓冲区中数据一般操作这个由内核空间共享的缓冲区数据。
- 发出write系统调用,导致用户空间到内核空间的上下文切换(第三次上下文切换)。将数据从内核空间缓冲区拷贝到内核空间socket相关联的缓冲区(第二次拷贝: kernel buffer ——> socket buffer)。
- write系统调用返回,导致内核空间到用户空间的上下文切换(第四次上下文切换)。通过DMA引擎将内核空间socket缓冲区中的数据传递到协议引擎(第三次拷贝: socket buffer ——> protocol engine)
mmap 示意图
3次拷贝 3次切换
②sendfile零拷贝
sendfile
:打开文件的fd+socket的fd告诉sendfile,也是经过和上面一样的3次复制。不过只进行了2次用户态和内核态的切换
- 发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard driver ——> kernel buffer)。
- 然后再将数据从内核空间缓冲区拷贝到内核中与socket相关的缓冲区中(第二次拷贝: kernel buffer ——> socket buffer)。
- sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。通过DMA引擎将内核空间socket缓冲区中的数据传递到协议引擎(第三次拷贝: socket buffer ——> protocol engine)。
通过sendfile实现的零拷贝I/O只使用了2次用户空间与内核空间的上下文切换,以及3次数据的拷贝。
你可能会说操作系统仍然需要在内核内存空间中复制数据(kernel buffer —>socket buffer)。 是的,但从操作系统的角度来看,这已经是零拷贝,因为没有数据从内核空间复制到用户空间。 内核需要复制的原因是因为通用硬件DMA访问需要连续的内存空间(因此需要缓冲区)。 但是,如果硬件支持scatter-and-gather,这是可以避免的。
-
Linux 2.1 版本 提供了
sendFile
函数,其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换 -
示意图和小结

-
提示:零拷贝从操作系统角度,是没有 cpu 拷贝
-
Linux 在 2.4 版本中,做了一些修改,避免了从 内核缓冲区(左面)拷贝到 Socket buffer 的操作,直接从内核缓冲区拷贝到协议栈,从而再一次减少了数据拷贝。具体如下图和小结:
- 发出sendfile系统调用,导致用户空间到内核空间的上下文切换(第一次上下文切换)。通过DMA引擎将磁盘文件中的内容拷贝到内核空间缓冲区中(第一次拷贝: hard drive ——> kernel buffer)。
- 没有数据拷贝到socket缓冲区。取而代之的是只有相应的描述符信息会被拷贝到相应的socket缓冲区当中。该描述符包含了两方面的信息:
- a) kernel buffer的内存地址;
- b) kernel buffer的偏移量。
- sendfile系统调用返回,导致内核空间到用户空间的上下文切换(第二次上下文切换)。DMA gather copy根据socket缓冲区中描述符提供的位置和偏移量信息直接将内核空间缓冲区中的数据拷贝到协议引擎上(第二次拷贝: kernel buffer ——> protocol engine),这样就避免了最后一次CPU数据拷贝。

- 这里其实有 一次 cpu 拷贝:
kernel buffer -> socket buffer
但是,拷贝的信息很少,比如 lenght , offset , 消耗低,可以忽略
零拷贝总结
-
我们说零拷贝,是从 操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据)。
-
零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。
mmap 和 sendFile 的区别
- mmap 适合小数据量读写,sendFile 适合大文件传输。
- mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝。
- sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)。
NIO的buffer:
NIO的buffer的作用是堆外不影响GC:
buffer数组,NIO把byte数组的位置和长度发给内核态,内核空间可以访问用户空间,这叫跨传输。如果发送GC,stw,线程都停止,会回收垃圾对象,会进行碎片整理,所以位置会变。NIO选择在堆外创建一个同样大小的buffer,先从用户空间拷贝到堆外空间(cpu拷贝),再发送write系统调用,这时候发送堆外的位置+长度,堆外是不发生GC的,然后再拷贝到内核缓冲区。NIO合理使用堆外内存可以避免堆内到堆外的一次拷贝,可以直接放到堆外buffer。
NIO使用了堆外空间:
Java NIO引入了用于通道的缓冲区的ByteBuffer。 ByteBuffer有三个主要的实现:
1 HeapByteBuffer
在调用ByteBuffer.allocate()时使用。 它被称为堆,因为它保存在JVM的堆空间中,因此你可以获得所有优势,如GC支持和缓存优化。 但是,它不是页面对齐的,这意味着如果你需要通过JNI与本地代码交谈,JVM将不得不复制到对齐的缓冲区空间。
2 DirectByteBuffer
在调用ByteBuffer.allocateDirect()时使用。 JVM将使用malloc()在堆空间之外分配内存空间。 因为它不是由JVM管理的,所以你的内存空间是页面对齐的,不受GC影响,这使得它成为处理本地代码的完美选择。 然而,你要C程序员一样,自己管理这个内存,必须自己分配和释放内存来防止内存泄漏。
3 MappedByteBuffer
在调用FileChannel.map()时使用。 与DirectByteBuffer类似,这也是JVM堆外部的情况。 它基本上作为OS mmap()系统调用的包装函数,以便代码直接操作映射的物理内存数据。
在NIO中使用了IOUtil.write()
,会判断是否是堆外内存if(var instanceof DirectBuffer){return writeFromNativeBuffer();}
,否则是堆内,Util.getTemporaryDirectorBuffer()
,创建临时堆外空间,大小和当前堆内buffer大小一样大,然后从堆内buffer拷贝到堆外buffer : var堆外.put(var堆内)
,然后writeFromNativeBuffer()
,从堆外写到内核缓冲区。
如何释放堆外空间:
合理使用堆外内存可以减少拷贝。但是堆外不受JVM管理,如何释放?JVM中根据可达性算法从根对象跟踪引用对象。JVM的栈里指向了一个堆内的对象,该堆内对象指向堆外内存,该堆内对象代理操作堆外内存。如果不可达了后,判断该代理对象成为垃圾,回收的时候会去释放堆外空间。
DirectByteBuffer.java:
构造函数中用unsafe.allocate(size)
分配堆外内存,返回堆外地址base。unsafe.setMemory(base,size,0)
将该堆外空间初始化置位0。然后创建Cleaner对象Cleaner.create(this堆外内存引用,new Deallocator(base,size,cap))
负责清理堆外内存。里面传了个Deallocator释放器,他的run()里有unsafe.freeMemeory(address)
。
Cleaner继承了PhantomReference虚引用。
java引用
java的引用有:强、软、虚、弱四种引用。
栈里存的是栈帧(即方法),有的强引用直接指向obj,也有的强引用指向ref,ref实例存放到堆,ref再指向obj。这个ref指的是除了强引用外的引用,不是我们写的a = new A(),b=a;这还是强引用
软引用
Soft软引用情况下,强引用置位null时,gc时可能就释放了obj。
解释下图:不断分配空间给buffer,buffer原来指向的空间没有强引用了,但是还有软引用。只有软引用时,GC时先不回收,GC后发现内存还不足就回收
- 软引用不一定被回收
弱引用
Weak弱引用情况下,强引用置为null时,gc一定回收obj
ReferenceQueue refQueue = new ReferenceQueue();
buffer = new buffer();
// 弱引用
WeakReference weakRef = new WeakReference(buffer,refQueue);
buffer=null;// 失去强引用,只剩下了一个弱引用,
// GC之前`
weakReference.get();//可以拿到obj对象引用,
//但是GC之前引用队列refQueue里
Reference ref0 = refQueue.poll();//为null
System.gc();
Thread.sleep(1000);//确保GC执行
// GC之后
weakReference.get();//为null。
// GC之后refQueue中获取的
ref = refQueue.poll();// 和weakRef一致,即被回收以后就把weakRef加入到refQueue,
// 所以GC之后的
refQueue.poll()==weakRef ;
//也就是说这个引用队列知道谁被回收了,weakHashmap就是利用这种方式清空无效的kv对
虚引用
Phantom虚引用情况下,
-
通过虚引用get永远是null(不能通过虚引用获取对象),
-
当强引用变为null时,gc一定回收obj。
-
虚引用的作用是创建虚引用时,可以传给一个引用对象,当obj被GC时,就可以在引用队列里找到虚引用
-
-
构造方法必须传引用队列
RefQueue
。 -
GC之前引用队列里为null,get也返回null。GC之后refQueue.poll()有了虚引用,get还是null。源码中get都返回null:
PhantomRef.get(){return null;}
-
所以说虚引用只是利用了引用队列,来进行判断谁被回收掉了
所以说Ref所指向的对象再GC时,根可达性算法不管他,obj还是会被回收
引用的四种状态:
- Active:激活。创建ref对象时就是激活状态
- Pending:等待入队。所对应的强引用被GC,就要入队,是GC线程做的
- Enqueued:入队了。守护线程,
- 如果指定了refQueue消费pending移动到enqueued状态。refQueue.poll时进入失效状态
- 如果没有指定refQueue,直接到失效状态。
- Inactive:失效
public class Reference{
ref属性:保存真实对象引用。
queue属性:指定引用队列(虚引用必须指定)
next属性:指向下一个ref,单向链表
discovered属性:VM线程使用。来判定当前ref的真实对象是垃圾后,会将当前ref加入到pending队列,然后JM把discovered连接起来组成pending链表
static pending属性:是一个ref链表。所以说多个堆内对象的ref就可以组合到pending中。GC线程操作的
static{
handler = new ReferenceHandler();
handler.setDaemon(true);
handler.start();
}
}
private class ReferenceHandler extends Thread{
public void run(){
// Cleaner
// 为什么需要同步?
//1.jvm及收玺器线程能要向pending队列追加ref
//2.当前线程狴这个pending队列
// if(pending!=null){pending先进后出;判断当前元素是不是Cleaner,继承了虚引用;pending出队;}
// else{阻塞等待gc线程唤醒,gc向pending队列添加新ref之后会notify}
// if(Cleaner类型){c.clean(); return;}
// else{获取创建ref时指定的refQueue,如果指定了就入队;return;}
tryHandlePending(true);
}
}
回到之前NIO的堆外内存,就是利用虚引用,上面有一个c.clean()负责释放堆外空间(里面有deallocate释放器),不会执行refQueue入队操作,所以在cleaner里引用队列没有用,但是因为虚引用必须传引用队列,所以new了个static引用队列占坑。
然后NIO的channel与堆外buffer打交道,堆外buffer与内核缓冲区打交道。堆外是为了传buffer位置的时候不受GC垃圾整理影响。
java-NIO 零拷贝案例
案例要求:
- 使用传统的 IO 方法传递一个大文件
- 使用 NIO 零拷贝方式传递(transferTo)一个大文件
- 看看两种传递方式耗时时间分别是多少
//NewIOServer.java
package com.atguigu.nio.zerocopy;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
//服务器
public class NewIOServer {
public static void main(String[] args) throws Exception {
InetSocketAddress address = new InetSocketAddress(7001);
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
ServerSocket serverSocket = serverSocketChannel.socket();
serverSocket.bind(address);
//创建 buffer
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
int readcount = 0;
while (-1 != readcount) {
try {
readcount = socketChannel.read(byteBuffer);
}catch (Exception ex) {
// ex.printStackTrace();
break;
}
//
byteBuffer.rewind(); //倒带 position = 0 mark 作废
}
}
}
}
零拷贝客户端:transferTo 底层使用到零拷贝
//NewIOClient.java
package com.atguigu.nio.zerocopy;
import java.io.FileInputStream;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;
public class NewIOClient {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 7001));
String filename = "protoc-3.6.1-win32.zip";
//得到一个文件 channel
FileChannel fileChannel = new FileInputStream(filename).getChannel();
//准备发送
long startTime = System.currentTimeMillis();
//在 linux 下一个 transferTo 方法就可以完成传输
//在 windows 下 一次调用 transferTo 只能发送 8m , 就需要分段传输文件, 而且要主要
//传输时的位置 =》 课后思考...
//transferTo 底层使用到零拷贝
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel); // 实际使用中,要计算size后自己除以8m然后计算位置
System.out.println("发送的总的字节数 =" + transferCount + " 耗时:" + (System.currentTimeMillis() -
startTime));
//关闭
fileChannel.close();
}
}