【javaEE】多线程——线程安全进阶☆☆☆

文章目录



这里是@那我掉的头发算什么
刷到我,你的博客算是养成了😁😁😁


前言

在初阶内容中我们提到,线程安全问题有五大条件,分别是:线程在cpu上的调度是随机的,多个线程同时修改同一个变量,修改操作不是原子的,内存可见性,指令重排序。上一篇博客中重点讲了前三个,这次我们来聚焦一下内存可见性问题,指令重排序我们将会在单例模式部分详细讲解。


内存可见性

认识内存可见性

java 复制代码
package Thread11_16;

import java.util.List;
import java.util.Scanner;

public class demo17 {
    public static int flag = 1;
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while(flag == 1){

            }
            System.out.println("t1执行完毕");
        });
        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入flag的值:");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

我们来看上述代码的逻辑:t1线程的主要任务是一个循环,条件是flag的值,t2线程主要作用是可以修改flag的值。因为flag刚开始定义时值为1,所以可以推断:在程序开始时,t1线程陷入死循环,直到t2线程将flag的值修改,t1线程打印结果,线程结束。

但是,实际结果真的是如此吗???

t1线程结束的标志是输出"t1执行完毕",然而不仅这个语句没有被打印,前台线程也没有全部结束,进程仍然在运行。很明显,程序因该是遇到bug了。

通过可视化工具可以得出:首先t2线程结束了,t1线程阻塞在第十行代码。也就是说,t2线程对flag值的修改并没有被t1线程读取到。

原因:

其实,我们平时自己写的程序,他们在编译器里面实际的运行状况如顺序与我们所预期的不一样。因为研究JDK的大佬,他们设计的解释器实际上考虑了对我们所写的编程的优化:在不改变原有的代码的逻辑的前提下,对代码进行调整,使得代码的执行效率变高。

然而,在多线程中,不改变原有的代码逻辑可能会被编译器误判:可能有些优化确实改变了逻辑,但是编译器没有反应过来,产生了错误。

拿这个循环来说,其实从指令级别的角度来说,这个循环由两个指令组成,真实的循环场景应该如下:

while(true){

load

cmp

}

其中,load指令是从内存中读取数据放入寄存器,cmp指令是将寄存器中的值与"1"作比较。我们知道,从内存中读取数据的耗时与寄存器处理数据的耗时的差别很大,甚至能达到几千倍。短时间内,这个循环执行了很多次,JVM在执行过程中感受到:load反复执行的结果是一样的。既然每次读取load的值都是一样的,不如把读内存中的值优化成读寄存器中的值。后续load操作都是读寄存器中的值。但是线程t2在修改时修改的是内存中flag的值,t1却不再重新从内存中读取数据了,所以感知不到flag的改变,因而陷入死循环。

只需要稍稍调整一下原代码:

众所周知,计算机的运算速度是很快的。我们在代码中加入了sleep(1)的语句,虽然只是短短的暂停了一毫秒,对人类来说,甚至感知不到一毫秒的存在。但是对于计算机来说,一毫秒如隔三秋,一毫秒足够计算机执行load+cmp不知道多少遍了。此时优化load对于整个代码来言微不足道,所以不会再改变代码逻辑了。

然而,依靠sleep来解决内存可见性问题太勉强了,使用了sleep会大大降低代码的执行效率,实际场景中,我们可以使用volatile关键字进行解决。

volatile

在 Java 并发编程中,volatile是一个关键字,核心作用是保证共享变量的 "可见性" 和 "有序性",但不保证原子性。

(1)保证可见性

当变量被volatile修饰后:

写操作:线程修改该变量时,会直接把新值同步到主内存,而不是只存在 CPU 缓存里;

读操作:线程读取该变量时,会直接从主内存读取,而不是从自己的 CPU 缓存里读旧值。

对应之前的flag例子:如果flag被volatile修饰,JVM 就不会把 "读 flag" 优化成读寄存器,t1 线程每次都会从主内存读最新的flag值,t2 修改后 t1 能立刻感知到。

(2)禁止指令重排序(保证有序性)

编译器 / CPU 会对普通指令做重排序优化(提升性能),但volatile会禁止这种优化:保证volatile变量的相关指令,执行顺序和代码编写顺序一致。

局限性:不保证原子性

volatile无法保证 "读 - 改 - 写" 这类复合操作的原子性。比如i++(实际是读i→i+1→写回i三步),如果多个线程同时执行i++,即使i是volatile修饰的,也会出现线程安全问题(因为三步操作可能被其他线程打断)。这种场景需要用synchronized、Lock或原子类(如AtomicInteger)。

结合之前的 flag 例子

如果把flag定义为volatile static int flag = 0;,就能解决之前的可见性问题:t2 修改flag后会立刻同步到主内存,t1 每次循环都会从主内存读最新的flag,从而感知到更新并结束循环。

wait-notify

