java并发系列 - 02信号量机制

在上篇介绍Lock互斥锁的文章末尾,提到了使用互斥锁的潜在问题。即当线程检测到Lock是锁定状态的情况下,它会被阻塞,然后在waiting queue队列中等待。处理机只负责定期地从等待队列中取出一个线程,让其再次检测锁的状态。如果可获得锁,那么Ok,这个线程进入临界区,继续自己的执行流程。但如果二次检测还是没有得到锁,那它将再次等待。之后可能还会重复这个过程。

其实,分析下原因,不难发现判断互斥锁状态然后进入临界区的逻辑是每个线程自己负责的。而且这个过程可能会重复检测多次。这些都是影响处理机执行效率的原因。

信号量(Semaphore)解决了线程重复检测互斥锁状态的问题。下面分析下信号量的机制:

信号量机制中,使用整数sem代表信号量,P、V原语控制协调线程对可用共享资源的访问。一次P原语操作使得信号量sem减1,V原语操作与之相反,它使信号量sem加1。

P原语操作的主要过程包括:

  1. sem减1。
  2. 如果sem减1后大于或等于0,则P原语执行结束,线程可以进入临界区继续执行。
  3. 如果sem减1后小于0,则执行P原语操作的线程将被阻塞并被放入与该信号量相对应的等待队列,然后处理机转线程调度。
P原语.png

V原语操作包括以下主要过程:

  1. sem加1。
  2. 如果sem加1后结果大于0,则V原语停止执行返回。该线程返回到调用处,继续执行。
  3. 如果sem加1后结果仍小于或等于0,则从与信号量对应的等待队列中唤醒一个阻塞线程,然后再返回原线程继续执行或者转处理机调度。
V原语.png

这么说可能感觉还是比较抽象,举个栗子就易于理解了。假设把教室比做临界区学生比做访问临界区的线程。教室一次只允许一个学生进入使用,使用完走出教室后另一个学生才能进去。使用互斥锁机制同步访问临界区就相当于,每次学生在使用教室之前,都需要自己跑到教室门口,看看教室门是否是锁着的。如果没锁,则可以进入使用,如果门被上锁,则他只好回去,然后等下个时间点再来看教室是否可用。而信号量机制中,信号量sem相当于教室的管理员。如果教室门上锁学生无法进入时,管理员会记录下这个人的名字。并在教室门打开时通知该学生进入。这样既省去了学生多次往返教室检查门是否打开的时间,同时也减少了学生自己检查造成的不公平现象。例如,有的学生可能来了很多次发现教室门都是锁着的,而有的学生只来一次就能进入教室。

介绍完理论的部分,现在来看下java内部时如何实现信号量机制的。java使用Semaphore类封装了信号量sem和P、V原语操作。在构造器方法中,用户可以指定permit(许可)参数,当permit大于等于0时表示可以供并发线程使用的资源实体数量;而当permit小于0时则表示正等待使用临界区的线程数。acquire()方法封装了P操作,release()方法对V操作做了封装。线程使用信号量机制访问临界区的流程如下:

semaphore.png

如果permit参数设置为1,则可以实现和互斥锁对临界区锁定的相同功能。下面通过代码示例学习一下信号量的用法:

// SyncWithSemaphore.java
import java.util.concurrent.*;
import java.util.concurrent.Semaphore;

public class SyncWithSemaphore {
  private static Account account = new Account();

  public static void main(String[] args) {
    ExecutorService executor = Executors.newCachedThreadPool();
    
    // 创建两个并发线程
    for (int i = 0; i < 2; i++) 
      executor.execute(new AddPennyTask());
  
    executor.shutdown();
     
    // 等待所有线程执行结束
    while (!executor.isTerminated()) {
    }
    
    System.out.println("结果: " + account.getBalance());  
  }
  
  private static class AddPennyTask implements Runnable {
    @Override 
    public void run() {
      account.deposit(1);
    }
  }

  private static class Account {
    private static Semaphore semaphore = new Semaphore(1);
    private int balance = 0;

    public int getBalance() {
      return this.balance;
    }

    public void deposit(int amount) {
      try {
        semaphore.acquire();  //获取访问许可
        int newBalance = balance + amount;
        Thread.sleep(100);
        balance = newBalance;
      } catch (InterruptedException ex) {
      } finally {
        semaphore.release();  //释放许可
      }
    }
  }
}

上面代码中创建了两个并发线程,用t0和t1表示。permit参数被赋初值为1。假设t0首先得到处理机先开始执行。在t0线程执行deposit方法时,首先申请获得许可。此时permit值为1,调用acquire方法后permit值为0,表示没有线程占用临界区,所以t0可以进入到deposit方法。如果此时t1线程也得到处理机开始执行,则它也对permit做减1操作,t0线程若未结束,则t1使permit减1后值变为-1。-1说明此时有一个线程已进入临界区,t1无法获得临界区,t1需等待进入临界区。当t0执行完临界区代码对permit值加1操作,permit值变为0。表明此时在等待队列中有1个等待线程,因此t1被唤醒。t1进入临界区,t1执行完临界区代码后再次对permit做加1操作,此时permit值变为1。说明等待队列中没有处于等待状态的线程,则t1执行结束,处理机转线程调度。

总结:

信号量机制和互斥锁都可以实现线程同步访问临界区的方法。但信号量机制解决了互斥锁可能存在的性能问题。在系统可靠性和执行效率方面,信号量机制的性能要更好一些。

你可能感兴趣的:(java并发系列 - 02信号量机制)