【转载】synchronized 与锁的关系

synchronized 关键字

说到锁,都会提 synchronized 。这个英文单词儿啥意思呢?翻译成中文就是「同步」的意思java

通常都是使用 synchronized 这个关键字来给一段代码或者一个方法上锁,使得这段代码或者方法,在同一个时刻只能有一个线程来执行它。web

synchronized 相比于 volatile 来讲,用的比较灵活,你能够在方法上使用,能够在静态方法上使用,也能够在代码块上使用。算法

关于 synchronized 这一块大概就说到这里,阿粉今天想着重来讲一下, synchronized 底层是怎么实现的编程

JVM 是如何实现 synchronized 的?

我知道能够利用 synchronized 关键字来给程序进行加锁,可是它具体怎么实现的我不清楚呀,别急,我们先来看个 demo :数组

public class demo {    
    public void synchronizedDemo(Object lock{  
        synchronized(lock){   
            lock.hashCode();  
        } 
    }
}

上面是我写的一个 demo ,而后进入到 class 文件所在的目录下,使用 javap -v demo.class 来看一下编译的字节码(在这里我截取了一部分):安全

  public void synchronizedDemo(java.lang.Object);    
    descriptor: (Ljava/lang/Object;)V               
    flags: ACC_PUBLIC    
    Code:      
        stack=2, locals=4, args_size=2         
        0: aload_1         
        1: dup         
        2: astore_2         
        3: monitorenter         
        4: aload_1         
        5: invokevirtual #2                  // Method java/lang/Object.hashCode:()I         
        8: pop         
        9: aload_2        
        10: monitorexit        
        11: goto          19        
        14: astore_3        
        15: aload_2        
        16: monitorexit        
        17: aload_3        
        18: athrow        
        19: return      
        Exception table:         
        from    to  target type             
        4    11    14   any            
        14    17    14   any

应该可以看到当程序声明 synchronized 代码块时,编译成的字节码会包含 monitorentermonitorexit 指令,这两种指令会消耗操做数栈上的一个引用类型的元素(也就是 synchronized 关键字括号里面的引用),做为所要加锁解锁的锁对象。若是看的比较仔细的话,上面有一个 monitorenter 指令和两个 monitorexit 指令,这是 Java 虚拟机为了确保得到的锁不论是在正常执行路径,仍是在异常执行路径上都可以解锁。多线程

关于 monitorentermonitorexit ,能够理解为每一个锁对象拥有一个锁计数器和一个指向持有该锁的线程指针:并发

