通过实例解析Java不可变对象原理

  不可变对象想必大部分朋友都不陌生,大家在平时写代码的过程中100%会使用到不可变对象,比如最常见的String对象、包装器对象等,那么到底为何Java语言要这么设计,真正意图和考虑点是什么?可能一些朋友没有细想过这些问题,今天我们就来聊聊跟不可变对象有关的话题。

一.什么是不可变对象

  下面是《Effective Java》这本书对于不可变对象的定义:

不可变对象(Immutable Object):对象一旦被创建后,对象所有的状态及属性在其生命周期内不会发生任何变化。

  从不可变对象的定义来看,其实比较简单,就是一个对象在创建后,不能对该对象进行任何更改。比如下面这段代码:

public class ImmutableObject {
  private int value;

  public ImmutableObject(int value) {
    this.value = value;
  }

  public int getValue() {
    return this.value;
  }
}

  由于ImmutableObject不提供任何setter方法,并且成员变量value是基本数据类型,getter方法返回的是value的拷贝,所以一旦ImmutableObject实例被创建后,该实例的状态无法再进行更改,因此该类具备不可变性。

  再比如我们平时用的最多的String:

public class Test {

  public static void main(String[] args) {
    String str = "I love java";
    String str1 = str;

    System.out.println("after replace str:" + str.replace("java", "Java"));
    System.out.println("after replace str1:" + str1);
  }
}

  输出结果:

  从输出结果可以看出,在对str进行了字符串替换替换之后,str1指向的字符串对象仍然没有发生变化。

二.深入理解不可变性

  我们是否考虑过一个问题:假如Java中的String、包装器类设计成可变的ok么?如果String对象可变了,会带来哪些问题?

  我们这一节主要来聊聊不可变对象存在的意义。

1)让并发编程变得更简单

  说到并发编程,可能很多朋友都会觉得最苦恼的事情就是如何处理共享资源的互斥访问,可能稍不留神,就会导致代码上线后出现莫名其妙的问题,并且大部分并发问题都不是太容易进行定位和复现。所以即使是非常有经验的程序员,在进行并发编程时,也会非常的小心,内心如履薄冰。

  大多数情况下,对于资源互斥访问的场景,都是采用加锁的方式来实现对资源的串行访问,来保证并发安全,如synchronize关键字,Lock锁等。但是这种方案最大的一个难点在于:在进行加锁和解锁时需要非常地慎重。如果加锁或者解锁时机稍有一点偏差,就可能会引发重大问题,然而这个问题Java编译器无法发现,在进行单元测试、集成测试时可能也发现不了,甚至程序上线后也能正常运行,但是可能突然在某一天,它就莫名其妙地出现了。

  既然采用串行方式来访问共享资源这么容易出现问题,那么有没有其他办法来解决呢?

  事实上,引起线程安全问题的根本原因在于:多个线程需要同时访问同一个共享资源。

  假如没有共享资源,那么多线程安全问题就自然解决了,Java中提供的ThreadLocal机制就是采取的这种思想。

  然而大多数时候,线程间是需要使用共享资源互通信息的,如果共享资源在创建之后就完全不再变更,如同一个常量,而多个线程间并发读取该共享资源是不会存在线上安全问题的,因为所有线程无论何时读取该共享资源,总是能获取到一致的、完整的资源状态。

  不可变对象就是这样一种在创建之后就不再变更的对象,这种特性使得它们天生支持线程安全,让并发编程变得更简单。

  我们来看一个例子,这个例子来源于:http://ifeve.com/immutable-objects/

public class SynchronizedRGB {
  private int red; // 颜色对应的红色值
  private int green; // 颜色对应的绿色值
  private int blue; // 颜色对应的蓝色值
  private String name; // 颜色名称

  private void check(int red, int green, int blue) {
    if (red < 0 || red > 255 || green < 0 || green > 255
        || blue < 0 || blue > 255) {
      throw new IllegalArgumentException();
    }
  }

  public SynchronizedRGB(int red, int green, int blue, String name) {
    check(red, green, blue);
    this.red = red;
    this.green = green;
    this.blue = blue;
    this.name = name;
  }

  public void set(int red, int green, int blue, String name) {
    check(red, green, blue);
    synchronized (this) {
      this.red = red;
      this.green = green;
      this.blue = blue;
      this.name = name;
    }
  }

