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