併發、線程安全


 

概念、理論

併發:多個線程操作相同的資源,優點:效率高、資源利用率高,缺點:線程可能不安全、數據可能不一致,需要使用一些方式保證線程安全、數據一致

高併發:服務器能同時處理大量請求

線程安全:當多個線程訪問某個類,不管採用何種調度方式、線程如何交替執行,這個類都能表現出正確的行爲。

 

造成線程不安全的原因

  • 存在共享資源
  • 多個線程同時操作同一共享資源,操做不具有原子性

 

如何實現線程安全?

  • 使多線程不同時操作同一共享資源:eg. 只使用單線程、必要的部分加鎖、使用juc的併發容器、併發工具類
  • 使對共享資源的操作具有原子性:eg.使用原子類
  • 不共享資源:eg. 使用ThreadLocal
  • 用final修飾共享資源,使之只讀、不可修改

只要實現以上任意一點,即可實現線程安全

 

互斥鎖的特性

  • 互斥性:同一時刻只能有1個線程對這部分數據進行操作,互斥性也常叫做操作的原子性
  • 可見性:如果多個線程同時操作相同的數據(讀、寫),對數據做的修改能及時被其它線程觀測到。可見性用happens-before原則保證

 

鎖的實現原理

在這裏插入圖片描述
獲取鎖:把主內存中對應的共享資源讀取到本地內存中,將主內存中的該部分共享資源置爲無效
釋放鎖:把本地內存中的資源刷到主內存中,作爲共享資源,把本地內存中的該部分資源置爲無效

 

juc包簡介

juc包提供了大量的支持併發的類,包括

  • 線程池executor
  • 鎖locks,locks包及juc下一些常用類CountDownLatch、Semaphore基於AQS實現。jdk將同步的通用操作封裝在抽象類AbstractQueuedSynchronizer中,acquire()獲取資源的獨佔權(獲取鎖),release()釋放資源的獨佔權(釋放鎖)
  • 原子類atomic,atomic包基於CAS實現,實現了多線程下無鎖操作
  • 併發容器(集合)collections
  • 併發工具類tools

 

實現線程安全的常用方式

synchronized

synchronized的用法
// 修飾普通方法
public synchronized void a(){

}

// 修飾靜態方法
public static synchronized void b(){

}


public static Object lock = new Object();

public void c(){
    // 修飾代碼塊。同步代碼塊,鎖住一個對象
    synchronized (lock){
        
    }

}

synchronized可以修飾方法、代碼塊,修飾的操作是原子性的,同一時刻只能有1個線程訪問、執行

  • 修飾普通方法,執行該方法時會自動鎖住該方法所在的對象
  • 修飾靜態方法,加的是類鎖,執行該方法時會鎖住所在類的class對象,即鎖住該類所有實例
  • 修飾代碼塊,加的是對象鎖,會鎖住指定對象

如果要修飾方法,儘量用普通方法,因爲靜態方法因爲會鎖住類所有的實例,嚴重影響效率。

 

synchronized的實現原理

synchronized使用對象作爲鎖,對象在內存的佈局分爲3部分:對象頭、實例數據、對齊填充,對象頭佔64位

  • 前32位是Mark Word,存儲對象的hashCode、gc分代年齡、鎖類型、鎖標誌位等信息
  • 後32位是類型指針,存儲對象所屬的類的元數據的引用,jvm通過類型指針確定此對象是哪個類的實例
     

Mark Work結構如下
在這裏插入圖片描述
每個對象都關聯了一個Monitor(這也是爲什麼每個對象都可以作爲鎖的原因),鎖的指針指向對象對應的Monitor,當某個線程持有鎖時,Monitor處於鎖定狀態

 

synchronized的4種鎖狀態及膨脹方向

無鎖 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖

  • 無鎖:沒有線程要獲取鎖,未加鎖
  • 偏向鎖:大多數情況下,鎖不存在多線程競爭,很多時候都是同一線程多次申請鎖。偏向鎖簡化了線程再次申請鎖的流程,減少了同一線程多次獲取同一個鎖的代價。偏向鎖只適用於鎖競爭不激烈的情況
  • 輕量級鎖:適用於鎖競爭一般的情況
  • 重量級鎖:適用於鎖競爭激烈的情況
    在這裏插入圖片描述

 

