搞懂Java并发编程的四个问题

1,601 阅读11分钟

前言

首先感谢优秀的极客时间专栏《Java并发编程实战》,本篇文章都是学习了这个专栏之后的一些总结和自己的思考,附上我总结的专栏重点知识笔记:并发专栏重要知识点。知道这些理论基础后,学习并发相关的其他知识点就上手得很快了。这是第一篇文章,有不足之处还望读者多多指出,大家共同进步。

并发编程在各类开发语言中都属于相对高阶的地位,这意味着并发编程使用起来有一定门槛,而且很可能一个不小心写出Bug还不知道哪里出了问题。今天我就来说说并发编程,在知道它的一些本质原理之后,不管是自己在实际项目中写并发编程的代码,还是面试中遇到并发编程程相关的问题,都能心里不慌,细细分析一波,找到可能出现Bug的地方。

img

如果现在有个需求,让你实现本地文件批量上传,你会怎么设计?

反手来个线程池,把任务丢进去异步上传。几乎是条件反射,像文件上传这么耗时的操作当然开个子线程。尤其在Android中,如果在主线程做耗时操作,很容易导致ANR。大家都知道为什么要用多线程,因为不能阻塞主线程,多几个线程并发交替执行任务,提高执行效率。

那么问题来了,实际上多个线程并发执行,同一个时刻也只有一个线程在执行,只不过多个线程快速地交替执行而已。这样看来多个线程各执行一个任务的消耗时间,跟单线程执行多个任务的消耗时间理论上是一样的,而且多线程开发还多了线程上下文切换的时间,看起来更耗时啊。

终于引出了今天的第一个问题,那为什么还要用并发编程?

为什么要用并发编程?

上面讲的场景用单线程去执行多个任务确实更高效更安全,少了线程切换的时间,也不存在线程安全问题。但是如果任务中要去执行IO操作,情况就不一样了。

如果要读文件,CPU就发个命令让设备驱动去干活,也就是执行IO操作。CPU发完命令后就处于空闲状态,只能干等,等IO操作结束后,CPU再接着执行后续任务,这样CPU的利用率就大大降低了。

为了在IO等待的时候不让CPU闲着,我们就把任务拆分交替执行。一个线程执行到IO操作时,CPU空闲了,另一个线程正好能获得CPU时间片。放个图方便理解:

image-20191017165055880

知道并发编程的好处之后,我们来看下一个问题。

怎么写好并发编程?

从全局的角度来看,并发编程可以总结为三个核心问题:分工、同步、互斥。

分工就是我们前面介绍的,把任务拆解分配给线程,具有这样特性的系统叫做分时操作系统。

分工之后,CPU利用率上来了,配合默契地协作能力在团队工作中也是必不可少的,为了对任务进行更好的组织编排,比如一个线程执行完了一个任务,再通知执行后续任务的线程开工,这就需要执行任务的线程之间就需要互相通信。因此操作系统提供了一套线程通信的方案,也就是线程同步

有了分工和同步,就可以愉快地编写高效的并发程序了,但还有一个深坑,如果多线程对同一个资源进行读写,并且这个资源还没有保护措施,这时候就会引发线程安全问题,也就是说这个程序的执行结果是不确定的。我们必须要保证同一时刻只有一个线程访问共享资源,也就是互斥

高效地分工、在合适的时机同步、正确地互斥,任何并发编程的问题都可以从这三个方面考虑。说这个问题主要是让大家建立一种全局观,能从宏观的角度去处理并发任务。

接下来看第三个问题,为什么在单线程下跑得好好的代码,一到并发环境下就Bug频出呢?到底是什么导致并发编程的Bug?

是什么导致并发编程的Bug?

线程不安全的本质就是一个线程对变量A进行写操作的时候(写操作还未完成),另一个线程对变量A进行了读写操作。这里引出三个概念:原子性、可见性和有序性,他们仨就是罪魁祸首,具体代表什么意思,等会再讲。

为了能充分协调CPU、内存和I/O设备三者的速度差异,计算机也是拼了老命在优化了,比如下面这些:

  • CPU增加了缓存,均衡CPU与内存的速度差异。

  • 操作系统增加了进程、线程,以分时复用CPU,均衡CPU与I/O设备速度差异。

  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

    • 比如第1行: a=10; 第100行,a=a+1,通过重排序,把他们放在一起执行,少了一步读取a的操作了,直接拿10进行计算。

虽然计算机的性能得到了提升,但这也是并发编程Bug的源头,以上的三个优化也带了三个问题:

1. 缓存导致了可见性问题

