【Java并发系列】----JUC之Lock

显式锁 Lock

在Java 5.0之前,协调共享对象的访问时可以使用的机制只有synchronized和volatile。Java 5.0后增加了一些新的机制,但并不是一种替代内置锁的方法,而是当内置锁不适用时,作为一种可选择的高级功能。

ReentrantLock(可重入锁) 实现了 Lock 接口,并提供了与synchronized 相同的互斥性和内存可见性。但相较于synchronized 提供了更高的处理锁的灵活性 。

部分内容引用自 并发编程网

一个简单的Lock示例:

 1 public class Counter{
 2     private Lock lock = new Lock();
 3     private int count = 0;
 4 
 5     public int inc(){
 6         lock.lock();
 7         int newCount = ++count;
 8         lock.unlock();
 9         return newCount;
10     }
11 }

与synchronized关键字给当前类对象上锁不同,lock方法会对Lock实例对象进行加锁,因此所有对该对象调用lock()方法的线程都会被阻塞,直到该Lock对象的unlock()方法被调用。

下面是一个Lock类的简单实现(非源码):

 1 public class Counter{
 2 public class Lock{
 3     private boolean isLocked = false;
 4 
 5     public synchronized void lock()
 6         throws InterruptedException{
 7         while(isLocked){
 8             wait();
 9         }
10         isLocked = true;
11     }
12 
13     public synchronized void unlock(){
14         isLocked = false;
15         notify();
16     }
17 }

注意其中的while(isLocked)循环,它又被叫做“自旋锁”。当isLocked为true时,调用lock()的线程在wait()调用上阻塞等待。如果isLocked为false,当前线程会退出while(isLocked)循环,并将isLocked设回true,让其它正在调用lock()方法的线程能够在Lock实例上加锁。

当线程完成了临界区(位于lock()和unlock()之间)中的代码,就会调用unlock()。执行unlock()会重新将isLocked设置为false,并且通知(唤醒)其中一个(若有的话)在lock()方法中调用了wait()函数而处于等待状态的线程。

这里之所以使用while循环而不是用if判断唤醒条件,是为防止线程的 虚假唤醒。同时在JDK说明文档中也建议在监视唤醒时使用循环:

锁的可重入性

概念:如果一个线程已经拥有了一个管程(监视器)对象上的锁,那么它就有权访问被这个管程对象同步的所有代码块。这就是可重入。线程可以进入任何一个它已经拥有的锁所同步着的代码块。

例如在synchronized关键字中,如果两个方法或代码块所同步的是同一个对象,那么在这两个方法或代码块相互调用或运行时,是不会被这个synchronized同步所阻塞的。

在前面的Lock简单实现例子中,是无法实现线程重入的,如下例所示:

 1 public class Reentrant2{
 2     Lock lock = new Lock();
 3 
 4     public outer(){
 5         lock.lock();
 6         inner();
 7         lock.unlock();
 8     }
 9 
10     public synchronized inner(){
11         lock.lock();
12         //do something
13         lock.unlock();
14     }
15 }

 

因为线程共享的是同一lock实例,当在inner()方法中调用lock()方法时,会发现这个Lock实例被锁住了,从而导致阻塞。

这是由于在循环语句中isLocked判断没有考虑是哪个线程锁住了它。

所以,在实现一个可重入的锁时,需要记录是哪个线程上的锁,同时还要记录上锁的次数

 1 public class Lock{
 2     boolean isLocked = false;
 3     Thread  lockedBy = null;
 4     int lockedCount = 0;
 5 
 6     public synchronized void lock()
 7         throws InterruptedException{
 8         Thread callingThread =
 9             Thread.currentThread();
10         while(isLocked && lockedBy != callingThread){
11             wait();
12         }
13         isLocked = true;
14         lockedCount++;
15         lockedBy = callingThread;
16   }
17 
18     public synchronized void unlock(){
19         if(Thread.curentThread() ==
20             this.lockedBy){
21             lockedCount--;
22 
23             if(lockedCount == 0){
24                 isLocked = false;
25                 notify();
26             }
27         }
28     }
29 
30     ...
31 }

 

之所以要记录上锁的次数,是因为在unlock()调用没有达到对应lock()调用的次数之前,我们不希望锁被一次性解除。

锁的公平性

先来介绍一下线程中的不公平现象(也称作线程饥饿)

Java中导致饥饿的原因:

  • 高优先级线程吞噬所有的低优先级线程的CPU时间。

你能为每个线程设置独自的线程优先级,优先级越高的线程获得的CPU时间越多,线程优先级值设置在1到10之间,而这些优先级值所表示行为的准确解释则依赖于你的应用运行平台。对大多数应用来说,你最好是不要改变其优先级值。

  • 线程被永久堵塞在一个等待进入同步块的状态。

Java的同步代码区也是一个导致饥饿的因素。Java的同步代码区对哪个线程允许进入的次序没有任何保障。这就意味着理论上存在一个试图进入该同步区的线程处于被永久堵塞的风险,因为其他线程总是能持续地先于它获得访问,这即是“饥饿”问题,而一个线程被“饥饿致死”正是因为它得不到CPU运行时间的机会。

  • 线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的wait方法)。

如果多个线程处在wait()方法执行上,而对其调用notify()不会保证哪一个线程会获得唤醒,任何线程都有可能处于继续等待的状态。因此存在这样一个风险:一个等待线程从来得不到唤醒,因为其他等待线程总是能被获得唤醒。

 

