Java多线程-锁机制(volatile、synchronized、CAS)

并发编程的三大特性:可见性、原子性、有序性
volatile保证可见性与部分有序性,但是不保证原子性,保证原子性需要借助synchronized这样的锁机制

volatile(最底层:lock add)

●保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。
●禁止进行指令重排序

volatile的可见性

Java内存模型 JMM

Java内存模型,是java虚拟机规范中所定义的⼀种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别( 注意这个跟JVM完全不是⼀个东⻄))
描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量,存储到内存和从内存中读取变量这样的底层细节。
在这里插入图片描述JMM有以下规定:
所有的共享变量都存储于主内存,这⾥所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
每⼀个线程还存在⾃⼰的⼯作内存,线程的⼯作内存,保留了被线程使⽤的变量的⼯作副本。
线程对变量的所有的操作(读,取)都必须在⼯作内存中完成,⽽不能直接读写主内存中的变量 。
不同线程之间也不能直接访问对⽅⼯作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

正是因为这样的机制,才导致了可⻅性问题的存在

volatile可⻅性是有指令原⼦性保证的,在jmm中定义了8类原⼦性指令,⽐如write,store,read,load。⽽volatile就要求write-store,load-read成为⼀个原⼦性操作,这样⼦可以确保在读取的时候都是从主内存读⼊,写⼊的时候会同步到主内存中(准确来说也是内存屏障)

JMM缓存不一致问题

●锁总线
CPU从主内存读取数据到高速缓存,会在总线对这个数据加锁,这样其他CPU就没办法去读写这个数据,直到这个CPU使用完数据释放锁之后,其他CPU才可使用(效率过低)
●MESI缓存一致性协议
多个CPU从主内存读取同一个数据到各自的高速缓存中,当其中某个CPU修改了缓存里面的数据,该数据就会马士同步回主内存,其他CPU通过总线嗅探机制可以感知到数据的变化从而将自己缓存的数据设为失效并且重新读取最新数据
嗅探
每个处理器通过嗅探在总线上传播的数据来检查⾃⼰缓存的值是不是过期了,当处理器发现⾃⼰缓存⾏对应的内存地址被修改,就会将当前处理器的缓存⾏设置成⽆效状态,当处理器对这个数据进⾏修改操作的时候,会重新从系统内存中把数据读到处理器缓存⾥。
总线⻛暴
由于Volatile的MESI缓存⼀致性协议,需要不断的从主内存嗅探和cas不断循环,⽆效交互会导致总线带宽达到峰值。
所以不要⼤量使⽤Volatile,⾄于什么时候去使⽤Volatile什么时候使⽤锁,根据场景区分。