  public synchronized int getRGB() {
    return ((red << 16) | (green << 8) | blue);
  }

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

  例如一个有个线程1执行了以下代码:

SynchronizedRGB color = new SynchronizedRGB(0, 0, 0, "Pitch Black");
int myColorInt = color.getRGB(); // Statement1
String myColorName = color.getName(); // Statement2

  然后有另外一个线程2在Statement 1之后、Statement 2之前调用了color.set方法:

color.set(0, 255, 0, "Green");

  那么在线程1中变量myColorInt的值和myColorName的值就会不匹配。为了避免出现这样的结果,必须要像下面这样把这两条语句绑定到一块执行:

synchronized (color) {
  int myColorInt = color.getRGB();
  String myColorName = color.getName();
}

  假如SynchronizedRGB是不可变类,那么就不会出现这个问题,比如将SynchronizedRGB改成下面这种实现方式:

public class ImmutableRGB {
  private int red;
  private int green;
  private int blue;
  private String name;

  private void check(int red, int green, int blue) {
    if (red < 0 || red > 255 || green < 0 || green > 255
        || blue < 0 || blue > 255) {
      throw new IllegalArgumentException();
    }
  }

  public ImmutableRGB(int red, int green, int blue, String name) {
    check(red, green, blue);
    this.red = red;
    this.green = green;
    this.blue = blue;
    this.name = name;
  }

  public ImmutableRGB set(int red, int green, int blue, String name) {
    return new ImmutableRGB(red, green, blue, name);
  }

  public int getRGB() {
    return ((red << 16) | (green << 8) | blue);
  }

  public String getName() {
    return name;
  }
}

  由于set方法并没有改变原来的对象,而是新创建了一个对象,所以无论线程1或者线程2怎么调用set方法,都不会出现并发访问导致的数据不一致的问题。

2)消除副作用

  很多时候一些很严重的bug是由于一个很小的副作用引起的,并且由于副作用通常不容易被察觉,所以很难在编写代码以及代码review过程中发现,并且即使发现了也可能会花费很大的精力才能定位出来。

  举个简单的例子:

class Person {
  private int age;  // 年龄
  private String identityCardID; // 身份证号码

  public int getAge() {
    return age;
  }

  public void setAge(int age) {
    this.age = age;
  }

  public String getIdentityCardID() {
    return identityCardID;
  }

  public void setIdentityCardID(String identityCardID) {
    this.identityCardID = identityCardID;
  }
}

public class Test {

  public static void main(String[] args) {
    Person jack = new Person();
    jack.setAge(101);
    jack.setIdentityCardID("42118220090315234X");

    System.out.println(validAge(jack));
    
    // 后续使用可能没有察觉到jack的age被修改了
    // 为后续埋下了不容易察觉的问题

  }

  public static boolean validAge(Person person) {
    if (person.getAge() >= 100) {
      person.setAge(100); // 此处产生了副作用
      return false;
    }
    return true;
  }

}

  validAge函数本身只是对age大小进行判断,但是在这个函数里面有一个副作用,就是对参数person指向的对象进行了修改,导致在外部的jack指向的对象也发生了变化。

  如果Person对象是不可变的,在validAge函数中是无法对参数person进行修改的,从而避免了validAge出现副作用,减少了出错的概率。

3)减少容器使用过程出错的概率

  我们在使用HashSet时,如果HashSet中元素对象的状态可变,就会出现元素丢失的情况,比如下面这个例子:

class Person {
  private int age;  // 年龄
  private String identityCardID; // 身份证号码

  public int getAge() {
    return age;
  }

  public void setAge(int age) {
    this.age = age;
  }

  public String getIdentityCardID() {
    return identityCardID;
  }

  public void setIdentityCardID(String identityCardID) {
    this.identityCardID = identityCardID;
  }

  @Override
  public boolean equals(Object obj) {
    if (obj == null) {
      return false;
    }

    if (!(obj instanceof Person)) {
      return false;
    }
    Person personObj = (Person) obj;
    return this.age == personObj.getAge() && this.identityCardID.equals(personObj.getIdentityCardID());
  }

  @Override
  public int hashCode() {
    return age * 37 + identityCardID.hashCode();
  }
}

public class Test {

