多线程强化(上)

简介: 多线程强化(上)

一、锁的策略( 面试题 )



锁的策略与编程语言无关


1. 乐观锁和悲观锁


乐观锁:假设一般情况下都不会产生锁冲突,因此就尝试直接访问数据,如果发现了锁冲突,之后再去处理。


悲观锁:假设一般情况下都会产生锁冲突,因此会先进行处理,然后再尝试访问数据。

锁冲突:线程阻塞等待,影响程序效率。


举个例子:有两个同学晚上在家做作业的时候,有一道数学题不会,准备发 QQ 问老师。


同学A 认为老师随时都有空,能够随时为自己解答,所以直接就问老师了。这就是乐观锁。


同学B 认为,既然已是放学时间,老师可能在吃晚饭,也可能已经休息了,所以自己就又把不会的数学题再做了一遍,等过了晚餐时间,同学B 再问老师问题。这就是悲观锁。


这两种思路不能说谁好谁坏,而是看当前的场景是否合适。

如果当前老师确实在休息,那么使用悲观锁的策略更合适。使用乐观锁会 " 浪费了时间 ",耗费额外的资源,而当前老师确实比较闲,那么使用乐观锁的策略更合适,使用悲观锁会让效率比较低。


Synchronized 初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。而乐观锁的一个重要功能就是要检测出数据是否发生访问冲突。


2. 读写锁


在日常开发中,很多场景都是读数据较多,写数据较少,那么此时我们就可以应用读写锁。


一个线程对于数据的访问,主要存在两种操作:读数据 和 写数据。


(1) 两个线程同时读一个数据,此时并不会造成线程不安全,直接并发读取即可。

(2) 两个线程同时写一个数据,可能就会造成线程不安全。

(3) 一个线程读,另一个线程写,也可能会造成线程不安全。


Java 标准库里提供了一个关于读和写分开的类:

某个线程如果只是读数据,就使用 ReentrantReadWriteLock.readLock 类

某个线程如果只是写数据,就使用 ReentrantReadWriteLock.writeLock 类


假设现在有10个线程。t0、t9是写线程,t1 - t8 是读线程。

如果 t1 和 t2 两个线程同时访问数据,此时两个读锁之间不会互斥,完全并发地执行,这就和没加锁无区别。


如果 t0 和 t1 两个线程同时访问数据,此时读锁和写锁之间就可能会互斥,要么是读完再写,要么是写完再读。

如果 t0 和 t9 两个写线程同时访问数据,此时写锁和写锁之间也可能会互斥,一定是一个线程写完,另一个线程再写。


对于这种读比较多,写比较少的情况,使用读写锁,就能大大地提高效率,实际上就是降低了锁冲突的概率,因为一旦锁冲突发生,就会导致线程阻塞等待,这样的阻塞等待是比较影响程序的效率的。


我们使用 synchronized 关键字为对象加锁的时候,它并没有将读和写进行区分,只要使用,就会让线程之间发生互斥,这样一来,开销大,效率也低。


3. 重量级锁和轻量级锁


重量级锁:加锁解锁开销更大,往往是通过内核来完成的。

轻量级锁:加锁解锁开销更小,往往只是在用户态完成的。

重量级锁和轻量级锁这两个锁策略和应用场景没什么关系,它们的设计策略主要与加锁解锁开销大不大相关。


(1) 内核态和用户态


用户态就是应用程序执行的代码,内核态就是操作系统执行的代码,一般来说,两者之间的切换,是一个开销较大的过程。


而内核是很忙的,要给许许多多个进程同时提供服务,因此,一旦要将某个进程把某个任务交给内核来去做,此时什么时候能把事情做好,有些难以把握了。


