线程安全-搞清synchronized的真面目

news2025/1/12 16:12:35

       多线程编程中,最难的地方,也是最重要的一个地方,还是一个最容易出错的地方,更是一个特别爱考的地方,就是线程安全问题

万恶之源,罪魁祸首,多线程的抢占式执行,带来的随机性.
如果没有多线程,此时程序代码执行顺序就是固定的.(只有一条路)﹒代码顺序固定,程序的结果就是固定的.[单线程的情况下,只需要理清楚这一条路即可)
如果有了多线程,此时抢占式执行下,代码执行的顺序,会出现更多的变数。代码执行顺序的可能性就从一种情况变成了无数种情况。
所以就需要保证这无数种线程调度顺序的情况下,代码的执行结果都是正确的。
只要有一种情况下,代码结果不正确,就都视为是有bug,线程不安全。

目录

线程安全

原因

synchronized

synchronized使用方法

1.修饰方法

2.修饰代码块             

3.可重入

4.其他的锁

5.Java标准库中的线程安全类

死锁

死锁的三种典型情况

1.一个线程一把锁

2.两个线程两把锁

3.多个线程多把锁

 死锁的四个必要条件

1.互斥使用

2.不可抢占

3.请求和保持

4.循环等待

如何避免死锁

内存可见性

​编辑

volatile

wait notify


线程安全

class Counter{
    public int count = 0;

    public void add(){
        count++;
    }
}

public class demo1 {
    public static void main(String[] args) {
        Counter counter = new Counter();

        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) {
            throw new RuntimeException(e);
        }
        System.out.println("coount=" + counter.count);
    }
}

count=59005

进程已结束,退出代码0


coount=75148

进程已结束,退出代码0


count=67437

进程已结束,退出代码0

我们先来看到这样一个代码:

两个线程各自自增5w次,一共自增10w次,预期结果count是10w,但是实际结果并不是10w,而且每一次都不一样,这个就称为bug。

为什么会出现这样的情况?

count++;

对于count++这个操作本质上要分为三步:

1.把内存中的值,读取到CPU的寄存器中去  load

2.把CPU寄存器里的数值进行+1运算            add

3.把得到的结果写到内存中去                        save

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

 但是那么多种情况,只有这种情况才是我们所需的正确的情况(t1 t2可以交换)

 下面这种情况就是一个不正确的,类似于事务中的读到了一个脏数据。t1读到的是一个t2还没来得及提交的脏数据,于是就出现了脏读问题~

此处讲的多线程,和前面的并发事务,本质上都是“并发编程”问题,并发处理事务,底层也是基于多线程这样的方式来实现的 。

一个线程是完成一个任务,要做一些工作,你这个工作是可以分解成一个一个的小步骤的,每一个小步骤就是一个指令。由于线程的抢占式执行,导致当前执行到任意一个指令的时候,线程都可能被调度走CPU让别的线程来执行。

当前这个代码,是否可能结果正好是10w呢?是有可能的,只是概率非常小,假设两个线程的每次调度顺序都是先t1再t2或者先t2再t1,那么还是有可能的~

同时也有可能最后的结果小于5w,可能t1先加载,t2连续执行三次,最后的结果count只加1。

原因

到底是什么样的情况会出现线程安全问题?

1.[根本原因] 抢占式执行,随机调度

2.代码结构:多个线程同时修改一个变量(注意,这里说的是修改,也就是写)

        一个线程修改一个变量,没事

        多个线程读取一个变量,没事

        多个线程修改多个不同的变量,也没事

3.原子性:如果修改操作是原子的,那么不会有事

   但是如果是非原子的,出现问题的概率就非常高了

count++可以拆分成 load add save 三个操作

我们需要通过操作把这个非原子的操作变成原子的:加锁

4.内存可见性问题

5.指令重排序(本质上是编译器优化出bug了)

以上分析出的是五个典型的原因,不是全部

一个代码究竟是线程安全还是不安全,都得具体问题 具体分析

如果一个代码踩中了上面的原因,也可能线程安全
如果一个代码没踩中上面的原因,也可能线程不安全.......

结合原因,结合需求,具体问题具体分析.
最终抓住的原则:多线程运行代码,不出bug,就是安全的!!!

如何从原子性入手,来解决线程安全问题呢?

synchronized

这是一个关键字,表示加锁

 加了synchronized之后,进入方法就会加锁,出了方法就会解锁

