当前位置:首页 » 《资源分享》 » 正文

初始JavaEE篇——多线程(3):可重入锁、死锁、内存可见性、volatile关键字

10 人参与  2024年11月20日 18:01  分类 : 《资源分享》  评论

点击全文阅读


找往期文章包括但不限于本期文章中不懂的知识点:

个人主页:我要学编程(ಥ_ಥ)-CSDN博客

所属专栏:JavaEE

目录

重复加锁——可重入锁

死锁

现象

原因、解决方法 

内存可见性

volatile关键字


初始JavaEE篇——多线程(2):join的用法、线程安全问题-CSDN博客

上文我们学习了 多线程的线程安全问题以及解决方法。下面我们针对加锁操作来继续深入学习。

重复加锁——可重入锁

针对 count++ 操作不是原子性,我们将其进行了加锁的操作,让其可以在执行时,不受操作系统调度的影响(即使调度了别的线程,也不能够进行count++ 操作)。虽然加锁是一个好办法,但是如果我们重复加锁呢?又会出现什么样的问题呢?

代码演示:

public class Test {    private static int count = 0;    public static void main(String[] args) throws InterruptedException {        Object locker = new Object();        Thread t1 = new Thread(()->{            synchronized (locker) {                for (int i = 0; i < 100000; i++) {                    synchronized (locker) {                        count++;                    }                }            }        });        Thread t2 = new Thread(()->{            synchronized (locker) {                for (int i = 0; i < 100000; i++) {                    synchronized (locker) {                        count++;                    }                }            }        });        t1.start();        t2.start();        t1.join();        t2.join();        System.out.println(count);    }}

代码分析: 

虽然我们上面分析的是对的,但代码运行的结果却是正确答案,是因为 在 Java 中,对于同一个对象的锁(使用synchronized关键字加锁的对象),当一个线程已经获取了该锁之后,再次对这个对象进行加锁操作,不会触发阻塞,而是直接继续执行。这是因为 Java 的锁是可重入锁

可重入锁的主要目的是为了避免死锁(上面分析出的死循环)和保证线程安全的情况下允许嵌套调用。例如,一个方法内部调用了另一个同步方法(被synchronized关键字修饰的方法),而这两个方法都使用了同一个对象的锁,如果不是可重入锁,那么在内部调用时就会发生死锁,因为线程已经持有了锁但又无法再次获取锁。而如果是其他线程尝试获取这个已经被某个线程持有的锁,就会正常阻塞,直到持有锁的线程释放锁。这是保证线程安全的基本机制,确保在同一时刻只有一个线程能够访问被锁保护的临界区。

可重入锁:一个线程针对一把锁进行重复加锁的操作,可以执行成功,不会阻塞等待。

实现原理:当有一个线程先拿到这把锁时,这把锁就会保留这个这个线程的信息,当下一次有线程继续进行加锁操作时,这个锁便会去检查是不是保留的这个线程,如果是的话,就会让其加锁成功,继续执行下去;反之,则会进行阻塞等待。

而针对多层加锁操作,什么时候解锁呢?用一个计数器来记录当前遇到的 {} 的数量。是 { ,就++,是 } 就 --,当 计数器为0时,就是解锁的时候了。

死锁

现象

接下来,我们就看真正的死锁。当线程1拿到锁1之后,另线程2也拿到了锁2,但是线程1在拿到锁1之后,还需要拿到锁2,来完成run方法,而线程2是需要拿到锁1,来完成run方法。但是线程1得等线程2执行完成之后释放锁2了,它才能拿到锁2,进而完成后面的run方法,线程2刚好相反,得等线程1执行完成之后释放锁1了,它才能拿到锁1。因此这就会造成互相等待,从而形成死锁的问题。

代码演示:

public class Test {    private static int count = 0;    public static void main(String[] args) throws InterruptedException {        Object locker1 = new Object();        Object locker2 = new Object();        Thread t1 = new Thread(() -> {            synchronized (locker1) { // 对locker1进行加锁                try {                    // 避免t1在t2拿到locker2之前,拿到了locker2                    Thread.sleep(1000);                } catch (InterruptedException e) {                    throw new RuntimeException(e);                }                for (int i = 0; i < 100000; i++) {                    synchronized (locker2) { // 对locker进行加锁                        count++;                    }                }            }        });        Thread t2 = new Thread(() -> {            synchronized (locker2) { // 对locker2进行加锁                for (int i = 0; i < 100000; i++) {                    synchronized (locker1) { // 对locker1进行加锁                        count++;                    }                }            }        });        t1.start();        t2.start();        t1.join();        t2.join();        System.out.println(count);    }}

上述代码由于线程之间相互请求、保持,因此形成了死锁。 

原因、解决方法 

死锁形成的原因有四点:

1、锁本身是互斥的,不能有多个线程同时访问。

2、锁是不可抢占的,不能有别的线程强行加锁给抢走了。

3、请求与保持。现象中描述的就是这种情况,线程既要其他的锁,又不肯放下这个锁。

4、循环等待。多个线程之间一直在等,不肯停下来。

对于1、2 是锁本身具有的特性,因此我们只能破坏3、4。

3 的解决方法是,尽量避免嵌套加锁的形式, 因为嵌套加锁会导致线程之间出现请求和保持的状况,虽然避免嵌套确实可以解决,但是有时候可能就需要嵌套呢。因此,我们更好的办法是针对 4,如果我们约定好线程之间的加锁顺序,那么当另一个线程去使用锁时,前面一个线程已经使用完了。

注意:频繁的加锁操作也是不可取的。因为加锁会使线程阻塞等待,那么就会造成效率低下。

内存可见性

前面我们知道了,造成线程安全问题的五大罪魁祸首:随机调度、多个线程同时修改同一个块内存空间、修改操作不是原子性、内存可见性、指令重排序。

接下来,我们学习内存可见性。CPU中有寄存器,用来存放当前线程中程序段需要的数据,以及计算的中间结果。当CPU频繁的从同一块内存空间中取出的值不变时,这时CPU就会直接绕过内存,去寄存器中取值。因为寄存器的存取速度是远远大于CPU的。编译器为了提高程序的运行效率,就会直接从寄存器中取值,因此后续内存中的值发生变化时,CPU不会感知到,其还是在寄存器中取值,因此这就导致程序除了BUG。因此这个内存可见性是编译器优化导致的。

代码演示:

public class Test {    private static int n = 0;    public static void main(String[] args) throws InterruptedException {        Thread t = new Thread(()->{           while (n == 0) {           }            System.out.println("t线程结束");        });        t.start();        System.out.println("请输入n的值:");        Scanner scanner = new Scanner(System.in);        n = scanner.nextInt();        System.out.println("main线程结束");    }}

即使上面我们输入了非零值,但是 t 线程依然还在跑。 

注意:

1、虽然我们认为感知是一瞬间就输入了,但是对于计算机来说,那就是桑海沧田了。因此CPU就是直接从寄存器中取值了。

2、上面的代码是一个线程(main)在修改,一个线程(t)在读取。

针对上述代码,我们可以直接暴力休眠即可,让线程休眠一段时间等待用户的输入,这样就不会出现CPU直接从寄存器中取值了。但是更好的做法是使用volatile关键字。

volatile关键字

接下来,就使用 volatile 关键字修饰变量,这是表明这个变量是易变的,这样编译器就不会擅作主张了。

代码演示:

public class Test {    private static volatile int n = 0;    public static void main(String[] args) throws InterruptedException {        Thread t = new Thread(()->{           while (n == 0) {           }            System.out.println("t线程结束");        });        t.start();        System.out.println("请输入n的值:");        Scanner scanner = new Scanner(System.in);        n = scanner.nextInt();        System.out.println("main线程结束");    }}

好啦!本期  初始JavaEE篇——多线程(3):可重入锁、死锁、内存可见性、volatile关键字 的学习之旅就到此结束啦!我们下一期再一起学习吧!


点击全文阅读


本文链接:http://zhangshiyu.com/post/189397.html

<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1