Java中的线程安全问题(如果想知道Java中有关线程安全问题的基本知识,那么只看这一篇就足够了!)

        前言:多线程编程已经广泛开始使用,其可以充分利用系统资源来提升效率,但是线程安全问题也随之出现,它直接影响了程序的正确性和稳定性,需要对其进行深入的理解与解决。


✨✨✨这里是秋刀鱼不做梦的BLOG

✨✨✨想要了解更多内容可以访问我的主页秋刀鱼不做梦-CSDN博客

在正式开始讲解之前,先让我们看一下本文大致的讲解内容:

目录

1.线程不安全概念及其原因

2.原子性问题

3.可见性问题

4.指令重排序问题

5.线程不安全的解决方案

        (1)synchronized关键字

补充:synchronized关键字的可重入性:

        (2)volatile关键字


1.线程不安全概念及其原因

        在多线程编程中,线程安全是一个至关重要的概念,当多个线程同时访问和操作共享数据时,如果没有适当的同步机制,可能会导致程序出现意想不到的结果。

        下面通过一个简单的代码示例来观察线程不安全现象:

// 此处定义一个int类型的变量
private static int count = 0;

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        // 对count变量进行自增5w次
        for (int i = 0; i < 50000; i++) {
            count++;
        }
    });
    Thread t2 = new Thread(() -> {
        // 对count变量进行自增5w次
        for (int i = 0; i < 50000; i++) {
            count++;
        }
    });
    t1.start();
    t2.start();
    // 如果没有这俩join,肯定不行的。线程还没自增完,就开始打印了。很可能打印出来的count值小于预期
    t1.join();
    t2.join();
    // 预期结果应该是10w
    System.out.println("count: " + count);
}

        在上述代码中,我们创建了两个线程 t1 和 t2,它们都试图对共享变量 count 进行大量的自增操作,理论上,当两个线程都完成任务后,count 的值应该达到100000。然而,实际运行结果却常常小于这个预期值(读者可以复制代码在编译器中自行尝试一下),这便是典型的线程不安全现象。

        ——那么,为何会出现这种情况呢?原因有如下两个:

  1. 线程调度的随机性:线程调度是由操作系统掌控的,它会在多个线程之间随机地切换执行权。在上述代码场景中,t1t2 线程极有可能交替执行自增操作。例如,t1 线程读取了 count 的当前值(假设为0),但在执行自增操作(count++)之前,线程调度器暂停了 t1 线程,并切换到 t2 线程。此时,t2 线程同样读取到 count 的值为0,随后执行自增操作,将 count 的值更新为1。接着,t1 线程恢复执行,可它依然使用之前读取到的0进行自增操作,最终将 count 的值更新为1,而非预期的2。

  2. 多个线程修改同一变量:当多个线程同时对同一个共享变量进行写操作,且没有任何同步保障时,数据的不一致性便极易出现,在当前例子中,t1t2 都在对 count 变量进行修改,它们的操作相互干扰,最终致使结果出现偏差。

        至此,我们通过上述的讲解,我们就大致的了解了到底什么是多线程中的线程不安全以及产生线程不安全的原因了。

2.原子性问题

        在多线程中,除了上述我们讲解的当我们有多个线程同时对同一个数据进行操作从而引起的线程安全问题外,原子性问题也是可能引起线程不安全的原因,那么什么是原子性问题呢?

原子性的概念:

        原子性,从本质上讲,是指一个操作或者一组操作作为一个不可分割的整体,其执行过程要么全部成功完成,要么全部不执行,绝不存在被其他线程中断的中间状态。在多线程环境中,倘若一个操作不具备原子性,那么就极有可能出现部分执行的状况,进而导致数据错误。

        这里我们还是使用上述的两个线程各自增加count5w次的例子来进行讲解,这里再让我们看一下上述的代码:

// 定义一个共享的int类型变量count,并初始化为0
private static int count = 0;

