线程常见锁策略,以及CAS相关内容

就感觉最近蛮水逆的,

希望水逆快快过去。

 目录

1.线程常见锁策略

2.CAS

2.1基于CAS实现的"原子类"

2.2基于CAS实现的"自旋锁"

2.3CAS中的ABA问题

3.synchronized中锁的优化机制


1.线程常见锁策略

①什么是锁策略:

接下来讲解的锁策略不仅仅是局限于 Java . 任何和 "" 相关的话题, 都可能会涉及到以下内容这些特性主要是给锁的实现者来参考的。

②乐观锁和悲观锁:

乐观锁:

预期锁冲突很低,做的更少,成本更低,更高效的操作

悲观锁:

预期锁冲突很高,做得更多,成本更高,更低效的操作

举个例子:

当我们快要进行期末考试时,就可以把大家分为两类人,乐观人和悲观人。乐观人觉得仅仅是一场考试而已,没有必要那么紧张,平时学好了,随便复习复习就行。而悲观人呢,就会觉得很紧张,一直不停地复习,从而做得更多,耗费的时间更多,因此对于其他事情就减少了平时的投入,显然这就是很低效的操作

③读写锁和普通的互斥锁:

读写锁:

是指分别对读和写单独进行加锁的操作。实际上有三个操作,加读锁,加写锁以及解锁。(注意!!!读锁和读锁之间是不会产生互斥关系的,只有读锁和写锁,写锁和写锁之间才会产生互斥关系)

普通的互斥锁:

只要两个或多个线程对同一对象进行加锁,就会产生互斥。它只有两个操作,加锁和解锁。

④重量级锁和轻量级锁:

重量级锁:

就是事情做多了,开销更大。如果锁是基于内核某些功能来实现的话,那么认为它是重量级锁。(操作系统中的锁会在内核中会做很多事情,比如让线程阻塞等待)

轻量级锁:

事情做少了,开销更小。如果锁是基于用户态来进行实现的话,那么认为它是轻量级锁。(用户态的代码更高效,更可控)

而在一般情况下,我们认为乐观锁是轻量级锁而悲观锁是重量级锁。

⑤挂起等待锁和自旋锁:

挂起等待锁:

往往是通过内核的一些机制来进行实现的,往往较重(是重量级锁的一种典型实现)

自旋锁:

往往是通过用户态代码来实现的,往往较轻(是轻量级锁的一种典型实现)如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝 试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁. 相比于挂起等待锁, 优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源.

⑥公平锁和非公平锁:

公平锁:

这里的公平锁,指的是多个线程在等待锁时,按照先来后到的顺序进行执行的

非公平锁:

指的是多个线程在等待锁时,不遵循先来后到的规则,他们获取到锁的概率是均等的

而对于操作系统而言,本身线程的调度就是随机的(机会相等的),操作系统提供的mutex锁就是非公平锁

⑦可重入锁和不可重入锁:

可重入锁:

一个线程针对同一把锁可以多次进行加锁

不可重入锁:

一个线程只能加一把锁,再继续加锁就会出现死锁的情况。

2.CAS

①解释CAS:

全称Compare and swap,意思:”比较并交换“。

②CAS具体操作:

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

1. 比较 A 与 V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V。(交换)
3. 返回操作是否成功。
③对CAS伪代码的解释:
下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解 CAS 的工作流程.

 ④CAS的意义:

为这种多线程的代码提供了安全的保障,提供了一种新的思路和方法。

2.1基于CAS实现的"原子类"

①基于CAS实现原子类的完整代码剖析:

Java标准库里提供了一组原子类,针对所常用多一些的int, long, int array...进行了封装,可以基于CAS的方式进行修改,并且线程安全。标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的。典型的就是 AtomicInteger 类. 其中的getAndIncrement 相当于 i++ 操作。

而更是因为CAS是基于原子类来实现的。因此它是线程安全的,并且相比于synchronized而言,CAS是一种更加高效的操作,synchronized会涉及到锁的竞争,两个线程要相互等待,CAS不涉及到线程阻塞等待。

下面我们就对之前用synchronized执行过的自增代码操作用CAS来进行操作实现:

import java.util.concurrent.atomic.AtomicInteger;
public class demo1 {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger num=new AtomicInteger();
        Thread t1=new Thread(()->{
            for(int i=0;i<5000;i++){
                //用getAndIncrement()来实现自增的操作
                num.getAndIncrement();
            }
        });
        Thread t2=new Thread(()->{
            for (int i=0;i<5000;i++){
                num.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(num);
    }
}

执行结果如下:

 ②利用伪代码进一步进行说明:

③利用画图来进一步阐述为什么上述自增操作是安全的:

2.2基于CAS实现的"自旋锁"

①自旋锁的伪代码及详解:

②进一步说明:

这里的自旋锁是轻量级的,这样的忙等,其实效率更高。

2.3CAS中的ABA问题(重点)

①什么是ABA?

我们知道CAS实质上是通过比较来对两个值进行交换的操作,所谓比较实际是对当前值和和旧值进行比较,但是这里就会存在一个问题,到底是一步到位的没有改变,还是说中间经过了变化,最后又变回来导致的结果没有改变。用下图就能够进一步清楚地为我们解释ABA问题。

就好比我要从成都到北京,我可以直接坐直达的列车,当然我可以先从成都到河北,再从河北到北京,最终我是到达了北京,但是你不知道我到底经历了什么而到达的北京。

②举个例子结合图解来进行分析说明:

滑稽老铁取钱去买东西

a. 基于非CAS问题:

在滑稽进行取款的时候,机器卡了一下,也就是滑稽多按了一次取款。这就相当于一次取钱操作执行了两次(两个线程并发地去执行了这个操作),而我们的目的是只取成功一次,即取走50,余额还剩50。

b.而基于CAS问题的方式:

来这里取款的话就会出现新的情况。现在的情况就是在滑稽取款的一瞬间,他朋友给他转了50,这个时候就会出现ABA问题,这两次的巧合导致了这个Bug,我们需要来解决这个问题

c.基于CAS但是非ABA问题:

所以按照上述分析,此处就是两次操作,实际上只有一次成功了,而上述的成功是因为没有引入ABA问题,下面我们就引入ABA问题进一步进行分析。 

d.CAS基于ABA问题的分析:(我们期望取款一次,而在这里我们取了两次)

 ③ABA问题的解决方案:(通过添加版本号来进行解决)

给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当前版本号比之前读到的版本号大, 就认为操作失败。

3.synchronized中锁的优化机制

①synchronized锁的特点:

a.既是一个乐观锁,也是—个悲观锁(根据锁竞争的激烈程度,自适应)

b.不是读写锁只是一个普通互斥锁

c.既是一个轻量级锁,也是一个重量级锁(根据锁竞争的激烈程度,自适应)

d.轻量级锁的部分基于自旋锁来实现.重量级的部分基于挂起等待锁来实现

e.非公平锁(通过竞争来获得锁,而不是先来后到)

f.可重入锁

②典型的优化手段:

a.锁膨胀/锁升级(体现了synchronized能够自适应的特点)

在引入之前先介绍一个概念:什么是偏向锁

偏向锁不是真的加锁, 而只是在锁的对象头中记录一个标记(记录该锁所属的线程). 如果没有其他线程参与竞争锁, 那么就不会真正执行加锁操作, 从而降低程序开销. 一旦真的涉及到其他的线程竞争, 再取消偏向锁状态, 进入轻量级锁状态.
简而言之,偏向锁就是只是对锁做了一个标记,并没有真正地去锁上,它的好处就是没人竞争的时候避免了锁的开销。

 自旋锁和重量级锁前面都有讲述,就不再重复了

b.锁粗化/锁细化

这里的粗细指的是"锁的粒度"

(粒度表示加锁代码涉及到的代码范围,加锁代码的范围越大,认为锁的粒度越粗,加锁代码范围越小,锁的粒度越细)

那么锁的粒度究竟是粗好还是细好?其各有各的好处:

如果锁粒度比较,多个线程之间的并发性就更高
如果锁粒度比较,加锁解锁的开销就更小

编译器就会有一个优化:

如果某个地方的代码锁的粒度太细了,就会自动判定进行粗化;
如果两次加锁之间的间隔较大(中间隔的代码多),一般不会进行这种优化.如果加锁之间间隔比较小(中间隔的代码少),就很可能触发这个优化。 

c.锁消除

比如你在单线程里面使用了StringBuffer,Vector(这些都在标准库里面进行了加锁操作),就相当于是你在单线程里面进行了加锁。

用StringBuffer()来进行一个举例

代码:

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

然而对于单线程而言,这样的加锁显然是没有必要的,会白白浪费资源的开销。

所以,编译器+JVM 判断锁是否可消除。 如果可以, 就直接消。这里表示再有些地方,可能并不需要加锁,但是你不小心加上了锁,编译器发现加上这个锁好像没啥必要,编译器就会直接把锁给去掉了

感谢观看~ 

猜你喜欢

转载自blog.csdn.net/weixin_58850105/article/details/124189371
今日推荐