【多线程的安全问题】synchronized 和 volatile——你必须知道的妙用!

news2025/2/26 5:50:36

📄前言:本文的主要内容是讨论个人在多线程编程带来的安全问题的表现、原因以及对应的解决方法。


文章目录

  • 一. 了解多线程安全问题
  • 二. 线程不安全的现象及原因
    • 🍆1. 修改共享的数据(根本原因)
    • 🍅2. 原子性
    • 🥦3. 可见性
    • 🍉4. 指令重排序
  • 三. synchronized 和 volatile 关键字
    • 🍚1. 锁和加锁
    • 🍥2. 加锁的语法及注意细节
    • 🍭3. 利用 synchronized关键字解决多线程安全问题
    • 🧊4. Java中 synchronized 的特性
      • 4.1 互斥
      • 4.2 刷新内存
      • 4.3 可重入(了解死锁)
    • 🍦5. 了解volatile关键字的作用并解决多线程问题

一. 了解多线程安全问题

我们都知道多线程的并发执行能够提高程序的执行效率,它好比你拥有了“孙悟空”的七十二变,能够在同一时刻做多件事情,完成任务的时间自然就减少了。

假如有一根很长很长的绳子,我们需要把它剪成若干段定长的小绳子,一个人剪需要花很长的时间,但如果另一个人在绳子的另一端同时裁剪呢?剪绳子花费的时间瞬间就少了一半。同样道理,还可以有一个人在中间帮忙剪(不影响首尾两个人的情况下,甚至4个人…
在这种情况下,它们看似做的事情都是在剪同一根绳子,但本质上是因为绳子足够长,他们之间不会相互影响,可以看作是做不同的工作,因此能够大大提供工作效率。(下图同理)
在这里插入图片描述

然而,程序的并发执行并不完全如想象的那般美好,当某个工作只适合一个人独自完成时,另一个人的加入反而会降低效率,甚至导致工作“搞砸了”。例如:同样是“剪绳子”工作,此时的绳子剩下 5cm 长,需要剪的小绳子长度为 3cm,如果此时有两个人分别在两头剪(相当于两个不同线程),他们两个人都“十分勤奋”,只是埋头工作而不观察外界的情况,那结果显而易见,这根大绳子最终会变成 3段 “不合格”的小绳。
在这里插入图片描述
这种情况就是典型的多线程带来的安全问题,它可能导致程序发生不可预估的错误。


二. 线程不安全的现象及原因

先说结论,导致多线程不安全有以下原因:

  • 修改共享的数据
  • 原子性
  • 可见性
  • 指令重排序

🍆1. 修改共享的数据(根本原因)

如果一个属性被多个线程共享,在某个时刻一个线程对该属性进行修改操作,若该修改操作未完成,此时另一个线程也对这个属性进行修改操作,这时可能会使属性的值超出预期结果,造成线程不安全的问题。

🍅2. 原子性

在多线程编程中,原子性是指不可分割的最小操作。若多个线程对同一个属性进行非原子性的修改操作时,就可能引发多线程不安全问题。

例如有以下代码:t1 和 t2 线程同时对一个静态成员变量 num 进行修改,修改规则为:每个线程都对 num变量 进行50000次自增操作。在主线程中等待 t1和t2 线程自增完成输出 num变量的值。

public class Demo {

    public static int num;

    public static void main(String[] args) {

        Thread t1 = new Thread(() -> {
            while (num  < 10000) {
                num++;
            }
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            while (num  < 10000) {
                num++;
            }
        });
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(num);
    }
}

在我们的潜意识中程序输出的结果应该是 10w,但是当我们实际运行这段代码会发现一个奇怪的地方:程序每次输出的结果可能并不相同,并且总是小于 10w。(如下图)
在这里插入图片描述

我们可以看到,在上述的代码中,两个线程代码执行的主要逻辑其实就一条语句,即对 num 进行自增操作,按道理它就是一个“原子性”的操作,那么程序的输出结果为什么总小于 10w 呢?
原因是 num的自增语句在操作系统的实现中会被拆分为以下 3个指令:

  1. load:将 num变量的值从内存加载到CUP的寄存器中
  2. add:对 num进行加1操作
  3. save:将 num的值重新加载到内存中

由于线程是“抢占”式执行的,因此在两个线程的并发执行过程中,可能出现以下若干种指令的执行情况:
在这里插入图片描述

在这里插入图片描述
可以预见的情况:当且仅当 t1和t2 线程严格地轮流进行自增操作时,num才能进行真正有效的自增,出现的其他的若干种时,都会使 num 比预期的结果小 1,因此当多个线程对共享数据进行非原子的修改操作可能会出现多线程安全问题

🥦3. 可见性

可见性是指当一个线程对一个共享的数据进行修改操作后,其他线程能够及时感知。

例如有以下代码:在Demo类中有一个静态成员变量 num,在main方法中有两个线程 t1和t2,t1线程对 num变量赋值,t2线程对 num变量的值进行判断,若 num变量的值不为0,则 t2线程结束循环。

public class Demo22 {

    public static int num;

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            Scanner in = new Scanner(System.in);
            System.out.println("请输入一个数为 num 变量赋值");
            num = in.nextInt();
            System.out.println("赋值成功,num = " + num);
        });

        Thread t2 = new Thread(() -> {
            while (num == 0) {

            }
            System.out.println("t2线程执行结束 !");
        });

        t2.start();
        t1.start();
    }
}

