【JavaEE精炼宝库】多线程(4)深度理解死锁、内存可见性、volatile关键字、wait、notify

news2024/11/24 19:48:54

目录

一、死锁

1.1 出现死锁的常见场景:

1.2 产生死锁的后果:

1.3 如何避免死锁:

二、内存可见性

2.1 由内存可见性产生的经典案例:

2.2 volatile 关键字:

2.2.1 volatile 用法:

2.2.2 volatile 不保证原子性:

2.2.3 volatile 作用总结:

三、wait 和 notify

3.1 wait 详解:

3.2 notify 和 notifyAll:

3.2.1 notify:

3.2.2 notifyAll:

3.3 面试题:wait 和 sleep 的区别:


在上一篇文章,我们了解了什么是线程安全,分析了产生线程不安全的原因。今天我们就要深度刨析一下线程不安全的经典案例:死锁和内存可见性引起的线程不安全问题。

一、死锁

1.1 出现死锁的常见场景:

• 场景一:

锁是不可重入锁(synchronized 是可重入锁),并且一个线程针对一个锁对象,连续加锁两次。

• 场景二:

两个线程两把锁。先让两个线程分别拿到一把锁,然后再去尝试获取对方的锁,这时就出现了死锁的情况。

• 场景三:

多个线程,多把锁。随着线程和锁的数目的增加,情况就会变得更加复杂,死锁就更容易出现。下面就是一个经典的死锁场景:哲学家就餐(除非吃到面条,否则不会放下筷子)。

 如果出现极端的情况,同一时刻所有的哲学家都拿起左边的筷子,这时就会出现死锁。

1.2 产生死锁的后果:

死锁是非常严重的问题。一个进程中线程的个数是有限的,死锁会使线程被卡住,没法继续工作。更加严重的是,死锁这种 bug 往往都是概率性出现(未知才是最可怕的)。测试的时候,怎么测试都没事,一旦发布,就出现了问题。更加要命的是发布也没有问题,等到夜深人静的时候,大家都睡着的时候,突然给你来点问题,直接带走年终奖😭。

1.3 如何避免死锁:

要想避免死锁,我们就要从产生死锁的原因入手。

教科书上经典的产生死锁的四个必要条件(下面给出的四个条件,友友们一定要背下来,面试的经典问题)。

1. 锁具有互斥性:

这时锁的基本特点,一个线程拿到锁之后,其他线程就得阻塞等待。

2. 锁具有不可抢占性(不可剥夺性):

一个线程拿到锁之后,除非他自己主动释放锁,否则谁也抢不走。

3. 请求和保持:

一个线程拿到一把锁之后,不释放这个锁的前提下,再尝试获取其他锁。

4. 循环等待。

多个线程获取多个锁的过程中,出现了循环等待,A 等待 B ,B 又等待 A。

在任何一个死锁的场景,都必须同时具备上述四点,只要缺少一个,都不会构成死锁。观察上面的四个条件不难发现条件 1 和条件 2 是锁的基本特性,这个我们无法改变,观察到条件 3 和条件 4 都是代码结构的问题,所以我们就从条件 3,4 入手。

• 针对条件 3:

不要让锁嵌套获取即可。如果有些场景必须要嵌套获取锁,那么就破除循环等待(条件 4 ),即使出现嵌套,也不会出现死锁。

• 针对条件 4:

当代码中,确实需要用到多个线程获取多把锁,一定要记得约定好加锁的顺序(每个线程都必须要先获取 A 锁,再获取 B 锁,再.......),就可以有效避免死锁了。

二、内存可见性

2.1 由内存可见性产生的经典案例:

请友友们观察一下下面这段代码,可以粘贴到自己的编译器上跑一下,看看是否符合你的预期。

public class demo1 {
    static int count = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while(count == 0){

            }
            System.out.println("t1.end");
        });
        Thread t2 = new Thread(() -> {
            Scanner in = new Scanner(System.in);
            System.out.println("请输入一个数字:");
            count = in.nextInt();
        });
        t1.start();
        t2.start();
    }
}

因为输入数据存在 IO 操作(很慢)所以一定能保证在我们输入数据的时候,t1 线程已经开始执行了。

正常来说,我们输入一个非 0 的数字后,t1 线程里面就会停止循环。但是产生的结果如下:

循环并没有退出,由于是前台线程,所以程序不能够结束。

上述问题产生的原因就是因为内存可见性

• 案例解析:

上面的案例产生的问题是由于编译器优化 / JVM 优化产生的问题。不是说优化不好,而是 JVM 在这种情况下的优化太激进了。为什么会产生这么激进的优化呢?

我们站在指令的角度来理解有两个方面:

1. 在while 循环体中,每次条件判断的时候,分为两个步骤:1. load:从内存读取数据到 cpu 寄存器。2. cmp:比较,条件成立就会继续执行。 当前循环的旋转速度很快,短时间内出现大量的 load 和 cmp 反复执行的效果,由于 load 执行消耗的时间比 cmp 消耗的时间多很多(量级是几千倍,上万倍)。

2. JVM 发现每次 load 执行的结果是一样的(在 t2 修改之前)。

于是 JVM 就把上述的 load 操作优化掉了,只有第一次是真正的进行 load 后续的 load 就直接读取刚才 load 在寄存器中的值,也就是说不会去内存中去读取值了,这时即使内存中的值已经修改,但是还是 load 不到,这就是我们的线程可见性问题。

其实在这里加上打印,程序就符合我们的预期了。

这是为什么呢?答:因为此时 IO 操作才是程序运行时间的大头,优化 load 就没有必要了,因为程序的瓶颈不是 load 。此外,IO 操作是不能被优化掉的,被优化的前提是反复执行的结果是相同的,IO 操作注定是反复执行的结果是不相同的。

• 小结:

上述问题的本质还是编译器优化引起的,优化掉 load 操作之后,使 t2 线程的修改,没有被 t1 线程感知到,这就是 ”内存可见性“ 问题。

2.2 volatile 关键字:

2.2.1 volatile 用法:

编译器到底啥时候优化这也是个 ”玄学问题“。我们作为程序员显然不希望看到这样的代码出现,因此 Java 就引入的 volatile 关键字,就可以解决内存可见性引起的问题。volatile 修饰的变量,能够保证 "内存可见性"。

代码在写入 volatile 修饰的变量的时候:

• 改变线程工作内存中 volatile 变量副本的值。

• 将改变后的副本的值从工作内存刷新到主内存。

代码在读取 volatile 修饰的变量的时候:

• 从主内存中读取 volatile 变量的最新值到线程的工作内存中。

• 从工作内存中读取 volatile 变量的副本。

前面我们讨论内存可见性时说了,直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存),速度非常快,但是可能出现数据不一致的情况。加上 volatile ,强制读写内存。速度是慢了,但是数据变的更准确了。

使用案例演示:

public class demo2 {
    public volatile static int count = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            System.out.println("t start");
            while(count == 0){

            }
            System.out.println("t end");
        });
        Thread t2 = new Thread(() -> {
            Scanner in = new Scanner(System.in);
            System.out.println("请输入一个数字:");
            count = in.nextInt();
        });
        t1.start();
        t2.start();
    }
}

案例效果:

2.2.2 volatile 不保证原子性:

volatile 和 synchronized 有着本质的区别。synchronized 能够保证原子性,volatile 保证的是内存可见性。例如下面这个案例:

public class demo3 {
    public volatile static int count = 0;
    public static void main(String[] args) {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            for(int i = 0;i < 50000;i++){
//                synchronized(locker){
                    count++;
//                }

            }
        });
        Thread t2 = new Thread(() -> {
            for(int i = 0;i < 50000;i++){
//                synchronized(locker){
                    count++;
//                }
            }
        });
        t1.start();
        t2.start();
        try {
            t1.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        try {
            t2.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(count);
    }
}

案例演示效果如下:

可以看到最终的结果还是不符合我们的预期,所以 volatile 不保证原子性。 

2.2.3 volatile 作用总结:

volatile 关键字的作用主要有如下两个:

• 保证内存可见性:

基于屏障指令实现,即当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。

• 保证有序性:

禁止指令重排序。编译时 JVM 编译器遵循内存屏障的约束,运行时靠屏障指令组织指令顺序。

注意:volatile 不能保证原子性。

三、wait 和 notify

由于线程之间是抢占式执行的,因此线程之间执行的先后顺序难以预知。但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序。就好像足球队一样,线程 1 要先传球,线程 2 才能射门。

针对随即调度,我们程序员也是有手段干预的,即通过 “等待” 的方式,能够让线程一定程度的按照我们预期的顺序来执行。无法主动让某个线程被调度,但是可以主动让某个线程等待(给别的线程机会)。

完成这个协调工作,主要涉及到三个方法:

注意:  wait,notify,notifyAll 都是 Object 类的方法(意味着所有类都可以)。

