0%

Java 并发包的基石(二):显示锁

1. 什么是显示锁,显示锁的作用是

  • Java 并发包中的显示锁接口和类位于包 java.util.concurrent.locks 下,主要接口和类有:

    • 锁接口 Lock,主要实现类是 ReentrantLock
    • 读写锁接口 ReadWriteLock,主要实现类是 ReentrantReadWriteLock
  • Java 并发包中的显示锁可以解决 synchronized 的一些限制

2. 显示锁接口 Lock 的定义是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public interface Lock {
//普通的获取锁方法,会阻塞直到成功
void lock();

//与 lock() 不同的是,它可以响应中断,如果被其他线程中断了,则抛出 InterruptedException 异常
void lockInterruptibly() throws InterruptedException;

//只是尝试获取锁,立即返回,不阻塞,如果获取成功,返回 true,否则返回 false
boolean tryLock();

//先尝试获取锁,如果能成功则立即返回 true,否则阻塞等待
//但等待的最长时间由指定的参数设置,在等待的同时响应中断,如果发生了中断,则抛出 InterruptedException
//如果在等待的时间内获得了锁,返回 true,否则返回 false
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

//普通的释放锁方法
void unlock();

//新建一个条件,一个 Lock 可以关联多个条件
Condition newCondition;
}

3. 显示锁接口 Lock 与 synchronized 相比如何

  • 相比 synchronized,显示锁接口 Lock 有如下特点:
    • 显示锁支持以非阻塞方式获取锁
    • 可以响应中断
    • 可以限时

4. 可重入锁 ReentrantLock 的基本用法是

  • Lock 接口的主要实现类是 ReentrantLock,它的基本用法 lock()/unlock() 实现了与 synchronized 一样的语义,包括:

    • 可重入,一个线程在持有一个锁的前提下,可以继续获得该锁。
    • 可以解决竞态条件问题。
    • 可以保证内存可见性
  • ReentrantLock 有两个构造方法:public ReentrantLock()/public ReentrantLock(boolean fair)

    • 参数 fair 表示是否公平,不指定的情况下,默认为 false,表示不公平。
    • 公平是指,等待时间最长的线程优先获得锁
    • 保证公平会影响性能,一般也不需要,所以默认不保证synchronized 锁也是不保证公平的
  • 使用显示锁,一定要记得调用 unlock()。一般而言,应该将 lock() 之后的代码包装到 try 语句内,在 finally 语句内释放锁。比如,使用 ReentrantLock 实现 Counter

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class Counter {
    private final Lock lock = new ReentrantLock();
    private volatile int count;
    public void incr() {
    lock.lock();
    try {
    count++;
    } finally {
    lock.unlock();
    }
    }
    public int getCount() {
    return count;
    }
    }

5. 使用 tryLock() 可以避免死锁吗

  • 可以。
  • 原因:在持有一个锁获取另一个锁而获取不到的时候,可以释放已持有的锁,给其他线程获取锁的机会,然后重试获取所有锁

6. 写一个使用 tryLock() 避免死锁的银行转账 Demo

  • 表示账户的类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    public class Account {
    private Lock lock = new ReentrantLock();
    private volatile double money; //当前账户余额
    public Account(double initialMoney) {
    this.money = initialMoney;
    }
    public void add(double money) { //存钱
    lock.lock();
    try {
    this.money += money;
    } finally {
    lock.unlock();
    }
    }
    public void reduce(double money) { //取钱
    lock.lock();
    try {
    this.money -= money;
    } fianlly {
    lock.unlock();
    }
    }
    public double getMoney() {
    return money;
    }
    void lock() {
    lock.lock();
    }
    void unlock() {
    lock.unlock();
    }
    boolean tryLock() {
    return lock.tryLock();
    }
    }
  • 在账户之间转账,需要两个账户都锁定,如果不使用 tryLock(),而直接使用 lock(),则转账的错误写法如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class AccountMgr {
    public static class NoEnoughMoneyException extends Exception {}
    public static void transfer(Account from, Account to, double money) throws NoEnoughMoneyException {
    from.lock();
    try {
    to.lock();
    try {
    if(from.getMoney() >= money) {
    from.reduce(money);
    to.add(money);
    } else {
    throw new NoEnoughMoneyException();
    }
    } finally {
    to.unlock();
    }
    } finally {
    from.unlock();
    }
    }
    }
    • 这么写是有问题的,如果两个账户都同时给对方转账,都先获取了第一个锁,则会发生死锁。
  • 模拟账户转账的死锁过程:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    public static void simulatDeadLock() {
    final int accountNum = 10;
    final Account[] accounts = new Account[accountNum]; //10 个账户
    final Random rnd = new Random();
    for(int i = 0; i<accountNum; i++) {
    account[i] = new Account(rnd.nextInt(10000));
    }
    int threadNum = 100;
    Thread[] threads = new Thread[threadNum]; //100 个线程
    for(int i=0; i<threadNum; i++) {
    thread[i] = new Thread() {
    public void run() {
    int loopNum = 100;
    for(int k=0; k<loopNum; k++) { //100 次循环
    int i = rnd.nextInt(accountNum);
    int j = rnd.nextInt(accountNum);
    int money = rnd.nextInt(10);
    if(i!=j) {
    try {
    transfer(accounts[i], accounts[j], money); //每次循环中,随机挑选两个账户进行转账
    } catch(NoEnoughMoneyException e) {

    }
    }
    }
    }
    };
    thread[i].start();
    }
    }
  • 使用 tryLock() 进行修改:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public static boolean tryTransfer(Account from, Account to, double money) throws NoEnoughMoneyException {
    if(from.tryLock()) {
    try {
    if(to.tryLock()) {
    try {
    if(from.getMoney() >= money) {
    from.reduce(money);
    to.add(money);
    } else {
    throw new NoEnoughMoneyException();
    }
    return true;
    } finally {
    to.unlock();
    }
    }
    } finally {
    from.unlock();
    }
    }
    return false;
    }
    • 如果两个锁都能够获得,且转账成功,则返回 true,否则返回 false。不管怎样,结束都会释放所有锁。
  • transfer() 方法可以循环调用 tryTransfer() 以避免死锁:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public static void transfer(Account from, Account to, double money) throws NoEnoughMoneyException {
    boolean success = false;
    do {
    success = tryTransfer(from, to, money);
    if(!success) {
    Thread.yield();
    }
    } while (!success);
    }

