JavaEE-多线程初阶(4)

目录

回顾上节

1.线程安全问题

2.解决线程安全问题

1.关于死锁

[1.1 死锁的概念](#1.1 死锁的概念)

[1.2 产生死锁的三种情况](#1.2 产生死锁的三种情况)

情况一

情况二

情况三

[1.3 如何避免死锁](#1.3 如何避免死锁)

[1.3.1 构成死锁的四个必要条件](#1.3.1 构成死锁的四个必要条件)

[1.3.2 避免死锁](#1.3.2 避免死锁)

[1.4 死锁小结](#1.4 死锁小结)

[2. Java标准库中的线程安全类](#2. Java标准库中的线程安全类)

[2.1 线程不安全](#2.1 线程不安全)

[2.2 线程安全,采用锁机制](#2.2 线程安全,采用锁机制)

[2.3 线程安全,没有加锁](#2.3 线程安全,没有加锁)

3.再谈线程安全问题

[3.1 内存可见性](#3.1 内存可见性)

【案例】

volatile关键字

JMM


回顾上节

1.线程安全问题

1)[根本]随机调度,抢占式执行

2)多个线程同时修改同一个变量

3)修改操作不是原子的

4)内存可见性(本节讨论)

5)指令重排序(本节讨论)

2.解决线程安全问题

1)锁的概念:互斥/排他

2)如何加锁:

synchronized(锁对象){

一些要保证线程安全的代码

}

3)synchronized的变种写法:

在方法内使用synchronized

用synchronized修饰方法

4)可重入


1.关于死锁

1.1 死锁的概念

具体看上篇文章可重入锁部分

1.2 产生死锁的三种情况

情况一

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


情况二

两个线程,两把锁,每个线程获得一把锁之后,尝试获取对方的锁

一个死锁的案例:

java 复制代码
    public static void main(String[] args) {
        Object locker1=new Object();
        Object locker2=new Object();
        Thread t1=new Thread(()->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2){
                    System.out.println("t1线程两个锁都获取到");
                }
            }
        });
        Thread t2=new Thread(()->{
            synchronized (locker2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker1){
                    System.out.println("t2线程两个锁都获取到");
                }
            }
        });
        t1.start();
        t2.start();
    }

执行代码:

【注意】

必须是,拿到第一把锁,再拿第二把锁(第一把锁不能释放),也就是必须要锁嵌套:

为什么要加sleep?

如果不加入sleep,很可能线程t1 一口气就把两把锁都获取了,此时线程t2还没有开始,自然不会构成死锁


情况三

N个线程,M把锁

一个经典的模型,哲学家就餐问题:

现有五个哲学家 均匀地坐在圆桌周围,圆桌中间有一碗面,并且桌上有五根筷子 ,每个哲学家的左右手都有一根筷子 ,当哲学家同时拿到左右手的两根筷子 时就可以吃面,否则等待

此时的哲学家有两种操作

1.思考人生(放下筷子,思考)

2.吃面条(拿起左右手的筷子)

并且这 五个哲学家 随机触发 吃面条思考人生这两个操作。

这5个哲学家,就相当于5个线程

5根筷子,就相当于5把锁

每个线程只需要拿到两把锁即可

大部分情况下,上述模型可以很好的运转,但是在一些极端情况下会造成死锁比如:

同一时刻,大家都想吃面条,同时拿起左手的筷子,此时任何一个哲学家都吃不了面

而对于线程来说,这五个线程同时分别获取到 locker1、locker2....locker5,当每个线程获取第二把锁的时候,无论第二把锁的对象是locker1、locker2还是其他的,都会造成阻塞

1.3 如何避免死锁

要避免死锁,首先要知道死锁是如何构成的。

1.3.1 构成死锁的四个必要条件

1.锁是互斥的。一个线程拿到锁之后,另一个线程再尝试获取锁,就必须阻塞等待

