明日复明日,明日何其多?我生待明日,万事成蹉跎。———《明日歌》
本文介绍线程安全性相关的概念,包括原子性,竞态条件,复合操作,内置锁等,通过这些术语的介绍逐步铺开线程安全的相关知识,了解在哪些情况下应当用内置锁,哪些情况下用线程安全类就足够了。同时,说明应过多的同步会引起程序的活跃度和性能问题。
对于要编写线程安全的代码,核心在于对状态的访问操作进行管理,特别是对共享的和可变的状态的访问。
共享,表示可以由多个线程同时访问;可变,表示变量的值在其生命周期内可以发生变化。
Java 中的主要同步机制是关键字 synchronized,它提供了一种独占的加锁方式。当然,同步还包括 volatile 类型的变量,显示锁以及原子变量。
什么是线程安全性
在线程安全性的定义中,最核心的概念就是正确性。当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类时线程安全的。无状态对象一定是线程安全的。比如下面例子,不包含任何域,也不包含任何对其他类中域的引用:
1@TreadSafe
2public class StatelessFactorizer implements Servlet{
3 public void service(ServletRequest req, ServletResponse resp){
4 BigInteger i = extractFromRequest(req);
5 BigInteger [] factors = factor(i);
6 encodeIntoresponse(resp, factors)
7 }
8}
原子性
众所周知,原子是构成物质的基本单位,所以原子代表的意思是[不可分]。在多线程程序中原子操作是一个非常重要的概念,它常常用来实现一些同步机制。
竞态条件
当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。常见的竞态条件类型就是[先检查后执行]操作,即通过检查一个可能失效的结果来决定下一步的动作。比如下面例子,延迟初始化中的竞态条件:
1@NotThreadSafe
2public class LazyInitRace{
3 private ExpensiveObject instance = null;
4 public ExpensiveObject getInstance(){
5 if(instance == null)
6 instance = new ExpensiveObject()
7 return instance;
8 }
9}
假如线程 A 和 B,同时执行 getInstance() ,并都判断 instance 为空,那么就会创建出两个不同的对象,不是我们预期想要的结果,可能会产生严重的问题。当然,这种需要某种不恰当的执行时序才会产生。
复合操作
对于 value++ 这种递增复合操作,即[读取-修改-写入],必须以原子方式执行,才能确保线程安全性。如何确保是原子操作呢?一种方式是加锁,另一种是采用一个线程安全类来实现。先来看线程安全类的方式:
1@ThreadSafe
2public class SafeSequene{
3 private final AtomicLong value = new AtomicLong(0);
4 //返回一个唯一的数值
5 public long getNext(){
6 return value.incrementAndGet();
7 }
8}
在 java.util.concurrent.atomic 包中,包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过用 AtmoicLong 来代替 long 类型的计数器,能够确保所有对计数器状态的访问都是原子的。因此也是线程安全的。
![](/qrcode.jpg)
在实际情况中,应尽可能地使用现有的线程安全对象来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为容易,从而也更容易维护和验证线程安全性。
加锁机制
假设我们创建一个类,包含两个变量并且让其自增,目的是让两个变量的值一直相等,并通过 isEqual() 来检验。如下所示,我们理想结果是每次调用 isEqual() 都返回 true。然而,我们能够保证返回的就是 true 吗?
1@NotThreadSafe
2public class CompareValue{
3 private final AtomicLong value1 = new AtomicLong(0);
4 private final AtomicLong value2 = new AtomicLong(0);
5 //返回连个数值是否相等
6 public boolean isEqual(){
7 return value1.incrementAndGet() == value2.incrementAndGet();
8 }
9}
实际上,这种方式并不能保证 isEqual 一定返回 true,即使是采用了线程安全类 AtmoicLong。原因是这里涉及多个变量,各个变量是彼此独立的,我们只能保证变量各自的自增操作是原子的;不能保证两个变量同时以原子的方式一起自增。线程 A 在获取 value1 后,可能线程 B 又同时修改了 value2。导致 A 获取到的是 B 修改后的值。
所以,要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
内置锁
Java 提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包含两部分:一个作为锁的对象引用;一个作为有这个锁保护的代码块。如:
1synchronized (lock) {
2 //访问或修改有锁保护的共享状态
3}
每个 Java 对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视锁(Monitor Lock)。线程进入同步代码块自动获得锁,退出同步代码块自动释放锁。
Java 的内置锁是一种互斥锁,这表示最多只有一个线程能持有这种锁。当线程 A 尝试获取线程 B 持有的锁时,线程 A 必须等待或者阻塞,知道 B 释放了这个锁。
这种同步机制,使得上节提到的例子,CompareValue 变得安全,能确保两个变量永远相等。线程 A 调用 isEqual() 时,线程 B 必须等待知道线程 A 调用完成释放锁。
1@NotThreadSafe
2public class CompareValue{
3 private final AtomicLong value1 = new AtomicLong(0);
4 private final AtomicLong value2 = new AtomicLong(0);
5 //返回一个唯一的数值
6 public synchronized boolean isEqual(){
7 return value1.incrementAndGet() == value2.incrementAndGet();
8 }
9}
重入
内置锁是可重入的,当某个线程试图获取一个已经有它持有的锁时,他是可以成功获取的。换句话说,重入意味着获取锁的操作粒度是[线程],而不是[调用]。
重入进一步提升了加锁行为的封装性,简化了面向对象并发代码的开发。举个例子,子类改写了父类的 synchronized 方法,然后调用父类的方法,此时如果不支持可重入锁,那么这段代码将产生死锁!!
1//如果内置锁不是可重入的,那么这段代码将发生死锁
2public class Base {
3 public synchronized void doSomething() {
4 ....
5 }
6}
7public class Child extends Base{
8 public synchronized void doSomething() {//重写
9 super.doSomething();//直接调用父类方法
10 }
11}
用锁来保护状态
由于锁能使其保护的代码路径以串行的形式来访问,因此可以通过所来构造一些协议以实现对共享状态的独占访问。对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁。在这种情况下,我们称状态变量是由这个锁来保护的。并且,每个共享和可变的状态变量应该只由一个锁来保护。
一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。许多线程安全类都使用这种模式,比如 Vector 和其他同步容器类。
另外,如果将每个方法都声明为 sychronized,也不足以保证在 Vector 上执行的符合操作是原子的!!
1if (!vector.contains(element))
2 vector.add(element);
如上所示,尽管 contains 和 add 两个方法都是原子的,但是尝试[缺少-加入]操作仍然存在竞争条件。所以,把多个操作组成一个复合操作时,仍然需要额外的锁来保证线程安全性。
活跃度与性能
如上所说,如果对于所有方法,我们都声明为 sychronized,那么每次只能有一个线程去执行它,而其他所有线程都必须等待。这会导致活跃度和性能问题。
解决的办法可以是,通过缩小 synchronized 块的范围,来提升并发性。同时,也要防止将一个原子操作分配到多个 synchronized 块中。
决定 sychronized 块的大小需要权衡各种设计要求,包括安全性、简单性、性能。通常简单性和性能之间是互相牵制的,实现一个同步策略时,不要过早的为了性能而牺牲简单性。
当使用锁时,你应该清楚块中的代码的功能,以及他的执行是否会耗时。如果长时间的占有锁,就会引起活跃度和性能风险的问题。有些耗时的操作,比如网络或控制台I/O,这些难以快速完成的任务,这期间不要占有锁。
本文原创首发于微信公众号 [ 林里少年 ],欢迎关注第一时间获取更新。
![]()