約在7萬多年前,我們的智人祖先經歷了一場所謂的”認知革命”。這場革命就像是一把鑰匙,打開了潘多拉的魔盒,人類的對於虛構世界的腦洞從此一開不可收拾。同人類其他衆多的幻想一樣,對人事物的“複製“的這一虛構臆想,推進了文明的演進,直接或間接地催促了藝術這種文化形態的繁榮。
而現今,隨着各種終端的普及,”複製“這個詞也隨着互聯網一起傳播出去。無論是你每天在電腦裏使用ctrl
+c
和ctrl
+v
快捷鍵,還是各種網站對數字資源的二次分發,都屬於“複製”這一範疇。而這一切的基礎,無外乎計算機對信息載體的編碼和解碼,然後就被電信號傳播。
你會不會和我一樣,忍不住地要去幻想,若未來人類複雜的思想也能被編碼成一串串字節碼,那時候的世界又將會是怎樣呢?
然而正文內容和這個引子並沒太大的關係
JVM在等號賦值的時候都幹了些什麼?
定義一個Parent
類和Child
類
private class Parent {
public Parent() {
}
protected void test() {
// do sth ...
}
static {
// do sth ...
}
}
private class Child extends Parent {
public Child() {
// do sth ...
}
@Override
protected void test() {
super.test();
// do sth ...
}
static {
// do sth ,,,
}
}
靜被變量和常量先行
在類在容器初始化時,JVM會按照順序自上而下運行類中的靜態語句/塊或常量,如果有父類,則首先按照順序運行靜態語句/塊或常量。初始化類的行爲有且僅有一次。
這一過程中,JVM會在堆內存中創建一個Class對象的實例,指向我們初始化後的這個類。這個也被稱作爲方法區。
此時並沒有實例化該對象。
在堆內存創建實例
public static void main(String args[]) {
Child child = new Child();
}
main(String args[])標誌着這是一個主方法入口
main方法中,類又會按照這個順序執行全局變量的賦值,然後執行父類的無參構造函數和子類的構造函數。
在棧幀中,JVM會提前分配內存地址用以儲存方法參數與局部變量。在這個例子中,儲存的是args(如果有的話),和child在堆上的引用。
child對象會在堆內存中被實例化,其中包含它(及它父類)的成員變量(名稱和具體值或指針)和方法(名稱和具體實現)的索引。
靜態成員變量會保存一個引用地址
入棧和出棧
public static void main(String args[]) {
Child child = new Child();
child.test();
}
執行test()方法時,會執行父類的同名方法,再執行子類的邏輯。
因爲此方法執行了super.test(),而不是如隱形調用
而在內存操作裏,此時會有一個新的棧幀被壓入棧中,同樣的,該棧幀保存了方法中傳入的參數和局部變量。
由於該方法被其他方法調用(這裏是main()方法),棧幀中還有一個區域會保存main()方法的返回地址,這個區域被稱作VM元數據區
。在test()方法結束時,它將被推出棧。並且根據元數據區的返回地址,正確地跳回到main()方法中。
在拋出異常時,可以看到一層層的Stack Trace
而如果該方法有一個返回值,這個又該如何傳遞給調用方呢?
private class Parent {
...
protected String test() {
return "EvinK " + "is Awesome!";
}
...
}
private class Child extends Parent {
...
@Override
protected String test() {
String str = super.test();
return str;
}
...
}
操作數棧
在這個步驟中,發揮了重要的作用。它屬於棧幀的一個組成部分,JVM臨時用它來存放需要計算的變量,然後將計算的結果推出到棧幀的局部變量區。
區域/棧幀 | return語句 | super.test() | str = super.test() | return語句 |
---|---|---|---|---|
局部變量區 | str = “EvinK is Awesome!” | |||
操作數棧 | EvinK | EvinK is Awesome! | 指向局部變量str | |
- | is Awesome! |
使用等號複製時,發生了什麼
private class Child extends Parent {
public String name;
public Child(String name) {
this.name = name;
}
...
}
public static void main(String args[]) {
Child child = new Child("小明");
Child child2 = child;
}
前面已經說了,使用new
關鍵字時,會在堆內存中存放該類的實例。而棧中,會儲存這個在堆內存中這個實例的引用。
而child2這個對象之間由child賦值,也會在棧幀中的變量區,創建一個指向這個實例在堆內存地址的引用。
child2.name = "EvinK"; // -> child.name = "EvinK"
// == 比較的是對象間的引用
System.out.print(child2 == child); // always true
正是因爲這兩個變量指向了同一個內存地址,所以只要修改這兩者中的任何一個引用,都會導致另外一個局部變量被動改變。
而作爲程序開發者的我們,對此居然一無所知。
字符串也是對象
照這種說法,字符串操作豈不是很危險,稍不留神,就會得出完全不一樣的結果。
String a = "a";
String b = a;
b = "b";
// a是什麼?
操作 | 常量池 | 指向地址 |
---|---|---|
a = “a” | “a” | a -> “a” |
b = a | “a” | b -> “a” |
b = “b” | “a”, “b” | b -> “b” |
字符串也的確遵守這種“指向複製”規則。
b在重新被賦值後,並沒有在常量池中發現該字符串對象,於是JVM在常量池中創建了新的字符串對象”b”。
讓情況再複雜點
String java1 = "java";
String java2 = "java";
String java3 = java;
String java4 = new String(java);
String jav = "jav";
String a = "a";
String java5 = jav + a;
System.out.println(java1 == java2);
System.out.println(java1 == java3);
System.out.println(java1 == java4);
System.out.println(java1 == java5);
字符串java1,java2和java3相等,因爲它們指向了同一塊內存地址。對於java2和java3而言,它們聲明時內存地址時,發現了已存在的字符串對象”java”,於是直接將引用指向這塊地址。
java4和java1的引用不相等。使用new
關鍵字時,會強制在常量池重新生成一個同值但不同地址的字符串對象。
java5和java1的引用不相等。java5的引用指向操作數幀的一個臨時地址,將在出棧時被銷燬。
複製
說了這麼多,是不是有點跑題了?
太長不看
Java裏的所有類都隱式地繼承了Object類,而在 Object 上,存在一個 clone() 方法,它被聲明爲了protected
,所以我們可以在其子類中,使用它。
// Object Class
protected Object clone() throws CloneNotSupportedException {
if(!(this instanceof Cloneable)) {
throw new CloneNotSupportedException("Class" + getClass().getName() +
" doesn`t implement Cloneable");
}
return internalClone();
}
private native Object internalClone();
可以看到,它的實現非常的簡單,它限制所有調用 clone() 方法的對象,都必須實現 Cloneable 接口,否者將拋出 CloneNotSupportedException 這個異常。最終會調用 internalClone() 方法來完成具體的操作。而 internalClone() 方法,實則是一個 native 的方法。對此我們就沒必要深究了,只需要知道它可以 clone() 一個對象得到一個新的對象實例即可。
克隆
public class Person implements Cloneable {
public String name;
public Person(String name) {
this.name = name;
}
@Override
protected Object clone() {
try {
return super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return null;
}
}
public static void main(String args[]) {
Person ming = new Person("小明");
Person evink = (Person) ming.clone();
evink.name = "EvinK";
}
當一個類的成員變量都是簡單的基礎類型時,淺複製就可以解決我們的問題。
讓情況變得複雜一點
public class Person implements Cloneable {
public String name;
public int[] scores;
...
}
public static void main(String args[]) {
Person ming = new Person("小明");
ming.scores = new int[]{
86
};
Person evink = (Person) ming.clone();
evink.name = "EvinK";
evink.scores[0] = 89; // -> ming.scores[0] = 89;
System.out.println(evink.scores); // [I@246b179d
System.out.println(ming.scores); // [I@246b179d
}
經過了克隆( clone()
)方法的洗禮後,我們聲明的兩個對象終於不再指向同一個內存地址了。可是,爲什麼還會發生上面一段代碼的問題。
簡單描述一下就是,爲什麼複製這個行爲,會和我們預期的不一致?
在堆內存中,進行復制操作時,會再在堆內分配一個地址用來存放Person對象,然後將原來Person中的成員變量的值或引用複製一份到新的對象中。而在棧幀中,ming和evink指向的Person對象地址不同,在代碼上表現爲這兩者不相等。而由於其成員變量中可能含有其他對象的引用,所以,即使經過了複製操作,被克隆出的對象中的成員變量仍然指向相同的內存地址。
使用淺複製時,會跳過構造方法的實現。
深度複製
基於clone()方法的改進方案
clone()方法的最大弊端是其無法複製對象內部的對象,所以,只要使對象內部的對象實現Cloneable接口,再在具體實現裏使用構造函數生成新的對象,這樣就能確保使用clone()方法生成的對象一定是全新的。
基於序列化(serialization)的改進方案
public class Person implements Cloneable, Serializable {
public String name;
public int[] scores;
...
public Object deepCopy() {
Object obj = null;
try {
// 將對象寫成 Byte Array
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bos);
out.writeObject(this);
out.flush();
out.close();
// 從流中讀出 byte array,調用readObject函數反序列化出對象
ObjectInputStream in = new ObjectInputStream(
new ByteArrayInputStream(bos.toByteArray()));
obj = in.readObject();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
return obj;
}
}
public static void main(String args[]) {
Person ming = new Person("小明");
ming.scores = new int[]{
86
};
Person evink = (Person) ming.deepCopy();
evink.name = "EvinK";
evink.scores[0] = 89; // -> ming.scores = 86;
System.out.println(evink.scores); // [I@504bae78
System.out.println(ming.scores); // [I@246b179d
}