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

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

大家好,我是冰河~~

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

为何需要优化加锁方式?

在《【高并发】高并发环境下诡异的加锁问题(你加的锁未必安全)》一文中,我们在转账类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;
                }   
            }
        }
    }
}

总结

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

写在最后

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

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

目录
相关文章
|
3月前
|
存储 监控 固态存储
在高并发环境下,如何优化 WAL 的写入性能?
在高并发环境下,如何优化 WAL 的写入性能?
|
1月前
|
存储 监控 固态存储
在高并发环境下,如何优化 WAL 的写入性能?
在高并发环境下,如何优化 WAL 的写入性能?
|
1月前
|
缓存 监控 负载均衡
nginx相关配置及高并发优化
Nginx的高并发优化是一个综合性的过程,需要根据具体的业务场景和硬件资源量身定制。以上配置只是基础,实际应用中还需根据服务器监控数据进行持续调整和优化。例如,利用工具如ab(Apache Benchmarks)进行压力测试,监控CPU、内存、网络和磁盘I/O等资源使用情况,确保配置的有效性和服务的稳定性。
107 0
|
3月前
|
存储 缓存 安全
.NET 在金融行业的应用:高并发交易系统的构建与优化之路
【8月更文挑战第28天】在金融行业,交易系统需具备高并发处理、低延迟及高稳定性和安全性。利用.NET构建此类系统时,可采用异步编程提升并发能力,优化数据库访问以降低延迟,使用缓存减少数据库访问频率,借助分布式事务确保数据一致性,并加强安全性措施。通过综合优化,满足金融行业的严苛要求。
49 1
|
4月前
|
SQL 关系型数据库 MySQL
(八)MySQL锁机制:高并发场景下该如何保证数据读写的安全性?
锁!这个词汇在编程中出现的次数尤为频繁,几乎主流的编程语言都会具备完善的锁机制,在数据库中也并不例外,为什么呢?这里牵扯到一个关键词:高并发,由于现在的计算机领域几乎都是多核机器,因此再编写单线程的应用自然无法将机器性能发挥到最大,想要让程序的并发性越高,多线程技术自然就呼之欲出,多线程技术一方面能充分压榨CPU资源,另一方面也能提升程序的并发支持性。
387 3
|
3月前
|
监控 算法 Java
企业应用面临高并发等挑战,优化Java后台系统性能至关重要
随着互联网技术的发展,企业应用面临高并发等挑战,优化Java后台系统性能至关重要。本文提供三大技巧:1)优化JVM,如选用合适版本(如OpenJDK 11)、调整参数(如使用G1垃圾收集器)及监控性能;2)优化代码与算法,减少对象创建、合理使用集合及采用高效算法(如快速排序);3)数据库优化,包括索引、查询及分页策略改进,全面提升系统效能。
48 0
|
5月前
|
存储 NoSQL Java
探索Java分布式锁:在高并发环境下的同步访问实现与优化
【6月更文挑战第30天】Java分布式锁在高并发下确保数据一致性,通过Redis的SETNX、ZooKeeper的临时节点、数据库操作等方式实现。优化策略包括锁超时重试、续期、公平性及性能提升,关键在于平衡同步与效率,适应大规模分布式系统的需求。
169 1
|
5月前
|
Arthas 监控 Java
深入解析与解决高并发下的线程池死锁问题
在高并发的互联网应用中,遇到线程池死锁问题导致响应延迟和超时。问题源于库存服务的悲观锁策略和线程池配置不当。通过以下方式解决:1) 采用乐观锁(如Spring Data JPA的@Version注解)替换悲观锁,减少线程等待;2) 动态调整线程池参数,如核心线程数、最大线程数和拒绝策略,以适应业务负载变化;3) 实施超时和重试机制,减少资源占用。这些改进提高了系统稳定性和用户体验。
218 2
|
4月前
|
存储 NoSQL Java
探索Java分布式锁:在高并发环境下的同步访问实现与优化
【7月更文挑战第1天】在分布式系统中,Java分布式锁解决了多节点共享资源的同步访问问题,确保数据一致性。常见的实现包括Redis的SETNX和过期时间、ZooKeeper的临时有序节点、数据库操作及Java并发库。优化策略涉及锁超时、续期、公平性及性能。选择合适的锁策略对高并发系统的稳定性和性能至关重要。
176 0
|
6月前
|
存储 NoSQL Java
探索Java分布式锁:在高并发环境下的同步访问实现与优化
【4月更文挑战第17天】Java分布式锁是解决高并发下数据一致性问题的关键技术,通过Redis、ZooKeeper、数据库等方式实现。它确保多节点共享资源时的同步访问,防止数据不一致。优化策略包括锁超时重试、续期、公平性和性能优化。合理设计分布式锁对支撑大规模分布式系统至关重要。
572 2