Java的多线程——多线程(3)线程安全

一、多线程带来的的风险---线程安全(重点)

1、观察线程不安全

家观察下是否适用多线程的现象是否⼀致?

同时尝试思考下为什么会有这样的现象发生呢?

java 复制代码
// 此处定义⼀个 int 类型的变量 
private static int count = 0;
 
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        // 对 count 变量进⾏⾃增 5w 次 
        for (int i = 0; i < 50000; i++) {
            count++;
        }
    });
    Thread t2 = new Thread(() -> {
        // 对 count 变量进⾏⾃增 5w 次 
        for (int i = 0; i < 50000; i++) {
            count++;
        }
    });
 
    t1.start();
    t2.start();
 
    // 如果没有这俩 join, 肯定不⾏的。 线程还没⾃增完, 就开始打印了, 很可能打印出来的 count 就是个 0 
    t1.join();
    t2.join();
 
    // 预期结果应该是10w 
    System.out.println("count: " + count);
}

2、线程安全的概念

想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

3、线程不安全的原因

3.1线程调度是随机的(抢占式执行)

这是线程安全问题的罪魁祸首 :线程的调度

随机调度使⼀个程序在多线程环境下,执行顺序存在很多的变数

程序猿必须保证在任意执行顺序下,代码都能正常工作

  • 核心问题:多线程环境中,修改操作若不是原子的(即操作可被拆分),会引发线程安全问题。
  • 解决方案:加锁是 Java 解决线程安全问题的主要方案。通过加锁,可将非原子操作打包成原子操作,确保同一时间只有一个线程能执行该操作。
  • 锁的特性:计算机中的锁与生活中的锁概念一致,具有互斥 / 排他性,即同一时间只有一个线程能获取锁并执行被锁保护的代码块。

1. 外部加锁(锁包裹整个 for 循环)

  • 执行逻辑 :在for循环的所有操作(包括i < 5w判断、i++count++)外围加锁。
  • 效果 :整个for循环的所有步骤都串行执行(同一时间只有一个线程能执行循环内的所有操作)。
  • 特点
    • 线程安全但效率低,因为循环的并发潜力(如i的判断和自增)被完全限制。
    • 适用于循环内所有操作都需要严格串行的场景,但通常不是最优选择。

2. 内部加锁(仅count++处加锁,即题目中的写法)

  • 执行逻辑 :仅对count++这一非原子操作 单独加锁,而for循环的i < 5w判断、i++操作仍可并发执行
  • 效果
    • count++是串行的(保证数据一致性),循环的其他步骤(i的判断和自增)是并发的。
    • 既保障了count++的线程安全,又利用了循环的并发潜力,因此执行速度更快

3. 外部加锁 为什么count++是串行的?

  • 锁的作用范围count++被包裹在加锁代码块内(如synchronized块),而锁具有互斥性------ 同一时间只有一个线程能获取锁并执行被保护的代码。
  • 因此,所有线程对count++的操作必须 "排队执行":一个线程执行完count++并释放锁后,下一个线程才能获取锁执行count++,最终实现串行化,避免了多线程同时修改count导致的数据不一致。

从里面加锁到这个外边加锁进行简化

那这个还有一种特殊的情况:

static修饰的方法,是针对类这个本身的,static 修饰的方法,不存在this ,那这个Synchronized 修饰的就是针对类对象加锁

如果说加两个锁呢?

第一次执行加锁成功加锁(锁没有人使用)

第二次执行此时这个锁已经被占用了,此时就会阻塞

注意:想要解锁,需要等待上一个锁使用完解锁,但是这个会一直等待,等待第一次锁释放,但是释放不了,就称之为死锁

死锁是一个非常严重的bug ,使代码执行到这一块之后,就卡住不动了

1. 直接嵌套加锁场景

  • 代码中对同一把锁locker进行了两次synchronized嵌套加锁。此时第一次加锁成功后,第二次加锁会因为锁已被当前线程持有而进入阻塞等待,这种重复加锁是不必要的,却容易在开发中因代码结构问题写出。

2. 方法调用层次较深的加锁场景

  • 外层代码对counter对象加锁,而counteradd方法内部又对this(即counter对象)加锁。当方法调用层次较深时,开发者容易忽略这种间接的重复加锁情况,从而引发锁重入导致的阻塞问题。

怎么解决嵌套死锁问题呢?

Java synchronized的引入了可重入概念,针对这个线程的锁,他已经被使用了,引入了可重入性就不会阻塞了,而是继续往下执行,因为这把锁就是被这个线程所持有的。

其他的线程加锁仍然会阻塞

死锁产生的必要条件

  1. 互斥访问:资源不能被共享

  2. 持有并等待:线程持有资源并等待其他资源

  3. 不可剥夺:资源只能由持有线程释放

  4. 循环等待:多个线程形成等待环

java 复制代码
public class demo20 {
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2){
                    System.out.println("他 线程两个锁都捕获到了");
                }
            }

        });
        Thread t2 = new  Thread(() -> {
            synchronized(locker2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker1){
                    System.out.println("t2两个线程的两个锁都获取到了");
                }
            }
        });
        t1.start();
        t2.start();
     t1.join();
     t2.join();
    }
}

这个代码产生了死锁,显示blocked(竞争锁的缘故导致阻塞)

如果不加sleep是否还会出现上述的问题?

  • 死锁的可能性降低,但并未完全消失

原因是:

  • 线程调度是抢占式的,若 t1 和 t2 的执行顺序刚好满足 "t1 持locker1locker2,t2 持locker2locker1",则会立即形成死锁;
  • 但由于没有sleep强制延迟,线程可能快速执行完锁的获取与释放(例如 t1 获取locker1后,在 t2 获取locker2前就已拿到locker2并释放所有锁,或反之),此时不会死锁。

1. 线程状态与锁持有关系

  • 线程Thread-1处于BLOCKED 状态 ,原因是它在等待获取java.lang.Object@6ae6a90b这把锁,而该锁当前被Thread-0持有。
  • 这符合 "线程因竞争锁而阻塞" 的场景,说明Thread-1在尝试进入某个synchronized代码块时,锁已被Thread-0占用。

2. 堆栈跟踪的锁信息

  • 堆栈中显示Thread-1已锁定java.lang.Object@28c30f8d,说明它在阻塞前已经持有了这把锁,现在又试图获取另一把锁(@6ae6a90b),这是锁竞争 + 多锁交互的典型表现,若处理不当可能引发死锁。

3.2修改共享数据

多 个线程修改同⼀个变量 上面的线程不安全的代码中,涉及到多个线程针对 此时这个 count 变量进行修改. count 是⼀个多个线程都能访问到的"共享数据

3.3修改操作不是原子性

什么是原子性

我们把一段代码想象成⼀个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入 房间之后,还没有出来;B是不是也可以进⼊房间,打断A在房间里的隐私。这个就是不具备原子性的。 那我们应该如何解决这个问题呢?是不是只要给房间加⼀把锁,A进去就把门锁上,其他⼈是不是就进不来了。这样就保证了这段代码的原子性了。 有时也把这个现象叫做同步互斥,表示操作是互相排斥的。

⼀条java语句

不⼀定是原子的,也不一定只是一条指令 比如刚才我们看到的n++,其实是由三步操作组成的:

  1. 从内存把数据读到CPU

  2. 进行数据更新

  3. 把数据写回到CPU 不保证原子性会给多线程带来什么问题 如果⼀个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。

这点也和线程的抢占式调度密切相关.如果线程不是"抢占"的,就算没有原子性,也问题不大

3.4 内存的可见性问题

可见性指,⼀个线程对共享变量值的修改,能够及时地被其他线程看到.

J**ava内存模型(JMM):**Java虚拟机规范中定义了Java内存模型.

目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果

.• 线程之间的共享变量存在主内存(MainMemory).

• 每⼀个线程都有自己的"工作内存"(WorkingMemory).

• 当线程要读取⼀个共享变量的时候,会先把变量从主内存拷贝到⼯作内存,再从⼯作内存读取数据.

• 当线程要修改⼀个共享变量的时候,也会先修改工作内存中的副本,再同步回主内存. 由于每个线程有自己的总顾总内存,这些工作内存中的内容相当于同⼀个共享变量的"副本".此时修改线 程1的⼯作内存中的值,线程2的工作内存不⼀定会及时变化.

1)初始情况下,两个线程的工作内存内容⼀致.

  1. ⼀旦线程1修改了a的值,此时主内存不⼀定能及时同步.对应的线程2的⼯作内存的a的值也不⼀定 能及时同步

这个时候代码中就容易出现问题. 此时引入了两个问题:

• 为啥要整这么多内存?

• 为啥要这么麻烦的拷来拷去?

  1. 为啥整这么多内存? 实际并没有这么多"内存".这只是Java规范中的⼀个术语,是属于"抽象"的叫法. 所谓的"主内存"才是真正硬件角度的"内存".而所谓的"工作内存",则是指CPU的寄存器和⾼速缓存.

  2. 为啥要这么麻烦的拷来拷去? 因为CPU访问自身寄存器的速度以及高速缓存的速度,远远超过访问内存的速度(快了3-4个数量级, 也就是几千倍,上万倍). 比如某个代码中要连续10次读取某个变量的值,如果10次都从内存读,速度是很慢的.但是如果只是 第一次从内存读,读到的结果缓存到CPU的某个寄存器中,那么后9次读数据就不必直接访问内存了. 效率就大大提高了. 那么接下来问题又来了,既然访问寄存器速度这么快,还要内存干啥??

答案就是⼀个字:贵

值的⼀提的是,快和慢都是相对的.CPU访问寄存器速度远远快于内存,但是内存的访问速度⼜远远快 于硬盘. 对应的,CPU的价格最贵,内存次之,硬盘最便宜

3.5指令重排序引起的线程不安全问题

指令重排序 什么是代码重排序 ⼀段代码是这样的:

  1. 去前台取下U盘

  2. 去教室写10分钟作业

  3. 去前台取下快递 如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按1->3->2的方式执行,也是没问 题,可以少跑⼀次前台。这种叫做指令重排序

编译器对于指令重排序的前提是"保持逻辑发生变化".这⼀点在单线程环境下比较容易判断,但是 在多线程环境下就没那么容易了,多线程的代码执行复杂程度更高,编译器很难在编译阶段对代码的 执行效果进行预测,因此激进的重排序很容易导致优化后的逻辑和之前不等价.

重排序是⼀个比较复杂的话题,涉及到CPU以及编译器的⼀些底层工作原理,此处不做过多讨论

死锁的四个必要条件

  1. 互斥条件 锁的基本性质:一个线程获取锁后,其他线程尝试获取该锁时必须阻塞等待,同一时间锁只能被一个线程持有。(如 Java 的synchronized锁遵循此特性)

  2. **不可抢占条件(不可剥夺)**锁的基本特性:线程 1 获取锁后,线程 2 无法强行抢占该锁,只能阻塞等待线程 1 主动释放。

  3. 请求和保持条件 一个线程持有锁 1 的情况下,不释放锁 1,继续请求锁 2。反例:若线程先释放锁 1 再请求锁 2,可避免死锁(如 "先放下左手筷子,再拿右手筷子")。

  4. 循环等待条件 多个线程、多把锁之间形成循环依赖的等待链。示例:线程 A 等线程 B 的锁,线程 B 等线程 A 的锁;或线程 A 等 B、B 等 C、C 等 A。

解决办法?

看下面这张图

一个滑稽代表一个线程,筷子是锁,俩筷子就可以继续执行不会产生死锁问题,一个的话就阻塞等待

这是五个滑稽,滑稽1 如果拿着一个筷子,他吃不到这个面条,需要两个筷子,那他拿着只有个筷子,只能等待,此时每一个线程都持有一个筷子,正在等待阻塞,这时候就会产生死锁,这就是我们刚刚所说的死锁问题。

解决办法如下:

我们不妨给他加一个约定,每个线程加锁的时候永远是先获得序号小的锁,在获取序号大的锁

滑稽1 先不拿锁(筷子)

滑稽2 拿1筷子

滑稽3 拿2筷子

滑稽4 拿3筷子

滑稽5 拿4筷子

滑稽1 此时就在阻塞等待1号筷子

这时候筷子5就空闲了,滑稽5 ,这时候就可以拿走筷子5 ,进行吃面条,吃完就释放筷子

筷子4也空闲了,滑稽4就可以拿筷子4和筷子5,进行吃面条,然后释放筷子

.......滑稽2释放筷子1和筷子2,

此时滑稽1等待到了他的筷子1 就拿起筷子1,然后筷子也是闲置的,此时两个锁都执行,至此所有的滑稽都吃上面条了,没有产生死锁问题

第一种方法:将嵌套锁改为并行锁(破坏 "请求和保持条件")

  • 核心逻辑:避免线程在持有锁的同时继续请求新锁。即线程需一次性获取所有需要的锁(并行申请),若无法同时获取,则释放已持有的锁并重新尝试。

  • 示例:线程需同时操作锁 A 和锁 B 时,不先拿 A 再拿 B,而是同时申请 A 和 B。若两者都能获取则执行,否则都不拿,避免 "持有 A 等待 B" 的嵌套状态。

  • 效果:直接消除 "请求和保持" 条件(不会持有部分锁并等待其他锁),从根源上减少死锁可能。

  • 并列的锁就解决这个问题了

  • 并列的锁",本质是 **"一次性获取所有需要的锁(并行申请锁)"的策略,核心是破坏 "请求和保持" 条件 **,避免线程在持有部分锁的同时等待其他锁。

    定义与逻辑

    "并列的锁" 指线程在执行前,一次性尝试获取所有需要的锁;若不能同时获取所有锁,则不持有任何锁,释放已尝试的资源后重新申请。

第二种方法:破坏 "循环等待条件"(如固定锁的获取顺序)

  • 核心逻辑:为所有锁定义统一的获取顺序,要求所有线程必须按相同顺序获取锁。
  • 示例:规定 "必须先获取编号小的锁,再获取编号大的锁"。若线程 A 和 B 都需要锁 1 和锁 2,则都会先拿 1 再拿 2,不会出现 "A 等 2、B 等 1" 的循环。
  • 效果:打破循环等待链,确保等待关系是单向的,避免形成闭环。
相关推荐
大象席地抽烟2 小时前
spring中使用rabbitmq(spring-boot-starter-amqp)
java
运维_攻城狮2 小时前
Nexus 3.x 私服搭建与运维完全指南(Maven 实战)
java·运维·maven
R.lin2 小时前
mmap内存映射文件
java·后端
chxii2 小时前
Maven 详解(中)
java·maven
SimonKing2 小时前
消息积压、排查困难?Provectus Kafka UI 让你的数据流一目了然
java·后端·程序员
周杰伦_Jay2 小时前
【主流开发语言深度对比】Python/Go/Java/JS/Rust/C++评测
开发语言·python·golang
d111111111d2 小时前
STM32外设学习--TIM定时器--输入捕获---测频方法。
stm32·单片机·学习
考虑考虑2 小时前
点阵图更改背景文字
java·后端·java ee
ldmd2842 小时前
Go语言实战:入门篇-5:函数、服务接口和Swagger UI
开发语言·后端·golang