运行以上代码可以发现 t2线程并没有退出循环,程序一直处于运行的状态。
在这里插入图片描述

造成上述结果的原因是:操作系统通过 load 指令将 num变量的值从内存加载到寄存器中,再通过比较指令将 num与 0 进行对比,由于当前 t2线程的执行逻辑较为简单,所以执行循环的速度非常快,如果每次进行循环都要从内存读取 num的值就会大大降低程序的执行效率。因此,编译器采用了一个“大胆的策略”,即只从内存中读取一个 num的值,后续的比较操作便直接从寄存器中取值。这样造成的内存属性已被修改,而其他线程感知不到的情况就是因为“内存可见性”引起的线程安全问题

🍉4. 指令重排序

指令重排序是指在某些情况下,编译器在保证“代码总体逻辑不变”的情况下对CPU指令集的执行顺序进行了调整(优化),在单线程的情况下,这种调整不会对程序的结果造成影响,但在多线程的情况下可能会造成线程安全问题。


三. synchronized 和 volatile 关键字

🍚1. 锁和加锁

在Java中,锁可以理解成是能够将某种资源私有化的一种物品,其中锁又可分为乐观锁和悲观锁 或 公平锁和非公平锁。

在多线程的运行环境中,只要涉及到修改操作的地方,都有可能引发线程不安全的问题,而解决线程不安全问题常用的策略就是利用 synchronized 关键字 给修改操作“加锁”(不公平锁)。

那么怎么理解加锁呢?举几个简单的例子:
1.我们平时在上厕所时,都会习惯性地把门给锁上,在你还没出来之前,后面排队的人都无法进入厕所,这样就能保证厕所的正常有序使用。在这里锁门的操作很明显是一个“加锁”操作,而当我们从厕所出来,开门的过程相当于“释放锁
2.在一个热闹的商场中,到了饭点各个餐厅都几乎处于满座的状态,因此我们需要选择想去的餐厅并到前台取一张带有序号的小票,当叫到我们手上的“排队序号”时,我们就可以凭借手上的小票进入餐厅吃饭,并且后面来的顾客必须在我们进入之后才能依次进入餐厅就餐。在这个过程中,我们获取小票相当于为我们进入餐厅这个行为进行“加锁”,当我们凭借小票进入餐厅后,小票失效的过程就相当于“释放锁

🍥2. 加锁的语法及注意细节

在多线程环境中,对于共享数据的修改操作,利用 synchronized 加锁的方式主要有两种:

  • 用 synchronized 将对应的代码块“包裹”起来
  • 用 synchronized 关键字修饰方法

语法格式如下:

// 1. 为代码块加锁
synchronized (加锁的对象) {
	// 相关的代码逻辑 & 修改操作
}


// 2. 为类的普通方法加锁
public synchronized void method() {
	// 相关的代码逻辑 & 修改操作
}

为方法加锁的等价写法:
在这里插入图片描述

在这里插入图片描述

何时加锁?何时释放锁?
当程序进入‘{’ 包裹的代码块 或 执行 synchronized修饰的方法时加锁。当程序出 ‘}’ 或 执行完方法时释放锁

什么是有效加锁?
当且仅当多个线程对同一把锁产生锁竞争的时候才能成功进行加锁

