0
点赞
收藏
分享

微信扫一扫

高薪程序员&面试题精讲系列70之如何保证线程安全?你有没有遇到过线程死锁问题?

炽凤亮尧 2022-03-11 阅读 98

一. 面试题及剖析

1. 今日面试题

2. 题目剖析

在之前的几篇文章中,壹哥 给大家重点讲解了线程、线程池、锁的种类及其原理等核心内容,这些内容在多线程开发时尤其重要。而在多线程开发环境下,也就是在所谓的“并发编程”环境下,经常会遇到“线程并发问题”,通俗地说就是“线程安全问题”!这个问题是多线程并发编程时的重点、难点、痛点,所以面试官经常会考察我们对并发编程及其安全性保障的掌握情况,以此来判断我们对线程及并发的处理能力。

总之,最近的几篇文章,是我们面试的高频考点,也是面试的重难点,希望各位可以重点掌握。如果你有什么不明白的地方,可以给壹哥留言,咱们互相讨论。

二. 线程安全问题

我们之前已经学习了关于线程、线程池、锁等方面的内容,感觉线程和锁使用起来也不是很难,那为什么会产生线程安全问题呢?如果真产生了线程安全问题,我们又该如何解决和避免呢?要想弄清楚这些问题,请大家跟着壹哥的思路,继续往下看吧。

1. 为什么会产生线程安全问题?

1.1 JMM内存模型

要想知道Java多线程并发操作时为什么会产生安全问题,我们首先来看看JMM(Java Memory Model)模型,如下图所示。

上图中,Java通过几种原子操作命令来完成工作内存和主内存的交互,这几种命令的含义如下:

话说壹哥小时候上数学课画坦克,让老师给骂了一顿,从此除了睡觉时擅长在床上绘制地图之外,其他时候就对画图有了阴影。但为了给大家讲明白今天的问题,上面这个JMM模型图可是壹哥花了1个多小时才画好的,真是废了老鼻子劲了,就冲这一点,各位是不是应该给壹哥点个赞啊。

1.2 产生线程安全问题的原因

从上图可知,在Java的内存模型中,我们可以把线程的内存分为主内存和工作内存。当有线程使用共享数据时,都是先从主内存中把数据拷贝到工作内存,使用完成之后再写入到主内存,所以我们可以认为线程之间是通过共享内存的方式来实现通讯的。

但在多线程环境下,不同线程对同一份数据进行并发操作时,可能会产生不同线程中的数据状态不一致的情况,这就是线程安全问题产生的原因和由来。

2. 线程安全概念

如果各位看过《Java并发编程实战》这本经典的多线程并发操作教程,就可以在其中找到关于线程安全的定义:

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在调用代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

关于这个定义,各位也不需要背诵,大家只需要结合我上面绘制的JMM模型图,再根据自己对多线程操作的理解,就可以对线程安全问题有个大概的认知了。

3. 无状态类

根据上面章节的内容,我们对线程安全问题就有了基本的认知。那么根据线程安全问题的定义,壹哥给大家设计了下面这样的一个类,你猜猜这个类会不会有线程安全问题?

/**
 * @Author: 一一哥
 * @Blame: yyg
 * @Since: Created in 2022/2/9
 */
public class OneStateless {
    
    public static void main(String[] args){
        System.out.println("无状态类...");   
    }
    
}

从上面代码中可以看出,该类中没有任何成员变量,我们把这种类叫做无状态类,这种类一定是线程安全的!

但如果该类某些方法中的参数引用了其他对象,那此时这个类还是线程安全的吗?答案也是肯定的!这是因为在多线程下使用方法中的参数,比如String,有可能String这个对象的实例会不正常。但对于OneStateless这个类的对象实例来说,它并不持有String对象的实例,它自己并不会有问题,有问题的是String这个实例对象,而非OneStateless本身。

4. 如何保证线程安全

既然现在我们已经对线程安全问题有了较为清晰的认知,那该怎么保证线程安全,避免或者说减少产生并发问题呢?

我们要保证线程安全,一般都需要满足数据操作的3个基本特性:

如果可以满足以上3个特性,其实就相当于在同一时刻只有一个线程进行数据操作,最后又将结果写入到主内存,其他线程就可以看到修改后的数据结果,这样就保证了线程的并发操作安全。当然真正开发时,我们主要是通过synchronized、atomic原子类、java.util.concurrent并发包等API来具体实现线程安全的代码。接下来 壹哥 就从这3个基本特性,并结合一些具体API,来给大家简单讲解该如何保证线程安全。今天壹哥先讲解如何保证线程操作的原子性。

三. 原子性--atomic

1. 原子性的实现方式

首先我们来看看如何满足线程安全的第一个特性,即原子性。如果想让我们的线程满足原子性要求,其实也不难,目前主要通过如下2种方式来实现。

接下来壹哥会分别针对这两种满足原子性的方式给大家进行讲解。

2. Atomic原子类

JDK中本身就自带了很多的Atomic原子类,比如AtomicInteger、AtomicLong、AtomicBoolean、AtomicLongArray、AtomicStampedReference等,这些原子类底层都是基于CAS(Unsafe.compareAndSwapInt)算法来保证原子性操作的。

2.1 Atomic原子类基本用法

关于Atomic原子类的使用,大家可以参考如下案例,这几个原子类用法大同小异,壹哥这里以AtomicLong为例展示原子类用法。

