Synchronized同步锁介绍
1. synchronized实现原理
Java中的每一个对象都可以作为锁。
-
对于普通同步方法,锁是当前类的实例对象(this)。
public synchronized void syncFunctino(){ doSomething(); }
-
对于静态同步方法,锁是当前类的Class对象。所有对象公用同一把锁
public static synchronized void syncFunction(){ //doSomething... }
-
对于同步方法块,锁是Synchonized括号里配置的对象,特殊的指定锁对象为Class对象
Object syncObj; public Sync(Object syncObj) { this.syncObj = syncObj; } synchronized (object) { //doSomething... }
当一个线程试图访问普通同步方法,静态同步方法,同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。
synchronized在JVM层面实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,使用monitorenter和monitorexit指令实现的。
monitorenter指令是在代码编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每一个对象在同一时间只与一个monitor(锁)相关联,而一monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:
- monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
- 如果这个monitor(线程的对象)已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加
- 这把锁已经被别的线程获取了,等待锁释放
monitorexit指令
:释放对于monitor的所有权,释放过程很简单,就是讲monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。
JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的互斥锁来实现的。
注:
1 什么是可重入?可重入锁?
可重入:与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。
可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
同一锁程中,每个对象拥有一个monitor计数器,当线程获取该对象锁后,monitor计数器就会加一,释放锁后就会将monitor计数器减一,线程不需要再次获取同一把锁。
2 保证可见性的原理:happens-before规则
Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。
2. OOP-Klass模型
JVM内部基于oop-klass模型描述一个java类,将一个java类分为两个部分进行描述,JVM在加载类时,会使用klass去存储这个类型的元数据和虚方法表。JVM在初始化对象时,会使用Ordinary Object Pointer (普通对象指针),看起来像个指针实际上是藏在指针里的对象,去存储这个类型的实例数据。
为什么设计OOP-Klass模型?
JVM的设计者不想让每个对象中都含有一个虚函数表,所以就把对象模型拆成klass和oop,其中oop中不含有任何虚函数,而Klass就含有虚函数表,可以进行方法派遣。
Klass的结构:
一般jvm在加载class文件时,会在方法区创建instanceKlass对象,表示类的元数据,包括方法、接口、字段、运行时常量池等。
一个OOP对象(16个字节)包含以下几个部分:
- 对象头(instanceOopDesc,Objectheader)
- 对象标记(MarkWord),存储hashcode, GC分代年龄,锁标记,锁监视等
- 元数据指针,即指向方法区的instanceKlass实例
- 实例数据
- 对齐填充
3. 类的加载过程
-
当创建对象时,首先JVM native层判断该类是否被加载过,没有的话就进行类的加载,在JVM内部创建一个instanceKlass对象表示该类的元数据。并在堆中创建类的class对象,供反射调用。
-
初始化的时候,JVM就创建OOP对象表示该对象的实例,然后进行Mark Word信息填充,将元数据指针指向instanceKlass对象,并填充实例变量。
-
instanceKlass对象通过字段_java_mirror定位到class对象,class对象通过字段klass,指向对象的元数据信息,实现双向引用
-
令栈中对象的引用指向实例对象。
Java类编译成字节码后,经过类加载器加载到JVM,将字节码数据分区存储。
-
堆存储类的OOP实例。栈帧存储局部变量表,包含变量基本类型,对象的引用。
-
栈中存对象的引用
-
程序计数器进行运算
-
方法区存储类的方法,接口,运行时常量池,字段。
类加载器
4. 锁的存储结构
synchronized用的锁是存在Java对象头的对象标记里的。
32位JVM的Mark Word(4字节)的默认存储结构如下表所示。
4字节32bit,25bit存储对象的hashcode,4bit存储gc年龄,2bit存锁标记位,1bit存是否偏向锁。
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据,如下表所示。
对象头(instanceOopDesc))中的对象标记(MarkWord)中的锁标记2位(bit),四种。轻量级锁00,无锁01,重量级锁10,GC标记11,偏向锁01。
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下表所示。
5. 锁升级
JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的互斥锁来实现的,但是由于使用互斥锁需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁、轻量级锁、锁粗化、锁消除、适应性自旋,在Jdk1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率,
5.1 偏向锁
在大多实际环境下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,那么在同一个线程反复获取锁和释放锁的过程中(执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟),带来了很多不必要的性能开销和上下文切换。为了解决这一问题,对Synchronized进行了优化,引入了偏向锁。
(1)偏向锁初始化
当一个线程访问同步块并获取锁时,会在对象头的的对象标记的锁标记和栈帧中的锁记录里存储锁偏向的当前线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1。如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
(2)偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制(无条件),所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KlQOVcJu-1668581576900)(…/…/…/Library/Application Support/typora-user-images/image-20221019215151886.png)]
偏向锁初始化撤销流程
(2)关闭偏向锁
-XX:BiasedLockingStartupDelay=0,关闭延迟。
-XX:-UseBiasedLocking=false,程序默认会进入轻量级锁状态。
5.2 轻量级锁
在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁(信号量)进入到阻塞状态,当锁被释放的时候被唤醒。
(1)轻量级锁加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word(取代标记字)。然后线程尝试使用CAS将对象头中的Mark Word(hashcode,GC年龄,锁标记,偏向锁标记)替换为指向锁记录的指针。如果成功,当前线程获得锁,并且对象Mark Word的锁标志位更新为(Mark Word
中最后的2bit)00,即表示此对象处于轻量级锁定状态。如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。自旋一定次数后,如果还是失败就进入锁膨胀阶段,直接膨胀位重量级锁。此时,锁的标志位为10.Mark Word
中存储的时指向重量级锁的指针。
(2)轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作将锁记录的Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。两个线程同时争夺锁,导致锁膨胀的流程图如下:
轻量级锁加锁,解锁,锁膨胀流程
注:
锁记录内部可以储存对象的 Mark Word 和对象引用 reference。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程为重量级锁被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。
锁的优缺点对比:
5.3 自旋锁
大家都知道,在没有加入锁优化时,Synchronized是一个非常“胖大”的家伙。在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了很大的压力。同时HotSpot团队注意到在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复阻塞线程并不值得。在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但不放弃CPU的执行时间。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这便是自旋锁由来的原因。如果锁占用的时间非常的短,那么自旋锁的性能会非常的好,相反,其会带来更多的性能开销(因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源)。因此自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin
来更改。
自适应自旋锁
自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准确,JVM也会越来越聪明。
5.4 锁粗化
原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。
大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。为了减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。
例子:
public static String test04(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
在上述的连续append()操作中就属于这类情况。JVM会检测到这样一连串的操作都是对同一个对象加锁,那么JVM会将加锁同步的范围粗化到整个一系列操作的外部,使整个一连串的append()操作只需要加锁一次就可以了。
5.5 锁消除
锁消除的主要判定依据来源于逃逸分析的数据支持,JVM会判断在一段程序中的同步数据明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据是线程独有的,不需要加同步。此时就会进行锁消除。通过逃逸分析也可以在当前线程的Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。
例子,String是一个不可变类,对字符串的连接操作总是通过生成的新的String对象来进行的。因此Javac编译器会对String连接做自动优化,会使用StringBuffer对象的连续append()操作。众所周知,StringBuilder是安全同步的,需要加锁,但是在下面代码中,JVM判断该段代码并不会逃逸,则将该代码带默认为线程独有的资源,并不需要同步,所以执行了锁消除操作。
public static String test03(String s1, String s2, String s3) {
String s = s1 + s2 + s3;
return s;
}
锁的优缺点对比
6. Synchronized与Lock
6.1 synchronized的缺陷
-
效率低
:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时 -
不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活
-
无法知道是否成功获得锁
,相对而言,Lock可以拿到状态,如果成功获取锁,…,如果获取失败,…
6.2 Lock解决相应问题
Lock类这里不做过多解释,主要看里面的4个方法:
lock()
: 加锁unlock()
: 解锁tryLock()
: 尝试获取锁,返回一个boolean值,中断tryLock(long,TimeUtil)
: 尝试获取锁,可以设置超时
Synchronized只有锁只与一个条件(是否获取锁)相关联,不灵活,后来Condition与Lock的结合
解决了这个问题。
多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReentrantLock的lockInterruptibly()方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后ReentrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReentrantLock时就不会像synchronized那样产生死锁了。
ReentrantLock
为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。
7. 深入理解
使用Synchronized有哪些要注意的?
- 锁对象不能为空,因为锁的信息都保存在对象头里
- 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
- 避免死锁
- 在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错
synchronized是公平锁吗?
synchronized实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区阻塞队列中等候已久的线程可能再次等待,这样有利于提高性能,但是也可能会导致饥饿现象。