Netty的高性能之道

1.背景

最近看到gitHub上有一个开源项目,通过使用 Netty4 + Thrift 压缩二进制编解码技术,他们实现了 10W TPS(1K 的复杂 POJO 对象)的跨节点远程服务调用。相比于传统基于 Java 序列化 +BIO(同步阻塞 IO)的通信框架,性能提升了 8 倍多。

事实上,通过选择合适的 NIO 框架,加上高性能的压缩二进制编解码技术,精心的设计 Reactor 线程模型,达到上述性能指标是完全有可能的。

目前主流的技术架构里,尤其是大数据架构里很多都使用了Netty
在这里插入图片描述

2.Netty概述

Netty 是一个高性能、异步事件驱动的 NIO 框架,它提供了对 TCP、UDP 和文件传输的支持,作为一个异步 NIO 框架,Netty 的所有 IO 操作都是异步非阻塞的,通过 Future-Listener 机制,用户可以方便的主动获取或者通过通知机制获得 IO 操作结果。

Netty 现在都在用的是4.x,5.x版本已经废弃,Netty 4.x 需要JDK 6以上版本支持。

JDK原生也有一套网络应用程序API,但是存在一系列问题,主要如下:

  • NIO的类库和API繁杂,使用麻烦,你需要熟练掌握Selector、ServerSocketChannel、SocketChannel、ByteBuffer等
  • 需要具备其它的额外技能做铺垫,例如熟悉Java多线程编程,因为NIO编程涉及到Reactor模式,你必须对多线程和网络编程非常熟悉,才能编写出高质量的NIO程序
  • 可靠性能力补齐,开发工作量和难度都非常大。例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等等,NIO编程的特点是功能开发相对容易,但是可靠性能力补齐工作量和难度都非常大
  • JDK NIO的BUG,例如臭名昭著的epoll bug,它会导致Selector空轮询,最终导致CPU 100%。
    官方声称在JDK1.6版本的update18修复了该问题,但是直到JDK1.7版本该问题仍旧存在,只不过该bug发生概率降低了一些而已,它并没有被根本解决

Netty 对 JDK 自带的 NIO 的 API 进行了良好的封装,解决了上述问题。且Netty拥有高性能、 吞吐量更高,延迟更低,减少资源消耗,最小化不必要的内存复制等优点。

3.RPC 调用的性能模型分析

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

3.1.1 网络传输方式问题

传统的 RPC 框架或者基于 RMI 等方式的远程服务(过程)调用采用了同步阻塞 IO,当客户端的并发压力或者网络时延增大之后,同步阻塞 IO 会由于频繁的 wait 导致 IO 线程经常性的阻塞,由于线程无法高效的工作,IO 处理能力自然下降,BIO通信模型如下图所示。
在这里插入图片描述

采用 BIO 通信模型的服务端,通常由一个独立的 Acceptor 线程负责监听客户端的连接,接收到客户端连接之后为客户端连接创建一个新的线程处理请求消息,处理完成之后,返回应答消息给客户端,线程销毁,这就是典型的一请求一应答模型。

该架构最大的问题就是不具备弹性伸缩能力,当并发访问量增加后,服务端的线程个数和并发访问数成线性正比,由于线程是 JAVA 虚拟机非常宝贵的系统资源,当线程数膨胀之后,系统的性能急剧下降,随着并发量的继续增加,可能会发生句柄溢出、线程堆栈溢出等问题,并导致服务器最终宕机。

3.1.2 序列化方式问题

Java 序列化存在如下几个典型问题:
1.Java 序列化机制是 Java 内部的一种对象编解码技术,无法跨语言使用;例如对于异构系统之间的对接,Java 序列化后的码流需要能够通过其它语言反序列化成原始对象(副本),目前很难支持;

2.相比于其它开源的序列化框架,Java 序列化后的码流太大,无论是网络传输还是持久化到磁盘,都会导致额外的资源占用;

3.序列化性能差(CPU 资源占用高)。

3.1.3 线程模型问题