public static void main(String[] args) throws InterruptedException {
    // 创建第一个线程t1,其任务是对count进行50000次自增操作
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            count++;
        }
    });
    // 创建第二个线程t2,同样对count进行50000次自增操作
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            count++;
        }
    });
    // 启动线程t1
    t1.start();
    // 启动线程t2
    t2.start();
    // 调用t1线程的join方法,确保t1线程执行完毕
    t1.join();
    // 调用t2线程的join方法,确保t2线程执行完毕
    t2.join();
    // 预期count的值应该是100000,但实际结果往往并非如此
    System.out.println("count: " + count);
}

        在上述代码中的 count++ 操作,看似简单的自增指令,实际上并非原子操作,在Java语言中,count++ 大致可分解为以下三个步骤:

  • 首先,读取 count 的当前值。

  • 接着,将读取到的值加1。

  • 最后,将计算后的新值写回 count

        假设 t1 和 t2 线程同时执行 count++ 操作,就可能出现如下情形:t1 线程读取了 count 的初始值为0,然而在执行加1操作之前,线程调度器切换到了 t2 线程。t2 线程同样读取到 count 的值为0,随后进行加1操作并将结果1写回 count,此时,count 的值变为1,接着,t1 线程恢复执行,它依旧使用之前读取到的0进行加1操作,得到结果1,并将其写回 count

        如此一来,最终 count 的值仅增加了1,而非预期的2。这便是因为 count++ 操作不具备原子性,在执行过程中被其他线程中断,从而导致了错误的结果。

        ——这就是所谓的原子性问题。

3.可见性问题

        在了解完上述的两种造成多线程中的线程安全问题的原因之后,在让我们看一下另一种造成多线程线程安全的原因——内存可见性问题

        ——那么什么是内存可见性问题(可见性问题)呢?

可见性的概念:

        可见性,简单来说,是指一个线程对共享变量值的修改,能够及时且准确地被其他线程察觉到。在多线程编程的情境下,如果一个线程修改了共享变量的值,但是其他线程无法立即获取到这个修改后的最新值,那么就会产生可见性问题。

        当然,提到内存可见性问题就不得不提及Java内存模型,那么Java内存模型和内存可见性问题又有什么联系呢?

Java内存模型与可见性问题的关系:

        Java内存模型(JMM)明确规定了Java程序中变量的访问规则。每个线程都拥有自己独立的工作内存,当线程需要读取一个共享变量时,会首先将变量从主内存拷贝到自己的工作内存,然后再从工作内存中读取数据;而当线程要修改一个共享变量时,会先在工作内存中修改其副本,之后再将修改后的值同步回主内存。

        由于每个线程的工作内存相互独立,这就可能导致一种情况:一个线程修改了共享变量的值,但这个修改尚未及时同步到主内存,或者其他线程还未从主内存更新自己工作内存中的副本,从而致使其他线程无法看到该变量的最新值。

        这样我们就大致的了解了什么是Java内存模型,以及Java内存模型与可见性问题的关系了。

        我相信读者在看到这里的时候脑子里只用一个想法,我勒个去,上边这都是什么和什么啊?根本看不懂啊!没关系,接下来让我们使用一个例子来帮助你更好的理解上述内存可见性问题。

案例代码:

static class Counter {
    public int flag = 0;
}

public static void main(String[] args) {
    Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        while (counter.flag == 0) {
            // 线程t1在此处循环等待,直到flag的值变为非0
        }
        System.out.println("循环结束!");
    });
    Thread t2 = new Thread(() -> {
        Scanner scanner = new Scanner(System.in);
        System.out.println("输入一个整数:");
        counter.flag = scanner.nextInt();
    });
    t1.start();
    t2.start();
}

        在这个例子中,t1 线程在一个循环里持续检查 counter.flag 的值是否为0,如果是,则持续循环等待;t2 线程等待用户输入一个整数,并将其赋值给 counter.flag。按照预期,当用户输入非0的值时,t1 线程应当结束循环并打印 "循环结束!"。

        然而,实际情况可能是,即便 t2 线程已经修改了 counter.flag 的值,t1 线程却并未立即察觉到这个变化,依旧在循环中持续等待。这是因为 t1 线程可能始终在使用自己工作内存中的 counter.flag 副本,而没有及时从主内存更新该副本,从而引发了可见性问题。

        至此,我相信读者通过上述的案例讲解之后,就对内存可见性问题有了进一步理解了!!!

