1. 區域變數儲存在哪裡
先從最基本的開始:區域變數。它們是在方法內宣告、且只在其執行期間存在的變數。它們的生命短暫而戲劇化:一旦方法結束,該方法的所有區域變數便會無影無蹤。
在 Java 中,區域變數儲存在堆疊中。每個執行緒都有自己的堆疊。當一個方法呼叫另一個方法時,堆疊會新增一個「框架」(stack frame),其中包含區域變數與返回位址。當方法完成時,該框架會從堆疊移除。
範例:區域變數的一生
public class LocalVariableDemo {
public static void main(String[] args) {
int a = 42; // 區域變數 a 只在 main 中存活
printSquare(a);
// 這裡變數 b 已不存在!
}
public static void printSquare(int b) {
int square = b * b; // 區域變數 square
System.out.println("平方: " + square);
// 離開 printSquare 後,所有區域變數都會消失
}
}
重要提醒:如果區域變數是引用(例如,String、Scanner、陣列),那麼引用本身位於堆疊,而物件則位於堆中!當引用消失且再也沒有其他引用指向該物件時,垃圾回收器可以刪除該物件。
示意圖
Stack(對於 main):
| int a = 42 |
| args |
-------------------
Heap:
| [透過 new 建立的物件] |
2. Java 的記憶體洩漏:迷思還是現實?
許多新手認為:「Java 有垃圾回收器!所以不可能有記憶體洩漏!」可惜,這是個迷思,第一次做大型專案就會被打破。
垃圾回收器只會刪除完全沒有任何存活引用的物件。如果某處還殘留著引用(哪怕在最意想不到的地方),該物件就會一直留在記憶體中,直到最後。或者直到發生 OutOfMemoryError。
範例 1:靜態集合陷阱
import java.util.ArrayList;
import java.util.List;
public class MemoryLeakDemo {
// 唉,這個靜態集合!
private static final List<String> BIG_LIST = new ArrayList<>();
public static void main(String[] args) {
for (int i = 0; i < 1_000_000; i++) {
BIG_LIST.add("字串編號 " + i);
}
System.out.println("已新增一百萬個字串");
// 即使 main 結束,BIG_LIST 仍會留在記憶體中,直到 JVM 結束
}
}
發生了什麼?
- 靜態變數 BIG_LIST 的壽命與類別相同(通常直到 JVM 結束)。
- 加入清單的所有字串都無法被垃圾回收器刪除——因為透過 BIG_LIST 永遠有引用指向它們。
- 如果你忘了清理這樣的集合——就會發生記憶體洩漏。
範例 2:未取消註冊的監聽器(listeners)
import java.util.ArrayList;
import java.util.List;
class EventSource {
private final List<Runnable> listeners = new ArrayList<>();
public void addListener(Runnable listener) {
listeners.add(listener);
}
// ... 其他方法 ...
}
public class ListenerLeakDemo {
public static void main(String[] args) {
EventSource source = new EventSource();
Runnable listener = () -> System.out.println("事件!");
source.addListener(listener);
// 若忘了呼叫 source.removeListener(listener),listener 會永遠留在記憶體中!
}
}
問題:如果監聽器已不再需要,但未從清單中移除——它以及它所引用的所有物件都會留在記憶體中。
範例 3:永不清空的快取
import java.util.HashMap;
import java.util.Map;
public class CacheLeakDemo {
private static final Map<String, byte[]> CACHE = new HashMap<>();
public static void main(String[] args) {
for (int i = 0; i < 1_000_000; i++) {
// 每次建立 1 KB 陣列
CACHE.put("key" + i, new byte[1024]);
}
System.out.println("已向快取加入一百萬個元素");
// 快取成長、記憶體耗盡,OutOfMemoryError!
}
}
結論:即使有 GC,如果不管理物件的生命週期,也很容易造成記憶體洩漏!
3. 弱引用(WeakReference)及其家族
有時我們需要一種快取或集合:當沒有其他引用指向某物件時,垃圾回收器可以刪除它。為此便有了弱引用(WeakReference)。
一般(strong)引用
String s = new String("hello"); // strong 引用
物件 s 只要還有至少一個 strong 引用存在,就會留在記憶體中。
弱引用(WeakReference)
import java.lang.ref.WeakReference;
public class WeakRefDemo {
public static void main(String[] args) {
String strong = new String("哈囉,世界!");
WeakReference<String> weak = new WeakReference<>(strong);
System.out.println("清除前: " + weak.get()); // 有引用
strong = null; // 移除 strong 引用
System.gc(); // 請求 GC 清理記憶體(無法保證!)
// 過一段時間後 weak.get() 可能會變為 null
System.out.println("GC 之後: " + weak.get());
}
}
運作方式:
- 只要還有一個 strong 引用指向物件,GC 不會刪除它。
- 若只剩弱引用,物件可能會在下一次垃圾回收時被刪除。
- 方法 weak.get() 在物件仍存活時回傳該物件;若已被刪除則回傳 null。
弱引用的應用場景?
主要用於快取:若物件被刪除並非關鍵。例如快取影像時,又不希望快取佔滿所有記憶體。
範例:WeakHashMap
WeakHashMap 是一種以弱引用儲存鍵的集合。當某個鍵不再被其他地方引用時,對應的鍵值對會從 map 中清除。
import java.util.Map;
import java.util.WeakHashMap;
public class WeakHashMapDemo {
public static void main(String[] args) {
Map<Object, String> map = new WeakHashMap<>();
Object key = new Object();
map.put(key, "值");
System.out.println("清理前: " + map);
key = null; // 移除對鍵的 strong 引用
System.gc(); // 請 GC 幫忙
// 過一段時間後,map 會變空!
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("GC 之後: " + map);
}
}
注意: WeakHashMap 只對鍵使用弱引用——值仍以一般 strong 引用儲存。
Soft、Weak、Phantom:引用家族
Java 中有四種引用(由「強」到「弱」排序):
| 引用類型 | GC 何時刪除物件? | 適用場景 |
|---|---|---|
|
僅當沒有任何 strong 引用時 | 一般變數、集合 |
|
在記憶體不足時 | 希望盡量保留的快取 |
|
若無 strong 引用,於下一次 GC 時 | 快取、 WeakHashMap、監聽器 |
|
在終結(finalization)之後,用於追蹤刪除 | 特殊任務、堆外清理 |
- SoftReference — 在記憶體不足時刪除(很適合圖片等快取)。
- WeakReference — 若無其他引用,會在下一次 GC 時刪除。
- PhantomReference — 最「幽靈」的類型,用於複雜情境,新手較少使用。
4. 實作:記憶體洩漏與修復範例
洩漏範例:靜態清單
import java.util.ArrayList;
import java.util.List;
public class LeakExample {
private static final List<byte[]> list = new ArrayList<>();
public static void main(String[] args) {
for (int i = 0; i < 100_000; i++) {
list.add(new byte[1024 * 1024]); // 1 MB
if (i % 10 == 0) System.out.println("已新增 " + i + " MB");
}
}
}
會發生什麼?
程式會很快吃掉所有可用記憶體並拋出 OutOfMemoryError,因為靜態清單保留了對所有建立之陣列的引用。
修復方式:使用弱引用
如果不是所有物件都必須永遠可用,可以透過弱引用來儲存它們:
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
public class LeakFixed {
private static final List<WeakReference<byte[]>> list = new ArrayList<>();
public static void main(String[] args) {
for (int i = 0; i < 100_000; i++) {
list.add(new WeakReference<>(new byte[1024 * 1024]));
if (i % 10 == 0) System.out.println("已新增 " + i + " MB");
System.gc(); // 對 GC 的提示(不保證立即清理!)
}
}
}
現在,只要沒有其他引用指向那些陣列,GC 就可以刪除它們——清單只保存弱引用。
5. 常見的記憶體洩漏情境
- 事件監聽器:忘了移除監聽器——物件就會一直存活。
- 靜態集合:不會清理的快取、全域清單——都可能導致洩漏。
- 內部類別與 lambda:如果內部類別或 lambda 捕獲了對外部物件的引用,只要外部物件仍存活,就無法被刪除。
內部類別範例
public class Outer {
private byte[] bigArray = new byte[1024 * 1024 * 100]; // 100 MB
public Runnable createTask() {
// 匿名內部類別會捕獲對 Outer 的引用!
return new Runnable() {
@Override
public void run() {
System.out.println("任務正在執行");
}
};
}
public static void main(String[] args) {
Outer outer = new Outer();
Runnable task = outer.createTask();
// 即使 outer = null,task 仍然持有對 bigArray 的引用!
}
}
解法:使用靜態內部類別,或將邏輯抽出到獨立類別,以避免保留多餘的引用。
6. 實作:在快取中使用弱引用
在教學應用中加入一個最簡單的快取,使用 WeakHashMap。
import java.util.Map;
import java.util.WeakHashMap;
public class ImageCache {
private final Map<String, byte[]> cache = new WeakHashMap<>();
public void put(String name, byte[] data) {
cache.put(name, data);
}
public byte[] get(String name) {
return cache.get(name);
}
public static void main(String[] args) {
ImageCache cache = new ImageCache();
cache.put("cat", new byte[1024 * 1024]); // 1 MB
System.out.println("小貓已加入快取");
// 若再也沒有對鍵 "cat" 的引用,物件可能被 GC 刪除
}
}
在真實應用中(例如影像相關函式庫),弱引用能藉由自動移除較少使用的資料,幫助避免記憶體爆滿。
7. Strong vs Weak:何時該用哪一種?
- Strong 引用 — 預設:用於一切需要保證存活的物件。
- 弱引用 — 用於快取、監聽器;當物件被刪除不影響核心功能時。
- Soft 引用 — 適合希望盡量保留、但在記憶體不足時可被刪除的快取。
- Phantom 引用 — 進階情境(例如在 heap 之外的清理)。
8. 處理記憶體與引用時的常見錯誤
錯誤 1:「GC 會替我清掉一切!」 垃圾回收器只會刪除沒有任何存活 strong 引用的物件。如果你在某處「遺留」了引用(例如在 static 集合中),物件就會一直存活。
錯誤 2:遺忘的監聽器。將監聽器加到某個物件上,卻沒有在物件銷毀時移除?監聽器以及它捕獲的一切都會留在記憶體中。
錯誤 3:沒有使用弱引用的快取。如果你用一般的 HashMap 來實作應該自動清理的快取,請改用 WeakHashMap 或 SoftReference。
錯誤 4:內部類別與 lambda 捕獲外部物件。內部類別(以及 lambda 運算式)會隱式地保存對外部物件的引用。如果你保存此類實例的時間比外部物件更長,就會發生洩漏。
錯誤 5:期待 GC 立刻執行。呼叫 System.gc() 並不保證垃圾回收會立刻發生。這只是對 JVM 的「請求」,而不是命令。
GO TO FULL VERSION