【多线程安全】死锁 锁竞争总结

news2024/12/28 19:56:00

下面有两段代码:

public class test {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        });

        t1.start();
        t1.join();  //程序这样写,相当于线程顺序执行

        t2.start();
        t2.join();

        System.out.println(count);
    }
}

上面代码中每个线程先start(),再join(),这样就相当于线程顺序执行了。不会发生线程不安全。

public class test {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        });

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

        t1.join();
        t2.join();

        System.out.println(count);
    }
}

显然,第二个代码的打印结果出出现了线程不安全。一个原因是因为count++这个操作不是原子的。

我们首先来看count++这个操作在CPU上是怎样执行的?

count++在CPU上执行大致分为三步:

  1. load 把数据从内存读到cpu寄存器中
  2. add 把寄存器中的数据进行+1
  3. save 吧寄存器中的数据保存到内存中。

那么当多个线程执行count++操作时,由于线程的调度顺序是随机的,就会如下图所示,假设执行顺序从上到下。

上述组合情况只列了四个,还是在两个线程的情况下,如果线程更多,那排列组合的情况也会更多。那么这种线程安全问题如何解决呢?为此我们引入加锁这种方式来解决。

 解决办法:  加锁   synchronized

  • synchronized修饰的是一个代码块。同时会指定一个"锁对象"。在进入代码块的时候,对该对象进行加锁,在出代码块的时候,对该对象进行加锁。
  • 锁对象一般是临时创建出来的,Object型的,对象是谁不重要,关键是两个线程加锁的对象是否是同一个。
  • 这样的好处就是当多个线程针对同一个对象进行加锁时,就会出现"锁竞争/锁冲突",并且只有当线程拿到锁之后,才能继续执行代码;没拿到锁时,就只能阻塞等待。等待拿到锁的线程释放锁之后,它才有机会拿到锁,继续执行。
  • 这样的规则,本质上是把"并发执行"变成"串行执行"了。
  • 并且加锁后可以将不是原子的 count++ 变成原子的。

即如图两种情况所示:

// 加锁  针对同一个变量进行修改时存在的线程问题的 例如下面的 count++
public class Demo10 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {

        //创建一个对象
        Object locker = new Object();
        Thread t1 = new Thread( () -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (locker) {     //加锁方式 ()中需要表示一个用来加锁的对象   synchronized 还可以修饰方法
                    count++;
                }

            }
        });

        Thread t2 = new Thread( () -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (locker) {     //加锁方式 ()中需要表示一个用来加锁的对象
                    count++;
                }
            }
        });

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

        t1.join();
        t2.join();

        System.out.println(count);
    }
}

 可以看到,加锁之后,符合预期结果了。

产生线程安全问题的原因 (面试题)

  1. 操作系统中,线程的调度顺序是随机的(抢占式执行)。
  2. 当两个或多个线程针对同一个变量进行修改时,如count++。
  3. 修改操作不是原子的。count++就是非原子的。
  4. 内存可见性问题。
  5.  指令重排序问题。

synchronized修饰的两种方法

  1. 修饰静态方法  即static修饰的方法。
  2. 修饰非静态普通方法。

1.修饰静态方法,实则修饰类对象  synchronized (类名.class) {  }  类对象在一个java进程中是唯一的。

    public static int count = 0;
    

    //修饰静态方法简化
    synchronized public static void increase() {    //修饰静态方法 实则修饰类对象 加锁
        count++;
    }


    //修饰静态方法完整
    public static void increase() {
        synchronized (Counter.class) {  //类对象
            count++;
        }
    }

2.修饰非静态普通方法,实则修饰this  synchronized (this) {  }

    public static int count = 0;

    //修饰非静态方法简化 实则修饰this
    synchronized public void increase() {
        count++;
    }
    //修饰非静态方法完整
    public void increase() {
        synchronized (this) {   //this
            count++;
        }
    }

synchronized的特性

