雪花演算法對System.currentTimeMillis()最佳化真的有用麼?

秦怀杂货店發表於2021-11-30

前面已經講過了雪花演算法,裡面使用了System.currentTimeMillis()獲取時間,有一種說法是認為System.currentTimeMillis()慢,是因為每次呼叫都會去跟系統打一次交道,在高併發情況下,大量併發的系統呼叫容易會影響效能(對它的呼叫甚至比new一個普通物件都要耗時,畢竟new產生的物件只是在Java記憶體中的堆中)。我們可以看到它呼叫的是native 方法:

// 返回當前時間,以毫秒為單位。注意,雖然返回值的時間單位是毫秒,但值的粒度取決於底層作業系統,可能更大。例如,許多作業系統以數十毫秒為單位度量時間。
public static native long currentTimeMillis();

所以有人提議,用後臺執行緒定時去更新時鐘,並且是單例的,避免每次都與系統打交道,也避免了頻繁的執行緒切換,這樣或許可以提高效率。

這個最佳化成立麼?

先上最佳化程式碼:

package snowflake;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

public class SystemClock {

    private final int period;

    private final AtomicLong now;

    private static final SystemClock INSTANCE = new SystemClock(1);

    private SystemClock(int period) {
        this.period = period;
        now = new AtomicLong(System.currentTimeMillis());
        scheduleClockUpdating();
    }

    private void scheduleClockUpdating() {
        ScheduledExecutorService scheduleService = Executors.newSingleThreadScheduledExecutor((r) -> {
            Thread thread = new Thread(r);
            thread.setDaemon(true);
            return thread;
        });
        scheduleService.scheduleAtFixedRate(() -> {
            now.set(System.currentTimeMillis());
        }, 0, period, TimeUnit.MILLISECONDS);
    }

    private long get() {
        return now.get();
    }

    public static long now() {
        return INSTANCE.get();
    }

}

只需要用SystemClock.now()替換System.currentTimeMillis()即可。

雪花演算法SnowFlake的程式碼也放在這裡:

package snowflake;

public class SnowFlake {

    // 資料中心(機房) id
    private long datacenterId;
    // 機器ID
    private long workerId;
    // 同一時間的序列
    private long sequence;

    public SnowFlake(long workerId, long datacenterId) {
        this(workerId, datacenterId, 0);
    }

    public SnowFlake(long workerId, long datacenterId, long sequence) {
        // 合法判斷
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
        }
        if (datacenterId > maxDatacenterId || datacenterId < 0) {
            throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
        }
        System.out.printf("worker starting. timestamp left shift %d, datacenter id bits %d, worker id bits %d, sequence bits %d, workerid %d",
                timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId);

        this.workerId = workerId;
        this.datacenterId = datacenterId;
        this.sequence = sequence;
    }

    // 開始時間戳(2021-10-16 22:03:32)
    private long twepoch = 1634393012000L;

    // 機房號,的ID所佔的位數 5個bit 最大:11111(2進位制)--> 31(10進位制)
    private long datacenterIdBits = 5L;

    // 機器ID所佔的位數 5個bit 最大:11111(2進位制)--> 31(10進位制)
    private long workerIdBits = 5L;

    // 5 bit最多隻能有31個數字,就是說機器id最多隻能是32以內
    private long maxWorkerId = -1L ^ (-1L << workerIdBits);

    // 5 bit最多隻能有31個數字,機房id最多隻能是32以內
    private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);

    // 同一時間的序列所佔的位數 12個bit 111111111111 = 4095  最多就是同一毫秒生成4096個
    private long sequenceBits = 12L;

    // workerId的偏移量
    private long workerIdShift = sequenceBits;

    // datacenterId的偏移量
    private long datacenterIdShift = sequenceBits + workerIdBits;

    // timestampLeft的偏移量
    private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

    // 序列號掩碼 4095 (0b111111111111=0xfff=4095)
    // 用於序號的與運算,保證序號最大值在0-4095之間
    private long sequenceMask = -1L ^ (-1L << sequenceBits);

    // 最近一次時間戳
    private long lastTimestamp = -1L;


    // 獲取機器ID
    public long getWorkerId() {
        return workerId;
    }


    // 獲取機房ID
    public long getDatacenterId() {
        return datacenterId;
    }


    // 獲取最新一次獲取的時間戳
    public long getLastTimestamp() {
        return lastTimestamp;
    }


    // 獲取下一個隨機的ID
    public synchronized long nextId() {
        // 獲取當前時間戳,單位毫秒
        long timestamp = timeGen();

        if (timestamp < lastTimestamp) {
            System.err.printf("clock is moving backwards.  Rejecting requests until %d.", lastTimestamp);
            throw new RuntimeException(String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds",
                    lastTimestamp - timestamp));
        }

        // 去重
        if (lastTimestamp == timestamp) {

            sequence = (sequence + 1) & sequenceMask;

            // sequence序列大於4095
            if (sequence == 0) {
                // 呼叫到下一個時間戳的方法
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            // 如果是當前時間的第一次獲取,那麼就置為0
            sequence = 0;
        }

        // 記錄上一次的時間戳
        lastTimestamp = timestamp;

        // 偏移計算
        return ((timestamp - twepoch) << timestampLeftShift) |
                (datacenterId << datacenterIdShift) |
                (workerId << workerIdShift) |
                sequence;
    }

    private long tilNextMillis(long lastTimestamp) {
        // 獲取最新時間戳
        long timestamp = timeGen();
        // 如果發現最新的時間戳小於或者等於序列號已經超4095的那個時間戳
        while (timestamp <= lastTimestamp) {
            // 不符合則繼續
            timestamp = timeGen();
        }
        return timestamp;
    }

    private long timeGen() {
        return SystemClock.now();
        // return System.currentTimeMillis();
    }

    public static void main(String[] args) {
        SnowFlake worker = new SnowFlake(1, 1);
        long timer = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) {
            worker.nextId();
        }
        System.out.println(System.currentTimeMillis());
        System.out.println(System.currentTimeMillis() - timer);
    }
}

