JAVA不可变类与可变类、值传递与引用传递深入理解

时间:2021-12-06 18:10:03

  

  一个由try...catch...finally引出的思考,在前面已经初步了解过不可变与可变、值传递与引用传递,在这里再次深入理解。

1.先看下面一个try..catch..finally的例子:

Person.java

package cn.qlq.test;

public class Person {
private int age;
private String name; public int getAge() {
return age;
} public void setAge(int age) {
this.age = age;
} public String getName() {
return name;
} public void setName(String name) {
this.name = name;
} @Override
public String toString() {
return "Person [age=" + age + ", name=" + name + "]";
} }
package cn.qlq.test;

public class FinallyTest {

    public static void main(String[] args) {
System.out.println(test1());
System.out.println(test2());
} public static String test1() {
String s = "s1";
try {
int i = 1 / 0;
s = "s2";
return s;
} catch (Exception e) {
s = "s3";
return s;
} finally {
s = "s4";
}
} public static Person test2() {
Person p = new Person();
p.setName("old");
try {
int i = 1 / 0;
return p;
} catch (Exception e) {
p.setName("exception");
return p;
} finally {
p.setName("finally");
}
}
}

结果:

s3
Person [age=0, name=finally]

总结:

  finally块的语句在try或catch中的return语句执行之后返回之前执行且finally里的修改语句可能影响也可能不影响try或catch中return已经确定的返回值,如果返回值类型为传址类型,则影响;传值类型(8种基本类型)与8种基本数据类型的包装类型与String(不可变类)不影响。若finally里也有return语句则覆盖try或catch中的return语句直接返回。

面试宝典解释的原因如下:

  程序在执行到return时首先会把返回值存到一个指定的位置(JVM中的slot),其次与执行finally块,最后再返回。如果finally中有return语句会以finally的return为主,相当于普通程序中的return结束函数。如果没有return语句,则会在finally执行完之后弹出slot存储的结果值并且返回,如果是引用类型则finally修改会影响结果,如果是基本数据类型或者不可变类不会影响返回结果。

补充:两个例子对上面很好的解释

(1)对不可变类不影响

package cn.xm.exam.test;

public class Test2 {
public static void main(String[] args) {
System.out.println(changeInteger());
} private static Integer changeInteger() {
Integer result = 1;
try {
int i = 1 / 0;
return result;
} catch (Exception e) {
result = 2;
return result;
} finally {
result = 3;
}
} }

结果:  2

    public static void main(String[] args) {
System.out.println(getValue());
} @SuppressWarnings("finally")
private static int getValue() {
try {
return 0;
} finally {
return 1;
}
}

结果:

1

(2)对引用类型可变类影响结果

package cn.xm.exam.test;

public class Test2 {
public static void main(String[] args) {
System.out.println(changeInteger());
} private static StringBuilder changeInteger() {
StringBuilder stringBuilder = new StringBuilder();
try {
int i = 1 / 0;
stringBuilder.append("1");
return stringBuilder;
} catch (Exception e) {
stringBuilder.append("2");
return stringBuilder;
} finally {
stringBuilder.append("3");
}
} }

结果:

23

2.值传递与引用传递

1)值传递:方法调用时,实际参数把它的值传递给对应的形式参数,形式参数只是用实际参数的值初始化自己的存储单元内容,是两个不同的存储单元,所以方法执行中形式参数值的改变不影响实际参数的值。

2)引用传递(指针传递):也称为传地址。方法调用时,实际参数是对象(或数组),这时实际参数与形式参数指向同一个地址,在方法执行中,对形式参数的操作实际上就是对实际参数的操作,所以方法执行中形式参数的改变将会影响实际参数。引用类型如果另一个采用new之后两者会指向不同的对象,也就不会再关联。

注意:

在Java中,原始数据类型在传递参数时都是按值传递,而包装类型在传递参数是是按引用传递,但包装类型在进行计算的时候会自动拆箱。

对象在函数调用传参的时候是引用传递(基本数据类型值传递),"="赋值也是引用传递(基本数据类型值传递)。

1.Integer采用引用传递

  由于8种基本数据类型和String的不可变性,加大了引用传值的理解程度,误认为"8种包装类型是“值传递",下面进行实例:

    public static void main(String[] args) {
Integer a = 5;
Integer b = a;
b++;
System.out.println(a);// String s1="s1";
String s2 = s1;
s2 = "s2";
System.out.println(s1);//s1
}

  解释:实际Integer和String是采用引用传递,=的时候a和b,s1和s2指向同一个对象。执行b++之后由于Integer的不可变性,b指向一个新的对象,b与a已经没有关系;s2="s2"之后s2指向一个新的对象,也与s1没关系。

为了验证Integer是采用引用传递,我门做案例如下:

package cn.qlq.test;

import java.util.concurrent.TimeUnit;