2.锁是不可抢占的(不可剥夺)。线程1拿到锁,线程2也尝试获取这个锁,线程2必须阻塞等待,而不是线程2直接把锁抢过来

3.请求和保持。一个线程拿到锁1之后,不释放锁1的情况下,获取锁2

4.循环等待。多个线程,多把锁之间的等待过程,构成了循环,例如:

A等待B,B等待A 或者 A等待B,B等待C,C等待A

1.3.2 避免死锁

上述四个条件中,条件1和条件2是锁的基本特性,Java中的synchronized是遵循着两点的

破坏掉上述的 条件3 或者 条件4 任何一个条件都能够打破死锁。

对于条件三

如果先放下左手的筷子,再拿起右手的筷子,就不会构成死锁

也就是说,代码中加锁的时候,不要去"嵌套"

但是这种做法是不够通用

因为事实上,有些情况确实需要拿到多个锁,再进行某个操作(嵌套是很难避免的)
对于条件四

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

约定:每个线程加锁的时候,永远是先获取需要小的锁,后获取序号大的锁

如果序号小的锁被占用了,就阻塞等待,直到序号小的锁被释放,才拿起第一把锁(序号小的)最后再拿起第二把锁(序号大的)

1.4 死锁小结

1. 构成死锁的场景

a)一个线程一把锁=>可重入锁

b)两个线程两把锁=>代码如何编写

c)N个线程M把锁
2. 死锁的四个必要条件

a)互斥

b)不可剥夺

c)请求和等待

d)循环等待
3. 如何避免死锁

打破上述 c:把嵌套的锁改成并列

打破上诉 d:加锁的顺序做出约定

2. Java标准库中的线程安全类

2.1 线程不安全

数据结构,集合类:

-ArraysList

-LinkedList

-HashMap

-TreeMap

-HashSet

-TreeSet

-StringBuilder

上面这些集合类自身没有进行任何加锁限制,线程不安全

2.2 线程安全,采用锁机制

但是还是有一些集合类是线程安全的,使用了一些锁机制来控制

-Vector(不推荐使用)

-HashTable(不推荐使用)

-StringBuffer

上面这三个集合类,属于在关键方法加了synchronized

虽然有synchronized,但是不推荐使用

原因:

加锁这个事情,不是没有代价的

一旦代码中使用了锁,意味着代码可能会因为锁竞争,产生阻塞=>程序的执行效率大打折扣
-ConcurrentHashMap

相比于HashTable来说,是高度优化的版本(后续详细分析)

2.3 线程安全,没有加锁

还有一些集合类,虽然没有加锁,但是不涉及"修改",仍然是线程安全的

-String

3.再谈线程安全问题

3.1 内存可见性

内存可见性也是造成线程安全问题的原因之一

【案例】

现有两个线程,一个int型成员变量flag,线程t1进行条件为flag==0的while循环,当while循环结束时,线程t1结束。而线程t2则使用scanner.nextInt()对成员变量flag进行修改 :

java 复制代码
    public static int flag=0;
    public static void main(String[] args) {
        Thread t1=new Thread(()->{
            while(flag==0){

            }
            System.out.println("线程t1结束");
        });
        Thread t2=new Thread(()->{
            //针对flag进行修改
            Scanner scanner=new Scanner(System.in);
            System.out.println("请输入flag的值:");
            flag=scanner.nextInt();
        });
        t1.start();
        t2.start();
    }

按理来说,执行代码后再输入一个值,flag的值就会改变,进而线程t1就会结束。

但是事实并非如此,执行代码:

程序并没有结束,说明t1线程还在继续执行(while循环)

很明显,这也是个Bug,也就是线程安全问题

一个线程读取,另一个线程修改,被修改的值并没有被线程读取到,这种问题被称为:

"内存可见性问题"
而产生这种问题的原因是编译器优化:

研究JDK的大佬们,希望通过编译器&JVM对程序员写的代码,自动进行优化。

本来写的代码是进行xxxx,编译器/JVM会在你原有逻辑不变的前提下,对你的代码进行调整

