Java线程安全问题的原因和解决方案

  • 1.什么是线程安全
  • 2.线程不安全的原因 及 解决措施
    • 2.1 多线程同时修改同一个变量
    • 2.2 修改操作不是原子性
      • 加锁操作关键字:`synchronized`
    • 2.3 抢占式执行,随机调度 (根本原因)
    • 2.4内存可见性问题
      • volatile 关键字
    • 2.5指令重排序

1.什么是线程安全

线程安全的确切定义是比较复杂的,不过我们可以这样认为:当多线程环境下的代码运行的结果是符合我们预期的,即在单线程环境下应该得到的结果,则说这个程序是线程安全的,反之,则是线程不安全.

注意:判定一个代码是否线程安全,要具体问题具体分析,不是加了锁就一定安全~

2.线程不安全的原因 及 解决措施

2.1 多线程同时修改同一个变量

如果一个线程修改一个变量;多线程读取同一个变量,是安全的,修改不同变量,都是安全的。因此可以通过调整代码结构来避免这种问题。

  • 来看一段带有线程安全问题的代码

    class Counter{
        public  int count;
        public void add(){
            count++;
        }
    }
    public class ThreadDemo10 {
        public static void main(String[] args) {
            //1.定义Counter实例
            Counter counter = new Counter();
            //2.定义两个线程,分别对counter 调用5w次的add方法 预期结果count = 10 0000
            Thread t1 = new Thread(()->{
                for (int i = 0; i < 50000; i++) {
                    counter.add();
                }
            });
            t1.start();
            Thread t2 = new Thread(()->{
                for (int i = 0; i < 50000; i++) {
                    counter.add();
                }
            });
            t2.start();
            //3.等待两个线程结束
            try {
                t1.join();
                t2.join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            //打印最终的count值
            //输出的结果是无法确定的
            System.out.println("count = "+counter.count);
        }
    }
    

Java线程安全问题的原因和解决方案_第1张图片

  • 此时输出的结果是和预期结果不同,这个现象称为bug。产生这个结果最根本的原因就是抢占式执行,随机调度。同时这也是个典型的线程安全问题。

  • 为什么出现这种情况?

    count++ 的++操作本质是要分成 三步走:

    ​ 1.把内存中的值,读取到cpu的寄存器中。(load)

    ​ 2.把寄存器中的值进行+1操作。(add)

    ​ 3.把得到的结果写回内存中。(save)

    (注意:这里的load add save 是cpu指令)

    此处这段代码 是两个线程针对同一个count++,也就是有两组load add save。如果线程调度顺序不同,执行的顺序也就不同,导致最终结果就不同。

    此处画的调度顺序只有6种,实际有无数种情况:

Java线程安全问题的原因和解决方案_第2张图片

比如我们拿最后一组执行顺序来说明,t2先执行了load 把count加载到cpu寄存器中,执行add 把count进行+1操作,此时t1执行load 把count加载到cpu寄存器中(此时count依旧为0,t1读取到的count值为0;因为t2还没有进行save把count=1写回内存中),t1执行add把count进行+1操作,t1执行save把count写回内存,此时内存中count值为1;最后t2执行save(此时count在寄存器中的值是1)把count=1写回内存中,最终两个线程对count进行两次自增操作,实际上count经过两次++操作 只增加了一次。

2.2 修改操作不是原子性

  • 概念:

修改操作不是原子性也就意味着在执行任务时,没有做完,执行中途 被调度走了(原子:即不可拆分的基本单位,对于上面的列子,对于count++就不是原子的,可以拆分成load、add、save,像单独一个load或者add和save这就是不可再拆分的,是原子的)。如果操作是原子性的,就不会有线程不安全的问题了。

  • 解决措施:通过加锁,把不是原子的变成原子(线程不安全的最根本解决措施)

加锁操作关键字:synchronized

把count的add方法加锁:意味着进入add方法就被自动加锁,出了方法就自动解锁。那么如果两个线程同时尝试加锁,A线程成功获取到锁,B线程就会一直阻塞等待(BLOCKED状态),一直等到A线程解锁,B才会成功获取到锁(才能执行代码)。

class Counter{
    public  int count;
    //add方法进行加锁
   synchronized public void add(){
        count++;
    }
}

Java线程安全问题的原因和解决方案_第3张图片

  • 锁对象规则

1): 如果两个线程A,B线程针对同一个对象进行加锁操作,就会出现锁冲突(锁竞争),A线程获取到锁(讲究先到先得),B线程阻塞等待,等待A线程解锁,B才能获取成功.

2): 如果此时针对不同的对象加锁,就不会出现锁冲突,因为这两个线程获取的是不同的锁,就不会有阻塞等待了.

3): 如果两个线程一个加锁一个不加锁,也是没有锁冲突的.

  • synchronized使用方法:
  1. 修饰方法:进入方法就加锁,离开方法就解锁。

​ a)修饰普通方法 加锁对象是this

​ b)修饰静态方法 加锁对象是类对象

  1. 修饰代码块:手动指定锁对象

进入代码块就加锁,出了代码块就解锁。括号里的对象可以任意指定其他对象。

    public void add(){
        synchronized(this){
            count++;
        }
    }
  • 可重入锁synchronized

如果一个线程针对同一个对象,连续加锁两次,是否会有问题?没有问题则叫可重入,有问题则叫不可重入。

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

上述代码,一个线程当进入add方法时,就会加锁(这次能够加锁成功),随后进入代码块,再次尝试加锁,此时站在锁对象(this)来看,它认为自己已经加锁了,这里要不要再进行加锁?还是进行阻塞等待?如果进行加锁,就是可重入锁,如果进行阻塞等待,就是不可重入锁,如果此处进行阻塞等待,那么就成了死锁(没法解锁)。

死锁: 此处A线程第一次加锁成功,如果有其他线程获取锁就会进行阻塞等待,可是A线程在第二个锁处进行阻塞等待了,由于其他线程都阻塞在第一个锁外,A线程又无法把第一把锁进行解锁,其他线程也进不来,就成了死锁。(抽象了 = =简单讲就是A加锁了,又卡在另一把锁,另一把锁也没线程来解锁,就死锁了)

2.3 抢占式执行,随机调度 (根本原因)

​ 万恶之源,罪魁祸首!操作系统调度线程是具有随机性的,多线程环境下会出现抢占式执行,此时就会出现代码执行顺序的可能性从一种情况变成了无数种情况,所有就需要保证在这种随机的线程调度顺序下,保证结果都是正确的预期结果.

  • 解决措施:wait 和 notify

比如t1先执行,t2先wait(阻塞,主动放弃cpu),等t1执行差不多了,再通过notify把t2唤醒,让t2执行。

如果使用join,则必须要t1彻底执行完,t2才能执行,sleep必须指定一个固定休眠时间,但实际执行多久我们并不确定,而wait和notify可以随便指定执行到何等程度时把t2唤醒或者让t2阻塞,控制多线程的执行顺序。

方法 说明
wait() / wait(long timeout) 让当前线程进入等待状态,此时处在WAITING状态。wait()放不加任何参数,就是一直等待。
notify() 唤醒在当前对象上等待的线程
notifyAll() 唤醒所有等待的线程

注意: wait, notify, notifyAll 都是 Object 类的方法 。

public class ThreadDemo12 {
    public static void main(String[] args) throws InterruptedException {
        Object ob = new Object();
        ob.wait();
    }
}

Java线程安全问题的原因和解决方案_第4张图片

wait会做三件事:

  1. 先释放锁。(因此线程在没有加锁情况下进行wait会抛异常,所以要配合synchronized使用)
        synchronized (ob) {
            //此时这个线程是阻塞在wait这一行代码处,等待notify唤醒
            //虽然是阻塞,带实际上是释放了锁,其他线程可以获取到ob对象这个锁
            ob.wait();
        }
  1. 进行阻塞等待。

  2. 收到通知后,重新尝试加锁,获取到锁后,继续往下执行。

  • 代码实例:解决抢占式执行,按照我们的意愿有顺序的执行
