多线程(四)【线程安全问题】

Hello,各位小伙伴们,这篇文章我将总结和归纳在多线程中最最最重要的内容 --- 线程安全问题,在实际开发中,线程安全问题是程序员最关注,最关心的话题,如果处理的不恰当,那么就会引发程序出现bug,更严重会造成程序的崩溃。

我将把自己所学的关于线程安全问题,即什么是线程安全问题,线程安全问题如何产生,如何解决线程安全问题这几个话题,如果有哪里总结归纳的地方不好,也请大家指出,我们一起学习进步~

1.什么是线程安全问题

2.线程安全问题产生的原因

3.synchronized的使用

4.死锁

5.死锁产生的四个必要条件

6.如何打破死锁

7.volatile

8.wait & notify

9.wait 和 sleep区别

什么是线程安全问题:

一个进程的多个线程,是共享同一份资源的,如果俩个或多个线程,对同一个成员变量进行修改,就可能会产生冲突 ---- bug

如果是单线程执行,对同一个变量修改,是可以的,但是多线程情况下就会出现问题。

我写一个多线程不安全的代码:

定义一个变量count,然后创建俩个线程,在俩个线程中,分别各循环50000次,对count自增++操作,最后打印count变量的值

java 复制代码
public class Demo{
private static int count = 0;
public static void main(String[] args) throw InterruptedException{

    Thread t1 = new Thread(() -> {
        for(int i = 1; i <= 50000; i++) {
            count++;
        }
        System.out.println("t1 线程退出");
    });

    Thread t2 = new Thread(() -> {
        for(int i = 1; i <= 50000; i++) {
            count++;
        }
        System.out.println("t2 线程退出");
    });
    
    t1.start();
    t2.start();

//让main线程等待t1和t2线程执行完毕,main线程才退出,否则count还是0
    t1.join();
    t2.join();    
    
    System.out.println("count = " + count);

 }
}

当我们俩次运行上面的代码,count最后的值都不同,说明上面代码就存在线程安全问题,俩个线程t1和t2分别对同一个变量count进行修改操作,引发了冲突

其实在for循环里,只有count++这一条语句,其实在操作系统中,count++其实是由三条指令,分别是:load (从内存中读取数据,加载到寄存器中) add(在寄存器中对count进行自增) save(将寄存器count自增后的值重新加载回内存中)

所以当t1线程在执行count++这三条指令的时候,就很可能被t2线程插队,从而产生bug,我们可以简易画一下t1线程和t2线程在执行count++指令时候的图片,注意,情况是无数种的,我们只是画其中几种:

如果t1和t2线程执行第一张和第一张图片的时候,就不会产生冲突,也就不会产生bug,count++最后的结果也就是正确的,但是线程的调度是随机的,无法保证其他情况不会出现!!!

线程安全问题产生的原因:

1.)操作系统对线程调度是随机的(根本原因)

2.)俩个线程对同一个变量进行修改操作(t1线程和t2线程都对count进行++操作)

3.)修改操作不是原子的(count++这一条操作,其实背后是三条指令,而这三条指

令并不是一口气执行完的,很可能当执行到load或者add的时候,就被其他线程

插队,导致线程安全问题)

4.)内存可见性

5.)指令重排序(后续介绍)

使用synchronized

synchronized可以给指令加锁

注意!此处的加锁,并不是禁止线程的调度,而是为了防止其他线程 "插队" 。

synchronized加锁实现的效果:

count++在cup上会有三条指令,分别是load add save,当我们给count++加上synchronized时,就会变成:lock load add save unlock,假设我们给t1线程和t2线程的count++指令加锁,这时,当t1线程执行到add操作时,操作系统的调度器调度给了t2线程,这时,t2线程无法完成加锁操作,因为t1线程并没有释放锁,所以t2 线程就会一直阻塞在lock这里,不会继续往下执行,当调度器重新调度回t1线程的时候,当t1 线程执行完指令并且释放锁unlock时候,t2线程locke阻塞才是结束,才能加锁成功,并且往下执行load add save

我们对上面存在的线程安全问题的代码进行修改,让他不存在线程安全问题:

java 复制代码
public class Demo {
private static int count = 0;
public static void main(String[] args) {
    
    //实例化Object类
    Object locker = new Object();

    Thread t1 = new Thread(() -> {
//如果存在俩个线程对同一个变量进行修改操作,就需要给这俩个线程加的锁传同一个对象
//这样当调度到t2线程,t1线程还未释放锁,t2线程才会产生阻塞等待
        synchronized(locker) {
            for(int i = 1; i <= 50000; i++) {
                count++;
            }
            System.out.println("t1 线程退出");
        }
    });

    Thread t2 = new Thread(() -> {
//因为t2线程和t1线程都是修改同一个count,所以给synchronized传的对象也是locker,一定要相同
        synchronized(locker) {
            for(int i = 1; i <= 50000; i++) {
                count++;
            }
        }
        System.out.println("t2 线程退出");
    });
    
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println("count = " + count);
 }
}

当我们给俩个线程的count++语句加上锁,就不会产生线程安全问题,解决了 "插队" 问题

给实例类的静态方法加锁:

java 复制代码
class Count{
    public static int count = 0;

//给Count类的静态方法加锁---当实例化Count对象,调用add方法的时候,就会给count++语句加锁
    synchronized private static void add() {
        count++;
    }
}


public class Demo{
    public static void main(String[] args) {
        Count c = new Count();

        Thread t1 = new Thread(() -> {
            for(int i = 1; i <= 50000; i++) {
                c.add();
            }
        });

        Thread t2 = new Thread(() -> {
            for(int i = 1; i <= 50000; i++) {
                c.add();
            }
        });
        t1.start();
        t2.start();

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

        System.out.println("count = " + count);
    }
}

给静态方法加锁:

java 复制代码
public class Demo {
public static int count = 0;

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

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

    Thread t1 = new Thread(() -> {
        for(int i = 1; i <= 50000; i++) {
            add();//调用add方法的时候,就会自动给count的指令加锁
        }
    });

    Thread t2 = new Thread(() -> {
        for(int i = 1; i <= 50000; i++) {
            add();//调用add方法的时候,就会自动给count指令加锁
        }
    });

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

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

    System.out.println("count = " + count);
 }
}

我们之前学习的数据结构中,其实很多标准库中的类集合,大部分都是线程不安全的

线程不安全:ArrayList LinkedList HashMap Queue .......

线程安全:Vector Hashtable. ........

但是可能在以后,vector等这些线程安全的集合类,也可能变成线程不安全的集合类

可能这里大家就会有疑惑,不是说线程不安全问题是需要我们注意和避免和解决的吗?为什么还要将本线程安全的集合类设计为线程不安全呢?

鱼和熊掌不可兼得,当我们将集合类设计为线程安全问题,就会多消耗资源和时间,加锁就会使程序的效率更低,就会产生锁竞争,产生阻塞,可能我们大部分使用到的集合类是安全的,我们程序员也注意到多线程安全问题,就会刻意去避免在使用集合类的时候出现线程安全问题,但是即使没有产生阻塞,加锁本身也可能往往会调用到操作系统内核的逻辑,导致我们程序效率变慢,所以把集合类设置为线程不安全的集合类,如果真的需要加锁,那就让程序员自己手动加锁。

死锁:

在实际开发中,使用锁进行多线程编程的时候,死锁也是非常经典的问题,让我们一一来讨论

1.)一个线程一把锁,连续加锁多次(可重入锁,Java直接帮我们解决)

当我们对某一个语句进行加锁,结果我们可能疏忽忘记,又对这个语句的外面再次进行加锁,我们可以写一个样例:

我们可以看到,调用add方法前,我们对add方法进行加锁,在调用add方法时候,add方法也加锁,当我们进入for循环后加锁,然后继续调用add方法是,add方法想要加锁,就会产生锁竞争,产生阻塞,他必须等待外侧的锁释放了,add方法才能够加锁成功,否则就一直阻塞,但是当我们编译运行的时候会发现,程序正常跑起来,结果也是正确的,这是因为在synchronized中,他会检测这俩把锁是否在同一个线程中,如果是,那么他就忽略了add方法内部的锁,直接跳过,如果是俩个线程,那么就会产生锁竞争,产生阻塞,但是我们要注意,这里死锁的逻辑是存在的,只是synchronized针对这种特殊情况进行特殊处理,在C++,python中,这种情况就会产生死锁~~~

2.)俩个线程俩把锁

俩个线程俩个锁是什么意思呢?我们可以举一个例子:就比如我们的车钥匙和家钥匙,我们的车钥匙在家里面,我们的家钥匙在车里,我们想要进家门,就必须打开车,但是想打开车,就比如打开家,想打开家,就比如打开车,这样就矛盾了,就会产生死锁!!!