可见性,主要是针对共享变量而言,具备可见性意味着一个线程对共享变量的修改,另一个线程能够立刻看到。正是因为CPU使用了缓存,会先从内存中读取值存入缓存,下次用的时候直接从缓存中取,速度更快,但是多个线程可能在不同的CPU执行,这时候线程1对共享变量A的修改,对线程2而言就不具备可见性了。放张图方便理解:

image-20191018183844133

如图所示,线程1和线程2一开始分别从内存中读取了共享变量A的值存到CPU缓存里,之后线程1对A做了修改,并把值刷新到了内存中,此时线程2再从CPU缓存中读到的A值已经不是最新的值了。这就叫存在可见性问题。

2. 线程切换带来了原子性问题

一个或者多个操作在CPU执行的过程中不被中断的特性叫原子性。操作系统做任务切换,可以发生在任何一条CPU指令执行完,而不是高级语言里的一条语句。比如最常见的A = A + 1就不具备原子性,因为完成这条语句需要三个动作, 取值 -> 加一 -> 赋值,那么可能在执行第二个动作的时候,发生了线程切换,另外一个线程修改了A的值,问题就来了。

image

3. 编译优化导致了有序性问题

程序按照代码的先后顺序执行就叫有序性。前面说了编译程序为了更好地利用缓存,会对代码进行重排序。最经典的例子就是新建对象。

创建对象的new操作对应的CPU语句是:

  1. 分配一块内存M
  2. 在内存M上初始化对象
  3. 将M的地址赋值给对象变量instance

正常代码执行顺序就是1、2、3,但是经过重排序后,2和3的顺序可能会颠倒。(???顺序颠倒能不出错吗?)如果在单线程中,是不会有问题的,因为不管你123,还是321,最终执行完new操作后对象都是初始化好了的,编译程序对代码进行重排序也是为了更好的利用计算机资源,它是能够保证程序的运行结果在单线程中是正确的。

但是在多线程中就可能有问题了,当线程1执行完语句3还未执行语句2时,切换线程,线程B判断到instance已经不为null,就直接使用了,实际上instance指向的对象还没有初始化,此时就可能触发空指针异常。

image

知道了并发Bug的源头,那Java自身又是怎么设计的去避免这些问题呢?Java又提供了哪些语言特性让开发者解决这些问题呢?

Java如何保证并发安全?

1. Java内存模型:保证可见性和有序性

导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。合理的方案应该是按需禁用缓存以及编译优化。Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。其中volatile、synchronized 和 final的用法这里不细说了,说起来可以写三篇文章了,详细用法网络上有很多文章可以参考。我说一下Happens-Before规则。

Happens-Before规则

Happens-Before规则大概内容如下:

  1. 程序的顺序性规则:指在一个线程中,按照程序顺序,前面的操作Happens-Before于后续的任意操作。
  2. volatile变量规则:指对一个volatile变量的写操作,Happens-Before于后续对这个volatile变量的读操作。
  3. 传递性:指如果A Happens-Before B,B Happens-Before C,那么A Happens-Before C。
  4. 管程中锁的规则:指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
  5. 线程start()规则:指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
  6. 线程join()规则:指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。

第一次接触Happens-Before规则时,我的内心

image

这一大堆规则到底是干嘛的呢?是因为市面上有很多种编译器,编译器们可以发挥自己的想象尽情优化程序,但是前提是优化后的程序一定要遵守所有的Happens-Before 规则。Java提供了这样一堆规则去约束编译器的行为,以保证并发程序的正确性。实际上Happens-Before语义本质上就是一种可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。

举一个规则4的代码例子,能理解更清楚点:

sychronized(obj){ //加锁
    //对共享变量进行修改
    a = 123;
}//隐式解锁

规则4的意思就是,如果线程A进入了sychronized块,对共享变量进行了修改,然后又退出了sychronized块,接着线程B进入sychronized块,此时能够保证线程B读取到的共享变量的值是a=123,也就是说能看到线程A在sychronized中对共享变量的修改。 如果只是一段未加锁的代码,是不能保证可见性的。这就是Happens-Before规则的意义。

2. 互斥锁:保证原子性

前面说过线程切换带来了原子性,互斥锁可以锁住一块代码区域,保证只有拿到锁的线程可以进入区域内,并且区域内同一时刻只允许一个线程进入,这种区域有个学名叫做临界区。这种用锁去保护资源的模型,在现实生活中也随处可见。Java提供了synchronized关键字实现互斥锁的功能,线程在synchronized块中,即使发生了线程切换,线程持有的锁也不会释放。Java并发包还提供了Lock相关的并发工具类。因此我们只要把对共享变量的相关操作都用锁封装起来,就能保证同一时刻只有一个线程对共享变量进行操作。模型如图所示:

image