多线程中那些看不见的陷阱

多线程编程就像一个沼泽,中间遍及各类各样的陷阱。大多数开发者绝大部分时间都是在作上层应用的开发,并不须要过多地涉入底层细节。可是在多线程编程或者说是并发编程中,有很是多的陷阱被埋在底层细节当中。若是不知道这些底层知识,可能在编写过程当中彻底意识不到程序已经出现了漏洞,甚至在漏洞爆发以后也很难排查出具体缘由进而解决漏洞。虽然前面提到的漏洞听起来很吓人,可是相信经过咱们逐步的抽丝剥茧,在最后必定能掌握大量的实用工具来帮助咱们解决这些问题,实现可靠的并发程序。java

阅读本文须要了解并发的基本概念和Java多线程编程基础知识,还不了解的读者能够参考一下下面两篇文章:编程

  1. 并发的基本概念——当咱们在说“并发、多线程”,说的是什么?
  2. Java多线程编程基础——这一次,让咱们彻底掌握Java多线程

数据竞争问题

为了了解多线程程序有什么隐藏的陷阱,咱们先来看一段代码:缓存

public class AccumulateWrong {

    private static int count = 0;

    public static void main(String[] args) throws Exception {
        Runnable task = new Runnable() {
            public void run() {
                for (int i = 0; i < 1000000; ++i) {
                    count += 1;
                }
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("count = " + count);
    }
}
复制代码

这段代码实现的基本功能就是在两个线程中分别对一个整型累加一百万次,那么咱们指望的输出应该总共是两百万。但在个人电脑上运行的结果只有1799369,并且每次都不同,相信在你的电脑上也会运行获得一个不一样的结果,可是确定会达不到两百万。性能优化

这段代码出现问题的缘由就在于,咱们在执行count += 1;这行代码时,实际在CPU上运行的会是多条指令:bash

  1. 获取count变量的当前值
  2. 计算count + 1的值
  3. 将count + 1的结果值存到count变量中

因此就有可能会发生下面的执行顺序:多线程

t1 t2
获取到count的值为100
计算100 + 1 = 101
获取到count的值为100
把101保存到count变量中
计算100+ 1 = 101
把101保存到count变量中

这么一轮操做结束以后,虽然咱们在两个线程中分别对count累加了一次,总共是两次,可是count的值只变大了1,这时结果就出现了问题。这种在多个线程中对共享数据进行竞争性访问的状况就被称为数据竞争,能够理解为对共享数据的并发访问会致使问题的状况就是数据竞争并发

那么咱们如何解决这样的数据竞争问题呢?app

synchronized关键字

相信大多数读者应该都知道synchronized这个关键字,它能够被用在方法定义或者是块结构上,那么它到底能发挥怎样的做用呢?咱们把它以块结构的形式把count += 1;语句包围起来看看。ide

for (int i = 0; i < 1000000; ++i) {
    synchronized (this) {
        count += 1;
    }
}
复制代码

运行以后能够看到,此次的输出是两百万整了。在这里,synchronized发挥的做用就是让两个线程互斥地执行count += 1;语句。所谓互斥也就是同一时间只能有一个线程执行,若是另外一个线程同时也要执行的话则必须等到前一个线程完成操做退出synchronized语句块以后才能进入。工具

这种同一时间只能被一个线程访问的代码块就被称为临界区,而synchronized这样的保护临界区同时只能被一个线程进入的机制就被称为互斥锁。当一个线程由于另一个线程已经获取了锁而陷入等待时,咱们能够称该线程被这个锁阻塞了。

在Java中,synchronized的背后是对象锁,每一个不一样的对象都会对应一个不一样的锁,同一个对象对应同一个锁。只有获取同一个锁才能达到互斥访问的做用,若是两个线程分别获取不一样的锁,那么互相就不会影响了。因此在使用synchronized时,区分背后对应的是哪个对象锁就相当重要了。synchronized关键字能够被用在方法定义和块结构两种状况中,具体对应的锁以下:

  1. 以块结构形式使用synchronized关键字,则获取的就是synchronized关键字后小括号中的对象所对应的锁;
  2. synchronized被标记在实例方法上,则获取的就是this引用指向对象所对应的锁;
  3. synchronized被标记在类方法(静态方法)上时,获取的就是方法所在类的“类对象”所对应的锁,这里的类对象就能够理解为是每一个类一个用于存放静态字段和静态方法的对象。

由于synchronized必定要有一个对应的对象,因此咱们天然不能将基本类型的变量传入到synchronized后面的括号中。

ReentrantLock

在Java 5中JDK引入了java.util.concurrent包,也许你们都或多或少据说过这个包,在这个包中提供了大量使用的并发工具类,例如线程池、锁、原子数据类等等,对Java语言的并发编程易用性和实际效率产生了跨越性的提升。而ReentrantLock就是这个包中的一员。

ReentrantLock发挥的做用与synchronized相同,都是做为互斥锁使用的。下面是把以前的累加代码改成使用ReentrantLock锁的版本:

final ReentrantLock lock = new ReentrantLock();

Runnable task = new Runnable() {
    public void run() {
        for (int i = 0; i < 1000000; ++i) {
            lock.lock();
            try {
                count += 1;
            } finally {
                lock.unlock();
            }
        }
    }
};
复制代码

运行以后的结果依然是两百万,说明ReentrantLock确实能起到保障互斥访问临界区的做用。可是既然ReentrantLocksynchronized的做用相同,并且从代码来看使用synchronized还更方便,为何还要专门定义一个ReentrantLock这样的类呢?

上面的代码中,虽然使用ReentrantLock还要专门写一个try..finally块来保证锁释放,比较麻烦,可是也能从中看到一个好处就是咱们能够决定加锁的位置和释放锁的位置。咱们甚至能够在一个方法中加锁,而在另外一个方法中解锁,虽然这样作会有风险。相对于传统的synchronizedReentrantLock还有下面的一些好处:

  1. ReentrantLock能够实现带有超时时间的锁等待,咱们能够经过tryLock方法进行加锁,并传入超时时间参数。若是超过了超时时间还么有得到锁的话,那么就tryLock方法就会返回false;
  2. ReentrantLock可使用公平性机制,让先申请锁的线程先得到锁,防止线程一直等待锁可是获取不到;
  3. ReentrantLock能够实现读写锁等更丰富的类型。

更简便的方式——AtomicInteger

java.util.concurrent包中,咱们能够找到一个颇有趣的子包atomic,在这个包中咱们看到有不少以Atomic开头的“包装类型”,这些类会有什么用呢?咱们先来看一下前面的累加程序使用AtomicInteger该如何实现。

public class AtomicIntegerDemo {

    private static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws Exception {
        Runnable task = new Runnable() {
            public void run() {
                for (int i = 0; i < 1000000; ++i) {
                    count.incrementAndGet();
                }
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("count = " + count);
    }

}
复制代码

运行这个程序,咱们也能够获得正确的结果两百万。在这个版本的代码中咱们主要改了两处地方,一个是把count变量的类型修改成了AtomicInteger类型,而后把Runnable对象中的累加方式修改成了count.incrementAndGet()

AtomicInteger提供了原子性的变量值修改方式,原子性保证了整个累加操做能够被当作是一个操做,不会出现更细粒度的操做之间互相穿插致使错误结果的状况。在底层AtomicInteger是基于硬件的CAS原语来实现的,CAS是“Compare and Swap”的缩写,意思是在修改一个变量时会同时指定新值和旧值,只有在旧值等于变量的当前值时,才会把变量的值修改成新值。这个CAS操做在硬件层面是能够保证原子性的。

咱们既能够用Atomic类来实现一些简单的并发修改功能,也可使用它来对一些关键的控制变量进行控制,起到控制并发过程的目的。线程池类ThreadPoolExecutor中用于控制线程池状态和线程数的控制变量ctl就是一个AtomicInteger类型的字段。

内存可见性问题

看完了如何解决数据竞争问题,咱们再来看一个略显神奇的例子。

public class MemoryVisibilityDemo {

    private static boolean flag;

    public static void main(String[] args) throws Exception {

        for (int i = 0; i < 10000; ++i) {
            flag = false;
            final int no = i;

            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    flag = true;
                    System.out.println(String.format("No.%d loop, t1 is done.", no));
                }
            });

            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    while (!flag) ;

                    System.out.println(String.format("No.%d loop, t2 is done.", no));
                }
            });

