1. 阻塞式I/O(BIO)
① 一请求一应答
- BIO的描述:
- 采用BIO通信模型的服务端,通常由一个独立的Accepor线程负责监听客户的连接请求。一般会在
while(true)
循环中,不断调用accpet()
函数接收客户的连接请求。 - 如果有多个客户端连接请求,可以将每个socket设置为一个线程,通过多线程实现多个socket的支持。
- 由于BIO中的
accpet()
和read()
、write()
函数都是阻塞型的,必须使用多线程。即一个socket连接对应一个线程。 - 在Java中Acceptor线程对应的
ServerSocket
对象,ServerSocket
对象通过accpet()
获取已经就绪的socket连接,并返回socket对象。之后,服务器便可以通过该socket对象与客户进行socket通信。
② BIO的通信实例
- 服务器端:
- 创建ServerSocket对象,在构造函数中绑定port。
- 在while(true)循环中不断获取就绪的socket连接,然后为每个socket连接创建一个单独的线程,用于与客户通信。
- 输入输出就是普通的字节流输入输出,通过socket对象的
getInputStream
和getOutputStream
方法可以获取输入输出流。
package socket;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(9999);
while (true) {
Socket socket = serverSocket.accept();
// 接收到客户端连接后,为每个socket创建一个线程
new Thread(new Runnable() {
@Override
public void run() {
// 读数据缓冲区
byte[] data = new byte[1024];
InputStream sin = null;
try {
sin = socket.getInputStream();
int len = 0;
// 读取数据直到数据结尾
while ((len = sin.read(data)) != -1) {
System.out.println(new String(data, 0, len));
}
} catch (IOException e) {
e.printStackTrace();
}finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}).start();
}
}
}
- 客户端:
- 创建socket对象,在构造函数中指定服务器的IP地址和port。
- 如果需要传输文字,可以通过String对象的
getBytes()
方法获取文字以utf-8
的方式编码后的字节流。
package socket;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
public class Client {
public static void main(String[] args) throws IOException {
Socket socket=new Socket("127.0.0.1",9999);
OutputStream sout=socket.getOutputStream();
sout.write("你好,服务器!".getBytes("utf-8"));
socket.close();
}
}
③ BIO中资源耗尽的问题
- 如果为每一个socket都创建一个线程,会存在很多问题:
- 线程的创建、销毁、切换,需要消耗大量的系统资源。有的socket连接在创建以后,甚至不会进行任何通信。
- 尤其是在Linux操作系统中,每个线程都看作一个轻量级的进程。
- 如果存在大量的客户访问服务器,会导致系统资源的压力激增,会出现线程堆栈溢出、创建新线程失败等情况。
- 这些原因,最终可能会导致服务器不能对外提供服务。
- 解决办法: 将socket封装成实现了
Runnable
接口的Task,通过线程池控制socket连接锁占用的资源。
2. 非阻塞式I/O(NIO)
① 关于java的NIO
- NIO即
New Input/Output
,对应java.nio
包,是JDK1.4中引入的。 - NIO与IO有相同的作用和目的,但是NIO提供了基于通道(channel)和缓冲区(Buffer)的、面向块的I/O,效率比IO高很多。
- 直接内存: 在JVM中,可以通过Native函数库直接分配堆外内存,使用JVM栈中的
DirectByteBuffer
对象作为这块内存引用进行操作。这样可以在一些场景中显著提高性能,因为避免了在堆內和堆外来回拷贝数据。
② 流与块
- NIO与IO最大的区别在于数据打包和传输方式:NIO以块的方式处理数据,IO以流的方式处理数据。
- 面向流的I/O:
- 一次处理一个字节数据: 一个输入流产生一个字节数据,一个输出流消费一个字节数据。
- 过滤器使得数据处理变得简单: 为流式数据创建过滤器非常容易,链接几个过滤器,每个过滤器负责复杂处理机制的一部分。
- 速度慢: 面向流的I/O通常很慢。
- 面向块的I/O:
- 一次处理一个数据块: 按块处理数据比按流处理数据要快的多。
- 面向块的I/O缺少一些面向流的I/O的优雅性和简单性,而且通过对
java.io
以NIO为基础进行重新实现,使得面向流的处理速度变得更快。
③ 核心组件——通道
- 通道(Channel)是对IO中流的模拟,NIO通过通道读取和写入数据。
- 通道与流的比较:
- 通道是双向的,可读、可写或者同时用于读写。
- 流只能在一个方向上移动,比如一个进程的
InputStream
必须对应另一进程的OutputStream
。要想实现双向通信,必须同时创建InputStream
和OutputStream
。
- NIO中的通道类型:
- FileChannel: 从文件中读写数据。
- DatagramChannel: 通过
UDP
读写网络中的数据。 - SocketChannel: 通过
TCP
读写网络中的数据。 - ServerSocketChannel: 与
ServerSocket
作用一致,监听新进来的TCP连接,对每一个新进来的连接都会创建一个SocketChannel
。
④ 核心组件——缓冲区
- NIO中数据的读写都要先经过缓冲区:
- 在NIO中,发送给一个通道的所有数据都要先放到缓冲区中;同样的,从一个通道中读取任何数据都要先读到缓冲区中。
- NIO中不会直接对通道进行数据读写操作,而是要先经过缓冲区。
- NIO中数据读取和写入的示意图:
- 缓冲区对应的Buffer类:
- 缓冲区实质上是一个数组,但又不止是一个数组。它不仅提供了对数据的结构化访问,还可以跟踪系统的读/写进程。
- 缓冲区对应NIO中的Buffer类,除了
Boolean
类型,基本数据类型都有对应的缓冲区。即除了Boolean
类型,基本数据类型都有对应的Buffer类的子类。 - 缓冲区的常见类型:
ByteBuffer
、CharBuffer
、ShortBuffer
、IntBuffer
、LongBuffer
、FloatBuffer
、DoubleBuffer
。 - 最常用的缓冲区是
ByteBuffer
,ByteBuffer
提供了一组操作byte数组的功能。
- 缓冲区中的三个状态变量:
- capacity: 记录缓冲区的最大容量,一般新建一个缓冲区时,
limit
和capacity
的值默认相等。 - position: 记录已经从缓冲区中读了或者向缓冲区写了多少数据,指向下一个将要读取或者写入的字节。
- limit: 记录缓冲区还有多少数据可以写入或多少数据可以读出,值小于等于
capacity
。 - 可以通过
clear()
方法和flip()
方法更改这三个状态变量的值。
- 缓冲区状态变量的举例:
- 初始化时,
position
指向缓冲区的头部,limit
和capacity
指向缓冲区的末尾。整个过程中,capacity
的位置不会发生改变。容量为8的缓冲区,position = 0
,limit = capacity = 8
。
- 从输入通道读取5个字节到缓冲区中,
position = 5
,limit
保持不变。
- 调用
flip()
方法,更新position
和limit
的位置,为将数据从缓冲区写到输出通道做准备。position = 0
,limit = 5
。
- 从缓冲区写四个字节到输出通道,
position = 4
。
- 调用
clear()
方法将缓冲区清空,position和limit都将回到最初的位置。position = 0
,limit = 8
。 flip()
和clear()
方法的代码如下:
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
- NIO的文件读写实例:
package socket;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class FileRead {
public static void main(String[] arg) {
try {
// 获取源文件的输入字节流
FileInputStream fin=new FileInputStream("C:\\Users" +
"\\zebra\\IdeaProjects\\HelloWorld\\src\\PhilosopherProblem.java");
// 获取输入字节流的输入通道
FileChannel fcin=fin.getChannel();
// 获取目的文件的输出字节流
FileOutputStream fout=new FileOutputStream("C:\\Users" +
"\\zebra\\Desktop\\1.java");
FileChannel fcout=fout.getChannel();
// 建立缓冲区
ByteBuffer bf=ByteBuffer.allocateDirect(1024);
int len=0;// 记录实际读取数据的大小
// 以块为单位,快速将源文件的内容拷贝到目的文件
while (true){
// 从输入通道读取数据到缓冲区中,并判断是否读到文件末尾
if ((fcin.read(bf))==-1){
break;
}
// 切换读写
bf.flip();
// 将缓冲区中的数据写入到输出通道
fcout.write(bf);
// 清空缓冲区
bf.clear();
}
fcin.close();
fcout.close();
fin.close();
fout.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
⑤ 核心组件——选择器
- 为什么NIO被叫做非阻塞I/O(Non Blocking I/O)?
- NIO提供了与IO中
Socket
、ServerSocket
两种套接字相对应的套接字通道:SocketChannel
、ServerSocketChannel
。 SocketChannel
、ServerSocketChannel
都支持阻塞模式和非阻塞模式,可以通过channel.configureBlocking(false)
设置为非阻塞模式。- NIO在网络通信中的非阻塞模式广泛被使用,因此进场将NIO叫做非阻塞I/O。
- 注意: 只有套接字通道才能设置为非阻塞模式,
FileChannel
不能设置。 - NIO实现了I/O多路复用中的Reactor模型:
- 占用一个线程的选择器
Selector
通过轮询的方式监听多个通道上的事件,这样一个线程可以处理多个事件。 - 将被监听的通道设置为非阻塞模式,
Selector
可以立即获取通道的状态。 - 如果该通道上的事件未就绪,
Selector
可以继续轮询其他通道。
- Selector的优点: 因为线程创建、切换、销毁的开销比较大,因此使用一个线程管理多个socket连接,对于具有大量socket连接的场景可以带来很好的性能。
⑥ NIO与IO的区别
- NIO与IO的区别:
- 是否支持非阻塞I/O: NIO中的socket通信支持阻塞和非阻塞两种模式,而IO只支持阻塞模式。因此我们通常将NIO叫做非阻塞I/O,而非
New I/O
。 - 数据处理: NIO面向块进行数据处理,速度较快;IO面向流进行数据处理,处理的速度较慢。
- 通行方式: NIO基于通道进行通信,而通道是双向的,可读、可写或同时读写;IO面向流进行通信,只能单向通信。
3. NIO的编程实例
① NIO+Selector实现高性能的服务器
- 通过selector实现服务的具体思想:
- 先创建
ServerSocket
通道,为通道对应的ServerSocket
绑定ip和port。 - 创建Selector对象,将
ServerSocket
通道设置为非阻塞模式,然后向Selector注册ServerSocket
通道。 - 在while循环中不断调用
selector.select()
,select()
会一直阻塞直到至少有一个事件就绪。 - 获取selector监测到的就绪事件,这些事件通过
SelectionKey
进行记录。 - 通过迭代
SelectionKey
,针对不同的就绪事件采取不同的操作。处理完事件后,手动移出该事件。
package NIOSocket;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
public class Server {
public static void main(String[] args) throws IOException {
// 创建服务器通道,为服务器通道绑定ip和port
ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.socket().bind(new InetSocketAddress("127.0.0.1", 9999));
// 创建selector对象,将服务器通道置为非阻塞模式,并向selector注册该通道
Selector selector = Selector.open();
ssChannel.configureBlocking(false);
ssChannel.register(selector, SelectionKey.OP_ACCEPT);
// 循环执行selector的select方法
while (true) {
// select方法会监听是否有事件就绪,它会一直阻塞直到有事件就绪
selector.select();
// 获取就绪事件的SelectionKey,通过迭代遍历SelectionKey
// 针对不同的SelectionKey,对其对应的通道实行不同的操作
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iterator = keys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 有新的的socket连接到达,需要为该连接创建socket通道
// 将通道设置为非阻塞模式,然后将新的socket通道注册到selector中
if (key.isAcceptable()) {
ServerSocketChannel temp = (ServerSocketChannel) key.channel();
SocketChannel channel = temp.accept();
channel.configureBlocking(false);
channel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
// 通道可读,从通道上读取数据,读取完后关闭通道
SocketChannel temp = (SocketChannel) key.channel();
readData(temp);
temp.close();
}
// 手动移出已经处理过的I/O事件
iterator.remove();
}
}
}
public static void readData(SocketChannel socketChannel) throws IOException {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
StringBuilder sb=new StringBuilder();// 记录最终的数据
int len = 0;
while (true) {
if ((len = socketChannel.read(buffer)) == -1) {
break;
}
buffer.flip(); // 切换读写
int limit = buffer.limit();
// 获取缓冲区的中数据
char[] chars = new char[limit];
for (int i = 0; i < limit; i++) {
chars[i] = (char) buffer.get(i);
}
sb.append(chars);
buffer.clear();
}
System.out.println(sb);
}
}
② 简单的客户端
- 客户端的具体流程:
- 创建
SocketChannel
,调用通道的connect()
方法与服务器进行连接。 - 创建缓冲区,向缓冲区中放入数据,通过
flip()
方法切换读写。 - 调用通道的
write()
方法,向服务器发送数据。
package NIOSocket;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
public class Client {
public static void main(String[] args) throws IOException {
SocketChannel channel = SocketChannel.open();
channel.connect(new InetSocketAddress("127.0.0.1", 9999));
channel.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(1024);
buffer.put("hello, lucy!".getBytes());
buffer.flip();
channel.write(buffer);
channel.close();
}
}
4. 异步I/O(AIO)
① AIO简介
- AIO是
JDK1.7
中引入的NIO的改进版,称为NIO 2.0
,它是异步的I/O模型。 - AIO基于事件和回调机制实现: 当执行某个I/O操作时,当前线程不会阻塞,而是立即返回;操作系统在I/O操作完成后,会主动通知相应的线程进行后续的操作。
- NIO中虽然I/O操作也会立即返回,但是并不保证I/O事件已就绪,需要通过轮询监听I/O事件是否就绪。如果I/O事件已就绪,还需要线程自己去完成数据的读写操作。
- 关于Netty:
- Netty是一个异步、事件驱动的,具有高性能、高可靠性的网络应用框架。
- 实质并没有使用AIO,而是使用的NIO。
- Netty解决了NIO不易使用的问题,如果需要使用NIO,可以使用Netty框架。
② java几种I/O的应用场景
- BIO适用于连接数目比较少且固定的架构,在
JDK 1.4
以前只能使用BIO进行socket通信。 - NIO适用于连接数目比较多且连接比较短的架构,如聊天服务器,从
JDK 1.4
开始支持。编程复杂,可以使用网络框架Netty。 - AIO适用于连接数目比较多且连接比较长的架构,如相册服务器,从
JDK 1.7
开始支持。虽然可以充分调用操作系统的并发,但编程复杂。
5. 面经问题总结
**1. Java中NIO的原理 **
- 基础: NIO面向块进行数据处理,三个核心组件:通道、缓冲区、selector。
- 进阶: NIO与IO的区别(是否支持非阻塞I/O、数据处理、通信方式)
- 高阶: 对I/O对路复用的讲解(select、poll、epoll)
2. Java中的BIO、NIO、AIO
- BIO: 以client和server之间的socket连接为例,
accept()
函数会一直阻塞直到有连接就绪。包括read()、write()方法都是阻塞式的,一个连接需要对应一个线程。 - NIO: 不停的轮询事件是否就绪,如果事件已就绪,则执行相应的I/O操作。面向块 + 三个核心组件,为什么会叫做非阻塞I/O而不是
New I/O
。 - AIO: 异步I/O,基于事件和回调机制。一个线程发起一个I/O操作后可以立即返回,操作系统完成I/O操作后通知线程执行后续操作。
NIO
编程比较复杂,一般使用Netty
网络框架。
3. NIO中缓冲区中的三种状态量
- 三种状态量:position、limit、capacity。
- 初始时,从输入通道读取数据、调用
flip()
方法后,向输出通道发送数据、调用clear()
方法。
4. 针对每个连接维护一个线程,开销很大,如何优化?
- 使用NIO,NIO中使用了Selector实现了I/O多路复用,一个线程便能处理多个事件。