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

Java 多线程(四)—— 线程安全 与 volatile 与 单例模式

什么是线程安全

在进行多线程编程的时候,当我们编写出来的多线程的代码运行结果不符合我们的预期的时候,这时候就是 bug,这种 bug 是由于多线程的问题而产生出来的 bug 我们称之为 线程安全问题

当我们编写出来的多线程代码运行之后的结果符合我们的预期结果的时候,说明代码没有问题,这时候就是 线程安全

线程安全问题的产生与解决方案

线程安全问题的产生主要有 五个原因

线程的调度是随机的

这个原因是由操作系统产生的,CPU 是多核心的,在进行线程的调度的时候并不是等到线程彻底执行完才轮到下一个线程执行,CPU 使用的是抢占式执行,也就是说,这个线程可能执行到一半,就立马被剥夺了 CPU 资源,开始执行下一个线程,然后执行完一半,又将上一个线程调度回来,这是由随机性的,程序员无法通过代码应用层得知。

这个问题是无法改变的,这也就是为什么会产生线程安全问题的最根本的原因

多个线程对同一个变量进行修改

在之前的文章中就已经设计过这种情况的讨论,如果修改的外部类的成员变量,是会发生线程安全问题的,如果修改的是局部变量,那就会触发 “变量捕获的语法”,这时候是不建议进行修改的。

解决方法也很简单,就是加锁,通过 synchronized 进行加锁。

public class Demo2 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        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);
    }
}

线程的修改操作不是原子性的

这个问题其实和第二个问题是一样的,为什么修改同一个变量可能会发生线程安全问题,因为我们的修改指令并不是原子性的,也就说,这个操作并不是 CPU 执行一次指令就可以完成 count++ 的,count ++ 实质是由三条指令实现的,首先 load count 这个数值,然后进行 count +1 操作,最后将结果保存到内存里。

为了使修改操作是原子性的,所以我们使用加锁的方式来实现,也就是上面的代码。

内存可见性问题

这个问题是由于 JVM 优化而导致的,

public class Test {
    private static int flag = 1;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while(flag == 1) {
                
            }
            System.out.println("hello t1");
        });

        Thread t2 = new Thread(() -> {
            Scanner scan = new Scanner(System.in);
            System.out.println("请输入falg 的数值");
            flag = scan.nextInt();
        });

        t1.start();
        t2.start();
    }
}

在这里插入图片描述

即使我们修改了 flag 数值,但是程序依旧没有反应,说明在 t1 线程中读取到的 flag 依旧还是 1

一个线程涉及到了读操作,一个线程涉及到了修改操作,这可能会触发线程安全问题,也就是内存可见性问题,读操作没有读到修改过的数值。

原因:JVM / 编译器 其实是带有优化功能的,因为不同的程序员写出来的代码不同,运行效率也是不同,为了提高代码的运行效率,JVM / 编译器 在不改变我们代码的逻辑的情况下,会对我们写的代码进行优化。虽然说对我们代码逻辑不会做出改变,但是在多线程编程下可能会发生误判。

例如上面的代码,t1 线程进行读 flag 操作,也就是寄存器会从内存中读取 flag ,但是这是一个 while 循环,在一秒钟之内就会读取很多次,虽然 t2 线程会对 flag 进行修改,但是 t2 线程在启动之前 flag 这个数值就被 t1 线程读取了 几千万次,所以编译器 / JVM 会认为 flag 是一个不会被修改的数值,即把这个读内存操作优化为 读寄存器操作,也就是把 flag 这个数值拷贝一份到寄存器里,这样 CPU 就直接从寄存器读 flag 数值而不用到 内存中读取了。

等到了 t2 线程开始运行的时候,我们进行修改 flag 数值,内存中 flag 即使被修改了,但是 t1 线程还是不知道flag 被修改了,因为此时它是从寄存器读取 flag 数值。


拓展一下,如果我们在 t1 线程 加上 sleep 的话,这个内存可见性问题就消失了。

    private static int flag = 1;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while(flag == 1) {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("hello t1");
        });

        Thread t2 = new Thread(() -> {
            Scanner scan = new Scanner(System.in);
            System.out.println("请输入falg 的数值");
            flag = scan.nextInt();
        });

        t1.start();
        t2.start();

    }

在这里插入图片描述

即使是 sleep 1 ms 内存可见性问题也没有发生,这是为什么?

因为读内存操作可能就是几 ns 的事情,优化为 读寄存器操作可以再快个几 ns,但是代码存在 sleep 1 ms ,这个 1ms 的存在,编译器/ JVM 即使优化这个读操作也不能让代码的效率有一个质的飞跃,所以干脆就不提升了。所以内存可见性问题也就不存在了。

JVM / 编译器的优化是一个很复杂的事情,具体的细节大家可以参考深入理解Java虚拟机 这本书,在后续文章中也会提到 JVM 的部分内容。


如何解决这个内存可见性问题???
使用 volatile 关键字

在这里插入图片描述

这个关键字的英文翻译的易变的,说明这个变量我是会进行修改的,你不能进行读操作的优化。

注意这个关键字只能修饰变量,不能修饰方法!!!

修改后的代码:

import java.util.Scanner;

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

            }
            System.out.println("hello t1");
        });

        Thread t2 = new Thread(() -> {
            Scanner scan = new Scanner(System.in);
            System.out.println("请输入falg 的数值");
            flag = scan.nextInt();
        });

        t1.start();
        t2.start();

    }
}

在这里插入图片描述


