系统基础知识

首先我们得知道系统运行程序是依靠CPU的,我们先来分析CPU的工作原理。
现代 CPU 芯片中大都集成了:控制单元、运算单元、存储单元。控制单元是 CPU 的控制中心, CPU 需要通过它才知道下一步做什么,也就是执行什么指令,控制单元又包含:指令寄存器(IR ),指令译码器( ID )和操作控制器( OC )。
当程序被加载进内存后,指令就在内存中了,这个时候说的内存是独立于 CPU 外的主存设备,也就是 PC 机中的内存条。指令指针寄存器 IP 指向内存中下一条待执行指令的地址,控制单元根据 IP 寄存器的指向,将主存中的指令装载到指令寄存器。这个指令寄存器也是一个存储设备,不过它集成在 CPU 内部,指令从主存到达 CPU 后只是一串 010101 的二进制串,还需要通过译码器解码,分析出 操作码是什么、操作数在哪。之后就是具体的运算单元进行算术运算(加减乘除)、逻辑运算(比较、位移),这样CPU通过不断地取读指令并计算来运行程序的。
也就是说,一个Java代码要运行,最终肯定是要变成指令代码存到内存里给CPU使用的。所以关键的一步就是把Java代码转成指令代码。

编译转码

有一定Java编程基础的童鞋应该都知道,Java文件经过编译成class文件才能运行起来的。
而拥有更高编程水平的童鞋还知道了Java虚拟机通过类加载器加载 class 文件里的字节码后(后面两步是在运行时才会去做的),会通过解释器解释成汇编指令,最终再转译成 CPU可以识别的机器指令。
以下为 System.out.println(“Hello world”) 编译后的字节码:

  1. 0x00: b2 00 02 getstatic Java .lang.System.out
  2. 0x03: 12 03 ldc "Hello, World!"
  3. 0x05: b6 00 04 invokevirtual Java .io.PrintStream.println
  4. 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

加载的内存关系

要正常运行程序,是不是得先把相关的信息加载到Linux的内存里(注意的是,加载就是在Java程序的运行过程中的,但这里拆分出来说)。
首先阅读的童鞋得清楚Java程序的生命周期都是由JVM去管理的,所以Java程序也叫JVM程序,而程序的内存如何分配的当然是由JVM决定,所以我们核心就是了解JVM与Linux的内存联系也就能知道加载的内存关系了。
因为JVM程序运行起来就是一个进程,了解Linux与进程的内存关系,是理解JVM程序与Linux内存的关系的基础。
下图给出了硬件、系统、进程三个层面的内存之间的概要关系:
image.png
从硬件上看,Linux系统的内存空间由两个部分构成:物理内存和SWAP(位于磁盘)。物理内存是Linux活动时使用的主要内存区域;当物理内存不够使用时,Linux会把一部分临时不用的内存数据放到磁盘上的SWAP中去,以便腾出很多其它的可用内存空间;而当须要使用位于SWAP的数据时,必须先将其换回到内存中。
从Linux系统上看,除了引导系统的BIN区,整个内存空间主要被分成两个部分:内核内存(Kernel space)、用户内存(User space)。
内核内存是Linux自身使用的内存空间,主要提供给程序调度、内存分配、连接硬件资源等程序逻辑使用。用户内存是提供给各个进程主要空间,Linux给各个进程提供同样的虚拟内存空间;这使得进程之间相互独立,互不干扰。实现的方法是採用虚拟内存技术:给每个进程一定虚拟内存空间,而仅仅有当虚拟内存实际被使用时,才分配物理内存。例如以下图所看到的,对于32的Linux系统来说,一般将0~3G的虚拟内存空间分配做为用户空间,将3~4G的虚拟内存空间分配为内核空间;64位系统的划分情况是类似的。
从进程的角度来看,进程能直接访问的用户内存(虚拟内存空间)被划分为5个部分:

  • 代码区:存放应用的机器代码,运行过程中不能被修改。具有只读和固定大小的特点。
  • 数据区:存放了应用程序中的全局数据,静态数据和一些常亮字符串,其大小也是固定的。
  • 堆:是运行时程序动态申请的空间,属于程序运行时直接申请,释放的内存资源。
  • 栈:区用来存放函数的传入参数,临时变量,以及返回地址等数据。
  • 未使用区:是分配新内存空间的预备区域。

    JVM程序其实也是一个进程,因此内存空间和普通进程也基本一致,但是Java程序将许多本来属于操作系统管理范畴的东西移植到了JVM程序自己内部去管理,目的在于减少系统调用的次数,JVM程序与普通进程内存模型比较如下图所示(注意的是):
    image.png

