1 概念
1.1 同步(Synchronous)和异步(Asynchronous)
同步和异步通常用来形容一次方法调用。
同步方法调用一旦开始,调用者必须等待方法调用返回后,才能继续后续的行为。
异步方法调用更像一个消息传递,一旦开始,方法调用就会立即返回,调用者就可以继续后续的操作。
对于同步,通俗的打个比方,我们去商场鞋子,看好后就告诉售货员下单。售货员去调货,于是你就在商场等货,等到后带着鞋回家;
对于异步,通俗的打个比方,我们进行一次网购,在网上选中鞋子,在线支付后就完成了,你可以去做别的事情,直到快递联系你收货;
1.2 并发(Concurrency)和并行(Parallelism)
并发和并行是两个非常容易被混淆的概念。它们都可以表示两个或者多个任务一起执行。但是偏重点有些不同。并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的。而并行是真正意义上的“同时执行”。
严格意义上来说,并行的多个任务是真实的同时执行,而对于并发来说,这个过程只是交替的,一会儿运行任务A一会而执行任务B,系统会不停地在两者间切换。但对于外部观察者来说,即使多个任务之间是串行并发的,也会造成多个任务是并行执行的错觉。
1.3 临界区
临界区用来表示一种公共资源或者说是共享数据,可以被多个线程使用。但是每一次,只能有一个线程使用它,一旦临界区资源被占用,其他线程想要使用这个资源,就必须等待。
1.4 阻塞(Blocking)和非阻塞(Non-Blocking)
阻塞和非阻塞通常用来形容多线程间的互相影响。
比如一个线程占用了临界区资源,那么其他所有需要这个资源的线程必须在这个临界区中等待。等待会导致线程挂起,这种情况就是阻塞。
此时,如果占用资源的线程一直不愿意释放资源,那么其他所有阻塞在这个临界区上的线程都不能工作。
非阻塞的意思于之相反,它强调没有一个线程可以妨碍其他线程执行。所有的线程都会尝试不断向前执行。
1.5 死锁(Deadlock)、饥饿(Starvation)和活锁(Livelock)
死锁、饥饿和活锁都属于多线程活跃性问题。如果发现上述几种情况,那么相关线程可能就不再活跃,也就说它可能很难在继续往下执行了。
死锁应该算是最糟糕的情况是,比如A线程等待B线程释放资源,同时,B线程等待A线程释放资源,那么就会进入死锁状态。
饥饿是指某一个或多个线程因为种种原因无法获得所需的资源,导致一直无法执行。比如它的线程优先级可能太低,而高优先级的线程不断抢占它需要的资源,导致低优先级线程无法工作。
活锁是一种非常有趣的情况。比如,当你要做电梯的时候下楼,电梯到了,门开了,这时你正准备出去。但是很不巧的是,门外一个人挡住你的去路,想进入电梯。他往左走想避开你,而你往右走像避开他,重复了几次......
如果这种情况发生在两个线程间可能就不会那么幸运了,如果两个线程都秉承着“谦让”原则,主动将资源释放给他人使用,那么就会出现资源不断在两个线程中跳动,而没有一个线程可以同时拿到所有资源而正常执行。这种情况就是活锁。
2 并发级别
由于临界区的存在,多线程之间的并发必须受到控制。根据控制并发的策略,我们可以把并发的级别进行分类,大致上可以分为:阻塞、无饥饿、无障碍、无锁和无等待几种。
2.1 阻塞(Blocking)
一个线程是阻塞的,那么在其他线程释放资源之前,当前线程法务继续执行。当我们使用synchronized关键字,或者重入锁时,我们得到的就是阻塞的线程。
无论是synchronized或是重入锁,都会在试图执行后续代码前,得到临界区的锁,如果得不到,线程就会被挂起等待,直到占有了所需资源为止。
2.2 无饥饿(Starvation-Free)
如果线程之间是有优先级的,那么线程调度的时候总是会倾向于满足高优先级的线程。也就是说,对于同一个资源的分配,是不公平的。
对于非公平的锁来说,系统允许高优先级的线程插队。这样有可能导致低优先级线程生产饥饿。但如果锁是公平的,满足先来后到,那么饥饿就不会产生,不管新来的线程优先级多高,想要获得资源,就必须乖乖排队。那么所有的线程都有机会执行。
2.3 无障碍(Obstruction-Free)
无障碍是一种最弱的非阻塞调度。两个线程如果是无障碍的执行,那么他们不会因为临界区的问题导致一方被挂起。换言之,大家都可以大摇大摆地进入临界区了。那么如果大家一起修改共享数据,就会有可能把数据修改坏。
对于无障碍的线程来说,一旦检测到这种情况,它就会立刻对自己所做的修改进行回滚,确保数据安全。但如果没有数据竞争发生,那么线程就可以顺利完成自己的工作,走出临界区。
如果阻塞的控制方式是悲观策略。也就是说,系统认为两个线程之间很有可能发生不幸的冲突,因此,以保护共享数据为第一优先级。相对来说,非阻塞的调度就是一种乐观的策略。它认为多个线程之间很有可能不会发生冲突,或者说这种几率不大。因此大家都应该无障碍的执行,但是一旦检测到冲突,就应该进行回滚。
从这个策略可以看到,无障碍的多线程程序并不一定能顺畅的运行。因为当临界区中存在严重的冲突时,所有的线程可能都会不断地回滚自己的操作,而没有一个线程可以走出临界区。这种情况会影响系统的正常执行。所以,我们可能会非常希望在这一堆线程中,至少可以有一个线程能够在有限时间内完成自己的操作,而退出临界区。至少这样可以保证系统不会在临界区中进行无限的等待。
一种可行的无障碍实现可以依赖一个“一致性标记”来实现。
线程在操作之前,先读取并保存这个标记,在操作完成后,再次读取,检查这个标记是否被更改过,如果两者是一致的,则说明资源访问没有冲突,如果不一致,则说明资源可能在操作过程中与其他写线程冲突,需要重试操作。而任何对资源有修改操作的线程,在修改数据前,都需要更新这个一致性标记,表示数据不再安全。
2.4 无锁(Lock-Free)
无锁的并行都是无障碍的。在无锁的情况下,所有的线程都能尝试对临界区进行访问,但不同的是,无锁的并发保证必然有一个线程能够在有限步内完成操作离开临界区。
在无锁的调用中,一个典型的特点是可能会包含一个无穷循环。在这个循环中,线程会不断尝试修改共享变量。如果没有冲突,修改成功,那么程序退出,否则继续尝试修改。但无论如何,无锁的并行总能保证有一个线程是可以胜出的,不至于全军覆没。至于临界区中竞争失败的线程,它们则必须不断重试,直到自己获胜。如果运气很不好,总是尝试不成功,则会出现类似饥饿的现场,线程会停止不浅。
2.5 无等待(Wait-Free)
无锁只要求有一个线程可以在有限步内完成操作,而无等待则在无锁的基础上更进一步进行拓展。
它要求所有的线程都必须在有限步内完成,这样就不会引起饥饿问题。如果限制这个步骤上限,还可以进一步分解为有界无等待和线程数无关的无等待几种,它们之间的区别只是对循环次数的限制不同。
3 回到JAVA:JMM
JMM的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的。因此需要了解这三个概念。
3.1 原子性(Atomicity)
原子性是指一个操作是不可中断的,即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
3.2 可见性(Visibility)
可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。
3.3 有序性(Ordering)
在并发时,程序的执行可能就会出现乱序。给人直观的感觉就是,写在前面的代码,会在后面执行。其原因是程序在执行时,可能会进行指令重排,排序后的指令与原指令的顺序未必一致。
3.4 那些指令不能重排序:Happen-Before规则
一下罗列了一些基本原则,这些原则是指令重排不可违背的:
- 程序顺序原则:一个线程内保证语义的串行性;
- volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性;
- 锁规则:解锁必先发生在随后的加锁前;
- 传递性:A先于B,B先于C,A必然先于C;
- 线程的start()方法先于它的每一个动作;
- 线程的所有操作先于线程的终结(Thread.join());
- 线程的中断(interrupt())先于被中断线程的代码;
- 对象的构造函数执行、结束先于finalize()方法;