JavaEE初阶---多线程(三)---内存可见性/单例模式/wait,notify的使用解决线程饿死问题

文章目录

1.volatile关键字

1.1保证内存的可见性--引入

什么叫做可见性,就是你原本应该可以看见的东西,但是现在你没有看见,这个时候,我们采用这个volatile关键字的手段,保证这个可见性一定可以被看见,不可以出现你看不见的情况,就是让你必须看见---哈哈哈哈哈,这个是不是很奇怪,通过下面的这个案例以及分析就可以明白上面的这段话的意思了;

下面的这个线程,我们使用一个全局变量控制一个死循环,这个全局变量的默认数值大小就是0,这个时候我们的这个t1线程就会一直执行,但是我们的这个t2线程里面设置一个输入,我们期望输入一个不是0的数字,可以结束这个死循环的过程,但是我们可以测试运行发现,这个是不成立的;

就是即使我们输入这个1,这个时候的t1线程也不会停止下来

1.2保证内存的可见性--分析

这个时候上面的这个案例应该让你明白,就是我们的这个修改的过程应该是被看见,但是这个我们输入数据之后,这个线程并没有停止,就是"没看见",因为如果看见的话,这个线程不应该继续执行的;

我们的volatile关键字就是解决这个问题的,就是我们的可见性的问题,我们先来分析一下这个为什么没有看见:

首先这个isQuit=0的处理涉及到两个步骤,一个是我们的这个load操作,就是把这个数值从我们的内存读取到这个寄存器里面去,然后是这个cmp操作,就是把我们的这个值进行比较,决定是否需要继续这个循环的过程;

这个循环的过程很快,因此这个执行的过程中就会涉及到大量的这个load和cmp的操作,因此这个时候因为刚开始执行的时候我们没有进行修改这个时候每一次load的数值都是一样的,同时介于这个从内存里面进行数据的读取很浪费时间,因此这个时候编译器进行优化,就是我们的这个load不再执行,只进行这个cmp的操作;

因此这个时候的执行过程从本应该的load cmp load cmp load cmp load cmo变化成为了这个load cmp cmp cmp cmp...因此这个编译器的优化使得我们的这个修改无法被从内存里面进行读取,因此这个时候就会出现上面的这个不可见性的问题;

1.3保证内存的可见性--解决

我们的这个volatile关键字就是用来修饰这个变量的,这个时候我们输入1的时候,上面的这个线程就必须要结束,也就是说我们的这个修改他必须看见!!!!

这个时候,你走该明白为什么叫做保证内存的可见性了吧~~

1.4内存可见性-JMM内存模型

JMM就是java memory model简称,全程就是我们的java内存模型--这个是java的官方的规范文档上面的叫法

还是上面的这个问题,我们的官方文档上面使用这个JMM进行解释:t1线程对应的这个isQuit变量,本身是存在于这个主内存上面的,因为这个编译器的优化,这个isQuit变量就会被放到这个工作内存里面,我们在这个t2线程上面修改这个变量的时候,不会影响这个工作内存里面的内容;

其实这个官方使用的JMM解释和我们上面的这个寄存器和内存的解释逻辑是一样的,就是换了一个方式罢了,这个里面的主内存相当于是我们上面介绍的这个内存,他们说的这个工作内存相当于我们的这个cpu寄存器,理解即可,就是这个相同的逻辑使用不同的方式表达罢了;

2.notify和wait介绍

上面的两个方法都是我们的object里面的方法;

2.1作用一:控制调度顺序

wait执行的时候需要经过三个步骤:

1.释放当前的这个锁;

2.这个线程进入阻塞的状态;

3.等待线程被唤醒,唤醒的时候重新获取锁;

监视器实际上就是我们的这个synchronized关键字修饰对象,上面的这个原因就是因为我们的这个对象还没有上锁就被解锁了,这个wait的第一步就是释放当前的这个锁,但是我们这个线程其实就没有加锁,因此这个就是监视器的状态异常,我们需要先加上这个锁,然后才可以对于这个锁进行释放(也就是使用我们的这个wait关键字);

加上这个synchronized关键字之后,,这个线程相当于就是被上锁了,这个时候我们就可以正常释放锁,并且这个线程会处于这个阻塞的状态,但是没有线程唤醒这个线程,因此这个线程就会一直处于阻塞的状态;

下面的这个情况,我们创建两个线程,t1线程使用这个wait进行这个加锁的操作,然后使用这个wait方法的时候就是出于阻塞的状态,我们的这个t2里面使用这个notify对于这个线程进行唤醒操作,就是让这个t1线程的阻塞状态执行;

2.2作用二:避免线程饿死

