关于线程同步中的各种锁的总结

公平锁和非公平锁

公平锁:等待时间较长的线程优先获得锁

非公平锁:没有优先级,等待时间长的线程不一定就会优先获得锁

乐观锁和悲观锁

悲观锁:

悲观锁简介

在修改数据之前先锁定再修改,具有强烈的独占和排他性
之所以叫悲观锁,是因为这是一种对数据的修改持有悲观的态度的并发控制方式:总是假设最坏的情况,也就是每次读取数据的时候都默认其他线程会修改数据,因此要进行枷锁操作。

悲观锁的实现:

1.传统的关关系数据库使用这种锁机制,比如行锁,表锁,读锁,写锁等,都是在操作之前先上锁
2.java里面的synchronized

悲观锁分类:

共享锁:又叫读锁,多个线程可用持有该锁进行读操作,但是无法进行修改操作

排他锁:又叫写锁,一个线程只有该锁,其他线程无法再获得该所以及其他操作所

乐观锁:

乐观锁采用了更加宽松的加锁机制。也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,但乐观锁不会可以使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。

乐观锁的实现:

1.CAS实现,java中java .util.concurrent.atomic包下面的原子变量使用了乐观锁的一种CAS实现方式

2.版本控制:一般是在数据表中加上一个数据版本号version字段,在读取的数据的同时也会记录version值,在提交更新的时候,若刚才读到的version值和当前数据库中的version值相等的时候才更新,否则重新更新操作,直到更新成功

CAS

一个 CAS 涉及到以下操作:

我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

比较 A 与 V 是否相等。(比较) 如果比较相等,将 B 写入 V。(交换) 返回操作是否成功。 当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。可见 CAS 其实是一个乐观锁。

img

image.png

如上图中,主存中保存V值,线程中要使用V值要先从主存中读取V值到线程的工作内存A中,然后计算后变成B值,最后再把B值写回到内存V值中。多个线程共用V值都是如此操作。CAS的核心是在将B值写入到V之前要比较A值和V值是否相同,如果不相同证明此时V值已经被其他线程改变,重新将V值赋给A,并重新计算得到B,如果相同,则将B值赋给V。

ABA 问题

CAS 由三个步骤组成,分别是“读取->比较->写回”。 考虑这样一种情况,线程1和线程2同时执行 CAS 逻辑,两个线程的执行顺序如下:

时刻1:线程1执行读取操作,获取原值 A,然后线程被切换走

时刻2:线程2执行完成 CAS 操作将原值由 A 修改为 B

时刻3:线程2再次执行 CAS 操作,并将原值由 B 修改为 A

时刻4:线程1恢复运行,将比较值(compareValue)与原值(oldValue)进行比较,发现两个值相等。 然后用新值(newValue)写入内存中,完成 CAS 操作

如上流程,线程1并不知道原值已经被修改过了,在它看来并没什么变化,所以它会继续往下执行流程。对于 ABA 问题,通常的处理措施是对每一次 CAS 操作设置版本号。java.util.concurrent.atomic 包下提供了一个可处理 ABA 问题的原子类 AtomicStampedReference,具体的实现这里就不分析了,有兴趣的朋友可以自己去看看。

死锁:

基本概念:
A拿到锁,在执行的过程中需要等待B执行某个任务,而B执行该任务也需要获取该锁,但这个时候锁在A身上,所以B在等待A释放该锁,而A又在等待B的执行。这造成了A在等待B,B在等待A的现象,这种现象叫做死锁。

自旋锁和互斥锁:

自旋锁:

基本概念:

自旋锁是指:当一个线程尝试获取自旋锁的时候,如果此时该锁已经被别的线程持有,那么该线程将会循环等待(开启一个while循环),直到获取到该锁才退出循环。

while (抢锁(lock) == 没抢到) {}

缺点:

需要注意的是,这个时候线程并没有休眠,所以不会引起线程切换。
所以如果别的线程长期持有该所,那么该线程会一直执行While循环,这会使CPU长时间做无用功

分类:

自旋锁属于非公平锁,因为等待时间长的线程并不具备优先获取到锁的权力
自旋锁属于悲观锁,因为它满足在修改数据之前先获取该锁

互斥锁:

