当前位置: 首页 > news >正文

【多线程】线程安全

目录

 一、初识线程安全

什么是线程安全问题

理解线程不安全的原因

原因总结

二、解决线程不安全

加锁🔐

锁对象

synchronized几种使用方式

死锁🔏

死锁的三个场景

(1)一个线程针对一把锁连续加锁两次

(2)两个线程两把锁

(3)N个线程M个锁

如何解决死锁问题

三、内存可见性问题

什么是内存可见性问题

volatile 关键字


多线程章节中,最重要的话题就是线程安全。因为多个线程同时执行某个代码的时候,可能会引起一些奇怪的bug,理解了线程安全,才能避免/解决上述的bug。

 一、初识线程安全

什么是线程安全问题

public class Demo18 {
    private static int count = 0;
 
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
 
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
 
        t1.start();
        t2.start();
 
        t1.join();
        t2.join();
 
        System.out.println("count=" + count);
    }
}

预期是10w结果和实际不一样,而且每次运行的结果都不一样

理解线程不安全的原因

上面代码中的cout++操作,其实在CPU视角来看,是3个指令
1)把内存中的数据,读取到CPU寄存器里     load
2)把CPU寄存器里的数据+1     add
3)把寄存器的值,写回内存     save

CPU在调度执行线程的时候,说不上啥时候,就会把线程给切换走(抢占式执行,随机调度)。指令是CPU执行的最基本单位,要调度,至少把当前执行完,不会执行一半调度走。但是由于cout++是三个指令,可能会出现CPU执行了其中的1个指令或者2个指令或者3个指令调度走的情况,这是都有可能无法预测的。

基于上面的情况,两个线程同时对count进行++就容易出现bug。

上述的执行顺序,只是一种可能的调度顺序,由于调度过程是"随机"的,因此就会产生很多其他的执行顺序。上述过程中,明明是++了两次但是最终结果,还是1,因为这两次加的过程中,结果出现了"覆盖"

由于循环5w次过程中,也不知道有多少次的执行顺序,是这种正确情况,有多少次是其他的出错情况,因此最终的结果,是不确定的值,而且这个值,一定小于10W。

对于多线程代码来说,最大的困难,就在于"随机调度,抢占式执行",是多线程编码的"罪魁祸首,万恶之源”。

面试的时候,被问到,线程不安全的原因,你也可以尝试给面试官画图。

其他所有原因总结

1️⃣线程在操作系统中,随机调度,抢占式执行 [根本原因]
2️⃣多线程,同时修改同一个变量(如果是多个线程读取变量或只有一个线程或修改不同的变量都不会)
3️⃣修改操作,不是"原子"的(count++ 背后是三个指令,这个操作不是原子的)

4️⃣内存可见性问题

5️⃣指令重排序

二、解决线程不安全

第一个原因,无法干预,操作系统内核,负责的工作,咱们作为应用层的程序员,无法干预。第二个原因,可以让线程修改不同的变量,可能可行,取决于实际的需求,有的场景能这么改,有的场景不能这么改,取决于实际的需求,在Java中这个方案不算很普适的方案,但是有的语言,更青睐这个方案,erlang这个语言,就是采取这个方案,解决并发编程中的"线程安全"问题的,它没有变量,所有的"变量"都是"常量",不能修改,自然也就不必担心上述的线程安全问题了。

加锁🔐

解决线程安全问题,最主要的办法,就是把"非原子"的修改,变成"原子"--“加锁”。

此处的加锁,并不是真的让count++变成原子的,也没有干预到线程的调度,只不过是通过这种加锁的方式,使一个线程在执行count++的过程中,其他的线程的count++不能插队进来。
下面结合代码来看:Java中提供了synchronized关键字,来完成加锁操作

public class Demo18 {
    private static int count = 0;
 
    private static Object locker = new Object();
 
    public static void main(String[] args) throws InterruptedException {
        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();
 
        t1.join();
        t2.join();
 
        System.out.println("count=" + count);
    }
}