使用Lock接口

synchronized使用前自動加鎖、使用完自動釋放鎖,很方便。synchronized是悲觀鎖的實現,每次操作共享資源前都要先加鎖;以前是重量級鎖,性能低,經過不斷優化,量級輕了很多,性能和Lock相比差距不再很大。

Lock需要自己加鎖、用完需要自己釋放。Lock是樂觀鎖的實現,每次先操作共享資源,提交修改時再驗證共享資源是否被其它線程修改過;Lock是輕量級鎖,性能很高。

Lock接口有很多實現類,常用的有ReentrantLock 可重入鎖、ReadWriteLock 讀寫鎖,也可以自己實現Lock接口來實現自定義的鎖。

 

ReentrantLock 可重入鎖

重入:一個線程再次獲取自己已持有的鎖

public class Xxx{
    public final static ReentrantLock lock=new ReentrantLock(); //鎖對象都可以加個final防止被修改
    //public final static ReentrantLock lock=new ReentrantLock(true);  //可指定是否是公平鎖,缺省時默認false

    public void a() {
        lock.lock();  //獲取鎖,如果未獲取到鎖,會一直阻塞在這裏
        // lock.tryLock();  //只嘗試1次,如果未獲取到鎖,直接失敗不執行後面的代碼
        
        //....   //操作共享資源
        
        lock.unlock();  //釋放鎖
    }
    
    public void b() {
        try {
            lock.tryLock(30, TimeUnit.SECONDS);  //如果獲取鎖失敗,會在指定時間內不停嘗試。此句代碼可能會拋出異常
            //.... //操作共享資源
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            if (!lock.isFair()){
                lock.unlock();  //如果獲取到鎖,最終要釋放鎖
            }
        }
    }

    public void c() {
        lock.lock();
        try {
            //.... //操作共享資源
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            lock.unlock();  //如果操作共享資源時可能發生異常,最終要釋放鎖
        }
    }
    
}

 

ReentrantLock如何實現公平鎖、非公平鎖?

使用鏈表存儲等待同一把鎖的線程,將線程添加到鏈表尾部,釋放鎖後

  • 公平鎖:將鎖分配給鏈表頭部的線程
  • 非公平鎖:將鎖分配個鏈表中的任意一個線程

將獲得鎖的線程從鏈表中移出

 

synchronized、ReentrantLock的比較
  • synchronized是關鍵字,ReentrantLock是類
  • 機制不同,synchronized是操作對象的Mark Word,ReentrantLock是使用Unsafe類的park()方法加鎖
  • synchronized是非公平鎖,ReentrantLock可以設置是否是公平鎖
  • ReentrantLock可以實現比synchronized更細粒度的控制,比如設置鎖的公平性
  • 鎖競爭不激烈時,synchronized的性能往往要比ReentrantLock高;鎖競爭激烈時,synchronized膨脹爲重量級鎖,性能不如ReentrantLock
  • ReentrantLock可以設置獲取鎖的等待時間,避免死鎖

 

ReadWriteLock 讀寫鎖

ReadWriteLock將鎖細粒度化分爲讀鎖、寫鎖,synchronized、ReentrantLock 同一時刻最多隻能有1個線程獲取到鎖,讀鎖同一時刻可以有多個線程獲取鎖,但都只能進行讀操作,寫鎖同一時刻最多隻能有1個線程獲取鎖進行寫操作,其它線程不能進行讀寫操作。

讀寫鎖做了更加細緻的權限劃分,加讀鎖時多個線程可以同時對共享資源進行讀操作,相比於synchronized、ReentrantLock,在以讀爲主的情況下可以提高性能。

ReadWriteLock是接口,常用的實現類是ReentrantReadWriteLock 可重入讀寫鎖。

public class Xxx {
    public static ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); 
    public static ReentrantReadWriteLock.ReadLock readLock = lock.readLock();  //從讀寫鎖獲取讀鎖
    public static ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();  //從讀寫鎖獲取寫鎖
    //.....

    public void a(){
        //....

        readLock.lock();
        //..... 操作共享資源
        readLock.unlock();
        
        //....
    }

}

