1. 程式人生 > >ConcurrentHashMap、synchronized與執行緒安全

ConcurrentHashMap、synchronized與執行緒安全

在看spring原始碼時,看到synchronized 包圍了 ConcurrentHashMap

最近做的專案中遇到一個問題:明明用了ConcurrentHashMap,可是始終執行緒不安全

除去專案中的業務邏輯,簡化後的程式碼如下:

  1. public class Test40 {

  2. public static void main(String[] args) throws InterruptedException {

  3. for (int i = 0; i < 10; i++) {

  4. System.out.println(test());

  5. }

  6. }

  7. private static int test() throws InterruptedException {

  8. ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<String, Integer>();

  9. ExecutorService pool = Executors.newCachedThreadPool();

  10. for (int i = 0; i < 8; i++) {

  11. pool.execute(new MyTask(map));

  12. }

  13. pool.shutdown();

  14. pool.awaitTermination(1, TimeUnit.DAYS);

  15. return map.get(MyTask.KEY);

  16. }

  17. }

  18. class MyTask implements Runnable {

  19. public static final String KEY = "key";

  20. private ConcurrentHashMap<String, Integer> map;

  21. public MyTask(ConcurrentHashMap<String, Integer> map) {

  22. this.map = map;

  23. }

  24. @Override

  25. public void run() {

  26. for (int i = 0; i < 100; i++) {

  27. this.addup();

  28. }

  29. }

  30. private void addup() {

  31. if (!map.containsKey(KEY)) {

  32. map.put(KEY, 1);

  33. } else {

  34. map.put(KEY, map.get(KEY) + 1);

  35. }

  36. }

  37. }

測試程式碼跑了10次,每次都不是800。這就很讓人疑惑了,難道ConcurrentHashMap的執行緒安全性失效了?

查了一些資料後發現,原來ConcurrentHashMap的執行緒安全指的是,它的每個方法單獨呼叫(即原子操作)都是執行緒安全的,但是程式碼總體的互斥性並不受控制。以上面的程式碼為例,最後一行中的:

map.put(KEY, map.get(KEY) + 1);

實際上並不是原子操作,它包含了三步:

  1. map.get
  2. 加1
  3. map.put

其中第1和第3步,單獨來說都是執行緒安全的,由ConcurrentHashMap保證。但是由於在上面的程式碼中,map本身是一個共享變數。當執行緒A執行map.get的時候,其它執行緒可能正在執行map.put,這樣一來當執行緒A執行到map.put的時候,執行緒A的值就已經是髒資料了,然後髒資料覆蓋了真值,導致執行緒不安全

簡單地說,ConcurrentHashMap的get方法獲取到的是此時的真值,但它並不保證當你呼叫put方法的時候,當時獲取到的值仍然是真值

為了使上面的程式碼變得執行緒安全,我引入了synchronized關鍵字來修飾目標方法,如下:

  1. public class Test40 {

  2. public static void main(String[] args) throws InterruptedException {

  3. for (int i = 0; i < 10; i++) {

  4. System.out.println(test());

  5. }

  6. }

  7. private static int test() throws InterruptedException {

  8. ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<String, Integer>();

  9. ExecutorService pool = Executors.newCachedThreadPool();

  10. for (int i = 0; i < 8; i++) {

  11. pool.execute(new MyTask(map));

  12. }

  13. pool.shutdown();

  14. pool.awaitTermination(1, TimeUnit.DAYS);

  15. return map.get(MyTask.KEY);

  16. }

  17. }

  18. class MyTask implements Runnable {

  19. public static final String KEY = "key";

  20. private ConcurrentHashMap<String, Integer> map;

  21. public MyTask(ConcurrentHashMap<String, Integer> map) {

  22. this.map = map;

  23. }

  24. @Override

  25. public void run() {

  26. for (int i = 0; i < 100; i++) {

  27. this.addup();

  28. }

  29. }

  30. private synchronized void addup() { // 用關鍵字synchronized修飾addup方法

  31. if (!map.containsKey(KEY)) {

  32. map.put(KEY, 1);

  33. } else {

  34. map.put(KEY, map.get(KEY) + 1);

  35. }

  36. }

  37. }

