天天看点

高并发面试题-什么是不可变对象设计模式?

作者:架构师面试宝典
高并发面试题-什么是不可变对象设计模式?

在开发中涉及到的所有关于多线程的问题都离不开共享资源的存在。那么什么是共享资源,共享资源就是被多个线程共同访问的数据资源,而且每个线程都会引起它的变化。伴随共享资源而生的新问题就是线程安全,线程安全的主要目的就在于受控制的并发访问中防止数据发生变化。这里的控制手段除了synchronized关键字对资源进行同步读写操作以外,还可以在线程之间不共享资源状态,或者将资源状态设置为不可改变。

  在这次的分享中就提供了一个新的概念就是不可改变对象,这样就可以脱离synchronized关键字的约束。

不可变对象设计模式

在实际开发中了解过Java底层的都知道,对于synchronized关键字和显式锁Lock都是通过设置一块不可变的内存标识来控制对象的同步访问的,这样的话在一定程度上会牺牲系统的性能。这就需要提出一个新的概念来满足系统性能损耗同时又能实现同步操作的方式,这就出现了不可变对象设计。

  在Java核心类库中提供了大量的不可变对象范例,例如在java.long.String中的每个方法都没有同步修饰,但是在多线程访问的情况下String对象却是线程安全的,又如在Java8中t通过Stream修饰的ArrayList在函数式方法并行访问的情况下也是线程安全的。也就是说所谓的不可变对象就是没有机会修改他,就算是进行了修改就会产生一个新的对象。也就是说String s1 =“nihui”,String s2 = s1+"Java "实际上是两个对象。

  在Java中有些线程不安全的对象被不可变对象处理之后同样是具有了不可变性。例如之前所述的Java8中Stream修饰ArrayList的例子。代码如下。

public class ArrayListStream {
    public static void main(String[] args){
        List<String> list = Arrays.asList("Java","Thread","Concurrency","Scala","Clojure");

        list.parallelStream().map(String::toUpperCase).forEach(System.out::println);

        list.forEach(System.out::println);
    }
}

           

虽然List在并行环境下执行,但是Stream中的每个操作都是在全新的List中,根本不会影响到最原始的list对象,这个实际就比较符合不可变对象的基本思想。

线程不安全累加器使用

从上的内容可以看出不可变对象的核心思想在于不给外部改变共享资源的机会,通过这样的方式来避免多线程情况下的数据冲突导致数据不一致的情况,也避免了对于锁的过分的依赖导致的性能低下的问题。下面的例子是仿照String类实现的不可变对象。

public class IntegerAccumulator {

    private int init;

    public IntegerAccumulator(int init) {
        this.init = init;
    }

    public int add(int i){
        this.init+=i;
        return this.init;
    }

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

    public static void main(String[] args){
        IntegerAccumulator accumulator = new IntegerAccumulator(10);
        IntStream.range(0,3).forEach(i->new Thread(()->{
            int inc = 0;
            while (true){
                int oldValue = accumulator.getValue();
                int result = accumulator.add(inc);
                System.out.println(oldValue+"+"+inc+"="+result);

                if (inc+oldValue != result) {
                    System.out.println("ERROR: "+oldValue+"+"+inc+"="+result);
                }
                inc++;
                slowly();
            }
        }).start());
    }

    private static void slowly() {
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
           

我们会发现,启动了三个线程同时访问对象并没有对共享资源进行加锁处理,也没有进行不可变的设计,在程序执行过程中偶尔会出现错误。也就是说出现了错误

方法同步增加线程安全性

在上面的例子说出现问题是肯定的,共享资源在多个线程访问的时候没有做任何的同步控制,导致出现了数据不一致的问题。下面来修改上面的代码

public class IntegerAccumulator {


    private int init;

    public IntegerAccumulator(int init) {
        this.init = init;
    }

    public int add(int i) {
        this.init += i;
        return this.init;
    }

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

    public static void main(String[] args) {
        IntegerAccumulator accumulator = new IntegerAccumulator(10);
        IntStream.range(0, 3).forEach(i -> new Thread(() -> {
            int inc = 0;
            while (true) {
                int oldValue;
                int result;

                synchronized (IntegerAccumulator.class) {
                    oldValue = accumulator.getValue();
                    result = accumulator.add(inc);
                }

                System.out.println(oldValue + "+" + inc + "=" + result);

                if (inc + oldValue != result) {
                    System.out.println("ERROR: " + oldValue + "+" + inc + "=" + result);
                }
                inc++;
                slowly();
            }
        }).start());
    }
}
           

在上面的代码中将数据同步放到了线程逻辑执行单元中,而在IntegerAccumulator中没有增加任何的控制,如果单纯的对getValue和add方法增加同步控制,虽然保证了方法的原子性,但是我们知道两个原子性的操作合并到一起并不就是原子性的,所以在线程的逻辑执行单元中增加同步控制是最为合理的。也就是在执行过程中控制原子性。

不可变的累加器对象设计方案

在上面的例子中,通过同步的方式解决了线程安全性的问题,正确的加锁方式可以使得一个类变成线程安全的,例如java.long.Vector,但是需要设计出的是类似于String的不可变的类。

public final class IntegerAccumulator{
    private final int init;


    public IntegerAccumulator(int init) {
        this.init = init;
    }

    public IntegerAccumulator(IntegerAccumulator accumulator,int init){
        this.init = accumulator.getValue()+init;
    }

    public IntegerAccumulator add(int i){
        return new IntegerAccumulator(this,i);
    }

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

        public static void main(String[] args){
        IntegerAccumulator accumulator = new IntegerAccumulator(10);
        IntStream.range(0,3).forEach(i->new Thread(()->{
            int inc = 0;
            while (true){
                int oldValue = accumulator.getValue();
                int result = accumulator.add(inc).getValue();
                System.out.println(oldValue+"+"+inc+"="+result);

                if (inc+oldValue != result) {
                    System.out.println("ERROR: "+oldValue+"+"+inc+"="+result);
                }
                inc++;
                slowly();
            }
        }).start());
    }

    private static void slowly() {
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
           

重构之后的的IntegerAccumulator类,使用了final修饰,为了方式被继承重写而导致失去线程安全性,另外的init属性也被final修饰,不允许线程对其改变,在构造函数中赋值完成之后将不会被改变。

  在Add方法并没有在原有的init基础上进行累加,而是创建了一个全新的IntegerAccumulator,并没有提供任何修改原始的对象的机会,也就不会出现报错的情况。

总结

在设计一个不可改变对象的时候要使得共享资源不可变,例如使用final关键字进行修饰,针对共享资源的操作是不允许被重写的,防止由于重写而带来的线程安全问题。凭借这两点就可以 保证类的不变性,例如String。所以说在实际使用的时候我们只需要抓住这两点,就可以设计出一个合理的不可变对象。

继续阅读