JAVA synchronized实现原理以及其中锁优化的归纳总结

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/Thousa_Ho/article/details/77992743

在java中存在两种锁机制,分别是synchronized和Lock。Lock接口和实现类是JDK5添加的内容,而synchronized在JDK6开始提供了一系列的锁优化,下面总结一下synchronized的实现原理和涉及的一些锁优化机制

1.synchronized内部实现原理

synchronized关键字在应用层的语义是可以把任何一个非null对象作为锁,当synchronized作用在方法上时,锁住的是对象实例(this),作用在静态方法上锁住的就是对象对应的Classs实例,由于Class实例存在于永久代,因此静态方法锁相当于类的一个全局锁,当synchronized作用在一个对象实例上,锁住的就是一个代码块
ps:在HotSpot JVM中 锁被称作对象监视器

当有多个线程同时请求某个对象监视器时,对象监视器会设置几种状态来区分请求的线程:

  1. Contention List:所有请求锁的线程被首先放置在该竞争队列中
  2. Entry List:Contention List 中有机会获得锁的线程被放置到Entry List
  3. Wait Set:调用wait()方法被阻塞的线程被放置到Wait Set中
  4. OnDeck:任何一个时候只能有一个线程竞争锁 该线程称作OnDeck
  5. Owner:获得锁的线程成为Owner
  6. !Owner:释放锁的线程

转换关系如下图:

这里写图片描述

新请求锁的线程被首先加入到Contention List中,当某个拥有锁定线程(Owner状态)调用unlock之后,如果发现Entry List为空就从ContentionList中移动线程到Entry List中

Contention List和Entry List的实现方式

1.Contention List虚拟队列

Contention List并不是一个真正的Queue,而是一个虚拟队列,原因是Contention List是由Node和next指针逻辑构成,并不存在一个Queue的数据结构。Contention List是一个后进先出的队列,每次添加Node时都会在队头进行,通过CAS改变第一个节点的指针在新增节点,同时设置新增节点的next指向后继节点,而取线程操作发生在队尾。

只有Owner线程才能从队尾取元素,线程出队操作无竞争,避免CAS的ABA问题

这里写图片描述

2.Entry List

EntryList与ContentionList逻辑上同属等待队列,ContentionList会被线程并发访问,为了降低对 ContentionList队尾的争用,而建立EntryList。Owner线程在unlock时会从Contention List中迁移线程到Entry List,并会指定Entry List中某个线程(一般是第一个)为OnDeck线程。Owner线程并不是把锁传递给 OnDeck线程,只是把竞争锁的权利交给OnDeck,OnDeck线程需要重新竞争锁。这样做虽然牺牲了一定的公平性,但极大的提高了整体吞吐量,在 Hotspot中把OnDeck的选择行为称之为“竞争切换”

OnDeck线程在获得锁后变成Owner线程,无法获得锁则会继续留在Entry List中,但是在Entry List中的位置不会发生改变。如果Owner线程被wait方法阻塞,就转移到WaitSet队列;如果在某个时刻被notify/notifyAll唤醒就会再次被转移到Entry List中

2.sychronized中的锁机制

介绍锁机制之前先介绍看一下同步的原理

同步的原理

JVM规范规定JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。

代码块同步是使用monitorenter和monitorexit指令实现,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明,但是方法的同步同样可以使用这两个指令来实现。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处, JVM要保证每个monitorenter必须有对应的monitorexit与之配对。

任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

java对象头的概念

java对象的内存布局包括对象头,数据和填充数据

数组类型的对象头使用3个字宽存储,非数组类型使用2个字宽存储,一个字宽等于四字节(32位)

这里写图片描述

Java对象头里的Mark Word里默认存储对象的HashCode,分代年龄和锁标记位

这里写图片描述

在运行期间随着锁标志位的变化存储的数据也会变化

这里写图片描述

64位JVM下Mark Word大小的64位的 存储结构如下

这里写图片描述

偏向锁的锁标记位和无锁是一样的,都是01,但是有单独一位偏向标记设置是否偏向锁。

轻量级锁00,重量级锁10,GC标记11,无锁 01.

1.自旋锁 Spin Lock

