多线程篇九

常见的锁策略

虽然我们开发者一般只关注如何使用锁,但设计锁我们也需要有一定的了解.

乐观锁和悲观锁

顾名思义,两个锁一个是考虑的最优情况一个考虑最坏情况.

乐观锁

在加锁之前,假设数据一般情况下不会产生冲突,只在数据进行返回更新的时候进行检查校验,如果发生并行冲突就返回错误信息,让用户重新进行决策.(就是加锁之前预估出现所冲突的概率不大所以在加锁前不会进行太多的工作【加锁过程做的事少加锁的速度更快但是更容易引入一些其他问题消耗CPU资源】)
比如你想吃饭,但是食堂这个时候被军爷占领了,你觉得你去的过够早军爷抢不过你,直接冲去食堂,结果排上了长长的队伍.【没加锁但能识别数据冲突】

悲观锁

在加锁之前,总假设数据从一开始就容易被修改,每次拿数据的时候就会加锁.想拿到这个数据只能等待阻塞拿到锁.(在加锁之前预估出现锁冲突的概率很大,加锁的时候会做更多的工作防止意外,此时加锁的速度可能更慢,但是整个过程中更不容易出现其他问题)
和上述同样的情况,为了防止空跑一趟你给可预定窗口发消息询问能否预定(相当于加锁)得到肯定答复之后会来取餐如果生意太火爆没回或者说不够预定的就下次再去这个窗口.

Synchronized初始使用乐观锁策略.当发现锁竞争比较频繁的时候就会自动切换成悲观锁策略.
当然这种相互结合的模式在实际应用中更具高效性.

重量级锁和轻量级锁

锁的核心特性—— “原子性”这样的机制追溯根源是CPU这样的硬件设备提供的.
CPU提供了”原子操作指令”
操作系统基于CPU原子指令,实现了mutex互斥锁.
JVM基于OS提供的互斥锁,实现了synchronized和ReentrankLock等关键字和类.
1

重量级锁

适用于锁高竞争的场景.【开销较高】
加锁开销更大,加锁速度更慢.

加锁机制重度依赖了OS提供的mutex.
大量的内核态用户态切换.
很容易引发线程的调度.
涉及用户态和内核态的切换成本高高高高

轻量级锁

基于CAS操作的锁实现,适用于低竞争场景;可以避免阻塞,但在竞争激烈时会膨胀为重量级锁.

加锁机制尽可能不使用mutex尽量在用户态代码完成.搞不赢再用mutex.

少量的内核态用户态切换.
不态容易引发线程调度.
synchronized开始时是一个轻量级锁,如果锁冲突比较严重就会变成重量级锁.
加锁开销更小,加锁速度更快.

挂起等待锁

一种重量级锁的典型例子同时也是一种悲观锁.
进行挂起等待的时候需要内核调度器接入【此时需要的操作变多】真正获取到锁耗费的时间自然增长.
适用锁竞争激烈的情况.

自旋锁

一种轻量级锁的实现同时也是一种乐观锁.
进行加锁的时候搭配while循环,如果加锁成功,结束循环.反之再次进行循环不放弃,再次尝试获取到锁.(坚强励志!)
这个反复执行的过程就称为”自旋”.一旦其他线程释放了锁就能立马拿到锁(舔的漂亮!bushi)
使用前提是预期锁冲突不大,其他线程释放了锁不然死死循环太耗费CPU
synchronized 中的轻量级锁策略⼤概率就是通过⾃旋锁的⽅式实现.

悲观乐观是加锁之前对未发生的事情进行的评估.
轻重量级是加锁之后对结果的评价.
synchronized是能自适应的锁,根据锁冲突的概率高还是低实现锁模式的切换

公平锁非公平锁

和”线程饿死”有关,公平指的是先来后放到.
有A、B、C三个线程.A先尝试获取锁然后获取成功,B此时开始尝试获取锁,获取失败阻塞等待,然后C也尝试获取锁,仍然阻塞等待.
两者没有好坏之分,关键看使用场景

公平锁

遵守”先来后到”.A释放锁之后B先得到锁,把C晾在一边.

非公平锁

不遵守”先来后到”.B和C公平竞争,两者都有可能获取到锁.
synchronized是非公平锁.

站在系统原生锁的角度锁是非公平的【操纵系统内部的线程调度就是可以视为是随机的 想实现公平锁需要引入额外的数据结构(引入队列记录每个线程先后顺序)】

可重入锁和不可重入锁
可重入锁

顾名思义,”可以重新进入的锁”,允许同一个线程多次获取同一把锁.
一个线程针对一把锁可以连续加锁两次不会死锁即是可重入锁.

不可重入锁

跟上述情况相反.第二次加锁的时候会阻塞等待直到第一个锁释放,才会获取到第二个锁.但是该线程摆了什么也不想干,此时就会死锁.

1
2
3
4
// 第⼀次加锁, 加锁成功
lock();
// 第⼆次加锁, 锁已经被占⽤, 阻塞等待.
lock();

Linux提供的mutex是不可重入锁
synchronized是可重入锁.

