二、 线程安全性
2.1 什么是线程安全
- 当多个线程同时访问某个类时,这个类始终能表现正确的行为,那么就称这个类时线程安全的。
- 在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。
- 无状态的对象一定是线程安全的
2.2 原子性
- 竞态条件:
- 在并发编程中,由于不恰当的执行时序而出现不正确的结果的情况;
- 当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件;
- 大多数竞态条件的本质是,基于一种可能失效的观察结果来做出判断或者执行某个计算;
- 要避免这个问题,就需使产生竞态条件的复合操作以原子性的形式完成
2.3 加锁机制
- 要保持状态的一致性,就需要在单个的原子操作中更新所有相关的状态变量
- 每个 Java 对象都可以做一个实现同步的锁,称为内置锁或监视锁。这种锁是互斥、可重入(重入说明锁的粒度是线程,不是调用)的锁
2.4 用锁保护状态
用锁保护的原则:访问共享状态的符合操作,都必须是原子性的以避免产生竞态条件。如果用同步来协调对某个变量的访问时,那么在访问这个变量的所有位置上都需要使用同步。而且当用锁协调对某个变量的访问时,在访问变量的所有位置都需要使用相同的锁。
一种常见的加锁约定是,将所有的可变状态都封装在对象的内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。例如Vector
和其他的同步集合类。
2.5 活跃性与性能
决定synchronized
块的大小需要权衡各种设计,包括安全性(不能妥协)、简单和性能。有时简单和性能会彼此冲突,但不要过早的为了性能而牺牲简单性。
三、共享对象
可见性
可见性问题可能导致读到失效数据。对于一个变量,没有同步的情况下,读取到的可能是一个之前的值,但如果是 64 位( double
long
)类型的数据,可能高低位读取到不同的过期值的高低位。因此,在多线程环境中即便不担心失效数据问题,使用 64 位数据还是要做同步保护。
加锁的含义除了互斥外,还包括内存可见性。对于多线程中共享的可变变量,在同一个锁上同步可以保证每个线程都读到最新值。
volatile
- 语意:
- 保证可见性。在变量改变需要依赖当前值,或者需要与其他变量共同参与不变性约束时需要额外同步来保证原子性
- 禁止指令重排优化。通过在编译时加入一个 lock 前缀指令,相当于内存屏障
- 当且仅当完全满足以下条件时,才可以使用 volatile:
- 写入不依赖当前值,或者保证只有单线程修改这个值
- 该变量的值不会与其他状态变量一起纳入不变性条件
- 在访问变量时不需要加锁
线程封闭
不共享数据,仅在单线程中访问数据,例如 JDBC 的Connection
对象,仅在一个线程中使用;ThreadLocal
。
不变性
不可变的对象一定是线程安全的。
一个变量被声明成final
的表示它的值不能被改变。如果它是一个引用表示不能被重新指定指向的对象,但是其对象的值是可以改变的。我们必须初始化final
变量,它只能被初始化一次。
安全发布
对象的安全发布需求取决与它的可变性:
- 不可变对象可以通过任意机制发布
- 事实不可变对象必须通过安全方式来发布
- 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来
在并发程序中使用和共享对象时,可以通过以下策略安全地共享变量:
- 线程封闭:线程封闭对象只能由一个线程拥有
- 只读共享:没有任何同步机制下,多个线程只读一个对象是安全的,任何线程都不能修改它
- 线程安全的共享:线程安全对象在其内部实现同步,多线程通过对象的接口访问,而不需要进一步同步
- 保护对象:通过持有特定的锁来访问
四、对象组合
设计线程安全的类
在设计线程安全的类时,需要包含以下三个基本要素:
- 找出构成对象状态的所有变量
- 找出约束变量的不变性条件
- 建立对象状态的并发访问管理策略
方法:
- 实施封闭
- 封装简化了线程安全类的实现过程,将数据封装在对象的内部,可以将数据的访问限制在对象的方法上,从而更容易的确保线程在访问数据时总能持有正确的锁。
- 实施委托
- 如果一个类是由多个独立的线程安全的状态变量组成,并且所有的操作都不包含无效状态的转换,那么可以将线程安全性委托给底层的状态变量
- 如果类中的线程安全状态不是彼此独立的,且含有复合操作,那么仅靠委托并不能实现线程安全
- 发布底层变量
五、基础构建模块
同步容器
同步容器包括Vector
和HashTable
,还有由通过Collections.synchronizedXxx
构造的类。它们实现线程安全的方式是:将状态封装起来,并对每个共有方法都进行同步,使得每次只有一个线程可以访问容器的状态。同步容器对所有状态的访问都串行化的代价是严重减低了并发性。在某些情况下要对它们进行额外的加锁来保护复合操作,如迭代、跳转、若没有则添加。
同步容器在迭代的时候,如果发现被其他线程修改会抛出ConcurrentModificationException
异常。防止异常发生可以在迭代之前加锁保护,降低并发,引起死锁;复制一个副本在副本上完成迭代,复制有消耗。
并发容器
并发容器是针对多个线程同时访问设计的,使用并发容器代替同步容器,可以极大的提高伸缩性并减低风险。并发容器在迭代的过程中不会抛出ConcurrentModificationException
异常,而是采用弱一致性设计,可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但不保证)在迭代器被构造后将修改反映给容器。常用的并发容器有:ConcurrentHashMap
、CopyOnWriteArrayList
、Queue
、BlockingQueue
、ConcurrentSkipListMap
、ConcurrentSkipListSet
。
- ConcurrentHashMap:用更细粒度的锁提升了并发性
- CopyOnWriteArrayList:它的线程安全性在于,只要正确地发布一个事实不可变的对象,那么在访问时就不需要进一步的同步。在每次修改时都会重新发布一个新的容器副本。
- Queue:常用实现
ConcurrentLinkedQueue
和PriorityQueue
。用来临时保存一组等待处理的元素。Queue
上的操作不会阻塞,没有元素时会直接返回null
- BlockingQueue:
BlockingQueue
扩展了Queue
,增加了可阻塞的插入和获取等操作
- Deque/BlockingDeque:双端队列,实现了在队头和队尾高效的插入和移除,使用于相关模式-工作密取(Work Stealing)。
同步工具类
所有的同步工具类都包含一些特定的结构化的属性:它们封装一些状态,这些状态决定执行同步工具的类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另一些方法用于高效地等待同步工具类进入到预期状态。
- Latch(闭锁)
- Semaphore(信号量)
- Barrier(栅栏)
- FutureTask:可以处于等待、正在、完成运行三种状态,完成状态包括正常结束、取消、异常。
七、取消和关闭
Java 没有提供任何机制,来安全地强迫线程停止手头的工作。只提供了中断的协作机制,使一个线程能够要求另一个线程停止当前的工作。当要求它们停止时,它们首先会清除当前进程中的工作,然后再终止。这提供了更好的灵活性,因为任务代码本身比发出取消请求的代码更明确应该清除什么。
任务取消
我们取消一个任务的原因有很多,例如用户取消、现时活动、应用程序事件、错误、关闭程序或服务。
Java 使用协作机制来取消任务。在协作机制中,有一种会设置 cancellation requested 标志,任务定期查看;如果发现标志被设置过,任务就会提前结束。线程中断就是这样的协作机制,一个线程给另一个线程发送信号,通知它在方便或者可能的情况下停止正在做的工作。Java 并没有把中断与任何取消的语义绑定起来,但事实上,使用中断之外的任何方式取消都是不明智的,难以支撑起更大的应用。
对中断最好的理解是:它并不会真正中断一个正在运行的线程;它仅仅发出了中断请求,收到请求的线程会在下一个方便的时刻(取消点)中断。有些方法对这样的请求很重视,比如 wait
sleep
join
,会抛出InterruptedException
,或者进入时中断状态就已经被设置了。
// 将调用该方法的对象所表示的线程标记一个停止标记,并不是真的停止该线程。
public void interrupt() {... }
// 获取当前线程的中断状态,并且会清除线程的状态标记。是一个是静态方法。
// 执行 interrupted() 后它会将状态标志清除,底层调用了 isInterrupted(true)
public static boolean interrupted() {...}
// 获取调用该方法的对象所表示的线程,不会清除线程的状态标记。是一个实例方法。
public boolean isInterrupted() {...}
中断策略
中断策略最有意义的是对线程级和服务级取消的规定:尽可能的快速退出,如果需要的话进行清理,可能的话通知其拥有的实体,这个线程已经推出。
响应中断
当调用可中断的阻塞函数时,有两种处理InterruptedException
的实用策略:
- 传递异常,使你的方法也成为一个可中断的阻塞方法
- 保存中断状态,上层调用栈的代码能够对其进行处理
只有实现了线程的中断策略的代码才可以接收中断请求。通用目的的任务和库的代码绝不应该接受中断请求。
如果你的代码不会调用可中断的阻塞方法,仍可以通过检查任务代码当前线程的中断状态来响应终端。选择适当的检查频率需要在效率和响应性之间进行权衡。
通过Future
来实现取消
Future
拥有一个cannel()
方法,该方法的参数mayInterruptIfRunning
表示任务已经开始了是否还可以取消,取消成功会返回true
。只有当你知道一个任务实现了中断策略时mayInterruptIfRunning
设置为true
才是安全的。
处理不可中断的阻塞
- Java.io 包中的 Socket I/O:关闭底层的套接字
- Java.io 包中的同步 I/O
- Selector 的异步 I/O:调用
close
wakeup
方法会抛出异常
- 获取某个锁:如果等待某个内置锁,那么将无法相应中断。
Lock
类中lockInterruptibly
方法允许等等锁的同时响应中断
停止基于线程的服务
对于线程持有的服务,只要服务的存在时间大于创建线程的方法存在的时间,那么就是应该提供生命周期的方法。ExecutorService
提供了shutdown
和shutdownNow
方法。
ExecutorService
关闭
可以用shutdown
和shutdownNow
方法关闭。shutdownNow
表示立刻关闭,是强制关闭,可以通过进一步封装ExecutorService
得到关闭后取消的任务。
致命药丸(poison pill)
可以使用致命药丸(poison pill)方式来保证生产者和消费者的关闭:一个可以识别的对象,置于队列中,如果得到它,就停止一切工作。只有在生产者和消费者的数量都已知的情况下,才能使用“毒丸”对象。
处理非正常的线程终止
线程非正常退出的后果可能是良性的,也可能是恶性的。若使用线程池,非正常终断会使线程池中的线程数减少。任何操作都可能带来RuntimeException
,所以在调用方法时要保持怀疑。
JVM 关闭
正常关闭:
- 最后一个“正常(非守护)”结束
- 调用
System.exit
- 特定平台的方法关闭
非正常关闭:
- 调用
Runtime.halt
- 操作系统杀死 JVM 进程
关闭钩子(Shutdown Hook)
在正常关闭中,会执行系统中已经设置的所有通过方法 addShutdownHook 添加的钩子,当系统执行完这些钩子后,jvm 才会关闭。
注意点:
- 何时增加关闭钩子:任何时候!!!在任何情况下都可以增加一个关闭钩子,只要在 JVM 关闭之前。如果试图在 JVM 开始关闭后注册一个关闭钩子,将抛出一个带有”Shutdown is progress”消息的 IllegalStateException。
- 增加相同的钩子:不能增加相同的钩子。如果这样做了,将抛出带有”Hook previously registered”消息的 IllegalArgumentException。
- 注销钩子:调用 Runtime.removeShutdownShook(Thread hook)可以注销一个钩子。
- 注意并发:万一不止一个关闭钩子,它们将并行地运行,并容易引发线程问题,例如死锁
- 关闭钩子的可靠性:JVM 将在退出的时候尽最大努力来执行关闭钩子,但是不保证一定会执行。例如,当在 Linux 中使用-kill 命令时,或在 Windows 中终结进程时,由于本地代码被调用,JVM 将立即退出或崩溃。
- 注意钩子的时间消耗:需要注意的重点之一是,关闭钩子不应该花费过多时间。考虑这样一个场景,当用户从操作系统中注销,操作系统花费非常有限的时间就正常退出了,因此在这样样的场景下 JVM 也应该尽快退出。
守护线程
用来做辅助功能的线程,不会阻碍 JVM 关闭。当 JVM 停止时,所有仍然存在的守护线程都将被抛弃(既不会执行 finally,也不会执行回卷栈),直接退出。应该尽量少用守护线程,因为很少有操作能够在不进行清理的情况下被安全的抛弃。特别注意,如果在守护线程中包含 I/O 操作的任务,是一件危险行为。
终结器
finalize 是基础类 java.lang.Object 的一个方法,它的设计目的是保证对象在被垃圾收集前完成特定资源的回收。finalize 机制现在已经不推荐使用,并且在 JDK 9 开始被标记为 deprecated。
finalize 的执行是和垃圾收集关联在一起的,一旦实现了非空的 finalize 方法,就会导致相应对象回收呈现数量级上的变慢,有人专门做过 benchmark,大概是 40~50 倍的下降。finalize 被设计成在对象被垃圾收集前调用,这就意味着实现了 finalize 方法的对象是个“特殊公民”,JVM 要对它进行额外处理。finalize 本质上成为了快速回收的阻碍者,可能导致你的对象经过多个垃圾收集周期才能被回收。
comments powered by