由于采用同步阻塞 IO,这会导致每个 TCP 连接都占用 1 个线程,由于线程资源是 JVM 虚拟机非常宝贵的资源,当 IO 读写阻塞导致线程无法及时释放时,会导致系统性能急剧下降,严重的甚至会导致虚拟机无法创建新的线程。

3.2 高性能的三个主题

在这里插入图片描述

1.传输:用什么样的通道将数据发送给对方,BIO、NIO 或者 AIO,IO 模型在很大程度上决定了框架的性能。

2.协议:采用什么样的通信协议,HTTP 或者内部私有协议。协议的选择不同,性能模型也不同。相比于公有协议,内部私有协议的性能通常可以被设计的更优。

3.线程:数据报如何读取?读取之后的编解码在哪个线程进行,编解码后的消息如何派发,Reactor 线程模型的不同,对性能的影响也非常大。

4.Netty高性能分析

4.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 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。

4.2 零拷贝

零拷贝是老生常谈的问题,无论是Kafka还是Netty等都用到了零拷贝的知识,那究竟什么是零拷贝呢

“零”:表示次数是0,它表示拷贝数据的次数是0

“拷贝”:指数据从一个存储区域转移到另一个存储区域

合起来,那零拷贝就是不需要将数据从一个存储区域复制到另一个存储区域。

零拷贝是指计算机执行IO操作时,CPU不需要将数据从一个存储区域复制到另一个存储区域,进而减少上下文切换以及CPU的拷贝时间。它是一种IO操作优化技术。

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

1.Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。

2.Netty中提供CompositeByteBuf类,可以聚合多个 ByteBuffer 对象,用户可以像操作一个 Buffer 那样方便的对组合 Buffer 进行操作,避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的 Buffer。

3.Netty 的文件传输采用了FileChannel的transferTo 方法,底层使用到sendfile函数,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。

下面对上述三种“零拷贝”进行说明

4.2.1 Netty接收Buffer的创建

在这里插入图片描述

每循环读取一次消息,就通过 ByteBufAllocator 的 ioBuffer 方法获取 ByteBuf 对象,并通过ioBuffer分配堆外内存,当进行 Socket IO 读写的时候,为了避免从堆内存拷贝一份副本到直接内存,Netty 的 ByteBuf 分配器直接创建非堆内存避免缓冲区的二次拷贝,通过“零拷贝”来提升读写性能

4.2.2 CompositeByteBuf

CompositeByteBuf它对外将多个 ByteBuf 封装成一个 ByteBuf,对外提供统一封装后的 ByteBuf 接口,它的类定义如下:
在这里插入图片描述

通过继承关系我们可以看出 CompositeByteBuf 实际就是个 ByteBuf 的包装器,它将多个 ByteBuf 组合成一个集合,然后对外提供统一的 ByteBuf 接口,相关定义如下:

public class CompositeByteBuf extends AbstractReferenceCountedByteBuf implements Iterable<ByteBuf> {
    
    

    private static final ByteBuffer EMPTY_NIO_BUFFER = Unpooled.EMPTY_BUFFER.nioBuffer();
    private static final Iterator<ByteBuf> EMPTY_ITERATOR = Collections.<ByteBuf>emptyList().iterator();

    private final ByteBufAllocator alloc;
    private final boolean direct;
    private final int maxNumComponents;

    private int componentCount;
    private Component[] components; // resized when needed

    private boolean freed;

添加 ByteBuf,不需要做内存拷贝,相关代码如下:

