目录
什么是线程安全?
如果在多线程环境下的运行结果符合预期,即跟在单线程下的结果一致,那么就说这个程序是线程安全的.
为什么会引入线程安全呢?这是因为多线程环境下会出bug.
又是什么造成的线程不安全呢?根本原因是线程调度不确定(无序/随机).
下面我们从多个角度分析一些造成线程不安全的主要原因:
1.抢占式执行
多线程执行的顺序是不确定的,随机且无序的.
2.多个线程修改同一个变量
下面我们来看一个代码:
我们创建两个线程,每个线程循环 1w 次,累加变量 count 的值,count 默认值为 0,
class Counter{
public int count=0;
public void add(){
count++;
}
}
public class TestDemo{
public static void main(String[] args) throws InterruptedException {
Counter counter=new Counter();
Thread t1=new Thread(()->{
int i=0;
while(i<10000)
{
counter.add();
i++;
}
});
Thread t2=new Thread(()->{
int i=0;
while(i<10000){
counter.add();
i++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
此时,我们当然希望代码的运行结果是20000,可是它真的是20000吗?下面我们一起来看一下:
显而易见,与我们的预期不符,那么,是什么造成的的呢?这里涉及到一个概念,多个线程修改同一个变量时,会产生线程不安全.
3.原子性
什么是原子性,我们把代码想象成一个房间,每个线程都是进入这个房间的人.当你没有作出任何说明的情况下,是不是别人也可以进入这个房间,此时这个房间失去原子性了.
也是结合上一段代码,对Counter.count进行++操作的代码分三步:
- load:那内存数据读取到CPU寄存器上;
- add:把寄存器中的值进行++(更新);
- save:把寄存器中的值再写到寄存器中去.
由于线程的执行是无序的,那么t1,t2的执行顺序也是不一定的:
下面我们通过两种执行方式,就能看出上述代码执行结果不是20000的原因了:
第一种:
此时按照这一种顺序,得到的count是符合预期的.
第二种:
此时,我们可以看到count的值不是2,而是1.所以没有达到我们想要的结果.
由于我们线程就是无序执行的,所以,当t1线程现在进行++操作时,必须要保证t2无法对同一个变量count进行++操作.我们如何区解决呢?这里涉及到锁!!!引入一个关键字synchronized.当我们t1线程进行++操作,对其进行加锁,此时t2也想加锁,就必须阻塞等待.等到t1线程释放锁了为止.
synchronized
synchronized 会起到互斥效果 , 某个线程执行到某个对象的 synchronized 中时 , 其他线程如果也执行到同一个对象 synchronized 就会 阻塞等待 .进入 synchronized 修饰的代码块 , 相当于 加锁退出 synchronized 修饰的代码块 , 相当于 解锁
对上述代码的add方法加锁,进入方法内部,相当于对当前对象进行加锁.
synchronized public void add(){
count++;
}
此时,我们再来看一下运行结果:
此时就是线程安全啦!!!!!!
我们分析一些为什么?
一个线程上了锁,其它线程只能等待.相当于一个人上厕所,其它人排队等着上厕所.
在此时,相当于我们t1对counter对象进行加锁时,如果t2也尝试对counter对象进行加锁,就不能加上,会造成阻塞等待.当t1完成++后,释放锁,t2才能获取到锁.
所以,t2拿到的是t1已经对count++后得到的值,再进行++结果是2,符合预期.
注:阻塞等待的概念:
针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁.
如果多个线程尝试对同一个对象进行加锁,就会产生锁竞争.
4.内存可见性
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到. 多线程环境下,编译器对于代码优化,产生了误判,从而引起了bug.
每个线程都有自己的工作内存,当线程要读取一个共享变量时,会将主内存的值放到工作内存中(相当于一个副本),再从工作内存中读取数据 ;当线程要修改一个共享变量时,会先修改工作内存中的值,再同步到主内存中去.
由于每个线程都有自己的工作内存,当线程t1修改了t1的工作内存中的值,但并未同步到t2的工作内存,t2也尝试修改共享变量的值.但t2的工作内存中的值还是未修改的值,此时再进行++,最后同步到主内存的共享变量的值将不是我们预期的.
1->2
2
1->2
解决方案就是引入volatile关键字
volatile
首先,创建两个线程 t1 和 t2 t1 中包含一个循环, 这个循环以 flag == 0 为循环条件. t2 中从键盘读入一个整数, 并把这个整数赋值给 flag. 预期当用户输入非 0 的值的时候, t1 线程结束.
class Counter2{
public int flag=0;
}
public class TestDemo9 {
public static void main(String[] args) {
Counter2 counter=new Counter2();
Thread t1=new Thread(()->{
while(counter.flag==0){
}
System.out.println("循环结束!!!");
});
t1.start();
Thread t2=new Thread(()->{
Scanner scanner=new Scanner(System.in);
System.out.println("请输入一个整数:");
counter.flag=scanner.nextInt();
});
t2.start();
}
}
此时,当输入一个非0值时,循环没有结束.
这是因为t1读的时自己的工作内存的值,t2修改的是自己的工作内存的值,并未同步到t1.
此时,我们对代码进行修改,引入volatile关键字:
class Counter2{
public volatile int flag=0;
}
注意:volatile不能解决原子问题
我们对第一个代码t1,t2对一个变量的count进行++的count加上volatile
class Counter{
public volatile int count=0;
public void add(){
count++;
}
}
还是无法保证结果.此时,我们还是要引入synchronized:
synchronized既可以保证代码可见性,又可以保证原子性.
5.指令重排序
当实例化一个对象时,我们是不是可以分成三步
- 创建内存
- 调用构造方法
- 将内存地址赋给引用
此时创建内存可以想象成买房,调用构造方法可以看成装修,将内幕才能地址赋给引用可以看成拿钥匙.
而2,3顺序是可以进行交换的.如果我们进行了指令重排序,3在2之前.是不是就是我们拿到钥匙的时候,房子是一个毛坯房;当我们后续想调用里面的方法(想坐沙发时),发现里面什么都没有.
解决方法:volatile.
总结
祝大家顺顺利利健健康康,下次见!!!