并发编程学习笔记2——管程、Lock & Condition、信号量

文章目录

  • 一、管程
    • 1.什么是管程
    • 2.MESA 模型
      • 互斥
      • 同步
    • 3.锁的正确用法
    • 4.wait() 的正确使用
    • 5.notify() 何时可以使用
  • 二、Lock和Condition
    • 1.Lock
    • 2.可重入锁
    • 3.可重入函数
    • 4.公平锁与非公平锁
    • 5.加锁的最佳实践
    • 6.Condition
    • 7.同步与异步
  • 三、Semaphore
    • 1.信号量模型
    • 2.使用信号量实现累加器
    • 3.使用信号量实现一个限流器
  • 思考题


一、管程

1.什么是管程

Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。

管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。

2.MESA 模型

在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是
同步,即线程之间如何通信、协作。

互斥

管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。如果想访问共享变量,只能通过调用管程提供的互斥方法,即只允许一个线程进入管程。
并发编程学习笔记2——管程、Lock & Condition、信号量_第1张图片

同步

在管程模型里,共享变量和对共享变量的操作是被封装起来的,当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量 A 和条件变量 B 分别都有自己的等待队列。
并发编程学习笔记2——管程、Lock & Condition、信号量_第2张图片

假设有个线程 T1 执行出队操作,不过需要注意的是执行出队操作,有个前提条件,就是队列不能是空的,而队列不空这个前提条件就是管程里的条件变量。 如果线程 T1 进入管程后恰好发现队列是空的,那怎么办呢?等待啊,去哪里等呢?就去条件变量对应的等待队列里面等。此时线程 T1 就去“队列不空”这个条件变量的等待队列中等待。线程 T1 进入条件变量的等待队列后,是允许其他线程进入管程的。

再假设之后另外一个线程 T2 执行入队操作,入队操作执行成功之后,“队列不空”这个条件对于线程 T1 来说已经满足了,此时线程 T2 要通知 T1,告诉它需要的条件已经满足了。当线程 T1 得到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列里面。

条件变量及其等待队列我们讲清楚了,下面再说说 wait()、notify()、notifyAll() 这三个操作。前面提到线程 T1 发现“队列不空”这个条件不满足,需要进到对应的等待队列里等待。这个过程就是通过调用 wait() 来实现的。如果我们用对象 A 代表“队列不空”这个条件,那么线程 T1 需要调用 A.wait()。同理当“队列不空”这个条件满足时,线程 T2 需要调用 A.notify() 来通知 A 等待队列中的一个线程,此时这个队列里面只有线程 T1。至于 notifyAll() 这个方法,它可以通知等待队列中的所有线程。

下面的代码实现的是一个阻塞队列,阻塞队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口。

对于入队操作,如果队列已满,就需要等待直到队列不满,所以这里用了 notFull.await();

对于出队操作,如果队列为空,就需要等待直到队列不空,所以就用了 notEmpty.await();

public class BlockedQueue<T>{
  final Lock lock =
    new ReentrantLock();
  // 条件变量:队列不满  
  final Condition notFull =
    lock.newCondition();
  // 条件变量:队列不空  
  final Condition notEmpty =
    lock.newCondition();

  // 入队
  void enq(T x) {
    lock.lock();
    try {
      while (队列已满){
        // 等待队列不满 
        notFull.await();
      }  
      // 省略入队操作...
      //入队后,通知可出队
      notEmpty.signal();
    }finally {
      lock.unlock();
    }
  }
  // 出队
  void deq(){
    lock.lock();
    try {
      while (队列已空){
        // 等待队列不空
        notEmpty.await();
      }
      // 省略出队操作...
      //出队后,通知可入队
      notFull.signal();
    }finally {
      lock.unlock();
    }  
  }
}

3.锁的正确用法

锁应该是私有的、不可变的、不可重用的。

// 普通对象锁
private final Object 
  lock = new Object();
// 静态对象锁
private static final Object
  lock = new Object(); 

4.wait() 的正确使用

while(条件不满足) {
  wait();
}

MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。

5.notify() 何时可以使用

除非经过深思熟虑,否则尽量使用 notifyAll() 。

使用 notify() 需要满足以下三个条件:

  • 所有等待线程拥有相同的等待条件;
  • 所有等待线程被唤醒后,执行相同的操作;
  • 只需要唤醒一个线程。

比如上面阻塞队列的例子中,对于“队列不满”这个条件变量,其阻塞队列里的线程都是在等待“队列不满”这个条件,所有等待线程被唤醒后执行的操作也是相同的,同时也满足第 3 条,只需要唤醒一个线程。所以上面阻塞队列的代码,使用 signal() 是可以的。

二、Lock和Condition

1.Lock

Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题。

