android 筑基 - CPU

623 阅读31分钟

前面那篇文章太长了,编辑的时候卡的要死,所以我把部分内容移到这里了

前言

之前专门写过 CPU 结构的知识,这里我就不过多再介绍了,有需要的去看这篇文章吧:

我在这里更想写的是 CPU 对内存的影响,操作系统对内存的影响,本章介绍 CPU 这块的。JAVA 屏蔽了对内存的操作,虽然方便我们开发了,但是要直到这只是方便了我们使用,对于我们理解、学习 JAVA 的特性、对内存的操作造成了不便

有的人会问:我们有必要学习这个嘛! 怎么不重要,不管任何操作系统,都离不开分配内存,JVM级别的内存操作、操作系统级别的内存操作,这些不光是说说看的东西,而是真实的我们在使用的,android 传统的本地缓存工具 SharedPreferences 大家都熟悉吧,基于IO实现,因性能问题要异步执行。微信最近开源了 SharedPreferences 替代工具 MMKV 工具,基于内存映射实现,速度很快不用异步了,内部使用了 aapt 这个技术,这个技术 Binder 源码就在使用

看到这里大家还有疑问吗,mmap 内存映射技术这是操作系统级别的,用C++开发的,MMKV本身其实代码少的很,就是几个C++方法就完事了,但就是性能好的一逼

现在优化思路就是语言级别做到机制了,能做的都差不多了,就往更低一层去想办法。我们要是不了解底层知识行吗,直接跟你说 mmap 的原理,你听都听不懂,信不信,反正我信,我知道这种明明找到了答案就是不理解、就是不明白的这种无力感

不光 MMKV,只要你是写代码的,多线程你能躲得过去吗,多线程里面的原理你都能理解吗,面试问你深一点必然会深入到底层知识,单单 JVM 字节码这层有的面试官还嫌不够,要一直问到你机器指令这个层级,你能搞定嘛 (ˉ▽ ̄~) 切~~

知道面试官为什么要文的这么深入吗,尤其是多线程这部分,实际体会,多线程你不理解到 JVM 字节码层级,机器指令层级你是不会真正理解多线程的一些原理的。就问你一个:说说DCL单例为什么要加volatile,你能真的说的明明白白的嘛,够呛

其实底层知识不多,就是有些深入,也没想象的难,拿点时间出来,好好看看都能学的会的,然后你也可以鄙视别人啦 (o≖◡≖)

这里我也不会罗里吧嗦说个没完,说些实际的,涉及到代码的和面试会问到的,其他的大家自己有需要再去深入研究,一般用不上了

从沙子到 CPU

我个人觉得这部分产业知识应该是从业人员了解的,不管敲代码的时候不会用到,但是这个应该知道的,了解我们这个行业的基本常识科目定不会错的,很多时候一些新闻报道你都看不懂,get不到其中关键的点,不知道影响有多大,会不会涉及到自己,这个我觉得应该是我们必备的技能了

一个CPU从沙子开始要经过以下步奏:

  • 沙子脱氧->石英->二氧化硅->提纯->硅锭
  • 切割->晶圆
  • 涂抹光刻胶->光刻->蚀刻->清除光刻胶
  • 电镀->抛光->镀铜->测试->切片->封装

有个著名的视频大家可以欣赏下:英特尔CPU制造过程 一堆沙子到一颗CPU的全过程

这块挺有意思的,当个科普片看看都成,我们从事的行业是一个多么复杂的行业啊,一步步的,从硬件到软件,100年内计算机都绝对是一个头部行业,我国一年芯片进口额有2500亿美元,比石油进口额都大,大家仔细体会

不要离开这个行业,计算机周边的行业就是目前最好的行业了,计算机你都混不好,到其他地方多半还不如计算机呢。所以该拼还得拼,不逼一下自己,你不会知道原来自己可以这么NB的

(੭ˊᵕˋ)੭φ(≧ω≦)♪♪(^∇^)♪(^∇^)♪(^∇^)♪(^∇^)o(*≧▽≦)ツ┏━┓

理解什么是汇编

一说到汇编大家都头疼,根据前辈们的经验,汇编这个东西吧非常难,难得一逼。但是这里为啥还要提到这里呢,因为我们学习一些原理会涉及到机器指令层级,这个和汇编就能扯上关系了。其实我们只要了解汇编是什么就行了,不要求你会,除非你用到了

我在这篇讲述计算机硬件的文章中:编程要了解的 CPU 硬件知识点中有说汇编的发展历程,这里我再简单的说一下

计算机1799年就出现了,那会是手摇式的,一个圆盘刻有10个刻度代表10进制,手摇出加减法结果

后来大家发展可以用电位表示2进制,2进制可以很好的和10进制抓换,没电位=0,稳定5V电位=1,就是这样。先是电子管,就是那种看着和灯泡一样的东西,后来临近二战出现了晶体管

