前言

hello hello💕,这里是洋不写bug~😄,欢迎大家点赞👍👍,关注😍😍,收藏🌹🌹
上篇线程博客中我们介绍了通过加锁来维护线程安全问题,但是不是所有线程安全问题都能通过加锁来解决,就比如内存可见性问题;有时加完锁之后,还要对加锁操作优化,提高效率,这篇博客就会针对这些方面来进行解析
在这里插入图片描述🎆个人主页:洋不写bug的博客
🎆所属专栏:JavaEE学习
🎆铁汁们对于JavaEE的各种常用核心语法,都可以在上面的前端专栏学习,专栏正在持续更新中🏀,有问题可以写在评论区或者私信我哦~

1,内存可见性问题

铁汁们看下面这段代码,逻辑上是没有一点问题的,在线程2中输入一个非0的数,这样线程1和线程2就都会结束

import java.util.Scanner;

public class demo21 {
    private static int flag = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while(flag == 0){

            }
            System.out.println("t1结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个flag的值");
            flag = scanner.nextInt();
            System.out.println("t2结束");
        });

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

    }
}



但是运行这个代码,t1却不会结束,只有t2结束了
在这里插入图片描述



打开线程检测平台来看一下,会发现这个线程1依旧处于运行状态,也就是while循环并没有结束
在这里插入图片描述



这就是内存可见性问题,t2对于flag变量的修改,t1线程是看不见的,看不见是因为编译器的优化策略

在Java中写的代码,编译器在编译执行时,会分析代码的意图和效果,然后对代码进行优化,确保在程序逻辑不变的情况下,提高执行效率(因为编译器的设计者考虑到Java程序员的水平是参差不齐的,这样做能降低大家写的代码的差距)

在大多数情况下,编译器优化后,都能保证逻辑不变的前提,但是在多线程的特殊情况中,就会出现“误判”,导致逻辑发生改变,前面的代码就是一个典型的误判的例子





在这里插入图片描述

对于线程1的这个while循环来说,编译器快速的,反复的读取这个值,来判断是否等于0
编译器要先通过load操作从内存中拿到这个值(load操作开销还是很大的),放到寄存器中,接着在cpu寄存器中进行比较(cmp操作)
线程2中修改之前,编译器已经读取了load了很多遍flag的值,但是发现每次都是一样的,就把load这个操作给优化掉了,直接从寄存器中读取值(寄存器中存放的还是之前读取的值)进行比较,这样这个循环的效率也就提升了

这个问题也很好解决,就需要我们使用一个关键字volatile(易变的),进而提醒编译器,这个变量是易变的,就不要在这里进行优化了

在定义flag变量时加一个volatile,代码就正常运行了

    private static volatile int flag = 0;

在这里插入图片描述




编译器可能会根据代码的不同而给出不同的优化策略,代码如下
就算不写volatile,会发现线程还是会正常结束,这是因为sleep的开销是远远要大于从内存中读取flag的值的开销的,另外循环速度也降低了,读取flag的频次也低了,而且每次sleep都会触发更新上下文,也就是重新读取,这几点综合下来,编译器就不优化falg读取了

import java.util.Scanner;

public class demo21 {
    private static int flag = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while(flag == 0){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }

            System.out.println("t1结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个flag的值");
            flag = scanner.nextInt();
            System.out.println("t2结束");
        });

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

    }
}

在这里插入图片描述




但是如果只在while循环中搞一个count++操作,那编译器就仍然会对falg读取进行优化,这是因为count++的开销小,速度快

import java.util.Scanner;

public class demo21 {
    private static  int flag = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            int count = 0;
            while(flag == 0){
               count++;
            }

            System.out.println("t1结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个flag的值");
            flag = scanner.nextInt();
            System.out.println("t2结束");
        });

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

    }
}

2,Java内存模型

JMM(Java Memory Modle) Java内存模型

如果铁汁们在网上找内存可见性问题的资料,会看到这样一种解释:

一个 Java 进程,会有一个 “主内存” 存储空间.每个 Java 线程又会有自己的 “工作内存” 存储空间.
形如上面的代码,t1 进行 flag 变量的判定,就会把 flag 值从主内存,先读取到工作内存,用工作内存中的值进行判定.同时,t2 对 flag 进行修改,修改的则是主内存的值,主内存的变更不会影响到 t1 的工作内存

这个主内存和工作内存的概念可能我们之前没有接触过,看的还是比较懵的
这段解释其实出自Java的官方文档,将main Memory翻译为了主内存,将work Memory翻译为了工作内存

其实跟我们之前说的还是一回事,这个main Memory就是内存的翻译,work Memory只是打了个比方,其本质上并不是内存,而是CPU寄存器和CPU缓存的统称



