并发工具(锁):深入Synchronized

synchronized解决哪些问题?

产生并发问题的三个根源在于“缓存可见性问题”,“原子性问题”,“指令顺序性问题”,Java并发包里面的所有并发工具都是针对于这三个问题的解决方案,所以说理解这个三个问题的根源对能否做好并发编程有决定性的意义,果你对这几个问题还不太了解,建议阅读并发问题的根源”。

当我们遇到并发问题时候,第一个想到的就是用锁,在JAVA里面使用synchronized解决并发问题已经是程序员潜意识的解决方案了,但是如果要问起synchronized解决了哪些问题时候,大部分人知道的都是让多个线程操作互斥,达到操作的原子性,但其实synchronized解决的不止是原子性问题,它同时还解决了“缓存可见性问题”和“指令重排序问题“。所以说理论上synchronized可以解决Java里面的所有并发问题。

synchronized 你用对了吗?

我们都知道使用synchronized对代码进行加锁来保证并发安全性,但是大部分人都不知道自己锁的到底是什么,也理不清锁和共享资源的关系是怎样的,以至于使用锁的结果和自己的预想的结果完全不一样,最后出现明明自己已经加锁了,还是会出现并发问题。

你知道自己加锁的对象是谁吗?

如果你还对synchronized修饰实例方法、静态方法、代码块有什么不同的疑问时,这个问题通常你是回答不上来的。

synchronized有两种使用方式,一种是在代码块中明确指定自己加锁的对像、另一种隐式指定自己加锁的对象,这种编译器帮我们做了处理,而我们常常被这种隐式加锁搞得晕头转向。

1、隐式加锁,synchronized修饰对象方法,效果等于synchronized(this),this也就是Test的当前实例对象。

 public class Test {
     //修饰在对象方法上,隐式加锁,效果等于synchronized(this)
     public synchronized void  thisLock(){}
 }

2、隐式加锁,synchronized修饰静态类方法,效果等于synchronized(Test.class)。这里需要注意Test.class是在JVM虚拟机真实存在的一个对象,JVM代码在经过编译、加载之后就会在堆里面创建一个 Test.class的对象,所以网上说锁住了Test.class对象就相当于锁住了Test的实例对象,这种说法是错误的,因为他们两个没有关系,不是一回事。

 public class Test {//修饰在类方法上,隐式加锁,效果等于synchronized(Test.class)
     public static synchronized void  classLock(){}}

3、显示加锁,在synchronized修饰代码块时会需要显示的指定一个加锁的对象,synchronized(任何对象)。

 public class Test {public  void  codeLock(){
         //显示加锁,锁的是当前Test实例对象
         synchronized(this){
             //Todo
         }
     }
 ​
     Object objA=new Object();
     public  void  objLock(){
         //显示加锁,锁的是objA对象
         synchronized(objA){
             //Todo
         }
     }}

上面几种方式加锁与对象的关系如下图
在这里插入图片描述

你知道加锁的对象和资源的关系吗?

现实世界里的一张门可以加多把锁,但是一把锁不能加到多个门上,但是编程世界里的锁却完全不同,编程世界里如果用多把锁去保护同一个资源就会出问题,但是却可以用一把去锁保护多个资源。

1、编程世界里,多把锁保护同一个共享资源会出现并发问题。
在这里插入图片描述
2、编程世界里,一把锁保护多个资源却是安全的。
在这里插入图片描述
3、下面的代码分别由不同的线程执行,是否安全,为什么?
答案是不安全,至于为什么请回过头看上面两个知识点。

 public class Test {static Integer value;
     
     //线程1执行此方法
     public synchronized void  thisLock(){
         value++;
     }
     
     //线程2执行此方法
     public static synchronized void  classLock(){
         value++;
     }}

synchronized的实现原理

synchronized我们都知道它是通过锁来实现线程的互斥的,但在我们认知的锁概念中,使用锁的时候应该是有一个lock 加锁操作,还有一个unlock解锁操作才对,但是在我们使用synchronizd的时候并没有做任何对锁的显示操作。这是因为java在编码层面

隐藏了synchronized的加锁和解锁的操作,因为在使用锁的过程中最容易出的问题就是加锁之后忘记解锁了,这样容易造成死锁,或许是为了降低使用synchronized的学习成本和出错成本,java就把synchronized的加锁和解锁操作都内置了。

如果我们把代码解析成指令码时,会看到在使用synchronized的代码前后会多一对monitor的指令,这个指令里面其实就包含了加锁和解锁的操作,我们把下面的源码解析成指令码就可以看到monitor的指令。

源码:

 public class Test {
      Integer value;
     public  void  thisLock(){
         synchronized (this){
             value++;
         }
     }}

先使用 javac Test.java 编译成class,然后使用 javap -p -v Test.class 查看类文件全部指令信息,解析成指令码后如下:
在这里插入图片描述
指令中monitorenter会进行加锁操作,monitorexit会进行解锁操作,有两个monitorexit是因为在程序发生异常后会默认的进行解锁。

