【高并发】优化加锁方式时竟然死锁了!!

写在前面

今天,在优化程序的加锁方式时,竟然出现了死锁!!到底是为什么呢?!经过仔细的分析之后,终于找到了原因。

为何需要优化加锁方式?

在《【高并发】高并发环境下诡异的加锁问题(你加的锁未必安全)》一文中,我们在转账类TansferAccount中使用TansferAccount.class对象对程序加锁,如下所示。

public class TansferAccount{
    private Integer balance;
    public void transfer(TansferAccount target, Integer transferMoney){
        synchronized(TansferAccount.class){
        	if(this.balance >= transferMoney){
                this.balance -= transferMoney;
                target.balance += transferMoney;
            }
        }
    }
}

这种方式确实解决了转账操作的并发问题,但是这种方式在高并发环境下真的可取吗?试想,如果我们在高并发环境下使用上述代码来处理转账操作,因为TansferAccount.class对象是JVM在加载TansferAccount类的时候创建的,所有的TansferAccount实例对象都会共享一个TansferAccount.class对象。也就是说,所有TansferAccount实例对象执行transfer()方法时,都是互斥的!!换句话说,所有的转账操作都是串行的!!

如果所有的转账操作都是串行执行的话,造成的后果就是:账户A为账户B转账完成后,才能进行账户C为账户D的转账操作。如果全世界的网民一起执行转账操作的话,这些转账操作都串行执行,那么,程序的性能是完全无法接受的!!!

其实,账户A为账户B转账的操作和账户C为账户D转账的操作完全可以并行执行。所以,我们必须优化加锁方式,提升程序的性能!!

初步优化加锁方式

既然直接TansferAccount.class对程序加锁在高并发环境下不可取,那么,我们到底应该怎么做呢?!

仔细分析下上面的代码业务,上述代码的转账操作中,涉及到转出账户this和转入账户target,所以,我们可以分别对转出账户this和转入账户target加锁,只有两个账户加锁都成功时,才执行转账操作。这样就能够做到账户A为账户B转账的操作和账户C为账户D转账的操作完全可以并行执行。

我们可以将优化后的逻辑用下图表示。

根据上面的分析,我们可以将TansferAccount的代码优化成如下所示。

public class TansferAccount{
    //账户的余额
    private Integer balance;
    //转账操作
    public void transfer(TansferAccount target, Integer transferMoney){
        //对转出账户加锁
        synchronized(this){
            //对转入账户加锁
            synchronized(target){
                if(this.balance >= transferMoney){
                    this.balance -= transferMoney;
                    target.balance += transferMoney;
                }
            }
        }
    }
}

此时,上面的代码看上去没啥问题,但真的是这样吗? 我也希望程序是完美的,但是往往却不是我们想的那样啊!没错,上面的程序会出现 死锁, 为什么会出现死锁啊? 接下来,我们就开始分析一波。

死锁的问题分析

TansferAccount类中的代码看上去比较完美,但是优化后的加锁方式竟然会导致死锁!!!这是我亲测得出的结论!!

关于死锁我们可以结合改进的TansferAccount类举一个简单的场景:假设有线程A和线程B两个线程同时运行在两个不同的CPU上,线程A执行账户A向账户B转账的操作,线程B执行账户B向账户A转账的操作。当线程A和线程B执行到 synchronized(this)代码时,线程A获得了账户A的锁,线程B获得了账户B的锁。当执行到synchronized(target)代码时,线程A尝试获得账户B的锁时,发现账户B已经被线程B锁定,此时线程A开始等待线程B释放账户B的锁;而线程B尝试获得账户A的锁时,发现账户A已经被线程A锁定,此时线程B开始等待线程A释放账户A的锁。

这样,线程A持有账户A的锁并等待线程B释放账户B的锁,线程B持有账户B的锁并等待线程A释放账户A的锁,死锁发生了!!

死锁的必要条件

