配合示例代码
这一篇主要是讲基础的网络IO模型,也就是用什么样的通道进行数据的发送和接收。这在很大程度上决定了程序通信的性能。
JAVA有三种网络IOC模型:BIO、NIO、AIO。
BIO 同步阻塞IO。服务器为每一个客户端连接分配一个线程。BIO方式适用于连接数目小且固定的架构。这种方式对服务器资源要求比较高。编程模型简单,程序简单容易理解。是JDK1.4以前的唯一选择。
NIO 同步非阻塞IO。 服务器以一个线程来处理多个连接,客户端发送的连接请求都会被注册到多路复用器上,多路复用器轮询到有IO请求的连接,就进行业务处理。是目前应用最广泛的IO模型。适用于连接数多并且连接比较短的轻操作架构。比如聊天服务器、弹幕系统、服务器间心跳通讯等。编程模型最为复杂,JDK1.4开始支持。
AIO 异步非阻塞IO。 AIO引入了异步通道的概念,有效的请求才会启动线程。他的特点是先由操作系统完成后才通知服务端程序启动线程去处理。JDK7开始支持,适用于连接数比较多且连接时间比较长的应用。
这里两个重要的概念 同步异步 与 阻塞非阻塞。只需要注意一点,就是他们的对象。同步异步的概念是针对请求而言的。而阻塞非阻塞是针对应用而言。
BIO的功能代码都在java.io模块中。BIO的工作模型比较简单,就是来一个连接就启动一个线程。连接处理完了,线程就结束了。具体流程如下:
服务器端启动一个 ServerSocket
客户端启动 Socket 对服务器进行通信,默认情况下服务器端需要对每个客户 建立一个线程与之通讯
客户端发出请求后, 先咨询服务器是否有线程响应,如果没有则会等待,或者被拒绝
如果有响应,客户端线程会等待请求结束后,在继续执行
简单示例:
public class BioServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("服务启动完成");
while (true) {
//BIO每次会在这个地方阻塞住,只到有请求进来。
final Socket socket = serverSocket.accept(); //<====阻塞点1
System.out.println("有请求进来了。");
//通过inputStream解析客户端传过来的消息
final InputStream inputStream = socket.getInputStream();
byte[] buffer = new byte[1024];
int len;
len = inputStream.read(buffer); //<====阻塞点2
//通过outputStream给客户端返回响应
final OutputStream outputStream = socket.getOutputStream();
outputStream.write(("你的消息 :" + new String(buffer) + " 收到了。").getBytes());
outputStream.flush();
System.out.println("返回消息响应。");
inputStream.close();
outputStream.close();
socket.close();
}
}
}
public class BioClient {
public static void main(String[] args) throws IOException {
final Socket socket = new Socket("127.0.0.1", 8080);
System.out.println("建立连接完成");
//直接通过outputStream提交信息
socket.getOutputStream().write(("我是客户端;"+socket.getLocalAddress()).getBytes());
System.out.println("发送消息完成");
//通过inputStream获取信息
final InputStream inputStream = socket.getInputStream();
byte[] buffer = new byte[1024];
inputStream.read(buffer);
System.out.println("收到服务端的响应:"+new String(buffer));
inputStream.close();
socket.close();
}
}
BIO的问题:
1、对线程资源消耗非常大。 连接一旦多起来,线程数就会增多,服务器的资源压力就会比较大。
2、程序阻塞点。我们看这个简单应用的服务端,有两个阻塞点。serverSocket.accept这是第一个阻塞点。程序运行时会阻塞在这个地方,直到有客户端发起连接,程序才会往下走。
然后还有第二个阻塞点,就是在读取数据的地方inputStream.read(buffer); 应用每次读取信息都必须要读到数据。如果没有读到数据,程序也会阻塞在这个地方,直到读到数据也就是客户端有数据发过来为止。比如在这个应用中,如果服务端把阻塞点这样改造一下:
while (true) {
len = inputStream.read(buffer);
if (len == -1) {
break;
}
System.out.println("收到消息:" + new String(buffer, 0, len));
}
本来服务端是想要读取inputStream中的所有数据,但是程序最终都会在inputStream.read这个地方阻塞住。程序即不会终止,也不会往下走,一直阻塞住不动。这就是因为读不到数据时,BIO就会阻塞住。这也是BIO使用时非常容易出错的地方。
示例代码参见com.roy.bio包
AIO目前还没有广泛的应用。所以虽然在示例代码中整理了一个简单的示例,但是目前基本上还不需要深究。因为这样简单的Demo虽然代码很简单,但是其中的问题还是非常多的。更重要的是,AIO模式需要操作系统的支持,有非常多的细节需要操作系统的参与,这就导致对他的定制开发非常艰难。
从示例代码中可以看到AIO编程模型的简单特点,就是服务端与客户端的交互都完全没有阻塞,双方的交互过程都跟点外卖一样自由随意。
Server端的serverChannel会在accept方法中注册一个回调函数,接收到Client端请求后,不会有任何的阻塞,直接执行后面的代码去了。而等业务处理完成后,会主动调用这个回调函数。然后Server端在回调函数中对数据进行处理。而Clinet端在通过socketChannel写入请求后,也不用做任何等待,之后可以在任意时刻通过socketChannel.read方法读到服务端的响应。
示例代码参见com.roy.aio包
在这三种IO模型中,我们的重点肯定就是这个NIO了。NIO在JDK中有一个单独的模块java.nio。Netty就是基于NIO的一个IO框架。目前应用最为广泛的IO模型就是NIO了。
对这个模型的简单解读:
1、NIO的三大核心组件: Buffer、Channel和Selector。
这里有个非常经典的面试题,就是关于多路复用器的三种实现机制:Select、Poll和Epoll。这三种多路复用器是Linux操作系统提供的多路复用支持。实际上,这就是三个底层操作系统的API。
其中Select机制会维护一个文件描述符FD的结合fd_set。将fd_set从用户空间复制到内核空间,激活Socket。他是一个数组结构,所以是有大小限制的。
Poll机制:和Select机制是差不多的。不过他对fd_set结构进行了优化,集合大小突破了操作系统的限制。并且使用Pollfd结构代替了fd_set结构,通过链表实现。
EPoll机制:Event Poll。不再去扫描所有的文件描述符,只将用户关心的FD的事件存放到内核的一个事件表中。减少了用户空间与内核空间之间需要拷贝的数量大小。
多路复用机制是由操作系统提供的底层实现,java只是上层调用,所以这些概念,记住就行。关于多路复用器,可以查看JDK的rt.jar下的sun.nio.ch.DefaultSelectorProvider这个类。在这里可以看到,windows操作系统下的jdk采用的是WindowsSelectorProvider实现。而在Linux下,会根据Linux操作系统的内核版本进行选择。2.6版本以上采用EPollSelectorProvider实现,而2.6以下的版本采用的是PollSelectorProvider实现。对这三个机制的简单对比如下:
提出时间 操作方式 底层实现 最大连接数 IO效率 select 1984 遍历 数组 受限于内核 一般 poll 1997 遍历 链表 无上限 一般 epoll 2002 事件回调 红黑树 无上限 高
2、NIO整体的模型就是每个Channel会对应一个Buffer。而Channel都需要注册到Selector上才能被服务端处理。Selector对应操作系统中的一个线程,处理多个channel连接。
3、NIO是一个事件驱动的模型。 Selector切换到哪个Channel,是由Channel上是否有事件反生来决定的。所以事件Event也是NIO中很重要的一个概念。
了解了整个NIO的模型之后,就分别从各个组件逐一理解。
Buffer是网路IO数据与本地数据的一个缓冲。Channel提供了网络字节流与本地文件、内存等数据之间的交互渠道,而这些所有的交易都需要经过Buffer。
Buffer的类定义
在rt.jar的java.nio模块中,Buffer是一个顶级的抽象类,他还提供了非常多的子实现类,有ByteBuffer,ShortBuffer,CharBuffer,IntBuffer,LongBuffer,DoubleBuffer,FloatBuffer,分别用来处理不同的基础数据类型。这其中最为基础的就是ByteBuffer,任何数据最终都要转换成Byte字节流才能在网路上进行传输。例如对于字符串类型String,是没有StringBuffer的,字符串类型必须转为ByteBuffer才能进行传输。
然后,在Buffer抽象类中,定义了NIO的Buffer中最为核心的四个属性:
private int mark = -1; --标记位
private int position = 0; --当前操作的位置。指定下一次读写操作的起点位置。
private int limit; --缓冲区的当前终点。对缓冲的读写操作不能超过这个终点位置。这个终点位置是可以调整的。
private int capacity; --底层数组的容量
Buffer底层就是一个数组,而他正是通过这四个属性来定义相关的操作限制。例如读数据时,position不能超过limit。具体可以参见示例中的BufferDemo。
public class BufferDemo {
public static void main(String[] args) {
//可以跟踪下IntBuffer的limit、position、mark三个属性来理解下这些操作
//定义Buffer的数组大小
final IntBuffer intBuffer = IntBuffer.allocate(5);
//往Buffer数组中添加数据,不能超过他的容量。超过容量会报错。
for(int i = 0 ; i < intBuffer.capacity();i ++){
intBuffer.put( i*5 );
}
//由写转为读必须要进行一下切换。本质是limit变更为position,限定后面的读操作不能超过之前写入的数据范围
// 另外将position调整到0,表示后续从数组的开始位置读
intBuffer.flip();
//查看第二个位置的数据
System.out.println("读取指定位置的数据:"+intBuffer.position(2));
while (intBuffer.hasRemaining()){
//顺序读
System.out.println("依次读取数组中的数据:"+ intBuffer.get());
}
//创建一个字节流
ByteBuffer buffer = ByteBuffer.allocate(64);
//按类型写入数据,本质就是按固定的大小写入字节流。
buffer.putInt(100);
buffer.putLong(9);
buffer.putChar('王');
buffer.putShort((short) 4);
//读写切换
buffer.flip();
//按照固定的大小顺序读字节,才能获取正确的值。顺序换了依然能读到字节流,但是无法还原成原始数据。
System.out.println(buffer.getInt());
System.out.println(buffer.getLong());
System.out.println(buffer.getChar());
System.out.println(buffer.getShort());
}
}
NIO中的Channel类似于流,只是BIO中的Stream是单向的,InputStream只能读取数据,OutputStream只能输出数据。但是NIO中的Channel是双向的,即可以读,也可以写。利用Channel通道可以实现异步读写数据。并且,Channel本身可以缓存数据,可以将数据先读入到Channel,然后再写入到其他的缓冲区。
Channel的类定义
Channel在NIO中是一个顶级的接口java.nio.channels.Channel。由此衍生出非常多的子接口与实现类。例如AIO中的AsynchronousChannel接口。
几个最为常用的实现类有:
FileChannel:实现文件读写的通道。
DatagramChannel:处理UDP连接的数据通道。
ServerSocketChannel和SocketChannel:处理TCP连接的数据通道。后面也主要是会用这两个通道做网络IO
其中关于FileChannel见示例代码中的FileChannelDemo1。示例中实现了文件的写入、复制以及读取。
然后关于NIO的零拷贝,也整理了两个实例,在nio/zerocopy包下。
另外,关于NIO中的零拷贝,是很多中间件的重要优化方式。
有两种方式实现零拷贝。一种是mmap,文件映射的方式。一种是sendFile文件传输的方式。
关于零拷贝:Linux操作系统分为【用户态】和【内核态】,文件操作、网络操作需要涉及这两种形态的切换,免不了进行数据复制。
一台服务器 把本机磁盘文件的内容发送到客户端,一般分为两个步骤:
1)read;读取本地文件内容;
2)write;将读取的内容通过网络发送出去。
这两个看似简单的操作,实际进行了4 次数据复制,分别是:
1 - 从磁盘复制数据到内核态内存;
2 - 从内核态内存复 制到用户态内存;
3 - 然后从用户态 内存复制到网络驱动的内核态内存;
4 - 最后是从网络驱动的内核态内存复 制到网卡中进行传输。在这个过程中,mmap方式,可以省去第二步的内存复制,用户内存中只保留文件的映射,而不需要保留文件的内容。这样可以减少一次拷贝次数以及上下文切换,提高速度。适合小文件的读取。在RocketMQ中就大量的运用了这种机制来进行消息持久化。Kafka中也有一小部分文件是用的这种方式来保存的。
而sendFile方式可以在内核态使用DMA内存直接拷贝,减少了CPU的参与。适合大文件的传输。Kafka中大量的运用这种方式将消息从硬盘拷贝到网卡。
这东西实现很简单,API都封装好了。只是面试喜欢问。记一下就行。
NIO中会在服务端以一个线程来管理所有客户端的请求,这时就会使用到Selector选择器。关于Selector选择器首先要注意下之前介绍过的三种实现机制。然后还有几个需要注意的地方:
1、Netty的IO线程NioEventLoop中聚合了Selector,一个Selector可以同时处理成百上千个客户端连接。但是如果客户端太多,那还是需要搭建集群横向扩展了。
2、NIO中,所有客户端的连接以Channel的形式注册到Selector上,然后一个处理线程会监听所有Channel上的事件。
3、线程只有在Channel上有特定事件发生时才来处理对应的事件,没有事件发生时,线程可以进行其他任务。
Selector的类定义
在java.nio包中,Selector对应一个顶级的抽象类java.nio.channels.Selector。往下就可以找到对应的最终实现类。windows操作系统下的jdk采用的是WindowsSelectorProvider实现。而在Linux下,会根据Linux操作系统的内核版本进行选择。2.6版本以上采用EPollSelectorProvider实现,而2.6以下的版本采用的是PollSelectorProvider实现。
然后Selector中有几个关键的方法需要了解下。
图中的关键点:
1、不光客户端的SocketChannel需要注册到selector上,服务端的ServerSocketChannel也需要注册到selector上。
2、客户端的SocketChannel注册到selector上后,服务端的ServerSocketChannel可以得到所有的SocketChannel。
3、服务端通过Selector进行监听,select方法阻塞,随时返回有事件发生的通道的个数。
4、服务端可以在运行过程中在selector上动态注册SocketChannel。
5、服务端处理业务时,通过注册时的SelectionKey,反向获取对应客户端的SocketChannel。
6、通过channel完成业务处理,并与客户端进行沟通。
然后关于NIO的编程方式,在Demo中做了两个示例。一个基础的客户端与服务端简单通信的示例。这个示例其实也不需要强行去记忆,大部分都是模板化的代码。重点是要理解清楚上面提到的NIO模型。
还一个是基于NIO的聊天室。如果理解了上面的简单通信机制,那这个聊天室也就很容易理解了。无非就是上一个Demo只找一个Channel,向一个客户端发消息。而聊天室就是找所有的Channel去通信。
原生的NIO虽然也能做出比较好的JAVA应用,但是IO其实是一个操作系统底层的操作。大量的功能都是由JVM底层的C、C++代码来完成的。JAVA只是做了上层封装,所以终归是有些别扭的。面对复杂的IO系统,还是容易暴露出一些问题,比如:
1、NIO的类库和API非常繁杂,使用起来非常麻烦。需要对底层的这些selector、SocketChannel、ServerSocketChannel等这些操作非常熟练。很多NIO的代码,如果对这些底层操作不是很熟悉的话,很多业务代码,从整体上来看,都是完全脱节的,上下并没有太多逻辑关联。这导致开发和维护都非常困难。
2、开发的难度和工作量也很大。我们的示例大都是基于本地网卡实现的,一般来说还不会有太多的变数。但是如果真正面对互联网这样复杂的网络环境时,还有非常多的问题需要去解决。比如客户端心跳检测、网络闪断、异常流、TCP粘包拆包等。而java的nio包,更多的是面向底层机制的封装,应用层面的封装并不多。这些问题,原生NIO也能解决,但是解决起来非常的麻烦。
3、还一个最大的问题就是,JAVA其实对底层的一些核心机制能做的事情非常有限。比如Selector的多路复用机制,面试最喜欢问的问题。但是JAVA的NIO中Selector有一个非常著名的Epoll BUG,若Selector的轮询结果为空,也没有wakeup或者新消息处理,就会造成空轮询,CPU使用率迅速上升到100%。这个BUG,JAVA官方就一直没有解决,只到Netty出现后,才在业务使用的层面处理了这个BUG。
因此,在实际项目中,很少有人会用原生的NIO进行编程。大家都需要一个完善的应用框架来处理NIO的各种各样的问题。而这个框架,就是Netty。