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

  

  一个由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修改会影响结果,如果是基本数据类型或者不可变类不会影响返回结果。

关于不影响基本类型与的原因参考:https://blog.csdn.net/abinge317/article/details/52253768

2.值传递与引用传递

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

2)引用传递(指针传递):也称为传地址。方法调用时,实际参数是对象(或数组),这时实际参数与形式参数指向同一个地址,在方法执行中,对形式参数的操作实际上就是对实际参数的操作,所以方法执行中形式参数的改变将会影响实际参数。

注意:

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

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

1.Integer采用引用传递

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

    public static void main(String[] args) {
        Integer a = 5;
        Integer b = a;
        b++;
        System.out.println(a);//5
        
        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作为参数传递的时候是地址传递,而非值传递。

第二种测试方法根据HashCode方法测试:

package cn.qlq.test;

import java.util.Arrays;

public class ArrayTest {
    public static void main(String[] args) {
        Integer in = 1;
        System.out.println(in.hashCode());//1
        test(in);
        System.out.println(in.hashCode());//1
    }

    public static void test(Integer in) {
        System.out.println(in.hashCode());//1
        in = 8;
        System.out.println(in.hashCode());// 8
    }
}

1
1
8
1

  在改变形参值的时候改变了引用地址。

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=new int[]{1,2};
    }
}

结果:

[10, 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,证明是引用传递。

另一种测试方法根据hashCode测试:

package cn.qlq.test;

import java.util.Arrays;

public class ArrayTest {
    public static void main(String[] args) {
        int a[] = { 1, 2 };
        System.out.println(a.hashCode());
        test(a);
        System.out.println(Arrays.toString(a));
    }

    public static void test(int a[]) {
        System.out.println(a.hashCode());
        a = new int[] { 10, 20 };
        System.out.println(a.hashCode());
    }
}

631201343
631201343
31755621
[1, 2]

总结一条:

  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指向相同对象。

图解:

  

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类型是不可变的类。

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

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

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

如下图:

猜你喜欢

转载自www.cnblogs.com/qlqwjy/p/9565303.html