众所周知,操作系统对于线程是随机调度的,但是在实际开发中,我们肯定是希望可以协调线程的执行顺序。我们可以让后执行的线程阻塞等待,等先执行的线程执行好了再通知它,让他继续执行。

我们之前学习到join的作用也是等待另一个线程执行结束再执行。但是这两种执行的逻辑是不一样的。join是等另一个线程完全运行结束了才能运行,但是wait不一样,wait是另一个线程通知他他就可以继续运行,与另一个线程是否运行完没关系。这里的notify的作用就是通知作用。

应用场景

线程饿死:

规定:当一个线程同时拥有cpu资源和锁时这个线程才可以运行。

此时,一个线程拿到了锁,进入cpu运行任务,但是,需要的cpu资源没有被释放,所以没有运行的条件,任务没有执行。完成了临界区任务后,此时线程会把锁释放掉。这一时刻,其他竞争这个锁的线程处于阻塞状态,这个刚刚释放锁的线程处于就绪态,虽然其他线程接下来会进入就绪态,但是慢第一个线程一步。根据线程的随即调度原理,下一个拿到锁的,其实大概率还是这个线程。因此,这一个线程反复拿到锁释放锁,陷入无尽循环,而其他的线程迟迟拿不到锁,任务被耽搁,最终"饿死"。

首先,wait和notify都是Object提供的方法,任何一个对象都可以使用。因为首先wait和notify都需要提前加锁才可以使用,并且只有加了同一个锁对象的前提下,wait和notify才能配合使用。所以我们可以使用锁对象来调用wait和notify。此外,我们在使用wait时需要抛Interrupted异常,也就意味着他跟sleep一样,中断时会被强行唤醒,所以实际应用中一般用while循环二次检测wait(这个后面再讲)。

wait在使用时,首先进行的第一个微操作就是释放锁,这也是为什么他能解决线程饿死的原因:自己阻塞时,将锁释放掉,让其他能够运行的线程拿到锁先运行再说。当阻塞结束后,线程会重新加锁。所以使用wait时需要先加锁(sychronized)。这也是wait和sleep的一大不同,sleep是"抱着锁睡觉",阻塞时不释放锁,所以没办法解决线程饿死问题。

配合使用

java 复制代码
package Thread11_16;

import java.util.Scanner;

public class demo19 {
    public static void main(String[] args) {
        Object locker = new Object();
        Thread t1 = new Thread(()->{
            System.out.println("wait之前");
            synchronized (locker){
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("wait之后");
        });
        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("输入一个数字,通知t1退出阻塞");
            int flag = scanner.nextInt();
            synchronized (locker){
                locker.notify();
            }
        });
        t1.start();
        t2.start();
    }
}

我们这里的输入操作,其实相当于一个阻塞,因为计算机也不知道用户什么时候会输入,但是用户的输入肯定是要优先处理的操作,所以只好一直等着,也就是阻塞。在此处的用处是,用户一直不输入,后续的通知操作就没法完成,相当于让用户决定什么时候通知t1。

我们上面提到,wait操作涉及先释放锁,阻塞完了再加上锁的内容,所以需要先加锁,才可以执行wait。但是notify并不涉及到释放锁,按理说不需要刻意加锁。但是在java中,给notify加锁是强制要求的。

需要注意的是,此处的锁对象必须一致,才会有阻塞通知的机制,如果不一样,这两个线程将毫无关联。

wait操作必须在notify之前才有用!

如果我们加入一段代码让线程1先阻塞十秒钟,保证我是先notify再wait阻塞。可以看到:

首先,在没有wait的时候我notify,并没有产生任何的异常和报错,并不会有什么副作用。其次,我们的wait在十秒后阻塞时,没法被通知唤醒,一直处于阻塞之中。

多个线程wait阻塞,一次notify时是随即唤醒的。

java 复制代码
package Thread11_16;

import java.util.Scanner;

public class demo20 {
    public static void main(String[] args) {
        Object locker = new Object();
        Thread t1 = new Thread(()->{

            System.out.println("wait1之前");
            synchronized (locker){
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("wait1之后");
        });
        Thread t2 = new Thread(()->{

            System.out.println("wait2之前");
            synchronized (locker){
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("wait2之后");
        });
        Thread t3 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("输入一个数字,通知线程退出阻塞");
            int flag = scanner.nextInt();
            synchronized (locker){
                locker.notify();
            }
        });
        t1.start();
        t2.start();
        t3.start();
    }
}


不过在实际业务中,一般这些wait都是干同样工作的,唤醒的先后顺序也没有那么重要。

notifyAll--唤醒所有被wait阻塞的线程。

不过虽然是同时唤醒t1,t2,但是唤醒之后还要重新加锁,所以还会涉及到随机调度问题。

wait可以传入时间参数规定阻塞时间

wait 和 join 类似,都提供两种版本:

"死等" 版本(无超时):如locker.wait(),会一直等待直到被 notify 唤醒;

"超时时间" 版本:如locker.wait(10000),最多等待指定时长(例中 10 秒),超时未被 notify 则自动结束等待。

