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

  1. 什么是显示锁?显示锁的作用是?
    答:

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

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    public interface Lock {
    void lock(); // 普通的获取锁方法,会阻塞直到成功
    void lockInterruptibly() throws InterruptedException; // 与 lock() 不同的是,它可以响应中断,如果被其他线程中断了,则抛出 InterruptedException 异常
    boolean tryLock(); // 只是尝试获取锁,立即返回,不阻塞,如果获取成功,返回 true,否则返回 false
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 先尝试获取锁,如果能成功则立即返回 true,否则阻塞等待,但等待的最长时间由指定的参数设置,在等待的同时响应中断,如果发生了中断,则抛出 InterruptedException,如果在等待的时间内获得了锁,返回 true,否则返回 false
    void unlock(); // 普通的释放锁方法
    Condition newCondition; // 新建一个条件,一个 Lock 可以关联多个条件
    }
  1. 显示锁接口 Locksynchronized 相比如何?
    答:相比 synchronized,显示锁支持以非阻塞方式获取锁、可以响应中断、可以限时,这使它灵活很多

  2. 可重入锁 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;
      }
      }
  1. 使用 tryLock() 可以避免死锁吗?
    答:

    • 使用 tryLock() 可以避免死锁
    • 在持有一个锁获取另一个锁而获取不到的时候,可以释放已持有的锁,给其他线程获取锁的机会,然后重试获取所有锁
  2. 【笔试题】手写一个使用 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
    36
    // 表示账户的类
    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();
    }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 在账户之间转账,需要两个账户都锁定,如果不使用 tryLock(),而直接使用 lock,则转账的错误写法如下
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
31
// 模拟账户转账的死锁过程
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();
}
}
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
// 使用 tryLock() 进行修改
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。不管怎样,结束都会释放所有锁
1
2
3
4
5
6
7
8
9
10
// transfer() 方法可以循环调用 tryTransfer() 以避免死锁
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);
}
  1. ReentrantLock 的实现原理?
    答:

    • 在最底层,它依赖于 CAS 方法
    • 另外,它依赖于类 LockSupportAQS 中的一些方法
  2. LockSupport 的基本方法有哪些?
    答:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 使当前线程放弃 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) // 通知操作系统调度,使参数指定的线程恢复可运行状态
  1. 这些 park()/unpark() 是怎样实现的?
    答:

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

    • 利用 CASLockSupport 提供的基本方法,就可以用来实现 ReentrantLock 了。
    • Java 中还有很多其他并发工具,如 ReentranReadWriteLockSemaphoreCountDownLatch,它们的实现有很多类似的地方。
    • 为了复用代码,Java 提供了一个抽象类 AbstractQueuedSynchronizer,简称 AQSAQS 简化了并发工具的实现,AQS 的整体实现比较复杂。
    • AQS 封装了一个状态,给子类提供了查询和设置状态的方法。
    • 用于实现锁时,AQS 可以保存锁的当前持有线程,提供了方法进行查询和设置。
    • ReentrantLock 内部使用 AQS
  3. ReentrantLocklock() 方法的基本过程是怎样的?
    答:能获得锁就立即获得,否则加入等待队列,被唤醒后检查自己是否是第一个等待的线程,如果是且能获得锁,则返回,否则继续等待。这个过程中如果发生了中断,lock() 会纪录中断标志位,但不会提前返回或抛出异常

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

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

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