网络通信编程
??Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口,一般由操作系统提供。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议处理和通信缓存管理等等都隐藏在Socket接口后面,对用户来说,使用一组简单的接口就能进行网络应用编程,让Socket去组织数据,以符合指定的协议。主机 A 的应用程序要能和主机 B 的应用程序通信,必须通过 Socket 建立连接。
??客户端连接上一个服务端,就会在客户端中产生一个socket接口实例,服务端每接受一个客户端连接,就会产生一个socket接口实例和客户端的socket进行通信,有多个客户端连接自然就有多个socket接口实例。,??连接->传输数据->保持连接 -> 传输数据-> 。。。 ->关闭连接。
??长连接指建立SOCKET连接后不管是否使用都保持连接。,??连接->传输数据->关闭连接
??传统HTTP是无状态的,浏览器和服务器每进行一次HTTP操作,就建立一次连接,但任务结束就中断连接。
??也可以这样说:短连接是指SOCKET连接后发送后接收完数据后马上断开连接。,??长连接多用于操作频繁,点对点的通讯。每个TCP连接都需要三步握手,这需要时间,如果每个操作都是先连接,再操作的话那么处理速度会降低很多,所以每个操作完后都不断开,下次处理时直接发送数据包就OK了,不用建立TCP连接。例如:数据库的连接用长连接, 如果用短连接频繁的通信会造成socket错误,而且频繁的socket 创建也是对资源的浪费。
??而像WEB网站的http服务按照Http协议规范早期一般都用短链接,因为长连接对于服务端来说会耗费一定的资源,而像WEB网站这么频繁的成千上万甚至上亿客户端的连接用短连接会更省一些资源。但是现在的Http协议,Http1.1,尤其是Http2、Http3已经开始向长连接演化。,??所有模式的通信编程都是围绕着连接(客户端连接服务器,服务器等待和接收连接)、读网络数据、写网络数据进行的。,BIO,意为Blocking I/O,即阻塞的I/O。
??在BIO中类ServerSocket负责绑定IP地址,启动监听端口,等待客户连接;客户端Socket类的实例发起连接操作,ServerSocket接受连接后产生一个新的服务端socket实例负责和客户端socket实例通过输入和输出流进行通信。
,??BIO的阻塞,主要体现在两个地方:
①若一个服务器启动就绪,那么主线程就一直在等待着客户端的连接,这个等待过程中主线程就一直在阻塞。
②在连接建立之后,在读取到socket信息之前,线程也是一直在等待,一直处于阻塞的状态下的。
??传统BIO通信模型:采用BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成后,通过输出流返回应答给客户端,线程销毁。即典型的一请求一应答模型,同时数据的读取写入也必须阻塞在一个线程内等待其完成。
??该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈1:1的正比关系,Java中的线程也是比较宝贵的系统资源,线程数量快速膨胀后,系统的性能将急剧下降,随着访问量的继续增大,系统最终就死掉了。
??我们可以使用线程池来管理这些线程,实现1个或多个线程处理N个客户端的模型(但是底层还是使用的同步阻塞I/O),通常被称为“伪异步I/O模型“。
??由于限制了线程数量,实现了N:M的伪异步I/O模型,但是当发生读取网络数据较慢的时候,大量并发的情况下,其他接入的消息,只能一直等待,这就是最大的弊端。若不限制线程数量,除了能自动管理线程(复用),与上述模型无明显区别。
,??NIO 库是在 JDK 1.4 中引入的。NIO 弥补了原来的 BIO 的不足,它在标准 Java 代码中提供了高速的、面向块的 I/O。NIO被称为 no-blocking io 或者 new io都说得通。,??Java NIO和BIO之间第一个最大的区别是,BIO是面向流的,NIO是面向缓冲区的。 Java BIO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。 Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。,??Java BIO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。
??Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。,??NIO有三大核心组件:Selector选择器、Channel管道、buffer缓冲区,??Java NIO的选择器允许一个单独的线程来监视多个输入通道,可以通过注册多个通道使用一个选择器(Selectors),然后使用一个单独的线程来操作这个选择器,进而“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道。
??应用程序将向Selector对象注册需要它关注的Channel,以及具体的某一个Channel会对哪些IO事件感兴趣Selector中也会维护一个“已经注册的Channel”的容器。,??**通道,被建立的一个应用程序和操作系统交互事件、传递内容的渠道(注意是连接到操作系统)。**那么既然是和操作系统进行内容的传递,那么说明应用程序可以通过通道读取数据,也可以通过通道向操作系统写数据,而且可以同时进行读写。,??通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。,??NIO是面向缓冲的。Buffer就是这个缓冲,用于和NIO通道进行交互。数据是从通道读入缓冲区,从缓冲区写入到通道中的。
??缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存(其实就是数组)。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。,写数据,flip
??flip方法将Buffer从写模式切换到读模式。调用flip()方法会将position设回0,并将limit设置成之前position的值。
读取数据,重新写入Buffer,mark和reset
??通过调用buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用buffer.reset()方法恢复到这个position。,equals
??当满足下列条件时,表示两个Buffer相等:,compareTo()
??compareTo()方法比较两个Buffer的剩余元素(byte、char等), 如果满足下列条件,则认为一个Buffer“小于”另一个Buffer:,??SelectionKey是一个抽象类,表示SelectableChannel在Selector中注册的标识.每个Channel向Selector注册时,都将会创建一个SelectionKey。SelectionKey将Channel与Selector建立了关系,并维护了channel事件。,“反应”器名字中”反应“的由来:
??“反应”即“倒置”,“控制逆转”,具体事件处理程序不调用反应器,而向反应器注册一个事件处理器,表示自己对某些事件感兴趣,有时间来了,具体事件处理程序通过事件处理器对某个指定的事件发生做出反应;
??这种控制逆转又称为“好莱坞法则”(不要调用我,让我来调用你),??① 服务器端的Reactor是一个线程对象,该线程会启动事件循环,并使用Selector(选择器)来实现IO的多路复用。注册一个Acceptor事件处理器到Reactor中,Acceptor事件处理器所关注的事件是ACCEPT事件,这样Reactor会监听客户端向服务器端发起的连接请求事件(ACCEPT事件)。
??② 客户端向服务器端发起一个连接请求,Reactor监听到了该ACCEPT事件的发生并将该ACCEPT事件派发给相应的Acceptor处理器来进行处理。Acceptor处理器通过accept()方法得到与这个客户端对应的连接(SocketChannel),然后将该连接所关注的READ事件以及对应的READ事件处理器注册到Reactor中,这样一来Reactor就会监听该连接的READ事件了。
??③ 当Reactor监听到有读或者写事件发生时,将相关的事件派发给对应的处理器进行处理。比如,读处理器会通过SocketChannel的read()方法读取数据,此时read()操作可以直接读取到数据,而不会堵塞与等待可读的数据到来。
??④ 每当处理完所有就绪的感兴趣的I/O事件后,Reactor线程会再次执行select()阻塞等待新的事件就绪并将其分派给对应处理器进行处理。
??注意,Reactor的单线程模式的单线程主要是针对于I/O操作而言,也就是所有的I/O的accept()、read()、write()以及connect()操作都在一个线程上完成的。
??但在目前的单线程Reactor模式中,不仅I/O操作在该Reactor线程上,连非I/O的业务操作也在该线程上进行处理了,这可能会大大延迟I/O请求的响应。,优化:添加了一个工作者线程池,并将非I/O操作从Reactor线程中移出转交给工作者线程池来执行。这样能够提高Reactor线程的I/O响应,不至于因为一些耗时的业务逻辑而延迟对后面I/O请求的处理。,??Reactor线程池中的每一Reactor线程都会有自己的Selector、线程和分发的事件循环逻辑。
??mainReactor可以只有一个,但subReactor一般会有多个。mainReactor线程主要负责接收客户端的连接请求,然后将接收到的SocketChannel传递给subReactor,由subReactor来完成和客户端的通信。
流程:
??① 注册一个Acceptor事件处理器到mainReactor中,Acceptor事件处理器所关注的事件是ACCEPT事件,这样mainReactor会监听客户端向服务器端发起的连接请求事件(ACCEPT事件)。启动mainReactor的事件循环。
??② 客户端向服务器端发起一个连接请求,mainReactor监听到了该ACCEPT事件并将该ACCEPT事件派发给Acceptor处理器来进行处理。Acceptor处理器通过accept()方法得到与这个客户端对应的连接(SocketChannel),然后将这个SocketChannel传递给subReactor线程池。
??③ subReactor线程池分配一个subReactor线程给这个SocketChannel,即,将SocketChannel关注的READ事件以及对应的READ事件处理器注册到subReactor线程中。当然你也注册WRITE事件以及WRITE事件处理器到subReactor线程中以完成I/O写操作。Reactor线程池中的每一Reactor线程都会有自己的Selector、线程和分发的循环逻辑。
??④ 当有I/O事件就绪时,相关的subReactor就将事件派发给响应的处理器处理。注意,这里subReactor线程只负责完成I/O的read()操作,在读取到数据后将业务逻辑的处理放入到线程池中完成,若完成业务逻辑后需要返回数据给客户端,则相关的I/O的write操作还是会被提交回subReactor线程来完成。
??注意,所以的I/O操作(包括,I/O的accept()、read()、write()以及connect()操作)依旧还是在Reactor线程(mainReactor线程 或 subReactor线程)中完成的。Thread Pool(线程池)仅用来处理非I/O操作的逻辑。
??多Reactor线程模式将“接受客户端的连接请求”和“与该客户端的通信”分在了两个Reactor线程来完成。mainReactor完成接收客户端连接请求的操作,它不负责与客户端的通信,而是将建立好的连接转交给subReactor线程来完成与客户端的通信,这样一来就不会因为read()数据量太大而导致后面的客户端连接请求得不到即时处理的情况。并且多Reactor线程模式在海量的客户端并发请求的情况下,还可以通过实现subReactor线程池来将海量的连接分发给多个subReactor线程,在多核的操作系统中这能大大提升应用的负载和吞吐量。,??观察者模式:也可以称为为 发布-订阅 模式,主要适用于多个对象依赖某一个对象的状态,并当某对象状态发生改变时,要通知其他依赖对象做出更新。是一种一对多的关系。当然,如果依赖的对象只有一个时,也是一种特殊的一对一关系。通常,观察者模式适用于消息事件处理,监听者监听到事件时通知事件处理者对事件进行处理(这一点上面有点像是回调,容易与反应器模式和前摄器模式的回调搞混淆)。
??Reactor模式:即反应器模式,是一种高效的异步IO模式,特征是回调,当IO完成时,回调对应的函数进行处理。这种模式并非是真正的异步,而是运用了异步的思想,当IO事件触发时,通知应用程序作出IO处理。模式本身并不调用系统的异步IO函数。
??观察者模式与单个事件源关联,而反应器模式则与多个事件源关联 。当一个主体发生改变时,所有依属体都得到通知。,??在所有的网络通信和应用程序中,每个TCP的Socket的内核中都有一个发送缓冲区(SO_SNDBUF)和一个接收缓冲区(SO_RECVBUF),可以使用相关套接字选项来更改该缓冲区大小。
??当某个应用进程调用write时,内核从该应用进程的缓冲区中复制所有数据到所写套接字的发送缓冲区。如果该套接字的发送缓冲区容不下该应用进程的所有数据(或是应用进程的缓冲区大于套接字的发送缓冲区,或是套接字的发送缓冲区中已有其他数据),假设该套接字是阻塞的,则该应用进程将被投入睡眠。
??内核将不从write系统调用返回,直到应用进程缓冲区中的所有数据都复制到套接字发送缓冲区。因此,从写一个TCP套接字的write调用成功返回仅仅表示可以重新使用原来的应用进程缓冲区,并不表明对端的TCP或应用进程已接收到数据。
??Java程序自然也要遵守上述的规则。但在Java中存在着堆、垃圾回收等特性,所以在实际的IO中,在JVM内部的存在着这样一种机制:
??在IO读写上,如果是使用堆内存,JDK会先创建一个DirectBuffer,再去执行真正的写操作。这是因为,当程序把一个地址通过JNI传递给底层的C库的时候,有一个基本的要求,就是这个地址上的内容不能失效。然而,在GC管理下的对象是会在Java堆中移动的。也就是说,有可能在将一个地址传给底层的write的过程中,这段内存却因为GC整理内存而失效了。所以必须要把待发送的数据放到一个GC管不着的地方。这就是调用native方法之前,数据—定要在堆外内存的原因。
??可见,站在网络通信的角度DirectBuffer并没有节省什么内存拷贝,只是Java网络通信里因为HeapBuffer必须多做一次拷贝,使用DirectBuffer就会少一次内存拷贝。相比没有使用堆内存的Java程序,使用直接内存的Java程序当然更快一点。
??从垃圾回收的角度而言,直接内存不受 GC(新生代的 Minor GC) 影响,只有当执行老年代的 Full GC 时候才会顺便回收直接内存,整理内存的压力也比数据放到HeapBuffer要小。,优点:,缺点:,??零拷贝(英语: Zero-copy) 技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。
??零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率。
??零拷贝技术减少了用户进程地址空间和内核地址空间之间因为上下文切换而带来的开销。
??零Copy并非不copy,而是减少冗余不必要的拷贝。,??DMA(Direct Memory Access,直接内存存取) 是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于CPU 的大量中断负载。
??DMA控制器,接管了数据读写请求,减少CPU的负担。这样一来,CPU能高效工作了。现代硬盘基本都支持DMA。
实际因此IO读取,涉及两个过程:
??1、DMA等待数据准备好,把磁盘数据读取到操作系统内核缓冲区;
??2、用户进程,将内核缓冲区的数据copy到用户空间。,
流程:,??显然,第二个和第三个数据副本是不需要的。应用程序除了缓存数据并将其传输回套接字缓冲区之外什么都不做,数据可以直接从读缓冲区传输到套接字缓冲区。,??其中,read和send都属于系统调用,每次调用都牵涉到两次上下文切换。
??传统的数据传送所消耗的成本:4次拷贝,4次上下文切换。
??4次拷贝,其中两次是DMA copy,两次是CPU copy。,??硬盘上文件的位置和应用程序缓冲区(application buffers)进行映射(建立一种一一对应关系),由于mmap()将文件直接映射到用户空间,所以实际文件读取时根据这个映射关系,直接将文件从硬盘拷贝到用户空间,只进行了一次数据拷贝,不再有文件内容从硬盘拷贝到内核空间的一个缓冲区。
??mmap内存映射将会经历:3次拷贝: 1次cpu copy,2次DMA copy;以及4次上下文切换,调用mmap函数2次,write函数2次。
,??当调用sendfile()时,DMA将磁盘数据复制到kernel buffer,然后将内核中的kernel buffer直接拷贝到socket buffer;但是数据并未被真正复制到socket关联的缓冲区内。取而代之的是,只有记录数据位置和长度的描述符被加入到socket缓冲区中。DMA模块将数据直接从内核缓冲区传递给协议引擎,从而消除了遗留的最后一次复制。但是要注意,这个需要DMA硬件设备支持,如果不支持,CPU就必须介入进行拷贝。
??一旦数据全都拷贝到socket buffer,sendfile()系统调用将会return、代表数据转化的完成。socket buffer里的数据就能在网络传输了。
??sendfile会经历:3(2,如果硬件设备支持)次拷贝,1(0,,如果硬件设备支持)次CPU copy, 2次DMA copy;
以及2次上下文切换,??数据从磁盘读取到OS内核缓冲区后,在内核缓冲区直接可将其转成内核空间其他数据buffer,而不需要拷贝到用户空间。
??如下图所示,从磁盘读取到内核buffer后,在内核空间直接与socket buffer建立pipe管道。
??和sendfile()不同的是,splice()不需要硬件支持。
??注意splice和sendfile的不同,sendfile是DMA硬件设备不支持的情况下将磁盘数据加载到kernel buffer后,需要一次CPU copy,拷贝到socket buffer。而splice是更进一步,连这个CPU copy也不需要了,直接将两个内核空间的buffer进行pipe。
splice会经历 2次拷贝: 0次cpu copy、2次DMA copy、2次上下文切换。
,内存映射mmap、sendfile, 网络通信编程入门
- Socket
-
- 什么是Socket?
- 长连接&短连接
-
- 长连接
- 短连接
- 什么时候用长连接,短连接?
- 网络编程
-
- BIO
-
- 什么是BIO
- BIO的阻塞模型
- NIO
-
- 什么是NIO?
- 和BIO的主要区别
-
- 面向流与面向缓冲
- 阻塞与非阻塞IO
- NIO三大组件
-
- Selector
- Channels
- Buffer
-
- 重要属性
- 分配Buffer对象
- 常见方法
- SelectionKey
- 三大组件之间的关系
- Reactor模式
-
- 单线程Reactor模式流程
-
- 单线程Reactor,工作者线程池
- 多线程主从Reactor模式
- Reactor模式和观察者模式的区别
- 直接内存
-
- 堆外内存
- 零Copy
-
- DMA
- 传统的数据传送机制
- mmap内存映射
- sendfile
- splice
- Java支持的零Copy
??Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口,一般由操作系统提供。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议处理和通信缓存管理等等都隐藏在Socket接口后面,对用户来说,使用一组简单的接口就能进行网络应用编程,让Socket去组织数据,以符合指定的协议。主机 A 的应用程序要能和主机 B 的应用程序通信,必须通过 Socket 建立连接。
??客户端连接上一个服务端,就会在客户端中产生一个socket接口实例,服务端每接受一个客户端连接,就会产生一个socket接口实例和客户端的socket进行通信,有多个客户端连接自然就有多个socket接口实例。
??连接->传输数据->保持连接 -> 传输数据-> 。。。 ->关闭连接。
??长连接指建立SOCKET连接后不管是否使用都保持连接。
??连接->传输数据->关闭连接
??传统HTTP是无状态的,浏览器和服务器每进行一次HTTP操作,就建立一次连接,但任务结束就中断连接。
??也可以这样说:短连接是指SOCKET连接后发送后接收完数据后马上断开连接。
??长连接多用于操作频繁,点对点的通讯。每个TCP连接都需要三步握手,这需要时间,如果每个操作都是先连接,再操作的话那么处理速度会降低很多,所以每个操作完后都不断开,下次处理时直接发送数据包就OK了,不用建立TCP连接。例如:数据库的连接用长连接, 如果用短连接频繁的通信会造成socket错误,而且频繁的socket 创建也是对资源的浪费。
??而像WEB网站的http服务按照Http协议规范早期一般都用短链接,因为长连接对于服务端来说会耗费一定的资源,而像WEB网站这么频繁的成千上万甚至上亿客户端的连接用短连接会更省一些资源。但是现在的Http协议,Http1.1,尤其是Http2、Http3已经开始向长连接演化。
??所有模式的通信编程都是围绕着连接(客户端连接服务器,服务器等待和接收连接)、读网络数据、写网络数据进行的。
BIO 什么是BIOBIO,意为Blocking I/O,即阻塞的I/O。
??在BIO中类ServerSocket负责绑定IP地址,启动监听端口,等待客户连接;客户端Socket类的实例发起连接操作,ServerSocket接受连接后产生一个新的服务端socket实例负责和客户端socket实例通过输入和输出流进行通信。
??BIO的阻塞,主要体现在两个地方:
①若一个服务器启动就绪,那么主线程就一直在等待着客户端的连接,这个等待过程中主线程就一直在阻塞。
②在连接建立之后,在读取到socket信息之前,线程也是一直在等待,一直处于阻塞的状态下的。
??传统BIO通信模型:采用BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成后,通过输出流返回应答给客户端,线程销毁。即典型的一请求一应答模型,同时数据的读取写入也必须阻塞在一个线程内等待其完成。
??该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈1:1的正比关系,Java中的线程也是比较宝贵的系统资源,线程数量快速膨胀后,系统的性能将急剧下降,随着访问量的继续增大,系统最终就死掉了。
??我们可以使用线程池来管理这些线程,实现1个或多个线程处理N个客户端的模型(但是底层还是使用的同步阻塞I/O),通常被称为“伪异步I/O模型“。
??由于限制了线程数量,实现了N:M的伪异步I/O模型,但是当发生读取网络数据较慢的时候,大量并发的情况下,其他接入的消息,只能一直等待,这就是最大的弊端。若不限制线程数量,除了能自动管理线程(复用),与上述模型无明显区别。
??NIO 库是在 JDK 1.4 中引入的。NIO 弥补了原来的 BIO 的不足,它在标准 Java 代码中提供了高速的、面向块的 I/O。NIO被称为 no-blocking io 或者 new io都说得通。
和BIO的主要区别 面向流与面向缓冲??Java NIO和BIO之间第一个最大的区别是,BIO是面向流的,NIO是面向缓冲区的。 Java BIO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。 Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。
阻塞与非阻塞IO??Java BIO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。
??Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。
??NIO有三大核心组件:Selector选择器、Channel管道、buffer缓冲区
Selector??Java NIO的选择器允许一个单独的线程来监视多个输入通道,可以通过注册多个通道使用一个选择器(Selectors),然后使用一个单独的线程来操作这个选择器,进而“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道。
??应用程序将向Selector对象注册需要它关注的Channel,以及具体的某一个Channel会对哪些IO事件感兴趣Selector中也会维护一个“已经注册的Channel”的容器。
??**通道,被建立的一个应用程序和操作系统交互事件、传递内容的渠道(注意是连接到操作系统)。**那么既然是和操作系统进行内容的传递,那么说明应用程序可以通过通道读取数据,也可以通过通道向操作系统写数据,而且可以同时进行读写。
- 所有被Selector(选择器)注册的通道,只能是继承了SelectableChannel类的子类。
- ServerSocketChannel:应用服务器程序的监听通道。只有通过这个通道,应用程序才能向操作系统注册支持“多路复用IO”的端口监听。同时支持UDP协议和TCP协议。
- ScoketChannel:TCP Socket套接字的监听通道,一个Socket套接字对应了一个客户端IP:端口 到 服务器IP:端口的通信连接。
??通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。
import java.io.IOException; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.nio.ByteBuffer; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; public class ChannelDemo { public static void main(String[] args) { try { //不立即建立来连接,创建一个初始未连接的socket, //必须使用connect()方法进行连接; SocketAddress address = new InetSocketAddress("127.0.0.1",8080); SocketChannel schannel = SocketChannel.open(); schannel.connect(address); //配置非阻塞通道,注意使用非阻塞通道,connect()会立即返回, // 然后程序就去做别的事情;必须调用finishConnect(); // 对于阻塞的通道,finishConnect()方法将立即返回true; schannel.configureBlocking(false); //返回boolean值,用来判断连接是否建立; schannel.isConnected(); //申请缓冲器 ByteBuffer buffer = ByteBuffer.allocate(100); ByteBuffer [] buffers = null; //读取通道中缓冲区的数据,首先将数据读取的数据放入到缓冲区,直到缓冲区放满,然后返回放入的字节数, // 如果到达流的末尾,则下一次调用read(),返回-1; schannel.read(buffer); //从一个源填充多个缓冲区,read()接受一个ByteBuffer对象数组作为参数,按顺序填充数组中的各个ByteBuffer; schannel.read(buffers); //同上,从第0个缓冲区开始,填充100个缓冲区; schannel.read(buffers, 0, 100); //像通道中缓冲区写入数据,填充一个ByteBuffer,然后穿个某个写入方法,其原理和输入相同 schannel.write(buffer); schannel.write(buffers); schannel.write(buffers, 0, 100); //关闭连接 schannel.close(); //只有一个目的,就是接受入站连接,open方法并不是打开一个新的socket,而是只创建这个对象; ServerSocketChannel server = ServerSocketChannel.open(); SocketAddress saddress = new InetSocketAddress(80); //绑定监听端口 server.bind(saddress); //监听端口入站连接,阻塞情况下,accept()方法, //等待连接,并返回连接到远程客户端的一个SocketChannel对象,阻塞模式是默认的; //在非阻塞模式下,如果没有入站连接,accept()方法将返回null, server.accept(); } catch (IOException e) { e.printStackTrace(); } } }Buffer
??NIO是面向缓冲的。Buffer就是这个缓冲,用于和NIO通道进行交互。数据是从通道读入缓冲区,从缓冲区写入到通道中的。
??缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存(其实就是数组)。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。
- capacity
??作为一个内存块,Buffer有一个固定的大小值,也叫“capacity”。一旦Buffer满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据。 - position
??当写数据到Buffer中时,position表示当前能写的位置。初始的position值为0.当一个byte、long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity – 1。
??当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0。当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。 - limit
??在写模式下,Buffer的limit表示最多能往Buffer里写多少数据。 写模式下,limit等于Buffer的capacity。
??当切换Buffer到读模式时, limit表示最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。换句话说,代码能够读到之前写入的所有数据(limit被设置成已写数据的数量,这个值在写模式下就是position)
- allocate
可以在堆上分配,也可以在直接内存上分配
ByteBuffer buf = ByteBuffer.allocate(48); - wrap
把一个byte数组或byte数组的一部分包装成ByteBuffer:
ByteBuffer wrap(byte [] array)
ByteBuffer wrap(byte [] array, int offset, int length)
写数据
- 读取Channel写到Buffer
int bytesRead = inChannel.read(buffer);
- 通过Buffer的put()写到Buffer里面
buffer.put(byte b); //向byteBuffer底层的bytes中下标为index的位置插入byte b,不改变position的值。 buffer.put(int index, byte b);
flip
??flip方法将Buffer从写模式切换到读模式。调用flip()方法会将position设回0,并将limit设置成之前position的值。
读取数据
- 从Buffer中读取到Channel
int bytesWritten = inChannel.write(buffer);
- 调用Buffer的get()
buffer.get(); //读取byteBuffer底层的bytes中下标为index的byte,不改变position的值。 buffer.get(int index);position。
//将position设回0,可以重读Buffer中的所有数据。limit保持不变, //仍然表示能从Buffer中读取多少个元素(byte、char等)。 buffer.rewind();
重新写入Buffer
//调用的是clear()方法,position将被设回0,limit被设置成 capacity的值。 buffer.clear(); //如果Buffer中有一些未读的数据,调用clear()方法,数据将“被遗忘”, //意味着不再有任何标记会告诉你哪些数据被读过,哪些还没有。 //如果Buffer中仍有未读的数据,且后续还需要这些数据,但是此时想要先先写些数据, //那么使用compact()方法。 //compact()方法将所有未读的数据拷贝到Buffer起始处。 //然后将position设到最后一个未读元素正后面。 //limit属性依然像clear()方法一样,设置成capacity。 //现在Buffer准备好写数据了,但是不会覆盖未读的数据。 buffer.compact();
mark和reset
??通过调用buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用buffer.reset()方法恢复到这个position。
buffer.mark();//call buffer.get() a couple of times, e.g. during parsing. buffer.reset(); //set position back to mark.
equals
??当满足下列条件时,表示两个Buffer相等:
- 有相同的类型(byte、char、int等)。
- Buffer中剩余的byte、char等的个数相等。
- Buffer中所有剩余的byte、char等都相同。
equals()只比较Buffer中的剩余元素。
compareTo()
??compareTo()方法比较两个Buffer的剩余元素(byte、char等), 如果满足下列条件,则认为一个Buffer“小于”另一个Buffer:
- 第一个不相等的元素小于另一个Buffer中对应的元素 。
- 所有元素都相等,但第一个Buffer比另一个先耗尽(第一个Buffer的元素个数比另一个少)。
limit(), limit(10)等 | 其中读取和设置这4个属性的方法的命名和jQuery中的val(),val(10)类似,一个负责get,一个负责set |
---|---|
reset() | 把position设置成mark的值,相当于之前做过一个标记,现在要退回到之前标记的地方 |
clear() | position = 0;limit = capacity;mark = -1; 有点初始化的味道,但是并不影响底层byte数组的内容 |
flip() | limit = position;position = 0;mark = -1; 翻转,也就是让flip之后的position到limit这块区域变成之前的0到position这块,翻转就是将一个处于存数据状态的缓冲区变为一个处于准备取数据的状态 |
remaining() | return limit - position;返回limit和position之间相对位置差 |
hasRemaining() | return position < limit返回是否还有未读内容 |
compact() | 把从position到limit中的内容移到0到limit-position的区域内,position和limit的取值也分别变成limit-position、capacity。如果先将positon设置到limit,再compact,那么相当于clear() |
get() | 相对读,从position位置读取一个byte,并将position+1,为下次读写作准备 |
get(int index) | 绝对读,读取byteBuffer底层的bytes中下标为index的byte,不改变position |
get(byte[] dst, int offset, int length) | 从position位置开始相对读,读length个byte,并写入dst下标从offset到offset+length的区域 |
put(byte b) | 相对写,向position的位置写入一个byte,并将postion+1,为下次读写作准备 |
put(int index, byte b) | 绝对写,向byteBuffer底层的bytes中下标为index的位置插入byte b,不改变position |
put(ByteBuffer src) | 用相对写,把src中可读的部分(也就是position到limit)写入此byteBuffer |
put(byte[] src, int offset, int length) | 从src数组中的offset到offset+length区域读取数据并使用相对写写入此byteBuffer |
??SelectionKey是一个抽象类,表示SelectableChannel在Selector中注册的标识.每个Channel向Selector注册时,都将会创建一个SelectionKey。SelectionKey将Channel与Selector建立了关系,并维护了channel事件。
public static final int OP_READ = 1 << 0; public static final int OP_WRITE = 1 << 2; public static final int OP_CONNECT = 1 << 3; public static final int OP_ACCEPT = 1 << 4;
操作类型 | 就绪条件以及说明 |
---|---|
OP_READ | ??当操作系统读缓冲区有数据可读时就绪。并非时刻都有数据可读,所以一般需要注册该操作,仅当有就绪时才发起读操作,有的放矢,避免浪费CPU。 |
OP_WRITE | ??当操作系统写缓冲区有空闲空间时就绪。一般情况下写缓冲区都有空闲空间,小块数据直接写入即可,没必要注册该操作类型,否则该条件不断就绪浪费CPU;但如果是写密集型的任务,比如文件下载等,缓冲区很可能满,注册该操作类型就很有必要,同时注意写完后取消注册。 |
OP_CONNECT | ??当SocketChannel.connect()请求连接成功后就绪。该操作只给客户端使用。 |
OP_ACCEPT | ??当接收到一个客户端连接请求时就绪。该操作只给服务器使用。 |
“反应”器名字中”反应“的由来:
??“反应”即“倒置”,“控制逆转”,具体事件处理程序不调用反应器,而向反应器注册一个事件处理器,表示自己对某些事件感兴趣,有时间来了,具体事件处理程序通过事件处理器对某个指定的事件发生做出反应;
??这种控制逆转又称为“好莱坞法则”(不要调用我,让我来调用你)
??① 服务器端的Reactor是一个线程对象,该线程会启动事件循环,并使用Selector(选择器)来实现IO的多路复用。注册一个Acceptor事件处理器到Reactor中,Acceptor事件处理器所关注的事件是ACCEPT事件,这样Reactor会监听客户端向服务器端发起的连接请求事件(ACCEPT事件)。
??② 客户端向服务器端发起一个连接请求,Reactor监听到了该ACCEPT事件的发生并将该ACCEPT事件派发给相应的Acceptor处理器来进行处理。Acceptor处理器通过accept()方法得到与这个客户端对应的连接(SocketChannel),然后将该连接所关注的READ事件以及对应的READ事件处理器注册到Reactor中,这样一来Reactor就会监听该连接的READ事件了。
??③ 当Reactor监听到有读或者写事件发生时,将相关的事件派发给对应的处理器进行处理。比如,读处理器会通过SocketChannel的read()方法读取数据,此时read()操作可以直接读取到数据,而不会堵塞与等待可读的数据到来。
??④ 每当处理完所有就绪的感兴趣的I/O事件后,Reactor线程会再次执行select()阻塞等待新的事件就绪并将其分派给对应处理器进行处理。
??注意,Reactor的单线程模式的单线程主要是针对于I/O操作而言,也就是所有的I/O的accept()、read()、write()以及connect()操作都在一个线程上完成的。
??但在目前的单线程Reactor模式中,不仅I/O操作在该Reactor线程上,连非I/O的业务操作也在该线程上进行处理了,这可能会大大延迟I/O请求的响应。
优化:添加了一个工作者线程池,并将非I/O操作从Reactor线程中移出转交给工作者线程池来执行。这样能够提高Reactor线程的I/O响应,不至于因为一些耗时的业务逻辑而延迟对后面I/O请求的处理。
多线程主从Reactor模式??Reactor线程池中的每一Reactor线程都会有自己的Selector、线程和分发的事件循环逻辑。
??mainReactor可以只有一个,但subReactor一般会有多个。mainReactor线程主要负责接收客户端的连接请求,然后将接收到的SocketChannel传递给subReactor,由subReactor来完成和客户端的通信。
流程:
??① 注册一个Acceptor事件处理器到mainReactor中,Acceptor事件处理器所关注的事件是ACCEPT事件,这样mainReactor会监听客户端向服务器端发起的连接请求事件(ACCEPT事件)。启动mainReactor的事件循环。
??② 客户端向服务器端发起一个连接请求,mainReactor监听到了该ACCEPT事件并将该ACCEPT事件派发给Acceptor处理器来进行处理。Acceptor处理器通过accept()方法得到与这个客户端对应的连接(SocketChannel),然后将这个SocketChannel传递给subReactor线程池。
??③ subReactor线程池分配一个subReactor线程给这个SocketChannel,即,将SocketChannel关注的READ事件以及对应的READ事件处理器注册到subReactor线程中。当然你也注册WRITE事件以及WRITE事件处理器到subReactor线程中以完成I/O写操作。Reactor线程池中的每一Reactor线程都会有自己的Selector、线程和分发的循环逻辑。
??④ 当有I/O事件就绪时,相关的subReactor就将事件派发给响应的处理器处理。注意,这里subReactor线程只负责完成I/O的read()操作,在读取到数据后将业务逻辑的处理放入到线程池中完成,若完成业务逻辑后需要返回数据给客户端,则相关的I/O的write操作还是会被提交回subReactor线程来完成。
??注意,所以的I/O操作(包括,I/O的accept()、read()、write()以及connect()操作)依旧还是在Reactor线程(mainReactor线程 或 subReactor线程)中完成的。Thread Pool(线程池)仅用来处理非I/O操作的逻辑。
??多Reactor线程模式将“接受客户端的连接请求”和“与该客户端的通信”分在了两个Reactor线程来完成。mainReactor完成接收客户端连接请求的操作,它不负责与客户端的通信,而是将建立好的连接转交给subReactor线程来完成与客户端的通信,这样一来就不会因为read()数据量太大而导致后面的客户端连接请求得不到即时处理的情况。并且多Reactor线程模式在海量的客户端并发请求的情况下,还可以通过实现subReactor线程池来将海量的连接分发给多个subReactor线程,在多核的操作系统中这能大大提升应用的负载和吞吐量。
??观察者模式:也可以称为为 发布-订阅 模式,主要适用于多个对象依赖某一个对象的状态,并当某对象状态发生改变时,要通知其他依赖对象做出更新。是一种一对多的关系。当然,如果依赖的对象只有一个时,也是一种特殊的一对一关系。通常,观察者模式适用于消息事件处理,监听者监听到事件时通知事件处理者对事件进行处理(这一点上面有点像是回调,容易与反应器模式和前摄器模式的回调搞混淆)。
??Reactor模式:即反应器模式,是一种高效的异步IO模式,特征是回调,当IO完成时,回调对应的函数进行处理。这种模式并非是真正的异步,而是运用了异步的思想,当IO事件触发时,通知应用程序作出IO处理。模式本身并不调用系统的异步IO函数。
??观察者模式与单个事件源关联,而反应器模式则与多个事件源关联 。当一个主体发生改变时,所有依属体都得到通知。
??在所有的网络通信和应用程序中,每个TCP的Socket的内核中都有一个发送缓冲区(SO_SNDBUF)和一个接收缓冲区(SO_RECVBUF),可以使用相关套接字选项来更改该缓冲区大小。
??当某个应用进程调用write时,内核从该应用进程的缓冲区中复制所有数据到所写套接字的发送缓冲区。如果该套接字的发送缓冲区容不下该应用进程的所有数据(或是应用进程的缓冲区大于套接字的发送缓冲区,或是套接字的发送缓冲区中已有其他数据),假设该套接字是阻塞的,则该应用进程将被投入睡眠。
??内核将不从write系统调用返回,直到应用进程缓冲区中的所有数据都复制到套接字发送缓冲区。因此,从写一个TCP套接字的write调用成功返回仅仅表示可以重新使用原来的应用进程缓冲区,并不表明对端的TCP或应用进程已接收到数据。
??Java程序自然也要遵守上述的规则。但在Java中存在着堆、垃圾回收等特性,所以在实际的IO中,在JVM内部的存在着这样一种机制:
??在IO读写上,如果是使用堆内存,JDK会先创建一个DirectBuffer,再去执行真正的写操作。这是因为,当程序把一个地址通过JNI传递给底层的C库的时候,有一个基本的要求,就是这个地址上的内容不能失效。然而,在GC管理下的对象是会在Java堆中移动的。也就是说,有可能在将一个地址传给底层的write的过程中,这段内存却因为GC整理内存而失效了。所以必须要把待发送的数据放到一个GC管不着的地方。这就是调用native方法之前,数据—定要在堆外内存的原因。
??可见,站在网络通信的角度DirectBuffer并没有节省什么内存拷贝,只是Java网络通信里因为HeapBuffer必须多做一次拷贝,使用DirectBuffer就会少一次内存拷贝。相比没有使用堆内存的Java程序,使用直接内存的Java程序当然更快一点。
??从垃圾回收的角度而言,直接内存不受 GC(新生代的 Minor GC) 影响,只有当执行老年代的 Full GC 时候才会顺便回收直接内存,整理内存的压力也比数据放到HeapBuffer要小。
优点:
- 减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作(可能使用多线程或者时间片的方式,根本感觉不到)。
- 加快了复制的速度。因为堆内在flush到远程时,会先复制到直接内存(非堆内存),然后在发送;而堆外内存相当于省略掉了这个工作。
缺点:
- 堆外内存难以控制,如果内存泄漏,那么很难排查。
- 堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象或者扁平化的比较适合。
??零拷贝(英语: Zero-copy) 技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。
??零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率。
??零拷贝技术减少了用户进程地址空间和内核地址空间之间因为上下文切换而带来的开销。
??零Copy并非不copy,而是减少冗余不必要的拷贝。
??DMA(Direct Memory Access,直接内存存取) 是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于CPU 的大量中断负载。
??DMA控制器,接管了数据读写请求,减少CPU的负担。这样一来,CPU能高效工作了。现代硬盘基本都支持DMA。
实际因此IO读取,涉及两个过程:
??1、DMA等待数据准备好,把磁盘数据读取到操作系统内核缓冲区;
??2、用户进程,将内核缓冲区的数据copy到用户空间。
流程:
- 将磁盘文件,读取到操作系统内核缓冲区;
- 将内核缓冲区的数据,copy到应用程序的buffer;
- 将application应用程序buffer中的数据,copy到socket网络发送缓冲区(属于操作系统内核的缓冲区);
- 将socket buffer的数据,copy到网卡,由网卡进行网络传输。
??显然,第二个和第三个数据副本是不需要的。应用程序除了缓存数据并将其传输回套接字缓冲区之外什么都不做,数据可以直接从读缓冲区传输到套接字缓冲区。
伪码实现如下: buffer = File.read() Socket.send(buffer)
??其中,read和send都属于系统调用,每次调用都牵涉到两次上下文切换。
??传统的数据传送所消耗的成本:4次拷贝,4次上下文切换。
??4次拷贝,其中两次是DMA copy,两次是CPU copy。
??硬盘上文件的位置和应用程序缓冲区(application buffers)进行映射(建立一种一一对应关系),由于mmap()将文件直接映射到用户空间,所以实际文件读取时根据这个映射关系,直接将文件从硬盘拷贝到用户空间,只进行了一次数据拷贝,不再有文件内容从硬盘拷贝到内核空间的一个缓冲区。
??mmap内存映射将会经历:3次拷贝: 1次cpu copy,2次DMA copy;以及4次上下文切换,调用mmap函数2次,write函数2次。
??当调用sendfile()时,DMA将磁盘数据复制到kernel buffer,然后将内核中的kernel buffer直接拷贝到socket buffer;但是数据并未被真正复制到socket关联的缓冲区内。取而代之的是,只有记录数据位置和长度的描述符被加入到socket缓冲区中。DMA模块将数据直接从内核缓冲区传递给协议引擎,从而消除了遗留的最后一次复制。但是要注意,这个需要DMA硬件设备支持,如果不支持,CPU就必须介入进行拷贝。
??一旦数据全都拷贝到socket buffer,sendfile()系统调用将会return、代表数据转化的完成。socket buffer里的数据就能在网络传输了。
??sendfile会经历:3(2,如果硬件设备支持)次拷贝,1(0,,如果硬件设备支持)次CPU copy, 2次DMA copy;
以及2次上下文切换
??数据从磁盘读取到OS内核缓冲区后,在内核缓冲区直接可将其转成内核空间其他数据buffer,而不需要拷贝到用户空间。
??如下图所示,从磁盘读取到内核buffer后,在内核空间直接与socket buffer建立pipe管道。
??和sendfile()不同的是,splice()不需要硬件支持。
??注意splice和sendfile的不同,sendfile是DMA硬件设备不支持的情况下将磁盘数据加载到kernel buffer后,需要一次CPU copy,拷贝到socket buffer。而splice是更进一步,连这个CPU copy也不需要了,直接将两个内核空间的buffer进行pipe。
splice会经历 2次拷贝: 0次cpu copy、2次DMA copy、2次上下文切换。
内存映射mmap、sendfile
,下一篇:锁编程和线程通信