多线程进阶:常见的锁策略、CAS

之前我们所了解的属于多线程的初阶内容。今天开始,我们进入多线程进阶的学习。

锁的策略

乐观锁 悲观锁

这不是两把具体的锁,应该叫做“两类锁”

乐观锁:预测锁竞争不是很激烈(这里做的工作可能就会少一些)

悲观锁,预测锁竞争会很激烈(这里的工作可能就会多一些)

这里都不绝对,悲观和乐观,唯一的区分主要就是看预测锁竞争激烈程度的结论~

轻量级锁 重量级锁

轻量级锁加锁解锁开销比较小~效率更高~

重量级锁加锁解锁开销比较大~效率更低~

多数情况下,乐观锁也是一个轻量级锁

多数情况下,悲观锁也是一个重量级锁

自旋锁 挂起等待锁

自旋锁,是一种典型的轻量级锁:当某个线程没有申请到锁的时候,该线程不会被挂起,而是每隔一段时间检测锁是否被释放。如果锁被释放了,那就竞争锁;如果没有释放,过一会儿再来检测。

挂起等待锁,是一种典型的重量级锁:当某个线程没有申请到锁的时候,此时该线程会被挂起,即加入到等待队列等待。当锁被释放的时候,就会被唤醒,重新竞争锁。

也就是说自旋锁在申请锁的过程中会一直重复申请,会占用大量的cpu资源。挂起等待锁也就可以把cpu省下来干别的事情~

互斥锁 读写锁

互斥锁:像synchronized这样的锁,提供加锁和解锁两个操作~

如果一个线程加锁了,另一个线程也尝试加锁就会阻塞等待~

读写锁:只有一组操作中有读也有写,才会产生竞争。多线程进阶:常见的锁策略、CAS_第1张图片

公平锁 非公平锁

此处应该把公平定义成“先来后到”。

操作系统和Java synchronized 原生都是“非公平锁”。操作系统这里的针对加锁的控制,本身就非常依赖线程调度顺序,这个调度顺序是随机的,不会考虑到这个线程等待多久了~

要想实现公平锁,就得在这个基础上,引入额外的东西(比如一个给锁排序的队列)

可重入锁 不可重入锁

不可重入锁:针对同一个线程连续加锁多次,会出现死锁

可重入锁:针对同一个线程连续加锁多滴,不会出现死锁

synchronized

1.synchronized既是一个悲观锁,又是一个乐观锁。

        synchronized默认是一个乐观锁,但是如果发现当前锁竞争比较激烈,就会变成悲观锁。

2.synchronized既是一个轻量级锁,也是一个重量级锁。

        synchronized默认是一个轻量级锁,如果发现当前锁竞争比较激烈,就会转换成重量级锁。

3.synchronized这里的轻量级锁,是基于自旋锁的方式来实现的。

        synchronized这里的重量级锁,是基于挂起等待锁的方式来实现的。

4.synchronized不是读写锁

5.synchronized是非公平锁

6.synchronized是可重入锁

总结:上述谈到的6种锁策略,可以视为是“锁的形容词”。

CAS

CAS全称Compare and swap,字面意思“比较和交换”

一个 CAS 涉及到以下操作:

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

1. 比较 A 与 V 是否相等。(比较)

2. 如果比较相等,将 B 写入 V。(交换)

3. 返回操作是否成功。

最重要的是:CAS的操作是原子的!

CAS的过程并非是通过一段代码实现的,而是通过一条CPU指令完成的。

既然是原子的,那么就可以一定程度上处理线程安全问题~

boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false;
}

通过一段伪代码(不能编译运行,只是表达了一个大概的逻辑)可以更好地理解CAS。

CAS的应用场景

1.实现原子类:Java标准库里提供的类

标准库中提供了 java.util.concurrent.atomic 包,,里面的类都是基于这种方式来实现的.。典型的就是 AtomicInteger 类。其中的 getAndIncrement 相当于 i++ 操作。

AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();

伪代码:

class AtomicInteger{
    private int value;
    public int getAndIncrement(){
        int oldvalue = value;
        while(CAS(value,oldvalue,oldvalue+1) != true){
            oldvalue = value;
        }
        return oldvalue;
    }
}此处为伪代码

可以把oldvalue理解成为寄存器里的值。

我们就拿伪代码来说明:

正常情况下,oldValue应该和value是一样的值,紧接着这里会产生CAS,把oldValue + 1写到value中去。

但是:可能会执行完把value的值写到oldvalue(寄存器)这一步后,线程发生切换了,另一个线程也进行修改了value的值

此时这个线程回来后,通过CAS判定,就认为value和oldvalue不相等了。

于是就返回false,不进行任何交换。进入循环,循环内部重新读取value的值到oldvalue中去。

此时在比较,发现相等了,进行CAS操作,并返回true,就不进入循环结束了。

原子类这里的实现,每次修改之前,再确认一下这个值是否符合要求。

通过形如上述代码就可以实现一个原子类,不需要使用重量级锁,就可以高效的完成多线程的自增操作。

2.实现自旋锁

自旋锁伪代码:

public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有. 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
        while(!CAS(this.owner, null, Thread.currentThread())){
       }
   }
    public void unlock (){
        this.owner = null;
   }
}

while循环中:监测当前的owner是否是null,如果是null,就进行交换,也就是把当前线程的引用赋值给owner 。如果赋值成功,此时循环结束,加锁完成了。
如果当前锁,已经被别的线程占用了,CAS就会发现,,this.owner 不是null,CAS就不会产生赋值,也同时返回false.循环继续执行,并进行下次判定....
 

ABA问题

CAS在运行中的核心:检查value和oldValue是否一致。如果一致,就视为value 中途没有被修改过,所以进行下一步交换操作是没问题的。

但是这里的一致,可能是没改过,也可能是改过,但是改回来了。

把value的值设为A的话,CAS判定value为A,此时可能确实value始终是A,也可能是value本来是A,被改成了B,又被还原成了A。

ABA这个情况,大部分情况下其实是不会对代码产生太大影响的,但是不排除一些极端情况,也是可能造成影响的。

假设我有 100 存款.。我想从 ATM 取 50 块钱,取款机创建了两个线程,并发的来执行 -50 操作,我们期望一个线程执行 -50 成功, 另一个线程 -50 失败。 如果使用 CAS 的方式来完成这个扣款过程就可能出现问题。

正常的过程

1) 存款 100。线程1 获取到当前存款值为 100,期望更新为 50; 线程2 获取到当前存款值为 100,,期望更新为 50。

2) 线程1 执行扣款成功,存款被改成 50。线程2阻塞等待中。

3) 轮到线程2执行了,发现当前存款为 50,和之前读到的 100 不相同,执行失败。

异常的过程

1) 存款 100。线程1 获取到当前存款值为 100,期望更新为 50;线程2 获取到当前存款值为 100,,期望更新为 50。

2) 线程1执行扣款成功,存款被改成 50。线程2阻塞等待中。

3) 在线程2执行之前,我的朋友正好给滑稽转账 50,账户余额变成 100 。

4) 轮到线程2 执行了,发现当前存款为100,和之前读到的100相同,再次执行扣款操作

这个时候扣款就被执行了两次。

解决办法

给要修改的值,引入版本号。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。CAS 操作在读取旧值的同时,也要读取版本号。真正修改的时候,如果当前版本号和读到的版本号相同,则修改数据,并把版本号 + 1 。如果当前版本号高于读到的版本号,操作失败(认为数据已经被修改过了)。

synchronized原理

两个线程,针对一个对象加锁,就会产生阻塞等待。

synchronized内部其实还有一些优化机制,存在的目的就是为了让这个锁更高效,更好用。

1.锁升级、锁膨胀

1)无锁  2)偏向锁  3)轻量级锁  4)重量级锁

synchronized(locker){
}

当代码执行到这个代码块中之后,加锁过程会经历前面说的这几个阶段:

偏向锁:

进行加锁的时候,首先会进入到偏向锁的状态。偏向锁的过程就是:“非必要,不加锁”。

synchronized的时候,并不是真的加锁,先偏向锁状态,做个标记(这个过程是非常轻量的),如果整个使用锁的过程中,都没有出现锁竞争,在synchronized执行完之后,取消偏向锁即可。

(反正也没有锁竞争,这样就开销最低了~)

但是如果使用过程中,另一个线程也尝试加锁,在它加锁之前,迅速地把偏向锁升级成真正的加锁状态,另一个线程也就只能阻塞等待了~

轻量级锁:

当synchronized发生锁竞争的时候,就会从偏向锁,升级成轻量级锁。

此时,synchronized相当于是通过自旋的方式来进行加锁的。刚才那个CAS那里的那个伪代码一样~

synchronized内部的自旋循环中,有个计数器,记录了循环了多少次/多久了,达到一定程度,就结束循环,执行重量级锁的逻辑。

重量级锁:

此时如果线程进行了重量级锁的加锁,并且发生锁竞争,此时线程就会被放到阻塞队列中,暂时不参与CPU调度了~

然后锁被释放了这个线程才有机会被调度到,并且有机会获取到锁。

锁降级:

锁能升级了,但是不能降级。只有锁升级,没有锁降级。除非是另外搞一个对象,重复刚才的从偏向锁开始升级的过程~

2.锁消除

编译器智能的判定,看当前的代码是否是真的要加锁,如果这个场景不需要加锁,程序猿也加了,就自动把锁给干掉~~
StringBuffer关键方法都带有synchronized。
但是如果在单线程中使用StringBuffer, synchronized 加了也白加,此时编译器就会直接把这些加锁操作干掉了。

3.锁粗化

锁的粒度:synchronized包含的代码越多,粒度就越粗,包含的代码就越少,粒度就越细。

通常情况下,认为锁的粒度细一点比较好。加锁的部分,是不能并发执行的。锁的粒度越细,能并发的代码就越多,反之就越少。

但是有些情况下,锁的粒度粗一些反而更好~

多线程进阶:常见的锁策略、CAS_第2张图片


 

你可能感兴趣的:(java,开发语言)