💝💝💝欢迎来到我的博客,很高兴能够在这里和您见面!希望您在这里可以感受到一份轻松愉快的氛围,不仅可以获得有趣的内容和知识,也可以畅所欲言、分享您的想法和见解。
非常期待和您一起在这个小小的网络世界里共同探索、学习和成长。💝💝💝
✨✨ 欢迎订阅本专栏 ✨✨
1.基础
1.IO 发展历程
- 在 JDK1.4 投入使用之前,只有 BIO 一种模式
- JDK1.4 以后开始引入了 NIO 技术,支持 select 和 poll
- JDK1.5 支持了 epoll
- JDK1.7 发布了 NIO2,支持 AIO 模型
2.I/O 请求
I/O 调用阶段:用户进程向内核发起系统调用
I/O 执行阶段:内核等待 I/O 请求处理完成返回
3.BIO
4.NIO
5.IO 多路复用
6.信号驱动
7.异步 IO
8.NIO 三大组件
NIO 是 non-blocking IO 非阻塞 IO
会详细讲解 NIO 的 Selector、ByteBuffer 和 Channel 三大组件。
Channel
channel 有一点类似于 stream,它就是读写数据的双向通道,可以从 channel 将数据读入 buffer,也可以将
buffer 的数据写入 channel,而之前的 stream 要么是输入,要么是输出,channel 比 stream 更为底层
常见的 Channel 有
- FileChannel
- DatagramChannel
- SocketChannel
- ServerSocketChannel
buffer
buffer 则用来缓冲读写数据,常见的 buffer 有
- ByteBuffer
- MappedByteBuffer
- DirectByteBuffer
- HeapByteBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
- CharBuffer
Selector
selector 单从字面意思不好理解,需要结合服务器的设计演化来理解已的用途
服务器设计
多线程版本
多袋程版缺点
- 内存占用高
- 线程上下文切换成本高
- 只适合连接数少的场景
线程池版本
线程池版缺点
- 阻塞模式下,线程仅能处理一个 socket 连接
- 仅适合短连接场景
selector 版设计
selector 的作用就是配合一个线程来管理多个 channel,获取这些 channel 上发生的事件,这些 channel 工作在非阻塞模式下,不会让线程吊死在一个 channel 上。适合连接数特别多,但流量低的场景 (low traffic)
调用 selector 的 select 方法会阻塞直到 channel 发生了读写就绪事件,这些事件发生,select 方法就会返回这些事件交给 thread 来处理
9.事件
-
accept -会在有连接请求时触发
-
connect -是客户端,连接建立后触发
-
read - 可读事件
-
wite-可写事件
10.阻塞和非阻塞
阻塞
-
阻塞模式下,相关方法都会导致线程暂停
- ServerSocketChannel.accept 会在没有连接建立时让线程暂停
- SocketChannel.read 会在没有数据可读时让线程暂停
- 阻塞的表现其实就是线程暂停了,暂停期间不会占用 Cpu,但线程相当于闲置
-
单线程下,阻塞方法之间相互影响,几乎不能正常工作,需要多线程支持
-
但多线程下,有新的问题,体现在以下方面
-
32 位 jvm 一个线程 320k,64 位 jvm 一个线程 1024k,如果连接数过多,必然导致 00M,并且线程太多,反而会因为频繁上下文切换导致性能降低
-
可以采用线程池技术来减少线程数和线程上下文切换,但治标不治本,如果有很多连接建立,但长时间 inactive,会阻塞线程池中所有线程,因此不适合长连接,只适合短连接
-
非阻塞
- 在某个 Channel 没有可读事件时,线程不必阻塞,它可以去处理其它有可读事件的 Channel
- 数据复制过程中,线程实际还是阻塞的(AIO 改进的地方)
- 写数据时,线程只是等待数据写入 Channel 即可,无需等 Channel 通过网络把数据发送出去
11.Java 中有几种类型的流
- 按照流的流向分:输入流(inputStream)和输出流(outputStream)
- 按照操作单元划分:字节流和字符流
- 按照流的角色功能划分:节点流和处理流。
- 节点流:可以从或向一个特定的地方(节点)读写数据。如 FileReader
- 处理流:是对一个已存在的流的连接和封装,通过所封装的流的功能调用实现数据读写。如 BufferedReader。处理流的构造方法总是要带一个其他的流对象做参数。一个流对象经过其他流的多次包装,称为流的链接。
12.字节流如何转为字符流
字节输入流转字符输入流通过 InputStreamReader 实现,该类的构造函数可以传入 InputStream 对象。
字节输出流转字符输出流通过 OutputStreamWriter 实现,该类的构造函数可以传入 OutputStream 对象.
13.字节流和字符流的区别
字符流处理的单元为 2 个字节的 Unicode 字符,分别操作字符字符数组或字符串,而字节流处理单元为 1 个字节, 操作字节和字节数组。所以字符流是由 Java 虚拟机将字节转化为 2 个字节的 Unicode 字符为单位的字符而成的,所以它对多国语言支持性比较好!如果是音频文件、图片、歌曲,就用字节流好点,如果是关系到中文(文本)的,用字符流好点
所有文件的储存是都是字节(byte)的储存,在磁盘上保留的并不是文件的字符而是先把字符编码成字节,再储存这些字节到磁盘。在读取文件(特别是文本文件)时,也是一个字节一个字节地读取以形成字节序列。
字节流可用于任何类型的对象,包括二进制对象,而字符流只能处理字符或者字符串
字节流提供了处理任何类型的 IO 操作的功能,但它不能直接处理 Unicode 字符,而字符流就可以。
14.序列化和 IO 的关系
[什么是 Java 序列化,如何实现 java 序列化](https://www.cnblogs.com/yangchunze/p/6728086.html)
序列化就是一种用来处理对象流的机制,所谓对象流也就是将对象的内容进行流化。可以对流化后的对象进行读写操作,也可将流化后的对象传输于网络之间。序列化是为了解决在对对象流进行读写操作时所引发的问题。
序 列 化 的 实 现 : 将 需 要 被 序 列 化 的 类 实 现 Serializable 接 口 , 该 接 口 没 有 需 要 实 现 的 方 法 ,
implements Serializable 只是为了标注该对象是可被序列化的,然后使用一个输出流(如:FileOutputStream)来构造一个 ObjectOutputStream(对象流)对象,接着,使用 ObjectOutputStream 对象的 writeObject(Object obj)方法就可以将参数为 obj 的对象写出(即保存其状态),要恢复的话则用输入流。
实体类
public class Customer implements Serializable {
private String name;
private int age;
public Customer(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "name=" + name + ", age=" + age;
}
}
测试类
public class Test {
public static void main(String[] args) throws Exception {
/*其中的 D:\\objectFile.obj 表示存放序列化对象的文件*/
// 序列化对象
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("D:\\objectFile.obj"));
Customer customer = new Customer("BWH_Steven", 22);
out.writeObject("Hello!"); // 写入字面值常量
out.writeObject(new Date()); // 写入匿名Date对象
out.writeObject(customer); // 写入customer对象
out.close();
// 反序列化对象
ObjectInputStream in = new ObjectInputStream(new FileInputStream("D:\\objectFile.obj"));
System.out.println("obj1 " + (String) in.readObject()); // 读取字面值常量
System.out.println("obj2 " + (Date) in.readObject()); // 读取匿名Date对象
Customer obj3 = (Customer) in.readObject(); // 读取customer对象
System.out.println("obj3 " + obj3);
in.close();
}
}
运行结果
// 实体类实现 Serializable 接口
obj1 Hello!
obj2 Sat Feb 06 11:17:57 CST 2021
obj3 name=BWH_Steven, age=22
// 实体类不实现 Serializable 接口
Exception in thread "main" java.io.NotSerializableException: cn.ideal.pojo.Customer
at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1184)
at java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:348)
at cn.ideal.Test.main(Test.java:27)
15.BIO 和 NIO 的概念
[关于 BIO 和 NIO 的理解](https://www.cnblogs.com/zedosu/p/6666984.html)
16.异常机制的过程
异常就是在程序发生异常时,强制终止程序运行,并且将异常信息返回,由开发者决定是否处理异常
简单说一下这个异常机制的过程:
当程序无法运行后,它会从当前环境中跳出,并且抛出异常,之后,它会先 new 一个异常对象,然后在异常位置终止程序,并且将异常对象的引用从当前环境中返回,这时候异常处理机制接管程序,并且开始寻找可以继续执行程序的恰当位置。
17.异常体系
- Error —— 错误:程序无法处理的严重错误,我们不作处理
- 这种错误一般来说与操作者无关,并且开发者与应用程序没有能力去解决这一问题,通常情况下,JVM 会做出终止线程的动作
- Exception —— 异常:异常可以分为运行时异常和编译期异常
-
RuntimeException:即运行时异常,我们必须修正代码
-
这些异常通常是由于一些逻辑错误产生的这类异常在代码编写的时候不会被编译器所检测出来,是可以不需要被捕获,但是程序员也可以根据需要行捕获抛出,(不受检查异常)这类异常通常是可以被程序员避免的。
-
常见的 RUNtimeException 有:NullpointException(空指针异常),ClassCastException(类型转 换异常),IndexOutOfBoundsException(数组越界异常)等。
-
-
非 RuntimeException:编译期异常,必须处理,否则程序编译无法通过
-
这类异常在编译时编译器会提示需要捕获,如果不进行捕获则编译错误。
-
常见编译异常有:IOException(流传输异常),SQLException(数据库操作异常)等。
18.异常输出打印的常用方法
方法方法 | 说明 |
---|---|
public String getMessage() | 回关于发生的异常的详细信息。这个消息在 Throwable 类的构造函数中初始化了 |
public Throwable getCause() | 返回一个 Throwable 对象代表异常原因 |
public String toString() | 使用 getMessage()的结果返回类的串级名字 |
public void printStackTrace() | 打印 toString()结果和栈层次到 System.err,即错误输出流 |
示例:
public class Demo {
public static void main(String[] args) {
int a = 520;
int b = 0;
int c;
try {
System.out.println("这是一个被除数为0的式子");
c = a / b;
} catch (ArithmeticException e) {
System.out.println("除数不能为0");
}
}
}
//运行结果
这是一个被除数为0的式子
除数不能为0
我们用上面的例子给出异常方法的测试
// System.out.println(e.getMessage()); 结果如下:
/ by zero
// System.out.println(e.getCause()); 结果如下:
null
// System.out.println(e.toString()); 结果如下:
java.lang.ArithmeticException: / by zero
// e.printStackTrace(); 结果如下:
java.lang.ArithmeticException: / by zero
at cn.bwh_01_Throwable.Demo.main(Demo.java:10)
19.Throw 和 Throws 的区别
Throw:
-
作用在方法内,表示抛出具体异常,由方法体内的语句处理。
-
具体向外抛出的动作,所以它抛出的是一个异常实体类。若执行了 Throw 一定是抛出了某种异常。
Throws:
-
作用在方法的声明上,表示如果抛出异常,则由该方法的调用者来进行异常处理。
-
主要的声明这个方法会抛出会抛出某种类型的异常,让它的使用者知道捕获异常的类型。
-
出现异常是一种可能性,但不一定会发生异常。
20.try-with-resources 替代 try-catch-finally
面对必须要关闭的资源,我们总是应该优先使用 try-with-resources 而不是 try-finally。随之产生的代码更简短,更清晰,产生的异常对我们也更有用。try-with-resources 语句让我们更容易编写必须要关闭的资源的代码,若采用 try-finally 则几乎做不到这点。—— Effecitve Java
Java 从 JDK1.7 开始引入了 try-with-resources ,在其中定义的变量只要实现了 AutoCloseable 接口,这样在系统可以自动调用它们的 close 方法,从而替代了 finally 中关闭资源的功能。
使用 try-catch-finally 你可能会这样做
try {
// 假设这里是一组关于 文件 IO 操作的代码
} catch (IOException e) {
e.printStackTrace();
} finally {
if (s != null) {
s.close();
}
}
但现在你可以这样做
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(new File("test.txt")))) {
// 假设这里是操作代码
} catch (IOException e) {
e.printStackTrace();
}
如果有多个资源需要 close ,只需要在 try 中,通过分号间隔开即可
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(new File("test.txt")));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(new File("test.txt")))) {
// 假设这里是操作代码
} catch (IOException e) {
e.printStackTrace();
}
21.读文件行
List<String> lines = Files.readAllLines(Paths.get(dicPath));
2.ByteBuffer
1.ByteBuffer 结构
ByteBuffer 有以下重要属性
- capacity
- position
- limit
写模式下,position 是写入位置,limit 等于可写容量,capacity 是最大容量
flip 动作发生后,position 切换为读取位置,limit 切换为读取限制
clear 动作发生后,状态
compact 方法,是把未读完的部分向前压缩,然后切换至写模式
下面是一个简单的图形化展示,展示了一个 ByteBuffer
对象的 position
、limit
和 capacity
属性的关系:
+-------------------+------------------+------------------+
| capacity | limit | position |
+-------------------+------------------+------------------+
| | | |
| | | |
| |<---- remaining --->|<---- remaining --->|
| | | |
| | | |
| |<--------- buffer --------->| |
| | | |
| | | |
| |<---- flip() ------>|<---- flip() ------>|
| | | |
| | | |
| |<------ clear() ------->| |
| | | |
+-------------------+------------------+------------------+
上面的图形展示了一个 ByteBuffer
对象,它具有以下属性:
capacity
:表示ByteBuffer
对象的容量,即它可以包含多少字节。limit
:表示ByteBuffer
对象中可读取或可写入的字节数。它始终小于或等于capacity
属性。position
:表示当前读取或写入操作所处的位置。初始时,position
的值为 0,它始终小于或等于limit
属性。
在创建一个 ByteBuffer
对象后,可以使用 put()
方法向其中写入数据。当写入完成后,需要调用 flip()
方法将 limit
属性设置为当前 position
的值,并将 position
的值重置为 0,以便读取数据。读取完成后,可以调用 clear()
方法将 limit
和 position
的值重置为初始值,以便重新写入数据。
需要注意的是,当读取或写入数据时,position
属性的值会随着操作的进行而自动增加。因此,在读取或写入数据之前,需要记录当前 position
的值,以便在操作完成后将 position
的值恢复到原始值。
2.ByteBuffer 实现
class java.nio.HeapByteBuffer:java 堆内存,读写效率较低,受到 GC 的影响
class java.nio.DirectByteBuffer:直接内存,读写效率高(少一次拷贝),不会受 GC,分配的效率低
3.分配空间
可以使用 allocate 方法为 ByteBuffer 分配空间,其它 buffer 类也有该方法
ByteBuffer.allocate(16);
4.向 buffer 写入数据
有两种办法
- 调用 channel 的 read 方法
- 调用 buffer 自己的 put 方法
#方式一
buffer.put(new byte[]{0x62, 0x63, 0x64});
#方式二
channel.read(buffer);
5.从 buffer 读取数据
同样有两种办法
- 调用 channel 的 write 方法
- 调用 buffer 自己的 get 方法
#方式一
int writeBytes = channel.write(buf);
#方式二
byte b =buf.get();
set 方法会让 position 读指针向后走,如果想重复读取数据
- 可以调用 rewind 方法将 position 重新置为 0
- 或者调用 get(int)) 方法获取索引 i 的内容,它不会移动读指针
get(i)不会改交读索引的位置
System.out.println((char) buffer.get(3));
6.mark 和 reset
mark 是在读取时,做一个标记,即使 position 改变,只要调用 reset 就能回到 mark 的位置
mark 微一个标记,记柔 position 位置,reset 是将 position 重置到 mark 的位置
7.字符串与 ByteBuffer 互裝
ByteBuffer bufferl = Standar dCharsets.UTF_8. encode("你好");
Bytesuffer buffer2 = Charset. forName("utf-8")encode("休好"):
debug (buffer1);
debug (buffer2);
CharBuffer buffer3 = Standardcharsets. UTF_8. decode (buffer 1);
System.out.print]n (buffer3.getClass ());
System.out.print]n(buffer3.toString());
8.Buffer 相关优化
- ChannelBuffer 变更为 ByteBuf,Buffer 相关的工具类可以独立使用
- Buffer 统一为动态变化,更安全地更改 Buffer 的容量
- 增加新的数据类型 CompositeByteBuf,用于减少数据拷贝
- GC 更加友好,增加池化缓存,4.1 版本开始 jemalloc 成为默认内存分配方式
- 内存泄漏检测功能
9.处理消息边界
- 一种思路是固定消息长度,数据包大小一样,服务器按预定长度读取,缺点是浪费带宽
- 另一种思路是按分隔符拆分,缺点是效率低
- TLV 格式,即 Type 类型、Length 长度、Value 数据,类型和长度已知的情况下,就可以方便获取消息大小,分配合适的 buffer,缺点是 buffer 需要提前分配,如果内容过大,则影响 server 吞吐量
- Http1.1 是 TLV 格式
- Http2.0 是 LTV 格式
10.ByteBuffer 大小分配
-
每个 channel 都需要记录可能被切分的消息,因为 ByteBuffer 不是线程安全的,因此需要为每个 channel 维
护一个独立的 ByteBuffer
-
ByteBuffer 不能太大,比如一个 ByteBuffer 1Mb 的话,要支持百万连接就要 1Tb 内存,因此需要设计大小
可变的 ByteBuffer
解决方案
-
一种思路是首先分配一个较小的 buffer,例如 4k,如果发现数据不够,再分配 8k 的 buffer,将 4k
buffer 内容拷贝至 8k buffer,优点是消息连续容易处理,缺点是数据拷贝耗费性能
-
另一种思路是用多个数组组成 buffer,一个数组不够,把多出来的内容写入新的数组,与前面的区别是消
息存储不连续解析复杂,优点是避免了拷贝引起的性能损耗
11.ByteBuffer 正确使用姿势
- 向 buffer 写入数据,例如调用 channel.read(buffer)
- 调用 flip()切换至读模式
- 从 buffer 读取数据,例如调用 buffer.get()
- 调用 clear()或 compact()切换至写模式
- 重复 1 ~ 4 步骤
3.channel
1.stream 和 channel
- stream 不会自动缓冲数据,channe 会利用系统提供的发送缓冲区、接收缓冲区(更为底层)
- stream 仅支持阻塞 APl,channel 同时支持阻塞、非阻塞 APl,网络 channel 可配合 selector 实现多路复用
- 二者均为全双工,即读写可以同时进行
4.selector
1.监听 Channel 事件
可以通过下面三种方法来监听是否有事件发生,方法的返回值代表有多少 channel 发生了事件
//方法1,阻塞直到绑定事件发生
int count = selector.select(O);
//方法2,阻塞直到绑定事件发生,或是超时(时间单位为ms)
int count = selector.select(long timeout);
//方法3,不会阻塞,也就是不管有没有事件,立刻返回,自己根据返回值检查是否有事件
int countT= selector.selectNow();
2.selector 和 selectedKeys
3.select 何时不阻塞
- 事件发生时
- 客户端发起连接请求,会触发 accept 事件
- 客户端发送数据过来,客户端正常、异常关闭时,都会触发 read 事件,另外如果发送的数据大于 buffer 缓冲区,会触发多次读取事件
- channel 可写,会触发 write 事件
- 在 linux 下 nio bug 发生时
- 调用 selector.wakeup()
- 调用 selector.close()
- selector 所在线程 interrupt
4.如何拿到 cpu 个数
-
Runtime.getRuntime().availableProcessors()如果工作在 docker 容器下,因为容器不是物理隔离的,
会拿到物理 cpu 个数,而不是容器申请时的个数
-
这个问题直到 jdk 10 才修复,使用 jvm 参数 UseContainerSupport 配置,默认开启
5.利用多线程优化
现在都是多核 cpu,设计时要充分考虑别让 cpu 的力量被白白浪费,前面的代码只有一个选择器,没有充分利用多核 cpu,如何改进呢?
分两组选择器:
- 单线程配一个选择器,专门处理 accept 事件
- 创建 cpu 核心数的线程,每个线程配一个选择器,轮流处理 read 事件
6.wakeup
selector.wakeup()//唤醒 select 方洗 boss
selector.select()//worker-0 阻塞
sc.register(selector, SelectionKey.OP_READ, null); // boss
因为 wakeup 方法的特性,即使提前唤醒,也不会在 select 方法阻塞
5.文件编程
1.FileChannel
FileChannel 只能工作在阻塞模式下
2.获取
不能直接打开 FileChannel,必须通过 FilelnputStream、FileOutputStream 或者 RandomAccessFile 来获取 FileChannel,它们都有 getChannel 方法
- 通过 FilelnputStream 获取的 channel 只能读
- 通过 FileOutputStream 获取的 channel 只能写
- 通过 RandomAccessFile 是否能读写根据构造 RandomAccessFile 时的读写模式决定
3.读取
会从 channel 读取数据填充 ByteBuffer,返回值表示读到了多少字节,-1 表示到达了文件的末尾
int readBytes = channel.read(buffer);
4.写入
写入的正确姿势如下
ByteBuffer buffer = .……;
buffer.put(...);//存入数据
buffer.flip();//切换读模式
while(buffer.hasRemaining()){
channel.write(buffer);
}
在 while 中调用 channel.write 是因为 write 方法并不能保证一次将 buffer 中的内容全部写入 channel
5.位置
//获取当前位置
long pos = channel.position();
//设置当前位置
long newPos =.....;
channel.position(newPos);
设置当前位置时,如果设置为文件的末尾
- 这时读取会返回-1
- 这时写入,会追加内容,但要注意如果 position 超过了文件末尾,再写入时在新内容和原末尾之间会有空洞
6.大小
使用 size 方法获取文件的大小
强制写入:
操作系统出于性能的考虑,会将数据缓存,不是立刻写入磁盘。可以调用 force(true)方法将文件内容和元数据(文件的权限等信息)立刻写入磁盘
7.Path
jdk7 引入了 Path 和 Paths 类
- Path 用来表示文件路径
- Paths 是工具类,用来获取 Path 实例
Path source =Paths.get("1.txt");//相对路径使用 user.dir环境变量来定位1.txt
Path source =Paths.get("d:\\1.txt");//绝对路径代表了 d:\1.txt
Path source=Paths.get("d:/1.txt");//绝对路径同样代表了 d:\1.txt
Path projects =Paths.get("d:\\data","projects");//代表了 d:\data\projects
- .代表了当前路径
- …代表了上一级路径
例如目录结构如下
d:
|- data
I- projects
|-a
|-b
Path path = Paths.get ("d:\\data\ \projects\la\\.. \\b");
System.out.println(path);
system.out.println(path.normalize();//正常化路径
//输出结果
d:\data\projects\a\.. \b
d: data projects\b
8.Files
检查文件是否存在
Path path = Paths.get("helloword/data.txt");
System.out.println(Files.exists(path));
创建一级目录
Path path = Paths.get("helloword/d1");
Files.createDirectory(path);
- 如果目录已存在,会抛异常 FileAlreadyExistsException
- 不能一次创建多级目录,否则会抛异常 NoSuchFileException
创建多级目录用
Path path = Paths.get("helloword/d1/d2");
Files.createDirectories(path)
9.拷贝文件
Path source = Paths.get("helloword/data.txt");
Path target = Paths.get("helloword/target.txt");
Files.copy(source, target);
- 如果文件已存在,会抛异常 FileAlreadyExistsException
- 如果希望用 source 覆盖掉 target,需要用 StandardCopyOption 来控制
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
10.移动文件
Path source = Paths.get("helloword/data.txt");
Path target = Paths.get("helloword/data.txt");
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
StandardCopyOption.ATOMIC_MOVE 保证文件移动的原子性
11.删除文件
Path target = Paths.get("helloword/target.txt");
Files.delete(target);
如果文件不存在,会抛异常 NoSuchFileException
12.删除目录
Path target = Paths.get("helloword/d1");
Files.delete(target);
如果目录还有内容,会抛异常 DirectoryNotEmptyException
6.IO 模型
1.同步异步
- 同步阻塞
- 同步非阻塞
- 多路复用
- 异步阻塞:不存在的类型
- 异步非阻塞
同步异步:
- 同步:线程自己去获取结果(一个线程)
- 异步:线程自己不去获取结果,而是由其它线程送结果(至少两个线程)
2.IO 模型
- 阻塞 IO
- 非阻塞 IO
- 多路复用
- 信号驱动
- 异步 IO
当调用一次 channel.read 或 stream.read 后,会切换至操作系统内核态来完成真正数据读取,而读取又分为两个
阶段,分别为:
- 等待数据阶段
- 复制数据阶段
3.阻塞 IO
4.非阻塞 IO
5.多路复用
6.异步 IO
AIO 用来解决数据复制阶段的阻塞问题
-
同步意味着,在进行读写操作时,线程需要等待结果,还是相当于闲置
-
异步意味着,在进行读写操作时,线程不必等待结果,而是将来由操作系统来通过回调方式由另外的线程来获
得结果
异步模型需要底层操作系统(Kernel)提供支持
- Windows 系统通过 IOCP 实现了真正的异步 IO
- Linux 系统异步 IO 在 2.6 版本引入,但其底层实现还是用多路复用模拟了异步 IO,性能没有优势
7.零拷贝
1.传统 IO
传统 IO 问题:传统的 lO 将一个文件通过 socket 写出
File f = new File("helloword/data.txt");
RandomAccessFile file = new RandomAccessFile(file, "r");
byte[] buf = new byte[(int)f.Tength()];
file.read(buf);
Socket socket =...;
socket.getOutputStream().write(buf);
内部工作流程是这样的:
-
java 本身并不具备 IO 读写能力,因此 read 方法调用后,要从 java 程序的用户态切换至内核态,去调用操作系统(Kernel)的读能力,将数据读入内核缓冲区。这期间用户线程阻塞,操作系统使用 DMA(Direct Memory Access)来实现文件读,期间也不会使用 cpu
-
从内核态切换回用户态,将数据从内核缓冲区读入用户缓冲区(即 byte[] buf),这期间 cpu 会参与拷贝,无
法利用 DMA
-
调用 write 方法,这时将数据从用户缓冲区(bytel[] buf)写入 socket 缓冲区,cpu 会参与拷贝
-
接下来要向网卡写数据,这项能力 java 又不具备,因此又得从用户态切换至内核态,调用操作系统的写能
力,使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu
DMA 也可以理解为硬件单元,用来解放 cpu 完成文件 IO
可以看到中间环节较多,java 的 lO 实际不是物理设备级别的读写,而是缓存的复制,底层的真正读写是操作系统
来完成的
- 用户态与内核态的切换发生了 3 次,这个操作比较重量级
- 数据拷贝了共 4 次
2.NIO 优化
通过 DirectByteBuff
- ByteBuffer.allocate(10) HeapByteBuffer
- ByteBuffer.allocateDirect(10) DirectByteBuffer
大部分步骤与优化前相同,不再赘述。唯有一点:java 可以使用 DirectByteBuffer 将堆外内存映射到 jvm 内存中来
直接访问使用
- 这块内存不受 jvm 垃圾回收的影响,因此内存地址固定,有助于 IO 读写
- java 中的 DirectByteBuffer 对象仅维护了此内存的虚引用,内存回收分成两步
- DirectByteBuffer 对象被垃圾回收,将虚引用加入引用队列
- 通过专门线程访问引用队列,根据虚引用释放堆外内存
- 减少了一次数据拷贝,用户态与内核态的切换次数没有减少
3.sendFile 优化
进一步优化(底层采用了 linux 2.1 后提供的 sendFile 方法),java 中对应着两个 channel 调用
transferTo/transferFrom 方法拷贝数据
- java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA 将数据读入内核缓冲区,不会使用 cpu
- 数据从内核缓冲区传输到 socket 缓冲区,cpu 会参与拷贝
- 最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu
可以看到
- 只发生了一次用户态与内核态的切换
- 数据拷贝了 3 次
4.进一步优化
进一步优化(linux2.4)
-
java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA 将数据读入内核缓冲区,不
会使用 cpu
-
只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗
-
使用 DMA 将内核缓冲区的数据写入网卡,不会使用 cpu
整个过程
- 仅仅只发生了一次用户态与内核态的切换
- 数据拷贝了 2 次
- 所谓的【零拷贝】,并不是真正无拷贝,而是在不会拷贝重复数据到 ivm 内存中,
零拷贝的优点有
- 更少的用户态与内核态的切换
- 不利用 cpu 计算,减少 cpu 缓存伪共享
- 零拷贝适合小文件传输> ❤️❤️❤️本人水平有限,如有纰漏,欢迎各位大佬评论批评指正!😄😄😄
💘💘💘如果觉得这篇文对你有帮助的话,也请给个点赞、收藏下吧,非常感谢!👍 👍 👍
🔥🔥🔥Stay Hungry Stay Foolish 道阻且长,行则将至,让我们一起加油吧!🌙🌙🌙