在如何解决死锁之前,我们先来看下发生死锁时有哪些必要的条件。如果要发生死锁,则必须存在以下四个必要条件,四者缺一不可。

  • 互斥条件

在一段时间内某资源仅为一个线程所占有。此时若有其他线程请求该资源,则请求线程只能等待。

  • 不可剥夺条件

线程所获得的资源在未使用完毕之前,不能被其他线程强行夺走,即只能由获得该资源的线程自己来释放(只能是主动释放)。

  • 请求与保持条件

线程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他线程占有,此时请求线程被阻塞,但对自己已获得的资源保持不放。

  • 循环等待条件

既然死锁的发生必须存在上述四个条件,那么,大家是不是就能够想到如何预防死锁了呢?

死锁的预防

并发编程中,一旦发生了死锁的现象,则基本没有特别好的解决方法,一般情况下只能重启应用来解决。因此,解决死锁的最好方法就是预防死锁。

发生死锁时,必然会存在死锁的四个必要条件。也就是说,如果我们在写程序时,只要“破坏”死锁的四个必要条件中的一个,就能够避免死锁的发生。接下来,我们就一起来探讨下如何“破坏”这四个必要条件。

  • 破坏互斥条件

互斥条件是我们没办法破坏的,因为我们使用锁为的就是线程之间的互斥。这一点需要特别注意!!!!

  • 破坏不可剥夺条件

破坏不可剥夺的条件的核心就是让当前线程自己主动释放占有的资源,关于这一点,synchronized是做不到的,我们可以使用java.util.concurrent包下的Lock来解决。此时,我们需要将TansferAccount类的代码修改成类似如下所示。

public class TansferAccount{
    private Lock thisLock = new ReentrantLock();
    private Lock targetLock = new ReentrantLock();
    //账户的余额
    private Integer balance;
    //转账操作
    public void transfer(TansferAccount target, Integer transferMoney){
        boolean isThisLock = thisLock.tryLock();
        if(isThisLock){
            try{
                boolean isTargetLock = targetLock.tryLock();
                if(isTargetLock){
                    try{
                         if(this.balance >= transferMoney){
                            this.balance -= transferMoney;
                            target.balance += transferMoney;
                        }
                    }finally{
                        targetLock.unlock
                    }
                }
            }finally{
                thisLock.unlock();
            }
        }
    }
}

其中Lock中有两个tryLock方法,分别如下所示。

  • tryLock()方法

tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待。

  • tryLock(long time, TimeUnit unit)方法

tryLock(long time, TimeUnit unit)方法和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。

  • 破坏请求与保持条件

破坏请求与保持条件,我们可以一次性申请所需要的所有资源,例如在我们完成转账操作的过程中,我们一次性申请账户A和账户B,两个账户都申请成功后,再执行转账的操作。此时,我们需要再创建一个申请资源的类ResourcesRequester,这个类的作用就是申请资源和释放资源。同时,TansferAccount类中需要持有一个ResourcesRequester类的单例对象,当我们需要执行转账操作时,首先向ResourcesRequester同时申请转出账户和转入账户两个资源,申请成功后,再锁定两个资源;当转账操作完成后,释放锁并释放ResourcesRequester类申请的转出账户和转入账户资源。

ResourcesRequester类的代码如下所示。

public class ResourcesRequester{
    //存放申请资源的集合
    private List<Object> resources = new ArrayList<Object>();
    //一次申请所有的资源
    public synchronized boolean applyResources(Object source, Object target){
        if(resources.contains(source) || resources.contains(target)){
            return false;
        }
        resources.add(source);
        resources.add(targer);
        return true;
    }

    //释放资源
    public synchronized void releaseResources(Object source, Object target){
        resources.remove(source);
        resources.remove(target);
    }
}

此时,TansferAccount类的代码如下所示。

public class TansferAccount{
    //账户的余额
    private Integer balance;
    //ResourcesRequester类的单例对象
    private ResourcesRequester requester;