这就像买高铁票一样,如果我们去高铁站的人工服务窗口购票,显然是很麻烦的一件事情,第一,你要排队,第二,售票员询问你乘坐的火车类型、车次、时间…,第三,售票员要为很多人服务,所以在面对形形色色的业务,他们所执行的时间较慢。而如果你直接从网上购票就不一样了,你可以按照自己的需求,很简单方便地就选购好了,退票、换票等等也比较直接。繁忙的售票员与你购票的场景就相当于内核态和用户态交互的过程,而你自己通过网上购票的场景就是用户态自己实现的过程,除了使用手机,就是自己与自己交互的过程。


2ee7d4ffa29245e4a7cd771b501dfba5.png


如果当前的锁就是通过内核的 mutex 来完成的,此时这样的锁往往就开销比较大。


如果当前的锁是在用户态,通过一些其他的手段来完成的,那么这样的锁往往就开销更小。


而 synchronized 初始是一个轻量级锁的状态,如果锁冲突比较严重,就会变成重量级锁。也就是说,它既是一个轻量级锁,又是一个重量级锁,根据场景,自适应。


4. 自旋锁和挂起等待锁


按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度。但实际上,大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题。


自旋锁伪代码:


while ( 抢锁(lock) == 失败 ) {
}


上述的代码就是无限循环,也就是说:线程如果获取锁失败,就会立即再次尝试获取锁,无限循环,直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来。一旦锁被其他线程释放,就能第一时间获取到锁。


举个例子:打篮球的小伙伴都喜欢在回家之前投一个 " 回家球 ",意思就是,最后一个球,我投进了,才离开球场,反正我是这样…那么我第一次投球没进,就会尝试第二次投球,直到投进球为止…或者说,换个不恰当的说法:不达目的,誓不罢休。这就是自旋锁。


而换作是挂起等待锁,它就比较佛系了,我只投一个 " 回家球 ",进或不进篮筐,我都离开球场了,如果我没进球,下次来球场打球再说…


自旋锁是一种典型的 轻量级锁 的实现方式。

优点:没有放弃 CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁。

缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗 CPU 资源。( 而挂起等待的时候是不消耗 CPU 的 )


自旋锁和挂起等待锁的使用场景是什么?

① 如果锁冲突的概率比较低,使用自旋锁比挂起等待锁,更合适。

② 如果线程持有锁的时间比较短,使用自旋锁也比挂起等待锁更合适。

③ 如果对 CPU 比较敏感不希望吃太多的CPU资源,那么就不太适合使用自旋锁。


而在 Java 中,synchronized 关键字可以自适应这两者的情况。


5. 公平锁和非公平锁


举个例子:用户1、用户2、用户3…排队,在银行的 ATM 机取钱,当用户1 取钱的时候,给门上锁了。当用户1 取完钱后,锁打开了,那么就轮到用户2 取钱了,接着再轮到用户3…而像这种按排队顺序组织的线程执行就是采用了公平锁策略。


而如果用户1取完钱后,还没轮到用户2 进入房间,用户5 突然插队,去取钱了,这就是采用了非公平锁策略。


对于操作系统的调度器来说,默认就是不公平锁。而要想实现公平锁,就需要有额外的数据结构 ( 比方说,利用队列来记录线程先来后到的过程 )。那么,具体什么时候使用公平锁,什么时候使用非公平锁,就得看具体的需求。大部分情况下,使用非公平锁就够用了。有些场景下,我们期望对于线程的调度的时间成本是可控的,这个时候就需要公平锁了。


6. 可重入锁 vs 不可重入锁


我们针对一个线程加锁,第一次加锁的时候没有释放,又尝试加锁第二次,如果是不可重入锁,就会出现 " 死锁 " 现象;如果是可重入锁,就不会死锁。


代码:假设 func1 方法和 fun2 方法针对同一个对象 this 来进行加锁。


synchronized void func1() {
    func2();
}
synchronized void func2() {
}


在上面的代码中,我们发现,func1 方法先进行加锁操作,能够成功。而我们尝试对 func2 方法加锁,就会阻塞等待。


