前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >synchronized—深入总结

synchronized—深入总结

作者头像
用户5325874
发布2020-01-16 18:09:13
5620
发布2020-01-16 18:09:13
举报
文章被收录于专栏:用户5325874的专栏

synchronized

传统的锁(也就是下文要说的重量级锁)依赖于系统的同步函数,在linux上使用mutex互斥锁,这些同步函数都涉及到用户态和内核态的切换、进程的上下文切换,成本较高。对于加了synchronized关键字但运行时并没有多线程竞争,或两个线程接近于交替执行的情况,使用传统锁机制无疑效率是会比较低的。

在JDK 1.6之前,synchronized只有传统的锁机制,因此给开发者留下了synchronized关键字相比于其他同步机制性能不好的印象。在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争的场景下因使用传统锁机制带来的性能开销问题。

内存语义

关于锁我们知道它可以让临界区互斥,但它还有另一个重要功能,锁的内存语义。

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。

线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

每个对象作为锁

java中每个对象都可以作为锁。

  • 对于普通方法:锁是当前实例对象
  • 对于静态方法:锁是当前类的class对象
  • 对于同步方法块:锁是synchronized括号里面的对象

实现原理

JVM基于进入和退出Monitor(监视器)对象来实现方法同步和代码块同步,但两者的实现细节不一样。

代码块的同步是使用monitorenter和monitorexit指令实现的,而方法的同步是依靠方法修饰符上的ACC_SYNCHRONIZED来完成的。无论采取哪种方式,其本质都是对一个对象的监视器进行获取,而这个获取过程是排他的,也就是同一时刻只能有一个线程获取到synchronized所保护对象的监视器。

monitorenter指令是在编译后插入到代码块的起始位置,monitorexit指令插入到代码块结束和异常的位置。

JVM保证monitorenter一定会有一个monitorexist对应,不然就死锁了。

当线程执行到monitorenter指令时,将会尝试获取对象关联的monitor(监视器)的所有权,即尝试获取对象的锁。

每一个对象都有一个monitor(监视器)与之关联。当一个monitor被持有后,它将处于锁定状态。当线程执行到monitorenter,线程将尝试获取对象关联的monitor的所有权,即尝试获取对象的锁。

线程获取到对象的监视器,才能进入同步代码块,而没有获取到监视器的线程则被阻塞到同步代码块入口处,进入BLOCKED状态。

下图描述了对象、对象的监视器、同步队列、执行线程之间的关系:

image-20191119223002391
image-20191119223002391

从图中可以看到,任意线程对Object对象的访问,首先需要获取Object的监视器,如果获取失败,线程进入同步队列,线程状态变为BLOCKED。当上一个获取了锁的线程,释放了锁,则该释放操作会唤醒在同步队列中的线程,使其重新尝试对监视器的获取。

Java对象头

在JVM中,对象在内存中除了本身的数据外还会有个对象头,对于普通对象而言,其对象头中有两类信息:mark word和类型指针。另外对于数组而言还会有一份记录数组长度的数据。

synchronize使用的锁是存储在java对象头里面的。

  • 如果对象是数组类型,则虚拟机用3字宽存储对象头(多出的1字宽用于存储数组的长度)
  • 如果对象是非数组类型,则虚拟机使用2字宽存储对象头。

在32位虚拟机中,1字宽等于4字节。

image-20191013115654950
image-20191013115654950

java对象头的mark world默认存储对象的HashCode、分代年龄、锁标志位。32位的JVM的mark world存储结构如图:

image-20191013120238571
image-20191013120238571

在运行期间,mark world里面存储的数据会随着锁标志位的变化而变化,如图:

image-20191013120416195
image-20191013120416195

在64位虚拟机下,Mark World是64bit大小,其存储结构如下:

image-20191102182644411
image-20191102182644411

锁升级与对比

Java SE1.6为了减少获得锁和释放锁带来的性能损耗,引入了“偏向锁”和“轻量级”锁。在java SE 1.6中,锁一共有4种状态,级别从高到底依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。这几个状态会随着锁竞争加剧而逐渐升级。

**锁可以升级,但不能降级。**意味着偏向锁升级为轻量级锁之后,不能降级为偏向锁。为什么这样子做呢?这样子是为了提高获取锁和释放锁的效率。 其实很好理解,如果锁升级了,证明这块同步区域将来也很有可能面临锁竞争,达到这个锁的级别,如果目前没有竞争,就把锁降级的话,将来产生同样的锁竞争,又将进行锁升级,就会降低获取锁的效率。

偏向锁

