并发工具类_01

176 阅读32分钟

14 | Lock和Condition(上):隐藏在并发包中的管程

Java SDK 并发包内容很丰富,包罗万象,但是我觉得最核心的还是其对管程的实现。因为理论上利用管程,你几乎可以实现并发包里所有的工具类。

Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题。

如何保证可见性

Java 里多线程的可见性是通过 Happens-Before 规则保证的,而 synchronized 之所以能够保证可见性,也是因为有一条 synchronized 相关的规则:synchronized 的解锁 Happens-Before 于后续对这个锁的加锁。

那 Java SDK 里面 Lock 靠什么保证可见性呢?例如在下面的代码中,线程 T1 对 value 进行了 +=1 操作,那后续的线程 T2 能够看到 value 的正确结果吗?

class X {
    private final Lock rtl = new ReentrantLock();
    int value;
    public void addOne() {
        // 获取锁
        rtl.lock();
        try {
            value+=1;
        } finally {
            // 保证锁能释放
            rtl.unlock();
        }
    }
}

答案必须是肯定的。

Java SDK 里面锁的实现非常复杂,这里我就不展开细说了,但是原理还是需要简单介绍一下:它是利用了 volatile 相关的 Happens-Before 规则。Java SDK 里面的 ReentrantLock,内部持有一个 volatile 的成员变量 state,获取锁的时候,会读写 state 的值;解锁的时候,也会读写 state 的值(简化后的代码如下面所示)。也就是说,在执行 value+=1 之前,程序先读写了一次 volatile 变量 state,在执行 value+=1 之后,又读写了一次 volatile 变量 state。根据相关的 Happens-Before 规则:

  1. 顺序性规则:对于线程 T1,value+=1 Happens-Before 释放锁的操作 unlock();
  2. volatile 变量规则:由于 state = 1 会先读取 state,所以线程 T1 的 unlock() 操作 Happens-Before 线程 T2 的 lock() 操作;
  3. 传递性规则:线程 T1 的 value+=1 Happens-Before 线程 T2 的 lock() 操作。
class SampleLock {
  volatile int state;
  // 加锁
  lock() {
    // 省略代码无数
    state = 1;
  }
  // 解锁
  unlock() {
    // 省略代码无数
    state = 0;
  }
}

所以说,后续线程 T2 能够看到 value 的正确结果。

什么是可重入锁

如果你细心观察,会发现我们创建的锁的具体类名是 ReentrantLock,这个翻译过来叫可重入锁所谓可重入锁,顾名思义,指的是线程可以重复获取同一把锁。

线程重复获取自己已经获得的锁,叫做重入。

如下面代码中,当线程 T1 执行到 ① 处时,已经获取到了锁 rtl ,当在 ① 处调用 get() 方法时,会在 ② 再次对锁 rtl 执行加锁操作。此时,如果锁 rtl 是可重入的,那么线程 T1 可以再次加锁成功;如果锁 rtl 是不可重入的,那么线程 T1 此时会被阻塞。

class X {
  private final Lock rtl = new ReentrantLock();
  int value;

  public int get() {
    // 获取锁
    rtl.lock();         ②
    try {
      return value;
    } finally {
      // 保证锁能释放
      rtl.unlock();
    }
  }
  
  public void addOne() {
    // 获取锁
    rtl.lock();  
    try {
      value = 1 + get(); ①
    } finally {
      // 保证锁能释放
      rtl.unlock();
    }
  }
}

公平锁与非公平锁

ReentrantLock 这个类有两个构造函数,一个是无参构造函数,一个是传入 fair 参数的构造函数。fair 参数代表的是锁的公平策略,如果传入 true 就表示需要构造一个公平锁,反之则表示要构造一个非公平锁。

//无参构造函数:默认非公平锁
public ReentrantLock() {
    sync = new NonfairSync();
}
//根据公平策略参数创建锁
public ReentrantLock(boolean fair){
    sync = fair ? new FairSync() : new NonfairSync();
}

第8节介绍过入口等待队列,锁都对应着一个等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。如果是公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁,很公平;如果是非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒。

用锁的最佳实践

我们已经知道,用锁虽然能解决很多并发问题,但是风险也是挺高的。可能会导致死锁,也可能影响性能。这方面有是否有相关的最佳实践呢?

有,还很多。但是我觉得最值得推荐的是并发大师 Doug Lea《Java 并发编程:设计原则与模式》一书中,推荐的三个用锁的最佳实践,它们分别是:

  • 永远只在更新对象的成员变量时加锁
  • 永远只在访问可变的成员变量时加锁
  • 永远不在调用其他对象的方法时加锁

并发问题,本来就难以诊断,所以你一定要让你的代码尽量安全,尽量简单,哪怕有一点可能会出问题,都要努力避免。

总结

Java SDK 并发包里的 Lock 接口里面的每个方法,你可以感受到,都是经过深思熟虑的。除了支持类似 synchronized 隐式加锁的 lock() 方法外,还支持超时、非阻塞、可中断的方式获取锁,这三种方式为我们编写更加安全、健壮的并发程序提供了很大的便利。希望你以后在使用锁的时候,一定要仔细斟酌。