多个晶体管可以组成逻辑门电路,众多的逻辑门电路又可以组成逻辑电路,这样简单的计算单元ALU就出现了 !

到了这里就是关键点了,总有人会问软件怎么操作硬件的。简单来说CPU硬件会支持预订的设计好的操作指令,我们把程序按照一条条指令的顺序写好了存在内存中,CPU从内存中一条条读取这些指令,这些指令叫机器指令,每种指令都有对应的识别逻辑电路,这些逻辑电路过电会连接不同的逻辑电路实现指令计算

机器指令是一串2进制数字,这个2进制数字包含指令类型,参与计算的数据的内存地址,参数一般会有多个

CPU中识别指令的部分叫控制器

01101010 硬件认识的就是这东西,但是这东西你看的懂嘛,看这个数字你知道是要干啥吗?后来人们实在接受不了了,就给每种机制指令类型加了个英文的助记符:1000=ADD,然后再写代码的时候先在纸上用助记符写一遍,然后再按照2进制的形式输入计算机

这个助记符就加做汇编

但是这个汇编看着还是太令人费解,不能一看就懂,于是人们发明了汇编编译器,能把自己写的编程语言转换成汇编,然后再执行,这样高级语言就诞生了:C/C++/JAVA/PYTHON/GO,任何高级语言最后执行都要编译成机器指令交给计算机执行的

android 从JIT进化到AOT,优化的不就是机器指令的编译时机吗,JIT会保存部分热点代码编译之后的机器指令,AOT则是在编译时直接保存机器指令,运行的时候不用再多一部解释操作了

到这里大家看懂了吧,汇编就是助记符,其实和机器指令是一回事,直到就行了,后面说到机器指令时就不迷糊了

计算机系统的储存结构

(づ ̄3 ̄)づ╭❤~ 我感受相当比例的人说不清这个,很多时候你会听到主存,高速缓存,寄存器,工作内存,内存副本这些,你能搞懂吗,搞不懂多线程是要出问题的,学着可费劲了

今天来听我烙上这么一烙啊

1. 主存

什么是主存,很简单、很明确的就是内存条

2. 寄存器、高速缓存

这部分内存是封装在CPU内存,是我们敲代码时操作不了的部分,其使用机制完全看操作系统和CPU架构

  • 寄存器: 是配合ALU计算单元做指令计算的,非常小,只会存本条机器指令计算涉及到的个别数据,不作为广义上的内存使用
  • 高速缓存: 这是计算机内部真正意义上存储数据的内存,因为CPU访问内存较慢,为了加快速度,CPU会自己选择把一部分和计算最相关的数据缓存在CPU内存

寄存器我找到一段话,说的很清楚:

因访问内存以得到指令或数据的时间比CPU执行指令花费的时间要长得多,所以,所有CPU内部都有一些用来保存关键变量和临时数据的寄存器

3. L1、L2、L3

CPU中一般采用3级缓存结构,L1、L2、L3,L1速度最快,离ALU最近,也最小;L3速度最慢,离ALU最远,也最大

一般来说,ALU计算单元加载数据的顺序是:

  • 先把数据从内存读取到L3
  • 再把数据从L3读取到L2
  • 然后把数据从L2读取到L1
  • 最后把数据读取到指定的寄存器做运算
  • 保存计算结果的顺序相反

一般来说:

  • L1 是每个CPU核心独享的
  • l2 在移动端ARM结构的CPU中,是每个大核和小核组成的单元共享的
  • L3 是所有CPU核心共享的

对于CPU读取数据的顺序,有个明确说明:

CPU在缓存中找到有用的数据被称为命中,当缓存中没有CPU所需的数据时(这时称为未命中),CPU才访问内存。从理论上讲,在一颗拥有二级缓存的CPU中,读取一级缓存的命中率为80%。也就是说CPU一级缓存中找到的有用数据占数据总量的80%,剩下的20%从二级缓存中读取。由于不能准确预测将要执行的数据,读取二级缓存的命中率也在80%左右(从二级缓存读到有用的数据占总数据的16%)。那么还有的数据就不得不从内存调用,但这已经是一个相当小的比例了。目前的较高端的CPU中,还会带有三级缓存,它是为读取二级缓存后未命中的数据设计的—种缓存,在拥有三级缓存的CPU中,只有约5%的数据需要从内存中调用,这进一步提高了CPU的效率

4. 通信总线

台式机大家都了解吧,有好多板卡,没一个都是一个组件,这些组件在插在主板上之后就能形成一个完整的系统,这些组件之间就是通过总线这个东西来相互传递数据的,看图:

总线就是这些主板背面像是线的东西,这些其实就是导电线路,能导电就能传递数据信号

