当前位置:首页 » 《休闲阅读》 » 正文

JavaEE 初阶篇-深入了解多线程安全问题(指令重排序、解决内存可见性与等待通知机制)

27 人参与  2024年04月16日 11:51  分类 : 《休闲阅读》  评论

点击全文阅读


?博客主页: 【小扳_-CSDN博客】
❤感谢大家点赞?收藏⭐评论✍

文章目录

        1.0 指令重排序概述

        1.1 指令重排序主要分为两种类型

        1.2 指令重排序所引发的问题

        2.0 内存可见性概述

        2.1 导致内存可见性问题主要涉及两个方面

        2.2 解决内存可见性问题

        2.2.1 使用 volatile 关键字

        2.2.2 使用 synchronized 关键字

        3.0 线程的等待通知机制概述

        3.1 等待 - wait()

        3.2 通知 - notity()

        3.3 通知所有 - notifyAll()


        1.0 指令重排序概述

        指令重排序是指编译器或处理器为了提高性能,在不改变程序执行结果的前提下,可以对指令序列进行重新排序的优化技术。这种优化技术可以使得计算机在执行指令时更高效地利用计算资源,提高程序的执行效率。

        1.1 指令重排序主要分为两种类型

        1)编译器重排序:编译器在生成目标代码时会对源代码中的指令进行优化和重排,以提高程序的执行效率。编译器重排序时在编译阶段完成的,目的是生成更高效率的机器代码。

        2)处理器重排序:处理器在执行指令也可以对指令进行重排序,以最大程度地利用处理器的流水线和多核等特性。目的提高指令的执行效率。

        1.2 指令重排序所引发的问题

        虽然指令重排序可以提高程序的执行效率但是在多线程编程中可能会引发内存可见性问题。由于指令重排序可能导致共享变量的读写顺序与代码中的顺序不一致,当多个线程同时访问共享变量时,可能会出现数据不一致的情况。

        2.0 内存可见性概述

        在多线程编程中,由于线程之间的执行是并发的,每个线程有自己的工作内存,共享变量存储在主内存中,线程在执行过程中会将共享变量从主内存中拷贝到自己的工作内存中进行操作,操作完成后再将结果写回主内存。这里的工作内存指的是:寄存器或者是缓存。

        2.1 导致内存可见性问题主要涉及两个方面

        1)多线程并发操作抢占式执行导致内存可见性:如果一个现车给修改了共享变量的值,但其他线程无法立即看到这个修改之后的共享变量,就会导致数据不一致的情况。

        2)指令重排序导致内存可见性:由于编译器和处理器可以对指令进行重排序优化,可能会导致共享变量的读写顺序与代码中的顺序不一致,从而影响了线程对共享变量的可见性。

代码如下:

public class demo1 {    public static int count = 0;    public static void main(String[] args) {        Thread t1 = new Thread(()->{            while (count == 0){            };            System.out.println("线程 t1 结束");        });        Thread t2 = new Thread(()->{            Scanner scanner = new Scanner(System.in);            System.out.println("输出:");            count = scanner.nextInt();        });        t1.start();        t2.start();    }}

        t1 在启动线程之后,只要 count == 0 这个条件满足时,就会进入循环;t2 启动线程要求输出一个值并且将该值赋值给 count 。

        预想过程:只要输出一个非 0 的值时,那么 count 不为 0 了,t1 线程中的循环就会退出,因此会输出 ”线程 t1 结束“ 这句话。最后程序结束。

运行结果:

        输出 1 之后,按理来说,count 此时应该赋值为 1 了,那么 t1 中的循环应该要结束了并且得输出一段话。但是,看到结果,即使输出了 1 之后,t1 还在循环中。

原因如下:

        由于 t1 循环中的代码块里面是没有任何代码,无需任何操作,在 CPU 中主要执行两条指令:load 将内存中的 count 加载到寄存器中;cmp 将 count 与 0 之间进行比较。

        因为 cpm 执行这条指令直接在寄存器中操作,而 load 需要将内存的数据加载到寄存器中,这个操作的速度就比 cmp 的速度慢很多很多了。所以编译器重排序在生成目标代码时对源代码中的指令进行优化重排,将 count 变量存储到寄存器或者缓存中,目的为了提高执行效率。然而,t2 线程对 count 进行重新赋值后,将重新赋值后的 count 写回到主存中,但是 t1 线程是没有看到重新赋值后的 count 变量。因为对于 t1 线程来说,count 变量已经”固定“在工作内存中,没有重新加载主存中的 count 变量,而是反复读取自己工作内存中的 count == 0 这个变量。

        总而言之,指令重排序导致了内存可见性问题。

        2.2 解决内存可见性问题

        主要有两个方法:使用 volatile 关键字、使用 synchronized 关键字。

        2.2.1 使用 volatile 关键字

        volatile 关键字可以确保被修饰的变量对所有线程可见,禁止指令重排序。

