共计 5168 个字符,预计需要花费 13 分钟才能阅读完成。
简介:目前手机 SOC 的性能越来越少,很多程序员在终端程序的开发过程中也不太留神性能方面的优化,尤其是不留神对齐和分支优化,然而这两种问题一旦呈现所引发的问题,是十分十分荫蔽难查的,不过好在我的项目中用到了挪动端的性能排查神器友盟 U -APM 工具的反对下,最终几个问题失去了圆满解决。
咱们先来看对齐的问题,对齐在没有并发竞争的状况下不会有什么问题,编译器个别都会帮忙程序员依照 CPU 字长进行对齐,但这在终端多线程同时工作的状况下可能会暗藏着微小的性能问题,在多线程并发的状况下,即便没有共享变量,也可能会造成伪共享,因为具体的代码涉密,因而咱们来看以下形象后的代码。
public class Main {public static void main(String[] args) {final MyData data = new MyData();
new Thread(new Runnable() {public void run() {data.add(0);
}
}).start();
new Thread(new Runnable() {public void run() {data.add(0);
}
}).start();
try{Thread.sleep(100);
} catch (InterruptedException e){e.printStackTrace();
}
long[][] arr=data.Getitem();
System.out.println("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]++;
}
}
}
在这段代码中,两个子线程执行相似工作,别离操作 arr 数组当中的两个成员,因为两个子线程的操作对象别离是 arr[0]和 arr[1]并不存在穿插的问题,因而过后判断判断不会造成并发竞争问题,也没有加 synchronized 关键字。
然而这段程序却常常莫名的卡顿,起初通过多方的查找,并最终通过友盟的卡顿剖析性能咱们最终定位到了上述代码段,发现这是一个因为没有依照缓存行进行对齐而产生的问题,这里先将批改实现后的伪代码向大家阐明一下:
public class Main {public static void main(String[] args) {final MyData data = new MyData();
new Thread(new Runnable() {public void run() {data.add(0);
}
}).start();
new Thread(new Runnable() {public void run() {data.add(0);
}
}).start();
try{Thread.sleep(10);
} catch (InterruptedException e){e.printStackTrace();
}
long[][] arr=data.Getitem();
System.out.println("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]++;
}
}
}
能够看到整体程序没有作何变动,只是将原来的数组变成了二维数组,其中除了第一个数组中除 arr0 元素外,其余 arr0-a0 元素除齐全不起作何与程序运行无关的作用,但就这么一个小小的改变,却带来了性能有了靠近 20% 的大幅晋升,如果并发更多的话晋升幅度还会更加显著。
缓存行对齐排查剖析过程
首先咱们把之前代码的多线程改为单线程串行执行,后果发现效率与原始的代码一并没有差很多,这就让我根本确定了这是一个由伪共享引发的问题,然而我初始代码中并没有变量共享的问题,所以这根本能够判断是因为对齐惹的祸。
古代的 CPU 个别都不是按位进行内存拜访,而是依照字长来拜访内存,当 CPU 从内存或者磁盘中将读变量载入到寄存器时,每次操作的最小单位个别是取决于 CPU 的字长。比方 8 位字是 1 字节,那么至多由内存载入 1 字节也就是 8 位长的数据,再比方 32 位 CPU 每次就至多载入 4 字节数据, 64 位零碎 8 字节以此类推。那么以 8 位机为例咱们来看一下这个问题。如果变量 1 是个 bool 类型的变量,它占用 1 位空间,而变量 2 为 byte 类型占用 8 位空间,如果程序目前要拜访变量 2 那么,第一次读取 CPU 会从开始的 0x00 地位读取 8 位,也就是将 bool 型的变量 1 与 byte 型变量 2 的高 7 位全副读入内存,然而 byte 变量的最低位却没有被读进来,还须要第二次的读取能力把残缺的变量 2 读入。
也就是说变量的存储应该依照 CPU 的字长进行对齐,当拜访的变量长度有余 CPU 字长的整数倍时,须要对变量的长度进行补齐。这样能力晋升 CPU 与内存间的拜访效率,防止额定的内存读取操作。但在对齐方面绝大多数编译器都做得很好,在缺省状况下,C 编译器为每一个变量或是数据单元按其天然对界条件调配空间边界。也能够通过 pragma pack(n)调用来扭转缺省的对界条件指令,调用后 C 编译器将依照 pack(n)中指定的 n 来进行 n 个字节的对齐,这其实也对应着汇编语言中的.align。那么为什么还会有伪共享的对齐问题呢?
古代 CPU 中除了按字长对齐还须要依照缓存行对齐能力防止并发环境的竞争,目前支流 ARM 核挪动 SOC 的缓存行大小是 64byte,因为每个 CPU 都装备了本人独享的一级高速缓存,一级高速缓存根本是寄存器的速度,每次内存拜访 CPU 除了将要拜访的内存地址读取之外,还会将前后处于 64byte 的数据一起读取到高速缓存中,而如果两个变量被放在了同一个缓存行,那么即便不同 CPU 外围在别离操作这两个独立变量,而在理论场景中 CPU 外围理论也是在操作同一缓存行,这也是造成这个性能问题的起因。
Switch 的坑
然而解决了这个对齐的问题之后,咱们的程序尽管在绝大多数状况下的性能都不错,然而还是会有卡顿的状况,后果发现这是一个因为 Switch 分支引发的问题。
switch 是一种咱们在 java、c 等语言编程时常常用到的分支解决构造,次要的作用就是判断变量的取值并将程序代码送入不同的分支,这种设计在过后的环境下十分的精妙,然而在以后最新的挪动 SOC 环境下运行,却会带来很多意想不到的坑。
出于涉与之前密的起因一样,实在的代码不能公开,咱们先来看以下这段代码:
public class Main {public static void main(String[] args) {long now=System.currentTimeMillis();
int max=100,min=0;
long a=0;
long b=0;
long c=0;
for(int j=0;j<10000000;j++){int ran=(int)(Math.random()*(max-min)+min);
switch(ran){
case 0:
a++;
break;
case 1:
a++;
break;
default:
c++;
}
}
long diff=System.currentTimeMillis()-now;
System.out.println("a is"+a+"b is"+b+"c is"+c);
}
}
其中随机数其实是一个 rpc 近程调用的返回,然而这段代码总是莫名其妙的卡顿,为了复现这个卡顿,定位到这个代码段也是通过友盟 U -APM 的卡顿剖析找到的,想复现这个卡顿只须要咱们再略微把 max 范畴由调整为 5。
public class Main {public static void main(String[] args) {long now=System.currentTimeMillis();
int max=5,min=0;
long a=0;
long b=0;
long c=0;
for(int j=0;j<10000000;j++){int ran=(int)(Math.random()*(max-min)+min);
switch(ran){
case 0:
a++;
break;
case 1:
a++;
break;
default:
c++;
}
}
long diff=System.currentTimeMillis()-now;
System.out.println("a is"+a+"b is"+b+"c is"+c);
}
}
那么运行工夫就会有 30% 的降落,不过从咱们剖析的状况来看,代码一均匀每个随机数有 97% 的概念要行 2 次判断能力跳转到最终的分支,总体的判断语句执行冀望为 2 0.97+10.03 约等于 2,而代码二有 30% 的概念只须要 1 次判断就能够跳转到最终分支,总体的判断执行冀望也就是 0.31+0.62=1.5, 然而代码二却正比代码一还慢 30%。也就是说在代码逻辑齐全没变只是返回值范畴的概率密度做一下调整,就会使程序的运行效率大大降落,要解释这个问题要从指令流水线说起。
指令流水线原理
咱们晓得 CPU 的每个动作都须要用晶体震荡而触发,以加法 ADD 指令为例,想实现这个执行指令须要取指、译码、取操作数、执行以及取操作后果等若干步骤,而每个步骤都须要一次晶体震荡能力推动,因而在流水线技术呈现之前执行一条指令至多须要 5 到 6 次晶体震荡周期能力实现。
为了缩短指令执行的晶体震荡周期,芯片设计人员参考了工厂流水线机制的提出了指令流水线的想法,因为取指、译码这些模块其实在芯片外部都是独立的,实现能够在同一时刻并发执行,那么只有将多条指令的不同步骤放在同一时刻执行,比方指令 1 取指,指令 2 译码,指令 3 取操作数等等,就能够大幅提高 CPU 执行效率:
以上图流水线为例,在 T5 时刻之前指令流水线以每周期一条的速度一直建设,在 T5 时代当前每个震荡周期,都能够有一条指令取后果,均匀每条指令就只须要一个震荡周期就能够实现。这种流水线设计也就大幅晋升了 CPU 的运算速度。
然而 CPU 流水线高度依赖指指令预测技术,如果在流水线上指令 5 本是不该执行的,但却在 T6 时刻曾经拿到指令 1 的后果时才发现这个预测失败,那么指令 5 在流水线上将会化为有效的气泡,如果指令 6 到 8 全副和指令 5 有强关联而一并生效的话,那么整个流水线都须要从新建设。
所以能够看出例子当中的这个效率差齐全是 CPU 指令预测造成的,也就是说 CPU 自带的机制就是会对于执行概比拟高的分支给出更多的预测歪斜。
解决倡议 - 用哈希表代替 switch
咱们上文也介绍过哈希表也就是字典,能够疾速将键值 key 转化为值 value,从某种程度上讲能够替换 switch 的作用,依照第一段代码的逻辑,用哈希表重写的计划如下:
import java.util.HashMap;
public class Main {public static void main(String[] args) {long now=System.currentTimeMillis();
int max=6,min=0;
HashMap<Integer,Integer> hMap = new HashMap<Integer,Integer>();
hMap.put(0,0);
hMap.put(1,0);
hMap.put(2,0);
hMap.put(3,0);
hMap.put(4,0);
hMap.put(5,0);
for(int j=0;j<10000000;j++){int ran=(int)(Math.random()*(max-min)+min);
int value = hMap.get(ran)+1;
hMap.replace(ran,value);
}
long diff=System.currentTimeMillis()-now;
System.out.println(hMap);
System.out.println("time is"+ diff);
}
}
上述这段用哈希表的代码尽管不如代码一速度快,然而总体十分稳固,即便呈现代码二的状况也比拟安稳。
经验总结
一、有并发的终端编程肯定要留神依照缓存行(64byte)对齐,不依照缓存行对齐的代码就是每减少一个线程性能会损失 20%。
二、重点关注 switch、if-else 分支的问题,一旦条件分支的取值条件有所变动,那么应该首选用哈希表构造,对于条件分支进行优化。
三、抉择一款好用的性能监测工具,如:友盟 U -APM,不仅收费且捕捉类型较为全面,举荐大家应用。
原文链接
本文为阿里云原创内容,未经容许不得转载。