多线程带来的风险——线程安全

多线程带来的风险——线程安全

一、何为线程安全

​ 编写多线程代码的时候,如果当前代码中因为多线程随机的调度顺序,导致程序出现了BUG,就称之为“线程不安全”,如果我们自己写的多线程代码,不管系统按照啥样的随机情况来调度,也不会导致出现 BUG,就称之为“线程安全”。

这里的多线程安全跟黑客无关emmmm…,黑客一般都是跟 网络安全 挂钩的。

  • 实现一个经典线程不安全的案例,两个线程进行变量累加

//线程不安全案列,两个线程进行变量累加

class Count {
    public int count = 0;

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

public class Demo11 {
    private static Count count = new Count();

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

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count.increase();
            }
        });
        t1.start();
        t2.start();

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

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

这里相加的结果应该是 10w,但是运行的结果大概率不是10w,而是在[5w,10w]之间,10w大概率很少出现。显然这就是一个bug,这种bug是因为线程调度的随机性导致的。

那么在这个过程中 count++ 到底干了给啥???

1.1 线程不安全的原因

count++ 操作其实是三个步骤:

1、把内存 中的值,读到 CPU 的寄存器中 (load)

2、把寄存器中的 0 给进行 +1 操作 (add)

3、把寄存器中的 1 给写回到 内存中 (save)

其中这也是CPU的三条指令。

如果是两个线程,同时操作这个count,此时由于线程之间的随机调度的过程就可能产生不同的结果。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LlG41j0X-1653876849286)(D:\常用文件夹\资料信息图片\Typora-image\Image\image-20220524093631935.png)]

按照上面图形的顺序才是应该正确执行步骤,但是线程调度是随机的。所以就会发生各种的情况。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-J6S9TwUi-1653876849287)(D:\常用文件夹\资料信息图片\Typora-image\Image\image-20220524094248859.png)]

这些都是可能出现的情况,但是不止这三种,这些情况都是线程不安全的!!!

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LOijOdkz-1653876849288)(D:\常用文件夹\资料信息图片\Typora-image\Image\image-20220524102622625.png)]

由此可见,这里情况是两个线程分别自增1,一共自增2次,但是内存还是1,两个线程分别都读到了0,接下来分别自增,得到的都是1,再往内存里写的时候,就都是1,这就是明显的bug。所以这里的线程调度是不安全的。

在操作系统中,调度线程过程中是随机的,就是使t1,t2和内存这三个操作,之间穿插多种情况。我这上面画的几种都是可能出现的情况,当然还有很多的情况我就不一一画出来了,并且那种情况各自出现多少次,这些都是随机的!

只有两种情况是正确的:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-crO6dM0m-1653876849288)(D:\常用文件夹\资料信息图片\Typora-image\Image\image-20220524103836252.png)]

有图观察可得:上述串行执行的情况,出现的次数,就直接决定了最终的执行结果。

极端情况,如果5w次循环,都是出现串行的情况,最终结果就是 10w

极端清况,如果5w次循环,一次串行的情况都不出,此时最终结果就是5w

所谓的极端情况,也是小概率事件。


综上所示:就是修改了共享数据,上述代码不安全中,就涉及到了多个线程针对 count 变量来修改,而此时 count 也是一个多个线程共享的数据。在共享的数据中就会出现多种随机情况,就会导致线程不安全。

​ 正如每个人都会有隐私,想象成一段代码是一个房间,这个房间没有锁,那么任何人都可以进入,假设这是一个女孩子的房间,那么这个时候一个男孩子进入了这个女孩子的房间,这样女孩子的隐私就会被侵犯,这种是万万不可的(排除这两人是夫妻hhh…) 。

二、多线程带来的风险——线程安全

2.1、 原子性

什么是原子性??

刚才男孩闯入女孩的房间就不具备原子性,那么给房间加一把锁,女孩进入房间把门锁上,其他人进不来,这样就保证了这段代码的原子性。

也就是说在一个操作中要么全做完,中途是不能暂停或者再次调度,要么就等另外一个操作执行完了,再进行;只要一个完整的操作不被 打扰调度暂停,那么这就是原子性。

这种现象也可以叫做同步互斥,表示操作是互相排斥的。

2.1.1如何让线程安全?

