Java Map多線程問題分析

問題背景

最近遇到一個比較棘手的問題: 類Builder中定義了Map對象,不同的方法會對Map對象進行讀寫操作。該代碼實現在單線程情況不會有問題,但是在多線程情況運行就不行了,會有下述異常發生。

java.util.ConcurrentModificationException

簡單點理解就是不同線程對同一個Map對象進行讀寫操作,兩者之間存在數據競爭。比如forEach進行遍歷(讀)的時候,該Map結構修改(put/clear)次數=1,而當遍歷完成後,修改次數變成了2,那麼此時便會存在異常。

package com.amos.example;

import java.util.HashMap;
import java.util.Map;

public class Builder {

    static final Map<String, Object> PARAM_MAP = new HashMap<>();

    public String build() {
        StringBuilder stringBuilder = new StringBuilder();
        // 返回map對象的所有value拼接成的字符串
        PARAM_MAP.forEach((k, v) -> stringBuilder.append(v));
        String ret = stringBuilder.toString();
        clear();
        return ret;
    }

    public Builder withStr(String str) {
        PARAM_MAP.put("key", str);
        return this;
    }

    private void clear() {
        PARAM_MAP.clear();
    }
}
package com.amos;

import com.amos.example.Builder;

public final class Factory {

    private static final Builder BASE_BUILDER = new Builder();

    private Factory() {
    }

    public static Builder baseBuilder() {
        return BASE_BUILDER;
    }
}

單線程調用的過程:
在這裏插入圖片描述

但是多線程便不是這樣的:
在這裏插入圖片描述

如果線程1在執行get操作的時候,剛好線程2進行了put或者clear,那麼此時便出現了併發修改異常(ConcurrentModificationException)。

修改爲線程安全Map類型

現在我們知道HashMap是線程不安全的類,需要使用線程安全的類型,於是我們將Map類型修改爲ConcurrentHashMap

