【多线程初阶】线程安全问题

文章目录

  • 前言
  • 1. 多线程带来的风险 - 线程安全
    • 1.1 观察线程不安全
    • 1.2 线程安全的概念
    • 1.3 线程不安全的原因
      • 1.3.1 抢占式执行
      • 1.3.2 修改共享数据
      • 1.3.3 原子性
      • 1.3.4 内存可见性
      • 1.3.5 指令重排序, 代码顺序性
    • 1.4 解决之前的线程不安全问题
  • 2. synchronized 关键字
    • 2.1 synchronized 特性
      • 2.1.1 互斥
      • 2.1.2 刷新内存
      • 2.1.3 可重入
    • 2.2 synchronized 使用方法
      • 2.2.1 直接修饰普通方法: 锁的 SynchronizedDemo 对象
      • 2.2.2 修饰静态方法: 锁的 SynchronizedDemo 类的对象
      • 2.2.3 修饰代码块: 明确指定锁哪个对象
    • 2.3 Java 标准库中的线程安全类
  • 3. volatile 关键字
    • 3.1 volatile 能保证内存可见性
    • 3.2 volatile 不保证原子性
    • 3.2 volatile 禁止指令重排序问题
    • ~~3.3 synchronized 也能保证内存可见性~~
  • 4. wait 和 notify
    • 4.1 wait() 方法
    • 4.2 notify() 方法
    • 4.3 notifyAll() 方法
    • 4.4 wait 和 sleep 的对比
  • 总结


前言

本文要讲的知识是多线程内容的重点, 多线程带来的风险 - 线程安全及相应的解决方法, 还有涉及到的 synchronized 和 volatile 关键字的知识, 以及 wait() 和 notify() 方法.

关注收藏, 开始学习吧


1. 多线程带来的风险 - 线程安全

在我们进行多线程操作时, 往往会遇到一些问题, 某个多线程程序运行时, 当程序预期的结果与实际实现的效果不同时, 这就是代码出现了 bug, 也就是线程不安全, 那么为什么会出现线程安全问题呢? 本质上来说, 是因为线程在系统中调度的顺序是无序的 / 随机的, 抢占式执行.

1.1 观察线程不安全

给大家提供一段代码来进行观察, 这段代码, 是两个线程针对同一个变量, 各自自增 5w 次, 预期结果应该输出 10w.

class Counter {

    private int count = 0;

    public void add() {
        count++;
    }

    public int get() {
        return count;
    }

}

public class ThreadDemo12 {

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

}

运行结果发现, 实际结果并不是 10w, 而像是一个随机值一样, 每次的结果还不一样, 我们想要得到的结果与实际结果不相符, 这时程序就是出现了 bug, 这就是犹豫多线程引发的 bug.

在这里我们先简单解释一下这段代码为什么会出现 bug.

其实这里和线程的调度随机性密切相关, 这里的 count++ 操作, 本质上是由三个 CPU 指令构成的.

  1. load, 把内存中的数据读取到 CPU 寄存器中.
  2. add, 把寄存器中的值, 进行 +1 运算.
  3. save, 把寄存器中运算好的值写回到内存中去.

由于多线程调度顺序是不确定的, 实际执行过程中, 这俩线程的 ++ 操作, 实际的指令排列顺序就有很多种可能.

这里我们就举一个会发生错误的排列顺序.

【多线程初阶】线程安全问题_第1张图片
我们用 t1, t2 分别代表我们的两个线程操作, 每个线程都包含三个步骤 load, add, save, t1 线程先进行 load 操作, 此时读到 t1 寄存器中的值为 0, 紧接着由于多线程调度顺序不确定, t2 也进行了 load 操作, 读到的也是 0, 然后 t2 立刻执行 add, save 操作, 将 0 写入回内存中, 此时 t1 执行 add, save 操作, 也是将 1 写回到了内存中, 这样系统就出现了 bug, 明明两个线程都进行了 add 运算, 得到的结果却是 1.

1.2 线程安全的概念

想给出一个线程安全的确切定义是复杂的, 但我们可以这样认为:

如果多线程环境下代码运行的结果是符合我们预期的, 即在单线程环境应该的结果, 则说这个程序是线程安全的.

1.3 线程不安全的原因

1.3.1 抢占式执行

这个是多线程代码出现 bug 的罪魁祸首, 由于多线程环境下, 线程的调度顺序是不确定的, 为抢占式执行, 程序就无法按照程序员预想的顺序来进行.

1.3.2 修改共享数据

上面的线程不安全的代码中, 涉及到俩个线程针对 count 变量进行修改.

此时这个 count 就是多个线程都能访问到的一个 “共享数据”.

【多线程初阶】线程安全问题_第2张图片
count 这个变量就是在 Heap 堆上. 因此可以被多个线程共享访问.

注意:

  • 只有当多个线程修改同一个变量时才会有几率出现 bug.
  • 当一个线程修改一个变量时, 是安全的.
  • 当多个线程读取同一个变量时, 也是安全的.
  • 当多个线程修改不同的变量时, 也是安全的.

1.3.3 原子性

【多线程初阶】线程安全问题_第3张图片

什么是原子性

在过去, 物质中不可分割的最小单位, 称为原子.

我们把一段代码想象成一个房间, 每个线程就是要进入这个房间的人. 如果没有任何机制保证, A进入房间之后, 还没有出来. B 是不是也可以进入房间, 打断 A 在房间里的隐私. 这个就是不具备原子性的.

那我们应该如何解决这个问题呢? 是不是只要给房间加一把锁, A 进去就把门锁上, 其他人是不是就进不来了. 这样就保证了这段代码的原子性了.

有时也把这个现象叫做同步互斥, 表示操作是互相排斥的.

请大家牢记: 一条 Java 语句不一定是原子的, 也不一定只是一条指令.

回想一下, 比如刚才我们看到的 count++, 其实是由三步操作组成的:

  1. load, 从内存把数据读到 CPU
  2. add, 进行数据更新
  3. save, 把数据写回到 CPU

不保证原子性会给多线程带来什么问题

如果一个线程正在对一个变量操作, 中途其他线程插入进来了, 如果这个操作被打断了, 结果就可能是错误的.

这点也和线程的抢占式调度密切相关. 如果线程不是 "抢占" 的, 就算没有原子性也问题不大.

1.3.4 内存可见性

可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.

Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.

目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.

【多线程初阶】线程安全问题_第4张图片

  • 线程之间的共享变量存在 “主内存” (Main Memory).
  • 每一个线程都有自己的 “工作内存” (Working Memory) .
  • 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
  • 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.

由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 “副本”. 此时修改 线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化.

  1. 初始情况下, 两个线程的工作内存内容一致
    【多线程初阶】线程安全问题_第5张图片
  2. 一旦 线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的工作内存的 a 的值也不一定能及时同步.
    【多线程初阶】线程安全问题_第6张图片

这个时候代码中就容易出现问题.

此时引入了两个问题:

  • 为啥要整这么多内存?
  • 为啥要这么麻烦的拷来拷去?
  1. 为啥整这么多内存?
    实际并没有这么多 “内存”. 这只是 Java 规范中的一个术语, 是属于 “抽象” 的叫法.
    所谓的 “主内存” 才是真正硬件角度的 “内存”. 而所谓的 “工作内存”, 则是指 CPU 的寄存器和高速缓存.
  2. 为啥要这么麻烦的拷来拷去?
    因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍).
    比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问内存了. 效率就大大提高了

那么接下来问题又来了, 既然访问寄存器速度这么快, 还要内存干啥??

答案就是一个字: 贵
【多线程初阶】线程安全问题_第7张图片
值的一提的是, 快和慢都是相对的. CPU 访问寄存器速度远远快于内存, 但是内存的访问速度又远远快于硬盘. 对应的, CPU 的价格最贵, 内存次之, 硬盘最便宜

1.3.5 指令重排序, 代码顺序性

什么是代码重排序

一段代码是这样的:

  1. 去前台取下 U 盘
  2. 去教室写 10 分钟作业
  3. 去前台取下快递

