1 背景介绍
1.1 传统RPC调用性能差的三个弊端
弊端一:网络传输方式存在弊端。传统的RPC框架或者基于RMI等方式的远程服务调用都采用BIO,当客户端的并发压力或网络延时增大的时候,BIO会因频繁“wait”导致I/O线程经常出现阻塞的情况,由于线程本身无法高效的工作,I/O处理能力自然就会下降。下面通过BIO通信模型看一下BIO通信的弊端。
采用BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,接收到客户端连接之后为客户端连接创建一个新的线程处理请求消息,处理完成之后,返回应答消息给客户端,线程销毁,这就是典型的一请求一应答模型。当用户访问量剧增时,并发量自然上升,而服务端的线程个数和并发访问数成线性正比。由于线程是JVM非常宝贵的系统资源,所以随着并发量的持续增加、线程数急剧膨胀,系统的性能也急剧下降,可能会发生句柄溢出和线程堆栈溢出等问题,最终可能会导致服务器宕机。
弊端二:序列化方式存在弊端。Java序列化存在如下几个较为典型的问题。
1、序列化是Java内部针对对象设计的编解码技术,无法跨语言使用。如果在异构系统之间对接,Java序列化后的字节码流需要能够通过其他语言发序列化成原始对象(即副本),在目前的技术环境下无法支持。
2、相比于其他开源的序列化框架,Java序列化后的字节码流占用的空间太大,无论是网路传输还是持久化到磁盘,都会增加资源的消耗。
3、序列化性能差,在编解码过程中需要占用高的CPU资源。
弊端三:线程模型存在弊端。由于传统的RPC框架均采用BIO模型,这使得每个TCP连接都需要分配一个线程,当I/O读写阻塞导致线程无法及时释放时,会导致系统性能急剧下降,甚至会导致虚拟机无法创建新的线程。
1.2 Netty高性能的三个主题
Netty高性能的三个主题如下图
1、I/O传输模型:用什么样的通道将数据发送给对方,是BIO、NIO还是AIO,I/O传输模型在很大程度上决定了框架的性能。
2、数据协议:采用什么样的通信协议,是HTTP还是内部私有协议。协议的选择不同,性能模型也就不同,一般来说内部私有协议比公有协议的性能更高。
3、线程模型:线程模型设计如何读取数据包,读取之后的编解码在哪个线程中进行,编解码后的消息如何派发等方面。线程 模型设计的不同,对性能也会产生非常大的影响。
2 Netty高性能的核心
2.1 异步非阻塞通信
在I/O编程过程中,当需要同时处理多个客户端接入请求的时候,可以利用多线程或者多路复用I/O技术来实现。多路复用I/O就是把多个I/O的阻塞用到同一个Selector的阻塞上,从而达到系统在单线程的情况下也可以同时处理多个客户端请求的目的。与传统的多进程/线程模型相比,多路复用I/O的最大优势是系统开销小,系统不再需要新的进程或线程,也不需要维护新创建的进程或线程的运行,降低了系统的维护工作量,减轻了系统开销。
从JDK1.4开始,Java API就提供了对非阻塞I/O(即NIO)的支持 。从JDK 1.5开始,采用了epoll模型替代传统的select/poll模型,极大地提升了NIO通信的性能。与Socket类和ServerSocket类相对应,NIO也提供了SocketChannel和ServerSocketChannel两种不同的套接字通道实现。这两种新增的Channel都支持阻塞和非阻塞两种I/O模式。开发者根据具体的业务场景来选择更合适的I/O模型。一般来说,低负载、低并发的应该程序可以使用BIO以降低编程复杂度。但对于高负载、高并发的网络应用程序,通常会采用NIO的非阻塞模式进行开发。
Netty就是一个满足高性能、高并发的网络通信框架。Netty底层采用Reactor线程模式来设计和实现的,先来看Netty服务端API的通信步骤,其序列图如下:
再来看一下Netty客户端API的通信步骤,其序列图如下:
通过上面的序列图,能够了解到Netty的I/O线程NioEventLoop聚合了Selector,可以同时并发处理成百上千个客户端Channel,而且它的写读操作都是非阻塞的,这可以大幅提高I/O线程的运行效率,避免由于频繁I/O阻塞导致的线程挂起。另外,由于Netty采用的是异步通信模型,单个I/O线程也可以并发处理多个客户端连接和读写操作,所以从根本上解决了传统BIO的单连接单线程模型的弊端。
2.2 零拷贝
Netty的零拷贝主要体现在如下三个方面。
1、Netty接收和发送ByteBuffer采用DirectBuffer,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆存进行Socket读写,那么JVM会将堆存拷贝一份到直接内存中,然后才写入Socket。相比于堆外直接内存,消息在发送的过程中多了一次缓冲区的内存拷贝。
2、Netty提供了组合Buffer对象,可以聚合多个ByteBuffer对象,用户可以像操作一个Buffer那样方便的对组合Buffer进行操作,避免了传统的通过内存拷贝的方式将几个小Buffer合并成一个大Buffer的繁琐操作。
3、Netty中文件传输采用了transferTo()方法,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write()方式导致的内存拷贝问题。
下面针对上述三种对零拷贝的描述在源码中进行验证,以加深理解。先看第一种Netty对于堆外直接内存的使用,AbstractNioByteChannel的源码如下:
public final void read() {
ChannelConfig config = AbstractNioByteChannel.this.config();
ChannelPipeline pipeline = AbstractNioByteChannel.this.pipeline();
ByteBufAllocator allocator = config.getAllocator();
RecvByteBufAllocator.Handle allocHandle = this.recvBufAllocHandle();
allocHandle.reset(config);
ByteBuf byteBuf = null;
boolean close = false;
try {
do {
byteBuf = allocHandle.allocate(allocator);
allocHandle.lastBytesRead(AbstractNioByteChannel.this.doReadBytes(byteBuf));
if (allocHandle.lastBytesRead() <= 0) {
byteBuf.release();
byteBuf = null;
close = allocHandle.lastBytesRead() < 0;
break;
}
allocHandle.incMessagesRead(1);
AbstractNioByteChannel.this.readPending = false;
pipeline.fireChannelRead(byteBuf);
byteBuf = null;
} while(allocHandle.continueReading());
allocHandle.readComplete();
pipeline.fireChannelReadComplete();
if (close) {
this.closeOnRead(pipeline);
}
} catch (Throwable var11) {
this.handleReadException(pipeline, byteBuf, var11, close, allocHandle);
} finally {
if (!AbstractNioByteChannel.this.readPending && !config.isAutoRead()) {
this.removeReadOp();
}
}
}
找到do...while()循环中的 allocHandle.allocate()方式,实际上调用的是DefaultMaxMessagesRecvByteBufAllocator类中的allocate()方法,如下:
public ByteBuf allocate(ByteBufAllocator alloc) {
return alloc.ioBuffer(this.guess());
}
相当于每循环读取一次消息,就通过ByteBufAllocator的ioBuffer方法获取ByteBuf对象,继续看它的接口定义:
public abstract class ByteBuf implements ReferenceCounted, Comparable<ByteBuf> {
...
}
当Socket进行I/O读写的时候,为了避免从堆内存拷贝一个副本到直接内存,Netty的ByteBuf分配器直接创建非堆内存避免缓冲区的二次拷贝,通过零拷贝来提升读写性能。
下面继续看第二种零拷贝组合Buffer的实现类CompositeByteBuf类,它的类定义如下图:
通过继承关系可以看出 CompositeByteBuf 实际就是一个ByteBuf的包装器,它将多个ByteBuf组合成一个集合,然后对外提供统一的ByteBuf接口,相关定义如下:
private static final ByteBuffer EMPTY_NIO_BUFFER;
private static final Iterator<ByteBuf> EMPTY_ITERATOR;
private final ByteBufAllocator alloc;
private final boolean direct;
private final List<Component> components;
private final int maxNumComponents;
private boolean freed;
添加ByteBuf不需要做内存拷贝,相关代码如下:
private int addComponent0(boolean increaseWriterIndex, int cIndex, ByteBuf buffer) {
assert buffer != null;
boolean wasAdded = false;
int var11;
try {
this.checkComponentIndex(cIndex);
int readableBytes = buffer.readableBytes();
Component c = new Component(buffer.order(ByteOrder.BIG_ENDIAN).slice());
if (cIndex == this.components.size()) {
wasAdded = this.components.add(c);
if (cIndex == 0) {
c.endOffset = readableBytes;
} else {
Component prev = (Component)this.components.get(cIndex - 1);
c.offset = prev.endOffset;
c.endOffset = c.offset + readableBytes;
}
} else {
this.components.add(cIndex, c);
wasAdded = true;
if (readableBytes != 0) {
this.updateComponentOffsets(cIndex);
}
}
if (increaseWriterIndex) {
this.writerIndex(this.writerIndex() + buffer.readableBytes());
}
var11 = cIndex;
} finally {
if (!wasAdded) {
buffer.release();
}
}
return var11;
}
最后,看一下第三种文件传输的零拷贝,transferTo()方法的应用如下
(DefaultFileRegion.java):
public long transferTo(WritableByteChannel target, long position) throws IOException {
long count = this.count - position;
if (count >= 0L && position >= 0L) {
if (count == 0L) {
return 0L;
} else if (this.refCnt() == 0) {
throw new IllegalReferenceCountException(0);
} else {
this.open();
long written = this.file.transferTo(this.position + position, count, target);
if (written > 0L) {
this.transferred += written;
}
return written;
}
} else {
throw new IllegalArgumentException("position out of range: " + position + " (expected: 0 - " + (this.count - 1L) + ')');
}
}
Netty文件传输DefaultFileRegion 类通过transferTo()方法将文件发送到目标Channel中,下面重点看FileChannel的transferTo方法,它的API DOC说明如下:
将文件Channel的数据写入指定的Channel。这个方法可能比简单的将数据从一个Channel循环读取到另一个Channel更有效。许多操作系统可以直接从文件系统缓存传输字节到目标通道,而不是实际拷贝它们。
2.3 内存池
随着JVM和JIT即时编译技术的发展,对象的分配和回收依然是一个非常轻量级的工作。但是对于缓冲区来说还有些特殊,尤其是对于堆外直接内存的分配和回收,是一种耗时的操作。为了尽量重复利用缓冲区内存,Netty设计了一套基于内存池的缓冲区重用机制。看一下ByteBuf的实现,如下图所示。
Netty提供了多种内存管理策略,通过在启动辅助类中配置相关参数,可以实现差异化的个性定制。
下面通过性能测试来看一下基于内存池循环利用的ByteBuf和普通ByteBuf的性能差异。
编写如下代码,采用内存池分配器创建直接缓冲区。
public class MemoryPoolTest {
public static void main(String[] args) {
final byte[] CONTENT = new byte[1024];
int loop = 1800000;
test01(loop,CONTENT);
test02(loop,CONTENT);
}
public static void test01(int loop,byte[] CONTENT){
long startTime = System.currentTimeMillis();
for (int i = 0; i < loop; i++) {
ByteBuf byteBuf = PooledByteBufAllocator.DEFAULT.directBuffer(1024);
byteBuf.writeBytes(CONTENT);
byteBuf.release();
}
long endTime = System.currentTimeMillis();
System.out.println("内存池分配缓冲区耗时:" + (endTime - startTime) + "ms");
}
public static void test02(int loop,byte[] CONTENT){
long startTime = System.currentTimeMillis();
ByteBuf buf = null;
for (int i = 0; i < loop; i++) {
buf = Unpooled.directBuffer(1024);
buf.writeBytes(CONTENT);
}
long endTime = System.currentTimeMillis();
System.out.println("非内存池分配缓冲区耗时:" + (endTime - startTime) + "ms");
}
}
运行结果如下:
通过对比表明,采用内存池的ByteBuf相比于非内存池的ByteBuf,性能明显提升。下面分析一下Netty内存池的内存分配。AbstractByteBufAllocator.java
public ByteBuf directBuffer(int initialCapacity, int maxCapacity) {
if (initialCapacity == 0 && maxCapacity == 0) {
return this.emptyBuf;
} else {
validate(initialCapacity, maxCapacity);
return this.newDirectBuffer(initialCapacity, maxCapacity);
}
}
继续看newDirectBuffer()方法。发现它是一个抽象方法,由AbstractByteBufAllocator的子类负责具体实现,其类关系如下:
查看PooledByteBufAllocator的newDirectBuffer()方法,从Cache中获取内存区域PoolArea,调用它的allocate方法进行内存分配。
protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
PoolThreadCache cache = (PoolThreadCache)this.threadCache.get();
PoolArena<ByteBuffer> directArena = cache.directArena;
Object buf;
if (directArena != null) {
buf = directArena.allocate(cache, initialCapacity, maxCapacity);
} else if (PlatformDependent.hasUnsafe()) {
buf = UnsafeByteBufUtil.newUnsafeDirectByteBuf(this, initialCapacity, maxCapacity);
} else {
buf = new UnpooledDirectByteBuf(this, initialCapacity, maxCapacity);
}
return toLeakAwareBuffer((ByteBuf)buf);
}
PoolArena的allocate方法如下:
PooledByteBuf<T> allocate(PoolThreadCache cache, int reqCapacity, int maxCapacity) {
PooledByteBuf<T> buf = this.newByteBuf(maxCapacity);
this.allocate(cache, buf, reqCapacity);
return buf;
}
下面重点分析newByteBuf()方法的实现,它同样是一个抽象方法,由子类DirectArena和HeapArena来实现不同类型的缓冲区分配,如下图所示:
如果没有开启和使用JDK内置的Unsafe,则执行如下代码:
protected PooledByteBuf<ByteBuffer> newByteBuf(int maxCapacity) {
return (PooledByteBuf)(HAS_UNSAFE ? PooledUnsafeDirectByteBuf.newInstance(maxCapacity) : PooledDirectByteBuf.newInstance(maxCapacity));
}
执行PooledDirectByteBuf的newInstance()方法,代码如下:
static PooledDirectByteBuf newInstance(int maxCapacity) {
PooledDirectByteBuf buf = (PooledDirectByteBuf)RECYCLER.get();
buf.reuse(maxCapacity);
return buf;
}
从上述代码可以看出,通过调用RECYCLER的get方法,获取循环使用的ByteBuf对象,判断如果是非内存池实现,则直接创建一个新的ByteBuf对象。从缓冲区中获取ByteBuf之后,调用AbstractReferenceCountedByteBuf的setRefCnt方法设置引用计数器,用于对象的引用技术和内存回收。
2.4 高效的Reactor线程模型
常用的Reactor线程模型有三种,分别如下:
1、Reactor单线程模型。
2、Reactor多线程模型。
3、主从Reactor多线程模型。
Reactor单线程模型,指的是所有的I/O操作都在同一个NIO线程中完成,NIO线程的职责如下:
1、作为NIO服务端,接收客户端的TCP连接。
2、作为NIO客户端,向服务端发起TCP连接。
3、读取通信对端的请求或者应答消息。
4、向通信对端发送消息请求或者应答消息。
Reactor单线程模型的工作方式如下图:
由于Reactor模型使用的是NIO,所有的I/O操作 都不会阻塞,理论上一个线程可以独立处理所有的I/O相关操作。从架构层面上看,一个NIO线程确实可以完成其承担的责任。从上图中看到,Acceptor负责接收客户端的TCP连接请求消息,链路建立成功之后,通过Dispatcher将对应的ByteBuffer派发到执行的Handler上进行消息解码,用户Handler通过NIO线程将消息发送给客户端。
对于并发量较小的业务场景,可以使用单线程模型。但单线程模型不适用于高负载、高并发的场景,主要原因如下。
1、一个NIO如果同时处理成百上千的链路,则机器在性能上无法满足,即便是NIO线程的CPU负载达到100%,也无法满足海量消息的编解码、读取和发送。
2、如果NIO线程负载过重,那么处理速度将变慢,从而导致大量客户端连接超时,超时之后往往会进行重发,这更加重了NIO线程的负载,最终会导致大量消息积压和处理超时,NIO线程就会成为系统的性能瓶颈。
3、一旦NIO线程发生意外或者进入死循环状态,就会导致整个系统通信模块不可用,从而不能接收和处理外部消息,造成节点故障。
Reactor多线程模型就是为了解决以上问题而被设计出来的,下面介绍Reactor多线程模型。
Reactor多线程模型与单线程模型最大的区别就是设计了一个NIO线程池处理I/O操作,它的原理如下图所示:
从上图可以看出,Reactor多线程模型有以下特点:
1、有一个专门的NIO线程Acceptor用于监听服务端、接收客户端的TCP连接请求。
2、网络I/O的读写等操作只由一个NIO线程池负责,可以采用标准的JDK线程池来实现,它包含一个任务队列和多个可用的线程,由这些NIO线程负责消息的读取、解码、编码和发送。
3、一个NIO线程可以同时处理多条请求链路,但是一条链路只对应一个NIO线程,防止发生并发串行。
在绝大多数场景下,Reactor多线程模型都可以满足性能需求。但是,在极特殊的应用场景中,一个NIO线程负责监听和处理所有的客户端连接也可能会有性能问题。例如,百万客户端并发连接,或者服务端需要对客户端的握手消息进行安全认证,认证本身消耗性能极大。在这类场景下,单个Acceptor线程可能会存在性能不足的问题,为了解决性能问题,就出现了主从Reactor多线程模型。
主从Reactor多线程模型的特点是:服务端用于接收客户端连接的不再是单个NIO线程,而是分配了一个独立的NIO线程池。Acceptor接收到客户端TCP连接请求并处理完成后(可能包含接入认证等),将新创建的SocketChannel注册到I/O线程池(sub Reactor 子线程池)的某个I/O线程上,有它负责SocketChannel的读写和编码工作。Acceptor线程池仅仅用于客户端的登录、握手和安全认证,一旦链路建立成功,就将链路注册到后端的Sub Reactor子线程池的I/O线程上,再由I/O线程负责后续的I/O操作。其线程模型工作原理如下图:
利用主从Reactor多线程模型可以解决一个服务端监听线程无法有效处理所有客户端连接的性能不足的问题。因此,在Netty官方的Demo中,推荐使用该线程模型。
事实上,Netty的线程模型并非固定不变,通过在启动辅助类中创建不同的EventLoopGroup实例并通过适当的参数配置,就可以自由选择上述三种Reactor线程模型。
2.5 无锁化的串行设计理念
在大多数应用场景下,并行多线程处理可以提升系统的并发性能。但是,如果对共享资源的并发处理访问不当,就会造成严重的锁竞争,最终导致系统性能的下降。为了间可能避免锁竞争带来的性能损耗,可以通过串行化设计来避免多线程竞争和同步锁,即消息的处理尽可能在同一个线程内完成,不进行线程切换。
为了尽可能提升性能,Netty采用了无锁化串行设计,在I/O线程内部进行串行操作,避免多线程竞争导致的性能下降。表面上看,似乎串行设计的CPU利用率不高,并发程度不够。但是,通过调整NIO线程池的线程参数,可以同时启动多个串行的线程并行运行,这种局部无锁化的串行线程设计相比一个队列-多个工作线程的模型性能更优。Netty串行设计工作原理如下:
NIO的NioEventLoop读取消息之火,直接调用ChannelPipeline的fireChannelRead(Object msg),只要用户不主动切换线程,NioEventLoop就会调用用户的Handler,期间不进行线程切换,这种串行处理方式避免了多线程操作导致的锁竞争,从性能角度看值最优的。
2.6 高效的并发编程
Netty的高效并发编程主要体现在如下几点:
1、volatile关键字的大量且正确的使用。
2、CAS和原子类的广泛使用。
3、线程安全容器的使用。
4、通过读写锁提升并发性能。
2.7 灵活的TCP参数配置能力
合理设置TCP参数在某些场景下对性能的提升具有显著效果。相关配置接口定义如下表所示: