synchronized 是可重入的排它鎖,和ReentrantLock 鎖功能相似,任何使用synchronized 的地方,幾乎都可以使用ReentrantLock 來代替,兩者最大的相似點就是:可重入排它鎖,兩者的區別主要有這些:
ReentrantLock 的功能更加豐富,比如提供了Condition,可以打斷的加鎖API、能滿足鎖隊列的複雜場景等等;
ReentrantLock 有公平鎖和非公平鎖之分,而synchronized 都是非公平鎖;
兩者的使用姿勢也不同,ReentrantLock 需要申明,有加鎖和釋放鎖的API,而synchronized 會自動對程式碼區塊進行加鎖釋放鎖的操作,synchronized 使用起來更加方便。
synchronized 和 ReentrantLock 功能相近,所以我們就以 synchronized 舉例。
在分散式的系統中,我們喜歡把一些死的設定資源在專案啟動的時候加鎖到JVM 記憶體裡面去,這樣請求在拿這些共享配置資源時,就可直接從記憶體裡面拿,不必每次都從資料庫拿,減少了時間開銷。
一般這樣的共享資源有:死的業務流程配置 死的業務規則配置。
共享資源初始化的步驟一般為:專案啟動-> 觸發初始化動作->單線程從資料庫中撈取資料-> 組裝成我們需要的資料結構-> 放到JVM 記憶體中。
在專案啟動時,為了防止共享資源被多次加載,我們往往會加上排它鎖,讓一個執行緒加載共享資源完成之後,另外一個執行緒才能繼續加載,此時的排它鎖我們可以選擇synchronized 或ReentrantLock,我們以synchronized 為例,寫了mock 的程式碼,如下:
// 共享资源 private static final Map<String, String> SHARED_MAP = Maps.newConcurrentMap(); // 有无初始化完成的标志位 private static boolean loaded = false; /** * 初始化共享资源 */ @PostConstruct public void init(){ if(loaded){ return; } synchronized (this){ // 再次 check if(loaded){ return; } log.info("SynchronizedDemo init begin"); // 从数据库中捞取数据,组装成 SHARED_MAP 的数据格式 loaded = true; log.info("SynchronizedDemo init end"); } }
不知道大家有沒有從上述程式碼中發現@PostConstruct 註解,@PostConstruct 註解的作用是在Spring 容器初始化時,再執行此註解打上的方法,也就是說上圖說的init 方法觸發的時機,是在Spring 容器啟動的時候。
大家可以下載示範程式碼,找到 DemoApplication 啟動文件,在 DemoApplication 文件上右鍵點擊 run,就可以啟動整個 Spring Boot 項目,在 init 方法上打上斷點就可以調試了。
我們在程式碼中使用了synchronized 來保證同一時刻,只有一個執行緒可以執行初始化共享資源的操作,並且我們加了一個共享資源載入完成的識別位(loaded),來判斷是否載入完成了,如果加載完成,那麼其它加載線程直接返回。
如果把synchronized 換成ReentrantLock 也是一樣的實現,只不過需要顯示的使用ReentrantLock 的API 進行加鎖和釋放鎖,使用ReentrantLock 有一點要注意的是,我們需要在try 方法區塊中加鎖,在finally 方法區塊中釋放鎖,這樣保證即使try 中加鎖後發生異常,在finally 中也可以正確的釋放鎖。
有的同學可能會問,不是可以直接使用了 ConcurrentHashMap 麼,為什麼還需要加鎖呢?的確ConcurrentHashMap 是線程安全的,但它只能夠保證Map 內部資料操作時的線程安全,是無法保證多線程情況下,查詢資料庫並組裝資料的整個動作只執行一次的,我們加synchronized 鎖住的是整個操作,確保整個操作只執行一次。
1:小明在淘寶上買了一個商品,覺得不好,把這個商品退掉(商品還沒出貨,只退錢),我們叫做單商品退款,單商品退款在後台系統中運行時,整體耗時30 毫秒。
2:雙11,小明在淘寶上買了40 個商品,產生了同一個訂單(實際上可能會產生多個訂單,為了方便描述,我們說成一個),第二天小明發現其中30 個商品是自己衝動消費的,需要把30 個商品一起退掉。
此時後台只有單商品退款的功能,沒有大量商品退款的功能(30 個商品一次退我們稱為批量),為了快速實現這個功能,同學A 按照這樣的方案做的:for 循環調用30 次單商品退款的接口,在qa 環境測試的時候發現,如果要退款30 個商品的話,需要耗時:30 * 30 = 900 毫秒,再加上其它的邏輯,退款30 個商品差不多需要1 秒了,這個耗時其實算很久了,當時同學A 提出了這個問題,希望大家幫忙看看如何優化整個場景的耗時。
同学 B 当时就提出,你可以使用线程池进行执行呀,把任务都提交到线程池里面去,假如机器的 CPU 是 4 核的,最多同时能有 4 个单商品退款可以同时执行,同学 A 觉得很有道理,于是准备修改方案,为了便于理解,我们把两个方案都画出来,对比一下:
同学 A 于是就按照演变的方案去写代码了,过了一天,抛出了一个问题:向线程池提交了 30 个任务后,主线程如何等待 30 个任务都执行完成呢?因为主线程需要收集 30 个子任务的执行情况,并汇总返回给前端。
大家可以先不往下看,自己先思考一下,我们前几章说的那种锁可以帮助解决这个问题?
CountDownLatch 可以的,CountDownLatch 具有这种功能,让主线程去等待子任务全部执行完成之后才继续执行。
此时还有一个关键,我们需要知道子线程执行的结果,所以我们用 Runnable 作为线程任务就不行了,因为 Runnable 是没有返回值的,我们需要选择 Callable 作为任务。
我们写了一个 demo,首先我们来看一下单个商品退款的代码:
// 单商品退款,耗时 30 毫秒,退款成功返回 true,失败返回 false @Slf4j public class RefundDemo { /** * 根据商品 ID 进行退款 * @param itemId * @return */ public boolean refundByItem(Long itemId) { try { // 线程沉睡 30 毫秒,模拟单个商品退款过程 Thread.sleep(30); log.info("refund success,itemId is {}", itemId); return true; } catch (Exception e) { log.error("refundByItemError,itemId is {}", itemId); return false; } } }
接着我们看下 30 个商品的批量退款,代码如下:
@Slf4j public class BatchRefundDemo { // 定义线程池 public static final ExecutorService EXECUTOR_SERVICE = new ThreadPoolExecutor(10, 10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(20)); @Test public void batchRefund() throws InterruptedException { // state 初始化为 30 CountDownLatch countDownLatch = new CountDownLatch(30); RefundDemo refundDemo = new RefundDemo(); // 准备 30 个商品 List<Long> items = Lists.newArrayListWithCapacity(30); for (int i = 0; i < 30; i++) { items.add(Long.valueOf(i+"")); } // 准备开始批量退款 List<Future> futures = Lists.newArrayListWithCapacity(30); for (Long item : items) { // 使用 Callable,因为我们需要等到返回值 Future<Boolean> future = EXECUTOR_SERVICE.submit(new Callable<Boolean>() { @Override public Boolean call() throws Exception { boolean result = refundDemo.refundByItem(item); // 每个子线程都会执行 countDown,使 state -1 ,但只有最后一个才能真的唤醒主线程 countDownLatch.countDown(); return result; } }); // 收集批量退款的结果 futures.add(future); } log.info("30 个商品已经在退款中"); // 使主线程阻塞,一直等待 30 个商品都退款完成,才能继续执行 countDownLatch.await(); log.info("30 个商品已经退款完成"); // 拿到所有结果进行分析 List<Boolean> result = futures.stream().map(fu-> { try { // get 的超时时间设置的是 1 毫秒,是为了说明此时所有的子线程都已经执行完成了 return (Boolean) fu.get(1,TimeUnit.MILLISECONDS); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } catch (TimeoutException e) { e.printStackTrace(); } return false; }).collect(Collectors.toList()); // 打印结果统计 long success = result.stream().filter(r->r.equals(true)).count(); log.info("执行结果成功{},失败{}",success,result.size()-success); } }
上述代码只是大概的底层思路,真实的项目会在此思路之上加上请求分组,超时打断等等优化措施。
我们来看一下执行的结果:
从执行的截图中,我们可以明显的看到 CountDownLatch 已经发挥出了作用,主线程会一直等到 30 个商品的退款结果之后才会继续执行。
接着我们做了一个不严谨的实验(把以上代码执行很多次,求耗时平均值),通过以上代码,30 个商品退款完成之后,整体耗时大概在 200 毫秒左右。
而通过 for 循环单商品进行退款,大概耗时在 1 秒左右,前后性能相差 5 倍左右,for 循环退款的代码如下:
long begin1 = System.currentTimeMillis(); for (Long item : items) { refundDemo.refundByItem(item); } log.info("for 循环单个退款耗时{}",System.currentTimeMillis()-begin1);
性能的巨大提升是线程池 + 锁两者结合的功劳。
以上是Java鎖定在工作中使用場景實例分析的詳細內容。更多資訊請關注PHP中文網其他相關文章!