public class IntegerSyn {

  public static void main(String[] args) throws InterruptedException {
Integer index = 0;
TestObject a = new TestObject(index);
synchronized (index) {
new Thread(a).start();
index.wait();
}
System.out.println("end");
}
} class TestObject implements Runnable {
private Integer index; public TestObject(Integer index){
this.index = index;
} public void run() {
try {
//线程休眠的另一种方法
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (index) {
index.notify();
}
}
}

5s后打印end

解释:  在程序刚启动的时候把 Integer 的index 对象锁住 ,并且调用了 wait方法,释放了锁的资源,等待notify,最后过了5秒钟,等待testObject 调用notify 方法就继续执行了。大家都知道锁的对象和释放的对象必须是同一个,否则会抛出  java.lang.IllegalMonitorStateException 。由此可以证明 Integer作为参数传递的时候是地址传递,而非值传递。

2.数组采用引用传值

  其实数组也是对象类型,传递的时候也是采用引用传递,只是因为基本数据类型数的不可变性也增大了理解难度,例如:

package cn.qlq.test;

import java.util.Arrays;

public class ArrayTest {
public static void main(String[] args) {
int a[] = { 10, 20 };
test(a);
System.out.println(Arrays.toString(a));
} public static void test(int arr[]) {
arr[0] = 100;
}
}

结果:

[100, 20]

  数组实际是引用传递。(这点必须理解,因为String的不可变是基于char[]与深复制实现)。实际上数组是基于引用传递,不管是基本数据类型数组还是包装类型数组都是引用传递。测试代码如下:

package cn.qlq.test;

import java.util.concurrent.TimeUnit;

public class ArraySyn {

  public static void main(String[] args) throws InterruptedException {
int index[] = {1,2};
TestObject1 a = new TestObject1(index);
synchronized (index) {
new Thread(a).start();
index.wait();
}
System.out.println("end");
}
} class TestObject1 implements Runnable {
private int[] index; public TestObject1(int []index){
this.index = index;
} public void run() {
try {
//线程休眠的另一种方法
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (index) {
index.notify();
}
}
}

5s后打印end,证明是引用传递。

另一种测试方法:传递引用类型数组

package cn.qlq.test;

import java.util.Arrays;

public class ArrayTest {
public static void main(String[] args) {
Person[] p = new Person[2];
Person p1 = new Person();
p1.setName("p1");
Person p2 = new Person();
p2.setName("p2");
p[0] = p1;
p[1] = p2;
testArr(p);
System.out.println(Arrays.toString(p));
} private static void testArr(Person[] p) {
p[0].setName("p1p1");
}
}

结果:

[Person [age=0, name=p1p1], Person [age=0, name=p2]]

总结一条:

  8种基本数据类型是值传递,8种基本数据类型与String与数组是引用传递,我们程序中的类也是引用传递,但是由于String与8种基本数据类型的不可变性,所以每次赋予新值的时候都是新指向一个对象。如果是函数调用是形参和实参指向同一个对象,所以改变实参的时候相当于新创一个对象并赋给形参,对实参不会造成影响。

3.String引用传递图解

更进一步的理解:"引用传值也是按值传递,只不过传的是对象的地址"。

比如下面一段代码:

package cn.qlq.test;

import java.util.Arrays;

public class ArrayTest {
public static void main(String[] args) {
String s = "hello";
System.out.println(s.hashCode()); test(s);
System.out.println(s);
} public static void test(String s1) {
System.out.println(s1.hashCode());
s1 = "world";
System.out.println(s1.hashCode());
}
}

结果:

99162322
99162322
113318802
hello

解释:调用test方法的时候采用引用传递(将s的地址传下去),执行s1="world"是新创一个"world"并赋值给s1,也就是s1此时已经指向其他对象,不再与s指向相同对象。

图解:

  JAVA不可变类与可变类、值传递与引用传递深入理解

4.补充:

  引用类型如果另一个采用 = 赋值 改变引用之后两者会指向不同的对象(因为这个纠结了一下午,new相当于新创一个对象并且赋值给该变量,如果不想让其new可以采用final限制为引用不可变),而且将一个静态变量赋值给局部变量的时候改变局部变量的值也会影响static变量的值,所以如果要定义真正的不可变对象可以用final变量。

Person.java

class Person {
public static Person person = new Person("1", 1);
private String name = "zhangsan";
private int age = 25; @Override
public String toString() {
return "Person [name=" + name + ", age=" + age + "]";
} public String getName() {
return name;
} public void setName(String name) {
this.name = name;
} public int getAge() {
return age;
} public void setAge(int age) {
this.age = age;
} public Person(String name, int age) {
super();
this.name = name;
this.age = age;
}
}

(1)测试一:测试new不影响原对象

    /**
* new之后是新创建一个对象并赋值给该变量,不会影响原来的变量
*/
private static void test3() {
Person p1 = Person.person;
System.out.println("p1:"+p1);
System.out.println("Person.person:"+Person.person);
p1 = new Person("2", 2);
System.out.println("--------p1重新new之后的值------");
System.out.println("p1:"+p1);
System.out.println("Person.person:"+Person.person);
}

结果:

p1:Person [name=1, age=1]
Person.person:Person [name=1, age=1]
--------p1重新new之后的值------
p1:Person [name=2, age=2]
Person.person:Person [name=1, age=1]

(2)将static变量引用传递给局部变量,改变局部变量也可以影响static变量

    /**
* 修改一个变量会影响static变量对应引用类型的值
*/
private static void test4() {
Person p1 = Person.person;
System.out.println("p1:" + p1);
System.out.println("Person.person:" + Person.person);
p1.setName("20");
p1.setAge(20);
System.out.println("--------p1改变值之后------");
System.out.println("p1:" + p1);
System.out.println("Person.person:" + Person.person);
}

结果:

p1:Person [name=1, age=1]
Person.person:Person [name=1, age=1]
--------p1改变值之后------
p1:Person [name=20, age=20]
Person.person:Person [name=20, age=20]

(3)测试2:(这个例子更加的可以理解)

public class PlainTest {
private Person per = Person.person; public static void main(String[] args) {
test1();
} private static void test1() {
PlainTest p1 = new PlainTest();
Person.person = new Person("2", 2);
System.out.println("Person.person:" + Person.person);
System.out.println("p1的per:" + p1.per); PlainTest p2 = new PlainTest();
System.out.println("p2的per:" + p2.per);
}
}

结果:

Person.person:Person [name=2, age=2]
p1的per:Person [name=1, age=1]
p2的per:Person [name=2, age=2]

test1解释:

  PlainTest p1 = new PlainTest();此时其实例变量per与Person的person指向堆中同一个对象。

  Person.person = new Person("2", 2);  此时新出那个键一个对象并且Person的person会指向该对象。但是p1.per指向的地址还是原来的地址。

  PlainTest p2 = new PlainTest();   此时新建一个PlainTest2,其成员变量与上面的Person.person指向同一对象(修改后的对象)

(4)测试3:(与上面例子3结合理解更好)

public class PlainTest {
private Person per = Person.person; public static void main(String[] args) {
test2();
}private static void test2() {
PlainTest p1 = new PlainTest();
Person.person.setName("2");
System.out.println("Person.person:" + Person.person);
System.out.println("p1的per:" + p1.per); PlainTest p2 = new PlainTest();
System.out.println("p2的per:" + p2.per);
}
}

结果:

Person.person:Person [name=2, age=1]
p1的per:Person [name=2, age=1]
p2的per:Person [name=2, age=1]

  这个例子很好解释,三个person都指向同一对象,所以修改任何一个都会影响剩下两个。

5.如果一个对象被赋予null值,也相当于与原来的对象脱离关系,被赋予null会孤立原来堆中的对象,也就是会被GC,前提是原来堆中的对象没有被其他变量引用

    public static void main(String[] args) {
Person p = new Person("张三", 0);
Person p1 = p;
p=null;
System.out.println(p1+"\t"+p1.hashCode());
System.out.println(p+"\t"+p.hashCode());
}

结果:(被重新赋值为null不会影响与之指向同一对象的引用,只是自己不再指向堆中的对象。)

Person [name=张三, age=0] 1605870897
Exception in thread "main" java.lang.NullPointerException
at cn.qlq.test.PlainTest.main(PlainTest.java:11)

  

3.可变类与不可变类

不可变类:所谓的不可变类是指这个类的实例一旦创建完成后,就不能改变其成员变量值。如JDK内部自带的很多不可变类:Interger、Long和String(8种基本数据类型的包装类和String都是不可变类)等。不可变类的意思是一旦这个对象创建之后其引用不会改变,每次重新赋值会新增一个对象。不可变类是实例创建后就不可以改变成员遍历的值。这种特性使得不可变类提供了线程安全的特性但同时也带来了对象创建的开销,每更改一个属性都是重新创建一 个新的对象。例如String s = "s1",s = "s2"实际上是创建了两个对象,第二次将其值指向新的"s2".。

可变类:相对于不可变类,可变类创建实例后可以改变其成员变量值,开发中创建的大部分类都属于可变类。

关于更详细的介绍参考:https://www.cnblogs.com/qlqwjy/p/7944456.html

在这里我们只需要明白8种基本数据类型的包装类和String类型是不可变类,其余我们程序中的大部分类都是可变类。

不可变类的设计原则:

1. 类添加final修饰符,保证类不被继承。
    如果类可以被继承会破坏类的不可变性机制,只要继承类覆盖父类的方法并且继承类可以改变成员变量值,那么一旦子类以父类的形式出现时,不能保证当前类是否可变。 2. 保证所有成员变量必须私有,并且加上final修饰(不可变指的是引用不可变,也就是不可以重新指向其他对象)
    通过这种方式保证成员变量不可改变。但只做到这一步还不够,因为如果是对象成员变量有可能再外部改变其值。所以第4点弥补这个不足。 3. 不提供改变成员变量的方法,包括setter
    避免通过其他接口改变成员变量的值,破坏不可变特性。 4.通过构造器初始化所有成员,进行深拷贝(deep copy) 如果构造器传入的对象直接赋值给成员变量,还是可以通过对传入对象的修改进而导致改变内部变量的值。例如: public final class ImmutableDemo {
private final int[] myArray;
public ImmutableDemo(int[] array) {
this.myArray = array; // wrong
}
}
这种方式不能保证不可变性,myArray和array指向同一块内存地址,用户可以在ImmutableDemo之外通过修改array对象的值来改变myArray内部的值。
为了保证内部的值不被修改,可以采用深度copy来创建一个新内存保存传入的值。正确做法: public final class MyImmutableDemo {
private final int[] myArray;
public MyImmutableDemo(int[] array) {
this.myArray = array.clone();
}
}
5. 在getter方法中,不要直接返回对象本身,而是克隆对象,并返回对象的拷贝
    这种做法也是防止对象外泄,防止通过getter获得内部可变成员对象后对成员变量直接操作,导致成员变量发生改变

string对象在内存创建后就不可改变,不可变对象的创建一般满足以上5个原则,我们看看String代码是如何实现的。

public final class String
implements java.io.Serializable, Comparable<String>, CharSequence
{
/** The value is used for character storage. */
private final char value[];//数组是引用传递
/** The offset is the first index of the storage that is used. */
private final int offset;
/** The count is the number of characters in the String. */
private final int count;
/** Cache the hash code for the string */
private int hash; // Default to 0
....
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length); // deep copy操作
}
...
public char[] toCharArray() {
// Cannot use Arrays.copyOf because of class initialization order issues
char result[] = new char[value.length];
System.arraycopy(value, 0, result, 0, value.length);
return result;
}
...
}

