JAVA并发(2)—PV机制与monitor(管程)机制

简书 · · 2618 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

JAVA并发(2)—PV机制与monitor(管程)机制

小胖学编程

JAVA并发(2)—PV机制与monitor(管程)机制

在操作系统中,进程之间经常有互斥协作两种关系,为了有效处理这两种情况,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();
        }
    }
}

信号量的分类:

  1. 二进制信号量:资源数目0或1;
  2. 资源信号量:资源数目为任何非负值;

如何使用信号量完成互斥操作:

  • 进入临界区使用P操作(S=S-1),若信号量S>=0,则进入临界区,否则进入等待队列进行阻塞;
  • 退出临界区使用V操作(S=S+1),若信号量S<=0,则证明等待队列中存在线程,唤醒一个等待线程。
图1-信号量完成互斥操作的流程.png

(1) 每类资源设置一个信号量,其初始值为1;

mutex=new Semaphore(1);

(2) 必须成对的使用P()操作和V()操作;

  • P()通过信号量S保证了互斥访问临界资源;
  • V()操作在使用后是否临界资源并唤醒等待的线程;
图2-信号量实现进程互斥的模型.png

如何使用信号量完成同步操作:

一个线程A使用P()操作,一个线程B使用V()操作。初始信号量设置为0,则为了满足条件,必须在线程B执行之后,才能执行线程A。

生产这-消费者问题便是信号量完成同步的一个典型应用:

图3-生产者-消费者问题描述.png

问题分析:

  1. 任何时刻只能有一个生产者或消费者访问缓冲区;(互斥访问)
  2. 缓冲区空时,生产者可访问缓冲区;
  3. 缓冲区满时,消费者可访问缓冲区;

需要依靠两种信号量来完成操作:

  • 消费者-生产者互斥(二进制信号量:mutex)
  • 消费者(资源信号量:fullBuffers)
  • 生产者(资源信号量:emptyBuffers)
图4-生产者-消费者模型.png
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操作封装起来,实现了同步操作。并且在管程中运行的线程可临时放弃管程的互斥访问。

引入管程的目的:把分散在各进程的临界区集中起来进行管理,防止进程违法的同步操作,便于高级语言来书写程序,也便于程序正确性的验证。

管程的属性:

  1. 共享性:管程可以被范围内的进互斥访问,属于共享资源;
  2. 互斥性:任一时刻,管程中只能有一个活跃进程;
  3. 封装性:管程内的数据结构是私有的,只能在管程中被使用。进程通过调用管程的过程使用临界资源。

2.1 管程的组成

  • 一个锁:控制管程代码的互斥访问;
  • 0或者多个条件变量:管理共享数据的并发访问;
图5-管程的组成.png

管程入口处的等待队列:

管程是封装了临界区上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 梅莎管程模型。其结构如下图所示:

图6-Java管程模型.png

其实管程一般由三部分组成:

  1. 一个锁:控制管程代码的互斥访问,并维护了入口线程队列;
  2. 条件变量:管理共享数据的并发访问,维护资源等待队列;
  3. 临界区:操纵共享资源的代码块;

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();
    }
图7-wait使用if条件导致异常.png

请点击获取objectMonitor.hpp源码...

  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的重量级锁

图8-对象与monitor对象的关联.png

无论是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 中的 Monitor 机制

深入理解Java并发之synchronized实现原理

相关阅读

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实现生产者消费者

评论0
4
抽奖
reward
45
赞赏
更多好文

本文来自:简书

感谢作者:简书

查看原文:JAVA并发(2)—PV机制与monitor(管程)机制

2618 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传
X
登录和大家一起探讨吧