"内存优化",IM长连接网关的必备技能你值得拥有。

avatar
研发 @比心APP

对于Java应用来说,内存的分配是由程序完成的,而内存的释放是通过GC完成的,这种方式简化了程序员的工作,但也增加了JVM的压力。有很多Java程序员过分依赖GC,但是无论JVM的垃圾回收机制做得多好,内存总归是有限的资源,因此就算GC会为我们完成了大部分的垃圾回收,但适当地注意编码过程中的内存优化还是非常有必要的。优化内存的主要目的是为了降低 youngGC 的频率、减少 fullGC 的次数 ,过多的 youngGC 和 fullGC 会占用比较多的系统资源(主要是CPU),影响整个系统的吞吐量。

对于网关这样追求高性能的服务,更是有必要去关注内存方面的优化。这样可以有效的减少GC次数,同时提升内存利用率,最大限度地提高程序的效率。


Netty ChannelHandler

在 Netty 中 每个 Channel 都有自己的 ChannelPipeline,每个 ChannelPipeline里面管理着一系列 ChannelHandler。

当客户端连接到服务器时,Netty 会新建一个 ChannelPipeline 处理其中的事件,而一个ChannelPipeline 中会添加若干个自定义或者Netty提供的 ChannelHandler。如果每来一个客户端连接都去新建一个 ChannelHandler 实例,当有大量连接时,服务器需要保存大量的ChannelHandler 实例。比如,如果建立了十万个连接,就会创建 10w * (添加的 ChannelHandler 的数量)个对象。这将是非常大的内存消耗。

好在 Netty 里面提供了一种方式来解决这个问题, 只要 ChannelHandler 是无状态的(即不需要保存任何状态数据),那么可以将其标注为 @Sharable。这样无论有多少个连接,也只需 new 一个 ChannelHandler 实例,被所有 ChannelPipeline 共享。

所以我们在实现自定义的 ChannelHandler 的时候,最好将其设计成无状态的(有一点需要注意,对于像 ByteToMessageDecoder 之类的编解码器是有状态的,是不能使用 Sharable 注解的)。

而且ChannelPipeline 中添加的那些 ChannelHandler 是以串行方式依次调用的,所以最好也要减少 ChannelHandler 的创建,在 Mercury 里面除了编解码相关的 ChannelHandler ,我只实现了一个无状态的 handler。

@Sharable 原理

在 io.netty.channel.DefaultChannelPipeline#addLast() 添加 ChannelHandler 的方法里面,会有一个 @Sharable 注解使用的检查。如果这个 ChannelHandler 实例已经被添加过了,并且没有标注 @Sharable 注解,就会抛出异常。

从源码中可以看出,Netty 只是做了一个很简单的检查,防止没有@sharable注解的实例被当成单例使用,并没有那么智能。对于 ChannelHandler 实例到底是不是无状态的,它其实是不知道的,这一点需要开发自己确保,否则可能会出现线程不安全的问题。这也是上面提到过的,对于 Netty 里面提供的一些编解码的 ChannelHandler 实例,是绝对不能弄成单例被所有 Channel 共享的。


AtomicXXXFieldUpdater

在一些高并发场景下,很多时候会使用 **AtomicXXX **对象保证线程安全,像常用的 AtomicInteger 或者 AtomicLong,底层都是通过 CAS 实现。

但在很多开源框架中会看到 **AtomicXXXFieldUpdater **的身影,比如 Netty 中就大量使用了,这么做的目的也是为了节约内存。

这里以 AtomicIntegerAtomicIntegerFieldUpdater 为例来说明:

AtomicInteger 成员变量只有一个int value,似乎并没有占用太多内存,但是我们的 AtomicInteger 是一个对象,一个对象的正确计算应该是:

对象头 + 实际数据大小 + 对齐填充

名称(单位byte)32位64位开启指针压缩后(指针对64位有效且默认开启)
对象头(Header)81612
数组对象头122416
引用(reference)484

在64位机器上 AtomicInteger 对象占用内存如下:

关闭指针压缩: 16(对象头)+4(实例数据)=20 不是8的倍数,因此需要对齐填充 16+4+4(padding)=24

开启指针压缩(-XX:+UseCompressedOop): 12+4=16已经是8的倍数了,不需要再padding。

由于我们的AtomicInteger是一个对象,还需要被引用,那么真实的占用为:

关闭指针压缩:24 + 8 = 32

开启指针压缩: 16 + 4 = 20

像在 Netty 中的 AbstractReferenceCountedByteBuf,熟悉 Netty 的同学都知道 Netty 是自己管理内存的,所有的 ByteBuf 都会继承 AbstractReferenceCountedByteBuf,在Netty中ByteBuf会被大量的创建。

如果使用 **AtomicInteger ,**那么在开启指针压缩的情况下需要占用 :

(ByteBuf的数量)* 20 字节

而 **AtomicIntegerFieldUpdater **是配合 **volatile int **来使用的,不管有多少对象都只需要创建一个 **AtomicIntegerFieldUpdater **即可。

**AtomicIntegerFieldUpdater **是16字节,**volatile int **是4字节 ,总的占用的内存是:

(ByteBuf的数量)* 4 字节 + 16字节

这个在少量对象的情况下可能不明显,当我们对象有几十万,几百万,或者几千万的时候,节约的可能就是几十M,几百M,甚至几个G。

Mercury 网关中需要维护大量的 Channel 连接,并且涉及到很多并发场景,我都使用了 AtomicIntegerFieldUpdater 来优化,在之前的压测过程中,效果非常好。


JOL

JOL 的全称是 Java Object Layout。是一个用来分析JVM中Object布局的小工具。包括Object在内存中的占用情况,实例对象的引用情况等等。如果觉得通过上面那样的公式计算java 对象的方式比较繁琐的话,也可以使用这个工具来快速分析。

<dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.10</version>
</dependency>

 System.out.println(ClassLayout.parseClass(AtomicInteger.class).toPrintable());

可以看到打印的对象占用内存信息

在 IDEA 里面有一个 JOL 插件,也可以直接通过安装插件来查看

在对象上右击选择 Show Object Layout,就能看到占用的内存信息