以上,Java SDK 并发包里的 Lock 有别于 synchronized 隐式锁的三个特性:能够响应中断、支持超时和非阻塞地获取锁。

15 | Lock和Condition(下):Dubbo如何用管程实现异步转同步?

以下,Java SDK 并发包里的 Condition,Condition 实现了管程模型里面的条件变量。

Java 语言内置的管程里只有一个条件变量,而 Lock&Condition 实现的管程是支持多个条件变量的,这是二者的一个重要区别。

在很多并发场景下,支持多个条件变量能够让我们的并发程序可读性更好,实现起来也更容易。例如,实现一个阻塞队列,就需要两个条件变量。

那如何利用两个条件变量快速实现阻塞队列呢?

public class BlockedQueue<T>{
    final Lock lock = new ReentrantLock();
    // 条件变量:队列不满  
    final Condition notFull = lock.newCondition();
    // 条件变量:队列不空  
    final Condition notEmpty = lock.newCondition();

    // 入队
    void enq(T x) {
      lock.lock();
      try {
        while (队列已满){
          // 等待队列不满
          notFull.await();
        }  
        // 省略入队操作...
        //入队后,通知可出队
        notEmpty.signal();
      }finally {
        lock.unlock();
      }
    }

    // 出队
    void deq(){
      lock.lock();
      try {
        while (队列已空){
          // 等待队列不空
          notEmpty.await();
        }  
        // 省略出队操作...
        //出队后,通知可入队
        notFull.signal();
      }finally {
        lock.unlock();
      }  
    }
}

需要注意,Lock 和 Condition 实现的管程,线程等待和通知需要调用 await()signal()signalAll(),它们的语义和 wait()、notify()、notifyAll() 是相同的。

但是不一样的是,Lock&Condition 实现的管程里只能使用前面的 await()、signal()、signalAll(),而后面的 wait()notify()notifyAll() 只有在 synchronized 实现的管程里才能使用

在知名项目 Dubbo 中,Lock 和 Condition 是怎么用的?先看2个概念,同步和异步。

同步与异步

我们平时写的代码,基本都是同步的。但最近几年,异步编程大火。

通俗点来讲就是调用方是否需要等待结果,如果需要等待结果,就是同步;如果不需要等待结果,就是异步。

同步,是 Java 代码默认的处理方式。如果你想让你的程序支持异步,可以通过下面两种方式来实现:

  1. 调用方创建一个子线程,在子线程中执行方法调用,这种调用我们称为异步调用;
  2. 方法实现的时候,创建一个新的线程执行主要逻辑,主线程直接 return,这种方法我们一般称为异步方法。

Dubbo 源码分析

在编程领域,异步的场景还是挺多的,比如 TCP 协议本身就是异步的,我们工作中经常用到的 RPC 调用,在 TCP 协议层面,发送完 RPC 请求后,线程是不会等待 RPC 的响应结果的。

奇怪的是,平时工作中的 RPC 调用大多数都是同步的啊? 事实上,RPC 框架 Dubbo 做了异步转同步的事情。

对于下面一个简单的 RPC 调用,默认情况下 sayHello() 方法,是个同步方法,也就是说,执行 service.sayHello(“dubbo”) 的时候,线程会停下来等结果。

DemoService service = 初始化部分省略
String message = service.sayHello("dubbo");
System.out.println(message);

如果此时你将调用线程 dump 出来的话,会是下图这个样子,你会发现调用线程阻塞了,线程状态是 TIMED_WAITING。本来发送请求是异步的,但是调用线程却阻塞了,说明 Dubbo 帮我们做了异步转同步的事情。

通过调用栈,你能看到线程是阻塞在 DefaultFuture.get() 方法上,所以可以推断:Dubbo 异步转同步的功能应该是通过 DefaultFuture 这个类实现的。

DubboInvoker 的 108 行调用了 DefaultFuture.get(),这一行很关键,先调用了 request(inv, timeout) 方法,这个方法其实就是发送 RPC 请求,之后通过调用 get() 方法等待 RPC 返回结果。

public class DubboInvoker{
      Result doInvoke(Invocation inv){
        // 下面这行就是源码中108行
        // 为了便于展示,做了修改
        return currentClient 
          .request(inv, timeout)
          .get();
      }
}

DefaultFuture 这个类是很关键,相关代码精简之后如下。

// 创建锁与条件变量
private final Lock lock = new ReentrantLock();
private final Condition done = lock.newCondition();

// 调用方通过该方法等待结果
Object get(int timeout){
    long start = System.nanoTime();
    lock.lock();
    try {
        while (!isDone()) {
            done.await(timeout);
              long cur=System.nanoTime();
            if (isDone() || cur-start > timeout){
              break;
            }
        }
    } finally {
        lock.unlock();
    }
    if (!isDone()) {
        throw new TimeoutException();
    }
    return returnFromResponse();
}

// RPC结果是否已经返回
boolean isDone() {
    return response != null;
}