按照原有的理解,此时锁已经被 func1 占用了,因此 func2 就应该阻塞等待,等待 func1 执行完了,把锁释放了,func2 才能获取到锁。但由于 func2 在 func1 内部,所以当 func2 发生阻塞,就导致了 func1 也阻塞,于是 func1 就无法执行到最后的括号,也就释放不了锁。而这个时候,程序就发生了死锁。


而我们引入可重入就是在解决死锁这个问题,解决方式:

让当前的锁记录一下这把锁是谁持有的,如果发现当前有同一个线程再次尝试获取锁,就让代码能够继续运行,而不是阻塞等待。同时在这个锁里面也维护一个计数器,这个计数器记录了当前这个线程针对这把锁加了几次锁,每次加锁,就计数器+ +,每次解锁,就计数器- -,直到计数器为0了,此时才真的释放锁,此时才能够让其他线程获取到这个锁。


在 Java 中,其实上面的代码没有问题,因为 synchronized 就是一个可重入逻辑的锁。


二、CAS



1. 什么是 CAS


CAS:Compare and swap字面意思:比较并交换,一个 CAS 涉及到以下操作:

假设内存中的原数据 a,旧的预期值 b,需要修改的新值 c


① 比较 a 与 b 是否相等

② 若相等,将 c 写入 a;反之,不做任何操作

③ 操作成功返回 true,操作失败返回 false


2. 原子类


//原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
AtomicBoolean
AtomicInteger
AtomicIntegerArray
AtomicLong
AtomicReference
AtomicStampedReference


//以 AtomicInteger 举例,常见方法有
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;


3. CAS 的原子性


在之前的博客,我们了解到了:一个变量自增操作实际上是三个步骤

count++:


① 把内存中 count 的值读到 CPU 中

② 在 CPU 中执行++操作

③ 把 CPU 中的操作后的值再写回到内存中


那么,如果使用加锁的办法,将上述的操作变为原子性,其实是比较低效的,因为它要求了其他线程需要阻塞等待…因此我们就可以使用 CAS ,既能够高效地完成自增操作,又能保证原子性,从而使多线程执行安全


Java 标准库中,为我们提供了原子类 AtomicInteger


程序清单1:


import java.util.concurrent.atomic.AtomicInteger;
public class Test {
    public static void main(String[] args) {
      //括号中的0 就是 num 的初始值
        AtomicInteger num = new AtomicInteger(0);
        //这个操作就相当于 num++
        num.getAndIncrement();
        System.out.println(num);
        //这个操作就相当于 num--
        num.getAndDecrement();
        System.out.println(num);
    }
}


输出结果:


3d07eee7d6414014b18ca8808c0e8eee.png


程序清单2:


在程序清单2中,这是我们之前使用多线程,让 count 自增的经典案例,t1 线程和 t2 线程各自增加 5w 次,我们尝试利用原子类来解决问题,前面我们提到了原子类具有原子性,那么此时的自增代码,我们无需进行加锁,只需要使用原子类的方法即可。


import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
public class Test {
    static class Counter {
      //定义 count 初始值为 0
        AtomicInteger count = new AtomicInteger(0);
        public ReentrantLock locker = new ReentrantLock();
        //让 count 变量自增
        public void increase() {
            count.getAndIncrement();
        }
    }
    static Counter counter = new Counter();
    public static void main(String[] args) {
        //线程1 自增 5w 次
        Thread t1 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        //线程2 自增 5w 次
        Thread t2 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(counter.count);
    }
}


输出结果:


76077d156279496e8f9d0bad04ee2792.png


那么,CAS 在底层是怎么实现这个原子类的呢?我们就拿刚刚的自增操作来举例子:


伪代码:


class AtomicInteger {
  private int value;
  public int getAndIncrement() {
    int oldValue = value;
    while ( CAS(value, oldValue, oldValue+1) != true ) {
      oldValue = value;
    }
  return oldValue;
  }
}


4. CAS 原子性的原理