下面的这个就是线程的一个形象的图示,在这个图里面,我们的这个锁就是我们的线程里面的这个锁,我们的这个滑稽1就是正在执行的线程,我们把这个调度执行的这个情形类比成为一个取款机的情形,就是我们想要取钱,但是这个滑稽1进去之后把这个门锁上了之后,其他的四个滑稽都是处于阻塞的状态,因此这个时候只能等这个滑稽1出来,但是这个滑稽1出来之后,想在进去看看这个时候有没有钱,这个时候自己又进去了,这样的话,可能我们的这个滑稽1一直在进进出出,但是其他的四个滑稽都是没有机会进入到这个里面去的。为什么会出现这个情况;

主要是我们的滑稽1本来就是处于这个CPU上面执行的,这个时候他想要再次执行,就是很容易的,但是对于这个其他的四个线程滑稽,如果他们想要执行,就需要被这个调度,这个调度的过程需要花费一定的时间,没有我们的这个滑稽1来的方便,因此这个时候就是会出现这个滑稽1一直进进出出,但是我们的其他的连进入这个取款机里面的这个机会都没有,这个情况是很常见的;

上面的这个一个线程一直在执行,但是其他的线程没有机会被调度就是属于我们的线程饿死的情况,想要解决这个线程饿死的情况,我们可以使用这个wait和notify进行处理;

使用这个wait之后,我们的这个线程就是按照上面说的三步操作,就是释放这个锁,然后处于阻塞的状态,具体到上面的这个例子里面,就是我们的线程1释放锁之后,处于阻塞的状态,这个时候我们其他的线程就有机会被调度,至于什么时候唤醒它,这个时候我们就可以控制了,滑稽1想要进去查看这个情况,至少需要我们的其他的滑稽都进去看了一遍之后,我们在唤醒它,这个时候就合理的解决了线程的饿死的情况,保证了线程都是会被调度的;

2.3notify和notifyAll区分

我们进行wait的线程可能是一个,其实可以是多个,这个时候,我们多个线程调用wait,都是处于阻塞的状态,这个时候,我们可以一次一次的进行唤醒,我们也可以使用这个notifyAll进行一次性全部唤醒;

3.单例模式--经典设计模式

单例:就是有的场景只需要每一个类只需要一个对象,不可以实例化多个对象;

这个情况下,可能有的人会说,我们设计程序的时候只new一次不就可以了吗,为什么会搞出来一个设计模式去处理这个问题,因为这个如果是程序员操控,可能会出现各种各样的问题,因为这个这个完全取决于我们程序员自己,有些时候如果哦我们忘记之类的,就会出现问题;

但是使用设计模式处理这个问题,就会交给这个编译器处理,如果一旦出现问题,这个机器肯定是会报错的,这个就是强制性的处理解决方案,因此这个时候就会变得更加的可靠,这个也是我们设计这个单例模式的一个原因,总之,很多事情,交给机器处理就是比交给人处理更加靠谱,这个主要是因为我们的人处理问题带有一定的不可靠性,但是我们的机器处理就是强制性的,遇到问题就是报错对于程序员进行提示,这个处理的方法更加的安全和稳妥;

3.1饿汉模式

下面的这个就是两种设计模式:饿汉模式和懒汉模式,两个模式的区别其实是很明显的,但是只听这个名字可能不是很清晰,我们集合下面的这个代码进行说明:

下面的这个就是一个实例,我们对于这个实例只允许其实例化一个对象,想要使用这个实例就是调用这个里面的方法,直接返回这个实例即可;

因为这个单例模式的主要的特点就是这个只可以实例化一个对象,因此我们把这个类的构造方法设计成为一个私有的,这样的话,我们的这个类是被封装的,里面只有一个实例,类的外面无法使用这个私有的方法,因此也就是无法进行这个对象的实例化;

但是这个饿汉模式很明显嘛,就是饿,因此这个创建实例的时间就是我们的这个类进行加载的时候就会进行这个实例的创建,这个就是饿汉模式;

3.2懒汉模式

和上面的这个饿汉模式不一样的就是我们的这个懒汉模式,就是懒汉,因此这个时候就就不会在很早的时候进行这个类的实例化的工作,因为上面的这个饿汉模式就是在类加载的时候进行这个类的实例化;

因为上面的这个在类加载的时候就创建这个实例可能我们暂时用不到,但是这个懒汉模式就是基于这个情况,我们的懒汉模式是在使用这个实例的时候进行这个实例的创建,这样的话我们一开始的这个实例就是空的,我们用到的时候,再次使用这个new进行实例的创建;

3.3设计模式和线程安全

上面的两个设计模式,各自都是有自己的特点的,但是两个设计模式哪一个会保证线程安全呢,这个懒汉模式其实线程就是不安全的,因为我们之前说过线程不安全的一个主要的原因就是对于这个变量进行修改;

在下面的这个饿汉模式的设计代码里面,只会涉及到去读操作,根本就谈不上修改的操作,因此这个就不会有这个线程的安全问题;

