Java多线程之线程安全问题

简介: Java多线程之线程安全问题

一. 线程安全概述

1. 什么是线程安全问题

我们知道操作系统中线程程的调度是抢占式执行的, 宏观上上的感知是随机的, 这就导致了多线程在进行线程调度时线程的执行顺序是不确定的, 因此多线程情况下的代码的执行顺序可能就会有无数种, 我们需要保证这无数种线程调度顺序的情况下, 代码的执行结果都是正确的, 只要有一种情况下, 代码的结果没有达到预期, 就认为线程是不安全的, 对于多线程并发时会使程序出现BUG的代码称作线程不安全的代码, 这就是线程安全问题.

2. 一个存在线程安全问题的程序

定义一个变量count, 初始值为0, 我们想要利用两个线程将变量count自增10万次, 每个线程各自负责5万次的自增任务.


于是写出了如下代码:

class Counter {
    public int count = 0;
    public void add() {
        count++;
    }
}
public class TestDemo12 {
    public static void main(String[] args) {
        Counter counter = new Counter();
        // 搞两个线程, 两个线程分别针对 counter 来 调用 5w 次的 add 方法
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        // 启动线程
        t1.start();
        t2.start();
        // 等待两个线程结束
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 打印最终的 count 值
        System.out.println("count = " + counter.count);
    }
}

执行结果:

73d8c9be8b2a4960a39693770de0ac9a.png

我们预期的结果应该时10万, 但得到得结果明显要比10万小很多, 你可以尝试将程序多运行几次你会发现程序每次运行的结果都不一样, 但绝大部分结果, 都会比预期值要小, 下面就来分析这种结出现的原因.

二. 线程不安全的原因和线程加锁

1. 案例分析

在上面, 我们使用多线程所写的程序将将一个初始值为0的变量自增10万次, 但得到的实际得到的结果要比预期的10万小, 万恶之源还是线程的抢占式执行, 线程调度的顺序是随机的, 就造成线程间自增的指令集交叉, 导致运行时出现两次或者多次自增但值只会自增一次的情况, 导致得到的结果会偏小.


一次的自增操作本质上可以分成三步:


把内存中变量的值读取到CPU的寄存器中(load).

在寄存器中执行自增操作(add)

将寄存器的值保存至内存中(save)

如果是两个线程并发的执行count++, 此时就相当于两组 load, add, save进行执行, 此时不同的线程调度顺序就可能会产生一些结果上的差异.


下面的时间轴总结了一个变量由两个线程并发进行两次自增时, 常见几种常见的情况:


情况1

线程间指令集无交叉, 实际结果和预期结果一致.

73d8c9be8b2a4960a39693770de0ac9a.png

情况2

线程间指令集存在交叉, 实际结果小于预期结果.

73d8c9be8b2a4960a39693770de0ac9a.png

情况3

线程间指令集完全交叉, 实际结果小于预期结果.

73d8c9be8b2a4960a39693770de0ac9a.png

上面列举的三种情况并不是所有可能状况, 其他状况也类似, 可以自己尝试推导一下, 观察上面列出的情况情况, 我们不难发现出当多线程的指令集没有交叉情况出现的时侯, 程序就可以得到正确的结果; 而一旦指令集间有了交叉, 结果就可能会比预期的要小, 也就是说造成这里线程安全问题的原因在于这里的自增操作不是原子性的.


那么再观察上面有问题的结果, 思考结果一定是大于5万吗, 其实不一定, 只是这种可能性比较小, 当线程当t2自增两次或多次,t1只自增一次, 最后的效果是加1.

73d8c9be8b2a4960a39693770de0ac9a.png

当然也有可能最后计算出来的结果是正确的, 不过再这种有问题的情况下可能就更小了, 但并不能说完全没有可能.


那么如何解决上面的线程安全问题呢, 我们只需要想办法让自增操作变成原子性的即可, 也就是让load, add, save三步编程一个整体, 也就是下面介绍的对对象加锁.

2. 线程加锁

