啃碎并发(五):Java线程安全特性与问题

0 前言

在单线程中不会出现线程安全问题,而在多线程编程中,有可能会出现同时访问同一个 共享、可变资源 的状况,这种资源能够是:一个变量、一个对象、一个文件等。特别注意两点:html

  1. 共享: 意味着该资源能够由多个线程同时访问;
  2. 可变: 意味着该资源能够在其生命周期内被修改;

简单的说,若是你的代码在单线程下执行和在多线程下执行永远都能得到同样的结果,那么你的代码就是线程安全的。那么,当进行多线程编程时,咱们又会面临哪些线程安全的要求呢?又是要如何去解决的呢?java

1 线程安全特性

1.1 原子性

跟数据库事务的原子性概念差很少,即一个操做(有可能包含有多个子操做)要么所有执行(生效),要么所有都不执行(都不生效)数据库

关于原子性,一个很是经典的例子就是银行转帐问题:编程

好比:A和B同时向C转帐10万元。若是转帐操做不具备原子性,A在向C转帐时,读取了C的余额为20万,而后加上转帐的10万,计算出此时应该有30万,但还将来及将30万写回C的帐户,此时B的转帐请求过来了,B发现C的余额为20万,而后将其加10万并写回。而后A的转帐操做继续——将30万写回C的余额。这种状况下C的最终余额为30万,而非预期的40万。api

1.2 可见性

可见性是指,当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程可以当即看到。可见性问题是好多人忽略或者理解错误的一点。缓存

