Java并发中的锁机制

什么是锁

在并发环境下,可能发生多个线程对同一个资源进行争抢的情况,不同线程对资源进行了修改,可能导致数据不一致的问题,所以引入了锁机制

JVM运行时结构主要包括了五个部分:程序计数器、JVM栈、Native方法、堆、方法区。

其中的红色区域为线程私有,不会出现线程竞争的关系。而蓝色区域中的数据被所有线程共享,其中:

  1. Java堆(Heap)中存放大量对象。
  2. 方法区中存放类信息、常量、静态变量等数据。

在这里插入图片描述

Java中主要采取了两种实现锁的方式:基于Object的悲观锁基于CAS的乐观锁

基于Object的悲观锁

对象、对象头结构

Java中,每个Object(对象)都拥有一把锁,这把锁存放在对象头中,记录了当前对象被哪个线程占用。

Java对象分为三个部分:

  • 对象头
  • 实例数据(初始化对象时设定的属性和状态等内容)
  • 对齐填充字节(是为了满足"Java对象的大小必须是byte的倍数"这一条件而设计,填充了无用字节)

对象头包含两部分,Mark WordClass Point

  • Class Point是一个指针,指向当前对象类型所在方法区的Class信息。
  • Mark Word存放了当前对象的运行时状态信息例如Hash Code、锁状态标志、指向锁记录的指针、偏向线程ID、锁标志位等。

在这里插入图片描述

我们从这张表中能看到,这把抽象的“锁”的信息就存储在对象头的Mark Word中。重点关注最后两位,这两位代表锁标志位,分别对应“无锁”、“偏向锁”、“轻量级锁”、“重量级锁”这四种状态。在Java中,启用对象锁的方式是使用synchronized关键字,那么synchronized背后的原理是什么,上面列举的这些状态又都是什么意思呢?

Synchronized

大家都知道在Java中,synchronized关键词可以用来同步线程,synchronized被编译后会生成monitorenter和monitorexit两个字节码指令,依赖这两个字节码指令来进行线程同步。

Monitor,Monitor常常被翻译成监视器或管程。关于Monitor,简单来说,你可以把它想像成一个只能容纳一名客人房间,而把想要获取对象锁的线程想像成想要进入这个房间的客人。一个线程进入了Monitor,那么其他线程只能等待,只有当这个线程退出,其他线程才有机会进入。

在这里插入图片描述

模拟流程:

  1. Entry Set中聚集了一些想要进入Monitor的线程,他们出于waiting状态。
  2. 假设某个A线程成功进入了Monitor,那么他就处于active状态。
  3. 此时A线程执行途中,遇到一个判断条件,需要他暂时让出执行权,那么它将进入Wait Set,状态被标记为wait。
  4. 这时Entry Set中的线程有机会进入Monitor,假设一个新的线程B成功进入并且顺利完成,那么它可以通过notify的形式来唤醒Wait Set中的线程A,让线程A再次进入Monitor,执行完成后便退出。

这就是synchronized关键字所实现的同步机制,但是synchronized可能存在性能问题,因为monitor的下层是依赖于操作系统的Mutex Lock来实现的。Java线程事实上是对操作系统线程的映射,所以每当挂起或唤醒一个线程都要切换到操作系统的内核态,这个操作是比较重量级的。在某些情况下,甚至切换时间本身就会超出线程执行任务的时间,这样的话,使用synchronized将会对程序的性能产生影响。

但是从Java6开始,synchronized进行了优化,引入了“偏向锁”、“轻量级锁”的概念。因此对象锁总共有四种状态,从低到高分别是“无锁”、“偏向锁”、“轻量级锁”、“重量级锁”,这就分别对应了Mark Word中锁标记位的四种状态。

