Java多线程(2)——线程安全

一、 竞态

状态变量(state variable):类的实例变量,静态变量。
共享变量(shared variable):可以被多个线程共同访问的变量。

竞态(race condition):是指计算的正确性依赖于相对时间顺序(Relative Timing)或者线程的交错(Interleaving)。
它不一定导致计算结果的不正确,只是不排除计算结果时而正确时而错误的可能。

导致竞态的常见因素是多个线程在没有采取任何控制措施的情况下并发地更新、读取同一个共享变量。局部变量不会导致竞态。

竞态的两种模式:

  • read-modify-write 读改写:读取一个共享变量的值,然后根据该值做一些计算,接着更新该共享变量的值。
  • check-then-act 检测而后行动:读取某个共享变量的值,根据该变量的值决定下一步的动作是什么。

二、 线程安全

如果一个类在单线程环境下能够运作正常,并且在多线程环境下,在其使用方不必为其做任何改变的情况下也能运作正常,那么我们就称其是 线程安全(Thread-safe) 的,相应地我们称这个类具有线程安全性(ThreadSafety)。

线程安全问题概括来说表现为3个方面:

  • 原子性
  • 可见性
  • 有序性

原子性

原子性(Atomicity):涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作。

不可分割:

  • 访问(读/写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,即其他线程不会看到该操作执行了部分的中间效果。
  • 访问同一组共享变量的原子操作是不能够被交错的,这就排除了一个线程执行一个操作期间另外一个线程读取或者更新该操作所访问的共享变量而导致的干扰(读脏数据)和冲突(丢失更新)的可能。

实现原子性的方式:

  • 锁(lock):在软件层实现
  • CAS(compare-and-swap)指令:在硬件层实现

在Java语言中,long型和double型以外的任何类型的变量的写操作都是原子操作,即对基础类型的变量和引用型变量的写操作都是原子的。

可见性

可见性(Visibility):可见性就是指一个线程对共享变量的更新的结果对于读取相应共享变量的线程而言是否可见的问题。多线程程序在可见性方面存在问题意味着某些线程读取到了旧数据(Stale Data)。

相对新值:一个线程更新了共享变量的值之后,其他线程能够读取到这个更新后的值,那么这个值就被称为该变量的相对新值。
最新值:如果读取这个共享变量的线程在读取并使用该变量的时候其他线程 无法更新 该变量的值,那么该线程读取到的相对新值就被称为该变量的最新值。

可见性的保障仅仅意味着一个线程能够读取到共享变量的相对新值,而不能保障该线程能够读取到相应变量的最新值。

处理器并不是直接与主内存(RAM)打交道而执行内存的读、写操作,而是通过寄存器(Register )、高速缓存(Cache)、写缓冲器(Store Buffer,也称 Write Buffer)和无效化队列(Invalidate Queue)等部件执行内存的读、写操作的。

缓存同步:一个处理器从其自身处理器缓存以外的其他存储部件中读取数据并将其反映(更新)到该处理器的高速缓存的过程。
缓存同步使得一个处理器(上运行的线程)可以读取到另外一个处理器(上运行的线程)对共享变量所做的更新,即保障了可见性。

因此,为了保障可见性,我们必须使一个处理器对共享变量所做的更新最终被写入该处理器的高速缓存或者主内存中,而不是始终停留在其写缓冲器中,这个过程被称为 冲刷处理器缓存

并且,一个处理器在读取共享变量的时候,如果其他处理器在此之前已经更新了该变量,那么该处理器必须必须从其他处理器的高速缓存或者主内存中对相应的变量进行缓存同步。这个过程被称为 刷新处理器缓存

因此,可见性的保障是通过使:

  • 更新共享变量的处理器执行冲刷处理器缓存的动作
  • 读取共享变量的处理器执行刷新处理器缓存的动作

原子性可以保证一个线程所读取到的共享变量的值要么是该变量的初始值要么是该变量的相对新值,而不是更新过程中的半成品值。
原子性和可见性一同得以保障了一个线程能够共享变量的相对新值。

有序性

有序性(Ordering):描述了一个处理器上运行的一个线程对共享变量所做的更新,在另外一个处理器上运行的其他线程看来,这些线程是以什么样的顺序观察到这些更新的问题。

编译器、处理器、存储子系统(写缓冲器和高速缓存等)和运行时(JIT编译器)都可能导致重排序。
重排序是出于性能的需要并在满足“貌似串行语义”的前提下进行的,它可能导致线程安全问题。
有序性的保障是通过部分地从逻辑上禁止重排序实现的。

  • 源代码顺序(Source Code):源代码中所指定的内存访问操作顺序。
  • 程序顺序(Program Order):在给定处理器上运行的目标代码所指定的内存访问操作顺序。
  • 执行顺序(Execution Order):内存访问操作在给定处理器上的实际执行顺序。
  • 感知顺序(Perceived Order):给定处理器所感知到的该处理器及其他 处理器的内存访问操作发生的顺序。

Java多线程(2)——线程安全

指令重排序是一种动作,它确确实实地对指令的顺序做了调整,其重排序的对象是指令。
内存子系统重排序是一种现象,没有真正对指令执行顺序进行调整。

可见性是有序性的基础:可见性描述的是一个线程对共享变量的更新对于另外一个线程是否可见,或者说什么情况下可见的问题。有序性描述的是,一个处理器上运行的线程对共享变量所做的更新,在其他处理器上运行的其他线程看来,这些线程是以什么样的顺序观察到这些更新的问题。
有序性影响可见性。由于重排序的作用,一个线程对共享变量的更新对于另外一个线程而言可能变得不可见。

三、 上下文切换

单处理器上的多线程是通过 时间片(time slice) 分配的方式实现的。

时间片决定了一个线程可以连续占用处理器运行的时间长度。

当一个进程中的一个线程由于其时间片用完或者其自身的原因*或者主动暂停其运行时,另外一个线程可以被操作系统(线程调度器)选中占用处理器开始或者继续其运行。这种一个线程被暂停,即被剥夺处理器的使用权,另外一个线程被选中开始或者继续运行的过程就叫作 线程上下文切换(Context Switch)

上下文(Context):一般包括通用寄存器(General Purpose Register)的内容和程序计数器(Program Counter)的内容。在切出时,操作系统需要将上下文保存到内存中,以便被切出的线程稍后占用处理器继续其运行时能够在此基础上进展。在切入时,操作系统需要从内存中加载被选中线程的上下文,以在之前运行的基础上继续进展。

从Java应用的角度来看,一个线程的生命周期状态在RUNNABLE状态与非RUNNABLE状态(包括BLOCKEDWAITINGTIMED_WAITING中的任意一个子状态)之间切换的过程就是一个上下文切换的过程。

上下文切换的开销包括直接开销和间接开销。

  • 直接开销:

    • 操作系统保存和恢复上下文所需的开销,这主要是处理器时间开销。
    • 线程调度器进行线程调度的开销。
  • 间接开销:

    • 处理器高速缓存重新加载的开销。一个被切出的线程可能稍后在另外一个处理器上被切入继续运行。由于这个处理器之前可能未运行过该线程,那么这个线程在其继续运行过程中需访问的变量仍然需要被该处理器重新从主内存或者通过缓存一致性协议从其他处理器加载到高速缓存之中。这是有一定时间消耗的。
    • 上下文切换可能导致整个一级高速缓存中的内容被冲刷,即一级高速缓存的内容会被写入下一级高速缓存或者主内存中。

一次上下文切换的时间消耗是微秒级的。
多线程编程相比于单线程编程来说,它意味着更多的上下文切换。因此,多线程编程不一定就比单线程编程的计算效率更高。

四、 线程的活性故障

线程活性故障(Liveness Failure):由资源稀缺性或者程序自身的问题和缺陷导致线程一直处于非RUNNABLE状态,或者线程虽然处于RUNNABLE状态但是其要执行的任务却一直无法进展的现象。

死锁 deadlock

两个或者更多的线程因相互等待对方而被永远暂停(blocked or waiting)的现象。

典型情形:线程A在持有锁L1的情况下申请锁L2,而线程B在持有L2的情况下申请L1,A只有在获得并释放L2后才会释放L1,而B只有在获得并释放L1后才会释放L2。因此这两个线程最终都无法获得它们申请的另外一个锁,两个线程都处于无限等待的状态。

死锁好比鹬蚌相争故事中的情形:鹬啄住蚌的肉,蚌夹住鹬的嘴。鹬对蚌说: “你先放开我的嘴我便不吸你的肉。”而蚌对鹬说:“你先放开我的肉我便不夹你的嘴。”于是最后谁也不放开谁!

产生条件(必要非充分)

  1. 资源互斥。涉及的资源必须是独占的,即每个资源一次只能够被一个线程使用。
  2. 资源不可抢夺。涉及的资源只能够被其持有线程主动释放,而无法被资源的持有者和申请者之外的第三方线程锁抢夺(被动释放)。
  3. 占用并等待资源。涉及的线程当前至少持有一个资源并申请其他资源,而这些资源恰好被其他线程持有。在这个资源等待的过程中,线程并不释放已经持有的资源。
  4. 循环等待资源。涉及的线程等待的资源形成一个循环。

规避方法

  • 粗锁法:使用粗粒度的锁代替多个锁。从而消除条件3,占用并等待资源。缺点是明显降低并发性,并可能导致资源浪费。因此适用范围比较有限。
  • 锁排序法:相关线程使用全局统一的顺序申请锁。从而消除条件4,循环等待资源。
  • 使用ReentrantLock.tryLock(long, TimeUnit)申请锁。该方法允许为锁申请操作指定超时时间,避免无限期等待其他线程的持有资源。
  • 开放调用。方法在调用外部方法时不持有任何锁。从而消除条件3,占用并等待资源。
  • 使用锁的替代品。

锁死 lockout

等待线程由于唤醒其所需的条件永远无法成立,或者其他线程无法唤醒这个线程而一直处于非运行状态导致其任务无法进展的现象。锁死好比无法醒来的睡美人,将一直沉睡下去。

  • 信号丢失锁死:由于没有相应的通知线程唤醒等待线程导致的锁死。如:wait前未判断保护条件,countDownLatch一直处于等待状态。
  • 嵌套监视器锁死:嵌套锁导致的锁死。
// 受保护方法
synchronized (monitorX) {
	synchronized (monitorY) {
		while (!flag) {
			monitorY.wait();
		}
	}
}

// 通知方法
synchronized (monitorX) {
	synchronized (monitorY) {
		flag = true;
		monitorY.notifyAll();
	}
}

饥饿 starvation

线程一直无法获得其所需的资源而导致其任务一直无法进展的现象。相当于“巧妇难为无米之炊”。

典型情形:高争用环境下非公平模式的读写锁。

活锁 livelock

线程一直处于运行状态,但是其任务却一直无法进展的现象。如同追着自己尾巴的猫。

五、 资源争用与调度

由于资源的稀缺性或者资源本身的特性,我们往往需要在多个线程间共享同一个资源。一次只能够被一个线程占用的资源被称为 排他性(Exclusive) 资源。
常见的排他性资源包括处理器、数据库连接、文件等。在一个线程占用一个排他性资源进行访问而未释放其对资源所有权的时候,其他线程试图访问该资源的现象就被称为 资源争用(Resource Contention)

资源的调度问题:在多个线程申请同一个排他性资源的情况下,决定哪个线程会被授予该资源的独占权。
资源调度策略的一个常见特性就是它能否保证 公平性(fairness)。所谓公平性,是指资源的申请者(线程)是否按照其申请(请求)资源的顺序而被授予资源的独占权。

资源调度的一种常见策略就是 排队
资源调度器内部维护一个等待队列,在存在资源争用的情况下,申请失败的资源申请者会被存入该队列。
通常,被存入等待队列的线程会被暂停。当相应的资源被其持有线程释放时,等待队列中的一个线程会被选中并被唤醒而获得再次申请资源的机会。被唤醒的线程如果申请到资源的独占权,那么该线程会从等待队列中移除;否则,该线程仍然会停留在等待队列中等待再次申请的机会,即该线程会再次被暂停。
因此,等待队列中的等待线程可能经历若干次暂停与唤醒才获得相应资源的独占权。可见,资源的调度可能导致上下文切换。

从排队的角度来看,公平的调度策略不允许插队现象的出现,即只有在资源未被其他任何线程占用,并且等待队列为空的情况下,资源的申请者才被允许抢占相应资源的独占权。因此,公平调度策略中的资源申请者总是按照先来后到的顺序来获得资源的独占权。而非公平的调度策略则允许插队现象。
由此可见,在极端的情况下非公平调度策略可能导致等待队列中的线程永远无法获得其所需的资源,即出现饥饿现象,而公平调度策略则可以避免饥饿现象。

一般来说,非公平调度策略的吞吐率较高,即单位时间内它可以为更多的申请者调配资源。其缺点是,从申请者个体的角度来看这些申请者获得相应资源的独占权所需时间的偏差可能比较大。
公平调度策略的吞吐率较低,这是其维护资源独占权的授予顺序的开销比较大(主要是线程的暂停与唤醒所导致的上下文切换)的结果。其优点是,从申请者个体的角度来看这些申请者获得相应资源的独占权所需时间的偏差可能比较小。

在非公平调度策略中,资源的持有线程释放该资源的时候等待队列中的一个线程会被唤醒,而该线程从被唤醒到其继续运行可能需要一段时间。在该时间内,新来的线程可以先被授予该资源的独占权。如果这个新来的线程占用该资源的时间不长,那么它完全有可能在被唤醒的线程继续其运行前释放相应的资源,从而不影响该被唤醒的线程申请资源。这种情形下,非公平调度策略能减少上下文切换的次数。
相反,如果多数线程占用资源的时间相当长,那么反而会导致被唤醒的线程需要再次经历暂停和唤醒,从而增加了上下文切换。

因此,在没有特别需要的情况下,我们默认选择非公平调度策略即可。在资源的持有线程占用资源的时间相对长或线程申请资源的平均间隔时间相对长的情况下,或者对资源申请所需的时间偏差有所要求的情况下,可以考虑使用公平调度策略。

六、 线程持有对象

线程持有对象:各个线程仅访问各自创建的实例, 且一个线程不能访问另外一个线程创建的实例。

线程特有对象既保障了对非线程安全对象的访问的线程安全,又避免了锁的开销。还有利于减少对象的创建次数。

ThreadLocal<T>类相当于线程访问其线程特有对象的代理,即各个线程通过 这个对象可以创建并访问各自的线程特有对象, 其类型参数 T指定了相应线程特有对象的 类型。

一个线程可以使用不同的ThreadLocal实例来创建并访问其不同的线程特有对象。
多个线程使用同一个ThreadLocal<T>实例所访问到的对象是类型 T 的不同实例,即这些线程各自的线程特有对象实例。

方法 功能
public T get() 获取当前线程的线程持有对象
public void set (T value) 重新关联线程持有对象
protected T initialValue 返回值为初始状态下当前线程的线程持有对象
public void remove() 删除该线程持有对象
final static ThreadLocal<SimpleDateFormat> SDF = new ThreadLocal<SimpleDateFormat>() {
	@Override
	protected SimpleDateFormat initialValue() {
		return new SimpleDateFormat("yyyy-MM-dd");
	}
};

问题:

  • 退化与数据错乱。使用线程特有对象,我们需要确保每个任务的处理逻辑被执行前相应的线程特有对象的状态不受前一个被执行的任务影响。这通常可以通过在任务处理逻辑被执行前为线程局部变量重新关联一个线程特有对象或者重置线 程特有对象的状态来实现。
  • 内存泄漏,伪内存泄漏。内存泄漏(memory leak)由于对象永远无法被垃圾回收导致其占用的虚拟机内存无法被释放。伪内存泄漏中对象所占用的内存在其不被使用后的相当长时间仍然无法被回收,甚至永远不会被回收。

应用场景:

  1. 需要使用非线程安全对象,但有不希望因此而引入锁。
  2. 使用线程安全对象,但希望必买年其使用的锁的开销和相关问题。
  3. 隐式参数传递。
  4. 特定与线程的单例模式。

七、 装饰器模式

装饰器(Decorator)模式可以用来实现线程安全,其基本思想是为非线程安全对象创建一个相应的线程安全的外包装对象(Wrapper), 客户端代码不直接访问非线程安全对象 而是访问其外包装对象。
外包装对象与相应的非线程安全对象具有相同的接口,因此客户端代码使用外包装对象的方式与直接使用相应的非线程安全对象的方式相同, 而外包装对象内部通常会借助锁, 以线程安全的方式调用相应非线程安全对象的同签名方法来实现其 对外暴露的各个方法。

Collections.synchronizedX(X = Set, Map...)方法使用装饰器模式将指定的非线程安全集合对象对外暴露为线程安全的对象。

public static <T> Collection<T> synchronizedCollection(Collection<T> c) {
	return new SynchronizedCollection<>(c);
}

这些同步集合的iterator方法返回的Iterator实例并不是线程安全的。

八、 并发集合

java.util.concurrent包中引入了一些线程安全的集合对象。
Java多线程(2)——线程安全

快照(snapshot):在Iterator实例被创建的那一刻待遍历对象内部的一个只读副本(不支持remove)。对同一个并发集合进行遍历操作的每个线程会得到各自的一份快照。因此快照相当于这些线程的线程持有对象。
准实时:遍历操作不是针对副本进行的,但又不借助锁来保障线程安全。支持remove

并发集合内部在保障线程安全时通常不使用锁,而是使用CAS操作,或优化的锁。





参考资料:《Java 多线程编程实战指南(核心篇)》 黄文海 著