自学内容网 自学内容网

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

引言

在编写多线程代码时,时常会出现预期结果与实际觉果不相符的情景,其中很大一部分是由于多线程的安全问题引起的,下面就谈谈为什么会产生线程安全问题以及都有哪些解决方案。

1.操作系统对线程的调度时随机的,即抢占式执行。

现有两个线程thread1与thread2,现在在不处理线程安全问题的情况下,当thread1执行时,可能会切换到thread2执行,但此时thread1还未执行完,这就导致代码运行结果与预期结果不同。

这种问题是无法解决的,这与CPU资源调度有关。

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

现在有如下代码:

public class Demo {
    public static int count;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}

有两个线程thread1,thread2,一个成员变量count,现在两线程同时修改count的值,一般情况下,我们设想结果为10000,可实际却是下面的答案:

这是因为count++的操作在CPU上对应三个指令,即:

1)load,把内存中count的值读取到CPU寄存器中;

2)add,把寄存器取出的值加一,再将这个值放回寄存器中;

3)save,将寄存器中count的值写回内存中;

这就导致在多线程操作时,这三个代码不会按照在同一线程中一次执行,可能的执行顺序如下:

而我们认为count经过两次加一结果应该为2,可是实际上由于代码是多线程执行的,结果就变为了1,为了解决这个问题,我们可以优化代码结构,现有以下两种解决方法:

1)将两个线程同时执行修改为先执行一个线程,后执行另一个线程,代码如下:

public class Demo {
    public static int count;
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {//运行时间短
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });
        Thread thread2 = new Thread(() -> {//运行时间长
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });
        thread1.start();
        thread1.join();//优化
        thread2.start();
        thread2.join();
        System.out.println(count);
    }
}

将join的顺序调换,就使得代码先执行thread1,当thread1执行完后再执行thread2;

2)将同时修改一个变量的操作优化为修改两个不同的变量,一个线程修改一个,代码如下:
 

public class Demo {
    public static int count;

    //将count拆分为count1和count2
    public static int count1;
    public static int count2;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {//运行时间短
            for (int i = 0; i < 5000; i++) {
                count1++;
            }
        });
        Thread thread2 = new Thread(() -> {//运行时间长
            for (int i = 0; i < 5000; i++) {
                count2++;
            }
        });
        thread1.start();
        thread1.join();
        thread2.start();
        thread2.join();
        count = count1 + count2;
        System.out.println(count);
    }
}

这样在thread1修改count1的值时,thread2不会对thread1产生干扰。

3.修改操作不是原子的

由原因2知,当两个线程同时修改一个变量时,由于count++操作是分三步执行的,这就使得在执行这三步时别的线程会对此产生干扰,于是可以通过将三步操作转化为一步执行,但由于我们不可能修改CPU指令的执行方式,因此我们可以通过加锁来解决此问题,代码如下:

public class Demo {
    public static int count;

    public static void main(String[] args) throws InterruptedException {

        //锁对象
        Object locker = new Object();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized(locker) {//加锁
                    count++;
                }//释放锁
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {//加锁
                synchronized(locker) {
                    count++;
                }//释放锁
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println(count);
    }
}

当两个count++都加上锁后,由于只有一个锁对象locker,当thread1获取到locker时,thread2就会发生阻塞,这就使得在同一时间只有thread1在修改count++变量,同时这个++操作就是不可分割的。

4.内存可见性问题

现有下面一段代码:

public class Demo {
    public static int flag = 0;
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            while (flag == 0) {

            }
            System.out.println(Thread.currentThread().getName());
        });

        Thread thread2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            flag = scanner.nextInt();
        });

        thread1.start();
        thread2.start();
    }
}

有一个变量flag,现启动thread1与thread2,按照正常情况来说,当thread2输入值时,会修改flag的值,就会使得thread1的while循环结束,从而输出thread1的名字,可实际上,这个代码陷入了死循环,这就是内存可见性问题的体现。

由于CPU执行的速度非常快,于是在我们输入值之前,thread1的while循环就已经执行了很多次,这就导致JVM认为flag就一直是0,于是JVM就会从寄存器上取flag的值(在修改之前寄存器中的值为0),这也是编译器优化,即使后面人为输入了一个非0的数字,但JVM还是从寄存器中读取flag的值,就导致形成了死循环。

解释“为什么thread2修改了flag的值但thread1读取到的依然是0”:

在Java内存模型中,每个线程都有自己的“工作内存”(即寄存器中的内存),同时这些线程共享一个“主内存”,当一个线程循环进行读取变量的操作时,就会把主内存的数据拷贝到该线程的工作内存中,后续另一个线程修改变量时,也是先修改自己的工作内存,再拷贝到主内存中,由于前一个线程仍在读取自己的工作内存,因此感知不到主内存的变化。

有如下几种解决方法:

1)休眠:

public class Demo {
    public static int flag = 0;
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            while (flag == 0) {
                try {
                    Thread.sleep(1000);//优化
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println(Thread.currentThread().getName());
        });

        Thread thread2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            flag = scanner.nextInt();
        });

        thread1.start();
        thread2.start();
    }
}

当thread1进入循环时,会休眠1秒,就使得thread1会主动放弃CPU资源而让给thread2执行,这样当thread2中修改flag的值时thread1就可以获取到flag的值,从而退出循环;

2)使用volatile关键字:

public class Demo {
    public static voltaile int flag = 0;
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            while (flag == 0) {//内存可见性

            }
            System.out.println(Thread.currentThread().getName());
        });

        Thread thread2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            flag = scanner.nextInt();
        });

        thread1.start();
        thread2.start();
    }
}

volatile会阻止编译器从寄存器中读取flag的值,而是从主内存中读取flag的值,即当一个线程修改共享变量时,另一个线程能读取到这个修改的值。

5.指令重排序

class Singleton {
    private static Singleton instance = null;

    private static Object locker = new Object();

    private Singleton() {

    }

    public static Singleton1 getInstance() {
        if (instance == null) {
            synchronized(locker) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

当我们执行getInstance方法时,涉及到以下三个操作,即

1)申请内存空间;

2)在空间上构造对象(初始化);

3)将内存空间的首地址赋值给引用;

thread1在CPU执行这三步操作时,执行顺序可能是1,3,2这就导致此时的引用是未初始化的,thread2执行getInstance时,由于第一个线程已经给instance赋过地址了,此时instance就不为null,于是会直接返回instance对象,而thread2可能会拿未初始化的instance去进行某些操作,就会出bug,这就是指令重排序问题。对于这个问题,有以下解决方法:

1)使用voltaile关键字

其实,voltaile不仅仅能避免内存可见性问题,也能避免指令重排序问题,即给instance加上voltaile,代码如下:

class Singleton {
    private static voltaile Singleton instance = null;

    private static Object locker = new Object();

    private Singleton() {

    }

    public static Singleton1 getInstance() {
        if (instance == null) {
            synchronized(locker) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

以上就是线程安全问题的原因以及解决方法了,希望大家积极讨论!


原文地址:https://blog.csdn.net/2301_79184547/article/details/143085824

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!