NIO
介绍
NIO 全称java non-blocking IO(非阻塞 I/O),后续提供了一系列改进的输入/输出的新特性,被统称为 NIO(即 New IO),是同步非阻塞的。
阻塞和非阻塞是进程在访问数据的时候,数据是否准备就绪的一种处理方式,当数据没有准备的时候。
阻塞(Block):往往需要等待缓冲区中的数据准备好过后才处理其他的事情,否则一直等待在那里。
非阻塞(Non-Block):当我们的进程访问我们的数据缓冲区的时候,如果数据没有准备好则直接返回,不会等待。如果数据已经准备好,也直接返回
同步和异步都是基于应用程序和操作系统处理 IO 事件所采用的方式;
同步:应用程序要直接参与 IO 读写的操作,必须阻塞在某个方法上面等待我们的 IO 事件完成
异步:所有的 IO 读写交给操作系统去处理,应用程序只需要等待通知,可以去做其他的事情,并不需要去完成真正的 IO 操作,当操作完成 IO 后,会给我们的应用程序一个通知
特点
1.非阻塞式的I/O操作。这意味着一个线程可以同时管理多个连接,而不必等待每个连接的I/O操作完成
2.通过Channel和Buffer来进行数据传输。Channel表示与实体(文件、套接字等)的连接,而Buffer是用于在Channel和应用程序之间传输数据的缓冲区
3.提供了内存映射文件的功能,可以将文件直接映射到内存中,从而实现了快速的文件I/O操作
4.提供了灵活的缓冲区管理功能,可以方便地进行数据的读取、写入和处理
5.采用了面向块的数据传输方式,可以一次性传输大量数据,提高了I/O操作的效率
运用场景
适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,弹幕系统,服务器间通讯等
三大组件
NIO的三个最重要的核心分别为:Channel,Buffer和Selector
Channel(通道)
通道;对原 I/O 包中的流的模拟,到任何目的地(或来自任何地方)的所有数据都必须通过一个 Channel 对象,通道是双向的(一个Channel既可以读数据,也可以写数据)
常见类型:
FileChannel
DatagramChannel
SocketChannel
ServerSocketChannel
FileChannel主要用于文件传输,其他三种用于网络通信。
Buffer(缓冲区)
缓冲区;实际上是一个容器对象,对数组进行了封装,用数组来缓存数据,还定义了一些操作数组的API,如 put()、get()、flip()、compact()、mark() 等。在NIO中,无论读还是写,数据都必须经过Buffer缓冲区.
ByteBuffer
MappedByteBuffer
DirectByteBuffer
HeapByteBuffer
ShortBuffer
IntBuffer
LongBuffer
FloatBuffer
DoubleBuffer
CharBuffer
其中最常用的是ByteBuffer
Selector(选择器)
选择器;是一个特殊的组件,用于采集各个通道的状态(或者事件)
socket连接方法
Socket编程理解为对TCP协议的具体实现。
多线程技术
系统为每一个连接分配一个thread(线程),分别去处理对应的socket连接
缺点:
1.内存占用高。每有一个socket连接,系统就要分配一个线程去对接。当出现大量连接时,会开辟大量线程,导致占用大量内存。
2.线程上下文切换成本高
3.只适合连接数较少的场景
线程上下文切换:
一个CPU在同一个时刻是只能处理一个线程的,由于时间片耗尽或出现阻塞等情况,CPU 会转去执行另外一个线程,这个叫做线程上下文切换
线程池技术
使用线程池,让线程池中的线程去处理连接
缺点:
1.在阻塞模式下,线程只能处理一个连接。线程池中的线程获取任务,只有当任务完成/socket断开连接,才会去获取执行下一个任务
2.只适合短链接的场景
selector技术
为每个线程配合一个选择器,让选择器去管理多个channel。(注:FileChannel是阻塞式的,因此无法使用选择器。)
让选择器去管理多个工作在非阻塞式下的Channel,获取Channel上的事件,当一个Channel没有任务时,就转而去执行别的Channel上的任务。这种适合用在连接多,流量小的场景。
若事件未就绪,调用 selector 的 select() 方法会阻塞线程,直到 channel 发生了就绪事件。这些事件就绪后,select 方法就会返回这些事件交给 thread 来处理。
ByteBuffer
简单示例
public class TestByteBuffer {
public static void main(String[] arge){
try{
//1.输入输出流,文件数据传输
FileChannel channel = new FileInputStream("network-program/data.txt").getChannel();
//2.准备缓冲区,并设置大小
ByteBuffer buffer = ByteBuffer.allocate(10);
//3.从channel读取数据,并写入buffer中
channel.read(buffer);
//4.buffer切换成读模式
buffer.flip();
//5.判断是否还有剩余未读数据
while (buffer.hasRemaining()){
byte b = buffer.get();
System.out.print((char)b);
}
}catch (Exception e){
e.printStackTrace();
}
}
}
使用步骤
1.向buffer写入数据,如:channel.read(buffer);
2.调用flip()切换至读模式
3.从buffer读取数据,如:buffer.get();
4.调用clear()或compact()切换到写模式
属性
capacity:缓冲区的容量,不可变
limit:缓冲区的界限。limit之后的数据不允许读写
position:读写指针。position不可大于limit,且position不为负数
mark:标记。记录当前position的值。position被改变后,可以通过调用reset() 方法恢复到mark的位置
常见方法
allocate方法
通过allocate我们可以给ByteBuffer分配空间,但是这个空间不可以动态变换,如果想要改变ByteBuffer的大小只能重新分配一个
ByteBuffer.allocate(10);
allocateDirect方法
通过allocateDirect我们也可以给ByteBuffer分配空间
ByteBuffer.allocateDirect(10);
allocate 与 allocateDirect的区别:
1.allocate创建出来的是HeapByteBuffer对象,allocateDirect创建出来的是DirectByteBuffer对象
2.HeapByteBuffer是存在于JVM的堆内存中,DirectByteBuffer是存在于直接(系统)内存中
3.HeapByteBuffer的读写效率低于DirectByteBuffer,因为HeapByteBuffer存在于jvm中的,自然会收到垃圾回收器的影响
4.DirectByteBuffer使用不当,容易造成内存泄露
put方法
put方法可以将数据放入到缓冲区中。操作完成后,position的值会+1,并指向下一个可存放的区域,limit=capacity
buffer.put(byte b);
flip方法
flip方法会切换对当前缓冲区的去操作,写/读->读/写
buffer.flip();
当是读模式切换到写模式时,恢复为put时的值。
get方法
get方法会读取缓冲区里的数据,一次只能读取一个。读取后,position的值会+1,指向下一个可读区。当position大于limit时,会报异常。get方法如果传入指定的索引位置:get(i)。则position的值不会产生变动。
buffer.get();
clear方法
clean方法就像初始化一样,会把ByteBuffer的里属性值都恢复到最初,并且清除缓冲区里的数据。
buffer.clear();
compact方法
compact方法会把已经读取的数据清除,后面未读取的数据向前压缩,然后切换到写模式。
数据前移后,原始位置的数据不会清楚,但是在后面的写入操作中会被覆盖。
buffer.compact();
rewind方法
rewind方法只能在读模式下使用,使用后,会恢复position、limit和capacity的值
buffer.rewind();
mark方法和reset方法
这个两个方法通常都是搭配着使用。
mark做一个标记,会保存当前position的值;reset方法会把mark保存的值重新赋给position。
buffer.mark();
buffer.reset();
字符串与ByteBuffer的相互转换
方法一:
// 编码:字符串的getByte方法
ByteBuffer buffer = ByteBuffer.allocate(15);
buffer.put(str.getBytes());
方法二:
// 编码:StandardCharsets的encode方法获取ByteBuffer
ByteBuffer buffer2 = StandardCharsets.UTF_8.encode(str);
方法三:
ByteBuffer buffer3 = ByteBuffer.wrap(str.getBytes());
// 解码: 通过StandardCharsets的decoder方法解码
String decodeStr3 = StandardCharsets.UTF_8.decode(buffer3).toString();
黏包和半包
黏包:发送方在发送数据时,并不是一条一条地发送数据,而是将数据整合在一起,当数据达到一定的数量后再一起发送。这就会导致多条信息被放在一个缓冲区中被一起发送出去。
半包:因为我们分配缓冲区的大小是固定,如果空间小于数据量,那就只能先把当前缓冲区里的数据读取完,再去接收剩下的的数据。数据就会出现被截断的断层现象。
如:
Hello world!\n
I’m LIKEGAKKI!\n
How are you?\n
经过传输后,服务端的产生了两个ByteBuffer:Hello,world\nI’m LIKEGAKKI\nHo(黏包)
w are you?\n?(半包)
重新拆分:
public class TestByteBufferExam {
public static void main(String[] args){
ByteBuffer buffer = ByteBuffer.allocate(32);
buffer.put("Hello,world\nI,m zhangsan\nHo".getBytes());
split(buffer);
buffer.put("w are you?\n".getBytes());
split(buffer);
}
private static void split(ByteBuffer buffer){
buffer.flip();
for(int i = 0;i<buffer.limit();i++){
if(buffer.get(i) == '\n'){
int length = i + 1 - buffer.position();
ByteBuffer byteBuffer = ByteBuffer.allocate(length);
for(int j = 0;j<length;j++){
byteBuffer.put(buffer.get());
}
System.out.println(byteBuffer.get());
}
}
buffer.compact();
}
在循环中用get(i)方法依次读取数据,当读取的数据匹配‘\n’时,说明之前的读取的是一段信息。
记录该段数据长度,以便于申请对应大小的缓冲区;将缓冲区的数据通过get()方法写入到target中。
调用compact方法切换模式,因为缓冲区中可能还有未读的数据。