深入探索 Java 物件創建與引用細節:Clone 和finalize 特性,以及強、軟、弱、虛引用

深入探索 Java 物件創建與引用細節:Clone 和finalize 特性,以及強、軟、弱、虛引用

Overview of Content

在Java虛擬機(JVM)中,物件的建立方式和引用關係至關重要

這篇文章將帶您深入了解Java中創建物件的方式,包括 Clone 特性、建構函數和反序列化。我們還將探討觸發JVM垃圾回收的方法,並詳細介紹finalize方法的特性。

此外,我們會深入探討Java中不同類型的物件引用,包括強引用、軟引用、弱引用和虛引用,以及它們之間的差異和使用情況。無論您是Java開發新手還是有經驗的專業人士,這篇文章都將為您提供深入且實用的知識,幫助您更好地理解和應用Java中的物件創建和引用概念

在 JVM 中,物件的通常都會建立在堆區,而堆區也是 GC 最常處理的區塊;以物件的生命週期來說,在堆區建立後就是”生“,被 GC 回收後就是 “死”(以下的內存就是記憶體)

寫文章分享不易,如有引用參考請詳註出處,如有指導、意見歡迎留言(如果覺得寫得好也請給我一些支持),感謝 😀


Java 建立物件的方式

在 Java 中有多種建立物件的方式,而不同的物件建立方式又會有不同的特性;分別有以下方法

A. 使用 new 關鍵字:最常見的方案

B. 使用 JVM Bytecode 反射創建:在設計、使用框架時常見;通常會使用到 java.lang.Classjava.lang.Constructor 這兩個類

C. 使用 Clone 機制:實作 Cloneable 這個標示界面

D. 反序列化:這個方案較少見;通常會使用到 java.io.ObjectInputStream 類來將物件實體化

物件的建立前提是類已經被 ClassLoader 加載進方法區,並初始化完成


Clone 特性

● Object#clone 方法:它是 Object 中的方法,它的源碼實現如下(以下我們看的是 Java 15 的源碼)


// Object.java    

public class Object {
    ... 省略部份

    protected native Object clone() throws CloneNotSupportedException;

}

我們可以看到 1. clone 是一個 Native 方法,代表他是在 Native 層被實作的,2. 它是 protected 方法,並且 3. 可能會拋出 CloneNotSupportedException

● 關於第二點與 Cloneable 標示介面 有關係


// java.lang.Cloneable

public interface Cloneable {
}

標示界面 是甚麽?

標示介面通常是拿來判斷的,標示界面並不具有任何需要實作的方法

Protected 方法

介於它是 Protected 方法,所以如果要讓外部呼叫,我們可以使用 overload 的技巧,對外開放

● 如果我們呼叫 clone 方法,但並沒有實作 Cloneable 介面,那就會拋出 CloneNotSupportedException 異常


class NoCloneable {

    @Override
    public NoCloneable clone() {
        try {
            return (NoCloneable) super.clone();
        } catch (CloneNotSupportedException e) {
            // 例外轉譯
            throw new AssertionError();
        }
    }

}

● 只要我們標示該介面是 Cloneable 就不會拋出異常


class WithCloneable implements Cloneable {

    @Override
    public WithCloneable clone() {
        try {
            return (WithCloneable) super.clone();
        } catch (CloneNotSupportedException e) {
            // 例外轉譯
            throw new AssertionError();
        }
    }

}

Clone 預設是 淺拷貝!當它拷貝物件時會 針對類型的不同而有不同的操作,我們接著來看看這兩種不同類型 clone 會如何操作

基礎類型:在呼叫 clone 方法,JVM 會對基礎類型(int, long, double... 等等)創建一個全新的基礎類型物件,並賦予 clone 物件

graph TD; 物件-->| JVM clone |Clone_物件 Clone_物件-->新的基礎類型

參考類型:參考類型(ref)像是物件、陣列;JVM 會直接複製該參考引用,而不會自動幫你創建一個新的參考物件!也就是說 clone 出來的物件,會與原先的物件使用相同的引用!

graph TD; 物件-->| JVM clone |Clone_物件; 物件-->參考物件; Clone_物件-->參考物件

class WithCloneable implements Cloneable {

    public String myStr = "Hello";

    public final int[] intArray = new int[1000];