3.1 wait 详解:

wait 做的事:

• 使当前执行代码的线程进行等待(把线程放到等待队列中)。

• 释放当前的锁(wait 必须要放在锁的代码块里面使用)。

• 满足一定条件时被唤醒,重新尝试获取这个锁。

wait 结束等待的条件:

• 其他线程调用该对象的 notify 方法。

• wait 等待时间超时(wait 方法提供一个带有 timeout 参数的版本,来指定等待时间)。

• 其他线程调用该等待线程的 interrupted 方法,导致 wait 抛出 InterruptedException 异常。

注意:wait 和 sleep 一样会被线程的 interrupt 打断,wait 也会自动清空标志位。

案例演示如下:

public class demo1 {
    public static void main(String[] args) {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            System.out.println("开始");
            try {
                synchronized(locker){
                    locker.wait();
                }

            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("结束");

        });
        t1.start();
    }
}

案例演示效果如下:

可以发现线程成功被停止了。 

注意:

wait 要搭配 synchronized 来使用。脱离 synchronized 使用 wait 会直接抛出异常。例如我们将上面的那段代码进行修改,将 locker 脱离锁,产生的情况如下:

这样在执行到 object.wait() 之后就一直等待下去,那么程序肯定不能⼀直这么等待下去了。这个时候就需要使用到了另外⼀个方法唤醒的方法 notify()。

3.2 notify 和 notifyAll:

3.2.1 notify:

notify 方法是唤醒等待的线程。具体作用如下:

• 方法 notify() 也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知 notify,并使它们重新获取该对象的对象锁。

• 如果有多个线程等待,则有线程调度器随机挑选出⼀个呈 wait 状态的线程。(并没有 "先来后到") 。

• 在 notify() 方法后,当前线程不会马上释放该对象锁,要等到执行 notify() 方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。

案例演示如下:

public class demo2 {
    public static void main(String[] args) {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            System.out.println("开始等待");
            synchronized(locker){
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("结束等待");
        });
        Thread t2 = new Thread(() -> {
            Scanner in = new Scanner(System.in);
            System.out.print("输入内容开始通知:");
            in.next();
            synchronized(locker){
                locker.notify();
                System.out.println("通知结束");
            }
        });
        t1.start();
        t2.start();
    }
}

案例演示效果如下:

3.2.2 notifyAll:

notify 只能随机唤醒一个由 wait 导致的等待线程,例如:

import java.util.*;
public class demo3 {
    public static void main(String[] args) {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            System.out.println("t1:开始等待");
            synchronized(locker){
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("t1:结束等待");
        });
        Thread t2 = new Thread(() -> {
            System.out.println("t2:开始等待");
            synchronized(locker){
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("t2:结束等待");
        });
        Thread t3 = new Thread(() -> {
            Scanner in = new Scanner(System.in);
            System.out.print("输入内容开始通知:");
            in.next();
            synchronized(locker){
                locker.notify();
                System.out.println("通知结束");
            }
        });
        t1.start();
        t2.start();
        t3.start();
    }
}

最终跑出的结果如下:

可以清楚的看到只有一个线程被唤醒了。

因此 Java 引入 notifyAll 来一次性唤醒全部。我们就直接将上述代码稍加修改即可。

import java.util.*;
public class demo3 {
    public static void main(String[] args) {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            System.out.println("t1:开始等待");
            synchronized(locker){
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("t1:结束等待");
        });
        Thread t2 = new Thread(() -> {
            System.out.println("t2:开始等待");
            synchronized(locker){
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("t2:结束等待");
        });
        Thread t3 = new Thread(() -> {
            Scanner in = new Scanner(System.in);
            System.out.print("输入内容开始通知:");
            in.next();
            synchronized(locker){
                locker.notifyAll();//修改处
                System.out.println("通知结束");
            }
        });
        t1.start();
        t2.start();
        t3.start();
    }
}

案例的演示效果如下:

可以看到我们所有等待的线程都被唤醒了。

注意:虽然是同时唤醒 2 个线程,但是这 2 个线程需要竞争锁,所以并不是同时执行,而仍然是有先有后的执行。

3.3 面试题:wait 和 sleep 的区别:

• wait:用于线程之间的通信。

• sleep:让线程阻塞一段时间。

相同点:是都可以让线程放弃执行一段时间。

大体的区别分为如下 3 点:

(1)wait 需要搭配 synchronized 使用,而 sleep 不需要。

