Netty铺垫-NIO
non-blocking io 非阻塞 IO 因为是为了学习netty而学习的NIO,所以在很多地方做了省略.
三大组件
channel & buffer
channel就是读写数据的双向通道,可以从channel将数据读入buffer,也可以将buffer的数据写入channel,而之前的stream要么是输入,要么是输出,channel比stream更为底层。 常见的Channel有:
- FileChannel
- DatagramChannel
- SocketChannel
- ServerSocketChannel
Buffer则用来缓冲读写数据,常见的Buffer有:
- ByteBuffer
- MappedByteBuffer
- DirectByteBuffer
- HeapByteBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
- CharBuffer
Selector
selector但从字面意思不好理解,需要结合服务器的设计眼花来理解他的用途
多线程版设计
多线程版缺点:
- 内存占用高
- 线程上下文切换成本高
- 只适合连接数少的场景
线程池版设计
线程池版缺点:
- 阻塞模式下,线程仅能处理一个socket连接
- 仅适合短连接场景
selector版设计
selector的作用就是配合一个线程来管理多个channel,获取这些channel上发生的事件,这些channel工作在非阻塞模式下,不会让线程吊死在一个channel上。适合连接数特别多,但流量低的场景。
调用selector的select()会阻塞直到channel发生了读写就绪事件,这些事件发生select方法就会返回这些事件交给thread来处理。
ByteBuffer
ByteBuffer正确使用步骤
:::tips
- 向buffer写入数据,例如调用channel.read(buffer)
- 调用flip切换至读模式
- 从buffer读取数据,例如调用buffer.get()
- 调用clear()或compact()切换至写模式
- 重复1~4步骤 :::
public class TestByteBuffer {
public static void main(String[] args) {
// FileChannel
//获取Channel
// 1. 输入输出流 2. RandomAccessFile
try (FileChannel channel = new FileInputStream("data.txt").getChannel()) {
//准备缓冲区
ByteBuffer buffer = ByteBuffer.allocate(10);
while (channel.read(buffer) != -1){
// 从channel读取数据向Buffer写入
//打印 buffer内容
buffer.flip();// 切换到读模式
while(buffer.hasRemaining()){ //是否还有剩余数据
byte b = buffer.get();
System.out.println((char) b);
}
buffer.clear();//切换到写模式
}
} catch (IOException e) {
}
}
}
ByteBuffer结构
ByteBuffer有以下重要属性:
- capacity
- position
- limit
一开始
写模式下,positon是写入位置,limit等于容量,下图表示写入了4个字节后的状态
flip动作发生后,position切换为读取位置,limit切换为读取限制。
读取4个字节后,状态
clear动作发生后,状态
compact方法,是把未读完的部分向前压缩,然后切换至写模式
ByteBuffer常见方法
- 分配空间
可以使用allocate方法为ByteBuffer分配空间,其他buffer类也有该方法
ByteBuffer buffer = ByteBuffer.allocate(10);
//java.nio.heapByteBuffer 在堆内存,读写效率较低,收到垃圾回收(GC)的影响
ByteBuffer buffer = ByteBuffer.allocateDirect(10);
//java.nio.DirectByteBuffer 在直接内存(系统内存),读写效率高(少一次拷贝),不会受到垃圾回收的影响,,分配内存的效率比较低,使用不当会造成内存泄露
- 向buffer写入数据
有两种办法
- 调用channel的read方法
- 调用buffer自己的put方法
int len = channel.read(buffer);
buffer.put((byte) 127);
- 从buffer读取数据
同样有两种方法
- 调用channel的write方法
- 调用buffer的get方法
int len = channel.write(buffer);
byte b = buffer.get();
get方法会让postion读指针向后走,如果想重复读取数据
-
可以调用rewind方法将postion重新置为0
-
或者调用get(int i )方法获取索引i的内容,他不会移动读指针
-
mark和rese
mark 做一个标记,记录 postion 位置,reset 是将 postion 重置到 mark 的位置
- ByteBuffer与字符串互转
// 1. 字符串转为ByteBuffer
ByteBuffer buffer1 = ByteBuffer.allocate(16);
buffer1.put("hello".getBytes());
//2. Charset
ByteBuffer buffer2 = StandardCharsets.UTF_8.encode("hello");//会自动切换到读模式
//3. wrap
ByteBuffer buffer3 = ByteBuffer.wrap("hello".getBytes());//自动切换到读模式
//ByteBuffer转为字符串
String str1 = StandardCharsets.UTF_8.decode(buffer2).toString();
分散读和集中写
Scattering Reads
分散读取,有一个文本文件3parts.txt :::tips onetwothree ::: 使用如下方式读取,可以将数据填充至多个Buffer
try (RandomAccessFile file = new RandomAccessFile("helloword/3parts.txt", "rw")) {
FileChannel channel = file.getChannel();
ByteBuffer a = ByteBuffer.allocate(3);
ByteBuffer b = ByteBuffer.allocate(3);
ByteBuffer c = ByteBuffer.allocate(5);
channel.read(new ByteBuffer[]{a,b,c});
a.flip();
b.flip();
c.flip();
} catch (IOException e) {
e.printStackTrace();
}
GatheringWrites
集中写入
//集中写入
ByteBuffer b1 = StandardCharsets.UTF_8.encode("hello");
ByteBuffer b2 = StandardCharsets.UTF_8.encode("world");
ByteBuffer b3 = StandardCharsets.UTF_8.encode("你好");
try (RandomAccessFile r = new RandomAccessFile("data2.txt", "rw")) {
FileChannel channel = r.getChannel();
long write = channel.write(new ByteBuffer[]{b1, b2, b3});
} catch (IOException e) {
e.printStackTrace();
}
黏包半包
黏包半包分析
黏包:因为tcp是在传输时为了提高效率,可能会把多个消息合并在一起发送,就出现了黏包
半包:因为缓冲区满了,消息没有发送完,留到了下一次发送,就出现了半包。
黏包半包解析
文件编程
filechannel
注:
只能工作在阻塞模式下
方法简介
获取
读取
写入
关闭
位置
大小
强制写入
传输数据
transferTo //底层会利用操作系统的零拷贝进行优化,代码简介并且效率高
传输数据大于2g
上面方法一次只能传输2g大小的数据,如果大于2g可以分多次传输。
Path
jdk7引入了Path和Paths类
- Path用来表示文件路径
- Paths是工具类,用来获取Path实例
Path source = Paths.get("1.txt");
source.normalize() // 正常化路径
Files
检查文件是否存在
Files.exists(path);
创建一级目录
Files.createDirectory(path)
//如果目录已存在,会抛异常
//不能一次创建多级目录,否则会抛异常
创建多级目录
Files.createDirectories(path);
拷贝文件
Files.copy(source,target);
// 如果文件已存在,会抛异常
// 若果希望用source覆盖掉target,需要用StandardCopyOption来控制
Files.copy(source,target,StandardCopyOption.REPLACE_EXISTING);
删除文件
Files.delete(path);
// 如果文件不存在,会抛异常
删除目录
Files.delete(path);
//如果目录还有内容,会抛异常
遍历目录文件
public class TestFilesWalkFileTree {
public static void main(String[] args) throws IOException {
AtomicInteger dirCount = new AtomicInteger();
AtomicInteger fileCount = new AtomicInteger();
//遍历文件夹
Files.walkFileTree(Paths.get("D:\\devlop\\jdk\\OpenJDK21"),new SimpleFileVisitor<Path>(){
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
//在访问目录前执行的方法
System.out.println("====>"+dir);
dirCount.incrementAndGet();
return super.preVisitDirectory(dir, attrs);
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
//在访问目录后执行的方法
System.out.println(file);
fileCount.incrementAndGet();
return super.visitFile(file, attrs);
}
});
System.out.println("文件夹:"+dirCount);
System.out.println("文件:"+fileCount);
}
}
使用walkFileTree对拷文件夹
//使用Files API完成文件夹的对拷
String source = "D:\\data";
String target = "D:\\data-copy";
Files.walkFileTree(Paths.get(source),new SimpleFileVisitor<>(){
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
String targetName = dir.toString().replace(source, target);
//如果是目录,则创建目录到目标文件夹
Files.createDirectory(Paths.get(targetName));
return super.preVisitDirectory(dir, attrs);
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
String targetName = file.toString().replace(source, target);
//如果是文件,则拷贝
Files.copy(file,Paths.get(targetName));
return super.visitFile(file, attrs);
}
});
网络
网络编程
阻塞 VS 非阻塞
阻塞
- 在没有数据可读时,包括数据复制过程中,线程必须阻塞等待,不会占用cpu,但线程相当于闲置
- 32位jvm一个线程320k,64位jvm一个线程1024k,为了减少线程数,需要采用线程池技术
- 但即便用了线程池,如果有很多连接建立,但长时间inactive,会阻塞线程池中所有线程
//使用nio来理解阻塞模式 单线程
//0. ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(16);
//1. 创建服务器
ServerSocketChannel ssc = ServerSocketChannel.open();
//2. 绑定监听端口
ssc.bind(new InetSocketAddress(8080));
//3. 连接集合
List<SocketChannel> channels = new ArrayList<>();
while (true){
//4. accept建立与客户端的连接,SocketChannel 用来与客户端之间通信
SocketChannel sc = ssc.accept();
channels.add(sc);
// 5. 接收客户端发送的数据
for (SocketChannel channel : channels) {
channel.read(buffer);//阻塞方法,客户端发送消息后才会继续执行
buffer.flip();
System.out.println(buffer.get());
buffer.clear();
}
}
//在阻塞模式下,一个方法的调用都会影响别的方法执行,所以在阻塞模式下,用一个线程来处理多个链接不是一个正确的方式,可以选择用线程池
非阻塞
- 在某个Channel没有可读事件时,线程不必阻塞,他可以去处理其他有可读事件的Channel
- 数据复制过程中,线程实际还是阻塞的(AIO改进的地方)
- 写数据时,线程只是等待数据写入Channel即可,无需等待Channel通过网络把数据发送出去
//使用nio来理解非阻塞模式 单线程
//0. ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(16);
//1. 创建服务器
ServerSocketChannel ssc = ServerSocketChannel.open();
//2. 绑定监听端口
ssc.bind(new InetSocketAddress(8080));
ssc.configureBlocking(false); //切换服务器到非阻塞模式
//3. 连接集合
List<SocketChannel> channels = new ArrayList<>();
while (true){
//4. accept建立与客户端的连接,SocketChannel 用来与客户端之间通信
SocketChannel sc = ssc.accept();
// 非阻塞,线程还会继续运行,只不过sc==null
sc.configureBlocking(false); //将sc设为非阻塞模式
channels.add(sc);
// 5. 接收客户端发送的数据
for (SocketChannel channel : channels) {
channel.read(buffer);
//非阻塞模式,线程仍然会继续运行,如果没有读到数据,read返回0
buffer.flip();
System.out.println(buffer.get());
buffer.clear();
}
}
多路复用
线程必须配合Selector才能完成对多个Channel可读写事件的监控,这称之为多路复用。
- 多路复用仅针对网络IO、普通文件IO没法利用多路复用
- 如果不用Selector的非阻塞模式,那么Channel读取到的字节很多时候都是0,而Selector保证了有可读事件才去读取
- Channel输入的数据一旦准备好,会触发Selector的可读事件
使用selector处理accept事件
public static void main(String[] args) throws IOException {
//1. 创建 selector , 管理多个 channel
Selector selector = Selector.open();
ByteBuffer buffer = ByteBuffer.allocate(16);
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
//2. 建立selector 和 channel 的联系(注册)
// selectionKey 就是将来事件发生后,通过它可以知道事件和哪个channel的事件
//事件类型 1.accept 会在有连接请求时触发
//2. connect 是客户端,连接建立后触发的事件
//3. read 可读事件 客户端向服务端发消息
//4. write 可写事件
SelectionKey sscKey = ssc.register(selector, 0, null);
//key 只关注 accept 事件
sscKey.interestOps(SelectionKey.OP_ACCEPT);
ssc.bind(new InetSocketAddress(8080));
while (true){
//3. select 方法,没有事件发生,线程阻塞,有事件,线程才会恢复运行
// selcet 再有事件未处理时,不会阻塞如果拿到事件不想处理,可以用cancel方法将事件取消
selector.select();
//4. 处理事件
//selectionKeys 内部包含了所有发生的事件
Set<SelectionKey> selectionKeys = selector.selectedKeys();
//要在集合遍历的时候删除,要用迭代器遍历,先拿到集合的迭代器
Iterator<SelectionKey> iter = selectionKeys.iterator();
while (iter.hasNext()){
SelectionKey key = iter.next();
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
channel.accept();
}
}
}
使用selector处理read事件
public static void main(String[] args) throws IOException {
//1. 创建 selector , 管理多个 channel
Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
//2. 建立selector 和 channel 的联系(注册)
// selectionKey 就是将来事件发生后,通过它可以知道事件和哪个channel的事件
//事件类型 1.accept 会在有连接请求时触发
//2. connect 是客户端,连接建立后触发的事件
//3. read 可读事件 客户端向服务端发消息
//4. write 可写事件
SelectionKey sscKey = ssc.register(selector, 0, null);
//key 只关注 accept 事件
sscKey.interestOps(SelectionKey.OP_ACCEPT);
ssc.bind(new InetSocketAddress(8080));
while (true){
//3. select 方法,没有事件发生,线程阻塞,有事件,线程才会恢复运行
// selcet 再有事件未处理时,不会阻塞如果拿到事件不想处理,可以用cancel方法将事件取消
selector.select();
//4. 处理事件
//selectionKeys 内部包含了所有发生的事件
Set<SelectionKey> selectionKeys = selector.selectedKeys();
//要在集合遍历的时候删除,要用迭代器遍历,先拿到集合的迭代器
Iterator<SelectionKey> iter = selectionKeys.iterator(); // accept read
while (iter.hasNext()){
SelectionKey key = iter.next();
//如果一个key被处理了,一定要把key删除,否则下一次循环会报空指针 异常
iter.remove();
//5, 区分事件类型
if (key.isAcceptable()){
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
SocketChannel sc = channel.accept();
sc.configureBlocking(false);
SelectionKey scKey = sc.register(selector, SelectionKey.OP_READ, null);
} else if (key.isReadable()) {
try {
SocketChannel channel = (SocketChannel)key.channel();
ByteBuffer buffer = ByteBuffer.allocate(16);
int read = channel.read(buffer);
//如果是正常断开,read方法返回值是-1
if (read == -1){
key.cancel();
}else {
buffer.flip();
System.out.println(buffer.get());
buffer.clear();
}
} catch (IOException e) {
e.printStackTrace();
key.cancel();
//因为客户端断开了,因此需要将key取消(从selector的keys集合中真正删除key)
}
}
}
}
}
selector处理客户端断开 :::tips 在客户端异常断开后服务器会抛异常,这时应该在catch代码块中取消key,当客户端正常断开时,服务端的key会触发一次read事件,返回值是-1,所以应该在read事件后对返回值进行判断,如果是-1的话则取消key,并且中断代码逻辑。代码示例如上面那段代码 ::: selector在处理完事件后一定要在迭代器中remove
selector写入内容过多问题
:::tips
这种情况下,服务端一次性向客户端发送大量数据,但是操作系统的缓冲区是有限的,一次性并不能发送这么多的数据,所以程序会分多次向缓冲区中写入数据,这样虽然客户端最后接受的数据是完整的,但是传输的效率并不高,所以我们可以在缓冲区满的时候让线程去做别的事,当缓冲区空了的时候触发一次写事件,让线程再回来继续向缓冲区写数据。因为netty对这些都做了优化,所以具体的代码不展示。
:::
selector处理消息边界问题
- 一种思路是固定消息长度,数据包大小一样,服务器按预定长度读取,缺点是浪费带宽。
- 另一种思路是按分隔符拆分,缺点是效率低
- TLV格式。即Type类型、Length长度、Value数据,类型和长度已知的的情况下,就可以方便获取消息大小,分配合适的buffer,缺点是buffer需要提前分配,如果内存过大,则影响server吞吐量
- Http 1.1 是TLV格式
- Http 2.0 是LTV格式
selector附件
可以把buffer和channel一起注册到selectionKey上
SelectionKey scKey = sc.register(selector, SelectionKey.OP_READ, buffer);
//获取附件
key.attachment();
扩容
:::tips 当发生时刻一中的情况时,需要对buffer进行扩容,具体操作就是创建一个新的bytebuffer,容量是原来的2倍,然后调用key.attach()方法吧新的buffer当做附件绑定到key上。因为在实际开发中会使用netty进行开发,而netty对nio做了优化,所以这里不展示具体代码。 :::
ByteBuffer大小分配
- 每个channel都需要记录可能被拆分的消息,因为ByteBuffer不能被多个channel共同使用,因此需要为每个channel维护一个独立的ByteBuffer。
- ByteBuffer不能太大,比如一个ByteBuffer 1MB的话,要支持百万连接就要1Tb内存,因此需要设计大小可变的ByteBuffer
- 一种思路是首先分配一个较小的buffer,例如4k,如果发现数据不够,再分配8k的buffer,将4k的buffer内容拷贝到8k buffer,优点是消息连续容易处理,缺点是数据拷贝耗费性能。
- 另一种思路是用多个数组组成buffer,一个数组不够,把多出来的内容写入新的数组,与前面的区别是消息存储不连续解析复杂,优点是避免了拷贝引起的性能损耗。
网络编程小结
网络编程小结
selector何时不阻塞:
:::tips
- 事件发生时
- 客户端发起连接请求,会触发accept事件
- 客户端发送数据过来,客户端正常、异常关闭时,都会触发read事件,另外如果发送的数据大于buffer缓冲区会触发多次读取事件
- channel可写,会触发write事件
- 在linux下nio bug 发生时
- 调用selector.wakeup() //唤醒阻塞在selector上的线程
- 调用selector.close()
- selector所在线程interrupt :::
多线程优化
前面的代码只有一个选择器,没有充分利用多核cpu,如何改进呢? 分两组选择器
- 单线程配一个选择器,专门处理accept事件
- 创建cpu核心数的线程,每个线程配一个选择器,轮流处理read事件
- 如上图中BOSS线程只负责建立连接,而数据的读写操作分配给worker线程
public class MultiThreadServer {
public static void main(String[] args) throws IOException {
Thread.currentThread().setName("boss");
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
Selector boss = Selector.open();
SelectionKey bossKey = ssc.register(boss, SelectionKey.OP_ACCEPT, null);
ssc.bind(new InetSocketAddress(8080));
//1. 创建固定数量的worker 并初始化
Worker worker = new Worker("worker-0");
while (true){
boss.select();
Iterator<SelectionKey> iterator = boss.selectedKeys().iterator();
while(iterator.hasNext()){
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()){
SocketChannel sc = ssc.accept();
sc.configureBlocking(false);
//2. 关联
worker.register(sc);
}
}
}
}
static class Worker implements Runnable{
private Thread thread;
private Selector worker;
private String name;
private volatile boolean start = false;
private ConcurrentLinkedQueue<Runnable> queue = new ConcurrentLinkedQueue<>();
public Worker(String name){
this.name = name;
}
//初始化线程和selector
public void register(SocketChannel sc) throws IOException {
if (!start){
thread = new Thread(this,name);
thread.start();
worker = Selector.open();
start = true;
}
//向队列里添加了任务,但没有执行
queue.add(()->{
try {
sc.register(this.worker,SelectionKey.OP_READ,null);
} catch (ClosedChannelException e) {
throw new RuntimeException(e);
}
});
}
@Override
public void run() {
while(true){
try {
worker.select();
Runnable task = queue.poll();
if (task != null){
task.run();
}
Iterator<SelectionKey> iter = worker.selectedKeys().iterator();
while (iter.hasNext()){
SelectionKey key = iter.next();
iter.remove();
if (key.isReadable()){
ByteBuffer buffer = ByteBuffer.allocate(16);
SocketChannel channel = (SocketChannel) key.channel();
channel.read(buffer);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
NIO VS BIO
stream VS channel
- stream不会自动缓冲数据,channel会利用系统提供的发送缓冲区和接收缓冲区(更为底层)
- stream仅支持阻塞API,channel同事支持阻塞、非阻塞API,网络channel可配合selecrtor实现多路复用
- 二者均为全双工,即读写可以同时进行
IO模型
同步阻塞、同步非阻塞、多路复用、异步阻塞、异步非阻塞 当调用一次channel.read或stream.read后,会切换至操作系统内核态来完成真正的数据读取,而读取又分为两个阶段,分别为:
- 等待数据阶段
- 复制数据阶段
-
阻塞IO :::tips 在阻塞模式下,当用户线程调用read方法后会切换到内核态,用户线程被阻塞,直到操作系统等待到数据并且复制完数据之后线程才会恢复。 :::
-
非阻塞IO :::tips 在非阻塞模式下,用户线程调用read方法切换到内核态,如果此时操作系统还没有等待到数据,会直接返回一个结果,用户线程并不会被阻塞,但是需要频繁的调用read方法,也就是频繁的进行内核态的切换,也会浪费计算机的资源。 :::
-
多路复用
- 信号驱动
- 异步IO :::tips 同步:线程自己去获取结果(一个线程) 异步:线程自己不去获取结果,而是由其他线程送结果(至少两个线程) :::
零拷贝
将本地文件拷贝到网络
- 用户态与内核态的切换发生了3次,比较重量级
- 数据拷贝了4次
NIO优化 通过DirectByteBuffer
- DirectByteBuffer使用的操作系统内存(操作系统和用户内存都可以访问)
AIO(异步IO)
AIO 用来解决数据复制阶段的阻塞问题
-
同步意味着,在进行读写操作时,线程需要等待结果,还是相当于闲置
-
异步意味着,在进行读写操作时,线程不必等待结果,而是将由操作系统来通过回到方式由另外的线程来获得结果 :::tips 异步模型需要底层操作系统提供支持
-
windows系统通过 IOCP 实现了真正的异步 IO
-
Linux 系统异步 IO 在2,6版本后引入,但其底层实现还是用多路复用模拟了异步IO,性能没有优势 ::: netty4.*版本还不支持异步IO,因为主要是为了学习netty而学的NIO,这里的异步IO就不多做赘述