多线程编程的安全问题及其解决方法

1 操作不具有原子性导致的多线程安全问题

1.1 初识由于操作不具有原子性导致的多线程安全问题

代码 1.1a

public class ThreadSafetyDemo {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread( () -> {
            for(int i = 0; i < 500_000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread( () -> {
            for(int i = 0; i < 500_000; i++) {
                count++;
            }
        });
        // 开始 t1 和 t2 两个线程
        t1.start();
        t2.start();
        // 确保 线程 t1 和 t2 执行完毕
        t1.join();
        t2.join();
        System.out.println(getCount());
    }
    public static int getCount(){
        return count;
    }
}

经过了专栏的前几期的讲解,想必大家已经对多线程有了一些概念了,很多朋友一定已经想要进行多线程编程了。所以,我们开始第一步,运行上述代码,可以得到如下结果:

大家的结果不一定是这个数字,但不会是正常应该得到的 1_000_000。那么这是为什么呢?

为了更好的帮助大家进行理解,我要介绍关于原子性的概念。

原子性(Atomicity)表示一个操作是不可分割的,即该操作要么全部执行完毕,要么不执行不能被中途打断

上述的代码无法得到应有的结果,就是因为 count++ 的操作不是一个具有原子性的操作。count++ 的操作实际上分为三个步骤:

  • Load:把内存数据加载到寄存器中
  • Add:进行 +1 的运算
  • Save:把寄存器的数据写回内存中

这三个操作可能会因为并发执行被随意地打断。理想情况下的线程执行顺序应当是这样的:

当前线程{Load -> Add -> Save} -> 下一个线程{Load -> Add -> Save};

但实际上的线程执行情况往往是当前线程{Load} -> 下一个线程{Load -> Add} -> 当前线程{Add -> Save} -> 下一个线程{Save}(这仅仅是众多不理想情况中的一个,线程的操作的步骤会被随意地中断,打乱后执行),在这种情况下,如果当前线程执行前内存中的数据为0,那么执行的过程会如下所示:

  1. 当前线程 Load ,加载 0 到寄存器中;
  2. 下一个线程 Load ,加载 0 到寄存器中;
  3. 下一个线程 Add ,寄存器中的 0+1,得到 1 ;
  4. 当前线程 Add ,寄存器中的 0+1 ,得到 1 ;
  5. 当前线程 Save,将 1 存入内存中;
  6. 下一个线程  Save, 将 1 存入内存中;

这样,我们可以发现,虽然利用多线程执行了两次 count++ 的操作,但是 count 的值仅增加了 1。

关于操作不具有原子性从而导致的线程不安全的原因的一些总结:

  • 根本原因:操作系统层面上线程的“随机调度”和“抢占式执行”。
  • 直接原因:多线程执行的操作不具有原子性。
  • 多线程的代码结构:
  1. 一个线程可以修改一个变量时线程安全;
  2. 多个线程可以读取一个变量时线程安全;
  3. 多个线程分别修改各自的变量时线程安全;
  4. 多个线程同时修改一个变量时线程不安全

知道了导致线程不安全的原因,我们才能对症下药来解决问题。

  • 针对根本原因,由于“抢占式执行”是系统层面的问题,虽然我们理论上可以修改操作系统的内核,但是且不说这种方法是“远水解不了近渴”,即便大家付出了极大的学习成本和努力改变了系统的内核,也相当于重新写出了一个操作系统,难以兼容各种已有的可执行文件。
  • 针对代码结构,有些时候我们可以通过修改代码结构来解决线程不安全的问题,但是有些时候无法调整,比如业务逻辑上需要多个线程同时修改同一个变量或是对象时。
  • 针对直接原因,只能说这个软柿子终于落到我们的手里了,我们要往死里捏!我们可以通过来将几个指令打包到一起,形成一个具有原子性的整体

1.2 利用锁来对操作进行原子化

在Java中,我们通常使用已经封装好的监视器锁 synchronized 来进行加锁和解锁的操作。

代码 1.2a:

public class ThreadSafetyDemo {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread( () -> {
            for(int i = 0; i < 500_000; i++) {
                synchronized(locker) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread( () -> {
            for (int i = 0; i < 500_000; i++) {
                synchronized (locker) {
                    count++;
                }
            }
        });
        // 开始 t1 和 t2 两个线程
        t1.start();
        t2.start();
        // 确保 线程 t1 和 t2 执行完毕
        t1.join();
        t2.join();
        System.out.println(getCount());
    }
    public static int getCount(){
        return count;
    }
}

现在运行这段代码,可以用两个线程共同完成计数。

因为Java中的每一个对象都天然拥有一个 监视器锁 (Monitor Lock),所以 Java 中任何一个对象都可以作为 synchronized 代码块或是方法所使用的锁。我们既可以用新建的 Object 类作为锁,也可以使用已经存在的类作为锁。

1.2.1 监视器锁 synchronized 的特性

互斥性

某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到使用同一个对象作为锁synchronized 就会阻塞等待

  • 进入 synchronized 修饰的代码块相当于 加锁
  • 离开 synchronized 修饰的代码块相当于 解锁
以代码 1.2a 为例,假如线程 t1 正在执行,当执行到 synchronized(locker) 时, 此时线程 t2 进行了抢占式执行,那么当 t2 执行到  synchronized(locker) 时, 由于共同使用了同一把锁 locker,线程 t2 会进入阻塞状态,直到线程 t1 将被 synchronized(locker) 修饰的代码块执行完毕的时候,线程 t2 才能执行被  synchronized(locker) 修饰的代码块。
与此同时,运行代码 1.2b。
代码 1.2b:
public class ThreadSafetyDemo {
    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( () -> {
            for(int i = 0; i < 500_000; i++) {
                synchronized(locker1) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread( () -> {
            for (int i = 0; i < 500_000; i++) {
                synchronized (locker2) {
                    count++;
                }
            }
        });
        // 开始 t1 和 t2 两个线程
        t1.start();
        t2.start();
        // 确保 线程 t1 和 t2 执行完毕
        t1.join();
        t2.join();
        System.out.println(getCount());
    }
    public static int getCount(){
        return count;
    }
}
得到的结果不是我们想要的1_000_000。如果我们分别使用不同的锁来锁定两个线程,那么由于两个线程执行的 synchronized 代码块具有不同的锁对象,并不存在互斥性,所以两个线程的 count++ 命令依然存在并发执行,导致线程不安全依然存在。
 
因此要注意,我们需要 对共享资源进行操代码使用相同的锁对象,以确保线程间的 互斥执行线程安全。
在多线程环境下,如果多个线程同时修改 同一个变量,这个变量就被称为 共享资源
内存可见性

synchronized 的工作过程:

  1. 获得互斥锁;
  2. 从主内存拷贝变量的最新副本到工作内存;
  3. 执行代码;
  4. 将变更后的变量写入主内存;
  5. 释放互斥锁。

Java 中的 主内存 和 工作内存:

主内存:

  • 在 Java 内存模型中,主内存是所有线程共享的存储区域,主要用来存储共享变量(实例变量、静态变量等)的值。

  • 从物理层面上看,主内存通常对应于计算机的物理内存(即 RAM)。这是线程之间共享的主要存储器,当我们提到“主内存”时,实际上是指程序运行期间共享的数据存放在主内存中。

  • JVM 抽象出来的主内存是一个逻辑概念,尽管物理层面上主内存是 RAM,但对程序来说,主内存存储着所有的共享变量,它并不直接与某种硬件绑定。

工作内存:

  • 工作内存是每个线程私有的存储区域,线程的所有操作(读取、写入、计算等)必须在工作内存中进行。工作内存存储了从主内存中拷贝的变量副本。

  • 从硬件角度来看,工作内存可以对应于处理器的缓存(L1、L2 缓存)寄存器。处理器通常不会每次都直接从主内存中读取和写入数据,而是通过缓存来加快访问速度。

synchronized 也可以保证内存的可见性。关于内存可见性的概念,我将在 2. 由内存可见性导致的线程不安全 中进行详细的介绍。
可重入性

在运行代码 1.2c 之前,大家先停下来思考一下,“Hello World!” 是否能被成功地打印出来?

代码 1.2c:

public class SynchronizedReentrancy {
    public static void main(String[] args){
        Object locker = new Object();
        Thread t = new Thread( () -> {
            synchronized (locker){
                synchronized (locker){
                    System.out.println("Hello World!");
                }
            }
        });
        t.start();
    }
}
答案是能!这个时候大家就会产生疑问,为什么使用同一个对象加锁,线程依然能够运行?根据监视器锁的互斥性,第二个 synchronized 代码块处不是应该产生阻塞吗?
这是因为 Java 中的监视器锁具有可重入性, 同一个线程可以多次获取同一个锁。值得注意的是,如果一个锁不具有可重入性,那么多次在同一线程中使用它,却没有及时解锁,会造成 死锁
可重入锁内部会持有两个信息:
  1. 当前持有锁的线程:可重入锁内部会跟踪当前持有该锁的线程。每当一个线程尝试获取锁的时候,锁会检查自身是否已经被该线程持有。若它未被任何线程持有或是已被当前线程持有,则当前线程可以获取锁。
  2. 计数器:每次同一个线程获取锁时,计数器会递增。当此线程释放锁时,计数器递减。当计数器变为0时,锁被该线程释放,其他线程才可以获取该锁。

1.2.2 其他几种常见的 synchronized 用例

修饰代码块,锁当前对象
public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            // 代码逻辑
        }
    }
}
修饰普通方法
public class SynchronizedDemo {
    public synchronized void method() {
        // 方法的代码逻辑
    }
}

这种方法等价于:

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            // 整个方法的代码逻辑
        }
    }
}
修饰代码块,锁类对象
public class SynchronizedDemo {
    public void method() {
        synchronized (SynchronizedDemo.class) {
            // 代码逻辑
        }
    }
}
修饰静态方法
public class SynchronizedDemo {
    public synchronized static void method() {
        // 整个方法的代码逻辑
    }
}