(2)wait 是 Object 的方法,sleep 是 Thread 的静态方法。

(3)(从状态来) wait 被调用后,当前线程进入 waiting 状态并释放锁,并可以通过 notify 和 notifyAll 方法进行唤醒。sleep 被调用后当前线程进入 TIMED_WAITING 状态,不涉及锁相关的操作。

结语:

其实写博客不仅仅是为了教大家,同时这也有利于我巩固知识点,和做一个学习的总结,由于作者水平有限,对文章有任何问题还请指出,非常感谢。如果大家有所收获的话还请不要吝啬你们的点赞收藏和关注,这可以激励我写出更加优秀的文章。

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

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

相关文章

2024 年最新商家转账到零钱功能申请问题集中解答

鉴于诸多商户在申请商家转账到零钱时受到过时、错误经验文章的误导&#xff0c;基于我们数千次成功开通商家转账到零钱功能的丰富经验&#xff0c;特整理此篇文章&#xff0c;以期对新商户开通微信支付的商家转账到零钱功能提供有益帮助。以下将针对商家转账到零钱功能申请前、…

阿里云sls 采集日志安装记录

参考阿里云给的安装文档 阿里云安装Logtail组件 注意这里&#xff0c;选择地域&#xff0c;是中国地域选中国&#xff0c;海外选海外即可 按照文档继续下去 修改配置文件./alibaba-cloud-log-all/values.yaml 所有的操作完成后&#xff0c;去控制台配置 以上操作的前提是…

多目标应用:MOHHO多目标哈里斯鹰优化算法求解无人机三维路径规划(MATLAB代码)

详细介绍 多目标应用&#xff1a;MOHHO多目标哈里斯鹰优化算法求解无人机三维路径规划&#xff08;MATLAB代码&#xff09;-CSDN博客 一次运行结果 完整MATLAB代码

工会考试基础知识题库分享(附答案解析)

单选题 1、国家机关在组织起草或者修改直接涉及职工切身利益的法律、法规、规章时&#xff0c;( )工会意见。 A、可以听取 B、应当听取 C、必须听取 D、应当吸收 [答案]B 【解析】国家机关在组织起草或者修改直接涉及职工自身利益的法律、法规、规章时&#xff0c;应当听取工…

正宇软件助力青岛打造智慧政协平台,引领新时代政协工作创新

在当前数字化、智能化浪潮的推动下&#xff0c;青岛市政协紧跟时代步伐&#xff0c;以“百舸争流奋楫先”的精神&#xff0c;不断开拓创新&#xff0c;推动政协工作高质量发展。5月30日&#xff0c;人民政协报报道了青岛市政协在推动高质量发展、加强思想政治引领、凝聚合作共识…

电赛报告书写

一、总体要求 &#xff08;1&#xff09;摘要&#xff1a;一页&#xff0c;小于300字 &#xff08;2&#xff09;正文&#xff1a;不超过8页 &#xff08;3&#xff09;附录&#xff1a;可以没有&#xff0c;但是不能超过2页 二、摘要书写 摘要要小于等于300字&#xff0c…

ARM公司发展历程

Arm从1990年成立前开始&#xff0c;历经漫长岁月树立各项公司里程碑及产品成就&#xff0c;一步步成为全球最普及的运算平台。 添加图片注释&#xff0c;不超过 140 字&#xff08;可选&#xff09; Acorn 时期 1978年&#xff0c;Chris Curry和Hermann Hauser共同创立了Acorn…

如何使用SeaFile文件共享服务器结合内网穿透将家中电脑变成个人云盘

文章目录 1. 前言2. SeaFile云盘设置2.1 Owncould的安装环境设置2.2 SeaFile下载安装2.3 SeaFile的配置 3. cpolar内网穿透3.1 Cpolar下载安装3.2 Cpolar的注册3.3 Cpolar云端设置3.4 Cpolar本地设置 4.公网访问测试5.结语 1. 前言 本文主要为大家介绍&#xff0c;如何使用两个…

普通人如何找到合适的创业方向

作为普通人创业&#xff0c;试错的成本是很高的&#xff0c;哪怕是低成本创业&#xff0c;你起码也得花费大量的时间&#xff0c;所以&#xff0c;在方向的选择上&#xff0c;我们需要谨慎&#xff0c;避免因为方向的选择错误&#xff0c;导致陷入进退两难的地步。 创业方向如何…

h5接入企业微信登录以及本地开发调试

