【多线程】线程安全问题及解决方法

🏀🏀🏀来都来了,不妨点个关注!
🎧🎧🎧博客主页:欢迎各位大佬!
在这里插入图片描述

1. 一段线程不安全的例子

class Counter1 {
    private int count = 0;

    public void add() {
         count++;
    }

    public int getCount() {
        return count;
    }
}
public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter1 counter1 = new Counter1();

        Thread t1 = new Thread( () -> {
            for (int i = 0; i < 50000; i++) {
                counter1.add();
            }
        });
        Thread t2 = new Thread( () -> {
            for (int i = 0; i < 50000; i++) {
                counter1.add();
            }
        });

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

        t1.join();
        t2.join();
        System.out.println(counter1.getCount());
    }
}

这里我们是创建了两个线程分别循环50000次调用Counter类的add()方法,预期结果是add()方法总共调用了10W次,count的值更新为10W,这肯定是大部分的想法,但我们通过运行发现结果并不是10W,而像一个随机数一样,如下图:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
上述代码就是一个线程不安全的例子,线程不安全是指多线程访问共享资源时,无法保证程序的正确性和数据的一致性。 简而言之就是一个代码在多线程的情况出现的问题就是线程不安全。那么为什么在多线程情况下就会出现问题呢,归根结底是线程在系统中的调度是无序的/随机的(抢占式执行)。
我们先来解释下为什么会出现上面这种情况,count++这个操作,本质上是由三个CPU指令构成的,如下:
1.load: 把内存中的数据读取到CPU寄存器中
2. add:把CPU寄存器中的值进行+1运算
3. save:将CPU寄存器中的值写回内存

但由于我们现在是多线程操作,CPU调度的顺序是不确定的,实际过程中这两个线程的++操作顺序有很多种可能,如下图:
在这里插入图片描述
以上这两种是理想的调度状态,count为0的话,经过上述count++,count为2,但很多时候都会出现下面的情况,如下图:
在这里插入图片描述
以上是两种进行两次count++,结果却为1的情况,还有很多种值不等于2的情况,这里就不一一列举了。而发生上面这种情况的原因则是我们开头说的,线程的调度是无序的

2 线程不安全的原因

2.1 抢占式执行

多个线程在调度执行过程中,可以视为“全随机”的,无法确定先执行哪一个线程,后执行哪一个线程。这是线程不安全的万恶之源

2.2 多个线程修改同一个变量

当多个线程修改同一个变量的时候,此时是线程不安全的。但也有以下几种情况是安全的:

  1. 一个线程修改同一个变量
  2. 多个线程读取同一个变量
  3. 多个线程修改不同变量

2.3 修改操作不是原子的

原子性是指不可分割的最小单位,CPU执行的一条指令,就是满足原子性的。但这并不意味着一条Java语句就一定满足原子性,它可能可以分为多个CPU指令,就像我们上面说的++操作,就分为了load,add,save三个操作。

2.4 内存可见性问题

内存可见性是指一个线程对共享变量的修改,能否及时地被其他线程看到。
介绍这个我们就需要先了解什么是Java内存模型(JMM)。

2.4.1 Java内存模型

Java内存模型将CPU中的寄存器和缓存统称为工作内存(Working Memory),把真正的内存称为主内存(Main Memory)。线程之间的共享变量存在主内存中,而每个线程都有自己的工作内存。当线程读取共享变量时,会先从主内存将其拷贝到工作内存;当线程修改共享变量时,也会先修改工作内存中的副本,然后再同步回主内存。这种机制可能导致一个线程修改了共享变量的值,而另一个线程没有及时看到更新后的值。
这里我们可以画图进行理解:
在这里插入图片描述

主内存:是虚拟机内存的一部分,可以认为是Java堆,所有变量都存储在主内存中,对于所有线程共享。
工作内存:是JVM中每个线程自己的工作内存,可以认为是线程的栈空间,保存了被该线程使用到的变量的主内存副本拷贝。 线程对变量的所以操作(读取,赋值等)都是在工作内存中完成的,不能直接读取主内存中的变量

2.5 指令重排序

编译器和处理器为了优化程序性能,可能会对指令的执行顺序进行重排序。这种重排序在单线程环境下是安全的,因为单线程内的指令执行顺序是确定的;但在多线程环境下,就可能导致线程安全问题。

3. 线程不安全的解决方法

3.1 synchronized关键字

在上面我们提到了线程不安全的原因中线程的随机调度这是我们无法控制的,但操作的原子性是我们可以控制的,这里我们可以通过synchronized关键字对上述count++操作进行加锁,这意味着,当一个线程A拿到这个锁之后,其他线程想要拿到这个锁对count进行++操作时就需要进行阻塞等待,等待线程A释放锁之后其他线程才能尝试获取锁。
synchronized:翻译过来就是同步的意思,主要解决多个线程访问一个资源时的同步性,可以保证被它修饰的方法或代码块在任意时刻只有一个线程访问。
对于上述代码,我们就可以使用synchronized关键字进行加锁了,代码如下:

public static void main(String[] args) throws InterruptedException {
        Counter1 counter1 = new Counter1();
        Object object = new Object();
        Thread t1 = new Thread( () -> {
            for (int i = 0; i < 50000; i++) {
                synchronized (object) {
                    counter1.add();
                }
            }
        });
        Thread t2 = new Thread( () -> {
            for (int i = 0; i < 50000; i++) {
                synchronized (object) {
                    counter1.add();
                }
            }
        });

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

        t1.join();
        t2.join();
        System.out.println(counter1.getCount());
    }