如果是在单线程情况下, JVM, CPU 指令集会对其进行优化, 比如, 按 1 -> 3 -> 2 的方式执行, 也是没问题, 可以少跑一次前台. 这种叫做指令重排序.

编译器对于指令重排序的前提是 "保持逻辑不发生变化". 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.

重排序是一个比较复杂的话题, 涉及到 CPU 以及编译器的一些底层工作原理, 此处不做过多讨论

1.4 解决之前的线程不安全问题

那么如何解决线程不安全问题呢? 我们需要从引起线程不安全问题的原因入手, 线程不安全是因为是无序的, 系统抢占式执行, 并且没有保证原子性.

那么我们是不是能想办法让之前的 count++ 操作变为原子呢? 我们可以对其进行加锁.

加锁, 就可以保证 原子性 的效果. 锁的核心操作有两个.

  1. 加锁
  2. 解锁

一旦某个线程加锁之后, 其他线程也想加锁, 就不能直接加上, 就需要阻塞等待, 一直等到拿到锁的线程释放锁为止.

给大家看一下通过使用 synchronized 关键字来进行加锁, 解决刚才 count++ 失败的问题.

class Counter {

    private int count = 0;

    synchronized void add() {
        count++;
    }

    public int get() {
        return count;
    }
}

public class ThreadDemo12 {

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

}

这段代码相比之前的代码, 其实只修改了一处, Counter 类中的 add 方法与之前有不同,
可以看到, add 方法是用 synchronized 关键字来修饰的.

【多线程初阶】线程安全问题_第8张图片
Java 中是如何进行加锁的呢? 以及 synchronized 关键字的具体用法, 我们接下来就进行讲解.

2. synchronized 关键字

synchronized 是 Java 中的一个关键字, 可以使用这个关键字来进行锁操作.

2.1 synchronized 特性

2.1.1 互斥

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.

  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁

【多线程初阶】线程安全问题_第9张图片
synchronized用的锁是存在Java对象头里的.
【多线程初阶】线程安全问题_第10张图片

可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 “锁定” 状态 (类似于厕所的 “有人/无人”).

  • 如果当前是 “无人” 状态, 那么就可以使用, 使用时需要设为 “有人” 状态.
  • 如果当前是 “有人” 状态, 那么其他人无法使用, 只能排队.

【多线程初阶】线程安全问题_第11张图片
理解 “阻塞等待”.

针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁.

注意:

  • 上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 “唤醒”. 这也就是操作系统线程调度的一部分工作.
  • 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.

synchronized 的底层是使用操作系统的mutex lock实现的.

2.1.2 刷新内存

synchronized 的工作过程:

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

2.1.3 可重入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题.

理解 “把自己锁死”
一个线程没有释放锁, 然后又尝试再次加锁.

// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待. 
lock();

按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁.
【多线程初阶】线程安全问题_第12张图片
这样的锁称为 不可重入锁.

Java 中的 synchronized 是 可重入锁, 因此没有上面的问题.

代码示例

在下面的代码中

  • increase 和 increase2 两个方法都加了 synchronized, 此处的 synchronized 都是针对 this 当前对象加锁的.
  • 在调用 increase2 的时候, 先加了一次锁, 执行到 increase 的时候, 又加了一次锁. (上个锁还没释放, 相当于连续加两次锁)

这个代码是完全没问题的. 因为 synchronized 是可重入锁.

static class Counter {
    public int count = 0;
    synchronized void increase() {
        count++;
   }
    synchronized void increase2() {
        increase();
   }
}

在可重入锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息.

  • 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
  • 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

2.2 synchronized 使用方法

synchronized 本质上要修改指定对象的 “对象头”. 从使用角度来看, synchronized 也势必要搭配一个具体的对象来使用.

2.2.1 直接修饰普通方法: 锁的 SynchronizedDemo 对象

public class SynchronizedDemo {
    public synchronized void methond() {
   }
}

2.2.2 修饰静态方法: 锁的 SynchronizedDemo 类的对象

public class SynchronizedDemo {
    public synchronized static void method() {
   }
}

2.2.3 修饰代码块: 明确指定锁哪个对象

  1. 直接锁当前对象
