导图社区 并发实战
这是一篇关于并发实战思维导图,介绍了线程安全性、对象的共享、对象的组合等。欢迎点赞收藏!
编辑于2024-04-14 18:59:36并发实战
第二章 线程安全性
第零节
一、理解
1)理解前提术语
1、共享
①什么是状态共享
某个变量可以被多个线程访问
2、可变
①什么是状态可变
变量的值在其生命周期内可以发生变化
3、状态
①什么是状态变量
②有哪四种状态变量
①既共享又可变(需要管理) ②共享但不可变(不需要管理) ③不共享但可变(不需要管理) ④既不共享又不可变(不需要管理) 要编写线程安全的代码, 其核心在于要对状态访问的操作进行管理,特别是对共享的(Shared) 和可变的(Mutable) 状态的访问
2)理解线程安全性
1、出现线程安全问题
①本质原因
①情况:多线程对某个对象『可变状态』的访问,在这种情况下,这个数据既是共享的,又是可变的 ②存在的问题? 对于共享且可变的数据进行访问的时候,如果无法对多个线程实现协同,也就是同步,那么可能会导致数据破坏以及其他不该出现的结果,也就是使得程序失去正确性,也就是带来线程安全性的问题
②如何判断线程是否安全
①和对象要实现的『功能无关』 ②和程序中对象的『访问方式有关』 一个对象是否需要是线程安全的,取决于它是否被多个线程访问
2、三种解决线程安全问题的思路
如果当多个线程访问同一个可变的状态变量时,没有使用合适的同步那么程序就会出现错误。有三种方式可以修复这个问题 ①不共享:不在线程之间共享该状态变量 ②不可变:将状态变量修改为不可变的变量 ③既共享又可变,但是需要同步 在访问状态变量时使用同步
3、java中的几种同步机制
①synchronized:Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式 ②volatile类型的变量 ③显式锁(Explicit Lock) ④原子变量 ⑤注意:同步这个术语不仅仅包括synchronized
第一节 线程安全性
一、定义
1)复杂的定义
2)困惑的定义
①可以在多个线程中调用,并且在线程之间不会出现错误的交互 ②可以同时被多个线程调用,而调用者无须执行额外的动作
1、这些定义让人困惑的两个原因
因为它们没有告诉我们—— ①如何区分『线程安全的类』以及『非线程安全的类』 ②定义中“安全”的含义是什么
2、如何定义线程安全,才不会困惑
在线程安全性的定义中,最核心的概念就是『正确性』。如果对线程安全性的定义是模糊的,那么就是因为缺乏对『正确性』的清晰定义
3)理解前提
1、理解『正确性』
①什么是『正确性』
正确性的含义是,某个『类的行为』与『类的规范』完全一致。在良好的规范中通常会定义(但是通常我们自己不会定义) ①各种『不变性条件』(Invariant)来约束对象的状态 ②定义各种『后验条件』(Postcondition) 来描述对象操作的结果
3)正确的定义
1、简易版
当多个线程访问某个类时,这个类始终都能表现出正确的行为(也即始终满足,类的不变性条件和后验性条件),那么就称这个类是线程安全的
2、完全版
二、举例
1)因数分解 servlet
1、功能说明
这个 Servlet 从请求中提取出数值,执行因数分解,然后将结果封装到该 Servlet 的响应中
2、代码举例
@ThreadSafe public class StatelessFactorizer implements Servlet { public void service(ServletRequest req, ServletResponse resp) { Biglnteger i = extractFromRequest(req); Biglnteger[] factors = factor(i); encodelntoResponse(resp, factors); } }
3、此 servlet 线程安全的原因
StatelessFactorizer 是无状态的:它既不包含任何域,也不包含任何对其他类中域的引用。(你可以点开 servlet 看一看,servlet 是一个接口,它没有任何属性)
4、无状态对象,线程安全的原因
①只有局部变量
A、如何理解局部变量,不会产生线程安全性问题
局部变量因为其存储位置决定了,它永不共享:计算过程中的临时状态仅存在于线程栈上的局部变量中(我们知道静态变量、局部变量、属性都是存放在计算机中的不同的位置),并且只能由正在执行的线程访问
②没有共享变量
A、如何理解无状态对象,没有共享状态?
多个线程访问同一个对象时,成员变量会被共享,但是无状态对象没有成员。别说共享状态了,无状态对象连成员变量都没有,连状态都没有,怎么可能有共享状态
③为什么说,多个线程访问同一个无状态对象,就好像访问不同的实例一样,也即好像没有访问共享的对象一样
访问 StatelessFactorizer 对象的线程不会影响,另一个访问同一个 StatelessFactorizer 对象的线程,的计算结果,因为这两个线程并没有共享状态( servlet 根本没有任何状态,更别说共享状态了),就好像它们都在访问不同的实例(虽然都是访问的同一个 servlet『 StatelessFactorizer 』实例)
第二节 原子性
一、新的需求
假设我们希望为 servlet,增加一个『计数器』来统计所处理的请求数量
二、解决方案
1)叙述
一种直观的方法是在 Servlet 中增加一个 long 类型的成员属性,并且每处理一个请求就将这个值加1
2)代码
@NotThreadSafe public class UnsafeCountingFactorizer implements Servlet { private long count = 0; public long getCount(){ return count; } public void service(ServletRequest req, ServletResponse resp) { Biglnteger i =extractFromRequest(req); Biglnteger[] factors = factor(i); ++count; encodelntoResponse(resp, factors) ; } }
3)可能的错误
1、起初
如果计数器的初始值为9
2、过程
那么在某些情况下,多个线程读到的值都为9(假如100核处器),同时读取了 count 属性,接着执行递增操作,并且都将计数器的值设为10
3、最后
结果肯定是错误的,处理了多个请求,为什么 servlet 对象的 count 属性的值,都是(++操作中的写入操作)写入了10,也即最终结果只增加了1,显然,这并不是我们希望看到的情况
4、错误原因
①原因:没有满足类的后验证性条件,也即破坏了类的正确性:存在多个线程访问后,访问次数只是加了1的情况 ②结论:因而这个 servlet 就不是线程安全的
三、竞态条件
1)概念
1、什么是『竞态条件』
①概念
在并发编程中,可能会因为出现『不恰当的执行时序』而出现『不正确的结果』,『这样的情况』就被称作『竞态条件』。
②特点
对于『竞态条件』来说,是否执行正确全凭运气。如果执行时序正确,那么计算就正确,如果执行时序不正确,计算就错误。
③解决思路
为了防止『竞态条件』出现不正确的结果,就需要规避『不恰当的执行时序』,那么如何规避『不恰当的执行时序』就是我们需要研究的课题
2、什么时候会出现『竞态条件』
当某个计算的正确性,取决于多个线程的『交替』『执行时序』时,那么就可能会发生竞态条件,因为存在线程的交替执行,因而可能会因为运气,产生不同的执行时序(根据线程的调度情况)。除非这多个线程的『交替』『执行时序』是正确的。
3、为什么竞态条件,会带来线程安全性问题
在多线程程序中,对于共享可变的状态进行访问时,由于执行时序的不同,可能会带来不同的结果——也就是说竞态条件,有一定的概率造成程序错误,影响程序的正确性(先验性条件、后验性条件),因而竞态条件会带来线程安全性的问题
2)分类
1、『先检查后执行』类型竞态条件
①为什么『先检查后执行』类型是竞态条件中最常见的
①说法一:多个线程的执行时序,会因为线程调度的不确定性,得到不同的检测结果,而不同的检测结果又带来了不同的执行结果 ②说法二: A、执行结果——依赖于——》检测的结果,而检测结果——依赖于——》多个线程的执行时序 B、多个线程的执行时序通常情况下是不固定不可判断的,从而导致执行结果出现各种情况(当然也就包括,错误的情况)
②本质
多线程由于执行时序的不确定性,导致多线程所访问的类,会基于一种『可能失效』的观察结果,进而做出判断或者执行某个计算(如果观察结果合理,就正确,如果观察结果失效,就错误)
③举例(没有做思维导图,今后做)
现实生活中『先检查后执行』类型的竞态条件
延迟初始化中的『先检查后执行』类型的竞态条件
2、『读取-修改-写入』类型竞态条件(没有做思维导图,今后做)
3)存在的问题
执行时序、竞态条件与线程安全的关系
①针对某一对象共享且可变的状态进行访问——》执行时序的不确定性——》存在竞态条件(有因为时序的不确定带来的错误的可能性)——》线程不安全 ②如何保证线程安全? 要保证线程安全,就需要规避竞态条件,要规避竞态条件,就需要防止出现『执行时序的不确定性』。为了防止出现『执行时序的不确定性』,就需要确保『针对同一状态,进行改变』这一复合操作的原子性,要实现复合操作的原子性,就需要对多个线程『访问同一状态变量』实现协同
4)解决方案
1、解决『竞态条件』引起的线程安全性问题的『思路』
①思路叙述
要解决线程安全性问题——》就要解决『竞态条件』问题——》要解决『竞态条件』问题——》就要解决执行时序不确定的问题——》要解决执行时序不确定的问题——》就要知道 A、什么是『正确的执行时序』 B、使用什么方法,保证『正确的执行时序』 ——》使用复合操作,确保操作的原子性,可以保证正确的执行时序
②思路解释
A、为什么,要解决『竞态条件』问题,就要解决执行时序不确定的问题?而不是把多线程变为单线程?
①首先:你不能把多线程变成单线程 抓住问题的源头,我们知道多线程开发方式,是产生一切问题的根源。但是,多线程开发是必须要进行的。因为多线程开发具有很多优点。你不能因为多线程中程序会因为不当的编码带来『竞态条件』,就把多线程改为单线程。 ②然后:你不能修改对象的『共享』『可变』的性质 多线程开发方式,又经常伴随着对『共享』『可变』状态的访问,你总不能让所有的多线程开发,都是访问不共享或者不可变的的对象状态吧 ③最后:因此前面两个步骤,你都无法做文章。 所以,你必须在这样的情况下,去思考如何解决『竞态条件』问题——在多线程的情况下,多个线程会访问共享可变的状态变量。这两个因素是永远存在的,你只能着眼于其它地方,考虑如何解决『竞态条件』问题。于是,很自然地你就会考虑,通过解决执行时序不确定的问题,来规避『竞态条件』,从而避免出现线程安全性问题
B、使用什么方法,解决执行时序不正确的问题?
在访问『共享』『可变』的状态时,你可以通过同步机制,实现原子操作,规避『执行时序』的不确定,从而规避竞态条件,避免线程不安全
C、什么是正确的执行时序?
多个线程,对于共享可变状态的『读取+修改操作』,以串行的方式执行,这样的执行时序就是正确的 (也即,正确的执行时序是——我在读,你不能改,我在改,你不能读) 如果我在改,你在读,那么在我改前和改后,读到的值都是不同的
D、如何确保正确的执行时序
使用复合操作——让多个线程间,在对某个状态变量进行『读取+修改操作』时,得以通过原子的方式执行,也即是以串行的方式进行,此时执行时序就一定是正确的了
2、复合操作
①严谨地描述复合操作
假定有两个操作A和B,如果从执行A操作的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。原子操作是指,对于『访问同一个状态的所有操作』(包括该操作本身)来说,这个操作是一个以原子方式执行的操作
②什么是复合操作?
包含了一组必须以原子方式执行的操作,从而确保线程安全性。由于『访问同一个状态的所有操作』必须以原子的方式执行,所以『访问同一个状态的所有操作』就必须组合在一起,作为一个复合操作
③如何定义复合操作
A、方法一:加锁机制(下一节讲)
B、方法二:使用一个现有的原子变量类
a、代码
public class CountingFactorizer implements Servlet { private final AtomicLong count = new AtomicLong(0); public long getCount(){ return count.get(); } public void service(ServletRequest req, ServletResponse resp) { Biglnteger i = extractFromRequest(req) ; Biglnteger [] factors = factor{i) ; count.incrementAndGet(); encodelntoResponse(resp, factors); } }
b、解释
A、原子变量类在什么包中? 在 java.util.concurrent.atomic 包中包含了一些原子变量类 B、原子变量类的作用? 用于实现在数值和对象引用上的『原子状态转换』,也即将『读取-修改-写入』等操作作为一个原子操作 C、作用举例 通过用 AtomicLong 来代替 long 类型的计数器,能够确保所有对计数器状态的访问操作都是原子的(用 count.incrementAndGet() 代替了 count++ ) D、如果成员是线程安全的,那么对象就是状态安全的? 由于 Servlet 的状态就是计数器的状态(因为 servlet 只有计数器对象这一个属性),并且计数器是线程安全的,因此这里的 Servlet 也是线程安全的。
c、结论
结论——使用一个现有的原子变量类(线程安全类)确保原子性 我们在因数分解的 servlet 中增加了一个计数器,并通过使用线程安全类 AtomicLong 来管理计数器的状态,从而确保了代码的线程安全性。 A、当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。 B、在实际情况中,应尽可能地使用现有的线程安全对象(例如AtomicLong)来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为容易,从而也更容易维护和验证线程安全性。
第三节 加锁机制
一、原子变量类的局限性
1)当前的原子变量类,使用情况?
当在 Servlet 中添加『一个状态变量』时,可以通过『线程安全的原子变量类实例对象』来管理『 Servlet 的计数器状态』(由于只有一个状态变量,此时 servlet 的状态就是这个对象的状态)以维护 Servlet 的线程安全性
2)当前的原子变量类,存在的问题?
1、原子变量可以满足需要的情况
①对象只有一个状态变量
②对象有多个状态变量,但是状态变量之间,没有联系,也即不变性条件,不会由多个变量共同来描述
2、原子变量不可以满足需要的情况
①多个状态变量,共同描述不变性条件
3)局限性
但如果想在 Servlet 中添加更多的状态,那么只是添加更多的原子变量,可能仍旧无法满足需要。是否满足需要,还要看不变性条件的描述涉及几个变量
二、带有缓存的因式分解servlet
1)目的
假设我们希望提升 Servlet 的性能:将最近的计算结果缓存起来,当『两个连续的请求』对『相同的数值』进行因数分解时,可以直接使用上一次的计算结果,而无须重新计算(这并非一种有效的缓存策略,5.6节将给出一种更好的策略)
2)带有缓存的因式分解 servlet,线程不安全的实现方式
1、思路
要实现该缓存策略,需要保存两个状态:最近『执行因数分解』的数值,以及分解结果
2、代码
UnsafeCachingFactorizer类,希望通过『线程安全的类』AtomicReferencee管理最近执行『因数分解的数值及其分解结果』 public class UnsafeCachingFactorizer implements Servlet { private final AtomicReference lastNumber = new AtomicReference(); privatefinalAtomicReference lastFactors = new AtomicReference(); public void service(ServletRequest req, ServletResponse resp) { BigInteger i =extractFromRequest(req); if (i.equals(lastNumber.get())) encodelntoResponse(resp,lastFactors.get()}; else { BigInteger [] factors = factor(i) ; lastNumber.set(i); lastFactors.set(factors); encodelntoResponse(resp, factors); } } }
3、存在的问题
① servlet 是线程不安全的
存在着『竞态条件』导致,无法满足『不变性条件』,从而使程序无法实现正确性,因而出现线程不安全的问题
②UnsafeCachingFactorizer的不变性条件之一
在 lastFactors 中缓存的『因数之积』应该等于在 lastNumber 中缓存的『数值』。只有确保了这个不变性条件不被破坏,上面的 Servlet 才是正确的
③解释:为什么不满足不变性条件
当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某一个变量时,需要在同一个原子操作中对其他变量同时进行更新。但是我们看到,变量 lastFactors 和 lastNumber 是分别更新的lastNumber.set(i); lastFactors.set(factors); 这便是存在『竞态条件』的根本原因
④错误结果
在某些执行时序中,UnsafeCachingFactorizer 可能会破坏这个不变性条件。在使用原子引用的情况下,尽管对 set 方法的每次调用都是原子的,但仍然无法同时更新 lastNumber 和 lastFactors a、情况a: 如果只修改了其中一个变量,那么在这两次修改操作之间的这段时间,其他线程将发现不变性条件被破坏了。比如目前 servlet 中的状态为,被分解数为5,因式分解结果为5,1。但是如果A线程刚好将被分解数变为3,而B线程刚好请求3的因式分解结果,那么此时,会为3返回5,1的因式分解结果。这中情况破坏了不变性条件,导致对象失去了正确性,因而变得线程不安全 b、情况b: 同样,我们也不能保证会同时获取两个值:在线程A获取这两个值的过程中,线程B可能修改了它们,这样线程A也会发现不变性条件被破坏了
⑤解决方案
要保持状态的一致性,就需要在单个原子操作中,更新所有不变性相关的状态变量
3)疑问
A、我们曾通过『线程安全的类』 AtomicLong 以线程安全的方式来管理计数器的状态 B、那么,在这里是否可以使用类似的『线程安全的类』AtomicReferencee,来管理最近执行因数分解的数值及其分解结果
三、内置锁
1)java 如何实现原子性
Java 提供了同步代码块(Synchronized Block),这种『内置的锁机制』,来支持原子性
2)同步代码块的两个部分
①第一个部分——作为锁的对象引用
该同步代码块的锁,就是方法调用所在的对象。了同步代码块所在方法,所在的对象(注意:静态的 synchronized 方法以 Class 对象作为锁)
②第二个部分——作为由这个锁保护的代码块
以 synchronized 同步关键字来『修饰的方法』,就是一种『横跨整个方法体』的同步代码块
3)锁
①何时获得锁,何时释放锁?
A、获得锁
线程在进入同步代码块之前会自动获得锁(也就是一个对象的引用)
B、释放锁
并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出
②如何获得内置锁?
进入由这个锁保护的同步代码块或方法
③锁的作用?
A、特点
Java 的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。由于每次只能有一个线程执行『内置锁』保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义—一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。
B、举例解释
当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A也将永远地等下去
4)『带有缓存的因式分解 servlet』线程安全的实现方式
①思路
使用同步代码块,这种内置的锁机制,编写具有原子性执行的代码,确保『因数分解 Servlet』的线程安全性
②特点:简单
这种『同歩机制』使得要确保『因数分解 Servlet』的『线程安全性』变得更简单
③方法
用了关键字 synchronized 来修饰 service 方法,因此在同一时刻只有一个线程可以执行 service 方法
④问题
现在的 SynchronizedFactorizer 是线程安全,然而,这种方法却过于极端,因为多个客户端无法同时使用因数分解 Servlet,服务的响应非常低,无法令人接受。这是一个性能问题,而不是线程安全问题,你可以想象解决方案
⑤代码
public class SynchronizedFactorizer implements Servlet { @GuardedBy("this").privateBigIntegerlagtliumber; @GuardedBy("this").privateBigInteger[]lastFactors; public synchronized void service(ServletRequest req, ServletResponse resp){ BigInteger i=extractFromRequest(req); if(i.equals(lastNumber)) encodelntoResponse(resp, lastFactors); else { Biglnteger[] factors = factor(i); lastNumber = i; lastFactors = factors; encodelntoResponse(resp, factors); } }
四、重入
1)什么是重入?(内置锁是可重入的)
『重入』意味着『获取锁的操作』的粒度是『线程』,而不是『调用』 ——调用是否成功,只是看是不是持有锁的线程 ①正面教材:当某个线程请求一个『已经由自己持有的』锁时,那么这个请求就会成功 ②方面教材:当某个线程请求一个『由其他线程持有的』锁时,发出请求的线程就会阻塞
2)重入的一种实现方法
①变量
为每个锁关联一个获取计数值和一个所有者线程
②过程
A、当计数值为0时,这个锁就被认为是没有被任何线程持有 B、当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1 C、如果同一个线程再次获取这个锁,计数值将递增 D、而当线程退出同步代码块时,计数器会相应地递减 E、当计数值为0时,这个锁将被释放
3)为什么需要『重入』这种功能?
①叙述
重入可以避免一种死锁情况的发生
②代码
public class Widget { public synchronized void doSomething() { } } public class LoggingWidget extends Widget { public synchronized void doSomething() { System.out.printIn(toString()+":calling doSomething"); super.doSomething(); } }
③解释
A、两个同步方法
由于 Widget 和 LoggingWidget 中 doSomething 方法都是synchronized 方法
B、同一把锁
由于重写的 doSomething 方法和从父类继承的 doSomething 方法,都是属于同一个对象,而对象就是方法锁,因此,这两个方法属于同一个对象,所以就拥有同一把锁
C、什么情况下,线程会请求一个『已经由自己持有的』锁
在一个线程中,当A方法调用B方法时,如果AB方法均为同步方法,并且用同一个对象作为锁。那么此时,就会出现,线程会请求一个『已经由自己持有的』锁
D、为什么,如果不能重入,就会出现死锁?
在线程中,对于使用同一把锁的AB方法来说,当A同步方法调用B同步方法时,一方面当前线程需要去获得一个锁,不然无法调用B同步方法,无法进入B方法的代码块执行下去,一方面当前线程又不能放弃目前持有的锁,因为还在A同步方法内没有执行完。而这两个锁,都是同一个对象,也就是这个LoggingWidget实例。因此每个 doSomething 方法在执行前都会获取 Widget 上的锁。然而,如果内置锁不是可重入的,那么在调用 super.doSomething 时将无法获得 Widget 上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。
第四节 用锁来保护状态(笔记为做好,思维导图暂时不做)
第五节 活跃性与性能
一、举什么例子说明『活跃性与性能』
1)第一阶段:不安全的 servlet
在 UnsafeCachingFactorizer 中,我们通过在因数分解 Servlet 中引入了缓存机制来提升性能
2)第二阶段:没有并发的 servlet
为了解决线程安全问题,在缓存中需要使用共享状态,因此需要通过同步块,来维护状态的完整性。但是这样又带来了性能问题
3)存在的问题
1、叙述
如果使用 SynchronizedFactorizer 中的同步方式,那么代码的执行性能将非常糟糕
2、问题根本原因
SynchronizedFactorizer 中采用的同步策略是,通过 Servlet 对象的内置锁来保护每一个状态变量,该策略的实现方式也就是对整个 service 方法进行同步。
3、可以实现功能——保证线程安全
虽然这种简单且粗粒度的方法能确保线程安全性,但付出的代价却很高
4、代价很大
由于 service 是一个 synchronized 方法,因此每次只有一个线程可以执行。这就背离了 Serlvet 框架的初衷,即 Serlvet 需要能同时处理多个请求,这在负载过高的情况下将给用户带来糟糕的体验。如果 Servlet 在对某个大数值进行因数分解时需要很长的执行时间,那么其他的客户端必须一直等待,直到 Servlet 处理完当前的请求,才能开始另一个新的因数分解运算。 如果在系统中有多个CPU系统,那么当负载很高时,仍然会有处理器处于空闲状态。即使一些执行时间很短的请求,比如访问缓存的值,仍然需要很长时间,因为这些请求都必须等待前一个请求执行完成。
4)新的解决思路
1、叙述
幸运的是,通过缩小同步代码块的作用范围,我们很容易做到既确保 Servlet 的并发性,同时又维护线程安全性
2、注意事项
要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作,从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态
3、同步只需要加在什么地方?
同步只需要在访问共享变量的地方加上,你不需要将整个方法同步。比如:此处只是同步了一个代码区块,只是为一个代码区,加了一个代码区锁 synchronized (this) { lastNumber = i; lastFactors = factors.clone(); }
5)新的代码
public class CachedFactorizer implements Servlet { @GuardedBy("this") private Biglnteger lastNumber; @GuardedBy("this") private Biglnteger[] lastFactors; @GuardedBy("this") private long hits;//表示一共处理了多少次请求 @GuardedBy("this") private long cacheHits;//表示一共缓存了多少次请求 public synchronized long getHits() { return hits; } public synchronized double getCacheHitRatio() { return (double) cacheHits/(double) hits; } public void service(ServletRequest req, ServletResponse resp) { Biglnteger i = extractFromRequest(req) ; Biglnteger[] factors = null; //第一个同步代码块 synchronized (this) { ++hits; if(i.equals(lastNumber)) { ++cacheHits; factors = lastFactors.clone(); } } // if (factors == null) { factors =factor(i); //第二个同步代码块 synchronized (this) { lastNumber = i; lastFactors = factors.clone(); } } encodelntoResponse(resp,factors); } }
1、解释
①第一个同步代码块
①第一个同步代码块——关于一个状态量操作,需要原子性: (无论『先检査后执行』,还是『读取-修改-写入』,都是关于一个状态量需要原子操作) 作用:第一个同步代码块,负责保护判断,是否只需返回缓存结果的『先检査后执行』操作序列 第一个状态变量:hit,用于记录有多少个请求,访问了这个 servlet 第二个状态变量:lastNumber,用于记录上一次请求的被分解数
②第二个同步代码块
②第二个同步代码块——关于多个状态量操作,需要原子性: 作用:第二个同步代码块则负责确保对『缓存的被分解的数值』和『因数分解结果』进行同步更新
③因数分解功能之外,引入了两个计数器
③因数分解功能之外,引入了两个计数器 A、此外,我们还重新引入了“命中计数器” B、添加了一个“缓存命中”计数器 并在第一个同步代码块中更新这两个变量,由于这两个计数器也是共享可变状态的一部分,因此必须在所有访问它们的位置上都使用同步。
④同步块之外的代码
位于同步代码块之外的代码将以独占方式来访问局部(位于栈上的)变量,这些变量不会在多个线程间共享,因此不需要同步。
第三章 对象的共享
第零节
一、关键字 synchronized 的两个作用
解释:使用关键字 synchronized ,就表示使用同步(显式的同步或者类库中内置的同步),使用同步,就表示确保原子性和内存可见性
1)保证原子性
通过上锁,将代码指定为『临界区』(Critical Section),从而保证原子性,防止某个线程正在『使用对象状态』而另一个线程在同时『修改该状态』
2)保证内存可见性
确保当一个线程『修改了对象状态』后,其他线程能够看到发生的『状态变化』,如果不能确保内存可见性,就会得到失效数据
二、何时需要确保原子性,内存可见性
什么时候需要保证『原子性』
当存在竞态条件时,需要保证『原子性』,必须使用同步机制规避竞态条件,确保线程安全
什么时候需要保证『内存可见性』
当读操作和写操作在不同的线程中执行时,无法确保执行读操作的线程能适时地看到其他线程写入的值,必须使用同步机制确保多个线程之间对内存写入操作的『内存可见性』
三、锁、同步块、阻塞的关系
当某个线程请求一个『由其他线程持有的』锁时,发出请求的线程就会阻塞,直到持有锁的线程释放这个锁,否则将永远地等下去;当某个线程请求一个『已经由自己持有的』锁时,那么这个请求就会成功
第一节 可见性
一、概念
1)单线程和多线程,对『内存写入操作』的『可见性』
1、在单线程环境中
如果向某个变量先写入值,然后在没有其他写入操作的情况下,读取这个变量,那么总能得到相同的值
2、在多线程环境中
当读操作和写操作在不同的线程中执行时,不能确保内存可见性,这时就有可能得到失效数据 A、存在的问题:我们无法确保执行读操作的线程,能适时地看到其他线程写入的最新的值,有时甚至是根本不可能的事情 B解决的方案:为了确保多个线程之间,对内存写入操作的可见性,必须使用同步机制
二、举例 说明
1)叙述
NoVisibility 说明了当多个线程,在没有同步的情况下,共享数据时出现的错误
2)代码
public class NoVisibility { private static boolean ready; private static int number; private static class ReaderThread extends Thread { //读线程 public void run() { while (!ready) Thread.yield(); System.out.println(number) ; } } //主线程 public static void main(String[] args) { new ReaderThread().start(); number = 42; ready = true; } }
3)解释
1、共享变量
主线程和读线程都将访问共享变量 ready 和 number
2、主线程和读线程的功能
A、主线程启动读线程,然后将number设为42,并将 ready 设为 true B、在 ready 的值仍然为默认的 false 之时,读线程『一直循环』睡眠,『直到发现』 ready 的值变为 true,然后才会输出 number 的值
3、可能的两种『不希望情况』
A、正确情况一
正确输出44
B、错误情况一
a、叙述:NoVisibility 可能会持续循环下去,因为读线程可能永远都看不到设置为 true 的 ready 的值(而依旧是看到设置之前的默认的 false 值)
C、错误情况二
a、叙述
NoVisibility可能会输出0,读线程注意到了 ready 值的修改,而未及时获取 number 值的修改,因此首先会输出0,今后才会因为注意到修改之后的值,再输出44
b、原因
『重排序』Reordering
c、解释——重排序的结果
在主线程中,主线程首先写入 number,然后在没有同步的情况下写入 ready; 在读线程中,读线程看到变量的顺序与写入的顺序完全相反,它先看到写入的 ready 值,此时还没看到写入的 number,可能之后才会看到,写入的 number
d、什么是『重排序』
主线程写入变量的顺序,和读线程看到的顺序可能与写入的顺序不同
f、出现『重排序』的根本原因
在没有同步的情况下,『编译器、处理器以及运行时』等都可能对『操作的执行顺序』进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论
D、几种结果的分析
①打印出0的原因
A、重排序带来的顺序的不确定性:由于 jvm 的优化带来的重排序,导致代码的编写顺序是 number = 42;ready = true;而真正执行顺序可能会是 ready = true;number = 42; B、线程调度的不确定性:可能出现这一种情况,执行 ready = true;后,还没来得及主线程执行 number = 42;就先支行了读线程,读线程发现 ready=true,就立即将 number 打印了出来,由于此时主线程还没有写入42,因此打印出来必然就是默认的0
②主线程很长时间没有打印
我们知道 valitile 就是用来确保一个值修改之后,可以将最新值在其它线程中可见。说明存在这样一种情况——一个值修改后,其修改的值在其它线程中不可见,这样就会一直是默认的 false,产生死循环的问题 注意:加锁可以解决以上问题 加锁后,编译器不会做重排序的优化,一定会顺序的执行;而且也能保证可见性
三、失效数据
1)什么时候,会产生失效数据?
在缺乏同步的程序中,一个写线程『修改了对象状态』后,读线程没有看到发生的『状态变化』,就会得到一个失效数据,产生错误结果
2)更糟糕的是,失效值可能不会同时出现
一个线程可能获得某个变量的最新值,而获得另一个变量的失效值
3)失效数据带来的错误后果?
4)非线程安全的可变整数类——Mutablelnteger
1、代码
@NotThreadSafe public class Mutablelnteger { private int value; public int get(){ return value; } public void set(int value) { this.value=value; } }
2、不安全的原因
因为 get 和 set 都是在没有同步的情况下访问 value 的,可能会产生失效值的问题。这里的线程安全问题,不是由竞态条件带来的(既不是判断——执行、也是读取——修改——写入),而是由可见性引起的
3、什么时候会出现『失效值问题』
如果某个线程调用了 set,那么另一个正在调用 get 的线程可能会看到更新后的 value 值,也可能看不到
5)线程安全的可变整数类——SynchronizedInteger
1、代码
@ThreadSafe public class SynchronizedInteger { ©GuardedBy("this") private int value; public synchronized int get(){return value;} public synchronized void set(int value){this.value = value;} }
2、解释
SynchronizedInteger 中,通过对 get 和 set 等方法进行同步,可以确保可见性,从而使 MutableInteger 成为一个线程安全的类。 仅对 set 方法进行同步是不够的,调用 get 的线程仍然会看见失效值。
四、Volatile变量
1)如何确保可见性
1、含义
volatile 变量如何确保可见性? 也即,Java 语言的『volatile 变量』(一种稍弱的同步机制),如何将变量的『更新操作』,通知到其他线程?
2、不重排序
当把变量声明为 volatile 类型后,『编译器与运行时』都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序
3、不缓存(也即总会返回最新值)
volatile 变量不会被缓存在『寄存器』或者对『其他处理器』『不可见的地方』,因此在读取 volatile 类型的变量时总会返回最新写入的值
2)volatile变量和sychronized同步块的区别
然而,在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile 变量是一种比 sychronized 关键字更轻量级的同步机制。也即 volatile 变量可以保证可见性,但是无法保证原子性
3)『volatile变量』和可见性的关系
1、volatile变量对可见性的影响比volatile变量本身更为重要
2、如何使用
A、首先:线程A写入一个 volatile 变量 B、然后:线程B随后读取该变量
3、作用
在写入 volatile 变量之前,对A线程可见的所有变量的值,在B线程读取了 volatile 变量后,对B线程也是可见的
4、『volatile变量』类比同步代码块的在使用方法上的比较
因此,从内存可见性的角度来看: A、写入 volatile 变量相当于退出同步代码块 B、读取 volatile 变量就相当于进入同步代码块
5、为什么,不建议过度依赖volatile变量提供的可见性
如果在代码中依赖 volatile 变量,来控制状态的可见性,通常比使用锁的代码 ①更脆弱②也更难以理解
10、『当且仅当』满足以下『所有条件』时,才应该使用『volatile变量』
①不需要『确保原子性』的情况下
A、在多个线程进行修改时,一定不能出现竞态条件
a、不能出现——关于单个状态变量的『竞态条件』 条件一:对变量的『写入操作』不依赖『变量的当前值』(例:存在 a=a+1时,就不可以,存在 a=1时就可以) b、不能出现——关于多个状态变量的『竞态条件』 条件二:该『变量』不会与『其他状态变量』一起纳入『不变性条件』中
B、单个线程进行修改时(一定不会出现竞态条件,你不需要确保原子性)
条件三:保证更新操作『线程封闭』,或者你能『确保只有单个线程』更新变量的值,这样只有一个线程在修改数据,其它的线程只是需要及时的读取这个更新之后的数据
②不需要使用更高级的技术
在访问变量时,不使用加锁(使用了加锁,就没有必要使用 volatile 变量了)
第二节 发布与逸出
一、概念
1)什么是发布(Publish)一个对象
①叙述
使对象能够在『当前作用域之外』的代码中使用
②举例
A、将一个指向该对象的引用,保存到其他代码可以访问的地方(例如:向一个在其他作用域内的集合,添加对象的引用) B、作为返回值:或者在某一个非私有的方法中返回(比如 return )该引用 C、作为参数:或者将引用传递到其他类的方法中
2)不同的需求
①有时需要『不发布对象』
在许多情况中,我们要『确保对象及其内部状态』『不被发布』
②有时需要『发布对象』
而在某些情况下,我们又需要『发布某个对象』
③有时需要『发布对象,并确保对象的线程安全性』
但如果在发布时要确保线程安全性,则可能需要同步
3)为什么说,发布对象可能会破坏对象的『线程安全性』
①叙述
发布内部状态可能会破坏封装性,并使得程序难以维持不变性条件
②举例
如果在对象构造完成之前就发布该对象,就会破坏线程安全性
4)什么是逸出(Escape)
当某个不应该发布的对象被发布时,这种情况就被称为逸出。(3.5节介绍了如何安全发布对象的一些方法。)
5)发布对象的几个方法
①最简单方法:公有的静态变量 将『对象的引用』保存到一个『公有的静态变量』中,以便任何类和线程都能看见该对象 ②在非私有方法中返回 ③间接发布:一个对象被发布后,它的非私有域中,引用的所有对象也会被间接的发布 ④将对象作为参数,传递给外部方法(相当于发布,注意理解这个相当于) ⑤发布内部类的实例:因为内部类的实例包含了外部类的引用
6)发布对象——举例说明
①代码
public static Set knownSecrets; public void initialize(){ knownSecrets = new HashSet (); }
②解释
当发布某个对象时,可能会间接地发布其他对象。 如果将一个Secret对象添加到集合knownSecrets中,那么同样会发布这个对象,因为任何代码都可以遍历这个集合,并获得对这个新Secret对象的引用。
③结论
在 initialize 方法中实例化一个新的 HashSet 对象,并将对象的引用保存到 knownSecrets 中,最后机会发布这个 HashSet 对象
7)逸出——举例说明
①代码
class Unsafestates { private String[] states = new String[] { "AK", "AL" ... }; public String[] getStates(){ return states; } }
②解释
同样,如果从非私有方法中返回一个引用,那么同样会发布返回的对象。 程序清单3-6中的UnsafeStates发布了本应为私有的状态数组。 为什么按照上述方式来发布states,就会出现问题? 因为任何调用者都能修改这个数组的内容。在这个示例中,数组states已经逸出了它所在的作用域,因为这个本应是私有的变量已经被发布了,这个私有的数组,现在可以被外界随意的修改,而不需要通过接口(get方法)。(但是这不正是面向对象的封装特性吗)
③什么是 Alien 外部方法
A、叙述
假定有一个类C,对于C来说,『外部(Alien)方法』是指行为并不完全由C来规定的方法
B、包括
其他类中定义的方法以及类C中可以被改写的方法——既不是 private 方法也不是 final 方法
④为什么把一个对象,传递给某个外部方法时,就『相当于』发布了这个对象?
你无法知道哪些代码会执行,也不知道在外部方法中究竟会发布这个对象(比如 return),还是会保留对象的引用并在随后由另一个线程使用(比如:没有 return,只是在当前的方法中使用而已)。无论其他的线程会对已发布的引用执行何种操作,其实都不重要,因为误用该引用的风险始终存在
⑤为什么是『相当于』
⑥解决方案——如何才能不逸出
A、叙述
你不应该发布这个 String 类型的数组对象——正确的方式
B、代码
class safestates { private String[] states = new String[] { "AK", "AL" ... }; public String getState(int n){ return states[n]; } }
C、解释
如果你确实要返回创建的字符串数组对象中的内容,你可以将这个对象中的数据一个个以值传递的方式返回,而不是以引用传递的方式,传递引用,将不应该发布的对象发布,造成逸出问题
8)发布内部类实例,会连带发布其外围类实例
1、代码
public class ThisEscape { public ThisEscape(EventSource source) { source.registerListener( new EventListener(){ public void onEvent(Event e){ doSomething(e); } }); } }
2、解释
发布一个内部的类实例——当 ThisEscape 发布了一个方法内部类实例(一个 EvenfListener 类的子类实例)时,也隐含地发布了外围类对象 ThisEscape 实例本身,因为在这个『内部类的实例』中包含了对 ThisEscape 实例的隐含引用(我们知道内部类实例,包含了对外部类的引用)。当发布内部类实例的时候,内部类实例已经构造成功了,但是由于发布内部类也一起将外部类发布,这就存在了问题,因为外部类还没有完全构造成功
二、安全的对象构造过程(思维导图,缓一缓)
第三节 防止发布(线程封闭)
一、基本概念
1)实现『线程安全』的几个办法
1、同步
当访问共享的可变数据时,通常需要使用同步
2、不共享:『线程封闭』
①叙述
它通过『不共享数据』,避免在多个线程之间,使用同步,就能实现『线程安全性』。这种技术被称为线程封闭(Thread Confinement)
②原因
由于数据是由单个线程所独占的(也即其它线程无法访问),因此仅在单线程内访问数据,因而不需要对数据进行同步
③如何实现一个对象(数据的一种),只由单个线程独占
如果数据是对象,那么通过『只在一个线程中』发布这个对象,并且不能从这个线程中逸出。这样,其它线程就无法访问这个对象,这个对象就只由单个线程独占
3、不可变
2)『同步』技术和『线程封闭』技术的比较
①『使用同步』实现『线程安全』时,要想实现『线程安全性』,则必须保证应用中的对象,是线程安全的,当然就算这些对象是线程安全的,也无法保证,整个程序的线程安全 ②『使用线程封闭』实现『线程安全』时,要想实现『线程安全性』,被封闭的对象本身不必是线程安全的 解释:当某个对象封闭在一个线程中时,即使被封闭的对象本身不是线程安全的,这种用法将自动实现线程安全性
3)Java 语言及其核心库中,维持『线程封闭性』的一些机制
1、局部变量
2、ThreadLocal 类
4)线程封闭技术举例
1、例子叙述
一种常见应用是 JDBC 的 Connection 对象
2、如何在程序中,实现 Connection 对象的线程封闭
由于大多数请求(例如 Servlet 请求或EJB调用等)都是由单个线程采用同步的方式来处理,并且在 Connection 对象返回之前,连接池不会再将它分配给其他线程,因此,这种连接管理模式,在处理请求时隐含地将 Connection 对象封闭在当前线程中
3、为什么JDBC规范,并不要求 Connection 对象必须是线程安全的
因为 Connection 对象是线程封闭的。所以即使它本身线程不安全,也可以保证整个应用的线程安全
二、三种分类
1)Ad-hoc 线程封闭 (不是很懂,思维导图,暂时不做)
2)栈封闭
1、『栈封闭』与『线程封闭』的关系?
栈封闭是线程封闭的一种特例
2、『栈封闭』的充分条件是什么
只能通过『局部变量』才能访问对象(如果引用是局部变量,那么。。)——》一定可以实现『栈封闭』 (也即引用是局部变量)
3、为什么说,『封闭在执行线程中』是『局部变量』的固有属性
局部变量位于执行线程的栈中,其他线程无法访问这个栈
4、『栈封闭』相较于『Adhoc线程封闭』的优点
栈封闭(也被称为线程内部使用或者线程局部使用,不要与核心类库中的 ThreadLocal 混淆)比『 Adhoc 线程封闭』更易于维护,也更加健壮
5、代码举例
public int loadTheArk(Collection candidates) { SortedSet animals; int numPairs = 0; Animal candidate = null; // animals被封闭在方法中,不要使它们逸出! animals = new TreeSet(new SpeciesGenderComparatorO) ; animals.addAll(candidates) ; for (Animal a :animals) { if (candidate == null || !candidate.isPotentialMate(a)) candidate = a; else { ark.load(new AnimalPair(candidate, a)); ++numPairs; candidate = null; } } return numPairs; }
解释
只是返回了一个 int 值,并没有返回引用值,确保了对象,线程封闭
6、为什么说,对于『基本类型的局部变量』无论如何都不会破坏『栈封闭性』
①叙述
由于任何方法,都无法获得对『基本类型』的引用,因此 Java 语言的这种语义,就确保了『基本类型的局部变量』始终封闭在线程内,也即外界永远无法访问基本类型的局部变量,基本类型只会存在值传递,不会出现引用传递,因此永远不会共享
②我的猜想
基本类型,只有值传递,没有引用传递,比如你返回一个值为5的 int 类型的变量,其实只有5这个值在传递
③举例
loadTheArk 方法的 numPairs ,无论如何都不会破坏栈封闭性,虽然在 loadTheArk 方法的最后,返回了 numPairs,但是它只是一个 int 类型的值传递
7、在『维持对象引用』的『栈封闭性』时,程序员『需要多做一些工作』以确保被『引用的对象不会逸出』
①当前『集合animals』的引用,『封闭在执行线程』中
在 loadTheArk 中实例化一个『TreeSet 对象』,并将『指向该对象的一个引用』保存到 animals 变量中。 此时,只有一个引用 animals 指向『TreeSet集合』,这个引用被『封闭在局部变量』中,因此也被『封闭在执行线程』中
②发布引用,对象逸出
如果发布了对集合 animals (或者该对象中的任何内部数据)的引用,那么『封闭性将被破坏』并导致『对象 TreeSet 的逸出』
③引用、对象的存储位置
我们知道:对象是存储在堆中,但是对象的引用,却是存储在栈中(引用放在栈中,具体的对象放在堆里) 对于方法中局部变量的引用存储在 java 运行时数据区的栈中,而对于实例变量则存储在 java 运行时数据区的堆中
3)ThreadLocal 类
1、原理
TlireadLocal 提供了 get 与 set 等访问接口或方法,这些方法为『每个使用该变量的线程』都存有一份关于该变量的『独立的副本』,因此 get 总是返回由『当前执行线程』在调用 set 时设置的最新值。而不是其它线程设置的最新值。 由于『每个使用该变量的线程』都存有一份『独立的副本』,因此它们没有对数据进行共享
2、作用
ThreadLocal 对象通常用于『防止』对『可变的』『单实例变量』(Singleton) 或『全局变量』进行共享
3、举例说明
①单线程中的『数据库连接——Connection对象』
例如,在单线程应用程序中,可能会维持一个全局的数据库连接——Connection 对象,并在程序启动时初始化这个连接对象。从而避免,在调用每个方法时,都要传递一个 Connection 对象,此时,你只需要获得这个『全局的数据库连接』即可
②多线程中的『数据库连接——Connection对象』
由于JDBC的连接对象不一定是线程安全的,因此,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将 JDBC 的连接保存到 ThreadLocal 对象中,每个线程都会拥有属于自己的连接,而不是所有的线程,对数据库连接对象进行共享
4、线程封闭的Connection对象,举例说明
①代码
private static ThreadLocal connectionHolder = new ThreadLocal() { public Connection initialValue{) { return DriverManager.getConnection(DB_URL); } } public static Connection getConnection() { return ConnectionHolder.get(); } }
②解释
A、initialValue 方法:当某个线程初次调用 ThreadLocal.get 方法时,就会调用 initialValue 来获取初始值 B、从概念上看 ThreadLocal: 你可以将 ThreadLocal视为包含了 Map对象,其中保存了特定于该线程的值,但其实现并非如此 C、从实现上看 ThreadLocal: 这些特定于线程的值保存在 Thread 对象中,当线程终止后,这些值会作为垃圾回收
6、如何『单线程应用程序』移植到『多线程环境』
假设你需要将一个『单线程应用程序』移植到『多线程环境』中,通过将『共享的全局变量』转换为 ThreadLocal 对象(如果全局变量的语义允许),可以维持线程安全性
7、在实现应用程序框架时,大量使用了ThreadLocal
例如,在EJB调用期间,J2EE容器需要将一个事务上下文(Transaction Context)与某个执行中的线程关联起来。 通过将『事务上下文』保存在静态的 ThreadLocal 对象中,可以很容易地实现这个功能:当框架代码需要判断当前运行的是哪一个事务时,只需从这个 ThreadLocal 对象中读取『事务上下文』。这种机制很方便,因为它避免了在调用每个方法时都要传递『执行上下文信息』,然而这也将使用该机制的代码与框架耦合在一起。
8、开发人员经常滥用ThreadLocal
例如将所有全局变量都作为 ThreadLocal 对象,或者作为一种"隐藏"方法参数的手段。 ThreadLocal 变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心
第四节 不变性
一、概念
1)带来『原子性』和『内存可见性』的根本原因
①此前介绍了许多,与原子性和可见性,相关的问题
例如得到失效数据,丢失更新操作或者观察到某个对象处于不一致的状态等等
②根本原因
都与多线程试图同时访问『同一个可变的状态』相关
2)一种解决思路
如果对象的状态不会改变,那么这些问题与复杂性也就自然消失了,由于值不可变,就不会出现竞态条件,也就不需要保证原子性,另一方面,由于值始终是一个,因此,不会有『内存可见性』问题
3)什么是『不可变对象』
如果某个对象在被创建后,其状态就不能被修改,那么这个对象就称为『不可变对象』
4)为什么说,线程安全性是『不可变对象』的固有属性之一
『不可变对象』的『不变性条件』是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件,就一定能得以维持
5)不可变对象一定是线程安全的
既不会有原子性问题(竞态条件)也不会有『内存可见性问题』
6)为什么说,不可变对象很简单
它们只有一种状态,不会改变,并且该状态由构造函数来控制
7)为什么『不可变对象』相较于『可变对象』更加可信
①如果将一个可变对象传递给不可信的代码,或者将该对象发布到『不可信代码』可以访问它的地方。那么就很危险 A、不可信代码会改变它们的状态 B、更糟的是,在代码中,将保留一个对该对象的引用,并稍后在其他线程中修改对象的状态 ②另一方面,不可变对象,不会像这样被恶意代码或者有问题的代码破坏,因此可以『安全地共享和发布』这些对象,而无须创建保护性的副本
8)定义不可变对象的两种方法
1、不可变对象内部,使用不可变对象来管理它们的状态(也即不可变对象嵌套,也即权威final)
2、不可变对象内部,使用可变对象来管理它们的状态,但是可变对象,不能对外发布
这样可变对象,虽然可变,但是外界没有办法修改它 举例说明:String 类中,只有一个非 final 的变量 hash,这个 int 类型的值。但是这个值只有 getter 没有 setter,所以,虽然具有可变性,但是外界无法改变,所以 String 是线程安全的。 如果A对象是一个不变性对象,并且此时它的某个状态是B对象,那么如果B对象无论有 setter 还是 getter 都会破坏A对象的不可变性。如果B对象的引用被定义为 final,那么自然不会有 setter,这时,为了确保A对象的不可变性,你就不能在定义B对象的 setter。而如果该状态不是一个对象,而是一个基本类型的B变量。那么,只要没有B变量的 setter,那么就可以确保B变量不会改变。因为就算提供了B变量的 getter,外界也只能拿到B变量的值,而无法修改它的值。
9)所有的域都声明为final类型,就是不可变性对象吗?
即使对象中所有的域都是 final 类型的,这个对象也仍然是可变的,因为在 final 类型的域中可以保存对可变对象的引用,也即是说,在所有域都是 final 类型之外,你还需要确保,如果属性是对象,那么这个对象的所有域也应该是 final 的,以此内推。因为引用为 final,只是说明,引用只是指向一个不变的对象,它不能再指向其它的对象。但是这个不变的引用所指向的对象,确实可以改变的
10)满足不可变对象的几个条件
①对象创建以后,其状态就不能修改
(猜想一:如何通过程序来实现其状态不可改变,特别是当某个属性是一个对象的时候?可以通过将这个对象属性的所有属性定义 final 实现吗?可以使用可变对象,但是进行额外的处理吗) (猜想二:如果某个属性是一个对象,那么这个对象的状态也不能修改,也即不可变对象也只能包含不可变对象。如果属性是一个对象,无论是引用,还是对象本身的属性都应该是 final 的。否则,要么这个属性的值可以变,要么这个属性引用可以变,可以指向不同的位置。这样对于对象来说,都不能满足不可变对象)
②对象的所有域,都是final类型
③对象是正确创建的(在对象的创建期间,this引用没有逸出)
(猜想三:为了保证状态不能被修改,可以通过既不提供 set 方法,也不提供 get 方法来实现。 A、如果你没有提供 get 方法,如果属性是对象,那么外界就无法访问这个对象。就无法修改对象中封装的在堆上存储的值。你总不能通过『对象名.属性名』这样以违反封装特性的方式,对对象类型的属性进行访问。 B、没有提供 set 方法的话,你就无法修改指向对象的引用。就算提供了 set 方法,你再调用 set 方法的时候,由于你是不可变对象,所有的成员变量都是 final 的,编译器就会报错的——cannot assign a value to a final variable )
11)在不可变对象的内部仍可以使用可变对象来管理它们的状态,但需要满足一定的条件
①代码
在不可变对象的内部仍可以使用可变对象来管理它们的状态,如程序清单3-11中的 ThreeStooges 所示。 public final class ThreeStooges { //set 对象,存放名字 private final Set stooges = new HashSet( ) ; public Set gettStooges(){ //如果属性是对象——不可变对象好像不能提供get方法,因为这会成员对象进行发布,外界就可以修改不可变对象,这样还能称之为不可变对象吗 return this.stooges; } public ThreeStooges() { stooges.add("Moe" ); stooges.add ("Larry") ; stooges.add("Curly" ) ; } public boolean isStooge(String name) { return stooges.contains(name) ; } public static void main(String[] args) { ThreeStooges threeStooges=new ThreeStooges(); threeStooges.stooges.add("ddd"); //编译器未报错:虽然stooges只能指向一个对象,但是,这个对象是可以变的 threeStooges.stooges=new HashSet( ) ; //编译器报错:cannot assign a value to a final variable 'stooges'由于stooges是一个final类型的,他不能指向一个新的对象。但是请注意:stooges也是私有的,你不能这样来访问,只能通过getter方法来访问,否则会破坏封装性 } } 12、 public final class ThreeStooges { private final Set stooges = new HashSet() ; public Set gettStooges(){ return this.stooges; } public ThreeStooges() { stooges.add("Moe" ); stooges.add ("Larry") ; stooges.add("Curly" ) ; } public boolean isStooge(String name) { return stooges.contains(name) ; } public static void main(String[] args) { ThreeStooges threeStooges=new ThreeStooges(); //编译器未报错:虽然stooges只能指向一个对象,但是,这个对象是可以变的 Set test1= threeStooges.gettStooges(); test1.add("ddd");//stooges所引用的对象,已经从ThreeStooges对象内部逸出了,所以你们可以随意的胡乱修改 Set test2= threeStooges.gettStooges(); //所以如果没有getter方法,那么对于不可变对象的对象属性,就无法修改它的状态 //因为通过对象.引用,来访问private的属性值,是不符合封装特性的 } }
②解释:不清楚——为什么是这样
stooges 是一个 final 类型的引用变量,因此所有的对象状态都通过一个 final 域来访问。最后一个要求是“正确地构造对象”,这个要求很容易满足, 因为构造函数能使该引用由除了构造函数及其调用者之外的代码来访问。
②条件
如果不可变对象的某个状态,是一个可变对象,只要这个可变对象没有对外发布,那么不可变对象仍旧不可变。 说明:在本代码中,尽管保存姓名的 Set 对象是可变的,但从 ThreeStooges 的设计中可以看到, 在 Set 对象构造完成后无法对其进行修改。因为这个 Set 对象的引用,外界无法拿到
二、Final
1、final的作用?
final 类型的域是不能修改的 注意事项:如果 final 域是一个引用(也即 final 中存放的地址不可变),而它所引用的对象是可变的(也即对象中的状态可变),那么这些被引用的对象,是可以修改的
2、什么时候使用『不可变对象』
3、即使对象是可变的,final也可以『限制对象的可变性』
①即使对象是可变的,通过将对象的某些域声明为 final 类型,可以减少可变状态的数量,通过将域声明为 final 类型,也相当于告诉维护人员这些域是不会变化的,从而可以简化对状态的判断 ②『限制对象的可变性』也就相当于『限制了』该对象可能的『状态集合』 ③仅包含一个或两个可变状态的『基本不可变』对象,仍然比包含『多个可变状态』的对象简单。我们知道不可变对象,只有一个状态。
4、良好的编程习惯
①『除非需要更高的可见性,否则应该将所有的域都声明为私有域』
②『除非需要某个域是可变的,否则应该将其声明为final域』
三、使用『Volatile类型』来发布『不可变对象』(思维导图,下次做)
四、定义不可变对象的两个方法
1、使用 final
2、不定义 setter方法
3、不定义 getter 或其它方法,返回状态的引用(如果状态是一个对象的情况下)
第五节 安全发布
一、
1、什么时候你不能发布对象
当你不希望对象被共享的时候
2、如何确保对象不被发布
让对象封闭在线程或另一个对象的内部
3、什么情况下,你需要发布对象
在某些情况下,我们希望,在多个线程间共享对象,这个时候你需要发布对象,从而可以让多个线程可以访问到。但是这个时候,存在一个问题,你必须确保,安全地进行共享,也即需要确保『安全发布』
4、不安全的发布
①代码
//不安全的发布 public Holder holder; public void initialize(){ holder = new Holder(42) ; } }
②疑惑
你可能会奇怪,这个看似没有问题的示例何以会运行失败
③原因
由于存在可见性问题,其他线程看到的 Holder 对象将处于不一致的状态,即便在该对象的『构造函数中』已经正确地构建了『不变性条件』。这种『不正确的发布』导致其他线程看到『尚未创建完成的对象』
④解释
将对象引用保存到公有域中,那么还不足以,安全地发布这个对象。由于对象逸出,外界什么时候都可以拿到对对象的引用,那么在以下情况下,都会产生错误。 A、在new Holder(42) ;执行之前,holder引用为null; B、在new Holder(42) ;执行之时,holder引用一直指向一个『尚未创建完成的对象』; 你不能指望一个尚未被完全创建的对象拥有完整性。 某个观察该对象的线程将看到对象处于不一致的状态,然后看到对象的状态突然发生变化,即使线程在对象发布后还没有修改过它。
二、不正确的发布:正确的对象被破坏
1、什么是『未被正确发布』
由于『没有使用同步』来确保 Holder 对象对其他线程『可见』,因此将 Holder 称为『未被正确发布』
2、在『未被正确发布的对象』中存在哪两个问题
①首先,『除了发布对象』的线程外,其他线程可以看到的 Holder 域是一个失效值,因此将看到一个空引用或者之前的旧值。 ②然而,更糟糕的情况是,线程看到 Holder 引用的值是最新的,但 Holder 状态的值却是失效的 ③情况变得更加不可预测的是,某个线程在第一次读取域时得到失效值,而再次读取这个域时会得到一个更新值, 这也是 assertSainty 抛出 AssertionError 的原因
3、以上三种情况,没有理解是什么回事!!
如果没有足够的同步,那么当在多个线程间共享数据时将发生一些非常奇怪的事情。
三、不可变对象与初始化安全性
四、安全发布的常用模式
第四章 对象的组合
第一节 设计线程安全的类
一、概述
1)设计线程安全类的三个基本要素
1、找出『构成对象状态』的『所有变量』
2、找出约束状态变量的『不变性条件』
3、建立『对象状态』的『并发访问』『管理策略』
2)如何分析『对象的状态』?
1、如果对象中所有的域都是『基本类型的变量』
①叙述
那么这些域将『构成对象』的『全部状态』
②举例
一个域:程序清单4-1中的Counter只有一个域value,因此这个域就是 Counter 的全部状态 N个域:对于含有 n 个『基本类型』域的对象,其状态就是这些域构成的 n 元组 两个域:二维点的状态就是它的坐标值(x, y)
2、如果在对象的域中引用了其他对象
①叙述
那么该对象的状态将包含被引用对象的域。
②举例
例如,LinkedList 的状态就包括该链表中所有节点对象的状态
3)什么是同步策略(Synchronization Policy)
1、说法一
定义了如何在『不违背』对象『不变条件』或『后验条件』的情况下,对其状态的『访问操作』进行协同
2、说法二
同步策略规定了,如何将各种技术(不可变性、线程封闭与加锁机制等)结合起来,以『维护线程的安全性』,并且还规定了哪些变量由哪些锁来保护
4)为什么必须将『同步策略』写为『正式文档』
要确保开发人员可以对这个类进行分析与维护
二、收集同步需求
1)什么是收集同步需求
就是要满足『不变性条件』和『后验条件』就可以了
2)不可变条件
1、在『并发访问的情况下』,为什么要防止,线程的『不变性条件』,遭到破坏
为了确保类的线程安全性:一旦对象的状态不再符合『不变性条件』,那么此时,对象的状态就是无效的,既然此时,对象表现出了不正确的行为,那么此时对象肯定就线程不安全了
2、如何防止,类的『不变性条件』,遭到破坏
对象与变量都有一个『状态空间』,即所有可能的取值,或者说取值范围
3、什么是『状态空间』
对象与变量都有一个『状态空间』,即所有可能的取值,或者说取值范围
4、『状态空间』大小与『可能状态』分析难度
『状态空间』越小,就越能简化对象『可能状态』的『分析过程』,final 类型的域使用得越多,分析起来就越轻松,在极端的情况中,不可变对象——只有唯一的状态
5、『不可变条件』的作用?
『不可变条件』用于『判断当前的状态』是『有效还是无效』(在许多类中都定义了一些『不可变条件』)
6、『不可变条件』与『状态空间』的举例说明:
A、例子
计数器类 Counter中,只有一个表示记录目前多少次的属性—— long 类型的变量 value。
B、状态空间
其状态空间为从 Long.MIN_VALUE 到 Long.MAX_VALUE
C、不可变条件
但 Counter 中 value 在取值范围上,存在着一个限制也就是『不可变条件』,即不能是负值,即 value>=0
D、关系
变量的取值范围,首先是在整个状态空间内的,并且必须要满足不可变条件
3)后验条件
①『后验条件』的作用?
判断『状态迁移』是否是有效的,它们是对『状态转换』施加的限制
②『后验条件』举例
如果Counter的当前状态为17,那么下一个有效状态只能是18。当下一个状态需要依赖当前状态时,这个操作就必须是一个复合操作
③什么时候,不需要『后验条件』?
状态迁移后,该变量之前的状态并不会影响计算结果
④举例说明——不需要『后验条件』的情况
并非所有的操作都会在『状态转换』上施加限制,也即不会规定,变化后的值是变化前的值的某种函数关系,或者逻辑关系,例如,当更新一个保存当前温度的变量时,变化后的温度,相较于变化前的温度,没有任何必然的关系,是增是减,增多少减多少,都是不定的
三、如何满足同步需求
1)换一种说法
也即:为了满足不变性条件和后验性条件,需要在这个对象上,做什么?
2)如何满足不变性条件
①叙述
使用封装,解决『不变性条件』在『状态』上施加的『各种约束』
②举例
例如,对于一个人对象,对于其年龄这个属性的访问,应该封装起来,外界只能通过 getter 和 setter 进行访问。如果你可以直接通过『对象.年龄』访问并修改年龄,那么可能无法满足『不变性条件』——大于等于0,小于等于150。而我们通过 setter 方法作为接口来访问年龄。这个属性的时候,就可以在 setter 方法中,进行 if 判断,如果要设置的属性值,不符合『不变性条件』——大于等于0,小于等于150,那么我不就不执行操作
3)如何满足后验条件
使用同步,解决『后验条件』在『状态转换』上施加的『各种约束』
4)方式比较
①封装——》满足『不变性条件』——》避免产生『无效状态』
②同步——满足『后验条件』——》避免产生『无效的状态转换』
③说明
为了防止客户代码,可能会使对象处于『无效状态』,必须对底层的状态变量进行封装,以满足『不变性条件』在『状态』上施加的『各种约束』。为了防止某个操作中,存在『无效的状态转换』,那么该操作必须是原子的,以满足『后验条件』在『状态转换』上施加的『各种约束』
5)存在的问题
1、叙述
要满足『约束多个状态变量』的『不变性条件』,也需要同步才能实现,仅仅使用封装已经不能达到效果
2、存在着什么特殊情况
一个类中,也可以包含,同时『约束多个状态变量』的『不变性条件』。同时『约束多个状态变量』的『不变性条件』,也就是『包含多个变量的不变性条件』
3、举例
在一个表示数值范围的类(例如 NumberRange)中可以包含两个状态变量,分别表示范围的上界和下界。这时存在一个『约束这两个状态变量』的『不变性条件』,这两个变量必须遵循的约束是,下界值应该小于或等于上界值
4、为什么『包含多个变量的不变性条件』将带来原子性需求
这些相关的变量必须在单个原子操作中进行读取或更新。不能首先更新一个变量,然后释放锁并再次获得锁,然后再更新其他的变量。因为释放锁后,可能会使对象处于无效状态。如果在一个不变性条件中包含多个变量,那么在执行任何访问相关变量的操作时,都必须持有保护这些变量的锁。
6)为什么『不变性条件』和『后验条件』都会带来原子性要求
①『不变性条件』可能带来原子性要求
如果『不变性条件』中涉及了多个变量,那么这几个变量需要在一个原子操作中,进行修改,否则,会因为执行时序的不同,破坏不变性条件,破坏了对象的正确性,带来了线程安全性问题。所以如果『不变性条件』中涉及了多个变量,那么如果不为其提供原子操作,那么也会产生竞态条件,因为它会因为执行时序的不同,破坏不变性条件,破坏正确性。比如在带有换存的因式分解 servlet 的例子中。每一次修改缓存,那么需要在一个同步块内以 原子的方式,修改『被分解数』以及『分解结果』。不然就会产生竞态条件
②『后验条件』可能带来原子性要求
在进行状态转换的时候,如果修改前后的状态状态之间,存在着某种关系。那么就需要先读取之前的状态,然后再根据函数关系(也可能是其它关系)进行修改,然后才能进行状态的转换。通过这个过程我们可以知道,会出常见的现竞态条件—— 『读取—修改—写入』或者『先检查后执行』
③举例说明:『后验条件』与『不变性条件』是不同的
A、差异叙述
A、差异叙述:每过一年,那么对于一个人来说 后验条件:检验是否增加一岁 不变性条件:检验是否超过了150岁
B、共同合作
一个对象要满足正确性,那么既要满足后验条件,也要满足不变性条件。比如一个人虽然满足小于150岁,但是如果每年增加2岁,那么这就是不正确的。还有就是如果每年增加1岁,但是现在是-23岁,那么也是不正确的。要想正确,必须要同时满足不变性条件(描述当前状态的约束)和后验条件(描述状态迁移的约束)。如果对于一个长期变化的状态来说,如果每一个历史状态都是正确的,符合不变性条件的;并且每一次状态变化都是正确的,符合后验条件。那么这个状态就总会是正确的。
四、依赖状态的操作
1)『先验条件』
1、作用叙述
①『先验条件』
根据『操作执行之前』的『状态』,判断能否进行某个操作
②『后验条件』
用来根据『操作执行之前』和『操作执行之后』的『状态』,判断进行某个操作(操作的结果:状态迁移),是否有效;
2、举例
例如,不能从空队列中移除一个元素,在删除元素前,队列必须处于『非空的』状态
3、对比
A、『先验条件』
在操作执行之前,基于状态(之前状态),必须满足的条件
B、『后验条件』
在操作执行之后,基于状态(前后状态),必须满足的条件
2)什么是『依赖状态的操作』
如果在某个操作中包含有『基于状态』的『先验条件』,那么『这个操作』就称为『依赖状态的操作』
3)『单线程和多线程』中的『先验条件』
①在『单线程程序』中
如果某个操作无法满足先验条件,那么就只能失败,也即就会走 else 分支
②在『并发程序』中
先验条件可能会由于其他线程执行的操作而变成真
③无论在『单线程程序』还是在『并发程序』中,都需要等到『先验条件』为真,才会执行该操作
4)如何实现『依赖状态的操作』?也即如何『实现』某个『等待先验条件为真时』才执行的『操作』
1、使用在『平台与类库中』提供的『各种底层机制』来『创建依赖状态的类』(第14章将介绍)
解释:在Java中,等待某个条件为真的各种内置机制(包括等待和通知等机制)都与内置加锁机制紧密关联,要想正确地使用它们并不容易
2、一种更简单的方法——利用『现有类库』:(第5章将介绍)
通过『现有类库』来实现『依赖状态』的操作——例如:同步工具类
3、阻塞类是同步工具类的子集,阻塞类包括——阻塞队列(Blocking Queue)或信号量(Semaphore)
四、状态的所有权
1)概念
1、对象的状态,包括哪些
①叙述
在对象图中,从对象『可以达到的所有域』中,只有那些对象『拥有的属性』才会构成『对象的状态』。也即如果对象之间是依赖关系,那么这两个对象之间,就不存在拥有关系。虽然在对象图中,相互依赖的对象,也是可以达到的。
②解释
A、什么是对象图——从对象图与类图的关系说起
a、不同点
o 一个对象图是类图的一个实例 o 对象图显示多个类的对象实例,而不是显示多个实际的类 o 由于对象存在生命周期,因此对象图只能在系统某一时间段存在
b、相同点
o 对象图是类图的实例 o 几乎使用与类图完全相同的标识 o 对于对象图来说无需提供单独的形式。类图中就包含了对象,所以只有对象而无类的类图就是一个"对象图"