對於從事後端開發的同學來說,執行緒安全
問題是我們每天都需要考慮的問題。
執行緒安全問題通俗的講:主要是在多執行緒的環境下,不同執行緒同時讀和寫公共資源(臨界資源),導致的資料異常問題。
比如:變數a=0,執行緒1給該變數+1,執行緒2也給該變數+1。此時,執行緒3獲取a的值有可能不是2,而是1。執行緒3這不就獲取了錯誤的資料?
執行緒安全問題會直接導致資料異常,從而影響業務功能的正常使用,所以這個問題還是非常嚴重的。
那麼,如何解決執行緒安全問題呢?
今天跟大家一起聊聊,保證執行緒安全的10個小技巧,希望對你有所幫助。
我們都知道只有多個執行緒存取公共資源
的時候,才可能出現資料安全問題,那麼如果我們沒有公共資源,是不是就沒有這個問題呢?
例如:
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類肯定是執行緒安全的。
如果多個執行緒存取的公共資源是不可變
的,也不會出現資料的安全性問題。
例如:
public class NoChangeService {
public static final String DEFAULT_NAME = "abc";
public void add(String status) {
System.out.println(DEFAULT_NAME);
}
}
DEFAULT_NAME被定義成了static
final
的常數,在多執行緒中環境中不會被修改,所以這種情況,也不會出現執行緒安全問題。
有時候,我們定義了公共資源,但是該資源只暴露了讀取的許可權,沒有暴露修改的許可權,這樣也是執行緒安全的。
例如:
public class SafePublishService {
private String name;
public String getName() {
return name;
}
public void add(String status) {
System.out.println("add status:" + status);
}
}
這個例子中,沒有對外暴露修改name欄位的入口,所以不存線上程安全問題。
使用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);
}
}
}
除了使用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
顯示鎖的方式,解決執行緒安全問題,給開發人員提供了更多的靈活性。
如果是在單機的情況下,使用synchronized
和Lock
保證執行緒安全是沒有問題的。
但如果在分散式的環境中,即某個應用如果部署了多個節點,每一個節點使用可以synchronized
和Lock
保證執行緒安全,但不同的節點之間,沒法保證執行緒安全。
這就需要使用:分散式鎖
了。
分散式鎖有很多種,比如:資料庫分散式鎖,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大坑》,裡面有更詳細的介紹。
有時候,我們有這樣的需求:如果在多個執行緒中,有任意一個執行緒,把某個開關的狀態設定為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
不能保證操作的原子性,可能會導致資料異常。
除了上面幾種解決思路之外,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的原理、用法和坑,有非常詳細的介紹。
有時候,我們需要使用的公共資源放在某個集合當中,比如: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快取起來。
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);
}
}
有時候,我們在操作集合資料時,可以通過資料隔離
,來保證執行緒安全。
例如:
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兩年踩過的一些非比尋常的坑》。
如果這篇文章對您有所幫助,或者有所啟發的話,幫忙掃描下發二維條碼關注一下,您的支援是我堅持寫作最大的動力。
求一鍵三連:點贊、轉發、在看。
關注公眾號:【蘇三說技術】,在公眾號中回覆:面試、程式碼神器、開發手冊、時間管理有超讚的粉絲福利,另外回覆:加群,可以跟很多BAT大廠的前輩交流和學習。