微服务底层通信与协议
回顾Java网络通信,包括传统BIO编程、伪异步I/O编程、NIO编程
一、传统BIO编程
通信的本质其实就是I/O,Java的网络编程主要涉及的内容是Socket编程,其他还有多线程编程、协议栈等相关知识。
在JDK 1.4推出Java NIO之前,基于Java的所有Socket通信都采用同步阻塞模式(BIO),类似于一问一答模式。客户端发起一次请求,同步等待调用结果的返回。同步阻塞模式易于调试且容易理解,但是存在严重的性能问题。
传统的同步阻塞模型开发中,ServerSocket负责绑定IP地址,启动监听端口;Socket负责发起连接操作。连接成功后,双方通过输入和输出流进行同步阻塞式通信。服务端提供IP和监听端口,客户端通过连接操作向服务端监听的地址发起连接请求,通过三次握手连接,如果连接成功建立,双方就可以通过套接字进行通信。
这里简单地描述一下BIO的服务端通信模型。采用BIO通信模型的服务端,通常由一个独立的Acceptor(消费者)线程负责监听客户端的连接,它接收到客户端连接请求之后,为每个客户端创建一个新的线程进行链路处理。处理完成后,通过输出流返回应答给客户端,线程销毁,即典型的一请求一应答通信模型,具体原理如图5-1所示。
该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈1:1的正比关系,Java中的线程也是比较宝贵的系统资源,线程数量快速膨胀后,系统的性能将急剧下降,随着访问量的继续增大,系统最终就死掉了。
二、伪异步I/O编程
我们可以使用线程池来管理这些线程,实现一个或多个线程处理N个客户端的模型,但是底层还是使用同步阻塞I/O,通常被称为“伪异步I/O模型”,具体如图5-2所示。
我们知道,如果使用CachedThreadPool线程池,除了能自动帮我们管理线程(复用)外,看起来就像是1:1的客户端线程数模型,而使用FixedThreadPool可以有效地控制线程的最大数量,保证系统有限资源的控制,实现N:M的伪异步I/O模型。但是,正因为限制了线程数量,如果发生大量并发请求,超过最大数量的线程就只能等待,直到线程池中有空闲的线程可以被复用。
当对Socket的输入流进行读取操作的时候,它会一直阻塞,直到发生如下3种事件:
(1)有数据可读。
(2)可用数据已经读取完毕。
(3)发生空指针或I/O异常。
所以在读取数据较慢时(比如数据量大、网络传输慢等),大量并发的情况下,其他接入的消息只能一直等待,这就是最大的弊端。
三、NIO编程
少量的线程如何同时为大量连接服务呢?答案就是就绪选择。这就好比到餐厅吃饭,每来一桌客人,就有一个服务员专门服务,从你进餐厅到最后结账走人。这种方式的好处是服务质量好,一对一的VIP服务,可是缺点也很明显,成本高。如果餐厅生意好,同时来100桌客人,就需要100个服务员,老板发工资的时候得心痛死了。这就是传统的一个连接一个线程的方式。
老板是什么人,精着呢。老板得捉摸怎么能用10个服务员同时为100桌客人服务。老板发现,服务员在为客人服务的过程中并不是一直都忙着。客人点完菜,上完菜,吃着的这段时间,服务员就闲下来了。可是这个服务员还是被这桌客人占用着,不能为别的客人服务。怎么把这段闲着的时间利用起来呢?餐厅老板就想了一个办法,让一个服务员(前台)专门负责收集客人的需求,登记下来。比如有客人进来、点菜、结账,都先记录下来按顺序排好。每个服务员到这里领一个需求。比如点菜,服务员拿着菜单帮客人点菜去了。客人点好菜以后,服务员马上回来,领取下一个需求,继续为别的客人服务。这种服务方式质量不如一对一的服务,当客人需求很多的时候就需要等待。但好处也很明显,由于客人吃饭时服务员不用闲着,因此服务员这段时间内可以为其他客人服务。原来10个服务员最多同时为10桌客人服务,现在可以同时为50桌客人服务。
这种服务方式跟传统服务的区别有两个:
(1)增加了一个角色:专门负责收集客人需求的人。NIO里对应的就是Selector。
(2)由阻塞服务变为非阻塞服务,客人吃着的时候服务员不用一直候在客人旁边。传统的IO操作,比如read(),当没有数据可读的时候,线程一直阻塞被占用,直到有数据到来。NIO中没有数据可读时,read()会立即返回0,线程不会阻塞。
NIO工作原理如图5-3所示。
NIO中客户端创建一个连接后,先要将连接注册到Selector。相当于客人进入餐厅后,告诉前台你要用餐。前台会告诉你,你的桌号是几号。然后你就可以到那张桌子坐下了,SelectionKey就是桌号。当某一桌需要服务时,前台就记录那一桌需要什么服务。比如1号桌要点菜、2号桌要结账,服务员从前台取一条记录,根据记录提供服务,服务完了再来取下一条需求。这样服务的时间就被有效地利用起来了。
三、Java NIO和IO的主要区别如下:
1. 面向流与面向缓冲
Java NIO和IO之间最大的区别是,IO是面向流的,NIO是面向缓冲区的。Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,Java IO不能前后移动流中的数据。如果需要前后移动从流中读取的数据,就需要先将它缓存到一个缓冲区。 Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查该缓冲区中是否包含所有你需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。
2. 阻塞与非阻塞IO
Java IO的各种流是阻塞的。这意味着,当一个线程调用read()或write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。Java NIO的非阻塞模式使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用,就什么都不会获取,而不是保持线程阻塞。所以直至数据变的可以读取之前,该线程可以继续做其他的事情。非阻塞写数据也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。线程通常将非阻塞IO的空闲时间用在其他通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(Channel)。
Java NIO是一个可以替代标准Java IO API的新IO,提供了与标准IO不同的工作方式。Java NIO由3个核心部分组成:Channel、Buffer和Selector。
1. Channel(通道)
Channel是对数据的源头和数据目标点流经途径的抽象,在这个意义上和InputStream、OutputStream类似。Channel可以译为“通道”或者“管道”,而传输中的数据仿佛就像是在其中流淌的水。前面也提到了Buffer,Buffer和Channel相互配合使用,才是Java的NIO。
Java NIO的通道与流的区别是:①既可以从通道中读取数据,又可以写数据到通道,但流的读写通常是单向的;②通道可以异步地读写;③通道中的数据总是先读到一个Buffer,或者总是从一个Buffer中写入。
我们对数据的读取和写入要通过Channel。它就像水管一样,通道不同于流的地方就是通道是双向的,可以用于读、写和同时读写操作。数据可以从Channel读到Buffer中,也可以从Buffer写到Channel中,具体如图5-4所示。
从广义上来说,通道可以被分为两类:File I/O和Stream I/O,也就是文件通道和套接字通道。若分得更细致一点,则是:
FileChannel:从文件读写数据。
SocketChannel:通过TCP读写网络数据。
ServerSocketChannel:可以监听新进来的TCP连接,并对每个连接创建对应的SocketChannel。
DatagramChannel:通过UDP读写网络中的数据Pipe。
2. Buffer(缓冲区)
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便地访问这块内存。使用Buffer读写数据一般遵循以下4个步骤:
(1)写入数据到Buffer。
(2)调用flip()方法。
(3)从Buffer中读取数据。
(4)调用clear()方法或者compact()方法。
当向Buffer写入数据时,Buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入Buffer的所有数据。一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()方法和调用compact()方法。clear()方法会清空整个缓冲区;compact()方法只会清除已经读过的数据,任何未读的数据都会被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。
3. Selector(选择器)
Java NIO引入了选择器的概念,选择器用于监听多个通道的事件(比如连接打开、数据到达)。Selector提供选择已经就绪的任务的能力。Selector会不断轮询注册在其上的Channel,如果某个Channel上面发生读或者写事件,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey获取就绪Channel的集合,进行后续的I/O操作。一个Selector可以同时轮询多个Channel,因为JDK使用了epoll()代替传统的select实现,没有最大连接句柄1024/2048的限制,所以只需要一个线程负责Selector的轮询,就可以接入成千上万的客户端。
要使用Selector,得向Selector注册Channel,然后调用它的select()方法。这个方法会一直阻塞直到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子:新连接进来、数据接收等。
与Selector一起使用时,Channel必须处于非阻塞模式下。这意味着不能将FileChannel与Selector一起使用,因为FileChannel不能切换到非阻塞模式,而套接字通道可以。注意register()方法的第二个参数,这是一个“interest集合”,意思是通过Selector监听Channel时对什么事件感兴趣。可以监听4种不同类型的事件:Connect、Accept、Read和Write。
通道触发了一个事件,意思是该事件已经就绪。所以,某个Channel成功连接到另一个服务器称为“连接就绪”。一个Server Socket Channel准备好接收新进入的连接称为“接收就绪”。一个有数据可读的通道可以说是“读就绪”。等待写数据的通道可以说是“写就绪”。这4种事件用SelectionKey的4个常量来表示:SelectionKey.OP_CONNECT、SelectionKey.OP_ACCEPT、SelectionKey.OP_READ和SelectionKey.OP_WRITE。
一旦向Selector注册了一个或多个通道,就可以调用几个重载的select()方法。这些方法返回你所感兴趣的事件(如连接、接受、读或写)已经准备就绪的那些通道。换句话说,如果你对“读就绪”的通道感兴趣,select()方法会返回读事件已经就绪的那些通道。