目前手機(jī)SOC的性能越來(lái)越少,很多程序員在終端程序的開(kāi)發(fā)過(guò)程中也不太注意性能方面的優(yōu)化,尤其是不注意對(duì)齊和分支優(yōu)化,但是這兩種問(wèn)題一旦出現(xiàn)所引發(fā)的問(wèn)題,是非常非常隱蔽難查的,不過(guò)好在項(xiàng)目中用到了移動(dòng)端的性能排查神器友盟U-APM工具的支持下,最終幾個(gè)問(wèn)題得到了圓滿解決。
我們先來(lái)看對(duì)齊的問(wèn)題,對(duì)齊在沒(méi)有并發(fā)競(jìng)爭(zhēng)的情況下不會(huì)有什么問(wèn)題,編譯器一般都會(huì)幫助程序員按照CPU字長(zhǎng)進(jìn)行對(duì)齊,但這在終端多線程同時(shí)工作的情況下可能會(huì)隱藏著巨大的性能問(wèn)題,在多線程并發(fā)的情況下,即使沒(méi)有共享變量,也可能會(huì)造成偽共享,由于具體的代碼涉密,因此我們來(lái)看以下抽象后的代碼。
public class Main { public static void main(String[] args) { final MyData data = new MyData(); new Thread(new Runnable() { public void run() { da(0); } }).start(); new Thread(new Runnable() { public void run() { da(0); } }).start(); try{ T(100); } catch (InterruptedException e){ e.printStackTrace(); } long[][] arr=da(); Sy("arr0 is "+arr[0]+"arr1 is"+arr[1]); } } class MyData { private long[] arr={0,0}; public long[] Getitem(){ return arr; } public void add(int j){ for (;true;){ arr[j]++; } } }
在這段代碼中,兩個(gè)子線程執(zhí)行類似任務(wù),分別操作arr數(shù)組當(dāng)中的兩個(gè)成員,由于兩個(gè)子線程的操作對(duì)象分別是arr[0]和arr[1]并不存在交叉的問(wèn)題,因此當(dāng)時(shí)判斷判斷不會(huì)造成并發(fā)競(jìng)爭(zhēng)問(wèn)題,也沒(méi)有加synchronized關(guān)鍵字。
但是這段程序卻經(jīng)常莫名的卡頓,后來(lái)經(jīng)過(guò)多方的查找,并最終通過(guò)友盟的卡頓分析功能我們最終定位到了上述代碼段,發(fā)現(xiàn)這是一個(gè)由于沒(méi)有按照緩存行進(jìn)行對(duì)齊而產(chǎn)生的問(wèn)題,這里先將修改完成后的偽代碼向大家說(shuō)明一下:
public class Main { public static void main(String[] args) { final MyData data = new MyData(); new Thread(new Runnable() { public void run() { da(0); } }).start(); new Thread(new Runnable() { public void run() { da(0); } }).start(); try{ T(10); } catch (InterruptedException e){ e.printStackTrace(); } long[][] arr=da(); Sy("arr0 is "+arr[0][0]+"arr1 is"+arr[1][0]); } } class MyData { private long[][] arr={{0,0,0,0,0,0,0,0,0},{0,0}}; public long[][] Getitem(){ return arr; } public void add(int j){ for (;true;){ arr[j][0]++; } } }
可以看到整體程序沒(méi)有作何變化,只是將原來(lái)的數(shù)組變成了二維數(shù)組,其中除了第一個(gè)數(shù)組中除arr[0][0]元素外,其余arr[0][1]-a[0][8]元素除完全不起作何與程序運(yùn)行有關(guān)的作用,但就這么一個(gè)小小的改動(dòng),卻帶來(lái)了性能有了接近20%的大幅提升,如果并發(fā)更多的話提升幅度還會(huì)更加明顯。
緩存行對(duì)齊排查分析過(guò)程
首先我們把之前代碼的多線程改為單線程串行執(zhí)行,結(jié)果發(fā)現(xiàn)效率與原始的代碼一并沒(méi)有差很多,這就讓我基本確定了這是一個(gè)由偽共享引發(fā)的問(wèn)題,但是我初始代碼中并沒(méi)有變量共享的問(wèn)題,所以這基本可以判斷是由于對(duì)齊惹的禍。
現(xiàn)代的CPU一般都不是按位進(jìn)行內(nèi)存訪問(wèn),而是按照字長(zhǎng)來(lái)訪問(wèn)內(nèi)存,當(dāng)CPU從內(nèi)存或者磁盤(pán)中將讀變量載入到寄存器時(shí),每次操作的最小單位一般是取決于CPU的字長(zhǎng)。比如8位字是1字節(jié),那么至少由內(nèi)存載入1字節(jié)也就是8位長(zhǎng)的數(shù)據(jù),再比如32位CPU每次就至少載入4字節(jié)數(shù)據(jù), 64位系統(tǒng)8字節(jié)以此類推。那么以8位機(jī)為例咱們來(lái)看一下這個(gè)問(wèn)題。假如變量1是個(gè)bool類型的變量,它占用1位空間,而變量2為byte類型占用8位空間,假如程序目前要訪問(wèn)變量2那么,第一次讀取CPU會(huì)從開(kāi)始的0x00位置讀取8位,也就是將bool型的變量1與byte型變量2的高7位全部讀入內(nèi)存,但是byte變量的最低位卻沒(méi)有被讀進(jìn)來(lái),還需要第二次的讀取才能把完整的變量2讀入。
也就是說(shuō)變量的存儲(chǔ)應(yīng)該按照CPU的字長(zhǎng)進(jìn)行對(duì)齊,當(dāng)訪問(wèn)的變量長(zhǎng)度不足CPU字長(zhǎng)的整數(shù)倍時(shí),需要對(duì)變量的長(zhǎng)度進(jìn)行補(bǔ)齊。這樣才能提升CPU與內(nèi)存間的訪問(wèn)效率,避免額外的內(nèi)存讀取操作。但在對(duì)齊方面絕大多數(shù)編譯器都做得很好,在缺省情況下,C編譯器為每一個(gè)變量或是數(shù)據(jù)單元按其自然對(duì)界條件分配空間邊界。也可以通過(guò)pragma pack(n)調(diào)用來(lái)改變?nèi)笔〉膶?duì)界條件指令,調(diào)用后C編譯器將按照pack(n)中指定的n來(lái)進(jìn)行n個(gè)字節(jié)的對(duì)齊,這其實(shí)也對(duì)應(yīng)著匯編語(yǔ)言中的.align。那么為什么還會(huì)有偽共享的對(duì)齊問(wèn)題呢?
現(xiàn)代CPU中除了按字長(zhǎng)對(duì)齊還需要按照緩存行對(duì)齊才能避免并發(fā)環(huán)境的競(jìng)爭(zhēng),目前主流ARM核移動(dòng)SOC的緩存行大小是64byte,因?yàn)槊總€(gè)CPU都配備了自己獨(dú)享的一級(jí)高速緩存,一級(jí)高速緩存基本是寄存器的速度,每次內(nèi)存訪問(wèn)CPU除了將要訪問(wèn)的內(nèi)存地址讀取之外,還會(huì)將前后處于64byte的數(shù)據(jù)一同讀取到高速緩存中,而如果兩個(gè)變量被放在了同一個(gè)緩存行,那么即使不同CPU核心在分別操作這兩個(gè)獨(dú)立變量,而在實(shí)際場(chǎng)景中CPU核心實(shí)際也是在操作同一緩存行,這也是造成這個(gè)性能問(wèn)題的原因。
Switch的坑
但是處理了這個(gè)對(duì)齊的問(wèn)題之后,我們的程序雖然在絕大多數(shù)情況下的性能都不錯(cuò),但是還是會(huì)有卡頓的情況,結(jié)果發(fā)現(xiàn)這是一個(gè)由于Switch分支引發(fā)的問(wèn)題。
switch是一種我們?cè)趈ava、c等語(yǔ)言編程時(shí)經(jīng)常用到的分支處理結(jié)構(gòu),主要的作用就是判斷變量的取值并將程序代碼送入不同的分支,這種設(shè)計(jì)在當(dāng)時(shí)的環(huán)境下非常的精妙,但是在當(dāng)前最新的移動(dòng)SOC環(huán)境下運(yùn)行,卻會(huì)帶來(lái)很多意想不到的坑。
出于涉與之前密的原因一樣,真實(shí)的代碼不能公開(kāi),我們先來(lái)看以下這段代碼:
public class Main { public static void main(String[] args) { long now=Sy(); int max=100,min=0; long a=0; long b=0; long c=0; for(int j=0;j<10000000;j++){ int ran=(in()*(max-min)+min); switch(ran){ case 0: a++; break; case 1: a++; break; default: c++; } } long diff=Sy()-now; Sy("a is "+a+"b is "+b+"c is "+c); } }
其中隨機(jī)數(shù)其實(shí)是一個(gè)rpc遠(yuǎn)程調(diào)用的返回,但是這段代碼總是莫名其妙的卡頓,為了復(fù)現(xiàn)這個(gè)卡頓,定位到這個(gè)代碼段也是通過(guò)友盟U-APM的卡頓分析找到的,想復(fù)現(xiàn)這個(gè)卡頓只需要我們?cè)偕晕裮ax范圍由調(diào)整為5。
public class Main { public static void main(String[] args) { long now=Sy(); int max=5,min=0; long a=0; long b=0; long c=0; for(int j=0;j<10000000;j++){ int ran=(in()*(max-min)+min); switch(ran){ case 0: a++; break; case 1: a++; break; default: c++; } } long diff=Sy()-now; Sy("a is "+a+"b is "+b+"c is "+c); } }
那么運(yùn)行時(shí)間就會(huì)有30%的下降,不過(guò)從我們分析的情況來(lái)看,代碼一平均每個(gè)隨機(jī)數(shù)有97%的概念要行2次判斷才能跳轉(zhuǎn)到最終的分支,總體的判斷語(yǔ)句執(zhí)行期望為2*0.97+1*0.03約等于2,而代碼二有30%的概念只需要1次判斷就可以跳轉(zhuǎn)到最終分支,總體的判斷執(zhí)行期望也就是0.3*1+0.6*2=1.5,但是代碼二卻反比代碼一還慢30%。也就是說(shuō)在代碼邏輯完全沒(méi)變只是返回值范圍的概率密度做一下調(diào)整,就會(huì)使程序的運(yùn)行效率大大下降,要解釋這個(gè)問(wèn)題要從指令流水線說(shuō)起。
指令流水線原理
我們知道CPU的每個(gè)動(dòng)作都需要用晶體震蕩而觸發(fā),以加法ADD指令為例,想完成這個(gè)執(zhí)行指令需要取指、譯碼、取操作數(shù)、執(zhí)行以及取操作結(jié)果等若干步驟,而每個(gè)步驟都需要一次晶體震蕩才能推進(jìn),因此在流水線技術(shù)出現(xiàn)之前執(zhí)行一條指令至少需要5到6次晶體震蕩周期才能完成
指令/時(shí)刻 | T1 | T2 | T3 | T4 | T5 |
ADD | 取指 | 譯碼 | 取操作數(shù) | 執(zhí)行 | 取結(jié)果 |
為了縮短指令執(zhí)行的晶體震蕩周期,芯片設(shè)計(jì)人員參考了工廠流水線機(jī)制的提出了指令流水線的想法,由于取指、譯碼這些模塊其實(shí)在芯片內(nèi)部都是獨(dú)立的,完成可以在同一時(shí)刻并發(fā)執(zhí)行,那么只要將多條指令的不同步驟放在同一時(shí)刻執(zhí)行,比如指令1取指,指令2譯碼,指令3取操作數(shù)等等,就可以大幅提高CPU執(zhí)行效率:
指令/時(shí) | T1 | T2 | T3 | T4 | T5 | T6 | T7 | T8 |
指令1 | 取指 | 譯碼 | 取操作數(shù) | 執(zhí)行 | 取結(jié)果 | |||
指令2 | 取指 | 譯碼 | 取操作數(shù) | 執(zhí)行 | 取結(jié)果 | |||
指令3 | 取指 | 譯碼 | 取操作數(shù) | 執(zhí)行 | 取結(jié)果 | |||
指令4 | 取指 | 譯碼 | 取操作數(shù) | 執(zhí)行 | 取結(jié)果 | |||
指令5 | 取指 | 譯碼 | 取操作數(shù) | 執(zhí)行 | ||||
指令6 | 取指 | 譯碼 | 取操作數(shù) | |||||
指令7 | 取指 | 譯碼 | ||||||
指令8 | 取指 |
以上圖流水線為例 ,在T5時(shí)刻之前指令流水線以每周期一條的速度不斷建立,在T5時(shí)代以后每個(gè)震蕩周期,都可以有一條指令取結(jié)果,平均每條指令就只需要一個(gè)震蕩周期就可以完成。這種流水線設(shè)計(jì)也就大幅提升了CPU的運(yùn)算速度。
但是CPU流水線高度依賴指指令預(yù)測(cè)技術(shù),假如在流水線上指令5本是不該執(zhí)行的,但卻在T6時(shí)刻已經(jīng)拿到指令1的結(jié)果時(shí)才發(fā)現(xiàn)這個(gè)預(yù)測(cè)失敗,那么指令5在流水線上將會(huì)化為無(wú)效的氣泡,如果指令6到8全部和指令5有強(qiáng)關(guān)聯(lián)而一并失效的話,那么整個(gè)流水線都需要重新建立。
指令/時(shí)刻 | T1 | T2 | T3 | T4 | T5 | T6 | T7 | T8 |
指令1 | 取指 | 譯碼 | 取操作數(shù) | 執(zhí)行 | 取結(jié)果 | |||
指令2 | 取指 | 譯碼 | 取操作數(shù) | 執(zhí)行 | 取結(jié)果 | |||
指令3 | 取指 | 譯碼 | 取操作數(shù) | 執(zhí)行 | 取結(jié)果 | |||
指令4 | 取指 | 譯碼 | 取操作數(shù) | 執(zhí)行 | 取結(jié)果 | |||
指令5 | 取指 | 譯碼 | 取操作數(shù) | 執(zhí)行 | ||||
指令6 | 取指 | 譯碼 | 取操作數(shù) | |||||
指令7 | 取指 | 譯碼 | ||||||
指令8 | 取指 |
所以可以看出例子當(dāng)中的這個(gè)效率差完全是CPU指令預(yù)測(cè)造成的,也就是說(shuō)CPU自帶的機(jī)制就是會(huì)對(duì)于執(zhí)行概比較高的分支給出更多的預(yù)測(cè)傾斜。
處理建議-用哈希表替代switch
我們上文也介紹過(guò)哈希表也就是字典,可以快速將鍵值key轉(zhuǎn)化為值value,從某種程度上講可以替換switch的作用,按照第一段代碼的邏輯,用哈希表重寫(xiě)的方案如下:
import java.u; public class Main { public static void main(String[] args) { long now=Sy(); int max=6,min=0; HashMap<Integer,Integer> hMap = new HashMap<Integer,Integer>(); (0,0); (1,0); (2,0); (3,0); (4,0); (5,0); for(int j=0;j<10000000;j++){ int ran=(in()*(max-min)+min); int value = (ran)+1; (ran,value); } long diff=Sy()-now; Sy(hMap); Sy("time is "+ diff); } }
上述這段用哈希表的代碼雖然不如代碼一速度快,但是總體非常穩(wěn)定,即使出現(xiàn)代碼二的情況也比較平穩(wěn)。
經(jīng)驗(yàn)總結(jié)
一、有并發(fā)的終端編程一定要注意按照緩存行(64byte)對(duì)齊,不按照緩存行對(duì)齊的代碼就是每增加一個(gè)線程性能會(huì)損失20%。
二、重點(diǎn)關(guān)注switch、if-else分支的問(wèn)題,一旦條件分支的取值條件有所變化,那么應(yīng)該首選用哈希表結(jié)構(gòu),對(duì)于條件分支進(jìn)行優(yōu)化。
三、選擇一款好用的性能監(jiān)測(cè)工具,如:友盟U-APM,不僅免費(fèi)且捕獲類型較為全面,推薦大家使用。
原文鏈接:
本文為阿里云原創(chuàng)內(nèi)容,未經(jīng)允許不得轉(zhuǎn)載。
1.《【0號(hào)元素】終端卡頓優(yōu)化的全記錄》援引自互聯(lián)網(wǎng),旨在傳遞更多網(wǎng)絡(luò)信息知識(shí),僅代表作者本人觀點(diǎn),與本網(wǎng)站無(wú)關(guān),侵刪請(qǐng)聯(lián)系頁(yè)腳下方聯(lián)系方式。
2.《【0號(hào)元素】終端卡頓優(yōu)化的全記錄》僅供讀者參考,本網(wǎng)站未對(duì)該內(nèi)容進(jìn)行證實(shí),對(duì)其原創(chuàng)性、真實(shí)性、完整性、及時(shí)性不作任何保證。
3.文章轉(zhuǎn)載時(shí)請(qǐng)保留本站內(nèi)容來(lái)源地址,http://f99ss.com/yule/2233748.html