目录
一:什么是线程安全
·线程安全问题是我们在使用多线程代码开发程序时的一个重要的安全问题,如果程序员写的代码是线程不安全的,那么程序在执行的时候就很有可能会产生bug。
而线程不安全的概念就是:代码在多线程的环境下执行会出现问题,但是在单线程环境下就不会出现问题,多线程执行代码与预期的结果不相符,我们称这样的代码是线程不安全的。
下面请看这段代码:
class ThreadFun{
public int count = 0;
public void add(){
count++;
}
}
public class Test {
public static void main(String[] args) throws InterruptedException {
ThreadFun tf = new ThreadFun();
//线程 t1
Thread t1 = new Thread(()->{
for (int i = 0; i <10000 ; i++) {
tf.add();
}
});
//线程 t2
Thread t2 = new Thread(()->{
for (int i = 0; i <10000 ; i++) {
tf.add();
}
});
t1.start();
t2.start();
//休眠100毫秒确保 t1,t2线程都执行完毕
Thread.sleep(100);
System.out.println(tf.count);
}
}
上面代码我们让 线程 t1 和线程 t2 分别使 tf 对象中的count 累加 10000次,那么当 t1 和 t2线程都执行完毕时,我们预期的count的结果应该为20000。
可是真实情况真的如此吗?
第一次执行结果:
第二次执行结果:
此时我们看到结果与我们预期的不符合,甚至每次执行的结果都不同,那么我们称上面的代码是线程不安全的。
那么造成这种线程不安全的原因是什么呢?让我们带着这个疑问来看下面的介绍
二:线程不安全的原因与解决方法
2.1 线程之间抢占式执行(主要原因)
线程之间抢占式执行是造成线程不安全的最主要的原因。
原因:因为了解线程的调度就知道操作系统在调度线程的时候是无序的,那么就绪态线程之间都可以进入CPU核心进行执行,这就会发生这些线程之间抢着进入CPU执行的情况。
那么就会发生CPU执行了线程1的一部分指令(没完全执行完),又去执行线程2的一部分指令,当这些指令都对同一个变量作用的时候,那么此时就会造成线程不安全。
上面的代码也是这个原因:
2.1.1 多个线程同时修改同一个变量
把上面代码拿下来为例:
t1 和 t2 线程同时修改同一个变量,导致结果不能预料
class ThreadFun{
public int count = 0;
public void add(){
count++;
}
}
public class Test {
public static void main(String[] args) throws InterruptedException {
ThreadFun tf = new ThreadFun();
//线程 t1
Thread t1 = new Thread(()->{
for (int i = 0; i <10000 ; i++) {
tf.add();
}
});
//线程 t2
Thread t2 = new Thread(()->{
for (int i = 0; i <10000 ; i++) {
tf.add();
}
});
t1.start();
t2.start();
//休眠100毫秒确保 t1,t2线程都执行完毕
Thread.sleep(100);
System.out.println(tf.count);
}
}
我们先得知道:代码count++ 执行实际上是三个指令:load,add , save
load:从内存中获取变量count的值进入CPU的缓存
add:CPU把缓存中对count的值进行累加,
save:把结果写回(保存)内存中
那么在多线程无序调度的情况下,就会发生以下情况(情况太多,简单举例几种)
情况1:t1 执行一次循环,把结果写入到了内存,然后 t2 线程执行时CPU再去内存中读取 count值 ,那么此时执行的结果并没有发生错误
情况2:t1 线程执行时,当执行完累加指令的时候,t2 线程就抢占了CPU核心去开始执行自 己的代码,t2 线程把累加的值写入到内存中时,t1线程再把累加的值写入内存,由于 他们从内存中拿到的值为为0,写入的值也都为1,那么虽然累加了两次,但是count 的结果为1,相当于执行了一次操作。此时就造成了线程的不安全。
所以线程之间用时修改同一个变量,但是相互又发生抢占式执行,那么CPU执行那条指令我们是不知道的,造成的结果也是不能预料的。
总结:
1.多个线程同时执行修改同一个变量的值,此时是线程不安全的(上面已解释)。
2.多个线程同时执行修改多个变量,每个线程修改的变量都不一样,那么此时是线程安全的。其原因就是线程造成的结果不相关,如果上面把t2线程修改的值换为其他的变量,那么 t1线程写入count的值的顺序就与t2线程没有关系。
3.多个线程读取同一个变量(不修改变量),那么此时是线程安全的,因为没有对变量造成影响,每次从内存中读取的变量值都是一样的。
2.1.2 修改操作不是原子的
原子性:代码为不可分割的最小单位(操作看作为一个整体)
依旧以上面的代码为例:代码count++ 执行实际上是三个指令:load,add , save
由于这三个指令不是一个整体,在执行中途,可能会调度执行其他线程的指令,那么此时这三个指令就分开执行了,所以就会造成上述代码的线程不安全。
如果一个代码的指令操作是原子的,图中情况1发生的情况
t1 和 t2 线程的一次操作都为原子的(一个整体),那么此时代码执行的结果就是预期值,也就不会线程不安全
2.2 解决方式:加锁(synchronized)
作用:通过加锁,使得加了锁的操作具有原子性,进而保证线程安全
方法,使用synchronized关键字
例如上面的代码例子:如果我们让count++加锁,使得load,add,save三个操作变成一个整体,在t1 和 t2 线程同时修改count的值时,每次执行的指令都类似图中的情况1,要不然就是t1线程count++为一个整体执行,要不然就是t2线程count++为一个整体执行,中间不会想回交杂,那么就保证了这这段代码的线程安全
加锁的操作本质上分为两个部分:1.线程指令加锁 2.指令执行结束解锁
代码:修改上面例子的代码,给count++加锁
class ThreadFun1{
public int count = 0;
public void add(){
//加锁
synchronized (this){
count++;
}
}
public int getCount(){
return this.count;
}
}
public class Test1 {
public static void main(String[] args) throws InterruptedException {
ThreadFun1 tf = new ThreadFun1();
//线程 t1
Thread t1 = new Thread(()->{
for (int i = 0; i <10000 ; i++) {
tf.add();
}
});
//线程 t2
Thread t2 = new Thread(()->{
for (int i = 0; i <10000 ; i++) {
tf.add();
}
});
t1.start();
t2.start();
//休眠100毫秒确保 t1,t2线程都执行完毕
Thread.sleep(100);
System.out.println(tf.count);
}
}
当线程t1先执行到count++操作时(先抢到锁),那么当 t2线程也执行到count++操作是,由于他们的锁对象都是 tf ,所以在t1线程没解锁前,t2就堵塞等待t1加锁指令解锁(锁等待)。那么此时就是先执行为t1的count++操作,然后再执行 t2 的count++操作,那么此时load,add,save就可以看做是原子的(为一个整体)
画图表示:修改上面的图为加锁的情况:增加两个指令lock(加锁),unlock(解锁)
此时保证了代码的原子性,即使是多线程的状态下操作同一个变量那么也是线程安全的
加锁的本质其实就是把并发转换为串行的,在保证线程安全的前提下同时还能让代码跑的更快,更好的利用多核CPU,但是加锁大概率还是会造成下线程堵塞等待的,对于程序的效率坑爹还是会有影响的
synchronized用法
用法一:用synchronized修饰需要的加锁的代码
synchronized(锁对象)
synchronized (this){
count++;
}
锁对象:就是执行锁的对象,例如前面的代码所对象为this,那么就是tf这个对象调用的锁
注意:1.如果两个线程针对同一个锁对象加锁才会造成锁竞争从而造成锁等待,而针对不同 锁对象进行加锁则加锁操作互不影响
2. 锁对象可以是任意Object类对象,除了不能是基本数据类型(int,char等),都能作为锁对象使用(而我们在进行加锁是,一般手动给予一个锁对象)
public Object lock = new Object();//定义一个锁对象lock
用法二:synchronized修饰实例方法,此时给这个方法加锁
synchronized public void add(){
count++;
}
这种方法就等于锁对象为this,哪个对象调用锁,锁对象就是谁
方法三:synchronized修饰静态方法(static),那么此时锁对象就是整个类。
因为静态方法是属于类的,与类的实例对象无关。
synchronized public static void add(){ //此时锁作用于整个类
count++;
}
2.3 内存可见性造成的线程不安全
内存可见性:指多线程的同时操作同一个变量时,其中一个线程更改了变量的值,另一个线程也能感知到。
代码例子:
//内存可见性造成线程不安全
public class Test2 {
public static boolean flag = true; //线程结束标志位
public static void main(String[] args) {
Thread t1 = new Thread(()->{
System.out.println("t1线程开始");
while(flag){
//不写任何代码,让判定快速运行,从而导致编译器自动优化
}
System.out.println("t1线程结束");
});
t1.start();
Thread t2 = new Thread(()->{
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
//把t1线程的结束标志置为false
flag = false;
});
t2.start();
}
}
结果:
此时虽然线程 t2 修改了flag为false,但是线程 t1并没有结束,原因就是虽然线程t2修改了flag的值,但是由于线程 t1在执行过程中把从内存中读取flag的操作给优化了,导致t1在运行时感知不到 t2 修改的flag的值,意思就是 t1 不知道 t2 修改了flag,所以此时 t1依旧在执行。
这也就是内存不可见造成的线程不安全
编译器进行优化的原因:flag 条件判定其实可以分为两个操作,1.从内存中读取flag的值(load)到缓存,2.进行条件判定是否为true。但是读取内存的值这个操作比条件判定操作的速度慢很多,导致编译器把向内存读取flag值的操作优化了,每次得到的都是第一次从内存读取到缓存中的flag的值(编译器优化),那么此时即使t2改变了flag的值,t1还是不知道条件已经被更改了,因为t1已经不从内存读取值了(直接从缓存中得到),所以t1不会结束。
2.4 指令重排序造成的线程不安全
一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序。
编译器对于指令重排序的前提:
“保持逻辑不发生变化”. 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价。
从而在多线程的状态下,由于编译器优化导致执行的指令重排造成结果发生改变造成的线程不安全
2.5 使用volatile关键字
volatile关键字修饰的变量:1.可以解决变量内存的可见性
2. 解决修饰代码的指令重排问题(取消重排,按原顺序执行)
2.5.1 volatile解决内存不可见问题
1.由volatile修饰的变量不会进行编译器优化,每次读变量的时候都会直接回到内存中读,那么其他线程修改了值就会直接被感知到,进而解决了内存不可见的问题(2.3的问题)
//volatile修饰flag,使得每次读取flag都直接到内存读取
volatile public static boolean flag = true; //线程结束标志位
此时t1线程读取到了修改单flag(false),那么自然而然的也就循环结束,线程也就结束了
2.5.2 volatile 解决指令重排序问题
2.由volatile修饰的变量不会进行编译器优化,那么也就不会对执行的变量指令进行重排,从而解决了由于指令重排序造成的线程不安全问题
本篇介绍到这就差不多结束了,后期有补充内存在进行详细介绍!!!
喜欢或者有收获的小伙伴能给个三连支持一下吗,万分感谢!!!