自学内容网 自学内容网

JavaEE-多线程初阶(4)

目录

回顾上节

1.线程安全问题

2.解决线程安全问题

1.关于死锁

1.1 死锁的概念

1.2 产生死锁的三种情况

情况一

情况二

情况三

1.3 如何避免死锁

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

1.3.2 避免死锁

1.4 死锁小结

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

2.1 线程不安全

2.2 线程安全,采用锁机制

2.3 线程安全,没有加锁

3.再谈线程安全问题

3.1 内存可见性

【案例】

volatile关键字

JMM


回顾上节

1.线程安全问题

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

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

3)修改操作不是原子的

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

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

2.解决线程安全问题

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

2)如何加锁:

synchronized(锁对象){

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

}

3)synchronized的变种写法:

在方法内使用synchronized

用synchronized修饰方法

4)可重入


1.关于死锁

1.1 死锁的概念

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

1.2 产生死锁的三种情况

情况一

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


情况二

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

一个死锁的案例:

    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进行修改 :

    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:这才是我们真正所说的内存


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


原文地址:https://blog.csdn.net/NoobNo2/article/details/143526930

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!