併發程序的測試

概述

併發測試大致分爲兩類,即安全性測試與活躍性測試。安全性是“不發生任何錯誤的行爲”,活躍性是“某個良好的行爲終究會發生”。

吞吐量:指一組併發任務中已完成任務所佔的比例。

響應性:指請求從發出到完成之間的時間。

可伸縮性:指在增加更多資源的情況下(通常指CPU),吞吐量(或者緩解短缺)的提升情況。

正確性測試

在爲某個併發類設計單元測試時,首先需要執行與測試串行類時相同的分析——找出需要檢查的不變形條件和後驗條件。

基於信號量的有界緩存:

public class BoundedBuffer<E> {
    private final Semaphore availableItems, availableSpaces;
    private final E[] items;
    private int putPosition = 0, takePosition = 0;

    public BoundedBuffer(int capacity) {
        availableItems = new Semaphore(0);
        availableSpaces = new Semaphore(capacity);
        items = (E[]) new Object[capacity];
    }

    public boolean isEmpty() {
        return availableItems.availablePermits() == 0;
    }

    public boolean isFull() {
        return availableSpaces.availablePermits() == 0;
    }

    public void put(E x) throws InterruptedException {
        availableSpaces.acquire();
        doInsert(x);
        availableItems.release();
    }

    public E take() throws InterruptedException {
        availableItems.acquire();
        E item = doExtract();
        availableSpaces.release();
        return item;
    }

    private synchronized E doExtract() {
        int i = takePosition;
        E x = items[i];
        items[i] = null;
        takePosition = (++i == items.length) ? 0 : 1;
        return x;
    }

    private synchronized void doInsert(E x) {
        int i = putPosition;
        items[i] = x;
        putPosition = (++i == items.length) ? 0 : 1;
    }
}

I.基本的單元測試

基本的單元測試,找出與併發性無關的問題。

public void testIsEmptyWhenConstructed() {
    BoundedBuffer<Integer> bb = new BoundedBuffer<>(10);
    assertTrue(bb.isEmpty());
    assertFalse(bb.isFull());
}

public void testIsFullAfterPuts() throws InterruptedException {
    BoundedBuffer<Integer> bb = new BoundedBuffer<>(10);
    for (int i = 0; i < 10; i++) {
        bb.put(i);
    }
    assertTrue(bb.isFull());
    assertFalse(bb.isEmpty());
}

II.對阻塞操作的測試

要測試一個方法的阻塞行爲,類似於測試一個拋出異常的方法:如果這個方法可以正常返回,那麼就意味着測試失敗。

在測試方法的阻塞行爲時,將引入額外的複雜性:當方法被成功地阻塞後,還必須使方法解除阻塞。實現這個功能的一種簡單方式就是使用中斷——在一個單獨的線程中啓動一個阻塞操作,等到線程阻塞後再中斷它,然後宣告阻塞操作成功。當然,這要求阻塞方法通過提前返回或者拋出InterruptedException來響應中斷。

public void testTakeBlocksWhenEmpty() {
   final BoundedBuffer<Integer> bb = new BoundedBuffer<>(10);
   Thread taker = new Thread(){
       @Override
       public void run() {
           try {
               int unused = bb.take();
               fail();//執行到這裏,表示出錯
           } catch (InterruptedException success) {
               success.printStackTrace();
           }
       }
   };
    try {
        taker.start();
        Thread.sleep(5000);
        taker.interrupt();
        taker.join(5000);
        assertFalse(taker.isAlive());
    } catch (Exception unexpected) {
        fail();
    }
}

III.安全性測試

要想測試一個併發類在不可預測的併發情況下能否正確執行,需要創建多個線程來分別執行put和take操作,並在執行一段時間後判斷在測試中是否會出現問題。要測試在生產者-消費者模式中使用的類,一種有效的方法就是檢查被放入隊列中和從隊列中取出的各個元素,通過一個對順序敏感的校驗和計算函數來計算所有入列元素以及出列元素的校驗和,並進行比較。

public class PutTakeTest extends TestCase {
    private static final ExecutorService pool = Executors.newCachedThreadPool();
    private final AtomicInteger putSum = new AtomicInteger(0);
    private final AtomicInteger takeSum = new AtomicInteger(0);
    private final CyclicBarrier barrier;
    private final BoundedBuffer<Integer> bb;
    private final int nTrials, nPairs;

    public PutTakeTest(int capacity, int npairs, int ntrials) {
        this.bb = new BoundedBuffer<>(capacity);
        this.nTrials = ntrials;
        this.nPairs = npairs;1
        this.barrier = new CyclicBarrier(npairs * 2 + 1);
    }

    static int xorShift(int y) {
        y ^= (y << 6);
        y ^= (y >> 21);
        y ^= (y << 7);
        return y;
    }

    public static void main(String[] args) {
        new PutTakeTest(50, 1, 5).test();
        pool.shutdown();
    }