如果两个线程同时尝试加锁,此时一个能获取成功,另一个只能阻塞等待(BLOCKED),一直阻塞到刚才的线程释放锁(解锁),当前线程才能加锁成功。

引出之前介绍的线程的几种状态之一:BLOCKED 等待另一个线程解锁的状态

加锁之后,代码执行速度一定是大打折扣的,但是仍然是比单线程要快。

刚刚的例子中,加锁只是针对了count++加锁了,但是除了count++之外,还有for循环的代码,for循环是可以并行的,只是count++串行了。一个任务中,一部分并发,一部分串行,仍然是比所有的代码串行要快~

synchronized使用方法

1.修饰方法

1)修饰普通方法        修饰普通方法,锁对象就是this

2)修饰静态方法        修饰静态方法,锁对象就是类对象(Counter.class)

2.修饰代码块             

修饰代码块,显示\手动指定锁对象

所以加锁是要明确执行对哪个对象加锁的

如果两个线程针对同一个对象加锁,会产生阻塞等待(锁竞争、锁冲突)

如果两个线程针对同一个对象进行加锁,就会出现锁竞争/锁冲突,一个线程能够获取到锁(先到先得)另一个线程阻塞等待,等待到上一个线程解锁,它才能获取锁成功~~否则就不会
如果两个线程针对不同对象加锁,此时不会发生锁竞争/锁冲突.这俩线程都能获取到各自的锁.不会有阻塞等待了.
还是两个线程,一个线程加锁,一个线程不加锁这个时候是否有锁竞争呢??没有的!!!

eg1:

public synchronized void add(){
    count++;
}这里直接把synchronized修饰到方法上了,此时相当于针对this加锁

eg2:

 eg3:

public void add(){
    synchronized(this){
        count++;
    }
}
进入代码块就解锁
出了代码块就解锁

这里的this可以指定任意你想指定的对象(不一定非要是this)

3.可重入

synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题

一个线程针对同一个对象,连续加锁两次,是否会有问题~~如果没问题,就叫可重入的。如果有问题,就叫不可重入的。

synchronized public void add(){
    synchronized(this){
        count++;
    }
}

 在这个代码块中,锁对象是this,只要有线程调用add,进入add方法的时候,就会先加锁(能够加锁成功),紧接着又遇到了代码块,再次尝试加锁。

站在this(锁对象)的视角,它认为自己已经被线程占用了,这里的第二次加锁要不要阻塞等待呢?

这里的第二个线程和第一个线程,其实是同一个线程

在是相同线程的前提下如果允许第二个锁不用阻塞等待,那么就说这个锁是可重入的

反之(第二次加锁会阻塞等待),就说是不可重入的

(就是在锁对象里面记录一下,当前的锁是哪个线程持有的,如果加锁线程和持有线程是同一个,就直接放过,否则就阻塞)

因为Java代码中很容易出现死锁,所以Java就把synchronized设定成可重入的了

4.其他的锁

除了Java的synchronized之外,很多别的语言别的库,加锁解锁往往是两个分开的操作,比如:加锁lock(),解锁unlock(),但是这样分开写容易忘记写unlock

所以synchronized基于代码块的方式,就有效的解决了上述问题

5.Java标准库中的线程安全类

死锁

       死锁是一个非常影响程序员幸福感的问题,一但程序出现死锁,就会导致无法执行后续工作,程序就会有严重bug。并且死锁是非常隐蔽的,开发阶段不经意间就会写出死锁代码,不容易测试出来。

死锁的三种典型情况

1.一个线程一把锁

连续加锁两次

如果锁是不可重入锁,就会死锁。

Java中synchronized和ReentrantLock都是可重入锁,C++,Python,操作系统原生的加锁API都是不可重入的,就会在这种情况下出现死锁。

2.两个线程两把锁

t1,t2各自先针对锁A,锁B加锁,再尝试获取对方的锁

(在这段代码中要加入sleep,否则会出现线程执行速度差别较大从而能够获取到对方的锁)

locker1和locker2分别加锁,再申请对方的锁,这样就会进入死锁,结果什么也没有,于是我们可以运用jconsole来看一下线程的情况:

可以很清楚的看到,两个线程都进入了BLOCKED状态,表示获取锁,获取不到的阻塞状态。