进入代码块就会进行加锁,出了代码块就会进行解锁

本质上是把随机的并发执行过程,强制变成了串行,从而解决了刚才的线程安全问题

锁对象

上述代码有效的前提是,两个线程,都加锁了,而且是针对同一个对象加锁

 

锁对象作用,就是用来区分,多个线程,是否是针对"同一个对象"加锁",是针对同一个对象加锁,此时就会出现"阻塞"(锁竞争/锁冲突)。不是针对同一个对象加锁,此时不会出现"阻塞",两个线程仍然是随机调度的并发执行。锁对象,填哪个对象,不重要,重要的是,多个线程是否是同一个锁对象。 

锁对象,肯定得是个对象,不能拿int,double这种内置类型,来写到()里,但是其他的类型,只要是Object(或者是子类)都是可以的,例如字符串就可以。

或者

⚠️注意:咱们此处的加锁后的代码本质上比join的串行执行,效率还是要高的。加锁,就是变成"串行执行",那么是否就没必要使用多线程了?当然不是的,加锁,只是把线程中的一小部分逻辑,变成"串行执行",剩下的其他部分,仍然是可以并发执行的。

如果是3个线程针对同一个对象加锁,也是类似的情况。其中某个线程先加上锁,另外两个线程阻塞等待(哪个线程拿到锁,这个过程不可预期的)。拿到锁的线程释放了锁之后,剩下两个线程谁先拿到锁呢?也是顺序不确定的。123,比如最开始1拿到锁,2、3阻塞等待1释放锁之后,2和3谁先拿到锁?不一定,也是随机的,即使2先加锁,3后加锁,也不一定谁先拿到。此处synchronized是JVM提供的功能,synchronized底层实现就是JVM中,通过C++代码来实现的,进一步的,也是依靠操作系统提供的API实现的加锁,操作系统的API则是来自于CPU上支持的特殊的指令来实现的。

synchronized几种使用方式

synchronized还可以修饰一个方法

class Counter {
    public int count = 0;
 
    public void add() {
        count++;
    }
}
 
public class Demo19 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
 
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized (counter){
                    counter.add();
                }
            }
        });
 
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized (counter){
                    counter.add();
                }
            }
        });
 
        t1.start();
        t2.start();
 
        t1.join();
        t2.join();
 
        System.out.println("counter=" + counter.count);
    }
}

或者

class Counter {
    public int count = 0;
 
    synchronized public void add() {
        count++;
    }
}
 
public class Demo19 {
    public static void main(String[] args) throws InterruptedException {
        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();
 
        t1.join();
        t2.join();
 
        System.out.println("counter=" + counter.count);
    }
}

1)synchronized(){}
圆括号指定锁对象
2)synchronized修饰一个普通的方法
相当于针对this加锁
3)synchronized修饰一个静态的方法
相当于针对对应的类对象加锁

锁是解决线程安全问题典型的做法,关于锁内部的原理和特性,Java其他的锁的实现,后面慢慢展开。

死锁🔏

死锁的三个场景

(1)一个线程针对一把锁连续加锁两次
class Counter {
    public int count = 0;
 
    public void add() {
        synchronized (this) {
            count++;
        }
    }
}
 
public class Demo19 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
 
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized (counter) {
                    counter.add();
                }
            }
        });
 
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized (counter) {
                    counter.add();
                }
            }
        });
 
        t1.start();
        t2.start();
 
        t1.join();
        t2.join();
 
        System.out.println("counter=" + counter.count);
    }
}

1)里面的synchronized要想拿到锁,就需要外面的synchronized释放锁
2)外面的synchronized要释放锁,就需要执行到}
3)要想执行到}就需要执行完这里的add
4)但是add正阻塞着

