1.实战java高并发程序设计--走入并行世界

1.2.1 同步(Synchronous)和异步(Asynchronous)

同步和异步通常用来形容一次方法调用。同步方法调用一旦开始,调用者必须等到方法调用返回后,才能继续后续的行为。异步方法调用更像一个消息传递,一旦开始,方法调用就会立即返回,调用者就可以继续后续的操作。而异步方法通常会在另外一个线程中“真实”地执行。整个过程,不会阻碍调用者的工作。图1.4显示了同步方法调用和异步方法调用的区别。对于调用者来说,异步调用似乎是一瞬间就完成的。如果异步调用需要返回结果,那么当这个异步调用真实完成时,则会通知调用者。

1.2.2 并发(Concurrency)和并行(Parallelism)

并发和并行是两个非常容易被混淆的概念。它们都可以表示两个或者多个任务一起执行,但是侧重点有所不同。并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的,而并行是真正意义上的“同时执行”

从严格意义上来说,并行的多个任务是真的同时执行,而对于并发来说,这个过程只是交替的,一会儿执行任务A,一会儿执行任务B,系统会不停地在两者之间切换。但对于外部观察者来说,即使多个任务之间是串行并发的,也会造成多任务间并行执行的错觉。

真实的并行也只可能出现在拥有多个CPU的系统中(比如多核CPU)。

1.2.3 临界区

临界区用来表示一种公共资源或者说共享数据,可以被多个线程使用。但是每一次,只能有一个线程使用它,一旦临界区资源被占用,其他线程要想使用这个资源就必须等待。

1.2.4 阻塞(Blocking)和非阻塞(Non-Blocking)

阻塞和非阻塞通常用来形容多线程间的相互影响。比如一个线程占用了临界区资源,那么其他所有需要这个资源的线程就必须在这个临界区中等待。等待会导致线程挂起,这种情况就是阻塞。此时,如果占用资源的线程一直不愿意释放资源,那么其他所有阻塞在这个临界区上的线程都不能工作。

非阻塞的意思与之相反,它强调没有一个线程可以妨碍其他线程执行,所有的线程都会尝试不断前向执行。

1.2.5 死锁(Deadlock)、饥饿(Starvation)和活锁(Livelock)

死锁、饥饿和活锁都属于多线程的活跃性问题。如果发现上述几种情况,那么相关线程可能就不再活跃,也就是说它可能很难再继续往下执行了。

死锁应该是最糟糕的一种情况了(当然,其他几种情况也好不到哪里去),图1.6显示了一个死锁的发生。

A、B、C、D四辆小车在这种情况下都无法继续行驶了。它们彼此之间相互占用了其他车辆的车道,如果大家都不愿意释放自己的车道,那么这个状态将永远持续下去,谁都不可能通过。死锁是一个很严重的并且应该避免和时时小心的问题,我们将安排在“锁的优化及注意事项”中进行更详细的讨论。

饥饿是指某一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行。比如它的线程优先级可能太低,而高优先级的线程不断抢占它需要的资源,导致低优先级线程无法工作.饥饿还是有可能在未来一段时间内解决的(比如,高优先级的线程已经完成任务,不再疯狂执行)。

如果线程的智力不够,且都秉承着“谦让”的原则,主动将资源释放给他人使用,那么就会导致资源不断地在两个线程间跳动,而没有一个线程可以同时拿到所有资源正常执行。这种情况就是活锁

1.3 并发级别

由于临界区的存在,多线程之间的并发必须受到控制。根据控制并发的策略,我们可以把并发的级别分为阻塞、无饥饿、无障碍、无锁、无等待几种。

1.3.1 阻塞

一个线程是阻塞的,那么在其他线程释放资源之前,当前线程无法继续执行。当我们使用synchronized关键字或者重入锁时(我们将在第2、3章介绍这两种技术),我们得到的就是阻塞的线程。synchronized关键字和重入锁都试图在执行后续代码前,得到临界区的锁,如果得不到,线程就会被挂起等待,直到占有了所需资源为止。

1.3.2 无饥饿(Starvation-Free)

如果线程之间是有优先级的,那么线程调度的时候总是会倾向于先满足高优先级的线程。也就说是,对于同一个资源的分配,是不公平的!图1.7显示了非公平锁与公平锁两种情况(五角星表示高优先级线程)。对于非公平锁来说,系统允许高优先级的线程插队。这样有可能导致低优先级线程产生饥饿。但如果锁是公平的,按照先来后到的规则,那么饥饿就不会产生,不管新来的线程优先级多高,要想获得资源,就必须乖乖排队,这样所有的线程都有机会执行。