    @Override
    public WithCloneable clone() {
        try {
            return (WithCloneable) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }

    public static void main(String[] args) {

        WithCloneable wc = new WithCloneable();

        WithCloneable c1 = wc.clone();

        System.out.println("Is clone object hashcode same: "
                + (wc.hashCode() == c1.hashCode()));

        System.out.println("Before set clone object's array(hashcode): "
                + (Arrays.hashCode(wc.intArray) == Arrays.hashCode(c1.intArray)));

        System.out.println("Before set clone object's string(hashcode): "
                + (wc.myStr.hashCode() == c1.hashCode()));

        System.out.println("Before set clone object's string(value): "
                + (wc.myStr.equals(c1.myStr)));

        System.out.println();

        c1.intArray[0] = 1000;
        System.out.println("After set clone object's array(hashcode): "
                + (Arrays.hashCode(wc.intArray) == Arrays.hashCode(c1.intArray)));

        c1.myStr = "World";
        System.out.println("After set clone object's string(value): "
                + (wc.myStr.equals(c1.myStr)));

        System.out.println("After set clone object's string(value): "
                + (wc.myStr.equals(c1.myStr)));
    }

}

深拷貝:如果有需拷貝全新參考類型,那要則需要自己手動做參考類型的 clone 方法

Clone 注意事項:不經建構函數

● 對於 使用 clone 方法來創過的物件,是 完全不會經過建構函數,它會直接拷貝記憶體內容!


class CloneAndConstructor implements Cloneable {

    private final String msg;

    CloneAndConstructor() {
        this.msg = "No message";

        System.out.println("Constructor without params");
    }

    CloneAndConstructor(String msg) {
        this.msg = msg;

        System.out.println("Constructor with params");
    }

    @Override
    public CloneAndConstructor clone() {
        try {
            return (CloneAndConstructor) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError(e);
        }
    }

    public static void main(String[] args) {
        CloneAndConstructor cc = new CloneAndConstructor("Hello Clone");
        System.out.println("Original object: " + cc.msg + "\n");

        CloneAndConstructor cloneObj = cc.clone();
        System.out.println("Clone object: " + cloneObj.msg);

    }

}

建構函數 - 反序列化

● 序列化/反序列化物件是一種比較少見的行為,但仍是一種創建物件的方式;它的特性如下

● 序列化物件 必須實作 Serializable 界面,該介面同樣也是一個標示介面


// java.io.Serializable

public interface Serializable {
}

● 反序列化會拷貝原先物件的參考引用物件 ref,但在改變參考引用物件 ref 的數值後,JVM 會自動幫我們創建一個新的物件

這類似一種 Copy on write 的技術

● 示意圖如下,在更改物件之前,指向同一個參考物件

graph TD; 物件-->| JVM clone |Clone_物件; 物件-->參考物件; Clone_物件-->參考物件

● 示意圖如下,在更改物件之後,指向全新的參考物件

graph TD; 物件-->|JVM Clone|Clone_物件; 物件-->參考類型; Clone_物件-->改動參考物件 改動參考物件-->新的參考類型

● 反序列化後,會創建一個全新的物件有新的記憶體位置(以引用來對照是不同的物件,但內容值相同),這種用法請特別注意只能用在「值物件


class AccountInfo implements Serializable {
    public final String name;
    public final long id;
    public final String password;

    public final String[] otherMessages = new String[1000];

    public AccountInfo() {
        this.name = "";
        this.id = -1;
        this.password = "-";


        System.out.println("Construct without params");
    }

    public AccountInfo(String name, long id, String password) {
        this.name = name;
        this.id = id;
        this.password = password;

        System.out.println("Construct with params");
    }

    @Override
    public String toString() {
        return "Name: " + name + "\n" +
                "id: " + id + "\n" +
                "password: " + password + "\n" +
                "otherMessages: " + Arrays.toString(otherMessages);
    }

    public static void main(String[] args) {
        String filename = "DemoAccount";

        // 序列化
        AccountInfo accountInfo = new AccountInfo("Alien", 9527, "HelloWorld");

        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(filename))){
            oos.writeObject(accountInfo);
            oos.flush();

            System.out.println("寫入對象成功,物件原本 hashcode: " + accountInfo.hashCode() + "\n");
        } catch (IOException e) {
            e.printStackTrace();
        }

