Synchronized和锁升级
Posted King Gigi.
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Synchronized和锁升级相关的知识,希望对你有一定的参考价值。
文章目录
1、Synchronized关键字
谈谈你对Synchronized的理解
synchronized关键字解决的是多个线程之间访问资源的同步性
,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
另外,在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)
是依赖于底层的操作系统的Mutex Lock
来实现的,Java 的线程是映射到操作系统的原生线程
之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态
,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized 效率低的原因。
庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁
、适应性自旋锁
、锁消除
、锁粗化
、偏向锁
、轻量级锁
等技术来减少锁操作的开销。
说说自己是怎么使用 synchronized 关键字?
- 修饰
实例方法
: 作用于当前对象实例加锁
,进入同步代码前要获得当前对象实例的锁 - 修饰
静态方法
:也就是给当前类加锁,会作用于类的所有对象实例
,因为静态成员不属于任何一个实例对象
,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象
,因为访问静态 synchronized 方法占用的锁是当前类的锁
,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。 - 修饰
代码块
:指定加锁对象
,对给定对象加锁,进入同步代码块前要获得给定对象的锁。
总结:
synchronized 关键字加到 static 静态方法
和 synchronized(class)代码块
上都是给 Class 类上锁
。synchronized关键字加到实例方法
上是给对象实例上锁
。尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能
!
2、无锁
Java对象刚刚创建时没有任何线程来竞争,该对象处于无锁状态。此时偏向锁状态位 0 ,锁状态标识位 01 ,
3、偏向锁
3.1、是什么
偏向锁是指一段同步代码一直被同一个线程
所访问,那么该线程会自动获得锁。锁会偏向于当前已经占有锁的线程
。如果内置锁处于偏向状态,当有一个线程来竞争锁时,先用偏向锁,这个线程要执行该锁关联的同步代码时,不在做任何检查和切换,效率非常高
3.2、核心原理
如果不存在线程竞争的
一个线程获得了锁,那么锁就进入偏向状态,Mark Word的结构变为偏向锁结构,锁标志位(lock)被改为 01 ,偏向标志位(biased lock)被改为1 ,然后线程的 id 记录在锁对象的Mark Word中
(使用CAS操作完成),以后该线程获取锁时先判断一下线程id 和标志位
,就可以直接进入同步块,都不需要CAS操作 ,这就省去了大量有关锁申请的操作,提升了程序的性能。
偏向锁适用于一个线程
,一旦有第二条线程竞争锁,那么偏向模式立即结束,进入轻量级锁的状态。
如果锁对象时常被多个线程竞争,偏向锁就很多余,其撤销的过程会带来一些性能的开销。
JVM 在启动的时候会延迟使用偏向锁机制
,默认延迟了4000毫秒。
如果想要去掉延迟,需要添加参数-XX:BiasedLockingStartupDelay=0
开启偏向锁:
-XX:+UseBiasedLocking -XX:BasiedLockingStartupDelay=0
关闭偏向锁:
-XX:-UseBiasedLocking
程序会默认进入轻量级锁状态
3.3、加载过程
锁总是被第一个占用它的线程拥有,这个线程就是锁的偏向线程,锁第一次被拥有的时候,记录下偏向线程的id 。新线程进入和退出这段加了同步锁的代码块时 ,只需要判断内置锁对象的Mark Word中的线程 id 是不是自己的 id 。
如果是
就直接使用这个锁,而不使用CAS交换
如果不是
,比如在第一次获得此锁时内置锁的线程 id 为空,就使用CAS交换 , 新线程将自己的线程 id 交换到内置锁的 Mark Word中
如果交换成功
,Mark Word中的线程id 为新线程的id,锁还是偏向锁
如果失败
,很可能需要升级为轻量级锁,保证线程之间的公平竞争锁
在循环抢锁中,每执行一轮抢占,JVM内部都会比较内置锁的偏向线程 id 与当前线程 id
,如果匹配 ,表明当前线程已经获得了偏向锁,当前线程可以快速进入临界区。
3.4、偏向锁的膨胀和撤销
假如有多个线程来竞争偏向锁,此对象锁已经有所偏向,其他的线程发现偏向锁并不是偏向自己,就说明存在了竞争,这时尝试撤销偏向锁,然后膨胀到轻量级锁
偏向锁撤销的大致过程:
- 在一个
安全点
停止拥有锁的线程 - 遍历线程的栈帧,检查是否存在锁记录。如果存在,就
清空锁记录
,使其变为无锁状态,并修复锁记录指向的Mark Word ,清除其线程 id
- 将当前锁升级成轻量级锁
唤醒
当前线程
撤销偏向锁的条件:
- 多个线程竞争偏向锁
- 调用偏向锁对象的
hashcode()
方法或System.identityHashCode()
方法计算对象的HashCode值之后,将哈希码存放到Mark Word中,内置锁变成无锁状态,偏向锁将被撤销
偏向锁的膨胀
偏向锁不会主动释放
,如果偏向锁被占据,第二个线程争抢这个对象时,JVM会检查原来持有该对象锁的占有线程是否依然存活
,如果挂了,就将锁变为无锁状态,然后进行重新偏向为抢锁线程,如果线程依然存活 ,进一步检查占有线程的调用栈帧是否通过锁记录持有偏向锁
。若是存在锁记录,表明原来的线程还在使用偏向锁,发生锁竞争,撤销原来的偏向锁,将锁膨胀为轻量级锁。
4、轻量级锁
4.1、是什么
轻量级锁是一种自旋锁
,
当锁处于偏向锁,又被另一个线程企图抢占时,锁会升级为轻量级锁,企图抢占的线程会通过自旋的方式尝试获取锁,不会阻塞抢锁线程。两个线程公平竞争,哪个线程先占有锁对象,锁对象的Mark Word就指向那个线程的栈帧中的锁记录,
线程自旋是需要消耗CPU的,默认最大的自旋次数是10 ,可以通过-XX:PreBlockSpin
选项更改
然而JDK1.6之后引入了自适应自旋锁
,意味着自旋的时间不是固定的,而是由前一次在同一个锁上的自旋时间
以及锁的拥有者的状态
来决定。如果自旋成功,下次自旋的次数就会更多,相反的,如果失败,下次自旋的次数就会减少。
如果持有锁的线程执行时间超过了自旋等待的最大时间任然没有释放锁,就会导致其他争用锁的线程在最大等待时间内获取不到锁,自旋不会一直持续下去,争用线程会停止自旋进入阻塞状态,该锁膨胀为重量级锁。
4.2、核心原理
在抢锁线程进入临界区之前,如果内置锁没有被锁定,JVM 首先将在抢锁线程的栈帧中建立一个锁记录
,用于存储对象目前Mark Word的拷贝,然后抢锁线程进入CAS自旋
,尝试将内置锁对象头的Mark Word 的 ptr_to_lock_record
(锁记录指针) 更新为抢锁线程栈帧中锁记录的地址
4.3、轻量级锁的膨胀
大部分临界区代码的执行时间是很短的,但是也会存在执行的很慢的临界区代码段。临界区代码执行时间过长,其他线程在原地自旋等待就会空消耗CPU (空自旋
),带来很大的性能损耗。
轻量级锁的本意是为了减少多线程进入操作系统底层的互斥锁的概率,并不是要替代操作系统互斥锁,所以在竞争激烈的场景下,轻量级锁会膨胀为操作系统内核互斥锁实现的重量级锁。
5、重量级锁
5.1、是什么
重量级锁通过监视器
的方式保障了任何时间只允许一个线程通过受监视器保护的临界区代码
5.2、核心原理
Java中synchronized的重量级锁,是基于进入和退出Monitor对象实现的。在编译时会将同步块的开始位置插入monitor enter
指令,在结束位置插入monitor exit
指令。
当线程执行到monitor enter指令时,会尝试获取对象所对应的Monitor所有权,如果获取到了,即获取到了锁,会在Monitor的owner中存放当前线程的id,这样它将处于锁定状态,除非退出同步块,否则其他线程无法获取到这个Monitor。
Demo:
锁升级发生后,hashcode去哪啦
锁升级为轻量级或重量级锁后,Mark Word中保存的分别是线程栈帧里的锁记录指针和重量级锁指针,己经没有位置再保存哈希码,GC年龄了,那么这些信息被移动到哪里去了呢?
-
在无锁状态下,Mark Word中可以存储对象的identity hash code值。当对象的hashCode()方法第一次被调用时,JVM会生成对应的identity hash code值并将该值存储到Mark Word中。
-
对于偏向锁,在线程获取偏向锁时,会用Thread ID和epoch值覆盖identity hash code所在的位置。如果一个对象的hashCode()方法己经被调用过一次之后,这个对象不能被设置偏向锁。因为如果可以的化,那Mark Word中的identity hash code必然会被偏向线程Id给覆盖,这就会造成同一个对象前后两次调用hashCode()方法得到的结果不一致。
-
升级为轻量级锁时,JVM会在当前线程的栈帧中创建一个锁记录(Lock Record)空间,用于存储锁对象的Mark Word拷贝,该拷贝中可以包含identity hash code,所以轻量级锁可以和identity hash code共存,哈希码和GC年龄自然保存在此,释放锁后会将这些信息写回到对象头。
-
升级为重量级锁后,Mark Word保存的重量级锁指针,代表重量级锁的ObjectMonitor类里有字段记录非加锁状态下的Mark Word,锁释放后也会将信息写回到对象头。
在java中每个对象都可以成为一把锁,因为在JVM中每个对象都一个monitor(监视器锁)
。对应到C底层叫做Object Monitor
,并用c定义了很多信息。再往下到操作系统中是基于Mutex Lock互斥锁
实现,涉及到了用户态和内核态的切换,所以非常耗费资源
synchronized用的锁是存在Java对象头里的Mark Word中,锁升级功能主要依赖MarkWord中锁标志位
和释放偏向锁标志位
锁指向
- 偏向锁:MarkWord存储的是偏向的线程ID;
- 轻量锁:MarkWord存储的是指向线程栈中Lock Record的指针;
- 重量锁:MarkWord存储的是指向堆中的monitor对象的指针
6、锁消除
从JIT角度看相当于无视锁,synchronized(o)不存在了,
这个锁对象并没有被共用扩散到其它线程使用,
极端的说就是根本没有加这个锁对象的底层机器码,消除了锁的使用
举个栗子:
运行上述代码会发现每次调用m1方法都会new 一个Object对象,每个线程都有锁,JIT底层会优化消除锁对象。
7、锁粗化
假如方法中首尾相接,前后相邻的都是同一个锁对象
,那JIT编译器就会把这几个synchronized块合并成一个大块,
加粗加大范围,一次申请使用即可,避免次次都申请和释放锁,提升了性能
最后贴一张大神的图再次理解下锁的升级过程:
深度分析:锁升级过程和锁状态,看完这篇你就懂了!
一、前言
锁的状态总共有四种,级别由低到高依次为:无锁、偏向锁、轻量级锁、重量级锁,这四种锁状态分别代表什么,为什么会有锁升级?其实在 JDK 1.6之前,synchronized 还是一个重量级锁,是一个效率比较低下的锁,但是在JDK 1.6后,Jvm为了提高锁的获取与释放效率对(synchronized )进行了优化,引入了 偏向锁 和 轻量级锁 ,从此以后锁的状态就有了四种(无锁、偏向锁、轻量级锁、重量级锁),并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别),不能锁降级(高级别到低级别),意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
二、锁的四种状态
在 synchronized 最初的实现方式是 “阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,这种方式就是 synchronized实现同步最初的方式,这也是当初开发者诟病的地方,这也是在JDK6以前 synchronized效率低下的原因,JDK6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
所以目前锁状态一种有四种,从级别由低到高依次是:无锁、偏向锁,轻量级锁,重量级锁,锁状态只能升级,不能降级
如图所示:
三、锁状态的思路以及特点
四、锁对比
五、Synchronized锁
synchronized 用的锁是存在Java对象头里的,那么什么是对象头呢?
5.1 Java 对象头
我们以 Hotspot 虚拟机为例,Hopspot 对象头主要包括两部分数据:Mark Word(标记字段) 和 Klass Pointer(类型指针)
Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
在上面中我们知道了,synchronized 用的锁是存在Java对象头里的,那么具体是存在对象头哪里呢?答案是:存在锁对象的对象头的Mark Word中,那么MarkWord在对象头中到底长什么样,它到底存储了什么呢?
在64位的虚拟机中:
在32位的虚拟机中:
下面我们以 32位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的
无锁 :对象头开辟 25bit 的空间用来存储对象的 hashcode ,4bit 用于存放对象分代年龄,1bit 用来存放是否偏向锁的标识位,2bit 用来存放锁标识位为01
偏向锁: 在偏向锁中划分更细,还是开辟 25bit 的空间,其中23bit 用来存放线程ID,2bit 用来存放 Epoch,4bit 存放对象分代年龄,1bit 存放是否偏向锁标识, 0表示无锁,1表示偏向锁,锁的标识位还是01
轻量级锁:在轻量级锁中直接开辟 30bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为00
重量级锁: 在重量级锁中和轻量级锁一样,30bit 的空间用来存放指向重量级锁的指针,2bit 存放锁的标识位,为11
GC标记: 开辟30bit 的内存空间却没有占用,2bit 空间存放锁标志位为11。
其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态
关于内存的分配,我们可以在git中openJDK中 markOop.hpp 可以看出:
public: // Constants enum { age_bits = 4, lock_bits = 2, biased_lock_bits = 1, max_hash_bits = BitsPerWord - age_bits - lock_bits - biased_lock_bits, hash_bits = max_hash_bits > 31 ? 31 : max_hash_bits, cms_bits = LP64_ONLY(1) NOT_LP64(0), epoch_bits = 2 };
age_bits: 就是我们说的分代回收的标识,占用4字节
lock_bits: 是锁的标志位,占用2个字节
biased_lock_bits: 是是否偏向锁的标识,占用1个字节
max_hash_bits: 是针对无锁计算的hashcode 占用字节数量,如果是32位虚拟机,就是 32 - 4 - 2 -1 = 25 byte,如果是64 位虚拟机,64 - 4 - 2 - 1 = 57 byte,但是会有 25 字节未使用,所以64位的 hashcode 占用 31 byte
hash_bits: 是针对 64 位虚拟机来说,如果最大字节数大于 31,则取31,否则取真实的字节数
cms_bits: 不是64位虚拟机就占用 0 byte,是64位就占用 1byte
epoch_bits: 就是 epoch 所占用的字节大小,2字节。
5.2 Monitor
Monitor 可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个 Java 对象就有一把看不见的锁,称为内部锁或者 Monitor 锁。
Monitor 是线程私有的数据结构,每一个线程都有一个可用 monitor record 列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个 monitor 关联,同时 monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为重量级锁。
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking=false来禁用偏向锁。
六、锁的分类
6.2 无锁
无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。
6.3 偏向锁
初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。
当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。
6.4 轻量级锁(自旋锁)
轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋(关于自旋的介绍见文末)的形式尝试获取锁,线程不会阻塞,从而提高性能。
轻量级锁的获取主要由两种情况:
① 当关闭偏向锁功能时;
② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。
一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。
在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。
长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。
6.4 重量级锁
重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。
重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资
五、总结
文中讲述了锁的四种状态以及锁是如何一步一步升级的过程,文中有理解不到位或者有问题的地方,欢迎大家在评论区中下方指出和交流,谢谢大家
以上是关于Synchronized和锁升级的主要内容,如果未能解决你的问题,请参考以下文章
JDK源码Synchronized关键字原理,和锁的膨胀过程