    //转账操作
    public void transfer(TansferAccount target, Integer transferMoney){
        //自旋申请转出账户和转入账户,直到成功
        while(!requester.applyResources(this, target)){
            //循环体为空
            ;
        }
        try{
            //对转出账户加锁
            synchronized(this){
                //对转入账户加锁
                synchronized(target){
                    if(this.balance >= transferMoney){
                        this.balance -= transferMoney;
                        target.balance += transferMoney;
                    }
                }
            }
        }finally{
            //最后释放账户资源
            requester.releaseResources(this, target);
        }

    }
}
  • 破坏循环等待条件

破坏循环等待条件,则可以通过对资源排序,按照一定的顺序来申请资源,然后按照顺序来锁定资源,可以有效的避免死锁。

例如,在我们的转账操作中,往往每个账户都会有一个唯一的id值,我们在锁定账户资源时,可以按照id值从小到大的顺序来申请账户资源,并按照id从小到大的顺序来锁定账户,此时,程序就不会再进行循环等待了。

程序代码如下所示。

public class TansferAccount{
    //账户的id
    private Integer id;
    //账户的余额
    private Integer balance;
    //转账操作
    public void transfer(TansferAccount target, Integer transferMoney){
        TansferAccount beforeAccount = this;
        TansferAccount afterAccount = target;
        if(this.id > target.id){
            beforeAccount = target;
            afterAccount = this;
        }
        //对转出账户加锁
        synchronized(beforeAccount){
            //对转入账户加锁
            synchronized(afterAccount){
                if(this.balance >= transferMoney){
                    this.balance -= transferMoney;
                    target.balance += transferMoney;
                }
            }
        }
    }
}

总结

在并发编程中,使用细粒度锁来锁定多个资源时,要时刻注意死锁的问题。另外,避免死锁最简单的方法就是阻止循环等待条件,将系统中所有的资源设置标志位、排序,规定所有的线程申请资源必须以一定的顺序来操作进而避免死锁。

写在最后

如果觉得文章对你有点帮助,请微信搜索并关注「 冰河技术 」微信公众号,跟冰河学习高并发编程技术。

最后,附上并发编程需要掌握的核心技能知识图,祝大家在学习并发编程时,少走弯路。

原文地址:https://www.cnblogs.com/binghe001/p/12687879.html

时间: 2024-10-08 16:47:32

【高并发】优化加锁方式时竟然死锁了!!的相关文章

对mysql的高并发优化配置的一些思考

对mysql的高并发优化配置的一些思考 mysql的高并发优化配置方案很多,但是适应你自己的就变得很少了,我们对数据库的优化,无非就是为了应对mysql的高并发情况罢了.随着大数据的时代的到来和网络用户的增多,很多企业中,可能每天应对的数量达百万,千万,甚至上亿的pv量,这样的量已经是超过普通配置的mysql所承受的量,所以应对日益增长的pv量,我们需要对mysql做出相应的对策,进一步优化mysql,达到我们所预期的效果,预防因为高并发所引起的mysql宕机,通过调试优化mysql,我们便可以

nginx高并发优化

nginx 高并发优化 一.关闭系统中不需要的服务 二.优化磁盘写操作 mount -o remount defaults,noatime,nodiratime partion mount_partion fstab 将partion mount_partion defaults 0 0 修改为partion mount_partion defaults,noatime,nodiratime 0 0 即修改为写入磁盘不修改访问时间 三.优化资源限制 ulimit -n 和ulimit -u 即o

ql Server 高频,高并发访问中的键查找死锁解析

死锁对于DBA或是数据库开发人员而言并不陌生,它的引发多种多样,一般而言,数据库应用的开发者在设计时都会有一定的考量进而尽量避免死锁的产生.但有时因为一些特殊应用场景如高频查询,高并发查询下由于数据库设计的潜在问题,一些不易捕捉的死锁可能出现从而影响业务.这里为大家介绍由于设计问题引起的键查找死锁及相关的解决办法. 这里我们在测试的同时开启trace profiler跟踪死锁视图(locks:deadlock graph).(当然也可以开启跟踪标记,或者应用扩展事件(xevents)等捕捉死锁)

