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

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

1.什么是线程安全问题

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

3.synchronized的使用

4.死锁

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

6.如何打破死锁

7.volatile

8.wait & notify

9.wait 和 sleep区别

什么是线程安全问题:

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

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

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

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

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

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

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++语句加上锁,就不会产生线程安全问题,解决了 “插队” 问题

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

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);
    }
}

给静态方法加锁:

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.)俩个线程俩把锁

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

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

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锁外面获取,这样就可以打破死锁

具体代码:

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线程获取锁的顺序也是从小到大的,这样就可以避免死锁的产生

具体代码:

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任然没退出,一直在死循环

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关键字,让编译器知道我这个变量是敏感的~~随时会被修改,请你不要优化~~

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中使用

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

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唤醒后,就直接把线程

        终止掉了~~~

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