对象锁的四种状态

  • 无锁

    无锁顾名思义就是没有对资源进行操作系统级别(Mutex Lock)的锁定。在这个基础上,我理解“无锁”其实有两种语义。

    第一种,某种资源不会出现在多线程环境下,或者即使出现在多线程环境下也不会出现线程竞争的情况,那么无需对这个资源进行任何同步保护,直接让他给各个线程随意调用。

    第二种,资源会被竞争,但是不使用操作系统同步原语对共享资源进行锁定,而是通过一些其他机制来控制同步,比如CAS,通过诸如这种函数级别的锁,我们可以进行无锁编程,大部分情况下效率更高。

  • 偏向锁

    加入一个对象被加锁了,但是在实际运行时,只有一条线程会获取这个对象锁,那么我们最理想的方式,是不要通过系统状态切换,只在用户态把这件事处理。我们设想的是最好对象能够认识这个线程,只要是这个线程过来,那么对象就把锁交出去,所以成为偏向锁。

    实现过程:那么偏向锁是怎么实现的呢?其实很简单,在Mark Word中,当锁标志位是01,那么判断倒数第三个bit是否为1,如果是1,代表当前对象的锁状态为偏向锁,于是再去读Mark Word的前23个bit,这23个bit就是线程ID,通过线程ID来确认想要获得对象锁的线程是不是“被偏爱的线程”。

在这里插入图片描述

假如情况发生了变化,对象发现目前不只有一个线程,而是由多个线程正在竞争锁,那么偏向锁将会升级为轻量级锁。

  • 轻量锁

    当锁的状态还是偏向锁时,是通过Mark Word中的线程ID来找到占有这个锁的线程,那么当锁的状态升级到“轻量级锁”时,如何判断线程和锁之间的绑定关系呢?

在这里插入图片描述

Mark Word中,这边已经不再使用线程ID这个字段,而是将前30个bit变为了指向虚拟机栈中锁记录的指针。

当一个线程想要获得某个对象的锁时,假如看到锁标志位为00,那么就知道它是轻量级锁,这时,线程会在自己的虚拟机栈中开辟一块被称为“Lock Record”的空间,关于虚拟机栈,上面简单讲过,是线程私有的。

Lock Record中存放什么呢?存放的是对象头的Mark Word的副本以及Owner指针。线程通过CAS去尝试获取锁,一旦获得,那么将会复制该对象的Mark Word到虚拟机栈的Lock Record中,并且将Lock Record中的Owner指针指向该对象锁。另一方面,对象的Mark Word中的前30bit将生成一个指针,指向持有该对象锁的线程虚拟机栈中的Lock Record。这样一来就实现了线程和对象锁的绑定,它们因此互相知道对方的存在。

这时,这个对象被锁定了,获取了这个对象锁的线程就可以执行一些任务。如果此时有其他的线程也想获取这个对象,此时其他线程会进行自旋等待。自旋,可以理解为一直轮询,其他想要获取对象锁的线程自己不断在循环尝试去看一下锁有没有被释放,如果被释放了就获取,如果没有释放那么进行下一轮循环,这种方式区别于被操作系统挂起的阻塞,因为如果对象锁很快就会被释放的话,自旋去获得锁完全在用户空间解决,不需要进行系统中断和现场恢复,所以效率更高。

(顺便提一下,自旋相当于是CPU在空转,如果长时间自旋,将会浪费CPU资源,于是出现一种叫做“适应性自旋”的优化,简单来说就是自旋的时间不再固定了,而是由上一次在同一个锁上的自旋时间及锁的状态来决定。比如在同一个锁上,当前正在自旋等待的线程刚刚成功获得过锁,但是锁目前被其他线程持有,那么虚拟机就会认为下次自旋很有可能会再次成功,进而它将允许更长的自旋时间。这就是“适应性自旋”。具体的算法,感兴趣的读者可以自己去了解一下。)

假如对象锁被一个线程持有着,此时也有一个线程正在自旋等待,如果同时又有多个线程想要获取这个对象锁。也就是说,一旦自选等待的线程数超过1个,那么轻量级锁将会升级为“重量级锁”。

  • 重量级锁