所有的数据都是在总线上流动的,但是数据必须先写到内存中,再通过总线输送到执行设备,比如显卡把渲染过的数据先写到内存,再通过总线交给显示器显示

北桥,南桥是主板上芯片组中最重要的两块了。相对的来讲,北桥要比南桥更加重要。北桥连接系统总线,担负着 CPU访问内存的重任。同时连接这AGP插口,控制PCI总线,割断了系统总线和局部总线,在这一段上速度是最快的。南桥不和CPU连接通常用来作I/O 和IDE设备的控制。所以速度比较慢,一般情况下,南桥和北桥中间是PCI总线。

5. 各存储组件速度差距

计算机内部这些储存单元,速度肯定不可能一样,记住速度是相对于谁来说的,是CPU,我们计算的是从这些存储组件发出,数据传递到CPU的速度,实际速度距CPU的物理距离也是有关系的,距离越远,电子信号衰减越严重

存储单元耗费时钟周期耗费时间
L13-41ns
L2103ns
L340-4515ns
内存60-80ns

可以看到 CPU 访问内存是最慢的,访问 CPU 内存的3级缓存速度要快得多,所以诞生了许多缓存机制,为的就是减少访问内存的次数,频繁的与内存交互会让 CPU 多数时间处于等待状态,会大大降低 CPU 执行效率。CPU 性能的提升,一小部分来源于时钟周期的提高,但是贡献更多的是对于 CPU 等待方面的优化,只要减少 CPU 等待数据的几率,那节省下来的时间 CPU 就能执行更多的命令

工作内存

我有必要专门强调以下这个,这是我们在学习 JVM、多线程阶段挺好的最多的内容了,必须正确理解工作内存这个概念

JVM 设计规范把内存分成主内存和工作内存2块,我们烙听别人说主内存、工作内存的出处就在这里,JVM设计会烦要求的

还记得这张图吗 主内存就是内存条,就是堆内存,这种说法不一定严谨,但是可以这么理解

而工作内存是说每一个线程都有一个自己的工作内存,这里说的就是虚拟机栈,JVM 内存结构中,每个线程都有专属与自己的,别的线程访问不了的虚拟机栈,栈中保存的是代表一个个方法在运行过程中的表示:栈帧

根据虚拟机栈顶优化原则,至少正在运行的线程中的虚拟机栈栈顶的栈帧是要放到CPU高速缓存中的,而其他栈帧根据CPU架构的不同,有的在主内存中,有的在高速缓存中。移动设备 ARM 架构、基于寄存器架构的 CPU,会把尽量多的栈帧都缓存在高速缓存中

栈帧中保存的是方法中用到的数据的拷贝,在栈帧从告诉内存写会主内存的时候会把栈帧中的数据同步到主内存,在方法运行过程中则会存在同一个数据工作内存和主内存的差异,这里大家搞明白就行了,不用纠结过多

《深入理解JVM虚拟机》书中是这么说工作内存的

缓存行

存行这个概念啊挺好理解的,还是基于CPU到内存的速度实在是慢,为了减少对内存的访问次数,CPU 从内存读取数据时不是用一个数据读一个到 CPU,而是一次尽可能多的读一些数据到 CPU 中

具体的做法是,数据存储在主内存中时是以一行一行的形式保存的,在 CPU 读取内存数据时,一次会把数据所在的那个缓存行的数据全部读取到 CPU 高速缓存中

inter CPU 一般一个缓存行是64个字节,太小了可能连一个对象都放不下,太大了单次读取时间太长,所以折中的是64个字节

比如像上面这个图,我们一次声明2个int变量x、y,x、y肯定在一个缓存行中,CPU1用到x时会把y一定读进去

好了就是这个意思,理解就行了,缓存行这里和 volatile 有一些瓜葛,是面试的重点,一定要门清

对于 CPU 来说,它是不会一个字节一个字节的加载的,因为这非常没有效率,一般来说都是要一块一块的加载的,对于这样的一块一块的数据单位,术语叫“Cache Line”,一般来说,一个主流的 CPU 的 Cache Line 是 64 Bytes(也有的 CPU 用 32Bytes 和 128Bytes),64Bytes 也就是 16 个 32 位的整型,这就是 CPU 从内存中捞数据上来的最小数据单位

你以为这就完了嘛,还有呢,我找到了更详细的资料:

主内存和CPU缓存皆适用缓存行这个东西,CPU 缓存中进一步把多个缓存行看成一路 way,称为 N-way 这种方式来管理缓存行

Intel 大多数处理器的 L1 Cache 都是 32KB,8-Way 组相联,Cache Line 是 64 Bytes。这意味着:

  • 32KB 的可以分成,32KB / 64 = 512 条 Cache Line
  • 因为有 8 Way,于是会每一 Way 有 512 / 8 = 64 条 Cache Line
  • 于是每一路就有 64 x 64 = 4096 Byts 的内存