在访问共享资源之前对进行加锁操作,在访问完成之后进行解锁操作。 加锁后,任何其他试图再次加锁的线程会被阻塞(线程休眠),锁被释放。

while (抢锁(lock) == 没抢到) {
    线程休眠,等到锁状态改变再唤醒该线程。
}

缺点:

互斥锁枷锁失败的时候,会从用户态切换到内核态,由内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本,开销成本主要体现在两次线程上下文的切换

线程上下文的切换的概念:

当两个线程属于同一个进程,进程的虚拟内存是所有线程共享的,在切换的时候虚拟内存保持不变,只需要切换线程的私有数据。有大佬统计过线程上下文切换的时间,大概在几十纳秒到几微秒之间,如果加锁的代码执行时间比较简单 (比如 i ++ 这种的),那上下文切换的可能会比加锁代码的执行时间还要长

两次上线程下文切换:

第一次线程上下文切换:
当线程枷锁失败时,内核会把线程从【运行态】转为【休眠态】,然后把CPU让给其他线程。

第二次线程上下文切换:
当锁被释放的时候,内核会把线程从【休眠态】转为【就绪态】,抢占到CPU后转为【运行态】

自旋锁和互斥锁的对比:

主要区别:

互斥锁会造成线程上下文的切换,而自旋锁一直在【用户态】执行While循环,具体逻辑由CAS(compare and swap)函数负责,不会主动产生线程上下文切换。

性能对比:

自旋锁相比较互斥锁,开销成本较小,速度也会比较快,但是CPU会做无用功。

使用场景:

1.如果需要加锁的代码块执行时间很短,应该使用自旋锁。

一是此时CPU做无用功的时间短,能够接受
二是减少互斥锁造成的线程上下文切换,降低性能开销成本

2.如果需要加锁的代码块执行时间较长,应该使用互斥锁

一是加锁代码快执行时间长,使用自旋锁,那CPU做无用功的时间大大增加,造成CPU资源的浪费
二是此时线程上下文切换的时间远小于加锁代码块执行时间,所以能够接受上下文切换

3.锁的竞争激烈的时候用互斥锁,不激烈的时候使用自旋锁

如果锁竞争激烈,我们需要依赖于互斥锁,让竞争失败的线程阻塞。
若此时使用自旋锁,假设有100个线程在参与锁竞争,当锁被释放的那一刻,100个线程中只有一个线程能够拿到该锁,也就是其它99个线程的之前的while循环是没有意义的,也就是CPU做了99个线程等待时间的无用功。

`img

轻量级锁和重量级锁:

轻量级锁:

轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗。所以自旋锁就是轻量级锁的一种。

重量级锁:

引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此称这种锁为 重量级锁。而互斥锁就是重量级锁的一种

使用场景:

与 自旋锁(对应轻量级锁) 和 互斥锁(对应重量级锁)相同

无锁:

不锁住资源,多线程中只有一个能成功修改资源,其它线程会重试。具体可以看看前面的CAS

偏向锁:

偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。

重入锁和不可重入锁:

基本概念:

重入锁:获取到重入锁的线程可以再次获取到该锁,而不会造成死锁。
不可重入锁:获取到冲入锁的线程或再次获取该所,会出现死锁

Atomic包

AtomicInteger

主要看看AtomicInteger,OkHttp就是使用该类来记录当前访问同一主机的请求数量。
AtomicInteger采用了 native层的CAS函数 + volatile 保证了像 i++, i-- 这种自增自减操作的原子性从而实现线程同步。

像 i = 0 , i = 1这种赋值操作就是原子性的,就是这种操作要么不执行,要么执行完,若开始执行了就一定不会被中断。

而像 i = value 这种形式的,就不是一个原子操作,因为需要两个原子操作
1.读取共享变量value的值
2.将value的值写入 i (i位于该线程的工作内存中)

但是 i ++ 这种也不具备原子性,因为它需要由多个原子操作来实现,具体如下
1.读取 i 的值
2.i 的值 + 1
3.写入值

而AtomicInteger就提供了 保证,自增,自减 等一些常用非原子操作的原子性 的方法

posted @ 2022-01-20 15:30  Shzy  阅读(139)  评论(0编辑  收藏  举报