2024年网络安全最全Java多线程之线程安全问题_java 线程安全

二. 线程不安全的原因和线程加锁

1. 案例分析

在上面, 我们使用多线程所写的程序将将一个初始值为0的变量自增10万次, 但得到的实际得到的结果要比预期的10万小, 万恶之源还是线程的抢占式执行, 线程调度的顺序是随机的, 就造成线程间自增的指令集交叉, 导致运行时出现两次或者多次自增但值只会自增一次的情况, 导致得到的结果会偏小.

一次的自增操作本质上可以分成三步:

  1. 把内存中变量的值读取到CPU的寄存器中(load).
  2. 在寄存器中执行自增操作(add)
  3. 将寄存器的值保存至内存中(save)

如果是两个线程并发的执行count++, 此时就相当于两组 load, add, save进行执行, 此时不同的线程调度顺序就可能会产生一些结果上的差异.

下面的时间轴总结了一个变量由两个线程并发进行两次自增时, 常见几种常见的情况:

  • 情况1

线程间指令集无交叉, 实际结果和预期结果一致.

img

  • 情况2

线程间指令集存在交叉, 实际结果小于预期结果.

img

  • 情况3

线程间指令集完全交叉, 实际结果小于预期结果.

img

上面列举的三种情况并不是所有可能状况, 其他状况也类似, 可以自己尝试推导一下, 观察上面列出的情况情况, 我们不难发现出当多线程的指令集没有交叉情况出现的时侯, 程序就可以得到正确的结果; 而一旦指令集间有了交叉, 结果就可能会比预期的要小, 也就是说造成这里线程安全问题的原因在于这里的自增操作不是原子性的.

那么再观察上面有问题的结果, 思考结果一定是大于5万吗, 其实不一定, 只是这种可能性比较小, 当线程当t2自增两次或多次,t1只自增一次, 最后的效果是加1.

img

当然也有可能最后计算出来的结果是正确的, 不过再这种有问题的情况下可能就更小了, 但并不能说完全没有可能.

那么如何解决上面的线程安全问题呢, 我们只需要想办法让自增操作变成原子性的即可, 也就是让load, add, save三步编程一个整体, 也就是下面介绍的对对象加锁.

2. 线程加锁

2.1 理解加锁

为了解决由于 “抢占式执行” 所导致的线程安全问题, 我们可以针对当前所操作的对象进行加锁, 当一个线程拿到该对象的锁后, 就会将该对象锁起来, 其他线程如果需要执行该对象所限制任务时, 需要等待该线程执行完该对象这里的任务后才可以.

用现实生活中的例子来理解, 假设小明要去银行的ATM机子上办理业务, 我们知道为了安全, 每台ATM一般都在一个单独的小房间里面, 这个小房间由一扇门和一把锁, 当小明进入房间使用ATM时, 门就会自动锁上, 此时如果其他人想要使用这台ATM就得等小明使用完从房间里面出来才行, 那么这里的 “小明” 就相当于一个线程, ATM就相当于一个对象, 房间就相当于一把锁, 其他想使用这台ATM机子的人就相当于其他的线程.

img

imgimg

在Java中最常用的加锁操作就是使用synchronized关键字进行加锁.

2.2 synchronized的使用

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

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

  • 使用方式1

使用synchronized关键字修饰普通方法, 这样会给方法所对在的对象加上一把锁.

以上面的自增代码为例, 对add()方法和加锁, 实质上是个一个对象加锁, 在这里这个锁对象就是this.

class Counter {
    public int count = 0;

    synchronized public void add() {
        count++;
    }
}

对代码做出如上修改后, 执行结果如下:

img

  • 使用方式2

使用synchronized关键字对代码段进行加锁, 需要显式指定加锁的对象.

还是基于最开始的代码进行修改, 如下:

class Counter {
    public int count = 0;

    public void add() {
        synchronized (this) {
            count++;
        }
    }
}

执行结果:

img

  • 使用方式3

使用synchronized关键字修饰静态方法, 相当于对当前类的类对象进行加锁.

class Counter {
    public static int count = 0;

    synchronized public static void add() {
        count++;
    }
}

执行结果:

img

2.3 再次分析案例

我们这里再来分析一下, 为什么上锁之后, 线程就安全了, 代码如下:

class Counter {
    public int count = 0;

    public void add() {
        count++;
    }
}

public class TestDemo12 {
    public static void main(String[] args) {
        Counter counter = new Counter();

        // 搞两个线程, 两个线程分别针对 counter 来 调用 5w 次的 add 方法
        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();

        // 等待两个线程结束
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 打印最终的 count 值
        System.out.println("count = " + counter.count);
    }
}

加锁, 其实就是想要保证这里自增操作 load, add, save的原子性, 但这里上锁后并不是说让这三步一次完成, 也不是在执行这三步过程中其他线程不进行调度, 加锁后其实是让其他想操作的线程阻塞等待了.

比如我们考虑两个线程指令集交叉的情况下, 加锁操作是如何保证线程安全的, 不妨记加锁为lock,解锁为unlock, t1和t2两个线程的运行过程如下:

t1线程首先获取到目标对象的锁, 对对象进行了加锁, 处于lock状态, t1线程load操作之后, 此时t2线程来执行自增操作时会发生阻塞, 直到t1线程的自增操作执行完成后, 释放锁变为unlock状态, 线程才能成功获取到锁开始执行load操作… , 如果有两个以上的线程以此类推…

img

加锁本质上就是把并发变成了串行执行, 这样的话这里的自增操作其实和单线程是差不多的, 甚至上由于add方法, 要做的事情多了加锁和解锁的开销, 多线程完成自增可能比单线程的开销还要大, 那么多线程是不是就没用了呢? 其实不然, 对方法加锁后, 线程运行该方法才会加锁, 执行完该方法的操作后就会解锁, 此方法外的代码并没有受到限制, 这部分程序还是可以多线程并发执行的, 这样整体上多线程的执行效率还是要比单线程要高许多的.

注意:

  • 加锁, 一定要明确是对哪个对象加的锁, 如果两个线程针对同一个对象加锁, 会产生阻塞等待(锁竞争/锁冲突); 而如果两个线程针对不同对象加锁, 不会产生锁冲突.

3. 线程不安全的原因

  1. 最根本的原因: 抢占式执行, 随机调度, 这个原因我们无法解决.
  2. 代码结构.

我们最初给出的代码之所以有线程安全的原因, 是因为我们设计的代码是让两个线程同时去修改一个相同的变量.

如果我们将代码设计成一个线程修改一个变量, 多个线程读取同一个变量, 多个线程修改多个不同的变量等, 这些情况下, 都是线程安全的; 所以我们可以通过调整代码结构来规避这个问题, 但代码结构是来源于需求的, 这种调整有时候不是一个普适性特别高的方案.

  1. 原子性.

如果我们的多线程操作中修改操作是原子的, 那出问题的概率还比较小, 如果是非原子的, 出现问题的概率就非常高了, 就比如我们最开头写的程序以及上面的分析.

  1. 指令重排序和内存可见性问题

主要是由于编译器优化造成的指令重排序和内存可见性无法保证, 就是当线程频繁地对同一个变量进行读取操作时, 一开始会读内存中的值, 到了后面可能就不会读取内存中的值了, 而是会直接从寄存器上读值, 这样如果内存中的值做出修改时, 线程就感知不到这个变量已经被修改, 就会导致线程安全问题, 归根结底这是编译器优化的结果, 编译器/jvm在多线程环境下产生了误判, 结合下面的代码进行理解:

import java.util.Scanner;

class MyCounter {
    volatile public int flag = 0;
}

public class TestDemo13 {
    public static void main(String[] args) {
        MyCounter myCounter = new MyCounter();

        Thread t1 = new Thread(() -> {
            while (myCounter.flag == 0) {
                // 这个循环体咱们就空着

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

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            myCounter.flag = scanner.nextInt();
        });

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

执行结果:

img

上面的代码中, t2线程修改flag的值让t1线程结束, 但当我们修改了flag的值后线程t1线程并没有终止, 这就是编译优化导致线程感知不到内存的变化, 从而导致线程不安全.

while (myCounter.flag == 0) {
// 这个循环体咱们就空着
}

t1线程中的这段代码用汇编来理解, 大概是下面两步操作:

  1. load, 把内存中flag的值读取到寄存器中.
  2. cmp, 把寄存器的值和0进行比较, 根据比较结果, 决定下一步往哪个地方执行(条件跳转指令).

要知道, 计算机中上面这个循环的执行速度是极快的, 一秒钟执行百万次以上, 在这许多次循环中, 在t2真正修改之前, load得到的结果都是一样的, 另一方面, CPU 针对寄存器的操作, 要比内存操作快很多, 也就是说load操作和cmp操作相比, 速度要慢的多, 此时jvm就针对这些操作做出了优化, jvm判定好像是没人修改flag的值的, 于是在之后就不再真正的重复load, 而是直接读取寄存器当中的值.

所以总结这里的内存可见性问题就是, 一个线程针对一个变量进行读取操作, 同时另一个线程针对这个变量进行修改, 此时读到的值, 不一定是修改之后的值, 这个读线程没有感知到变量的变化.

但实际上flag的值是有人修改的, 为了解决这个问题, 我们可以使用volatile关键字保证内存可见性, 我们可以给flag这个变量加上volatile关键字, 意思就是告诉编译器,这个变量是 “易变” 的, 一定要每次都重新读取这个变量的内存内容, 不可以进行优化了.

class MyCounter {
    volatile public int flag = 0;
}

修改后的执行结果:

img

给大家的福利

零基础入门

对于从来没有接触过网络安全的同学,我们帮你准备了详细的学习成长路线图。可以说是最科学最系统的学习路线,大家跟着这个大的方向学习准没问题。

同时每个成长路线对应的板块都有配套的视频提供:

在这里插入图片描述

因篇幅有限,仅展示部分资料

网络安全面试题

绿盟护网行动

还有大家最喜欢的黑客技术

网络安全源码合集+工具包

所有资料共282G,朋友们如果有需要全套《网络安全入门+黑客进阶学习资源包》,可以扫描下方二维码领取(如遇扫码问题,可以在评论区留言领取哦)~

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化资料的朋友,可以点击这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值