下面是俩个线程俩把锁的代码:

java 复制代码
class Demo{
public static void sleep(long time) {
    try{
        Thread.sleep(time);
    }catch(InterruptedException e) {
        throw new RuntimeException(e);
    }
    
}

public static void main(String[] args) {
    Objcet locker1 = new Object();
    Object locker2 = new Object();

    //创建俩个线程
    Thread t1 = new Thread(() -> {
        synchronized(locker1) {
            System.out.println("t1 线程拿到locker1锁");
            sleep(1000);
            synchronized(locker2) {
                System.out.println("t1 线程拿到locker2锁");
                sleep(1000);
            }
        }
    });

    Thread t2 = new Thread(() -> {
        synchronized(locker2) {
            System.out.println("t2 线程拿到locker2锁");
            sleep(1000);
            synchronized(locker1) {
                System.out.println("t2 线程拿到locker1锁");
                sleep(1000);
            }
        }
    });

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

 }
}

当我们运行程序就会发现,程序好像一直产生阻塞,也无法结束,这就是死锁

因为t1线程拿到了locker1,然后t1线程想继续拿到locker2锁,但是t2线程拿到了locker2锁,所以t1线程就要等到t2线程对locker2锁进行释放,但是t2线程想拿到locker1锁,所以他要等待t1线程对locker1锁进行释放,这就会产生锁冲突,俩个线程各个互相等待对方,就一直阻塞住了~~~

3.)n个线程m把锁

这也是哲学家就餐问题~

五名哲学家围着一张桌子吃面条,但是俩俩之间只有一根筷子,如果某一个哲学家拿起左右俩边筷子吃面条,吃完就把筷子放下,下一位哲学家接着吃,就不会产生线程安全问题,但是当每位哲学家同时拿起左手边的筷子时,每位哲学家右手就没有筷子了,他们也不会善罢甘休,没有一个哲学家愿意退一步放下左手的筷子,这时每位哲学家宁愿等,也不愿意放~~所以就会一直等,一直阻塞住,这也就是死锁~~~

死锁的四个必要条件(任意打破一个就可以避免死锁的产生)

1.)锁是互斥的(对于synchronized来说是改变不了的)

2.)锁不可被抢占(对于synchronized来说也是改变不了的)

就别如t1线程已经获取到了locker1锁,t2线程也想获取locker1的锁,那么只能等待t1线程释

放,t2线程才能获取到locker1锁,而t2线程是不能直接抢占t1线程未释放的锁的~~~

3.)保持和请求

t1线程已经获取到了locker1锁,t2线程获取到了locker2锁,但是t1线程还是想获取locker2锁

那么t1线程就保持持有locker1锁(不释放),然后尝试请求获取locker2锁,如果获取失败

就一直阻塞等待

4.)循环等待

就是刚刚我们举的例子,车钥匙在家里,家要是在车里,想打开家,就必须拿到家钥匙,想

拿到家钥匙,就必须打开车~~~~

如何打破死锁

1.)打破保持和请求(避免代码中出现嵌套的场景)

当t1线程获取到locker1锁后,t2线程获取locker2锁,如果t1线程还想继续获取locker2锁,那么就不要在locker1锁里获取,而是在locker1锁释放后获取,t2线程想获取locker1锁,也不要在locker2锁内部获取,而是在locker2锁外面获取,这样就可以打破死锁

具体代码:

java 复制代码
public class Demo{
public static void sleep(long time) {
    try{
        Thread.sleep(time);
    }catch(InterruptedException e) {
        throw new RuntimeException(e);
    }
}

public static void main(String[] args) {
    Object locker1 = new Object();
    Object locker2 = new Object();

    //创建俩个线程
    Thread t1 = new Thread(() -> {
        synchronized(locker1){
            System.out.println("t1线程获取到locker1锁");
            sleep(1000);
        }
//打破保持和请求
        synchronized(locker2) {
            System.out.println("t1线程获取到locker2锁");
            sleep(1000);
        }
    });

    Thread t2 = new Thread(() -> {
        synchronized(locker2) {
            System.out.println("t2线程获取到locker2锁");
            sleep(1000);
        }
        synchronized(locker1) {
            System.out.println("t2线程获取到locker1锁");
            sleep(1000);
        }
    });

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

}

2.)打破循环等待

让t1线程获取锁的顺序是从小到大的,同理,让t2线程获取锁的顺序也是从小到大的,这样就可以避免死锁的产生

具体代码:

java 复制代码
public class Demo{
public static void sleep(long time){
    try{
        Thread.sleep(time);
    }catch(InterruptedException e){
        throw new RuntimeException(e);
    }
}
public static void main(String[] args) {
    Object locker1 = new Object();
    Object locker2 = new Object();

    Thread t1 = new Thread(() -> {
        synchronized(locker1){
            System.out.println("t1 线程获取到locker1锁");
            sleep(1000);
            synchronized(locker2) {
                System.out.println("t1 线程获取到locker2锁");
                sleep(10000);
            }
        }
    });

    Thread t2 = new Thread(() -> {
        synchronized(locker1) {
            System.out.println("t2 线程获取到locker1锁");
            sleep(1000);
            synchronized(locker2) {
                System.out.println("t2 线程获取到locker2锁");
                sleep(1000);
            }
        }
    });
    
    t1.start();
    t2.start();
 }
}

volatile ---- 解决内存可见性问题

通过下面代码,我们可以观察看我输入了flag的值,但是线程1任然没退出,一直在死循环

java 复制代码
public class Demo{
private 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(() -> {
        System.out.println("请任意输入flag的值:");
        Scanner s = new Scanner(System.in);
        flag = s.next();
        System.out.println("t2线程退出");
    });

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

我们可以看到,flag的值确实修改了,t2线程也退出了,但是t1线程迟迟未退出

因为cpu执行的速度非常的快,一秒钟可能达到上万次,当我们输入flag之前,while循环可能已经不断从内存读取flag的值到寄存器,如何比较flag是否为0,一直反复操作这个动作可能已经上万次了,此处编译器就做了大胆的优化,直接把load指令优化了,就不再从内存读取flag的值了,后续循环过程中直接读取寄存器上的flag值,所以当我们再t2线程修改flag的值,t1线程也就无法感知到了,因为t1线程并没有从内存中读取flag的值。

这一系列的原因都是因为编译器优化导致的,因为编译器也想保持代码逻辑不变的情况下,自动修改代码内容,让程序运行效率更快!!!~~~~

所以我们可以使用volatile关键字,让编译器知道我这个变量是敏感的~~随时会被修改,请你不要优化~~

java 复制代码
public class Demo{
private static volatile int flag = 0;

public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        while(flag == 0) {
            //啥也不干
        }
        System.out.println("t1线程退出");
    });   
    
    Thread t2 = new Thread(() -> {
        System.out.println("请任意输入flag的值:");
        Scanner s = new Scanner(System.in);
        flag = s.next();
        System.out.println("t2线程退出");
    });

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

Java官方也给出了解释:

Java进程中,每个线程都有一个工作内存(work memory)​,这些进程会共享同一个主内存(main memory),针对某个数据进行修改读取操作时候:

修改:先把主内存中的数据拷贝到工作内存,再工作内存中修改后,写回主内存

读取:把数据从主内存拷贝到工作内存,从工作内存中读取

所以内存可见性问题:
t1线程while 循环的flag的值是工作内存中flag的值,而t2线程修改的是主内存中flag的值,由于t1工作内存中flag的值是主内存拷贝的副本,所以t2线程修改主内存,不会影响到t1线程中的工作内存

但是上面的代码中,while循环里面是啥也不干的,如果当我们加入sleep,这时我们再次修改flag的值,t1线程也会被终止,难道sleep也有和volatile同样效果吗???

并不是,因为之前的while循环主体啥也不干,所以就会触发编译器的优化,但是加入了sleep,编译器不会优化load这一条指令,因为sleep背后的逻辑更加复杂,指令更多,所以在循环体中做各种复杂的操作时,就可能会引起编译器优化失效~~~·

wait & notify

在编程过程中,因为多线程的调度是随机的,当我们的代码疏忽或者失误,就可能导致某一个线程或者多个线程一直无法执行,导致一个在执行一个线程,这就会导致线程饿死,为了解决线程饿死问题,我们就引入了wait和notify,来避免线程饿死问题。

wait作用:释放锁并且阻塞,同时等待其他线程通知,接收到通知后,如果其他线程解锁,

那么wait就重新加锁,从阻塞状态变回就绪状态。

notify作用:随机唤醒某个线程的wait,当其他线程执行到wait的时候,就会阻塞并且等待

notify的通知,当wait接收到notify的通知就会唤醒阻塞,然后重新获取锁往下执行

注意!!!wait和notify必须在synchronized中使用

