一、Java的I/O演进之路
Java共支持3种网络编程的I/O模型:BIO、NIO、AIO
BIO(Blocking I/O):
阻塞 + 同步(传统阻塞型),服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销
NIO(No Blocking IO):
非阻塞 + 同步,服务器实现模式为一个线程处理多个请求(连接),即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求就进行处理
AIO:
AIO ,全称 Asynchronous IO ,也叫 NIO2 ,是一种非阻塞 + 异步的通信模式。在 NIO 的基础上,引入了新的异步通道的概念,并提供了异步文件通道和异步套接字通道的实现
二、BIO深入剖析
1、BIO概述
- BIO(Blocking I/O)就是传统的Java IO编程,其相关的类和接口在java.io包下。
- BIO是同步阻塞的,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过线程池机制改善(实现多个客户连接服务器)
2、BIO案例
服务端:
public class Server {
public static void main(String[] args) throws IOException {
System.out.println("===服务端启动===");
// 1.定义一个ServerSocket对象进行服务端的端口注册
ServerSocket ss = new ServerSocket(9999);
// 2.监听客户端的Socket连接请求
Socket socket = ss.accept();
// 3.从Socket管道中得到一个字节输入流对象
InputStream is = socket.getInputStream();
// 4.把字节输入流包装成一个缓冲字符输入流
BufferedReader br = new BufferedReader(new InputStreamReader(is));
String msg;
while ((msg = br.readLine()) != null) {
System.out.println("服务端接收到:" + msg);
}
}
}
客户端:
public class Client {
public static void main(String[] args) throws IOException {
System.out.println("===客户端启动===");
// 1.创建Socket对象请求服务端的连接
Socket socket = new Socket("127.0.0.1", 9999);
// 2.从Socket对象中获取一个字节输出流
OutputStream os = socket.getOutputStream();
// 3.把字节输出流包装成一个打印流
PrintStream ps = new PrintStream(os);
ps.println("Hello World!服务端,你好!");
ps.flush();
}
}
小结:
在以上通信中,服务端会一直等待客户端的消息,如果客户端没有进行消息的发送,服务端将一直进入阻塞状态
3、伪异步I/O
1)、概述
伪异步I/O采用线程池和任务队列实现,当客户端接入时,将客户端的Socket封装成一个Task(该任务实现java.lang.Runnable线程任务接口)交给后端的线程池中进行处理。JDK的线程池维护一个消息队列和N个活跃的线程,对消息队列中Socket任务进行处理,由于线程池可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机
2)、代码实现
客户端:
public class Client {
public static void main(String[] args) {
try {
Socket socket = new Socket("127.0.0.1", 9999);
OutputStream os = socket.getOutputStream();
PrintStream ps = new PrintStream(os);
Scanner sc = new Scanner(System.in);
while (true) {
System.out.print("请说:");
String msg = sc.nextLine();
ps.println(msg);
ps.flush();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
线程池处理类:
public class SocketServerPoolHandler {
/**
* 1.创建一个线程池的成员变量用于存储一个线程池对象
*/
private ExecutorService executorService;
/**
* 2.创建这个类的时候就需要初始化线程池对象
*/
public SocketServerPoolHandler(int maxThreadNum, int queueSize) {
executorService = new ThreadPoolExecutor(3, maxThreadNum,
120, TimeUnit.SECONDS, new ArrayBlockingQueue<>(queueSize));
}
/**
* 3.提供一个方法来提交任务给线程池的任务队列来暂存,等着线程池来处理
*/
public void execute(Runnable target) {
executorService.execute(target);
}
}
服务端:
public class Server {
public static void main(String[] args) {
try {
// 1.注册端口
ServerSocket ss = new ServerSocket(9999);
// 2.定义一个循环接收客户端的Socket连接请求
// 初始化一个线程池对象
SocketServerPoolHandler poolHandler = new SocketServerPoolHandler(3, 10);
while (true) {
Socket socket = ss.accept();
// 3.把Socket对象交给一个线程池进行处理
// 把Socket封装成一个任务对象交给线程池处理
Runnable target = new ServerRunnableTarget(socket);
poolHandler.execute(target);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
public class ServerRunnableTarget implements Runnable {
private Socket socket;
public ServerRunnableTarget(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
// 处理接收的客户端Socket通信需求
try {
InputStream is = socket.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(is));
String msg;
while ((msg = br.readLine()) != null) {
System.out.println("服务端接收到:" + msg);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
运行结果:
启动服务端并启动多个客户端发送消息,由于核心线程数=最大线程数=3,当客户端数>3时,客户端的Socket任务会到线程池的阻塞队列中等待,关闭客户端,当客户端数<=3时,Socket任务将会被服务端处理
3)、小结
- 伪异步I/O采用了线程池实现,因此避免了为每个请求创建一个独立线程造成线程资源耗尽的问题,但由于底层依然是采用的同步阻塞模型,因此无法从根本上解决问题
- 如果单个消息处理的缓慢,或者服务器线程池中的全部线程都被阻塞,那么后续Socket的I/O消息都将在队列中排队。新的Socket请求将被拒绝,客户端会发生大量连接超时
三、NIO深入剖析
1、NIO概述
- NIO(Non-Blocking IO)是从Java 1.4版本开始引入的一个新的IO API,可以替代标准的Java IO API。NIO与原来的IO有同样的作用和目的,但是使用的方式完全不同,NIO支持面向缓冲区的、基于通道的IO操作。NIO将以更加高效的方式进行文件的读写操作。NIO可以理解为非阻塞IO,传统的IO的read和write只能阻塞执行,线程在读写IO期间不能干其他事情,比如调用socket.read()时,如果服务器一直没有数据传输过来,线程就一直阻塞,而NIO中可以配置socket为非阻塞模式
- NIO相关类都被放在java.nio包及子包下,并且对原java.io包中的很多类进行改写
- NIO有三大核心部分:Channel(通道)、Buffer(缓冲区)、Selector(选择器)
- Java NIO的非阻塞模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情
- 通俗理解:NIO是可以做到用一个线程来处理多个操作的。假设有1000个请求过来,根据实际情况,可以分配20或者80个线程来处理。不像之前的阻塞IO那样,非得分配1000个
2、NIO和BIO的比较
- BIO以流的方式处理数据,而NIO以块的方式处理数据,块I/O的效率比流I/O高很多
- BIO是阻塞的,NIO则是非阻塞的
- BIO基于字节流和字符流进行操作,而NIO基于Channel(通道)和Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于监听多个通道的事件(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道 | NIO | BIO | | —- | —- | | 面向缓冲区(Buffer) | 面向流(Stream) | | 非阻塞(Non Blocking IO) | 阻塞IO(Blocking IO) | | 选择器(Selector) | |
3、NIO三大核心原理示意图
NIO有三大核心部分:Channel(通道)、Buffer(缓冲区)、Selector(选择器)
Buffer缓冲区:
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。相比较直接对数组的操作,Buffer API更加容易操作和管理
Channel(通道):
Java NIO的通道类似流,但又有些不同:既可以从通道中读取数据,又可以写数据到通道。但流的(input或output)读写通常是单向的。通道可以非阻塞读取和写入通道,通道可以支持读取或写入缓冲区,也支持异步地读写
Selector选择器:
Selector是一个Java NIO组件,可以能够检查一个或多个NIO通道,并确定哪些通道已经准备好进行读取或写入。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接,提高效率
- 每个channel都会对应一个Buffer
- 一个线程对应Selector,一个Selector对应多个channel(连接)
- 程序切换到哪个channel是由事件决定的
- Selector会根据不同的事件,在各个通道上切换
- Buffer就是一个内存块,底层是一个数组
- 数据的读取写入是通过Buffer完成的,BIO中要么是输入流,或者是输出流,不能双向,但是NIO的Buffer是可以读也可以写
- Java NIO系统的核心在于:通道(Channel)和缓冲区(Buffer)。通道表示打开到IO设备(例如:文件、套接字)的连接。若需要使用NIO系统,需要获取用于连接IO设备的通道以及用于容纳数据的缓冲区。然后操作缓冲区,对数据进行处理。简而言之,Channel负责传输,Buffer负责存取数据
4、NIO核心一:缓冲区(Buffer)
1)、Buffer概述
Buffer是一个用于特定基本数据类型的容器。由java.nio包定义的,所有缓冲区都是Buffer抽象类的子类。Java NIO中的Buffer主要用于与NIO通道进行交互,数据是从通道读入缓冲区,从缓冲区写入通道中的
2)、Buffer类及其子类
Buffer就像一个数组,可以保存多个相同类型的数据。根据数据类型不同,有以下Buffer常用子类:
- ByteBuffer
- CharBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
上述Buffer类都采用相似的方法进行管理数据,只是各自管理的数据类型不同而已。都是通过如下方法获取一个Buffer对象:
static XxxBuffer allocate(int capacity) //创建一个容量为capacity的XxxBuffer对象
3)、缓冲区的基本属性
- 容量(capacity):作为一个内存块,Buffer具有一定的固定大小,也称为容量,缓冲区容量不能为负,并且创建后不能更改
- 限制(limit):表示缓冲区中可以操作数据的大小(limit后数据不能进行读写)。缓冲区的限制不能为负,并且不能大于其容量。写入模式,限制等于buffer的容量。读取模式下,limit等于写入的数据量
- 位置(position):下一个要读取或写入的数据的索引。缓冲区的位置不能为负,并且不能大于其限制
- 标记(mark)与重置(reset):标记是一个索引,通过Buffer中的mark()方法指定Buffer中一个特定的position,之后可以通过调用reset()方法恢复到这个position
- 标记、位置、限制、容量遵守以下不变式:0 <= mark <= position <= limit <= capacity
4)、Buffer常见方法
Buffer clear() //清空缓冲区并返回对缓冲区的引用(缓冲区中的数据依然存在,但是处于被遗忘状态)
Buffer flip() //为将缓冲区的界限设置为当前位置,并将当前位置重置为0
int capacity() //返回Buffer的capacity大小
boolean hasRemaining() //判断缓冲区中是否还有元素
int limit() //返回Buffer的界限(limit)的位置
Buffer limit(int n) //将设置缓冲区界限为n,并返回一个具有新limit的缓冲区对象
Buffer mark() //对缓冲区设置标记
int position() //返回缓冲区的当前位置position
Buffer position(int n) //将设置缓冲区的当前位置为n,并返回修改后的Buffer对象
int remaining() //返回position和limit之间的元素个数
Buffer reset() //将位置position转到以前设置的mark所在的位置
Buffer rewind() //将位置设为0,取消设置的mark
5)、缓冲区的数据操作
Buffer所有子类提供了两个用于数据操作的方法:get()和put()方法
获取Buffer中的数据:
get() //读取单个字节
get(byte[] dst) //批量读取多个字节到dst中
get(int index) //读取指定索引位置的字节(不会移动position)
放到入数据到Buffer中:
put(byte b) //将给定单个字节写入缓冲区的当前位置
put(byte[] src) //将src中的字节写入缓冲区的当前位置
put(int index, byte b) //将指定字节写入缓冲区的索引位置(不会移动position)
使用Buffer读写数据一般遵循以下四个步骤:
- 写入数据到Buffer
- 调用flip()方法,转换为读取模式
- 从Buffer中读取数据
调用buffer.clear()方法或者buffer.compact()方法清除缓冲区
6)、Buffer案例
```java @Test public void test01() {
// 1.分配一个缓冲区,容量设置成10
ByteBuffer buffer = ByteBuffer.allocate(10);
System.out.println(buffer.position()); // 0
System.out.println(buffer.limit()); // 10
System.out.println(buffer.capacity()); // 10
System.out.println("--------------------");
// 2.put()往缓冲区中添加数据
String name = "hello";
buffer.put(name.getBytes());
System.out.println(buffer.position()); // 5
System.out.println(buffer.limit()); // 10
System.out.println(buffer.capacity()); // 10
System.out.println("--------------------");
// 3.flip()为将缓冲区的界限设置为当前位置,并将当前位置重置为0 可读模式
buffer.flip();
System.out.println(buffer.position()); // 0
System.out.println(buffer.limit()); // 5
System.out.println(buffer.capacity()); // 10
System.out.println("--------------------");
// 4.get()数据的读取
char ch = (char) buffer.get();
System.out.println(ch);
System.out.println(buffer.position()); // 1
System.out.println(buffer.limit()); // 5
System.out.println(buffer.capacity()); // 10
System.out.println("--------------------");
}
```java
@Test
public void test02() {
// 1.分配一个缓冲区,容量设置成10 put()往缓冲区中添加数据
ByteBuffer buffer = ByteBuffer.allocate(10);
String name = "hello";
buffer.put(name.getBytes());
System.out.println(buffer.position()); // 5
System.out.println(buffer.limit()); // 10
System.out.println(buffer.capacity()); // 10
System.out.println("--------------------");
// 2.clear()清除缓冲区中的数据 并没有真正清除数据,只是让position的位置恢复到初始位置,后续添加数据的时候才会覆盖每个位置的数据
buffer.clear();
System.out.println(buffer.position()); // 0
System.out.println(buffer.limit()); // 10
System.out.println(buffer.capacity()); // 10
System.out.println((char) buffer.get()); // h
System.out.println("--------------------");
// 3.定义一个缓冲区
ByteBuffer buf = ByteBuffer.allocate(10);
String n = "hello";
buf.put(n.getBytes());
buf.flip();
// 读取数据
byte[] b = new byte[2];
buf.get(b);
System.out.println(new String(b));
System.out.println(buf.position()); // 2
System.out.println(buf.limit()); // 5
System.out.println(buf.capacity()); // 10
System.out.println("--------------------");
buf.mark(); // 标记此刻这个位置 2
byte[] b2 = new byte[3];
buf.get(b2);
System.out.println(new String(b2));
System.out.println(buf.position()); // 5
System.out.println(buf.limit()); // 5
System.out.println(buf.capacity()); // 10
System.out.println("--------------------");
buf.reset(); // 回到标记位置
if (buf.hasRemaining()) {
System.out.println(buf.remaining()); // 3
}
}
7)、直接与非直接缓冲区
ByteBuffer可以是两种类型,一种是基于直接内存(也就是非堆内存);另一种是非直接内存(也就是堆内存)。对于直接内存来说,JVM将会在IO操作上具有更高的性能,因为它直接作用于本地系统的IO操作。而非直接内存,也就是堆内存中的数据,如果要作IO操作,会先从本进程内存复制到直接内存,再利用本地IO处理
从数据流的角度,非直接内存是下面这样的作用链:
本地IO—>直接内存—>非直接内存—>直接内存—>本地IO
而直接内存是:
本地IO—>直接内存—>本地IO
很明显,在做IO处理时,比如网络发送大量数据时,直接内存会具有更高的效率。直接内存使用allocateDirect()创建,但是它比申请普通的堆内存需要耗费更高的性能。不过,这部分的数据是在JVM之外的,因此它不会占用应用的内存。所以呢,当有很大的数据要缓存,并且它的生命周期又很长,那么就比较适合使用直接内存。只是一般来说,如果不是能带来很明显的性能提升,还是推荐直接使用堆内存。字节缓冲区是直接缓冲区还是非直接缓冲区可通过调用其isDirect()方法来确定
直接缓冲区使用场景:
- 有很大的数据需要存储,它的生命周期又很长
- 适合频繁的IO操作,比如网络并发场景