// RPC结果返回时调用该方法   
private void doReceived(Response res) {
    lock.lock();
    try {
        response = res;
        if (done != null) {
            done.signal();
        }
    } finally {
        lock.unlock();
    }
}

调用线程通过调用 get() 方法等待 RPC 返回结果:调用 lock() 获取锁,在 finally 里面调用 unlock() 释放锁;获取锁后,通过经典的在循环中调用 await() 方法来实现等待。

当 RPC 结果返回时,会调用 doReceived() 方法,这个方法里面,调用 lock() 获取锁,在 finally 里面调用 unlock() 释放锁,获取锁后通过调用 signal() 来通知调用线程,结果已经返回,不用继续等待了。

总结

Lock&Condition 实现的管程相对于 synchronized 实现的管程来说更加灵活、功能也更丰富。

了解原理比了解实现更能让你快速学好并发编程,所以没有介绍太多 Java SDK 并发包里锁和条件变量是如何实现的。但如果你对实现感兴趣,可以参考《Java 并发编程的艺术》一书的第 5 章《Java 中的锁》,里面详细介绍了实现原理,我觉得写得非常好。

16 | Semaphore:如何快速实现一个限流器?

Semaphore,现在普遍翻译为“信号量”,以前也曾被翻译成“信号灯”,因为类似现实生活里的红绿灯,车辆能不能通行,要看是不是绿灯。同样,在编程世界里,线程能不能执行,也要看信号量是不是允许。

信号量是由大名鼎鼎的计算机科学家迪杰斯特拉(Dijkstra)于 1965 年提出,在这之后的 15 年,信号量一直都是并发编程领域的终结者,直到 1980 年管程被提出来,我们才有了第二选择目前几乎所有支持并发编程的语言都支持信号量机制,所以学好信号量还是很有必要的。

下面我们首先介绍信号量模型,之后介绍如何使用信号量,最后我们再用信号量来实现一个限流器

信号量模型

可以简单概括为:一个计数器,一个等待队列,三个方法。

在信号量模型里,计数器和等待队列对外是透明的,所以只能通过信号量模型提供的三个方法来访问它们,这三个方法分别是:init()down()up()

image.png

信号量模型里面,down()up() 这两个操作历史上最早称为 P 操作和 V 操作,所以信号量模型也被称为 PV 原语。另外,还有些人喜欢用 semWait() 和 semSignal() 来称呼它们,虽然叫法不同,但是语义都是相同的。在 Java SDK 并发包里,down() 和 up() 对应的则是 acquire()release()

如何使用信号量

就像我们用互斥锁一样,只需要在进入临界区之前执行一下 down() 操作,退出临界区之前执行一下 up() 操作就可以了。如下是 Java 代码的示例:

static int count;
//初始化信号量
static final Semaphore s = new Semaphore(1);

//用信号量保证互斥    
static void addOne() {
    s.acquire();
    try {
        count+=1;
    } finally {
        s.release();
    }
}

下面我们再来分析一下,信号量是如何保证互斥的。

假设两个线程 T1 和 T2 同时访问 addOne() 方法,当它们同时调用 acquire() 的时候,由于 acquire() 是一个原子操作,所以只能有一个线程(假设 T1)把信号量里的计数器减为 0,另外一个线程(T2)则是将计数器减为 -1。对于线程 T1,信号量里面的计数器的值是 0,大于等于 0,所以线程 T1 会继续执行;对于线程 T2,信号量里面的计数器的值是 -1,小于 0,按照信号量模型里对 down() 操作的描述,线程 T2 将被阻塞。所以此时只有线程 T1 会进入临界区执行count+=1;。

当线程 T1 执行 release() 操作,也就是 up() 操作的时候,信号量里计数器的值是 -1,加 1 之后的值是 0,小于等于 0,按照信号量模型里对 up() 操作的描述,此时等待队列中的 T2 将会被唤醒。于是 T2 在 T1 执行完临界区代码之后才获得了进入临界区执行的机会,从而保证了互斥性。

快速实现一个限流器

既然 Java SDK 里面提供了 Lock,为啥还要提供一个 Semaphore

其实实现一个互斥锁,仅仅是 Semaphore 的部分功能,Semaphore 还有一个功能是 Lock 不容易实现的,那就是:Semaphore 可以允许多个线程访问一个临界区。

class ObjPool<T, R> {
    final List<T> pool;
    // 用信号量实现限流器
    final Semaphore sem;

    // 构造函数
    ObjPool(int size, T t){
        pool = new Vector<T>(){};
        for(int i=0; i<size; i++){
          pool.add(t);
        }
        sem = new Semaphore(size);
    }
    // 利用对象池的对象,调用func
    R exec(Function<T,R> func) {
        T t = null;
        sem.acquire();
        try {
            t = pool.remove(0);
            return func.apply(t);
        } finally {
            pool.add(t);
            sem.release();
        }
    }
}

// 创建对象池
ObjPool<Long, String> pool = new ObjPool<Long, String>(10, 2);
// 通过对象池获取t,之后执行  
pool.exec(t -> {
    System.out.println(t);
    return t.toString();
});