Sql Server 高频,高并发访问中的键查找死锁解析

死锁对于DBA或是数据库开发人员而言并不陌生,它的引发多种多样,一般而言,数据库应用的开发者在设计时都会有一定的考量进而尽量避免死锁的产生.但有时因为一些特殊应用场景如高频查询,高并发查询下由于数据库设计的潜在问题,一些不易捕捉的死锁可能出现从而影响业务.这里为大家介绍由于设计问题引起的键查找死锁及相关的解决办法. 这里我们在测试的同时开启trace profiler跟踪死锁视图(locks:deadlock graph).(当然也可以开启跟踪标记,或者应用扩展事件(xevents)等捕捉死锁)

Java高并发秒杀API之高并发优化

---恢复内容开始--- 第1章 秒杀系统高并发优化分析   1.为什么要单独获得系统时间 访问cdn这些静态资源不用请求系统服务器 而CDN上没有系统时间,需要单独获取,获取系统时间不用优化,只是new了一个日期对象返回,java访问一次内存(cacheline)的时间大概为10ns,即一秒可可访问一亿次 倒计时放在js端,在浏览器中,不会对服务器端造成影响,也不用优化 2.秒杀地址接口分析 秒杀未开启,秒杀开启,秒杀结束,秒杀地址返回的数据不同,不是静态的,无法使用CDN缓存 但它适合使用r

MySQL MyISAM/InnoDB高并发优化经验

最近做的一个应用,功能要求非常简单,就是 key/value 形式的存储,简单的 INSERT/SELECT,没有任何复杂查询,唯一的问题是量非常大,如果目前投入使用,初期的单表 insert 频率约 20Hz(次/秒,我喜欢这个单位,让我想起国内交流电是 50Hz),但我估计以后会有 500Hz+ 的峰值.目前的工作成果,额定功率 200Hz(CPU 占用 10 – 20,load avg = 2),最大功率 500Hz(这时 load avg > 20,很明显,只能暂时挺挺,应该在出现这种负

海量数据、高并发优化方案

一.应用服务器负载均衡 1.链路负载均衡 通过DNS解析域名时,将客户端的访问解析成不同的IP,分配到不同的入口,同时尽可能保证所访问的入口是所有入口中可能较快的一个. 2.软件负载均衡 访问时生成页面的任务会被分配给其中一台服务器完成,这个过程要保证公正.公平.平均. 3.硬件负载均衡 二.页面优化 1.减少请求次数 通过合并CSS和Javascript文件来减少请求次数或是将资源文件分布在多个域名下来绕过浏览器并发加载的限制. 2.压缩CSS和Javascript代码. 通过对文件代码内容删

SSM实战——秒杀系统之高并发优化

一:高并发点 高并发出现在秒杀详情页,主要可能出现高并发问题的地方有:秒杀地址暴露.执行秒杀操作. 二:静态资源(页面)访问优化--CDN CDN,内容分发网络.我们把静态的资源(html/css/js)放在CDN上,以加快用户获取数据的速度. 用户访问页面时,优先从最近的CDN服务器上获取页面资源,而不是从单一的网站服务器上获取.只有CDN获取不到才会访问后端服务器. 因此,我们可以使用CDN进行网站的加速优化,把静态资源(或某些动态资源)推送到CDN站点上.(大公司自己搭建CDN网络,小公司

SSM(Spring+SpringMVC+MyBatis)高并发优化思路

SSM(Spring+SpringMVC+MyBatis)框架集由Spring.MyBatis两个开源框架整合而成(SpringMVC是Spring中的部分内容).常作为数据源较简单的web项目的框架. 学习课程的地址:https://www.imooc.com/learn/632 老师的GitHub地址:https://github.com/geekyijun/seckill 高并发发生在哪里?分析整个系统流程,用户进行秒杀时最感兴趣的进入详情页进行秒杀.图中红色表示可能会出现高并发的点,绿色