synchronized是Java的一个关键字,它能够将代码块(方法)锁起来
synchronized是一种互斥锁
- 一次只能允许一个线程进入被锁住的代码块
synchronized是一种内置锁/监视器锁
- Java中每个对象都有一个内置锁(监视器,也可以理解成锁标记),而synchronized就是使用对象的内置锁(监视器)来将代码块(方法)锁定的! (锁的是对象,但我们同步的是方法/代码块)
- synchronized保证了线程的原子性。(被保护的代码块是一次被执行的,没有任何线程会同时访问)
- synchronized还保证了可见性。(当执行完synchronized之后,修改后的变量对其他的线程是可见的)
原理
public class Main {
//修饰方法
public synchronized void test1(){
}
public void test2(){
// 修饰代码块
synchronized (this){
}
}
}
同步代码块:
- monitorenter和monitorexit指令实现的
同步方法(在这看不出来需要看JVM底层实现)
- 方法修饰符上的ACC_SYNCHRONIZED实现。
synchronized底层是是通过monitor对象,对象有自己的对象头,存储了很多信息,其中一个信息标示是被哪个线程持有。
作用
synchronized一般我们用来修饰三种东西:
- 修饰普通方法:当前对象
- 修饰代码块:括号里的对象
- 修饰静态方法:当前类
重入锁
public class Widget {
// 锁住了
public synchronized void doSomething() {
...
}
}
public class LoggingWidget extends Widget {
// 锁住了
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}
- 当线程A进入到LoggingWidget的
doSomething()
方法时,此时拿到了LoggingWidget实例对象的锁。 - 随后在方法上又调用了父类Widget的
doSomething()
方法,它又是被synchronized修饰。 - 那现在我们LoggingWidget实例对象的锁还没有释放,进入父类Widget的
doSomething()
方法还需要一把锁吗?
不需要的!
因为锁的持有者是“线程”,而不是“调用”。线程A已经是有了LoggingWidget实例对象的锁了,当再需要的时候可以继续“开锁”进去的!
这就是内置锁的可重入性。记住,持有锁的是线程**。
释放锁
- 当方法(代码块)执行完毕后会自动释放锁,不需要做任何的操作。
- 当一个线程执行的代码出现异常时,其所持有的锁会自动释放。
原理
在《深入理解Java虚拟机》一书中,介绍了HotSpot
虚拟机中,对象的内存布局分为三个区域:对象头(Header)、实例数据(Instance Data)和对齐数据(Padding)
。而对象头又分为两个部分“Mark Word”和类型指针,其中“Mark Word”包含了线程持有的锁。
因此,synchronized
锁,也是保存在对象头中。JVM基于进入和退出Monitor
对象来实现synchronized
方法和代码块的同步,对于方法和代码块的实现细节又有不同:
- 代码块,使用
monitorenter
和monitorexit
指令来实现;monitorenter
指令编译后,插入到同步代码块开始的位置,monitorexit
指令插入到方法同步代码块结束位置和异常处,JVM保证每个monitorenter
必须有一个monitorexit
指令与之对应。线程执行到monitorenter
指令处时,会尝试获取对象对应的Monitor
对象的所有权 (任何一个对象都有一个Monitor
对象预制对应,当一个Monitor
被持有后,它将处于锁定状态) 。 - 方法:在《深入理解Java虚拟机》同步指令一节中,关于方法级的同步描述如下:
- 方法级的同步是隐式的,即无需通过字节码指令来控制,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有管程,然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程获取了管程,其他线程就无法获取管程。
什么是Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)。
不过两者的本质都是对对象监视器 monitor 的获取。
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,同时该线程进入WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示
作者:foofoo
链接:https://juejin.cn/post/6844903670933356551
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
锁优化
上边介绍了synchronized
锁,主要通过获取Monitor来获取锁,而获取和释放Monitor的成本会非常高,因为,线程之间需要进行用户态到内核态的切换。JDK 1.6为了减少获取锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,JDK 1.6中锁一共有4中状态依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态
。另外,还使用了很多的锁优化技术,如适应性自旋锁、锁消除、锁粗化等。
自旋锁+自适应自旋锁
首先,什么自旋锁?
- 顾名思义就是一个线程不停的自旋(循环),以达到代码块只有一个线程执行的同步效果。
那么为什么会引入自旋锁呢?
- 因为在许多应用中,共享数据的锁定状态只会持续很短的时间,短时间内挂起和恢复线程是浪费的,因此,为了让线程保持运行,但是不去争夺同步资源,引入了自旋锁,即让线程执行一个忙循环,当持有锁的线程释放锁后,该线程再去获取锁。避免线程挂起和恢复消耗资源。
上边介绍了引入自旋锁的需求,锁持续的时间很短,这种情况下,让线程自旋等待相比挂起和恢复,似乎是更高效的方式;但是,如果锁长时间未释放,那么线程将一直自旋下去,这样会张勇太多的处理器时间,白白消耗处理器资源做无用功,带来性能上的浪费。
因此,自旋锁等待的时间必须要有一定的限制,超过了规定次数,还没有获得锁,线程就会挂起。自旋次数默认是10次,可以使用 -XX:PreBlockSpin 参数来修改。
上边的自旋锁已经可以自旋指定的次数了,这样相比原来不停的自旋或者直接挂起线程已经高效很多了;可是如果我们自旋10次之后,刚挂起线程,就获得了锁,有需要唤醒线程,我们的处理器肯定在想你为什么不多自旋一会,我们的自旋锁是否能够更加智能呢?
JVM 的开发者没有让我们失望,在JDK 1.6 中引入了自适应的自旋锁,自适应就是说自旋的时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。自适应自旋锁认为,如果同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在执行,那么虚拟机就会任务这次自旋锁很可能会再次成功,将会等待更长的时间;如果某个锁,很少更改获得,那么后边的线程将忽略自旋的过程,直接挂起,避免处理器资源的浪费。
锁消除
消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。<br /> 那么 JVM 虚拟机是如何检测锁不可能存在数据竞争呢?另外,如果不存在数据竞争我们再写代码的时候不加锁不久可以了,虚拟机为什么还要做这个检测呢?<br /> 虚拟机使用逃逸分析技术,来检测锁是否存在竞争;虽然我们再写代码时,可能不会加锁,但是javac编译器会对我们的程序进行自动优化,因此可能就会引入部分我们自己都不知道的锁,所以,虚拟机的锁消除还是有必要的,而且也更好的优化了程序的性能。
锁粗化
锁粗化就是将多个连续的锁,连接在一起,扩展成一个范围更大的锁。但是,在刚学习多线程时,总是推荐我们同步代码块的范围要尽可能小,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快得到锁。
大部分情况下,我们遵循以上的原则是正确的,但是如果一系列连续的操作都对同一个对象加锁(例如:频繁调用一些同步方法,StringBuffer.append()等),程序将会频繁的进行加锁解锁过程,影响效率。因此,虚拟机如果检测到这种对同一对象加锁的连续操作,将会把加锁范围扩展到整个操作序列的外部。
偏向锁
大多数情况下,锁总是由一个线程多次获得,这种情况下,为了降低线程获取锁的代价,引入了偏向锁。
线程每次进入同步代码块,会在对象头中记录线程ID,一个该线程如果再次进入和退出同步代码块时,检测到对象头中存储的指向当前线程的偏向锁,则不需要使用CAS加锁和解锁;如果检测失败,则再检测一下对象头中的偏向锁标识是否设置为1,即当前是偏向锁:如果不是偏向锁,使用CAS竞争锁;如果是,使用CAS将对象头的偏向锁指向当前线程。
- 释放偏向锁 偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
- 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态;
- 撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态;
轻量级锁
上边偏向锁中说道,当多个线程竞争锁时,偏向锁就会撤销,偏向锁撤销之后会升级为轻量级锁,轻量级锁的获取步骤如下:
- 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3);
- JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3);
- 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;
释放锁
- 取出在获取轻量级锁保存在Displaced Mark Word中的数据;
- 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
- 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。
https://juejin.cn/post/6844903830644064264
锁升级
https://www.cnblogs.com/wuqinglong/p/9945618.html
偏向锁
偏向锁是针对于一个线程而言的, 线程获得锁之后就不会再有解锁等操作
假如有两个线程来竞争该锁话, 那么偏向锁就失效了, 进而升级成轻量级锁了.
为什么要这样做呢? 因为经验表明, 其实大部分情况下, 都会是同一个线程进入同一块同步代码块的. 这也是为什么会有偏向锁出现的原因.
在Jdk1.6中, 偏向锁的开关是默认开启的, 适用于只有一个线程访问同步块的场景.
加锁
当一个线程获取锁的时候,会在锁对象的对象头和栈帧记录存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要进行CAS操作来加锁和解锁, 只需要简单的测试一下锁对象的对象头的MarkWord里是否存储着指向当前线程的偏向锁(线程ID是当前线程),
- 如果测试成功, 表示线程已经获得了锁;
- 如果测试失败, 则需要再测试一下MarkWord中偏向锁的标识是否设置成1(表示当前是偏向锁), 如果没有设置, 则使用CAS竞争锁, 如果设置了, 则尝试使用CAS将锁对象的对象头的偏向锁指向当前线程.
撤销
偏向锁使用了一种等到竞争出现才释放锁的机制, 所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁.
- 偏向锁的撤销需要等到全局安全点(在这个时间点上没有正在执行的字节码).
- 首先会暂停持有偏向锁的线程, 然后检查持有偏向锁的线程是否存活, 如果线程不处于活动状态, 则将锁对象的对象头设置为无锁状态;
- 如果线程仍然活着, 则锁对象的对象头中的MarkWord和栈中的锁记录要么重新偏向于其它线程要么恢复到无锁状态, 最后唤醒暂停的线程(释放偏向锁的线程).
偏向锁在Java6及更高版本中是默认启用的, 但是它在程序启动几秒钟后才激活. 可以使用-XX:BiasedLockingStartupDelay=0来关闭偏向锁的启动延迟, 也可以使用-XX:-UseBiasedLocking=false来关闭偏向锁, 那么程序会直接进入轻量级锁状态.
轻量级锁
当出现有两个线程来竞争锁的话, 那么偏向锁就失效了, 此时锁就会膨胀, 升级为轻量级锁.
加锁
线程尝试使用CAS将对象头中的MarkWord替换为指向锁记录的指针. 如果成功, 当前线程获得锁; 如果失败, 表示其它线程竞争锁, 当前线程便尝试使用自旋来获取锁, 之后再来的线程, 发现是轻量级锁, 就开始进行自旋.
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗, 和执行非同步代码方法的性能相差无几. | 如果线程间存在锁竞争, 会带来额外的锁撤销的消耗. | 适用于只有一个线程访问的同步场景 |
轻量级锁 | 竞争的线程不会阻塞, 提高了程序的响应速度 | 如果始终得不到锁竞争的线程, 使用自旋会消耗CPU | 追求响应时间, 同步快执行速度非常快 |
重量级锁 | 线程竞争不适用自旋, 不会消耗CPU | 线程堵塞, 响应时间缓慢 | 追求吞吐量, 同步快执行时间速度较长 |