简单谈谈Netty的高性能之道

传统RPC 调用性能差的三宗罪

网络传输方式问题:传统的RPC 框架或者基于RMI 等方式的远程服务(过程)调用采用了同步阻塞IO,当客户端的并发压力或者网络时延增大以后,同步阻塞IO 会因为频繁的wait 致使IO 线程常常性的阻塞,因为线程没法高效的工做,IO 处理能力天然降低。下面,咱们经过BIO 通讯模型图看下BIO 通讯的弊端:react

  采用BIO 通讯模型的服务端,一般由一个独立的Acceptor 线程负责监听客户端的链接,接收到客户端链接以后为客户端链接建立一个新的线程处理请求消息,处理完成以后,返回应答消息给客户端,线程销毁,这就是典型的一请求一应答模型。该架构最大的问题就是不具有弹性伸缩能力,当并发访问量增长后,服务端的线程个数和并发访问数成线性正比,因为线程是JAVA 虚拟机很是宝贵的系统资源,当线程数膨胀以后,系统的性能急剧降低,随着并发量的继续增长,可能会发生句柄溢出、线程堆栈溢出等问题,并致使服务器最终宕机。算法

序列化方式问题:Java 序列化存在以下几个典型问题:编程

  1. Java 序列化机制是Java 内部的一种对象编解码技术,没法跨语言使用;例如对于异构系统之间的对接,Java 序列化后的码流须要可以经过其它语言反序列化成原始对象(副本),目前很难支持;
  2. 相比于其它开源的序列化框架,Java 序列化后的码流太大,不管是网络传输仍是持久化到磁盘,都会致使额外的资源占用;
  3. 序列化性能差(CPU 资源占用高)。

线程模型问题:因为采用同步阻塞IO,这会致使每一个TCP 链接都占用1 个线程,因为线程资源是JVM 虚拟机很是宝贵的资源,当IO 读写阻塞致使线程没法及时释放时,会致使系统性能急剧降低,严重的甚至会致使虚拟机没法建立新的线程。后端

高性能的三个主题:

  1. 传输:用什么样的通道将数据发送给对方,BIO、NIO 或者AIO,IO 模型在很大程度上决定了框架的性能。
  2. 协议:采用什么样的通讯协议,HTTP 或者内部私有协议。协议的选择不一样,性能模型也不一样。相比于公有协议,内部私有协议的性能一般能够被设计的更优。
  3. 线程:数据报如何读取?读取以后的编解码在哪一个线程进行,编解码后的消息如何派发,Reactor 线程模型的不一样,对性能的影响也很是大。

Netty 惊人的性能数据:

  经过使用Netty(NIO 框架)相比于传统基于Java 序列化+BIO(同步阻塞IO)的通讯框架,性能提高了8 倍多。经过选择合适的NIO 框架,精心的设计Reactor 线程模型,达到上述性能指标是彻底有可能的。数组

1.异步非阻塞通讯:

  在IO 编程过程当中,当须要同时处理多个客户端接入请求时,能够利用多线程或者IO 多路复用技术进行处理。IO 多路复用技术经过把多个IO 的阻塞复用到同一个select 的阻塞上,从而使得系统在单线程的状况下能够同时处理多个客户端请求。与传统的多线程/多进程模型比,I/O 多路复用的最大优点是系统开销小,系统不须要建立新的额外进程或者线程,也不须要维护这些进程和线程的运行,下降了系统的维护工做量,节省了系统资源。JDK1.4 提供了对非阻塞IO(NIO)的支持,JDK1.5_update10 版本使用epoll 替代了传统的select/poll,极大的提高了NIO 通讯的性能。JDK NIO 通讯模型以下所示:安全

  与Socket 类和ServerSocket 类相对应,NIO 也提供了SocketChannel 和ServerSocketChannel 两种不一样的套接字通道实现。这两种新增的通道都支持阻塞和非阻塞两种模式。阻塞模式使用很是简单,可是性能和可靠性都很差,非阻塞模式正好相反。开发人员通常能够根据本身的须要来选择合适的模式,通常来讲,低负载、低并发的应用程序能够选择同步阻塞IO 以下降编程复杂度。可是对于高负载、高并发的网络应用,须要使用NIO 的非阻塞模式进行开发。Netty 架构按照Reactor 模式设计和实现。服务器

  它的服务端通讯序列图以下:网络

  客户端通讯序列图以下:多线程

  Netty 的IO 线程NioEventLoop 聚合了多路复用器Selector,能够同时并发处理成百上千个客户端Channel,因为读写操做都是非阻塞的,这就能够充分提高IO 线程的运行效率,避免因为频繁IO 阻塞致使的线程挂起。另外,因为Netty采用了异步通讯模式,一个IO 线程能够并发处理N 个客户端链接和读写操做,这从根本上解决了传统同步阻塞IO 一链接一线程模型,架构的性能、弹性伸缩能力和可靠性都获得了极大的提高。架构