public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            
       }
   }
}
  1. 锁类对象
public class SynchronizedDemo {
    public void method() {
        synchronized (SynchronizedDemo.class) {
       }
   }
}

我们重点要理解,synchronized 锁的是什么. 两个线程竞争同一把锁, 才会产生阻塞等待.

两个线程分别尝试获取两把不同的锁, 是不会产生竞争的.
【多线程初阶】线程安全问题_第13张图片

2.3 Java 标准库中的线程安全类

Java 标准库中很多类都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

但是还有一些是线程安全的. 使用了一些锁机制来控制.

  • Vector (不推荐使用)
  • HashTable (不推荐使用)
  • ConcurrentHashMap
  • StringBuffer

还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的.

  • String

3. volatile 关键字

之前我们讲了 synchronized 关键字用来解决原子性的问题, 之前我们还提到了内存可见性的问题, 这里我们就可以利用 volatile 来解决此问题.

3.1 volatile 能保证内存可见性

volatile 修饰的变量, 能够保证 “内存可见性”.
【多线程初阶】线程安全问题_第14张图片

代码在写入 volatile 修饰的变量的时候,

  • 改变线程工作内存中volatile变量副本的值
  • 将改变后的副本的值从工作内存刷新到主内存

代码在读取 volatile 修饰的变量的时候,

  • 从主内存中读取volatile变量的最新值到线程的工作内存中
  • 从工作内存中读取volatile变量的副本

前面我们讨论内存可见性时说了, 直接访问工作内存 (实际是 CPU 的寄存器或者 CPU 的缓存), 速度非常快, 但是可能出现数据不一致的情况.

加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了.

代码示例:

先给大家写一个关于内存可见性的 bug 代码.

public class ThreadDemo13 {

    public 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(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数给 flag 赋值, 输入0以外数字代表结束 t1: ");
            flag = scanner.nextInt();
        });

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

}

此代码预期效果为:

  • t1 通过 flag == 0 作为条件进行循环, 初始值为0, 将进入循环.
  • t2 通过控制台输入一个整数给 flag 进行赋值, 一旦用户输入了一个非0的值, 此时 t1 的循环就会立即结束, 从而 t1 线程也会结束.

但代码的实际效果为:

  • 输入非0的值后, 循环并没有结束, t1 线程也并没有退出, 仍处于 RUNNABLE 状态.

t1 读的是自己工作内存中的内容. 当 t2 对 flag 变量进行修改, 此时 t1 感知不到 flag 的变化.

如果我们给 flag 加上 volatile 关键字, 此时编译器就能保证每次都是重新从内存中读取 flag 变量的值.

volatile public static int flag = 0;

这时 t2 修改 flag, t1 就可以立刻感知到, 并正常退出.

【多线程初阶】线程安全问题_第15张图片

3.2 volatile 不保证原子性

volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.

  • volatile 适用的场景是一个线程读, 一个线程写的情况.
  • 而 synchronized 则是多个线程写的情况.

3.2 volatile 禁止指令重排序问题

volatile 还有一个效果是, 禁止指令重排序, 这个也是引起线程不安全的原因之一, 而 volatile 可以很好地解决.

3.3 synchronized 也能保证内存可见性

该说话尚且存在争议, 网上的资料众说纷纭, 大家可以知道即可.

4. wait 和 notify

由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知.

但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序.

完成协调工作, 主要涉及到以下方法

  • wait() / wait(long timeout): 让当前线程进入等待状态.
  • notify() / notifyAll(): 唤醒在当前对象上等待的线程.

注意: wait 和 notify 都是 Object 的方法, 只要是个类对象, 都可以使用.

4.1 wait() 方法

在使用 wait时, 切记要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常. 必须写在 synchronized 代码块中.

wait 做的三件事情:

  • 使当前执行代码的线程进行等待. (把线程放到等待队列中)
  • 释放当前的锁.
  • 满足一定条件时被唤醒, 重新尝试获取这个锁.

wait 结束等待的条件:

  • 其他线程调用该对象的 notify 方法.
  • wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
  • 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.

代码示例: 观察wait()方法使用

