- 基础平台Java虚拟机,虚拟机除了要执行main函数外,还需要做JIT编译和垃圾回收。无论是main函数、JIT编译还是垃圾回收,在虚拟机内部都是一个单独的线程。
- 多核CPU:将多个独立的计算单元整合到单独的CPU中
- 如何让多个CPU内核有效并正确地工作也就成了一门技术。如多线程间如何保证线程安全,如何正确理解线程间的无序性、可见性。如何尽可能地设计并行程序,如何将串行程序改造为并行程序等等。
必须知道的几个概念
服务端编程还是需要大量并行计算的,而Java也主要占领着服务端市场,那么对Java并行计算的研究就显得非常重要。
同步(Synchronous)和异步(Asynchronous)
同步和异步通常用来形容一次方法调用。
同步方法调用一旦开始,调用者必须等到方法调用返回后,才能继续后续的行为。
异步方法调用更像一个消息传递,一旦开始,方法调用就会立即返回,调用者就可以继续后续的操作。而异步方法通常会在另外一个线程中真实地执行。整个过程不会阻碍调用者的工作。对于调用者来说,异步方法调用似乎是一瞬间就完成的。如果异步方法调用需要返回结果,那么当这个异步调用真实完成时,则会通知调用者。
并发(Concurrency)与并行(Parallelism)
并发和并行是两个非常容易混淆的概念。它们都可以表示两个或者多个任务一起执行,但是侧重点有所不同。
单核CPU下,线程实际还是串行执行的。操作系统中有一个组件叫做任务调度器,将CPU的时间片分给不同的程序使用,只是由于CPU在线程间切换非常快,人类感觉是同时运行的。
一般会将这种线程轮流使用CPU的做法成为并发,并发偏重于多个任务交替执行,而多个任务之间有可能是串行的。
在多核CPU下,每个核都可以调度运行线程,这时候线程是可以并行的。
- 并发(concurrent)是同一时间应对多件事情的能力
- 并发是指在一个时间段内,多个任务交替执行的能力。这些任务可以是同时启动的,但它们不一定同时执行。在并发中,任务之间可能会交错执行,每个任务都分配一些时间片段来执行。
- 并行(parallel)是同一时间做多件事情的能力(真正意义上的"同时执行")
- 并行是指多个任务在同一时刻实际上同时执行的能力,通常需要多个处理单元(例如多核处理器)或多台计算机来实现。
实际上,如果系统内只有一个CPU,而使用多进程或者多线程任务,那么真实环境中这些任务不可能是真实并行的,毕竟一个CPU一次只能执行一条指令,在这种情况下多进程或者多线程就是并发的,而不是并行的(操作系统会不停地切换多个任务)。真实的并行也只可能出现在拥有多个CPU的系统中(比如多核CPU)。
临界区
临界区用来表示一种公共资源或者说共享数据,可以被多个线程使用。但是每一次只能有一个线程使用它,一旦临界区资源被占用,其他线程想要使用这个资源就必须等待。
举例:办公室中的打印机。
在并行(或并发)程序中,临界区资源是保护的对象。
阻塞(Blocking)和非阻塞(Non-Blocking)
阻塞和非阻塞通常用来形容多线程间的相互影响。比如一个线程占用了临界区资源,那么其它所有需要这个资源的线程就必须在这个临界区中等待。等待会导致线程挂起,这种情况就是阻塞。
非阻塞的意思与之相反,它强调没有一个线程可以妨碍其他线程执行,所有的线程都会尝试不断向前执行。
死锁(Deadlock)、饥饿(Starvation)、活锁(Livelock)
死锁指当每个进程都在等待某个资源被释放,而无法继续执行时,就会导致死锁。
死锁的四个必要条件:
1. 互斥条件:一个资源每次只能被一个进程使用;
2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放;
3. 不剥夺条件: 进程已获得的资源,在未使用完之前,不能强行剥夺;
4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系;
饥饿是指某一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行。比如,它的线程优先级可能太低,而高优先级的线程不断抢占它需要的资源,导致低优先级线程无法工作。
与死锁相比,饥饿还是有可能在未来一段时间内解决的。(比如高优先级的线程已经完成任务,不再疯狂执行)。
活锁指的是任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败,从而陷入一种似乎被锁住的状态。
举例:两个人面对面走来,你向右走,对方也向右走。
并发级别(了解)
由于临界区的存在,多线程之间的并发必须受到控制。根据控制并发的策略,我们可以把并发的级别分为阻塞、无饥饿、无障碍、无锁、无等待等。
有关并行的两个重要定律(了解)
为什么要使用并行程序:
- 为了获得更好的性能
- 由于业务模型的需要,确实需要多个执行的实体
Amdahl定律
根据Amdahl定律,使用多核CPU对系统进行优化的效果取决于CPU的数量,以及系统中的串行代码比例。CPU数量越多,串行化比例越低,则效果越好。仅增加CPU数量而不降低程序的串行化比例,无法提高系统性能。
Gustafson定律
从Gustafson定律中,我们可以更容易地发现,如果串行化比例很小,并行化比例很大,那么加速比就是处理器的个数。只要增加处理器,就能获得更快的速度。
Java内存模型(JMM)
JMM的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的。
原子性(Atomicity)
原子性是指一个操作是不可中断的。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
比如,对于一个静态全局变量int i,两个线程同时对它赋值,线程A给它赋值为1,线程B给它赋值为-1。那么不管这两个线程以何种方式、何种步调工作,i的值要么是1,要么是-1。线程A和线程B之间是没有干扰的。这就是原子性的一个特点——不可被中断。
但如果我们不使用int型数据而使用long型数据,可能就没有那么幸运了。对于32位系统来说,long型数据的读写不是原子性的(因为long型数据有64位)。也就是说,如果两个线程同时对long型数据进行写入(或者读取),则线程之间是有干扰的。
可见性(Visibility)
可见性是指当一个线程修改了某个共享变量的值时,其他线程是否能够立即知道这个修改。
Java提供了volatile关键字来保证可见性。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
有序性(Ordering)
有序性问题的原因是程序在执行时,可能会进行指令重排。
在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然JMM是通过Happens-Before 规则来保证有序性的。
不过这里还需要强调一点,对于一个线程来说,它看到的指令执行顺序一定是一致的(否则应用根本无法正常工作)。也就是说指令重排是有一个基本前提的,就是保证串行语义的一致性。指令重排不会使串行语义逻辑发生问题。因此,在串行代码中,大可不必担心。
happens-before原则
前文已经介绍了指令重排,虽然Java虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的,并非所有的指令都可以随便改变执行位置。以下罗列了一些基本原则,这些原则是指令重排不可违背的。
· 程序顺序原则:在一个线程内保证语义的串行性。
· volatile规则:volatile变量的写先于读发生,这保证了volatile变量的可见性。
· 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前。
· 传递性:A先于B, B先于C,那么A必然先于C。
· 线程的start()方法先于它的每一个动作。
· 线程的所有操作先于线程的终结(Thread.join())。
· 线程的中断(interrupt())先于被中断线程的代码。
· 对象的构造函数的执行、结束先于finalize()方法。