2.零拷贝

Netty 的“零拷贝”主要体如今以下三个方面:

  • 1) Netty 的接收和发送ByteBuffer 采用DIRECT BUFFERS,使用堆外直接内存进行Socket 读写,不须要进行字节缓冲区的二次拷贝。若是使用传统的堆内存(HEAP BUFFERS)进行Socket 读写,JVM 会将堆内存Buffer 拷贝一份到直接内存中,而后才写入Socket 中。相比于堆  外直接内存,消息在发送过程当中多了一次缓冲区的内存拷贝。当进行Socket IO 读写的时候,为了不从堆内存拷贝一份副本到直接内存,Netty 的ByteBuf 分配器直接建立非堆内存避免缓冲区的二次拷贝,经过“零拷贝”来提高读写性能。
  • 2) Netty 提供了组合Buffer 对象,能够聚合多个ByteBuffer 对象,用户能够像操做一个Buffer 那样方便的对组合Buffer进行操做,避免了传统经过内存拷贝的方式将几个小Buffer 合并成一个大的Buffer。
  • 3) Netty 的文件传输采用了transferTo()方法,它能够直接将文件缓冲区的数据发送到目标Channel,避免了传统经过循环write()方式致使的内存拷贝问题。对于不少操做系统它直接将文件缓冲区的内容发送到目标Channel 中,而不须要经过拷贝的方式,这是一种更加高效的传输方式,它实现了文件传输的“零拷贝”

3.内存池

  三个维度:

  • Pooled与UnPooled(池化与非池化)
  • UnSafe和非UnSafe(底层读写与应用程序读写)
  • Heap和Direct(堆内存与堆外内存)

  随着JVM 虚拟机和JIT 即时编译技术的发展,对象的分配和回收是个很是轻量级的工做。可是对于缓冲区Buffer,状况却稍有不一样,特别是对于堆外直接内存的分配和回收,是一件耗时的操做。为了尽可能重用缓冲区,Netty 提供了基于内存池的缓冲区重用机制。下面咱们一块儿看下Netty ByteBuf 的实现:

  Netty 提供了多种内存管理策略,经过在启动辅助类中配置相关参数,能够实现差别化的定制。下面经过性能测试,咱们看下基于内存池循环利用的ByteBuf 和普通ByteBuf 的性能差别。

  用例一,使用内存池分配器建立直接内存缓冲区:

final byte[] CONTENT = new byte[1024];
int loop = 1800000;
long startTime = System.currentTimeMillis();
ByteBuf poolBuffer = null;
for (int i = 0; i < loop; i++) {
  poolBuffer = PooledByteBufAllocator.DEFAULT.directBuffer(1024);
  poolBuffer.writeBytes(CONTENT);
  poolBuffer.release();
}
long endTime
= System.currentTimeMillis(); System.out.println("内存池分配缓冲区耗时" + (endTime - startTime) + "ms.");

  用例二,使用非堆内存分配器建立的直接内存缓冲区:

long startTime2 = System.currentTimeMillis();
ByteBuf buffer = null;
for (int i = 0; i < loop; i++) {
  buffer = Unpooled.directBuffer(1024);
  buffer.writeBytes(CONTENT);
  buffer.release(); }
