并发策略

并发策略

1 线程安全性

如果要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是共享的可变状态的访问。
‘共享’意味着变量可以有多个线程同时访问。
‘可变’意味着变量的值在他的生命周期中可以发生变化。
要想对象是线程安全的,需要使用同步机制来协同对对象可变状态的访问。

线程安全最核心的概念就是正确性。
当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么这个类便是一个线程安全类。
在线程安全类的对象上执行任何串行或并行的操作,都不会使对象处于无效状态。

1.1 无状态对象

对象不包含任何域,也不包含对其他类中域的引用,计算过程中的临时状态仅存在于线程栈的局部变量中,并且只能由这个线程访问。

1.2 线程隔离

不在多个线程中共享状态变量。如(ThreadLoacl、隔离的Task)

1.3 不可变对象

将状态变量修改为不可变变量

1.4 线程同步

在访问状态变量时使用同步策略。

2 线程封闭

线程封闭的对象只能由一个线程拥有,对象被封装在线程中,并且只能由整个线程修改。

2.1 Ad-hoc线程封闭

维护线程封闭性的职责完全由程序实现来承担。

2.1.1 GUI应用程序

几乎所有的GUI工具包都被实现为一个单线程子系统,这意味着所有的GUI操作都将被限制在单个线程中。
Swing的数据结构不是线程安全的,所以必须将操作封装在事件线程中。

2.1.1.1 单线程子系统

串行事件处理

对GUI视图模型或数据模型的操作会被串行化处理。
如果一个任务执行时间很长,那么其他任务必须等待该任务执行结束,因此在事件线程中执行的任务必须尽快将控制权还给事件线程。
如果要在执行某个时间较长的任务时更新进度标识,或者任务完成后提供一个可视化反馈,那么需要再次执行事件线程的代码。

线程封装机制

Swing中的组件以及模型只能在这个事件分发线程中进行创建、修改以及查询。

2.1.1.2 短时间GUI任务

短时间的任务可以将整个操作都放在事件线程中执行。
长时间的任务,应该放到另一个线程中运行。

2.1.1.3 长时间的GUI任务

长时间的任务,必须放在另一个线程中运行。此时可以使用缓存线程池。
在GUI中,线程间的接力是处理长时间任务的经典方法。

  1. 用户触发动作,将长时间任务提交到线程池中。
  2. 线程池中的任务运行过程中,需要更新GUI进度,将GUI更新任务提交给事件线程
  3. 长时间任务运行结束,将运行结果更新任务提交给事件线程。

任务取消

  1. 线程中断
  2. future

进度标识和完成标识

将进度更新和完成更新任务提交到事件线程中,用于更新GUI组件。

2.1.1.4 共享数据模型

线程安全的数据模型

使用线程安全的数据模型

分解数据模型

如果应用程序中既包含用于表示的数据模型,又包含应用程序特定的数据模型,那么这种应用程序称为分解模型设计。
通常情况下,表示模式只能由事件线程操作,共享模式为多线程安全模型。
表现模型注册共享模型的监听器,从而在数据更新时得到通知。
通知的方式:

  1. 全量更新
  2. 增量更新
2.1.1.5 其他单线程子系统

Akka的Active Object
可以使用Future+newSingleThreadExecutor一起完成。
提供一个代理对象来拦截所有对线程封闭对象的调用,在代理方法中可以调用submit方法提交任务,然后调用future.get来等待结果。

2.1.2 volatile

如果能够确保只有一个线程负责对volatile变量的“读取-修改-写入”,其他线程只负责读取,则可以保障线程安全。

2.2 线程栈封闭

在线程栈中,只能通过局部变量才能访问对象,其他线程无法访问栈内信息。

2.2.1 基本类型

基本类型的局部变量始终被封闭在线程中。

2.2.2 引用类型

如果对象引用没有被逸出,则被封闭在线程栈中。

2.3 ThreadLocal

