힙 내부와 외부의 메모리 문제를 해결하고 최적화하는 것을 잊지 마십시오.

17309207:



Taobao 대역폭 비용을 최적화하기 위해 게이트웨이 SDK(Java)에서 GZIP 압축 대신 ZSTD를 사용하여 더 높은 압축률과 더 작은 응답 패킷을 얻습니다. 특정 구현에서는 공식적으로 권장되는  zstd-jni 라이브러리를 사용합니다 . zstd-jni는 zstd의 C++ 라이브러리를 호출합니다.


배경

성능 스트레스 테스트 및 최적화 프로세스 중에 다음 세 가지 문제가 발생했습니다.

  1. GC 수는 동일하게 유지되지만 시간 소모는 두 배로 늘어납니다.

  2. 프로세스 메모리 누수 및 극단적인 경우 OOM Killer가 프로세스를 종료할 수 있습니다.

  3. Netty 오프-힙 메모리 누수(문제 1을 최적화할 때 도입됨)


다음으로, 이 세 가지 질문으로 시작하여 문제 해결을 위한 아이디어와 프로세스를 공유하겠습니다.

GC 최적화

[GC 시간이 두 배로 늘어나는 문제] 현상  


우리의 기대에 따르면 ZSTD 압축을 사용하면 대규모 패킷 시나리오(20KB 이상)에서 GZIP보다 더 높은 압축 비율을 달성할 수 있을 뿐만 아니라 동시에 압축 성능도 어느 정도 최적화되어야 합니다 . 구체적인 최적화 수준은 다음에 따라 다릅니다. 비즈니스 특성상 적어도 그렇지는 않지만 성능 저하가 있을 것입니다 .

그러나 실제 성능 스트레스 테스트에서는 동일한 수준의 Netty GZIP과 비교했을 때 ZSTD 압축 시 GC 수는 변하지 않지만 시간 소모는 거의 두 배에 달해 최종 애플리케이션에서는 성능 최적화가 거의 이루어지지 않고 심지어는 RT에 영향을 미칩니다(CMS 하에서).

[GC 시간이 많이 걸리는 배가 문제] 분석  


