线程安全问题

目录

今日良言:一路惊喜 马声蹄蹄

一、线程安全问题

1.概念

2.代码

3.原因

4.解决方案


今日良言:一路惊喜 马声蹄蹄

一、线程安全问题

1.概念

如果多线程环境下代码运行的结果是符合我们预期的,即该代码在单线程中运行得到的结果,那么就说说这个程序是线程安全的,否则就是线程不安全的.

线程安全问题最根本的原因是:多线程的抢占式执行带来的随机性.

如果没有多线程,此时代码的执行顺序是固定的,因此程序的结果也就是固定的.

如果有了多线程,此时抢占式执行下,代码的执行顺序就会有很多种情况,所以为了执行结果正确,就需要保证在这多种执行顺序的情况下,代码运行得到的结果都是一样的.

在多线程中,只要有一种情况下,代码结果不正确,就认为是有bug的,线程不安全的

2.代码

通过下面代码,来理解线程安全问题

class MyCount {
    public  int count = 0;
    public void add() {
        count++;
    }
}
public class ThreadDemo22 {
    public static void main(String[] args) {
        // 创建一个实例
        MyCount myCount = new MyCount();
        // 创建两个线程,调用5万次 add 方法
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                myCount.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                myCount.add();
            }
        });
        t1.start();
        t2.start();
        // 等待两个线程结束
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        // 打印最终的结果
        System.out.println(myCount.count);
    }
}

 运行三次上述代码,观察结果

线程安全问题_第1张图片

线程安全问题_第2张图片

预期的效果是代码运行后,输出结果是100000  但是很遗憾,三次输出结果都不是

如果在单线程中运行结果是100000,此时在多线程中发生了线程安全问题.

为什么程序会出现这种情况呢?

这是因为:在 count++ 操作中, ++ 操作本质上要分为3步:

1.先把内存中的值,读取到CPU的寄存器上(该步骤称为load)

2.CPU寄存器中的值进行 +1操作 (该步骤称为add)

3.将得到的结果写回到内存中 (该步骤称为save)

这三个操作,就是CPU上执行的指令,指令可以视为是机器语言.

分析一下上述count++操作:

线程安全问题_第3张图片

 可以看到,在多线程中,count++ 操作有无数种情况,针对自增结果正确和不正确情况再进行分析:

结果正确

线程安全问题_第4张图片

 结果不正确

线程安全问题_第5张图片

 这里出现结果错误的情况,主要是因为t2读到了t1(还没提交)的数据.所以说,当运行代码后,最后的结果很大可能性是小于100000的.

3.原因

多线程出现线程安全的主要原因有以下几点:

1).抢占式执行,随机调度

    这是多线程中线程安全问题的根本原因

2).多个线程同时修改同一个变量

    一个线程修改一个变量,没问题

   多个线程读取同一个变量,没问题

   多个线程修改多个不同的变量,也没事

3).修改操作不是原子的

  如果修改操作是原子的,不会出现问题

  如果修改操作是非原子的,出现问题的概率非常高.

  原子:不可拆分的基本单位.

  上述的count++ 是非原子操作,可以拆分成load  add  save三个指令,而这三个指令无法再拆

   分了,是原子的

4).内存可见性问题

    一个线程针对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,

    此时读到的值,不一定是修改之后的值.这个读线程没有感知到变量的变化,

    归根结底是编译器/jvm在多线程环境下优化时产生了误判.

    为了解决内存可见性问题:需要为变量加上volatile关键字

    当为变量加上volatile关键字时,告诉编译器,这个变量是易变的,需要每次都重新读取这个变量的内存内容

Volatile 不保证原子性   原子性是靠 synchronized 来保证的

Volatile 和 synchronized 都能保证线程安全

volatile关键字的作用主要有两个:

一个是解决内容可见性问题,一个是禁止指令重排序

    从JMM(java Memory Model  java内存模型)的角度表述该问题:

    Java程序里,除了主内存,每个线程都有自己的工作内存(线程1和线程2的工作内存不是一个东西).

     线程1进行读取的时候,只是读取了工作内存的值.

     线程2修改的时候,先修改工作内存的值,然后再把工作内存的内容同步到主内存中.

     但是,由于编译器的优化,导致线程1没有重新从主内存同步数据到工作内存,读到的数据就是"修改之前"的结果.

5).指令重排序

    本质上是编译器优化出bug了,可能是编译器觉得我们的代码有点差,在保持逻辑不变的情况

    下,进行调整(调整了代码的执行顺序),从而加快程序的执行效率.

4.解决方案

主要是从原子性入手,来解决线程安全问题.

通过 '加锁' 操作,将非原子操作转换成原子.

加锁关键字:synchronized   这个关键字不仅要会写还要会读哦

线程安全问题_第6张图片

 对上面的add方法加锁:

线程安全问题_第7张图片

此时再看执行结果,就是100000

线程安全问题_第8张图片

 加了synchronized 后,进入方法就会加锁,出了方法就会解锁.

如果两个线程同时尝试加锁,此时一个获取锁成功,另一个获取锁失败阻塞等待,只有当前面的线程释放锁之后,才可以获取到锁.

针对上面 count++ 结果不正确的操作,加锁后进行分析:

线程安全问题_第9张图片

 加锁的本质是把并发执行变成了串行执行

加锁后,线程安全问题就得到了改善,但是代码的执行速度是大打折扣的.

此时,就需要考虑我们的需求了,如果是要计算结果准确点,加锁无疑是正确的,虽然加锁会使多线程的速度慢了,但是还是比单线程要快.

synchronized 的使用方法

1.修饰方法

1)修饰普通方法

锁对象是this,谁调用这个普通方法,锁的对象就是谁

2).修饰类方法

锁对象是类对象

2.修饰代码块

显式/手动指定锁对象.

3.可重入

一个线程针对同一个对象加锁两次,是否会有问题,如果没有问题,就叫可重入,如果有问题,就叫不可重入.

以上面的add方法为例:

线程安全问题_第10张图片

只要有线程调用add方法,进入add方法的时候,会先加锁(能够加锁成功),紧接着遇到代码块,再次尝试加锁.从this的角度看,它认为自己已经被另外的线程给占用了,这里的第二次是否要阻塞等待呢?  如果不阻塞等待就是可重入的,阻塞等待就是不可重入的.

所以,加锁是要明确对哪个对象加锁的

如果两个线程对同一个对象加锁,就会产生阻塞等待,锁竞争/锁冲突.

如果两个线程对不同对象加锁,不会产生阻塞等待(不会锁冲突/锁竞争)

你可能感兴趣的:(java,开发语言)