​ 如何让线程安全??根据上面的例子就是给这个房间加锁!!

通过加锁的操作,就是把上述 “无序” 的过程 给变成 “有序” ,把上述 的 “随机” 变成 “确定”。

2.1.2、 synchronized (这里简单了解下,后续祥细说)

java 给线程加锁的方案有多种,其中最常用的方案,就是 synchronized (这个也是java中内置的关键字)。

中文翻译为同步,也可以理解为“互斥”。

class Count {
    public int count = 0;

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

这时候在 increase ()方法中加上 synchronized 关键字之后,相当于 进入increase()方法,就加锁,出了这个方法 就解锁。

那么 synchronized 关键字是如何解决线程安全的问题??

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MjidHe7H-1653876849289)(D:\常用文件夹\资料信息图片\Typora-image\Image\image-20220524114824902.png)]

由上图可知:

基本操作就是先等一个线程执行完,解锁之后,在进行下一个线程执行。可以理解成排队上厕所,厕所里面有人,门是锁着的下一个人拉不开门,只能等厕所里面的人上完厕所解锁出来,下一个人才能去上厕所。

  • 线程不安全的原因

这就也相当于是多个线程在修改一个变量。如果是一个线程修改一个变量,可行;如果是两个线程读这个变量,可行;如果是两个线程修改两个变量,可行;(两个变量相当于两个不同的空间)。

总结:加锁 (synchronized),进入 synchronized 修饰的方法中,就会先加锁,出了方法就会解锁,如果当前有线程占用了这把锁,有其他的线程,那么其他的线程尝试占用这把锁,此时就会出现阻塞等待。

❓❓❓❓提出疑问?

实现并发编程,本来就是提高效率的,但是加锁过后,执行结果对了,但是并发性就低了,速度效率也降下来了。

但是做成多线程这个东西还是有意义的,两个并发线程,可能各自要完成的任务有很多,也有不少工作能够并行进行的,整体来说,多线程还是有意义的。

2.2、内存可见性 ☆

public class Demo12 {
    private static int flg = 0;
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            while(flg == 0) {
                // 先啥也不写,无限循环。
            }
            
            System.out.println("t线程结束");
        });
        t.start();

        Scanner scan = new Scanner(System.in);
        System.out.println("请输入一个数");
        flg = scan.nextInt();
        System.out.println("main 线程结束");

    }
}

按照普通人的逻辑,这段代码在用户输入一个数字时,这个数就会赋值给flg,flg!=0,从而跳出循环。打印“t线程结束”。但是真正运行的Consequences 并不是这样。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Wp5E5QyG-1653876849291)(D:\常用文件夹\资料信息图片\Typora-image\Image\image-20220526195005652.png)]

这个场景就是设置一个变量,一个线程无限循环读取这个变量的值,另一个线程会在一定时间之后,修改这个变量的值。但是这里的 t 线程并没有读到修改后变量的值。这种问题就是 内存可见性

  • 引起内存可见性的原因。
这里就要补充一个概念,java编译器中的优化功能:
	程序猿在编译器中写的代码,编译器并不会逐字翻译,编译器会保证原有逻辑不变的情况下,动态调整要执行的指令内容,这个调整过程,是需要保证原有逻辑不变的,从而这种调整就会提高程序运行的效率。

可惜😥😥😥 得是在多线程的场景下,编译器的判定可能就会存在误差,优化的操作就可能会影响原有的逻辑

编译器优化功能不仅是java,许多主流的语言也是会有编译器优化的功能,因为开发编译器的大佬并不信任我们能写出高效的代码emmmm… 所以让编译器来动态修改我们写的代码,这种优化就会让程序快得非常多,可能是倍数的 promote。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-re8H5LYM-1653876849293)(D:\常用文件夹\资料信息图片\Typora-image\Image\image-20220526201705539.png)]

这样的优化也将导致了后面进行 变量 修改的时候 t 线程感知不到,所以就会一直认为 flg == 0 ,然后一直循环,t线程就不会退出。

所谓的内存可见性就是:一个线程改了内存里面的值,但是另一个线程看不见你改的值。

load读的是内存,后面的比较就是在寄存里面比较,而内存的速度要比寄存器的速度要慢 3~4 个数量级。

