关于synchronized

基本语法

synchronized 有三种方式来加锁,不同的修饰类型,代表锁的控制粒度

  1. 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  2. 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  3. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
探究 Jvm 源码实现

在 Java 代码中使用 new 创建一个对象实例时,JVM(hotspot 虚拟机) 层面实际上会创建一个instanceOopDesc 对象。

Hotspot 虚拟机采用 OOP-Klass 模型来描述 Java 对象实例。
  • OOP(Ordinary Object Point)指的是普通对象指针,
  • Klass 用来描述对象实例的具体类型。
Hotspot 采用 instanceOopDesc 和 arrayOopDesc 来描述对象头,arrayOopDesc 对象用来描述数组类型。
  • instanceOopDesc 的定义在 Hotspot 源码中的instanceOop.hpp 文件中
  • arrayOopDesc 的定义对应arrayOop.hpp
  • instanceOopDesc继承自 oopDesc,oopDesc 的定义在 Hotspot 源码中的oop.hpp 文件中
在普通实例对象中,oopDesc 的定义包含两个成员,分别是 _mark 和 _metadata
  • _mark 表示对象标记。属于 markOop 类型,即下面记载的 Mark World,它记录了对象和锁有关的信息。
  • _metadata 表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址,指向了这个对象所从属的class(即通过指针可以找到它的类对象)。
  • 其中 _klass 表示普通指针,_compressed_klass 表示压缩类指针。
Mark Word

在 Hotspot 中,markOop 的定义在 markOop.hpp 文件中,Mark word 记录了对象和锁有关的信息,当某个对象被synchronized 关键字当成同步锁时,那么围绕这个锁的一系列操作都和 Mark word 有关系。Mark Word 在 32 位虚拟机的长度是 32bit,在 64 位虚拟机的长度是 64bit。
Mark Word 里面存储的数据会随着锁标志位的变化而变化。

为什么任何对象都可以实现锁
  1. 首先,Java 中的每个对象都派生自 Object 类,而每个Java Object 在 JVM 内部都有一个 native 的 C++对象oop/oopDesc 进行对应。
  2. 线程在获取锁的时候,实际上就是获得一个monitor对象,monitor 可以认为是一个同步对象,所有的Java 对象被创建都会携带各自的monitor对象。多个线程访问同步代码块时,相当于去争抢同步对象的monitor对象,修改对象中的锁标识。
synchronized 锁的升级

synchronized 在JDK1.6 之后做了一些优化,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁、轻量级锁的概念。
所以在 synchronized 中,锁存在四种状态分别是:无锁、偏向锁、轻量级锁、重量级锁; 锁的状态根据竞争激烈的程度从低到高不断升级。

偏向锁的基本原理

大部分情况下,锁不仅仅不存在多线程竞争,而是总是由同一个线程多次获得,为了让线程获取锁的代价更低就引入了偏向锁的概念。
当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了。

获取
  1. 首先获取锁 对象的 Markword,判断是否处于可偏向状态。
    (_biased_locking_enabled == true && ThreadId == null)
  2. 如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID写入到 MarkWord
    • 如果 cas 成功,则获得了锁对象的偏向锁,接着执行同步代码块
    • 如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁。
      (这个操作需要等到全局安全点,也就是没有线程在执行字节码才能执行)
  3. 如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的 ThreadID
    • 如果相等,不需要再次获得锁,可直接执行同步代码块
    • 如果不相等,说明当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁
撤销

偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败,即存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。
对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:

  1. 原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无锁状态并且争抢锁的线程可以基于 CAS 重新偏向前线程
  2. 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块。

在应用开发中,绝大部分情况下一定会存在 2 个以上的线程竞争,那么如果开启偏向锁,反而会提升获取锁的资源消耗。所以可以通过 jvm 参数UseBiasedLocking 来设置开启或关闭偏向锁。

轻量级锁的基本原理
加锁

锁升级为轻量级锁之后,对象的 Markword 也会进行相应
的的变化。升级为轻量级锁的过程:

  1. 线程在自己的栈桢中创建锁记录 LockRecord。
  2. 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中。
  3. 将锁记录中的 Owner 指针指向锁对象。
  4. 将锁对象的对象头的 MarkWord替换为指向锁记录的指针。
自旋锁

