目录
🎉 1.线程安全引发的原因
🚀1.多线程在调度的时候是随机的,抢占式执行的
🚀2.多个线程修改同一个变量
🚀3.修改时不是原子的
🚀4.内存可见性
🚀5.指令重排序
🎉2.线程安全问题的解决办法
🚀1.加锁 synchronized
🚀2.加关键字 volitale
1.线程安全引发的原因
1.多线程的抢占式执行
2.多个线程修改同一个变量
3.修改操作不是原子的
4.内存可见性引起的线程不安全
5.指令重排序引起的线程不安全
1和2没有办法去改变,我们就从第三个开始看
下面我们就来具体说一说每一个原因
1.多线程的抢占执行
这个是线程安全原因的万恶之源,最根本的原因
2.多个线程修改同一个变量
多个线程是共用同一个内存资源,变量在内存上,所以多个线程修改同一个变量,到底谁修改,修改结果是啥,都是不确定的,所以这个是不安全的
3.修改操作不是原子的
接着上期的继续看,这个操作我们用一段代码来看
class Counter { private int count = 0; public void add() { count++; } public int get(){ return count; } } public class ThreadDemo16 { public static void main(String[] args) throws InterruptedException { Counter counter=new Counter(); Thread t1=new Thread(()->{ for(int i=0;i<5000;i++){ counter.add(); } }); Thread t2=new Thread(()->{ for(int i=0;i<5000;i++){ counter.add(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(counter.get()); } }
看这个执行结果
发现这个结果没有按照预期打印10000,这是什么原因呢?其实就是因为修改操作不是原子的
count++这个操作,其实是由三条cpu指令构成的,它们分别是load,add,save,针对这个代码案例我们来画一下它的执行过程
执行情况还有很多,这里就不一一列举了,来看看在内存和cpu寄存器上是咋执行的,还得画个图
这图就表示了修改操作不是原子的
那么我们可以通加锁操作保证原子性
就在这个count++操作这里进行加锁操作
来认识一下加锁:synchronized
将它放在代码中
class Counter { private Object locker =new Object(); private int count = 0; public void add() { synchronized (locker){ count++; } } public int get(){ return count; } } public class ThreadDemo16 { public static void main(String[] args) throws InterruptedException { Counter counter=new Counter(); Thread t1=new Thread(()->{ for(int i=0;i<5000;i++){ counter.add(); } }); Thread t2=new Thread(()->{ for(int i=0;i<5000;i++){ counter.add(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(counter.get()); } }
看运行结果
锁可以保证操作的原子性
锁有两个核心操作,一个是加锁,一个是解锁
当进入synchronized代码块的时候,就触发加锁,出synchronized代码块进行解锁操作
一旦对一个线程进行加锁操作,其他线程就要阻塞等待,一直要等到该线程释放锁其他线程才可以拿到锁
现在重点来说一下锁括号里面的东西
1.括号里面如果写this,那么就代表对counter这个对象加锁,那么因为是对一个同对象加锁的,所以代码执行效果是正确的
2.如果在Counter类里面单独new一个Object类的对象,注意!!!,是new一个,那么放到锁对象的括号里,
也代表对同一个对象locker加锁,因为还是对同一个对象,那么加锁还是有效的,代码依然执行正确
其实synchronized()的这个括号里面写啥对象都行,只要是Object类的对象都行,不可以是内置类型
这括号主要就是为为了告诉大家,多个线程针对同一个对象加锁就会出现锁竞争,如果针对不同的对象加锁,就不会出现锁竞争了,再也没有别的作用
加锁以后,操作就变成原子的了,那么我们再来画一下这个图
此时t1线程先加了锁,t2想要加锁,就必须等到t1释放锁,t2才能拿到锁,才能进行下面的操作
这个时候是t1线程执行完以后才是t2执行,所以相当于串行化执行
所以,加锁本质上是把并发变成串行的了
在我学习这部分的时候,我就产生了疑问,既然都是阻塞等待,那么synchronized和join有啥区别呢?
其实,他俩区别大了去了
join操作是完全让t1,t2线程串行执行,而synchronized操作是让部分操作串行执行,其他的还是并发的
比如这个代码中,t1和t2不仅有count++操作,还有创建变量,判断大小,调用ADD方法等操作,这些依然是并发执行的,就像A同学和B同学都要去教室,但是要去上厕所,并且那个厕所只有一个坑位,这样的例子是不是很好理解呢
任何事物都有两面性,加锁会阻塞等待,那么代码运行效率也就变慢了,比不加锁慢,比串行快,也要比不加锁算得准
这叫做给一个静态方法加锁,
这两种是一样的
那么谈到static修饰,我们就来具体说一说,static修饰的方法和对象
static修饰的方法叫做类方法,类方法就是我们所说的静态方法
Java文件首先以javac存在,通过编译,变成java.class文件到JVM虚拟机上跑 ,要运行的话得读到内存上,这个过程叫做类加载,类对象就是 记录该类的对象,属性,方法等信息就是说描述一个类
好滴,现在我们来说一说另一个线程不安全的场景
由于内存可见性,引起的线程不安全的问题
我们先来写一个错误的代码
public class ThreadDemo1 { public static int flag=0; public static void main(String[] args) { Thread t1=new Thread(()->{ while(flag==0){ } System.out.println("线程结束"); }); t1.start(); Thread t2=new Thread(()->{ Scanner scanner=new Scanner(System.in); System.out.println("请输入一个数"); flag=scanner.nextInt(); }); t2.start(); } }
按照我们想的,t2线程中如果输入一个不为0的数,那么会打印线程结束,我们来运行代码来看结果
可以看到线程一直没有结束,因为什么呢,就是因为编译器的优化,编译器在多次从内存读取数据到CPU寄存器的过程中,因为一直是一个值,编译器就只在内存上读取第一次独到的数据,后期会在工作内存(CPU寄存器和缓存)上读取数据.所以这个代码中就算将flag改为1,也依旧读不到,所以一直是0所以线程一直不结束
在while(flag==0)这个操作里,有两部,一个是load,一个是compare,load的开销要远远大于compare操作,load是在内存上读取数据到CPU寄存器上,而compare操作是在寄存器上比较值的大小
访问CPU寄存器的速度要远大于访问内存,读几千次寄存器,相当于读一次内存,那么鉴于这个情况,编译器就做了优化,把load给优化掉,后续执行的只有cmp操作
总的来说编译器优化就是在代码执行逻辑改变条件下,执行结果不变,这一条对于单线程是成立的,但是对于多线程来说,大多数情况是不安全的
因此,我们要采取措施保证线程安全,那么就要用到关键字volatile
针对此代码,我们在变量flag前加一个volatile,就可以保证线程是安全的
public class ThreadDemo6 { volatile public static int flag=0; public static void main(String[] args) { Thread t1=new Thread(()->{ while(flag==0){ } System.out.println("线程结束"); }); t1.start(); Thread t2=new Thread(()->{ Scanner scanner=new Scanner(System.in); System.out.println("请输入一个数"); flag=scanner.nextInt(); }); t2.start(); } }
这样就保证了线程安全
volatile让编译器停止优化,保证每次都是从内存中读取flag的值
下一个问题
指令重排序
volatile还有一个可以禁止指令重排序的功能
什么是指令重排序呢?
也是编译器优化的手段
也是改变代码的执行逻辑,结果不变,但是在多线程中就会产生问题
举个例子
有一个牛对象
COW{
t1
cow=new Cow()
}
t2.
if(cow!=null){
cow.eataGrass();
}
这是一段伪代码
我们主要是为了感受一下指令重排序
在创建这个对象的时候,应该有三个步骤
1.申请内存空间
2.调用构造方法(初始化内存中的数据)
3.将对象的引用赋给cow(内存地址的赋值)
2和3的执行顺序可以调换,假设按照 1 3 2的顺序执行,俺们来分析一下
啥情况呢?
t1执行1和3,即将执行2的时候,t2开始执行,t2拿到的就不是一个空的对象,是一个非空的,他就去调用cow的方法,但是实际上,t1还没有初始化,调用方法,会产生bug,所以我们可以在cow对象前加关键字volatile,保证执行顺序
当然,要是不加volatile关键字,也可以采用加锁(synchronized)的方法
如果既不想加锁,也不想加volatile,就让代码按照123执行,也不会出问题
因为这个问题的设想比较极端,所以具体的例子就无法举出,就根据这个例子感受一下
总结:
volatile关键字的作用主要有如下两个:
保证内存可见性:基于屏障指令实现,即当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
保证有序性:禁止指令重排序。编译时 JVM 编译器遵循内存屏障的约束,运行时靠屏障指令组织指令顺序。
注意:volatile不能保保证原子性
今天的讲解就到这里,我们下期再见!!!