上面一顿分析猛如虎,结果一运行,结果出来了,没有死锁呀,这里没有死锁,是Java的synchronized做了特殊处理。同样的代码,换成C++/Python就会死锁,Java为了减少程序员写出死锁的概率,引入了特殊机制,解决上述的死锁问题,"可重入锁"。加锁的时候,是需要判定,当前这个锁,是否是被占用的状态,可重入锁,就是在锁中,额外记录一下,当前是哪个线程,对这个锁加锁了。对于可重入锁来说,发现加锁的线程就是当前锁的持有线程,并不会真正进行任何加锁操作,也不会进行任何的"阻塞操作"而是直接放行,往下执行代码。

可重入锁引入之后,为了避免,出现上述一个线程连续加锁两次就死锁的情况,synchronized就是可重入锁,可重入锁内部记录了当前是哪个线程持有的锁,后续加锁的时候都会进行判定,还会通过一个引用计数维护当前已经加锁几次了,并且描述出何时真正释放锁。

(2)两个线程两把锁

public class Demo20 {
    private static Object locker1 = new Object();
 
    private static Object locker2 = new Object();
 
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("t1 加锁 locker1 完成");
 
                //这里的 sleep 是为了确保,t1 和 t2 都先分别拿到 locker1 和 locker2 然后再分别拿对方的锁
                //如果没有 sleep 执行顺序就不可控,可能出现某个线程一口气拿到两把锁,另一个线程还没执行呢,无法构造出死锁
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
 
                synchronized (locker2) {
                    System.out.println("t1 加锁 locker2 完成");
                }
            }
        });
 
        Thread t2 = new Thread(() -> {
            synchronized (locker2) {
                System.out.println("t2 加锁 locker2 完成");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
 
                synchronized (locker1) {
                    System.out.println("t2 加锁 locker1 完成");
                }
            }
        });
 
        t1.start();
        t2.start();
    }
}

借助第三方工具也可以看到两线程都是BLOCKED的状态

(3)N个线程M个锁

死锁经典模型:哲学家就餐问题

如何解决死锁问题

死锁产生的四个必要条件:
·互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
·不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
·请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
·循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。
当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。
其中最容易破坏的就是"循环等待"

最常用的一种死锁阻止技术就是锁排序,假设有N个线程尝试获取M把锁,就可以针对M把锁进行编号(1,2,3......M)N个线程尝试获取锁的时候,都按照固定的按编号由小到大顺序来获取锁。这样就可以避免环路等待。

每个滑稽加锁的时候一定是先拿起编号小的筷子,后拿起编号大的筷子。同一时刻,所有线程拿起第一根筷子。

public class Demo20 {
    private static Object locker1 = new Object();
 
    private static Object locker2 = new Object();
 
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("t1 加锁 locker1 完成");
 
                //这里的 sleep 是为了确保,t1 和 t2 都先分别拿到 locker1 和 locker2 然后再分别拿对方的锁
                //如果没有 sleep 执行顺序就不可控,可能出现某个线程一口气拿到两把锁,另一个线程还没执行呢,无法构造出死锁
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
 
                synchronized (locker2) {
                    System.out.println("t1 加锁 locker2 完成");
                }
            }
        });
 
        Thread t2 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("t2 加锁 locker1 完成");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
 
                synchronized (locker2) {
                    System.out.println("t2 加锁 locker2 完成");
                }
            }
        });
 
        t1.start();
        t2.start();
    }
}

三、内存可见性问题

什么是内存可见性问题

如果一个线程修改,另一个线程读取,这样的代码是否会有线程安全呢?

import java.util.Scanner;
 
public class Demo21 {
 
    private static int n = 0;
 
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (n == 0) {
                // 啥都不写
            }
            System.out.println("t1 线程结束循环");
        });
 
        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            n = sc.nextInt();
        });
 
        t1.start();
        t2.start();
    }
}

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

