【多线程】线程安全相关问题

本文详细探讨了多线程编程中的线程安全问题,包括抢占式执行、共享数据、原子性、内存可见性和指令重排序。通过实例讲解了synchronized、volatile关键字的作用,以及wait()和notify()方法的使用。
摘要由CSDN通过智能技术生成

📎前言

✨本篇文章主要介绍了多线程中线程安全相关的问题,文中提到了多线程编程中会遇到的一些风险, 后续将持续更新自己的学习记录,以此来督促我不断地学习与进步.希望大家关注支持!✨
🚀🚀系列专栏:【多线程】
📻📻本章内容:线程安全

我们首先给出一个例子,该段代码我们期望的要求是使count自增到10w.

class Counter {
    public int count = 0;
    public void increse() {
        count ++;
    }

}


//线程安全问题展示
public class Demo4 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

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

        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

运行上述代码,我们看到运行结果出现了bug.每次运行的结果都不同,但不会达到我们到要求。这是什么原因呢?
我们首先分析count++这个动作,这个操作本质上其实是三个步骤:

  1. 把内存中的数据加载到CPU的寄存器上(load)
  2. 把寄存器中的数据+1(add)
  3. 把寄存器中的数据写回到内存中;(save)
    通过对以上步骤的分析,我们不难想到,多线程由于并发执行,就可能会导致在一定的执行顺序下,运算时结果会被覆盖.因为线程调度是抢占式且随机的。

1.线程安全问题的原因

1.1 抢占式执行

[根本原因] 多个线程之间的调度顺序是随机的,操作系统采用抢占式执行的策略来调度线程.

1.2 修改共享数据

上面的线程不安全的代码中, 涉及到多个线程针对 count 变量进行修改.
此时这个 count 是一个多个线程都能访问到的 “共享数据”
在这里插入图片描述
count 这个变量就是在堆上. 因此可以被多个线程共享访问.

1.3 原子性

在这里插入图片描述
什么是原子性
在物质中,不可分割的最小单位称为原子.

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

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

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

特别注意的是:
一条 java 语句不一定是原子的,也不一定只是一条指令
比如刚才我们看到的 count++,其实是由三步操作组成的:

  1. 把内存中的数据加载到CPU的寄存器上
  2. 把寄存器中的数据+1;
  3. 把寄存器中的数据写回到内存中;

1.4内存可见性

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

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

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

什么是内存可见性引起的线程安全问题呢?我们再次通过一段代码来引入。

import java.util.Scanner;

public class Demo1 {
    private static volatile int isQuit =0;
    public static void main(String[] args) {

        Thread t1 = new Thread(()->{
           while (isQuit == 0) {

           }
            System.out.println("t1结束");
        });

        Thread t2 = new Thread(()->{
                Scanner scanner = new Scanner(System.in);
                System.out.println("请输入isQuit的值:");
                isQuit = scanner.nextInt();

        });


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

在这段代码中我们需要实现当用户输入一个非0的整数时t1线程就该循环结束.当我们执行这段代码时发现会一直执行下去,并没有结束循环。这显然是不符合预期的。而这里出现的问题也就是内存可见性问题

当程序在编译过程中,Java编译器和jvm可能会对代码本身进行“优化”,会对代码本身保持原有的逻辑不变的情况下,提高代码的执行效率。而且在计算机层面来看优化的效果是非常明显的。但如果当这个优化效果遇到多线程,就很有可能出现差错。

在上述代码中,t1线程的while(isQuit == 0)本质上是两个指令:

  1. load(读取内存)
  2. cmp&jump(比较并跳转)
    读取内存的操作在计算机层面是非常慢的,而此时编译器与jvm发现,在这个逻辑中代码在反复的读取isQuit的值并且每次都出来都是一样的。此时编译器就大胆的把load操作优化掉了,只执行了第一次的load,后续都直接拿寄存器中的数据进行比较了。

这时我们尽管在t2线程中修改了isQuit的值,也无法影响到t1进行。也就出现了上述的问题,也就是内存可见性问题

1.5 指令重排序

一段逻辑是这样的:

  1. 去楼下洗衣服
  2. 去教室写 10 分钟作业
  3. 去楼下取快递

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

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

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


那么针对原子性问题,我们引入了一个新的概念——加锁

2.加锁(synchronized)

加锁操作=> 相当于把一组操作打包为了一个"原子性"的操作.通过加锁使各线程的操作之间进行"互斥"

synchronized. 是Java中的一个关键字
将这关键字加在increase()方法前,我们就会针对这个自增方法进行加锁
在这里插入图片描述
而且根据synchronized的特性,在进入这个方法时就会将其上锁(lock),在出方法时就会自动解锁(unlock),这样就大大简化了加锁的步骤。
在这里插入图片描述

但是在使用这个关键字时其实是指定了某个具体的对象进行加锁,当synchronized直接修饰方法就相当于是针对this加锁.
在这里插入图片描述

如果两个线程针对同一个对象进行加锁,就会出现 锁竞争/锁冲突(一个线程加锁成功,另一个线程阻塞等待),这时就不会出现线程安全问题(可理解为排队等待上厕所)。

特别注意的是但如果是两个线程针对不同的对象加锁,就不会产生锁竞争与阻塞等待,那就仍然会出现线程安全问题。

class Counter {
    public int count = 0;
    //新建一个locked对象
    private Object locked = new Object();
    public void increse1() {
    //针对this加锁
        synchronized (this) {
            count ++;

        }
    }
    
    public void increse2() {
    //针对locked加锁
        synchronized (locked) {
            count ++;
        }
    }

}


//线程安全问题展示
public class Demo4 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

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

        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);

    }
}

上述代码针对两个不同的对象进行加锁,运行代码我们会发现又出现了之前的现象:结果出现错误
在这里插入图片描述

3.volatile 关键字

3.1volatile能够保证内存可见性

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

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

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

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

对于上述内存可见性问题的代码我们引入了一个关键字来弥补缺口——volatile,用volatile形容一个变量时,编译器就明白这个变量是“易变的”,就不会按照上述方式优化到读寄存器中。

private static volatile int isQuit =0;

此时就能保证t1在循环的过程中始终读取内存中的数据。当我们修改isQuit时,t1就可以很快的感知到。
在这里插入图片描述

3.2 volatile也可以禁止指令重排序

指令重排序也是会引起线程安全问题的原因之一,后续在单例模式中会介绍到。volatile也可以很好地解决这个问题。

3.3 synchronized也可以保证内存可见性

这个说法目前仍存在争议,很多资料说法不一,官方文档也含糊其辞。具体是因为让代码执行速度变慢没有触发优化还是因为内存可见性仍无从考证,了解即可。

4.wait()和notify()

由于多线程之间是抢占式执行的,多线程的调度也是随机的,很多时候我们希望多个线程可以按照人为规定的顺序来执行,完成多线程之间的配合工作。
通常情况下我们会使用到以下方法

  • wait()/wait(long timeout) 让当前的线程进入等待状态
  • notify()/notifyAll() 唤醒正在等待的线程
    注意:这些方法都是Object提供的方法,任意对象都可直接使用

4.1 wait()方法

wait在执行的时候会做三件事:

  1. 解锁,wait首先会尝试针对当前对象解锁。
  2. 阻塞等待
  3. 当被其他线程唤醒之后,就会尝试重新加锁,加锁成功,wait执行完毕,继续往下执行其他的逻辑

知道wait的逻辑,我们就会意识到:wait必须在synchronized中进行使用
代码示例:

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

当执行到object.wait()之后就会一直等待下去,要想唤醒它就需要用到另一个方法:notify();

4.2 notify()方法

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

有几个注意事项:

  • 要想让notify能够顺利的唤醒wait 首先要确保wait和notify是使用同一个对象进行调用的
  • wait和notify都需要放在synchronized中。(尽管notify并不涉及“解锁操作”,但也强制要求)
  • 如果进行notify时,另一个线程并没有处于wait状态,此时并不会有任何的副作用。
    代码示例:使用notify唤醒线程
public class Demo2 {
    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) {
                    throw new RuntimeException(e);

                }
        });

        t1.start();
        Thread.sleep(1000);

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

            System.out.println("notify 结束");


        });

        t2.start();
	}
}

在这里插入图片描述

4.3 notifyAll()

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

比如在多个线程中都调用了 object.wait(), 此时在 main 中调用 object.notify() 会随机唤醒上述中的一个线程 另外的线程仍然是 WAITING 状态, 如果调用 object.notifyAll() 就会将所有线程全部唤醒.
注意:虽然是同时唤醒,但在计算机层面这些线程还是存在锁竞争,所以还是一个一个唤醒的

4.4 wait()和sleep()间的差异

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

🎉总结

🎇到这里本篇文章就结束了,感谢大家的阅读。非常希望大家可以提出宝贵的建议!!
这篇文章主要介绍了影响线程安全的一些问题,并提供了一些相应的解决方法.
🎇本篇文章是博主对自己学习中所学知识的个人理解。日后我也将不断更新自己学习过程以达到温故知新。
🎇希望大家可以点赞+收藏+评论支持一下噢!
🎇继续保持关注~

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jester.F

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值