synchronized 加锁过程
简介
synchronized是一种块结构(Block Structured)的同步方法,关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit两个字节码指令。
两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指明了对象参数,就以这个对象的引用作为reference;如果没有明确指定,那就根据synchronized修饰的方法类型(如实例方法或者类方法),来决定是否取代码所在的对象的实例还是取类型对应的Class对象来作为线程要持有的锁。
在Java中,synchronized
有两种使用形式,同步方法和同步代码块。代码如下:
1 | public class SynchronizedTest { |
反编译以上代码,结果如下(部分无用信息过滤掉了):
1 | public synchronized void doSth(); |
反编译后,我们可以看到Java编译器为我们生成的字节码。在对于doSth
和doSth1
的处理上稍有不同。也就是说。JVM对于同步方法和同步代码块的处理方式不同。
对于同步方法,JVM采用ACC_SYNCHRONIZED
标记符来实现同步。 对于同步代码块。JVM采用monitorenter
、monitorexit
两个指令来实现同步。
可以把执行monitorenter
指令理解为加锁,执行monitorexit
理解为释放锁。 每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter
)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit
指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。
作用范围
静态方法上的锁
静态方法是属于“类”,不属于某个实例,是所有对象实例所共享的方法。也就是说如果在静态方法上加入synchronized,那么它获取的就是这个类的锁,锁住的就是这个类。类锁是所有线程共享的锁,所以同一时刻,只能有一个线程使用加了锁的方法或方法体,不管是不是同一个实例。
实例方法(普通方法)上的锁
实例方法并不是类所独有的,每个对象实例独立拥有它,它并不被对象实例所共享。这也比较能推出,在实例方法上加入synchronized,锁住的就是这个对象实例。使用对象锁的情况,只有使用同一实例的线程才会受锁的影响,多个实例调用同一方法也不会受影响。
Monitor
介绍
Monitor 可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个 Java 对象就有一把看不见的锁,称为内部锁或者 Monitor 锁。
Monitor 是线程私有的数据结构,每一个线程都有一个可用 monitor record 列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个 monitor 关联,同时 monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为重量级锁。
数据结构
在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现的,其主要数据结构如下:
1 | ObjectMonitor() { |
ObjectMonitor中有几个关键属性:
- _owner:指向持有ObjectMonitor对象的线程
- _WaitSet:存放处于wait状态的线程队列
- _EntryList:存放处于等待锁block状态的线程队列
- _recursions:锁的重入次数
- _count:用来记录该线程获取锁的次数
当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁。
若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
重量级操作?
事实上,只有在JDK1.6之前,synchronized
的实现才会直接调用ObjectMonitor的enter
和exit
,这种锁被称之为重量级锁。为什么说这种方式操作锁很重呢?
Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到核心态,因此状态转换需要花费很多的处理器时间,对于代码简单的同步块(如被synchronized修饰的get 或set方法)状态转换消耗的时间有可能比用户代码执行的时间还要长,所以说synchronized是java语言中一个重量级的操纵。
锁升级过程
Mark Word
JVM对象结构
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
1 | |-----------------------------------------------------------------------------------------------------------------| |
1.对象头
HotSpot虚拟机的对象头(Object Header)包括两部分信息,
(1)第一部分MarkWord:用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机(暂 不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。
一个类被加载的时候,hashCode是被存放在对象头里面的Mark Word里面的。
(2)第一部分KlassWord,即类型指针,即是对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。
2.实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
3.对齐填充
第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
- 对象头中的Mark Word(标记字)主要用来表示对象的线程锁状态,另外还可以用来配合GC、存放该对象的hashCode;
- Klass Word是一个指向方法区中Class信息的指针,意味着该对象可随时知道自己是哪个Class的实例;
- 数组长度也是占用64位(8字节)的空间,这是可选的,只有当本对象是一个数组对象时才会有这个部分;
- 对象体是用于保存对象属性和值的主体部分,占用内存空间取决于对象的属性数量和类型;
- 对齐字是为了减少堆内存的碎片空间(不一定准确)。
Mark Word结构
Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。
Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。
下为各种情形下标识位:
状态 | 标志位 | 存储内容 |
---|---|---|
未锁定 | 01 | 对象哈希码、对象分代年龄 |
轻量级锁定 | 00 | 指向锁记录的指针 |
重量级锁定 | 10 | 执行重量级锁定的指针 |
GC 标记 | 11 | 空(不需要记录信息) |
可偏向 | 01 | 偏向线程的ID、偏向时间戳、对象分代年龄 |
轻量级锁(自旋锁)
轻量级锁的获取主要由两种情况:
① 当关闭偏向锁功能时;
② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。
加锁
在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标识位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前Mark Word的拷贝(即Displaced Mark Word)。
然后虚拟机将使用CAS操作尝试将对象的Mark Word更新指向Lock Record指针。
- 如果更新操作成功,代表线程拥有了对象的锁,并将最后两位标识位置为“00”,表示轻量级锁状态
- 如果更新操作失败了,意味着至少存在一条线程与当前线程竞争获取对象的锁。虚拟机会先检查Mark Word是否指向当前线程的栈帧
- 如果是,那就说明线程已经拥有了对象的锁。直接进入同步块继续执行就可以了,即可重入
- 否则,当前锁对象已经被其他线程抢占了。
膨胀
JVM 提供了一种自旋锁,可以通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。这是基于大多数情况下,线程持有锁的时间都不会太长,毕竟线程被挂起阻塞可能会得不偿失。
从 JDK1.7 开始,自旋锁默认启用,自旋次数由 JVM 设置决定,这里我建议设置的重试次数过多,因为 CAS 重试操作意味着长时间地占用 CPU。自旋锁重试之后如果抢锁依然失败,同步锁就会升级至重量级锁,锁标志位改为 10。在这个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在 _WaitSet 队列中。
如果出现两条以上线程争用同一个锁的情况,轻量级锁不再有效,膨胀为重量级锁,置锁标记状态位为“10”,Mark Word存储指向重量级锁(互斥量)的指针,后面等待锁的线程必须进入阻塞状态
重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。
重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源
解锁
通过CAS操作,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和复制过的Displaced Mark Word替换回来。
- 如果成功替换,同步过程顺利完成
- 否则说明有其他线程尝试获取该锁,释放锁的同时需要唤醒被挂起的线程
轻量级锁尝试在应用层面解决线程同步问题,而不触发操作系统的互斥操作,轻量级锁减少多线程进入互斥的几率,不能代替互斥。
偏向锁
JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking=false来禁用偏向锁。
如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。
假设当前虚拟机启用了偏向锁,初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁,把对象头中的标识位设置为“01”,把偏向模式设置为“1”,表示进入偏向模式,同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word中。
- 如果CAS执行成功,持有偏向锁的线程以后每次进入锁相关的同步块的时候,虚拟机都不再进行任何同步操作
- 一旦出现另一个线程去尝试获取这个锁的情况,偏向模式马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标识位恢复到未锁定“01”或者轻量级锁定“00”的状态。
很明显偏向锁几乎没有额外开销,性能极高。
对比
锁优化
自旋锁
挂起线程和恢复线程都需要转入内核态中完成,而共享数据的锁定状态只会持续很短的一段时间,为了这段时间挂起和恢复线程并不值得。我们可以让后面请求锁的那个线程“稍等一会儿”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就可以释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),即我们所说的自旋锁。
自旋等待不能代替阻塞,自旋本身虽然避免了线程切换的开销,但是他是要占用处理器时间的,如果锁被占用的时间很短,自旋效果就会非常好,反之如果锁被占用的时间很长,自旋的线程就会白白消耗处理器资源,不会做任何有价值的工作,带来性能浪费。因此自旋的等待时间有一定的限度,如果自旋超过了限定的次数还没有成功获得锁,就应该使用传统的方式去挂起线程。自旋次数默认十次,用户可以根据-XX:PreBlockSpin来进行修改。
自适应自旋
在JDK6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上自旋时间以及锁的拥有者的状态来决定的。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越精准,虚拟机会变得越来越聪明。
锁消除
锁消除就是编译器运行时,对一些被检测到不可能存在共享数据竞争的锁进行消除。如果判断一段代码中,堆上的数据不会逃逸出去从而被其他线程访问到,则可以把他们当做栈上的数据对待,认为它们是线程私有的,不必要加锁。
1 | public String concatString(String s1, String s2, String s3) { |
在 StringBuffer.append() 方法中有一个同步代码块,锁就是sb对象,但 sb 的所有引用不会逃逸到 concatString() 方法外部,其他线程无法访问它。因此这里有锁,但是在即时编译之后,会被安全的消除掉,忽略掉同步而直接执行了。
锁粗化
锁粗化就是 JVM 检测到一串零碎的操作都对同一个对象加锁,则会把加锁同步的范围粗化到整个操作序列的外部。以上述 concatString() 方法为例,内部的 StringBuffer.append() 每次都会加锁,将会锁粗化,在第一次 append() 前至 最后一个 append() 后只需要加一次锁就可以了。可以减少不必要的性能损耗。
伪唤醒问题
线程也可以唤醒,而不会被通知,中断或超时,即所谓的虚假唤醒 。
出现虚假唤醒的原因是从阻塞态到就绪态再到运行态没有进行判断,我们只需要让其每次得到操作权时都进行判断就可以了
e.g.
1 | package com.company; |
由于使用了if而发生了虚假唤醒,方法在被唤醒后没有去进行条件判断而是直接执行了后续的代码块