掌握这些套路,你也能顺利解决并发问题

2023-10-25 09:44:09 浏览数 (1)

一、情景再现

小菜开发的统计调用商品详情接口次数的功能代码存在严重的线程安全问题,会导致统计出来的结果数据远远低于预期结果,这个问题困扰了小菜很长时间,经过老王的耐心讲解,小菜已经明白了出现线程安全问题的原因。但是,作为211、985毕业的高材生,小菜并不会止步于此,他可是立志要成为像老王一样的牛人。所以,他也在思考着解决这些线程安全问题的方案。

二、寻求帮助

尽管小菜思想上很积极,也很主动,但是对于一个刚刚毕业的应届生来说,很多知识不够系统,也不够全面,在网上搜索对应的解决方案时,也不知道哪些信息是正确的,哪些是模棱两可的。于是,小菜决定还是要请教自己的直属领导老王。

这天,小菜还是早早的来到了公司等老王的到来。过了一会儿,他看到老王来到了公司,便主动走到老王的工位说:“老大,我现在知道我写的代码为什么会出现线程安全的问题了,但是有哪些方案可以解决这些问题,我现在还不太清楚,可以给我讲讲吗?”。

“可以,你拿上笔和本子,我们还是到会议室说吧”,说着,老王便拿起了电脑,与小菜一起向会议室走去。

三、并发问题解决方案

“我们先来从整体上了解下解决并发问题存在哪些方案,其实,总体上来说,解决并发问题可以分为有锁方案和无锁方案”,说着老王便打开电脑画了一张解决并发问题解决方案的图,如图3-1所示。

老王接着说:“看这张图,解决并发问题的方案总体上可以分成有锁方案和无锁方案,有锁方案可以分成synchronized锁和Lock锁两种方案,无锁方案可以分成局部变量、CAS原子类、ThreadLocal和不可变对象等几种方案。小菜你先把这张图记一下,接下来,我们再一个个讲一下这些方案”。

“好的”,小菜回应道。

四、加锁方案

“好了,我们继续讲,这里,我们一起讲synchronized锁和Lock锁,它们统称为加锁方案”,老王说道,“像synchronized锁和Lock锁,都是采用了悲观锁策略,实现的功能类似,只不过synchronized锁是通过JVM层面来实现加锁和释放锁,在使用时,不需要我们自己手动释放锁。而Lock锁是通过编码方式实现加锁和释放锁,在使用时,需要我们自己在finally代码块中释放锁,我们先来看一段代码”。说着,老王便在IDEA中噼里啪啦的敲了一段代码,这段代码的类是SynchronizedLockCounter。

SynchronizedLockCounter类的源码详见:concurrent-design-patterns-immutable工程下的io.binghe.concurrent.design.right.SynchronizedLockCounter。

代码语言:javascript复制
public class SynchronizedLockCounter {
    private int count;
    private Lock lock = new ReentrantLock();

    public void lockMethod(){
        lock.lock();
        try{
            this.add();
        }finally {
            lock.unlock();
        }
    }

    public synchronized void synchronizedMethod(){
        this.add();
    }

    private void add(){
        count  ;
    }
}

“看这个类,lockMethod()使用了Lock加锁和释放锁,并且是我们自己在finally代码块中手动释放了锁。而使用synchronized加锁时,并没有手动释放锁,两个方法都具备原子性。这点明白吗?”。

“明白”,小菜说道。

“好,那接下来,我们再分析下上面的代码,其实,在执行count 操作时,还是会分成三个步骤”。

1.从主内存读取count的值。

2.将count的值进行加1操作。

3.将count的值写回主内存。

“使用synchronized和Lock对方法加锁,都会保证上面三个步骤的原子性,那是怎么保证的呢?我们再来看一张图”,说着老王又画了一张图,如图3-2所示。

“我们结合这张图来讲”,老王画完图对小菜说道:“假设现在有线程1和线程2两个线程同时抢占锁资源,假设线程1抢占锁成功后执行代码逻辑,而线程2由于抢占锁失败,就会进入等待队列,当线程1执行完代码逻辑释放锁之后,就会通知等待队列中的线程去尝试重新获取锁,如果此时线程2成功获取到锁,就会执行代码逻辑”。

小菜也是边听边记。

接着老王又说到:“synchronized锁和Lock能够保证原子性的原理了解了吧?”。

