Java多线程常见面试题-第一节:锁策略CAS和Synchronized原理

Posted 我擦我擦

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java多线程常见面试题-第一节:锁策略CAS和Synchronized原理相关的知识,希望对你有一定的参考价值。

文章目录

一:常见的锁策略

synchronized是什么锁:总的来说,synchronized是一把自适应锁,它既是乐观锁(基于自旋锁实现)也是悲观锁(基于挂起等待实现);即是轻量级锁也是重量级锁;不是读写锁,是普通互斥锁;是非公平锁;是可重入锁

(1)乐观锁与悲观锁

乐观锁:预测锁冲突的概率不高,因此所做工作可以简单一些

悲观锁:预测锁冲突的概率较高,因此所做工作就要负责一些

举个例子:同学A和同学B想请教老师问题

  • 乐观锁:同学A认为“老师是比较闲的,现在去问问题,大概率有空解答”。因此同学A直接就会来找老师(没有加锁,直接访问资源)。如果老师确实比较闲,那么问题就直接解决了;如果老师比较忙,同学A不会打搅老师,会在抽空再来(虽然没加锁,但是能识别出数据访问冲突)
  • 悲观锁:同学B认为“老师是比较忙的,现在去问问题,大概率没空解答”。因此同学B会给老师先发消息询问他是否有空。得到肯定回复之后,才会真的去问问题;如果得到否定回复,那么同学A就会抽空再商定时间

乐观锁与悲观锁并不能说谁优谁劣,它们有各自的适用场景

  • 如果当前老师确实比较闲,那么使用乐观锁更合适,而使用悲观锁会让效率降低
  • 如果当前老师确实比较忙,那么使用悲观锁更合适,而使用乐观锁会让同学“白跑几趟”,耗费资源

(2)读写锁

读写锁:线程在访问数据时主要会涉及两种操作,分为,此时

  • 两个线程如果读的是一个数据,没有线程安全问题
  • 两个线程写同一个数据,存在线程安全问题
  • 一个线程读另一个线程写,存在线程安全问题

所以读写锁会把读操作和写操作区别对待

  • 读锁与读锁之间不会产生竞争
  • 写锁和写锁之间有竞争
  • 读锁和写锁之间有竞争

(3)轻量级锁和重量级锁

轻量级锁:加锁和解锁开销比较小,例如纯用户态的加锁逻辑

重量级锁:加锁和解锁开销比较大,例如进入内核态的加锁逻辑

(4)自旋锁

自旋锁:一般来说,线程在抢占锁失败之后就会进入阻塞状态,放弃CPU,需要过一段时间才能再次被调度。但其实,在大部分情况下,用不了多长时间这个锁便会释放,所以没必要立马放弃CPU,因此可以立即再尝试获取锁,无限循环,直到获取到锁为止,如下

while(抢锁(lock) == 失败)

自旋锁是一种典型的轻量级锁的实现方式

  • 优点:没有放弃CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁
  • 缺点:如果锁被其他线程持有的时间比较久,就会持续耗费CPU资源

(5)公平锁和非公平锁

公平锁:遵从先来后到原则,谁先来的谁就先获取到锁

不公平锁:不遵从先来后到原则,释放锁时任意一个线程都有可能获取到锁

(6)可重入锁和不可重入锁

(Java高级教程)第一章Java多线程基础-第一节4:synchronized关键字(监视器锁monitor lock)和volatile关键字

二:CAS

(1)CAS是什么

CAS:全称为Compare and swap,翻译过来就是比较并交换。一个CAS会涉及到如下操作,假设内存中的原数据为 D D D,旧的预期值为 A A A,需要修改的新值为 B B B

  • 比较 A A A V V V是否相等
  • 如果想当,则将 B B B吸入V
  • 返回此操作是否成功

使用伪代码可解释如下,但注意下面的操作是非原子性的,真实的CAS是依靠原子硬件指令完成的

boolean CAS(address, expectValue, swapValue)
	if(&address == expectedValue)
		&address = swapValue;
		return true;
	
	return false;

CAS可以视为是一种乐观锁,当多个线程同时对某个资源进行CAS操作时,只能有一个线程操作成功,但是并不会阻塞其他线程,因为其他线程收到的是操作失败的信号

(2)CAS应用

①:实现原子类

实现原子类:标准库中的java.util.concurrent.atomic包,里面的类都是基于这种方式实现的。以最为典型的AtomicInteger类为例,其中的getAndIncrement相当于i++操作

AtomicInteger atomicInteger = new AtomicInteger(0);
atomicInteger.getAndIncrement();

其伪代码实现如下,注释中的文字可解释基本原理

class AtomicInteger
	private int value;
	public int getAndIncrement()
		//相当于把内存中的value读到某个寄存器中
		int oldValue = value;
		//oldValue就是前面所说的新值
		while(CAS(value, oldValue, oloValue+1) != true)
			//如果相同,就设置新值
			oldValue = value;
		
	
	return oldValue;