首先,我们拿 oldValue 来记录当前内存中的 value. 如果 value 和 oldValue 相等,就将 value 替换成 oldValue+1;如果不相等,就循环将 oldValue 拿到新的 value.


fbe5f68871f1446d9dc3be1efd9ce00f.png


在上图中,我们就可以看到,两个线程并发自增变量的时候,由于 CAS 操作具有原子性,那么两个线程对同一个值修改,将十分高效!此外,它并不会引入线程不安全。


所以我们就明白了,CAS 是直接读写内存的,而不是操作寄存器,CAS 的读、写内存操作是一条硬件指令,就是一个原子的操作,对于线程使用 CAS 操作来说,可视为不可分割。


5. CAS 的 ABA 问题


ABA 问题:假设存在两个线程 t1 和 t2,有一个共享变量 num,初始值为 A,接下来,线程 t1 想使用 CAS 操作 把 num 值改成 C。而与此同时,可能会发生一种情况,线程2 在中间过程中,将 num 的值变成了 B,之后 num 的值又被改回了 A;而最终线程t1 依旧将 num 的值改回了 C。虽然 num 的结果是 C 不错,当实际上 num 这个变量还是多经历了一个变化过程。


e3b95af0940947b48ac867aa1b972a9a.png


举个例子:有些黑心商家,你去店里面买手机的时候,买了一部手机,然而,用了一段时间,你会发现手机很卡,之后自己把手机拆了,发现里面的很多部件都是旧的,而这部手机就是 " 翻新机 ",它表面上和新手机看起来一模一样,但实际上,它在售卖之前,黑心商家对它进行了一顿处理,将一个二手手机换了换电池,换了换壳…然而这就是从 A 转换成 B,再从 B 转换成 A 的问题,表面上看上去是一样,但本质就不一样了。


在计算机中的大部分情况下,CAS 的 ABA 问题影响并不大,因为它所得出的结果并没有问题,只是过程不一样,但也有些地方会出现问题。


情况一


假设,Jack 去银行的 ATM 取钱,他的账户有 1000 元,他准备从 ATM 取出 500元,取的时候,ATM 顿时卡了一下, Jack 就不小心按了两下取钱。

那么此时有两个线程,线程1和线程2都尝试进行 -500 的操作


① 线程1 获取到当前的存款值为1000,线程2 也获取到存款值1000.

② 线程1尝试执行 -500 操作之前,对比 ATM 中的存款值和 oldValue 是一致的,一致就扣钱,线程1执行完毕之后,账户余额就被改成了 500

③ 线程2尝试执行 -500 操作之前,对比 ATM 中的存款值和 oldValue ,结果发现不一致,就不会进行扣钱。


此时线程1 修改成功,线程2 修改失败,ATM 虽然卡了一下,出现了两个线程,但由于 CAS 操作的机制,保证了最终的结果仍然是对的,结果存款都是 500元。


ef547fc17f3048099688d012033147da.png


情况二


此情况下,ATM 依旧顿时卡了一下, Jack 依旧不小心按了两下取钱。

那么此时有两个线程,线程1和线程2都尝试进行 -500 的操作


① 线程1 获取到当前的存款值为1000,线程2 也获取到存款值1000.

② 线程1 尝试执行 -500 操作之前,对比 ATM 中的存款值和 oldValue 是一致的,一致就扣钱,线程1执行完毕之后,账户余额就被改成了 500

③ 在线程2尝试执行 -500 之前,突然有人给 Jack 的账户上又打了500 块钱账户,余额又变成1000了。

④ 线程2 时对比 ATM 值和 oldValue,发现结果是一致的,于是又扣了一次钱。


这个时候就出错了,本来卡里面最终剩余 500 + 500 = 1000元的,现在就只剩下 500元了。而问题呢,就出现在情况二的第 ④ 步骤,它分不清当前的1000元变过还是没变过!


1adbb91da46e4866881afc6890eca42e.png


