同步、异步、阻塞、非阻塞:
别小看这4个概念,很多时候很容易混为一谈。它们是从不同维度来描述同一件事情。
1.同步和异步
同步和异步关注的是消息通信机制 (synchronous communication/ asynchronous communication),是主动等待消息返回还是被动接受消息。所谓同步,就是在发出一个*调用*时,在没有得到结果之前,该*调用*就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由*调用者*主动等待这个*调用*的结果。
而异步则是相反,*调用*在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在*调用*发出后,*被调用者*通过状态、通知来通知调用者,或通过回调函数处理这个调用。可以这样理解,对于异步请求分两步:
(1)调用方发送request没有返回对应的response(可能返回的是一个空的response);
(2)服务提供方将response处理完成以后通过callback的方式通知调用方。
对于(1)而言是同步操作(调用方请求服务方),对于(2)而言也是同步操作(服务方回掉调用方)。从请求的目的(调用方发送一个request,希望获得对应的response)来看,这两个步骤拆分开来没有任何意义,需要结合起来看,而这整个过程就是一次异步请求。异步请求有一个最典型的特点:需要callback、状态或者通知的方式来告知调用方结果。
2.阻塞和非阻塞:
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。
阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
阻塞和非阻塞最大的区别就是看调用方线程是否会被挂起。
参考文档:https://www.zhihu.com/question/19732473
同步阻塞IO:
针对Sender而言,请求发送出去以后,一直等到Receiver有结果了才返回,这是同步。在Sender获取结果的期间一直被block住了,也就是在此期间Sender不能处理其它事情,这是阻塞。
阻塞:体现在Sender获取结果的期间一直被block住了,没法干别的。
同步:体现在Sender请求发送出去以后,一直等到Receiver有结果了才返回。
异步阻塞IO:
针对Sender而言,请求发送出去以后,立刻返回,然后再等待Receiver的callback,最后再次请求获取response,这整个过程是异步。在Sender等待Receiver的callback期间一直被block住了,也就是在此期间Sender不能处理其它事情,这是阻塞。
同步非阻塞:
针对Sender而言,请求发送出去以后,立刻返回,然后再不停的发送请求,直到Receiver处理好结果后,最后一次发请求给Receiver才获得response。Sender一直在主动轮询,每一个请求都是同步的,整个过程也是同步的。在Sender等待Receiver的response期间一直是可以处理其它事情的(比如:可以发送请求询问结果),这是非阻塞。
非阻塞:Sender等待Receiver的response期间一直是可以处理其它事情的。
同步:体现在消息通知机制上,Sender仍然需要定时去获取真正的结果(轮询),没法通过回调等方式获取结果。
异步非阻塞:
针对Sender而言,请求发送出去以后,立刻返回,然后再等待Receiver的callback,最后再次请求获取response,这整个过程是异步。在Sender等待Receiver的callback期间一直是可以处理其它事情的,这是非阻塞。
参考博客:https://blog.csdn.net/weixin_37850264/article/details/112793865
在《Unix网络编程》一书中提到了五种IO模型,分别是:阻塞IO、非阻塞IO、多路复用IO、信号驱动IO以及异步IO。(https://www.cnblogs.com/dolphin0520/p/3916526.html)
阻塞IO:
最传统的一种IO模型,即在读写数据过程中会发生阻塞现象。 当用户线程发出IO请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除block状态。
非阻塞IO:
当用户线程发起一个read操作后,并不需要等待,而是马上就得到了一个结果。如果结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦内核中的数据准备好了,并且又再次收到了用户线程的请求,那么它马上就将数据拷贝到了用户线程,然后返回。
所以事实上,在非阻塞IO模型中,用户线程需要不断地询问内核数据是否就绪,也就说非阻塞IO不会交出CPU,而会一直占用CPU。
但是对于非阻塞IO就有一个非常严重的问题,在while循环中需要不断地去询问内核数据是否就绪,这样会导致CPU占用率非常高,因此一般情况下很少使用while循环这种方式来读取数据。
多路复用IO模型:
多路复用IO模型是目前使用得比较多的模型。Java NIO实际上就是多路复用IO。
在多路复用IO模型中,会有一个线程不断去轮询多个socket的状态,只有当socket真正有读写事件时,才真正调用实际的IO读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有在真正有socket读写事件进行时,才会使用IO资源,所以它大大减少了资源占用。
在Java NIO中,是通过selector.select()去查询每个通道是否有到达事件,如果没有事件,则一直阻塞在那里,因此这种方式会导致用户线程的阻塞。
也许有朋友会说,我可以采用 多线程+ 阻塞IO 达到类似的效果,但是由于在多线程 + 阻塞IO 中,每个socket对应一个线程,这样会造成很大的资源占用,并且尤其是对于长连接来说,线程的资源一直不会释放,如果后面陆续有很多连接的话,就会造成性能上的瓶颈。
而多路复用IO模式,通过一个线程就可以管理多个socket,只有当socket真正有读写事件发生才会占用资源来进行实际的读写操作。因此,多路复用IO比较适合连接数比较多的情况。
另外多路复用IO为何比非阻塞IO模型的效率高是因为在非阻塞IO中,不断地询问socket状态时通过用户线程去进行的,而在多路复用IO中,轮询每个socket状态是内核在进行的,这个效率要比用户线程要高的多。
信号驱动IO模型:
在信号驱动IO模型中,当用户线程发起一个IO请求操作,会给对应的socket注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号之后,便在信号函数中调用IO读写操作来进行实际的IO请求操作。
异步IO模型:
异步IO模型才是最理想的IO模型,在异步IO模型中,当用户线程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从内核的角度,当它受到一个asynchronous read之后,它会立刻返回,说明read请求已经成功发起了,因此不会对用户线程产生任何block。然后,内核会等待数据准备完成,然后将数据拷贝到用户线程,当这一切都完成之后,内核会给用户线程发送一个信号,告诉它read操作完成了。也就说用户线程完全不需要实际的整个IO操作是如何进行的,只需要先发起一个请求,当接收内核返回的成功信号时表示IO操作已经完成,可以直接去使用数据了。
Java共支持3种网络编程模型/IO模式:BIO、NIO、AIO
Java BIO : 同步并阻塞(传统阻塞型),服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销 。
Java NIO : 同步非阻塞,服务器实现模式为一个线程处理多个请求(连接),即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求就进行处理 。使用的是上诉中的IO多路复用模型。
Java AIO: 异步非阻塞,AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用 。
BIO、NIO、AIO适用场景分析
BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序简单易理解。
NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,弹幕系统,服务器间通讯等。编程比较复杂,JDK1.4开始支持。
AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持。
Java BIO:
- 服务器端启动一个ServerSocket
- 客户端启动Socket对服务器进行通信,默认情况下服务器端需要对每个客户 建立一个线程与之通讯
- 客户端发出请求后, 先咨询服务器是否有线程响应,如果没有则会等待,或者被拒绝
- 如果有响应,客户端线程会等待请求结束后,在继续执行
BIO通信服务端,通常有一个独立的Acceptor线程负责监听客户端的连接。接收到客户端连接请求后会为每个客户端创建一个新的线程进行链路处理,处理完成后返回应答给客户端,也就是经典的请求-应答通信模型。但是随着客户端并发量上升,服务端的线程数膨胀,系统性能急剧下降,最终会导致系统不可用。
Java NIO:
NIO是 面向缓冲区 ,或者面向 块 编程的。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞式的高伸缩性网络。
NIO 有三大核心部分:Channel(通道),Buffer(缓冲区), Selector(选择器)
为什么说java nio是同步非阻塞的?
我其实也没有弄的特别明白,感觉好乱啊!!!!!
个人理解:首先同步异步如文章开始时说的,说的是消息的通信机制,而阻塞非阻塞说的线程等待结果的状态。NIO中并不是通过什么回调函数的方式去获取结果的,所以是同步的。而非阻塞,就是比如我服务端和一个客户端建立的连接后创建一个socket,然后把socket交给Selector,Selector对应的线程不会一直阻塞在那儿只管这个socket的读写,其他的客户端和服务端建立的连接也会加到这个Selector上,Selector通过轮训方式看各个socket是否有读写等事件发生,有的话才会进行对应的读写操作(读写操作起始的阻塞的)。可以这样认为:比如Selector有socket-A和socket-B,他们都关注“读”事件,但是不会因为其中一个处于等待状态时,另一个就没法进行“读”操作了,就是这个Selector对应的线程不会被socket-A或socket-B给阻塞。
BIO和NIO的比较
BIO 以流的方式处理数据,而 NIO 是面向缓存区的。BIO中面向流意味着每次从流中读一个或多个直接,直至读取所有字节,他们没有被缓存在任何地方。此外,它不能前后移动流中的数据。
BIO 是阻塞的,NIO 则是非阻塞的
NIO有选择器Selector,而BIO没有。
补充说明:
BIO基于字节流和字符流进行操作,而 NIO 基于 Channel(通道)和 Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于监听多个通道的事件(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道(这也是和BIO的很大一点不同,BIO接收到客户端连接请求后会为每个客户端创建一个新的线程进行处理) 。
当一个连接建立之后,他有两个步骤要做,第一步是接收完客户端发过来的全部数据,第二步是服务端处理完请求业务之后返回response给客户端。NIO和BIO的区别主要是在第一步。
在BIO中,等待客户端发数据这个过程是阻塞的,这样就造成了一个线程只能处理一个请求的情况,而机器能支持的最大线程数是有限的,这就是为什么BIO不能支持高并发的原因。
而NIO中,当一个Socket建立好之后,Thread并不会阻塞去接受这个Socket,而是将这个socket交给Selector,Selector会不断的去遍历所有的Socket,一旦有一个Socket有读写请求,他会通知Thread,然后Thread处理完数据再返回给客户端——这个过程是不阻塞的,这样就能让一个Thread处理更多的请求了。
Selector 、 Channel 和 Buffer 的关系图
1.每个channel 都会对应一个Buffer
2.Selector 对应一个线程, 一个线程对应多个channel(连接)
3.该图反应了有三个channel 注册到 该selector 。
4.程序切换到哪个channel 是由事件决定的, Event 就是一个重要的概念
5.Selector 会根据不同的事件,在各个通道上切换
6.Buffer 就是一个内存块 , 底层是有一个数组
7.数据的读取写入是通过Buffer, 这个和BIO , BIO 中要么是输入流,或者是输出流, 不能双向,但是NIO的Buffer 是可以读也可以写, 需要 flip 方法切换 channel 是双向的, 可以返回底层操作系统的情况, 比如Linux , 底层的操作系统通道就是双向的。
通道(channel):
1. BIO 中的 stream 是单向的,例如 FileInputStream 对象只能进行读取数据的操作,而 NIO 中的通道(Channel)是双向的,可以读操作,也可以写操作。
2. Channel在NIO中是一个接口
public interface Channel extends Closeable{}
3. 常用的 Channel 类有:FileChannel、DatagramChannel、ServerSocketChannel 和 SocketChannel。【ServerSocketChanne 类似 BIO中的ServerSocket , SocketChannel 类似 BIO中的Socket】
4. FileChannel 用于文件的数据读写,DatagramChannel 用于 UDP 的数据读写,ServerSocketChannel 和 SocketChannel 用于 TCP 的数据读写。
FileChannel 类
FileChannel主要用来对本地文件进行 IO 操作,常见的方法有
- public int read(ByteBuffer dst) ,从通道读取数据并放到缓冲区中
- public int write(ByteBuffer src) ,把缓冲区的数据写到通道中
- public long transferFrom(ReadableByteChannel src, long position, long count),从目标通道中复制数据到当前通道
- public long transferTo(long position, long count, WritableByteChannel target),把数据从当前通道复制给目标通道
缓冲区(Buffer):
Buffer可以理解为一块内存区域,可以写入数据,并且在之后读取它。这块内存被包装成NIO buffer对象,它提供了一些方法来更简单地操作内存。
使用 Buffer 读写数据一般遵循以下四个步骤:
1. 写入数据到 Buffer;
2. 调用 flip() 方法;
3. 从 Buffer 中读取数据;
4. 调用 clear() 方法或者 compact() 方法。
当向 Buffer 写入数据时,Buffer 会记录下写了多少数据。一旦要读取数据,需要通过 flip() 方法将 Buffer 从写模式切换到读模式。在读模式下,可以读取之前写入到 Buffer 的所有数据。
一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用 clear() 或 compact() 方法。clear() 方法会清空整个缓冲区。compact() 方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。
缓冲区(Buffer)参数说明:
Capacity | 容量,即可以容纳的最大数据量;在缓冲区创建时被设定并且不能改变 |
Limit | 表示缓冲区的当前终点,不能对缓冲区超过极限的位置进行读写操作。且极限是可以修改的 |
Position | 位置,下一个要被读或写的元素的索引,每次读写缓冲区数据时都会改变改值,为下次读写作准备 |
Mark | 标记 |
缓存创建时,limit的值等于capacity的值。假设 capacity = 1024,我们在程序中设置了 limit = 512,说明,Buffer 的容量为 1024,但是从 512 之后既不能读也不能写,因此可以理解成,Buffer 的实际可用大小为 512。
mark使用场景:假设缓冲区中有 10 个元素,position 目前的位置为 2,现在只想发送 6 - 10 之间的缓冲数据,此时我们可以 buffer.mark(buffer.position()),即把当前的 position 记入 mark 中,然后 buffer.postion(6),此时发送给 channel 的数据就是 6 - 10 的数据。发送完后,我们可以调用 buffer.reset() 使得 position = mark,因此这里的 mark 只是用于临时记录一下位置用的。
ByteBuffer(及其他类型buffer)继承自抽象Buffer,继承关系如下:
选择器(Selector):
选择器(Selector)可以实现一个单独的线程来监控多个注册在它上面的信道(Channel),通过轮询的选择机制,实现多路复用的效果。
NIO多路复用的主要步骤和元素:
首先,通过 Selector.open() 创建一个 Selector,作为类似调度员的角色。
然后,创建一个 ServerSocketChannel,配置为非阻塞模式,并且向 Selector 注册,通过指定 SelectionKey.OP_ACCEPT,告诉调度员,它关注的是新的连接请求。(为什么我们要明确配置非阻塞模式呢?这是因为阻塞模式下,注册操作是不允许的,会抛出 IllegalBlockingModeException 异常。)
Selector 阻塞在 select 操作,当有 Channel 发生接入请求,就会被唤醒。
在 具体的 方法中,通过 SocketChannel 和 Buffer 进行数据操作
nio client端代码示例:
nio server端代码示例:
nio臭名昭著的空轮训问题(参考https://www.jianshu.com/p/4303f47fc525):
若Selector的轮询结果为空,也没有调用wakeup或新消息处理,Selector.select()被唤醒而发生空轮询,CPU使用率100%。JDK1.6中很明显,JDK1.6之后做了优化,但是没有完全消除。
因为java的epoll实现存在bug,而linux下NIO底层使用的是epoll来实现的(而windows不是),因此该Bug只在linux系统下。
int num = selector.select();一般情况下是阻塞模式,但该bug下确被唤醒,num==0即selectionKey为空,则 while (it.hasNext()) { ..}不会执行,而进行了死循环,导致CPU飙到100%。
Netty中解决该bug的方法:
对Selector的select操作周期进行统计,每完成一次空的select操作进行一次计数,若在某个周期内连续发生N次空轮询,则触发了epoll死循环bug。
重建Selector,判断是否是其他线程发起的重建请求,若不是则将原SocketChannel从旧的Selector上去除注册,重新注册到新的Selector上,并将原来的Selector关闭。
NIO中的零拷贝:
Java中的零拷贝说的是只是用户态的零拷贝,不是操作系统层面的零拷贝。
Linux底层实现的零拷贝(https://blog.csdn.net/qq_19801061/article/details/118361779):
通过sendfile实现的零拷贝I/0。
通过mmp实现的零拷贝I/0。
java NIO中的零拷贝,其实就是调用的系统底层的零拷贝方式来实现的。
Java NIO中对零拷贝的使用:
1.FileChannel.transferTo()方法底层调用了sendfile()。
transferTo()的实现方式就是通过系统调用sendfile()(当然这是Linux中的系统调用,Windows中系统调用有所不同),根据我们上面所写说这个过程是效率远高于从内核缓冲区到用户缓冲区的读写的。
2.FileChannel.map()方法底层调用了mmap()方法。
channel的map()方法封装的是mmap系统调用,所以会将用户态内存与内核态中文件的内存地址映射,并获取用户态内存地址,构造并返回一个MappedByteBuffer类,里面有各种对文件操作的的API。