endTime
= System.currentTimeMillis(); System.out.println("非内存池分配缓冲区耗时" + (endTime - startTime2) + "ms.");

  性能测试经验代表,采用内存池的ByteBuf 相比于朝生夕灭的ByteBuf,性能高了很多(性能数据与使用场景强相关)。下面咱们一块儿简单分析下Netty 内存池的内存分配:

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 中获取内存区域PoolArena,调用它的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 {
            buf = PlatformDependent.hasUnsafe() ? UnsafeByteBufUtil.newUnsafeDirectByteBuf(this, initialCapacity, maxCapacity) : 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 来实现不一样类型的缓冲区分配,因为测试用例使用的是堆外内存,所以重点分析DirectArena 的实现:若是没有开启使用sun 的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 方法设置引用计数器,用于对象的引用计数和内存回收(相似JVM 垃圾回收机制)。而 Unpooled.directBuffer(1024) 则是每次都要new

public static ByteBuffer allocateDirect(int capacity) {
        return new DirectByteBuffer(capacity);
}

4.高效的Reactor 线程模型

  经常使用的Reactor 线程模型有三种,分别以下:

Reactor 单线程模型;

Reactor 多线程模型;

主从Reactor 多线程模型

  Reactor 单线程模型,指的是全部的IO 操做都在同一个NIO 线程上面完成,NIO 线程的职责以下:

  1. 做为NIO 服务端,接收客户端的TCP 链接;
  2. 做为NIO 客户端,向服务端发起TCP 链接;
  3. 读取通讯对端的请求或者应答消息;
  4. 向通讯对端发送消息请求或者应答消息。

  Reactor 单线程模型示意图以下所示:

  因为Reactor 模式使用的是异步非阻塞IO,全部的IO 操做都不会致使阻塞,理论上一个线程能够独立处理全部IO 相关的操做。从架构层面看,一个NIO 线程确实能够完成其承担的职责。例如,经过Acceptor 接收客户端的TCP 链接请求消息,链路创建成功以后,经过Dispatch 将对应的ByteBuffer 派发到指定的Handler 上进行消息解码。用户Handler能够经过NIO 线程将消息发送给客户端。对于一些小容量应用场景,可使用单线程模型。可是对于高负载、大并发的应用却不合适,主要缘由以下:

  1. 一个NIO 线程同时处理成百上千的链路,性能上没法支撑,即使NIO 线程的CPU 负荷达到100%,也没法知足海量消息的编码、解码、读取和发送;
  2. 当NIO 线程负载太重以后,处理速度将变慢,这会致使大量客户端链接超时,超时以后每每会进行重发,这更加剧了NIO 线程的负载,最终会致使大量消息积压和处理超时,NIO 线程会成为系统的性能瓶颈;
  3. 可靠性问题:一旦NIO 线程意外跑飞,或者进入死循环,会致使整个系统通讯模块不可用,不能接收和处理外部消息,形成节点故障。

  为了解决这些问题,演进出了Reactor 多线程模型,下面咱们一块儿学习下Reactor 多线程模型。Rector 多线程模型与单线程模型最大的区别就是有一组NIO 线程处理IO 操做,它的原理图以下:

Reactor 多线程模型的特色:

  1. 有专门一个NIO 线程-Acceptor 线程用于监听服务端,接收客户端的TCP 链接请求;
  2. 网络IO 操做-读、写等由一个NIO 线程池负责,线程池能够采用标准的JDK 线程池实现,它包含一个任务队列和N个可用的线程,由这些NIO 线程负责消息的读取、解码、编码和发送;
  3. 1 个NIO 线程能够同时处理N 条链路,可是1 个链路只对应1 个NIO 线程,防止发生并发操做问题。

在绝大多数场景下,Reactor 多线程模型均可以知足性能需求;可是,在极特殊应用场景中,一个NIO 线程负责监听和处理全部的客户端链接可能会存在性能问题。例如百万客户端并发链接,或者服务端须要对客户端的握手消息进行安全认证,认证自己很是损耗性能。在这类场景下,单独一个Acceptor 线程可能会存在性能不足问题,为了解决性能问题,产生了第三种Reactor 线程模型-主从Reactor 多线程模型。

主从Reactor 线程模型的特色是:

  服务端用于接收客户端链接的再也不是个1 个单独的NIO 线程,而是一个独立的NIO线程池。Acceptor 接收到客户端TCP 链接请求处理完成后(可能包含接入认证等),将新建立的SocketChannel 注册到IO 线程池(sub reactor 线程池)的某个IO 线程上,由它负责SocketChannel 的读写和编解码工做。Acceptor线程池仅仅只用于客户端的登录、握手和安全认证,一旦链路创建成功,就将链路注册到后端subReactor 线程池的IO线程上,由IO 线程负责后续的IO 操做。它的线程模型以下图所示:

  利用主从NIO 线程模型,能够解决1 个服务端监听线程没法有效处理全部客户端链接的性能不足问题。所以,在Netty的官方demo 中,推荐使用该线程模型。事实上,Netty 的线程模型并不是固定不变,经过在启动辅助类中建立不一样的EventLoopGroup 实例并经过适当的参数配置,就能够支持上述三种Reactor 线程模型。正是由于Netty 对Reactor 线程模型的支持提供了灵活的定制能力,因此能够知足不一样业务场景的性能诉求。

5.无锁化的串行设计理念

  在大多数场景下,并行多线程处理能够提高系统的并发性能。可是,若是对于共享资源的并发访问处理不当,会带来严重的锁竞争,这最终会致使性能的降低。为了尽量的避免锁竞争带来的性能损耗,能够经过串行化设计,即消息的处理尽量在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。

  为了尽量提高性能,Netty 采用了串行无锁化设计,在IO 线程内部进行串行操做,避免多线程竞争致使的性能降低。表面上看,串行化设计彷佛CPU 利用率不高,并发程度不够。可是,经过调整NIO 线程池的线程参数,能够同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列-多个工做线程模型性能更优。Netty 的串行化设计工做原理图以下:

  Netty 的NioEventLoop 读取到消息以后,直接调用ChannelPipeline 的fireChannelRead(Object msg),只要用户不主动切换线程,一直会由NioEventLoop 调用到用户的Handler,期间不进行线程切换,这种串行化处理方式避免了多线程操做致使的锁的竞争,从性能角度看是最优的。

6.高效的并发编程

  Netty 的高效并发编程主要体如今以下几点:

  1. volatile 的大量、正确使用;
  2. CAS 和原子类的普遍使用;
  3. 线程安全容器的使用;
  4. 经过读写锁提高并发性能。

7.高性能的序列化框架

  影响序列化性能的关键因素总结以下:

  1. 序列化后的码流大小(网络带宽的占用);
  2. 序列化&反序列化的性能(CPU 资源占用);
  3. 是否支持跨语言(异构系统的对接和开发语言切换)。

  Netty 默认提供了对Google Protobuf 的支持,经过扩展Netty 的编解码接口,用户能够实现其它的高性能序列化框架,例如Thrift 的压缩二进制编解码框架。下面咱们一块儿看下不一样序列化&反序列化框架序列化后的字节数组对比:

  从上图能够看出,Protobuf 序列化后的码流只有Java 序列化的1/4 左右。正是因为Java 原生序列化性能表现太差,才催生出了各类高性能的开源序列化技术和框架(性能差只是其中的一个缘由,还有跨语言、IDL 定义等其它因素)。

8.灵活的TCP 参数配置能力

  合理设置TCP 参数在某些场景下对于性能的提高能够起到显著的效果,例如SO_RCVBUF 和SO_SNDBUF。若是设置不当,对性能的影响是很是大的。下面咱们总结下对性能影响比较大的几个配置项:

  1. SO_RCVBUF 和SO_SNDBUF:一般建议值为128K 或者256K;
  2. SO_TCPNODELAY:NAGLE 算法经过将缓冲区内的小封包自动相连,组成较大的封包,阻止大量小封包的发送阻塞网络,从而提升网络应用效率。可是对于时延敏感的应用场景须要关闭该优化算法;
  3. 软中断:若是Linux 内核版本支持RPS(2.6.35 以上版本),开启RPS 后能够实现软中断,提高网络吞吐量。RPS根据数据包的源地址,目的地址以及目的和源端口,计算出一个hash 值,而后根据这个hash 值来选择软中断运行的cpu,从上层来看,也就是说将每一个链接和cpu 绑定,并经过这个hash 值,来均衡软中断在多个cpu 上,提高网络并行处理性能。

  Netty 在启动辅助类中能够灵活的配置TCP 参数,知足不一样的用户场景。相关配置接口定义以下:

   基本上对于Netty的高性能是由以上主要的八点所共同支撑的。

相关文章
相关标签/搜索