我们用一个 List 来保存对象实例,用 Semaphore 实现限流器。关键的代码是 ObjPool 里面的 exec() 方法,这个方法里面实现了限流的功能。

在这个方法里面,我们首先调用 acquire() 方法(与之匹配的是在 finally 里面调用 release() 方法),假设对象池的大小是 10,信号量的计数器初始化为 10,那么前 10 个线程调用 acquire() 方法,都能继续执行,相当于通过了信号灯,而其他线程则会阻塞在 acquire() 方法上。

对于通过信号灯的线程,我们为每个线程分配了一个对象 t(这个分配工作是通过 pool.remove(0) 实现的),分配完之后会执行一个回调函数 func,而函数的参数正是前面分配的对象 t ;执行完回调函数之后,它们就会释放对象(这个释放工作是通过 pool.add(t) 实现的),同时调用 release() 方法来更新信号量的计数器。如果此时信号量里计数器的值小于等于 0,那么说明有线程在等待,此时会自动唤醒等待的线程。

信号量在 Java 语言里面名气并不算大,但是在其他语言里却是很有知名度的。Java 在并发编程领域走的很快,重点支持的还是管程模型。

管程模型理论上解决了信号量模型的一些不足,主要体现在易用性和工程化方面,例如用信号量解决我们曾经提到过的阻塞队列问题,就比管程模型麻烦很多。

17 | ReadWriteLock:如何快速实现一个完备的缓存?

快速实现一个缓存

ReadWriteLock 快速实现一个通用的缓存工具类。

声明了一个 Cache 类,其中类型参数 K 代表缓存里 key 的类型,V 代表缓存里 value 的类型。缓存的数据保存在 Cache 类内部的 HashMap 里面,HashMap 不是线程安全的,这里我们使用读写锁 ReadWriteLock 来保证其线程安全。

ReadWriteLock 是一个接口,它的实现类是 ReentrantReadWriteLock,通过名字你应该就能判断出来,它是支持可重入的。下面我们通过 rwl 创建了一把读锁和一把写锁。

Cache 这个工具类,我们提供了两个方法,一个是读缓存方法 get(),另一个是写缓存方法 put()

读缓存需要用到读锁,读锁的使用和前面我们介绍的 Lock 的使用是相同的,都是 try{}finally{} 这个编程范式。写缓存则需要用到写锁,写锁的使用和读锁是类似的。这样看来,读写锁的使用还是非常简单的。

class Cache<K,V> {
    final Map<K, V> m = new HashMap<>();
    final ReadWriteLock rwl = new ReentrantReadWriteLock();

    // 读锁
    final Lock r = rwl.readLock();
    // 写锁
    final Lock w = rwl.writeLock();

    // 读缓存
    V get(K key) {
        r.lock();
        try { return m.get(key); }
        finally { r.unlock(); }
    }
    // 写缓存
    V put(K key, V value) {
        w.lock();
        try { return m.put(key, v); }
        finally { w.unlock(); }
    }
}

使用缓存首先要解决缓存数据的初始化问题。缓存数据的初始化,可以采用一次性加载的方式,也可以使用按需加载(懒加载,数据量大时)的方式

实现缓存的按需加载

如果缓存中没有缓存目标对象,那么就需要从数据库中加载,然后写入缓存, 写缓存需要用到写锁,所以在代码中的⑤处,我们调用了 w.lock() 来获取写锁。

需要注意的是,在获取写锁之后,我们并没有直接去查询数据库,而是在代码⑥⑦处,重新验证了一次缓存中是否存在,再次验证如果还是不存在,我们才去查询数据库并更新本地缓存。为什么我们要再次验证呢?

再次验证的方式,能够避免高并发场景下重复查询数据的问题。

class Cache<K,V> {
    final Map<K, V> m = new HashMap<>();
    final ReadWriteLock rwl = new ReentrantReadWriteLock();
    final Lock r = rwl.readLock();
    final Lock w = rwl.writeLock();
   
    V get(K key) {
        V v = null;
        //读缓存
        r.lock();         ①
        try {
            v = m.get(key); ②
        } finally{
            r.unlock();     ③
        }

        //缓存中存在,返回
        if(v != null) {   ④
            return v;
        }
          
        //缓存中不存在,查询数据库
        w.lock();         ⑤
        try {
            //再次验证
            //其他线程可能已经查询过数据库
            v = m.get(key); ⑥
            if(v == null){  ⑦
                //查询数据库
                v=省略代码无数
                m.put(key, v);
            }
        } finally{
            w.unlock();
        }
        return v; 
    }
}

读写锁的升级与降级

锁的升级是不允许的,但是锁的降级却是允许的。

//读缓存
r.lock();         ①
try {
    v = m.get(key); ②
    if (v == null) {
        w.lock();
        try {
            //再次验证并更新缓存
            //省略详细代码
        } finally{
            w.unlock();
        }
    }
} finally{
    r.unlock();     ③
}