        // 反序列化
        try (ObjectInputStream oos = new ObjectInputStream(new FileInputStream(filename))){
            AccountInfo deserializeObj = (AccountInfo) oos.readObject();

            System.out.println("讀取對象成功,反序列化後 hashcode: " + deserializeObj.hashCode());
            System.out.println(("Is same content: " + accountInfo).equals(deserializeObj.toString()));

            System.out.println("clone's array hashcode: " + Arrays.hashCode(deserializeObj.otherMessages));
            System.out.println("original's array hashcode: " + Arrays.hashCode(accountInfo.otherMessages) + "\n");

            accountInfo.otherMessages[0] = "123";

            System.out.println("clone's array index 0: " + deserializeObj.otherMessages[0]);
            System.out.println("clone's array index 0: " + Arrays.hashCode(deserializeObj.otherMessages) + "\n");

            System.out.println("original's array index 0: " + accountInfo.otherMessages[0]);
            System.out.println("original's array index 0: " + Arrays.hashCode(accountInfo.otherMessages) + "\n");
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }

    }
}

觸發 JVM GC:觀察物件回收

● Java GC 是否回收物件,這會有關到 JVM 判斷物件的 可達性分析 機制,當物件不可達時,就會被認定該物件不再使用,這時就可以回收

JVM 又會因為 Java 引用的差異而會有不同的處理方式

● 有關於 手動觸發 GC

當我們使用 System.gc()Runtime.getRuntime().gc() 並非是直接驅動立即開始 GC

GC 是另外一個低優先度的線程去處理的行為,所以通常我們測試時會使用 Thread#sleep 讓主線程休眠,等待 GC 回收

finalize 方法、特性

● 該方法的原形在 Object.java 中,可以用來拯救被回收的對象,有以下特點

A. 是只能拯救回收的對象一次 (一次 new 會有一次 finalize 機會,拯救後如果再次被加入回收列表,就不會再通知!)

B. 運行 finalize 的方法跑在另一個線程,該線程優先權較低


package GC;

public class testFinalize {

    public static testFinalize intance = null;

    @Override
    public void finalize() {
        System.out.println("finalize work");
        // 牽引回 GC Root
        intance = this;
    }

    public static void main(String[] args) throws InterruptedException {
        intance = new testFinalize();
        intance = null;

        // First:手動 GC
        System.gc();		

        // 由於 finally Thread 優先權太低,所以延遲主線程
        Thread.sleep(1000);		
        if(intance == null) {
            System.out.println("First intance == null");
        } else {
            System.out.println("First intance != null");
        }

        intance = null;

        // Second:手動 GC
        System.gc();		
        Thread.sleep(1000);
        if(intance == null) {
            System.out.println("Second intance == null");
        } else {
            System.out.println("Second intance != null");
        }
    }

}

--實做結果--

Object#finalize 方法也被遺棄,不再推薦被使用

● 為何需要休眠一秒?

因為 finalize 線程是運行在別的線程,優先權沒有主線程高,不一定會被先執行到,fialize 可以算是歷史的產物

如果沒有休眠可以看到下方的 finalize 根本來不及拯救

C. finalize 方法中拋出異常,GC 也不會有問題(GC 會吞下這個異常)


public class GCTest {

    private static GCTest gcTest;

    @Override
    protected void finalize() throws Throwable {
        System.out.println("GC - finalize");

        gcTest = this;

        // 並不會產生異常,GC 會吞下這筆異常
        throw new Exception("Test exception");
    }

    public static void main(String[] args) throws InterruptedException {
        gcTest = new GCTest();

        System.out.println("Before GC, GCTest: " + gcTest);
        gcTest = null;

        System.gc();
        Thread.sleep(1000);

        System.out.println("After GC, GCTest: " + gcTest);

    }

}

Java 物件引用差異

JDK 1.2 後就有出現不同的引用方式,強引用、軟引用、弱引用、虛引用,以下會使用 System.gc() 強制會收並觀察其引用狀況

這些引用物件都在 java.lang.ref 套件


強引用:GC 不回收

● 如果對象具有強引用 (new 物件),GC 就絕對不會回收它,寧可拋出 OOM (Out of Memory) 異常也不會回收該對象 來解決內存不足的問題


Object o = new Object();

o = null; // 將引用指針指向 null

參考佇列 ReferenceQueue 使用:追蹤回收

可以拿引用指針指向 null,促進 GC 回收(讓該對象不可達)

