Java并发-synchronized和cas

参考文档

CAS底层解析:https://www.cnblogs.com/Leo_wl/p/6899716.html

https://blog.csdn.net/tiandao321/article/details/80811103

各种锁类型的介绍:https://www.jianshu.com/p/e7aa0a5083fb

synchronized对比cas性能比较:https://www.jianshu.com/p/736c532869a3

synchronized对比cas使用介绍: https://www.cnblogs.com/konck/p/9393135.html

锁分类

乐观锁

读的时候不加锁,之后在写的时候才加锁。并且在写的时候,会比较当前值跟预期值是否一致,只有一致才会去执行写操作。乐观锁基本上都是由CAS来实现的。

悲观锁

读写的时候都加锁

公平锁

锁的获取遵循先进先出的原则

不公平锁

锁的获取遵循先进先出的原则

synchronized

JVM 中的同步是基于进入和退出管程(Monitor)对象实现的。每个对象实例都会有一个 Monitor,Monitor 可以和对象一起创建、销毁。Monitor 是由 ObjectMonitor 实现,而 ObjectMonitor 是由 C++ 的 ObjectMonitor.hpp 文件实现。

当多个线程同时访问一段同步代码时,多个线程会先被存放在 ContentionList集合中,处于 block 状态的线程。

正在运行的线程调用notify,会将 WaitSet 集合中一个线程放入到 _EntryList 集合;调用notifyAll则会将WaitSet 集合中所有线程放入到 _EntryList

当占用CPU的线程执行完毕,释放锁后,会将ContentionList 中的线程放入到 _EntryList 集合,然后_EntryList中的线程参与CPU竞争

当线程获取到对象的 Monitor 时,Monitor 是依靠底层操作系统的 Mutex Lock 来实现互斥的,线程申请 Mutex 成功,则持有该 Mutex,其它线程将无法获取到该 Mutex,竞争失败的线程会再次进入 ContentionList和WaitSet 被挂起。

如果线程调用 wait() 方法,就会释放当前持有的 Mutex,并且该线程会进入 WaitSet 集合中,等待下一次被唤醒。

如果当前线程顺利执行完方法,也将释放 Mutex。

看完上面的讲解,相信你对同步锁的实现原理已经有个深入的了解了。总结来说就是,同步锁在这种实现方式中,因 Monitor 是依赖于底层的操作系统实现,存在用户态与内核态之间的切换,所以增加了性能开销。

锁升级

 

入口代码如下

Handle h_obj(THREAD, obj);
  if (UseBiasedLocking) {
    // Retry fast entry if bias is revoked to avoid unnecessary inflation
    ObjectSynchronizer::fast_enter(h_obj, lock, true, CHECK);
  } else {
    ObjectSynchronizer::slow_enter(h_obj, lock, CHECK);
  }

偏向锁

UseBiasedLocking 是一个检查,因为在 JVM 启动时,我们可以指定是否开启偏斜锁。fast_enter 是我们熟悉的完整锁获取路径,slow_enter 则是绕过偏斜锁,直接进入轻量级锁获取逻辑。

偏斜锁并不适合所有应用场景,撤销操作(revoke)是比较重的行为,只有当存在较多不会真正竞争的 synchronized 块儿时,才能体现出明显改善。实践中对于偏斜锁的一直是有争议的,有人甚至认为,当你需要大量使用并发类库时,往往意味着你不需要偏斜锁。从具体选择来看,我还是建议需要在实践中进行测试,根据结果再决定是否使用。

还有一方面是,偏斜锁会延缓 JIT 预热的进程,所以很多性能测试中会显式地关闭偏斜锁,命令如下:

-XX:-UseBiasedLocking

Java对象的内存布局:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。这里我们关注对象头信息。锁存在Java对象头里。如果对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。也就是8个字节。

明白它是通过 CAS 设置 Mark Word 就完全够用了,对象头中 Mark Word 的结构,可以参考下图:

其中最后两位锁标志的含义如下,00 代表轻量级锁,01 代表无锁(或偏向锁),10 代表重量级锁,11 则跟垃圾回收算法的标记有关。

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

