最近脑海里一直有个念头,要重新好好复习一下netty的相关理论知识,即补充自己对底层通信的知识盲区,也想把相关的观点写下来,和众多网友分享。文章主要从以下几个方面进行介绍:
- 为什么要学习netty
- netty有哪些基础知识点
- 如何将netty和自己工作打通融合起来
- netty的高阶使用
1. 为什么要学习netty
Netty
是由JBOSS
提供的一个Java
开源框架,现为Github
上的独立项目。Netty
本质是一个NIO
框架,通过异步、基于事件驱动的IO,用以快速开发高性能、高可靠性的网络IO
程序,主要针对在TCP
协议下,面向Client
端的高并发应用,或者Peer-to-Peer
场景下的大量数据持续传输的应用。
作为Java后端研发同学,一定都会接触到框架和网络通信,就我接触过并看过源码的开源项目,可以说基本上都是使用Netty作为网络通信的底层框架,如Dubbo、RocketMQ、Canal、otter、HBase等等。
另一方面,netty作为高性能网络通信基础,有很多很好的设计可以借鉴到我们的日常研发过程中,比如零拷贝操作,bytebuffer理念,pipeline设计等。当然,netty的相关知识也是我们面试时重点考察的对象,所以好好学习netty一举多得。
2. netty有哪些基础知识点
基于博主对netty的理解认识,对netty高性能分为三部分,即网络IO、线程模型和内存优化:
所以后续netty的基础知识点也将围绕这这三方面来展开:
- 网络IO模型
- 多线程模型
- 设计模式
- 内存管理
- 高性能数据结构
3. netty的IO网络模型
netty是一个java开源框架,其IO模型是在JavaIO的基础上封装改进而来,底层关于Java IO的知识概念是一致的。
3.1 Java中的IO
在Java IO中,流从概念上来说是一个连续的数据流,既可以从流中读取数据,也可以往流中写数据。IO相关的媒介包括:
- 文件
- 管道
- 网络连接
- 内存缓存
- System.in, System.out
IO的设计,主要是解决IO相关的操作。从数据传输的方式上,分为字节流和字符流。字节流一次性读取传输一个字节,而字符流则是以字符为单位进行读取传输。
LINUX中进程无法直接操作I/O设备,必须通过系统调用请求kernel来协助完成I/O动作。内核会为每个I/O设备维护一个缓冲区,IO输入时应用进程请求内核,内核会先看缓冲区中有没有相应的缓存数据,有数据则直接复制到进程空间,没有的话再到设备中读取。通常用户进程中的一个完整IO分为两阶段:用户进程空间<-->内核空间、内核空间<- ->设备空间。
由于CPU和内存的速度远远高于外设的速度,所以在IO编程中,就存在速度严重不匹配的问题,所以有了同步/异步,阻塞和非阻塞IO之分。
3.1.1 IO 模型
IO模型分为:BIO、NIO、IO多路复用、信号驱动IO和AIO。
BIO:进程发起IO系统调用后,进程被阻塞,转到内核空间处理,整个IO处理完毕后返回进程,操作成功则进程获取到数据。BIO阻塞时,其它应用进程还能正常执行,所以不消耗CPU时间,这种模型的CPU利用率较高。
NIO:非阻塞IO模型在内核数据没准备好,需要进程阻塞的时候,就返回一个错误,以使得进程不被阻塞;由于CPU需要不断轮询内核数据是否准备好,CPU相比利用率会低一些。
IO多路复用:多个的进程的IO可以注册到一个复用器(selector)上,然后用一个进程调用该select,,select会监听所有注册进来的IO,这一过程会被阻塞,当某一个套接字可读时返回,之后再用recvfrom吧数据从内核拷贝到进程中。 IO多路复用也被称为事件驱动IO。
信号驱动IO:当进程发起一个IO操作,会向内核注册一个信号处理函数,内核立即返回,应用程序可以继续进行。当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用IO读取数据。相比于非阻塞的IO轮询,信号驱动IO的CPU利用率更高。
AIO:当进程发起一个IO操作,应用进程执行aio_read系统调用会立即返回,应用程序可以继续进行,内核会在所有操作完成后向应用程序发送信号。AIO和信号驱动IO的区别是:AIO的信号是通知进程IO完成,而信号驱动IO是通知应用程序可以进行IO。
3.1.2 Java NIO
NIO即new IO,是在JDK1.4引入的,NIO和IO有相同的作用和目的,但实现方式不同,NIO主要用到的是块,所以NIO的效率要比IO高很多。
NIO的核心对象包括:
- Buffer:在NIO中,所有的数据都是用Buffer处理的,它是NIO读写数据的中转池。Buffer实质上是一个数组,通常是一个字节数据,但也可以是其他类型的数组。
- Channel:是一个对象,可以通过channel读取和写入数据,是IO中流的抽象。但是channel是双向的,也可以是异步读写,并且channel读写必须通过buffer。
- Selector:是一个对象,可以同时监听多个channel上发生的事件,并且能够根据事件情况决定Channel读写。
// 打开Selector
Selector selector = Selector.open();
// 将channel注册到selector
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
Selector感兴趣的事件有SelectionKey.OP_CONNECT, SelectionKey.OP_ACCEPT, SelectionKey.OP_READ, SelectionKey.OP_WRITE。
SelectionKey表示通道channel在Selector上的注册,事件的传递是通过SelectionKey,也可以通过selectionKey获取注册的channel和对应绑定的selector。
Channel channel = selectionKey.channel();
Selector selector = selectionKey.selector();
一旦向selector注册一个或者多个通道后,就可以调用重载的select()方法,select()方法会返回读事件已经就绪的那些通道
- int select():阻塞到至少有一个通道的事件就绪
- int select(long timeout):与select一样,多个一个超时时间
- int selectNow():不会阻塞,不管什么通道就绪都立刻返回,如果没有通道可选择,就返回0.
一旦调用了select()
方法,它就会返回一个数值,表示一个或多个通道已经就绪,然后你就可以通过调用selector.selectedKeys()
方法返回的SelectionKey集合来获得就绪的Channel。某个线程调用select()方法后阻塞了,即使没有通道就绪,也有办法让其从select()方法返回。
- 让其它线程在调用select方法的对象上调用
Selector.wakeup()
方法即可,阻塞在select()方法上的线程会立马返回。 - 如果其它线程调用了wakeup(),但是当前没有线程阻塞在select上,下一个调用select阻塞的线程会被立即唤醒。
3.1.3 Java NIO和netty关系
直接使用Java NIO的缺点:
- NIO的类库和API繁杂,需要熟练掌握Selector、ServerSocketChannel、SocketChannel、ByteBuffer等才能很好使用。
- 可靠性较弱,需要自行维护,工作量大,例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等问题;
- JDK NIO的BUG,例如epoll bug,它会导致Selector空轮询,最终导致CPU 100%。
Netty是对Java NIO的封装框架,简化了NIO的使用难度,Netty特性总结如下:
- API使用简单,开发门槛低
- 功能强大,预置了多种编解码功能,支持多种主流协议
- 定制能力强,可以通过ChannelHandler对通信框架进行灵活地扩展
- 性能高,通过与其他业界主流的NIO框架对比,Netty的综合性能最优
- 成熟、稳定,Netty修复了已经发现的所有JDK NIO BUG,业务开发人员不需要再为NIO的BUG而烦恼
- 社区活跃,版本迭代周期短,发现的BUG可以被及时修复,同时更多的新功能会加入
- 经历了大规模的商业应用考验,质量得到验证。
3.2 Netty中的NIO关键组件
3.2.1 Buffer缓冲区
缓冲区本质上是一个可以读写数据的内存块,Netty封装Buffer对象提供了一组方法,可以更轻松地使用内存块,并且跟踪和记录缓冲区的状态变化。从Channel
读取或写入的数据都必须经由Buffer。
Buffer的类图结构有:
public abstract class ByteBuf implements ReferenceCounted, Comparable<ByteBuf> {
public ByteBuf() {
}
// 存储大小
public abstract int capacity();
public abstract ByteBuf capacity(int var1);
...
public abstract int readerIndex();
public abstract int writerIndex();
public abstract ByteBuf clear();
public abstract ByteBuf markReaderIndex();
public abstract ByteBuf resetReaderIndex();
public abstract ByteBuf markWriterIndex();
public abstract ByteBuf resetWriterIndex();
...
}
public abstract class AbstractByteBuf extends ByteBuf {
...
int readerIndex;
int writerIndex;
private int markedReaderIndex;
private int markedWriterIndex;
private int maxCapacity;
// 可读
public ByteBuf readerIndex(int readerIndex) {
if (readerIndex >= 0 && readerIndex <= this.writerIndex) {
this.readerIndex = readerIndex;
return this;
} else {
throw new IndexOutOfBoundsException(String.format("readerIndex: %d (expected: 0 <= readerIndex <= writerIndex(%d))", readerIndex, this.writerIndex));
}
}
// 可写
public ByteBuf writerIndex(int writerIndex) {
if (writerIndex >= this.readerIndex && writerIndex <= this.capacity()) {
this.writerIndex = writerIndex;
return this;
} else {
throw new IndexOutOfBoundsException(String.format("writerIndex: %d (expected: readerIndex(%d) <= writerIndex <= capacity(%d))", writerIndex, this.readerIndex, this.capacity()));
}
}
// 清理Buffer
public ByteBuf clear() {
this.readerIndex = this.writerIndex = 0;
return this;
}
public ByteBuf markReaderIndex() {
this.markedReaderIndex = this.readerIndex;
return this;
}
public ByteBuf resetReaderIndex() {
this.readerIndex(this.markedReaderIndex);
return this;
}
public ByteBuf markWriterIndex() {
this.markedWriterIndex = this.writerIndex;
return this;
}
public ByteBuf resetWriterIndex() {
this.writerIndex = this.markedWriterIndex;
return this;
}
...
}
netty的ByteBuffer有几个特性:
- Netty的ByteBuffer提供两个指针变量用于顺序读和顺序写,ReadIndex和WriteIndex。两个指针将Buffer分成3个区域,读取的数据只能位于readIndex和writeIndex之间,可写数据位于writeIndex和capacity之间。0到readIndex之间的区域是已经读取过的区域,可以调用discardReadBytes来重用这部分区间,以节约内存,防止Buffer的动态扩张。
- clear操作,并不会清空buffer的存储内容,而是用来充值readIndex和writeIndex,position,Mark和limit的,将他们还原为初始设置值。
- Mark和reset,Mark操作会将当前的位置指针备份到Mark变量中,调用reset后会将指针恢复到备份的位置,这种操作主要是因为对于某些读写需要回滚。
从存储结构上,buffer分为堆内存和直接内存buffer。
- 堆内存的特点是分配和回收速度快,也能被JVM自动管理,缺点是做Socket IO 操作时,需要从用户态拷贝到内核态,多一次复制操作。
- 直接内存buffer在堆外分配,缺点是分配和回收速度慢,但是做Socket IO操作时,会少一次内存拷贝。
结合对内对外内存分配的特点,在IO通信的时候使用直接内存分配,可以减少JVM的内存分配限制,还可以直接使用zeroCopy技术。后端业务编码的时候,可以直接使用HeapByteBuffer。
Netty的容量自动扩展,在小于4M的时候,采用倍增;大于等于4M时,采用每次增加4M。
补:select, poll和epoll
在OS上,对多个IO事件的监听是通过select/poll和epoll来实现的。他们的区别如下:
select
int select(int n, fd_set *readfds, fd_set *writefds, struct timeval *timeout)
- fd_set使用数组实现,数组大小使用FD_SETSIZE,所以只能监听少于FD_SETSIZE的描述符
- timeout为超时参数,调用select会一直阻塞直到描述符事件ready或者超时;
- 成功调用会返回1,错误返回-1,超时返回0
poll
int poll(struct pollfd *fds, unsigned int nfds, int timeout)
poll的功能和select类似,但poll中描述符是polld类型数组。polld定义如下:
struct pollfd{
int fd;
short events; /* request events */
short revents; /* return events */
}
epoll
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * enevts, int maxevents, int timeout);
epoll_ctl()用于向内核注重心的描述符或者改变某个文件描述的状态。已注册的描述符会在内核中维护在一颗红黑树上,通过回调函数内核会将IO准备好的描述符加入到一个链表中管理,进程调用epoll_wait()便可以得到事件完成的描述符。
epoll的描述符事件有两种触发模式:LT(level trigger)和ET(edge trigger)。
比较
- select和poll的功能基本相同,但是select会修改描述符,poll不会;
- select的描述符使用数组实现,默然大小不超过1024,而poll没有限制;
- poll提供了更多的事件类型,并且对描述符重复利用率比select高
- 如果一个线程对某个描述符调用了select或者poll,另一个线程关闭了改描述符,会导致结果不确定。
- 几乎所有的OS都支持select,但只有新的OS支持poll。
- epoll比select和poll更叫灵活而且描述符没有限制,对多线程也更友好,一个线程调用epoll_wait(),另一个线程关闭了这个描述符也不会产生不确定情况,但epoll仅适用于linux OS
场景:
- select 的timeout参数是微妙,而poll和epoll在毫秒级别;
- poll没有最大描述符数量限制,如果平台实时性要求不高,可使用poll;
- 在Linux OS上并且有大量描述符(大于1000)需要同时轮询,选择epoll。