HotSpot的作者经研究发现,大多数情况下,锁不仅不存在多线程竞争,并且总是由同一个线程获得。这样子每次获取锁都进行同步,代价也太大了。为了让线程获取锁的代价更低而引入了偏向锁。

对象创建

当JVM启用了偏向锁模式(1.6以上默认开启),新创建一个对象的时候,那新创建对象的mark word将是可偏向状态,此时mark word中的thread id为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。

偏向锁的获取
  • 当该对象第一次被线程获得锁的时候,发现是匿名偏向状态(锁没有偏向哪个线程),则会用CAS指令,将mark word中的thread id由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁。
  • 当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程,会往当前线程的的栈帧中添加一条Displaced Mark Word为空的Lock Record,并且将Lock Record的obj字段指向锁对象,然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized关键字带来的性能开销基本可以忽略。
  • 当其他线程尝试进入同步块时,发现锁对象已经有偏向的线程了,则会进入到撤销偏向锁的逻辑里,一般来说,会在safepoint中去查看偏向的线程是否还存活,
    • 如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里;
    • 如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改为无锁状态(unlocked),之后再升级为轻量级锁。

由此可见,偏向锁升级的时机为:当锁已经发生偏向后,只要有另一个线程尝试获得偏向锁,则该偏向锁就会升级成轻量级锁。当然这个说法不绝对,因为还有批量重偏向这一机制。

释放锁过程

释放锁指的是线程退出同步块的时候,释放偏向锁。

当有其他线程尝试获得锁时,是根据遍历偏向线程的lock record来确定该线程是否还在执行同步块中的代码

,如果lock record中obj字段指向锁对象,那么其他线程就认为该线程还在执行同步块代码。

因此偏向锁的释放(解锁)很简单,仅仅将栈中的最近一条lock recordobj字段设置为null。需要注意的是,偏向锁的解锁步骤中并不会修改对象头中的thread id。

偏向锁的撤销

偏向锁的撤销指线程在获取偏向锁的时候失败了,导致要将锁对象改为非偏向锁状态,升级为轻量级锁

  • 会在safepoint中去查看偏向的线程是否还存活
    • 如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里(当先线程会尝试获取轻量级锁,如果获取不成功,就只能获取重量级锁导致阻塞)
    • 如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改为无锁状态(unlocked),之后再升级为轻量级锁。

下图是发生竞争的情况下,进行偏向锁的撤销:

image-20191013130101306
image-20191013130101306
  • 判断Mark Word中是否有指向自己的线程ID
  • 否,则判断当前锁是否为偏向锁
    • 是。那么线程2会用CAS替换来尝试获取锁。 CAS替换Mark Word成功表示获取偏向锁成功,这里由于对象头中Mark Word已经指向了线程1,所以替换失败,需要进行撤销操作
  • 撤销的时候需要暂停线程1。
    • 如果线程1已经终止了,则将锁对象的对象头设置为无锁状态
    • 如果线程1还未终止,唤醒线程1
关闭偏向锁

偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,如果不想有这个延迟,那么可以使用-XX:BiasedLockingStartUpDelay=0; 如果不想要偏向锁,那么可以通过-XX:-UseBiasedLocking = false来设置;

轻量级锁

轻量级加锁
  • 线程在执行同步块之前,JVM会先在当前线程的栈桢中创建一个用于存储锁记录Lock Record的空间,并将对象头中的Mark Word拷贝到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。
    • Lock Record包含用于存储对象头的mark work,并且另外还有一个指向对象的指针
image-20191102194748047
image-20191102194748047
  • 如果当前锁的状态是无锁状态,则CAS成功,当前线程获得锁
  • 如果当前锁的状态不是无锁状态,则CAS失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  • 如果使用自旋锁也获取不到锁,那么它就会修改markword,标识为重量级锁,表示该升级为重量锁了。

等待轻量锁的线程不会阻塞,它会自旋等待一段时间。这就是自旋锁。

尝试获取锁的线程,在没有获得锁的时候,不被挂起,而转而去执行一个空循环,即自旋。在若干个自旋后,如果还没有获得锁,则才被挂起。

获得锁,则执行代码。虽然自旋可以防止阻塞,节省从内核态到用户态的开销,但是如果长时间自旋,则会导致CPU长时间做一个同样的无用循环操作。浪费CPU的资源。这时候引入了自适应自旋。

#####自适应自旋锁

