JUC并发编程系列详解篇十(Synchronized底层原理分析)

synchronized 底层语义原理

synchronized 锁机制在 Java 虚拟机中的同步是基于进入和退出监视器锁对象 monitor 实现的(无论是显示同步还是隐式同步都是如此),每个对象的对象头都关联着一个 monitor 对象,当一个 monitor 被某个线程持有后,它便处于锁定状态。在 HotSpot 虚拟机中,monitor 是由 ObjectMonitor 实现的,每个等待锁的线程都会被封装成 ObjectWaiter 对象,ObjectMonitor 中有两个集合,_WaitSet 和 _EntryList,用来保存 ObjectWaiter 对象列表 ,owner 区域指向持有 ObjectMonitor 对象的线程。当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合尝试获取 moniter,当线程获取到对象的 monitor 后进入 _Owner 区域并把 _owner 变量设置为当前线程,同时 monitor 中的计数器 count 加1;若线程调用 wait() 方法,将释放当前持有的 monitor,count自减1,owner 变量恢复为 null,同时该线程进入 _WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor 并复位变量的值,以便其他线程获取 monitor。

_EntryList:存储处于 Blocked 状态的 ObjectWaiter 对象列表。
_WaitSet:存储处于 wait 状态的 ObjectWaiter 对象列表。

Synchronized原理分析

首先来看一个例子,深入JVM看字节码,创建如下的代码:

public class SynchTestDemo {
    public static void printlnTest(){
        synchronized (SynchTestDemo.class){
            System.out.println("hhhhh");
        }
    }
    public static void main(String[] args) {
        printlnTest();
    }
}

使用javac命令进行编译生成.class文件:

javac SynchTestDemo.java

使用javap命令反编译查看.class文件的信息

javap -verbose SynchTestDemo.class

执行完毕可以得到如下信息,如图所示:
在这里插入图片描述
请红色方框里的monitorentermonitorexit。其中monitorenter指令执行;了一次,而monitorexit指令实际上是执行了两次,第一次是正常情况下释放锁,第二次为发生异常情况时释放锁,这样做的目的在于保证线程不死锁。

monitor指令

Monitorenter和Monitorexit指令,会让对象在执行,使其锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得。

monitorenter指令

在JVM规范中有提到对monitorenter指令的描述: 任何一个对象都有一个monitor与其相关联,当且有一个monitor被持有后,它将处于锁定的状态,其他线程无法来获取该monitor。当JVM执行某个线程的某个方法内部的monitorenter时,他会尝试去获取当前对应的monitor的所有权。

一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:

  • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
  • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加。
  • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

monitorexit指令

在JVM规范中同样有提到对monitorenter指令的描述:能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程;执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。

字节码分析

synchronized关键字被编译成字节码后会被翻译成monitorentermonitorexit两条指令分别在同步块逻辑代码的起始位置与结束位置,如下图所示:
在这里插入图片描述
每个同步对象都有一个自己的Monitor(监视器锁),加锁过程如下图所示:
在这里插入图片描述
synchronized的实现原理:synchronized的底层实际是通过一个monitor对象来实现的,其实wait/notify方法也是依赖于monitor对象来实现的,这就是为什么只有在同步代码块或者方法中才能调用该方法,否则就会抛出出java.lang.IllegalMonitorStateException的异常的原因。再看一个例子:

public class SynchTestDemo {

    public synchronized void printlnTest(){
        System.out.println("hhhhh");
    }
    public static void main(String[] args) {
        new SynchTestDemo().printlnTest();
    }
}

根据上面的方法编译运行然后反编译获取字节码文件,可得下面的内容:
在这里插入图片描述
从字节码反编译的可以看出,同步方法并没有通过指令monitorenter和monitorexit来实现的,但是相对于普通方法来说,其常量池多了了 ACC_SYNCHRONIZED 标示符。JVM实际就是根据该标识符来实现方法的同步的。

当方法被调用时,会检查ACC_SYNCHRONIZED标志是否被设置,若被设置,线程会先获取monitor,获取成功才能执行方法体,方法执行完成后会再次释放monitor。在方法执行期间,其他线程都无法获得同一个monitor对象。

其实两种同步方式从本质上看是没有区别的,两个指令的执行都是JVM调用操作系统的互斥原语mutex来实现的,被阻塞的线程会被挂起、等待重新调度,会导致线程在“用户态”和“内核态”进行切换,就会对性能有很大的影响。

monitor详解

monitor通常被描述为一个对象,可以将其理解为一个同步工具,或者可以理解为一种同步机制。所有的Java对象自打new出来的时候就自带了一把锁,就是monitor锁,也就是对象锁,存在于对象头(Mark Word),锁标识位为10,指针指向的是monitor对象起始地址。

在Java虚拟机(HotSpot)中,Monitor是由其底层实际是由C++对象ObjectMonitor实现的:

ObjectMonitor() {
    _header = NULL;
    _count = 0;            //用来记录该线程获取锁的次数
    _waiters = 0,
    _recursions = 0;         // 线程的重入次数 
    _object = NULL;         // 存储该monitor的对象
    _owner = NULL;           // 标识拥有该monitor的线程
    _WaitSet = NULL;         // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock = 0 ;
    _Responsible = NULL;
    _succ = NULL;
    _cxq = NULL;           // 多线程竞争锁时的单向队列
    FreeNext = NULL;
    _EntryList = NULL;         // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq = 0;
    _SpinClock = 0;
    OwnerIsThread = 0;
}
  1. _owner:初始时为NULL。当有线程占有该monitor时,owner标记为该线程的唯一标识。当线程释放monitor时,owner又恢复为NULL。owner是一个临界资源,JVM是通过CAS操作来保证其线程安全的;
  2. _cxq:竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)。cxq是一个临界资源,JVM通过CAS原子指令来修改cxq队列。修改前cxq的旧值填入了node的next字段,_cxq指向新值(新线程)。因此_cxq是一个后进先出的stack(栈);
  3. _EntryList:_cxq队列中有资格成为候选资源的线程会被移动到该队列中;
  4. _WaitSet:因为调用wait方法而被阻塞的线程会被放在该队列中。

举个例子具体分析一下_cxq队列与_EntryList队列的区别:

public void print() throws InterruptedException {
    synchronized (obj) {
        System.out.println("Hello World");
        //obj.wait();
    }
 }

若多线程执行上面这段代码,刚开始t1线程第一次进同步代码块,能够获得锁,之后马上又有一个t2线程也准备执行这段代码,t2线程是没有抢到锁的,t2这个线程就会进入_cxq这个队列进行等待,此时又有一个线程t3准备执行这段代码,t3当然也会没有抢到这个锁,那么t3也就会进入_cxq进行等待。

接着,t1线程执行完同步代码块把锁释放了,这个时候锁是有可能被t1、t2、t3中的任何一个线程抢到的。

假如此时又被t1线程给抢到了,那么上次已经进入_cxq这个队列进行等待的线程t2、t3就会进入_EntryList进行等待,若此时来了个t4线程,t4线程没有抢到锁资源后,还是会先进入_cxq进行等待。

具体分析一下_WaitSet队列与_EntryList队列:(图片来源微信公众号 - 得物技术:精选文章|深入理解synchronzied底层原理 )
在这里插入图片描述
每个object的对象里 markOop->monitor() 里可以保存ObjectMonitor的对象。ObjectWaiter 对象里存放thread(线程对象) 和unpark的线程, 每一个等待锁的线程都会有一个ObjectWaiter对象,而objectwaiter是个双向链表结构的对象。

结合上图monitor的结构图可以分析出,当线程的拥有者执行完线程后,会释放锁,此时有可能是阻塞状态的线程去抢到锁,也有可能是处于等待状态的线程被唤醒抢到了锁。在JVM中每个等待锁的线程都会被封装成ObjectMonitor对象,_owner标识拥有该monitor的线程,而_EntryList和_WaitSet就是用来保存ObjectWaiter对象列表的,_EntryList和_WaitSet最大的区别在于前者是用来存放等待锁block状态的线程,后者是用来存放处于wait状态的线程。

当多个线程同时访问同一段代码时:

  • 首先会进入_EntryList集合每当线程获取到对象的monitor后,会将monitor中的_ower变成设置为当前线程,同时会将monitor中的计数器_count加1。
  • 若线程调用wait()方法时,将释放当前持有的monitor对象,将_ower设置为null,_count减1,同时该线程进入_WaitSet中等待被唤醒。
  • 若当前线程执行完毕,也将释放monitor锁,并将_count值复原,以便于其他线程获取锁。

monitor对象存在于每个Java对象的对象头(Mark Word)中,所以Java中任何对象都可以作为锁,由于notify/notifyAll/wait等方法会使用到monitor锁对象,所以必须在同步代码块中使用。

多线程情况下,线程需要同时访问临界资源,监视器monitor可以确保共享数据在同一时刻只会有一个线程在访问。

可重入原理:加锁次数计数器

可重入:(来源于维基百科)若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。

可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。

public class SynchronizedDemo {

    public static void main(String[] args) {
        SynchronizedDemo demo =  new SynchronizedDemo();
        demo.method1();
    }

    private synchronized void method1() {
        System.out.println(Thread.currentThread().getId() + ": method1()");
        method2();
    }

    private synchronized void method2() {
        System.out.println(Thread.currentThread().getId()+ ": method2()");
        method3();
    }

    private synchronized void method3() {
        System.out.println(Thread.currentThread().getId()+ ": method3()");
    }
}

执行monitorenter获取锁

  • (monitor计数器=0,可获取锁)
  • 执行method1()方法,monitor计数器+1 -> 1 (获取到锁)
  • 执行method2()方法,monitor计数器+1 -> 2
  • 执行method3()方法,monitor计数器+1 -> 3

执行monitorexit命令

  • method3()方法执行完,monitor计数器-1 -> 2
  • method2()方法执行完,monitor计数器-1 -> 1
  • method2()方法执行完,monitor计数器-1 -> 0 (释放了锁)
  • (monitor计数器=0,锁被释放了)

参考文章

  • https://www.pdai.tech/md/java/thread/java-thread-x-key-synchronized.html
  • 微信公众号(得物技术) :精选文章|深入理解synchronzied底层原理
  • https://blog.csdn.net/a745233700/article/details/119923661
  • 《深入理解Java虚拟机》+《Java并发编程的艺术》
  • https://juejin.im/post/5ae6dc04f265da0ba351d3ff
  • https://www.cnblogs.com/javaminer/p/3889023.html
  • https://www.jianshu.com/p/dab7745c0954
  • https://www.cnblogs.com/wuchaodzxx/p/6867546.html
  • https://www.cnblogs.com/xyabk/p/10901291.html
  • https://www.jianshu.com/p/64240319ed60
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值