CPU缓存需要之道自己缓存的是内存哪块的内存,若我们对主内存或是CPU缓存一一遍历的方式来确定2者的关联关系,那么性能上是灾难性的,这是我们不能接受的,所以就需要有一种地址关联的算法。这里使用了一种数据映射方式,有点像内存地址从逻辑地址向虚拟地址映射的方法,但不完全一样

映射关系需自然需要一些数据来记录其中关系,这些数据保存在 CPU 的缓存行中,CPU 的每一个缓存行开头都有Tag、index、offset 3个头信息来方便索引内存地址:

  • Tag: 每条 Cache Line 前都会有一个独立分配的 24 bits 来存的 tag,其就是内存地址的前 24bits
  • Index: 内存地址后续的 6 个 bits 则是在这一 Way 的是 Cache Line 索引,2^6 = 64 刚好可以索引 64 条 Cache Line
  • Offset: 再往后的 6bits 用于表示在 Cache Line 里的偏移量

当有数据没有命中缓存的时候,CPU 就会以最小为 Cache Line 的单元向内存更新数据。当然,CPU 并不一定只是更新 64Bytes,因为访问主存是在是太慢了,所以,一般都会多更新一些。好的 CPU 会有一些预测的技术,如果找到一种 pattern 的话,就会预先加载更多的内存,包括指令也可以预加载

到这里缓存行的东西才差不多算是全了,大家理解下,还是有些学习必要的

很多时候 CPU 从内存加载数据,一次都是读一页,一页是4K,页的概念大家去看后面 Linux 的部分,有解释清除的

缓存命中性能问题

这个问题就是缓存行衍生出来的问题,在并发量巨大、数据数量打的后端程序中问题会比较严重

例子1:

首先,假设我们有一个 64M 长的数组,设想一下下面的两个循环:

const int LEN = 64*1024*1024; 
int *arr = new int[LEN];  
for (int i = 0; i < LEN; i += 2) arr[i] *= i;  
for (int i = 0; i < LEN; i += 8) arr[i] *= i; 

一般来说,按照预计,第二个循环要比第一个循环少 4 倍的计算量,速度也应该快 4 倍的。但实际跑下来并不是,在我的机器上

  • 第一个循环127ms
  • 第二个循环121

速度居然一样,原因就是缓存命中了。CPU 会以一个 Cache Line 64Bytes 最小时单位加载,也就是 16 个 32bits 的整型,所以,无论你步长是 2 还是8,都差不多,你计算的数据前后都在一个缓存行中被CPU加载进来,而后面的乘法其实是不怎么耗 CPU 时间的,耗时的加载内存操作耗时都是一样的

例子2:

我们以一定的步长increment 来访问一个连续的数组

for (int i = 0; i < 10000000; i++) {     
	for (int j = 0; j < size; j += increment) {         
    	memory[j] += j;     
    } 
}

从 [1024] 以后,耗时显注上升。我机器的 L1 Cache 是 32KB, 8 Way 的,前面说过,8 Way 的一个组有 64 个 Cache Line,也就是 4096 个字节,而 1024 个整型正好是 4096 Bytes。所以,一旦过了 8 Way + 4096 Bytes 这个界,每个步长都无法命中 L1 Cache,每次都是 Cache Miss,所以,导致访问时间一下子就上升了

这个操作的有的CPU缓存分析会直接会缓存加载满的,但是不一定,要看你机器的CPU

例子3:

我们对一个二维数组的两种遍历方式,一个逐行遍历,一个是逐列遍历,这两种方式在理论上来说,寻址和计算量都是一样的,执行时间应该也是一样的

const int row = 1024; 
const int col = 512; 
int matrix[row][col];  

//逐行遍历 
int sum_row=0; 
for(int r=0; r<row; r++) {     
	for(int c=0; c<col; c++){         
    	sum_row += matrix[r];     
    } 
}

//逐列遍历 
int sum_col=0; 
for(int c=0; c<col; c++) {     
	for(int r=0; r<row; r++){         
    	sum_col += matrix[r];     
    }
} 
  • 逐行遍历:0.081ms
  • 逐列遍历:1.069ms

十几倍的差距,究其原因,就是逐列遍历对于 CPU Cache 的运作方式并不友好,每次都缓存miss,每次都得从内存加载数据,不像逐行,利用缓存命中,可以几次操作之后才从内存加载一次数据

例子4:

多线程环境下缓存行问题依然严重,不考虑加锁,2个线程同时对数据读写数据

void fn (int* data) {     
for(int i = 0; i < 10*1024*1024; ++i){
	data += rand (); 
    }
}

int p[32];   
int *p1 = &p[0]; 
int *p2 = &p[1]; 

thread t1(fn, p1); 
thread t2(fn, p2);
  • 对于 p[0] 和 p[1] :560ms
  • 对于 p[0] 和 p[30]:104ms