处于Contention List,Entry List和Wait Set中的线程均属于阻塞状态,阻塞操作由操作系统完成(在Linux系统下通过pthread_mutex_lock函数),线程被阻塞后进入内核调度状态,这个会导致在用户态和内核态之间来回切换,严重影响锁的性能。

解决上述问题的方法就是自旋,原理是:
当发生争用时,若Owner线程能在很短的时间内释放锁,则那些正在争用线程可以稍微等等(自旋),在Owner线程释放锁之后,争用线程可能会立即获得锁,避免了系统阻塞

但是Owner运行的时间可能会超出临界值,争用线程自旋一段时间无法获得锁的话会停止自旋进入阻塞状态。
因此自旋锁对于执行时间很短的代码块有性能提高。

线程自旋的时候可以执行几次for循环,可以执行几条空的汇编指令,目的是占着CPU不放,等待获取锁的机会。
因此自旋的时间很重要,如果过长会影响整体性能,过短达不到延迟阻塞的目的。HotSpot认为最佳的时间是一个线程上下文切换的时间,但是目前只实现了通过汇编暂停集合CPU周期。

其他自旋锁的优化:

  • 如果平均负载小于CPU的个数则一直自旋
  • 如果超过CPU个数一半个线程正在自旋,则后面的线程会直接阻塞
  • 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
  • 如果CPU处于节点模式就停止自旋
  • 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
  • 自旋时会适当放弃线程优先级之间的差异

那synchronized实现何时使用了自旋锁?
答案是在线程进入ContentionList时,也即第一步操作前。
线程在进入等待队列时首先进行自旋尝试获得锁,如果不成功再进入等待队列。这对那些已经在等待队列中的线程来说,稍微显得不公平。
还有一个不公平的地方是自旋线程可能会抢占了Ready线程的锁。自旋锁由每个监视对象维护,每个监视对象一个

2.偏向锁(Biased Lock)

主要解决无竞争下的锁性能问题

按照之前HotSpot设计,每次加锁/解锁都会涉及到一些CAS操作(比如等待队列的CAS操作)。CAS操作会延迟本地调用,因此偏向锁的想法是一旦线程第一次获得了监视对象,之后让监视对象偏向这个线程,之后的多次调用可以避免CAS操作。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会尝试消除它身上的偏向锁,把锁恢复到标准的轻量级锁。

流程是这样的 偏向锁->轻量级锁->重量级锁

简单的加锁机制:

每个锁都关联一个请求计数器和一个占有她的线程,当请求计数器为0时,这个锁可以被认为是unheld的,当一个线程请求一个unheld的锁时,JVM记录锁的拥有者,并把锁的请求计数加1,如果同一个线程再次请求这个锁是,请求计数器就会加一,当线程退出synchronized块时,计数器减一。当计数器为0时,释放锁。

偏向锁的流程

在锁对象的对象头中有一个ThreadId字段,如果字段是空,第一次获取锁的时候就把自身的ThreadId写入到锁的ThreadId字段内,把锁内的是否是偏向锁状态位置设置为1。下次获取锁的时候,直接查看ThreadId是否和自身线程Id一致,如果一致就认为当前线程已经取得了锁无需再次获取锁,略过了轻量级锁和重量级锁的加锁阶段,提高了效率。

但是偏向锁也有一个问题,就是当锁有竞争关系的时候,需要解除偏向锁,使锁进入竞争的状态

这里写图片描述

对于偏向锁的抢占问题,一旦偏向锁冲突,双方都会升级会轻量级锁。之后就会进入轻量级的锁状态

这里写图片描述

偏向锁使用的是一种等到竞争出现才释放锁的机制,所以在其他线程尝试获取竞争偏向锁时,持有偏向锁的线程才会释放锁,释放锁需要等到全局安全点(在该时间点上没有字节码在执行)

消除偏向锁的过程是:
先暂停偏向锁的线程,尝试直接切换,如果不成功,就继续运行,并且标记对象不适合偏向锁,锁升级成轻量级锁。

关闭偏向锁:
偏向锁在jdk6和7中是默认开启的,但是总是在程序启动几秒钟后才激活
可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay=0
同时也可以使用参数来关闭偏向锁-XX:-UseBiasedLocking=false

轻量级锁

