并发相关概念简述

136 阅读11分钟

前言

随着时代的发展,软件变得越来越复杂。随之而带来的,是为了保证一定性能而不断扩大的并发需求。并发,也成为了一个程序员必须要掌握的技能。本文将对并发的各种概念做一个简单的介绍。

谈到并发,难免提到进程与线程两个概念。单看这两个名称,似乎差距不大。但我们可以继续看看它们的英文:process 和 thread。看到 process,很容易联想到 program(程序)。在操作系统中,我们恰恰可以粗略的认为,一个运行的程序是一个进程。相对于线程来说,进程是重量级的。而 thread,线,线是又轻又小的,线程也是轻量级的。

一般来说,进程与进程之间是无关的,而进程中却可以存在很多的线程。而并发,简单的来说,就是指多个线程同时运行。如果线程运行时,线程的行为不会因为其他线程的干扰而发生改变,我们认为就该线程是安全的。如果一个线程并不与其他线程进行相互,那么这个线程往往是安全的。但很多情况下,线程们往往存在于同一个进程,线程之间会互相干扰、相互协作,当这种时候,线程的行为无法保证,便不再安全了。请看这样一个例子

public class Unsafe(){
    private int count=0;

    public void increase(){
        count++;
    }
}

count++虽然看似只有一个操作,但实际上,JVM 执行了多条指令。

  void increase();
    Code:
       0: aload_0
       1: dup
       2: getfield      #3                  // Field count:I
       5: iconst_1
       6: iadd
       7: putfield      #3                  // Field count:I
      10: return

当线程 A 与线程 B 同时对 count 操作,线程 A 将 count 加 1 之后就被挂起,线程 B 开始执行 increase(),在 B 执行完后 count=1,A 这之后继续执行写入,那么 count 将会=1,而不是预想中的 2。

解决这个问题有个很直观的办法,那就是将 count++转变为不会被其他线程影响的不可分割操作(原子性操作)即可。java 中便有一种内置的锁机制,可以实现这种原子性:synchronized。

synchronized

synchronized(JavaClass){
    // do something
}

synchronized 囊括的代码块叫做临界区(同步块)。每个 java 对象都有一个内置锁,只有获得了内置锁的线程(monitor)才能进入临界区,当其离开临界区时则会释放锁。于是,不管 monitor 何时暂停(被挂起),其他的线程都由于无法进入临界区只能等待(阻塞)而无法对临界区内容造成影响。

我们可以在脑海中模拟一下这个过程,不难发现,临界区中的操作实际上只会有一个线程进行执行,也就是 synchronized 将临界区中本该并行的操作,变成了顺序执行的串行操作。这样,临界区的操作自然不可分割、受其他线程影响,于是这些操作便成为了原子操作。而,synchronized 除了确保一些操作原子之外,还保证了另一个线程间协作的关键:内存一致性。

内存一致性与 volatile

说到内存一致性,必须得了解 JVM 的内存模型。在 JVM 的内存模型中,每个线程都拥有自己的工作内存(私有),并定期与主内存进行交互(save/load)来更新工作内存的数据,线程只能从工作内存中读取数据而不能从主内存中读取。随之来的,是不同的线程不一定保证自己工作内存中的数据与其他线程工作内存一致,也不能保证与主内存中一致,这称为无法保证内存一致性。

我们来看这样一个例子:

public class Unsafe(){
    private int value;

    public int get(){
        return value;
    }

    public synchronized void increase(){
        this.value++;
    }
}

所以当 A 线程调用了 increase(),B 线程调用了 get()时,由于无法保证内存一致性,B 线程可能获取到最新的 value 值 1,也可能取到初始化的值 0。

为了确保一致性,可以将 get()也用 synchronized 修饰。

public class Safe(){
    private int value;

    public synchronized int get(){
        return value;
    }

    public synchronized void increase(){
        this.value++;
    }
}

这样,可以保证 B 线程能获取到 A 线程的更新值,换句话说,A 线程的更新对 B 线程时是可见的,也就是所说的可见性。

除了 synchronized,java 还提供了一种更为轻量级的可见性机制:volatile 关键字。

被 volatile 修饰的变量,更新操作将对所有的线程可见。在读取 volatile 变量时,总会返回最新的值。因此,上述代码也可以改为:

public class Safe(){
    private volatile int value;

    public int get(){
        return value;
    }

    public synchronized void increase(){
        this.value++;
    }
}

悲观锁与乐观锁

在基于锁的同步机制下,锁将会被一个线程独占(独占锁),另外的线程不得进入等待(被阻塞)。线程被挂起与恢复的过程中,需要较大的开销。那么,是否存在一种方法,可以在避免线程被挂起(非阻塞)的情况下,实现同步呢?这里,我们先引入一个概念:悲观锁与乐观锁。 悲观锁假设不获得锁便无法进行正确的行为,因此总是获取锁之后才进行操作;而乐观锁假设不获得锁的情况下,也能进行正确行为,如果失败,那么就重试,直至成功。独占锁是一种悲观锁,而一个简单的乐观锁如下:

public class SimpleOptimisticLock{
    private int value;

    public void increase(){
        // +1失败,则一直重试,直至成功
        while(!compareAndSet(value,value+1));
    }

    public synchronized boolean compareAndSet(int expectedValue,int newValue){
        return expectedValue==compareAndSwap(expectedValue,newValue)
    }

    public synchronized int compareAndSwap(int expectedValue,int newValue){
        int oldValue=value;
        if(oldValue==expectedValue){
            value=newValue;
        }
        return oldValue;
    }

}

CAS(Compare and Swap)与 ABA

上述代码中,我们使用 synchronized 来保证 compareAndSet 操作的原子性。所以,尽管 increase 使用了乐观锁,依然无法避免阻塞。幸运的是,现代处理器中,我们可以使用单一指令进行比较与交换(CAS)。在 java 中,更是提供了不少的原子操作,因此,我们可以将上面的代码改写成

public class SimpleOptimisticLock{
    private int value;

    // 使用Unsafe的原子操作
    private static final Unsafe U = Unsafe.getUnsafe();
    private static final long VALUE=U.objectFieldOffset(SimpleOptimisticLock.class, "value");

    public void increase(){
        while(!U.compareAndSetInt(this,VALUE,value,value+1));
    }
}

运用 CAS 实现的乐观锁,避免了线程的阻塞,通常情况下,效率高于如 synchronized 的独占锁。但在一些时候,却会出现 ABA 问题:线程 t1 执行 V.compareAndSwap(A,C)时,线程 t2 执行了 V.compareAndSwap(A,B)与 V.compareAndSwap(B,A)。A 看到的 V 值仍然是 A,但事实上,V 值已经发生了变化。解决这个问题有一个相对简单的方案:每次更新除了更新值之外,还更新一个版本号(如 AtomicStampedReference 类),借此判断是否是原值。

False Sharing 与@Contended

JVM 中,每个线程都有私有内存(ThreadLocalAllocBuffer,缓存)来减少与主内存的交互,提升效率。每个线程从各自的缓存中读取数据,而非直接从主内存中读取数据。运行线程也并非每次从缓存中读取一个字节,而是一次性从连续的内存地址中读取一定数量的字节(目前多数为 64byte)。这些被一次读取的连续内存块被称为缓存行。 所以当线程 t1 中的缓存行 L 中的一个数据值发生了改变的时候,将会导致整个缓存行 L 进行更新。与此同时,为了确保数据正确的共享,主内存与其他线程中的对应缓存行也会随之更新(内存一致性)。显而易见,那些缓存行中并不需要共享值的数据,额外承担了共享的代价,这就是 False Sharing(伪共享)。

为了减少 False Sharing,我们可以用一些额外字节来进行占位填充(pad)。在 java 中,.class 文件里每个对象都有固定的表示规则,我们可以通过在字段之间填充占位字段,来将不同的字段存放至不同的缓存行。不过随着 jdk 的不断发展,无用的填充可能因为优化而被去掉。好在,java 提供了@Contended 来更方便的进行缓存行的处理:拥有该注解的字段,将会和其他字段放置在不同的缓存行。而ConcurrentHashMap统计size的机制中,就利用了这个注解。

synchronized与Lock

synchronized作为关键字之一,为我们提供了便利的内置锁机制来保证线程安全。synchronized有三种用法,如下所示:

内置锁:synchronized

public class Foo {

