- 为什么再造管程
- Lock如何保证可见性
- 什么是可重入锁
- 公平与非公平锁
- 锁的最佳实践
- Condition条件变量
- Condition在Dubbo中的应用
在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题。
Java 语言本身提供的 synchronized 也是管程的一种实现,既然 Java 从语言层面已经实现了管程了,那为什么还要在 SDK 里提供另外一种实现呢?
你也许经常看到别人这样说,在 Java 的 1.5 版本中,synchronized 性能不如 SDK 里面的 Lock,但 1.6 版本之后,synchronized 做了很多优化,将性能追了上来,所以 1.6 之后的版本又有人推荐使用 synchronized 了。如果仅仅是因为性能的限制,那么优化一下即可,大可不必再重新造一个管程。
在破解死锁问题的解决方案中,有一个破坏不可抢占条件方案,但是这个
方案 synchronized 没有办法解决。原因是 synchronized 申请资源的时候,如果申请不
到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占
有的资源。但我们期望对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
如果重新设计一把锁机解决synchronized不能破解死锁不可抢占的问题,你会从哪几个方面进行考虑呢?
- 能够响应中断。synchronized 的问题是,持有锁 A 后,如果尝试获取锁 B 失败,那么
线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞
状态的线程能够响应中断信号,也就是说当我们给阻塞的线程发送中断信号的时候,能
够唤醒它,那它就有机会释放曾经持有的锁 A。这样就破坏了不可抢占条件了。 - 支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个
错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。 - 非阻塞地获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线
程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
Java SDK并发包的Lock方法从上面三个角度进行了实现,对应到API的三个方法。
// 支持中断的 API void lockInterruptibly() throws InterruptedException; // 支持超时的 API boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 支持非阻塞获取锁的 API boolean tryLock();Lock如何保证可见性
Java 里多线程的可见性是通过 Happens-Before 规则保证的,而 synchronized 之所以能够保证可见性,也是因为有一条 synchronized 相关的规则:synchronized 的解锁 Happens-Before 于后续对这个锁的加锁。那 Java SDK 里面 Lock 靠什么保证可见性呢?
举个例子:线程 T1 对value 进行了 +=1 *** 作,那后续的线程 T2 能够看到 value 的正确结果吗?
class X { private final Lock rtl = new ReentrantLock(); int value; public void addOne() { // 获取锁 rtl.lock(); try { value+=1; } finally { // 保证锁能释放 rtl.unlock(); } } }
Java SDK 里面锁的实现非常复杂,这里我就不展开细说了,但是原理还是需要简单介绍一下:它是利用了 volatile 相关的 Happens-Before 规则。Java SDK里面的 ReentrantLock,内部持有一个 volatile 的成员变量 state,获取锁的时候,会读写state 的值;解锁的时候,也会读写 state 的值。
根据相关的 Happens-Before 规则:
- 顺序性规则:对于线程 T1,value+=1 Happens-Before 释放锁的 *** 作 unlock();
- volatile 变量规则:由于 state = 1 会先读取 state,所以线程 T1 的 unlock() *** 作
Happens-Before 线程 T2 的 lock() *** 作; - 传递性规则:线程 T1 的 value+=1 Happens-Before 线程 T2 的 lock() *** 作。
我们创建的锁的具体类名是 ReentrantLock,这个翻译过来叫可重入锁,所谓可重入锁,顾名思义,指的是线程可以重复获取同一把锁。
例如下面代码中,当线程 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(); } } }公平与非公平锁
ReentrantLock 这个类有两个构造函数,一个是无参构造函数,一个是传入 fair 参数的构造函数。fair 参数代表的是锁的公平策略,如果传入 true 就表示需要构造一个公平锁,反之则表示要构造一个非公平锁。
// 无参构造函数:默认非公平锁 public ReentrantLock() { sync = new NonfairSync(); } // 根据公平策略参数创建锁 public ReentrantLock(boolean fair){ sync = fair ? new FairSync() : new NonfairSync(); }
锁都对应着一个等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。如果是公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁,很公平;如果是非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒。
锁的最佳实践在此引入并发大师Doug Lea的最佳实践总结:
- 永远只在更新对象的成员变量时加锁
- 永远只在访问可变的成员变量时加锁
- 永远不在调用其他对象的方法时加锁
Java 语言内置的管程里只有一个条件变量,而 Lock&Condition 实现的管程是支持多个条件变量的,这是二者的一个重要区别。
例如,实现一个阻塞队列,就需要两个条件变量:
一个阻塞队列,需要两个条件变量,一个是队列不空(空队列不允许出队),另一个是队列
不满(队列已满不允许入队)。
public class BlockedQueue{ 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(); } } }
这里你需要注意,Lock 和 Condition 实现的管程,线程等待和通知需要调用
await()、signal()、signalAll(),它们的语义和 wait()、notify()、notifyAll() 是相同的。
RPC 调用,在 TCP 协议层面,发送完 RPC 请求后,线程是不会等待 RPC 的响应结果的。平时工作中的 RPC 调用大多数都是同步的啊?这是怎么回事呢?其实很简单,一定是有人帮你做了异步转同步的事情。例如目前知名的 RPC 框架 Dubbo就给我们做了异步转同步的事情。
如果此时你将调用线程 dump 出来的话,你会发现调用线程阻塞了,**线程状态是 TIMED_WAITING。本来发送请求是异步的,但是调用线程却阻塞了,说明Dubbo 帮我们做了异步转同步的事情。**通过调用栈,你能看到线程是阻塞在DefaultFuture.get() 方法上,所以可以推断:Dubbo 异步转同步的功能应该是通过DefaultFuture 这个类实现的。
public class DubboInvoker{ Result doInvoke(Invocation inv){ // 下面这行就是源码中 108 行 // 为了便于展示,做了修改 return currentClient.request(inv, timeout).get(); } }
当 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(); } }
调用线程通过调用 get() 方法等待 RPC 返回结果,这个方法里面,你看到的都是熟悉的“面孔”:调用 lock() 获取锁,在 finally 里面调用 unlock() 释放锁;获取锁后,通过经典的在循环中调用 await() 方法来实现等待。当 RPC 结果返回时,会调用 doReceived() 方法,这个方法里面,调用 lock() 获取锁,在finally 里面调用 unlock() 释放锁,获取锁后通过调用 signal() 来通知调用线程,结果已经返回,不用继续等待了。
总结:
觉得有用的客官可以点赞、关注下!感谢支持谢谢!
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)