这是因为 p[0] 和 p[1] 在同一条 Cache Line 上,而 p[0] 和 p[30] 则不可能在同一条 Cache Line 上 ,CPU 的缓冲最小的更新单位是 Cache Line,所以,这导致虽然两个线程在写不同的数据,但是因为这两个数据在同一条 Cache Line 上,就会导致缓存需要不断进在两个 CPU 的 L1/L2 中进行同步,从而导致了 5 倍的时间差异。

例子5:

多线程环境下要是多个 volatile 数据也会有缓存行问题,2个线程分别操作处于同一个缓存行的 volatile 数据,你会发现6个线程并发都跑不过一个线程,就是因为 volatile 要频繁往内存中刷新数据,甚至不用多个 volatile,1个 volatile 都会带来性能问题

所以 volatile 真不能乱用,这里就不写代码了

@Contended 注解

JDK8 加入了 @Contended 注解,当该注解应用于变量上时,被注释的变量将和其他变量隔离开来,会被加载在独立的缓存行上,这个玩意叫做:缓存行填充,多线程情况下的伪共享冲突问题

缓存一致性协议

缓存一致性协议单独说不好理解,这个要结合 volatile 一起说才有实际意义。 volatile 保证了数据的可见性,还是用之前上面的这张图做解释

我们给X加上 volatile 关键字,同时有2个线程A、B同时都读取了 X int 值,有了可见性这个特征后,线程A对X的修改会同步到线程B,反过来也一样。这个是怎么实现的,靠的就是CPU层面的缓存一致性协议

缓存一致性协议是每种CPU架构都必须实现的一种设计规范,inter CPU 中缓存一致性协议是 MESI 这个东西,除此之外在其他CPU平台上还有:MSI/MOSI/Synapse Firefly Dragon 等,区别是具体的实现不一样罢了,但是干的事都是一样的就是保证缓存中CPU系统中的一致

缓存一致性协议中规定数据有4种状态:

  • M 修改 Modified
  • E 独享、互斥 Exclusive
  • S 共享 Shared
  • I 无效 Invalid

再来看看计算机系统的储存结构

在多核CPU中,缓存一致性真的非常有必要

还是拿上面X、Y 说事啊,我详细解释下数据在缓存一致性协议中状态的变化:

  1. 线程A 加载了 int X,此时线程A中的 X 是 E 独享状态的,线程发现 X 是 volatile 修饰的,会根据缓存一致性协议协议,启动总线嗅探机制监听 X 在其他缓存中的变化
  2. 此时线程B 也加载了 int X,总线嗅探机制嗅探到了 X 在多个核心缓存中存在,线程A、线程B 中的 X 此时都是 S 共享状态的了
  3. 此时线程A 修改了 X,线程A 中的 X 变成了 M 状态,通过总线嗅探机制的通知,线程B 中的 X 变成了 I 状态,此时,线程B 中对 X 的任何修改都不会被写入到内存中
  4. 然后线程A 把对 X 的修改立马写回到内存中,线程A 中的 X 变成了 E 独占状态
  5. 最后线程B 再从内存中读取一次 X 的数据进来,此时 线程A和B 中的 X 都变成了 S 状态

其实这涉及到了一个锁:缓存锁,任何 volatile 修饰的变量在CPU缓存中被修改都会第一时间被写回内存中去,这当然有性能损失,所以 volatile 不能瞎用,好处是确保了数据在多核心架构中的一致

线程B 在接到总线嗅探机制的通知后,X 变成 I 无效状态了,此时会给 X 在主内存中内存位置加一把缓存锁,线程B在 X 变成 I 无效后会第一时间去内存中读取 X 的最新值,发现主内存中的X有一把缓存锁就会一致阻塞在这里,一直到线程A同步完对X的修改,主内存的中X变成 S 去掉锁才会把X最新值加载会线程B,注意这里锁的存在,缓存锁锁住的是谁

volatile 的可见性不光可以通过缓存一致性协议 MESI 实现,更直接的可以通过总线锁实现,只不过总线所锁住的范围更差,性能上没有优势

但是对于那些跨缓存行的大对象而言,就不能用 MESI 了,必须使用总线锁了,所以一定要清楚这里,才能直到加了 volatile 对性能上带来多少负担,本身 volatile 的使用 就被建议要异常谨慎的

CPU 乱序执行引起的问题

这个大家都知道吧,为了提高 CPU 执行效率,在保证最终结果不问题的前提下,编译器会对指令重排序,前后打乱执行, 比如沏茶水的过程,乱序执行之后,前后步奏就不一定是完全按顺序来的了,在等待一些指令返回结果的过程中,会执行其他指令

