编写优质的并发代码是一件难度极高的事情。Java语言从第一版本开始内置了对多线程的支持,这一点在当年是非常了不起的,但是当我们对并发编程有了更深刻的认识和更多的实践后,实现并发编程就有了更多的方案和更好的选择。本篇Chat为接下来的Java并发编程精华版本,重点知识,如果某个知识点不理解,可以再深入的看本专栏中的其它Blog内容介绍。
- 并发编程的挑战:并行与并发的区别,并发编程的几大挑战。
- JMM内存模型:JMM内存模型是什么样的,如何从底层机制保证Java顶层的并发编程能力
- Java并发机制的底层实现:基于JMM内存模型,底层还做了哪些控制来支撑并发能力
- Java的进程与线程:什么是进程,什么是线程,Java如何使用多线程
- 线程生命周期及状态切换:线程的生命周期是怎样的,如何进行状态切换
- Java线程安全与同步方案:如何对共享资源进行有效的控制,线程如何能安全的访问共享资源
- JUC并发包概述:JUC并发包下有哪些内容,有什么好处
- JUC并发包下原子类:JUC下的原子类重点有哪些,如何发挥作用
- JUC并发包下的锁,虽然在线程安全同步方案中已经提及,这里还是需要体系化的认知一下,锁的种类,作用
- JUC并发包下的工具类,一些我们可能会用到的工具类,了解即可
- JUC并发包下的容器类,并发包下的容器类,重点掌握ConcurrentHashMap和CopyOnWrite
- JUC并发包下的线程池,需要掌握线程池的参数,常用的四种线程池,一些方法的比较,线程池的执行流程
- 死锁问题及解决方案,产生死锁的必要条件,死锁的代码示例,以及如何解决死锁问题
适合人群:不了解Java并发编程的新手,对Java并发编程的实现机制感兴趣的技术人员
本文的全部内容来自我个人在Java并发编程学习过程中整理的博客,是该博客专栏的精华部分。在书写过程中过滤了流程性的上下文,例如部署环境、配置文件、代码示例等,而致力于向读者讲述其中的核心部分,如果读者有意对过程性内容深入探究,可以移步专栏中的其它Blog。
并发编程的挑战
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- 什么是并发,并发和并行的区别是什么
- 并发编程有什么挑战、怎么解决
主要围绕两个问题,并发与并行的区别,以及并发编程的挑战和解决方式有哪些。
什么是并发,并发和并行的区别是什么
并发是指两个或多个事件在同一时间间隔内发生,在多道程序环境下,一段时间内宏观上有多个程序在同时执行,而在同一时刻,单处理器环境下实际上只有一个程序在执行,故微观上这些程序还是在分时的交替进行。操作系统的并发是通过分时得以实现的,和串行以及并行的概念区别:
- 串行:顺序做不同事的能力:先洗衣服,洗完后做饭。
- 并发:交替做不同事的能力:一会儿洗衣服,一会儿做饭,交替执行,但快如闪电。洗衣服和做饭的是一个(cpu),在同一个时间段内每个cpu各司其职。并发的实质是一个物理CPU(也可以多个物理CPU) 在若干道程序之间多路复用,并发性是对有限物理资源强制行使多用户共享以提高效率。
- 并行:同时做不同事的能力:左手洗衣服右手做饭,在同一时刻同时做两件事。并行性指两个或两个以上事件或活动在同一时刻发生。在多道程序环境下,并行性使多个程序同一时刻可在不同CPU上同时执行。
并发关注的是资源充分利用(也就是不让cpu闲下来),并行关注的是一个任务被分解给多个执行者同时做,缩短这个任务的完成时间(也就是尽快做完这件事),操作系统的并发性是指计算机系统中同时存在多个运行着的程序,因此它具有处理和调度多个程序同时执行的能力。在操作系统中,引入进程的目的是使程序能并发执行。并行则是同时间同时刻有几个程序同时运行,有几核就就几个程序在并行。单核CPU只能并发多个程序,多核CPU可以并发也可以并行【4核CPU可以并行4个程序,程序大于核心时就需要用到并发性】
并发编程有什么挑战
包括线程轮转执行的上下文切换问题、对同步资源加锁时的死锁问题,整体的资源限制问题
- 上下文切换:CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上 下文切换,减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
- 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
- CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
- 使用最少线程或线程池。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
- 使用协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换
- 死锁问题:对于共享的资源线程没能安全协同处理,互相持有对方线程的资源,有如下几种避免死锁的机制:
- 避免一个线程同时获取多个锁
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况
- 资源限制问题,包括硬件资源限制和软件资源限制问题
- 对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让程序在多机上运行
- 对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket连接复用
关键问题是上下文切换和死锁如何避免。
JMM内存模型
本部分回答以下几个问题,如果能回答正确,则证明本部分掌握好了。
- CPU处理器的内存模型是什么样的
- JMM内存模型的基本构造
- JMM内存操作的整体流程,8个操作过程
- 什么是指令重排,数据依赖,As-if-serial语义指什么
- 什么是Happens-Before先行发生原则,有哪八个原则
接下来我们看这部分的内容。
处理器内存模型
多数运算中,处理器都要和内存进行交互,如读取数据、存储结果等。由于计算机存储设备和处理器运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高缓存Cache来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存中同步回内存,这样处理器就无须等待缓慢的内存读写了。 解决运算冲突却导致了高速缓存的一致性问题。
高缓存Cache引入了缓存的一致性Cache Coherence的问题。在多处理器系统中,每个处理器都有自己的高速缓存,又共享同一主内存,可能导致各自的缓存数据不一致【可见性问题】。需要各处理器访问缓存时遵循一些协议,在读写时根据协议来操作,如MSI,MESI,MOSI,Snapse,Firefly,dRAGON Protocol等。处理器还可能会对输入的代码进行乱序执行优化,之后又将乱序结果重组,保证该结果与顺序执行结果一致,还有指令重排序优化【有序性问题】。
JMM内存模型基本结构
什么是内存模型?内存模型:在特定的操作协议下对特定的内存或高速缓存进行读写访问的过程抽象。Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行(共享变量内存可见),整个通信过程对程序员完全透明,而同步总是显式执行(同步域内有序执行)。
在Java中(JDK1.7),所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享(共享变量代指实例域,静态域和数组元素)。局部变量(Local Variables),方法定义参数和异常处理器参数等不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
JMM,Java Memory Model,用来屏蔽掉各种硬件和操作系统之间的内存访问差异,以实现让Java程序在各平台下都能达到一致的内存访问效果。
在此之前,主流程序语言(如C/C++等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,因此在某些场景就必须针对不同的平台来编写程序。
JMM主要目标:定义程序中各个共享变量的访问规则,即在虚拟机中将变量存储到内存和从内存取出变量这样的底层细节。
主内存与工作内存
如上图所示,JMM中区分为主内存和工作内存:
- JMM规定所有共享变量均存储在主内存(虚拟机内存的一部分)中。每条线程还有自己的工作内存,类比高速缓存。
- 线程对共享变量的所有操作都在工作内存中,不能直接在主内存中读写操作
- 不同线程之间也不能直接访问对方的工作内存中的共享变量。只能通过主内存来传递变量值
注意:主内存与工作内存和Java内存区域的堆栈方法区等并不是同一个层次的内存划分。工作内存可以理解为本地处理器的高速缓冲区。
内存间交互操作
主内存与工作内存之间具体的交互协议:一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存等的细节。
Java内存模型定义了8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子操作。对于long 和double类型的变量,store,read,write,load操作在某些平台上允许有例外。
- lock(锁定):作用于主内存的变量,它把一个共享变量标志为一条线程独占的状态。
- unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的共享变量释放出来,释放后的共享变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个共享变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的共享变量值放入工作内存的共享变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中一个共享变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到共享变量的字节码指令时将执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接受到的值赋给工作内存的共享变量,遇到赋值的字节码时执行。
- store(存储):作用于工作内存的变量,它把工作内存中一个共享变量变量的值传送到主内存中,以便随后的write操作使用。
- write(写入):作用于主内存中的变量,它把store操作从主内存中得到的变量值放入主内存的变量中。
整体执行的流程如下:
以上操作,仅保持顺序执行即可,不用保证连续执行。如可能发生 read a read b load b load a。
交互操作规范
变量操作相关的
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况
- 不允许一个线程丢弃它的最近assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
- 不允许一个线程无原因的(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中,即不能对变量没做任何操作却无原因的同步回主内存
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,就是对一个变量执行use和store之前必须先执行过了load和assign操作
lock操作相关的
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁,synchronized实现可重入锁的基石
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值,synchronized实现可见性的基石
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量,synchronized实现锁的基石
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store write)synchronized实现可见性的基石
以上可以完全确定Java程序中哪些内存访问操作在并发下是安全的。
指令重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,也叫做指令重排,指令重排可能导致预期的执行结果不符,所以JMM提供了两种语义解决这个问题:as-if-serial和Happens-Before,JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事
- as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
- as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
- as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度
程序员可以依据Happens-Before原则来进行多线程正确同步下的执行顺序推演。
As-if-serial语义
如果两个操作访问同一个共享变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性
- 单线程或单处理器下,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
- 多处理器之间或多线程之间的数据依赖性不被编译器和处理器考虑
在并发编程中,数据依赖性其实得不到满足
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序
Happens-Before的语义
两个操作之间具有 happens-before 关系, 并不意味着前一个操作必须要在后一个操作之前执行,happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照,happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。
关于顺序执行的三个原则
- 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。(同一个线程中前面的所有写操作对后面的操作可见)
- 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
- 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
关于锁定与读写的两个原则
- 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。(如果线程1解锁了monitor a,接着线程2锁定了a,那么,线程1解锁a之前的写操作都对线程2可见(线程1和线程2可以是同一个线程))
- volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。(如果线程1写入了volatile变量v(临界资源),接着线程2读取了v,那么,线程1写入v及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程))
关于线程的三个原则
- 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。(假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行前对线程B可见。注意:线程B启动之后,线程A在对变量修改线程B未必可见。)
- 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。(线程t1写入的所有变量,在任意其它线程t2调用t1.join(),或者t1.isAlive() 成功返回后,都对t2可见。)
- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。(线程t1写入的所有变量,调用Thread.interrupt(),被打断的线程t2,可以看到t1的全部操作)(A h-b B , B h-b C 那么可以得到 A h-b C)
以上先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从以上规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。