如何理解“同一把锁”?
同一把锁就同一个对象,产生锁竞争的关键在于是不是同一个对象,而不关注对象的类型(可以为Object类、Lock类或String类等 ),这个对象可以是某个类的实例 或 某个类的Class对象

当多个线程竞争 synchronized关键字加的同一把锁会发生什么?
最先得到锁的线程拿到锁后继续执行代码,其余的线程进入“阻塞等待”的状态,直到第一个线程释放锁后,其余线程同时“公平”地争夺这把锁(不存在先来后到的情况)。

🍭3. 利用 synchronized关键字解决多线程安全问题

由原子性引起的多线程安全问题可以通过 synchronized加锁来解决,在上面的示例中,为两个线程的自增操作加锁,即让自增的过程由抢占式的并发执行变为轮流自增的串行执行就可保证程序的正确性。
修改代码示例如下:

public static void main(String[] args) {

    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            synchronized (Thread.class) {
                num++;
            }
        }
    });
    t1.start();

    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            synchronized (Thread.class) {
                num++;
            }
        }
    });
    t2.start();

    try {
        t1.join();
        t2.join();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println(num);
}

程序的运行结果如下:
在这里插入图片描述

🧊4. Java中 synchronized 的特性

先说结论,synchronized的特性有:

  • 互斥
  • 刷新内存
  • 可重入

4.1 互斥

互斥是产生锁竞争的关键因素,当多个线程都尝试获取一把锁时,拿到锁的线程继续执行代码,其他线程进入阻塞等待状态,直到锁被释放时才由操作系统唤醒。

4.2 刷新内存

synchronized刷新内存的工作过程如下:

  1. 获得互斥锁
  2. 从主内存拷贝变量的最新副本到工作的内存
  3. 执行代码
  4. 将更改后的共享变量的值刷新到主内存
  5. 释放互斥锁

因此使用 synchronized 可以保证内存可见性。

4.3 可重入(了解死锁)

使用 synchronized 关键字加的锁是可重入锁,即当某个线程获得锁后,在代码的执行阶段又尝试获取同一把锁,能够成功获取,不会造成“死锁”的现象。

那么什么是死锁呢?
假设一个程序中有两个线程,并且每个线程分别获得了 1 把不同的锁,它们在释放锁前都尝试获取对方的锁,然而因为它们各自都未释放锁,因此两个线程都进入阻塞等待的状态,造成了死锁的现象
代码示例如下:

public static void main(String[] args) {
    Object lock1 = new Object();
    Object lock2 = new Object();

    Thread t1 = new Thread(() -> {
        synchronized (lock1) {
            System.out.println("t1线程成功获取 lock1");
            synchronized (lock2) {
                System.out.println("t1线程成功获取 lock2");
            }
        }
        System.out.println("线程t1结束 !");
    });

    Thread t2 = new Thread(() -> {
        synchronized (lock2) {
            System.out.println("t2线程成功获取 lock2");
            synchronized (lock1) {
                System.out.println("t1线程成功获取 lock1");
            }
        }
        System.out.println("线程t2结束 !");
    });
    t1.start();
    t2.start();

}

程序的运行结果如下:(可以发现程序并没有正常退出,出现“卡死”的情况)
在这里插入图片描述

产生死锁的四个必要条件

  1. 互斥使用:一个资源每次只能被一个线程使用。
  2. 不可抢占:线程已获得的资源,在末使用完之前,不能强行剥夺。
  3. 请求和保持:当一个线程尝试获取多把锁时,对已获得的锁未进行释放
  4. 循环等待:多个线程获取第二把锁锁形成循环等待的状况。

🍦5. 了解volatile关键字的作用并解决多线程问题

volatile关键字用来修饰一个成员变量,它能够保证内存可见性和禁止指令重排序。即用 volatile 修饰的成员变量在每次被线程访问时,都强制从共享内存中重新读取该成员变量的值;当成员变量发生变化时,会强制线程将变化值回写到共享内存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。
注意:volatile可以保证内存可见性,不保证原子性

在上面的示例中,要保证程序运行的正确性只需用 volatile修饰对应的成员变量皆可。(代码如下)

public volatile static int num;

public static void main(String[] args) throws InterruptedException {

    Thread t1 = new Thread(() -> {
        Scanner in = new Scanner(System.in);
        System.out.println("请输入一个数为 num 变量赋值");
        num = in.nextInt();
        System.out.println("赋值成功,num = " + num);
    });

    Thread t2 = new Thread(() -> {
        while (num == 0) {

        }
        System.out.println("t2线程执行结束 !");
    });

    t2.start();
    t1.start();
}