轻量级锁:在无竞争的情况下使用CAS操作去消除同步使用的互斥量 
偏向锁:在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了 
意义:锁偏向于第一个获得它的线程。如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
相关参数: 
默认-XX:+UseBiasedLocking=true
-XX:-UseBiasedLocking=false关闭偏向锁 ,那么默认会进入轻量级锁状态 (在存在大量锁对象的创建并高度并发的环境下禁用偏向锁能够带来一定的性能优化)
应用程序启动几秒钟之后才激活
-XX:BiasedLockingStartupDelay = 0关闭延迟
 
加锁
当锁对象第一次被线程获取的时候,虚拟机把对象头中的标志位设为“01”(就初始状态来说,是不变),即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中的偏向线程ID,并将是否偏向锁的状态位置置为1。
如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,直接检查ThreadId是否和自身线程Id一致, 如果一致,则认为当前线程已经获取了锁,虚拟机就可以不再进行任何同步操作(例如Locking、Unlocking及对Mark Word的Update等)。
当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就如上面介绍的轻量级那样执行。
偏向锁、轻量级锁的状态转化及对象Mark Word的关系如下图:  
 

偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。

总结:偏向锁是在轻量锁的基础上减少了锁重入的开销。偏向锁可以提高带有同步但无竞争的程序性能。如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。在具体情形分析下,禁止偏向锁优反而可能提升性能。

 

另外一种描述

具体来说,在线程进行加锁时,如果该锁对象支持偏向锁,那么 Java 虚拟机会通过 CAS 操作,将当前线程的地址记录在锁对象的标记字段之中,并且将标记字段的最后三位设置为 101。在接下来的运行过程中,每当有线程请求这把锁,Java 虚拟机只需判断锁对象标记字段中:最后三位是否为 101,是否包含当前线程的地址,以及 epoch 值是否和锁对象的类的 epoch 值相同。如果都满足,那么当前线程持有该偏向锁,可以直接返回。

这里的 epoch 值是一个什么概念呢?我们先从偏向锁的撤销讲起。当请求加锁的线程和锁对象标记字段保持的线程地址不匹配时(而且 epoch 值相等,如若不等,那么当前线程可以将该锁重偏向至自己),Java 虚拟机需要撤销该偏向锁。这个撤销过程非常麻烦,它要求持有偏向锁的线程到达安全点,再将偏向锁替换成轻量级锁。

如果某一类锁对象的总撤销数超过了一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRebiasThreshold,默认为 20),那么 Java 虚拟机会宣布这个类的偏向锁失效。具体的做法便是在每个类中维护一个 epoch 值,你可以理解为第几代偏向锁。当设置偏向锁时,Java 虚拟机需要将该 epoch 值复制到锁对象的标记字段中。在宣布某个类的偏向锁失效时,Java 虚拟机实则将该类的 epoch 值加 1,表示之前那一代的偏向锁已经失效。而新设置的偏向锁则需要复制新的 epoch 值。为了保证当前持有偏向锁并且已加锁的线程不至于因此丢锁,Java 虚拟机需要遍历所有线程的 Java 栈,找出该类已加锁的实例,并且将它们标记字段中的 epoch 值加 1。该操作需要所有线程处于安全点状态。

如果总撤销数超过另一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRevokeThreshold,默认值为 40),那么 Java 虚拟机会认为这个类已经不再适合偏向锁。此时,Java 虚拟机会撤销该类实例的偏向锁,并且在之后的加锁过程中直接为该类实例设置轻量级锁。

epoch共有两个作用,一个是类的所有对象撤销次数达到阈值时,会将该类的epoch内的值加一,并且也会在安全点内将已存在的对象的对象头内的epoch值加1。如果所有撤销次数又达到了另一个阈值,那么 Java 虚拟机会认为这个类已经不再适合偏向锁。此时,Java 虚拟机会撤销该类实例的偏向锁,并且在之后的加锁过程中直接为该类实例设置轻量级锁。但是类的偏向锁的撤销和禁用其实只是跟该类总的撤销次数有关,跟epoch值好像没啥关系呀?撤销次数即使体现在epoch里的。

轻量级锁