可重入锁  可重入锁是如何实现的?

  1. 使用一个专门的属性来记录当前是那个线程加的锁。
  2. 引入一个计数器count++来记录当前已经加了多少层锁,还需要减多少下count--才可以真正释放锁。
  3. 即从最外面一层锁出来后才释放锁,中间的 synchronized 不会释放锁。

死锁 

  • 所谓的死锁就是线程一拥有锁1,线程二拥有锁2,双方在拥有自身锁的同时尝试获取对方的锁,最终两个线程就会进入无线等待的状态,这就是死锁。相当于门上钥匙锁车里了,车钥匙锁家里了。
  • 注意在代码中的两个sychronized是嵌套关系,不是并列关系,嵌套关系是指在占用一把所得前提下,获取另一把锁(会可能出现死锁)。而并列关系则是先释放前面的锁,再获取下一把锁(不会出现死锁)。
  • 还要注意代码中的的sleep,很重要,目的是保证两个线程先拿到各自的锁。
public static void main(String[] args) {
        //创建两个锁对象
        Object locker1 = new Object();
        Object locker2 = new Object();
 
        //创建两个线程
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                try {
                            
                    //此处的sleep很重要,确保t1和t2都拿到一把锁之后,再往后执行
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName());
                synchronized (locker2) {
                    System.out.println(Thread.currentThread().getName());
                }
 
            }
        },"t1");
 
        Thread t2 = new Thread(() -> {
            synchronized (locker2) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName());
                synchronized (locker1) {
                    System.out.println(Thread.currentThread().getName());
                }
            }
        },"t2");
 
        t1.start();
        t2.start();
 
    }

运行代码,并用jconsole观察:

不难发现,两个线程都处于BLOCKED阻塞状态,想要获取的锁都被对方拥有。

死锁的成因及如何解决??

成因有四个必要条件:

  1. 互斥使用。虽然是锁的基本特性,但也不可避免的会造成死锁,即当一个线程已经拥有一把锁之后,另一个线程也想获取到此锁,就要阻塞等待。
  2. 不可抢占。也是锁的基本特性。即当一个锁已经被线程1得到之后,线程2要想再获取此锁,就只能等待线程1主动释放锁,不能强行去抢。
  3. 请求保持。代码结构,程序员写的逻辑。即一个线程尝试去获取多把锁(先拿锁1,再尝试获取锁2,获取锁2的时候,锁1并不会释放),嵌套synchronized。
  4. 循环等待/环路等待。等待的依赖关系,形成环了。

解决死锁的方法

只要破坏上述必要条件中的任意一条就行,但前两条本身就是锁的特性,破坏不了,故破坏后两条任意一条就行。

对于第三条:调整代码逻辑,避免"锁嵌套"逻辑。也得看实际需求,可能实际需求就是会有"锁嵌套"这种逻辑。所以第四条的破坏就尤为重要。

public class bbbbbbbb {
    public static void main(String[] args) {
        //创建两个锁对象
        Object locker1 = new Object();
        Object locker2 = new Object();

        //创建两个线程
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                try {

                    //此处的sleep很重要,确保t1和t2都拿到一把锁之后,再往后执行
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }

            //挪出来
            synchronized (locker2) {
                System.out.println("t1 加锁成功!");
            }

        },"t1");

        Thread t2 = new Thread(() -> {
            synchronized (locker2) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                
            }

            synchronized (locker1) {
                System.out.println("t2 加锁成功!");
            }

        },"t2");

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

    }
}

对于第四条;可以约定加锁的顺序,就可以避免循环等待。即针对锁进行编号。比如约定在再多把锁的时候,先加编号小的锁,再加编号大的锁。规定所有线程都要遵守这个规则。

面试题: 你是否了解死锁,谈谈对死锁的理解???

volatile 关键字作用及使用

  1. 保证内存可见性
  2. 禁止指令重排序

1).保证内存可见性

由于计算机运行的程序/代码,经常要访问数据,这些数据被存储在内存中。CPU在使用这个变量的时候,就会把内存中的数据先读出来,再放到CPU的寄存器中,再参与运算。(load 在上文count++的三步时讲过)。