/**
 * @Author: 一一哥
 * @Blame: yyg
 * @Since: Created in 2022/2/9
 */
public class AtomicTest {

    /**
     * 定义一个原子变量,初始值为0。如果不使用原子类或添加同步锁,多线程并发进行以下递增操作时,得到的结果可能不是预期中的20000!
     */
    private AtomicLong atomicCount = new AtomicLong(0L);

    public void currentIncrement() throws InterruptedException {
        /**
         * 创建2个线程,2个线程都对count变量进行自加操作10000次
         */
        for (int i = 2; i > 0; i--) {
            new Thread(() -> {
                //自增
                for (int j = 0; j < 10000; j++) {
                    atomicCount.incrementAndGet();
                }
            }).start();
        }

        Thread.sleep(1000);
        System.out.println(atomicCount);
    }

    public static void main(String[] args) {
        try {
            //原子性操作
            AtomicTest atomicTest=new AtomicTest();
            atomicTest.currentIncrement();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

通过上面的代码,我们就可以在多线程并发环境下,得到预期的累加结果--20000!当然我们也可以利用synchronized同步锁,或者是利用JDK 8中提供的LongAdder计数器来实现该效果。但如果不使用以上3种方式,在进行多线程并发操作时,就可能会出现并发异常,也就是所谓的线程安全问题。原子类是如何保证线程安全的呢?其底层原理如何?我们继续往下分析。

2.2 Atomic原子类底层原理(重点)

Atomic原子类的底层,其实都是基于CAS(Unsafe.compareAndSwap)算法来实现原子性操作的。我们知道,CAS是一种无锁算法,属于乐观锁的一种具体实现。CAS算法有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,才将内存值V修改为B并返回true,否则返回false。

无论我们使用的是AtomicInteger,还是AtomicLong等任意原子类,这些类中一般都会有一个自增操作incrementAndGet()方法。该方法内部使用了unsefe类提供的unsefe.getAndAddInt/Long/Object...()方法,这个unfefe.getAndAddInt/Long/Object...()方法是通过do-while语句来做主体函数,在其内部又会调用compareAndSwapInt/Long/Object...(var1, var2, var5, var5+var4)来进行具体实现,如下图所示:

这里的compareAndSwapInt/Long/Object...()方法是一个native底层方法,属于CAS的核心方法,它不是由Java语言实现的,而是由C语言实现的,如下图所示:

该类方法的实现原理如下:

2.3 AtomicStampReference、AtomicLongArray、LongAdder简介

JDK中提供的原子类其实有很多,除了上面几个常规的原子类之外,还有其他一些新的原子类,比如AtomicStampReference、AtomicLongArray等,这里壹哥再简单给大家介绍几个其他的原子类。

2.3.1 AtomicStampReference简介

解决“ABA问题”的一个常用方法,就是可以在每次更新变量时,额外记录一个版本号,并加1递增,这样就避免了ABA问题。AtomicStampReference类就提供了解决ABA问题的方法,核心方法是compareAndSet()方法。方法中多了一个对stamp的比较,就是对变量版本号的比较,stamp的值也是在每次变量进行更新时进行维护。

2.3.2 AtomicLongArray简介

AtomicLongArray中维护了一个数组,可以选择性的更新其中某一个索引对应的值,这也是原子性的操作。相比于AtomicInteger和AtomicLong中的方法,AtomicLongArray中方法的参数列表多了数组索引的值。

2.3.3 LongAdder简介

JDK8中新增了一个LongAdder类,该类与AtomicLong很类似,只是在LongAdder中将AtomicLong里的incrementAndGet()方法改成了increment()方法。

之所以新增一个LongAdder类,是因为AtomicLong的CAS算法是在一个死循环中不断尝试修改目标的值,当竞争激烈时修改失败的几率就会很高,这就导致了会一直在循环中进行原子性操作,性能会受到影响。

LongAdder类的核心思想是将热点数据分离,比如将AtomicLong的内部核心数据Value分离成一个数组,每个线程访问时通过hash()算法映射到其中的一个元素进行运算,最后的结果为这些运算结果的求和累加,当前value的实际值也有Value分离出的所有元素累计合成。这样做相当于将AtomicLong的单点更新压力分散到各个节点上,在高并发是通过分散提高了性能。

但LongAdder也有缺点,统计时有可能会导致统计出的数据有误差,比如在生成序列号等需要准确且全局唯一数据时,还是应该使用AtomicLong。

3. 原子锁synchronized+Lock

保证线程原子操作的另一种方式,就是给线程添加原子锁,也就是我们熟知的同步锁。在Java中实现同步锁主要有以下两种方式:

关于synchronized+Lock锁,壹哥 在前面的文章中,已经对其用法及底层原理做了非常详细的介绍,各位可以翻看我前面的文章,这里不再细说。

4. synchronized、Lock、Atomic、LongAdder对比

上面壹哥给大家介绍了几种满足原子性的方式,那么在开发时,我们该怎么选择呢?壹哥对这几种方式做了个简单的对比,供大家参考:

四. 结语

今天壹哥 先给大家总结了保证线程安全性的第一个要点,就是满足线程操作的原子性,今天的内容你都Get到了吗?接下来壹哥会在下一篇文章中讲解如何满足线程操作的可见性与有序性,敬请各位继续关注哦。原创不易,如果各位觉得壹哥的文章写得还不错,请给我点赞关注一下,给壹哥持续的创作动力。

举报

相关推荐

0 条评论