4.指令重排序问题

        讲解完上述三种产生多线程问题的原因之后,还有没有其他的可能产生多线程线程安全的原因呢?还真有,其就是指令重排序问题。

指令重排序的概念:

        指令重排序是指编译器或处理器为了优化程序的性能,在不改变单线程程序语义的前提下,对指令的执行顺序进行重新排列。在单线程环境中,指令重排序通常不会引发问题,因为程序的执行结果是确定的。然而,在多线程环境下,指令重排序可能会改变代码的执行顺序,进而导致线程安全问题。

        这里我们也是使用一个案例来帮助读者来进一步理解指令重排序问题。

// 定义两个共享变量
private static boolean initialized = false;
private static int value;

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        value = 42;
        initialized = true;
    });
    Thread t2 = new Thread(() -> {
        if (initialized) {
            System.out.println("value: " + value);
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
}

        在这个例子中,t1 线程首先对 value 赋值为42,随后将 initialized 设置为 truet2 线程则检查 initialized 的值,如果为 true,就打印 value 的值。由于指令重排序的存在,t1 线程中的指令可能会被重新排序。

        例如,initialized = true 可能会在 value = 42 之前执行。这样一来,当 t2 线程检查 initialized 的值为 true 时,value 的值可能还未被正确赋值,从而导致打印出错误的结果(可能是0,而不是42)。

        这样我们就了解了什么是指令重排序问题了。

5.线程不安全的解决方案

        学习完上述可能产生线程安全的原因之后,接下来就让我们学习一下如何去在多线程编程中防止程序发生线程安全问题。

        (1)synchronized关键字

在学习如何使用synchronized关键字之前,先让我们看一下synchronized关键字是什么:

synchronized 关键字具有强大的互斥特性。当一个线程进入一个对象的 synchronized 方法或代码块时,其他线程若试图进入同一个对象的 synchronized 方法或代码块,将会被阻塞等待,直到持有锁的线程释放锁为止。

这里我们使用一个例子来进行讲解:

public class Demo2 {
    public static int number = 0;

    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                synchronized (locker) {
                    number++;
                }
            }
        });
        
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                synchronized (locker) {
                    number++;
                }
            }
        });
        
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        
        System.out.println(number);
    }
}

代码解析:

  • 静态变量 number 用于存储共享计数。
  • 使用 Object locker 作为同步锁,确保对 number 的修改是线程安全的。
  • 创建两个线程,每个线程循环 1000 次,通过 synchronized (locker) 代码块安全地增加 number
  • 启动两个线程并等待它们结束,最后输出 number 的值。

        通过上述的案例,我相信读者就可以对synchronized关键字有一定的理解了!

        当然synchronized关键字还可以修饰方法,当修饰普通方法时,锁对象为当前对象(this);修饰静态方法时,锁对象为类对象(class),例如:

public class SynchronizedMethodDemo {
    private static int count = 0;

    // 修饰普通方法,锁对象为this
    public synchronized void increment() {
        count++;
    }

    // 修饰静态方法,锁对象为类对象
    public synchronized static void staticIncrement() {
        count++;
    }
}

        需要特别注意的是,使用 synchronized 关键字会带来一定的性能开销,因为获取和释放锁的过程需要消耗时间。因此,在实际应用中,应尽可能缩小同步代码块的范围,仅在必要之处进行同步操作,以此提高程序的性能。

补充:synchronized关键字的可重入性:

        这里我们先给出可重入性的简介:

可重入性是指当一个线程已经获得了某个对象的锁后,它可以再次获得这个锁,而不会被阻塞

