首页 > 系统相关 >多线程(五):死锁&内存可见性问题

多线程(五):死锁&内存可见性问题

时间:2024-10-18 09:18:43浏览次数:3  
标签:重入 加锁 t1 死锁 线程 内存 多线程

目录

1. synchronized --- 监视器锁 monitor lock

2. 死锁

2.1 死锁 --- 情况1

2.1.1 可重入

2.1.2 如何实现一个可重入锁 [面试题]

2.2 死锁 --- 情况2

2.2.1 BLOCKED

2.2.2 手写死锁代码 [经典面试题]

2.3 死锁 --- 情况3

3. 避免死锁的出现

3.1 构成死锁的四个必要条件 ★★★

3.2 破除循环等待

4. 死锁小结 

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

6. 内存可见性问题 --- 线程安全问题的产生原因之一

6.1 引入内存可见性问题

6.2  编译器优化

6.3 volatile关键字


1. synchronized --- 监视器锁 monitor lock

synchronized又称为监视器锁.

监视器锁(monitor lock), 这是JVM中采用的一个术语, 使用锁的过程中如果抛出一些异常, 就会看到 监视器锁 这样的报错信息. 

2. 死锁

2.1 死锁 --- 情况1

同一个线程, 对同一把锁, 连续加锁两次. 是死锁的第一种表现形式~

2.1.1 可重入

synchronized还具备可重入的特性. 要了解什么是可重入, 我们要先了解死锁~

对于死锁, 我们先来看以下代码:

  • 第一次加锁时, 锁没有被使用, 能成功加锁
  • 第二次加锁时, 锁已经被占用, 就会发生阻塞等待. 必须等第一次的加锁被释放,才能解除阻塞. 而想要释放第一次加锁, 就必须往下执行, 可是程序又在第二次加锁时被阻塞, 导致线程被卡住锁死("死锁")

 这样的问题, 就称为"死锁(dead lock)".(以上问题只是死锁的一种)

"死锁"是一个非常严重的bug, 当代码执行到这里后, 会导致线程被卡住锁死.

为了解决上述问题, Java的 synchronized 中引入了 可重入 的概念, 

可重入: 当某一个线程对锁(任意对象)加锁成功后, 后续该线程再次对同一把锁进行加锁操作时, 不会发生阻塞等待, 而是继续往下执行(因为这个锁 就是被这个线程持有的).

但是, 如果其他线程对这个对象进行加锁时, 就会正常阻塞. 

注意: 可重入锁, 只是针对 同一个线程, 同一把锁 的情况. 当同一个线程对同一把锁重复加锁时, 能避免出现自己把自己锁死的情况.

可重入锁只能解决 同一个线程, 同一把锁 情况下的死锁, 也就是说Java也会出现其他死锁的情况.

2.1.2 如何实现一个可重入锁 [面试题]

要想实现可重入锁, 我们需要先知道可重入锁的底层原理:

可重入锁的实现原理, 关键在于, 让锁对象, 内部保存, 当前是哪一个线程持有的这把锁. 后续线程对这个锁进行加锁操作时, 判断锁持有者的线程和当前要加锁的线程是否为同一个. 若是同一个线程, 不必再次加锁, 也不会阻塞等待; 若不是同一个线程, 则阻塞等待.

 当同一个线程, 多次对一把锁进行加锁操作时, 只在第一次(最外层的{ )加锁时真正加锁, 在最后一次(最外层的} )解锁时真正解锁.

如何判断哪个是左右花括号是最外层的呢?

