《Java并发编程实战》学习笔记 线程安全、共享对象和组合对象

时间:2023-03-20 14:04:50

Java Concurrency in Practice一本完美的Java并发参考手册。

查看豆瓣读书

推荐:InfoQ迷你书《Java并发编程的艺术》

第一章 介绍

线程的优势:
充分利用多处理器
简化模型
简化异步事件的处理
提供用户界面的响应(时间)
线程的风险:
安全的风险(不好的事情会发生),提高错误出现的几率
活性的风险(好的事情不会发生),如某些代码不会执行,出现死锁、活锁以及饥饿
性能的风险,不好的多线程编程可能会危害性能

第二章 线程安全

编写线程安全的代码,实质是管理对状态的访问,尤其是那些共享、可变的状态。对象的状态包括任何能影响它外部可见行为的数据。
当有过个线程能访问状态变量时,而且他们当中能对变量进行修改,则需要对他们进行同步管理。
在Java中实现同步的方式有:使用synchronized关键字,使用volatile变量,使用锁,使用原子变量。
在没有正确同步的情况下,如果多个线程访问了同一个可变变量,你的程序就存在隐患,有三种方法修复它:
把变量变为非共享
使变量变为不可变
使用合适的同步机制
一开始就将类设计为线程安全的,比之后修复它更简单
好的封装措施可以更简单的使我们的程序线程安全,同时有助于维护。因为封装后,外面的代码无法访问它的状态变量,我们只需要保存该对象本身时线程安全的就行。这对大型项目尤其重要。
不能为了些许的性能提升而损害代码的线程安全。因为这更得不偿失。
当多个线程访问一个类时,如果不用考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步及在调用方代码不必作其他的协调,这个类的行为仍然是正确的,那么称这个类是线程安全的。
线程安全的类封装了任何必要的同步,因此客户不需要自己提供。
一个类是无状态的,是指它既没有自己的状态域,也没有引用其他类的域。无状态对象永远是线程安全的。
竞争条件:当计算的正确性依赖于“幸运”的时序,会产生竞争条件
数据竞争:访问共享数据时没有采用同步措施,也就是多个线程会“不会控制”的使用数据
惰性初始化的目的是延迟对象的初始化,直到程序真正使用它,同时确保它只初始化一次。
假设操作A和B,如果从执行A的线程的角度看,当其他线程执行B时,要么B全部执行完成,要么一点都没有执行,这样A和B互为原子操作。一个原子操作是指:该操作对于所有的操作,包括它自己,都满足前面描述的状态。
为了保护状态的一致性,要在单一的原子操作中更新相互关联的状态变量。
Synchronized方法包括两部分:一个对象的引用,充当的是锁的角色;该锁保护的代码段。Synchronized关键字充当锁的对象就是方法本身,也就是this关键字。
可重入锁的请求是基于“每个线程”,而不是“每次调用”。
一种常见得锁规则是:在对象内部封装所有的可变状态,通过对象的内部锁来同步任何访问可变状态的代码路径,保护它在并发访问中的安全。
对于每一个涉及多个变量的不变约束,需要同一个锁保护其所有的变量。
决定同步代码段大小的因素有:安全性、简单性和性能。
分析线程安全:首先要分析共享的可变变量是否是线程安全的,然后再分析涉及不变约束的多个变量是否被同步(如同一个锁保护)。
第三章 共享对象

