面试突击36:线程安全问题是怎么产生的?

线程安全是指某个方法或某段代码,在多线程中能够正确的执行,不会出现数据不一致或数据污染的情况,我们把这样的程序称之为线程安全的,反之则为非线程安全的。

举个例子来说,比如银行只有张三一个人来办理业务,这种情况在程序中就叫做单线程执行,而单线程执行是没有问题的,也就是线程安全的。但突然有一天来了很多人同时办理业务,这种情况就叫做多线程执行。如果所有人都一起争抢着办理业务,很有可能会导致错误,而这种错误就叫非线程安全。如果每个人都能有序排队办理业务,且工作人员不会操作失误,我们就把这种情况称之为线程安全的。

问题演示

接下来我们演示一下,程序中非线程安全的示例。我们先创建一个变量 number 等于 0,然后开启线程 1 执行 100 万次 number++ 操作,同时再开启线程 2 执行 100 万次 number-- 操作,等待线程 1 和线程 2 都执行完,正确的结果 number 应该还是 0,但不加干预的多线程执行结果却与预期的正确结果不一致,如下代码所示:

public class ThreadSafeTest {
    // 全局变量
    private static int number = 0;
    // 循环次数(100W)
    private static final int COUNT = 1_000_000;

    public static void main(String[] args) throws InterruptedException {
        // 线程1:执行 100W 次 number+1 操作
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < COUNT; i++) {
                number++;
            }
        });
        t1.start();

        // 线程2:执行 100W 次 number-1 操作
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < COUNT; i++) {
                number--;
            }
        });
        t2.start();

        // 等待线程 1 和线程 2,执行完,打印 number 最终的结果
        t1.join();
        t2.join();
        System.out.println("number 最终结果:" + number);
    }
}

以上程序的执行结果如下图所示:

image.png


从上述执行结果可以看出,number 变量最终的结果并不是 0,和我们预期的正确结果是不相符的,这就是多线程中的线程安全问题。

产生原因

导致线程安全问题的因素有以下 5 个:

  1. 多线程抢占式执行。
  2. 多线程同时修改同一个变量。
  3. 非原子性操作。
  4. 内存可见性。
  5. 指令重排序。

接下来我们分别来看这 5 个因素的具体含义。

1.多线程抢占式执行

导致线程安全问题的第一大因素就是多线程抢占式执行,想象一下,如果是单线程执行,或者是多线程有序执行,那就不会出现混乱的情况了,不出现混乱的情况,自然就不会出现非线程安全的问题了。

2.多线程同时修改同一个变量

如果是多线程同时修改不同的变量(每个线程只修改自己的变量),也是不会出现非线程安全的问题了,比如以下代码,线程 1 修改 number1 变量,而线程 2 修改 number2 变量,最终两个线程执行完之后的结果如下:

public class ThreadSafe {
    // 全局变量
    private static int number = 0;
    // 循环次数(100W)
    private static final int COUNT = 1_000_000;
    // 线程 1 操作的变量 number1
    private static int number1 = 0;
    // 线程 2 操作的变量 number2
    private static int number2 = 0;

    public static void main(String[] args) throws InterruptedException {
        // 线程1:执行 100W 次 number+1 操作
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < COUNT; i++) {
                number1++;
            }
        });
        t1.start();

        // 线程2:执行 100W 次 number-1 操作
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < COUNT; i++) {
                number2--;
            }
        });
        t2.start();

        // 等待线程 1 和线程 2,执行完,打印 number 最终的结果
        t1.join();
        t2.join();
        number = number1 + number2;
        System.out.println("number=number1+number2 最终结果:" + number);
    }
}

以上程序的执行结果如下图所示:

image.png


从上述结果可以看出,多线程只要不是同时修改同一个变量,也不会出现线程安全问题

3.非原子性操作

原子性操作是指操作不能再被分隔就叫原子性操作。比如人类吸气或者是呼气这个动作,它是一瞬间一次性完成的,你不可能先吸一半(气),停下来玩会手机,再吸一半(气),这种操作就是原子性操作。而非原子性操作是我现在要去睡觉,但睡觉之前要先上床,再拉被子,再躺下、再入睡等一系列的操作综合在一起组成的,这就是非原子性操作。
非原子性操作是有可以被分隔和打断的,比如要上床之前,发现时间还在,先刷个剧、刷会手机、再玩会游戏,甚至是再吃点小烧烤等等,所以非原子性操作有很多不确定性,而这些不确定性就会造成线程安全问题问题。
像 i++ 和 i-- 这种操作就是非原子的,它在 +1 或 -1 之前,先要查询原变量的值,并不是一次性完成的,所以就会导致线程安全问题。
比如以下操作流程:

操作步骤 线程1 线程2
T1 读取到 number=1,准备执行 number-1 的操作,但还没有执行,时间片就用完了。
T2 读取到 number=1,并且执行 number+1 操作,将 number 修改成了 2。
T3 恢复执行,因为之前已经读取了 number=1,所以直接执行 -1 操作,将 number 变成了 0。

以上就是一个经典的错误,number 原本等于 1,线程 1 进行 -1 操作,而线程 2 进行加 1,最终的结果 number 应该还等于 1 才对,但通过上面的执行,number 最终被修改成了 0,这就是非原子性导致的问题。

4.内存可见性问题

在 Java 编程中内存分为两种类型:工作内存和主内存,而工作内存使用的是 CPU 寄存器实现的,而主内存是指电脑中的内存,我们知道 CPU 寄存器的操作速度是远大于内存的操作速度的,它们的性能差异如下图所示:


那这和线程安全有什么关系呢?
这是因为在 Java 语言中,为了提高程序的执行速度,所以在操作变量时,会将变量从主内存中复制一份到工作内存,而主内存是所有线程共用的,工作内存是每个线程私有的,这就会导致一个线程已经把主内存中的公共变量修改了,而另一个线程不知道,依旧使用自己工作内存中的变量,这样就导致了问题的产生,也就导致了线程安全问题。

5.指令重排序

指令重排序是指 Java 程序为了提高程序的执行速度,所以会对一下操作进行合并和优化的操作。
比如说,张三要去图书馆还书,舍友又让张三帮忙借书,那么程序的执行思维是,张三先去图书馆把自己的书还了,再去一趟图书馆帮舍友把书借回来。而指令重排序之后,把两次执行合并了,张三带着自己的书去图书馆把书先还了,再帮舍友把书借出来,整个流程就执行完了,这是正常情况下的指令重排序的好处。
但是指令重排序也有“副作用”,而“副作用”是发生在多线程执行中的,还是以张三借书和帮舍友还书为例,如果张三是一件事做完再做另一件事是没有问题的(也就是单线程执行是没有问题的),但如果是多线程执行,就是两件事由多个人混合着做,比如张三在图书馆遇到了自己的多个同学,于是就把任务分派给多个人一起执行,有人借了几本书、有人借了还了几本书、有人再借了几本书、有人再借了还了几本书,执行的很混乱没有明确的目标,到最后悲剧就发生了,这就是在指令重排序带来的线程安全问题。

总结

线程安全是指某个方法或某段代码,在多线程中能够正确的执行,不会出现数据不一致或数据污染的情况,反之则为线程安全问题。简单来说所谓的非线程安全是指:在多线程中,程序的执行结果和预期的正确结果不一致的问题。而造成线程安全问题的因素有 5 个:多线程抢占式执行、多线程同时修改同一个变量、非原子性操作、内存可见性和指令重排序。

是非审之于己,毁誉听之于人,得失安之于数。

公众号:Java面试真题解析

面试合集:https://gitee.com/mydb/interview

原文地址:https://www.cnblogs.com/vipstone/p/16109645.html

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