如上代码所示,可以观察到以下设计细节:

  1. String类被final修饰,不可继承
  2. string内部所有成员都设置为私有变量
  3. 不存在value的setter
  4. 并将value和offset设置为final。
  5. 当传入可变数组value[]时,进行copy而不是直接将value[]复制给内部变量.
  6. 获取value时不是直接返回对象引用,而是返回对象的copy.

这都符合上面总结的不变类型的特性,也保证了String类型是不可变的类。

补充:深复制与浅赋值区别:

浅复制:被赋值的对象与原对象都含有相同的值,而所有对其他对象的引用仍然指向原来的对象。换言之,浅复制仅仅赋值所考虑的对象,而不复制它所引用的对象。

深赋值:被复制的对象的所有变量都有与原对象相同的值,除去那些引用其他对象的变量。那些引用其他对象的变量将指向被赋值的新对象,而不再是原来的那些被引用的对象。换言之,深复制把复制的对象所引用的对象都复制了一遍。

如下图:

JAVA不可变类与可变类、值传递与引用传递深入理解

  总结:

  (1)关于finally:

  finally块的语句在try或catch中的return语句执行之后返回之前执行且finally里的修改语句可能影响也可能不影响try或catch中return已经确定的返回值,如果返回值类型为传址类型,则影响;传值类型(8种基本类型)与8种基本数据类型的包装类型与String(不可变类)不影响。若finally里也有return语句则覆盖try或catch中的return语句直接返回,相当于普通流程中的return语句。

面试宝典解释的原因如下:

  程序在执行到return时首先会把返回值存到一个指定的位置(JVM中的slot),其次与执行finally块,最后再返回。如果finally中有return语句会以finally的return为主,相当于普通程序中的return结束函数。如果没有return语句,则会在finally执行完之后弹出slot存储的结果值并且返回,如果是引用类型则finally修改会影响结果,如果是基本数据类型或者不可变类不会影响返回结果。

  (2)值传递与引用传递:

    =与函数调用是引用传递,8种基本数据类型采用值传递,其包装类型与String与其他我们手写的类都是引用传递。只是由于String和8种包装类型都是不可变类,所以每次操作都是新创一个对象并重新赋给引用;在函数调用的时候,如果形参是String或者8种包装类型,操作形参不会影响实参,操作形参相当于重新创建对象不会影响原实参。

  

  (3)可变与不可变

    String与8种包装类型、BigInteger、BigDecimal是不可变类,不可变的意思是每次更换值都会重新生成对象并赋给引用。不用考虑线程安全。我们也可以设计自己的不可变类。

    其他我们手写的一般是可变类。