线程的安全问题 和 解决方案

在多线程中, 线程是非常高效的操作,但是往往存在着一些安全隐患方面的问题.

举一个例子 如果对两个线程进行累加五万次操作, 然后将结果返回到终端 , 此时我们就会生出以下代码:

class Counter{
    //这个变量就是两个线程要自增的变量
    public int count ;
    public void increase(){
        count ++;
    }

}
public class Main {
    private static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        //等待线程执行完毕 再打印count 
        t1.join();
        t2.join();
        //在main 中打印两个线程自增得到的count 结果
        System.out.println(counter.count);
    }
}

但是没有得到预期的打印结果10_0000

第一次执行:

第二次执行 :

第三次执行:

可以看出 并不是偶然而是本身确实达到不了预期的结果, 这就是多线程的安全问题

线程不安全的原因:

线程的特性 :

因为线程是抢占式执行 , 因此线程的调度是随机的 , 因此对一个变量进行操作的时候, 结果是充满未知的

对同一个变量进行读取操作 :

上述操作是多个线程修改同一个变量 ,在CPU内, 当线程修改一个变量的时候, 会分为三个指令

  • 从内存中拿到 变量放到寄存器中 (load)

  • 再将变量再寄存器内进行 ++ 操作 (add)

  • 再将变量放回到内存中(save )

但是因为线程是抢占式执行, 因此存在如下可能:

当两个线程同时将变量进行了load操作

此时寄存器和内存的情况是这样的:

之后两个 线程都在寄存器内进行 ++ 操作

寄存器和内存中的情况:

再将寄存器储存的值放进去

此时内存中的情况是这样的:

此时就出现了问题, 当前两个线程同时对变量自增了一次, 但是实际内存中改变的结果却是1!

这就是多个线程同时修改同一个变量而存在的问题.

原子性:

针对该变量的操作不是原子性的, 所谓原子性 , 就是指CPU执行的三个指令 , 如果这三个指令 转为一个指令 , 就是原子性的.

内存可见性:

前三条证明了 对变量修改是存在线程安全问题的, 那么如果对线程不进行修改, 只进行读取操作就不会出现问题了吗? 当然不是, 其实如果只对变量进行读取操作, 会引发内存可见性的问题!

代码证明 :

让线程不判断读取操作, 如果isQuit 变为 0 以外的数字就结束循环.

public class Main {
    private static int isQuit = 0;
    public static void main(String[] args) {
        Thread t = new Thread(( ) -> {
            //读取操作
            while (isQuit == 0){

            }
            System.out.println( "循环结束 ,t 线程退出");
        });
        t.start();
        Scanner sc = new Scanner(System.in);
        System.out.println("请输入一个 isQuit 的值: ");
        //修改
        isQuit = sc.nextInt();
        System.out.println("main 线程执行完毕! ");
    }
}

此时会看到以下结果:

可以看到 主线程已经结束了, isQuit 结果已经被修改了, 但是 线程t 并没有结束任务的执行 , 而是在不断的读取isQuit 变量.

出现上述情况的原因就是因为没有保证内存的可见性, 解释如下:

t线程在循环中读取这个变量, 但是, 读取内存的操作相比读取寄存器是一个非常低效的操作,

因JAVA对代码进行了优化: 此时不再从内存中读取数据 , 而是从寄存器中读取, (也就是不再执行load操作了)

此时一旦 t线程进行了这样的操作, 此时main 线程修改了 isQuit 的值, 此时 t 线程就不在感知到了.

(为什么需要进行优化? 因为如果编译器会对程序员写出的代码做出的一些调整, 保证原有的逻辑不变的前提之下, 程序的执行效率能够大大的提升)

指令重排序 :

指令重排序其实也是一种编译器优化的操作

首先构造对象需要三个步骤,

假如有一个对象 , 假设有两个线程 此时线程1 在进行 s 的初始化, 而线程2 到了判断s 是否为空的情况的时候,

但是此时 Java 编译器对指令进行了优化 , 如果将 2 3 顺序进行调换

此时 就会出现不可预期的问题! 因为此时 s2 线程中判定的是 true 就会进行调用对象的方法之类的操作... 但是此时该对象还没有被初始化数据! 那么后果是不堪设想的...


线程安全的解决方案:

synchronized 加锁操作

所谓 synchronized , 就是Java 的一种关键字, 当一个方法加上了该关键字, 就相当于对该方法进行了"加锁"操作, 什么是加锁?

就相当于当多个线程对同一把锁进行争夺的时候, 只有抢到锁的线程才可以进行锁内的操作

就类似于这样的图 , 三个滑稽老铁(也就是三个线程)去上厕所(调用构造方法) , 但是只有一扇门, 门上了锁, 如果没有锁进不去,

此时第一位第一位滑稽老铁拿到了锁,此时门上就上了锁, 其他的滑稽老铁都进不去, 只有当 第一个滑稽老铁 上完厕所, 出来了之后, 释放了锁, 此时 三个人才可以竞争锁, 看谁进去上厕所 (有可能是1 , 也有可能是2 , 也有可能是3).

应该此时大家就应该能懂 加锁的意义了: 让锁内的方法, 或者代码只能让拿到锁的线程进行执行, 不存在同时进入方法内的情况.

因此, 针对 线程不安全的前三条, 可以 通过 synchronized 关键字对需要加锁的方法进行上锁,

class Counter{
    //这个变量就是两个线程要自增的变量
    public int count ;
    //最好的方法就是加锁 如果线程1 拿到这个锁的方法..
    // 线程2在线程1枷锁成功的时候 线程2不断尝试枷锁,这个过程中 线程2 就一直处于阻塞状态BLOCKED
    // 阻塞一直会持续到 线程1 释放锁
    synchronized public void increase(){
        count ++;
    }

}

执行结果如下:

关于synchronized 不仅可以对方法进行加锁, 还可以对一部分代码进行加锁操作

直接修饰普通方法

使用synchronized 的时候本质上是在针对某个"对象" 进行加锁 (设置了一个对象的"对象头"上存在标志位)

如果修饰的是普通方法 那么也就相当于把锁对象 指定为this 了

class Counter{
    public int count ;
    //对普通方法直接加锁
    synchronized public void increase(){
        count ++;
    }

}

修饰代码块

需要显式指定针对那个对象加锁(Java中的任意对象都可以成为锁对象);

此时线程是可以进入Counter类内部, 但是没有获得this 对象的锁 是不能进行count++ 操作的,

只有拿到了锁的线程才可以修改变量.

class Counter{
    public int count ;
    public void increase(){
        //针对count++ 进行加锁, this 关键字就是该对象, 拿到该对象的线程可以进行操作
        synchronized (this) {
            count ++;
        }    
    }

}

修饰一个静态方法(静态方法没有this)

所谓静态方法 -> "类方法"

相当于针对当前类的对象进行加锁

Counter.class (反射)

volatile关键字

volatile关键字可以保证内存可见性, 还可以禁止指令重排序 (禁止Java编译器对代码的优化)

public class Main {
    volatile private static int isQuit = 0;
    public static void main(String[] args) {
        Thread t = new Thread(( ) -> {
            //读取操作
            while (isQuit == 0){

            }
            System.out.println( "循环结束 ,t 线程退出");
        });
        t.start();
        Scanner sc = new Scanner(System.in);
        System.out.println("请输入一个 isQuit 的值: ");
        //修改
        isQuit = sc.nextInt();
        System.out.println("main 线程执行完毕! ");
    }
}

结果如下:

可以看出 , 当isQuit被修改之后, 线程确实已经结束了, 不再继续执行任务, 因此可以看出,

volatile 能够保证内存可见性.

但是请注意 , volatile并不能保证原子性, 因此要想保证线程安全 ,需要搭配synchronized 一起使用才可以保证线程的安全性!!!


觉得有用的话 麻烦点赞收藏支持一下 谢谢!!! 🌹🌹🌹

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值