關(guān)于 Java 內(nèi)存泄露的錯(cuò)誤認(rèn)知,你所應(yīng)該了解的
今天我們來聊一下 Java 虛擬機(jī)生態(tài)核心技術(shù)—— 內(nèi)存泄漏,即 “Memory Leak” 。
在本篇文章中,我們將了解什么是 Java 中的內(nèi)存泄漏,以及關(guān)于 Java 內(nèi)存泄漏場(chǎng)景的錯(cuò)誤認(rèn)知進(jìn)行簡(jiǎn)要解析。
帶你認(rèn)識(shí) Java 內(nèi)存泄漏點(diǎn)點(diǎn)滴滴
眾所周知,Java 提供了強(qiáng)大的內(nèi)存管理機(jī)制,使得開發(fā)人員不需要像其他過程性編程語言(如 C 和 C++ )那樣進(jìn)行手動(dòng)管理內(nèi)存。在 Java 生態(tài)中,我們通常使用 new 關(guān)鍵字創(chuàng)建對(duì)象時(shí),Java 虛擬機(jī)(JVM)會(huì)自動(dòng)為該對(duì)象分配內(nèi)存。當(dāng)該對(duì)象不再被應(yīng)用程序引用時(shí),垃圾收集器會(huì)自動(dòng)識(shí)別并回收這些不再使用的對(duì)象,從而釋放內(nèi)存空間供其他對(duì)象使用。
盡管 Java 的內(nèi)存管理機(jī)制看似完美,但仍然存在潛在的內(nèi)存泄漏問題。那么,什么是 Java 中的內(nèi)存泄漏 ?
通常,在 Java 中,內(nèi)存泄漏指的是垃圾收集器無法識(shí)別不再使用的對(duì)象,導(dǎo)致這些對(duì)象無限期地駐留在內(nèi)存中,從而減少了分配給應(yīng)用程序的可用內(nèi)存。由于這些未使用的對(duì)象仍然被引用,可能會(huì)導(dǎo)致內(nèi)存不足錯(cuò)誤(OutOfMemoryError),從而影響應(yīng)用程序的可靠性和性能。
針對(duì) Java 內(nèi)存泄露相關(guān)原因,大家可參考之前的文章,具體可點(diǎn)擊如下圖片查閱。
Java 內(nèi)存泄漏的典型場(chǎng)景錯(cuò)誤認(rèn)知
關(guān)于 Java 虛擬機(jī)內(nèi)存問題的錯(cuò)誤認(rèn)知,是指一些常見的誤解或誤導(dǎo),可能導(dǎo)致對(duì)內(nèi)存管理機(jī)制的理解不準(zhǔn)確。在開發(fā) Java 應(yīng)用程序時(shí),理解和正確處理內(nèi)存是至關(guān)重要的。本文將基于筆者 10 多年的一線經(jīng)驗(yàn),簡(jiǎn)單介紹一些常見的錯(cuò)誤認(rèn)知,幫助大家建立正確的 Java 虛擬機(jī)內(nèi)存知識(shí)體系。
認(rèn)知 1: “重啟” 將會(huì)解決內(nèi)存泄露問題
ITOps 團(tuán)隊(duì)經(jīng)常采取快速修復(fù)措施,比如重新啟動(dòng)應(yīng)用程序或服務(wù)器。這是 99% 的技術(shù)人員經(jīng)常干的事情。然而,僅僅重新啟動(dòng)應(yīng)用程序本身并不能釋放所有不正確分配的內(nèi)存,通常只能釋放正確分配的內(nèi)存。不正確分配的內(nèi)存需要通過常規(guī)垃圾收集來清理,因此重新啟動(dòng)應(yīng)用程序只能暫時(shí)解決問題,而問題很可能會(huì)再次出現(xiàn)。
重新啟動(dòng)應(yīng)用程序服務(wù)或服務(wù)器可以重置內(nèi)存狀態(tài),但從長(zhǎng)遠(yuǎn)來看,任何導(dǎo)致內(nèi)存泄漏的問題都有可能再次發(fā)生,而且可能更加頻繁。定期重新啟動(dòng)服務(wù)器表明存在應(yīng)用程序問題,我們的應(yīng)用程序可能會(huì)無謂地消耗資源,并暴露于性能問題和速度減慢的風(fēng)險(xiǎn)中。忽視應(yīng)用程序問題的跡象是不明智的。
因此,除了簡(jiǎn)單地重新啟動(dòng)應(yīng)用程序或服務(wù)器外,ITOps 團(tuán)隊(duì)?wèi)?yīng)該致力于解決潛在的應(yīng)用程序問題。我們可以通過分析和優(yōu)化代碼、進(jìn)行內(nèi)存泄漏檢測(cè)和修復(fù)、進(jìn)行性能優(yōu)化等方式來解決這些問題。通過采取這些措施,可以提高應(yīng)用程序的穩(wěn)定性、性能和效率,減少不必要的資源消耗,并避免頻繁的重新啟動(dòng)操作。
認(rèn)知 2: “擴(kuò)容” 將消滅一切內(nèi)存問題
除了上述認(rèn)知 1 的“重啟”操作,“擴(kuò)容”行為在解決內(nèi)存泄露時(shí),也是經(jīng)常采取的一種措施。
其實(shí),從本質(zhì)上而言,大多數(shù)的內(nèi)存泄漏就像一個(gè)無底洞,無止境地吞噬著資源。我們投入的資源越多,它就越貪婪地索取更多。最終,將耗盡可用內(nèi)存,而無法預(yù)測(cè)應(yīng)用程序何時(shí)會(huì)達(dá)到內(nèi)存上限,一旦達(dá)到上限,我們的生產(chǎn)服務(wù)將受到嚴(yán)重影響。
舉個(gè)簡(jiǎn)單的場(chǎng)景:假設(shè)我們的核心平臺(tái)服務(wù)存在內(nèi)存泄漏。隨著越來越多的用戶同時(shí),系統(tǒng)最終會(huì)因內(nèi)存耗盡而崩潰,出現(xiàn) OutOfMemoryError 錯(cuò)誤。
如果我們依賴云基礎(chǔ)設(shè)施,如 Google GCP(Google Cloud Platform)、Microsoft Azure 或國內(nèi)阿里、華為以及騰訊云等,并根據(jù)資源使用和按需付費(fèi)的定價(jià)模式進(jìn)行支付,那么意味著我們要為解決內(nèi)存泄漏而浪費(fèi)的不必要資源將對(duì)我們所構(gòu)建的業(yè)務(wù)利潤(rùn)和預(yù)算產(chǎn)生影響。我們可以將這些開支用于更有意義的事務(wù)上。
因此,及時(shí)發(fā)現(xiàn)和修復(fù)內(nèi)存泄漏問題對(duì)于確保應(yīng)用程序的穩(wěn)定性和性能至關(guān)重要。通過進(jìn)行定期的性能分析、內(nèi)存監(jiān)測(cè)和代碼審查,我們可以捕捉并解決潛在的內(nèi)存泄漏問題。這樣不僅可以避免系統(tǒng)崩潰和服務(wù)中斷,還可以節(jié)省資源成本,讓我們的業(yè)務(wù)能夠?qū)W⒂诟袃r(jià)值的方面。
認(rèn)知 3: Java 具有自動(dòng)內(nèi)存管理,無需對(duì)其進(jìn)行干涉
有時(shí)候技術(shù)人員錯(cuò)誤地認(rèn)為 Java 完全不需要關(guān)注內(nèi)存管理,因?yàn)樗哂凶詣?dòng)垃圾回收機(jī)制。然而,這種觀點(diǎn)是誤導(dǎo)性的。雖然 Java 提供了自動(dòng)垃圾回收,但仍然需要開發(fā)人員關(guān)注內(nèi)存的分配和釋放,以避免內(nèi)存泄漏等問題。
現(xiàn)實(shí)的情況是:我們的“屎山”代碼往往或多或少存在如下問題,從而導(dǎo)致內(nèi)存泄漏現(xiàn)象可能發(fā)生:
- 未取消引用創(chuàng)建的對(duì)象:在代碼中創(chuàng)建對(duì)象后,如果沒有適時(shí)地取消對(duì)這些對(duì)象的引用,垃圾收集器將無法回收它們,從而導(dǎo)致內(nèi)存泄漏。
- 保留 HashMap 或 HashSet 中的靜態(tài)對(duì)象: 在靜態(tài)集合對(duì)象(如 HashMap 或 HashSet)中保留對(duì)象的引用,即使這些對(duì)象不再需要,也會(huì)導(dǎo)致內(nèi)存泄漏。確保在不再需要時(shí)及時(shí)從靜態(tài)集合中移除對(duì)象引用,以避免內(nèi)存泄漏。
- 未關(guān)閉 JDBC 連接、ResultSet 和語句對(duì)象、文件句柄和套接字等資源: 在使用需要手動(dòng)管理的資源時(shí),如 JDBC 連接、ResultSet 和語句對(duì)象、文件句柄和套接字等,如果沒有正確地關(guān)閉或釋放這些資源,會(huì)導(dǎo)致資源泄漏和內(nèi)存泄漏。
- 在 ThreadLocal 中保留對(duì)對(duì)象的引用而不清理: ThreadLocal 是一種線程本地變量,如果在 ThreadLocal 中保留對(duì)對(duì)象的引用,而在不再需要時(shí)沒有清理它們,將導(dǎo)致對(duì)象一直存在于內(nèi)存中,引發(fā)內(nèi)存泄漏。
為避免這些問題,在實(shí)際的項(xiàng)目開發(fā)活動(dòng)中,我們需要遵循良好的編程實(shí)踐,及時(shí)取消對(duì)象引用,正確關(guān)閉資源以及謹(jǐn)慎使用 ThreadLocal,可以最大程度地避免內(nèi)存泄漏問題,提高應(yīng)用程序的性能和可靠性。
認(rèn)知 4: 內(nèi)存泄露主要出現(xiàn)在高并發(fā)場(chǎng)景
其實(shí),基于歷史經(jīng)驗(yàn)教訓(xùn),內(nèi)存泄露可以在任何場(chǎng)景下出現(xiàn),不僅限于高并發(fā)場(chǎng)景。內(nèi)存泄露的根本原因是程序中存在某些內(nèi)存無法被自動(dòng)回收,這與并發(fā)量沒直接關(guān)系。
但由于高并發(fā)場(chǎng)景下,同一問題發(fā)生的頻率更高,內(nèi)存占用也更容易突破閾值,因此內(nèi)存泄露的問題更容易被發(fā)現(xiàn)和注意。這種現(xiàn)象讓人容易聯(lián)想為“內(nèi)存泄露只在高并發(fā)場(chǎng)景出現(xiàn)”,但實(shí)際上是兩個(gè)沒有必然聯(lián)系的問題。
內(nèi)存泄漏不僅可能發(fā)生在高并發(fā)或高流量的應(yīng)用場(chǎng)景,也同樣可能隱藏在流量較小或使用水平較低的應(yīng)用程序中。這類內(nèi)存泄漏問題可能起初非常難以被發(fā)現(xiàn),但會(huì)隨著時(shí)間推移而逐步積累,最終導(dǎo)致應(yīng)用程序運(yùn)行崩潰或宕機(jī)。
特別是在當(dāng)前微服務(wù)架構(gòu)盛行的背景下,許多企業(yè)會(huì)部署運(yùn)行大量微小的服務(wù)實(shí)例。這樣一來,每個(gè)單個(gè)微服務(wù)實(shí)例的內(nèi)存泄漏問題所造成的影響似乎很小,容易被忽略,但這些服務(wù)實(shí)例的數(shù)量又非常多,分布廣泛,長(zhǎng)時(shí)間累積下來,聚合起來的內(nèi)存泄漏問題可能會(huì)是非常嚴(yán)重的。
如果不能有效監(jiān)控和發(fā)現(xiàn)這些個(gè)別服務(wù)中的內(nèi)存泄漏問題,并及時(shí)排查修復(fù),它們就可能“藏”在系統(tǒng)中,成為一個(gè)不易察覺的巨大隱患。當(dāng)達(dá)到某個(gè)臨界點(diǎn)后,可能會(huì)突然爆發(fā),導(dǎo)致整個(gè)系統(tǒng)或關(guān)鍵業(yè)務(wù)不可用。
所以,我們不能忽視任何個(gè)別服務(wù)或應(yīng)用中的潛在內(nèi)存泄漏問題。必須建立起全面的監(jiān)控體系,確保能及時(shí)發(fā)現(xiàn)任何級(jí)別的應(yīng)用中的內(nèi)存泄漏情況,并快速定位修復(fù),避免問題積累擴(kuò)大到不可控的地步。
認(rèn)知 5: 哥的代碼杠杠的,應(yīng)該不會(huì)有問題
通常而言,代碼質(zhì)量跟內(nèi)存泄漏沒有絕對(duì)的正比例關(guān)系。代碼質(zhì)量是指代碼的可讀性、可維護(hù)性、健壯性等方面的評(píng)價(jià)。雖然高質(zhì)量的代碼可以提高程序的可靠性和性能,但并不能保證絕對(duì)沒有內(nèi)存泄漏問題。即使代碼在其他方面達(dá)到了高質(zhì)量的標(biāo)準(zhǔn),仍然有可能存在內(nèi)存泄漏的風(fēng)險(xiǎn)。
由于軟件開發(fā)通常在動(dòng)態(tài)環(huán)境中進(jìn)行,涉及多線程、并發(fā)訪問、異步操作等復(fù)雜情況。這些因素增加了內(nèi)存泄漏問題的潛在風(fēng)險(xiǎn)。即使代碼質(zhì)量較高,也需要在實(shí)際運(yùn)行環(huán)境中進(jìn)行充分的測(cè)試和監(jiān)控,以確保沒有內(nèi)存泄漏問題。
除此之外,作為技術(shù)人員,我們必須明白,我們編寫的代碼再完美和嚴(yán)謹(jǐn),也無法完全避免依賴的第三方庫中可能存在的內(nèi)存泄漏問題。
我們?cè)陧?xiàng)目中不可避免需要依賴各種第三方庫和框架,這已經(jīng)成為現(xiàn)代軟件開發(fā)的基本情況。這些依賴庫中,即使是非常優(yōu)秀和流行的項(xiàng)目,也很難完全杜絕內(nèi)存泄漏的風(fēng)險(xiǎn)。
更糟糕的是,我們通常需要依賴多個(gè)第三方庫,它們之間的交互也可能產(chǎn)生無法預(yù)知的內(nèi)存問題。即使每個(gè)第三方庫的質(zhì)量都很高,組合使用時(shí)還是可能出現(xiàn)意想不到的問題。
所以我們必須對(duì)系統(tǒng)中的所有第三方依賴保持高度的警惕。需要采取各種手段,比如靜態(tài)代碼分析、運(yùn)行時(shí)檢測(cè)等方式,盡可能提前發(fā)現(xiàn)第三方庫中的內(nèi)存泄漏問題。一旦發(fā)現(xiàn),需要及時(shí)跟進(jìn)第三方維護(hù)者解決。
同時(shí),我們?cè)陂_發(fā)自己的代碼時(shí),也要考慮依賴的不確定性。采取更嚴(yán)謹(jǐn)?shù)木幋a方式,進(jìn)行徹底的單元測(cè)試,降低問題擴(kuò)散的風(fēng)險(xiǎn)。這樣,即使依賴存在問題,也能將影響控制在最小范圍。
認(rèn)知 6: 老版本框架才有出現(xiàn)內(nèi)存泄漏問題
內(nèi)存泄漏是一個(gè)影響所有 Java 版本的潛在問題,包括最新版本在內(nèi)。我們不能因使用了新版本而降低警惕。
事實(shí)上,Java 的一些新功能和改進(jìn),在解決舊版問題的同時(shí),有時(shí)也會(huì)無意中引入新的內(nèi)存泄漏源。這主要是由于新功能的邊界案例沒有完全覆蓋到。比如在 Java 11.0.16 版本中,就發(fā)現(xiàn)了與 C2 JIT 編譯器相關(guān)的內(nèi)存泄漏問題,嚴(yán)重影響了一些流行應(yīng)用如 Jenkins。
這個(gè)例子表明,即使我們的源代碼嚴(yán)格規(guī)范,也不能完全避免因編譯器等其他環(huán)節(jié)引入的內(nèi)存泄漏。這種編譯器導(dǎo)致的內(nèi)存泄漏又較難排查,需要借助專業(yè)工具才能發(fā)現(xiàn)。
綜上所述,內(nèi)存泄漏是一個(gè)跨版本的潛在隱患,同時(shí)也需要警惕來自編譯器等外部因素導(dǎo)致的內(nèi)存泄漏。我們必須對(duì)任何 Java 版本都保持高度重視,多途徑全面監(jiān)測(cè)內(nèi)存情況,一旦發(fā)現(xiàn)異常,立即進(jìn)行排查分析,主動(dòng)查找潛在內(nèi)存泄漏問題,而不能被動(dòng)等待問題顯現(xiàn)。
認(rèn)知 7: 內(nèi)存型應(yīng)用才有出現(xiàn)內(nèi)存泄漏問題
我們需要清楚的是,應(yīng)用程序占用大量?jī)?nèi)存資源與存在內(nèi)存泄漏是兩個(gè)不同的形態(tài)。
有一些應(yīng)用程序由于其功能特點(diǎn),天生需要占用非常大量的內(nèi)存才能保證服務(wù)質(zhì)量,比如緩存系統(tǒng)、大數(shù)據(jù)處理平臺(tái)等。當(dāng)這類應(yīng)用程序啟動(dòng)時(shí),我們通常會(huì)看到內(nèi)存占用快速飆升。但是這種情況下,只要內(nèi)存占用處于某個(gè)穩(wěn)定水平,并不會(huì)無限增長(zhǎng),那么就不屬于內(nèi)存泄漏。
嚴(yán)格意義上來講,內(nèi)存泄漏主要指的是應(yīng)用程序中的內(nèi)存占用隨時(shí)間推移而永無止境地增長(zhǎng),這通常是由于存在釋放內(nèi)存的代碼缺陷導(dǎo)致。對(duì)于本身就需要大量?jī)?nèi)存的應(yīng)用,我們需要區(qū)分正常的內(nèi)存占用增長(zhǎng)和內(nèi)存泄漏導(dǎo)致的不正常增長(zhǎng)。
在實(shí)際的業(yè)務(wù)場(chǎng)景中,當(dāng)觀測(cè)到內(nèi)存占用激增時(shí),我們不能草率地就判斷存在內(nèi)存泄漏。需要進(jìn)一步觀察占用量隨時(shí)間是否穩(wěn)定、是否會(huì)釋放、是否會(huì)增長(zhǎng)到系統(tǒng)資源耗盡等。結(jié)合應(yīng)用類型和場(chǎng)景,才能對(duì)根源進(jìn)行準(zhǔn)確判斷。區(qū)分占用量增長(zhǎng)的性質(zhì),再采取針對(duì)性的優(yōu)化措施,才是應(yīng)對(duì)之道。
認(rèn)知 8: 主流 GC 策略可以避免內(nèi)存泄漏問題
在軟件項(xiàng)目開發(fā)活動(dòng)中,有時(shí)候人們傾向于跟隨潮流,這意味著他們會(huì)看到其他人家或項(xiàng)目中運(yùn)用先進(jìn)技術(shù)以最大化性能,并希望將這些成功經(jīng)驗(yàn)應(yīng)用到自己的項(xiàng)目中。然而,由于項(xiàng)目的特性、架構(gòu)的差異以及框架的版本特性,這種模仿行為往往導(dǎo)致了失敗和困惑。
在軟件開發(fā)領(lǐng)域,技術(shù)的快速演進(jìn)和變化意味著新的工具、框架和方法不斷涌現(xiàn)。當(dāng)開發(fā)人員看到其他項(xiàng)目取得成功時(shí),他們可能會(huì)嘗試復(fù)制那些成功的因素,期望獲得類似的結(jié)果。這種跟風(fēng)心態(tài)很常見,因?yàn)槿藗兿M軌蚬?jié)省時(shí)間和精力,避免自己犯錯(cuò)或重復(fù)發(fā)明輪子。
然而,項(xiàng)目的特性和需求往往是獨(dú)特的,每個(gè)項(xiàng)目都有其獨(dú)特的目標(biāo)、范圍和約束條件。對(duì)于不同的項(xiàng)目,采用同一種技術(shù)或方法并不能保證獲得相同的成功結(jié)果。項(xiàng)目的特性可能涉及不同的業(yè)務(wù)領(lǐng)域、不同的用戶需求、不同的性能要求等等。此外,項(xiàng)目的架構(gòu)和框架版本也可能不同,這會(huì)導(dǎo)致在復(fù)制別人的成功經(jīng)驗(yàn)時(shí)出現(xiàn)問題。
當(dāng)人們盲目跟風(fēng)而沒有深入理解技術(shù)和其適用性時(shí),很容易在項(xiàng)目中遇到挫折和問題??赡軙?huì)發(fā)現(xiàn)所選的技術(shù)與項(xiàng)目需求不匹配,或者在實(shí)施過程中遇到了無法解決的兼容性或性能問題。這種情況下,就會(huì)發(fā)生翻車,即項(xiàng)目遇到嚴(yán)重的失敗或困難。
最為典型的場(chǎng)景便是 Java 虛擬機(jī)參數(shù)的配置,基于較老的框架、底層 OS 以及落后的技術(shù)堆棧,使得在實(shí)際的業(yè)務(wù)場(chǎng)景中,期望能夠采用主流的 GC 策略以解決內(nèi)存泄漏問題。然而,不幸的事,主流的 GC 策略可以幫助自動(dòng)管理內(nèi)存,但并不能完全避免內(nèi)存泄漏問題。開發(fā)人員仍然需要在編碼中注意避免保持不必要的強(qiáng)引用、處理循環(huán)引用等情況,以確保程序的內(nèi)存使用是有效和可控的。雖然 GC 可以幫助減少手動(dòng)內(nèi)存管理的負(fù)擔(dān),但對(duì)于確保內(nèi)存泄漏問題的解決,仍需要開發(fā)人員的主動(dòng)參與和正確的編碼實(shí)踐。