• 线程安全问题


    多线程

    Thread类及常见方法

    线程的状态



    线程安全

    什么是线程安全

    多线程编程中,多个线程并发执行,操作系统随机调度,如果这种随机调度使得线程访问的对象行为正确一致,那么就说对这个对象的操作是线程安全的;如果这个对象出现了错误的行为,就表明对这个对象的操作是线程不安全的,即出现了线程安全问题。

    一个线程不安全的例子(自增运算)

    我们使用2个线程对一个变量分别自增50000次,如果依据我们已有的经验,最后变量应该自增了100000次,下面是具体代码及运行结果:

    class Counter{
        //用来计数的变量
        public  int count;
        //自增函数
        public void increase(){
            count++;
        }
    }
    public class Test3 {
        public static void main(String[] args) {
            Counter counter=new Counter();
            //创建第一个线程
            Thread t1=new Thread(()->{
                for (int i = 0; i <50000 ; i++) {
                    counter.increase();
                }
            });
            //创建第2个线程
            Thread t2=new Thread(()->{
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            });
    
            t1.start();
            t2.start();
            //利用join控制线程的结束,减少主线程对新线程执行过程的影响
            try {
                t1.join();
                t2.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
            System.out.println("count = "+counter.count);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    在这里插入图片描述

    然而最终的结果却是出乎我们意料的69114次,显然,这就是一个线程不安全的情况;

    那么,为什么自增运算符不是线程安全的呢?

    自增运算符不是线程安全的

    实际上,自增运算符是一个复合操作,它至少包含了3个JVM指令:内存取值(load)、寄存器增加1(add)、存值到内存(save),这3个指令在JVM中独立进行,不可避免地就会出现多个线程并发执行;

    假设m=0,有2个线程对其做自增运算,在最理想的状况下,首先线程1执行,执行结束后的值存入内存之后,线程2执行:

    在这里插入图片描述

    最后得到结果为2;

    但若是2个线程并发执行,线程1首先进行load,add操作,然后线程2进行load,add操作,然后线程1进行save操作,线程2进行save操作,得到的结果可能就不会是2了:

    在这里插入图片描述

    内存取值(load)、寄存器增加1(add)、存值到内存(save),这3个JVM指令本身是不可再分的,具有原子性,也是线程安全的,叫做原子操作;但是两个或两个以上的原子操作合在一起操作就不再具备原子性了,这也就是为什么自增运算符线程不安全的原因了;

    线程不安全的原因

    • 操作系统的随机调度,抢占式执行;

    这是造成线程不安全最重要的一个原因,但也是我们最无能为力的一个原因;

    • 修改共享资源;

    即多个线程修改同一个变量,类似上面自增运算的例子;

    • 进行非原子操作的修改;

    原子性:不可以再进行拆分的性质;
    具备原子性的操作带来的现象也可以认为是同步互斥的;

    上面自增操作单独的3条指令就是具备了原子性的,但是整体就自增运算而言却不是原子的,从而导致一个线程正在进行操作时被另一个线程打断,致使结果发生错误;

    • 内存不可见

    什么是可见性?可见性就是指, 一个线程对共享变量值的修改,能够及时地被其他线程看到;由于JMM(Java内存模型)的存在,它屏蔽掉了各种硬件和操作系统的内存访问差异,让Java程序在各种平台下都达到一致的并发效果.
    而线程之间由于线程在执行过程中是将临界区的共享资源首先复制到自己的工作内存中,然后对资源进行操作,操作完成以后再将操作结果的副本存到内存中,这样的执行过程,实际是在操作共享资源的副本,因此不可避免的就有可能出现读到的与写入的不一致的问题;

    这是由于系统或者JVM等优化引起的线程安全问题;

    • 指令重排序

    指令重排序就是对代码重新进行排序,这也是由于内部优化引起的一种线程安全问题;

    线程不安全问题的解决办法

    synchronized 关键字

    synchronized是Java中的一个关键字,在线程同步中使用广泛;

    每个java对象都隐含有一把锁,称为java内置锁,当使用关键字synchronized时,就相当于获取了当前对象的内置锁,使用这种方法可以很好地对代码进行保护;

    synchronized的使用
    • synchronized同步方法

    当使用synchronized关键字修饰一个方法的时候,这个方法就被称为同步方法;
    依然是上那个线程不安全的自增运算的例子:

    无需改动其他地方,只需要对其自增函数使用synchronized修饰:

    在这里插入图片描述程序的运行结果就变成了:

    在这里插入图片描述

    用synchronized修饰方法,保证了其方法的代码执行流程是排他性的,即与其他线程使用该方法是互斥的;此时操作系统只允许一个线程进入该方法,若其他线程要执行同样的方法,就需要进行等待;

    使用synchronized修饰方法时,如果方法中的资源变量不止一个,当线程进入时,如果线程在操作变量1而没有操作变量2时,由于其他线程依然无法进入方法,就会造成变量2闲置却不能去执行操作;这难免会造成方法中临界资源的闲置等待,进而可能会影响临界区代码段的吞吐量;

    为了避免这种情况,我们可以使用synchronized来同步代码块;

    • synchronized同步代码块;

    将synchroniz关键字放在函数体内,就是同步代码块:

     public static Object locker1=new Object();
        public static Object locker2=new Object();
        
        public int n1=0;
        public int n2=0;
        
        public void func(int t1,int t2){
            synchronized (locker1){
                n1+=t1;
            }
            
            synchronized (locker2){
                n2+=t2;
            }
            
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    这里没有对线程具体实现,主要是展现了synchronized同步代码块的大概情形;

    相比synchronized同步方法,synchronized同步块是一种更加细粒度的并发控制,可以保证在某一时刻,不同的线程可以对不同的资源进行操作;

    synchronized同步方法和synchronized同步块在某些时候其实是可以达到同等的效果的,因为在java的内部实现上,synchronized方法实际上就等同于用一个synchronized代码块,这个代码块包含了同步方法中的所有语句:

      public void method(){
            synchronized (this){
                
                //对临界区资源的具体操作
                
            }
        }
        public synchronized void method(){
            
            //对临界区资源的具体操作
            
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    java中,this代表了当前对象,因此在这里传入this关键字,就是 synchronized锁了当前方法所属的对象本身;

    • synchronized修饰静态方法
       public static synchronized void fun(){
            
            //临界区代码
            
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    由于静态方法属于class实例而不是单个Object实例,在静态方法内部是不可以访问Object实例的this引用的,因此修饰静态方法的synchronized关键字不能获得Object实例的this对象的监视锁;

    synchronized的特性
    • 互斥;

    如果一个线程先上了锁,其他线程必须等待这个线程释放

    进入 synchronized 修饰的代码块, 相当于加锁,退出 synchronized 修饰的代码块, 相当于解锁;关于解锁,一种是synchronized修饰的方法或者代码块正确执行完毕,监视锁自动释放,一种是程序出现异常,非正常退出synchronized修饰的代码块,监视锁自动释放;

    • 可重入

    synchronized一般不会出现自己锁死自己的情况,因为synchronized对同一个线程是可以进行重入的;

    关于synchronized带来的竞争问题,只有两个线程竞争同一把锁,才会发生阻塞等待;即只有两个线程对同一个对象加锁时,才会产生竞争;

    volatile 关键字

    volatile的作用就是为了使变量在多个线程之间可见;

    首先来看这样一段代码:

    public class thread {
        static class Counter{
            public int flag=0;
        }
    
        public static void main(String[] args) {
            Counter counter=new Counter();
    
            Thread t1=new Thread(()->{
                while(counter.flag==0){
                    //不执行任何操作
                   
                }
                 System.out.println("t1 结束");
            });
            t1.start();
    
    
            Thread t2=new Thread(()->{
                Scanner scanner=new Scanner(System.in);
                System.out.println("请输入一个整数:");
                counter.flag=scanner.nextInt();
            });
            t2.start();
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    如果是根据正常的认知来看这段代码,线程t1在flag=0时循环,线程t2通过用户输入一个非零值来结束t1的循环,实际的运行结果如下:

    在这里插入图片描述

    程序进入了循环;

    为什么会造成循环呢?其实就是因为线程的私有堆栈与公共堆栈中的值不同步而引起的,尽管我们修改了falg的值,但这并没有影响到第一个线程,解决这一问题,就需要用到volatile 关键字;

    上面的程序只需要修改flag用volatile修饰即可:

     static class Counter{
           volatile public   int flag=0;
        }
    
    
    • 1
    • 2
    • 3
    • 4

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

    通过使用volatile关键字,强制的从公共内存中读取变量的值,从而增加了实例变量在多个线程之间的可见性;

    另外关于volatile的一种奇怪现象:

    public class thread {
        static class Counter{
            public   int flag=0;
        }
    
        public static void main(String[] args) {
            Counter counter=new Counter();
    
            Thread t1=new Thread(()->{
                while(counter.flag==0){
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
    
                }
                System.out.println("t1 结束");
            });
            t1.start();
    
    
            Thread t2=new Thread(()->{
                Scanner scanner=new Scanner(System.in);
                System.out.println("请输入一个整数:");
                counter.flag=scanner.nextInt();
            });
            t2.start();
        }
    
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    这段代码与上面类似,在没有加volatile的情况下,应该也是陷入一种循环而无法结束的,下面是运行结果:

    在这里插入图片描述
    又是我们意料之外的结果,为什么呢?

    前面我们说,volatile所解决的问题是内存之间不可见 ,而这种问题所带来的线程安全问题一般是由系统的优化造成的;因此,当使用了sleep,循环的转速变慢,读内存的操作就不会那么频繁了,也许就不会再触发优化,不会带来问题了;

    volatile与synchronized区别

    • volatile只修饰变量,synchronize可以修饰方法和变量;
    • volatile不会引起阻塞等待,synchronize可能引起阻塞等待;
    • volatile保证数据的可见性,不保证原子性;synchronize保证原子性,也可以间接地保证可见性;
    • volatile解决的是变量在多个线程间的可见性,synchronize解决的是多个线程之间访问资源的同步性;

    wait 和 notify

    由于线程之间是抢占性的执行,因此我们一般无法控制多个线程之间的执行顺序,而使用wait 和 notify就可以尝试协调线程的执行顺序,Java中的wait 和 notify两个方法用于等待方和通知方之间的交互;

    对象的wait 方法

    wait()方法的主要作用是让当前线程阻塞并等待被唤醒;
    wait()方法必须与对象监视器即synchronized一起使用,放在同步块中使用,脱离synchronize使用wait方法会抛出异常;

    wait方法的核心原理:

    • 当线程调用了某个锁对象的wait方法后,JVM会将当前线程加入锁对象监视器的等待集(Wait-Set),等待被其他线程唤醒;
    • 当前线程释放锁对象监视器的Owner权利,让其他线程可以抢夺当前这个锁对象的监视器;
    • 当前线程等待,其状态变成WAITING;

    wait方法是Object类中的成员方法,有三种形式:

    • void wait( );
    • void wait( long timeout );类似限时等待,等待时间结束,线程就不再等待;
    • void wait(long timeout ,int nanos );限时等待时间的设置更加精确;
    对象的notify() 方法

    对象的notify() 方法的主要作用是唤醒在等待的线程;
    与wait方法类似,同样需要与synchronize共同使用,放在同步块中;

    notify() 方法的核心原理:

    • 线程调用notify() 方法后,JVM随机唤醒等待集一条线程;
    • 等待线程被唤醒以后,从监视器的Wait-Set移动到EntryList,线程就具备了抢占执行的资格,线程状态变成BLOCKED;

    notify() 方法也是Object类中的成员方法,有两种形式:

    • void notify( ),随机唤醒一个线程;
    • void notifyAll( ),唤醒所有等待的线程;
    等待-通知通信模式

    即一个线程1调用了对象的wait()方法进入了等待状态,另一个线程2调用了同一对象的notify()方法通知等待线程1,线程1接到通知,重新进入就绪状态,准备执行;

    public class test2 {
    
        public static Object lock=new Object();
        public static void main(String[] args) {
    
            Thread t1=new Thread(()->{
    
    
                synchronized (lock){
                    try {
                        System.out.println("wait 开始");
                        lock.wait();
                        System.out.println("wait 结束");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            t1.start();
    
    
            Thread t2=new Thread(()->{
    
                Scanner scanner = new Scanner(System.in);
                System.out.println("输入任意内容, 开始通知:");// next 会阻塞, 直到用户真正输入内容以后
                scanner.next();
    
                synchronized (lock){
                    System.out.println("开始通知");
                    lock.notify();
                    System.out.println("通知结束");
                }
            });
    
            t2.start();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    在这里插入图片描述
    在上面的代码中,t1首先调用lock.wait()进入阻塞状态并且等待通知,释放了当前的锁,然后t2获取到当前锁对象,并且执行相关操作来通知t1,最后唤醒线程;

    wait 和 sleep 的对比(!)
    • wait()用于线程之间的通行,sleep()让线程阻塞一段时间;
    • wait()需要搭配synchronize使用;
    • wait()是Object类的成员方法,sleep()是Thread的静态方法;

    over!

  • 相关阅读:
    【以太网硬件十八】网卡是什么?
    Java并发编程系列33:线程池ThreadPoolExecutor工作流程
    LSTM 词语模型上的动态量化
    Vue 组件之间的通信
    UMLChina为什么叒要翻译《分析模式》?
    中国程序员容易发错音的单词「GitHub 热点速览 v.22.23」
    【C++题解】1043. 行李托运价格
    SwiftUI AI之如何使用 DALL-E API——生成人脸(教程含源码)
    python抓取网页视频
    Springboot整合Elasticsearch
  • 原文地址:https://blog.csdn.net/weixin_54175406/article/details/126198939