并发编程之同步互斥篇

并发编程之基础问答篇中我对并发编程的概念、优势、应用场景、注意事项等做了整体上的大概介绍,文章最后提出了我从实际编程的角度对并发编程的理解:多线程的并发执行,线程间的通信和线程间对共享状态的同步与互斥。虽然说多线程并发执行是另外两方面的基础,但线程间对共享状态的同步互斥是并发编程的正确性保障,也会在另外两方面的讲解和例子中大量用到,再者鉴于本系列博客的期望读者是已经掌握并发编程最最基本使用的开发者(知道Runnable,Thread创建线程就好),因此在这三方面中我将首先介绍第三点:线程间对共享可变状态的同步与互斥。

线程安全性是以正确性为核心的,要编写线程安全的代码的核心就在于要对状态访问操作进行正确的管理,这里的状态就是指线程间共享且可变的状态,对这些状态的管理主要就是确保状态的正确同步与互斥。
首先先熟悉一下相关的基本概念:

  • 共享状态:这里所说的状态就是指变量的值,共享表示这个值可能会被多个线程同时访问(包括读取和修改),这里的状态不只包括自身,还可能会包括其他依赖对象的域。比如某个对象拥有一个List<Map<String,Person>>类型的对象,那么这个对象的状态不仅包括这个集合值,还包括他的每一个元素即Map的状态,而每个Map的状态还包括每个元素的Map.Entry<String,Person>状态,这其中自然还包括了这个对象内部拥有的所有状态
  • 同步:同步是指程序用于控制不同线程之间操作发生相对顺序的机制,同步的目的是确保共享状态的在线程间的可见性,即当一个线程改变共享状态后下一个访问该共享状态的线程能拿到最新的正确值,而不是之前已经过期的值
  • 互斥:保证共享状态在某一特定时刻内只能由一个线程访问

而在Java中实际上同步和互斥并不能严格区分开来讲,它们是相辅相成的,阅读完下面的内容相信你一定能理解我为什么这么说了。

首先看一个例子,假如我们要统计Servlet中某个函数的调用频率,即要对这个方法的调用计数,不考虑多线程的话我们可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class UserInfoServlet extends Servlet{

private int mRegisterCount = 0; //注册函数调用计数

public void register(Bundle savedInstanceState) {
//注册具体操作
++mRegisterCount;
}

public int getRegisterCount(){
return this.mRegisterCount;
}
}

如果是单线程执行即访问这段代码在任意时刻最多只有一个调用者时,上述代码并不会有什么问题,因为单线程执行的话就是按照重排序的指令按序执行的,而一旦有多个调用者同时调用register函数就会出现问题结果序列可能会出现重复和回退的情况:1,2,2,3…为什么会出现这种情况呢,我们先由指令的执行序列分析一下。
首先要明确++mRegisterCount其中包含了三个基本操作:取值、计算、写入,当只有一个调用者执行时,操作序列就是按序执行的而且取值的寄存器和写入值的寄存器是相同的,而一旦多线程同时执行时,进程中就有多个同样顺序的操作序列执行:

这只是程序执行时可能出现的一种情况,一旦类似情况出现的话程序的结果很可能就是错误的,这个例子的错误原因就在于++mRegisterCount并不是单个操作,它是一组操作的复合,当多个线程同时执行这组操作的时候某个时刻每个线程都可能在任一个阶段,不同的执行时序使得最后的结果不尽相同,最后可能由于几个线程都用相同的值+1使最终结果远远小于实际结果。这种由于不恰当的时序关系而出现不正确结果的情况叫做“竞态条件”,这就涉及到了互斥的一个核心概念:原子性。

  • 原子性

    原子性是指一组操作在外界看来是作为一个整体执行的,即要么全部执行,要么一条指令也不执行,这和数据库的原子性的定义是一致的,原子性保证了组合操作可以作为一个整体执行,在这段代码执行期间,其他访问它所持有共享状态的线程都需要等待。在上面的例子中如果能保证每个线程执行++mRegisterCount时不被其他线程干扰,并且本线程更新完的值能够被下一执行线程感知,就能得到正确的结果,这就需要 对共享状态的互斥和同步。

  • 加锁机制

    原子性如何保证呢,最常用的做法是加锁机制。加锁可以认为是先把共享状态锁定,只有持有该共享状态外部锁对应钥匙的线程才能执行这段被锁起来的代码访问加锁的状态,而其他没有拿到钥匙的线程如果想要访问该被锁定的状态时要等待正在持有钥匙的线程退出后把钥匙还回去才可能获得钥匙执行锁住的代码。
    加锁在Java中对应synchronized关键字,它对某个状态加锁,并指定在某段代码区域内才对此状态加锁,这段代码就是该共享状态对应的一个临界区,也叫做共享代码块,它包括两部分,一个是作为锁的对象引用,一个是由这个锁保护的代码块,以上述统计调用次数的多线程版本为例,我们使用加锁机制来实现线程安全的操作:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16

    public class Test extends Servlet{

    private int mRegisterCount = 0;

    public void register(Bundle savedInstanceState) {
    //注册具体操作
    synchronized (this) { //使用对象内置锁,锁定整个对象的状态
    ++mRegisterCount;
    }
    }

    public synchronized int getRegisterCount(){
    return this.mRegisterCount;
    }
    }

    这里我使用的是状态所属对象的锁,其实每个Java对象都可以作为一个实现同步的锁,这些锁被称为“内置锁”或者“监视器锁”,线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出,获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。Java中的内置锁相当于是一种互斥锁,即最多只有一个线程能持有这种锁,当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁,如果B永远不释放锁,那么A也将永远等下去。这也是锁在实际使用时很容易犯的一个错误。由于任意时刻只能有一个线程执行内置锁保护的代码块,那么由这个锁保护的同步代码块自然就会以原子方式执行,多线程在执行该代码块时也不会被相互干扰。
    通过上述对Test对象加锁,实现了 读取mRegisterCount值 -> 对mRegisterCount+1 -> 将+1后的值写入mRegisterCount 之一复合操作的原子性从而保证了最后结果的正确执行。

  • 内置锁的可重入性

    Java的内置锁还有一个特性:可重入性。即如果一个线程试图获取一个已经由它持有的锁时这个请求会成功。这也反映出了Java中获取锁的粒度是线程,而不是调用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class Test extends Servlet{

    private int mRegisterCount = 0;

    public void register(Bundle savedInstanceState) {
    //注册具体操作
    synchronized (this) { //使用对象内置锁,锁定整个对象的状态
    ++mRegisterCount;
    System.out.println("count="+getResisterCount()); //测试可重入性
    }
    }

    public synchronized int getRegisterCount(){
    return this.mRegisterCount;
    }
    }

    在上面的代码中我们增加了一行通过另一同步方法来读取共享状态mRegisterCount,如果说内置锁不是可重入的,那在register中就会发生持续阻塞,因为既然线程如果能进入register的同步代码块,就说明它已经拿到了内置锁,而getRegisterCount也要取得相同的内置锁,它就要等待register方法执行完释放锁,但是由于getRegisterCount方法还在一直等待获得锁,造成register方法不会执行完,由此整个线程就阻塞在这里。而实际上经过我们的测试其实程序是可以正常执行的,这就证明了上面的结论。

  • 内存可见性

    synchronized并不只是保证了组合操作的原子性,试想即便线程间不能同时访问同步代码块,但上一线程执行完的结果不能被下一线程感知,即下一线程不能读取到最新的状态时,结果还是不正确的,这就涉及到了共享状态的内存可见性问题。内存可见性是一种比较复杂的属性,而且它很多时候不容易被直观观察出来。我们所说的共享状态的同步与互斥不仅要防止一个线程在读取状态而另一个线程在修改状态,还要确保线程修改了状态之后其他线程能够看到状态的变化。
    影响内存可见的原因涉及到了java内存模型中的重排序:编译器重排序、指令重排序、内存重排序。这里有一篇文章深入理解java内存模型介绍了java的内存模型,我们只拿其中的结论来说,重排序解释了为何在多线程执行时会出现线程间不可见的问题的原因,happens-before原则则给定了能够确保线程间可见性的情况,这就是我们在代码的角度来保证这种可见性的依据。

    假设我们希望能够让一个线程一直执行直到某个状态发生变化,可以在线程内部加一个状态变量来控制线程的终止:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class NoVisiableThread extends Thread{
    private boolean canRun = true;
    @Override
    public void run(){
    while(canRun){
    System.out.println("runnning");
    }
    }
    public voi setCanRun(boolean canRun){
    this.canRun = canRun;
    }
    }

    当外部调用者启动线程之后线程会持续执行,而按照以上的设计思路持有线程的对象如果想终止线程的执行的话很可能会设置canRun为false:noVisiableThread.setCanRun(false),然而这种方式并不一定成功,因为当外部线程更改了状态后,并没有什么有效的机制来保证下次循环时能读取到最新状态,这就导致线程可能还会一直执行。为了保证内存可见性,可以使用锁实现,也可以用更轻量级的volatile实现。
    volatile意为“易变的”,是Java中的一个修饰符。作为上述”happens-before”原则之一,当变量生声明为volatile类型时,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,它可以保证修饰的值都从主存中读取,修改后也马上写入主存,从而实现相对锁同步较弱的一种同步。
    因此上述代码只需将canRun修饰为volatile的变量就可以实现实时的停止和恢复该线程的执行了。即:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class NoVisiableThread extends Thread{
    private volatile boolean canRun = true;
    @Override
    public void run(){
    while(canRun){
    System.out.println("runnning");
    }
    }
    public voi setCanRun(boolean canRun){
    this.canRun = canRun;
    }
    }

    volatile使用起来非常简单,较synchronized来说也更轻量,因为他避免了上下文的切换和调度,但是鉴于它的实现机制,仅当volatile变量能简化代码的实现以及对同步策略的验证时才可以使用它。volatile变量使用的正确方式包括:确保他们自身状态的可见性、确保他们所引用对象的状态的可见性以及标识一些重要的程序生命周期事件的发生(例如初始或关闭),只有当下面的所有条件都被满足时才应该使用volatile变量:

    • 对变量的写入操作不依赖变量的当前值,或者能确保只有单个线程能改变该变量的值;
    • 该变量不会与其他状态变量一起纳入不变性条件中
    • 在访问变量时不需要加锁
  • 线程封闭

    以上介绍了如何确保共享变量同步与互斥的一些方法,这些是必须掌握的,但有些情况下我们可以考虑如何避免对状态的共享,即将问题还原到单线程的串行执行方式上去。这就是“线程封闭”的作用,它是实现线程安全性的最简单方式之一,就像我们之前说的没有并发就不需要同步与互斥,当一个状态完全被封闭在一个对象或一段代码中时,它本身就具有线程安全性,虽然状态本身并不是线程安全的,这种通过避免多线程访问同一状态的解决方式称为“线程封闭”。
    线程封闭的应用很广泛,我们知道一般的GUI框架比如swing,android UI中,各种UI元素和相关操作都只能在主线程执行,这样就避免了对这些状态的额外管理,同时也不会因为其它线程而阻塞UI造成屏幕卡顿的情况。
    Java语言本身及其核心库中也提供了一些机制来实现线程封闭性,包括局部变量、ThreadLocal变量等等。

    • Ad-hoc线程封闭

      这是非常脆弱的一种线程封闭方式,因为没有任何语言机制来保证它的正确性,它完全由程序实现承担,即程序编写人员要假设某段代码永远不会被多线程同时执行,从而不为它采取额外的同步互斥措施

    • 栈封闭

      这是线程封闭的一种特例,对状态的访问只能通过局部变量,而每个线程也都有自己的局部变量,线程间的局部变量也互相不可见,这是相对于Ad-hoc线程封闭方式来说更健壮的一种方式。不过千万要注意一定要考察局部变量的溢出,即作用域超出创建的代码段范围从而导致被其他线程持有又引发并发访问的情况。

    • ThreadLocal变量

      利用ThreadLocal封装的状态将在每个线程都拥有一个副本,彼此之间的状态不会干扰,状态值是和线程绑定的。它常用来防止可变的单实例变量或全局变量进行共享,TreadLocal的实现原理以后会再单独介绍,先来看一个例子.例如,在与数据库有紧密联系的应用程序中,程序的很多方法可能都需要访问数据库。在系统的每个方法中都包含一个 Connection 作为参数是不方便的 — 用“单子”来访问连接可能是一个虽然更粗糙,但却方便得多的技术。然而,多个线程不能安全地共享一个 JDBC Connection ,通过使用“单子”中的 ThreadLocal ,我们就能让我们的程序中的任何类容易地获取每线程 Connection 的一个引用。这样,我们可以认为 ThreadLocal 允许我们创建每个线程单例。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      public class ConnectionDispenser { 
      private static class ThreadLocalConnection extends ThreadLocal {
      public Object initialValue() {
      return DriverManager.getConnection(ConfigurationSingleton.getDbUrl());
      }
      }

      private ThreadLocalConnection conn = new ThreadLocalConnection();
      public static Connection getConnection() {
      return (Connection) conn.get();
      }
      }
  • 不可变对象

    除线程封闭外满足同步需求的另一种方式是使用不可变对象。不可变对象一定是线程安全的,但不可变对象并不是单纯由final修饰就可以了,它需要同时满足三个条件:

    • 对象创建以后状态不能更改;
    • 对象的所有域都是final类型的;
    • 对象是正确创建的,即在创建过程中本对象没有溢出
  • 如何设计线程安全的类
    在设计线程安全类的过程中需要包含以下三个基本要素:

    • 找出构成对象状态的所有变量:从对象的域开始,递归地去找构成对象的全部状态
    • 找出约束状态变量的不变性条件:分析出状态的限制条件、状态间的依赖关系
    • 建立对象的并发访问管理策略:根据共享状态和不变形条件设计出并发访问时应该如何在满足不变性条件的情况下保证共享转台的正确访问
  • 设计注意点

    • 状态空间越小,越容易判断线程的状态
    • final类型的域使用越多,越能简化对象可能状态的分析过程
    • 良好的封装让共享状态尽量少地被外部直接访问
    • 尽量让需要使用锁同步的相关代码位置靠近,减少后续处理时遗漏同步互斥的处理
    • 尽量多使用内置的同步工具类,这些类不能解决时再采取自己的同步互斥措施
    • 需要在现成同步工具类进一步封装需要同步的操作时,最好使用组合的方式,重新加统一锁,避免多个锁暴露给外部,造成操作的混淆和外部使用者的繁琐
    • 同步策略文档化:将某个对象使用的锁、不变性条件、外部调用者的注意事项记录在文档中,以便今后的维护,另外也可以采用自定义注解的方式在代码中结合注释更明确地指明相关操作的意义和注意点

对共享状态的同步互斥的不恰当处理会直接造成程序的隐患,以上介绍的相关概念、原理、方式和注意事项也仅仅是个人对之前这一方面的一些总结,以后还会逐步更新,下一篇文章将通过解析Java语言中典型同步工具类的实现和使用来更深入地理解和应用本篇文章介绍的知识。