[线程安全问题] 多线程到底可能会带来哪些风险?

1. 目标

        本文最终目标是熟练掌握在多线程的情况下, 会出现的安全性问题, 以及为什么会出现这样的问题, 最后引出对应的解决办法.

2. 什么是线程安全

        线程安全就是在多线程的情况下执行代码的结果如果和预期(在单线程情况下执行的结果)的一样, 那么就是线程安全. 否则, 就是线程不安全, 这时候就很可能会出现线程安全问题.

3. 线程安全问题以及造成线程不安全的原因

        在探索线程安全问题之前, 这里先举出一个线程不安全的例子以及运行结果来引出下文:

//创建两个线程,让这两个线程并发执行一个变量,分别进行自增5w次, 最终预计一共自增10w次
class Counter{
    //保存计数的变量
    public int count;

    public void increase(){
        count++;
    }
}

public class Main {
    public static void main(String[] args) {
        Counter counter=new Counter();

        Thread thread1=new Thread(() -> {
            for(int i=0;i<50000;i++){
                counter.increase();
            }
        });

        Thread thread2=new Thread(() -> {
            for(int i=0;i<50000;i++){
                counter.increase();
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("count="+counter.count);
    }
}

        运行结果:
在这里插入图片描述

        这里会发现: 我们在运行这段代码后, 结果都是小于10万的, 且每次的运行结果都是不一样的(但是大概率都是在5万和10万之间, 具体原因放在后面解释), 在与我们所预期的完全不一样, 我们所希望的是使用多线程提高运行效率然后达到目标效果, 而运行出来的结果和10万显然差别很大. 这是为什么呢?

3.1 线程安全问题一: 操作系统的随机调度

        操作系统的随机调度(或者说是抢占式执行)是引起线程不安全最根本的原因. 由于线程 thread1 和线程 thread2 是并发的, 所以在操作系统内部并发执行这两个线程的时候, 可能会发生两个线程同时读取内存中同一个数据, 但是最后在两个线程执行完之后, 内存中保存的只会是后存储到内存中的值(这只是其中可能会出现的一种情况, 还可能会在一个线程将计算之后的数据存储到内存之前, 另外一个线程就已经在读取内存中的数据…), 总之这种操作系统的随机调度使得在执行线程的先后顺序是随机的, 永远都猜不到操作系统中下一个指令会执行啥, 非常复杂… 这就是造成线程不安全的原因之一, 也是最根本的原因.

3.2 线程安全问题二: 多个线程修改同一个变量

        就类似上面那样, 两个线程(CPU)来对同一块(内存)变量进行修改(例子中是进行加法操作)的时候, 就很可能会出现线程安全问题. 注意: 这里有三个关键点 — 1. 多个线程 -> 2. 对同一个变量 -> 3. 而且进行的必须是修改操作(单纯的读操作是不会出现线程安全问题的). 这追根到底也是操作系统随机调度(不确定性)所引起的, 因为如果只是一个线程来修改变量(这样就等于是之前一直写的普通代码, 不需要考虑线程安全问题), 或者是多个线程读取同一个变量, 又或者多个线程来修改多个变量(这也就相当于每个线程各司其职, 变相的单线程), 都是不会引起线程安全问题的, 而多个线程修改同一个变量的时候, 操作系统对内存进行读写的先后顺序我们是不知道的, 这也就造成线程不安全的原因之一.

3.3 线程安全问题三: 修改操作不是原子性的

        在上面代码中, Counter 类中的 increase() 方法每执行一次 ++ 操作, 操作系统底层都会进行三步操作(三个指令): 1. 将内存中的数据加载到CPU(LOAD); 2. 在CPU中执行加法操作(ADD); 3. 将计算之后的结果存储到内存中(SAVE). 我们将这三个操作视为是一次修改操作. 原子性在之前MySQL中的事物(事物中最核心的特性)就介绍过, 简单说, 原子性就是把一些操作视为是一个密不可分的整体.
        那么, 为什么说修改操作不是原子性的就可能会导致线程不安全呢?
        其实这也还是操作系统随机调度所造成的. 上面的三步操作如果是分离开来的话, 如若只是单纯的单线程的话, 是不会有任何影响的, 但是示例代码中是有两个线程的, 那么在时间层序上看, 这三步操作(三个指令)就很有可能是交错进行执行的, 这就会导致修改之后的结果是错误的. 这也就造成线程不安全的原因之一.

3.4 线程安全问题四: 内存可见性

        内存可见性问题其实是操作系统对代码进行优化的时候, 所引发的线程安全问题. 如果在线程中有一些操作是一直在重复做某个工作, 那么这时候操作系统可能会对其进行一个优化, 将内存设为不可见, 而是直接读取寄存器上的内容, 这可能就会省略了一些重复的计算机指令, 保留下有效的指令. 最经典的就是在单线程的情况下, 我们在执行 ++ 的时候, 需要经历 LOAD , ADD , SAVE 三个指令才能完成, 但是如果是在执行循环的 ++ 操作的时候, 操作系统就会默认地把一直循环的这三个指令优化成了 LOAD , ADD , ADD , ADD … , ADD , SAVE .也就是说, 把原本的三指令循环优化成了只进行一次 LOAD 和 SAVE , 而在 ADD 上进行了循环, 这样就可以大大地降低了反复读写内存的时间, 提高了计算的效率. 当然, 在这样的单线程情况下, 是不会出现安全问题的, 结果也都是正确的, 但是如果在多线程情况下的话, 就很可能会出现问题了, 这里举一个很经典的例子(如下代码):

public class Main {
    public static int flag=0;

    public static void main(String[] args) {
        Thread thread1=new Thread(() -> {
            while(flag==0){
                try {
                    ;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("循环结束");
        });

        Thread thread2=new Thread(() -> {
            Scanner scanner=new Scanner(System.in);
            System.out.println("请输入一个整数");
            flag=scanner.nextInt();
        });

        thread1.start();
        thread2.start();
    }
}

        由于在线程 thread1 中反复对 flag 的值进行判断, 且判断都为 true (进入循环体), 这时候, 操作系统在进行优化的时候, 就可能会省略了这一步, 这就导致了如果在线程 thread2 对 flag 值进行修改也不会使在线程 thread1 跳出循环. 这也就造成线程不安全的原因之一.

3.5 线程安全问题五: 指令发生重排序

        指令重排序问题其实也是操作系统在优化的过程中出现的线程安全问题. 指令重排序其实是操作系统帮我们找了指令执行的逻辑顺序的一个最优解, 从而来提高代码执行的效率. 就比如, 本来有几个指令可以完成一个事情, 但是当把这几个指令的顺序调换一下, 可能会得到一个更优的解决方案, 这时候, 操作系统就很可能会直接对这些指令进行优化, 来提高代码执行效率. 当然, 这如果是在单线程的情况下肯定是没事的, 因为无论指令顺序如何调整, 最后执行的结果还是那样, 只是进行一个优化而已. 但是在多线程的情况下, 指令顺序的调整, 是会引起最后执行结果的不一样, 这也就造成线程不安全的原因之一.

4. 出现上述线程安全问题的解决方法

4.1 针对 问题一

        针对问题一操作系统的随机调度, 我们是没有办法来进行解决的, 操作系统在多线程里面的这种随机调度是非常讨厌的, 我们能做的只是在必要的时候进行避免, 不能够也无法对操作系统随机调度这一特性进行修改.

4.2 针对 问题二

        针对问题二多个线程修改同一个变量这个问题, 我们其实可以直接通过对编写代码的结构进行调整, 不让多线程修改同一个变量即可.
        但这里有人问: 如果我一定就要通过多线程来修改同一个变量的话, 有没有什么解决方法? 你要的答案在下面, 请继续往下看.

4.3 针对 问题三

        针对问题三修改操作不是原子性的, 就拿上面第一段代码来说, 我们的解决办法是: 将 LOAD ADD SAVE 这三个指令进行加锁操作(也就是把这三个指令打包在一起, 这样不就是一个密不可分的整体了吗, 也就不会出现线程安全问题了).
        在Java中的加锁操作是有很多种的, 这里介绍一种最常见的加锁方法: 使用 synchronized 关键字.

4.3.1 详解 synchronized 关键字

        这里的 synchronized 关键字有"同步"的意思, 当然, 这里的"同步"不是IO场景下或者上下级调用场景下的"同步"和"异步". 这里"同步"的意思是"互斥", 也就是说, 如果给一个方法加上 synchronized 关键字, 那么就相当于给这个方法上了锁, 在一个线程中调用这个方法的时候, 由于加了锁, 所以这是其他线程如果想要再调用这个方法的时候, 就需要进行阻塞等待, 直到方法调用结束锁解开的时候, 才可以被其他线程所调用, 这样就形成了"互斥"的效果. 当然, "同步"还有其他的意思, 这里做一个补充点: 在IO场景下或者上下级调用场景下, "同步"表示的是调用者自己来负责获取到调用结果的操作; "异步"表示的是调用者自己不负责获取调用结果, 而是由被调用者把计算好的结果主动推送给调用者的操作.
        使用 synchronized 关键字加锁的两种情况:
        1. 给对象加锁. 在给对象加锁的时候, 有两种写法:
        (1) 直接修饰普通方法, 示例代码如下:

public class SynchronizedDemo {
	synchronized public void methond() {
		...
	}
}

        (2) 使用指定 this 的修饰代码块, 示例代码如下:

public class SynchronizedDemo {
	public void method() {
		synchronized (this) {
			...
		}
	}
}

        注意: 上面的这两种只是写法不同, 最终的效果是相同的, 所以这两种写法是对等的.

        2. 给类对象加锁. 在给类对象加锁的时候, 也有两种写法:
        (1) 直接修饰静态方法, 示例代码如下:

public class SynchronizedDemo {
	synchronized public static void method() {
		...
	}
}

        (2) 使用指定类.class 的修饰代码块, 示例如下:

public class SynchronizedDemo {
public void method() {
		synchronized (SynchronizedDemo.class) {
			...
		}
	}
}

        注意: (1) 上面的这两种只是写法不同, 最终的效果是相同的, 所以这两种写法是对等的. (2) 第二种写法指定的类.class 不一定是要使用本类(该方法对应的类), 也可以是其他类.


        在使用 synchronized 关键字进行加锁的时候, 我们只需要认准: 两个线程一定需要是同一把锁, 才会发生阻塞等待的情况, 否则是不会发生阻塞等待的, 因为它们并没有指向同一把锁(也就可以理解为这两个线程之间不是原子性的). 那么, 我们又该如何判断两个线程之间是否指向同一把锁呢?
        情况一: 给对象加锁. 如果两个线程中所使用的是同一个对象中的加锁方法, 那么后执行的线程就会发生阻塞等待的情况. 但是如果两个线程中使用的是不同对象的加锁方法, 那么这样就不会发生阻塞等待了. 举个例子:

class A{
    synchronized public void m1(String a){
        System.out.println(a+"开始m1");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(a+"结束m1");
    }

    synchronized public void m2(String a){
        System.out.println(a+"开始m2");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(a+"结束m2");

    }
}

public class Main {
    public static void main(String[] args) {
        A a1=new A();
        A a2=new A();

        Thread thread1=new Thread(() -> {
            a1.m1("线程1");
        });

        Thread thread2=new Thread(() -> {
            a2.m1("线程2");
        });

        thread1.start();
        thread2.start();
    }
}

运行结果:
在这里插入图片描述

        尽管有 synchronized 修饰, 但是由于两个线程中所分别使用的 a1 和 a2 是两个不同的对象, 所以它们是不会发生阻塞等待的.

class A{
    synchronized public void m1(String a){
        System.out.println(a+"开始m1");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(a+"结束m1");
    }

    synchronized public void m2(String a){
        System.out.println(a+"开始m2");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(a+"结束m2");

    }
}

public class Main {
    public static void main(String[] args) {
        A a1=new A();
        A a2=new A();

        Thread thread1=new Thread(() -> {
            a1.m1("线程1");
        });

        Thread thread2=new Thread(() -> {
            a1.m2("线程2");
        });

        thread1.start();
        thread2.start();
    }
}

运行结果:
在这里插入图片描述

        由于两个线程中使用的都是同一个对象中加锁的方法, 所以就会发生阻塞等待了, 类似这样的操作就是线程安全了.

        情况二: 给类对象加锁. 如果两个线程中调用的方法的锁是指向同一个类的, 那么尽管它们所使用的不是同一个对象, 也是会出现阻塞等待(也就是线程安全). 所以可以说, 给类对象加锁重点就在于看看不同线程之间调用方法的锁是否指向的是同一个类.

class A{
    synchronized public static void m1(String a){
        System.out.println(a+"开始m1");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(a+"结束m1");
    }

    synchronized public static void m2(String a){
        System.out.println(a+"开始m2");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(a+"结束m2");

    }
}

public class TestDemo4 {
    public static void main(String[] args) {
        Thread thread1=new Thread(() -> {
            A.m1("线程1");
        });

        Thread thread2=new Thread(() -> {
            A.m2("线程2");
        });

        thread1.start();
        thread2.start();
    }
}

运行结果:
在这里插入图片描述

        当然, 给类对象加锁也可以是针对不同类, 两个线程之间也是可以发生阻塞等待的. 再举个例子:

class B{
    public void m(String a){
        synchronized (B.class){
            System.out.println(a+"开始");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(a+"结束");
        }
    }
}

class C{
    public void m(String a){
        synchronized (B.class){
            System.out.println(a+"开始");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(a+"结束");
        }
    }
}

public class TestDemo5 {
    public static void main(String[] args) {
        B b=new B();
        C c=new C();

        Thread thread1=new Thread(() -> {
            b.m("线程1");
        });

        Thread thread2=new Thread(() -> {
            c.m("线程2");
        });

        thread1.start();
        thread2.start();
    }
}

运行结果:
在这里插入图片描述

4.3.2 使用 synchronized 关键字在解决问题三存在的问题

        通过上面对 synchronized 关键字的了解后, 解决问题三修改操作不是原子性的就会变得非常简单了, 直接在执行 ++ 操作的方法加上 synchronized 关键字即可. 具体代码如下:

public class TestDemo {
    static class Counter{
        public int count;

        synchronized public void crease(){
            count++;
        }
    }

    public static void main(String[] args) {
        Counter counter=new Counter();

        Thread thread1=new Thread(() -> {
            for(int i=0;i<10000;i++){
                counter.crease();
            }
        });

        Thread thread2=new Thread(() -> {
            for(int i=0;i<10000;i++){
                counter.crease();
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("count="+counter.count);
    }
}

4.4 针对 问题四 + 问题五

        针对问题四内存可见性和问题五指令发生重排序, 其实他们都是操作系统对代码进行优化之后执行多线程所会出现的问题, 就如上面问题四的那段代码. 那么面对这个问题, 我们又该如何解决呢? 在多线程的时候, 我们可以使用 volatile 关键字来阻止(禁止)操作系统对代码进行优化的操作(也就是让内存由不可见变为可见的以及不让指令发生重排序), 这个关键字其实是给说加上的变量加上一段特殊的二进制指令 — “内存保障”. 所以修改之后的代码就可以是:

public class Main {
    volatile public static int flag=0;

    public static void main(String[] args) {
        Thread thread1=new Thread(() -> {
            while(flag==0){
                try {
                    ;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("循环结束");
        });

        Thread thread2=new Thread(() -> {
            Scanner scanner=new Scanner(System.in);
            System.out.println("请输入一个整数");
            flag=scanner.nextInt();
        });

        thread1.start();
        thread2.start();
    }
}

        加上 volatile 之后, 就不会进行优化操作, 运行的结果也就正确了. 这是一种方法, 这里还有一种方法是不用 volatile 关键字也不会进行优化的操作, 那就是在这段代码线程 thread1 中的循环体里面加上 sleep 方法来对代码起到一个阻塞的作用, 这时候会让代码循环转速变慢了一些, 读写内存这个操作也就不会变得那么频繁, 也就不会触发代码优化了(但是这里还是建议: 尽量加上 volatile 关键字, 以免可能出现一些优化对代码逻辑造成不必要的影响). 具体代码如下:

public class Main {
    public static int flag=0;

    public static void main(String[] args) {
        Thread thread1=new Thread(() -> {
            while(flag==0){
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("循环结束");
        });

        Thread thread2=new Thread(() -> {
            Scanner scanner=new Scanner(System.in);
            System.out.println("请输入一个整数");
            flag=scanner.nextInt();
        });

        thread1.start();
        thread2.start();
    }
}

        volatile关键字的作用主要有两个:
        (1) 保证内存可见性: 基于屏障指令实现, 即当一个线程修改一个共享变量时, 另外一个线程能读到这个修改的值.
        (2) 保证有序性: 禁止指令重排序. 编译时 JVM 编译器遵循内存屏障的约束, 运行时靠屏障指令组织指令顺序.
        还有一点要注意: volatile 不能保证原子性的.

        说到优化问题, 这里简单谈谈上面优化的过程, 在Java中也叫作"JMM(Java Memory Model)".

在这里插入图片描述

        优化之后出现问题的原因就是: 线程优化之后, 主要在操作工作内存, 没有及时读取主内存, 从而导致出现了误判的现象. 其中, 这里的工作内存指的是CPU的寄存器(可能包括CPU缓存); 这里的主内存才是计算机中所说的真正的内存. 所以, 我们也可以把上面这段话简化成: 线程优化之后, 主要在操作CPU, 没有及时读取内存, 从而导致出现了误判的现象.

5. 总结

        前面MySQL文章中所讲到的事物和多线程很相似, 其实从某种现象上说, 事物可以是多线程的一个简化版本, 它们都是在执行并发过程中会出现的某些问题, 并且在解决这些问题之后, 都会使代码的准确性(或隔离性)提高, 但是却会牺牲掉一部分运行效率.
        但是话又说回来, 总而言之, 多线程的确会带来一些风险, 对此我们在写代码的时候更要胆大心细, 沉着应对, 减少因为执行多线程而出现bug的情况.

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

蔡欣致

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值