Java官方文档这样写,其实是用Java写程序的人,不需要了解底层存储,如果单从操作系统和硬件上来看,是没有“主内存”和“工作内存”这一说的
存储分为四部分:内存,缓存,寄存器,外存(硬盘)

寄存器的存储量是非常小的,现代的CPU引入了缓存,缓存比寄存器大,速度比寄存器慢

打开任务管理器中的性能,就会发现寄存器分为3级,存储大小:L1 > L2 > L3

在这里插入图片描述

存储是这样的,越往上,存储越小,速度越快
在这里插入图片描述

寄存器和三级缓存,就是官方文档中统称的“工作内存”
在这里插入图片描述

前面说内存可见性问题,就是编译器优化,把原来应该从寄存器中读取的数据,改为了从寄存器中读
严谨来说,也可能从L1,或者L2,或者L3级缓存中来读取

CPU缓存对于CPU执行性能的影响,还是非常大的,比较经典的处理器,就是AMD出的7800x3处理器,核心和线程数,还有频率都比较一般,但是3级缓存非常大,有96MB,因此玩游戏的时候体验就很好

3,执行顺序控制

线程本身是随机调度的,执行顺序不确定,我们使用join可以控制线程的结束顺序

但是有时候,我们希望两个线程能持续运行下去,只是在其中某一段环节内,线程1运行完成后,再让线程2执行
这时候就可以让线程2先wait线程1,等到线程1执行好逻辑好,再用notify激活线程2

另外,wait和notify的使用也能避免线程饿死问题,如下所示,线程A占用cpu资源,用锁锁上,其他线程在外面排队

过了一会这个线程A解锁,从cpu中出来,这时候又临时给这些线程分配了任务,因为其他线程还要等待操作系统的唤醒,那线程A就有很大的概率再次捷足先登,再次竞争到这个资源,这样排在后面的线程有的可能任务比较重要,但是很长时间竞争不到锁

在这里插入图片描述





线程饿死问题并没有死锁那么严重,死锁是程序会卡在这里,只能重启,而线程饿死只是线程拿到锁的时间比较长,程序的效率降低了

解决这个问题,就可以使用wait/notify,例如说让线程1先wait,那线程1就会把锁打开,不占用CPU资源,让其他线程来使用CPU资源,notify后线程1才能参与CPU资源的竞争

wait和notify都是Java Object类中的方法,因此,Java中随便一个类,都有这两个方法

铁汁们看下面这段代码,就是对object对象使用wait方法,这个wait方法会造成阻塞等待,所以要抛出个InterruptedException

public class Demo22 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        System.out.println("object wait之前");
        object.wait();
        System.out.println("object wait之后");
    }
}

在这里插入图片描述

但是在运行这个代码后,会抛出一个IllegalMonitorStateException(非法的监视器异常),这里的Monitor指的其实sychronized,因为sychronized在Java底层实现,被称为“监视器锁” 故这个异常的意思是“非法的锁状态”

如下图,如果想让房间中的滑稽不再竞争CPU资源,处于阻塞状态,就对这个滑稽使用wait,但是wait做的第一步就是要开锁,让这个滑稽出来
只有这样,其他的滑稽才能去竞争CPU资源
但是,前面的代码并没有锁,这样就出问题了

在这里插入图片描述

通过sychronized进行加锁操作后,这个代码就正常了,如下所示:

public class Demo22 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        System.out.println("object wait之前");
        synchronized (object){
            object.wait();
        }
        System.out.println("object wait之后");
    }
}





具体的使用代码如下:
首先让t1线程处于阻塞状态,接着t2线程拿到锁,占用CPU资源,执行激活t1线程的逻辑,激活t1线程后,t1才有竞争锁的资格,t1会进入队列中进行排队,等t2线程逻辑执行完成后,才能占用CPU资源

注:这里无论是wait还是notify,都要写在sychronized下面,并且要使用同一个锁对象

import java.util.Scanner;

public class Demo24 {
    private static Object locker = new Object();
    public static void main(String[] args) {

        Thread t1 = new Thread(() -> {
            System.out.println("t1 wait之前");
            synchronized (locker){
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

            System.out.println("t1 wait之后");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("输入内容,唤醒t1");
            scanner.next();
            synchronized (locker){
                locker.notify();
            }
        });
        t1.start();
        t2.start();
    }
}

在这里插入图片描述


线程使用wait时,阻塞是有两个状态的:

