前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Java并发-推荐使用不可变对象的原因分析

Java并发-推荐使用不可变对象的原因分析

作者头像
Fisherman渔夫
发布2019-07-31 15:49:29
5870
发布2019-07-31 15:49:29
举报
文章被收录于专栏:渔夫

一、不可变对象的引出

在Java语法中,String即是不可变对象,一旦创建,假设你若想修改String对象值,只能重新创建String对象。 实现方式如下:1.将内部char类型数组用priovate以及final关键词修饰。2.将String类修饰为final 以下是String的JDK源码:

代码语言:javascript
复制
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
    //.....(省略很多其他实现细节)
1.1String类不可变实现的途径:
  1. final修饰的String,代表了String的不可继承性,final修饰的char[]代表了被存储的数据不可更改性。
  2. private修饰了char[]数组,并且不提供set方法,和final一起使得String实现了不可变性。
1.2保证String不可变的原因和目的:
  1. 只有当字符串是不可变的,字符串池才有可能实现,字符串池的实现可以在运行时节约很多heap空间。
  2. 如果字符串是可变的,那么会引起很严重的安全问题。譬如,数据库的用户名、密码都是以字符串的形式传入来获得数据库的连接。
  3. 因为字符串是不可变的,所以是多线程安全的,同一个字符串实例可以被多个线程共享。节约了线程同步问题上额外的开销。
  4. 因为字符串是不可变的,所以在它创建的时候HashCode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
1.3引入不可变对象的疑虑:

在并发编程中,不可变对象特别有用。由于创建后不能被修改,所以不会出现由于线程干扰产生的错误或是内存一致性错误。 但是程序员们通常并不热衷于使用不可变对象,因为他们担心每次创建新对象的开销。实际上这种开销常常被过分高估,而且使用不可变对象所带来的一些效率提升也抵消了这种开销。例如:使用不可变对象降低了垃圾回收所产生的额外开销,也减少了用来确保使用可变对象不出现并发错误的一些额外代码。 另外一点,我们创建对象应秉持避免创建不必要的对象为原则,而不是尽量创建更少的对象为原则。


二、不可变对象

2.1 什么是不可变对象

其实不光是String对象,Java中的很多对象都符合上述不可改变状态的特性。简而言之,当一个对象构造完成后,其状态就不再变化,我们称这样的对象为不可变对象(Immutable Object),这些对象关联的类为不可变类(Immutable Class)。

比如Java中的Integer、Double、Long等所有原生类型的包装器类型,也都是不可变的。

那么明明可以直接修改this对象,为何Java中还要大费周章地去构造一个全新的对象返回呢?那这就要从不可变对象的好处说起了。

2.2 不可变对象的优点
2.2.1 对并发友好

提到多线程并发,最让人苦恼的莫过于线程间共享资源的访问冲突,古往今来,多少Bug因此而生。即便是最有经验的程序员,面对多线程编程时,也往往需瞻前顾后,反复思量后,才能逐渐对自己编写的代码产生信心。如果多线程错误可以跟编译错误一样,能够被自动发现该有多好。

目前大多数语言中,面对多线程冲突问题,都是采用序列化访问共享资源的方案。Java也不例外,Java语言中的synchronize关键字,Lock锁对象等机制,都是为实施此类方案准备的。此类方案最大的弊端在于:能不能保证多线程间没有冲突,完全取决于程序员对共享资源加锁解锁的时机对不对。如果程序员加锁的时机有丝毫差错,Java是不负责检测的,可能你的单元测试、集成测试、预发布测试也发现不了,程序上线后也看上去一切正常,但是等到某一个重要的时刻,它会以一个突如其来的线上Bug的形式通知你,是不是欲哭无泪。

然而,解决多线程冲突问题还有一个方向,就是从多线程冲突的根因 —— 共享资源上入手

如果完全没有共享资源,多线程冲突问题就天然不存在了,比如Java中的ThreadLocal机制就是利用了这一点理念。

但是大多数时候,线程间是需要使用共享资源互通信息的。此时,如果该共享资源诞生之后就完全不再变更(犹如一个常量),多线程间共同并发读取该共享资源是不会产生线程冲突的,因为所有线程无论何时读取该共享资源,总是能获取到一致的、完整的资源状态,这样也能规避多线程冲突。不可变对象就是这样一种诞生之后就完全不再变更的对象,该类对象可以天生支持无忧无虑地在多线程间共享。

