多线程安全——synchronized原理

synchronized 它可以把任意一个非 NULL 的对象当作锁。他属于独占式的悲观锁,同时属于可重 入锁

作用范围

1. 作用于方法时,锁住的是对象的实例(this);

2. 当作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久带PermGen (jdk1.8 则是 metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁, 会锁所有调用该方法的线程;

3. synchronized 作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。它有多个队列, 当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。

synchronized有三种方式来加锁,分别是:方法锁对象锁synchronized(this)类锁synchronized(Demo.Class)

synchronized代码块底层原理

public class SynchronizedTest1 {

    private static int count = 0;

    public static void test(){
        synchronized (SynchronizedTest1.class) {
            count++;
        }
    }
}

通过javap -v 来查看对应代码的字节码指令:

对于同步块的实现使用了monitorenter和monitorexit指令:他们隐式的执行了Lock和UnLock操作,用于提供原子性保证。monitorenter指令插入到同步代码块开始的位置、monitorexit指令插入到同步代码块结束位置,jvm需要保证每个monitorenter都有一个monitorexit对应。这两个指令,本质上都是对一个对象的监视器(monitor)进行获取,这个过程是排他的,也就是说同一时刻只能有一个线程获取到由synchronized所保护对象的监视器。线程执行到monitorenter指令时,会尝试获取对象所对应的monitor所有权,也就是尝试获取对象的锁;而执行monitorexit,就是释放monitor的所有权。

synchronized方法底层原理

public class SynchronizedTest2 {

    private static int count = 0;

    public synchronized static void test() {
        count++;
    }
}

方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。

JVM通过该ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor, 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。

实现原理

Java对象头是实现synchronized的锁对象的基础,一般而言,synchronized使用的锁对象是存储在Java对象头里。它是轻量级锁和偏向锁的关键。

Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。

JVM中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度)。

在Hotspot虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充;

Mark Word

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。

关于 synchronized锁的四种状态与锁升级过程 图文详解_第5张图片

关于 synchronized锁的四种状态与锁升级过程 图文详解_第4张图片

 

以 32位虚拟机为例:

无锁:对象头开辟 25bit 的空间用来存储对象的 hashcode ,4bit 用于存放对象分代年龄,1bit 用来存放是否偏向锁的标识位,2bit 用来存放锁标识位为01

偏向锁: 在偏向锁中划分更细,还是开辟 25bit 的空间,其中23bit 用来存放线程ID,2bit 用来存放 Epoch,4bit 存放对象分代年龄,1bit 存放是否偏向锁标识, 0表示无锁,1表示偏向锁,锁的标识位还是01

轻量级锁:在轻量级锁中直接开辟 30bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为00

重量级锁: 在重量级锁中和轻量级锁一样,30bit 的空间用来存放指向重量级锁的指针,2bit 存放锁的标识位,为11

GC标记: 开辟30bit 的内存空间却没有占用,2bit 空间存放锁标志位为11。

其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。

关于内存的分配,在git中openJDK中 markOop.hpp:

关于 synchronized锁的四种状态与锁升级过程 图文详解_第6张图片

  • age_bits: 就是我们说的分代回收的标识,占用4字节
  • lock_bits: 是锁的标志位,占用2个字节
  • biased_lock_bits: 是是否偏向锁的标识,占用1个字节
  • max_hash_bits: 是针对无锁计算的hashcode占用字节数量,如果是32位虚拟机,就是 32 - 4 - 2 -1 = 25 byte,如果是64 位虚拟机,64 - 4 - 2 - 1= 57 byte,但是会有 25 字节未使用,所以64位的 hashcode 占用 31 byte
  • hash_bits: 是针对 64 位虚拟机来说,如果最大字节数大于 31,则取31,否则取真实的字节数
  • cms_bits: 不是64位虚拟机就占用 0byte,是64位就占用 1byte
  • epoch_bits: 就是 epoch 所占用的字节大小,2字节。

Monitor(管程)

管程首先由霍尔(C.A.R.Hoare)和汉森(P.B.Hansen)两位大佬提出,是一种并发控制机制由编程语言来具体实现。它负责管理共享资源以及对共享资源的操作,并提供多线程环境下的互斥和同步,以支持安全的并发访问

管程能够保证同一时刻最多只有一个线程访问与操作共享资源(即进入临界区)。在临界区被占用时,其他试图进入临界区的线程都将等待。如果线程不能满足执行临界区逻辑的条件(比如资源不足),就会阻塞。阻塞的线程可以在满足条件时被唤醒,再次试图执行临界区逻辑。

“共享资源以及对共享资源的操作”在操作系统理论中称为critical section,即临界区。

所有的Java对象是天生的Monitor。synchronized借助Java对象中的Monitor完成线程的阻塞加锁就是在竞争 monitor 对象。

Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。

只有重量级锁才会借助Monitor,而偏向锁和轻量级锁均借助的是对象头中mark word的标识来实现的。

Mesa模型