指令重排序问题

这个问题在下面的单例模式中的懒汉模式会提到~~

单例模式

单例模式是一种设计模式,也就是一个规范。

单例模式,顾名思义就是只允许一个对象的创建,也就是一个类只能创建实例化一个对象,不能进行多次实例化。这种设计模式的应用场景还是很多的,例如:我们在进行服务器开发的时候,我们需要一个对象来存放数据,这时候我们就会先写出类,然后再去创建对象,但是如果这个对象包含的数据很大,假如有100G,那么创建多次之后,也就是有几百G 的数据需要放在服务器上,并且这么多重复的数据也就只有一份是有用的,不仅仅是浪费了服务器的内存资源,还可能会导致服务器的崩溃,在这种情况下,我们通常使用单例模式来进行约束,只允许一个对象的创建。

饿汉模式

饿汉模式 是程序已启动,随着类的加载,对象也随之创建出来了,所以称之为 饿汉模式,说明创建的很快。

class Singleton {
    private static Singleton instance = new Singleton();

    private Singleton() {}
    
    public Singleton getInstance() {
        return instance;
    }
}

从上面的代码,我们就可以看到是要类一加载,对象instance 也就创建出来了private static Singleton instance = new Singleton();

为什么说我们不能进行多次创建呢?
因为这个类的构造方法被我们用private 修饰了,在外面是不能进行实例化的,这也是单例模式的点睛之笔。

我们来讨论一下,这个饿汉模式 的代码会不会出现线程安全问题?
答案是不会的,线程只是从getInstance() 进行读操作,获取 instance 这个对象,并没有涉及到修改操作,自然没有线程安全问题的存在。

懒汉模式

懒汉模式 顾名思义就是 懒,等我们真正需要这个对象的时候,才会进行实例化对象的操作。我们来看一下代码:

class SingletonLazy {
    private static SingletonLazy instance;
    
    public SingletonLazy getInstance() {
        if(instance == null) {
            instance = new SingletonLazy();
        }
        return instance;
    }

    private SingletonLazy() {}
}

当我们真正需要用到这个对象的时候,才进行实例化,这就是懒汉模式。

但是在多线程编程下,是可能会出现线程安全问题,由于代码涉及到写操作,也就是 实例化对象的操作,假设有两个线程同时进行对象的实例化,就会发生线程安全问题,所以要加上锁 synchronized .

class SingletonLazy {
    private static SingletonLazy instance;

    public SingletonLazy getInstance() {
        synchronized (this) {
            if (instance == null) {
                instance = new SingletonLazy();
            }
        }
        return instance;
    }

    private SingletonLazy() {}
}

但是每次进行判断的时候都需要进行加锁,这就导致效率低下,所以我们在外面再加一层 if 判断,减少加锁的次数。

class SingletonLazy {
    private static SingletonLazy instance;

    public SingletonLazy getInstance() {
        if(instance == null) {
            synchronized (this) {
                if (instance == null) {
                    instance = new SingletonLazy();
                }
            }
        }
        return instance;
    }

    private SingletonLazy() {}
}

即使代码被我们修改成这样,还是会存在一个问题,指令重排序的问题

我们在实例化一个对象有三条指令需要做:第一申请内存空间,第二初始化对象,第三将内存空间的首地址赋值给引用。

在编译器/JVM 下可能会进行优化,将上面的三条指令优化为先执行1,再执行3 ,最后执行 2.

这可能会导致一个线程还没初始化对象,另一个线程就直接拿到这个对象进行使用了,但是这些使用操作,在后面的初始化完之后又被覆盖掉了。这就是第五个引起线程安全问题的原因 —— 指令重排序。

在这里插入图片描述

如何解决这个问题???
使用 volatile 关键字

没错 volatile 关键字不仅仅能解决内存可见性问题,还能解决指令重排序问题。

private static volatile SingletonLazy instance;

懒汉模式最终代码

class SingletonLazy {
    private static volatile SingletonLazy instance;

    public SingletonLazy getInstance() {
        if(instance == null) {
            synchronized (this) {
                if (instance == null) {
                    instance = new SingletonLazy();
                }
            }
        }
        return instance;
    }

    private SingletonLazy() {}
}

http://www.kler.cn/news/358468.html

相关文章:

  • js模板式生成大疆上云kml文件(含详细注释,已封装成函数)
  • 爬虫学习——25.JS逆向(1)
  • 关于Qt中QMenu控件踩坑实录
  • Codeforces Round 980 (Div. 2) D 题解
  • element ui plus 版本 日期时间选择器的差异
  • UE5 gameplay学习1 蓝图修改材质和参数
  • 408数据结构-折半查找,分块查找 自学知识点整理
  • 101、QT摄像头录制视频问题
  • 有限状态机和抽象类多态
  • 再论保距变换概念让5000年都无人能识的N外标准自然数一下子浮出水面推翻百年集论
  • WebGL编程指南 - 颜色与纹理
  • 【AWS AMI跨境备份】跨境使用 S3 备份和还原 AMI 镜像
  • 最新版!《末日地带2》十四项修改器 增加健康/增加信心/设置游戏速度
  • Scala中的reduce
  • PROFINET开发EtherNet/IP开发Vline板卡在称重设备行业的应用
  • Python SQL 注入攻击及其防护措施:编写安全的数据库查询
  • 数据结构之链表——单向链表
  • Centos7系统Python3.11.2版本安装
  • 理解ES6中的模块
  • Leetcode刷题. 贪心算法