  1. 线程wait之后,处于waiting状态,这时候线程就在等待,连竞争锁的资格都没有
  2. 使用notify激活线程之后,线程才有竞争锁的资格,在没有竞争到之前,就处于blocked状态

这个wait就是死等,如果一直不notfy,那线程就一直没有竞争锁的资格,这里也可以设置死等的上限时间

在t1线程中设置下等待上限时间,1000ms,1s后,无论是否notify,t1线程都会被激活,代码如下所示:

public class Demo25 {
    private static Object locker = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            System.out.println("t1 wait之前");
            synchronized (locker){
                try {
                    locker.wait(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1 wait之后");
        });
        t1.start();
    }
}

在这里插入图片描述





那如果让t1,t2,t3三个线程都处于waiting状态,接着在t4中调用locker.notify方法,铁汁们猜下是会激活哪个线程

import java.util.Scanner;

public class Demo25 {
    private static Object locker = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            System.out.println("t1 wait之前");
            synchronized (locker){
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1 wait之后");
        });

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

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

        Thread t4 = new Thread(()->{
            System.out.println("请输入一段话,唤醒线程");
            Scanner scanner = new Scanner(System.in);
            scanner.next();
            synchronized (locker){
                locker.notify();
            }
        });
        t1.start();
        t2.start();
        t3.start();
        t4.start();
    }
}

这里激活t1线程的概率比较大,但是,locker.notify()是随机唤醒在这个锁对象上调用wait方法,处于waiting状态的一个线程
在大部分情况下,这里t1线程最先start,最先wait也是最早进入等待序列的,系统都会唤醒等待序列的第一个线程

那既然这样,为什么还说唤醒线程是随机的呢?
这是因为在部分场景下,虽然t1的start在t2之前
但是系统还是会先调度t2运行,哪个线程先执行wait是不确定的,哪个线程先进入等待序列也不确定,因此,唤醒的线程是随机的

在这里插入图片描述

如果想要唤醒所有在waiting状态下的线程,就使用notifyAll()

 synchronized (locker){
               locker.notifyAll();
            }

在这里插入图片描述




有一道经典的面试题,问wait和sleep有啥区别?

  1. wait的设计是为了让notify来唤醒的,设定超时时间,是留后手,也就是planB
    sleep的设计就是为了到时间以后唤醒,虽然也可以通过interrupt来提前唤醒,但是这样会产生异常(程序出现不符合预期的情况下才会产生异常)
  2. wait需要搭配锁来使用,wait执行时会先释放锁(也就是从房间出来,让其他线程可以进去)
    sleep不需要搭配锁来使用,当把sleep放到sychronized内部时,不会释放锁(相当于线程在房间中睡)

因此,在实际开发中,为了提升效率,更多还是会使用sleep

4,单例模式简介

设计模式就类似于我们在下象棋的棋谱,用棋谱上下法去应对对手的一些招式,那棋下的就不会差
设计模式就是大佬们设计的一套代码风格指导指南,如果按照设计模式来写代码,就会得到一个比较靠谱的代码(设计模式是对于程序员是软性要求)
有一本介绍设计模式的书,介绍了比较经典的23种,但设计模式是有很多很多种的



一个比较经典的设计模式就是单例模式,单例就表示单个实例(对象)
单例模式就是一个类只能创建一个实例
在实际开发中,有时候希望一个类只创建一个对象,就要用到单例模式
最经典的使用单例模式的场景就是MySQL JDBC中使用Datasource时
DataSource描述了数据库在哪里,通常情况下一个项目只用一个数据库
这时候我们就希望DataSource只有一个实例,就要用到单例模式

实现单例模式比较经典的两种方式就是饿汉模式和懒汉模式



下面这种就是饿汉模式,把构造方法设置为private,此时就无法在这个类外部来new一个对象了,只能通过getInstance方法来获取对象,代码如下所示

class Singleton{
    private static Singleton instance = new Singleton();
    public static Singleton getInstance(){
        return instance;
    }
    private Singleton(){
        
    }
}
public class Demo26 {
    public static void main(String[] args) {
        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();
        System.out.println(s1 == s2);
    }
}

在这里插入图片描述

注:这种单例模式主要是用来防止别人new多个对象的,主要在团队协作开发项目时会这样用,如果所有代码都是自己写的,那有没有单例模式是无所谓的,因为我们自己记得只创建一个实例就行了

那上面的写法为什么叫做饿汉模式呢?
这是因为在代码中,无论我们是否需要创建对象,类加载后都会创建出一个对象,创建对象是非常急切的,因此就叫做饿汉模式




下面这段代码不会立马就创建对象,只有在类外面第一次调用instance时,才会创建对象,这样看起来比较懒,因此就称之为懒汉模式,

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

    }
}

public class Demo27 {

