問題背景
最近遇到一個比較棘手的問題: 類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
:
- 線程安全:在寫操作時上鎖,只有一個線程允許操作寫,但讀不限制。
- 性能優於
HashTable
/Collections.synchronizedMap(Map)
,此兩個類是鎖住整個Map對象,而ConcurrentHashMap
只鎖住某一個Segment
(圖片來源:https://howtodoinjava.com/wp-content/uploads/ConcurrentHashMap.jpg)
於是我們的調用過程變成:
注意:
-
在此我們認爲put操作和get操作花費的CPU時鐘週期是一樣的;
-
因爲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
從執行結果可以看出:
- 線程t1從map中get時,出現該鍵值對被線程t2給clear的情況,如上
loop7
所示 - 線程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.
奇怪了,多線程執行一個類時不會有數據競爭,多線程執行多個類還是會有數據競爭。難道這個鎖沒有生效麼?
問題出在 Map
和 LOCK
修飾符上,一把鎖只能與一個對象對應。但是代碼中
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進行讀寫。可以如下修改:要麼在多個實例內LOCK
和Map
都是同一個對象,那麼在多個實例內都不屬於同一個對象(一把鎖對應一個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併發編程實戰》