例如,当一个线程调用一个 synchronized 方法时,若该方法内部又调用了另一个 synchronized 方法,此时该线程能够继续获取锁并执行内部的 synchronized 方法,而不会被自身阻塞。这是因为在可重入锁的内部机制中,包含了“线程持有者”和“计数器”两个重要信息,当某个线程加锁时,若发现锁已被自己占用,那么它仍然可以顺利获取锁,并使计数器自增。只有当计数器递减为0时,锁才会真正被释放,从而允许其他线程获取该锁。

        可重入性的特点:

  1. 锁的重复获取:同一个线程可以多次获取同一个锁,而不会导致死锁。例如,如果线程 A 已经获得了对象 O 的锁,那么它可以再次进入 O 的同步方法或同步块。

  2. 计数机制:Java 的 synchronized 内部使用了一个计数机制。当一个线程获得锁时,计数器加一;当线程释放锁时,计数器减一。当计数器为零时,锁被释放。 ​​​​​​​

        如果读者看了上述的文字解释之后还是不太理解,那么我们接下看使用一个例子来帮助你进一步理解synchronized的可重入性:

public class ReentrantExample {
    synchronized void methodA() {
        System.out.println("Method A is called");
        methodB(); // 可以在这里调用同一个对象的另一个同步方法
    }

    synchronized void methodB() {
        System.out.println("Method B is called");
    }

    public static void main(String[] args) {
        ReentrantExample example = new ReentrantExample();
        example.methodA(); // 调用 methodA
    }
}

        在上面的例子中,当 methodA 被调用时,线程获得了锁并执行 methodA,然后可以安全地调用 methodB,因为它已经持有了该对象的锁,这就是synchronized的可重入性。

        (2)volatile关键字

在了解完了synchronized关键字之后,让我们了解一下volatile关键字,首先先让我们了解一下什么是volatile关键字:

volatile 关键字的核心作用是保证内存可见性。它强制线程在读写共享变量时,必须直接从主内存读取或写入,而不能使用工作内存中的副本。当一个线程修改了 volatile 修饰的变量时,它会立即将修改后的值刷新到主内存,并且其他线程在读取这个变量时,会直接从主内存获取最新的值,而不是使用自己工作内存中的旧副本

这里我们使用一个例子来进行讲解:

public class VolatileDemo {
    private volatile boolean flag = false;

    public void setFlag(boolean flag) {
        this.flag = flag;
    }

    public boolean isFlag() {
        return flag;
    }
}

        在上述代码中,flag 变量被 volatile 修饰。当一个线程调用 setFlag 方法修改 flag 的值时,其他线程能够立即察觉到这个修改。

public class VolatileExample {
    public static void main(String[] args) {
        VolatileDemo volatileDemo = new VolatileDemo();
        Thread t1 = new Thread(() -> {
            while (!volatileDemo.isFlag()) {
                // 线程t1在此处循环等待,直到flag的值变为true
            }
            System.out.println("t1线程检测到flag为true,结束循环");
        });
        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            volatileDemo.setFlag(true);
            System.out.println("t2线程将flag设置为true");
        });
        t1.start();
        t2.start();
    }
}

        在这个例子中,t1 线程在一个循环中不断检查 volatileDemo.flag 的值,如果为 false,则继续循环等待;t2 线程在睡眠1秒后将 flag 设置为 true。由于 flag 被 volatile 修饰,当 t2 线程修改 flag 的值后,t1 线程能够立即看到这个修改,从而结束循环。

        这样我们就了解了volatile关键字了。


以上就是本篇文章的全部内容了~~~

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/911757.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

2024 CSS保姆级教程二 - BFC详解

前言 - CSS中的文档流 在介绍BFC之前&#xff0c;需要先给大家介绍一下文档流。​ 我们常说的文档流其实分为定位流、浮动流、普通流三种。​ ​ 1. 绝对定位(Absolute positioning)​ 如果元素的属性 position 为 absolute 或 fixed&#xff0c;它就是一个绝对定位元素。​ 在…

