一、基础概念
1、同步(Synchronous)与异步(Asynchronous)
同步和异步通常用来形容一次方法调用。
同步方法调用:一旦开始,调用者必须等到方法调用返回后,才能继续后续的操作行为。
异步方法调用:类似一个消息传递,一旦开始,方法调用就会立即返回,不影响调用者执行后续的行为。异步调用是在一瞬间完成的,如果异步调用需要返回结果,则会在异步调用真正完成时通知调用者。
2、并发(Concurrency)与并行(Parallelism)
并发和并行都可以表示两个或多个任务一起执行,但侧重点不同。并发偏重于多个任务交替执行,而多个任务之间可能还是串行的,对于外部观察者来说,即使多个任务之间是串行并发的,也会造成多任务之间是并行执行的错觉。而并行的多个任务是真正意义上的同时执行。
如果系统内只有一个CPU,使用多线程或多进程任务,在真实环境中这些任务是不可能实际并行的,因为一个CPU一次只能执行一条指令,这种情况下多进程或多线程就是并发的,并非是并行的(操作系统会不停的切换多个任务)。真实的并行只可能出现在多核CPU中。
3、临界区
临界区表示一种公共资源或者共享数据,可以被多个线程使用。每一次只能有一个线程使用它,一旦临界区资源币占用,其他线程要想使用这个资源,就必须等待其他线程将资源释放。在并行程序中,临界区资源是受保护的对象。
4、阻塞(Blocking)与非阻塞(Non-Blocking)
阻塞和非阻塞通常用来形容多线程之间的相互影响,如一个线程占用了临界区资源,那么其他所有需要这个资源的线程就必须在这个临界区中等待。等待会导致线程挂起,即阻塞。如果占用资源的线程一直不释放,则其他所有阻塞在这个临界点的线程都将不能工作。
非阻塞表示没有一个线程可以妨碍其他线程执行,所有的线程都会不断尝试继续执行。
5、死锁(Deadlock)、饥饿(Starvation)与(Livelock)
死锁、饥饿和活锁都属于多线程的活跃性问题,这几种情况下线程可能不再活跃或者说不能继续执行了。
死锁:指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法进行下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。死锁是一个很严重的问题,一旦死锁它不能自行恢复。
一种情形,如果执行程序中两个或多个线程发生永久堵塞(等待),每个线程都在等待被其他线程占用并堵塞了的资源。例如线程A锁住了记录1并等待记录2,而线程B锁住了记录2并等待记录1,这样两个线程就发生了死锁现象。
死锁发生的四个必要条件:
1)互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
2)请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
3)不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
4)环路等待条件:指在发生死锁时,必然存在一个进程与资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。
饥饿:指一个或多个线程因为某种原因无法获取到所需的资源,导致一直无法执行。如它的线程优先级可能太低,高优先级的线程不断抢占它所需的资源,导致低优先级的线程无法工作。另外一种情况,某个线程一直占用着关键资源不释放,导致其他需要这个资源的线程无法执行,也会造成饥饿。与死锁相比,它有可能在未来一段时间内解决。
活锁:指的是任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败的过程。处于活锁的实体是在不断的改变状态,活锁有可能自行解开。活锁可以认为是一种特殊的饥饿。例如事务T2再不断的重复尝试获取锁R,那么这个就是活锁。活锁是一系列进程在轮询地等待某个不可能为真的条件为真。发生活锁时的进程是不会blocked,这会导致耗尽CPU资源。
活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。
二、并发级别
由于临界区的存在,多线程之间的并发需要受到控制,根据控制并发的策略,可以把并发级别分为:阻塞、无饥饿、无障碍、无锁、无等待。
1、阻塞(Blocking)
如果一个线程是阻塞的,那么在其他线程释放资源钱,当前线程将无法继续执行。当使用synchronized关键字或者重入锁时,得到的线程就是阻塞的。它们都会试图在执行后续代码前,得到临界区的资源锁,如果得不到,线程就会被挂起等待,直到占有了所需资源为止。
2、无饥饿(Starvation-Free)
如果线程之间有优先级,则线程调度时总会倾向于高优先级的线程。对于非公平的锁来说,系统允许高优先级的线程插队,这样可能会导致低优先级的线程饥饿,如果锁是公平的,则不会产生饥饿,无论线程的优先级高低,要获取到资源必须排队,那么所有的线程就都有机会运行。
3、无障碍(Obstruction-Free)
无障碍是一种最弱的非阻塞调度,如果两个线程是无障碍的执行,则它们都不会因为临界区的资源问题导致一方被挂起。就是说所有线程都可以修改共享数据,对于无障碍线程来说,一旦检测到这种情况,它就会立即对自己所做修改进行回滚,确保数据安全。若没有发生资源竞争,那么线程可以完成任务,走出临界区。
阻塞调度方式是一种悲观策略,系统认为两个线程之间很可能发生冲突,时刻以保护共享数据为最高优先级。相对的,非阻塞的调度方式就是一种乐观的策,它认为多个线程间很有可能不会发生冲突,大家都应该无障碍的执行,但一旦检测到冲突,则应该回滚操作。
无障碍的线程并不一定能顺利的运行,当临界区的资源出现严重冲突时,所有线程可能都会不断的回滚自己的操作,从而导致没有一个线程可以走出临界区,此时将会影响系统的正常运行。一种可行的无障碍实现方式是依赖“一致性标记”来实现,线程在操作前,先读取并保存这个标记,在操作完成后,再次读取,比对这个标记是否被修改过。如果两者一致则说明资源访问没有冲突,否则说明资源在操作过程中与其他写线程产生冲突,需要重试操作。任何线程在对资源进行修改前,都必须更新这个一致性标记,表示数据不再安全。
4、无锁(Lock-Free)
无锁的并行都是无障碍的,在无锁情况下,所有线程都可以尝试对临界区的资源访问,不同的是,无锁的并发保证必然有一个线程能够在有限的步骤内完成操作并离开临界区。
在无锁的调用中,一个典型的特点是可能会包含一个无穷循环。在此循环中,线程会不断的尝试修改共享变量,如果没有冲突则修改成功,否则会继续尝试修改。无锁的并行总能保证有一个线程是可以成功的。对于临界区中竞争失败的线程,它们必须不断重试,直到成功为止,若总是失败,则会出现类似饥饿的现象。
5、无等待(Wait-Free)
无等待要求所有的线程都必须在有限步内完成,这样不会引起饥饿问题,如果限制步骤数,可以分解为有界无等待和线程数无关的无等待的类型,它们的区别是对循环次数的限制不同。
典型的无等待结构是RCU(Read-Copy-Update),基本思想是对数据的读可以不加控制,所有的读线程都是无等待的,它们不会被锁定等待也不会引起冲突,在写的时候先读取原始数据的副本,接着修改数据副本,修改完成后再回写数据。
三、并行相关的定律
1、Amdahl定律
阿姆达尔定律是计算机系统设计的重要定量原理之一,于1967年由IBM360系列机的主要设计者阿姆达尔首先提出。该定律是指:系统中对某一部件采用更快执行方式所能获得的系统性能改进程度,取决于这种执行方式被使用的频率,或所占总执行时间的比例。阿姆达尔定律实际上定义了采取增强(加速)某部分功能处理的措施后可获得的性能改进或执行时间的加速比。它定义了串行系统并行化后的加速比的计算公式和理论上限。
加速比定义:加速比 = 优化前系统耗时 / 优化后系统耗时
公式:S = 1 / (1 - a + a / n)
其中,a为并行计算部分所占比例,n为并行处理结点个数。
当1-a=0时,(即只有并行)最大加速比s=n;
当a=0时(即只有串行),最小加速比s=1;
当n→∞时,极限加速比s→ 1/(1-a),也就是加速比的上限。
加速比就是优化前的耗时与优化后耗时的比值,加速比越高,表明优化效果越明显。根据Amdahl定律,使用多核CPU对系统进行优化,优化的效果取决于CPU的数量以及系统中的串行化程序的比重。CPU数量越多,串行化比重越低,则优化效果越好。仅提高CPU数量而不降低程序的串行化比重,无法系统性能。
2、Gustafson定律
gustafson 定律:系统优化某部件所获得的系统性能的改善程度,取决于该部件被使用的频率,或所占总执行时间的比例。
gustafson定律在Amdahl定律的基础上提出,但思想角度不同。
执行时间: 串行时间a + 并行时间b
优化后时间: a + nb
加速比: (a + nb) / (a + b)
f串行比例 : a / (a + b)
如果串行化比例很小,并行化比例很大,那么加速比就是处理器的个数。只要不断的增加处理器,就可以获得更快的速度。
Amdahl定律强调:当串行比例一定时,加速比是有上限的,不管你增加了多少CPU都不可能突破这个上限。
Gustafson定律:如果可被并行化的代码所占比重足够多,那么加速比就能随着CPU的数量线性增长。
两个定律最低点、最高点都是一致的结论:
无可并行的程序,加速比就是1。
全部是并行程序,加速比就是n。
四、Java内存模型(JMM)
1、原子性(Atomicity)
原子性是指一个操作是不可中断的,即使是在多个线程一起执行的时候,一个操作一旦开始就不会被其他线程影响。如:一个静态全局变量int i,2个线程同时对它赋值,线程A赋值1,线程B赋值2,那么不管这2个线程以何种方式运行,i的值要么是1,要么是2,这2个线程之间是没有干扰的。如果将int类型换为long的话就未必是这样了,对于32位系统来说,long类型的数据读写不是原子性的,long有64位长度。若两个线程同时对long写入的话,对线程之间的结果是有影响的。
2、可见性(Visibility)
可见性是指当一个线程修改了某个共享变量值,其他线程是否能立即知道这个修改。对于串行程序而言不存在可见性问题。对于并行程序来说,如果一个线程修改了某个全局变量,其他线程未必会立刻知道这个改动。比如在CPU1和CPU2各有一个线程,它们共享变量t,由于编译器优化或硬件优化的原因,在CPU1的线程将t进行了优化,并缓存在cache或寄存器里。此时CPU2上的线程修改了t的值,那么CPU1上的线程可能无法知道这个改动,依然读取使用cache或寄存器里值,这样就产生了数据可见性问题。
导致可见性问题的原因有缓存优化或者硬件优化、指令重排及编译器的优化等。
3、有序性(Ordering)
对于一个线程的执行代码而言,我们总是习惯性的认为代码的执行是从先往后依次执行的,在一个线程内这样理解并无问题,但是在并发时,程序的执行可能会出现乱序。造成的感觉就是:前面的代码会在后面执行。有序性问题的原因是因为程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。看似有点奇怪,但它的确可能存在。线程A的指令执行顺序在线程B看来是没有保证的,两个线程可能执行顺序一致,也可能不一致。
指令重排的前提是:保证串行语义的一致性。即指令重排不会使串行的语义逻辑发生问题。它可以保证串行语义的一致性,但并无法保证多线程间的语义也一致。
指令的执行步骤:
1)取指 IF
2)译码和取寄存器操作数 ID
3)执行或有效地址计算 EX
4)存储器访问 MEM
5)写回 WB
由于一个步骤可能使用不同的硬件完成,工程师发明了流水线技术来执行指令,原理如下:
指令1:IF ID EX MEM WB
指令2: IF ID EX MEM WB
当执行指令2时,第一条指令其实并未执行完,只是刚取了值而已。假如每一步都需要花费1ms,那么指令2等待指令1完全执行完再执行,需要等待5ms,而是用流水线后,指令2只需等待1ms就可以执行,对性能的提升相当明显。
流水线可以让CPU高效的运行,在满载时性能很高,但一旦中断,所有的硬件设备都会进入一个停顿期,再次满载又需要几个周期,性能损失会很大,故必须尽力保证流水线不中断。指令重排就是为了尽量少的中断流水线。
4、哪些指令不能重排:Happen-Before规则
1)程序顺序原则:一个线程内保证语义的串行性
2)volatile规则:volatile变量的写,先发生于读,保证了volatile变量的可见性
3)锁规则:解锁必然发生在随后的加锁前
4)传递性:A先于B,B先于C,那么A必然先于C
5)线程的start()先于它的每一个动作
6)线程的所有操作先于线程的终结(Thread.join())
7)线程的中断(interrupt())先于被中断线程的代码
8)对象的构造函数执行与结束先于finalize()方法
--参考文献《实战Java高并发程序设计》