18.不可变对象

168 阅读14分钟

★不可变类

不可变类的状态不可以改变

顾名思义,一个类实例化一个对象后,对象的属性无法被改变,可称之为不可变类。

不存在并发修改:线程安全

​ JDK中的八大包装类、String类等都是不可变类,因为其内部的状态不可以改变。

如果一个对象在不能够修改其内部状态(属性),那么它就是线程安全的,因为不存在并发修改啊!

​ 不可变类,实际是另一种避免竞争的方式,因此它们的方法都是线程安全的。

​ String、Integer 等都是不可变类,所谓的不可变类是指这个类的实例一旦创建完成后,就不能改变其成员变量值。

​ 而String类作为我们最常用的类之一,通过字符串常量池大大提升了性能。不可变类因为是不可变的,所以天然具有线程安全性。

​ 有同学或许有疑问,String 有 replace,substring 等方法【可以】改变值啊,那么这些方法又是如何保证线程安全的呢?

但有同学会说,使用字符串时,也有一些跟修改相关的方法啊,比如 substring 等,那么下面就看一看这些方法是如何实现的,就以 substring 为例:

public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

//发现其内部是调用 String 的构造方法创建了一个新字符串。
//再进入这个构造看看,是否对 final char[] value 做出了修改:
public String(char value[], int offset, int count) {
    if (offset < 0) {
    	throw new StringIndexOutOfBoundsException(offset);
    }
    if (count <= 0) {
    	throw new StringIndexOutOfBoundsException(count);
    }
    if (offset <= value.length) {
   	 	this.value = "".value;
    	return;
    }
}
if (offset > value.length - count) {
	throw new StringIndexOutOfBoundsException(offset + count);
}
	this.value = Arrays.copyOfRange(value, offset, offset+count);
}
//结果发现也没有,构造新字符串对象时,会生成新的 char[] value,对内容进行复制 。
//这种通过创建副本对象来避免共享的手段称之为【保护性拷贝(defensive copy)】

查看源码发现有以下几个特点:

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

string对象在内存创建后就不可改变,不可变对象的创建一般满足以下5个原则:

1.不提供任何会修改类状态的方法,包括set方法,避免通过set方法改变成员变量的值,破坏不可变特性。

2.使用final修饰符修饰类,保证类不被继承。如果类可以被继承会破坏类的不可变性机制。如果子类覆盖父类的方法并且子类可以改变成员变量值,那么不能保证当前类是不可变。

3.保证所有成员变量必须私有被private修饰,并且加上final。 这种方式保证成员变量不可改变。但只做到这一步还不够,因为如果是对象成员变量,其值有可能在外部被改变。
如果类具有指向可变对象的域,则必须确保该类的使用者无法获得指向这些对象的引用。如构造器传入的对象直接赋值给成员变量,还是可以通过对传入对象的修改进而导致改变内部变量的值。所以还需要保护性拷贝弥补这个不足。

4、保护性拷贝弥补

5、一个类的private方法会隐式被指定为final方法。
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();   
    }   
}

​ 这种做法是为了防止对象外泄。防止通过获得内部可变成员对象后对成员变量直接操作,导致成员变量发生改变。

​ 在get方法中,不要直接返回对象本身,而是返回对象的拷贝。这种做法也是防止对象外泄,防止通过get获得内部可变成员对象后对成员变量直接操作,导致成员变量发生改变。

首先,关于类的成员变量(也就是属性),如果它们都不是不可变类,那么它们应该是私有的、final的。通过私有的封装来让属性外部无法修改,而final的作用是让属性初始化后就不能再改变(第3点);

如果类可以被继承会破坏类的不可变性机制,只要继承类覆盖父类的方法并且继承类可以改变成员变量值,那么一旦子类以父类的形式出现时,不能保证当前类是否可变。(第2点);

其次,关于类的方法,我们不能提供任何修改属性的方法(第1点),比如常见的setXXX方法就不能再出现了。

如果类成员变量本身不是不可变的,那么需要注意两点:

1、在初始化(比如构造器里)给该成员变量赋值时,应该取的是外部对象引用的克隆(第5点);