  public static void main(String[] args) {
    Person jack = new Person();
    jack.setAge(10);
    jack.setIdentityCardID("42118220090315234X");

    Set<Person> personSet = new HashSet<Person>();
    personSet.add(jack);

    jack.setAge(11);

    System.out.println(personSet.contains(jack));

  }
}

 输出结果: 

  所以在Java中,对于String、包装器这些类,我们经常会用他们来作为HashMap的key,试想一下如果这些类是可变的,将会发生什么?后果不可预知,这将会大大增加Java代码编写的难度。

三.如何创建不可变对象

  通常来说,创建不可变类原则有以下几条:

  1)所有成员变量必须是private

  2)最好同时用final修饰(非必须)

  3)不提供能够修改原有对象状态的方法

最常见的方式是不提供setter方法

如果提供修改方法,需要新创建一个对象,并在新创建的对象上进行修改

  4)通过构造器初始化所有成员变量,引用类型的成员变量必须进行深拷贝(deep copy)

  5)getter方法不能对外泄露this引用以及成员变量的引用

  6)最好不允许类被继承(非必须)

  JDK中提供了一系列方法方便我们创建不可变集合,如:

Collections.unmodifiableList(List<? extends T> list)

  另外,在Google的Guava包中也提供了一系列方法来创建不可变集合,如:

ImmutableList.copyOf(list)

  这2种方式虽然都能创建不可变list,但是两者是有区别的,JDK自带提供的方式实际上创建出来的不是真正意义上的不可变集合,看unmodifiableList方法的实现就知道了:

  可以看出,实际上UnmodifiableList是将入参list的引用复制了一份,同时将所有的修改方法抛出UnsupportedOperationException。因此如果在外部修改了入参list,实际上会影响到UnmodifiableList,而Guava包提供的ImmutableList是真正意义上的不可变集合,它实际上是对入参list进行了深拷贝。看下面这段测试代码的结果便一目了然:

public class Test {

  public static void main(String[] args) {
    List<Integer> list = new ArrayList<Integer>();
    list.add(1);
    System.out.println(list);

    List unmodifiableList = Collections.unmodifiableList(list);
    ImmutableList immutableList = ImmutableList.copyOf(list);

    list.add(2);
    System.out.println(unmodifiableList);
    System.out.println(immutableList);

  }

}

  输出结果:

四.不可变对象真的"完全不可改变"吗?

  不可变对象虽然具备不可变性,但是不是"完全不可变"的,这里打上引号是因为通过反射的手段是可以改变不可变对象的状态的。

  大家看到这里可能有疑惑了,为什么既然能改变,为何还叫不可变对象?这里面大家不要误会不可变的本意,从不可变对象的意义分析能看出来对象的不可变性只是用来辅助帮助大家更简单地去编写代码,减少程序编写过程中出错的概率,这是不可变对象的初衷。如果真要靠通过反射来改变一个对象的状态,此时编写代码的人也应该会意识到此类在设计的时候就不希望其状态被更改,从而引起编写代码的人的注意。下面是通过反射方式改变不可变对象的例子:

public class Test {
  public static void main(String[] args) throws Exception {
    String s = "Hello World";
    System.out.println("s = " + s);

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

    char[] value = (char[]) valueFieldOfString.get(s);
    value[5] = '_';
    System.out.println("s = " + s);
  }

}

  输出结果:

参考文章:

http://ifeve.com/immutable-objects/

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • 浅谈java对象结构 对象头 Markword

    概述 对象实例由对象头.实例数据组成,其中对象头包括markword和类型指针,如果是数组,还包括数组长度; | 类型 | 32位JVM | 64位JVM| | ------ ---- | ------------| --------- | | markword | 32bit | 64bit | | 类型指针 | 32bit |64bit ,开启指针压缩时为32bit | | 数组长度 | 32bit |32bit | header.png compressed_header.png 可以看到

  • Java比较对象大小两种常用方法

    引入原因: Java中的对象,正常情况下,只能进行比较:== 或!= ,不能使用 < 或 > ,但是在开发时需要用到比较对象的大小 1.Comparable接口的使用(自然排序) 1.像String .包装类等实现了Comparable接口,重写了compareTo()方法,给出了比较两个对象大小的方法 2.像String .包装类等重写了compareTo()方法后,默认执行了从小到大的排序 3.重写compareTo()的规则: 如果当前对象this大于形参对象obj,则返回正整数,如果当

  • Java返回可变引用对象问题整理

    1.问题 /** * 输出: Mon Apr 26 10:54:10 CST 2010 * Mon Apr 26 10:54:10 CST 2010 */ public static void main(String[] args){ Example test = new Example(new Date()); Date d = test.getDate(); double tenYearsInMillisSeconds = 10 * 365.25 * 24 * 3600 * 1000; d.

  • java8 多个list对象用lambda求差集操作

    业务场景:调用同步接口获取当前全部有效账户,数据库已存在部分账户信息,因此需要筛选同步接口中已存在本地的帐户. 调用接口获取的数据集合 List<AccountVo> list = response.getData().getItems(); 本地查询出来的账户集合 List<Account> towList = accountRepository.findAll(); 筛选差集代码 List<AccountVo> distinctByUniqueList = list

  • Java 中的 String对象为什么是不可变的

    什么是不可变对象? String对象是不可变的,但这仅意味着你无法通过调用它的公有方法来改变它的值. 众所周知, 在Java中, String类是不可变的.那么到底什么是不可变的对象呢? 可以这样认为:如果一个对象,在它创建完成之后,不能再改变它的状态,那么这个对象就是不可变的.不能改变状态的意思是,不能改变对象内的成员变量,包括基本数据类型的值不能改变,引用类型的变量不能指向其他的对象,引用类型指向的对象的状态也不能改变. 区分对象和对象的引用 对于Java初学者, 对于String是不可变对

  • 详解Java中的不可变对象

    不可变对象想必大部分朋友都不陌生,大家在平时写代码的过程中100%会使用到不可变对象,比如最常见的String对象.包装器对象等,那么到底为何Java语言要这么设计,真正意图和考虑点是什么?可能一些朋友没有细想过这些问题,今天我们就来聊聊跟不可变对象有关的话题. 一.什么是不可变对象 下面是<Effective Java>这本书对于不可变对象的定义: 不可变对象(Immutable Object):对象一旦被创建后,对象所有的状态及属性在其生命周期内不会发生任何变化. 从不可变对象的定义来看,

  • Java面向对象基础,类,变量,方法

    一.面向对象的4个基本特征 抽象性.封装性.继承性和多态性. 抽象性分为过程抽象和数据抽象. 封装性 封装将数据以及加在这些数据上的操作组织在一起,成为有独立意义的构件.外部无法直接访问封装的数据,从而保证了这些数据的正确性. 如果外部需要访问类里面的数据,就必须通过接口.接口规定了可对一个特定的对象发出哪些请求. 继承性 继承是一种联结的层次模型,并允许和鼓励类的重用,它提供给了一种明确表述共性的方法.对象的一个新类可以从现有的类中派生,这个过程称为类继承.新类继承了原始类的特性,新类称为原始

  • 通过实例解析Java不可变对象原理

    不可变对象想必大部分朋友都不陌生,大家在平时写代码的过程中100%会使用到不可变对象,比如最常见的String对象.包装器对象等,那么到底为何Java语言要这么设计,真正意图和考虑点是什么?可能一些朋友没有细想过这些问题,今天我们就来聊聊跟不可变对象有关的话题. 一.什么是不可变对象 下面是<Effective Java>这本书对于不可变对象的定义: 不可变对象(Immutable Object):对象一旦被创建后,对象所有的状态及属性在其生命周期内不会发生任何变化. 从不可变对象的定义来看,

  • 实例解析Java中的构造器初始化

    1.初始化顺序 当Java创建一个对象时,系统先为该对象的所有实例属性分配内存(前提是该类已经被加载过了),接着程序开始对这些实例属性执行初始化,其初始化顺序是:先执行初始化块或声明属性时制定的初始值,再执行构造器里制定的初始值. 在类的内部,变量定义的先后顺序决定了初始化的顺序,即时变量散布于方法定义之间,它们仍就会在任何方法(包括构造器)被调用之前得到初始化. class Window { Window(int maker) { System.out.println("Window(&quo

  • 通过实例解析JMM和Volatile底层原理

    这篇文章主要介绍了通过实例解析JMM和Volatile底层原理,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 JMM和volatile分析 1.JMM:Java Memory Model,java线程内存模型 JMM:它是一个抽象的概念,描述的是线程和内存间的通信,java线程内存模型和CPU缓存模型类似,它是标准化的,用于屏蔽硬件和操作系统对内存访问的差异性. 2.JMM和8大原子操作结合 3.volatile的应用及底层原理探究 volat

  • java类和对象原理与用法分析

    本文实例讲述了java类和对象原理与用法.分享给大家供大家参考,具体如下: 面向对象编程OOP 类:相似对象的集合. 对象 对象:实体.一切可以被描述的事物. 属性:特征. 方法:动作,行为. 类和对象的区别 [1]类时抽象的,对象是具体的. [2]类是一个模板,创建出来的对象具备共同的属性和方法. [3]类是一种数据烈性.引用数据类型. 语法 public classs 类名{ //定义属性部分 属性1的类型 属性1: 属性2的类型 属性2: ... 属性3的类型 属性n; //定义方法部分

  • Python可变对象与不可变对象原理解析

    一.原理 可变对象:list dict set 不可变对象:tuple string int float bool 1. python不允许程序员选择采用传值还是传引用.Python参数传递采用的肯定是"传对象引用"的方式.实际上,这种方式相当于传值和传引用的一种综合.如果函数收到的是一个可变对象的引用,就能修改对象的原始值--相当于通过"传引用"来传递对象.如果函数收到的是一个不可变对象的引用,就不能直接修改原始对象--相当于通过"传值'来传递对象. 2

  • 通过实例解析java String不可变性

    一.原理 1.不变模式(不可变对象) 在并行软件开发过程中,同步操作似乎是必不可少的.当多线程对同一个对象进行读写操作时,为了保证对象数据的一致性和正确性,有必要对对象进行同步.而同步操作对系统性能是相当的损耗.为了能尽可能的去除这些同步操作,提高并行程序性能,可以使用一种不可改变的对象,依靠对象的不变性,可以确保其在没有同步操作的多线程环境中依然始终保持内部状态的一致性和正确性.这就是不变模式. 不变模式天生就是多线程友好的,它的核心思想是,一个对象一旦被创建,则它的内部状态将永远不会发生改变

  • 实例解析Java的Jackson库中的数据绑定

    数据绑定API用于JSON转换和使用属性访问或使用注解POJO(普通Java对象).以下是它的两个类型. 简单数据绑定 - 转换JSON,从Java Maps, Lists, Strings, Numbers, Booleans 和 null 对象. 完整数据绑定 - 转换JSON到任何JAVA类型.我们将在下一章分别绑定. ObjectMapper读/写JSON两种类型的数据绑定.数据绑定是最方便的方式是类似XML的JAXB解析器. 简单的数据绑定 简单的数据绑定是指JSON映射到Java核心

  • 解析Java程序中对象内存的分配和控制的基本方法

    一.对象与内存控制的知识点 1.java变量的初始化过程,包括局部变量,成员变量(实例变量和类变量). 2.继承关系中,当使用的对象引用变量编译时类型和运行时类型不同时,访问该对象的属性和方法是有区别的. 3.final修饰符特性. 二.java变量的划分与初始化过程 java程序的变量大体可以分为成员变量和局部变量,成员变量可以分为实例变量(非静态变量)和类变量(静态变量),一般我们遇到的局部变量会在下列几种情况中出现: (1)形参:在方法签名中定义的局部变量,由调用方为其赋值,随着方法结束消

  • 实例解析Java关于static的作用

    概述 只要是有学过Java的都一定知道static,也一定能多多少少说出一些作用和注意事项.如果已经对static了如指掌的请点击关闭按钮,看下去也只是浪费您宝贵时间而已.这篇随笔只是个人的习惯总结. 为什么需要static? 有时候我们并不想去new一个对象,只是单纯的想要调用一个函数,并且希望这个函数不会与包含它的类的其他对象有所关联.说得通俗点,即使没有创建对象,也能通过类本身来调用函数. static静态变量 被static修饰的变量属于类变量,通过字面意思就说明了这个变量的归属(类),

  • 通过实例解析Java class文件编译加载过程

    一.Java从编码到执行 首先我们来看一下Java是如何从编码到执行的呢? 我们有一个x.java文件通过执行javac命令可以变成x.class文件,当我们调用Java命令的时候class文件会被装载到内存中,这个过程叫做classloader.一般情况下我们自己写代码的时候会用到Java的类库,所以在加载的时候也会把Java类库相关的类也加载到内存中.装载完成之后会调用字节码解释器和JIT即时编译器来进行解释和编译,编译完之后由执行引擎开始执行,执行引擎下面对应的就是操作系统硬件了.下图是大

随机推荐