首先管理员登录企业微信&#xff0c;创建第三方应用 把appID(企业微信ID) &#xff0c; AgentId&#xff08;应用id&#xff09;和Secret&#xff08;密钥&#xff09;记下来给后端同事 在应用中配置可信域名&#xff0c;例如&#xff1a; 我配置好的如下&#xff1a; 配置…

【C语言】学生管理系统:完整模拟与实现

&#x1f308;个人主页&#xff1a;是店小二呀 &#x1f308;C语言笔记专栏&#xff1a;C语言笔记 &#x1f308;C笔记专栏&#xff1a; C笔记 &#x1f308;喜欢的诗句:无人扶我青云志 我自踏雪至山巅 &#x1f525;引言 本篇文章为修改了在校期间实训报告&#xff0c;使用C…

基于python flask的旅游景点评论数据可视化大屏实现,包括数据采集

背景 在旅游行业中&#xff0c;了解游客对旅游景点的评论和评价对于景点管理和市场营销至关重要。通过采集旅游景点评论数据并进行可视化分析&#xff0c;可以帮助景点管理者更好地了解游客对景点的看法和体验&#xff0c;发现优劣势&#xff0c;优化服务和提升用户满意度。基…

【全开源】CMS内容管理系统(ThinkPHP+FastAdmin)

基于ThinkPHPFastAdmin的CMS内容管理系统&#xff0c;自定义内容模型、自定义单页、自定义表单、专题、统计报表、会员发布等 提供全部前后台无加密源代码和数据库私有化部署&#xff0c;UniAPP版本提供全部无加密UniAPP源码​ &#x1f50d; 解锁内容管理新境界&#xff1a;C…

ai智能全自动批量剪辑软件神器,让视频创作变得简单!

随着科技的飞速发展&#xff0c;人工智能技术在各个领域都取得了突破。在视频制作领域&#xff0c;AI智能全自动批量剪辑软件神器的出现&#xff0c;为视频创作者带来了前所未有的便利。接下来咱们详细介绍这款软件的特点和优势&#xff0c;以及它如何让视频创作变得更加简单。…

Wireshark抓包日常运维实用过滤

0x0 Wireshark 介绍 Wireshark 是一款功能强大的网络分析工具&#xff0c;适用于网络专业人员。它提供了出色的过滤器&#xff0c;您可以轻松放大到您认为可能存在问题的位置。过滤器的主要好处是消除定位流量&#xff0c;并缩小要查找的数据类型。 0x1 根据源 IP 地址过滤主…

在windows操作系统上安装MariaDB

最近收到关于数据库在哪里看的评论&#xff0c;所以就一不做二不休&#xff0c;把安装数据库的步骤写一篇文章吧。 这篇文章介绍如何在windows上完成MariaDB-10.6.5版本的安装&#xff0c;对应MySQL-8.x版本。 第一步&#xff1a;下载安装包 通过以下网盘链接下载MariaDB-10.6…

RabbitMQ消息的发布确认机制详解

RabbitMQ发布确认机制确保消息从生产者成功传输到交换机和队列&#xff0c;提高系统可靠性。在Spring Boot项目中&#xff0c;通过配置publisher-confirm-type和publisher-returns&#xff0c;启用发布确认和消息返回机制。配置RabbitTemplate的确认回调和返回回调&#xff0c;…

Java面试——中间件

OpenFeign 1、openFeign是一个HTTP客户端&#xff0c;它融合了springmvc的注解&#xff0c;使之可以用REST风格的映射来请求转发。 2、可以把openFegin理解为是controller层或是service层。可以取代springmvc控制层作为请求映射&#xff0c;亦或是作为service层处理逻辑&#…

镜头效果技术在AI绘画中的革新作用

随着人工智能技术的飞速发展&#xff0c;AI绘画已经成为艺术与科技交汇的前沿领域。在这一领域中&#xff0c;镜头效果技术的应用不仅为艺术家和设计师们提供了全新的创作工具&#xff0c;更在艺术创作中扮演了革命性的角色。本文将深入探讨镜头效果技术在AI绘画中的应用&#…

【uni-app】uniapp页面与组件生命周期介绍

uniapp应用开发过程中经常会在不同的时机触发一些事件&#xff0c;这篇文章主要是总结一下uniapp常用的一些生命周期钩子。 不同的环境运行可能有差异,下图为微信小程序执行图示 1. 应用生命周期 函数名说明onLaunch当uni-app 初始化完成时触发&#xff08;全局只触发一次&…