1. 简介
1.2 线程的优势
- 注意java内置的并发框架和actor CSP等的区别,java使用共享内存,后两个Don’t communicate by sharing memory, share memory by communicating 通过发送消息
1.2.1 发挥多处理器的强大能力
1.2.2 建模的简单性
- 如果为模型中的每种任务都分配一个专门的线程,那么可以形成一种串行执行的假象,同时我觉得你思考程序的逻辑也会简化,等于又做了个抽象.并将程序的执行逻辑与调度机制的细节,交替执行的操作,异步I/O以及资源等待等问题分离开来.通过使用线程,可以讲复杂并且异步的工作流进一部分解为一组简单并且同步的工作流,每个工作流在一个单独的线程中运行,并在特定的同步位置进行交互.
2. 线程安全性
- 无状态:即不包含任何域,也不包含任何对其他类中域的引用.无状态对象一定是线程安全的,局部变量只能由正在执行的线程访问
2.2 原子性
2.2.1 竞态条件
2.2.2 复合操作
2.3 加锁机制
2.3.1 内置锁
2.3.2 重入
- 重入意味着锁操作的粒度是线程而不是调用(可以用在子类父类的方法,重入避免死锁)
2.4 用锁保护状态
- 对象的内置与对象的状态之间没有内在的关联,对象的域不一定要通过内置锁来保护.当获取与对象关联的锁时,并不能阻止其他线程对象访问该对象!!!注意,只是其他线程不能获取同一个锁对象.之所以每个对象都有一个内置锁,只是为了免去显示的创建锁对象.
2.5 活跃度与性能
- 同步代码块的大小要合适,执行长时间的计算或者可能无法快速完成的操作时(网络I/O,控制台I/O ) 一定不要持有锁
3. 对象的共享
3.1 可见性
- 同步还有一个重要的方面:内存可见性
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;
}
}
- 这个代码可能输出0,或者根本无法终止,因为在代码中没有使用足够的同步机制,无法保证主线程写入的ready值和number值对读线程可见
3.1.1 失效数据
- 只对set加锁是不够的,调用get的线程仍然会看到失效值
3.1.2 非原子的64位操作
- 最低安全性:读到的值至少是之前某个线程设置的,不会是随机的
- 最低安全性例外:非volatile类型的64位变量(double和long),对他们的读写可能会分解为两个32位的操作.很可能会读到某个值得高32位和另一个值的低32位
3.1.3 加锁与可见性
- 由同一个锁同步的变量,某个线程对他写入的值对其他线程都是可见的(看到最新值)
3.1.4 Volatile变量
- 典型用法
volatile boolean asleep;
...
while (!asleep)
countSomeSheep();
- 加锁机制既可以确保可见性,又可以确保原子性;volatile只确保可见性
- 当且仅当满足一下所有条件时,才应该使用volatile变量
- 对变量的写入不依赖变量的当前值,或只有单个线程更新变量的值
- 该变量不会与其他状态变量一起纳入不变形条件中
- 在访问变量时不需要加锁
3.2 发布(Publish)与溢出
- 发布一个对象:使对象能在当前作用于范围之外的代码中使用
- 当发布某个对象的时候,可能会间接的发布其他对象比如:把对象放到集合中,把集合发布
class UnsafeStates {
private String[] states = new String[]{
"AK", "AL" /*...*/
};
public String[] getStates() {
return states;
}
}
- 按照上述方式发布states,数组states就溢出了,任何调用get方法的程序都可以修改它,虽然他是private的
- 解决方法:良好的封装
- this引用溢出:(没看懂以后再补把):现在见解是这个 new EventListener() 里面用了ThisEscape的doSomething方法,等于获得了ThisEscape的引用吗.然后重点是这个对象的发布是在这个对象的构造函数中啊,所以可能发布了一个尚未构造完成的对象.如果this引用在构造过程中逸出,那么这种对象就别认为是不正确的构造.
- 重点就是:不要让其他线程在构造函数返回前使用他的this引用
- 在构造过程中使this引用逸出的一个常见错误是,在构造函数中启动一个线程.当对象在构造函数中创建一个线程的时候,无论是显示创建(把线程对象传给构造函数)还是隐式创建,this引用都会被新创建的线程共享.在对象未被构造完成之前,新的线程就可以看见它.在构造函数中创建线程并没有错误,但是最好不要立即启动它.在构造函数中调用一个可改写的实例方法时(既不是final也不是private)同样会导致this引用在构造函数过程中逸出.
public class ThisEscape {
public ThisEscape(EventSource source) {
source.registerListener(
new EventListener() {
public void onEvent(Event e) {
//这里 在外部封装的ThisEscape实例也逸出了
doSomething(e);
}
});
}
void doSomething(Event e) {
}
}
- 如果想要在构造函数中注册一个事件监听器或启动线程,可以使用一个私有的构造器函数和一个公共的工厂方法,以避免不正确的构造过程
public class SafeListener {
private final EventListener listener;
private SafeListener() {
listener = new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
};
}
public static SafeListener newInstance(EventSource source) {
SafeListener safe = new SafeListener();
source.registerListener(safe.listener);
return safe;
}
void doSomething(Event e) {
}
}
3.3 线程封闭
- 仅在单线程中访问数据,就不需要同步,称为线程封闭
3.3.1 Ad-hoc线程封闭
- 定义:维护线程封闭性的职责完全由程序实现来承担,非常脆弱,对于java没有语言特性能将对象封闭到目标线程上,程序里尽量减少使用
3.3.2 栈封闭
- 是线程封闭的一种特例,在栈封闭中只有局部变量可以访问对象.对于基本类型的局部变量,无论如何都不会破坏栈封闭性.在维持对象引用的栈封闭性时,可以使用保护性复制
3.3.3 ThreadLocal类
- 可以将
ThreadLocal<T>
视为包含了Map<Thread,T>
对象,但是其实现并不是如此. - 假设你需要将一个单线程应用程序移植到多线程环境中,可以把共享的全局变量转换为ThreadLocal对象,可以维持线程安全性
- 在实现应用程序框架的时候大量使用了ThreadLocal.讲一个事务上下文与某个执行的线程关联起来,就是把事务上下文保存在静态的ThreadLocal对象中;然而这也将使用该机制的代码与框架耦合在一起.
- ThreadLocal变量类似于全局变量,会降低代码的可重用性,并在类之间隐含的耦合性
3.4 不变性
- 不可变对象一定是线程安全的
3.4.1 Final域
- 除非某个域是可变的,不然都应该声明为final的(String比较特殊,可以认为他是不可变的)
3.4.2 使用Volatile类型来发布不可变对象
public 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);
}
}
- 如果要更新这些变量,可以创建一个新的容器对象
3.5 安全发布
- 任何线程都可以在不需要额外同步的情况下安全的访问不可变对象(这是内存可见性也保证了吗),即使在发布这些对象的时候没有使用同步(需满足不可变性的要求:1.状态不可变 2.所有的域都是final类型 3.正确的构造过程)
3.5.3 安全发布的常用模式
-
一个正确构造的对象可以通过以下方式来安全地发布
-
在静态初始化函数中初始化一个对象引用
-
将对象的引用保存到volatile类型的域或者AtomicReferance对象中
-
将对象的引用保存到某个正确构造对象的final类型域中
-
将对象的引用保存到一个由锁保护的域中
-
如果线程A将对象X放入一个线程安全的容器,随后线程B读取这个对象,那个可以B看到A设置的X的状态.线程安全库中的容器类提供了一下的安全发布保证:
-
通过将一个键或者值放入Hashtable,synchronizedMap或者ConcurrentMap中,可以安全的将它发布给任何从这些容器中访问它的线程(这里的角度是容器)
-
通过将某个元素放入 Vector,CopyOnWriteArrayList, CopyOnWriteArraySet,synchronizedList或者synchronizedSet中, 可以安全的将该元素发布给任何从这些容器中访问它的线程(这里的角度是容器里的元素)
-
通过将某个元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该元素安全的发布到任何从这些队列中访问该元素的线程
-
通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器,静态初始化器由JVM在类的初始化阶段执行,由于在JVM内部存在着同步机制,因此通过这种方式初始化的任何对象都可以安全地被发布.
public static Holder = new Holder(42);
3.5.4 事实不可变对象
定义:从技术上来看是可变的,但其状态在发布后不会再改变 对象的发布需求取决于它的可变性
- 不可变对象可以通过任意对象发布'
- 事实不可变对象必须通过安全的方式发布
- 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来
3.5.6 安全的共享对象
实用策略:
- 1.线程封闭:线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改
- 2.只读共享:可以由多个线程并发访问,但是都不能改变其状态(共享的只读变量包括:不可变对象和事实不可变对象)
- 3.线程安全共享:线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来访问而不需要进一步的同步
- 4.保护对象:被保护的对象只能通过持有特定的锁来访问.保护对象包括封装在其他线程安全的对象中的对象,以及已发布的并且由某个特定锁保护的对象
4.对象的组合
- 本章介绍一些组合模式,使一个类更容易成为线程安全的
4.1 设计线程安全的类
- 在设计线程安全类的过程中,需要包含以下三个基本要素
- 1.找出构成对象状态的所有变量
- 2.找出约束状态变量的不变形条件
- 3.建立对象状态的并发访问管理策略
5.基础构建块
第四章介绍了构建线程安全类的一些技术,例如将线程安全性委托给现有的线程安全类,委托是创建线程安全类的一种最有效的策略:只需让现有的线程安全类管理所有的状态即可.
5.1 同步容器类
注意 同步容器类不是并发容器类
5.1.1 同步容器类的问题
- 类本身是线程安全的,但是某些情况(迭代,size())可能需要额外的客户端加锁来保护复合操作.
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));
}
- 这种迭代操作的正确性完全依靠运气,即在调用size和get之间没有线程会修改Vector,否则会抛出ArrayIndexOutOfBoundsException也是我们不希望看到的,可以使用客户端加锁来解决这个问题,但是就是降低了并发性.
5.1.2 迭代器与ConcurrentModificationException
Vector虽然是一个古老的容器类,然而许多现代的容器类也没有消除复合操作的问题,无论直接迭代还是foreach,对容器类的标准迭代都是使用Iterator(hasNext和next),在迭代期间并发修改虽然表现出"fail-fast"(抛出ConcurrentModificationException),要想避免出现这个异常,就必须在迭代过程中持有容器的锁,这样对并发性影响很大,需要长时间的持有锁.
List<Widget> widgetList = Collections.synchronizedList(new ArrayList<Widget>());
// May throw ConcurrentModificationException
for (Widget w : widgetList)
doSomething(w);
- 如果不希望在迭代的时候对容器加锁,那么一种迭代的方式就是"克隆容器",并在副本上进行迭代,由于副本封闭在线程内,所以其他线程不会对其改变,避免了抛出异常,不过这种方式也存在显著的性能开销
5.1.3 隐藏的迭代器
- 加锁可以防止迭代器抛出异常,实际情况更复杂,因为在某些情况下,迭代器会隐藏起来.下面例子没有显式的迭代操作,但第二行代码其实会执行迭代操作,因为编译器会将字符串的连接操作转换为调用StringBuilder.append(Object),这个方法又会调用容器的toString方法,标准容器的toString方法又会迭代容器.
- 教训:如果状态与保护它的同步代码之间相隔越远,那么开发人员就越容易忘记在访问状态时使用正确的同步
- 容器的hashCode和equals,当容器作为另一个容器的元素或者键值,containsAll,removeAll,retainAll等方法,以及把容器作为参数的构造函数,都会对容器进行迭代
private final Set<Integer> set = new HashSet<Integer>();
System.out.println("DEBUG: added ten elements to " + set);
5.2 并发容器
通过并发容器来代替同步容器,可以极大的提高伸缩性并降低风险
5.2.1 ConcurrentHashMap
- synchronizedMap是同步容器 ConcurrentHashMap是并发容器
- 不会抛出ConcurrentModificationException,因此不需要在迭代过程中对容器加锁.它返回的迭代器具有弱一致性,并非及时失败.弱一致性的迭代器可以容忍并发的修改,当创建迭代器的时候,会遍历已有的元素,并可有(但不是)保证在迭代器被构造后将修改操作反映给容器.
- 对于一些需要在整个Map上进行计算的方法,例如size和isEmpty,这些方法的语义都被减弱了,他们在返回结果的时候都已经可能过期了,实际只是一个估计值.虽然让人不安,但这些操作在并发环境下的需求并不大,换取其他更重要的操作性能优化(get put containsKey和remove等)
5.2.2 额外的原子Map操作
- ConcurrentHashMap不能被加锁来执行独占访问,因此我们无法使用客户端加锁来创建新的原子操作(不太理解?应该是翻译的问题?),一些原子操作如:"若没有则添加","若相等则移除","若相等则替换"等都已经在ConcurrentMap的接口中声明.
5.2.3 CopyOnWriteArrayList
- 写入时复制,在迭代时不用对容器进行加锁或者复制.每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性.当迭代操作远多于修改操作时,才应该使用写入时复制容器.
阻塞队列和生产者-消费者模式
- 线程池也可以算是一种生产者-消费者模式
- 应该尽早的通过阻塞队列构建资源管理机制.,如果阻塞队列不能满足需求,还能通过信号量来创建其他的阻塞结构.
5.3.3 双端队列与work stealing
- Deque和BlockingDeque,阻塞队列适用于生产者-消费者模式,双端队列适用于work-stealing模式,work-stealing模式具有更高的课伸缩性,work-stealing
5.5 同步工具类
5.5.1 闭锁
- 比如CountDownLatch countDown方法递减计数器,表示有一个事件已经发生了await方法等待计数器为0,这表示所有需要等待的事件都已发生.闭锁可以用来确保某些活动直到其他活动都完成后才继续进行.
5.5.2 FutureTask
- FutureTask是通过Callable来实现的相当于一种生成结果的Runnable.
- 如果任务已经完成,Future.get会立即返回结果.否则get将阻塞直到任务进入完成状态,然后返回异常或者抛出异常
- FutureTask在Executor框架中表示异步任务,此外还可以用来表示一些时间较长的运算,这些计算可以在计算结果之前启动(异步异步异步!!!).
public class Preloader {
private final FutureTask< ProductInfo > future =
new FutureTask< ProductInfo >( new Callable< ProductInfo >() {
public ProductInfo call () throws DataLoadException {
return loadProductInfo();
}
} );
private final Thread thread = new Thread( future );
public void start () {
thread.start();
}
public ProductInfo get () throws DataLoadException, InterruptedException {
try {
return future.get();
}
catch ( ExecutionException e ) {
Throwable cause = e.getCause();
if ( cause instanceof DataLoadException ) {
throw ( DataLoadException ) cause;
}
else {
throw launderThrowable( cause );
}
}
}
}
public static RuntimeException launderThrowable(Throwable t) {
if (t instanceof RuntimeException)
return (RuntimeException) t;
else if (t instanceof Error)
throw (Error) t;
else
throw new IllegalStateException("Not unchecked", t);
}
- Callable表示的任务可以抛出受检查或者未受检查的异常,并且任何代码都有可能抛出一个Error.我们必须对每种情况单独处理,就有了launderThrowable
5.5.3 信号量
Semaphore中管理着一组虚拟的许可(permit),许可的初始数量可以通过构造函数指定,在执行操作的时候首先获得许可(只要还有剩余的许可),并在使用后释放许可.如果没有许可,那么acquire将阻塞直到有许可(或者直到被中断或者操作超时).release方法会返回一个许可给信号量.初始值为1的Sempahore是二值信号量,可以用作互斥体(mutex),并且具备不可重入的加锁语义.
- Semaphore可以用于实现资源池,例如数据库连接池,我们可以构建一个固定长度的资源池,当池为空的时候,请求资源将会失败,但你真正希望看到的行为是阻塞而不是失败,并且非空时解除阻塞.
//利用Semmphore为容器设置边界
public class BoundedHashSet < T > {
private final Set< T > set;
private final Semaphore sem;
public BoundedHashSet ( int bound ) {
this.set = Collections.synchronizedSet( new HashSet< T >() );
this.sem = new Semaphore( bound );
}
public boolean add ( T o ) throws InterruptedException {
sem.acquire();
boolean wasAdded = false;
try {
wasAdded = set.add( o );
return wasAdded;
} finally {
if ( !wasAdded ) {
sem.release();
}
}
}
public boolean remove ( Object o ) {
boolean wasRemoved = set.remove( o );
if ( wasRemoved ) {
sem.release();
}
return wasRemoved;
}
}
5.5.4 栅栏
- 栅栏(Barrier)类似于闭锁,区别在于所有线程必须同时到达栅栏位置才能继续执行.闭锁用于等待事件,而栅栏用于等待其他线程.栅栏用于实现一些协议,例如"所有人6.00在麦当劳碰头,到了以后要等其他人,之后再讨论下一步的事情"
- CyclicBarrier可以使一定数量的参与方反复的在栅栏位置汇集.这种算法通常将一个问题拆分成一系列相互独立的子问题.当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置.如果所有线程都到了栅栏位置,那么栅栏将打开,此时所有的线程都被释放,而栅栏将被重置以便下次使用,如果对await的调用超时,或者await阻塞的线程被中断,那么栅栏就被认为是打破了,所有阻塞的await调用都将终止并抛出BrokenBarrierException.
5.6 构建高效且可伸缩的结果缓存
5.6.1 从一个迭代来看
public interface Computable < A, V > {
V compute ( A arg ) throws InterruptedException;
}
public class ExpensiveFunction implements Computable< String, BigInteger > {
@Override
public BigInteger compute ( String arg ) throws InterruptedException {
//这里是很复杂的计算(此处简化)
return new BigInteger( arg );
}
}
v1.0 使用HashMap和同步机制来初始化缓存
public class Memoizer < A, V > implements Computable< A, V > {
private final Map< A, V > cache = new HashMap<>();
private final Computable<A,V> c;
//面向接口编程,具体实现是ExpensiveFunction或者其他
public Memoizer ( Computable< A, V > c ) {
this.c = c;
}
@Override
public synchronized V compute ( A arg ) throws InterruptedException {
V result = cache.get( arg );
if ( result == null ) {
result = c.compute( arg );
cache.put( arg, result );
}
return result;
}
}
- 1.0问题 效率太低
v2.0 使用ConcurrentHashMap替换HashMap
public class Memoizer2 < A, V > implements Computable< A, V > {
private final Map< A, V > cache = new ConcurrentHashMap< A, V >();
private final Computable< A, V > c;
public Memoizer2 ( Computable< A, V > c ) {
this.c = c;
}
public V compute ( A arg ) throws InterruptedException {
V result = cache.get( arg );
if ( result == null ) {
result = c.compute( arg );
cache.put( arg, result );
}
return result;
}
}
- 2.0的问题在于某个线程启动了一个开销很大的计算那么就会这样 B不知道A也在缓存这个
v3.0 2.0的主要问题是没法表示计算的过程,使用FutureTask可以解决这个问题,它是判断这个计算是否开始,而2.0是判断计算是否结束
- cache.put( arg, ft );注意这里cache的定义Map< A, Future< V > > cache 这里面的future也是用来爱计算cache的
public class Memoizer3 < A, V > implements Computable< A, V > {
private final Map< A, Future< V > > cache = new ConcurrentHashMap< A, Future< V > >();
private final Computable< A, V > c;
public Memoizer3 ( Computable< A, V > c ) {
this.c = c;
}
public V compute ( final A arg ) throws InterruptedException {
Future< V > f = cache.get( arg );
if ( f == null ) {
/*
Callable< V > eval = new Callable< V >() {
public V call () throws InterruptedException {
return c.compute( arg );
}
};
*/
Callable< V > eval = () -> c.compute( arg );
FutureTask< V > ft = new FutureTask<>( eval );
f = ft;
cache.put( arg, ft );
ft.run(); // call to c.compute happens here
}
try {
return f.get();
}
catch ( ExecutionException e ) {
throw LaunderThrowable.launderThrowable( e.getCause() );
}
}
}
- 但是它还有一个缺陷,还是存在两个线程计算出来相同值的漏洞,但是概率是远小于2.0的
v4.0 可以使用ConcurrentMap中的原子方法putIfAbsent
public V compute ( final A arg ) throws InterruptedException {
while ( true ) {
Future< V > f = cache.get( arg );
if ( f == null ) {
Callable< V > eval = () -> c.compute( arg );
FutureTask< V > ft = new FutureTask<>( eval );
//return the previous value associated with the specified key, or null if there was no mapping for the key. 有点违反直觉
f = cache.putIfAbsent( arg, ft );//return ft
if ( f == null ) { //如果原来没有缓存值和future对象(当然现在已经放进去了)
f = ft;
ft.run();
}
}
try {
return f.get();
}
catch ( CancellationException e ) {
cache.remove( arg, f );
}
catch ( ExecutionException e ) {
throw LaunderThrowable.launderThrowable( e.getCause() );
}
}
}
问题:如果某个计算取消或者失败,为了避免这种情况,如果发现计算被消除,那么将把Future从缓存中移除.如果检查到有Runtime异常,也会移除future.这个程序同样没有解决缓存逾期的问题,但是可以通过FutureTask的子类来解决,在子类中为每个结果指定一个逾期时间,并定期扫描缓存中逾期的元素,同样他也没有解决缓存清理的问题,即移除旧的计算结果以便为新的计算结果腾出空间,从而不会使缓存消耗过多内存.
在因式分解Servlet中使用Memoizer来缓存结果
public class Factorizer extends GenericServlet implements Servlet {
private final Computable< BigInteger, BigInteger[] > c = arg -> factor( arg );
private final Computable< BigInteger, BigInteger[] > cache = new Memoizer<>( c );
public void service ( ServletRequest req, ServletResponse resp ) {
try {
BigInteger i = extractFromRequest( req );
encodeIntoResponse( resp, cache.compute( i ) );
}
catch ( InterruptedException e ) {
encodeError( resp, "factorization interrupted" );
}
}
void encodeIntoResponse ( ServletResponse resp, BigInteger[] factors ) {}
void encodeError ( ServletResponse resp, String errorString ) {}
BigInteger extractFromRequest ( ServletRequest req ) {
return new BigInteger( "7" );
}
BigInteger[] factor ( BigInteger i ) {
// Doesn't really factor
return new BigInteger[] { i };
}
}