2.2.1、内存可见性的解决方案。(volatile)

解决方案有两种:

1、使用 synchronized ,加上 synchronized,编译器就会禁止在 synchronized 内部的代码产生上述的优化。

2、还可以使用另一个关键字,volatile.

volatile,用这个关键字修饰对应的变量就行,有了这个关键字,编译器在进行优化的时候,识别到了这个关键字,就知道会禁止进行上述的 读内存 的优化,会保证每次都重新从内存读。

这两个方法,保证了内存可见性,禁止了编译器自己相关的优化。

  • 第一种改法
Thread t = new Thread(()->{
            while(true) {
                synchronized(Demo12.class) {
                    if (flg != 0) {
                        break;
                    }

                }
            }
            System.out.println("t线程结束");
        });
        t.start();
  • 第二种改法
private static volatile int flg = 0;

2.3、指令重排序

也是跟编译器优化存在关联(一种优化手段)。

触发指令重排的前提也是要保证代码原逻辑不变;指令重排,就是保证原有逻辑不变,调整了程序指令的执行顺序,从而提高效率。

单线程环境下,这里的判定比较准,

如果是多线程环境下,这里的判定就不太准了

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-O4uDBIaY-1653876849295)(D:\常用文件夹\资料信息图片\Typora-image\Image\image-20220526223433793.png)]

这上面的图是单线程的情况,但是有多个线程的时候,就会判断优化失误;

解决办法也是使用关键字 synchronized ,编译器对于 synchronized 内部的代码非常的谨慎,不会随便乱优化。

三、synchronized 具体使用方法 ☆

三个方面起到的效果:

1、互斥 (核心),也就是阻塞等待

2、保证内存可见性

3、禁止指令重排序

2,3 都是提醒编译器能够优化的谨慎一点

具体的使用方法:

1、在一个普通方法上使用synchronized,进入方法是加锁,出方法就是解锁,锁对象相当于this。

2、加到一个代码块,需要手动的指定一个 “锁对象”。

​ 锁对象可以手动指定锁对象 this,也可以指定其他对象作为锁对象;java中,任何一个继承自 Object 类的对象,都可以作为锁对象。(synchronized 加锁操作,本质上是在操作 Object 对象头中的一个标志位)

3、加到一个 static 方法上,此时相当于指定了当前的 类对象,为锁对象。

类对象里面包含了这个类中的一些关键信息,这些关键信息就支撑了java的反射机制,类对象 和 普通对象一样,也是可以作为被加锁的对象。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3wpOdbor-1653876849296)(D:\常用文件夹\资料信息图片\Typora-image\Image\image-20220527201904284.png)]

4、两个线程针对同一个对象加锁,才会产生竞争;两个线程针对不同对象加锁,不会产生竞争。

可以看成上厕所的时候厕所里面人满了,需要排队等待,直到在锁里面的人解锁出来下一个人才能进去,此时就形成了锁锁竞争;如果厕所里面还有空的厕所间,下一个人就直接进入空的厕所间,他们就不会产生竞争关系;在同一个空间里面就是竞争,在不同空间就没有竞争。

在加锁的时候,必须要明确当前是针对啥来上锁的!!!只有说针对指定的对象上锁之后,此时多个线程尝试操作相关的对象才会产生竞争关系!!!

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7M3HF3g0-1653876849297)(D:\常用文件夹\资料信息图片\Typora-image\Image\image-20220527204451395.png)]

3.1 可重入锁

是 synchronized 重要特性!

如果 synchronized 没有 可重入锁,那么就会出现 “死锁” 的情况。

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

可能会经常写出这种情况,就是针对同一个对象 Count ,加锁了两次,没有可重入的功能,就会出现 “死锁”。

按照之前的理解,第二次加锁的时候是会阻塞等待的,等待第一次加锁把锁释放到了,才能获取到第二把锁,但是释放第一把锁使用该线程里面的代码完成的,这个时候就僵住了。

为了避免这种情况,java大佬们就设计出来 可重入锁,针对同一把锁可以多次加锁,不会有负面效果。

锁中会持有两个信息:

1、当前这个锁被那个线程给持有;

2、当前这个锁被加锁了几次;