代码如下:

当给 count 加上 volatile 关键时,编译器或者处理器就不会对指令重排序了

import java.util.Scanner;public class demo1 {    public static volatile int count = 0;    public static void main(String[] args) {        Thread t1 = new Thread(()->{            while (count == 0){            };            System.out.println("线程 t1 结束");        });        Thread t2 = new Thread(()->{            Scanner scanner = new Scanner(System.in);            System.out.println("输出:");            count = scanner.nextInt();        });        t1.start();        t2.start();    }}

运行结果:

        当输出 1 回车之后,count 就会重新赋值为 1 。从而 t1 中的循环退出,输出打印之后,整个进程就结束了。

        2.2.2 使用 synchronized 关键字

        可以确保同一时刻只有一个线程可以访问共享变量,同时保证了线程间的数据一致性。

代码如下:

import java.util.Scanner;public class demo1 {    public static int count = 0;    public static void main(String[] args) {    Object o = new Object();        Thread t1 = new Thread(()->{            synchronized (o){                System.out.println("线程 t1 开始");                while (count == 0){                    try {                        o.wait();                    } catch (InterruptedException e) {                        throw new RuntimeException(e);                    }                };                System.out.println("线程 t1 结束");            }        });        Thread t2 = new Thread(()->{                System.out.println("输出:");                Scanner scanner = new Scanner(System.in);                synchronized (o){                    count = scanner.nextInt();                    o.notify();                }        });        t1.start();        t2.start();    }}

运行结果:

        t1 线程在进入循环前会先获取对象 o 的锁,并在循环体中通过 o.wait() 释放锁并等待唤醒。当 t2 线程修改了 count 的值后,会再次获取对象 o 的锁并调用 o.notify() 唤醒 t1 线程,从而解除等待状态,保证了内存可见性和线程间的通信。

        

        3.0 线程的等待通知机制概述

        线程的等待通知机制是多线程编程中常用的一种同步机制,用于实现线程间的协作和通信。

        3.1 等待 - wait()

        线程调用对象的 wait() 方法时,会释放对象的锁并且同时进入等待状态,直到其他线程调用相同对象的 notify() 或者 notifyAll() 方法来唤醒它。在等待的过程中,线程会一直处于阻塞状态。 

        3.2 通知 - notity()

        线程调用对象的 notify() 方法时,会唤醒等待在该对象上的一个线程,若有多个等待唤醒的线程时,具体唤醒的线程是不确定的,使其从等待状态转为就绪状态,被唤醒的线程会尝试重新获取对象的锁,并继续执行。

        3.3 通知所有 - notifyAll()

        线程调用对象的 notifyAll() 方法时,会唤醒所有等待在该对象上的线程,使它们从等待状态转为就绪状态。被唤醒的线程会竞争对象的锁,只有一个线程能够获取锁并继续执行,其他线程会再次进入等待状态。

举个例子:

public class demo2 {    public static void main(String[] args) throws InterruptedException {        Object lock = new Object();        Thread t1 = new Thread(()->{            synchronized (lock){                try {                    lock.wait();                } catch (InterruptedException e) {                    throw new RuntimeException(e);                }                System.out.println("正在执行 t1 线程");            }        });        Thread t2 = new Thread(()->{            synchronized (lock){                try {                    lock.wait();                } catch (InterruptedException e) {                    throw new RuntimeException(e);                }                System.out.println("正在执行 t2 线程");            }        });        Thread t3 = new Thread(()->{            synchronized (lock){                lock.notify();                lock.notify();                System.out.println("正在执行 t3 线程");            }        });        t1.start();        t2.start();        Thread.sleep(1000);        t3.start();    }}

        t1 ,t2 线程都在阻塞状态,等待 t3 线程通知,但是 t3 线程还没释放锁,所以 t1 ,t2 线程继续阻塞状态。直到 t3 线程释放锁之后,t1,t2 线程就可以竞争获取锁,假设 t1 获取锁之后,执行完代码,释放锁,t1 线程结束。再到 t2 线程获取锁,执行完代码释放锁,t2 线程也结束。因此线程的先后顺序:t3 线程一定是最早结束的,接着到 t1 或者 t2 线程随机其中的一个线程。

运行结果:

补充:

        等待通知机制通常需要搭配 synchronized 关键字来确保线程安全。在Java中, wait()、notiyf() 和 notiyfAll() 方法必须在同步代码块或同步方法中调用,即在获取对象锁的情况下使用,以避免出现并发访问的问题。


点击全文阅读


本文链接:http://zhangshiyu.com/post/95966.html

<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1