우리의 ZSTD 압축은 JNI를 통해 구현되었으며, 프로세스는 힙에 있는 데이터를 압축을 위해 힙 외부로 복사한 다음 다시 힙에 복사하는 것입니다.
JNI를 사용하면 GC의 효율성이 어느 정도 영향을 받는다는 것을 알고 있지만 시간이 두 배로 늘어나는 것은 예상을 뛰어넘는 수준입니다. 따라서 우리는 압축의 실행 흐름을 분석하려고 한다.
JDK 22는 JNI 실행 중에 GC를 비활성화할 필요가 없도록 G1에 지역 고정을 구현하여 대기 시간을 줄입니다. 자세한 내용은 JEP 423: Region Pinning for G1 (주소: https://openjdk.org/jeps/423)을 참조하세요.
GZIP과 비교하여 ZSTD는 단일 압축 프로세스 중에 더 많은 메모리를 차지합니다.
  1. 압축된 데이터 가 차지하는 힙 메모리
    a.ZSTD는 원본 데이터와 압축된 데이터를 압축하여 별도로 저장하며 메모리 복사본 2개를 차지합니다. GZIP은 압축 전에 압축된 데이터를 바이트 배열에 다시 쓰며 하나의 메모리만 차지합니다.
    b. 또한 특히 스트리밍 ZSTD 시나리오에서 여러 응답은 동일한 OutputStream을 재사용하여 최적의 압축 비율을 달성하지만 OutputStream의 버퍼는 추가 힙 공간을 차지합니다.
  2. 오프힙 압축에 필요한 메모리는 ZSTD 압축 컨텍스트(사전 저장)를 저장하는 데 사용됩니다 .

그래프 분석에 따르면 GC가 더 오래 걸리는 두 가지 문제가 있을 수 있습니다.
  1. 불필요한 힙 메모리 사용량
  2. 힙 내부 및 외부의 불필요한 데이터 복사

[GC 시간이 두 배로 늘어나는 문제] 해결  



아이디어: 위의 두 가지 문제를 해결하기 위해 압축 후 원본 데이터를 힙 외부에 직접 쓸 수 있기를 바랍니다 . 한편으로는 원본 데이터가 차지한 힙 내부 메모리가 최대한 빨리 해제됩니다. , 반면에 힙 내부 및 외부의 불필요한 복사본은 줄어듭니다.


구현 : zstd-jni에서 제공하는 Off-Heap 압축 인터페이스를 이용하여 압축을 위해 원본 데이터를 힙 외부에 직접 복사하고, Netty를 통해 힙 외부에 직접 씁니다(위 그림의 프로세스는 ZSTD Direct입니다) .


【파이널라이저 문제】현상  


그러나 오프-힙 압축으로 전환한 후 다시 스트레스 테스트를 실시한 결과 GC가 예상만큼 감소하지 않고 오히려 잦아지고 힙 메모리 사용량이 높아지는 것으로 나타났습니다.


그래서 GC 이후 힙 레이아웃을 보기 위해 덤프를 하고 JVM 힙 메모리를 분석한 결과 전체 힙 사용량이 915M/4G로 매우 비정상적이라는 것을 발견했습니다. 우리 테스트 애플리케이션에는 수명이 긴 객체가 없었습니다. 예상했던 대로였습니다. GC 이후 힙 크기는 수십 MB에 불과해야 합니다.



힙에 있는 개체를 추가로 검사한 결과 수많은 새로운 의심스러운 개체가 발견되었습니다.
  1. 종료자
  2. ZstdJNIDirectByteBufCompressor(압축 인스턴스, JNI 호출 항목.)
  3. DefaultInvocation(요청 컨텍스트, 요청 및 응답에 대한 모든 정보를 포함하며 애플리케이션 대형 개체입니다.)

그들의 참조 관계는 다음과 같습니다.
Finalizer -> ZstdJNIDirectByteBufCompressor <-> DefaultInvocation。

그 중에는 ZstdJNIDirectByteBufCompressor와 DefaultInvocation이 무려 1604개에 달해 704M이 넘는 메모리를 차지해 사용된 힙 메모리의 77%를 차지 하지만 이전에는 이런 객체가 존재하지 않았다.



【파이널라이저 문제】분석  


  • 그렇게 많은 Finalizer 개체는 어디에서 왔으며, 이것이 GC 시간 소비 증가와 어떤 관련이 있습니까?


Finalizer 객체가 무엇인지 알려면 먼저 JVM의 finalize() 메서드를 이해해야 합니다.

finalize() 메소드는 Object 클래스에 정의되어 있으며 finalize()를 구현하는 객체의 경우 가비지 수집기가 해당 객체에 참조가 없다고 판단하면 finalize()가 호출됩니다.


저자는 C 및 C++ 언어의 소멸자와 동일하지 않지만 전통적인 C 및 C++ 프로그래머가 Java를 더 쉽게 수용할 수 있도록 Java가 처음 탄생했을 때 만들어진 절충자이므로 사용하지 말 것을 권장합니다. 실행 비용이 많이 들고, 불확실성이 높으며, 각 객체의 호출 순서를 보장할 수 없어 현재는 권장되지 않는 구문으로 공식 선언되었습니다. 일부 교과서에서는 "외부 리소스 닫기"와 같은 정리 작업에 적합하다고 설명하는데, 이는 finalize() 메서드의 목적에 완전히 부합하는 것입니다. finalize()가 수행할 수 있는 모든 작업은 try-finally 또는 다른 메서드를 사용하여 보다 시기적절하게 더 잘 수행할 수 있으므로 저자는 Java 언어에서 이 메서드를 완전히 잊어버릴 것을 권장합니다.

--"JVM에 대한 심층적인 이해"


finalize 방법에 대한 대부분의 학생들의 이해는 위의 단락에서 나올 수 있습니다. 그들은 "실행하는 데 비용이 많이 들고" "권장되지 않음"을 알고 있습니다. 그러면 이것이 우리 응용 프로그램에 어떤 영향을 미칠까요?


  • JVM은 finalize()를 어떻게 실행합니까?


  1. JVM이 클래스를 로드할 때 클래스가 finalize()를 구현하는지 여부를 식별하고, 구현된 경우 해당 클래스를 "finalize class"로 표시합니다.

  2. "finalize Class" 객체를 생성할 때 Finalizer#register()가 호출됩니다. 이 메서드에서는 Finalizer 객체가 생성됩니다. Finalizer 객체는 원본 객체를 참조한 다음 이를 unfinalized라는 전역 대기열에 등록합니다. Finalizer 객체 및 그것이 참조하는 원본 객체는 GC되기 전에 finalize()가 실행될 수 있도록 항상 도달 가능합니다.

  1. 一次 GC时,JVM 判断原始对象除了 Finalizer 对象引用之外没有其他对象引用之后,就把 Finalizer 对象从 “unfinalized” 队列中取出,加入到 “Finalizer queue” 中。

  2. JVM 在启动时,会启动一个“finalize”线程,该线程会一直从“Finalizer queue”中取出对象,然后执行原始对象中的 finalize()。

  1. 在完成步骤 4 后,Finalizer 对象以及其引用的原始对象,再无其他引用,属于不可达对象,再次 GC 的时候他们将会被回收掉。(如果在 finalize() 使该对象重新可达,再次 GC 该对象不会被回收,即 finalize() 方法是对象逃脱死亡 (GC) 命运的最后一次机会)。


  • 使用 finalize() 带来哪些影响?


  1. 创建一个实现 finalize() 的对象时,需要额外创建其 Finalizer 对象并且注册到队列中,因此需要额外的内存空间,且创建时间长于普通对象创建。

  2. 相比普通对象,实现 finalize() 的对象生存周期更长,至少需要两次 GC 才可被回收。

  3. 在 GC 时需要对实现 finalize() 的对象做特殊处理(比如 Finalizer 对象的出队入队操作等), GC 耗时更长。

  4. 因为 finalize 线程优先级比较低,若 CPU 繁忙,可能会导致 “ Finalizer queue” 有积压,在经历多次 YGC 之后原始对象及其 Finalizer 对象就会进入 old 区域,那么这些对象只能等待 FGC 才能被 GC。


总的来说,使用 finalize() 方法本身会加重系统负担、严重影响 GC 并且无法保证 finalize 的调用时机,其应用场景也仅仅是防止资源泄漏,finalize() 能做的所有工作,使用 try-finally 或者其他方式都可以做得更好、 更及时,所以我们还是忘记它的存在吧。

  【Finalizer 问题】解决


最佳实践:

尽可能避免使用 finalize 机制。若实在无法避免,也应尽量避免其引用大对象。


JDK 18 中已经弃用 finalize 机制以在未来版本中删除。详见:Deprecate Finalization for Removal(地址:https://openjdk.org/jeps/421)


在我们的 ZSTD 场景下,由于 zstd-jni 将 finalize() 作为堆外资源的兜底清理手段,因此我们断开其对应用大对象的引用后,耗时翻倍的问题被成功解决。


我们的测试应用单机极限 QPS 较低(300),Finalizer 只要不引用大对象,对 GC 的影响不大;但在更高 QPS 场景下,Finalizer 对 GC 的影响会更加凸显。


我们在另一线上应用使用 ZSTD 压缩,在单机 QPS 1000 时,比起使用 NoFinalizer 的 Zstd Compressor,使用 Finalizer 的 Zstd Compressor GC 耗时涨了近 10 倍。


因此,我们最终决定直接使用 NoFinalizer 的 Zstd Compressor。


Netty ByteBuf 内存泄漏


  现象


为了优化 GC,我们通过 Netty 的 DirectByteBuf 操作堆外内存,直接在堆外压缩并响应。


但在性能压测时,通过 Netty 的内存泄漏检测工具,发现在极限情况下会产生内存泄漏,经过观察,会伴随着以下几种现象:

  1. 施压 QPS 达到单机极限,持续有 FGC 产生;

  2. 客户端超时主动断连,继续往被关闭的 channel 里写入内容失败,会出现连接已关闭的报错;

  3. Netty 堆外内存满;


  分析


Step 1 泄漏堆栈显示泄漏对象为响应内容的 DirectByteBuf

Step 2 通过增加埋点追溯业务代码中可能的泄漏点,发现在写给 netty ChannelOutboundHandler pipeline 之前,是没有泄漏的。

Step 3 排查聚焦在 netty 的 ChannelOutboundHandler pipeline,排查我们自己实现的 ChannelOutboundHandler 内部也并未有泄漏。

Step 4 进一步分析 netty 内存泄漏检测的堆栈,发现泄漏内存的最后访问点有 netty 框架内部代码,所以猜测泄漏可能是框架执行过程中产生。

Step 5 进一步分析 netty 写出响应的代码。


  1. 我们调用 netty 的 AbstractChannel#writeAndFlush(java.lang.Object) 写出内容,会从 pipeline 的最后一个节点执行,最终进入到 next.invokeWriteAndFlush(m, promise)。

  1. invokeHandler() 会检查 handler 的状态(如下图),确认其是否可被执行。若 handler 被认为不可执行,则会直接尝试执行下一个 handler (如 1 中图)。

  1. 尝试追溯 handlerState 的更新。发现当 channel 被 deregister 后(连接关闭), pipeline 所有中间 handler 的状态都会被置为 REMOVE_COMPLETE,即不可执行,这样后续再写入的消息都不会再进入到这些 handler 里了。(泄漏就是从这里开始)

setRemoved:911, AbstractChannelHandlerContext (io.netty.channel)callHandlerRemoved:950, AbstractChannelHandlerContext (io.netty.channel)callHandlerRemoved0:637, DefaultChannelPipeline (io.netty.channel)destroyDown:876, DefaultChannelPipeline (io.netty.channel)destroyUp:844, DefaultChannelPipeline (io.netty.channel)destroy:836, DefaultChannelPipeline (io.netty.channel)access$700:46, DefaultChannelPipeline (io.netty.channel)channelUnregistered:1392, DefaultChannelPipeline$HeadContext (io.netty.channel)invokeChannelUnregistered:198, AbstractChannelHandlerContext (io.netty.channel)invokeChannelUnregistered:184, AbstractChannelHandlerContext (io.netty.channel)fireChannelUnregistered:821, DefaultChannelPipeline (io.netty.channel)run:839, AbstractChannel$AbstractUnsafe$8 (io.netty.channel)safeExecute$$$capture:164, AbstractEventExecutor (io.netty.util.concurrent)safeExecute:-1, AbstractEventExecutor (io.netty.util.concurrent)
- Async stack traceaddTask:-1, SingleThreadEventExecutor (io.netty.util.concurrent)execute:825, SingleThreadEventExecutor (io.netty.util.concurrent)execute:815, SingleThreadEventExecutor (io.netty.util.concurrent)invokeLater:1042, AbstractChannel$AbstractUnsafe (io.netty.channel)deregister:822, AbstractChannel$AbstractUnsafe (io.netty.channel)fireChannelInactiveAndDeregister:782, AbstractChannel$AbstractUnsafe (io.netty.channel)close:765, AbstractChannel$AbstractUnsafe (io.netty.channel)close:620, AbstractChannel$AbstractUnsafe (io.netty.channel)close:1352, DefaultChannelPipeline$HeadContext (io.netty.channel)invokeClose:622, AbstractChannelHandlerContext (io.netty.channel)close:606, AbstractChannelHandlerContext (io.netty.channel)close:472, AbstractChannelHandlerContext (io.netty.channel)close:957, DefaultChannelPipeline (io.netty.channel)close:244, AbstractChannel (io.netty.channel)close:92, DefaultHttpStream (com.alibaba.xxx.xxx.xxx.inbound.http)onRequestReceived:111, DefaultHttpStreamTest$getHttpServerRequestListener$1 (com.alibaba.xxx.xxx.xxx.inbound.http)onHttpRequestReceived:53, HttpServerStreamHandler (com.alibaba.xxx.xxx.xxx.inbound.http.server)channelRead:44, HttpServerStreamHandler (com.alibaba.xxx.xxx.xxx.inbound.http.server)invokeChannelRead:379, AbstractChannelHandlerContext (io.netty.channel)invokeChannelRead:365, AbstractChannelHandlerContext (io.netty.channel)fireChannelRead:357, AbstractChannelHandlerContext (io.netty.channel)channelRead:286, IdleStateHandler (io.netty.handler.timeout)invokeChannelRead:379, AbstractChannelHandlerContext (io.netty.channel)invokeChannelRead:365, AbstractChannelHandlerContext (io.netty.channel)fireChannelRead:357, AbstractChannelHandlerContext (io.netty.channel)channelRead:103, MessageToMessageDecoder (io.netty.handler.codec)invokeChannelRead:379, AbstractChannelHandlerContext (io.netty.channel)invokeChannelRead:365, AbstractChannelHandlerContext (io.netty.channel)fireChannelRead:357, AbstractChannelHandlerContext (io.netty.channel)channelRead:103, MessageToMessageDecoder (io.netty.handler.codec)channelRead:111, MessageToMessageCodec (io.netty.handler.codec)invokeChannelRead:379, AbstractChannelHandlerContext (io.netty.channel)invokeChannelRead:365, AbstractChannelHandlerContext (io.netty.channel)fireChannelRead:357, AbstractChannelHandlerContext (io.netty.channel)channelRead:103, MessageToMessageDecoder (io.netty.handler.codec)invokeChannelRead:379, AbstractChannelHandlerContext (io.netty.channel)invokeChannelRead:365, AbstractChannelHandlerContext (io.netty.channel)fireChannelRead:357, AbstractChannelHandlerContext (io.netty.channel)fireChannelRead:324, ByteToMessageDecoder (io.netty.handler.codec)channelRead:296, ByteToMessageDecoder (io.netty.handler.codec)invokeChannelRead:379, AbstractChannelHandlerContext (io.netty.channel)invokeChannelRead:365, AbstractChannelHandlerContext (io.netty.channel)fireChannelRead:357, AbstractChannelHandlerContext (io.netty.channel)channelRead:1410, DefaultChannelPipeline$HeadContext (io.netty.channel)invokeChannelRead:379, AbstractChannelHandlerContext (io.netty.channel)invokeChannelRead:365, AbstractChannelHandlerContext (io.netty.channel)fireChannelRead:919, DefaultChannelPipeline (io.netty.channel)read:166, AbstractNioByteChannel$NioByteUnsafe (io.netty.channel.nio)processSelectedKey:719, NioEventLoop (io.netty.channel.nio)processSelectedKeysOptimized:655, NioEventLoop (io.netty.channel.nio)processSelectedKeys:581, NioEventLoop (io.netty.channel.nio)run:493, NioEventLoop (io.netty.channel.nio)run:986, SingleThreadEventExecutor$4 (io.netty.util.concurrent)run:74, ThreadExecutorMap$2 (io.netty.util.internal)run:748, Thread (java.lang)
  1. 可以看到 pipeline 中间 handler 被跳过了,其中也包括我们自己实现的 handler。分析下图代码,我们写给 netty pipeline 的 msg 实际是我们自己包装的 HttpObject,是在我们自己实现的 handler 里才转成 netty 的 ReferenceCounted 对象的,由于 handler 被跳过导致该对象并没有被转换成 ReferenceCounted,所以即使 netty 有兜底的 release ,实际并没有产生作用,HttpObject 内部的 ByteBuf 并未真正被释放,此时产生泄漏。


  解决


  1. 【最佳实践】在写入 channel 之前,一定要先判断 channel 是否 active 。

  2. 【最佳实践】我们写给 netty 的内容,最好是实现了 ReferenceCounted 接口的对象,这样即使 netty 内部出现不预期情况,我们也可以利用 netty 的兜底 release 来释放资源。

  3. 控制 ByteBuf 的使用范围。在我们的场景里,可以将压缩的实现下移到 netty 层,但上述 1、2 也同样必须改进才能确保不出问题。

  4. 好处:对 ByteBuf 的操作可以收口在传输层,应用层编程难度大大降低。

  5. 坏处:考虑到可能存在多个 传输层 (http server) 的实现,压缩逻辑可能需要根据堆内堆外做两份实现,每个 http server 都需要对接。


堆外内存


  现象




开启 zstd 压缩时:

  1. QPS 增加会导致操作系统内存持续增加,直到 OOM Killer。

  2. 在 QPS 调零数十小时后,内存也几乎不会降低。


因此怀疑存在堆外内存泄漏。


  分析



首先,整个应用进程用到的堆外内存分两块:
  1. JVM 堆外内存:在我们的测试应用里,JVM 的堆外内存最大值均为 1g,主要是 netty 使用,即 25%。
  2. zstd 库使用的原生内存:
    压缩流程使用原生内存的过程可以简单描述为:创建 zstd ctx 准备压缩 -> 调用 malloc 分配操作系统内存 -> 执行压缩 -> 调用 free 释放内存 -> 释放 zstd ctx。

首先分析源码:
  • Java 代码:在我们的应用里 zstd ctx 的生命周期为请求级别,且我们通过 Java 埋点确认了请求结束后一定会正确释放。
  • zstd c++ 代码:zstd 没有额外的内存管理,直接使用 stdlib 的 malloc 和 free 操作内存。在 zstd ctx 创建的时候分配对应的内存块,销毁实例时释放对应的内存块。
理论上不会存在 zstd 相关的内存泄漏

其次,通过对比实验分析:
  • 在未开启 zstd 压缩时,不会出现堆外内存疑似泄漏的问题。而开启 zstd 压缩时,内存会涨到 95%+,远超过 JVM 占用的最大内存。
因此,基本排除 1 的泄漏可能。

接着,分析进程实际内存使用:
  1. 使用 jemalloc 对压测到 95% 内存的进程进行内存分析,发现堆外内存主要由 zstd 库持有(其实这个 case 进程内存最终降下来了,但当时未查明原因。)
  2. 使用 jemalloc 内存泄漏检测工具,未检测到 zstd 库代码的内存泄漏。
因此,我们认为 zstd 库对内存的操作大概率没有泄漏。


直到最后,我们尝试升级 JDK 版本,重新压测发现 QPS 调零后,内存能够降下来了。而 JDK 版本升级前后的区别在于使用的内存分配器不同:glibc 默认的 ptmalloc vs jemalloc。因此我们怀疑内存泄漏和内存分配器有关。


  • 内存分配器是什么?

内存分配器是用来为应用分配和管理操作系统内存的,分配器从操作系统获取内存再自行管理,具体分配、管理、回收策略取决于内存分配器的具体实现。

我们通常使用的内存分配器,即 malloc/free 函数,由 C 标准库 (libc) 提供的,也被称为动态内存分配器,不同的内存分配器对函数有不同的实现。



  • 内存分配器的核心思想?


内存分配器的核心是 平衡内存分配的性能和内存使用的效率。前者保证响应快、时间短,后者保证有足够内存可用、不浪费。
  • 内存分配器百家争鸣,但是核心思想都是相似的,只是差在具体的算法和元数据的存储上。内存分配器的核心思想概括起来三条:

  • 内存分配及管理:将内存分为多种固定大小的内存块(Chunk),通过内存块管理和元信息存储策略,使对每个 size class 或大内存区域的访问的性能最优。

  • 内存回收及预测:当用户释放内存时,要能够合并小内存为大内存,根据一些条件,该保留的就保留起来,在下次使用时可以快速的响应。不需要保留时,则释放回系统,避免长期占用。

  • 多线程内存分配:比如通过线程独占内存区间(TLS Thread Local Storage)以降低锁竞争对性能的影响。


  • 几种常见的应用层内存分配器对比


内存分配及管理
内存回收及预测
链表维护空闲内存块,每次分配时从链首遍历尝试寻找大小合适(但不相等,因此容易产生内存碎片)的空闲内存块,若无,则尝试继续向 OS 申请新内存块(内存扩张,64 位系统下每次申请 64M,Linux 64M 内存块问题就来源于此
  1. 维护空闲链表:合并后的空闲块会被插入到相应大小类别的空闲链表中。
  2. 合并空闲块:free()被调用时,释放的内存块会尝试与相邻的空闲块合并,形成更大的空闲块。
  3. 延迟归还:只有堆顶的空闲块足够大且达到归还条件才会收缩内存。
内存管理分为线程 Cache 和中央堆两部分。
为每个线程分配一份线程 Cache,小内存分配从线程 Cache 获取,大内存从中央堆分配。
在需要时从中央堆获取内存补充线程 Cache。
  1. 定期 回收 ThreadCache 内存到 CentralCache 里,解决 PTMalloc 内存不能跨分配区迁移的问题。
  2. 定期从中央堆的内存归还给操作系统。
使用多级大小来优化小块内存的分配;
  • (small < 54KB、large < 4MB 、huge)
使用分配区(arena)来维护内存,每个分配区都维护了一系列分页,来提供 small 和 large 的内存分配请求;
每个线程有线程 Cache,且固定选择一个分配区,small 和 large 对象优先从 tcache 分配,其次从线程固定的 arena 分配。
从一个分配区分配出去的内存块,在释放的时候一定会回到该分配区。
有两个层面的回收:
  1. 定期将线程缓存的空闲内存回收到 arena 中;
  2. 定期将 arena 中的空闲内存归还给操作系统;


总的来说,不同的内存分配器有不同的策略,需要根据场景选择:

  • PTMalloc:是 glibc 默认的内存分配器;存在内存浪费、内存碎片、以及加锁导致的性能问题。

  • TCMalloc:适合线程的数量、创建,销毁等是动态的场景;在一些内存需求较大的服务(如推荐系统),小内存上限过低,当请求量上来,锁冲突严重,CPU 使用率将指数暴增。

  • JEMalloc:适合线程的数量、创建、销毁等是静态的(比如线程池)的场景。当 JEMalloc 为了容纳更多的线程时,它会去申请新的 Cache,这会导致出现瞬间的性能剧烈抖动。


  解决


由于测试应用使用的 JDK 版本较低,底层 malloc 实现为 glibc 默认的 ptmalloc,存在内存碎片问题,底层使用 jemalloc 即可解决内存碎片问题。


总结与感想

  1. 时刻关注代码对应用性能的影响。比如一些容易被忽略的点,堆内外拷贝操作、长时间引用大对象等。
  2. 最好不要用 Finalizer,避免降低 GC 回收效率。
  3. 堆外内存使用得当,一定程度上能够优化性能,但要注意由此引发的泄漏风险。
  • 尽量控制堆外内存的使用范围,降低业务层编码难度。
  • 在使用堆外内存时可以需要通过显式约定来尽量降低内存泄漏的风险,比如在代码中明确说明 ByteBuf 的使用原则:
    a.谁消费谁释放,如果 A 组件将 ByteBuf 传递给 B 组件,则通常交由 B 组件决定是否释放。
    b.如果不希望使用者释放,在传给使用者之前,调用一次 .retain() 方法。
  • 写给 netty 的内容,最好是实现了 ReferenceCounted 接口的对象,这样即使 netty 内部出现不预期情况,我们也可以利用 netty 的兜底 release 来释放资源。


¤  拓展阅读  ¤

3DXR技术 |  终端技术 |  音视频技术
服务端技术  |  技术质量 |  数据算法


本文分享自微信公众号 - 大淘宝技术(AlibabaMTT)。
如有侵权,请联系 [email protected] 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

博通宣布终止现有 VMware 合作伙伴计划 deepin-IDE 版本更新,旧貌换新颜 WAVE SUMMIT 迎来第十届,文心一言将有最新披露! 周鸿祎:鸿蒙原生必将成功 GTA 5 完整源代码被公开泄露 Linus:圣诞夜我不看代码,明年再发布新版 Java 工具集 Hutool-5.8.24 发布,一起发发牢骚 Furion 商业化探索:轻舟已过万重山,v4.9.1.15 苹果发布开源多模态大语言模型 Ferret 养乐多公司确认 95 G 数据被泄露
{{o.name}}
{{m.name}}

추천

출처my.oschina.net/u/4662964/blog/10320783