Netty笔记
一、nio
1、nio三大组件
(1)channel
channel是一个双向的数据通道,常用的channel:
- FileChannel
- DatagramChannel
- SocketChannel
- ServerSocketChannel
(2)buffer
buffer,每次读写数据都是先往buffer读取或者写入数据。有以下类型:
- ByteBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
- CharBuffer
(3)selector
selector的作用就是配合一个线程管理多个channel,获取到管理的channel上发生的事件。这些channel工作在非阻塞的模式下,不会让线程阻塞在某个channel上。比较适合连接数比较多,但是流量比较少的场景下。
调用selector的select()方法会阻塞直到 channel发生了读写就绪事件,这些事件的发生,select会返回这些事件交给线程处理。
2、ByteBuffer的使用
ByteBuffer有以下重要的属性:
- Position,读写的位置指针
- Limit,读写的限制位置
- Capacity,容量
3、粘包和半包的问题
当数据在网络中传输的时候,由于缓冲区的大小限制,会导致数据包之间的粘连和拆分。当数据包过大时,会把数据包进行拆分后发送;当数据包过小的时候,会把数据包合并起来发送。
所以在数据接收端,需要把粘包的数据进行拆分,把半包的数据进行合并。
4、transferTo方法
该方法采用了零拷贝的技术,从一个缓冲区的数据拷贝到另一个缓冲区的数据,但是每一次拷贝的数据大小上限为2G
5、nio实例
server的代码:
package com.anxiao.nio;
@Slf4j
public class ServerSocketChannelTest {
public static void main(String[] args) throws IOException {
//创建一个selector
Selector select = Selector.open();
//创建serverSocketChannel
ServerSocketChannel serverChannel = ServerSocketChannel.open().bind(new InetSocketAddress(8088));
//配置非阻塞
serverChannel.configureBlocking(false);
//把serverSocketChannel加入到selector中,返回对应的selectionKey
SelectionKey serverKey = serverChannel.register(select, 0, null);
//设置ServerSocketChannel关注的事件,为链接事件
serverKey.interestOps(SelectionKey.OP_ACCEPT);
log.info("server is ready ....");
while (true) {
//如果没有事件发生,该方法会阻塞,如果有事件发生,那么线程唤醒继续执行
//由于有事件的时候,线程不会阻塞,所以有事件的时候要处理,不能不处理,否则在这个循环一直执行
select.select();
//首先,获取到selector上有事件发生的channel的集合,发生事件的集合与注册的集合不是同一个
//发生事件的集合,事件处理了还是会存在事件集合中,所以处理之后要记得移除
Iterator<SelectionKey> iterator = select.selectedKeys().iterator();
while (iterator.hasNext()) {
//发生时间的selecttionKey
SelectionKey selectionKey = iterator.next();
//移除当前事件
iterator.remove();
//获取发生事件的channel
SelectableChannel channel = selectionKey.channel();
//根据不同的事件类型进行处理
if (selectionKey.isAcceptable()) {
//表示有客户端连接进来
//获取到该事件发生的channel
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) channel;
//获取到连接进来的客户端channel
SocketChannel socketChannel = serverSocketChannel.accept();
//配置非阻塞
socketChannel.configureBlocking(false);
log.info("client connected:{}", socketChannel);
//把客户端的channel信息注册到selector上,并为灭个selectionKey绑定一个bytebuffer,大小为1k
SelectionKey socketSelectionKey = socketChannel.register(select, 0, ByteBuffer.allocate(1024));
//设置客户端的读事件
socketSelectionKey.interestOps(SelectionKey.OP_READ);
} else if (selectionKey.isReadable()) {
//表示有读事件发生
SocketChannel socketChannel = (SocketChannel) channel;
try {
ByteBuffer byteBuffer = (ByteBuffer) selectionKey.attachment();
//由于设定了buffer的大小,如果客户端传递过来的数据大于该buffer的大小,就会分批次触发read事件,因此需要考虑半包粘包的问题
int read = socketChannel.read((ByteBuffer) selectionKey.attachment());
if (read == -1) {
//客户端正常断开,也会产生一个读事件,但是读取的内容是-1
selectionKey.cancel();
log.info("客户端断开连接:{}", channel);
} else {
byteBuffer.flip();
log.info("读取{}的数据:{}", socketChannel, new String(byteBuffer.array(), StandardCharsets.UTF_8));
}
} catch (Exception e) {
log.info("客户端异常断开...{}", channel);
//当客户端断开的时候,其实会触发一个读的事件,因此在此需要把对应的事件取消
//他会从selector的注册集合中移除
selectionKey.cancel();
}
}
}
}
}
}
client的代码:
package com.anxiao.nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
public class SocketChannelTest {
public static void main(String[] args) throws IOException {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 8088));
Scanner scanner = new Scanner(System.in);
while (true){
String string = scanner.nextLine();
if(null != string && string.length() != 0){
if("exit".equals(string)){
break;
}
if("close".equals(string)){
socketChannel.close();
break;
}
socketChannel.write(ByteBuffer.wrap(string.getBytes(StandardCharsets.UTF_8)));
}
}
}
}
(1)关于read的事件
- 当客户端发送消息到服务器端,会触发一次读事件
- 当客户端调用close()会触发一次读事件,需要调用cancel()取消在selector上的注册
- 当客户端异常断开,也会触发一次读事件,需要调用cancel()取消在selector上的注册
- 当客户端发送的内容比服务器设定的缓ByteBuffer的长度还大,那么在Server端会触发多次读事件
(2)关于处理事件过程
当channel发生事件的时候,selector会把发生事件的channel放入一个集合,然后可以通过获取这个集合来遍历处理事件,但是处理完之后,需要把事件集合里面的元素移除。
(3)处理消息边界的问题
由于服务器端的ByteBuffer的长度是有限的或者无法实时适配每个客户端发送过来的数据。而客户端发送过来的数据有可能是大于ByteBuffer的大小,也可能小于Bytebuffer的大小,因此需要考虑半包、粘包的问题。
一种思路就是客户端发送的消息大小与服务器端的Bytebuffer的大小保持固定大小,缺点就是浪费宽带
另一种思路就是按照固定分隔符来对消息进行分割,但是由于每次处理都要把消息进行遍历,所以效率比较低
采用LTV格式,T就是Type格式,L就是Length长度,V就是value内容。直到这三个值,就可以直到分配的ByteBuffer的大小了,缺点是需要提前分配,入股哦内容过大会影响server端的吞吐量
(4)ByteBuffer大小的分配问题
- 每个channel都需要记录可能切分的消息,由于ByteBuffer不是线程安全的,所以我们需要为每一个channel维护一个ByteBuffer
- 而每个ByteBuffer不能太大,因此需要设计可变大小的
1)一种方案就是先分配固定大小的ByteBuffer,当读取数据的时候,如果大小不足,再进行扩容。优点就是数据是连续的,但是由于扩容涉及到数据的复制,所以比较消耗性能
2)另一个方案就是把数据放入到多个Byte Buffer中,多个ByteBuffer里的数据组成完整的数据。缺点就是数据存储不是连续的,但是少了数据复制的的性能消耗
6、阻塞、非阻塞、多路复用
(1)阻塞
在阻塞模式下,相关的方法都会导致线程阻塞:
accept()、read()方法都会导致线程阻塞,虽然不会占用cpu资源,但是线程闲置
- 单线程情况下,阻塞方法之间相互影响,几乎不能正常工作,需要多线程支持
- 但是多线程情况下,随着连接数越来越多,占用内存越来越高,cpu上下文切换也会越来越频繁,必然会导致oom和cpu占用过高的问题;如果采用线程池的方式去实现多线程,但是还是治标不治本。
(2)非阻塞
非阻塞模式下,相关方法不会让线程暂停:
- accept方法在没有连接的时候,会返回null,然后继续运行
- read方法在没有数据可读的情况下,会返回0,不会阻塞,可以去执行其他的事件
但是非阻塞模式下,没有事件发生的时候,线程都是一直运行下去的,浪费了cpu的资源。并且在读取数据的过程中(数据从内核空间复制到用户空间的时候)也是阻塞的。
(3)多路复用
单线程可以配合selector完成对channel事件的监控,称之为多路复用
- 多路复用仅仅针对网络IO,但是文件IO是无法多路复用的
- 如果不适用Selector的非阻塞模式,线程大部分时间还是做无用功
- 而且Selector保证:
1)有连接事件才去处理连接事件
2)有可读可写事件才会去处理读写事件
6、关于零拷贝
(1)传统io
File file = new File("文件路径");
RandomAccessFile random = new RandomAccessFile(file, "r");
byte[] buf = new byte[(int)f.length()];
//当代码调用read的时候,进行了一次用户态和内核态的切换
random.read(buf);
//执行完read的时候,又进行了一次内核态和用户态之间的切换
.....
Scoket socket = ....
//当调用write方法的时候,又进行了一次用户态和内核态之间的切换
socket.getOutputStream().write(buf);
以上代码进行了三次的用户态和内核态之间的切换
执行流程
传统io进行了三次的用户态和内核态之间的切换,并且进行了四次的复制过程
(2)NIO的优化
1)NIO中,从FileChannel读取数据到bytebuffer,然后从bytebuffer读取数据写入到SocketChannel中
,其中ByteBuff:
ByteBuffer.allocate(xxx) HeapByteBuffer 这个是java堆的内存,就是用户空间的
ByteBuffer.allocateDirect(xx) DirectByteBuffer 这个是操作系统的内存空间,是堆外内存
2)使用堆外内存时的内部流程:
3)NIO的操作也是三次用户态和内核态之间的切换,但是数据复制少了一次
第一次,就是从磁盘读取数据到堆外内存,是一次用户态切换内核态的过程
第二次,读取完数据之后,就是内核态切换到用户态的过程
第三次,调用write方法,把数据从堆外内存写入到socket缓冲区的过程,是一次用户态切换内核态的过程
(3)NIO进一步的优化
在linux2.1 后提供了sendFile方法,对应Java中的transferTo和transferFrom 方法
1)其内部流程
2)这一次优化,数据复制次数依然是三次,但是内核态和用户态的切换只进行了一次,就是调用transferTo和transferFrom 的时候
(4)NIO再一步进行优化(linux2.4)
1)用户态和内核态的切换一样是只切换了一次,就是调用transferTo和transferFrom的时候,但是数据复制只进行了两次
2)内部流程:
当用户态切换到内核态之后,数据DMA直接把数据放入到内核缓冲区,不会使用cpu;
内核缓冲区只会把少量的数据,比如offset和length等信息复制到socket缓冲区,几乎无消耗;
使用DMA把数据充内核缓冲区把写入到网卡,不使用cpu
整个过程只切换了一次用户态和内核态,复制只用了两次。
零拷贝并不是零次拷贝,而是不需要将数据从内核空间复制到用户空间
零拷贝适合小文件的复制,大文件不适合
7、异步io
(1)window才是真正的实现了异步io,使用的是iocp实现的异步io
(2)linux底层使用了io多路复用模拟了异步io,并没有真正实现异步io
(3)netty也没有使用异步io
8、阻塞io、非阻塞io、多路复用io都是阻塞的,只有异步io是非阻塞的
二、Netty
netty是一个异步的、基于事件驱动的网络应用框架,可以快速开发可维护的、高性能的网络服务器和客户端。这里的异步指的是使用了多线程实现调用者和结果处理的分离,并不是说netty使用了异步io的模型,netty使用的是io多路复用的模型。
1、组件
1.1、EventLoop
EventLoop本质上是一个单线程的执行器,内部同事维护了一个selector,里面有run方法处理channel上源源不断的事件
继承关系:
继承了scheduledExecutorService,因此包含了线程池的所有方法
继承了netty自己的orderedEventExecutor,其中:
1、提供了 Boolean inEventLoop(Thread thread) 判断一个线程是否数以某个事件组
2、提供了parent方法查看属于哪个循环组
1.2、EventLoopGroup
是一组EventLoop,channel会调用register方法绑定EventLoopGroup里面的某一个EventLoop,后续此channel的事件都有该EventLoop处理,保证了io的线程安全
继承了netty自己的EventExecutorGroup,实现了iterable接口,具有遍历EventLoop的能力;具有next获取下一个eventLoop的能力。
1.3 、pipeline
pipeline中的handler可以指定不同的EventLoopGroup,因为实际场景中,有可能某个Handler执行业务操作的事件比较长,可能会影响到io的EventLoopGroup的操作,所以可以重新顶一个新的EventLoopGroup,然后添加handler的收,指定新创建的EventLoopGroup
EventLoopGroup other = new DefaultEventLoopGroup()
new ServerBootstrap().group(new NioEventLoopGroup(), new NioEventLoopGroup()).......
//第一个handler使用NioEventLoopGroup执行,第二个handler使用other这个EventLoopGroup执行
.pipeline().addLast(xxxxx).addLast(other, xxxxx)
当执行这个pipeline里面的handler的时候,某个handler使用另外的EventLoopGoup(可以理解为另外的线程的意思)执行,所以需要切换线程,源码如下:
io.netty.channel.AbstractChannelHandlerContext
/**
*next 下一个handler的上下文信息
* msg 需要处理的消息
*/
static void invokeChannelRead(final AbstractChannelHandlerContext next, Object msg) {
final Object m = next.pipeline.touch(ObjectUtil.checkNotNull(msg, "msg"), next);
//获取到下一个的eventLoop,因为EventLoop继承了EventExecutor的
EventExecutor executor = next.executor();
//判断下一个的eventLoop与eventLoop是否同一个线程
if (executor.inEventLoop()) {
//是同一个线程,就直接调用
next.invokeChannelRead(m);
} else {
//如果不是,就把执行的任务(Runnable代码)交给下一个eventLoop的线程执行
executor.execute(new Runnable() {
@Override
public void run() {
next.invokeChannelRead(m);
}
});
}
}
2、netty中使用的是异步处理事件,但是为何使用异步呢?
把处理流程细节化,每一个线程处理某一部分,那么就可以提高单个线程处理单个环节的处理数量,从而提高吞吐量,但是流程的总花费时间是大于一个线程处理一个总流程的时间的,这个是提高吞吐量,降低了响应时间
3 、netty中的Future和promise
(1)netty中的Future继承自jdk中的Future,但是对比于jdk中的Future,netty中的Future可以异步获取执行结果,并且可以知道结果是否是成功或失败。
(2)netty中的promise继承netty中的Future,相比Future,promise中多了可以设置执行成功或失败的条件
4、 Handler和pipeline
pipeline包含多个handler,每一个Handler是处理channel上的各种事件的,分为出站事件和入站栈事件
- 出战处理器,通常是ChannelOutboundHandlerAdepter的子类,主要是用于读取客户端的结果
- 入栈处理器,通常是ChannelInboundHandlerAdepter的子类,主要是用于对写回结果的加工
关于handler的执行顺序
5、Netty中的byteBuf
-
相同点:
都可以创建一个堆内存和直接内存的空间 -
不同的是
(1)netty中的byteBuff,可以选择池化,把创建过的buteBuf保存起来,等待下一次使用。可以使用参数开启池化,默认是开启的
(2)netty中的byteBuff,读写模式不用切换,可以一直写一直读(读到写的指针为止)。内部分为读指针、写指针、容量(初始容量,默认是16)和最大容量(默认是Integer的最大值)
(3)当写入的数据已经超过初始容量,会进行扩容
- 如果写入后的数据未超过512,那么会选择下一个16的倍数进行扩容。
- 如果写入后的数据超过了512,则会选择下一个2n,比如写入后的数据是512(29),那么扩容就是2^10=1024
(4)netty回收byteBuf的方法是采用引用计数来进行回收的,每一个ByteBuf都实现了ReferenceCount接口:
- 每个ByteBuf对象的初始引用计数为1
- 每次调用release方法引用计数减少1,若为0,则会被回收
- 调用retain方法引用计数加1,表示调用者没有使用完之前,就算其他使用者调用了release了也不会回收
- 当引用计数为0,底层内存会被回收,就算对象还在,其方法都无法使用
6、零拷贝的体现
(1)slice
可以从原来的byteBuf中指定切割出来作为新的byteBuff使用,但是两个byteBuf使用的是同一块物理内存,对其中一个byteBuf中的元素修改都会同时影响。
但是切割出来的byteBuf不允许添加新的元素,否则会报错
另外,需要注意的是,如果原来的byteBuf释放了内存,导致了引用计数为0,那么切割出来的byteBuf会变成不可使用
(2)duplicate
可以从原来的byteBuf截取所有的内容,并且没有最大容量限制,就是说新截取出来的byteBuf可以添加新元素,但是使用的而是同一块物理内存
(3)Unpool类的相关操作
可以把多个byteBuff合并成一个
7、解决半包和粘包问题
(1)Netty提供了通过换行符、指定数据长度、指定分隔符作为处理半包和粘包问题的handler,但是都是会存在问题
(2)netty提供了定义数据格式来解析正确的数据的handler
LengthFieldBasedFrameDecoder
其中比较重要的几个参数:
- lengthFieldOffset 长度字段偏移量,就是定义内容长度字段从整块内容中的哪一个位置开始的
- lengthFieldLength 长度字段的长度,意思是说,发送的数据中,有几个字节是记录实际内容的长度的
- langthAdjugement 以定义长度的字段为基准,还有多少个字节是内容,意思是,从定义长度的字段开始,到实际内容之间,是否还有隔着的字节数
- initialBytesToStrip 从头剥离多少个字节
8、协议定义
(1)netty提供了各种协议,比如SimpleChannelInboundHandler<>,可以处理特定消息类型的请求,比如HttpRequest
(2)协议的定义
- 魔术,用来第一时间判定是否无效的数据包
- 协议版本号,可以支持协议的升级
- 序列化算法,消息正文采用的序列化算法,比如json、protobuf
- 战令类型,比如登陆,注册等等
- 请求的序号,为了双开工,提高异步的能力
- 正文消息的长度
- 消息正文
9、处理器
(1)入站处理器
ChannelInboundHandlerAdepter
(2)出站处理器
ChannelOutboundHandlerAdepter
(3)出入站都可以
ChannelDuplexHandler
10、参数调优
(1)connect_timeout_millis,属于客户端的参数
- 属于socketChannel的参数
- 场景:如果在指定毫秒内无法连接成功服务器,会抛出timeout异常
Bootstrap bootstrap = new Bootstrap();
//设置超时连接时间
bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, XXXX);
(2)so_backlog参数
这个参数是serverSocketChannel的参数设置,这个值会影响服务器性能
图解:
-
首先,服务器的accept是发生在tcp的三次握手之后的。
-
当第一次握手的时候,服务器会把客户端的连接信息放入到半连接队列中
-
当三次握手之后,服务器会从半连接队列中取出完成三次连接的连接放入到全连接队列中
-
然后服务器accept的时候,就是从全连接队列取出连接进行处理
-
backlog的参数其大小直接影响到服务器能够允许客户端连接的数量,一旦连接数超过服务器的backlog的值,服务器会拒绝连接;
-
backlog的参数,在linux2.2之前,是包含半连接队列和全连接队列的大小,在2.2之后,分别用以下方式设置:
a. syns queue-半连接队列
proc/sys/net/ipv4/tcp_max_syn_backlog,指定,在syncookies开启的情况下,逻辑上是没有最大的限制,这个参数的设置会被忽略
b. accept queue-全连接队列
其大小通过/proc/sys/net/core/somaxconn来指定(),使用listen函数时,内核会根据传入参数于系统设置的参数值比较,取两者的最小值 如果accept queue满了,那么服务器会发送一个拒绝连接的错误信息给客户端
而在netty中,可以通过以下方式设置全连接队列的大小设置:
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.option(ChannelOption.SO_BACKLOG);