给我一首本草纲目的时间,带你了解线程安全和死锁

本节要点

  • 了解线程安全问题的概念
  • 为啥会导致线程安全问题
  • 线程安全问题如何避免

什么是线程安全

线程安全是多线程编程时的计算机程序代码中的一个概念。在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。(百度百科)

我们上节内容已经了解了java中的多线程状态!
而就是因为线程是并发执行的,而每个线程在不同的时刻会有不同都状态!
而线程是抢占式执行,随机的,没有所谓的顺序可言!
而随机就是不受我们程序员控制!进而会导致很多问题,出现一下bug!
而线程安全问题,就是由于线程的抢占式执行和其他线程特性而导致的bug,这就是线程安全!
注意区别,并不是我们大家所熟知的网络安全,因为黑客而导致的安全问题!!!

线程不安全实例

当我们对一个数自加时,如果自加要得到的数较大,那么我们可能会想到使用多线程,毕竟现学现用嘛!

让我们看看效果:

public class Thread_15 {
    
    
    private static int a = 0;//同时对a自加
    private static final int count = 10_0000;//分别自加10万
    public static void increase(){
    
    
        for (int i = 0; i < count; i++) {
    
    
            a++;
        }
    }
    public static void main(String[] args) throws InterruptedException {
    
    
        Thread t1 =new Thread(){
    
    
            @Override
            public void run() {
    
    
                increase(); //线程t1对a进行自加10万次
            }
        };
        Thread t2 =new Thread(){
    
    
            @Override
            public void run() {
    
    
                increase(); //线程t2对a进行自加10万次
            }
        };
        t1.start();
        t2.start();

        t1.join();//等待t1线程结束
        t2.join();//等待t2线程结束
        System.out.println("多线程执行后后a的结果:"+a);
    }
}

在这里插入图片描述
我们可以看到和我们预期的结果并不匹配!
我们预期是两个线程分别自加10万,结果应该是20万!

为啥会出现这样的情况呢?

我们来分析一下a++cpu中到底干了啥!

我们站在cpu的角度:其实a++cpu中需要3条指令才能完成该操作!
分别为:

  1. 在内存中拿到 a值,加载到cpu的寄存器中(load)
  2. a值自增操作(add)
  3. 把寄存器中的值放回到内存(save)

上述3部操作才完成了a++!

扫描二维码关注公众号,回复: 14301353 查看本文章

而我们知道我们的线程t1t2是并发执行的!

而由于线程的抢占性和随机性!我们无法确定上述3条指令,t1t2线程的执行时刻!
那么便会出现很多先后排列的情况!

我们把各种情况用时间轴的方式一一列举出!

  1. 串行执行,在t1线程对a完成一次自加后,t2在执行!显然这里并不会发生线程安全问题!

在这里插入图片描述
2. 并行执行,两个线程都同时在内存中取出a值进行自加!而此时就会出现线程安全问题,两次自加,结果就a就自加了一次!!!
在这里插入图片描述
上述的3种指令顺序都会导致,线程不安全!!!

分析上述案例,我们知道,如果我们多个线程对一个资源进行操作!由于有多条指令,可能会导致线程不安全问题!

我们知道我们两个线程对a自加,结果肯定在10万和20万之间!

两个极端:
10万 说明两个线程彻底在cpu核并行执行了!
20万 说明两个线程在cpu多核并发执行!

显然上述两种结果出现的情况微乎其微!

如何解决上面出现的线程不安全问题呢?

加锁: synchronized

java

就好比我们去上洗手间!
如果我们进去后,将门反锁!那么其他线程就拿不到该资源!
除非你这个线程结束,其他线程才能拿到该资源进行执行线程!
所以这里加锁,可以避免线程安全问题!

我们java提供了一个加锁的关键字synchronized!

我们演示一下加锁后的效果!
在这里插入图片描述

public class Thread_16 {
    
    
    private static int a = 0;//同时对a自加
    private static final int count = 10_0000;//分别自加10万
    public synchronized static void increase(){
    
    //对该方法加锁!!!
        for (int i = 0; i < count; i++) {
    
    
            a++;
        }
    }
    public static void main(String[] args) throws InterruptedException {
    
    
        Thread t1 =new Thread(){
    
    
            @Override
            public void run() {
    
    
                increase(); //线程t1对a进行自加10万次
            }
        };
        Thread t2 =new Thread(){
    
    
            @Override
            public void run() {
    
    
                increase(); //线程t2对a进行自加10万次
            }
        };
        t1.start();
        t2.start();

        t1.join();//等待t1线程结束
        t2.join();//等待t2线程结束
        System.out.println("加锁后a的结果:"+a);
    }
}

在这里插入图片描述

我们又想到了另一个问题,如果这样对线程加锁,那么多线程还有意义嘛!这不是和单线程一样了嘛!!!

显然我们在对一个数进行自加采用多线程,并没有达到多线程的效果!!
但是,如果我们避免多个线程同时对一个资源的更改,那么多线程就不会出现线程不安全问题,也发挥了多线程的优势!

