取法其上,得乎其中

Java 各种各样的锁: 悲观锁 / 乐观锁 / 共享锁 / 公平锁 / 可重入锁 / 自旋锁

关于锁

锁在并发编程之中是必不可少的一部分,且由于 Synchronize 使用起来效率不高,而且缺乏灵活性,所以出现了各式各样的锁。但值得说明的是,如标题所示 “悲观锁、乐观锁、共享锁、公平锁、可重入锁、自旋锁” 大部分并不是代指某个锁的名字这样叫,我认为是指符合这一特性的一类锁。

使用 ReentrantLock.tryLock 解决死锁问题


关于死锁问题,简而言之便是两个线程所被阻塞的缘故是因为缺少对方所持有的锁,因此导致双方都无法正常运行。

而.tryLock 最大的作用就是当在指定时间内(或及时反馈)无法获取到锁的话,会直接返回false,这样我们把要执行的任务放在为true的条件下。当目前任务由于无法继续执行后需任务的时候,则要求此任务结束此次操作,因此该任务所持有的锁将会被释放。

当释放当前拥有的锁之后再进入睡眠,那么另外一个线程借此可能就成功获得该锁,以此死锁便被破除。

    static class t1 implements Runnable {
    @SneakyThrows
    @Override
    public void run() {
        boolean isSuccess = false;
        while (true) {
            if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
                try {
                    System.out.println("t1获取到lock1");
                    Thread.sleep(new Random().nextInt(1000));
                    if (lock2.tryLock(800, TimeUnit.MILLISECONDS)) {
                        try {
                            System.out.println("t1获取到lock2");
                        } finally {
                            lock2.unlock();
                            isSuccess = true;
                        }
                    } else {
                        System.out.println("t1未能获得lock2, 正在重试");
                    }
                } finally {
                    lock1.unlock();
                    Thread.sleep(new Random().nextInt(1000)); //解锁后进行睡眠
                }
            } else {
                System.out.println("t1未能获得lock1, 正在重试");
            }
            if (isSuccess) {
                System.out.println("t1获取到了两把锁");
                break;
            }
        }

    }
}

悲观锁与公平锁

悲观锁

该锁认为,如果自己不去锁住自己要锁的资源的话,那么其他的线程就会来争抢,因此悲观锁为了确保线程安全,会将数据全部锁住使别人无法访问。其代表实现就是Synchronized和Lock相关类。

乐观锁

该锁认为自己在处理数据的时候一般不会有其他线程来争抢资源,因此不会锁住对应的对象,但当乐观锁更新的时候会去对比哪些数据有没有发生改变。如果没有发生改变,那就说明数据只有自己在操作,因此正常执行。但如果发现数据与一开始拿到的不一样的话,那么当前操作会被放弃或报错以及重试等策略。

可重入锁

主要思想为,如果当前要获取的锁已经被锁住了,且获取这把锁的线程是当前线程,那么此时也可以获取锁。

显然,如果该锁不允许可重入性质,那么f2方法将会与f1方法抢夺锁,以致于发生死锁问题。

而这正是 ReentrantLock之所以叫可重入锁的缘故。在其代码之中可以看到,如果当前锁的持有者是当前线程,那么可对当前线程共享这把锁。

公平和非公平锁

公平锁指的是按照线程的请求顺序来分配锁的所得权,而非公平锁则指不完全按照请求的顺序,在某种情况下可以插队。

那么在什么样的情况下非公平锁会允许某个线程插队呢?

假设线程B执行完毕后去归还锁,之后由A拿到锁,但A从睡眠中被唤醒是存在一定的时间的,假设这时线程C刚好处于活跃状态,那么则这把锁会让线程C插队拿到。

因为即使此时线程C并没有插队,此时线程A也是无法进行任务的,所以优先让线程C获得锁,以提升整体运行效率。

ReentrantLock 公平与非公平设置

显然,ReentrantLock 拥有一个参数表明当前锁是公平锁还是非公平锁,默认为非公平锁。

公平与非公平输出结果

