在操作系统中,进程之间经常有互斥
和协作
两种关系,为了有效处理这两种情况,W.Dijkstra在1965年提出了信号量(semaphore 塞吗佛
)和PV操作。
1. 信号量与PV机制
信号量是一种抽象的数据类型,由一个整型S变量和P原语、V原语组成(原语:即不可中断的过程)。并且这个整型变量只能由PV改变。
- P(S)意味着
S-1
,若S-1<0
,说明资源不够用,将进程加入到等待队列中; - V(S)意味着
S+1
,若S+1<=0
,说明等待队列中存在进程,那么唤醒一个等待进程;
信号是操作系统提供的一种协调共享资源访问的方法。信号量由操作系统进行管理,地位高于进程,操作系统保证信号量的原子性。
注意:信号量的值仅能由PV操作来改变,而由操作系统保证,PV操作都是原子操作。
线程执行P()可能会被阻塞,但是执行V()是不会被阻塞的。
信号量的具体实现:
public class Semaphore {
private int sem; //整型S
private Queue<Thread> waitQueue = new ArrayDeque<>(); //等待队列
public Semaphore(int sem) {
this.sem = sem;
}
//伪代码,该操作由操作系统保证原子性
public void P(Thread thread) {
sem--;
//若无资源,线程阻塞
if (sem < 0) {
waitQueue.offer(thread);
}
}
//伪代码,该操作由操作系统保证原子性
public void V() {
sem++;
//线程释放资源后,此时等待队列中依旧存在线程
//释放一个线程
//注:sem的绝对值为等待线程的数量
if (sem <= 0) {
waitQueue.poll();
}
}
}
信号量的分类:
- 二进制信号量:资源数目0或1;
- 资源信号量:资源数目为任何非负值;
如何使用信号量完成互斥操作:
- 进入临界区使用P操作(S=S-1),若信号量S>=0,则进入临界区,否则进入等待队列进行阻塞;
- 退出临界区使用V操作(S=S+1),若信号量S<=0,则证明等待队列中存在线程,唤醒一个等待线程。
(1) 每类资源设置一个信号量,其初始值为1;
mutex=new Semaphore(1);
(2) 必须成对的使用P()操作和V()操作;
- P()通过信号量S保证了互斥访问临界资源;
- V()操作在使用后是否临界资源并唤醒等待的线程;
如何使用信号量完成同步操作:
一个线程A使用P()操作,一个线程B使用V()操作。初始信号量设置为0,则为了满足条件,必须在线程B执行之后,才能执行线程A。
生产这-消费者问题便是信号量完成同步的一个典型应用:
问题分析:
- 任何时刻只能有一个生产者或消费者访问缓冲区;(互斥访问)
- 缓冲区空时,生产者可访问缓冲区;
- 缓冲区满时,消费者可访问缓冲区;
需要依靠两种信号量来完成操作:
- 消费者-生产者互斥(二进制信号量:mutex)
- 消费者(资源信号量:fullBuffers)
- 生产者(资源信号量:emptyBuffers)
public class BoundedBuffer {
//二进制信号量(互斥,同一时刻只能一个线程访问临界区)
private Semaphore mutex=new Semaphore(1);
//资源信号量(同步)
private Semaphore fullBuffers=new Semaphore(0);
//资源信号量(同步)
private Semaphore emptyBuffers=new Semaphore(10);
public void produce(){
//空盘子资源-1
emptyBuffers.P(Thread.currentThread());
//互斥锁资源-1,加锁
mutex.P(Thread.currentThread());
/**
* 生产资源
*/
//互斥锁资源+1,释放锁,并唤醒资源
mutex.V();
//满盘子资源+1,唤醒消费者
fullBuffers.V();
}
public void consume(){
//满盘子-1
fullBuffers.P(Thread.currentThread());
//互斥锁资源-1,加锁
mutex.P(Thread.currentThread());
/**
* 消费资源
*/
//互斥锁资源+1,释放锁,并唤醒资源
mutex.V();
//空盘子资源+1,并唤醒生产者资源
emptyBuffers.V();
}
}
2. 管程Monitor机制
信号量机制的缺点:进程自备同步操作,P(S)和V(S)操作大量分散在各个进程中,不易管理并且易发生死锁;
管程特点:管程将临界区上PV操作封装起来,实现了同步操作。并且在管程中运行的线程可临时放弃管程的互斥访问。
引入管程的目的:把分散在各进程的临界区集中起来进行管理,防止进程违法的同步操作,便于高级语言来书写程序,也便于程序正确性的验证。
管程的属性:
- 共享性:管程可以被范围内的进互斥访问,属于共享资源;
- 互斥性:任一时刻,管程中只能有一个活跃进程;
- 封装性:管程内的数据结构是私有的,只能在管程中被使用。进程通过调用管程的过程使用临界资源。
2.1 管程的组成
- 一个锁:控制管程代码的互斥访问;
- 0或者多个条件变量:管理共享数据的并发访问;
管程入口处的等待队列:
管程是封装了临界区上semaphore的PV操作,即具有互斥性。当多个线程同时调用P操作时,只能有一个线程进入临界区,其他线程会进入入口处的等待队列;
管程内的资源等待队列:
管程用于管理资源的,当进入管程的进程因资源被占用等原因不能继续执行时,将其加入资源等待队列,该队列由条件变量维护。资源等待队列可以有多个,每个资源对应着一个队列。
条件变量:
条件变量(例如名称为c)是管程内的一种数据结构,且只有在管程中才能访问,它对管程内所有过程是全局的,只能通过两个原语(即原子性)操作来控制它。
c.wait():调用进程阻塞并移入与条件变量c相关的队列中,并释放管程,直到另一个进程在该条件变量c上执行signal()唤醒等待的进程并移出队列。
c.signal():如果存在其他进程由于对条件变量c执行wait()而被释放,便释放之,如果没有进程在等待,那么信号被丢弃。
条件变量时一种信号量,但不是PV操作中纯粹的计数信号量,没有与条件变量关联的值,不能像semaphore(信号量)一样累积起来供以后使用,仅仅起到维护资源等待队列的作用。因此在使用条件变量x时,常常需要定义一个与之配套使用的整型变量x-count
用于记录条件变量x所维护等待队列中的进程数。
而wait()和signal()也是可以看做P()和V()操作的。
2.2 JAVA管程
在Java使用是的是Mesa 梅莎
管程模型。其结构如下图所示:
其实管程一般由三部分组成:
- 一个锁:控制管程代码的互斥访问,并维护了入口线程队列;
- 条件变量:管理共享数据的并发访问,维护资源等待队列;
- 临界区:操纵共享资源的代码块;
Meas管程的特点:
线程阻塞状态被唤醒之后不会立即执行,而是回到入口等待。当阻塞的线程再次获取到CPU后,会执行wait()方法后的代码。此时若不使用while循环(取代if判断),可能会引起异常!
运行结果请点击....
public static void main(String[] args) throws InterruptedException {
produceAndCustome resouce = new produceAndCustome();
//消费资源
Thread customer1 = new Thread(resouce::custom);
Thread customer2 = new Thread(resouce::custom);
customer1.start();
customer2.start();
Thread.sleep(500);
//开始生产资源
Thread procuder1 = new Thread(resouce::produce);
procuder1.start();
}
ObjectMonitor() {
_header = NULL;
//获取管程锁的次数
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
//持有该ObjectMonitor线程的指针
_owner = NULL;
//管程的条件变量的资源等待队列
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
//管程的入口线程队列
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
2.3 Java的重量级锁
无论是semaphore(信号量)机制还是Monitor(管程)机制,本质上都是在OS进行操作的。
在JDK1.6之前,用户线程(JVM线程)若想借助Java Monitor(synchronized关键字)去控制并发,线程会进行用户态到内核态的转换。而这个过程,是比较耗费时间的。
所以JDK1.6后对synchronized关键字进行了优化,使用了自旋锁,让并发的线程先不借助monitor进行同步操作,而是自旋一段时间。等待临界区
线程执行完毕。
轻量级锁:
轻量级锁的相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先强调的一点是:轻量级锁不是用来取代重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统重量级锁使用产生的性能消耗。
轻量级锁适用场景是线程交替执行同步块的情况下,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
偏向锁:
偏向锁的目的是在某个线程获取锁之后,消除这个线程重入的开销。偏向锁只需要在置换ThreadID的时候依赖一次CAS指令。
2.4 管程解决生产者消费者问题
public class produceAndCustome {
//仓库最大容量
private static final int MAX_SIZE = 10;
//共享资源
private List<String> list = new ArrayList<>();
public void produce() {
//若是施加重量级锁,则开启管程(管程具有同步(互斥)性)
synchronized (list) {
//若资源已满,生产者会进入资源等待队列
while(list.size() == MAX_SIZE) {
try {
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
list.add("产品");
//生产产品后,便唤醒消费者中的线程
list.notifyAll();
}
}
public void custom() {
synchronized (list) {
while (list.size() == 0) {
try {
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
list.remove(0);
//消费产品后,便唤醒消费者进程
list.notifyAll();
}
}
}
3. 学习有感
自己的感悟,正确性有待商榷!
1. 什么叫做重量级锁:
若synchronized借助monitor机制实现互斥(伴随线程由用户态切换到内核态)。那么就是重量级锁;
2. wait()方法为啥要在synchronized中使用:
只因为synchronized关键字(重量级锁,开启了管程),使得对象指向了ObjectMonitor对象,所以调用对象的wait()和notify等方法才会将线程阻塞(加入到_WaitSet中)。
3. wait()为啥是Object方法
又因为wait()和notify()是ObjectMonitor的方法。而Object对象头中保存了ObjectMonitor的指针,所以是Object便可操作wait()方法。
总结:
使用synchronized开启重量级锁时,object与objectMonitor(java管程)进行关联。执行object的wait方法。实际上是执行管程的wait原语操作,即将线程放入到条件变量所维护的资源等待队列中。
推荐阅读
Java并发基石——所谓“阻塞”:Object Monitor和AQS(1)
Java精通并发-通过openjdk源码分析ObjectMonitor底层实现
相关阅读
JAVA并发(1)—java对象布局
JAVA并发(2)—PV机制与monitor(管程)机制
JAVA并发(3)—线程运行时发生GC,会回收ThreadLocal弱引用的key吗?
JAVA并发(4)— ThreadLocal源码角度分析是否真正能造成内存溢出!
JAVA并发(5)— 多线程顺序的打印出A,B,C(线程间的协作)
JAVA并发(6)— AQS源码解析(独占锁-加锁过程)
JAVA并发(7)—AQS源码解析(独占锁-解锁过程)
JAVA并发(8)—AQS公平锁为什么会比非公平锁效率低(源码分析)
JAVA并发(9)— 共享锁的获取与释放
JAVA并发(10)—interrupt唤醒挂起线程
JAVA并发(11)—AQS源码Condition阻塞和唤醒
JAVA并发(12)— Lock实现生产者消费者