volatile缓存可见性实现原理(最底层lock add)
底层实现主要是通过汇编lock前缀指令(lock add),它会锁定这块内存区域的缓存(缓存行锁定)并写回到主内存。
✓会将当前处理器缓存行的数据立即写回到系统内存
✓这个写回到内存的操作会引起其他CPU里缓存了该内存地址的数据无效(MESI缓存一致性协议

禁⽌指令重排序

什么是重排序?
为了提⾼性能,编译器和处理器常常会对既定的代码执⾏顺序进⾏指令重排序。

源代码–>编译器优化的重排–> 指令并行也可能会重排–> 内存系统也会重排—> 执行

⼀般重排序可以分为如下三种:
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执⾏顺序;
指令级并⾏的重排序。现代处理器采⽤了指令级并⾏技术来将多条指令重叠执⾏。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执⾏顺序;
内存系统的重排序。由于处理器使⽤缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执⾏的。

as-if-serial有序性的保障
不管怎么重排序,单线程下的执⾏结果不能被改变。
编译器、runtime和处理器都必须遵守as-if-serial语义。

volatile如何保证线程间可⻅和避免指令重排
volatile可⻅性是有指令原⼦性保证的,在jmm中定义了8类原⼦性指令,⽐如write,store,read,load。⽽volatile就要求write-store,load-read成为⼀个原⼦性操作,这样⼦可以确保在读取的时候都是从主内存读⼊,写⼊的时候会同步到主内存中(准确来说也是内存屏障),指令重排则是由内存屏障来保证的,由两个内存屏障:
✓⼀个是编译器屏障:阻⽌编译器重排,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执⾏。
✓第⼆个是cpu屏障:sfence保证写⼊,lfence保证读取,lock类似于锁的⽅式。java多执⾏了⼀个“load addl $0x0, (%esp)”操作,这个操作相当于⼀个lock指令,就是增加⼀个完全的内存屏障指令。在这里插入图片描述需要注意的是:volatile写是在前⾯和后⾯分别插⼊内存屏障,⽽volatile读操作是在后⾯插⼊两个内存屏障。
在这里插入图片描述在这里插入图片描述✓字节码层面:ACC_VOLATILE
✓jvm层面
在这里插入图片描述在这里插入图片描述happens-before
如果⼀个操作执⾏的结果需要对另⼀个操作可⻅,那么这两个操作之间必须存在happens-before关系。
volatile域规则:对⼀个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。
如果现在我的变了falg变成了false,那么后⾯的那个操作,⼀定要知道我变了。
聊了这么多,我们要知道Volatile是没办法保证原⼦性的,⼀定要保证原⼦性,可以使⽤其他⽅法。

为啥要双重检查?如果不⽤Volatile会怎么样?
对象实际上创建对象要进过如下⼏个步骤:
✓1.分配内存空间。
✓2.调⽤构造器,初始化实例。
✓3.返回地址给引⽤
但由于JVM指令重排序的特性,执行顺序有可能变成 1->3->2,那有可能构造函数在对象初始化完成前就赋值完成了,在内存⾥⾯开辟了⼀⽚存储区域后直接返回内存的引⽤,这个时候还没真正的初始化完对象,会导致一个线程获得还没有初始化的实例。。
在这里插入图片描述

什么叫保证部分有序性?
当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

x=2    //语句1
y=0    //语句2
flag=true   //语句3
x=4    //语句4
y=-1    //语句5

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
使用 Volatile 一般用于 状态标记量 和 单例模式的双检锁

volatile与synchronized的区别
volatile只能修饰实例变量和类变量,⽽synchronized可以修饰⽅法,以及代码块。
volatile保证数据的可⻅性,但是不保证原⼦性(多线程进⾏写操作,不保证线程安全);⽽synchronized是⼀种排他(互斥)的机制。 volatile⽤于禁⽌指令重排序:可以解决单例双重检查对象初始化代码执⾏乱序问题。
volatile可以看做是轻量版的synchronized,volatile不保证原⼦性,但是如果是对⼀个共享变量进⾏多个线程的赋值,⽽没有其他的操作,那么就可以⽤volatile来代替synchronized,因为赋值本身是有原⼦性的,⽽volatile⼜保证了可⻅性,所以就可以保证线程安全了。
Volatile总结

  1. volatile修饰符适⽤于以下场景:某个属性被多个线程共享,其中有⼀个线程修改了此属性,其他线程可以⽴即得到修改后的值,⽐如booleanflag;或者作为触发器,实现轻量级同步。
  2. volatile属性的读写操作都是⽆锁的,它不能替代synchronized,因为它没有提供原⼦性和互斥性。因为⽆锁,不需要花费时间在获取锁和释放锁_上,所以说它是低成本的。
  3. volatile只能作⽤于属性,我们⽤volatile修饰属性,这样compilers就不会对这个属性做指令重排序。
  4. volatile提供了可⻅性,任何⼀个线程对其的修改将⽴⻢对其他线程可⻅,volatile属性不会被线程缓
    存,始终从主 存中读取。
  5. volatile提供了happens-before保证,对volatile变量v的写⼊happens-before所有其他线程后续对v的读操作。
  6. volatile可以使得long和double的赋值是原⼦的。
  7. volatile可以在单例双重检查中实现可⻅性和禁⽌指令重排序,从⽽保证安全性。

volatile扩展
●原子操作类AtomicInteger
对于a++的操作,其实可以分解为3个步骤。

(1)从主存中读取a的值

(2)对a进行加1操作

(3)把a重新刷新到主存

这三个步骤在单线程中一点问题都没有,但是到了多线程就出现了问题了。比如说有的线程已经把a进行了加1操作,但是还没来得及重新刷入到主存,其他的线程就重新读取了旧值。因为才造成了错误。如何去解决呢?方法当然很多,但是为了和我们今天的主题对应上,很自然的联想到使用AtomicInteger

●Unsafe.compareAndSwapInt()方法,即Unsafe类的CAS操作
CAS 即比较并替换,实现并发算法时常用到的一种技术。CAS操作包含三个操作数——内存位置、预期原值及新值。执行CAS操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。

●在高并发情况下,LongAdder(累加器)比AtomicLong原子操作效率更高
在高度并发竞争情形下,AtomicLong每次进行add都需要flush和refresh(这一块涉及到java内存模型中的工作内存和主内存的,所有变量操作只能在工作内存中进行,然后写回主内存,其它线程再次读取新值),每次add()都需要同步,在高并发时会有比较多冲突,比较耗时导致效率低;而LongAdder中每个线程会维护自己的一个计数器,在最后执行LongAdder.sum()方法时候才需要同步,把所有计数器全部加起来,不需要flush和refresh操作。

synchronized原子性

在这里插入图片描述 synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

另外,在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized 效率低的原因。庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

synchronized三种使用方式
✓修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
✓修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
✓修饰代码块: 指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

总结: synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因 为JVM中,字符串常量池具有缓存功能!

synchronized 关键字的底层原理

synchronized 关键字底层原理属于 JVM 层面
① synchronized 同步语句块的情况–同步代码

public class SynchronizedDemo {
 public void method() {
  synchronized (this) { 
  System.out.println("synchronized 代码块");
      }
  } 
}

在这里插入图片描述

synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权.当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

②synchronized修饰方法的的情况–同步方法

public class SynchronizedDemo2 { 
public synchronized void method() {
 System.out.println("synchronized 方法");
  }
}

在这里插入图片描述synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

ACC_SYNCHRONIZED会去隐式调⽤刚才的两个指令:monitorenter和monitorexit。所以归根究底,还是monitor对象的争夺。

CAS(Compare And Swap ⽐较并且替换)

CAS是乐观锁的⼀种实现⽅式,是⼀种轻量级锁,JUC 中很多⼯具类的实现就是基于 CAS 的。
过程:内存值V,旧的预期值A,要修改的新值B,当A=V时,将内存值修改为B,否则什么都不做;
在这里插入图片描述线程在读取数据时不进⾏加锁,在准备写回数据时,先去查询原值,操作的时候⽐较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执⾏读取流程。
在这里插入图片描述
CAS问题
✓ABA问题(狸猫换太子)–>版本号时间戳
在这里插入图片描述在cas(1,2)还没完成之前,cas(1,3)与cas(3,1)已经完成,但cas(1,2)看到A的值还是1,不知道它已经经历过值为3。(如:分手复合中间她还有过一次伤心的经历)

✓循环时间⻓开销⼤的问题:
是因为CAS操作⻓时间不成功的话,会导致⼀直⾃旋,相当于死循环了,CPU的压⼒会很⼤。

✓只能保证⼀个共享变量的原⼦操作:
CAS操作单个共享变量的时候可以保证原⼦的操作,多个变量就不⾏了,JDK 5之后 AtomicReference可以⽤来保证对象之间的原⼦性,就可以把多个对象放⼊CAS中操作。

CAS在java中的应⽤:
(1):Atomic系列

CAS保证原子性问题
在这里插入图片描述

锁的四种状态–锁升级
在这里插入图片描述无锁(new)->偏向锁->轻量级锁->重量级锁
●无锁(new)
●偏向锁 会偏向第⼀个访问锁的线程,当⼀个线程访问同步代码块获得锁时,会在对象头和栈帧记录⾥存储锁偏向的线程ID,当这个线程再次进⼊同步代码块时,就不需要CAS操作来加锁了,只要测试⼀下对象头⾥是否存储着指向当前线程的偏向锁 如果偏向锁未启动,new出的对象是普通对象(即⽆锁,有稍微竞争会成轻量级锁),如果启动,new出的对象是匿名偏向(偏向锁) 对象头主要包括两部分数据:Mark Word(标记字段, 存储对象⾃身的运⾏时数据)、class Pointer(类型指针, 是对象指向它的类元数据的指针)
●轻量级锁(⾃旋锁)–忙等待
lock cmpxchg,cmpxchg作用就是CAS这个函数的作用,比较并交换操作数,这就是CAS原子操作,轻量级锁是通过CAS来避免进⼊开销较⼤的互斥操作
(1):在把线程进⾏阻塞操作之前先让线程⾃旋等待⼀段时间,可能在等待期间其他线程已经 解锁,这时就⽆需再让线程执⾏阻塞操作,避免了⽤户态到内核态的切换。(⾃适应⾃旋时间为⼀个线程上下⽂切换的时间)
(2):在⽤⾃旋锁时有可能造成死锁,当递归调⽤时有可能造成死锁
(3):⾃旋锁底层是通过指向线程栈中Lock Record的指针来实现的
轻量级锁与偏向锁的区别
(1):轻量级锁是通过CAS来避免进⼊开销较⼤的互斥操作
(2):偏向锁是在⽆竞争场景下完全消除同步,连CAS也不执⾏
⾃旋锁升级到重量级锁条件
(1):某线程⾃旋次数超过10次;
(2):等待的⾃旋线程超过了系统core数的⼀半;
●重量级锁–等待队列–由操作系统完成的
在这里插入图片描述Atomic::cmpxchg_ptr,Atomic::inc_ptr等内核函数,对应
的线程就是park()和upark()。操作系统完成的这个操作涉及⽤户态和内核态的转换了,这种切换是很耗资源的(一次80中断操作)。
在这里插入图片描述
公平锁与分公平锁
(1):公平锁指在分配锁前检查是否有线程在排队等待获取该锁,优先分配排队时间最⻓的线程,⾮公平直接尝试获取锁
(2):公平锁需多维护⼀个锁线程队列,效率低;默认⾮公平
独占锁与共享锁
(1):ReentrantLock为独占锁(悲观加锁策略) (2):ReentrantReadWriteLock中读锁为共享锁
(3): JDK1.8 邮戳锁(StampedLock), 不可重⼊锁 读的过程中也允许获取写锁后写⼊!这样⼀来,我们读的数据就可能不⼀致,所以,需要⼀点额外的代码来判断读的过程中是否有写⼊,这种读锁是⼀种乐观锁, 乐观锁的并发效率更⾼,但⼀旦有⼩概率的写⼊导致读取的数据不⼀致,需要能检测出来,再读⼀遍就⾏

⽤户态和内核态
Linux系统的体系结构,分为⽤户空间(应⽤程序的活动空间)和内核。
我们所有的程序都在⽤户空间运⾏,进⼊⽤户运⾏状态也就是(⽤户态),但是很多操作可能涉及内核运⾏,⽐我I/O,我们就会进⼊内核运⾏状态(内核态)。

⽤synchronized还是Lock呢?

我们先看看他们的区别:
synchronized是关键字,是JVM层⾯的底层啥都帮我们做了,⽽Lock是⼀个接⼝,是JDK层⾯的有丰富的API。
✓synchronized会⾃动释放锁,⽽Lock必须⼿动释放锁。
✓synchronized是不可中断的,Lock可以中断也可以不中断。
✓通过Lock可以知道线程有没有拿到锁,⽽synchronized不能。
✓synchronized能锁住⽅法和代码块,⽽Lock只能锁住代码块。
✓Lock可以使⽤读锁提⾼多线程读效率。
✓synchronized是⾮公平锁,ReentrantLock可以控制是否是公平锁。
✓两者⼀个是JDK层⾯的⼀个是JVM层⾯的,我觉得最⼤的区别其实在,我们是否需要丰富的api,还有⼀个我们的场景。
在这里插入图片描述
⽐如我现在是滴滴,我早上有打⻋⾼峰,我代码使⽤了⼤量的synchronized,有什么问题?锁升级过程是不可逆的,过了⾼峰我们还是重量级的锁,那效率是不是⼤打折扣了?这个时候你⽤Lock是不是很好?

Java中synchronized 和 ReentrantLock 有什么不同?
●相似点:
这两种同步方式有很多相似之处,它们都是加锁方式同步,而且都是阻塞式的同步,也就是说当如果一个线程获得了对象锁,进入了同步块,其他访问该同步块的线程都必须阻塞在同步块外面等待,而进行线程阻塞和唤醒的代价是比较高的.
●区别:
这两种方式最大区别就是对于Synchronized来说,它是java语言的关键字,是原生语法层面的互斥,需要jvm实现。而ReentrantLock它是JDK 1.5之后提供的API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成。
Synchronized进过编译,会在同步块的前后分别形成monitorenter和monitorexit这个两个字节码指令。在执行monitorenter指令时,首先要尝试获取对象锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象锁,把锁的计算器加1,相应的,在执行monitorexit指令时会将锁计算器就减1,当计算器为0时,锁就被释放了。如果获取对象锁失败,那当前线程就要阻塞,直到对象锁被另一个线程释放为止。
由于ReentrantLock是java.util.concurrent包下提供的一套互斥锁,相比Synchronized,ReentrantLock类提供了一些高级功能,主要有以下3项:
1.等待可中断,持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,这相当于Synchronized来说可以避免出现死锁的情况。
2.公平锁,多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁,Synchronized锁非公平锁,ReentrantLock默认的构造函数是创建的非公平锁,可以通过参数true设为公平锁,但公平锁表现的性能不是很好。
3.锁绑定多个条件,一个ReentrantLock对象可以同时绑定对个对象

SynchronizedMap和ConcurrentHashMap有什么区别?
SynchronizedMap()和Hashtable一样,实现上在调用map所有方法时,都对整个map进行同步。而ConcurrentHashMap的实现却更加精细,它对map中的所有桶加了锁。所以,只要有一个线程访问map,其他线程就无法进入map,而如果一个线程在访问ConcurrentHashMap某个桶时,其他线程,仍然可以对map执行某些操作。
所以,ConcurrentHashMap在性能以及安全性方面,明显比Collections.synchronizedMap()更加有优势。同时,同步操作精确控制到桶,这样,即使在遍历map时,如果其他线程试图对map进行数据修改,也不会抛出ConcurrentModificationException。

猜你喜欢

转载自blog.csdn.net/qq_44961149/article/details/108448213