可以引入一个计数器, 记录{ 和 } 的数量, 每遇到一个 { 时计数器++, 当第一个遇到 { 时加锁. 每遇到一个 } 时, 计数器--, 当计数器为0时, 解锁.

所以, 我们可以这样设计可重入锁:

  1. 在锁内部记录当前是哪个线程持有的锁. 后续每次加锁时, 都判定加锁的线程是否为持有锁的线程.
  2. 通过计数器, 记录线程中加锁的次数, 从而确定何时真正进行解锁.

2.2 死锁 --- 情况2

两个线程, 两把锁, 每个线程获取到一把锁后, 又尝试去获取对方的锁(即锁嵌套, "请求和保持").在这种情况下, 也会构成死锁.

对于这种情况下的死锁, 这里也举个例子:

有的人喜欢吃饺子蘸醋, 有的人喜欢吃饺子蘸酱油. 过年时吃饺子, 饺子端上来后, 我拿起了桌子上的醋, 我妹妹拿起了桌子上的酱油, 我对妹妹说: "你把酱油给我!" 妹妹对我说:"凭啥, 你把醋给我!" 我们两个人互不相让, 就会构成死锁.

又或者说: 门钥匙锁车里了, 车钥匙锁家里了. 这也构成了一个死锁~

注意, 这种情况下的死锁, 必须是拿到第一把锁后, 再去拿第二把锁(不能释放第一把锁).

注意:

每个线程拿到第一把锁后进行sleep休眠操作的目的是: 确保t1拿到locker1, t2拿到locker2.

如果不加sleep, t1可能一口气拿到了locker1和locker2;  t2也可能一口气拿到了locker2和locker1. 这样就构不成死锁了~

2.2.1 BLOCKED

线程因为竞争锁的缘故而发生阻塞, 这种状态就为BLCOKED:

2.2.2 手写死锁代码 [经典面试题]

对于C/C++方向来说, 直接一个线程加锁两次就OK.

可是对于我们Java程序员, synchronized具有可重入的特性, 所以我们就得通过两个线程, 两把锁, 精确控制好加锁的顺序, 以达到死锁效果.

public class Demo16 {
    public static void main(String[] args) {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("t1 的第一把锁");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2) {
                    System.out.println("t1 尝试获取 t2 的锁");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (locker2) {
                System.out.println("t2 的第一把锁");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker1) {
                    System.out.println("t2 尝试获取 t1 的锁");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

2.3 死锁 --- 情况3

N个线程M把锁, 在极端情况下会出现死锁.

对于这种情况下的死锁, 这里以一个经典模型来举例 --- 哲学家就餐问题.

一张桌子, 桌子上有一碗面条, 桌子周围坐着5个哲学家, 每个哲学家之间有一支筷子.

哲学家会随机触发两种状态:1. 思考人生(放下筷子, 思考) 2. 吃面条(拿起两支筷子)

5个哲学家相等于5个线程, 5支筷子相当于5把锁.

在大多数情况下, 哲学家们可以互不影响, 很好的生活. 但是在一些极端的情况下, 可能导致哲学家们都吃不到面条, 构成死锁.

比如, 同一时刻,每个哲学家都想吃面条, 他们都拿起左手边的筷子, 此时, 每个线程都没有右手边的筷子, 他们又不肯放下左手边的筷子, 每个人都一直等待, 导致每一个人都吃不了面条, 导致死锁发生.

虽然是在极端的情况下才会出现死锁, 但对于我们服务器开发, 服务器每天的访问量是巨大的, 即使发生死锁的概率极小, 也会产生很大的影响.

用温家宝总理的话就是:‌“多么小的问题,乘以13亿,都会变得很大”


3. 避免死锁的出现

死锁是一个很严重的bug, 我们要想避免死锁的出现, 就需要先去了解死锁是怎样构成的.

3.1 构成死锁的四个必要条件 ★★★

  • 1. 锁是 互斥/排他 的. 线程1拿到锁(加锁)后, 线程2再想尝试获取锁, 就必须要阻塞等待.

这是锁的基本特性. 我们无法修改锁的特性来去避免死锁.

  • 2. 锁是不可抢占的. 线程1拿到锁后, 线程2再想尝试获取锁, 就必须要等待线程1将锁释放, 而不能直至把锁强过来.

这同样也是锁的基本特性. 我们无法修改.

  • 3. 请求和保持. 即线程1拿到锁1后, 不释放锁1的前提下, 再去获取锁2.

构成死锁的这项原因, 我们是可以通过修改代码来避免的. 就如上文所说的哲学家就餐问题, 如果哲学家中有人放下左手的筷子, 就可以避免死锁.

但是这种解决死锁的方法, 不够通用, 有些情况下, 确实是需要通过多个锁嵌套来进行安全的保障(synchronized的嵌套是很难避免的....)

  • 4. 循环等待. 多个线程间, 多把锁之间的等待过程, 构成了"循环". 例如: A等待B, B等待C, C等待A(上文的 吃饺子问题, 哲学家就餐问题)

我们可以通过破除 循环等待 来避免死锁的出现.

3.2 破除循环等待

通过约定好加锁的顺序, 来破除循环等待.

还是以刚才哲学家就餐的模型为例. 

如果给每一根筷子编号, 规定哲学家(线程)先获取编号小的筷子(锁), 再获取编号大的筷子(锁), 这样以来, 每个人都可以吃到面条, 就可以破除循环等待, 避免出现死锁.

同样, 在锁嵌套导致出现死锁时, 也可以规定先获取编号小的锁, 再获取编号大的锁, 以此避免死锁的出现:


4. 死锁小结 

死锁小结:

1. 构成死锁的场景

a) 一个线程一把锁, 重复获取 --> 可重入锁

b) 两个线程两把锁, 尝试获取对方锁 --> 代码

c) N个线程M把锁 --> 哲学家就餐

2.  构成死锁的4个必要条件

a) 锁的互斥

b) 锁不可抢占

c) 请求和保持