    private <T> int addComponents0(boolean increaseWriterIndex, int cIndex,
            ByteWrapper<T> wrapper, T[] buffers, int offset) {
    
    
        checkComponentIndex(cIndex);

        // No need for consolidation
        for (int i = offset, len = buffers.length; i < len; i++) {
    
    
            T b = buffers[i];
            if (b == null) {
    
    
                break;
            }
            if (!wrapper.isEmpty(b)) {
    
    
                cIndex = addComponent0(increaseWriterIndex, cIndex, wrapper.wrap(b)) + 1;
                int size = componentCount;
                if (cIndex > size) {
    
    
                    cIndex = size;
                }
            }
        }
        return cIndex;
    }

3.2.3 文件传输的零拷贝

transferTo 方法将数据从 FileChannel 对象传送到可写的字节通道(如Socket Channel等)。在 transferTo 方法内部实现中,由 native 方法 transferTo0 来实现,它依赖操作系统底层的支持。在 UNIX 和 Linux 系统中,调用这个方法会引起 sendfile() 系统调用,实现了数据直接从内核的读缓冲区传输到套接字缓冲区,避免了用户态(User-space)与内核态(Kernel-space)之间的数据拷贝。

    /**
     * Transfers the content of this file region to the specified channel.
     * 将此文件区域的内容传输到指定的频道。
     * @param target    the destination of the transfer
     * @param position  the relative offset of the file where the transfer
     *                  begins from.  For example, <tt>0</tt> will make the
     *                  transfer start from {@link #position()}th byte and
     *                  <tt>{@link #count()} - 1</tt> will make the last
     *                  byte of the region transferred.
     */
    long transferTo(WritableByteChannel target, long position) throws IOException;


客户端发起 sendfile() 后,进行上下文切换到内核状态。内核向磁盘发起 ask for data,使用的是操作系统的 transferTo 方法调用触发 DMA 引擎将文件上下文信息拷贝到内核缓冲区中。与上面的不同是,此时,不会再将数据发送给用户缓冲区(减少复制和上下文切换)。而是 write data to target socket buffer(将数据从内核缓冲区拷贝到目标 socket(套接字) 相关联的缓冲区)

相比于传统 IO ,使用 NIO 零拷贝后。我们将上下文切换次数从4次减少到了2次。将数据拷贝次数从4次减少到3次(其中一次涉及了 CPU,另外2次是 DMA 直接存取)

    @Override
    public long transferTo(WritableByteChannel target, long position) throws IOException {
    
    
        long count = this.count - position;
        if (count < 0 || position < 0) {
    
    
            throw new IllegalArgumentException(
                    "position out of range: " + position +
                    " (expected: 0 - " + (this.count - 1) + ')');
        }
        if (count == 0) {
    
    
            return 0L;
        }
        if (refCnt() == 0) {
    
    
            throw new IllegalReferenceCountException(0);
        }
        // Call open to make sure fc is initialized. This is a no-oop if we called it before.
        open();

        long written = file.transferTo(this.position + position, count, target);
        if (written > 0) {
    
    
            transferred += written;
        } else if (written == 0) {
    
    
            // If the amount of written data is 0 we need to check if the requested count is bigger then the
            // actual file itself as it may have been truncated on disk.
            //
            // See https://github.com/netty/netty/issues/8868
            validate(this, position);
        }
        return written;
    }

4.3 内存池

随着 JVM 虚拟机和 JIT 即时编译技术的发展,对象的分配和回收是个非常轻量级的工作。但是对于缓冲区 Buffer,情况却稍有不同,特别是对于堆外直接内存的分配和回收,是一件耗时的操作。为了尽量重用缓冲区,Netty 提供了基于内存池的缓冲区重用机制。下面我们一起看下 Netty ByteBuf 的实现:
在这里插入图片描述

Netty 内存池的内存分配:

    @Override
    public ByteBuf directBuffer(int initialCapacity, int maxCapacity) {
    
    
        if (initialCapacity == 0 && maxCapacity == 0) {
    
    
            return emptyBuf;
        }
        validate(initialCapacity, maxCapacity);
        return newDirectBuffer(initialCapacity, maxCapacity);
    }

继续看 newDirectBuffer 方法,我们发现它是一个抽象方法,由 AbstractByteBufAllocator 的子类负责具体实现,代码如下:
在这里插入图片描述

代码跳转到 PooledByteBufAllocator 的 newDirectBuffer 方法,从 Cache 中获取内存区域 PoolArena,调用它的 allocate 方法进行内存分配,PooledByteBufAllocator 的内存分配过程如下代码所示:

    @Override
    protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
    
    
        PoolThreadCache cache = threadCache.get();
        PoolArena<ByteBuffer> directArena = cache.directArena;

        final ByteBuf 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(buf);
    }

PoolArena 的 allocate 缓冲区分配方法如下:

    PooledByteBuf<T> allocate(PoolThreadCache cache, int reqCapacity, int maxCapacity) {
    
    
        PooledByteBuf<T> buf = newByteBuf(maxCapacity);
        allocate(cache, buf, reqCapacity);
        return buf;
    }

我们重点分析 newByteBuf 的实现,它同样是个抽象方法,由子类 DirectArena 和 HeapArena 来实现不同类型的缓冲区分配,PoolArena 的 newByteBuf 抽象方法如下图所示
在这里插入图片描述

因此重点分析 DirectArena 的实现:
在这里插入图片描述

如果没有开启使用 sun 的 unsafe,执行 PooledDirectByteBuf 的 newInstance 方法,代码如下:
在这里插入图片描述

通过 RECYCLER 的 get 方法循环使用 ByteBuf 对象,如果是非内存池实现,则直接创建一个新的 ByteBuf 对象。从缓冲池中获取 ByteBuf 之后,调用 AbstractReferenceCountedByteBuf 的 setRefCnt 方法设置引用计数器,用于对象的引用计数和内存回收(类似 JVM 垃圾回收机制)。

4.4 高效的 Reactor 线程模型

Reactor是反应堆的意思,Reactor模型,是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。 服务端程序处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reactor模式也叫Dispatcher模式,即I/O多了复用统一监听事件,收到事件后分发(Dispatch给某进程),是编写高性能网络服务器的必备技术之一。

Reactor模型中有2个关键组成:

  • Reactor
    Reactor在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对IO事件做出反应。 它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人
  • Handlers
    处理程序执行I/O事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际官员。Reactor通过调度适当的处理程序来响应I/O事件,处理程序执行非阻塞操作

常用的 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 多线程模型

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 线程模型的支持提供了灵活的定制能力,所以可以满足不同业务场景的性能诉求。

4.5 无锁化的串行设计理念

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

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

Netty 的串行化设计工作原理图如下:
在这里插入图片描述

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

4.6 高效的并发编程

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

  1. volatile 的大量、正确使用 ;

  2. CAS 和原子类的广泛使用;

  3. 线程安全容器的使用;

  4. 通过读写锁提升并发性能。

以ChannelOutboundBuffer 的代码为例,看看如何对发送的总字节数进行计数和更新操作,先看定义:

    private static final AtomicLongFieldUpdater<ChannelOutboundBuffer> TOTAL_PENDING_SIZE_UPDATER =
            AtomicLongFieldUpdater.newUpdater(ChannelOutboundBuffer.class, "totalPendingSize");

    @SuppressWarnings("UnusedDeclaration")
    private volatile long totalPendingSize;

首先定义了一个 volatile 的变量,它可以保证某个线程对于 totalPendingSize 的修改可以被其它线程立即访问到,但是,它无法保证多线程并发修改的安全性。紧 接 着 又 定 义 了 一 个AtomicIntegerFieldUpdater 类 型 的 变 量 WTOTAL_PENDING_SIZE_UPDATER,实现totalPendingSize 的原子更新,也就是保证 totalPendingSize 的 多 线 程 修 改 并 发 安 全 性,重 点 看 下 AtomicIntegerFieldUpdater 的 API 说明:

/**
 * (Transport implementors only) an internal data structure used by {@link AbstractChannel} to store its pending
 * outbound write requests.
 * <p>
 * All methods must be called by a transport implementation from an I/O thread, except the following ones:
 * <ul>
 * <li>{@link #size()} and {@link #isEmpty()}</li>
 * <li>{@link #isWritable()}</li>
 * <li>{@link #getUserDefinedWritability(int)} and {@link #setUserDefinedWritability(int, boolean)}</li>
 * </ul>
 * </p>
 */
