【多线程】并发安全

JavaEE专栏入口

目录

一、体会线程安全问题

二、线程安全的概念

三、线程安全问题的原因

四、解决线程安全问题的方法

[4.1 synchronized 关键字](#4.1 synchronized 关键字)

[4.2 volatile 关键字](#4.2 volatile 关键字)

五、死锁

[5.1 synchronized 的可重入性](#5.1 synchronized 的可重入性)

[5.2 死锁的概念](#5.2 死锁的概念)

[5.3 如何避免死锁](#5.3 如何避免死锁)


一、体会线程安全问题

当我们编写一个多线程程序,要求两个线程对同一个变量(共享变量)进行修改,得到的结果是否与预期一致?

创建两个线程,分别对共享变量(count)进行自增5万次操作,最后输出的结果理论上应为10万,但是实际上输出的结果是一个小于10万且不确定的数。

读者可以自行实现一下该多线程程序,运行后看看结果是否符合预期。

java 复制代码
public class Demo14_threadSafety {
    private static int count = 0;

    public static void main1(String[] args) {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
            System.out.println("t1-结束");
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
            System.out.println("t2-结束");
        });

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

        // 理论上输出的结果应是100000,实际输出的结果是0
        // 原因是主线程 main 运行太快了,当 t1 和 t2 线程还在计算时,主线程已经打印结果、运行完毕了
        System.out.println(count);
    }

    // 让主线程等待 t1 和 t2 线程,等到它们两个都执行完成再打印,故使用 join 方法
    public static void main2(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
            System.out.println("t1-结束");
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
            System.out.println("t2-结束");
        });

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

        // 在主线程中,通过 t1 和 t2 对象调用 join 方法
        // 表示让主线程 main 等待 t1 线程和 t2 线程
        t1.join();
        t2.join();

        // 当两个线程都执行完毕后主线程再继续执行打印操作
        System.out.println(count);
        // 实际输出的结果小于100000,仍不符合预期
    }
}

二、线程安全的概念

通过上面的一个例子,想必读者已经体会到线程安全问题了吧?那究竟什么是线程安全问题呢?其原因是什么?如何解决线程安全问题呢?

不要急,且听小编慢慢道来~

如果在多线程环境下运行的程序其结果符合预期或与在单线程环境下运行的结果一致,就说这个程序是线程安全的,否则是线程不安全的。

上面的例子在单线程环境下运行------比如来两个循环对共享变量进行自增操作,那么结果是符合预期的;但是在多线程环境下运行就不符合预期。因此该程序是线程不安全的,也可以说该程序存在线程安全问题。

三、线程安全问题的原因

究竟是哪里出问题导致程序出现线程安全问题呢?

究其根本,罪魁祸首是 操作系统的线程调度有随机性/抢占式执行。

由于操作系统的线程调度是有随机性的,这就会存在这种情况:某一个线程还没执行完呢,就调度到其他线程去执行了,从而导致数据不正确。

当然了,一个巴掌拍不响,还有以下三个导致线程不安全的原因:

  1. 原子性 :指 Java 语句,一条 Java 语句可能对应不止一条指令,若对应一条指令,就是原子的。
  2. 可见性 :一个线程对主内存(共享变量)的修改,可以及时被其他线程看到。
  3. 有序性 :一个线程观察其他线程中指令的执行顺序,由于 JVM 对指令进行了重排序,观察到的顺序一般比较杂乱。因其原理与 CPU 及编译器的底层原理有关,暂不讨论。

之前的例子就是由于原子性没有得到保障而出现线程安全问题:

java 复制代码
public static void main2(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            count++;
        }
        System.out.println("t1-结束");
    });

    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            count++;
        }
        System.out.println("t2-结束");
    });

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

    t1.join();
    t2.join();

    System.out.println(count);
}
  1. "count ++" 这条语句对应多条指令:读取数据、计算结果、存储数据。

  2. t1 线程和 t2 线程分别执行一次"count++"语句,期望的结果是"count = 2",其过程如下:

初始情况:

当线程执行"count ++"时,总共分三个步骤:load、update、save,由于线程调度的随机性/抢占式执行,可能会出现以下情况(可能出现的情况有很多种,这里只是其中一种):

这时候 t1 正在执行"count ++"这条语句,执行了"load 和 update"指令后,t1 的工作内存(寄存器)存着更新后的值,但是还未被写回内存中:

接着调度到 t2 线程并开始执行"count ++"语句,并且语句中包含的三条指令都执行。此时由于 t1 更新后的 count 的值还未写回内存,因此 t2 执行 load 操作所获取到的 count 仍是 0。接着 t2 执行 update 和 save 指令:

当 t2 执行完成,内存的 count 已被修改为 1 。此时调度回 t1 线程并继续执行 save 指令,但是 t1 线程寄存器中 count 的值也是 1 ,此时写回内存更新后 count 的值依然是 1 。

结果 count = 1,与预期的 count = 2 不符,因此存在线程安全问题,其原因是操作系统的随机线程调度和 count 语句存在非原子性。

四、解决线程安全问题的方法

从上面的例子我们知道,当一条语句的指令被拆开来执行的话是存在线程安全问题的,但是,当我们将"count ++"这条语句的三个指令都放在一起执行怎么样?

当线程调度的情况如下:

此时 t1 线程开始执行"count ++"语句的 load、update 和 save 指令。内存中的 count 为 0,t1 读取到内存中的 count 之后更新至 1 并写回内存中。当 t1 执行完成后内存的 count 由 0 更新至 1:

接着调度至 t2 线程,开始执行"count ++"语句的 load、update 和 save 指令。经过更新后内存中的 count 为 1,此时 t2 读取 count 并更新为 2,然后写回内存中。当 t2 执行完成,内存中的 count 就更新成 2 了:

可以发现,结果与预期相符!说明这个方法可行。

可以将操作顺序改成先让 t1 线程完成"count ++"操作,再让 t2 线程完成该操作------即串行执行。

现在我们对之前的例子进行优化:

java 复制代码
// 可以试着让 t1 线程先执行完后,再让 t2 线程执行,改成串行执行
public static void main3(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            count++;
        }
        System.out.println("t1-结束");
    });

    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            count++;
        }
        System.out.println("t2-结束");
    });

    t1.start();
    t1.join();

    t2.start();
    t2.join();

    System.out.println(count);
}

刚刚是让一个线程一次性执行"count ++"这条语句的三个指令,也就是说,我们是通过这样操作将原本是非原子的三条指令打包成了一个原子指令(即执行过程中不可被打断------调度走)。这样就有效的解决了线程安全问题。

而上述的操作,其实就是 Java 中的加锁操作。

当一个线程执行一个非原子的语句时,通过加锁操作可以防止在执行过程中被调度走或被其他线程打断,若其他线程想要执行该语句,则要进入阻塞等待的状态,当线程执行完毕并将锁释放,操作系统这时唤醒等待中的线程,才可以执行该语句。

就相当于上厕所:当厕所内没有人时(没有线程加锁),就可以使用;当厕所内有人时(已经有线程加锁了),那么就必须等里面的人出来后才能使用。

注意:

  1. 前一个线程解锁之后,并不是后一个线程立刻获取到锁。而是需要靠操作系统唤醒阻塞等待中的线程的。
  2. 若 t1、t2 和 t3 三个线程竞争同一个锁,当 t1 线程获取到锁,t2 线程再尝试获取锁,接着 t3 线程尝试获取锁,此时 t2 和 t3 线程都因获取锁失败而处于阻塞等待状态。当 t1 线程释放锁之后,t2 线程并不会因为先进入阻塞状态在被唤醒后比 t3 先拿到锁,而是和 t3 进行公平竞争。(不遵循先来后到原则)

4.1 synchronized 关键字

在处理由原子性导致的线程安全问题时,通常采用加锁操作。

加锁 / 解锁这些操作本身是在操作系统所提供的 API 中的,很多编程语言对其进行了封装,Java 中使用 synchronized 关键字来进行加锁 / 解锁操作,其底层是使用操作系统的mutex lock来实现的。Java 中的任何一个对象都可以用作"锁"。

synchronized (锁对象){ ------> 进入代码块,相当于加锁操作

// 一些需要保护的逻辑

} ------> 出了代码块,相当于解锁操作

当多个线程针对同一个锁对象竞争的时候,加锁操作才有意义。

对之前的例子进行加锁操作:

java 复制代码
public class Demo15_synchronized {
    private static int count = 0;

    public static void main1(String[] args) throws InterruptedException {
        Object locker = new Object();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized (locker) {
                    count++;
                }
            }
            System.out.println("t1-结束");
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized (locker) {
                    count++;
                }
            }
            System.out.println("t2-结束");
        });

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

        t1.join();
        t2.join();

        System.out.println(count);
    }
}

synchronized 关键字用来修饰 普通方法 时,相当于给 this 加锁;

synchronized 关键字用来修饰 静态方法 时,相当于给 类对象 加锁。

