并发学习
2022-06-16  学习

并发编程里的知识点

  1. volatile

是jvm提供的轻量级的同步机制:保证可见性、不保证原子性、禁止指令重排序

可见性:一个线程修改了主内存的值,要通知其他线程

  1. synchronized

普通同步方法,锁是当前实例对象;静态同步方法,锁是当前类的Class对象;同步方法块:锁是括号里的对象。

  • 同步代码:通过moniterenter、moniterexit 关联到到一个monitor对象,进入时设置Owner为当前线程,计数+1、退出-1。除了正常出口的 monitorexit,还在异常处理代码里插入了 monitorexit。
  • 实例方法:隐式调用moniterenter、moniterexit(ACC_SYNCHRONIZED)
  • 静态方法:隐式调用moniterenter、moniterexit

每一个对象都关联一个monitor,执行monitorenter时,会尝试获得对象对应的monitor的所有权,即尝试获得对象的锁。

在 HotSpot 虚拟机中,对象在内存中的布局分为三块区域:对象头,实例数据和对齐填充。对象头中包含两部分:MarkWord 和 类型指针。如果是数组对象的话,对象头还有一部分是存储数组的长度。多线程下 synchronized 的加锁就是对同一个对象的对象头中的 MarkWord 中的变量进行CAS操作。

mariword

epoch: 偏向时间戳后面再查查资料

如果当前锁已偏向其他线程||epoch值过期||class偏向模式关闭||获取偏向锁的过程中存在并发冲突,都会进入到InterpreterRuntime::monitorenter方法, 在该方法中会进行偏向锁撤销和升级。

只有匿名偏向的对象才能进入偏向锁模式。偏向锁是延时初始化的,默认是4000ms。初始化后会将所有加载的Klass的prototype header修改为匿名偏向样式。当创建一个对象时,会通过Klass的prototype_header来初始化该对象的对象头。简单的说,偏向锁初始化结束后,后续所有对象的对象头都为匿名偏向样式,在此之前创建的对象则为无锁状态。而对于无锁状态的锁对象,如果有竞争,会直接进入到轻量级锁。这也是为什么JVM启动前4秒对象会直接进入到轻量级锁的原因。

为什么需要延迟初始化?

JVM启动时必不可免会有大量sync的操作,而偏向锁并不是都有利。如果开启了偏向锁,会发生大量锁撤销和锁升级操作,大大降低JVM启动效率。

因此,我们可以明确地说,只有锁对象处于匿名偏向状态,线程才能拿到到我们通常意义上的偏向锁。而处于无锁状态的锁对象,只能进入到轻量级锁状态。

无锁状态只能升级为轻量级锁,匿名偏向状态才能进入到偏向锁

3.偏向锁并不都有利,其适用于单个线程重入的场景,原因为:偏向锁的撤销需要进入safepoint,开销较大。需要进入safepoint是由于,偏向锁的撤销需要对锁对象的lock record进行操作,而lock record要到每个线程的栈帧中遍历寻找。在非safepoint,栈帧是动态的,会引入更多的问题。目前看来,偏向锁存在的价值是为历史遗留的Collection类如Vector和HashTable等做优化,迟早药丸。Java 15中默认不开启。
4.执行Object类的hashcode方法,偏向锁撤销并且锁会膨胀为轻量级锁或者重量锁。执行Object类的wait/notify/notifyall方法,偏向锁撤销并膨胀成重量级锁。
5.轻量级锁适用于两个线程的交替执行场景:线程A进入轻量级锁,退出同步代码块并释放锁,会将锁对象恢复为无锁状态;线程B再进入锁,发现为无锁状态,会cas尝试获取该锁对象的轻量级锁。如果有竞争,则直接膨胀为重量级锁,没有自旋操作,详情看10。

Java锁与线程的那些事

这篇写得太好了,后面一定好好看看

  1. volatile