● 參考佇列 (ReferenceQueue) 可以使用在 軟引用、弱引用、虛引用 之上,可以用來追蹤 JVM 回收所參考的物件的活動

範例如下


void useWeakWithQueue() throws InterruptedException {

    String msg = new String("Apple");

    ReferenceQueue<String> rq = new ReferenceQueue<>();
    WeakReference<String> wf = new WeakReference<>(msg, rq);

    System.out.println("Before GC - WeakReference: " + wf.get());
    System.out.println("Before GC - ReferenceQueue: " + rq.poll());

    // 移除強參考(必須)
    msg = null;
    System.gc();
    Thread.sleep(1000);

    System.out.println("Before GC - WeakReference: " + wf.get());
    // 被回收後 wf 才加入 ReferenceQueue
    System.out.println("Before GC - ReferenceQueue: " + rq.poll());

}

藉由結果我們可以看到,當物件被 GC 回收後,才會出現在 ReferenceQueue 列表中,如果物件尚未被回收則不會出現在列表

軟引用 SoftReference

● 當 內存不夠 (OOM) 時就會 GC 軟引用對象,並回收他們來解決內存不足的問題

● 但如果 GC 回收後發現內存仍不足就會再往外拋出


// 設定 VM Options: -Xmx20m -Xms20m

public class testSoftRef {

    public static void main(String[] args) {
        String str = new String("Alien");

        SoftReference<String> soft = new SoftReference<>(str);

        str = null;	// 移除強引用
        System.out.println("soft String: " + soft.get());
        System.gc();

        System.out.println("After GC, soft String: " + soft.get());

        List<byte[]> list = new LinkedList<>();
        try {
            // 100 個 1M 對象
            for(int i = 0; i < 100; i++) {
                list.add(new byte[1024*1024]);
            }
            System.gc();
        } catch (Throwable e) {
            System.out.println("OOM-----------: " + soft.get());
        }

    }

}

A. 將強引用標記為軟引用

使用 new SoftReference<>(str)

B. 促進回收,將引用指向於 null,讓它離開 GC 判定的可達性區塊

str = null

● 如何觸發 OOM

A. 由於 JVM 通常在 內存足夠的狀況下不會回收 SoftReference 物件,所以在這邊設定堆區的 最大 -Xmx-Xms 最小值

B. 手動創建超出堆區大小的強引用(list.add(new byte[1024*1024])),就會發生 OOM

這時 GC 就會被喚起去檢查 SoftReference 物件

弱引用 WeakReference

● 生命週期比軟引用更短,每次 GC 發現後立刻回收不管內存是否足夠 (就算內存足夠也回收)


import java.lang.ref.WeakReference;

public class ReferenceTest {

    public static void main(String[] args) {
        String str = new String("Alien");
        //"1. "
        WeakReference<String> weak = new WeakReference<>(str);

        System.out.println("weak Integer: " + weak.get());

        System.gc();

        System.out.println("After GC, weak Integer: " + weak.get());
        //"2. "
        str = null;
        System.gc();

        System.out.println("After GC & ptr to null, weak Integer: " + weak.get());
    }

}

GC 回收的前提:該物件是不可達物件

A. 將強引用標記為弱引用

new WeakReference<>(str)

B. 促進回收,將引用指向於 null (至其為不可打狀態) 並且再次回收才可回收掉

不管記憶體是否足夠,只要觸發 GC 後,被發現就會直接回收

虛引用 PhantomReference

任何時候都可能被回收,其目的是,當被回收時會收到系統通知,功能是確認 GC 有正常運作


public class ReferenceTest {

    public static void main(String[] args) {
        String str = new String("Alien");

        ReferenceQueue<String> queue = new ReferenceQueue<>();

        PhantomReference<String> phantom = new PhantomReference<>(str, queue);

        System.out.println("phantom Integer: " + phantom.get());

        System.gc();

        System.out.println("After GC, phantom Integer: " + phantom.get());

        str = null;
        System.gc();

        System.out.println("After GC & ptr to null, phantom Integer: " + phantom.get());
    }
}

有可能你剛創建出 PhantomReference 引用物件後,立即就無法透過 PhantomReference 取得物件了

--實做結果--


其他知識點

清除物件引用