Windows:i5-4590 16G記憶體 4核 512固態

Mac: Mac pro 2020 512G固態 16G記憶體

Linux:deepin系統,虛擬機器,160G磁碟,記憶體8G

單執行緒環境測試一下 System.currentTimeMillis()

平臺/資料量10000100000010000000100000000
mac5247244424416
windows3249244824426
linux(deepin)135598407626388

單執行緒環境測試一下 SystemClock.now()

平臺/資料量10000100000010000000100000000
mac52299250124674
windows56394238934389983
linux(deepin)3361226445427639

上面的單執行緒測試並沒有體現出後臺時鐘執行緒處理的優勢,反而在windows下,資料量大的時候,變得異常的慢,linux系統上,也並沒有快,反而變慢了一點。

多執行緒測試程式碼:

    public static void main(String[] args) throws InterruptedException {
        int threadNum = 16;
        CountDownLatch countDownLatch = new CountDownLatch(threadNum);
        int num = 100000000 / threadNum;
        long timer = System.currentTimeMillis();
        thread(num, countDownLatch);
        countDownLatch.await();
        System.out.println(System.currentTimeMillis() - timer);

    }

    public static void thread(int num, CountDownLatch countDownLatch) {
        List<Thread> threadList = new ArrayList<>();
        for (int i = 0; i < countDownLatch.getCount(); i++) {
            Thread cur = new Thread(new Runnable() {
                @Override
                public void run() {
                    SnowFlake worker = new SnowFlake(1, 1);
                    for (int i = 0; i < num; i++) {
                        worker.nextId();
                    }
                    countDownLatch.countDown();
                }
            });
            threadList.add(cur);
        }
        for (Thread t : threadList) {
            t.start();
        }
    }

下面我們用不同執行緒數來測試 100000000(一億) 資料量 System.currentTimeMillis()

平臺/執行緒24816
mac14373613234103247
windows12408686267917114
linux20753190551891919602

用不同執行緒數來測試 100000000(一億) 資料量 SystemClock.now()

平臺/執行緒24816
mac12319627536913746
windows194763110442153960174974
linux26516253132549725544

在多執行緒的情況下,我們可以看到mac上沒有什麼太大變化,隨著執行緒數增加,速度還變快了,直到超過 8 的時候,但是windows上明顯變慢了,測試的時候我都開始刷起了小影片,才跑出來結果。而且這個資料和處理器的核心也是相關的,當windows的執行緒數超過了 4 之後,就變慢了,原因是我的機器只有四核,超過了就會發生很多上下文切換的情況。

linux上由於虛擬機器,核數增加的時候,並無太多作用,但是時間對比於直接呼叫 System.currentTimeMillis()其實是變慢的。

但是還有個問題,到底不同方法呼叫,時間重複的機率哪一個大呢?

    static AtomicLong atomicLong = new AtomicLong(0);
    private long timeGen() {
        atomicLong.incrementAndGet();
        // return SystemClock.now();
        return System.currentTimeMillis();
    }

下面是1千萬id,八個執行緒,測出來呼叫timeGen()的次數,也就是可以看出時間衝突的次數:

平臺/方法SystemClock.now()System.currentTimeMillis()
mac2306720912896314
windows70546003935164476
linux116555235281422626

可以看出確實SystemClock.now()自己維護時間,獲取的時間相同的可能性更大,會觸發更多次數的重複呼叫,衝突次數變多,這個是不利因素!還有一個殘酷的事實,那就是自己定義的後臺時間重新整理,獲取的時間不是那麼的準確。在linux中的這個差距就更大了,時間衝突次數太多了。

結果

實際測試下來,並沒有發現SystemClock.now()能夠最佳化很大的效率,反而會由於競爭,獲取時間衝突的可能性更大。JDK開發人員真的不傻,他們應該也經過了很長時間的測試,比我們自己的測試靠譜得多,因此,個人觀點,最終證明這個最佳化並不是那麼的可靠。

不要輕易相信某一個結論,如果有疑問,請一定做做實驗,或者找足夠權威的說法。

【作者簡介】
秦懷,公眾號【秦懷雜貨店】作者,技術之路不在一時,山高水長,縱使緩慢,馳而不息。個人寫作方向:Java原始碼解析JDBCMybatisSpringredis分散式劍指OfferLeetCode等,認真寫好每一篇文章,不喜歡標題黨,不喜歡花裡胡哨,大多寫系列文章,不能保證我寫的都完全正確,但是我保證所寫的均經過實踐或者查詢資料。遺漏或者錯誤之處,還望指正。

劍指Offer全部題解PDF

2020年我寫了什麼?

開源程式設計筆記

相關文章