1.什么是DMA
2.什么是用户态和内核态
3.普通BIO的拷贝流程分析
4.mmap系统函数
5.sendFile系统函数(零拷贝)
6.java堆外内存如何回收
1.什么是DMA
DMA(Direct Memory Access直接存储器访问),我们先从一张图来了解一下DMA是一个什么装置。
假设在什么没有DMA的情况下,如果CPU想从内存里读取数据并发送到网卡中,在读的过程中,我们可以知道:
1.1)CPU的速度最快。
1.2)当CPU在内存中读取数据的时候,读取的速度瓶颈在于内存的读写速度。
1.3)当CPU完成读取,将数据写入网卡的时候,写入的速度瓶颈在于网卡的速度。
1.4)CPU在读写的时候,是无法做其它事情的。
这个时候我们就可以得出结论:
1.5)cpu的速度取决于这一系列操作环上最慢的那一个。
1.6)cpu利用率极低,大部分时间都在等待IO。
此时如果有了DMA,那么我们的读写就会变得和如图一样:
CPU只需要把读写任务委托给DMA,协助CPU搬运数据,这些操作都由DMA自动执行,而不需要依赖于CPU的大量中断负载,此时cpu就可以去做其它的事情了。
2.什么是用户态和内核态
其实最后我们的服务器程序,都是要在linux上运行的,Linux根据命令的重要程度,也分为不同的权限。Linux操作系统就将权限分成了2个等级,分别就是用户态和内核态。
用户态:
用户态的进程能够访问的资源就有极大的限制,有些指令操作无关痛痒,随意执行都没事,大部分都是属于用户态的。
内核态:
运行在内核态的进程可以“为所欲为”,可以直接调用操作系统内核函数。
比如:我们调用malloc函数申请动态内存,此时cpu就要从用户态切换到内核态,调用操作系统底层函数来申请空间。
3.普通BIO的拷贝流程分析
我们来看一下普通IO的拷贝流程:
我们先从服务器上读取了一个文件,然后通过连接和流传输到请求客户端上,我们可以看到大致的请求流程是这样的:
当程序或者操作者对CPU发出指令,这些指令和数据暂存在内存里,在CPU空闲时传送给CPU,CPU处理后把结果输出到输出设备上
3.1)用户态程序接到请求,要从磁盘上读取文件,切换到内核态,这里是第1次用户态内核态切换。
3.2)当要读取的文件通过DMA复制到内核缓冲区的时候,我们还要把这些数据传送给CPU,CPU之后再把这些数据送到输出设备上,这里是第1次cpu拷贝。
3.3)当内核态程序数据读取完毕,切换回用户态,这里是第2次内核态用户态切换。
3.4)当程序创建一个缓冲区,并将数据写入socket缓冲区,这里是第3次用户态内核态切换。
3.5)此时cpu要把数据拷贝到socket缓冲区,这里是第2次cpu拷贝。
3.6)完成所有操作之后,应用程序从内核态切换回用户态,继续执行后续操作(程序到此为止)。这里是第4次用户态内核态切换。
此时我们可以看出,传统的IO拷贝流程,经历了4次用户态和内核态的切换,进行了2次cpu复制,性能耗费巨大,我们有没有更节省资源的做法呢?
4.mmap系统函数
linux的底层内核函数mmap函数对底层进行了一个优化:
4.1)用户态程序接到请求,要从磁盘上读取文件,切换到内核态,这里是第1次用户态内核态切换。
4.2)当要读取的文件通过DMA复制到内核缓冲区完成,此时内核缓冲区,用户数据缓冲区共享一块物理内存空间,这里就无需cpu拷贝到用户空间中。
4.3)此时读取文件完毕,用户切换回用户态,这是第2次用户态内核态切换。
4.4)申请一块缓冲区,需要调用内核函数,这是第3次用户态内核态切换。
4.5)内核态通过cpu复制,将共享空间的数据内容拷贝到socket缓冲区中,这是第1次cpu拷贝。
4.6)完成所有操作之后,应用程序从内核态切换回用户态,继续执行后续操作(程序到此为止)。这里是第4次用户态内核态切换。
我们可以看出,mmap函数少了一次cpu复制,对于空间的利用率提高了,不过还是需要4次用户态和内核态的切换。
5.sendFile系统函数(零拷贝)
零拷贝:指的是没有cpu拷贝,数据还是需要通过DMA拷贝到内存中,再发送出去的。
4.1)用户态程序接到请求,要从磁盘上读取文件,切换到内核态,这里是第1次用户态内核态切换。
4.2)当数据通过DMA复制进入内核缓冲区并且完成,我们还是通过cpu复制把数据复制到socket缓冲区,不过这里的cpu复制只复制很少量的内容,可以几乎忽略不计。
4.3)此时数据通过DMA复制发送给目的地。
4.4)程序切换回用户态,这是第2次用户态内核态切换。
我们发现,sendFile系统函数,只需要两次用户态到内核态的切换,而且一次cpu复制都不需要,大大节约了资源。
6.java堆外内存如何回收
介绍了零拷贝技术,其实Netty底层是使用堆外内存来实现零拷贝技术的,api:ByteBuffer.allocateDirect(),这条命令直接在堆外内存开辟了一块空间,我们都知道GC是收集堆内存垃圾的,那堆外内存又是如何收集的呢?
堆外内存的优势:
堆外内存的优势在于IO上,java在使用socket发送数据的时候,如果使用堆外内存,就可以直接使用堆外内存往socket上发送数据,就节省了先把堆外数据拷贝到堆内数据的开销。
我们先来看看ByteBuffer.allocateDirect()的源码:
我们可以看出,java使用unsafe类来分配了一块堆外内存
那么堆外内存是如何回收的呢?我们来看这样一行代码:
cleaner就是用来回收堆外内存的,但是它是如何工作的呢?我们仔细研究一下cleaner这个类,它是一个链表结构:
通过create(Object,Runnable)方法创建cleaner对象,调用自身的add方法,将其加入链表中。
clean有个重要的clean方法, 它首先将对象从自身链表中删除:
然后执行this.thunk的run方法,thunk就是由创建的时候传入的Runnable函数:
可以看出,run方法是一个释放堆外内存的函数。
逻辑我们已经梳理完,但是JVM如何释放其占用的堆外内存呢?如何跟Cleaner关联起来?
首先,Cleaner继承了PhantomReference(虚引用),关于强软弱虚引用,在前面的博客已经赘述过:深入理解JVM(八)——强软弱虚引用
简单地再介绍一下虚引用,当GC某个对象的时候,如果此对象上有虚引用,会将其加入PhantomReference加入到ReferenceQueue队列。
Cleaner继承PhantomReference,而PhantomReference又继承Reference,Reference初始化的时候,会运行一个静态代码块:
我们可以看出,ReferenceHandler作为一个优先级比较高的守护线程被启动了。
在看他的处理逻辑之前,我们先了解一下对象的四种状态;
- Active:激活。创建ref对象时就是激活状态
- Pending:等待入引用队列。所对应的引用被GC,就要入队。
Enqueued:入队状态。
- 如果指定了refQueue消费pending移动到enqueued状态。refQueue.poll时进入失效状态
- 如果没有指定refQueue,直接到失效状态。
- Inactive:失效
这是一个死循环,我们再往里点:
static boolean tryHandlePending(boolean waitForNotify) {
Reference
我们可以得出:
1)当对象状态是Pending的时候,就会进入if,将这个对象转化为clean对象,并将这个引用置空
2)进行clean的垃圾收集
3)这个线程一直在后台启动,如果有引用,就会唤醒该线程。