2、如果不可变类提供给外部用于获取该成员变量的方法时,应该使用该成员变量的克隆,而非直接返回成员变量本身。这两点的目的都是避免外部通过对象引用修改不可变类的内部成员变量(第5点)。

具体参考:

www.cnblogs.com/wuxun1997/p…

www.cnblogs.com/jaylon/p/57…

创建不可变类的五个条件

那么如何定义一个类为不可变类呢?要使类成为不可变类,遵循以下5条规则:

1.不要提供任何会修改类属性的方法;

2.保证类不会被继承;

3.使所有值域都为final4.使所有值域都成为private私有;

5.如果类具有指向可变对象的属性。则必须确保该类的使用者无法获得指向这些对象的引用。

​ 接下来我们从另一个角度来仔细过一下上面的规则:

  首先,关于类的成员变量(也就是属性),如果它们都是可变类,那么它们应该是私有的、final的。通过私有的封装来让属性外部无法修改,而final的作用是让属性初始化后就不能再改变(第3、4点);

  其次,关于类的方法,我们不能提供任何修改属性的方法(第1点),比如常见的setXXX方法就不能再出现了。

​ 如果类成员变量本身是可变的,那么需要注意两点:

​ 1.在初始化(比如构造器里)给该成员变量赋值时,应该取的是外部对象引用的克隆;

​ 2.如果不可变类提供给外部用于获取该成员变量的方法时,应该使用该成员变量的克隆,而非直接返回成员变量本身。

这两点的目的都是避免外部通过对象引用修改不可变类的内部成员变量(第5点)。

1.final修饰类,保证类不能继承,类中方法不能重写

2.private final修饰成员变量

1.为什么要用final修饰?

final修饰成员变量,可以被继承。但是不能被修改。不能被修改包含2个方面:

1.基本类型

值不能被修改。

2.引用类型

引用不能被修改。但是引用对象的值是可以被修改。

属性用 final 修饰保证了

1.属性在构造方法完成之前完成初始化,并且该属性只能被初始化1次

2.不能进行第2次显式的赋值。

举个例子

package base01;

import java.util.Date;

public class Immutable {
    private String name;
  	 public Immutable(String name, Date date) {
        this.name = name;
    }
    public static void main(String[] args) {
        String name = "a";
        Immutable t = new Immutable(name);
        //修改成功! 如果使用final修饰 在成员变量被初始化以后就无法被显式赋值
        t.name = "c";
    }
}

2.为什么还要用private修饰呢?

final的作用是让属性初始化后就不能再改变,而private通过私有的封装来让属性在外部无法修改

其实这里解释的有些勉强,只能说属性被私有是一般化的常态化的。这是一种规范:不能通过对象直接操作对象的属性,而是通过对象的方法获取对象属性的引用再去操作。

3.不提供改变成员变量的方法,包括set

防止通过set方法修改不可变类的成员变量

4.通过构造器初始化所有成员,进行深拷贝

防止通过构造方法传递引用构造对象,然后通过引用修改不可变类的成员变量。

这里的date引用是外部传进来的,外部可以在构造对象完成后,继续改变date的属性值。

public class Immutable {
    private  String name;
    private final Date date;
    public Immutable(String name, Date date) {
        this.name = name;
        this.date = date;
    }

    public String getName() {
        return name;
    }

    public Date getDate() {
        return date;
    }

    @Override
    public String toString() {
        return "Immutable{" +
                "name='" + name + '\'' +
                ", date=" + date +
                '}';
    }
}
    public static void main(String[] args) {
        String name = "a";
        Date today = new Date();

        Immutable t = new Immutable(name, today);
        System.out.println("Start Change: " + t.toString());

        //构造器初始化方法漏洞
        //String是不可变类,所以没有影响
        //Date是可变类所以需要注意。
        name = t.getName();
        name = "b";
        today.setTime(today.getTime() + 1000 * 60 * 60);
        System.out.println("After Change: " + t.toString());

        //设置值方法漏洞
        //String和Date都受到了影响
        today = t.getDate();
        t.name = "c";
        today.setTime(today.getTime() + 1000 * 60 * 60);
        System.out.println("After Change: " + t.toString());
    }