针对这样的死锁问题,也是需要借助像jconsole这样的工具来进行定位的。看线程的状态和调用栈,就可以分析出代码是在哪里死锁了。

3.多个线程多把锁

 死锁的四个必要条件

1.互斥使用

线程1拿到了锁,线程2就得等着(锁的基本特性)

2.不可抢占

线程1拿到锁之后,必须是线程1主动释放,不能说是线程2就把锁给强行获取到。

3.请求和保持

线程1拿到锁A之后,再次尝试获取锁B,A这把锁没有释放,就仍然是保持的。

4.循环等待

线程1尝试获取到锁A和锁B;线程2尝试获取到锁B和锁A。

线程1在获取B的时候等待线程2释放B;同时线程2在获取A的时候等待线程1释放A。

只有这四个条件同时具备,才出现死锁。

循环等待是这四个条件里唯一一个和代码结构相关的,也是我们可以控制的。

如何避免死锁

避免死锁,突破点就是循环等待

方法:给锁编号,然后指定一个固定的顺序(比如从小到大)来加锁,任意线程加多把锁的时候,都让线程遵守上述顺序,此时循环等待自然破除。

内存可见性

class Mycounter{
    int flag = 0;
}
public class demo1 {
    public static void main(String[] args) {
        Mycounter mycounter = new Mycounter();

        Thread t1 = new Thread(() -> {
            while(mycounter.flag == 0){            t1这里要快速重复的读取flag的值

            }
            System.out.println("循环结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入值");
            mycounter.flag = scanner.nextInt();
        });
    }
}

线程2修改了flag的值,理论上线程1应该会打印循环结束,但是实际上并不会。当输入1的时候,这个线程并不会结束循环。

这个问题就叫做:内存可见性问题

这是一个bug,也是一个线程安全问题

while(mycounter.flag == 0)

这里用汇编来理解,就是两步操作:

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

2.cmp,把寄存器的值,和0进行比较,根据比较结果再进行下一步的执行

上述是一个循环,这个循环执行速度极快,一秒钟执行百万次以上。

循环执行这么多次,在t2真正修改之前,load得到的结果都是一样的,另一方面,load操作和cmp操作相比,执行速度慢非常非常多~

由于load执行的速度太慢(相比于cmp来说),再加上反复的load到的结果都一样,JVM就做出了一个大胆的决定:不再真正的重复load,判定好像flag的值不会被修改,干脆就只读取一次就好了。

因为CPU针对寄存器的操作,要比内存快很多!于是通过编译器优化,从而导致了这样的结果。

内存可见性问题:

一个线程对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,此时读到的值,不一定是修改过后的值。

volatile

这时候就需要我们手动干预,需要用到的关键字是violatile。

volatile关键字的作用主要有如下两个:
1. 线程的可见性:当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
2. 顺序一致性:禁止指令重排序。

同时volatile只能修饰变量,synchronized可以修饰方法,静态方法,代码块。

这就相当于告诉编译器,这个变量是易变的,你要每次都重新读取这个变量的内容。

一个变量在两个线程中,一个读,一个写就需要考虑violatile了。

wait notify

现在有一个场景:t1 t2俩线程,希望t1先干活,干的差不多了,再让t2来干。就可以让t2先wait (阻塞,主动放弃cpu)等t1干的差不多了,再通过notify通知t2,把t2唤醒,让t2接着干。

是不是这个场景和join有点类似,也是让其中一个线程等待另一个线程。但是如果我们想先让t1执行50%,再执行t2,join就做不到了。

这个时候就需要用到wait和notify

当t1执行到50%时,手动让其wait,让其进入WAITING状态,然后等待t2执行完毕再执行t1,仅需要用notify唤醒就行了。

 但是报错了

 为什么会有这个异常?先来了解一下wait的操作:

1.先释放锁

2.进行阻塞等待

3.收到通知之后,重新尝试获取锁,并且在获取锁后,继续往下执行。

因此wait操作要搭配synchronized来使用

public class demo2 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();

        System.out.println("t1 wait之前");
        Thread t1 = new Thread(() -> {
            synchronized (object){
                try {
                    object.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1 wait之后");
        });

        Thread t2 = new Thread(() -> {
            System.out.println("t2 notif之前");
            synchronized (object){
                object.notify();
            }
            System.out.println("t2 notif之后");
        });
        t1.start();
        t2.start();
    }
}

