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):给定处理器所感知到的该处理器及其他 处理器的内存访问操作发生的顺序。
指令重排序是一种动作,它确确实实地对指令的顺序做了调整,其重排序的对象是指令。
内存子系统重排序是一种现象,没有真正对指令执行顺序进行调整。
可见性是有序性的基础:可见性描述的是一个线程对共享变量的更新对于另外一个线程是否可见,或者说什么情况下可见的问题。有序性描述的是,一个处理器上运行的线程对共享变量所做的更新,在其他处理器上运行的其他线程看来,这些线程是以什么样的顺序观察到这些更新的问题。
有序性影响可见性。由于重排序的作用,一个线程对共享变量的更新对于另外一个线程而言可能变得不可见。
三、 上下文切换
单处理器上的多线程是通过 时间片(time slice) 分配的方式实现的。
时间片决定了一个线程可以连续占用处理器运行的时间长度。
当一个进程中的一个线程由于其时间片用完或者其自身的原因被迫或者主动暂停其运行时,另外一个线程可以被操作系统(线程调度器)选中占用处理器开始或者继续其运行。这种一个线程被暂停,即被剥夺处理器的使用权,另外一个线程被选中开始或者继续运行的过程就叫作 线程上下文切换(Context Switch)。
上下文(Context):一般包括通用寄存器(General Purpose Register)的内容和程序计数器(Program Counter)的内容。在切出时,操作系统需要将上下文保存到内存中,以便被切出的线程稍后占用处理器继续其运行时能够在此基础上进展。在切入时,操作系统需要从内存中加载被选中线程的上下文,以在之前运行的基础上继续进展。
从Java应用的角度来看,一个线程的生命周期状态在RUNNABLE
状态与非RUNNABLE
状态(包括BLOCKED
、WAITING
和TIMED_WAITING
中的任意一个子状态)之间切换的过程就是一个上下文切换的过程。
上下文切换的开销包括直接开销和间接开销。
- 直接开销:
- 操作系统保存和恢复上下文所需的开销,这主要是处理器时间开销。
- 线程调度器进行线程调度的开销。
- 间接开销:
- 处理器高速缓存重新加载的开销。一个被切出的线程可能稍后在另外一个处理器上被切入继续运行。由于这个处理器之前可能未运行过该线程,那么这个线程在其继续运行过程中需访问的变量仍然需要被该处理器重新从主内存或者通过缓存一致性协议从其他处理器加载到高速缓存之中。这是有一定时间消耗的。
- 上下文切换可能导致整个一级高速缓存中的内容被冲刷,即一级高速缓存的内容会被写入下一级高速缓存或者主内存中。
一次上下文切换的时间消耗是微秒级的。
多线程编程相比于单线程编程来说,它意味着更多的上下文切换。因此,多线程编程不一定就比单线程编程的计算效率更高。
四、 线程的活性故障
线程活性故障(Liveness Failure):由资源稀缺性或者程序自身的问题和缺陷导致线程一直处于非RUNNABLE
状态,或者线程虽然处于RUNNABLE
状态但是其要执行的任务却一直无法进展的现象。
常见的活性故障:
- 死锁(Deadlock):死锁产生的典型场景是一个线程X持有资源A的时候等待另外一个线程释放资源B,而另外一个线程Y在持有资源B的时候却等待线程X释放资源A。死锁的外在表现是当事线程的生命周期状态永远处于非RUNNABLE状态而使其任务一直无法进展。
- 锁死(Lockout):锁死就好比睡美人的故事中睡美人醒来的前提是她要得到王子的亲吻,但是如果王子无法亲吻她,那么睡美人将一直沉睡!
- 活锁(Livelock):活锁的外在表现是线程可能处于RUNNABLE状态,但是线程所要执行的任务却丝毫没有进展,即线程可能一直在做无用功。
- 饥饿(Starvation):线程因无法获得其所需的资源而使得任务执行无法进展的现象。
五、 资源争用与调度
由于资源的稀缺性或者资源本身的特性,我们往往需要在多个线程间共享同一个资源。一次只能够被一个线程占用的资源被称为 排他性(Exclusive) 资源。
常见的排他性资源包括处理器、数据库连接、文件等。在一个线程占用一个排他性资源进行访问而未释放其对资源所有权的时候,其他线程试图访问该资源的现象就被称为 资源争用(Resource Contention)。
资源的调度问题:在多个线程申请同一个排他性资源的情况下,决定哪个线程会被授予该资源的独占权。
资源调度策略的一个常见特性就是它能否保证 公平性(fairness)。所谓公平性,是指资源的申请者(线程)是否按照其申请(请求)资源的顺序而被授予资源的独占权。
资源调度的一种常见策略就是 排队。
资源调度器内部维护一个等待队列,在存在资源争用的情况下,申请失败的资源申请者会被存入该队列。
通常,被存入等待队列的线程会被暂停。当相应的资源被其持有线程释放时,等待队列中的一个线程会被选中并被唤醒而获得再次申请资源的机会。被唤醒的线程如果申请到资源的独占权,那么该线程会从等待队列中移除;否则,该线程仍然会停留在等待队列中等待再次申请的机会,即该线程会再次被暂停。
因此,等待队列中的等待线程可能经历若干次暂停与唤醒才获得相应资源的独占权。可见,资源的调度可能导致上下文切换。
从排队的角度来看,公平的调度策略不允许插队现象的出现,即只有在资源未被其他任何线程占用,并且等待队列为空的情况下,资源的申请者才被允许抢占相应资源的独占权。因此,公平调度策略中的资源申请者总是按照先来后到的顺序来获得资源的独占权。而非公平的调度策略则允许插队现象。
由此可见,在极端的情况下非公平调度策略可能导致等待队列中的线程永远无法获得其所需的资源,即出现饥饿现象,而公平调度策略则可以避免饥饿现象。
一般来说,非公平调度策略的吞吐率较高,即单位时间内它可以为更多的申请者调配资源。其缺点是,从申请者个体的角度来看这些申请者获得相应资源的独占权所需时间的偏差可能比较大。
公平调度策略的吞吐率较低,这是其维护资源独占权的授予顺序的开销比较大(主要是线程的暂停与唤醒所导致的上下文切换)的结果。其优点是,从申请者个体的角度来看这些申请者获得相应资源的独占权所需时间的偏差可能比较小。
在非公平调度策略中,资源的持有线程释放该资源的时候等待队列中的一个线程会被唤醒,而该线程从被唤醒到其继续运行可能需要一段时间。在该时间内,新来的线程可以先被授予该资源的独占权。如果这个新来的线程占用该资源的时间不长,那么它完全有可能在被唤醒的线程继续其运行前释放相应的资源,从而不影响该被唤醒的线程申请资源。这种情形下,非公平调度策略能减少上下文切换的次数。
相反,如果多数线程占用资源的时间相当长,那么反而会导致被唤醒的线程需要再次经历暂停和唤醒,从而增加了上下文切换。
因此,在没有特别需要的情况下,我们默认选择非公平调度策略即可。在资源的持有线程占用资源的时间相对长或线程申请资源的平均间隔时间相对长的情况下,或者对资源申请所需的时间偏差有所要求的情况下,可以考虑使用公平调度策略。