线程安全的原因与解决方法

  • 线程安全
    • 什么是线程安全
    • 典型示例
  • 线程安全的原因
    • 原子性
    • 内存可见性
    • 指令重排序
  • 解决线程安全问题
    • synchronized 关键字
      • 互斥
      • 可重入
    • volatile关键字

线程安全

什么是线程安全

线程安全是指在多线程环境中,一个类或者方法能够保证在任意时刻,无论在哪个线程中调用,都能表现出一致的行为,且不会对其他线程产生不可预测的影响.

相反我们则称为存在线程安全问题或者线程不安全

典型示例

看了上面的解释大家可能还不理解,下面我们通过一个典型的线程不安全示例

    static int count=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            for (int i = 0; i < 1_0000; i++) {
                count++;
            }
        });
        Thread t2=new Thread(()->{
            for (int i = 0; i < 1_0000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        //保证两个线程都执行完
        t1.join();
        t2.join();
        System.out.println(count);
    }

如果是在单线程中,我们都知道结果是20000,但是在多线程中就不一样了
线程安全的原因与解决方法_第1张图片
多运行几次,我们发现每次的结果都不一样,这就是因为两个线程存在线程安全问题,会相互影响

这里就和汇编指令有关系了,在CPU中一个简单的count++,实际要执行3个指令,我们这里简单分为3步:

  1. load: 从内存中取出count的值,加载到寄存器上
  2. add: 对寄存器上count的值加1
  3. save: 把寄存器的值存入内存

根据前面的学习,我们都知道线程是随机调度的,我们也不知道什么时候是执行哪个线程

因为两个线程都是对count进行操作,因此两者都要执行这三个指令,就会产生很多种搭配
例如:
t1线程先执行load指令后被调度走,(我们假设是刚开始)这时寄存器加载的是0;t2执行完3个指令再被调度走,这时count的值变成1,但是t1已经执行了load指令拿到了0时刻的值,再到t1执行的时候把剩下两个指令执行完,我们发现count应该会变成2,结果变成了1
根据上面的例子,我们可以联想到会有很多种情况,因此我们说上面代码是线程不安全的

线程安全的原因

根本原因是线随机调度

原子性

原子性是指一个操作或者一系列操作不可再分,要执行就要全部执行完.

例如典型示例中的count++操作就不是符合原子性的
count=1赋值操作是符合原子性的

内存可见性

可见性是指当多个线程都会访问同一个变量,一个线程对该变量进行更改,其他线程都能立即得到修改后的值.
但如果其他线程还是使用该变量未更改之前的值,这类问题就是内存可见性问题

我们来下面的典型示例

    static boolean key=true;

    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            while (key) {

            }
            System.out.println("t1,结束了");
        });

        Thread t2=new Thread(()->{
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            key=false;
            System.out.println("t2,结束了");
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("main,结束了");
    }

如果不看运行结果,相信大家都会认为是分别打印t2,t1,main 就结束了
但运行结果是死循环了
线程安全的原因与解决方法_第2张图片
这是因为优化导致的,t1线程在多次获取key的值都是相同的,就把key的值放到寄存器上,从内存中获取变成之间从寄存器中取就行,这样可以省下不少的开销,但是也因此t2线程虽然对key的值进行修改,但是t1线程并没有从内存中重新获取key的值,而是一直使用旧的数据导致t1线程死循环

指令重排序

指令重排序是指一个操作不是原子性的情况下,JVM可能会进行优化,导致指令不是顺序执行的

例如我们去买水果,你要买西瓜,草莓,苹果,雪梨,我们可以根据路线(远近)优化成先买草莓,再买雪梨,接着买西瓜,最后买苹果

关于指令重排序问题,后面我会以单例模式中的双重检测懒汉模式为例进行讲解

解决线程安全问题

synchronized 关键字

使用synchronized关键字可以给对象加锁,如果其他线程访问到锁对象则会阻塞,等到锁对象解锁了才可以使用

锁对象是什么不重要,重要的是是否相同
进⼊synchronized修饰的代码块,相当于加锁
退出synchronized修饰的代码块,相当于解锁

互斥

使用synchronized关键字可以把一系列操作变成原子性
通过synchronized关键字可以解决count++的问题

static int count=0;

    public static void main(String[] args) throws InterruptedException {
        Object object=new Object();
        Thread t1=new Thread(()->{
            synchronized (object) {
                for (int i = 0; i < 100_0000; i++) {
                    count++;
                }
                System.out.println("t1,执行完毕");
            }
        });
        Thread t2=new Thread(()->{
            synchronized (object){
                for (int i = 0; i < 100_0000; i++) {
                    count++;
                }
                System.out.println("t2,执行完毕");
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }

线程安全的原因与解决方法_第3张图片

如果synchronized关键字修饰普通方法则是对当前对象加锁

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

如果synchronized关键字修饰静态方法则是对类对象加锁

public static synchronized void addCount() {
        count++;
    }

可重入

在java中,如果一个线程对同一个对象加锁多次,并不会导致阻塞,而是只当成一把锁
如果我们把t1线程变成下面这样是不影响使用的

Thread t1=new Thread(()->{
            synchronized (object) {
                synchronized (object){
                    for (int i = 0; i < 100_0000; i++) {
                        count++;
                    }
                    System.out.println("t1,执行完毕");
                }
            }
        });

volatile关键字

使用volatile关键字能保证内存可见性,不能保证原子性

volatile关键字作用如下:

  1. 禁止JVM指令重排序,保证指令按照代码的顺序执行
  2. volatile关键字修饰的变量值修改都会存入内存
  3. volatile关键字修饰的变量读取值都是从内存中读取,保证数据的准确性

我们通过volatile关键字来解决上面内存可见性的问题

    volatile static boolean key=true;
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            while (key) {

            }
            System.out.println("t1,结束了");
        });
        Thread t2=new Thread(()->{
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            key=false;
            System.out.println("t2,结束了");
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("main,结束了");
    }

线程安全的原因与解决方法_第4张图片

但是如果我们用volatile关键字能不能解决一开始count++的问题呢?
我们来试一下

    volatile static int count=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            for (int i = 0; i < 100_0000; i++) {
                count++;
            }
        });
        Thread t2=new Thread(()->{
            for (int i = 0; i < 100_0000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }

线程安全的原因与解决方法_第5张图片

显然是不行的,因此volatile关键字不能保证原子性

你可能感兴趣的:(安全)