多线程同时读写同一个共享变量的时候存在并发问题。这里的必要条件之一是同时读写,如果只有同时读没有写,是不存在并发问题的。

解决并发问题最简单的办法就是让变量只读而不改变,这个方法很重要,于是被上升为了一种解决并发问题的设计模式:不变性(Immutablity)模式。所谓的不变性,就是对象一旦被创建之后,其状态不再发生变化,也就是变量一旦被赋值,就不允许修改了。没有修改操作也就保持了变量的不变性。

快速实现具备不可变性的类

实现一个不可变类,还是比较容易的,首先将一个类的所有属性都设置为 final ,并且只允许存在只读方法,那么这个类基本就具备了不可变性。更严格的做法是这个类本身也是 final 的,这样不允许继承,因为子类是可以继承并覆盖父类的方法,所有存在改变不可变性的可能,在工作中,需要使用这种更严格的方法保证不可变性。

JDK 中很多类都具备不可变性,但是由于它们使用起来太简单太频繁,反而被忽略了。例如最常用的字符串类 String基本数据类型包装类 LongIntegerDouble 等,这些对象的线程安全性都是靠不可变性来保证的

如果你去查看源码,会发现:这些声明属性方法,都严格遵守不可变的三个要求:类和属性都是 final 的,所有方法都是只读的。

但是我们平时使用字符串的,也经常使用 「替换 replace」函数,虽然大家都知道 String 是一个不可变类,那么这个函数是怎么实现字符串替换的呢,下面是作者精简过后的 JDK8String 源码,保留了关键属性 value[]replace() 方法,看完会发现, value[] 都是 final 的,replace 的实现是将替换后的字符串作为返回值返回了。

replace 源码:

image-20200815153615890

作者精简后的代码:

public final class String {
  // 底层的数据结构,使用数组来保存 char,将 char 组合成字符串
  private final char value[];
  
  // 字符替换
  String replace(char oldChar, char newChar) {
    // 无需替换,直接返回【这里源码不是直接判断了相等,而是判断了不等然后进入逻辑中操作,在 判断外直接 return this,不过语义和作者精简后的代码是一致的】
    if(oldChar == newChar) {
      return this;
    }
    
    int len = value.lenght;
    int i = -1;
    /* avoid getfield opcode */
    char[] val = value;
    
    // 定位到需要替换的字符位置
    while(++i < len) {
      if(val[i] == oldChar) {
        break;
      }
    }
    // 如果未找到 oldChar,则无需替换
    if( i>= len) {
      return this;
    }
    
    //创建一个新的char数组 buf, 这是关键,用来保存替换后的字符串
    char buf[] = new char[len];
    for (int j = 0; j < i; j++) {
      buf[j] = val[i];
    }
    while(i < len) {
      char c = val[i];
      // 替换操作
      buf[i] = (c == oldChar) ? newChar : c;
      i++;
    }
    // 创建一个新的字符串对象实例返回,原字符串对象不会发生任何变化
    return new String(buf,true);
  }
}

经过上面的分析,可以发现 不可变对象需要提供类似修改状态的功能的做法是:创建一个新的不可变对象,而不是去修改旧对象的状态,这是与可变对象的一个重要区别。

【所以之前总有面试题问字符串经过几次拼接之后堆中存在几个对象这种类型的问题...,就是因为 String 每次拼接都会产生一个新的对象,而如果在实际代码中大量的进行拼接操作,也会对性能产生影响。】

不可变对象的所有修改操作都是创建一个新的不可变对象,那么这样是不是会太浪费内存了呢?是的,这样确实浪费,于是我们有了以下的解决方法。

利用享元模式避免创建重复对象

面向对象设计模式中有一种享元模式Flyweight Pattern)。利用这个模式可以减少创建对象的数量,从而减少内存占用。 Java 中实现的 LongIntegerByteShort 等这些基本数据的包装类型都用到了享元模式。

下面通过分析 Long 这个类来说明它是如何使用享元模式优化对象创建的。

享元模式本质是一个对象池,利用享元模式创建对象的逻辑也很简单:创建之前去对象池中看看要创建的对象是否已经存在,如果已经存在,直接返回池中对象,不存在就创建一个新对象,并将新创建的对象放入池中。

Long 并没有照搬享元模式,其中维护了一个静态对象池,仅缓存了 [-128,127] 之间的数字,这个对象池在 JVM 启动时就已经创建完成,并且这个对象池不会变化,所以是静态的。

之所以这样设计,是因为 Long 对象有 264 种,数量太大,不适合全部缓存,而 [-128,127] 之间的数字利用率最高,所以将256个对象进行了缓存。

