大家好,我是Java不惑(WX公众号同名),这是专栏的第三篇文章。
在前面的两篇文章中,我介绍了volatile、cas以及其在处理器中的实现。
我们需要知道,volatile和cas是最基础的工具,实际的业务场景中共享变量的同步问题是非常复杂的,所以很少直接使用它们来处理同步问题。
基于这个工具上面我们可以封装成“锁”,方便我们解决多线程同步问题。今天我会讲一下基于volatile+cas实现的同步机制,也就是信号量和管程。
信号量
信号量的概念是由荷兰计算机科学家艾兹赫尔·戴克斯特拉(Edsger W. Dijkstra)于1965年发明,广泛的应用于不同的操作系统中。在系统中,给予每一个进程一个信号量,代表每个进程目前的状态,未得到控制权的进程会在特定地方被强迫停下来,等待可以继续进行的信号到来。如果信号量是一个任意的整数,通常被称为计数信号量。
信号量在《计算机操作系统》一书中应该已经学过了,下面我再简单介绍一下。
信号量可以看成特殊的变量,可以对它进行增加和减少的操作,并且是原子性的。
整型信号量
首先我看先看一下整型信号量,整型信号量定义为表示资源数目的整型量S,仅能通过原子操作P、V操作。
当线程想要使用共享变量时,会先获取信号量,如果拿到信号量,才被允许使用共享变量。
wait(S):
while(S <= 0) {
//循环等待
}
S = S - 1;
// 使用共享变量
signal(S):
S = S + 1;
举个例子解释一下:假设有一间会议室,因为会议室椅子有限,所以容纳的参会人员有限(不允许人站着),这时我们把会议室当做共享变量,参会人员当做线程。为了防止多个人(线程)同时进入会议室(共享变量),所以主办方在会议室门口放了一个盒子,盒子里面是一些门票(信号量S),小盒只允许一个人伸手拿门票(原子性),拿到门票就代表可以进入会议室。
当盒子里只有一张门票时(信号量S值为1),会议室(共享变量)只允许一个人(线程)进入,此时会议室(共享变量)是互斥访问的。
好了,上面就是整型信号量的介绍,使用整型信号量,我们就实现了一把非常简单的锁。我们抽象出来了门票的概念,想要同步的访问共享变量只需要获取到门票(信号量S)就可以了。
记录型信号量
上面的整型信号量是非常简陋的,并且还存在缺陷。比如在wait操作中,只要S<=0就会不断地循环,多个线程访问同一信号量,会严重消耗CPU资源。
解决方法也很简单,我们增加一个链表(队列)L,进程获取信号量资源失败,会调用block原语自我阻塞,并插入链表L中等待。
当其他线程释放信号量时,会执行signal操作,释放资源,这时链表L中如果存在进程,则调用wakeup原语,将链表中第一个等待进程唤醒,等待中的线程就会去尝试获取信号量。
AND型信号量
上述问题是解决多个进程共享同一个共享变量,如果多个进程共享多个共享变量,会出现死锁。
例如下面的例子,进程A获取S1的资源,进程B获取S2的资源,当进程A想要获取资源S2时,会因为获取不到而发生阻塞,这时进程B想要获取S1资源。因此在无外力作用下,两者都无法在阻塞状态中恢复而发生死锁。
process A: wait(S1);
process B: wait(S2);
process A: wait(S2);
process B: wait(S1);
AND同步机制的思想是:将进程在整个运行过程中所需要的资源,一致性分配给进程,如果有一个资源未能分配给进程,其他资源也需要释放。
wait(S1,……,Sn):
if(S1 >= 1 and …… and Sn >= 1) {
for(int i = 1; i <= n; i++) {
Si = Si - 1;
}
} else {
//放入等待队列中
}
signal(S1,……,S3):
for(int i = 1; i <= n; i++) {
Si = Si + 1;
}
//等待队列中移除
信号量可以解决并发中的同步问题,Java中仅JUC并发工具类中的Semaphore实现了信号量。 Java中的Semaphore用处并不大,多个线程同时访问临界区,同时进入的线程也会导致原子性问题。当然可以设置为仅允许一个线程进入,但这种情况下使用管程可能会更好。
Java中基于管程实现了锁,下面我们看一下管程是什么,以及在Java中的实现。
管程
管程(Monitor,也称为监视器)是东尼·霍尔与泊·派克·汉森提出的,并由泊·派克·汉森首次在并行Pascal中实现。东尼·霍尔证明了这与信号量是等价的。
对于管程,我们直接看下图(图片来源网络):
首先当多个线程想要进入临界区时,会首先获取到锁,如果获取不到锁就进入同步队列中,同步队列中的线程等待其他线程释放锁。拿到锁之后进入管程,进入后会判断条件变量(condition),如果不符合条件会进入等待队列中,要想再次获取锁,必须等待其他线程调用notify唤醒等待队列中的第一个线程,并进入同步队列中排队;或者等待其他线程调用notifyAll将所有等待队列中的线程放入同步队列中。
是不是想到了什么,Java中的synchronized就是使用了管程。
synchronized和管程
synchronized中,和上图不同,它仅存在一个条件变量(condition)。
Java中每个对象对应一个管程(monitor),所以在Object类中存在wait() 、notify()和notifyAll ()等方法都属于对象中的管程。
final Object lock = new Object();
synchronized (lock) {
while(条件不满足) {
lock.wait();
}
//处理完成
//释放资源,条件满足
lock.notifyAll();
}
如上面代码所示,线程进入管程时,会先加锁,如果获取到了锁就进入管程,如果获取不到锁,就进入同步队列中。
那么synchronized中的锁是怎么实现的呢?monitor中存在两个变量,一个是_owner变量,这个变量保存是哪个线程进入了管程,这个变量和信号量S是比较像的。因为synchronized是可重入锁,所以需要有一个计数器用于保存重入的次数,计数器这个变量是:count。
获取到锁之后,进入管程,也就是上面代码中synchronized修饰的大括号。
进入管程后,可以使用while()判断条件变量是否满足。当然在实际的开发过程中,条件变量(condition)使用次数还是比较少的。
如果条件不满足,会调用Object对象中的wait()方法进入等待队列中。
如果其他线程满足条件,调用notifyAll()后,会 将等待队列中的线程放入同步队列中,争用到锁之后进入管程继续执行。
上面的过程也解释了为什么wait() 、notify()和notifyAll ()需要获取到锁才能执行。
总结
在这篇文章中,我介绍了信号量和管程,以及管程在Java中的实现。希望你看完有所收获,受限于个人水平,文章若有错漏,还望读者不吝赐教。
在接下来的文章中,我将进入这个专栏的主题,也就是JUC并发类相关的介绍和实现。
最后,如果我的文章对你有帮助,请帮我点赞转发!
如果你对volatile和cas不熟悉,可以看第一篇文章《【刨根问底】带你深入理解JUC并发工具类 — volatile和cas》;
如果你向了解一下valatile的实现原理,可以看一下第二篇文章《【刨根问底】带你深入理解JUC并发工具类 — 缓存一致性和内存屏障》
也可以访问该专栏的导航文章:《【刨根问底】带你深入理解JUC并发工具类 — 开篇》