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

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

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

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

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

相关文章

【VRRP】虚拟路由冗余协议

什么是VRRP&#xff1f; 虚拟路由冗余协议VRRP&#xff08;Virtual Router Redundancy Protocol&#xff09;是一种用于提高网络可靠性的容错协议。通过VRRP&#xff0c;可以在主机的下一跳设备出现故障时&#xff0c;及时将业务切换到备份设备&#xff0c;从而保障网络通信的…

Redis基础知识

Redis基础知识 redis共有16个数据库&#xff0c;默认使用的是第0个 可以使用select进行切换数据库 # 切换数据库 127.0.0.1:6379> select 1 OK # 查看DB大小 127.0.0.1:6379[1]> DBSIZE (integer) 0查看当前数据库所有的key 127.0.0.1:6379> keys * #查看当前数据…

Mybatis查询数据

上一篇我们介绍了在pom文件中引入mybatis依赖&#xff0c;配置了mybatis配置文件&#xff0c;通过读取配置文件创建了会话工厂&#xff0c;使用会话工厂创建会话获取连接对象读取到了数据库的基本信息。 如果您需要对上面的内容进行了解&#xff0c;可以参考Mybatis引入与使用…

【业务功能篇87】微服务-springcloud-本地缓存-redis-分布式缓存-缓存穿透-雪崩-击穿

一、缓存 1. 什么是缓存 缓存的作用是减低对数据源的访问频率。从而提高我们系统的性能。 缓存的流程图 2.缓存的分类 2.1 本地缓存 其实就是把缓存数据存储在内存中(Map <String,Object>).在单体架构中肯定没有问题。 单体架构下的缓存处理 2.2 分布式缓存 在分布式环…

Java学习笔记31——字符流

字符流 字符流为什么出现字符流编码表字符串中的编码解码问题字符流写数据的5中方式字符流读数据的两种方式字符流复制Java文件 字符流 为什么出现字符流 汉字的存储如果是GBK编码占用2个字节&#xff0c;如果是UTF-8占用三个字节 用字节流复制文本文件时&#xff0c;文本文…

2023年腾讯云轻量应用服务器优缺点大全

2023年腾讯云轻量应用服务器优缺点大全&#xff0c;腾讯云轻量应用服务器性能如何&#xff1f;轻量服务器CPU内存带宽配置高&#xff0c;CPU采用什么型号主频多少&#xff1f;轻量应用服务器会不会比云服务器CVM性能差&#xff1f;腾讯云服务器网详解CPU型号主频、内存、公网带…

Linux通过libudev获取挂载路径、监控U盘热拔插事件、U盘文件系统类型

文章目录 获取挂载路径监控U盘热拔插事件libusb 文件系统类型通过挂载点获取挂载路径添libudev加库 获取挂载路径 #include <stdio.h> #include <libudev.h> #include <string.h>int main() {struct udev *udev;struct udev_enumerate *enumerate;struct ud…

数据库备份和Shell基础测试及AWK(运维)

第一题&#xff1a;简述一下如何用mysql命令进行备份和恢复&#xff0c;请以test库为例&#xff0c;创建一个备份&#xff0c;并再用此备份恢复备份 备份步骤&#xff1a; 备份test库&#xff1a;使用mysqldump命令备份test库&#xff0c;并将备份写入一个.sql文件中。命令示例…

【第1章 数据结构概述】

目录 一. 基本概念 1. 数据、数据元素、数据对象 2. 数据结构 二. 数据结构的分类 1. 数据的逻辑结构可分为两大类&#xff1a;a. 线性结构&#xff1b;b. 非线性结构 2. 数据的存储结构取决于四种基本的存储方法&#xff1a;顺序存储、链接存储、索引存储、散列存储 3. …

【力扣每日一题】2023.8.24 统计参与通信的服务器

目录 题目&#xff1a; 示例&#xff1a; 分析&#xff1a; 代码&#xff1a; 题目&#xff1a; 示例&#xff1a; 分析&#xff1a; 题目顾名思义&#xff0c;要我们统计参与通信的服务器&#xff0c;给我们一个二维矩阵&#xff0c;元素为1的位置则表示是一台服务器。 …

