Plumbr是唯一通过解释应用程序性能数据来自动检测Java性能问题的根本原因的解决方案。
几个月前,我们在Plumbr中引入了锁定线程检测之后,我们开始收到类似于“嘿,太好了,现在我知道是什么导致了性能问题,但是现在应该做什么?”这样的查询。
我们正在努力将解决方案说明构建到我们自己的产品中,但是在这篇文章中,我将分享几种独立于检测锁的工具可以应用的常见技术。 这些方法包括锁拆分,并发数据结构,保护数据(而不是代码)和减少锁范围。
锁不是邪恶的,锁争用是
每当您遇到线程代码的性能问题时,就有机会开始指责锁。 毕竟,常见的“知识”是锁很慢并且限制了可伸缩性。 因此,如果您具备了这种“知识”并开始优化代码并摆脱了锁,那么您最终有可能引入令人讨厌的并发错误,这些错误将在以后出现。
因此,了解竞争锁和非竞争锁之间的区别非常重要。 当一个线程试图进入另一个线程当前执行的同步块/方法时,发生锁争用。 现在,第二个线程被迫等待,直到第一个线程完成执行同步块并释放监视器。 当一次仅一个线程试图执行同步代码时,锁保持无竞争状态。
实际上,JVM中的同步针对无竞争的情况进行了优化,并且对于绝大多数应用程序而言,无竞争的锁几乎在执行期间没有开销。 因此,它不是性能应归咎的锁,而是争执的锁。 有了这些知识,让我们看看如何减少争用的可能性或减少争用的时间。
保护数据而不是代码
实现线程安全的一种快速方法是锁定对整个方法的访问。 例如,请看以下示例,该示例说明了构建在线扑克服务器的幼稚尝试:
class GameServer {public Map<<String, List<Player>> tables = new HashMap<String, List<Player>>();public synchronized void join(Player player, Table table) {if (player.getAccountBalance() > table.getLimit()) {List<Player> tablePlayers = tables.get(table.getId());if (tablePlayers.size() < 9) {tablePlayers.add(player);}}}public synchronized void leave(Player player, Table table) {/*body skipped for brevity*/}public synchronized void createTable() {/*body skipped for brevity*/}public synchronized void destroyTable(Table table) {/*body skipped for brevity*/}
}
作者的意图一直很好–当新玩家加入桌台时,必须保证坐在桌旁的玩家人数不会超过9人。
但是,只要这样的解决方案实际上负责使玩家坐在桌子上,即使是在流量适中的扑克场所,该系统注定要通过等待释放锁的线程不断触发争用事件。 锁定块包含帐户余额和表限制检查,这可能会涉及昂贵的操作,从而增加争用的可能性和持续时间。
解决方案的第一步是通过将同步从方法声明移到方法主体,确保我们保护数据,而不是代码。 在上面的简约示例中,它起初可能没有太大变化。 但是让我们考虑整个GameServer接口,而不仅仅是单个join()方法:
class GameServer {public Map<String, List<Player>> tables = new HashMap<String, List<Player>>();public void join(Player player, Table table) {synchronized (tables) {if (player.getAccountBalance() > table.getLimit()) {List<Player> tablePlayers = tables.get(table.getId());if (tablePlayers.size() < 9) {tablePlayers.add(player);}}}}public void leave(Player player, Table table) {/* body skipped for brevity */}public void createTable() {/* body skipped for brevity */}public void destroyTable(Table table) {/* body skipped for brevity */}
}
原本似乎是很小的更改,但现在影响了整个班级的行为。 每当玩家加入表时,先前同步的方法就会锁定在GameServer实例( this )上,并向试图同时离开 table ()表的玩家引入争用事件。 将锁从方法签名移到方法主体可推迟锁定并减少争用可能性。
缩小锁定范围
现在,在确保它是我们实际保护的数据而不是代码之后,我们应该确保我们的解决方案仅锁定必要的内容,例如,当上面的代码被重写如下时:
public class GameServer {public Map<String, List<Player>> tables = new HashMap<String, List<Player>>();public void join(Player player, Table table) {if (player.getAccountBalance() > table.getLimit()) {synchronized (tables) {List<Player> tablePlayers = tables.get(table.getId());if (tablePlayers.size() < 9) {tablePlayers.add(player);}}}}//other methods skipped for brevity
}
那么检查玩家帐户余额的潜在耗时操作(可能涉及IO操作)现在已超出锁定范围。 请注意,引入该锁仅是为了防止超出表的容量,并且帐户余额检查也不是该保护措施的一部分。
分开锁
当我们看最后一个代码示例时,您可以清楚地注意到整个数据结构都受到同一锁的保护。 考虑到我们可能在这种结构中容纳成千上万张扑克桌,由于我们必须分别保护每张桌子以防容量溢出,因此它仍然会带来争用事件的高风险。
为此,有一种简单的方法可以在每个表中引入单个锁,例如以下示例:
public class GameServer {public Map<String, List<Player>> tables = new HashMap<String, List<Player>>();public void join(Player player, Table table) {if (player.getAccountBalance() > table.getLimit()) {List<Player> tablePlayers = tables.get(table.getId());synchronized (tablePlayers) {if (tablePlayers.size() < 9) {tablePlayers.add(player);}}}}//other methods skipped for brevity
}
现在,如果我们仅同步访问同一表而不是所有表的访问 ,则可以大大降低锁争用的可能性。 例如,在我们的数据结构中有100个表,争用的可能性现在比以前小100倍。
使用并发数据结构
另一个改进是删除传统的单线程数据结构,并使用为并行使用而明确设计的数据结构。 例如,当选择ConcurrentHashMap来存储所有扑克表时,将导致类似于以下代码:
public class GameServer {public Map<String, List<Player>> tables = new ConcurrentHashMap<String, List<Player>>();public synchronized void join(Player player, Table table) {/*Method body skipped for brevity*/}public synchronized void leave(Player player, Table table) {/*Method body skipped for brevity*/}public synchronized void createTable() {Table table = new Table();tables.put(table.getId(), table);}public synchronized void destroyTable(Table table) {tables.remove(table.getId());}
}
由于我们需要保护单个表的完整性,因此join()和leave()方法中的同步仍然像我们之前的示例中那样。 因此, ConcurrentHashMap在这方面没有帮助。 但是,由于我们还在创建新表并在createTable()和destroyTable()方法中销毁表,因此对ConcurrentHashMap的所有这些操作都是完全并发的,从而允许增加或减少并行表的数量。
其他提示和技巧
- 降低锁的可见性。 在上面的示例中,这些锁被声明为公共锁,因此对于世界都是可见的,因此有可能其他人也会通过锁定您精心挑选的监视器来破坏您的工作。
- 请查看java.util.concurrent.locks,以查看在那里实施的任何锁定策略是否都会改善解决方案。
- 使用原子操作。 我们在上面的示例中实际进行的简单计数器增加实际上并不需要锁定。 用AtomicInteger替换计数跟踪中的Integer最适合此示例。
希望本文能帮助您解决锁争用问题,而无论您使用的是Plumbr 自动锁检测解决方案还是从线程转储中手动提取信息。
Plumbr是唯一通过解释应用程序性能数据来自动检测Java性能问题的根本原因的解决方案。
翻译自: https://www.javacodegeeks.com/2015/01/improving-lock-performance-in-java.html