运行结果:
在这里插入图片描述
这里我们是创建了一个object对象并针对object对象进行加锁,当t1线程count++操作时则会尝试获取object对象的锁,如果获取不到就阻塞等待,如果获取到了就进行count++操作,当t1线程获取了object对象的锁的时候,此时t2线程再去尝试获取object对象的锁就获取不到就会进行阻塞等待,直到t1线程释放锁。
除了针对object对象加锁外还有两种加锁方式,下面我们来介绍。

3.1.1 synchronized的几种用法

3.1.1.1 synchronized修饰方法

在上面的count++操作中我们可以将锁加在add()方法上,这表示对该对象进行加锁,使用如下:

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

将synchronized关键字放在方法的返回类型声明之前,表示该方法为同步方法。当一个线程访问某个对象的某个synchronized方法时,其他线程必须等待该线程执行完该方法后才能访问该方法。这种方式线程获得的是成员锁,即对象锁。

3.1.1.2 synchronized修饰静态方法

对于静态方法,synchronized关键字作用于类本身,表示该类的所有对象实例的该静态方法在同一时刻只能被一个线程访问。这种方式线程获得的是类锁。 使用如下:

public static synchronized void add() {  
    count++;
}
3.1.1.3 synchronized修饰代码块

synchronized还可以用于修饰代码块,通过指定一个对象作为锁,使得同时只有一个线程能够进入该代码块。这种方式提供了更细粒度的锁控制。 这也就是我们上述举例中针对object加锁。代码如下:

public void  run() {
   for (int i = 0; i < 50000; i++) {
       synchronized (object) {  //或   synchronized(this)
           counter1.add();
        }
   }
}
 

3.1.2 synchronized的锁机制

对象锁(成员锁):当synchronized作用于非静态方法或代码块,并且指定对象为当前实例(如synchronized(this))时,锁是当前实例对象。
类锁(静态锁):当synchronized作用于静态方法或指定对象为类本身(如synchronized(ClassName.class))时,锁是当前类的Class对象。

3.2 volatile关键字

volatile 关键字可以保证变量的可见性,如果我们将变量声明成volatile的,就说明这个变量是共享且不稳定的,每次读取这个变量需要从主存中进行读取。

3.2.1 volatile关键字解决内存可见性问题

这里我们拿下面的代码进行举例:

import java.util.Scanner;

public class ThreadDemo15 {
        public static int flag = 0;
    public static void main(String[] args) {

        Thread t1 = new Thread( () -> {
              while (flag == 0) {
               
              }
            System.out.println(" 循环结束,t1线程结束");
        });
        Thread t2 = new Thread( () -> {
            Scanner sc = new Scanner(System.in);
            System.out.println("请输入一个整数");
            flag = sc.nextInt();

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

    }
}

这里我们创建了两个线程分别为t1和t2,其中t1是死循环,只有当flag不为0的时候才会跳出循环,t2线程则让我们输入一个flag的值,当我们输入一个不为0的值的时候比如输入1的时候,t1循环正常情况下就结束了,但当我们执行的时候发现了t1依旧在死循环,如下:
在这里插入图片描述
通过jconsole我们也可以发现该线程的状态是runnable:
在这里插入图片描述

这个就是由于我们上面说的内存可见性导致的。
这里我们需要知道flag==0这个操作其实由下面两个CPU指令组成:

  • load : 从内存中读取数据到cpu寄存器
  • cmp : 比较寄存器中的值是否是0

而load操作时间开销很大,远远高于cmp ,此时CPU有个大胆的操作,将load操作优化了,就导致只有第一次读取flag的值的时候t1线程会从主存中进行读取,之后则在本地内存进行读取,这就会导致当t2线程修改flag的值并同步到主存的时候,t1线程并没有读取到更新的值,这和我们上述提到过的Java内存模型对应,当我们给flag变量加上volatile关键字的时候问题就解决了,这是由于加了volatile关键字的变量,会指示JVM每次读取这个变量的时候都需要从主存中进行读取。如下图:
在这里插入图片描述
需要注意的是:volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。

3.2.2 volatile关键字解决指令重排序问题

volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。
指令重排序也是编译器优化的策略,即调整了代码的执行顺序,让程序变的更高效,但前提是保证整体逻辑不变。
这里就拿去超市买菜来举例:
在这里插入图片描述

比如现在小万要去超市买上面四种菜,她可能先买了酱香饼然后又去买排骨再去买土豆,最后再去买牛肉,但这样十分不省时,最省时的买法肯定是①->②->③->④,这也是编译器优化的方法调整买菜的顺序,让买菜更加高效。这是一个指令重排序的简单举例,由于在代码执行中,大部分情况下是正确的,下面的代码逻辑我们是模拟演示下:
在这里插入图片描述
上述t1在实例化s对象的时候,就会因为编译器的优化手段,导致执行的顺序为1->3->2,此时如果执行完3的时候t2线程进行s非空判断为真调用s的learn()方法,就会出现bug,这里我们就可以给s加上volatile关键字修饰,或者给t1和t2的操作进行同一个对象的加锁,问题也可以解决,关于指令重排序的问题,在我们之后的单例模式中也会进行实际举例应用,这里就不做过多介绍了。

本次分享就结束了,感谢支持!

  • 28
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值