CPU从主内存中读数据的效率相对来讲不高,如今主流的计算机中,都有几级缓存。每一个线程读取共享变量时,都会将该变量加载进其对应CPU的高速缓存里,修改该变量后,CPU会当即更新该缓存,但并不必定会当即将其写回主内存(实际上写回主内存的时间不可预期)。此时其它线程(尤为是不在同一个CPU上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非第一个线程更新后的数据。安全

这一点是操做系统或者说是硬件层面的机制,因此不少应用开发人员常常会忽略。多线程

1.3 有序性

有序性指的是,程序执行的顺序按照代码的前后顺序执行。如下面这段代码为例:并发

boolean started = false; // 语句1
long counter = 0L; // 语句2
counter = 1; // 语句3
started = true; // 语句4
复制代码

从代码顺序上看,上面四条语句应该依次执行,但实际上JVM真正在执行这段代码时,并不保证它们必定彻底按照此顺序执行。oracle

处理器为了提升程序总体的执行效率,可能会对代码进行优化,其中的一项优化方式就是调整代码顺序,按照更高效的顺序执行代码

讲到这里,有人要着急了——什么,CPU不按照个人代码顺序执行代码,那怎么保证获得咱们想要的效果呢?实际上,你们大可放心,CPU虽然并不保证彻底按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致

2 线程安全问题

2.1 竞态条件与临界区

线程之间共享堆空间,在编程的时候就要格外注意避免竞态条件。危险在于多个线程同时访问相同的资源并进行读写操做。当其中一个线程须要根据某个变量的状态来相应执行某个操做的以前,该变量极可能已经被其它线程修改

也就是说,当两个线程竞争同一资源时,若是对资源的访问顺序敏感,就称存在 竞态条件。致使竟态条件发生的代码称做 临界区

/** * 如下这段代码就存在竞态条件,其中return ++count就是临界区。 */
public class Obj {

    private int count;

    public int incr() {
        return ++count;
    }

}
复制代码

2.2 死锁

死锁:指两个或两个以上的进程(或线程)在执行过程当中,因争夺资源而形成的一种互相等待的现象,若无外力做用,它们都将没法推动下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

关于死锁发生的条件:

  1. 互斥条件:线程对资源的访问是排他性的,若是一个线程对占用了某资源,那么其余线程必须处于等待状态,直到资源被释放。
  2. 请求和保持条件:线程T1至少已经保持了一个资源R1占用,但又提出对另外一个资源R2请求,而此时,资源R2被其余线程T2占用,因而该线程T1也必须等待,但又对本身保持的资源R1不释放。
  3. 不剥夺条件:线程已得到的资源,在未使用完以前,不能被其余线程剥夺,只能在使用完之后由本身释放。
  4. 环路等待条件:在死锁发生时,必然存在一个“进程-资源环形链”,即:{p0,p1,p2,...pn},进程p0(或线程)等待p1占用的资源,p1等待p2占用的资源,pn等待p0占用的资源。(最直观的理解是,p0等待p1占用的资源,而p1而在等待p0占用的资源,因而两个进程就相互等待)

2.3 活锁

活锁:是指线程1可使用资源,但它很礼貌,让其余线程先使用资源,线程2也可使用资源,但它很绅士,也让其余线程先使用资源。这样你让我,我让你,最后两个线程都没法使用资源

关于“死锁与活锁”的比喻

死锁:迎面开来的汽车A和汽车B过马路,汽车A获得了半条路的资源(知足死锁发生条件1:资源访问是排他性的,我占了路你就不能上来,除非你爬我头上去),汽车B占了汽车A的另外半条路的资源,A想过去必须请求另外一半被B占用的道路(死锁发生条件2:必须整条车身的空间才能开过去,我已经占了一半,尼玛另外一半的路被B占用了),B若想过去也必须等待A让路,A是辆兰博基尼,B是开奇瑞QQ的屌丝,A素质比较低开窗对B狂骂:快给老子让开,B很生气,你妈逼的,老子就不让(死锁发生条件3:在未使用完资源前,不能被其余线程剥夺),因而二者相互僵持一个都走不了(死锁发生条件4:环路等待条件),并且致使整条道上的后续车辆也走不了。

活锁:马路中间有条小桥,只能容纳一辆车通过,桥两头开来两辆车A和B,A比较礼貌,示意B先过,B也比较礼貌,示意A先过,结果两人一直谦让谁也过不去。

2.4 饥饿

饥饿:是指若是线程T1占用了资源R,线程T2又请求封锁R,因而T2等待。T3也请求资源R,当T1释放了R上的封锁后,系统首先批准了T3的请求,T2仍然等待。而后T4又请求封锁R,当T3释放了R上的封锁以后,系统又批准了T4的请求......,T2可能永远等待

也就是,若是一个线程由于CPU时间所有被其余线程抢走而得不到CPU运行时间,这种状态被称之为“饥饿”。而该线程被“饥饿致死”正是由于它得不到CPU运行时间的机会

关于“饥饿”的比喻

在“首堵”北京的某一天,天气阴沉,空气中充斥着雾霾和地沟油的味道,某个苦逼的临时工交警正在处理塞车,有两条道A和B上都堵满了车辆,其中A道堵的时间最长,B相对堵的时间较短,这时,前面道路已疏通,交警按照最佳分配原则,示意B道上车辆先过,B道路上过了一辆又一辆,A道上排队时间最长的却无法经过,只能等B道上没有车辆经过的时候再等交警发指令让A道依次经过,这也就是ReentrantLock显示锁里提供的不公平锁机制(固然了,ReentrantLock也提供了公平锁的机制,由用户根据具体的使用场景而决定到底使用哪一种锁策略),不公平锁可以提升吞吐量但不可避免的会形成某些线程的饥饿

在Java中,下面三个常见的缘由会致使线程饥饿,以下:

  1. 高优先级线程吞噬全部的低优先级线程的CPU时间

    你能为每一个线程设置独自的线程优先级,优先级越高的线程得到的CPU时间越多,线程优先级值设置在1到10之间,而这些优先级值所表示行为的准确解释则依赖于你的应用运行平台。对大多数应用来讲,你最好是不要改变其优先级值

  2. 线程被永久堵塞在一个等待进入同步块的状态,由于其余线程老是能在它以前持续地对该同步块进行访问

    Java的同步代码区也是一个致使饥饿的因素。Java的同步代码区对哪一个线程容许进入的次序没有任何保障。这就意味着理论上存在一个试图进入该同步区的线程处于被永久堵塞的风险,由于其余线程老是能持续地先于它得到访问,这便是“饥饿”问题,而一个线程被“饥饿致死”正是由于它得不到CPU运行时间的机会

  3. 线程在等待一个自己(在其上调用wait())也处于永久等待完成的对象,由于其余线程老是被持续地得到唤醒

    若是多个线程处在wait()方法执行上,而对其调用notify()不会保证哪个线程会得到唤醒,任何线程都有可能处于继续等待的状态。所以存在这样一个风险:一个等待线程历来得不到唤醒,由于其余等待线程老是能被得到唤醒

2.5 公平

解决饥饿的方案被称之为“公平性” – 即全部线程均能公平地得到运行机会。在Java中实现公平性方案,须要:

  1. 使用锁,而不是同步块;
  2. 使用公平锁;
  3. 注意性能方面;

在Java中实现公平性,虽Java不可能实现100%的公平性,依然能够经过同步结构在线程间实现公平性的提升

首先来学习一段简单的同步态代码:

public class Synchronizer{
    public synchronized void doSynchronized () {
        // do a lot of work which takes a long time
    }
}
复制代码

若是有多个线程调用doSynchronized()方法,在第一个得到访问的线程未完成前,其余线程将一直处于阻塞状态,并且在这种多线程被阻塞的场景下,接下来将是哪一个线程得到访问是没有保障的

改成 使用锁方式替代同步块,为了提升等待线程的公平性,咱们使用锁方式来替代同步块:

public class Synchronizer{
    Lock lock = new Lock();
    public void doSynchronized() throws InterruptedException{
        this.lock.lock();
        //critical section, do a lot of work which takes a long time
        this.lock.unlock();
    }
}
复制代码

注意到doSynchronized()再也不声明为synchronized,而是用lock.lock()和lock.unlock()来替代。下面是用Lock类作的一个实现:

public class Lock{

    private boolean isLocked      = false;

    private Thread lockingThread = null;

    public synchronized void lock() throws InterruptedException{
        while(isLocked){
            wait();
        }

        isLocked = true;
        lockingThread = Thread.currentThread();
    }

    public synchronized void unlock(){

        if(this.lockingThread != Thread.currentThread()){
            throw new IllegalMonitorStateException("Calling thread has not locked this lock");
        }

        isLocked = false;
        lockingThread = null;
        notify();
    }
}
复制代码

注意到上面对Lock的实现,若是存在多线程并发访问lock(),这些线程将阻塞在对lock()方法的访问上。另外,若是锁已经锁上(校对注:这里指的是isLocked等于true时),这些线程将阻塞在while(isLocked)循环的wait()调用里面。要记住的是,当线程正在等待进入lock() 时,能够调用wait()释放其锁实例对应的同步锁,使得其余多个线程能够进入lock()方法,并调用wait()方法

这回看下doSynchronized(),你会注意到在lock()和unlock()之间的注释:在这两个调用之间的代码将运行很长一段时间。进一步设想,这段代码将长时间运行,和进入lock()并调用wait()来比较的话。这意味着大部分时间用在等待进入锁和进入临界区的过程是用在wait()的等待中,而不是被阻塞在试图进入lock()方法中

在早些时候提到过,同步块不会对等待进入的多个线程谁能得到访问作任何保障,一样当调用notify()时,wait()也不会作保障必定能唤醒线程。所以这个版本的Lock类和doSynchronized()那个版本就保障公平性而言,没有任何区别。

但咱们可以改变这种状况,以下:

当前的Lock类版本调用本身的wait()方法,若是每一个线程在不一样的对象上调用wait(),那么只有一个线程会在该对象上调用wait(),Lock类能够决定哪一个对象能对其调用notify(),所以能作到有效的选择唤醒哪一个线程

下面将上面Lock类转变为公平锁FairLock。你会注意到新的实现和以前的Lock类中的同步和wait()/notify()稍有不一样。重点是,每个调用lock()的线程都会进入一个队列,当解锁时,只有队列里的第一个线程被容许锁住FairLock实例,全部其它的线程都将处于等待状态,直到他们处于队列头部。以下:

public class FairLock {
    private boolean isLocked = false;
    private Thread lockingThread = null;
    private List<QueueObject> waitingThreads = new ArrayList<QueueObject>();

    public void lock() throws InterruptedException{
        // 当前线程建立“令牌”
        QueueObject queueObject = new QueueObject();
        boolean isLockedForThisThread = true;
        synchronized(this){
            // 全部线程的queueObject令牌,入队
            waitingThreads.add(queueObject);
        }

        while(isLockedForThisThread){
            synchronized(this){
                // 1. 判断是否已被锁住:是否已有线程得到锁,正在执行同步代码块
                // 2. 判断头部令牌与当前线程令牌是否一致:也就是只锁住头部令牌对应的线程;
                isLockedForThisThread = isLocked || waitingThreads.get(0) != queueObject;
                if(!isLockedForThisThread){
                    isLocked = true;
                    // 移除头部令牌
                    waitingThreads.remove(queueObject);
                    lockingThread = Thread.currentThread();
                    return;
                }
            }
            try{
                // 其余线程执行doWait(),进行等待
                queueObject.doWait();
            }catch(InterruptedException e){
                synchronized(this) { waitingThreads.remove(queueObject); }
                throw e;
            }
        }
    }

    public synchronized void unlock(){
        if(this.lockingThread != Thread.currentThread()){
            throw new IllegalMonitorStateException("Calling thread has not locked this lock");
        }
        isLocked = false;
        lockingThread = null;
        if(waitingThreads.size() > 0) {
            // 唤醒头部令牌对应的线程,能够执行
            waitingThreads.get(0).doNotify();
        }
    }
}

public class QueueObject {
    private boolean isNotified = false;

    public synchronized void doWait() throws InterruptedException {
        while(!isNotified){
            this.wait();
        }
        this.isNotified = false;
    }

    public synchronized void doNotify() {
        this.isNotified = true;
        this.notify();
    }

    public boolean equals(Object o) {
        return this == o;
    }
}
复制代码

首先注意到lock()方法不在声明为synchronized,取而代之的是对必需同步的代码,在synchronized中进行嵌套

FairLock新建立了一个QueueObject的实例,并对每一个调用lock()的线程进行入队操做。调用unlock()的线程将从队列头部获取QueueObject,并对其调用doNotify(),以唤醒在该对象上等待的线程。经过这种方式,在同一时间仅有一个等待线程得到唤醒,而不是全部的等待线程。这也是实现FairLock公平性的核心所在。

还需注意到,QueueObject实际是一个semaphore。doWait()和doNotify()方法在QueueObject中保存着信号。这样作以免一个线程在调用queueObject.doWait()以前被另外一个线程调用unlock()并随之调用queueObject.doNotify()的线程重入,从而致使信号丢失。queueObject.doWait()调用放置在synchronized(this)块以外,以免被monitor嵌套锁死,因此另外的线程能够解锁,只要当没有线程在lock方法的synchronized(this)块中执行便可。

最后,注意到queueObject.doWait()在try – catch块中是怎样调用的。在InterruptedException抛出的状况下,线程得以离开lock(),并需让它从队列中移除

3 如何确保线程安全特性

3.1 如何确保原子性

3.1.1 锁和同步

经常使用的保证Java操做原子性的工具是 锁和同步方法(或者同步代码块)。使用锁,能够保证同一时间只有一个线程能拿到锁,也就保证了同一时间只有一个线程能执行申请锁和释放锁之间的代码。

public void testLock () {
    lock.lock();
    try{
        int j = i;
        i = j + 1;
    } finally {
        lock.unlock();
    }
}
复制代码

与锁相似的是同步方法或者同步代码块。使用非静态同步方法时,锁住的是当前实例;使用静态同步方法时,锁住的是该类的Class对象;使用静态代码块时,锁住的是synchronized关键字后面括号内的对象。下面是同步代码块示例:

public void testLock () {
    synchronized (anyObject){
        int j = i;
        i = j + 1;
    }
}
复制代码

不管使用锁仍是synchronized,本质都是同样,经过锁或同步来实现资源的排它性,从而实际目标代码段同一时间只会被一个线程执行,进而保证了目标代码段的原子性。这是一种以牺牲性能为代价的方法

3.1.2 CAS(compare and swap)

基础类型变量自增(i++)是一种常被新手误觉得是原子操做而实际不是的操做。Java中提供了对应的原子操做类来实现该操做,并保证原子性,其本质是利用了CPU级别的CAS指令。因为是CPU级别的指令,其开销比须要操做系统参与的锁的开销小。AtomicInteger使用方法以下:

AtomicInteger atomicInteger = new AtomicInteger();
for(int b = 0; b < numThreads; b++) {
    new Thread(() -> {
        for(int a = 0; a < iteration; a++) {
            atomicInteger.incrementAndGet();
        }
    }).start();
}
复制代码

3.2 如何确保可见性

Java提供了volatile关键字来保证可见性。当使用volatile修饰某个变量时,它会保证对该变量的修改会当即被更新到内存中,而且将其它线程缓存中对该变量的缓存设置成无效,所以其它线程须要读取该值时必须从主内存中读取,从而获得最新的值。

volatile适用场景:volatile适用于不须要保证原子性,但却须要保证可见性的场景。一种典型的使用场景是用它修饰用于中止线程的状态标记。以下所示:

boolean isRunning = false;
public void start () {
    new Thread( () -> {
        while(isRunning) {
            someOperation();
        }
    }).start();
}
public void stop () {
    isRunning = false;
}
复制代码

在这种实现方式下,即便其它线程经过调用stop()方法将isRunning设置为false,循环也不必定会当即结束。能够经过volatile关键字,保证while循环及时获得isRunning最新的状态从而及时中止循环,结束线程

3.3 如何确保有序性

上文讲过编译器和处理器对指令进行从新排序时,会保证从新排序后的执行结果和代码顺序执行的结果一致,因此从新排序过程并不会影响单线程程序的执行,却可能影响多线程程序并发执行的正确性

Java中可经过volatile在必定程序上保证顺序性,另外还能够经过synchronized和锁来保证顺序性。

synchronized和锁保证顺序性的原理和保证原子性同样,都是经过保证同一时间只会有一个线程执行目标代码段来实现的。

除了从应用层面保证目标代码段执行的顺序性外,JVM还经过被称为happens-before原则隐式地保证顺序性。两个操做的执行顺序只要能够经过happens-before推导出来,则JVM会保证其顺序性,反之JVM对其顺序性不做任何保证,可对其进行任意必要的从新排序以获取高效率。

happens-before原则(先行发生原则),以下:

  1. 传递规则:若是操做1在操做2前面,而操做2在操做3前面,则操做1确定会在操做3前发生。该规则说明了happens-before原则具备传递性
  2. 锁定规则:一个unlock操做确定会在后面对同一个锁的lock操做前发生。这个很好理解,锁只有被释放了才会被再次获取
  3. volatile变量规则:对一个被volatile修饰的写操做先发生于后面对该变量的读操做。
  4. 程序次序规则:一个线程内,按照代码顺序执行。
  5. 线程启动规则:Thread对象的start()方法先发生于此线程的其它动做。
  6. 线程终结原则:线程的终止检测后发生于线程中其它的全部操做。
  7. 线程中断规则: 对线程interrupt()方法的调用先发生于对该中断异常的获取。
  8. 对象终结规则:一个对象构造先于它的finalize发生。

4 关于线程安全的几个为何

  1. 平时项目中使用锁和synchronized比较多,而不多使用volatile,难道就没有保证可见性?

    锁和synchronized便可以保证原子性,也能够保证可见性。都是经过保证同一时间只有一个线程执行目标代码段来实现的。

  2. 锁和synchronized为什么能保证可见性?

    根据JDK 7的Java doc中对concurrent包的说明,一个线程的写结果保证对另外线程的读操做可见,只要该写操做能够由happen-before原则推断出在读操做以前发生。

  3. 既然锁和synchronized便可保证原子性也可保证可见性,为什么还须要volatile?

    synchronized和锁须要经过操做系统来仲裁谁得到锁,开销比较高,而volatile开销小不少。所以在只须要保证可见性的条件下,使用volatile的性能要比使用锁和synchronized高得多。

  4. 既然锁和synchronized能够保证原子性,为何还须要AtomicInteger这种的类来保证原子操做?

    锁和synchronized须要经过操做系统来仲裁谁得到锁,开销比较高,而AtomicInteger是经过CPU级的CAS操做来保证原子性,开销比较小。因此使用AtomicInteger的目的仍是为了提升性能。

  5. 还有没有别的办法保证线程安全?

    有。尽量避免引发非线程安全的条件——共享变量。若是能从设计上避免共享变量的使用,便可避免非线程安全的发生,也就无须经过锁或者synchronized以及volatile解决原子性、可见性和顺序性的问题

  6. synchronized便可修饰非静态方式,也可修饰静态方法,还可修饰代码块,有何区别?

    synchronized修饰非静态同步方法时,锁住的是当前实例;synchronized修饰静态同步方法时,锁住的是该类的Class对象;synchronized修饰静态代码块时,锁住的是synchronized关键字后面括号内的对象

相关文章
相关标签/搜索