讀鎖、寫鎖的操作方式和ReentrantLock完全相同,都可以設置超時,這3種鎖都是可重入鎖

 

鎖降級

在獲取寫鎖後,寫鎖可以降級爲讀鎖

public class Xxx {
    public static ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); 
    public static ReentrantReadWriteLock.ReadLock readLock = lock.readLock();  //讀鎖
    public static ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();  //寫鎖
    //.....

    public void a(){
        //....

        writeLock.lock();  //獲取寫鎖
        //..... 對共享資源進行寫操作
        readLock.lock();  //獲取讀鎖(仍然持有寫鎖)
        writeLock.unlock();  //釋放寫鎖(只持有讀鎖,寫鎖->讀鎖,鎖降級)
        //.....  //對共享資源進行讀操作
        readLock.unlock();  //釋放讀鎖
        
        //....
    }

}
  • 鎖降級後,線程仍然持有寫鎖,需要自己釋放寫鎖
  • 鎖降級的意義在於:後續對共享資源只進行讀操作,及時釋放寫鎖可以讓其它線程也能獲取到讀鎖、進行讀操作
  • 鎖降級的應用場景:對數據比較敏感,在修改數據之後,需要校驗數據
  • 寫鎖可以降級爲讀鎖,但讀鎖不能升級爲寫鎖

 

AQS如何用int值表示讀寫狀態

AbstractQueuedSynchronizer,抽象類

int,4字節32位,高位(前16位)表示讀鎖狀態,低位(後16位)表示寫鎖狀態。狀態指的是重入次數,最大爲2^16-1=65536

 

StampedLock

StampedLock是jdk1.8新增的類,可以獲取讀寫鎖、讀鎖、寫鎖,可以選擇悲觀鎖、樂觀鎖,但StampedLock是不可重入的,且API比其他方式複雜,使用難度稍高。

 

ThreadLocal

ThreadLocal維護了一個map,這個map中存儲的數據是當前線程獨有的。ThreadLocal可以保證各個線程的數據互不干擾,併發場景下可以實現無狀態調用,適用於各個線程依賴不同的變量值完成操作的場景。

public class Xxx {
    private static ThreadLocal<Integer> i = ThreadLocal.withInitial(() -> 100); //必須要初始化值

    public void a() {
        i.set(20);  //設置值
        Integer value = i.get();  //獲取值
        i.remove();  //移出set()賦的值,重置爲初始化時的值,即100
    }

}

 

volatile

volatile的使用
public static volatile boolean flag = true;  //禁止對此變量進行指令重排序

volatile只能修飾變量,實現了該變量的可見性、可以禁止指令重排序,當該變量的被某個線程修改時會自動通知其它使用此變量的線程。

volatile只實現了可見性,沒有實現原子性,嚴格來說並沒有實現線程安全,一般只用於

  • 作爲開關 ,eg. while(flag){ }
  • 在懶漢式單例中修飾對象實例,禁止指令重排序

 

volatile、synchronized的比較

在這裏插入圖片描述
 

原子類

i++、++i、i–、--i、+=、-=等操作都不是原子性的,juc的atomic包下的類提供了自增、自減、比較賦值、取值修改等原子性方法,可以線程安全地進行操作,因爲類中的方法都是原子性的,所有叫做原子類。

public class Xxx {

    public static AtomicInteger i = new AtomicInteger(0);  //int
    public static AtomicLong l = new AtomicLong(0);  //long
    public static AtomicBoolean b = new AtomicBoolean(false);  //boolean
    public static AtomicReference<User> user = new AtomicReference<>(new User());  //引用

    public static AtomicIntegerArray intArr = new AtomicIntegerArray(new int[]{1, 23});  //int[ ]
    public static AtomicLongArray longArr = new AtomicLongArray(new long[]{1, 23});  //long[ ]