当 t 线程加锁后,当前这个锁就是 t 持有的,后续再次进行加锁操作,并不会真正的加锁,而只是修改计数(1->2);

后续往下执行的时候,出了 synchronized 代码块,就触发一次解锁,这里的解锁也不会真正的解锁,而是计数 -1 操作;

在外层方法执行完了之后,再次解锁,再次计数 -1 ,计数减成0了,才真正的进行了解锁。

但是在操作系统原生提供加锁相关的api中就是不可重入锁了。

3.2 死锁

死锁不仅仅只会出现我们上述的情况,针对同一把锁加锁多次,也会有另外一种经典的情况。(哲学家们就餐的问题)

1个线程,1把锁;

2个线程,2把锁;

N个线程,M把锁;

我这里是举了个差不多的栗子:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0ysDaucH-1653876849298)(D:\常用文件夹\资料信息图片\Typora-image\Image\image-20220527213054369.png)]

这种僵住的情况也就是 “死锁”,N个线程,M把锁;这也是一种“死锁”的情况。


因为多线程有很多的陷阱,同时也引出了面试中的常见问题:某个集合类是否是线程安全的??

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBulider

以上属于线程不安全!! 注意这些对象在多线程中谨慎使用,尤其是一个对象被多个线程修改的时候。

  • Vector
  • HashTable
  • ConcurrentHashMap
  • StringBuffer
  • String (注意)

以上都是线程安全的,前面都带有 synchronized 关键字,更加放心的在多线程环境下使用;注意这里的 String 比较特殊,因为 String 是不可变对象,不能修改,因此就不能在多线程中修改同一个 String 了。


❓❓❓❓提问:

synchronized 和 volatile 两个有啥区别?

synchronized:原子性,内存可见性,指令重排序

volatile:内存可见性。


3.3 JMM

JMM => java Memory Model java 存储模型(内存模型)。

JMM 就是 CPU 的寄存器,以及 内存 之间这样的一套模型,java只是取了一个抽象的名字 JMM

JMM中把CPU的寄存器这部分存储称为 “工作内存”(work memory),memory理解成存储更好,因为是CPU的寄存器。

JMM中把正常的内存称之为 ”主内存“(main memory),这也是理解成存储会更好;

在代码中执行一些操作,例如前面的 count++,就是把 主内存 中的数据,拷贝到 工作内存 ,然后 工作内存自增,再次拷贝到 主内存 中。这里只是说法概念不一样,但是逻辑是一样的;JMM相当于概念性的东西。

面试中问到了 内存可见性 问题,可以从两个维度来回答 :一、CPU/ 内存 这个角度来;二、也可以从JMM 的 主内存/ 工作内存 这个角度来回答。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7IQiH7rX-1653876849299)(D:\常用文件夹\资料信息图片\Typora-image\Image\image-20220528222533272.png)]

3.3.1缓存

缓存是属于CPU上面的:

1、缓存的容量比内存小 1-2 个数量级

2、缓存的容量比寄存器要大,

3、缓存的速度比内存快

分为L1(一级内存),L2,L3(三级内存),想对缓存而言,L1最小最快,L3最大最慢。

为什么会出现缓存??

CPU的工艺和性能要比内存更好,假如CPU每秒能读10条指令,但是内存每秒是能读1条指令。而CPU是要通过内存才能读取指令的,由于内存的效率低,就拉低了CPU的性能,每秒只能读一条指令。因此CPU就大才小用了!怎么解决??

1、让内存的工艺跟CPU差不多,但是代码的后果就是内存的价格成本极高,可以理解是8内存100万…😕😕😕,pass

2、就是在CPU和内存中间有一个缓存(Cache),而 Cache 的速度比内存快,所以,CPU在请求指令的时候同时请求的是 Cache和内存,由于 Cache 比内存快,响应的结果就会先给CPU,而 Cache 里面的东西就是先预先下载好CPU常用的指令,响应的速度更快。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YXo9ykKr-1653876849299)(D:\常用文件夹\资料信息图片\Typora-image\Image\202205021806311.png)]

缓存顾名思义也是日常生活中的缓存,其实就是把CPU常用的指令缓存下来从而提升速度,尽可能以小的代价,实现尽可能高的性价比。当然容量大,指令的命中率就越高,这里的命中率始终指的是 Cache。