情况二在 ABA 问题中,属于小概率事件,那么小概率事件需不需要被解决呢?答案是肯定的,因为这种情况已经涉及到 " 账户和金钱 " 了,比方说,现在某大型网络平台出现金额交易出错这种事故,对于那么多的用户基数,无疑是一种打击。


解决 ABA 问题的办法


CAS 操作在读取旧值的同时,也要读取版本号,按这个版本号来判断是否真正需要进行修改操作。


此处给 ATM 这个存款数据搭配一个版本号1,后续每次操作,都要对版本号+1,注意,这里版本号只允许加,不允许往后减,这样一来,我们就能明白了,版本号不可逆。而现在CAS 操作在进行对比数据的时候,就不是对比数值本身了,而是对比版本号。版本号相同,就修改;反之,就不修改。


① 线程1 获取到当前版本号是1,线程2 也获取到当前版本号1

② 线程1 尝试执行 -500 操作,对比 ATM 中版本号和自己当前记录的版本号是不是一致,一致就扣钱,结果发现一致,账户余额就被改成了 500,由于此时发生了更改操作,所以就把当前版本号改成了 2

③ 突然有人给 Jack 的账户上又打了500 块钱账户,余额又变成1000了。同时 ATM 中 的版本号变成了 3

④ 线程2 对比 ATM 中版本号和自己当前记录的版本号是不是一致,结果 ATM 的版本号为 3,自己记录的版本号为1,版本号不相等,于是不扣钱。


2d380665f9204ab694528e0be8d39dff.png


目录
相关文章
|
1天前
|
监控 安全 定位技术
《C++新特性:为多线程数据竞争检测与预防保驾护航》
多线程编程是提升软件性能的关键,但数据竞争问题却是一大挑战。C++新特性如增强的原子类型和完善的内存模型,为检测和预防数据竞争提供了有力支持。这些改进不仅提高了程序的可靠性,还提升了开发效率,使多线程编程更加安全高效。
41 19
|
4月前
|
安全 数据安全/隐私保护 数据中心
Python并发编程大挑战:线程安全VS进程隔离,你的选择影响深远!
【7月更文挑战第9天】Python并发:线程共享内存,高效但需处理线程安全(GIL限制并发),适合IO密集型;进程独立内存,安全但通信复杂,适合CPU密集型。使用`threading.Lock`保证线程安全,`multiprocessing.Queue`实现进程间通信。选择取决于任务性质和性能需求。
92 1
|
1月前
|
安全 Java 数据库连接
Python多线程编程:竞争问题的解析与应对策略【2】
Python多线程编程:竞争问题的解析与应对策略【2】
22 0
|
1月前
|
安全 Java 数据库连接
Python多线程编程:竞争问题的解析与应对策略
Python多线程编程:竞争问题的解析与应对策略
22 0
|
4月前
|
安全 Java 调度
多线程编程的挑战与解决方案
多线程编程的挑战与解决方案
|
5月前
|
缓存 并行计算 安全
【并发编程系列一】并发编年史:线程的双刃剑——从优势到风险的全面解析
【并发编程系列一】并发编年史:线程的双刃剑——从优势到风险的全面解析
|
5月前
|
安全 程序员
多线程的6个综合练习
多线程的6个综合练习
34 0
|
6月前
|
安全 物联网 Java
未来交织:新兴技术的融合与革新深入理解Java并发编程:线程安全与性能优化
【5月更文挑战第27天】 在数字化的浪潮中,创新技术如同星辰般璀璨,引领着时代的前行。本文聚焦于区块链、物联网(IoT)、虚拟现实(VR)等前沿科技,剖析它们的发展脉络,并探讨这些技术的交互融合与实际应用。通过深入分析,我们预见这些技术将如何重塑经济结构、改善人类生活,并引发社会层面的深刻变革。
|
安全 Java
【并发技术05】传统线程同步通信技术
【并发技术05】传统线程同步通信技术
多线程的安全问题
多线程的安全问题
70 0