            t2.start();
            t1.start();

            t1.join();
            t2.join();
        }
    }

}
复制代码

这段程序在个人电脑上输出是这样的:

No.0 loop, t2 is done.
No.0 loop, t1 is done.
No.1 loop, t1 is done.
No.1 loop, t2 is done.
No.2 loop, t2 is done.
No.2 loop, t1 is done.
No.3 loop, t2 is done.
No.3 loop, t1 is done.
No.4 loop, t1 is done.
复制代码

在上面的程序输出中咱们能够看到,代码中的循环是10000次,可是在程序输出结果中到第五次就结束了。并且第五次运行中只有t1执行完了,t2的结束语句一直没输出。这说明程序被卡在了while (!flag) ;上,可是t1明明已经运行结束了,说明此时flag = true已经执行了,为何t2还会被卡住呢?

这是由于内存可见性在做祟,在计算机中,咱们的存储会分为不少不一样的层次,你们比较常见的就是内存和外存,外存就是好比磁盘、SSD这样的持久性存储。其实在内存之上还有多个层次,较完整的计算机存储体系从下到上依次有外存、内存、“L三、L二、L1三层高速缓存”、寄存器这几层。在这个存储体系中从下到上是一个速度从慢到快的结构,越上层速度越快,因此当CPU操做内存数据时会尽可能把数据读取到内存之上的高速缓存中再进行读写。

