一、引言
对于Java开发者而言,关于底层知识,我们一般当做黑盒来进行使用,不需要去打开这个黑盒。
但随着目前程序员行业的发展,我们有必要打开这个黑盒,去探索其中的奥妙。
本篇系列文章,将带你一起探索底层黑盒的奥秘之处。
二、相关书籍推荐
读书的原则:不求甚解,观其大略
你如果进到庐山里头,二话不说,蹲下头来,弯下腰,就对着某棵树某棵小草猛研究而不是说先把庐山的整体脉络研究清楚了,那么你的学习方法肯定效率巨低而且特别痛苦。
最重要的还是慢慢地打击你的积极性,说我的学习怎么那么不happy啊,怎么那么没劲那,因为你的学习方法错了,大体读明白,先拿来用,用着用着,很多道理你就明白了。
《编码:隐匿在计算机软硬件背后的语言》
《深入理解计算机系统》(不建议读)
《算法导论》、《Java数据结构和算法》、《剑指offer》
《30天自制操作系统》
《TCP/IP详解》卷一
龙书《编译原理》
三、硬件基础知识
1、CPU的制作过程
CPU是如何制作的?
我相信每一个人都会都这么一个问号,今天来告诉你,所有的CPU都来源于:沙子
对于CPU的制作流程,这里有篇文章,大家有兴趣的可以看一下:CPU是如何制作的
没有兴趣的直接看概括:
- 第一步:我们从沙子中提供单晶硅 晶体
- 第二步:将晶体切割成薄片,得到 晶圆
- 第三步:将金属粒子轰击到晶圆上,再进行 电镀
- 第四步:在晶圆上进行 光刻,完成不同晶体管之间的导线互连
- 第五步:质量检测,去除质量差的CPU
2、CPU的原理
计算机最开始要解决的问题:如何代表数字?
最原始的计算机采用的是利用灯泡,当我们计算 0100 + 1010 时,我们使用 8 个灯泡,以 灯泡的状态来代表 0 和 1,这样我么的第一版的计算机已经OK了。
ENIAC重达27吨,占地1800平方英尺(约合167.2平方米)。诞生于二战时期,最初是作为辅助炮兵计算炮弹轨迹的工具。
第一版的计算机,有一个让人哭笑不得的缺点。
我们在对计算机进行高速计算时,灯泡的闪光频次比较高,有可能造成灯泡的损坏,就需要有工作人员及时的更换灯泡,从而影响效率。
作为最初的一款计算机来说,他的面世足够让地球人震撼。
目前的计算机大都采取晶体管的方式进行计算,利用 与门
、或门
、非门
、或非门
的状态去表示不同的计算方式。
我们经常在生活中听说,CPU32位、CPU64位
,简单来说,他们的区别就是:一次性读取多少位(bit)的数字。
我们任何的计算,都可以通过逻辑运算来进行得到,我们来看下面这个逻辑运算:0 && 1 = 0
,是怎么实现的?
首先,我们看一下电路图:这是一个 与门
电路图
对于该电路而言,A
和 B
作为输入,Q
作为输出。
例如 A
输入低电平、B
输出高电平,那么 Q
就会输出低电平,转换为二进制就是 A
输入0、B
输出1,那么 Q
就会输出0,对应的逻辑运算表达式为 0 && 1 = 0
这里有一个关于BUG来源的小故事:从前有一个人,进行计算机的计算时,
发现数字总是不正确,找了好久也没找到原因,后来发现是计算机有个孔被虫子(BUG)腐蚀了,导致没办法进行低电平、高电平的切换,从此,我们编程上的错误就叫做:BUG
3、汇编语言的执行过程
我们想一下,在上面电路中,发生了 0 && 1 = 0
这样的事件,我使用者怎么知道机器发生了这种事件呢?
我们不可能直接把机器拆开,看里面的电平变换吧。
所以,在这里就出现了 汇编语言
,而汇编语言的本质也是作为机器语言的助记符
出现的
比如,我们给计算机说,你去给我计算 1 + 2
这个操作,计算机需要进行高低电平的差异输出计算结果
而我们的汇编语言:mov
、add
、sub
…
我们可以一目了然的了解目前计算机的操作状态
计算机的组成图:
我们看一下计算机的计算的整个流程:
这里要说明一下 Java
和 C
语言的区别:
- C语言:直接可以让CPU进行编译
- Java语言:需要让
JVM
翻译,才能让CPU
编译,这也正是Java
跨平台的关键所在
4、量子计算机
对于量子计算机而言,目前世界上都在进行探索,暂无成果
在我们普通的计算机中,一个比特代表 1
或者 0
,32个比特,可以代表 2^32
的任何一个数字
而我们的量子比特,最亮眼地方在于,他可以同时表示1
和0
- 一位量子比特:1、0
- 二位量子比特:00、01、10、11
- 三位量子比特:000、001、011、111…
- 三十二位量子比特:一次性表示
2 ^ 32
的数字
这样描述可能不太直观,我们看一个例子:
现在有一个数字,我们知道该数字范围为: 1~2^32
,我们怎么能快速求出该数字呢?
对于普通比特而言,一次只能表示一个,所以我们需要循环遍历 2^32
次,才可以找到该数字
而对于量子比特而言,直接使用 32
位的操作系统即可完成
5、CPU的基本组成
- PC(Programme Counter):程序技术器当前指令的地址
- Registers:寄存器,暂时存储CPU计算需要的数据
- ALU(Arithmetic & logic Unit):运算单元,做运算使用
- CU(Control Unit):控制单元
- MMU(memory Mangagement unit):内存处理单元
- Cache:缓存
5.1 ALU
之前的CPU属于单核情况,这样的话,会只有一个 Registers
,我们的 PC
会不断的进行切换来指向新的线程,将所对应的数据存放到 Registers
,对于切换(context switch)而言,会严重影响我们的效率。
现在的CPU通常是多核状态,在进行计算时,我们会有两个以上 Registers
,这样的话,我们的PC就不需要频繁的进行切换,我们的 ALU
处理计算的切换即可。
5.2 寄存器
5.3 Cache
我们通过上面的图可以看出,我们的计算机为了获取数据的方便性,增加了三级缓存,对于不同的缓存,获取的时间的长短也是不一样的
对于多核CPU来说,如下图所示:
- L1、L2存储在不同的核中
- L3存储在同一个
CPU
中
5.3.1 局部性原理
简单来说,我们的CPU在读取数据时,将数据按快读取,不单独取一个字节,如下图所示:
当前的CPU需要 X
这个目标值,步骤如下:
- 第一步:去寄存器里寻找,有没有
X
这个字段 - 第二步:去
L1、L2、L3
Cache 去寻找X
这个字段 - 第三步:去内存、磁盘等寻找
X
这个字段 - 第四步:找到后,
将以 X 开头的 64个字节
形成一个块 - 第五步:在
L3、L2、L1
中分别存入这个数据,方便下次去拿缓存
- 第六步:将
X
写入寄存器,进行数据处理、
5.3.2 MESI Cache一致性协议
我们可以看到,对于上述两个核的 L1、L2
的缓存行要保持一致,保持一致的协议被称为:MESI 缓存一致性协议
CPU
每个 Cache line
标记四种状态
- M(已修改):该
Cache line
有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 - E(独占):该
Cache line
有效,数据和内存中的数据一致,数据只存在于本Cache中。 - S(共享):该
Cache line
有效,数据和内存中的数据一致,数据存在于很多Cache中。 - I(无效):
Cache line
是无效的
因特尔——缓存行
- 缓存行越大,局部性空间效率越高,但读取时间慢
- 缓存行越小,局部性空间效率越低,但读取时间快
- 因特尔通过实验规定,缓存行的大小为:
64 字节
总线锁(缓存行装不下的情况下,就必须锁总线)
缓存锁实现之一,有些无法被缓存的数据或者跨越多个缓存行的数据,依然必须使用总线锁
我们怎么测试我们的猜想是正确的呢?
我们测试两个程序
篇幅受限,源码的话这里暂时不展示了,有兴趣的可以关注公众号,回复:算法源码
- 第一个程序:数值的更改在同一个缓存行
- 第二个程序:数值的更改不在同一个缓存行
- 有上述程序验证我们的猜想,两个线程频繁的更快缓存区中的缓存快,导致运行时间加长
5.3.3 缓存行对齐
对于有些特别敏感的数字,会存在线程高竞争的访问,为了保证不发生伪共享,我们一般不要求缓存行对齐
简单来说,我们不希望我们获取 X
数字的同时,把 Y
也给获取进来
在我们 JDK7
和 disruptor
都采取long cache line padding
public long p1, p2, p3, p4, p5, p6, p7; // cache line padding private volatile long cursor = INITIAL_CURSOR_VALUE; public long p8, p9, p10, p11, p12, p13, p14; //cache line padding
这样的话,当我们缓存行获取时,就会把 cursor
前面的 long
或者 后面的 long
加载到缓存块中,避免 cursor
的缓存行对齐
在我们的 JDK8
中,我们可以给该参数加入 @Contended
(根据底层的CPU来进行设定,保证不会让两个参数共享一个缓存行),需要加上 -XX:-RestrictContended
生效