【多线程】线程安全问题

1.造成线程不安全的原因,以及具体措施

  • 我们知道在多线程环境下,线程之间是抢占式执行的,调度过程是随机的(这也是造成相爱难成不安全的万恶之源),但是我们对这个问题,没有办法😒
  • 多个线程同时修改一个变量。我们知道多个线程同时读取一个变量没事,多个线程同时修改多个变量也没事,但是对同一个变量修改就不行了。就类似于博主在之前的博客中写道两个线程同时对一个变量同时自增5000次,最后得到的结果不是10000,原因就是,两个进程会同时修改一个变量,原本是增加2,但是两个线程同时对同一个变量自增,就只是增加了1。解决办法:适当的调整代码结构,避免这种情况
  • 针对变量的操作不是原子的。原子性:就好比说,一个任务要么执行完,要么干脆就不执行。正如 count++这种,本质上有三个指令:load add save 那么在多线程环境下,三个指令也许并不是属于这条线程的这三条指令逐一执行的。解决办法:加锁(synchronized)
  • 内存可见性 一个下线程频繁读,一个线程写,我们知道如果一个线程频繁进行读取操作,此时编译器就会进行优化,因为线程在内存中读取数据的效率低于CPU的读取效率,所以我们此时如果频繁的在内存中读取数据的话,编译器就会做出优化,让线程在CPU上读取数据(CPU读取数据的效率是内存读取效率的3-4个数量级),但是如果一个线程把在这个读取的变量修改了,那么其他线程在CPU上读取数据的时候,就会感知不到,这就产生了BUG,导致线程不安全。解决办法:使用volatile,volatile可以禁止线程在CPU上进行读取操作,每次读取数据的时候,都要从内存中读取数据。
  • 指令重排序 解决办法:使用synchronized修饰代码,禁止指令重排序

2.synchronized关键字-监视器所monicor lock

2.1互斥

synchronized关键字会起到互斥效果,某个线程被执行到某个对象的synchronized中时,其他线程如果也想执行到这同一个对象,此时synchronized就会阻塞等待。

  • 在进入synchronized修饰的代码块时,相当于加锁

  • 在退出synchronized修饰的代码块时,相当于解锁

其实就是说:当两个线程同时针对同一个对象加锁,就会产生锁竞争,如果两个线程正对不同的对象加锁,就不会产生锁竞争。

在这里插入图片描述
举一个例子:

锁竞争 在这里插入图片描述

synchronized用的锁存在Java对象头里的

在这里插入图片描述

我们可以这样认为:每个对象在内存中存储的时候,都存有一块表示当前的"锁定"状态(类似于厕所有人/无人)

如果当前是"无人"状态,那么就可以使用,使用是需要设置为"有人状态"

如果当前是"有人"状态,那么其他人无法使用,只能排队

阻塞等待:

在这里插入图片描述

理解"阻塞等待"

针对每一把锁,操作系统内部都会维护一个等待队列,当这个锁被某个线程占用的时候,其他线程尝试进行加锁,就加不上,就会阻塞等待,就一直阻塞等待,直到之前的线程解锁之后,有操作系统华兴一个新的线程,在来获取到这个锁。

注意:

  • 上一个线程解锁之后,下一个线程并不是立即就能获得到锁,而是要靠操作系统来"唤醒",这也就是操作系统线程调度的一部分工作。(就如上面的例子,滑稽老铁1号分手之后,滑稽老铁2号没有及时知道一样,需要有其他人告诉滑稽老铁1号此时已经分手了,这就是操作系统要通过调度下线程,让这个线程处于就绪状态)
  • 假设有 A B C 三个线程,线程A 先获取到锁,然后 B 想尝试获取锁 然后 C 再向尝试获取锁,此时 B 和 C 都在阻塞队列中排队等待,但是当 A 释放锁之后,虽然B 比 C 先来,但是 B 不一定就能获取到锁,而是和 C 从新竞争,并不最需先来后到的规则。

synchronized的底层时使用操作系统的 mutex lock实现的。

2.2 刷新内存

synchronized的工作过程:

  1. 获得互斥锁
  2. 从主内存拷贝变量的最新副本到工作的内存
  3. 执行代码
  4. 将更改后的共享的值刷新到主内存
  5. 释放互斥锁

所以synchronized 也能保证内存可见性。

2.3 可重入

synchronized 同步块对同一条下次讷航来说是可重入的,不会出现自己把自己锁死的问题。

那么什么是把自己锁死呢?这就要讨论一下死锁了。

