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

news2024/12/23 4:49:22

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


✨✨✨这里是秋刀鱼不做梦的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关键字了。


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

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Kubernetes Node 节点的生命周期简述

Node 节点是 Kubernetes 的核心组件之一&#xff0c;其生命周期可以简要概括为&#xff1a;注册、运行、下线。本文将简要介绍 Node 生命周期中发生的关键事件。 节点注册 每个 node 节点都需要运行 kubelet&#xff0c;kubelet 启动后会向 kube-apiserver 发起节点的注册请求…

GenAI 生态系统现状:不止大语言模型和向量数据库

自 20 个月前 ChatGPT 革命性的推出以来&#xff0c;生成式人工智能&#xff08;GenAI&#xff09;领域经历了显著的发展和创新。最初&#xff0c;大语言模型&#xff08;LLMs&#xff09;和向量数据库吸引了最多的关注。然而&#xff0c;GenAI 生态系统远不止这两个部分&#…

ctf文件上传题小总结与记录

解题思路&#xff1a;先看中间件&#xff0c;文件上传点&#xff08;字典扫描&#xff0c;会员中心&#xff09;&#xff0c;绕过/验证&#xff08;黑名单&#xff0c;白名单&#xff09;&#xff0c;解析漏洞&#xff0c;cms&#xff0c;编辑器&#xff0c;最新cve 文件上传漏…

如何使用VBA识别Excel中的“单元格中的图片”(2/2)

Excel 365升级了新功能&#xff0c;支持两种不同的插入图片方式&#xff1a; 放置在单元格中&#xff08;Place in cell&#xff09;&#xff0c;新功能&#xff0c;此操作插入的图片下文中简称为单元格中的图片。放置在单元格上&#xff08;Place over cell&#xff09;&…

python绘制置信椭圆

置信椭圆 置信椭圆&#xff08;Confidence Ellipse&#xff09;是一种用来表示二维数据的置信区域的统计工具。它是多元统计分析中常用的图形&#xff0c;通过它可以表示两个变量之间的协方差关系以及同时包含两个变量的可能取值范围。置信椭圆一般用于描述一组二维数据的散布…

用Python设置、更新和获取Excel单元格的值

Excel工作簿作为一款广泛使用的数据管理工具&#xff0c;与Python相结合&#xff0c;可以使得自动化处理大量数据成为可能。通过Python来设置、更新以及读取Excel单元格的值&#xff0c;不仅可以极大地提高工作效率&#xff0c;减少重复劳动&#xff0c;还能增强数据处理流程的…

轴承故障全家桶更新 | 基于时频图像的分类算法

往期精彩内容&#xff1a; Python-凯斯西储大学&#xff08;CWRU&#xff09;轴承数据解读与分类处理 Pytorch-LSTM轴承故障一维信号分类(一)-CSDN博客 Pytorch-CNN轴承故障一维信号分类(二)-CSDN博客 Pytorch-Transformer轴承故障一维信号分类(三)-CSDN博客 三十多个开源…

001-Kotlin界面开发之Jetpack Compose Desktop学习路径

Compose Desktop学习之路 学习过程 理解Kotlin的基本语法 Compose Desktop采用Kotlin构建&#xff0c;因此对Kotlin的基本语法有很好的理解是必不可少的。你可以从官方的Kotlin文档开始。 用一句话概括&#xff0c;Kotlin是一种现代的、静态类型的编程语言&#xff0c;它结合…

阳振坤:云时代数据库的思考 | OceanBase发布会实录

在2024 OceanBase 年度发布会中&#xff0c;OceanBase 的创始人与首席科学家阳振坤进行了《云时代数据库的思考》的主题分享。本文为演讲实录。 亲爱的朋友们&#xff0c;衷心感谢各位莅临今天的发布会现场。今天是一个云的时代&#xff0c;我想与大家分享&#xff0c;我对于云…

JavaScript语法基础(函数,对象,常用类Array,String,Math和Date)【超详细!!!新手入!!!】

一、函数 1、函数的定义 函数指的是一段可被重复调用的代码块。函数与变量不同&#xff0c;需要先定义再调用。 定义函数的语法格式为&#xff1a; function 函数名&#xff08;参数1&#xff0c;参数2&#xff0c;...&#xff09; { 语句&#xff1b; …

