java并发编程实战--第一部分 基础知识
二、线程安全性
核心在于要对状态访问进行管理,特别是共享、可变的。
共享意味着变量可以由多个线程进行访问,可变意味着变量的值发生变化。
java同步机制是关键词synchronized,他提供一种加锁方式,但同步术语还得volatile。
修复线程安全的方式:
- 不在线程之间共享该状态变量
- 将状态变量修改为不可变的变量
- 在访问状态变量时使用同步
2.1线程安全性
当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。
大多数Servlet对象时无状态的
无状态对象一定是线程安金的。
只有当Servlet在处理请求时需要保存些信息,线程安全性才会成为一个问题。由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状大多数Servlet都是无状态的,从而极大地降低了在实现Servlet线程安全性时的复杂性。
2.2 原子性
当在无状态对象中增加一个状态,如命中计算器,来统计所处理的请求数量。当对一个值,进行修改时,操作并不是原子性的,包括“读取-修改-写入”。在并发编程中执行时序不正确的结果是一种非常重要的情况,他有一个正式的名字:竞态条件。
2.2.1 竞态条件
正确的结果取决于运气。最常见的就是“先检查后执行,通过一个可能失效的观测结果决定下一步的动作。
2.2.2 实例:延迟初始化化的竞态条件
使用“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次。
单例模式中没有加入双重检查的getInstance
2.2.3 复合操作
要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。
例如修改变量的操作是原子操作,为了保证线程的安全性,’先检查后执行’和‘读取修改写入’操作必须是原子性,这些操作我们称之为复合操作,包含了一组以原子操作来确保线程的安全性。
在java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过用AtomicLong来代替long 类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。°由于Servlet的状态就是计数器的状态,并且计数器是线程安全的,因此这里的Servlet也是线程安全的。
我们在因数分解的Servlet中增加了一个计数器,并通过使用线程安全类AtomicLong来管理计数器的状态,从而确保了代码的线程安全性。当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。然而,在2.3节你将看到,当状态变量的数量由一个变为多个时,并不会像状态变量数量由零个变为一个那样简单。
2.3 加速机制
当在Servlet中添加一个状态变量时,可以通过线程安全的对象来管理Servlet的状态以维护Servlet的线程安全性。但如果想在Servlet中添加更多的状态,那么是否只需添加更多的线程安全状态变量就足够了?
当线程A获取这个值时,线程B可能修改了他们,这样线程A也就不会发现不变性条件被破坏了。
要保证状态的一致性, 就需要在单个原子操作中更新所有相关的状态变量。
2.3.1内置锁
java提供一种内置的机制保证原子性,同步代码块。
同步代码块包括2个部分:
- 一个作为锁的对象引用
- 一个作为由这锁保护的代码块
以关键词synchronized修饰的方法计算横跨整个方法体的同步代码块,其中该代码块的锁计算方法调用所在的对象。
每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
Java的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁,当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A也将永远地等下去。
这种方法很安全,但是性能非常低,无法令人接受。
2.3.2 重入
·当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”。
重入的一种实现方式是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为О时,这个锁将被释放。
重入进一步提升了加锁行为的封装性。
当子类改写了父类的synchronized的方法,然后调用父类的方法,如果没有可重入锁,那么这段代码就会产生死锁
2.4 用锁来保护状态
由于锁能使其保护的代码路径以串行形式来来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。
访问共享状态的复合操作,例如命中计数器的递增操作(读取–修改-写人)或者延迟初始化(先检查后执行),都必须是原子操作以避免产生竟态条件。如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同-一个锁。
一种常见的错误是认为,只有在写入共享变量时才需要使用同步,然而事实并非如此(3.1)节将进一步解释其中的原因)。
对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用做一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。你需要自行构造加锁协议或者同步策略来实现对共享状态的安全访问,并且在程序中自始至终地使用它们。
一种常见的加锁约定是,将所有的可变状态都封装在对象内部,通过内置锁进行同步,例如Vector当包含多个变量的不变性条件是,其中所有的变量都需要由同一把锁来保护。
2.5 活跃性和性能
当使用锁时,你应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题。
三、对象的共享
第2章的开头曾指出,要编写正确的并发程序,关键问题在于:在访问共享的可变状态时需要进行正确的管理。
3.1 可见性
通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用司步机制。
1 | public class NoVisibility { |
NoVisibility可能会持续循环下去,因为读线程可能永远都看不到ready的值。一种更奇怪的现象是,NoVisibility可能会输出0,因为读线程可能看到了写入ready的值,但却没有看到之后写人number的值,这种现象被称为“重排序(Reordering)”。只要在某个线程中无法检测到重排序情况(即使在其他线程中可以很明显地看到该线程中的重排序),那么就无法确保线程中的操作将按照程序中指定的顺序来执行。当主线程首先写人number,然后在没有同步的情况下写人ready,那么读线程看到的顺序可能与写人的顺序完全相反。
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。
3.1.1 失效数据
NoVisibility展示了在缺乏同步的程序中可能产生错误结果的–种情况﹔失效数据。当读线程查看ready变量时,可能会得到一个已经失效的值。除非在每次访问变量时都使用同步,否则很可能获得该变量的一个失效值。更糟糕的是,失效值可能不会同时出现:一个线程可能获得某个变量的最新值,而获得另一个变量的失效值。
如果某个线程调用了set,那么一个正在调用get的线程可能会看到更新后的value值,也可能看不到。.
1 |
|
3.1.2 非原子的64位操作
当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性。
最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型的64位数值变量(double和long,请参见3.1.4节)。Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。当读取-个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字·volatile来声明它们,或者用锁保护起来。
3.1.3 加锁与可见性
内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的结果。
当线程A执行某个同步代码块时,线程B随后进人由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放之前,A看到的变量值在B获得锁后同样可以由B看到。换句话说,当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作结果。如果没有同步,那么就无法实现上述保证。

3.1.4 Volatile
volatile变量,用来确保将变量的更新操作通其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
理解volatile变量的一种有效方法是,将它们的行为想象成程序清单3-3中SynchronizedInteger类似行为,并将volaLile变量的读操作和写操作分别替换为get方法和set方法。然而,在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile变量是一svchronized关键空更轻量级的同步机制。
因此,从内存可见性的角度来看,写入volatile变量相当于退出同步代码块,而读取volatile变量就相当于进入同步代码块。
虽然volatile变量很方便,但也存在一些局限性。volatile变量通常用做某个操作完成、发生中断或者状态的标志。尽管volatile变量也可以用于表示其他的状态信息,但在使用时要非常小心。例如,volatile的语义不足以确保递增操作(count++)的原子性,除非你能确保只有一个线程对变量执行写操作。
加锁机制既可以确保可见性又可以确保原子性,volatile只能保证可见性。
当且仅当满足以下所有条件时,才应该使用volatile变量:
- 对变量的写人操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其他状态变量一起纳人不变性条件中。
- 在访问变量时不需要加锁。
3.2 发布和溢出
“发布(Publish)”一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。多数情况要确保对象及其内部状态不能被发布。
当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape)。
隐式的使this引用溢出
1 | public class ThisEscape { |
当内部的EventListener实例发布时,在外部封装的ThisEscape实例也逸出了。当且仅当对象的构造函数返回时,对象才处于可预测的和一致的状态。因此,当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。即使发布对象的语句位于构造函数的最后一行也是如此。如果this引用在构造过程中逸出,那么这种对象就被认为是不正确构造°。
在构造过程中使this 引用逸出的一个常见错误是,在构造函数中启动一个线程。当对象在其构造函数中创建一个线程时,无论是显式创建(通过将它传给构造函数)还是隐式创建(由于Thread或Runnable是该对象的一个内部类),this 引用都会被新创建的线程共享。在对象尚未完全构造之前,新的线程就可以看见它。在构造函数中创建线程并没有错误,但最好不要立即启动它,而是通过一个start或initialize方法来启动
如果在构造函数中注册一个事件监听机制或启动线程,可以使用一个私有的构造方法和一个公共的工厂方法。
1 | public class safeListener { |
3.3 线程封闭
当访问共享的可变数据时,一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这这种技术被称为线程封闭。
线程封闭技术的另一个常见应用是JDBC的Connection对象。JDBC的规范不要求Connection对象是线程安全的,大多数请求都是由单个线程采用同步的方式来处理,并在Connection对象返回之前,连接池都不会再将它分配给其他线程
Ad-hoc线程封闭
维护线程封闭性的职责完全由程序实现来承担。少用
栈封闭
线程封闭的特例,只能通过局部变量才能访问对象。
ThreadLocal类
维护线程封闭性的规范的方法是使用ThreadLocal,他提供了get和set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,get总是返回由当前执行线程在调用set时设置的新值。
ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个Connection对象。由于JDBC的连接对象不一定是线程安全的,因此,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。
通过将JDBC连接保存到Thread中,每个线程都会拥有属于自己的连接。
1
2
3
4
5
6
7
8
9private static ThreadLocal<Connection> connectionHolder
=new ThreadLocal<connection> (){
public Connection initialvalue ( ) {
return DriverManager.getConnection (DB_URL);
}
};
public static connection getconnection ( ) {
return connectionHolder.get ( ) ;
}当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用这项技术。例如,在Java 5.0之前,Integer.toString()方法使用ThreadLocal对象来保存一个12字节大小的缓冲区,用于对结果进行格式化,而不是使用共享的静态缓冲区(这需要使用锁机制)或者在每次调用时都分配一个新的缓冲区。
当某个线程初次调用ThreadLocal.get方法时,就会调用initialValue来获取初始值。从概念上看,你可以将ThreadLocal视为包含了Map< Thread,T>对象 ,其中保存了特定于该线程的值,但ThreadLocal 的实现并非如此。这些特定于线程的值保存在Thread对象中,当线程终止后,这些值会作为垃圾回收。假设你需要将一个单线程应用程序移植到多线程环境中,通过将共享的全局变量转换为ThreadLocal对象(如果全局变量的语义允许),可以维持线程安全性。然而,如果将应用程序范围内的缓存转换为线程局部的缓存,就不会有太大作用。
3.4 不变性
满足同步需求的另一种方法是使用不可变对象。不可变对象只有一种状态并且这个状态由构造函数控制。
不可变性并不等于将对象中所有的域都声明为final类型,即使对象中所有的域都是final类型的,这个对象也仍然是可变的,因为在final类型的域中可以保存对可变对象的引用。
1 | 当满足以下条件时,对象才是不可变的: |
不可变对象内部可以使用可变对象来管理他们的状态。
1 |
|
stooges是一个final类型的引用变量。
Final域
final类型的域是不能修改的,final域能确保初始化过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无需同步。
使用Volatile类型发布不可变对象
分解Servlet的2个原子操作,更新缓存的结果,通过判断缓存招聘那个数值来决定是否直接读取缓存中的分解结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class OnevalueCache {
private final BigInteger lastNumber ;
private final BigInteger [] lastFactors;
public Onevaluecache (BigInteger i,
BigInteger [] factors) {
lastNumber = i;
lastFactors = Arrays.copyof (factors,factors.length) ;
}
public BigInteger [] getFactors (BigInteger i){
if(lastNumber == null || !lastNumber.equals(i))
return null ;
else
return Arrays.copyof (lastFactors,lastFactors.length) ;
}
}
public class volatilecachedFactorizer implements Servlet {
private volati1e OnevalueCache cache =new Onevaluecache (null,null) ;
public void service(ServletRequest req,ServletResponse resp){
Big Integer i = extractFromRequest (req) ;
BigIntegeri] factors = cache . getFactors (i) ;
if (factors == null){
factors = factor (i) ;
cache = new Onevaluecache (i, factors) ;
}
encodeintoResponse (resp, factors) ;
}
}上面代码使用了OneValueCache来保存缓存的数值及其因数,当一个线程将volatile类型的cache设置为引用一个新的OneValueCache时,其他线程就会立即看到新缓存的数据。
3.5 安全发布
在某些情况下我们希望在多个线程间共享对象,必须确保安全的进行共享。
不正确的发布:正确的对象被破坏
不能指望一个没用被完全创建的对象拥有完整性。
1 | public class Holder { |
没有使用同步来确保Holder对象其他线程可见,有2个问题,除了发布对象线程外,其他线程可以看到Holder是一个失效值。线程看到Holder是最新的,但Holder状态的值是失效的
不可变对象与初始化安全性
某个想的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程一定是可见的
即使发布不可变对象的引用没有使用同步,也仍然可以安全地访问对象。为了满足初始化安全性必须满足不可变性的条件:状态不可修改,所有域都是final,以及正确的构造过程。
安全发布的采用模式
可变对象必须使用同步。
1 | 要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布: |
线程安全同步容器内部的同步意味着,将对象放入某个容器。例如Vector或synchronizedList满足最后一条。
1 | 通过将一个键或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。 |
通常,要发布一个静态构造的对象,最简单使用静态初始化器:
1 | public static Holder holder = new Holder(42); |
静态初始化由JVM在类初始化阶段执行。JVM内部存在同步机制,通过这种方法初始化的任何对象都可以安全发布。
事实不可变对象
如果想从技术上看是可变的,但状态在发布后不会改变,那么把这种对象称为事实不可变对象。
例如,Date本身是可变的·,但如果将它作为不可变对象来使用,那么在多个线程之间共享Date对象时,就可以省去对锁的使用。假设需要维护一个Map对象,其中保存了每位用户的最近登录时间:public Map<string, Date> iastLogin =
collections.synchronizedMap(new HashMap<string,Date>()) ;
如果Date对象的值在被放入Map后就不会改变,那么synchronizedMap中的同步机制就足以使 Date值被安全地发布,并且在访问这些Date值时不需要额外的同步。
可变对象
对象的发布需求取决于它的可变性:
- 不可变对象可以通过任意机制来发布。
- 事实不可变对象必须通过安全方式来发布,
- 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。
安全的共享对象
在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
- 线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
- 只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
- 线程安全共享,线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访闻而不需要进一步的同步。
- 保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他.线程安全对象中的对象,以及已发布的并且由某个特定销保护的对象。
四、对象的组合
4.1 设计线程安全的类
在设计线程安全类的过程中,需要包含以下三个基本要素:
- 找出构成对象状态的所有变量。
- 找出约束状态变量的不变性条件。
- 建立对象状态的并发访问管理策略。
要分析对象的状态,首先从对象的域开始。如果对象中所有的域都是基本类型的变量,那么这些域将构成对象的全部状态。
对于含有n个基本类型域的对象,其状态就是这些域构成的n元组。例如,二维点的状态就是它的坐标值(x,y)。如果在对象的域中引用了其他对象,那么该对象的状态将包含被引用对象的域。例如,LinkedList的状态就包括该链表中所有节点对象的状态。
收集同步需求
依赖状态的操作
某些对象的方法中包括一些基于状态的先验条件。
状态的所有权
4.2 实例封闭
将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
1 |
|
PersonSet 的状态由HashSet来管理的,而HashSet并非线程安全的。但由于mySet是私有的并且不会逸出,因此HashSet被封闭在PersonSet中。唯一能访问mySet的代码路径是addPerson与containsPerson,在执行它们时都要获得PersonSet上的锁。PersonSet的状态完全由它的内置锁保护,因而PersonSet是一个线程安全的类。
这个示例并未对Person的线程安全性做任何假设,但如果Person类是可变的,那么在访问从PersonSet中获得的Person对象时,还需要额外的同步。要想安全地使用Person对象,最可靠的方法就是使Person成为一个线程安全的类。另外,也可以使用锁来保护Person对象,并确保所有客户代码在访问Person对象之前都已经获得正确的锁。
在Java平台的类库中还有很多线程封闭的示例,其中有些类的唯一用途就是将非线程安全的类转化为线程安全的类。一些基本的容器类并非线程安全的,例如ArrayList和HashMap,但类库提供了包装器工厂方法(例如Collections.synehronizedList 及其类似方法),使得这些非线程安全的类可以在多线程环境中安全地使用。这些工厂方法通过“装饰器(Decorator)”模式(Gamma et al.,1995)将容器类封装在一个同步的包装器对象中,而包装器能将接口中的每个方法都实现为同步方法,并将调用请求转发到底层的容器对象上。只要包装器对象拥有对底层容器对象的唯一引用(即把底层容器对象封闭在包装器中),那么它就是线程安全的。
Java监视器模式
遵循Java监视器模式的对象会把对象的所有可变状态封装起来,并且右自己内置锁保护
1 | public class PrivateLock { |
使用私有的锁对象而不是对象的内置锁,有许多优点。私有的锁对象可以将锁封装起来,使客户代码无法得到锁,但客户代码可以通过公有方法来访问锁,以便(正确或者不正确地)参与到它的同步策略中。如果客户代码错误地获得了另一个对象的锁,那么可能会产生活跃性问题。此外,要想验证某个公有访问的锁在程序中是否被正确地使用,则需要检查整个程序,而不是单个的类。
车辆追踪
一个用于调度车辆的“车辆追踪器”,例如出租车、警车、货车等。首先使用监视器模式来构建车辆追踪器,然后再尝试放宽某些封装性需求同时又保持线程安全性。
每台车都由一个String对象来标识,并且拥有一个相应的位置坐标(x,y)。在VehicleTracke类中封装了车辆的标识和位置,因而它非常适合作为基于MVC (Model-View-Controller,模型–视图一控制器)模式的GUI应用程序中的数据模型,并且该模型将由一个视图线程和多个执行更新的线程共享。视图会读取每辆车辆的名字和位置,并将它们显示在界面上。
1 | Map<string,Point> locations e vehicles.getLocations() ; |
修改车辆位置
1 | void vehicleMoved (vehicleMovedEvent evt) { |
1 | Threadsafe |
1 |
|
由于每次调用getLocation就要复制数据,因此将出现一种错误情况–—虽然车辆的实际位置发生了变化,但返回的信息却保持不变。这种情况是好还是坏,要取决于你的需求。如果在location集合上存在内部的一致性需求,那么这就是优点,在这种情况下返回一致的快照就非常重要。然而,如果调用者需要每辆车的最新信息,那么这就是缺点,因为这需要非常频繁地刷新快照。
4.3 线程安全性的委托
基于委托的车辆追踪器
我们将车辆位置保存到一个Map对象中,因此首先设计线程安全的Map类型,ConcurrentHashMap,用一个不可变的Point类,是一个线程安全的
1 |
|
使用concurrentHashMap管理
1 |
|
这里是实时的车辆快照。
如果需要一个不发生变化的车辆视图,可以返回一个浅拷贝。犹豫Map内容不可变,只需要复制Map的结构,不复制内容。不能保证返回一个线程安全的Map
1 | public Map<String, Point> getLocations () { |
独立的状态向量
我们还可以将线程安全性委托给多个状态向量
以下代码是一个图形组件,监控鼠标和键盘的事件,它为每种事件都有一个已注册的监听器列表,键盘和鼠标的监听器直接不存在关联,二者是彼此独立的,可以将线程安全性委托给两个线程安全的监听器列表。
1 | public class VisualComponent { |
使用CopyonwriteArrayList保存各个监听器列表,是一个线程安全的列表。
当委托失效时
大多数组合不会这么简单,他们的状态变量直接存在着某些不变性条件。
1 | public class NumberRange { |
这个代码使用2个AtomicInteger来管理状态,并且有个约束条件,第一个数值要小于等于第二个数值。这个代码不是线程安全的,没有位置对下界和上界进行约束的不变性条件。先检查后执行,没有使用加锁机制来保证操作的原子性。一个线程setLower(5),一个线程setUpper(4),都是先检查,那么取值范围就是(5,4)。虽然AtomicInteger是线程安全的,但组合得到的类不是。
发布底层的状态变量
什么时候才可以发布这些变量,使其他类修改他们?
取决于类对这些变量施加了哪些不变性条件。
发布状态的车辆追踪器
可变且线程安全的Point类,x和y必须同时操作。
1 |
|
1 |
|
将线程安全性委托给ConcurrentHashMap,Map中元素是线程安全且可变的Point.getLocation方法返回的是一个不可变副本,调用者不能增加或删除车辆,只能修改返回map中的值来改变车辆位置。
4.4 在现有线程安全类中添加功能
扩展一个若没有则添加
1 |
|
客户端加锁机制
对于由Collections.synchronizedList封装的ArrayList,这两种方法在原始类中添加一个方法或者对类进行扩展都行不通,因为客户代码并不知道在同步封装器工厂方法中返回的List对象的类型。第三种策略是扩展类的功能,但并不是扩展类本身,而是将扩展代码放入一个“辅助类”中。
1 |
|
不能实现线程安全性,这里虽然所有的操作都被声明为synchronized,但是使用了不同的锁,无法确保putIfAbsent执行时另一个线程不会修改链表。list是发布出去的
必须使客户端内部和外部加锁使用同一个锁
1 |
|
必须保证加锁的对象不会改变。对list加锁,其他地方就会不能修改list
组合
对一个现有的类添加一个原子操作时使用组合
1 |
|
五、基础模块构建
5.1 同步容器类
同步容器类实现线程安全的方式是:将它们的状态封装起来,并对每个共有方法都进行同步,使得每次只有一个线程能访问容器的状态。
同步容器类的问题
当复合操作时例如,迭代、跳转、条件运算(若没有则添加)。这些操作在没有客户端加锁的情况下是线程安全的,但当其他线程并发的修改容器,会出问题。
1 | public static Object getLast (vector list) { |
问题A线程调用getLast,B线程调用deleteLast,会出现问题。在调用size与调用getLast操作直接,Vector变小了,使的到的索引不在有效。
使用客户端加锁就可以解决,通过同步容器类自身的锁来保护每个方法,使操作变成原子操作。synchronized(list)
在size和get之间Vector的长度会发生改变
1 | synchronized(vector){ |
迭代器与ConcurentModificationException
许多现代的容器都没有消除复合操作的问题。无论是之间迭代还是foreach,使用的都是Iterator,如果有线程并发的修改容器,那么使用迭代器也无法问题。及时失败(fail-fast)。
这意味着,当它们发现容器在迭代过程中被修改时,就会抛出一个ConcurrentModificationException异常。
这种“及时失败”的迭代器并不是一种完备的处理机制,而只是“善意地”捕获并发错误,因此只能作为并发问题的预警指示器。它们采用的实现方式是,将计数器的变化与容器关联起来:如果在迭代期间计数器被修改,那么hasNext或next将抛出ConcurrentModificationException。然而,这种检查是在没有同步的情况下进行的,因此可能会看到失效的计数值,而迭代器可能并没有意识到已经发生了修改。
隐藏迭代器
虽然加锁可以防止迭代器抛出ConcurrentModificationException,但你必须要记住在所有对共享容器进行迭代的地方都需要加锁。实际情况要更加复杂,因为在某些情况下,迭代器会隐藏起来,在HiddenIterator中没有显式的迭代操作,代码中将执行迭代操作。编译器将字符串的连接操作转换为调用StringBuildet.append(Object),而这个方法又会调用容器的 toString方法,标准容器的 toString方法将迭代容器,并在每个元素上调用toString来生成容器内容的格式化表示。
1 | public class HiddenIterator { |
容器的hashCode和equals等方法也会间接地执行迭代操作,当容器作为另一个容器的元素或键值时,就会出现这种情况。同样,containsAll、renoveAll和retainAll等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些间接的迭代操作都可能抛出ConcurrentModificationException。
5.2 并发容器
同步容器将对容器状态的访问串行化,实现线程安全性,代价是严重降低并发性,吞吐量降低。
新的ConcurrentMap接口中添加了一家常见的复合操作,例如“若没有则添加”,替换以及有条件删除。
2个新的容器类型:Queue和BlockingQueue。
Queue用来临时保存一组等待处理队列。。它提供了几种实现,包括:ConcurrentLinkedQueue,这是一个传统的先进先出队列,以及 PriorityQueue,这是一个(非并发的)优先队列。Queue 上的操作不会阻塞,如果队列为空,那么获取元素的操作将返回空值。虽然可以用List来模拟Queue的行为——事实上,正是通过LinkedList来实现Queue的,但还需要一个Queue的类,因为它能去掉List 的随机访问需求,从而实现更高效的并发。
BlockingQueue扩展了Queue,增加了可阻塞的插入和获取等操作。如果队列为空,那么获取元素的操作将一直阻塞,直到队列中出现–个可用的元素。如果队列已满(对于有界队列来说),那么插入元素的操作将一直阻塞,直到队列中出现可用的空间。在“生产者–消费者”这种设计模式中,阻塞队列是非常有用的。
ConcurrentHashMap
基于散列的Map,任意数量的线程可以并发的访问Map,在并发环境下实现更高的吞吐量,在单线程只损失非常小的性能。
有一下需要权衡的因素,size和isEmpty,由于size返回的结果计算时可能已经过期了,实际上是一个估计值,返回一个近似值而不是精确值。这些操作的需求被弱化了,以换取对其他更重要的操作性能优化,get、put、containsKey、remove.
与Hashtable和 synchronizedMap相比,ConicurrentHashMap有着更多的优势以及更少的劣势,因此在大多数情况下,用ConcurrentHashMap来代替同步Map能进一-步提高代码的可伸缩性。只有当应用程序需要加锁Map以进行独占访问时,才应该放弃使用ConcurrentHashMap。
额外的原子操作
由于ConcurrentHashMap不能被加锁来执行独占访问,因此我们无法使用客户端加锁来创建新的原子操作。但是,一些常见的复合操作,例如“若没有则添加”、“若相等则移除(Remove-If-Equal)”和“若相等则替换(Replace-If-Equal)”等,都已经实现为原子操作并且在ConcurrentMap的接口中声明,如程序清单5-7所示。如果你需要在现有的同步Map中添加这样的功能,那么很可能就意味着应该考虑使用ConcurrentMap 了。
1 | public interface ConcurrentMap<K,V> extends Map<K,V> { |
CopyOnWriteArrayList
代替同步List,写入时复制,只要正确的发布一个事实不可变对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。“写入时复制”容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需确保数组内容的可见性。因此,多个线程可以同时对这个容器进行迭代,而不会彼此干扰或者与修改容器的线程相互干扰。“写入时复制”容器返回的迭代器不会抛出ConcurrentModificationException,’并且返回的元素与迭代器创建时的元素完全一致,而不必考虑之后修改操作所带来的影响。
显然,每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时。仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。这个准则很好地描述了许多事件通知系统:在分发通知时需要迭代已注册监听器链表,并调用每一个监听器,在大多数情况下,注册和注销事件监听器的操作远少于接收事件通知的操作。
5.3 阻塞队列和生产者消费者模式
阻塞队列提供put和take方法,已以及支持定时的offer和poll方法。如果队列已经满了,put方法将阻塞直到有空间可用;如果队列为空,take方法会阻塞直到有元素可用。
阻塞队列支持生产者–消费者这种设计模式。该模式将“找出需要完成的工作”与“执行工作”这两个过程分离开来,并把工作项放入一个“待完成”列表中以便在随后处理,而不是找出后立即处理。生产者一消费者模式能简化开发过程,因为它消除了生产者类和消费者类之间的代码依赖性,此外,该模式还将生产数据的过程与使用数据的过程解耦开来以简化工作负载的管理,因为这两个过程在处理数据的速率上有所不同。
阻塞队列简化了消费者程序的编码。take会阻塞直到有可用的数据;out会阻塞直到有消防支队到来;当数据项不能被添加到队列中,会返回一个失败状态。
在类库中包含了BlockingQueue的多种实现,其中,LinkedBlockingQueue和 ArrayBlockingQueue是FIFO队列,二者分别与LinkedList和 ArrayList类似,但比同步List拥有更好的并发性能。PriorityBlockingQueue是一个按优先级排序的队列,当你希望按照某种顺序而不是FIFO来处理元素时,这个队列将非常有用。正如其他有序的容器一样,PriorityBlockingQueue既可以根据元素的自然顺序来比较元素(如果它们实现了Comparable方法),也可以使用Comparator来比较。
最后一个 BlockingQueue实现是SynchronousQueue,实际上它不是一个真正的队列,因为它不会为队列中元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入或移出队列。
示例:桌面搜索
代理程序,它将扫描本地驱动器上的文件并建立索引以便随后进行搜索。生产者任务FileCrawler:在某个文件层次结构中搜索复合索引标准的文件,并将它们放入工作队列。消费者任务:从队列中取出文件建立索引。
1 | public class FileCrawler implements Runnable { |
将文件遍历和建立索引分解为独立的操作,每个操作只完成一个任务,并且阻塞队伍将负责所有控制流。如果生产者和消费者并行度不同,那么他们紧密耦合在已经会把整体并行度降低为二者中更小行度
下面程序中启动了多个爬虫程序和索引建立程序,消费者线程永远不会退出,程序无法终止,生产者-消费者线程通过Executor任务执行框架来做。
1 | public static void startIndexing (File[] roots){ |
串行线程封闭
对于可变对象,生产者-消费者这种设计与阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付给消费者。线程封闭对象只能由单个线程拥有,但可以通过安全地发布该对象来“转移”所有权。在转移所有权后,也只有另一个线程能获得这个对象的访问权限,并且发布对象的线程不会再访问它。这种安全的发布确保了对象状态对于新的所有者来说是可见的,并且由于最初的所有者不会再访问它,因此对象将被封闭在新的线程中。
我们也可以使用其他发布机制来传递可变对象的所有权,但必须确保只有一个线程能接受被转移的对象。阻塞队列简化了这项工作。除此之外,还可以通过ConcurrentMap的原子方法remove或者AtomicReference的原子方法compareAndSet来完成这项工作。
双端队列与工作密取
Deque是一个双端队列,实现了在队列头和队列尾的高效插入和移除。具体实现包括 ArrayDeque和LinkedBlockingDeque。
正如阻塞队列适用于生产者–消费者模式,双端队列同样适用于另一种相关模式,即工作密取(Work Stealing)。在生产者一消费者设计中,所有消费者有一个共享的工作队列,而在工作密取设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作。密取工作模式比传统的生产者一消费者模式具有更高的可伸缩性,这是因为工作者线程不会在单个共享的任务队列上发生竞争。在大多数时候,它们都只是访问自己的双端队列,从而极大地减少了竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作,因此进一步降低了队列上的竞争程度。
工作密取非常适用于既是消费者也是生产者问题——当执行某个工作时可能导致出现更多的工作。例如,在网页爬虫程序中处理一个页面时,通常会发现有更多的页面需要处理。类似的还有许多搜索图的算法,例如在垃圾回收阶段对堆进行标记,都可以通过工作密取机制来实现高效并行。当一个工作线程找到新的任务单元时,它会将其放到自己队列的末尾(或者在工作共享设计模式中,放入其他工作者线程的队列中)。当双端队列为空时,它会在另一个线程的队列队尾查找新的任务,从而确保每个线程都保持忙碌状态。
5.4 阻塞方法与中断方法
被阻塞的线程必须等待不受它控制点事件发生后才能继续执行。
Thread提供了interrupt方法,用于中断线柱或者查询线程是否已经被中断。每个线程都有一个布尔类型的属性,表示线程的中断状态,当中断线程时将设置这个状态。
中断是一种协作机制。一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作。当线程A中断B时,A仅仅是要求B在执行到某个可以暂停的地方停止正在执行的操作——前提是如果线程B愿意停止下来。虽然在API或者语言规范中并没有为中断定义任何特定应用级别的语义,但最常使用中断的情况就是取消某个操作。方法对中断请求的响应度越高,就越容易及时取消那些执行时间很长的操作。
5.5 同步工具类
同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。阻塞队可以作为同步工具类,其他类型的同步工具类还包括信号量(Semaphore)、栅栏(Barrier)以及闭锁(Latch)。在平台类库中还包含其他一些同步工具类的类,如果这些类还无法满足需要,那么可以按照第14章中给出的机制来创建自己的同步工具类。.
所有的同步工具类都包含一些特定的结构化属性:它们封装了一些状态,这些状态将决定丸行同步工具类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另一些方法用千高效地等待同光工具.类讲入到预期状态。
闭锁
延迟线程的进度直到其到达终止状态。相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。
- 确保某个计算在其需要的所有资源都被初始化之后才继续执行。二元闭锁(包括两个状态)可以用来表示“资源R已经被初始化”,而所有需要R的操作都必须先在这个闭锁上等待。
- 确保某个服务在其依赖的所有其他服务都已经启动之后才启动。每个服务都有一个相关的二元闭锁。当启动服务S时,将首先在S依赖的其他服务的闭锁上等待,在所有依赖的服务都启动后会释放闭锁S,这样其他依赖S的服务才能继续执行。
- 等待直到某个操作的所有参与者(例如,在多玩家游戏中的所有玩家)都就绪再继续执行。在这种情况中,当所有玩家都准备就绪时,闭锁将到达结束状态。
CountDownLatch是一种灵活的闭锁。它使多个线程等待一组事件。闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。countDown方法递减计数器,表示有一个事件已经发生了,而await方去等待计数器达到零,这表示所有需要等待的事件都已经发生。如果计数器的值非零,那么wait会一直阻塞直到计数器为零,或者等待中的线程中断,或者等待超时。
1 | public class TestHarness{} |
FutureTask
也是一直闭锁,通过Callable实现,相当于一种可生成结果的Runnable,有3种状态:等待运行、正在运行、运行完成。
Future.get的行为取决于任务的状态。如果任务已经完成,那么get 会立即返回结果,否则get将阻塞直到任务进入完成状态,然后返回结果或者抛出异常。FutureTask 将计算结果从执行计算的线程传递到获取这个结果的线程,而FutureTask 的规范确保了这种传递过程能实现结果的安全发布。
1 | public class Preloader { |
信号量
控制同时访问某个特定资源的操作数量。Semaphore中管理着一组虚拟的许可(permit),许可的初始数量可通过构造函数来指定。在执行操作时可以首先获得许可(只要还有剩余的许可),并在使用以后释放许可。如果没有许可,那么acquire将阻塞直到有许可(或者直到被中断或者操作超时)。release方法将返回一个许可给信号量。计算信号量的一种简化形式是二值信号量,即初始值为1的Semaphore。二值信号量可以用做互斥体(mutex),并具备不可重入的加锁语义i谁拥有这个唯一的许可,谁就拥有了互斥锁。可用于实现资源池,如数据库连接池
1 | public class BoundedHashset<T> { |
栅栏
闭锁是一次性对象,一旦进入终止状态,就不能重置。栅栏也是闭锁,能阻塞一组线程直到某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。
CyclicBarrier可以使一定数量的参与方反复地在栅栏位置汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立的子问题。当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置。如果所有线程都到达了栅栏位置,那么栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下次使用。如果对await的调用超时,或者await阻塞的线程被中断,那么栅栏就被认为是打破了,所有阻塞的await调用都将终止并抛出 BrokenBarrierException。如果成功地通过栅栏,那么await将为每个线程返回一个唯一的到达索引号,我们可以利用这些索引来“选举”产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊的工作。CyclicBarrier还可以使你将一个栅栏操作传递给构造函数,这是一个Runnable,当成功通过栅栏时会(在一个子任务线程中)执行它,但在阻塞线程被释放之前是不能执行的。
另一种形式的栅栏是Exchanger,它是一种两方(Two-Party)栅栏,各方在栅栏位置上交换数据。当两方执行不对称的操作时,Exchanger会非常有用,例如当一个线程向缓冲区写入数据,而另一个线程从缓冲区中读取数据。这些线程可以使用Exchanger来汇合,并将满的缓冲区与空的缓冲区交换。当两个线程通过Exchanger交换对象时,这种交换就把这两个对象安全地发布给另一方。
5.6 构建高效且可伸缩的结果缓存
开发一个高效且可伸缩的缓存,改进一个高计算函数。
在 ExpensiveFunction中实现的Computable,需要很长的时间来计算结果,我们将创建一个Computable包装器,帮助记住之前的计算结果,并将缓存过程封装起来。
1 | // |
第一种方法使用HashMap保存之前计算的结果。compute首先检查需要的结果是否已经在缓存中。HashMap不是线程安全的,对整个方法进行同步,但是如果有另外一个线程正在计算结果,就会阻塞。
Memoizer2用ConcurrentHashMap代替HashMap。由于ConcurrentHashMap是线程安全的,因此在访问底层Map时就不需要进行同步,因而避免了在对Memoizer1中的compute方法进行同步时带来的串行性。
Memoizer2 比Memoizerl有着更好的并发行为﹔多线程可以并发地使用它。但它在作为缓存时仍然存在一些不足—当两个线程同时调用compute时存在一个漏洞,可能会导致计算得到相同的值。在使用memoization的情况下,这只会带来低效,因为缓存的作用是避免相同的数据被计算多次。但对于更通用的缓存机制来说,这种情况将更为糟糕。对于只提供单次初始化的对象缓存来说,这个漏洞就会带来安全风险。.
1 | public class Memoizer2<A,v> implements Computable<A, v>{ |
Memoizer2的问题在于,如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在进行,那么很可能会重复这个计算。需要让其他线程知道这个计算正在进行中。
我们已经知道有一个类能基本实现这个功能:FutureTask。FutureTask表示一个计算的过程,这个过程可能已经计算完成,也可能正在进行。如果有结果可用,那么FutureTask.get将立即返回结果,否则它会一直阻塞,直到结果计算出来再将其返回。
Memoizer3将用于缓存值的Map重新定义为ConcurrentHashMap<A,Future
1 | public class Memoizer3<A,V> implements computable<A,V> { |
Memoizer3的实现几乎是完美的:它表现出了非常好的并发性(基本上是源于ConcurrentHashMap高效的并发性),若结果已经计算出来,那么将立即返回。如果其他线程正在计算该结果,那么新到的线程将一直等待这个结果被计算出来。它只有一个缺陷,即仍然存在两个线程计算出相同值的漏洞。 if 代码仍然是“先检查后执行”。
Memoizer使用了ConcurrentMap中的原子方法 putIfAbsent,避免了Memoizer3的漏洞。
1 | public class Memoizer<A,V> implements computable<A,V> { |
当缓存的是Future而不是值时,将导致缓存污染(Cache Pollution)问题:如果某个计算被取消或者失败,那么在计算这个结果时将指明计算过程被取消或者失败。为了避免这种情况,如果Memoizer发现计算被取消,那么将把Future从缓存中移除。如果检测到RuntimeException,那么也会移除Future,这样将来的计算才可能成功。Memoizer同样没有解决缓存逾期的问题,但它可以通过使用FutureTask的子类来解决,在子类中为每个结果指定一个逾期时间,并定期扫描缓存中逾期的元素。(同样,它也没有解决缓存清理的问题,即移除旧的计算结果以便为新的计算结果腾出空间,从而使缓存不会消耗过多的内存。)·
在完成并发缓存的实现后,就可以为第2章中因式分解servlet添加结果缓存。程序清单5-20中的Factorizer使用Memoizer来缓存之前的计算结果,这种方式不仅高效,而且可扩展性也更高。