Java⾥只要以Reentrant开头命名的锁都是可重⼊锁,⽽且JDK提供的所有现成的Lock实现类.

普通互斥锁读写锁
普通互斥锁

类似synchronized操作涉及到的加锁和解锁.

读写锁

多线程之间,数据的读取⽅之间不会产⽣线程安全问题,但数据的写⼊⽅互相之间以及和读者之间都 需要进⾏互斥。如果两种场景下都⽤同⼀个锁,就会产⽣极⼤的性能损耗。所以读写锁因此⽽产⽣.

1)加读锁
2)加写锁
读锁和读锁之间不会产生冲突(不会阻塞)
写锁和写锁之间会产生锁冲突(会阻塞)
读锁和写锁之间会出现锁冲突(会阻塞)

一个线程加读锁的时候另一个线程只能读不能写.
一个线程加写锁的时候另一个线程不能写也不能读.

synchronized不是读写锁.

引入读写锁原因

如果是两个线程在读那线程本身就是安全的不需要互斥.
如果使用synchronized这种方式加锁两个线程读会产生互斥,产生阻塞.(性能损失)
如果完全给读操作不加锁,一个线程读一个线程写,可能会读到写了一半的数据.
引入读写锁就可以解决.

Synchronized锁的内部优化

上述锁策略已经可以明确synchronized内部有一套自己优化的策略,使得synchronized能够适应多种情景.

当线程执行到未加锁的synchronized中的对象时会经历以下三个过程.目前来看此处的锁级别是不能降级的.

偏向锁(假设没线程来竞争锁)

核心思想是懒汉模式,在需要用到的时候才加锁,能晚加锁就晚加锁.但并未真正加锁,而是在线程上加一个轻量级的标记.如果没有其他线程来竞争就省去加锁操作,否则升级未轻量级操作.

轻量级锁(假设竞争小)

通过自旋锁实现.
优势:只要另外的线程释放锁就可以立马拿到锁.(坚持不懈的舔狗 bushi)
劣势:比较消耗CPU资源.
此阶段synchronized内部会统计当前这个锁对象上有多少个线程在参与竞争.如果竞争者较多就会升级到重量级锁.

重量级锁(假设竞争大)

此时拿不到锁的线程不再进行自旋而是阻塞等待.
让出CPU使用权【防止CPU占用率过高】
当前线程释放锁时,系统会随机分配另一个线程来获取锁.

Tips

偏向锁到轻量级锁这个过程不涉及解锁,只是确保有偏向锁状态的线程先拿到锁(优先性)
偏向锁标记是每个对象头的一个属性,每个对象都有自己唯一的标记.当锁对象首次加锁时进入偏向锁状态,如果这个加锁过程没有涉及锁竞争下次加锁还是偏向锁,否则跳过偏向锁到下一级阶段(轻量级锁).

锁消除策略

编译器的一种优化方式.比那一起编译代码的时候遇到错误代码就不会加锁,而是自动把锁取消.
比如加锁代码中没有涉及到成员变量的修改只有一些局部变量是不用加锁的.
针对一眼识别的完全不涉及线程安全问题的代码能够把锁消除掉.但是只有偏向锁运行起来才知道有没有锁冲突.

锁粗化

怎么分别此处的粗细?
synchrionized{ }中的代码越少就认为锁的粒度越细包含的代码越多就认为锁的粒度越粗
同样的,不同的场景需要的锁粗细粒度不同,视具体情况而定.
锁粗化会将多个细粒度的锁,合并成一个粗粒度的锁,避免了重复加锁解锁的过程

总结
1.怎么理解乐观锁和悲观锁的,具体怎么实现?

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

悲观锁的实现就是先加锁(⽐如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待. 乐观锁的实现可以引⼊⼀个版本号. 借助版本号识别出当前的数据访问是否冲突. (实现细节参考上⾯ 的图).

2.介绍读写锁

读写锁就是把读操作和写操作分别进⾏加锁. 读锁和读锁之间不互斥. 比特就业课 写锁和写锁之间互斥. 写锁和读锁之间互斥. 读写锁最主要⽤在 “频繁读, 不频繁写” 的场景中.

3.什么是⾃旋锁,为什么要使⽤⾃旋锁策略,缺点是什么?

如果获取锁失败, ⽴即再尝试获取锁, ⽆限循环, 直到获取到锁为⽌. 第⼀次获取锁失败, 第⼆次的尝试 会在极短的时间内到来. ⼀旦锁被其他线程释放, 就能第⼀时间获取到锁. 相⽐于挂起等待锁, 优点: 没有放弃 CPU 资源, ⼀旦锁被释放就能第⼀时间获取到锁, 更⾼效. 在锁持有时间⽐较短的场景 下⾮常有⽤. 缺点: 如果锁的持有时间较⻓, 就会浪费 CPU 资源.

4.synchronized 是可重⼊锁吗?

是可重⼊锁. 可重⼊锁指的就是连续两次加锁不会导致死锁. 实现的⽅式是在锁中记录该锁持有的线程⾝份, 以及⼀个计数器(记录加锁次数). 如果发现当前加锁的 线程就是持有锁的线程, 则直接计数⾃增.