根据上面的得出synchronized语义所表示的原始指令码,我们可以把synchronized的加锁流程理解成下图的样子
在这里插入图片描述
wait()、notify()、notifyAll()方法
根据上面我们已经知道,synchronized 其实就是维护了一个monitor,然后monitorenter,monitorexit会进行加锁和解锁的操作,到这里你已经对synchronized有一个基本的了解了,但是要把synchronized用好的话,那么你必须要熟练掌握wait()、notify()、notifyAll()这几个方法 ,首先我们看看这几个方法的作用。

wait():当前线程阻塞,并释放锁。

notify():唤醒等待队列的一个线程。

notifyAll():唤醒等待队列的所有线程。

那我们什么时候需要使用wait()、notify()、notifyAll()? 咱们来看一个不是很恰当,但是有助于你理解这几个方法的案例

场景:假如我们正在开发一个互联网项目,参与角色包括产品(负责收集需求、产出产品文档)、研发(负责根产品文档进行项目编码)、测试(负责测试系统功能),他们需要在办公室才能进行工作,但是公司条件简陋,只有一个办公室可以使用。

条件一:一个办公室一次只能容纳一个角色的人进入,也就是说有一个角色进入了办公室之后就会锁上办公室,其他角色的人都进不来。

条件二:开发人员必须有了产品文档之后再产出项目代码、测试人员在开发人员开发完毕了之后进入测试、产品人员在测试完毕了之后进行验收。
在这里插入图片描述
分析:在这个场景里面,多个角色其实就是系统的多个线程,办公室就是一个共享资源,同一时刻只能有一个角色进入也意味着角色之间存在的互斥的特性,这个特性我们通过synchronized对办公室资源进行加锁就可以解决。

但是值得注意的是另外一个条件,就是当研发、产品、测试他们同时来竞争办公室这个资源时,如果产品需求文档没有出来之前研发和测试先进入到了办公室,或者项目没开发完之前测试进入到了办公室也一样,这个时候他们工作的前提条件是不满足的,开发没有产品文档是没法做事的,但是办公室又被你开发占了产品都没办法进来,那么势必就会造成一个死循环,开发没文档不能编码,产品进不了办公室没办法出文档,所以这个时候就需要提供一种方式让开发人员把办公室的锁资源释放,让产品可以先进来工作,然后自己到办公室外等着,而synchronized提供的wait()方法就是专门处理这个问题的,开发人员当发现工作条件不满足的时候可以调用wait()方法,让出办公室资源,这样产品就可以进到办公室产出产品需求文档,然后这个项目流程才可以继续下去。

经过上面的场景说明,我想你一定理解了wait()方法的含义,那么我们继续来看notify()和notifyAll()这两个方法。 上面的开发人员已经知道在产品需求文档没出来之前,可以调用wait()方法让出办公室资源,那么让出了办公室资源后,是不是需要我们的开发人员通知下外面等待的的产品人员,让他们知道办公室已经空出来了,而notify()和notifyAll()两个方法就是这个作用,notify()的意思是通知等待队伍中的第一个人,告诉它办公室空出来了,notifyAll()是通知等待队伍中的所有人,然后他们都可以来竞争办公室资源了。

总结:synchronized实现原理

综合synchronized 的指令码、 加上wait()、notify()、notifyAll()的理解,我们其实已经在大脑里面里建立起了synchronized的实现原理图,首先synchronized会通过monitorenter 进行锁的竞争,加锁成功则进行临界区内操作共享资源,加锁失败则进入等待队列中等待,进入到临界区的线程当发现自己的执行条件不满足时,可以条用wait()方法释放锁,然后使用nofify()或者notifAll()唤醒等待队列的锁,如果条件满足那么就正常的执行临界区的逻辑,操作完毕后monitorexit 退出临界区,释放锁资源,同时通知等待队列的线程,大致的流程图如下。
在这里插入图片描述

synchronized的锁是如何存储的?

我们经常说加锁加锁,那么这个锁到底是存在哪里的,那么下面我们现在就来揭开synchronized里锁的神秘面纱。为什么之前我们要那么强调要知道自己加锁的对象是谁,那是因为锁的标记是保存在象信息里面的,至于是如何存储的我们就先了解下对象在内存的结构是怎样的,对象创建后在内存里面的结构包括以下几个部分的信息,对象头、类元信息、对象的实例信息、填充,我们先对每个部分功能做一个简单的了解。

对象头:这里面保存的对象的锁相关信息、分代年龄(GC回收的那个年龄)。

类元信息:这保存着指向class信息的地址指针

对象实例:这里保存着对象的实例成员变量等信息。

填充: 这个主要是起补齐作用,对象申请内存的时候都是以字节整数大小申请的,当对象内容比申请的容量小,那么空余的部分会采用补齐的方式填充。

对象头内容数据图:
在这里插入图片描述
我们从图中可以看到对象头里面包括了 锁的状态和锁对应类型的标记位,从这里我们也发现锁类型分为轻量级锁、重量级锁、偏向锁好几种,那么这几个有什么区别我们下面继续了解。