Lock 和 synchronized 区别:

  • synchronized 不能破坏不可抢占条件,原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了。
  • Lock 提供三种方法解决这个问题。
  1. 能够响应中断,从而唤醒它,那它就有机会释放曾经持有的锁 A。
  2. 支持超时。
  3. 非阻塞地获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回。
// 支持中断的API
void lockInterruptibly() 
  throws InterruptedException;
// 支持超时的API
boolean tryLock(long time, TimeUnit unit) 
  throws InterruptedException;
// 支持非阻塞获取锁的API
boolean tryLock();
class X {
  private final Lock rtl =
  new ReentrantLock();
  int value;
  public void addOne() {
    // 获取锁
    rtl.lock();  
    try {
      value+=1;
    } finally {
      // 保证锁能释放
      rtl.unlock();
    }
  }
}

Lock 使用范式

try {
	lock();
} finally {
	unlock();
}

如何保证可见性

利用了 volatile 相关的 Happens-Before 规则。Java SDK 里面的 ReentrantLock,内部持有一个 volatile 的成员变量 state,获取锁的时候,会读写 state 的值;解锁的时候,也会读写 state 的值。

  • 顺序性规则:对于线程 T1,value+=1 Happens-Before 释放锁的操作 unlock()
  • volatile 变量规则:T1 的 unlock() 操作 Happens-Before 线程 T2 的 lock() 操作
  • 传递性规则:线程 T1 的 value+=1 Happens-Before 线程 T2 的 lock() 操作。

2.可重入锁

可重入锁,指的是线程可以重复获取同一把锁。

例如下面代码中,当线程 T1 执行到 ① 处时,已经获取到了锁 rtl ,当在 ① 处调用 get() 方法时,会在 ② 再次对锁 rtl 执行加锁操作。此时,如果锁 rtl 是可重入的,那么线程 T1 可以再次加锁成功;如果锁 rtl 是不可重入的,那么线程 T1 此时会被阻塞。

class X {
  private final Lock rtl =
  new ReentrantLock();
  int value;
  public int get() {
    // 获取锁
    rtl.lock();try {
      return value;
    } finally {
      // 保证锁能释放
      rtl.unlock();
    }
  }
  public void addOne() {
    // 获取锁
    rtl.lock();  
    try {
      value = 1 + get();} finally {
      // 保证锁能释放
      rtl.unlock();
    }
  }
}

3.可重入函数

可重入函数,指的是多个线程可以同时调用该函数,每个线程都能得到正确结果;同时在一个线程内支持线程切换,无论被切换多少次,结果都是正确的。也就是线程安全的。

4.公平锁与非公平锁

ReentrantLock 这个类有两个构造函数,一个是无参构造函数,一个是传入 fair 参数的构造函数。fair 参数代表的是锁的公平策略,如果传入 true 就表示需要构造一个公平锁,反之则表示要构造一个非公平锁。

//无参构造函数:默认非公平锁
public ReentrantLock() {
    sync = new NonfairSync();
}
//根据公平策略参数创建锁
public ReentrantLock(boolean fair){
    sync = fair ? new FairSync() 
                : new NonfairSync();
}
  • 公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁。
  • 非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒。

5.加锁的最佳实践

  • 永远只在更新对象的成员变量时加锁
  • 永远只在访问可变的成员变量时加锁
  • 永远不在调用其他对象的方法时加锁

调用其他对象的方法,实在是太不安全了,也许其他方法里面有线程 sleep() 的调用,也可能会有奇慢无比的 I/O 操作,这些都会严重影响性能。更可怕的是,其他类的方法可能也会加锁,然后双重加锁就可能导致死锁。

6.Condition

Condition 实现了管程模型里面的条件变量。

Lock&Condition 实现的管程是支持多个条件变量的,synchronized 只能有一个条件变量。

线程等待和通知需要调用 await()、signal()、signalAll()。

7.同步与异步

通俗点来讲就是调用方是否需要等待结果,如果需要等待结果,就是同步;如果不需要等待结果,就是异步。

如果你想让你的程序支持异步,可以通过下面两种方式来实现:

  • 调用方创建一个子线程,在子线程中执行方法调用,这种调用我们称为异步调用;
  • 方法实现的时候,创建一个新的线程执行主要逻辑,主线程直接 return,这种方法我们一般称为异步方法。

Dubbo 异步转同步:TCP 协议本身就是异步的,在 TCP 协议层面,发送完 RPC 请求后,线程是不会等待 RPC 的响应结果的。Dubbo 异步转同步的功能是通过 DefaultFuture 这个类实现的。

当 RPC 返回结果之前,阻塞调用线程,让调用线程等待;当 RPC 返回结果后,唤醒调用线程,让调用线程重新执行。

大致的原理代码如下:

// 创建锁与条件变量
private final Lock lock 
    = new ReentrantLock();