正是因为不可能有读锁和写锁同时持有的情况,所以升级写锁的过程中,需要等到所有的读锁都释放,此时才能进行升级。 假设有 A,B 和 C 三个线程,它们都已持有读锁。假设线程 A 尝试从读锁升级到写锁。那么它必须等待 B 和 C 释放掉已经获取到的读锁。如果随着时间推移,B 和 C 逐渐释放了它们的读锁,此时线程 A 确实是可以成功升级并获取写锁。 但是我们考虑一种特殊情况。假设线程 A 和 B 都想升级到写锁,那么对于线程 A 而言,它需要等待其他所有线程,包括线程 B 在内释放读锁。而线程 B 也需要等待所有的线程,包括线程 A 释放读锁。这就是一种非常典型的死锁的情况。谁都愿不愿意率先释放掉自己手中的锁。 但是读写锁的升级并不是不可能的,也有可以实现的方案,如果我们保证每次只有一个线程可以升级,那么就可以保证线程安全。只不过最常见的 ReentrantReadWriteLock 对此并不支持。

18 | StampedLock:有没有比读写锁更快的锁?

Java 在 1.8 这个版本里,提供了一种叫 StampedLock 的锁,它的性能就比读写锁还要好。

StampedLock 支持的三种锁模式

ReadWriteLock 支持两种模式:一种是读锁,一种是写锁。而 StampedLock 支持三种模式,分别是:写锁悲观读锁乐观读

其中,写锁、悲观读锁的语义和 ReadWriteLock 的写锁、读锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的。不同的是:StampedLock 里的写锁和悲观读锁加锁成功之后,都会返回一个 stamp;然后解锁的时候,需要传入这个 stamp。

final StampedLock sl = new StampedLock();
  
// 获取/释放悲观读锁示意代码
long stamp = sl.readLock();
try {
    //省略业务相关代码
} finally {
    sl.unlockRead(stamp);
}

// 获取/释放写锁示意代码
long stamp = sl.writeLock();
try {
    //省略业务相关代码
} finally {
    sl.unlockWrite(stamp);
}

StampedLock 的性能之所以比 ReadWriteLock 还要好,其关键是 StampedLock 支持乐观读的方式。

ReadWriteLock 支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻塞;而 StampedLock 提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都被阻塞。

ReadWriteLock: 1.读读非阻塞 2.读写阻塞 3.写写阻塞 StampedLock: 1.读读非阻塞 2.读写非阻塞 3.写写阻塞

注意这里,我们用的是“乐观读”这个词,而不是“乐观读锁”,是要提醒你,乐观读这个操作是无锁的,所以相比较 ReadWriteLock 的读锁,乐观读的性能更好一些。

文中下面这段代码是出自 Java SDK 官方示例,并略做了修改。

在 distanceFromOrigin() 这个方法中,首先通过调用 tryOptimisticRead() 获取了一个 stamp,这里的 tryOptimisticRead() 就是我们前面提到的乐观读。之后将共享变量 x 和 y 读入方法的局部变量中,不过需要注意的是,由于 tryOptimisticRead() 是无锁的,所以共享变量 xy 读入方法局部变量时,x 和 y 有可能被其他线程修改了。因此最后读完之后,还需要再次验证一下是否存在写操作,这个验证操作是通过调用 validate(stamp) 来实现的。

class Point {
    private int x, y;
    final StampedLock sl = new StampedLock();
    