此操作为了防止长时间的自旋,在自旋操作上加了一些限制条件。

  • 比如一开始给线程自旋的时间是10秒,如果线程在这个时间内获得了锁,那么就认为这个线程比较容易获得锁,就会适当的加长它的自旋时间。
  • 如果这个线程在规定时间内没有获得到锁,并且阻塞了。那么就认为这个线程不容易获得锁,下次当这个线程进行自旋的时候会减少它的自旋时间
轻量级解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头。

  • 如果成功,则表示没有竞争发生。成功替换,等待下一个线程获取锁。
  • 如果失败,表示当前锁存在竞争,锁就会升级为重量级锁。

因为自旋会消耗CPU,为了避免过多的自旋,一旦锁升级成重量级锁,就不会再 恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后 会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

下图是两个锁同时竞争,导致锁升级的流程图:

image-20191013132634997
image-20191013132634997

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再 恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后 会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

重量级锁

重量级锁的状态下,对象的mark word为指向一个monitor对象的指针。

一个monitor对象包括这么几个关键字段:cxq(下图中的ContentionList),EntryList ,WaitSet,owner。

其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。

image-20191102194423540
image-20191102194423540
  • 重量级锁是JVM中为基础的锁实现。在这种状态下,JVM虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。
  • Java线程的阻塞以及唤醒,都是依靠操作系统来完成的。举例来说,对于符合posix接口的操作系统(如macOS和绝大 部分的Linux),上述操作通过pthread的互斥锁(mutex)来实现的。此外,这些操作将涉及系统调用,需要从操作系统的用户态切换至内核态,其开销非常之大。
  • 为了尽量避免昂贵的线程阻塞、唤醒操作,JVM会在线程进入阻塞状态之前,以及被唤醒之后竞争不到锁的情况 下,进入自旋状态,在处理器上空跑并且轮询锁是否被释放。如果此时锁恰好被释放了,那么当前线程便无须进入阻塞状态,而是直接获得这把锁。

锁的对比

image-20191013133725683
image-20191013133725683
偏向锁
  • 优点:
    • 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级别的差距
  • 缺点:
    • 如果线程间存在锁竞争,会带来额外的锁撤销的消耗
  • 适用场景
    • 适用于只有一个线程访问同步块的场景
轻量级锁
  • 优点:
    • 竞争的线程不会阻塞,提高了程序的响应速度
  • 缺点:
    • 自旋时间过长,会消耗CPU
  • 适用场景:
    • 适用于同步块执行速度非常快的场景
    • 追求响应时间
重量级锁
  • 优点:
    • 线程竞争不会自旋,不消耗CPU
  • 缺点:
    • 线程阻塞,响应时间缓慢
  • 适用场景:
    • 适用于同步块执行速度较慢的场景

ReentrantLock的区别

  • ReentrantLock支持等待超时,可以有效避免死锁
  • ReentrantLock支持中断
  • ReentrantLock支持公平锁,也就是按照FIFO的顺序获取锁
  • ReentrantLock支持绑定Condition对象
  • 在资源竞争不是很激烈的情况下,synchronize使用的是偏向锁,效率较高。而ReentrantLock总是会阻塞线程。

总结

Java虚拟机中synchronized关键字的实现,按照代价由高到低可以分为重量级锁、轻量锁和偏向锁三种。

  • 重量级锁会阻塞、唤醒请求加锁的线程。它针对的是多个线程同时竞争同一把锁的情况。JVM采用了自适应自旋,来避免线程在面对非常小的synchronized代码块时,仍会被阻塞、唤醒的情况。
  • 轻量级锁采用CAS操作,将锁对象的mark world替换为一个指针,指向当前线程栈上的一块空间(锁记录),存储着锁对象原本的mark world。它针对的是多个线程在不同时间段申请同一把锁的情况
  • 偏向锁只会在第一次请求时采用CAS操作,在锁对象的标记字段中记录下当前线程的内存地址。在之后的运行过程中,持有该偏向锁的线程的加锁操作将直接返回。它针对的是锁仅会被同一线程持有的情况

问题

轻量级锁为什么要使用自旋锁?

为了避免调用系统的同步函数,造成用户态和内核态的切花

自旋锁适用于什么场景?

线程的任务执行时间比较短的场景

参考

《并发编程艺术》

死磕Synchronized底层实现–概论

死磕Synchronized底层实现–偏向锁

synchronzied和ReentrantLock区别

[死磕Synchronized底层实现–重量级锁

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • synchronized
    • 内存语义
      • 每个对象作为锁
        • 实现原理
          • Java对象头
        • 锁升级与对比
          • 偏向锁
          • 轻量级锁
          • 重量级锁
          • 锁的对比
        • ReentrantLock的区别
          • 总结
            • 问题
              • 参考
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档