提升Java的锁性能

jopen 10年前

几个月前我们介绍了如何通过Plumbr进行线程锁检测,随后便收到了很多类似的问题,“Hi,文章写得不错,现在我终于知道是什么引发的性能问题了,但是现在我该怎么做?”

为了在我们的产品中集成这个解决方案,我们付出了许多努力,不过在本文中,我想给大家分享几个常用的优化技巧,而不一定非要使用我们这款锁检测的工具。包括分拆锁,并发数据结构,保护数据而非代码,以及缩小锁的作用域。

锁无罪,竞争其罪

如果你在多线程代码中碰到了性能问题,你肯定会先抱怨锁。毕竟,从“常识”来讲,锁的性能是很差的,并且还限制了程序的可伸缩性。如果你怀揣着这样的想法去优化代码并删除锁的话,最后你肯定会引入一些难缠的并发BUG。

因此分清楚竞争锁与无竞争锁的区别是很有必要的。如果一个线程尝试进入另一个线程正在执行的同步块或者方法时,便会出现锁竞争。第二个线程就必须等待前一个线程执行完这个同步块并释放掉监视器(monitor)。如果只有一个线程在执行这段同步的代码,这个锁就是无竞争的。

事实上,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实例上,并与同时想离开牌桌(leave)的玩家产生竞争行为。而将锁从方法签名移到方法内部以后,则将上锁的时机往后推迟了,一定程度上减小了竞争的可能性。

缩小锁的作用域

现在我们已经确保保护的是数据而不是代码了,我们得再确认锁住的部分都是必要的——比如说,代码可以重写成这样 :

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  }

现在检查玩家余额的这个耗时操作就在锁作用域外边了。注意到了吧,锁的引入其实只是为了保护玩家数量不超过桌子的容量而已,检查帐户余额这个事情并不在要保护的范围之内。

分拆锁

再看下上面这段代码,你会注意到整个数据结构都被同一个锁保护起来了。考虑到这个数据结构中可能会存有上千张牌桌,出现竞争的概率还是非常高的,因此保护每张牌桌不超出容量的工作最好能分别来进行。

对于这个例子而言,为每张桌子分配一个独立的锁并非难事,代码如下:

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()方法的同步操作变得更简单了,因为我们现在不用再对tables进行加锁了,这都多亏了 ConcurrentHashMap。然而,我们还是要保证每个tablePlayers的一致性。因此这个地方ConcurrentHashMap帮不上什么忙。同时我们还得在createTable()与destroyTable()方法中创建新的桌子以及销毁桌子,这对 ConcurrentHashMap而言本身就是并发的,因此你可以并行地增加或者减少桌子的数量。

其它的技巧及方法

  • 降低锁的可见性。在上述例子中,锁是声明为public的,因此可以被别人所访问到,你所精心设计的监视器可能会被别人锁住,从而功亏一篑。
  • 看一下java.util.concurrent.locks包下面有哪些锁策略对你是有帮助的。
  • 使用原子操作。上面这个例子中的简单的计数器其实并不需要进行加锁。将计数的Integer换成AtomicInteger对这个场景来说就绰绰有余了。

希望本文对你解决锁竞争的问题能有所帮助,不管你有没有使用我们的Plumbr所提供的自动化锁检测方案,还是自己手动从线程dump信息中提取信息。

原创文章转载请注明出处:提升Java的锁性能

英文原文链接