并发学习笔记 (5)

591 查看

tutorials site

Locks in java

Locks (and other more advanced synchronization mechanisms) are created using synchronized blocks, so it is not like we can get totally rid of the synchronized keyword.
锁的实现是利用synchonized, wait(),notify()方法实现的。所以不可以认为锁可以完全脱离synchonized实现。

Java包 JUC java.util.concurrent.locks 包括了很多lock接口的实现了类,这些类足够使用。
但是需要知道如何使用它们,以及这些类背后的理论。JUC包教程

用synchonized:可以保证在同一时间只有一个线程可以执行 return ++count:

public class Counter{
  private int count = 0;

  public int inc(){
    synchronized(this){
      return ++count;
    }
  }
}

以下的Counter类用Lock代替synchronized 达到同样的目的:
lock() 方法会对 Lock 实例对象进行加锁,因此所有其他对该对象调用 lock() 方法的线程都会被阻塞,直到该 Lock 对象的 unlock() 方法被调用。

public class Counter{
  private Lock lock = new Lock();
  private int count = 0;

  public int inc(){
    lock.lock();
    int newCount = ++count;
    lock.unlock();
    return newCount;
  }
}

那么问题来了, Lock类是怎么设计的?


Lock 类的设计

一个Lock类的简单实现:

javapublic class Lock{
  private boolean isLocked = false;

  public synchronized void lock() throws InterruptedException{
    while(isLocked){
      wait();
    }
    isLocked = true;
  }

  public synchronized void unlock(){
    isLocked = false;
    notify();
  }
}

while(isLocked) 循环, 又被称为spin lock自旋锁。当 isLockedtrue 时,调用 lock() 的线程在 wait() 调用上阻塞等待。为防止该线程没有收到 notify() 调用也从 wait() 中返回(也称作虚假唤醒),这个线程会重新去检查 isLocked 条件以决定当前是否可以安全地继续执行还是需要重新保持等待,而不是认为线程被唤醒了就可以安全地继续执行了。如果 isLocked 为 false,当前线程会退出 while(isLocked) 循环,并将 isLocked 设回 true,让其它正在调用 lock() 方法的线程能够在 Lock 实例上加锁。

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

锁的可重入性

synchronized 同步块是可重入的。这意味着: 如果一个java线程进入了代码中的同步块synchonzied block,并因此获得了该同步块使用的同步对象对应的管程monitor object上的锁那么这个线程可以进入由同一个管程对象所同步的另一个 java 代码块

前面的Lock的设计就不是可重入的:

javapublic class Reentrant2{
    Lock lock = new Lock();

    public outer(){
        lock.lock();
        inner();
        lock.unlock();
    }

    public synchronized inner(){
        lock.lock();
        //do something
        lock.unlock();
    }
}

一个线程是否被允许退出 lock() 方法是由 while 循环(自旋锁)中的条件决定的。当前的判断条件是只有当 isLocked 为 false 时 lock 操作才被允许,而没有考虑是哪个线程锁住了它。
所以需要对Lock的设计做出如下修改,才能可重入。

javapublic class Lock{
    boolean isLocked = false;
    Thread  lockedBy = null;
    int lockedCount = 0;

    public synchronized void lock()
        throws InterruptedException{
        Thread callingThread =
            Thread.currentThread();
        while(isLocked && lockedBy != callingThread){
            wait();
        }
        isLocked = true;
        lockedCount++;
        lockedBy = callingThread;
  }

    public synchronized void unlock(){
        if(Thread.curentThread() ==
            this.lockedBy){
            lockedCount--;

            if(lockedCount == 0){
                isLocked = false;
                notify();
            }
        }
    }

    ...
}

注意到现在的 while 循环(自旋锁)也考虑到了已锁住该 Lock 实例的线程。如果当前的锁对象没有被加锁 (isLocked = false),或者当前调用线程已经对该 Lock 实例加了锁,那么 while 循环就不会被执行,调用 lock() 的线程就可以退出该方法(译者注:“被允许退出该方法” 在当前语义下就是指不会调用 wait() 而导致阻塞)。

除此之外,我们需要记录同一个线程重复对一个锁对象加锁的次数。否则,一次 unblock() 调用就会解除整个锁,即使当前锁已经被加锁过多次。在 unlock() 调用没有达到对应 lock() 调用的次数之前,我们不希望锁被解除。

现在这个 Lock 类就是可重入的了。

锁的公平性

 Starvation and Fairness 饥饿和公平

一个线程因为其他线程长期占有CPU而自己获得不到,这种状态称为Starvation. 解决线程饥饿的方法是公平机制fairness公平机制,让所有线程都能公平的有机会去获得CPU。

导致饥饿的原因

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

Java's synchronized code blocks can be another cause of starvation.

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

这里细说一下:多线程通过共享一个object对象,来调用对象的wait/notifyAll 来导致线程等待或者唤醒; 每次一个线程进入同步块,其他所有线程陷入等待状态;然后active线程调用notifyALL()函数唤醒所有等待线程,所有线程竞争,只有一个线程竞争成功,获得CPU执行。竞争失败的线程处于就绪状态,长期竞争失败的线程就会饥饿。

线程之间的对资源(object)竞争导致的饥饿,为了避免竞争,所以想办法一次唤醒一个线程。也就是下面讲的FairLock 公平锁机制。

Implementing Fairness in Java

使用锁lock来代替同步块synchonized block

每一个调用 lock() 的线程都会进入一个队列,当解锁后,只有队列里的第一个线程 (队首)被允许锁住 Fairlock 实例,所有其它的线程都将处于等待状态,直到他们处于队列头部。
公平锁实现机制:为每一个线程创建一个专属锁对象(而非多个线程共享一个对象,来wait/notify()),然后用一个队列来管理这些锁对象,尝试加锁的线程会在各自的对象上等待,当一个线程unlock的时候,只通知队列头的锁对象,以唤醒其对应的线程

为了让这个 Lock 类具有可重入性,我们需要对它做一点小的改动:

javapublic class FairLock {
    private boolean           isLocked       = false;
    private Thread            lockingThread  = null;
    private List<QueueObject> waitingThreads =
            new ArrayList<QueueObject>();

  public void lock() throws InterruptedException{
    QueueObject queueObject           = new QueueObject();
    boolean     isLockedForThisThread = true;
    synchronized(this){
        waitingThreads.add(queueObject);
    }

    while(isLockedForThisThread){
      synchronized(this){
        isLockedForThisThread =
            isLocked || waitingThreads.get(0) != queueObject;
        if(!isLockedForThisThread){
          isLocked = true;
           waitingThreads.remove(queueObject);
           lockingThread = Thread.currentThread();
           return;
         }
      }
      try{
        queueObject.doWait();
      }catch(InterruptedException e){
        synchronized(this) { waitingThreads.remove(queueObject); }
        throw e;
      }
    }
  }

  public synchronized void unlock(){
    if(this.lockingThread != Thread.currentThread()){
      throw new IllegalMonitorStateException(
        "Calling thread has not locked this lock");
    }
    isLocked      = false;
    lockingThread = null;
    if(waitingThreads.size() > 0){
      waitingThreads.get(0).doNotify();
    }
  }
}