②:实现自旋锁

实现自旋锁:基于CAS可以实现更加灵活的锁,获取到更多控制权,自旋锁伪代码如下

public class SpinLock
	//当前这把锁是谁获取到的
	private Thread owner = null;
	//通过CAS看当前锁是否被某个线程持有
	//比较owner与null,如果为null表示是无人获取,即解锁状态,此时
	//此时把当前调用lock1的线程的值设置到owner里
	//如果这个锁已经被别的线程持有,那么就自旋等待
	while(!CAS(this.owner, null, Thread.currentThread()))
	
	public void unlock()
		this.owner = null
	
	

(3)CAS缺陷:ABA问题

ABA问题:有两个线程t1t2,以及一个共享变量num,初始值为A。线程t1想要使用CAS把num的值改为C,那么就需要先读取num的值,记录到oldNum变量中,然后使用CAS判定当前num的值是否为A,如果是,则将其修改为C。但这里存在一个问题,线程t1的CAS是期望num不变就修改,但num无法保证在这个过程中没有被修改过(例如t2有可能把numA改到B然后又改回了A),所以t1线程无法区分num始终是A呢还是说它早已经历了一个变化的过程,已经不再是原来的那个num

当然,在绝大多数情况下,num反复修改并不会 带来什么问题,但也不排除一些特殊情况,例如,你有1000的存款,想要取出500,但不小心连按了两下,此时就会创建出两个线程来并发执行-500的操作

  • 在正常情况下

    • 线程1获取到当前存款为1000,期望更新为500;线程1获取到当前存款为1000,期望更新为500
    • 线程1执行扣款成功,存款此时为500;线程2阻塞
    • 轮到线程2执行时,发现当前存款为500,与CAS中读到的那个100不相同,所以失败
  • 在异常情况下

    • 线程1获取到当前存款为1000,期望更新为500;线程1获取到当前存款为1000,期望更新为500
    • 线程1执行扣款成功,存款此时为500;线程2阻塞
    • 在线程执行2之前,你的朋友正好给你转账了500,账户余额又变为了1000
    • 轮到线程2执行时,发现当前存款为1000,因此再次扣款

解决方案:给要修改的值引入版本号,在CAS比较value和oldvalue时,也要比较版本号是否符合预期。在真正修改的时候

  • 如果当前版本号和读到的版本号相同,则修改数据,然后版本号+1
  • 如果当前版本号高于读到的版本号,则认为数据已经被修改过了,操作失败

三:Synchronized原理

Synchronized加锁过程:JVM将Synchronized分为了无锁、偏向锁、轻量级锁重量级锁这4种状态,并根据具体情况,进行升级。所以它是一把自适应锁

  • 如果当前场景中,锁竞争不激烈,则会以轻量级锁进行工作,保证线程能够第一时间拿到锁
  • 如果当前场景中,锁竞争激烈,则会以重量级锁进行工作,使线程挂起等待
Created with Raphaël 2.3.0 Synchronized 无锁 偏向锁 自旋锁 重量级锁

偏向锁第一个尝试加锁的线程,优先进入偏向锁状态。偏向锁不是真的加锁,而是给对象头中做一个偏向锁标记,用于记录这个锁属于哪个线程

  • 如果后续没有其他线程来竞争这个锁:那么自然而然避免了加锁解锁的开销
  • 如果后续有其他线程来竞争这个锁:那么就很容易能判断出当前申请锁的是不是之前记录的那个线程,然后解除偏向锁状态,进入一般的轻量级锁状态

所以偏向锁本质就是延迟加锁,能不加锁就不加锁,从而避免不必要的加锁解锁开销

轻量级锁:随着其他线程加入竞争,偏向锁状态解除,进入轻量级状态(自适应的自旋锁),此处自旋锁就是通过CAS 来实现的

  • 通过CAS检查并更新一块内存
  • 如果更新成功则认为加锁成功
  • 如果更新失败则认为锁被占用,于是自旋等待

重量级锁:如果竞争进一步加剧,自旋不能快速获取到锁状态,就会膨胀为重量级锁,此处重量级锁是通过内核中的mutex实现的

  • 执行加锁操作,先进入内核态
  • 在内核态判定当前锁是否已经被占用
  • 如果该锁没有占用,则加锁成功,并切换回用户态
  • 如果该锁被占用,则加锁失败。此时线程进入锁的等待队列,挂起等待被操作系统唤醒
  • 等待这个锁被其他线程释放了,操作系统也想起了这个挂起的线程,于是唤醒
    这个线程,尝试重新获取锁

以上是关于Java多线程常见面试题-第一节:锁策略CAS和Synchronized原理的主要内容,如果未能解决你的问题,请参考以下文章

Java多线程常见面试题

Java常见面试题(第二弹)

Java常见面试题(第六弹):分布式锁的实现方式有哪三种?

Java 并发常见面试题总结(下)

java 关于锁常见面试题

敲黑板!Java多线程常见面试题!!