程序运行结果如下:
在这里插入图片描述


以上就是本篇文章的全部内容了,如果这篇文章对你有些许帮助,你的点赞、收藏和评论就是对我最大的支持。
另外,文章可能存在许多不足之处,也希望你可以给我一点小小的建议,我会努力检查并改进。

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

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

相关文章

python期末实训-学生成绩管理系统

pythontkintermatplotlib学生管理系统 一.需求分析 读取学生成绩的excel表格到数据库中将学生成绩导出到excel文件中学生成绩单增删改查与展示学生成绩按学号搜索和按姓名模糊搜素展示班级学生的整体成绩情况导出每位学生的成绩分析图将程序打包成exe 二.系统设计 将表格数…

Ubuntu使用docker-compose安装redis

ubuntu环境搭建专栏&#x1f517;点击跳转 Ubuntu系统环境搭建&#xff08;十三&#xff09;——使用docker-compose安装redis 文章目录 Ubuntu系统环境搭建&#xff08;十三&#xff09;——使用docker-compose安装redis1.搭建文件夹2.docker-compose.yaml配置文件3.redis.co…

如何唯一标识一个进程

如何唯一标识一个进程 进程ID (PID)&#xff1a; 每个运行中的进程都有一个全局唯一的整数标识符&#xff0c;称为进程ID&#xff08;Process ID&#xff09;。PID由内核分配&#xff0c;并在整个系统范围内保持唯一。 在shell中输入echo $$来查看当前shell的进程ID&#xf…

Java 基于 SpringBoot+Vue 的社区团购系统

博主介绍&#xff1a;✌程序员徐师兄、7年大厂程序员经历。全网粉丝30W、csdn博客专家、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java技术领域和毕业项目实战✌ &#x1f345;文末获取源码联系&#x1f345; &#x1f447;&#x1f3fb; 精彩专栏推荐订阅&#x1f447;…

《Linux C编程实战》笔记:信号的捕捉和处理

Linux系统中对信号的处理主要由signal和sigaction函数来完成&#xff0c;另外还会介绍一个函数pause&#xff0c;它可以用来响应任何信号&#xff0c;不过不做任何处理 signal函数 #include <signal.h> void (*signal(int signum, void (*handler)(int)))(int);可以分解…

拿捏!相关性分析,一键出图!皮尔逊、斯皮尔曼、肯德尔、最大互信息系数(MIC)、滞后相关性分析,直接运行!独家可视化程序!

适用平台&#xff1a;Matlab2020及以上 相关性分析是一种统计方法&#xff0c;用于衡量两个或多个变量之间的关系程度。通过相关性分析&#xff0c;我们可以了解变量之间的相互关系、依赖性&#xff0c;以及它们是如何随着彼此的变化而变化的。相关性分析通常包括计算相关系数…

DBA技术栈MongoDB: 数据增改删除

该博文主要介绍mongoDB对文档数据的增加、更新、删除操作。 1.插入数据 以下案例演示了插入单个文档、多个文档、指定_id、指定多个索引以及插入大量文档的情况。在实际使用中&#xff0c;根据需求选择适合的插入方式。 案例1&#xff1a;插入单个文档 db.visitor.insert({…

【Python】--- 基础语法(1)

目录 1.变量和表达式2.变量和类型2.1变量是什么2.2变量的语法2.3变量的类型2.3.1整数2.3.2浮点数&#xff08;小数&#xff09;2.3.3字符串2.3.4布尔2.3.5其他 2.4为什么要有这么多类型2.5动态类型特征 3.注释3.1注释的语法3.2注释的规范 结语 1.变量和表达式 对python的学习就…

数据库课程设计-图书管理系统数据库设计

目录 一、实验目的 二、实验内容 三、实验要求 四、实验设计 4.1需求分析 4.1.1系统目标 4.1.2功能需求 4.1.3性能需求 4.14界面需求 4.2概念模型设计 4.2.1 实体及联系 4.2.2 E-R图 4.3 逻辑设计 4.3.1 E-R模型向关系模型的转换 4.3.2 数据库逻辑结构 4.3.3数据库模型函数依赖…

Elasticsearch各种高级文档操作