Synchronized的两个作用:一是保护临界区,二是内部可见性。我们不仅希望能够避免一个线程修改其他线程正在使用的对象的状态,而且希望确保当一个线程修改了对象的状态后,其他线程能够真正看到改变。
为了确保跨线程写入的内存可见性,你必须使用同步机制。
在没有同步的情况下,编译器、处理器,运行时安排操作的执行顺序可能完全出人意料(重排序)。在没有进行适当同步的多线程程序中,尝试推断那些“必然”发生在内存中的动作时,你总是会判断错误。
在多线程中,没有使用同步机制的“读”操作可能会引发一种错误:读到过期的数据。因而在多线程中,只要有“写”共享变量,读写共享变量都要使用同步机制。
JVM允许将64位的读或写划分为两个32位的操作,因为在多程序中使用共享的、可变的long和double变量时,必须将它们声明为volatile类型,或者用锁保护起来。
锁不仅仅是关于同步与互斥的,也是关于内存可见的。一个线程在同步块之中或之前所做的每一件事,当其他线程处于同步块时都是可见的。故某些操作不一定要放到同步块中,之前也行。
Volatile可以解决可见性,而且如同同步块一样,某个线程在写volatile变量前的操作,在其它线程读volatile变量后,也都变成可见的了。相当于“栅栏”,栅栏前和后的操作只会分别重排序,而不会一起重排序。然而,我们不应该过度依赖volatile的栅栏作用,因为这比使用锁的代码更脆弱,更难以理解。正确使用volatile的方式包括:用于确保它们所引用的对象状态的可见性,或者用于表示重要的生命周期事件(比如初始化或关闭)的发生。
加锁可以保证原子性和可见性,volatile只能保证可见性。
发布一个对象的意思是指使它能够被当前范围之外的代码所使用,比如将它的引用存储到其他代码可以访问的地方,在一个非私有的方法中返回这个对象,或者传递它到其它类的方法中。发布了一个不该发布的对象或没准备好的对象(的现象)称为逸出。
在构造函数内部发布的对象,只是一个未完成构造的对象。不要让this引用在构造函数中逸出。
线程封闭是指把变量限制在单线程中,仅仅在单线程中被访问,这样就不需要任何同步。线程封闭实例:Swing的可视化组件和数据模型,JDBC connection对象。
线程封闭的三种实现方式:
Ad-hoc线程限制:是指维护线程限制性的任务全部落在实现上,而不需要经过设计。如使用volatile修饰单写多读的共享变量。这种方式是非常容易出错的。
栈限制:在线程中定义本地变量(对象),此时必须保证该对象不能被其他线程访问。
threadLocal:把一个全局共享的变量设置为threadlocal,这样每个线程都会保存一个该变量的副本,而不会相互冲突。使用threalocal还可以频繁执行的操作每次都重新分配临时对象(相对于栈限制)。
不可变性:创建后状态不能被修改的对象叫做不可变对象。不可变对象永远是线程安全的。只有满足如下条件,一个对象才是不可变的:
它的状态不能在创建后再被修改;
所有域都是final类型;并且(final域可能是可变的,因为它可以获得一个可变对象的引用)
它被正确创建(创建期间没有发生this引用的逸出)。
“将所有的域声明为final型,除非它们是可变的”,是一条良好的时间,可以减少对象的复杂度。
不可变对象可以在没有额外同步的情况下,安全的用于任意线程;甚至发布它们时亦不需要同步。
为了安全的发布对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确创建的对象可以通过下列条件安全地发布:
通过静态初始化器初始化对象的引用;
将它的引用存储到volatile域或AtomicReference;
将它的引用存储到正确创建的对象的final域中;
或者将它的引用存储到由锁正确保护的域中。
线程安全库中的容器提供了如下的线程安全保证:
直入Hashtable、synchronizedMap、ConcurrentMap中的主键以及键值,会安全的发布到可以从Map获得他们的任一线程中,无论是直接获得还是通过迭代器(iterator)获得;
置入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或者synchronizedSet中的元素,会安全的发布到可以从容器中获得它的任意线程中;
置入BlockingQueue或者ConcurrentLinkedQueue的元素,会安全的发布到可以从队列中获得它的任意线程中。
一个对象在技术上不是不可变的,但是它的状态不会在发布后被修改,这样的对象称作有效不可变对象。任何线程都可以在没有额外的同步下安全的使用一个安全发布的有效不可变对象。
可变对象安全发布仅仅可以保证“发布当时”状态的可见性。发布对象的必要条件依赖与对象的可变性:
不可变对象可以通过任意机制发布;
有效不可变对象必须要安全的发布;
可变对象必须要安全发布,同时不需要线程安全或者是被锁保护。
在并发程序中,使用和共享对象的一些最有效的策略如下:
线程限制:一个线程限制的对象,通过限制在线程中,而被线程独占,且只能被堵占有它的线程修改。
共享只读(share read-only):一个共享的只读对象,在没有额外同步的情况下,可以被多个线程并发的访问,但是任何线程都不能修改它,共享只读对象包括不可变对象和有效不可变对象。
共享线程安全(shared thread-safe):一个线程安全的对象在内部进行同步,所以其他线程无须额外同步,就可以通过公共接口随意的访问它。
被守护的(Guarded):一个被守护的对象只能通过特定的锁来访问。被守护的对象包括那些被线程安全对象封装的对象,和已知被特定的锁保护起来的已发布对象。
可见性是指在某个线程中修改了变量,其他线程可以“发觉”——读到;发布是指在某个线程中定义了变量,可以被其他线程“发觉”。

