目录
🌟观察线程不安全的现象
🌟线程不安全的原因
🌈1、多个线程修改了同一个共享变量
🌈2、线程是抢占式执行的,CPU的调度是随机的
🌈3、指令执行时没有保证原子性
🌈4、多线程环境中内存的可见性问题
🌈5、代码的顺序性问题
🌟解决线程不安全的现象
🌈分析
🌈1、synchronized 关键字—监视器锁monitor lock。
1 使用
2、 解释
3、案例:理解下面两种情况
4、存在的问题
5、原因解释
6、锁对象(重点)
7、synchronized特性
8、关于synchronized使用时的注意事项:
🌈2、volatile关键字
1、通过代码观察内存不可见的现象
2、volatile关键字的使用:用来修饰变量
3、怎么实现内存可见性的?->MESI缓存一致性协议
4、volatile可以解决有序性问题
5、总结synochnized与volatile
🌟观察线程不安全的现象
我们首先来观察一段代码,分别使用两个线程来对变量进行5w次的自增。理想情况下,使用两个线程同时自增的效率一定比单线程的效率高。
//定义自增操作的对象
private static Counter counter = new Counter();
//定义自增的对象
public static void main(String[] args) throws InterruptedException {
//定义两个线程,分别自增5w次
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increment();
}
});
//启动线程
t1.start();
t2.start();
//等待自增完成
t1.join();
t2.join();
//打印结果
System.out.println("count="+counter.count);
}
}
class Counter{
public int count = 0;
//设置自增方法
public void increment(){
count++;
}
但是运行结果却不是我们预期的10w。这种现象我们就说这是线程不安全的。总结来说,如果在多线程环境下,代码的运行结果是符合我们预期的,即与单线程环境得到的结果相同,则说明这个线程是安全的。
🌟线程不安全的原因
🌈1、多个线程修改了同一个共享变量
注意区分:
多个线程修改同一个共享变量会线程不安全;✅
多个线程读取同一个变量,不会线程不安全;❎
多个线程修改不同的变量,不会线程不安全;❎
单线程环境下,不会出现线程不安全。❎
🌈2、线程是抢占式执行的,CPU的调度是随机的
多个线程在CPU上的调度是随机的,顺序是不可预知的。
🌈3、指令执行时没有保证原子性
我们之前说过,原子性指的就是代码要么全执行,要么全都不执行。看图:
因此,两个线程从CPU中操作count++的时候,由于是随机调度CPU的 ,因此会出现count计数结果不正确的现象发生。总结来说,这是由于没有保证指令执行的原子性导致多个线程在做了自增操作之后,互相不可见,从而覆盖了上个线程修改后的值。
🌈4、多线程环境中内存的可见性问题
内存可见性:指的是某一个线程在多线程环境下,修改了变量的值,而另一个线程没有感知到。这里我们要提到一个新的概念:Java内存模型(JMM)。JMM的出现是为了解决缓存一致性的问题,即多个线程之间读取同一个变量的值是一致的。
JMM内存模型:
1、JMM中最重要的是主内存和工作内存。
2、主内存指的是硬件的内存条,进程在启动的时候会申请一些资源,包括内存资源,用来保存所有的变量。
3、工作内存指的是线程独有的内存空间,它们之间不能相互访问,起到线程之间内存隔离的作用。每个工作内存之间是相互隔离的。
4、JMM规定,一个线程在修改某个变量的值的时候,必须把变量从主内存中加载到自己的工作内存,修改完成后再啥寻回主内存。
❓问题1:为什么要用JVM?
Java虚拟机规范中定义了Java内存模型,Java是一个跨平台的语言,把不同的计算机设备和操作系统对内存的管理做了一个统一的封装。目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一定的并发效果。
❓问题2:为什么要弄这么多的内存?
实际上并没有这么多的“内存”,这只是Java规范中的一个术语,属于“抽象”的叫法。我们所说的主内存其实才是真正硬件角度的“内存”,而工作内存,指的是CPU的寄存器和高速缓存。寄存器的作用是保存代码的运行结果,如果下次还有这个变量,就直接从寄存器中读取,不需要从内存中加载。这主要是由于CPU访问自身寄存器和高速缓存的速度远远大于访问内存的速度(快几千倍,上万倍)。而内存访问速度又远快于硬盘。相应的,CPU价格高于内存高于硬盘。
🌈5、代码的顺序性问题
指在编译过程,JVM调用本地接口以及CPU执行指令的过程中,指令的有序性。因为指令并不是按照程序员的预期去执行的,在特殊情况下会打乱顺序。这种经过优化的方式,在程序中就叫做指令重排序,目的是为了提高程序的效率。指令重排序的前提是:保证代码重排序后的结果一定要是正确的。其中,在单线程的情况下,重排序的结果一定是正确的,但是在多线程环境中,由于环境的复杂性,结果不一定是正确的。
🌟解决线程不安全的现象
🌈分析
针对以上5点分别考虑:
1、在真实业务场景中大多都是要修改同一个变量的,因此无法规避;
2、CPU调度是硬件层面的,我们无法规避;
3、有可能通过java层面处理;
4、有可能通过java层面处理;
5、有可能通过java层面处理。强行通知编译器不要做指令重排序。
JMM的主要特性就是保证原子性,内存可见性以及有序性。
🌈1、synchronized 关键字—监视器锁monitor lock。
1 使用
A- 修饰普通方法
B- 修饰要执行的代码块
两种方法的区别:使用synchronized关键字修饰,就是将并行操作变成串行操作。使用代码块的方式就是将要串行的代码块包裹起来,将锁的颗粒度减少,从而提升了代码的运行效率。
C- 修饰静态方法:锁对象是当前的类对象=类名.class
2、 解释
对当前执行的代码加上一把锁,某个线程要执行这个代码或者方法时就先获取锁,获取到之后再去执行线程,另外的方法在执行这个线程时也要获取锁,但是当有线程持有锁时就要进行等待,等到上一个线程释放之后才能获取该锁。
过程总结
(1)线程在获取到锁之后开始执行锁中代码;
(2)其他线程在执行代码之前要先检查锁的状态;
(3)如果该锁被其他线程占用,那么就要阻塞等待,对应的线层状态我们就称为BLOCK;
(4)当锁被释放之后,其他线程才可以继续竞争锁资源。
3、案例:理解下面两种情况
4、存在的问题
加锁之后的方法变成了单线程。分场景分析这个现象:在获取数据的时候用多线程提高效率,在修改变量的时候用该关键字修饰,单线程,但是保证安全性。
5、原因解释
synchronized可以解决原子性问题;可以保证内存可见性问题;但是不能保证有序性。
- 保证原子性的原因:通过对代码加锁实现;
- 保证内存可见性的原因:通过对代码加锁,保证一个线程执行完所有的操作之后并释放锁,第二个线程才可以获取到锁,此时读到的一定是第一个线程修改的最新结果,从而保证了可见性。
6、锁对象(重点)
我们在前文使用synchronized关键字的时候,用到了this,表示的是当前对象。也就是锁对象:锁对象的作用是用来记录当前是哪一个线程获取到了锁。必须是同一个对象就可以产生所竞争,产生了锁竞争计算结果才是正确的。
锁对象可以是Java中的任何对象,只要能够记录到当前获取到锁的线程就可以了。在Java虚拟机中,对象在内存中的结构可以划分为4部分。最关键的是markword。
举例:区分以下情况
(1)可以使用任意对象充当锁对象
使用类对象来充当锁对象
(2)两个线程一个加锁一个不加锁,计算结果错误❎
(3)两个线程获取的是不同的锁,计算结果错误❎,不会产生锁竞争。
(4)注意区分观察以下两种情况是不是同一个对象?
7、synchronized特性
(1)互斥性:同一时间只能有一个线程获取到锁,其他的线程就要阻塞等待;
(2)刷新内存:线程修改完变量的值后,会把新的值写回主内存,是以并行->串行的方式来做的;
(3)可重入性:Java中的synochnized是可重入锁。一个线程可以对同一个锁对象进行多次加锁。synchronized是可以嵌套使用的。如果持有锁的线程是当前线程,那么线程可以再次获取锁对象,锁对象会维护当前线程获取锁的次数,比如我们上述代码中的count=count+1;而每退出一层就会count=count-1,直到count=0的时候,就意味着当前的线程释放了锁。
8、关于synchronized使用时的注意事项:
(1)从并行到串行(指令重排序):要先保证正确,再是效率;
(2)加锁与CPU调度:加锁后:对一个方法加锁并不是说这个线程一直把这个刚发执行完才会调度走,在调度走的时候,只要没有释放锁,其他线程就一直处于BLOCK阻塞等待的状态,直到锁释放再竞争锁。
(3)加锁的范围:加锁的范围越大称为锁的粒度大,也就是串行化执行的更多;
(4)只给一个线程加锁:不会产生锁竞争,计算结果是错误的;
(5)给代码块加锁:synchronized可以加在方法上,也可以加在代码块中。在修饰代码块的时候,要传入参数:锁对象。锁对象可以是自身this,也可以是类对象,其他产生的对象。(看上述例子)
(6)锁对象:要计算结果正确,必须是同一个对象!同一个对象才可以产生锁竞争!
synchronized可以解决原子性,内存可见性问题,不能解决有序性问题。而且synchronized并没有真正的通过线程之间的通信解决内存可见性。我们接下来看下一个关键字:volatile关键字。
🌈2、volatile关键字
1、通过代码观察内存不可见的现象
🌰我们先来通过一段代码重现一下内存不可见的问题。创建两个线程,一个线程不断的循环判断flag标志位是否能够退出,第二个线程则用来修改flag标志位。理想情况下,我们认为,第二个线程修改标志位后,第一个线程就应该正常退出了,我们看一下实验结果是否符合我们的预期呢?
//定义标志位
private static int flag = 0;
public static void main(String[] args) {
//创建第一个线程,当flag为0 的时候一直循环
Thread t1 = new Thread(()->{
System.out.println("t1线程已经启动");
//根据flag的标志不断的循环
while (flag==0){
//TODO 要执行的代码
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t1线程已经退出");
});
//创建第二个线程,同时修改标记位flag
Thread t2 = new Thread(()->{
System.out.println("t2线程已经启动");
//修改标记位flag
System.out.println("请输入整数:");
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
System.out.println("t2线程已经退出");
});
//启动线程
t1.start();
t2.start();
}
控制台输出:
这是怎么回事呢?我们观察到的现象是:当用户输入一个非零的值之后,线程t1并没有正确的退出,出现了线程不安全的现象。主要原因就在于了内存不可见。
画图理解🌰
解释:在执行的过程中,线程1先将flag变量从主内存中加载到自己的工作内存中,也就是寄存器和缓存中,后来CPU发现,我当前这个t1线程并没有要修改flag这个变量的值的操作,而且从工作内存中读取变量的速度是主内存的几千甚至上万倍,那么CPU就对执行过程做了一定的优化:我每次就直接从工作内存中读取这个变量,不从主内存中加载,因此就获取不到t2修改flag后的最新值。因此出现了这种内存不可见的现象。
2、volatile关键字的使用:用来修饰变量
解决办法:在上述代码中用volatile来修饰变量flag。
当然也可以使用sleep()来实现,由于设备不同等因素,你不能确保在一定时间范围内它的休眠时间就足以让线程退出。因这种方式强烈不推荐。代码演示:
3、怎么实现内存可见性的?->MESI缓存一致性协议
缓存一致性协议也可以理解为是一种通知机制。 当某个线程修改了一个共享变量之后,通知其他CPU其缓存中的变量值已经失效了,要从主内存中加载最新的值。
简单了解下Java层面的内存屏障
Load指令(读屏障):它将内存存储的数据拷贝到处理器的缓存中。
Store指令(写屏障):它主要实现让当前线程写入高速缓存中的最新数据 更新写入到内存,让其他线程也可见。
当发生写操作之后就会通过缓存一致性协议来通知其他CPU中的缓存值失效。加上volatile强制读写内存,虽然速度变慢,但是准确率提高了。因此volatile可以解决内存可见性的问题。
4、volatile可以解决有序性问题
有序性指的是在程序执行结果正确的前提下,编译器,CPU对指令进行的优化过程。用volatile修饰的变量,就是要告诉编译器,我不需要你对这个变量涉及到的操作进行优化,从而实现有序性。
5、总结synochnized与volatile
原子性 | 内存可见性 | 有序性 | |
synchronized | ✅ | ✅ | ❎ |
volatile | ❎ | ✅ | ✅ |
建议:如果涉及到多线程,对于共享变量最好加上一个volatile关键字修饰。