但是在我们的这个懒汉模式里面,因为这个判断之后进行实例,这个实际上就是一个先读取,然后就会进行修改,因为原来是空的,现在是一个新的实例,这个难道不是修改吗;

但是我们的这个懒汉就是初始化,因此这个没有涉及到这个修改的内容;

下面的这个形象的展示了我们的懒汉模式出现的这个线程安全问题的情况,我们的第一个线程进行读取判断的时候,发现是空的,这个时候就会准备进行修改,但是这个时候我们的t2线程开始执行,这个是穿插执行的,因此这个时候还没有等到这个t1线程进行修改,我们的这个t2线程就会再次进行判断,因此这个时候就会t2线程先进行修改,然后这个修改之后,轮到我们的这个t1线程执行,这个时候t1线程再次修改,这个其实就是线程不安全原因

3.4解决饿汉模式的安全问题

想要解决这个线程的不安全的问题,我们的解决方案和之前一样,就是加锁,我们需要进行这个对象的加锁,这个加锁想要解决这个问题,主要是解决这个交叉执行的问题,因此我们的这个加锁的范围就是我们的这个循环分支的范围;

这样的话,两个线程就不会出现上面的这个交叉执行的情况了;

这样写固然可以解决这个线程安全的问题,但是一旦加上之后,我们每一次调用这个getInstance方法的时候,都需要先加上锁,但是这个安全问题只会出现在这个最开始的时候,一旦创建出来之后,我们的这个就不存在线程安全了,因为第一次是没有创建对象,但是一旦创建对象,我们的线程安全不会存在问题了,但是我们这样写就会每一次调用这个getinstance方法的时候都会加锁,这个降低了我们的程序的效率,有些画蛇添足了;

3.5解决方案的优化

下面的这个就是在原来的基础上面加上我们的这个外层的if判断,判断我们的这个实例是不是被创建,这样的话,这个只会在第一次的时候去加锁,解决了我们上面说的这个每一次都需要加锁的情况;

上面的这个加锁,其实如果我们的这个实例已经存在,就不存在线程的安全问题了,这个时候加锁就没有必要了,因此我们判断这个时候是不是进行实例的创建,如果是已经创建,我们就不会加锁了;

我们的两个if内容一致,但是意义不同,第一个是判断是否需要加锁,第二个是判断是不是需要进行这个实例的创建,因为如果没有创建实例的话,我们需要自己去创建(这个是最开始的版本就存在的);

3.6指令重排序的解决

指令重排序也是我们的编译器优化的一个体现,这个也会对于我们的代码执行情况产生影响;

什么是指令的重排序,就是这个执行的先后顺序发生变化,这个变化也是编译器的优化导致的;

例如上面的这个new实际上就是三个步骤:

1.申请内存空间;

2.在内存空间上面使用构造方法创建对象;

3.把内存的地址,赋值给我们的instance引用;

上面的这个执行的顺序可能是这个123,但是如果出现了这个指令重排序的情况,这个的执行顺序就是132

执行13的时候,我们的这个instance已经不是一个null了,只不过这个对象没有创建,指向的是这个非法的内存区域,这个时候我们的这个t2线程进行判断,发现这个instance==null不成立,这个时候就会直接返回我们的instance实例,然后就可以对于这个实例进行操作;---------这个时候极容易出现bug!!!

解决上面的这个问题,就是使用我们的volatile关键字修饰,因为我们之前总结过但是没有介绍过的这个volatile就有这个解决指令重排序的特性;

因此经过上面的分析,下面的这个才是我们的单例模式(懒汉式)的最终代码,这个涉及到三次调整和改进,请仔细琢磨~~

相关推荐
feilieren8 分钟前
leetcode - 684. 冗余连接
java·开发语言·算法
The Future is mine19 分钟前
Java根据word模板导出数据
java·开发语言
一颗甜苞谷32 分钟前
开源一款前后端分离的企业级网站内容管理系统,支持站群管理、多平台静态化,多语言、全文检索的源码
java·开发语言·开源
星夜孤帆32 分钟前
Java面试题集锦
java·开发语言
论迹40 分钟前
【Java】-- 接口
java·开发语言
dawn1912282 小时前
Java 中的正则表达式详解
java·开发语言·算法·正则表达式·1024程序员节
葉A子2 小时前
poi处理excel文档时,与lombok的@Accessors(chain = true)注解冲突
java
鱼跃鹰飞2 小时前
大厂面试真题-简单描述一下SpringBoot的启动过程
java·spring boot·后端·spring·面试
大只因bug2 小时前
基于Springboot的在线考试与学习交流平台的设计与实现
java·spring boot·后端·学习·mysql·vue·在线考试与学习交流平台系统
想进大厂的小王2 小时前
Spring Boot⾃动配置
java·spring boot·后端