多线程---线程安全

发布于:2022-12-10 ⋅ 阅读:(804) ⋅ 点赞:(0)

在这里插入图片描述

🎉🎉🎉写在前面:
博主主页:🌹🌹🌹戳一戳,欢迎大佬指点!
目标梦想:进大厂,立志成为一个牛掰的Java程序猿,虽然现在还是一个小菜鸟嘿嘿
-----------------------------谢谢你这么帅气美丽还给我点赞!比个心-----------------------------

在这里插入图片描述


四,线程安全

4.1,什么是线程安全

我们都知道,在多线程下,各个线程的调度顺序是不确定的,所以这就导致有可能我们的代码在某一种情况下它的执行结果是不满足我们的要求的,而这就是线程不安全的,否则就是线程安全的。


【简单例子认识线程不安全:】

假设现在存在一个值count初始值为0,我们想要将其自增100000次,但是要利用两个线程进行自增,即每个线程将其自增50000次。

class Test{
    public int count = 0;
    public void increase(){
        count++;
    }
}
public class Demo13 {
    public static void main(String[] args) throws InterruptedException {
        Test test = new Test();
        Thread t1 = new Thread(()->{
           for(int i = 0;i < 50000;i++){
               test.increase();
           }
        });

        Thread t2 = new Thread(()->{
            for(int i = 0;i < 50000;i++){
                test.increase();
            }
        });

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

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

        System.out.println(test.count);
    }
}

在这里插入图片描述


可以看到结果,最后结果并不是100000,而且多运行几次会发现每次的结果都是不一样的。其实这就是因为两个线程之间并发执行,调度顺序不确定引发的线程不安全问题。

在底层上,我们的自增操作其实是三条指令,load指令将值内存中读取到CPU寄存器上,add指令将值进行自增,save指令将已经自增好的值写回到内存中。所以在执行的时候就可能会出现如下情况:

在这里插入图片描述


如果说按照上图的顺序进行调度执行,你会发现count只自增了一次。如果是想要两次自增,那么两个线程调度顺序是必须得串行执行的,但是不可能一直保持一种调度顺序,大多数情况下的调度顺序都会造成上面的这种情况,所以最终结果不可能是100000。


4.2,线程不安全的原因

4.2.1,抢占式执行

这个点在并发编程中是避免不了的,但是这也是我们线程不安全的万恶之源。就是因为我们的多个线程是抢占式执行,所以CPU对于这个线程的调度顺序以及执行情况完全是不可控的,那么这就导致了我们写的代码如果不注意就很可能在不同的调度顺序下出现问题,从而出现线程不安全的问题。

4.2.2,多个线程修改同一个变量

注意,是多个线程,修改,同一个变量,这几个点缺一不可,在这种情况下会引发线程不安全的问题。

4.2.3,修改操作不是原子的

原子的概念之前在MySQL的事务里面讲过,某个操作是原子的也就是这个操作是不可再分的。CPU在执行的时候是按照指令一条条来执行的,但是对于一个操作而言,他可能是要有多条指令才能完成,相当于现在这个操作是可再分的,那再结合之前的抢占式执行,多个线程下多个指令之间的执行顺序是不确定的,就像前面演示的count++的例子,就会导致线程不安全的问题。

解决线程不安全的问题一般都是从这里入手,把某个操作通过一定的手段打包成原子的。


4.2.4,内存可见性问题

这个是JVM的代码优化机制引入的问题。 代码优化指的是在保证逻辑不变的情况下,JVM会在底层对于我们写的代码进行等价转换从而使他变得更加高效。不过虽然这个优化机制都是大佬中的大佬写出来的,但是在多线程的情况下,还是会有可能会出现误判,可能在某次的转换之后就不是等效的了。

4.2.5,指令重排序

这也是JVM的优化带来的问题,多个指令完成一个操作,中间指令的步骤可能会调换顺序,而这在多线程下很可能会引发线程安全问题。

4.3,如何解决线程不安全问题

就拿之前的count++的例子作为导向,抢占式执行这是操作系统内核决定的我们无能为力,多个线程修改同一个这是我们的代码要求这也没办法,所以就只能从原子性上入手,让count++这个操作成为原子的。方式就是加锁(synchronized)。

在count++之前加锁,在其执行完之后再解锁。这个时候多个线程之间对于这一个变量的修改操作就是一个互斥的关系,会有锁竞争,当有一个线程在进行修改的时候,另一个线程想要修改就只能阻塞等待,其状态就为BLOCKED状态。


(1),使用synchronized修饰一个普通方法

class Test{
    public int count = 0;
    public synchronized void increase(){
        count++;
    }
}