● 通常當我們不需要一個物件時,最好有習慣去手動清理物件,這可以幫助 GC 對於物件的可達性分析判斷;這裡我們可以針對常見的兩種狀況做分析

A. 方法中創建的物件

這種物件的引用在 JVM 中的 Java 棧,而物件本身則在 Java 堆中;在離開該方法後,引用就不會再可達!這種就會自動回收


void showMessage(String... strings) {
    StringBuffer stringBuffer = new StringBuffer();

    for (String string : strings) {
        stringBuffer.append(string).append("\t");
    }

    System.out.println(stringBuffer.toString());

    // 沒必要!
    // 因為離開該方法後該 StringBuffer 物件就會被回收(非即時)
    stringBuffer = null;
}

B. 方法中創建,但同時引用到類中(全局)


class MessageHandler {

    private List<StringBuffer> history = new ArrayList<>();

    void collectMessage(String... strings) {
        StringBuffer stringBuffer = new StringBuffer();

        for (String string : strings) {
            stringBuffer.append(string).append("\t");
        }

        // 將區域變量中的物件 加入到 全局變量,該物件就不會被回收(因為可達)
        history.add(stringBuffer);
    }

    String popMessage(int index) throws IllegalAccessException {
        if (index >= history.size()) {
            throw new IllegalAccessException();
        }

        StringBuffer tmp = history.get(index);
        String result = tmp.toString();

        // 移除全局變量對物件的引用
        history.remove(tmp);
        // 手動解除引用
        tmp = null;

        return result;
    }

}

不可變類別、可變類別:執行序的保護性複製

不可變類別:通常是一個 Data Class,它負責攜帶資料,其重點是

A. 不可變類別成員皆為 final (Kotlin 中就是 val) 不可改變

B. 由於它的不可變性,也就代表他是 read only 資料

C. 又由於它是 read only 資料,所以它 執行序(Thread, 線程)安全


class ImmutableData {

    public final String message;
    public final String name;

    ImmutableData(String message, String name) {
        this.message = message;
        this.name = name;
    }
}

可變類別:與其相反的就是可變類別,它與不可變類別的特性就剛好相反,所以在多執行序(Thread, 線程)使用中要小心


class MutableData {
    public String message;
    public String name;
}

保護性複製

在某些情況之下,雖然我們的類別是不可變,但其實其成員內是可變的,所以我們要用保護性複製來保護不可變資料;範例如下

● 非安全性使用範例:


public class CopyFinalObj {

    public static void main(String[] args) {
        unsafeOperation();
    }

    public static void unsafeOperation() {

        LinkedList<String> myMessages = new LinkedList<>() {
            {
                push("HelloWorld");
            }
        };

        UnsafeMessageBox box = new UnsafeMessageBox(myMessages);
        System.out.println(box);

        myMessages.push("Yoyo123");
        System.out.println(box);

    }

}

class UnsafeMessageBox {

    private final LinkedList<String> messages;

    public UnsafeMessageBox(LinkedList<String> message) {
        this.messages = message;
    }

    @Override
    public String toString() {
        return messages.toString();
    }

}

外部操縱影響到內部資料

保護性複製範例


public class CopyFinalObj {

    public static void main(String[] args) {
        safeOperation();
    }

    public static void safeOperation() {

        LinkedList<String> myMessages = new LinkedList<>() {
            {
                push("HelloWorld");
            }
        };

        SafeMessageBox box = new SafeMessageBox(myMessages);
        System.out.println(box);

        myMessages.push("Yoyo123");
        System.out.println(box);

    }

}

class SafeMessageBox {

    private final LinkedList<String> messages;

    public SafeMessageBox(LinkedList<String> message) {
        // 保護性複製!
        this.messages = new LinkedList<>(message);
    }

    @Override
    public String toString() {
        return messages.toString();
    }

}

外部操縱不會影響到內部資料


更多的 Java 語言相關文章

Java 語言深入

● 在這個系列中,我們全方位地探討了 Java 語言的各個核心主題,旨在幫助你徹底掌握這門強大的編程語言。無論你是想深入理解 Java 的基礎類型與變數作用域,還是探索異常處理與運算子的細節,這些文章都將為您提供寶貴的知識

深入 Java 物件導向

● 探索 Java 物件導向的奧妙,掌握介面、抽象類、繼承等重要概念!幫助你針對物件導向設計有更深入的了解!


Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

發表迴響