根据冯·诺依曼思想,计算机采用二进制作为数制基础,必须包含:运算器、控制器、存储设备,以及输入输出设备,如下图所示。
我们先来分析 CPU 的工作原理,现代 CPU 芯片中大都集成了,控制单元,运算单元,存储单元。控制单元是 CPU 的控制中心, CPU 需要通过它才知道下一步做什么,也就是执行什么指令,控制单元又包含:指令寄存器(IR ),指令译码器( ID )和操作控制器( OC )。
当程序被加载进内存后,指令就在内存中了,这个时候说的内存是独立于 CPU 外的主存设备,也就是 PC 机中的内存条,指令指针寄存器IP 指向内存中下一条待执行指令的地址,控制单元根据 IP寄存器的指向,将主存中的指令装载到指令寄存器。
这个指令寄存器也是一个存储设备,不过他集成在 CPU 内部,指令从主存到达 CPU 后只是一串 010101 的二进制串,还需要通过译码器解码,分析出操作码是什么,操作数在哪,之后就是具体的运算单元进行算术运算(加减乘除),逻辑运算(比较,位移)。而 CPU 指令执行过程大致为:取址(去主存获取指令放到寄存器),译码(从主存获取操作数放入高速缓存 L1 ),执行(运算)。
这里解释下上图中 CPU 内部集成的存储单元 SRAM ,正好和主存中的 DRAM 对应, RAM 是随机访问内存,就是给一个地址就能访问到数据,而磁盘这种存储媒介必须顺序访问,而 RAM 又分为动态和静态两种,静态 RAM 由于集成度较低,一般容量小,速度快,而动态 RAM 集成度较高,主要通过给电容充电和放电实现,速度没有静态 RAM 快,所以一般将动态 RAM 做为主存,而静态 RAM 作为 CPU 和主存之间的高速缓存 (cache),用来屏蔽 CPU 和主存速度上的差异,也就是我们经常看到的 L1 , L2 缓存。每一级别缓存速度变低,容量变大。
下图展示了存储器的层次化架构,以及 CPU 访问主存的过程,这里有两个知识点,一个是多级缓存之间为保证数据的一致性,而推出的缓存一致性协议,具体可以参考这篇文章,另外一个知识点是, cache 和主存的映射,首先要明确的是 cahce 缓存的单位是缓存行,对应主存中的一个内存块,并不是一个变量,这个主要是因为 CPU 访问的空间局限性:被访问的某个存储单元,在一个较短时间内,很有可能再次被访问到,以及空间局限性:被访问的某个存储单元,在较短时间内,他的相邻存储单元也会被访问到。
而映射方式有很多种,类似于 cache 行号 = 主存块号 mod cache总行数 ,这样每次获取到一个主存地址,根据这个地址计算出在主存中的块号就可以计算出在 cache 中的行号。
下面我们接着聊 CPU 的指令执行。取址、译码、执行,这是一个指令的执行过程,所有指令都会严格按照这个顺序执行。但是多个指令之间其实是可以并行的,对于单核 CPU 来说,同一时刻只能有一条指令能够占有执行单元运行。这里说的执行是 CPU 指令处理 (取指,译码,执行) 三步骤中的第三步,也就是运算单元的计算任务。
所以为了提升 CPU 的指令处理速度,所以需要保证运算单元在执行前的准备工作都完成,这样运算单元就可以一直处于运算中,而刚刚的串行流程中,取指,解码的时候运算单元是空闲的,而且取指和解码如果没有命中高速缓存还需要从主存取,而主存的速度和 CPU 不在一个级别上,所以指令流水线 可以大大提高 CPU 的处理速度,下图是一个3级流水线的示例图,而现在的奔腾 CPU 都是32级流水线,具体做法就是将上面三个流程拆分的更细。
除了指令流水线, CPU 还有分支预测,乱序执行等优化速度的手段。好了,我们回到正题,一行 Java 代码是怎么执行的?
一行代码能够执行,必须要有可以执行的上下文环境,包括:指令寄存器、数据寄存器、栈空间等内存资源,然后这行代码必须作为一个执行流能够被操作系统的任务调度器识别,并给他分配 CPU 资源,当然这行代码所代表的指令必须是 CPU 可以解码识别的,所以一行 Java 代码必须被解释成对应的 CPU 指令才能执行。下面我们看下System.out.println("Hello world")这行代码的转译过程。
刚刚说到, CPU 只要一上电就像一个永动机, 不停的取指令,运算,周而复始,而中断便是操作系统的灵魂,故名思议,中断就是打断 CPU 的执行过程,转而去做点别的。
例如系统执行期间发生了致命错误,需要结束执行,例如用户程序调用了一个系统调用的方法,例如mmp等,就会通过中断让 CPU 切换上下文,转到内核空间,例如一个等待用户输入的程序正在阻塞,而当用户通过键盘完成输入,内核数据已经准备好后,就会发一个中断信号,唤醒用户程序把数据从内核取走,不然内核可能会数据溢出,当磁盘报了一个致命异常,也会通过中断通知 CPU ,定时器完成时钟滴答也会发时钟中断通知 CPU 。
中断的种类,我们这里就不做细分了,中断有点类似于我们经常说的事件驱动编程,而这个事件通知机制是怎么实现的呢,硬件中断的实现通过一个导线和 CPU 相连来传输中断信号,软件上会有特定的指令,例如执行系统调用创建线程的指令,而 CPU 每执行完一个指令,就会检查中断寄存器中是否有中断,如果有就取出然后执行该中断对应的处理程序。
陷入内核 : 我们在设计软件的时候,会考虑程序上下文切换的频率,频率太高肯定会影响程序执行性能,而陷入内核是针对 CPU 而言的, CPU 的执行从用户态转向内核态,以前是用户程序在使用 CPU ,现在是内核程序在使用 CPU ,这种切换是通过系统调用产生的。
通过上面的例子我们可以证明,linux中每个进程有单独的地址空间,在此之前,我们先了解下 CPU 是如何访问内存的?
假设我们现在还没有虚拟地址,只有物理地址,编译器在编译程序的时候,需要将高级语言转换成机器指令,那么 CPU 访问内存的时候必须指定一个地址,这个地址如果是一个绝对的物理地址,那么程序就必须放在内存中的一个固定的地方,而且这个地址需要在编译的时候就要确认,大家应该想到这样有多坑了吧。
如果我要同时运行两个 office word 程序,那么他们将操作同一块内存,那就乱套了,伟大的计算机前辈设计出,让 CPU 采用 段基址 + 段内偏移地址 的方式访问内存,其中段基地址在程序启动的时候确认,尽管这个段基地址还是绝对的物理地址,但终究可以同时运行多个程序了, CPU 采用这种方式访问内存,就需要段基址寄存器和段内偏移地址寄存器来存储地址,最终将两个地址相加送上地址总线。
//文件预热,OS_PAGE_SIZE = 4kb 相当于每 4kb 就写一个 byte 0 ,将所有的页都加载到内存,真正使用的时候就不会发生缺页异常了
public void warmMappedFile(FlushDiskType type, int pages) {
long beginTime = System.currentTimeMillis();
ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
int flush = 0;
long time = System.currentTimeMillis();
for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {
byteBuffer.put(i, (byte) 0);
// force flush when flush disk type is sync
if (type == FlushDiskType.SYNC_FLUSH) {
if ((i / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE) >= pages) {
flush = i;
mappedByteBuffer.force();
}
}
当然内存对齐另外一个原因是为了让字段只出现在同一个 CPU 的缓存行中,如果字段不对齐,就有可能出现一个字段的一部分在缓存行 1 中,而剩下的一半在 缓存行 2 中,这样该字段的读取需要替换两个缓存行,而字段的写入会导致两个缓存行上缓存的其他数据都无效,这样会影响程序性能。
通过内存对齐可以避免一个字段同时存在两个缓存行里的情况,但还是无法完全规避缓存伪共享的问题,也就是一个缓存行中存了多个变量,而这几个变量在多核 CPU 并行的时候,会导致竞争缓存行的写权限,当其中一个 CPU 写入数据后,这个字段对应的缓存行将失效,导致这个缓存行的其他字段也失效。
在 Disruptor 中,通过填充几个无意义的字段,让对象的大小刚好在 64 字节,一个缓存行的大小为64字节,这样这个缓存行就只会给这一个变量使用,从而避免缓存行伪共享,但是在 jdk7 中,由于无效字段被清除导致该方法失效,只能通过继承父类字段来避免填充字段被优化,而 jdk8 提供了注解@Contended 来标示这个变量或对象将独享一个缓存行,使用这个注解必须在 JVM 启动的时候加上 -XX:-RestrictContended 参数,其实也是用空间换取时间。
[mw_shl_code=java,true]jdk6 --- 32 位系统下
public final static class VolatileLong
{
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6; // 填充字段
}
jdk7 通过继承
public class VolatileLongPadding {
public volatile long p1, p2, p3, p4, p5, p6; // 填充字段
}
public class VolatileLong extends VolatileLongPadding {
public volatile long value = 0L;
}
jdk8 通过注解
@Contended
public class VolatileLong {
public volatile long value = 0L;
}[/mw_shl_code]
NPTL和 Java 的线程模型
按照教科书的定义,进程是资源管理的最小单位,而线程是 CPU 调度执行的最小单位,线程的出现是为了减少进程的上下文切换(线程的上下文切换比进程小很多),以及更好适配多核心 CPU 环境,例如一个进程下多个线程可以分别在不同的 CPU 上执行,而多线程的支持,既可以放在Linux内核实现,也可以在核外实现,如果放在核外,只需要完成运行栈的切换,调度开销小,但是这种方式无法适应多 CPU 环境,底层的进程还是运行在一个 CPU 上,另外由于对用户编程要求高,所以目前主流的操作系统都是在内核支持线程,而在Linux中,线程是一个轻量级进程,只是优化了线程调度的开销。
刚刚说到线程的阻塞是一个系统调用,开销大,所以 JVM 设计了自适应自旋锁,就是当没有获取到锁的时候, CPU 回进入自旋状态等待其他线程释放锁,自旋的时间主要看上次等待多长时间获取的锁,例如上次自旋5毫秒没有获取锁,这次就6毫秒,自旋会导致 CPU 空跑,另一个副作用就是不公平的锁机制,因为该线程自旋获取到锁,而其他正在阻塞的线程还在等待。除了自旋锁, JVM 还通过 CAS 实现了轻量级锁和偏向锁来分别针对多个线程在不同时间访问锁和锁仅会被一个线程使用的情况。后两种锁相当于并没有调用底层的信号量实现(通过信号量来控制线程A释放了锁例如调用了 wait(),而线程B就可以获取锁,这个只有内核才能实现,后面两种由于场景里没有竞争所以也就不需要通过底层信号量控制),只是自己在用户空间维护了锁的持有关系,所以更高效。
关于应用程序中设置多少线程数合适的问题,我们一般的做法是设置 CPU 最大核心数 * 2 ,我们编码的时候可能不确定运行在什么样的硬件环境中,可以通过 Runtime.getRuntime().availableProcessors() 获取 CPU 核心。
但是具体设置多少线程数,主要和线程内运行的任务中的阻塞时间有关系,如果任务中全部是计算密集型,那么只需要设置 CPU 核心数的线程就可以达到 CPU 利用率最高,如果设置的太大,反而因为线程上下文切换影响性能,如果任务中有阻塞操作,而在阻塞的时间就可以让 CPU 去执行其他线程里的任务,我们可以通过 线程数量=内核数量 / (1 - 阻塞率)这个公式去计算最合适的线程数,阻塞率我们可以通过计算任务总的执行时间和阻塞的时间获得。
[mw_shl_code=java,true]
public final void wait(long timeout, int nanos) throws InterruptedException {
if (timeout < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (nanos < 0 || nanos > 999999) {
throw new IllegalArgumentException(
"nanosecond timeout value out of range");
}
if (nanos > 0) {
timeout++;
}
wait(timeout);
}[/mw_shl_code]
这个方法是想提供一个可以支持纳秒级的超时时间,然而只是粗暴的加 1 毫秒。
Thread.sleep(long millisecond) 目前一般通过这种方式释放 CPU ,如果参数为 0 ,表示释放 CPU 给更高优先级的线程,自己从运行状态转换为可运行态等待 CPU 调度,他也提供了一个可以支持纳秒级的方法实现,跟 wait 额区别是它通过 500000 来分隔是否要加 1 毫秒。
[mw_shl_code=java,true]public static void sleep(long millis, int nanos)
throws InterruptedException {
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (nanos < 0 || nanos > 999999) {
throw new IllegalArgumentException(
"nanosecond timeout value out of range");
}
if (nanos >= 500000 || (nanos != 0 && millis == 0)) {
millis++;
}
sleep(millis);
}[/mw_shl_code]
只需要将这些定时任务按照时间做一个排序,越靠前待执行的任务放在前面,第一个任务到了在设置第二个任务相对当前时间的值,毕竟 CPU 同一时刻也只能运行一个任务,关于时间的精度问题,我们无法在软件层面做的完全精准,毕竟 CPU 的调度不完全受用户程序控制,当然更大的依赖是硬件的时钟周期频率,目前 TSC 可以提高更高的精度。