聊聊保證線程安全的 10 個小技巧

前言

對於從事後端開發的同學來說,線程安全問題是我們每天都需要考慮的問題。

線程安全問題通俗的講:主要是在多線程的環境下,不同線程同時讀和寫公共資源(臨界資源),導致的數據異常問題。

比如:變量 a=0,線程 1 給該變量 + 1,線程 2 也給該變量 + 1。此時,線程 3 獲取 a 的值有可能不是 2,而是 1。線程 3 這不就獲取了錯誤的數據?

線程安全問題會直接導致數據異常,從而影響業務功能的正常使用,所以這個問題還是非常嚴重的。

那麼,如何解決線程安全問題呢?

今天跟大家一起聊聊,保證線程安全的 10 個小技巧,希望對你有所幫助。

  1. 無狀態

我們都知道只有多個線程訪問公共資源的時候,纔可能出現數據安全問題,那麼如果我們沒有公共資源,是不是就沒有這個問題呢?

例如:

public class NoStatusService {

    public void add(String status) {
        System.out.println("add status:" + status);
    }

    public void update(String status) {
        System.out.println("update status:" + status);
    }
}

這個例子中 NoStatusService 沒有定義公共資源,換句話說是無狀態的。

這種場景中,NoStatusService 類肯定是線程安全的。

  1. 不可變

如果多個線程訪問的公共資源是不可變的,也不會出現數據的安全性問題。

例如:

public class NoChangeService {
    public static final String DEFAULT_NAME = "abc";

    public void add(String status) {
        System.out.println(DEFAULT_NAME);
    }
}

DEFAULT_NAME 被定義成了static final的常量,在多線程中環境中不會被修改,所以這種情況,也不會出現線程安全問題。

  1. 無修改權限

有時候,我們定義了公共資源,但是該資源只暴露了讀取的權限,沒有暴露修改的權限,這樣也是線程安全的。

例如:

public class SafePublishService {
    private String name;

    public String getName() {
        return name;
    }

    public void add(String status) {
        System.out.println("add status:" + status);
    }
}

這個例子中,沒有對外暴露修改 name 字段的入口,所以不存在線程安全問題。

  1. synchronized

使用JDK內部提供的同步機制,這也是使用比較多的手段,分爲:同步方法同步代碼塊

我們優先使用同步代碼塊,因爲同步方法的粒度是整個方法,範圍太大,相對來說,更消耗代碼的性能。

其實,每個對象內部都有一把,只有搶到那把鎖的線程,才被允許進入對應的代碼塊執行相應的代碼。

當代碼塊執行完之後,JVM 底層會自動釋放那把鎖。

例如:

public class SyncService {
    private int age = 1;
    private Object object = new Object();

    //同步方法
    public synchronized void add(int i) {
        age = age + i;        
        System.out.println("age:" + age);
    }

    
    public void update(int i) {
        //同步代碼塊,對象鎖
        synchronized (object) {
            age = age + i;                     
            System.out.println("age:" + age);
        }    
     }
     
     public void update(int i) {
        //同步代碼塊,類鎖
        synchronized (SyncService.class) {
            age = age + i;                     
            System.out.println("age:" + age);
        }    
     }
}
  1. Lock

除了使用synchronized關鍵字實現同步功能之外,JDK 還提供了Lock接口,這種顯示鎖的方式。

通常我們會使用Lock接口的實現類:ReentrantLock,它包含了:公平鎖非公平鎖可重入鎖讀寫鎖 等更多更強大的功能。

例如:

public class LockService {
    private ReentrantLock reentrantLock = new ReentrantLock();
    public int age = 1;
    
    public void add(int i) {
        try {
            reentrantLock.lock();
            age = age + i;           
            System.out.println("age:" + age);
        } finally {
            reentrantLock.unlock();        
        }    
   }
}

但如果使用 ReentrantLock,它也帶來了有個小問題就是:需要在finally代碼塊中手動釋放鎖

不過說句實話,在使用Lock顯示鎖的方式,解決線程安全問題,給開發人員提供了更多的靈活性。

  1. 分佈式鎖

如果是在單機的情況下,使用synchronizedLock保證線程安全是沒有問題的。

但如果在分佈式的環境中,即某個應用如果部署了多個節點,每一個節點使用可以synchronizedLock保證線程安全,但不同的節點之間,沒法保證線程安全。

這就需要使用:分佈式鎖了。

分佈式鎖有很多種,比如:數據庫分佈式鎖,zookeeper 分佈式鎖,redis 分佈式鎖等。

其中我個人更推薦使用 redis 分佈式鎖,其效率相對來說更高一些。

使用 redis 分佈式鎖的僞代碼如下:

try{
  String result = jedis.set(lockKey, requestId, "NX""PX", expireTime);
  if ("OK".equals(result)) {
      return true;
  }
  return false;
} finally {
    unlock(lockKey);
}

同樣需要在finally代碼塊中釋放鎖。

如果你對 redis 分佈式鎖的用法和常見的坑,比較感興趣的話,可以看看我的另一篇文章《聊聊 redis 分佈式鎖的 8 大坑》,裏面有更詳細的介紹。

  1. volatile

有時候,我們有這樣的需求:如果在多個線程中,有任意一個線程,把某個開關的狀態設置爲 false,則整個功能停止。

簡單的需求分析之後發現:只要求多個線程間的可見性,不要求原子性

如果一個線程修改了狀態,其他的所有線程都能獲取到最新的狀態值。

這樣一分析這就好辦了,使用volatile就能快速滿足需求。

例如:

@Service
public CanalService {
    private volatile boolean running = false;
    private Thread thread;

    @Autowired
    private CanalConnector canalConnector;
    
    public void handle() {
        //連接canal
        while(running) {
           //業務處理
        }
    }
    
    public void start() {
       thread = new Thread(this::handle, "name");
       running = true;
       thread.start();
    }
    
    public void stop() {
       if(!running) {
          return;
       }
       running = false;
    }
}

需要特別注意的地方是:volatile不能用於計數和統計等業務場景。因爲volatile不能保證操作的原子性,可能會導致數據異常。

  1. ThreadLocal

除了上面幾種解決思路之外,JDK 還提供了另外一種用空間換時間的新思路:ThreadLocal

當然 ThreadLocal 並不能完全取代鎖,特別是在一些秒殺更新庫存中,必須使用鎖。

ThreadLocal 的核心思想是:共享變量在每個線程都有一個副本,每個線程操作的都是自己的副本,對另外的線程沒有影響。

溫馨提醒一下:我們平常在使用 ThreadLocal 時,如果使用完之後,一定要記得在finally代碼塊中,調用它的remove方法清空數據,不然可能會出現內存泄露問題。

例如:

public class ThreadLocalService {
    private ThreadLocal<Integer> threadLocal = new ThreadLocal<>();

    public void add(int i) {
        Integer integer = threadLocal.get();
        threadLocal.set(integer == null ? 0 : integer + i);
    }
}

如果對 ThreadLocal 感興趣的小夥伴,可以看看我的另一篇文章《ThreadLocal 奪命 11 連問》,裏面有對 ThreadLocal 的原理、用法和坑,有非常詳細的介紹。

  1. 線程安全集合

有時候,我們需要使用的公共資源放在某個集合當中,比如:ArrayList、HashMap、HashSet 等。

如果在多線程環境中,有線程往這些集合中寫數據,另外的線程從集合中讀數據,就可能會出現線程安全問題。

爲了解決集合的線程安全問題,JDK 專門給我們提供了能夠保證線程安全的集合。

比如:CopyOnWriteArrayList、ConcurrentHashMap、CopyOnWriteArraySet、ArrayBlockingQueue 等等。

例如:

public class HashMapTest {

    private static ConcurrentHashMap<String, Object> hashMap = new ConcurrentHashMap<>();

    public static void main(String[] args) {

        new Thread(new Runnable() {
            @Override
            public void run() {
                hashMap.put("key1""value1");
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                hashMap.put("key2""value2");
            }
        }).start();

        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(hashMap);
    }
}

在 JDK 底層,或者 spring 框架當中,使用 ConcurrentHashMap 保存加載配置參數的場景非常多。

比較出名的是 spring 的refresh方法中,會讀取配置文件,把配置放到很多的 ConcurrentHashMap 緩存起來。

  1. CAS

JDK 除了使用鎖的機制解決多線程情況下數據安全問題之外,還提供了CAS機制

這種機制是使用 CPU 中比較和交換指令的原子性,JDK 裏面是通過Unsafe類實現的。

CAS 內部包含了四個值:舊數據期望數據新數據地址,比較舊數據 和 期望的數據,如果一樣的話,就把舊數據改成新數據。如果不一樣的話,當前線程不斷自旋,一直到成功爲止。

不過,使用 CAS 保證線程安全,可能會出現ABA問題,需要使用AtomicStampedReference增加版本號解決。

其實,實際工作中很少直接使用Unsafe類的,一般用atomic包下面的類即可。

public class AtomicService {
    private AtomicInteger atomicInteger = new AtomicInteger();
    
    public int add(int i) {
        return atomicInteger.getAndAdd(i);
    }
}
  1. 數據隔離

有時候,我們在操作集合數據時,可以通過數據隔離,來保證線程安全。

例如:

public class ThreadPoolTest {

    public static void main(String[] args) {

      ExecutorService threadPool = new ThreadPoolExecutor(8, //corePoolSize線程池中核心線程數
      10, //maximumPoolSize 線程池中最大線程數
      60, //線程池中線程的最大空閒時間,超過這個時間空閒線程將被回收
      TimeUnit.SECONDS,//時間單位
      new ArrayBlockingQueue(500), //隊列
      new ThreadPoolExecutor.CallerRunsPolicy()); //拒絕策略

      List<User> userList = Lists.newArrayList(
      new User(1L, "蘇三", 18, "成都"),
      new User(2L, "蘇三說技術", 20, "四川"),
      new User(3L, "技術", 25, "雲南"));

      for (User user : userList) {
          threadPool.submit(new Work(user));
      }

      try {
          Thread.sleep(100);
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
      System.out.println(userList);
  }

    static class Work implements Runnable {
        private User user;

        public Work(User user) {
            this.user = user;
        }

        @Override
        public void run() {
            user.setName(user.getName() + "測試");
        }
    }
}

這個例子中,使用線程池處理用戶信息。

每個用戶只被線程池中的一個線程處理,不存在多個線程同時處理一個用戶的情況。所以這種人爲的數據隔離機制,也能保證線程安全。

數據隔離還有另外一種場景:kafka 生產者把同一個訂單的消息,發送到同一個 partion 中。每一個 partion 都部署一個消費者,在 kafka 消費者中,使用單線程接收消息,並且做業務處理。

這種場景下,從整體上看,不同的 partion 是用多線程處理數據的,但同一個 partion 則是用單線程處理的,所以也能解決線程安全問題。

如果你對 kafka 的使用比較感興趣,可以看看我的另一篇乾貨文章《我用 kafka 兩年踩過的一些非比尋常的坑》。

蘇三說技術 作者就職於知名互聯網公司,掘金月度優秀作者,從事開發、架構和部分管理工作。實戰經驗豐富,對 jdk、spring、springboot、springcloud、mybatis 等開源框架源碼有一定研究,歡迎關注,和我一起交流。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/MV-oPx3YTysX-ImZWc8_VQ