寄存器我们通常都知道,读内存的速度远远大于读外存的速度,而读寄存器的速度却又远远快于内存。CPU在进行大部分操作时都很快,但一旦操作到读/写内存时速度就变慢了。

为了解决这个变慢问题,提高效率,此时编译器就可能会对代码做出优化,即把一些本来要度内存的操作,优化成读取寄存器了,这样就会减少读内存的操作,也就会提高整体程序的效率了。但这种优化操作并不一定是我们想要的。于是也要去解决防止编译器自动优化。方法如下。

示例多线程代码:

预期目标是改变isQuit的值,然后打印 t1 线程执行结束!

import java.util.Scanner;

public class test {
    //两个线程
    //预期目标是改变isQuit的值,然后打印 t1 线程执行结束!
    public static int isQuit = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while(isQuit == 0) {

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

        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.print("请输入 isQuit=");
            isQuit = sc.nextInt();
        });

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

结果是程序并没有停止运行。此时就是发生内存可见性问题,即编译器通过优化读取操作,把读内存优化成都寄存器了,而改变后的值在却内存中,虽然是读速度变快了,但是有可能也就不准了,这样的优化操作也无疑会使预期结果出错。

如何解决?即对该变量加上修饰符 volatile。就会禁止编译器做这种优化操作。

import java.util.Scanner;

public class test {
    //两个线程
    //预期目标是改变isQuit的值,然后打印 t1 线程执行结束!
    public  volatile static int isQuit = 0;  //加 volatile
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while(isQuit == 0) {

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

        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.print("请输入 isQuit=");
            isQuit = sc.nextInt();
        });

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

或者,不加 volatile ,给循环里加个 sleep 也行,原因是加了sleep之后,while循环的执行速度变慢了,这样load操作的开销就不大了。因此优化也就没必要进行了。故没有触发load的优化,也就没内存可见性问题了。但总的来说,编译器优不优化,拿捏不准,还是最好使用volatile更靠谱

不过这种优化前提是在一个线程中使用,另一个线程中修改isQuit的值,编译器以为没人修改isQuit,就做出了优化。如果都是在一个线程中使用并修改,就不会有BUG了。

import java.util.Scanner;

public class aaaaaaaa {
    //两个线程
    //预期目标是改变isQuit的值,然后打印 t1 线程执行结束!
    public  volatile static int isQuit = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while(isQuit == 0) {
                try {
                    Thread.sleep(1000);   //加sleep
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

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

        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.print("请输入 isQuit=");
            isQuit = sc.nextInt();
        });

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

"内存可见性"问题