在 Spring Boot 中实时监控 Redis 命令流

前言 在 Redis 的日常使用和调试中&#xff0c;监控命令流有助于我们更好地理解 Redis 的工作状态。Redis 提供了 MONITOR 命令&#xff0c;可以实时输出 Redis 中所有客户端的命令请求&#xff0c;这一功能在调试和分析性能时非常有帮助。在 Spring Boot 项目中&#xff0c;我…

ReadKidz | 一个生成儿童故事绘本的平台

AI创作丨使用ReadKidz快速生成儿童故事绘本 ReadKidz 是一款AI平台&#xff0c;专为快速创作儿童故事绘本而设计。用户仅需输入简单提示词并根据喜好进行选择&#xff0c;便能生成精美的个性化绘本&#xff0c;适合家长、教师或创作者为孩子们创建有趣且富教育意义的故事。 使用…

aosp15系统窗口闪屏原生bug-dim图层相关-你会修改吗?

背景 近期各个大厂已经开始准备aosp15的系统rom适配工作了&#xff0c;应该是想2025年初开发发布相关的新机型&#xff0c;所以慢慢的我们也要开始适应aosp15版本的相关问题的修改和研究哈。 近期就有相关学员朋友在做android15相关的dialog开发时候&#xff0c;发现了一个严…

UML统一建模语言,学习笔记

目录 一、UML 的概述 二、UML 的主要图形类型 1. 类图 2. 对象图 3. 用例图 4. 序列图 5. 协作图 6. 状态图 7. 活动图 三、UML 的关系 1. 继承 2. 实现 3. 关联 4. 聚合 5. 组合 四、UML 的应用场景 1. 软件设计系统 2. 需求分析 3. 项目文档化 4. 团队协…

133.鸿蒙基础01

鸿蒙基础 1.自定义构建函数1. 构建函数-[Builder ](/Builder )2. 构建函数-传参传递(单向)3. 构建函数-传递参数(双向)4. 构建函数-传递参数练习5. 构建函数-[BuilderParam ](/BuilderParam ) 传递UI 2.组件状态共享1. 状态共享-父子单向2. 状态共享-父子双向3. 状态共享-后代组…

uniapp组件样式运行至小程序失效

文章目录 一、uniapp样式穿透打包运行至微信小程序失效 一、uniapp样式穿透打包运行至微信小程序失效 组件样式隔离文章参考 解决方案 options: {styleIsolation: "shared",},这个配置项改变了小程序组件的样式隔离模式&#xff0c;使得组件的样式能够共享和继承。…

在服务器里安装2个conda

1、安装新的conda 下载地址&#xff1a;Index of /anaconda/archive/ | 清华大学开源软件镜像站 | Tsinghua Open Source Mirror 本文选择&#xff1a;Anaconda3-2023.03-1-Linux-x86_64.sh 安装&#xff1a;Ubuntu安装Anaconda详细步骤&#xff08;Ubuntu22.04.1&#xff…

软考系统分析师知识点三七:今日考试

前言 今年报考了11月份的软考高级&#xff1a;系统分析师。 考试时间&#xff1a;11月9日。 今日考试。 今日考试 上午&#xff1a;选择题、案例题&#xff0c;注意记一下选择题和案例题中出现的知识点。 中午&#xff1a;再次整理强记一下论文框架、论文知识点 下午&…

通过 Windows IIS 服务访问腾讯云 CFS 文件系统

互联网信息服务&#xff08;IIS&#xff09;可以像访问本地数据一样访问文件存储&#xff08;Cloud File Storage&#xff0c;CFS&#xff09;系统上的数据&#xff0c;并提供 Web 服务&#xff0c;实现网站存储与计算分离。本文介绍如何配置 IIS 访问 CFS 文件系统。 背景信息…

鸿蒙的进击之路

1. 题记&#xff1a; 为什么要写鸿蒙&#xff0c;因为她是华为的&#xff0c;为什么是华为就要写&#xff0c;因为华为背负了国人太多太多的包袱&#xff0c;或点赞或抨击。 我是强烈支持华为的&#xff0c;但我会客观公正地去评价华为的产品&#xff0c;就比如这篇博文&#…

