Java并发编程实战
2024-07-14 18:05:44 0 举报
AI智能生成
登录查看完整内容
为你推荐
查看更多
本篇专注于《Java并发实战》一书的阅读摘要,不仅涵盖了核心知识点,还融入了诸多概念的简易解读,旨在为那些在Java并发领域探索中感到困惑或挑战的读者提供显著的帮助。通过将复杂的并发原理以通俗易懂的方式阐述,此笔记极大地降低了学习门槛,让即使是初学者也能轻松掌握并发编程的要领,从而在实践中游刃有余。【如侵权请联系删除】
作者其他创作
大纲/内容
资源利用率
公平性
便利性
计算机中加入操作系统来实现多个程序的同时执行,主要基于以下原因
线程共享进程的内存资源
1.1 并发简史
1.2.1 发挥多处理器的强大能力
将一种类型的任务封装为Runnable
1.2.2 建模的简单性
比如套接字
1.2.3 异步事件的简化处理
其实就是主线程拆分为子线程
1.2.4 响应更灵敏的用户界面
1.2 线程的优势
多个线程中的操作执行顺序是不可预测的
1.3.1 安全性问题
就是死锁那些东西
1.3.2 活跃性问题
线程状态的切换、频繁切换上下文、线程调度
1.3.3 性能问题
1.3 线程带来的风险
假如我们并没有显式的创建线程,JVM的垃圾回收,框架等等也都有创建线程
1.4 线程无处不在
第一章 简介
线程或锁在并发编程中的所用,类似于铆钉和工子梁在土木工程中的作用
要编写线程安全的代码,核心在于要对状态访问操作进行管理,特别是对共享和可变状态的访问
【共享】意味着变量可以由多个线程同时访问,【可变】意味着变量的值在其生命周期内发生变化
怎么保证安全,同步机制(synchronized、volatile、显式锁、原子变量)
1,使用不可变对象
2,同步访问可变状态
3,使用线程安全的数据结构
4,尽量减少锁的持有时间
要设计一个线程安全的类
某个类的行为与其规范完全一致
最核心的概念就是正确性
线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为
通常,线程安全性的需求并非来源于对线程的直接使用,而是使用像Servlet这样的框架
计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能有正在执行的线程访问
不包含可变状态
纯函数
独立性
什么是无状态对象
2.1 什么是线程安全的
++count 看起来是一段函数,其实是读取-修改-写入三个操作序列
在并发编程中,由于不恰当的执行时序而出现不正确的结果就是竞态条件
2.2.1 竞态条件
比如未加锁锁的懒加载单例,可能导致创建多个实例
2.2.2 示例:延迟初始化的竞态条件
包含了一组必须以原子方式执行的操作以确保线程安全性
原子变量类 AtomicLong
2.2.3 复合操作
2.2 原子性
Synchronized Block
同步代码块包含两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块
每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁或监视锁
Java的内置锁相当于一中互斥体(或互斥锁)
这种方式又会导致性能问题
2.3.1 内置锁
内置锁是可重入的
重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者的线程
当计数值为0时,这个锁就被认为是没有被任何线程持有
当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1
如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。
当计数值为0,这个锁将被释放
重入避免了一些死锁的发生
2.3.2 重入
2.3 加锁机制
每个共享的和可变的变量都应该只由一个锁来保护,从而维护人员知道是哪个锁
一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象商不会发生并发访问
2.4 用锁来保护状态
对尽可能短的代码路径进行同步
子主题
2.5 活跃性与性能
第二章 线程安全性
本章将介绍如何共享和发布对象
我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态
而且我们希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化
内存可见性
在没有同步的情况下,编译器、处理器以及运行时等可能对操作的执行顺序进行一些意想不到的调整
重排序
可以理解为读到了被其他线程修改前的数据有点像事务中的脏读
3.1.1 失效数据
Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量JVM允许将64位的读操作或写操作分解为两个32位的操作
3.1.2 非原子的64位操作
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值。所有执行读操作或者写操作的线程都必须在同一个锁上同步
拿到了这把锁,线程才能看到那个共享变量
问题:如果不是同一把锁呢?
3.1.3 加锁与可见性
稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。
当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。
volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值
不建议过度依赖volatile变量提供的可见性
正确示例:比如某个boolean标志位
加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性
3.1..4 Volatile变量
3.1 可见性
使对象能够在当前作用域之外的代码中使用
发布
比如对象在构造完成之前
比如本应是私有的变量已经被发布了
不应该发布的对象被发布
在构造过程中使this引用溢出的一个常见错误是,在构造函数中启动一个线程
如果想在构造函数中注册一个时间监听器或启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造过程
逸出
3.2 发布和逸出
如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭
比如JDBC的Connection对象。连接池将一个Connection对象交给A线程,在这个Connection对象返回之前,不会把他交给其他线程使用
例如局部变量和ThreadLocal类
维护线程封闭性的职责完全由程序实现来承担
脆弱,尽量少用
3.3.1 Ad-hoc线程封闭技术
栈封闭式线程封闭的一种特例
也被称为线程内部使用或者线程局部使用,不要与核心类库中的ThreadLocal混淆
如果局部变量被发布,那么封闭性将被破坏,并导致对象的逸出
3.3.2 栈封闭
维持线程封闭性 的一种更规范方法
这个类能使线程中的某个值与保存值的对象关联起来
ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值
ThreadLocal对象通常用于防止对可变的单实例变量或全局变量进行共享例如:在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个Connection对象。
当某个频繁执行的操作需要一个临时对象,例如一个缓存区,而同时又希望避免每次执行时都重新分配该临时对象,就可以使用这项技术
3.3.3 ThreadLocal类
3.3 线程封闭
如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。
对象创建以后其状态就不能修改
对象的所有域都是final类型
对象是正确创建的(在对象的创建期间,this引用没有逸出)
不可变对象需要满足的条件
final 域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步
3.4.1 Final域
将多个可变变量封装在由volatile修饰的变量内部
3.4.2 示例:使用Volatile 类型来发布不可变对象
3.4 不变性
这段代码会运行失败。由于存在可见性问题,其他线程看到的Holder对象将处于不一致的状态,即便 在该对象的构建函数中已经正确地构建了不变性条件。这种不正确的发布导致其他线程看到尚未创建完成的对象
// 不安全的发布 public Holder holder; public void initialize() { holder = new Holder(42); }
1,除了发布对象的线程外,其他线程可以看到的Holder域是一个失效值因此将看到一个空引用或之前的旧值
2,线程看到Holer应用的值是最新的,但Holder状态的值却是失效的某个线程在第一次读取域时得到失效值,而再次读取这个域时会得到一个更新值这也是抛出AssertionError的原因
3.5.1 不正确的发布: 正确的对象被破坏
即使在发布不可变对象的引用时没有使用同步,也仍然可以安全地访问该对象。为了维持这种初始化安全性的保证,必须满足不可变性的所有需求:状态不可修改,所有域都是final类型,以及正确的构造过程
3.5.2 不可变对象与初始化安全性
在静态初始化函数中初始化一个对象引用
将对象的引用保存到volatile类型的域或者AtomicReferance对象中
将对象的引用保存到某个正确构造对象的final类型域中
将对象的引用保存到一个由锁保护的域中
3.5.3 安全发布的常用模式
在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。即使这个对象没有满足3.5.3的一些条件
3.5.4 事实不可变对象
不可变对象可以通过任意机制来发布
事实不可变对象必须通过安全方式来发布
可变对象必须通过安全方式发布,并且必须是线程安全的或者由某个锁保护起来
对象的发布需求取决于它的可变性
3.5.5 可变对象
线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改
只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象
线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步
保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象
在并发程序中使用和共享对象时,可以使用一些实用的策略,包括
3.5.6 安全的共享对象
3.5 安全发布
第三章 对象的共享
本章将介绍一些组合模式,这些模式能够使一个类更容易成为线程安全的,并且在维护这些类时不会无意中破坏类的安全性保证
找出构成对象状态的所有变量
找出约束状态变量的不变性条件
建立对象状态的并发访问管理策略
三个基本要素
4.1.1 收集同步需求
如果在某个操作中包含有基于状态的先验条件,那么这个操作就称为依赖状态的操作
例如列表的删除操作之前要验证非空
4.1.2 依赖状态的操作
就是构成一个对象所有的子集的状态
4.1.3 状态的所有权
4.1 设计线程安全的类
将数据封装在对象内部,可以将数据的访问权限限制在对象的方法上,从而容易确保线程在访问数据时总能持有正确的锁
该模式仅仅是一种编写代码的约定,对于任何一种锁对象,只要自始至终都使用该锁对象,都可以用来保护对象的状态
例如通过一个私有锁来保护状态
4.2.1 Java装饰器模式
4.2 实例封闭
比如这个类的属性是Atomic类型的,那么就不需要再去用锁来保证这个的对象的安全
import java.util.Collections;import java.util.HashSet;import java.util.Set;public class PointSet { private final Set<Point> points = Collections.synchronizedSet(new HashSet<>()); public void addPoint(Point point) { points.add(point); } public boolean containsPoint(Point point) { return points.contains(point); }}
委托示例
上面讲到的都仅仅委托给了单个线程安全的状态变量。我们还可以将线程安全性委托给多个状态变量,只要这些变量是彼此独立的,即组合而成的类并不会再其包含的多个状态变量上增加任何不变性条件
就是多个变量
4.3.2 独立的状态变量
比如定义了几个Atomic类型的但是set、get没有保证安全,那么这就是委托失效
所以:volatile变量规则:仅当一个变量参与到包含其他状态变量的不变性条件时,才可以声明为volatile类型
4.3.3 当委托失效时
如果一个状态变量是线程安全的,并且没有任何不变性标间来约束它的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全地发布这个变量
4.3.4 发布底层的状态变量
4.3 线程安全性的委托
public class MyVector<E> extends Vector { public synchronized boolean putIfAbsent(E x) { boolean absent = !contains(x); if (absent) { add(x); } return absent }}
\"扩展\"方法比直接将代码添加到类中更加脆弱,因为现在的同步策略实现被分布到多个单独维护的源代码文件中。如果底层的类改变了同步策略并选择了不同的锁来保护它的状态变量,那么它的子类会被破坏,因为在同步策略改变后它无法再使用正确的锁来控制对基类状态的并发访问。(在Vector的规范中定义了它的同步策略,因此MyVector不存在这个问题)
客户端加锁机制与扩展类机制有许多共同点,二者都是将派生类的行为与基类耦合在一起。会破坏同步策略的封装性
4.4.1 客户端加锁机制
当为现有的类添加一个原子操作时,有一种更好的方法:组合
public class MyList<T> implements List<T> { private final List<T> list; public MyList(List<T> list) { this.list = list; } public synchronized boolean putIfAbsent(T x) { // 这个锁是MyList的还是List的,这样就安全了吗 boolean contains = list.contains(x); if (contains) { list.add(x); } return !contains; }}
4.4.2 组合
4.4 在现有的线程安全类中添加功能
在文档中说明客户代码需要了解的线程安全性保证,以及代码维护人员需要了解的同步策略
那些变量声明为volatile类型
那些变量用锁来保护
那些锁锁保护那些变量
那些变量必须是不可变的或者是封闭在线程中的
那些操作必须是原子操作
在设计阶段编写是最佳时间
4.5 将同步策略文档化
本章介绍了构造安全类时采用的一些技术,例如将线程安全性委托给现有的线程安全类。委托是创建线程安全类的一个最有效的策略:只需让现有的线程安全类管理所有的状态即可
第四章 对象的组合
这些类实现线程安全的方式是:将他们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态
例如Vector和Hashtable
性能开销
死锁
并发性降低
复合操作的原子性
迭代器的安全性
5.1.1 同步容器类的问题
当它们发现容器在迭代过程中被修改时,就会抛出这个异常。及时失败
如果不希望在迭代期间对容器加锁,那么一种替代方法就是“克隆”容器并在副本上进行迭代
5.1.2 迭代器与ConcurrentModificationException
虽然加锁可以防止迭代器抛出ConcurrentModificationException,但是你必须要记住所有对共享容器进行迭代的地方都需要加锁
更复杂的是,有些迭代器会隐藏起来比如字符串的拼接
5.1.3 隐藏迭代器
同步容器将所有对容器状态的访问都串型化,以实现他们的线程安全性。但也严重降低了并发性
同步类容器在执行每个操作期间都持有一个锁
5.1 同步容器类
通过并发容器替代同步容器,可以极大地提高伸缩性并降低风险
ConcurrentHashMap并不是将每个方法都在同一个锁上同步并使得每次只有只能有一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制称为分段锁
5.2.1 ConcurrentHashMap
比如“若没有则添加”,“若相等则移除”,“若相等则替换”等等,在ConcurrentMap接口中声明了,我们只需要重写即可
问题:为啥继承Vector扩展方法不好,应为扩展后的方法使用的锁是子类的锁
5.2.2 额外的原子Map操作
类似的,CopyOnWriteArraySet的作用是替代同步Set
“写入时复制”容器的线程安全性在于,只要真确发布一个事实不可变对象,那么在访问该对象时就不再需要进一步的同步。在每次增删改时,都会创建并重新发布一个新的容器副本,从而实现可变性。
对容器进行修改(添加、删除元素)时,不直接在原始数据上进行操作,而是先将原始数据复制一份,在副本上进行修改,最后将修改后的副本替换原始数据。
写操作会比较昂贵,因为需要复制整个数组。
由于每次写操作都会复制数组,可能会占用更多的内存。
不适合频繁写入、读取的场景,适用于读操作远远多于写操作的场景。
适用场景
5.2.3 CopyOnWriteArrayList
5.2 并发容器
阻塞队列提供了可阻塞的put和take方法,以及支持定时的offer和poll方法。如果队列已经满了,那么put方法将阻塞直到有空间可用;如果队列为空,那么take方法将会阻塞直到有元素可用。队列可以是有界的也可以是无界的,无界队列永远都不会充满,因此无界队列上的put方法也永远不会阻塞
在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮
待处理元素就在该队列中
LinkedBlockingQueue和ArrayBlocking-Queue
它不会为队列中元素维护存储空间
与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入或移除队列
它使用的是直接交付的方式,就是生产者把元素地址发送非消费者,消费者直接去消费
SynchronousQueue
BlockingQueue的实现
将多个任务拆分,然后都放到控制流的队列处理
5.3.1 示例: 桌面搜索
对于可变对象,生产者-消费者这种设计与阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付给消费者。
线程封闭对象只能由单个线程拥有,但可以通过安全地发布该对象来“转移”所有权。
在转移所有权后,也只有另一个线程能获得这个对象的访问权限,并且发布对象的线程不会再访问它
这种安全的发布确保了对象状态对于新的所有者来说是可见的,并且由于最初的所有者不会再访问它,因此对象将被封闭在新的线程中
5.3.2 串行线程封闭
Java6 增加了两种容器类型 Deque和BlockingDeque
Deque是一个双端队列,实现了在队列头和队列尾的高效插入和移除。具体实现包括ArrayDeque和LinkedBlockingDeque
每个消费者都有自己的双端队列
如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作
密取工作模式比传统的生产者-消费者模式具有更高的可伸缩性这是因为工作者线程不会在单个共享的任务队列上发生竞争。
在大多数时候,它们都只是访问自己的双端队列,从而极大地减少了竞争
当工作者需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作,因此进一步降低了队列上的竞争程度
工作密取
双端队列的工作模式
5.3.3 双端队列与工作密取
5.3 阻塞队列和生产者-消费者模式
等待IO操作结束
等待获取一个锁
等待从Thread.sleep方法中醒来
等待另一个线程的计算结果
线程可能会阻塞或暂停执行,原因可能有多种
等待IO完成
等待某个锁变成可用
等待外部计算的结束
当某个外部事件发生时,线程被置回RUNNABLE状态,并可以再次被调度执行
被阻塞的线程必须等待某个不受它控制的事件发生后才能继续执行
当某方法抛出(受检查异常)InterruptedException时,表示该方法是一个阻塞方法,如果这个方法被中断,那么它将努力提前结束阻塞状态
Thread提供了interrupt方法,用于中断线程或者查询线程是否已经被中断
一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作
如果线程处于阻塞状态(如调用了 sleep()、wait()、join() 等方法),并且在阻塞期间被中断,那么线程会抛出 InterruptedException 异常,而不是直接进入阻塞状态码。这意味着线程被中断后,会从阻塞状态中醒来,并且抛出异常,需要在代码中捕获并处理这个异常。
在其他情况下,比如线程处于运行状态,如果线程被中断,它会收到中断信号,但并不会直接进入阻塞状态,线程可以继续执行,只是在合适的时机根据中断状态做出相应的处理。
总之,线程的中断并不是直接让线程进入阻塞状态,而是一种通知机制,线程需要根据中断状态来做出相应的处理,无论线程处于何种状态。
我们可以使用interrupt方法来定制化我们想需求
中断是一种协作机制。
传递InterruptedException
恢复中断
处理InterruptedException的两种基本选择
5.4 阻塞方法与中断方法
同步工具类可以是任何一个对象,只要它根据自身的状态来协调线程的控制流。阻塞队列可以作为同步工具类,其他类型的同步工具类还包括信号量(Semaphore)、栅栏(Barrier)以及闭锁(Latch)
它们封装了一些状态
这些状态将决定执行同步工具类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另一些用于高效地等待同步工具类进入预期状态
同步工具类的结构属性
可以延迟线程的进度直到其到达终止状态
闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态
场景:闭锁可以用来确保某些活动直到其他活动都完成后才继续执行
CountDownLatch是一种灵活的闭锁实现
示例:使用两个闭锁,一个表示起始门,一个表示结束门。每个线程工作线程首先要做的是在起始门上等待,从而确保所有线程都就绪才开始执行。而每个线程要做的最后一件事就是将调用结束梦的countDown方法减1,这能使主线程高效地等待直到所有工作线程都执行完毕,因此可以统计所消耗的时间
5.5.1 闭锁
FutureTask也可以用作闭锁
FutureTask实现了Future语义,表示一种抽象的可生产结果的计算
等待运行
正在运行
运行完成
FutureTask表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于以下3种状态
正常结束
由于取消而结束
由于异常而结束
执行完成 表示计算的所有可能结束方式,包括
当FutureTask进入完成状态后,它会永远停止在这个状态上
Future.get的行为取决于任务的状态。如果任务已完成,那么get会立即返回结果,否则get将阻塞直到任务进入完成状态,然后返回结果或者抛出异常
FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程,而FutureTask的规范确保了这种传递过程能实现结果的安全发布
FutureTask在Executor框架中表示异步任务,此外还可以用来表示一些时间较长的计算,这些计算可以在使用计算结果之前启动
5.5.2 FutureTask
计数信号量用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。
计数信号量还可以用来实现某种资源池,或者对容器施加边界
Semaphore中管理着一组虚拟的许可(permit),许可的初始数量可通过构造函数来指定。在执行操作时可以首先获得许可(只要还有剩余的许可),并在使用以后释放许可。如果没有许可,那么acquire将阻塞直到有许可(或者直到被中断或者操作超时)。release方法将返回一个许可给信号量。
Semaphore可以用于实现资源池,例如数据库连接池
5.5.3 信号量
闭锁是一次性对象,一旦进入终止状态,就不能被重置
栅栏(Barrier)类似于闭锁,它能阻塞一组线程直到某个事件发生。
栅栏(CyclicBarrier):栅栏允许一组线程相互等待,直到所有线程都达到某个公共的屏障点,然后才能继续执行。栅栏是可以重用的,一旦所有线程都到达栅栏点后,栅栏会被重置,线程可以再次使用它。栅栏适用于一组线程需要同步执行某项任务,然后再继续执行下一阶段任务的情况。
闭锁(CountDownLatch):闭锁是一种同步工具,用于等待一个或多个线程完成操作,然后再执行主线程的任务。闭锁一旦被触发就无法重用,也就是说一旦计数归零,闭锁就不能再次打开。闭锁适用于主线程需要等待所有子线程执行完毕后再继续执行的情况。
栅栏和闭锁的区别
CyclicBarrier。当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置
5.5.4 栅栏
5.5 同步工具类
synchronized compute方法
问题:不管是计算线程还是读取线程都要获取锁进行排队
方案一
问题:如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在进行,那么很可能会重复这个计算
方案二
如果已经启动,那么等待现有计算的结果就可以了
问题:存在两个线程计算出相同值的漏洞
方案三
不使用原始的put方法,而是使用putIfAbsent原子方法
方案四
5.6 构建高效且可伸缩的结果缓存
第五章 基础构建模块
可变状态是至关重要的 所有的并发问题都可以归结为如何协调对并发状态的访问。可变状态越少,就越容易确保线程安全性
尽量将域声明为final类型,除非需要它们是可变的
不可变对象一定是线程安全的 不可变对象能极大地降低并发编程的复杂性。它们更为简单而且安全,可以任意共享而无须使用加锁或保护性复制等机制
封装有助于管理复杂性 在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但为什么要这样做?将数据封装在对象中,更易于维持不变性条件;将同步机制封装在对象中,更易于遵循同步策略
用锁来保护每个可变变量
当保护同一个不变性条件中的所有变量时,要使用同一个锁
在执行复合操作期间,要持有锁
如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出现问题
不要故作聪明地推断出不需要使用同步
在设计过程中考虑线程安全,或者在文档中明确地指出他不是线程安全的
[用户_97008097]
将同步策略文档化
第一部分小结
第一部分 基础知识
大多数并发应用程序都是围绕“任务执行”来构造的:任务通常是一些抽象的且离散的工作单元
在围绕“任务执行”来设计应用程序结构时,第一步就是要找出清晰的任务边界。在理想的情况喜爱,各个任务之间是相互独立的:任务并不依赖于其他任务的状态、结果或边界效应
大多数服务器应用程序都提供了一种自然的任务边界选择方式:以独立的客户请求为边界
串行处理机制通常都无法提供高吞吐率或快速响应性。比如遇到IO
6.1.1 串行地执行任务
为每个请求创建一个新的线程来提供服务
6.1.2 显式地为任务创建线程
线程生命周期的开销非常高
资源消耗
稳定性
6.1.3 无限制创建线程的不足
6.1 在线程中执行任务
任务是一组逻辑工作单元,而线程则是使任务异步执行的机制
串行执行的问题在于其糟糕的响应性能和吞吐量
“为每个任务分配一个线程”的问题在于资源管理的复杂性
Executor提供了一种标准的方法将任务的提交过程与执行过程解耦开来,并用Runnable来表示任务
Executor的实现还提供了对生命周期的支持,以及统计信息收集、应用程序管理机制和性能监视等机制
Executor基于生产者-消费者模式,提交任务的操作相当于生产者(生成待完成的工作单元),执行任务的线程则相当于消费者(执行完这些工作单元)
我们也可以implements Executor,重写execute方法添加我们需要的逻辑
6.2.1 示例:基于Executor的Web服务器
在什么线程中执行任务?
任务按照什么顺序执行(FIFO、LIFO、优先级)?
有多少个任务能并发执行?
在队列中有多少个任务在等待执行?
如果系统由于过载而需要拒绝一些任务,那么应该选择哪一个任务?另外,如何通知应用程序有任务被拒绝
在执行一个任务之前或之后,应该进行那些动作?
各种执行策略都是一种资源管理工具,最佳策略取决于可用的计算资源以及对服务质量的需求
6.2.2 执行策略
线程池是与工作队列密切相关的,其中在工作队列中保存了所有等待执行的任务。
工作者线程的任务很简单:从工作队列中获取一个任务,执行任务,然后返回线程池并等待下一个任务
newFiexedThreadPool 固定长度
newCacheThreadPool 不限制
newScheduledTheadPool 固定长度,延迟或定时
类库提供一个灵活的线程池以及一些有用的默认配置
6.2.3 线程池
如果无法正确关闭Executor,那么JVM将无法结束
为了解决执行服务的声明周期问题,Executor扩展了ExecutorService接口,添加了一些用于生命周期管理的方法
ExecutorService的生命周期有3种状态: 运行、关闭和已终止
ExecutorService在初始创建时处于运行状态。shutdown方法将执行平缓的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成---包含那些还未开始执行的任务。shutdownNow方法将执行粗暴的关闭过程:它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。
6.2.4 Executor的生命周期
只会创建一个线程,如果某个任务的执行时间过长,那么将破坏其他TimerTask的定时精确性
Timer线程并不捕获异常,因此当TimerTask抛出未检查的异常时将终止定时线程。已经被调度但尚未执行的TimerTask将不会再恢复执行,新的任务也不能被调度。
Timer类负责管理延迟任务以及周期任务,但是存在一些缺陷
使用ScheduledThreadPoolExecutor来代替它
如果要构建自己的调度任务,那么可以使用DelayQueue,它实现了BlcokingQueue,并未ScheduledThreadPoolExecutor提供调度任务。DelayQueue管理着一组Delayed对象。每个Delayed对象都有一个相应的延迟时间:在DelayQueue中,只有某个元素逾期后,才能从DelayQueue中执行take操作。从DelayQueue中返回的对象将根据他们的延迟时间进行排序。
6.2.5 延迟任务与周期任务
6.2 Executor框架
Executor框架帮助执行执行策略,但如果要使用Executor,必须将任务表述为一个Runnable
6.3.1 示例:串行的页面渲染器
许多任务实际上都是存在延迟的计算----执行数据库查询,从网络上获取资源,或者计算某个复杂的功能。对于这个任务,Callable是一种更好的抽象:它任务主入口点(即call)将返回一个值,并可能抛出一个异常。
Runnable和Callable描述的都是抽象的计算任务。这些任务通常是有范围的,即都有一个明确的起始点,并且最终会结束
Callable需要通过ExecutorService的submit()方法提交执行,返回一个Future对象;而Runnable可以直接通过Thread的构造函数或者ExecutorService的execute()方法提交执行。
Executor执行的任务有4个生命周期阶段:创建、提交、开始、完成
Future表示一个任务的生命周期,并提供了相应的方法来判断是否已经完成或取消,以及获取任务的结果和取消任务等。
Future规范中隐含:任务的生命周期只能前进,不能后退
如果任务已完成,那么get会立即返回或者抛出一个Exception
如果任务没有完成,那么get将阻塞并直到任务完成
如果任务抛出了异常,那么get将该异常封装为ExecutionException并重新抛出
如果任务被取消,那么get将抛出CancellationException
Future的get方法的行为取决于任务的状态(尚未开始、正在运行、已完成)
6.3.2 携带结果的任务Callable和Future
6.3.3 示例:使用Future实现页面渲染器
只有当大量相互独立且同构的任务可以并发进行处理时,才能体现出将程序的工作负载分配到多个任务中带来的真正的性能提升
6.3.4 在异构任务并行化中存在的局限
CompletionService将Executor和BlockingQueue的功能融合在一起。你可以将Callable任务提交给它来执行,然后使用类似于队列操作的take和poll等方法来获取已完成的结果,而这些结果会在完成时将被封装为Future。
6.3.5 CompletionService : Executor与BlockingQueue
6.3.6 示例:使用CompletionService实现页面渲染器
有时候,如果某个任务无法在指定时间内完成,那么将不再不要它的结果,此时可以放弃这个任务
Future.get中支持这种需求:当结果可用时,它将立即返回,如果在指定时限内没有计算出结果,将抛出TimeoutException。这个时候可以catch捕获,Future.cancel来取消任务,避免资源的浪费
6.3.7 为任务设置时限
6.3.8 示例:旅行预定门户网站
6.3 找出可利用的并行性
小结:通过围绕任务执行来设计应用程序,可以简化开发过程,并有助于实现并发。Executor框架将任务提交与执行策略解耦开来,同时还支持多种不同类型的执行策略。当需要创建线程来执行任务时,可以考虑使用Executor。要想在将应用程序分解为不同的任务时获得最大的好处,必须定义清晰的任务边界。某些应用程序中存在着比较明显的任务边界,而在其他一些程序中则需要进一步分析才能揭示出粒度更细的并行性。
第六章 任务执行
Java没有提供任何机制来安全地终止线程。但它提供了中断,这是一种协作机制,能够使一个线程终止另一个线程的当前工作。
我们很少希望某个任务、线程或服务立即停止,因为这种立即停止会使共享的数据结构处于不一致的状态。相反,在编写任务和服务时可以使用一种协作的方式:当需要停止时,它们首先会清除当前正在执行的工作,然后再结束。
行为良好的软件能很完善地处理失败、关闭和取消等过程。
如果外部代码能在某个操作正常完成之前将其置入“完成”状态,那么这个操作就可以称为可取消的。
换句话说,可取消的操作是指在外部代码的干预下,可以中止或取消正在进行的操作,使其提前结束。
用户请求取消
有时间限制的操作
应用程序事件
错误
当一个程序或服务关闭时,必须对正在处理和等待处理的工作执行某种操作。在平缓的关闭过程中,当前正在执行的任务将继续执行直到完成,而在立即关闭过程中,当前的任务则可能取消。
关闭
取消某个操作的原因很多
设置某个“已请求取消”标志,而任务将定期地查看该标志。如果设置了这个标志,那么任务将提前结束。
线程中断是一种协作机制,线程可以通过这种机制来通知另一个线程,告诉它在合适的或者可能的情况下停止当前工作,并转而执行其他的工作。
每个线程都有一个boolean类型的中断状态。当中断线程时,这个线程的中断状态将被设置为true。在Thread中包含了中断线程以及查询线程中断状态的方法,interrupt方法能中断目标线程,而isInterrupted方法能返回目标线程的中断状态。静态的interrupted方法将清除当前线程的中断状态,并返回它之前的值,这也是清除中断状态的唯一方法。
阻塞库方法,例如Thread.sleep和Object.wait等,都会检查线程何时中断,并且在发现中断时提前返回。它们在响应中断时执行的操作包括:清除中断状态,抛出InterruptedException,表示阻塞操作由于中断而提前结束。JVM并不能保证阻塞方法检测到中断的速度,但在实际情况下响应速度还是非常快的。
当线程在非阻塞状态下中断时,它的中断状态被设置,然后根据将被取消的操作来检查中断状态以判断发生了中断。通过这样的方法,中断操作将变得“有黏性”----如果不触发InterruptedException,那么中断状态将一直保持,直到明确地清除中断状态
对中断操作的正确理解是:它并不会真正地中断一个正在运行的线程,而只是发出中断请求,然后由线程在下一个合适的时刻中断自己。这些时刻也被称为取消点。
void interrupt() 方法用于中断线程,即设置线程的中断状态为 true。
调用 interrupt() 方法将会设置线程的中断状态,但并不会立即停止线程的执行,而是给线程一个中断的信号,线程可以根据中断状态来做出相应的处理。
interrupt
boolean isInterrupted() 方法用于检查当前线程的中断状态,并返回一个 boolean 值。
当调用 isInterrupted() 方法时,会返回当前线程的中断状态,如果中断状态为 true,则表示线程被中断。
isInterrupted
static boolean interrupted() 方法用于检查当前线程的中断状态,并清除当前线程的中断状态,将其重新设置为 false。
调用 interrupted() 方法会首先返回当前线程的中断状态,然后将当前线程的中断状态重置为 false。这样可以方便地检查线程是否被中断,并清除中断状态以便后续操作。
static interrupted
Thread中的中断方法
7.1.1 中断
正如任务中应该包含取消策略一样,线程同样应该包含中断策略。中断策略规定线程如何解释某个中断请求---------当发现中断请求时,应该做那些工作(如果需要的话),那些工作单元对于中断来说是原子操作,以及以多快的速度来响应中断。
最合理的中断策略是某种形式的线程级取消操作或服务级取消操作:尽快退出,在必要时进行清理,通知某个所有者该线程已经退出。此处还可以简历其他的中断策略,例如暂停服务或重新开始服务,但对于那些包含非标准中断策略的线程或线程池,只能用于能知道这些策略的任务中。
抛出InterruptedException只是作为中断响应。尽快退出执行流程,并把中断信息传递给调用者,从而使调用栈中的上层代码可以采取进一步的操作。
当检查到中断请求时,任务并不需要放弃所有的操作-------它可以推迟处理中断请求,并直到某个更合适的时刻因此需要记住中断请求,并在完成当前任务后抛出InterruptedException或表示已收到中断请求。这项技术能够确保在更新过程中发生中断时,数据结果不会被破坏。
如果除了将nterruptedException传递给调用者外还需要执行其他操作,那么应该在捕获nterruptedException之后恢复中断状态。
7.1.2 中断策略
传递异常(可能在执行某个特定于任务的清除操作之后),从而使你的方法也成为可中断的阻塞方法。
恢复中断状态,从而使调用栈中的上层代码能够对其进行处理
处理nterruptedException的两种策略
对于一些不支持取消但仍可以调用可中断阻塞方法的操作,它们必须在循环中调用这些方法,并在发现中断后重新尝试。通常,可中断的方法会在阻塞或进行重要的工作前首先检查中断,从而尽快地响应中断
在取消过程中可能涉及除了中断状态之外的其他状态。例如,当一个由ThreadPoolExecutor拥有的工作者线程检测到中断时,它会检查线程池是否正在关闭。如果是,它会在结束之前执行一些线程池清理工作,否则它可能创建一个新线程将线程池恢复到合理的规模。
7.1.3 响应中断
7.1.4 示例:计时运行
ExecutorService.submit将返回一个Future来描述任务。
Future拥有一个cancel方法,该方法带有一个boolean类型的参数mayInterruptIfRunning,表示取消操作是否成功。这只是表示任务是否能够接受中断,而不是表示任务是否能检测并处理中断。
通过任务的Future来取消它们。
当Future.get抛出InterruptedException或TImeoutException时,如果你知道不再需要结果,那么就可以调用Future.cancel来取消任务。
7.1.5 通过Future来实现取消
并非所有的可阻塞方法或者阻塞机制都能响应中断
如果一个线程由于执行同步的Socket I/O 或者等待获得内置锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有其他任何作用。
Java.io包中的同步Socket I/O
Java.io包中的同步 I/O
Selector的异步 I/O
获取某个锁
线程阻塞的原因
7.1.6 处理不可中断的阻塞
newTaskFor是一个工厂方法,它将创建Future来代表任务。
newTaskFor还能返回一个RunnableFuture接口,该接口扩展了Fuutre和Runnable
通过定制表示任务的Future可以改变Future.cancel的行为。例如,实现日志记录等等。
7.1.7 采用newTaskFor来封装非标准的取消
7.1 任务取消
应用程序通常会创建多个线程的服务,例如线程池,并且这些服务的生命周期通常比创建它们的方法的生命周期更长。如果应用程序准备退出,那么这些服务所拥有的线程也需要结束。由于无法通过抢占式的方法来停止线程,因此它们需要自行结束。
正确的封装原则是:除非拥有某个线程,否则不能对该线程进行操控。
线程有一个相应的所有者,即创建该线程的类。因此线程池是其工作者线程的所有者,如果要中断这些线程,那么应该使用线程池。
与其他封装对象一样,线程的所有权是不可传递的:应用程序可以拥有服务,服务也可以拥有工作线程,但应用程序并不能拥有工作者线程,因此应用程序不能直接停止工作者线程。相反,服务应该提供生命周期方法来关闭它自己以及它所拥有的线程。这样,当应用程序关闭该服务时,服务就可以关闭所有的线程了。在ExecutorService中提供了shutdown和shutdownNow等方法。
7.2.1 示例:日志服务
速度慢,但却更安全,因为ExecutorService会一直等到队列中的所有任务都执行完成后才关闭。
shutdown 正常关闭
首先关闭当前正在执行的任务,然后返回所有尚未启动的任务清单。
速度更快,但风险也更大,因为任务很可能在执行到一半时被结束
shutdownNow 强制关闭
7.2.2 关闭ExecutorService
“毒丸”是指一个放在队列上的对象,其含义是:当得到这个对象时,立即停止
在FIFO(先进先出)队列中,“毒丸”对象将确保消费者在关闭之前首先完成队列中的所有工作,在提交“毒丸”对象之前提交的所有工作都会被清理,而生产者在提交了“毒丸”对象后,将不会再提交任何工作
7.2.3 “毒丸”对象
如果某个方法需要处理一批任务,并且当所有任务都处理完成后才返回,那么可以通过一个私有的Executor来简化服务的生命周期管理,其中该Executor的生命周期是由这个方法来控制的。
7.2.4 示例:只执行一次的服务
我们无法通过常规方法来找出那些任务已经开始但尚未结束。
7.2.5 shutdownNow的局限性
7.2 停止基于线程的服务
当单线程的控制台程序由于发生了一个未捕获的异常而终止时,程序将停止运行,并产生与程序正常输出非常不同的栈追踪信息。
在并发程序中的某个线程发生故障,那么通常并不会如此明显。在控制台中可能会输出栈追踪信息,但没有人会观察控制台。此外,当线程发生故障时,应用程序可能看起来仍然在工作,所以这个失败很可能会被忽略。
导致线程提前死亡的最主要原因就是RunntimeException。
这些线程应该在try-catch代码快中调用这些任务,这样就能捕获那些未检查异常了,或者也可以使用try-finallly代码块来确保框架能够知道线程非正常退出的情况。
在Thread API中同样提供了UncaughtExceptionHandler, 它能检测出某个线程由于未捕获的异常而终结的情况。
当一个线程由于未捕获异常而退出时,JVM会把这个事件报告给应用程序提供的UncaughtExceptionHandler异常处理器。如果没有没有提供任何异常处理器,那么默认的行为是将栈追踪消息输出得到System.err。一般我们会实现UncaughtExceptionHandler重写 方法,然后logger打印日志
要为线程池中的所有线程设置一个UncaughtExceptionHandler,需要为ThreadPoolExecutor的构造函数提供一个ThreadFactory。
如果你希望在任务由于发生异常而失败时获得通知,并且执行一些特定任务的恢复操作,那么可以将任务封装在能捕获异常的Runnable或Callable中,或者改写ThreadPoolExecutor的afterExecute方法
只有通过execute提交的任务,才能将它抛出的异常交给未捕获异常处理器,而通过submit提交的任务,无论是抛出的未检查异常还是已检查异常,都将被认为是任务返回状态的一部分。如果一个由submit提交的任务由于抛出了异常而结束,那么这个异常将被Future.get封装在ExexutionException中重新抛出。
7.3 处理非正常的线程终止
JVM既可以正常关闭,也可以强行关闭。
在正常关闭中,JVM首先调用所有已注册的关闭钩子(Shutdown Hook)。
关闭钩子应该是线程安全的:它们在访问共享数据时必须使用同步机制,并且小心避免死锁。,这与其他并发代码的要求相同。而且,关闭钩子不应该对应用程序的状态(例如,其他服务是否已关闭,或者所有的正常线程是否已经执行完成)或者JVM的关闭原因做出任何假设,因此在编写关闭钩子的代码必须考虑周全。最后,关闭钩子必须尽快退出,因为它们会延迟JVM的结束时间,而用户可能希望JVM能尽快终止。
关闭钩子不应该依赖那些可能被应用程序或其他关闭钩子关闭的服务。
7.4.1 关闭钩子
有时候,你希望创建一个线程来执行一些辅助工作,但又不希望这个线程阻碍JVM的关闭。
普通线程与守护线程之间的差异仅在于当线程退出时发生的操作。当一个线程退出时,JVM会检查其他正在运行的线程,如果这些线程都是守护线程,那么JVM会正常退出操作。当JVM停止时,所有仍然存在的守护线程都将被抛弃-------既不会执行finally代码块,也不会执行回卷栈,而JVM只是直接退出。
如果在守护线程中执行可能包含IO操作的任务,那么将是一种危险的行为。守护线程最好用于执行“内部”任务,例如周期性地从内存的缓存中移除逾期的数据。
7.4.2 守护线程
当不再需要内存资源时,可以通过垃圾回收器来回收它们。但对于其他一些资源,例如文件句柄或套接字句柄,当不再需要它们时,必须显式地交还给操作系统。为了实现这个功能,垃圾回收器对那些定义了finalize方法的对象会j进行特殊处理:在回收器释放它们后,调用它们的finalize方法,从而保证一些持久化的资源被释放。
由于终结器可以在某个由JVM管理的线程中运行,因此终结器访问的任何状态都可能被多个线程访问,这样就必须对其访问操作进行同步。
在大多数情况下,通过使用finally代码块和显式的close方法,能够比使用终结器更好地管理资源。唯一的例外情况在于:当需要管理对象,并且该对象持有的资源是通过本地方法获得的。基于这些原因以及其他一些原因,我们要尽量避免编写或使用包含终结器的类。
7.4.3 终结器
7.4 JVM关闭
第七章 取消与关闭
Executor框架可以将任务的提交与任务的执行策略解耦开来。
虽然Executor框架为制定和修改执行策略都提供了相当大的灵活性,但并非所有的任务都能适用所有的执行策略。
如果提交给线程池的任务需要依赖其他的任务,那么就隐含地给执行策略带来了约束,此时必须小心地维持这些执行策略以避免产生活跃性问题。
依赖性任务
对象封闭在任务线程中,那么安全性就比较容易处理。
如果将Executor从单线程环境改为线程池环境,那么将会失去线程安全性。
使用线程封闭机制的任务
例如GUI应用程序对于响应时间是敏感的。
对响应时间敏感的任务
只有当线程本地值的生命周期受限于任务的生命周期时,在线程池的线程中使用ThreadLocal才有意义,而在线程池中不应该使用ThreadLocal在任务之间传递值。
通常情况下,ThreadLocal 适合用来在单个任务内部传递值或保存线程本地的数据,确保数据在整个任务生命周期内保持一致性。
使用ThreadLocal的任务
有些类型的任务需要明确地制定执行策略,包括
只有当任务都是同类型的并且相互独立时,线程池的性能才能达到最佳。
在线程池中,如果任务依赖于其他任务,那么可能产生死锁。
线程饥饿死锁:只要线程池中的任务需要无限期地等待一些必须由池中其他任务才能提供的资源或条件,例如某个任务等待另一个任务的返回值或执行结果,那么除非线程池足够大,否则将发生线程饥饿死锁。
8.1.1 线程饥饿死锁
执行时间较长的任务不仅会造成线程池堵塞,甚至还会增加执行时间较短任务的服务时间。
有一项技术可以缓解执行时间较长任务造成的影响,即限定任务等待资源的时间,而不要无限制的等待。
8.1.2 运行时间较长的任务
8.1 在任务与执行策略之间的隐性耦合
在代码中通常不会固定线程池的大小,而应该通过某种配置机制来提供,或者根据Runtime.availableProcessors来动态计算。
N(threads) = N(cpu) * U(cpu) * (1+W/C)
int a = Runtime.getRuntime().availableProcessors(); 获取CPU的数目
8.2 设置线程池的大小
基本大小
最大大小
存活时间
时间单位
BlockingQueue
ThreadFactory
RejectedExecutionHandler
七个参数
回收空闲线程会产生额外的延迟,因为当需求增加时,必须创建新的线程来满足需求。
8.3.1 线程的创建和销毁
BlockingQueue就是为了保存请求突增的情况。
LinkedBlockingQueue
无界队列
ArrayBlockingQueue
PriorityBlockingQueue
队列填满后,新的任务该怎么办
有界队列
适用场景:非常大的或者无界的线程池
可以通过SyncchronousQueue来避免排队,以及直接将任务从生产者移交给工作者线程。
SyncchronousQueue不是一个真正的队列,而是一种线程之间进行移交的机制。
只有当线程池是无界的或者可以拒绝任务时,SyncchronousQueue才有实际价值。
同步移交
基本的任务排队方法有3种
8.3.2 管理队列任务
当有界队列被填满后,饱和策略开始发挥作用。
中止。抛出异常
AbortPolicy
将任务回退到调用者,从而降低新任务的流量
CallerRunsPolicy
抛弃。
DiscardPolicy
抛弃最旧。
DiscardOldestPolicy
ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改
当工作队列被填满后,没有预定义的饱和策略来阻塞execute。然后,可以通过使用Semaphore(信号量)来限制任务的到达率
8.3.3 饱和策略
每当线程池需要创建一个线程时,都是通过线程工厂方法来完成的。
默认的线程工厂方法将创建一个新的、非守护的线程,并且不包含特殊的配置信息。
实现ThreadFactory,重写newThead方法
如果我们要定制呢
8.3.4 线程工厂
在Executors中包含一个unconfigurableExecutorService工厂方法,该方法对一个现有的ExecutorService进行包装,使其只暴露出ExecutorService的方法,因此不能对它进行配置。使用这项计算以防止执行策略被修改。
8.3.5 在调用构造函数后再定制ThreadPoolExecutor
8.3 配置ThreadPoolExecutor
beforeExecute
afterExecute
terminated
提供重写的方法
8.4 扩展ThreadPoolExecutor
调用processInParallel比调用processSequentially能更快返回,因为proxcessInParallel会在所有下载任务都进入了Eexecutor的队列后就立即返回,而不会等待这些任务全部完成。
当串行循环中的各个迭代操作之间彼此独立,并且每个迭代操作执行的工作量比管理一个新任务时带来的开销更多,那么这个串行循环就适合并行化。
8.5 递归算法的并行化
第八章 线程池的使用
为了维持安全性,一些特定的任务必须运行在Swing的事件中。在事件线程中不应该执行时间较长的操作,以免用户界面失去响应。而且,由于Swing的数据结构不是线程安全的,因此必须将他们限制在事件线程中。
线程安全性: GUI 库通常是非线程安全的,多个线程同时操作 GUI 元素可能导致不可预测的结果,比如界面闪烁、数据错乱等问题。通过将 GUI 设计为单线程,可以避免多线程并发访问导致的安全性问题。
事件处理: GUI 应用程序通常依赖于事件驱动模型,如按钮点击、鼠标移动等事件。单线程模型可以确保事件的顺序性和一致性,简化事件处理逻辑。
UI 更新: GUI 的更新通常涉及到界面元素的刷新和重绘,如果多个线程同时更新界面,可能会导致界面显示不一致或闪烁。单线程模型可以确保界面更新的有序性。
资源共享: GUI 界面中的组件通常是共享资源,如窗口、控件等。单线程模型可以简化对共享资源的管理和同步。
好处是多个任务不会的处理过程不会重叠
不利之处在于,如果某个任务的执行时间很长,那么其他任务必须等到该任务执行结束。
9.1.1 串行事件处理
Swing的单线程规则是:Swing中的组件以及模型只能在这个事件分发过程中进行创建、修改以及查询。
Swing中只有少数方法可以安全地从其他线程中调用,而在Javadoc中已经很清楚地说明了这些方法的线程安全性。
9.1.2 Swing中的线程封闭机制
9.1 为什么GUI是单线程的
如果所有任务的执行时间都较短(并且应用程序中不包含执行时间较长的非GUI部分),那么整个应用程序都可以在事件线程内部运行,并且完全不用关心线程。
9.2 短时间的GUI任务
对于长时间的任务,可以使用缓存线程池。只有GUI应用程序很少会发起大量的长时间任务,因此即使线程池可以无限制地增长也不会有太大的风险。
在GUI应用程序中,【线程接力】是处理长时间任务的典型方法。
当某个任务在线程中运行了过长时间还没有结束,用户可能希望取消它。你可以直接通过线程中断来实现取消操作,但是一种更简单的办法是使用Future,专门用来管理可取消掉的任务。
9.3.1 取消
通过Future来表示一个长时间的任务,可以极大地简化取消操作的实现。
在FutureTask中也有一个done方法同样有助于实现完成通知。当后台的Callable完成后,将调用down。通过done方法在事件线程中触发一个完成任务,我们能够构造一个BackgroundTask类,这个类将提供一个在事件线程中调用onCompletion方法。
BackgroundTask还支持进度标识。compute方法可以调用setProgress方法以数字形式来指示进度。因而在事件线程中调用onProgress,从而更新用户界面以显示可视化的进度信息。
基于FutureTask构造的BackgroundTask还能简化取消操作。Compute不会检查线程的中断状态,而是调用Future.isCancelled。
9.3.2 进度标识和完成标识
专门用来执行长时间的任务。
9.3.3 SwingWorker
9.3 长时间的GUI任务
例如CopyOnWriteArrayList
9.4.1 线程安全的数据模型
是指将一个大的数据结构或任务分解成更小的部分,以便并行处理。
提高并行度: 将大任务或数据结构分解成多个小部分,可以让不同的线程同时处理这些小部分,从而提高并行度,加快处理速度。
减少竞争: 将大任务或数据结构分解成小部分后,可以减少线程之间的竞争和争夺共享资源的情况,减轻锁竞争的压力,提高系统的并发性能。
分解数据模型的目的主要有两个方面:
9.4.2 分解数据模型
9.4 共享数据模型
线程封闭不仅仅可以在GUI中使用,每当某个工具需要被实现为单线程子系统时,都可以使用这项技术。有时候,当程序员无法避免同步或死锁等问题时,也将不得不使用线程封闭。
9.5 其他形式的单线程子系统
第九章 图形用户界面应用程序
第二部分 结构化并发应用程序
我们使用加锁机制来确保线程安全,但如果过度地使用加锁,则可能导致锁顺序死锁。我们使用线程池和信号量来限制对资源的使用,但这些被限制的行为可能导致资源死锁。Java应用程序无法从死锁中恢复过来,因此在设计时一定要排除那些可能导致死锁出现的条件。
每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所有需要的资源之前都不会放弃已拥有的资源。
当一个线程永远地持有一个锁,并且其他线程都尝试获得这个锁,那么它们将永远被阻塞。线程A等待线程B所占有的资源。
数据库检测到一组事务发生了死锁时(通过在表示等待关系的有向图中搜索循环),将选择一个牺牲者并放弃这个事务。作为牺牲者的事务会释放它所持有的资源,从而使其他事务继续进行。应用程序可以重新执行被强行中止的事务,而这个现在可以成功完成,应为所有跟它竞争资源的事务都已完成了。
在JVM中,当一组Java线程发送死锁时,“游戏”到此结束-----这些线程永远不能再使用了。根据线程完成工作的不同,可能造成应用程序完全停止。恢复应用程序的唯一方法就是中止并重启它,并希望不要再发生同样的事情。
如果所有的线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁的问题。
10.1.1 锁顺序死锁
存在嵌套的锁获取操作。由于我们无法控制参数的顺序,因此要解决这个问题,必须定义锁的顺序,并在整个应用程序中都按照这个顺序来获取锁。
转账的例子
在指定锁的顺序时,可以使用System.identityHashCode方法,该方法将返回由Object.hashCode返回的值。在极少数的情况下,两个对象可能拥有相同的散列值,为了避免这种情况,可以使用“加时赛”锁,其实就是独立与账号参数以外的锁。
如果是转账,应为账号本来就是唯一的,可以对这两个账号排序,从而不需要使用“加时赛”锁
10.1.2 动态的锁顺序死锁
如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获取其他锁(这可能产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。
10.1.3 在协作对象之间发生的死锁
如果调用某个方法时不需要持有锁,那么这种调用被称为开放调用。
开放调用避免了死锁的产生。但是这个时候需要使同步代码块仅被用于保护那些涉及共享状态的操作。
在构造一个并发对象时,使得每次只有单线程执行使用了开放调用的代码路径。在关闭某个服务时,你可能希望所有正在运行的操作执行完成以后,再释放这些服务占用的资源。如果在等待操作完成的同时持有该服务的锁,那么将很容易导致死锁,但如果在服务关闭之前就释放服务的锁,则可能导致其他线程开始新的操作。解决办法:在将服务的状态更新为“关闭”之前一直持有锁,这样其他想要开始新操作的线程,包括想关闭该服务的其他线程,会发现服务已经不可用,因此也就不会试图开始新的操作。然后,你可以等待关闭操作结束,并且知道当开放调用完成后,只有执行关闭操作的线程才能访问服务的状态。
10.1.4 开放调用
当它们在相同的资源集合上等待时,也会发生死锁。
如果某些任务需要等待其他任务的结果,那么这些任务往往是产生线程饥饿死锁的主要来源。有界线程池/资源池与相互依赖的任务不能一起使用。
10.1.5 资源死锁
10.1 死锁
如果一个程序每次至多只能获取一个锁,那么就不会产生锁顺序死锁。如果必须获取多个锁,那么在设计时必须考虑锁的顺序:尽量减少潜在的加锁交互数量,并将这些写入文档。
在使用细粒度的程序中,可以通过使用两阶段策略来检查代码中的死锁。首先,找出在什么地方将获取多个锁(使这个集合尽量小),然后对所有这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。
显式使用Lock类中的定时tryLock功能来代替内置锁机制,可以检测死锁和从死锁中恢复过来。
当使用内置锁时,只要没有获得锁,就会永远等待下去。而显式锁则可以指定一个超时期限,在等待超过该时间后tryLock会返回一个失败信息。我们可以根据这个失败信息定制自己要做的事情。
10.2.1 支持定时的锁
JVM会通过线程转储来帮助识别死锁的发生。
线程转储包括各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪信息。
线程转储还包含加锁信息,例如每个线程持有了那些锁,在那些栈帧中获得这些锁,以及被阻塞的线程正在等待获取那个锁。
10.2.2 通过线程转储信息来分析死锁
10.2 死锁的避免与诊断
当线程由于无法访问它所需要的资源而不能继续执行时,就发生了“饥饿”。引发饥饿的最常见资源就是CPU时钟周期。如果在Java应用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构(例如无限循环,或者无限制地等待某个资源),那么也可能导致饥饿。
要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大多数并发程序中,都可以使用默认的线程优先级。
10.3.1 饥饿
如果GUI应用程序中使用了后台线程,那么这种问题是很常见的。
如果某个线程长时间占有一个锁(或许正在对一个大容器进行迭代,并且对每个元素进行计算密集的处理),而其他想要访问这个容器的线程就必须等待很长时间。
10.3.2 糟糕的响应性
该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败。
活锁通常发生在处理事务消息的应用程序中:如果不能成功地处理某个消息,那么消息处理机制就回滚整个事务,并将它重新放到队列的开头。如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务会馆。由于这条消息又被放回到队列开头,因此处理器将反复调用,并返回相同的结果。这种消息也叫毒药消息。
在并发应用程序中,通过等待随机长度的时间和回退可以有效避免活锁的发生。
10.3.3 活锁
10.3 其他活跃性危险
第十章 避免活跃性危险
虽然我们希望获得更好的性能,但始终要把安全性放在第一位。
提升性能意味着用更少的资源做更多的事情。
对于一个给定的操作,通常会缺乏某种特定的的资源,例如CPU时钟周期、内存、网络宽带、IO带宽、数据库请求、磁盘空间以及其他资源。
线程之间的协调(例如加锁、触发信号以及内存同步等)
增加的上下文切换
线程的创建和销毁
线程的调度
使用多线程造成的开销包括
更有效地利用现有处理资源
在出现新的处理资源时使程序尽可能地利用这些新资源
要想提高性能,需要努力做好两件事情
应用程序的性能可以采用多个指标来衡量,例如服务时间、延迟时间、吞吐率、效率、可伸缩性、容量等。(服务时间、等待时间)用于衡量程序的“运行速度”, 即某个指定的任务单元需要“多快”才能处理完成。(生产量、吞吐量)用于程序的“处理能力”,即在计算资源一定的情况喜爱,能完成“多少”工作
可伸缩性:当增加计算资源时(例如CPU、内存、存储容量或IO带宽),程序的吞吐量或者处理能力相应地增加。
当进行性能调优时,其目的通常是用更小的代价完成相同的工作,例如通过缓存来重用之前的计算当进行可伸缩性调优时,其目的是设法将问题的计算并行化,从而能利用更多的计算资源来完成更多的工作
11.1.1 性能与可伸缩性
避免不成熟的优化。首先使程序正确,然后再提高运行速度-----如果它还运行不够快。
“更快”的含义是什么?
该方法在什么条件下运行得更快?在低负载还是高负载的情况下?大数据集还是小数据集?能否通过测试结果来验证你的答案?
这些条件在运行环境中的发生频率?能否通过测试结果来验证你的答案?
在其他不同条件的环境中能否使用这里的代码?
在实现这种性能提升时需要付出那些隐含的代价,例如开发风险或维护开销?这种权衡是否合适?
性能决策之前的问题
已测试为基准,不要猜测。
11.1.2 评估各种性能权衡因素
11.1 对性能的思考
示例:农业中的农民收割,作物的生长速度
Amdahl定律:在增加计算资源的情况下,程序在理论上能够实现那最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。
利用率:加速比除以处理器数量。
无论访问何种共享数据结构,基本上都会在程序中引入一个串行部分。
即使串行部分所占百分比很小,也会极大地现在当增加计算资源时能够提升的吞吐率。
比如synchronizedList封装的LinkedList和ConcurrentLinkedQueue
11.2.1 示例:在各种框架中隐藏的串行部分
例如锁分解(将一个锁分解为两个锁)和锁分段(把一个锁分解为多个锁)
11.2.2 Amdahl定律的应用
11.2 Amdahl定律
如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保持当前运行线程的执行上下文,并将调度进来的线程的执行上下文设置为当前上下文。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁的发生阻塞,那么它们将无法使用完成的调度时间片。
在大多数通用的处理器中,上下文切换的开销相当于5000~ 10000个时钟周期,也就是几微秒。
11.3.1 上下文切换
同步操作的性能开销包含多个方面。在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏。内存栅栏可以刷新缓存,是缓存无效,刷新硬件的写缓存,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。
在评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。synchronized机制针对无竞争的同步进行了优化(volatile通常是非竞争的),但是非竞争同步将消耗20~ 250个时钟周期。虽然无竞争同步开销不为零,但它对应用程序整体性能的影响微乎其微
现代的JVM能通过优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么JVM就可以通过优化来去掉这个锁获取操作,因为另一个线程无法与当前线程在这个锁上发生同步。
不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或消除开销。因此,我们应该将优化的重点放在那些发生锁竞争的地方。
11.3.2 内存同步
非竞争的同步可以完全在JVM中进行处理,而竞争的同步可能需要操作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采用自旋等待或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用自旋等待方式,而如果等待时间较长,则适合采用线程挂起方式。有些JVM将根据对历史等待时间的分析数据在这两者之间进行选择,但是大多数JVM在等待锁时都只是将线程挂起。
11.3.3 阻塞
11.3 线程引入的开销
在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。
锁的请求频率
每次持有锁的时间
影响在锁上发生竞争可能性的因素
减少锁的持有时间
降低锁的请求频率
使用带有协调机制的独占锁,这些机制允许更高的并发性
降低锁竞争程度的3种方式
尽可能缩短锁的持有时间。例如可以将一些与锁无关的代码移出同步代码块。但是同步代码块也不能太小, 一些需要采用原子方式执行的操作必须包含在一个同步块中。
11.4.1 缩小锁的范围(快进快出)
降低线程请求锁的频率。通过锁分解和锁分段等技术来实现,在这些技术中将采用相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况》但是,发生死锁的风险也越高。
本来我们只需要一个锁来控制多个变量,但是可以为每个变量上锁,比如去锁A变量,而不影响B变量的读取。
11.4.2 减小锁的粒度
锁分段:对一组独立对象上的锁进行分解。将共享数据分成多个部分,每个部分都有专门的锁来控制访问
在Java中,如果多个线程同时访问共享的数据,可能会导致数据不一致或者错误的结果。为了解决这个问题,可以使用锁来控制对共享数据的访问。锁分段就是将共享数据分成多个部分,每个部分都有专门的锁来控制访问。这样,不同的线程可以同时访问不同部分的数据,从而提高了并发访问的效率。
举个例子,假设有一个包含多个元素的数组作为共享数据。使用锁分段技术时,可以将数组分成多个小块,每个小块都有自己的锁。这样,当一个线程需要访问数组的某个部分时,只需获取该部分的锁,而不会影响其他部分的访问。这样就可以实现更细粒度的并发控制,提高了并发访问的效率。
锁分段的劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。
11.4.3 锁分段
锁分解和锁分段技术都能提高可伸缩性,因为它们都能使不同的线程在不同的数据(或者同一个数据的不同部分)上操作,而不会相互干扰。如果程序采用锁分段记录,那么一定要表现出在锁上的竞争效率高于在锁保护的数据上发生竞争的频率。
当每个操作请求多个变量时,锁的粒度将很难降低。这是在性能与可伸缩性之间相互制衡的另一方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引起一些“热点域”, 而这些热点域往往会限制可伸缩性。
ConcurrentHashMap中的size将对么个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。
11.4.4 避免热点域
例如使用并发容器,读-写锁、不可变对象以及原子变量。
ReadWriteLock:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占形式来获取锁。
11.4.5 一些替代独占锁的方法
负载不充足
I/O密集
外部限制,比如应用程序依赖外部服务
锁竞争
CPU没有充分利用的几点原因
11.4.6 监测CPU的利用率
什么事对象池,就是对象重复利用的池子,为了解决缓慢的对象生命周期问题
线程协调
安全性
阻塞
劣势
11.4.7 向对象池说“不”
11.4 减少锁的竞争
11.5 示例:比较Map的性能
当任务在运行和阻塞这两个状态之间转换时,就相当于一个上下文切换。
在大多数日志框架中都是简单地对println进行包装。方案:记录日志的工作由一个专门的后台线程完成,而不是由发出请求的线程完成。
11.6 减少上下文切换的开销
小结:由于使用线程常常是为了充分利用多个处理器的计算能力,因此在并发程序性能的讨论中,通常更多地将侧重点放在吞吐量和可伸缩性商,而不是服务时间。Amdahl定律告诉我们,程序的可伸缩性取决于在所有代码中必须被串行执行代码的比例。因为Java程序中串行操作的主要来源是独占方式的资源锁,因此通常可以通过以下方式提升可伸缩性:减少锁的持有时间,降低锁的粒度,以及采用非独占的锁或非阻塞锁来代替独占锁。
第十一章 性能和可伸缩性
通常会采用测试不变性条件的形式,即判断某个类的行为是否与其规范保持一致。例如,假设有一个链表,在它每次被修改时把其大小缓存下拉,那其中一项安全性性测试就是比较在缓存中保存的大小值与链表中实际元素的数目是否相等。
安全性测试
包括进展测试和无进展测试两方面,这些都是很难量化的----如何验证某个方法是被阻塞了,而不是运行缓慢?同样,如何测试某个算法不会发生死锁?要等待多久才能宣告它发生了故障?
吞吐量:指一组并发任务中已完成的任务所占的比例。
响应性:指请求从发出到完成之间的时间(也称为延迟)
与活跃性测试相关的是性能测试
活跃性测试
并发测试大致分为两类
在为某个并发类设计单元测试时,首先需要执行与测试串行类时相同的分析---找出需要检查的不变性条件和后验条件。
12.1.1 基本的单元测试
在测试并发的基本属性时,需要引入多个线程。大多数测试框架并不能很好地支持并发性测试:它们很少会包含相应的工具来创建线程或监视线程,以确保它们不会意外结束。如果在某个测试用例创建的辅助线程中发现了一个错误,那么框架通常无法得知与这个线程相关的是哪一个测试,所以需要通过一些工作将成功或失败信息传递回主线程,从而才能将相应的信息报告出来。
在测试方法的阻塞行为时,将引入额外的复杂性:当方法被成功阻塞后,还必须使方法解除阻塞。实现这个功能的一种简单方式就是使用中断----在一个单独的线程中启动一个阻塞操作,等到线程阻塞后再中断它,然后宣告阻塞操作成功。当然,这要求阻塞方法通过提前返回或者抛出InterruptedException来响应中断。
竞争条件:多个线程对共享资源进行读写操作,可能导致数据不一致或错误。
死锁:多个线程相互等待对方释放资源,导致所有线程无法继续执行。
饥饿:某些线程长时间无法获得所需资源,导致无法执行或执行效率低下。
在阻塞操作的测试中,我们通常会关注以下方面:
12.1.2 对阻塞操作的测试
12.1.3 安全性测试
例如对内存的测试,堆分析工具
12.1.4 资源管理的测试
12.1.5 使用回调
有一种有用的方法可以提高交替操作的数量,以便能更有效地搜索程序的状态空间:在访问共享状态的操作中,使用Thead.yield将产生更多的上下文切换。
12.1.6 产生更多的交替操作
12.1 正确性测试
衡量典型测试用例中的端到端的性能
根据经验值来调整各种不同的限值,例如线程数量、缓存容量等。这些限值可能依赖于具体平台特性(例如,处理器的类型、处理器的布进级别,CPU的数量或内存大小等),因此需要动态地进行配置,而我们通常需要合理地选择这些值,从而使程序能够在更多的系统上良好的运行。
性能测试的两个目标
12.2.1 在PutTaskTest中增加计时功能
12.2.2 多种算法的比较
12.2.3 响应性衡量
12.2 性能测试
垃圾回收的执行时序是无法预测的,因此在执行测试时,垃圾回收器可能在任何时刻运行。这就可能导致测试结果不准确。
策略一:确保垃圾回收操作在测试运行的整个期间都不会执行。可以在调用JVM指定-verbose:gc 来判断是否执行了垃圾回收操作
策略二:确保垃圾回收操作在测试期间执行多次,这样测试程序就能充分反映出运行期间的内存分配与垃圾回收等开销。通常选择这种,能更好的反映实际环境下的性能。
有两种策略可以防止垃圾回收操作对测试结果产生偏差
12.3.1 垃圾回收
防止动态编译对测试结果产生偏差的方式:就是是程序运行足够长的时间(至少数分钟)这样编辑过程以及解释执行都只是总运行时间的很小一部分。另一种方法是使代码预先运行一段时间并且不测试这段时间的代码性能,这样在开始计时前代码就已经被完全编译了。
12.3.2 动态编译
12.3.3 对代码路径的不真实采样
如果N个线程从共享工作队列中获取任务并执行它们,并且这些任务都是计算密集型的以及运行时间较长(但不会频繁地访问共享数据),那么在这种情况下几乎不存在竞争,吞吐量仅受限于CPU资源的可用性。然而,如果任务的生命周期非常短,那么在工作队列上将会存在严重的竞争,此时的吞吐量将受限于同步的开销。
12.3.4 不真实的竞争程度
12.3.5 无用代码的消除
12.3 避免性能测试的陷阱
12.4.1 代码审查
例如开源的FindBugs
不一致的同步
调用Thread.run
未被释放的锁
空的同步块
双重检查加锁
在构造函数中启动一个线程
通知错误
条件等待中的错误。
对Lock和Condition的误用
在休眠或者等待的同时持有一把锁
自旋循环
比如一些检查器
12.4.2 静态分析工具
AOP
12.4.3 面向切面的测试技术
12.4.4 分析与监测工具
12.4 其他的测试方法
第十二章 并发程序的测试
第三部分 活跃性、性能与测试
在Java5.0之前,在协调对共享对象的访问时可以使用的机制只有synchronized和volatile。Java5.0增加了一种新的机制:ReentrantLock。与之前提到过的机制相反,ReentrantLock并不是一种替代内置加锁的方法,而是当内置加锁机制不适用时,作为一种可选择的高级功能。
与内置加锁机制不同,Lock提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁的方法都是显式的。
ReentrantLock实现了Lock接口,并提供了与synchronized相同的的互斥性和内存可见性。在获取ReentrantLock时,有着与进入同步代码块相同的内存语义,在释放ReentrantLock时,同样有着与退出同步代码块相同的内存语义。ReentrantLock也提供了可重入的加锁语义。
内置锁的局限性:无法中断一个正在等待获取锁的线程,或者无法在请求获取一个锁时无限等待下去。内置锁必须在获取该锁的代码块中释放,这简化了编码,但是却无法实现非阻塞结构的加锁规则。
ReentrantLock不会自动清除锁。
可定时的与可轮询的锁获取模式是由tryLock方法实现的,与无条件的锁获取模式相比,它具有更完善的错误恢复机制。
在内置锁中,死锁是一个严重的问题,恢复程序的唯一方法是重新启动程序,而防止死锁的唯一方法就是在构造程序时避免出现不一致的锁顺序。可定时的与可轮询的锁提供了另一种选择:避免死锁的发生。
如果不能获得所有需要的锁,那么可以使用可定时的或可轮询的锁获取方式,从而使你重新获得控制权,它会释放已经获得的锁,然后重新尝试获取所有的锁(或者至少会将这个失败记录到日志,并采取其他措施)
在实现具有事件限制的操作时,定时锁同样非常有用。当在带有事件限制的操作中调佣了一个阻塞方法时,它能根据剩余时间来提供一个时限。如果操作不能在指定的时间内给出结果,那么就会使程序提前结束。而内置锁却很难实现这样的操作。
13.1.1 轮询锁与定时锁
可中断的锁获取操作指的是当一个线程在等待获取锁的过程中,可以被其他线程中断,即可响应中断请求而放弃获取锁的操作。
在ReentrantLock中,通过lockInterruptibly()方法来实现可中断的锁获取操作。当一个线程调用lockInterruptibly()方法尝试获取锁时,如果其他线程中断了该线程,那么该线程会抛出InterruptedException异常,从而中断获取锁的操作。
这种可中断的锁获取操作可以帮助避免线程因为获取锁而被长时间阻塞,提高了程序的健壮性和响应性。同时,通过使用ReentrantLock的可中断特性,我们可以更加灵活地控制线程的行为,有效地处理线程之间的竞争关系。
13.1.2 可中断的锁获取操作
在内置锁中,锁的获取和释放等操作都是基于代码块的----释放锁的操作总是与获取锁的操作处于同一个代码块,而不考虑控制权如何退出该代码块。
\"非块结构的加锁\"可能是指在使用 ReentrantLock 时,并不是在传统的 synchronized 块中进行加锁操作。这意味着可以在方法的任意位置调用 ReentrantLock 的 lock() 方法来获取锁和释放锁。这个lock还能传递到下一个方法,通过下一个方法unlock。
13.1.3 非块结构的加锁
13.1 Lock与ReentrantLock
在Java5.0中,当从单线程(无竞争)变化到多线程时,内置锁的性能将急剧下降,而ReentrantLock的性能下降则更为平缓,因为它具有更好的可伸缩性。但在Java6中,请求就完全不同了,内置锁的性能不会由于竞争而急剧下降,并且两者的可伸缩性也基本相当。
13.2 性能考虑因素
在ReentrantLock的构造函数中提供了两种公平性选择:创建一个非公平的锁(默认)或者一个公平的锁。
在公平锁上,线程将按照它们发出请求的顺序来获取锁,但在非公平的锁上,则允许“插队”。Semaphore中同样可以选择采用公平的或非公平的获取顺序。
在大多数的情况下,非公平锁的性能要高于公平锁的性能。
在竞争激烈的情况下,非公平锁的性能高于公平锁的性能的一个原因是:在恢复一个被挂起的线程与该线程真正开始运行之间存在着严重的延迟。
当持有锁的时间相对较长,或者请求锁的平均时间间隔较长,那么应该使用公平锁。
13.3 公平性
在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的、可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用synchronzied。
13.4 在synchronzied和ReentrantLock之间进行选择
一个资源可以被多个读操作访问,或者被一个写操作访问。但两者不能同时进行。
释放优先。当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那么应该优先选择读线程,写线程,还是最先发出请求的线程?
读线程插队。如果锁是由读线程持有,但有写线程正在等待,那么新到达的读线程能够立即获得访问权,还是应该在写线程后面等待?如果运行读线程插队到写线程之前,那么将提供并发性,但却可能造成写线程发生饥饿问题。
重入性。读取锁和写入锁是否是可重入的?
降级。如果一个线程持有写入锁,那么它能否在不释放该锁的情况下获得读取锁?这可能会使得写入锁被“降级”为读取锁,同时不允许其他写线程修改被保护的资源。
升级。读取锁能否优先于其他正在等待的读现场和写线程而升级为一个写入锁?在大多数的读-写锁实现中并不支持升级,因为如果没有显式的升级操作,那么很容易造成死锁。
在读取锁和写入锁之间的交互可以采用多种实现方式。ReadWriteLock中的一些可选实现包括:
ReentrantReadWriteLock
13.5 读-写锁
第十三章 显示锁
类库包含了许多包含状态依赖性的类,例如FutureTask、Semaphore、BlockingQueue等。
创建状态依赖类的最简单方法通常是在类库中现有状态依赖类的基础上进行构造。
在单线程程序中调用一个方法时,如果某个基于状态的前提条件未被满足(例如连接池必须非空),那么这个条件将无法永远成真。因此,在编写顺序程序中的类时,要使得这些类在它们的前提条件未被满足就失败。
但在并发程序中,基于状态的条件可能会由于其他线程的操作而改变:一个资源池可能在几条指令之前还是空的,但现在却变为非空的,因为另一个线程可能会返回一个元素到资源池。对于并发对象上依赖状态的方法,虽然有时候在前提条件不满足的情况下不会失败,但通常有一种更好的选择,即等待前提条件变为真。
依赖状态的操作可以一直阻塞直到可以继续执行,这比使它们先失败再实现起来要更为方便且更不易出错。
内置的条件队列可以使线程一直阻塞,直到对象进入某个线程可以继续执行的状态,并且当被阻塞的线程可以执行时再唤醒它们。
调用者必须自行处理前提条件失败的情况。
14.1.1 示例:将前提条件的失败传递给调用者
如果缓存为空,那么take将休眠并直到另一个线程在缓存中放入一些数据;如果缓存是满的,那么put将休眠并直到另一个线程从缓存中移除一些数据。
14.1.2 示例:通过轮询与休眠来实现简单的阻塞
它使的一组线程(称之为等待线程集合)能够通过某种方式来等待特定的条件变为真。传统队列的元素是一个个数据,而与之不同的是,条件队列中的元素是一个个正在等待相关条件的线程。
正如每个Java对象都可以作为一个锁,每个对象同样可以作为一个条件队列,并且Object中的wait、notify、notifyAll方法就构成了内部条件队列的API。
14.1.3 条件队列
14.1 状态依赖性的管理
要想正确地使用条件队列,关键是找出对象在那个条件谓词上等待。
条件谓词是使某个操作成为状态依赖操作的前提条件。在有界缓存中,只有当缓存不为空时,take方法才能执行,否则必须等待。对take方法来说,它的条件谓词是“缓存不满”。条件谓词是由类中各个状态变量构成的表达式。
在条件等待中存在一种重要的三元关系,包括加锁、wait方法和一个条件谓词
每一次wait调用都会隐式地与特定的条件谓词关联起来。但调用某个特定条件谓词的wait时,调用者必须已经持有与条件队列相关的锁,并且这个锁必须保护着构成条件谓词的状态变量。
14.2.1 条件谓词
通常都有一个条件谓词----包括一些对象状态的测试,线程在执行前必须首先通过这些测试。
在调用await之前测试条件谓词,并且从wait中返回时再次进行测试。
在一个循环中调用await。
确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量。
当调用wait、notify或notifyAll等方法时,一定要持有与条件队列相关的锁。
在检查条件谓词之后以及开始执行相应的操作之前,不要释放锁。
当使用条件等待时(例如Object.wait 或Condition.await)
14.2.2 过早唤醒
丢失的信号是指:线程必须等待一个已经为真的条件,但在开始等待之前没有检查条件谓词。现在,线程将等待一个已经发生过的事件。
14.2.3 丢失的信号
每当在等待一个条件时,一定要确保在条件谓词变为真时通过某种方式发出通知。
在条件队列API中有两个发出通知的方法,即notify和notifyAll。无论调用哪一个,都必须持有与条件队列对象相关联的锁。
在调用notify时,JVM会从这个条件队列上等待的多个线程中选择一个来唤醒,而调用notifyAll则会唤醒所有在这个条件队列上等待的线程。
由于在调用notify或notifyAll时必须持有条件队列对象的锁,而如果这些等待中线程此时不能重新获得锁,那么无法从wait返回,因此发出通知的线程应该尽快地释放锁,从而确保正在等待的线程尽可能快地解除阻塞。
所有等待线程的类型都相同。只有一个条件谓词与条件队列相关,并且每个线程在从wait返回后将执行相同的操作。
单进单出。在条件变量上的每次通知,最多只能唤醒一个线程来执行。
只有同时满足以下两个条件,才能用单一的notify而不是notifyAll
14.2.4 通知
闭锁的缺陷:按照这种方式构造的阀门在打开后无法重新关闭。
当增加一个新的状态依赖操作时,可能需要对多条修改对象的代码路径进行改动,才能正确地执行通知。
14.2.5 示例:阀门类
在使用条件通知或单次通知时,一些约束条件使得子类化过程变得更加复杂。要想支持子类化,那么在设计类时需要保证:如果在实施子类化时违背了条件通知或单次通知的某个需求,那么在子类中可以增加合适的通知机制类代表基类。
对于状态依赖的类,要么将其等待和通知等协议完全向子类公开,要么完全阻止子类参与到等待和通知等过程中。
14.2.6 子类的安全问题
通常,我们应该把条件队列封装起来,因而除了使用条件队列的类,就不能在其他地方访问它。否则,调用者会自以为理解了在等待和通知上使用的协议,并采用一种违背设计的方式来使用条件队列。
是用于多线程间的协作和通信的重要概念,通常是与锁(Lock)结合使用的。条件队列允许线程在满足特定条件之前等待,并在条件满足时被唤醒。这种机制可以用来实现复杂的线程同步和通信需求。
在Java中,常见的条件队列是通过 java.util.concurrent.locks.Condition 接口实现的,在使用 ReentrantLock 时可以通过 Condition 来实现精确地控制线程的等待和唤醒。
条件队列通常用于解决经典的生产者-消费者问题或者一些需要线程间协作的场景。例如,在生产者-消费者问题中,消费者线程需要在队列为空时等待生产者线程往队列中添加数据,而当队列不为空时,消费者线程需要被唤醒来消费数据。
条件队列
14.2.7 封装条件队列
入口协议就是该操作的条件谓词。出口协议则包括,检查被该操作修改的所有状态变量,并确认它们是否使某个其他的条件谓词变为真,如果是,则通知相关的条件队列。
14.2.8 入口协议和出口协议
14.2 使用条件队列
Condition也是一种广义的内置条件队列。
内置条件队列存在的缺陷,每个内置锁都只能有一个相关联的条件队列。
与Lock相似,Condition是扩展
特别注意:在Condition对象中,与wait、notify和notifyAll方法对应的分别是await、signal和signalAll。但是,Condition对Object进行了扩展,因而它也包含wait和notify方法。一定要确保使用正确的版本---await和signal。
14.3 显式的Condition对象
在ReentrantLock和Semaphore这两个接口之间存在许多共同点。这两个类都可以用做一个“阀门”,即每次只允许一定数量的线程通过,并当线程到达阀门时,可以通过(在调用lock或acquire时成功返回),也可以等待(在调用lock或acquire时阻塞),还可以取消(在调用tryLock或tryAcquire时返回“假”,表示在指定的时间内锁时不可用的或者无法获得许可)。而且,这两个接口都支持可中断的、不可中断的以及限时的获取操作,并且也都支持等待线程执行公平或非公平的队列操作。
AQS解决了在实现同步器时涉及的大量细节问题,例如等待线程采用FIFO队列操作顺序。在不同的同步器中还可以定义一些灵活的标准来判断某个线程是应该通过还是需要等待。
14.4 Synchronizer剖析
大多数开发者都不会直接使用AQS,标准同步器类的集合能够满足绝大多数情况的需求。
在基于AQS构建的同步器中,最基本的操作包括各种形式的获取操作和释放操作。
获取操作是一种依赖状态的操作,并且通常会阻塞。当使用锁或信号量时,“获取”操作的含义就很直观,即获取的是锁或者许可,并且调用者可能会一直等待直到同步器类处于可被获取的状态。
“释放”并不是一个可阻塞的操作,当执行“释放”操作时,所有在请求时被阻塞的线程都会开始执行。
14.5 AbstractQueuedSynchronizer
ReentrantLock只支持独占方式的获取操作,因此它实现了tryAcquire、tryRelease和isHeldExclusively。ReentrantLock将同步状态用于保存锁获取操作的次数,并且还维护一个owner变量来保存当前所有者线程的标识符,只有在当前线程刚刚获取到锁,或者正要释放锁的时候,才会修改这个变量。在tryRelease中检查owner域,从而确保当前线程在执行unlock操作之前已经获取了锁:在tryAcquire中将使用这个域来区分获取操作是重入的还是竞争的。
当一个线程尝试获取锁时,tryAcquire将首先检查锁的状态。如果锁未被持有,那么它将尝试更新锁的状态标识锁已经被持有。由于状态可能在检查后被立即修改,因此tryAcquire使用compareAndSetState来原子地更新状态,表示这个锁已经被占有,并确保状态在最后一次检查以后就再没有被修改过。如果锁状态表明它已经被持有,并且如果当前线程是锁的持有者,那么获取计数会递增,如果当前线程不是锁的拥有者,那么获取操作将失败。
14.6.1 ReentrantLock
Semaphore将AQS的同步状态用于保存当前可用许可的数量。tryAcquireShared方法首先计算剩余许可的数量,如果没有足够的许可,那么会返回一个值表示获取操作失败。如果还有剩余的许可,那么tryAcquireShared会通过compareAndSetState以原子方式来降低许可的计数。
如果这个操作成功(这意味着许可的计数自从上一次读取后就没有被修改过),那么将返回一个值表示获取操作成功。在返回值中还包含了表示其他共享获取操作能否成功的信息,如果成功,那么其他等待的线程同样会解除阻塞。
CountDownLatch使用AQS的方式与Semaphore很相似:在同步状态中保存的是当前的计数值。countDown方法调用release, 从而导致计数值递减,并且当计数值为零时,解除所有等待线程的阻塞。await调用acquire,当计数器为零时,acquire将立即返回,否则将阻塞。
14.6.2 Semaphore与CountDownLatch
初看上去,FutureTask甚至不像一个同步器,但Future.get的语义非常类似于闭锁的语义------如果发生了某个事件(由FutureTask表示的任务执行完成或取消),那么线程就可以恢复执行,否则这些线程将停留在队列中并直到该事件发生。
在FutureTask中,AQS同步状态被用来保存任务的状态,例如,正在运行、已完成或已取消。FutureTask还维护一些额外的状态变量,用来保存计算结果或者抛出的异常。此外,它还维护了一个引用,指向正在执行计算任务的线程(如果它当前处于运行状态),因而如果任务取消,该线程就会中断。
14.6.3 FutureTask
ReadWriteLock接口表示存在两个锁:一个读取锁和一个写入锁,但在基于AQS实现的ReentrantReadWriteLock中,单个AQS子类将同时管理读取加锁和写入加锁。
ReentrantReadWriteLock使用了一个16位的状态来表示写入锁的计数,并且使用了另一个16位的状态来表示读取锁的计数。
在读取锁上的操作将使用共享的获取方法与释放方法,在写入锁上的操作将使用独占的获取方法与释放方法。
AQS内部维护一个等待线程队列,其中记录了某个线程请求的是独占访问还是共享访问。
在ReentrantReadWriteLock中,当锁可用时,如果位于队列头部的线程执行写入操作,那么线程会得到这个锁,如果位于队列头部的线程执行读取访问,那么队列中在第一个写入线程之前的所有线程都将获得这个锁。
14.6.4 ReentrantReadWriteLock
14.6 java.util.concurrent同步器类中的AQS
要实现一个依赖状态的类-------如果没有满足依赖状态的前提条件,那么这个类的方法必须阻塞,那么最好的方式是基于现有的库类来构建。
然后,有时候现有的库类不能提供足够的功能,在这种情况下,可以使用内置的条件队列、显式的Condition对象或者AQS来构建自己的同步器。
内置条件队列与内置锁是紧密绑定在一起的,这是因为管理状态依赖性的机制必须与确保状态一致性的机制关联起来。
同样,显式的Condition与显式的Lock也是紧密地绑定到一起的,并且与内置条件队列相比,还提供了一个扩展的功能集,包括每个锁对应于多个等待线程集,可中断或不可中断的条件等待,公平或非公平的队列操作,以及基于时限的等待。
小结
第十四章 构建自定义的同步工具
非阻塞算法是用底层的原子机器指令(例如比较和交换指令)代替锁来确保数据在并发访问中的一致性。
与基于锁的方案相比,非阻塞算法在设计和实现上都要复杂许多,但它们在可伸缩性和活跃性上却拥有巨大的优势。由于非阻塞算法可以使多个线程在竞争相同的数据时不会发生阻塞,因此它能在粒度更细的层次上进行协调,并且极大地减少调度开销。而且,在非阻塞算法中不存在死锁和其他活跃性问题。
在基于锁的算法中,如果一个线程在休眠或自旋的同时持有一个锁,那么其他线程都无法执行下去,而非阻塞算法不会受到单个线程失败的影响。
如果多个线程同时请求锁,那么JVM就需要借助操作系统的功能。如果出现了这种情况,那么一些线程将被挂起并且在稍后恢复运行。当线程恢复执行时,必须等待其他线程执行完它们的时间片以后,才能被调度执行。在挂起和恢复线程等过程中存在着很大的开销,并且通常存在着较长时间的中断。如果在基于锁的类中包含细粒度的操作(例如同步容器类,在其大多数方法中只包含了少量操作),那么当在锁上存在着激烈的竞争时,调度开销与工作开销的比值会非常高。
volatile变量是一种更轻量级的同步机制,因为在使用这些变量时不会发生上下文切换或线程调度等操作。然而,volatile变量同样存在一些局限:虽然它们提供了相似的可见性保证,但不能用于构建原子的复合操作。
例如,自增操作(++i)看起来像一个原子操作,但是事实上它包含了3个独立的操作--获取变量的当前值,将这个值加1,然后再写入新值。
锁的其他缺点。当一个线程正在等待锁时,它不能做任何其他事情。如果一个线程在持有锁的情况下被延迟执行(例如发生了缺页错误、调度延迟、或者其他类似情况),那么所有需要这个锁的线程都无法执行下去。如果被阻塞线程的优先级较高,而持有锁的线程优先级较低,那么这将是一个严重的问题----也被称为优先级反转。即使高优先级的线程可以抢先执行,但仍然需要等待锁被释放,从而导致它的优先级会降至低优先级线程的级别。如果持有锁的线程被永久地阻塞(例如由于出现了无限循环,死锁,活锁或者其他活跃性故障),所有等待这个锁的线程就永远无法执行下去。
15.1 锁的劣势
独占锁是一种悲观技术
乐观锁可以在不发送干扰的情况下完成更新操作。这种方法需要借助冲突检查机制来判断在更新过程中是否存在来自其他线程的干扰,如果存在,这个操作将失败,并且可以重试(也可以不重试)。
现在几乎所有的现代处理器中都包含了某种形式的原子读-改-写指令,例如比较并交换或者关联加载/条件存储。操作系统和JVM使用这些指令来实现锁和并发的数据结构,但在Java5.0之前,在Java类中还不能直接使用这些指令。
在大多数处理器架构中采用的方法是实现一个比较并交换(CAS)指令。有些处理器中,采用一对指令来实现相同的功能:关联加载和条件存储。
CAS是一项乐观的技术,它希望能成功地执行更新操作,并且如果有另一个线程在最近一次检查后更新了该变量,那么CAS能检测到这个错误。
当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其他线程都将失败。然后,失败的线程并不会被挂起(这与获取锁的情况不同:当获取失败时,线程将被挂起),而是被告知在这次竞争中失败,并可以再次尝试。
由于一个线程在竞争CAS时失败不会阻塞,因此它可以决定是否重新尝试,或者执行一些恢复操作,也或者不执行任何操作。这种灵活性就大大减少了与锁相关的活跃性风险。
15.2.1 比较并交换
初看起来,基于CAS的计数器似乎比基于锁的计数器在性能上更差一些,因为它需要执行更多的操作和更复杂的控制流,并且还依赖看似复杂的CAS操作。但实际上,当竞争程度不高时,基于CAS的计数器在性能上远远超过了基于锁的计数器,而在没有竞争时甚至更好。
一个很管用的经验法则是:在大多数处理器上,在无竞争的锁获取和释放的“快速代码路径”上的开销,大约是CAS开销的两倍。
15.2.2 非阻塞的计数器
在Java5.0之前,如果不编写明确的代码,那么就无法执行CAS。
在Java5.0引入了底层的支持,在int、long和对象引用等类型上都公开了CAS操作,并且JVM把它们编程为底层硬件提供的最有效方法。
在支持CAS的平台上,运行时把它们编译为相应的(多条)机器指令。
在最坏的情况下,如果不支持CAS指令,那么JVM将使用自旋锁。
在原子变量类(例如Atomicxxx)中使用了这些底层的JVM支持为数字类型和引用类型提供一种高效的CAS操作,而在java.util.concurrent中的大多数类在实现时则直接或间接地使用了这些原子变量类。
15.2.3 JVM对CAS的支持
15.2 硬件对并发的支持
原子变量比锁的粒度更细,量级更轻。
原子变量将发送竞争的范围缩小到单个变量上,这是你获得的粒度最细的情况。
更新原子变量的快速(非竞争)路径不会比获取锁的快速路径慢,并且通常更快,而它的慢速路径肯定比锁的慢速路径块,因为它不需要挂起和重新调度任务。
在原子变量类中同样没有重新定义hashCode或equals方法,每个实例都是不同的。去其他可变对象相同,它们也不宜用作基于散列的容器中的键值。
由于有一个不变性条件限制了两个数值,并且它们无法在同时更新时还维持该不变性条件,因此如果在数值范围类中使用volatile引用或者多个原子整数,那么将出现不安全的“先检查再运行”操作序列。
15.3.1 原子变量是一种“更好的volatile”
在中低程度的竞争下,原子变量能提供更高的可伸缩性。
在高强度的竞争下,锁能够更有效地避免竞争。
15.3.2 性能比较:锁与原子变量
15.3 原子变量类
如果某种算法中,一个线程的失败或挂起不会导致其他线程也失败或挂起,那么这种算法就被称为非阻塞算法。
如果在算法的每个步骤中都存在某个线程能够执行下去,那么这种算法也被称为无锁算法
如果在算法中仅将CAS用于协调线程之间的操作,并且能正确地实现,那么它既是无阻塞算法,又是一种无锁算法。
在非阻塞算法中通常不会出现死锁和优先级反转问题,但可能会出现饥饿和活锁问题,因为在算法中会反复地重试。
创建非阻塞算法的关键在于,找出如何将原子修改的范围缩小到单个变量上,同时还要维护数据的一致性。
在链式容器类(例如队列中)中,有时候无须将状态转换操作表示为对节点链接的修改,也无须使用AtomicReference来表示每个必须采用原子操作来更新的链接。
15.4.1 非阻塞的栈
CAS的基本使用模式:在更新某个值时存在不确定性,以及在更新失败时重新尝试。
链表队列比栈更为复杂,因为它必须支持对头节点和尾节点的快速访问。
引入“哨兵节点”或“哑节点”来表示一个中间状态,如果新线程发现是中间状态则等待。
15.4.2 非阻塞的链表
原子的域更新器(AtomicFieldUpdater)是 Java 并发包中提供的一个工具类,用于对指定类的指定 volatile 字段进行原子更新操作。通常情况下,我们无法直接对某个类的 volatile 字段进行原子性操作,而原子的域更新器提供了一种机制来实现对这些字段的原子性更新。
需要对指定类的指定字段进行原子性更新,而又不想使用锁的情况
对字段的修改操作相对简单,且频繁发生
要求被更新的字段必须声明为 volatile 类型,以确保可见性。
要求字段不能被 final 修饰,因为 final 修饰的字段无法被修改。
使用原子的域更新器需要谨慎,因为它绕过了 Java 内存模型的一些限制,可能导致一些隐含的线程安全问题。
注意点
15.4.3 原子的域更新器
解决方案:不是更新某个引用的值,而是更新两个值,包括一个引用和一个版本号。
AtomicStampedReference(以及AtomicMarkableReference)支持在两个变量上执行原子的条件更新。
AtomicStampedReference将更新一个“对象-引用” 二元组,通过在引用上加上“版本号”,从而避免ABA问题。
类似地,AtomicMarkableReference将更新一个“对象引用-布尔值”二元组,在某些算法中将通过这种二元组使节点保存在链表中同时又将其标记为“已删除的节点”。
15.4.4 ABA问题
15.4 非阻塞算法
小结:非阻塞算法通过底层的并发原语(例如比较并交换而不是锁)来维持线程的安全性。这些底层的原语通过原子变量类向外公开,这些类也用作一种“更好的volatile变量”,从而为整数和对象引用提供原子的更新操作。非阻塞算法在设计和实现时非常困难,但通常能够提供更高的可伸缩性,并能更好地防止活跃性故障的发生。在JVM从一个版本升级到下一个版本的过程中,并发性能的主要提升都来来自于(在JVM内部以及平台类库上)对非阻塞算法的使用。
第十五章 原子变量与非阻塞同步机制
在编译器中生成的指令顺序,可以与源代码中的顺序不同,此外编辑器还会把变量保存在寄存器而不是内存中
处理器可以采用乱序或并行等方式来执行指令
缓存可能会改变将写入变量提交到主内存的次序
保存在处理器本地缓存中的值,对于其他处理器是不可见的。
如果缺少同步,那么将会有许多因素使得线程无法立即甚至永远,看到另一个线程的操作结果。
Java语言规范要求JVM在线程中维护一种类似串行的语义;只要程序的最终结果与在严格串行环境中执行的结果相同,那么上述所有操作都是允许的。
在多线程环境下,维护程序的串行将导致很大的性能开销。对于并发应用程序中的线程来说,它们在大部分时间里都执行各自的任务,因此在线程之间的协调操作只会降低应用程序的运行速度,而不会带来任何好处。
只有当多个线程要共享数据时,才必须协调它们之间的操作,并且JVM依赖程序通过同步操作来找出这些协调操作将在何时发生。
JMM在设计时就在可预测性和程序的易于开发性之间进行了权衡,从而在各种主流的处理器体系架构上能实现高性能的JVM。
在共享内存的多处理器体系架构中,每个处理都拥有自己的缓存,并且定期地与主内存进行协调。
在不同的处理器架构中提供了不同级别的缓存一致性,其中一部分只提供最小的保证,即允许不同的处理器在任意时刻从同一个存储位置上看到不同的值。
要想确保每个处理器都能在任意时刻知道其他处理器正在进行的工作,将需要非常大的开销。在大多数时间里,这种信息是不必要的,因此处理器会适当放宽存储一致性保存,以换取性能的提升。
在架构定义的内存模型中将告诉应用程序可以从内存系统中获得怎么的保证,此外还定义了一些特殊的指令(称为内存栅栏或栅栏),当需要共享数据时,这些指令就能实现额外的存储协调保证。
16.1.1 平台的内存模型
各种操作延迟或者看似乱序执行的不同原因,都可以归为重排序。
内存级的重排序会使程序的行为变得不可预测。
同步将限制编译器、运行时和硬件对内存操作重排序的方式,从而在实施重排序时不会破坏JMM提供的可见性保证,
16.1.2 重排序
Java内存模型是通过各种操作定义的,包括对变量的读/写操作,监视器的加锁和释放操作,以及线程的启动和合并操作。
JMM为程序中所有的操作定义了一个偏序关系,称之为Happens-Before。要想保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系。如果两个操作之间缺乏Happens-Before关系,那么JVM可以对它们任意地重排序。
当一个变量被多个线程读取并且至少被一个线程写入时,如果在读操作和写操作之间没有依照Happens-Before来排序,那么就会产生数据竞争问题。在正确同步的程序中不存在数据竞争,并会表现出串行一致性,这意味着程序中的所有操作都会按照一种固定的和全局的顺序执行。
程序顺序规则。如果程序中操作A在操作B之前,那么在线程中A操作将在B操作之前执行。
监视器锁规则。在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行。
volatile变量规则。对volatile变量的写入操作必须在对该变量的读操作之前执行。
线程启动规则。在线程上对Thread.Start的调用必须在该线程中执行任何操作之前执行。
线程结束规则。线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从Thread.join中成功返回,或者在调用Thread.isAlive时必须返回false。
中断规则。当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行(通过抛出InterruptedException,或者调用isInterrupted和interrupted)。
终结器规则。对象的构造函数必须在启动该对象的终结器之前执行完成。
传递性。如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。
Happens-Before的规则包括:
16.1.3 Java内存模型简介
由于Happens-Before的排序功能很强大,因此有时候可以“借助”现有同步机制的可行性属性。这需要将Happens-Before的程序顺序规则与其他某个顺序规则(通常是监视器锁规则或者volatile变量规则)结合起来,从而对某个未被锁保护的变量的访问操作进行排序。
将一个元素放入一个线程安全容器的操作将在另一个线程从该容器中获得这个元素的操作之前执行。
在CountDownLatch上的倒数操作将在线程从闭锁上的await方法中返回之前执行。
释放Semaphore许可的操作将在从该Semaphore上获得一个许可之前执行。
Future表示的任务的所有操作将在从Future.get中返回之前执行。
向Executor提交一个Runnable或Callable的操作将在任务开始之前执行。
一个线程到达CyclicBarrier或Exchanger的操作将在其他到达该栅栏或交换点的线程被释放之前执行。如果CyclicBarrier使用一个栅栏操作,那么到达栅栏的操作将在栅栏操作之前执行,而栅栏操作之前又会在线程从栅栏中释放之前执行。
在类库中提供的Happens-Before排序包括:
16.1.4 借助同步
16.1 什么是内存模型,为什么需要它
第3章介绍了如何安全地或者不正确地发布一个对象。对于其中介绍的各种安全技术,它们的安全性都来自于JMM提供的保证,而造成不正确发布的真正原因,就是在“发布一个共享对象”与“另一个线程访问该对象”之间缺少一种Happens-Before排序。
当缺少Happens-Before关系时,就可能出现重排序问题,这就解释了为什么在没有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象。
在初始化一个新的对象时需要写入多个变量,即新对象中的各个域。同样,在发布一个引用时也需要写入一个变量,即新对象的引用。
如果无法确保发布共享引用的操作在另一个线程加载该共享引用之前执行,那么对新对象引用的写入操作将与对象中各个域的写入操作重排序。在这种情况下,另一个线程可能看到对象引用的最新值,但同时也将看到对象的某些或全部状态中包含的是无效值,即一个被部分构造对象。
除了不可变对象以外,使用被另一个线程初始化的对象通常都是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行。
16.2.1 不安全的发布
第三章介绍的安全发布常用模式可以确保被发布对象对于其他线程是可见的,因为它们保证发布对象的操作将在使用对象的线程开始使用该对象的引用之前执行。
事实上,Happens-Before比安全发布提供了更强可见性与顺序保证。
既然JMM已经提供了这种更强大的Happens-Before关系,那么为什么还要介绍@GuardedBy和安全发布呢?与内存写入操作的可见性相比,从转移对象的所有权以及对象公布等角度来看,它们更符合大多数的程序设计。Happens-Before排序是在内存访问级别上操作的,它是一种“并发级汇编语言”,而安全发布的运行级别更接近程序设计。
16.2.2 安全的发布
有时候,我们需要推迟一些高开销的对象初始化操作,并且只有当使用这些对象时才进行初始化。
在初始器中采用了特殊的方式来处理静态域(或者在静态代码块中初始化的值),并提供了额外的线程安全性保证。静态初始化是由JVM在类的初始化阶段执行,即在类被加载后并且被线程使用之前。由于JVM将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类已经加载,因此在静态初始化期间,内存写入操作将自动对所有线程可见。
无论是在在构造期间还是被引用时,静态初始化的对象都不需要显式的同步。然后,这个规则仅适用于在构造时的状态,如果对象是可变的,那么在读线程和写线程之间仍然需要通过同步来确保随后的修改操作是可见的,以及避免数据破坏。
16.2.3 安全初始化模式
简称:DCL
DCL的真正问题在于:当在没有同步的情况下读取一个共享对象时,可能发生的最糟糕事情只是看到一个失效值(在这种情况下是一个空值),此时DCL方法将通过在持有锁的情况下再次尝试来避免这种风险。然而,实际情况远比这种情况糟糕------线程可能看到引用的当前值,但对象的状态值却是失效的,这意味着线程可以看到对象处于无效或错误的状态。
在JMM的后续版本(Java5.0以及更高的版本)中,使用volatile修饰变量,那么就能启用DCL,并且这种方式对性能的影响很小,y因为volatile变量读取操作的性能通常只是略高于非volatile变量读取操作的性能。
DCL这种使用方法已经被广泛废弃-----促使该模式出现的驱动力(无竞争同步的执行速度很慢,已经JVM启动时很慢)已经不复存在,因而它不是一种高效的优化措施。延迟初始化占位符模式能带来同样的优势,并且更容易理解。
16.2.4 双重检查加锁
16.2 发布
对于含有final域的对象,初始化安全性可以防止对对象的初始引用被重排序到构造过程之前。当构造函数完成时,构造函数对final域的所有写入操作,以及对通过这些域可以到达的任何变量的写入操作,都将被“冻结”,并且任何获取该对象引用的线程都至少能确保看到被冻结的值。对于通过final域可到达的初始变量的写入操作,将不会与构造过程后的操作一起被重排序。
初始化安全性只能保证通过final域可达的值从构造过程完成时开始的可见性。对于非final域可达的值,或者在构造过程完成后可能改变的值,必须采用同步来确保可见性。
16.3 初始化过程中的安全性
小结:Java内存模型说明了某个线程的内存操作在安歇情况下对于其他线程是可见的。其中包括确保这些操作时按照一种Happens-Before的偏序关系进行排序,而这种关系是基于内存操作和同步操作等级别来定义的。如果缺少充足的同步,那么当线程访问共享数据时,会发生一些非常奇怪的问题。
第十六章 Java内存模型
第四部分 高级主题
@Immutable表示类是不可变的,它包含了@ThreadSafe的含义。
@NotThreadSafe表示类不是线程安全的。
@ThreadSafe
3个类级别的标志来描述类的线程安全性保证
类的标注
@GuardedBy (lock)表示只有在持有了某个特定的锁时才能访问这个域或方法。参数lock表示在访问被标注的域或方法时需要持有的错。
@GuardedBy(\"this\"),表示在包含对象上的内置锁(被标注的方法或域时该对象的成员)
@GuardedBy(\"fieldName\"),表示与fieldName引用的对象相关联的锁,可以是一个隐式锁(对于不引用一个Lock的域),也可以是一个显式锁(对于引用了一个Lock的域)。
@GuardedBy(\"Class Name.fieldName\"),类似于@GuardedBy(\"fieldName\"),但指向在另一个类的静态域中持有的锁对象。
@GuardedBy(\"methodName()\"),是指通过调用命名方法返回的锁对象。
@GuardedBy(\"ClassName.class\"),是指命名类的类字面量对象。
lock的可能取值包括
域和方法的标注
附录:并发性标注
Java并发编程实战
![JVM虚拟机](https://www.processon.com/chart_image/template/thumb/6693933d95015f55cdc89858.png?tid=66820abeae4058056f1b33d7)
收藏
![Java并发编程实战](https://www.processon.com/chart_image/template/thumb/6693903b339a5b613a74123c.png?tid=65dd9f63235aaa17952ff0df)
收藏
0 条评论
回复 删除
下一页