当有两个或两个以上线程访问同一个对象时,偏向锁就不行了,此时就会升级到轻量级锁,其实就是乐观锁。

加锁:
虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,(这个锁记录并不是我们通常意义上说的锁对象(包含队列的那个)),用于存储锁记录目前的Mark Word的拷贝(称为Displaced Mark Word) 
拷贝mark word的作用:为了不想在lock与unlock这种底层操作上再加同步。
修改Object mark word轻量级锁指针作用:告诉其他线程,该object monitor已被占用
owner指向object mark word作用:在下面的运行过程中,识别哪个对象被锁住了。

然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下

如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧。如果指向,说明当前线程已经拥有了这个对象的锁,也算锁申请成功,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。
解锁:

如果对象的Mark Word仍然指向着线程的锁记录,通过CAS将lock record中的Object原MarkValue赋还给Object的MarkValue,若替换成功,则解锁完成,若替换不成功,表示在当前线程持有锁的这段时间内,其他线程也竞争过锁,并且发生了锁升级为重量锁,这时需要去Monitor的等待队列中唤醒一个线程去重新竞争锁。

当发生锁重入时,会对一个Object在线程栈中生成多个lock record,每当退出一个synchronized代码块便解锁一次,并弹出一个lock record。

lock\unlock流程图如下

两个线程同时争夺锁,导致锁膨胀的流程图

 
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。 
总结: 轻量锁通过CAS检测锁冲突,在没有锁冲突的前提下,避免采用重量锁的一种优化手段。这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

重量级锁

如果并发访问的线程很多,并且每个线程都要锁很长时间。此时轻量级锁就会不断的自旋检查,造成CPU被占满。当轻量级锁的自选次数超过阈值时,此时就会升级到重量级锁,重量级锁会将等待锁的线程转入阻塞状态。虽然需要用户态和内核态的切换,但是避免了死循环自旋,大大降低了cpu的使用。

重量级锁实用Monitor管程对象实现,而管程使用操作系统的mutex内核对象实现。既有等待队列实现加锁互斥功能,又有阻塞队列实现同步控制功能。

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

  • Contention List:所有请求锁的线程将被首先放置到该竞争队列
  • Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List
  • Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set
  • OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
  • Owner:获得锁的线程称为Owner
  • !Owner:释放锁的线程
下图反映了个状态转换关系:

 

 

新请求锁的线程将首先被加入到ConetentionList中,当某个拥有锁的线程(Owner状态)调用unlock之后,如果发现EntryList为空则从ContentionList中移动线程到EntryList,下面说明下ContentionList和EntryList的实现方式:

ContentionList并不是一个真正的Queue,而只是一个虚拟队列,原因在于ContentionList是由Node及其next指针逻辑构成,并不存在一个Queue的数据结构。ContentionList是一个先进先出(LIFO)的队列,每次新加入Node时都会在队头进行,通过CAS改变第一个节点的的指针为新增节点,同时设置新增节点的next指向后续节点,而取得操作则发生在队尾。显然,该结构其实是个Lock-Free的队列。

因为只有Owner线程才能从队尾取元素,也即线程出列操作无争用,当然也就避免了CAS的ABA问题。

 

EntryList

EntryList与ContentionList逻辑上同属等待队列,ContentionList会被线程并发访问,为了降低对ContentionList队尾的争用,而建立EntryList。Owner线程在unlock时会从ContentionList中迁移线程到EntryList,并会指定EntryList中的某个线程(一般为Head)为Ready(OnDeck)线程。Owner线程并不是把锁传递给OnDeck线程,只是把竞争锁的权利交给OnDeck,OnDeck线程需要重新竞争锁。这样做虽然牺牲了一定的公平性,但极大的提高了整体吞吐量,在Hotspot中把OnDeck的选择行为称之为“竞争切换”。 
OnDeck线程获得锁后即变为owner线程,无法获得锁则会依然留在EntryList中,考虑到公平性,在EntryList中的位置不发生变化(依然在队头)。如果Owner线程被wait方法阻塞,则转移到WaitSet队列;如果在某个时刻被notify/notifyAll唤醒,则再次转移到EntryList。
 