1.3.3 无障碍(Obstruction-Free)

无障碍是一种最弱的非阻塞调度。两个线程如果无障碍地执行,那么不会因为临界区的问题导致一方被挂起。换言之,大家都可以大摇大摆地进入临界区了。那么大家一起修改共享数据,把数据改坏了怎么办呢?对于无障碍的线程来说,一旦检测到这种情况,它就会立即对自己所做的修改进行回滚,确保数据安全。但如果没有数据竞争发生,那么线程就可以顺利完成自己的工作,走出临界区。

如果说阻塞的控制方式是悲观策略,也就是说,系统认为两个线程之间很有可能发生不幸的冲突,因此以保护共享数据为第一优先级,相对来说,非阻塞的调度就是一种乐观的策略。它认为多个线程之间很有可能不会发生冲突,或者说这种概率不大。因此大家都应该无障碍地执行,但是一旦检测到冲突,就应该进行回滚

从这个策略中也可以看到,无障碍的多线程程序并不一定能顺畅运行。因为当临界区中存在严重的冲突时,所有的线程可能都会不断地回滚自己的操作,而没有一个线程可以走出临界区。这种情况会影响系统的正常执行。所以,我们可能会非常希望在这一堆线程中,至少可以有一个线程能够在有限的时间内完成自己的操作,而退出临界区。至少这样可以保证系统不会在临界区中进行无限的等待。

一种可行的无障碍实现可以依赖一个“一致性标记”来实现。线程在操作之前,先读取并保存这个标记,在操作完成后,再次读取,检查这个标记是否被更改过,如果两者是一致的,则说明资源访问没有冲突。如果不一致,则说明资源可能在操作过程中与其他写线程冲突,需要重试操作。而任何对资源有修改操作的线程,在修改数据前,都需要更新这个一致性标记,表示数据不再安全。

1.3.4 无锁(Lock-Free)

无锁的并行都是无障碍的。在无锁的情况下,所有的线程都能尝试对临界区进行访问,但不同的是,无锁的并发保证必然有一个线程能够在有限步内完成操作离开临界区。在无锁的调用中,一个典型的特点是可能会包含一个无穷循环。在这个循环中,线程会不断尝试修改共享变量。如果没有冲突,修改成功,那么程序退出,否则继续尝试修改。但无论如何,无锁的并行总能保证有一个线程是可以胜出的,不至于全军覆没。至于临界区中竞争失败的线程,它们必须不断重试,直到自己获胜。如果运气很不好,总是尝试不成功,则会出现类似饥饿的现象,线程会停止。下面就是一段无锁的示意代码,如果修改不成功,那么循环永远不会停止。

1.3.5 无等待(Wait-Free)

无锁只要求有一个线程可以在有限步内完成操作,而无等待则在无锁的基础上更进一步扩展。它要求所有的线程都必须在有限步内完成,这样就不会引起饥饿问题。如果限制这个步骤的上限,还可以进一步分解为有界无等待和线程数无关的无等待等几种,它们之间的区别只是对循环次数的限制不同。一种典型的无等待结构就是RCU(Read Copy Update)。它的基本思想是,对数据的读可以不加控制。因此,所有的读线程都是无等待的,它们既不会被锁定等待也不会引起任何冲突。但在写数据的时候,先取得原始数据的副本,接着只修改副本数据(这就是为什么读可以不加控制),修改完成后,在合适的时机回写数据。

1.4 有关并行的两个重要定律

目前,主要有两个定律对这个问题进行解答,一个是Amdahl定律,另外一个是Gustafson定律。

1.4.1 Amdahl定律

Amdahl定律是计算机科学中非常重要的定律。它定义了串行系统并行化后的加速比的计算公式和理论上限。

加速比定义:加速比 = 优化前系统耗时 / 优化后系统耗时

注意:根据Amdahl定律,使用多核CPU对系统进行优化,优化的效果取决于CPU的数量,以及系统中的串行化程序的比例。CPU数量越多,串行化比例越低,则优化效果越好。仅提高CPU数量而不降低程序的串行化比例,也无法提高系统性能。

1.4.2 Gustafson定律

Gustafson定律也试图说明处理器个数、串行化比例和加速比之间的关系,如图1.12所示,但是Gustafson定律和Amdahl定律的角度不同。同样,加速比都被定义为优化前的系统耗时除以优化后的系统耗时。

