文章目录
- 1.线程安全的原因
- ①抢占式执行
- ②多线程修改同一个变量
- ③修改的操作不是原子的
- ④内存可见性
- ⑤指令重排序
- 2. 线程安全的解决方案
- 3 synchronized的特性------可重入锁
1.线程安全的原因
①抢占式执行
操作系统对线程的调度是随机的,没有规律(主要原因)
例如:定义了一个变量count,执行count++这种操作,本质上是三个CPU指令,load(将count的值读入cpu寄存器中)、add(将寄存器的数据进行+1)、save(将寄存器中的数据读入到内存中),而CPU执行指令都是以一个指令为单位顺序进行的,试想,有两个线程同时执行count++操作,这些一个一个的指令就会抢占执行,线程一的add的操作刚完,线程二的add就抢占了下一个位置…
线程的调度是随机的,在有些调度下,代码的逻辑会出现问题,结果会与预计结果不同,但这个是内核实现的,没有办法改变
②多线程修改同一个变量
当多线程修改同一个变量时,会出现问题。一个线程修改一个变量,结果不会出现问题,多线程修改不同的变量也不会出现问题,多线程读取同一个变量也不会出现问题。
就像刚刚提到的抢占式执行的例子,如果一个变量count,进行count++这种操作,分load、add、save,要说线程一二修改不同变量倒也没事,互不干扰,然如果修改同一变量,就会出现以下情况:
如上,这两种是正常情况,这两种执行结果与预期结果相符,但更多的是出现下面的情况:
上面只是列举了两种异常情况,实际上的异常情况更多,线程调度的顺序是随机的,两个线程的执行顺序有无数种,在有些调度顺序下,代码逻辑就会出现问题,发生线程安全问题。
总结:这里确实可以通过调整代码,来避免线程安全问题,但是以及适用性不高;
③修改的操作不是原子的
原子表示不可分割的最小单位,CPU执行指令是一条一条执行的,这一条一条的指令就可以理解为原子,也正因为count++不是原子的才会引发上述的多线程修改同一变量会引发线程安全;
结论:既然上述1,2都没有方法很好的解决线程安全问题,那么咱就试试从这入手——修改操作,使其是原子的,也就是说,咱可以把这些多个原子操作包装成一个原子操作!(例如可以把刚刚所说的count++这个例子的的三条指令包装成一个);
④内存可见性
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.
例如,一个线程负责读数据,另一个线程负责修改数据:
private static int isQuit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (isQuit == 0) { //次数过多编译器会进行优化,volatile防止JVM优化
// 循环体里啥都没干.
// 此时意味着这个循环, 一秒钟就会执行很多很多次.
}
System.out.println("t1 退出!");
});
t1.start();
Thread t2 = new Thread(() -> {
System.out.println("请输入 isQuit: ");
Scanner scanner = new Scanner(System.in);
// 一旦用户输入的值, 不为 0, 此时就会使 t1 线程执行结束.
isQuit = scanner.nextInt();
});
t2.start();
}
这里的while(isQuit == 0),就是要先从内存中读取isQuit 的值(LOAD操作),再到寄存器中读取isQuit 的值与0进行比较(CMP操作),这里while会循环的进行这个操作(非常快),而我们知道的是,CPU读写数据最快,内存次之(与CPU差3 ~ 4个数量级),硬盘最慢(与内存差3 ~ 4个数量级);所以LOAD从内存中读取数据操作的速度相对于在CPU上进行CMP操作就要慢的多,那么编译器就要偷懒了,既然频繁的LOAD读取isQuit 这个数据,多次执行的结果还都是一样,干脆LOAD就只执行一次;将CPU读内存的操作变成读取寄存器,减少读取内存的操作,也可以提高整体程序的效率。
运行上述代码:
分析:这时可以发现, 当输入数字5时,相当于修改了isQuit 这个变量的值为5,按理来说t1线程的run方法中isQuit 只要不等于0就会停下来,可是程序依旧没有停止,就出现了内存可见性问题,直接读取寄存器的值,而没有读取我们修改之后的值;
编译器优化,在多线程情况下可能存在误判——使用volatile关键字,可以告诉JVM不允许优化
private static volatile int isQuit = 0;
可以看到,当我们线程2一修改isQuit的值,线程1就停止运行了。
volvatile 关键字有如下两大作用:
- 禁止指令重排序:保证指令执行的顺序,防止 JVM 出于优化而修改指令执行顺序,引发线程安全问题。
- 保证内存可见性:也就是说,保证了我们读取到的数据是内存中的数据,而不是缓存,具体的,当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
Java 内存模型 (JMM):
Java虚拟机规范中定义了Java内存模型. 目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.
⑤指令重排序
一段代码是这样的:
- 去前台取下 U 盘
- 去教室写 10 分钟作业
- 去前台取下快递
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序编译器对于指令重排序的前提是 “保持逻辑不发生变化”. 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
总结:JVM的代码优化在多线程情况下,也会带来一些BUG;
2. 线程安全的解决方案
上面提到操作不是原子的,我们可以从这里入手,将count++这个操作的三个布置包装成一个步骤变成原子的,如何做呢——“加锁”;count++之前加锁,count++之后再解锁,别的线程若是想在加锁和解锁之间进行需修改,很抱歉,修改不了,别的线程只能处于阻塞等待的线程状态(BLOCKED状态);
Java的代码中如何进行加锁呢?
使用synchronized关键字,synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待(BLOCKED状态).
- 进入 synchronized 修饰的代码块, 相当于加锁
- 退出 synchronized 修饰的代码块, 相当于 解锁
//对实例方法加锁
synchronized public void increase(){
count++;
}
这个锁具体是怎么执行的呢?
锁具有抢占特性,如果这个锁没人加,有人想加,就可以立即加上,若这个锁以及被人加上了,加锁操作就会阻塞等待;如刚才的栗子,count++分三步进行,load、add、save,而线程调度是随机的过程,一旦这两个线程同时调用,这两组三个操作就会进行排列组合,就会产生线程不安全,现在使用锁,就可以使这三个操作串行执行了;如下
此时,并发执行就变成了串行执行,这个操作就会减慢执行效率,但是保证了线程安全
3 synchronized的特性------可重入锁
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
“不可重入锁”:
// 第一次加锁, 加锁成功 lock(); // 第二次加锁, 锁已经被占用, 阻塞等待. lock();
一个线程没有释放锁, 然后又尝试再次加锁. 按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放,才能获取到第二个锁.但想要第一把锁解锁,需要执行完synchronized代码块,才可以加下一把锁,然而第二把锁一直在阻塞等待,所以第一把锁既不能解锁,第二把锁也不能加锁,就卡在这里了;
并且,有时候由于多次嵌套,无法直接观察出是否多次加锁:public static Object locker = new Object(); public static void increase1(){ synchronized (locker){ } } public static void increase2(){ increase3(); } public static void increase3(){ increase4(); } public static void increase4(){ //可重入锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息. synchronized (locker){ //synchronized属于可重入锁,防止多次加锁,产生死锁 } }
Java 中的 synchronized 是 可重入锁, 因此没有上面的问题.
代码示例:在下面的代码中, increase 和 increase2 两个方法都加了 synchronized, 此处的 synchronized 都是针对 this 当前对象加锁的. 在调用 increase2 的时候, 先加了一次锁, 执行到 increase 的时候, 又加了一次锁. (上个锁还没释放, 相当于连续加两次锁)这个代码是完全没问题的. 因为 synchronized 是可重入锁.
static class Counter {
public int count = 0;
synchronized void increase() {
count++;
}
synchronized void increase2() {
increase();
}
}
snychronized实现可重入的底层原理:
在可重入锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息.
- 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
- 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
计数器还未归0,程序就抛出异常,会不会死锁?
分析:若程序抛出异常,并且没有catch捕捉,程序就会脱离之前的代码块,一旦脱离这层加锁的代码块,计数器就会- -,脱离多层代码块,计数器减到0,也就解锁了;
总结:加锁时若出现异常,是不会死锁的,也是一个使得synchronized优秀到将他设计成关键字的原因了,若是C++/Python加锁解锁,都是通过对象来实现的,这时就有可能由于出现异常引起代码未执行完,解锁代码未执行引起死锁;