多线程进阶之CAS等

(接我之前的那条)
二、CAS(compare and swap)
操作系统/硬件设备 赋予应用程序的一种原子操作的力量源泉之一。
判断某个变量的值,如果这个值和你猜的值一样,就交换值。如果这个值猜的不一样就无事发生。(硬件实现的原子操作)。
CAS 的典型应用场景:
无锁编程。保证线程安全。
加锁操作开销比较大,及时 Sychronized 内置了很多的优化策略。还是比较大。要想要更加灵活,更加高效的完成,就可以使用 CAS。
例如,想要完成一个线程安全的 i++ 这样操作:
void add(){
// 相关让 i 进行 ++
int oldValue = i;
while(!CAS( &i, oldValue, oldValue+1)){
oldValue = i;
}
}
这样的diamante就可以保证多线程并发调用 add,对于 i 的修改仍然是线程安全的。
i ++多线程同时进行的时候,涉及到先读内容,再修改赋值(read and update)使用CAS就能保证多线程进行 i++ 操作时候仍然是安全的。CAS 相当于把读取判断操作和修改操作做成一个原子操作。
注意一点:CAS 是原子的,哪怕多线程并发执行也是一个线程执行完了,再执行另一个线程的。
上面代码的本质就在于,通过 CAS 验证看当前的 i 的值是否有人再改。如果没有人改,自己就该,如果有人改,自己就等会再改(此处等会,不是挂起等待,而是while 循环会快速的再次执行到 CAS 操作的)

JVM内部使用CAS 完成了很多功能。
1.原子类。
int ,long 内置类型,++/-- 等操作(本质上就是对 CAS 进行了封装)
在这里插入图片描述
2.就可以在 用户态实现轻量级锁/自旋锁

class Locker {
    Thread owner - null; // 表示那个线程持有锁
    
    void lock() {
        if(CAS(&owner,null,Thread.currentThread())) {
        // 此处如果想实现自旋,将 if 改成 while 即可。
            .....
            // 当前线程获取所成功。
        }else {
            // 表示当前线程获取锁失败,需要等待,或者放弃获取锁。
        }
    }
}

(重量级锁,就相当于这样的逻辑完全由内核来实现。轻量级锁,这部分逻辑自己实现,就更可控一些。)
CAS 的一些小缺陷:
ABA问题:比如:上述 add 代码中 可能 i 从来没有被修改过。或者可能 i 已经被修改过,但是又被改回来了,这两种情况 CAS 不太容易区分。

三、锁优化
如果自己需要实现一个锁,需要考虑锁优化。
锁的实现原理:
以加锁为例

1.先通过原子从挨揍,检查并更新(CAS)一块内存区域,如果修改成功过后,表示加锁成功,否则表示加锁失败。(更新的内存区域就可以想象成是哪个线程持有了这个锁)
2.如果更新失败,表示锁被别的线程占用了。
3.如果被占用了,挂起等待 / 自旋…策略很灵活。

4.如果是挂起等待,吧该线程放到一个锁对应的等待队列中,后面锁释放后才有机会被唤醒。
5.放弃 CPU

(前三步可以是用户实现,也可以时内核自己实现,后两步主要靠内核实现。 如果前三步是用户自己实现,就相当于是轻量级锁,如果五步都是内核实现,就相当于重量级锁)

JVM 实现 sychronized 时的优化策略:
1.编译器 + JVM 智能判定该锁是否可以消除,如果可以,就直接消除,就不加锁了。
2.第一个获取线程的锁,只加一个偏向锁(就是一个简单的标记位,没有互斥机制,如果有多个线程竞争锁了,在真正进行加锁。)
3.当多个线程进入竞争的时候,刚才的偏向锁就被消除,进入轻量级锁状态,没有获取到锁的线程就会自旋等待。
4.如果竞争进一步激烈(锁冲突越来越频繁),通过自旋也无法获取到锁了,膨胀为重量级锁,在内核针对该线程挂起等待。
5.如果一段逻辑中,出现多次加锁解锁操作,编译器+ JVM会自动把多次相邻的加锁解锁合并成一次加锁解锁(锁粗化)

