noteless 头像

java锁与监视器概念 为什么wait、notify、notifyAll定义在Object中 多线程中篇(九)

在Java中,与线程通信相关的几个方法,是定义在Object中的,大家都知道Object是Java中所有类的超类
在Java中,所有的类都是Object,借助于一个统一的形式Object,显然在有些处理过程中可以更好地完成转换,传递,省去了一些不必要的麻烦
另外有些东西,比如toString,的确是所有的类的特征
但是,为何线程通信相关的方法会被设计在Object中?
image_5c6a14a2_4692

对于多线程编程模型,一个少不了的概念就是锁
虽然叫做锁,但是其实相当于临界区大门的一个钥匙,那把钥匙就放到了临界区门口,有人进去了就把钥匙拿走揣在了身上,结束之后会把钥匙还回来
只有拿到了指定临界区的锁,才能够进入临界区,访问临界区资源,当离开临界区时,释放锁,其他线程才能够进入临界区
而对于锁本身,也是一种临界资源,是不允许多个线程共同持有的,同一时刻,只能够一个线程持有;
 
在前面的章节中,比如信号量介绍中,对于PV操作,就是对临界区资源的访问,下面的S就是临界区资源
Wait(S)和 signal(S)操作可描述为:
   wait(S):  while (S<=0);
                  S:=S-1;
  signal(S):S:=S+1; 
但是上面的S,只是一种抽象的概念,在Java中如何表达?
换个问题就是:在Java中是如何描述锁这种临界区资源的?
其实任何一个对象都可以被当做锁
锁在Java中是对象头中的数据结构中的数据,在JVM中每个对象中都拥有这样的数据
如果任何线程想要访问该对象的实例变量,那么线程必须拥有该对象的锁(也就是在指定的内存区域中进行一些数据的写入)
当所有的其他线程想要访问该对象时,就必须要等到拥有该对象的锁的那个线程释放锁
一个线程拥有了一个对象的锁之后,他就可以再次获取锁,也就是平常说的可重入,如下图所示,两个方法同一个锁
假设methodA中调用了methodB(下面没调用),如果不可重入的话,一个线程获取了锁,进入methodA然后等待进入methodB的锁,但是他们是同一个锁
自己等待自己,岂不是死锁了?所以锁具有可重入的特性
image_5c6a14a2_3c47
对于锁的可重入性,JVM会维护一个计数器,记录对象被加锁了多少次,没有被锁的对象是0,后续每重入一次,计数器加1(只有自己可以重入,别人是不可以,是互斥的)
只有计数器为0时,其他的线程才能够进入,所以,同一个线程加锁了多少次,也必然对应着释放多少次
而对于这些事情,计数器的维护,锁的获取与释放等,是JVM帮助我们解决的,开发人员不需要直接接触锁
简言之,在对象头中有一部分数据用于记录线程与对象的锁之间的关系,通过这个对象锁,进而可以控制线程对于对象的互斥访问

监视器

对于对象锁,可以做到互斥,但是仅仅互斥就足够了吗?比如一个同步方法(实例方法)以当前对象this为锁,如果多个线程过来,只有一个线程可以持有锁,其他线程需要等待
这个过程是如何管理的?
而且,在Java中,还可以借助于wait notify方法进行线程间的协作,这又是如何做到的?
 
其实在Java中还有另外一个概念,叫做监视器
《深入Java虚拟机》中如下描述监视器:
可以将监视器比作一个建筑,它有一个很特别的房间,房间里有一些数据,而且在同一时间只能被一个线程占据。
一个线程从进入这个房间到它离开前,它可以独占地访问房间中的全部数据。
如果用一些术语来定义这一系列动作:
  • 进入这个建筑叫做“进入监视器”
  • 进入建筑中的那个特别的房间叫作“获得监视器”
  • 占据房间叫做“持有监视器”
  • 离开房间叫做“释放监视器”
  • 离开建筑叫做“退出监视器”
image_5c6a14a2_4160
这些概念说起来,稍微有些晦涩,换个角度
还记得《上篇系列》中的管程的概念么?
还记得管程的英文单词吗?
image_5c6a14a2_4d1e
其实Java中的监视器Monitor就是管程的概念,他是管程的一种实现
不管实现细节如何,不管对概念的实现程度如何,它的核心其实就是管程
在进程通信的部分有介绍到:
“管程就是管理进程,管程的概念就是设计模式中“依赖倒置原则”,依赖倒置原则是软件设计的一个理念,IOC的概念就是依赖倒置原则的一个具体的设计
管程将对共享资源的同步处理封闭在管程内,需要申请和释放资源的进程调用管程,这些进程不再需要自主维护同步。
有了管程这个大管家(秘书?)(门面模式?)进程的同步任务将会变得更加简单。
管程是墙,过程是门,想要访问共享资源,必须通过管程的控制(通过城墙上的门,也就是经过管程的过程)
而管程每次只准许一个进程进入管程,从而实现了进程互斥
image_5c6a14a2_2fb8
管程的核心理念就是相当于构造了一个管理进程同步的“IOC”容器。”
简言之:Java的监视器就是管程的一种实现,借助于监视器可以实现线程的互斥与同步

