利用不变性解决并发问题

利用不变性解决并发问题

不变性模式

我们知道并发最容易产生线程不安全的问题,主要原因是存在数据竞争,如果多个线程对同一个变量只读不修改那么就不存在线程安全问题,这种思想其实很简单,但也是最容易被我们忽略,利用这种思想就衍生出了一种模式-不变性模式也称为Immutability,简单描述就是一个对象创建后,它的状态就不会发生改变。

不变性类

不可变性类定义

在Java中描述一个对象不可变一般采用final修饰,那么不可变性类也是如此,只要将类的属性全部采用fianl修饰,并且只允许方法只读那么这个类就是不可变性类,不过为了严谨说法还要求这个类被final修饰,因为被final修饰的类不可被继承,就可以避免其他类继承不可变性类后修改其属性值。

不可变性类验证

不可变类在SDK中存在很多,不过我们平时可能都没注意到,如Integer、Long、Double等等一些基础类的包装类基本上都是不可变类,这里以典型代表String为例证实不可变性,String我相信大多数人都了解一点如String不可变、底层采用字符数组存储等等,源码如下。

image-20220310223512984

上图验证了不可变性类属性一般由final修饰、类也由final修饰。

不少人这里就有疑问了,String类确实属性由final修饰,但是方法并没有全部只读,如replace不就是修改了变量的值吗?表面看确实如此我们可以通过源码分析如下

 public String replace(char oldChar, char newChar) {
     if (oldChar != newChar) {
         int len = value.length;
         int i = -1;
         // 现有值的字符串数组
         char[] val = value; /* avoid getfield opcode */
 ​
         while (++i < len) {
             // 找到第一个出现oldChar的位置
             if (val[i] == oldChar) {
                 break;
             }
         }
         if (i < len) {
             // 新值字符数组 不可变性体现在这里!!!!
             char buf[] = new char[len];
             // 将出现oldChar之前的值放入新值的字符数组中
             for (int j = 0; j < i; j++) {
                 buf[j] = val[j];
             }
             while (i < len) {
                 char c = val[i];
                 // 判断当前取出来的值否是为旧字符
                 buf[i] = (c == oldChar) ? newChar : c;
                 i++;
             }
             // 返回新值数组对象
             return new String(buf, true);
         }
     }
     return this;
 }
复制代码

从源码中我们得到了一个不可变性类如果需要修改属性值,那么只能重新创建一个对象将新属性值赋值给新对象,从而达到修改的目的,和可变性类的区别在于可变性类修改的是自身属性的值,不可变性类修改一次属性值就会创建一个新的对象这样势必造成内存的消耗。

这个问题是不是和线程创建问题很像呢?如果执行子任务就要new Thread()创建一个线程对象,那么线程的创建和销毁带来的性能消耗将直接影响应用,为了解决这个问题SDK推出线程池一次性创建多个线程,需要用时向线程池申请,不需要用时还给线程池这样就能避免线程反复的创建和销毁带来的性能消耗问题,那么不可变性类是不是也有池化思想呢?

当然是存在的这就是我们设计模式中的一种名为享元模式。

享元模式解决对象重复创建

什么是享元模式

享元模式(Flyweight Pattern):是一种软件设计模式。它使用共享物件,用来尽可能减少内存使用量以及分享资讯给尽可能多的相似物件;它适合用于只是因重复而导致使用无法令人接受的大量内存的大量物件。通常物件中的部分状态是可以分享。常见做法是把它们放在外部数据结构,当需要使用时再将它们传递给享元。

通俗讲就是享元模式类似于池化思想,通过享元模式创建对象时先去对象池中查看是否存在该对象,如果存在对象池中就使用,不存在就创建同时放入对象池中,这样就能减少对象的创建。

这个思想现在SDK已经运用在Long、Integer、Short、Byte等这些基本数据类型的包装类中,这里以Integer为例,它没有全部运用享元模式。

SDK如何运用享元模式

