JavaEE初阶3.0

目录

四、线程安全(重重点)

[1.0 例子解析](#1.0 例子解析)

[2.0 产生线程安全问题的原因](#2.0 产生线程安全问题的原因)

[(1) 根本原因 操作系统对于线程的调度是随机的 抢占式执行](#(1) 根本原因 操作系统对于线程的调度是随机的 抢占式执行)

[(2)代码结构 多个线程同时修改同一个变量](#(2)代码结构 多个线程同时修改同一个变量)

[(3) 修改操作,不是原子的](#(3) 修改操作,不是原子的)

[(4)内存可见性问题 引起的线程不安全](#(4)内存可见性问题 引起的线程不安全)

(5)指令重排序,引起的线程不安全

[3.0 如何解决线程安全问题](#3.0 如何解决线程安全问题)

(1)锁:抽象的概念

(2)加锁的方式

[(3) 死锁的三种情况](#(3) 死锁的三种情况)

[(4) 内存可见性问题](#(4) 内存可见性问题)

[4.0 wait和notify方法](#4.0 wait和notify方法)

[五、多线程代码案例 单例模式](#五、多线程代码案例 单例模式)

[1.0 单例模式](#1.0 单例模式)

[(1) 定义](#(1) 定义)

[(2) 内容](#(2) 内容)

(3)方式

(4)懒汉饿汉是否是线程安全的


四、线程安全(重重点)

多线程带来的风险 整个多线程最关键的要点(面试 工作)

如果不理解线程安全问题 很难保证写出正确的多线程代码

1.0 例子解析

三个线程 t1线程 t2线程 主线程

我们想要在t1线程里面 变量count 从0到50000

在t2线程里面 也是加50000 在主线程打印 得到效果为100000

但是实际打印结果是0 为什么会这样

因为主线程先结束的 所以打印的是0

这个时候 我们的解决办法有两种

但是实际的结果是 58200 并不是10w 再跑一次变成了82863 在运行67899

这样的代码很明显就是bug(实际执行效果和预期执行效果不符合)

这样的问题其实是多线程并发执行引起的问题 如果是串行执行 就一直是10w

t1.start(); t2.start(); t1.join(); t2,join(); 结果是上面写的 58200等不规则(并发)

t1.start();t1.join();t2.start();t2,join(); 这样的结果就是稳定的10w(串行)

很明显 当前的bug是由于多线程的并发执行代码引起的bug

这样的bug 就称为"线程安全问题" "线程不安全"

反之,如果一个代码,在多线程并发执行的环境下,也不会出现类似于上述bug

这样的代码就叫做 "线程安全"

站在cpu执行指令的角度:

count++ 这个操作 在计算机上面至少需要三条指令

123线程 随机调度 12 3 1 2 3 123等等 并发执行

由于操作系统的调度是"随机"的,执行任何一个指令的过程中,都可能触发上述的

"线程切换"操作 正是这样的切换,导致我们后续出现的bug

记忆小妙招:main线程中 t.join() main等待t线程

反过来理解,t线程合并入main 两个执行流合并成为一个 一个执行流结束,另一个继续执行

通过下面讨论:一个线程的load得在另一个线程得save之后

每次执行都不一样 因为是随机调度 所以结果是多少就不确定了

但是结果肯定是小于十万的

三次自增 结果为1 四次自增 结果为1 都是有可能出现的

实际的调度顺序出现无数种可能

如果是循环50次而不是5000次 很可能在执行t2执行之前,t1就算完了

所以错误的概率会降低很多 很多都是100 多运行几次 也会出现不是100

2.0 产生线程安全问题的原因
(1) 根本原因 操作系统对于线程的调度是随机的 抢占式执行

抢占式执行策略 最初诞生多任务操作系统的时候,非常重大的发明~

后续的操作系统也是一脉相成的(20世纪那会 这种抢占式)

(2)代码结构 多个线程同时修改同一个变量

t1和t2都在修改同一个内存空间

(3) 修改操作,不是原子的

数据库里面的那个原子性 cpu不会出现 一条指令执行一半 这样的情况的

如果对应到多个cpu指令 就是不是原子的 +=就不是原子 =是原子

(4)内存可见性问题 引起的线程不安全

引入:创建两个线程 一个根据成员变量的值运行 一个修改成员变量

但是修改了成员变量的值之后 另一个线程并没有停止运行

很明显这个是一个bug 内存可见性问题

咱们写的代码编译器会自动优化(因为程序员的水平层次不齐) jvm会在你逻辑不变的情况下 对代码进行优化 但是 在多线程的程序中 编译器的判断可能出现失误(刚刚就是这样)

使优化后的逻辑 和优化前的逻辑出现细节上的偏差~

那么编译器怎么优化出这个问题的?

线程1里面 把读取内存的操作 优化成为读取寄存器这样的操作 于是等到后面很多秒之后用户整整修改值的时候 线程1就感知不到了 使得t1线程的读操作 不是真正的读内存

在t1线程中加上了sleep(1) 就会感知到了 哪怕休眠1毫秒 也能保证jvm不自动优化了

编译器的优化 本身是一个复杂的工程 具体怎么优化 咱们作为普通程序员很难感知的到

但是不能经常使用sleep 这样会大大影响到程序的效率

JDK大佬知道上述可见性的问题 在编译器优化的角度难以进行调整 在语法中,引入volatie关键字

通过这个关键字来修饰某个变量 此时编译器针对这个变量的读取操作,就不会被优化成寄存器

volatie(易变)

private volatie static int flag = 0 ; 这样相当于告诉编译器 这个是易变的

这样变量的读取操作就不会被编译器优化了 t2修改 t1就能及时看到了

volatile 解决内存可见性问题 不是解决原子问题 之前的count++ 是原子问题

这个是解决一个读一个写 只能修饰变量

(5)指令重排序,引起的线程不安全
3.0 如何解决线程安全问题

(1)操作系统的底层设定,咱们左右不了~~ 操作系统就是随即调度 抢占实行

(2)和代码结构有关直接相关的 调整代码结构,规避一些线程不安全的代码

但是有些情况下,需求上就是需要多线程修改同一个变量的~

(3)Java中解决线程安全问题 最主要的方案

加锁 通过加锁操作,让不是原子的操作,打包成为原子

(1)锁:抽象的概念

计算机中的锁 和生活中的锁,是同样的概念 互斥 排他

把锁 锁上 成为"加锁" 把锁"解开"称为"解锁"

就可以使用锁,把刚才不是原子的count++包裹起来

在count++之前,先加锁,然后进行count++ 计算完毕之后,再解锁

加锁操作,不是把线程锁死到cpu上,禁止这个线程被调度走

是禁止其他线程重新加这个锁,避免其他线程的操作,在当前线程执行过程中 插队

(2)加锁的方式

Java中使用的是 synchronized(同时的 一同发生)关键字搭配代码块来实现类似的效果的

sychronized (){ //执行一些要保护的逻辑 进入代码块就相当于加锁

代码块 } //出了代码块 就相当于 解锁

sychronized (填写用来加锁的对象){ count++ } 这样就把count++锁住了

Java中任何一个对象都可以用作锁 这个对象的类型是不重要 随便写个Object类型对象就可以了

重要的是 是否有多个线程尝试针对这同一个对象加锁(是否在竞争同一个锁)

如果是不同的锁对象 此时不会有互斥效果 线程安全问题 没有得到改变的

线程安全问题 不是你写了synchronized就可以的 而是要正确的使用锁


Java中为啥使用synchronized +代码块 做法?而不是使用lock+unlock函数方式来

其实在Java中,也有lock unlock风格的锁 一般很少使用

Lock locker = new Locker(); locker.lock(); ... locker.unlock() 其他语言一般都这么写

这种写法容易把unlock个遗漏的 (我不会的 我写了lock 就会立即加上unlock~~ 但是实际不一定做到 就算你非常细心,能够确保每个条件都加unlock 但是你们组新来的实习生 可能...)


java采取的synchronized 就能确保 ,只要出了 一定能释放锁 无论因为return 还是因为异常 无论里面调用了哪些其他代码 都是可以确保unlock操作执行到的

变种写法:可以使用synchronized修饰方法

synchronized public void add(){ } 加到public前面

synchronized修饰普通方法 相当于给this加锁

synchronized修饰静态方法 相当于给类对象加锁

(3) 死锁的三种情况

监视器锁 monitor lock

针对一个对象进行加锁 我们在写代码的时候总是不小心就写出来了

死锁

情况1 一个线程一把锁 -------->可重入锁

阻塞等待 等到前一次加锁被释放 第二次加锁的阻塞才会继续执行

当某个线程针对一个锁,加锁成功之后,后续该线程再次针对这个锁进行加锁 不会触发阻塞 而是直接往下走

死锁一个非常严重的bug 阻塞之后 这个线程就不动了

为了解决这个问题 引入了可重入的概念

一个线程一把锁 连续加锁两次

当某个线程针对一个锁,加锁成功之后 后续为该线程再次针对这个锁进行加锁 不会触发阻塞

而是直接往下走 (因为当前这把锁 就是被这个线程持有)

但是如果其他线程加锁 就会正常阻塞

情况2 两个线程 两把锁 每个线程获取到一把锁之后 尝试获取对方的锁

(钥匙锁车里了 车钥匙锁家里了) 这是一个嵌套锁

经典面试题:手写一个出现死锁的代码

情况3N个线程M把锁

滑稽拿起筷子吃面 例如4号吃的时候 3号和5号就需要阻塞等待

大部分情况下 上述模型可以很好的运动

但是极端情况下 会造成死锁 (5个线程同时执行的时候)五个人同时吃面

出现死锁的概率低 也不能忽视

咱们之后做的工作 服务器开发 同时能给很多个用户提供服务

(百度 一天要处理10亿量级的请求)

(4)如何避免代码中出现死锁

构成死锁的四个必要条件

#锁是互斥的(一个线程拿到锁之后 另一个线程再尝试获取锁 必须要阻塞等待)

#锁是不可抢占的 线程1拿到锁 线程2也尝试获取这个锁 线程2必须阻塞等待

而不是把锁抢过来

#请求和保持 一个线程拿到锁1之后,不释放锁1的前提下 获取锁2(拿起一边的筷子 尝试拿起另

一边筷子的时候 不会放下刚刚那边的筷子)

#循环等待 多个线程,多把锁之间的等待过程 构成了循环 A等待B B也等待A 或者A等B B等C

如何避免死锁 1 和 2 是锁的特性 不易更改 从3和4入手

代码中加锁的时候 不要去"嵌套" 这种做法通用性是不够的

循环等待 约定好加锁的顺序 就可以破除循环等待了

Java标准库中的线程安全类

其中除了concrrentHashMap 其他的在关键方法上加了synchronized

(4) 内存可见性问题

造成线程安全问题的原因之一

一个线程读取,一个线程修改 但是修改线程的值,并没有被读取到

程序员们的水平参差不齐,研究JDK的大佬们就希望通过编译器&jvm对程序员写的代码自动的优化本来代码是进行xxxxx,编译器会在你原有逻辑不变的前提下,对你的代码进行调整,使得程序的效率更高

编译器虽然声称优化,是能够保证逻辑不变 尤其是在多线程程序中,编译器的判断可能出现失误

可能导致编译器的优化 使得优化后的逻辑和优化前的逻辑 出现细节上的偏差

针对内存可见性问题 也不能指望通过sleep来解决~ 因为使用sleep大大影响到程序的效率(虽然1s中在现实世界里面很短 但是在计算机世界中 1毫秒都能干好多事情)

解决办法:

在编译器中引入volatile关键字 通过这个关键字来修饰某个变量 此时编译器针对这个变量的读取操作,就不会被优化成寄存器

volatile(易变的) private volatile static int flag = 0;

这样变量的读取操作,就不会被编译器进行自动优化了

JMM:Java Memory Model

work memory 应该理解成为 存储空间

Java文档上没有明确的说"寄存器" 而是使用更抽象的work memory表示 是为了能够兼容不同的设备 因为Java是跨平台的 程序员其实并不需要关心硬件(CPU)差别的

其实不同cpu 用来缓存上述内存数据的区域,可能是不同的

寄存器虽然快 但是空间太小 存不了多少东西 于是开发cpu的大佬就在cpu上另外建设了一些存储空间 称为 缓存 后来慢慢的发展为 寄存器 L1缓存 L2缓存 L3缓存

4.0 wait和notify方法

协调线程之间的执行逻辑顺序的

可以让后执行的逻辑 等待先执行的逻辑 先跑

虽然无法直接干预调度器的调度顺序 但是可以让后执行的逻辑(线程) 等待先执行的逻辑跑完了

通知一下当前的线程 让他继续执行

join也是等 join是等另一个线程彻底执行完,才继续走

wait也是等 等到另一个线程执行notify 才继续走(不需要另一个线程执行完)

线程饿死

多个线程竞争一把锁的时候 获取到锁的线程如果释放了 其他是哪个线程拿到锁

不确定(随即调度) 操作系统的调度是随机的 其他线程都属于在锁上阻塞等待 是阻塞状态

当前这个释放锁的线程 就是就绪状态 这个线程有很大的概率再次拿到这个锁

上述的场景 就是wait 和 notify的典型应用场景

object .wait( ) ; 第一件事,就是先释放object对象对应的锁

能够释放锁的前提是 object对象应该处于加锁状态 才是释放

object.notify() ;

这俩顺序是不一定谁先谁后 但是必须确保 notify的执行要在wait之后

如果有多个线程在同一对象上wait 进行notify的时候 是随机唤醒其中的一个线程

补充:

wait和join类似 也是提供了"死等"版本和"超时间"版本

locker.wait(1000) ; 表示最多等待1s 还没有notify 就不等了

wait和sleep的区别

五、多线程代码案例 单例模式

1.0 单例模式

设计模式相当于象棋里面的棋谱 棋谱能保证我们的下线 只要你照着棋谱来下象棋 不说你这个棋下的多好 但是一定不会差 设计模式是程序员的棋谱

大佬们把一些典型的问题场景,整理出来,并且针对这些场景,代码该怎么写,具体方案给出了一些指导 程序员掌握了设计模式 咱们写的代码再怎么差 也不会太差

框架属于是硬性要求 框架属于是软性要求

单例模式 就是设计模式中一种非常典型的模式 也是比较简单的模式 还是校招中最容易被考到的设计模式~~

(1) 定义

单例模式是指强制要求某个类 在某个程序中 只有唯一个实例(不允许创建多个实例,不允许new多次)

(2) 内容

JDBC代码的基本流程中的第一步 创建DataSource 描述了数据库服务器在哪里 这个datasource非常适合作为单例 描述数据库的信息

单例模式强制要求一个类不能创建多个对象 不是一个口头上的"君子协定" 需要通过机器/程序 ,强制要求~~~ 通过一些编程技巧达成上述的强制要求

在代码中 如果创建了多个实例 直接编译失败

(3)方式

饿汉方式

饿汉是迫切的 静态成员的初始化 是在类加载阶段触发的 类加载就是在程序一启动就会触发

下面是一个示例代码

java 复制代码
public class Singleton {
    private static Singleton instance = new Singleton();

    //提供了一个方法来得到instance的值
    public static Singleton getInstance(){
        return instance;
    }
    private Singleton(){
        //为什么空的构造方法也能创建对象?
        //构造方法的作用是用来初始化对象的 即使是空的 JMM仍然会
        //分配内存 调用构造方法(即使里面没有代码 也会执行默认的初始化)
        //返回对象引用
        //new Singleton()仍然会调用构造方法 只是构造方法里面没写任何逻辑
    }}

其中private Singleton是单例模式中的点睛之笔 在类外面new操作 都会编译失败

懒汉方式

懒和饿是相对的 饿是尽早创建实例 懒是尽量晚的创建实例(甚至不能创建了)

其实懒在计算机里面是一个褒义词 懒的另一面是高效率

例:假设有一个很大的文件 (千万字的小说) 编辑器打开 需要两步

1.把所有的内容 都从文件加载到内存中 再显示 (这样明显卡顿 就算加载那么多 你也看不过来)

2.只把一部分内容加载并显示 后续如果用户翻页 随着翻页 随时加载后续数据

下面是一个示例代码:

java 复制代码
public class SingletonLazy {
    private static  SingletonLazy instance = null;

    public static SingletonLazy getInstance(){
        //懒汉模式下 创建实例的时机 是在第一次使用的是偶
        //而不是程序启动的时候
        if (instance == null){
            instance = new SingletonLazy();
        }
        return instance;
    }

    private  SingletonLazy (){

    }
}
(4)懒汉饿汉是否是线程安全的

对于饿汉方式 只是涉及到读操作 很安全 就像String 不可变对象 天然线程安全

但是对于懒汉方式 其中if语句判断之后 才返回 这就可能涉及到多线程的修改~~

虽然是覆盖了 而且随着第二个线程的覆盖操作 第一个线程new出来的对象 也就会被GC给释放掉 但是不要忘记 new这个对象的过程中 有可能是把100G的数据从硬盘加载到内存

这样本来程序的启动时间是10min 由于上述的bug加载两份 导致最终的时间远远超过10min

加锁是一个思路

if(instance == null) {

synchronized(locker){

instance = new SingletonLazy();

}

} 这样 修改是原子的 但是如果每次都需要条件判断 和 修改

加锁的时间就多了 后执行的代码就会在加锁的位置阻塞 阻塞到前一个线程解锁

当后一个线程进入条件的时候 前一个线程已经修改完毕 instance不再为null 就不会进行后续的new操作 所以我们应该换一种新的写法

把方法加上锁 这样锁对象成为了类对象 和之前的locker相比没有什么区别

每次调用上述方法 都会触发一次加锁操作 虽然不涉及安全问题 但是多线程情况下 这里的加锁 就会相互阻塞 影响程序的执行效率 如何效率更高呢

按需要加锁 真正涉及到线程安全的时候 再加锁 不涉及的时候就不加锁

但是这样的代码 仍然存在问题

是否会存在:内存可见性"问题呢? t1线程在读取instance的时候,t2线程进行修改

t1读的真的是内存里面的吗

可能存在 编译器自动优化这个事情 非常复杂 但是为了稳妥可见 可以给instance直接加上一个volatile 从根本上杜绝内存可见性问题 (加个保险)

这里更关键的问题是 指令重排序 (也是编译器优化的一种体现形式)

编译器在逻辑不变的前提下,调整你代码的先后顺序 以达到提升性能的效果

例子 : instance = new Singleton Lazy();

这个过程需要哪几步呢?

1申请内存空间 2在空间上构造对象(初始化) 3内存空间的首地址 赋值给引用变量

正常来说 这三个步骤 按照123这样的顺序来执行的

申请内存空间----->买房(先要有地方住) 在空间上构造对象(初始化)------>装修(让房子能住人)调用构造方法,初始化对象的成员变量 内存地址赋值给引用变量--->拿到钥匙 内存空间的首地址会赋值给引用变量 这样才会指向这个对象

在单线程环境下,1 2 3还是1 3 2 其实无所谓 但是在多线程环境下,可能会出现bug

在t1线程完成那三个过程(1买房和3拿到钥匙)的时候(这个时候还没有初始化)

同时t2线程已经判断if外面是空 然后拿着未初始化的对象来进行操作

(锁只是阻塞 "竞争同一把锁"的线程 )

因为指令重排序的问题( 本来 123 现在132) 导致出现了bug

volatile 的功能有两方面:

确保每次读取操作,都是读内存(之前的笔记里面些过 为了解决内存可见性问题)

关于该变量的读取操作和修改操作,不会触发重排序

综上 这个懒汉方式的单例模式代码是:

java 复制代码
public class SingletonLazy {

private static volatile SingletonLazy instance = null;
    //3volatile关键字解决指令重排序或内存可见性问题
    private static Object locker = new Object();
    public static SingletonLazy getInstance(){
        if (instance == null){//2双层if 减少加锁操作带来的低效率  使得需要的时候才加锁
            synchronized (locker){// 1加锁操作应对多线程安全问题
                if (instance == null){
                    instance = new SingletonLazy();
                }
            }
        }   
         return instance;
    }
}

感谢大家的支持

更多内容还在加载中...........

如有问题欢迎批评指正,祝大家生活愉快、学习顺利!!!

相关推荐
兮山与1 个月前
JavaEE初阶2.0
javaee初阶