public static void main(String[] args) throws InterruptedException {
    Object object = new Object();
    synchronized (object) {
        System.out.println("等待中");
        object.wait();
        System.out.println("等待结束");
   }
}

这样在执行到object.wait()之后就一直等待下去, 那么程序肯定不能一直这么等待下去了. 这个时候就需要使用到了另外一个方法唤醒的方法 notify().

4.2 notify() 方法

notify() 方法是唤醒等待的线程.

  • 方法 notify() 也要放在 synchronized 修饰的代码块中使用, 而且必须要先有 wait, 再有 notify, 此时代码才会有效果, 该方法是用来通知那些可能等待该对象的对象锁的其它线程, 对其发出通知 notify, 并使它们重新获取该对象的对象锁.
  • 如果有多个线程等待, 则有线程调度器随机挑选出一个呈 wait 状态的线程. (并没有 “先来后到” )
  • 在 notify() 方法后, 当前线程不会马上释放该对象锁, 要等到执行 notify() 方法的线程将程序执行完, 也就是退出同步代码块之后才会释放对象锁.

代码示例: 使用 notify() 方法唤醒线程

  • t1 先执行, 执行到 wait, 堵塞等待.
  • 1s 后 t2 开始执行, 执行到 notify 唤醒 t1.
  • t1 继续执行.
public class ThreadDemo14 {

    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            try {
                System.out.println("wait 开始");
                synchronized (locker) {
                    locker.wait();
                }
                System.out.println("wait 结束");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        t1.start();

        Thread.sleep(1000);

        Thread t2 = new Thread(() -> {
            synchronized (locker) {
                System.out.println("notify 开始");
                locker.notify();
                System.out.println("notify 结束");
            }
        });
        t2.start();
    }

}

【多线程初阶】线程安全问题_第16张图片

补充:

  • 使用 wait, 阻塞等待会让线程进入到 WAITING 状态.
  • wait 还提供了一个带参数版本, 参数指定的是最大等待时间. 超过最大时间之后, 不需要 notify, 也会自己唤醒自己.

4.3 notifyAll() 方法

唤醒操作, 还有一个方法 notifyAll(), notify() 方法只是唤醒某一个等待线程. 使用 notifyAll() 方法可以一次唤醒所有的等待线程.

比如在 t1, t2, t3 中都调用了 object.wait(), 此时在 main 中调用 object.notify() 会随机唤醒上述中的一个线程 (另外两个仍然是 WAITING 状态), 如果调用 object.notifyAll() 就会将三个线程全部唤醒.

注意: 虽然是同时唤醒 3 个线程, 但是这 3 个线程需要竞争锁. 所以并不是同时执行, 而仍然是有先有后的执行.

4.4 wait 和 sleep 的对比

其实理论上 wait 和 sleep 完全是没有可比性的, 因为一个是用于线程之间的通信的, 一个是让线程阻塞一段时间.

唯一的相同点就是都可以让线程放弃执行一段时间.

当然在这里, 我们还是总结一下:

  1. wait 需要搭配 synchronized 使用. sleep 不需要.
  2. wait 是 Object 的方法 sleep 是 Thread 的静态方法

总结

✨ 本文主要讲了线程安全问题, 给大家讲解了什么是线程安全, 引起线程不安全的原因有哪些, 以及如何解决线程不安全问题, 搭配 synchronized, volatile 关键字进一步讲解线程安全问题, 又讲了如何用 wait()notify() 方法来控制线程的执行顺序.
✨ 想了解更多的多线程知识, 可以收藏一下本人的多线程学习专栏, 里面会持续更新本人的学习记录, 跟随我一起不断学习.
✨ 感谢你们的耐心阅读, 博主本人也是一名学生, 也还有需要很多学习的东西. 写这篇文章是以本人所学内容为基础, 日后也会不断更新自己的学习记录, 我们一起努力进步, 变得优秀, 小小菜鸟, 也能有大大梦想, 关注我, 一起学习.

再次感谢你们的阅读, 你们的鼓励是我创作的最大动力!!!!!

你可能感兴趣的:(多线程学习之路,安全,java,开发语言)