Start Change: Immutable{name='a', date=Tue May 10 09:20:15 CST 2022}
After Change: Immutable{name='a', date=Tue May 10 10:20:15 CST 2022}
After Change: Immutable{name='c', date=Tue May 10 11:20:15 CST 2022}

但是对于String来说String是不可变对象,是不会发生这种问题的。对于不可变对象来说,你是无法改变它的属性值的。所以这里是不需要担心的。但是最好还是一起改下:

public Immutable3(String name, Date date) {
        //这里注意String 是不可变的,所以是安全的
        //但是还是建议使用:
        this.name = new String(name);
        this.date = (Date) date.clone();
    }

5.在getter方法中,不返回对象本身,返回对象拷贝

    public String getName() {
        return name;
    }

    public Date getDate() {
        return date;
    }
  public String getName() {
        return new String(name);
    }

    public Date getDate() {
        return (Date) date.clone();
    }

创建不可变类

详见下面例子:

package base01;

import java.util.Date;

public class Immutable {
    private String name;
    private final Date date;

    public Immutable(String name, Date date) {
        this.name = name;
        this.date = date;
    }

    public String getName() {
        return name;
    }

    public Date getDate() {
        return date;
    }

    @Override
    public String toString() {
        return "Immutable{" +
                "name='" + name + '\'' +
                ", date=" + date +
                '}';
    }

    public static void main(String[] args) {
        String name = "a";
        Date today = new Date();

        Immutable t = new Immutable(name, today);
        System.out.println("Start Change: " + t.toString());

        //构造器初始化方法漏洞
        //String是不可变类,所以没有影响
        //Date是可变类所以需要注意。
        name = t.getName();
        name = "b";
        today.setTime(today.getTime() + 1000*60*60);
        System.out.println("After Change: " + t.toString());

        //设置值方法漏洞
        //String和Date都受到了影响
        today = t.getDate();
        t.name = "c";
        today.setTime(today.getTime() + 1000*60*60);
        System.out.println("After Change: " + t.toString());
    }
}

运行结果:

Start Change: Immutable{name='a', date=Tue May 10 08:46:19 CST 2022}
After Change: Immutable{name='a', date=Tue May 10 09:46:19 CST 2022}
After Change: Immutable{name='c', date=Tue May 10 10:46:19 CST 2022}

我们看到String是不可变类,所以我们很放心。而Date并非不可变类,所以它变了。

但是事实并非如此,String对象由于我们可以直接拿到对象的t.name的引用。

所以还是可以操作对象的name属性。

我们通过克隆来让对象的引用不被外部操纵:

package base01;

import java.util.Date;

public class Immutable2 {
    private String name;
    private final Date date;

    public Immutable2(String name, Date date) {
        this.name = name;
        this.date = (Date) date.clone();
    }

    public String getName() {
        return name;
    }

    public Date getDate() {
        return (Date) date.clone();
    }

    @Override
    public String toString() {
        return "Immutable2{" +
                "name='" + name + '\'' +
                ", date=" + date +
                '}';
    }

    public static void main(String[] args) {
        String name = "a";
        Date today = new Date();

        Immutable2 t = new Immutable2(name, today);
        System.out.println("Start Change: " + t.toString());

        //构造器初始化方法漏洞
        //String是不可变类,所以没有影响
        //Date是可变类所以需要注意。
        name = t.getName();
        name = "b";
        today.setTime(today.getTime() + 1000 * 60 * 60);
        System.out.println("After Change: " + t.toString());

        //设置值方法漏洞
        //String和Date都受到了影响
        today = t.getDate();
        t.name = "c";
        today.setTime(today.getTime() + 1000 * 60 * 60);
        System.out.println("After Change: " + t.toString());
    }
}
Start Change: Immutable2{name='a', date=Tue May 10 08:53:30 CST 2022}
After Change: Immutable2{name='a', date=Tue May 10 08:53:30 CST 2022}
After Change: Immutable2{name='c', date=Tue May 10 08:53:30 CST 2022}