如果对象锁状态被标记为重量级锁,需要通过Monitor来对线程进行控制,此时将会使用同步原语来锁定资源,对线程的控制也最为严格。

乐观锁机制

悲观锁简单来说,就是操作系统将会悲观地认为,如果不严格同步线程调用,那么一定会产生异常,所以互斥锁将会锁定资源,只供一个线程调用,而阻塞其他线程,让其他线程等待,因此,这种同步机制也叫做“悲观锁”。

但悲观锁不是在所有情况下都适用,比如在一些情况下,同步代码块执行的耗时远远小于线程切换的耗时,这样就很不划算。程序员们可能更加希望一些场景下,能够在用户态中对线程的切换进行管理,这样效率更高。所以,我们不想让操作系统那么“悲观”,每次都使用同步原语对共享资源进行锁定,而是希望让线程反复“乐观”地去尝试获取共享资源,如果发现空闲,那么使用,如果被占用,那么继续“乐观”地重试。

在这里插入图片描述

CAS

CAS(Compare And Swap)

我们现在假设有一间更衣室,房间门上挂着一块牌子,正面是0,反面是1,这块牌子代表房间是否被占用的状态。当显示0的时候,房间为空,谁都可以进入,当显示1时,则代表有人正在使用。在上面这个比喻里,房间就是共享资源,号码牌就是一把乐观锁,人就是线程。

假设此时A和B这两条线程都看到了牌子上显示的是0,于是争抢着去使用房间。但是A线程抢先获得了时间片,他第一个冲进房间并将这块牌子的状态改为1,此时B线程才冲过来,但是发现牌子上的状态已经被改为1,不过B线程没有放弃,不断回来看看牌子变回0了没。

通过以上的比喻,当共享资源的状态值为0的一瞬间,A、B线程读到了。此时这两条线程认为共享资源当前空闲未被占用,于是它们各自将会生成两个值。

  1. old value,代表之前读到的资源对象的状态值
  2. new value,代表想要将资源对象的状态值更新后的值。

这里对AB线程来说,old value都是0,new value都是1。

此时AB线程争抢着去修改资源对象的状态值,然后占用它。假设A线程运气比较好,率先获得时间片时,他将old value与资源对象的状态值进行compare,发现一致,于是将牌子上的值swap为new value。而线程B没有那么幸运,它落后了一步,此时资源对象的状态值已经被A线程修改成了1,所以B线程在compare的时候,发现和自己预期的old value不一致,所以放弃swap操作。

 int cas(long *addr, long oldValue, long newValue) 
 { 
     /* Executes atomically. */ 
     if(*addr != old) 
         return 0; 
     *addr = new; 
     return 1;  
 }

ABA问题,因为看上去这个CAS函数本身没有进行任何同步措施,似乎还是存在线程不安全的问题。比如A线程看到牌子的状态是0,伸手去翻的一瞬间,很有可能B线程突然抢到时间片,将牌子翻成了1,但是线程A不知情,也将牌子翻到了1,这就出现了线程安全问题,AB线程同时获得了资源,好比两个人进入了更衣室,非常尴尬。

有进行任何同步措施,似乎还是存在线程不安全的问题。比如A线程看到牌子的状态是0,伸手去翻的一瞬间,很有可能B线程突然抢到时间片,将牌子翻成了1,但是线程A不知情,也将牌子翻到了1,这就出现了线程安全问题,AB线程同时获得了资源,好比两个人进入了更衣室,非常尴尬。

这么看来,一个亟待解决的问题是,“比较数值是否一致并且修改数值”的这个动作,必须要么成功要么失败,不能存在中间状态,换句话说,CAS操作必须是原子性的。只有基于这个真理,我们前面的所有设想才能成立。

非原创,整理自B站用户@寒食君视频

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值