原理:首先我们要了解线程在java内存里面执行的原理,每个线程获取到CPU的时钟区间之后,会从ready状态->running状态,在x86处理器下,每个线程在执行的时候,不会直接读取主内存,而是会在每个CPU的高速缓存里面读取数据,每次CPU在执行线程的时候,会将需要的数据从主内存读取到高速缓存中,而在多核CPU的情况下,如果一个CPU进行了计算,然而其他CPU里面的缓存数据还是旧的,那么就会导致计算出错(脏数据)的情况,为了避免这种情况,保证多个CPU之间的高速缓存是一致的,OS里面会有一个缓存一致性协议,volatile就是通过OS的缓存一致性策略来保持共享变量在多个线程之间的可见性。

缓存一致性:每个CPU会在总线上面有一个嗅探器,当一个CPU将高速缓存的内容写到主内存时候,每个CPU会去查看自己缓存里面的缓存行对应的内存地址的值是否被修改了,如果发现被修改了,会将缓存里面的数据设为无效,当处理器要对自身告诉缓存里面的这个数据进行修改,会强制重新从系统主内存读取数据进来之后再去修改(详细可参考intel的mesi协议:http://blog.csdn.net/muxiqingyang/article/details/6615199)。

局限性:由于volatile只是保持了共享变量的可见性,当多线程并发的时候,多个线程分别分配到CPU中,比如执行x++操作,我们都知道实际上x++ <=> x=x+1,那么x++不是一个原子操作而是一个两步的操作,当对共享变量使用volatile之后,在CPU1里面一个线程进行了+1操作,并将数据写回到主内存时候,根据缓存一致性策略,会将各个其他CPU高速缓存里面的缓存行设为无效,然而当此时另一个线程已经完成了从CPU告诉缓存段读取数据到变量的操作,此时变量的值已经在jvm的栈里面,虽然CPU2里面的缓存段已经失效了,但是在并发情况下,还是可能会出现数据丢失的情况,不能保证并发情况下对共享变量的访问。

使用场景

(1)对变量的写操作不依赖于当前值。

(2)该变量没有包含在具有其他变量的不变式中。

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

所以可以看出,实际上volatile作为只保证可见性的并发策略,只适用于独立的不依赖于当前值的变量,一般来说是只能适合于Boolean变量并且是独立的与其他互不相关的Boolean变量,当然自从jdk1.5之后,java引进了CAS机制来保证volatile的原子性。

volatile适合一个线程写,多个线程读

CAS比synchronized快

(1)CAS是一个硬件指令,通过硬件层次去保证原子性,比synchronized在jvm层次通过一个监听者作为锁来保证原子性更快

(2)OS里面的LOCK指令分为两种锁:

1.一种是总线锁,当LOCK指令锁住的是总线的时候,那么每一刻只有一个CPU能够访问到总线,那样就保证了原子性的操作,但是由于同一时刻只有一个CPU,就是单线程能访问到总线,但因为是硬件上层次的锁,所以性能还是优于synchronized;

2.另外一种是缓存锁,当cmpxchg指令要操作的内存能完全保存在一个缓存行里面的时候,CPU高速缓存里面也完全缓存了这个缓存行,当要对缓存行进行写操作之前,根据缓存一致性策略会将缓存行修改为MESI里面的E(Exclusive)状态,当缓存行处于这个状态的时候,其他CPU里面不能访问这个缓存行的数据,就是说此时这个缓存行是被锁定独占的,那么CAS就会就直接执行cmpxchg指令而不去发出LOCK指令到总线,因为是独占的占有这个缓存行,所以也是一个原子性的操作。而因为缓存行层次上的锁更具有并发性和锁的时间更短,所以性能上比synchronized要快的多。

所以当同步锁的性能还不是系统性能瓶颈的时候,可以先考虑使用同步锁synchronized和lock,但是当同步锁的性能已经是系统瓶颈,那就要开始考虑使用CAS+volatile的非阻塞乐观锁的方式来降低同步锁带来的阻塞性能的问题

https://tech.youzan.com/javasuo-yu-xian-cheng-de-na-xie-shi/

https://www.jianshu.com/p/cd4744d799e4

https://www.cnblogs.com/wuqinglong/p/9945618.html