【java多线程】线程安全是如何保证的?什么是死锁?含大量图解~~

线程安全是如何产生的?

想象一下你的代码是个夜店,单线程时一切都井然有序,但多线程就像突然来了群嗨过头的客人…

线程安全问题就像一群人在抢最后一杯奶茶:

//cnt初始=0
  Thread t1 = new Thread(()->{
          for (int i = 0; i < 50000; i++) {
              cnt+=1;
      }
  });
  
  Thread t2 = new Thread(()->{
        for (int i = 0; i < 50000; i++) {
            cnt += 1;
        }
});

当这两个线程同时运行的时候,我们最后是想让cnt最后等于100000的,但是这两个线程同时哄抢同一个变量的时候,会有人偷摸不付钱!

线程t1读取cnt(0)

线程t2也读取cnt(0)

线程t1加1后写入(1)

线程t2加1后也写入(1)

结果:两杯奶茶的钱,只喝到一杯 😭

多线程在运行的时候会遇见很多种情况,而cnt+=1在cpu执行的时候是分为三个步骤来执行的:

✨ 读取load(cnt)
✨ 增加add(cnt)
✨ 写入save(cnt)

而如果在执行的过程中出现如图类似的情况,那么你的两次+1操作会被吞掉一个。
在这里插入图片描述
CPU的线程调度是随机的,程序员无法控制它,所以在执行的时候会有无限的情况,因此cnt的值永远也不会为100000。

这是一个典型的线程安全问题。

线程安全产生的原因

根本原因

▌ 操作系统对于线程的调度是随机的(抢占式执行)

直接原因

├─ 🟢 共享数据:多个线程对同一个变量进行修改
├─ 🟠 非原子操作:修改操作不是原子的
└─ 🔴 执行顺序不可控:线程交替执行的顺序不确定


临时解决方案(局限性)

让t1完全执行完再执行t2(串行化)
t1.start();
t1.join();  
t2.start();

✖ 缺点:失去了多线程的并发优势

而这时我们就需要掏出我们的大法宝,锁~

锁的本质概念

锁:和生活中的锁一样的概念,互斥、排他,一旦把锁加上了,其他人想要加锁,就得阻塞等待。

锁如何保障线程安全

线程安全问题的根源

我们之前分析过线程安全产生的原因,其中关键一点是:

  • 操作不是原子性的:指在CPU层面的操作不是单个命令,这会给其他线程可乘之机

锁的解决方案

Java的锁机制可以将多个操作封装在一起,使这一系列命令成为一个不可分割的整体,从而具备原子性。

我们依旧使用上述的案例来举例子,如果我们对其加上锁,它的执行过程会变成这样:
在这里插入图片描述

锁的使用

synchronized关键字

在java中,我们使用synchronized对要执行的任务进行加锁,synchronized在汉语中是同步的意思,但是在里面是互斥的含义。

synchronized(锁对象) {
    // 临界区代码
    // 任意Java对象都可作为锁
    // 重要的是多个线程要竞争同一把锁
}
示例

之前的代码我们可以这样修改~

Object locker = new Object(); 
int cnt = 0;

Thread t1 = new Thread(()->{
    synchronized (locker) {  // 获取锁
        for (int i = 0; i < 50000; i++) {
            cnt += 1;
        }
    }  // 释放锁
});

Thread t2 = new Thread(()->{
    synchronized (locker) {  // 获取同一把锁
        for (int i = 0; i < 50000; i++) {
            cnt += 1;
        }
    }  // 释放锁
});
锁对象的选择要点

这里的要传入的对象可以理解为门牌号,当一个线程进入这个门的时候会把这个门锁上,执行完离开的时候才会解锁。如果这个时候其他线程也想进入这个门就得等里面的进程执行完。

但如果我们两个进入的压根不是同一个门,那当然也就不会产生这种烦恼了~

这里的锁对象没有固定的类型,而且将它作为锁也不会影响这个对象的正常使用,但是为了不影响业务逻辑我们一般还是会单独开一个变量来当做锁。

synchronized修饰方法

此外我们可以使用这个关键字来修饰方法,来简化我们的使用。

public synchronized void add() {
 	cnt++;
}
     Object locker = new Object();

 Thread t1 = new Thread(()->{
         for (int i = 0; i < 50000; i++) {
             this.add();
         }
 });

这个方法本质上是使用了this当做关键字。
在这里插入图片描述


讲了这么多,锁也并不是越多越好的!一旦你的代码中使用了锁,意味着你的代码就可能会产生锁竞争导致阻塞,这会导致你程序的执行效率大大折扣。

所以不需要锁的时候不要乱加!

死锁

死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,这些线程都无法继续执行下去。

死锁主要是存在三种类型

单线程,单锁

示例
Thread t1 = new Thread(()->{
    synchronized (locker) {
        synchronized (locker) {
            //运行内容
        }
    }
});

分析一下这段代码

  • 🔒 第二层同步块尝试获取已被当前线程持有的locker锁

  • ⏳ 按照基础锁理论,这会导致线程阻塞等待自己释放锁

  • 🚫 形成看似"死锁"的状态(线程等待自己)

可重入机制

但是在java中我们并不需要考虑这种情况,因为设计java的大佬考虑到了这种情况,为了解决上述问题,java的synchronized引入了可重入的概念,所以实际上并不会卡住。

当某个线程针对一把锁加锁成功后,后续再对这个锁进行加锁并不会触发阻塞,而是直接往下走。

它的实现原理是让锁对象内部保存当前线程持有那把锁,当后续线程针对这个锁加锁的时候进行对比,如果是同一个就计数器+1,否则正常运行。

加锁流程:

线程首次加锁成功 → 记录持有线程 + 计数器=1
↓
同一线程再次加锁 → 计数器+1(不阻塞)
解锁流程:

解锁流程

每次解锁 → 计数器-1
↓
计数器归零 → 真正释放锁

可重入的概念是针对同一个线程的同一把锁连续加锁多次的情况。最外层真正加锁,最外层真正解锁。

两个线程两把锁

当这两个线程去互相去拿对方的锁的时候可能会出现死锁的状况。

Thread t1 = new Thread(() -> {
    synchronized (locker1) {
        synchronized (locker2) {
            System.out.println("666");
        }
    }
});

Thread t2 = new Thread(() -> {
    synchronized (locker2) {
        synchronized (locker1) {
            System.out.println("777");
        }
    }
});

他执行的时候有两种可能性

情况1

在这里插入图片描述

情况2

在这里插入图片描述
而这种情况是需要自己来考量的,java并没有相应机制来应对

n个线程,m把锁

这是最复杂的一种状况,发生概率低,但是在服务器庞大的基数下依旧风险很大
在这里插入图片描述
和上一中情况类似,也是当发生互相纠缠的时候可能产生死锁。

如何避免代码出现死锁?

死锁构成条件:

  1. 锁是互斥的,一个线程拿到锁后,另一个线程再尝试获取锁,必须阻塞等待;(锁的基本性质)

  2. 锁是不可抢占的,线程1拿到后其他线程要想获取必须等待,不可以直接抢过来;(锁的基本特性)

  3. 请求和保持,一个线程在拿到锁1后,不释放锁1的前提下,获取锁2;

  4. 循环等待,多个线程之间的等待关系构成了循环;

1和2是基本特征,所以破坏3和4是关键。

方法1:加锁过程不再去嵌套,通用性不强,有些情况难以避免;

方法2:约定加锁顺序,破除循环等待;


感谢各位的观看Thanks♪(・ω・)ノ,如果觉得满意的话留个关注再走吧。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值