加锁流程:

线程在执行同步块之前,JVM会在当前线程的栈帧中创建用于存储锁记录的空间,并把对象头中的Mark Word复制到锁记录空间中,官方称为Displaced Mark Word,然后线程尝试持有CAS把对象头中的Mark Word替换成指向锁记录的指针,如果成功,当前线程获得锁,如果失败表示有其他线程竞争锁,当前线程尝试使用自旋来获取锁,获取失败就升级成重量级锁。

解锁流程:

会使用CAS操作来把Displaced Mark Word替换回对象头,如果成功表示没有竞争发生,如果失败,升级成重量级锁。

这里写图片描述

锁的优缺点比较

偏向锁:
优点:加锁解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距
缺点:如果线程间存在锁竞争,会带来额外的锁撤销的消耗
适用场景:适合只有一个线程访问同步快的场景

轻量级锁:
优点:竞争的线程不会阻塞,提高程序的响应速度
缺点:如果始终得不到锁竞争的线程使用自旋会消耗CPU
适用场景:追求响应时间 同步块执行速度非常快

重量级锁:
优点:线程竞争不适用自旋 不会消耗CPU
缺点:线程阻塞 响应时间缓慢
适用场景:追求吞吐量 同步块执行时间长

ps:偏向锁和轻量级锁理念上的区别:

  • 轻量级锁:在无竞争的情况下使用CAS操作去消除同步使用的互斥量
  • 偏向锁:在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了

这里写图片描述

总结

在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、
偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。

  • 锁粗化(Lock Coarsening) 减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁
  • 锁消除(Lock Elimination) 通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步快以外被其他线程共享的数据的锁的保护,通过逃逸分析也可以在线程本地Stack上进行对象空间的分配(减少堆上上GC开销)
  • 偏向锁(Biased Locking) 是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟
  • 轻量级锁 (Lightweight Locking) 这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒
  • 适应性自旋(Adaptive Spinning) 当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁
    (mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁),进入到阻塞状态。

对于synchronized加锁的完整过程描述:

  1. 检查Mark Word里存放的是否是自身的ThreadId,如果是,表示当前线程处于偏向锁,无需加锁就可获取临界资源
  2. 如果不是自身的ThreadId,锁升级,使用CAS来进行切换,新的线程根据MarkWord里现有的ThreadId,通知之前线程暂停,之前线程把MarkWord的内容设置为空
  3. 两个线程都把对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作把共享对象的MarkWord的内容修改为自己新建的记录空间的地址的方式竞争MarkWord
  4. 成功执行CAS的获得资源,失败的进入自旋
  5. 自旋在线程在自旋过程中,成功获得资源则整个状态依然处于轻量级的锁状态
  6. 如果自旋失败进入重量级锁的状态,自旋的线程进行阻塞,等待之前的线程完成并唤醒自己

最后说一下CAS操作:

CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

下面是sun.misc.Unsafe类的compareAndSwapInt()方法的源代码:

public final native boolean compareAndSwapInt(Object o, long offset,                                              int expected,int x);

可以看到这是个本地方法调用。这个本地方法在openjdk中依次调用的c++代码为:unsafe.cpp,atomic.cpp和atomicwindowsx86.inline.hpp。

对于32位/64位的操作应该是原子的:

奔腾6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器不能自动保证其原子性,比如跨总线宽度,跨多个缓存行,跨页表的访问。但是处理器提供总线锁定缓存锁定两个机制来保证复杂内存操作的原子性。

缺点主要是ABA问题,循环时间开销大和只能保证一个共享变量

1.ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。

ABA问题的解决思路就是使用版本号
在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A

从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。
这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,
则以原子方式将该引用和该标志的值设置为给定的更新值。

2.循环时间开销大 自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。

如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

3.只能保证一个共享变量的原子操作

解决方法:
把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。
从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,也可以把多个变量放在一个对象里来进行CAS操作。


参考文章:

http://blog.csdn.net/chen77716/article/details/6618779

http://blog.163.com/silver9886@126/blog/static/35971862201472274958280/

http://www.infoq.com/cn/articles/java-se-16-synchronized

阅读更多
想对作者说点什么?

博主推荐

换一批

没有更多推荐了,返回首页