Java线程安全问题原因和解决方案

目录

出现线程安全问题的原因

1:抢占式执行

2:多个线程修改同一个变量

3:修改的操作不是原子的

解决方案

4:内存可见性

5:指令重排序

解决方案


出现线程安全问题的原因

我们日常开发中使用java进行多线程编程的时候,总是避免不了出现线程安全问题,那么线程安全问题具体在我们的代码中是如何体现的呢?我们接着往下看。

其实导致产生线程安全问题的本质原因很明显:

我们的线程在CPU上调度是随机的,不可预测的,无序的。是抢占式的执行,可以说这个就是导致线程安全问题的罪魁祸首。

当然还有其他原因,不能只有这一个导致线程安全的原因。以下就是导致线程安全问题的原因:

1:抢占式执行

2:多个线程修改同一个变量

3:修改的操作不是原子的

我们先来看看抢占式执行和多个线程修改同一个变量还有修改操作不是原子的性的导致的bug。

所谓抢占式执行,就是多个线程在CPU上并发执行的时候,是无序的,是随机的,这就导致我们的程序是不可预测的,会出来结果与我们预期结果不一致,这就出现bug。

我们看一个栗子来具体了解什么是抢占式执行

我们先看下面代码:

class conner {
    public int count = 0;
    public void add () {
        count++;
    }
    public int get() {
        return count;
    }
}
public class threadDemo11{
    public static void main(String[] args) throws InterruptedException {
        conner conner = new conner();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                conner.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                conner.add();
            }
        }); 
        t1.start();  
        t2.start();
        t1.join();
        t2.join();
        System.out.println(conner.get());
    }
}

上述代码我们可以看到当程序执行起来的时候,我们有2个线程并发执行,此时2个线程是同时对同一个变量进行++操作,一个线程进行++操作50000次,两个线程共进行++操作100000次,所有我们预期结果就是100000。我们来看结果如何?

 但是此时我们发现结果和我们预期的不一致,这就出现了bug。

那么为什么会出现这样的原因呢? 

其实原因也很简单,就是多个线程在CPU上是随机的,无序的,是抢占式执行的,还有我们的add操作不是原子的。

public void add()
   count++;
}

原子的其实理解起来也很简单:

所谓原子就是不可分割的最小单位,为啥add操作不是原子呢?

因为add下的count++操作在我们的CPU上并非是一条指令,而是拆分为三条指令,分别为 load   add  save 。

load 为把内存中的值读取到CPU寄存器上。

add 把寄存器上的值进行+1操作。

save 把寄存器上的值写回内存中。 

由于多线程的调度顺序是无序的,随机的,实际执行过程中,这两个线程的++操作实际上有很多的排列可能。

比如下图:

t1和t2是有他们的独立的寄存器的。

此时t1线程先执行load、add 操作,还没有来得及执行save操作的时候,CPU突然调度t2线程也开始执行load操作,但是此时内存里面的值t1线程已经进行了一次+1操作,但是还没有来的及把+1后的值保存到内存上,所以此时内存上的值还是0,t2线程读到的就还是0了

假如此时CPU又突然调度到了t1线程,由于t1线程上次执行到了save操作,所有此时t1线程就把寄存器中的值写回到内存上,现在内存上的值是1,如果此时又调度到t2线程,t2线程继续执行,add,save操作,执行完了之后,t2线程把寄存器中的值写回到内存上,此时就出现了bug。

 可以看出来,t1和t2线程分别对内存中的值进行了一次++操作,但是内存中实际的值是1。

构成这个bug的原因就是因为此时多个线程试图去修改同一个变量,而这个修改的操作并不是原子的,可以拆分为多个CPU指令,而由于拆分为了多个CPU的指令,而线程的执行顺序是抢占式执行的,无序的。这就导致了两个线程在执行的过程中的CPU指令的顺序也有很多种变化。

这就是上述出现bug的CPU指令的顺序,这只是造成bug的其中一种。 

那么要如何解决上述的问题呢?

解决方案

其实解决方法就很简单了,根据上述我们可以看出,问题主要在线程的的抢占式执行上,修改操作上。我们可以针对add方法进行加锁操作,这样就可以解决上述的问题了。

class Conner01 {
    public int count = 0;
    public void add() {
        synchronized (this) {  
            count++;
        }
    }
}
public class threadDemo14 {
    public static void main(String[] args) throws InterruptedException {
        Conner01 conner01 = new Conner01();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                conner01.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                conner01.add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        ;
        System.out.println(conner01.count);
    }
}

 可以看出我们在add方法里面进行了加锁操作,这个加锁操作就会让我们的线程安全问题得以解决。

此时我们看出,代码的执行结果和我们预期的结果一致。

那么现在我们来仔细研究这个add方面里面的锁操作。

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

进入synchronized修饰的代码块就会触发加锁操作,这里的this就是针对当前对象进行加锁,

此时如果t1和t2线程尝试对同一个对象进行加锁的话,就会产生锁竞争,如果t1和t2针对不同的对象进行加锁,就不会产生锁竞争。

当出来synchronized修饰的代码块时,就会解锁。

如果此时t1线程成功的加锁了,那么t2线程就只能阻塞等待了,也就不涉及到同时修改一个变量的事情了,也解决了线程的抢占式执行的问题,同时synchronized也能保证原子性。会让++操作变为不能分割的最小单位。

4:内存可见性

5:指令重排序

什么是内存可见性呢?

我们来看下面的代码

public class threadDemo13 {
    //public static int flag = 0;
    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 scanner = new Scanner(System.in);
            System.out.println("请输入一个结束t1线程的标志");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

按照我们的逻辑来执行这个代码,t1线程循环执行,t2线程输入一个让t1线程结束的一个标志。

但是当我们运行代码之后,发现t1线程并不会结束。

 

 此时程序并没有结束,而是t1线程继续在死循环中。

出现这个问题的原因就是我们的编译器优化策略。

编译器优化在多线程中就会导致代码执行结果和我们的预期解结果不一致,就会产生bug。

flag == 0 这个操作,在CPU中有两个指令:

load : 把flag加载到内存中

cmp : 然后在进行比较

但是load的开销比cmp的开销是非常大的。

那么编译器就发现,每一次flag的值都是一样的,编译器索性就不每一次循环都在内存中读取值了,就把load给优化了,而是复用之前的值来进行比较。

编译器优化的手段:

智能的调整代码执行逻辑,保证程序在结果不变的前提下,通过加减语句,通过语句变换,还有一系列的操作,让整个程序的执行效率大大提高。编译器优化在单线程下是非常好的,但是在多线程就会出现bug。

解决方案

那么我们的机制就是让编译器暂停优化:

我们使用volatile关键字来让编译器暂停优化。

 volatile public static int flag = 0; //此时这个变量就禁止编译器进行优化了  代码也就不会出现bug了

此时编译器就不会优化这个变量了,每次都是在内存中读取数据,而不是复用之前的值了。

但是我们的volatile不会保证原子性,适用于一个线程度,一个线程写的情况。

synchronized则是能保证原子性,两个线程一起写。

volatile还有一个效果,就是禁止指令重排序问题:

指令重排序也是编译器的优化策略

调整了代码的执行顺序,让程序更加高效,前提是保证代码的整体逻辑不变。

以下是最终代码:

public class threadDemo13 {
   
    public volatile 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 scanner = new Scanner(System.in);
            System.out.println("请输入一个结束t1线程的标志");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

 此时这个线程就能正常的结束了。

猜你喜欢

转载自blog.csdn.net/qq_63525426/article/details/129832560