“了解了”,小菜回应道。

“好,你先简单消化下,我们接下来简单讲讲局部变量”。

“好的”,小菜在本子上快速的记录着。

五、局部变量

“好了,我们继续讲讲局部变量吧”,老王说道。

“好的”,小菜回应道。

“其实说起局部变量,它只会存在于每个线程的工作线程中,不会在多个线程之前共享,所以不会有线程安全的问题,我们还是看一个代码片段”,说着老王又写了一个LocalVariable类。

源码详见:concurrent-design-patterns-immutable工程下的io.binghe.concurrent.design.right.LocalVariable。

代码语言:javascript复制
public class LocalVariable {
    public void localVariableMethod(){
        int count = 0;
        count  ;
        System.out.println(count);
    }
}

“假设多个线程执行LocalVariable类的localVariableMethod()方法,只有当每个线程执行到int count = 1; 这行代码时,才会在各自线程的工作内存中创建count局部变量,并且这个count变量不会在多个线程之间共享”。老王一边说,一边画图,如图3-3所示。

“看到图我明白了”,这个时候,小菜说话了:“局部变量只会存在于每个线程的工作内存中,多个线程之间根本不会共享局部变量的值,所以,局部变量是线程安全的”。

“很好,看来对于局部变量是理解透彻了”,老王微笑着说,“那我们再来看看CAS原子类”。

六、CAS原子类

“在讲CAS原子类之前,我们先来看看什么是CAS,CAS的英文全称是Compare And Swap,中文就是比较并交换”。

“CAS我知道是怎么回事”,小菜说道:“CAS使用了3个基本操作数,需要读写的内存值 V,进行比较的值 A和要写入的新值 B,当且仅当 V 的值等于 A 时, CAS 通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作,并且CAS中的比较和交换是一个原子操作,一般情况下是一个自旋操作,也就是会不断的重试”。

“很好,小菜,看来你对CAS已经有所了解了”,老王说道。

“嘿嘿,前几天看过相关的知识点”,小菜挠了挠头发。

“好,那我们再讲讲Java中的CAS原子类”,老王继续道。

“Java中提供了一系列以Atomic开头的CAS原子类,它们的并发性能比较高,可以多个线程同时执行,并且不会出现线程安全问题”,说着,老王又写了一段代码。

源码详见:concurrent-design-patterns-immutable工程下的io.binghe.concurrent.design.right.AtomicIntegerTest。

代码语言:javascript复制
public class AtomicIntegerTest {

    private AtomicInteger atomicIntegerCount = new AtomicInteger(0);

    public void add(){
        atomicIntegerCount.incrementAndGet();
    }
}

“在这段代码中,声明了一个AtomicInteger类型的成员变量atomicIntegerCount,并且在add()方法中调用了atomicIntegerCount的incrementAndGet()方法,此时无论多少个线程调用add()方法,都不会出现线程安全的问题”。

“这是为什么呢?”,此时的小菜有点不解,“atomicIntegerCount也是成员变量呀,它会在多个线程之前共享,为什么就没有线程安全问题呢?”。

“别急,我们慢慢来”,老王说道:“其实答案就在AtomicInteger类的源码里”,说着老王打开了AtomicInteger类的源码,如下所示。

代码语言:javascript复制
public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;

“我们先来看这部分代码”,老王继续说,“在AtomicInteger代码中,会有一个Unsafe类的实例对象,Unsafe类是JDK中提供的一个硬件级别的原子操作类,底层是通过native方法调用C 代码实现的功能,提供了内存分配和释放、线程挂起和恢复,定位对象字段的内存地址和修改对象在内存地址里的字段值等等一系列的操作,Java中也基于Unsafe类实现了CAS操作”。

“Unsafe类我在学校的时候了解一点,但是具体有点忘记了,今天又想起来了”,小菜说道。

“很好”,老王继续说,“我们再使用AtomicInteger类时,主要是使用里面的CAS操作,就拿AtomicIntegerTest类中,在add()方法里调用AtomicInteger的incrementAndGet()方法来说吧,最终会调用到AtomicInteger类的getAndAddInt()方法”。

代码语言:javascript复制
public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5   var4));

    return var5;
}

