You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
156 lines
16 KiB
156 lines
16 KiB
6 years ago
|
---
|
||
|
Java 与底层操作系统的交互细节
|
||
|
---
|
||
|
|
||
|
#### 目录
|
||
|
|
||
|
1. 前言
|
||
|
2. 结合 CPU 理解一行 Java 代码是怎么执行的
|
||
|
3. 是
|
||
|
|
||
|
#### 前言
|
||
|
|
||
|
本文原文地址:[Java 与底层操作系统的交互细节](https://gitbook.cn/books/5c8a24db3ab17e223768e3da/index.html)
|
||
|
|
||
|
#### 结合 CPU 理解一行 Java 代码是怎么执行的
|
||
|
|
||
|
根据冯·诺依曼思想,计算机采用二进制作为数制基础,必须包含:运算器、控制器、存储设备,以及输入输出设备,如下图所示:
|
||
|
|
||
|
![](https://i.loli.net/2019/06/11/5cff20346dcca67923.png)
|
||
|
|
||
|
我们先来分析 CPU 的工作原理。
|
||
|
|
||
|
现代 CPU 芯片中大都集成了:控制单元、运算单元、存储单元。控制单元是 CPU 的控制中心,CPU 需要通过它才知道下一步做什么,也就是执行什么指令,控制单元又包括:指令寄存器(IR),指令译码器(ID)和操作控制器(OC)。
|
||
|
|
||
|
当程序被加载到内存后,指令就在内存中了,这个时候说的内存是独立于 CPU 外的主存设备,也就是 PC 机中的内存条。指令指针寄存器 IP 指向内存中下一条待执行指令的地址,控制单元根据 IP 寄存器的指向,将主存中的指令装载到指令寄存器。这个指令寄存器也是一个存储设备,不过它集成在 CPU 内部,指令从主存到达 CPU 后只是一串串 010101 的二进制串,还需要通过译码器解码,分析出操作码是什么、操作数在哪。之后就是具体的运算单元进行算数运行(加减乘除)、逻辑运算(比较、位移)。
|
||
|
|
||
|
而 CPU 指令执行过程大致为:取址(去主存获取指令放到寄存器)、译码(从主存获取操作数放入高速缓存 L1)、执行(运算)。
|
||
|
|
||
|
![](https://i.loli.net/2019/06/11/5cff20c0d811176378.png)
|
||
|
|
||
|
这里解释下上图中 CPU 内部集成的存储单元 SRAM,正好和主存中的 DRAM 对应,RAM 是随机访问内存,就是给一个地址就能访问到数据;而磁盘这种存储媒介必需顺序访问。
|
||
|
|
||
|
而 RAM 又分为动态和静态两种:静态 RAM 由于集成度较低,一般容量小、速度快;而动态 RAM 集成度较高,主要通过给电容充电和放电实现,速度没有静态 RAM 快。所以一般将动态 RAM 作为主存,而静态 RAM 作为 CPU 和主存之间的高速缓存(Cache),用来屏蔽 CPU 和 主存速度上的差异,也就是我们经常看到的 L1、L2 缓存。每一级别缓存速度变低,容量变大。
|
||
|
|
||
|
下图展示了存储器的层次化结构,以及 CPU 访问主存的过程。这里有两个知识点,一个是多级缓存之间为保证数据的一致性,而推出的缓存一致性协议,具体可以参考[这篇文章](https://www.cnblogs.com/yanlong300/p/8986041.html);另外一个知识点是,Cache 和主存的映射。
|
||
|
|
||
|
首先要明确的是,Cache 缓存的单位是缓存行,对应主存中的一个内存块,并不是一个变量,这个主要是因为 CPU 访问的空间局限性:被访问的某个存储单元,在一个较短时间内,很有可能再次被访问到。以及空间局限性:被访问的某个存储单元,在较短时间内,他的相邻存储单元也会被访问到。
|
||
|
|
||
|
而映射方式有很多种,类似于 Cache 行号 = 主存快号 mod cache 总行数。这样每次获取到一个主存地址,根据这个地址计算出主存中的块号就可以计算出在 Cache 中的行号。
|
||
|
|
||
|
![](https://i.loli.net/2019/06/11/5cff20e0cb67d24889.png)
|
||
|
|
||
|
下面我们接着聊 CPU 的指令执行。取址、译码、执行,这是一个指令的执行过程,所有指令都会严格按照这个顺序执行,但是多个指令之间其实可以并行的。对于单核 CPU 来说,同一时刻只能有一条指令能够占有执行单元运行 --- 这里说的执行是 CPU 指令处理(取址、译码、执行)三步骤中的第三步,也就是运算单元的计算任务。所以为了提升 CPU 的指令处理速度,需要保证运算单元在执行前的准备工作都完成,这样运算单元就可以一直处于运算中。而刚刚的串行流程中,取址、解码的时候运算单元是空闲的,而且取址和解码如果没有命中高速缓存还需要从主存取,而主存的速度和 CPU 不在一个级别上,所以指令流水线可以大大提高 CPU 的处理速度。
|
||
|
|
||
|
除了指令流水线,CPU 还有分支预测、乱序执行等优化速度的手段。好了,我们回到正题,一行 Java 代码是怎么执行的。
|
||
|
|
||
|
一行代码能够执行,必须要有可以执行的上下文环境,包括指令寄存器、数据寄存器、栈空间等内存资源,然后这行代码必须作为一个执行流能够被操作系统的任务调度器识别,并给他分配 CPU 资源。当然这行代码所代表的指令必须是 CPU 可以解码识别的,所以一行 Java 代码必须被解释成对应的 CPU 指令才能执行。下面我们看下 System.out.println("Hello world") 这行代码的转译过程。
|
||
|
|
||
|
Java 是一门高级语言,这类语言不能直接运行在硬件上,必须运行在能够识别 Java 语言特性的虚拟机上,而 Java 代码必须通过 Java 编译器将其转换成虚拟机所能识别的指令序列,也称为 Java 字节码。之所以称为字节码是因为 Java 字节码的操作指令(OpCode)被固定为一个字节,以下为 System.out.printlf("Hello world") 编译后的字节码:
|
||
|
|
||
|
```java
|
||
|
0x00: b2 00 02 getstatic Java .lang.System.out
|
||
|
0x03: 12 03 ldc "Hello, World!"
|
||
|
0x05: b6 00 04 invokevirtual Java .io.PrintStream.println
|
||
|
0x08: b1 return
|
||
|
```
|
||
|
|
||
|
* 最左列是偏移
|
||
|
* 中间列是给虚拟机读的字节码
|
||
|
* 最右列是高级语言的代码
|
||
|
|
||
|
下面是通过汇编语言转换成的机器指令,中间是机器码,第三列为对应的机器指令,最后一列是对应的汇编代码:
|
||
|
|
||
|
```
|
||
|
0x00: 55 push rbp
|
||
|
0x01: 48 89 e5 mov rbp,rsp
|
||
|
0x04: 48 83 ec 10 sub rsp,0x10
|
||
|
0x08: 48 8d 3d 3b 00 00 00 lea rdi,[rip+0x3b]
|
||
|
; 加载 "Hello, World!\n"
|
||
|
0x0f: c7 45 fc 00 00 00 00 mov DWORD PTR [rbp-0x4],0x0
|
||
|
0x16: b0 00 mov al,0x0
|
||
|
0x18: e8 0d 00 00 00 call 0x12
|
||
|
; 调用 printf 方法
|
||
|
0x1d: 31 c9 xor ecx,ecx
|
||
|
0x1f: 89 45 f8 mov DWORD PTR [rbp-0x8],eax
|
||
|
0x22: 89 c8 mov eax,ecx
|
||
|
0x24: 48 83 c4 10 add rsp,0x10
|
||
|
0x28: 5d pop rbp
|
||
|
0x29: c3 ret
|
||
|
```
|
||
|
|
||
|
JVM 通过类加载器加载 class 文件里的字节码后,会通过解释器解释成汇编指令,最终再转译成 CPU 可以识别的机器指令。解释器是软件来实现的,主要是为了实现同一份 Java 字节码可以在不同的硬件平台上运行,而将汇编指令转换成机器指令由硬件直接实现。这一步速度是很快的,当然 JVM 为了提高运行效率也可以将某些热点代码(一个方法内的代码)一次全部编译成机器指令后然后再执行,也就是和解释执行对应的即时编译(JIT),JVM 启动的时候可以通过 -Xint 和 -Xcomp 来控制执行模式。
|
||
|
|
||
|
从软件层面上,class 文件被加载进虚拟机后,类信息会存放在方法区。在实际运行的时候会执行方法区中的代码,在 JVM 中所有的线程共享堆内存和方法区。而每个线程有自己独立的 Java 方法栈、本地方法栈(面向 Native 方法)、PC 寄存器(存放线程执行位置),当调用一个方法的时候,Java 虚拟机会在当前线程对应的方法栈中压入一个栈帧,用来存放 Java 字节码操作数以及局部变量。这个方法执行完会弹出栈帧,一个线程会连续执行多个方法,对应不同的栈帧的压入和弹出,压入栈帧后就是 JVM 解释执行的过程了。
|
||
|
|
||
|
![](https://i.loli.net/2019/06/11/5cff46c6ad40318762.png)
|
||
|
|
||
|
##### 中断
|
||
|
|
||
|
刚刚说到,CPU 只要一上电就像一个永动机,不停地取指令、运算,周而复始。而中断便是操作系统的灵魂。
|
||
|
|
||
|
顾名思义,中断就是打断 CPU 的执行过程,转而去做点别的。例如系统执行期间发生了致命错误,需要结束执行;例如用户程序调用了一个系统调用的方法,如 mmap 等,就会通过中断让 CPU 切换上下文,转到内核空间;例如一个等待用户输入的程序正在阻塞,而当用户通过键盘完成输入,内核数据已经准备好后,就会发一个中断信号,唤醒用户程序把数据从内核取走,不然内核可能会数据溢出;当磁盘报了一个致命异常,也会通过中断通知 CPU,定时器完成时钟滴答也会发时钟中断通知 CPU。
|
||
|
|
||
|
中断的种类,我们这里就不做细分了,中断有点类似于我们经常说的事件驱动编程。而这个事件通知机制是怎么实现的呢?
|
||
|
|
||
|
硬件中断的实现通过一个导线和 CPU 相连来传输中断信号,软件上会有特定的指令,例如执行系统调用创建线程的指令,而 CPU 每执行完一个指令,就会检查中断寄存器中是否有中断,如果有就取出然后执行该中断对应的处理程序。
|
||
|
|
||
|
##### 陷入内核
|
||
|
|
||
|
我们在设计软件的时候,会考虑程序上下文切换的频率,频率太高肯定会影响程序执行性能,而陷入内核是针对 CPU 而言的,CPU 的执行从用户态转向内核态。以前是用户程序在使用 CPU,现在是内核程序在使用 CPU,这种切换是通过系统调用产生的。
|
||
|
|
||
|
系统调用是执行操作系统底层的程序。Linux 的设计者,为了保护操作系统,将进程的执行状态用内核态和用户态分开。同一个进程中,内核和用户共享同一个地址空间。一般 4G 的虚拟地址,其中 1G 给内核态,3G 给用户态。在程序设计的时候我们要尽量减少用户态到内核态的切换,例如创建线程是一个系统调用,所以我们有了线程池的实现。
|
||
|
|
||
|
#### 从 Linux 内存管理角度理解 JVM 内存模型
|
||
|
|
||
|
##### 进程上下文
|
||
|
|
||
|
我们可以将程序理解为一段可执行的指令集合,而这个程序启动后,操作系统就会为他分配 CPU、内存等资源,而这个正在运行的程序就是我们说的进程。进程是操作系统对处理器中运行的程序的一种抽象,而为进程分配的内存及 CPU 资源就是这个进程的上下文,保存了当前执行的指令以及变量值。而 JVM 启动后也是 Linux 上的一个普通进程,进程的物理实体和支持进程运行的环境合称为上下文。
|
||
|
|
||
|
上下文切换就是将当前正在运行的进程换下,换一个新的进程到处理器运行,以此来让多个进程并发的执行。上下文切换可能来自操作系统调度,也有可能来自程序内部。例如读取 IO 的时候,会让用户代码和操作系统之间进行切换。
|
||
|
|
||
|
![](https://i.loli.net/2019/06/11/5cff4ffda001097346.png)
|
||
|
|
||
|
##### 虚拟存储
|
||
|
|
||
|
当我们同时启动多个 JVM 执行 System.out.println(new Object());,将会打印这个对象的 hashCode,hashCode 默认为内存地址,最后发现他们打印的都是 Java.lang.Object@4fca772d,也就是多个进程返回的内存地址竟然是一样的。
|
||
|
|
||
|
通过上面的例子我们可以证明,Linux 中每个进程都有单独的地址空间,在此之前,我们先了解下 **CPU 是如何访问内存**的。
|
||
|
|
||
|
假设我们现在还没有虚拟地址,只有物理地址,编译器在编译程序的时候,需要将高级语言转换成机器指令。那么 CPU 访问内存的时候必须指定一个地址,这个地址如果是一个绝对的物理地址,那么程序就必须放在内存中的一个固定的地方,而且这个地址需要在编译的时候就要确认 --- 大家应该想到这样有多坑了吧。
|
||
|
|
||
|
如果我要同时运行两个 Office Word 程序,那么它们将操作同一块内存。那就乱套了。伟大的计算机前辈设计出,让 CPU 采用**段基址 + 段内偏移地址**的方式访问内存。
|
||
|
|
||
|
其中段基地址在程序启动的时候确认,尽管这个段基地址还是绝对的物理地址,但终究可以同时运行多个程序了,CPU 采用这种方式访问内存,就需要段基址寄存器和段内偏移地址寄存器来存储地址,最终将两个地址相加送上地址总线。
|
||
|
|
||
|
而内存分段,相当于每个进程都会分配一个内存段,而且这个内存段需要是一块连续的空间,主存里维护着多个内存段。当某个进程需要更多内存,并且超出物理内存的时候,就需要将某个不常用的内存段换到硬盘上,等有充足内存的时候在从硬盘加载进来,也就是 **swap**。每次交换都需要操作整个段的数据。
|
||
|
|
||
|
首先连续的地址空间是很宝贵的,例如一个 50M 的内存,在内存段之间有空隙的情况下,将无法支持 5 个需要 10M 内存才能运行的程序,如何才能让段内地址不连续呢?答案是**内存分页**。
|
||
|
|
||
|
在保护模式下,每一个进程都有自己独立的地址空间,所以段基地址是固定的,只需要给出段内偏移地址就可以了。而这个偏移地址称为线性地址,线性地址是连续的,而内存分页将连续的线性地址和和分页后的物理地址相关联,这样逻辑上的连续线性地址可以对应不连续的物理地址。
|
||
|
|
||
|
物理地址空间可以被多个进程共享,而这个映射关系将通过页表(PageTable)进行维护。标准页的尺寸一般为 4KB,分页后,物理内存被分为若干个 4KB 的数据页,进程申请内存的时候,可以映射为多个 4KB 大小的物理内存,而应用程序读取数据的时候会以页为最小单位,当需要和硬盘发生交换的时候也是以页为单位。
|
||
|
|
||
|
现代计算机多采用虚拟存储技术,虚拟存储让每个进程以为自己独占整个内存空间,其实这个虚拟空间是主存和磁盘的抽象。这样的好处是,每个进程拥有一致的虚拟地址空间,简化了内存管理,进程不需要和其他进程竞争内存空间,因为它是独占的,也保护了各自进程不被其他进程破坏;另外,它把主存看成磁盘的一个缓存,主存中仅保存活动的程序段和数据段,当主存中不存在数据的时候发生缺页中断,然后从磁盘加载进来,当物理内存不足的时候会发生 swap 到磁盘。
|
||
|
|
||
|
页表保存了虚拟地址和物理地址的映射,页面是一个数组,每个元素为一个页的映射关系,这个映射关系可能是和主存地址,也可能和磁盘、页面存储在主存,我们将存储在高速缓冲区 Cache 中的页面称为快表 TLAB。
|
||
|
|
||
|
![](https://i.loli.net/2019/06/11/5cff605eaa67f59664.png)
|
||
|
|
||
|
* 装入位:表示对于页是否在主存,如果地址页每页表示,数据还在磁盘
|
||
|
* 存放位置:建立虚拟页和物理页的映射,用于地址转换,如果为 null 表示是一个未分配页
|
||
|
* 修改位:用来存储数据是否修改过
|
||
|
* 权限位:用来控制是否有读写权限
|
||
|
* 禁止缓存位:主要用来保证 Cache 主存、磁盘的数据一致性
|
||
|
|
||
|
##### 内存映射
|
||
|
|
||
|
正常情况下,我们读取文件的流程为,先通过系统调用从磁盘读取数据,存入操作系统的内核缓冲区,然后在从内核缓冲区拷贝到用户空间。而内存映射,是将磁盘文件直接映射到用户的虚拟存储空间中,通过页表维护虚拟地址到磁盘的映射。
|
||
|
|
||
|
通过内存映射的方式读取文件的好处有很多,因为减少了从内核缓冲区到用户空间的拷贝,直接从磁盘读取数据到内存,减少了系统调用的开销。对用户而言,仿佛直接操作磁盘上的文件,另外由于使用了虚拟存储,所以不需要连续的主存空间来存储数据。
|
||
|
|
||
|
![](https://i.loli.net/2019/06/11/5cff656d9a7dc33822.png)
|
||
|
|
||
|
在 Java 中,我们使用 MappedByteBuffer 来实现内存映射。这是一个堆外内存,在映射完之后,并没有立即占有物理内存,而是访问数据页的时候,先查页表,发现还没加载,发起缺页异常,然后在从磁盘将数据加载进内存。
|
||
|
|