欢迎来到 安卓源码空间!
安卓源码空间

【Java并发知识总结 | 第五篇】深入理解Synchronized底层原理(Monitor对象、


                                         Synchronized锁优化)



文章目录



参考文章链接:


Synchronized解析——如果你愿意一层一层剥开我的心

深入理解synchronized底层原理,一篇文章就够了!



5.深入理解Synchronized底层原理(Monitor对象、Synchronized锁优化)


5.1Synchronized的特性


  • Synchronized有以下四个特性:原子性、可见性、有序性、可重入性

5.1.1原子性


  • 原子性:指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  • 被synchronized修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放,这中间的过程无法被中断(除了已经废弃的stop()方法),即保证了原子性。

5.1.2可见性


  • 可见性:指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。
  • 一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主存当中,保证资源变量的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待锁的释放。

5.1.3有序性


  • Synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

  • Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。


5.1.4可重入性

  • 当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,
  • 但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁。

5.2Synchronized的用法


  1. Synchronized可以修饰的地方:

    • 修饰静态方法
    • 修饰成员函数
    • 直接定义代码块

    image-20240326150207416


  2. 但是归根结底它上锁的资源只有两类:

    • 一个是 对象
    • 一个是

  3. 普通的成员函数,归该类的实例对象所有,即若需要访问该方法,需要拿到该类的实例对象相应的锁;


  4. 被static修饰的静态方法、静态属性归类所有,即若需要访问该方法,需要拿到该类相应的锁;


 public class Testl { private int i=0; private static int j=0; private final Testl instance = new Test1(); 
                    //对成员函数加锁,必须获得该类的实例对象的锁才能进入同步块 
                       public synchronized void add1(){ i++; } 
                       //对静态方法加锁,必须获得类的锁才能进入同步块  
 public static synchronized void add2(){ i++; } public void method(){  
 // 同步块,执行前必须获得Test1类的锁  
 synchronized(Testl.class){ } //同步块,执行前必须先获得实例对象的锁  
 synchronized(instance){ } } }  

5.3Synchronized的两种同步方式


  1. Synchronized有两种形式上锁,一个是对方法上锁,一个是构造同步代码块。
  2. 他们的底层实现其实都一样,在进入同步代码之前先获取锁,获取到锁之后锁的计数器+1,同步代码执行完,锁的计数器-1,如果获取失败就阻塞式等待锁的释放。
  3. 只是他们在同步块识别方式上有所不一样,从class字节码文件可以表现出来
    • 同步方法:通过方法flags标志,
    • 同步代码块:monitorenter和monitorexit指令操作。

4.3.1同步代码块


image-20240326150940681


反编译,可得如下图:


image-20240326150956120


  1. 由图可得,添加了synchronized关键字的代码块,多了两个指令**monitorenter、monitorexit**。即JVM使用monitorenter和monitorexit两个指令实现同步。
  2. 同步块是由monitorenter指令进入,然后monitorexit释放锁
  3. 在执行monitorenter之前需要尝试获取锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器加1。当执行monitorexit指令时,锁的计数器也会减1。当获取锁失败时会被阻塞,一直等待锁被释放。
  4. 第二个monitorexit是来处理异常的,仔细看反编译的字节码,正常情况下第一个monitorexit之后会执行goto指令,而该指令转向的就是23行的return,也就是说正常情况下只会执行第一个monitorexit释放锁,然后返回。而如果在执行中发生了异常,第二个monitorexit就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。

5.3.2同步方法


image-20240326150416061


反编译,可得如下图:


image-20240326150433433


由图可得,添加了synchronized关键字的方法,多了ACC_SYNCHRONIZED标记。即JVM通过在方法访问标识符(flags)中加入ACC_SYNCHRONIZED来实现同步功能。


5.4Synchronized的底层实现


5.4.1 monitorenter、monitorexit指令和ACC_SYNCHRONIZED标志


(1)monitorenter指令


  1. 每个对象都与一个monitor 相关联。当且仅当拥有所有者时(被拥有),monitor才会被锁定。
  2. 执行到monitorenter指令的线程,会尝试去获得对应的monitor,如下:
    • 每个对象维护着一个记录着被锁次数的计数器, 对象未被锁定时,该计数器为0。线程进入monitor(执行monitorenter指令)时,会把计数器设置为1
    • 当同一个线程再次获得该对象的锁的时候,计数器再次自增.
  3. 当其他线程想获得该monitor的时候,就会阻塞,直到计数器为0才能成功。

image-20240326152514500


(2)monitorexit指令


  1. monitor的拥有者线程才能执行 monitorexit指令。
  2. 线程执行monitorexit指令,就会让monitor的计数器减一。
  3. 如果计数器为0,表明该线程不再拥有monitor。其他线程就允许尝试去获得该monitor了。

image-20240326152618288


(3)ACC_SYNCHRONIZED标志


  1. 方法级别的同步是隐式的,作为方法调用的一部分。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。
  2. 当调用一个设置了ACC_SYNCHRONIZED标志的方法,执行线程==需要先获得monitor锁==,然后开始执行方法,方法执行之后再释放monitor锁,当方法不管是正常return还是抛出异常都会释放对应的monitor锁。
  3. 在这期间,如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。
  4. 如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。

image-20240326152800585


(4)总结


  1. 同步代码块是通过monitorenter和monitorexit来实现,当线程执行到monitorenter的时候要先获得monitor锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。
  2. 同步方法是通过中设置ACC_SYNCHRONIZED标志来实现,当线程执行有ACC_SYNCHRONI标志的方法,需要获得monitor锁。
  3. 每个对象维护一个加锁计数器,为0表示可以被其他线程获得锁,不为0时,只有当前锁的线程才能再次获得锁。
  4. 同步方法和同步代码块底层都是通过monitor来实现同步的。
  5. 每个对象都与一个monitor相关联,线程可以占有或者释放monitor。