监视区域

对于监视器“房间”内的内容被称为监视区域,说白了监视区域就是监视器掌管的空间区域
这个空间区域不管里面有多少内容,对于监视器来说,他们是最小单位,是原子的,是不可分割的代码,只会被同一个线程执行
不管你多少并发,监视器会对他进行保障
(对于开发者来说,你使用一个synchronized关键字就有了监视器的效果,监视器依赖JVM,而JVM依赖操作系统,操作系统则会进一步依赖软件甚至硬件,就是这样层层封装)
其实废话这么多,一个同步方法内(同步代码块)中所有的内容,就是属于同一个监视区域
image_5c6a14a2_319c

Java监视器逻辑

去医院就医时,有时需要进一步检查,现在你感冒有时都会让你查血  ̄□ ̄||
大致的流程可能是这样子的:
image_5c6a14a2_3d4d
挂号后,你会在医生办公室外等待医生叫号,医生处理(开化验单)后,你会去缴费,化验、等待结果等,拿到结果后,在重新回来进入医生办公室,当医生给当前的病人结束后,就会帮你看
(也有些医院取结果后也有报道机,会有复诊的队列,此处我只是举个例子,不要较真,我想你肯定见过这种场景:就是你挂号进去之后,医生旁边站了好几个人,那些要么是拿到结果回来的,要么是取药后回来咨询的)
在上面的流程中,相当于有两个队伍,一个是第一次挂号后等待叫号,另一个是医生诊治后还需要再次诊治的等待队伍
image_5c6a14a2_7ad1
而对于Java监视器,其实也是类似这样一种逻辑(类似!)
当一个线程到达时,如果一个监视器没有被任何线程持有,那么可以直接进入监视器执行任务;
如果监视器正在被其他线程持有,那么将会进入“入口区域”,相当于走廊,在走廊排队等待叫号;
在监视器中执行的线程,也可能因为某些事情,不得不暂停等待,可以通过调用等待命令;比如经典的“读者--写者”问题,读者必须等待缓冲区“非满”状态,这就相当于大夫开出来了化验单,你要去化验,你要暂时离开医生,医生也就因此空闲了;此时这个线程就进入了这个监视器的“等待区域”
一旦离开,医生空闲,监视区域空出来了,所以其他的线程就有机会进入监视区域运行了;
一个监视区域内运行的线程,也可以执行唤醒命令,通过唤醒命令可以将等待区域的线程重新有机会进入监视区域
简言之
  • 一个监视区域前后各有一个区域:入口区域,等待区域:
  • 如果监视区域有线程,那么入口区域需要等待,否则可以进入;
  • 监视区域内执行的线程可以通过命令进入等待队列,也可以将等待队列的线程唤醒,唤醒后的线程就相当于是入口区域的队列一样,可以等待进入监控区域;
需要注意的是:
并不是说监控区域内的线程一定要在或者会在最后一个时刻才会唤醒等待区域的线程,他随时都可以将等待区域内的线程唤醒
也就是说唤醒别人的同时,并不意味着他离开了监控区域,所以JVM的这种监控器实现机制也叫做“发信号并继续”
而且需要注意的是,等待线程并不是唤醒后就立即醒来,当唤醒线程执行结束退出监视区域后,等待线程才会醒来
可以想一下,线程进入等待区域必然是有某些原因不满足,所以才会等待,但是唤醒线程并不是最后一步才唤醒的,既然是在继续执行,方才条件满足唤醒了,那现在是否还满足?另外如果唤醒线程退出监控区域之后,反而出现了第三个线程抢先进入了监控区域怎么办?这个线程也是有可能对资源进行改变的,执行结束后可能等待线程的条件是否仍旧还是满足的?这都是不得而知的,所以也可能继续进入等待也可能退出等待区域,只能说除非逻辑有问题,不然只能够说在唤醒的那一刻,看起来是满足了的

进出监视器流程