7. ReentrantLock 的实现原理

  • 在最底层,它依赖于 CAS 方法。
  • 另外,它依赖于类 LockSupportAQS 中的一些方法。

8. LockSupport 的基本方法有哪些

1
2
3
4
5
6
7
8
9
10
//使当前线程放弃 CPU,进入等待状态(WAITING),操作系统不再对它进行调度
//不同于 Thread.yield(),yield() 只是告诉操作系统可以先让其他线程运行,但自己依然是可运行状态
//park() 会放弃调度资格,使线程进入 WAITING 状态
//park() 是响应中断的,当有中断发生时,park() 会返回,线程的中断状态会被设置
//park() 可能会无缘无故地返回,线程应该重新检查 park() 等待的条件是否满足
public static void park()

public static void parkNanos(long nanos) //可以指定等待的最长时间,参数是相对于当前时间的纳秒数
public static void parkUntil(long deadline) //可以指定最长等到什么时候,参数是绝对时间,是相对于纪元时的毫秒数。当等待超时的时候,它们也会返回
public static void unpark(Thread thread) //通知操作系统调度,使参数指定的线程恢复可运行状态

9. 这些 park()/unpark() 是怎样实现的

  • CAS 方法一样,它们也调用了 Unsafe 类中对应的方法。
  • Unsafe 类最终调用了操作系统的 API,从程序员的角度,我们可以认为 LockSupport 中的这些方法就是基本操作。

10. 什么是 AQS

  • 利用 CASLockSupport 提供的基本方法,就可以用来实现 ReentrantLock 了。
  • Java 中还有很多其他并发工具,如 ReentranReadWriteLockSemaphoreCountDownLatch,它们的实现有很多类似的地方。
  • 为了复用代码,Java 提供了一个抽象类 AbstractQueuedSynchronizer,简称 AQSAQS 简化了并发工具的实现,AQS 的整体实现比较复杂。
  • AQS 封装了一个状态,给子类提供了查询设置状态的方法。
  • 用于实现锁时,AQS 可以保存锁的当前持有线程,提供了方法进行查询和设置。
  • ReentrantLock 内部使用 AQS

11. ReentrantLock 的 lock() 方法的基本过程是怎样的

  1. 能获得锁就立即获得,否则加入等待队列。
  2. 被唤醒后检查自己是否是第一个等待的线程,如果是且能获得锁,则返回,否则继续等待。
  3. 这个过程中如果发生了中断,lock() 会纪录中断标志位,但不会提前返回或抛出异常。

12. ReentrantLock 的带参数的构造方法为什么默认不保证公平

  • 保证公平整体性能比较低,低的原因不是这个检查慢,而是会让活跃线程得不到锁,进入等待状态,引起频繁上下文切换,降低了整体的效率。
  • 通常情况下,谁先运行关系不大,而且长时间运行,从统计角度而言,虽然不保证公平,也基本是公平的。
  • 需要说明的是,即使 fairtrueReentrantLock 中不带参数的 tryLock() 方法也是不保证公平的,它不会检查是否有其他等待时间更长的线程。

13. 对比一下 ReentrantLock 和 synchronized

  • 相比 synchronizedReentrantLock 可以实现与 synchronized 相同的语义,而且支持以非阻塞方式获取锁,可以响应中断,可以限时,更为灵活;不过,synchronized 的使用更为简单,写的代码更少,也更不容器出错。
  • synchronized 代表一种声明式编程思维,程序员更多的是表达一种同步声明,由 Java 系统负责具体实现,程序员不知道其实现细节;显示锁代表一种命令式编程思维,程序员实现所有细节。
  • 声明式编程的好处除了简单,还在于性能,在较新版本的 JVM 上,ReentranLocksynchronized 的性能是接近的,但 Java 编译器和虚拟机可以不断优化 synchronized 的实现。比如,自动分析 synchronized 的使用,对于没有锁竞争的场景,自动省略对锁获取/释放的调用。
  • 简单总结下,能用 synchronized 就用 synchronized,不满足要求时再考虑 ReentranLock
-------------------- 本文结束感谢您的阅读 --------------------