ConcurrentHashMap、synchronized與執行緒安全
在看spring原始碼時,看到synchronized 包圍了 ConcurrentHashMap
最近做的專案中遇到一個問題:明明用了ConcurrentHashMap,可是始終執行緒不安全
除去專案中的業務邏輯,簡化後的程式碼如下:
-
public class Test40 {
-
public static void main(String[] args) throws InterruptedException {
-
for (int i = 0; i < 10; i++) {
-
System.out.println(test());
-
}
-
}
-
private static int test() throws InterruptedException {
-
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<String, Integer>();
-
ExecutorService pool = Executors.newCachedThreadPool();
-
for (int i = 0; i < 8; i++) {
-
pool.execute(new MyTask(map));
-
}
-
pool.shutdown();
-
pool.awaitTermination(1, TimeUnit.DAYS);
-
return map.get(MyTask.KEY);
-
}
-
}
-
class MyTask implements Runnable {
-
public static final String KEY = "key";
-
private ConcurrentHashMap<String, Integer> map;
-
public MyTask(ConcurrentHashMap<String, Integer> map) {
-
this.map = map;
-
}
-
@Override
-
public void run() {
-
for (int i = 0; i < 100; i++) {
-
this.addup();
-
}
-
}
-
private void addup() {
-
if (!map.containsKey(KEY)) {
-
map.put(KEY, 1);
-
} else {
-
map.put(KEY, map.get(KEY) + 1);
-
}
-
}
-
}
測試程式碼跑了10次,每次都不是800。這就很讓人疑惑了,難道ConcurrentHashMap的執行緒安全性失效了?
查了一些資料後發現,原來ConcurrentHashMap的執行緒安全指的是,它的每個方法單獨呼叫(即原子操作)都是執行緒安全的,但是程式碼總體的互斥性並不受控制。以上面的程式碼為例,最後一行中的:
map.put(KEY, map.get(KEY) + 1);
實際上並不是原子操作,它包含了三步:
- map.get
- 加1
- map.put
其中第1和第3步,單獨來說都是執行緒安全的,由ConcurrentHashMap保證。但是由於在上面的程式碼中,map本身是一個共享變數。當執行緒A執行map.get的時候,其它執行緒可能正在執行map.put,這樣一來當執行緒A執行到map.put的時候,執行緒A的值就已經是髒資料了,然後髒資料覆蓋了真值,導致執行緒不安全
簡單地說,ConcurrentHashMap的get方法獲取到的是此時的真值,但它並不保證當你呼叫put方法的時候,當時獲取到的值仍然是真值
為了使上面的程式碼變得執行緒安全,我引入了synchronized關鍵字來修飾目標方法,如下:
-
public class Test40 {
-
public static void main(String[] args) throws InterruptedException {
-
for (int i = 0; i < 10; i++) {
-
System.out.println(test());
-
}
-
}
-
private static int test() throws InterruptedException {
-
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<String, Integer>();
-
ExecutorService pool = Executors.newCachedThreadPool();
-
for (int i = 0; i < 8; i++) {
-
pool.execute(new MyTask(map));
-
}
-
pool.shutdown();
-
pool.awaitTermination(1, TimeUnit.DAYS);
-
return map.get(MyTask.KEY);
-
}
-
}
-
class MyTask implements Runnable {
-
public static final String KEY = "key";
-
private ConcurrentHashMap<String, Integer> map;
-
public MyTask(ConcurrentHashMap<String, Integer> map) {
-
this.map = map;
-
}
-
@Override
-
public void run() {
-
for (int i = 0; i < 100; i++) {
-
this.addup();
-
}
-
}
-
private synchronized void addup() { // 用關鍵字synchronized修飾addup方法
-
if (!map.containsKey(KEY)) {
-
map.put(KEY, 1);
-
} else {
-
map.put(KEY, map.get(KEY) + 1);
-
}
-
}
-
}
執行之後仍然是執行緒不安全的,難道synchronized也失效了?
查閱了synchronized的資料後,原來,不管synchronized是用來修飾方法,還是修飾程式碼塊,其本質都是鎖定某一個物件。修飾方法時,鎖上的是呼叫這個方法的物件,即this;修飾程式碼塊時,鎖上的是括號裡的那個物件
在上面的程式碼中,很明顯就是鎖定的MyTask物件本身。但是由於在每一個執行緒中,MyTask物件都是獨立的,這就導致實際上每個執行緒都對自己的MyTask進行鎖定,而並不會干涉其它執行緒的MyTask物件。換言之,上鎖壓根沒有意義
理解到這點之後,對上面的程式碼又做了一次修改:
-
public class Test40 {
-
public static void main(String[] args) throws InterruptedException {
-
for (int i = 0; i < 10; i++) {
-
System.out.println(test());
-
}
-
}
-
private static int test() throws InterruptedException {
-
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<String, Integer>();
-
ExecutorService pool = Executors.newCachedThreadPool();
-
for (int i = 0; i < 8; i++) {
-
pool.execute(new MyTask(map));
-
}
-
pool.shutdown();
-
pool.awaitTermination(1, TimeUnit.DAYS);
-
return map.get(MyTask.KEY);
-
}
-
}
-
class MyTask implements Runnable {
-
public static final String KEY = "key";
-
private ConcurrentHashMap<String, Integer> map;
-
public MyTask(ConcurrentHashMap<String, Integer> map) {
-
this.map = map;
-
}
-
@Override
-
public void run() {
-
for (int i = 0; i < 100; i++) {
-
synchronized (map) { // 對共享物件map上鎖
-
this.addup();
-
}
-
}
-
}
-
private void addup() {
-
if (!map.containsKey(KEY)) {
-
map.put(KEY, 1);
-
} else {
-
map.put(KEY, map.get(KEY) + 1);
-
}
-
}
-
}
此時在呼叫addup時直接鎖定map,由於map是被所有執行緒共享的,因而達到了讓所有執行緒互斥的目的,執行緒安全達成。
修改後,ConcurrentHashMap的作用就不大了,可以直接將程式碼中的map換成普通的HashMap,以減少由ConcurrentHashMap帶來的鎖開銷
最後特別補充的是,synchronized關鍵字判斷物件是否是它屬於鎖定的物件,本質上是通過 == 運算子來判斷的。換句話說,上面的程式碼中,可以採用任何一個常量,或者每個執行緒都共享的變數,或者MyTask類的靜態變數,來代替map。只要該變數與synchronized鎖定的目標變數相同(==),就可以使synchronized生效
綜上,程式碼最終可以修改為:
-
public class Test40 {
-
public static void main(String[] args) throws InterruptedException {
-
for (int i = 0; i < 100; i++) {
-
System.out.println(test());
-
}
-
}
-
private static int test() throws InterruptedException {
-
Map<String, Integer> map = new HashMap<String, Integer>();
-
ExecutorService pool = Executors.newCachedThreadPool();
-
for (int i = 0; i < 8; i++) {
-
pool.execute(new MyTask(map));
-
}
-
pool.shutdown();
-
pool.awaitTermination(1, TimeUnit.DAYS);
-
return map.get(MyTask.KEY);
-
}
-
}
-
class MyTask implements Runnable {
-
public static Object lock = new Object();
-
public static final String KEY = "key";
-
private Map<String, Integer> map;
-
public MyTask(Map<String, Integer> map) {
-
this.map = map;
-
}
-
@Override
-
public void run() {
-
for (int i = 0; i < 100; i++) {
-
synchronized (lock) {
-
this.addup();
-
}
-
}
-
}
-
private void addup() {
-
if (!map.containsKey(KEY)) {
-
map.put(KEY, 1);
-
} else {
-
map.put(KEY, map.get(KEY) + 1);
-
}
-
}
-
}