How to Improve the Performance of Java locks
Two months ago, after introducing thread Deadlock Detection to Plumbr, we began to receive some questions similar to this: "Great! Now I know why the program has performance problems, but what should I do next ?"
We try our best to find a solution to the problems encountered by our products, but I will share with you several common technologies in this article, these technologies include separating locks, parallel data structures, protecting data rather than code, and narrowing down the scope of the lock. These technologies enable us to detect deadlocks without any tools.
The lock is not the root cause of the problem, but the competition between locks is
When you encounter performance problems in multi-threaded code, you usually complain about the lock problem. After all, the lock will reduce the running speed of the program and its low scalability is well known. Therefore, if you begin to optimize the Code with this "Common Sense", the result may be annoying concurrency problems later.
Therefore, it is important to understand the differences between a competitive lock and a non-competitive lock. Lock contention is triggered when a thread tries to enter the synchronization block or method being executed by another thread. This thread will be forced to enter the waiting state until the first thread executes the synchronization block and has released the monitor. When only one thread tries to execute the synchronized code area at a time, the lock will remain non-competitive.
In fact, in non-competitive scenarios and most applications, JVM has optimized synchronization. Non-competitive locks do not incur any additional overhead during execution. Therefore, you should not complain about the lock due to performance issues, but should complain about the lock competition. With this understanding, let's look at what we can do to reduce the possibility of competition or reduce the duration of competition.
Protect data, not code
A quick way to solve the thread security problem is to lock the accessibility of the entire method. In the following example, we try to create an online poker game server using this method:
- 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 card table, make sure that the number of players on the card table does not exceed the total number of players that the card table can accommodate.
However, this solution actually requires control of the player's entry to the card table at any time, even when the server's access volume is small, threads waiting for lock release are destined to frequently trigger system competition events. Locked blocks that include checks on account balances and card table restrictions may greatly increase the overhead of calling operations, which will undoubtedly increase the possibility and duration of competition.
The first step is to ensure that we protect the data, rather than moving from the method declaration to the synchronous declaration in the method body. For the simple example above, it may not change much. However, we need to consider the interface of the entire game service, rather than simply a 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 */}
- }
It may have been a small change, but it affects the behavior of the entire class. The previous synchronization method locks the entire GameServer instance at any time, and then competes with gamers who attempt to leave the table at the same time. Moving the lock from the method declaration to the method body will delay the loading of the lock, thus reducing the possibility of lock competition.
Narrow down the lock Scope
Now, when we are convinced that we need to protect data rather than programs, we should make sure that we only lock where necessary-for example, after the code above is restructured:
- 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
- }
This section contains codes that may cause time-consuming operations when detecting the player account balance, which may lead to IO operations. The code is moved out of the lock control range. Note: The lock is only used to prevent the number of players from exceeding the number of players allowed on the table. Checking the account balance is no longer part of this protection measure.
Split lock
The last line of code in the above example clearly shows that the entire data structure is protected by the same lock. Considering that there may be thousands of card tables in this data structure, we must protect the number of people in any card table from exceeding the capacity, under such circumstances, there is still a high risk of competition.
There is a simple way to introduce a separation lock to each card 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 skipped for brevity
- }
Now, we only synchronize the accessibility of a single table instead of all tables, which significantly reduces the possibility of lock competition. For example, if there are 100 table-level instances in our data structure, the possibility of competition is 100 times lower than before.
Use thread-Safe Data Structure
Another improvement is to discard the traditional single-threaded data structure and use a Data Structure explicitly designed as thread-safe. For example, when you use ConcurrentHashMap to store your card table instance, the Code may be as follows:
- 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 synchronization blocks in the join () and leave () methods are still the same as in the previous example, because we need to ensure the data integrity of a single table.ConcurrentHashMapThere is no help at this point. But we will stillCreateTable ()AndDestoryTable ()Use in MethodConcurrentHashMapCreate and destroy a new table.ConcurrentHashMapIt is completely synchronous, which allows us to add or reduce the number of tables in parallel.
Other suggestions and tips
-
Reduce lock visibility. In the above example, the lock is declared as public to be visible to the outside, which may cause some people with ulterior motives to damage your work by locking on your carefully designed monitor.
-
Check the java. util. concurrent. locks API to see if there are other implemented lock policies and use them to improve the above solution.
-
Use atomic operations. The simple incremental counter used above does not actually require locking. In the above example, it is more suitable to use AtomicInteger instead of Integer as the counter.
Finally, whether you are using the automatic Deadlock Detection solution of Plumber or manually obtaining the solution information from the thread dump, I hope this article will help you solve the lock competition problem.