JVM程序自己管理内存的方式的长处是显而易见的:

  1. 降低系统调用的次数,JVM程序分配内存空间时不须要操作系统干预,只在Java堆大小变化时须要向操作系统申请内存或通知回收。
  2. 降低内存泄漏。普通程序没有(或者没有及时)通知操作系统内存空间的释放是内存泄漏的重要原因之中的一个,而由JVM程序统一管理,能够避免程序猿带来的内存泄漏问题。

方法的执行过程

那现在加载完程序的信息了,就代表着程序已经跟普通进程一样跑起来了,如果此刻我们执行了JVM程序的一个方法,执行过程会是怎样的呢?
执行的时候JVM程序读取是方法对应的ava字节码,然后再由解释器来即时转换成指令,也有部分虚拟机是携带类似即时编译器JIT的,把一些“热点代码”编译成机器码保存起来加快速度,最终与CPU进行交互起来。(这里忽略了JVM程序自身的一些处理,比如分配对象入栈出栈啥的,有兴趣可自行查阅JVM相关知识)

其他关系

上文我们已经知道Java程序大概是怎样在Linux上跑的了,所以下面是分享一些存在的关系。

用户态与内核态

image.png
从宏观上来看,Linux操作系统的体系架构分为用户态和内核态:

  • 内核态从本质上看是一种软件——控制计算机的硬件资源,并提供上层应用程序运行的环境。
  • 用户态即上层应用程序的活动空间,应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。

为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口,即系统调用,它是操作系统的最小功能单位,这些系统调用根据不同的应用场景可以进行扩展和裁剪,我们可以把系统调用看成是一种不能再化简的操作(类似于原子操作,但是不同概念),有人把它比作一个汉字的一个“笔画”,而一个“汉字”就代表一个上层应用,我觉得这个比喻非常贴切。因此,有时候如果要实现一个完整的汉字(给某个变量分配内存空间),就必须调用很多的系统调用。如果从实现者(程序员)的角度来看,这势必会加重程序员的负担,良好的程序设计方法是:重视上层的业务逻辑操作,而尽可能避免底层复杂的实现细节。

库函数正是为了将程序员从复杂的细节中解脱出来而提出的一种有效方法。它实现对系统调用的封装,将简单的业务逻辑接口呈现给用户,方便用户调用,从这个角度上看,库函数就像是组成汉字的“偏旁”。这样的一种组成方式极大增强了程序设计的灵活性,对于简单的操作,我们可以直接调用系统调用来访问资源,如“人”,对于复杂操作,我们借助于库函数来实现,如“仁”。显然,这样的库函数依据不同的标准也可以有不同的实现版本,如ISO C 标准库,POSIX标准库等。
Shell是一个特殊的应用程序,俗称命令行,本质上是一个命令解释器,它下通系统调用,上通各种应用,通常充当着一种“胶水”的角色,来连接各个小功能程序,让不同程序能够以一个清晰的接口协同工作,从而增强各个程序的功能。同时,Shell是可编程的,它可以执行符合Shell语法的文本,这样的文本称为Shell脚本,通常短短的几行Shell脚本就可以实现一个非常大的功能,原因就是这些Shell语句通常都对系统调用做了一层封装。为了方便用户和系统交互,一般,一个Shell对应一个终端,终端是一个硬件设备,呈现给用户的是一个图形化窗口。我们可以通过这个窗口输入或者输出文本。这个文本直接传递给shell进行分析解释,然后执行。

因为操作系统的资源是有限的,如果访问资源的操作过多,必然会消耗过多的资源,而且如果不对这些操作加以区分,很可能造成资源访问的冲突。所以,为了减少有限资源的访问和使用冲突,Unix/Linux的设计哲学之一就是:对不同的操作赋予不同的执行等级,就是所谓特权的概念。Intel的X86架构的CPU提供了0到3四个特权级,数字越小,特权越高,Linux操作系统中主要采用了0和3两个特权级,分别对应的就是内核态和用户态。运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,而运行在内核态的进程则可以执行任何操作并且在资源的使用上没有限制。很多程序开始时运行于用户态,但在执行的过程中,一些操作需要在内核权限下才能执行,这就涉及到一个从用户态切换到内核态的过程。比如C函数库中的内存分配函数malloc(),它具体是使用sbrk()系统调用来分配内存,当malloc调用sbrk()的时候就涉及一次从用户态到内核态的切换,类似的函数还有printf(),调用的是wirte()系统调用来输出字符串,等等。

当程序中有系统调用语句,程序执行到系统调用时,首先使用类似int 80H的软中断指令,保存现场,去的系统调用号,在内核态执行,然后恢复现场,每个进程都会有两个栈,一个内核态栈和一个用户态栈。当执行int中断执行时就会由用户态,栈转向内核栈。系统调用时需要进行栈的切换。而且内核代码对用户不信任,需要进行额外的检查。系统调用的返回过程有很多额外工作,比如检查是否需要调度等。
系统调用一般都需要保存用户程序得上下文(context), 在进入内核得时候需要保存用户态得寄存器,在内核态返回用户态得时候会恢复这些寄存器得内容。这是一个开销的地方。 如果需要在不同用户程序间切换的话,那么还要更新cr3寄存器,这样会更换每个程序的虚拟内存到物理内存映射表的地址,也是一个比较高负担的操作。

所以JVM程序在代码实现层面上,能不进行用户态与内核态的转换就不进行。

线程

在 Linux 中,线程是一个轻量级进程,只是优化了线程调度的开销。而在 JVM 程序中的线程和内核线程是一一对应的,线程的调度完全交给了内核,当调用 Thread.run 的时候,就会通过系统调用 fork() 创建一个内核线程,这个方法会在用户态和内核态之间进行切换,性能没有在用户态实现线程高,当然由于直接使用内核线程,所以能够创建的最大线程数也受内核控制。目前 Linux上 的线程模型为 NPTL( Native POSIX Thread Library),它使用一对一模式,兼容 POSIX 标准,没有使用管理线程,可以更好地在多核 CPU 上运行。
因为创建一个线程需要切换到内核态,开销是很大的,所以Java设计了线程池等操作来复用线程。

零拷贝

阅读以下需要一点Linux的基础函数知识。
首先我们来看看传统的read/write方式进行socket的传输。

  1. 系统调用read导致了从用户空间到内核空间的上下文切换。DMA模块从磁盘中读取文件内容,并将其存储在内核空间的缓冲区内,完成了第1次复制。
  2. 数据从内核空间缓冲区复制到用户空间缓冲区,之后系统调用read返回,这导致了从内核空间向用户空间的上下文切换。此时,需要的数据已存放在指定的用户空间缓冲区内(参数tmp_buf),程序可以继续下面的操作。
  3. 系统调用write导致从用户空间到内核空间的上下文切换。数据从用户空间缓冲区被再次复制到内核空间缓冲区,完成了第3次复制。不过,这次数据存放在内核空间中与使用的socket相关的特定缓冲区中,而不是步骤一中的缓冲区。
  4. 系统调用返回,导致了第4次上下文切换。第4次复制在DMA模块将数据从内核空间缓冲区传递至协议引擎的时候发生,这与我们的代码的执行是独立且异步发生的。你可能会疑惑:“为何要说是独立、异步?难道不是在write系统调用返回前数据已经被传送了?write系统调用的返回,并不意味着传输成功——它甚至无法保证传输的开始。调用的返回,只是表明以太网驱动程序在其传输队列中有空位,并已经接受我们的数据用于传输。可能有众多的数据排在我们的数据之前。除非驱动程序或硬件采用优先级队列的方法,各组数据是依照FIFO的次序被传输的。

在这个过程中很明显也是有用户态与内核态的切换了(先忽略数据重复拷贝多次带来的开销)。因此Linxu提供了一些方式,用来降低这种情况带来的开销,比如高级函数sendfile(其他方式如mmap这里不说)。
sendfile系统调用在两个文件描述符之间直接传递数据(完全在内核中操作),从而避免了数据在内核缓冲区和用户缓冲区之间的拷贝,操作效率很高,被称之为零拷贝。其具体的使用过程如下:

  1. sendfile系统调用利用DMA引擎将文件数据拷贝到内核缓冲区,之后数据被拷贝到内核socket缓冲区中。
  2. DMA引擎将数据从内核socket缓冲区拷贝到协议引擎中。

相应的Java的FileChannel通道对象的transferTo方法可以用于将文件直接传输给target变量所指的可写通道中,通常我们可以使用此方法完成对sendfile函数的调用,当然这个调用是通过JNI 来调用的,我们可以将该方法与SocketChannel一起使用来避免数据先从磁盘传输到用户空间,然后再写回内核,最后放入socket的缓冲区增加性能。