线程安全问题

一、何为线程安全问题

为了理解什么叫线程安全问题,我们先给出一段代码,看看其运行结果:

class Counter{
    private int count = 0;

    public void add(){
        count++;
    }

    public int getCount() {
        return count;
    }
}

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

        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();
        }

        System.out.println(counter.getCount());
    }
}

很明显,我们创建了两个线程 t1 和 t2,每个线程都调用了 5w 次 add 方法,也就是一共调用了 10w 次 add 方法,那么我们期望得到的结果是输出值为 100000:
在这里插入图片描述
明显不是10000,我们再运行几次试试:
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
很遗憾,离100000差距很大。此时这种由于多线程引起的问题,我们就称之为线程不安全 / 线程安全问题。
此时我们就会好奇,为什么结果不是 100000,接下来我们就好好探究一下这其中的原因。

二、原因

上述代码的核心操作是 count++,这个操作我们仔细分析一下,其实本质上有三个 cpu 指令构成:

  1. load,把内存中的数据读取到 CPU 的寄存器中
  2. add,把寄存器中的值进行 +1 运算
  3. save,把寄存器中的值协会到内存中
    我们借助画图来形象的说明一下,这三步操作可能导致的问题:
    由于多线程的调度顺序是不确定的,实际执行过程中,这两个线程(t1 t2)的 ++ 操作的指令排列顺序就有很多中可能:
    1)t1 与 t2 同时执行 load、add、save
    在这里插入图片描述
    2)t1 的三个指令执行完,t2的三条指令才开始执行,
    当然也可能 t2 的先执行:
    在这里插入图片描述
    3)t1 执行 load, t2 执行 load、add、save,t1 继续执行 add、save:
    在这里插入图片描述
    已经举例了以上三种,聪明的朋友们看到这里肯定能够知道剩余的情况了,我就不再列出来了,这么多不同的执行顺序,执行完的结果可能是不同的,这也是为什么我们开始的代码得到的结果不是100000,并且每次运行结果都不相同,我们挑两个例子具体说明一下:
    举例一:
    在这里插入图片描述
    t1 和 t2 是两个线程,可能是运行在不同的 CPU 核心上,也可能是云想在同一个核心上(但是是分时复用的,并发)。这两种情况的最终效果是一致的,我们就以简单的,运行在两个CPU核心的为例:
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

第一步,t1 先执行 load,将内存中的count(此时为0)值加载到寄存器中,也为0,然后执行 add,变为1,然后执行 save ,协会到内存中,此时内存中 count 值为 1,然后 t2 执行 load,将 1 加载到寄存器中,在执行 add,变为 2, 最后将2 写会到内存中,此时 count 的结果为2,这个是符合我们预期的,我们认为这个结果没有问题。

举例二:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
第一步 t1 执行 load,将内存中的 count 值加载到 寄存器中,值为 0, 第二步 t2 执行 load,将内存中的 count 值 加载到寄存器中,值为 0,第三步 t2 执行 add,寄存器中值变为 1, 第四步t 2 执行 save,将 1 写回到内存中,第五步 t1 执行 add,寄存器中值变为 1,第六步 t1 执行 save,将 1 写回到内存中,此时做种结果为1,显然是错的,说明 bug 就产生了,相当于其中一次的自增结果被另一个给覆盖了(不是没有自增)。
此时我可以告诉大家,上述的画的已经没画出来的那么多种情况中,只有两种是没有问题的,就是 t1 执行完执行 t2 以及 t2 执行完再执行 t1 这两种,其它情况都是 1。
由此,我们总结一下线程不安全的原因(4、5两点与上述count++无关):

  1. 抢占式执行(根本原因)
  2. 多个线程修改同一个变量
  3. 修改操作不是原子的(上述的 ++ 操作可拆分为 load、add、save三个操作),一般情况下,某个操作对应单个 CPU 指令,就是原子的;如果这个操作对应多个 CPU 指令,大概率就不是原子的。
  4. 内存可见性,引起的线程不安全
  5. 指令重排序,引起的线程不安全

三、如何解决

到这里我们已经知道了上述线程不安全的原因,所以我们就从原因入手,首先对于抢占式执行我们程序员是不能改变的,第二点我们也不好改,那就只能从原子性这一点入手了,我们确实有办法让上面的count++操作办成原子的,就是加锁。

1.锁

锁的核心操作:

  1. 加锁:一旦某个线程加锁了以后没其他线程想要加锁就会陷入阻塞,知道拿着锁的线程释放锁了为止。不过需要注意的是,假设线程1拿到锁后,还有线程2、线程3、线程4…好几个线程正在阻塞等待这把锁,当线程1释放锁之后,由于抢占式执行,不一定是后面的哪个线程能拿到锁,也可能还是线程1拿到了。
  2. 解锁

2. 如何加锁

我们只需要对上面的代码使用 synchronized:

class Counter{
    private int count = 0;

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

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

        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();
        }

        System.out.println(counter.getCount());
    }
}

我们也可以将 synchronized 加到方法前面啊,效果是一样的:

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

这种直接给方法使用 synchronized 修饰,就认为是以 this 为锁对象。
如果 synchronized 修饰静态方法,此时就不是给 this 加锁了,而是给类对象加锁。
此时我们运行程序就可以得到正确的结果 :
在这里插入图片描述
这里的 synchronized() 里的参数随便一个 Object 实例就可以,而这个对象就是锁对象。此时如果有大于一个线程针对同一个对象加锁,就会出现“锁竞争”(一个线程拿到锁,其他线程进入阻塞等待)。这样加锁之后,就可以保证 count++ 操作变成原子的了。
我们需要牢记一点:如果多个线程尝试对同一个锁对象加锁,就会产生锁竞争,针对不同对象加锁,就不会有锁竞争。

四、另一种线程不安全场景

1. 问题描述

由于内存可见性,引起的线程不安全,依然是先写个代码感受一下:

public class ThreadTest2 {

    public static int flag = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 0){
                // flag 为 0 一直循环
            }
            System.out.println("t1, 循环结束");
        });

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

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

此时,我们预期的效果是:t1 通过 flag == 0 作为条件进行循环。t2 通过控制台输入一个整数赋给 flag,一旦这个整数不为 0,t1 线程循环立刻结束,进而改线程结束。
此时我们运行程序并输入一个 1 试试:
在这里插入图片描述
此时我们发现循环别说立刻结束了,它就没有结束。此时又是实际效果不等于预期效果,所以说这个也是线程安全问题,而这个问题就是由内存可见性引起的。

2. 分析

接下来我们再具体分析一下线程所做的事情:
t1 线程主要做的事情是 循环:
在这里插入图片描述
首先是一个 while 循环,判定条件是 flag == 0,此时这个判定条件在 CPU 上也是分为两步,第一步是 load(从内存读取数据到 CPU 寄存器中),第二步是 cmp(比较寄存器中的值是不是 0)。由于这个循环转速极快(因为循环体什么也没有),有可能 一秒就要执行上亿次,然后我们还需要知道一个知识:读内存比读硬盘快(快个几千倍),读寄存器比读内存快(快个几千倍)。这也就意味着 load 操作比 cmp 操作的开销要大很多。
此时编译器就发现:

  1. load 的开销很大
  2. 每次 load 的结果都一样(刚进入循环时 flag 为 0,而且循环转速很快)

基于这两点,编译器做出了一个大胆的操作,那就是把 load 操作优化掉了,这也就意味着只有第一次执行 load 才真正执行了,后续循环就只执行 cmp,不执行 load(相当于复用之前寄存器中 load 过的值)
上面的这个大胆的操作是编译器优化的手段,编译器优化是非常普遍得事情。而所谓的编译器优化,就是能够智能的调整咱们程序员的代码执行逻辑,在保证程序结果不变的前提下,通过加减语句,通过语句变换,通过一系列的操作,让整个程序的执行效率打打提升。
其中编译器对“程序执行结果不变”这个判定在单线程环境下是非常准确的,但是在多线程就不一定了!就可能导致,调整之后,效率确实高了,但是结果变了(编译器出现了误判),这就引起了 bug。
所以说,所谓的内存可见性问题,就是在多线程环境下,编译器对于代码的优化,产生了误判,从而引起 bug。

3. 解决方法

此处咱们的处理方式,就是让编译器针对这个场景的优化暂停。如何做到让编译器暂停优化呢,使用 volatile 关键字。被 volatile 修饰的变量,此时编译器就会禁止上述优化,也就能保证每次都是从内存重新读取数据。
此时我们在代码中变量前面加上 volatile 修饰:

volatile public static int flag = 0;

再次运行程序:
在这里插入图片描述
结束的很快,问题解决。
补充说明:volatile 不保证原子性,适用于一个线程读,一个线程写这样的场景。synchronized 适用于多个线程写的场景。
除此之外,其实 volatile 还可以禁止指令重排序,这个指令重排序也是编译器优化的策略,就是调整了代码的执行顺序,让程序更高效,当然前提也是保证整体逻辑不变。举个例子来说明:
假设某天小明要去买书:A《明解C语言》 B《历史的温度》 C 《平凡之路》 D《白马山庄杀人案》,小明要买这四本书,此时的ABCD就是四条指令,按道理说就是按照ABCD的顺序来执行的:
在这里插入图片描述
此时就得从入口先到A再去B再去C再去D,最后出去,显然这样可以达到目的,但是很明显这样比较低效,因为要绕着书店转好几圈,那么小明就可一调整买书的顺序,不按ABCD来,按ADBC来买:
在这里插入图片描述
这样小明也达成了目的,而且效率更高了。
此时的优化和前面的内存可见性是一样的,必须保证调整前和调整后的结果保持一致,也是单线程下容易保证,但是多线程不好说了。这个问题代码不好演示,因为绝大多数情况下都是没有问题的,所以我们就脑补一下:
在这里插入图片描述

