正在运行的程序背后隐藏着什么?我想说答案是你+操作系统+硬件。这就是为什么将其命名为原样。我这里要传达的最核心的概念是这样的,不管你用的是什么编程语言,最终都变成了目标CPU可识别的指令、地址和数据,从这个意义上说你更清楚CPU是如何识别的如果你想追求极致的性能,你的代码的内容将通过 CPU 流水线。您不一定非得具备 JVM 专业性和硬件专业性,但了解它们的工作方式绝对有利于使您的代码与硬件的工作方式保持一致,从而主动适应和适应。想象一下,一个不了解自己的汽车是如何工作的汽车司机怎么能成为一名优秀的司机?作为一个软件程序员,类比一下,也是一样的。
我是一个 Java 的人,正在做一个即时通讯项目,除了流之外,还旨在实现高并发、高吞吐量和高扩展性。在研究相关技术的过程中,我被操作系统和硬件吸引得越来越近。这让我渴望全面了解操作系统和硬件在地球上是如何工作的,因为我一直在问自己是否有更多的方法可以让它变得越来越好。毕竟,不可否认的是,当一个程序在那里运行时,操作系统和相关硬件肯定在做一些事情,所以不容忽视。我在这个项目上的搜索轨迹一直关注性能,在这个过程中我的学习在Java、JVM、OS和硬件之间跳来跳去。最终我把所有的东西都放在一个图中,如下所示。
这张图大致说明了数据和CPU之间距离的概念,其中T#表示CPU从T#所在位置取数据需要花费的时间。显然,位于同一行的组件不需要相同的时间让 CPU 获取它们所持有的数据。想必很多程序员,像我以前一样,在日常编码中从来没有认真思考过在运行时代码背后到底发生了什么。我认为,部分原因通常是他们不关心他们的代码将在什么环境中运行。现在我想建议注意操作系统和硬件,因为它们在许多层面上对程序的性能都非常重要。希望这个像地图一样的数字可以指导您找到您可能需要查看的地方。
Java 被称为高度抽象的编程语言并且基本上是硬件无知的,如果你说它没有提供明确的方法来控制内存或当你想与 JNI 和 JNA 分开时直接发射一些神奇的 CPU 指令,那是真的。但是,这并不意味着 Java 人员没有空间让他们的程序表现得更好。当您在编码时将 Java、JVM、操作系统和硬件连接在一起时,您肯定有机会使您的代码性能提高几个数量级。您可以逐个元素检查您的代码,如图所示,看看您是否已经压缩了它们的每一点以充分利用它们。
现代 CPU 比主存快得多。软件人和硬件人都在与此作斗争。有趣的是,弥合制造业的差距在技术上是可行的,但在经济上是不可行的。本文将只关注 T1,并使用一些实际代码来演示或引发一些问题。所有代码都在此运行:
- Windows 8.1 64 位
- 4 个 AMD A10-7300 Radeon R6、10 个计算核心 + 6G
- 16G DDR3
- 一级缓存 192KB
- 二级缓存 4096KB
- JDK 8
在深入测试之前,我真的很喜欢借用下面的一些基本数字,让你有一个整体的感觉。
- 注册 0 周期
- 一级缓存 3 个周期
- L2 缓存 9 个周期
- L3 高速缓存 21 个周期
- 主存 150-400 个周期
1. 在一维数组上测试
迭代一个int数组,看Forward、Backward、Random、Stride四种情况的写入速度有何不同,如调整数组大小和stride大小相对于64字节的缓存行。
import java.util.concurrent.ThreadLocalRandom;
public class CacheTestA {
private static void test(int arraySize, int strideLength){
ThreadLocalRandom tlr=ThreadLocalRandom.current();
int[] d=new int [arraySize];
long total=0;
for(int i=0;i<arraySize;i++){
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int forward=(int)(total/arraySize);
total=0;
for(int i=arraySize-1;i>=0;i--){
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int backward=(int)(total/arraySize);
total=0;
for(int i=0;i<arraySize;i++){
int ind=tlr.nextInt(arraySize);
long s=System.nanoTime();
d[ind]=i;
long e=System.nanoTime();
total+=e-s;
}
int random=(int)(total/arraySize);
total=0;
int cnt=0;
for(int i=0;i<arraySize;i+=strideLength){
cnt++;
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int stride=(int)(total/cnt);
System.out.println("Forward: "+forward+" Backwrod: "+backward+" Random: "+random+" Stride: "+stride);
}
public static void main(String[] args) {
int L1CacheKB=192;
int L2CacheKB=4096;
int bytesOfInt=4;
int strideLength=16;//assuming cache line size is 64 bytes
// int arraySize=L1CacheKB1024/bytesOfInt;
int arraySize=L2CacheKB1024/bytesOfInt;
// int arraySize=1510241024/bytesOfInt;
for(int i=0;i<20;i++)
test(arraySize, strideLength);
}
}
Result:
Forward: 44 Backwrod: 43 Random: 59 Stride: 67
Forward: 44 Backwrod: 43 Random: 58 Stride: 34
Forward: 45 Backwrod: 42 Random: 60 Stride: 42
Forward: 45 Backwrod: 40 Random: 58 Stride: 50
Forward: 41 Backwrod: 46 Random: 60 Stride: 45
预期的:
- 如果数组大小小于 L1 缓存大小,则最快。
- 如果数组大小介于 L1 缓存大小和 L2 大小之间,则速度较慢。
- 如果数组大小大于 L1 + L2,则最慢。
- 如果步幅大小不等于缓存行大小,则相对于等于更慢。
- 随机的,在数组大小和步幅大小变化的任何情况下都较慢。
结果:
只有 E 为真。
解释:
很有可能,下一个数组元素根本不在同一个缓存行中,它只有 1/16 命中,这解释了 E。
本质上,主存中的数据以线性方式管理,而高速缓存行是主存和高速缓存之间传输的最小内存单元。可以传输超过高速缓存行的任何大小的内存块的假设是不正确的。这解释了 A – C。
只要您的内存访问行为符合规则模式而不是跳来跳去,CPU 预取器就可以很好地预测接下来会发生什么。这就解释了 D。
2. VS私有线程共享的数据
以一维int数组为观察对象,看共享一个公共数组和每个线程创建一个数组在元素读取性能上的差异。
import java.util.concurrent.ThreadLocalRandom;
public class CacheTestA {
private static void test(int arraySize, int strideLength){
ThreadLocalRandom tlr=ThreadLocalRandom.current();
int[] d=new int [arraySize];
long total=0;
for(int i=0;i<arraySize;i++){
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int forward=(int)(total/arraySize);
total=0;
for(int i=arraySize-1;i>=0;i--){
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int backward=(int)(total/arraySize);
total=0;
for(int i=0;i<arraySize;i++){
int ind=tlr.nextInt(arraySize);
long s=System.nanoTime();
d[ind]=i;
long e=System.nanoTime();
total+=e-s;
}
int random=(int)(total/arraySize);
total=0;
int cnt=0;
for(int i=0;i<arraySize;i+=strideLength){
cnt++;
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int stride=(int)(total/cnt);
System.out.println("Forward: "+forward+" Backwrod: "+backward+" Random: "+random+" Stride: "+stride);
}
public static void main(String[] args) {
int L1CacheKB=192;
int L2CacheKB=4096;
int bytesOfInt=4;
int strideLength=16;//assuming cache line size is 64 bytes
// int arraySize=L1CacheKB1024/bytesOfInt;
int arraySize=L2CacheKB1024/bytesOfInt;
// int arraySize=1510241024/bytesOfInt;
for(int i=0;i<20;i++)
test(arraySize, strideLength);
}
}
Result:
Forward: 44 Backwrod: 43 Random: 59 Stride: 67
Forward: 44 Backwrod: 43 Random: 58 Stride: 34
Forward: 45 Backwrod: 42 Random: 60 Stride: 42
Forward: 45 Backwrod: 40 Random: 58 Stride: 50
Forward: 41 Backwrod: 46 Random: 60 Stride: 45
预期的:
共享比线程私有更快。
结果:
每个循环测量,是的,但略有;
每个元素测量,没有;
解释:
我能推理的就是“ int [x];”太快了,“”不够,所以累积起来每个循环测量的结果会出现不同,但每个元素不会。你可能猜想在“call()”方法中实例化数组可能会有所不同,而不是使用在构造函数中实例化的数组的字段变量,实际上不是。
正如某些人所展示的那样,您可以通过条带化到多个线程然后聚合来计算二维 int 数组中的奇数。有些人可能会使用这种情况(见下面的代码)来证明共享比不共享慢得多,但实际上并不多,就像上面显示的那样,如果你净化了测试用例。
import java.util.concurrent.ThreadLocalRandom;
public class CacheTestA {
private static void test(int arraySize, int strideLength){
ThreadLocalRandom tlr=ThreadLocalRandom.current();
int[] d=new int [arraySize];
long total=0;
for(int i=0;i<arraySize;i++){
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int forward=(int)(total/arraySize);
total=0;
for(int i=arraySize-1;i>=0;i--){
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int backward=(int)(total/arraySize);
total=0;
for(int i=0;i<arraySize;i++){
int ind=tlr.nextInt(arraySize);
long s=System.nanoTime();
d[ind]=i;
long e=System.nanoTime();
total+=e-s;
}
int random=(int)(total/arraySize);
total=0;
int cnt=0;
for(int i=0;i<arraySize;i+=strideLength){
cnt++;
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int stride=(int)(total/cnt);
System.out.println("Forward: "+forward+" Backwrod: "+backward+" Random: "+random+" Stride: "+stride);
}
public static void main(String[] args) {
int L1CacheKB=192;
int L2CacheKB=4096;
int bytesOfInt=4;
int strideLength=16;//assuming cache line size is 64 bytes
// int arraySize=L1CacheKB1024/bytesOfInt;
int arraySize=L2CacheKB1024/bytesOfInt;
// int arraySize=1510241024/bytesOfInt;
for(int i=0;i<20;i++)
test(arraySize, strideLength);
}
}
Result:
Forward: 44 Backwrod: 43 Random: 59 Stride: 67
Forward: 44 Backwrod: 43 Random: 58 Stride: 34
Forward: 45 Backwrod: 42 Random: 60 Stride: 42
Forward: 45 Backwrod: 40 Random: 58 Stride: 50
Forward: 41 Backwrod: 46 Random: 60 Stride: 45
如果您不认真对待它,您可能会误以为共享比不共享慢得多,事实并非如此。为什么?因为,
“sharedCounter[threadIndex]++;”像这样(使用 javap),
import java.util.concurrent.ThreadLocalRandom;
public class CacheTestA {
private static void test(int arraySize, int strideLength){
ThreadLocalRandom tlr=ThreadLocalRandom.current();
int[] d=new int [arraySize];
long total=0;
for(int i=0;i<arraySize;i++){
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int forward=(int)(total/arraySize);
total=0;
for(int i=arraySize-1;i>=0;i--){
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int backward=(int)(total/arraySize);
total=0;
for(int i=0;i<arraySize;i++){
int ind=tlr.nextInt(arraySize);
long s=System.nanoTime();
d[ind]=i;
long e=System.nanoTime();
total+=e-s;
}
int random=(int)(total/arraySize);
total=0;
int cnt=0;
for(int i=0;i<arraySize;i+=strideLength){
cnt++;
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int stride=(int)(total/cnt);
System.out.println("Forward: "+forward+" Backwrod: "+backward+" Random: "+random+" Stride: "+stride);
}
public static void main(String[] args) {
int L1CacheKB=192;
int L2CacheKB=4096;
int bytesOfInt=4;
int strideLength=16;//assuming cache line size is 64 bytes
// int arraySize=L1CacheKB1024/bytesOfInt;
int arraySize=L2CacheKB1024/bytesOfInt;
// int arraySize=1510241024/bytesOfInt;
for(int i=0;i<20;i++)
test(arraySize, strideLength);
}
}
Result:
Forward: 44 Backwrod: 43 Random: 59 Stride: 67
Forward: 44 Backwrod: 43 Random: 58 Stride: 34
Forward: 45 Backwrod: 42 Random: 60 Stride: 42
Forward: 45 Backwrod: 40 Random: 58 Stride: 50
Forward: 41 Backwrod: 46 Random: 60 Stride: 45
和“反++;”像这样,
import java.util.concurrent.ThreadLocalRandom;
public class CacheTestA {
private static void test(int arraySize, int strideLength){
ThreadLocalRandom tlr=ThreadLocalRandom.current();
int[] d=new int [arraySize];
long total=0;
for(int i=0;i<arraySize;i++){
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int forward=(int)(total/arraySize);
total=0;
for(int i=arraySize-1;i>=0;i--){
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int backward=(int)(total/arraySize);
total=0;
for(int i=0;i<arraySize;i++){
int ind=tlr.nextInt(arraySize);
long s=System.nanoTime();
d[ind]=i;
long e=System.nanoTime();
total+=e-s;
}
int random=(int)(total/arraySize);
total=0;
int cnt=0;
for(int i=0;i<arraySize;i+=strideLength){
cnt++;
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int stride=(int)(total/cnt);
System.out.println("Forward: "+forward+" Backwrod: "+backward+" Random: "+random+" Stride: "+stride);
}
public static void main(String[] args) {
int L1CacheKB=192;
int L2CacheKB=4096;
int bytesOfInt=4;
int strideLength=16;//assuming cache line size is 64 bytes
// int arraySize=L1CacheKB1024/bytesOfInt;
int arraySize=L2CacheKB1024/bytesOfInt;
// int arraySize=1510241024/bytesOfInt;
for(int i=0;i<20;i++)
test(arraySize, strideLength);
}
}
Result:
Forward: 44 Backwrod: 43 Random: 59 Stride: 67
Forward: 44 Backwrod: 43 Random: 58 Stride: 34
Forward: 45 Backwrod: 42 Random: 60 Stride: 42
Forward: 45 Backwrod: 40 Random: 58 Stride: 50
Forward: 41 Backwrod: 46 Random: 60 Stride: 45
我相信相应的底层汇编指令的数量也大不相同。这解释了为什么它在数量级上出现不同而不是共享与否。
3.二维数组测试
对于同一个二维数组的遍历,有两种方式,一种是逐行,一种是逐列。不假思索,你可能会认为遍历时间长度基本没有区别,简单的以相同数量的元素和相同的内存分配来推理,为什么不呢。
import java.util.concurrent.ThreadLocalRandom;
public class CacheTestA {
private static void test(int arraySize, int strideLength){
ThreadLocalRandom tlr=ThreadLocalRandom.current();
int[] d=new int [arraySize];
long total=0;
for(int i=0;i<arraySize;i++){
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int forward=(int)(total/arraySize);
total=0;
for(int i=arraySize-1;i>=0;i--){
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int backward=(int)(total/arraySize);
total=0;
for(int i=0;i<arraySize;i++){
int ind=tlr.nextInt(arraySize);
long s=System.nanoTime();
d[ind]=i;
long e=System.nanoTime();
total+=e-s;
}
int random=(int)(total/arraySize);
total=0;
int cnt=0;
for(int i=0;i<arraySize;i+=strideLength){
cnt++;
long s=System.nanoTime();
d[i]=i;
long e=System.nanoTime();
total+=e-s;
}
int stride=(int)(total/cnt);
System.out.println("Forward: "+forward+" Backwrod: "+backward+" Random: "+random+" Stride: "+stride);
}
public static void main(String[] args) {
int L1CacheKB=192;
int L2CacheKB=4096;
int bytesOfInt=4;
int strideLength=16;//assuming cache line size is 64 bytes
// int arraySize=L1CacheKB1024/bytesOfInt;
int arraySize=L2CacheKB1024/bytesOfInt;
// int arraySize=1510241024/bytesOfInt;
for(int i=0;i<20;i++)
test(arraySize, strideLength);
}
}
Result:
Forward: 44 Backwrod: 43 Random: 59 Stride: 67
Forward: 44 Backwrod: 43 Random: 58 Stride: 34
Forward: 45 Backwrod: 42 Random: 60 Stride: 42
Forward: 45 Backwrod: 40 Random: 58 Stride: 50
Forward: 41 Backwrod: 46 Random: 60 Stride: 45
理解以下关键概念可以给出完整的答案。
- Row/Column-major, 内存分配是逐行/逐列,这是编程语言特有的。 Java 是行优先的。
- Inter-Loop, 编译器可以巧妙地互换内循环和外循环。
- Cache Line, 在内核之间以及高速缓存和主存之间移动的最小内存单元,通常为64字节。
本质上,物理内存分配是线性进行的,因此,对于Java,逐列访问肯定会导致缓存惩罚。
4. 线程之间的对话,ExecutorService (ES) VS Super-Fast
过去,我曾经面临每秒处理近百万条并发消息的挑战。首先,我使用 ES,只是将处理任务交给它,但当消息以公开方式传入时,情况就更糟了。后来做了一个生产者消费者的小工具,只是稍微改进了一点点,离Martin Thompson的奇葩发明的东西差了点,叫做Disruptor,绝对牛逼。他的创新方式可以在消耗一种队列(实际上是环形缓冲区)而不是ES的那种BlockingQueue时达到每秒数百万次操作。这种新方式使用了多种技术组合,包括 Unsafe、volatile、final、atomic*、内存栅栏、乱序执行、虚假共享、JMM 等。要理解它,确实需要深入了解 CPU 的工作方式。
我写了一个测试来比较 ES 和 Disruptor 在相同的生产和消费工作量下,结果很惊人。由于太多,我不会在这里发布代码。
我所做的是使用四个线程作为生产者,不睡觉只是不停地扔。在 ES 案例中,CPU 占用率高达 90+%,内存使用率持续增长,两者都很快,然后输出看起来很不稳定;在 Disruptor 的情况下,运行平稳,CPU (~60%) 和内存 (70MB) 都平稳而可爱。 ES的内在限制就在于对队列的争用。
5. NUMA——非统一内存访问
如果一个 Java 程序运行在多个 NUMA 节点上怎么办?基本上我在谷歌搜索的前几页中没有看到任何关于它的实际例子。 JVM 确实提供了像 UseNUMA 这样的选项,并声称性能提高了 40%(64 位操作系统)。在 Java HotSpot JVM 中,有一个 NUMA 感知分配器,它可以在 NUMA 节点上对内存进行分区。 Oracle 指出, 分配器依赖于一个假设,即分配对象的线程最有可能使用该对象。为了确保最快地访问新对象,分配器将其放置在分配线程的本地区域中。可以动态调整区域大小以反映在不同节点上运行的应用程序线程的分配率。这使得提高单线程应用程序的性能成为可能。此外,年轻代、老年代和永久代的“from”和“to”survivor空间都开启了page interleaving。这确保所有线程平均对这些空间具有相同的访问延迟 。我手头没有多个 NUMA 节点机器,这里没有可发布的验证代码。将您的 Java 代码与 Oracle 的假设对齐是一种很好的做法,这是非常合理的。希望有人可以分享一些探索,以提供有用的实践和一般规则。我想知道什么时候会发生更糟糕的情况,例如跨 NUMA 节点的远程访问甚至通过转发进行多次跳跃,以及如何避免。