为了提高阅读的体验,可以点击这里
早期版本的synchronized在性能上比较差,好在Jdk1.6之
后对其进行种种优化,那么这篇我们就来学习一下synchronized锁都有哪些优化操作!因为网上关于这块的解析比较多了,所以基础如自旋、Mark Word就不再复述了,主要讲我对锁优化的认识!
我觉得要想了解 synchronized 的优化,就必须要先认识到早期 synchronized 中的传统锁有哪些不足点,这里的传统锁就是经常听到的重量锁。那么先来看一下重量锁是如何工作的。
在Java中每个对象都有一个monitor对象与之对应,在重量级锁的状态下,对象的mark word
存放的是一个指针,指向了与之对应的monitor对象。这个monitor对象就是实现重量锁的关键。注意我这里说的是实现重量锁的关键,所以偏向锁、轻量锁在实现上和monitor是没有关系的。
一个monitor对象包括这么几个关键字段:ContentionList,EntryList ,WaitSet,owner。其中ContentionList、EntryList 、WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。
◆ Contention List:所有请求锁的线程将被首先放置到该竞争队列。
◆ Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List。
◆ Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set。
◆ OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck。
◆ Owner:获得锁的线程称为Owner。
◆ !Owner:释放锁的线程。
重量锁竞争的过程:
EntryList与ContentionList逻辑上同属等待队列,ContentionList会被线程并发访问,为了降低对ContentionList队尾的争用,而建立EntryList。
当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter对象插入到ContentionList的队列尾部,然后暂停当前线程。当持有锁的线程释放锁前,会将ContentionList中的所有元素移动到EntryList中去,并会指定EntryList中的某个线程(一般为Head)为Ready(OnDeck)线程。Owner线程并不是把锁传递给OnDeck线程,只是把竞争锁的权利交给OnDeck,OnDeck线程需要重新竞争锁。
重量锁性能差的原因:
以上就是竞争重量锁的基本情况,我们可以知道实现的关键在于monitor。而monitor是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。
重量锁更适合多线程同时进入临界区
JVM的开发者发现在很多情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。
在重量锁的时候Mark Word指向了与之对应的monitor对象,在轻量锁的情况下Mark Word也是存放了一个指针,这个指针指向了竞争线程帧栈中的锁记录。听起来很绕口,看下面竞争的过程:
轻量锁竞争:
当线程试图获取一个锁时,会先在当前的帧栈中创建用于存储锁记录的空间,然后将Mark Word的内容拷贝到这个记录空间。
接着线程会再试图通过CAS修改Mark Word,让Mark Word中的内容变成一个指针,指向刚刚创建的锁记录的地址。修改成功的线程就拿到了锁。如果失败了,它不会像重量锁一样马上将线程挂起,轻量锁就是要解决重量锁动不动就把线程挂起来的操作(内核态与用户态的切换成本高),因为它认为锁马上就会被释放掉,它会通过占用CPU再去尝试修改几次,这个过程就是自旋。轻量锁优化的地方在于:它认为自旋的时间与线程挂起的操作相比是划算的。
轻量锁释放:
如果一个线程2通过自旋还是没有得到锁,它就认为再这样占用CPU的时间反而不值当了,还不如把线程挂起来,不去浪费CPU的时间,等待占用锁的线程1释放之后的中断。也就是说这个锁已经不适合使用轻量锁了,应该膨胀为重量锁,于是自旋失败的线程2会把Mark Word修改成指向monitor的指针。
线程1同步块执行完毕之后发现,诶!Mark Word原来不是指向线程1中的锁记录吗?!!怎么现在指向其他地方了(指向monitor),他知道了有其他线程因为自旋几次失败,然后修改了Mark Word。那么线程1释放完锁后就唤醒被挂起的线程2。然后这个锁就变成了重量锁。
线程1同步块执行完毕,如果发现Mark Word还是指向当前线程的锁记录,那么说明没有其他线程在它使用的期间因为获取锁失败,而修改Mark Word的值。于是把刚刚拷贝到帧栈中的Display Mark Word拷贝回原来的对象头中。竞争不够强烈,还是使用轻量锁。
轻量锁更适用于多个线程交替进入临界区
因为轻量锁想要知道多线程竞争是否激烈(究竟是交替进入还是同时竞争),所以会拷贝Mark Word以及通过CAS修改Mark Word,过程会涉及到多次CAS(CAS本身仍旧是一种操作系统同步原语,有一定的开销)。但JVM的开发者发现多数情况下:一个对象在一段很长的时间内都只被一个线程用。那么偏向锁就此出现。
从上面的轻量锁和重量锁我们知道一个对象的Mark Word会存放一个指针,而在偏向锁中,Mark Word存放线程ID来标识当前使用的线程。
偏向锁加锁:
当该对象第一次被线程获得锁的时候,发现是匿名偏向状态,则会用CAS指令,将
mark word
中的thread id由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁。当有其他线程尝试获得锁时,就需要等到
safe point
时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。safe point
这个词我们在GC中经常会提到,其代表了一个状态,在该状态下所有线程都是暂停的(大概这么个意思)
偏向锁为什么要升级:
我当时在想如果不存在重量、轻量锁,无论线程多少个都使用偏向锁来做可以吗?我觉得是可以的。但是你想如果当其他线程想要获取的时候,就需要等到safe point,而等待是不是就要通过挂起或者自旋呢?那么到底是使用自旋还是挂起呢?当然是先考虑自旋了。所以偏向锁就变成了轻量锁,轻量锁不适合再使用重量锁。所以不存在哪个锁代替哪个锁,只是哪个场景适用,就用哪个!
当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略(线程会在帧栈中复制一个Displaced Mark Word
为空的Lock Record
用来表示重复进入的一个次数,因为操纵的是线程私有的栈,因此不需要用到CAS指令,所以性能很高)
当使用synchronized代码块的时候,通过反编译得到字节码后可以看到代码块上有对应的monitor:
monitorenter //进入同步方法
//..........省略其他
monitorexit //退出同步方法
//省略其他.......
monitorexit //退出同步方法
synchronized代码块的时候,可以看到其字节码会将该方法标识位ACC_SYNCHRONIZED,指明位同步方法:
public synchronized void syncTask();
descriptor: ()V
//方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
//省略其他.......
当线程访问同步快时,就会先判断当前锁状态,然后做出对应的操作:
http://blog.sina.com.cn/s/blog_c038e9930102v2i0.html
https://github.com/farmerjohngit/myblog/issues/12
https://blog.csdn.net/javazejian/article/details/72828483#偏向锁
https://www.zhihu.com/question/53826114/answer/236363126