public class addCount {
    public static Test test = new Test();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for(int i = 0;i < 10000;i++){
                test.increase();
            }
        });

        Thread t2 = new Thread(()->{
            for(int i = 0;i < 10000;i++){
                test.increase();
            }
        });

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

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

        System.out.println(test.count);
    }
}

锁是具有独占性的,当某个操作已经加锁之后,别的线程想要操作,进行加锁就只能阻塞等待。如下图:

在这里插入图片描述


这个时候其实对于这么一个自增的操作,两个线程之间可以就理解为串行了。这个时候效率会有下降,虽说多线程是为了并发执行提高效率,但是前提是我们得等能够计算正确。

当然注意,我们加锁之后,这个操作变成了原子的,但是这个在这个原子性的操作之中,CPU该怎么调度还是怎么调度,也就是说中间CPU还是会调度走的,只不过现在这个线程对于这个操作还是一个加锁的状态,锁没有释放,即使你CPU调度走了,别的线程想要进来CPU进行调度操作还是不行,还是只能阻塞等待。就相当于你在图书馆看上了一个好位置,你想去坐,但是在你之前已经有人把座位给占了,你想要用那就只能等,但是呢,那位同学也没有一直学习,中途可能去吃饭去玩了,但是他把书放在座位上给占着,所以你还是用不了,只能阻塞等待。


对于上面的两个for循环之间还是并发的,只不过里面的increase()加锁之后是串行的,所以整个过程就可以看成是并发 + 串行,效率相对于完全并发肯定是降低了,但是对于完全串行肯定还是快不少的。所以在加锁的时候我们是需要考虑好锁哪段代码的,锁的代码越多,锁的粒度就越大,反之就越小。


可能会有同学会想,如果我只对一个线程进行加锁,那还能保证线程安全吗?答案是不可以。

我们之所以可以多线程修改同一个变量,就是因为我们把并发修改通过加锁变成了串行修改,要两个都加锁才会涉及到锁竞争,你只对一个一个线程进行操作,那么多个线程之间的修改操作相当于还是并发的,你这个锁加不加都没啥必要。

class Test{
    public int count = 0;
    public synchronized void increase(){
        count++;
    }

    public  void increase2(){
        count++;
    }
}

public class addCount {
    public static Test test = new Test();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for(int i = 0;i < 10000;i++){
                test.increase();
            }
        });

        Thread t2 = new Thread(()->{
            for(int i = 0;i < 10000;i++){
                test.increase2();//调用不加锁的自增方法
            }
        });

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

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

        System.out.println(test.count);
    }
}
//输出结果不是两万

(3),使用synchronized修饰静态方法

class Test{
    public  static int count = 0;

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

public class addCount {
    public static Test test = new Test();
    public static Test test2 = new Test();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for(int i = 0;i < 10000;i++){
                test.increase();
            }
        });

        Thread t2 = new Thread(()->{
            for(int i = 0;i < 10000;i++){
                test2.increase();

            }
        });

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

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

        System.out.println(test.count);


    }
}

在多线程的代码中定义静态的方法,如果是在方法里面定义的变量(只可能是普通变量,不可能是定义静态的),那不会有任何的问题,因为每个线程都会自己创建一个新的副本,也就是线程安全的。但是如果静态的方法里面操作的是外部定义的静态的成员变量,因为静态的变量是只有一份的,所以这个时候就会有线程安全的问题。那么对于这个静态的方法加锁之后,锁对象相当于就是类对象。


(4),使用synchronized修饰代码块(也就是一个方法里面只有部分内容需要加锁)

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

synchronized (){}就是用法,()里面需要填一个东西叫做锁对象,也就是你想针对哪个对象进行加锁,只有明确锁对象之后,多个线程之间才会有锁竞争。Java中任意对象都可以成为锁对象。

1,可以写this

这个时候谁调用这个increase()方法谁就是这个锁对象。相当于现在锁的就不是方法,而是具体的调用对象。

2,可以在类的内部定义一个单独的对象作为锁对象

class Locker{

}
class Test{
    public int count = 0;
    public Locker locker = new Locker();//单独的对象
    public  void increase(){
        synchronized (locker){
            count++;
        }
    }
}

总的来说,我们在多线程代码里面,我们关心的只是你加锁的对象是不是同一个,是就会有锁竞争,否则就没有。我们不关心你具体是个啥对象,是public还是static还是啥类型的。这个锁对象它就只是起到了一个标志的作用,某个线程给它加锁了之后,另外的线程发现其被加锁了之后就无法执行其代码块里的逻辑只能阻塞等待。

一般情况下来说,写this就可以了,但是在记住也不能无脑写this,某些特殊情况下还是要具体问题具体分析。


【synchronized锁对象的一些情况分析:】

1:

在这里插入图片描述


2:

在这里插入图片描述


3:

在这里插入图片描述


4:

在这里插入图片描述


5:

在这里插入图片描述


4.4,synchronized特性

(1)互斥性

锁之间的互斥性是保证线程安全的核心,因为互斥,也就是存在锁竞争,才能够使得并发执行串行化。

(2)可重入性

首先,对于加锁,我们可能会出现对于一个对象同时加锁两次最终造成死锁的情况,例如:
class Count{
    public synchronized void increase(){
        synchronized (this){

        }
    }
}

class Count{
    public synchronized void increase(){
        increase2();
    }
    
    public void increase2(){
        increase3();
    }
    
    public synchronized void increase3(){
        
    }
}
对于方法直接加锁就是相当于对于this加锁,然后内部又对this进行了一次加锁。然而内部的加锁需要外部的先解锁,外部要解锁又必须内部先加锁然后把代码走完才可以,那这样整个前后就矛盾住了,造成了死锁。
那么针对上述的情况,会产生死锁的锁就叫做不可重入锁,不会产生死锁的锁就是可重入锁。注意synchronized是可重入锁,所以不会出现上述所说的死锁情况。

【那么是如何实现的呢?】

在这里插入图片描述


当然,死锁会有还有其他情况,只是说针对同一把锁连续加锁两次造成的死锁情况是可以用可重入锁解决掉的。


4.5,volatile关键字

volatile关键字可以用来解决前面所说的内存可见性问题。

在这里插入图片描述

上述情况相当于就是t2偷偷的把内存中的值给改了,但是t1没有看见感知到。这就是内存的可见性问题,这也是因为在多线程下,编译器优化所引发的bug,那么如何解决就需要使用到我们的volatile关键字了。相当于就是程序员显示的提醒编译器这个地方不要进行优化,那么内存可见性的问题也就可以避免了。


class Count2{
    volatile public  int count = 0;//使用volatile修饰count
}

使用volatile关键字修饰变量之后,这个时候就可以解决内存可见性的问题了。此时被修饰的变量,编译器就不会做出只读一次内存,后续直接在寄存器读取数值的优化了。这个时候的t1只能每次都乖乖的去内存中读取count的值来进行比较。volatile关键字的作用就只是用来修饰变量,它解决的问题是内存可见性问题,它是不能保证我们的操作是原子性的。


【扩展:】

注意,我们的编译器的优化它并不是时刻都会存在,什么时候编译器会去选择优化,很大程度上是你的代码所决定的,例如下面我们在while循环里面进行休眠,那么在这个休眠的时间内,是足够去重新读取内存的,所以其实这个时候是否优化都没有很大的关系了,最终的效果就是每次循环都是重新读取内存然后再进行比较值,那么这个时候也就不会存在内存可见性的问题了。

Thread t1 = new Thread(()->{
    while(count2.count == 0){
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    System.out.println("t1线程执行完毕!");
});

那么volatile能够保证内存可见性的问题核心是啥?

volatile修饰变量的核心就是禁止编译器进行优化,避免了直接读取CPU寄存器里面缓存的数据,而是每次都去重新读取内存。但是,我们Java的初心就是要屏蔽掉底层硬件上的区别,所以上面的说法也就有了另一种更加贴近java的说法,也即是JMM内存模型。

在这里插入图片描述


缓存的机制就是为了提高效率,它的定位存在于寄存器与内存之间。空间比寄存器大一些但是比内存小,读取速度比寄存器慢一些但是比内存快,这个时候利用缓存就可以一次性从内存中缓存更多的数据,后续就无需再频繁的去访问内存了,作用有点类似于之前的编译器优化。


【补充:变量捕获的知识】

我们在编写多线程代码的时候,经常会使用到外部的变量,这里会涉及到一个变量不会的问题,因为对于你线程里面的run方法而言,外面的变量其实就相当于是类外的变量,而一般情况下,我们是访问不到类外部的局部变量的,但是能够直接访问到肯定是方便我们进行编程的,所以就有了变量捕获。

public class Test {
    public static void main(String[] args) {
        int ret = 1;
        //final int ret = 1;
        Thread t1 = new Thread(){
            @Override
            public void run() {
                System.out.println(ret);
            }
        };

    }
}

不过注意,我们说的变量捕获所能捕获到的局部变量只能是final修饰的或者是从未进行过改变的才行

当然,如果觉得变量捕获不太好的话,我们其实可以直接将我们的这个要使用的变量定义为类的成员变量,这样就是相当于是要访问外部类的成员变量,这是可以访问的,也就绕开了变量捕获。(可是是直接定义为main所在类的成员变量,也可以是单独抽离出一个类,具体看你实际需求决定就好)

class RetTest{
    public int ret = 1;
}
public class Test {
    //public static int ret = 1;
    public static RetTest Ret = new RetTest();
    public static void main(String[] args) {
        Thread t1 = new Thread(){
            @Override
            public void run() {
                System.out.println(Ret.ret);
            }
        };

    }
}

4.6,wait()与notify()

我们知道线程的调度是抢占式的,也就是随机,但是在实际开发中我们可能会希望能够合理的协调一下各个线程之间的调度顺序。那么wait()与notify()就是为了协调我们的调度顺序。


4.6.1,wait()方法,notify()方法

public class Test1 {
    public static Object object = new Object();
    public static Test1 test1 = new Test1();
    //wait方法是Object类的方法 而Object类是所有类的父类 所以任何类的对象都可以调用
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            System.out.println("wait开始之前");
            try {
                test1.wait();
            } catch (InterruptedException e) {//也有可能会触发异常而提前唤醒wait()
                e.printStackTrace();
            }
            System.out.println("wait开始之后");
        });
        t1.start();
        t1.join();
    }
}

