并发程序的噩梦——数据竞争

前言

在本文当中我次要通过不同线程对同一个数据进行加法操作的例子,层层递进,应用忙期待、synchronized和锁去解决咱们的问题,切实领会为什么数据竞争是并发程序的噩梦。

问题介绍

在本文当中会有一个贯通全文的例子:不同的线程会对一个全局变量一直的进行加的操作!而后比拟后果,具体来说咱们设置一个动态类变量data,而后应用两个线程循环10万次对data进行加一操作!!!

像这种多个线程会存在同时对同一个数据进行批改操作的景象就叫做数据竞争数据竞争会给程序造成很多不可意料的后果,让程序存在许多破绽。而咱们下面的工作就是一个典型的数据竞争的问题。

并发不平安版本

在这一大节咱们先写一个上述问题的并发不平安的版本:

public class Sum {    public static int data;    public static void main(String[] args) throws InterruptedException {        Thread t1 = new Thread(() -> {            for (int i = 0; i < 100000; i++)                data++;        });        Thread t2 = new Thread(() -> {            for (int i = 0; i < 100000; i++)                data++;        });        t1.start();        t2.start();        // 让主线程期待 t1 和 t2        // 直到 t1 和 t2 执行实现        t1.join();        t2.join();        System.out.println(data);    }}// 输入后果131888

下面两个线程执行的后果最终都会小于200000,为什么会呈现这种状况呢?

咱们首先来看一下内存的逻辑布局图:

data全局变量保留在主内存当中,当现成开始执行的时候会从主内存拷贝一份到线程的工作内存当中,也就是线程的本地内存,在本地进行计算之后就会将本地内存当中的数据同步到主内存

咱们当初来模仿一下呈现问题的过程:

  • 主内存data的初始值等于0,两个线程失去的data初始值都等于0。

  • 当初线程一将data加一,而后线程一将data的值同步回主内存,整个内存的数据变动如下:

  • 当初线程二data加一,而后将data的值同步回主内存(将原来主内存的值笼罩掉了):

咱们原本心愿data的值在通过下面的变动之后变成2,然而线程二笼罩了咱们的值,因而在多线程状况下,会使得咱们最终的后果变小。

忙期待(Busy waiting)

那么咱们能在不应用锁或者synchronized的状况实现下面这个工作吗?答案是能够,这种办法叫做忙期待。具体怎么做呢,咱们能够用一个布尔值flag去标识是那个线程执行sum++,咱们先看代码而后进行剖析:

public class BusyWaiting {    public static int sum;    public static boolean flag;    public static void main(String[] args) throws InterruptedException {        Thread t1 = new Thread(() -> {            for (int i = 0; i < 100000; i++) {                // 当 flag == false 的时候这个线程进行                // sum++ 操作而后让 flat = true                // 让另外一个线程进行 sum++ 操作                while (flag);                sum++;                flag = true;                System.out.println("Thread 1 : " + sum);            }        });        Thread t2 = new Thread(() -> {            for (int i = 0; i < 100000; i++) {                // 当 flag = true 的之后这个线程进行                // sum++ 操作而后让 flat = false                // 让另外一个线程进行 sum++ 操作                while (!flag) ;                sum++;                flag = false;                System.out.println("Thread 2 : " + sum);            }        });        t1.start();        t2.start();        // 让主线程期待 t1 和 t2        // 直到 t1 和 t2 执行实现        t1.join();        t2.join();        System.out.println(sum);    }}

下面代码的流程是一个线程进行完sum++操作之后会将本人的flag变成另外一个值,而后本人的while循环当中的条件会始终为true本人就会始终处于while循环当中,而后另外一个线程的while循环条件会变成false,则另外一个线程会执行sum++操作,而后将flag变成另外一个值,而后线程又开始执行了......

忙期待中数据竞争的BUG

然而下面的代码会呈现问题,就是在执行一段时间之后两个线程都会卡死,都会始终处于死循环当中。这是因为一个线程在更新完flag之后,另外一个线程的flag值没有更新,也就是说两个线程的flag值不一样,这样就都会处于死循环当中。呈现这个问题的起因是一个线程更新完flag之后,另外一个线程的flag应用的还是旧值。

比方在某个时刻主内存当中的flag的值等于false线程t1线程t2当中的flag的值都等于false,这个状况下线程t1是能够进行sum++操作的,而后线程t1进行sum++操作之后将flag的值改成true,而后将这个值同步更新回主内存,然而此时线程t2拿到的还是旧值false,他仍旧处于死循环当中,就这样两个线程都处于死循环了。

下面的问题实质上是一个数据可见性的问题,也就是说一个线程批改了flag的值,另外一个线程拿不到最新的值,应用的还是旧的值,而在java当中给咱们提供了一种机制能够解决数据可见性的问题。在java当中咱们能够应用volatile去解决数据可见性的问题。当一个变量被volatile关键字润饰时,对于共享资源的写操作先要批改工作内存,然而批改完结后会立即将其刷新到主内存中。当其余线程对该volatile润饰的共享资源进行了批改,则会导致以后线程在工作内存中的共享资源生效,所以必须从主内存中再次获取。这样的话就能够保障共享数据的可见性了。因为某个线程如果批改了volatile润饰的共享变量时,其余线程的值会生效,而后从新从主内存当中加载最新的值,对于volatile的内容还是比拟多,在本文当中咱们只谈他在本文当中作用。

批改后的正确代码如下:

public class BusyWaiting {    public static volatile int sum;    public static volatile boolean flag;    public static void main(String[] args) throws InterruptedException {        Thread t1 = new Thread(() -> {            for (int i = 0; i < 100000; i++) {                // 当 flag == false 的时候这个线程进行                // sum++ 操作而后让 flat = true                // 让另外一个线程进行 sum++ 操作                while (flag);                sum++;                flag = true;                System.out.println("Thread 1 : " + sum);            }        });        Thread t2 = new Thread(() -> {            for (int i = 0; i < 100000; i++) {                // 当 flag = true 的之后这个线程进行                // sum++ 操作而后让 flat = false                // 让另外一个线程进行 sum++ 操作                while (!flag) ;                sum++;                flag = false;                System.out.println("Thread 2 : " + sum);            }        });        t1.start();        t2.start();        // 让主线程期待 t1 和 t2        // 直到 t1 和 t2 执行实现        t1.join();        t2.join();        System.out.println(sum);    }}

下面的sum也须要应用volatile进行润饰,因为如果某个线程++之后,如果另外一个线程没有更新最新的值就进行++的话,在数据更新回主内存的时候就会笼罩原来的值,最终的后果就会变小,因而须要应用volatile进行润饰。

在上文当中咱们次要剖析了如何应用忙期待来解决咱们的问题,然而忙期待有一个很大的问题就是线程会一直的进行循环,这很耗费CPU资源,在下文当中咱们将次要介绍两种办法解决本文结尾提出的问题,而且能够不进行循环操作,而是将线程挂起,而不是始终在执行。

synchronized并发平安版本

synchronizedjava语言的关键字,它能够用来保障程序的原子性。在并发的状况下咱们能够用它来保障咱们的程序在某个时刻只能有一个线程执行,保障同一时刻只有一个线程取得synchronized锁对象。

public class Sum01 {    public static int sum;    public static synchronized void addSum() {        for (int i = 0; i < 100000; i++)            sum++;    }    public static void main(String[] args) throws InterruptedException {        Thread t1 = new Thread(Sum01::addSum);        Thread t2 = new Thread(Sum01::addSum);        t1.start();        t2.start();        // 让主线程期待 t1 和 t2        // 直到 t1 和 t2 执行实现        t1.join();        t2.join();        System.out.println(sum);    }}// 输入后果200000

下面的代码addSum办法退出了synchronized进行润饰,在java当中被synchronized的静态方法在同一个时刻只能有一个线程可能进入,也就是说下面的代码会让线程t1或者t2先执行addSum函数,而后另外一个线程在进行执行,那这个跟串行执行就一样了。那么咱们就能够不在静态方法上加synchronized的关键字,能够应用动态代码块:

public class Sum02 {    public static int sum;    public static Object lock = new Object();    public static void addSum() {        for (int i = 0; i < 100000; i++)            synchronized (lock) {            sum++;        }    }    public static void main(String[] args) throws InterruptedException {        Thread t1 = new Thread(Sum02::addSum);        Thread t2 = new Thread(Sum02::addSum);        t1.start();        t2.start();        // 让主线程期待 t1 和 t2        // 直到 t1 和 t2 执行实现        t1.join();        t2.join();        System.out.println(sum);    }}

下面代码尽管没有应用用synchronized润饰的静态方法,然而下面的代码应用了用synchronized润饰的同步代码块,在每一个时刻只能有一个线程执行上面这段代码:

// synchronized 润饰的代码块称作同步代码块 ,// lock 是一个 全局的动态类变量 只有竞争到 lock 对象的线程// 才可能进入同步代码块 同样的每一个时刻只能有一个线程进入synchronized (lock) {    sum++;}

下面代码尽管没有应用动态同步办法(synchronized润饰的静态方法),然而有同步代码块(synchronized润饰的代码块),在一个代码当中会有100000次进入同步代码块,这里也破费很多工夫,因而下面的代码的效率也不高。

其实咱们能够用一个长期变量存储100000次加法的后果,最初一次将后果退出到data当中:

public class Sum03 {    public static int sum;    public static Object lock = new Object();    public static void addSum() {        int tempSum = 0;        for (int i = 0; i < 100000; i++)            tempSum++;        synchronized (lock) {            sum += tempSum;        }    }    public static void main(String[] args) throws InterruptedException {        Thread t1 = new Thread(Sum03::addSum);        Thread t2 = new Thread(Sum03::addSum);        t1.start();        t2.start();        // 让主线程期待 t1 和 t2        // 直到 t1 和 t2 执行实现        t1.join();        t2.join();        System.out.println(sum);    }}

应用锁进行临界区的爱护

临界区:像这种与数据竞争无关的代码块叫做临界区,比方上文代码当中的sum++

java当中除了应用synchronized造成同步办法或者同步代码块的办法保障多个线程拜访临界区的程序,还能够应用锁对临界区进行爱护。在java当中一个十分罕用的锁就是可重入锁ReentrantLock,能够保障在lockunlock之间的区域在每一个时刻只有一个线程在执行。

import java.util.concurrent.locks.ReentrantLock;public class Sum04 {    public static int sum;    public static ReentrantLock lock = new ReentrantLock();    public static void addSum() {        int tempSum = 0;        for (int i = 0; i < 100000; i++)            tempSum++;        lock.lock();        try {            sum += tempSum;        }catch (Exception ignored) {        }finally {            lock.unlock();        }    }    public static void main(String[] args) throws InterruptedException {        Thread t1 = new Thread(Sum04::addSum);        Thread t2 = new Thread(Sum04::addSum);        t1.start();        t2.start();        // 让主线程期待 t1 和 t2        // 直到 t1 和 t2 执行实现        t1.join();        t2.join();        System.out.println(sum);    }}

synchronized和锁对可见性的影响

在上文当中咱们仅仅提到了synchronized和锁能够保障在每一个时刻只能有一个线程在临界区当中执行,这一点其实后面的忙期待也能够实现,然而前面咱们提到了第一版忙期待的实现是有谬误的,在程序运行的时候程序会陷入死循环。因而synchronized和锁也会有这样的问题,然而为什么synchronized和锁能够使得程序可能失常执行呢?起因如下:

  • synchronized关键字可能保障可见性,synchronized 关键字可能不仅可能保障同一时刻只有一个线程取得锁,并且还会确保在锁开释之前,会将对变量的批改刷新到主内存当中。
  • JDK给咱们提供的锁工具(比方ReentrantLock)也可能保障可见性,锁的lock办法可能保障在同一时刻只有一个线程取得锁而后执行同步办法,并且会确保在锁开释(锁的unlock办法)之前会将对变量的批改刷新到主内存当中。

数据竞争的出名结果——死锁

死锁是因为多个线程相互之间竞争资源而造成的一种互相僵持的场面。

在后面的忙期待当中的那个谬误如果不认真思考是很容易疏忽的,由此可见数据竞争给并发编程带来的难度。上面一个典型的存在死锁可能的代码:

public class DeadLock {    public static Object fork = new Object();    public static Object chopsticks = new Object();    public static void fork() {        System.out.println(Thread.currentThread().getName() +  "想取得叉子");        synchronized (fork) {            System.out.println(Thread.currentThread().getName() +  "曾经取得叉子");            System.out.println(Thread.currentThread().getName() +  "想取得筷子");            synchronized (chopsticks) {                System.out.println(Thread.currentThread().getName() +  "曾经取得筷子");            }        }    }    public static void chopsticks() {        System.out.println(Thread.currentThread().getName() +  "想取得筷子");        synchronized (chopsticks) {            System.out.println(Thread.currentThread().getName() +  "曾经取得筷子");            System.out.println(Thread.currentThread().getName() +  "想取得叉子");            synchronized (fork) {                System.out.println(Thread.currentThread().getName() +  "曾经取得叉子");            }        }    }    public static void main(String[] args) throws InterruptedException {        Thread t1 = new Thread(DeadLock::fork);        Thread t2 = new Thread(DeadLock::chopsticks);        t1.start();        t2.start();        t1.join();        t2.join();    }}// 输入后果Thread-0想取得叉子Thread-1想取得筷子Thread-0曾经取得叉子Thread-0想取得筷子Thread-1曾经取得筷子Thread-1想取得叉子// 在这里曾经卡死
  • 线程1想先取得叉子,再取得筷子。
  • 线程2想先取得筷子,再取得叉子。

如果有一个状态线程1曾经取得叉子,线程2曾经取得筷子,当初线程1想取得线程2手中的筷子,线程2想取得线程1的叉子,由此产生一个困境,两个线程都想从对方取得货色,也就是说两个线程都无奈失去资源,两个线程都卡死了,因此产生了死锁。依据下面的剖析咱们能够发现死锁产生最基本的起因还是数据竞争。

死锁产生的条件

  • 互斥条件:一个资源只能被一个线程占有,如果其余线程想得到这个资源必须由其余线程开释。
  • 不剥夺条件:一个线程取得的资源在没有应用完之前不能被其余线程强行取得,只能由线程被动开释。
  • 申请放弃条件:至多放弃了一个资源而且提出新的资源申请,比方下面线程1取得了叉子又申请筷子。
  • 循环期待条件:线程1申请线程2的资源,线程2申请线程3的资源,....,线程$N$申请线程1的资源。

如果想毁坏死锁,那就去毁坏下面四个产生死锁的条件即可。

总结

在本篇文章当中次要通过一道并发的题,通过各种办法去解决,在本文当中最重要的例子就是忙期待了,在忙期待的例子当中咱们剖析咱们程序的问题,领会了数据竞争问题的复杂性,他会给咱们的并发程序造成很多的问题,比方前面提到的死锁的问题。除此之外咱们还介绍了对于volatilesynchronized还有锁对数据可见性的影响。

以上就是本文所有的内容了,心愿大家有所播种,我是LeHung,咱们下期再见!!!(记得点赞珍藏哦!)


更多精彩内容合集可拜访我的项目:https://github.com/Chang-LeHu...

关注公众号:一无是处的钻研僧,理解更多计算机(Java、Python、计算机系统根底、算法与数据结构)常识。