《Java并发编程实战》6到10讲学习总结

第6讲心得

本讲介绍了等待-通知机制:当线程需要的条件不满足时,可以将线程阻塞,当满足条件时再进行通知,从而避免了轮询操作对CPU的消耗。

  1. Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法可以快速实现等待-通知机制。
  2. 当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait() 方法就能够满足这种需求。
  3. 当条件满足时调用 Java 对象的 notify()方法会通知等待队列中的线程,告诉它条件曾经满足过。为什么说是曾经满足过呢?因为 notify() 只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合。
  4. notify() 会随机地通知等待的某一个线程,而 notifyAll() 会通知等待的所有线程。通常会选用notifyAll()进行通知,因为使用 notify() 有风险,它的风险在于被通知的线程可能并没有执行所需的条件。

第7讲心得

本讲介绍了并发编程遇到的三种大问题:安全性问题、活跃性问题、性能问题。

  1. 安全本质上就是正确性,即程序按照我们期望的执行。多个线程同时读写同一数据就会出现安全性问题。竞态条件是指在并发场景中,程序的执行依赖于某个状态变量。互斥可以处理竞态条件的问题。
  2. 活跃性问题指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。活锁指的是多个线程陷入了“检测-冲突-检测-冲突”的循环,它的解决方案是设置随机等待时间。饥饿指的是某些线程一直无法获得执行的机会,它的解决方案是公平锁。
  3. 使用锁会影响性能。可以使用无锁的算法和数据结构了:线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、原子类。也可以减少锁持有的时间:使用细粒度的锁,如ConcurrentHashMap使用了分段锁;使用读写锁,也就是读是无锁的。

第8讲心得

本讲介绍了管程,它是一把解决并发编程问题的万能钥匙。

  1. Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。管程指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。在Java 领域,就是管理类的成员变量和成员方法,让这个类是线程安全的。
  2. 在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen 模型、Hoare 模型和 MESA 模型。其中,现在广泛应用的是 MESA 模型,并且 Java 管程的实现参考的也是 MESA 模型。
  3. 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 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
  4. 什么时候可以使用 notify() 呢?需要满足以下三个条件:所有等待线程拥有相同的等待条件;所有等待线程被唤醒后,执行相同的操作;只需要唤醒一个线程。

第9讲心得

本讲介绍了Java线程的生命周期。

  1. 通用的线程生命周期有五个状态:初始状态、可运行状态、运行状态、休眠状态和终止状态。初始状态指的是线程已经被创建,但是还不允许分配 CPU 执行。这个状态属于编程语言特有的,这里的被创建仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。可运行状态指的是线程可以分配 CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配 CPU 执行。当有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,被分配到 CPU 的线程的状态就转换成了运行状态。运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。线程执行完或者出现异常就会进入终止状态,进入终止状态也就意味着线程的生命周期结束了。
  2. Java 语言中线程共有六种状态,分别是:NEW(初始化状态)、RUNNABLE(可运行 / 运行状态)、BLOCKED(阻塞状态)、WAITING(无时限等待)、TIMED_WAITING(有时限等待)、TERMINATED(终止状态)。Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即前面我们提到的休眠状态。也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权。
  3. 线程等待 synchronized 的隐式锁时会从 RUNNABLE 转换到 BLOCKED 状态,而当等待的线程获得 synchronized 隐式锁时又会从 BLOCKED 转换到 RUNNABLE 状态。而我们平时所谓的 Java 在调用阻塞式 API 时线程会阻塞,指的是操作系统线程的状态,并不是 Java 线程的状态。
  4. 三种场景会触发RUNNABLE 到 WAITING 的状态转换:获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法;调用无参数的 Thread.join() 方法;调用 LockSupport.park() 方法。
  5. 五种场景会触发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) 方法。
  6. 调用线程对象的 start() 方法可以从 NEW 状态转换到 RUNNABLE 状态。
  7. 线程执行完 run() 方法后,会自动转换到 TERMINATED 状态。如果执行 run() 方法的时候异常抛出,也会导致线程终止。有时候我们需要强制中断 run() 方法的执行,Thread 类里面倒是有个 stop() 方法,不过不建议使用。正确的姿势其实是调用 interrupt() 方法。stop() 方法会真的杀死线程,不给线程喘息的机会, interrupt() 方法就温柔多了,interrupt() 方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。
  8. 被 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讲心得

本讲介绍了为何要使用多线程及创建多少线程合适。

  1. 度量性能的指标有很多,但是有两个指标是最核心的,它们就是延迟和吞吐量。延迟指的是发出请求到收到响应这个过程的时间,延迟越短,意味着程序执行得越快,性能也就越好。 吞吐量指的是在单位时间内能处理请求的数量,吞吐量越大,意味着程序能处理的请求越多。这两个指标内部有一定的联系(同等条件下,延迟越短,吞吐量越大),但是由于它们隶属不同的维度(一个是时间维度,一个是空间维度),并不能互相转换。
  2. 对于 CPU 密集型的计算场景,理论上“线程的数量 =CPU 核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。
  3. 对于 I/O 密集型计算场景,最佳的线程数是与程序中 CPU 计算和 I/O 操作的耗时比相关的,我们可以总结出这样一个公式:最佳线程数 =1 +(I/O 耗时 / CPU 耗时)。上面这个公式是针对单核 CPU 的,至于多核 CPU,也很简单,只需要等比扩大就可以了,计算公式如下:最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值