ThreadLocal,这个类能使线程中的某个值与保存值得对象关联起来。
这些特定于线程的值保留在Thread中,当线程终止时,这些值会作为垃圾进行回收。
Thread对象中有一个Map<ThreadLoacl,T>的属性,用于保存ThreadLocal和值得关联关系。

2.3.1 Connection
2.3.2 事务

3 只读共享

在没有额外的同步情况下,共享的只读对象可以有多个线程并发访问,但任何线程都不能修改它,共享的只读对象包括不变对象和事实不变对象。

3.1 不变对象

如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。
不可变对象可以安全的共享和发布。

一般通过将一个保存新状态的实例来替换原来的不可变状态(函数式语言的经典用法)。

3.1.1 不变对象的条件
  1. 对象创建后,其状态不能被修改
  2. 对象所有域都是final类型的
  3. 对象被正确创建
3.1.2 final域

final域能够确保初始化过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时不需要同步。

仅包含一个或两个可变状态的‘基本不变’对象仍然比包含多个不可变状态的对象简单。

  1. 除非需要更高的可见性,否则将所有的域都声明为私有域。
  2. 除非需要某个域是可变的,否则将其声明为final的。
3.1.3 valatile发布不可变对象

不可变对象可以提供一种弱形式的原子性。
每当需要对一组相关数据以原子方式执行某个操作时,可以考虑创建一个不可变类来包含这些数据。
通过使用包含多个状态变量的容器对象来维护不可变性条件,并使用volatile类型的引用来确保可见性,可以保证在没有锁的情况下仍然是线程安全的(不变对象保证原子性,volatile保证可见性)。

4 线程安全共享

线程安全的对象在它内部实现同步,因此多个线程可以通过对象的共有接口来访问而不需要进一步的同步。

4.1 同步容器

同步容器包括Vector、Hashtable等,最主要的一类封装为Collections.synchronizedXXX
同步容器使用自身的锁来保护他的每一个方法。
他们将状态封装起来,并对每个公有方法进行同步,使得每次只有一个线程能访问容器的状态。

4.1.1 组合操作

对类的方法进行组合操作时,需要在容器实例锁的保护下进行操作。

removeLast
getLast
foreach等

4.1.2 迭代器

当发现容器在迭代过程中被修改时,会抛出ConcurrentModificationException
他的实现策略是将计数器的变化和容器关联起来,如果在迭代期间计数器被修改了,那么在执行hasNext或next时将抛出ConcurrentModificationException。
解决方案:

  1. 加锁(容器实例锁)
  2. 克隆,然后在容器的副本上进行迭代。
4.1.3 隐式迭代器

toString、hashCode、equals、containsAll、removeAll、retainAll

4.2 并发容器

并发容器是针对多个线程并发访问设计的。

4.2.1 ConcurrentHashMap

ConcurrentHashMap并不是将每个方法都在同一个锁上同步并使每次只能有一个线程访问容器,而是使用一种颗粒更细的加锁机制来实现更大程度的共享,这种机制称为分段锁。
ConcurrentHashMap提供的迭代器不会抛出ConcurrentModificationException
副作用:全局操作所返回的数据不够准确size、isEmpty等,获取结果时可能已经过期。
额外的原子操作命令:
putIfAbsent:仅当没有对应Key时插入
remove(k, v):仅当k的值为v时删除
replace(k,ov,ev):仅当k的值为ov时替换
replace(k,v):仅当k存在映射值时替换

4.2.2 CopyOnWriteArrayList

在每次修改时,都会创建并重新发布一个新的容器副本。
仅当迭代操作远远大于修改操作时,才应该使用。

4.3 阻塞队列

阻塞队列提供了可阻塞的put和take方法,以及支持定时的offer和poll方法。
构建高可靠的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止产生过多的工作项,使应用程序在负载过高的情况下变得更加健壮。