为了尽量避免昂贵的线程阻塞、唤醒操作,Java 虚拟机会在线程进入阻塞状态之前,以及被唤醒后竞争不到锁的情况下,进入自旋状态,在处理器上空跑并且轮询锁是否被释放。如果此时锁恰好被释放了,那么当前线程便无须进入阻塞状态,而是直接获得这把锁。
也就是说即使在重量级锁的情况下,也会发生自旋操作。
自旋状态还带来另外一个副作用,那便是不公平的锁机制。处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。
 
不公平和自旋的说明
因为在轻量级或者重量级锁时,都会发生自旋操作,而自旋的线程会优先获得锁,因此都是非公平的锁。自旋都有阈值,不会等待很长时间,适用于锁占用时间较短的情况,如果每次锁占用的时间都很长,那么每次自旋后还是会进入阻塞状态,也就失去了自旋的意义。
 

动态编译实现锁消除 / 锁粗化

除了锁升级优化,Java 还使用了编译器对锁进行优化。JIT 编译器在动态编译同步块的时候,借助了一种被称为逃逸分析的技术,来判断同步块使用的锁对象是否只能够被一个线程访问,而没有被发布到其它线程。确认是的话,那么 JIT 编译器在编译这个同步块的时候不会生成 synchronized 所表示的锁的申请与释放的机器码,即消除了锁的使用。在 Java7 之后的版本就不需要手动配置了,该操作可以自动实现。锁粗化同理,就是在 JIT 编译器动态编译时,如果发现几个相邻的同步块使用的是同一个锁实例,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程“反复申请、释放同一个锁”所带来的性能开销。
减小锁粒度除了锁内部优化和编译器优化之外,我们还可以通过代码层来实现锁优化,减小锁粒度就是一种惯用的方法。当我们的锁对象是一个数组或队列时,集中竞争一个对象的话会非常激烈,锁也会升级为重量级锁。我们可以考虑将一个数组和队列对象拆成多个小对象,来降低锁竞争,提升并行度。最经典的减小锁粒度的案例就是 JDK1.8 之前实现的 ConcurrentHashMap 版本。我们知道,HashTable 是基于一个数组 + 链表实现的,所以在并发读写操作集合时,存在激烈的锁资源竞争,也因此性能会存在瓶颈。而 ConcurrentHashMap 就很很巧妙地使用了分段锁 Segment 来降低锁资源竞争。

CAS compareAndSet

它是使用硬件特性来保证原子性。在底层调用unsafe.compareAndSwapObject方法实现原子操作,当然该方法内部也是一个硬件加锁的操作。流程就是该方法会指定要修改的对象、对象的现有值和修改值。如果对象实际值跟指定现有值一致,就用修改值替换现有值。如果正在被其他线程加锁就返回修改失败。然后会有自旋检查,循环执行该原子操作,直到获取锁并执行成功。如果不限制执行次数,可能会造成死循环。

优点

1、不用阻塞线程,并让线程在用户态和内核态直接切换,省去切换的时间,一次阻塞和唤醒操作就需要两次切换。

缺点

1、如果锁一直被其他线程占用,并且自旋操作没有设置最大次数,就会造成死循环,造成cpu占用过高。

2、只能保证某一个对象的原子性,并不能保证几个对象或一个线程同步操作。

实现应用

 1、java.util.concurrent下的很多类,比如Atomic开头的原子操作变量,ReentrantLock,都是根据CAS实现的。如下图示

使用方式

  1. 修饰实例方法,为当前实例加锁,进入同步方法前要获得当前实例的锁。
  2. 修饰静态方法,为当前类对象加锁,进入同步方法前要获得当前类对象的锁。
  3. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁。

锁定字符串

https://www.cnblogs.com/xrq730/p/6662232.html

一般情况下,即使锁定字符串内容相同的不同字符串对象,是无法保证原子性操作的。但是如果调用字符串的方法intern方法,则会返回一个常量区的对象,并且在常量区,字符串值相同只有一个对象,就可以实现对同一个字符串对象进行加锁了。

 

posted on 2020-04-07 20:18  simple_孙  阅读(890)  评论(0编辑  收藏  举报

导航