同时要注意,只有object四次引用的对象是同一个对象,那么这里的结果才是我们想要的。

wait的带有等待时间的版本,看起来就和sleep有点像,其实还是有本质差别的
虽然都是能指定等待时间,虽然也都能被提前唤醒(wait是使用notify唤醒, sleep使用interrupt唤醒)但是这里表示的含义截然不同。
notify唤醒wait,这是不会有任何异常的。(正常的业务逻辑)interrupt唤醒sleep 则是出异常了。(表示一个出问题了的逻辑)

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

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

相关文章

paddle.load与pandas.read_pickle的速度对比(分别在有gpu 何无gpu 对比)

有GPU 平台 测试通用代码 import time import paddle import pandas as pd# 测试paddle.load start_time time.time() paddle_data paddle.load(long_attention_model) end_time time.time() print(f"Paddle load time: {end_time - start_time} seconds")# 测试…

【USRP】调制解调系列4:BPSK、QPSK、8PSK、OQPSK、Pi/4DQPSK,基于labview的实现

PSK Phase Shift Keying – 相移键控 在某些调制解调器中用于数据传输的调制系统&#xff0c;在最简单的方式中&#xff0c;二进制调制信号产生0和1。载波相位来表示信号占和空或者二进制1和O。对于有线线路上较高的数据传输速率&#xff0c;可能发生4个或8个不同的相移&…

系统架构:软件工程

文章目录 资源知识点自顶向下与自底向上形式化方法结构化方法敏捷方法净室软件工程面向服务的方法面向对象的方法快速应用开发螺旋模型软件过程和活动开放式源码开发方法功用驱动开发方法统一过程模型RUP基于构件的软件开发UML 资源 信息系统开发方法 知识点 自顶向下与自底…

uniapp 配置网络请求并使用请求轮播图

由于平台的限制&#xff0c;小程序项目中不支持 axios&#xff0c;而且原生的 wx.request() API 功能较为简单&#xff0c;不支持拦截器等全局定制的功能。因此&#xff0c;建议在 uni-app 项目中使用 escook/request-miniprogram 第三方包发起网络数据请求。 官方文档&#xf…

7. 搭建网络

7.1 神经网络 ① 把网络结构放在Sequential里面&#xff0c;好处就是代码写起来比较简介、易懂。 ② 可以根据神经网络每层的尺寸&#xff0c;根据下图的公式计算出神经网络中的参数。 7.2 搭建神经网络 import torch import torchvision from torch import nn from torch.…

【Day-22慢就是快】代码随想录-二叉树-理论基础

二叉树的种类 满二叉树 如果一棵二叉树只有度为0的结点和度为2的结点&#xff0c;并且度为0的结点在同一层上&#xff0c;则这棵二叉树为满二叉树。 深度为K&#xff0c;有2^k-1个节点。 完全二叉树 在完全二叉树中&#xff0c;除了最底层节点可能没填满外&#xff0c;其余…

提升代码可读性与可维护性:利用责任链模式优化你的Spring Boot代码

1. 基本介绍 责任链是一种非常常见的设计模式, 具体我就不介绍了, 本文是讲解如何在SpringBoot中优雅的使用责任链模式 1.1. 代码执行流程 基本步骤如下 : SpringBoot启动时, 需要获取 handler 对应Bean, 不同业务对应着不同的多个处理器, 比如 购票业务, 可能需要检查参数是…

LeetCode第21~25题解

CONTENTS LeetCode 21. 合并两个有序链表&#xff08;简单&#xff09;LeetCode 22. 括号生成&#xff08;中等&#xff09; LeetCode 21. 合并两个有序链表&#xff08;简单&#xff09; 【题目描述】 将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两…

使用awvs进行web安全扫描

1、安装 docker pull secfa/docker-awvs docker run -it -d -name awvs -p 13443:3443 --cap-add LINUX_IMMUTABLE secfa/docker-awvs2、账号密码 # https://ip:13443/ # 用户名:adminadmin.com # 密码:Admin1233、使用 ps:需要征得甲方的同意

重磅!OpenAI突然发布企业版ChatGPT:没有限制、更快、更强、更安全的GPT-4

这是由【小瑶智能体】 AI创作的第 4 篇科技文章 大模型研究测试传送门 GPT-4传送门&#xff08;免墙&#xff0c;可直接测试&#xff0c;遇浏览器警告点高级/继续访问即可&#xff09;&#xff1a;Hello, GPT4! 大家好&#xff0c;我是小瑶智能体&#xff0c;一个喜欢分享人…