image_5c6a14a2_226f
  • 线程到达监控区域开始处,通过途径1进入入口区域,如果没有任何线程持有监控区域,通过途径2进入监控区域,如果被占用,那么需要在入口区域等待;
  • 一个活动线程在监控区域内,有两种途径退出监控区域,当条件不满足时,可以通过途径3借助于等待命令进入等待或者顺利执行结束后通过途径5退出并释放监视器
  • 当监视器空闲时,入口区域的等待集合将会竞争进入监视器,竞争成功的将会进入监控区域,失败的继续等待(如果有等待的线程被唤醒,将会一同参与竞争)
  • 对于等待区域,要么通过途径3进入,要么通过途径4退出,只有这两条途径,而且只有一个线程持有监视器时才能执行等待命令,也只有再次持有监视器时才能离开等待区
  • 对于等待区域中的线程,如果是有超时设置的等待,时间到达后JVM会自动通过唤醒命令将他唤醒,不需要其他线程主动处理

关于唤醒

JVM中有两种唤醒命令,notify和notify all,唤醒一个和唤醒所有
唤醒更多的是一种标志、提示、请求,而不是说唤醒后立即投入运行,前面也已经讲过了,  如果条件再次不满足或者被抢占。
对于JVM如何选择下一个线程,依照具体的实现而定,是虚拟机层面的内容。比如按照FIFO队列?按照优先级?各种权重综合?等等方式
而且需要注意的是,除非是明确的知道只有一个等待线程,否则应该使用notify all,否则,就可能出现某个线程等待的时间过长,或者永远等下去的几率。

语法糖

对于开发者来说,最大的好处就是线程的同步与调度这些是内置支持的,监视器和锁是语言附属的一部分,而不需要开发者去实现
synchronized关键字就是同步,借助于他就可以达到同步的效果,这应该算是语法糖了
对于同步代码块,JVM借助于monitorenter和monitorexit,而对于同步方法则是借助于其他方式,调用方法前去获取锁
只需要如下图使用关键字 synchronized就好,这些指令都不需要我们去做
image_5c6a14a2_4549

有关锁的几个概念

  • 死锁
  • 锁死
  • 活锁
  • 饥饿
  • 锁泄露
死锁
共享资源竞争时,比如两个锁a和b,A线程持有了a等待b,而B持有了b而等待a,此时就会出现互相等待的情况,这就叫做死锁
锁死
当一个线程等待某个资源时,或者等待其他线程的唤醒时,如果迟迟等不到结果,就可能永远的等待沉睡下去,这就是锁死
活锁
虽然线程一直在持续运行,处于RUNNABLE,但是如果任务迟迟不能继续进行,比如每次回来条件都不满足,比如一直while循环进行不下去,这就是活锁
饥饿
如果一个线程因为某种条件等待或者睡眠了,但是却再也没有得到CPU的临幸,迟迟得不到调度,或者永远都没有得到调度,这就是饥饿
锁泄露
如果一个线程获得锁之后,执行完临界区的代码,但是却并没有释放锁,就会导致其他等待该锁的线程无法获得锁,这叫做锁泄露

总结

Java在语言级别支持多线程,是Java的一大优势,这种支持主要是线程的同步与通信,这种机制依赖的就是监视器,而监视器底层也是对锁依赖的,对象锁是对监视器的支撑,也就是说,对象锁是根本,如果没有对象锁,根本就没有办法互斥,不能互斥的话,更别提协作同步了,监视器是构建于锁的基础上实现的一种程序,进一步提供了线程的互斥与协作的功能
开发时比如synchronized关键字的使用,底层也会依赖到监视器,比如两个线程调用一个对象的同步方法,一个进入,那么另一个等待,就是在监视器上等待
在JVM中,每一个类和对象在逻辑上都对应一个监视器
其实想要理解监视器的概念,还是要理解管程的概念
而 wait方法和notify notifyAll方法不就是管程的过程吗?
管程就是相当于对于线程进行同步的一个“IOC”,借助于管程托管了线程的同步,如果想要深入可以去研究下虚拟机
毕竟对于任何一种语言来说,也都是一层层的封装最终转换为操作系统的指令代码,所有的这些功能在JVM层面看也毕竟都是字节码指令。
 
所以,说到这里,回到本文的最初问题上,“为什么wait、notify、notifyAll 都是Object的方法”?
Java中所有的类和对象逻辑上都对应有一个锁和监视器,也就是说在Java中一切对象都可以用来线程的同步、所以这些管程(监视器)的“过程”方法定义在Object中一点也不奇怪
只要理解了锁和监视器的概念,就可以清晰地明白了
 
posted @ 2019-02-18 10:27  noteless  阅读(9513)  评论(1编辑  收藏  举报