深度解析Java中內(nèi)存溢出(OOM)的典型案例與避坑指南
自動(dòng)垃圾收集器不是萬(wàn)能的,這些隱蔽的OOM陷阱你遇到過嗎?
Java的自動(dòng)垃圾收集器(GC)讓我們能專注于業(yè)務(wù)邏輯,但內(nèi)存溢出(OOM)依然是生產(chǎn)環(huán)境中常見的“隱形殺手”。本文通過三個(gè)真實(shí)案例,揭示業(yè)務(wù)代碼中容易忽略的OOM場(chǎng)景,并給出解決方案。我們將使用關(guān)鍵代碼和Mermaid圖直觀展示問題本質(zhì),幫助你從根本上避免類似問題。
案例一:太多份相同的對(duì)象導(dǎo)致OOM
場(chǎng)景描述
某項(xiàng)目需要實(shí)現(xiàn)用戶名的自動(dòng)補(bǔ)全功能(類似搜索框的聯(lián)想提示)。開發(fā)同學(xué)設(shè)計(jì)了一個(gè)內(nèi)存緩存:以用戶名的所有前綴作為Key,Value是對(duì)應(yīng)前綴的用戶列表。例如用戶“aa”和“ab”會(huì)生成Key“a”、“aa”、“ab”,輸入“a”時(shí)就能返回兩個(gè)用戶。
問題代碼
private ConcurrentHashMap<String, List<UserDTO>> autoCompleteIndex = new ConcurrentHashMap<>();
@PostConstruct
public void wrong() {
// 從數(shù)據(jù)庫(kù)加載所有用戶(假設(shè)1萬(wàn)個(gè))
userRepository.findAll().forEach(userEntity -> {
int len = userEntity.getName().length();
// 為每個(gè)用戶名的前1~N位創(chuàng)建索引
for (int i = 0; i < len; i++) {
String key = userEntity.getName().substring(0, i + 1);
autoCompleteIndex.computeIfAbsent(key, s -> new ArrayList<>())
.add(new UserDTO(userEntity.getName())); // 每次都new對(duì)象
}
});
}
每個(gè)UserDTO除了用戶名還包含10KB的模擬數(shù)據(jù)。運(yùn)行后,1萬(wàn)個(gè)用戶卻生成了6萬(wàn)個(gè)UserDTO對(duì)象,占用約1.2GB內(nèi)存,遠(yuǎn)超預(yù)期。
問題分析
雖然只有1萬(wàn)個(gè)真實(shí)用戶,但每個(gè)用戶名平均長(zhǎng)度6位,因此產(chǎn)生了6萬(wàn)個(gè)索引條目,每個(gè)條目都創(chuàng)建了新的UserDTO對(duì)象,導(dǎo)致內(nèi)存中對(duì)象數(shù)量膨脹6倍。
原始方案的對(duì)象關(guān)系圖:

解決方案
使用HashSet去重,確保每個(gè)用戶只保留一份UserDTO,所有索引Key的List都引用同一份對(duì)象。
@PostConstruct
public void right() {
// 先構(gòu)建去重的用戶緩存
HashSet<UserDTO> cache = userRepository.findAll().stream()
.map(item -> new UserDTO(item.getName()))
.collect(Collectors.toCollection(HashSet::new));
// 構(gòu)建索引時(shí)共享對(duì)象
cache.forEach(userDTO -> {
int len = userDTO.getName().length();
for (int i = 0; i < len; i++) {
String key = userDTO.getName().substring(0, i + 1);
autoCompleteIndex.computeIfAbsent(key, s -> new ArrayList<>())
.add(userDTO); // 共享同一對(duì)象
}
});
}
優(yōu)化后的對(duì)象關(guān)系圖:

優(yōu)化后,內(nèi)存占用降至不足200MB,對(duì)象數(shù)量從6萬(wàn)減至約1萬(wàn)。
教訓(xùn)
- 容量評(píng)估時(shí)不能想當(dāng)然地認(rèn)為“一份數(shù)據(jù)在內(nèi)存中也是一份”。經(jīng)過框架轉(zhuǎn)換、多次復(fù)制,內(nèi)存占用可能成倍增長(zhǎng)。
- 使用集合緩存時(shí),務(wù)必考慮對(duì)象復(fù)用,避免重復(fù)創(chuàng)建。
案例二:使用WeakHashMap不等于不會(huì)OOM
場(chǎng)景描述
開發(fā)者想用WeakHashMap作為緩存,認(rèn)為當(dāng)Key不再被外部引用時(shí),Entry會(huì)自動(dòng)被GC回收,避免內(nèi)存堆積。于是實(shí)現(xiàn)了如下代碼,緩存200萬(wàn)個(gè)用戶資料。
private Map<User, UserProfile> cache = new WeakHashMap<>();
@GetMapping("wrong")
public void wrong() {
String userName = "zhuye";
LongStream.rangeClosed(1, 2000000).forEach(i -> {
User user = new User(userName + i);
cache.put(user, new UserProfile(user, "location" + i));
});
}
運(yùn)行后卻發(fā)現(xiàn)cache.size()始終是200萬(wàn),最終導(dǎo)致OOM。
問題分析
WeakHashMap的Key是弱引用,但ValueUserProfile卻持有User對(duì)象的強(qiáng)引用(通過其user字段)。這導(dǎo)致即使外部的user變量不再使用,User對(duì)象仍然被UserProfile引用,無(wú)法被GC回收。
引用關(guān)系圖(問題版):

當(dāng)GC發(fā)生時(shí),Key(User)只有弱引用,本應(yīng)被回收,但因Value中的強(qiáng)引用,整個(gè)Entry無(wú)法從ReferenceQueue中移除,導(dǎo)致內(nèi)存泄漏。
解決方案
讓Value也使用弱引用包裝,切斷Value對(duì)Key的強(qiáng)引用鏈。
private Map<User, WeakReference<UserProfile>> cache2 = new WeakHashMap<>();
@GetMapping("right")
public void right() {
String userName = "zhuye";
LongStream.rangeClosed(1, 2000000).forEach(i -> {
User user = new User(userName + i);
cache2.put(user, new WeakReference<>(new UserProfile(user, "location" + i)));
});
}
或者重新創(chuàng)建User對(duì)象,使UserProfile不再引用原來(lái)的Key:
cache.put(user, new UserProfile(new User(user.getName()), "location" + i));
優(yōu)化后的引用關(guān)系:

現(xiàn)在,當(dāng)Key只被弱引用時(shí),GC可以回收Key,同時(shí)WeakReference<UserProfile>也會(huì)被回收,Entry最終被清除。
補(bǔ)充
Spring提供的ConcurrentReferenceHashMap支持Key和Value同時(shí)使用軟引用或弱引用,線程安全且性能更好,是更優(yōu)的選擇。
案例三:Tomcat參數(shù)配置不合理導(dǎo)致OOM
場(chǎng)景描述
某應(yīng)用在業(yè)務(wù)高峰期頻繁出現(xiàn)OOM,堆Dump顯示有大量1.7GB的byte數(shù)組,占滿了2GB的堆內(nèi)存。分析發(fā)現(xiàn),這些數(shù)組來(lái)自Tomcat的工作線程。
問題代碼
查看項(xiàng)目配置,發(fā)現(xiàn)有人修改了Tomcat的max-http-header-size參數(shù):
server.max-http-header-size=10000000
起因是開發(fā)遇到了java.lang.IllegalArgumentException: Request header is too large異常,搜索后簡(jiǎn)單地將該參數(shù)改為一個(gè)超大值(10MB),期望永遠(yuǎn)不再報(bào)錯(cuò)。
問題分析
Tomcat的Http11InputBuffer和Http11OutputBuffer會(huì)根據(jù)max-http-header-size分配固定大小的緩沖區(qū)。該配置導(dǎo)致每個(gè)請(qǐng)求的Request和Response各占用約10MB內(nèi)存(實(shí)際InputBuffer稍大)。假設(shè)有100個(gè)工作線程,僅緩沖區(qū)就占用近2GB,加上業(yè)務(wù)對(duì)象,很容易OOM。
請(qǐng)求處理內(nèi)存分配示意圖:

解決方案
將參數(shù)改為合理值,例如20000(20KB),并壓測(cè)驗(yàn)證:
server.max-http-header-size=20000
教訓(xùn)
- 修改參數(shù)前要理解其含義,容量類參數(shù)背后往往代表資源占用,不能隨意設(shè)置超大值。
- 建議預(yù)留2~5倍余量,但必須結(jié)合實(shí)際需求。
總結(jié)與建議
1.對(duì)象復(fù)用意識(shí):相同數(shù)據(jù)可能因多次轉(zhuǎn)換、索引等原因在內(nèi)存中存在多份,使用HashSet等去重可大幅降低內(nèi)存占用。
2.引用類型陷阱:WeakHashMap的Value若持有Key的強(qiáng)引用,會(huì)導(dǎo)致Key無(wú)法回收。使用弱引用包裝Value或切斷引用鏈。
3.合理配置資源:Tomcat等中間件的容量參數(shù)需謹(jǐn)慎設(shè)置,過大會(huì)直接導(dǎo)致內(nèi)存暴漲。
4.OOM排查手段:
啟用GC日志和HeapDumpOnOutOfMemoryError:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=. -XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M
使用MAT、jvisualvm等工具分析堆Dump,定位大對(duì)象和引用鏈。
思考與討論
- Spring的
ConcurrentReferenceHashMap支持Key和Value使用軟引用或弱引用。你覺得哪種方式更適合做緩存?為什么? - 動(dòng)態(tài)執(zhí)行Groovy腳本時(shí),每次
new GroovyShell()會(huì)生成大量類,容易導(dǎo)致Metaspace OOM。你知道如何避免嗎?
到此這篇關(guān)于深度解析Java中內(nèi)存溢出(OOM)的典型案例與避坑指南的文章就介紹到這了,更多相關(guān)Java內(nèi)存溢出避坑內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
微信java開發(fā)之實(shí)現(xiàn)微信主動(dòng)推送消息
這篇文章主要介紹了微信開發(fā)過程中的使用java實(shí)現(xiàn)微信主動(dòng)推送消息示例,需要的朋友可以參考下2014-03-03
使用eclipse打包Maven項(xiàng)目的實(shí)現(xiàn)步驟
本文主要介紹了使用eclipse打包Maven項(xiàng)目的實(shí)現(xiàn)步驟,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2022-03-03
Java基于解釋器模式實(shí)現(xiàn)定義一種簡(jiǎn)單的語(yǔ)言功能示例
這篇文章主要介紹了Java基于解釋器模式實(shí)現(xiàn)定義一種簡(jiǎn)單的語(yǔ)言功能,簡(jiǎn)單描述了解釋器模式的概念、功能及Java使用解釋器模式定義一種簡(jiǎn)單語(yǔ)言的相關(guān)實(shí)現(xiàn)與使用技巧,需要的朋友可以參考下2018-05-05
Java8需要知道的4個(gè)函數(shù)式接口簡(jiǎn)單教程
這篇文章主要介紹了Java?8中引入的函數(shù)式接口,包括Consumer、Supplier、Predicate和Function,以及它們的用法和特點(diǎn),文中通過代碼介紹的非常詳細(xì),需要的朋友可以參考下2025-03-03
springboot中請(qǐng)求路徑配置在配置文件中詳解
這篇文章主要介紹了springboot中請(qǐng)求路徑配置在配置文件中,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-01-01
Spring Boot日志收集及鏈路追蹤實(shí)現(xiàn)示例
這篇文章主要為大家介紹了Spring Boot日志收集及鏈路追蹤實(shí)現(xiàn)示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-12-12
mybatis-plus update更新操作的三種方式(小結(jié))
本文主要介紹了mybatis-plus update更新操作的三種方式,文中通過示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-10-10
java.Net.UnknownHostException異常處理問題解決
這篇文章主要介紹了java.Net.UnknownHostException異常處理方法,問題原因是在系統(tǒng)的?/etc/Hostname中配置了主機(jī)名,而在/etc/hosts文件中沒有相應(yīng)的配置,本文給大家詳細(xì)講解,需要的朋友可以參考下2023-03-03
Java ffmpeg 實(shí)現(xiàn)視頻加文字/圖片水印功能(示例代碼)
本文介紹了使用Java和ffmpeg庫(kù)實(shí)現(xiàn)視頻加文字或圖片水印的方法,通過引入依賴代碼和示例,詳細(xì)說明了如何將文字水印和圖片水印添加到視頻中,為需要在視頻中加入水印的開發(fā)者提供了實(shí)用的指導(dǎo),這種方法不僅增強(qiáng)了視頻內(nèi)容的版權(quán)保護(hù),也為視頻編輯提供了更多的可能性2024-10-10
淺談Java方法調(diào)用的優(yōu)先級(jí)問題
這篇文章主要介紹了淺談Java方法調(diào)用的優(yōu)先級(jí)問題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來(lái)看看吧2020-10-10