2.1 理解加锁

为了解决由于 “抢占式执行” 所导致的线程安全问题, 我们可以针对当前所操作的对象进行加锁, 当一个线程拿到该对象的锁后, 就会将该对象锁起来, 其他线程如果需要执行该对象所限制任务时, 需要等待该线程执行完该对象这里的任务后才可以.


用现实生活中的例子来理解, 假设小明要去银行的ATM机子上办理业务, 我们知道为了安全, 每台ATM一般都在一个单独的小房间里面, 这个小房间由一扇门和一把锁, 当小明进入房间使用ATM时, 门就会自动锁上, 此时如果其他人想要使用这台ATM就得等小明使用完从房间里面出来才行, 那么这里的 “小明” 就相当于一个线程, ATM就相当于一个对象, 房间就相当于一把锁, 其他想使用这台ATM机子的人就相当于其他的线程.

73d8c9be8b2a4960a39693770de0ac9a.png73d8c9be8b2a4960a39693770de0ac9a.png73d8c9be8b2a4960a39693770de0ac9a.png

在Java中最常用的加锁操作就是使用synchronized关键字进行加锁.

2.2 synchronized的使用

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.


线程进入 synchronized 修饰的代码块, 相当于加锁, 退出 synchronized 修饰的代码块, 相当于解锁.


使用方式1

使用synchronized关键字修饰普通方法, 这样会给方法所对在的对象加上一把锁.


以上面的自增代码为例, 对add()方法和加锁, 实质上是个一个对象加锁, 在这里这个锁对象就是this.

class Counter {
    public int count = 0;
    synchronized public void add() {
        count++;
    }
}

对代码做出如上修改后, 执行结果如下:

73d8c9be8b2a4960a39693770de0ac9a.png

使用方式2

使用synchronized关键字对代码段进行加锁, 需要显式指定加锁的对象.


还是基于最开始的代码进行修改, 如下:

class Counter {
    public int count = 0;
    public void add() {
        synchronized (this) {
            count++;
        }
    }
}

执行结果:

73d8c9be8b2a4960a39693770de0ac9a.png

使用方式3

使用synchronized关键字修饰静态方法, 相当于对当前类的类对象进行加锁.

class Counter {
    public static int count = 0;
    synchronized public static void add() {
        count++;
    }
}

执行结果:

73d8c9be8b2a4960a39693770de0ac9a.png

2.3 再次分析案例

我们这里再来分析一下, 为什么上锁之后, 线程就安全了, 代码如下:

class Counter {
    public int count = 0;
    public void add() {
        count++;
    }
}
public class TestDemo12 {
    public static void main(String[] args) {
        Counter counter = new Counter();
        // 搞两个线程, 两个线程分别针对 counter 来 调用 5w 次的 add 方法
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        // 启动线程
        t1.start();
        t2.start();
        // 等待两个线程结束
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 打印最终的 count 值
        System.out.println("count = " + counter.count);
    }
}

加锁, 其实就是想要保证这里自增操作 load, add, save的原子性, 但这里上锁后并不是说让这三步一次完成, 也不是在执行这三步过程中其他线程不进行调度, 加锁后其实是让其他想操作的线程阻塞等待了.


比如我们考虑两个线程指令集交叉的情况下, 加锁操作是如何保证线程安全的, 不妨记加锁为lock,解锁为unlock, t1和t2两个线程的运行过程如下:


t1线程首先获取到目标对象的锁, 对对象进行了加锁, 处于lock状态, t1线程load操作之后, 此时t2线程来执行自增操作时会发生阻塞, 直到t1线程的自增操作执行完成后, 释放锁变为unlock状态, 线程才能成功获取到锁开始执行load操作… , 如果有两个以上的线程以此类推…

73d8c9be8b2a4960a39693770de0ac9a.png