d) 循环等待

3. 破除死锁

破除 c) --> 将嵌套锁改成并列锁(不通用)

破除 d) --> 规定加锁的顺序


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

Java 标准库中很多都是线程不安全的, 下列结合类中没有进行任何加锁的限制:

  1. ArrayList
  2. LinkedList
  3. HashMap
  4. TreeMap
  5. HashSet
  6. TreeSet
  7. StringBuilder

也有线程安全类, 在关键方法上加了synchronized来修饰:

  1. Vector (不推荐使用)
  2. HashTable (不推荐使用)
  3. ConcurrentHashMap(HashTable 的高度优化版本)
  4. StringBuffer 

有的类虽然是线程安全的, 但是并不推荐使用(前两个).

因为加锁并不是没有代价的, 加上锁后, 意味着会发生锁冲突(锁竞争), 一旦发生锁竞争就会阻塞等待离开cpu的调度, 而再被调度回来需要多长时间就不得而知.

加锁一个是等价交换的买卖, 就是使用 效率换取安全.

综上, 加锁意味着 执行效率将大打折扣. 所以加锁时一定要思考清楚, 不要乱加锁.

还有一类, 虽然没有加锁操作, 但是不涉及修改, 依然是线程安全的: 

  • String 

6. 内存可见性问题 --- 线程安全问题的产生原因之一

在上篇博客中, 给大家提到了 内存可见性问题是造成线程安全问题的原因之一. 

这篇博客, 就来给大家详细分析一下 内存可见性问题.

6.1 引入内存可见性问题

首先大家来看以下代码:

当t2线程中修改了flag值后, t1线程依旧继续执行, 说明t1线程没有读取到t2线程所修改的值:

很明显, 上述代码也是一个有线程安全问题的代码, 而这个bug的产生, 就是因为 内存可见性 问题引起的. 

内存可见性问题: 一个线程读取值, 一个线程修改值, 修改线程修改的值, 没有被读取线程读到.

6.2  编译器优化

为了提高程序效率, 研发JDK的大佬们就对 编译器/JVM 进行了这样的设置, 使其能够对我们程序员写的代码自动进行优化, 使程序的效率提高. 要知道程序员的水平是参差不齐的, 有的程序员写出的代码很长很冗余, 那JVM 就会在保证代码原有逻辑不变的基础上, 进行调整, 使程序的效率提高.

虽然声称是优化操作, 但仍避免不了错误的出现, 尤其是在多线程程序中, 编译器的判断可能会失误, 使得优化后的代码, 和优化前的代码, 出现偏差.

而我们上文提到的 内存可见性问题, 就是由于 编译器优化 而产生的.

cmp是纯cpu寄存器操作, 而flag值的获取, 需要从内存读(load)到寄存器上. 而load的时间开销是cmp的几千倍, 所以此时t1线程中的读内存操作是影响效率的关键因素, 因此进行了编译器优化...

t1中, 循环的速度是很快的,1s就能转个几千万上亿次, 而过了很久后(对JVM来说),用户才对flag进行修改.JVM就会认为flag的值是不变的(一直为0), 而每次都要读内存, 太慢了~于是就将 读取内存的操作, 优化成 读取寄存器的操作(将值读到寄存器中, 下次直接从寄存器中取). 所以等好几秒后, flag的值被修改后, t1线程就感知不到了.(编译器优化, 使得读操作从读内存优化成读寄存器)

而, 如果我们对代码进行微调:

发现, t1中只需加了休眠1ms的操作后, 就能正常结束.

这是因为, 循环的速度很快,1s就能转个几千万上亿次, 而加了休眠操作后, 即使是1ms, 也将大幅度降低循环的速度, 此时的休眠操作成了影响程序效率的主要因素, 相比来说, flag读取操作的开销(ns级别)将不值一提, 对读内存操作的优化也将无足轻重, 于是JVM便不再对读内存的操作进行优化了~

举个例子:

如果你家里有一个亿, 你丢了100块钱, 那你会觉得无所谓, 不差那100块~~

但是如果你全部的家当只有500块, 那你丢了100块钱, 影响是非常大的~~

所以, 加了sleep后, 就解决了该代码的线程安全问题.

但是, 使用sleep将大大影响到程序的效率, 所以针对内存可见性问题, 并不是指望sleep来解决的.

于是在语法上, 引入了volatile关键字~ 

6.2.1 JMM --- Java内存模型

JMM是Java官方文档的术语:

JMM : 

每个线程, 都有一个自己的"工作内存(work memory)", 同时这些线程又共有一个"主内存(main memory)". 当一个线程循环多次进行读取变量操作的时候, 就会把主内存中的数据, 拷贝到工作内存当中, 后续的读取操作就会直接从工作内存中读取(速度更快), 而不会从主内存中读取.

而后续另一个线程修改这个变量后(也是先修改自己的工作内存, 再拷贝到主内存中), 由于第一个线程仍然读的是自己的工作内存, 因此感知不到主内存的变化. 

我们上文讲的编译器优化, 讲的是把读内存 优化为 读编译器. 而JMM说的是线程读自己的"工作内存".

其实, 这两者本质上是一个意思. 

6.3 volatile关键字

通过volatile关键字("易变的")来修饰某个变量, 那么编译器对这个变量的读取操作, 就不会被优化成读寄存器了, JVM将会一直从内存中读取该变量的值, 一旦该变量的值被修改, 那么线程就会立刻感知.

当我们对上文的flag变量使用 volatile 修饰时, t1线程就能感知到 flag 的修改, 从而正常结束:

所以, 可以通过使用 volatile 修饰相关的变量, 从而可以解决内存可见性问题.

标签:重入,加锁,t1,死锁,线程,内存,多线程
From: https://blog.csdn.net/2401_83595513/article/details/142911975

相关文章

  • 【Linux线程】Linux多线程编程:深入理解线程互斥与同步机制
    ......
  • ThreadLocal内存泄漏怎么回事
    ThreadLocal本地线程,调用set方法往里面存的值,是每个线程互相隔离,互不影响的,每个线程都有一块存储ThreadLocal数据的地方叫做ThreadLocalMap,这个变量专门用于存储当前线程的map数据,调用ThreadLocal.set方法的时候,就是往这个ThreadLocalMap里面存储一个一个的entry,由key和value组成......
  • Java多线程编程:深入理解与实践
    java笔记,点击下载在现代软件开发中,多线程编程已成为提高程序性能和响应能力的关键技术之一。Java作为一门高级编程语言,提供了丰富的多线程支持,使得开发者能够轻松地编写并发程序。本文将深入探讨Java多线程的基本概念、实现方式以及最佳实践。多线程的基本概念多线程是指......
  • 一个月学会Java 第20天 多线程
    Day20多线程线程,很重要的概念,因为我们的CPU假如是intel或者amd的都是说一核二线程,假如你的电脑是8核的cpu那基本上就是16线程,如果你的mac的M芯片自然是几核就是几线程。想要查看自己的电脑是几个线程的我们有几种方法,一种直接使用Java运行一串代码,其次我们可以看任务管......
  • 【2024华为OD-E卷-100分-内存资源分配】(题目+思路+Java&C++&Python解析+在线测试)
    在线评测链接题目描述有一个简易内存池,内存按照大小粒度分类,每个粒度有若干个可用内存资源,用户会进行一系列内存申请,需要按需分配内存池中的资源返回申请结果成功失败列表。分配规则如下:分配的内存要大于等于内存的申请量,存在满足需求的内存就必须分配,优先分配粒度小的......
  • Redis的内存管理体系
    Redis的内存管理体系Redis的内存管理体系由多种策略和机制组成,旨在有效利用内存资源、优化性能和确保数据的可靠性。以下是Redis内存管理体系的主要组成部分:1.内存分配Redis使用自定义的内存分配器,默认使用Jemalloc。这种分配器旨在减少内存碎片,提高内存分配和释放的......
  • flink同步MySQL数据的时候出现内存溢出
    flink同步MySQL数据的时候出现内存溢出背景:需要将1000w的某类型数据同步到别的数据源里面,使用公司的大数据平台可以很快处理完毕,而且使用的内存只有很少很少量(公司的大数据平台的底层是flink,但是连接器使用的是chunjun开源产品),由于我个人想使用flink原生的连接器来尝试一下,所......
  • 【Linux】<互斥量>解决<抢票问题>——【多线程竞争问题】
    前言大家好吖,欢迎来到YY滴Linux系列,热烈欢迎!本章主要内容面向接触过C++的老铁主要内容含:欢迎订阅YY滴C++专栏!更多干货持续更新!以下是传送门!YY的《C++》专栏YY的《C++11》专栏YY的《Linux》专栏YY的《数据结构》专栏YY的《C语言基础》专栏YY的《初学者易错点》......
  • Android15音频进阶之4种调试线程死锁利器(八十九)
    简介:CSDN博客专家、《Android系统多媒体进阶实战》一书作者新书发布:《Android系统多媒体进阶实战》......
  • Windbg下使用dump分析内存溢出
    https://www.cnblogs.com/M-MAKI/p/17085360.html 分析简述 创建dump文件;通过 !address-summary 和 !eeheap-gc判断是否为内存泄漏;通过!dumpheap-stat观察出问题的类型;通过!dumpheap-mtMT号-minxxx来索引该类型下占用较高的数据;再通过!gcrootGC根来查看该根被......