约在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
}