首先感谢优秀的极客时间专栏《Java并发编程实战》,本篇文章都是学习了这个专栏以后的一些总结和本身的思考,附上我总结的专栏重点知识笔记:并发专栏重要知识点。知道这些理论基础后,学习并发相关的其余知识点就上手得很快了。这是第一篇文章,有不足之处还望读者多多指出,你们共同进步。html
并发编程在各种开发语言中都属于相对高阶的地位,这意味着并发编程使用起来有必定门槛,并且极可能一个不当心写出Bug还不知道哪里出了问题。今天我就来讲说并发编程,在知道它的一些本质原理以后,无论是本身在实际项目中写并发编程的代码,仍是面试中遇到并发编程程相关的问题,都能内心不慌,细细分析一波,找到可能出现Bug的地方。面试
若是如今有个需求,让你实现本地文件批量上传,你会怎么设计?编程
反手来个线程池,把任务丢进去异步上传。几乎是条件反射,像文件上传这么耗时的操做固然开个子线程。尤为在Android中,若是在主线程作耗时操做,很容易致使ANR。你们都知道为何要用多线程,由于不能阻塞主线程,多几个线程并发交替执行任务,提升执行效率。缓存
那么问题来了,实际上多个线程并发执行,同一个时刻也只有一个线程在执行,只不过多个线程快速地交替执行而已。这样看来多个线程各执行一个任务的消耗时间,跟单线程执行多个任务的消耗时间理论上是同样的,并且多线程开发还多了线程上下文切换的时间,看起来更耗时啊。安全
终于引出了今天的第一个问题,那为何还要用并发编程?bash
上面讲的场景用单线程去执行多个任务确实更高效更安全,少了线程切换的时间,也不存在线程安全问题。可是若是任务中要去执行IO操做,状况就不同了。网络
若是要读文件,CPU就发个命令让设备驱动去干活,也就是执行IO操做。CPU发完命令后就处于空闲状态,只能干等,等IO操做结束后,CPU再接着执行后续任务,这样CPU的利用率就大大下降了。多线程
为了在IO等待的时候不让CPU闲着,咱们就把任务拆分交替执行。一个线程执行到IO操做时,CPU空闲了,另外一个线程正好能得到CPU时间片。放个图方便理解:并发
知道并发编程的好处以后,咱们来看下一个问题。app
从全局的角度来看,并发编程能够总结为三个核心问题:分工、同步、互斥。
分工就是咱们前面介绍的,把任务拆解分配给线程,具备这样特性的系统叫作分时操做系统。
分工以后,CPU利用率上来了,配合默契地协做能力在团队工做中也是必不可少的,为了对任务进行更好的组织编排,好比一个线程执行完了一个任务,再通知执行后续任务的线程开工,这就须要执行任务的线程之间就须要互相通讯。所以操做系统提供了一套线程通讯的方案,也就是线程同步。
有了分工和同步,就能够愉快地编写高效的并发程序了,但还有一个深坑,若是多线程对同一个资源进行读写,而且这个资源尚未保护措施,这时候就会引起线程安全问题,也就是说这个程序的执行结果是不肯定的。咱们必需要保证同一时刻只有一个线程访问共享资源,也就是互斥。
高效地分工、在合适的时机同步、正确地互斥,任何并发编程的问题均可以从这三个方面考虑。说这个问题主要是让你们创建一种全局观,能从宏观的角度去处理并发任务。
接下来看第三个问题,为何在单线程下跑得好好的代码,一到并发环境下就Bug频出呢?究竟是什么致使并发编程的Bug?
线程不安全的本质就是一个线程对变量A进行写操做的时候(写操做还未完成),另外一个线程对变量A进行了读写操做。这里引出三个概念:原子性、可见性和有序性,他们仨就是罪魁祸首,具体表明什么意思,等会再讲。
为了能充分协调CPU、内存和I/O设备三者的速度差别,计算机也是拼了老命在优化了,好比下面这些:
CPU增长了缓存,均衡CPU与内存的速度差别。
操做系统增长了进程、线程,以分时复用CPU,均衡CPU与I/O设备速度差别。
编译程序优化指令执行次序,使得缓存可以获得更加合理地利用。
虽然计算机的性能获得了提高,但这也是并发编程Bug的源头,以上的三个优化也带了三个问题:
可见性,主要是针对共享变量而言,具有可见性意味着一个线程对共享变量的修改,另外一个线程可以马上看到。正是由于CPU使用了缓存,会先从内存中读取值存入缓存,下次用的时候直接从缓存中取,速度更快,可是多个线程可能在不一样的CPU执行,这时候线程1对共享变量A的修改,对线程2而言就不具有可见性了。放张图方便理解:
如图所示,线程1和线程2一开始分别从内存中读取了共享变量A的值存到CPU缓存里,以后线程1对A作了修改,并把值刷新到了内存中,此时线程2再从CPU缓存中读到的A值已经不是最新的值了。这就叫存在可见性问题。
一个或者多个操做在CPU执行的过程当中不被中断的特性叫原子性。操做系统作任务切换,能够发生在任何一条CPU指令执行完,而不是高级语言里的一条语句。好比最多见的A = A + 1
就不具有原子性,由于完成这条语句须要三个动做, 取值 -> 加一 -> 赋值,那么可能在执行第二个动做的时候,发生了线程切换,另一个线程修改了A的值,问题就来了。
程序按照代码的前后顺序执行就叫有序性。前面说了编译程序为了更好地利用缓存,会对代码进行重排序。最经典的例子就是新建对象。
建立对象的new操做对应的CPU语句是:
正常代码执行顺序就是一、二、3,可是通过重排序后,2和3的顺序可能会颠倒。(???顺序颠倒能不出错吗?)若是在单线程中,是不会有问题的,由于无论你123,仍是321,最终执行完new操做后对象都是初始化好了的,编译程序对代码进行重排序也是为了更好的利用计算机资源,它是可以保证程序的运行结果在单线程中是正确的。
可是在多线程中就可能有问题了,当线程1执行完语句3还未执行语句2时,切换线程,线程B判断到instance已经不为null,就直接使用了,实际上instance指向的对象尚未初始化,此时就可能触发空指针异常。
知道了并发Bug的源头,那Java自身又是怎么设计的去避免这些问题呢?Java又提供了哪些语言特性让开发者解决这些问题呢?
致使可见性的缘由是缓存,致使有序性的缘由是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,可是这样问题虽然解决了,咱们程序的性能可就堪忧了。合理的方案应该是按需禁用缓存以及编译优化。Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来讲,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。其中volatile、synchronized 和 final的用法这里不细说了,提及来能够写三篇文章了,详细用法网络上有不少文章能够参考。我说一下Happens-Before规则。
Happens-Before规则大概内容以下:
第一次接触Happens-Before规则时,个人心里
这一大堆规则究竟是干吗的呢?是由于市面上有不少种编译器,编译器们能够发挥本身的想象尽情优化程序,可是前提是优化后的程序必定要遵照全部的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规则的意义。
前面说过线程切换带来了原子性,互斥锁能够锁住一块代码区域,保证只有拿到锁的线程能够进入区域内,而且区域内同一时刻只容许一个线程进入,这种区域有个学名叫作临界区。这种用锁去保护资源的模型,在现实生活中也随处可见。Java提供了synchronized关键字实现互斥锁的功能,线程在synchronized块中,即便发生了线程切换,线程持有的锁也不会释放。Java并发包还提供了Lock相关的并发工具类。所以咱们只要把对共享变量的相关操做都用锁封装起来,就能保证同一时刻只有一个线程对共享变量进行操做。模型如图所示: