• 万字总结线程安全问题


    目录

    1. 线程安全

    1.1 线程不安全的原因

    1)修改共享数据

    2)原子性

    2)可见性

    2. synchronized 关键字-监视器锁 monitor lock

    2.1 synchronized 的特性

    1)互斥

    2)可重入

    2.2 使用 synchronized 解决上面的线程不安全问题

    2.3 synchronized使用

    2.4 什么情况会发生阻塞等待

    3. Java标准库中的线程安全类

    4. volatile关键字

    5. wait 和 notify

    5.1 wait() 方法

    5.2 notify()方法

    5.3  notifyAll()方法

    5.4 wait 和 sleep 的对比(面试题)


    1. 线程安全

    线程安全(Thread Safety)是指在多线程环境中,当多个线程同时访问共享的数据或资源时,不会出现不可预期的错误或不一致的结果。线程安全的目标是确保多线程并发操作不会导致数据损坏、程序崩溃或不正确的计算结果。

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

    可以通过代码来看一下线程不安全的情况是什么样子的:

    1. public class Demo1 {
    2. static class Counter{
    3. public int count = 0;
    4. void increase(){
    5. count++;
    6. }
    7. }
    8. public static void main(String[] args) throws InterruptedException {
    9. final Counter counter = new Counter();
    10. Thread t1 = new Thread(() -> {
    11. for (int i = 0; i < 50000; i++) {
    12. counter.increase();
    13. }
    14. });
    15. Thread t2 = new Thread(() -> {
    16. for (int i = 0; i < 50000; i++) {
    17. counter.increase();
    18. }
    19. });
    20. t1.start();
    21. t2.start();
    22. t1.join();
    23. t2.join();
    24. System.out.println(counter.count);
    25. }
    26. }

    直接看运行结果:

    此时程序就没有按照我们实现预期的结果执行。很明显我们想要对count进行十万次自增,然而程序的执行结果并不是这样子。这就是因为线程安全问题导致的程序bug。

    1.1 线程不安全的原因

    1)修改共享数据

    上面的线程不安全的代码中, 涉及到多个线程针对 counter.count 变量进行修改.
    此时这个 counter.count 是一个多个线程都能访问到的“共享数据”

    2)原子性

    将客户端A、B看做成两个线程。当客户端A执行判断时候还有一张票,于是执行到第一步,想要将票卖掉,还没有执行更新数据库时。客户端B也检查了票数,发现此时的票数也是大于0,于是又卖了一次票。后面A,B两个客户端都对数据库进行了更新,这样就出现了一张票被卖两次的情况。

    什么是原子性?

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

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

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

    在上面的代码示例中我们看到的n++实际上就可以分成三步:

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

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

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

    2)可见性

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

    Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
    目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.

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

    由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 "副本". 此时修改线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化。因此也就导致了线程不安全问题。

    1)初始情况下,两个线程的工作区内容保持一致。

    2) 一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的工作内存的 a 的值也不一定能及时同步。

    此时代码就容易出现因为线程不安全导致的bug。

    3)代码顺序性

    先了解一下代码重排序。

    比如现在又一段代码是:

    1.区菜鸟驿站拿快递;

    2.回宿舍;

    3.去菜鸟驿站寄快递。

    如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以避免来回再宿舍和驿站之间跑动。

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

    2. synchronized 关键字-监视器锁 monitor lock

    2.1 synchronized 的特性

    1)互斥

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

    synchronized 用的锁是存在java对象头里的。

    可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 "锁定" 状态(类似于厕所的 "有人/无人").
    如果当前是 "无人" 状态, 那么就可以使用, 使用时需要设为 "有人" 状态.
    如果当前是 "有人" 状态, 那么其他人无法使用, 只能排队。

    当一个线程先上了锁,那么其他线程只能等待这个线程释放。

    理解阻塞等待:

    针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁.
    注意:
    上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这
    也就是操作系统线程调度的一部分工作.
    假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则

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

    2)可重入

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

    1. public class Demo2 {
    2. static class Counter1 {
    3. public int count = 0;
    4. synchronized void increase1() {
    5. System.out.println("获取到第一把锁");
    6. increase2();
    7. }
    8. synchronized void increase2() {
    9. System.out.println("获取到内部锁");
    10. count++;
    11. }
    12. }
    13. public static void main(String[] args) {
    14. Counter1 counter1 = new Counter1();
    15. Thread t1 = new Thread(() -> {
    16. counter1.increase1();
    17. });
    18. t1.start();
    19. }
    20. }

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

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

    2.2 使用 synchronized 解决上面的线程不安全问题

    1. public class ThreadDemo6 {
    2. public static class Counter {
    3. public int count = 0;
    4. synchronized void increase() {
    5. count++;
    6. }
    7. }
    8. public static void main(String[] args) throws InterruptedException {
    9. final Counter counter = new Counter();
    10. Thread t1 = new Thread(() -> {
    11. for (int i = 0; i < 50000; i++) {
    12. counter.increase();
    13. }
    14. });
    15. Thread t2 = new Thread(() -> {
    16. for (int i = 0; i < 50000; i++) {
    17. counter.increase();
    18. }
    19. });
    20. t1.start();
    21. t2.start();
    22. t1.join();
    23. t2.join();
    24. System.out.println(counter.count);
    25. }
    26. }

    2.3 synchronized使用

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

    1)修饰普通方法:

    1. public class Demo3 {
    2. public synchronized void method1() {
    3. // 这是一个普通方法,锁定的是实例对象
    4. }
    5. }

    2)修饰静态方法:

    1. public class Demo3 {
    2. public static synchronized void method1() {
    3. // 这是一个静态方法,锁定的是类对象
    4. }
    5. }

    修饰普通方法和修饰静态方法的区别:

    • 锁的对象不同

      • 修饰普通方法:当使用synchronized修饰一个普通方法时,锁对象是调用该方法的实例对象。这意味着不同实例对象上的相同方法可以并行执行,因为它们使用不同的锁。
      • 修饰静态方法:当使用synchronized修饰一个静态方法时,锁对象是类本身,而不是实例对象。这意味着无论调用该静态方法的是哪个实例对象,都会共享同一个锁。
    • 影响范围不同

      • 修饰普通方法:每个实例对象都有自己的锁,因此同一时刻可以有多个不同实例的相同方法在不同线程中并行执行。
      • 修饰静态方法:无论是哪个实例对象调用静态方法,都会共享同一个锁,因此只能有一个线程同时执行该静态方法,不管是哪个实例对象调用它。
    • 静态方法锁的范围更广:由于静态方法的锁是类级别的,因此它可以用于控制类级别的资源或操作,而不仅仅是实例级别的资源或操作

    3)修饰代码块

    修饰代码块也分为两种情况:

    锁当前对象:

    1. public class SynchronizedDemo {
    2. public void method() {
    3. synchronized (this) {
    4. }
    5. }
    6. }

    锁类对象:

    1. public class SynchronizedDemo {
    2. public void method() {
    3. synchronized (SynchronizedDemo.class) {
    4. }
    5. }
    6. }

    2.4 什么情况会发生阻塞等待

    情况一:两个(多个)线程竞争同一把锁。此时会发生阻塞等待。

    情况二:两个线程竞争两把不同的锁。此时不会发生阻塞等待。

    情况三:两个线程其中一个加锁,另一个没有加锁。此时不会发生阻塞等待。

    3. Java标准库中的线程安全类

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

    • ArrayList
    • LinkedList
    • HashMap
    • TreeMap
    • HashSet
    • TreeSet
    • StringBuilde

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

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

    4. volatile关键字

    volatile 能保证内存可见性

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

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

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

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

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

    代码示例:

    在这个代码中

    • 创建两个线程 t1 和 t2
    • t1 中包含一个循环, 这个循环以 flag == 0 为循环条件.
    • t2 中从键盘读入一个整数, 并把这个整数赋值给 flag.
    • 预期当用户输入非 0 的值的时候, t1 线程结束.
    1. public class ThreadDemo8 {
    2. static class Counter {
    3. public int flag = 0;
    4. }
    5. public static void main(String[] args) {
    6. Counter counter = new Counter();
    7. Thread t1 = new Thread(() -> {
    8. while (counter.flag == 0) {
    9. // do nothing
    10. }
    11. System.out.println("循环结束!");
    12. });
    13. Thread t2 = new Thread(() -> {
    14. Scanner scanner = new Scanner(System.in);
    15. System.out.println("输入一个整数:");
    16. counter.flag = scanner.nextInt();
    17. });
    18. t1.start();
    19. t2.start();
    20. }
    21. // 执行效果
    22. // 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug) 因此需要加volatile关键字让县城修改的变量别其他线程看到(内存可见)
    23. }

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

     如果给flag 加上volatile:

    1. static class Counter {
    2. public volatile int flag = 0;
    3. }

    此时,当用户输入非0的数字,t1线程能够从主存中重新读取变量,从而感受到flag的变化,循环结束。

    volatile不保证原子性

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

    代码示例
    这个是最初的演示线程安全的代码.

    • 给 increase 方法去掉 synchronized
    • 给 count 加上 volatile 关键字.
    1. public class ThreadDemo6 {
    2. public static class Counter {
    3. public volatile int count = 0;
    4. void increase() {
    5. count++;
    6. }
    7. }
    8. public static void main(String[] args) throws InterruptedException {
    9. final Counter counter = new Counter();
    10. Thread t1 = new Thread(() -> {
    11. for (int i = 0; i < 50000; i++) {
    12. counter.increase();
    13. }
    14. });
    15. Thread t2 = new Thread(() -> {
    16. for (int i = 0; i < 50000; i++) {
    17. counter.increase();
    18. }
    19. });
    20. t1.start();
    21. t2.start();
    22. t1.join();
    23. t2.join();
    24. System.out.println(counter.count);
    25. }
    26. }

    最终 count 的值仍然无法保证是 100000.

    5. wait 和 notify

    由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知.
    但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序.

    完成这个协调工作, 主要涉及到三个方法

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

    注意: wait, notify, notifyAll 都是 Object 类的方法.

    5.1 wait() 方法

    wait 做的事情:

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

    wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.

     wait 结束等待的条件:

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

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

    1. public class Demo3 {
    2. public static void main(String[] args) throws InterruptedException {
    3. Object object = new Object();
    4. synchronized (object) {
    5. System.out.println("等待中...");
    6. object.wait();
    7. System.out.println("等待结束");
    8. }
    9. }
    10. }

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

    5.2 notify()方法

    notify方法是唤醒等待的线程。

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

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

    • 创建 WaitTask 类, 对应一个线程, run 内部循环调用 wait.
    • 创建 NotifyTask 类, 对应另一个线程, 在 run 内部调用一次 notify
    • 注意, WaitTask 和 NotifyTask 内部持有同一个 Object locker. WaitTask 和 NotifyTask 要想配合就需要搭配同一个 Object.
    1. public class WaitAndNotify {
    2. static class WaitTask implements Runnable {
    3. private Object locker;
    4. public WaitTask(Object locker) {
    5. this.locker = locker;
    6. }
    7. @Override
    8. public void run() {
    9. synchronized (locker) {
    10. while (true) {
    11. try {
    12. System.out.println("wait 开始");
    13. locker.wait();
    14. System.out.println("wait 结束");
    15. } catch (InterruptedException e) {
    16. e.printStackTrace();
    17. }
    18. }
    19. }
    20. }
    21. }
    22. static class NotifyTask implements Runnable {
    23. private Object locker;
    24. public NotifyTask(Object locker) {
    25. this.locker = locker;
    26. }
    27. @Override
    28. public void run() {
    29. synchronized (locker) {
    30. System.out.println("notify 开始");
    31. locker.notify();
    32. System.out.println("notify 结束");
    33. }
    34. }
    35. }
    36. public static void main(String[] args) throws InterruptedException {
    37. Object locker = new Object();
    38. Thread t1 = new Thread(new WaitTask(locker));
    39. Thread t2 = new Thread(new NotifyTask(locker));
    40. t1.start();
    41. Thread.sleep(1000);
    42. t2.start();
    43. }
    44. }

    5.3  notifyAll()方法

    notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程.
    范例:使用notifyAll()方法唤醒所有等待线程, 在上面的代码基础上做出修改.

    1. public class WaitAndNotify {
    2. static class WaitTask implements Runnable {
    3. private Object locker;
    4. public WaitTask(Object locker) {
    5. this.locker = locker;
    6. }
    7. @Override
    8. public void run() {
    9. synchronized (locker) {
    10. while (true) {
    11. try {
    12. System.out.println("wait 开始");
    13. locker.wait();
    14. System.out.println("wait 结束");
    15. } catch (InterruptedException e) {
    16. e.printStackTrace();
    17. }
    18. }
    19. }
    20. }
    21. }
    22. static class NotifyTask implements Runnable {
    23. private Object locker;
    24. public NotifyTask(Object locker) {
    25. this.locker = locker;
    26. }
    27. @Override
    28. public void run() {
    29. synchronized (locker) {
    30. System.out.println("notify 开始");
    31. locker.notify();
    32. System.out.println("notify 结束");
    33. }
    34. }
    35. }
    36. public static void main(String[] args) throws InterruptedException {
    37. Object locker = new Object();
    38. Thread t1 = new Thread(new WaitTask(locker));
    39. Thread t3 = new Thread(new WaitTask(locker));
    40. Thread t4 = new Thread(new WaitTask(locker));
    41. Thread t2 = new Thread(new NotifyTask(locker));
    42. t1.start();
    43. t3.start();
    44. t4.start();
    45. Thread.sleep(1000);
    46. t2.start();
    47. }
    48. }

    此时可以看到,调用notify只可以唤醒一个线程。

    如果将NotifyTask 中的 run 方法, 把 notify 替换成notifyAll方法的话:

    1. static class NotifyTask implements Runnable {
    2. private Object locker;
    3. public NotifyTask(Object locker) {
    4. this.locker = locker;
    5. }
    6. @Override
    7. public void run() {
    8. synchronized (locker) {
    9. System.out.println("notifyAll 开始");
    10. locker.notifyAll();
    11. System.out.println("notifyAll 结束");
    12. }
    13. }
    14. }

    此时可以看到, 调用 notifyAll 能同时唤醒 3 个wait 中的线程。

    注意:

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

    5.4 wait 和 sleep 的对比(面试题)

    其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,唯一的相同点就是都可以让线程放弃执行一段时间。

    当然为了面试的目的,我们还是总结下:

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

  • 相关阅读:
    diffusion model
    Nodejs 开发者路线图 2022 零基础学习指南
    Liunx常用命令
    应用平台桌面菜单由常规拖放,换成ht.Grid布局操作步骤
    【知识专栏丨python数分实战】电商数据分析案例
    Vue2.0开发之——babel-loader处理高级JS语法(09)
    MySQL常用函数
    微服务环境搭建
    汽车IVI中控开发入门及进阶(二十五):CVBS视频流
    linux中如何设置多个redis进程并且设置独立密码?
  • 原文地址:https://blog.csdn.net/qq_45875349/article/details/132805556