Skip to content

Latest commit

 

History

History
308 lines (235 loc) · 18.6 KB

Java并发编程的艺术笔记.md

File metadata and controls

308 lines (235 loc) · 18.6 KB
title Java 并发编程的艺术笔记
date 2018/05/09
categories
java
tags
java
javacore
concurrent

Java 并发编程的艺术笔记

第 1 章 Java 并发编程的挑战

1.1 上下文切换

  • 即使是单核处理器也支持多线程执行代码,CPU 通过给每个线程分配 CPU 时间片来实现这个机制。时间片是 CPU 分配给各个线程的时间,因为时间片非常短,所以 CPU 通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
  • CPU 通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
  • 上下文切换也会影响多线程的执行速度。
    • 并发执行不一定比串行执行快。因为线程有创建和上下文切换的开销。
  • 减少上下文切换的方法
    • 无锁并发编程 - 多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的 ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据。
    • CAS 算法 - Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。
    • 使用最少线程 - 避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
    • 使用协程 - 在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

1.2 死锁

  • 什么是死锁
    • 多个线程互相等待对方释放锁。
  • 避免死锁的方法
    • 避免一个线程同时获取多个锁。
    • 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
    • 尝试使用定时锁,使用 lock.tryLock(timeout)来替代使用内部锁机制。
    • 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。

1.3 资源限制的挑战

  • 什么是资源限制
    • 资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。
  • 资源限制引发的问题
    • 在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。
  • 如何解决资源限制的问题
    • 对于硬件资源限制,可以考虑使用集群并行执行程序。
    • 对于软件资源限制,可以考虑使用资源池将资源复用。
  • 在资源限制情况下进行并发编程
    • 根据不同的资源限制调整程序的并发度

第 2 章 Java 并发机制的底层实现原理

Java 中所使用的并发机制依赖于 JVM 的实现和 CPU 的指令。

2.1 volatile

  • volatile 是轻量级的 synchronized,它在多处理器开发中保证了共享变量的“可见性”。
  • 可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
  • volatile 比 synchronized 的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。
  • 如果一个字段被声明成 volatile,Java 线程内存模型确保所有线程看到这个变量的值是一致的。
  • volatile 的两条实现原则
    • Lock 前缀指令会引起处理器缓存回写到内存。
    • 一个处理器的缓存回写到内存会导致其他处理器的缓存无效。

2.2 synchronized

  • synchronized 实现同步的基础是:Java 中的每一个对象都可以作为锁。
    • 对于普通同步方法,锁是当前实例对象。
    • 对于静态同步方法,锁是当前类的 Class 对象。
    • 对于同步方法块,锁是 Synchonized 括号里配置的对象。
  • synchronized 用的锁是存在 Java 对象头里的。

2.3 原子操作的实现原理

  • 原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。
  • 在 Java 中可以通过锁和循环 CAS 的方式来实现原子操作。

第 3 章 Java 内存模型

