看完《Java 并发编程的艺术》的总结与归纳。如有问题,敬请指正。
Chapter 1 并发编程的挑战
1.1 上下文切换
1.任务切换前会保存上一个任务的状态,以便在下次切换回这个任务的时候,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
2.如何减少上下文切换?
-
无锁并发编程
-
CAS算法
-
使用最少线程
-
使用协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换
1.2 死锁
1.如何避免死锁?
-
避免一个线程获取多个锁
-
避免一个线程再锁内占用多个资源,尽量保证一个线程只占用一个资源
-
尝试使用定时锁,使用 lock.tryLock(timeout) 来替代使用内部锁机制
-
对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况
Chapter 2 Java并发机制的底层实现原理
2.1 volatile 的应用
-
volatile 是轻量级的 synchronized,他在多个处理器并发中保证了共享变量的 “可见性”。如果一个字段被声明成 volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。
-
不会引起上下文的切换和调度。
-
volatile的两条实现原则
- Lock前缀指令会引起处理器缓存回写到内存
- 一个处理器的缓存回写到内存会导致其他处理器的缓存无效
2.2 synchronized
1.synchronized 实现同步的基础:Java 中的每一个对象都可以作为锁。
2.三种表现形式:
- 对于普通同步方法,锁是当前实例对象;
- 对于静态同步方法,锁是当前类的 Class 对象;
- 对于同步代码块,锁是 Synchronized 括号里配置的对象。
2.2.2 锁的升级与对比
-
无锁状态-》偏向锁-》轻量级锁-》重量级锁。锁只能升级不能降级。
-
关闭偏向锁延迟(偏向锁通常在程序启动几秒后才激活)
-XX:BiasedLockingStartupDelay = 0;
- 关闭偏向锁(程序之后默认进入轻量级锁状态)
-XX:UseBiasedLocking = false;
- 轻量级锁解锁失败,表示当前存在锁竞争,锁就回膨胀为重量级锁。
5.锁的优缺点:
- 优点
-
偏向锁:加解锁不需要额外的消耗,和执行非同步方法相比只存在纳秒级的差距
-
轻量级锁:竞争的线程不会阻塞,提高了程序的响应速度;
-
重量级锁:线程竞争不使用自旋,不会消耗CPU
- 缺点
-
偏向锁:如果线程存在锁竞争,会带来额外的锁撤销的消耗;
-
轻量级锁:如果始终得不到锁竞争的线程,使用自旋会消耗CPU
-
重量级锁:线程阻塞,响应时间慢
- 适用场景
-
偏向锁:适于只有一个线程访问同步块
-
轻量级锁:追求响应时间,同步块执行速度非常快
-
重量级锁:追求吞吐量,同步块执行速度较长
2.3 原子操作
- 处理器如何实现原子操作?
(1)使用总线锁保证原子性;
(2)使用缓存锁保证原子性;
两种情况除外:
- 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定
- 有些处理器不支持缓存锁定
- Java如何实现原子操作?
通过锁和循环CAS实现;
(1)使用循环CAS实现原子操作
JVM中的CAS操作,利用了处理器提供的CMPXCHG指令实现。自旋CAS实现的基本思路就是循环进行CAS操作知道成功为止。
(2)CAS实现原子操作的三大问题
-
ABA 问题
- 解决思路:使用版本号,在变量前面追加版本号,每次变量更新的时候把版本号+1
-
循环时间长开销大
-
只能保证一个共享变量的原子操作
(3)使用锁机制实现
锁机制确保了只有获得锁的线程才能够操作锁定的内存区域。
Chapter 4 Java 并发编程基础
4.1 线程简介
4.1.1 什么是线程
1.现代操作系统调度的最小单元就是线程,也叫轻量级进程。在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆、栈、和局部变量等属性,并且能够访问共享的内存变量。
4.1.2 为什么使用多线程
- 更多的处理器核心
- 更快的响应时间
- 更好的编程模型
4.1.3 线程优先级
-
针对频繁阻塞(休眠或I/O操作)的线程需要设置高优先级,而偏重计算(需要较多CPU时间或者偏运算)的线程则设置为较低优先级,确保处理器不会被独占;
-
线程优先级不能作为程序正确性的依赖,因为OS完全可以不理会Java线程对于优先级的设定。
4.1.4 线程的状态
4.1.5 Daemon 线程
-
一种支持型线程,主要被用作程序中后台调度以及支持型工作;意味着,当一个Java虚拟机中不存在Daemon线程的时候,Java虚拟机将会退出。
-
Thread.setDarmon(true) 来设置,需要在启动线程之前设置。
-
在构建Daemon线程时,不能依靠finally块中的内容来确保执行关闭或清理资源的逻辑。
4.2 启动和终止线程
4.2.1 构造线程
4.2.2 启动线程
初始化完成之后,调用start()方法就可以启动线程。启动一个线程前,最好为这个线程设置线程名称。
4.2.3 理解中断
-
中断:理解为线程的一个标识位属性,它表示一个运行中的线程是否被其他线程进行了中断操作。中断好比其他线程对该线程打了一个招呼,其他线程通过调用该线程的interrupt()方法对其进行中断操作。
-
线程通过 isInterrupted()来进行判断是否被中断,也可以调用静态方法Thread.interrupted()对当前线程的中断标识位进行复位。
4.3 线程间通信
4.3.2 等待/通知机制
调用wait()、notify()、notifyAll()需要注意:
- 使用时需要先对调用对象加锁;
- 调用wait()后,线程状态由Running变为Waiting,并将当前线程放置到对象的等待队列;
- notify、notifyAll调用后,等待线程依旧不会从wait返回,需要调用notify和notifyAll的线程释放锁后,等待线程才有机会从wait中返回;
- notify、notifyAll将等待队列中的线程移动到同步队列,被移动的线程状态由Waiting变为Blocked
- 从wait方法返回的前提是获得了调用对象的锁
4.3.3 等待/通知的经典范式
等待方:
- 获取对象的锁
- 如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件
- 条件满足则执行对应的逻辑
- 伪代码如下
synchronized(对象) {
while(条件不满足) {
对象.wait();
}
// 对应的逻辑处理
}
通知方:
- 获得对象的锁
- 改变条件
- 通知所有等待在对象上的线程
- 伪代码如下
synchronized(对象) {
改变条件
对象.notifyAll();
}
4.3.4 管道输入输出流
4.3.5 Thread.join()的使用
如果一个线程A执行了thread.join() 语句,含义是:当前线程A等待thread线程终止后才从thread.join() 返回。线程Thread除了提供join() 方法外,还提供了join(long mills)和join(long mills,int nanos)两个具备超时的方法。
4.4 线程应用实例
4.4.3 线程池技术及其实例
线程池:预先创建若干数量的线程,并且不能由用户直接对线程的创建进行控制,在这个前提下重复使用固定或较为固定数目的线程来完成任务的执行。这样做的好处,一方面,消除了频繁创建线程和消亡线程的系统资源开销,另一方面,面对过量任务的提交能够平缓的劣化。
Chapter 5 Java中的锁
5.1 Lock 接口
- 与synchronized的区别:
- 缺少了(通过synchronized块或者方法提供的)隐式获取释放锁的便捷性;
- 拥有了锁获取与释放的可操作性。可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性
Lock lock = new ReentrantLock();
lock.lock();
try {
} finally {
lock.unlock();
}
-
在finally中释放锁,确保获取到锁之后,最终能被释放
-
不要将获取锁的过程写在try块中,因为如果在获取锁(自定义锁的实现)时发生了异常,异常抛出的同时,也会导致锁无故释放
4.特性:
- 尝试非阻塞地获取锁: 当前线程获取锁,如果这一时刻锁没有被其他线程获取到,则成功获取并持有锁;
- 能被中断地获取锁:
- 超时获取锁:
5.2 队列同步器
AbstractQueuedSynchronizer(AQS)-同步器
同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的三个方法来进行操作。同步器既支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就方便实现不同类型的同步组件(ReentrantLock、ReentrantReadWriteLock、CountDownLatch)
5.3 重入锁
表示该锁能够支持一个线程对资源的重复加锁,此外,还支持获取锁时的公平和非公平性选择。
公平的获取锁:也就是等待时间最长的线程优先获取锁。
5.4 读写锁
5.5 LockSupport 工具
5.6 Condition 接口
Chapter 6 Java 并发容器和框架
6.1 ConcurrentHashMap的实现原理与应用
线程安全且高效的HashMap
6.1.1 为什么要使用?
在并发编程中使用HashMap可能导致死循环,而使用线程安全的HashTable效率又低。
- 线程不安全的HashMap
多线程中,进行put操作会引起死循环,导致CPU利用率解决100%。导致HashMap的Entry链表形成环形数据结构。
- 效率低下的HashTable
使用synchronized保证线程安全,但在线程竞争激励时,效率低下;
- ConcurrentHashMap的 锁分段技术可以有效提升并发访问率
首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个端数据时,其他段的数据也能被其他线程访问。
6.1.2 ConcurrentHashMap 的结构
ConcurrentHashMap 是由Segment数据结构和HashEntry数据结构组成。Segment是一种可重复锁,扮演锁的角色;HashEntry用于存储键值对数据。
一个 ConcurrentHashMap包含一个Segment数组,一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的元素进行修改时,首先必须获得与他对应的Segment锁。
6.1.3 ConcurrentHashMap 的初始化
-
初始化 segments 数组
-
初始化 segmentShift 和 segmentMask
-
- segmentShift 最大为16
- segmentMask 最大为65535
-
初始化每个 segment
6.1.4 定位 Segment
因为 ConcurrentHashMap 使用分段锁 Segment 来保护不同段的数据,那么在插入和获取元素的时候,必须先通过散列算法定位到 Segment。
再散列目的: 减少散列冲突,使元素能够均匀地分布在不同的 Segment 上,从而提高容器的存取效率。
定位segment
final Segment<K,V> segmentFor(int hash) { return segments[(hash >>> segmentShift) & segmentMask]; }
6.1.5 ConcurrentHashMap 的操作
- get 操作
public V get(Object key) { int hash = hash(key.hashCode()); return segmentFor(hash).get(key,hash); }
整个过程不用加锁,除非读到空值才会加锁重读。
原因: get 方法里将要使用到的共享变量都定义为 volatile 类型,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值),在get 操作里只需要读不需要写共享变量 count 和 value,所以可以不用加锁。
( hash >>> segmentShift ) & segmentMask // 定位 Segment 使用的 hash 函数 int index = hash & (tab.length - 1) // 定位 HashEntry 使用的 hash 函数
- put 操作
要写数据,在操作共享变量时,一定要加锁。
两个步骤:
- 判断是否需要对 Segment 里的 HashEntry 数组进行扩容
- 定位添加元素的位置,然后将其放在 HashEntry 数组里
(1)是否需要扩容
在插入元素前,先判断 Segment 里的 HashEntry 数组是否超过阈值(threshold),若超过,就对 HashEntry 数组扩容。(HashMap 是在插入后对判断是否已经达到容量,再进行扩容)
(2)如何扩容
创建一个容量为原来2倍的数组,然后将原数组里面的元素进行再散列后插入到新的数组里面。ConcurrentHashMap 只对某个 Segment 进行扩容,更高效。
- size 操作
先尝试2次通过不锁住 Segment 的方式来统计各个 Segment 大小,如果统计过程中,容器的 count 发生变化,则采用**加锁的方式(把所有的 Segment 的put、get 和clean方法全部锁住)**来统计所有 Segment 的大小。
6.2 ConcurrentLinkedQueue
实现一个线程安全的队列有两种方式:一种是使用阻塞算法,另一种是非阻塞算法。使用阻塞算法的队列可以用一个锁(入队和出队公用一把锁)或两把锁(出入队用不同锁)的方式来实现。非阻塞的算法可以用循环 CAS 的方式来实现。
ConcurrentLinkedQueue 是一个基于链接节点的无界线程安全队列,采用 FIFO 的规则对节点进行排序。
6.2.2 入队列
- 入队列的过程
将入队节点添加到队列的尾部。入队过程两件事儿:①定位出尾结点;②使用 CAS 算法将入队节点设置成尾结点的 next 节点,如不成功则重试。
- 定位尾结点
tail 节点并不总是尾结点,所以每次入队都需要通过 tail 节点来找到尾结点
- 设置入队节点为尾结点
- HOPS 的设计意图
控制并减少 tail 节点的更新频率,而不是每次节点入队后都将 tail 节点更新成尾结点,而是大于等于常量 HOPS 时才更新,提高了入队的效率。
6.2.3 出队列
也是通过设置 HOPS 来确定什么时候更新 head 节点,提高出队效率。
6.3 Java 中的阻塞队列
6.3.1 什么是阻塞队列
BlockingQueue:
- 阻塞插入: 队列满时,阻塞插入元素的线程,直至不满;
- 阻塞移除: 队列空时,阻塞移除元素的线程,直至不空。
方法/处理方式 | 抛出异常 | 返回特殊值 | 一直阻塞 | 超时退出 |
---|---|---|---|---|
插入方法 | add ( e ) | offer ( e ) | put ( e ) | offer ( e, time, unit) |
移除方法 | remove ( ) | poll ( ) | take ( ) | poll ( time, unit) |
检查方法 | element ( ) | peek ( ) | 不可用 | 不可用 |
6.3.2 Java 里的阻塞队列
JDK 7:
- ArrayBlockingQueue
数组实现的有界阻塞队列。默认不保证线程访问的公平性。为了保证公平性,可能降低吞吐量。
- LinkedBlockingQueue
链表实现的有界阻塞队列。默认和最大长度为 Integer.MAX_VALUE
- PriorityBlockingQueue
支持优先级的无界阻塞队列。
- DelayQueue
支持延时获取元素的无界阻塞队列。使用优先队列实现
- SynchronousQueue
不存储元素的阻塞队列,每一个 put 操作必须等待一个 take 操作,否则不能继续添加元素。吞吐量高于 LinkedBlockingQueue 和 ArrayBlockingQueue
- LinkedTransferQueue
链表结构的无界阻塞队列
- LinkedBlockingDeque
链表结构,双向阻塞。可运用在“工作窃取”模式中。
6.3.3 阻塞队列的实现原理
使用通知模式实现。
6.4 Fork/Join 框架
6.4.1 是什么?
是把一个大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。
6.4.2 工作窃取算法
是指从某个线程(这个线程自己的活儿干完了,跑去帮别人干活儿)从其他队列里窃取任务来执行。
使用双端队列,被窃取线程从头部拿任务执行,窃取线程从尾部拿任务执行。
优点:
- 充分利用线程进行并行运算,减少了线程间的竞争。
缺点:
- 在某些情况下还是存在竞争,比如队列中只有一个任务时。且该算法会消耗更多的系统资源,比如创建多个线程和多个双端队列。
6.4.3 框架设计
使用两个类完成分割任务,执行任务并合并结果。
①ForkJoinTask:创建ForkJoin 任务
两个子类(用时继承):
- RecursiveAction: 用于没有返回结果的任务
- RecursiveTask: 用于有返回结果的任务
②ForkJoinPool:ForkJoinTask 需要通过 ForkJoinPool 来执行。
Chapter 7 Java 中的13个原子操作类
7.1 原子更新基本类型类
- AtomicBoolean
- AtomicInteger
- AtomicLong
7.2 原子更新数组
- AtomicIntegerArray
- AtomicLongArray
- AtomicReferenceArray
- AtomicIntegerArray
7.3 原子更新引用类型
- AtomicReference
- AtomicReferenceFiledUpdater
- AtomicMarkableReference
7.4 原子更新字段类
- AtomicIntegerFiledUpdater
- AtomicLongFiledUpdater
- AtomicStampedReference
Chapter 8 Java 中的并发工具类
CountDownLatch、CyclicBarrier和Semphore 并发流程控制;
Exchanger 线程间交换数据
8.1 等待多线程完成的 CountDownLatch
允许一个线程或多个线程等待其他线程完成操作。
CountDownLatch 的构造函数接收一个 int 类型的参数作为计数器,如果你想等待 N 个点完成,这里就传入 N。调用 countDown 方法,N 就减1。CountDownLatch 的 await 方法会阻塞当前线程,直到 N = 0。N 个点,可以是 N 个线程,也可以是1个线程里面的 N 个执行步骤。用在多线程里面,只需要吧 CountDownLatch 的引用传递到线程里即可。
CountDownLatch 不能重新初始化或修改 CountDownLatch 对象的内部计数器的值。
8.2 同步屏障 CyclicBarrier
让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被拦截的线程才会继续运行(继续执行的线程没有先后顺序,取决于CPU调度)。
8.2.1 简介
默认构造方法是 CyclicBarrier (int parties),其参数表示屏障拦截的线程数量,每个线程调用 await 方法告诉 CyclicBarrier 我已经到达屏障,然后当前线程被阻塞。
还有一个高级构造方法 CyclicBarrier (int parties, Runnable barrierAction),用于在线程到达屏障时,优先执行 barrierAction,方便处理更复杂的业务场景。
8.2.3 CyclicBarrier 与 CountDownLatch 的区别
CountDownLatch 的计数器只能使用一次,但是 CyclicBarrier 的计数器可以使用 reset()方法重置,因此 CyclicBarrier 能处理更为复杂的业务场景。
8.3 控制并发线程数的 Semaphore
用来控制同时访问特定资源的线程数量,通过协调各个线程,以保证合理使用公共资源。
- 应用场景
做流量控制,特别是公共资源有限的应用场景,比如数据库连接。
8.4 线程间交换数据的 Exchanger
用于线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以通过 exchange 方法交换彼此的数据。
可以应用于遗传算法。
可以应用于校对工作。
Chapter 9 Java中的线程池
好处:
- 降低资源消耗。
- 提高响应速度。
- 提高线程的可管理性。
9.1 线程池的实现原理
处理流程中,先判断核心线程池是否已满,满了再判断工作队列是否已满,满了再判断线程池是否已满,满了再执行拒绝策略。
ThreadPoolExecutor 执行 execute()方法:
四种情况分析:
- 若当前运行的线程少于 coorPoolSize,则创建新线程来执行任务(需要获得全局锁,开销大)
- 若运行的线程大于等于 coorPoolSize,则将任务加入 BlockingQueue(大多数时候处于这里)
- 若 BlockingQueue 队列已满,则创建新的线程来处理任务(需要获得全局锁,开销大)
- 若创建新线程将使当前运行的总线程数超出 maximumPoolSize,那么执行拒绝策略。
工作线程: 线程池创建线程时,会将线程封装成工作线程 Worker,Worker在执行完任务之后还会循环获取工作队列里的任务来执行。
9.2 线程池的使用
9.2.1 线程池创建
通过 ThreadPoolExecutor 创建,很多个参数。
new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, millseconds, runnableTaskQueue, handler)
9.2.2 向线程池提交任务
execute() // 提交不需要返回值的任务,无法判断任务是否被线程池执行成功 submit() // 提交需要返回值的任务
submit(): 线程池会返回一个 future 类型的对象,通过这个对象可以判断任务是否执行成功,且可以通过 future 的 get 方法获取返回值,get 方法会阻塞当前线程直到任务完成,使用 get(long timeout, TimeUnit unit) 会阻塞一段时间后立即返回,这时任务可能还没有执行完成。
9.2.3 关闭线程池
shutdown( ) 或 shutdownNow( ) 方法。
原理是遍历线程池中的所有工作线程,然后逐个调用 interrupt 方法来中断线程,所以无法响应中断的任务可能永远无法停止。
通常使用 shutdown 来关闭线程池。