1. 錯誤描述
io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 16777216 byte(s) of direct memory (used: 939524103, max: 954728448)
at io.netty.util.internal.PlatformDependent.incrementMemoryCounter(PlatformDependent.java:802)
at io.netty.util.internal.PlatformDependent.allocateDirectNoCleaner(PlatformDependent.java:731)
at io.netty.buffer.PoolArena$DirectArena.allocateDirect(PoolArena.java:648)
at io.netty.buffer.PoolArena$DirectArena.newChunk(PoolArena.java:623)
at io.netty.buffer.PoolArena.allocateNormal(PoolArena.java:202)
at io.netty.buffer.PoolArena.tcacheAllocateNormal(PoolArena.java:186)
at io.netty.buffer.PoolArena.allocate(PoolArena.java:136)
at io.netty.buffer.PoolArena.allocate(PoolArena.java:126)
at io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:394)
at io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:188)
at io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:179)
at io.netty.buffer.AbstractByteBufAllocator.buffer(AbstractByteBufAllocator.java:116)
at org.springframework.core.io.buffer.NettyDataBufferFactory.allocateBuffer(NettyDataBufferFactory.java:71)
at org.springframework.core.io.buffer.NettyDataBufferFactory.allocateBuffer(NettyDataBufferFactory.java:39)
at org.springframework.core.codec.CharSequenceEncoder.encodeValue(CharSequenceEncoder.java:91)
at org.springframework.core.codec.CharSequenceEncoder.lambda$encode$0(CharSequenceEncoder.java:75)
at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onNext(FluxMapFuseable.java:113)
我的環境配置如下:
- springboot 2.6.4
- springcloud 2021.0.1
- jdk 1.8
2. 排查過程
2.1. 回顧Java程序記憶體配置設定
JVM記憶體區域劃分位兩塊:堆區和非堆區
- 堆區:我們熟知的新生代老年代
- 非堆區:如下圖所示,有中繼資料和直接記憶體
【需要注意的是:永久代(jdk1.8時徹底移除)存放JVM運作時使用的類,永久代的對象在full GC的時候才進行垃圾回收】
2.2. 什麼是直接記憶體 DirectMemory
直接記憶體(Direct Memory)并不是虛拟機運作時資料區的一部分,也不是《Java虛拟機規範》中定義的記憶體區域。但是這部分記憶體也被頻繁地使用,而且也可能導緻 OutOfMemoryError 異常出現,是以我們放到這裡一起講解。 在 JDK 1.4 中新加入了 NIO(New Input/Output)類,引入了一種基于通道(Channel)與緩沖區(Buffer)的 I/O 方式,它可以使用 Native 函數庫直接配置設定堆外記憶體,然後通過一個存儲在 Java 堆裡面的 DirectByteBuffer 對象作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高性能,因為避免了在 Java 堆和 Native 堆中來回複制資料。 顯然,本機直接記憶體的配置設定不會受到 Java 堆大小的限制,但是,既然是記憶體,則肯定還是會受到本機總記憶體(包括實體記憶體、SWAP分區或者分頁檔案)大小以及處理器尋址空間的限制,一般伺服器管理者配置虛拟機參數時,會根據實際記憶體去設定 -Xmx 等參數資訊,但經常忽略掉直接記憶體,使得各個記憶體區域總和大于實體記憶體限制(包括實體的和作業系統級的限制),進而導緻動态擴充時出現 OutOfMemoryError 異常。
摘自:《深入了解 Java 虛拟機 第三版》2.2.7 小節 。
翻譯一下上文可以得到下圖:
- 關于上述的記憶體:可以這麼粗淺地認為:棧用于方法的層層調用、傳回、順序執行,堆負責存儲對象。
- 關于記憶體空間的稱呼變化:JDK 1.4 之前的稱呼 native heap 轉為現在的稱呼 directory memory 。之是以 heap 前加 native 來修飾,是因為要讓其和虛拟機規範中的記憶體 heap,而現在稱呼 directory memory 是因為我們能夠直接通過引用通路對象,消除了拷貝操作。
實際上,棧中通路一個對象還是要借助堆,stack 尋求一個對象還是和以前一樣,會問:”堆,請把對象xxx給我“,而不會向 native 堆索要。是以這個直接性是不徹底的。真正的實作是這樣的,Java 程式仍然需要使用在 Java heap 中的一個對象(實際上規定為 DirectByteBuffer 類型對象來操作),但是這個對象(buffer)所持有的資料實際上存儲于 native memory 中,而 Java 堆隻僅僅擁有着對 native heap 的一個引用。
關于直接記憶體的具體介紹請參考:
https://cloud.tencent.com/developer/article/1586341
但作者的說法在當今的眼光看也是有點問題,比如:“直接記憶體的最大大小可以通過 -XX:MaxDirectMemorySize 來設定,預設是 64M。”
jdk8之後似乎就有變動,預設是初始的DirectMemorySize=0,最大為Xmx配置的大小,參考JDK文檔
https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html#-XX:MaxDirectMemorySize=size
2.3. 分析事故原因
考慮到DirectMemory是因為NIO的一些操作導緻,而網關reactor底層依賴Netty實作,會大量使用NIO,剛好契合我們的現象;同時本着先懷疑自身而不是中間件的原則,先檢視是否存在申請dataBuffer後未正常釋放的case,還真有:
第一處問題:
因為有擷取請求體驗簽及修改請求體進行加密串替換及日志列印等需求,我單獨寫了一個過濾器進行相關操作代碼如下:
CachedBodyOutputMessage outputMessage = new CachedBodyOutputMessage(exchange, headers);
return bodyInserter.insert(outputMessage, new BodyInserterContext())
.then(Mono.defer(() -> {
// 重新封裝請求
ServerHttpRequest decoratedRequest = requestDecorate(exchange, headers, outputMessage);
// 記錄響應日志
ServerHttpResponseDecorator decoratedResponse = recordResponseLog(exchange, requestUri);
return chain.filter(exchange.mutate().request(decoratedRequest).response(decoratedResponse).build());
}));
可以看到上述代碼使用CachedBodyOutputMessage包裝後,正常情況下,CachedBodyOutputMessage内部已實作dataBuffer釋放的操作,但如遇到異常則需要手動釋放,這裡并未進行相關操作,更改後如下:
MyCachedBodyOutputMessage outputMessage = new MyCachedBodyOutputMessage(exchange, headers);
return bodyInserter.insert(outputMessage, new BodyInserterContext())
.then(Mono.defer(
() -> {
// 重新封裝請求
ServerHttpRequest decoratedRequest = requestDecorate(exchange, headers, outputMessage);
// 記錄響應日志
ServerHttpResponseDecorator decoratedResponse = recordResponseLog(exchange, requestUri);
return chain.filter(exchange.mutate().request(decoratedRequest).response(decoratedResponse).build());
}
)
).onErrorResume((Function<Throwable, Mono<Void>>) throwable -> release(exchange, outputMessage, throwable));
可以看到這裡CachedBodyOutputMessage對象被替換給了自定義的MyCachedBodyOutputMessage,且內建了手動釋放release(exchange, outputMessage, throwable)
為什麼要自定義MyCachedBodyOutputMessage?
/**
* 釋放dataBuffer
*
* @param exchange
* @param outputMessage
* @param throwable
* @return
*/
Mono<Void> release(ServerWebExchange exchange, MyCachedBodyOutputMessage outputMessage,
Throwable throwable) {
if (outputMessage.isCached()) {
return outputMessage.getBody().map(DataBufferUtils::release).then(Mono.error(throwable));
}
return Mono.error(throwable);
}
如上述源碼,釋放前需要進行outputMessage.isCached()判斷,而isCached()并不是public方法,無法直接調用,因為複制了CachedBodyOutputMessage的源碼,僅僅改動了isCached()的修飾符形成了新的MyCachedBodyOutputMessage類,這樣才可以順利完成outputMessage.isCached()的調用
第二處問題:
/**
* 記錄響應日志
* 通過 DataBufferFactory 解決響應體分段傳輸問題。
*/
private ServerHttpResponseDecorator recordResponseLog(ServerWebExchange exchange, String requestUri) {
ServerHttpResponse response = exchange.getResponse();
DataBufferFactory bufferFactory = response.bufferFactory();
return new ServerHttpResponseDecorator(response) {
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
if (body instanceof Flux) {
// 擷取響應類型,如果是 json 就列印
String originalResponseContentType = exchange.getAttribute(ServerWebExchangeUtils.ORIGINAL_RESPONSE_CONTENT_TYPE_ATTR);
if (ObjectUtil.equal(this.getStatusCode(), HttpStatus.OK)
&& StrUtil.contains(originalResponseContentType, "application/json")) {
Flux<? extends DataBuffer> fluxBody = Flux.from(body);
return super.writeWith(fluxBody.buffer().map(dataBuffers -> {
// 合并多個流集合,解決傳回體分段傳輸
DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory();
DataBuffer join = dataBufferFactory.join(dataBuffers);
byte[] content = new byte[join.readableByteCount()];
join.read(content);
// 釋放掉記憶體 注意這裡,手動釋放了,但實際上是沒有釋放成功的
DataBufferUtils.release(join);
String responseResult = new String(content, StandardCharsets.UTF_8);
// 設定響應時間
exchange.getAttributes().put(Constants.RESPONSE_TIME, System.currentTimeMillis());
// 設定響應體
exchange.getAttributes().put(Constants.RESPONSE_BODY_OBJECT_KEY, responseResult);
// 其他業務操作
log.info("=========== requestUri:{} ,responseResult:{}", requestUri, responseResult);
return bufferFactory.wrap(content);
}));
}
}
// if body is not a flux. never got there.
return super.writeWith(body);
}
};
}
這裡确實調用了DataBufferUtils.release(join);手動釋放,但檢視其源碼後發現,釋放并未生效:
/**
* Release the given data buffer, if it is a {@link PooledDataBuffer} and
* has been {@linkplain PooledDataBuffer#isAllocated() allocated}.
* @param dataBuffer the data buffer to release
* @return {@code true} if the buffer was released; {@code false} otherwise.
*/
public static boolean release(@Nullable DataBuffer dataBuffer) {
if (dataBuffer instanceof PooledDataBuffer) {
PooledDataBuffer pooledDataBuffer = (PooledDataBuffer) dataBuffer;
if (pooledDataBuffer.isAllocated()) {
try {
return pooledDataBuffer.release();
}
catch (IllegalStateException ex) {
// Avoid dependency on Netty: IllegalReferenceCountException
if (logger.isDebugEnabled()) {
logger.debug("Failed to release PooledDataBuffer: " + dataBuffer, ex);
}
return false;
}
}
}
return false;
}
可以看到如果想要生效dataBuffer必須是PooledDataBuffer類型的,而我的代碼是DefaultDataBuffer類型的,因次并未成功釋放;
修改後的代碼如下
// 合并多個流集合,解決傳回體分段傳輸
DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory();
DataBuffer join = dataBufferFactory.join(dataBuffers);
byte[] content = new byte[join.readableByteCount()];
join.read(content);
// 釋放掉記憶體 注意這裡,手動釋放了,但實際上是沒有釋放成功的
DataBufferUtils.release(join);
----------------------------------------------------------------------------------------------
// 合并多個流集合,解決傳回體分段傳輸
DataBufferFactory dataBufferFactory = new NettyDataBufferFactory(ByteBufAllocator.DEFAULT);
DataBuffer join = dataBufferFactory.join(dataBuffers);
byte[] content = new byte[join.readableByteCount()];
join.read(content);
// 釋放掉記憶體 ,成功釋放
DataBufferUtils.release(join);
2.4. 看下修改後的效果對比
修改前
修改後
因為釋放掉了dataBuffer,整體記憶體占用都降低了很多,釋出到線上後面持續在進行觀察...
3. 思考
為什麼這個問題是在非生産環境,而不是生産環境出現呢?
猜測原因:
首先要明确:fullGC的時候會釋放掉直接記憶體的占用,
非生産環境因為隻有内部測試時才會用到,流量較小,很難觸發fullGC,是以也就一直無法釋放直接記憶體。
生産環境流量依然遠遠大于非生産,因為産生了gc是以也就間接的釋放掉了直接記憶體