OpenGL入门004——使用EBO绘制矩形

本节将利用EBO来绘制矩形 文章目录 一些概念EBO 实战简介utilswindowFactory.hRectangleModel.hRectangleModel.cpp main.cppCMakeLists.txt最终效果 一些概念 EBO 概述&#xff1a; Element Buffer Object 用于存储顶点的索引数据&#xff0c;以便在绘制图形时可以重用顶点数…

linux之网络子系统-用户层接收数据包之同步阻塞方案

一、前言 之前讲述了网络包是如何从网卡送到协议栈的&#xff0c;接下来内核还有一项重要的工作&#xff0c;就是在协议栈接收处理完输入包后要通知到用户进程&#xff0c;如何用户进程接收到并处理这些数据。 进程与内核配合有多种方案&#xff0c;这里我们这分析两种典型的…

高效消防应急:RFID技术救援装备的快速管理

基层应急救援消防设施管理面临着一个既复杂又迫切的挑战。通常&#xff0c;受限的资源和专业人才的短缺导致应对突发事件的反应迟缓&#xff0c;处理结果不理想。同时&#xff0c;消防团队的人员结构和技术能力也在一定程度上决定了应急救援的成效和效率。在数字化浪潮下&#…

TMDOG的Gin学习笔记_01——初识Gin框架

TMDOG的Gin学习笔记_01——初识Gin框架 博客地址&#xff1a;[TMDOG的博客](https://blog.tmdog114514.icu) 作者自述&#xff1a; 停更太久了&#xff0c;是因为开学了课太多了&#xff0c;并且我一直在准备上篇文章的内容正在coding&#xff0c;就先搁置了更新博客QAQ&…

【ROS的TF系统】

系列文章目录 TF系统简介 前面的章节实现了SLAM节点的建图功能&#xff1a; 激光雷达节点—> /scan话题 —>hector_mapping节点—> 地图数据话题/map 本期来实现SLAM节点的定位功能&#xff1a; TF&#xff08;TransForm&#xff09;主要描述的是两个坐标系的空间关…

Pandas JSON学习

1.JSON简介 JSON&#xff08;JavaScript Object Notation&#xff0c;JavaScript 对象表示法&#xff09;&#xff0c;是存储和交换文本信息的语法&#xff0c;类似 XML。JSON 比 XML 更小、更快&#xff0c;更易解析&#xff0c;Pandas 可以很方便的处理 JSON 数据。 [{"…

SQL Server身份验证模式

SQL Server是一个广泛使用的关系数据库管理系统&#xff0c;通常使用两种身份验证模式&#xff1a;Windows身份验证和SQL Server身份验证。理解这些身份验证方式的概念与更改方式的操作&#xff0c;对于数据库管理员和开发者至关重要。本文将详细介绍身份验证方式的概念以及如何…

DC-9靶机通关

这是这个系列的最后一个靶机了&#xff01;&#xff01;&#xff01;经过前面的锻炼和学习&#xff0c;这次我的目标是尽量不借助任何教程或者提示来拿下这个靶机&#xff01;&#xff01;&#xff01;下面我们看能不能成功&#xff01;&#xff01;&#xff01; 1.实验环境 攻…

百度SEO分析实用指南 提升网站搜索排名的有效策略

内容概要 在数字化时代&#xff0c;搜索引擎优化&#xff08;SEO&#xff09;已经成为提升网站曝光度的关键工具。本指南将带您了解SEO的基本知识&#xff0c;帮助您在复杂的网络环境中立足。我们将从关键词优化开始&#xff0c;重点讲解如何选择合适的关键词来提高搜索引擎排…

ML2001-1 机器学习/深度学习 Introduction of Machine / Deep Learning

图片说明来自李宏毅老师视频的学习笔记&#xff0c;如有侵权&#xff0c;请通知下架 影片参考 【李宏毅】3.第一节 - (上) - 机器学习基本概念简介_哔哩哔哩_bilibili 1. 机器学习的概念与任务类型 概念&#xff1a;机器学习近似于寻找函数&#xff0c;用于处理不同类型的任…