1.锁消除
作为程序员,为了让代码更可靠,可能会在很多地方加锁,有一些本不该加锁的地方也给加锁了。
在这里插入图片描述
由于代码中只有一个线程,不需要加锁。编译器+ JVM 自动把stringBuffer 中的加锁操作就去掉了。(编译器+JVM 比较智能的过程,取决于JVM的具体实现)
加锁时一个开销比较大的操作,能不加尽量就不加。

2.偏向锁
就是一个标记,如果只是加一个标记,这个操作很轻量(把一个boolean从 false 改成true 一样)
如果现在某个线程加锁,JVM 也不确定这个锁到底涉不涉及多线程竞争,先当成不会竞争。就简单的给这个线程来个标记。
如果是后面的线程不和这个代码发生锁竞争的haul,就可以彻底不用加锁了。
如果要是后面的线程要是需要竞争的话,再真正加锁。
(加锁时比较开销大的操作,能不加就尽量不加。)

3.sychronizd 中的自旋是一种“自适应”自旋锁。
到底是否自旋,以及自旋多久,都是“自适应”,根据环境的差异,会有一些变化。
JVM 会评估当前某个锁的竞争激烈程度。

自旋其实就是耗着 CPU 在空转。(占着地方不干事儿)
如果能大概率的获取到锁,那就多自旋一会儿。
如果能获取锁的概率较低,那还不如放弃 CPU 成全别的线程。

4.锁膨胀
自旋锁效率是更高的,能更快的获取到锁。
如果自旋了很久都没有拿到锁,也不能干等,只能在内核中挂起等待了。
(这也好过一上来就是重量级锁)

5.锁粗化。(锁的粒度)
加锁解锁操作中间夹的代码越多,就认为锁越粗,反之就越细。
一般情况下,我们希望代码写的时候尽量让锁的粒度细一点,锁粒度越细,多线程并发执行时,并发程度就越高。
举个例子:在这里插入图片描述
上面的代码,for 语句自身,包括n 的操作也都是在锁内部,串行执行的。

下面的代码,for 在锁之外,多个线程之间的 for 以及针对 n 的操作就是并发的。
如果锁竞争很激烈,这个时候肯定是粒度越细越好。
如果锁竞争不激烈,甚至就没竞争。
此时反复多次加锁解锁,就不如一次加锁解锁来的更快。
在这里插入图片描述
在这里插入图片描述
锁粗化相当于是把这5次加锁解锁,合并成一次加锁解锁了。
这段代码真正执行的时候,到底是触发锁消除,还是锁粗化,还是其他优化策略,都是JVM 在运行时,根据当前的情况灵活判定。

四、java.util.concurrent.*; (并发)
标准库提供的一组工具类,帮我们简化并发编程。
java.util.concurrent.atomic . java.util.concurrent.locks . 原子类 包含一些其他的锁。
除了 sychronized 之外,还有一些其他的锁。

1.各种 locks
在这里插入图片描述
上面加红的 Lock 接口,就是让用户自己实现锁的时候,就可以基于这个接口来开发。
在这里插入图片描述
读写锁,非常有用
读锁写锁之间是并发的。
lock 和 sychronized 之间的区别。
(1).lock 这里的加锁解锁是分开的,更灵活。
(2).lock 这个体系是 Java 语言层面实现的,sychronized 是JVM 内部实现的。
(3).lock 还提供了一些具体的更灵活的方法。
如 trylock 方法:lock 方法是直接加锁,如果加锁失败,就会等待(自旋/挂起)尝试获取锁失败,就直接放弃。或者等待指定等待时间再放弃。

大部分情况下使用 sychronized ,特殊情况下再使用其他的锁,读写锁和 trylock 都是常用的内容。如果需要自定制更加复杂策略的锁,也就需要使用 Lock 系列。

2.原子类
3.Callable / Future / FutureTask 这几个是搭配使用的

把- -个线程封装成了能返回结果的样子.
前面直接创建的Thread,都是没有返回值的.
如果你想多线程进行一些计算, 此处的结果需要自己来进行汇总.
这些组件就是为了简化这个汇总过程.
更方便的使线程能够得到一个想要的最终结果(类似于函数返回值一样)
如果你创建的线程不需要返回值,就可以使用Runnable.如果需要使用返回值,就可以使用Callable (需要搭配Future或者Future 或者 FutureTask)
使用FutureTask的原因是Thread类没有一个直接获取到结果的方法.就只能靠FutureTask了.

