第一章 并发编程的挑战
上下文切换
-
上下文切换概述
- 切出:一个线程被剥夺处理器的使用权而暂定运行
- 切入:一个线程被选中占用处理器或者继续运行
- 上下文:在这种切入切出的过程中,操作系统需要保存和恢复相应的进度信息,这个进度信息就是上下文
-
上下文切换实现
- 在 Linux 系统中(Windows是抢占式的,即设置优先级),CPU 通过给每个线程分配 CPU 时间片来实现这个机制。时间片是 CPU 分配给各个线程的时间,因为时间片非常短,所以 CPU 通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。CPU 通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态
- 因为 CPU 的实现方式,单核处理器也支持多线程执行代码
- 线程有创建和上下文切换的开销,在执行任务较少时,单线程会比多线程快
- 在 Linux 系统中(Windows是抢占式的,即设置优先级),CPU 通过给每个线程分配 CPU 时间片来实现这个机制。时间片是 CPU 分配给各个线程的时间,因为时间片非常短,所以 CPU 通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。CPU 通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态
-
如何避免上下文切换:
- 无锁并发编程:多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用将数据的 ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据
- CAS算法:Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁(CAS在Linux中对应的是cmpxchg指令)
- 使用最少线程:避免创建不需要的线程
- 协程:在单线程里实现多任务的调度,并在单线程里## 标题维持多个任务间的切换(由于 Go 语言从语言层面原生支持协程,所以 GO 在处理多线程时很具有优势)
死锁
-
什么是死锁
- 死锁是指两个或两个以上的进程(线程)在运行过程中因争夺资源而造成的一种僵局(Deadly-Embrace) ) ,若无外力作用,这些进程(线程)都将无法向前推进
-
死锁产生的四个必要条件(只要有一个条件不满足就不会产生死锁)
- 互斥条件。一个资源每次只能被一个进程使用
- 不可剥夺条件。进程已获得的资源,在末使用完之前,不能强行剥夺
- 请求和保持条件。申请了某个资源后,继续再申请别的资源,那之前的资源除非亲自释放掉,否则不可被别的线程使用
- 循环等待条件。若干进程之间形成一种头尾相接的循环等待资源关系
-
死锁产生的原因
- 系统资源不足
- 进程运行推进的顺序不合适
- 资源分配不当等
-
避免死锁的办法
- 避免一个线程同时获取多个锁
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
- 尝试使用定时锁,使用 lock.tryLock(timeout) 来替代使用内部锁机制
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况
-
饥饿与死锁
- 饥饿可以认为是一个或一个以上线程或是进程在无限的等待另外两个或多个线程或进程占有的但是不会往外释放的资源
- 饥饿还可以认为是多线程执行中有线程优先级这个东西,优先级高的线程能够插队并优先执行,这样如果优先级高的线程一直抢占优先级低线程的资源,导致低优先级线程无法得到执行,这就是饥饿
- 看一些源码时候经常看到在循环中有 Thread.Sleep(0) 的写法, 这么做的作用就是触发操作系统立刻重新进行一次 CPU 竞争
- Windows 是一个抢占式的多任务操作系统
- 它会在前后台切换的时候调整优先级
- 它会为 I/O 操作动态提升优先级
- 它会使用 “饥饿” 的时间片分配策略来动态调整。即如果有线程一直渴望得到时间片但是很长时间都没有获得时间片,Windows 就会临时将这个线程的优先级提高,并一次分配给 2 倍的时间片来执行,当用完 2 倍的时间片后,优先级又会恢复到之前的水平
资源限制的挑战
-
什么是资源挑战
- 资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源
-
资源限制引发的问题
- 在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间
-
资源限制的解决
- 对于硬件的限制,可以增加机器的方式
- 对于软件资源限制,可以考虑使用资源池将资源复用。例如:Java 在调用数据库时,禁止在 for 循环里写 sql 就是为了减少数据库连接的资源消耗
第二章 Java并发机制的底层实现原理
Java 代码在编译后会变成 Java 字节码,字节码被类加载器加载到 JVM 里,JVM 执行字节码,最终需要转化为汇编指令在 CPU 上执行,Java 中所使用的并发机制依赖于 JVM 的实现和 CPU 的指令本
volatile 的应用
-
volatile 定义
- Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量
-
volatile 实现原理
- 为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存
- 如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,即每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里
synchronized 的实现原理与应用
-
Java 中的每一个对象都可以作为锁,具体表现为以下 3 种形式
- 对于普通同步方法,锁是当前实例对象
- 对于静态同步方法,锁是当前类的 Class 对象
- 对于同步方法块,锁是 Synchonized 括号里配置的对象
-
synchronized 锁的实现
- 代码块同步:每个对象有一个监视器锁(monitor)。当 monitor 被占用时就会处于锁定状态,线程执行 monitorenter 指令时尝试获取 monitor 的所有权,过程如下
- 如果 monitor 的进入数为 0,则该线程进入 monitor,然后将进入数设置为 1,该线程即为 monitor 的所有者
- 如果线程已经占有该 monitor,只是重新进入,则进入 monitor 的进入数加 1
- 如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为0,再重新尝试获取 monitor 的所有权
- 其实 wait/notify 等方法也依赖于 monitor 对象,这就是只有在同步的块或者方法中才能调用 wait/notify 等方法的原因
- 普通方法:对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM 就是根据该标示符来实现方法的同步的。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成
- 无论 synchronized 关键字加在方法上还是对象上,如果它作用的对象是非静态的,则它取得的锁是对象;如果 synchronized 作用的对象是一个静态方法或一个类,则它取得的锁是对类,该类所有的对象同一把锁
- 代码块同步:每个对象有一个监视器锁(monitor)。当 monitor 被占用时就会处于锁定状态,线程执行 monitorenter 指令时尝试获取 monitor 的所有权,过程如下
-
对象头
-
在 Hotspot 虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充
-
Java 对象头是实现 synchronized 的锁对象的基础,一般而言,synchronized 使用的锁对象是存储在 Java 对象头里。如果对象是数组类型,则虚拟机用 3 个字宽(Word)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在 32 位虚拟机中,1 字宽等于 4 字节,即 32bit
-
Java 对象头里的 Mark Word 里默认存储对象的 HashCode、分代年龄和锁标记位。32位 JVM 的 Mark Word 的默认存储结构如下:
锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志位 无锁状态 对象的hashcode 对象分代年龄 0 01 -
在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据
锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志位 轻量级锁 指向栈中锁记录的指针(占用30bit) – – 00 重量级锁 指向互斥量(重量级锁)的指针(占用30bit) – – 10 GC标志 空(占用30bit) – – 00 偏向锁 线程ID(23bit)+Epoch+对象分代年龄 1 01 -
在64位虚拟机下,Mark Word 是 64bit 大小的,其存储结构如下
锁状态 25bit 31bit 1bit(cms_free) 4bit(分代年龄) 1bit是否是偏向锁 2bit锁标志位 无锁 unused hashcode – – 0 00 偏向锁 线程ID(54bit)+Epoch(2bit) – – – 1 01
-
-
锁的升级与对比
- 锁的状态
- 在 Java SE 1.6 中,锁一共有 4 种状态,级别从低到高
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
- 这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率
- 在 Java SE 1.6 中,锁一共有 4 种状态,级别从低到高
- 偏向锁
- 偏向锁的概念
- 当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需测试对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下 Mark Word 中偏向锁的标识是否设置成 1(表示当前是偏向锁):如果没有设置,则使用 CAS 竞争锁;如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程
- 偏向锁的撤销
- 偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的 MarkWord 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程
- 关闭偏向锁
- 偏向锁在 Java 6 以后是默认启用的,但是它在应用程序启动几秒钟之后才激活
// 关闭偏向锁延迟激活 -XX:BiasedLockingStartupDelay=0 // 关闭偏向锁,系统默认进入轻量级锁(在存在大量锁对象的创建并高度并发的环境下禁用偏向锁能够带来一定的性能优化) -XX:-UseBiasedLocking=false
- 偏向锁在 Java 6 以后是默认启用的,但是它在应用程序启动几秒钟之后才激活
- 偏向锁的概念
- 轻量级锁
- 轻量级锁加锁
- 线程在执行同步块之前,JVM 会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁
- 轻量级锁解锁
- 轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁
- 因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争
- 轻量级锁加锁
- 锁的优缺点对比
锁 优点 缺点 适用场景 偏向锁 加锁和解锁不需要额外消耗,和执行非同步方法相比仅存在纳秒级别的差距 如果线程间存在竞争,会带来额外所撤销的消耗 适用只有线程访问同步代码块的场景 轻量级锁 竞争的线程不会阻塞,提高了线程的响应速度 始终得不到锁竞争的线程会使用自旋从而消耗 CPU 追求响应时间,同步代码块执行速度非常快 重量级锁 线程竞争不使用自旋,不会消耗 CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步代码块执行速度较慢 - 附一张偷来的总图
- 锁的状态
第三章 Java内存模型
Java 内存模型的基础
-
并发编程模型的两个关键问题
- 线程之间如何通信
- 在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递
- 在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信
- 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信
- 线程之间如何同步
- 同步是指程序中用于控制不同线程间操作发生相对顺序的机制
- 在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行
- 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的
- Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明
- 线程之间如何通信
-
Java 内存模型的抽象结构
-
在 Java 中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响
-
Java 线程之间的通信由 Java 内存模型控制(本文简称为JMM),JMM 决定一个线程对共享变量的写入何时对另一个线程可见
- JMM 规定线程之间的所有共享变量都存储在主内存中
- JMM 规定每个线程都有一个私有的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的值的传递均需要通过主内存来完成
- 工作内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化
-
A 写数据进入主内存,B 从主内存读取数据。从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 Java 程序员提供内存可见性保证
-
-
工作内存与主内存交互
- java 内存中线程的工作内存和主内存的交互是由 java 虚拟机定义了的 8 种操作来完成的
- 每种操作必须是原子性的(double和long类型在某些平台有例外,参考volatile详解和非原子性协定),java 虚拟机中主内存和工作内存交互,就是一个变量如何从主内存传输到工作内存中,如何把修改后的变量从工作内存同步回主内存
操作 含义 lock(锁定) 作用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操作表示这条线成独占这个变量 unlock(解锁) 作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定 read(读取) 作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的 load 操作使用 load(载入) 作用于线程的工作内存的变量,表示把 read 操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的) use(使用) 作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作 assign(赋值) 作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作 store(存储) 作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的 write 操作使用 write(写入) 作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中 - 在执行这 8 中操作的时候必须遵循如下的规则:
- 不允许 read 和 load、store 和 write 操作之一单独出现,也就是不允许从主内存读取了变量的值但是工作内存不接收的情况,或者不允许从工作内存将变量的值回写到主内存但是主内存不接收的情况
- 不允许一个线程丢弃最近的 assign 操作,也就是不允许线程在自己的工作线程中修改了变量的值却不同步/回写到主内存
- 不允许一个线程回写没有修改的变量到主内存,也就是如果线程工作内存中变量没有发生过任何 assign 操作,是不允许将该变量的值回写到主内存
- 变量只能在主内存中产生,不允许在工作内存中直接使用一个未被初始化的变量,也就是没有执行 load 或者 assign 操作。也就是说在执行 use、store 之前必须对相同的变量执行了 load、assign 操作
- 一个变量在同一时刻只能被一个线程对其进行 lock 操作,也就是说一个线程一旦对一个变量加锁后,在该线程没有释放掉锁之前,其他线程是不能对其加锁的,但是同一个线程对一个变量加锁后,可以继续加锁,同时在释放锁的时候释放锁次数必须和加锁次数相同
- 对变量执行 lock 操作,就会清空工作空间该变量的值,执行引擎使用这个变量之前,需要重新 load 或者 assign 操作初始化变量的值
- 不允许对没有 lock 的变量执行 unlock 操作,如果一个变量没有被 lock 操作,那也不能对其执行 unlock 操作,当然一个线程也不能对被其他线程 lock 的变量执行 unlock 操作
- 对一个变量执行 unlock 之前,必须先把变量同步回主内存中,也就是执行 store 和 write 操作
- 在执行这 8 中操作的时候必须遵循如下的规则:
可见性与重排序
-
可见性
- 可见性的定义常见于各种并发场景中,以多线程为例:当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改
- 从性能角度考虑,没有必要在修改后就立即同步修改的值——如果多次修改后才使用,那么只需要最后一次同步即可,在这之前的同步都是性能浪费。因此,实际的可见性定义要弱一些,只需要保证:当一个线程修改了线程共享变量的值,其它线程在使用前,能够得到最新的修改值
- 可见性可以认为是最弱的“一致性”(弱一致),只保证用户见到的数据是一致的,但不保证任意时刻,存储的数据都是一致的(强一致)
-
重排序
- 重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段
-
重排序的场景
- 编译器优化的重排序(JVM)
- 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
- 处理器执行的重排序 (CPU)
- 现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
- 内存系统的重排序/缓存同步顺序(JVM/CPU)
- 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行,其本质为可见性问题
- 编译器优化的重排序(JVM)
-
解决缓存同步顺序的方式
- 缓存一致性 - MESI 协议
- 处理器上有一套完整的协议,来保证缓存的一致性,比较经典的应该就是 MESI 协议了,其实现方法是在 CPU 缓存中保存一个标记位,以此来标记四种状态。另外,每个 Core 的 Cache 控制器不仅知道自己的读写操作,也监听其它 Cache 的读写操作,就是嗅探(snooping)协议
- M:被修改的。处于这一状态的数据,只在本 CPU 核中有缓存数据,而其他核中没有。同时其状态相对于内存中的值来说,是已经被修改的,只是没有更新到内存中
- 一个处于 M 状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回 CPU
- E:独占的。处于这一状态的数据,只有在本 CPU 中有缓存,且其数据没有修改,即与内存中一致
- 一个处于 E 状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为 S
- S:共享的。处于这一状态的数据在多个 CPU 中都有缓存,且与内存一致
- 一个处于 S 状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为 I
- I:无效的。本 CPU 中的这份缓存已经无效
- M:被修改的。处于这一状态的数据,只在本 CPU 核中有缓存数据,而其他核中没有。同时其状态相对于内存中的值来说,是已经被修改的,只是没有更新到内存中
- 当 CPU 需要读取数据时,如果其缓存行的状态是 I 的,则需要从内存中读取,并把自己状态变成 S,如果不是 I,则可以直接读取缓存中的值,但在此之前,必须要等待其他 CPU 的监听结果,如其他 CPU 也有该数据的缓存且状态是 M,则需要等待其把缓存更新到内存之后,再读取
- 当 CPU 需要写数据时,只有在其缓存行是 M 或者 E 的时候才能执行,否则需要发出特殊的 RFO 指令(Read Or Ownership,这是一种总线事务),通知其他 CPU 置缓存无效 (I),这种情况下性能开销是相对较大的。在写入完成后,修改其缓存状态为 M
- 处理器上有一套完整的协议,来保证缓存的一致性,比较经典的应该就是 MESI 协议了,其实现方法是在 CPU 缓存中保存一个标记位,以此来标记四种状态。另外,每个 Core 的 Cache 控制器不仅知道自己的读写操作,也监听其它 Cache 的读写操作,就是嗅探(snooping)协议
- 缓存一致性 - MESI 协议
-
解决 CPU 重排序的方式
- 通过内存屏障可以解决硬件层面的可见性与重排序问题
- 介绍
- 内存屏障是硬件之上、操作系统或 JVM 之下,对并发作出的最后一层支持。再向下是是硬件提供的支持;向上是操作系统或 JVM 对内存屏障作出的各种封装。内存屏障是一种标准,各厂商可能采用不同的实现
- 两个指令
- Store:将处理器缓存的数据刷新到内存中
- Load:将内存存储的数据拷贝到处理器的缓存中
- 内存屏障
屏障类型 指令示例 说明 LoadLoad Barriers Load1;LoadLoad;Load2 该屏障确保 Load1 数据的装载先于 Load2 及其后所有装载指令的的操作 StoreStore Barriers Store1;StoreStore;Store2 该屏障确保 Store1 立刻刷新数据到内存(使其对其他处理器可见)的操作先于 Store2 及其后所有存储指令的操作 LoadStore Barriers Load1;LoadStore;Store2 确保 Load1 的数据装载先于 Store2 及其后所有的存储指令刷新数据到内存的操作 StoreLoad Barriers Store1;StoreLoad;Load2 该屏障确保 Store1 立刻刷新数据到内存的操作先于 Load2 及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令 - StoreLoad Barriers 同时具备其他三个屏障的效果,因此也称之为全能屏障(mfence),是目前大多数处理器所支持的;但是相对其他屏障,该屏障的开销相对昂贵
- 介绍
- 通过内存屏障可以解决硬件层面的可见性与重排序问题
-
解决 JVM 重排序的方式
- JVM 解决可见性和重排序的方式
- volatile
- final
- cas
- 锁
- volatile
- volatile 简介
- 通过 volatile 标记,可以解决编译器层面的可见性与重排序问题
- volatile 的特性
- 可见性。对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入
- 原子性。对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性
- volatile 的内存语义
- volatile的写-读与锁的释放-获取有相同的内存效果:volatile 写和锁的释放有相同的内存语义;volatile 读与锁的获取有相同的内存语义
- volatile 写的内存语义:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存
- volatile 读的内存语义:当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量
- volatile 内存语义的体现
第一个操作 第二个操作 第二个操作 第二个操作 是否能重排序 普通读写 volatile读 volatile写 普通读写 NO volatile读 NO NO NO volatile写 NO NO - 从表中可以看出:
- 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保从 volatile 写之前的操作不会被编译器重排序到 volatile 写之后
- 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前
- 当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序
- 从表中可以看出:
- volatile 内存语义的实现
- 在编译器层面,仅将 volatile 作为标记使用,取消编译层面的缓存和重排序
- 如果硬件架构本身已经保证了内存可见性(如单核处理器、一致性足够的内存模型等),那么 volatile 就是一个空标记,不会插入相关语义的内存屏障
- 如果硬件架构本身不进行处理器重排序、有更强的重排序语义(能够分析多核间的数据依赖)、或在单核处理器上重排序,那么 volatile 就是一个空标记,不会插入相关语义的内存屏障
- 如果不保证,仍以 x86 架构为例,JVM 对 volatile 变量的处理如下
- 在写 volatile 变量 v 之后,插入一个 sfence(StoreStore Barriers)。这样,sfence 之前的所有 store(包括写v)不会被重排序到 sfence 之后,sfence 之后的所有 store 不会被重排序到 sfence 之前,禁用跨 sfence 的 store 重排序;且 sfence 之前修改的值都会被写回缓存,并标记其他 CPU 中的缓存失效
- 在读 volatile 变量 v 之前,插入一个 lfence(LoadLoad Barriers)。这样,lfence 之后的 load(包括读v)不会被重排序到 lfence 之前,lfence 之前的 load 不会被重排序到 lfence 之后,禁用跨 lfence 的 load 重排序;且 lfence 之后,会首先刷新无效缓存,从而得到最新的修改值,与 sfence 配合保证内存可见性
- 在另外一些平台上,JVM 使用 mfence 代替 sfence 与 lfence,实现更强的语义
- volatile 存在的意义
- 由于 JMM 属于语言级的内存模型,为了确保在不同的编译器和不同的处理器平台之上都能为程序员提供一致的内存可见性保证,它必须有自己一套的方式(volatile)禁止特定类型的编译器重排序和处理器重排序
- volatile 简介
- final 域的内存语义
- final 域的处理器语义
- 写 final 域的重排序规则会要求编译器在 final 域的写之后,构造函数 return 之前插入一个 sfence 障屏
- 读 final 域的重排序规则要求编译器在读 final 域的操作前面插入一个 lfence 屏障
- 对于 final 域,编译器和处理器要遵守两个重排序规则
- 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
- 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序
- 类的 final 字段在 <clinit>() 方法中初始化,其可见性由 JVM 的类加载过程保证,对于被正确构造的对象,所有线程都能看到构造函数给对象的各个 final 字段设置的正确值,而不管采用何种方式来发布对象
- final 域的处理器语义
- CAS
- 简介
- 在 x86 架构上,CAS 被翻译为"lock cmpxchg…"。cmpxchg 是 CAS 的汇编指令。在 CPU 架构中依靠 lock 信号保证可见性并禁止重排序
- lock 前缀是一个特殊的信号,执行过程
- 对总线和缓存上锁
- 强制所有 lock 信号之前的指令,都在此之前被执行,并同步相关缓存
- 执行 lock 后的指令(如cmpxchg)
- 释放对总线和缓存上的锁
- 强制所有 lock 信号之后的指令,都在此之后被执行,并同步相关缓存
- 总线锁
- 当一个 CPU 对其缓存中的数据进行操作的时候,往总线中发送一个 Lock 信号。其他处理器的请求将会被阻塞,那么该处理器可以独占共享内存。总线锁相当于把 CPU 和内存之间的通信锁住了,所以这种方式会导致 CPU 的性能下降
- 缓存锁
- P6 系列以后的处理器,LOCK 信号一般不锁总线,而是锁缓存。即如果缓存在处理器缓存行中的内存区域在 LOCK 操作期间被锁定,当它执行锁操作回写内存时,处理不在总线上声明 LOCK 信号,而是修改内部的缓存地址,然后通过缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域的数据,当其他处理器回写已经被锁定的缓存行的数据时会导致该缓存行无效
- 与内存屏障相比,lock 信号要额外对总线和缓存上锁,成本更高
- JVM使用 CAS 实现原子操作的三大问题
- ABA 问题
- 如果一个值的变化是 A -> B -> A,那么使用 CAS 进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA 问题的解决思路就是使用版本号。例如 1A -> 2B -> 3A。从 Java 1.5 开始,JDK的 Atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题
- 循环时间长开销大
- 自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销
- 只能保证一个共享变量的原子操作
- 当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量 i=2,j=a,合并一下 ij=2a,然后用 CAS 来操作 ij。从Java 1.5 开始,JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行 CAS 操作
- ABA 问题
- 简介
- 锁
- JVM 的内置锁通过操作系统的管程实现。且不论管程的实现原理,由于管程是一种互斥资源,修改互斥资源至少需要一个 CAS 操作。因此,锁必然也使用了 lock 信号,具有 mfence 的语义
- 锁的 mfence 语义实现了 Happens-Before 关系中的监视器锁规则
- 锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM 内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM 实现锁的方式都用了循环 CAS,即当一个线程想进入同步块的时候使用循环 CAS 的方式来获取锁,当它退出同步块的时候使用循环 CAS 释放锁
- 锁的内存语义:
- 当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中
- 当线程获取锁时,JMM 会把该线程对应的本地内存置为无效
- 锁内存语义的实现
- 公平锁和非公平锁释放时,最后都要写一个 volatile 变量 state
- 公平锁获取时,首先会去读 volatile 变量
- 非公平锁获取时,首先会用 CAS 更新 volatile 变量,这个操作同时具有 volatile 读和 volatile 写的内存语义
- 锁释放-获取的内存语义的实现至少有下面两种方式
- 利用 volatile 变量的写-读所具有的内存语义
- 利用 CAS 所附带的 volatile 读和 volatile 写的内存语义
- 由于 Java 的 CAS 同时具有 volatile 读和 volatile 写的内存语义,因此 Java 线程之间的通信现在有了下面 4种方式
- A 线程写 volatile 变量,随后 B 线程读这个 volatile 变量
- A 线程写 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量
- A 线程用 CAS 更新一个 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量
- A 线程用 CAS 更新一个 volatile 变量,随后 B 线程读这个 volatile 变量
- JVM 解决可见性和重排序的方式
as-if-serial 语义
- 不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime 和处理器都必须遵守 as-if-serial 语义
double pi = 3.14; // A double r = 1.0; // B double area = pi * r * r; // C // A 和 B 可以重排序执行,只要 C 最后执行就好了。这就是 as-if-serial
- 在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-ifserial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果
happens-before
-
happens-before 产生的原因
- 因为 jvm 会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要 happens-before 规则定义一些禁止编译优化的场景,保证并发编程的正确性
public class VolatileExample { int x = 0 ; volatile boolean v = false; public void writer(){ x = 42; v = true; } public void reader(){ if (v == true){ // 这里x会是多少呢 // jdk1.5 之前 x等于0或者42 // jdk1.5 之后 x等于42 } } }
- 因为 jvm 会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要 happens-before 规则定义一些禁止编译优化的场景,保证并发编程的正确性
-
happens-before 简介
- JSR-133 使用 happens-before 的概念来阐述操作之间的内存可见性 在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系
-
JMM 把 happens-before 要求禁止的重排序分为两类
- 会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序
- 不会改变程序执行结果的重排序,JMM 对编译器和处理器不做要求(JMM 允许这种重排序)
-
happens-before 的定义
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前,如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)
-
happens-before 规则
规则 内容 程序顺序规则(最基本规则) 一个线程中的每个操作 happens-before 于该线程中的任意后续操作 监视器锁规则 对一个锁的解锁,happens-before 于随后对这个锁的加锁 volatile 变量规则 对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读 传递规则 如果 A happens-before B,且B happens-before C,那么A happens-before C 线程启动规则 Thread 对象的 start() 方法先行发生于此线程的每个一个动作 线程中断规则 对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生 线程终结规则 线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行 对象终结规则 一个对象的初始化完成先行发生于他的 finalize() 方法的开始 - 两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!
-
happens-before 和 as-if-serial
- happens-before 关系本质上和 as-if-serial 语义是一回事
- as-if-serial 语义保证单线程内程序的执行结果不被改变,happens-before 关系保证正确同步的多线程程序的执行结果不被改变
- as-if-serial 语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before 关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按 happens-before 指定的顺序来执行的
- as-if-serial 语义和 happens-before 这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度
顺序一致性
-
顺序一致性简介
- 顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照
-
顺序一致性内存模型特性
- 一个线程中的所有操作必须按照程序的顺序来执行
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见
-
JMM 的顺序一致性
- 在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致
class SynchronizedExample { int a = 0; boolean flag = false; public synchronized void writer() { // 获取锁 a = 1; flag = true; } // 释放锁 public synchronized void reader() { // 获取锁 if (flag) { int i = a; …… } // 释放锁 } }
- 在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致
* 从这里我们可以看到,JMM 在具体实现上的基本方针为:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门
-
未同步程序的执行特性
- 对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM 保证线程读操作读取到的值不会无中生有的冒出来。为了实现最小安全性,JVM 在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在已清零的内存空间分配对象时,域的默认初始化已经完成了。所以未同步程序在 JMM 中的执行时,整体上是无序的,其执行结果无法预知
-
未同步程序在两个模型中的执行特性
- 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序
- JMM 不保证对 64 位的 long 型和 double 型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性
- 原因:假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时,处理器 B 中 64 位的读操作被分配到单个的读事务中执行。处理器 B 可能只读到处理器 A 写了一半的无效值。从 JSR-133 内存模型开始(即从JDK5开始),仅仅只允许把一个 64 位 long/double 型变量的写操作拆分为两个 32 位的写操作来执行,任意的读操作在JSR-133 中都必须具有原子性(即任意读操作必须要在单个读事务中执行)
双重检查锁定与延迟初始化
- 例子
public class DoubleCheckedLocking { // 1 private static Instance instance; // 2 public static Instance getInstance() { // 3 if (instance == null) { // 4:第一次检查 synchronized (DoubleCheckedLocking.class) { // 5:加锁 if (instance == null) // 6:第二次检查 instance = new Instance(); // 7:问题的根源出在这里 } // 8 } // 9 return instance; // 10 } }
- 问题所在
- 在线程执行到第 4 行,代码读取到 instance 不为 null 时,instance 引用的对象有可能还没有完成初始化
- 问题根源
-
第 7 行可以分为伪代码
memory = allocate(); // 1:分配对象的内存空间 ctorInstance(memory); // 2:初始化对象 instance = memory; // 3:设置instance指向刚分配的内存地址
-
由于 2 和 3 之间可以重排序,只要保证访问这个对象的时候 2 执行完毕,在单线程下就不会影响结果,但是多线程下是会有线程安全问题的
时间 线程A 线程B t1 A1:分配对象的内容空间 t2 A3:设置 instance 指向内存空间 t3 B1:判断 instance 是否为空 t4 B2:由于 instance 不为空,线程 B 将访问 instance 引用的对象 t5 A2:初始化对象 t6 A4:访问 insatnce 引用的对象
-
- 解决
- 禁止 2 和 3 之间的重排序
- 2 和 3 重排序时,禁止其他线程看到
- 方案
- 方案一:
public class SafeDoubleCheckedLocking { private volatile static Instance instance; public static Instance getInstance() { if (instance == null) { synchronized (SafeDoubleCheckedLocking.class) { if (instance == null) instance = new Instance(); // instance为volatile,现在没问题了 } } return instance; } }
- 方案二
public class InstanceFactory { private static class InstanceHolder { public static Instance instance = new Instance(); } public static Instance getInstance() { return InstanceHolder.instance ; // 这里将导致InstanceHolder类 被初始化 注意这里是静态内部类 // JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了 } }
- 方案三
public class SingleInstance { private static final SingleInstance instance = new SingleInstance(); // 借助final的内存语义 private SingleInstance() { } public static SingleInstance getInstance() { return instance; } }
- 方案一:
- 类初始化过程
-
第1阶段:通过 在Class 对象上同步(即获取 Class 对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁
-
第2阶段:线程 A 执行类的初始化,同时线程 B 在初始化锁对应的 condition 上等待
-
第3阶段:线程 A 设置 state=initialized,然后唤醒在 condition 中等待的所有线程。此时已经完成类的初始化!
-
第4阶段:线程 B 结束类的初始化处理
-
第5阶段:线程 C 执行类的初始化的处理
时间 线程A 线程B(或者C) t1 A1:尝试获取 class 对象的初始化锁。这里假设线程 A 获取到了初始化锁 B1:尝试获取 class 对象的初始化锁,由于 A 获取到锁,线程 B 将一直等待获取初始化锁 t2 A2:线程 A 看到线程还未被初始化(state = NoInitialization),设置线程为 state = initializing t3 A3:线程 A 释放初始化锁 t4 A4:执行类的静态初始化和初始化类中声明的静态字段 B2:获取到初始化锁 t5 B3:读取到 state = initializing t6 B4:释放初始化锁 t7 B5:在初始化锁的 condition 中等待 t8 A5:获取初始化锁 t9 A6:设置 state = initialized t10 A7:唤醒在 condition 中等待的所有线程 t11 A8:释放初始化锁 t12 B6:获取初始化锁 t13 B7:读取到 state = initialized t14 B8:释放初始化锁 t15 B9:线程 B 的类初始化处理过程完成 t16 C1:获取初始化锁 t17 C2:读取到 state = initialized t18 C3:释放初始化锁 t19 C4:线程 C 的类初始化处理过程完成
-
第四章 Java并发编程基础
线程的简介
-
进程和线程简介
- 现代操作系统在运行一个程序时,会为其创建一个进程。例如,启动一个 Java 程序,操作系统就会创建一个 Java 进程
- 现代操作系统调度的最小单元是线程,也叫轻量级进程,在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量。处理器在这些线程上高速切换,让使用者感觉到这些线程在同时执行
- 进程和线程都是一个时间段的描述,是 CPU 工作时间段的描述,不过是颗粒大小不同
- 进程就是上下文切换之间的程序执行的部分。但进程的颗粒度太大,每次的执行都要进行进程上下文的切换。如果我们把进程比喻为一个运行在电脑上的软件,那么一个软件的执行不可能是一条逻辑执行的,必定有多个分支和多个程序段,就好比要实现程序 A,实际分成 a,b,c 等多个块组合而成。那么这里具体的执行就可能变成:程序 A 得到 CPU =》CPU 加载上下文,开始执行程序 A 的 a 小段,然后执行 A 的 b 小段,然后再执行 A 的 c 小段,最后 CPU 保存 A 的上下文。这里 a,b,c 的执行是共享了 A 进程的上下文,CPU 在执行的时候仅仅切换线程的上下文,而没有进行进程上下文切换的。进程的上下文切换的时间开销是远远大于线程上下文时间的开销。这样就让 CPU 的有效使用率得到提高。这里的 a,b,c 就是线程,也就是说线程是共享了进程的上下文环境,的更为细小的 CPU 时间段。线程主要共享的是进程的地址空间
-
进程、线程和协程的对比
进程 线程 协程 英文 Process Thread fiber/co-routine 解释 cpu 执行的时间段 更细粒度的进程 fiber 约等于 Thread,co-routine 代表协作式调度(非抢占式) 常见操作系统实现 内核级:进程是资源的一个单元,进程内有多个线程。进程包含了:1.虚拟地址空间;2.可执行代码;3.安全的上下文;4.唯一的进程标识;5.环境变量;6.系统对象的引用、文件、设备、socket、窗口;7.一个进程里至少一个线程 内核级线程:线程是进程内调度和执行的单元;用户级进程:如果内存空间并且抢占式调度,这叫做用户级线程,即当前的线程可能会被打断并被另一个线程抢占,线程包含了:1.分析进程的虚拟地址空间;2.分析进程的系统资源;3.有自己的异常处理;4.调度优先级;5.本地现场存储;6.唯一的线程表示;7.线程上下文。用户级线程:1.实现方是用户;2.不能被操作系统识别;3.实现难度简单;4.上下文切换少;5.如果线程阻塞则所在进程阻塞;6.案例为:Java Thread/POXISthread。内核级线程:1.实现方是系统;2.操作系统可以识别;3.实现难度难;4.上下文切换锁;5.如果线程阻塞所在进程不阻塞;6.Windows/Solaris 用户级别线程:如果内存空间且非抢占式调度,这叫做即协程 调度 1.调度方是操作系统内核;2.基于时钟中断;3.系统调用 1.调用方是操作系统内核;2.基于始终中断;3.抢占式 1.调度方是用户级;2.非抢占式;3.仅当线程让出执行体的时候才被打断 对比 相比进程:1.响应度好。一个线程完成后立即可以输出;2.上下文切换速度块,初始化和销毁一个进程成本比线程要高;3.多核利用率高,可以安排同一个进程的不同线程到不同的 cpu 执行;4.同一个进程内线程资源共享;5.系统吞吐量大;6.线程和线程通讯成本高 相比线程:1.上下文切换速度块;2.比较适用于事件驱动的编程;3.IO密集型处理量非常大 -
Java 进程与线程的区别
- 进程之间共享信息可通过 TCP/IP 协议,线程间共享信息可通过共用内存
- 进程是资源分配的最小单位,线程是 CPU 调度的最小单位
- 进程是抢占处理机的调度单位;线程属于某个进程,共享其资源
- 线程是程序的多个顺序的流动态执行
- 线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制
- 进程有独立的地址空间,相互不影响,线程只是进程的不同执行路径
- 线程没有自己独立的地址空间,多进程的程序比多线程的程序健壮
- 进程的切换比线程的切换开销大
-
为什么要使用多线程
- 更多的处理器核心
- 更快的响应时间
- 更好的编程模型
-
线程优先级
- 现代操作系统基本采用时分的形式调度运行的线程,操作系统会分出一个个时间片,线程会分配到若干时间片,当线程的时间片用完了就会发生线程调度,并等待着下次分配。线程分配到的时间片多少也就决定了线程使用处理器资源的多少,而线程优先级就是决定线程需要多或者少分配一些处理器资源的线程属性
- 在 Java 线程中,通过一个整型成员变量 priority 来控制优先级,优先级的范围从 1~10,在线程构建的时候可以通过 setPriority(int) 方法来修改优先级,默认优先级是 5,优先级高的线程分配时间片的数量要多于优先级低的线程。设置线程优先级时,针对频繁阻塞(休眠或者I/O操作)的线程需要设置较高优先级,而偏重计算(需要较多CPU时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。在不同的JVM以及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定
-
线程的状态
状态名称 说明 NEW 初始状态,线程被构建,但是还没有调用start()方法 RUNNABLE 运行状态,Java线程将操作系统中的就绪和运行两种状态笼统的称为“运行中” BLOCKED 阻塞状态,表示线程阻塞于锁 WAITING 等待状态,表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定工作(通知或中断) TIME_WAITING 超时等待状态,该状态不同于WAITING,它可以在指定的时间自行返回 TERMINATED 终止状态,表示当前线程已经执行完毕 - Java 将操作系统中的运行和就绪两个状态合并称为运行状态。阻塞状态是线程阻塞在进入 synchronized 关键字修饰的方法或代码块(获取锁)时的状态,但是阻塞在 java.concurrent 包中 Lock 接口的线程状态却是等待状态,因为 java.concurrent 包中 Lock 接口对于阻塞的实现均使用了 LockSupport 类中的相关方法
-
Daemon 线程
- Daemon 线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个 Java 虚拟机中不存在非 Daemon 线程的时候,Java 虚拟机将会退出。可以通过调用Thread.setDaemon(true) 将线程设置为 Daemon 线程。注意 Daemon 属性需要在启动线程之前设置,不能在启动线程之后设置。在构建 Daemon 线程时,不能依靠 finally 块中的内容来确保执行关闭或清理资源的逻辑
启动和终止线程
-
构造线程
private void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc) { if (name == null) { throw new NullPointerException("name cannot be null"); } // 当前线程就是该线程的父线程 Thread parent = currentThread(); this.group = g; // 将daemon、priority属性设置为父线程的对应属性 this.daemon = parent.isDaemon(); this.priority = parent.getPriority(); this.name = name.toCharArray(); this.target = target; setPriority(priority); // 将父线程的InheritableThreadLocal复制过来 if (parent.inheritableThreadLocals != null) this.inheritableThreadLocals=ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); // 分配一个线程ID tid = nextThreadID(); }
在上述过程中,一个新构造的线程对象是由其 parent 线程来进行空间分配的,而 child 线程继承了 parent 是否为 Daemon、优先级和加载资源的 contextClassLoader 以及可继承的 ThreadLocal,同时还会分配一个唯一的 ID 来标识这个 child 线程。至此,一个能够运行的线程对象就初始化好了,在堆内存中等待着运行
-
启动线程
- 线程对象在初始化完成之后,调用 start() 方法就可以启动这个线程。线程 start() 方法的含义是:当前线程(即 parent 线程)同步告知 Java 虚拟机,只要线程规划器空闲,应立即启动调用 start() 方法的线程
- 启动一个线程前,最好为这个线程设置线程名称,因为这样在使用 jstack 分析程序或者进行问题排查时,就会给开发人员提供一些提示,自定义的线程最好能够起个名字
-
理解中断
- 中断可以理解为线程的一个标识位属性,它表示一个运行中的线程是否被其他线程进行了中断操作
- 线程通过检查自身是否被中断来进行响应,线程通过方法 isInterrupted() 来进行判断是否被中断,也可以调用静态方法 Thread.interrupted() 对当前线程的中断标识位进行复位。如果该线程已经处于终结状态,即使该线程被中断过,在调用该线程对象的 isInterrupted() 时依旧会返回 false
线程间通信
-
volatile 和 synchronized 关键字
- 关键字 volatile 可以用来修饰字段(成员变量),就是告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性
- 关键字 synchronized 可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性
-
等待/通知机制
- 等待/通知机制,是指一个线程 A 调用了对象 O 的 wait() 方法进入等待状态,而另一个线程 B 调用了对象 O 的 notify() 或者 notifyAll() 方法,线程 A 收到通知后从对象 O 的 wait() 方法返回,进而执行后续操作。上述两个线程通过对象 O 来完成交互,而对象上的 wait() 和 notify/notifyAll() 的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作
- 需要注意的细节
- 使用 wait()、notify() 和 notifyAll() 时需要先对调用对象加锁
- 调用 wait() 方法后,线程状态由 RUNNIN 变为 WAITING,并将当前线程放置到对象的等待队列
- notify() 或 notifyAll() 方法调用后,等待线程依旧不会从 wait() 返回,需要调用 notify() 或 notifAll() 的线程释放锁之后,等待线程才有机会从 wait() 返回
- notify() 方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而 notifyAll() 方法则是将等待队列中所有的线程全部移到同步队列,被移动的线程状态由 WAITING 变为BLOCKED
- 从 wait() 方法返回的前提是获得了调用对象的锁
-
等待/通知的经典范式
- 等待方遵循如下原则:
- 获取对象的锁
- 如果条件不满足,那么调用对象的 wait() 方法,被通知后仍要检查条件
- 条件满足则执行对应的逻辑
- 通知方遵循如下原则:
- 获得对象的锁
- 改变条件
- 通知所有等待在对象上的线程
- 等待方遵循如下原则:
-
管道输入/输出流
- 管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存
- 管道输入/输出流主要包括了如下4种具体实现:PipedOutputStream、PipedInputStream、PipedReader和PipedWriter,前两种面向字节,而后两种面向字符
-
Thread.join() 的使用
- 如果一个线程 A 执行了 thread.join() 语句,其含义是:当前线程 A 等待 thread 线程终止之后才从 thread.join() 返回
// 加锁当前线程对象 public final synchronized void join() throws InterruptedException { // 条件不满足,继续等待 while (isAlive()) { wait(0); } // 条件符合,方法返回 }
-
ThreadLocal 的使用
- ThreadLocal 介绍
- ThreadLocal,很多地方叫做线程本地变量,也有些地方叫做线程本地存储,是一个以 ThreadLocal 对象为键、任意对象为值的存储结构。这个结构被附带在线程上,也就是说一个线程可以根据一个 ThreadLocal 对象查询到绑定在这个线程上的一个值。可以通过 set(T) 方法来设置一个值,在当前线程下再通过 get() 方法获取到原先设置的值
- ThreadLocal 的常用方法
- void set(T value):设置当前线程的线程局部变量的值
- public T get():该方法返回当前线程所对应的线程局部变量
- public void remove():将当前线程局部变量的值删除。当线程结束后,对应该线程的局部变量将自动被垃圾回收,所以显式调用该方法清除线程的局部变量并不是必须的操作,但它可以加快内存回收的速度
- protected T initialValue():返回该线程局部变量的初始值,该方法是一个 protected 的方法,显然是为了让子类覆盖而设计的。这个方法是一个延迟调用方法,在线程第 1 次调用 get() 或 set(T) 时才执行,并且仅执行 1 次。ThreadLocal 中的缺省实现直接返回一个 null
- ThreadLocal 创建本地变量的过程
- 首先,在每个线程 Thread 内部有一个 ThreadLocal.ThreadLocalMap 类型的成员变量 threadLocals,这个 threadLocals 就是用来存储实际的变量副本的,键值为当前 ThreadLocal 变量,value 为变量副本(即T类型的变量)
- 初始时,在 Thread 里面,threadLocals 为空,当通过 ThreadLocal 变量调用 get() 方法或者 set() 方法,就会对 Thread 类中的 threadLocals 进行初始化,并且以当前 ThreadLocal 变量为键值,以ThreadLocal 要保存的副本变量为 value,存到 threadLocals
- 然后在当前线程里面,如果要使用副本变量,就可以通过 get() 方法在 threadLocals 里面查找
- 注意
- ThreadLocalMap 中的 Entry 的 key 使用的是 ThreadLocal 对象的弱引用,在没有其他地方对 ThreadLocal 依赖,ThreadLocalMap 中的 ThreadLocal 对象就会被回收掉,但是对应的不会被回收,这个时候 Map 中就可能存在 key 为 null 但是 value 不为 null 的项,这需要实际的时候使用完毕及时调用 remove 方法避免内存泄漏
- JDK 建议 ThreadLocal 定义为 private static,这样 ThreadLocal 的弱引用问题则不存在了
- ThreadLocal 介绍
第五章 Java中的锁
Lock 接口
-
Lock 介绍
- 锁是用来控制多个线程访问共享资源的方式。在 Lock 接口出现之前,Java 程序是靠 synchronized 关键字实现锁功能的,而 Java SE 5 之后,并发包中新增了 Lock 接口(以及相关实现类)用来实现锁功能,它提供了与 synchronized 关键字类似的同步功能,只是在使用时需要显式地获取和释放锁。虽然它缺少了(通过 synchronized 块或者方法所提供的)隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等多种 synchronized 关键字所不具备的同步特性
-
Lock 的简单实用方法
Lock lock = new ReentrantLock(); lock.lock(); try { } finally { lock.unlock(); }
-
Lock 的API
public interface Lock { // 获取锁 void lock(); // 响应中断地获取锁 void lockInterruptibly() throws InterruptedException; // 尝试获取锁,若获取锁失败,方法立即返回 boolean tryLock(); // 在给定的时间内尝试获取锁,在此时间段内仍未获取到锁,最终方法还是将返回 boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 释放锁 void unlock(); /** * 返回一个与当前锁绑定的新Condition实例,可以绑定多个Condition实例<br/> * 此Condition实例用作线程通信,类似于Object的nofify()/wait() */ Condition newCondition(); }
队列同步器-AQS
-
AQS 简介
- 队列同步器 AbstractQueuedSynchronizer(以下简称同步器或 AQS),是用来构建锁或者其他同步组件的基础框架,它使用了一个 int 成员变量表示同步状态,通过内置的 FIFO 队列来完成资源获取线程的排队工作
- AQS 自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,AQS 既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态
-
AQS 和锁的区别
- 锁是面向使用者的,它定义了使用者与锁交互的接口(比如可以允许两个线程并行访问),隐藏了实现细节
- AQS 面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作
-
AQS的接口与示例
- AQS 的设计是基于模板方法模式的,也就是说,使用者需要继承 AQS 并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用 AQS 提供的模板方法,而这些模板方法将会调用使用者重写的方法
- 实现了 AQS 的锁有:自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏
- 重写 AQS 指定的方法时,需要使用 AQS 提供的如下 3 个方法来访问或修改同步状态
- getState():获取当前同步状态
- setState(int newState):设置当前同步状态
- compareAndSetState(int expect,int update):使用 CAS 设置当前状态,该方法能够保证状态设置的原子性
- AQS 可重写的方法
方法名称 描述 tryAcquire(int arg) 独占获取同步状态,实现该方法需要查询当前状态,并判断同步状态是否符合预期状态,然后再进行 CAS 设置同步状态 treRelease(int arg) 独占式释放同步状态,等待获取同步状态的线程将有机会获取同步状态 tryAcquireShared(int arg) 共享式获取同步状态,返回大于等于 0 的值,表示获取成功,反之失败 tryReleaseShared(int arg) 共享式释放同步状态 isHeldExclusively() 当前同步器是否在独占模式下被线程占用,一般该方法表示是否被当前线程所独占 - 实现自定义同步组件时,将会调用 AQS 提供的模板方法,AQS 提供的模版方法基本上分为 3 类
- 独占式获取与释放同步状态
- 共享式获取与释放同步状态
- 查询同步队列中的等待线程情况
- AQS 的设计是基于模板方法模式的,也就是说,使用者需要继承 AQS 并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用 AQS 提供的模板方法,而这些模板方法将会调用使用者重写的方法
-
AQS 的实现分析
-
同步队列
- AQS 依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,AQS 会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点
- 节点是构成同步队列的基础,AQS 拥有首节点(head)和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部。同步队列遵循 FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点
属性类型与名称 描述 int waitStatus 等待状态:1.CANCELLED,值为1,由于在同步队列中等待的线程等待超时获取被中断,需要从同步队列中取消等待,节点进入该状态将不会变化;2.SIGNAL,值为-1,后继节点的线程处于等待状态,而当前节点的线程如果释放了同步状态或者被取消,将会通知后继节点,使后继节点线程得以运行;3.CONDITION,值为-2,节点在等待队列中,节点线程等待在Condition上,当其他线程对Condition调用了signal()方法后,该节点将会从等待队列中转移到同步队列中,加入到对同步状态的获取中;4.PROPAGATE,值为-3,表示下一次共享式同步状态获取将会无条件地被传播下去;5.INITIAl ,值为0,初始状态 Node prev 前驱节点,当节点加入同步队列时被设置(尾部添加) Node next 后继节点 Node nextWaiter 等待队列中的后继节点,如果当前节点时共享的,那么这个字段将是一个SHARED常量,也就是节点类型(独占和共享)和等待队列中的后继节点公用同一个字段 Thread thread 获取同步状态的线程
-
独占式同步状态获取与释放
- 首先调用自定义同步器实现的 tryAcquire(int arg) 方法,该方法保证线程安全的获取同步状态,如果同步状态获取失败,则构造同步节点(独占式Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过 addWaiter(Nodenode) 方法将该节点加入到同步队列的尾部,最后调用 acquireQueued(Node node,int arg) 方法,使得该节点以“死循环”的方式获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现
// 该方法主要完成了同步状态获取、节点构造、加入同步队列以及在同步队列中自旋等待的相关工作 public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); // 快速尝试在尾部添加 Node pred = tail; if (pred != null) { node.prev = pred; // 用compareAndSetTail(Node expect,Node update)方法来确保节点能够被线程安全添加 if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } enq(node); return node; } // 同步器通过“死循环”来保证节点的正确添加,在“死循环”中只有通过CAS将节点设置成为尾节点之后,当前线程才能从该方法返回,否则,当前线程不断地尝试设置 private Node enq(final Node node) { for (;;) { Node t = tail; if (t == null) { // Must initialize if (compareAndSetHead(new Node())) tail = head; } else { node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } } } //当前线程在“死循环”中尝试获取同步状态,而只有前驱节点是头节点才能够尝试获取同步状态 // 第一,头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。 // 第二,维护同步队列的FIFO原则 final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } } ```![在这里插入图片描述](https://img-blog.csdnimg.cn/20201007085535358.PNG?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2xiaF9wYW9wYW8=,size_16,color_FFFFFF,t_70#pic_center)
- 当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能够继续获取同步状态。通过调用同步器的 release(int arg) 方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)
public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; }
- 在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用 tryRelease(int arg) 方法释放同步状态,然后唤醒头节点的后继节点
- 首先调用自定义同步器实现的 tryAcquire(int arg) 方法,该方法保证线程安全的获取同步状态,如果同步状态获取失败,则构造同步节点(独占式Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过 addWaiter(Nodenode) 方法将该节点加入到同步队列的尾部,最后调用 acquireQueued(Node node,int arg) 方法,使得该节点以“死循环”的方式获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现
-
共享式同步状态获取与释放
- 共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态,例如文件的读写
- 在 acquireShared(int arg) 方法中,同步器调用 tryAcquireShared(int arg) 方法尝试获取同步状态,tryAcquireShared(int arg) 方法返回值为 int 类型,当返回值大于等于 0 时,表示能够获取到同步状态。因此,在共享式获取的自旋过程中,成功获取到同步状态并退出自旋的条件就是 tryAcquireShared(int arg) 方法返回值大于等于 0。可以看到,在 doAcquireShared(int arg) 方法的自旋过程中,如果当前节点的前驱为头节点时,尝试获取同步状态,如果返回值大于等于 0,表示该次获取同步状态成功并从自旋过程中退出
public final void acquireShared(int arg) { if (tryAcquireShared(arg) < 0) doAcquireShared(arg); } private void doAcquireShared(int arg) { final Node node = addWaiter(Node.SHARED); boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head) { int r = tryAcquireShared(arg); if (r >= 0) { setHeadAndPropagate(node, r); p.next = null; if (interrupted) selfInterrupt(); failed = false; return; } } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }
- 共享式通过调用 releaseShared(int arg) 方法可以释放同步状态,该方法在释放同步状态之后,将会唤醒后续处于等待状态的节点。对于能够支持多个线程同时访问的并发组件(比如Semaphore),它和独占式主要区别在于 tryReleaseShared(int arg) 方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和 CAS 来保证的,因为释放同步状态的操作会同时来自多个线程
public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; }
-
独占式超时获取同步状态
- 通过调用同步器的 doAcquireNanos(int arg,long nanosTimeout) 方法可以超时获取同步状态,即在指定的时间段内获取同步状态,如果获取到同步状态则返回 true,否则,返回 false
- 在 Java 5 之前,当一个线程获取不到锁而被阻塞在 synchronized 之外时,对该线程进行中断操作,此时该线程的中断标志位会被修改,但线程依旧会阻塞在 synchronized 上,等待着获取锁。在 Java 5 中,同步器提供了 acquireInterruptibly(int arg) 方法,这个方法在等待获取同步状态时,如果当前线程被中断,会立刻返回,并抛出 InterruptedException
- 超时获取同步状态过程可以被视作响应中断获取同步状态过程的“增强版”,doAcquireNanos(int arg,long nanosTimeout) 方法在支持响应中断的基础上,增加了超时获取的特性。针对超时获取,主要需要计算出需要睡眠的时间间隔 nanosTimeout,为了防止过早通知,nanosTimeout 计算公式为:nanosTimeout-=now-lastTime,其中 now 为当前唤醒时间,lastTime 为上次唤醒时间,如果 nanosTimeout 大于 0 则表示超时时间未到,需要继续睡眠 nanosTimeout 纳秒,反之,表示已经超时
private boolean doAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { long lastTime = System.nanoTime(); final Node node = addWaiter(Node.EXCLUSIVE); boolean failed = true; try { for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return true; } if (nanosTimeout <= 0) return false; if (shouldParkAfterFailedAcquire(p, node) && nanosTimeout > spinForTimeoutThreshold) LockSupport.parkNanos(this, nanosTimeout); long now = System.nanoTime(); //计算时间,当前时间now减去睡眠之前的时间lastTime得到已经睡眠 //的时间delta,然后被原有超时时间nanosTimeout减去,得到了 //还应该睡眠的时间 nanosTimeout -= now - lastTime; lastTime = now; if (Thread.interrupted()) throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } }
- 该方法在自旋过程中,当节点的前驱节点为头节点时尝试获取同步状态,如果获取成功则从该方法返回,这个过程和独占式同步获取的过程类似,但是在同步状态获取失败的处理上有所不同。如果当前线程获取同步状态失败,则判断是否超时(nanosTimeout 小于等于 0 表示已经超时),如果没有超时,重新计算超时间隔 nanosTimeout,然后使当前线程等待 nanosTimeout 纳秒(当已到设置的超时时间,该线程会从 LockSupport.parkNanos(Object locker,long nanos) 方法返回)。如果 nanosTimeout 小于等于 spinForTimeoutThreshold(1000纳秒) 时,将不会使该线程进行超时等待,而是进入快速的自旋过程。原因在于,非常短的超时等待无法做到十分精确,如果这时再进行超时等待,相反会让 nanosTimeout 的超时从整体上表现得反而不精确。因此,在超时非常短的场景下,同步器会进入无条件的快速自旋
-
自定义同步组件-TwinsLock
- 在前面的章节中,对 AQS 进行了实现层面的分析,本节通过编写一个自定义同步组件来加深对 AQS 的理解
- 设计一个同步工具:该工具在同一时刻,只允许至多两个线程同时访问,超过两个线程的访问将被阻塞,我们将这个同步工具命名为 TwinsLock
- 首先,确定访问模式。TwinsLock 能够在同一时刻支持多个线程的访问,这显然是共享式访问,因此,需要使用同步器提供的 acquireShared(int args) 方法等和 Shared 相关的方法,这就要求 TwinsLock 必须重写 tryAcquireShared(int args) 方法和 tryReleaseShared(int args) 方法,这样才能保证同步器的共享式同步状态的获取与释放方法得以执行
- 其次,定义资源数。TwinsLock 在同一时刻允许至多两个线程的同时访问,表明同步资源数为 2,这样可以设置初始状态 status 为 2,当一个线程进行获取,status 减 1,该线程释放,则 status 加 1,状态的合法范围为 0、1 和 2,其中 0 表示当前已经有两个线程获取了同步资源,此时再有其他线程对同步状态进行获取,该线程只能被阻塞。在同步状态变更时,需要使用 compareAndSet(int expect,int update) 方法做原子性保障
- 最后,组合自定义同步器。前面的章节提到,自定义同步组件通过组合自定义同步器来完成同步功能,一般情况下自定义同步器会被定义为自定义同步组件的内部类
// 实现 public class TwinsLock implements Lock { private final Sync sync = new Sync(2); private static final class Sync extends AbstractQueuedSynchronizer { Sync(int count) { if (count <= 0) { throw new IllegalArgumentException("count must large than zero."); } setState(count); } public int tryAcquireShared(int reduceCount) { for (;;) { int current = getState(); int newCount = current - reduceCount; if (newCount < 0 || compareAndSetState(current, newCount)) { return newCount; } } } public boolean tryReleaseShared(int returnCount) { for (;;) { int current = getState(); int newCount = current + returnCount; if (compareAndSetState(current, newCount)) { return true; } } } } public void lock() { sync.acquireShared(1); } public void unlock() { sync.releaseShared(1); } // 其他接口方法略 } // 测试 public class TwinsLockTest { @Test public void test() { final Lock lock = new TwinsLock(); class Worker extends Thread { public void run() { while (true) { lock.lock(); try { SleepUtils.second(1); System.out.println(Thread.currentThread().getName()); SleepUtils.second(1); } finally { lock.unlock(); } } } } // 启动10个线程 for (int i = 0; i < 10; i++) { Worker w = new Worker(); w.setDaemon(true); w.start(); } // 每隔1秒换行 for (int i = 0; i < 10; i++) { SleepUtils.second(1); System.out.println(); } } }
-
重入锁
-
重入锁 ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁的还支持获取锁时的公平和非公平性选择
- synchronized 关键字隐式的支持重进入,比如一个 synchronized 修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁,ReentrantLock 虽然没能像 synchronized 关键字一样支持隐式的重进入,但是在调用 lock() 方法时,已经获取到锁的线程,能够再次调用 lock() 方法获取锁而不被阻塞
- 这里提到一个锁获取的公平性问题,如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的。公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。ReentrantLock 提供了一个构造函数,能够控制锁是否是公平的。事实上,公平的锁机制往往没有非公平的效率高,但是,并不是任何场景都是以 TPS 作为唯一的指标,公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足
-
实现重进入:重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,该特性的实现需要解决以下两个问题
- 线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取
- 锁的最终释放。线程重复 n 次获取了锁,随后在第 n 次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于 0 时表示锁已经成功释放
- ReentrantLock 是通过组合自定义同步器来实现锁的获取与释放,默认非公平性,该方法增加了再次获取同步状态的处理逻辑:通过判断当前线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回 true,表示获取同步状态成功
final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
- 成功获取锁的线程再次获取锁,只是增加了同步状态值,这也就要求 ReentrantLock 在释放同步状态时减少同步状态值
protected final boolean tryRelease(int releases) { int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c); return free; }
-
公平与非公平获取锁的区别
- 公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是 FIFO,对于非公平锁,只要 CAS 设置同步状态成功,则表示当前线程获取了锁,而公平锁则不同,该实现增加了 hasQueuedPredecessors() 方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获取锁
protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
- 公平性锁保证了锁的获取按照 FIFO 原则,而代价是进行大量的线程切换。非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量
- 公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是 FIFO,对于非公平锁,只要 CAS 设置同步状态成功,则表示当前线程获取了锁,而公平锁则不同,该实现增加了 hasQueuedPredecessors() 方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获取锁
读写锁
-
读写锁概述
- 读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。Java 并发包提供读写锁的实现是 ReentrantReadWriteLock
- ReentrantReadWriteLock 的特性
- 公平性选择:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平
- 重进入:该锁支持重进入,以读写线程为例,读线程在获取了锁之后,能够再次获取读锁。而写线程在获取了写锁之后能够再次获得写锁,同时也可以或获取读锁
- 锁降级:遵循获取写锁,获取读锁,再释放写锁的次序,写锁能够降级成为读锁
-
读写锁的接口与示例
- ReadWriteLock 仅定义了获取读锁和写锁的两个方法,即 readLock() 方法和 writeLock() 方法,而其实现——ReentrantReadWriteLock,除了接口方法之外,还提供了一些便于外界监控其内部工作状态的方法
- int getReadLockCount():返回当前读锁被获取的次数
- int getReadHoldCount():获取当前线程获取读锁的次数
- boolean isWriteLocked():判断读锁是否被获取
- int getWriteHoldCount():获取当前线程获取写锁的次数
public class Cache { static Map<String, Object> map = new HashMap<String, Object>(); static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); static Lock r = rwl.readLock(); static Lock w = rwl.writeLock(); // 获取一个key对应的value public static final Object get(String key) { r.lock(); try { return map.get(key); } finally { r.unlock(); } } // 设置key对应的value,并返回旧的value public static final Object put(String key, Object value) { w.lock(); try { return map.put(key, value); } finally { w.unlock(); } } // 清空所有的内容 public static final void clear() { w.lock(); try { map.clear(); } finally { w.unlock(); } } }
- ReadWriteLock 仅定义了获取读锁和写锁的两个方法,即 readLock() 方法和 writeLock() 方法,而其实现——ReentrantReadWriteLock,除了接口方法之外,还提供了一些便于外界监控其内部工作状态的方法
-
读写锁的实现分析
- 接下来分析 ReentrantReadWriteLock 的实现,主要包括:读写状态的设计、写锁的获取与释放、读锁的获取与释放以及锁降级(以下没有特别说明读写锁均可认为是 ReentrantReadWriteLock)
- 读写状态的设计
- 读写锁同样依赖自定义同步器来实现同步功能,而读写状态就是其同步器的同步状态。回想 ReentrantLock 中自定义同步器的实现,同步状态表示锁被一个线程重复获取的次数,而读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态,使得该状态的设计成为读写锁实现的关键
- 如果在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁将变量切分成了两个部分,高 16 位表示读,低 16 位表示写
- 读写锁状态的划分方式当前同步状态表示一个线程已经获取了写锁,且重进入了两次,同时也连续获取了两次读锁。读写锁是如何迅速确定读和写各自的状态呢?答案是通过位运算。假设当前同步状态值为 S,写状态等于 S&0x0000FFFF(将高 16 位全部抹去),读状态等于 S>>>16 (无符号补 0 右移 16 位)。当写状态增加 1 时,等于 S+1,当读状态增加 1 时,等于 S+(1<<16),也就是 S0x00010000。根据状态的划分能得出一个推论:S 不等于 0 时,当写状态(S&0x0000FFFF) 等于 0 时,则读状态(S>>>16)大于 0,即读锁已被获取
- 写锁的获取与释放
- 写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为 0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态。读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞
- 写锁的释放与 ReentrantLock 的释放过程基本类似,每次释放均减少写状态,当写状态为 0 时表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对后续读写线程可见
protected final boolean tryAcquire(int acquires) { Thread current = Thread.currentThread(); int c = getState(); int w = exclusiveCount(c); if (c != 0) { // 存在读锁或者当前获取线程不是已经获取写锁的线程 if (w == 0 || current != getExclusiveOwnerThread()) return false; if (w + exclusiveCount(acquires) > MAX_COUNT) throw new Error("Maximum lock count exceeded"); setState(c + acquires); return true; } if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) { return false; } setExclusiveOwnerThread(current); return true; }
- 读锁的获取与释放
- 读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在没有其他写线程访问(或者写状态为0)时,读锁总会被成功地获取,而所做的也只是(线程安全的)增加读状态。如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态
- 读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数只能选择保存在 ThreadLocal 中,由线程自身维护,这使获取读锁的实现变得复杂。因此,这里将获取读锁的代码做了删减,保留必要的部分
- 在 tryAcquireShared(int unused) 方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠 CAS 保证)增加读状态,成功获取读锁。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是(1<<16)
protected final int tryAcquireShared(int unused) { for (;;) { int c = getState(); int nextc = c + (1 << 16); if (nextc < c) throw new Error("Maximum lock count exceeded"); if (exclusiveCount(c) != 0 && owner != Thread.currentThread()) return -1; if (compareAndSetState(c, nextc)) return 1; } }
- 锁降级
- 锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程
- 接下来看一个锁降级的示例。因为数据不常变化,所以多个线程可以并发地进行数据处理,当数据变更后,如果当前线程感知到数据变化,则进行数据的准备工作,同时其他处理线程被阻塞,直到当前线程完成数据的准备工作。当数据发生变更后,update 变量(布尔类型且 volatile 修饰)被设置为 false,此时所有访问 processData() 方法的线程都能够感知到变化,但只有一个线程能够获取到写锁,其他线程会被阻塞在读锁和写锁的 lock() 方法上。当前线程获取写锁完成数据准备之后,再获取读锁,随后释放写锁,完成锁降级
- 锁降级中读锁的获取是否必要呢?答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程 T 的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程 T 将会被阻塞,直到当前线程使用数据并释放读锁之后,线程 T 才能获取写锁进行数据更新。RentrantReadWriteLock 不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的
public void processData() { readLock.lock(); if (!update) { // 必须先释放读锁 readLock.unlock(); // 锁降级从写锁获取到开始 writeLock.lock(); try { if (!update) { // 准备数据的流程(略) update = true; } readLock.lock(); } finally { writeLock.unlock(); } // 锁降级完成,写锁降级为读锁 } try { // 使用数据的流程(略) } finally { readLock.unlock(); } }
LockSupport 工具类
-
LockSupport 工具类介绍
- 当需要阻塞或唤醒一个线程的时候,都会使用 LockSupport 工具类来完成相应工作。LockSupport 定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能,而 LockSupport 也成为构建同步组件的基础工具
-
LockSupport 常用方法
- LockSupport 定义了一组以 park 开头的方法用来阻塞当前线程,以及 unpark(Thread thread) 方法来唤醒一个被阻塞的线程
- static void park(Object blocker):阻塞当前线程
- static void parkNanos(Object blocker, long nanos):阻塞当前线程,不过有超时时间的限制
- static void parkUntil(Object blocker, long deadline):阻塞当前线程,直到某个时间
- static void park():阻塞当前线程,如果调用 unpark(Thread thread) 方法或当前线程被中断,才能用 park() 方法返回
- static void parkNanos(long nanos):阻塞当前线程,不过有超时时间的限制
- static void parkUntil(long deadline):阻塞当前线程,直到某个时间(1970年开始的毫秒数)
- static void unpark(Thread thread):唤醒处于阻塞状态的线程 thread
- static Object getBlocker(Thread t);
- LockSupport 定义了一组以 park 开头的方法用来阻塞当前线程,以及 unpark(Thread thread) 方法来唤醒一个被阻塞的线程
-
LockSupport 的实现
- LockSupport 是通过控制变量 _counter 来对线程阻塞唤醒进行控制的。原理有点类似于信号量机制
- 当调用 park() 方法时,会将 _counter 置为 0,同时判断前值,小于 1 说明前面被 unpark 过,则直接退出,否则将使该线程阻塞
- 当调用 unpark() 方法时,会将 _counter 置为 1,同时判断前值,小于 1 会进行线程唤醒,否则直接退出
- LockSupport 是通过控制变量 _counter 来对线程阻塞唤醒进行控制的。原理有点类似于信号量机制
-
LockSupport 总结
- park 和 unpark 可以实现类似 wait 和 notify 的功能,但是并不和 wait 和 notify 交叉,也就是说 unpark 不会对 wait 起作用,notify 也不会对 park 起作用
- wait 和 notify 都是 Object 中的方法,在调用这两个方法前必须先获得锁对象,但是 park 不需要获取某个对象的锁就可以锁住线程
- park 和 unpark 的使用不会出现死锁的情况
- blocker 的作用是在 dump 线程的时候看到阻塞对象的信息
Condition 接口
-
Condition 介绍
- 任意一个 Java 对象,都拥有一组监视器方法(定义在 java.lang.Object 上),主要包括 wait()、wait(long timeout)、notify() 以及 notifyAll() 方法,这些方法与 synchronized 同步关键字配合,可以实现等待/通知模式。Condition 接口也提供了类似 Object 的监视器方法,与 Lock 配合可以实现等待/通知模式,但是这两者在使用方式以及功能特性上还是有差别的
- Object 的监视器方法与 Condition 接口的对比
对比项 Object monitor Methods Condition 前置条件 获取锁的对象 调用Lock.losk()获取锁,调用Lock.newCondition()获取condition对象 调用方式 直接调用,例如object.wait() 直接调用,例如:condition.await() 等待队列个数 一个 多个 当前线程释放锁并进入等待状态 支持 支持 当前线程释放锁并进入等待状态,在等待状态中不响应中断 不支持 支持 当前线程释放锁并进入超时等待状态 支持 支持 当前线程释放锁并进入等待状态到将来的某个时刻 不支持 支持 唤醒等待队列中的一个线程 支持 支持 唤醒等待队列中的所有线程 支持 支持
-
Condition 接口与示例
- Condition 定义了等待/通知两种类型的方法,当前线程调用这些方法时,需要提前获取到 Condition 对象关联的锁。Condition 对象是由 Lock 对象(调用Lock对象的newCondition()方法)创建出来的,换句话说,Condition 是依赖 Lock 对象的
Lock lock = new ReentrantLock(); Condition condition = lock.newCondition(); public void conditionWait() throws InterruptedException { lock.lock(); try { condition.await(); } finally { lock.unlock(); } } public void conditionSignal() throws InterruptedException { lock.lock(); try { condition.signal(); } finally { lock.unlock(); } }
- Condition 的(部分)方法以及描述
方法名称 描述 void await() throws InterruptedException 当前线程进入等待状态知道被通知或中断,当前线程将进入运行状态且从 await() 方法返回的情况,包括:其他线程调用该 Conditioncn 的 signal() 或 signalAll() 方法,而当前线程被选中唤醒;其他线程(调用 interrupt() 方法)中断当前线程;如果当前等待线程从 await() 方法返回,那么表明该线程已经获取了 Condition 对象所对应的锁 void awaitUninterruptibly() 当前线程进入等待状态直到被通知,该方法对中断不敏感,也就是在等待状态中不能被中断 long awaitNanos(long nanosTimeout) throws InterruptedException 当前线程进入等待状态,直到被通知,中断,或者超时。返回值表示剩余时间,如果返回值为 0 或者负数,说明已经超时了。如果在 nanosTimeout 之前就被唤醒了,那么返回值就是 nanosTimeout-实际耗时 boolean await(long time, TimeUnit unit) throws InterruptedException 当前线程进入等待状态,直到被通知,中断,或者超时。支持自定义时间单位,false:表示方法超时之后自动返回的,true:表示等待还未超时时,await 方法就返回了(超时之前,被其他线程唤醒了) boolean awaitUntil(Date deadline) throws InterruptedException 当前线程进入等待状态,直到被通知,中断,或者到将来某个时间,如果没有到指定时间就被通知,返回 true,如果到了某个时间,还未被唤醒,就返回 false void signal() 唤醒一个等待在 Condition 上的线程,该线程从等待方法返回前必须获得与 Condition 相关联的锁 void signalAll() 唤醒所有等待在 Condition 上的线程,能够从等待方法返回的线程必须是获得了与 Condition 相关联的锁 - 获取一个 Condition 必须通过 Lock 的 newCondition() 方法。下面通过一个有界队列的示例来深入了解 Condition 的使用方式。有界队列是一种特殊的队列,当队列为空时,队列的获取操作将会阻塞获取线程,直到队列中有新增元素,当队列已满时,队列的插入操作将会阻塞插入线程,直到队列出现“空位”
public class BoundedQueue<T> { private Object[] items; // 添加的下标,删除的下标和数组当前数量 private int addIndex, removeIndex, count; private Lock lock = new ReentrantLock(); private Condition notEmpty = lock.newCondition(); private Condition notFull = lock.newCondition(); public BoundedQueue(int size) { items = new Object[size]; } // 添加一个元素,如果数组满,则添加线程进入等待状态,直到有"空位" public void add(T t) throws InterruptedException { lock.lock(); try { while (count == items.length) notFull.await(); items[addIndex] = t; if (++addIndex == items.length) addIndex = 0; ++count; notEmpty.signal(); } finally { lock.unlock(); } } // 由头部删除一个元素,如果数组空,则删除线程进入等待状态,直到有新添加元素 @SuppressWarnings("unchecked") public T remove() throws InterruptedException { lock.lock(); try { while (count == 0) notEmpty.await(); Object x = items[removeIndex]; if (++removeIndex == items.length) removeIndex = 0; --count; notFull.signal(); return (T) x; } finally { lock.unlock(); } } }
- 以添加方法为例。首先需要获得锁,目的是确保数组修改的可见性和排他性。当数组数量等于数组长度时,表示数组已满,则调用 notFull.await(),当前线程随之释放锁并进入等待状态。如果数组数量不等于数组长度,表示数组未满,则添加元素到数组中,同时通知等待在 notEmpty 上的线程,数组中已经有新元素可以获取。在添加和删除方法中使用 while 循环而非 if 判断,目的是防止过早或意外的通知,只有条件符合才能够退出循环。回想之前提到的等待/通知的经典范式,二者是非常类似的
- Condition 定义了等待/通知两种类型的方法,当前线程调用这些方法时,需要提前获取到 Condition 对象关联的锁。Condition 对象是由 Lock 对象(调用Lock对象的newCondition()方法)创建出来的,换句话说,Condition 是依赖 Lock 对象的
-
Condition 的实现分析
- 介绍
- ConditionObject 是同步器 AbstractQueuedSynchronizer 的内部类
- 等待队列
- 等待队列是一个 FIFO 的队列,在队列中的每个节点都包含了一个线程引用,该线程就是在 Condition 对象上等待的线程,如果一个线程调用了 Condition.await() 方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态。事实上,节点的定义复用了同步器中节点的定义,也就是说,同步队列和等待队列中节点类型都是同步器的静态内部类 AbstractQueuedSynchronizer.Node
- 一个 Condition 包含一个等待队列,Condition 拥有首节点(firstWaiter)和尾节点(lastWaiter)。当前线程调用 Condition.await() 方法,将会以当前线程构造节点,并将节点从尾部加入等待队列
Condition 拥有首尾节点的引用,而新增节点只需要将原有的尾节点 nextWaiter 指向它,并且更新尾节点即可。上述节点引用更新的过程并没有使用 CAS 保证,原因在于调用 await() 方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的 - 在 Object 的监视器模型上,一个对象拥有一个同步队列和等待队列,而并发包中的 Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列
Condition 的实现是同步器的内部类,因此每个 Condition 实例都能够访问同步器提供的方法,相当于每个 Condition 都拥有所属同步器的引用
- 等待
- 调用 Condition 的 await() 方法(或者以 await 开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。当从 await() 方法返回时,当前线程一定获取了 Condition 相关联的锁。如果从队列(同步队列和等待队列)的角度看 await() 方法,当调用 await() 方法时,相当于同步队列的首节点(获取了锁的节点)移动到 Condition 的等待队列中
public final void await() throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); // 当前线程加入等待队列 Node node = addConditionWaiter(); // 释放同步状态,也就是释放锁 int savedState = fullyRelease(node); int interruptMode = 0; while (!isOnSyncQueue(node)) { LockSupport.park(this); if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) break; } if (acquireQueued(node, savedState) && interruptMode != THROW_IE) interruptMode = REINTERRUPT; if (node.nextWaiter != null) unlinkCancelledWaiters(); if (interruptMode != 0) reportInterruptAfterWait(interruptMode); }
- 调用 Condition 的 await() 方法(或者以 await 开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。当从 await() 方法返回时,当前线程一定获取了 Condition 相关联的锁。如果从队列(同步队列和等待队列)的角度看 await() 方法,当调用 await() 方法时,相当于同步队列的首节点(获取了锁的节点)移动到 Condition 的等待队列中
- 通知
- 调用 Condition 的 signal() 方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将节点移到同步队列中
调用该方法的前置条件是当前线程必须获取了锁,可以看到 signal() 方法进行了 isHeldExclusively()查,也就是当前线程必须是获取了锁的线程。接着获取等待队列的首节点,将其移动到同步队列并使用 LockSupport 唤醒节点中的线程public final void signal() { if (!isHeldExclusively()) throw new IllegalMonitorStateException(); Node first = firstWaiter; if (first != null) doSignal(first); }
- 节点从等待队列移动到同步队列的过程
- 通过调用同步器的 enq(Node node) 方法,等待队列中的头节点线程安全地移动到同步队列。当节点移动到同步队列后,当前线程再使用 LockSupport 唤醒该节点的线程。被唤醒后的线程,将从 await() 方法中的 while 循环中退出(isOnSyncQueue(Node node) 方法返回 true,节点已经在同步队列中),进而调用同步器的 acquireQueued() 方法加入到获取同步状态的竞争中。成功获取同步状态(或者说锁)之后,被唤醒的线程将从先前调用的 await() 方法返回,此时该线程已经成功地获取了锁。Condition 的 signalAll() 方法,相当于对等待队列中的每个节点均执行一次 signal() 方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程
- 调用 Condition 的 signal() 方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将节点移到同步队列中
- 介绍
第六章 Java并发容器和框架
ConcurrentHashMap 的实现原理与使用
-
注意
- 书中 ConcurrentHashMap 使用的是jdk 1.7。但 ConcurrentHashMap 在1.8 发生了比较大的变化。这里只是读书笔记,所以参照原书来笔记
-
ConcurrentHashMap 介绍
- ConcurrentHashMap 是线程安全且高效的 HashMap
-
为什么要使用 ConcurrentHashMap
- 线程不安全的 HashMap
- 效率低下的 HashTable
- ConcurrentHashMap 的锁分段技术可有效提升并发访问率
-
ConcurrentHashMap 的结构
- ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。Segment 是一种可重入锁(ReentrantLock),在 ConcurrentHashMap 里扮演锁的角色;HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和 HashMap 类似,是一种数组和链表结构。一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得与它对应的 Segment 锁
-
ConcurrentHashMap 的初始化
- ConcurrentHashMap 初始化方法是通过 initialCapacity、loadFactor 和 concurrencyLevel 等几个参数来初始化 segment 数组、段偏移量 segmentShift、段掩码 segmentMask 和每个 segment 里的 HashEntry 数组来实现的
- 初始化 segments 数组
- segments 数组的长度 ssize 是通过 concurrencyLevel 计算得出的。为了能通过按位与的散列算法来定位 segments 数组的索引,必须保证 segments 数组的长度是 2 的 N 次方(power-of-two size),所以必须计算出一个大于或等于 concurrencyLevel 的最小的 2 的 N 次方值来作为 segments 数组的长度。假如 concurrencyLevel 等于 14、15 或 16,ssize 都会等于 16,即容器里锁的个数也是 16,注意 concurrencyLevel 的最大值是 65535,这意味着 segments 数组的长度最大为 65536,对应的二进制是 16 位
if (concurrencyLevel > MAX_SEGMENTS) concurrencyLevel = MAX_SEGMENTS; int sshift = 0; int ssize = 1; while (ssize < concurrencyLevel) { ++sshift; ssize <<= 1; } segmentShift = 32 - sshift; segmentMask = ssize - 1; this.segments = Segment.newArray(ssize);
- 初始化 segmentShift 和 segmentMask
- 这两个全局变量需要在定位 segment 时的散列算法里使用,sshift 等于 ssize 从 1 向左移位的次数,在默认情况下 concurrencyLevel 等于 16,1 需要向左移位移动 4 次,所以 sshift 等于 4。segmentShift 用于定位参与散列运算的位数,segmentShift 等于 32 减 sshift,所以等于 28,这里之所以用 32 是因为 ConcurrentHashMap 里的 hash() 方法输出的最大数是 32 位的,后面的测试中我们可以看到这点。segmentMask 是散列运算的掩码,等于 ssize 减 1,即 15,掩码的二进制各个位的值都是 1。因为 ssize 的最大长度是 65536,所以 segmentShift 最大值是 16,segmentMask 最大值是 65535,对应的二进制是 16 位,每个位都是 1
- 初始化每个 segment
- 输入参数 initialCapacity 是 ConcurrentHashMap 的初始化容量,loadfactor 是每个 segment 的负载因子,在构造方法里需要通过这两个参数来初始化数组中的每个 segment
if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; int c = initialCapacity / ssize; if (c * ssize < initialCapacity) ++c; int cap = 1; while (cap < c) cap <<= 1; for (int i = 0; i < this.segments.length; ++i) this.segments[i] = new Segment<K,V>(cap, loadFactor);
- 上面代码中的变量 cap 就是 segment 里 HashEntry 数组的长度,它等于 initialCapacity 除以 ssize 的倍数 c,如果 c 大于 1,就会取大于等于 c 的 2 的 N 次方值,所以 cap 不是 1,就是 2 的 N 次方。segment 的容量 threshold=(int)cap*loadFactor,默认情况下initialCapacity 等于 16,loadfactor 等于 0.75,通过运算 cap 等于 1,threshold 等于零
- 输入参数 initialCapacity 是 ConcurrentHashMap 的初始化容量,loadfactor 是每个 segment 的负载因子,在构造方法里需要通过这两个参数来初始化数组中的每个 segment
-
定位 Segment
- 既然 ConcurrentHashMap 使用分段锁 Segment 来保护不同段的数据,那么在插入和获取元素的时候,必须先通过散列算法定位到 Segment。可以看到 ConcurrentHashMap 会首先使用 Wang/Jenkins hash 的变种算法对元素的 hashCode 进行一次再散列,之所以进行再散列,目的是减少散列冲突,使元素能够均匀地分布在不同的 Segment 上,从而提高容器的存取效率。假如散列的质量差到极点,那么所有的元素都在一个 Segment 中,不仅存取元素缓慢,分段锁也会失去意义
private static int hash(int h) { h += (h << 15) ^ 0xffffcd7d; h ^= (h >>> 10); h += (h << 3); h ^= (h >>> 6); h += (h << 2) + (h << 14); return h ^ (h >>> 16); }
- ConcurrentHashMap 通过以下散列算法定位 segment
final Segment<K,V> segmentFor(int hash) { return segments[(hash >>> segmentShift) & segmentMask]; }
- 既然 ConcurrentHashMap 使用分段锁 Segment 来保护不同段的数据,那么在插入和获取元素的时候,必须先通过散列算法定位到 Segment。可以看到 ConcurrentHashMap 会首先使用 Wang/Jenkins hash 的变种算法对元素的 hashCode 进行一次再散列,之所以进行再散列,目的是减少散列冲突,使元素能够均匀地分布在不同的 Segment 上,从而提高容器的存取效率。假如散列的质量差到极点,那么所有的元素都在一个 Segment 中,不仅存取元素缓慢,分段锁也会失去意义
-
ConcurrentHashMap 的操作
- get 操作
- Segment 的 get 操作实现非常简单和高效。先经过一次再散列,然后使用这个散列值通过散列运算定位到 Segment,再通过散列算法定位到元素
public V get(Object key) { int hash = hash(key.hashCode()); return segmentFor(hash).get(key, hash); }
- get 操作的高效之处在于整个 get 过程不需要加锁,除非读到的值是空才会加锁重读。它的 get 方法里将要使用的共享变量都定义成 volatile 类型,如用于统计当前 Segement 大小的 count 字段和用于存储值的 HashEntry 的 value。定义成 volatile 的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值),在 get 操作里只需要读不需要写共享变量 count 和 value,所以可以不用加锁。之所以不会读到过期的值,是因为根据 Java 内存模型的 happen before 原则,对 volatile 字段的写入操作先于读操作,即使两个线程同时修改和获取 volatile 变量,get 操作也能拿到最新的值,这是用 volatile 替换锁的经典应用场景
transient volatile int count; volatile V value;
- 在定位元素的代码里我们可以发现,定位 HashEntry 和定位 Segment 的散列算法虽然一样,都与数组的长度减去 1 再相“与”,但是相“与”的值不一样,定位 Segment 使用的是元素的 hashcode 通过再散列后得到的值的高位,而定位 HashEntry 直接使用的是再散列后的值。其目的是避免两次散列后的值一样,虽然元素在 Segment 里散列开了,但是却没有在 HashEntry 里散列开
hash >>> segmentShift) & segmentMask // 定位Segment所使用的hash算法 int index = hash & (tab.length - 1); // 定位HashEntry所使用的hash算法
- Segment 的 get 操作实现非常简单和高效。先经过一次再散列,然后使用这个散列值通过散列运算定位到 Segment,再通过散列算法定位到元素
- put 操作
- 由于 put 方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须加锁。put 方法首先定位到 Segment,然后在 Segment 里进行插入操作。插入操作需要经历两个步骤,第一步判断是否需要对 Segment 里的 HashEntry 数组进行扩容,第二步定位添加元素的位置,然后将其放在 HashEntry 数组里
- 是否需要扩容。在插入元素前会先判断 Segment 里的 HashEntry 数组是否超过容量(threshold),如果超过阈值,则对数组进行扩容。值得一提的是,Segment 的扩容判断比 HashMap 更恰当,因为 HashMap 是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容之后没有新元素插入,这时 HashMap 就进行了一次无效的扩容
- 如何扩容。在扩容的时候,首先会创建一个容量是原来容量两倍的数组,然后将原数组里的元素进行再散列后插入到新的数组里。为了高效,concurrentHashMap 不会对整个容器进行扩容,而只对某个 segment 进行扩容
- size 操作
- 如果要统计整个 ConcurrentHashMap 里元素的大小,就必须统计所有 Segment 里元素的大小后求和。Segment 里的全局变量 count 是一个 volatile 变量,那么在多线程场景下,是不是直接把所有 Segment 的 count 相加就可以得到整个 ConcurrentHashMap 大小了呢?不是的,虽然相加时可以获取每个 Segment 的 count 的最新值,但是可能累加前使用的 count 发生了变化,那么统计结果就不准了。所以,最安全的做法是在统计 size 的时候把所有 Segment 的 put、remove 和 clean 方法全部锁住,但是这种做法显然非常低效
- 因为在累加 count 操作过程中,之前累加过的 count 发生变化的几率非常小,所以 ConcurrentHashMap 的做法是先尝试 2 次通过不锁住 Segment 的方式来统计各个 Segment 大小,如果统计的过程中,容器的 count 发生了变化,则再采用加锁的方式来统计所有 Segment 的大小
- 那么 ConcurrentHashMap 是如何判断在统计的时候容器是否发生了变化呢?使用 modCount 变量,在 put、remove 和 clean 方法里操作元素前都会将变量 modCount 进行加 1,那么在统计 size 前后比较 modCount 是否发生变化,从而得知容器的大小是否发生变化
- get 操作
ConcurrentLinkedQueue
-
介绍
- 如果要实现一个线程安全的队列有两种方式
- 一种是使用阻塞算法,使用阻塞算法的队列可以用一个锁(入队和出队用同一把锁)或两个锁(入队和出队用不同的锁)等方式来实现
- 一种是使用非阻塞算法。非阻塞的实现方式则可以使用循环 CAS 的方式来实现
- ConcurrentLinkedQueue 是一个基于链接节点的无界线程安全队列,它采用先进先出规则对节点进行排序,当我们添加一个元素的时候,它会添加到队列的尾部;当我们获取一个元素时,它会返回队列头部的元素。它采用了 “wait-free” 算法(即 CAS 算法)来实现,该算法在 Michael&Scott 算法上进行了一些修改
- 如果要实现一个线程安全的队列有两种方式
-
ConcurrentLinkedQueue 的结构
- ConcurrentLinkedQueue 由 head 节点和 tail 节点组成,每个节点(Node)由节点元素(item)和指向下一个节点(next)的引用组成,节点与节点之间就是通过这个 next 关联起来,从而组成一张链表结构的队列。默认情况下 head 节点存储的元素为空,tail 节点等于 head 节点
private transient volatile Node<E> tail = head;
- ConcurrentLinkedQueue 由 head 节点和 tail 节点组成,每个节点(Node)由节点元素(item)和指向下一个节点(next)的引用组成,节点与节点之间就是通过这个 next 关联起来,从而组成一张链表结构的队列。默认情况下 head 节点存储的元素为空,tail 节点等于 head 节点
-
入队列
- 入队列就是将入队节点添加到队列的尾部
- 入队列的过程
- 添加元素1。队列更新 head 节点的 next 节点为元素1节点。又因为 tail 节点默认情况下等于 head 节点,所以它们的 next 节点都指向元素1节点
- 添加元素2。队列首先设置元素1节点的 next 节点为元素2节点,然后更新 tail 节点指向元素2节点
- 添加元素3,设置 tail 节点的 next 节点为元素3节点
- 添加元素4,设置元素3的 next 节点为元素4节点,然后将 tail 节点指向元素4节点
- 整个入队过程主要做两件事情:第一是定位出尾节点;第二是使用 CAS 算法将入队节点设置成尾节点的 next 节点,如不成功则重试
public boolean offer(E e) { if (e == null) throw new NullPointerException(); // 入队前,创建一个入队节点 Node<E> n = new Node<E>(e); retry: // 死循环,入队不成功反复入队。 for (;;) { // 创建一个指向tail节点的引用 Node<E> t = tail; // p用来表示队列的尾节点,默认情况下等于tail节点。 Node<E> p = t; for (int hops = 0; ; hops++) { // 获得p节点的下一个节点。 Node<E> next = succ(p); // next节点不为空,说明p不是尾节点,需要更新p后在将它指向next节点 if (next != null) { // 循环了两次及其以上,并且当前节点还是不等于尾节点 if (hops > HOPS && t != tail) continue retry; p = next; } // 如果p是尾节点,则设置p节点的next节点为入队节点。 else if (p.casNext(null, n)) { // 如果tail节点有大于等于1个next节点,则将入队节点设置成tail节点,更新失败了也没关系,因为失败了表示有其他线程成功更新了tail节点 if (hops >= HOPS) // 更新tail节点,允许失败 casTail(t, n); return true; } // p有next节点,表示p的next节点是尾节点,则重新设置p节点 else { p = succ(p); } } } }
- 定位尾节点
- tail 节点并不总是尾节点,所以每次入队都必须先通过 tail 节点来找到尾节点。尾节点可能是 tail 节点,也可能是 tail 节点的 next 节点。代码中循环体中的第一个 if 就是判断 tail 是否有 next 节点,有则表示 next 节点可能是尾节点。获取 tail 节点的 next 节点需要注意的是 p 节点等于 p 的 next 节点的情况,只有一种可能就是 p 节点和 p 的 next 节点都等于空,表示这个队列刚初始化,正准备添加节点,所以需要返回 head 节点。获取 p 节点的 next 节点代码如下
final Node<E> succ(Node<E> p) { Node<E> next = p.getNext(); return (p == next) head : next; }
- tail 节点并不总是尾节点,所以每次入队都必须先通过 tail 节点来找到尾节点。尾节点可能是 tail 节点,也可能是 tail 节点的 next 节点。代码中循环体中的第一个 if 就是判断 tail 是否有 next 节点,有则表示 next 节点可能是尾节点。获取 tail 节点的 next 节点需要注意的是 p 节点等于 p 的 next 节点的情况,只有一种可能就是 p 节点和 p 的 next 节点都等于空,表示这个队列刚初始化,正准备添加节点,所以需要返回 head 节点。获取 p 节点的 next 节点代码如下
- 设置入队节点为尾节点
- p.casNext(null,n) 方法用于将入队节点设置为当前队列尾节点的 next 节点,如果 p 是 null,表示 p 是当前队列的尾节点,如果不为 null,表示有其他线程更新了尾节点,则需要重新获取当前队列的尾节点
-
出队列
- 出队列的就是从队列里返回一个节点元素,并清空该节点对元素的引用
- 并不是每次出队时都更新 head 节点,当 head 节点里有元素时,直接弹出 head 节点里的元素,而不会更新 head 节点。只有当 head 节点里没有元素时,出队操作才会更新 head 节点。这种做法也是通过 hops 变量来减少使用 CAS 更新 head 节点的消耗,从而提高出队效率
- 首先获取头节点的元素,然后判断头节点元素是否为空,如果为空,表示另外一个线程已经进行了一次出队操作将该节点的元素取走,如果不为空,则使用 CAS 的方式将头节点的引用设置成 null,如果 CAS 成功,则直接返回头节点的元素,如果不成功,表示另外一个线程已经进行了一次出队操作更新了 head 节点,导致元素发生了变化,需要重新获取头节点
public E poll() { Node<E> h = head; // p表示头节点,需要出队的节点 Node<E> p = h; for (int hops = 0;; hops++) { // 获取p节点的元素 E item = p.getItem(); // 如果p节点的元素不为空,使用CAS设置p节点引用的元素为null, // 如果成功则返回p节点的元素。 if (item != null && p.casItem(item, null)) { if (hops >= HOPS) { // 将p节点下一个节点设置成head节点 Node<E> q = p.getNext(); updateHead(h, (q != null) q : p); } return item; } // 如果头节点的元素为空或头节点发生了变化,这说明头节点已经被另外一个线程修改了。那么获取p节点的下一个节点 Node<E> next = succ(p); // 如果p的下一个节点也为空,说明这个队列已经空了 if (next == null) { // 更新头节点。 updateHead(h, p); break; } // 如果下一个元素不为空,则将头节点的下一个节点设置成头节点 p = next; } return null; }
Java 中的阻塞队列
-
什么是阻塞队列
- 阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入和移除方法。阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器
- 支持阻塞的插入方法:意思是当队列满时,队列会阻塞插入元素的线程,直到队列不满
- 支持阻塞的移除方法:意思是在队列为空时,获取元素的线程会等待队列变为非空
- 在阻塞队列不可用时,这两个附加操作提供了4种处理方式
方法/处理方式 抛出异常 返回特殊值 一直阻塞 超时退出 插入方法 add(e) offer(e) put(e) offer(e,time,unit) 移除方法 remove() poll() take() poll(time,unit) 检查方法 element() peek() 不可用 不可用 - 抛出异常:当队列满时,如果再往队列里插入元素,会抛出 IllegalStateException(“Queue full”) 异常。当队列空时,从队列里获取元素会抛出 NoSuchElementException 异常
- 返回特殊值:当往队列插入元素时,会返回元素是否插入成功,成功返回 true。如果是移除方法,则是从队列里取出一个元素,如果没有则返回 null
- 一直阻塞:当阻塞队列满时,如果生产者线程往队列里 put 元素,队列会一直阻塞生产者线程,直到队列可用或者响应中断退出。当队列空时,如果消费者线程从队列里 take 元素,队列会阻塞住消费者线程,直到队列不为空
- 超时退出:当阻塞队列满时,如果生产者线程往队列里插入元素,队列会阻塞生产者线程一段时间,如果超过了指定的时间,生产者线程就会退出
- 注意 如果是无界阻塞队列,队列不可能会出现满的情况,所以使用 put 或 offer 方法永远不会被阻塞,而且使用 offer 方法时,该方法永远返回 true
- 阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入和移除方法。阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器
-
Java 里的阻塞队列
- JDK 7 提供了 7 个阻塞队列,如下:
- ArrayBlockingQueue:底层实现数组、先入先出、有界队列,构造是需指定数组长度且不可变,ReentrantLock、Condition 实现线程安全
- LinkedBlockingQueue:底层实现链表,先入先出、无界队列,ReentrantLock、Condition 实现线程安全
- PriorityBlockingQueue:底层数组实现二叉堆,数组可变,所以是支持优先级无界阻塞队列,ReentrantLock、Condition实现线程安全
- DelayQueue:底层数据是 PriorityQueue(无锁无阻塞无界优先级队列),ReentrantLock、Condition 实现线程安全,保存元素必须实现 Delayed 接口,可以指定元素出队时间
- SynchronousQueue:没有容量,不管是 take 还是 put 进来的线程,如果没有匹配就阻塞,等待异类线程交换数据并唤醒,支持公平与非公平模式,无锁通过 CAS 实现
- LinkedTransferQueue:链表实现无界阻塞队列,put 方法不阻塞,take 方法先进可以占位置,后面的 put 会先给到它,transfer 方法与 SynchronousQueue 的公平模式一样,无锁通过 CAS 实现
- LinkedBlockingDeque:双向链表、无界阻塞队列,可实现先入先出、先入后出、优先进出,ReentrantLock、Condition 实现线程安全
- JDK 7 提供了 7 个阻塞队列,如下:
-
ArrayBlockingQueue
- ArrayBlockingQueue 是一个用数组实现的有界阻塞队列。此队列按照先进先出(FIFO)的原则对元素进行排序。默认情况下不保证线程公平的访问队列,所谓公平访问队列是指阻塞的线程,可以按照阻塞的先后顺序访问队列,即先阻塞线程先访问队列。非公平性是对先等待的线程是非公平的,当队列可用时,阻塞的线程都可以争夺访问队列的资格,有可能先阻塞的线程最后才访问队列。为了保证公平性,通常会降低吞吐量。我们可以使用以下代码创建一个公平的阻塞队列
ArrayBlockingQueue fairQueue = new ArrayBlockingQueue(1000,true); 访问者的公平性是使用可重入锁实现的,代码如下。 public ArrayBlockingQueue(int capacity, boolean fair) { if (capacity <= 0) throw new IllegalArgumentException(); this.items = new Object[capacity]; lock = new ReentrantLock(fair); notEmpty = lock.newCondition(); notFull = lock.newCondition(); }
- ArrayBlockingQueue 是一个用数组实现的有界阻塞队列。此队列按照先进先出(FIFO)的原则对元素进行排序。默认情况下不保证线程公平的访问队列,所谓公平访问队列是指阻塞的线程,可以按照阻塞的先后顺序访问队列,即先阻塞线程先访问队列。非公平性是对先等待的线程是非公平的,当队列可用时,阻塞的线程都可以争夺访问队列的资格,有可能先阻塞的线程最后才访问队列。为了保证公平性,通常会降低吞吐量。我们可以使用以下代码创建一个公平的阻塞队列
-
LinkedBlockingQueue
- LinkedBlockingQueue 是一个用链表实现的有界阻塞队列。此队列的默认和最大长度为 Integer.MAX_VALUE。此队列按照先进先出的原则对元素进行排序
-
PriorityBlockingQueue
- PriorityBlockingQueue 是一个支持优先级的无界阻塞队列。默认情况下元素采取自然顺序升序排列。也可以自定义类实现compareTo()方法来指定元素排序规则,或者初始化PriorityBlockingQueue时,指定构造参数Comparator来对元素进行排序。需要注意的是不能保证同优先级元素的顺序
-
DelayQueue
- DelayQueue 是一个支持延时获取元素的无界阻塞队列。队列使用 PriorityQueue 来实现。队列中的元素必须实现 Delayed 接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能从队列中提取元素。DelayQueue 非常有用,可以将 DelayQueue 运用在以下应用场景
- 缓存系统的设计:可以用 DelayQueue 保存缓存元素的有效期,使用一个线程循环查询 DelayQueue,一旦能从 DelayQueue 中获取元素时,表示缓存有效期到了
- 定时任务调度:使用 DelayQueue 保存当天将会执行的任务和执行时间,一旦从 DelayQueue 中获取到任务就开始执行,比如 TimerQueue 就是使用 DelayQueue 实现的
- 如何实现 Delayed 接口
- DelayQueue 队列的元素必须实现 Delayed 接口。我们可以参考 ScheduledThreadPoolExecutor 里 ScheduledFutureTask 类的实现,一共有三步
- 第一步:在对象创建的时候,初始化基本数据。使用 time 记录当前对象延迟到什么时候可以使用,使用 sequenceNumber 来标识元素在队列中的先后顺序。代码如下
private static final AtomicLong sequencer = new AtomicLong(0); ScheduledFutureTask(Runnable r, V result, long ns, long period) { super(r, result); this.time = ns; this.period = period; this.sequenceNumber = sequencer.getAndIncrement(); }
- 第二步:实现 getDelay 方法,该方法返回当前元素还需要延时多长时间,单位是纳秒,代码如下:通过构造函数可以看出延迟时间参数 ns 的单位是纳秒,自己设计的时候最好使用纳秒,因为实现 getDelay() 方法时可以指定任意单位,一旦以秒或分作为单位,而延时时间又精确不到纳秒就麻烦了。使用时请注意当 time 小于当前时间时,getDelay 会返回负数
public long getDelay(TimeUnit unit) { return unit.convert(time - now(), TimeUnit.NANOSECONDS); }
- 第三步:实现 compareTo 方法来指定元素的顺序。例如,让延时时间最长的放在队列的末尾。实现代码如下。
public int compareTo(Delayed other) { if (other == this) // compare zero ONLY if same object return 0; if (other instanceof ScheduledFutureTask) { ScheduledFutureTask<> x = (ScheduledFutureTask<>)other; long diff = time - x.time; if (diff < 0) return -1; else if (diff > 0) return 1; else if (sequenceNumber < x.sequenceNumber) return -1; else return 1; } long d = (getDelay(TimeUnit.NANOSECONDS) - other.getDelay(TimeUnit.NANOSECONDS)); return (d == 0) 0 : ((d < 0) -1 : 1); }
- 如何实现延时阻塞队列
- 延时阻塞队列的实现很简单,当消费者从队列里获取元素时,如果元素没有达到延时时间,就阻塞当前线程
- 代码中的变量 leader 是一个等待获取队列头部元素的线程。如果 leader 不等于空,表示已经有线程在等待获取队列的头元素。所以,使用 await() 方法让当前线程等待信号。如果 leader 等于空,则把当前线程设置成 leader,并使用 awaitNanos() 方法让当前线程等待接收信号或等待 delay 时间
long delay = first.getDelay(TimeUnit.NANOSECONDS); if (delay <= 0) return q.poll(); else if (leader != null) available.await(); else { Thread thisThread = Thread.currentThread(); leader = thisThread; try { available.awaitNanos(delay); } finally { if (leader == thisThread) leader = null; } }
- DelayQueue 是一个支持延时获取元素的无界阻塞队列。队列使用 PriorityQueue 来实现。队列中的元素必须实现 Delayed 接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能从队列中提取元素。DelayQueue 非常有用,可以将 DelayQueue 运用在以下应用场景
-
SynchronousQueue
- SynchronousQueue 是一个不存储元素的阻塞队列。每一个 put 操作必须等待一个 take 操作,否则不能继续添加元素。它支持公平访问队列。默认情况下线程采用非公平性策略访问队列。使用以下构造方法可以创建公平性访问的 SynchronousQueue,如果设置为 true,则等待的线程会采用先进先出的顺序访问队列
- SynchronousQueue 可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费者线程。队列本身并不存储任何元素,非常适合传递性场景。SynchronousQueue 的吞吐量高于LinkedBlockingQueue 和 ArrayBlockingQueue
public SynchronousQueue(boolean fair) { transferer = fair new TransferQueue() : new TransferStack(); }
-
LinkedTransferQueue
- LinkedTransferQueue 是一个由链表结构组成的无界阻塞 TransferQueue 队列。相对于其他阻塞队列,LinkedTransferQueue 多了 tryTransfer 和 transfer 方法
- transfer方法
- 如果当前有消费者正在等待接收元素(消费者使用 take() 方法或带时间限制的 poll() 方法时),transfer 方法可以把生产者传入的元素立刻 transfer(传输)给消费者。如果没有消费者在等待接收元素,transfer 方法会将元素存放在队列的 tail 节点,并等到该元素被消费者消费了才返回。transfer 方法的关键代码如下
- 第一行代码是试图把存放当前元素的s节点作为 tail 节点。第二行代码是让 CPU 自旋等待消费者消费元素。因为自旋会消耗 CPU,所以自旋一定的次数后使用 Thread.yield() 方法来暂停当前正在执行的线程,并执行其他线程
Node pred = tryAppend(s, haveData); return awaitMatch(s, pred, e, (how == TIMED), nanos);
- tryTransfer 方法
- tryTransfer 方法是用来试探生产者传入的元素是否能直接传给消费者。如果没有消费者等待接收元素,则返回 false。和 transfer 方法的区别是 tryTransfer 方法无论消费者是否接收,方法立即返回,而 transfer 方法是必须等到消费者消费了才返回。对于带有时间限制的 tryTransfer(E e,long timeout,TimeUnit unit)方法,试图把生产者传入的元素直接传给消费者,但是如果没有消费者消费该元素则等待指定的时间再返回,如果超时还没消费元素,则返回 false,如果在超时时间内消费了元素,则返回 true
-
LinkedBlockingDeque
- LinkedBlockingDeque 是一个由链表结构组成的双向阻塞队列。所谓双向队列指的是可以从队列的两端插入和移出元素。双向队列因为多了一个操作队列的入口,在多线程同时入队时,也就减少了一半的竞争。相比其他的阻塞队列,LinkedBlockingDeque 多了 addFirst、addLast、offerFirst、offerLast、peekFirst 和 peekLast 等方法,以 First 单词结尾的方法,表示插入、获取(peek)或移除双端队列的第一个元素。以 Last 单词结尾的方法,表示插入、获取或移除双端队列的最后一个元素。另外,插入方法 add 等同于 addLast,移除方法 remove 等效于 removeFirst。但是 take 方法却等同于 takeFirst,不知道是不是 JDK 的 bug,使用时还是用带有 First 和 Last 后缀的方法更清楚。在初始化 LinkedBlockingDeque 时可以设置容量防止其过度膨胀。另外,双向阻塞队列可以运用在“工作窃取”模式中
-
阻塞队列的实现原理
- 通知模式实现。所谓通知模式,就是当生产者往满的队列里添加元素时会阻塞住生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用
private final Condition notFull; private final Condition notEmpty; public ArrayBlockingQueue(int capacity, boolean fair) { // 省略其他代码 notEmpty = lock.newCondition(); notFull = lock.newCondition(); } public void put(E e) throws InterruptedException { checkNotNull(e); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == items.length) notFull.await(); insert(e); } finally { lock.unlock(); } } public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == 0) notEmpty.await(); return extract(); } finally { lock.unlock(); } } private void insert(E x) { items[putIndex] = x; putIndex = inc(putIndex); ++count; notEmpty.signal(); }
- 当往队列里插入一个元素时,如果队列不可用,那么阻塞生产者主要通过 LockSupport.park(this) 来实现
public final void await() throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); Node node = addConditionWaiter(); int savedState = fullyRelease(node); int interruptMode = 0; while (!isOnSyncQueue(node)) { LockSupport.park(this); if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) break; } if (acquireQueued(node, savedState) && interruptMode != THROW_IE) interruptMode = REINTERRUPT; if (node.nextWaiter != null) // clean up if cancelled unlinkCancelledWaiters(); if (interruptMode != 0) reportInterruptAfterWait(interruptMode); }
- 继续进入源码,发现调用 setBlocker 先保存一下将要阻塞的线程,然后调用 unsafe.park 阻塞当前线程
public static void park(Object blocker) { Thread t = Thread.currentThread(); setBlocker(t, blocker); unsafe.park(false, 0L); setBlocker(t, null); }
- unsafe.park 是个 native 方法,代码如下。
public native void park(boolean isAbsolute, long time);
- park这个方法会阻塞当前线程,只有以下4种情况中的一种发生时,该方法才会返回
- 与 park 对应的 unpark 执行或已经执行时。“已经执行”是指 unpark 先执行,然后再执行 park 的情况
- 线程被中断时
- 等待完 time 参数指定的毫秒数时
- 异常现象发生时,这个异常现象没有任何原因
- park 在不同的操作系统中使用不同的方式实现
- 通知模式实现。所谓通知模式,就是当生产者往满的队列里添加元素时会阻塞住生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用
Fork/Join 框架
-
什么是 Fork/Join 框架
- Fork/Join 框架是 Java 7 提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架
- Fork 就是把一个大任务切分为若干子任务并行的执行
- Join 就是合并这些子任务的执行结果
-
工作窃取算法
- 工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行。例如建立多个线程,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。可以加快任务执行效率
- 工作窃取算法的优点:充分利用线程进行并行计算,减少了线程间的竞争
- 工作窃取算法的缺点:在某些情况下还是存在竞争,比如双端队列里只有一个任务时。并且该算法会消耗了更多的系统资源,比如创建多个线程和多个双端队列
-
Fork/Join 框架的设计
- Fork/Join 框架的设计
- 分割任务。首先我们需要有一个 fork 类来把大任务分割成子任务,有可能子任务还是很大,所以还需要不停地分割,直到分割出的子任务足够小
- 执行任务并合并结果。分割的子任务分别放在双端队列里,然后几个启动线程分别从双端队列里获取任务执行。子任务执行完的结果都统一放在一个队列里,启动一个线程从队列里拿数据,然后合并这些数据
- Fork/Join 使用的两个类
- ForkJoinTask:我们要使用 ForkJoin 框架,必须首先创建一个 ForkJoin 任务。它提供在任务中执行 fork() 和 join() 操作的机制。通常情况下,我们不需要直接继承 ForkJoinTask 类,只需要继承它的子类,Fork/Join 框架提供了以下两个子类
- RecursiveAction:用于没有返回结果的任务
- RecursiveTask:用于有返回结果的任务
- ForkJoinPool:ForkJoinTask 需要通过 ForkJoinPool 来执行
- ForkJoinTask:我们要使用 ForkJoin 框架,必须首先创建一个 ForkJoin 任务。它提供在任务中执行 fork() 和 join() 操作的机制。通常情况下,我们不需要直接继承 ForkJoinTask 类,只需要继承它的子类,Fork/Join 框架提供了以下两个子类
- 任务分割出的子任务会添加到当前工作线程所维护的双端队列中,进入队列的头部。当一个工作线程的队列里暂时没有任务时,它会随机从其他工作线程的队列的尾部获取一个任务
- Fork/Join 框架的设计
-
使用 Fork/Join 框架
package fj; import java.util.concurrent.ExecutionException; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.Future; import java.util.concurrent.RecursiveTask; public class CountTask extends RecursiveTask<Integer> { private static final int THRESHOLD = 2; // 阈值 private int start; private int end; public CountTask(int start, int end) { this.start = start; this.end = end; } @Override protected Integer compute() { int sum = 0; // 如果任务足够小就计算任务 boolean canCompute = (end - start) <= THRESHOLD; if (canCompute) { for (int i = start; i <= end; i++) { sum += i; } } else { // 如果任务大于阈值,就分裂成两个子任务计算 int middle = (start + end) / 2; CountTask leftTask = new CountTask(start, middle); CountTask rightTask = new CountTask(middle + 1, end); // 执行子任务 leftTask.fork(); rightTask.fork(); // 等待子任务执行完,并得到其结果 int leftResult=leftTask.join(); int rightResult=rightTask.join(); // 合并子任务 sum = leftResult + rightResult; } return sum; } public static void main(String[] args) { ForkJoinPool forkJoinPool = new ForkJoinPool(); // 生成一个计算任务,负责计算1+2+3+4 CountTask task = new CountTask(1, 4); // 执行一个任务 Future<Integer> result = forkJoinPool.submit(task); try { System.out.println(result.get()); } catch (InterruptedException e) { } catch (ExecutionException e) { } } }
-
Fork/Join 框架的异常处理
- ForkJoinTask 在执行的时候可能会抛出异常,但是我们没办法在主线程里直接捕获异常,所以 ForkJoinTask 提供了 isCompletedAbnormally() 方法来检查任务是否已经抛出异常或已经被取消了,并且可以通过 ForkJoinTask 的 getException 方法获取异常。使用如下代码
- getException 方法返回 Throwable 对象,如果任务被取消了则返回 CancellationException。如果任务没有完成或者没有抛出异常则返回 null
if(task.isCompletedAbnormally()){ System.out.println(task.getException()); }
-
Fork/Join 框架的实现原理
- ForkJoinPool 由 ForkJoinTask 数组和 ForkJoinWorkerThread 数组组成,ForkJoinTask 数组负责将存放程序提交给 ForkJoinPool 的任务,而 ForkJoinWorkerThread 数组负责执行这些任务
- ForkJoinTask 的 fork 方法实现原理
- 当我们调用 ForkJoinTask 的 fork 方法时,程序会调用 ForkJoinWorkerThread 的 pushTask 方法异步地执行这个任务,然后立即返回结果
public final ForkJoinTask<V> fork() { ((ForkJoinWorkerThread) Thread.currentThread()).pushTask(this); return this; }
- pushTask 方法把当前任务存放在 ForkJoinTask 数组队列里。然后再调用 ForkJoinPool 的 signalWork() 方法唤醒或创建一个工作线程来执行任务
final void pushTask(ForkJoinTask<> t) { ForkJoinTask<>[] q; int s, m; if ((q = queue) != null) { // ignore if queue removed long u = (((s = queueTop) & (m = q.length - 1)) << ASHIFT) + ABASE; UNSAFE.putOrderedObject(q, u, t); // or use putOrderedInt queueTop = s + 1; if ((s -= queueBase) <= 2) pool.signalWork(); else if (s == m) growQueue(); } }
- 当我们调用 ForkJoinTask 的 fork 方法时,程序会调用 ForkJoinWorkerThread 的 pushTask 方法异步地执行这个任务,然后立即返回结果
- ForkJoinTask 的 join 方法实现原理
- Join 方法的主要作用是阻塞当前线程并等待获取结果。让我们一起看看 ForkJoinTask 的 join 方法的实现
public final V join() { if (doJoin() != NORMAL) return reportResult(); else return getRawResult(); } private V reportResult() { int s; Throwable ex; if ((s = status) == CANCELLED) throw new CancellationException(); if (s == EXCEPTIONAL && (ex = getThrowableException()) != null) UNSAFE.throwException(ex); return getRawResult(); }
- 首先,它调用了 doJoin() 方法,通过 doJoin() 方法得到当前任务的状态来判断返回什么结果,任务状态有4种:已完成(NORMAL)、被取消(CANCELLED)、信号(SIGNAL)和出现异常(EXCEPTIONAL)
- 如果任务状态是已完成,则直接返回任务结果
- 如果任务状态是被取消,则直接抛出 CancellationException
- 如果任务状态是抛出异常,则直接抛出对应的异常
- 在 doJoin() 方法里,首先通过查看任务的状态,看任务是否已经执行完成,如果执行完成,则直接返回任务状态;如果没有执行完,则从任务数组里取出任务并执行。如果任务顺利执行完成,则设置任务状态为 NORMAL,如果出现异常,则记录异常,并将任务状态设置为 EXCEPTIONAL
private int doJoin() { Thread t; ForkJoinWorkerThread w; int s; boolean completed; if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) { if ((s = status) < 0) return s; if ((w = (ForkJoinWorkerThread)t).unpushTask(this)) { try { completed = exec(); } catch (Throwable rex) { return setExceptionalCompletion(rex); } if (completed) return setCompletion(NORMAL); } return w.joinTask(this); } else return externalAwaitDone(); }
- Join 方法的主要作用是阻塞当前线程并等待获取结果。让我们一起看看 ForkJoinTask 的 join 方法的实现
第七章 Java中的13个原子操作类
原子更新基本类型类
-
原子更新基本类型,Atomic 包提供了以下 3 个类
- AtomicBoolean:原子更新布尔类型
- AtomicInteger:原子更新整型
- AtomicLong:原子更新长整型
-
AtomicInteger 的常用方法
- int addAndGet(int delta):以原子方式将输入的数值与实例中的值(AtomicInteger里的value)相加,并返回结果
- boolean compareAndSet(int expect,int update):如果输入的数值等于预期值,则以原子方式将该值设置为输入的值
- int getAndIncrement():以原子方式将当前值加1,注意,这里返回的是自增前的值
- void lazySet(int newValue):最终会设置成 newValue,使用 lazySet 设置值后,可能导致其他线程在之后的一小段时间内还是可以读到旧的值
- int getAndSet(int newValue):以原子方式设置为 newValue 的值,并返回旧值
- private volatile int value; volatile 标记的属性
-
示例
public class AtomicIntegerTest { static AtomicInteger ai = new AtomicInteger(1); public static void main(String[] args) { System.out.println(ai.getAndIncrement()); System.out.println(ai.get()); } }
-
getAndIncrement() 解析
- 源码中 for 循环体的第一步先取得 AtomicInteger 里存储的数值,第二步对 AtomicInteger 的当前数值进行加 1 操作,关键的第三步调用 compareAndSet 方法来进行原子更新操作,该方法先检查当前数值是否等于 current,等于意味着 AtomicInteger 的值没有被其他线程修改过,则将 AtomicInteger 的当前数值更新成 next 的值,如果不等 compareAndSet 方法会返回 false,程序会进入 for 循环重新进行 compareAndSet 操作
- jdk1.8 使用 do…while 做的循环,效果是一样的
-
Unsafe 只提供了 3 种 CAS 方法:compareAndSwapObject、compare-AndSwapInt 和 compareAndSwapLong,再看 AtomicBoolean 源码,发现它是先把 Boolean 转换成整型,再使用compareAndSwapInt 进行 CAS,所以原子更新 char、float 和 double 变量也可以用类似的思路来实现
public final int getAndIncrement() { for (;;) { int current = get(); int next = current + 1; if (compareAndSet(current, next)) return current; } } public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update); }
原子更新数组
-
通过原子的方式更新数组里的某个元素,Atomic 包提供了以下 3 个类
- AtomicIntegerArray:原子更新整型数组里的元素
- AtomicLongArray:原子更新长整型数组里的元素
- AtomicReferenceArray:原子更新引用类型数组里的元素
-
AtomicIntegerArray 类主要是提供原子的方式更新数组里的整型
- int addAndGet(int i,int delta):以原子方式将输入值与数组中索引 i 的元素相加
- boolean compareAndSet(int i,int expect,int update):如果当前值等于预期值,则以原子方式将数组位置i的元素设置成 update 值
-
示例
public class AtomicIntegerArrayTest { static int[] value = new int[] { 1, 2 }; static AtomicIntegerArray ai = new AtomicIntegerArray(value); public static void main(String[] args) { ai.getAndSet(0, 3); System.out.println(ai.get(0)); System.out.println(value[0]); } }
-
数组 value 通过构造方法传递进去,然后 AtomicIntegerArray 会将当前数组复制一份,所以当 AtomicIntegerArray 对内部的数组元素进行修改时,不会影响传入的数组
原子更新引用类型
-
原子更新基本类型的 AtomicInteger,只能更新一个变量,如果要原子更新多个变量,就需要使用这个原子更新引用类型提供的类。Atomic 包提供了以下 3 个类
- AtomicReference:原子更新引用类型
- AtomicReferenceFieldUpdater:原子更新引用类型里的字段
- AtomicMarkableReference:原子更新带有标记位的引用类型。可以原子更新一个布尔类型的标记位和引用类型。构造方法是 AtomicMarkableReference(V initialRef,boolean initialMark)
-
示例
public class AtomicReferenceTest { public static AtomicReference<user> atomicUserRef = new AtomicReference<user>(); public static void main(String[] args) { User user = new User("conan", 15); atomicUserRef.set(user); User updateUser = new User("Shinichi", 17); atomicUserRef.compareAndSet(user, updateUser); System.out.println(atomicUserRef.get().getName()); System.out.println(atomicUserRef.get().getOld()); } static class User { private String name; private int old; public User(String name, int old) { this.name = name; this.old = old; } public String getName() { return name; } public int getOld() { return old; } } }
-
代码中首先构建一个 user 对象,然后把 user 对象设置进 AtomicReferenc 中,最后调用 compareAndSet 方法进行原子更新操作,实现原理同 AtomicInteger 里的 compareAndSet 方法
原子更新字段类
-
如果需原子地更新某个类里的某个字段时,就需要使用原子更新字段类,Atomic 包提供了以下 3 个类进行原子字段更新
- AtomicIntegerFieldUpdater:原子更新整型的字段的更新器
- AtomicLongFieldUpdater:原子更新长整型字段的更新器
- AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题
-
要想原子地更新字段类需要两步。第一步,因为原子更新字段类都是抽象类,每次使用的时候必须使用静态方法 newUpdater() 创建一个更新器,并且需要设置想要更新的类和属性。第二步,更新类的字段(属性)必须使用 public volatile 修饰符
-
示例
public class AtomicIntegerFieldUpdaterTest { // 创建原子更新器,并设置需要更新的对象类和对象的属性 private static AtomicIntegerFieldUpdater<User> a = AtomicIntegerFieldUpdater. newUpdater(User.class, "old"); public static void main(String[] args) { // 设置柯南的年龄是10岁 User conan = new User("conan", 10); // 柯南长了一岁,但是仍然会输出旧的年龄 System.out.println(a.getAndIncrement(conan)); // 输出柯南现在的年龄 System.out.println(a.get(conan)); } public static class User { private String name; public volatile int old; public User(String name, int old) { this.name = name; this.old = old; } public String getName() { return name; } public int getOld() { return old; } } }
第八章 Java中的并发工具类
等待多线程完成的 CountDownLatch
-
CountDownLatch 概述
- CountDownLatch 是一个同步工具类,用来协调多个线程之间的同步,或者说起到线程之间的通信(而不是用作互斥的作用)。CountDownLatch 能够使一个线程在等待另外一些线程完成各自工作之后,再继续执行
-
CountDownLatch 实现方式
- CountDownLatch 的构造函数接收一个 int 类型的参数作为计数器,如果你想等待 N 个点完成,这里就传入 N。当我们调用 CountDownLatch 的 countDown 方法时,N 就会减 1,CountDownLatch 的 await 方法会阻塞当前线程,直到 N 变成零。由于 countDown 方法可以用在任何地方,所以这里说的 N 个点,可以是 N 个线程,也可以是 1 个线程里的 N 个执行步骤。用在多个线程时,只需要把这个 CountDownLatch 的引用传递到线程里即可
-
CountDownLatch 应用场景
- 典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行
- 如果有某个解析 sheet 的线程处理得比较慢,我们不可能让主线程一直等待,所以可以使用另外一个带指定时间的 await 方法——await(long time,TimeUnit unit),这个方法等待特定时间后,就会不再阻塞当前线程。join 也有类似的方法
- 注意 计数器必须大于等于 0,只是等于 0 时候,计数器就是零,调用 await 方法时不会阻塞当前线程。CountDownLatch 不可能重新初始化或者修改 CountDownLatch 对象的内部计数器的值。一个线程调用 countDown 方法 happen-before,另外一个线程调用 await 方法
-
示例
public class CountDownLatchTest { staticCountDownLatch c = new CountDownLatch(2); public static void main(String[] args) throws InterruptedException { new Thread(new Runnable() { @Override public void run() { System.out.println(1); c.countDown(); System.out.println(2); c.countDown(); } }).start(); c.await(); System.out.println("3"); } }
同步屏障 CyclicBarrier
-
CyclicBarrier 概述
- CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行
-
CyclicBarrier 简介
- CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。如果把 new CyclicBarrier(2) 修改成 new CyclicBarrier(3),则主线程和子线程会永远等待,因为没有第三个线程执行 await 方法,所以之前到达屏障的两个线程都不会继续执行
-
示例一
public class CyclicBarrierTest { staticCyclicBarrier c = new CyclicBarrier(2); public static void main(String[] args) { new Thread(new Runnable() { @Override public void run() { try { c.await(); } catch (Exception e) { } System.out.println(1); } }).start(); try { c.await(); } catch (Exception e) { } System.out.println(2); } } // 输出 1 2 或2 1 // 因为主线程和子线程的调度是由CPU决定的,两个线程都有可能先执行,所以会产生两种输出
-
示例二
- CyclicBarrier 还提供一个更高级的构造函数 CyclicBarrier(int parties,Runnable barrierAction),用于在线程到达屏障时,优先执行 barrierAction,方便处理更复杂的业务场景。因为 CyclicBarrier 设置了拦截线程的数量是 2,所以必须等代码中的第一个线程和线程 A 都执行完之后,才会继续执行主线程,然后输出 2
import java.util.concurrent.CyclicBarrier; public class CyclicBarrierTest2 { static CyclicBarrier c = new CyclicBarrier(2, new A()); public static void main(String[] args) { new Thread(new Runnable() { @Override public void run() { try { c.await(); } catch (Exception e) { } System.out.println(1); } }).start(); try { c.await(); } catch (Exception e) { } System.out.println(2); } static class A implements Runnable { @Override public void run() { System.out.println(3); } } } // 输出 3 1 2
- CyclicBarrier 还提供一个更高级的构造函数 CyclicBarrier(int parties,Runnable barrierAction),用于在线程到达屏障时,优先执行 barrierAction,方便处理更复杂的业务场景。因为 CyclicBarrier 设置了拦截线程的数量是 2,所以必须等代码中的第一个线程和线程 A 都执行完之后,才会继续执行主线程,然后输出 2
-
CyclicBarrier 的应用场景
- CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的场景。例如,用一个 Excel 保存了用户所有银行流水,每个 Sheet 保存一个账户近一年的每笔银行流水,现在需要统计用户的日均银行流水,先用多线程处理每个 sheet 里的银行流水,都执行完之后,得到每个 sheet 的日均银行流水,最后,再用 barrierAction 用这些线程的计算结果,计算出整个 Excel 的日均银行流水
publicclass BankWaterService implements Runnable { /** * 创建4个屏障,处理完之后执行当前类的run方法 */ private CyclicBarrier c = new CyclicBarrier(4, this); /** * 假设只有4个sheet,所以只启动4个线程 */ private Executor executor = Executors.newFixedThreadPool(4); /** * 保存每个sheet计算出的银流结果 */ private ConcurrentHashMap<String, Integer>sheetBankWaterCount = new ConcurrentHashMap<String, Integer>(); privatevoid count() { for (inti = 0; i< 4; i++) { executor.execute(new Runnable() { @Override publicvoid run() { // 计算当前sheet的银流数据,计算代码省略 sheetBankWaterCount.put(Thread.currentThread().getName(), 1); // 银流计算完成,插入一个屏障 try { c.await(); } catch (InterruptedException | BrokenBarrierException e) { e.printStackTrace(); } } }); } } @Override publicvoid run() { intresult = 0; // 汇总每个sheet计算出的结果 for (Entry<String, Integer>sheet : sheetBankWaterCount.entrySet()) { result += sheet.getValue(); } // 将结果输出 sheetBankWaterCount.put("result", result); System.out.println(result); } publicstaticvoid main(String[] args) { BankWaterService bankWaterCount = new BankWaterService(); bankWaterCount.count(); } } //输出 4
-
CyclicBarrier 和 CountDownLatch 的区别
- CountDownLatch 的计数器只能使用一次,而 CyclicBarrier 的计数器可以使用 reset() 方法重置。所以 CyclicBarrier 能处理更为复杂的业务场景。例如,如果计算发生错误,可以重置计数器,并让线程重新执行一次
- CyclicBarrier 还提供其他有用的方法,比如 getNumberWaiting 方法可以获得 CyclicBarrier 阻塞的线程数量。isBroken() 方法用来了解阻塞的线程是否被中断
控制并发线程数的 Semaphore
-
Semaphore 概述
- Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源
-
Semaphore 应用场景
- Semaphore 可以用于做流量控制,特别是公用资源有限的应用场景,比如数据库连接
- 在代码中,虽然有 30 个线程在执行,但是只允许 10 个并发执行。Semaphore 的构造方法 Semaphore(int permits)接受一个整型的数字,表示可用的许可证数量。Semaphore(10)表示允许 10 个线程获取许可证,也就是最大并发数是 10。Semaphore 的用法也很简单,首先线程使用 Semaphore 的 acquire() 方法获取一个许可证,使用完之后调用 release() 方法归还许可证。还可以用 tryAcquire() 方法尝试获取许可证
-
示例
public class SemaphoreTest { private static final int THREAD_COUNT = 30; private static ExecutorServicethreadPool = Executors.newFixedThreadPool(THREAD_COUNT); private static Semaphore s = new Semaphore(10); public static void main(String[] args) { for (inti = 0; i< THREAD_COUNT; i++) { threadPool.execute(new Runnable() { @Override public void run() { try { s.acquire(); System.out.println("save data"); s.release(); } catch (InterruptedException e) { } } }); } threadPool.shutdown(); } }
-
其他方法
- intavailablePermits():返回此信号量中当前可用的许可证数
- intgetQueueLength():返回正在等待获取许可证的线程数
- booleanhasQueuedThreads():是否有线程正在等待获取许可证
- void reducePermits(int reduction):减少 reduction 个许可证,是个 protected 方法
- Collection getQueuedThreads():返回所有等待获取许可证的线程集合,是个 protected 方法
线程间交换数据的 Exchange
-
Exchange 概述
- Exchanger(交换者)是一个用于线程间协作的工具类。Exchanger 用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过 exchange 方法交换数据,如果第一个线程先执行 exchange() 方法,它会一直等待第二个线程也执行 exchange 方法,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方
-
Exchange 应用场景
- Exchanger 可以用于遗传算法,遗传算法里需要选出两个人作为交配对象,这时候会交换两人的数据,并使用交叉规则得出 2 个交配结果。Exchanger 也可以用于校对工作,比如我们需要将纸制银行流水通过人工的方式录入成电子银行流水,为了避免错误,采用 AB 岗两人进行录入,录入到 Excel 之后,系统需要加载这两个 Excel,并对两个 Excel 数据进行校对,看看是否录入一致
- 如果两个线程有一个没有执行 exchange() 方法,则会一直等待,如果担心有特殊情况发生,避免一直等待,可以使用 exchange(V x,longtimeout,TimeUnit unit) 设置最大等待时长
-
示例
public class ExchangerTest { private static final Exchanger<String>exgr = new Exchanger<String>(); private static ExecutorServicethreadPool = Executors.newFixedThreadPool(2); public static void main(String[] args) { threadPool.execute(new Runnable() { @Override public void run() { try { String A = "银行流水A"; // A录入银行流水数据 exgr.exchange(A); } catch (InterruptedException e) { } } }); threadPool.execute(new Runnable() { @Override public void run() { try { String B = "银行流水B"; // B录入银行流水数据 String A = exgr.exchange("B"); System.out.println("A和B数据是否一致:" + A.equals(B) + ",A录入的是:" + A + ",B录入是:" + B); } catch (InterruptedException e) { } } }); threadPool.shutdown(); } }
第九章 Java中的线程池
线程池的优势
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行
- 提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控
线程池的实现原理
- 线程的处理流程
- 线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程
- 线程池判断工作队列是否已经满。如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程
- 线程池判断线程池的线程是否都处于工作状态。如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务
- ThreadPoolExecutor 执行 execute 方法分下面4种情况
- 如果当前运行的线程少于 corePoolSize,则创建新线程来执行任务(注意,执行这一步骤需要获取全局锁)
- 如果运行的线程等于或多于 corePoolSize,则将任务加入 BlockingQueue
- 如果无法将任务加入 BlockingQueue(队列已满),则创建新的线程来处理任务(注意,执行这一步骤需要获取全局锁)
- 如果创建新线程将使当前运行的线程超出 maximumPoolSize,任务将被拒绝,并调用 RejectedExecutionHandler.rejectedExecution() 方法
public void execute(Runnable command) { if (command == null) throw new NullPointerException(); // 如果线程数小于基本线程数,则创建线程并执行当前任务 if (poolSize >= corePoolSize || !addIfUnderCorePoolSize(command)) { // 如线程数大于等于基本线程数或线程创建失败,则将当前任务放到工作队列中。 if (runState == RUNNING && workQueue.offer(command)) { if (runState != RUNNING || poolSize == 0) ensureQueuedTaskHandled(command); } // 如果线程池不处于运行中或任务无法放入队列,并且当前线程数量小于最大允许的线程数量, // 则创建一个线程执行任务。 else if (!addIfUnderMaximumPoolSize(command)) // 抛出RejectedExecutionException异常 reject(command); // is shutdown or saturated } }
- 工作线程
- 线程池创建线程时,会将线程封装成工作线程 Worker,Worker 在执行完任务后,还会循环获取工作队列里的任务来执行
public void run() { try { Runnable task = firstTask; firstTask = null; while (task != null || (task = getTask()) != null) { runTask(task); task = null; } } finally { workerDone(this); } }
- 线程池创建线程时,会将线程封装成工作线程 Worker,Worker 在执行完任务后,还会循环获取工作队列里的任务来执行
- 线程池中的线程执行任务分两种情况
- 在 execute() 方法中创建一个线程时,会让这个线程执行当前任务
- 这个线程执行完任务后,会反复从 BlockingQueue 获取任务来执行
线程池的使用
-
线程池的创建
- 我们可以通过 ThreadPoolExecutor 来创建一个线程池
new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime,milliseconds,runnableTaskQueue, handler);
- 创建一个线程池时需要的参数
- corePoolSize(线程池的基本大小):当提交一个任务到线程池时,线程池会创建一个线程来执行任务,即使其他空闲的基本线程能够执行新任务也会创建线程,等到需要执行的任务数大于线程池基本大小时就不再创建。如果调用了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有基本线程
- runnableTaskQueue(任务队列):用于保存等待执行的任务的阻塞队列
- ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按 FIFO (先进先出)原则对元素进行排序
- LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按 FIFO 排序元素,吞吐量通常要高于 ArrayBlockingQueue。静态工厂方法 Executors.newFixedThreadPool()使 用了这个队列
- SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于 LinkedBlockingQueue,静态工厂方法 Executors.newCachedThreadPool 使用了这个队列
- PriorityBlockingQueue:一个具有优先级的无限阻塞队列
- maximumPoolSize(线程池最大数量):线程池允许创建的最大线程数。如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。值得注意的是,如果使用了无界的任务队列这个参数就没什么效果
- ThreadFactory:用于设置创建线程的工厂,可以通过线程工厂给每个创建出来的线程设置更有意义的名字。使用开源框架 guava 提供的 ThreadFactoryBuilder 可以快速给线程池里的线程设置有意义的名字
new ThreadFactoryBuilder().setNameFormat("XX-task-%d").build();
- RejectedExecutionHandler(饱和策略):当队列和线程池都满了,说明线程池处于饱和状态,那么必须采取一种策略处理提交的新任务。这个策略默认情况下是 AbortPolicy,表示无法处理新任务时抛出异常。可以根据应用场景需要来实现 RejectedExecutionHandler 接口自定义策略。在 JDK 1.5 中 Java 线程池框架提供了以下 4 种策略
- AbortPolicy:直接抛出异常
- CallerRunsPolicy:只用调用者所在线程来运行任务
- DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务
- DiscardPolicy:不处理,丢弃掉
- keepAliveTime(线程活动保持时间):线程池的工作线程空闲后,保持存活的时间。所以,如果任务很多,并且每个任务执行的时间比较短,可以调大时间,提高线程的利用率
- TimeUnit(线程活动保持时间的单位):可选的单位有天(DAYS)、小时(HOURS)、分钟(MINUTES)、毫秒(MILLISECONDS)、微秒(MICROSECONDS,千分之一毫秒)和纳秒(NANOSECONDS,千分之一微秒)
- 我们可以通过 ThreadPoolExecutor 来创建一个线程池
-
向线程池提交任务
- 可以使用两个方法向线程池提交任务,分别为execute()和submit()方法
- execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功
- submit() 方法用于提交需要返回值的任务。线程池会返回一个 future 类型的对象,可以通过 future 的 get() 方法来获取返回值,get() 方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit) 方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完
threadsPool.execute(new Runnable() { @Override public void run() { // TODO Auto-generated method stub } }); Future<Object> future = executor.submit(harReturnValuetask); try { Object s = future.get(); } catch (InterruptedException e) { // 处理中断异常 } catch (ExecutionException e) { // 处理无法执行任务异常 } finally { // 关闭线程池 executor.shutdown(); }
-
关闭线程池
- 可以通过调用线程池的 shutdown 或 shutdownNow 方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的 interrupt 方法来中断线程,所以无法响应中断的任务可能永远无法终止
- shutdownNow 首先将线程池的状态设置成 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表
- shutdown 只是将线程池的状态设置成 SHUTDOWN 状态,然后中断所有没有正在执行任务的线程
- 只要调用了这两个关闭方法中的任意一个,isShutdown 方法就会返回 true。当所有的任务都已关闭后,才表示线程池关闭成功,这时调用 isTerminaed 方法会返回 true。至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用 shutdown 方法来关闭线程池,如果任务不一定要执行完,则可以调用 shutdownNow 方法
-
合理地配置线程池
- 任务的性质
- CPU 密集型任务:CPU 密集型任务应配置尽可能小的线程,如配置 Ncpu+1 个线程的线程池
- IO 密集型任务:由于 IO 密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如 2*Ncpu
- 混合型任务:如果可以拆分,将其拆分成一个 CPU 密集型任务和一个 IO 密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐量将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解
- 可以通过 Runtime.getRuntime().availableProcessors() 方法获得当前设备的 CPU 个数
- 任务的优先级
- 高、中和低。优先级不同的任务可以使用优先级队列 PriorityBlockingQueue 来处理。它可以让优先级高的任务先执行。注意 如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能执行
- 任务的执行时间
- 长、中和短。执行时间不同的任务可以交给不同规模的线程池来处理,或者可以使用优先级队列,让执行时间短的任务先执行
- 任务的依赖性
- 是否依赖其他系统资源,如数据库连接。依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,等待的时间越长,则CPU空闲时间就越长,那么线程数应该设置得越大,这样才能更好地利用CPU
- 建议使用有界队列
- 有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点儿,比如几千。有一次,我们系统里后台任务线程池的队列和线程池全满了,不断抛出抛弃任务的异常,通过排查发现是数据库出现了问题,导致执行SQL变得非常缓慢,因为后台任务线程池里的任务全是需要向数据库查询和插入数据的,所以导致线程池里的工作线程全部阻塞,任务积压在线程池里。如果当时我们设置成无界队列,那么线程池的队列就会越来越多,有可能会撑满内存,导致整个系统不可用,而不只是后台任务出现问题。当然,我们的系统所有的任务是用单独的服务器部署的,我们使用不同规模的线程池完成不同类型的任务,但是出现这样问题时也会影响到其他任务
- 任务的性质
-
线程池的监控
- 如果在系统中大量使用线程池,则有必要对线程池进行监控,方便在出现问题时,可以根据线程池的使用状况快速定位问题。可以通过线程池提供的参数进行监控,在监控线程池的时候可以使用以下属性
- taskCount:线程池需要执行的任务数量
- completedTaskCount:线程池在运行过程中已完成的任务数量,小于或等于 taskCount
- largestPoolSize:线程池里曾经创建过的最大线程数量。通过这个数据可以知道线程池是否曾经满过。如该数值等于线程池的最大大小,则表示线程池曾经满过
- getPoolSize:线程池的线程数量。如果线程池不销毁的话,线程池里的线程不会自动销毁,所以这个大小只增不减
- getActiveCount:获取活动的线程数
- 通过扩展线程池进行监控。可以通过继承线程池来自定义线程池,重写线程池的 beforeExecute、afterExecute 和 terminated 方法,也可以在任务执行前、执行后和线程池关闭前执行一些代码来进行监控。例如,监控任务的平均执行时间、最大执行时间和最小执行时间等。这几个方法在线程池里是空方法
protected void beforeExecute(Thread t, Runnable r) { }
- 如果在系统中大量使用线程池,则有必要对线程池进行监控,方便在出现问题时,可以根据线程池的使用状况快速定位问题。可以通过线程池提供的参数进行监控,在监控线程池的时候可以使用以下属性
第十章 Executor框架
Executor 框架
Java 的线程既是工作单元,也是执行机制。从 JDK 5 开始,把工作单元与执行机制分离开来。工作单元包括 Runnable 和 Callable,而执行机制由 Executor 框架提供
Executor 框架简介
- Executor 框架的两级调度模型
- 在 HotSpot VM 的线程模型中,Java 线程(java.lang.Thread)被一对一映射为本地操作系统线程。Java 线程启动时会创建一个本地操作系统线程;当该 Java 线程终止时,这个操作系统线程也会被回收。操作系统会调度所有线程并将它们分配给可用的 CPU。在上层,Java 多线程程序通常把应用分解为若干个任务,然后使用用户级的调度器(Executor框架)将这些任务映射为固定数量的线程;在底层,操作系统内核将这些线程映射到硬件处理器上
- Executor框架的结构与成员
- Executor 框架的结构
- Executor 框架主要由 3 大部分组成
- 任务。包括被执行任务需要实现的接口:Runnable 接口或 Callable 接口
- 任务的执行。包括任务执行机制的核心接口 Executor,以及继承自 Executor 的 ExecutorService 接口。Executor 框架有两个关键类实现了 ExecutorService 接口(ThreadPoolExecutor和ScheduledThreadPoolExecutor)
- 异步计算的结果。包括接口 Future 和实现 Future 接口的 FutureTask 类
- Executor 框架包含的主要的类与接口
- Executor 是一个接口,它是 Executor 框架的基础,它将任务的提交与任务的执行分离开来
- ThreadPoolExecutor 是线程池的核心实现类,用来执行被提交的任务
- ScheduledThreadPoolExecutor 是一个实现类,可以在给定的延迟后运行命令,或者定期执行命令。ScheduledThreadPoolExecutor 比 Timer 更灵活,功能更强大
- Future 接口和实现 Future 接口的 FutureTask 类,代表异步计算的结果
- Runnable 接口和 Callable 接口的实现类,都可以被 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行
- Executor 框架工作方式
- 主线程首先要创建实现 Runnable 或者 Callable 接口的任务对象。工具类 Executors 可以把一个 Runnable 对象封装为一个 Callable 对象(Executors.callable(Runnable task)或 Executors.callable(Runnable task,Object resule))
- 然后可以把 Runnable 对象直接交给 ExecutorService 执行(ExecutorService.execute(Runnable command));或者也可以把 Runnable 对象或 Callable 对象提交给ExecutorService 执行(Executor-Service.submit(Runnable task)或 ExecutorService.submit(Callabletask))
- 如果执行 ExecutorService.submit(…),ExecutorService 将返回一个实现 Future 接口的对象(到目前为止的JDK中,返回的是 FutureTask 对象)。由于 FutureTask 实现了 Runnable,程序员也可以创建 FutureTask,然后直接交给 ExecutorService 执行
- 最后,主线程可以执行 FutureTask.get() 方法来等待任务执行完成。主线程也可以执行 FutureTask.cancel(boolean mayInterruptIfRunning) 来取消此任务的执行
- Executor 框架主要由 3 大部分组成
- Executor 框架的成员
- ThreadPoolExecutor
- ThreadPoolExecutor 通常使用工厂类 Executors 来创建。Executors 可以创建 3 种类型的 ThreadPoolExecutor、SingleThreadExecutor、FixedThreadPoolCachedThreadPool
- FixedThreadPool
- FixedThreadPool 适用于为了满足资源管理的需求,而需要限制当前线程数量的应用场景,它适用于负载比较重的服务器
public static ExecutorService newFixedThreadPool(int nThreads) public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactorythreadFactory)
- FixedThreadPool 适用于为了满足资源管理的需求,而需要限制当前线程数量的应用场景,它适用于负载比较重的服务器
- SingleThreadExecutor
- SingleThreadExecutor 适用于需要保证顺序地执行各个任务;并且在任意时间点,不会有多个线程是活动的应用场景
public static ExecutorService newSingleThreadExecutor() public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory)
- SingleThreadExecutor 适用于需要保证顺序地执行各个任务;并且在任意时间点,不会有多个线程是活动的应用场景
- CachedThreadPool
- CachedThreadPool 是大小无界的线程池,适用于执行很多的短期异步任务的小程序,或者是负载较轻的服务器
public static ExecutorService newCachedThreadPool() public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory)
- CachedThreadPool 是大小无界的线程池,适用于执行很多的短期异步任务的小程序,或者是负载较轻的服务器
- ScheduledThreadPoolExecutor
- ScheduledThreadPoolExecutor 通常使用工厂类 Executors 来创建
- ScheduledThreadPoolExecutor
- ScheduledThreadPoolExecutor 适用于需要多个后台线程执行周期任务,同时为了满足资源管理的需求而需要限制后台线程的数量的应用场景
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize,ThreadFactory threadFactory)
- ScheduledThreadPoolExecutor 适用于需要多个后台线程执行周期任务,同时为了满足资源管理的需求而需要限制后台线程的数量的应用场景
- SingleThreadScheduledExecutor
- SingleThreadScheduledExecutor 适用于需要单个后台线程执行周期任务,同时需要保证顺序地执行各个任务的应用场景
public static ScheduledExecutorService newSingleThreadScheduledExecutor() public static ScheduledExecutorService newSingleThreadScheduledExecutor(ThreadFactory threadFactory)
- SingleThreadScheduledExecutor 适用于需要单个后台线程执行周期任务,同时需要保证顺序地执行各个任务的应用场景
- Future 接口
- Future 接口和实现 Future 接口的 FutureTask 类用来表示异步计算的结果。当我们把 Runnable 接口或 Callable 接口的实现类提交(submit)给 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 时,ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 会向我们返回一个 FutureTask 对象
<T> Future<T> submit(Callable<T> task) <T> Future<T> submit(Runnable task, T result) Future<> submit(Runnable task)
- Future 接口和实现 Future 接口的 FutureTask 类用来表示异步计算的结果。当我们把 Runnable 接口或 Callable 接口的实现类提交(submit)给 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 时,ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 会向我们返回一个 FutureTask 对象
- Runnable 接口和 Callable 接口
- Runnable 接口和 Callable 接口的实现类,都可以被 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。它们之间的区别是 Runnable 不会返回结果,而 Callable 可以返回结果
- 除了可以自己创建实现 Callable 接口的对象外,还可以使用工厂类 Executors 来把一个 Runnable 包装成一个 Callable
public static Callable<Object> callable(Runnable task) // 假设返回对象Callable1 public static <T> Callable<T> callable(Runnable task, T result) // 假设返回对象Callable2
- 前面讲过,当我们把一个 Callable 对象(比如上面的Callable1或Callable2)提交给 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行时,submit(…) 会向我们返回一个 FutureTask 对象。我们可以执行 FutureTask.get() 方法来等待任务执行完成。当任务成功完成后 FutureTask.get() 将返回该任务的结果。例如,如果提交的是对象 Callable1,FutureTask.get() 方法将返回 null;如果提交的是对象 Callable2,FutureTask.get() 方法将返回 result 对象
- ThreadPoolExecutor
- Executor 框架的结构
ThreadPoolExecutor详解
-
Executor 框架最核心的类是 ThreadPoolExecutor,它是线程池的实现类,主要由下列 4 个组件构成
- corePool:核心线程池的大小
- maximumPool:最大线程池的大小
- BlockingQueue:用来暂时保存任务的工作队列
- RejectedExecutionHandler:当 ThreadPoolExecutor 已经关闭或 ThreadPoolExecutor 已经饱和时(达到了最大线程池大小且工作队列已满),execute() 方法将要调用的 Handler
-
通过 Executor 框架的工具类 Executors,可以创建 3 种类型的 ThreadPoolExecutor
- FixedThreadPool
- FixedThreadPool 被称为可重用固定线程数的线程池
public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); }
- FixedThreadPool 的 corePoolSize 和 maximumPoolSize 都被设置为创建 FixedThreadPool 时指定的参数 nThreads
- 执行流程
- 当线程池中的线程数大于 corePoolSize 时,keepAliveTime 为多余的空闲线程等待新任务的最长时间,超过这个时间后多余的线程将被终止。这里把 keepAliveTime 设置为 0L,意味着多余的空闲线程会被立即终止
- 如果当前运行的线程数少于 corePoolSize,则创建新线程来执行任务
- 在线程池完成预热之后(当前运行的线程数等于 corePoolSize ),将任务加入 LinkedBlockingQueue
- 线程执行完 1 中的任务后,会在循环中反复从 LinkedBlockingQueue 获取任务来执行
- FixedThreadPool 使用无界队列 LinkedBlockingQueue 作为线程池的工作队列(队列的容量为 Integer.MAX_VALUE)。使用无界队列作为工作队列会对线程池带来如下影响
- 当线程池中的线程数达到 corePoolSize 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 corePoolSize
- 由于 1,使用无界队列时 maximumPoolSize 将是一个无效参数
- 由于 1 和 2,使用无界队列时 keepAliveTime 将是一个无效参数
- 由于使用无界队列,运行中的 FixedThreadPool(未执行方法shutdown()或shutdownNow())不会拒绝任务(不会调用 RejectedExecutionHandler.rejectedExecution 方法)
- FixedThreadPool 被称为可重用固定线程数的线程池
- SingleThreadExecutor
- SingleThreadExecutor 是使用单个 worker 线程的 Executor
public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())); } ```![在这里插入图片描述](https://img-blog.csdnimg.cn/20201007085908609.PNG?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2xiaF9wYW9wYW8=,size_16,color_FFFFFF,t_70#pic_center) * SingleThreadExecutor 的 corePoolSize 和 maximumPoolSize 被设置为 1。其他参数与 FixedThreadPool 相同。SingleThreadExecutor 使用无界队列 LinkedBlockingQueue 作为线程池的工作队列(队列的容量为Integer.MAX_VALUE)
- SingleThreadExecutor 使用无界队列作为工作队列对线程池带来的影响与 FixedThreadPool 相同
- 执行流程
- 如果当前运行的线程数少于 corePoolSize(即线程池中无运行的线程),则创建一个新线程来执行任务
- 在线程池完成预热之后(当前线程池中有一个运行的线程),将任务加入 LinkedBlockingQueue
- 线程执行完 1 中的任务后,会在一个无限循环中反复从 LinkedBlockingQueue 获取任务来执行
- SingleThreadExecutor 是使用单个 worker 线程的 Executor
- CachedThreadPool
- CachedThreadPool 是一个会根据需要创建新线程的线程池
public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); }
- CachedThreadPool 的 corePoolSize 被设置为 0,即 corePool 为空;maximumPoolSize 被设置为 Integer.MAX_VALUE,即 maximumPool 是无界的。这里把 keepAliveTime 设置为 60L,意味着 CachedThreadPool 中的空闲线程等待新任务的最长时间为 60 秒,空闲线程超过 60 秒后将会被终止
- FixedThreadPool 和 SingleThreadExecutor 使用无界队列 LinkedBlockingQueue 作为线程池的工作队列。CachedThreadPool 使用没有容量的 SynchronousQueue 作为线程池的工作队列,但 CachedThreadPool 的 maximumPool 是无界的。这意味着,如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool 会不断创建新线程。极端情况下,CachedThreadPool 会因为创建过多线程而耗尽 CPU 和内存资源
- 执行流程
- 首先执行 SynchronousQueue.offer(Runnable task)。如果当前 maximumPool 中有空闲线程正在执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS),那么主线程执行 offer 操作与空闲线程执行的 poll 操作配对成功,主线程把任务交给空闲线程执行,execute() 方法执行完成;否则执行下面的步骤
- 当初始 maximumPool 为空,或者 maximumPool 中当前没有空闲线程时,将没有线程执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS)。这种情况下,步骤1)将失败。此时 CachedThreadPool 会创建一个新线程执行任务,execute() 方法执行完成
- 在步骤2)中新创建的线程将任务执行完后,会执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS)。这个 poll 操作会让空闲线程最多在 SynchronousQueue 中等待 60 秒钟。如果 60 秒钟内主线程提交了一个新任务(主线程执行步骤1)),那么这个空闲线程将执行主线程提交的新任务;否则,这个空闲线程将终止。由于空闲 60 秒的空闲线程会被终止,因此长时间保持空闲的 CachedThreadPool 不会使用任何资源
- SynchronousQueue 是一个没有容量的阻塞队列。每个插入操作必须等待另一个线程的对应移除操作,反之亦然。CachedThreadPool 使用 SynchronousQueue,把主线程提交的任务传递给空闲线程执行
- CachedThreadPool 是一个会根据需要创建新线程的线程池
- FixedThreadPool
ScheduledThreadPoolExecutor详解
-
概述
- ScheduledThreadPoolExecutor 继承自 ThreadPoolExecutor。它主要用来在给定的延迟之后运行任务,或者定期执行任务。ScheduledThreadPoolExecutor 的功能与 Timer 类似,但ScheduledThreadPoolExecutor 功能更强大、更灵活。Timer 对应的是单个后台线程,而 ScheduledThreadPoolExecutor 可以在构造函数中指定多个对应的后台线程数
-
ScheduledThreadPoolExecutor 的运行机制
- DelayQueue 是一个无界队列,所以 ThreadPoolExecutor 的 maximumPoolSize 在 Scheduled-ThreadPoolExecutor 中没有什么意义(设置 maximumPoolSize 的大小没有什么效果)
- 执行流程
- 当调用 ScheduledThreadPoolExecutor 的 scheduleAtFixedRate() 方法或者 scheduleWith-FixedDelay() 方法时,会向 ScheduledThreadPoolExecutor 的 DelayQueue 添加一个实现了 RunnableScheduledFutur 接口的 ScheduledFutureTask
- 线程池中的线程从 DelayQueue 中获取 ScheduledFutureTask,然后执行任务
- ScheduledThreadPoolExecutor 为了实现周期性的执行任务,对 ThreadPoolExecutor 做了如下的修改
- 使用 DelayQueue 作为任务队列
- 获取任务的方式不同(后文会说明)
- 执行周期任务后,增加了额外的处理(后文会说明)
-
ScheduledThreadPoolExecutor 的实现
- ScheduledFutureTask
- ScheduledThreadPoolExecutor 会把待调度的任务(ScheduledFutureTask)放到一个 DelayQueue 中
- ScheduledFutureTask 主要包含 3 个成员变量
- long 型成员变量 time,表示这个任务将要被执行的具体时间
- long 型成员变量 sequenceNumber,表示这个任务被添加到 ScheduledThreadPoolExecutor 中的序号
- long 型成员变量 period,表示任务执行的间隔周期
- DelayQueue 封装了一个 PriorityQueue,这个 PriorityQueue 会对队列中的 ScheduledFutureTask 进行排序。排序时,time 小的排在前面(时间早的任务将被先执行)。如果两个ScheduledFutureTask 的 time 相同,就比较 sequenceNumber,sequenceNumber 小的排在前面(也就是说,如果两个任务的执行时间相同,那么先提交的任务将被先执行)
- 执行流程
- 线程 1 从 DelayQueue 中获取已到期的 ScheduledFutureTask(DelayQueue.take()),到期任务是指 ScheduledFutureTask 的 time 大于等于当前时间
- 线程 1 执行这个 ScheduledFutureTask
- 线程 1 修改 ScheduledFutureTask 的 time 变量为下次将要被执行的时间
- 线程 1 把这个修改 time 之后的 ScheduledFutureTask 放回 DelayQueue 中(DelayQueue.add())
- DelayQueue.take() 方法
public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); // 1 try { for (;;) { E first = q.peek(); if (first == null) { available.await(); // 2.1 } else { long delay = first.getDelay(TimeUnit.NANOSECONDS); if (delay > 0) { long tl = available.awaitNanos(delay); // 2.2 } else { E x = q.poll(); // 2.3.1 assert x != null; if (q.size() != 0) available.signalAll(); // 2.3.2 return x; } } } } finally { lock.unlock(); // 3 } } ```![在这里插入图片描述](https://img-blog.csdnimg.cn/2020100709005762.PNG?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2xiaF9wYW9wYW8=,size_16,color_FFFFFF,t_70#pic_center) * 执行流程 1. 获取 Lock 2. 获取周期任务 * 如果 PriorityQueue 为空,当前线程到 Condition 中等待;否则执行下面的2.2 * 如果 PriorityQueue 的头元素的 time 时间比当前时间大,到 Condition 中等待到 time 时间;否则执行下面的 2.3 * 获取 PriorityQueue 的头元素(2.3.1);如果 PriorityQueue 不为空,则唤醒在Condition中等待的所有线程(2.3.2) 3 释放 Lock
- DelayQueue.add() 方法
public boolean offer(E e) { final ReentrantLock lock = this.lock; lock.lock(); // 1 try { E first = q.peek(); q.offer(e); // 2.1 if (first == null || e.compareTo(first) < 0) available.signalAll(); // 2.2 return true; } finally { lock.unlock(); // 3 } } ```![在这里插入图片描述](https://img-blog.csdnimg.cn/20201007090111619.PNG?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2xiaF9wYW9wYW8=,size_16,color_FFFFFF,t_70#pic_center) * 执行流程 1. 获取 Lock 2. 添加任务 * 向 PriorityQueue 添加任务 * 如果在上面 2.1 中添加的任务是 PriorityQueue 的头元素,唤醒在 Condition 中等待的所有线程 3. 释放 Lock
- ScheduledFutureTask
FutureTask详解
-
FutureTask简介
- FutureTask 除了实现 Future 接口外,还实现了 Runnable 接口。因此,FutureTask 可以交给 Executor 执行,也可以由调用线程直接执行(FutureTask.run())
- 根据 FutureTask.run() 方法被执行的时机,FutureTask 可以处于下面 3 种状态
- 未启动。FutureTask.run() 方法还没有被执行之前,FutureTask 处于未启动状态。当创建一个 FutureTask,且没有执行 FutureTask.run() 方法之前,这个 FutureTask 处于未启动状态
- 已启动。FutureTask.run() 方法被执行的过程中,FutureTask 处于已启动状态
- 已完成。FutureTask.run() 方法执行完后正常结束,或被取消(FutureTask.cancel(…)),或执行 FutureTask.run() 方法时抛出异常而异常结束,FutureTask 处于已完成状态
- 当 FutureTask 处于未启动或已启动状态时,执行 FutureTask.get() 方法将导致调用线程阻塞;当 FutureTask 处于已完成状态时,执行 FutureTask.get() 方法将导致调用线程立即返回结果或抛出异常
- 当 FutureTask 处于未启动状态时,执行 FutureTask.cancel() 方法将导致此任务永远不会被执行;当 FutureTask 处于已启动状态时,执行 FutureTask.cancel(true) 方法将以中断执行此任务线程的方式来试图停止任务;当 FutureTask 处于已启动状态时,执行 FutureTask.cancel(false) 方法将不会对正在执行此任务的线程产生影响(让正在执行的任务运行完成);当FutureTask 处于已完成状态时,执行FutureTask.cancel(…)方法将返回 false
-
FutureTask的使用
- 可以把 FutureTask 交给 Executor 执行;也可以通过 ExecutorService.submit(…)方法返回一个 FutureTask,然后执行 FutureTask.get() 方法或 FutureTask.cancel(…) 方法。除此以外,还可以单独使用 FutureTask
- 当一个线程需要等待另一个线程把某个任务执行完后它才能继续执行,此时可以使用 FutureTask。假设有多个线程执行若干任务,每个任务最多只能被执行一次。当多个线程试图同时执行同一个任务时,只允许一个线程执行任务,其他线程需要等待这个任务执行完后才能继续执行
- 当两个线程试图同时执行同一个任务时,如果 Thread 1 执行 1.3 后 Thread 2 执行 2.1,那么接下来 Thread 2 将在 2.2 等待,直到 Thread 1 执行完 1.4 后 Thread 2 才能从 2.2 (FutureTask.get()) 返回
private final ConcurrentMap<Object, Future<String>> taskCache = new ConcurrentHashMap<Object, Future<String>>(); private String executionTask(final String taskName) throws ExecutionException, InterruptedException { while (true) { Future<String> future = taskCache.get(taskName); // 1.1,2.1 if (future == null) { Callable<String> task = new Callable<String>() { public String call() throws InterruptedException { return taskName; } }; // 1.2创建任务 FutureTask<String> futureTask = new FutureTask<String>(task); future = taskCache.putIfAbsent(taskName, futureTask); // 1.3 if (future == null) { future = futureTask; futureTask.run(); // 1.4执行任务 } } try { return future.get(); // 1.5,2.2线程在此等待任务执行完成 } catch (CancellationException e) { taskCache.remove(taskName, future); } } }
-
FutureTask的实现
- FutureTask 的实现基于 AbstractQueuedSynchronizer(以下简称为AQS)。java.util.concurrent 中的很多可阻塞类(比如ReentrantLock)都是基于 AQS 来实现的。AQS 是一个同步框架,它提供通用机制来原子性管理同步状态、阻塞和唤醒线程,以及维护被阻塞线程的队列。JDK 6 中 AQS 被广泛使用,基于 AQS 实现的同步器包括:ReentrantLock、Semaphore、ReentrantReadWriteLock、CountDownLatch和FutureTask
- 每一个基于 AQS 实现的同步器都会包含两种类型的操作
- 至少一个 acquire 操作。这个操作阻塞调用线程,除非/直到 AQS 的状态允许这个线程继续执行。FutureTask 的 acquire 操作为 get()/get(long timeout,TimeUnit unit) 方法调用
- 至少一个 release 操作。这个操作改变 AQS 的状态,改变后的状态可允许一个或多个阻塞线程被解除阻塞。FutureTask 的 release 操作包括 run() 方法和 cancel(…) 方法
- 基于“复合优先于继承”的原则,FutureTask 声明了一个内部私有的继承于 AQS 的子类 Sync,对 FutureTask 所有公有方法的调用都会委托给这个内部子类。AQS 被作为“模板方法模式”的基础类提供给 FutureTask 的内部子类 Sync,这个内部子类只需要实现状态检查和状态更新的方法即可,这些方法将控制 FutureTask 的获取和释放操作。具体来说,Sync 实现了 AQS 的 tryAcquireShared(int) 方法和 tryReleaseShared(int) 方法,Sync 通过这两个方法来检查和更新同步状态
- Sync 是 FutureTask 的内部私有类,它继承自 AQS。创建 FutureTask 时会创建内部私有的成员对象 Sync,FutureTask 所有的的公有方法都直接委托给了内部私有的 Sync
- FutureTask.get() 方法会调用 AQS.acquireSharedInterruptibly(int arg) 方法,这个方法的执行过程如下
- 调用 AQS.acquireSharedInterruptibly(int arg) 方法,这个方法首先会回调在子类 Sync 中实现的 tryAcquireShared() 方法来判断 acquire 操作是否可以成功。acquire 操作可以成功的条件为:state 为执行完成状态 RAN 或已取消状态 CANCELLED,且 runner 不为 null
- 如果成功则 get() 方法立即返回。如果失败则到线程等待队列中去等待其他线程执行 release 操作
- 当其他线程执行 release 操作(比如FutureTask.run()或FutureTask.cancel(…)) 唤醒当前线程后,当前线程再次执行tryAcquireShared()将返回正值1,当前线程将离开线程等待队列并唤醒它的后继线程(这里会产生级联唤醒的效果,后面会介绍)
- 最后返回计算的结果或抛出异常
- FutureTask.run()的执行过程如下。
- 执行在构造函数中指定的任务(Callable.call())
- 以原子方式来更新同步状态(调用AQS.compareAndSetState(int expect,in update),设置 state 为执行完成状态RAN)。如果这个原子操作成功,就设置代表计算结果的变量 result 的值为 Callable.call() 的返回值,然后调用 AQS.releaseShared(int arg)
- AQS.releaseShared(int arg) 首先会回调在子类 Sync 中实现的 tryReleaseShared(arg) 来执行 release 操作(设置运行任务的线程runner为null,然会返回true);AQS.releaseShared(int arg),然后唤醒线程等待队列中的第一个线程
- 调用FutureTask.done()
- 当执行 FutureTask.get() 方法时,如果 FutureTask 不是处于执行完成状态 RAN 或已取消状态 CANCELLED,当前执行线程将到 AQS 的线程等待队列中等待(见下图的线程A、B、C和D)。当某个线程执行 FutureTask.run() 方法或 FutureTask.cancel(…) 方法时,会唤醒线程等待队列的第一个线程
- 假设开始时 FutureTask 处于未启动状态或已启动状态,等待队列中已经有 3 个线程(A、B和C)在等待。此时,线程 D 执行 get() 方法将导致线程 D 也到等待队列中去等待。当线程 E 执行 run() 方法时,会唤醒队列中的第一个线程 A。线程 A 被唤醒后,首先把自己从队列中删除,然后唤醒它的后继线程 B,最后线程 A 从 get() 方法返回。线程 B、C 和 D 重复 A 线程的处理流程。最终,在队列中等待的所有线程都被级联唤醒并从 get() 方法返回
第十一章 Java并发编程实践
生产者和消费者模式
-
概述
- 在并发编程中使用生产者和消费者模式能够解决绝大多数并发问题
- 生产者和消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通信,而是通过阻塞队列来进行通信,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。纵观大多数设计模式,都会找一个第三者出来进行解耦,如工厂模式的第三者是工厂类,模板模式的第三者是模板类。在学习一些设计模式的过程中,先找到这个模式的第三者,能帮助我们快速熟悉一个设计模式
-
生产者消费者模式实战
// 修改前 public void extract() { logger.debug("开始" + getExtractorName() + "。。"); // 抽取邮件 List<Article> articles = extractEmail(); // 添加文章 for (Article article : articles) { addArticleOrComment(article); } // 清空邮件 cleanEmail(); logger.debug("完成" + getExtractorName() + "。。"); } // 修改后 public class QuickEmailToWikiExtractor extends AbstractExtractor { private ThreadPoolExecutor threadsPool; private ArticleBlockingQueue<ExchangeEmailShallowDTO> emailQueue; public QuickEmailToWikiExtractor() { emailQueue= new ArticleBlockingQueue<ExchangeEmailShallowDTO>(); int corePoolSize = Runtime.getRuntime().availableProcessors() * 2; threadsPool = new ThreadPoolExecutor(corePoolSize, corePoolSize, 10l, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(2000)); } public void extract() { logger.debug("开始" + getExtractorName() + "。。"); long start = System.currentTimeMillis(); // 抽取所有邮件放到队列里 new ExtractEmailTask().start(); // 把队列里的文章插入到Wiki insertToWiki(); long end = System.currentTimeMillis(); double cost = (end - start) / 1000; logger.debug("完成" + getExtractorName() + ",花费时间:" + cost + "秒"); } /** * 把队列里的文章插入到Wiki */ private void insertToWiki() { // 登录Wiki,每间隔一段时间需要登录一次 confluenceService.login(RuleFactory.USER_NAME, RuleFactory.PASSWORD); while (true) { // 2秒内取不到就退出 ExchangeEmailShallowDTO email = emailQueue.poll(2, TimeUnit.SECONDS); if (email == null) { break; } threadsPool.submit(new insertToWikiTask(email)); } } protected List<Article> extractEmail() { List<ExchangeEmailShallowDTO> allEmails = getEmailService().queryAllEmails(); if (allEmails == null) { return null; } for (ExchangeEmailShallowDTO exchangeEmailShallowDTO : allEmails) { emailQueue.offer(exchangeEmailShallowDTO); } return null; } /** * 抽取邮件任务 * * @author tengfei.fangtf */ public class ExtractEmailTask extends Thread { public void run() { extractEmail(); } } }
- 代码的执行逻辑是,生产者启动一个线程把所有邮件全部抽取到队列中,消费者启动 CPU*2 个线程数处理邮件,从之前的单线程处理邮件变成了现在的多线程处理,并且抽取邮件的线程不需要等处理邮件的线程处理完再抽取新邮件,所以使用了生产者和消费者模式后,邮件的整体处理速度比以前要快了几倍
-
多生产者和多消费者场景
/** * 总消息队列管理 * * @author tengfei.fangtf */ public class MsgQueueManager implements IMsgQueue{ private static final Logger LOGGER = LoggerFactory.getLogger(MsgQueueManager.class); /** * 消息总队列 */ public final BlockingQueue<Message> messageQueue; private MsgQueueManager() { messageQueue = new LinkedTransferQueue<Message>(); } public void put(Message msg) { try { messageQueue.put(msg); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } public Message take() { try { return messageQueue.take(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return null; } } /** * 分发消息,负责把消息从大队列塞到小队列里 * * @author tengfei.fangtf */ static class DispatchMessageTask implements Runnable { @Override public void run() { BlockingQueue<Message> subQueue; for (;;) { // 如果没有数据,则阻塞在这里 Message msg = MsgQueueFactory.getMessageQueue().take(); // 如果为空,则表示没有Session机器连接上来, // 需要等待,直到有Session机器连接上来 while ((subQueue = getInstance().getSubQueue()) == null) { try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } // 把消息放到小队列里 try { subQueue.put(msg); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } } /** * 均衡获取一个子队列。 * * @return */ public BlockingQueue<Message> getSubQueue() { int errorCount = 0; for (;;) { if (subMsgQueues.isEmpty()) { return null; } int index = (int) (System.nanoTime() % subMsgQueues.size()); try { return subMsgQueues.get(index); } catch (Exception e) { // 出现错误表示,在获取队列大小之后,队列进行了一次删除操作 LOGGER.error("获取子队列出现错误", e); if ((++errorCount) < 3) { continue; } } } } // 往消息队列里添加一条消息 // 使用 IMsgQueue messageQueue = MsgQueueFactory.getMessageQueue(); Packet msg = Packet.createPacket(Packet64FrameType. TYPE_DATA, "{}".getBytes(), (short) 1); messageQueue.put(msg);
-
线程池与生产消费者模式
- Java中的线程池类其实就是一种生产者和消费者模式的实现方式,但是我觉得其实现方式更加高明。生产者把任务丢给线程池,线程池创建线程并处理任务,如果将要运行的任务数大于线程池的基本线程数就把任务扔到阻塞队列里,这种做法比只使用一个阻塞队列来实现生产者和消费者模式显然要高明很多,因为消费者能够处理直接就处理掉了,这样速度更快,而生产者先存,消费者再取这种方式显然慢一些
线上问题定位
- 查找进程
- top 命令查看进程占用资源情况
- 查找线程
- 使用 top -H -p <pid> 查看线程占用情况
- 查找 java 的堆栈信息
- 使用 printf %x <pid> 将线程 id 转换成十六进制
- 然后再使用 jstack 查询线程的堆栈信息
- 语法:jstack | grep -a 线程id(十六进制)
- 这样就找出了有问题的代码了。剩下的就是分析原因和修改代码了
性能测试
-
查看网络流量
cat /proc/net/dev
-
查看系统平均负载
cat /proc/loadavg
-
查看系统内存情况
cat /proc/meminfo
-
查看CPU的利用率
cat /proc/stat
异步任务池
- Java 中的线程池设计
- Java 中的线程池设计得非常巧妙,可以高效并发执行多个任务,但是在某些场景下需要对线程池进行扩展才能更好地服务于系统。例如,如果一个任务仍进线程池之后,运行线程池的程序重启了,那么线程池里的任务就会丢失。另外,线程池只能处理本机的任务,在集群环境下不能有效地调度所有机器的任务
- 任务池的主要处理流程
- 每台机器会启动一个任务池,每个任务池里有多个线程池,当某台机器将一个任务交给任务池后,任务池会先将这个任务保存到数据中,然后某台机器上的任务池会从数据库中获取待执行的任务,再执行这个任务
- 任务状态
- 每个任务有几种状态,分别是创建(NEW)、执行中(EXECUTING)、RETRY(重试)、挂起(SUSPEND)、中止(TEMINER)和执行完成(FINISH)
- 创建:提交给任务池之后的状态
- 执行中:任务池从数据库中拿到任务执行时的状态
- 重试:当执行任务时出现错误,程序显式地告诉任务池这个任务需要重试,并设置下一次执行时间
- 挂起:当一个任务的执行依赖于其他任务完成时,可以将这个任务挂起,当收到消息后,再开始执行
- 中止:任务执行失败,让任务池停止执行这个任务,并设置错误消息告诉调用端
- 执行完成:任务执行结束
- 每个任务有几种状态,分别是创建(NEW)、执行中(EXECUTING)、RETRY(重试)、挂起(SUSPEND)、中止(TEMINER)和执行完成(FINISH)
- 任务池的任务隔离
- 异步任务有很多种类型,比如抓取网页任务、同步数据任务等,不同类型的任务优先级不一样,但是系统资源是有限的,如果低优先级的任务非常多,高优先级的任务就可能得不到执行,所以必须对任务进行隔离执行。使用不同的线程池处理不同的任务,或者不同的线程池处理不同优先级的任务,如果任务类型非常少,建议用任务类型来隔离,如果任务类型非常多,比如几十个,建议采用优先级的方式来隔离
- 任务池的重试策略
- 根据不同的任务类型设置不同的重试策略,有的任务对实时性要求高,那么每次的重试间隔就会非常短,如果对实时性要求不高,可以采用默认的重试策略,重试间隔随着次数的增加,时间不断增长,比如间隔几秒、几分钟到几小时。每个任务类型可以设置执行该任务类型线程池的最小和最大线程数、最大重试次数
- 使用任务池的注意事项
- 任务必须无状态:任务不能在执行任务的机器中保存数据,比如某个任务是处理上传的文件,任务的属性里有文件的上传路径,如果文件上传到机器1,机器2获取到了任务则会处理失败,所以上传的文件必须存在其他的集群里,比如 OSS 或 SFTP
- 异步任务的属性
- 包括任务名称、下次执行时间、已执行次数、任务类型、任务优先级和执行时的报错信息(用于快速定位问题)