Java并发编程之设计线程安全的类

设计线程安全的类

前边咱们对线程安全性的分析都停留在一两个可变共享变量的基础上,真实并发程序中可变共享变量会很是多,在出现安全性问题的时候很难准肯定位是哪块儿出了问题,并且修复问题的难度也会随着程序规模的扩大而提高(由于在程序的各个位置均可以随便使用可变共享变量,每一个操做均可能致使安全性问题的发生)。比方说咱们设计了一个这样的类:程序员

public class Increment {
    private int i;

    public void increase() {
        i++;
    }

    public int getI() {
        return i;
    }
}

而后有不少客户端程序员在多线程环境下都使用到了这个类,有的程序员很聪明,他在调用increase方法时使用了适当的同步操做:缓存

public class RightUsageOfIncrement {

    public static void main(String[] args) {
        Increment increment = new Increment();

        Thread[] threads = new Thread[20];  //建立20个线程
        for (int i = 0; i < threads.length; i++) {
            Thread t = new Thread(new Runnable() {

                @Override
                public void run() {
                    for (int i = 0; i < 100000; i++) {
                        synchronized (RightUsageOfIncrement.class) {    // 使用Class对象加锁
                            increment.increase();
                        }
                    }
                }
            });
            threads[i] = t;
            t.start();
        }

        for (int i = 0; i < threads.length; i++) {
            try {
                threads[i].join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }

        System.out.println(increment.getI());
    }
}

在调用Incrementincrease方法的时候,使用RightUsageOfIncrement.class这个对象做为锁,有效的对i++操做进行了同步,的确不错,执行以后的结果是:安全

2000000

但是并非每一个客户端程序员都会这么聪明,有的客户端程序员压根儿不知道啥叫个同步,因此写成了这样:多线程

public class WrongUsageOfIncrement {

    public static void main(String[] args) {
        Increment increment = new Increment();

        Thread[] threads = new Thread[20];  //建立20个线程
        for (int i = 0; i < threads.length; i++) {
            Thread t = new Thread(new Runnable() {

                @Override
                public void run() {
                    for (int i = 0; i < 100000; i++) {
                        increment.increase();   //没有进行有效的同步
                    }
                }
            });
            threads[i] = t;
            t.start();
        }

        for (int i = 0; i < threads.length; i++) {
            try {
                threads[i].join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }

        System.out.println(increment.getI());
    }
}

没有进行有效同步的执行结果是(每次执行均可能不同):并发

1815025

其实对于Increment这个类的开发者来讲,本质上是把对可变共享变量的必要同步操做转嫁给客户端程序员处理。有的状况下咱们但愿本身设计的类可让客户端程序员们不须要使用额外的同步操做就能够放心的在多线程环境下使用,咱们就把这种类成为线程安全类。其实就是类库设计者把一些在多线程环境下可能致使安全性问题的操做封装到类里边儿,好比Incrementincrease方法,咱们能够写成这样:ide

public synchronized void increase() {
    i++;
}

也就是说把对可变共享变量i可能形成多线程安全性问题的i++操做在Increment类内就封装好,其余人直接调用也不会出现安全性问题。使用封装也是无奈之举:你没法控制其余人对你的代码调用,风险始终存在,封装使无心中破坏设计约束条件变得更难性能

封装变量访问

找出共享、可变的字段this

设计线程安全类的第一步就是要找出全部的字段,这里的字段包括静态变量也包括成员变量,而后再分析这些字段是不是共享而且可变的。spa

首先辨别一下字段是不是共享的。因为咱们没法控制客户端程序员以怎样的方式来使用这个类,因此咱们能够经过访问权限,也就是public权限protected权限默认权限以及private权限来控制哪些代码是能够被客户端程序员调用的,哪些是不能够调用的。通常状况下,咱们须要把全部字段都声明为 private 的,把对它们的访问都封装到方法中,对这些方法再进行必要的同步控制,也就是说咱们只暴露给客户端程序员一些能够调用的方法来间接的访问到字段,由于若是直接把字段暴露给客户端程序员的话,咱们没法控制客户端程序员如何使用该字段,好比他能够随意的在多线程环境下对字段进行累加操做,从而不能保证把全部同步逻辑都封装到类中。因此若是一个字段是能够经过对外暴露的方法访问到,那这个字段就是共享的。线程

而后再看一下字段是不是可变的。若是该字段的类型是基本数据类型,能够看一下类全部对外暴露的方法中是否有修改该字段值的操做,若是有,那这个字段就是可变的。若是该字段的类型是非基本数据类型的,那这个字段可变就有两层意思了,第一是在对外暴露的方法中有直接修改引用的操做,第二是在对外暴露的方法中有直接修改该对象中字段的操做。好比一个类长这样:

public class MyObj {
    private List<String> list;