    private Object obj = new Object();

    // 实例对象加锁
    synchronized void foo() {

    }

    // Class对象加锁
    synchronized static void bar() {

    }

    void baz() {
        // lock对象加锁
        synchronized (obj) {

        }
    }
}

synchronized内置锁拥有一个计数器,如果某线程进入了临界区,该线程便会占有锁并且内置锁的计数器加1,离开临界区计数器则会减1,直到计数器为0才会释放锁。这也就是说,对于一个线程来讲,内置锁是可以重入的。

而java中,挂起与恢复线程需要依赖操作系统,而操作系统切换线程的资源消耗比较大,属于重量级操作,所以频繁的切换线程将会大大影响并发性能。因此,JVM对内置锁进行了大量优化来减少切换线程的次数,如偏向锁、轻量级锁。

要理解偏向锁,先得眼光投向java对象在内存中的存储。在Hotspot虚拟机中,java对象的存储分为三个部分:对象头、实例数据和对齐填充字节。对象头又分为三个部分,其中一部分叫做Mark Word。而Mark Word中,有专门的锁状态标识来记录锁状态:无锁、偏向锁、轻量级锁与重量级锁。

偏向锁偏向于最近获得锁的线程。当锁对象被线程A获取时,JVM将会把锁状态标识设为偏向锁模式,并且用CAS将线程A的ID记录在Mark Word中。直到另一个线程B尝试去获取锁之前,线程A进入临界区时都不需要进行额外的操作。

如果线程B尝试获得锁时,锁未被占有,那么本着最近原则,偏向锁将会属于线程B。但如果锁正在被线程A占有,那么偏向锁将会膨胀成轻量级锁:锁状态变为轻量级锁。

轻量级锁主要是通过自旋操作,去让线程“忙等待”而不是被挂起(自旋锁)。当线程A占有锁并占有一个处理器进行计算时时,线程B在另一个处理器上通过自旋等待A释放锁。如果A在B的等待过程中释放了锁,那么显然就避免了一次切换线程的开销。不过,自旋等待中会白白耗费处理器资源,所以,在进行了一定次数的自旋之后,轻量级锁将会膨胀为重要级锁:线程将会挂起,然后恢复。

自旋还有一种自适应策略:如果当前拥有自旋锁的线程,也是在自旋等待期间获得锁的,那么JVM会认为这次自旋策略成功的可能性更大,从而增加当前线程容许的自旋次数。

同时,synchronized是非公平性的:一个线程会先试图直接抢占锁,失败之后才会加入等待队列排队获得锁。

所以,JVM通过增加偏向锁与轻量级锁等减少内置锁切换线程的开销,大大增加了synchronized的性能。而除了语法层面的互斥锁synchronized,java还提供了Api层面的Lock接口来进行同步。与synchronized相比,Lock额外拥有等待可中断、限时等待、多条件绑定等特性。

Lock与ReentrantLock

public interface Lock {

    void lock();

    void lockInterruptibly() throws InterruptedException;

    boolean tryLock();

    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

    void unlock();

    Condition newCondition();
}

public interface Condition {

    void await() throws InterruptedException;

    void awaitUninterruptibly();

    long awaitNanos(long nanosTimeout) throws InterruptedException;

    boolean await(long time, TimeUnit unit) throws InterruptedException;

    boolean awaitUntil(Date deadline) throws InterruptedException;

    void signal();

    void signalAll();
}

以lock.lock()和lock.unlock()包裹的代码块,大致相当于用synchronized修饰的代码块。而Lock接口的其他方法,则提供了synchronized锁不具备的特性:

  • void lockInterruptibly():线程在等待锁的过程中可以被其他线程所中断
  • boolean tryLock():尝试获得锁
  • boolean tryLock(long time, TimeUnit unit):尝试在给定时间中获得锁
  • Condition newCondition():新条件绑定

lock与condition.await()/condition.signal()的协作,类似于synchronized(obj)和obj.wait()/obj.notify()的配合,但一个lock可以通过lock.newCondition()绑定多个条件,一个synchronized却只能有一个obj。

ReentrantLock作为Lock的实现类,除了如类名所示,同synchronized一样可重入之外,还可以指定是公平锁还是非公平锁:

    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }