第四章 线程安全问题的原因和解决方案
为什么会存在线程安全问题?有些代码,在单个线程环境下去执行,完全正确。但是如果同样的代码,让多个线程去同时执行,此时就可能会出现 bug了。这种就是“线程安全问题”。
public class Demo2 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() ->{
for (int i = 0; i < 5000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
大家可以看一下上面的代码,我们创建了两个线程,两个线程都是对 count 变量自增5000次,最终我们预期的效果应该是输出10000。
但是现实结果和预想的却不是一样的,而且,惊奇地发现,两次运行的结果还不一样,可以说是每次运行的结果都不相同。那出现这两个问题到底是什么原因呢?
由于多个线程之间的调度顺序是“随机”的,就会导致在有些调度顺序下,上述的逻辑就会出现问题。
其实,这里的执行顺序有无数种情况。我们来分析下第三种情况,t1 线程先 load 后,t2 线程也load ,随着 t2 线程进行 add和 save ,那么内存中 count 也就变成了1,紧接着 t1 线程进行 add ,但是之前 t1 线程进行 load 的是0,那么此时由0变成1,save 以后还是1,最终,内存上并不是预期的结果2,而是1。这就相当于在自增过程中,两个线程的结果没有往上累加,而是各自独立运行。所以,这也就为什么程序每次运行的结果都不相同。而且一定是小于10000的数字。
要想解决线程安全问题,就需要从这几个原因入手。
所以就要想办法让 count++ 成为“原子”的------加锁!!!
synchronized 在使用的时候,要搭配一个代码块{ },进入就会加锁,出了就会解锁。在已经加锁的状态下,另一个线程尝试加相同的一个锁,就会产生“锁冲突/锁竞争”,后一个线程就会阻塞等待,一直等到前一个线程解锁为止。
public class Demo2 {
private static int count = 0;
Object locker = new Object();
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() ->{
for (int i = 0; i < 5000; i++) {
synchronized (locker){
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (locker){
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
因为这里我们给 t1 线程和 t2 线程加同样的锁,所以这里 t2 线程会一直阻塞等带 t1 线程执行完毕,t2线程才会开始执行。那么这样线程安全问题就迎刃而解了!
public class Demo5 {
private static int isQuit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() ->{
while (isQuit == 0){
//循环体里啥都没干
//此时意味着这个循环,一秒钟会执行很多很多次
}
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();
}
}
大家观察一下上面的代码,如果输入的 isQuit 的值不为0,那么就可以使线程 t1 执行结束,这是我们预期的结果。那么,事实效果又会怎样呢?
光标此刻一直在闪,说明程序并没有结束,无论你 isQuit 输入的值是多少,程序此刻都毫无反应。其实,这也是一种线程安全问题的情况。
原因:
这是编译器进行的代码优化搞出来的 bug。代码优化是一种非常普遍的情况,编译器为了进一步地提高代码的执行效率,会在保持逻辑不变的前提下,调整生成的代码内容。如果是多线程的代码,代码优化就有可能会出现误判,优化之后的逻辑就和之前不一样了。
计算机运行的程序/代码,经常要访问数据,这些依赖的数据,往往会存储在内存中(比如定义的变量),CPU 使用这个变量的时候,就会把这个内存中的数据,先读出来,放到 CPU 的寄存器中后再参与运算。CPU 读取内存的这个操作相比是非常慢的。
为了解决上述的问题来提高效率,此时的编译器就可能对代码作出优化,把一些本来要读内存的操作,优化成读取寄存器,减少读内存的次数,也就提高整体程序的效率了。
此处我们写的代码就是“内存可见性”情况引起的。
在线程 t1 的循环条件中,其实是做了两步操作,首先要进行 load,读取内存中 isQuit 的值到寄存器中,然后通过 cmp 指令比较寄存器的值是否是0,决定是否要继续循环
由于这个循环速度飞快,短时间内就会进行大量的循环。此时,编译器 JVM 就发现虽然进行这么多次 load ,但是 load 出来的结果都一样,并且 load 操作又非常消耗时间,所以,编译器做了一个大胆的决定,只是第一次循环的时候读内存,后续就不再读内存了,而是直接从寄存器中取出 isQuit 的值。
所以在上述代码里,t2 线程修改了 isQuit 之后,t1 线程却感知不到 isQuit 变量的变化(感知不到内存的变化)—这就是“内存可见性”问题。
多线程中一个比较重要的机制—协调多个线程的执行顺序
public class Demo4 {
public static void main(String[] args) {
Object object = new Object();
Thread t1 = new Thread(() ->{
synchronized (object){
System.out.println("wait 之前!");
try {
object.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("wait 之后!");
}
});
Thread t2 = new Thread(() ->{
synchronized (object){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
object.notify();
System.out.println("唤醒!");
}
});
t1.start();
t2.start();
}
}
单例模式是一个非常经典的设计模式,保证某个类在程序中只存在唯一一份实例,而不会创建出多个实例。
class Singleton{
private static Singleton instance = new Singleton();
//通过这个方法来获取到刚才是实例
//后续如果想使用这个类的实例,都是通过 getInstance 方法来获取
public static Singleton getInstance(){
return instance;
}
private Singleton(){
//把构造方法设置为私有,此时类外面的其他代码就无法 new 出这个类的对象了
}
}
class SingletonLazy{
private static SingletonLazy instance = null;
public static SingletonLazy getInstance(){
if (instance == null){
instance = new SingletonLazy();
}
return instance;
}
private SingletonLazy(){
}
}
public static SingletonLazy getInstance(){
synchronized (Singleton.class){
if (instance == null){
instance = new SingletonLazy();
}
}
return instance;
}
于是,我们就要加个锁,将 if 判定和 new 操作 来变成原子的操作。加锁,把 if 和 new 这两个语句,放到一个 synchronized 中。
public static SingletonLazy getInstance(){
if (instance == null){
synchronized (Singleton.class){
if (instance == null){
instance = new SingletonLazy();
}
}
}
return instance;
}
指令重排序,可能对咱们上述的代码产生影响
指令重排序:也是编译器优化,编译器为了执行效率,可能会调整原有代码的执行顺序,调整的前提是保持逻辑不变。通常情况下,指令重排序能保证逻辑不变的前提下,把程序执行效率大幅度提高。
class SingletonLazy{
private static volatile SingletonLazy instance = null;
public static SingletonLazy getInstance(){
if(instance == null){
synchronized (Singleton.class){
if (instance == null){
instance = new SingletonLazy();
}
}
}
return instance;
}
private SingletonLazy(){
}
}
于是,我们让 volatile 修饰 instance,此时就可以保证 instance 在修改的过程中就不会出现指令重排序的现象了。
这章,我们学习了多线程中比较重要的内容—线程安全问题和解决方案。知道了五种产生线程安全问题的原因以及解决方案—加锁。也了解了什么是单例模式,分别有饿汉模式和懒汉模式,什么情况下该加锁来解决线程安全问题,加锁又应该加在哪里。