上面这个图片的意思是有两个线程 t1 和 t2,线程之外有一个 s 是 Student 的对象但是还没有实例化,t1线程就对 s 进行实例化,t2 线程拿着实例化后的 s 调用其 learn()。
其中 s = new Student() 这个操作大体可以分为三步操作:

  1. 申请内存空间
  2. 调用构造方法(初始化内存的数据)
  3. 把对象的引用赋值给 s (内存地址的赋值)
    这三个操作如果是单线程环境,可以进行指令重排序:
    第一步还是 1,这个不能变,但是 2、3 两步可以 2、3 顺序,也可以 3、2 顺序,结果都是一样的。但是此时还有个线程 t2 在操作,如果发生了指令重排序,t1 线程执行顺序是 132,那么就可能出现这种情况:
    t1 在执行完 13,将要执行 2 的时候,t2 执行了 s.learn(),此时 s 不是 null,但是由于并没有初始化,所以此时调用方法还会出现什么情况,不好说,很可能产生 bug,这个问题 synchronized 和 volatile 都可以解决。

5. CAS 介绍

CAS就是 寄存器 A 的值和内存 M 的值进行对比,如果值相同,就把寄存器 B 的值和 M 的值进行交换,看字面好修昂 CAS 是好几步操作,但是实际上,CAS 操作是一条 CPU 指令。换句话说,这个 CAS 操作是原子的。
基于 CAS 就可以实现很多操作

  1. 实现原子类
    标准库中提供了 AtomicInteger 类,使用这个类来进行 ++ 操作,不需要加锁就是线程安全的,我们代码演示一下:
public static void main(String[] args) throws InterruptedException {
        AtomicInteger m = new AtomicInteger(0);
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                // 这个就相当于 前置++
                m.getAndIncrement();
            }
        });
        t1.start();
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                m.getAndIncrement();
            }
        });
        t2.start();
        
        t1.join();
        t2.join();
        System.out.println(m);
    }

此时我们运行程序:
在这里插入图片描述
此时我们可以多运行几次看看结果,都是 100000。这就是 CAS 保证的线程安全。
接下来我们就来讲解一下,CAS 到底是怎么保证的,我们使用一段伪代码来说明:

class AtomicInteger{
	private int value;
	public int getAndIncrement(){
		int oldValue = value;
		while(CAS(value, oldValue, oldValue + 1) != true){
			oldValue = value;
		}
		return oldValue;
	}
}

这里的 oldValue 可以视为是一个寄存器,由于 java 中无法表示一个在寄存器中的值,我们这里就用变量表示了。
getAndIncrement 这个方法,就是先把 value 值加载到寄存器,然后比较 oldValue 值与 value 值是否相等,如果相等,那么就把 oldValue + 1 赋给 value,这就相当于 value++ 了,然后 CAS 返回 true,结束循环。反之,如果 value 与 oldValue 值不相等,那么 CAS 就会返回 false,进入循环,重新设置 oldValue 的值。这个代码虽然是刚把 value 赋值给 oldValue,然后就比较,看起来好像不可能不相等,但是经过上面的学习我们知道,在多线程环境下,还是有可能不相等的,一旦不相等,就会进入循环,把新的 value 值读进寄存器,赋给 oldValue,这也就保证了线程安全问题。

  1. 实现自旋锁
    还是伪代码来说明一下:
class SpinLock{
        private Thread owner = null;

        public void lock(){
            while (CAS(this.owner, null, Thread.currentThread())) {

            }
        }
        public void unlock(){
            this.owner = null;
        }
}

这里的 owner 记录当前的锁被哪个线程持有,为 null 就是没人持有。此时循环里的CAS :如果当前 owner 为 null,比较就成功,就把当前的线程的引用设置到 owner 中,加锁完成。如果比较不成功,意味着 owner 非空,已经有线程持有了,此时 CAS 就啥也不干,返回 false,继续循环,此时这里的循环会转的很快,不停地尝试询问这里的锁是不是释放了。

CAS 的 aba 问题

CAS 的关键在于对比 内存和寄存器 的值,看是否相等,其实就是通过这个对比,来检测内存是不是改变过。但是现在有这样一种情况,那就是对比是一样的,但是并不是没变过,比如 内存中的值 从 A -> B -> A,此时就会有一定概率出问题。
如何解决 aba 呢,如果我们约定数据只能单方面变化,问题就迎刃而解了。如果需求要求该数值技既能增加也能减少,那我们可以引入一个版本号变量,约定版本号只能增加(每次修改都增加一个版本号),那么每次 CAS 对比的时候就不是对比数值了,而是对比版本号有没有变化

  • 15
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 18
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

不想菜的鸟

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

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

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

打赏作者

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

抵扣说明:

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

余额充值