多线程编程带来的不安全问题

  • Post author:
  • Post category:其他


作者:敲代码の流川枫

博客主页:流川枫的博客

专栏:和我一起学java

语录:Stay hungry stay foolish

给大家推荐一款好用的神器


Apifox = Postman + Swagger + Mock + JMeter。集接口文档工具、接口Mock工具、接口自动化测试工具、接口调试工具于一体,提升 10 倍研发效率戳我来体验~



目录


1.观察线程不安全问题


2.出现线程不安全问题原因


2.1 根本原因


2.2 代码结构


2.3 原子性


2.4 内存可见性问题


2.5指令重排序


3.通过原子性解决线程安全问题


4.synchronized的使用方法


4.1 修饰方法


4.2 修饰代码块


1.观察线程不安全问题

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的

如果没有多线程,代码的执行顺序就是固定的,代码执行顺序固定,那么程序的结果也就是固定的,有了多线程,在抢占式执行,随即调度的机制下,代码的执行顺序就会发生改变,产生更多的变数!

代码执行顺序的可能性从一种情况变成无数种执行顺序,所以要保证无数种线程调度顺序的情况下,代码执行结果是正确的,如果不正确,线程就是不安全的!!

下面通过代码感受线程安全问题

我们定义两个线程,分别对count进行自增50000次,我们的预期结果是count为100000,在线程安全的前提下是这样的,如果线程不安全,结果肯定有差异,那如何让线程安全呢?

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

}
public class ThreadDemo14 {

    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) {
            throw new RuntimeException(e);
        }
        System.out.println("counter = "+counter.count);
    }
}

多次运行后的结果

我们预期的结果是十万,这里的结果不是并且每次结果都不一样,因此这是一个多线程带来的bug!!!

我们分析一下原因

自增一次操作分为三步,先把内存中的值读取到cpu的寄存器中(load),把cpu中的数值+1运算(add),最后把结果写到内存中(save)

由于线程是抢占式执行,两个线程中的这三个步骤中执行到任意一个指令时,线程都可能被被调度走,cpu让别的线程来执行

其中,这两个情况是没有问题的,线程安全

第一种情况

t1线程执行三个步骤,对count++后count为1,然后t2对count操作,count++后写入内存中,自增两次,count结果为2,正确.另一种情况和这种情况相反,t2先让count ++后写入内存,此时t1加载出的count是1,自增后count为2,写入内存

来看这种线程不安全的情况

t1先加载,并且自增,count等于1,但是此时没有写入内存,t2线程就开始加载,count为0,自增后为1,然后写入内存,count为1,然后t1线程写入内存,count还是1!!!就在这里出现了问题

这种情况也不安全,t1先load,此时count=0,然后t2load, 此时count=0.然后自增,count=1,写入内存中,然后t1自增,count=1,写入内存中,后一次的自增覆盖了前一次的自增,count还是1!经历了两次自增,count结果还是1,这就出bug了!

类似于之前提到事务”读未提交 read uncommitted”是相同的,相当于t1读到的是t2还没来得及提交的数据,并发事务和多线程都属于并发编程问题

这就是count结果不是十万的原因,但是也有可能结果恰好是正确的,如果线程每次调度的顺序都是上面提到的正确的顺序,结果就是正确的,但是可能性很小!

结果不是10W,那么结果一定都会大于5w吗,也不一定,如果全都出现了两个线程都自增一次,count+1这种情况,结果就不大于5w,或者,t1自增1次,t2自增了2次,count最终还是+1这种情况,就小于5w

这就属于,t2中的count自增无论多少次,还是会被t1最后给覆盖了,(t1load时count为0)count还是1

2.出现线程不安全问题原因

2.1 根本原因

抢占式执行,随即调度

2.2 代码结构

多个线程同时修改同一个变量(一个线程修改一个变量,安全.多个线程读取同一个变量,安全,像String对象,不可变对象,天然石线程安全的,无法修改,只能读取.多个线程修改多个不同的变量,安全)

因此可以通过调整代码结构来规避这个问题,这种调整不一定都能使用,不是一个普适性的方案

2.3 原子性

如果修改操作是原子的,那就线程安全,但是像上述案例中,非原子的,那就有很大概率出现线程安全问题!

那么如何让操作变成原子的呢?引入了重要的概念—-加锁

2.4 内存可见性问题

如果一个线程读,一个线程改,那么读到的结果可能不符合预期

2.5指令重排序

本质是编译器的优化出问题了 ,把代码调整了,保持逻辑不变的情况下,调整代码的执行顺序,可能会出问题

这几个问题是典型的线程不安全问题的原因,但是线程是否安全远不止这些原因,多线程运行代码 ,不出bug就是安全的!!

3.通过原子性解决线程安全问题

通过”加锁操作”把非原子的操作转化为原子的操作

我们对第一个案例进行改动

对方法加了synchronized之后,进入该方法,就会加锁,出了方法就会解锁

一个线程获取到🔒之后,除非他主动释放,否则不能强占

如果两个线程同时获取这个锁,只有一个线程能成功获取到,另一个没获取到的线程阻塞等待(BLOCKED)一直等待到上一个线程释放锁之后,当前线程才能获取到🔒

lock的阻塞就把刚才的t2的load推迟到t1的save之后了,也就避免了脏读问题!!(在t1执行提交数据后,t2再读数据)

再来看效果,多次执行后都是准确的结果,线程安全问题得到解决!!

加锁之后,代码的执行速度肯定会降低,但是为了主线任务,保证结果的正确性,是必须要加锁的

4.synchronized的使用方法

加锁要明确对哪个对象进行加锁,如果两个线程同时对一个对象加锁,就会产生阻塞等待(锁竞争/锁冲突),如果两个线程对不同对象加锁,不会产生锁竞争/冲突

4.1 修饰方法

修饰普通方法,进入方法加锁,出了方法解锁

锁的对象是this,哪个对象调用了方法,this就指向这个对象,即对这个对象加锁

这种情况就是把synchronized加到方法上了,相当于针对this加锁,当t1线程调用add()后,counter就加上锁了,另一个线程执行add()的时候,也尝试对counter 加锁,但是此时t1已经加过锁了,此时t2的加锁操作就会阻塞等待

修饰静态方法

锁的对象是类对象,和修饰普通方法同理

4.2 修饰代码块

需要显式指定锁对象,也就是手动指定🔒加到哪个对象上

进入代码块就加锁,出代码块解锁



版权声明:本文为chenchenchencl原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。