这个方法等价于:

public class SynchronizedDemo {
    public void method() {
        synchronized (SynchronizedDemo.class) {
            // 整个方法的代码逻辑
        }
    }
}

1.2.3 锁的粒度

代码 1.2d:

public class ThreadSafetyDemo {
    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 < 500_000; i++) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread( () -> {
            synchronized (locker) {
                for (int i = 0; i < 500_000; i++) {
                    count++;
                }
            }
        });
        // 开始 t1 和 t2 两个线程
        t1.start();
        t2.start();
        // 确保 线程 t1 和 t2 执行完毕
        t1.join();
        t2.join();
        System.out.println(getCount());
    }
    public static int getCount(){
        return count;
    }
}

将代码 1.2a 和代码 1.2d 比较,两个代码都能多线程完成计数,那么两个代码的区别在于什么呢?

在代码 1.2a 中,synchronized(locker) 被包裹在 count++ 操作的每次循环中。因此,每次执行 count++ 操作时,线程都会获取 locker 的锁,修改 count, 然后立即释放锁。这意味着:

  • 每次自增操作 count++ 都需要加锁和解锁。
  • 由于每次循环都在获取锁,锁的开销更大,但锁住的代码块更小。
  • 更细粒度的锁控制,锁的持有时间较短。

在代码 1.2d 中,synchronized(locker) 包裹的是整个循环。这意味着:

  • 线程 t1 或 t2 在开始执行时,会一次性获取 locker 锁,并在执行完全部 500_000 次 count++ 操作后才释放锁。
  • 由于锁只获取一次并保持整个循环过程,锁的获取和释放此时减少,但锁的持有时间会比较长。
  • 这会导致第二个线程必须要等到第一个线程执行完所有操作后才能获取锁,锁的粒度较粗。