    void test() {
        try {
            for (int i = 0; i < nPairs; i++) {
                pool.execute(new Producer());
                pool.execute(new Consumer());
            }
            barrier.await();//等待所有的線程就緒
            barrier.await();//等待所有的線程完成
            assertEquals(putSum.get(), takeSum.get());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private class Producer implements Runnable {
        @Override
        public void run() {
            try {
                int seed = (this.hashCode() ^ (int) System.nanoTime());
                int sum = 0;
                barrier.await();
                for (int i = nTrials; i > 0; --i) {
                    bb.put(seed);
                    sum += seed;
                    seed = xorShift(seed);
                }
                putSum.getAndAdd(sum);
                barrier.await();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    private class Consumer implements Runnable {
        @Override
        public void run() {
            try {
                barrier.await();
                int ele, sum = 0;
                for (int i = nTrials; i > 0; --i) {
                    ele = bb.take();//未知的java.lang.NullPointerException,待研究
                    sum += ele;
                }
                takeSum.getAndAdd(sum);
                barrier.await();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

IV.資源管理的測試

對於任何持有或管理其他對象的對象,都應該在不需要這些對象時銷燬對它們的引用。這種存儲資源泄露不僅會妨礙垃圾回收器回收內存(或線程、文件句柄、套接字、數據庫連接或其他有限資源),而且還會導致資源耗盡以及應用程序失敗。

public void testLeak() throws InterruptedException {
    BoundedBuffer<Big> bb = new BoundedBuffer<Big>(CAPACITY);
    long heapSize1 = Runtime.getRuntime().freeMemory();//獲取空閒堆大小
    for (int i = 0; i < CAPACITY; i++) {
        bb.put(new Big());
    }
    for (int j = 0; j < CAPACITY; j++) {
        bb.take();
    }
    long heapSize2 = Runtime.getRuntime().freeMemory();//獲取空閒堆大小
    assertTrue(Math.abs(heapSize1 - heapSize2) < THRESHOLD);
}

V.使用回調

在構造測試案例時,對客戶提供的代碼進行回調是非常有幫助的。回調函數的執行通常是在對象生命週期的一些已知位置上,並且在這些位置上非常適合判斷不變性條件是否被破壞。例如,在ThreadPoolExecutor中將調用任務的Runnable和ThreadFactory。

public void testPoolExpansion() throws InterruptedException {
    int MAX_SIZE = 10;
    MyThreadFactory threadFactory = new MyThreadFactory();
    ExecutorService exec = Executors.newFixedThreadPool(MAX_SIZE,threadFactory);
    for (int i = 0; i < 10 * MAX_SIZE; i++) {
        exec.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(Long.MAX_VALUE);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        });
    }
    for (int i = 0; i < 20 && threadFactory.numCreated.get() < MAX_SIZE; i++) 
        Thread.sleep(100);
    assertEquals(MAX_SIZE, threadFactory.numCreated.get());
    exec.shutdown();
}

VI.產生更多的交替操作

由於併發代碼中的大多數錯誤都是一些低概率事件,因此在測試併發錯誤時需要反覆地執行許多次,但有些方法可以提高發現這些錯誤的概率。有一種有用的方法可以提高交替操作的數量,以便能更有效地搜索程序的狀態空間:在訪問共享狀態的操作中,使用Thread.yield將產生更多的上下文切換。

public synchronized void transfer(Account from, Account to, int amount) {
    Random random = new Random();
    from.setBalance(from.getBalance().subtract(new BigDecimal(amount)));
    if (random.nextInt(1000) > THRESHOLD) {
        Thread.yield();
    }
    to.setBalance(from.getBalance().subtract(new BigDecimal(amount)));
}

性能測試

性能測試將衡量典型測試用例中的端到端性能。性能測試的第二個目標時根據經驗值來調整各種不同的限值,例如線程數量、緩存容量等。  

PutTakeTest中增加計時功能

不測量單個操作的時間,而是實現一種更精確的測量方式:記錄整個運行過程的時間,然後除以總操作的數量,從而得到每次操作的運行時間。

public class TimedPutTakeTest {
    private static final ExecutorService pool = Executors.newCachedThreadPool();
    private final AtomicInteger putSum = new AtomicInteger(0);
    private final AtomicInteger takeSum = new AtomicInteger(0);
    private final BoundedBuffer<Integer> bb;
    private final int nTrials, nPairs;
    private final BarrierTimer timer;
    private final CyclicBarrier barrier;

    public TimedPutTakeTest(int capacity, int npairs, int ntrials) {
        this.bb = new BoundedBuffer<>(capacity);
        this.nTrials = ntrials;
        this.nPairs = npairs;
        this.timer = new BarrierTimer();
        this.barrier = new CyclicBarrier(npairs * 2 + 1, timer);
    }

    static int xorShift(int y) {
        y ^= (y << 6);
        y ^= (y >> 21);
        y ^= (y << 7);
        return y;
    }

    public static void main(String[] args) throws Exception {
        int tpt = 100000;//每個線程中的測試次數
        for (int cap = 1; cap <= 1000; cap *= 10) {
            System.out.println("Capacity: " + cap);
            for (int pairs = 1; pairs <= 128; pairs *= 2) {
                TimedPutTakeTest t = new TimedPutTakeTest(cap, pairs, tpt);
                System.out.print("Pairs: " + pairs + "\t");
                t.test();
                System.out.println("\t");
                Thread.sleep(1000);
                t.test();
                System.out.println();
                Thread.sleep(1000);
            }
        }
        pool.shutdown();
    }

    void test() {
        try {
            timer.clear();
            for (int i = 0; i < nPairs; i++) {
                pool.execute(new Producer());
                pool.execute(new Consumer());
            }
            barrier.await();//等待所有的線程就緒
            barrier.await();//等待所有的線程完成
            long nsPerItem = timer.getTime() / (nPairs * (long) nTrials);
            System.out.println("Throughput: " + nsPerItem + " ns/item");
            System.out.println(putSum.get() + "-" + takeSum.get());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private class Producer implements Runnable {
        @Override
        public void run() {
            try {
                int seed = (this.hashCode() ^ (int) System.nanoTime());
                int sum = 0;
                barrier.await();
                for (int i = nTrials; i > 0; --i) {
                    bb.put(seed);
                    sum += seed;
                    seed = xorShift(seed);
                }
                putSum.getAndAdd(sum);
                barrier.await();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    private class Consumer implements Runnable {
        @Override
        public void run() {
            try {
                barrier.await();
                int ele, sum = 0;
                for (int i = nTrials; i > 0; --i) {
                    ele = bb.take();//未知的java.lang.NullPointerException,待研究
                    sum += ele;
                }
                takeSum.getAndAdd(sum);
                barrier.await();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

}

避免性能測試的陷阱

理論上,開發性能測試程序是很容易的—找出一個典型的使用場景,編寫一段程序多次執行這種使用場景,並統計程序的運行時間。但在實際情況中,你必須提防多種編碼陷阱,它們會使性能測試變得毫無意義。

I.垃圾回收

有兩種策略可以防止垃圾回收操作對測試結果產生偏差。第一種策略是,確保垃圾回收操作在測試運行的整個期間都不會執行(可以在調用JVM時指定-verbose:gc來判斷是否執行了垃圾回收操作)。第二種策略是,確保垃圾回收操作在測試期間執行多次,這樣測試程序就能充分反映出運行期間的內存分配與垃圾回收等開銷。

II.動態編譯

當某個類第一次被加載時,JVM會通過解譯字節碼的方式來執行它。在某個時刻,如果一個方法運行的次數足夠多,那麼動態編譯器會將它編譯爲機器代碼,當編譯完成後,代碼的執行方式將從解釋執行編程直接執行。

如果編譯器可以在測試期間運行,那麼將在兩個方面對測試結果帶來偏差:在編譯過程中將消耗CPU資源,並且,如果在測量的代碼中既包含解釋執行的代碼,又包含編譯執行的代碼,那麼通過測試這種混合代碼得到的性能指標沒有太大意義。

有一種方式可以防止動態編譯對測試結構產生偏差,就是使程序運行足夠長的時間(至少數分鐘),這樣編譯過程以及解釋執行都只是總運行時間的很小一部分。另一種方法是使代碼預先運行一段時間並且不測試這段時間內的代碼性能,這樣在開始計時前代碼就已經被完全編譯了。

III.對代碼的不真實採樣

運行時編譯器根據收集到的信息對已編譯的代碼進行優化。測試程序不僅要大致判斷某個典型應用程序的使用模式,還需要儘量覆蓋在該應用程序中將執行的代碼路徑集合。

IV.不真實的競爭程度

併發的應用程序可以交替執行兩種不同類型的工作:訪問共享數據(例如從共享工作隊列中取出下一個任務)以及執行線程本地的計算(例如,執行任務,並假設任務本身不會訪問共享數據)。根據兩種不同類型工作的相關程度,在應用程序中將出現不同程度的競爭,並表現出不同的性能與可伸縮性。

要獲得有實際意義的結構,在併發性能測試中應該儘量模擬典型應用程序中的線程本地計算量以及併發協調開銷。如果在真實應用程序的各個任務中執行的工作,與測試程序中執行的工作截然不同,那麼測試出的性能瓶頸位置將是不準確的。

V.無用代碼的消除

在編寫優秀的基準測試程序時,一個需要面對的挑戰就是:優化編譯器能找出並消除那些不會對輸出結果產生任何影響的無用代碼(Dead Code),由於基準測試通常不會執行任何計算,因此它們很容易在編譯器的優化過程中被消除。這將使被測試的內容變少。

在HotSpot中,許多基準測試在“-server”模式下都能比在“-client”模式下運行得更好,這不僅是因爲“-server”模式的編譯器能產生更有效的代碼,而且這種模式更易於通過優化消除無用代碼。

其他的測試方法

測試的目標不是更多地發現錯誤,而是提高代碼能按照預期方式工作的可信度。由於找出所有的錯誤是不現實的,所以質量保證(Quality Assurance,QA)的目標應該是在給定的測試資源下實現最高的可信度。其他的測試方法包括代碼審查,使用靜態分析工具(FindBugs),面向方面的測試技術,分析與檢測工具等。

的任務中。

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