第四章 组合对象

设计线程安全类的过程应该包括下面3个基本要素:
确定对象状态是由哪些变量构成的;
确定限制状态变量的不变约束;
制定一个管理并发访问对象状态的策略。
对象的同步策略:定义了对象如何协调对其状态的访问,并且不会违反它的不变约束或后验条件。应该将类的同步策略写入文档。
对象与变量拥有一个状态空间:即它们可能处于的状态范围。不可变对象是一种极限情况,它只可能处于唯一的状态。类的不变约束与方法的后验条件约束了对象合法的状态和合法状态转换。不理解对象的不变约束和后验条件,你就不能保证线程安全性。要约束状态变量的有效值或者状态转换,就需要原子性与封装性。
若一个操作存在基于状态的先验条件,则把它称为是状态依赖的(state-dependent)。在单线程化的程序中,操作如果无法满足先验条件,必然失败;但在多线程中,可以选择:持续等待,直到先验条件为真,再继续处理操作。这可以使用java的内置高效机制wait和notify。
一个线程不安全的对象也可以应用于多线程,因为它可以被其他安全的对象封装。这称为实例限制。将数据封装在对象内部,把对数据的访问限制在对象的方法上,更易确保线程在访问数据时总能获得正确的锁。限制对象时,要防止对象逸出它的期望范围,即防止外部不通过方法直接访问对象。
限制性使构造线程安全的类变得更容易,因为类的状态被限制后,分析它的线程安全性时,就不必检查完整的程序。
使用实例限制最好的例子是Java监视器模式。遵循Java监视器模式的对象封装了所有的可变状态,并由对象自己的内部锁保护。Vector和Hashtable都使用了Java监视器模式。
线程安全委托:类自己不解决线程安全的问题,让类中的变量来解决,这种现象叫做线程安全委托。如果一个类由多个彼此独立的线程安全的状态变量组成,并且类的操作不包含任何无效状态转换时,可以将线程安全委托给这些状态变量。
如果一个状态变量是线程安全的,没有任何不便约束限制它的值,并且没有任何状态转换限制它的操作,那么它可以被安全发布。
向已有的线程安全类添加功能有三种方式:
向原始类中加入方法,需要原始代码支持和理解同步策略
扩展类,需要了解类的状态,一般同步方式是使用synchronized()就行
扩展类的功能,要确保类的内部锁和我们加的锁是同一个锁,一般是使用synchronized(object),object为原始类。
还有一种更健壮的方式——组合。其实就是使用另一个类封装原始类,而且新类中引入一个新的锁层(包括原有的方法和新方法),新的锁和原始类的锁不需要有任何关系。虽然这种方式会稍微的影响性能,但它安全。
在维护线程安全性的过程中,文档是最强大的工具之一。为类的用户编写类的线程安全性担保文档;为类的维护者编写类的同步策略文档。技巧:使用@GuardedBy标签。