ReentrantLock 基本使用

在看 ReentrantLock 使用之前,先来了解一下线程活跃性;活跃性分为:死锁、活锁和饥饿。

死锁

一个线程需要获取多把锁的时候,有可能就会发生死锁。

例如下面这个例子:

  • t1 获得 A 对象锁,接下来想获取 B 对象锁
  • t2 获得 B 对象锁,接下来想获取 A 对象锁
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
    synchronized (A) {
        log.debug("lock A");
        sleep(1);
        synchronized (B) {
            log.debug("lock B");
            log.debug("操作...");
        }
    }
}, "t1");

Thread t2 = new Thread(() -> {
    synchronized (B) {
        log.debug("lock B");
        sleep(0.5);
        synchronized (A) {
            log.debug("lock A");
            log.debug("操作...");
        }
    }
}, "t2");
t1.start();
t2.start();

活锁

两个线程相互改变对方的结束条件,导致谁也结束不了。

public class TestLiveLock {
    static volatile int count = 10;
    static final Object lock = new Object();
    public static void main(String[] args) {
        new Thread(() -> {
            // 期望减到 0 退出循环
            while (count > 0) {
                sleep(0.2);
                count--;
                log.debug("count: {}", count);
            }
        }, "t1").start();
        new Thread(() -> {
            // 期望超过 20 退出循环
            while (count < 20) {
                sleep(0.2);
                count++;
                log.debug("count: {}", count);
            }
        }, "t2").start();
    }
}

饥饿

线程饥饿不太好演示,线程饥饿指的就是:某个线程一只获取不到锁,或者说获取锁的次数小于其它线程。

小结

对于死锁和饥饿可以是用 RenntrantLock 解决,但是对于活锁可以通过增加随机时间,相互错开结束条件解决。

ReentrantLock

ReentrantLock 是在 java.util.concurrent 并发工具包下的一个重要工具类,相对于 synchronized 它具备如下特点:

  • 可中断
  • 可以设置超时时间
  • 可以设置为公平锁
  • 支持多个条件变量

与 synchronized 一样,都支持可重入(同一个线程多次获取同一把锁)。

下面是一个获取锁的基本方式:

public class SharedObject {
    ReentrantLock lock = new ReentrantLock();

    public void perform() {
        // 获取锁
        lock.lock();
        try {
            // 临界区
        } finally {
            // 释放锁
            lock.unlock();
        }
    }
}

可打断

ReentrantLock#lock() 方法和 synchronized 获取锁的时候是一样的,会一直等待(不可打断)直到获取了锁;而可打断的意思是说,打断你获取锁;就是说不用你获取锁了。

如果你允许获取锁的时候被被人打断,那么可以使用 ReentrantLock#lockInterruptibly() 方法获取锁。当别的线程调用了 interrupt() 方法时,该方法就会抛出异常。

ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
    log.debug("启动...");
    try {
        lock.lockInterruptibly();
    } catch (InterruptedException e) {
        e.printStackTrace();
        log.debug("等锁的过程中被打断");
        return;
    }
    try {
        log.debug("获得了锁");
    } finally {
        lock.unlock();
    }
}, "t1");

lock.lock();
log.debug("获得了锁");
t1.start();
try {
    sleep(1);
    t1.interrupt();
    log.debug("执行打断");
} finally {
    lock.unlock();
}

锁超时

就是说,多长时间内没有获取到锁,就不获取锁了。而想要打到这个效果,就需要使用 ``ReentrantLock#tryLock()` 方法,如果使用无参方法,则没有获取到锁后直接返回;如果使用带参方法,则超过指定时间后还没有取到锁则返回。

指的注意的是:tryLock(..) 也是支持可中断的;并且可以用它来解决死锁问题。

ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
    log.debug("启动...");
    if (!lock.tryLock()) {
        log.debug("获取立刻失败,返回");
        return;
    }
    try {
        log.debug("获得了锁");
    } finally {
        lock.unlock();
    }
}, "t1");


lock.lock();
log.debug("获得了锁");

t1.start();
try {
    sleep(2);
} finally {
    lock.unlock();
}

条件变量

synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入 waitSet 等待。

ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比 synchronized 是那些不满足条件的线程都在一间休息室等消息,而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒。

使用要点:

  • await 前需要获得锁
  • await 执行后,会释放锁,进入 conditionObject 等待
  • await 的线程被唤醒(或打断、或超时)取重新竞争 lock 锁
  • 竞争 lock 锁成功后,从 await 后继续执行
static ReentrantLock lock = new ReentrantLock();
static Condition waitCigaretteQueue = lock.newCondition();
static Condition waitbreakfastQueue = lock.newCondition();
static volatile boolean hasCigrette = false;
static volatile boolean hasBreakfast = false;
public static void main (String[]args){
    new Thread(() -> {
        try {
            lock.lock();
            while (!hasCigrette) {
                try {
                    waitCigaretteQueue.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            log.debug("等到了它的烟");
        } finally {
            lock.unlock();
        }
    }).start();

    new Thread(() -> {
        try {
            lock.lock();
            while (!hasBreakfast) {
                try {
                    waitbreakfastQueue.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            log.debug("等到了它的早餐");
        } finally {
            lock.unlock();
        }
    }).start();
    sleep(1);
    sendBreakfast();
    sleep(1);
    sendCigarette();
}

private static void sendCigarette () {
    lock.lock();
    try {
        log.debug("送烟来了");
        hasCigrette = true;
        waitCigaretteQueue.signal();
    } finally {
        lock.unlock();
    }
}

private static void sendBreakfast () {
    lock.lock();
    try {
        log.debug("送早餐来了");
        hasBreakfast = true;
        waitbreakfastQueue.signal();
    } finally {
        lock.unlock();

    }
}

也就是可以创建多个 Condition,将线程放入不同的 Condition 中进行等待,可以随机唤醒 Condition 中的一个线程或唤醒所有线程。

公平锁

ReentrantLock 默认是非公平锁。

ReentrantLock lock = new ReentrantLock(false);
lock.lock();
for (int i = 0; i < 500; i++) {
    new Thread(() -> {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " running...");
        } finally {
            lock.unlock();
        }
    }, "t" + i).start();
}

// 1s 之后去争抢锁
Thread.sleep(1000);
new Thread(() -> {
    System.out.println(Thread.currentThread().getName() + " start...");
    lock.lock();
    try {
        System.out.println(Thread.currentThread().getName() + " running...");
    } finally {
        lock.unlock();
    }
}, "强行插入").start();
lock.unlock();

公平锁 VS 非公平锁

ReentrantLock 的公平锁是为了解决饥饿问题,可以通过构造方法 ReentrantLock(boolean fair) 来启用公平锁(默认是非公平锁);但是一般根本不会启用公平锁。

公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,只有队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU 不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

他们两个的主要区别就是:线程有一定几率不会阻塞,而是直接获取锁(就是说,一个线程执行完毕,另一个线程刚好要获取锁),这样就先少了唤起线程的开销;公平锁是,只有等待队列的第一个线程才能获取锁。

直接用语言描述可能有点抽象,这里作者用从别处看到的一个例子来讲述一下公平锁和非公平锁。

如上图所示,假设有一口水井,有管理员看守,管理员有一把锁,只有拿到锁的人才能够打水,打完水要把锁还给管理员。每个过来打水的人都要管理员的允许并拿到锁之后才能去打水,如果前面有人正在打水,那么这个想要打水的人就必须排队。管理员会查看下一个要去打水的人是不是队伍里排最前面的人,如果是的话,才会给你锁让你去打水;如果你不是排第一的人,就必须去队尾排队,这就是公平锁。

但是对于非公平锁,管理员对打水的人没有要求。即使等待队伍里有排队等待的人,但如果在上一个人刚打完水把锁还给管理员而且管理员还没有允许等待队伍里下一个人去打水时,刚好来了一个插队的人,这个插队的人是可以直接从管理员那里拿到锁去打水,不需要排队,原本排队等待的人只能继续等待。如下图所示:

参考资料

不可不说的Java“锁”事

posted @ 2021-06-26 13:04  代号-G  阅读(806)  评论(0)    收藏  举报