Synchronized原理
前言
本文针对java互斥锁synchronized关键字的底层原理进行描述。
为什么需要锁?
锁存在于并发的场景,并发场景存在什么问题? 前提是并发场景存在一个多线程争夺的资源,该资源可以同时被多个线程获取到,但是这样会产生问题。比如 这个资源是一个数字1,现在多个线程的任务都是将这个数字累加。A线程先拿到数字1,这时候累加未结束,B线程又去拿到了A,那么B运行之后数字不是3而是2。这就产生了问题。那么怎么解决? 一 让这个数字的变化再所以线程中共享,也就是可见性,二、保证线程A计算之后,线程B再能拿到数字,也就是顺序性。synchronized可以实现可见性和顺序性两种能力。
说一说可见性设计的计算机组件,cpu 寄存器 和缓存。寄存器离cpu最近,然后是缓存。寄存器可以和cpu超高速沟通,这样寄存器中就可以存一部分数据,如果cpu想复用该数据就可以直接从寄存器中拿。如果寄存器中没有,那么就去缓存中拿,如果存在那么就缓存中的数据。如果没有,那么只能从内存拿,内存是离cpu最远的,速度也是最低的。所以为了提高效率产生了 寄存器、缓存(1,2,3,3级缓存是共享的),虽然这样提高了效率也带来了问题,就是我们上面说的可见性,如果寄存器或者缓存存取的数字不是最新的,那么就发生了问题。那么计算机为了克服这一问题,就研究了一些列缓存一致性协议,synchronized也是其中的一种,volatile也是。
使用方式
- 修饰实例方法,实例加锁,进入该方法需要获取该实例的锁。
- 修饰代码块,实例加锁,进入代码块需要获取该实例的锁。
- 修饰静态方法,类锁,进入静态方法需要获取该类对象的锁。
synchronized底层原理
关键字synchronized再经过jvm编译之后会变成monitorenter和monitorexit字节码指令,两个指令都是monitor开头,也就说明了锁机制是通过对象的monitor对象实现的。(如果是锁方法,则是通过读取常量池中方法的ACC_SYNCHRONIZED标志来实现的)。那么monitor是什么呢?这要从JAVA对象头来说起,那我们就从整个java对象的结构说起把。
Jvm中,对象再内存中是由三部分组成,分别是:对象头,实际数据,对齐填充(可以为0)。
-
对象头:分为两个部分,第一部分是我们所谓的markword,也就是标记世界,用于存储对象自身的运行时数据,如hashCode、GC分代年龄、锁状态标识、线程持有的锁、偏向锁线程ID、偏向时间戳等,这部分数据的长度随着操作系统位数的不同随之变化,32位和64位中大小分别为32bit和64bit. 第二部分,kclass指针或者叫做class metadate Addreess , 它是指向方法区中该对象属于的类元数据(Klass)的指针(这个klass不等于堆上的Kclass对象,其实堆上是这个Klass的实例对象叫做instanceKlassOop,给获取类数据一个接口,反射就是根据这个对象来实现的)。数组对象还存在一个length字段来记录这个数组的长度。
-
实际数据:实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
-
对齐填充:第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
方法区存了什么数据?
类型信息
对每个加载的类型,jvm必须在方法区中存储以下类型信息:
一 这个类型的完整有效名
二 这个类型直接父类的完整有效名(除非这个类型是interface或是
java.lang.Object,两种情况下都没有父类)
三 这个类型的修饰符(public,abstract, final的某个子集)
四 这个类型直接接口的一个有序列表
类型名称在java类文件和jvm中都以完整有效名出现。在java源代码中,完整有效名由类的所属包名称加一个”.”,再加上类名
组成。例如,类Object的所属包为java.lang,那它的完整名称为java.lang.Object,但在类文件里,所有的”.”都被
斜杠“/”代替,就成为java/lang/Object。完整有效名在方法区中的表示根据不同的实现而不同。
除了以上的基本信息外,jvm还要为每个类型保存以下信息:
类型的常量池( constant pool):包括实际的常量(string,integer, 和floating point常量)和对类型,域和方法的符号引用。符号引用=字符串,直接引用= 地址指针(也就是native指针)
域(Field)信息: 字段名、字段类型和字段修饰符(public、private等等)
方法(Method)信息:
除了常量外的所有静态(static)变量
Class对象的引用:instanceKclassOop的引用,因为对象是再堆上
方法表:该类中方法的直接引用,符号引用找到这里。
Mark Word详解
Mark Word部分是实现synchronized的基础,我们来刨析一下它的具体结构。如下表所示:
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象HashCode | 对象分代年龄 | 0 | 01 |
锁状态标识该对象目前处于哪一种锁,它实际是有2bit的锁标志位来决定的。也就是最后两位01,而其倒数第三位是0是无锁状态,这时候线程来了不需要排队。
由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:
其中轻量级锁和偏向锁是Java 6 对 synchronized 锁进行优化后新增加的,稍后我们会简要分析。这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; //持有该锁的线程
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
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(锁)。如下图所示
线程的生命周期存在5个状态,start、running、waiting、blocking和dead
对于一个synchronized修饰的方法(代码块)来说:
- 当多个线程同时访问该方法,那么这些线程会先被放进_EntryList队列,此时线程处于blocking状态
- 当一个线程获取到了实例对象的监视器(monitor)锁,那么就可以进入running状态,执行方法,此时,ObjectMonitor对象的_owner指向当前线程,_count加1表示当前对象锁被一个线程获取
- 当running状态的线程调用wait()方法,那么当前线程释放monitor对象,进入waiting状态,ObjectMonitor对象的_owner变为null,_count减1,同时线程进入_WaitSet队列,直到有线程调用notify()方法唤醒该线程,则该线程重新获取monitor对象进入_Owner区
- 如果当前线程执行完毕,那么也释放monitor对象,进入waiting状态,ObjectMonitor对象的_owner变为null,_count减1
由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因(关于这点稍后还会进行分析),ok~,有了上述知识基础后,下面我们将进一步分析synchronized在字节码层面的具体语义实现。
synchronized 代码块的底层原理
这里不上字节码了直接说结论,字节码中会再加锁的代码块前后自动生成monitorenter 和两个monitorexit
3: monitorenter //进入同步方法
//..........省略其他
15: monitorexit //退出同步方法
16: goto 24
//省略其他.......
21: monitorexit //退出同步方法
从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。
synchronized 方法的底层原理
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。
存在什么问题?
同时我们还必须注意到的是在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单了解一下Java官方在JVM层面对synchronized锁的优化。也就是我们常说的锁升级。
锁升级
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
无锁状态
就是对象的锁标志位为01,并且偏向锁标志位为0。 这样多线程可以随意共享该对象。
偏向锁
产生原因:因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。
偏向锁升级过程:
当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
偏向锁的取消:
偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,如果不想有这个延迟,那么可以使用-XX:BiasedLockingStartUpDelay=0;
如果不想要偏向锁,那么可以通过-XX:-UseBiasedLocking = false来设置;
轻量级锁
产生原因:轻量级锁适用于两条或者多条线程,但是这几条线程的工作周期很凑巧,比如 A、B,需要满足A的工作时间,小于B的获取资源时间。也就A、B虽然竞争获取一个资源,但是不存在重叠,也可能存在几个B自旋的重叠忽略不计。
加锁过程(从偏向锁或无锁状态):
- A线程再执行到同步代码块之前,JVM回在当前代码块所在的方法栈帧中创建一个Lock Record的区域用来存储锁记录空间,这个就是所谓的displaced Mark Word(拿到这个是为了之后的CAS获取锁)。之后,线程获得了最初的mark word信息,用cas操作来比较获取到的和cas时是否一致,如果一致说明不存在竞争线程,或者竞争线程还没来及的替换mark word。
- 如果上一步的cas成功,说明线程A拿到了锁。进入步骤3,失败进入步骤4.
- cas成功说明A拿到了锁,A将栈帧的displaced word 锁标识改为00,此时对象头mark word 存储的是栈帧中的displaced mark word的指针。
- 如果CAS替换失败,说明锁被线程B获取了。那么这时候A需要自旋来等待锁,因为虚拟机作者任务轻量级锁的场景线程会很快完成任务,从而释放锁。自旋一定次数(10次?)或者这时候来了线程C,那么就会膨胀为重量级锁。膨胀之后,线程B去修改mark word的锁标识,并将该对象的monitor指针赋值到mark word中,然后阻塞自己。等待线程A结束后。A结束后,cas释放锁,发现失败。它也会释放锁,然后去唤醒阻塞的线程B。
解锁过程:
轻量级锁解锁时,会使用CAS将之前复制在栈桢中的 Displaced Mard Word 替换回 Mark Word 中。如果替换成功,则说明整个过程都成功执行,期间没有其他线程访问同步代码块。
但如果替换失败了,表示当前线程在执行同步代码块期间,有其他线程也在访问,当前锁资源是存在竞争的,那么锁将会膨胀成重量级锁
轻量级锁转换成重量级锁的锁标识是由获取锁失败的其它线程来完成的。
优缺点:
轻量级锁涉及到一个自旋的问题,而自旋操作是会消耗CPU资源的。为了避免无用的自旋,当锁资源存在线程竞争时,偏向锁就会升级为重量级锁来避免其他线程无用的自旋操作。所以这就引出了偏向锁的一个缺点:如果始终无法获得锁资源,线程就会自旋消耗CPU资源。
但是偏向锁相对于重量级锁的一个有点就是:因为线程在竞争资源时采用的是自旋,而不是阻塞,也就避免了线程的切换带来的时间消耗,提高了程序的响应速度。
锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。比如,有一些线程安全的对象操作函数加锁。