  • 当程序执行 monitorenter 时,若是目标锁对象的计数器为 0 ,说明这个时候它没有被其余线程所占有,此时若是有线程来请求使用, Java 虚拟机就会分配给该线程,而且把计数器的值加 1
  • 目标锁对象计数器不为 0 时,若是锁对象持有的线程是当前线程, Java 虚拟机能够将其计数器加 1 ,若是不是呢?那很抱歉,就只能等待,等待持有线程释放掉
  • 当执行 monitorexit 时, Java 虚拟机就将锁对象的计数器减 1 ,当计数器减到 0 时,说明这个锁就被释放掉了,此时若是有其余线程来请求,就能够请求成功

为何采用这种方式呢?是为了容许同一个线程重复获取同一把锁。好比,一个 Java 类中拥有好多个 synchronized 方法,那这些方法之间的相互调用,不论是直接的仍是间接的,都会涉及到对同一把锁的重复加锁操做。这样去设计的话,就能够避免这种状况。函数

在 Java 多线程中,全部的锁都是基于对象的。也就是说, Java 中的每个对象均可以做为一个锁。你可能会有疑惑,不对呀,不是还有类锁嘛。可是 class 对象也是特殊的 Java 对象,因此呢,在 Java 中全部的锁都是基于对象的性能

在 Java6 以前,全部的锁都是"重量级"锁,重量级锁会带来一个问题,就是若是程序频繁得到锁释放锁,就会致使性能的极大消耗。为了优化这个问题,引入了"偏向锁"和"轻量级锁"的概念。因此在 Java6 及其之后的版本,一个对象有 4 种锁状态:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。

在 4 种锁状态中,无锁状态应该比较好理解,无锁就是没有锁,任何线程均可以尝试修改,因此这里就一笔带过了。

随着竞争状况的出现,锁的升级很是容易发生,可是若是想要让锁降级,条件很是苛刻,有种你想来能够,可是想走不行的赶脚。

阿粉在这里啰嗦一句:不少文章说,锁若是升级以后是不能降级的,其实在 HotSpot JVM 中,是支持锁降级的

锁降级发生在 Stop The World 期间,当 JVM 进入安全点的时候,会检查有没有闲置的锁,若是有就会尝试进行降级

看到 Stop The World 和 安全点 可能有人比较懵,我这里简单说一下,具体还须要读者本身去探索一番.(由于这是 JVM 的内容,这篇文章的重点不是 JVM )

在 Java 虚拟机里面,传统的垃圾回收算法采用的是一种简单粗暴的方式,就是 Stop-the-world ,而这个 Stop-the-world 就是经过安全点( safepoint )机制来实现的,安全点是什么意思呢?就是 Java 程序在执行本地代码时,若是这段代码不访问 Java 对象/调用 Java 方法/返回到原来的 Java 方法,那 Java 虚拟机的堆栈就不会发生改变,这就表明执行的这段本地代码能够做为一个安全点。当 Java 虚拟机收到 Stop-the-world 请求时,它会等全部的线程都到达安全点以后,才容许请求 Stop-the-world 的线程进行独占工做

接下来就介绍一下几种锁和锁升级

Java 对象头

在刚开始就说了, Java 的锁都是基于对象的,那是怎么告诉程序我是个锁呢?就不得不来讲, Java 对象头 每一个 Java 对象都有对象头,若是是非数组类型,就用 2 个字宽来存储对象头,若是是数组,就用 3 个字宽来存储对象头。在 32 位处理器中,一个字宽是 32 位;在 64 位处理器中,字宽就是 64 位咯~对象头的内容就是下面这样:

image.png

我们主要来看 Mark Word 的内容:

image.png

从上面表格中,应该可以看到,是偏向锁时, Mark Word 存储的是偏向锁的线程 ID ;是轻量级锁时, Mark Word 存储的是指向线程栈中 Lock Record 的指针;是重量级锁时, Mark Word 存储的是指向堆中的 monitor 对象的指针

偏向锁

HotSpot 的做者通过大量的研究发现,在大多数状况下,锁不只不存在多线程竞争,并且老是由同一线程屡次得到

基于此,就引入了偏向锁的概念

因此啥是偏向锁呢?用大白话说就是,我如今给锁设置一个变量,当一个线程请求的时候,发现这个锁是 true ,也就是说这个时候没有所谓的资源竞争,那也不用走什么加锁/解锁的流程了,直接拿来用就行。可是若是这个锁是 false 的话,说明存在其余线程竞争资源,那我们再走正规的流程

看一下具体的实现原理:

当一个线程第一次进入同步块时,会在对象头和栈帧中的锁记录中存储锁偏向的线程 ID 。当下次该线程进入这个同步块时,会检查锁的 Mark Word 里面存放的是否是本身的线程 ID。若是是,说明线程已经得到了锁,那么这个线程在进入和退出同步块时,都不须要花费 CAS 操做来加锁和解锁;若是不是,说明有另一个线程来竞争这个偏向锁,这时就会尝试使用 CAS 来替换 Mark Word 里面的线程 ID 为新线程的 ID 。此时会有两种状况:

  • 替换成功,说明以前的线程不存在了,那么 Mark Word 里面的线程 ID 为新线程的 ID ,锁不会升级,此时仍然为偏向锁
  • 替换失败,说明以前的线程仍然存在,那就暂停以前的线程,设置偏向锁标识为 0 ,并设置锁标志位为 00 ,升级为轻量级锁,按照轻量级锁的方式进行竞争锁

撤销偏向锁

偏向锁使用了一种等到竞争出现时才释放锁的机制。也就说,若是没有人来和我竞争锁的时候,那么这个锁就是我独有的,当其余线程尝试和我竞争偏向锁时,我会释放这个锁

在偏向锁向轻量级锁升级时,首先会暂停拥有偏向锁的线程,重置偏向锁标识,看起来这个过程挺简单的,可是开销是很大的,由于:

