线程安全性
什么是线程安全性
线程是CPU执行的基本单位,进程是CPU分配的基本单位。
多线程访问某个类时,不管运行环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或者异步,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
示例:一个无状态的Servlet
1 | //Servlet容器是一个单例多线程的容器对象 |
StatelessServlet
是无状态的,它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态存储在线程栈帧的局部变量表中,并且只能由正在执行的线程访问。
访问StatelessServlet
的线程不会影响另一个访问StatelessServlet
的线程计算结果。由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此,无状态对象一定是线程安全的。
原子性
当我们在无状态对象中增加一个状态时,会出现什么情况?假设我们希望增加一个 “命中计数器” 来统计所处理的请求数量。一种直观的方法是增加一个long
类型的域,并且每次处理一个请求就将这个值加1。
1 |
|
不幸的是,UnsafeContingServlet
并非线程安全的,尽管它在单线程环境中能够正确运行,这个类可能会丢失一些更新操作,++count
并非原子的,它包含了三个独立的操作 :读取count
值,将值加1,然后将计算结果写入count
。这是一个 “读取-修改-写入” 的操作,并且其结果状态依赖于之前的状态。
上述例子给出了两个线程在没有同步的情况下,同时对一个计数器执行递增操作时发生的情况。如果计数器的初始值为9,那么在某些情况下,每个线程读到的值都为9,接着执行递增操作,并且都将计数器的值设置为10。命中计数器的值就讲偏差1。
竟态条件
在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竟态条件(Race Condition)。
最常见的竟态条件类型就是 “先检查后执行(Check-then-Act)” 操作,即通过一个可能失效的观测结果来决定下一步的动作。
1 |
|
在LazyInitRace
中包含一个竟态条件,它可能会破坏这个类的正确性。假定 线程A 和 线程B 同时执行getInstance
,A看到instance
为空,因而创建一个新的Object
实例,B同样需要判断instance
是否为空,此时的instance
是否为空,取决于不可预测的时序,包括线程的调度方式,以及A需要花多长时间来初始化Object
并设置instance
,如果当B检查时,instance
为空,那么两次调用getInstance
时可能会得到不同的结果。
在UnsafeCountingServlet
的统计命中计数器中存在另一种竟态条件。在 “读取-修改-写入” 操作中,基于对象之前的状态来定义对象状态的转换,要递增一个计数器,你必须知道它之前的值,并确保在执行更新的过程中没有其他线程会修改或使用这个值。
1 |
|
在java.util.concurrent.atomic
包中包含一些原子变量类,通过用AtomicLong
来代替long
类型的计数器,能够确保所有对计数器状态的访问都是原子的。
内置锁
Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block
),同步代码块包括两个部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。
以关键字synchronized
来修饰方法就是一种横跨整个方法体的同步代码快,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized
方法以Class对象作为锁。
1 | synchronized (lock) { |
每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁或监视器锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。
Java的内置锁相当于一种互斥锁,最多只有一个线程能够持有这种锁,当 线程A 尝试获取一个由 线程B 持有的锁时,线程A 必须等待或者阻塞,直到 线程B 释放这个锁,如果B永远不释放锁,那么A也将永远地等下去。
重入
当某个线程请求一个由其他线程持有的锁时,发出的请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它持有的锁,那么这个请求就会成功。
“重入”获取锁的操作粒度是“线程”,重入的实现方法是,为每一个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有,当线程请求一个未被持有的锁时,JVM将几下锁的持有者,并且将获取计数值置为1,如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码快时,计数器会相应递减,当计数值为0时,这个锁将被释放。
1 | public class Father { |
子类改写了父类的synchronized
方法,然后调用父类中的方法,由于Father
和Son
都是synchronized
方法,因此每个method
方法在执行前都会获取Father
上的锁,然而如果内置锁不是可重入的,那么在调用super.method()
时将无法获得Father
上的锁。
对象的共享
可见性
1 |
|
1 | public static void main(String[] args) { |
输出结果:
1 | 100-Thu Apr 16 16:17:04 CST 2020 |
MutableInteger
不是线程安全的,因为 get 和 set 都是在没同步的情况下访问 value 的,如果某个线程调用了set,那么另一个正在调用 get 的线程可能会看到更新后的 value 值,也可能看不到。
1 |
|
Volatile变量
volatile
变量用来确保将变量的更新操作通知到其他线程,当把变量申明为volatile
类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序,因此在读取volatile
类型的变量时总会返回最新写入的值。
1 |
|
然而在访问volatile
时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile
变量是一种比synchronized
关键字更轻量级的同步机制。
从内存的可见性角度来看,写入volatile
变量相当于退出同步代码块,而读取volatile
变量就相当于进入同步代码块,但并不建议过度依赖volatile
变量提供的可见性,如果在代码中依赖volatile
变量来控制状态的可见性,通常比使用锁的代码更脆弱,更难以理解。
尽管volatile
只能确保可见性,在复合操作情况下,无法确保其正确性,如count++
,具备了”读取-修改-写入”的复合操作。
加锁机制既可以确保可见性又可以确保原子性,而volatile
变量只能确保可见性。
当且仅当满足以下所有条件时,才应该使用volatile
变量:
- 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其他状态变量一起纳入不变性条件中。
- 在访问变量时不需要加锁。
逸出
提供一个对象的引用给作用域之外的代码,我们称做发布该对象。如果在对象构造完成之前就发布该对象,就会破坏线程安全性,当某个不应该发布的对象被发布时,这种情况被称为逸出。
1 | public class ThisEscape { |
在ThisEscape
中给出了一个this引用在构造函数中逸出的示例。this引用逸出的一个常见错误是,在构造函数中启动一个线程,当对象在其构造函数中创建一个线程时,this引用都会被新创建的线程共享,在对象未完全构造之前,新的线程就可以看见它。在构造函数创建线程并没有错误,但最好不要立即启动它。
当且仅当对象的构造函数返回时,对象才处于可预测和一致的状态。当从构造函数中发布对象时,只是发布了一个尚未构造完成的对象,即使发布语句位于构造函数的最后一行,那么这种对象被认为是不正确构造。
不要再构造过程中使this引用逸出。
线程封闭
当访问共享的可变数据时,通常需要使用同步,一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭(Thread Confinement
),它是实现线程安全性的最简单方式之一。
栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象,局部变量固有属性就是封闭在执行线程中,他们位于执行线程的栈中,其他线程无法访问这个栈。
1 | public int execute() { |
此时只有一个引用指向集合list,这个引用被封闭在局部变量中,因此也被封闭在执行线程中,然而如果发布了对集合list(或者该对象中的任何内部数据)的引用,那么封闭性呗破坏,并导致对象list逸出。
不变性
如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。线程安全性是不可变对象的固有属性之一,他们的不变性条件是由构造函数创建的,不可变对象一定是线程安全的。
不可变性不等于将对象所有域都声明为final
类型,即使对象中所有域都是final
类型的,这个对象也仍然是可变的,因为final
类型的域中可以保存对可变对象的引用。
1 | public final class MutableClass { |
当满足以下条件时,对象才是不可变的:
- 对象创建以后其状态就不能修改。
- 对象的所有域都是
final
类型。 - 对象是正确创建的(在对象的创建期间,this引用没有逸出)
1 | public final class ImmutableClass { |
即使对象是可变的,通过将对象的某些域声明为final
类型,仍然可以简化对状态的判断,相当于限制了该对象可能的状态变化。
除非需要更高的可见性,否则应将所有域都声明为私有域是一个良好的变成习惯,除非需要某个域是可变的,否则应将其声明为final
域,也是一个良好的变成习惯。
要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见,一个正确构造的对象可以通过以下方式来安全地发布:
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存到
valatile
类型的域或者AtomicReferance
对象中 - 将对象的引用保存到某个正确的构造对象的
final
类型域中。 - 将对象的引用保存到一个由锁保护的域中。
在线程安全容器内部同步意味着,将对象放到某个容器,例如Vector
或者synchronizedList
时将满足上述最后一条需求。
线程安全库中的容器提供了以下的安全发布保证:
- 通过将一个键或者值放入
Hashtable
、synchronizedMap
或者ConcurrentMap
中,可以安全将它发布给任何从这些容器中访问它的线程。 - 通过将某个元素放入
Vector
、CopyOnWriteArrayList
、CopyOnWriteArraySet
、SynchronizedList
或者synchronizedSet
中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。
事实不可变对象
如果对象从技术上来说事可变的,但其状态在发布后不会再改变,那么把这种对象称之为事实不可变对象。这些对象不需要满足不可变性的严格定义,在这些对象发布后,程序只需将它们视为不可变对象即可。
在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。
可变对象
如果对象在构造后可以修改,那么安全发布只能确保 “发布当时” 状态的可见性,对于可变对象,不仅在发布对象时需要使用同步,而且每次对象访问时同样需要使用同步来确保后续修改操作的可见性。要安全地共享可变对象,这些对象就必须被安全地发布,并且必须是线程安全的或者由某个锁保护起来。
对象发布需求取决于它的可变性:
- 不可变对象可以通过任意机制来发布。
- 事实不可变对象必须通过安全方式来发布。
- 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来的。
“本篇文章主要摘自《JAVA 并发编程实战》”
最后更新: 2020年09月24日 18:37