多线程编程中经常遇到的一个问题就是对于同样的输入,程序的输出有时候是正确的而有时候却是错误的。这种一个计算结果的正确性与时间有关的现象就被称为竞态(RaceCondition)。
二维表分析法:解释竞态的结果术语定义 状态变量(StateVariable):即类的实例变量、静态变量。 共享变量(SharedVariable):即可以被多个线程共同访问的变量。共享变量中的“共享”强调的是“可以被共享”的可能性,因此称呼一个变量为共享变量并不表示该变量一定会被多个线程访问。状态变量由于可以被多个线程共享,因此也被称为共享变量。
竞态(RaceCondition)是指计算的正确性依赖于相对时间顺序(RelativeTiming)或者线程的交错(Interleaving)。根据这个定义可知,竞态不一定就导致计算结果的不正确,它只是不排除计算结果时而正确时而错误的可能。
出竞态的两种模式:read-modify-write(读—改—写)和check-then-act(检测而后行动)。
read-modify-write(读—改—写)操作,该操作可以被细分为这样几个步骤:读取一个共享变量的值(read),然后根据该值做一些计算(modify),接着更新该共享变量的值(write)。
check-then-act(检测而后行动)操作,该操作可以被细分为这样几个步骤:读取某个共享变量的值,根据该变量的值决定下一步的动作是什么。
synchronized关键字 使其修饰的方法在任一时刻只能够被一个线程执行,这使得该方法涉及的共享变量在任一时刻只能够有一个线程访问(读、写),从而避免了这个方法的交错执行而导致的干扰,这样就消除了竞态。
线程安全问题概括来说表现为3个方面:原子性、可见性和有序性。
原子性 原子(Atomic)的字面意思是不可分割的(Indivisible)。对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,相应地我们称该操作具有原子性(Atomicity)。 所谓“不可分割”,其中一个含义是指访问(读、写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,即其他线程不会“看到”该操作执行了部分的中间效果。 访问同一组共享变量的原子操作是不能够被交错的,这就排除了一个线程执行一个操作期间另外一个线程读取或者更新该操作所访问的共享变量而导致的干扰(读脏数据)和冲突(丢失更新)的可能。这就是“不可分割”的第二个含义。
总的来说,Java中有两种方式来实现原子性。一种是使用锁(Lock)。锁具有排他性,即它能够保障一个共享变量在任意一个时刻只能够被一个线程访问。这就排除了多个线程在同一时刻访问同一个共享变量而导致干扰与冲突的可能,即消除了竞态。
另一种是利用处理器提供的专门CAS(Compare-and-Swap)指令,CAS指令实现原子性的方式与锁实现原子性的方式实质上是相同的,差别在于锁通常是在软件这一层次实现的,而CAS是直接在硬件(处理器和内存)这一层次实现的,它可以被看作“硬件锁”。
long型和double型以外的任何类型的变量的写操作都是原子操作,即对基础类型(long/double除外,仅包括byte、boolean、short、char、float和int)的变量和引用型变量的写操作都是原子的。这点由Java语言规范(JLS,JavaLanguageSpecification)规定,由Java虚拟机具体实现。Java语言规范特别地规定对于volatile关键字修饰的long/double型变量的写操作具有原子性。
可见性
如果一个线程对某个共享变量进行更新之后,后续访问该变量的线程可以读取到该更新的结果,那么我们就称这个线程对该共享变量的更新对其他线程可见,否则我们就称这个线程对该共享变量的更新对其他线程不可见。可见性就是指一个线程对共享变量的更新的结果对于读取相应共享变量的线程而言是否可见的问题。多线程程序在可见性方面存在问题意味着某些线程读取到了旧数据(StaleData),而这可能导致程序出现我们所不期望的结果。
有序性(Ordering)
指在什么情况下一个处理器上运行的一个线程所执行的内存访问操作在另外一个处理器上运行的其他线程看来是乱序的(Outoforder)。
上下文切换 自发性上下文切换指线程由于其自身因素导致的切出。从Java平台的角度来看,一个线程在其运行过程中执行下列任意一个方法都会引起自发性上下文切换。
- Thread.sleep(longmillis)
- Object.wait()/wait(longtimeout)/wait(longtimeout,intnanos)
- Thread.yield()[14]
- Thread.join()/Thread.join(longtimeout)
- LockSupport.park()
- I/O操作
上下文切换的开销 从定性的角度来说,上下文切换的开销包括直接开销和间接开销。其中, 直接开销包括:
- 操作系统保存和恢复上下文所需的开销,这主要是处理器时间开销。
- 线程调度器进行线程调度的开销
间接开销包括:
- 处理器高速缓存重新加载的开销。一个被切出的线程可能稍后在另外一个处理器上被切入继续运行。由于这个处理器之前可能未运行过该线程,那么这个线程在其继续运行过程中需访问的变量仍然需要被该处理器重新从主内存或者通过缓存一致性协议从其他处理器加载到高速缓存之中。这是有一定时间消耗的。
- 上下文切换也可能导致整个一级高速缓存中的内容被冲刷(Flush),即一级高速缓存中的内容会被写入下一级高速缓存(二级高速缓存或RAM中)
线程的活性故障
- 死锁:死锁产生的典型场景是一个线程X持有资源A的时候等待另外一个线程释放资源B,而另外一个线程Y在持有资源B的时候却等待线程X释放资源A。死锁的外在表现是当事线程的生命周期状态永远处于非RUNNABLE状态而使其任务一直无法进展。
- 锁死(Lockout)。锁死就好比睡美人的故事中睡美人醒来的前提是她要得到王子的亲吻,但是如果王子无法亲吻她(比如王子“挂了”……),那么睡美人将一直沉睡!
- 活锁(Livelock)。活锁好比小猫试图咬自己的尾巴,虽然它总是追着自己的尾巴咬,但却始终无法咬到。活锁的外在表现是线程可能处于RUNNABLE状态,但是线程所要执行的任务却丝毫没有进展,即线程可能一直在做无用功。
- 饥饿(Starvation)。饥饿好比母鸟给雏鸟喂食的情形,健壮的雏鸟总是抢先从母鸟的嘴中抢到食物,从而导致那些弱小的雏鸟总是挨饿。饥饿就是线程因无法获得其所需的资源而使得任务执行无法进展的现象。