1.线程安全性
线程安全类
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么称这个类是线程安全的。
正确性
某个类的行为与其规范完全一致。在良好的规范中通常会定义各种不变性条件(Invariant)来约束对象的状态,以及定义各种后验条件(Postcondition)来描述对象的操作结果。
比如说在Vector中,get(i)
时应该先判断i >= 0 && i < size
,否则应该抛出ArrayIndexOutOfBoundsException
异常。
不是所有的时候都会为代码编写详细的规范,所以退而求其次,以代码可信性
来间接表述代码的正确性。 在单线程程序中,正确性近似等于所见即所知
,即在正确的单线程程序中,通过输入是可以确定输出结果。那么,线程安全类也可以理解为在并发环境和单线程环境中都不会被被破坏的类,在一定程度上可以确定输出结果。
有状态对象
拥有数据存储功能的对象,拥有自己的数据域或者对其他对象的引用,通常数据不同的对象会表现出不同的状态。
比如在开发过程中使用的实体对象
、JavaBean
等对象,可以称为有状态对象,还有一些既包含数据又包含复杂操作的对象,比如List
、Map
等类型的对象。
无状态对象
不存储数据的对象,通常是一系列操作的集合。这种对象既不包含任何域,也不包含任何对其他类中域的引用。
比如在开发过程中使用的Controller
、Service
等类型的对象,这些对象只有一些操作,通常在系统中还会以单例的方式存在。
无状态对象一定是线程安全的。
在无状态类中添加一个状态时,如果该状态完全是由线程安全的对象来管理,那么这个类仍然是线程安全的。但是,当状态数量由一个变为多个时,尤为需要注意,此时并不能像状态数量由0个变为1个时那样简单的成为线程安全类。
在开发过程中,应尽可能地使用现有的线程安全对象(例如
AtomicLong
)来管理类的状态。
2.原子性
原子(atomic)操作
如果这个操作所处的层(layer)的更高层不能发现其内部实现与结构,那么这个操作就是原子操作。原子操作可以是一个步骤,也可以是多个操作步骤,但是顺序不可以被打乱,也不可以被切割而只执行其中的一部分。原子操作一旦开始执行,就一直运行到结束。将整个操作视作一个操作是原子性的的核心特征。
在Java中,把不会被线程调度所打断的操作称之为原子操作,比如说在对非long
型和非double
型变量的读写操作就是原子操作。
竞态条件(Race Condition)
由于不恰当的执行时序而出现不正确的结果。
当某个操作的正确性取决于多个线程的交替执行时序时,那么这种情况就称为为竞态条件。等同于获得正确的结果完全看运气。两种常见的竞态条件是先检查后执行(Check-Then-Act)
和读取-修改-写入
。
数据竞争
如果在访问共享的非final类型的域时没有采用同步来进行协同,那么就会出现数据竞争。
比如当一个线程写入一个变量而另一个线程接下来读取这个变量,或者读取一个之前由另一个线程写入的变量时,并且在这个两个线程之间没有使用同步,那么可能会出现数据竞争。
数据竞争会产生类似于事务操作过程中的脏读
、不可重复读
、幻读
等问题。
3.加锁机制
在线程安全性的定义要求中,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。为了保证不变性条件不被破坏,就需要使用锁来进行保护。
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
同步代码块(Synchronized Blcok)
同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。每个Java对象
都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)
,内置锁是互斥锁。
线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁(包括正常退出和抛出异常后退出)。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
重入
如果某个线程试图获取一个已经由它自己持有的锁,那么这个请求会成功。
重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1.如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。
内置锁是可重入的。
用锁来保护状态
对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,称状态变量是由这个锁保护的。每个共享的和可变的变量都应该只由一个锁保护,便于维护。
对于每个包含多个变量的不变性条件,其中涉及所有变量都需要由同一个锁来保护。
当执行时间较长的计算或者可能无法快速完成的操作时(例如网络I/O),一定不要持有锁。