    //计算到原点的距离  
    int distanceFromOrigin() {
        // 乐观读
        long stamp = sl.tryOptimisticRead();
        // 读入局部变量,
        // 读的过程数据可能被修改
        int curX = x, curY = y;
        //判断执行读操作期间,
        //是否存在写操作,如果存在,
        //则sl.validate返回false
        if (!sl.validate(stamp)){
            // 升级为悲观读锁
            stamp = sl.readLock();
            try {
                curX = x;
                curY = y;
            } finally {
                //释放悲观读锁
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(
            curX * curX + curY * curY);
    }
}

在上面这个代码示例中,如果执行乐观读操作的期间,存在写操作,会把乐观读升级为悲观读锁。这个做法挺合理的,否则你就需要在一个循环里反复执行乐观读,直到执行乐观读操作的期间没有写操作(只有这样才能保证 x 和 y 的正确性和一致性),而循环读会浪费大量的 CPU。升级为悲观读锁,代码简练且不易出错,建议你在具体实践时也采用这样的方法。

进一步理解乐观读

StampedLock 的乐观读和数据库的乐观锁有异曲同工之妙。

乐观锁的实现很简单,在生产订单的表 product_doc 里增加了一个数值型版本号字段 version,每次更新 product_doc 这个表的时候,都将 version 字段加 1。生产订单的 UI 在展示的时候,需要查询数据库,此时将这个 version 字段和其他业务字段一起返回给生产订单 UI。假设用户查询的生产订单的 id=777,那么 SQL 语句类似下面这样:

select id,... ,version
from product_doc
where id=777

用户在生产订单 UI 执行保存操作的时候,后台利用下面的 SQL 语句更新生产订单,此处我们假设该条生产订单的 version=9。

update product_doc 
set version=version+1,...
where id=777 and version=9

如果这条 SQL 语句执行成功并且返回的条数等于 1,那么说明从生产订单 UI 执行查询操作到执行保存操作期间,没有其他人修改过这条数据。因为如果这期间其他人修改过这条数据,那么版本号字段一定会大于 9。

你会发现数据库里的乐观锁,查询的时候需要把 version 字段查出来,更新的时候要利用 version 字段做验证。这个 version 字段就类似于 StampedLock 里面的 stamp。这样对比着看,相信你会更容易理解 StampedLock 里乐观读的用法。

StampedLock 使用注意事项

StampedLock 的功能仅仅是 ReadWriteLock 的子集。 StampedLock 不支持重入,这个是在使用中必须要特别注意的。

还有一点需要特别注意,那就是:如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 方法,会导致 CPU 飙升。

final StampedLock lock = new StampedLock();
Thread T1 = new Thread(()->{
      // 获取写锁
      lock.writeLock();
      // 永远阻塞在此处,不释放写锁
      LockSupport.park();
});
T1.start();
// 保证T1获取写锁
Thread.sleep(100);

Thread T2 = new Thread(()->
      //阻塞在悲观读锁
      lock.readLock()
);
T2.start();
// 保证T2阻塞在读锁
Thread.sleep(100);

//中断线程T2 会导致线程T2所在CPU飙升
T2.interrupt();
T2.join();

所以,使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。这个规则一定要记清楚。

总结

建议你在实际工作中尽量按照这个模板来使用 StampedLock。

StampedLock 读模板:

final StampedLock sl = new StampedLock();

// 乐观读
long stamp = sl.tryOptimisticRead();
// 读入方法局部变量
......
// 校验stamp
if (!sl.validate(stamp)){
      // 升级为悲观读锁
      stamp = sl.readLock();
      try {
        // 读入方法局部变量
        .....
      } finally {
        //释放悲观读锁
        sl.unlockRead(stamp);
      }
}
//使用方法局部变量执行业务操作
......

StampedLock 写模板:

long stamp = sl.writeLock();
try {
    // 写共享变量
    ......
} finally {
    sl.unlockWrite(stamp);
}

19 | CountDownLatch和CyclicBarrier:如何让多线程步调一致?

image.png

目前对账系统的处理逻辑是首先查询订单,然后查询派送单,之后对比订单和派送单,将差异写入差异库。

while(存在未对账订单){
      // 查询未对账订单
      pos = getPOrders();
      // 查询派送单
      dos = getDOrders();
      // 执行对账操作
      diff = check(pos, dos);
      // 差异写入差异库
      save(diff);
} 

利用并行优化对账系统

老板要我优化性能,那我就首先要找到这个对账系统的瓶颈所在。

image.png

对于串行化的系统,优化性能首先想到的是能否利用多线程并行处理。

查询未对账订单 getPOrders() 和查询派送单 getDOrders() 是否可以并行处理呢?显然是可以的,因为这两个操作并没有先后顺序的依赖。

image.png

同等时间里,并行执行的吞吐量近乎单线程的 2 倍,优化效果还是相对明显的。

代码实现如下:创建了两个线程 T1T2,并行执行查询未对账订单 getPOrders() 和查询派送单 getDOrders() 这两个操作。在主线程中执行对账操作 check() 和差异写入 save() 两个操作。

需要注意的是:主线程需要等待线程 T1 和 T2 执行完才能执行 check() 和 save() 这两个操作,为此我们通过调用 T1.join()T2.join() 来实现等待,当 T1 和 T2 线程退出时,调用 T1.join() 和 T2.join() 的主线程就会从阻塞态被唤醒,从而执行之后的 check() 和 save()。

while(存在未对账订单){
    // 查询未对账订单
    Thread T1 = new Thread(()->{
        pos = getPOrders();
    });
    T1.start();
    // 查询派送单
    Thread T2 = new Thread(()->{
        dos = getDOrders();
    });
    T2.start();
    // 等待T1、T2结束
    T1.join();
    T2.join();
    // 执行对账操作
    diff = check(pos, dos);
    // 差异写入差异库
    save(diff);
} 

用 CountDownLatch 实现线程等待

经过上面的优化之后,基本上可以跟老板汇报收工了,但还是有点美中不足,相信你也发现了,while 循环里面每次都会创建新的线程,而创建线程可是个耗时的操作。所以最好是创建出来的线程能够循环利用,估计这时你已经想到线程池了,是的,线程池就能解决这个问题。

我们首先创建了一个固定大小为 2 的线程池,之后在 while 循环里重复利用。一切看上去都很顺利,但是有个问题好像无解了,那就是主线程如何知道 getPOrders() 和 getDOrders() 这两个操作什么时候执行完。前面主线程通过调用线程 T1 和 T2 的 join() 方法来等待线程 T1 和 T2 退出,但是在线程池的方案里,线程根本就不会退出,所以 join() 方法已经失效了。

// 创建2个线程的线程池
Executor executor = Executors.newFixedThreadPool(2);
while(存在未对账订单){
    // 计数器初始化为2
    CountDownLatch latch = new CountDownLatch(2);
    // 查询未对账订单
    executor.execute(()-> {
        pos = getPOrders();
        latch.countDown();
    });
    // 查询派送单
    executor.execute(()-> {
        dos = getDOrders();
        latch.countDown();
    });
    
    // 等待两个查询操作结束
    latch.await();
    
    // 执行对账操作
    diff = check(pos, dos);
    // 差异写入差异库
    save(diff);
}

在 while 循环里面,我们首先创建了一个 CountDownLatch,计数器的初始值等于 2,之后在pos = getPOrders();和dos = getDOrders();两条语句的后面对计数器执行减 1 操作,这个对计数器减 1 的操作是通过调用 latch.countDown(); 来实现的。在主线程中,我们通过调用 latch.await() 来实现对计数器等于 0 的等待。

进一步优化性能

很显然,两个查询操作和对账操作也是可以并行的,也就是说,在执行对账操作的时候,可以同时去执行下一轮的查询操作。

image.png

两次查询操作能够和对账操作并行,对账操作还依赖查询操作的结果,这明显有点生产者 - 消费者的意思,两次查询操作生产者对账操作消费者。既然是生产者 - 消费者模型,那就需要有个队列,来保存生产者生产的数据,而消费者则从这个队列消费数据。

订单查询操作将订单查询结果插入订单队列,派送单查询操作将派送单插入派送单队列,这两个队列的元素之间是有一一对应的关系的。两个队列的好处是,对账操作可以每次从订单队列出一个元素,从派送单队列出一个元素,然后对这两个元素执行对账操作,这样数据一定不会乱掉。

image.png

如何用双队列来实现完全的并行。一个最直接的想法是:一个线程 T1 执行订单的查询工作,一个线程 T2 执行派送单的查询工作,当线程 T1 和 T2 都各自生产完 1 条数据的时候,通知线程 T3 执行对账操作。

如下,线程 T1 和线程 T2 只有都生产完 1 条数据的时候,才能一起向下执行,也就是说,线程 T1 和线程 T2 要互相等待,步调要一致;同时当线程 T1 和 T2 都生产完一条数据的时候,还要能够通知线程 T3 执行对账操作。

image.png

CyclicBarrier 实现线程同步

上面方案的难点有两个:一个是线程 T1 和 T2 要做到步调一致,另一个是要能够通知到线程 T3。

Java 并发包里已经提供了相关的工具类:CyclicBarrier

// 订单队列
Vector<P> pos;
// 派送单队列
Vector<D> dos;
// 执行回调的线程池 
Executor executor = Executors.newFixedThreadPool(1);
final CyclicBarrier barrier = new CyclicBarrier(2, ()->{
    executor.execute(()->check());
});
  
void check(){
    P p = pos.remove(0);
    D d = dos.remove(0);
    // 执行对账操作
    diff = check(p, d);
    // 差异写入差异库
    save(diff);
}
  
void checkAll(){
    // 循环查询订单库
    Thread T1 = new Thread(()->{
        while(存在未对账订单){
            // 查询订单库
            pos.add(getPOrders());
            // 等待
            barrier.await();
        }
    });
    T1.start();  
    // 循环查询运单库
    Thread T2 = new Thread(()->{
        while(存在未对账订单){
            // 查询运单库
            dos.add(getDOrders());
            // 等待
            barrier.await();
        }
    });
    T2.start();
}

线程 T1 负责查询订单,当查出一条时,调用 barrier.await() 来将计数器减 1,同时等待计数器变成 0;线程 T2 负责查询派送单,当查出一条时,也调用 barrier.await() 来将计数器减 1,同时等待计数器变成 0;当 T1 和 T2 都调用 barrier.await() 的时候,计数器会减到 0,此时 T1 和 T2 就可以执行下一条语句了,同时会调用 barrier 的回调函数来执行对账操作。

非常值得一提的是,CyclicBarrier 的计数器有自动重置的功能,当减到 0 的时候,会自动重置你设置的初始值。这个功能用起来实在是太方便了。

总结

CountDownLatch 和 CyclicBarrier 是 Java 并发包提供的两个非常易用的线程同步工具类,这两个工具类用法的区别在这里还是有必要再强调一下:CountDownLatch 主要用来解决一个线程等待多个线程的场景,可以类比旅游团团长要等待所有的游客到齐才能去下一个景点;而 CyclicBarrier 是一组线程之间互相等待,更像是几个驴友之间不离不弃。除此之外 CountDownLatch 的计数器是不能循环利用的,也就是说一旦计数器减到 0,再有线程调用 await(),该线程会直接通过。但 CyclicBarrier 的计数器是可以循环利用的,而且具备自动重置的功能,一旦计数器减到 0 会自动重置到你设置的初始值。除此之外,CyclicBarrier 还可以设置回调函数,可以说是功能丰富。

20 | 并发容器:都有哪些“坑”需要我们填?

Java 并发包有很大一部分内容都是关于并发容器的,因此学习和搞懂这部分的内容很有必要。

Java 1.5 之前提供的同步容器虽然也能保证线程安全,但是性能很差,而 Java 1.5 版本之后提供的并发容器在性能方面则做了很多优化,并且容器的类型也更加丰富了。下面我们就对比二者来学习这部分的内容。

并发容器虽然数量非常多,但依然是前面我们提到的四大类:ListMapSetQueue,下面的并发容器关系图,基本上把我们经常用的容器都覆盖到了。

image.png

(一)List

CopyOnWriteArrayList 内部维护了一个数组,成员变量 array 就指向这个内部数组,所有的读操作都是基于 array 进行的,如下图所示,迭代器 Iterator 遍历的就是 array 数组。

image.png

如果在遍历 array 的同时,还有一个写操作,例如增加元素,CopyOnWriteArrayList 是如何处理的呢?

CopyOnWriteArrayList 会将 array 复制一份,然后在新复制处理的数组上执行增加元素的操作,执行完之后再将 array 指向这个新的数组。

image.png

使用 CopyOnWriteArrayList 需要注意的“坑”主要有两个方面。

一个是应用场景,CopyOnWriteArrayList 仅适用于写操作非常少的场景,而且能够容忍读写的短暂不一致。例如上面的例子中,写入的新元素并不能立刻被遍历到。

另一个需要注意的是,CopyOnWriteArrayList 迭代器是只读的,不支持增删改。因为迭代器遍历的仅仅是一个快照,而对快照进行增删改是没有意义的。

(二)Map

Map 接口的两个实现是 ConcurrentHashMapConcurrentSkipListMap,它们从应用的角度来看,主要区别在于 ConcurrentHashMap 的 key 是无序的,而 ConcurrentSkipListMap 的 key 是有序的。所以如果你需要保证 key 的顺序,就只能使用 ConcurrentSkipListMap。

使用 ConcurrentHashMap 和 ConcurrentSkipListMap 需要注意的地方是,它们的 key 和 value 都不能为空,否则会抛出 NullPointerException 这个运行时异常。下面这个表格总结了 Map 相关的实现类对于 key 和 value 的要求,你可以对比学习。

image.png

只有hashmap的key和value都可以为null,TreeMap的key要支持排序,所以key不能为null,value可以为null,其他线程安全的Map key、value都不能为null。

ConcurrentSkipListMap 里面的 SkipList 本身就是一种数据结构,中文一般都翻译为“跳表”。跳表插入、删除、查询操作平均的时间复杂度是 O(log n),理论上和并发线程数没有关系,所以在并发度非常高的情况下,若你对 ConcurrentHashMap 的性能还不满意,可以尝试一下 ConcurrentSkipListMap。

(三)Set

Set 接口的两个实现是 CopyOnWriteArraySetConcurrentSkipListSet,使用场景可以参考前面讲述的 CopyOnWriteArrayList 和 ConcurrentSkipListMap,它们的原理都是一样的,这里就不再赘述了。

(四)Queue

Java 并发包里面 Queue 这类并发容器是最复杂的,你可以从以下两个维度来分类。

一个维度是阻塞与非阻塞,所谓阻塞指的是当队列已满时,入队操作阻塞;当队列已空时,出队操作阻塞。另一个维度是单端与双端,单端指的是只能队尾入队,队首出队;而双端指的是队首队尾皆可入队出队。

Java 并发包里阻塞队列都用 Blocking 关键字标识,单端队列使用 Queue 标识,双端队列使用 Deque 标识

这两个维度组合后,可以将 Queue 细分为四大类,分别是:

单端阻塞队列

其实现有 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、LinkedTransferQueue、PriorityBlockingQueue 和 DelayQueue。

image.png

双端阻塞队列

其实现是 LinkedBlockingDeque

image.png

单端非阻塞队列

其实现是 ConcurrentLinkedQueue

双端非阻塞队列

其实现是 ConcurrentLinkedDeque

另外,使用队列时,需要格外注意队列是否支持有界(所谓有界指的是内部的队列是否有容量限制)。实际工作中,一般都不建议使用无界的队列,因为数据量大了之后很容易导致 OOM

上面我们提到的这些 Queue 中,只有 ArrayBlockingQueue 和 LinkedBlockingQueue 是支持有界的,所以在使用其他无界队列时,一定要充分考虑是否存在导致 OOM 的隐患

总结

在实际工作中,你不单要清楚每种容器的特性,还要能选对容器,这才是关键,至于每种容器的用法,用的时候看一下 API 说明就可以了,这些容器的使用都不难。

课后思考

线上系统 CPU 突然飙升,你怀疑有同学在并发场景里使用了 HashMap,因为在 1.8 之前的版本里并发执行 HashMap.put() 可能会导致 CPU 飙升到 100%,你觉得该如何验证你的猜测呢?

Java7中的HashMap在执行put操作时会涉及到扩容,由于扩容时链表并发操作会造成链表成环,所以可能导致cpu飙升100%。