线程安全问题之原因及解决方案

根本原因

根本原因:多线程抢占式执行,随机调度。

代码结构

代码结构:多个线程同时修改同一个变量。

原子性

原子:不可拆分的基本单位

如果修改操作是非原子性的,出现线程安全问题的概率就比较高。

举个例子:

public class Thread_demo {
    
    
    static class Counter{
    
    
        int count=0;
        public void increase(){
    
    
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
    
    
        final Counter counter=new Counter();
        Thread t1=new Thread(()->{
    
    
            //count自增1000次
            for (int i = 0; i <1000 ; i++) {
    
    
                counter.increase();
            }
        });
        Thread t2=new Thread(()->{
    
    
            //count自增1000次
            for (int i = 0; i <1000 ; i++) {
    
    
                counter.increase();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

运行结果:

"C:\Program Files\Java\jdk1.8.0_192\bin\java.exe" "-javaagent:D:\Program Files\IDEA\IntelliJ IDEA Community Edition 2021.3.2\lib\idea_rt.jar=56803:D:\Program Files\IDEA\IntelliJ IDEA Community Edition 2021.3.2\bin" -Dfile.encoding=UTF-8 -classpath "C:\Program Files\Java\jdk1.8.0_192\jre\lib\charsets.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\deploy.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\access-bridge-64.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\cldrdata.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\dnsns.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\jaccess.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\jfxrt.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\localedata.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\nashorn.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunec.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunjce_provider.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunmscapi.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunpkcs11.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\zipfs.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\javaws.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jce.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jfr.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jfxswt.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jsse.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\management-agent.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\plugin.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\resources.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\rt.jar;D:\Java\java\untitled\out\production\untitled" Thread_demo
1673

Process finished with exit code 0

需求是两个线程各自自增1000次,预期结果是输出2000,
而实际运行结果是1673,与预期不符。这是典型的线程安全问题。

下面针对 count++ 操作进行分析

++操作本质上分为三步:

  • 先把内存中的值,读取到CPU的寄存器中。(load)
  • 把CPU寄存器的数值进行+1运算。(add)
  • 将得到的结果写回到内存中。(save)
    这三个操作就是CPU上执行的三个指令

如果是两个线程并发的执行count++,此时就相当于两组 load add save进行执行,由于线程的抢占式执行,导致当前执行到任意一个指令时侯,线程都可能被调度走,cpu让别的线程执行,从而导致结果有差异。

解决方案:synchronized

通过使用 synchronized 关键字来对线程进行加锁操作。

如果两个线程同时尝试对同一个对象加锁,此时一个能获取锁成功,另一个只能阻塞等待(BLOCKED),一直阻塞到刚才的线程释放锁(解锁),当前线程才能加锁成功!!

1.修饰方法

  • 修饰普通方法,相当于针对this加锁
    在这里插入图片描述
 Thread t1=new Thread(()->{
    
    
     //count自增1000次
     for (int i = 0; i <1000 ; i++) {
    
    
         counter.increase();
     }
 });
 Thread t2=new Thread(()->{
    
    
     //count自增1000次
     for (int i = 0; i <1000 ; i++) {
    
    
         counter.increase();
     }
 });
        

加上关键字后的运行结果:

"C:\Program Files\Java\jdk1.8.0_192\bin\java.exe" "-javaagent:D:\Program Files\IDEA\IntelliJ IDEA Community Edition 2021.3.2\lib\idea_rt.jar=55120:D:\Program Files\IDEA\IntelliJ IDEA Community Edition 2021.3.2\bin" -Dfile.encoding=UTF-8 -classpath "C:\Program Files\Java\jdk1.8.0_192\jre\lib\charsets.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\deploy.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\access-bridge-64.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\cldrdata.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\dnsns.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\jaccess.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\jfxrt.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\localedata.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\nashorn.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunec.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunjce_provider.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunmscapi.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunpkcs11.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\zipfs.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\javaws.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jce.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jfr.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jfxswt.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jsse.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\management-agent.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\plugin.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\resources.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\rt.jar;D:\Java\java\untitled\out\production\untitled" Thread_demo
2000

Process finished with exit code 0

t1执行increase操作时,就针对counter这个对象加上锁了
t2执行increase操作时,也尝试对counter加锁,但是由于counter已经被t1占用了,这里的加锁操作就会阻塞

2.修饰静态代码块
修饰静态方法,和修饰一般方法,同理

3. 修饰代码块

注意
修饰普通方法时,锁对象是this
修饰静态代码块时,锁对象是类对象
修饰代码块时,显示/手动指定锁对象

3.可重入
synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题

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

内存可见性问题

一个线程针对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,此时读到的值,不一定是修改之后的值,这个读线程没有感知到变量的变化,归根结底是编译器/jvm在多线程环境下优化时产生了误判。

举个例子:

public class Thread_demo2 {
    
    
    static class MyCounter{
    
    
        int flag=0;

    }

    public static void main(String[] args) throws InterruptedException {
    
    
        MyCounter myCounter=new MyCounter();

        Thread t1= new Thread(()->{
    
    
            while (myCounter.flag==0){
    
    
                //
            }
            System.out.println("t1循环结束");
        });
        Thread t2=new Thread(()->{
    
    
            Scanner scanner=new Scanner(System.in);
            System.out.println("请输入一个整数");
            myCounter.flag=scanner.nextInt();

        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

    }
}

运行结果

"C:\Program Files\Java\jdk1.8.0_192\bin\java.exe" "-javaagent:D:\Program Files\IDEA\IntelliJ IDEA Community Edition 2021.3.2\lib\idea_rt.jar=59250:D:\Program Files\IDEA\IntelliJ IDEA Community Edition 2021.3.2\bin" -Dfile.encoding=UTF-8 -classpath "C:\Program Files\Java\jdk1.8.0_192\jre\lib\charsets.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\deploy.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\access-bridge-64.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\cldrdata.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\dnsns.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\jaccess.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\jfxrt.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\localedata.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\nashorn.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunec.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunjce_provider.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunmscapi.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunpkcs11.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\zipfs.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\javaws.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jce.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jfr.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jfxswt.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jsse.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\management-agent.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\plugin.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\resources.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\rt.jar;D:\Java\java\untitled\out\production\untitled" Thread_demo2
请输入一个整数
1

预期结果是 t2 把 flag 改成非0的值之后,t1 随之结束循环,但实际运行结果与预期不符。

下面针对 **while (myCounter.flag==0)**操作进行分析,大概分为两步操作:
1.load,把内存中flag的值,读取到寄存器里
2.cmp,把寄存器的值,和0进行比较,根据比较结果,决定下一步往哪执行

上述循环执行速度极快,在线程 t2 真正修改 flag 值之前,load得到的结果都相同,又加上 load 操作相比 cmp 操作执行速度慢很多,JVM就判定flag值不会被修改,从而不在真正重复load操作。

解决方案 volatile

用关键字volatile修饰变量:
当一个变量被声明为 “volatile” 时,编译器会确保对该变量的读取和写入操作按照严格的顺序进行,以避免出现内存可见性问题。
如图:

在这里插入图片描述
加上关键字后的运行结果:

"C:\Program Files\Java\jdk1.8.0_192\bin\java.exe" "-javaagent:D:\Program Files\IDEA\IntelliJ IDEA Community Edition 2021.3.2\lib\idea_rt.jar=54891:D:\Program Files\IDEA\IntelliJ IDEA Community Edition 2021.3.2\bin" -Dfile.encoding=UTF-8 -classpath "C:\Program Files\Java\jdk1.8.0_192\jre\lib\charsets.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\deploy.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\access-bridge-64.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\cldrdata.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\dnsns.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\jaccess.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\jfxrt.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\localedata.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\nashorn.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunec.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunjce_provider.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunmscapi.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunpkcs11.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\zipfs.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\javaws.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jce.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jfr.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jfxswt.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jsse.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\management-agent.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\plugin.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\resources.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\rt.jar;D:\Java\java\untitled\out\production\untitled" Thread_demo2
请输入一个整数
1
t1循环结束

Process finished with exit code 0

指令重排序问题

本质上是编译器优化出现bug.

在执行程序时,编译器器可能会对指令的执行顺序进行重新排列,以提高指令级并行性和系统性能。
由于重排序的存在,程序的执行结果可能与源代码的预期结果不一致。

wait和notify

wait和notify作用针对多个线程执行顺序进行控制

wait会让调用线程进行阻塞
通过其他线程的notify进行通知

wait操作

  • 先释放锁(wait操作需要搭配synchronized来使用
  • 进行阻塞等待(WAITING状态)
  • 收到通知之后,重新尝试获取锁,并且在获取锁后,继续往下执行

notify操作:

  • 和wait配对
  • wait的锁对象和notify的锁对象要一致,否则notify不会有任何效果

举个例子:

public class Thread1 {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        Object object=new Object();
        Thread t1=new Thread(()->{
    
    
            //这个线程负责进行等待
            System.out.println("t1:wait之前");

            try {
    
    
                synchronized (object){
    
    
                    object.wait();
                }
            } catch (InterruptedException e) {
    
    
                e.printStackTrace();
            }

            System.out.println("t2:wait之后");
        });

        Thread t2=new Thread(()->{
    
    

            System.out.println("t2:notify之前");
            synchronized (object){
    
    
                //notify务必要获取到锁,才能进行通知
                object.notify();
            }

            System.out.println("t2:notify之后");
        });
        t1.start();
        Thread.sleep(500);
        t2.start();
    }
}

运行结果:

"C:\Program Files\Java\jdk1.8.0_192\bin\java.exe" "-javaagent:D:\Program Files\IDEA\IntelliJ IDEA Community Edition 2021.3.2\lib\idea_rt.jar=55923:D:\Program Files\IDEA\IntelliJ IDEA Community Edition 2021.3.2\bin" -Dfile.encoding=UTF-8 -classpath "C:\Program Files\Java\jdk1.8.0_192\jre\lib\charsets.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\deploy.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\access-bridge-64.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\cldrdata.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\dnsns.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\jaccess.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\jfxrt.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\localedata.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\nashorn.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunec.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunjce_provider.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunmscapi.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\sunpkcs11.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext\zipfs.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\javaws.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jce.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jfr.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jfxswt.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\jsse.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\management-agent.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\plugin.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\resources.jar;C:\Program Files\Java\jdk1.8.0_192\jre\lib\rt.jar;D:\Java\java\untitled\out\production\untitled" Thread1
t1:wait之前
t2:notify之前
t2:notify之后
t2:wait之后

Process finished with exit code 0


wait的锁对象和notify的锁对象要一致,

如下图:
在这里插入图片描述

notify 方法用于唤醒正在等待同一个对象锁的一个线程。
如果有多个线程在等待,那么只有其中一个线程会被唤醒,具体唤醒哪个线程是不确定的。而 notifyAll 方法则会唤醒所有等待的线程。

判定一个代码是否线程安全,一定要具体问题具体分析!!!

猜你喜欢

转载自blog.csdn.net/m0_63904107/article/details/131426383