    public void m1() {
        list = new ArrayList<>(); //直接修改字段指向的对象
    }

    public void m2() {
        list[0] = "aa"; //修改该字段指向对象的字段
    }
}

代码中的m1m2均可以算作是修改字段list,若是类暴露的方法中有这两种修改方式中的任意一种,就能够算做这个字段是可变的。

小贴士:是否是把字段声明成final类型,该字段就不可变了呢?

若是该字段是基本数据类型,那声明为final的确能够保证在程序运行过程当中不可变,可是若是该字段是非基本数据类型,那么须要让该字段表明的对象中的全部字段都是不可变字段才能保证该final字段不可变。

因此在使用字段的过程当中,应该尽量的让字段不共享或者不可变,不共享或者不可变的字段才不会引发安全性问题哈哈。

这让我想起了一句老话:只有死人才不会说话~

用锁来保护访问

肯定了哪些字段必须是共享、可变的以后,就要分析在哪些对外暴露的方法中访问了这些字段,咱们须要在全部的访问位置都进行必要的同步处理,这样才能够保证这个类是一个线程安全类。一般,咱们会使用来保证多线程在访问共享可变字段时是串行访问的。

可是一种常见的错误就是:只有在写入共享可变字段时才须要使用同步,就像这样:

public class Test {
    private int i;

    public int getI() {
        return i;
    }

    public synchronized void setI(int i) {
        this.i = i;
    }
}

为了使Test类变为线程安全类,也就是须要保证共享可变字段i在全部外界能访问的位置都是线程安全的,而上边getI方法能够访问到字段i,却没有进行有效的同步处理,因为内存可见性问题的存在,在调用getI方法时仍有可能获取的是旧的字段值。因此再次强调一遍:咱们须要在全部的访问位置都进行必要的同步处理

使用同一个锁

还有一点须要强调的是:若是使用锁来保护共享可变字段的访问的话,对于同一个字段来讲,在多个访问位置须要使用同一个锁。

咱们知道若是多个线程竞争同一个锁的话,在一个线程获取到锁后其余线程将被阻塞,若是是使用多个锁来保护同一个共享可变字段的话,多个线程并不会在一个线程访问的时候阻塞等待,而是会同时访问这个字段,咱们的保护措施就变得无效了。

通常状况下,在一个线程安全类中,咱们使用同步方法,也就是使用this对象做为锁来保护字段的访问就OK了~。

封不封装取决于你的心情

虽然面向对象技术封装了安全性,可是打破这种封装也没啥不能够,只不过安全性会更脆弱,增长开发成本和风险。也就是说你把字段声明为public访问权限也没人拦得住你,固然你也可能由于某种性能问题而打破封装,不过对于咱们实现业务的人来讲,仍是建议先使代码正确运行,再考虑提升代码执行速度吧~。

不变性条件

现实中有些字段之间是有实际联系的,好比说下边这个类:

public class SquareGetter {
    private int numberCache;    //数字缓存
    private int squareCache;    //平方值缓存

    public int getSquare(int i) {
        if (i == numberCache) {
            return squareCache;
        }
        int result = i*i;
        numberCache = i;
        squareCache = result;
        return result;
    }

