线程安全问题

文章详细解释了线程不安全的原因,包括原子性、可见性和指令重排序问题。接着介绍了synchronized的特性,如互斥、内存可见性和可重入性,并展示了不同使用场景。volatile关键字用于保证内存可见性,但不保证原子性。wait和notify用于线程间的通信,wait使线程等待,notify唤醒等待线程。最后,提出了保证线程安全的思路,包括避免共享资源、使用不可变对象等策略。
摘要由CSDN通过智能技术生成

1. 线程不安全的原因

  1. 修改共享数据
    一般变量都是在堆上存储, 多个线程可以共享访问,如果多个线程同时修改就可能造成数据错误.

  2. 原子性
    我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。
    一条 java 语句不一定是原子的,也不一定只是一条指令,例如我们常用的 i++,其实是由三步操作组成的:

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

    如果当前线程执行到一半CPU切换到另外一个线程就会出现问题
    在这里插入图片描述

    如上图所示线程1 读取到 0 然后切换到线程2 此时线程2 读到的也是 0, 线程2 执行完之后 i = 1, 切换回线程1 之后因为线程1 读到的是 0 所以还是会把 i 赋值为 1, 相当于两次++ 但是 i 只增加了1 ,此时就会出现问题.

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

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

2. synchronized

2.1 synchronized 特性

  1. 互斥
    synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.
    进入 synchronized 修饰的代码块, 相当于 加锁
    退出 synchronized 修饰的代码块, 相当于 解锁
    可以把这个行为想象成上公共场所当一个人进入厕所时会把门锁上, 后边的人想要上厕所就需要排队等待.在这里插入图片描述
    synchronized的底层是使用操作系统的mutex lock实现的.

  2. 内存可见性
    synchronized 的工作过程:

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

    所以 synchronized 也能保证内存可见性.

  3. 可重入性
    synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题.
    在可重入锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息.
    如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
    解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

2.2 synchronized 的使用场景

  1. 直接修饰普通方法:
public class SynchronizedDemo {
    public synchronized void methond() {
   }
}
  1. 修饰静态方法:
public class SynchronizedDemo {
    public synchronized static void method() {
   }
}
  1. 修饰代码块:
public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            
       }
   }
}

3. volatile

volatile 修饰的变量, 能够保证 “内存可见性”.
我们来看下面一段代码:

public static void main(String[] args) {

        Thread t1 = new Thread(() -> {
            while (test.count == 0) {

            }
            System.out.println("t1结束");
        });
        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.println("请输入count");
            test.count = sc.nextInt();

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

按照我们预想的在输入一个非零的值时 t1 就会结束, 然而实际结果却是这样:
在这里插入图片描述
很多人看的这里就懵了😵,明明count 的值已经修改为什么 t1 还没有结束?
这就是今天我们要提到的内存可见性问题,.
每一个线程都有自己的工作内存, 当需要读取变量时就会访问该内存, 当工作内存多次访问主存发现变量并未修改, 这时就会产生编译器优化, 不在访问主存, 所以 t1 这个线程访问到的 count 的值一直就是 0 就导致线程不会停止.
解决这个问题的方法就是在变量前 加上 volatile 这个关键字, 让编译器不在优化,强制读写内存.
这个时候我们在来看一下加上 volatile 之后程序运行的效果:
在这里插入图片描述
由此可见, 当我们输入一个非零的值时 t1 这个线程就会从内存中读取到 count 的变化从而结束线程.
注意: volatile 不能保证原子性

4. wait 和 notify

4.1 wait

wait 做的事情:
使当前执行代码的线程进行等待. (把线程放到等待队列中)
释放当前的锁
满足一定条件时被唤醒, 重新尝试获取这个锁.
wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.
wait 结束等待的条件:
其他线程调用该对象的 notify 方法.
wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.

4.2 notify

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

5. 总结-保证线程安全的思路

  1. 使用没有共享资源的模型
  2. 适用共享资源只读,不写的模型
    1. 不需要写共享资源的模型
    2. 使用不可变对象
  3. 直面线程安全
    1. 保证原子性
    2. 保证顺序性
    3. 保证可见性
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值