这个问题还是涉及到了 volatile 这个关键字,volatile 面试一大阴影啊,绝对问你的想死的心都有啊 w(゚Д゚)w

DCL 双判断单例为什么要加 volatile 关键字

单例一般大家可能会这么写,有的人就是不信邪,就不加 volatile

public class Max {
​
    public static Max instance;
​
    public static Max getInstance() {
​
        if (instance == null) {
            synchronized (Max.class) {
                if (instance == null) {
                    instance = new Max();
                }
            }
        }
        return instance;
    }
}
我们先回顾下对象创建的过程:
1. `判断对象对应的类是否被加载、链接、初始化了` 也就是new指令会去方法区常量池中定位到类的符号引用,检查这个符号引用是否被加载了
2. `给对象分配内存空间,计算对象需要占用多大的内存空间`
    * 堆内存规整:指针碰撞
    * 堆内存不规整:JVM要维护一个空闲列表,记录哪些内存块可用的,哪块是用过的,碎片化的问题,对应的是标记清除算法,伊甸园区回收完垃圾之后不做规整整理就这样,比如早期的CMS垃圾回收器
3. `并发处理` 堆内存是共享区域,就会有并发问题
    * CAS 失败重试,区域加锁保证原子性
    * 堆内存给每个线程预先分配一块TLAB空间,也就是针对每个线程预先给一块专属的内存空间,以防止并发问题,问题是这块空间不大,所以还需要上面CAS的配合,可以通过`-XX:UseTLAB`来设置
4. `初始化分配到的内存空间(默认初始化)` 也就是给属性赋一个初始的默认值,即便该属性在代码里设置值了,在这一步也会先给一个默认值,在之后的步奏里再赋指定值
5. `设置对象头` 对象头保存有对象的hashcode,GC信息,锁信息,所属类(方法区元数据地址)
6. `显示初始化,执行init方法并初始化` 具体可以看下一个问题的解答,init就是类的构造器,包括属性的赋值操作
我们再来看看字节码

Max instance = new Max();

0: new #2 // class java/lang/String
3: dup
4: invokespecial #3 // Method java/lang/String."<init>":()V
7: astore_1

new 关键字先是在堆内存中开辟一块空间(会半初始化);
然后把这块内存的地址放到操作数栈栈顶;
然后进行初始化操作;
最后把操作数栈栈顶也就是内存开辟的内存地址赋给局部变量表里面的对象指针

造成的问题:

  1. 由于 JVM 的乱序执行,执行的顺序可能是这样的,0->3->7->4,这样就会出问题了
  2. 此时正好执行到7这里,CPU时间用完,下一个时间片没抢到,栈帧会把数据写回内存,此时内存中的 instance 对象的确是指向了内存中的一块区域,但是 instance 并没有执行初始化方法,使用起来肯定会出问题的
  3. 此时其他线程走到这一看 instance 不是空,那就继续下面的执行,因为 instance 没初始化就会出现一些列莫名其妙的问题,这个问题根据实际经验,百万次会出现一次,基本调试不出来,但是影响很恶劣,属于无解的难题的那种,怎么找都找不到原因的那种

但是我们加上 volatile 就行了

public class Max {
​
    public static volatile Max instance;
​
    public static Max getInstance() {
​
        if (instance == null) {
            synchronized (Max.class) {
                if (instance == null) {
                    instance = new Max();
                }
            }
​
        }
        return instance;
    }
}

加上了 volatile 关键字,编译器就不会对 instance = new Max() 这一行代码进行冲排序,该怎么执行就怎么执行

volatile 特性:

  • JVM 通过内存屏障 (memory barrier)禁止重排序,即时编译器根据具体的底层 体系架构,将这些内存屏障替换成具体的 CPU 指令
  • 对于编译器而言,内存屏障将限制它所能做的重排序优化
  • 对于处理器而言,内存屏障将会导致缓存的刷新操作。 比如,对于 volatile,编译器将在 volatile 字段的读写操作前后各插入一些内存

volatile 指令冲排序的经典例子

可以接上面内存继续分析

public class ThreadTest {
    int a = 0;
    int b = 0;
    int x = 1;
    int y = 1;
 
    public boolean test() throws InterruptedException {
        a = b = 0;
        x = y = 1;
        CyclicBarrier cy = new CyclicBarrier(2);
        Thread t1 = new Thread(() -> {
             a = 1;
        	 x = b;
            
        });
        Thread t2 = new Thread(() -> {
             b = 1;
             y = a;
        });
 
        t1.start();
        t2.start();
 
        t1.join();
        t2.join();
 
        if (x == 0 && y == 0) {
            return false;
        } else {
            return true;
        }
    }
 
    public static void main(String[] args) throws InterruptedException {
        for(int i=0; i<=10; i++){
            ThreadTest tt = new ThreadTest();
            boolean b = tt.test();
            if(!b){
                System.out.println(i);
            }
        }
    }
}

