我要学编程(ಥ_ಥ)

我要学编程(ಥ_ಥ)

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

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

目录

重复加锁——可重入锁

死锁

现象

原因、解决方法 

内存可见性

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);
    }
}

代码分析: 

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

虽然我们上面分析的是对的,但代码运行的结果却是正确答案,是因为 在 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);
    }
}

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

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

原因、解决方法 

死锁形成的原因有四点:

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关键字 的学习之旅就到此结束啦!我们下一期再一起学习吧!

10-31 22:37