執行之後仍然是執行緒不安全的,難道synchronized也失效了?

查閱了synchronized的資料後,原來,不管synchronized是用來修飾方法,還是修飾程式碼塊,其本質都是鎖定某一個物件。修飾方法時,鎖上的是呼叫這個方法的物件,即this;修飾程式碼塊時,鎖上的是括號裡的那個物件

在上面的程式碼中,很明顯就是鎖定的MyTask物件本身。但是由於在每一個執行緒中,MyTask物件都是獨立的,這就導致實際上每個執行緒都對自己的MyTask進行鎖定,而並不會干涉其它執行緒的MyTask物件。換言之,上鎖壓根沒有意義

理解到這點之後,對上面的程式碼又做了一次修改:

  1. public class Test40 {

  2. public static void main(String[] args) throws InterruptedException {

  3. for (int i = 0; i < 10; i++) {

  4. System.out.println(test());

  5. }

  6. }

  7. private static int test() throws InterruptedException {

  8. ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<String, Integer>();

  9. ExecutorService pool = Executors.newCachedThreadPool();

  10. for (int i = 0; i < 8; i++) {

  11. pool.execute(new MyTask(map));

  12. }

  13. pool.shutdown();

  14. pool.awaitTermination(1, TimeUnit.DAYS);

  15. return map.get(MyTask.KEY);

  16. }

  17. }

  18. class MyTask implements Runnable {

  19. public static final String KEY = "key";

  20. private ConcurrentHashMap<String, Integer> map;

  21. public MyTask(ConcurrentHashMap<String, Integer> map) {

  22. this.map = map;

  23. }

  24. @Override

  25. public void run() {

  26. for (int i = 0; i < 100; i++) {

  27. synchronized (map) { // 對共享物件map上鎖

  28. this.addup();

  29. }

  30. }

  31. }

  32. private void addup() {

  33. if (!map.containsKey(KEY)) {

  34. map.put(KEY, 1);

  35. } else {

  36. map.put(KEY, map.get(KEY) + 1);

  37. }

  38. }

  39. }

此時在呼叫addup時直接鎖定map,由於map是被所有執行緒共享的,因而達到了讓所有執行緒互斥的目的,執行緒安全達成。

修改後,ConcurrentHashMap的作用就不大了,可以直接將程式碼中的map換成普通的HashMap,以減少由ConcurrentHashMap帶來的鎖開銷

最後特別補充的是,synchronized關鍵字判斷物件是否是它屬於鎖定的物件,本質上是通過 == 運算子來判斷的。換句話說,上面的程式碼中,可以採用任何一個常量,或者每個執行緒都共享的變數,或者MyTask類的靜態變數,來代替map。只要該變數與synchronized鎖定的目標變數相同(==),就可以使synchronized生效

綜上,程式碼最終可以修改為:

  1. public class Test40 {

  2. public static void main(String[] args) throws InterruptedException {

  3. for (int i = 0; i < 100; i++) {

  4. System.out.println(test());

  5. }

  6. }

  7. private static int test() throws InterruptedException {

  8. Map<String, Integer> map = new HashMap<String, Integer>();

  9. ExecutorService pool = Executors.newCachedThreadPool();

  10. for (int i = 0; i < 8; i++) {

  11. pool.execute(new MyTask(map));

  12. }

  13. pool.shutdown();

  14. pool.awaitTermination(1, TimeUnit.DAYS);

  15. return map.get(MyTask.KEY);

  16. }

  17. }

  18. class MyTask implements Runnable {

  19. public static Object lock = new Object();

  20. public static final String KEY = "key";

  21. private Map<String, Integer> map;

  22. public MyTask(Map<String, Integer> map) {

  23. this.map = map;

  24. }

  25. @Override

  26. public void run() {

  27. for (int i = 0; i < 100; i++) {

  28. synchronized (lock) {

  29. this.addup();

  30. }

  31. }

  32. }

  33. private void addup() {

  34. if (!map.containsKey(KEY)) {

  35. map.put(KEY, 1);

  36. } else {

  37. map.put(KEY, map.get(KEY) + 1);

  38. }

  39. }

  40. }