在 Java 语言中,提高程序的执行效率有两种实现方法,一个是使用线程、另一个是使用线程池。而在生产环境下,我们通常会采用后者。为什么会这样呢?今天我们就来聊聊线程池的优点,以及池化技术及其应用。 1.池化技术 池化技术指的是提前准备一些资源,在需要时可以重复使用这些预先准备的资源。 池化技术的优点
在 Java 中停止线程的实现方法有以下 3 种: 自定义中断标识符,停止线程。 使用线程中断方法 interrupt 停止线程。 使用 stop 停止线程。 其中 stop 方法为 @Deprecated 修饰的过期方法,也就是不推荐使用的过期方法,因为 stop 方法会直接停止线程,这样就没有给
在多线程编程中,wait 方法是让当前线程进入休眠状态,直到另一个线程调用了 notify 或 notifyAll 方法之后,才能继续恢复执行。而在 Java 中,wait 和 notify/notifyAll 有着一套自己的使用格式要求,也就是在使用 wait 和 notify(notifyAll
在 Java 语言中,并发编程都是通过创建线程池来实现的,而线程池的创建方式也有很多种,每种线程池的创建方式都对应了不同的使用场景,总体来说线程池的创建可以分为以下两类: 通过 ThreadPoolExecutor 手动创建线程池。 通过 Executors 执行器自动创建线程池。 而以上两类创建线
sleep 方法和 wait 方法都是用来将线程进入休眠状态的,并且 sleep 和 wait 方法都可以响应 interrupt 中断,也就是线程在休眠的过程中,如果收到中断信号,都可以进行响应,并抛出 InterruptedException 异常。那 sleep 和 wait 的区别都有哪些呢
在 Java 中,线程的创建方法有 7 种,分为以下 3 大类: 继承 Thread 类的方式,它有 2 种实现方法。 实现 Runnable 接口的方式,它有 3 种实现方法。 实现 Callable 接口的方式,它有 2 种实现方法。 接下来我们一个一个来看。 1.继承Thread类 继承 Th
所谓的线程池的 7 大参数是指,在使用 ThreadPoolExecutor 创建线程池时所设置的 7 个参数,如以下源码所示: public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
在 Java 语言中,线程分为两类:用户线程和守护线程,默认情况下我们创建的线程或线程池都是用户线程,所以用户线程也被称之为普通线程。 想要查看线程到底是用户线程还是守护线程,可以通过 Thread.isDaemon() 方法来判断,如果返回的结果是 true 则为守护线程,反之则为用户线程。 我们
聊到线程池就一定会聊到线程池的执行流程,也就是当有一个任务进入线程池之后,线程池是如何执行的?我们今天就来聊聊这个话题。线程池是如何执行的?线程池的拒绝策略有哪些? 线程池执行流程 想要真正的了解线程池的执行流程,就得先从线程池的执行方法 execute() 说起,execute() 实现源码如下:
单例模式是面试中的常客了,它的常见写法有 4 种:饿汉模式、懒汉模式、静态内部类和枚举,接下来我们一一来看。 1.饿汉模式 饿汉模式也叫预加载模式,它是在类加载时直接创建并初始化单例对象,所以它并不存在线程安全的问题。它是依靠 ClassLoader 类机制,在程序启动时只加载一次,因此不存在线程安
线程安全是指某个方法或某段代码,在多线程中能够正确的执行,不会出现数据不一致或数据污染的情况,我们把这样的程序称之为线程安全的,反之则为非线程安全的。在 Java 中,解决线程安全问题有以下 3 种手段: 使用线程安全类,比如 AtomicInteger。 加锁排队执行 使用 synchronize
在 Java 语言中,保证线程安全性的主要手段是加锁,而 Java 中的锁主要有两种:synchronized 和 Lock,我们今天重点来看一下 synchronized 的几种用法。 用法简介 使用 synchronized 无需手动执行加锁和释放锁的操作,我们只需要声明 synchronize
在 Java 语言中,有两个线程池可以执行定时任务:ScheduledThreadPool 和 SingleThreadScheduledExecutor,其中 SingleThreadScheduledExecutor 可以看做是 ScheduledThreadPool 的单线程版本,它的用法和
从公平的角度来说,Java 中的锁总共可分为两类:公平锁和非公平锁。但公平锁和非公平锁有哪些区别?孰优孰劣呢?在 Java 中的应用场景又有哪些呢?接下来我们一起来看。 正文 公平锁:每个线程获取锁的顺序是按照线程访问锁的先后顺序获取的,最前面的线程总是最先获取到锁。 非公平锁:每个线程获取锁的顺序
单例模式的实现方法有很多种,如饿汉模式、懒汉模式、静态内部类和枚举等,当面试官问到“为什么单例模式一定要加 volatile?”时,那么他指的是为什么懒汉模式中的私有变量要加 volatile? 懒汉模式指的是对象的创建是懒加载的方式,并不是在程序启动时就创建对象,而是第一次被真正使用时才创建对象。
读写锁(Readers-Writer Lock)顾名思义是一把锁分为两部分:读锁和写锁,其中读锁允许多个线程同时获得,因为读操作本身是线程安全的,而写锁则是互斥锁,不允许多个线程同时获得写锁,并且写操作和读操作也是互斥的。总结来说,读写锁的特点是:读读不互斥、读写互斥、写写互斥。 1.读写锁使用 在
很多场景下,我们需要等待线程池的所有任务都执行完,然后再进行下一步操作。对于线程 Thread 来说,很好实现,加一个 join 方法就解决了,然而对于线程池的判断就比较麻烦了。 我们本文提供 4 种判断线程池任务是否执行完的方法: 使用 isTerminated 方法判断。 使用 getCompl
在 Java 中,线程池的状态和线程的状态是完全不同的,线程有 6 种状态:NEW:初始化状态、RUNNABLE:可运行/运行状态、BLOCKED:阻塞状态、WAITING:无时限等待状态、TIMED_WAITING:有时限等待状态和 TERMINATED:终止状态。而线程池的状态有以下 5 种:
volatile 是 Java 并发编程的重要组成部分,也是常见的面试题之一,它的主要作用有两个:保证内存的可见性和禁止指令重排序。下面我们具体来看这两个功能。 内存可见性 说到内存可见性问题就不得不提 Java 内存模型,Java 内存模型(Java Memory Model)简称为 JMM,主要
1.第一范式 第一范式规定表中的每个列都应该是不可分割的最小单元。比如以下表中的 address 字段就不是不可分割的最小单元,如下图所示: 其中 address 还可以拆分为国家和城市,如下图所示: 这样改造之后,上面的表就满足第一范式了。 2.第二范式 第二范式是在满足第一范式的基础上,规定表中