俗话说"好记性不如烂笔头",编程的海洋如此的浩大,养成做笔记的习惯是成功的一步!
此笔记主要是netty-4.1.6.Final版本的笔记,并且笔记都是博主自己一字一字编写和记录,有错误的地方欢迎大家指正。
一、基础知识:
1、Netty是由JBOSS提供的一个java开源框架。Netty提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器和客户端程序。Netty是网络架构的新贵,与apache的Mina是同一个创始人,Netty是在Mina之后才开发出来的,集成了mina架构的优点,并且性能更加优越,被广泛用于大数据处理、互联网消息中间件、游戏和金融行业等。官方网站:http://netty.io
2、目前最新的netty版本是2025年12月份发布的netty-4.2.9.Final版本(netty5还属于内测版本不建议使用)。当前作者使用的是netty-4.1.6.Final版本,netty框架要求jdk1.6以上,不依赖于任何第三方框架,只需引入netty-all-4.1.6.Final.jar包即可。在netty-4.1.6.Final目录下有拆分的子jar包,也可以根据需要分jar包引入。
3、netty的特性:
(1)提供统一的api接口,底层封装了BIO和NIO等socket流。
(2)灵活并且高可扩展性,事件模型可扩展,关注点分割清晰。
(3)从3.1版本开始支持UDP协议(datagram socket)。
(4)高吞吐量,低延迟。
(5)可使用堆外内存,更少的资源消耗。
(6)支持 SSL/TLS 和 StartTLS安全传输。
4、netty采用的是Reactor反应器模式,与java的NIO流设计思想类似,通过事件注册自己感兴趣的事件,当此事件发生时将调用注册的处理器。netty的Reactor分为三种线程模型:
(1) Reactor单线程模型。
由单个线程来处理所有的客户端请求,包括握手认证到数据传输。此模式可靠性比较低,如果单个线程出现问题,那么整个通信框架将不可用。此模式只适合小容量应用场景,对于高负载、大并发的应用不适合。
(2) Reactor多线程模型。
专门有一个单线程(NIO线程-Acceptor线程)来监听和认证客户端的连接,当握手成功后,有一个线程池来处理客户端的读写操作。大多数情况下此模式都能满足需求,但在极特殊应用场景中,一个NIO线程负责监听和处理所有的客户端连接可能会存在性能问题。例如百万客户端并发连接,服务端需要对客户端的握手消息进行安全认证,而认证本身非常损耗性能。
(3) 主从Reactor多线程模型。
服务端用于接收客户端连接的不再是个1个单独的NIO线程,而是一个独立的NIO线程池。Acceptor接收到客户端TCP连接请求处理完成后(可能包含接入认证等),将新创建的SocketChannel注册到IO线程池(sub reactor线程池)的某个IO线程上,由它负责SocketChannel的读写和编解码工作。可靠性和性能最高,此模式也是官方推荐的。
提示:在代码中,指定使用Reactor线程模型,通过指定group方式,代码如下:
java
ServerBootstrap boot = new ServerBootstrap();
//指定了两个事件组,为 主从Reactor多线程模型。
boot.group(bossGroup, workGroup);
5、使用netty需要注意:
(1)TCP协议是基于流传输的,接收的数据是存放在buffer中,基于流发送的数据不是packet包而是byte字节,你写入一次的数据可能会分片多次传输,则就会出现接收数据可能一次接收不完全的情况。接受端可以判断接收的数据字节长度,直到达到了原始数据的长度才进行操作处理,netty提供了基于此方式的类ByteToMessageDecoder,可以直接继承此类来修改。
java
代码示例:
public class TimeDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
if (in.readableBytes() < 4) {
return;
}
//如果out有输入数据,则表示达到了所需要的数据,执行下一个ChannelHandler处理器。
out.add(in.readBytes(4));
}
}
(2)当处理比较复杂的协议时,netty中使用使用了pepeline管道模式,可以采用职责链的设计模式,将协议切分为多个解析处理操作,即可以传入多个ChannelHandler实现类,在处理时按传入顺序分别执行。
java
代码示例:
b.handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
//注意处理器的顺序。
ch.pipeline().addLast(new TimeDecoder(), new TimeClientHandler());
}
});
6、基于TCP协议进行数据传输时,需要协定如何才能表示接收到的数据是否完整。通常可以通过数据长度、定界符等形式来做判断依据。 Netty提供了DelimiterBasedFrameDecoder, FixedLengthFrameDecoder, LengthFieldBasedFrameDecoder, LineBasedFrameDecoder等类实现了各种常用的数据完整性判断方式。如需自己定义,可继承ByteToMessageDecoder类来自己实现。
7、关于JDK的NIO(非阻塞IO)和AIO(异步IO)的底层依赖,在Linux环境下,其实都是用Epoll方式,即AIO也只是个伪异步的IO,主要是因为Linux底层的真正异步IO还存在诸多问题和限制,所以,在 Linux 上,JDK AIO 可以看作是一个"基于NIO的、线程池包装的、提供了异步API外观"的模拟实现。 它的本质仍然是同步I/O,只是通过线程池和回调机制,为上层提供了异步的编程模型。
8、需要注意,无论是用JDK的BIO还是NIO方式,只要使用TCP,都会存在粘包/半包的情况,应用层程序员都必须自己负责解决消息边界问题。 因为BIO的代码逻辑通常是线性的、一个连接一个线程,开发者被迫需要处理 read返回的字节数,并手动解析缓冲区,判断一个完整的消息是否已经接收完毕。例如,你可能会先读取一个消息头(包含消息体长度),然后根据长度循环读取,直到凑够一个完整的消息。换句话说,BIO的编程模型"强迫"你从一开始就必须考虑和解决粘包/半包问题,否则你的程序根本无法正确工作。 这使得问题变得非常明显和直接。
9、TCP是以流的方式进行数据传送,对于TCP的粘包/半包处理策略,通常如下:
(1)消息长度固定,通过读取固定长度的数据报文后,就认为读取到一个完整的消息。
(2)将回车换行符作为消息结束符,例如FTP协议,这种方式在文本协议中应用比较广泛。
(3)将特殊的分隔符作为消息的结束标记,例如回车换行符就是一种特殊的结束分隔符。
(4)通过在消息头中定义长度来标识消息的总长度。例如固定用前面8位作为消息的总长度。
10、Netty的Handler执行顺序,入站(读操作)和出站(写操作)是完全相反的:
入站:数据从网络进来,从 head 开始处理
出站:数据要发往网络,从 tail 开始,最终到 head 发出
例如按如下顺序添加Handler:
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast("handler1", new MyHandler1()); // 第一个
pipeline.addLast("handler2", new MyHandler2()); // 第二个
pipeline.addLast("handler3", new MyHandler3()); // 第三个
此时的pipeline管道结构为:[head] → handler1 → handler2 → handler3 → [tail]
入站执行顺序(顺序):收到数据 → head → InboundHandler1 → InboundHandler2 → InboundHandler3 → tail
出站执行顺序(倒序):调用 write() → tail → OutboundHandler3 → OutboundHandler2 → OutboundHandler1 → head → 网络
注意管道的传播需要主动调用:
入站必须调用 ctx.fireChannelRead(msg) 才能继续往一个管道传播,调用下个管道的channelRead方法。
出站必须调用 ctx.write(msg, promise) 才能继续往下一个管道传播,调用下个管道的write方法。
附加:相应的,ctx还有fireChannelWritabilityChanged()、fireChannelActive()、fireChannelReadComplete()、fireExceptionCaught()等方法,都是用于向下一个管道传播对应的处理方法。
11、Netty的异步非阻塞实现,底层是基于JDK的NIO(即Selector)为核心进行开发的,并不是使用JDK的AIO模型。Netty的设计哲学是 "在稳定、高效的基础之上,构建更优雅的抽象" 。JDK的NIO提供了这个稳定高效的基础(尤其是在Linux上),而JDK的AIO则是不具备的。另外JDK的AIO在Linux上,实际底层使用的是系统的epoll的模拟实现,并非真正的异步I/O,它仍然是在背后使用线程池来模拟异步效果,存在性能瓶颈和稳定性问题。
12、在创建服务端的时候,分别有handler和childHandler的方法来指定处理器,这两种指定处理器区别如下:
handler:作用对象 ServerSocketChannel,处理服务端连接建立、绑定端口等事件,与服务器生命周期相同
childHandler:作用对象SocketChannel,处理客户端连接的数据读写、连接关闭等事件,与每个客户端连接的生命周期相同
附加;相应的,在handler和childHandler的方法里指定的处理器,也是根据不同生命周期来new处理器的,例如在childHandler,相同客户端连接的处理器都是同一个,但是不同客户端连接或同客户端新连接则是不同一个处理器,故处理器可以作为线程安全来定义(前提是一个对象不能同时给多个channel使用,通过严格按照在childHandler里面new新的处理器来保证)。Handler支持动态的添加或者删除操作,以便于在某些特殊情况下可以自由的增减对应的Handler。
13、当需要在handler处理器里处理耗时逻辑,或者需要定时触发执行一些逻辑(比如心跳机制的定时发送),可以使用ctx.executor()线程池来异步执行,这个线程池就是绑定给当前channel专门使用。
14、服务端的启动助手类ServerBootstrap可以指定两个线程池,参数1为Boss线程池,是父类的NIO线程池,用于服务端监听和接收客户端连接的Reactor线程;参数2为工作线程池,用于处理IO读写的Reactor线程。boss线程池的线程数量不需要过多(netty默认为CPU核心数*2),因为一个Selector只会绑定给一个EventLoop,即只会给一个线程使用,即使Boos线程池里有多个线程,也不会出现多个线程同时竞争和使用同个Selector实例。只有当监听多个端口(Netty支持服务端同时监听多个端口)或者多个IP(即多网卡情况),有多个Selector实例的时候,才会使用boss线程池的多个线程。
15、Netty并没有直接使用JDK的ByteBuffer字节缓存类来操作网络的IO,而是自定义了ByteBuf抽象类,对应的实现类有UnpooledHeapByteBuf、PooledDirectByteBuf等类型,其中 UnpooledHeapByteBuf底层是用byte数组,PooledDirectByteBuf底层是使用JDK的ByteBuffer。相较于ByteBuffer,Netty的ByteBuf有更好的API(读写指针是分离的),更易于资源释放(通过引用计数器来实现),同时支持自动扩容等优点。
16、Netty的ByteBuf类,提供了支持池化复用(即缓存对象重复使用),分别为PooledDirectByteBuf 、PooledHeapByteBuf、PooledUnsafeDirectByteBuf和PooledUnsafeHeapByteBuf几个支持池化的ByteBuf实现类。如果是堆内存HeapByteBuf,只复用byte[]数组,记录的是数组引用+偏移。如果是直接内存DirectByteBuf,则记录的是内存地址的起始位置,然后通过sun.misc.Unsafe来反复使用使用申请的这块内存(申请的内存虚拟地址是连续的,只需要记录起始位置,就可以计算出整片内存的地址,JDK的DirectByteBuffer也是直接用Unsafe通过内存地址来直接读写,不过不会重复使用),从而达到复用,不需要反复申请新的内存空间。Netty池化技术,确保需要复用的内存不会被回收,采取的方式是预先创建和分配好对象来占用内存。例如直接内存缓存是创建一个16MB的DirectByteBuffer对象,通过位图管理和记录内存的使用情况,调用者申请需要的内存大小,进行对此对象进行slice,共享底层内存。对于堆内存则是创建一个16MB的byte[]数组,同样通过位图管理和记录内存的使用情况,后根据调用者需要的内存大小,进行对此对象进行slice,来共享此数组,达到底层内存共享。
附加:
PooledDirectByteBuf:直接内存安全访问。使用ByteBuffer操作直接内存,相对安全,有ByteBuffer的边界检查,兼容性好,但性能中等。
PooledUnsafeDirectByteBuf:直接内存不安全访问。直接使用Unsafe操作内存地址,性能最高,特别是批量操作。但是绕过java安全检查。
PooledHeapByteBuf:堆内存安全访问。使用byte[]存储数据、所有访问都通过数组索引、兼容性好,最安全。
PooledUnsafeHeapByteBuf:堆内存不安全访问。继承自PooledHeapByteBuf,使用sun.misc.Unsafe直接进行内存操作,绕过java数组边界检查,但是性能更高。
对于大多数Netty应用,使用默认的PooledByteBufAllocator.DEFAULT即可,Netty会根据平台自动选择最佳实现。只有在特定性能要求或兼容性需求时,才需要手动选择特定类型。堆内存HeapByteBuf,使用场景是数据主要在JVM内部处理,不需要频繁与I/O交互,分配速度快,GC管理,减少内存复制。
直接内存DirectByteBuf,使用场景是网络I/O操作(Socket读写),需要零拷贝的场景(如文件传输)避免jvm堆与native堆之间的内存复制,但是分配较慢,需要手动释放内存。
默认情况下的ByteBuf使用:
netty的分配器:标准JVM(可理解为非安卓的场景下) → 池化;Android → 非池化
内存类型:有 Unsafe → Direct Buffer;无 Unsafe → Heap Buffer
I/O 操作:优先使用 ioBuffer() → Direct Buffer
智能预测:根据历史 I/O 大小动态调整初始容量
注意:
(1)Netty的池化内存复用,是要前提条件的,例如规格相同(规格化后的大小) 、内存类型匹配、缓存对象未满、相同Arena、分配内存小于16MB等要求。
(2)Netty复用的是内存地址,不是ByteBuf对象,也不是内存数据。当这些条件满足时,新分配的ByteBuf会指向之前用过的同一块物理内存,是复用内存。
(3)Netty的复用跟传统的复用思想有差异的,传统的是将整个对象用Map等方式缓存起来不给GC回收,而Netty是复用内存,并不是复用这个ByteBuf对象,目的是节省内存空间,提高复用率。
17、在服务端或者客户端,对于渠道有可读数据时,应该分配多大的ByteBuf来进行接收是个很重要的性能处理问题,虽然Netty的ByteBuf支持动态扩容,但是如果初始的ByteBuf分配不合理,导致频繁地扩容,或者是容量分配过大,都会导致性能急剧下降,故Netty引入了RecvByteBufAllocator接口,其中AdaptiveRecvByteBufAllocator根据历史接收消息记录动态调整初始的ByteBuf初始容量建议,减少ByteBuf的不合理分配初始容量问题。
18、早期的JDK版本上的epoll实现存在bug,会导致Selector空轮询,使得IO线程一直处于100%状态,Netty的NioEventLoop类,对Selector在进行轮询时会判断如果再某个周期内连续发生N次(默认512)空轮询,则任务是触发了JDK NIO的epoll()死循环的bug,会通过重建新的Selector方式(同时把原来注册的SocketChannel重新注册到新的Selector上,关闭旧Selector)让系统恢复正常。重建Selector的参考代码:io.netty.channel.nio.NioEventLoop#rebuildSelector
19、Netty源码分析:
(1)服务端通过ServerBootstrap类进行绑定端口并且监听服务,对boss线程池和work线程池创建的时候就会初始号线程数量对应的NioEventLoop,在执行调用bind()方法的时候, 执行initAndRegister方法进行初始和注册时,就会通过在boss线程池取出一个NioEventLoop对象。然后会调用init方法,对执行管道增加ServerBootstrapAcceptor处理器,并且此处理器传入的线程为work线程池,后续的连接后的handler处理都是用work线程池来执行。最后通过doBind0里调用channel.eventLoop().execute进行异步执行绑定操作,此处用的NioEventLoop对象是boss线程池里面的,故相当于Selector监听客户端连接是用了boss线程池。
(2)NioEventLoop是继承了SingleThreadEventLoop类,是一个对象对应一个线程的,虽然NioEventLoop有线程池Executor属性(默认是ThreadPerTaskExecutor实现类),但是内部有有控制只允许执行一次线程池的execute方法(通过懒加载方式使用startThread方法开启线程执行,调用后会打标记防止执行多次),即会保证只有一个线程执行处理NioEventLoop的run方法,故可以简单理解为一个对象对应一个线程。
(3)由于NioEventLoop是一个对象对应一个线程的,Netty线程池的使用,其实是根据在启动服务时传入的NioEventLoopGroup对象,此类会根据需要的线程数(默认为CPU核数*2)进行一次性初始化跟线程数相等的NioEventLoop对象,然后当需要线程池执行时,使用next方法从初始好的池里根据规则获取NioEventLoop对象来异步执行处理逻辑。
(4)服务端的boss线程是接收请求,work是处理对应每个连接的后续业务处理和IO操作。这块功能的处理机制源码分析如下:首先是用boss线程进行绑定端口并注册到从boss线程取出来的NioEventLoop对象上,然后NioEventLoop对象会在run方法里无限循环监听Selector的key,如果有客户端发起连接请求了,监听到连接成功或者读取事件,就会调用unsafe.read()方法,此时是是调用 io.netty.channel.nio.AbstractNioMessageChannel.NioMessageUnsafe#read方法,此类对应的会调用实现类io.netty.channel.socket.nio.NioServerSocketChannel#doReadMessages方法,将接受到的客户端请求SocketChannel对象封装到msg消息里,然后执行pipeline.fireChannelRead方法进行管道传播处理,此时管道处理器里是放置了ServerBootstrapAcceptor方法来处理(此管道是服务端ServerBootstrap在初始化绑定监听时加入的),ServerBootstrapAcceptor类的channelRead方法被触发调用,此方法会将接收到的客户端请求SocketChannel对象进行注册到子线程(即work线程池)childGroup.register(child),后续此条客户端的连接处理都有此子线程的NioEventLoop对象进行调用和处理,直到连接关闭。从而实现了boss线程接受请求到转给work线程进行处理具体的业务逻辑。
二、使用笔记:
1、服务端使用ServerBootstrap构建服务,客户端使用Bootstrap构建连接到服务端。
Netty的业务处理,使用职责链的设计模式,通过实现ChannelHandler接口来处理,可以自由的增加和减少此接口的实现类。
提示:
ServerBootstrap的使用需要注意handler和childHandler等区分普通channel和子channel的设计。
handler是客户端新接入ScoketChannel队列的CHannelPipeline的Handler处理,childHandler是TCP三次握手成功后,
NioServerScoketChannel使用的handler处理。
2、常用的解码器:
(1)LineBasedFrameDecoder:依次遍历ByteBuf中的可读字节,判断是否有"\n"或者"\r\n",如果有则以此位置为结束位置。
(2)StringDecoder:将接受到的对象转行为字符串,然后继续调用后面的Handler。
(3)DelimiterBasedFrameDecoder:指定分隔符来作为结束位置。如果达到单条消息最大长度还没查找到分隔符,会抛出异常。
(4)FixedLengthFrameDecoder:固定长度解码器,能够按照指定的长度对消息进行自动解码。
(5)LengthFieldBasedFrameDecoder:读取基于头部字段表示的长度报文后再解码。即相当于报文长度不固定,根据发送方给的报文长度字段值来界定结束位置。(头部字段也有可能出现半包,此时Netty会等待直到头部字段长度足够才读取)
(6)LengthFieldPrepender:对编码的数据增加头部字段,用于存入实际的数据长度数值,以供解码端读取到实际数据长度。与LengthFieldBasedFrameDecoder配套使用。
3、常用的处理器:
(1)ReadTimeoutHandler:读取超时处理器。会根据配置的超时时间,如果在这个时间内没有读取到数据,则会抛出超时的异常,并关闭通道。原理是通过调度器,定时执行判断最近一次读取数据时间是否有超时。
(2)IdleStateHandler:空闲处理器,提供了读写超时的处理机制,可以继承此处理器然后定制化对读写超时的处理逻辑。ReadTimeoutHandler也是继承此处理器进行二次开发。
原理是采用定时任务定时执行判断最近一次的读写时间是否超过指定时间阀值,每次产生读写时间都会更新对应的读写时间属性值。
(3)GlobalTrafficShapingHandler:控制全局网络流量的重要组件。使用方式跟普通handler无区别,需要手动创建,然后加入到pipeline的管道中。此流量控制器可以一般是全部管道都共用一个,启动全局控制的目的。
(4)ChannelTrafficShapingHandler:链路级流程控制组件。可针对不同的链路进行单独的流量控制,实现原理和GlobalTrafficShapingHandler类似,只是作用区域不同。
4、在网络之间使用流传输对象和数据时,通常都不使用jdk默认的序列化技术,因为不支持跨语言使用,并且序列化户的码流太大。通常用业界流程的json格式传输、MessagePack编解码、Google Protobuf编解码、JBoss marshalling编解码。
5、Http是基于TCP传输协议之上的应用层协议,在Netty中也是用NIO通讯框架来开发和支持Http协议的,因此也是异步非阻塞的。通常情况下都是通过web容器(例如tomcat、jetty)进行提供http的服务,但是这些容器都是重量级的,功能繁杂并且存在很多漏洞,如果只是想提供一个简单的http服务, 这是就可以考虑用Netty实现基于Http协议栈的服务支持。
6、返回http的响应时,需要注意 DefaultFullHttpResponse和DefaultHttpResponse的区别:
DefaultFullHttpResponse:可包含消息体,完整的相应内容,写入此对象后不能再继续写入。通常用于API接口内容响应。
DefaultHttpResponse:不包含消息体,仅能设置相应头信息。通常用于流式响应、分块传输文件。
7、Netty可以实现WebSocket的服务端和客户端,它基于Http协议栈开发了WebSocket协议栈,利用Netty的WebSocket协议栈可以非常方便的开发出WebSocket的服务端和客户端。本身WebSocket的协议就是基于Http先做一次基础请求,在Http的请求头里带上Upgrade头声明升级为websocket,然后服务端解析附加头信息后进行相应给客户端,双方就建立起WebSocket连接,后续的通讯都按照WebSocket协议来全双工自由传递信息,这个连接不会关闭持续存在直到客户端或者服务端的某一方主动关闭连接。连接是采用PING/PONG特殊消息来保持存活。所以在Netty框架里,实现WebSocket协议功能,也是用Http的HttpServerCodec、HttpObjectAggregator、ChunkedWriteHandler这些处理器。
附加:在通过http进行协议升级到WebSocket协议时,Netty会使用WebSocketServerHandshaker方法进行修改管道的Handler,增加对WebSocket协议支持的编码和解码处理器。
8、使用Netty的ByteBuf类时,需要注意及时释放引用,防止对象无法回收导致内存泄漏。需要遵循的原则如下:
(1)谁申请谁释放原则。创建buffer的组件负责最后的释放。创建当初引用次数初始为1。
(2)不要过度 retain:过多的 retain 会导致内存无法释放。
(3)release 次数必须匹配 retain 次数:否则会导致提前释放或内存泄漏。
(4)线程安全:retain() 增加引用和 release() 释放引用都是线程安全的。
(5)零拷贝优化:正确使用 retain/release 可以实现零拷贝数据传输。
注意如果是操作Handler传入进来的ByteBuf对象,不需要主动release(),因为pipeline会自动处理。
9、如果需要自定义私有协议,需要考虑如下的情况:
(1)定制专有的编解码规则。
(2)报文格式建议分为两部分:消息头+消息体组成。
(3)消息头的字段定于清楚,消息长度建议直接在消息头里用专门字段定义保存,简化编解码的复杂度和处理TCP半包和粘包问题。
(4)如果是长连接,需要有心跳机制,防止链路空闲被中间件防火墙等杀掉或者挂起。
(5)完整的私有协议,还需要考虑重连、登录校验和重复登录、消息缓存重发、安全机制、可扩展性设计等方面。
10、常用设置参数:
ChannelOption.SO_BACKLOG:是TCP的backlog参数,用于设置排队连接的队列最大连接个数(包括握手未完成的未连接队列和握手已完成的已连接队列,此参数是两个队列的总和值)。netty默认的backlog为100。
ChannelOption.TCP_NODELAY:激活或禁止ChannelOption.TCP_NODELAY套接字选项,觉得是否使用Nagle算法,如果是延迟敏感型应用,建议关闭Nagle算法。
ChannelOption.CONNECT_TIMEOUT_MILLIS:连接超时时间,由于NIO的原始客户端并不提供连接超时的接口,Netty底层采用的是自定义连接超时定时器负责检测和超时控制。
nnelOption.SO_SNDBUF:发送缓冲区大小。
ChannelOption.SO_RCVBUF:接收缓冲区大小。