因此若是程序想要修改一个变量的值,那么系统会先把新值写到L1缓存中,以后在合适的时间才会将缓存中的数据写回内存当中。虽然这样的设置使系统的整体效率获得了提高,可是也带来了一个问题,那就是L一、L2两级高速缓存是核内缓存,也就是说多核处理器的每个核心都有本身独立的L一、L2高速缓存。那么若是咱们在一个核中运行的线程上修改了变量的值而没有写回内存的话,其余核心上运行的线程就看不到这个变量的最新值了。

结合咱们前面的程序例子,由于修改和读取静态变量flag的代码在两个不一样的线程中,因此在多核处理器上运行这段程序时,就有可能在两个不一样的处理器核心上运行这两段代码。最终就会致使线程t1虽然已经把flag变量的值修改成true了,可是由于这个值尚未写回内存,因此线程t2看到的flag变量的值仍然是false,这就是以前的代码会被卡住的罪魁祸首。

那么咱们如何解决这个问题呢?

volatile变量

最简单的方式是使用volatile变量,即把flag变量标记为volatile,以下所示:

private static volatile boolean flag;
复制代码

这下程序就能够稳定地跑完了,那么volatile作了什么解决了内存可见性问题呢?根据编号为JSR-133的Java语言规范所定义的Java内存模型(JMM)volatile变量保证了对该变量的写入操做和在其以后的读取操做之间存在同步关系,这个同步关系保证了对volatile变量的读取必定能够获取到该变量的最新值。在底层,对volatile变量的写入会触发高速缓存强制写回内存,该操做会使其余处理器核心中的同一个数据块无效化,必须从内存中从新读取。Java内存模型的具体内容在下一节中会有简单的介绍。

从上面的内存可见性问题咱们能够发现,多线程程序中会出现的一些问题涉及一些很是底层的知识,并且不了解的人是很难事先预防和过后排查的。因此对于但愿真正掌握多线程编程的朋友来讲,这必然会是一场很是奇妙与漫长的旅程,但愿你们都能坚持到最后。

Java内存模型

Java语言规范中的JSR-133定义了一系列决定不一样线程之间指令的逻辑顺序,从而保证了不会出现内存可见性和指令重排序所引起的并发问题,这对彻底掌握多线程程序的正确性相当重要。

在程序中,咱们通常会认定程序语句是按代码中的顺序执行的,好比下面这段代码:

a = 0;
a = 1;
b = 2;
c = 3;
复制代码

咱们固然会认为程序的执行顺序是a = 0; -> a = 1; -> b = 2; -> c = 3;,但实际上会有两种状况可能会破坏语句的执行顺序,一是编译器对指令的重排序可能会致使语句的顺序发生改变,二是前面提到的内存可见性。

对于编译器的指令重排序来讲,虽然编译器会保证单个线程内语句的执行效果与顺序执行相同,可是在上面的代码中三个语句之间是没有依赖关系的,任意顺序执行的效果都是相同的,因此编译器是有可能对其中的语句进行重排序的。在单线程程序中这固然没有问题,任意顺序执行上面代码中的语句都是同样的,可是在多线程状况下,问题就复杂了。若是另一个线程在变量b的值变为2后会打印变量a的值,那么按咱们的指望这段程序应该打印出的1。可是若是b = 2;语句被重排序到了a = 1;以前和a = 0;以后,那么咱们打印出的值就是0了。

