《吊打面试官》系列-乐观锁、悲观锁

https://mp.weixin.qq.com/s/WtAdXvaRuBZ-SXayIKu1mA

《吊打面试官》系列-乐观锁、悲观锁


收录于话题面试

 

数据库

6个安全

前言

关于线程安全一提到可能就是加锁,在面试中也是面试官百问不厌的考察点,每每能看出面试者的基本功和是否对线程安全有本身的思考。多线程

那锁自己是怎么去实现的呢?又有哪些加锁的方式呢?并发

我今天就简单聊一下乐观锁和悲观锁,他们对应的实现 CAS ,Synchronized,ReentrantLockjvm

正文

一个120斤一身黑的小伙子走了进来,看到他微微发福的面容,看来是最近疫情伙食好运动少的结果,他难道就是今天的面试官渣渣丙?ide

image.png

等等难道是他?前几天刷B站看到的不会是他吧!!!函数

image.png

是的我已经开始把面试系列作成视频了,之后会有各类级别的面试,从大学生到阿里P7+的面试,还有阿里,拼多多,美团,字节风格的面试我也都约好人了,就差时间了,你们能够去B站搜:三太子敖丙观看工具

我也很少跟你BB了,咱们直接开始好很差,你能跟我聊一下CAS么?this

CAS(Compare And Swap 比较而且替换)是乐观锁的一种实现方式,是一种轻量级锁,JUC 中不少工具类的实现就是基于 CAS 的。

CAS 是怎么实现线程安全的?

线程在读取数据时不进行加锁,在准备写回数据时,先去查询原值,操做的时候比较原值是否修改,若未被其余线程修改则写回,若已被修改,则从新执行读取流程。

举个栗子:如今一个线程要修改数据库的name,修改前我会先去数据库查name的值,发现name=“帅丙”,拿到值了,咱们准备修改为name=“三歪”,在修改以前咱们判断一下,原来的name是否是等于“帅丙”,若是被其余线程修改就会发现name不等于“帅丙”,咱们就不进行操做,若是原来的值仍是帅丙,咱们就把name修改成“三歪”,至此,一个流程就结束了。

有点懵?理一下停下来理一下思路。

Tip:比较+更新 总体是一个原子操做,固然这个流程仍是有问题的,我下面会提到。

他是乐观锁的一种实现,就是说认为数据老是不会被更改,我是乐观的仔,每次我都以为你不会渣我,差很少是这个意思。

image.png

你这个栗子不错,他存在什么问题呢?

有,固然是有问题的,我也恰好想提到。

大家看图发现没,要是结果一直就一直循环了,CUP开销是个问题,还有ABA问题和只能保证一个共享变量原子操做的问题。

你能分别介绍一下么?

好的,我先介绍一下ABA这个问题,直接口述可能有点抽象,我画图解释一下:

image.png

看到问题所在没,我说一下顺序:

  1. 线程1读取了数据A

  2. 线程2读取了数据A

  3. 线程2经过CAS比较,发现值是A没错,能够把数据A改为数据B

  4. 线程3读取了数据B

  5. 线程3经过CAS比较,发现数据是B没错,能够把数据B改为了数据A

  6. 线程1经过CAS比较,发现数据仍是A没变,就写成了本身要改的值

懂了么,我尽量的幼儿园化了,在这个过程当中任何线程都没作错什么,可是值被改变了,线程1却没有办法发现,其实这样的状况出现对结果自己是没有什么影响的,可是咱们仍是要防范,怎么防范我下面会提到。

循环时间长开销大的问题

是由于CAS操做长时间不成功的话,会致使一直自旋,至关于死循环了,CPU的压力会很大。

只能保证一个共享变量的原子操做

CAS操做单个共享变量的时候能够保证原子的操做,多个变量就不行了,JDK 5以后 AtomicReference能够用来保证对象之间的原子性,就能够把多个对象放入CAS中操做。

我还记得你以前说在JUC包下的原子类也是经过这个实现的,能举个栗子么?

那我就拿AtomicInteger举例,他的自增函数incrementAndGet()就是这样实现的,其中就有大量循环判断的过程,直到符合条件才成功。

image.png

大概意思就是循环判断给定偏移量是否等于内存中的偏移量,直到成功才退出,看到do while的循环没。

乐观锁在项目开发中的实践,有么?

有的就好比咱们在不少订单表,流水表,为了防止并发问题,就会加入CAS的校验过程,保证了线程的安全,可是看场景使用,并非适用全部场景,他的优势缺点都很明显。

那开发过程当中ABA大家是怎么保证的?

加标志位,例如搞个自增的字段,操做一次就自增长一,或者搞个时间戳,比较时间戳的值。

举个栗子:如今咱们去要求操做数据库,根据CAS的原则咱们原本只须要查询本来的值就行了,如今咱们一同查出他的标志位版本字段vision。

以前不能防止ABA的正常修改:

update table set value = newValue where value = #{oldValue}
//oldValue就是咱们执行前查询出来的值 

带版本号能防止ABA的修改:

