JVM的內存模型介紹一下
面試官您好,您問的“JVM內存模型”,這是一個非常核心的問題。在Java技術體系中,這個術語通常可能指代兩個不同的概念:一個是JVM的運行時數據區,另一個是Java內存模型(JMM)。前者是JVM的內存布局規范,描述了內存被劃分成哪些區域;后者是并發編程的抽象模型,定義了線程間如何通過內存進行通信。
我先來介紹一下JVM的運行時數據區,這通常是大家更常提到的“內存模型”。
一、 JVM運行時數據區 (The Structure)
根據Java虛擬機規范,JVM在執行Java程序時,會把它所管理的內存劃分為若干個不同的數據區域。這些區域可以分為兩大類:線程共享的和線程私有的。
【線程共享區域】
這些區域的數據會隨著JVM的啟動而創建,隨JVM的關閉而銷毀,并且被所有線程共享。
-
堆 (Heap)
- 這是JVM內存中最大的一塊。它的唯一目的就是存放對象實例和數組。我們通過
new
關鍵字創建的所有對象,都在這里分配內存。 - 堆是垃圾回收器(GC) 工作的主要區域。為了方便GC,堆內存通常還會被細分為新生代(Eden區、Survivor區)和老年代。
- 這是JVM內存中最大的一塊。它的唯一目的就是存放對象實例和數組。我們通過
-
方法區 (Method Area)
- 它用于存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器(JIT)編譯后的代碼緩存等數據。
- 可以把它理解為一個“元數據”區。
- 在HotSpot JVM中,方法區的實現在不同JDK版本中有所演變:
- JDK 7及以前:方法區被稱為 “永久代”(Permanent Generation) ,是堆的一部分。
- JDK 8及以后:永久代被徹底移除,取而代之的是 “元空間”(Metaspace),它使用的是本地內存(Native Memory),而不再是JVM堆內存。這樣做的好處是元空間的大小只受限于本地內存,不容易出現OOM。
-
運行時常量池 (Runtime Constant Pool)
- 它是方法區的一部分。用于存放編譯期生成的各種字面量和符號引用。
【線程私有區域】
這些區域的生命周期與線程相同,隨線程的創建而創建,隨線程的銷毀而銷毀。
-
Java虛擬機棧 (Java Virtual Machine Stack)
- 每個線程都有一個獨立的虛擬機棧。它用于存儲棧幀(Stack Frame)。
- 每當一個方法被調用時,JVM就會創建一個棧幀,并將其壓入棧中。棧幀里存儲了局部變量表、操作數棧、動態鏈接、方法出口等信息。
- 當方法執行完畢后,對應的棧幀就會被彈出。我們常說的“棧內存”就是指這里。如果線程請求的棧深度大于虛擬機所允許的深度,會拋出
StackOverflowError
。
-
本地方法棧 (Native Method Stack)
- 與虛擬機棧非常相似,區別在于它為虛擬機使用到的
native
方法(即由非Java語言實現的方法)服務。
- 與虛擬機棧非常相似,區別在于它為虛擬機使用到的
-
程序計數器 (Program Counter Register)
- 這是一塊非常小的內存空間,可以看作是當前線程所執行的字節碼的行號指示器。
- 字節碼解釋器工作時,就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令。
- 它是唯一一個在Java虛擬機規范中沒有規定任何
OutOfMemoryError
情況的區域。
二、 Java內存模型 (JMM - The Concurrency Model)
如果說運行時數據區是物理層面的內存劃分,那么Java內存模型(JMM)就是并發編程領域的抽象規范。它不是真實存在的內存結構,而是一套規則。
- 目的:JMM的核心目的是為了屏蔽各種硬件和操作系統的內存訪問差異,讓Java程序在各種平臺上都能達到一致的內存訪問效果,從而實現“一次編寫,到處運行”的承諾。
- 核心內容:它定義了線程和主內存之間的抽象關系。
- 主內存 (Main Memory):所有線程共享的區域,存儲了所有的實例字段、靜態字段等。這可以粗略地對應于堆。
- 工作內存 (Working Memory):每個線程私有的區域,存儲了該線程需要使用的變量在主內存中的副本拷貝。這可以粗略地對應于CPU的高速緩存。
- 三大特性:JMM圍繞著在并發過程中如何處理原子性(Atomicity)、可見性(Visibility)和有序性(Ordering)這三個核心問題,定義了一系列的同步規則,比如
volatile
、synchronized
、final
的內存語義,以及著名的Happens-Before原則。
總結一下:
- 運行時數據區是JVM的內存藍圖,告訴我們數據都存放在哪里。
- Java內存模型(JMM)是并發編程的行為準則,告訴我們線程間如何安全地共享和通信數據。
JVM內存模型里的堆和棧有什么區別?
面試官您好,堆和棧是JVM運行時數據區中兩個最核心、但功能和特性截然不同的內存區域。它們的區別,我通常從以下幾個維度來理解。
一個貫穿始終的比喻:快餐店的點餐與后廚
我們可以把一次程序運行想象成在一家快餐店點餐:
- 棧(Stack) 就像是前臺的點餐流程單。
- 堆(Heap) 就像是后廚的中央廚房。
1. 核心用途與存儲內容 (做什么?)
-
棧 (點餐流程單):
- 用途:主要用于管理方法的調用和存儲基本數據類型變量以及對象引用。
- 內容:每當一個方法被調用,JVM就會創建一個“棧幀”(就像流程單上的一行),里面記錄了這個方法的所有局部變量、操作數、方法出口等信息。
- 比喻:點一個漢堡(調用一個方法),服務員就在流程單上記下一筆。
-
堆 (中央廚房):
- 用途:是JVM中唯一用來存儲對象實例和數組的地方。
- 內容:我們通過
new
關鍵字創建的所有對象,其實體都存放在堆中。棧上的那個對象引用,僅僅是一個指向堆中對象實體的“門牌號”或“地址”。 - 比喻:流程單上記的“漢堡”,只是一個名字(引用)。真正的漢堡實體(對象實例),是在后廚(堆)里制作和存放的。
2. 生命周期與管理方式 (誰管?怎么管?)
-
棧 (自動化的流程單):
- 生命周期:非常規律和確定。一個方法調用開始,其對應的棧幀就被壓入棧頂;方法執行結束,棧幀就自動彈出并銷毀。
- 管理方式:由編譯器和JVM自動管理,無需我們程序員干預,也沒有垃圾回收(GC)。
-
堆 (需要專人管理的廚房):
- 生命周期:不確定。一個對象的生命周期從
new
開始,直到沒有任何引用指向它時才結束。 - 管理方式:由垃圾回收器(GC) 來自動管理。GC會定期地巡視堆,找出那些不再被使用的“無主”對象(垃圾),并回收它們占用的空間。
- 生命周期:不確定。一個對象的生命周期從
3. 空間大小與存取速度 (多大?多快?)
-
棧:
- 空間:通常較小且大小是固定的(可以通過
-Xss
參數設置)。 - 速度:非常快。因為棧的數據結構簡單(LIFO),內存是連續的,CPU可以高效地進行壓棧和彈棧操作。
- 空間:通常較小且大小是固定的(可以通過
-
堆:
- 空間:通常較大且大小是可動態調整的(可以通過
-Xms
和-Xmx
設置)。 - 速度:相對較慢。因為內存分配是不連續的,并且分配和回收的過程都比棧要復雜。
- 空間:通常較大且大小是可動態調整的(可以通過
4. 線程共享性與可見性 (公有還是私有?)
-
棧:線程私有。每個線程都有自己獨立的虛擬機棧。一個線程不能訪問另一個線程的棧空間,因此棧上的數據天然是線程安全的。
-
堆:所有線程共享。整個JVM進程只有一個堆。這意味著任何線程都可以通過引用訪問堆上的同一個對象。這也正是多線程并發問題的根源所在,我們需要通過各種鎖機制來保證對堆上共享對象訪問的安全性。
5. 典型的異常
這兩種內存區域如果使用不當,會分別導致兩種最經典的JVM異常:
StackOverflowError
(棧溢出):通常是由于方法遞歸調用過深(流程單寫得太長,超出了紙的范圍),或者棧幀過大導致的。OutOfMemoryError: Java heap space
(堆溢出):通常是由于創建了大量的對象實例,并且這些對象由于被持續引用而無法被GC回收(后廚的東西太多,放不下了),最終耗盡了堆內存。
通過這個全方位的對比,我們就能清晰地理解堆和棧在JVM中所扮演的不同角色和承擔的不同職責了。
棧中存的到底是指針還是對象?
面試官您好,您這個問題問到了JVM內存管理的一個核心細節。最精確的回答是:棧中既不存指針,也不直接存對象,它存的是“基本類型的值”和“對象的引用”。
我們可以通過一個具體的代碼例子和生活中的比喻來理解它。
1. 一個具體的代碼例子
假設我們有下面這樣一個方法:
public void myMethod() {// 1. 基本數據類型int age = 30; // 2. 對象引用類型String name = "Alice"; // 3. 數組引用類型int[] scores = new int[3];
}
當myMethod()
被調用時,JVM會為它在當前線程的虛擬機棧上創建一個棧幀。這個棧幀的“局部變量表”里會存放以下內容:
-
對于
int age = 30;
:age
是一個基本數據類型。JVM會直接在棧幀里為age
分配一塊空間,并將值30
本身存放在這塊空間里。
-
對于
String name = "Alice";
:name
是一個對象引用。JVM的處理分為兩步:- 在堆(Heap)中創建一個
String
對象,其內容是 “Alice”。 - 在棧幀中為
name
變量分配一塊空間,這塊空間里存放的不是"Alice"這個字符串本身,而是一個指向堆中那個String
對象的內存地址。這個地址,我們就稱之為 “引用”(Reference)。
- 在堆(Heap)中創建一個
-
對于
int[] scores = new int[3];
:scores
也是一個對象引用(在Java中,數組是對象)。- 處理方式與
String
類似:- 在堆中創建一個可以容納3個整數的數組對象。
- 在棧幀中為
scores
變量分配空間,存放一個指向堆中那個數組對象的引用。
2. 一個生動的比喻:酒店房間與房卡
我們可以把這個過程比喻成入住一家酒店:
- 堆(Heap):就像是酒店本身,里面有許多實實在在的房間(對象實例)。
- 棧(Stack):就像是你手里的那張房卡(對象引用)。
- 基本類型:就像是你口袋里的零錢(值),你直接就帶在身上。
那么:
new String("Alice")
:相當于酒店為你分配了一間房間(在堆上創建對象)。String name = ...
:酒店前臺給了你一張房卡(在棧上創建引用),這張房卡上有房間號,可以讓你找到并打開那間房。- 你手里拿的,永遠是房卡(引用),而不是整個房間(對象)。你想找房間里的東西,必須先通過房卡找到房間。
3. 總結:棧到底存了什么?
- 基本數據類型:直接存儲值本身。
- 引用數據類型:存儲一個引用(內存地址),這個引用指向堆中存放的對象實例。
所以,嚴格來說,棧中存的既不是C++意義上的“指針”(雖然功能類似,但Java的引用是類型安全的,且由JVM管理),更不是對象本身。它存的是一個受JVM管理的、類型安全的、指向堆內存的“門牌號”——我們稱之為“引用”。
堆分為哪幾部分呢?
面試官您好,JVM的堆內存是垃圾回收器(GC)進行管理的主要區域,為了優化GC的效率,特別是為了實現分代回收(Generational GC) 的思想,HotSpot虛擬機通常會將堆劃分為以下幾個主要部分:
1. 新生代 (Young Generation / New Generation)
新生代是絕大多數新創建對象的“第一站”。它的主要特點是對象“朝生夕死”,存活率低。因此,新生代通常采用復制算法(Copying Algorithm) 進行垃圾回收,這種算法在對象存活率低的場景下效率非常高。
新生代內部又被細分為三個區域:
-
a. Eden區 (Eden Space)
- 這是絕大多數新對象誕生的地方。當我們
new
一個對象時,它首先會被分配在Eden區。 - Eden區的空間是連續的,分配速度很快。
- 這是絕大多數新對象誕生的地方。當我們
-
b. 兩個Survivor區 (Survivor Space)
- 通常被稱為From區(S0)和To區(S1)。
- 這兩個區的大小是完全一樣的,并且在任何時候,總有一個是空閑的。
- 它們的作用:當Eden區進行垃圾回收(這個過程通常被稱為Minor GC或Young GC)時,存活下來的對象會被復制到那個空閑的Survivor區(To區)。同時,另一個正在使用的Survivor區(From區)中還存活的對象,也會被一并復制到這個To區。
- 復制完成后,Eden區和From區就被完全清空了。然后,From區和To區的角色會發生互換,等待下一次Minor GC。
2. 老年代 (Old Generation / Tenured Generation)
老年代用于存放那些生命周期較長的對象,或者是一些大對象。
-
對象來源:
- 從新生代晉升:一個對象在新生代的Survivor區之間,每經歷一次Minor GC并且存活下來,它的年齡(Age)就會加1。當這個年齡達到一個閾值(默認是15)時,它就會被“晉升”到老年代。
- 大對象直接分配:如果一個對象非常大(比如一個巨大的數組),超過了JVM設定的閾值(可以通過
-XX:PretenureSizeThreshold
參數設置),為了避免它在新生代的Eden區和Survivor區之間頻繁復制,JVM會選擇將其直接分配在老年代。
-
GC算法:老年代的對象特點是存活率高,不適合用復制算法(因為需要復制的對象太多,空間浪費也大)。因此,老年代的垃圾回收(通常被稱為Major GC或Full GC)通常采用標記-清除(Mark-Sweep)或標記-整理(Mark-Compact) 算法。
一個對象的“一生”
我們可以用一個故事來描繪一個普通對象的生命周期:
- 出生:一個對象在Eden區誕生。
- 第一次考驗:經歷了一次Minor GC,它幸運地活了下來,被移動到了Survivor的To區,年齡變為1。
- 顛沛流離:在接下來的多次Minor GC中,它在S0區和S1區之間來回被復制,每次存活,年齡都會加1。
- 晉升:當它的年齡終于達到15歲時,它被認為是一個“穩定”的對象,在下一次Minor GC后,它會被晉升到老年代。
- 定居與終老:在老年代,它會“定居”下來,不再經歷頻繁的Minor GC。它會等待很久之后,發生Major GC或Full GC時,才會被檢查是否還在被使用。如果最終不再被任何引用指向,它才會被回收,結束其一生。
這種分代的設計,使得JVM可以針對不同生命周期的對象,采用最高效的回收策略,從而大大提升了GC的整體性能。
程序計數器的作用,為什么是私有的?
面試官您好,程序計數器(Program Counter Register)是JVM運行時數據區中一塊非常小但至關重要的內存區域。要理解它,我們可以從 “它是什么” 和 “為什么必須是線程私有” 這兩個角度來看。
1. 程序計數器的作用 (What is it?)
- 核心定義:程序計數器可以看作是當前線程所執行的字節碼的行號指示器。
- 它的工作:在JVM中,字節碼解釋器就是通過讀取和改變程序計數器的值,來確定下一條需要執行的字節碼指令。分支、循環、跳轉、異常處理、線程恢復等基礎功能,都依賴于這個計數器來完成。
- 一個重要的細節:
- 如果當前線程正在執行的是一個Java方法,那么程序計數器記錄的就是正在執行的虛擬機字節碼指令的地址。
- 如果當前線程正在執行的是一個
native
方法(本地方法),那么這個計數器的值是空(Undefined)。因為native
方法是由底層操作系統或其他語言實現的,不受JVM字節碼解釋器的控制。
2. 為什么程序計數器必須是線程私有的?(Why is it private?)
其根本原因就在于Java的多線程是通過CPU時間片輪轉來實現的。
-
場景分析:
- 現代操作系統都是多任務的,CPU會在多個線程之間高速地進行上下文切換。
- 比如,線程A的當前時間片用完了,操作系統需要暫停它,然后切換到線程B去執行。
- 在暫停線程A之前,必須記錄下它“剛才執行到哪里了”。這個“位置信息”,就是由程序計數器來保存的。
- 當未來某個時刻,線程A重新獲得CPU時間片時,它就需要恢復現場,從它上次被中斷的地方繼續執行。這時,它就會去查看自己的程序計數器,找到下一條應該執行的指令。
-
結論:
- 因為每個線程的執行進度都是獨立且不一樣的,它們在任何時刻都可能被中斷。為了在切換回來后能準確地恢復到正確的執行位置,每個線程都必須擁有自己專屬的、互不干擾的程序計數器。
- 如果所有線程共享一個程序計數器,那么一個線程的執行就會覆蓋掉另一個線程的進度記錄,整個執行流程就會徹底混亂。
3. 一個獨特的特性
值得一提的是,程序計數器是JVM運行時數據區中唯一一個在Java虛擬機規范中沒有規定任何OutOfMemoryError
情況的區域。因為它所占用的內存空間非常小且固定,幾乎可以忽略不計。
總結一下,程序計數器就像是每個線程專屬的 “書簽”,它忠實地記錄著每個線程的閱讀進度,確保了在并發執行和頻繁切換的復雜環境下,每個線程都能準確無誤地繼續自己的執行流程。因此,它的“線程私有”特性,是實現多線程正確性的根本保障。
方法區中的方法的執行過程?
面試官您好,雖然方法本身的代碼是存放在方法區的,但一個方法的執行過程,其主戰場卻是在Java虛擬機棧(JVM Stack) 中。
整個過程,可以看作是一個棧幀(Stack Frame)在虛擬機棧中“入棧”和“出棧” 的旅程。
我通過一個簡單的例子來描述這個動態過程:
public class MethodExecution {public static void main(String[] args) {int result = add(3, 5); // 1. 調用add方法System.out.println(result);}public static int add(int a, int b) { // 2. add方法int sum = a + b;return sum; // 3. 返回}
}
第一步:方法調用與棧幀創建 (入棧)
- 解析:當
main
線程執行到add(3, 5)
這行代碼時,JVM首先需要找到add
方法在方法區的具體位置(如果之前沒解析過的話)。 - 創建棧幀:在調用
add
方法之前,JVM會在main
線程的虛擬機棧中,為add
方法創建一個新的棧幀(我們稱之為add-Frame
),并將其壓入棧頂。- 此時,
main
方法對應的棧幀(main-Frame
)就在add-Frame
的下方。 - 這個
add-Frame
就像一個專屬的工作空間,它里面包含了:- 局部變量表:用于存放
add
方法的參數a
和b
(值分別為3和5),以及局部變量sum
。 - 操作數棧:一個臨時的計算區域,用于執行加法等操作。
- 動態鏈接:指向運行時常量池中該方法所屬類的引用。
- 方法返回地址:記錄了
add
方法執行完畢后,應該回到main
方法中的哪一行代碼繼續執行。
- 局部變量表:用于存放
- 此時,
第二步:方法體執行
- 參數傳遞:
main-Frame
中的操作數3和5,會被傳遞到add-Frame
的局部變量表中,賦值給a
和b
。 - 字節碼執行:CPU開始執行
add
方法的字節碼指令。- 將局部變量
a
和b
的值加載到add-Frame
的操作數棧上。 - 執行加法指令,從操作數棧中彈出兩個數相加,并將結果8再壓入操作數棧。
- 將操作數棧頂的結果8,存回到局部變量
sum
中。 - 執行
return
指令,將局部變量sum
的值再次加載到操作數棧頂,準備作為返回值。
- 將局部變量
第三步:方法返回與棧幀銷毀 (出棧)
方法執行完畢,需要返回。返回分為兩種情況:
-
正常返回 (Normal Return):
- 像本例中,執行
return sum;
。 add
方法的棧幀會將返回值(8)傳遞給調用者(main
方法)的棧幀,通常是壓入main-Frame
的操作數棧中。- 然后,
add
方法的棧幀會從虛擬機棧中被銷毀(出棧)。 - 程序計數器會根據之前保存的方法返回地址,恢復到
main
方法中調用add
的那一行,繼續向后執行(比如將main-Frame
操作數棧頂的8賦值給result
變量)。
- 像本例中,執行
-
異常返回 (Abrupt Return):
- 如果在
add
方法中發生了未被捕獲的異常。 add
方法的棧幀同樣會被銷毀(出棧),但它不會有任何返回值給調用者。- JVM會把這個異常對象拋給調用者
main
方法去處理。如果main
方法也處理不了,這個異常會繼續向上傳播,直到最終導致線程終止。
- 如果在
總結一下,方法的執行過程,本質上是線程的虛擬機棧中,棧幀不斷入棧和出棧的過程。當前正在執行的方法,其對應的棧幀永遠位于棧頂。這個清晰、高效的棧式結構,是Java方法能夠實現有序調用和遞歸的基礎。
方法區中還有哪些東西?
面試官您好,方法區是JVM運行時數據區中一個非常重要的線程共享區域。正如《深入理解Java虛擬機》中所述,它主要用于存儲已被虛擬機加載的元數據信息。
我們可以把方法區想象成一個JVM的 “類型信息檔案館”,當一個.class
文件被加載進內存后,它的大部分“檔案信息”都存放在這里。
這些信息主要可以分為以下幾大類:
1. 類型信息 (Type Information)
這是方法區的核心。對于每一個被加載的類(或接口),JVM都會在方法區中存儲其完整的元信息,包括:
- 類的全限定名 (e.g.,
java.lang.String
)。 - 類的直接父類的全限定名 (e.g.,
java.lang.Object
)。 - 類的類型 (是類
class
還是接口interface
)。 - 類的訪問修飾符 (
public
,abstract
,final
等)。 - 類的直接實現接口的有序列表。
- 字段信息 (Field Info):每個字段的名稱、類型、修飾符等。
- 方法信息 (Method Info):每個方法的名稱、返回類型、參數列表、修飾符,以及最重要的——方法的字節碼 (Bytecodes)。
2. 運行時常量池 (Runtime Constant Pool)
-
來源:每個
.class
文件內部都有一個“常量池表(Constant Pool Table)”,用于存放編譯期生成的各種字面量和符號引用。當這個類被加載到JVM后,這個靜態的常量池表就會被轉換成方法區中的運行時常量池。 -
內容:
- 字面量:比如文本字符串(
"Hello, World!"
)、final
常量的值等。 - 符號引用 (Symbolic References):這是一種編譯時的、用字符串表示的間接引用。它包括:
- 類和接口的全限定名。
- 字段的名稱和描述符。
- 方法的名稱和描述符。
- 在程序實際運行時,JVM會通過這些符號引用,去動態地查找并鏈接到真實的內存地址(這個過程叫動態鏈接)。
- 字面量:比如文本字符串(
-
動態性:運行時常量池的一個重要特性是它是動態的。比如
String.intern()
方法,就可以在運行時將新的常量放入池中。
3. 靜態變量 (Static Variables)
- 也稱為“類變量”。被
static
關鍵字修飾的字段,會存放在方法區中。 - 這些變量與類直接關聯,而不是與類的某個實例對象關聯,因此被所有線程共享。
4. 即時編譯器(JIT)編譯后的代碼緩存
- 為了提升性能,HotSpot虛擬機會將頻繁執行的“熱點代碼”(HotSpot Code)通過JIT編譯器編譯成本地機器碼。
- 這部分編譯后的、高度優化的本地機器碼,也會被緩存存放在方法區中,以便下次直接執行,無需再解釋字節碼。
方法區的演進:永久代與元空間
值得一提的是,方法區是一個邏輯上的概念,它的具體物理實現在不同JDK版本中是不同的:
- JDK 7及以前:HotSpot JVM使用永久代(Permanent Generation)來實現方法區。永久代是堆內存的一部分,它有固定的大小上限,容易導致
OutOfMemoryError: PermGen space
。 - JDK 8及以后:永久代被徹底移除,取而代之的是元空間(Metaspace)。元空間使用的是本地內存(Native Memory),而不是JVM堆內存。這樣做的好處是,元空間的大小只受限于操作系統的可用內存,極大地降低了因元數據過多而導致OOM的風險。
總結一下,方法區就像是JVM的“圖書館”,里面存放著所有加載類的“戶口本”(類型信息)、“字典”(運行時常量池)、“公共財產”(靜態變量)以及“最優操作手冊”(JIT編譯后的代碼)。它是Java程序能夠運行起來的基礎。
String保存在哪里呢?
情況一:通過字面量直接賦值 (String s = "abc";
)
-
存儲位置:當您像這樣直接用雙引號創建一個字符串時,這個字符串
"abc"
會被存放在一個特殊的內存區域,叫做字符串常量池(String Constant Pool)。 -
工作機制:
- JVM在處理這行代碼時,會先去字符串常量池里檢查,看是否已經存在內容為
"abc"
的字符串。 - 如果存在,JVM就不會創建新的對象,而是會直接將常量池中那個字符串的引用返回,賦值給變量
s
。 - 如果不存在,JVM才會在常量池中創建一個新的
String
對象,內容是"abc"
,然后將它的引用返回。
- JVM在處理這行代碼時,會先去字符串常量池里檢查,看是否已經存在內容為
-
特性:這種方式創建的字符串,是共享的。例如:
String s1 = "abc"; String s2 = "abc"; System.out.println(s1 == s2); // 輸出: true
這里的
s1
和s2
指向的是常量池中同一個對象。
情況二:通過new
關鍵字創建 (String s = new String("abc");
)
-
存儲位置:這種方式的行為就和普通的Java對象一樣了,它會涉及到兩個內存區域。
-
工作機制:
new String("abc")
這行代碼,首先,JVM還是會去檢查字符串常量池,確保池中有一個"abc"
的對象(如果沒有就創建一個)。- 然后,最關鍵的一步是,
new
關鍵字會在Java堆(Heap) 上,創建一個全新的String
對象。這個新的String
對象內部的字符數組,會復制常量池中那個"abc"
對象的數據。 - 最后,將堆上這個新對象的引用返回給變量
s
。
-
特性:這種方式總是在堆上創建一個新對象,即使字符串的內容已經存在于常量池中。
String s1 = "abc"; // 在常量池 String s2 = new String("abc"); // 在堆上 System.out.println(s1 == s2); // 輸出: false
這里的
s1
和s2
指向的是兩個完全不同的對象,一個在常量池,一個在堆。
字符串常量池的演進
值得一提的是,字符串常量池的物理位置在JDK版本中是有變遷的:
- JDK 6及以前:字符串常量池是方法區(永久代) 的一部分。
- JDK 7:字符串常量池被從方法區移到了Java堆中。
- JDK 8及以后:永久代被元空間取代,但字符串常量池仍然在Java堆中。
將常量池移到堆中,一個主要的好處是方便GC對常量池中的字符串進行回收。
intern()
方法的作用
String
類還提供了一個intern()
方法,它是一個與常量池交互的橋梁:
- 當一個堆上的
String
對象(比如通過new
創建的)調用intern()
方法時,JVM會去字符串常量池里查找是否存在內容相同的字符串。- 如果存在,就返回常量池中那個字符串的引用。
- 如果不存在,就會將這個字符串的內容放入常量池,并返回新放入的那個引用。
總結一下:
- 直接用字面量創建的
String
,對象在字符串常量池中。 - 用
new
關鍵字創建的String
,對象主體在Java堆中。
理解這個區別,對于我們優化內存使用和正確判斷字符串相等性(特別是用==
時)至關重要。
引用類型有哪些?有什么區別?
面試官您好,Java中的引用類型,除了我們最常用的強引用,還提供了軟、弱、虛三種不同強度的引用,它們的設計主要是為了讓我們可以更靈活地與垃圾回收器(GC) 進行交互,從而實現更精細的內存管理。
我們可以把這四種引用的強度,比作一段關系的“牢固程度”:
1. 強引用 (Strong Reference) —— “生死相依”
- 定義與特點:這就是我們日常編程中最常見的引用形式,比如
Object obj = new Object();
。只要一個對象還存在強引用指向它,那么垃圾回收器永遠不會回收這個對象,即使系統內存已經非常緊張,即將發生OutOfMemoryError
。 - 生命周期:直到這個強引用被顯式地設置為
null
(比如obj = null;
),或者超出了其作用域,它與對象之間的“強關聯”才會斷開。 - 應用場景:所有常規對象的創建和使用。
2. 軟引用 (Soft Reference) —— “情有可原,可有可無”
- 定義與特點:用
SoftReference
類來包裝對象。軟引用關聯的對象,是那些有用但并非必需的對象。 - GC回收時機:當系統內存即將發生溢出(OOM)之前,垃圾回收器會把這些軟引用關聯的對象給回收掉,以釋放內存,嘗試挽救系統。如果回收之后內存仍然不足,才會拋出OOM。
- 應用場景:非常適合用來實現高速緩存。比如,一個圖片加載應用,可以將加載到內存的圖片用軟引用包裝起來。內存充足時,圖片可以一直保留在內存中,加快下次訪問速度;內存緊張時,這些圖片緩存可以被自動回收,而不會導致系統崩潰。
3. 弱引用 (Weak Reference) —— “萍水相逢,一碰就忘”
- 定義與特點:用
WeakReference
類來包裝對象。弱引用的強度比軟引用更弱。 - GC回收時機:只要垃圾回收器開始工作,無論當前內存是否充足,被弱引用關聯的對象都一定會被回收。也就是說,它只能“活”到下一次GC發生之前。
- 應用場景:
ThreadLocal
的Key:ThreadLocalMap
中的Key就是對ThreadLocal
對象的弱引用,這有助于在ThreadLocal
對象本身被回收后,防止一部分內存泄漏。- 各種緩存和監聽器注冊:在一些需要避免內存泄漏的緩存或回調注冊場景中,使用弱引用可以確保當目標對象被回收后,相關的緩存條目或監聽器也能被自動清理。最典型的就是
WeakHashMap
。
4. 虛引用 (Phantom Reference) —— “若有若無,形同虛設”
- 定義與特點:也叫“幻影引用”,是所有引用類型中最弱的一種。它由
PhantomReference
類實現,并且必須和引用隊列(ReferenceQueue
)聯合使用。 - 核心特性:
- 一個對象是否有虛引用,完全不影響其生命周期。就像沒有這個引用一樣,該被回收時就會被回收。
- 我們永遠無法通過虛引用來獲取到對象實例。
phantomRef.get()
方法永遠返回null
。
- 它的唯一作用:當一個對象被GC確定要回收時,如果它有關聯的虛引用,那么JVM會在真正回收其內存之前,將這個虛引用對象本身(而不是它引用的對象)放入與之關聯的
ReferenceQueue
中。 - 應用場景:它主要用于跟蹤對象被垃圾回收的活動。最經典的應用就是管理堆外內存(Direct Memory)。比如
DirectByteBuffer
,它在Java堆上只是一個很小的對象,但它在堆外分配了大量的本地內存。我們可以為這個DirectByteBuffer
對象創建一個虛引用。當GC回收這個對象時,虛引用會入隊。我們的后臺清理線程可以監視這個隊列,一旦發現有虛引用入隊,就知道對應的堆外內存已經不再被使用,就可以安全地調用free()
方法來釋放這塊本地內存了。
通過這四種不同強度的引用,Java賦予了開發者與GC協作的能力,讓我們能夠根據對象的生命周期和重要性,設計出更健壯、內存使用更高效的程序。
弱引用了解嗎?舉例說明在哪里可以用?
面試官您好,我了解弱引用。它是一種比軟引用“更弱”的引用類型,其核心特點是:一個只被弱引用指向的對象,只要垃圾回收器開始工作,無論當前內存是否充足,它都一定會被回收。
弱引用提供了一種讓我們能夠“監視”一個對象生命周期,但又“不干涉”其被回收的方式。
弱引用最經典的應用案例剖析
在Java的API和各種框架中,弱引用有很多巧妙的應用。我舉兩個最著名的例子來說明它在哪里用,以及如何用:
案例一:ThreadLocal
中的內存泄漏“防線”
這是弱引用最廣為人知的一個應用。
- 背景:
ThreadLocal
的內部,每個線程都持有一個ThreadLocalMap
。這個Map的Entry(鍵值對)被設計為:- Key:是對
ThreadLocal
對象的弱引用 (WeakReference<ThreadLocal>
)。 - Value:是對我們實際存儲的值的強引用。
- Key:是對
- 為什么用弱引用?
- 假設我們在代碼中將一個
ThreadLocal
變量置為null
了 (myThreadLocal = null;
),這意味著我們不再需要它了。 - 如果沒有弱引用,而是強引用,那么即使
myThreadLocal
被置為null
,只要這個線程還存活,ThreadLocalMap
中的Entry就會一直強引用著這個ThreadLocal
對象,導致它永遠無法被回收。 - 而使用了弱引用后,當
myThreadLocal
在外部的強引用消失,下一次GC發生時,ThreadLocalMap
中那個作為Key的ThreadLocal
對象就會被自動回收,Entry的Key就變成了null
。
- 假設我們在代碼中將一個
- 作用:這為清理Value創造了條件。雖然Value本身還是強引用,但
ThreadLocal
在調用get()
,set()
時,會順便檢查并清理掉這些Key為null
的Entry。弱引用的使用,是ThreadLocal
能夠進行部分自我清理、防止內存泄漏的第一道防線。
案例二:WeakHashMap
—— 構建“會自動清理的緩存”
這是一個更直接體現弱引用價值的例子。
WeakHashMap
是什么?- 它是一個鍵(Key)是弱引用的
HashMap
。
- 它是一個鍵(Key)是弱引用的
- 它是如何工作的?
- 當我們向
WeakHashMap
中put(key, value)
時,這個key
對象被弱引用所包裹。 - 當外部不再有任何強引用指向這個
key
對象時,在下一次GC后,這個key
對象就會被回收。 WeakHashMap
內部有一個機制(通過ReferenceQueue
),當它發現某個key
被回收后,它會自動地將整個Entry(包括key和value)從Map中移除。
- 當我們向
- 應用場景:
- 非常適合用來做緩存。我們可以把緩存的鍵作為
key
,緩存的內容作為value
。 - 好處:當緩存的鍵(比如某個業務對象)在程序的其他地方不再被使用、被GC回收后,
WeakHashMap
中對應的這條緩存記錄也會自動地、安全地被清理掉,我們完全不需要手動去維護緩存的過期和清理,從而完美地避免了因緩存引發的內存泄漏。
- 非常適合用來做緩存。我們可以把緩存的鍵作為
總結
弱引用的核心用途,就是構建一種非侵入式的、依賴于GC的關聯關系。它允許我們“依附”于一個對象,但又不會強行延長它的生命周期。這在實現緩存、元數據存儲、監聽器管理等需要避免內存泄漏的場景中,是非常有價值的工具。
內存泄漏和內存溢出的理解?
面試官您好,內存泄漏和內存溢出是Java開發者必須面對的兩個核心內存問題。它們是兩個不同但又緊密相關的概念。
我可以用一個 “水池注水” 的比喻來解釋它們:
- 內存(堆):就像一個容量固定的水池。
- 創建新對象:就像往水池里注入新的水。
- 垃圾回收(GC):就像是水池的排水口,會自動排掉不再需要的水。
- 內存泄漏:就像是排水口被一些垃圾(無用的引用)堵住了一部分。
- 內存溢出:就是水池最終被灌滿了,水溢了出來。
1. 內存泄漏 (Memory Leak) —— “該走的不走”
- 定義:內存泄漏指的是,程序中一些已經不再被使用的對象,由于仍然存在著某個(通常是無意的)強引用鏈,導致垃圾回收器(GC)無法將它們回收。
- 本質:這些對象邏輯上已經是“垃圾”了,但GC不這么認為。它們就像“僵尸”一樣,占著茅坑不拉屎,持續地、無效地消耗著寶貴的堆內存。
- 后果:一次小小的內存泄漏可能不會立即產生影響,但如果這種泄漏發生在頻繁執行的代碼路徑上,日積月累,就會導致可用內存越來越少。
- 常見原因:
- 長生命周期的對象持有短生命周期對象的引用:最典型的就是靜態集合類。一個靜態的
HashMap
,如果不手動remove
,它里面存放的對象的生命周期就和整個應用程序一樣長,即使這些對象早就不需要了。 - 資源未關閉:比如數據庫連接、網絡連接、文件IO流等,如果沒有在
finally
塊中正確關閉,它們持有的底層資源和緩沖區內存就無法被釋放。 - 監聽器和回調未注銷:一個對象注冊了監聽器,但自身銷毀前沒有去注銷,導致被監聽的目標對象一直持有它的引用。
ThreadLocal
使用不當:沒有在finally
中調用remove()
方法,導致在線程池場景下,Value對象無法被回收。
- 長生命周期的對象持有短生命周期對象的引用:最典型的就是靜態集合類。一個靜態的
2. 內存溢出 (OutOfMemoryError, OOM) —— “想來的來不了”
- 定義:內存溢出是一個結果,是一個錯誤(Error)。它指的是,當程序需要申請更多內存時(比如
new
一個新對象),而JVM發現堆內存已經耗盡,并且經過GC后也無法騰出足夠的空間,最終只能拋出OutOfMemoryError
,導致應用程序崩潰。 - 常見原因:
- 內存泄漏的累積:這是最隱蔽、最常見的原因。持續的內存泄漏最終會“吃光”所有可用內存,導致OOM。
- 瞬時創建大量對象:程序在某個時刻需要處理大量數據,一次性加載了海量對象到內存中,直接超出了堆的上限。比如,一次性從數據庫查詢一百萬條記錄并映射成對象。
- 堆空間設置不合理:JVM啟動時,通過
-Xmx
參數設置的堆最大值,對于應用的實際需求來說太小了。 StackOverflowError
:雖然這也是OOM的一種,但它特指棧內存溢出,通常是由于無限遞歸或方法調用鏈過深導致的。
3. 關系總結
- 內存泄漏是原因,內存溢出是結果。
- 持續的、未被發現的內存泄漏,最終必然會導致內存溢出。
- 但是,發生內存溢出,并不一定是因為內存泄漏。也可能是因為數據量確實太大,或者JVM參數配置不當。
4. 如何排查?
在實踐中,排查這類問題,我會使用專業的內存分析工具:
- 通過JVM參數(
-XX:+HeapDumpOnOutOfMemoryError
)讓JVM在發生OOM時,自動生成一個堆轉儲快照(Heap Dump)文件。 - 使用內存分析工具(如 MAT (Memory Analyzer Tool)、JProfiler等)來打開和分析這個dump文件。
- 在MAT中,可以查看支配樹(Dominator Tree)和查找泄漏嫌疑(Leak Suspects),工具會自動幫我們分析哪些對象占用了大量內存,以及是什么樣的引用鏈導致它們無法被回收,從而快速定位到問題的根源。
JVM內存結構有哪幾種內存溢出的情況?
面試官您好,JVM的內存結構在不同區域都可能發生內存溢出,這通常意味著程序申請內存超出了JVM所能管理的上限。我主要熟悉以下四種最常見的內存溢出情況:
1. 堆內存溢出 (Heap OOM)
-
異常信息:
java.lang.OutOfMemoryError: Java heap space
-
原因分析:這是最常見的一種OOM。正如您所說,根本原因是在堆中無法為新創建的對象分配足夠的空間。這通常由兩種情況導致:
- 內存泄漏(Memory Leak):程序中存在生命周期過長的對象(如靜態集合),它們持有了不再使用的對象的引用,導致GC無法回收,可用內存越來越少。
- 內存確實不夠用:程序需要處理的數據量確實非常大,比如一次性從數據庫查詢了數百萬條記錄并加載到內存中,直接超出了堆的容量上限。
-
代碼示例:
// 模擬內存確實不夠用 List<byte[]> list = new ArrayList<>(); while (true) {// 不斷創建大對象,直到耗盡堆內存list.add(new byte[1024 * 1024]); // 1MB }
-
解決方案:
- 分析Heap Dump:使用MAT等工具分析OOM時生成的堆轉儲文件,查看是哪些對象占用了大量內存,并檢查其引用鏈,判斷是否存在內存泄漏。
- 優化代碼:如果是數據量過大,需要優化代碼邏輯,比如使用流式處理、分批加載等方式,避免一次性加載所有數據。
- 調整JVM參數:如果確認業務上需要這么多內存,可以通過增大
-Xmx
參數來調高堆的最大值。
2. 虛擬機棧和本地方法棧溢出 (Stack OOM)
-
異常信息:通常是
java.lang.StackOverflowError
,在極少數無法擴展棧的情況下可能是OutOfMemoryError
。 -
原因分析:每個線程都有自己的虛擬機棧,用于存放方法調用的棧幀。棧溢出通常不是因為內存“不夠大”,而是因為棧的深度超過了限制。
- 最常見的原因就是無限遞歸或方法調用鏈過深。
-
代碼示例:
public class StackOverflowTest {public static void recursiveCall() {recursiveCall(); // 無限遞歸}public static void main(String[] args) {recursiveCall();} }
-
解決方案:
- 檢查代碼邏輯:仔細檢查代碼,找出導致無限遞歸或過深調用的地方并修復它。這是最根本的解決辦法。
- 調整棧大小:如果確認業務邏輯需要很深的調用棧,可以通過
-Xss
參數來增大每個線程的棧空間大小,但這治標不治本。
3. 元空間溢出 (Metaspace OOM)
-
異常信息:
java.lang.OutOfMemoryError: Metaspace
-
原因分析:元空間(在JDK 8之前是永久代)主要存儲類的元數據信息。元空間溢出意味著加載的類太多了。
- 常見原因包括:系統本身非常龐大,加載了大量的類和第三方jar包;或者在運行時通過動態代理、反射、CGLIB等技術,動態生成了大量的類,但這些類又沒能被及時卸載。
-
代碼示例:
// 使用CGLIB等字節碼技術不斷生成新類 while (true) {Enhancer enhancer = new Enhancer();enhancer.setSuperclass(MyClass.class);enhancer.setUseCache(false);enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> proxy.invokeSuper(obj, args));enhancer.create(); }
-
解決方案:
- 排查類加載情況:檢查是否有動態類生成相關的庫(如CGLIB)被濫用。
- 優化依賴:精簡項目依賴,移除不必要的jar包。
- 調整JVM參數:通過增大
-XX:MaxMetaspaceSize
參數來調高元空間的最大值。
4. 直接內存溢出 (Direct Memory OOM)
-
異常信息:
java.lang.OutOfMemoryError: Direct buffer memory
-
原因分析:這是由于使用了NIO(New I/O) 中的
ByteBuffer.allocateDirect()
方法,在堆外(本地內存) 分配了大量內存,而這部分內存又沒能被及時回收。- 直接內存的回收,依賴于與之關聯的
DirectByteBuffer
對象被GC回收時,觸發一個清理機制(通過虛引用和Cleaner
)。如果堆內存遲遲沒有觸發GC,那么堆外的直接內存就可能一直得不到釋放,最終耗盡。
- 直接內存的回收,依賴于與之關聯的
-
代碼示例:
// 不斷分配直接內存,但不觸發GC List<ByteBuffer> buffers = new ArrayList<>(); while (true) {buffers.add(ByteBuffer.allocateDirect(1024 * 1024)); // 1MB }
-
解決方案:
- 檢查NIO代碼:確保合理使用直接內存,并在不需要時及時清理。
- 適時手動GC:在一些極端情況下,如果確認直接內存壓力大,可以考慮在代碼中調用
System.gc()
來“建議”JVM進行一次Full GC,但這通常不被推薦。 - 調整JVM參數:通過
-XX:MaxDirectMemorySize
參數來明確指定直接內存的最大容量。
通過對這幾種OOM的理解和分析,我們可以在遇到問題時,根據不同的異常信息,快速地定位到可能的原因,并采取相應的解決措施。
有具體的內存泄漏和內存溢出的例子么請舉例及解決方案?
案例一:靜態集合類導致的內存泄漏
這是最常見、也最容易被忽視的一種內存泄漏。
1. 問題場景代碼
假設我們有一個需求,需要臨時緩存一些用戶信息,但開發人員錯誤地使用了一個靜態的HashMap
來存儲。
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;// 模擬一個用戶服務
class UserService {// 【問題根源】使用了一個靜態的Map來緩存用戶對象private static Map<String, User> userCache = new HashMap<>();public void cacheUser(User user) {if (!userCache.containsKey(user.getId())) {userCache.put(user.getId(), user);System.out.println("緩存用戶: " + user.getId() + ", 當前緩存大小: " + userCache.size());}}// 缺少一個移除緩存的方法!
}// 用戶對象
class User {private String id;private String name;// ... 構造函數, getter/setter ...public User(String id, String name) { this.id = id; this.name = name; }public String getId() { return id; }
}// 模擬Web請求不斷調用
public class StaticLeakExample {public static void main(String[] args) {UserService userService = new UserService();while (true) {// 模擬每次請求都創建一個新的User對象并緩存String userId = UUID.randomUUID().toString();User newUser = new User(userId, "User-" + userId);userService.cacheUser(newUser);// 為了不讓程序瞬間OOM,稍微 sleep 一下try {Thread.sleep(1);} catch (InterruptedException e) {e.printStackTrace();}}}
}
2. 泄漏原因分析
- 靜態變量的生命周期:
userCache
是一個static
變量,它的生命周期和整個UserService
類的生命周期一樣長,通常也就是整個應用程序的運行時間。 - 持續的強引用:
while (true)
循環不斷地創建新的User
對象并調用cacheUser
方法。每調用一次,這個新的User
對象就被put
進了靜態的userCache
中。 - 無法被GC回收:
userCache
這個Map一直強引用著所有被放進去的User
對象。即使這些User
對象在業務邏輯上早就不需要了,但只要userCache
還引用著它們,垃圾回收器就永遠不會回收這些User
對象。 - 最終結果:隨著時間推移,
userCache
越來越大,占用的堆內存越來越多,最終耗盡所有堆內存,拋出java.lang.OutOfMemoryError: Java heap space
。
3. 解決方案
-
明確移除(治標):最直接的辦法是,在確定不再需要某個緩存對象時,手動從
userCache
中調用remove()
方法將其移除,切斷強引用。但這依賴于開發者必須記得去調用,容易遺漏。 -
使用弱引用(治本):這是一個更優雅、更自動化的解決方案。我們可以使用
WeakHashMap
來替代HashMap
。// 解決方案:使用WeakHashMap private static Map<String, User> userCache = new WeakHashMap<>();
WeakHashMap
的特性:它的鍵(Key)是弱引用。當一個User
對象在程序的其他地方不再有任何強引用指向它時(比如,處理完一個Web請求,相關的User
對象都變成了垃圾),即使它還存在于WeakHashMap
中,GC也會將它回收。WeakHashMap
在檢測到Key被回收后,會自動地將整個鍵值對從Map中移除。- 這樣,緩存的生命周期就和它所緩存的對象的生命周期自動綁定了,完美地避免了內存泄漏。
-
使用專業的緩存框架(最佳實踐):在生產環境中,我們不應該手寫緩存。應該使用專業的緩存框架,如Guava Cache, Caffeine, 或 Ehcache。這些框架不僅內置了基于弱引用、軟引用的自動清理機制,還提供了更豐富的功能,如基于大小的淘汰、基于時間的過期、統計等。
案例二:ThreadLocal
使用不當導致的內存泄漏
這個案例在線程池環境下尤其常見。
1. 問題場景代碼
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;public class ThreadLocalLeakExample {// 創建一個ThreadLocal來存儲大對象static ThreadLocal<byte[]> localVariable = new ThreadLocal<>();public static void main(String[] args) {// 使用固定大小的線程池ExecutorService executor = Executors.newFixedThreadPool(1);for (int i = 0; i < 100; i++) {executor.submit(() -> {// 【問題根源】為ThreadLocal設置了一個大對象localVariable.set(new byte[1024 * 1024 * 5]); // 5MBSystem.out.println("線程 " + Thread.currentThread().getName() + " 設置了值");// 【關鍵問題】任務執行完畢后,沒有調用remove()方法!// localVariable.remove(); // 正確的做法應該是加上這一行});try {Thread.sleep(100);} catch (InterruptedException e) {e.printStackTrace();}}// ...}
}
2. 泄漏原因分析
- 線程池與線程復用:
newFixedThreadPool(1)
創建了一個只有一個線程的線程池。這意味著,所有100個任務,都是由同一個線程來輪流執行的。 ThreadLocal
的存儲原理:ThreadLocal
的值實際上是存儲在Thread
對象自身的ThreadLocalMap
中的。- 引用鏈分析:
- 當第一個任務執行時,它在線程T1的
ThreadLocalMap
中放入了一個5MB的字節數組。 - 任務結束后,
localVariable
這個ThreadLocal
對象可能會因為方法結束而被回收(它的Key是弱引用)。 - 但是,線程T1并不會被銷毀,它會被歸還給線程池,等待下一個任務。
- 此時,一條強引用鏈依然存在:
線程池 -> 線程T1 -> T1.threadLocals(ThreadLocalMap) -> Entry -> Value(5MB的byte[])
。 - 這個5MB的數組就因為這條強引用鏈而無法被GC回收。
- 當第一個任務執行時,它在線程T1的
- 最終結果:當后續的任務在這個線程上執行,又調用
localVariable.set()
時,它會覆蓋掉舊的值,但如果后續任務不再使用這個ThreadLocal
,那么最后一次設置的那個5MB的數組就會永久地留在這個線程里,直到線程池被關閉。如果線程池很大,或者ThreadLocal
存儲的對象更多,就會慢慢地耗盡內存,導致OOM。
3. 解決方案
解決方案非常簡單,但必須強制遵守:
-
養成在
finally
塊中調用remove()
的習慣。executor.submit(() -> {localVariable.set(new byte[1024 * 1024 * 5]);try {// ... 執行業務邏輯 ...System.out.println("線程 " + Thread.currentThread().getName() + " 設置了值");} finally {// 確保在任務結束時,無論正常還是異常,都清理ThreadLocal的值localVariable.remove();System.out.println("線程 " + Thread.currentThread().getName() + " 清理了值");} });
-
調用
remove()
方法會徹底地將ThreadLocalMap
中對應的Entry
移除,從而切斷整個引用鏈,讓Value對象可以被正常地垃圾回收。這是使用ThreadLocal
時必須遵守的鐵律。