我们先来介绍一下 锁的粒度 的概念。

锁的粒度:是指在多线程编程中,锁定的代码块或资源范围的大小

锁的粒度与并发性、性能的关系:

  • 细粒度锁:

    • 优点:可以提高并发性,因为锁住的范围较小,不同线程可以在不同的锁定资源上并行工作。

    • 缺点:锁的获取和释放次数较多,锁的管理开销较大。如果锁住的代码频繁执行(如每次递增一个变量),会降低整体性能。

  • 粗粒度锁:

    • 优点:锁的获取和释放次数少,锁的管理开销较低。

    • 缺点:由于锁定的范围较大,会降低并发性,多个线程必须等待同一个大范围的锁释放,导致线程的等待时间较长,降低系统的吞吐量。

因此,两种编程方法并没有优劣之分,但是需要根据实际的需求对锁的粒度进行控制和取舍。

  • 细粒度锁适用于需要高并发性和频繁访问共享资源的场景,可以通过锁住更小的资源范围来提高线程的并行度。但在这种情况下,锁的管理开销较大,可能会影响性能。
  • 粗粒度锁适用于不频繁访问共享资源,或对性能要求不高的场景。虽然并发性较低,但锁管理开销较小,更容易实现。

2. 由内存可见性导致的线程不安全

