【深入浅出Java多线程】锁与AQS

240 阅读9分钟

锁的基本概念

公平锁与非公平锁

公平锁

image.png 公平锁是指加锁时,线程按照申请锁的顺序进入到队列中排队 ,队列中的第一个线程才能获得锁。

公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞。

非公平锁

image.png 非公平锁是指加锁,线程直接尝试获取锁,获取不到才会到等待队列的队尾等待。

非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

可重入锁与不可重入锁

可重入锁,又名递归锁,是指同一个线程能重复获取同一个临界资源(锁)。重入锁的一个优点是可以避免在递归的时候形成死锁,比如:子类同步方法调用了父类同步方法,如没有可重入的特性,则会发生死锁。

image.png 可重入锁的设计思路:为每个锁关联一个获取计数器和一个所有者线程。

  1. 当计数值为0的时候,这个锁就没有被任何线程持有。
  2. 当线程请求一个未被持有的锁时,记录锁的持有者,并且将获取计数值置为1,
  3. 如果同一个线程再次获取这个锁,计数值将递增,退出一次同步代码块,计算值递减
  4. 当计数值为0时,这个锁就被释放。

乐观锁与悲观锁

悲观锁的概念是指在使用数据时,认为一定会有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。

乐观锁则是指在使用数据时,默认不会有别的线程修改数据,不加锁,但更新数据时,判断数据是否被更新。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

乐观锁一般使用CAS实现。

CAS

CAS全称 Compare And Swap(比较与交换)。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。本质上是利用了CPU提供的CAS指令。

CAS算法涉及到三个操作数:

  • 共享变量的内存地址 A
  • 用于比较的值 B
  • 共享变量的新值 C

只有当内存中地址 A 处的值等于 B 时,才能将内存中地址 A 处的值更新为新值 C。

CAS自身也存在一些问题:

  1. 无法解决ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
  2. CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
  3. 对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。

死锁与活锁

死锁:两个或两个以上进程(线程)在竞争临界资源的时候相互等待的情况。

活锁:活锁指的是在竞争临界资源的时候,线程没有被阻塞甚至会主动释放资源,由于某些条件没有满足,导致一直重复尝试—失败—尝试—失败的过程。比如下面这个例子:

image.png

解决“活锁”的方案很简单,在重试获取临界资源时,尝试等待一个随机的时间就可以了。由于等待时间是随机的,所以不同线程获取临界资源的时间会被错开,这样形成活锁的概率会被降低。“等待一个随机时间”的方案虽然很简单,却非常有效,Raft 这样知名的分布式一致性算法中也用到了它。

常用的锁

ReentrantLock

ReentrantLock,可重入锁,默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。

public class LockDemo {

    public void fun() throws InterruptedException {
        Lock lock = new ReentrantLock();
        // 获取锁 如果锁已被其他线程获取,则进行等待
        lock.lock();
        try {
            // do something
        } finally {
            lock.unlock();a
        }

        // 获取锁 成功获取锁返回true 失败返回false
        if (lock.tryLock()) {
            try {
                // do something
            } finally {
                lock.unlock();
            }
        }

        // 获取锁 能够响应中断
        lock.lockInterruptibly();
        try {
            // do something
        } finally {
            lock.unlock();
        }
    }
}

ReentrantReadWriteLock

可重入的读写锁,读锁可共享,写锁互斥。

public void fun() throws InterruptedException {
        ReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
        Lock readLock = reentrantReadWriteLock.readLock();
        // 加读锁
        readLock.lock();
        try {
            // do something
        } finally {
            readLock.unlock();
        }


        // 加写锁
        Lock writeLock = reentrantReadWriteLock.writeLock();
        writeLock.lock();
        try {
            // do something
        } finally {
            writeLock.unlock();
        }
}

StampedLock

StampedLock 支持三种模式,分别是:写锁、悲观读锁和乐观读。

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

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

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);
  }
}

StampedLock不支持重入,且如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 方法,会导致 CPU 飙升。

Semaphore

Semaphore也叫信号量,在JDK1.5被引入,可以用来控制同时访问临界资源的线程数量。

public class LockDemo {
    Semaphore semaphore = new Semaphore(10);
    public void fun() throws InterruptedException {
        try {
            semaphore.acquire();
            // do something
        }
        finally {
            semaphore.release();
        }
    }
}

Semaphore可以用来做流量控制,特别是对公共资源有限的场景,比如数据库连接。举个例子,假如我们需要读取几万个文件的数据到数据库中,由于文件读取是IO密集型任务,可以启动几十个线程并发读取,但是数据库连接数只有10个,这时就必须控制最多只有10个线程能够拿到数据库连接进行操作。这个时候,就可以使用Semaphore做流量控制。

AQS

AbstractQueuedSynchronizer,队列同步器,是一种提供了原子式管理同步状态、阻塞和唤醒线程功能以及队列模型的简单框架。Java中的大部分同步类(Lock、Semaphore、ReentrantLock等)都是基于AQS实现的。

AQS框架的组成

image.png AQS有几个重要组成部分:

  1. 等待队列,用于存放尝试获取临界资源的线程
  2. state,临界资源状态
  3. 当前占用临界资源的线程 AQS有两种不同的模式,独占模式和共享模式,在不同模式下,临界资源的状态和线程占用临界资源的方式有所不同

独占模式下的AQS

独占模式下,临界资源只允许被一个线程占用。

一般情况下,state的初始值设为0,表示空闲。当一个线程获取到同步状态时,利用CAS操作让state加1,表示非空闲。

获取资源时,有tryAcquire和 acquire 两种模式:

  1. tryAcquire尝试以独占的方式获取资源,如果获取成功,则直接返回true,否则直接返回false
  2. acquire尝试以独占的方式获取资源,由于默认实现方式是非公平的,所以线程会先尝试获取临界资源,如果临界资源已被占用,会将线程挂起加入到等待队列中,等待临界资源释放。

队列的head元素表示占用临界资源的线程,当临界资源可用时,AQS会唤醒等待的线程,然后将head指向争抢到临界的资源的节点。

共享模式下的AQS

共享模式下,临界资源允许被多个线程占用。

一般情况下,可以将state的初始值设为N(N > 0),表示空闲。每当一个线程获取到同步状态时,就利用CAS操作让state减1,直到减到0表示非空闲。

获取资源时,有 tryAcquireShared 和 acquireShared 两种模式:

  1. tryAcquireShared 返回一个int:
  • 负值代表获取失败;
  • 0代表获取成功,但没有剩余资源
  • 正数表示获取成功,还有剩余资源,其他线程还可以去获取。
  1. acquireShared,与独占模式类型,默认非公平实现,所以线程会先尝试获取临界资源,如果临界资源获取失败,将线程加入到等待队列中,等待有临界资源可用后被唤醒。

不管时共享模式还是独占模式,AQS均不响应中断,即加入到同步队列中的线程,如果因为中断而被唤醒的话,不会立即返回并抛出InterruptedException。而是再次去判断其前驱节点是否为head节点,决定是否争抢同步状态。如果其前驱节点不是head节点或者争抢同步状态失败,那么再次挂起。等争抢到临界资源后再自行中断,也就时下面代码中的selfInterrupt。

public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
}