    public static void main(String[] args) {
        SingletonLazy s1 = SingletonLazy.getInstance();
        SingletonLazy s2 = SingletonLazy.getInstance();

        System.out.println(s1 == s2);

    }
}

铁汁们可以分析下饿汉模式跟懒汉模式哪个比较好?

在Java中,其实“懒”是个褒义词,代表着高效率
试想这样一个场景,用户打开了一个几个GB的大文件在查看,有两种选择:

  1. 把整个文件都加载到内存中,读取完成后再让用户看,但是这样就会有明显的卡顿
  2. 只加载文章的一小部分,就立刻让用户查看,后面随着用户的翻页操作再来加载对应的内容(加载单页的速度非常快,所以用户是察觉不出来这个最初是没有加载的)

那肯定是选择第二种的

5,懒汉模式中的线程安全

那在饿汉模式和懒汉模式中,哪个可能会出现线程不安全的问题?

如下图,在懒汉模式中,如果t1和t2线程是这样调用的,那就会new两次对象
在这里插入图片描述
可能有的铁汁会这样想:先new了一个对象A并赋值给instance,后面再new了一个对象B赋值给isntance,那前面的对象A就没有引用指向它了,就会被Java的垃圾回收机制给释放掉,这样看最后还是一个对象,似乎对程序没什么影响呀

但是在实际开发中,单例类的构造方法是一个非常重量级的方法,公司中会通过单例类来管理整个服务器的所有依赖数据,实例创建的过程,会把硬盘上的资源加载到内存中(这个资源可能上百G),创建两次对象就会多耗时,效率就降低了

解决上述问题也简单,就是加锁,把if操作和创建新对象的操作打包成一个原子操作,代码如下所示

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





这样加锁线程的安全问题解决了,但是同时又产生了一个效率问题,只有在第一次调用的时候,可能会产生线程安全问题,只要对象创建完成,后续就直接return了,就不会产生线程安全问题了,但是还是会一直加锁,加锁也是要耗时的

优化也很简单,在外面再写个if判断,只有第一次没有创建对象的时候,才进行加锁操作

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

这个代码看起来是有点奇怪的,铁汁们在学习线程之前应该很少遇到过这种一模一样的条件,连续判断两遍的代码
这是因为以前都是一个线程,代码执行下来,第一次判定和第二次判定,结论一定是相同的
但是现在是多线程,第一次判定和第二次判定的结果可能不同,因为中间可能有另一个线程,修改了instance



铁汁们猜下这个懒汉模式的代码还有线程安全隐患吗?
其实是有的,这个跟编译器对代码的优化有关,前面提到过编译器对flag读取操作的优化,导致程序出错

编译器还会对我们的指令进行重排序,也就是调整代码的顺序,在保证逻辑一致的情况下,让代码的效率变得更高

这里创建SingletonLazy()对象这行代码就可能因为指令排序而出现安全问题

 instance = new SingletonLazy();

这行代码涉及到的指令是非常多的,可以简化抽象为三个步骤:

  1. 申请内存空间
  2. 在内存空间上进行初始化(构造方法)
  3. 把内存地址保存到引用变量中

那编译器优化时把这三个步骤的顺序可能会优化为1 3 2,在单线程中这样优化是完全没问题的,但是在多线程中,就会出错了,如下图:

如果t1线程进行完第三步后,把内存地址保存到引用中,这时候t2线程调用getInstance方法,这个instance的值不是null,t2就会直接拿到对象,但是这时候这个对象还没有初始化,t2线程中如果用这个对象来调用方法或者属性,就会报错

在这里插入图片描述

解决这个漏洞也很简单,就是定义引用变量时加一个volatile,告诉编译器不要在这个地方进行指令优化了

    private static volatile SingletonLazy instance = null;

注:这种编译器重排序指令造成的安全问题,随着现在JVM的优化,这个问题触发的概率是极低的,可能试个成千上万次,也不会出现问题
但是在写代码时最好加上volatile,是比较保险的做法

如何解决懒汉模式下的线程安全问题,在面试中是一个高频问题:
就主要从以下三个方面回答

  1. 加锁,把if操作和创建新对象的操作打包成一个原子操作
  2. 外面再加上个if判断,确保只有在第一次调用getInstance的时候才加锁,提升效率
  3. 定义引用变量时加上volatile,避免编译器对指令进行重排序而造成线程安全问题

结语💕💕

这篇博客还是有两个面试的高频考点,一个是sleep和wait的区别,一个是如何解决懒汉模式下的线程安全问题,铁汁们可以参考博主写的来组织下自己的语言

以上就是今天的所有内容啦~完结撒花~🥳🎉🎉
在这里插入图片描述

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