VR全景展示:打造三维、立体化的VR房产参观方式

我们从近期的新闻中可以了解到&#xff0c;房地产行业正在经历挑战和压力&#xff0c;因为房地产销售市场的持续低迷&#xff0c;导致很多公司出现了债务危机。线下销售模式效果不佳&#xff0c;很多房企开始转战线上销售&#xff0c;VR全景展示方案为房地产销售带来了全新的体…

open cv快速入门系列---数字图像基础

目录 一、数字图像基础 1.1 数字图像和图像单位 1.2 区分图片分辨率与屏幕分辨率 1.3 图像的灰度与灰度级 1.4 图像的深度 1.5 二值图像、灰度图像与彩色图像 1.6 通道数 二、数字图像处理 2.1 图像噪声及其消除 2.2 数字图像处理技术 2.2.1 图像变换 2.2.2 图像增强…

汇编代码:在代码段中使用栈完成数据的倒序

前言 在代码段中使用栈完成数据的倒序 1、代码如下 assume cs:codesgcodesg segmentdw 0123H,0456H,0789H,0abcH,0defH,0fedH,0cbaH,0987Hdw 0,0,0,0,0,0,0,0 ;用dw定义8个字型数据&#xff0c;在程序加载后&#xff0c;将取得8个字的;内存空间&#xff0c;存放这8个数据。我…

Linux通信--构建进程通信的 方案之管道(下)|使用匿名管道实现功能解耦|命名管道实现serveclient通信

文章目录 一、管道的应用实例-父进程唤醒子进程&#xff0c;子进程执行某种任务 二、命名管道 1.创建一个命名管道 2.匿名管道与命名管道的区别 3.命名管道的打开规则 4.用命名管道实现server&client通信 一、匿名管道的应用实例-父进程唤醒子进程&#xff0c;子进程执…

Verilog开源项目——百兆以太网交换机(一)架构设计与Feature定义

Verilog开源项目——百兆以太网交换机&#xff08;一&#xff09;架构设计与Feature定义 &#x1f508;声明&#xff1a;未经作者允许&#xff0c;禁止转载 &#x1f603;博主主页&#xff1a;王_嘻嘻的CSDN主页 &#x1f511;全新原创以太网交换机项目&#xff0c;Blog内容将聚…

vue使用命令npm install 报错 cb() never called!

一.错误说明,npm本身下载就慢&#xff0c;有可能是网络的问题。 二.解决方案,把npm设置成淘宝镜像后,再重新npm install npm config set registry https://registry.npm.taobao.org 三.还是不行&#xff0c;还会出现同样的问题&#xff0c;那接下来先清理一下npm缓存 npm cache…

微信网站应用申请

网站应用是基于微信开放平台的。它的特点是&#xff1a; 1、用户可使用微信帐号快速登录你的网站&#xff0c;降低注册门槛&#xff0c;提高用户留存 2、同一用户使用微信登录你的不同应用和公众帐号&#xff0c;会对应同一个UnionID&#xff0c;以便进行不同业务间的帐号统一…

【力扣每日一题】2023.8.28 插入区间

目录 题目&#xff1a; 示例&#xff1a; 分析&#xff1a; 代码&#xff1a; 题目&#xff1a; 示例&#xff1a; 分析&#xff1a; 和昨天的题大差不差&#xff0c;我们仍然是有一堆区间&#xff0c;题目给我们一个新的区间&#xff0c;要我们把新区间插入到原本的区间数…

ES 7.6 - API高阶操作篇

ES 7.6 - API高阶操作篇 分片和副本索引别名添加别名查询所有别名删除别名使用别名代替索引操作代替插入代替查询 场景实操 滚动索引索引模板创建索引模板查看模板删除模板 场景实操一把索引的生命周期数据迁移APIGEO(地理)API索引准备矩形查询圆形查询多边形查询 自定义分词器…

范式 事务 多表查询

范式 概念&#xff1a;设计数据库时&#xff0c;需要遵循的一些规范。要遵循后边的范式要求&#xff0c;必须遵循前边的所有范式要求 第一范式&#xff1a; 数据库表的每一列都是不可分割的基本数据项 这样子就不满足第一范式 这样子就满足第一范式 存在问题&#xff1a; 数…