线程不安全原因

  • 线程是抢占性执行的,线程间的调度充满了随机性(线程不安全的根本原因)

  • 多个线程对同一个资源进行了更改操作(如果是不同的资源,或者只是对一个资源进行读操作就不会出现线程不安全问题)
    我们可以更改代码结构,让不同的线程对不同的变量进行更改就不会出现问题

  • 针对变量的操作不是原子的!
    因为对变量的操作的指令并不是一条!而是多条指令!
    我们可以通过加锁操作,使多个指令打包成一个原子,避免线程不安全问题!

  • 内存可见性
    什么是内存可见性呢?
    就是编译器对cpu操作的优化!
    举个简单的例子:
    当一个线程一直循环读一个数据时,我们的cpu就要一直在内存中读数据,而我们知道内存读取的速度想必于cpu中的寄存器慢了好几个数量级!那么这时编译器就进行优化,他懒得去内存中读取数据了,直接将数据保存在寄存器进行读取操作,而如果这时有另外一个线程对该数据进行修改,那么就会产生线程不安全问题!!!
    我们的编译器都是由大佬编写的,所以在不改变逻辑性和结果的情况下,会对代码进行优化!!!
    如何避免该问题呢?
    我们可以采用synchronized关键字对线程加锁或者使用volatile关键字保证内存可见性!

  • 指令重排序导致线程不安全问题,这也是由于编译器的优化操作而导致的线程不安全问题!
    我们的代码先后执行顺序有时候并不会影响我们的结果,那么这时编译器在不改变代码逻辑的基础上就会改变一下顺序,提高运行效率,而这个操作在多线程往往会出现线程不安全问题!
    这里也可以使用synchronized关键字避免指令重排列!

synchronized关键字

我们已经了解到了线程不安全问题可以用java提供的synchronized关键字来避免!
我们来学习synchronized如何使用!

  • 修饰实例方法
class Count{
    
    
    int count=0;
    //对实例方法进行加锁
    synchronized public void increase(){
    
    
        count++;
    }
}

这里的加速操作就是相当于对该实例的对象(this)进行加锁,而代码底层又是如何完成这个加锁操作的呢?
我们知道一切对象的父类都是Object类,而我们创建一个类,除了有我们描述的基本属性外,java还会自动开辟一块空间保存对象头信息!显然我们没有听说过,这里的属性是给jvm使用的!我们程序员并没有用!而加锁就是在对象头设置一个锁的标志位!
如果多个线程对同一个锁进行操作就会有锁竞争
对不同的锁进行操作就不会出现锁竞争!

  • 修饰代码块
    java可以在任意位置加锁!,但修饰代码块时我们需要指明加锁的对象!
class Count{
    
    
    int count=0;
    public void increase(){
    
    
        synchronized(this){
    
    //指明加锁对象!
            count++;
        }
    }
}
  • 修饰静态方法
class Count{
    
    
    static int count=0;
    public static void increase(){
    
    
        synchronized(Count.class){
    
    //修饰静态方法!
            count++;
        }
    }
}

synchronozed修饰静态方法时,并不能对this加锁,因为这是类方法!
我们可以采用反射的反射对类对象进行加锁!!!

死锁

死锁类型

  • 一个线程一把锁
    我们知道synchronized关键字可以给对象加锁!那如果我们不小心给同一个对象加锁两次,会出现什么情况呢?
class Count_1{
    
    
    private int count=0;
    synchronized public void increase(){
    
     //外层锁
        synchronized (this){
    
     //内层锁
            count++;
        }
    }
}

假设synchronized是不可重入锁,就是不能进行多次加锁!

我们的外层锁,在进入方法后就会对该对象进行加锁,有效加锁!而里层锁,会一直阻塞等待外层锁释放锁,才会进行加锁,此时代码就阻塞在这里了! 而外层锁要方法执行结束,才能释放锁!
显然现在的情形是谁也不让着谁!这就导致了一个尴尬的局面,就是我们所说的死锁!

就好比生活中的例子:

你手机没电了,要先老板借个充电宝!老板说,你先付钱我就借你,而你的手机已经关机了,又没带现金,然后你说,你借我我就付钱!然后就两个阿叉棍在哪死锁了!!!

不过我们写jvm的大佬设计时,将synchronized设置成了可重入锁!
多次加锁并不会发生死锁!

加锁: 如果我们现在有一个线程t1 ,对象a加锁后,t1线程拿到了该锁! synchronized就会在锁信息中记入该线程信息,还有标志该线程的加锁次数为1,如果t1线程再次对a加锁,那么并不会真正的再次加锁,只会把加锁次数加一!
解锁:如果该线程解锁,锁信息就会将锁次数减一,直到锁次数为0,此时该线程就将该锁释放!