【java】哈希<两数之和> 理解哈希

两数之和 题目描述&#xff1a; 给定一个整数数组 nums 和一个整数目标值 target&#xff0c;请你在该数组中找出 和为目标值 target 的那 两个 整数&#xff0c;并返回它们的数组下标。 你可以假设每种输入只会对应一个答案&#xff0c;并且你不能使用两次相同的元素。 你…

GS-Blur数据集:首个基于3D场景合成的156,209对多样化真实感模糊图像数据集。

2024-10-31&#xff0c;由韩国首尔国立大学的研究团队创建的GS-Blur数据集&#xff0c;通过3D场景重建和相机视角移动合成了多样化的真实感模糊图像&#xff0c;为图像去模糊领域提供了一个大规模、高覆盖度的新工具&#xff0c;显著提升了去模糊算法在真实世界场景中的泛化能力…

深入Pillow:处理图像下载中的意外挑战

在当今数字化时代&#xff0c;获取和处理图像数据已经成为了许多应用程序的核心功能。从社交媒体到电子商务&#xff0c;图像的获取和处理对于用户体验至关重要。下载图片不仅能够丰富我们的内容&#xff0c;还能够通过分析图像数据为我们的应用提供更多价值。然而&#xff0c;…

qt5将程序打包并使用

一、封装程序 (1)、点击创建项目->库->clibrary &#xff08;2&#xff09;、填写自己想要封装成库的名称&#xff0c;这里我填写的名称为mydll1 &#xff08;3&#xff09;、如果没有特殊的要求&#xff0c;则一路下一步&#xff0c;最终会出现如下文件列表。 (4)、删…

.NET中通过C#实现Excel与DataTable的数据互转

在.NET框架中&#xff0c;使用C#进行Excel数据与DataTable之间的转换是数据分析、报表生成、数据迁移等操作中的常见需求。这一过程涉及到将Excel文件中的数据读取并加载至DataTable中&#xff0c;以便于利用.NET提供的丰富数据处理功能进行操作&#xff0c;同时也包括将DataTa…

我谈正态分布——正态偏态

目录 pdf和cdf参数 标准正态分布期望和方差分布形态 3 σ 3\sigma 3σ原则 正态和偏态正态偏态瑞利分布偏度 (Skewness)峰度 (Kurtosis) 比较 正态分布的英文是Normal Distribution&#xff0c;normal是“正常”或“标准”的意思&#xff0c;中文翻译是正态&#xff0c;多完美的…

jsp+servlet+mysql机票订票管理系统

jspsevletmysql机票订票管理系统 一、系统介绍二、功能展示1.机票查询2.选择航班3.填写乘客信息4.提交定单 四、其它1.其他系统实现 一、系统介绍 系统主要功能&#xff1a; 机票查询 1.航行类型 2.出发城市 3.到达城市 4.出发日期 5.返回日期 选择航班 1.航班信息 2.起飞时间…

【启程Golang之旅】一站式理解Go语言中的gRPC

在本文中将深入探讨如何使用Go语言构建基于gRPC的高效服务通信&#xff0c;无论你是刚刚接触gRPC还是已经有一定基础的开发者&#xff0c;这篇文章都将带你从理论到实践&#xff0c;全面理解如何借助Go和gRPC提升应用程序的性能与可维护性。 目录 初识gRPC gRPC基本使用 初识…

「QT」几何数据类 之 QMatrix4x4 4x4矩阵类

✨博客主页何曾参静谧的博客&#x1f4cc;文章专栏「QT」QT5程序设计&#x1f4da;全部专栏「VS」Visual Studio「C/C」C/C程序设计「UG/NX」BlockUI集合「Win」Windows程序设计「DSA」数据结构与算法「UG/NX」NX二次开发「QT」QT5程序设计「File」数据文件格式「PK」Parasolid…