本文来记录下Elasticsearch各种文档操作 文章目录 初始化文档数据查询所有文档匹配查询文档关键字精确查询文档多关键字精确查询文档字段匹配查询文档指定查询字段查询文档过滤字段查询文档概述指定想要显示的字段示例指定不想要显示的字段示例 组合查询文档范围查询文档概述使…

74.MySQL 分页原理与优化(下)

文章目录 前言一、一次分页查询的演进二、分页数据在不同页反复出现的坑 前言 上一篇文章介绍了分页原理与优化&#xff1a;73.MySQL 分页原理与优化&#xff08;上&#xff09; 但分页还有一个“坑”需要注意&#xff0c;本文细细道来&#xff0c;可能很多朋友都踩过这个坑还…

LOSS损失函数值是什么意思?

环境&#xff1a; Bert-VITS2-v2.3 问题描述&#xff1a; LOSS损失函数值是什么意思&#xff1f; 解决方案&#xff1a; 在机器学习和深度学习中&#xff0c;损失函数&#xff08;Loss Function&#xff09;用来衡量模型预测值与实际值之间的差异或误差。LOSS损失函数值是…

(Bean实例化的基本流程 )学习Spring的第六天

Bean实例化的基本流程 其实可以解释为三个过程: 1 . 有关Bean的信息被封装成一个map集合 : DeanDefinitionMap . key为Bean的名称 , value为有关<bena>标签的信息 2 . Spring框架对这个存储Bean信息的Map进行遍历 , 进行创建对象 , 把创建好的对象存储到另一个Map集合里…

08. Springboot集成webmagic实现网页爬虫

目录 1、前言 2、WebMagic 3、Springboot集成Webmagic 3.1、创建Springboot&#xff0c;并引入webmagic依赖 3.2、定义PageProcessor 3.3、元素选择 3.3.1、F12查看网页元素 3.3.2、元素选择 3.3.3、注意事项 4、小结 1、前言 在信息化的时代&#xff0c;网络爬虫已…

redis远程连接不上解决办法

问题描述&#xff1a; redis远程服务端运行在192.168.3.90计算机上&#xff0c;客户端计算机&#xff08;ip:192.168.3.110&#xff09;通过redsi-cli.exe客户端工具连接时&#xff0c;没有反应&#xff0c;连接不上。 如图所示&#xff1a; 解决步骤&#xff1a; 步骤一&…

二叉树进阶oj题目

二叉树进阶oj题目 两个结点的最近公共祖先前序中序&#xff08;中序后序&#xff09;还原二叉树 1、两个结点的最近公共祖先&#xff08;两种方法&#xff09; leetcode链接 题目描述&#xff1a;给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。 百度百科中最近公共…

web蓝桥杯真题--12、由文本溢出引发的“不友好体验”

背景介绍 通常情况下&#xff0c;为保证布局的稳定性&#xff0c;以及遵循在有限的空间展示更多内容的原则&#xff0c;页面的某块区域不会随内容的增多而无限增高或增宽&#xff0c;一般会有一个约束。 例如&#xff1a;整体元素过多可以使用滚动条&#xff1b;文字内容过多…

【Leetcode】接雨水(双指针、单调栈)

目录 &#x1f4a1;题目描述 &#x1f4a1;双指针解法 &#x1f4a1;单调栈解法 &#x1f4a1;题目描述 给定 n 个非负整数表示每个宽度为 1 的柱子的高度图&#xff0c;计算按此排列的柱子&#xff0c;下雨之后能接多少雨水。 提示&#xff1a; n height.length1 < n…

CGLIB动态代理(AOP原理)(面试重点)

推荐先看JDK 动态代理&#xff08;Spring AOP 的原理&#xff09;&#xff08;面试重点&#xff09; JDK 动态代理与 CGLIB 动态代理的区别 JDK 动态代理有⼀个最致命的问题是其只能代理实现了接⼝的类. 有些场景下,我们的业务代码是直接实现的,并没有接⼝定义.为了解决这个问…

linux内核源码编译2.6失败

centos7环境 iso选择 https://mirrors.tuna.tsinghua.edu.cn/centos/7/isos/x86_64/CentOS-7-x86_64-DVD-2009.iso 自带qemu&#xff0c;未实测是否可用 选择编译版本2.6 下载地址 遇到的编译错误解决 yum list | grep curses yum install ncurses-devel.x86_64 -y yum i…