Synchronized的底层实现原理(原理解析,面试必备)

synchronized

一. synchronized解读

Synchronized的底层实现原理(原理解析,面试必备)_第1张图片

1.1 简单描述

synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized 翻译为中文的意思是同步,也称之为同步锁
synchronized的作用是保证在同一时刻, 被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。

1.2 特性

  • 原子性:synchronized保证语句块内操作是原子的
    同步方法
    ACC_SYNCHRONIZED 这是一个同步标识,对应的 16 进制值是 0x0020
    这 10 个线程进入这个方法时,都会判断是否有此标识,然后开始竞争 Monitor
    对象。

    同步代码
     monitorenter,在判断拥有同步标识 ACC_SYNCHRONIZED 抢先进入此方法
    的线程会优先拥有 Monitor 的 owner ,此时计数器 +1。
     monitorexit,当执行完退出后,计数器 -1,归 0 后被其他进入的线程获得。

  • 可见性:synchronized保证可见性(通过“在执行unlock之前,必须先把此变量同步回主内存”实现)
    那么为什么添加 synchronized 也能保证变量的可见性呢?
    因为:

    1. 线程解锁前,必须把共享变量的最新值刷新到主内存中。
    2. 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存
      中重新读取最新的值。
    3. volatile 的可见性都是通过内存屏障(Memnory Barrier)来实现的。
    4. synchronized 靠操作系统内核的Mutex Lock(互斥锁)实现,相当于 JMM 中的 lock、unlock。退出代码块时刷新变量到主内存。
  • 有序性:synchronized保证有序性(通过“一个变量在同一时刻只允许一条线程对其进行lock操作”)
    as-if-serial,保证不管编译器和处理器为了性能优化会如何进行指令重排序,
    都需要保证单线程下的运行结果的正确性。也就是常说的:如果在本线程内观察,
    所有的操作都是有序的;如果在一个线程观察另一个线程,所有的操作都是无序
    的。
    这里有一段双重检验锁(Double-checked Locking)的经典案例:

    public class SingletonDoubleCheckLock {
    
    private SingletonDoubleCheckLock(){}
    
    private volatile static SingletonDoubleCheckLock instance;
    
    public SingletonDoubleCheckLock getInstance(){
        if (null == instance){
            synchronized (SingletonDoubleCheckLock.class){
                if (null == instance){
                    instance = new SingletonDoubleCheckLock();
                }
            }
        }
        return instance;
    }
    
    

    为什么,synchronized 也有可见性的特点,还需要 volatile 关键字?
    因为,synchronized 的有序性,不是 volatile 的防止指令重排序。那如果不加 volatile 关键字可能导致的结果,就是第一个线程在初始化初始化对象,设置 instance 指向内存地址时。第二个线程进入时,有指令重排。在判断 if (instance == null) 时就会有出错的可能,因为这会可能 instance 可能还没有初始化成功。

  • 重入性:synchronized 是可重入锁,也就是说,允许一个线程二次请求自己持有对象锁
    的临界资源,这种情况称为可重入锁。
    那么我们就写一个例子,来证明这样的情况。

    public class A {
        public synchronized void doA(){
            System.out.println("父类方法:A.doA() ThreadId:" + Thread.currentThread().getId());
        }
    }
    
    public class RetryTest extends A {
        public static void main(String[] args) {
            RetryTest retryTest = new RetryTest();
            retryTest.doA();
        }
    
        public synchronized void doA(){
            System.out.println("子类方法:RetryTest.doA() ThreadId:" + Thread.currentThread().getId());
            doB();
        }
    
        private synchronized void doB(){
            super.doA();
            System.out.println("子类方法:RetryTest.doB() ThreadId:" + Thread.currentThread().getId());
        }
    }
    

    输出结果:
    Synchronized的底层实现原理(原理解析,面试必备)_第2张图片
    这段单例代码是递归调用含有 synchronized 锁的方法,从运行正常的测试结果看,并没有发生死锁。所有可以证明 synchronized 是可重入锁。

    synchronized 锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。
    之所以,是可以重入。是因为 synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线程执行完毕后 -1,直到清零释放锁。

1.3 实现原理

jvm基于进入和退出Monitor对象来实现方法同步和代码块同步

方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。

代码块的同步是利用monitorenter和monitorexit这两个字节码指令。它们分别位于同步代码块的开始和结束位置。当jvm执行到monitorenter指令时,当前线程试图获取monitor对象的所有权,如果未加锁或者已经被当前线程所持有,就把锁的计数器+1;当执行monitorexit指令时,锁计数器-1;当锁计数器为0时,该锁就被释放了。如果获取monitor对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。

这里要注意:

  • synchronized是可重入的,所以不会自己把,自己锁死
  • synchronized锁一旦被一个线程持有,其他试图获取该锁的线程将被阻塞(这也是synchronized最原始的实现,重量级锁的特点)。

关于ACC_SYNCHRONIZED 、monitorenter、monitorexit指令,可以看下面的反编译代码


public class SynchronizedTest {
    public void get(){
        synchronized (this){        // 这个是同步代码块
            System.out.println("你好呀");
        }
    }
    public synchronized void f(){    //这个是同步方法
        System.out.println("Hello world");
    }

    public static void main(String[] args) {

    }

}

可以通过javap -verbose SynchronizedTest 对代码进行反编译,如下:
在这里插入图片描述
反编译之后的结果,如下:
Synchronized的底层实现原理(原理解析,面试必备)_第3张图片
monitorenter:代表 监视器入口,获取锁;
monitorexit:代表监视器出口,释放锁;
monitorexit:第二次monitorexit,代表 发生异常,释放锁;
Synchronized的底层实现原理(原理解析,面试必备)_第4张图片
ACC_SYNCHRONIZED访问标志:当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。

二. synchronized底层实现

synchronized的底层实现是完全依赖JVM虚拟机的,所以谈synchronized的底层实现,就不得不谈数据在JVM内存的存储:Java对象头,以及Monitor对象监视器。

2.1 对象结构

HotSpot 虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、
实例数据(Instance Data)和对齐填充(Padding)。
Synchronized的底层实现原理(原理解析,面试必备)_第5张图片

  • 实例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
  • 对象头:HotSpot虚拟机的对象头分为两部分信息,第一部分用于存储对象自身运行时数据,如哈希码、GC分代年龄等,这部分数据的长度在32位和64位的虚拟机中分别为32位和64位。官方称为Mark Word。另一部分用于存储指向对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分存储数组长度。
    Synchronized的底层实现原理(原理解析,面试必备)_第6张图片
    由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间。

2.2 对象头的组成

先简单介绍下对象头的形式,JVM中对象头的方式有以下两种(以32位JVM为例):

  • 普通对象:
    Synchronized的底层实现原理(原理解析,面试必备)_第7张图片
  • 数组对象:
    Synchronized的底层实现原理(原理解析,面试必备)_第8张图片

2.2.1 Mark Word

这部分主要用来存储对象自身的运行时数据,如hashcode、gc分代年龄等。mark word的位长度为JVM的一个Word大小,也就是说32位JVM的Mark word为32位,64位JVM为64位。
为了让一个字大小存储更多的信息,JVM将字的最低两个位设置为标记位,不同标记位下的Mark Word示意如下:
Synchronized的底层实现原理(原理解析,面试必备)_第9张图片
其中各部分的含义如下:

  • lock:2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个mark word表示的含义不同。
    Synchronized的底层实现原理(原理解析,面试必备)_第10张图片
  • bias_lock:对象是否启动偏向锁标记,只占1个二进制位。为1时表示对象启动偏向锁,为0时表示对象没有偏向锁。
  • age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
  • identity_hashcode:25位的对象标识Hash码,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor中。
  • thread:持有偏向锁的线程ID。
  • epoch:偏向时间戳。
  • ptr_to_lock_record:指向栈中锁记录的指针。
  • ptr_to_heavyweight_monitor:指向monitor对象(也称为管程或监视器锁)的起始地址,每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor对象可以与对象一起创建销毁或当前线程试图获取对象锁时自动生,但当一个monitor被某个线程持有后,它便处于锁定状态。

2.2.2 class pointer

这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。

如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。为了节约内存可以使用选项+UseCompressedOops开启指针压缩,其中,oop即ordinary object pointer普通对象指针。开启该选项后,下列指针将压缩至32位:

  • 每个Class的属性指针(即静态变量)
  • 每个对象的属性指针(即对象变量)
  • 普通对象数组的每个元素指针

当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。

2.2.3 array length

如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位。

2.3 Monitor监视器锁

2.3.1 monitor介绍

在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
Synchronized的底层实现原理(原理解析,面试必备)_第11张图片
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示:
Synchronized的底层实现原理(原理解析,面试必备)_第12张图片

2.3.2 Java对象Object的Monitor机制

Java虚拟机给每个对象和class字节码都设置了一个监听器Monitor,用于检测并发代码的重入,同时在Object类中还提供了notify和wait方法来对线程进行控制。

在java.lang.Object类中有如下代码:

public class Object {

private transient int shadow$monitor;
public final native void notify();
public final native void notifyAll();
public final native void wait() throws InterruptedException;
public final void wait(long millis) throws InterruptedException {
wait(millis, 0);
}
public final native void wait(long millis, int nanos) throws InterruptedException;

}

结合上图来分析Object的Monitor机制。

Monitor可以类比为一个特殊的房间,这个房间中有一些被保护的数据,Monitor保证每次只能有一个线程能进入这个房间进行访问被保护的数据,进入房间即为持有Monitor,退出房间即为释放Monitor。

当一个线程需要访问受保护的数据(即需要获取对象的Monitor)时,它会首先在entry-set入口队列中排队(这里并不是真正的按照排队顺序),如果没有其他线程正在持有对象的Monitor,那么它会和entry-set队列和wait-set队列中的被唤醒的其他线程进行竞争(即通过CPU调度),选出一个线程来获取对象的Monitor,执行受保护的代码段,执行完毕后释放Monitor,如果已经有线程持有对象的Monitor,那么需要等待其释放Monitor后再进行竞争。

再说一下wait-set队列。当一个线程拥有Monitor后,经过某些条件的判断(比如用户取钱发现账户没钱),这个时候需要调用Object的wait方法,线程就释放了Monitor,进入wait-set队列,等待Object的notify方法(比如用户向账户里面存钱)。当该对象调用了notify方法或者notifyAll方法后,wait-set中的线程就会被唤醒,然后在wait-set队列中被唤醒的线程和entry-set队列中的线程一起通过CPU调度来竞争对象的Monitor,最终只有一个线程能获取对象的Monitor。

前面已经分析了Monitor的机制,那么在Java中是如何实现的呢?
即通过synchronized关键字实现线程同步来获取对象的Monitor。
实现方式为:ACC_SYNCHRONIZED和monitorenter/monitorexit 前面已讲过

三. synchronized的锁升级

锁解决了数据的安全性,但是同样带来了性能的下降,hotspot 虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。

所以基于这样一个概率,synchronized 在JDK1.6 之后做了一些优化,为了减少获得锁和释放锁来的性能开销,引入了偏向锁、轻量级锁,锁的状态根据竞争激烈的程度从低到高不断升级。

3.1 无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

3.2 偏向锁

偏向锁是JDK1.6中引用的优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的性能。大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步。

3.3 轻量级锁

轻量级锁也是在JDK1.6中引入的新型锁机制。它不是用来替换重量级锁的,它的本意是在没有多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

3.4 重量级锁

指的是原始的Synchronized的实现,重量级锁的特点:其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程。

参考:Java对象头与monitor
参考:Java对象的Monitor机制
参考:Synchronized的底层实现原理
参考:synchronized底层实现原理及锁优化

你可能感兴趣的:(多线程,jvm,面试,java,jvm)