synchronized锁的升级过程。

从上面的图中我们已经知道了锁是存储在对象的对象头信息里面,并且锁还分了好几种类型,那么几种类型的锁分别有什么区别和联系呢,其实从名称上的定义我们也许就能看出一些逻辑,轻量级锁、重量级锁 这个好像就是一个锁的升级过程,而事实也的确如此。

为了更好的理解锁的升级过程,首先我们要联系到现实情况,然后根据实际情况来说明我们的锁是如何进行升级的,我们程序执行的场景可能是这样的:

场景1(无锁状态):程序不会有锁的竞争。

那么这种情况我们不需要加锁,所以这种情况下对象锁状态为无锁。

场景2(偏向锁):经常只有某一个线程来加锁。

也许获取锁的经常为同一个线程,这种情况下为了避免加锁造成的性能开销,所以并不会加实际意义上的锁,偏向锁的执行流程如下:

1、线程首先检查该对象头的线程ID是否为当前线程;

2、A:如果对象头的线程ID和当前线程ID一直,则直接执行代码;B:如果不是当前线程ID则使用CAS方式替换对象头中的线程ID,如果使用CAS替换不成功则说明有线程正在执行,存在锁的竞争,这时需要撤销偏向锁,升级为轻量级锁。

3、如果CAS替换成功,则把对象头的线程ID改为自己的线程ID,然后执行代码。

4、执行代码完成之后释放锁,把对象头的线程ID修改为空。

在这里插入图片描述
场景3(轻量级锁):有线程来参与锁的竞争,但是锁竞争的时间很短。

当开始有锁的冲突了,那么偏向锁就会升级到轻量级锁;线程获取锁出现冲突时,线程必须做出决定是继续在这里一直询问,还是回家等别人打电话通知,而轻量级锁采用的方式就是采用继续在这里询问的方式,=。

当发现有锁冲突,线程首先会使用自旋(循环)的方式循环在这里获取锁,因为使用自旋的方式非常消耗CPU,当一定时间内通过自旋的方式无法获取到锁的话,那么锁就开始升级为重量级锁了。

场景4(重量级锁):有大量的线程参与锁的竞争,冲突性很高。

我们知道当获取锁冲突多,时间越长的时候,我们的线程肯定不能一直一致在这里循环的询问,这种方式太消耗CPU资源,而且也是没意义的。所以这个时候最好的方式就是先让线程进入队列等待,然后等前面获取锁的线程释放了锁之后再开启下一轮的锁竞争,而这种形式就是我们的重量级锁,只有当锁升级为重量级锁的时候才真正意义上的在操作系统层面进行了加锁操作。
在这里插入图片描述
总结:偏向锁、轻量级锁、重量级锁其实分别是针对几种场景所做的优化机制,当线程获取锁的时候不存储竞争的时候,这时使用的是偏向锁,当线程之前有少量的竞争时,我们采用轻量级锁等一等的方式来获取锁,当锁竞争激烈等的时间太长那就没办法只能使用Monitor 基于操作系统的锁达到效果了。

synchronized设计模型(管程模型)

synchronized并非Java的个性化设计,而是根据计算机通用的并发问题解决模型“管程模型”来设计的,monitor其实也就是管程,管程是通过管理共享变量访问过程,保证共享变量线程安全的一种机制, 这种机制是通过线程之间访问共享资源的互斥特性来达到线程安全的目的,保持互斥的方式也就是我们常说的“锁”,因为管程里线程互斥特性,并发时候同一时间能加锁成功操作共享资源,那么获取锁的这些线程是采用什么等待机制,加锁成功的线程结束后使用什么方式通知其它线程,这就是线程之间的协作,我们称为线程“同步”。

管程是一个抽象的概念模型,可以理解它就是解决并发问题的一套方法论(通过“并发理论基础:解决原子性问题可以对管程进一步的了解”),Java 里面的Lock+Condition 和synchronized都是基于管程模型实现的,任何语言都可用通过都可以通过这套模型编写出安全的并发程序是多个进程或线程同时访问一个共享资源时能达到"互斥"和"同步"的效果,管程实现必须达到下面几点要求

1、管程中的共享变量对于外部都是不可见的,只能通过管程才能访问对应的共享资源(对应到synchronized,意思是共享变量的操作只能通过synchronized 的代码块才能访问)。

2、管程是互斥的,某个时刻只能允许一个进程或线程访问共享资源(对应到synchronized,一个线程加锁后,其他线程就无法对共享资源加锁了)。

3、管程中需要有线程等待队列和相应等待和唤醒操作,必须有一种办法使进程无法继续运行时被阻塞(对应到synchronized的,synchronized提供了几个方法,wait()方法使线程阻塞,notify() notifyAll() 可以唤醒一个或者所有等待线程)。

下面我们对比一下管程模型的释义图、和synchronizd的释义图。

管程模型释义图
在这里插入图片描述
synchronized 的执行原理示意图
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值