常见的锁策略

常见的锁策略

定义:处理冲突的过程中,设计到不同的处理方式.

乐观锁VS悲观锁

悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁.(在加锁之前,预估当前锁冲突出现的概率比较大,因此加锁的时候就会做更多工作).

特性:加锁开销大,加锁速度更慢,但是整个过程不容易出现问题.

乐观锁

假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何做.(加锁之前预估当前出现锁冲突的概率不大,因此在加锁时不会做太多工作.)

特性:加锁开销小,加锁速度更快,但可能引入一些其他问题(消耗更多cpu资源).

重量级锁VS轻量级锁

锁的核心特性"原子性",这样的机制追根溯源是CPU这样的硬件设备提供的.

CPU提供了"原子操作指令".

操作系统基于CPU的原子指令,实现了mutex互斥锁.

JVM基于操作系统提供的互斥锁,实现了synchronized和ReentrantLock等关键字和类.

注意:synchronized并不仅仅对mutex进行封装,在synchronized内部还进行了很多其它的工作.

重量级锁

加锁机制重度依赖了OS提供的mutex

大量的内核态用户转换

很容易引发线程的调度

这两个操作,成本比较高,一旦涉及到用户态和内核态的转换,就意味着沧海桑田.

加锁的开销更大,加锁速度更慢->重量级锁,一般就是悲观锁.

轻量级锁

加锁机制尽量不适用mutex,而是尽量在用户态代码完成,实在搞不定了,再使用mutex.

少量的内核态用户态转换

不太容易引发线程调度.

加锁的开销更小,加锁的速度更快->轻量级锁,一般就是乐观锁.

注:

轻量重量是加锁之后,对结果的评价.

悲观乐观是加锁之前,对未发生的事情进行的预估.

整体来说,这两种角度,描述的是同一件事情.

自旋锁VS挂起等待锁

自旋锁

按之前的方式,线程在抢锁失败后进入阻塞状态,放弃cpu,需要过很久才能再次被调度.

但实际上,大部分情况下,虽然当前抢锁失败,但过不了多久,锁就会被释放.没必要放弃cpu.这个时候就可以使用自旋锁来解决这样的问题.

反复快速执行的过程-->自旋.

一旦其它线程释放锁,能第一时间拿到锁.使用自旋的前提就是预期锁冲突概率不大,其它线程释放了锁,就能第一时间拿到.  但如果万一当前加锁的线程特别多,自旋的意义就不大,白白浪费cpu.

自旋锁的伪代码:

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

如果获取失败,立即再尝试获取到锁,无限循环,直到获取到锁为止.第一次获取锁失败,第二次的尝试会在极短时间内到来.

一旦锁被其它线程释放,就能第一时间获取到锁.

自旋锁是轻量级锁的典型表现.

优点:没有放弃cpu,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁.

缺点:如果锁被其它线程持有的时间比较久,那么就会持续地消耗cpu资源.

挂起等待锁

挂起等待锁就是我们之讲过的wait(),notify()方法的那一部分.通常涉及到线程的同步和协调.

详细见等待和通知-CSDN博客

挂起等待锁是悲观锁的典型体现

因此可以适用于锁冲突激烈的情况

挂起等待锁是重量级锁的典型体现.

进行挂起等待时,就需要内核调度器介入了,这一块完成的操作就多了. 

优点:使线程不需要再循环中不断地检查某个条件是否满足,这有助于节省CPU资源.可有效减少线程对共享资源的竞争,提高程序执行效率.

缺点:如果不谨慎使用挂起等待锁,可能导致死锁的发生,即多个线程相互等待对方释放资源,但彼此无法继续执行.

公平锁VS非公平锁

假设三个线程A,B,C.   A先尝试获取锁,获取成功.然后B再尝试获取锁,获取失败,阻塞等待;然后C也尝试获取锁,C也获取失败,也阻塞等待.

当线程A释放锁的时候,会发生啥呢?

公平锁:遵守"先来后到".B比C先来的.当A释放锁之后,B就能先于C获得到锁.

非公平锁:不遵守"先来后到".B和C都有可能获取到锁.

注意:

操作系统内部的线程调度可以看作是随机的.如果不做任何额外的限制,锁就是非公平锁.如果要实现公平锁,就需要依赖额外的数据结构(队列),来记录线程们的先后顺序.(因此,使用公平锁,天然就可以避免线程饿死问题).

读写锁

多线程之间,数据的读取方之间都不会产生线程安全的问题,但数据的写入方互相之间以及和读者之间都需要进行互斥.如果两种场景下都用同一个锁,就会产生极大的性能消耗.所以读写锁应运而生.

比如两个线程读本身就是线程安全的,不需要互斥,而且大部分操作都是读.

如使用synchronized加锁,两线程读会互斥,产生阻塞,造成性能消耗.

读写锁就能将并发读之间的锁冲突开销省下了,对于性能提升就明显了.

读写锁分为两种情况:(1)加读锁.(2)加写锁.

一个线程加读锁,另一个线程只能读不能写.

一个线程加写锁,另一个线程不能读也不能写.

Java标准库提供了ReentrantReadWriteLock类,这里不介绍,请自行查阅.

相关面试题

1.你是如何理解乐观锁和悲观锁的,具体怎么实现的?

悲观锁认为多个线程访问同一个共享变量冲突概率较大,会每次在访问共享变量之前都去真正加锁.乐观锁认为多个线程访问同一个共享变量冲突概率不大.并不会真正加锁,而是尝试访问数据.在访问的同时识别当前的数据是否会出现访问冲突.

悲观锁的实现就是先加锁(比如借用系统中的mutex),获取到锁再操作数据.获取不到锁就等待. 

2.介绍一下读写锁.

读写锁就是将读操作和写操作分开进行加锁.

读锁和读锁之间不互斥

写锁和写锁之间互斥

读锁和写锁之间互斥

读写锁主要用在"频繁读,不频繁写"的场景中. 

3.什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?

如果获取锁失败,立即尝试再次获取到锁,无限循环,直到获取到锁为止.第一次获取锁失败,第二次的尝试会在极短的时间内到来.一旦锁被其它线程释放,就能第一时间获取到锁.

相比于挂起等待锁.

优点:没有放弃CPU资源,一旦锁被释放就能第一时间获取到锁,更高效.在锁持有时间比较短的场景下非常有用.

缺点:如果锁的持有时间较长,就会浪费CPU资源. 

你可能感兴趣的:(java)