「JavaEE」线程安全1:成因&死锁

发布于:2024-05-02 ⋅ 阅读:(37) ⋅ 点赞:(0)

🎇个人主页Ice_Sugar_7
🎇所属专栏JavaEE
🎇欢迎点赞收藏加关注哦!

🍉线程安全问题的成因

一个代码,如果在单个线程或多个线程下执行时都不会产生 bug,那么这个情况就称为线程安全
而如果这个代码在单线程下能正确运行,但是在多线程下可能会产生 bug,则称为线程不安全或存在线程安全问题

举一个线程不安全的典例:两个计算都让同一个数 sum 自增 5w 次,然后观察结果

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

但是我们发现结果不符合预期,那么这就是个 bug

在这里插入图片描述
在这里插入图片描述
除了结果不对之外,每次运行的结果还都不一样

接下来我们来分析一下原因
sum++ 看上去只有一步,但其实它是由三个 CPU 指令构成的:

  1. load:从内存中读取数据到 CPU 的寄存器
  2. add:把寄存器中的值 +1
  3. save:把寄存器的值写回到内存中

假设 sum 为 0,那么过程如下图:
在这里插入图片描述
如果只有一个线程执行上面三个指令,那肯定不会出问题
但如果有两个或多个线程并发执行上面的操作,那就不一定了(注意这里的“并发”是并发执行和并行执行的统称)
以两个线程 t1、t2 为例,因为线程之间调度的顺序是不确定的,所以两个线程这三个指令的执行顺序就无法确定,下面列举其中四种情况

在这里插入图片描述
我们列举的情况都是 t1 和 t2 只执行一次,实际上可能 t1 执行一次 ++ 时,t2 已经执行了两次甚至三次,所以实际是有无数种情况
虽然有很多种情况,但只有按照第二个线程的 load 在第一个线程的 save 之后这样的顺序,得到的结果才是正确的

这个其实挺好想的,因为第二个线程如果在第一个线程 save 之前 load,那么读取到 CPU 寄存器的数据就是第一个线程 add 之前的数据,如果第一个线程自增两次并save,然后第二个线程 save 的话,那就会把第一个线程的结果给覆盖掉,结果自然就是错的

由上面这个例子,我们可以总结出导致线程不安全的原因:

  1. 操作系统上的线程是“抢占式执行”/随机调度,这给线程之间的执行顺序带来了变数(根本原因,前面的文章强调过很多次)
  2. 代码中多个线程同时修改同一个变量。如果多个线程读取同一个变量,那不会出问题;修改不同变量也没事儿;但是如果修改同一个变量,那就会存在相互覆盖的情况(代码结构层面的原因)
  3. 自增这个操作,本身不是“原子的”(直接原因)

实际上还有两个原因:内存可见性问题、指令重排序问题。不过在上面的例子不涉及到,所以放到后面再解释

既然知道了原因,那就可以针对这些原因采取预防措施
根本原因肯定没法采取啥措施,因为机制本来就是这样
代码结构的原因得看具体情况,有时候代码结构可以调整,但是有时候没法调整

所以貌似只能针对直接原因入手了
count++ 看起来是生成三个指令,但是我们通过一些手段把这三个指令打包到一起,成为一个整体。这就是我们接下来要讲的加锁

锁是一种机制,用来控制对于共享资源的访问,它可以确保在同一时间内只有一个线程可以访问共享资源,其他线程必须等待获取锁后才能访问

加锁需要先准备好一个锁对象,加锁、解锁都是围绕它展开的,一般使用 Object 类的实例作为锁对象

一个线程在针对一个对象加锁后,其他线程如果也尝试给这个对象加锁,就会产生阻塞(就是处于 BLOCKED 状态,这个过程称为锁冲突/锁竞争),一直阻塞到前一个进程释放锁为止(被硬控了)
(如果两个线程是针对不同对象加锁,那就不会有锁竞争,也就不会阻塞了)
加锁方式有很多种,我们最主要使用 synchronized 关键字来加锁,我们在上面的代码中加入 synchronized:

在这里插入图片描述

在这里插入图片描述
加锁就是将 sum++ 的三个指令打包变成原子,所以加锁后 t1 和 t2 的 sum++ 部分是串行执行的(但是 for 循环部分是可以并发执行的),但在执行三个操作的过程中,加锁的线程是有可能被调度走的,不过即使如此,其他线程也无法“插队”执行

加锁虽然会影响到多线程的执行效率,不过还是比单线程串行执行快

上面我们是在 main 方法中让 sum 自增,我们也可以把 sum 放在一个类的实例中,这个类有一个 add 方法,通过 add 方法来修改 sum

public class MyThread {
    public static int sum = 0;

    public void add() {
        synchronized (this) {
            sum++;
        }
    }

    public static void main(String[] args) throws InterruptedException{
        MyThread mt = new MyThread();
        Thread t1 = new Thread(()-> {
            for(int i = 1;i <= 50000;i++) {
                mt.add();
            }
        });
        Thread t2 = new Thread(()-> {
            for(int i = 1;i <= 50000;i++) {
                mt.add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(sum);
    }
}

注意 add 方法中的锁对象写作 this,代表当前对象,谁调用这个方法就对谁加锁。显然 t1 和 t2 都是对 mt 加锁,所以这样也能保证线程安全

上面 synchronized (this) 也可以 synchronized 加到方法上,这两种写法是等价的

synchronized public void add() {
    sum++;
}

括号内除了写 this,我们也可以写类对象,因为一个 Java 进程中一个类只有一个类对象,所以 t1、t2 仍是对同一对象加锁,还是存在锁竞争

public void add() {
    synchronized (MyThread.class) {
        sum++;
    }
}

拓展:synchronized 如果加到静态方法上,那就等价于给类对象加锁

🍉可重入性

在同一个线程中,如果某个对象已经处于加锁的状态,那此时如果尝试再给它加锁不会出现阻塞的情况,这个特性就称为可重入性
原因也很简单,因为是同一线程,锁对象知道第二次加锁的线程就是持有锁的线程,所以第二次操作就直接放行通过,而不会出现阻塞

下面拿一段代码作为示例:

    public static void main(String[] args) throws InterruptedException{
        Object locker = new Object();
        Thread t = new Thread(()-> {
           synchronized (locker) {
               synchronized (locker) {
                   System.out.println("这是可重入锁");
               }
           }
        });
        t.start();
    }

可以顺利打印出“这是可重入锁”

在这里插入图片描述

C++ 中使用一个 std::mutex 的锁,它是不可重入的,所以对于上面的代码,就会出现阻塞,而且无法自动恢复,于是这个线程就卡死了。这种卡死的情况,称为死锁
可重入锁就可以避免上述出现死锁的情况

我们前面说 synchronized 在进大括号时加锁,出大括号解锁,那对于上面那种嵌套了两个 synchronized 的情况,什么时候加锁、解锁呢?
对于可重入锁,它内部有两个信息:

  1. 当前这个锁被哪个线程持有
  2. 加锁次数的计数器

计数器初始情况下为 0,每进一个 synchronized 的大括号后就会加 1,同时记录是哪个线程加锁。第二次加锁发现加锁线程和持有锁的线程是同一个线程,此时只对计数器加1,没有其他操作
每出一个 synchronized 大括号,计数器就会减1,当计数器减到0时,就会进行解锁

由此可以得到一个结论:进入 synchronized 最外层大括号时加锁;出最外层大括号时解锁

这样的机制可以保证即使嵌套了多层 synchronized,也不会使解锁操作混乱,始终可以在正确的时机解锁

🍉死锁

加锁能够解决线程安全问题,但如果加锁方式不当,就可能产生死锁
有三种典型的死锁场景:

  1. 一个线程一把锁
    一个线程如果对一个不可重入锁加锁两次,就会出现死锁
  2. 两个线程两把锁
    线程 1 获取到锁 A,线程 2 获取到锁 B。然后 1 尝试获取 B,2 尝试获取 A,就会出现死锁
public static void main(String[] args) throws InterruptedException{
    Object A = new Object();
    Object B = new Object();
    MyThread mt = new MyThread();
    Thread t1 = new Thread(()-> {
        synchronized (A) {
            try {
                //sleep 是为了给 t2 时间让它拿到 B
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            synchronized (B) {
                System.out.println("t1 拿到两把锁");
            }
        }

    });
    Thread t2 = new Thread(()-> {
        synchronized (B) {
            try {
                //sleep 是为了给 t1 时间让它拿到 A
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            synchronized (A) {
                System.out.println("t2 拿到两把锁");
            }
        }

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

程序运行起来后发现没有结果

在这里插入图片描述

查看线程状态,可以发现两个线程都处于 BLOCKED 状态

在这里插入图片描述

在这里插入图片描述

  1. N 个线程 M 把锁
    这里需要引入一个著名的问题——哲学家就餐问题

哲学家就餐问题是一个经典的并发编程问题,它讲的是五位哲学家围坐在一张圆桌上就餐,每个人需要两根筷子才能进餐,吃一些食物后会放下筷子去思考人生,现在有五根筷子,每根筷子刚好放在每两位哲学家中间

注:在这个问题中 N = M

在这里插入图片描述
因为每位哲学家啥时候吃饭,啥时候思考人生是不确定的,所以绝大部分情况下,上述模型是可以正常运行的
不过在一些极端情况下就会出问题
假设某一时刻,所有人都想吃饭,同时拿起左手的筷子,那此时当他们想再拿起右手的筷子,会发现根本拿不了。所有人都不想放下已经拿起来的筷子,都在等待旁边的人放下筷子,那就成为死锁了

🍌解决方案

要解决死锁问题,就需要了解产生死锁的条件,只要让产生条件无法成立(破坏条件),那就可以解决了
产生死锁有四个必要条件,缺一不可:

  1. 获取锁的过程是互斥的。就是一个线程拿到这把锁,另一线程想获取的话,就需要阻塞等待。这是锁最基本的特性,没法破坏
  2. 不可抢占。就是一个线程拿到锁之后,只能主动解锁,不能让别的线程强行抢走锁。这也是最基本的特性
  3. 一个线程拿到锁之后,在持有它的前提下,尝试获取另一个锁,但是这个锁被其他线程所持有,这个线程不会释放已持有的锁,并等待其他线程释放锁。这个涉及到代码结构,不一定可以破坏。如果可以破坏,我们一般会采用“请求保持”的策略:一个线程在获取锁之前必须释放已经拿到的锁
  4. 循环等待(环路等待)。线程与线程之间形成循环链,每个线程都在等待下一个线程释放其所持有的锁。以就餐问题为例,就是每个哲学家同时拿起左手的筷子,然后等待旁边的人放下筷子。这个最容易破坏,只需指定加锁顺序给锁进行编号,约定每个线程获取锁的时候一定要先获取编号小的锁,然后才能获取编号大的锁

在这里插入图片描述
在极端情况下,五个人同时伸手拿筷子,到最后 1 号或 5 号肯定能拿到一双筷子
其实有很多种方案可以解决死锁,比如引入额外的筷子(锁)、去掉一个线程、引入计数器来限制同一时刻最多有几个人吃饭…这些方案都不太复杂,但是普适性不高,有时候用不了。最通用的还得是上面所说的“引入加锁顺序的规则”

补充:实际上还有一种做法也能解决死锁问题——银行家算法,但是这种算法太复杂了,你写的算法本身很可能存在 bug,所以它只是“理论可行”而已,实践中不推荐这么做