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操作都不做了
相关参数:
默认-XX:+UseBiasedLocking=true
-XX:-UseBiasedLocking=false关闭偏向锁 ,那么默认会进入轻量级锁状态 (在存在大量锁对象的创建并高度并发的环境下禁用偏向锁能够带来一定的性能优化)
应用程序启动几秒钟之后才激活
-XX:BiasedLockingStartupDelay = 0关闭延迟
如果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里的。
轻量级锁
当有两个或两个以上线程访问同一个对象时,偏向锁就不行了,此时就会升级到轻量级锁,其实就是乐观锁。
修改Object mark word轻量级锁指针作用:告诉其他线程,该object monitor已被占用
owner指向object mark word作用:在下面的运行过程中,识别哪个对象被锁住了。
然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下
如果对象的Mark Word仍然指向着线程的锁记录,通过CAS将lock record中的Object原MarkValue赋还给Object的MarkValue,若替换成功,则解锁完成,若替换不成功,表示在当前线程持有锁的这段时间内,其他线程也竞争过锁,并且发生了锁升级为重量锁,这时需要去Monitor的等待队列中唤醒一个线程去重新竞争锁。
当发生锁重入时,会对一个Object在线程栈中生成多个lock record,每当退出一个synchronized代码块便解锁一次,并弹出一个lock record。
lock\unlock流程图如下两个线程同时争夺锁,导致锁膨胀的流程图
重量级锁
如果并发访问的线程很多,并且每个线程都要锁很长时间。此时轻量级锁就会不断的自旋检查,造成CPU被占满。当轻量级锁的自选次数超过阈值时,此时就会升级到重量级锁,重量级锁会将等待锁的线程转入阻塞状态。虽然需要用户态和内核态的切换,但是避免了死循环自旋,大大降低了cpu的使用。
重量级锁实用Monitor管程对象实现,而管程使用操作系统的mutex内核对象实现。既有等待队列实现加锁互斥功能,又有阻塞队列实现同步控制功能。
当多个线程同时请求某个对象监视器时,对象监视器会设置几种状态用来区分请求的线程:
- Contention List:所有请求锁的线程将被首先放置到该竞争队列
- Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List
- Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set
- OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
- Owner:获得锁的线程称为Owner
- !Owner:释放锁的线程
ContentionList并不是一个真正的Queue,而只是一个虚拟队列,原因在于ContentionList是由Node及其next指针逻辑构成,并不存在一个Queue的数据结构。ContentionList是一个先进先出(LIFO)的队列,每次新加入Node时都会在队头进行,通过CAS改变第一个节点的的指针为新增节点,同时设置新增节点的next指向后续节点,而取得操作则发生在队尾。显然,该结构其实是个Lock-Free的队列。
因为只有Owner线程才能从队尾取元素,也即线程出列操作无争用,当然也就避免了CAS的ABA问题。
EntryList
动态编译实现锁消除 / 锁粗化
CAS compareAndSet
它是使用硬件特性来保证原子性。在底层调用unsafe.compareAndSwapObject方法实现原子操作,当然该方法内部也是一个硬件加锁的操作。流程就是该方法会指定要修改的对象、对象的现有值和修改值。如果对象实际值跟指定现有值一致,就用修改值替换现有值。如果正在被其他线程加锁就返回修改失败。然后会有自旋检查,循环执行该原子操作,直到获取锁并执行成功。如果不限制执行次数,可能会造成死循环。
优点
1、不用阻塞线程,并让线程在用户态和内核态直接切换,省去切换的时间,一次阻塞和唤醒操作就需要两次切换。
缺点
1、如果锁一直被其他线程占用,并且自旋操作没有设置最大次数,就会造成死循环,造成cpu占用过高。
2、只能保证某一个对象的原子性,并不能保证几个对象或一个线程同步操作。
实现应用
1、java.util.concurrent下的很多类,比如Atomic开头的原子操作变量,ReentrantLock,都是根据CAS实现的。如下图示
使用方式
- 修饰实例方法,为当前实例加锁,进入同步方法前要获得当前实例的锁。
- 修饰静态方法,为当前类对象加锁,进入同步方法前要获得当前类对象的锁。
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁。
锁定字符串
https://www.cnblogs.com/xrq730/p/6662232.html
一般情况下,即使锁定字符串内容相同的不同字符串对象,是无法保证原子性操作的。但是如果调用字符串的方法intern方法,则会返回一个常量区的对象,并且在常量区,字符串值相同只有一个对象,就可以实现对同一个字符串对象进行加锁了。