如果线程间对共享资源的访问不仅局限于读,还想改变共享资源的状态呢,这种时候不可变对象又能否从容应对呢?答案是肯定的。原理很简单,某个线程想要修改共享资源A的状态时,不要去直接修改A本身的状态,而是先在本线程中构造一个新状态的共享资源B,待B构造完整后,再用B去直接替换A,由于对引用赋值操作是原子性的,所以也不会造成线程冲突问题。不可变对象所提供的方法,不会改变自身的状态,最多构造一个新状态的新对象的返回,这也与上述思路完全契合。但是需要注意可见性问题,如果你想要A替换B后,其他所有线程实时感知到此变化,需要使用volatile关键字保证可见性。 如下:

代码语言:javascript
复制
public class Test {
    private volatile String shared = "shared"; //使用volatile关键字保证共享资源的可见性

    public void test() {
        new Thread(() -> {
            String newValue = shared.replace("s", "S"); //在本线程中先构建一个新String
            shared = newValue; //用新String替换共享资源,引用的赋值是原子性的
        }).start();
    }
}

值得注意的是,线程安全需同时考虑原子性和可见性问题,所以网上常说的不可变对象是线程安全的,其实是不严谨的。

所以,不可变对象的好处在于,只要对象符合不可变原则,该对象在线程间传递是不会产生冲突的。这就将以前的到处可能是坑的多线程编程解耦为安全的两步,首先使用不可变对象,然后在线程间传递不可变对象。这能显著减少人脑需要考虑的情况分支,让编程更加轻松和可控。

其实,所有的函数式编程语言Lisp、Haskell、Erlang等,都从语法层面保证你只能使用不可变对象,所以所有函数编程语言是天生对并发友好的,这也是在一些高并发场景中,函数式编程语言更受青睐的原因。

2.2.2 易于在进程内缓存

当一个对象被频繁访问,而生成该对象的开销较大时,经常需要进行进程内缓存,即将频繁访问的对象存入一个缓存集合中(比如Map),当需要使用该对象时,优先从缓存中提取。

使用进程内缓存就不得不面对缓存污染问题,当缓存的对象被提取使用时,如果上层业务代码修改了该缓存对象的状态,那么当再次从缓存中提取该对象时,该对象的状态已经不再是最开始加入缓存时的状态了,即已经被污染了。缓存污染会导致很多问题,比如业务数据被意外篡改、业务数据间的互相干扰等。

通常为了保证缓存不被污染,当我们从缓存中提取对象时,会返回原始缓存对象的一个深拷贝,这样无论上层业务代码对提取到的对象如何修改,均不会对缓存本身造成影响。

但是深拷贝毕竟有额外的性能开销,此时如果缓存的是不可变对象,就皆大欢喜了。因为你可以放心大胆的把缓存对象的引用返回给上层代码使用,因为无论上层代码怎样操作,它也无法修改一个不可变对象的状态,这也就天然规避了缓存污染问题,同时也可将深拷贝带来的性能开销延迟到真正需要修改对象时才发生。

2.2.3 更好的可维护性

当我们在代码中看到一个不可变对象时,心情是轻松的,因为这类对象很单纯,不会在哪个隐藏的逻辑分支中偷偷改变自身的状态,对代码的测试、调试和阅读理解都有好处。

2.3 不可变对象的局限
2.3.1 编程思维的转变

如果所有对象都被设计为不可变的,等价于使用函数式编程思维,编程思维上的变化并非所有程序员都能很好的适应,如果适应不了,强行推广只会适得其反。况且Java本身也并不是纯粹的函数式编程语言。

2.3.2 性能上的额外开销

由于不可变对象需要复制一份状态用于修改后返回新的的对象,如果设计和使用不当的话,可能因此形成性能瓶颈点。

但是不必过于担心性能问题,一方面内存拷贝速度极快,另外也并非所有额外的性能开销都是不可容忍的,代码性能测试时,你可能会发现很多各式各样的性能瓶颈点,大部分可能都是你意想不到的,所以过早考虑性能而放弃编码安全是不可取的。就好比汇编效率最高,但是也不会因此所有代码都直接汇编编程,遇到真正的性能瓶颈时,有针对性的做汇编层面的调优才是上策。