学习Linux基础知识与命令行操作

开始学习Linux系统前&#xff0c;首先要掌握计算机基础知识&#xff0c;了解硬件、操作系统、文件系统、网络和安全等概念。对这些基础知识的了解能够帮助理解Linux系统的概念和功能。 在Linux系统中&#xff0c;文件和目录是数据管理的基本单位。每个文件和目录都有一个称为&…

OAuth2.0 知识点梳理

文章目录 OAuth2.0 知识点梳理一、四种角色二、四种模式的概述三、四种模式的图解 OAuth2.0 知识点梳理 一、四种角色 为了能够更好的理解本文中后续的内容&#xff0c;这里我先说下&#xff0c;OAuth2.0 中相关的四种角色&#xff0c;如下&#xff1a; 资源拥有者资源服务客…

内网实战1

1、信息收集&#xff1a; 使用nmap做端口扫描&#xff1a; nmap -sV -Pn -T4 192.168.26.174重要端口&#xff1a;80、445、139、135、3306 目录扫描&#xff1a; 访问80端口&#xff1a;发现一个网站是phpstudy搭建的&#xff1b; 发现一个mysql数据库&#xff0c;那我们…

[QT]设置程序仅打开一个,再打开就唤醒已打开程序的窗口

需求&#xff1a;speedcrunch 这个软件是开源的计算器软件。配合launch类软件使用时&#xff0c;忘记关闭就经常很多窗口&#xff0c;强迫症&#xff0c;从网上搜索对版本进行了修改。 #include "gui/mainwindow.h"#include <QCoreApplication> #include <…

CocosCreator3.8研究笔记(一)windows环境安装配置

一、安装Cocos 编辑器 &#xff08;1&#xff09;、下载Cocos Dashboard安装文件 Cocos 官方网站Cocos Dashboard下载地址 &#xff1a; https://www.cocos.com/creator-download9下载完成后会得到CocosDashboard-v2.0.1-win-082215.exe 安装文件&#xff0c;双击安装即可。 …

智能工厂移动式作业轻薄加固三防平板数据采集终端

在这个高度自动化和数字化的环境中&#xff0c;数据采集变得尤为重要。为了满足这个需求&#xff0c;工业三防平板数据采集终端应运而生。工业三防平板数据采集终端采用了轻量级高强度镁合金材质&#xff0c;这使得它在保持轻薄的同时具有更强的坚固性。这种材质还具有耐磨防损…

机器学习笔记之核函数再回首:Nadarya-Watson核回归python手写示例

机器学习笔记之核函数再回首——Nadaraya-Watson核回归手写示例 引言回顾&#xff1a; Nadaraya-Watson \text{Nadaraya-Watson} Nadaraya-Watson核回归通过核函数描述样本之间的关联关系使用 Softmax \text{Softmax} Softmax函数对权重进行划分将权重与相应标签执行加权运算 N…

自动化测试(三):接口自动化pytest测试框架

文章目录 1. 接口自动化的实现2. 知识要点及实践2.1 requests.post传递的参数本质2.2 pytest单元测试框架2.2.1 pytest框架简介2.2.2 pytest装饰器2.2.3 断言、allure测试报告2.2.4 接口关联、封装改进YAML动态传参&#xff08;热加载&#xff09; 2.3 pytest接口封装&#xff…

Android 绘制之文字测量

drawText() 绘制文字 绘制进度条:paint.strokeCap Paint.CAP.RONUD 线条两边样式 设置文字字体:paint.typeFace Resources.Compat.getFont(context,font) 设置加粗 paint.isFakeBoldText 设置居中: paint.setTextAlign Paint.Align.CENTER //居中, 并不是真正的居中 往…

农村农产品信息展示网站的设计与实现(论文+源码)_kaic

摘 要 随着软件技术的迅速发展,农产品信息展示的平台越来越多,传统的农产品显示方法将被计算机图形技术取代。这种网站技术主要把农产品的描述、农产品价格、农产品图片等内容&#xff0c;通过计算机网络的开发技术&#xff0c;在互联网上进行展示&#xff0c;然后通过计算机网…