    public int[] getCache() {
        return new int[] {numberCache, squareCache};
    }
}

这个类提供了一个很简单的getSquare功能,能够获取指定参数的平方值。可是它的实现过程使用了缓存,就是说若是指定参数和缓存的numberCache的值同样的话,直接返回缓存的squareCache,若是不是的话,计算参数的平方,而后把该参数和计算结果分别缓存到numberCachesquareCache中。

从上边的描述中咱们能够知道,squareCache不论在任何状况下都是numberCache平方值,这就是SquareGetter类的一个不变性条件,若是违背了这个不变性条件的话,就可能会得到错误的结果。

在单线程环境中,getSquare方法并不会有什么问题,可是在多线程环境中,numberCachesquareCache都属于共享的可变字段,而getSquare方法并无提供任何同步措施,因此可能形成错误的结果。假设如今numberCache的值是2,squareCache的值是3,一个线程调用getSquare(3),另外一个线程调用getSquare(4),这两个线程的一个可能的执行时序是:

图片描述

两个线程执行事后,最后numberCache的值是4,而squareCache的值居然是9,也就意味着多线程会破坏不变性条件。为了保持不变性条件咱们须要把保持不变性条件的多个操做定义为一个原子操做,即用锁给保护起来

咱们能够这样修改getSquare方法的代码:

public synchronized int getSquare(int i) {
    if (i == numberCache) {
        return squareCache;
    }
    int result = i*i;
    numberCache = i;
    squareCache = result;
    return result;
}

可是不要忘了将代码都放在同步代码块是会形成阻塞的,能不进行同步,就不进行同步,因此咱们修改一下上边的代码:

public int getSquare(int i) {

    synchronized(this) {
        if (i == numberCache) {  // numberCache字段的读取须要进行同步
            return squareCache;
        }
    }

    int result = i*i;   //计算过程不须要同步

    synchronized(this) {   // numberCache和squareCache字段的写入须要进行同步
        numberCache = i;
        squareCache = result;
    }
    return result;
}

虽然getSquare方法同步操做已经作好了,可是别忘了SquareGetter类getCache方法也访问了numberCachesquareCache字段,因此对于每一个包含多个字段的不变性条件,其中涉及的全部字段都须要被同一个锁来保护,因此咱们再修改一下getCache方法

public synchronized int[] getCache() {
    return new int[] {numberCache, squareCache};
}

这样修改后的SquareGetter类才属于一个线程安全类

使用volatile修饰状态

使用锁来保护共享可变字段虽然好,可是开销大。使用volatile修饰字段来替换掉锁是一种可能的考虑,可是必定要记住volatile是不能保证一系列操做的原子性的,因此只有咱们的业务场景符合下边这两个状况的话,才能够考虑:

  • 对变量的写入操做不依赖当前值,或者保证只有单个线程进行更新。
  • 该变量不须要和其余共享变量组成不变性条件。

比方说下边的这个类:

public class VolatileDemo {

    private volatile int i;

    public int getI() {
        return i;
    }

    public void setI(int i) {
        this.i = i;
    }
}

VolatileDemo中的字段i并不和其余字段组成不变性条件,并且对于能够访问这个字段的方法getIsetI来讲,并不须要以来i的当前值,因此可使用volatile来修饰字段i,而不用在getIsetI的方法上使用锁。

避免this引用逸出

咱们先来看一段代码:

public class ExplicitThisEscape {

    private final int i;

    public static ThisEscape INSTANCE;

    public ThisEscape() {
        INSTANCE = this;
        i = 1;
    }
}

在构造方法中就把this引用给赋值到了静态变量INSTANCE中,而别的线程是能够随时访问INSTANCE的,咱们把这种在对象建立完成以前就把this引用赋值给别的线程能够访问的变量的这种状况称为 this引用逸出,这种方式是极其危险的!,这意味着在ThisEscape对象建立完成以前,别的线程就能够经过访问INSTANCE来获取到i字段的信息,也就是说别的线程可能获取到字段i的值为0,与咱们指望的final类型字段值不会改变的结果是相违背的。因此千万不要在对象构造过程当中使this引用逸出

上边的this引用逸出是经过显式将this引用赋值的方式致使逸出的,也可能经过内部类的方式神不知鬼不觉的形成this引用逸出:

public class ImplicitThisEscape {

    private final int i;

    private Thread t;

    public ThisEscape() {
        t = new Thread(new Runnable() {
            @Override
            public void run() {
                // ... 具体的任务
            }
        });
        i = 1;
    }
}

虽然在ImplicitThisEscape的构造方法中并无显式的将this引用赋值,可是因为Runnable内部类的存在,做为外部类的ImplicitThisEscape,内部类对象能够轻松的获取到外部类的引用,这种状况下也算this引用逸出

this引用逸出意味着建立对象的过程是不安全的,在对象还没有建立好的时候别的线程就能够来访问这个对象。虽然咱们不肯定客户端程序员会怎么使用这个逸出的this引用,可是风险始终存在,因此强烈建议千万不要在对象构造过程当中使this引用逸出

总结

  1. 客户端程序员不靠谱,咱们有必要把线程安全性封装到类中,只给客户端程序员提供线程安全的方法。
  2. 认真找出代码中既共享又可变的变量,并把它们使用锁来保护起来,同一个字段的多个访问位置须要使用同一个锁来保护。
  3. 对于每一个包含多个字段的不变性条件,其中涉及的全部字段都须要被同一个锁来保护。
  4. 在对变量的写入操做不依赖当前值以及该变量不须要和其余共享变量组成不变性条件的状况下能够考虑使用volatile变量来保证并发安全。
  5. 千万不要在对象构造过程当中使this引用逸出。
相关文章
相关标签/搜索