2.4final修饰在构造函数中的作用

对于含有final域的对象,JVM必须保证对对象的初始引用在构造函数之后执行,不能乱序执行(out of order),也就是可以保证一旦你得到了引用,final域的值都是完成了初始化的工作。

2.5 建议

在自己能力范围内,尽量优先考虑使用不可变对象的设计。性能问题可以不必过于担心,如果引发了性能瓶颈,再有针对性地做出调整。不可变对象对并发编程友好、易于在进程内缓存、且拥有更好的可维护性,建议在自己能力范围内,尽量优先考虑使用不可变对象的设计。


三、Oracle官方并发教程之不可变对象(案例说明)

3.1一个同步类的例子

SynchronizedRGB是表示颜色的类,每一个对象代表一种颜色,使用三个整形数表示颜色的三基色,字符串表示颜色名称。简单查看以下代码就知道案例的具体描述了。

代码语言:javascript
复制
public class SynchronizedRGB {

    // Values must be between 0 and 255.
    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;
    }

    public synchronized void invert() {
        red = 255 - red;
        green = 255 - green;
        blue = 255 - blue;
        name = "Inverse of " + name;
    }
}

使用SynchronizedRGB时需要小心,避免其处于不一致的状态。比如颜色数值和颜色名可以因为多线程的调动而对应不上。

3.2定义不可变对象的策略

以下的一些规则是创建不可变对象的简单策略。并非所有不可变类都完全遵守这些规则,不过这不是编写这些类的程序员们粗心大意造成的,很可能的是他们有充分的理由确保这些对象在创建后不会被修改。但这需要非常复杂细致的分析,并不适用于初学者。

  1. 不要提供set方法。(包括修改字段的方法和修改字段引用对象的方法);
  2. 将类的所有字段定义为final、private的;
  3. 不允许子类重写方法。简单的办法是将类声明为final,更好的方法是将构造函数声明为私有的,通过工厂方法创建对象;
  4. 如果类的字段是对可变对象的引用,不允许修改被引用对象。 1)不提供修改可变对象的方法。 2)不共享可变对象的引用。当一个引用被当做参数传递给构造函数,而这个引用指向的是一个外部的可变对象时,一定不要保存这个引用。如果必须要保存,那么创建可变对象的拷贝,然后保存拷贝对象的引用。同样如果需要返回内部的可变对象时,不要返回可变对象本身,而是返回其拷贝。

将这一策略应用到SynchronizedRGB有以下几步:

  1. SynchronizedRGB类有两个setter方法。第一个set方法只是简单的为字段设值(译者注:删掉即可),第二个invert方法修改为创建一个新对象,而不是在原有对象上修改。
  2. 所有的字段都已经是私有的,加上final即可。
  3. 将类声明为final的,避免类被继承
  4. 只有一个字段是对象引用,并且被引用的对象也是不可变对象(final private String name)。

经过以上这些修改后,我们得到了ImmutableRGB:

代码语言:javascript
复制
final public class ImmutableRGB {

    // Values must be between 0 and 255.
    final private int red;
    final private int green;
    final private int blue;
    final 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 int getRGB() {
        return ((red << 16) | (green << 8) | blue);
    }

    public String getName() {
        return name;
    }

    public ImmutableRGB invert() {
        return new ImmutableRGB(255 - red,
                       255 - green,
                       255 - blue,
                       "Inverse of " + name);
    }
}

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2019年07月28日,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、不可变对象的引出
    • 1.1String类不可变实现的途径:
      • 1.2保证String不可变的原因和目的:
        • 1.3引入不可变对象的疑虑:
        • 二、不可变对象
          • 2.1 什么是不可变对象
            • 2.2 不可变对象的优点
              • 2.3 不可变对象的局限
                • 2.4final修饰在构造函数中的作用
                  • 2.5 建议
                  • 三、Oracle官方并发教程之不可变对象(案例说明)
                    • 3.1一个同步类的例子
                      • 3.2定义不可变对象的策略
                      领券
                      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档