“在getAndAddInt()方法中,首先会获取内存中的旧值,然后赋值给var5变量,接着会调用compareAndSwapInt()方法通过CAS的方式进行比较并交换操作,如果操作失败,就会进入while循环,直到操作成功。其中,compareAndSwapInt()方法底层调用的是C 代码实现的功能,它能够保证比较并交换操作的原子性,这样就能够避免并发问题”。老王继续说。

“我们再来看看你昨天写的代码,如果使用AtomicInteger类实现的话,就不会出现线程安全问题了”,说着老王又在IDEA中写下了RightCounter类。

源码详见:concurrent-design-patterns-immutable工程下的io.binghe.concurrent.design.right.RightCounter。

代码语言:javascript复制
public class RightCounter {

    private AtomicInteger atomicIntegerCounter = new AtomicInteger(0);

    public void accessVisit(){
        atomicIntegerCounter.incrementAndGet();
    }

    public int getVisitCount(){
        return atomicIntegerCounter.get();
    }
}

“这段代码就不会出现线程安全问题了,那这段代码的执行流程是啥呢?我们继续看一张图”,说着,老王大手一挥,又画了一张图,如图3-4所示。

“假设此时有两个线程,分别是线程1和线程2同时访问RightCounter类的accessVisit()方法,此时主内存中的visitCount值为0,线程1和线程2同时对visitCount的值进行累加操作。此时假设线程1和线程2都读取到的visitCount的值都是0,线程1成功执行了CAS操作,将visitCount的值由0变更为1。而线程2在执行CAS操作时,发现此时内存中的visitCount的值是1不是0,所以,线程2会重新读取内存中的visitCount的值,此时从内存中读取到的visitCount的值就为1,接下来,就会将visitCount的值由1变更为2,这样就得出了正确的结果。这里,明白了吗”?老王问小菜。

“明白了”,小菜回答道。

“好,我们再来讲讲ThreadLocal”。

七、ThreadLocal

“ThreadLocal其实很简单,没有想象的那么复杂。ThreadLocal本质上也是在每个线程里存储一份数据的副本,这个数据副本不会在多个线程之间共享,互不影响,还是来看图”。老王是真牛,又要画图了,如图3-5所示。

画完图,老王继续说:“按照图来说,假设我们现在定义了一个名字为count的ThreadLocal类,它会在每个线程中复制一份Integer对象,但是每个线程复制的Integer对象,并不是同一个对象,每个对象只会被一个线程操作。在多个线程之间不存在共享变量,自然就不会有线程安全问题”。

“噢,ThreadLocal理解起来确实比较简单,这个我学会了”,小菜兴奋的说。

“很好,小菜,那我们再讲讲不可变对象?能消化吧?”。

“好的,能消化”。。。

八、不可变对象

“不可变对象,从其名字就可以看出,说的是这个对象一经创建,对外的一些状态就不会再发生变化了,如果一个对象是不变的,无论有多少个线程来访问它,它也不会变化。连对象都不变了,那它肯定就是线程安全的了”。

“这里有点听不懂”,小菜说道。

“不急,我们来举个例子”,老王说道,“比如,我们在开发过程中,经常式使用的字符串对象,本质上就是一个不可变对象,例如,String name = 'xiaocai',我们说的字符串是'xiaocai'这个字符串,而不是指的引用’xiaocai‘ 字符串的name变量,哪怕对'xiaocai'这个字符串进行了一系列的操作,例如拼接了其他的字符串,得到了一个新的字符串'good morning, xiaocai', 原来的'xiaocai'这个字符串也不会发生变化,这样说明白了吗?”。

“明白了,我记一下”。

“好,今天讲的知识点有点多,自己要好好总结和消化下啊”,老王对小菜说。

“好的,我先记一下,下班后回去后,我再好好总结和思考下”,小王说到。

“好,那我们出去吧”。

“好的”。

二人一起走出了会议室,小菜今天又学到了不少知识。

九、本章总结

本章,主要以老王的视角为小菜,介绍了解决并发问题的常见方案。首先,从总体上介绍了并发问题的解决方案。接下来,以此介绍了加锁方案、局部变量、CAS原子类、ThreadLocal和不可变对象。这些方案都能够解决线程的安全问题,主人公小菜今天又学到了不少知识。

最后,可以在评论区写下你学完本章节的收获,祝大家都能学有所成,我们一起搞定高并发设计模式。

好了,愿大家都能有所收获,我是冰河,我们下期见~~

往期推荐

推荐

0 人点赞