多线程(4)

上一篇我们已经知道了synchronized加锁来解决线程安全问题(修改操作不是原子的),synchronized修饰普通方法相当于给this进行加锁,synchronized修饰静态方法相当于给类对象加锁。

synchronized-监视器锁 monitor lock:JVM中采用的一个术语,使用锁的过程中抛出一些异常,可能会看到监视器锁这样的报错信息。

1 synchronized的特性:

1)互斥

多个线程针对同一个对象加锁才会产生互斥(锁冲突/锁竞争),这个上一篇已经说过了

2)可重入

要想理解可重入,我们先来了解一下死锁的一种情况

直接的代码示例:

在这段代码中,第二次synchronized就得阻塞等待,等到第一次加锁被释放,第二次加锁的阻塞才会继续执行。看起来是两次一样的加锁,没有必要,但是在实际开发中,很容易写出这样的代码

比如:

这样的代码,我们稍不注意就会写出来。这样的代码第一次加锁是可以成功的,因为锁没有被使用,第二次进行加锁,锁是被占用的状态,就得阻塞等待。要想解除阻塞,需要往下执行才可以,要想往下执行,就需要等到第一次的锁被释放......,这样的问题,就成为"死锁"。

值得注意的是这只是死锁的一种情况:一个线程,一把锁,连续加锁多次。其他的情况稍后再说

死锁是一个非常严重的bug,会使代码执行到这一块之后,就会被卡住。为了解决上述的问题,Java的synchronized就引入了可重入的概念。

可重入:当某个线程针对一个锁,加锁成功之后,后续该线程再次针对这个锁进行加锁。不会触发阻塞,而是继续往下走。但是如果是其他线程尝试加锁,就会正常阻塞。

可重入锁的实现原理:关键在于让锁对象内部保存当前是哪个线程持有的这把锁。后续有线程针对这把锁加锁的时候,进行对比,看锁持有的线程是否和当前加锁的线程是同一个

可以连续加锁多次:

最外层是真正加锁,最外层也是真正解锁。站在JVM的视角,看到多个}需要执行,那它如何知道哪个}是真正解锁的那个?

会先引入一个变量,计数器。每次触发{,计数器++,每次触发},计数器--,当计数器-- 为0的时候,就是真正需要解锁的时候。

那如何实现一个可重入锁呢?

1)在锁内部记录当前是哪个线程持有的锁,后续每次加锁,都进行判定

2)通过计数器,记录当前加锁的次数,从而确定何时真正解锁

2 关于死锁

上面已经知道了一种情况:一个线程,一把锁,连续加锁多次

第二种情况:两个线程,两把锁,每个线程获取到一把锁之后,尝试获取对方的锁

代码示例:

复制代码
public static void main(String[] args) throws InterruptedException {
    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();
    t1.join();
    t2.join();
}

值得注意的是:必须是拿到第一把锁,再拿第二把锁。必须是嵌套的关系

这样的代码就构成了死锁

那如果不加sleep,是否还会出现一样的现象呢?

那就得看具体的调度的顺序了。咱们加上sleep是为了确保t1拿到locker1,t2拿到locker2,等待1秒,t1尝试拿locker2,t2尝试拿locker1。如果不加sleep,很可能t1一口气把locker1和locker2都拿了,这个时候,t2还没开动呢,自然就构不成死锁。

第三种情况:N个线程M把锁

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

哲学家相当于线程。筷子相当于锁。大部分情况下,这个模型可以很好的运转,只有在一些极端情况下会造成死锁。

极端情况:同一时刻,大家都想吃面条,同时拿起了左手边的筷子(如上图所示),此时,任何一个线程都无法拿起右手边的筷子,任何一个哲学家都吃不成面条

那如何避免代码中出现死锁呢???

要想避免,得先知道死锁是如何构成的

构成死锁的四个必要条件(重点)

1)锁是互斥的

一个线程拿到锁之后,另一个线程在尝试获取锁,就得阻塞等待

2)锁是不可抢占(不可剥夺)的