java 复制代码
public class Demo{
public static void main(String[] args) {
    Object locker = new Object();
    Thread t1 = new Thread(() -> {
        synchronized(locker){
            System.out.println("t1 线程wait之前");
            try{
                locker.wait();//释放锁给t2线程,同时接收t2线程通知,然后在这里阻塞
            }catch(InterruptedException e){
                throw new RuntimeException(e);
            }
            System.out.println("t1 线程wait之后");
        }
    });

    Thread t2 = new Thread(() -> {
        synchronized(locker) {
            System.out.println("t2 线程notify之前");
            Scanner s = new Scanner(System.in);
            System.out.println("随机输入一个值,启动notify");
            s.next();
            locker.notify();
            System.out.println("t2 线程notify之后");
        }
    });

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

 }
}

当我们随机输入一个值,启动notify后,notify就会唤醒t1线程的wait,但是此时的wait仍然是阻塞,此时wait状态时BLOCKED,因为此时锁还在t2线程手里,需要等到t2线程释放锁,t1线程的wait才会从阻塞状态变回就绪状态,然后重新获取到锁,然后继续往下执行。

当有多个线程有wait的时候,一个notify只能随机唤醒其中一个线程的wait,所以notify还有另外一个版本,notifyAll,它可以唤醒所有的wait

java 复制代码
public class Demo{
public static void main(String[] args) {
    Object locker = new Object();
    Thread t1 = new Thread(() -> {
        synchronized(locker){
            System.out.println("t1 线程wait之前");
            try{
                locker.wait();
            }catch(InterruptedException e){
                throw new RuntimeException(e);
            }
            System.out.println("t1 线程wait之后");
        }        
    });
    Thread t2 = new Thread(() -> {
        synchronized(locker){
            System.out.println("t2 线程wait之前");
            try{
                locker.wait();
            }catch(InterruptedException e){
                throw new RuntimeException(e);
            }
            System.out.println("t2 线程wait之后");
        }        
    });
    Thread t3 = new Thread(() -> {
        synchronized(locker){
            System.out.println("t3 线程wait之前");
            try{
                locker.wait();
            }catch(InterruptedException e){
                throw new RuntimeException(e);
            }
            System.out.println("t3 线程wait之后");
        }        
    });

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

    Thread t4 = new Thread(() -> {
        synchronized(locker){
            System.out.println("t4 线程notify之前");
            System.out.println("请随机输入一个值,启动notify");
            Scanner s = new Scanner(System.in);
            s.next();
            locker.notify();
            System.out.println("t4 线程notify后");
        }
    });
    t4.start();
 }
}

wait 和 sleep 区别:

1.) wait 的设计是为了被notify,sleep的设计是为了按照一定的时间阻塞

2.)wait 必须搭配锁使用,sleep不需要

3.)一执行到wait 就会释放锁,然后重新获取锁,而sleep在锁里面休眠不会释放锁

4.)wait 也可以使用interrupt唤醒,但是更希望是通过notify发送通知唤醒,被notify

唤醒之后,还可以再次wait 和 notify,而sleep被interrupt唤醒后,就直接把线程

终止掉了~~~

相关推荐
猷咪4 分钟前
C++基础
开发语言·c++
IT·小灰灰6 分钟前
30行PHP,利用硅基流动API,网页客服瞬间上线
开发语言·人工智能·aigc·php
快点好好学习吧7 分钟前
phpize 依赖 php-config 获取 PHP 信息的庖丁解牛
android·开发语言·php
秦老师Q8 分钟前
php入门教程(超详细,一篇就够了!!!)
开发语言·mysql·php·db
烟锁池塘柳08 分钟前
解决Google Scholar “We‘re sorry... but your computer or network may be sending automated queries.”的问题
开发语言
是誰萆微了承諾8 分钟前
php 对接deepseek
android·开发语言·php
24zhgjx-lxq10 分钟前
华为ensp:MSTP
网络·安全·华为·hcip·ensp
vx_BS8133012 分钟前
【直接可用源码免费送】计算机毕业设计精选项目03574基于Python的网上商城管理系统设计与实现:Java/PHP/Python/C#小程序、单片机、成品+文档源码支持定制
java·python·课程设计
2601_9498683612 分钟前
Flutter for OpenHarmony 电子合同签署App实战 - 已签合同实现
java·开发语言·flutter
星火开发设计26 分钟前
类型别名 typedef:让复杂类型更简洁
开发语言·c++·学习·算法·函数·知识