要理解什么是内存可见性,我们要先看一组十分有趣的代码:

代码 2.1a:

import java.util.Scanner;

public class MemoryVisibilityDemo {
    private 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(() -> {
            System.out.println("请输入 flag 的值:");
            Scanner scanner = new Scanner(System.in);
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

代码 2.1b

import java.util.Scanner;

public class MemoryVisibilityDemo {
    private static int flag = 0;
    public static void main(String[] args){
        Thread t1 = new Thread( () -> {
            while(flag == 0) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("线程t1结束!");
        });
        Thread t2 = new Thread(() -> {
            System.out.println("请输入 flag 的值:");
            Scanner scanner = new Scanner(System.in);
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

当我们分别运行 代码 2.1a 和 代码 2.1b 的时候,会发生截然不同的结果。在终端中输入 1 ,代码 2.1a 的 t1 线程不会结束,而代码 2.1b 的线程 t1 则是会结束。我们写这段代码的目的就是在终端中输入一个非零的数字来终止线程 t1 内的循环,令其自然终止,但是代码 2.1a 为什么达不到我们的目的呢?为什么代码 2.1b 只是添加了一个 sleep 的指令,这个 bug 就消失了呢?

还记得我们在 1.2 章节中介绍的 Java 中的主内存和工作内存的概念吗?线程默认读取的值通常是从它自己的工作内存中读取的,用户通过输入值修改的是主内存中的 flag 值,但是线程 t1 读取的始终是工作内存中的值,无法读取到修改后的主内存中的 flag 的值。

而代码 2.1b 中,由于 sleep 方法会引发上下文的切换,上下文的切换过程中,CPU 通常会刷新工作内存中的数据,所以线程 t1 能读取到修改后的 flag 值。

几种常见的线程会主动从主内存中读取数据的情况:

  • 使用 volatile 修饰的变量
  • 同步块(synchronized)
  • 线程的上下文切换
  • 线程启动与终止时

关于同步块的部分,我们已经在 1.2.1 中的内存可见性部分进行了介绍。而线程的上下文切换和启动与终止则不具有可操作性,所以我们选择使用 volatile 修饰变量来强制线程从主内存中读取对应的数据。

代码 2.1c:

import java.util.Scanner;

public class MemoryVisibilityDemo {
    private static volatile int flag = 0;
    public static void main(String[] args){
        Thread t1 = new Thread( () -> {
            while(flag == 0) {

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

现在输入一个非零的数字,可以使线程 t1 结束。这是因为 flag 被 volatile 修饰后,当 flag 被修改的时候, volatile 关键字能确保修改能立即被刷新到主内存中,并且强制其他线程在读取 flag 的时候从主内存中读取,而非是从自己的工作内存中读取未刷新的旧值。

还有一点要注意的是,volatile 只能保证同步性, 而不能保证原子性!代码 2.1c 进行的只是一个简单的赋值操作,但是如果进行了类似 count++ 的操作,则仍旧需要 synchronized 来保证线程的安全。

3. 由指令重排序导致的线程不安全

代码 3.1a:

public class Singleton {
    private static Singleton instance;

    private Singleton() {
        // 初始化资源
    }

    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

这个代码看起来很合理,但是它依然存在着某种潜在的隐患。

instance = new Singleton() 实际上分为三步:

  1. 分配内存;
  2. 初始化 Singleton 对象;
  3. 将 instance 指向该内存地址。

但是指令重排序可能会导致这三步变为:

  1. 分配内存;
  2. 将 instance 指向该内存地址;
  3. 初始化 Singleton 对象。

在这种情况下,如果这个不具有原子性的操作在第三步初始化 Singleton 对象之前被打断的话,instance == null 就不成立了,这样的话,未成功初始化的 instance 就永远无法成功的初始化了,未完全初始化的 instance 可能会出现在其他线程中,引发线程安全的问题。

那么我们要如何避免这个问题呢?这个时候,我们就可以找到我们才认识的新朋友,volatile 关键字,它能够确保禁止指令重排序

代码3.1b

public class SingletonLazy {
        private volatile static SingletonLazy instance;

        private SingletonLazy() {
            // 初始化资源
        }

        public static SingletonLazy getInstance() {
            if (instance == null) { // 第一次检查
                synchronized (SingletonLazy.class) {
                    if (instance == null) { // 第二次检查
                        instance = new SingletonLazy();
                    }
                }
            }
            return instance;
        }


}

如代码 3.1b 所示,使用 volatile 修饰 instance 即可避免这个问题。

为了防止指令重排序,volatile 在编译时会在其前后插入内存屏障,以保证特定的指令执行顺序。在 JMM 中,volatile 的内存屏障可分为四种规则:

  1. 在 volatile 写操作前插入写屏障,确保之前的所有操作在写 volatile 变量之前完成。
  2. 在 volatile 写操作后插入写屏障,确保写 volatile 变量后的操作不会被重排序到该写操作之前。(在实例化操作中,三部操作都是写操作,volatile 保证的是每一步的写操作都不被重排序)
  3. 在 volatile 读操作前插入读屏障,确保 volatile 变量读取操作之前的操作不会被重排序到读取之后。
  4. 在 volatile 读操作后插入读屏障,确保读取 volatile 变量后的操作不会被重排序到读取之前。

提到原子化,大家一定马上就能想到 synchronized 关键字。直接用 synchronized 修饰 getInstance 的确可以保证线程安全。如代码 3.1c 所示:

代码 3.1c:

public class Singleton {
    private static Singleton instance;

    private Singleton() {}

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

它等价于:

public class Singleton {
    private static Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
        return instance;
    }
}

这样就会导致如果 Singleton 被其他线程调用加锁的时候,无论 instance 是否被初始化都要阻塞等待,导致程序性能的降低。所以我们选择 volatile 关键字这种高效的放重排序方法。

volatile 关键字特别适合用于只需要保证变量级别的有序性和可见性的场景。

4. 死锁

死锁是一个在多线程编程中常见且严重的问题。

4.1 常见的死锁情景

  1. 一个线程,一把锁。如果这个线程连续对一个非重入锁加锁两次或两次以上,且没有及时解锁,就既有可能会造成死锁。
  2. 两个线程,两把锁。线程1获取锁A,线程2获取锁B,然后尝试获取对方的锁。
  3. N个线程M把锁。哲学家的就餐问题。

哲学家就餐问题:

有 5 位哲学家围坐在一张圆桌旁,每位哲学家面前有一盘意大利面。在每位哲学家之间放有一根筷子,也就是说,共有 5 根筷子,且每根筷子放在相邻的两位哲学家之间。

哲学家们的活动包含两部分:

  1. 思考:哲学家在思考时不需要筷子。
  2. 进餐:哲学家要进餐时,必须拿起他左边和右边的两根筷子,并在进餐后放下这两根筷子。

由于每位哲学家都需要两根筷子才能进餐,而每根筷子只能由一位哲学家使用,可能会出现以下情况:

  • 每位哲学家拿起自己左边的筷子,等待右边的筷子被放下。
  • 结果是每位哲学家都拿着一根筷子在等待,造成所有哲学家都无法进餐,也不会放下筷子,导致死锁。

4.2 常见死锁情景的解决方案

4.2.1 一个线程,一把锁的情况

Java中的监视器锁是可重入锁,直接使用 synchronized 代码块或是 synchronized 关键字即可避免此种情况的死锁。

4.2.2 两个线程,两把锁的情况

代码 4.2.2a:

public class DeadLockDemo {
    public static void main(String[] args) {
        Object A = new Object();
        Object B = new Object();

        Thread t1 = new Thread(() -> {
            System.out.println("获取锁A,等待锁B!");
            synchronized (A) {
                try {
                    // 确保线程 t2 拿到了锁 B
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (B) {
                    System.out.println("t1 拿到了两把锁!");
                }
            }
            System.out.println("t1 拿着两把锁执行完毕!");
        });

        Thread t2 = new Thread(() -> {
            System.out.println("获取锁B,等待锁A!");
            synchronized (B) {
                try {
                    // 确保线程 t1 拿到了锁 A
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (A) {
                    System.out.println("t2 拿到了两把锁!");
                }
            }
            System.out.println("t2 拿着两把锁执行完毕!");
        });

        t1.start();
        t2.start();
    }
}

运行这段代码,我们会发现线程 t1 永远在等待锁 B,线程 t2 永远在等待锁 A。打开 jconsole 一看,两个线程全是 BLOCKED 状态!一根筋变成两头堵了家人们!

那么要解决死锁的问题,我们就要明白产生死锁的原因是什么。产生死锁有四个必要条件,也就是只要我们打破了其中的一个条件,死锁就不会再产生了。

产生死锁的必要条件:

  1. 互斥使用:获取锁的过程是互斥的,另一个线程想获取此线程已经获取的锁,就必须阻塞等待。
  2. 不可抢占:此线程获取锁后,就只能等待此线程主动解锁,其他的线程不能强行把锁抢走并获取。
  3. 请求保持:当一个线程拿到锁A后,在持有A的情况下,尝试获取锁B。
  4. 循环等待:一组线程形成了循环依赖的关系,每一个线程都在等待下一个线程持有的锁。

互斥使用不可抢占都是锁的基本特性,这意味着只要我们需要使用锁,它们就难以破坏。请求保持则是与代码结构相关,有时代码是有需求进行请求保持的,因此虽然破坏这个条件有可执行性,但是不具有普适性。那么我们就要专注于破坏循环等待的条件了,这个条件也是相对来说最容易被破坏掉的一个。

那么我们要怎样打破循环等待呢?那就是给每个锁都编号,然后指定加锁顺序。

代码 4.2.2b:

public class DeadLockDemo {
    public static void main(String[] args) {
        Object A = new Object();
        Object B = new Object();

        Thread t1 = new Thread(() -> {
            System.out.println("获取锁A,等待锁B!");
            acquireLocksInOrder(A, B);
            System.out.println("t1 拿着两把锁执行完毕!");
        });

        Thread t2 = new Thread(() -> {
            System.out.println("获取锁B,等待锁A!");
            acquireLocksInOrder(A, B);
            System.out.println("t2 拿着两把锁执行完毕!");
        });

        t1.start();
        t2.start();
    }
    private static void acquireLocksInOrder(Object lockA, Object lockB) {
        Object firstLock = lockA;
        Object secondLock = lockB;
        synchronized (firstLock){
            System.out.println(Thread.currentThread().getName() + "拿到了锁 A!");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            synchronized (secondLock){
                System.out.println(Thread.currentThread().getName() + "拿到了锁 B!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }

    }
}

代码 4.4.2b 中,每一个线程都必须先获取锁 A,再获取锁 B,这样就避免了死锁。运行此代码,可以得到类似这样的结果。

4.4.3 n个线程,m把锁的情况

代码 4.4.3a

public class DiningPhilosopherDeadLock {
    private Object[] chopsticks;
    private int numOfPhilosophers;
    public DiningPhilosopherDeadLock(int n){
        this.chopsticks = new Object[n];
        for(int i = 0 ;i < n; i++) {
            this.chopsticks[i] = new Object();
        }
        this.numOfPhilosophers = n;
    }

    private void dine(int philosopher) {
        Object leftChopstick = chopsticks[philosopher];
        Object rightChopstick = chopsticks[(philosopher + 1) % numOfPhilosophers];
        // 哲学家尝试拿起左边的筷子
        synchronized (leftChopstick) {
            System.out.println("哲学家" + philosopher + "拿起了左边的筷子,左边筷子的编号是" + philosopher);
            // 哲学家一拿起左筷子就开始思考人生了,避免了旁边的哲学家拿不到左筷子
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            // 哲学家尝试拿起右边的筷子
            synchronized (rightChopstick) {
                System.out.println("哲学家" + philosopher + "拿起了右边的筷子,右边筷子的编号是" +
                        (philosopher + 1) % numOfPhilosophers);
                System.out.println("哲学家" + philosopher + "拿到了一双筷子!");
            }
        }
        System.out.println("哲学家吃饱了!哲学家" + philosopher + "放下了筷子!");
    }

    public static void main(String[] args) {
        final int n = 5;
        DiningPhilosopherDeadLock diningPhilosopherDeadLock = new DiningPhilosopherDeadLock(n);
        // 每一个哲学家都有自己的思想,所以每一个哲学家都是一个独立的线程
        for(int i = 0 ; i < n; i++) {
            final int philosopher = i;
            new Thread(() -> {
                diningPhilosopherDeadLock.dine(philosopher);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }).start();
        }
    }
}

利用代码 4.4.3a 来模拟哲学家晚餐的问题,例子中有五名哲学家共同进餐。运行代码,发现哲学家们都拿起了自己左边的筷子。

丸辣!这下子五根筋变成五头堵了!哲学家们的线程全都 BLOCKED 了!

但是大家不要慌,我们在 4.4.2 中已经找到了解决死锁的办法,那就是打破循环等待。对于这个问题,我们可以规定编号为偶数的哲学家先拿左边的筷子,编号为奇数的哲学家先拿右边的筷子。如此这般,要么是相邻的两人都拿到了一只筷子,但是他们两人之间的筷子没有被拿走,可以两人中的一个拿走,用完以后再放回去;要么是其中一个人没抢过别人一只筷子也拿不到,他也懒得去拿另一只筷子了,只能乖乖等别人用完了筷子再去尝试拿筷子。

代码 4.4.3b:

public class DiningPhilosopherSolution {
    private Object[] chopsticks;
    private int numOfPhilosophers;
    public DiningPhilosopherSolution(int n){
        this.chopsticks = new Object[n];
        for(int i = 0 ;i < n; i++) {
            this.chopsticks[i] = new Object();
        }
        this.numOfPhilosophers = n;
    }

    private void dine(int philosopher) {
        Object leftChopstick = chopsticks[philosopher];
        Object rightChopstick = chopsticks[(philosopher + 1) % numOfPhilosophers];
        // 偶数编号的哲学家先拿左筷子,后拿右筷子
        if (philosopher % 2 == 0) {
            // 哲学家尝试拿起左边的筷子
            synchronized (leftChopstick) {
                System.out.println("哲学家" + philosopher + "拿起了左边的筷子,左边筷子的编号是" + philosopher);
                // 哲学家一拿起左筷子就开始思考人生了,避免了旁边的哲学家拿不到左筷子
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                // 哲学家尝试拿起右边的筷子
                synchronized (rightChopstick) {
                    System.out.println("哲学家" + philosopher + "拿起了右边的筷子,右边筷子的编号是" +
                            (philosopher + 1) % numOfPhilosophers);
                    System.out.println("哲学家" + philosopher + "拿到了一双筷子!");
                }
            }
            System.out.println("哲学家吃饱了!哲学家" + philosopher + "放下了筷子!");
        }
        // 奇数编号的哲学家先拿右筷子,后拿左筷子
        else {
            // 哲学家尝试拿起右边的筷子
            synchronized (rightChopstick) {
                System.out.println("哲学家" + philosopher + "拿起了右边的筷子,右边筷子的编号是" +
                        (philosopher + 1) % numOfPhilosophers);
                // 哲学家一拿起右筷子就开始思考人生了,避免了其他哲学家来不及尝试拿筷子
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                // 哲学家尝试拿起左边的筷子
                synchronized (leftChopstick) {
                    System.out.println("哲学家" + philosopher + "拿起了左边的筷子,左边筷子的编号是" + philosopher);
                    System.out.println("哲学家" + philosopher + "拿到了一双筷子!");
                }
            }
            System.out.println("哲学家吃饱了!哲学家" + philosopher + "放下了筷子!");
        }
    }

    public static void main(String[] args) {
        final int n = 5;
        DiningPhilosopherSolution diningPhilosopherSolution = new DiningPhilosopherSolution(n);
        // 每一个哲学家都有自己的思想,所以每一个哲学家都是一个独立的线程
        for(int i = 0 ; i < n; i++) {
            final int philosopher = i;
            new Thread(() -> {
                diningPhilosopherSolution.dine(philosopher);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }).start();
        }
    }
}

这样,我们可以观察到,五个哲学家都吃上了饭。

总而言之,遇到死锁时,大家要想办法打破循环等待

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值