于是可以使用另一种写法:

java 复制代码
// 写法二:
// 将 count++ 所包含的三个操作封装成一个 add 方法
// 使用 synchronized 修饰 add 方法

class Counter {
    private int count = 0;

    synchronized public void add () {
        // synchronized 修饰普通方法相当于给 this 加锁
        count++;
    }
    // 相当于:
    // public void add () {
    //     synchronized (this) {
    //         count++;
    //     }
    // }

    public int get () {
        return count;
    }
}

public static void main(String[] args) throws InterruptedException {
    Counter counter = new Counter();

    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.add();
        }
    });

    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.add();
        }
    });

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

    t1.join();
    t2.join();

    System.out.println(counter.get());
}

这样一来,就成功解决了多线程程序中由原子性导致的线程安全问题。

4.2 volatile 关键字

我们再来一个例子:

让 t1 线程读取共享变量的值,然后让 t2 线程修改共享变量的值:

java 复制代码
public class Demo17_volatile {
    private static int flag = 0;

    public static void main1(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 0) {
                // 当 flag 为 0 时一直循环
            }
            System.out.println("t1-结束");
        });

        Thread t2 = new Thread(() -> {
            // 对 flag 进行修改
            Scanner in = new Scanner(System.in);
            System.out.println("请输入 flag 的值:");
            flag = in.nextInt();
        });

        t1.start();
        t2.start();
        // 运行后发现即使输入1,t1 线程并不会结束
    }
}

我们期望该程序运行后,输入非零的数如 1,t1 线程能够结束,实际并非如此,很显然,这也是出现了线程安全问题。

这次出现问题的原因并非原子性,而是 可见性。我们说过,可见性是一个线程对主内存(共享变量)的修改能够被其他线程及时看到,若不能被其他线程及时看到,就会出现数据错误,从而导致线程安全问题。

如果我们使用锁来处理的话,可以发现并没有什么效果。

我们先来认识一个东西:编译器优化。

由于不能保证写代码的人每一次写代码都不会出错,所以开发 JDK 的先人们就让编译器 / JVM 能够根据代码的原有逻辑进行优化。

在我们这个程序中:

  1. while {...load...cmp...} 先将数据从内存中 load 到寄存器中,然后在寄存器进行 "条件比较" 指令 cmp 。在短时间内用户还没来得及输入呢,但是这个循环语句能够执行成万上亿次且内存中的 flag 的值一直都是 0。
  2. 那么这时候编译器 / JVM 就察觉到:既然一直读取 flag 都是同一个值,那干脆我直接读取寄存器算了(读取寄存器开销更小)。
  3. 这样一来,当用户输入 1 的时候(存入内存),t1 线程就无法读取通过 t2 线程更新之后的值了,也就不会结束。

在上面的程序中,t2 线程修改后的值无法被 t1 线程看到,这就是可见性导致的线程安全问题,由于编译器我们没办法更改,所以只好另寻他法。

如果我们能让循环执行的慢一点,是不是就能解决问题了?

尝试优化该程序:

java 复制代码
public static void main2(String[] args) {
    Thread t1 = new Thread(() -> {
        while (flag1 == 0) {
            // 当 flag1 为 0 时一直循环
            try {
                Thread.sleep(1);
                // 放慢读取速度
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        System.out.println("t1-结束");
    });

    Thread t2 = new Thread(() -> {
        // 对 flag1 进行修改
        Scanner in = new Scanner(System.in);
        System.out.println("请输入 flag1 的值:");
        flag1 = in.nextInt();
    });

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

在运行优化版的程序后,发现问题被解决了,其原因是:使用了 sleep(1)------ 表示每一次读取的时候休眠 1毫秒,这对于读取操作的速度来说已经很慢了。因此不会触发编译器优化,也就不会出现可见性导致的线程安全问题了。

但是,在实际开发环境中频繁使用 sleep 的话会导致程序效率下降,这样的话用户体验就会变差。

Java 中使用 volatile 关键字来处理可见性导致的线程安全问题。

使用 volatile 关键字来修饰共享变量,这样一来这个变量无论如何都不会被编译器优化。

具体流程是:当 t1 线程读取被修饰的变量时,会强制读取主内存中变量最新的值;当 t2 线程修改被修饰的变量时,在工作内存(寄存器)中更新变量的值之后,立即将改变的值刷新至主内存。

加上 volatile 关键字强制读取内存,虽然速度慢了,但是保证数据不出错。

使用 volatile 关键字解决:

java 复制代码
// 使用 volatile 关键字来修饰被读取的变量,此时无论读取速度怎样,JVM 都不会对该变量进行优化
private volatile static int flag = 0;

public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        while (flag == 0) {
            // 当 flag 为 0 时一直循环
        }
        System.out.println("t1-结束");
    });

    Thread t2 = new Thread(() -> {
        // 对 flag 进行修改
        Scanner in = new Scanner(System.in);
        System.out.println("请输入 flag 的值:");
        flag = in.nextInt();
    });

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

注意:

  • volatile 关键字只能保证可见性,不能保证原子性,因此不能++完全++解决线程安全问题。
  • 当多线程中存在 ++、--、+=、-=、*= 和 /= 或者类似的操作符时就不能够使用 volatile 关键字来处理,需要使用 synchronized 关键字。

五、死锁

那么话又说回来,如果对锁使用不当,就很容易出现死锁。

如果一个线程已经获取了锁,再次获取这个锁,能成功吗?会出现死锁吗?

java 复制代码
Object locker = new Object();

synchronized (locker) {
    synchronized (locker) {
        // 一些需要保护的逻辑
    }
}

按照前面的逻辑,第一次获取锁对象后,该锁对象已被占有,下一次获取该锁对象时应该会触发阻塞等待。而要想解除阻塞等待就得往下继续执行,但是要想往下执行就得将锁解开。这样不就构成死锁了嘛?

不妨试试在你的 IDE 上运行一下~

5.1 synchronized 的可重入性

如果你在 IDE 上运行了刚刚的程序,可以发现程序是没问题的。

按照我们这之前的逻辑,当锁对象第一次被获取到,其他的线程再想获取锁对象时就只能阻塞等待。但是,第二次获取锁对象的是同一个线程呀~ 因此并不会触发阻塞等待。

  • Java 中的 synchronized 关键字是具有可重入性的,即对于同一个线程可以重复获取同一个锁对象。
  • 可重入锁内部包含了"线程持有者"和"计数器"两个信息,若线程加锁时发现锁已被占有且恰好是自己,此时仍可以获取到锁,让计数器自增即可;当计数器为 0 时,将锁释放,其他线程可以才获取到锁。

这样就可以避免死锁的出现------开发 JDK 的前人们真的为我们考虑了太多😭

5.2 死锁的概念

  1. 当有两个线程为了保护两个不同的共享资源而使用两个不同的锁且这两个锁使用不当时,就会造成两个线程都在等待对方解锁,在没有外界干扰的情况下他们会一直相互等待,此时就是发生了死锁。
  2. 死锁需要同时具备以下四个条件:
  • 互斥条件:要求同一个资源不能被多个线程同时占有。
  • 不可剥夺条件:当资源已被某个线程占有,其他线程只能等到该线程使用完并释放后才能获取,不可以强行打断并获取。
  • 持有并等待条件:有三个线程两个资源,当线程 1 已经占有资源 A(线程 2 尝试获取资源 A 但触发阻塞等待)且尝试获取资源 B 时(此时资源 B 已被线程 3 占有),线程 1 就会触发阻塞等待且不释放手中持有的资源 A 。
  • 循环等待/环路等待条件:有两个线程两个资源,线程 1 已占有资源 A 并想要获取资源 B ,但是资源 B 已被线程 2 占有,并且线程 2 在占有资源 B 的同时想要获取资源 A 。

比如,有两个线程 t1 和 t2 以及两个锁 locker1 和 locker2,t1 先获取 locker1 并尝试获取 locker2;t2 先获取 locker2 并尝试获取 locker1:

java 复制代码
// 两个线程两把锁,每个线程获取到一把锁之后尝试获取对方的锁
public static void main2(String[] args) throws InterruptedException {
    Object locker1 = new Object();
    Object locker2 = new Object();

    Thread t1 = new Thread(() -> {
        synchronized (locker1) {
            // t1 线程获取到锁对象 locker1
            try {
                Thread.sleep(1000);
                // 确保 t2 线程拿到 locker2
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            // 尝试获取锁对象 locker2
            synchronized (locker2) {
                System.out.println("t1 线程获取到两把锁");
            }
        }
    });

    Thread t2 = new Thread(() -> {
        synchronized (locker2) {
            // t2 线程获取到锁对象 locker2
            try {
                Thread.sleep(1000);
                // 确保 t1 线程拿到 locker1
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            // 尝试获取锁对象 locker1
            synchronized (locker1) {
                System.out.println("t2 线程获取到两把锁");
            }
        }
    });

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

    t1.join();
    t2.join();
}

运行该程序会发现什么也没输出,程序却还在运行。

这就是发生了死锁。

我们借助第三方工具来观察线程的状态:

5.3 如何避免死锁

要想避免死锁,我们就要从死锁的四个条件入手,其中,互斥条件和不可剥夺条件基本上是无法打破的,因为这两个是 synchronized 锁的基本特性,因此我们选择从后两个条件入手。

  1. 持有并等待条件:

通常是由于代码中的嵌套加锁导致的,因此选择将嵌套的加锁代码改成串行的加锁代码(先将已占有的资源释放掉,然后去获取另一个资源):

java 复制代码
// 避免死锁的写法:打破持有并等待条件------将嵌套加锁改成串行加锁
public static void main(String[] args) throws InterruptedException {
    Object locker1 = new Object();
    Object locker2 = new Object();

    Thread t1 = new Thread(() -> {
        synchronized (locker1) {
            // t1 线程获取到锁对象 locker1
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        // t1 线程释放已占有的锁对象 locker1
        // 尝试获取锁对象 locker2
        synchronized (locker2) {
            System.out.println("t1 线程获取到两把锁");
            // t1 获取到两把锁之后结束执行,此时 locker1 和 locker2 均被释放
        }
    });

    Thread t2 = new Thread(() -> {
        synchronized (locker2) {
            // t2 线程获取到锁对象 locker2
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        // t2 线程释放已占有的锁对象 locker2
        // 尝试获取锁对象 locker1
        synchronized (locker1) {
            System.out.println("t2 线程获取到两把锁");
            // t2 获取到两把锁后结束执行
        }
    });

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

    t1.join();
    t2.join();
}
  1. 循环等待/环路等待条件:

这种情况一般都是双方都持有对方想要的锁但是都不肯释放,所以我们就调整一下顺序:让 t1 和 t2 获取锁的顺序都是 locker1 和 locker2,当 t1 已占有 locker1 时 t2 想获取只能阻塞等待,但这时 t1 可以获取 locker2,等待 t1 两把锁都获取到并释放之后,t2 被唤醒并且获取两把锁。

java 复制代码
// 防止死锁的写法:打破循环等待条件------调整加锁顺序
public static void main(String[] args) throws InterruptedException {
    Object locker1 = new Object();
    Object locker2 = new Object();

    Thread t1 = new Thread(() -> {
        synchronized (locker1) {
            // t1 线程获取到锁对象 locker1
            try {
                Thread.sleep(1000);
                // 此时 t2 因 locker1 被 t1 获取而处于阻塞状态 BLOCKED
                // t1 休眠结束后,获取 locker2
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            // 尝试获取锁对象 locker2
            synchronized (locker2) {
                System.out.println("t1 线程获取到两把锁");
                // t1 获取到两把锁之后结束执行,此时 locker1 和 locker2 均被释放
            }
        }
    });

    Thread t2 = new Thread(() -> {
        synchronized (locker1) {
            // t2 线程获取到锁对象 locker1
            // 因 t1 先获取到 locker1 ,t2 此时处于阻塞状态 BLOCKED
            // 当 t1 结束执行后,t2 恢复执行并获取 locker1
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                // 休眠结束后继续获取 locker2
                throw new RuntimeException(e);
            }
            // 尝试获取锁对象 locker2
            synchronized (locker2) {
                System.out.println("t2 线程获取到两把锁");
                // t2 获取到两把锁后结束执行
            }
        }
    });

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

    t1.join();
    t2.join();
}

今天暂且到这吧~

若有错误请尽管指出🌹


完

相关推荐
xcl09252 小时前
北京24小时自助健身房系统软件开发实战:从架构到部署全流程指南
java·spring boot·架构
一 乐3 小时前
动漫书销售商城|基于springboot + vue动漫书销售商城(源码+数据库+文档)
java·数据库·vue.js·spring boot·毕业设计
卓怡学长4 小时前
w214基于jsp知道特产网
java·intellij-idea
步行cgn4 小时前
Spring 注解使用详解
java·spring
一条小小yu4 小时前
Spring IoC的理解
java·后端·spring
落魄实习生4 小时前
Agent Scope Java 2.x 系列【7】工具使用
java·开发语言·ai
旺仔学长 哈哈4 小时前
springboot钓鱼爱好者交流平台APP设计与实现
java·spring boot·mysql·充电桩管理系统
自强的小白6 小时前
核心功能(Service接口)
java·mybatis
乌暮7 小时前
深入理解 Java 泛型:把「万能盒子」用对、用稳
java·开发语言·后端·学习