发现String类型的name还是被修改了。参考Date类型的数据,我们也可以返回1个新的对象。

package base01;

import com.sun.xml.internal.stream.events.NamedEvent;

import java.util.Date;

public class Immutable3 {
    private final String name;
    private final Date date;

    public Immutable3(String name, Date date) {
        //这里注意String 是不可变的,所以是安全的
        //但是还是建议使用:
        this.name = new String(name);
        this.date = (Date) date.clone();
    }

    public String getName() {
        return new String(name);
    }

    public Date getDate() {
        return (Date) date.clone();
    }

    @Override
    public String toString() {
        return "Immutable3{" +
                "name='" + name + '\'' +
                ", date=" + date +
                '}';
    }

    public static void main(String[] args) {
        String name = "a";
        Date today = new Date();

        Immutable3 t = new Immutable3(name, today);
        System.out.println("Start Change: " + t.toString());

        //构造器初始化方法漏洞
        //String是不可变类,所以没有影响
        //Date是可变类所以需要注意。
        name = t.getName();
        name = "b";
        today.setTime(today.getTime() + 1000 * 60 * 60);
        System.out.println("After Change: " + t.toString());

        //设置值方法漏洞
        //String和Date都受到了影响
        today = t.getDate();
        //加了final 就不能再显式初始化赋值了
        //t.name = "c";
        today.setTime(today.getTime() + 1000 * 60 * 60);
        System.out.println("After Change: " + t.toString());
    }
}
Start Change: Immutable3{name='a', date=Tue May 10 09:00:05 CST 2022}
After Change: Immutable3{name='a', date=Tue May 10 09:00:05 CST 2022}
After Change: Immutable3{name='a', date=Tue May 10 09:00:05 CST 2022}

String对象的不可变性的优点

从上一节分析,String数据不可变类,那设置这样的特性有什么好处呢?我总结为以下几点:

1.字符串常量池的需要。

字符串常量池可以将一些字符常量放在常量池中重复使用,避免每次都重新创建相同的对象、节省存储空间。但如果字符串是可变的,此时相同内容的String还指向常量池的同一个内存空间,当某个变量改变了该内存的值时,其他遍历的值也会发生改变。所以不符合常量池设计的初衷。

2.线程安全考虑。

同一个字符串实例可以被多个线程共享。这样便不用因为线程安全问题而使用同步。字符串自己便是线程安全的。

3.类加载器使用字符串,不可变保证正确加载。

类加载器使用字符串,提供安全性,不可变保证正确加载。

譬如你想加载java.sql.Connection类,而这个值被改成了myhacked.Connection,那么会对你的数据库造成不可知的破坏。

4.支持hash映射和缓存。

因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。

String对象的不可变性的缺点

如果有对String对象值改变的需求,那么会创建大量的String对象。

String对象不是真的不可变

虽然String对象将value设置为final,并且还通过各种机制保证其成员变量不可改变。但是还是可以通过反射机制的手段改变其值。例如:

//创建字符串"Hello World", 并赋给引用s

  String s = "Hello World"; 

  System.out.println("s = " + s); //Hello World
//获取String类中的value字段

  Field valueFieldOfString = String.class.getDeclaredField("value");

  //改变value属性的访问权限

  valueFieldOfString.setAccessible(true);

  //获取s对象上的value属性的值

  char[] value = (char[]) valueFieldOfString.get(s);

  //改变value所引用的数组中的第5个字符

  value[5] = '_';

  System.out.println("s = " + s);  //Hello_World

打印结果为:

s = Hello World

s = Hello_World

发现String的值已经发生了改变。也就是说,通过反射是可以修改所谓的“不可变”对象的

总结

不可变类是实例创建后就不可以改变成员遍历的值。这种特性使得不可变类提供了线程安全的特性.

但同时也带来了对象创建的开销,每更改一个属性都是重新创建一个新的对象。

JDK内部也提供了很多不可变类如Integer、Double、String等。

String的不可变特性主要为了满足常量池、线程安全、类加载的需求。

合理使用不可变类可以带来极大的好处。