第6讲心得
本讲介绍了等待-通知机制:当线程需要的条件不满足时,可以将线程阻塞,当满足条件时再进行通知,从而避免了轮询操作对CPU的消耗。
- Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法可以快速实现等待-通知机制。
- 当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait() 方法就能够满足这种需求。
- 当条件满足时调用 Java 对象的 notify()方法会通知等待队列中的线程,告诉它条件曾经满足过。为什么说是曾经满足过呢?因为 notify() 只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合。
- notify() 会随机地通知等待的某一个线程,而 notifyAll() 会通知等待的所有线程。通常会选用notifyAll()进行通知,因为使用 notify() 有风险,它的风险在于被通知的线程可能并没有执行所需的条件。
第7讲心得
本讲介绍了并发编程遇到的三种大问题:安全性问题、活跃性问题、性能问题。
- 安全本质上就是正确性,即程序按照我们期望的执行。多个线程同时读写同一数据就会出现安全性问题。竞态条件是指在并发场景中,程序的执行依赖于某个状态变量。互斥可以处理竞态条件的问题。
- 活跃性问题指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。活锁指的是多个线程陷入了“检测-冲突-检测-冲突”的循环,它的解决方案是设置随机等待时间。饥饿指的是某些线程一直无法获得执行的机会,它的解决方案是公平锁。
- 使用锁会影响性能。可以使用无锁的算法和数据结构了:线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、原子类。也可以减少锁持有的时间:使用细粒度的锁,如ConcurrentHashMap使用了分段锁;使用读写锁,也就是读是无锁的。
第8讲心得
本讲介绍了管程,它是一把解决并发编程问题的万能钥匙。
- Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。管程指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。在Java 领域,就是管理类的成员变量和成员方法,让这个类是线程安全的。
- 在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen 模型、Hoare 模型和 MESA 模型。其中,现在广泛应用的是 MESA 模型,并且 Java 管程的实现参考的也是 MESA 模型。
- Hasen 模型、Hoare 模型和 MESA 模型的一个核心区别就是当条件满足后,如何通知相关线程。管程要求同一时刻只允许一个线程执行,那当线程 T2 的操作使线程 T1 等待的条件满足时,T1 和 T2 究竟谁可以执行呢?Hasen 模型里面,要求 notify() 放在代码的最后,这样 T2 通知完 T1 后,T2 就结束了,然后 T1 再执行,这样就能保证同一时刻只有一个线程执行。Hoare 模型里面,T2 通知完 T1 后,T2 阻塞,T1 马上执行;等 T1 执行完,再唤醒 T2,也能保证同一时刻只有一个线程执行。但是相比 Hasen 模型,T2 多了一次阻塞唤醒操作。MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
- 什么时候可以使用 notify() 呢?需要满足以下三个条件:所有等待线程拥有相同的等待条件;所有等待线程被唤醒后,执行相同的操作;只需要唤醒一个线程。
第9讲心得
本讲介绍了Java线程的生命周期。
- 通用的线程生命周期有五个状态:初始状态、可运行状态、运行状态、休眠状态和终止状态。初始状态指的是线程已经被创建,但是还不允许分配 CPU 执行。这个状态属于编程语言特有的,这里的被创建仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。可运行状态指的是线程可以分配 CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配 CPU 执行。当有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,被分配到 CPU 的线程的状态就转换成了运行状态。运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。线程执行完或者出现异常就会进入终止状态,进入终止状态也就意味着线程的生命周期结束了。
- Java 语言中线程共有六种状态,分别是:NEW(初始化状态)、RUNNABLE(可运行 / 运行状态)、BLOCKED(阻塞状态)、WAITING(无时限等待)、TIMED_WAITING(有时限等待)、TERMINATED(终止状态)。Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即前面我们提到的休眠状态。也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权。
- 线程等待 synchronized 的隐式锁时会从 RUNNABLE 转换到 BLOCKED 状态,而当等待的线程获得 synchronized 隐式锁时又会从 BLOCKED 转换到 RUNNABLE 状态。而我们平时所谓的 Java 在调用阻塞式 API 时线程会阻塞,指的是操作系统线程的状态,并不是 Java 线程的状态。
- 三种场景会触发RUNNABLE 到 WAITING 的状态转换:获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法;调用无参数的 Thread.join() 方法;调用 LockSupport.park() 方法。
- 五种场景会触发RUNNABLE 到 TIMED_WAITING 的状态转换:调用带超时参数的 Thread.sleep(long millis) 方法;获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;调用带超时参数的 Thread.join(long millis) 方法;调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。
- 调用线程对象的 start() 方法可以从 NEW 状态转换到 RUNNABLE 状态。
- 线程执行完 run() 方法后,会自动转换到 TERMINATED 状态。如果执行 run() 方法的时候异常抛出,也会导致线程终止。有时候我们需要强制中断 run() 方法的执行,Thread 类里面倒是有个 stop() 方法,不过不建议使用。正确的姿势其实是调用 interrupt() 方法。stop() 方法会真的杀死线程,不给线程喘息的机会, interrupt() 方法就温柔多了,interrupt() 方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。
- 被 interrupt 的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。当线程 A 处于 WAITING、TIMED_WAITING 状态时,如果其他线程调用线程 A 的 interrupt() 方法,会使线程 A 返回到 RUNNABLE 状态,同时线程 A 的代码会触发 InterruptedException 异常。上面我们提到转换到 WAITING、TIMED_WAITING 状态的触发条件,都是调用了类似 wait()、join()、sleep() 这样的方法,我们看这些方法的签名都会 throws InterruptedException 这个异常。这个异常的触发条件就是:其他线程调用了该线程的 interrupt() 方法。当线程 A 处于 RUNNABLE 状态时,并且阻塞在 java.nio.channels.InterruptibleChannel 上时,如果其他线程调用线程 A 的 interrupt() 方法,线程 A 会触发 java.nio.channels.ClosedByInterruptException 这个异常;而阻塞在 java.nio.channels.Selector 上时,如果其他线程调用线程 A 的 interrupt() 方法,线程 A 的 java.nio.channels.Selector 会立即返回。上面这两种情况属于被中断的线程通过异常的方式获得了通知。还有一种是主动检测,如果线程处于 RUNNABLE 状态,并且没有阻塞在某个 I/O 操作上时,如果其他线程调用线程 A 的 interrupt() 方法,那么线程 A 可以通过 isInterrupted() 方法,检测是不是自己被中断了。
第10讲心得
本讲介绍了为何要使用多线程及创建多少线程合适。
- 度量性能的指标有很多,但是有两个指标是最核心的,它们就是延迟和吞吐量。延迟指的是发出请求到收到响应这个过程的时间,延迟越短,意味着程序执行得越快,性能也就越好。 吞吐量指的是在单位时间内能处理请求的数量,吞吐量越大,意味着程序能处理的请求越多。这两个指标内部有一定的联系(同等条件下,延迟越短,吞吐量越大),但是由于它们隶属不同的维度(一个是时间维度,一个是空间维度),并不能互相转换。
- 对于 CPU 密集型的计算场景,理论上“线程的数量 =CPU 核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。
- 对于 I/O 密集型计算场景,最佳的线程数是与程序中 CPU 计算和 I/O 操作的耗时比相关的,我们可以总结出这样一个公式:最佳线程数 =1 +(I/O 耗时 / CPU 耗时)。上面这个公式是针对单核 CPU 的,至于多核 CPU,也很简单,只需要等比扩大就可以了,计算公式如下:最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]。