爲何使用ConcurrentHashMap

  1. 線程安全:在寫操作時上鎖,只有一個線程允許操作寫,但讀不限制。
  2. 性能優於HashTable/Collections.synchronizedMap(Map),此兩個類是鎖住整個Map對象,而ConcurrentHashMap只鎖住某一個Segment(圖片來源:https://howtodoinjava.com/wp-content/uploads/ConcurrentHashMap.jpg)

在這裏插入圖片描述

於是我們的調用過程變成:

注意:

  1. 在此我們認爲put操作和get操作花費的CPU時鐘週期是一樣的;

  2. 因爲clear涉及到遍歷然後remove操作,認爲它花費的時鐘週期大於put/get

在這裏插入圖片描述

當我們將Map類型修改爲ConcurrentHashMap之後,雖然沒有再出現java.util.ConcurrentModificationException,但是實際還是有問題的。ConcurrentHashMap的線程安全指的是,它的每個方法單獨調用(即原子操作)都是線程安全的,但是代碼總體的互斥性並不受控制。假設線程1給Map put一個鍵值對爲"key": “value1”;而線程2給Map put另外一個鍵值對爲"key": “value2”,線程1和線程2的get操作讀到的數據不一定是對應的value1、value2。

我們可以通過代碼來實踐。下面我們往map對象中put了線程名,然後再將實際的線程名從map中get到的線程名做比較(單線程情況下完全一致,多線程則未必),如果不一致則打印:

package com.amos.example;

import com.amos.Factory;

public class ThreadDemo extends Thread {

    private Thread t;
    private String threadName;

    ThreadDemo(String name) {
        threadName = name;
        System.out.println("Creating " + threadName);
    }

    @Override
    public void run() {
        System.out.println("Running " + threadName);
        try {
            // 記錄數據競爭的次數
            int count = 0;
            int loop = 100;
            for (int i = loop; i > 0; i--) {
                // 往map中put線程名,然後get
                String str = Factory.baseBuilder().withStr(threadName).build();
                // 判斷當前實際線程名和從map中get到的線程名是否一致
                boolean safe = threadName.equals(str);
                if (!safe) {
                    System.out.println("Thread: " + threadName + ", loop:" + i + ", str:" + str);
                    count++;
                }
                Thread.sleep(5);
            }
            System.out.println("數據競爭次數:" + count);
        } catch (InterruptedException e) {
            System.out.println("Thread " + threadName + " interrupted.");
        }
        System.out.println("Thread " + threadName + " exiting.");
    }

    @Override
    public void start() {
        System.out.println("Starting " + threadName);
        if (t == null) {
            t = new Thread(this, threadName);
            t.start();
        }
    }

    public static void main(String[] args) {
        ThreadDemo t1 = new ThreadDemo("t1");
        ThreadDemo t2 = new ThreadDemo("t2");
        t1.start();
        t2.start();
    }
}

執行結果: (已經省略部分打印結果)

Thread: t1, loop:8, str:t2
Thread: t1, loop:7, str:
Thread: t1, loop:6, str:t2
Thread: t2, loop:5, str:t1
Thread: t1, loop:4, str:t2
Thread: t1, loop:3, str:t2
Thread: t2, loop:1, str:t1
Thread:t1, 數據競爭次數:31
Thread:t2, 數據競爭次數:21

從執行結果可以看出:

  1. 線程t1從map中get時,出現該鍵值對被線程t2給clear的情況,如上loop7所示
  2. 線程t2從map中get時,出現該鍵值對被線程t1給覆蓋的情況,如上loop1所示

我們實際上期望的執行過程應該是這樣的,也就是說必須保證Builder類的增/刪/查方法對於map操作的原子性。
在這裏插入圖片描述

如何處理呢?

線程安全未必安全,嘗試加鎖

在Builder類中添加一個類變量ReentrantLock ,在Builder類初始化的時候獲取鎖,再在clear方法調用後解鎖。也就滿足了我們期望的增查刪組合的原子操作。

package com.amos.example;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;

public class Builder {
    public final ReentrantLock LOCK = new ReentrantLock();
    static final Map<String, Object> PARAM_MAP = new ConcurrentHashMap<>();

    public String build() {
        StringBuilder stringBuilder = new StringBuilder();
        PARAM_MAP.forEach((k, v) -> stringBuilder.append(v));
        String ret = stringBuilder.toString();
        clear();
        LOCK.unlock();
        return ret;
    }

    private void clear() {
        PARAM_MAP.clear();
    }
}
package com.amos;

import com.amos.example.Builder;

public final class Factory {

    private static final Builder BASE_BUILDER = new Builder();

    private Factory() {
    }

    public static Builder baseBuilder() {
        try{
            BASE_BUILDER.LOCK.lockInterruptibly();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return BASE_BUILDER;
    }
}

添加鎖之後再次運行ThreadDemo類,沒有再出現數據競爭的情況。但是情況並不是這麼簡單,現在是兩個線程對同一個類下的Map進行讀寫,實際出現問題的代碼是多個Builder類對超類下的Map進行讀寫。一個NameBuilder一個PhoneBuilder,都繼承至Builder超類,代碼如下:

package com.amos.example;

public final class NameBuilder extends Builder {

    public NameBuilder withName(String name) {
        PARAM_MAP.put("name", name);
        return this;
    }

    @Override
    public String build() {
        return super.build();
    }
}
package com.amos.example;

public final class PhoneBuilder extends Builder {

    public PhoneBuilder withPhone(String phone) {
        PARAM_MAP.put("phone", phone);
        return this;
    }

    @Override
    public String build() {
        return super.build();
    }
}

多線程測試代碼更新爲:

package com.amos.example;

import com.amos.Factory;

public class ThreadDemo extends Thread {

    private Thread t;
    private String threadName;

    ThreadDemo(String name) {
        threadName = name;
        System.out.println("Creating " + threadName);
    }

    @Override
    public void run() {
        System.out.println("Running " + threadName);
        try {
            int count1 = 0;
            int count2 = 0;
            int loop = 100;
            for (int i = loop; i > 0; i--) {
                // 往map中put線程名
                String phone = Factory.phoneBuilder().withPhone(threadName).build();
                // 判斷當前實際線程名和從map中get到的線程名是否一致
                boolean flag1 = threadName.equals(phone);
                if (!flag1) {
                    System.out.println("Thread: " + threadName + ", loop:" + i + ", phone:" + phone);
                    count1++;
                }
                String name = Factory.nameBuilder().withName(threadName).build();
                // 判斷當前實際線程名和從map中get到的線程名是否一致
                boolean flag2 = threadName.equals(name);
                if (!flag2) {
                    System.out.println("Thread: " + threadName + ", loop:" + i + ", name:" + name);
                    count2++;
                }
                Thread.sleep(5);
            }
            System.out.println("Thread:" + threadName + ", phone數據競爭次數:" + count1);
            System.out.println("Thread:" + threadName + ", name數據競爭次數:" + count2);
        } catch (InterruptedException e) {
            System.out.println("Thread " + threadName + " interrupted.");
        }
        System.out.println("Thread " + threadName + " exiting.");
    }

    @Override
    public void start() {
        System.out.println("Starting " + threadName);
        if (t == null) {
            t = new Thread(this, threadName);
            t.start();
        }
    }

    public static void main(String[] args) {
        ThreadDemo t1 = new ThreadDemo("t1");
        ThreadDemo t2 = new ThreadDemo("t2");
        t1.start();
        t2.start();
    }
}

測試結果:

Creating t1
Creating t2
Starting t1
Starting t2
Running t1
Running t2
Thread: t1, loop:100, name:t2t1
Thread: t2, loop:100, phone:t2t1
Thread: t2, loop:97, phone:
Thread: t2, loop:92, name:t1t2
Thread: t1, loop:92, phone:t1t2
Thread: t2, loop:25, name:t1t2
Thread: t1, loop:25, phone:
Thread: t1, loop:23, phone:t1t2
Thread: t2, loop:23, name:t1t2
Thread: t1, loop:12, phone:t1t2
Thread: t2, loop:12, name:t1t2
Thread: t1, loop:9, name:t2t1
Thread: t2, loop:9, phone:
Thread:t1, phone數據競爭次數:4
Thread:t1, name數據競爭次數:2
Thread t1 exiting.
Thread:t2, phone數據競爭次數:3
Thread:t2, name數據競爭次數:4
Thread t2 exiting.

奇怪了,多線程執行一個類時不會有數據競爭,多線程執行多個類還是會有數據競爭。難道這個鎖沒有生效麼?

問題出在 MapLOCK 修飾符上,一把鎖只能與一個對象對應。但是代碼中

public final ReentrantLock LOCK = new ReentrantLock();
static final Map<String, Object> PARAM_MAP = new ConcurrentHashMap<>();

通過兩個類初始化之後:

Factory.phoneBuilder().withPhone(threadName).build();
Factory.nameBuilder().withName(threadName).build();

phoneBuilder實例擁有一把鎖,nameBuilder實例擁有另外一把鎖,而Map卻在不同實例、不同線程中始終都是同一個對象。也就是說實例A在線程1鎖住了Map,只是不允許實例A在線程2對Map進行讀寫,卻無法阻止實例B在線程1和線程2對Map進行讀寫。可以如下修改:要麼在多個實例內LOCKMap都是同一個對象,那麼在多個實例內都不屬於同一個對象(一把鎖對應一個Map)

public static final ReentrantLock LOCK = new ReentrantLock();
static final Map<String, Object> PARAM_MAP = new ConcurrentHashMap<>();
public final ReentrantLock LOCK = new ReentrantLock();
final Map<String, Object> PARAM_MAP = new ConcurrentHashMap<>();

再次運行ThreadDemo進行測試,結果如下:

Creating t1
Creating t2
Starting t1
Starting t2
Running t1
Running t2
Thread:t2, phone數據競爭次數:0
Thread:t2, name數據競爭次數:0
Thread:t1, phone數據競爭次數:0
Thread:t1, name數據競爭次數:0
Thread t1 exiting.
Thread t2 exiting.

問題解決~ 如果您有更好的解決方法,還望不吝賜教~

參考

https://blog.csdn.net/imzoer/article/details/8621074

https://howtodoinjava.com/java/multi-threading/best-practices-for-using-concurrenthashmap/

https://stackoverflow.com/questions/12646404/concurrenthashmap-and-hashtable-in-java

《Java併發編程實戰》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章