当前位置: 首页 > 科技观察

如何提高Java中的锁定性能

时间:2023-03-17 20:42:18 科技观察

两个月前将线程死锁检测引入Plumbr后,我们开始收到类似这样的询问:“太棒了!现在我知道导致我的程序出现性能问题的原因消失了,但我应该怎么做下一个?”我们尝试去想解决我们产品遇到的问题,但是在这篇文章中我会和大家分享几种常用的技术,包括分离锁,并行数据结构,保护数据而不是代码,以及减少锁的范围是一个很少有技术可以让我们在不使用任何工具的情况下检测死锁。锁不是问题的根源,锁之间的竞争通常是在多线程代码中遇到性能问题时,一般抱怨的是锁的问题。毕竟,众所周知,锁会减慢程序速度并且可扩展性低。因此,如果您开始使用这种“常识”来优化您的代码,那么以后很可能会出现严重的并发问题。因此,了解竞争锁和非竞争锁的区别非常重要。当一个线程试图进入另一个线程正在执行的同步块或方法时,就会发生锁争用。该线程将被迫进入等待状态,直到第一个线程执行完同步块并释放监视器。当一次只有一个线程尝试执行同步代码区域时,锁保持未竞争状态。事实上,JVM已经针对非竞争情况和大多数应用程序中的同步进行了优化。无竞争锁在执行期间不会产生任何额外开销。因此,你不应该因为性能问题而抱怨锁,你应该抱怨锁竞争。考虑到这一点,让我们看看可以做些什么来减少竞争的可能性或持续时间。保护数据,而不是代码解决线程安全问题的一种快速方法是锁定整个方法的可访问性。例如,以下示例尝试以这种方式构建在线扑克游戏服务器:classGameServer{publicMap<>tables=newHashMap>();publicsynchronizedvoidjoin(Playerplayer,Tabletable){if(player.getAccountBalance()>table.getLimit()){ListtablePlayers=tables.get(table.getId());if(tablePlayers.size()<9){tablePlayers.add(玩家);}}}publicsynchronizedvoidleave(Playerplayer,Tabletable){/*bodyskippedforbrevity*/}publicsynchronizedvoidcreateTable(){/*bodyskippedforbrevity*/}publicsynchronizedvoiddestroyTable(Tabletable){/*bodyskippedforbrevity*/}}作者的用意是好的——当一个新的玩家入桌前,必须保证桌上的玩家人数不超过该桌可容纳的玩家总数9。但这种方案实际上需要控制玩家何时进入牌桌——即使在服务器访问量较小的情况下,那些等待释放锁的线程势必会频繁触发系统比赛事件。包括检查账户余额和表限制在内的锁定块可能会显着增加调用操作的开销,这无疑会增加竞争的可能性和持续时间。解决方案的第一步是确保我们保护的是数据,而不是从方法声明移至方法主体的同步声明。对于上面的简单示例,可能没有太大变化。但是我们必须考虑整个游戏服务的接口,而不仅仅是一个join()方法。classGameServer{publicMap>tables=newHashMap>();publicvoidjoin(Playerplayer,Tabletable){synchronized(tables){if(player.getAccountBalance()>table.getLimit()){ListtablePlayers=tables.get(table.getId());if(tablePlayers.size()<9){tablePlayers.add(player);}}}}publicvoidleave(Playerplayer,Tabletable){/*bodyskippedforbrevity*/}publicvoidcreateTable(){/*bodyskippedforbrevity*/}publicvoiddestroyTable(Tabletable){/*bodyskippedforbrevity*/}}可能只是一个很小的变化,但它影响了整个班级的行为。每当玩家加入一张桌子时,先前的同步方法都会锁定整个GameServer实例,从而与试图同时离开桌子的玩家进行比赛。将锁从方法声明中移到方法体中延迟了锁的加载,从而减少了锁争用的可能性。缩小锁的范围现在,当我们确定我们需要保护数据而不是程序时,我们应该确保我们只在必要的地方加锁——例如重构上面的代码时:publicclassGameServer{publicMap>tables=newHashMap>();publicvoidjoin(Playerplayer,Tabletable){if(player.getAccountBalance()>table.getLimit()){synchronized(tables){ListtablePlayers=tables.get(table.getId());if(tablePlayers.size()<9){tablePlayers.add(player);}}}}//othermethodsskippedforbrevity}这部分包含检测玩家账户余额(可能会引起IO操作)可能导致耗时的操作被移出锁控制范围。请注意,现在锁定仅用于防止玩家数量超过桌子容量,检查帐户余额不再是此保护的一部分。拆分锁从上面例子的第一行代码可以清楚的看出:整个数据结构都被同一个锁保护着。考虑到在一个数据结构中可能有几千张表,而且我们要保护任何一张表的人数不超过容量,在这种情况下发生争用事件的风险仍然很高。一个简单的方法是为每个表引入单独的锁,如下例所示:Playerplayer,Tabletable){if(player.getAccountBalance()>table.getLimit()){ListtablePlayers=tables.get(table.getId());synchronized(tablePlayers){if(tablePlayers.size()<9){tablePlayers.add(player);}}}}//othermethodsskippedforbrevity}现在,我们只同步单个表的可访问性,而不是所有表,这大大降低了锁争用的可能性。举一个具体的例子,现在我们的数据结构中有100个扑克牌表实例,现在发生比赛的机会比以前小100倍。使用线程安全的数据结构可以改进的另一个方面是放弃传统的单线程数据结构,并使用明确设计为线程安全的数据结构。例如,当使用ConcurrentHashMap存储您的表实例时,代码可能如下所示:{/*Methodbodyskippedforbrevity*/}publicsynchronizedvoidleave(Playerplayer,Tabletable){/*Methodbodyskippedforbrevity*/}publicsynchronizedvoidcreateTable(){Tabletable=newTable();tables.put(table.getId(),table);}publicsynchronizedvoiddestroyTable(Tabletable){表.remove(table.getId());}}join()和leave()方法中的同步块仍然与前面的示例相同,因为我们要确保单个表数据的完整性。ConcurrentHashMap在这里没有帮助。但是我们仍然会在increateTable()和destroyTable()方法中使用ConcurrentHashMap来创建和销毁新表,所有这些操作对于ConcurrentHashMap来说都是完全同步的,这允许我们并行地添加或减少表的数量。其他一些提示和技巧降低锁的可见性。在上面的示例中,锁被声明为公开(对外界可见),这可能会让别有用心的人通过锁定您精心设计的显示器来破坏您的工作。检查java.util.concurrent.locksAPI,看看是否有其他的锁策略已经实现,并使用它们来改进上面的解决方案。使用原子操作。上面使用的简单递增计数器实际上不需要锁定。在上面的例子中,使用AtomicInteger而不是Integer作为计数器更合适。最后,不管你是在使用Plumber的自动死锁检测方案,还是手动从threaddump中获取方案信息,希望本文能帮助你解决锁竞争问题。