Integer能表达的最大区间是[-231,231-1],但是常用的不是很多所以SDK管方将常用的[-128,127]之间的数字做了缓存如下所示,在JVM启动时就会创建而且永远不会变,在Integer.valueOf()方法中可以看到方法调用,简化代码如下

 public static Integer valueOf(int i) {
     if (i >= -128 && i <= 127)
         return IntegerCache.cache[i + (128)];
     return new Integer(i);
 }
 // 缓存对象类
 private static class IntegerCache {
     static final int low = -128;
     static final int high;
     static final Integer cache[];
 ​
     static {
         high = 127;
         // 127 - (-128)  +1
         cache = new Integer[(high - low) + 1];
         int j = low;// -128
         // 缓存数据从-128开始到127为止
         for(int k = 0; k < cache.length; k++)
             cache[k] = new Integer(j++);
     }
     private IntegerCache() {}
 }
复制代码

到这里就可以解释为什么使用基本数据类型的包装类做为锁会有线程安全的问题了,代码验证如下

假设存在A,B两个类,互不干扰调用各自的setAX或者setBY时先去获取a1、b1对象锁,分两个线程t1、t2执行这里看应该是异步逻辑,t2会先执行完毕,t1需要等待setAX执行完毕才能解锁,但真是这样吗?

 public class Test1 {
     public static void main(String[] args) throws InterruptedException {
         Thread t1 = new Thread(()->{
             System.out.println("====开始调用setAX");
             A a = new A();
             a.setAX();
         });
 ​
         Thread t2 = new Thread(()->{
             System.out.println("====开始调用setBY");
             B b = new B();
             b.setBY();
         });
 ​
         t1.start();
         t2.start();
 ​
         t1.join();
         t2.join();
     }
 }
 ​
 class A{
     Long a1=Long.valueOf(1);
 ​
     public void setAX() {
         synchronized (a1) {
             try {
                 TimeUnit.SECONDS.sleep(5);
                 System.out.println("执行setAX完毕");
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
         }
     }
 }
 ​
 class B {
     Long b1=Long.valueOf(1);
 ​
     public void setBY(){
         synchronized (b1) {
             System.out.println("执行setBY完毕");
         }
     }
 }
复制代码

执行结果如下,在t1阻塞的时候t2并没有调用setBY方法而是也被阻塞了,等t1释放锁线程t2才执行setBY方法。

image-20220310235616708

如果将a1,b1的值换成128,缓存之外的值呢,线程t2会先执行完毕,不会等待t1执行完毕。

image-20220310235854967

得出结论,如果a1,b1的值是[-128,127]之间的值,因为采用了享元模式所以取的是缓存中的值也就是同一个值,所以t1、t2线程并发执行会有互斥关系,如果a1,b1的值不在这个区间内那么t1、t2线程就没有互斥关系。

不变模式的注意点

对象的所有属性都是 final 的,并不能保证不可变性,如下所示

如果属性是对象类型Foo,那么即使当前类Bar是不可变类,但是依然可以通过setAge方法修改Foo的属性值。

 class Foo{
     int age=0;
     String name="abc";
 }
 ​
 final class Bar{
     Foo foo;
     public Bar(Foo foo){
         this.foo = foo;
     }
     void setAge(int age){
         foo.age = age;
     }
 }
复制代码

如何正确的发布不可变类

不可变类虽然是线程安全的,但是并不代表它的引用就是线程安全,如下代码

Foo为不可变类线程安全,Bar线程不安全而它拥有Foo的对象引用foo,那么多线程下对foo的修改其实是没有可见性和原子性的。

 final class Foo{
     final int age=0;
     final String name="abc";
 }
 ​
 class Bar{
     Foo foo;
     public void setFoo(Foo foo){
         this.foo = foo;
     }
 }
复制代码

如何解决呢?采用原子引用

 class Bar{
     AtomicReference<Foo> atomicReference = new AtomicReference<>(new Foo());
     public void setFoo(Foo foo){
         Foo foo1 = atomicReference.get();
         atomicReference.compareAndSet(foo1,foo);
     }
 }
复制代码

最简单的不变性类

具备不变性类的对象只有一种状态,这种状态需要对象里面的不变属性共同维护,而还有一种更加简单的不变性类那就是无状态,什么是无状态呢?其实就是没有属性只有方法的类称之为无状态,无状态的对象没有线程安全问题,为什么呢?其实很简单因为只要是方法内部的变量都是局部变量,局部变量都是线程安全的。

\

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享