English | 简体中文 | 繁體中文 | Русский язык | Français | Español | Português | Deutsch | 日本語 | 한국어 | Italiano | بالعربية

Methods to Improve the Performance of Locks in Java

Methods to Improve the Performance of Locks in Java

We strive to think of solutions for the problems encountered by our products, but in this article, I will share several commonly used techniques with you, including separating locks, parallel data structures, protecting data rather than code, and narrowing the scope of locks. These techniques can enable us to detect deadlocks without using any tools.

The root of the problem is not the lock itself, but the competition between locks

When performance issues are encountered in multi-threaded code, it is generally complained that the problem is with the locks. After all, it is well-known that locks can slow down the program's execution speed and have lower scalability. Therefore, if you start optimizing the code with this 'common sense', the result is likely to be讨厌的 concurrency issues later on.

It is very important to understand the difference between competitive locks and non-competitive locks. Lock contention is triggered when a thread tries to enter a synchronized block or method that is being executed by another thread. The thread will be forced into a waiting state until the first thread completes the synchronized block and releases the monitor. When only one thread attempts to execute the synchronized code area at the same time, the lock maintains a non-competitive state.

In fact, in non-competitive situations and most applications, JVM has optimized synchronization. Non-competitive locks do not incur any additional overhead during execution. Therefore, you should not complain about locks due to performance issues; instead, you should complain about lock contention. After gaining this understanding, let's see what can be done to reduce the likelihood of contention or reduce the duration of contention.

Protecting data rather than code

One quick way to solve thread safety issues is to lock the accessibility of the entire method. For example, in the following example, this method is used to establish an online poker game server:

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*/}
}

The author's intention is good - when a new player joins the table, it must be ensured that the number of players on the table does not exceed the total number of players that the table can accommodate9.

However, this solution in fact needs to control the entry of players into the table at all times - even when the server's traffic is low. The threads waiting for the lock release are bound to trigger system contention events frequently. The locking block that includes checks for account balance and table limits is likely to significantly increase the overhead of call operations, which undoubtedly increases the possibility and duration of contention.

The first step in solving this problem is to ensure that we are protecting data, not the synchronization declaration that was moved from the method declaration to the method body. For the simple example above, the change may not be significant. However, we need to consider the entire interface of the game service, not just the join() method.

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 */}
}

What may have been a minor change could affect the behavior of the entire class. Whenever a player joins a table, the previous synchronization method would lock the entire GameServer instance, leading to competition with those players who are trying to leave the table at the same time. Moving the lock from the method declaration to the method body would delay the loading of the lock, thereby reducing the possibility of lock contention.

Narrowing the scope of the lock

Now, when we are confident that we need to protect data rather than the program, we should ensure that we only lock where necessary - for example, after the above code has been refactored:

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 omitted for brevity
}

This code that contains the check of the player's account balance (which may trigger IO operations) and may cause time-consuming operations has been moved outside the scope of lock control. Note that now the lock is only used to prevent the number of players from exceeding the number of players that the table can accommodate; checking the account balance is no longer part of this protection measure.

separate locks

You can clearly see from the last line of code in the above example: the entire data structure is protected by the same lock. Considering that there may be thousands of tables in this kind of data structure, and we must ensure that the number of people in any table does not exceed its capacity, there is still a high risk of contention events in this case.

There is a simple way to address this, which is to introduce separate locks for each table, as shown in the following example:

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 omitted for brevity
}

Now, we only synchronize the accessibility of a single table instead of all tables, which significantly reduces the likelihood of lock contention. To give a specific example, now in our data structure there is10If there are 0 instances of tables, the likelihood of contention will be smaller than before100 times.

Use thread-safe data structures

Another area that can be improved is to abandon the traditional single-threaded data structure and use data structures that are explicitly designed for thread safety. For example, when using ConcurrentHashMap to store your table instances, the code may look like this:

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());
 }
}

The synchronized block inside the join() and leave() methods is still the same as the previous example, because we need to ensure the integrity of the single table data. ConcurrentHashMap does not help with this. But we will still use ConcurrentHashMap to create and destroy new tables in the increaseTable() and destoryTable() methods, all of these operations are completely synchronized for ConcurrentHashMap, allowing us to add or reduce the number of tables in a parallel manner.

Other suggestions and tips

Reduce the visibility of locks. In the above example, the lock is declared as public (visible to the outside), which may allow some malicious people to destroy your work by locking on the carefully designed monitor.

Let's take a look at the java.util.concurrent.locks API to see if there are other implemented lock strategies that can improve the above solution.

Use atomic operations. The simple incrementing counter currently in use does not actually require locking. It is more suitable to use AtomicInteger instead of Integer as the counter in the above example.

Last but not least, whether you are using Plumber's automatic deadlock detection solution or manually obtaining information from thread dumps to solve the problem, I hope this article can help you solve the problem of lock contention.

Thank you for reading, I hope it can help everyone. Thank you for your support of this site!

You May Also Like