+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
🚀欢迎大家来到我的博客⭐
🚀本人是双非软工专业的一名大二学生,让我们共同努力,冲击校招!!!⭐
🚀本章博客介绍的是关于Thread的常用方法⭐
🚀一下是我的QQ号,欢迎大家来进行技术交流!!⭐
🚀QQ:2641566921⭐
🚀以后会更新一些笔试有关的题目,请大家多多关注⭐
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
线程安全问题的产生
我们先来通过一个例子来引入线程的安全问题
假如我们要对一个变量自增十万次,小明学习过了多线程编程,他就在想,如果这十万次自增操作放在两个线程中执行,肯定会更快一些,时间上肯定会费时更短,他写了下面的这段代码。
package DEMO;
public class Demo11 {
public static int count = 0;
public static void add(){
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(()->{
for(int i = 1; i <= 50000; i++){
add();
}
});
Thread thread2 = new Thread(()->{
for(int i = 1; i <= 50000; i++){
add();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(count);
}
}
小明自信满满的点击了运行,可是结果却让他大跌眼镜
小明相信自己是眼睛花了,又一次点击了运行
可是结果有一次发生了变化,这次小明纳闷儿了,我让一个变量在单线程中自增十万次,与在两个线程中个自增五万次有什么区别呢?而且为什么运行之后两次结果都不一样呢?
这就是多线程中的线程安全问题,我们在这里先不解答是为什么,请同学们接着往下看。
线程安全问题
我们在进行多线程编程的时候常常会出现线程的安全问题。线程的安全问题根本原因就是线程的抢占式执行造成的。
线程的安全问题有很多种,我们这里拿出常见的三种例子来解释线程的安全问题
1、原子性问题
不知道大家听到原子性问题会不会有些熟悉?在数据库的事务四大特性中就有原子性,原子性简单来说就是执行一次操作,要么全部执行完毕,要么全部都不执行,不会存在执行到一半停止的状态。很多线程的安全问题都是由于操作不是原子性造成的。
就用我们上面的小明进行十万次自增的代码来解释原子性,小明的代码其实就是错在对于一个共享变量,两个线程抢占执行,而且这个自增操作不是原子性导致的。那么有的同学就会问了,自增操作只有一句代码就是count++,怎么可能不是原子性的呢?这明明只有一步操作啊?其实这只是表层,我们还要看执行count++这段代码的时候计算机在内部做了什么事情。
自增操作的本质其实有三部分,取数据(load)自增数据(+1)保存数据(save)
由于自增操作不是原子性的,而线程之又是抢占式执行的,这就导致了一个线程进行自增的三个操作很可能会被其他线程给抢占,造成了结果的错误。
我们分析一下上述代码的问题,假如现在是程序刚开始,count还是0,此时内存中保存的count就是0,thread1进行load操作,接着被thread2抢占执行,进行了load,thread1的load取出来的数是0,thread2进行load取出来的数也是0,然后各自加一,两个线程中的数字都变成1,最后各自保存结果也都是1,这就造成了两个线程虽然各进行了一次自增操作,但是最后保存的结果却是1的情况。这就可以解释为什么两个线程各增加50000次却比10W小的原因了。至于如何解决,就是让这三个操作变成原子性操作,让他不能被抢占截断。
我们可以使用synchronized保证原子性。
package DEMO;
public class Demo11 {
public static int count = 0;
public synchronized static void add(){
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(()->{
for(int i = 1; i <= 50000; i++){
add();
}
});
Thread thread2 = new Thread(()->{
for(int i = 1; i <= 50000; i++){
add();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(count);
}
}
我们接下来用一个图表示加了synchronized之后的效果。
由于load add save操作都是原子性的,一个线程获得了synchronized锁的时候,另一个线程就处于阻塞状态,必须等待三个操作都完成之后,第一个线程把锁释放之后,才能获得锁,这样就保证了原子性。
2、内存可见性问题
内存可见性问题也是导致多线程编程出现错误的原因。
简单解释一下什么是内存可见性问题,因为cpu的处理速度比把数据从内存中取出来放到cpu上的速度快很多,所以Java虚拟机JVM就在主内存和cpu之间建立了一个工作内存,就比如是高速缓存器,来弥补cpu和内存存储时间速度的差异,当取数据的时候,先从主内存中取数据到工作内存,然后cpu再从工作内存中取数据,以后取数据也都在工作内存中取数据,这样就会快很多。
总结一下:
1、线程的共享变量存在主内存
2、每个线程有自己的工作内存
3、当线程需要访问共享变量的时候,先从主内存拷贝到工作内存,然后再从工作内存取到cpu。
4、线程修改共享变量的时候也会先更改工作内存中的数据,然后同步到主内存中。
如果线程1没有来的及同步,那么线程2取出的数据就会发生错误。
可以使用volatile关键字来保证内存可见性,当方法或者变量加上了volatile之后,就不会从工作内存中取数,就会直接在主内存中取数,这样就保证了另一个线程更改的结果可以被察觉到,缺点就是效率下降,速度变慢。
3、指令重排序
指令重排序是JVM优化代码的一种机制,动态或者静态的在不改变代码逻辑的情况下进行指令的重新排序,这种机制在单线程状态下肯定是好的,但是在多线程很可能会导致代码出现错误。
避免指令重排序我们可以使用synchronized和volatile来保证程序的正确。
线程安全的代码:
package DEMO;
public class Demo11 {
public static int count = 0;
public synchronized static void add(){
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(()->{
for(int i = 1; i <= 50000; i++){
add();
}
});
Thread thread2 = new Thread(()->{
for(int i = 1; i <= 50000; i++){
add();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(count);
}
}
虽然加锁之后会导致程序运行变慢,但是还是比单线程执行的快,所以添加原子操作之后,既保证了线程的安全性,也保证了程序的快速执行。