private final Condition done 
    = lock.newCondition();

// 调用方通过该方法等待结果
Object get(int timeout){
  long start = System.nanoTime();
  lock.lock();
  try {
  while (!isDone()) {
    done.await(timeout);
      long cur=System.nanoTime();
    if (isDone() || 
          cur-start > timeout){
      break;
    }
  }
  } finally {
  lock.unlock();
  }
  if (!isDone()) {
  throw new TimeoutException();
  }
  return returnFromResponse();
}
// RPC结果是否已经返回
boolean isDone() {
  return response != null;
}
// RPC结果返回时调用该方法   
private void doReceived(Response res) {
  lock.lock();
  try {
    response = res;
    if (done != null) {
      done.signal();
    }
  } finally {
    lock.unlock();
  }
}

三、Semaphore

1.信号量模型

可以概括为:一个计数器,一个等待队列,三个方法。计数器和等待队列对外是透明的,所以只能通过信号量模型提供的三个方法来访问它们,这三个方法分别是:init()、down() 和 up()。

  • init():设置计数器的初始值。
  • down():计数器的值减 1;如果此时计数器的值小于 0,则当前线程将被阻塞,否则当前线程可以继续执行。
  • up():计数器的值加 1;如果此时计数器的值小于等于 0,则唤醒等待队列中的一个线程,并将其从等待队列中移除。

Java 信号量模型是由 java.util.concurrent.Semaphore 实现的,这三个方法都是原子操作

2.使用信号量实现累加器

在累加器的例子里面,count+=1 操作是个临界区,只允许一个线程执行,也就是说要保证互斥。

static int count;
//初始化信号量
static final Semaphore s 
    = new Semaphore(1);
//用信号量保证互斥    
static void addOne() {
  s.acquire();
  try {
    count+=1;
  } finally {
    s.release();
  }
}

3.使用信号量实现一个限流器

Semaphore 有一个功能是 Lock 不容易实现的,那就是:允许多个线程访问一个临界区。常见的有:池化资源,同一时刻允许多个线程同时使用连接池。

只要把计数器的值设置成对象池里对象的个数 N,就能完美解决对象池的限流问题了。

public class PoolWithSemaphore {
    class ObjPool<T, R> {
        final List<T> pool;
        final Semaphore sem;

        // 构造函数
        ObjPool(int size, T t) {
            pool = new Vector<T>() {
            };
            for (int i = 0; i < size; i++) {
                pool.add(t);
            }
            sem = new Semaphore(size);
        }

        // 利用对象池的对象,调用func
        R exec(Function<T, R> func) throws InterruptedException {
            T t = null;
            sem.acquire();
            try {
                t = pool.remove(0);
                return func.apply(t);
            } finally {
                pool.add(t);
                sem.release();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        // 创建对象池
        PoolWithSemaphore x = new PoolWithSemaphore();
        ObjPool<Integer, String> pool = x.new ObjPool<Integer, String>(10, 2);
        // 通过对象池获取t,之后执行
        pool.exec(t -> {
            System.out.println(t);
            return t.toString();
        });
    }
}

这个例子中,对象保存在了 Vector 中,这里不可以换成 ArrayList,因为ArrayList 非线程安全,因为可能存在多个线程同时执行 remove 和 add 方法,会导致不可预知的错误。


思考题

1、wait() 方法,在 MESA 模型里面,增加了超时参数,你觉得这个参数有必要吗?

:有,避免没人唤醒一直阻塞。超时后会退出条件变量的等待队列,重新进入管程的等待队列。


2、你已经知道 tryLock() 支持非阻塞方式获取锁,下面这段关于转账的程序就使用到了 tryLock(),你来看看,它是否存在死锁问题呢?

class Account {
  private int balance;
  private final Lock lock
          = new ReentrantLock();
  // 转账
  void transfer(Account tar, int amt){
    while (true) {
      if(this.lock.tryLock()) {
        try {
          if (tar.lock.tryLock()) {
            try {
              this.balance -= amt;
              tar.balance += amt;
            } finally {
              tar.lock.unlock();
            }
          }//if
        } finally {
          this.lock.unlock();
        }
      }//if
    }//while
  }//transfer
}

:不会死锁,但是可能活锁,两个线程同时对自己加锁,然后获取对方的锁失败,又释放自己的锁,反复循环。可以在外层 finally 处加一个随机时间的sleep。


3、DefaultFuture 里面唤醒等待的线程,用的是 signal(),而不是 signalAll(),你来分析一下,这样做是否合理呢?

:合理。每个rpc请求都会占用一个线程并产生一个新的DefaultFuture实例,它们的lock&condition是不同的,并没有竞争关系


参考资料:王宝令----Java并发编程实战

你可能感兴趣的:(java基础)