CPU和内存的那些事——程序员必知的硬件知识

CPU和内存的那些事——程序员必知的硬件知识 CPU和内存的那些事——程序员必知的硬件知识上次写了一篇关于CPU制作的文章有朋友留言说了解这些有什么用我又不做硬件。其实对我们Java程序员来说理解CPU和内存的工作原理能帮你更好地理解JVM、多线程、并发编程这些高级话题。今天就来聊聊CPU和内存的那些事儿。一、CPU的核心组件1.1 ALU算术逻辑单元ALUArithmetic Logic Unit是CPU的大脑负责所有的算术和逻辑运算// 这些操作最终都是ALU在执行inta12;// 加法intba0xFF;// 位与intca2;// 左移ALU内部其实就是一堆逻辑门的组合通过不同的电路实现不同的运算。1.2 PC程序计数器PCProgram Counter是一个寄存器存储着下一条要执行的指令的地址。内存地址 指令 0x1000 mov eax, 1 0x1004 add eax, 2 0x1008 retCPU执行完0x1000的指令后PC会自动更新为0x1004指向下一条指令。1.3 寄存器CPU的工作台寄存器是CPU内部的高速存储单元速度极快小于1纳秒但容量很小通常64位。寄存器访问速度 1ns L1缓存约1ns L2缓存约3ns L3缓存约15ns 主内存约80ns可以看到寄存器比主内存快了将近100倍二、超线程一个顶俩2.1 什么是超线程超线程Hyper-Threading是Intel的一项技术让一个物理核心可以同时处理两个线程。传统CPU 核心1: [ALU] [PC] [Registers] → 一次只能跑一个线程 超线程CPU 核心1: [ALU] [PC1] [Registers1] [PC2] [Registers2] → 一次可以跑两个线程你可能听说过四核八线程就是指4个物理核心每个核心支持超线程总共能同时处理8个线程。2.2 超线程的原理超线程的核心思想是当一个线程在等待内存数据时另一个线程可以继续使用ALU。线程1: 去内存读数据...要等80ns 线程2: 我来算点东西使用ALU 线程1: 数据回来了继续执行这样就提高了CPU的利用率。2.3 对Java程序员的启示// 创建线程数 CPU核心数 * 2超线程intthreadCountRuntime.getRuntime().availableProcessors()*2;但要注意超线程不是真正的两个核心它们共享ALU等资源。如果两个线程都是CPU密集型的超线程的帮助就不大了。三、存储器的层次结构3.1 为什么要分层存储器有个不可能三角速度 ←→ 容量 ←→ 成本寄存器最快、最小、最贵硬盘最慢、最大、最便宜所以计算机采用了分层结构用快的小存储来缓存慢的大存储的数据。3.2 各层存储器的特点L0: 寄存器 1ns 几KB CPU内部 L1: 一级缓存 约1ns 32-64KB 每个核心独有 L2: 二级缓存 约3ns 256KB 每个核心独有 L3: 三级缓存 约15ns 几MB 所有核心共享 L4: 主内存 约80ns 几GB-几十GB L5: 磁盘 约ms级 几TB L6: 远程存储 约s级 无限3.3 缓存的工作原理当CPU需要读取一个数据时先查L1缓存找到就直接用命中L1没有查L2L2没有查L3L3没有去主内存读每次没找到都要多花几十倍的时间所以缓存命中率非常重要。四、缓存行一个容易被忽视的细节4.1 什么是缓存行缓存不是按字节存储的而是按缓存行Cache Line存储的。目前大多数CPU的缓存行大小是64字节。内存: [byte0] [byte1] [byte2] ... [byte63] [byte64] ... |------------- 缓存行1 ------------|-- 缓存行2 --|当CPU读取byte0时会把byte0到byte63整个缓存行都加载到缓存中。4.2 程序局部性原理这个设计基于局部性原理时间局部性最近访问的数据很可能马上又要访问空间局部性访问了一个数据它旁边的数据很可能也要访问// 空间局部性的例子int[]arrnewint[1000];for(inti0;iarr.length;i){arr[i]i;// 访问arr[0]时arr[1]到arr[15]也被加载到缓存了}4.3 缓存行对齐与伪共享在多线程环境下缓存行可能导致伪共享False Sharing问题// 两个线程分别修改不同的变量classData{longx;// 线程1修改longy;// 线程2修改}如果x和y在同一个缓存行中当线程1修改x时整个缓存行都会失效线程2修改y时就需要重新加载。这就是伪共享。解决方案是缓存行填充// Java 8之前的做法classData{longx;longp1,p2,p3,p4,p5,p6,p7;// 填充确保x独占一个缓存行longy;}// Java 8及之后使用Contended注解sun.misc.Contendedlongx;五、MESI协议多核缓存的一致性5.1 问题的来源在多核CPU中每个核心都有自己的L1/L2缓存。当多个核心缓存了同一个数据时如何保证数据的一致性核心1的缓存: x 1 核心2的缓存: x 1 内存: x 1 核心1把x改成2核心2的缓存怎么办5.2 MESI协议MESI协议用两个比特来标记每个缓存行的状态状态含义MModified已修改和内存不一致EExclusive独占和内存一致SShared共享多个核心都有IInvalid失效不能使用当核心1修改x时核心1把x的状态改为MModified通过总线通知核心2核心2把x的状态改为IInvalid核心2下次要读x时需要从核心1或内存重新获取六、CPU的乱序执行6.1 什么是乱序执行CPU为了提高效率会对指令进行重排序指令1: 去内存读数据要等很久 指令2: 计算一个值不依赖指令1的结果 CPU会先执行指令2而不是傻等指令1的数据回来6.2 乱序执行带来的问题在单线程下乱序执行不会有问题CPU保证单线程的语义正确性。但在多线程下可能会出问题。经典的例子是DCLDouble Check Lock单例publicclassSingleton{privatestaticvolatileSingletoninstance;publicstaticSingletongetInstance(){if(instancenull){// 第一次检查synchronized(Singleton.class){if(instancenull){// 第二次检查instancenewSingleton();// 这里可能出问题}}}returninstance;}}new Singleton()这行代码在CPU层面大致分为三步分配内存初始化对象把引用指向内存由于乱序执行步骤2和3可能被重排分配内存把引用指向内存此时对象还没初始化初始化对象如果另一个线程在步骤2之后、步骤3之前检查instance null会得到false然后使用一个未初始化的对象6.3 解决方案内存屏障CPU提供了内存屏障Memory Barrier来禁止特定的重排序sfence: 写屏障 - 屏障前的写操作必须在屏障后的写操作前完成 lfence: 读屏障 - 屏障前的读操作必须在屏障后的读操作前完成 mfence: 全屏障 - 屏障前的读写操作必须在屏障后的读写操作前完成在Java中volatile关键字会在写操作前后添加内存屏障StoreStoreBarrier volatile 写 StoreLoadBarrier这就是为什么DCL单例必须加volatile——它禁止了指令重排序。七、NUMA架构7.1 UMA vs NUMA早期的多CPU系统采用UMAUniform Memory Access架构CPU1 CPU2 CPU3 CPU4 \ | | / \ | | / [内存]所有CPU访问内存的速度是一样的但随着CPU数量增加内存争用会成为瓶颈。NUMANon-Uniform Memory Access架构CPU1 CPU2 CPU3 CPU4 \ / \ / [内存1] [内存2]每个CPU组有自己的本地内存访问本地内存快访问远程内存慢。7.2 ZGC的NUMA优化Java的ZGC垃圾收集器支持NUMA感知在分配内存时优先在当前线程所在CPU组的内存进行分配可以显著提高效率。八、总结这篇文章我们了解了CPU的核心组件ALU、PC、寄存器超线程一个核心跑两个线程存储器层次结构从寄存器到硬盘缓存行64字节的缓存单元以及伪共享问题MESI协议多核缓存一致性CPU乱序执行为什么DCL要加volatileNUMA架构非统一内存访问理解这些硬件知识能帮你更好地理解JVM的内存模型、volatile的作用、以及各种并发编程的为什么。下一篇我们会深入探讨volatile的实现细节以及JVM的happens-before原则。参考资料《深入理解计算机系统》Intel CPU手册