在这里插入图片描述


可以看到代码跑起来之后并没有和我们预期的一样进入等待状态,而是抛出一个异常之后程序就结束了。所以接下来就要说一下wait()操作做的事以及细节了。

wait()内部实际做了三件事:1,先释放当前锁 2,进程等待通知 3,当别的线程调用notify()之后,被唤醒,尝试重新获取到锁继续执行

(1)首先释放锁,因为该线程调用wait操作之后,线程是要进入阻塞状态的,那如果线程还把锁占着,别的线程想要使用到锁都不行。所以在调用wait()操作的第一步,就是先释放锁。
(2) 调用wait()之后,该线程就会进入阻塞状态,等待其他线程调用notify(),也就是等待通知。这种等待是我们人为造成的,因为在我们的规划里现在这个线程还不到执行的时机,这些操作也就是协调了各个线程之间的执行顺序。
(3) 别的线程调用notify之后,就会唤醒wait,那么那个线程就会尝试重新获取到锁。如果是多个线程都在wait的话,那么会先唤醒哪个也是随机的。

图示一下顺序:
在这里插入图片描述


【代码演示:】

public class Test2 {
    //定义一个特定的对象,来确保我们的调用wait与notify是同一个对象
    public static Object object = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            while(true){
                System.out.println("wait之前");
                synchronized (object){
                    try {
                        object.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("wait唤醒之后");
            }
        });
        t1.start();
        Thread.sleep(1000);//确保能先执行t1线程

        Thread t2 = new Thread(()->{
            while(true){
                System.out.println("notify之前");
                synchronized (object){
                    object.notify();
                }
                System.out.println("notify之后");
                try {
                    Thread.sleep(5000);//休眠一下,假设t2这里还有其他逻辑没有执行完
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t2.start();

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

    }
}

在这里插入图片描述


通过wait与notify的配合使用,我们就可以在抢占式调度的环境下,确保我们wait唤醒之后的代码一定是在notify之后执行的。
两个线程之间还是抢占式调度。所以可能是先执行t1调用wait,然后t2执行调用notify唤醒wait。但是也有可能是先执行t2调用notify,这个时候即使没有线程wait,调用notify是没有任何的副作用的,那么这个时候如果后面不再次调用notify,那么t1线程调用wait之后就没有人来唤醒了。

另外,除了notify之外,还有一个notifyAll,相对于前者随机唤醒一个wait,那么后者则是全部唤醒,不过一般都是用的前者,因为就算一次性唤醒多个,你这几个线程之间还是要锁竞争,也就是串行了。


【使用细节:】

1,wait(),与notify()使用都需要搭配synchronize的。

2,调用wait()与notify()的对象必须是同一个,并且锁对象也需要和调用wait/notify的对象一致。

3,即使没有线程wait,那么调用notify也是没有任何副作用的。


【面试题:请说说sleep()与wait()的异同点?】

两个方法都是让线程进入等待状态。

区别:

1,sleep()使用无需搭配synchronized。

2,sleep()是通过时间控制唤醒的,而wait()是通过notify()唤醒的。

3,wait()其实还有一个重载的版本,也可以传入时间参数,类似于join(time)的作用,表示最大等待时间。


今天关于线程安全的总结就这么多了,大家如果觉得写的不错的话还请点点赞咯,十分感谢呢!🥰🥰🥰
在这里插入图片描述

本文含有隐藏内容,请 开通VIP 后查看