所谓的死锁就是对同一个代码块加了两层锁。 在这里插入图片描述

按照之前的对于所得设定,第二次加锁的时候,就会阻塞等待,直到第一次的锁释放,才能获取到第二个锁,但是释放第一个锁也要该线程来完成,结果这个相爱难成已经躺平了,啥都不想干(原因是代码块没有执行完,第一次加的锁,无法释放),也就无法进行解锁操作,这就会**“死锁”**

public static Object locker = new Object();
public synchronized void increase(){
    synchronized(locker){
        count++;
    }
}

上述的这种代码,在我们的实际开发中,稍不留神,就容易写出来了,如果代码真的死锁了,那么程序员的bug就太多太多了吧。

我们来分析一下这个代码:

在这个代码中,外层先加了一次锁,里层有对同一个对象在加了一次锁

外层锁: 进入方法,开始加锁,这次加锁能够成功,当前锁是没有人占用的

里层锁: 进入代码块,开始加锁,这次加锁不能加锁成功(按照咱们之前的观点分析)因为锁被外层占用着,得等到外层锁释放了之后,里层锁才能加锁成功。

外层锁要执行完整个方法,才能释放锁。

但是要想执行整个方法没救要让里层锁加锁成功才能往下走。

Java中的 synchronized 是可重入锁,因此没有上面的问题

实现JVM的大佬们显然也意识到了这个问题,就把synchronized事项成了可重入锁,对于可重入锁来说,上述的加锁操作,不会导致死锁

可重入锁描述:

可重入锁内部会记录当前的锁被哪个线程占用着,同时也会记录一个"加锁次数"。

例如还是上面的那个被两个 synchronized 修饰的代码块,此时线程a 针对第一次加锁的时候,显然是能够加锁成功的,锁内部记录了当前占用这 线程a ,同时加锁次数为1,后续如果在对线程 a 加锁,此时就不是真正的加锁,而是单纯的把奇数给自增,加锁次数为2,后续在解锁的时候,先把奇数进行-1,当锁的计数加到0的时候,就真的解锁。

可重入锁的意义就是降低了程序员的负担,提高了开发的效率,但是锁属于也付出了代价,在程序中需要更多的开销,维护锁属于哪个线程,并且加减计数,降低了运行效率。

那么让开发效率高一点还是让运行效率高一点呢?

其实判断两个东西,哪个好,咱们得有一般尺子,那就是程序员的核心价值观(程序员的幸福指数:挣钱多,加班少)

我们可以设想一下,如果使用了不可重入锁,此时的开发效率就会降低,但是运行效率就会提高。

但是,一旦代码写出死锁了,线上的程序就会出现bug,程序员就要加班加点的修复bug,同时如果这个bug的级别比较严重,那么此时该程序员的年终奖就没了。所以说程序员来说,该干完的是干完了就行了,开发效率是非常重要地。说实话,程序员的主要工作就是驱动机器来干活,咱们能让人少干一点,就少干一点,能让机器多干亿点,都是很好的。

那么此时就会有些老铁说:我听过一个名词好像叫,敏捷模型,这个能提高我们的开发效率吗?

其实所谓的敏捷开发就是一个"伪命题"

敏捷开发的宣言是:程序员每天工作不应该超过8小时。(此时就呵呵了,在国内那个互联网工作不加班?)

造成死锁的其他场景

第一种: 一个线程,一把锁(不可重入锁)

第二种: 两个线程,两把锁

举一个例子:就比如说,我和我老妈,要饺子馆吃饺子,我吃饺子的时候喜欢蘸醋,我老妈吃饺子的时候喜欢蘸酱油。

但是我们后来,醋和酱油都蘸。

于是我拿起来醋,我妈拿起了酱油

我说:你把酱油给我,

老妈说:你把醋给我,

我说:你先把酱油给我,我用完之后,就给你醋

老妈说:你先把醋给我,我用完之后,就给你酱油。 于是这样僵持不下,我不给你,你也不跟我

就相当于此时有两个线程,一个是是线程 A 另一个是线程 B,上面例子中的醋和酱油就相当于两把锁 分别是锁1 和 锁 2。此时线程 A 此时拥有锁1,线程B此时拥有锁B,

但是现在线程A要想获得 锁2 那么要等线程B 把锁2修饰的代码块执行完,锁2释放之后,再修饰线程A。

线程B要想获得 锁1 那么就要等线程A 把锁1修饰的代码执行完,锁1释放之后,再修饰线程B.

第三种: n个线程,m把锁

这个情况比较复杂,我们现在使用一个教科书上的经典案例来描述----哲学家就餐问题

在这里插入图片描述

那么我们又什么行之有效的办法,让上面的哲学家能够吃上面条呢(线程可以获取到两把锁(其实就是一把锁[可重入锁]))?答案肯定是有的。

在这里插入图片描述

生成死锁的四个必要条件

  • 互斥使用,一个锁被一个线程占用之后,其他线程占用不了这把锁(锁的本质,保证原子性)

  • 不可抢占,一个锁被一个线程占用之后,其他的线程不能把这个锁抢走。(挖墙脚是不行的)

  • 请求的保持,当一个线程占据了多把锁之后,除非显示的释放锁,否则这些锁始终是被该线程持有的 (前面说的这三条都是锁的本身特点)

  • 环路等待,等待关系,成环了,正如我们上面所说的哲学家就餐问题,就是一个典型的环路等待,A 等 B B 等 C C等 A

如何避免出现环路等待?

只要约定号,针对多吧锁加锁的时候,有固定的的顺序即可,所有的下次讷航那个都遵循同样的规则顺序,就不会出现环路等待。

但是我们在实际开发的时候,很少出现这种一个线程需要锁中套锁的情况。

在这里插入图片描述

2.4 synchronized关键字的具体使用

synchronized 本质上是修改指定对象的"对象头",从使用角度来看,synchronized 也势必要搭配一个具体的对象来使用

2.4.1 直接修饰普通方法

public int count = 0;
//如果直接使用synchronized 修饰普通方法,也就相当于把锁对象指定为this
public synchronized void increase(){
     count++;
}

2.4.2 修饰静态方法

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

如果把synchronized 加到静态方法上(静态方法 有this吗?),其实所谓的静态方法,更严谨的说,就是类方法,欧通的方法,更严谨的说就是“实例方法”,其实在静态方法中加synchronized就是在针对类对象进行加锁

那么就可以引申出:

public static int count = 0;
class Count{
	public void increase(){
		count++
	}
}
publci static void func(){
	synchronized(Count.class){
	 //这里的Count.class 就是类对象,就是咱们在运行程序的时候. .class文件加载到JVM内存中的模样
        //我们可以回想到,以前学到的反射机制,正是因为有了类对象,所以在反射机制中,我们才可以看到,类中的方法名,变量等赋值参数
	}
}

2.4.3 修饰代码块

public int count = 0;
public void increase(){
    //这里的this 就是一个锁对象,如果针对某个代码块进行加锁,就需要手动的指定,锁对象是撒(针对那个对象加锁)
    synchronized(this){
        count++;
    }
}
public int count = 0;
//创建锁对象
public Object locker = new Object();
public void increase(){
	synchronized(locker){
		count++;
	}
}

3.Java标准库中的下线程安全类

java有很多现成的类,有些实现线程安全的,有些不是安全的,在多线程环境下,如果使用线程不安全的类,就需要谨记。

在这里插入图片描述

4.volatile 关键字

4.1 volatile 能保存内存可见性

禁止编译器优化,保证内存可见性。
在这里插入图片描述

JMM(Java Memory Model)java内存模型

JMM就是把上述的硬件结构,在Java中用专门的术语有重新抽象封装了一遍

在这里插入图片描述

CPU从内存中读取数据的时候,取得太慢了,尤其是频繁的取操作,那么就可以把这样的数据直接放到寄存器里,后面直接从寄存器来读(但是寄存器空间太紧张),于是CPU又另外搞出了一个存储空间,这个空间比寄存器大,但是比内存小,速度比寄存器慢,但是比内存块,它成为缓存(cache)

在这里插入图片描述

4.2 volatile 不保证原子性

volatile只能保证内存可见性,不能保证原子性,vloatile 只是抱枕一个线程对一个线程写的情况。

但是synchronized既能保证原子性又能保证内存可见性。

在有些面试体重,也会偶尔的问一下 volatile 和 synchronized的区别

但是这两个关键字,本身就没有什么区别,他们在java恰好是关键字而已。

在回答面试官问题时:就回答synchronized的功能和volatile的功能即可。

synchronized的功能: 可以保证操作变量的原子性,同时可以保证内存可见性,在读取数据的时候,每次都在内存中读取,还可以避免指令重排序

volatile的功能: 可以保证内存可见性,但是不能保证操作变量的原子性,同时也不能避免指令重排序。