线程1拿到锁,线程2也尝试获取这个锁,线程2必须阻塞等待,而不是线程2直接把锁抢过来

1)和 2)是锁的基本特性,Java的synchronized是遵守这两点的

3)请求和保持

一个线程拿到锁1之后,在不释放锁1的情况下,获取锁2

在上面的哲学家的极端情况中,如果先放下左手的筷子,再拿右手的筷子,就不会够成死锁

那可能就说了,代码中加锁的时候,不去嵌套不就可以了。但是,这种做法,不是通用的,有些时候就得嵌套

4)循环等待

多个线程,多把锁之间的等待过程,构成了"循环"。

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

解决办法:约定好加锁的顺序,就可以破除循环等待了

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

代码示例:

运行结果:

避免死锁:

上述1)和 2)的情况是不可避免的,3)可以把嵌套的锁改为并列的锁,4)对加锁的顺序作出约定

3 内存可见性

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

代码示例:

运行结果:

我们可以看到,虽然输入了非0的值,但是此时t1线程循环并没有结束。很明显,这是个bug,是线程安全问题。

一个线程读取,一个线程修改,修改线程修改的值,但并没有被线程读取到,这就是内存可见性问题。

要想知道内存可见性问题,得先谈谈编译器优化

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

值得注意的是,编译器,虽然声称优化操作是能够保证逻辑不变。但是在多线程的程序中,编译器的判断可能会出现失误,可能导致优化后的逻辑和优化前的逻辑出现细节上的偏差

对于,cmp这样的指令(条件跳转):,load是读内存操作,cmp是纯cpu寄存器操作,load的时间开销可能是cmp的几千倍。短时间之内,这个循环,就会循环很多次,执行过程中,JVM就能感知到load反复执行的结果好像是一样的。这时,JVM心想:我执行这么多次读flag的操作,值始终都是0,既然都是一样的结果,何必要反复执行这么多次呢。

这个flag的值取决于用户输入,不知道用户过多久才能输入

于是就把读内存的操作优化成读取寄存器的操作(把内存的值读到寄存器了,后续再load,就不再重新读内存了,而是直接从寄存器里取)。于是等到很多秒之后,用户真正输入新的值,真正修改flag,此时t1线程就感知不到了(编译器优化,使得t1线程的读操作,不是真正读内存)

如果我们微调上述代码,就会得到不一样的结果

代码示例:

运行结果:

本来这个while循环转的飞起,1秒钟转几千万次......,但是加了sleep(1)之后,循环次数大幅度下降了,当引入sleep之后,sleep消耗的时间相比于上面的load flag的操作,就不知道高了多少了。

但是针对内存可见性问题,不能指望通过sleep来解决,因为使用sleep会大大影响到程序的效率。

那该如何解决呢?

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

代码示例:

运行结果:

OK啦,到此结束!!!!!

相关推荐
小白不想画工图38 分钟前
银河麒麟V10系统安装QT5.12
linux·开发语言·qt·银河麒麟
吴声子夜歌1 小时前
Guava——散列
java·guava
用户3126874877201 小时前
AQS 到底怎么排队的?CLH 队列、state 与条件变量全链路拆解
java
Json____1 小时前
springboot开发高校选课管理系统:从零构建前后端分离的全栈实践
java·spring boot·后端·毕业设计·毕设·wwwoop.com
xcl09251 小时前
民宿预约系统开发实战:从需求分析到上线部署全流程解析
java·spring boot
IT_Octopus1 小时前
JSON 日志里的 `{“$ref“:“$.xxx“}`:从 fastjson 兼容包到原生 fastjson2 的迁移实录
开发语言·python·json
geovindu1 小时前
python:HandWriting Recognition using paddlepaddle
开发语言·后端·python·paddlepaddle
步行cgn2 小时前
为何以继承方式引入SpringBoot
java·spring boot·后端
CoderYanger2 小时前
A.每日一题:1140. 石子游戏 II
java·程序人生·算法·leetcode·游戏·职场和发展·深度优先