内存可见性问题,本质上,是编译器/JVM对代码进行优化的时候优化出bug,如果代码是单线程的,编译器/JVM,代码优化一般都是非常准确的,优化之后,不会影响到逻辑。但是代码如果是多线程的,编译器/JVM的代码优化,就可能出现误判(编译器/JVM的bug),导致不该优化的地方,也给优化了,于是就造成了内存可见性问题了。编译器为啥要做上述的代码优化?为啥不老老实实的按照程序员写的代码,一板一眼执行,主要是因为,有的程序员,写出来的代码,太低效了,为了能够降低程序员的门槛,即使你代码写的一般,最终执行速度也不会落下风。因此主流编译器,都会引入优化机制(优化手段是多种多样的),优化编译器自动调整你的代码,保持原有逻辑不变的前提下,提高代码的执行效率,代码优化的效果是非常明显的。

解决方案一:

此处即使sleep时间非常短,但是刚才的内存可见性问题就消失了,t2的修改就能被t1感知到。说明加入sleep之后,刚才谈到的针对读取n内存数据的优化操作,不再进行了。和读内存相比,sleep开销是更大的,远远超过了读取内存就算把读取内存操作优化掉,也没有意义,杯水车薪。

volatile 关键字

如果代码中,循环里没有sleep,又希望代码能够没有bug的正确运行呢?volatile关键字修饰一个变量,提示编译器说,这个变量是"易变"的。编译器进行上述优化的前提是编译器认为,针对这个变量的频繁读取,结果都是固定的,此时,编译器就会禁止上述的优化,确保每次循环都是从内存中重新读取数据。

import java.util.Scanner;
 
public class Demo21 {
 
    private static volatile int n = 0;
 
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (n == 0) {
                // 啥都不写
            }
            System.out.println("t1 线程结束循环");
        });
 
        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            n = sc.nextInt();
        });
 
        t1.start();
        t2.start();
    }
}

编译器的开发者,知道这个场景中,可能出现误判,于是就把权限交给了程序员,让程序员能够部分的干预到优化的进行。让程序员显式的提醒编译器,这里别优化。引入volatile的时候,编译器生成这个代码的时候,就会给这个变量的读取操作,附近生成一些特殊的指令,称为"内存屏障",后续JVM执行到这些特殊指令,就知道了,不能进行上述优化了。

⚠️注意:volatile只是解决内存可见性问题,不能解决原子性问题。如果两个线程针对同一个变量进行修改(count++),volatile无能为力:

public class Demo22 {
 
    private static volatile int count = 0;
 
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
 
        System.out.println("count =" + count);
    }
}

相关文章:

  • [LeetCode]day27 28. 找出字符串中第一个匹配项的下标
  • 音视频入门基础:RTP专题(10)——FFmpeg源码中,解析RTP header的实现
  • Docker仿真宇树狗GO1
  • Spring Security+JWT+Redis实现项目级前后端分离认证授权
  • 【DeepSeek-R1背后的技术】系列九:MLA(Multi-Head Latent Attention,多头潜在注意力)
  • 深入解析适配器模式:软件架构中的接口协调大师
  • printf和 vprintf的区别
  • MongoDB学习
  • CASS11快捷键设置
  • 国内三大知名开源批发订货系统对比
  • 【React】React 基础(2)
  • 深度解读DeepSeek:从原理到模型
  • Cursor不能白嫖还不安全:Cline + DeepSeek V3,最强国产双开源解决方案
  • C语言内存函数
  • 【MATLAB例程】RSSI/PLE定位与卡尔曼滤波NLOS抑制算法,附完整代码
  • 智能自动化新纪元:AI与UiPath RPA的协同应用场景与技术实践
  • vscode软件中引入vant组件
  • leetcode hot100-34 合并K个升序链表
  • 什么是Firehose?它的作用是什么?
  • 蓝桥杯笔记——递归递推
  • 乡村快递取件“跑腿费”屡禁不止?云南元江县公布举报电话
  • 水利部:山西、陕西等地旱情将持续
  • 外交部回应中美经贸高层会谈:这次会谈是应美方请求举行的
  • “半世纪来对无争议边界最深入袭击”:印巴冲突何以至此又如何收场?
  • 央行:5月8日起,下调个人住房公积金贷款利率0.25个百分点
  • 工信部:加强通用大模型和行业大模型研发布局