  • CPU先load操作读取内存中isQuit的值到寄存器中。
  • 再通过汇编语言cmp指令比较寄存器中的值是否为0,并决定是否要进行while循环。
  • 由于这个while循环速度非常快,短时间内会进行大量的循环,也就是进行大量的load和cmp操作。此时,编译器/JVM就会发现虽然进行了折磨多次的load,但是load书来的结果都是一样的,而且load操作有非常浪费时间,可以说一次load顶得上上万次cmp操作了。于是编译器就做了一个决定。只是在第一次循环的时候,去读内存即进行load操作,后续就不去再读内存了,而是直接从寄存器中读isQuit的值,即进行只cmp操作了,这样就即使是isQuit的值改变了,也感知不到。

若对于什么是原子操作,内存可见性,指令重排序,类对象不懂的等可看气的下一篇博客。

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

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

相关文章

速通Redis基础(一):掌握Redis的字符串类型和命令

目录 字符串&#xff08;String&#xff09; 常见命令 SET GET MSET&MGET SETNX INCR INCRBY DECR DECRBY INCRBYFLOAT APPEND GETRANGE SETRANGE STRLEN Redis字符串类型命令总结 Redis&#xff08;Remote Dictionary Server&#xff09;是一个高性能的…

机器学习基础之《分类算法(8)—随机森林》

一、什么是集成学习方法 1、定义 集成学习通过建立几个模型组合的来解决单一预测问题。它的工作原理是生成多个分类器/模型&#xff0c;各自独立地学习和作出预测。这些预测最后结合成组合预测&#xff0c;因此优于任何一个单分类的做出预测 谚语&#xff1a;三个臭皮匠顶个诸…

论文悦读(6)——PM操作系统之TreeSLS单级存储

TreeSLS &#xff08;SOSP23&#xff09; 1. 背景 (Background)1.1 内存-存储二级架构 (1, 2.1)1.2 单级架构 (2.2)1.3 总结 2. 动机 (Motivation)2.1 现有SLS性能低下 (2.3)2.2 现有SLS难以支持External Synchrony (1, 2.4)2.3 高速PM出现为SLS带来新的机遇与挑战 (1, 2.5) 3.…

CDN是什么?(网络零基础入门篇)

1.CDN的全称 是 Content Delivery Network&#xff0c;即内容分发网络。 其基本思路是尽可能避开互联网上有可能影响数据传输速度和稳定性的瓶颈和环节&#xff0c;使内容传输得更快、更稳定。 通过在网络各处放置节点服务器所构成的在现有的互联网基础之上的一层智能虚拟网…

探索古彝文的秘密,AI实现古籍传承

陈老老老板&#x1f934; &#x1f9d9;‍♂️本文专栏&#xff1a;生活&#xff08;主要讲一下自己生活相关的内容&#xff09;生活就像海洋,只有意志坚强的人,才能到达彼岸。 &#x1f9d9;‍♂️本文简述&#xff1a;最新资讯&#xff0c;合合信息扫描全能王实现古彝文识别&…

sigmoid和softmax函数有什么区别

Sigmoid函数和Softmax函数都是常用的激活函数&#xff0c;但它们的主要区别在于应用场景和输出结果的性质。 Sigmoid函数&#xff08;也称为 Logistic函数&#xff09;&#xff1a; Sigmoid函数将输入值映射到0到1之间的连续实数范围&#xff0c;通常用于二元分类问题。 Si…

U-Net神经网络简明教程

推荐&#xff1a;用 NSDT编辑器 快速搭建可编程3D场景 在我们进入技术细节之前&#xff0c;考虑一下&#xff0c;为什么要使用 U-Net&#xff1f; 你可能在搜索语义分割时遇到过这一点&#xff0c;我很快就会写一篇博客&#xff0c;然后再看看这个。 制作此架构的初衷是用于生物…

Spring MVC程序开发(JavaEE进阶系列3)

目录 前言&#xff1a; 1.什么是Spring MVC 1.1MVC的定义 1.2MVC和Spring MVC的关系 1.3为什么要学习Spring MVC 2.Spring MVC项目的创建 3.Spring MVC框架的使用 3.1连接的功能 3.1.1RequestMapping 3.1.2GetMapping 3.1.3PostMapping 3.2获取参数的功能 3.2.1获…

罗彻斯特大学探讨ChatGPT等人工智能将如何影响高等教育

人工智能聊天机器人ChatGPT持续引起互联网用户的热议&#xff0c;它能够回答关于各个领域的问题&#xff0c;创作歌曲、食谱&#xff0c;起草电子邮件等等。罗切斯特的教职员工和管理人员就他们如何处理 ChatGPT 以及它如何影响未来的教学和学习提出了他们的想法。 “让这项技…

基于Java的新能源汽车在线租赁平台设计与实现(源码+lw+ppt+部署文档+视频讲解等)

文章目录 前言具体实现截图论文参考详细视频演示为什么选择我自己的网站自己的小程序&#xff08;小蔡coding&#xff09;有保障的售后福利 代码参考源码获取 前言 &#x1f497;博主介绍&#xff1a;✌全网粉丝10W,CSDN特邀作者、博客专家、CSDN新星计划导师、全栈领域优质创作…

基于ChatGPT快速入门体验NLP词云

基于ChatGPT快速入门体验NLP词云 一、什么是自然语言处理二、自然语言处理和词云的关系三、Python环境准备四、基于ChatGpt制作词云4.1 ChatGPT生成初级词云代码4.2 ChatGPT生成进阶词云代码4.3 基于ChatGPT解决代码问题4.4 基于ChatGPT建议修改问题代码 一、什么是自然语言处理…

c语言进阶部分详解(详细解析字符串常用函数,并进行模拟实现)

前段时间也是把指针较为详细系统的讲解完毕&#xff0c;接下来介绍一个全新的知识点&#xff0c;就是字符函数和字符串函数 前几期文章可进我主页观看&#xff1a;总之就是非常唔姆_Matlab,经验分享,c语言题目分享-CSDN博客 想要源代码可以去我的github看看&#xff1a;Neros…

教你拥有一个自己的QQ机器人!0基础超详细保姆级教学!基于NoneBot2 Windows端搭建QQ机器人

0.序言 原文链接&#xff1a;教你本地化部署一个QQ机器人本教程主要面向Windows系统用户教程从0开始全程详细指导&#xff0c;0基础萌新请放心食用&#x1f355;如果你遇到了问题&#xff0c;请仔细检查是否哪一步有遗漏。如果你确定自己的操作没问题&#xff0c;可以到原文链…

苹果macbook电脑磁盘满了怎么清理内存

如果你是苹果macbook用户&#xff0c;可能会面临一个常见但又令人头疼的问题——磁盘空间不足。这不仅影响了你的电脑性能&#xff0c;还可能导致新的软件无法安装&#xff0c;甚至影响到文件的保存。好消息是&#xff0c;有多种方法可以有效地解决这个问题。下面就一起来看看吧…

【100个 Unity实用技能】☀️ | UGUI Text中加入超链接文本,可直接点击跳转

Unity 小科普 老规矩&#xff0c;先介绍一下 Unity 的科普小知识&#xff1a; Unity是 实时3D互动内容创作和运营平台 。包括游戏开发、美术、建筑、汽车设计、影视在内的所有创作者&#xff0c;借助 Unity 将创意变成现实。Unity 平台提供一整套完善的软件解决方案&#xff…

unity脚本_Input鼠标键盘 c#

获取鼠标坐标 检测鼠标输入 如果在运行游戏场景中点击一下鼠标左键 检测鼠标抬起 选中即可 检测键盘按下 当前屏幕分辨率 注意&#xff1a;获取的是显示器的分辨率 获取设备屏幕宽高 屏幕休眠模式 窗口/全屏模式 移动设备屏幕转向

【C语言】字符函数和字符串函数(1)

#国庆发生的那些事儿# 大家好&#xff0c;我是苏貝&#xff0c;本篇博客带大家了解字符函数和字符串函数&#xff0c;如果你觉得我写的还不错的话&#xff0c;可以给我一个赞&#x1f44d;吗&#xff0c;感谢❤️ 目录 1.本章重点2. strlen2.1函数介绍2.2 模拟实现 3. strcpy3…

第八章 排序 六、简单选择排序

目录 一、算法思想 二、例子 1、我们有以下序列要排序 2、首先从左往右扫描&#xff0c;在其中找到最小的一个数&#xff0c;让它与第一个数互换位置 3、此次扫描完成后&#xff0c;我们取新的子序列&#xff0c;并再次从左往右扫描&#xff0c;在其中找到最小的一个数&…

makeMakefile

一、 什么是make&Makefile &#xff1f; ①make 是一条命令,makefile是一个文件,配合使用,通过依赖关系和依赖方法达到我们形成可执行程序的目的 ②makefile好处就是可以进行 自动化编译 ” &#xff0c;极大的提高软件开发的效率,一旦写好&#xff0c;只需要一个 make 命令…

推荐一款在线的JDK17中文文档

spring6.0及springboot3.0最低版本要求都是java17&#xff0c;换上java17是迟早的事&#xff0c;所以虽然我现在做的是java8&#xff0c;但是后面我想从java8直接飞升到java17&#xff0c;先做个准备&#xff0c;找到一个JDK17的中文文档&#xff0c;是在线的&#xff0c;地址&…