对于内存可见性,若是b = 2;对变量b的修改结果先于a = 1;写回了内存中。那么在另外一个线程中,当看到变量b的值变为2时还不能看到变量a的新值1,这一样会致使程序打印出不符合咱们指望的值。

从上面的介绍咱们能够看出,在这个问题中最重要的是语句的执行顺序,在默认状况下,咱们能够保证单线程内的执行顺序所产生的结果必定是符合咱们的指望的,但一旦进入多线程状况下,咱们就不能作出这样的保证了。**那么咱们如何保证多个线程之间语句的执行顺序关系呢?**这就要说到咱们以前说到的Java内存模型了。

Java内存模型中定义了不一样线程中的语句的顺序关系,这被称为Happens-Before关系,如下简称HB。这个关系指的是若是“操做A”HB于“操做B”,那么若是“操做A”确实在“操做B”以前已经发生了,那么“操做B”必定会像在“操做A”以后发生同样:看到“操做A”发生后所产生的全部结果,好比变量值的修改。若是“操做A”把变量a的值修改成了2,那么全部“操做B”都必定能看到变量a的值为2,不管是编译器对指令的重排序仍是不一样处理器核心之间的内存可见性都不能破坏这个结果。

正是由于这种指令执行前后关系的核心就是看到以前执行指令在内存中体现的结果,因此这个规范才被称为Java内存模型

经常使用的Happens-Before关系规则:

  1. 同一个线程中,“先执行的语句” HB于 “以后执行的全部语句”;
  2. “对volatile变量的写操做” HB于 “对同一个变量的读操做”;
  3. “对锁的释放操做” HB于 “对同一个锁的加锁操做”;
  4. “对Thread对象的start操做” HB于 “该线程任务中的第一行语句”;
  5. “线程任务中的最后一行语句” HB于 “对该线程对应的Thread对象的join操做”;
  6. 传递性规则:若是“操做B” HB于 “操做A”,“操做C” HB于 “操做B”,那么“操做C” 也HB于 “操做A”。

经过第一条规则咱们就肯定了单线程内的语句的执行顺序,而经过规则2到规则4,咱们就能够线程间肯定具体的语句执行顺序了。最后的规则6传递性规则是整个规则体系的补充,利用这条规则咱们就能够把规则1中的线程内顺序和规则2到4的线程间规则进行结合,获得最终的完整顺序体系了。

在下图中,左边一列和右边一列分别是两条不一样的线程中执行的语句及其顺序。若是变量c是一个volatile变量,那么根据规则2,咱们能够知道操做c = 3 HB于 操做print c,下图中用红线标明了这个关系。因此根据JMM的定义,print c将能够看到变量c的值已经被修改成3了,打印结果将是3,若是在print c语句下方继续执行对变量a和b的打印,那么结果必然分别是1和2。

可是咱们不能保证右侧的第一条print b语句必定会打印出2的值,即便它在时间上发生于b = 2以后。由于指令重排序或者内存可见性问题都有可能会使它只能看到变量b在b = 2以前的原值。也就是说HB关系是没办法指定两条线程中在HB关系以前的语句相互之间的顺序关系的,在下图的例子中就是print b并不能保证必定能够打印出值2,也有可能打印出变量b原来的值。

总结

在这篇文章中咱们主要介绍了如何保证多线程程序的正确性,使运行过程和结果符合咱们的预期。经过对多线程程序正确性问题的探索,咱们介绍了三种经常使用的线程同步方式,分别是锁、CAS与volatile变量。其中,锁有synchronized关键字和ReentrantLock两种实现方式。

在这个过程当中,咱们深刻到了计算机系统的底层,了解了计算机存储体系结构和volatile对高速缓存与内存的影响。多线程编程是一个很是好的切入口,让咱们能够将之前曾经学过的计算机理论知识与编程实践结合起来,这种结合对很是多的高级知识领域都是相当重要的。

由于错误的程序是没有价值的,因此对一个程序来讲最重要的固然是正确性。可是在实现了正确性的前提下,咱们也必需要想办法提高程序的性能。由于多线程的目标就是经过多个线程的协做来提高程序的性能,若是达不到这个目标的话咱们辛辛苦苦写的多线程代码就没有意义了。在下一篇文章中咱们将会具体测试多线程程序的性能,经过发现多线程中那些会让多线程程序运行得比单线程程序更慢的性能陷阱,最终咱们将找到解决这些陷阱的性能优化方法。下一篇文章将在下周发布,有兴趣的读者能够关注一下。

相关文章
相关标签/搜索