使程序效率更高

编译器,虽然声称优化操作是能够保证逻辑不变。

但是事实上,尤其是在多线程的程序中,编译器的判断可能会出现失误

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

对于上述案例代码,分析出现问题的原因:

对于while里的条件判断操作,可以看成是一个cmp这样的指令(条件跳转)

而在判断之前,首先要进行load操作(读取内存中flag的值,然后再进行判断)

对于这个while循环,短时间内会循环很多次,所以,while(flag==0){ }这个代码可以等效于:

while{

load

cmp

}

而对于load和cmp:

load:是读内存操作

cmp:是纯cpu寄存器操作

因此,load的时间开销可能是cmp的几千倍

对于上述循环而言,flag的改变取决于用户的输入(System.in),而这段时间对于计算机来说是很长的(在用户输入值的这段时间内,while循环已经执行了很多很多次了)

在这个执行过程中,JVM就能感知到,load反复执行的结果,好像都是一样的

JVM认为:既然结果都是一样的,为何还要反复执行折这么多次

于是,JVM就把读取内存的操作,优化成读取寄存器这样的操作

(把内存的值读取到寄存器,后续再load,不需要再读取内存,直接从寄存器里取)

于是,等到用户再输入值,修改flag时

此时的t1线程早已经感知不到了(编译器优化,使得t1线程的读操作,不是真正读内存)

如果稍微调整上述代码,给while循环内加入sleep(1),就不会出现这样的问题了:

执行结果:

原理:

因此,JVM就不再对此部分进行优化了

但是,针对内存可见性问题,也不能指望通过sleep来解决

使用sleep会大大影响到程序运行的效率

如何不使用sleep也能解决上述的内存可见性问题呢?


volatile关键字

Java语法中,有一个volatile关键字:

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

再次执行程序,没有出现内存可见性问题:

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

既然谈到了volatile,就不得不谈谈JMM(Java Memory Model,Java 内存模型)


JMM

Java内存模型,Java官方文档的术语:

每个线程,有一个自己的**"工作内存"(work memory),同时这些线程共享同一个"主内存"(main memory)。**当一个线程循环进行上述读取变量操作的时候,就会把主内存中的数据,拷贝到该线程的工作内存中。后续另一个线程修改,也是先修改自己的工作内存,拷贝到主内存里。由于第一个线程仍然在读自己的工作内存,因此感知不到主内存的变化

咱们前面讲的是,把读内存的操作,优化成读寄存器操作,其实是同样的意思。

【注意】

work memory:这里说的工作内存,其实并不是我们常说的"内存",就是指cpu的寄存器

main memory:这才是我们真正所说的内存


如果哪里有疑问的话欢迎来评论区指出和讨论,如果觉得文章有价值的话就请给我点个关注还有免费的收藏和赞吧,谢谢大家

相关推荐
诚丞成4 分钟前
计算世界之安生:C++继承的文水和智慧(上)
开发语言·c++
Smile灬凉城66616 分钟前
反序列化为啥可以利用加号绕过php正则匹配
开发语言·php
lsx20240627 分钟前
SQL MID()
开发语言
Dream_Snowar31 分钟前
速通Python 第四节——函数
开发语言·python·算法
西猫雷婶32 分钟前
python学opencv|读取图像(十四)BGR图像和HSV图像通道拆分
开发语言·python·opencv
鸿蒙自习室33 分钟前
鸿蒙UI开发——组件滤镜效果
开发语言·前端·javascript
星河梦瑾33 分钟前
SpringBoot相关漏洞学习资料
java·经验分享·spring boot·安全
黄名富36 分钟前
Redis 附加功能(二)— 自动过期、流水线与事务及Lua脚本
java·数据库·redis·lua
love静思冥想38 分钟前
JMeter 使用详解
java·jmeter
言、雲40 分钟前
从tryLock()源码来出发,解析Redisson的重试机制和看门狗机制
java·开发语言·数据库