关于《Java并发编程实站》 —– 第一部分的阅读笔记
第一章:简介
第一部分主要是 2~5章的内容,主要介绍了Java并发编程的基本理论,包括线程安全与状态对象的基础知识。
这部分主要是一些基础概念的认识和了解。比较繁琐……..
这本书还是很不错的,一句话来讲: 开卷有益!
第二章 :线程安全性
一、 线程安全性
什么是线程安全性: 当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么称这个类是线程安全的。
正确性的定义:某个类的行为与其规范完全一致。
- 无状态对象一定是线程安全的。 无状态是指: 对象既不包含任何域,也不包含任何对其他类中域的引用。因为计算过程中 俩个线程并没有共享状态,所以是线程安全的。
二、 原子性
- 竞态条件 : 在并发编程中,由于不恰当的执行时序而出现不正确的结果的情况,称为:竞态条件(RaceCondition)。最常见的竞态条件的类型为: “先检查后执行”操作,在多线程的情况下就会产生竞态条件。
实例: 延迟初始化中的竞态条件 —- 单件模式中的懒汉模式的写法
public class Singleton{
private static Singleton uniqueInstance;
private Singleton(){}
public static Singleton getInstance(){
if(uniqueInstance == null){
uniqueInstance = new Singleton();
}
return uniqueInstance;
}
}
上面的代码,在多线程的情况下,会有竞态条件的产生,线程不安全。
02 . 原子操作: 对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。
03 . 复合操作: 我们将“先检查后执行”以及“读取 - 修改 - 写入”等操作称为复合操作:包含一组必须以原子方式执行的操作以确保线程安全性。
04 . 使用线程安全对象来管理类的状态,比非线程安全的对象更为容易。
示例: 用来统计已处理请求的数量
非线程安全代码
public class UnsafeCountingFactorizer implements Servlet {
private long count = 0; // 记录处理的数量
public long getCount(){ return count; }
public void service(ServletRequest req, ServletResponse resp){
doSomething(req);
++ count;
encodeIntoResponse(resp);
}
}
线程安全代码
public class CountingFactorizer implements Servlet {
// java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。
//通过AtomicLong来代替long类型,能够确保所有对计数器状态访问的操作都是原子的。
private final AtomicLong count = new AtomicLong(0);
public long getCount(){ return count.get(); }
public void service(ServletRequest req, ServletResponse resp){
doSomething(req);
count.incrementAndGet(); // 自增长
encodeIntoResponse(resp);
}
}
三、 加锁机制
01 . 内置锁
Java提供了一种内置的锁机制来支持原子性: 同步代码块。 同步代码块包含俩部分: 一个是作为锁的对象引用,一个作为由这个锁保护的代码块。
synchronized( lock ) {
//访问或修改由锁保护的共享状态
}
- 每个Java对象都可以用做一个实现同步的锁,这些同步锁称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。
- Java的内置锁相当于一种互斥体(或互斥锁),因此最多只有一个线程能持有这种锁。
- 这种同步机制非常简单,但是会极大的影响性能。
02 . 重入 : 当一个线程试图获取一个已经由它自己持有的锁,那么这个请求会成功。重入主要是为了避免这种死锁的状况发生。
示例:
public class Widget{
public synchronized void doSomething{
// .......
}
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething" );
super.doSomething(); //这里会产生重入
}
}
四、 用锁来保护对象
定义: 对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。
01 . 如果复合操作的执行过程中持有一个锁,那么会使复合操作变成原子操作。然而,仅仅将复合操作封装到一个同步代码快中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。
02 . 每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。
03 . 对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
04 . 每一个方法都作为同步方法会导致活跃性问题或性能问题。
五、活跃性与性能
01 . 不良并发应用程序: 可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。
02 . 持有锁的时间过长,会代码活跃性或性能问题。当执行时间较长的计算或者可能无法快速完成的操作时,一定不用持有锁。
第三章 : 对象的共享
同步还有另一个重要的方面: 内存可见性(Memory Visibility),我们希望一个线程修改对象状态后,其他线程能够看到发生的状态变化。如果没有同步,那么这种情况就无法实现。你可以通过显示的同步或者类库中内置的同步来保证对象被安全的发布。
一、 可见性
01 . 为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
02 . 失效数据:在缺乏同步的程序中可能产生错误结果的一种情况。
03 . 非原子的64位操作: 非volatile类型的64位数值变量(double 和 long)不是线程安全的。
04 . 内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。
05 . 加锁的含义: 互斥行为 、 内存可见性。
06 . Volatile变量 :Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行是都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile遍历不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
07 . volatile变量的正确使用方式: 确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生。
08 . 加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。
09 . 当且仅当满足以下所有条件时,才应该使用volatile变量。
- 对变量的写入操作不依赖变量的当且值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其他状态变量一起纳入不变性条件中。
- 在访问变量时不需要加锁。
二、 发布与逸出
01 . 使用封装的主要原因: 封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。
三、 线程封闭
01 . 仅在单线程内访问数据,就不需要同步。这种技术称为: 线程封闭(Thread Confinement).
有一些三种方式实现线程封闭:
- Ad-hoc 线程封闭:维护线程封闭性的职责完全由程序实现来承担。(Ad-hoc程序封闭非常脆弱)。
- 栈封闭: 在栈封闭中只有局部变量才能访问对象。局部变量的固有属性之一就是封闭在执行线程中。
- ThreadLocal类 : 维持线程封闭性的一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。 ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。
四、 不变性
01 . 不可变对象一定是线程安全的。
满足一些条件时,对象才是不可变的:
- 对象创建以后其状态就不能修改。
- 对象的所有域都是final类型。
- 对象是正确创建的(在对象创建期间,this引用没有逸出)。
五、 安全发布
01 . 为了维持这种初始化安全性的保证,必须满足不可变性的所有需求(上面的三个条件)。
02 . 安全发布的常用模式:
要安全的发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全发布:
- 在静态初始化函数中初始化一个对象的引用。
- 将对象的引用保存到volatile类型的域或者AtomicReferance对象中。
- 将对象的引用保存到某个正确构造对象的final类型域中。
- 将对象的引用保存到一个由锁保护的域中。
03 . 事实不可变对象 : 如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象(Effectively Immutable Object)”。
04 . 在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。
05 . 要安全的共享可变对象,这些对象就必须被安全发布,并且必须是线程安全的或者由某个锁保护起来。
06 . 对象的发布需求取决于它的可变性:
- 不可变对象可以通过任意机制来发布
- 事实不可变对象必须通过安全方式来发布
- 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。
07 . 在并发程序中使用和共享对象时,可以使用一些实用的策略:
- 线程封闭 : 线程封闭对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
- 只读共享 : 在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
- 线程安全共享 : 线程安全的对象在其内部实现同步,因此多个线程可以通过对象的共有接口来进行访问而不需要进一步同步。
- 保护对象 : 被保护对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。
第四章 : 对象的组合
一 、 设计线程安全类
01 . 在设计线程安全类的过程中,需要包含以下三个基本要素:
- 找出构成对象状态的所有变量
- 找出约束状态变量的不变性条件
- 建立对象状态的并发访问管理策略
二、 实例封闭
01 . 通过将封闭机制与合适的加锁策略结合起来,可以确保以线程安全的方式来使用非线程安全对象 。
02 . 将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
03 . 一些基本容器都是非线程安全的: HashSet,ArrayList , HashMap。
04 . Java监视器模式: 是一种编写代码的约定,对于任何一种锁对象,只要自始至终都使用该锁对象,都可以用来保护对象的状态。
四 、 在现有的线程安全类中添加功能
01 . 客户端加锁机制:
示例:
非线程安全的“若没有则添加” —- 因为无法确保在调用putIfAbsent方法时,其他线程会不会修改 list集合。
public class ListHelper<E> {
public List<E> list = Collections.synchronizedList(new ArrayList<E>());
...
public synchronized boolean putIfAbsent(E x) {
boolean absent = !list.contains(x);
if (absent)
list.add(x);
return absent;
}
}
通过客户端加锁来实现“若没有则添加”
public class ListHelper<E> {
public List<E> list = Collections.synchronizedList(new ArrayList<E>());
...
public boolean putIfAbsent(E x) {
synchronized (list) {
boolean absent = !list.contains(x);
if (absent)
list.add(x);
return absent;
}
}
}
02 . 组合 ; 客户端加锁机制非常脆弱,有一种更好的实现方式:组合。
通过组合实现“若没有则添加”
public class ImprovedList<T> implements List<T> {
private final List<T> list;
public ImprovedList(List<T> list) { this.list = list };
public synchronized boolean putIfAbsent(T x) {
boolean contains = list.contains(x);
if (contains)
list.add(x);
return !contains;
}
public synchronized void clear() { list.clear() } ;
//... 按照类似的方式委托List的其他方法
}
03 . 使用同步话文档:
- 在设计阶段是编写设计决策文档的最佳时间。
- 我们应该保证将类中的线程安全性文档化。
第五章 : 基础构建模块
一、 同步容器类
01 . 委托是创建线程安全类的一个最有效的策略 : 只需让现有的线程安全类管理所有的状态即可。
02 . 同步容器类实现线程安全的方式是: 将他们的状态封装起来,并对每个共有方法都进行同步,使得每次只有一个线程能访问容器状态。
03 . 同步容器类都是线程安全的,但在某些情况下可能需要额外的客户端加锁来保护复合操作。容器常见的复合操作包括: 迭代、跳转、条件运算(如: 若没有则添加)。
示例: Vector上可能导致混乱结构的复合操作。当俩个线程同时操作同一个Vector对象的时候就会产生线程安全问题,如: 线程A执行getLast()时,线程B执行deleteLast(),那么get(index)方法就可能会报错。
public static Object getLast(Vector list) {
int lastIndex = list.size() -1 ;
return list.get(lastIndex);
}
public static void deleteLast(Vector list) {
int lastIndex = list.size() - 1 ;
list.remove(lastIndex);
}
使用客户端加锁的Vector上的复合操作
public static Object getLast(Vector list) {
synchronized(list) {
int lastIndex = list.size() -1 ;
return list.get(lastIndex);
}
}
public static void deleteLast(Vector list) {
synchronized(list) {
int lastIndex = list.size() - 1 ;
list.remove(lastIndex);
}
}
可能会抛出ArrayIndexOutOfBoundsException的迭代操作
for (int i = 0 ; i< vector.size() ; i++ ) {
doSomething(vector.get(i));
}
带有客户端加锁的迭代
synchronized (vector) {
for (int i = 0; i < vector.size(); i++ ){
doSomething(vector.get(i));
}
}
一般开发人员并不希望在迭代期间对容器加锁,如果有许多线程都在等待锁释放,那么将极大的降低吞吐量和CPU的利用率。如果不希望在迭代期间对容器加锁,那么一种代替方式就是“克隆”容器,并在副本上进行迭代。由于副本被封闭在线程内,因此其他线程不会在迭代期间对其进行修改。(克隆过程仍然需要加锁)
二、并发容器
01 . Java 5.0 提供了多种并发容器类来改进同步容器的性能。同步容器将所有对容器状态的访问都串行化,以实现它们的线程安全性。这种方法的代价是严重降低并发性,当多个线程竞争容器锁的时候,吞吐率将严重降低。
02 . 在Java 5.0 中增加了ConcurrentHushMap,用来替代同步且基于散列的Map,以及CopyOnWriteArrayList,用于在遍历操作为主要操作情况下代替同步的List。
03 . 在Java 5.0 中增加了俩种新的容器类型 : Queue 和 BlockingQueue。
- Queue用来临时保存一组等待处理的元素。Queue上的操作不会阻塞,若队列为空,那么获取元素的操作将返回空值。
- BlockingQueue扩展了Queue,增加了可阻塞的插入和获取操作。如果队列为空,那么获取元素的操作将一直阻塞,直到队列出现一个可用的元素。
04 . ConcurrentHushMap使用了粒度更细的加锁机制: 分段锁。
05 . CopyOnWriteArrayList提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制。
06 . “写入时复制(copy - on - write)”容器的线性安全性在于,只要正确发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步同步。
三、阻塞队列和生产者 - 消费者模式
01 . 阻塞队列:当队列为空,take方法将阻塞到容器有数据为止。当容器满了,则put方法将阻塞到容器可用。
02 . 生产者 - 消费者模式: 将生成数据的过程和消费数据的过程解耦,以简化工作负载的管理,因为俩个过程在处理数据的速率上有所不同。
03 . Java 6 增加了俩种容器类型, Deque和BlockingDeque,他们分别对 Queue 和 BlockingQueue进行了扩展。 Deque是一个双端队列,实现了在队列头和队列尾的高效插入和移除。
五、 同步工具
01 . 同步工具类可以是任何一个对象,只有它根据其自身的状态来协调线程的控制流。常见的同步工具类有: 阻塞队列、信号量(Semephore)、栅栏(Barrier)、闭锁(Latch)。
- 闭锁是一种同步工具,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门 : 在闭锁到达结束状态之前,这扇门一直是关闭的,没有任何线程可以通过,当到达结束状态时,这扇门会打开并允许所有线程通过。打开后这扇门将会一直打开。
- 信号量 : 用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。
- 栅栏: 类似于闭锁,它能阻塞一组线程直到某个事件发生。栅栏和闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。当闭锁打开时,就会一直打开。而当栅栏打开,所有线程都被释放,而栅栏会被重置以便下次使用。
第一部分小结:
01 . 可变状态是至关重要的。
所有的并发问题都可以归结为如何协调对并发状态的访问。可变状态越少,就越容易确保线程安全性。
02 . 尽量将域声明为final类型,除非需要他们是可变的。
03 . 不可变对象一定是线程安全的。
04 . 封装有助于管理复杂性。
05 . 用锁来保护每个可变变量。
06 . 当保护同一个不变性条件中的所有变量时,要使用同一个锁。
07 . 在执行复合操作期间,要持有锁。
08 . 在设计过程中考虑线程安全,或者在文档中明确的指出它不是线程安全的。
09 . 将同步策略文档化。