4. Semaphore(信号量)
很有用.相当于是一个"计数器"
例如:停车场.停车场门口都挂个电子牌,显示当前还有N个车位
每次有车开进停车场,计数就减 1
每次有车开出停车场,计数就加 1
(这个计数器就是信号量.表示当前可用资源的个数.当停车场门口的牌子为0了, 每次有人申请一个资源,信号量的值就-1 (P操作)。每次有人释放一个资源, 信号量的值就+1 (V操作) )

此时如果有新的车想开进来咱办? .
1.车在门口等. 一直等到有其他车开出来为止. [更常见]
2.掉头就走,找其他停车场.

多线程进行PV操作,此时都是线程安全的. (信号量计数加减都是原子的)

信号量中有一个特殊情况,信号量的值只有0和1两种取值的,这种称为"二元信号量",本质上就是一个互斥锁.(synchronized就可以视为是一个"二元信号量"。同一时刻之后一个线程能获取到锁.这就相当于可用资源为1.)

核心操作就是P V
在有多个共享资源的时候,就可以考虑使用信号量.
例如实现-个线程安全的阻塞队列,使用信号量也可以~~ (之前用的是锁+ wait)

5.线程池ThreadPoolExecutor ExecutorService Executors
线程池存在的意义,是为了避免频繁创建/销毁线程的开销。
在这里插入图片描述
corePoolSize:公司中的正式员工的个数
maximumPoolSize:公司中的总员工个数. (正式员工+临时工)
正式员工不能随便辞退,但是临时工可以。
keepAliveTime + unit:允许临时工空闲时间。
如果正式员I没啥活干,哪怕是呆着,也得呆着。
如果临时工没啥活干,超过一定的时间就会被辞退。
workQueue:任务队列.公司要接订单。
threadFactory:创建线程的工厂类,相当于公司的hr。
handler:拒绝策略,如果任务队列超出公司的负荷之后该咋办.
a)直接抛异常。 (本质上一个方法,执行- -些出现问题之后的处理逻辑.)
b)谁下的订单谁去负责处理。
c)丢弃最老的订单。
d)丢弃新订单。
(1). 每个线程都是一个员工。其中有一部分是正式员工,有一部分是临时工。 (临时工就是空闲久了就要被辞退,被销毁)线程池中的线程数目会保持在一个下限之上。
(2).线程池都需要有 个阻塞队列,保存要执行的任务。

6. CountDownLatch
CountDownLatch latch = new CountDownLatch(10);
表示有10个选手
latch. countDown();
某个选手撞线了。
latch. await();
等待十个选手都撞线之后, await才会返回。否则就会阻塞。
例如,写一个多线程的下载器。
要下载一个1G大小的文件,分成10个线程,每个线程负责100MB
10个线程都下载完,才算整体下载完~~

创建一个初始值为10的latch对象。
每个线程下载完-部分之后, 调用countDown方法。
十个都玩了, await返回才真正认为文件整体下载完毕。

五、ConcurrentHashMap
原来我们学习的大多数集合类,大部分都是线程不安全的。
ArrayList, LinkedList, HashMap, HashSet… 都是线程不安全的。
前面学过的Vector, Stack, HashTable线程安全的,其他的都不是。
1.如果想在多线程环境下使用ArrayList咋办?
1).自己加锁。
2)
在这里插入图片描述
3). JUC里还有一个CopyOnWriteArrayList

2.如果想在多线程环境下使用HashMap咋办?
1). HashTable (不太建议使用)
2). ConcurrentHashMap (推荐使用)

  1. HashTable和ConcurrentHashMap的区别:
    HashTable保证线程安全很简单,搞了一个sychronized ,一把大锁来控制线程安全。
    HashTable每个方法都直接使用sychronized来修饰。
    ConcurrentHashMap特点
    a)充分利用了CAS. (比直接使用sychronized更高效)例如size更新,直接CAS完成。
    b)没有使用一把大锁,而是使用若干个小锁。 (每个hash桶分配一个锁) 降低锁冲突的概率。
    c)优化了扩容策略,如HashTable触发扩容,加锁,扩容,释放锁.这个过程可能很久。代码使用HashTable ,用着用着突然某一个操作会卡很久。

以上就是我对多线程进阶的一些总结,纯属自己学校学习后自己总结,一些例子也是上课时老师给我们讲的,如果有什么不对的地方,希望大家不吝赐教。我一定虚心接受各位的观点。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值