什么是死锁?怎么解决死锁?

前情提要

 上一篇中我们已经对Java的并发编程有了一个粗略的认知,这一篇中我们就来了解一下我们在享受多线程给我们带来便利的同时又给我们带来了什么样问题?以及又该怎么去解决这些问题?
 如果对于多线程没有什么概念的朋友,请移步至上一篇,传送门在文末。

多线程带来的问题

 总的来说,多线程的设计给我们带来了线程不安全、死锁、内存泄漏及性能问题。本篇会涉及到线程不安全和死锁的相关说明,对于内存泄漏和性能问题不在这一篇的介绍范围内。而且,关于多线程带来的内存泄漏和性能问题不是我们的重点,不在本系列专题中。如果后续有需要,我会在专题结束后补充这方面的内容。

线程不安全

 线程不安全这个词我们大家都常常挂在嘴边或听同事谈起,那么到底什么是线程不安全呢?

线程不安全是什么

 关于线程安全的定义,在《Java Concurrency In Practice》一书中这样说到:“当多个线程线程同时访问一个对象,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那么就称这个对象是线程安全的。”

 事实上,上面的定义相当严谨,把所有可能对线程安全的定义存在理解偏差的情况都排除在外了。所以我们可以直接说,不满足上面的任何一点都不能称之为线程安全。
 如果你对多线程有一些了解的话,我相信单从上面的定义中,你就能得到很多信息。比如:线程安全是针对对象来说的,我们讨论的线程安全都是指的这个对象是不是线程安全的。只有明白了这一点,我们才可以接着往下去深究。

为什么会发生线程不安全

 为了方便真实的演示线程不安全的情况,我们先来预定义一个业务场景,根据业务场景敲一段示例代码,然后跑代码观察实际的执行情况。
 我们定义的业务场景是这样的:

开启两个线程,实现一个数从10自减为0的过程

 一段简单的代码就像下面这样产生了:

    public class Decrement  implements Runnable{
        private int initialNumber;
        {
            initialNumber = 10;
        }
        @Override
        public void run() {
            while (true) {
                if (initialNumber > 0) {
                    try {
                        // 没啥实际意义,更方便演示问题
                        Thread.sleep(10);
                        System.out.println("线程:" + Thread.currentThread().getName()
                                + "已完成Decrement.initialNumber的自减,当前值为:" + initialNumber--);
                    }catch (Exception e){
                        // 这部分省略
                    }
                }
            }
        }
    }
    public class ClinitTest {
        public static void main(String[] args){
            Decrement.SelfSub selfSub = new Decrement.SelfSub();
            Thread t1 = new Thread(selfSub,"first");
            Thread t2 = new Thread(selfSub,"second");
            t1.start();
            t2.start();
        }
    }

 执行上面的代码,会发现输出可能是下面这样的。因为不可预测性,所以大家看到的输出结果可能和我这边的不一样,但是结果是类似的。

    // 线程:second已完成Decrement.initialNumber的自减,当前值为:9
    // 线程:first已完成Decrement.initialNumber的自减,当前值为:10
    // 线程:first已完成Decrement.initialNumber的自减,当前值为:8
    // 线程:second已完成Decrement.initialNumber的自减,当前值为:7
    // 线程:second已完成Decrement.initialNumber的自减,当前值为:6
    // 线程:first已完成Decrement.initialNumber的自减,当前值为:6
    // 线程:second已完成Decrement.initialNumber的自减,当前值为:5
    // 线程:first已完成Decrement.initialNumber的自减,当前值为:5
    // 线程:second已完成Decrement.initialNumber的自减,当前值为:4
    // 线程:first已完成Decrement.initialNumber的自减,当前值为:3
    // 线程:second已完成Decrement.initialNumber的自减,当前值为:2
    // 线程:first已完成Decrement.initialNumber的自减,当前值为:1

 在单线程中,正确的输出应该是10到0的有序结果。而事实上,上面的输出结果能看出些问题了,出现了两次6和两次5,所以这段代码是有问题的。基于上面的情况,我们就可以说这段代码线程不安全。

 线程安全性的问题归根结底其实是多个线程同时操作共享对象造成的,这就意味着只要一个对象能被多个线程共同访问,那么这个对象就有可能不是线程安全的。

线程不安全如何解决

 Java中线程不安全的解决办法有很多,归根结底就两点:避免共享和同步。其中,避免共享有使用ThreadLocal变量和使用不可变对象(比如String类、CopyOnWrite集合类);同步细分的话又分为互斥同步(synchronized、Lock接口等)和非阻塞同步(CAS等)。

 例如上面的例子,我们用synchronized关键字去实现同步代码块时,只需将run方法改为如下的内容:

    @Override
    public void run() {
        while (true) {
            if (initialNumber > 0) {
                synchronized (this) {
                    try {
                        Thread.sleep(1);
                        System.out.println("线程:" + Thread.currentThread().getName()
                                + "已完成Decrement.initialNumber的自减,当前值为:" + initialNumber--);
                    } catch (Exception e) {
    
                    }
                }
            }
        }
    }
    // 输出
    // 线程:first已完成Decrement.initialNumber的自减,当前值为:10
    // 线程:first已完成Decrement.initialNumber的自减,当前值为:9
    // 线程:first已完成Decrement.initialNumber的自减,当前值为:8
    // 线程:first已完成Decrement.initialNumber的自减,当前值为:7
    // 线程:first已完成Decrement.initialNumber的自减,当前值为:6
    // 线程:first已完成Decrement.initialNumber的自减,当前值为:5
    // 线程:first已完成Decrement.initialNumber的自减,当前值为:4
    // 线程:second已完成Decrement.initialNumber的自减,当前值为:3
    // 线程:second已完成Decrement.initialNumber的自减,当前值为:2
    // 线程:second已完成Decrement.initialNumber的自减,当前值为:1
    // 线程:first已完成Decrement.initialNumber的自减,当前值为:0

 同步这块内容在此就不展开细说了,在后面的章节中会涉及到相关的内容。
*关于这块内容在后面的文章中会详细介绍,此处应插眼。*

关于线程死锁

 当我们在线程同步的时候,如果发生了多个线程无限期阻塞,那么很有可能就发生了线程死锁。

死锁是什么

 线程死锁描述的其实是这样的一类问题:在某一时刻,多个线程都处于阻塞状态,尽管时间流逝,而这些线程不能从阻塞状态中被解放出来。死锁发生的原因是互相等待资源而不释放现有的资源。这句话有点绕,画个图来演示一下这个情况。


死锁示意图

 如上图所示,在某一时刻,"一个线程"已经持有"一个资源",还需要"另一个资源";而"另一个线程"持有"另一个资源",需要"一个资源"。这样,这两个线程都会因为拿不到需要的资源而一直处于等待状态中,这就是死锁。

产生死锁的必要条件

 死锁产生的必要条件有四个,如下。

  1. 互斥条件:共享资源任意一个时刻只由一个线程持有;
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已持有的资源保持不放;
  3. 不剥夺条件:线程已获得的资源在末使用完之前无法被其他线程强行剥夺,只有自己使用完毕后才主动释放资源;
  4. 循环等待条件:若干线程之间形成一个头尾相连的循环等待资源关系。

怎么解决死锁

 只需要破坏任意一个必要条件,就能解放死锁。分别讨论一下可行性:

  1. 互斥条件:这个不可能被破坏,共享资源本身就要求互斥;
  2. 请求与保持条件:这个可行。在申请资源的时候一次性申请所有需要的资源,避免因持有部分资源无法释放而死锁;
  3. 不剥夺条件:可行。如果一个线程持有部分资源,在申请另外的资源未果后,立即释放持有的资源,供其他线程使用;
  4. 循环等待条件:靠特殊顺序申请资源来预防死锁发生。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件;

模拟死锁

 我们可以来模拟一下死锁的发生情况,如下代码片段:

    public class DeadLock {
        private static Integer integer1 = 50;
        private static Integer integer2 = 100;
    
        public static class OneThread implements Runnable{
            @Override
            public void run() {
                synchronized (integer1) {
                    System.out.println(Thread.currentThread().getName() + "持有资源integer1");
                    try {
                        // 防止线程太快执行完毕
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "等待资源integer2");
                    synchronized (integer2) {
                        System.out.println(Thread.currentThread() + "持有资源integer2");
                    }
                }
            }
        }
    
        public static class AnotherThread implements Runnable{
            @Override
            public void run() {
                synchronized (integer2) {
                    System.out.println(Thread.currentThread().getName() + "持有资源integer2");
                    try {
                        // 防止线程太快执行完毕
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "等待资源integer1");
                    synchronized (integer1) {
                        System.out.println(Thread.currentThread() + "持有资源integer1");
                    }
                }
            }
        }
    
        public static void main(String[] args) {
            OneThread oneThread = new OneThread();
            AnotherThread anotherThread = new AnotherThread();
            Thread thread1 = new Thread(oneThread,"一个线程");
            Thread thread2 = new Thread(anotherThread,"另一个线程");
            thread1.start();
            thread2.start();
        }
    }
    // 执行结果
    // 一个线程持有资源integer1
    // 另一个线程持有资源integer2
    // 一个线程等待资源integer2
    // 另一个线程等待资源integer1

 从上面的结果可以看到已经发生了死锁,下面我们来解决这个死锁。
(一)破坏请求与保持条件

    public static class OneThread implements Runnable{
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + "等待资源integer1");
                System.out.println(Thread.currentThread().getName() + "等待资源integer2");
                synchronized (DeadLock.class) {
                    System.out.println(Thread.currentThread().getName() + "持有资源integer1");
                    try {
                        // 防止线程太快执行完毕
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "持有资源integer2");
                }
            }
        }
        public static class AnotherThread implements Runnable{
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + "等待资源integer2");
                System.out.println(Thread.currentThread().getName() + "等待资源integer1");
                synchronized (DeadLock.class) {
                    System.out.println(Thread.currentThread().getName() + "持有资源integer2");
                    try {
                        // 防止线程太快执行完毕
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "持有资源integer1");
                }
            }
        }

 破坏请求与保持条件要求需要一次性申请所有的资源,要么全部申请成功并持有,要么阻塞不持有任何资源,这里我们用了synchronized (DeadLock.class)来模拟这一实现。在虚拟机的类加载机制中我们提到所有的静态成员都是属于类本身的,我们在此处给类加synchronized的意义就相当于一次性申请该类(事实上也是java.lang.Class对象)的所有资源,包括了integer1和integer2。这样一个线程要么integer1和integer2都拿到,要么都拿不到。如果你的资源是实例成员,那么此处应该锁实例。
(二)破坏不剥夺条件

    public static class OneThread implements Runnable{
            @Override
            public void run() {
                synchronized (integer1) {
                    System.out.println(Thread.currentThread().getName() + "持有资源integer1");
                    try {
                        // 防止线程太快执行完毕
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println(Thread.currentThread().getName() + "等待资源integer2");
                synchronized (integer2) {
                    System.out.println(Thread.currentThread().getName() + "持有资源integer2");
                }
            }
        }
        public static class AnotherThread implements Runnable{
            @Override
            public void run() {
                synchronized (integer2) {
                    System.out.println(Thread.currentThread().getName() + "持有资源integer2");
                    try {
                        // 防止线程太快执行完毕
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println(Thread.currentThread().getName() + "等待资源integer1");
                synchronized (integer1) {
                    System.out.println(Thread.currentThread().getName() + "持有资源integer1");
                }
            }
        }

 破坏不可剥夺条件的意思就是当一个资源使用完毕时,线程主动释放该资源,而不是一直持有。在上面的代码片段中,我们把所有资源分开申请,分开释放,不使用嵌套的synchronized。这样就避免了一个线程因为申请不到一个资源而一直持有另外的资源,而这些资源正是另外的线程所等待的。

 这里所指的破坏不可剥夺是指线程本身释放已经使用完毕的资源。并不是说当一个资源正在被使用时,持有权被剥夺了;也不是说这个剥夺的过程是由其他线程来实施的。
(三)破坏循环等待条件

    public static class OneThread implements Runnable{
            @Override
            public void run() {
                // 按照hashCode值大小顺序来申请资源
                if(integer1.hashCode() > integer2.hashCode()){
                    synchronized (integer1) {
                        System.out.println(Thread.currentThread().getName() + "持有资源integer1");
                        try {
                            // 防止线程太快执行完毕
                            Thread.sleep(100);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        System.out.println(Thread.currentThread().getName() + "等待资源integer2");
                        synchronized (integer2) {
                            System.out.println(Thread.currentThread().getName() + "持有资源integer2");
                        }
                    }
                }else {
                    synchronized (integer2) {
                        System.out.println(Thread.currentThread().getName() + "持有资源integer2");
                        try {
                            // 防止线程太快执行完毕
                            Thread.sleep(100);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        System.out.println(Thread.currentThread().getName() + "等待资源integer1");
                        synchronized (integer1) {
                            System.out.println(Thread.currentThread().getName() + "持有资源integer1");
                        }
                    }
                }
            }
        }
        // AnotherThread略

 所有线程按照特定的顺序去申请资源,倒序释放资源,就不会发生持有资源又申请不到另外资源的情况了。

扩展区域

扩展区域主体

这是一个没有实现的扩展。


上一篇:你必须应该掌握的Java并发基础
下一篇:面试常备-线程池工作原理分析

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349