管程的设计有HansenHoareMesa三种模型。Mesa是Java采用的设计方案。

图中有两个条件变量a、b,它们对应的线程队列为a.q和b.q,另外还有一个入口队列e,它们分别占用一个房间。右下角的大房间即为临界区。该模型的执行流程如下:

  1. 多个线程进入管程的入口队列e,并试图获取临界区锁。获取到锁的线程进入临界区,其他线程仍然在e中。
  2. 通过外部条件来判断进入临界区的线程是否能执行操作,分为以下3、4两种情况。
  3. 如果不能执行,则调用wait原语,该线程阻塞,释放临界区的锁,离开临界区并根据条件进入a.q或者b.q。
  4. 如果能执行,那么在执行完毕后调用notify原语(相当于signal),唤醒a.q或b.q中的一个线程。执行完毕的线程释放锁,并离开管程的作用域。
  5. 被唤醒的线程进入队列e,返回第1步重新开始。

Mesa管程的特点是:线程由阻塞状态被唤醒之后不会立即执行,而是回到入口等待。相对地,Hoare管程在线程被唤醒后就会立即切换上下文,让被唤醒的线程先执行。后者的实现简单,但会触发更多的上下文切换操作,浪费CPU时间。前者的效率自然比较高,但带来的潜在问题是线程回到队列e后,原先满足的条件可能已经不再满足,必须重新检查。所以在Mesa管程模型下编写程序时,检查条件应该用while,而不是if:

while (!condition) {
    wait(a)
}

为什么每个对象都可以成为锁?

在 Java 程序运行的过程中,每创建一个新的对象,在 JVM 内部就会相应地创建一个对应类型的  opp(普通对象指针) 对象。各种 oop 类的共同基类为 oopDesc 类。所以每个object对象都包含markOop

在hotspot虚拟机中,采用ObjectMonitor类来实现monitor。

每个object的对象里markOop->monitor() 里可以保存ObjectMonitor的对象。所以这也是为什么每个对象都能成为锁的原因之一。

oop.hpp

markOop.hpp

class oopDesc {//顶层基类
  friend class VMStructs;
 private:
  volatile markOop  _mark;//每个对象的mark头
  union _metadata {
    Klass*      _klass;
    narrowKlass _compressed_klass;
  } _metadata;
}

Monitor数据结构

openJDK objectMonitor.hpp

_count:线程获取管程锁的次数;

_recursions:管程锁的重入次数;

_owner:指向持有ObjectMonitor对象的线程

_waiters:处于等待状态的线程数;

_WaitSet:处于等待状态的线程队列(双向链表);

_EntryList:管程的入口线程队列(双向链表);

_cxq:线程竞争管程锁时的队列(单向链表);

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter)

其中,_EntryList就相当于Mesa管程模型中的队列e,而_WaitSet就相当于其中的队列a.q或者b.q。Object.wait()/notify()/notifyAll()三个方法也会直接映射到ObjectMonitor的同名方法。由此也可见,ObjectMonitor只有一个隐式的条件变量,及与其相关的线程队列。_EntryList、_WaitSet和_owner之间的关系如下图所示:

Synchronized的原理及其实现

Monitor竞争过程

 

  • 刚开始 Monitor 中 Owner 为 null。
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner。
  • 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入EntryList BLOCKED状态。
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时候是非公平的。
  • WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程。

wait和notify的原理

调用wait方法,首先会获取监视器锁,获得成功以后,会让当前线程进入等待状态进入等待队列并且释放锁;然后当其他线程调用notify或者notifyall以后,会通知等待线程可以醒了,而执行完notify方法以后,并不会立马唤醒线程,原因是当前的线程仍然持有这把锁,处于等待状态的线程无法获得锁。必须要等到当前的线程执行完按monitorexit指令以后,也就是锁被释放以后,处于等待队列中的线程就可以开始竞争锁了。

 

wait()/notify()/notifyAll()为什么存在于Object顶级对象中?

Object类中的wait()/notify()/notifyAll()都是native方法,其真正实现是C++中的objectMonitor.cpp

。它们都是monitor的函数,而每个java对象都与一个monitor关联,所以wait()/notify()/notifyAll()存在于Object对象中。

wait和notify为什么需要在synchronized里面?

wait方法的语义有两个,一个是释放当前的对象锁、另一个是使得当前线程进入阻塞队列, 而这些操作都和监视器是相关的,所以wait必须要获得一个监视器锁。

而对于notify来说也是一样,它是唤醒一个线程,既然要去唤醒,首先得知道它在哪里?所以就必须要找到这个对象获取到这个对象的锁,然后到这个对象的等待队列中去唤醒一个线程。

文献

synchronized原理

管程

synchronized底层实现monitor详解

说一说管程(Monitor)及其在Java synchronized机制中的体现

  • 0
    点赞
  • 0
    收藏
  • 打赏
    打赏
  • 0
    评论

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

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:大白 设计师:CSDN官方博客 返回首页
评论

打赏作者

城南孔乙己

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值