Java 线程安全

JavaEE 之线程安全问题

一、什么是线程安全问题?

       某段代码,在多线程的环境下执行,会出现 bug,导致代码的运行结果不符合预期,这个代码就存在线程 安全问题。

  • join( )能不能防止线程的抢占式执行?

       join(),虽然可以让程序员通过代码控制线程的执行顺序,让一个 t1 线程等待另一个 t2 线程结束后,t1线程再结束,但是这样做就等同于让两个线程串行执行了。多线程的初心,就是让多个线程并发执行,更好得到利用多核 CPU,提高多线程的执行效率。

二、引起线程安全问题的原因

1. 抢占式执行

	根本的原因是因为在操作系统中,线程的调度是无序的、随机的。

2. 多个线程修改同一个变量

例如:

class Count {
    private int count = 0;

    public void add() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {

        Count counter = new Count();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });

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

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

        t1.start();
        t2.start();
        t3.start();

        t1.join();
        t2.join();
        t3.join();

        System.out.println(counter.getCount());
    }

}

       假设,如果不存在线程安全问题,那么程序执行结束后,count 应该等于 150000,然而,实际上程序执行的结果中 count 的值是一个大于 0 小于 150000 的一个随机值。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

上述的这段代码说明,多线程环境下,对同一个变量进行修改,存在线程安全问题。

3. 修改操作不是原子的

  1. 什么是原子性

原子性:原子,表示不可分割的最小单元。

原子操作:不可中断的一个或者一系列操作, 也就是不会被线程调度机制打断的操作, 运行期间不会有任何的上下文切换(context switch). 例如,某个操作,对应单个 CPU 指令,就是原子的。如果这个操作对应多个 CPU 指令,大概率就不是原子的。

       像上述的代码中 count++ 的操作就不是原子的,++ 操作在操作系统中,可以拆分为三个 CPU 指令:

  1. load,把内存中的数据读取到 cpu 寄存器中。
  2. add,把寄存器中的值,进行 +1 运算。
  3. save,把寄存器中的值写回到内存中。

       因为线程的调度是随机的,因此在 cpu 中,这 3 个指令的排序方式有很多种的,例如:

在这里插入图片描述

       对于不同的 cpu 指令的组合,对于 ++ 操作的运算结果是不一样的。例如假设序号 1 的 t1 和 t2 线程同时对 count 执行 +1 操作,这两个线程执行结束后,预期结果应该 count+2,但是实际结果只进行了 +1,t1 线程的执行结果覆盖掉了 t2 线程的计算结果。序号 2 的执行结果是正确的,因为 t1 线程先执行了count+1 操作,然后将 count+1 的结果 存入到内存中,然后 t2 线程再进行 load 操作,此时读取到寄存器中的值为 count+1 的值,t2 线程结束后,将count+2 的值写回到内存中,执行结果为 count+2 的值,计算结果符合预期,此时序号 2 是正确的;序号 3 的 t1 线程会覆盖线程 t2 的运算结果。

       通过上述的例子可以得出这个结论,修改操作不是原子的会引起线程安全问题。

  • 如何解决多线程中,非原子性的这个问题?

    可以通过加锁的操作,保证原子性。 加锁的核心操作有两个:
            1. 加锁
            2. 解锁
        一旦某个变量加锁之后,有一个线程拿到了这个变量(就相当于拿到了锁),对这个变量进行操作,这时候就会触发锁竞争事件,拿到锁的线程先执行,其他的线程需要阻塞等待,一直等到拿到锁的线程执行结束,释放了锁为止。一旦释放了锁,需要对该变量执行操作的所有线程又会发生锁竞争,具体是哪个线程会拿到这个变量,是无法确定的,因为线程的调度是无序的。
        例如,假设有 3 个线程 t1、t2、t3,这 3 个线程同时对一个变量进行操作,此时对操作变量的代码进行加锁,如果这 3 个线程是并发执行的,当 t1 线程拿到变量之后,就获得了锁资源。此时,如果 t2、t3 线程想要对该变量进行操作,获得锁资源,需要阻塞等待,等待 t1 线程执行结束,释放了锁(变量),t2 和 t3 线程才可以拿到锁(变量),但是至于是 t2 线程和 t3 线程哪个先拿到锁,这是不确定的。因为线程的调度是无序的、随机的、抢占式执行的。

        在 Java 中使用关键字 synchronized 实现加锁的效果。进入 synchronized 修饰的代码块,就会触发加锁的操作,除了 synchronized 修饰的代码块,就会触发解锁的操作。

        因此,如果要保证上述代码 count++ 执行结束之后的结果等于预期结果 150000,就需要对 count++ 进行加锁的操作。具体代码如下:

class Count {
    private int count = 0;

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

    public int getCount() {
        return count;
    }
}

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {

        Count counter = new Count();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });

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

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

        t1.start();
        t2.start();
        t3.start();

        t1.join();
        t2.join();
        t3.join();

        System.out.println(counter.getCount());
    }

}

运行结果:

在这里插入图片描述

4.内存可见性,引起线程安全问题

4.1 什么叫做内存可见性?

    所谓的内存可见性,就是指在多线程的环境下,编译器对于代码优化,产生了误判,从而引起了 bug,进一步导致了程序的 bug。

        让我们看一下下面这段代码,预期结果:t1 线程通过 flag == 0 作为循环初始情况,将进入循环,循环体中,什么都不做,如果 t1 线程结束后,会打印一句 “循环结束!t1 结束!” 的字样,t 2 线程通过控制台输入一个整数,一旦用户输入了一个非 0 的值,此时的循环竟会立即结束,从而 t1 线程的循环条件 flag ==0 为假,循环结束,t1线程结束,控制台上输出 “循环结束!t1 结束!”。

public class ThreadDemo2 {
    public static int flag = 0;
    
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 0) {

            }

            System.out.println("循环结束!t1结束 !");
        });


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

        t1.start();
        t2.start();
    }
}

运行结果:

在这里插入图片描述

        执行上述代码后,可以观察到,虽然 t2 线程在执行时,可以在控制台上输入一个不为 0 的 整数,但是 t1 线程并不会结束,在控制台上看不见输出字样 “循环结束!t1 结束!”,并且能观察光标存在,表示 t1 线程并没有结束。

4.2 为什么会出现这样的情况呢?

   首先先从 while(flag == 0) 进行分析,这个过程在 cpu 中有两步操作,第一步操作是 load(从内存读取数据到 cpu 寄存器),第二步是进行 cmp 操作(比较寄存器里的值是否为0,这两个操作中,从内存读取数据到 cpu 寄存器中的速度(load) 远远高于 cmp),此时 t2 线程还没有对 flag 进行修改,编译器就发现,load 的开销很大,每次 load 的结果都一样。此时编译器就会把 load 优化掉,只有第一次执行 load 操作时是真正的执行的,后续循环都只是进行 cmp 操作。这个过程是编译器优化的手段。

4.3 什么是编译器优化?

   就是能够通过调整代码执行逻辑,在保证程序结果不变的前提下,通过加减语句,通过语句变换,通过一系列操作,让整个程序执行的效率大大提升。

      实际上编译器对于“程序结果不变”在单线程情况下的判断是非常准确的!!!但是对于多线程环境下就不一定了,可能会导致,优化调整之后,效率变高了,但是结果却变了,导致程序运行的结果不符合预期,引起程序 bug。

4.4 如何解决内存可见性问题引起的线程不安全问题?

      解决办法:
            1. 在 where(flag == 0){}中加入 sleep

            2. 对变量 flag 加关键字 volatile 修饰
      volatile 关键字的作用: 被 volatile 修饰的变量,对该变量进行操作时,编译器就会禁止代码优化,能够保证每一次使用该变量时,都是从内存中重新读取该变量的数据,保证代码的准确性。volatile 的这个效果,称为“保证内存可见性”。

  • 方法1: 在 where(flag == 0){}中加入 sleep:
import java.util.Scanner;

import static java.lang.Thread.sleep;

public class ThreadDemo2 {
    public static int flag = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 0) {
                try {
                    sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

            System.out.println("循环结束!t1结束 !");
        });


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

        t1.start();
        t2.start();
    }
}

运行结果:

在这里插入图片描述

为什么通过在循环体内添加 sleep() 就能够解决内存可见性问题呢?
         在之前的代码中,where(flag == 0){} 的循环体是空的,此时执行该段代码时,就只有两个操作,一个是从内存读取数据到 cpu 寄存器(load)中,另外一个就是从 cpu 寄存器中读取数据进行 cmp 操作,从 cpu 寄存器中读取数据的速度,要比从内存中读取数据到 cpu 寄存器中的速度要快上几千倍,此时 load 操作相较于 cmp 操作要慢上几千倍,从而影响了代码的执行效率,此时代码的主要矛盾是 load,因为 load 操作占用了线程执行时间的 99.99%,因此编译器对代码进行了优化,使 load 只加载一次,循环一直使用 flag == 0 作为循环的判断条件 ,因此产生了 bug,当通过 t2 线程对 flag 赋值一个非 0 的整数时,无法控制 t1 的线程结束。而当在 where(flag ==0){ } 的循环体中加入 sleep,可以让代码的执行效率大幅度降低,此时 load 的执行效率和 cmp 的执行效率相当,load 的执行效率不再是代码执行效率的主要矛盾,因此当执行该代码时,编译器不会对代码进行优化,循环的次数降低了,load 操作不再是负担,编译器就没必要再进行优化了,此时 每次循环体 flag == 0 的判断都需要从内存中重新读取 flag 的数据,此时就可以通过 t2 线程控制 t1 线程的结束了。

  • 方法2:用 volatile 关键字修饰变量:
import java.util.Scanner;

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

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 0) {

            }

            System.out.println("循环结束!t1结束 !");
        });


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

        t1.start();
        t2.start();
    }
}

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

5. 指令重排序,引起线程不安全

         volatile 还有一个效果,禁止指令重排序。指令重排序,也是编译器优化的一种策略,调整代码执行顺序,让程序更高效。

         编译器对代码的优化机制,在单线程的环境下是很准确的,但是对于多线程的环境下就不一定了。指令重排序的问题涉及到 cpu 指令级别的操作,此处是无法通过代码来进行演示的。只能通过假设来举例讲解:
在这里插入图片描述

         上述的过程,如果在单线程的情况下,不存在线程调度的问题,无论指令怎么排序,都可以正常的执行完 3 个步骤,这个时候代码是正常的;但是如果是在多线程的环境下,就会存在线程调度的问题,如果上述的过程发生指令重排序后,假设执行顺序为 1 -> 3 -> 2,如果刚执行完 步骤 3 之后线程发生调度,不再执行步骤 2,现在的 user 已经存储了 User 对象的引用,user 不为空,但是在堆内存中的 User 对象并没有进行初始化的操作,如果现在执行后续的代码,if ( user != null ) ,此时 user 非空, 对 user 进行解引用的操作 user.learn(),就会 bug。这就是在多线程环境下,指令重排序产生的 bug 。

三、wait() 和 notify()

         假设有一个场景,张三、李四和王五一起去 ATM 办理业务,此时,张三先进去 ATM(拿到了锁),但是当他取钱时发现,ATM 里没钱了,所以他就退了出来(释放锁),但是,因为线程的调度是无序的、随机的,所以并不确定谁会先拿到锁,当出现极端的情况,张三一直能抢到锁,但是 ATM 里又没钱。那么李四和王五的业务就没有办法办理。线程处于线程饿死的状态。导致线程的执行效率大大降低了。

解决办法:

     如果张三拿到锁,先进行业务办理,进去取钱时,发现没钱了,就退出来(释放锁),此时张三就进行等待(wait),那么李四和王五就可以进行锁竞争,而张三不进行锁竞争,不论是李四还是王五拿到锁,都可以去 ATM 办理业务。当另外的线程,把 ATM 充上钱之后,就可以唤醒张三,张三就可以办理取钱的业务了。

1. wait() 的作用

  • 阻塞等待
  • 释放当前锁
  • 当收到通知时,就会被唤醒,同时重新尝试获取这个锁。
        wait() 要搭配 synchronized 来使用。脱离了 synchronized 使用 wait(),会直接抛出异常 illegalMonitorStateException(非法的锁状态异常)。 synchronized 又称为监视器锁。造成这个原因,是因为 wait() 和 notify() 要搭配 synchronized 使用,如果没有搭配 synchronized,相当于还没有把锁获取到,就尝试解锁,就会产生上述的异常。同时 wait() 和 notify() 写在 synchronized 修饰的代码块里。

2. wait() 结束等待的条件

  • 其他线程使用该锁对象的 notify()
  • wait() 等待时间超过最长等待时间 ( wait()方法提供了一个带有 time out 参数的版本,用于指定最长等待时间)
  • 其他线程调用该等待线程的 interrupted(),导致 wait() 抛出 interruptedException 异常。

3. wait() 和 notify() 的使用

public class ThreadDemo3 {
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();

        Thread t1 = new Thread(() -> {
            System.out.println("wait 调用前");
            synchronized (locker) {
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("wait 调用后");

        });
        t1.start();

        //保证 t1 线程先执行
        Thread.sleep(3000);

        Thread t2 = new Thread(() -> {
            synchronized (locker) {
                System.out.println("调用 notify 之前");
                locker.notify();
                System.out.println("调用 notify 之后");
            }
        });
        t2.start();


    }
}

运行结果:

在这里插入图片描述

     上述代码的执行逻辑是,t1 线程先执行,带代码执行到 wait()的时候,t1 线程就阻塞等待,然后释放锁。3s 之后 t2 线程开始执行,执行到 notify() 的时候,就会通知 t1 线程,t1 线程被唤醒,然后 t1 线程开始重新尝试获取锁。需要注意的是, notify() 也是需要在 synchronized 修饰的代码块内部使用的,t1 线程想要获取到锁资源,必须等到 t2 线程执行 notify() 之后,并且 t2 线程执行结束后,释放了锁,t1 线程重新尝试获取锁资源,才能够得到锁。如果 t2 线程执行 notify() 之后,t1 线程已经被唤醒了,此时如果 t2 线程还没有执行结束,那么此时 t1 线程的状态从 阻塞等待 转变为 等待锁的阻塞 状态。这两个状态是不一样的,阻塞等待 是执行 wait() 的效果,是 wait() 的功能,必须收到 notify() 的通知或者 interrupted() 后才能被唤醒;而 等待锁的阻塞状态,是因为 t1 线程还没有获取到锁资源,所以会阻塞等待 t2 线程释放锁,只要 t2 线程释放了锁资源,t1 线程就会立即获取到锁资源。

四、 join() 和 wait() 的区别

        wait() 方法的作用是让当前线程进入等待状态,wait() 会与 notify() 和 notifyAll() 方法一起使用。notify() 和 notifyAll() 方法的作用是唤醒等待中的线程,notify() 方法:唤醒单个线程,notifyAll()方法:唤醒所有线程。

        join()方法是等待这个线程结束,完成其执行。它的主要起同步作用,使线程之间的执行从“并行”变成“串行”。

五、 wait() 和 sleep() 的区别

相同点:
        都可以使进程阻塞一段时间,都能够被提前唤醒。wait() 通过 notify() 提前唤醒,sleep() 通过 interrupted() 修改结束标志位提前唤醒。

不同点:

  • wait():
            1. 解决的是线程之间执行顺序的问题
            2. wait() 需要搭配 synchronized 以及 notify() 使用
            3. wait() 是 Object 类的方法
  • sleep():
            1. 单纯的让当前线程休眠一段时间
            2. 不需要搭配 synchronized 使用
            3. sleep() 是 Thread 类的静态方法
  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值