電腦科學中抽象的好處與問題—偽共享例項分析
David John Wheeler有一句名言“電腦科學中的任何問題都可以通過加上一層間接層來解決”,一層不夠就再加一層。後半句是我加的 (* ̄︶ ̄) ,雖然有點玩笑的意思,但是也的確能說明一些問題。電腦科學的確是靠著一層又一層的抽象與封裝解決了巨量的問題。
我們來簡單回顧一下:
開始的時候是程式設計師直接輸入二進位制指令來操縱硬體的,不僅效能低下還很耗費使用者時間;於是後來出現了作業系統,用檔案、程序與執行緒、地址空間抽象了磁碟、CPU與記憶體,統一和簡化了硬體訪問方式;機器語言對使用者不友好,於是便出現了組合語言、中級語言(如C)、高階語言(如Java)的包裝,其最終執行還是要轉化為機器語言; 裸高階語言大家還用得不爽,覺得開發效率低,於是又出現了各種框架(如Spring、Hibernate)……這樣一層一層抽象包裝下來,我們要想實現一個功能比如定時寫檔案等已經變成了很簡單的事,只需要幾行程式碼就搞定了。 但是抽象層數過多就會導致我們頂層的使用者有時候會出現一些莫名其妙的問題,我們用一個實際的案例偽共享
public class FalseSharing { private static AtomicLong time = new AtomicLong(0); public static void main(String... args) throws InterruptedException { int testNum = 50; for (int i = 0 ; i< testNum;i++){// 測試50次 Thread thread = new Thread(new Job()); thread.start(); thread.join(); } System.out.println(time.get()/1000/testNum + " us,avg"); } static class Job implements Runnable{ @Override public void run() { int number = 8; int iterationNumber = 20000; CountDownLatch countDownLatch = new CountDownLatch(number); Obj[] objArray = new Obj[number]; for (int i = 0;i < number;i++) { objArray[i] = new Obj(); } long start = System.nanoTime(); for (int i = 0;i < number;i++){ int ii = i; Thread thread = new Thread(new Runnable() { int iterationNumberInner = iterationNumber; @Override public void run() { while (iterationNumberInner-->0){ objArray[ii].aLong+=1L; } countDownLatch.countDown(); } }); thread.start(); } try { countDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } long end = System.nanoTime(); time.getAndAdd(end-start); } } @Contended private static final class Obj{ private volatile long aLong = 8L;//8Bytes // private volatile long a=2L,b=2L,c=2L,d=2L,e=2L,f=2L,g=2L;//***** } }
全部程式碼在此,為了避免Java JIT(這也是一層抽象)的影響,我們每次執行都要加引數-Xint
來強制使用解釋模式。
在我的機器上(4core,8processor,Core-i7),直接執行這段程式碼得到結果1是4594 us,avg這個級別,在結果1基礎上把//*****
一行取消註釋得到結果2是3916 us,avg 這個級別,在結果1基礎上執行引數加上-XX:-RestrictContended
使得@Contended
起作用就能得到結果33466 us,avg。
這時候頂層使用者就會莫名奇妙了,怎麼多了幾個欄位執行時間反而減小了?怎麼加上@Contended
後時間就更短了?
從Java程式碼這一層次的抽象來看,完全是沒有問題的,那麼問題究竟在哪呢?
我們知道一個CPU中的每個核是有自己的Cache的,高級別的L1是自己私有的,更低級別的L2、L3等可能是私有的,也可能是不同核共享的。這些不同級別的快取(一次訪問時間在幾個ns左右)是用來彌補CPU的快速(一個週期通常零點幾個ns)和記憶體訪問的慢速(一次訪問時間在幾十個ns)之間的鴻溝的,而且是以CacheLine Size: N Bytes(Core-i7是64)為基本單位的,依據區域性性原理一次性把記憶體中該訪問變數周圍的N Bytes內容拷貝到Cache中,如果一個物件不夠N Bytes,就有可能和幾個物件共用一個CacheLine,這樣一個執行緒重新整理Cacheline就會導致其他執行緒的快取失效,要去更低級別的Cache甚至記憶體訪問,就大大降低了訪問速度。
這樣回到剛才的問題,多加幾個欄位能在一定程度上增大該物件所佔空間,減小共用CacheLine的機率,所以訪問時間減少了,而@Contended
則使得一個物件一個CacheLine,直接幫我們避免了偽共享,所以訪問時間更少了。要解決這個問題,光知道Java這一層抽象(語法、JDK API等)是不可能的,還得懂作業系統、甚至CPU晶片原理這些層抽象才行。
再比如說,JVM幫我們把C/C++的手動記憶體管理封裝了一層抽象做到記憶體自動管理從而解放了我們,我們當然用得很爽,但是如果我們不懂這一層的抽象與封裝,那麼程式OOM的時候就只能傻眼了。
最後總結一下,電腦科學中的任何問題都可以通過加上一層間接層來解決,這是很正確的,但是也正是因為一層一層的抽象和包裝,導致出了問題後很難定位,你都不知道問題究竟是出現在哪一層。所以要想提高技術水平不僅要知其然(看得見最頂層的包裝)也要知其所以然(看得見底層的包裝),每一層如果都懂或者說了解一些,那麼出了問題很大程度上都可以憑直覺定位,即使不能憑直覺也可以通過各種手段debug,只會最頂層的抽象很多時候就只能望bug興嘆了。