1.1 概念
1.1.1 同步(Synchronous)和异步(Asynchronous)
- 同步和异步通常用来形容一次调用。
- 同步方法调用一旦开始,调用者必须等到方法调用返回后,才能继续后续的行为。
- 异步调用更像一个消息传递,一旦开始,方法调用就会立即返回,调用者就可以继续后续的操作。而异步方法通常会在另外一个线程中“真实”地执行。整个过程,不会阻碍调用者工作。
- 对于调用者来说,异步调用似乎是一瞬间就完成的。如果异步调用需要返回结果,那么当这个异步调用真实完成时,则会通知调用者。
- 图1.4显示了同步方法调用和异步方法调用的区别。
1.1.2 并发(Concurrency)和并行(Parallelism)
- 并发和并行都可以表示两个或者多个任务一起执行。并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的。而并行是真正意义上的“同时执行”。
- 真正的并行也只能出现在拥有多个CPU的系统中(比如多核CPU)。
- 图1.5
1.1.3 临界区
- 临界区用来表示一种公共资源或者说是共享数据,可以被多个线程使用。但是每一次只能有一个线程使用它,一旦临界区资源被占用,其他线程要想使用这个资源,就必须等待。
1.1.4 阻塞(Blocking)和非阻塞(Non-Blocking)
- 阻塞和非阻塞通常用来形容多线程间的相互影响。
- 比如一个线程占用了临界区资源,那么其他所有需要这个资源的线程就必须在这个临界区中进行等待。等待会导致线程挂起,这种情况就是阻塞。此时,如果占用资源的线程一直不愿意释放资源,那么其他 所有阻塞在这个临界区上的线程都不能工作。
- 非阻塞强调没有一个线程可以妨碍其他线程执行。所有的线程都会尝试不断前向执行。
1.1.5 死锁(Deadlock)、饥饿(Starvation)和活锁(Livelock)
-
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在 互相等待的进程称为死锁进程。
-
饥饿是指某一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行。与死锁相比,饥饿还是有可能在未来一段时间内解决的。
-
如果线程的智力不够,且都秉承“谦让”的原则,主动将资源释放给他人使用,那么就会出现资源不断在两个线程中跳动,而没有一个线程可以同时拿到所有资源而正常执行。这种情况就是活锁。
1.2 并发级别
- 并发的级别大致分为阻塞、无饥饿、无障碍、无锁、无等待。
1.2.1 阻塞(Blocking)
- 一个线程是阻塞的,那么在其他线程释放资源之前,当前线程无法继续执行。当我们使用synchronize关键字,或者重入锁时,我们得到的就是阻塞的线程。
1.2.2 无饥饿(Starvation-Free)
- 如果线程之间是有优先级的,那么线程调度的时候总是会倾向于满足高优先级的线程。对于非公平的锁来说,系统允许高优先级的线程插队。这样有可能导致低优先级线程产生饥饿。但如果锁是公平的,满 足先来后到,那么饥饿就不会产生,不管新来的线程优先级多高,要想获得资源,就必须乖乖排队。那么所有的线程都有机会执行。
1.2.3 无障碍(Obstruction-Free)
- 无障碍是一种最弱的非阻塞调度。两个线程如果是无障碍的执行,那么他们不会因为临界区的问题导致一方被挂起。换言之,大家都可以大摇大摆地进入临界区了。那么如果大家一起修改共享数据,把 数据改坏了可怎么办呢?对于无障碍的线程来说,一旦检测到这种情况,它会立即对自己所做的修改进行回滚,确保数据安全。但如果没有数据竞争产生,那么线程就可以顺利完成自己的工作,走出临界区。
1.2.4 无锁(Lock-Free)
- 无锁的并行都是无障碍的。在无锁的情况下,所有线程都能尝试对临界区进行访问,但不同的是,无锁的并发保证必然有一个线程能够在有限步内完成操作离开临界区。
1.2.5 无等待(Wait-Free)
- 无锁只要求有一个线程可以在有限步内完成操作,而无等待则在无锁的基础上更进一步进行扩展。。它要求所有的线程都必须在有限步内完成,这样就不会引起饥饿问题。
- 一种典型的无等待结构就是RCU(Read-Copy-Update)。它的基本思想是,对数据的读可以不加控制。因此,所有的读线程都是无等待的,它们既不会被锁定等待也不会引起任何冲突。但在写数据的时候,先取得原始数据的副本,接着只修改副本数据,修改完成后,在合适的时机回写数据。
1.3 回到Java:JMM
- JMM的关键技术点都是围绕着多线程的原子性、可见性、和有序性来建立的。
1.3.1 原子性(Atomicity)
- 原子性是指一个操作是不可中断的。即使是多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
1.3.2 可见性(Visibility)
-
可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。显然,对于串行程序来说,可见性问题是不存在的。因为你在任何一个操作步骤中修改了某个变量,那么在后 续的步骤中,读取这个变量的值,一定是修改后的新值。
-
图1.14展示了发生可见性问题的一种可能。
-
如果在CPU1和CPU2上各运行了一个线程,它们共享变量t,由于编辑器优化或者硬件优化的缘故,在CPU1上的线程将变量t进行了优化,将其缓存在cache中或者寄存器里。这种情况下,如果在CPU2上的某 个线程修改了变量t的实际值,那么CPU1上的线程可能并无法意识到这个改动,依然会读取cache中或者寄存器里的数据。因此,就产生了可见性问题。外在表现为:变量t的值被修改,但是CPU1上的线程依然会读到一个旧值。
1.3.3 有序性(Ordering)
-
有序性问题的原因是因为程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。
-
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。
-
为什么要指令重排,因为性能考虑。一条指令的执行可以分为以下几步:
- 取指IF
- 译码和取寄存器操作数ID
- 执行或者有效地址计算EX
- 存储器访问MEM
- 写回WB
1.3.4 哪些指令不能重排:Happen-Before规则
- 以下罗列了一些基本原则,这些原则是指令重排不可违背的。
- 程序顺序原则:一个线程内保证语义的串行性
- volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性
- 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前
- 传递性:A先于B,B先于C,那么A必然先于C
- 线程的start()方法先于它的每一个动作
- 线程的所有操作先于线程的终结(Thread.join())
- 线程的中断(interrupt())先于被中断线程的代码
- 对象的构造函数执行、结束先于finalize()方法