当 wait 引入超时时间后,和 sleep 直观上很像:

两者都有 "等待时间";

两者都能被提前唤醒(wait 靠 notify,sleep 靠 Interrupt)。
关键差异:

使用前提:wait 必须搭配锁(先通过 synchronized 加锁,才能调用 wait);sleep 无需加锁即可使用。

锁的处理(synchronized 内部使用时):wait 会释放当前持有的锁;sleep 不会释放锁(会 "抱着锁睡",导致其他线程无法获取该锁)。

所以说在实际开发中很少用到sleep,因为sleep阻塞纯纯在浪费时间。

总结

这部分内容围绕 Java 并发中的内存可见性与 wait-notify 机制展开:前者是多线程下共享变量修改后其他线程可能无法感知的问题,源于 JVM 的缓存优化,可通过 volatile 关键字保证变量的主内存读写可见性与有序性,但无法保障原子性;后者是协调线程执行顺序的工具,需搭配同一锁对象并在 synchronized 内使用,wait 会释放锁以解决线程饿死问题,notify 随机唤醒一个 wait 线程、notifyAll 唤醒全部,它无需像 join 那样等线程结束,也区别于 sleep(需锁且释放锁),同时 wait 支持死等和超时等待两种版本。

练习题

java 复制代码
package Thread11_16;

public class demo21 {
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Object locker3 = new Object();
        Thread a = new Thread(()->{
            for (int i = 0; i < 10; i++) {
                synchronized (locker1){
                    try {
                        locker1.wait();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    System.out.print("A");
                }
                synchronized (locker2){
                    locker2.notify();
                }
            }
        });
        Thread b = new Thread(()->{
            for (int i = 0; i < 10; i++) {
                synchronized (locker2){
                    try {
                        locker2.wait();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    System.out.print("B");
                }
                synchronized (locker3){
                    locker3.notify();
                }
            }
        });
        Thread c = new Thread(()->{
            for (int i = 0; i < 10; i++) {
                synchronized (locker3){
                    try {
                        locker3.wait();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    System.out.println("C");
                }
                synchronized (locker1){
                    locker1.notify();
                }
            }
        });
        a.start();
        b.start();
        c.start();
        Thread.sleep(1000);
        synchronized (locker1){
            locker1.notify();
        }
    }
}

A只能唤醒B,B只能唤醒C,C只能唤醒A,构成循环。

线程一开始要先阻塞,防止随机调度导致第一次输出顺序有问题。

循环需要提供动力,在主线程先把线程A唤醒一次。

唤醒之前先加一个sleep是为了防止还没阻塞就先通知了。

另一种思路:

其实也可以先通知再阻塞:

java 复制代码
package Thread11_16;

public class demo22 {
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Object locker3 = new Object();
        Thread a = new Thread(()->{
            for (int i = 0; i < 10; i++) {
                System.out.print("A");
                synchronized (locker2){
                    locker2.notify();
                }
                synchronized (locker1){
                    try {

                        locker1.wait();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }

                }

            }
        });
        Thread b = new Thread(()->{
            for (int i = 0; i < 10; i++) {
                System.out.print("B");
                synchronized (locker3){
                    locker3.notify();
                }
                synchronized (locker2){

                    try {

                        locker2.wait();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }

                }

            }
        });
        Thread c = new Thread(()->{
            for (int i = 0; i < 10; i++) {
                System.out.println("C");
                synchronized (locker1){
                    locker1.notify();
                }
                synchronized (locker3){
                    try {

                        locker3.wait();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }

                }

            }
        });
        a.start();
        Thread.sleep(1000);
        b.start();
        Thread.sleep(1000);
        c.start();

    }
}

只是这个代码最后执行完了进程不会结束,因为最后c处于阻塞状态,不过我们设置一个sleep时间到时候再唤醒一下c就OK了。

相关推荐
悟空CRM服务2 小时前
我用一条命令部署了完整CRM系统!
java·人工智能·开源·开源软件
组合缺一2 小时前
Solon AI 开发学习 - 1导引
java·人工智能·学习·ai·openai·solon
百***49002 小时前
基于SpringBoot和PostGIS的各省与地级市空间距离分析
java·spring boot·spring
电摇小人2 小时前
科学备赛今年NOIP!!
java·开发语言
未若君雅裁2 小时前
LeetCode 18 - 四数之和 详解笔记
java·数据结构·笔记·算法·leetcode
Bug快跑-12 小时前
Java、C# 和 C++ 并发编程的深度比较与应用场景
java·开发语言·前端
chipsense2 小时前
隔离式电流采样方案:HK4V H00,高绝缘耐压,保障强电环境采样安全
安全·开环霍尔电流传感器
一RTOS一2 小时前
工业AI安监超脑,为智能建造打造“安全数字底座”
人工智能·安全
云安全联盟大中华区2 小时前
构建AI原生工程组织:关于速度、文化与安全的经验
人工智能·安全·web安全·网络安全·ai·ai-native