轻量级锁在加锁过程中,用到了自旋锁。指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁。
注意,锁在原地循环的时候,是会消耗 cpu 的,就相当于在执行一个啥也没有的 for 循环。
所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短的时间就能够获得锁了。
自旋锁的使用,其实也是有一定的概率背景,在大部分同步代码块执行的时间都是很短的。所以通过看似无异议的循环反而能提升锁的性能。
但是自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么这个线程不断的循环反而会消耗 CPU 资源。默认情况下自旋的次数是 10 次,可以通过 preBlockSpin 来修改。

自适应自旋锁

在 JDK1.6 之后,引入了自适应自旋锁。意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。

  • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
  • 如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接
    阻塞线程,避免浪费处理器资源。
解锁

轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑,通过CAS 操作把线程栈帧中的 LockRecord 替换回到锁对象的MarkWord 中。

  • 如果成功表示没有竞争。
  • 如果失败,表示当前锁存在竞争,那么轻量级锁就会膨胀成为重量级锁。
重量级锁的基本原理

当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞来等待被唤醒了。

使用synchronized修饰同步代码块以后,在字节码中会看到一个monitorenter 和 monitorexit。
每一个 JAVA 对象都会与一个监视器 monitor 关联,可以把它理解成为一把锁,当一个线程想要执行一段被synchronized 修饰的同步方法或者代码块时,该线程得先获取到 synchronized 修饰的对象对应的 monitor。

  • monitorenter 表示去获得一个对象的monitor 。
  • monitorexit 表示释放 monitor 的所有权,使得其他被阻塞的线程可以尝试去获得这个monitor。

monitor 依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。

加锁

任意线程对由 synchronized 保护的 Object 的访问,首先要获得 Object 的monitor 。如果获取失败,线程进入同步队列,线程状态变为 BLOCKED。当访问 Object 的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对monitor 的获取。

举例说明线程的竞争机制

有这样一个同步代码块,存在 Thread#1、Thread#2 等多个线程
偏向锁情景:只有 Thread#1 会进入临界区。
此时当 Thread#1 进入临界区时,JVM 会将 lockObject 的
对象头 Mark Word 的锁标志位设为“01”,同时会用 CAS 操作把 Thread#1 的线程 ID 记录到 Mark Word 中,此时进入偏向模式。所谓“偏向”,指的是这个锁会偏向于 Thread#1,
若接下来没有其他线程进入临界区,则 Thread#1 再出入临界区无需再执行任何同步操作。也就是说,若只有Thread#1 会进入临界区,实际上只有 Thread#1 初次进入临界区时需要执行 CAS 操作,以后再出入临界区都不会有同步操作带来的开销。
轻量级锁情景:Thread#1 和 Thread#2 交替进入临界区,竞争不激烈。
偏向锁的场景太过于理想化,更多的时候是 Thread#2 也会尝试进入临界区, 如果 Thread#2 也进入临界区但是Thread#1 还没有执行完同步代码块时,会暂停 Thread#1并且升级到轻量级锁。Thread#2 通过自旋再次尝试以轻量级锁的方式来获取锁。
重量级锁情景:Thread#1/Thread#2/Thread3… 同时进入临界区,竞争激烈。
如果 Thread#1 和 Thread#2 正常交替执行,那么轻量级锁基本能够满足锁的需求。但是如果 Thread#1 和 Thread#2同时进入临界区,那么轻量级锁就会膨胀为重量级锁,意味着 Thread#1 线程获得了重量级锁的情况下,Thread#2就会被阻塞。

Object 对象中的wait(),notify(),notifyAll()

用于显示控制线程的状态。

基本概念
  • wait:表示持有对象锁的线程 A 准备释放对象锁权限,释放 cpu 资源并进入等待状态。
  • notify:表示持有对象锁的线程 A 准备释放对象锁权限,通知 jvm 唤醒某个竞争该对象锁的线程 X。线程 A 的synchronized 代码执行结束并且释放了锁之后,线程 X 直接获得对象锁权限,其他竞争线程继续等待,即使线程 X 同步完毕,释放对象锁,其他竞争线程仍然等待,直至有新
    的 notify(),notifyAll() 被调用。
  • notifyAll:notifyall 和 notify 的区别在于,notifyAll 会唤醒所有竞争同一个对象锁的所有线程,当已经获得锁的线程 A 释放锁之后,所有被唤醒的线程都有可能获得对象锁权。限
注意
  1. 三个方法都必须在 synchronized 同步关键字所限定的作用域中调用,否则会报错 java.lang.IllegalMonitorStateException
    意思是因为没有同步,所以线程对对象锁的状态是不确定的,不能调用这些方法。
  2. 需要通过同步机制来确保线程从 wait() 方法返回时能够感知到 notify() 线程对变量做出的修改。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容