3.4 wait和notify

多线程的调度过程是充满随机性的,系统源码是改变不了的,所以系统层面上不能解决问题。

但是在实际开发中我们希望合理的协调多线程之间的先后顺序。

通过 wait和notify 机制,来对多线程之间的执行顺序,做出一定的控制。

当某个线程调用 wait 之后,就会阻塞等待。

直到其他某个线程调用 notify 把这个线程唤醒为止。

public class Demo14 {
    public static void main(String[] args) throws InterruptedException {
        Object o = new Object();
        
        // 这里会跳出 java.lang.IllegalMonitorStateException 的错误。
            //System.out.println("等待之前");
            //o.wait(); // 这个线程阻塞等待。
            //System.out.println("等待之后");

        // 正确代码
        synchronized (o){
            System.out.println("等待之前");
            o.wait(); // 这个线程阻塞等待。
            System.out.println("等待之后");
        }
    }
}

上面的这个代码 o线程 调用了 wait 就会一直等。直到notify唤醒才会继续进行。

这里的必须要加 synchronized ,不然后报错, 出现Exception in thread "main" java.lang.IllegalMonitorStateException 的错误,Monitor -> 就是监视器,synchronized也可以叫做监视器锁。

wait 这个方法里面会做三件事情:
1、先针对 o 解锁。(应该加了锁才能解锁)
2、进行等待(等待通知的到来)
3、当通知到来之后,就会被唤醒,同时尝试重新获取到锁,然后再继续执行。

正因为 wait 里面做了这几件事情,所以 wait 需要搭配 synchronized 来使用。

public class Demo15 {
    private static Object loker = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread waiter = new Thread(()->{
            while(true) {
                synchronized (loker){
                    System.out.println("wait 开始");

                    try {
                        loker.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("wait 结束");
                }
            }
        });
        waiter.start();

        Thread.sleep(3000);

        Thread notifier = new Thread(()->{
            synchronized(loker) {
                System.out.println("notify 之前");
                loker.notify();
                System.out.println("notify 之后");
            }
        });
        notifier.start();
    }
}

运行结果:

wait 开始
notify 之前
notify 之后
wait 结束
wait 开始

从运行结果可以看出,wait开始,进行阻塞等待,notify唤醒,线程继续,后面又循环,线程开始,直到有 notify 唤醒为止线程才继续。

notify 也是 Object 类的方法,那个对象调用了wait,就需要那个对象调用 notify 来唤醒。

o1.wait(), 就需要o1.notify 来唤醒。

o1.wait(),使用 o2.notify,没效果。

notify 同样搭配 synchronized 来使用;如果多个线程都在等待,调用一次 notify ,只能唤醒其中的一个线程,具体唤醒那个线程就是系统随机的了,如果没有任何线程等待,调用notify,不会有副作用。

notify 也是用必要加锁的,notify 本质上也是在针对 locker 对象里面的对象头进行了修改状态,需要保证先加上锁,在进行其他修改。(属于java的特殊要求)

总结:有了 wait 和 notify 机制,就可以针对多个线程之间的顺序进行一定的修改了。

除此之外,java中还有个 notifyAll 的操作,一下子全部唤醒,唤醒之后,这些线程再尝试竞争这同一个锁,唤醒全部,这些线程尝试竞争锁,然后按照竞争成功的顺序,依次往下执行。notify的话就是唤醒一个,其他线程仍然在wait中阻塞。

从而也引出面试题:wait 和 sleep 的对比
一个是线程之间的通信,一个是让线程阻塞一段时间;
总结:
1.wait 需要搭配 synchronized 使用,sleep不需要;
2、wait 是Object 的方法 sleep 是 Thread 的静态方法;

这篇帖子重点介绍了线程安全的问题,面试高频考点,是整个多线程中最重要的关键点!!!也是概念居多(八股文),代码较少,下一篇会写关于多线程的案例,代码会 enhance 点。

铁汁们,觉得笔者写的不错的可以点个赞哟❤🧡💛💚💙💜🤎🖤🤍💟,收藏关注呗,你们支持就是我写博客最大的动力!!!!

  • 42
    点赞
  • 73
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 27
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 27
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

鸢也

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值