  • 首先须要在一个安全点中止拥有锁的线程
  • 而后遍历线程栈,若是存在锁记录的话,就须要修复锁记录和 Mark Word ,变成无锁状态
  • 最后唤醒被中止的线程,把偏向锁升级成轻量级锁

你觉得就是升级一个轻量级锁?too young too simple

偏向锁向轻量级锁升级的过程当中,是很是耗费资源的,若是应用程序中全部的锁一般都处于竞争状态,偏向锁此时就是一个累赘,此时就能够经过 JVM 参数关闭偏向锁: -XX:-UseBiasedLocking=false ,那么程序默认会进入轻量级锁状态

最后,来张图吧~

轻量级锁

若是多个线程在不一样时段获取同一把锁,也就是不存在锁竞争的状况,那么 JVM 就会使用轻量级锁来避免线程的阻塞与唤醒

轻量级锁加锁

JVM 会为每一个线程在当前线程的栈帧中建立用于存储锁记录的空间,称之为 Displaced Mark Word 。若是一个线程得到锁的时候发现是轻量级锁,就会将锁的 Mark Word 复制到本身的 Displaced Mark Word 中。以后线程会尝试用 CAS 将锁的 Mark Word 替换为指向锁记录的指针。

若是替换成功,当前线程得到锁,那么整个状态仍是 轻量级锁 状态

若是替换失败了呢?说明 Mark Word 被替换成了其余线程的锁记录,那就尝试使用自旋来获取锁.(自旋是说,线程不断地去尝试获取锁,通常都是用循环来实现的)

自旋是耗费 CPU 的,若是一直获取不到锁,线程就会一直自旋, CPU 那么宝贵的资源就这么被白白浪费了

解决这个问题最简单的办法就是指定自旋的次数,好比若是没有替换成功,那就循环 10 次,尚未获取到,那就进入阻塞状态

可是 JDK 采用了一个更加巧妙的方法---适应性自旋。就是说,若是此次线程自旋成功了,那我下次自旋次数更多一些,由于我此次自旋成功,说明我成功的几率仍是挺大的,下次自旋次数就更多一些,那么若是自旋失败了,下次我自旋次数就减小一些,就好比,已经看到了失败的前兆,那我就先溜,而不是非要“不撞南墙不回头”

自旋失败以后,线程就会阻塞,同时锁会升级成重量级锁

轻量级锁释放:

在释放锁时,当前线程会使用 CAS 操做将 Displaced Mark Word 中的内容复制到锁的 Mark Word 里面。若是没有发生竞争,这个复制的操做就会成功;若是有其余线程由于自旋屡次致使轻量级锁升级成了重量级锁, CAS 操做就会失败,此时会释放锁同时唤醒被阻塞的过程

一样,来一张图吧:

重量级锁

重量级锁依赖于操做系统的互斥量( mutex )来实现。可是操做系统中线程间状态的转换须要相对比较长的时间(由于操做系统须要从用户态切换到内核态,这个切换成本很高),因此重量级锁效率很低,可是有一点就是,被阻塞的线程是不会消耗 CPU 的

每个对象均可以当作一个锁,那么当多个线程同时请求某个对象锁时,它会怎么处理呢?

对象锁会设置集中状态来区分请求的线程:

Contention List:全部请求锁的线程将被首先放置到该竞争队列

Entry List: Contention List 中那些有资格成为候选人的线程被移到 Entry List 中

Wait Set:调用 wait 方法被阻塞的线程会被放置到 Wait Set 中

OnDeck:任什么时候刻最多只能有一个线程正在竞争锁,该线程称为 OnDeck

Owner:得到锁的线程称为 Owner

!Owner:释放锁的线程

当一个线程尝试得到锁时,若是这个锁被占用,就会把该线程封装成一个 ObjectWaiter对象插入到 Contention List 队列的队首,而后调用 park 函数挂起当前线程

当线程释放锁时,会从 Contention List 或者 Entry List 中挑选一个线程进行唤醒

若是线程在得到锁以后,调用了 Object.wait 方法,就会将该线程放入到 WaitSet 中,当被 Object.notify 唤醒后,会将线程从 WaitSet 移动到 Contention List 或者 Entry List 中。

可是,当调用一个锁对象的 waitnotify 方法时,若是当前锁的状态是偏向锁或轻量级锁,则会先膨胀成重量级锁

总结

synchronized 关键字是经过 monitorenter 和 monitorexit 两种指令来保证锁的

当一个线程准备获取共享资源时:

  • 首先检查 MarkWord 里面放的是否是本身的 ThreadID ,若是是,说明当前线程处于 "偏向锁"
  • 若是不是,锁升级,这时使用 CAS 操做来执行切换,新的线程根据 MarkWord 里面现有的 ThreadID 来通知以前的线程暂停,将 MarkWord 的内容置为空
  • 而后,两个线程都将锁对象 HashCode 复制到本身新建的用于存储锁的记录空间中,接着开始经过 CAS 操做,把锁对象的 MarkWord 的内容修改成本身新建的记录空间地址,以这种方式竞争 MarkWord ,成功执行 CAS 的线程得到资源,失败的则进入自旋
  • 自旋的线程在自旋过程当中,若是成功得到资源(也就是以前得到资源的线程执行完毕,释放了共享资源),那么整个状态依然是 轻量级锁 的状态
  • 若是没有得到资源,就进入 重量级锁 的状态,此时,自旋的线程进行阻塞,等待以前线程执行完成而且唤醒本身

参考:

  • Java 并发编程的技术
  • 极客时间---深刻拆解 Java 虚拟机

到这里,整篇文章的内容就算是结束了。

相关文章
相关标签/搜索