    public static AtomicIntegerFieldUpdater<User> userId1 = AtomicIntegerFieldUpdater.newUpdater(User.class,"id");  //對象的int型字段
    public static AtomicLongFieldUpdater<User> userId2 = AtomicLongFieldUpdater.newUpdater(User.class,"id");  //對象的long型字段
    public static AtomicReferenceFieldUpdater<User, List>  userOrderList= AtomicReferenceFieldUpdater.newUpdater(User.class, List.class,"orderList");  //對象的引用型字段
    
}
  • 原子類使用CAS實現樂觀鎖,併發支持好、效率高
  • CAS提交修改失敗時會while循環進行重試,如果重試時間過長,會給cpu帶來很大開銷
  • 可能發生ABA問題。有2個原子類解決了ABA問題 :AtomicMarkableReference、AtomicStampedReference,使用標記、郵戳實現樂觀鎖,和版本號、時間戳機制差不多,避免了ABA問題。
  • 只能保證單個變量的原子性,只能進行簡單操作,如果要保證多個變量、稍微複雜點的操作的原子性,要用其它方式來實現線程安全(一般是加鎖)
     

併發容器

Vector、Hashtable 的方法都使用synchronized修飾,是線程安全的,但缺點較多,基本不使用這2個類。

Collections.synchronizedXxx()可以將集合轉換爲同步集合,是使用synchronized鎖住整個集合,效率低下,不推薦。

juc提供了常用的併發容器,使用CAS保證線程安全,效率高,常見的併發容器如下

CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();  //有序,按照插入順序排列,內部使用Object[]存儲元素
CopyOnWriteArraySet<String> set = new CopyOnWriteArraySet<>(); //無序,CopyOnWriteArraySet內部使用CopyOnWriteArrayList存儲元素

ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();  //map

ConcurrentLinkedQueue<String> queue1 = new ConcurrentLinkedQueue<>();  //基於鏈表的隊列

LinkedBlockingQueue<String> queue2 = new LinkedBlockingQueue<>();  //基於鏈表的阻塞隊列,如果參數指定了元素個數,則有界、不能擴容,如果未指定,則無界

ArrayBlockingQueue<String> queue3 = new ArrayBlockingQueue<>(20);  //基於數組的阻塞隊列,指定容量,不能擴容(有界)
ArrayBlockingQueue<String> queue4 = new ArrayBlockingQueue<>(20,true);  //可以指定是否是公平鎖,默認false

阻塞指的是,在進行某些操作時,會阻塞線程

 
在生產者/消費者的線程協作模式中,常用阻塞隊列LinkedBlockingQueue作爲倉庫

LinkedBlockingQueue<String> queue = new LinkedBlockingQueue<>();  //基於鏈表的阻塞隊列
        
//入隊的3個方法
queue.offer("");  //返會操作結果,boolean,如果隊列滿了放不下,返回false
queue.add("");  //返會操作結果,boolean,如果隊列滿了放不下,會拋出異常
try {
    queue.put("");  //如果隊列滿了,會阻塞線程,直到隊列元素變少、可以放進去
} catch (InterruptedException e) {
    e.printStackTrace();
}


//出隊的3個方法
queue.poll();  //如果隊列是空的,返回null
queue.remove();  //如果隊列是空的,會拋出異常
try {
    queue.take();  //在隊列爲空的時候,會阻塞線程,直到有元素可彈出
} catch (InterruptedException e) {
    e.printStackTrace();
}

 

併發工具類

CountDownLatch

CountDownLatch是一個計數器,常用於等待某些線程執行完畢

CountDownLatch countDownLatch = new CountDownLatch(2);  //指定次數

new Thread(()->{
    //.....
    countDownLatch.countDown();  //次數-1
}).start();


new Thread(()->{
    //......
    countDownLatch.countDown();
}).start();


try {
    countDownLatch.await();  //阻塞當前線程,直到次數爲0時才繼續往下執行,即等待2個線程執行完畢
    //......
} catch (InterruptedException e) {
    e.printStackTrace();
}

 

CyclicBarrier 柵欄

CyclicBarrier cyclicBarrier = new CyclicBarrier(3);  //指定await的線程數

new Thread(()->{
    //......
    try {
        cyclicBarrier.await();  //第一個
        //.....
    } catch (InterruptedException e) {
        e.printStackTrace();
    } catch (BrokenBarrierException e) {
        e.printStackTrace();
    }
}).start();


new Thread(()->{
    //......
    try {
        cyclicBarrier.await();  //第二個
        //.....
    } catch (InterruptedException e) {
        e.printStackTrace();
    } catch (BrokenBarrierException e) {
        e.printStackTrace();
    }
}).start();


//.....
try {
    cyclicBarrier.await();  //第三個
    //.....
} catch (InterruptedException e) {
    e.printStackTrace();
} catch (BrokenBarrierException e) {
    e.printStackTrace();
}

線程執行到await()處會阻塞,停下來,直到指定數量的線程都執行到await()纔會繼續往下執行。

CountDownLatch用於一些線程等待另一些線程執行完畢,類似超市收銀員等待顧客挑好東西來結賬;CyclicBarrier用於指定數量的線程互相等待,類似於大家指定地點集合。

 

Semaphore 信號量

Semaphore用於限流

Semaphore semaphore = new Semaphore(2);  //指定信號量
// Semaphore semaphore = new Semaphore(2,true);  //可指定是否使用公平鎖,默認false

new Thread(() -> {
    //......
    try {
        semaphore.acquire();  //使用1個信號量,信號量-1。如果信號量爲0,沒有可用的信號量,阻塞線程直到獲取到信號量
        //....
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        semaphore.release();  //操作完釋放信號量,信號量+1
    }
}).start();

 

Exchanger

交換機,用於2條線程之間交換數據,只能用於2條線程之間,即一個Exchanger對象只能被2條線程使用(成對)

Exchanger<String> stringExchanger = new Exchanger<>();  //泛型指定交換的數據類型