显然jvm这样可重入锁设置,如果我们多次对一个对象加锁,会我们的运行速度降低,但是这样提高了我们人力成本,如果为不可重入锁,但造成死锁,那该程序就会中断,我们需要花大量时间进行调试!!!

  • 两个线程两把锁
    假设一种情况:
    当有两个人手机需要充电,而一个人有充电头,一个人有数据线,然后想要充电的话,需要两者结合,然后两个人都比较倔,谁都不肯退让,这就造成了死锁!

  • N个线程M把锁
    这里就需要讲到教科书上的经典案例:哲学家就餐问题
    假如有一群哲学家围在一个圆桌上干饭,然后他们干饭的时候还会思考人生,思考人生时不那筷子吃饭! 他们吃饭和思考人生是随机的!
    假如有5个人,5根筷子! 显然筷子不够!但是就将用!
    每个哲学家的两边都分别有一根筷子!

情形如下:
在这里插入图片描述
而且哲学家都比较倔,当他们拿到一根筷子时,如果没有筷子了,他们会一直等待,直到又一双筷子,才会干饭!

如果哲学家同时拿一根筷子时,就会造成死锁!
这顿饭估计永远都结束不了!!!

如何解决上面的问题呢?
我们可以先个筷子编个号,然后让哲学家们约定好,先拿编号小的筷子,再拿编号大的筷子,如果编号小的筷子被拿了,那就一直等待!
在这里插入图片描述
这样一约定,哲学家就可以将这顿饭干完了!

造成死锁的必要条件

  • 互斥使用: 当一个锁被线程占用时,其他线程就不能占用该锁了(锁的本质,原子性)
  • 不可抢占: 当一个锁被线程占用后,其他线程不能把该锁抢走(也就好比人家已经有对象,你就不能挖别人墙角)
  • 请求和保持: 当一个线程占用多把锁后,如果不释放锁,那么这些锁始终被该线程所有!
  • 环路等待: 就入哲学家就餐,同时拿筷子,造成了环路等待,a等b,b等c,c又等a!
    避免环路等待,需要约定加锁顺序,然后线程都按照该顺序加锁!

synchronizedvolatile区别

我们再来总结一下volatile的作用!
volatile关键字,可以保证该变量内存可见性,避免编译器偷懒优化,直接在寄存器中读取数据,导致线程不安全!

synchronized:

  • 给对象加锁,当多个线程对同一个变量更改时,保证线程安全

  • 给变量加锁,保证指令原子性

  • 保证内存可见性

  • 避免指令重排序

可以看到synchronized关键字,可以解决所有的线程不安全问题!
那我们是不是无脑用synchronized就好了
但是我我们知道synchronized加锁后,代码的执行效率就大打折扣!发挥不了多线程并发编程的优势!使用时需要视情况而定!!!

volatile只是保证了该变量内存可见性!并不能避免其他线程安全问题!
synchronized加锁后,会造成线程阻塞,volatile
并不会导致阻塞!!!

wait 和notify方法

等待和通知线程

这两个方法是Object类中的方法!

我们知道线程的调度是随机的,抢占性!而java下的waitnotify方法可以保证线程的执行顺序!
我们来学习一下这两个方法的使用!
调用wait方法后线程就会阻塞,直到有线程调用了notify通知!

public class Thread_20 {
    
    
    public static void main(String[] args) {
    
    
        Thread t1 = new Thread(()->{
    
    
           Object object = new Object();
            System.out.println("wait前:");
            try {
    
    
                object.wait();//阻塞
            } catch (InterruptedException e) {
    
    
                e.printStackTrace();
            }
            System.out.println("wait后:");
        });
        t1.start();
        t1.getState();
    }
}

在这里插入图片描述
本来预期是线程阻塞结果却抛出非法的监听器状态异常!

我们来看看wait方法的作用

  • 释放锁
  • 阻塞状态,等待其他线程通知
  • 收到通知,重新获取锁,继续往下执行

而我们上述代码连锁都没有又如何释放锁呢!

所以waitnotify方法需要搭配synchronized关键字使用!
正确用法:

    public class Thread_21 {
    
    
        private static Object object = new Object();
        public static void main(String[] args) throws InterruptedException {
    
    
            Thread t1 = new Thread(()->{
    
    
                //进行wait操作
                System.out.println("wait前");
                synchronized (object){
    
    
                    try {
    
    
                        object.wait();
                    } catch (InterruptedException e) {
    
    
                        e.printStackTrace();
                    }
                }
                System.out.println("wait后");
            });
            t1.start();
            t1.sleep(3000);
            Thread t2 = new Thread(()->{
    
    
                System.out.println("notify前");
                synchronized(object){
    
    
                   object.notify();
                }
                System.out.println("notify后");
            });
            t2.start();
        }
    }

在这里插入图片描述
我们可以看到,当object执行了wait方法后就造成了线程阻塞,直到执行notify通知后,线程才拿到锁继续执行下去!

notifynotifyAll区别

当我们多个线程都对object加锁后,notify通知后,只会随机给一个线程进行通知,然后该线程就可以拿到锁,执行线程!

notifyAll方法是通知所有阻塞线程,此时就会产生锁竞争!

猜你喜欢

转载自blog.csdn.net/weixin_52345071/article/details/124488709
今日推荐