class Counter {
    volatile public int count;
    public void increase() {
        count++;
    }
}

public class demo1 {
    private static Counter counter = new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

运行结果在这里插入图片描述

4.3 synchronized 也能保证内存可见性

那么我们知道synchronized关键字的功能有这么多,那么我们在多线程环境下,直接无脑使用synchronized关键字就完了嘛?

其实我们不能无脑使用。

因为使用synchronized关键字的使用是要付出代价的,代价就是一旦使用了synchronized就很容易导致线程阻塞,一旦线程阻塞(线程此时放弃了CPU),下次回到CPU,这个时间就是不可控的(可能是沧海桑田),如果跳读不回来,自然对应的任务执行时间就会被拖慢

一句不客气的话,如果这个代码中使用到了synchronized,那么这个代码大概率就和"高性能"无缘了,还有volatile关键字,不能呆滞线程阻塞

5. wait 和 notify

由于线程之间是抢占式执行的,因此线程之间执行的先后顺序就难以预测,但是我们在实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序,让线程的执行是可控的。

相信我们有的老铁喜欢打篮球,在球场中每个运动员都是一个独立的执行流,我们可以认为是一个线程。而我们完成一个进攻得分操作,要有好几个运动员一起配合,按照一定的顺序执行一定的动作,线程1,先传球,然线程2负责扣篮。

完成这个协调工作主要涉及三个方法:

wait()/wait(long outtime):当前线程进入等待状态(当前下次线程在阻塞等待状态,放弃了CPU)

notift()/notifyAll():唤醒在阻塞队列中的线程,让这个线程处于就绪状态

5.1 wait()方法

wait()方法要做的事:

  • 使当前执行的代码进程处于阻塞等待状态,(把线程放到等待队列中)

  • 释放当前锁

  • 满足一定的条件是被唤醒,重新尝试获得这个锁。

    wait 要搭配,synchronized 来使用,脱离了 synchronized 使用wait 会直接抛出异常

wait结束等待的条件:

  • 其他线程调用对象的notify()方法
  • wait等待的时间超过了wait(outtime),时间超过了outTime
  • 其他线程调用该等待线程的 interrupted方法,导致wait抛出InterruptedException异常
public class demo2 {
    public static Object locker = new Object();
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            synchronized (locker){
                System.out.println("wait之前");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("wait之后");
            }
        });
        t.start();
    }
}

当前代码运行之后,只会打印"wait之前",那么是为什么呢?

其实就是因为在打印“wait之前”,之后执行locker.wait()操作,此时t线程就会处于阻塞等待状态,需要别的线程使用notify()去唤醒这个线程(此处说的使用别的线程指的是同一个进程中的线程)

5.2 notify()方法

notify()方法的主要功能: 唤醒在阻塞等待状态下的线程。

方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的

其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。

如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 “先来后到”)

在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行

完,也就是退出同步代码块之后才会释放对象锁。

public class demo2 {
    public static Object locker = new Object();
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            synchronized (locker){
                System.out.println("wait之前");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("wait之后");
            }
        });
        t.start();
        Thread t2 = new Thread(()->{
            synchronized(locker){
                System.out.println("notify之前");
                locker.notify();
                System.out.println("notify之后");
            }
        });
        t2.start();
    }
}

在这里插入图片描述

5.3 notifyAll()方法

notify()方法只能唤醒一个等待下承诺恒,使用notifyAll()可以一次性把所有的等待线程全部唤醒。

但是同时唤醒多个等待线程,这么多的等待线程就需要竞争锁,所以并不是同时执行被唤醒线程中的代码,而是任然有先后顺序的执行。

5.4 notify()和notifyAll()的区别

图文演示:

在这里插入图片描述

在这里插入图片描述

  • notify(),唤醒在等待队列中的线程是随机的,但是notfiy()不会像notifyAll()一样,把等待队列中的所有线程有唤醒,唤醒之后还存在着大量的锁竞争**
  • 相对来说,我们平时在编码的时候,使用notify()居多

5.5 wait 和 sleep 的对比

其实理论上wait()和sleep()完全是没有可比性的,因为一个线程之间通信的,一个是让线程阻塞一段时间。他们唯一的相同带你就是可以让线程放弃执行一段时间

  • wait()需要搭配synchronized使用 sleep()不需要
  • wait()是Object的方法 sleep()是Thread的静态方法。
  • 6
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 7
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

小周学编程~~~

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

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

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

打赏作者

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

抵扣说明:

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

余额充值