3.1 Java 内存模型的基础

  • 并发编程模型的两个关键问题
    • 线程之间如何通信
    • 线程之间如何同步
  • 线程之间的通信机制
    • 共享内存 - 线程之间共享程序的公共状态,通过读写内存中的公共状态进行隐式通信。
    • 消息传递 - 线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。
  • 线程之间的同步
    • 同步是指程序中用于控制不同线程间操作发生相对顺序的机制。
    • 在共享内存并发模型里,同步是显式进行的。
    • 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
  • Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。
  • Java 内存模型的抽象结构
    • 在 Java 中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。
    • 局部变量(Local Variables),方法定义参数(Java 语言规范称之为 Formal Method Parameters)和异常处理器参数(Exception Handler Parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
    • Java 线程之间的通信由 Java 内存模型控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。
  • 从源代码到指令序列的重排序
    • 重排序类型
      • 编译器优化的重排序
      • 指令级并行的重排序
      • 内存系统的重排
  • Java 源代码到最终实际执行的指令序列的执行流程
  • 源代码 > 编译器优化的重排序 > 指令级并行的重排序 > 内存系统的重排 > 最终执行的指令序列
  • 现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。
  • happens-before 规则
    • 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
    • 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
    • volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的
    • 读。
    • 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
    • 两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行。

3.2 重排序

  • 重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
  • 数据依赖性 - 如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
  • as-if-serial 语义 - 不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime 和处理器都必须遵守 as-if-serial 语义。
    • 为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
  • 程序顺序规则:A > B 且 B > C,所以 A > C。

3.3 顺序一致性

  • 数据竞争:在一个线程中写一个变量,在另一个线程读同一个变量,而且写和读没有通过同步来排序。
  • 如果程序是正确同步的,程序的执行将具有顺序一致性。
  • 顺序一致性内存模型有两大特性
    • 一个线程中的所有操作必须按照程序的顺序来执行。
    • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
  • 同步程序的顺序一致性效果
    • 顺序一致性模型中,所有操作完全按程序的顺序串行执行。
    • 在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。
  • 未同步程序的执行特性
    • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。
    • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。
    • JMM 不保证对 64 位的 long 型和 double 型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。
  • 每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。
  • 总线事务包括读事务(Read Transaction)和写事务(Write Transaction)。

第 4 章 Java 并发编程基础

4.1 线程简介

4.1.1 什么是线程

  • 现代操作系统调度的最小单元是线程,也叫轻量级进程(Light Weight Process),在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量。

4.1.2 为什么要使用多线程

  • 更有效率的利用多处理器核心
  • 更快的响应时间
  • 更好的编程模型

4.1.3 线程优先级

  • 在 Java 线程中,优先级的范围从 1~10,优先级高的线程分配时间片的数量要多于优先级低的线程。线程优先级不能作为程序正确性的依赖,因为操作系统可以完全不用理会 Java 线程对于优先级的设定。

4.1.4 线程的状态

  • 新建(NEW) - 新创建了一个线程对象。
  • 可运行(RUNNABLE) - 线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 cpu 的使用权 。
  • 运行(RUNNING) - 可运行状态(runnable)的线程获得了 cpu 时间片(timeslice) ,执行程序代码。
  • 阻塞(BLOCKED) - 阻塞状态是指线程因为某种原因放弃了 cpu 使用权,也即让出了 cpu timeslice,暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得 cpu 时间片转到运行(running)状态。阻塞的情况分三种:
    • 等待阻塞 - 运行(running)的线程执行 o.wait()方法,JVM 会把该线程放入等待队列(waitting queue)中。
    • 同步阻塞 - 运行(running)的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则 JVM 会把该线程放入锁池(lock pool)中。
    • 其他阻塞 - 运行(running)的线程执行 Thread.sleep(long ms)或 t.join()方法,或者发出了 I/O 请求时,JVM 会把该线程置为阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入可运行(runnable)状态。
  • 死亡(DEAD) - 线程 run()、main() 方法执行结束,或者因异常退出了 run() 方法,则该线程结束生命周期。死亡的线程不可再次复生。

4.1.5 Daemon 线程

  • Daemon 线程不影响 JVM 退出。
  • Daemon 属性需要在启动线程之前设置,不能在启动线程之后设置。

4.2 启动和终止线程

4.2.1 构造线程

4.2.2 启动线程

  • 调用 start()方法就可以启动这个线程。

4.2.3 理解中断

  • 中断可以理解为线程的一个标识位属性,它表示一个运行中的线程是否被其他线程进行了中断操作。
  • 其他线程通过调用该线程的 interrupt()方法对其进行中断操作。
  • 线程通过方法 isInterrupted()来进行判断是否被中断。
  • 调用静态方法 Thread.interrupted()对当前线程的中断标识位进行复位。

4.2.4 过期的 suspend()、resume()和 stop()

  • suspend()、resume()和 stop() 已废弃,废弃原因:不保证释放已经占有的资源,易引发死锁。

4.2.5 安全地终止线程

  • 中断操作是一种简便的线程间交互方式,而这种交互方式最适合用来取消或停止任务。
  • 利用一个 boolean 型 volatile 变量来控制是否需要停止任务并终止该线程。

4.3 线程间通信

4.3.1 volatile 和 synchronized 关键字

  • volatile 可以用来修饰字段(成员变量),就是告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。
  • 过多地使用 volatile 是不必要的,因为它会降低程序执行的效率。
  • synchronized 可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性。
  • 任意一个对象都拥有自己的监视器锁,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取到该对象的监视器才能进入同步块或者同步方法,而没有获取到监视器(执行该方法)的线程将会被阻塞在同步块和同步方法的入口处,进入 BLOCKED 状态。

4.3.2 等待/通知机制

  • 等待/通知的相关方法是任意 Java 对象都具备的,因为这些方法被定义在所有对象的超类 java.lang.Object 上。
  • 使用 wait()、notify()和 notifyAll()时需要先对调用对象加锁。
  • 调用 wait()方法后,线程状态由 RUNNING 变为 WAITING,并将当前线程放置到对象的等待队列。
  • notify()或 notifyAll()方法调用后,等待线程依旧不会从 wait()返回,需要调用 notify()或 notifAll()的线程释放锁之后,等待线程才有机会从 wait()返回。
  • notify()方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而 notifyAll()方法则是将等待队列中所有的线程全部移到同步队列,被移动的线程状态由 WAITING 变为 BLOCKED。
  • 从 wait()方法返回的前提是获得了调用对象的锁。

4.3.3 等待/通知的经典范式

  • 等待/通知的经典范式是生产者、消费者问题。
  • 等待方遵循如下原则
    • 获取对象的锁。
    • 如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件。
    • 条件满足则执行对应的逻辑。
  • 通知方遵循如下原则
    • 获得对象的锁。
    • 改变条件。
    • 通知所有等待在对象上的线程。

4.3.4 管道输入/输出流

  • 管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存。
  • 管道输入/输出流主要包括了如下4种具体实现:PipedOutputStream、PipedInputStream、PipedReader和PipedWriter,前两种面向字节,而后两种面向字符。

4.3.5 Thread.join()的使用

  • 如果一个线程A执行了thread.join()语句,其含义是:当前线程A等待thread线程终止之后才从thread.join()返回。线程Thread除了提供join()方法之外,还提供了join(long millis)和join(long millis,int nanos)两个具备超时特性的方法。这两个超时方法表示,如果线程thread在给定的超时时间里没有终止,那么将会从该超时方法中返回。

4.3.6 ThreadLocal 的使用

  • ThreadLocal,即线程变量,是一个以ThreadLocal对象为键、任意对象为值的存储结构。这个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。

4.4 线程应用实例

第 5 章 Java 中的锁

5.1 Lock 接口

  • 锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁)。

  • Lock vs synchronize

    • 可以控制获取和释放锁
    • 响应中断
    • 设置超时时间

5.2 队列同步器

5.2.1 队列同步器的接口与示例

5.2.2 队列同步器的实现分析

5.3 重入锁

5.4 读写锁

5.4.1 读写锁的接口与示例

5.4.2 读写锁的实现分析

5.5 LockSupport 工具

5.6 Condition 接口

5.6.1 Condition 接口与示例

5.6.2 Condition 的实现分析

第 6 章 Java 并发容器

第 7 章 Java 原子操作类

第 8 章 Java 并发工具类

第 9 章 Java 线程池

第 10 章 Executor 框架

第 11 章 Java 并发编程实战