在Java中,和內存相關的問題主要有兩種,內存溢出和內存泄漏。
- 內存溢出(Out Of Memory) :就是申請內存時,JVM沒有足夠的內存空間。通俗說法就是去蹲坑發現坑位滿了。
- 內存泄露 (Memory Leak):就是申請了內存,但是沒有釋放,導致內存空間浪費。通俗說法就是有人占着茅坑不拉屎。
1、內存溢出
在JVM的幾個內存區域中,除了程序計數器外,其他幾個運行時區域都有發生內存溢出(OOM)異常的可能。
1.1、Java堆溢出
Java堆用於儲存對象實例,我們只要不斷地創建對象,並且保證GC Roots到對象之間有可達路徑來避免垃圾回收機制清除這些對象,那么隨着對象數量的增加,總容量觸及最大堆的容量限制后就會產生內存溢出異常。
我們來看一個代碼的例子:
/**
* VM參數: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<OOMObject>();
while (true) {
list.add(new OOMObject());
}
}
}
接下來,我們來設置一下程序啟動時的JVM參數。限制內存大小為20M,不允許擴展,並通過參數-XX:+HeapDumpOnOutOf-MemoryError 讓虛擬機Dump出內存堆轉儲快照。
在Idea中設置JVM啟動參數如下圖:
運行一下:
Java堆內存的OutOfMemoryError異常是實際應用中最常見的內存溢出異常情況。出現Java堆內存溢出時,異常堆棧信息“java.lang.OutOfMemoryError”會跟隨進一步提示“Java heap space”。 Java堆文件快照文件dump到了java_pid18728.hprof文件。
要解決這個內存區域的異常,常規的處理方法是首先通過內存映像分析工具(如JProfiler、Eclipse Memory Analyzer等)對Dump出來的堆轉儲快照進行分析。
看到內存占用信息如下:
然后可以查看代碼問題如下:
常見堆JVM相關參數:
-XX:PrintFlagsInitial
: 查看所有參數的默認初始值-XX:PrintFlagsFinal
:查看所有的參數的最終值(可能會存在修改,不再是初始值)
-Xms
: 初始堆空間內存(默認為物理內存的1/64)
-Xmx
: 最大堆空間內存(默認為物理內存的1/4)
-Xmn
: 設置新生代大小(初始值及最大值)
-XX:NewRatio
: 配置新生代與老年代在堆結構的占比
-XX:SurvivorRatio
:設置新生代中Eden和S0/S1空間的比例
-XX:MaxTenuringThreshold
:設置新生代垃圾的最大年齡(默認15)
-XX:+PrintGCDetails
:輸出詳細的GC處理日志
打印GC
簡要信息:①-XX:+PrintGC
②-verbose:gc
-XX:HandlePromotionFailure
:是否設置空間分配擔保
1.2、虛擬機棧和本地方法棧溢出
HotSpot虛擬機中將虛擬機棧和本地方法棧合二為一,因此對於HotSpot來說,-Xoss參數(設置本地方法棧大小)雖然存在,但實際上是沒有任何效果的,棧容量只能由-Xss參數來設定。關於虛擬機棧和本地方法棧,有兩種異常:
-
如果線程請求的棧深度大於虛擬機所允許的最大深度,將拋出
StackOverflowError
異常。 -
如果虛擬機的棧內存允許動態擴展,當擴展棧容量無法申請到足夠的內存時,將拋出
OutOfMemoryError
異常。
1.2.1、StackOverflowError
HotSpot虛擬機不支持棧的動態擴展,在HotSpot虛擬機中,以下兩種情況都會導致StackOverflowError。
-
棧容量過小
如下,使用Xss參數減少棧內存容量
/**
* vm參數:-Xss128k
*/
public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + oom.stackLength);
throw e;
}
}
}
運行結果:
-
棧幀太大
如下,通過一長串變量,來占用局部變量表空間。
運行結果:
無論是由於棧幀太大還是虛擬機棧容量太小,當新的棧幀內存無法分配的時候, HotSpot虛擬機拋出的都是StackOverflowError異常。
1.2.2、OutOfMemoryError
雖然不支持動態擴展棧,但是通過不斷建立線程的方式,也可以在HotSpot上產生內存溢出異常。
需要注意,這樣產生的內存溢出異常和棧空間是否足夠並不存在任何直接的關系,主要取決於操作系統本身的內存使用狀態。因為操作系統給每個進程的內存時有限的,線程數一多,自然會超過進程的容量。
創建線程導致內存溢出異常 :
/**
* vm參數:-Xss2M
*/
public class JavaVMStackOOM {
private void dontStop() {
while (true) {
}
}
public void stackLeakByThread() {
while (true) {
Thread thread = new Thread(new Runnable() {
public void run() {
dontStop();
}
});
thread.start();
}
}
public static void main(String[] args) throws Throwable {
JavaVMStackOOM oom = new JavaVMStackOOM();
oom.stackLeakByThread();
}
}
以上是一段比較有風險的代碼,可能會導致系統假死,運行結果如下:
1.3、方法區和運行時常量池溢出
這里再提一下方法區和運行時常量池的變遷,JDK1.7以后字符串常量池移動到了堆中,JDK1.8在直接內存中划出一塊區域元空間來實現方區域。
String:intern()是一個本地方法,它的作用是如果字符串常量池中已經包含一個等於此String對象的 字符串,則返回代表池中這個字符串的String對象的引用;否則,會將此String對象包含的字符串添加到常量池中,並且返回此String對象的引用。在JDK 6或更早之前的HotSpot虛擬機中,常量池都是分配在永久代中,永久代本身內存不限制可能會出現錯誤:
java.lang.OutOfMemoryError: PermGen space
1.4、本機直接內存溢出
直接內存(Direct Memory)的容量大小可通過-XX:MaxDirectMemorySize參數來指定,如果不去指定,則默認與Java堆最大值(由-Xmx指定)一致。
直接通過反射獲取Unsafe
實例,通過反射向操作系統申請分配內存:
/**
* vm參數:-Xmx20M -XX:MaxDirectMemorySize=10M
*/
public class DirectMemoryOOM {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws Exception {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(_1MB);
}
}
}
運行結果:
由直接內存導致的內存溢出,一個明顯的特征是在Heap Dump文件中不會看見有什么明顯的異常情況。
2、內存泄漏
內存回收,簡單說就是應該被垃圾回收的對象沒有被垃圾回收。
在上圖中:對象 X 引用對象 Y,X 的生命周期比 Y 的生命周期長,Y生命周期結束的時候,垃圾回收器不會回收對象Y。
我們來看幾個內存泄漏的例子:
-
靜態集合類引起內存泄漏
靜態集合的生命周期和 JVM 一致,所以靜態集合引用的對象不能被釋放。
public class OOM {
static List list = new ArrayList();
public void oomTests(){
Object obj = new Object();
list.add(obj);
}
}
-
單例模式:
和上面的例子原理類似,單例對象在初始化后會以靜態變量的方式在 JVM 的整個生命周期中存在。如果單例對象持有外部的引用,那么這個外部對象將不能被 GC 回收,導致內存泄漏。
-
數據連接、IO、Socket等連接
創建的連接不再使用時,需要調用 close 方法關閉連接,只有連接被關閉后,GC 才會回收對應的對象(Connection,Statement,ResultSet,Session)。忘記關閉這些資源會導致持續占有內存,無法被 GC 回收。
try {
Connection conn = null;
Class.forName("com.mysql.jdbc.Driver");
conn = DriverManager.getConnection("url", "", "");
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("....");
} catch (Exception e) {
}finally {
//不關閉連接
}
}
-
變量不合理的作用域
一個變量的定義作用域大於其使用范圍,很可能存在內存泄漏;或不再使用對象沒有及時將對象設置為 null,很可能導致內存泄漏的發生。
public class Simple {
Object object;
public void method1(){
object = new Object();
//...其他代碼
//由於作用域原因,method1執行完成之后,object 對象所分配的內存不會馬上釋放
object = null;
}
}
-
引用了外部類的非靜態內部類
非靜態內部類(或匿名類)的初始化總是需要依賴外部類的實例。默認情況下,每個非靜態內部類都包含對其包含類的隱式引用,若在程序中使用這個內部類對象,那么即使在包含類對象超出范圍之后,也不會被回收(內部類對象隱式地持有外部類對象的引用,使其成不能被回收)。
-
Hash 值發生改變
對象Hash值改變,使用HashMap、HashSet等容器中時候,由於對象修改之后的Hah值和存儲進容器時的Hash值不同,會導致無法從容器中單獨刪除當前對象,造成內存泄露。
-
ThreadLocal 造成的內存泄漏
ThreadLocal 可以實現變量的線程隔離,但若使用不當,就可能會引入內存泄漏問題。
參考:
【1】:周志朋編著《深入理解Java虛擬機:JVM高級特性與最佳實踐》
【2】:周志朋等翻譯《Java虛擬機規范》
【3】:封亞飛編著《揭秘Java虛擬機 JVM設計原理與實現》
【4】:Java 中的內存溢出和內存泄露是什么?我給你舉個有味道的例子
【6】:Intellij IDEA 集成 JProfiler 性能分析神器