什么是IO?
IO指 Input(输入流)、OutPut(输出流) 以支持JAVA应用对外传输交换数据。传输入目标可以是文件、网络、内存等。
IO模型的演变史
随着对传输效率的要求越来越高,java也逐步演变出了三代IO模型分别是 BIO、NIO、AIO。
BIO 同步阻塞式 (Blocking I/O)
在java1.4之前 这种传输的实现 只能通过inputStream和OutputStream 实现。这是一种阻塞式IO模型,应对连接数不多的文件系统还好,如果应对的是成千上万的网络服务,这种阻塞式模型就会造成大量的线程占用,造成服务器无法承载更高的并发。
NIO 同步非阻塞式(Non Blocking I/O)
为解决这个问题 java1.4之后引入了NIO ,它通过双向管道进行通信,并且支持以非阻塞式的方式进行,就解决了网络传输导致线程占用的问题。Netty其在底层就是采用这种通信模型。
AIO 同步非阻塞式(Asynchronous Blocking I/O)
NIO的非阻塞的实现是依赖选择器 对管道状态进行轮循实现,如果同时进行的管道较多,性能必会受影响,所以java1.7引入了 异步非阻塞式IO,通过异步回调的方式代替选择器。这种改变在windows下是很明显,在linux系统中不明显。现大部分JAVA系统都是通过linux部署,所以AIO直正被应用的并不广泛。所以我们接下学的学习重点更关注到BIO与NIO的对比。
BIO与NIO模型区别
两组模型最大的别区在于阻塞与非阻塞,而所谓的阻塞是什么呢?而非阻塞又是如何解决的呢?
在阻塞模型中客户端与服务端建立连接后,就会按顺序发生三个事件
- 客户端把数据写入流中。(阻塞)
- 通过网络通道(TPC/IP)传输到服务端。(阻塞)
- 服务端读取。
这个过程中服务端的线程会一直处阻塞等待,直到数据发送过来后执行第3步。
如果在第1步客户端迟迟不写入数据,或者第2步网络传输延迟太高,都会导致服务端线程阻塞时间更长。所以更多的并发,就意味着需要更多的线程来支撑。
为了解决BIO的线程等待问题,于是出现了NIO。BIO模型里是通1对1线程来等待 第1、2步的完成。而在NIO里是指派了选择器(Selector)来检查,是否满足执行第3步的条件,满足了就会通知线程来执行第3步,并处理业务。这样1、2步的延迟就与 用于处理业务线程无关。
NIO简易模型
多了一个IO线程,用于处理IO,所有的请求都需要通过IO进行管理,当请求发送过来的时候IO线程通知相应的工作线程开始工作,在此之前工作线程是可以干别的事情的,保证工作线程不用阻塞在网络连接过程中
IO线程只是检测连接的状态(Selector)判断连接是否发送请求过来
Tomcat BIO通信模型图
Acceptor:用来建立连接 ServerSocket.accept();
BIO中的阻塞指的是在Socket建立连接后,客户端和服务器如果不进行数据的传送的阻塞
Tomcat 非阻塞式IO通信模型图
IO线程负责建立连接,当数据穿送过来后,将数据写入到缓冲区,再异步的通知线程池执行任务,这时数据已经在内存的缓冲区中,所以不会产生阻塞
NIO基础组件Channel和Buffer
在BIO API中是通过InputStream 与outPutStream 两个流进行输入输出。而NIO 使用一个双向通信的管道代替了它俩。管道(Channel)必须依赖缓冲区(Buffer)实现通信
- 性能提高
- 增加功能
- 实现堆外内存映射(零拷贝)
缓冲区Buffer定义与结构
所有管道都依赖了缓冲区,必须先掌握它。所谓缓冲区就是一个数据容器内部维护了一个数组来存储。Buffer缓冲区并不支持存储任何数据,只能存储一些基本类型,就连字符串也是不能直接存储的。
Buffer只能存储基本类型
Buffer 内部结构
在Buffer内部维护了一个数组,同时有三个属性我们需要关注即:capacity:容量, 即内部数组的大小,这个值一但声明就不允许改变position:位置,当前读写位置,默认是0每读或写个一个位就会加1limit:限制,即能够进行读写的最大值,它必须小于或等于capacity
有了capacity做容量限制为什么还要有limit,原因往Buffer中写数据的时候 不一定会写满,而limit就是用来标记写到了哪个位置,读取的时候就不会超标。
如果读取超标就会报:BufferUnderflowException
同样写入超标也会报:BufferOverflowException
假设容器的容量为6,设置好后就不能再改变
public class IntBufferTest {
@Test
public void test1(){
IntBuffer allocate = IntBuffer.allocate(6);// position=0、limit=6、capacity=6
allocate.put(1);
allocate.put(2); // position=2、limit=6、capacity=6
allocate.flip(); // position=0、limit=2、capacity=6
allocate.get(); // position=1、limit=2、capacity=6
allocate.get(); // position=2、limit=2、capacity=6
// allocate.put(3); // 超出limit 限制 ==》 BufferOverflowException
//allocate.get(); // 超出limit 限制 ==》 BufferUnderflowException
// Buffer 没有删除数据这个说法
// 循环利用空间
// 回到缓冲的初始状态
allocate.clear(); position=0、limit=6、capacity=6
for (int i = 0; i < 6; i++) {
allocate.put(i+1);
}
allocate.flip();
allocate.get();
allocate.get();
allocate.mark();
int i = allocate.get()+100;
int i1 = allocate.get()+100;
allocate.reset();
allocate.put(i);
allocate.put(i1);
System.out.println(Arrays.toString(allocate.array()));
}
}
Buffer核心使用
- allocate:声明一个指定大小的Buffer,position为0,limit为容量值
- wrap:基于数组包装一个Buffer,position为0,limit为容量值
flip操作
为读取做好准备,将position 置为0,limit置为原position值
clear操作
为写入做好准备,将position 置为0,limit置为capacity值
注:clear不会实际清空数据
mark操作
添加标记,以便后续调用 reset 将position回到标记,常用场景如:替换某一段内容
目前我们知道,总共有4个值,分别是 mark、position、limit、capacity它们等于以下规则:
0 <= 标记 <= 位置 <= 限制 <= 容量
rewind操作
为重新读取做准备,position置位0,limit不变
remaining():表示limit和position之间的剩余空间
Channel定义
管道用于连接文件、网络Socket等。它可同时同时执行读取和写入两个I/O 操作,固称双向管道,它有连接和关闭两个状态,在创建管道时处于打开状态,一但关闭 在调用I/O操作就会报ClosedChannelException
。通过管道的isOpen
方法可判断其是否处于打开状态。
FileChannel 文件管道
固名思议它就是用于操作文件的,除常规操作外它还支持以下特性:
- 支持对文件的指定区域进行读写
- 堆外内存映射,进行大文件读写时,可直接映射到JVM声明内存之外,从面提升读写效率。
- 零拷贝技术,通过
transferFrom
或transferTo
直接将数据传输到某个通道,极大提高性能。 - 锁定文件指定区域,以阻止其它程序员进行访问
打开FileChannel目前只能通过流进行间打开,如inputStream.getChannel() 和outputStream.getChannel() ,通过输入流打开的管道只能进行取,而outputStream打开的只能写。否则会分别抛出NonWritableChannelException与NonReadableChannelException异常。
如果想要管道同时支持读写,必须用RandomAccessFile
读写模式才可以。
FileChannel示例:
public class FileChannelTest {
String file_name="/Users/tommy/temp/coderead-netty/test.txt";
@Test
public void test1() throws IOException {
//1. 打开文件管道
/*FileInputStream inputStream=new FileInputStream(file_name);
FileChannel channel = inputStream.getChannel();*/
FileChannel channel = new RandomAccessFile(file_name,"rw").getChannel();
ByteBuffer buffer=ByteBuffer.allocate(1024); // 声明1024个空间
// 从文件中 读取数据并写入管道 再写入缓冲
channel.read(buffer);
buffer.flip();
byte[] bytes= new byte[buffer.remaining()];
int i =0;
while (buffer.hasRemaining()){
bytes[i++]= buffer.get();
}
// position=10 limit=10
System.out.println(new String(bytes));
// 把缓冲区数据写入到管道
channel.write(ByteBuffer.wrap("hello 大叔".getBytes()));
channel.close();
}
}
read方法会将数据写入到buffer 直到Buffer写满或者数据已经读取完毕。count 返回读取的数量,-1表示已读取完毕。
关于堆外内存映射与零拷贝等技术术,将会在后续章节中详细说明
DatagramChannel UDP套接字管道
udp 是一个无连接协议,DatagramChannel就是为这个协议提供服务,以接收客户端发来的消息。
udp实现步骤如下:
// 1.打开管道
DatagramChannel channel = DatagramChannel.open();
// 2.绑定端口
channel.bind(new InetSocketAddress(8080));// 绑定端口
ByteBuffer buffer = ByteBuffer.allocate(8192);
// 3.接收消息,如果客户端没有消息,则当前会阻塞等待
channel.receive(buffer);
public class DatagramChannelTest {
@Test
public void test1() throws IOException {
DatagramChannel channel=DatagramChannel.open();
// 绑定端口
channel.bind(new InetSocketAddress(8080));
ByteBuffer buffer=ByteBuffer.allocate(8192);
while (true){
buffer.clear(); // 清空还原
channel.receive(buffer); // 接收数据,如果没有数据则阻塞
buffer.flip();
byte[] bytes=new byte[buffer.remaining()];
buffer.get(bytes);
System.out.println(new String(bytes));
}
}
}
nc -vu 127.0.0.1 8080 这个命令可向udp发送消息
TCP套接字管道
TCP是一个有连接协议,须建立连接后才能通信。这就需要下面两个管道:
- ServerSocketChannel :用于与客户端建立连接
- SocketChannel :用于和客户端进行消息读写
TCP管道实现步骤如下:
Plain Text// 1.打开TCP服务管道
ServerSocketChannel channel = ServerSocketChannel.open();
// 2.绑定端口
channel.bind(new InetSocketAddress(8080));
// 3.接受客户端发送的连接请求,(如果没有则阻塞)
SocketChannel socketChannel = channel.accept();
ByteBuffer buffer=ByteBuffer.allocate(1024);
// 4.读取客户端发来的消息(如果没有则阻塞)
socketChannel.read(buffer);
// 5.回写消息
socketChannel.write(ByteBuffer.wrap("返回消息".getBytes()));
// 6.关闭管道
socketChannel.close();
可通过命令进行测试TCP服务 telnet 127.0.0.1 8080
上述例子接收一个消息,并返回客户端,然后关闭。它只能处理一个客户端的请求,然后整个服务就结束了。如果想要处理多个请求,我们可以加上一个循环来接收请求,然后在分配一个子线程去处理每个客户端请求。
ServerSocketChannel channel = ServerSocketChannel.open();
channel.bind(new InetSocketAddress(8080));
while(true){
SocketChannel socketChannel = channel.accept();
// 使用子线程处理请求
new Thread(()->handle(socketChannel)).start();
}
处理客户端请求
private void handle(SocketChannel socketChannel){
ByteBuffer buffer=ByteBuffer.allocate(1024);
// 1.读取客户端发来的消息(如果没有则阻塞)
socketChannel.read(buffer);
// 返回消息
socketChannel.write(ByteBuffer.wrap("返回消息".getBytes()));
// 关闭管道
socketChannel.close();
}
至此我们已完成了一个简单但完整的 请求响应模型。这个模型中每个客户端连接都会有一个子线程进行处理。在没有读到消息前这个线程会一直阻塞在read方法中。所以不难推断这就是一个典型的BIO阻塞模型。
那怎么在管道中实现非阻塞的NIO模型呢?下节课我们继续探讨。
public class ServerSocketChannelTest {
@Test
public void test1() throws IOException {
ServerSocketChannel channel = ServerSocketChannel.open();
channel.bind(new InetSocketAddress(8080));
// 1.建立连接
// 2.通信
ByteBuffer buffer = ByteBuffer.allocate(8192);
while (true) {
handle(channel.accept());
}
}
public void handle(final SocketChannel socketChannel) throws IOException {
// 2.通信
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
ByteBuffer buffer = ByteBuffer.allocate(8192);
while (true) {
try {
buffer.clear();
socketChannel.read(buffer);
// 从buffer 当中读出来
buffer.flip();
byte[] bytes = new byte[buffer.remaining()];
buffer.get(bytes);
String message = new String(bytes);
System.out.println(message);
// 写回去
buffer.rewind();
socketChannel.write(buffer);
if (message.trim().equals("exit")) {
break;
}
} catch (Exception e) {
e.printStackTrace();
}
}
try {
socketChannel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
});
thread.start();
}
}