a = 1;x = b; 这2行代码之间是没有关系的,由于指令冲排序,x = b 有可能就在 a = 1 前面执行,就有可能 x=0,y=0,这种情况多跑机会绝对会出现,面试看到这道题不要慌,问的就是指令冲排序的事

内存屏障

之前写的不满意,重新走了一遍 (๑•̀ㅂ•́)و✧

volatile 的2个特性:可见性、防止指令重排序

这2个特性都是通过 内存屏障 的相关指令完成的,也就是说内存屏障一个指令干了2件事

内存屏障有两个作用:

  • 阻止屏障两侧的指令重排序
  • 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效

看到这里想起什么,想起没想起缓存一致性协议协议,就是这么回事,volatile 涉及的知识点全是钩着的,捋清楚其中关系真实花了点时间的

从 java 代码到最终执行的指令,可能会经过三种重排序:

其实内存屏障从跟上说是 CPU 硬件提供的机器指令,JVM 也设计了专门的字节码、汇编码来封装这些不同平台的机器指令

硬件层的内存屏障指令:

  • Load Barrier - 读屏障
  • Store Barrier - 写屏障
  • Full Barrier - 全屏障

Full Barrier 这个指令一般都没说,但是的确有,这3个指令是 X86 架构的 CPU 的机器指令,其他架构、平台的 CPU 也科目定有,是 CPU 都必须要实现内存屏障,区别是具体的指令不一样罢了,这些指令都是 lock 锁指令种的一种

这么理解就行了:

  • 对于 Load Barrier 来说,在指令前插入 Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据
  • 对于 Store Barrier 来说,在指令后插入 Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见

上面说了对于不同的硬件平台有不同的实现内存屏障的机器指令,JVM 统一做了封装处理,有自己的一个 lock addl 汇编指令,再更具平台不同翻译成不同平台的机器指令,知道有这么个东西就行了

JVM 层的内存屏障指令

java 的内存屏障通常所谓的四种:LoadLoad,StoreStore,LoadStore,StoreLoad

  • StoreStore: 写入操作执行前,保证写入操作对其它处理器可见
  • StoreLoad: 保证对数据的写入对所有处理器可见
  • LoadLoad: 保证要读取的数据被读取完毕
  • LoadStore:保证要读取的数据被读取完毕

搞不明白也不打紧,知道有这么东西就行了,Store 的是一组,Load 的是一组。对 volatile 数据的读取和修改的操作前后都会加上这些语句,具体是这样走的:

  • 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障
  • 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障

软件层面通常使用内存屏障和原子指令来保持变量的可见性和指令顺序

hapens-before原则

这里还有概念:hapens-before原则,这个是 JVM 设计规范中的内容,规定哪些情况下不能进行指令重排序

final 语义中的内存屏障

  • 对于final域,编译器和CPU会遵循两个排序规则
  • 新建对象过程中,构造体中对final域的初始化写入和这个对象赋值给其他引用变量,这两个操作不能重排序
  • 初次读包含final域的对象引用和读取这个final域,这两个操作不能重排序;(意思就是先赋值引用,再调用final值)

final 这块的东西大家了解下,就是 final 对象的创建和读取都是不会指令冲排序的

volatile 带来的锁竞争

volatile 的使用要谨慎,volatile 本身也涉及到锁:缓存锁、总线锁

这里有个经典面试题:同一个缓存行中的X/Y都加 volatile 会怎么样,答案是会引起锁竞争和频繁强制同步CPU->内存的数据,反而会拖慢速度

在核心1上运行的线程想更新变量X,同时核心2上的线程想要更新变量Y。不幸的是,这两个变量在同一个缓存行中。每个线程都要去竞争缓存行的所有权来更新变量。如果核心1获得了所有权,缓存子系统将会使核心2中对应的缓存行失效。当核心2获得了所有权然后执行更新操作,核心1就要使自己对应的缓存行失效。这会来来回回的经过L3缓存,大大影响了性能。如果互相竞争的核心位于不同的插槽,就要额外横跨插槽连接,问题可能更加严重。

这是我找来的说法,说的很清楚了。实际上不止L3,还有主存,volatile 会强制CPU缓存中的数据刷新至内存的,要频繁和内存通信,这速度自然就慢下来了

对于总线嗅探的理解看这里的解释,我觉得提供好懂的:

处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完之后不知道何时会写到内存;如果对声明了Volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

注意啊,缓存所不会死锁的,最多有个前后的关系

@Contended 注解

JDK8 加入了 @Contended 注解,当该注解应用于变量上时,被注释的变量将和其他变量隔离开来,会被加载在独立的缓存行上,这个玩意叫做:缓存行填充,多线程情况下的伪共享冲突问题

超线程概念