根据上述概念可知,如果我们将锁设计为公平锁,那么我们得到的任务执行顺序一定是根据顺序而来,反之几乎一定存在插队现象。

代码如下。


public static ReentrantLock lock = new ReentrantLock(false);

public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
        new Thread(() -> f()).start();
    }
}
public static void f() {
    lock.lock();
    try {
        System.out.println(Thread.currentThread().getName() + ": 正在执行1");
        Thread.sleep(200);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        lock.unlock();
    }
    lock.lock();
    try {
        System.out.println(Thread.currentThread().getName() + ": 正在执行2");
    } finally {
        lock.unlock();
    }
}

而其后分别构建公平锁以及非公平锁执行,得其结果如下。

显然,在非公平锁的情况下当第一部分代码执行完毕且释放锁后,随后就被后面一部分的代码直接运行(即插队到最前方)。而在公平锁的情况下则是根据队列依次按顺序运行。

共享锁和排他锁

排他锁,又称为独享锁,当其被使用之时,其他线程无法读亦无法写,这是前述锁的共识,即 ReentrantLock 就是排他锁。

共享锁,便是当它锁住某个资源的时候,资源虽然不允许修改或删除,但允许其他线程去读取操作。共享锁之所以存在,因为有时多个线程去获得资源时可能只是为了去读取资源,这时并不会出现线程安全问题

在Java中的典型是读写锁 ReentrantReadWriteLock, 这把锁下有两把锁以它为载体。其一为读锁,即共享锁。其二为写锁,即排它锁。

public static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public static ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
public static ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();

总的来说,当多个线程申请读锁(进行读操作)的时候,是允许它们都持有锁的,显然这也就意味着读写锁适合于读多写少的并发场景。但如果有一个线程已经持有锁且要进行写操作,那么其他线程就无法拿到锁了,哪怕它们只是进行读操作。


public static void read() {
    readLock.lock();
    try {
        System.out.println(Thread.currentThread().getName() + "已获得读锁");
        Thread.sleep(500);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        readLock.unlock();
        System.out.println(Thread.currentThread().getName() + "已释放读锁");
    }
}

public static void write() {
    writeLock.lock();
    try {
        System.out.println(Thread.currentThread().getName() + "已获得写锁");
        Thread.sleep(500);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        writeLock.unlock();
        System.out.println(Thread.currentThread().getName() + "已释放写锁");
    }
}

其运行结果为:

另外值得一说的是,读写锁中的读锁在公平锁下一般是不允许插队的。假设此时线程A和B进行读操作,而C和D在队列之中等待,C为写操作,D为读操作。按道理说D完全可以插队,因为它和A以及B是丝毫不干涉的,可以直接同时拥有锁,如果插队可以效率更高。

但如果C只有并不只有一个D,而有成千上万个读,那么线程C将很久不能得到被执行,此时便会出现很大的问题。

那么什么时候是允许插队的呢?即,假设线程A为写锁,其后有读锁B、C、D。当写锁A结束的一瞬间,此时进入了读锁E,那么E就可能插队到B、C、D之前。

自旋锁和阻塞锁

自旋锁一般适用于被锁的代码块内容非常简单,其执行的速度可能小于CP{切换线程状态(即阻塞状态和运行状态的切换)消耗的时间,那么这时被锁住的线程当被阻塞的时候并不会对线程状态进行切换,而是重复进行尝试获取锁(即自旋)。

而阻塞锁则相反,只要无法拿到锁那边直接把线程切换到阻塞状态,等待被唤醒。

降级策略

当线程A持有写锁时,此后有读锁B、C、D,如果想要在线程A不抛弃写锁的同时,使后面的B、C、D执行的话,可以通过再获得一把读锁从而实现降级操作,使后者得以执行。

Java 各种各样的锁: 悲观锁 / 乐观锁 / 共享锁 / 公平锁 / 可重入锁 / 自旋锁

https://ku-m.cn/index.php/archives/585/

作者

KuM

发布时间

2022-07-14

许可协议

CC BY 4.0

本页的评论功能已关闭