文章目录
- 第一章 简介
- 第二章 线程安全性
- 第三章 对象的共享
- 第四章 对象的组合
- 第五章 基础构建模块
- 小结
平台提供的各种并发功能与开发人员在程序中需要的并发语义并不匹配!在Java语言中提供一些底层机制,例如同步和条件等待,但在使用这些机制来实现应用级的协议与策略时必须始终保持一致。
第一章 简介
- 线程使复杂的异步代码变得更简单,简化复杂系统的开发,发挥多处理器系统的计算能力
- 计算机加入操作系统来实现多个程序同时执行的原因:资源利用率、公平性、便利性
- 串行编程模型的优势在于其直观性和简单性
- 线程允许在同一个进程中同时存在多个程序控制流
- 线程还提供了一种直观的分解模式来充分利用多处理器系统的硬件并行性,而在同一个程序中的多个线程也可以被同时调度到多个CPU上运行
- 线程能够将大部分的异步工作流转换成串行工作流,降低代码的复杂度
- 使用多个线程还有助于在单处理器系统上获得更高的吞吐率
- 通过使用线程,可以将复杂并且异步的工作流进一步分解为一组简单并且同步的工作流,每个工作流在一个单独的线程中运行,并且在特定的同步位置进行交互
- 额外的性能开销:共享数据使用同步机制,往往会抑制编译器优化,使内存缓冲区中的数据无效,以及增加共享内存总线的同步流量
- 框架通过在框架线程中调用应用程序代码(回调 CallBack)将并发引入到程序中
第二章 线程安全性
- 编写线程安全代码的核心:要对状态访问操作进行管理,特别是对共享的(Shared)和可变的(Mutable)状态的访问
- 共享意味着变量可以由多个线程同时访问,可变则意味着变量的值在其生命周期内可以发生变化
- Java中的主要同步机制是关键字synchronized,提供了一种独占的加锁方式,但同步这个术语还包括volatile类型的变量,显示锁(Explicit Lock)以及原子变量
- 完全由线程安全类构成的程序不一定就是线程安全的,而在线程安全类中也可以包含非线程安全的类
- 线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为
- 当多个线程访问某个类时,不管运行环境采用何种调度方式或者这些线程将如何交替执行,并且在主调函数代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么称这个类是线程安全的
- 在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施
- 无状态对象一定是线程安全的(大多数Sevlet)
- 竞态条件:并发编程中由于不恰当的执行时序而出现正确的结果,本质:基于一种可能失效的观察结果来做出判断或者执行某个计算
- 当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。最常见的竞态条件类型就是:先检查后执行(Check -Then-Act)操作,即通过一个可能失效的观测结果来决定下一步的动作
- 竞态条件 && 数据竞争
- 延迟初始化存在竞态条件,可能会导致两次调用getInstance返回不同的对象,参考懒汉式 - 单例
- 与大多数并发错误一样,竞态条件并不总是会产生错误,还需要某种不恰当的执行时序
- 复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性
- 当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的
- 在实际情况当中,应尽可能地使用现有的线程安全对象(例如AcomicLong)来管理类的状态
- 当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某一个变量时,需要在同一个原子操作中对其他变量同时进行更新
- 要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量
- 每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁或监视器锁,Java内置锁相当于一种互斥体(互斥锁)
- 内置锁是可重入的,重入意味着获取锁的操作的粒度是线程,而不是调用
- 重入进一步提高了加锁行为的封装性,简化OOP并发代码的开发;避免了子类继承父类同步方法死锁问题的发生
- 如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作
- 如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都要使用同步
- 对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的
- 对象的内置锁与其状态之间没有内在的关联;当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁
- 每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁
- 对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护
- 虽然
synchronized
方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,还是需要额外的加锁机制 - 通常,在简单性与性能之间存在着相互制约因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性)
- 当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O,)一定不要持有锁
第三章 对象的共享
- 同步的另一个重要的方面:内存可见性(Memory Visibility)
- 只要有数据在多个线程之间共享,就使用正确的同步
- 在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意向不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论
- 举🌰: 在缺少同步的情况下,Java内存模型允许编译器对操作顺序进行重排序,并将数值缓存在寄存器中。此外,它还允许CPU对操作顺序进行重排序,并将数值缓存在处理器特定的缓存中;看上去失败的设计,但却能使JVM充分地利用现代多核处理器的强大性能
- 最低安全性:线程在没有同步的情况下读取变量,读到的可能是一个失效值,但至少是由之前某个线程设置的值,而不是一个随机值
- 最低安全性适用于绝大多数变量,但非
volatile
类型的64位数值变量(double和long) - Java内存模型要求,变量的读取操作和写入操作必须是原子操作,但对于非
volatile
类型的double和long变量,JVM允许将64位的读操作和写操作分解为两个32位的操作 - 加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有的线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁🔒上同步
- Java稍弱的同步机制(
volatile
):确保将变量的更新操作通知到其他线程;编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile
变量不会被缓存再寄存器或者对其他处理器不可见的地方,因此在读取volatile
类型的变量时总会返回最新写入的值 volatile
变量对可见性的影响比volatile
变量本身更为重要;从内存可见性的角度来看,写入volatile
变量相当于退出同步代码块,而读取volatile变量就相当于进入同步代码块volatile
正确使用方式:确保它们自身状态的可见性,确保它们引用对象的状态的可见性,以及标识一些重要的程序生命周期事情的发生(举🌰,初始化或关闭)- 加锁机制既可以确保可见性又可以确保原子性,而
volatile
变量只能确保可见性 - 当且仅当满足下列所有条件,才应该使用
volatile
变量:
🍐对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值
🍐改变量不会与其他状态变量一起纳入不变性条件中
🍐在访问变量是不需要加🔒 - 发布一个对象,就是是对象能够在当前作用域之外的代码中使用;逸出不该被发布的对象被发布
- 当发布一个对象时,在对该对象的非私有域中引用的所有对象同样会被发布
- 发布的形式(举🌰):
🐖将对象的引用保存到一个公有的静态变量中
🐖从非私有方法中返回一个引用
🐖把一个对象传递给某个外部方法时
🐖发布一个内部的类实例(this引用在构造函数逸出) - 不要在构造过程中是this引用逸出,只有当构造函数返回时,this引用才应该从线程中逸出
- 线程封闭技术:
🍒Ad-hoc 线程封闭
🍇栈封闭
🍆ThreadLocal - 实现线程安全的最简单方式之一:线程封闭,就是不共享数据,当某个对象封闭在一个线程中时,这种方法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的
- Java不强制某个变量必须由锁🔒来保护,同样也无法强制将对象封闭在某个线程中;线程封闭必须在程序中实现,Java语言以及核心库提供一些机制来维持线程封闭,举🌰局部变量和ThreadLocal类
- 弱爆了Ad-hoc线程封闭,维护线程封闭性的职责完全由程序实现来承担
volatile
变量上存在的一种特殊的线程封闭,确保单个线程对共享volatile
变量执行写操作,便可以安全的执行“读取 - 修改 - 写入”的操作,还保证可见性- 栈封闭(被称为线程内部使用或者线程局部使用)比Ad-hoc线程封闭更易于维护,也更加健壮;实际上就是放在方法里
- ThreadLocal类(更规范),使线程中的某个值与保存值的对象关联起来;ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值
- ThreadLocal对象通常用于防止对可变的单实例变量(Singleton或全局变量进行共享)
- 某个线程初次调用ThreadLocal.get方法时,就会调用
initialValue
来获取初始值。从概念上看,可以将ThreadLocal<T>
视为包含了Map<ThreadLocal,T>
对象,其中保存了特定于该线程的值,但其实现并非如此;这些特定于线程的值保存在Thread对象中,线程终止会作为垃圾回收 - ThreadLocal变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心
- 不可变对象(Immutable Object):对象创建后其状态就不能被修改;它们的不变性条件是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持
- 不可变对象一定是线程安全的
- 不可变性不等于将对象中所有的域都声明为final类型,即使对象中所有的域都是final类型的,这个对象也仍然是可变的,因为在final类型的域中看可以保存对可变对象的引用
- 当满足以下条件时,对象才是不可变的:
🦁对象创建以后其状态就不能修改
🐅对象的所有域都是final类型 🌰从技术角度看,并不需要,String要对类的良性数据竞争情况做精准分析
🦐对象是正确创建的(在对象的创建期间,this引用没有溢出) - 不可变对象的内部仍可以使用可变对象来管理它们的状态。🌰final型的Set对象构造完成后无法对其进行修改
- 保存在不可变对象中的程序状态仍然可以更新,即通过将一个保存新状态的实例来“替换”原有的不可变对象
- Java内存模型中,final域的特殊语义,确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步
- 正如除非需要更高的可见性,否则应将所有的域都声明为私有域是一个良好的编程习惯,除非需要某个域是可变的,否则应将其声明为final域也是一个良好的编程习惯
- 在某些情况下,不可变对象可以提供一种弱形式的原子性;每当需要一组相关数据以原子方式执行某个操作时,就可以考虑🤔创建一个不可变的类来包含这些数据
- 对于访问和更新多个相关变量时出现的竞争条件问题,可以通过将这些变量全部保存在一个不可变对象中来消除
- 某个对象的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程来说就一定是可见的
- 任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步,这种保证还将延伸到被正确创建对象中所有final类型的域;在没有额外同步的情况下,也可以安全地访问final类型的域;然而,如果final类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态是仍需要同步
- 可变对象必须通过安全的方式来发布,通常意味着在发布和使用该对象的线程时必须使用同步
- 要安全发布对象,对象的引用以及对象的状态必须同时对其他线程可见,安全发布方式举🌰:
🍎在静态初始化函数中初始化一个对象引用
🍐将对象的引用保存到volatile类型的域或者AtomicReferance对象中
🍌将对象的运用保存到某个正确构造对象的final类型域中
🍊将对象的引用保存到一个由锁保护的域中 - 发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器:
public static Holder = new Holder(42);
静态初始化器由JVM在类的初始化阶段执行。由于在JVM内部存在着同步机制,因此通过这种方式初始化的任何对象都可以被安全地发布 - 事实不可变对象:从技术上来看是可变的,但其状态在发布后不会再改变
- 在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象
- 可变对象:对象在构造后可以修改,那么安全发布只能确保发布当时状态的可见性
- 对象的发布需求取决于它的可变性:🌰
🐘不可变对象可以通过任意机制来发布
🐵事实不可变对象必须通过安全方式来发布
🕷可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁🔒保护起来 - 在并发程序中使用和共享对象时,可以使用一些实用策略:
🦈 线程封闭
线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改
🦀 只读共享
在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象
🐢 线程安全共享
线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步
🦐 保护对象
被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁🔒保护的对象
第四章 对象的组合
- 封装技术,可以使得在不对整个程序进行分析的1情况下就可以判断一个类是都是线程安全的
- 设计线程安全的类的过程,需要包含以下三个基本要素:举🌰
🐳找出构成对象状态的所有变量
🐉找出约束状态变量的不变形条件
🐭建立对象状态的并发管理策略 - 同步策略:如何在不违背对象不变性条件或后验条件的情况下对其状态的访问操作进行协同,还规定如何将不变性、线程封闭与加锁机制等结合起来以维护线程的安全性
- 后验条件:判断状态迁移是否是有效的,当下一个状态需要依赖当前状态时,这个操作就必须是一个复合操作
- 并非所有的操作都会在状态转换上施加了各种约束
- 如果不了解对象的不变性条件与后验条件,那么就不能确保线程安全性。要满足在状态变量的有效值或状态转换上的各种约束条件,那就需要借助于原子性与封装性
- 类的不变性条件与后验条件约束了在对象上有哪些状态和状态转换是有效的
- 依赖状态的操作:基于状态的先验条件
- Java中,等待某个条件为真的各种内置机制(包括等待和通知等机制)都与内置加锁机制紧密相关
- 垃圾回收机制使我们避免了如何处理所有权的问题
- 所有权和封装性总是相互关联的:对象封装它拥有的状态,反之也成立,即对它封装的状态拥有所有权
- 容器类通常表现出一种所有权分离的形式,其中容器类拥有其自身的状态,而客户代码则拥有容器中各个对象的状态
- 将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁🔒
- 实例封闭是构建线程安全类的一个最简单方式,它使得在锁🔒策略的选择上拥有了更多的灵活性
举例🌰:
🏀ArrayList和HashMap基本的非线程安全的容器类可以通过包装器工厂方法Collections.synchronizedList及其类似方法,变成线程安全的容器类;对底层容器对象的所有访问必须通过包装器来进行 - 封闭机制更易于构造线程安全的类,因为当封类的状态时,在分析类的线程安全性时就无须检查整个程序
- Java监视器模式:遵循该模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护(编写代码的约定)使用一个私有锁🔒来保护状态,可以缩小检查的范围
- 问:如果类中各个组件都是线程安全的,是否需要增加额外的线程安全层?
答案是:视情况而定 - 线程安全性的委托:可以将线程的安全性交给某个或多个状态变量来进行管理,以至于程序中没有使用任何显示的同步
- 大多数组合,它们的状态变量之间存在着某些不变形条件
- 如果一个类是由多个独立且线程安全的状态变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量
- 如果一个状态变量是线程安全的,并且没有任何不变形条件来约束它的值,在变量的操作上也不存在任何不允许的状态转化,那么就可以安全第发布这个变量
- 在现有的线程安全类中添加功能的3️⃣种方式:🌰
🍈最安全的方法就是修改原始的类
🍉另一个方法就是扩展这个类,相比上面的方法更加脆弱,原因:相当于同步策略被分布到多个单独维护的源代码文件中
🍓扩展类的功能,客户端加锁,但不是扩展类的本身,而是将扩展代码放入一个“辅助类”中 - 客户端加锁:对于使用某个对象X的客户端对象代码,使用X本身用于保护其状态的保护这段客户代码
- 通过添加一个原子操作来扩展类说是脆弱的,因为它将类的加锁代码分布到多个类中。然而,客户端加锁却更加脆弱,因为它将类C的加锁代码放到与C完全无关的其他类中
- 通过组合来为现有的类添加一个原子操作
- 在文档中说明客户代码需要了解的线程安全性保证,以及代码维护人员需要了解的同步策略
- 如果某个类没有明确地声明是线程安全的,那么就不要假设它是线程安全的
- 从实现者的角度去解释规范,而不是从使用者的角度去解释
第五章 基础构建模块
- 委托shi创建线程安全类的一个最有效的策略:只需让现有的线程安全类管理所有的状态即可
- 同步封装器类是由Collections.synchronizedXxx等工厂方法创建的。实现线程安全的方式:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态
- 同步容器遵守同步策略,即支持客户端加锁;同步容器类通过其自身的锁来保护它的每个方法
- 迭代器无法避免在迭代期间对容器加锁;在设计同步容器类的迭代器时并没有考虑🤔到并发修改的问题,且表现出的行为是“即时失败;这种即时失败迭代器并不是一种完备的处理机制,只能作为并发问题的预警指示器”
- 虽然加锁可以防止迭代器抛出
ConcurrentModificationException
,但你必须要记住在所有对共享容器进行迭代的地方都需要加锁;实际会更加复杂,隐藏迭代器:所有间接的迭代操作🌰toString
🌰hashCode
🌰equals
🌰containsAll
🌰removeAll
🌰retainAll
- 正如封装对象的状态有助于维持不变性条件一样,封装对象的同步机制同样有助于确保实施同步策略
- 通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险
- 并发容器:
🌰BlockingQueue
🌰ConcurrentHashMap
🌰ConcurrentSkipListMap
🌰ConcurrentSkipListSet
- 同步容器类在执行每个操作期间都持有一个锁🔒,但
ConcurrentHashMap
并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制叫做分段锁 ConcurrentHashMap
返回的迭代器具有弱一致性,而并非即时失败,🌰size
、🌰isEmpty
的操作需求被弱化,来换取对其他更重要操作的性能优化🌰get
🌰put
🌰containsKey
🌰remove
- 由于
ConcurrentHashMap
不能被加锁来执行独占访问,因此我们无法使用客户端加锁来创建新的原子操作 - 仅当迭代操作远远多于修改操作时,才应该使用写入时复制容器(
CopyOnWriteArrayList
)
🌰事件通知系统:
在分发通知时需要迭代已注册监听器链表,并调用每一个监听器,在大多数情况下,注册和注销事件监听器的操作远少于接收事件通知的操作 - 阻塞队列(存在缓冲区,有储存功能)支持生产者-消费者模型
- 同步队列(没有缓冲区,无存储功能)仅当有足够多的消费者,并且总是有一个消费者准备好获取交付的工作时,才适合使用同步队列
- 双端队列适用于工作密取的模式,如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取任务
- 在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮
- 线程封闭对象只能由单个线程拥有,但可以通过安全发布该对象来转移所有权,发布后就和原线程没关系了;对象池利用了串行线程封闭,将对象借给一个请求线程
- 阻塞操作与执行时间很长的普通操作的区别在于,被阻塞的线程必须等待某个不受它控制的时间发生后才能继续执行
- Thread的
interrupt
方法通过修改线程的一个布尔型属性来表示中断线程 - 中断是一种协作机制
- 解决InterruptedException异常的方法
🌰 传递InterruptedException 避开这个异常通常是最明智的选择
🌰 恢复中断 - 在容器类中,阻塞队列是一种独特的类:它们不仅能作为保存对象的容器,还能协调生产者和消费者等线程之间的控制流
- 同步工具类:
🌰闭锁
🌰FutureTask
🌰信号量
🌰栅栏 - 构建高效且可伸缩的结果缓存
小结
🌰并发技巧清单
- 可变状态是至关重要的
🌬☁所有的并发问题都可以归结为如何协调对并发状态的访问。可变状态越少,就越容易确保线程安全安全性 - 尽量将域声明为final类型,除非需要它们是可变的
- 不可变对象一定是线程安全的
🥒🥒不可变对象能极大地降低并发编程的复杂性。它们更为简单而且安全,可以任意共享而无需使用加锁或保护性复制等机制 - 封装有助于管理复杂性
🌶🌶在编写线程安全的程序时,虽然可以将所有数据都保护在全局变量中,但为什么要这样做?将数据封装在对象中,更易于维持不变性条件:将同步机制封装在对象中,更易于遵循同步策略 - 用锁🔒来保护每个可变变量
- 当保护同一个不变性条件中的所有变量时,要使用同一个锁🔒
- 当执行复合操作期间,要持有锁🔒
- 如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出现问题
- 不要故作聪明地推断出不需要使用同步
- 在设计过程中考虑🤔线程安全,或者在文档中明确地指出它不是线程安全的
- 将同步策略文档化