下面是 JDK8valueOf() 方法,利用到了 LongCache 这个缓存,可以结合这个例子加深理解:

// 缓存类,等价于对象池,只缓存 -128 ~ 127 之间的数字
private static class LongCache {
  private LongCache(){}

  static final Long cache[] = new Long[-(-128) + 127 + 1];

  static {
    for(int i = 0; i < cache.length; i++)
      cache[i] = new Long(i - 128);
  }
}

public static Long valueOff(long l) {
final int offset = 128;

// [-128,127] 之间的数字做了缓存
if(l >= -128 && l <= 127) {
    return LongCache.cache[(int)l+offset];
  }
  return new Long(l)
}

之前在在锁的相关章节提到过"IntengerString 类型的对象都不适合做锁",其实所有基础类型包装类都不适合做锁对象,因为锁要求对象是私有的,而使用这种类型对象做锁看上去是私有,实际上可能是共有的,导致锁并不能提供互斥的功能。例如下面代码中:本意是用 A 锁 al,B 锁 bl,各自管理各自的状态,但是实际上 al 和 bl 是一个对象,导致 A 和 B 用的是同一把锁。

class A {
  Long al = Long.valueOf(1);
  public void setAX() {
    synchronized (al) {
      // 具体业务代码
    }
  }
}

class B {
  Long bl = Long.valueOf(1);
  public void By() {
    synchronized (al) {
      // 具体业务代码
    }
  }
}

使用 Immutability 模式的注意事项

使用 不变性模式的时候需要注意以下两点:

  1. 对象的所有属性都是 final 的,并不能保证不变性。
  2. 不可变对象需要正确的发布

首先第一点:如果 final 修饰的是基础类型的变量long ,int 之类的,那么可以保证其值一旦被赋值就不会改变。

但是如果 final 修饰的是一个引用对象,那么这里不变的只是指向这个对象的引用不可变,其本身的状态是可以改变的。

例如下面代码中的示例:

class Foo{
  int age = 0;
  int name = "abc";
}
final class Bar {
  final Foo foo; // 虽然 foo 对象被 final 修饰,但是其中的 age 和 name 字段可以被改变
  void setAge(int a) {
    foo.age = a;
  }
}

所以需要我们确定不变性的边界,是否要求属性对象也具备不可变性。

下面是对象的发布:不可变对象虽然是线程安全的,但是音乐这些不可变对象的对象并不一定线程安全。

例如下面的示例代码:

// 线程安全的类 Foo
final class Foo {
  final int age =0;
  final String name = "abc";
}

// 非线程安全的 Bar
class Bar {
  Foo foo;
  void setFoo(Foo f) {
    this.foo = f;
  }
}

Foo 具备不可变性,线程安全。 Bar 是一个普通的类,并且对 foo 进行赋值的时候并没有使用同步工具,这导致在多线程环境下对 foo 引用的修改并不能保证原子性可见性

如果需要 foo 保证可见性,可以使用 volatile 关键字,如果同时需要可见性和原子性,由于只有一个变量,所以使用原子类就可以保证:

public class SafeWM {
  class WMRange {
    final int upper;
    final int lower;
    WMRange(int upper, int lower) {
      // 省略构造函数的实现
    }
  }
  
  final AtomicReference<WMRange> rf = new AtomicReference(new WMRange(0,0));
  
  // 设置库存上限
  void setUpper(int v) {
    while(true) {
      WMRange or = rf.get();
      // 检查参数合法性
      if(v < or.lower) {
        throw new IllegalArgumentException();
      }
      WMRanger nr = new WMRange(v,or.lower);
      if(rf.compareAndSet(or,nr)) {
        return ;
      }
    }
  }
}

总结

利用 Immutablity 模式解决并发问题,第一次见可能感觉有点陌生,但是其实我们一直都在享受它的成功。

Java 中的不可变类有很多,这些类的线程安全性都是靠不可变性来保证的。 Immutablity 模式最简单的解决并发问题的方法,当你尝试解决并发问题时,可以先考虑不可变模式,看看是否能够快速解决。

具备不变性的对象除了不可变对象还有一种就是无状态对象,这种对象类中没有属性,只有方法,除了无状态对象还有无状态服务、无状态协议等。

无状态有很多好处,最核心的就是性能。在多线程领域,无状态对象没有线程安全问题,无需同步处理,自然性能很好。在分布式领域,无状态意味着可以无限水平扩展,所以分布式的性能瓶颈一般不是出在无状态节点上,

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。