4.3.1 阻塞队列实现
  1. LinkedBlockIngQueue
  2. ArrayBlockingQueue
  3. PriorityBlockingQueue。既可以按照自然顺序比较元素,也可以使用Comparator来比较;
  4. SynchronousQueue。
    他不是一个真正的队列,因为他不维护存储空间,他维护一组线程,这些线程等待把元素加入或移除队列。
    由于可以直接交付工作,从而降低了将数据从生产者移动到消费者的延迟。
    由于没有存储,因此put或take将一直阻塞,直到有另一个线程已经准备好参与到交付过程中。
4.3.2 串行线程封闭

线程封闭对象只能由单线程拥有,但可以通过安全发布该对象来转移所有权。

4.3.2.1 阻塞队列

将对象的所有权从生产者交付给了消费者。

4.3.2.2 对象池
4.3.2.3 compareAndSet
4.3.3 双端队列与工作密取

Deque是一个双端队列,实现了在队列头和队列尾的高效插入和移除。
双端队列适用于工作密取,如果一个消费者完成了自己的双端队列中的全部工作,那么它可以从其他消费者的双端队列末尾秘密的获取工作。
工作密取非常适用于既是消费者又是生产者的问题,当执行某个工作时可能会产生更多的工作。
ForkJoinPool

4.4 同步工具

同步工具可以是任意对象,只要它能够根据自身的状态来协调线程的控制流。
他们都封装了一些状态,这些状态将决定执行同步工具类的线程是继续运行还是等待,此外还提供了一些方法对状态进行操作,以及另外一些方法用于高效的等待同步工具进入预期状态。

4.4.1 闭锁

延迟线程的进度直到其到达终止状态,闭锁相当于一扇门:在闭锁到达结束状态前,这扇门一直是关闭状态,没有线程能够通过,当到达结束状态后,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会再改变,并永远保持打开状态。

4.4.1.1 使用场景
  1. 确保计算在所需要的资源都初始化后进行
  2. 确保某个服务在其依赖的服务全部启动后运行
  3. 直到所有的参与者都就绪后启动
4.4.1.2 CountDownLatch

包含一个计算器,该计数器初始化为一个正整数,标示需要等待的事件总数量,countDown方法递减计数器,标示一个事件已经发生,await方法等待计数器为零,标示所有的事件都已经发生。如果计算器非零,那么await将阻塞到计算器为零,或者中断、或者超时。

4.4.2 FutureTask

可处于三种状态:

  1. 等待运行
  2. 运行中
  3. 运行完成
    当FutureTask运行完成后,他将永远停止在这个状态上。
4.4.2.1 Future.get

如果任务已经完成,那么直接返回其结果,如果任务没有完成,将阻塞直到任务完成,然后返回结果或抛出异常。
FutureTask全部结果的安全发布。

4.4.2.2 Callable

Callable标示任务可以抛出受检查或未受检查的异常,无论抛出什么异常,都会被封装在ExecutionException中,在调用get方法时被重新抛出。

4.4.3 信号量

信号量用来控制同时访问某个资源的操作数量,或同时执行某个指定操作的数量。
计数信号量还可以用来实现某种资源池,或对容器施加边界。
执行操作时,需要先获取许可,并在执行完成时,释放许可。

4.4.4 栅栏

所有线程必须同时到达栅栏位置,才能继续执行,闭锁用于等待事件、栅栏用于等待其他线程。

4.4.4.1 CyclicBarrier

可以是一定数量的参与方,反复在栅栏位置汇聚。

  1. 如果成功通过栅栏,那么await方法将为每一个线程返回一个唯一的到达索引号,我们可以利用这些索引号选举一个领导线程,并在下一次迭代中由该线程执行一些特殊的工作。
  2. 还可以使你将一个栅栏操作传递给构造函数,当成功通过栅栏时会执行它。
4.4.4.2 Exchanger

它是一个双方栅栏,各方在栅栏位置上交换数据。
当两个线程通过Exchanger交换对象时,这种交换将把这两个对象安全的发布给另一方。

5保护对象

被保护的对象只能通过持有特定锁来访问,保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

wenxinzizhu wechat
扫一扫,添加我的微信,一起交流共同成长(备注为技术学习)