update table set value = newValue ,vision = vision + 1 where value = #{oldValue} and vision = #{vision} 
// 判断原来的值和版本号是否匹配,中间有别的线程修改,值可能相等,可是版本号100%不同

除了版本号,像什么时间戳,还有JUC工具包里面也提供了这样的类,想要扩展的小伙伴能够去了解一下。

聊一下悲观锁?

悲观锁从宏观的角度讲就是,他是个渣男,你认为他每次都会渣你,因此你每次都提防着他。

咱们先聊下JVM层面的synchronized:

synchronized加锁,synchronized 是最经常使用的线程同步手段之一,上面提到的CAS是乐观锁的实现,synchronized就是悲观锁了。

它是如何保证同一时刻只有一个线程能够进入临界区呢?

synchronized,表明这个方法加锁,至关于无论哪个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其余同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,而后直接运行。

我分别从他对对象、方法和代码块三方面加锁,去介绍他怎么保证线程安全的:

  • synchronized 对对象进行加锁,在 JVM 中,对象在内存中分为三块区域:对象头(Header)、实例数据(Instance
    Data)和对齐填充(Padding)。

  • 对象头:咱们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

    你能够看到在对象头中保存了锁标志位和指向 monitor 对象的起始地址,以下图所示,右侧就是对象对应的 Monitor 对象。

  • image.png

    当 Monitor 被某个线程持有后,就会处于锁定状态,如图中的 Owner 部分,会指向持有 Monitor 对象的线程。

    另外 Monitor 中还有两个队列分别是EntryList和WaitList,主要是用来存放进入及等待获取锁的线程。

    若是线程进入,则获得当前对象锁,那么别的线程在该类全部对象上的任何操做都不能进行。

    • Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用本身的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

    • Klass Point:对象指向它的类元数据的指针,虚拟机经过这个指针来肯定这个对象是哪一个类的实例。

在对象级使用锁一般是一种比较粗糙的方法,为何要将整个对象都上锁,而不容许其余线程短暂地使用对象中其余同步方法来访问共享资源?

若是一个对象拥有多个资源,就不须要只为了让一个线程使用其中一部分资源,就将全部线程都锁在外面。

因为每一个对象都有锁,能够以下所示使用虚拟对象来上锁:

   class FineGrainLock{
   MyMemberClassx,y;
   Object xlock = new Object(), ylock = newObject();
   public void foo(){
       synchronized(xlock){
       //accessxhere
        }
       //dosomethinghere-butdon'tusesharedresources
        synchronized(ylock){
        //accessyhere
        }
   }
      public void bar(){
        synchronized(this){
           //accessbothxandyhere
       }
      //dosomethinghere-butdon'tusesharedresources
      }
  }
  • synchronized 应用在方法上时,在字节码中是经过方法的 ACC_SYNCHRONIZED 标志来实现的。

    我反编译了一小段代码,咱们能够看一下我加锁了一个方法,在字节码长啥样,flags字段瞩目:

    synchronized void test();
      descriptor: ()V
      flags: ACC_SYNCHRONIZED
      Code:
        stack=0, locals=1, args_size=1
           0return
        LineNumberTable:
          line 70
        LocalVariableTable:
          Start  Length  Slot  Name   Signature
              0       1     0  this   Ljvm/ClassCompile;

    反正其余线程进这个方法就看看是否有这个标志位,有就表明有别的仔拥有了他,你就别碰了。

  • synchronized 应用在同步块上时,在字节码中是经过 monitorenter 和 monitorexit 实现的。

    每一个对象都会与一个monitor相关联,当某个monitor被拥有以后就会被锁住,当线程执行到monitorenter指令时,就会去尝试得到对应的monitor。

    步骤以下:

  1. 每一个monitor维护着一个记录着拥有次数的计数器。未被拥有的monitor的该计数器为0,当一个线程得到monitor(执行monitorenter)后,该计数器自增变为 1 。

  • 当同一个线程再次得到该monitor的时候,计数器再次自增;

  • 当不一样线程想要得到该monitor的时候,就会被阻塞。

当同一个线程释放 monitor(执行monitorexit指令)的时候,计数器再自减。

当计数器为0的时候,monitor将被释放,其余线程即可以得到monitor。

一样看一下反编译后的一段锁定代码块的结果:

public void syncTask();
  descriptor: ()V
  flags: ACC_PUBLIC
  Code:
    stack=3, locals=3, args_size=1
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter  //注意此处,进入同步方法
       4: aload_0
       5: dup
       6: getfield      #2             // Field i:I
       9: iconst_1
      10: iadd
      11: putfield      #2            // Field i:I
      14: aload_1
      15: monitorexit   //注意此处,退出同步方法
      16: goto          24
      19: astore_2
      20: aload_1
      21: monitorexit //注意此处,退出同步方法
      22: aload_2
      23: athrow
      24return
    Exception table:
    //省略其余字节码.......

小结:

同步方法和同步代码块底层都是经过monitor来实现同步的。

二者的区别:同步方式是经过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现,同步代码块是经过monitorenter和monitorexit来实现。

咱们知道了每一个对象都与一个monitor相关联,而monitor能够被线程拥有或释放。