理解 OutOfMemoryError 異常
OutOfMemoryError 異常應該可以算得上是一個非常棘手的問題。JAVA 的程序員不用像苦逼的 C 語言程序員手動地管理內存,JVM 幫助他們分配內存,釋放內存。但是當遇到內存相關的問題,就比如 OutOfMemoryError,如何去排查並且解決就變成一個非常令人頭疼的問題。在 JAVA 中,所有的對象都存儲在堆中,通常如果 JVM 無法再分配新的內存,內存耗盡,並且垃圾回收器無法及時回收內存,就會拋出 OutOfMemoryError。
我之前在做一個工具,需要讀取大量的文件,比如 word 或者 excel,而我給機器分配的最大的內存只有 2G。所以,很多人的機器往往會因為 OutOfMemoryError 異常導致程序中止運行。後來我發現一個現象,OutOfMemoryError 可以通過 Error 或者 Throwable 去捕獲,OutOfMemoryError 類繼承關係如下:
因此 OutOfMemoryError 是一個 Error 而不是一個 Exception,並且據我觀察,OutOfMemoryError 無法被 throw 到上一層函數中。
發生 OutOfMemoryError 的原因
越早找出 OutOfMemoryError 的原因就越利於我們解決問題。到底是因為 JAVA 的堆滿了還是因為原生堆就滿了呢?為了找到其原因,我們可以通過異常的細節信息來獲得提示。
Exception in thread thread_name: java.lang.OutOfMemoryErrorError: Java heap space
這是一個非常常見的情況,大多數 OutOfMemoryError 的異常都是因為這個原因導致的。這個細節信息表示在 JAVA 堆中無法再分配對象。這個錯誤並不代表你的程序一定發生了內存泄漏。可能很簡單這就是一個配置的問題,可能默認的堆內存(JVM 設置的內存)無法滿足應用的需求。
另外,也有可能是在一些長時間運行的程序中,可能是一直保持著對某些對象的引用(實際上這些對象已經不需要了),這會阻止垃圾回收器收集內存從而無法分配新的內存空間。這就等同於是一個內存泄漏。
另外一個潛在的原因可能是對於 finalize 方法的過度使用。如果某個類具有 finalize 方法,那麼屬於這種類的對象在垃圾回收時就不會回收空間。而是在垃圾回收之後,對象會在一個隊列中等待析構,這通常會發生的遲一些。在 Oracle Sum 公司的實現中,finalizer 是通過一個為 finalization 隊列提供服務的守護線程來執行。如果 finalizer 線程的速度沒有辦法跟上 finalization 隊列速度的時候,那麼 JAVA 堆就會填滿接著就會拋出 OutOfMemoryError 異常。
Exception in thread thread_name: java.lang.OutOfMemoryErrorError: GC Overhead limit exceeded
這是另外一個常見的異常信息,這個信息一般表示 JAVA 程序運行很緩慢並且垃圾回收器一直在運行。在垃圾回收之後,如果 JAVA 進程花費超過 98% 的時間來做垃圾回收,如果在連續的 5次垃圾回收中恢復少於 2% 的堆內存,就會拋出 OutOfMemoryError 異常。一般這種情況下是因為生成大量的數據佔用 JAVA 堆內存從而沒有辦法分配新的內存。通俗的來講,垃圾回收器回收的速度還沒有辦法跟上內存分配的速度。這就好比有戶人家家裡是有點財產,但財產是有限的,雖然能定時收回來一些,但是禁不住家裡有個敗家子,所以遲早有一天會破產(OutOfMemoryError)。
不過對於GC Overhead limit exceeded可以通過命令行標誌 來進行關閉,雖然最終還是可能還是會拋出 OutOfMemoryError 異常。
Exception in thread thread_name: java.lang.OutOfMemoryErrorError: Requested array size exceeds VM limit
這個異常信息表示應用程序嘗試給數組分配一個大於堆大小的數組。比如,如果程序嘗試分配一個 512 MB 大小的數組,但是堆大小最大只有 256MB,那麼 OutOfMemoryError 異常則會被拋出。導致這種異常信息的原因一般要麼就是配置的問題(堆內存太小),要麼就是程序的 BUG,嘗試分配太大的數組。
Exception in thread thread_name: java.lang.OutOfMemoryErrorError: Metaspace
Java 類 metadata(Java 類虛擬機內部的表示) 使用原生內存(這裡指的是 metaspace)來進行分配。如果用於 metadata 的 metaspace 耗盡了,那麼具有這個異常信息的 OutOfMemoryError 異常就會被拋出。Metaspace 的總數受限於參數 MaxMetaSpaceSize,這個可以通過命令行來進行設置。當分配給 metadata 原生的內存總數超過了 MaxMetaSpaceSize,那麼帶有這個異常信息的 OutOfMemoryError 異常就會被拋出。MetaSpace 和 JAVA 堆從同樣的地址空間進行分配。減少 JAVA 堆的大小就會增加 MetaSpace 的空間。
Exception in thread thread_name: java.lang.OutOfMemoryErrorError: request size bytes for reason. Out of swap space?
這個異常信息看起來是一個 OutOfMemoryError 異常。然而,當原生堆無法分配內存或者原生堆可能接近耗盡的時候,Java HotSpot VM 代碼就會報這個異常。通常這個異常信息的原因是源代碼模塊報告分配失敗,儘管有時候的確是這個原因。當這個錯誤消息被拋出時,VM 會調用致命錯誤處理機制(即它會生成一個致命的錯誤日誌文件,其中包含有關崩潰時線程,進程和系統的有用信息)。 在本地堆耗盡的情況下,日誌中的堆內存和內存映射信息可能很有用。如果拋出 OutOfMemoryErrorError 異常,則可能需要在操作系統上使用故障排除實用程序來進一步診斷問題。
Exception in thread thread_name: java.lang.OutOfMemoryError: Compressed class space
在 64 位平台上,指向 metadata 類的指針可以用32位偏移量(使用 UseCompressedOops)表示。這由命令行標誌 UseCompressedClassPointers(默認為on)控制。如果使用 UseCompressedClassPointers,則metadata 類的可用空間量將固定為 CompressedClassSpaceSize。如果 UseCompressedClassPointers 所需的空間超過 CompressedClassSpaceSize,則會拋出一個包含詳細 Compressed 類空間的java.lang.OutOfMemoryError。增加CompressedClassSpaceSize 可以關閉 UseCompressedClassPointers。CompressedClassSpaceSize 的可接受大小存在界限。例如 ,超過可接受的範圍將導致如下消息:
注意:有多種類型的元數據類- klass metadata 和其他 metadata。只有 klass metadata 存儲在由 CompressedClassSpaceSize 限定的空間中。其他 metadata 存儲在 Metaspace 中。
Exception in thread threadname: java.lang.OutOfMemoryError: reason stacktracewithnative_method
如果異常信息是這個,並且列印了堆棧跟蹤,其中第一幀是本機方法,則表明本機方法遇到了分配故障。 這與之前的消息之間的區別在於分配失敗是在 Java 本地介面(JNI)或本機方法中檢測到的,而不是在JVM代碼中檢測到的。如果拋出此類 OutOfMemoryError 異常,則可能需要使用操作系統的本機實用程序來進一步診斷問題。
解決辦法
以上說到了多種 OutOfMemoryError 異常的情況以及其可能的原因,那麼應該如何解決 OutOfMemoryError 異常呢?發生這種異常的原因其實是多種多樣的,有的時候可能是程序的 BUG,導致了內存泄漏。有的時候可能就是設置問題,內存設置太小,只要設置大一點就可以了。有的時候也不一定就是內存泄漏,可能就是程序分配的內存無法處理,這時候就需要你想辦法來進行優化,避免內存的消耗,或者準確的來說盡量避免一次性分配太多的內存,從而導致內存分配失敗。以下,就我自己的一些經驗,談談一些解決辦法。
最簡單,最粗暴的方法就是直接調整 JVM 的堆大小。通過 參數可以設置 JAVA 堆最大內存,一般來說如果你一開始分配的內存過小,則可以通過這樣的設置來避免。參數的設置應該根據程序的運行情況和機器的實際內存決定的,一般來說 JVM 的堆大小不應該超過機器內存的一半。通過調整參數設置或許可以解決一時的問題,但是往往只是推遲了 OutOfMemoryError 發生的時間,但是找到程序的關鍵問題,查出內存消耗的關鍵點才是根本之道。
另外一種常見的避免異常的方法就是記得關閉輸入流。經常有人打開文件的時候,忘記最後關閉輸入流,倘若發生了異常,就會導致輸入流沒有關閉。常見的做法就是在 關閉輸入流,因為在 中最後都會執行這一步驟。在 JAVA7 就可以通過 實現資源的自動關閉:
字元串和 List 是 JAVA 中經常使用的數據類型。其實 JAVA 內置已經做了很多針對於 String 的優化,個人可以做的優化其實已經微乎其微了。開發者可以做的是就是檢查程序字元串的分配,是否進行了一些沒有必要的字元串操作,反正就是能省一點是一點。另外就是對於動態數組類型的數據,盡量可以使用 ArrayList。ArrayList是實現了基於動態數組的數據結構,而LinkedList是基於鏈表的數據結構。一般來說,對於數據的操作,對於數據的查詢 ArrayList 的效率更高,但是如果是刪除或者插入,那麼 LinkedList 的效率就更勝一籌了。ArrayList 的空間浪費主要體現在在 list 列表的結尾預留一定的容量空間,而 LinkedList 的空間花費則體現在它的每一個元素都需要消耗相當的空間。因為 ArrayList 的實現是基於動態數組,ArrayList 在動態拓展大小的時候都是以 1.5 倍的比率增加的,這樣導致當 ArrayList 已經很大的時候,其動態拓展時需要分配更多的空間。另外一小點就是通過 可以減少 ArrayList 佔用的空間,但是確保之後不會再添加新的元素就可以了。
另外一種常見的情況就是讀取文件,比如 txt 文件以及 excel 或者 word 文件。我開發的程序就是需要讀取大量的文件,而 OutOfMemoryError 往往就是因為文件讀取導致的。通過 讀取 txt 文件可以通過分隔符控制一次讀取的文本量的大小(useDelimiter),從而避免一次讀取大量的文本。對於 word 和 excel 的讀取,POI 可以說得上是最優秀的方案,之前我寫過一篇文章POI 讀取文件的最佳實踐,這篇文章總結了使用 POI 讀取 word 和 excel 文件遇到的一些坑,我覺得可以算得上是國內網上比較好關於這方面的文章。老版本的 word 或者 excel 是二進位數據,而之後的版本本質上其實就是壓縮文件。如果你將 docx 文件使用壓縮文件打開,可以觀察其內部組成。所以,雖然 word 或者 excel 文件的大小可能不是很誇張,但是在讀取器內存的時候,往往需要消耗大量的內存。對於 excel 文件的讀取,可以採取流式的方式去讀去,將特別大的文件拆分成臨時的小文件再進行讀取,從而避免內存溢出。網上就有一個優秀的第三方庫 excel-streaming-reader。另外一個做的優化就是,對於可以使用 File 對象的場景下,我是去使用 File 對象去讀取文件而不是使用 InputStream 去讀取,因為使用 InputStream 需要把它全部載入到內存中,所以這樣是非常佔用內存的。
還有一點就是開發思維上的一些注意事項,避免長時間的對同一變數進行操作,比如一直操作數組,不斷添加新的元素,這樣的確很容易造成 OutOfMemoryError 異常。可以分批進行操作,從而避免無限制的擴大內存,最終導致內存耗盡。
總而言之,導致內存溢出的原因可能各種各樣,可能不是某單單一個原因導致的,其表現可能也不是穩定的。這也就是 OutOfMemoryError 為什麼排查起來比較困難,也比較難解決。有時候可能也需要藉助於性能分析工具,比如 dump 內存日誌或者使用 jdk 自帶的 jvm 性能分析工具 jConsole 分析內存的使用情況排查問題。
TAG:madMen |