都听过线程撕裂者吧、超线程、4核8线程这些的,一个核心怎么跑2个线程的,主要是就是在一个CPU核心中,1个计算单元 ALU 有2套寄存器,一个线程一套,在一个线程不得不切换上下文时,这是 ALU 不用再咸鱼啦,不用等线程切换完毕,直接可以运行另一套寄存器中的线程任务

没找到图,大家脑补吧,这个知识点直到就行了,不要惊讶就好

UMA/NUMA

这个概念是对服务器说的,大家知道这个东西就行了

  • UMA: 统一访问内存系统
  • NUMA 非统一访问内存系统

传统的系统机器上就1个CPU、一组内存,后来发展到主板上可以支持多个CPU和很多内存条,尤其是服务器典型的就是这样

这就带来一个问题:CPU 之间为了争抢内存地址资源浪费了太多性能,一般这个上限是4颗CPU抢同一套内存 这个图就是 UMA 系统的样子

后来大家一看这样不行啊,加了这么多硬件,钱花了不少,但是实际性能增加非常有限,怎么办,把CPU和内存分组使用,不同的CPU、内存块之间可以通过专用总线访问 这个图就是 NUMA 系统的样子

我再来个 CPU 居多的

CPU 多了只能这么搞,这块内存规定给哪几个 CPU,大家都别抢。当然网上有很多文章反应这样出现不少问题,还有专门的 JVM 优化参数 w(゚Д゚)w 真是越来越复杂

NUMA 还涉及到了最新的 ZGC 垃圾回收器,ZGC 会把对象分配在这个线程所在 CPU 最近的那块内存上,好复杂的样子

开机启动过程

在了解操作系统之前,我们先了解一点硬件知识,我们大体知道操作系统怎么跑起来的

总体来说是开机七佛那个的过程是这样的:通电->bois/uefi芯片工作->自检->在硬盘固定位置加载bootloader引导程序->读取可配置信息->cmos芯片

BISO/UEFI 芯片

BISO 芯片我想攒过机的朋友都知道,UEFI 是新一代 BISO 芯片。区别是:BISO 比较老只能提供简单的黑白界面,UEFI 可以提供彩色的复杂的界面,这个界面就是大家熟悉的 BISO 界面,安装系统必须进的,选择开机启动项的地方

记住 BISO 是个硬件,是焊接在主板上的一个独立芯片,现在基本都是 UEFI 芯片了

  • BISO 界面:

  • UEFI 界面:

BISO 工作内容

BISO 芯片里面存储的一段固定的程序,出厂时就写好了,我们自己改不了,但是厂商提供的工作可以更新 BISO 芯片里的程序,干的就是2件事:

  • 开机自检: 检查所有硬件设备是否都能物理联通
  • 加载引导程序: bootloader 引导程序是固定写在硬盘第一个扇区里的,存储位置是固定的,强制约定的固定位置,你写在别的地方 BISO 芯片知道去哪读取这个引导程序呀

bootloader 引导程序

引导程序也是固定位置,也是存储在硬盘的第0个柱区、第0个磁头、第1个扇区内,记录的机器中OS安装情况,

典型的就是安装多系统后,电脑会有一个界面问你要启动哪个系统

有个著名的病毒 CLH 攻击的就是硬盘第一个扇区,病毒会格式化该扇区,只要电脑关机了,就再也启动不起来,除非重新装系统。 写这个病毒的人被抓起来判了2年,出来后各大安全公司上门抢人,年薪百万,也算是财务自由了,

引导程序确定了要启动哪个操作系统之后,会去硬盘第一个扇区的固定位置加载操作系统的第一行代码,所以这里又出现了一个固定位置,病毒破坏这些固定位置就能让机器挂掉,破坏力是相当大的

CMOS 芯片

CMOS 也是主板上一块独立的内存单元,但是 CMOS 芯片比较特殊,它有一块专门给他通电的电池

看到这个大家是不是就很熟悉啦

CMOS 保存的是机器的一些配置信息,比如:开机密码,上次启动的操作系统,开机项 等信息。有个著名的段子就是哪天你要是忘了开机密码,就把机箱卸了,把主板上 BISO 电池扣下来就行啦。这块电池一般有效期3年左右

磁盘存储数据

这里说下传统的磁盘是怎么存数据的,磁盘的盘片上其实是凹凸不平的,凹面可以存储一个电位,有电子就表示1,没电子就表示0

磁盘的整体结构多少有些复杂,硬盘上不光有一张磁盘,是有多张磁盘的

磁盘的地址分为

  • 盘片:
  • 磁道: 盘片上一圈就是一个磁道
  • 扇区: 每个磁道一定扇形的区域就是一个扇区
  • 柱面: 所有磁盘上相同位置的扇区组成一个柱面

硬盘寻址时都是说哪个柱面,哪个磁头,哪个扇面,然后加上数据首地址