第3章-对象的共享
- 3.1 可见性
- 3.1.4 Volatile变量
- volatile变量的典型用法, 检查某个状态标记以判断是否退出循环,
vlatile boolean asleep; while (!asleep) {...dosomething();...}
- 当满足以下条件才应该使用volatile变量
- 对变量的写入操作不依赖变量的当前值, 或者你能确保只有单个线程更新变量的值
- 该变量不会与其他状态一起纳入不变性条件中
- 在访问变量时不需要加锁
- 3.2 发布与逸出
- 发布即使对象能够在当前作用域之外的代码中使用
- 逸出即当某个不应该发布的对象被发布
- 3.3 线程封闭
- 仅在单线程内访问数据, 不需要同步即线程封闭(Thread Confinement)
- Ad-hoc线程封闭, 指维护线程封闭性的职责完全由程序实现来承担(封闭性弱, 尽量不使用)
- 栈封闭, 只能通过局部变量才能访问对象
ThreadLocal
- 3.4 不变性
- 当满足以下条件时, 对象才是不可变的
- 对象创建以后其状态就是不能修改
- 对象的所有域都是
final
类型
- 对象是正确创建的(在对象的创建期间, this引用没有逸出)
- 3.5.3 安全发布的常用模式
- 要安全地发布一个对象, 对象的引用以及对象的状态必须同时对其他线程可见, 一个正确构造的对象可以通过以下方式安全发布
- 在静态初始化函数中初始化一个对象引用
- 将对象的引用保存到
volatile
类型的域或者AtomicReference
对象中
- 将对象的引用保存到某个正确构造对象的final类型域中
- 将对象的引用保存到一个由锁保护的域中
- 线程安全库中容器类提供以下安全发布保证
- 通过将一个键或者值放入
HashTable
、synchronizedMap
或者ConcurrentMap
中, 可以安全地将它发布给任何从这些容器中访问它的线程
- 通过将某个元素放入
Vector
、CopyOnWriteArrayList
、CopyOnWriteArraySet
、synchronizedList
或者synchronizedSet
中, 可以将该元素安全地发布到任何从这些容器中访问该元素的线程
- 通过将某个元素放入
BlockingQueue
或者ConcurrentLinkedQueue
中, 可以将该元素安全地发布到任何从这些队列中访问该元素的线程
- 3.5.4 试试不可变对象
- 如果对象从技术上来看是可变的, 但其状态在发布后不会再改变, 称为事实不可变对象(Effectively Immutable Object), 如
Date
- 3.5.5 可变对象
- 对象的发布需求取决于它的可变性
- 不可变对象可以通过任意机制来发布
- 事实不可变对象必须通过安全方式来发布
- 可变对象必须通过安全方式发布, 并且必须是线程安全的或者由某个锁保护起来
- 3.5.6 安全地共享对象
- 线程封闭
- 只读共享
- 线程安全共享, 内部实现同步
- 保护对象, 通过持有锁来访问
第4章-对象的组合
- 4.1 设计线程安全的类
- 找出构成对象状态的所有变量
- 找出约束状态变量的不变性条件
- 建立对象状态的并发访问管理策略
- 4.1.2 依赖状态的操作
- 如果在某个操作中包含有基于状态的先验条件, 那么这个操作称为依赖状态的操作, 如不能从空队列已从一个元素, 在删除元素前, 队列必须处于非空状态
- 4.2.1 Java监视器模式
- 遵循Java监视器模式的对象会把对象的所有可变状态封装起来, 并由对象自己的内置锁来保护,
Vector
, Hashtable
均使用该模式
- 4.4 在现有的线程安全类中添加功能
- 4.4.1 客户端加锁机制, 如
putIfAbsent()
操作, 需要对synchronizedList
进行加锁
- 4.4.2 组合
- 当为现有的类添加一个原子操作时, 更好的方法是组合(Composition), 如
public synchronized boolean putIfAbsent() {...}
, public ImprovedList(List<T> list) {...}
, ImprovedList
通过将List
对象的操作委托给底层的List
实例来实现List
的操作, 同时还添加一个原子的putIfAbsent
方法
第5章-基础构建模块
- 5.1 同步容器类的问题
- 同步容器类都是线程安全的, 但在某些情况下可能需要额外的客户端加锁来保护符合操作, 容器上常见的符合操作包括, 迭代(反复访问元素, 直到遍历完容器中所有元素)、跳转(根据指定顺序找到当前元素的下一个元素)以及条件运算, 例如”若没有则添加”
- 5.1.2 迭代器与
ConcurrentModificationException
- 及时失败(fail-fast), 容器在迭代过程被修改, 那么在
hasNext()
或next()
, 就会抛出一个ConcurrentModificationException
异常
- 与迭代
Vector
一样, 要避免出现ConcurrentModificationException
就必须在迭代过程持有容器的锁
- 如果不希望在迭代期间对容器加锁, 那么替代方法就是”克隆”容器, 并在副本上进行迭代
- 5.1.3 隐藏迭代器
- 索然加锁可以防止迭代器抛出
ConcurrentModificationException
, 在所有对共享容器进行迭代的地方都需要加锁, 比如隐藏迭代(间接迭代操作)
- 容器的
hashCode()
和equals()
等方法会间接执行迭代操作, 当容器作为另一个容器的元素或键值时, 可能出现ConcurrentModificationException
, 同样containsAll()
, removeAll()
, retainAll()
等方法, 以及把容器作为参数的构造函数, 都会对容器进行迭代, 所有这些间接的迭代操作都可能抛ConcurrentModificationException
- 5.2 并发容器
- ConcurrentHashMap
- 分段锁, 任意数量的读取线程可以并发访问
Map
执行读取操作的线程和执行写入操作的线程可以并发底访问Map
, 并且一定数量的写入线程可以并发修改Map
ConcurrentHashMap
与其他并发容器一起增强了同步容器类: 它们提供的迭代器不会抛出ConcurrentModificationException
, 因此不需要在迭代过程中对容器加锁。ConcurrentHashMap
返回的迭代器具有弱一致性(Weakly Consistent), 而非及时失败(fail-fast)。弱一致性的迭代器可以容忍并发修改, 当创建迭代器时会遍历已有的元素, 并可以(但不保证)在迭代器被构造后将修改操作反应给容器
- 对于需要整个
Map
上计算的方法, 例如size()
和isEmpty()
, 这些方法的语义被略微减弱了以反应容器的并发特性。由于返回的结果在计算时可能已国企, 它实际上只是一个估计值。原因size()
和isEmpty()
在并发环境下用处很小, 因为它们的返回值总在变化, 因此需求被弱化, 以换取get()
, put()
, containsKey()
和remove()
等性能优化
- 在通过原子方式添加映射, 或者对
Map
迭代若干次并在此期间保持元素顺序相同等场景会用到Hashtable
或synchronizedMap
- 5.2.2 额外的原子
Map
操作
- “若没有则添加”, “若相等则移除(Remove-If-Equal)”和”若相等则替换(Replace-If-Equals)”已在
ConcurrentMap
实现为原子操作, 有该需求, 考虑使用ConcurrentMap
- 5.2.3
CopyOnWriteArrayList
- 写入时复制(Copy-On-Write)容器的线程安全性在于, 只要正确发布一个事实不可变的对象, 那么在访问该对象时不在需要进一步的同步
- 5.3 阻塞队列和生产者 - 消费者模式
- 仅当有足够多的消费者, 并且总时有一个消费者准备好获取交付的工作时, 才适合使用同步队列
SynchronousQueue
- 5.3.2 串行线程封闭
- 对象池利用了串行线程封闭, 将对象借给一个请求线程。只要对象池包含足够的内部同步来安全地发布池中的对象, 并且只要客户代码本身不会发布池中的对象, 或者在将对象返回给对象池后就不再使用它, 那么就可以安全地在线程之间传递所有权
- 5.3.3 双端队列与工作密取
- 阻塞队列适用于生产者-消费者模式, 双端队列适用于工作密取(Work Stealing)模式, 工作密取非常适用于既是消费者也是生产者问题-当执行某个工作时可能导致出现更多的工作, 例如爬虫, 搜索图算法等
- 5.4 阻塞方法与中断方法
- 线程可能会阻塞或暂停执行, 原因有多种: 等待I/O操作结束, 等待获取一个锁, 等待从
Thread.sleep()
方法中醒来, 或是等待另一个线程的计算结果
- 中断是一种协作机制, 一个线程不能强制其它线程停止正在执行的操作而去执行其它的操作。虽然在API或者语言规范中并没有为中断定义任何特定应用级别的语义, 但最长使用中断的情况是取消某个操作。方法对中断请求的响应越高, 就越容易及时取消哪些执行时间很长的操作
- 当在代码中调用了一个将抛出
InterruptedException
异常的方法时, 你自己的方法也就变成了一个阻塞方法, 并且必须要处理对中断的响应。对于库代码来说, 有两种基本选择:
- 传递
InterruptedException
, 将InterruptedException
传递给方法的调用者
- 恢复中断,
catch (InterruptedException) { Thread.currentThread().interrupt(); // 恢复被中断状态}
, 通过调用当前线程上的interrupt()
方法恢复中断状态, 这样在调用栈中更高层的代码将看到引发了一个中断
- 5.5 同步工具类
- 同步工具类可以是任何一个对象, 只要它根据其自身的状态来协调线程的控制流。阻塞队列可以作为同步工具类, 其他类型的同步工具类还包括信号量(Semaphore)、栅栏(Barrier)、以及闭锁
(Latch)
- 所有同步工具类都包含一些特定的结构化属性: 它们封装了一些状态, 这些状态将决定执行同步工具类的线程是继续执行还是等待, 此外还提供了一些方法对状态进行操作, 以及另一些方法用于高效地等待同步工具类进入预期状态
- 5.5.1 闭锁, 可以延迟线程的进度直到其到达终止状态, 闭锁可以用来确保某些活动直到其他活动都完成才继续执行, 例如: 1). 确保某个计算在其需要的所有资源都被初始化之后才继续执行, 2). 确保某个服务在其依赖的所有其他服务都已经启动之后才启动, 3). 等待直到某个操作的所有参与者(比如王者开局)都就绪再继续执行
CountDownLatch
是一种灵活的闭锁实现, 可以使一个或多个线程等待一组事件发生。闭锁状态包含一个计数器, 表示一个事件已经发生, 而await()
方法等待计数器达到零, 这表示所有需要等待的事件都已经发生
- 5.5.2
FutureTask
FutureTask
也可以用做闭锁。(FutureTask
实现了Future
语义, 表示一种抽象的可生成结果的计算)。FutureTask
表示的计算是通过Callable
来实现的, 相当于一种可生成结果的Runnable
, 包含3中状态: 等待运行(Waiting to run), 正在运行(Running)和运行完成(Completed)
Future#get()
的行为取决于任务的状态, 如果任务已经完成, 那么get()
会立即返回结果, 否则get()
将阻塞直到任务进入完成状态, 然后返回结果或抛出异常
FutureTask
在Executor
框架中表示异步任务, 此外还可以用来表示一些时间较长的计算
Callable
表示的任务可以抛出受检查的或未受检查的异常, 并且任何代码都可能抛出一个Error
。无论任务代码抛出什么异常, 都会被封装到一个ExecutionException
中, 并在Future#get()
中重新抛出
- 5.5.3 信号量
- 计数信号量(Counting Semaphore)用来控制同时访问某个特定资源的操作数量, 或同时执行某个指定操作的数量
- 计算信号量的一种简化形式是二值信号量, 即初始值为1的
Semaphore
。二值信号量可用做互斥体(mutex)
Semaphore
可用于实现资源池, 例如数据库连接池
- 5.5.4 栅栏
- 栅栏(Barrier)类似于闭锁, 它能阻塞一组线程直到某个事件发生。栅栏和闭锁的关键区别是, 所有线程必须同时到达栅栏位置, 才能继续执行。闭锁用于等待事件, 而栅栏用于等待其他线程
CyclicBarrier
可使一定数量的参与方反复地在栅栏位置汇集, 它在并行迭代算法中非常有用: 即将一个问题拆分成一系列相对独立的子问题。当线程到达栅栏位置时将调用await()
方法, 这个方法将阻塞直到所有线程都到达栅栏位置。如果对await()
的调用超时, 或者await()
阻塞的线程被中断, 那么栅栏被打破, 所有阻塞的await()
调用将终止并抛出BrokenBarrierException
- 另一种形式的栅栏
Exchanger
, 它是一种两方(Two-Party)栅栏, 各方在栅栏位置上交换数据。当两方执行不对称的操作时, Exchanger
非常有用, 例如当一个线程向缓冲区写入数据, 另一个线程从缓冲区中读取数据。这些线程可以使用Exchanger
来汇合, 并将满的缓冲区和空的缓冲区交换
第6章-任务执行
- 6.1.3 无限制创建线程的不足
- 线程生命周期的开销(创建和销毁等)非常高。
- 资源消耗, 活跃的线程会消耗系统资源, 尤其是内存
- 稳定性, 在可创建线程的数量上存在限制, 破坏限制很可能抛出
OutOfMemoryError
- 6.2.3 线程池, 指管理一组同构工作线程的资源池
- 6.2.4
Executor
的生命周期
ExexutorService
的生命周期有3种状态: 运行、关闭和已终止; shutdown()
方法将执行平缓的关闭过程, 不再接受信的任务, 同时等待已提交的任务执行完成; shutdownNow()
方法将执行粗暴的关闭过程, 将尝试取消所有运行中的任务, 并且不再启动队列尚未开始执行的任务
- 6.2.5 延迟任务与周期任务
Timer
存在一些缺陷, 因此应该考虑使用ScheduledThreadPoolExecutor
来代替它, Timer
在执行所有定时任务时只创建一个线程。如果某个任务的执行时间过长, 那么将破坏其他TimeTask
的定时精确性
Timer
抛出一个未检查的异常, 那么Timer
将表现出糟糕的行为。Timer
线程并不捕获异常, 因此当TimerTask
抛出未检查的异常时将终止定时线程, 且Timer
也不会恢复线程的执行
- 6.3.2 携带结果的任务
Callable
与Future
Callable
认为主入口(即call
)将返回一个值, 并可能抛出一个异常
Executor
执行任务有4个生命周期阶段: 创建、提交、开始和完成
Future
表示一个任务的生命周期, 并提供了相应的方法来判断是否已经完成或取消, 以及获取任务的结果和取消任务
- 6.3.5
CompletionService:Executor
与BlockingQueue
- 如果向
Executor
提交了一组计算任务, 并且希望在计算完成后获得结果, 可以使用完成服务(CompletionService
)
第7章-取消与关闭
- 7.1 任务取消
- 在
Java
中没有一种安全的抢占式方法来停止线程, 因此也就没有安全的抢占式方法停止任务。只有一些协作式机制, 使请求取消的任务和代码都遵循协商好的协议
- 设置某个”已请求取消(Cancellation Requested)”标记
- 7.1.1 中断
- 标记法如果这种方法的任务调用了一个阻塞方法, 例如
BlockingQueue#put()
, 那么任务可能永远不会检查取消标志, 因此永远不会结束
- 线程中断是一种协作机制, 可用于取消任务
- 中断并不会真正地中断正在运行的线程, 而只是发出中断请求, 由线程在下一个合适的时刻中断自己
- 7.1.2 中断策略
- 最合理的中断策略是某种形式的线程级(Thread-Level)取消操作或服务级(Service-Level)取消操作: 尽快退出, 在必要时进行清理, 通知某个所有者该线程已经退出
- 7.1.3 响应中断
- 传递异常(可能在执行某个特定于任务的清除操作之后), 从而使你的方法也成为可中断的阻塞方法
- 恢复中断状态, 从而使调用栈中的上层代码能够对其进行处理
- 如果不想或无法传递
InterruptedException
, 另一种方式使保存中断请求, 标准的方法就是通过再次调用interrupt
来恢复中断状态
- 7.1.5 通过
Future
来实现取消
Future
是一种抽象机制来管理任务的生命周期, 处理异常, 以及实现取消
Future
拥有cancel()
方法, 该方法带有boolean
类型得参数mayInterruptIfRunning
, 表示取消操作是否成功。如果mayInterruptIfRunning
为true
并且任务当前正在某个线程中运行, 那么这个线程能被中断。如果为false
, 即若任务还没有启动, 就不要运行它
- 7.1.6 处理不可中断的阻塞
- 如果线程由于执行同步的
Socket I/O
或者等待获得内置锁而阻塞, 那么中断请求只能设置线程的中断状态, 除此之外没有其他作用, 如下:
- Java.io包中的同步
Socket I/O
- Java.io包中的同步I/O
Selector
的异步I/O
- 获取某个锁。如果线程由于等待某个内置锁而阻塞, 那么将无法响应中断
- 7.2.2 关闭
ExecutorService
shutdown()
速度慢, 但更安全
shutdownNow
速度快, 但风险更大
- 7.2.3 “毒丸”对象
- 另一种关闭生产者-消费者服务的方式就是使用”毒丸(Poison Pill)”对象: 毒丸是指放在队列的对象, 其含义是得到这个对象时, 立即停止
- 只有在生产者和消费者的数量都已知的情况下, 才可以使用毒丸对象
- 7.3 处理非正常的线程终止
// 典型的线程池工作者线程结构
public void run() {
Throwable thrown = null;
try {
while (!isInterrupted())
runTask(getTaskFromWorkQueue());
} catch (Throwable e) {
thrown = e;
} finally {
threadExited(this, thrown);
}
}
- 未捕获异常的处理
- 在
Thread
API中同样提供了UncaughtExceptionHandler
, 它能检测出某个线程由于未捕获的异常而终结的情况。
- 当一个线程由于未捕获异常而退出时, JVM会把这个事件报告给应用程序提供的
UncaughtExceptionHandler
异常处理器。如果没有提供任何异常处理器, 那么默认的行为是将栈追踪信息输出到System.err
- 7.4 JVM关闭
- 7.4.1 关闭狗子
- 在正常关闭中, JVM首先调用所有已注册的关闭钩子(Shutdown Hook)。关闭钩子是指通过
Runtime#addShutdownHook()
注册的但尚未开始的线程
- 7.4.3 终结器
- 垃圾回收起对那些定义了
finalize
方法的对象会进行特殊处理: 在回收期释放它们后, 调用它们的finalize
方法, 从而保证一些持久化的资源被释放
第8章-线程池的使用
- 8.1 在任务与执行策略之间的隐性耦合
- 以下任务使用
Executor
需要明确指定执行策略
- 依赖性任务, 如果提交给线程池的任务依赖其他的任务, 那么隐含给执行策略带来约束, 小心维持执行策略以避免产生活跃性问题
- 使用线程封闭机制的任务, 对象可以封闭在任务线程中, 不需要同步, 仅限于单线程环境
- 对响应时间敏感的任务
- 使用
ThreadLocal
的任务, Executor
可能会重用线程, 只有当线程本地值的生命周期受限于任务的生命周期时, 在线程池的线程中使用ThreadLocal
才有意义。
- 8.1.1 线程饥饿死锁
- 在更大的线程池中, 如果所有正在执行任务的线程都由于等待其他仍处于工作队列的任务而阻塞, 这种现象成为线程饥饿死锁(Thread Starvation Deadlock)
- 如果线程池勾搭, 那么当多个任务通过栅栏(Barrier)机制来彼此协调时, 将导致线程饥饿死锁
- 8.2 设置线程池的大笑
- 对于计算密集型的任务, 当线程池的大小为N(cpu) + 1时, 通常能实现最优的利用率
- CPU利用率的水平: N(cpu) = number of CPUS; U(cpu) = target CPU utilization, 0<= U(cpu) <= 1; W/C = ratio of wait time to compute time; 要使处理器达到期望的使用率, 线程池的最优大笑等于: N(threads) = N(cpu) * U(cpu) * (1 + W/C)
- 8.3.2 管理队列任务
- 对于
Executor
, newCachedThreadPool
工厂方法是一种很好的默认选择
- 8.4 扩展
ThreadPoolExecutor
- 在执行任务的线程中将调用
beforeExecute
和afterExecute
等方法, 在这些方法中还可以添加日志、计时、监视或统计信息收集的功能。无论任务是从run
中正常返回, 还是抛出一个异常而返回, afterExecute
都会被调用。(如果任务在完成后带有一个Error
, 那么就不会调用afterExecute
) 如果beforeExecute
抛出一个RuntimeException
, 那么任务将不被执行, 并且afterExecute
也不会被调用
第10章-避免活跃性危险
- 10.1 死锁
- 10.1.1 锁顺序死锁
- 两个线程试图以不同的顺序来获得相同的锁可能导致顺序死锁
- 10.1.2 动态的锁顺序死锁
- 类似顺序死锁, 不过锁对象由传参传入, 因而是”动态的”
- 10.1.3 在协作对象之间发生的死锁
- 一个线程调用
setLocation()
, 另一个线程调用getImage()
class Taxi {
...
public synchronized Point getLocation() {...}
public synchronized void setLocation(Point location) {
...
if (location.equals(destination))
dispatcher.notifyAvailable(this);
}
}
class Dispatcher {
...
public synchronized void notifyAvailable(Taxi taxi) {
...
}
public synchronized Image getImage() {
...
for (Taxi t : taxis)
image.drawMarker(t.getLocation());
}
}
- 10.1.4 开放调用
- 如果在调用某个方法时不需要持有锁, 那么这种调用被成为开放调用(Open Call)
- 10.1.5 资源死锁
- 多个线程互相持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁, 当它们在相同的资源集合上等待时, 也会发生死锁
- 另一种基于资源的死锁形成就是线程饥饿死锁(Thread-Starvation Deadlock), 例如一个任务提交另一个任务, 并等待提交任务在单线程的
Executor
中执行完成
- 有界线程池/资源池与互相依赖的任务不能一起使用
- 10.2 死锁的避免和诊断
- 10.2.1 支持定时的锁
- 10.2.2 通过线程转储(Thread Dump)信息来分析死锁
- 10.3 其他活跃性危险
- 10.3.1 饥饿
- 当线程由于无法访问它所需要的资源而不能继续执行时, 就发生了”饥饿(Starvation)”
- 10.3.2 糟糕的响应性
- 10.3.3 活锁
- 活锁不会阻塞线程, 但也不能继续执行, 因为线程将不断重复执行相同的操作, 而且总会失败。活锁通常发生在处理事务消息的应用程序中, 消息失败回滚, 且重新放到队列投, 反复调用的过程
第11章-性能与可伸缩性
- 11.1 对性能的思考
- 多线程的性能开销: 1). 线程之间的协调(例如加锁、触发信号以及内存同步等), 增加的上下文切换, 线程的创建和销毁, 以及线程的调度等
- 通过并发获得更好的性能, 需要做好: 1). 更有效地利用现有处理资源; 2). 在出现新的处理资源时使程序尽可能地利用这些新资源
- 11.1.1 性能与可伸缩性
- 可伸缩性指的是: 当增加计算资源时(例如CPU、内存、存储容量或I/O带宽), 程序的吞吐量或处理能力相应地增加
- 在进行可伸缩性调优时, 其目的设法将问题的计算并行化, 从而能利用更多的计算资源来完成更多的工作
- 11.3.1 上下文切换
- 上下文切换将保存当前运行线程的执行上下文, 并将新调度进来的线程的执行上下文设置为当前上下文
- 11.3.2 内存同步
- 在
synchronized
和volatile
提供的可见性保证中可能会使用一些特殊指令, 即内存栅栏(Memory Barrier), 内存栅栏可以刷新缓存, 使缓存无效, 刷新硬件的写缓冲, 以及停止执行管道
- 11.3.3 阻塞
- JVM在实现阻塞行为时, 可以采用自旋等待(Spin-Waiting,指通过循环不断地尝试获取锁, 直到成功)或通过操作系统挂起被阻塞的线程
- 11.4 减少锁的竞争
- 在并发程序中, 对可伸缩性的最主要威胁就是独占方式的资源锁
- 影响在锁上发生竞争的两个因素: 1). 锁的请求频率, 2). 每次持有该锁的时间
- 3种降低锁的竞争程度的方式: 1). 减少锁的持有时间, 2). 降低锁的请求频率, 3). 使用带有协调机制的独占锁, 这些机制允许更高的并发性
- 11.4.1 缩小锁的范围(“快进快出”)
- 11.4.2 减小锁的粒度
- 另一种减小锁的持有时间的方式时降低线程请求锁的频率, 可以通过锁分解和锁分段等技术实现
- 11.4.3 锁分段
- 将锁分解技术进一步扩展为对一组独立对象上的锁进行分解, 称为锁分段
- 锁分段的劣势: 与采用单个锁来实现独占锁访问相比, 要获取多个锁来实现独占访问将更加困难并且开销更高
- 11.4.4 避免热点域
- 如果程序采用锁分段技术, 那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生的竞争频率
- 缓存
size
操作的结果, 计数器称为热点域, 因为每个导致元素数量发生变化的操作都需要访问它
第13章-显示锁
- 13.1
Lock
与ReentrantLock
- 如果没有使用
finally
来释放Lock
, 相当于启动了定时炸弹, 将很难追踪到最初发生错误的位置, 因为没有记录应该释放锁的位置和时间
- 13.1.1 轮询锁与定时锁
- 13.1.2 可中断的锁获取操作
- 13.2 性能考虑因素
ReentrantLock
比内置锁提供更好的竞争性能; 竞争性能是可伸缩性的关键要素: 如果有越多的资源被耗费在锁的管理和调度上, 那么应用程序得到的资源就越少
- 13.3 公平性
- 公平锁: 线程将按照它们发出请求的顺序来获取锁
- 非公平锁允许插队: 当一个线程请求非公平锁时, 如果在发出请求的同时该锁的状态变为可用, 那么线程将跳过队列中所有的等待线程并获得这个锁
- 非公平锁的性能高于公平锁的性能原因: 在恢复被挂起的线程与该线程真正开始运行之间存在着严重的延迟。假设线程A持有锁, 线程B请求锁, 此时B被挂起, 当A释放锁时, B被唤醒; 与此同时,如果C请求这个锁, 那么C很可能会再B被完全唤醒之前获得锁;而B获得锁的时刻并没有推迟, C更高地获得锁
- 当持有锁的时间相对较长, 或者请求锁的平均时间间隔较长, 那么应该使用公平锁
- 13.4 在
synchronized
和ReentrantLock
之间进行选择
ReentrantLock
在加锁和内存上提供的语义与内置锁相同, 此外还包含定时的锁等待, 可中断的锁等待, 公平性, 以及实现非块结构加锁
- 在内置锁无法满足需求情况下,
ReentrantLock
可以作为高级工具, 当需要可定时的, 可轮询的, 与可中断的锁获得操作, 公平队列, 以及非块结构的锁的高级功能才应该使用ReentrantLock
, 否则应该有限使用synchronized
- 内置锁的优势: 1).
ReentrantLock
的危险性比同步机制高, 2). 在线程转储(Thread Dump)中能给出在哪些调用帧中获得了哪些锁, 并能够检测和识别发生死锁的线程, 3). synchronized
时JVM的内置属性, 未来可能能提升
- 13.5 读 - 写锁
ReadWriteLock
中的可选实现包括: 1). 释放优先, 2). 读线程插队, 3). 重入性, 4). 降级, 5). 升级
ReentrantReadWriteLock
的公平锁中, 等待时间最长的线程将有限获得锁; 如果这个锁由读线程持有, 而另一个线程请求写入锁, 那么其他读线程都不能获得读取锁, 直到写线程使用完并且释放了写入锁。在非公平锁中, 线程获得访问许可的顺序是不确定的; 写线程降级为读线程是可以的, 但从读线程升级为写线程则是不可以的(这样做会导致死锁)
- 如果需要对另一种
Map
实现(例如LinkedHashMap
)提供并发性更高的访问, 那么可以使用ReentrantReadWriteLock
来包装Map
第14章-构建自定义的同步工具
- 14.1.3 条件队列
- 条件队列使得一组线程(称之为等待线程集合)能够通过某种方式来等待特定的条件变成真
- 如果某个功能无法通过”轮询和休眠”来实现, 那么使用条件队列也无法实现, 但条件队列使得在表达和管理状态依赖性时更加简单和高效
- 14.2.1 条件谓词
- 在条件等待中存在一种重要的三元关系, 包括加锁,
wait
方法和一个条件谓词。在条件谓词中包含多个状态变量, 而状态变量由一个锁来保护, 因此在测试条件谓词之前必须先持有这个锁, 锁对象与条件队列对象(即调用wait
和notify
等方法所在的对象)必须时同一个对象
- 14.2.2 过早唤醒
- 当使用条件等待时(例如
Object#wait
或Condition#await
)
- 通常都有一个条件谓语-包括一些对象状态的测试, 线程在执行前必须通过这些测试
- 在调用
wait
之前测试条件谓语, 并且从wait
返回时进行测试
- 在一个循环中调用
wait
- 确保使用与条件队列相关的锁来保护构成条件谓语的各个状态变量
- 当调用
wait
、notify
或notifyAll
等方法时, 一定要持有与条件队列相关的锁
- 在检查条件谓词之后以及开始执行相应的操作之前, 不要释放锁
- 14.2.4 通知
- 只有同时满足一下两个条件, 才能用单一的
notify
而不是notifyAll
- 所有等待线程的类型都相同。只有一个条件谓词与条件队列相关, 并且每个线程在从
wait
返回后将执行相同的操作
- 单进单出。在条件变量上的每次通知, 最多只能唤醒一个线程来执行
- 14.3 显示的
Condition
对象
- 内置条件队列存在缺陷, 每个内置锁都只能有一个相关联的条件队列
- 如果想编写一个带有多个条件谓词的并发对象, 或想获得除了条件队列可见性之外的更多控制权, 就可以使用显式的
Lock
和Condition
Condition
对象继承了相关的Lock
对象的公平性, 对于公平的锁, 线程会依照FIFO顺序从Condition#await
中释放
- 与内置锁和条件队列一样, 当使用显式的
Lock
和Condition
时, 也必须满足锁、条件谓词和条件变量之间的三元关系。在条件谓词中包含的变量必须由Lock
来保护, 并且在检查条件谓词以及调用await
和signal
时, 必须持有Lock
对象
- 14.5
AbstractQueueSynchronizer
- 在基于AQS够着的同步器类中, 最基本的操作包括各种形式的获取操作和释放操作
- 在使用
CountDownLatch
时, “获取”操作意味着”等待并直到闭锁到达结束状态”, 而在使用FutureTask
时, 则意味着”等待并直到任务已经完成”
- AQS负责管理同步器类中的状态, 它管理了一个整数状态信息, 可以通过
getState
、setState
以及compareAndSetState
等protected
类型方法来进行操作
- 获取操作可以是独占操作(例如
ReentrantLock
), 也可以是非独占操作(例如Semaphore
和CountDownLatch
), 获取操作包含两部分。1). 同步器判断当前状态是否允许获得操作, 如果是, 则允许线程执行, 否则获取操作将阻塞或失败; 2). 更新同步器的状态, 获取同步器的某个线程可能会对其他线程能否也获取该同步器造成影响
- 在同步器的子类中, 可以根据其获取操作和释放操作的语义, 使用
getState
、setState
以及compareAndSetState
来检查和更新状态, 并通过返回的状态值来告知基类”获取”或”释放”同步器的操作是否成功。例如, 如果tryAcquireShared
返回一个负值, 那么表示获取操作失败, 返回零值表示同步器通过独占式被获取, 返回正值则表示同步器通过非独占方式被获取
- 14.6
java.util.concurrent
同步器类中的AQS
java.util.concurrent
中许多阻塞类, 例如ReentrantLock
、Semaphore
、ReentrantReadWriteLock
、CountDownLatch
、SynchronousQueue
和FutureTask
等, 都是基于AQS构建的
- 14.6.2
Semaphore
与CountDownLatch
Semaphore
将AQS的同步状态用于保存当前可用许可的数量
CountDownLatch
在同步状态中保存的是当前的计数值, countDown
方法调用release
从而导致计数值递减, 并且当计数值为零时, 接触所有等待线程的阻塞
- 14.6.3
FutureTask
- 再
FutureTask
中, AQS同步状态被用来保存任务的状态, 例如正在运行、已完成或已取消。FutureTask
还维护一些额外的状态变量, 用来保存计算结果或者抛出异常。此外它还维护了一个引用指向正在执行计算任务的线程,因而如果任务取消,该线程就会中断
- 14.6.4
ReentrantReadWriteLock
- 在读取锁上的操作将使用共享的获取方法与释放方法, 在写入锁上的操作将使用独占的获取方法和释放方法
- 在
ReentrantReadWriteLock
中, 当锁可用时, 如果位于队列头部的线程执行写入操作, 那么线程会得到这个锁, 如果位于队列头部的线程执行读取访问, 那么队列中在第一个写入线程之前的所有线程都将获得这个锁
第15章-原子变量与非阻塞同步机制
- 15.1 锁的劣势
- 当一个变量依赖其他的变量时, 或者当变量的新值依赖于旧值时, 就不能使用
volatile
变量
- 15.2 硬件对并发的支持
- 处理器支持原子的指令: 1). 测试并设置(Test-ans-Set), 获取并递增(Fetch-and-Increment)以及交换(Swap)等指令
- 15.2.1 比较并交换
- CAS的典型使用模式: 首先从V中读取值A, 并根据A计算新值B, 然后再通过CAS以原子方式将V中的值由A变成B(只要再这期间没有任何线程将V的值修改位其他值)
- 15.2.2 非阻塞的计算器
- CAS的主要缺点是: 它将使调用者处理竞争问题(通过重试、回退、放弃), 而在锁中能自动处理竞争问题(线程再获取锁之前将一直阻塞)
- 15.3 原子变量类
- 12个原子变量类, 可分为4组: 标量类(Scalar)、更新器类、数组类以及复合变量类。最常用的原子变量就是标量类:
AtomicInteger
、AtomicLong
、AtomicBoolean
以及AtomicReference
- 15.4 非阻塞算法
- 一个线程的失败或挂起不会导致其他线程也失败或挂起, 那么这种算法就被成为非阻塞算法。如果在算法的每个步骤中都存在某个线程能够执行下去, 那么这种算法也被成为无锁(Lock-Free)算法, 如果在算法中仅将CAS用于协调线程之间的操作, 并且能正确地实现, 那么它既使一种无阻塞算法, 又是一种无锁算法
参考