new Thread(()->{
    try {
        String data = stringExchanger.exchange("are you ok?");
        System.out.println("線程1接收到的數據:" + data);  //ok
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}).start();

new Thread(()->{
    try {
        String data = stringExchanger.exchange("ok");
        System.out.println("線程2接收到的數據:" + data);  //are you ok
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}).start();

 

單例與線程安全

單例有2種模式

  • 餓漢式:在類加載時就實例化,線程安全
  • 懶漢式:在需要使用實例時才實例化,可能是線程不安全的

 
餓漢式

//餓漢式
public class A {
    private static A a=new A();  //用靜態成員保存實例,調用構造方法創建實例。類加載時會初始化靜態成員
    //.....  

    private A(){  //構造方法私有化,隱藏掉

    }

    public static A getInstance(){  //把獲取實例的方法暴露出去
        return a;  //只有1步,原子性,線程安全
    }

    //.....  
}

 

懶漢式

//懶漢式  寫法一
class A {
    private static A a;  //用靜態成員保存實例
    //.....   

    private A(){  //構造方法私有化,隱藏掉

    }

    public static A getInstance(){  //暴露獲取實例的方法,多步,不具有原子性,不是線程安全的
        if (null==a){  
            a = new A();  
        }
        return a;
    }

    //.....   

}

 
寫法二:用synchronized修飾獲取獲取實例的靜態方法,但這種方式獲取實例時會鎖住類,使多個線程不能同時獲取實例,效率低下
 

//懶漢式  寫法三
class A {
    private static volatile A a;  //volatile禁止指令重排序
    //.....   

    private A(){

    }

    public static A getInstance(){
        if (null==a){
            synchronized (A.class){  //優化寫法,只在創建實例時鎖住類
                a = new A();
            }
        }
        return a;
    }

    //.....  

}

 

鎖的分類

  • 自旋鎖:未獲取到鎖時進入等待狀態,多線程切換上下文會消耗系統資源,頻繁切換上下文不值得,jvm會在線程沒獲取到鎖時,暫時執行空循環等待獲取鎖,即自旋,循環次數即自旋次數;如果在指定自旋次數內沒獲取到鎖,則掛起線程,切換上下文,執行其它線程。鎖默認是自旋的。

  • 自適應自旋鎖:自旋次數不固定,由上一次獲取該鎖的自旋時間及鎖持有者的狀態決定,更加智能
     

  • 阻塞鎖:阻塞鎖會改變線程的運行狀態,讓線程進入阻塞狀態進行等待,當獲得相應信號(喚醒或阻塞時間結束)時,進入就緒狀態
     

  • 重入鎖:已持有鎖的線程,在未釋放鎖時,可以再次獲取到該鎖

public class Xxx{
    public final static ReentrantLock lock=new ReentrantLock();

    public void a() {
        lock.lock();
        //.....
        b();  //如果鎖是可重入的,則b()直接獲取到鎖;如果鎖不是可重入的,則b()需要單獨獲取獲取鎖,但鎖還沒被a()釋放,b()會一直獲取不到鎖
        //.....
        lock.unlock();
    }

    public void b() {
        lock.lock();
        //......
        lock.unlock();
    }

}

 

  • 讀鎖:是一種共享鎖 | S鎖(share),多條線程可同時操作共享資源,但都只能進行讀操作、不能進行寫操作

  • 寫鎖:是一種排它鎖 | 互斥鎖 | 獨佔鎖 | X鎖,同一時刻最多隻能有1個線程可以對共享資源進行寫操作,其它線程不能對該資源進行讀寫
     

  • 悲觀鎖:每次操作共享資源時,認爲期間其它線程一定會修改共享資源,每次操作共享數據之前,都要給共享資源加鎖

  • 樂觀鎖:每次操作共享資源時,認爲期間其它線程一般不會修改共享資源,操作共享資源時不給共享資源加鎖,只在提交修改時驗證數據是否被其它線程修改過,常用版本號等方式實現樂觀鎖
     

  • 公平鎖:等待鎖的線程按照先來先得順序獲取鎖(慎用)

  • 非公平鎖:釋放鎖後,等待鎖的線程都可能獲取到鎖,不是先來先得

非公平鎖可能導致某些線程長時間甚至一直獲取不到鎖,但這種情況畢竟是極少數;使用公平鎖,爲保證公平性有額外的開銷,會降低性能,所以一般使用非公平鎖
 

  • 偏向鎖:初次獲取鎖後,鎖進入偏向模式,當獲取過鎖的線程再次獲取該鎖時會簡化獲取鎖的流程,即鎖偏向於曾經獲取過它的線程

 

鎖消除:編譯時會掃描上下文,自動去除不可能存在線程競爭的鎖

鎖細化:如果只操作共享資源的一部分,不用給整個共享資源加鎖,只需給要操作的部分加鎖即可。使用細粒度的鎖可以讓多個線程同時操作共享資源的不同部分,提高效率。

鎖粗化:要操作共享資源的多個部分,如果每次只給部分加鎖,頻繁加鎖、釋放鎖會影響性能,可以擴大鎖的作用範圍,給整個共享資源加鎖,避免頻繁加鎖帶來的開銷。

 

指令重排序

指令重排序:編譯器、處理器會對指令序列重新排序,提高執行效率、優化程序性能

int a=1;
int b=1;

以上2條指令會被重排序,可能2條指令併發執行,可能int a=1;先執行,可能int b=1;先執行。
 

指令重排序遵循的2個原則

1、 數據依賴性,不改變存在數據依賴關係的兩個操作的執行順序。

int a=1;
int b=a;

b依賴於a,重排序不能改變這2個語句的執行順序
 

2、as-if-serial原則,重排序不能改變單條線程的執行結果

int a=1;
int b=a;

執行結果是a=1、b=1,重排序後執行得到的也要是這個結果

 

數據同步接口

有時候需要對接第三方的項目,或者公司大部門之間對接業務,不能直接連接、操作他們的數據庫,一般是建中間庫|中間表,把我們|他們需要的數據放到中間庫|表中,去中間庫|表獲取數據。更新數據庫時需要同步更新中間庫|表。
 

中間表的設計

  • 只存儲要使用的字段即可
  • 需要用一個字段記錄該條數據的狀態:已入庫、正在處理、處理時發生異常、已處理
  • 需要用一個字段記錄數據入庫時間
  • 需要用一個字段記錄處理時間

記錄時間是爲了日後好排查問題、統計分析

 

對中間表的處理

可以使用生產者/消費者的線程協作模式

  • 生產者分批讀取中間表中未處理的數據 where status=‘xxx’,放到倉庫中。因爲數據量一般很大,所以通常要分批讀取,防止倉庫裝不下。如果要操作多張表,很多操作都差不多,可以抽象出接口
  • 消費者處理倉庫中的數據

操作時需要更新中間表中的數據狀態、處理時間

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章