从Gustafson定律中,我们可以更容易地发现,如果串行化比例很小,并行化比例很大,那么加速比就是处理器的个数。只要不断地累加处理器,就能获得更快的速度。

1.4.3 是否相互矛盾

说的啥玩意,卡不懂!


1.5 回到Java:JMM

由于并发程序要比串行程序复杂很多,其中一个重要原因是并发程序中数据访问的一致性和安全性将会受到严重挑战。

JMM的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的。因此,我们首先必须了解这些概念。

1.5.1 原子性(Atomicity)

原子性是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。比如,对于一个静态全局变量int i,两个线程同时对它赋值,线程A给它赋值1,线程B给它赋值为-1。那么不管这两个线程以何种方式、何种步调工作,i的值要么是1,要么是-1。线程A和线程B之间是没有干扰的。这就是原子性的一个特点,不可被中断。但如果我们不使用int型数据而使用long型数据,可能就没有那么幸运了。对于32位系统来说,long型数据的读写不是原子性的(因为long型数据有64位)。也就是说,如果两个线程同时对long型数据进行写入(或者读取),则对线程之间的结果是有干扰的。

1.5.2 可见性(Visibility)

可见性是指当一个线程修改了某一个共享变量的值时,其他线程是否能够立即知道这个修改。显然,对于串行程序来说,可见性问题是不存在的。因为你在任何一个操作步骤中修改了某个变量,在后续的步骤中读取这个变量的值时,读取的一定是修改后的新值。但是这个问题存在于并行程序中。如果一个线程修改了某一个全局变量,那么其他线程未必可以马上知道这个改动。图1.14展示了发生可见性问题的一种可能。如果在CPU1和CPU2上各运行了一个线程,它们共享变量t,由于编译器优化或者硬件优化的缘故,在CPU1上的线程将变量t进行了优化,将其缓存在cache中或者寄存器里。在这种情况下,如果在CPU2上的某个线程修改了变量t的实际值,那么CPU1上的线程可能无法意识到这个改动,依然会读取cache中或者寄存器里的数据。因此,就产生了可见性问题。外在表现为:变量t的值被修改,但是CPU1上的线程依然会读到一个旧值。可见性问题也是并行程序开发中需要重点关注的问题之一。

1.5.3 有序性(Ordering)

有序性问题可能是三个问题中最难理解的了。对于一个线程的执行代码而言,我们总是习惯性地认为代码是从前往后依次执行的。这么理解也不能说完全错误,因为就一个线程内而言,确实会表现成这样。但是,在并发时,程序的执行可能就会出现乱序。给人的直观感觉就是:写在前面的代码,会在后面执行。听起来有些不可思议,是吗?有序性问题的原因是程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。下面来看一个简单的例子:

注意:这里说的是可能存在。因为如果指令没有重排,这个问题就不存在了,但是指令是否发生重排、如何重排,恐怕是我们无法预测的。因此,对于这类问题,我认为比较严谨的描述是:线程A的指令执行顺序在线程B看来是没有保证的。如果运气好的话,线程B也许真的可以看到和线程A一样的执行顺序。

注意:指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致

指令重排对于提高CPU处理性能是十分必要的。虽然确实带来了乱序的问题,但是这点牺牲是完全值得的。

1.5.4 哪些指令不能重排:Happen-Before规则

在前文已经介绍了指令重排,虽然Java虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的,并非所有的指令都可以随便改变执行位置,以下罗列了一些基本原则,这些原则是指令重排不可违背的。● 程序顺序原则:一个线程内保证语义的串行性。● volatile规则:volatile变量的写先于读发生,这保证了volatile变量的可见性。● 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前。● 传递性:A先于B,B先于C,那么A必然先于C。● 线程的start()方法先于它的每一个动作。● 线程的所有操作先于线程的终结(Thread.join())。● 线程的中断(interrupt())先于被中断线程的代码。● 对象的构造函数的执行、结束先于finalize()方法。

此外,锁规则强调,unlock操作必然发生在后续的对同一个锁的lock之前。也就是说,如果对一个锁解锁后,再加锁,那么加锁的动作绝对不能重排到解锁的动作之前。很显然,如果这么做,则加锁行为是无法获得这把锁的。其他几条原则也是类似的,这些原则都是为了保证指令重排不会破坏原有的语义结构。

发布了24 篇原创文章 · 获赞 1 · 访问量 3417

猜你喜欢

转载自blog.csdn.net/ashylya/article/details/104277207