public final class ChannelOutboundBuffer {
    
    }

从 API 的说明我们可以看出来,它主要用于实现 volatile 修饰的 int 变量的原子更新操作,对于使用者,必须通过类似 compareAndSet 或者 set 或者与这些操作等价的原子操作来保证更新的原子性,否则会导致问题。

当执行 write 操作外发消息的时候,需要对外发的消息字节数进行统计汇总,由于调用 write 操作的既可以是 IO 线程,也可以是业务的线程,也可能由业务线程池多个工作线程同时执行发送任务,因此,统计操作是多线程并发的,这也就是为什么要将计数器定义成 volatile 并使用原子更新类进行原子操作,下面,我们看下计数的代码:

long oldvalue = totalPendingSize:
long newWriteBufferSize = oldvalue + size:
while (!TOMAL PENDING SIZE UPDATER.compareAndSet(this, oldValue, newuritebufferSize)) (oldvalue = totalPendingSize:
newlriteBuffersize = oldvalue + size:

首先,我们发现计数操作并没有实现锁,而是使用了 CAS 自旋操作,通过
TOTAL_PENDING_SIZE_UPDATER.compareAndSet(this, oldValue,newWriteBufferSize)来判断本次原子操作是否成功,如果成功则退出循环,代码继续执行;如果失败,说明在本次操作的过程中计数器已经被其它线程更新成功,我们需要进入循环,首先,对 oldValue 进行更新,代码如下:

oldValue = totalPendingSize;

然后重新对更新值进行计算:

newWriteBufferSize = oldValue + size;

继续循环进行 CAS 操作,直到成功。它跟 AtomicInteger 的 compareAndSet操作类似。
使用 JAVA 自带的 Atomic 原子类,可以避免同步锁带来的并发访问性能降低的问题,减少犯错的机会,因此,Netty 中对于 int、long、boolean 等大量使用其原子类,减少了锁的应用,降低了频繁使用同步锁带来的性能下降。

读写锁的应用

JDK1.5 新的并发编程工具包中新增了读写锁,它是个轻量级、细粒度的锁,合理的使用读写锁,相比于传统的同步锁,可以提升并发访问的性能和吞吐量,在读多写少的场景下,使用同步锁比同步块性能高一大截。尽管 JDK1.6 之后,随着 JVM 团队对 JIT 即使编译器的不断优化,同步块和读写锁的性能差距缩小了很多;但是,读写锁的应用依然非常广泛,例如,JDK的线程安全 List CopyOnWriteArrayList 就是基于读写锁实现的,代码如下:

class  CopyOnWriteArrayList<E> implements List<E>, RandomAccess,Cloneable, Serializable{
    
    
    private  static final long serialVersionUID=8673264195297992595L;
    transient final ReentrantLock lock=new ReentrantLock();

    private volatile transient Object[]array;

}

Netty中也大量使用了读写锁,下面以Netty处理定时任务的过程为例,分析Netty中读写锁的应用

当新增一个定时任务的时候使用了读锁,用于感知 wheel 的变化,由于读锁是共享锁,所以当有多个线程同时调用 newTimeout 的时候,并不会互斥,这样,就提升了并发读的性能。

获取并删除所有过期的任务时,由于要从迭代器中删除任务,所以使用了写锁:

4.7 高性能的序列化框架

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

  1. 序列化后的码流大小(网络带宽的占用);

  2. 序列化 & 反序列化的性能(CPU 资源占用);

  3. 是否支持跨语言(异构系统的对接和开发语言切换)。

Netty 默认提供了对 Google Protobuf 的支持,通过扩展 Netty 的编解码接口,用户可以实现其它的高性能序列化框架,例如 Thrift 的压缩二进制编解码框架。
在这里插入图片描述

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

4.8 灵活的 TCP 参数配置能力

Netty 在启动辅助类中可以灵活的配置 TCP 参数,满足不同的用户场景。相关配置接口定义如下:

猜你喜欢

转载自blog.csdn.net/zhiyikeji/article/details/131131367