为避免发生上述线程饥饿导致的不公平现象,提高等待线程的公平性,我们使用锁方式来替代同步块。

以下引自 饥饿和公平

在Lock类的实现中,有一个公平锁Fairlock。它的基本实现思路为:让每个线程在不同的对象上调用wait(),使得只有一个线程在每个对象上调用wait(),Lock类可以决定哪个对象能对其调用notify(),从而实现有效的选择唤醒哪个线程。

 1 public class FairLock {
 2     private boolean           isLocked       = false;
 3     private Thread            lockingThread  = null;
 4     private List<QueueObject> waitingThreads =
 5             new ArrayList<QueueObject>();
 6 
 7   public void lock() throws InterruptedException{
 8     QueueObject queueObject           = new QueueObject();
 9     boolean     isLockedForThisThread = true;
10     synchronized(this){
11         waitingThreads.add(queueObject);
12     }
13 
14     while(isLockedForThisThread){
15       synchronized(this){
16         isLockedForThisThread =
17             isLocked || waitingThreads.get(0) != queueObject;
18         if(!isLockedForThisThread){
19           isLocked = true;
20            waitingThreads.remove(queueObject);
21            lockingThread = Thread.currentThread();
22            return;
23          }
24       }
25       try{
26         queueObject.doWait();
27       }catch(InterruptedException e){
28         synchronized(this) { waitingThreads.remove(queueObject); }
29         throw e;
30       }
31     }
32   }
33 
34   public synchronized void unlock(){
35     if(this.lockingThread != Thread.currentThread()){
36       throw new IllegalMonitorStateException(
37         "Calling thread has not locked this lock");
38     }
39     isLocked      = false;
40     lockingThread = null;
41     if(waitingThreads.size() > 0){
42       waitingThreads.get(0).doNotify();
43     }
44   }
45 }
FairLock
 1 public class QueueObject {
 2 
 3     private boolean isNotified = false;
 4 
 5     public synchronized void doWait() throws InterruptedException {
 6 
 7     while(!isNotified){
 8         this.wait();
 9     }
10 
11     this.isNotified = false;
12 
13 }
14 
15 public synchronized void doNotify() {
16     this.isNotified = true;
17     this.notify();
18 }
19 
20 public boolean equals(Object o) {
21     return this == o;
22 }
23 
24 }
QueueObject

首先注意到lock()方法不在声明为synchronized,取而代之的是对必需同步的代码,在synchronized中进行嵌套。

FairLock新创建了一个QueueObject的实例,并对每个调用lock()的线程进行入队列。调用unlock()的线程将从队列头部获取QueueObject,并对其调用doNotify(),以唤醒在该对象上等待的线程。通过这种方式,在同一时间仅有一个等待线程获得唤醒,而不是所有的等待线程。这也是实现FairLock公平性的核心所在。

请注意,在同一个同步块中,锁状态依然被检查和设置,以避免出现滑漏条件。

还需注意到,QueueObject实际是一个semaphore。doWait()和doNotify()方法在QueueObject中保存着信号。这样做以避免一个线程在调用queueObject.doWait()之前被另一个调用unlock()并随之调用queueObject.doNotify()的线程重入,从而导致信号丢失。queueObject.doWait()调用放置在synchronized(this)块之外,以避免被monitor嵌套锁死,所以另外的线程可以解锁,只要当没有线程在lock方法的synchronized(this)块中执行即可。

最后,注意到queueObject.doWait()在try – catch块中是怎样调用的。在InterruptedException抛出的情况下,线程得以离开lock(),并需让它从队列中移除。

在finally语句中调用unlock()

如果用Lock来保护临界区,并且临界区有可能会抛出异常,那么在finally语句中调用unlock()就显得非常重要了。这样可以保证这个锁对象可以被解锁以便其它线程能继续对其加锁。以下是一个示例:

lock.lock();
try{
    //do critical section code,
    //which may throw exception
} finally {
    lock.unlock();
}

 

这个简单的结构可以保证当临界区抛出异常时Lock对象可以被解锁。如果不是在finally语句中调用的unlock(),当临界区抛出异常时,Lock对象将永远停留在被锁住的状态,这会导致其它所有在该Lock对象上调用lock()的线程一直阻塞。

Lock与synchronized的比较

  1. Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
  2. synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
  3. Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
  4. 通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
  5. Lock可以提高多个线程进行读操作的效率。

深入JVM锁机制之一:synchronized

深入JVM锁机制之二:Lock

Lock与synchronized 的区别

Java.util.Lock

关于Lock的说明

Lock 框架是同步的兼容替代品,它提供了 synchronized 没有提供的许多特性,它的实现在争用下提供了更好的性能。但是,这些明显存在的好处,还不足以成为用 ReentrantLock 代替 synchronized 的理由。相反,应当根据您是否 需要 ReentrantLock 的能力来作出选择。大多数情况下,您不应当选择它 —— synchronized 工作得很好,可以在所有 JVM 上工作,更多的开发人员了解它,而且不太容易出错。只有在真正需要 Lock 的时候才用它。

 

相关资料:

Java中的锁

饥饿和公平

JDK 5.0 中更灵活、更具可伸缩性的锁定机制

posted @ 2017-03-19 22:02  代码简史  阅读(325)  评论(0编辑  收藏  举报