Netty进阶之路——跟着案例学Netty (李林锋)
● 您在位置 #1705-2012的标注 | 添加于 2019年9月3日星期二 下午12:52:20
第11章 IoT百万长连接性能调优案例
随着车联网、智能家居、智慧城市等业务的发展,IoT 进入了飞速发展期。由于要接入海量的硬件设备和传感器,且协议多样化,同时还要在极短的时间内处理大量的数据,所以对服务端的协议接入和处理能力要求极高。 由于 Netty 内置了多种协议栈(TCP、HTTP、MQTT 等),同时利用它提供的编解码框架可以快速地完成私有协议的接入,因此 Netty在 IoT领域得到了较广泛的应用。基于Netty 构建的物联网服务端,实现更多设备的接入和数据收发,提升性能,是一个巨大的挑战。
11.1 海量长连接接入面临的挑战
当客户端的并发连接数达到数十万或者数百万时,系统一个较小的抖动就会导致很严重的后果,例如服务端的 GC,导致应用暂停(STW)的 GC 持续几秒,就会导致海量的客户端设备掉线或者消息积压,一旦系统恢复,会有海量的设备接入或者海量的数据发送,很可能瞬间就把服务端冲垮。
11.1.1 IoT设备接入特点
IOT设备接入有如下几个特点(以车联网为例)。
(1)使用的网络主要是运营商的无线移动网络,网络质量不稳定,例如在一些偏远地区、丘陵地带等信号很差,网络容易闪断。
(2)海量的端侧设备接入,而且通常使用长连接,服务端的压力很大。 (3)不稳定,消息丢失、重复发送、延迟送达、过期发送时有发生。
(4)协议不统一,有各种私有协议,开发和测试成本较高。
11.1.2 IoT服务端性能优化场景
服务端的性能优化主要有如下几类场景。 (1)降成本:对于很多初创型公司,资金和资源相对比较紧张,希望服务端的单节点性能足够高,以降低硬件和运维成本。
(2)云服务提供商:对于提供IoT解决方案的云服务提供商,由于部署规模比较大(假如有上千个服务节点),单节点的性能提升效益会被放大,带来的经济收益很高。
(3)技术竞争力:在很多场景下,单个服务节点的处理性能仍然是最重要的技术指标之一,高性能对提升产品竞争力非常重要。
11.1.3 服务端面临的性能挑战
尽管都是采用Netty构建的,但是不同的IoT解决方案性能却差异很大,主要原因如下。
(1)对协议接入相关的操作系统参数没有进行针对性的调优。
(2)对Netty海量TCP接入的性能参数不熟悉,没有做优化。
(3)对JVM的垃圾收集器没有做调优,不合理的内存设置或者GC参数导致GC频率和应用中断频发,影响系统的稳定性。 要想实现海量设备的接入,需要对操作系统相关参数、Netty 框架、JVM GC 参数,甚至业务代码做针对性的优化,各种优化要素互相影响,设置或者组合不当就容易导致性能问题,这也是服务端实现海量设备接入的最大挑战。
11.2 智能家居内存泄漏问题
智能家居 MQTT 消息服务中间件,保持 10 万用户在线长连接,2万用户并发消息请求。在程序运行一段时间后,发现内存泄漏,怀疑是Netty的Bug。相关信息如下。 (1)硬件资源:MQTT消息服务中间件服务器内存16GB,CPU 8核。
(2)Netty 中 boss 线程池大小为
1,worker 线程池大小为 8,其余线程分配给业务使用。该分配方式后来调整为worker线程池大小为16,问题依旧。 11.2.1 服务端内存泄漏原因定位 首先需要Dump内存堆栈,对疑似内存泄漏的对象和引用关系进行分析,如图11-1所示。 图11-1 Dump内存堆栈 我们发现Netty的ScheduledFutureTask增加了9076%,达到110万个实例,通过对业务代码的分析发现,用户使用 IdleStateHandler 在链路空闲时进行业务逻辑处理,但是空闲时间设置得比较长,为 15 分钟。Netty 的 IdleStateHandler 会根据用户的使用场景,启动 三 类 定 时 任 务,分 别 是 ReaderIdleTimeoutTask、WriterIdleTimeoutTask 和AllIdleTimeoutTask,它们都会被加入NioEventLoop的任务队列调度和执行。 由于超时时间过长,10万个长连接会创建10万个ScheduledFutureTask对象,每个对象还保存了业务的成员变量,非常消耗内存。用户的老年代设置得比较大,一些定时任务被晋升到老年代,没有被新生代GC回收,导致内存一直增长,用户误认为存在内存泄漏。 事实上,通过进一步分析发现,用户的超时时间设置得非常不合理,15分钟达不到快速检测设备是否掉线的设计目标,将超时时间设置为 45 秒后,内存可以正常回收,问题解决。 11.2.2 问题背后的一些思考 如果是100个长连接,即便是长周期的定时任务,也不存在内存泄漏问题,在新生代中通过minor GC就可以实现内存回收。正是因为10万数量级的长连接,导致小问题被放大,引发了后续的各种问题。 如果用户确实有长周期的定时任务,该如何处理?对于海量长连接的接入场景,代码处理稍有不慎,就满盘皆输,下一节我们针对 Netty的架构特点,讲解如何对海量设备接入做性能调优。 11.3 操作系统参数调优 要实现百万级的长连接接入,首先需要对服务端的操作系统参数进行性能调优,如果保持出厂的默认配置,性能是无法满足业务需求的。 11.3.1 文件描述符 首先查看系统最大文件句柄数,执行命令#cat/proc/sys/fs/file-max,查看最大句柄数是否满足需要,如果不满足,通过#vim/etc/sysctl.conf命令插入如下配置: 配置完成后,执行#sysctl-p命令,让配置修改立即生效。 设置完系统最大文件句柄数,对单进程打开的最大句柄数进行设置。通过 ulimit-a命令查看当前设置的值是否满足要求: 当并发接入的TCP连接数超过上限时,就会提示“too many open files”,所有新的客户端接入将失败。通过#vi/etc/security/limits.conf命令添加如下配置参数: 修改之后保存,注销当前用户,重新登录,通过ulimit-a命令查看修改是否生效。 11.3.2 TCP/IP相关参数 需要重点调优的TCP/IP参数如下。 (1)net.ipv4.tcp_rmem:为每个TCP连接分配的读缓冲区内存大小。第一个值是socket接收缓冲区分配的最小字节数。第二个值是默认值,缓冲区在系统负载不高的情况下可以增长到该值。第三个值是接收缓冲区分配的最大字节数。 (2)net.ipv4.tcp_wmem:为每个TCP连接分配的写缓冲区内存大小。第一个值是socket发送缓冲区分配的最小字节数。第二个值是默认值,缓冲区在系统负载不高的情况下可以增长到该值。第三个值是发送缓冲区分配的最大字节数。 (3)net.ipv4.tcp_mem:内核分配给TCP连接的内存,单位是page(1个page通常为4096字节,可以通过#getconf PAGESIZE命令查看),包括最小、默认和最大三个配置项。 (4)net.ipv4.tcp_keepalive_time:最近一次数据包发送与第一次keep alive探测消息发送的时间间隔,用于确认TCP连接是否有效。 (5)tcp_keepalive_intvl:在未获得探测消息响应时,发送探测消息的时间间隔。 (6)tcp_keepalive_probes:判断TCP连接失效连续发送的探测消息个数,达到之后判定连接失效。 (7)net.ipv4.tcp_tw_reuse:是否允许将TIME_WAIT Socket重新用于新的TCP连接,默认为0,表示关闭。 (8)net.ipv4.tcp_tw_recycle:是否开启TCP连接中TIME_WAIT Socket的快速回收功能,默认为0,表示关闭。 (9)net.ipv4.tcp_fin_timeout:套接字自身关闭时保持在FIN_WAIT_2状态的时间,默认为60。 通过#vi/etc/sysctl.conf命令对上述网络参数进行优化,具体修改如下(大约可以接入50万个连接,可以根据业务需要调整参数): 修改完成后,通过执行#sysctl-p命令使配置立即生效。 11.3.3 多网卡队列和软中断 随着网络带宽的不断提升,单核 CPU 不能完全满足网卡的需求,通过多队列网卡驱动的支持,将各个队列通过中断绑定到不同的 CPU 内核,以满足对网络吞吐量要求比较高的业务场景的需要。 多队列网卡需要网卡硬件支持,首先判断当前系统是否支持多队列网卡,通过命令“lspci-vvv”或者“ethtool-l 网卡interface名”查看网卡驱动型号,根据网卡驱动官方说明确认当前系统是否支持多队列网卡(是否支持多队列网卡与网卡硬件、操作系统版本等有关)。有些网卡驱动默认开启了多队列网卡,有些则没有,由于不同的网卡驱动、云服务商提供的开启命令不同,因此需要根据实际情况处理,此处不再详细列举开启方式。 对于不支持多队列网卡的系统,如果内核版本支持RPS(kernel 2.6.35及以上版本),开启RPS后可以实现软中断,提升网络的吞吐量。RPS根据数据包的源地址、目的地址及目的和源端口,算出一个hash值,然后根据这个hash值选择软中断运行的CPU,从上层来看,也就是说将每个连接和CPU绑定,并通过这个hash值,在多个CPU上均衡软中断,提升网络并行处理性能,它实际提供了一种通过软件模拟多队列网卡的功能。
11.4 Netty性能调优
11.4.1 设置合理的线程数
对于线程池的调优,主要集中在用于接收海量设备 TCP 连接、TLS 握手的 Acceptor线程池(Netty 通常叫 boss NioEventLoopGroup)上,以及用于处理网络数据读写、心跳发送的I/O工作线程池(Netty通常叫work NioEventLoopGroup)上。 对于Netty服务端,通常只需要启动一个监听端口用于端侧设备接入即可,但是如果服务端集群实例比较少,甚至是单机(或者双机冷备)部署,在端侧设备在短时间内大量接入时,需要对服务端的监听方式和线程模型做优化,以满足短时间内(例如 30s)百万级的端侧设备接入的需要。 服务端可以监听多个端口,利用主从Reactor线程模型做接入优化,前端通过SLB做4 层/7 层负载均衡。主从 Reactor 线程模型特点如下:服务端用于接收客户端连接的不再是一个单独的NIO线程,而是一个独立的NIO线程池;Acceptor接收到客户端TCP连接请求并处理后(可能包含接入认证等),将新创建的SocketChannel注册到I/O线程池(sub Reactor线程池)的某个I/O线程,由它负责SocketChannel的读写和编解码工作;Acceptor线程池仅用于客户端的登录、握手和安全认证等,一旦链路建立成功,就将链路注册到后端sub Reactor线程池的I/O线程,由I/O线程负责后续的I/O操作。IoT服务端主从线程池模型如图11-2所示。 图11-2 IoT服务端主从线程池模型 由于同时监听了多个端口,每个 ServerSocketChannel 都对应一个独立的 Acceptor 线程,这样就能并行处理,加速端侧设备的接入速度,减小端侧设备的连接超时失败率,提升服务端单节点的处理性能。 对于I/O工作线程池的优化,可以先采用系统默认值(即 CPU内核数×2)进行性能测试,在性能测试过程中采集I/O线程的CPU占用大小,看是否存在瓶颈,具体策略如下。
(1)通过执行 ps-ef|grep java 找到服务端进程pid。
(2)执行top-Hp pid 查询该进程下所有线程的运行情况,通过“shift+p”对CPU占用大小做排序,获取线程的pid及对应的CPU占用大小。
(3)使用printf‘%x\n’ pid将pid转换成16进制格式。
(4)通过jstack-f pid命令获取线程堆栈,或者通过jvisualvm工具打印线程堆栈,找到I/O work工作线程,查看它们的CPU占用大小及线程堆栈,如图11-3所示。
图11-3 I/O work工作线程堆栈 如果连续采集几次进行对比,发现线程堆栈都停留在 SelectorImpl.lockAndDoSelect处,则说明I/O 线程比较空闲,无须对工作线程数做调整。 如果发现I/O线程的热点停留在读或者写操作,或者停留在ChannelHandler的执行处,则可以通过适当调大NioEventLoop线程的个数来提升网络的读写性能。调整方式有两种。
(1)接口API指定:在创建NioEventLoopGroup实例时指定线程数。
(2)系统参数指定:通过-Dio.netty.eventLoopThreads来指定NioEventLoopGroup线程池(本质是聚合了多个单线程线程池的线程组)的线程数,这种方法有个弊端,它是系统配置,即一旦设置了该参数,所有创建NioEventLoopGroup未指定线程数的地方都使用该配置,而不是默认的“CPU内核数×2”,使用时需要注意。
11.4.2 心跳优化
针对海量设备接入的IoT服务端,心跳优化策略如下。
(1)要能够及时检测失效的连接,并将其剔除,防止无效的连接句柄积压,导致OOM等问题。
(2)设置合理的心跳周期,防止心跳定时任务积压,造成频繁的老年代 GC(新生代和老年代都有导致STW的GC,不过耗时差异较大),导致应用暂停。
(3)使用 Netty提供的链路空闲检测机制,不要自己创建定时任务线程池,加重系统的负担,以及增加潜在的并发安全问题。 当设备突然掉电、连接被防火墙挡住、长时间 GC 或者通信线程发生非预期异常时,会导致链路不可用且不易被及时发现。特别是如果异常发生在凌晨业务低谷期间,当早晨业务高峰期到来时,由于链路不可用会导致瞬间大批量业务失败或者超时,这将对系统的可靠性产生重大的威胁。 从技术层面看,要解决链路的可靠性问题,必须周期性地对链路进行有效性检测。目前最流行和通用的做法就是心跳检测。心跳检测机制分为三个层面。
(1)TCP层的心跳检测,即TCP的Keep-Alive机制,它的作用域是整个TCP协议栈。
(2)协议层的心跳检测,主要存在于长连接协议中,例如MQTT。
(3)应用层的心跳检测,它主要由各业务产品通过约定方式定时给对方发送心跳消息实现。 心跳检测的目的就是确认当前链路是否可用,对方是否活着并且能够正常接收和发送消息。作为高可靠的 NIO框架,Netty也提供了心跳检测机制,心跳检测机制的工作原理如图11-4所示。 不同协议的心跳检测机制存在差异,归纳起来主要分为两类。
(1)Ping-Pong型心跳:由通信一方定时发送Ping消息,对方接到Ping消息立即返回Pong应答消息给对方,属于“请求-响应型”心跳。
(2)Ping-Ping型心跳:不区分心跳请求和应答,由通信双方按照约定定时向对方发送心跳Ping消息,它属于双向心跳。
图11-4 心跳检测机制的工作原理 心跳检测策略如下。
(1)连续N次心跳检测都没有收到对方的Pong应答消息或者Ping请求消息,则认为链路已经发生逻辑失效,这被称为心跳超时。
(2)在读取和发送心跳消息的时候如果直接发生了I/O异常,说明链路已经失效,这被称为心跳失败。无论发生心跳超时还是心跳失败,都需要关闭链路,由客户端发起重连操作,保证链路能够恢复正常。 Netty提供了三种链路空闲检测机制,利用该机制可以轻松地实现心跳检测。
(1)读空闲,链路持续时间T没有读取到任何消息。
(2)写空闲,链路持续时间T没有发送任何消息。
(3)读写空闲,链路持续时间T没有接收或者发送任何消息。 链路空闲事件被触发后并没有关闭链路,而是触发 IdleStateEvent 事件,用户订阅IdleStateEvent事件,用于自定义逻辑处理,例如关闭链路、客户端发起重新连接、告警和打印日志等。 利用Netty提供的链路空闲检测机制,可以非常灵活地实现协议层的心跳检测。如果选择双向心跳,在初始化Channel时将Netty的IdleStateHandler实例添加到ChannelPipeline中,然后监听 READER_IDLE 事件,一旦 READER_IDLE 事件发生,说明周期 T 内没有读取到设备的消息,触发服务端主动发送心跳,检测链路是否存活,如果发生I/O异常说明链路已经失效,则主动关闭链路;如果发送成功,则等待最终的心跳超时,即在连续N个周期T内都没有接收到端侧设备发送的业务数据或者心跳消息,则说明端侧设备已经发生故障,服务端主动关闭连接,释放资源。采用双向心跳检测的主要优点有两个。
(1)可以及时识别网络单通、对方突然掉电等特殊异常。
(2)可以识别对方是否能够正常工作,而不仅是网络层面的互通性检测。 除了 IdleStateHandler,也可以根据实际需要选择 ReadTimeoutHandler 或者WriteTimeoutHandler,链路空闲检测相关类库如图11-5所示。
图11-5 链路空闲检测相关类库 对于IoT场景,不建议过长的心跳检测周期和超时机制,主要有如下几点考虑。 (1)百万级的长连接就有百万级的定时器,这么庞大的定时器会占用大量内存,如果长时间存活,会被“晋升”到老年代,加重 CMS 等老年代垃圾收集器的负担,容易导致STW问题。
(2)过长的心跳检测超时不能及时发现掉线的设备(例如突然掉电),导致大量无效的TCP连接在内存中积压,同时占用操作系统句柄,影响性能,也容易导致OOM异常。 在创建IdleStateHandler实例时,可以指定空闲检测时间,代码如下(IdleStateHandler类): 11.4.3 接收和发送缓冲区调优 在一些场景下,端侧设备会周期性地上报数据和发送心跳,单个链路的消息收发量并不大,针对此类场景,可以通过调小 TCP的接收和发送缓冲区来降低单个 TCP 连接的资源占用率,例如将收发缓冲区设置为8KB,相关代码如下: 需要指出的是,对于不同的应用场景,收发缓冲区的最优值可能不同,用户需要根据实际场景,结合性能测试数据进行针对性的调优。 11.4.4 合理使用内存池 随着 JVM 虚拟机和 JIT 即时编译技术的发展,对象的分配和回收是一个非常轻量级的工作。但是对于缓冲区Buffer,情况却稍有不同,特别是堆外直接内存的分配和回收,是一个耗时的操作。为了尽量重用缓冲区,Netty提供了基于内存池的缓冲区重用机制。 在物联网场景下,需要为每个接入的端侧设备至少分配一个接收和发送缓冲区对象,采用传统的非池模式,每次消息读写都需要创建和释放ByteBuf对象,如果有100万个连接,每秒上报一次数据或者心跳,就会有100万次/秒的ByteBuf对象申请和释放,即便服务端的内存可以满足要求,GC的压力也会非常大。 以上问题最有效的解决方法就是使用内存池,每个NioEventLoop线程处理N个链路,在线程内部,链路的处理是串行的。假如A链路首先被处理,它会创建接收缓冲区等对象,待解码完成,构造的POJO对象被封装成任务后投递到后台的线程池中执行,然后接收缓冲区会被释放,每条消息的接收和处理都会重复接收缓冲区的创建和释放。如果使用内存池,则当A链路接收到新的数据报时,从NioEventLoop的内存池中申请空闲的ByteBuf,解码后调用release将ByteBuf释放到内存池中,供后续的B链路使用。 Netty 内存池从实现上可以分为两类:堆外直接内存和堆内存。由于 ByteBuf 主要用于网络I/O读写,因此采用堆外直接内存会减少一次从用户堆内存到内核态的字节数组拷贝,所以性能更高。由于DirectByteBuf的创建成本比较高,因此如果使用DirectByteBuf,则需要配合内存池使用,否则性价比可能还不如HeapByteBuf。 Netty 默认的 I/O 读写操作采用的都是内存池的堆外直接内存模式,如果用户需要额外使用 ByteBuf,建议也采用内存池方式;如果不涉及网络 I/O 操作(只是纯粹的内存操作),可以使用堆内存池,这样内存的创建效率会更高一些。 由于堆外直接内存定位内存泄漏等问题不太方便,有时候需要在测试环境将内存分配策略调整为堆内存模式,待问题解决后再切换到堆外直接内存。假如将消息读取 ByteBuf设置为非内存池、堆内存模式,代码示例如下: 设置 Dio.netty.noUnsafe 属性为 true,使用 Heap 堆内存方式创建 ByteBuf,启动参数设置如图11-6所示。 图11-6 启动参数设置 修改后,在服务端消息接收处设置断点,发现ByteBuf为UnpooledHeapByteBuf,调测结果如图11-7所示。 图11-7 修改后测试结果 11.4.5 防止I/O线程被意外阻塞 通常情况下,大家都知道不能在Netty的I/O线程上做执行时间不可控的操作,例如访问数据库、调用第三方服务等。但是有些隐形的阻塞操作却容易被忽略,例如打印日志。 在生产环境中,通常需要实时打印接口日志,其他日志处于ERROR级别,当服务发生 I/O 异常时,会记录异常日志。如果当前磁盘的 WIO 比较高,写日志文件操作可能会被同步阻塞(阻塞时间无法预测)。这就会导致Netty的NioEventLoop线程被阻塞,Socket链路无法被及时关闭,其他的链路也无法进行读写操作。 以最常用的log4j(1.2.X版本)为例,尽管它支持异步写日志(AsyncAppender),但是当日志队列满时,它会同步阻塞业务线程(采用等待非丢弃方式时),直到日志队列有空闲位置可用,相关代码如下: 类似问题具有极强的隐蔽性,往往 WIO 高的时间持续非常短,或者是偶现的,在测试环境中很难模拟此类故障,问题定位难度非常大。一旦在生产环境中出现问题,在测试环境中又无法重现,就会比较被动。 11.4.6 I/O线程和业务线程分离 如果服务端不做复杂的业务逻辑操作,仅是简单的内存操作和消息转发,则可以通过调大NioEventLoop工作线程池的方式,直接在I/O线程中执行业务ChannelHandler,这样便减少了一次线程上下文切换,性能反而更高。 如果有复杂的业务逻辑操作,则建议I/O线程和业务线程分离,对于I/O线程,由于互相之间不存在锁竞争,可以创建一个大的 NioEventLoopGroup 线程组,所有 Channel 都共享同一个线程池。对于后端的业务线程池,则建议创建多个小的业务线程池,线程池可以与 I/O线程绑定,这样既减少了锁竞争,又提升了后端的处理性能。I/O线程和业务线程分离原理如图11-8所示。 图11-8 I/O线程和业务线程分离原理
11.4.7 针对端侧并发连接数的流控
无论服务端的性能优化到多少,都需要考虑流控功能。当资源成为瓶颈,或者遇到端侧设备的大量接入,需要通过流控对系统做保护。流控的策略有很多种,IoT 场景最重要的就是针对端侧连接数的流控。 在 Netty中,可以非常方便地实现流控功能:新增一个 FlowControlChannelHandler,添加到ChannelPipeline靠前的位置,继承channelActive()方法,创建TCP链路后,执行流控逻辑,如果达到流控阈值,则拒绝该连接,调用ChannelHandlerContext的close()方法关闭连接。 TLS/SSL的连接数的流控相对复杂一些,可以在TLS/SSL握手成功后,监听握手成功的事件,执行流控逻辑。握手成功后发送SslHandshakeCompletionEvent事件,代码示例如下(SslHandler类): FlowControlChannelHandler继承userEventTriggered()方法,拦截TLS/SSL握手成功事件,执行流控逻辑,示例代码如下: 11.5 JVM相关性能优化 JVM层面的调优主要涉及GC参数优化,GC参数设置不当会导致频繁GC,甚至OOM异常,对服务端的稳定运行产生重大影响。
11.5.1 GC调优
1.确定GC优化目标 GC(垃圾收集)有三个主要指标。 (1)吞吐量:是评价 GC能力的重要指标,在不考虑 GC引起的停顿时间或内存消耗时,吞吐量是GC能支撑应用程序达到的最高性能指标。 (2)延迟:GC 能力的最重要指标之一,是由于 GC 引起的停顿时间,优化目标是缩短延迟时间或完全消除停顿(STW),避免应用程序在运行过程中发生抖动。 (3)内存占用:GC正常时占用的内存量。 JVM GC调优的三个基本原则如下。 (1)Minor GC 回收原则:每次新生代 GC 回收尽可能多的内存,减少应用程序发生Full GC的频率。 (2)GC 内存最大化原则:垃圾收集器能够使用的内存越大,垃圾收集效率越高,应用程序运行也越流畅。但是过大的内存一次Full GC耗时可能较长,如果能够有效避免Full GC,就需要做精细化调优。 (3)3选2原则:吞吐量、延迟和内存占用不能兼得,无法同时做到吞吐量和暂停时间都最优,需要根据业务场景做选择。对于大多数 IoT 应用,吞吐量优先,其次是延迟。当然对于时延敏感型的业务,需要调整次序。 2.确定服务端内存占用 在优化GC之前,需要确定应用程序的内存占用大小,以便为应用程序设置合适的内存,提升GC效率。内存占用与活跃数据有关,活跃数据指的是应用程序稳定运行时长时间存活的Java对象。活跃数据的计算方式:通过GC日志采集GC数据,获取应用程序稳定时老年代占用的 Java 堆大小,以及永久代(元数据区)占用的 Java 堆大小,两者之和就是活跃数据的内存占用大小。 3.GC数据的采集 GC数据的采集有两种方式。 (1)通过-XX:+PrintGC、-XX:+PrintGCDetails 和-XX:+PrintGCDateStamps 等参数打印GC日志,通过GC日志采集数据如图11-9所示。 图11-9 通过 GC日志采集数据 (2)通过Visual GC工具监控GC数据,如图11-10所示。 图11-10 通过GCVisual工具监控 GC数据 如果应用程序没有或者很少发生Full GC,则可以通过jvisualvm等工具人工触发Full GC,如图11-11所示。 图11-11 人工触发Full GC 执行强制GC之后,发现GC日志打印了Full GC相关数据,如图11-12所示。 图11-12 Full GC相关数据 4.GC数据解读 通过打印GC日志方式来采集垃圾回收数据,需要人工解读GC相关信息,新生代GC数据解读如图11-13所示。 图11-13 新生代GC数据解读 5.Java堆大小设置原则(如表11-1所示) 表11-1 Java堆大小设置原则 上述经验数据已经可以满足大多数应用场景的需要,如果业务场景比较特殊,可以根据GC统计数据和应用性能测试结果进行相应的优化。 6.垃圾收集器的选择 如果是 JDK8及以上版本,建议选择 G1收集器,如果是较低版本的 JDK,或者业务已经积累了一些优化的GC参数,则可以继续使用“ParNew收集器+CMS收集器”组合,根据测试情况和对应垃圾收集器的特点做相应的调优。 CMS吞吐量调优的主要策略如下。
(1)增加新生代空间,降低新生代GC频率,减少固定时间内新生代GC的次数。
(2)增加老年代空间,降低 CMS 的频率并减少内存碎片,最终减小并发模式失效引起Full GC发生的概率。
(3)调整新生代 Eden和 Survivor 空间的大小比例,减少由新生代晋升到老年代的对象数目,降低CMS GC频率。 G1调优的策略如下。 (1)不要使用-Xmn 选型或者-XX:NewRatio 等其他相关选型显式设置年轻代的大小,这样会覆盖暂停时间指标。 (2)暂停时间不要设置得太小,否则为了达到暂停时间目标会增加垃圾回收的开销,影响吞吐量指标。 (3)防止触发Full GC:在某些情况下,例如并发模式失败,G1会触发Full GC,这时G1会退化使用Serial收集器来完成垃圾清理工作,它仅使用单线程来完成GC,GC暂停时间可能会达到秒级。 7.一些GC调优误区 在实际项目中,针对GC相关的一些认知或者做法存在误区,总结如下: (1)在生产环境中不配置GC日志打印参数,担心影响业务性能。
(2)GC 日志格式选择-XX:+PrintGCTimeStamps,导致 GC 日志很难跟其他业务日志对应起来。
(3)GC日志文件路径设置为静态路径,例如gc.log,没有配置绕接、切换策略,导致重启之后日志被覆盖。
(4)GC日志文件没有配置单个文件大小、绕接和备份机制,导致单个GC文件过大。 (5)只有Full GC才会导致应用暂停,分析STW问题时直接使用Full GC关键字搜索,其他的不看。 (6)给出最优的GC参数或者明确GC优化方向之后,通过一次调整就能解决问题。 (7)业务内存使用不当导致的性能问题,希望通过GC参数优化解决问题,业务不用改代码。 (8)只有GC才会导致应用暂停。 11.5.2 其他优化手段 在实际测试时,往往需要先在测试环境模拟海量端侧设备的接入,为了方便测试,节约机器资源,需要在客户端配置虚拟IP: 配置完成后通过 ifconfig eth0:1 查看 IP 与子网掩码是否正确,如果没有问题,通过ifconfig eth0:1 up启动新增的虚拟IP进行测试。 操作系统对单个IP是有连接数限制的,每个IP对应的端口范围为0~65535,其中0~1023被系统占用,所以连接能够分配的端口从1024开始,考虑到其他进程的端口占用,单个 IP 能够接入的连接数约为 6万个。如果系统支持多网卡,则可以采用多网卡、多 IP的方式解决,否则需要使用虚拟IP的方式解决连接数限制问题。 11.6 总结 除了通过操作系统内核参数、Netty框架和JVM调优来提升单节点处理性能,还可以通过分布式集群的方式提升整个服务端的处理能力,把性能的压力分散到各个节点上。除了可以降低单个节点的风险,也可以利用云平台的弹性伸缩实现服务端的快速扩容,以应对突发的流量洪峰。如果每个节点负担过重,一旦某个节点宕机,流量会瞬间转移到其他节点,导致其他节点超负荷运行,系统的可靠性降低。通过“分布式+弹性伸缩”构建可平滑扩容的IoT服务端,是未来的一种主流模式。