5.4.2 monitor监视器


  1. 一种同步工具,或者说是同步机制,它通常被描述成一个对象。
  2. 操作系统的管程是概念原理,ObjectMonitor是它的原理实现。

img


(1)操作系统的管程


  1. 管程 (英语:Monitors,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。
  2. 这些共享资源一般是硬件设备或一群变量。管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。
  3. 与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计。
  4. 管程提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。

(2)ObjectMonitor


在Java虚拟机(HotSpot)中,Monitor(管程)是由ObjectMonitor实现的,其主要数据结构如下:


image-20240326154435898


ObjectMonitor中几个关键字段的含义如图所示:


img


(3)Java Monitor的工作机理


img


  1. 想要获取monitor的线程,首先会进入_EntryList队列。
  2. 当某个线程获取到对象的monitor后,进入Owner区域,设置为当前线程,同时计数器_count加1。_
  3. 如果线程调用了wait()方法,则会进入WaitSet队列。它会释放monitor锁,即将owner赋值为null,count自减1,进入WaitSet队列阻塞等待。
  4. 如果其他线程调用 notify() / notifyAll() ,会唤醒WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入_Owner区域。
  5. 同步方法执行完毕了,线程退出临界区,会将monitor的owner设为null,并释放监视锁。

5.4.3 Java对象头与monitor关联


(1)Java对象头


在JVM中,对象是分成三部分存在的:对象头、实例数据、对其填充。


img


  1. 实例数据:对象真正存储的有效信息,存放类的属性数据信息,包括父类的属性信息;
  2. 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
  3. 对象头:Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。
  4. 对象头,是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。
    • 对象头主要结构是由Mark Word和Class Metadata Address组成
    • 其中Mark Word存储对象的hashCode、锁信息或分代年龄或GC标志等信息
    • Class Metadata Address是类型指针指向对象的类元数据,JVM通过该指针确定该对象是哪个类的实例

(2)Mark Word


  1. Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
  2. 在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下
    • 那么Mark Word的32bit空间里的25位用于存储对象哈希;
    • 4bit用于存储对象分代年龄;
    • 2bit用于存储锁标志位
    • 1bit固定为0,表示非偏向锁。其他状态如下图所示:

  3. 锁标志位(2bit):
    • 01:该对象为无锁状态
    • 00:该对象为轻量级锁,指向栈中锁记录的指针
    • 10:重量级锁,指向互斥量的指针

img


(2)对象头和monitor关联


img


  • 对象怎么和monitor实现关联
  1. 对象里有对象头
  2. 对象头里面有Mark Word
  3. Mark Word指针指向了monitor

5.5Synchronized的优化


  1. JDK6的时候,新增了两个锁状态(偏向锁、轻量级锁),通过锁消除、锁粗化、自旋锁等方法使用各种场景,给synchronized性能带来了很大的提升。

5.5.1锁膨胀


上面讲到锁有四种状态,并且会因实际情况进行膨胀升级,其膨胀方向是:无锁——>偏向锁——>轻量级锁——>重量级锁,并且膨胀方向不可逆。


5.5.2偏向锁


  1. 作用:减少统一线程获取锁的代价。在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时就是偏向锁。
  2. 核心思想:让同一个线程一直拥有同一个锁,直到出现竞争,才去释放锁
  3. 举例:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查 Mark Word 的锁标记位为偏向锁以及当前线程ID等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作。

5.5.3轻量级锁


  1. 轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。
  2. 注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。
  3. 使用轻量级锁,那些等待竞争锁的线程不需要切 换到阻塞状态,只需等一等(自旋),等持有锁 的线程释放锁,即可获取锁

5.5.4重量级锁


重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。

重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。


image-20240326161403407


5.5.5锁消除


消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。比如下面代码的method1和method2的执行效率是一样的,因为object锁是私有变量,不存在所得竞争关系。


img


5.5.6锁粗化


锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。比如下面method3经过锁粗化优化之后就和method4执行效率一样了。


img


5.5.7自旋锁和自适应自旋锁


轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

自旋锁:许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,通过让线程执行循环等待锁的释放,不让出CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放CPU,会带来许多的性能开销。


自适应自旋锁:这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。


5.6常见面试题


5.6.1Synchronized和Lock两者区别?


Synchronized Lock
形态不同 java关键字、jvm层次 接口
锁的释放不同 1.执行完同步代码,自动释放锁
2.发生异常,jvm释放锁
1.手动释放锁 unlock()
2.在finally里必须释放,不然会死锁
锁类型不同 可重入、非公平 可重入、可公平(非公平)
是否可以尝试获取锁 不可以 可以,tryLock()
粒度


5.6.2Synchronized和ReentrantLock两者区别?


Synchronized ReentrantLock
锁类型不同 非公平锁 非公平锁、公平锁
锁的释放不同 1.执行完同步代码,自动释放锁
2.发生异常,jvm释放锁
手动释放锁
是否可以尝试获取锁 不可以 可以,tryLock()
是否可以超时获取锁 不支持 可以,tryLock(time)
是否可响应中断 不支持,不可响应线程的interrupt信号 支持,lockInterruptibly()
性能 较差 比Synchronized优20%




copyright@ 2020-2028  安卓源码空间网版权所有   

备案号:豫ICP备2023034476号-1号