首頁  >  文章  >  Java  >  Java鎖定在工作中使用場景實例分析

Java鎖定在工作中使用場景實例分析

PHPz
PHPz轉載
2023-04-28 15:34:141027瀏覽

    1、synchronized

    synchronized 是可重入的排它鎖,和ReentrantLock 鎖功能相似,任何使用synchronized 的地方,幾乎都可以使用ReentrantLock 來代替,兩者最大的相似點就是:可重入排它鎖,兩者的區別主要有這些:

    • ReentrantLock 的功能更加豐富,比如提供了Condition,可以打斷的加鎖API、能滿足鎖隊列的複雜場景等等;

    • ReentrantLock 有公平鎖和非公平鎖之分,而synchronized 都是非公平鎖;

    • 兩者的使用姿勢也不同,ReentrantLock 需要申明,有加鎖和釋放鎖的API,而synchronized 會自動對程式碼區塊進行加鎖釋放鎖的操作,synchronized 使用起來更加方便。

    synchronized 和 ReentrantLock 功能相近,所以我們就以 synchronized 舉例。

    1.1、共享資源初始化

    在分散式的系統中,我們喜歡把一些死的設定資源在專案啟動的時候加鎖到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 鎖住的是整個操作,確保整個操作只執行一次。

    2、CountDownLatch

    2.1、場景

    1:小明在淘寶上買了一個商品,覺得不好,把這個商品退掉(商品還沒出貨,只退錢),我們叫做單商品退款,單商品退款在後台系統中運行時,整體耗時30 毫秒。

    2:雙11,小明在淘寶上買了40 個商品,產生了同一個訂單(實際上可能會產生多個訂單,為了方便描述,我們說成一個),第二天小明發現其中30 個商品是自己衝動消費的,需要把30 個商品一起退掉。

    2.2、實作

    此時後台只有單商品退款的功能,沒有大量商品退款的功能(30 個商品一次退我們稱為批量),為了快速實現這個功能,同學A 按照這樣的方案做的:for 循環調用30 次單商品退款的接口,在qa 環境測試的時候發現,如果要退款30 個商品的話,需要耗時:30 * 30 = 900 毫秒,再加上其它的邏輯,退款30 個商品差不多需要1 秒了,這個耗時其實算很久了,當時同學A 提出了這個問題,希望大家幫忙看看如何優化整個場景的耗時。

    同学 B 当时就提出,你可以使用线程池进行执行呀,把任务都提交到线程池里面去,假如机器的 CPU 是 4 核的,最多同时能有 4 个单商品退款可以同时执行,同学 A 觉得很有道理,于是准备修改方案,为了便于理解,我们把两个方案都画出来,对比一下:

    Java鎖定在工作中使用場景實例分析

    同学 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);
      }
    }

    上述代码只是大概的底层思路,真实的项目会在此思路之上加上请求分组,超时打断等等优化措施。

    我们来看一下执行的结果:

    Java鎖定在工作中使用場景實例分析

    从执行的截图中,我们可以明显的看到 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中文網其他相關文章!

    陳述:
    本文轉載於:yisu.com。如有侵權,請聯絡admin@php.cn刪除