加锁本质上就是把并发变成了串行执行, 这样的话这里的自增操作其实和单线程是差不多的, 甚至上由于add方法, 要做的事情多了加锁和解锁的开销, 多线程完成自增可能比单线程的开销还要大, 那么多线程是不是就没用了呢? 其实不然, 对方法加锁后, 线程运行该方法才会加锁, 执行完该方法的操作后就会解锁, 此方法外的代码并没有受到限制, 这部分程序还是可以多线程并发执行的, 这样整体上多线程的执行效率还是要比单线程要高许多的.


注意:


加锁, 一定要明确是对哪个对象加的锁, 如果两个线程针对同一个对象加锁, 会产生阻塞等待(锁竞争/锁冲突); 而如果两个线程针对不同对象加锁, 不会产生锁冲突.

3. 线程不安全的原因

最根本的原因: 抢占式执行, 随机调度, 这个原因我们无法解决.

代码结构.

我们最初给出的代码之所以有线程安全的原因, 是因为我们设计的代码是让两个线程同时去修改一个相同的变量.


如果我们将代码设计成一个线程修改一个变量, 多个线程读取同一个变量, 多个线程修改多个不同的变量等, 这些情况下, 都是线程安全的; 所以我们可以通过调整代码结构来规避这个问题, 但代码结构是来源于需求的, 这种调整有时候不是一个普适性特别高的方案.


原子性.

如果我们的多线程操作中修改操作是原子的, 那出问题的概率还比较小, 如果是非原子的, 出现问题的概率就非常高了, 就比如我们最开头写的程序以及上面的分析.


指令重排序和内存可见性问题

主要是由于编译器优化造成的指令重排序和内存可见性无法保证, 就是当线程频繁地对同一个变量进行读取操作时, 一开始会读内存中的值, 到了后面可能就不会读取内存中的值了, 而是会直接从寄存器上读值, 这样如果内存中的值做出修改时, 线程就感知不到这个变量已经被修改, 就会导致线程安全问题, 归根结底这是编译器优化的结果, 编译器/jvm在多线程环境下产生了误判, 结合下面的代码进行理解:

import java.util.Scanner;
class MyCounter {
    volatile public int flag = 0;
}
public class TestDemo13 {
    public static void main(String[] args) {
        MyCounter myCounter = new MyCounter();
        Thread t1 = new Thread(() -> {
            while (myCounter.flag == 0) {
                // 这个循环体咱们就空着
            }
            System.out.println("t1 循环结束");
        });
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            myCounter.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

执行结果:

73d8c9be8b2a4960a39693770de0ac9a.png上面的代码中, t2线程修改flag的值让t1线程结束, 但当我们修改了flag的值后线程t1线程并没有终止, 这就是编译优化导致线程感知不到内存的变化, 从而导致线程不安全.

while (myCounter.flag == 0) {
// 这个循环体咱们就空着
}

t1线程中的这段代码用汇编来理解, 大概是下面两步操作:


load, 把内存中flag的值读取到寄存器中.

cmp, 把寄存器的值和0进行比较, 根据比较结果, 决定下一步往哪个地方执行(条件跳转指令).

要知道, 计算机中上面这个循环的执行速度是极快的, 一秒钟执行百万次以上, 在这许多次循环中, 在t2真正修改之前, load得到的结果都是一样的, 另一方面, CPU 针对寄存器的操作, 要比内存操作快很多, 也就是说load操作和cmp操作相比, 速度要慢的多, 此时jvm就针对这些操作做出了优化, jvm判定好像是没人修改flag的值的, 于是在之后就不再真正的重复load, 而是直接读取寄存器当中的值.


所以总结这里的内存可见性问题就是, 一个线程针对一个变量进行读取操作, 同时另一个线程针对这个变量进行修改, 此时读到的值, 不一定是修改之后的值, 这个读线程没有感知到变量的变化.


但实际上flag的值是有人修改的, 为了解决这个问题, 我们可以使用volatile关键字保证内存可见性, 我们可以给flag这个变量加上volatile关键字, 意思就是告诉编译器,这个变量是 “易变” 的, 一定要每次都重新读取这个变量的内存内容, 不可以进行优化了.

class MyCounter {
    volatile public int flag = 0;
}

修改后的执行结果:

73d8c9be8b2a4960a39693770de0ac9a.png

编译器优化除了导致的内存可见性问题会有线程安全问题, 还有指令重排序也会导致线程安全问题, 指令重排序通俗点来讲就是编译器觉得你写的代码太垃圾了, 就把你的代码自作主张进行了调整, 也就是编译器会智能的在保持原有逻辑不变的情况下, 调整代码的执行顺序, 从而加快程序的执行效率.


上面所说的原因并不是造成线程安全的全部原因, 一个代码究竟是线程安全还是不安全, 都得具体问题具体分析, 难以一概而论, 如果一个代码踩中了上面的原因,也可能是线程安全, 而如果一个代码没踩中上面的原因,也可能是线程不安全的, 我们写出的多线程代码, 只要不出bug, 就是线程安全的.

JMM模型 :

在看内存可见性问题时, 还可能碰到JMM(Java Memory Model)模型, 这里简单介绍一下, JMM其实就是把操作系统中的寄存器, 缓存(cache)和内存重新封装了一下, 在JMM中寄存器和缓存称为工作内存, 内存称为主内存; 其中缓存和寄存器一样是在CPU上的, 分为一级缓存L1, 二级缓存L2和三级缓存L3, 从L1到L3空间越来越大, 最大也比内存空间小, 最小也比寄存器空间大,访问速度越来越慢, 最慢也比内存的访问速度快, 最快也没有寄存器访问快.


synchronized与volatile关键字的区别:

synchronized关键字能保证原子性, 但是是否能够保证内存可见性是不一定的, 而volatile关键字只能保证内存可见性不能保证原子性.

三. 线程安全的标准类

Java 标准库中很多都是线程不安全的, 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施, 这些类在多线代码中使用要格外注意,下面列出的就是一些线程不安全的集合:


ArrayList

LinkedList

HashMap

TreeMap

HashSet

TreeSet

StringBuilder

但是还有一些是线程安全的, 使用了一些锁机制来控制, 如下:


Vector (不推荐使用)

HashTable (不推荐使用)

ConcurrentHashMap

StringBuffer

比如我们可以看一下StringBuffer中的方法, 绝大多数都是加锁了的.

73d8c9be8b2a4960a39693770de0ac9a.png

还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的:


String

我们需要的知道的是加速操作是有副作用的, 在加锁的同时, 会带来额外的时间开销, 那些线程安全的类已经强制加锁了, 但有些情况下, 不使用多线程是没有线程安全问题的, 这个时候使用那些线程不安全感的类更好一些, 而且使用这些线程不安全的类更灵活, 就算面临线程安全问题, 我们可以自行手动加锁, 有更多的选择空间.


目录
相关文章
|
9天前
|
存储 监控 Java
【Java并发】【线程池】带你从0-1入门线程池
欢迎来到我的技术博客!我是一名热爱编程的开发者,梦想是编写高端CRUD应用。2025年我正在沉淀中,博客更新速度加快,期待与你一起成长。 线程池是一种复用线程资源的机制,通过预先创建一定数量的线程并管理其生命周期,避免频繁创建/销毁线程带来的性能开销。它解决了线程创建成本高、资源耗尽风险、响应速度慢和任务执行缺乏管理等问题。
120 60
【Java并发】【线程池】带你从0-1入门线程池
|
22天前
|
Python
python3多线程中使用线程睡眠
本文详细介绍了Python3多线程编程中使用线程睡眠的基本方法和应用场景。通过 `time.sleep()`函数,可以使线程暂停执行一段指定的时间,从而控制线程的执行节奏。通过实际示例演示了如何在多线程中使用线程睡眠来实现计数器和下载器功能。希望本文能帮助您更好地理解和应用Python多线程编程,提高程序的并发能力和执行效率。
46 20
|
5天前
|
Java 调度
【源码】【Java并发】【线程池】邀请您从0-1阅读ThreadPoolExecutor源码
当我们创建一个`ThreadPoolExecutor`的时候,你是否会好奇🤔,它到底发生了什么?比如:我传的拒绝策略、线程工厂是啥时候被使用的? 核心线程数是个啥?最大线程数和它又有什么关系?线程池,它是怎么调度,我们传入的线程?...不要着急,小手手点上关注、点赞、收藏。主播马上从源码的角度带你们探索神秘线程池的世界...
51 0
【源码】【Java并发】【线程池】邀请您从0-1阅读ThreadPoolExecutor源码
|
21天前
|
Java 程序员 开发者
Java社招面试题:一个线程运行时发生异常会怎样?
大家好,我是小米。今天分享一个经典的 Java 面试题:线程运行时发生异常,程序会怎样处理?此问题考察 Java 线程和异常处理机制的理解。线程发生异常,默认会导致线程终止,但可以通过 try-catch 捕获并处理,避免影响其他线程。未捕获的异常可通过 Thread.UncaughtExceptionHandler 处理。线程池中的异常会被自动处理,不影响任务执行。希望这篇文章能帮助你深入理解 Java 线程异常处理机制,为面试做好准备。如果你觉得有帮助,欢迎收藏、转发!
87 14
|
24天前
|
安全 Java 程序员
Java 面试必问!线程构造方法和静态块的执行线程到底是谁?
大家好,我是小米。今天聊聊Java多线程面试题:线程类的构造方法和静态块是由哪个线程调用的?构造方法由创建线程实例的主线程调用,静态块在类加载时由主线程调用。理解这些细节有助于掌握Java多线程机制。下期再见! 简介: 本文通过一个常见的Java多线程面试题,详细讲解了线程类的构造方法和静态块是由哪个线程调用的。构造方法由创建线程实例的主线程调用,静态块在类加载时由主线程调用。理解这些细节对掌握Java多线程编程至关重要。
53 13
|
24天前
|
安全 Java 开发者
【JAVA】封装多线程原理
Java 中的多线程封装旨在简化使用、提高安全性和增强可维护性。通过抽象和隐藏底层细节,提供简洁接口。常见封装方式包括基于 Runnable 和 Callable 接口的任务封装,以及线程池的封装。Runnable 适用于无返回值任务,Callable 支持有返回值任务。线程池(如 ExecutorService)则用于管理和复用线程,减少性能开销。示例代码展示了如何实现这些封装,使多线程编程更加高效和安全。
|
27天前
|
安全 Java C#
Unity多线程使用(线程池)
在C#中使用线程池需引用`System.Threading`。创建单个线程时,务必在Unity程序停止前关闭线程(如使用`Thread.Abort()`),否则可能导致崩溃。示例代码展示了如何创建和管理线程,确保在线程中执行任务并在主线程中处理结果。完整代码包括线程池队列、主线程检查及线程安全的操作队列管理,确保多线程操作的稳定性和安全性。
|
2月前
|
监控 Java
java异步判断线程池所有任务是否执行完
通过上述步骤,您可以在Java中实现异步判断线程池所有任务是否执行完毕。这种方法使用了 `CompletionService`来监控任务的完成情况,并通过一个独立线程异步检查所有任务的执行状态。这种设计不仅简洁高效,还能确保在大量任务处理时程序的稳定性和可维护性。希望本文能为您的开发工作提供实用的指导和帮助。
121 17
|
2月前
|
缓存 安全 算法
Java 多线程 面试题
Java 多线程 相关基础面试题
|
25天前
|
Linux
Linux编程: 在业务线程中注册和处理Linux信号
本文详细介绍了如何在Linux中通过在业务线程中注册和处理信号。我们讨论了信号的基本概念,并通过完整的代码示例展示了在业务线程中注册和处理信号的方法。通过正确地使用信号处理机制,可以提高程序的健壮性和响应能力。希望本文能帮助您更好地理解和应用Linux信号处理,提高开发效率和代码质量。
45 17

热门文章

最新文章