乐观锁和悲观锁的底层实现原理

本文参考——敖丙——JavaFamily

概述

上一篇文章我们提到了乐观锁和悲观锁的工作方式和使用场景,那么这两种锁本身是如何实现的?

这篇文章就来总结一下乐观锁和悲观锁,他们对应的实现—— CAS ,Synchronized,ReentrantLock

乐观锁——CAS

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

CAS是如何保证线程安全的?
线程在读取数据时不进行加锁,在准备写回数据时,先去查询原值,操作的时候比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。

Tips:这就是Compare and Swap的由来,注意比较-替换是一个原子操作,要么两个同时完成,要么两个同时失败。

它总是乐观的去考虑事件,认为线程发生冲突的概率很小
在这里插入图片描述
那么CAS就这么完美吗?它存在什么问题?
世界上从来就没有完美的东西,CAS也一样,我们可以看上面的流程图,如果数据值一直发送改变,那么CAS就会一直自旋,CPU开销就是一个问题,而且CAS还有一个经典的ABA问题

什么是ABA问题?
在这里插入图片描述
我们来捋一下上图的工作流程
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没错,修改数据A为B

在这个过程中任何线程都没做错什么,但是值被改变了,线程1却没有办法发现,其实这样的情况出现对结果本身是没有什么影响的,但是我们还是要防范,怎么防范我下面会提到。

如何防止ABA问题发生
加标志位,例如搞个自增的字段(版本号之类),操作一次就自增加一,或者搞个时间戳,比较时间戳的值。

举个栗子:现在我们去要求操作数据库,根据CAS的原则我们本来只需要查询原本的值就好了,现在我们一同查出他的标志位版本字段vision。

只查询原本的值不能防止ABA

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

加上标志位version之后

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

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

CPU忙等待时间过长导致的开销问题
是因为CAS操作长时间不成功的话,会导致一直自旋,相当于死循环了,CPU的压力会很大。

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

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

在这里插入图片描述
乐观锁在项目开发中的实践?
比如我们在很多订单表,流水表,为了防止并发问题,就会加入CAS的校验过程,保证了线程的安全,但是看场景使用,并不是适用所有场景,他的优点缺点都很明显。

悲观锁——Synchronized和ReentrantLock

悲观锁就是考虑事情比较悲观,认为在访问共享资源时发生冲突的概率比较高,所以每次访问前线程都要先加锁。

我们首先来聊一下基于JVM层面实现的synchronized关键字

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

它是如何保证同一时刻只有一个线程可以进入临界区呢?
使用synchronized对一个方法加锁,相当于不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。

我们分别从方法,代码块,对象三个层面解析它的实现原理,以及它是如何保证线程安全的

  • synchronized对对象进行加锁,在JVM中,每个对象可以分为三个区域,对象头,实例数据和对齐填充。每个对象都有一把锁,存放在对象头中

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

      • Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
      • Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

我们可以看到对象头中保存了锁标志位和指向 monitor 对象的起始地址,如下图所示,右侧就是对象对应的 Monitor 对象。
在这里插入图片描述

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

Monitor对象中还有两个队列EntryList和WaitList,Entry队列主要是存放还在等待锁的线程,而Wait队列存放那些已经运行完并释放锁的线程。

当一个线程获取到这个对象的锁之后,其他线程在该类的所有对象上的操作都不可以进行。

Tips:在对象层面使用锁是一种相对粗糙的方式,为什么我们要对整个对象上锁,不允许其他线程短暂地使用对象中的其他同步方法访问共享资源

就好比一个对象有多个共享资源,我们不需要为了一个线程使用其中的一部分资源,就将其他线程全部锁在外面

由于每个对象都有锁,我们可以使用虚拟对象来上锁

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来实现的
synchronized void test();
  descriptor: ()V
  //拥有这个标志位就表明当前锁已经被持有
  flags: ACC_SYNCHRONIZED
  Code:
    stack=0, locals=1, args_size=1
       0: return
    LineNumberTable:
      line 7: 0
    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对象会被阻塞
    2.当同一个线程释放monitor(执行monitorexit指令),计数器自减,当计数器变为0的时候,释放monitor对象,这时候其他线程可以获取这个monitor了

小结

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

两者的区别在于同步方法是通过标志位ACC_SYNCHRONIZED来实现的,而同步代码块是通过执行monitorenter,monitorexit指令来实现的。

我们以前说synchronized是重量级锁,为什么现在不提了
在多线程并发编程中 synchronized 一直是元老级角色,很多人都会称呼它为重量级锁。

但是,随着 Java SE 1.6 对 synchronized 进行了各种优化之后,有些情况下它就并不那么重,Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。

针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。
在这里插入图片描述
锁的状态有4种
无锁<偏向锁<轻量级锁<重量级锁

锁只能升级,不能降级

ReentrantLock

在介绍ReentrantLock之前,我们有必要先了解AQS(AbstractQueuedSynchronizer)

AQS:也就是队列同步器,这是实现 ReentrantLock 的基础。

AQS有一个标志位state,值为1时表示已经有线程占用,其他线程需要进入同步队列等待,这里说的同步队列其实是一个双向链表在这里插入图片描述
当获得锁的线程还需要等待某个条件时,就转移到condition的等待队列,等待队列可以有多个。

当 condition 条件满足时,线程会从等待队列重新进入同步队列进行获取锁的竞争。

Tips:等待队列就是除了锁之外还需要等待别的条件,而同步队列就只需要等待锁释放然后再去和别的线程竞争即可

ReentrantLock 就是基于 AQS 实现的,如下图所示,ReentrantLock 内部有公平锁和非公平锁两种实现,差别就在于新来的线程是否比已经在同步队列中的等待线程更早获得锁。

和 ReentrantLock 实现方式类似,Semaphore 也是基于 AQS 的,差别在于 ReentrantLock 是独占锁,Semaphore 是共享锁。
在这里插入图片描述
从上图可以看出,ReentrantLock有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。

Sync有公平锁FairSync和非公平锁NotFairSync两个子类

ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。

总结

锁其实还有很多,例如自旋锁,自适应自旋,公平锁,非公平锁,可重入(文中提到的都是可重入),不可重入锁,共享锁,排他锁等。希望以后可以弄清楚它们的原理并自己总结出来。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值