public class ThreadDemo13 {
    public static void main(String[] args) {
        Object object = new Object();
        Thread t1 = new Thread(()->{
           //这个线程负责等待
            System.out.println("t1:wait 之前");
            synchronized (object){
                try {
                    object.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t1:wait 之后");
            }
        });

        Thread t2 = new Thread(()->{
            //这个线程负责通知唤醒
            System.out.println("t2: 通知之前");
            synchronized (object){
                object.notify();
            }
            System.out.println("t2: 通知之后");
        });
        t1.start();
        //为了保证t1先执行
         try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        t2.start();
    }
}

Java线程安全问题的原因和解决方案_第5张图片

  1. 注意此处wait和notify使用的对象必须是同一个对象,如果是不同的对象,则此时notify不会有任何效果;notify只能唤醒在同一个对象上的等待的线程。
  2. 此处t1和t2的start,由于线程调度的随机性,不能保证是先wait后执行notify,如果先执行notify后执行wait,则此处wait就无法被唤醒,所以在t2.start()之前先sleep一段时间,保证先后顺序。

2.4内存可见性问题

比如一个线程读操作,一个线程改,就可能会出现读操作的结果不符合预期,也就是读到的值可能不是修改后的值。

解决方案:保证内存可见性

volatile 关键字

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

class Number{
    int flag = 0;

}
public class ThreadDemo11 {
    public static void main(String[] args) {
        //创建number
        Number number = new Number();

        Thread t1 = new Thread(()->{
            //循环读取flag值
            while(number.flag == 0){
                ;
            }
         System.out.println("t1线程结束");

        });
        t1.start();

        Thread t2 = new Thread(()->{
            //写操作 修改flag值
            Scanner scanner = new Scanner(System.in);
            number.flag = scanner.nextInt();
        });
        t2.start();
    }
}

Java线程安全问题的原因和解决方案_第6张图片

上述代码读写同一个变量,当t2进行写操作,t1循环获取flag值。预期结果是:t2输入的是非0,则t1的循环结束。实际情况是:当输入1的时候,t1线程一直未结束。这种情况就属于内存可见性问题。

t1里的while循环的条件判断语句 用汇编层次来看,有两步操作1、load 把内存中的flag值读到寄存器中,2、cmp 把寄存器的值和0比较,决定下一步怎么走(条件跳转指令)。注意此处while循环体没有语句,也就意味着while循环执行速度极快,可能1s上万次,而执行load执行效率速度太慢,所以当执行这么多次每次load的值都是相同的,于是JVM就进行自动优化,导致只读取一次flag值,每次比较就不再读取flag值,直接用寄存器之前存储的值来和0比较。但实际上我们有其他线程随时对flag进行修改,这就导致JVM自动优化导致了错误。也就是前面说的一个线程读,一个线程改,当修改后,读的可能就不是修改后的值。要想解决这个问题,就需要我们手动对flag这个变量加上volatile关键字,加上之后编译器就不会进行优化,一定是每次都重新读取flag值。

class Number{
    //加上volatile关键字
   volatile int flag = 0;

}

Java线程安全问题的原因和解决方案_第7张图片

此时给flag加上volatile关键字后,当t2输入非0时,t1的while循环就成功结束了。注意:编译器优化是由于t1循环里没有语句,循环速度极快才导致了优化,如果在循环里加个sleep控制循环速度,即使不加volatile,该代码执行结果也会正确(t1循环结束)。

2.5指令重排序

编译器觉得你的代码不够效率,于是把你的代码在保证逻辑的不变的情况下,进行调整(调整代码执行顺序),以加快程序的执行效率。
比如你写的代码执行顺序是1、2、3,编译器给你优化成1、3、2。

  • 解决措施:

由于编译器认为我们的代码不够效率,进行了指令重排序(优化),那么我们可以告诉编译不要优化,比如上面的volatile关键字。

你可能感兴趣的:(JavaEE,java,开发语言,多线程安全)