一、沉默王二-并发编程
1、Java内存模型(JMM)
- Java 内存模型(JMM)定义了 Java 程序中的变量、线程如何和主存以及工作内存进行交互的规则。它主要涉及到多线程环境下的共享变量可见性、指令重排等问题,是理解并发编程中的关键概念。
- Java 内存模型(JMM)主要针对的是多线程环境下,如何在主内存与工作内存之间安全地执行操作。
- Java 运行时内存区域描述的是在 JVM 运行时,如何将内存划分为不同的区域,并且每个区域的功能和工作机制。主要包括方法区、堆、栈、本地方法栈、程序计数器。
- 指令重排是为了提高 CPU 性能,但是可能会导致一些问题,比如多线程环境下的内存可见性问题。
- happens-before 规则是 JMM 提供的强大的内存可见性保证,只要遵循 happens-before 规则,那么我们写的程序就能保证在 JMM 中具有强的内存可见性。
Java 内存模型(JMM)和 Java 运行时内存区域的区别可大着呢。
Java 内存模型(Java Memory Model,JMM)定义了 Java 程序中的变量、线程如何和主存以及工作内存进行交互的规则。它主要涉及到多线程环境下的共享变量可见性、指令重排等问题,是理解并发编程中的关键概念。
并发编程的线程之间存在两个问题:
- 线程间如何通信?即:线程之间以何种机制来交换信息
- 线程间如何同步?即:线程以何种机制来控制不同线程间发生的相对顺序
有两种并发模型可以解决这两个问题:
- 消息传递并发模型
- 共享内存并发模型
这两种模型之间的区别如下图所示:
两种并发模型的比较
Java 使用的是共享内存并发模型。
1)Java内存模型
1.什么是共享变量?
先来看一下运行时数据区,相信大家一点都不陌生:
对于每一个线程来说,栈都是私有的,而堆是共有的。
也就是说,在栈中的变量(局部变量、方法定义的参数、异常处理的参数)不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。而在堆中的变量是共享的,一般称之为共享变量。
所以,内存可见性针对的是堆中的共享变量。
2.内存可见性问题是如何发生的?
那可能就有小伙伴会问:既然堆是共享的,为什么在堆中会有内存不可见问题?
这是因为现代计算机为了高效,往往会在高速缓存区中缓存共享变量,因为 CPU 访问缓存区比访问内存要快得多。
线程之间的共享变量存在于主存中,每个线程都有一个私有的本地内存,存储了该线程的读、写共享变量的副本。本地内存是 Java 内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。
Java 线程之间的通信由 Java 内存模型(简称 JMM)控制,从抽象的角度来说,JMM 定义了线程和主存之间的抽象关系。JMM 的抽象示意图如图所示:
JMM抽象示意图
从图中可以看出:
-
所有的共享变量都存在主存中。
-
每个线程都保存了一份该线程使用到的共享变量的副本。
-
如果线程 A 与线程 B 之间要通信的话,必须经历下面 2 个步骤:
- 线程 A 将本地内存 A 中更新过的共享变量刷新到主存中去。
- 线程 B 到主存中去读取线程 A 之前已经更新过的共享变量。
所以,线程 A 无法直接访问线程 B 的工作内存,线程间通信必须经过主存。
注意,根据 JMM 的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主存中读取。
- 主内存:Java堆中对象实例数据部分,对应于物理硬件的内存
- 工作内存:Java栈中的部分区域,优先存储于寄存器和高速缓存
所以线程 B 并不是直接去主存中读取共享变量的值,而是先在本地内存 B 中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存 B 去主存中读取这个共享变量的新值,并拷贝到本地内存 B 中,最后线程 B 再读取本地内存 B 中的新值。
3.如何保证内存可见性?
那么怎么知道这个共享变量的被其他线程更新了呢?这就是 JMM 的功劳了,也是 JMM 存在的必要性之一。JMM 通过控制主存与每个线程的本地内存之间的交互,来提供内存可见性保证。
Java 中的 volatile 关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序,synchronized 关键字不仅保证可见性,同时也保证了原子性(互斥性)。
在更底层,JMM 通过内存屏障来实现内存的可见性以及禁止重排序。为了程序员更方便地理解,设计者提出了 happens-before 的概念,它更加简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则,以及这些规则的具体实现方法。
4.JMM 与 Java 运行时内存区域的区别
前面提到了 JMM 和 Java 运行时内存区域的划分,这两者既有差别又有联系:
-
区别
两者是不同的概念。JMM 是抽象的,他是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开。
而 Java 运行时内存的划分是具体的,是 JVM 运行 Java 程序时必要的内存划分。
-
联系
都存在私有数据区域和共享数据区域。一般来说,JMM 中的主存属于共享数据区域,包含了堆和方法区;同样,JMM 中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。
总结一下:
-
Java 运行时内存区域描述的是在 JVM 运行时,如何将内存划分为不同的区域,并且每个区域的功能和工作机制。主要包括以下几个部分:
- 方法区:存储了每一个类的结构信息,如运行时常量池、字段和方法数据、构造方法和普通方法的字节码内容。
- 堆:几乎所有的对象实例以及数组都在这里分配内存。这是 Java 内存管理的主要区域。
- 栈:每一个线程有一个私有的栈,每一次方法调用都会创建一个新的栈帧,用于存储局部变量、操作数栈、动态链接、方法出口等信息。所有的栈帧都是在方法调用和方法执行完成之后创建和销毁的。
- 本地方法栈:与栈类似,不过本地方法栈为 JVM 使用到的 native 方法服务。
- 程序计数器:每个线程都有一个独立的程序计数器,用于指示当前线程执行到了字节码的哪一行。
-
Java 内存模型 (JMM) 主要针对的是多线程环境下,如何在主内存与工作内存之间安全地执行操作。
它涵盖的主题包括变量的可见性、指令重排、原子操作等,旨在解决由于多线程并发编程带来的一些问题。
- 可见性:当一个线程修改了共享变量的值,这个新值对于其他线程来说可以立即知道。
- 原子性:一个或多个操作在整个过程中,不会被其他的线程或者操作所打断,这些操作是一个整体,要么都执行,要么都不执行。
- 有序性:程序执行的顺序按照代码的先后顺序执行的。
2)JMM 与重排序
前面提到了,JMM 定义了多线程之间如何互相交互的规则,主要目的是为了解决由于编译器优化、处理器优化和缓存系统等导致的可见性、原子性和有序性。
那我们接下来就来聊聊重排序以及它所带来的顺序问题。
1.为什么指令重排可以提高性能?
大家都知道,计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
那可能有小伙伴就要问:为什么指令重排序可以提高性能?
简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令 1 还没有执行完,就可以开始执行指令 2,而不用等到指令 1 执行结束后再执行指令 2,这样就大大提高了效率。
但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。
我们分析一下下面这段代码的执行情况:
a = b + c;
d = e - f ;
先加载 b、c(注意,有可能先加载 b,也有可能先加载 c),但是在执行 add(b,c) 的时候,需要等待 b、c 装载结束才能继续执行,也就是需要增加停顿,那么后面的指令(加载 e 和 f)也会有停顿,这就降低了计算机的执行效率。
为了减少停顿,我们可以在加载完 b 和 c 后把 e 和 f 也加载了,然后再去执行 add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。
换句话说,既然 add(b,c) 需要停顿,那还不如去做一些有意义的事情(加载 e 和 f)。
综上所述,指令重排对于提高 CPU 性能十分必要,但也带来了乱序的问题。
2.重排序有哪几种?
指令重排一般分为以下三种:
- 编译器优化重排,编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
- 指令并行重排,现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
- 内存系统重排,由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。
3)JMM 与顺序一致性模型
当程序未正确同步的时候,就可能存在数据竞争。
数据竞争:在一个线程中写一个变量,在另一个线程读同一个变量,并且写和读没有通过同步来排序。
如果程序中包含了数据竞争,那么运行的结果往往充满了不确定性,比如读发生在了写之前,可能就会读到错误的值;如果一个线程能够正确同步,那么就不存在数据竞争。
Java 内存模型(JMM)对于正确同步多线程程序的内存一致性做了以下保证:如果程序是正确同步的,程序的执行将具有顺序一致性。即程序的执行结果和该程序在顺序一致性模型中执行的结果相同。
这里的同步包括使用 volatile、final、synchronized 等关键字实现的同步。
如果我们开发者没有正确使用volatile、final、synchronized 等关键字,那么即便是使用了同步,JMM 也不会有内存可见性的保证,很可能会导致程序出错,并且不可重现,很难排查。
1.什么是顺序一致性模型?
顺序一致性模型是一个理想化的理论参考模型,它为程序提供了极强的内存可见性保证。顺序一致性模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序(即 Java 代码的顺序)来执行。
- 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。即在顺序一致性模型中,每个操作必须是原子性的,且立刻对所有线程可见。
为了理解这两个特性,我们举个例子,假设有两个线程 A 和 B 并发执行,线程 A 有 3 个操作,他们在程序中的顺序是 A1->A2->A3,线程 B 也有 3 个操作,B1->B2->B3。
假设正确使用了同步,A 线程的 3 个操作执行后释放锁,B 线程获取同一个锁。那么在顺序一致性模型中的执行效果如下所示:
操作的执行整体上有序,并且两个线程都只能看到这个执行顺序。
2.JMM 为什么不保证顺序一致性?
假设没有使用同步,那么在顺序一致性模型中的执行效果如下所示:
操作的执行整体上无序,但是两个线程都只能看到这个执行顺序。之所以可以得到这个保证,是因为顺序一致性模型中的每个操作必须立即对任意线程可见。
但是 JMM 没有这样的保证。
比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,这个写操作根本没有被当前线程所执行。
只有当前线程把本地内存中写过的数据刷新到主存之后,这个写操作才对其他线程可见。在这种情况下,当前线程和其他线程看到的执行顺序是不一样的。
在顺序一致性模型中,所有操作完全按照程序的顺序串行执行。但是 JMM 中,临界区内(同步块或同步方法中)的代码可以发生重排序(但不允许临界区内的代码“逃逸”到临界区之外,因为会破坏锁的内存语义)。
虽然线程 A 在临界区做了重排序,但是因为锁的特性,线程 B 无法观察到线程 A 在临界区的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
同时,JMM 会在退出临界区和进入临界区做特殊的处理,使得在临界区内程序获得与顺序一致性模型相同的内存视图。
由此可见,JMM 的具体实现方针是:在不改变(正确同步的)程序执行结果的前提下,尽量为编译期和处理器的优化打开方便之门。
对于未同步的多线程,JMM 只提供最小安全性:线程读取到的值,要么是之前某个线程写入的值,要么是默认值,不会无中生有。
为了实现这个安全性,JVM 在堆上分配对象时,首先会对内存空间清零,然后才会在上面分配对象(这两个操作是同步的)。
JMM 没有保证未同步程序的执行结果与该程序在顺序一致性中执行结果一致。因为如果要保证执行结果一致,那么 JMM 需要禁止大量的优化,对程序的执行性能会产生很大的影响。
未同步程序在 JMM 和顺序一致性内存模型中的执行特性有如下差异:
- 顺序一致性保证单线程内的操作会按程序的顺序执行;JMM 不保证单线程内的操作会按程序的顺序执行。(因为重排序,但是 JMM 保证单线程下的重排序不影响执行结果)
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。(因为 JMM 不保证所有操作立即可见)
- 顺序一致性模型保证对所有的内存读写操作都具有原子性,而 JMM 不保证对 64 位的 long 型和 double 型变量的写操作具有原子性。
4)JMM 与 happens-before
一方面,我们开发者需要 JMM 提供一个强大的内存模型来编写代码;另一方面,编译器和处理器希望 JMM 对它们的束缚越少越好,这样它们就可以尽可能多的做优化来提高性能,希望的是一个弱的内存模型。
JMM 考虑了这两种需求,并且找到了平衡点,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。
对于我们开发者来说,JMM 提供了happens-before 规则(JSR-133 规范),满足了我们的诉求——简单易懂,并且提供了足够强的内存可见性保证。 换言之,我们开发者只要遵循 happens-before 规则,那么我们写的程序就能保证在 JMM 中具有强的内存可见性。
JMM 使用 happens-before 的概念来定制两个操作之间的执行顺序。这两个操作可以在一个线程内,也可以是不同的线程种。
happens-before 关系的定义如下:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。
happens-before 关系本质上和 as-if-serial 语义是一回事。
as-if-serial 语义保证单线程内重排序后的执行结果和程序代码本身应有的结果是一致的,happens-before 关系保证正确同步的多线程程序的执行结果不被重排序改变。
总之,如果操作 A happens-before 操作 B,那么操作 A 在内存上所做的操作对操作 B 都是可见的,不管它们在不在一个线程。
1.happens-before 关系有哪些?
在 Java 中,有以下天然的 happens-before 关系:
- 程序顺序规则:一个线程中的每一个操作,happens-before 于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
- 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
- start 规则:如果线程 A 执行操作
ThreadB.start()启动线程 B,那么 A 线程的ThreadB.start()操作 happens-before 于线程 B 中的任意操作。 - join 规则:如果线程 A 执行操作
ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从ThreadB.join()操作成功返回。
举例:
int a = 1; // A操作
int b = 2; // B操作
int sum = a + b;// C 操作
System.out.println(sum);
根据以上介绍的 happens-before 规则,假如只有一个线程,那么不难得出:
1> A happens-before B
2> B happens-before C
3> A happens-before C
注意,真正在执行指令的时候,其实 JVM 有可能对操作 A & B 进行重排序,因为无论先执行 A 还是 B,他们都对对方是可见的,并且不影响执行结果。
如果这里发生了重排序,这在视觉上违背了 happens-before 原则,但是 JMM 是允许这样的重排序的。
所以,我们只关心 happens-before 规则,不用关心 JVM 到底是怎样执行的。只要确定操作 A happens-before 操作 B 就行了。
重排序有两类,JMM 对这两类重排序有不同的策略:
- 会改变程序执行结果的重排序,比如 A -> C,JMM 要求编译器和处理器都禁止这种重排序。
- 不会改变程序执行结果的重排序,比如 A -> B,JMM 对编译器和处理器不做要求,允许这种重排序。
二、小林-图解系统-Linux虚拟内存管理
1、进程虚拟内存空间的管理
既然我们要介绍进程的虚拟内存空间管理,那就离不开进程在内核中的描述符 task_struct 结构。
struct task_struct {
// 进程id
pid_t pid;
// 用于标识线程所属的进程 pid
pid_t tgid;
// 进程打开的文件信息
struct files_struct *files;
// 内存描述符表示进程虚拟地址空间
struct mm_struct *mm;
.......... 省略 .......
}
在进程描述符 task_struct 结构中,有一个专门描述进程虚拟地址空间的内存描述符 mm_struct 结构,这个结构体中包含了前边几个小节中介绍的进程虚拟内存空间的全部信息。
每个进程都有唯一的 mm_struct 结构体,也就是前边提到的每个进程的虚拟地址空间都是独立,互不干扰的。
当我们调用 fork() 函数创建进程的时候,表示 进程地址空间的mm_struct 结构会随着进程描述符 task_struct 的创建而创建。
随后会在 copy_process 函数中创建 task_struct 结构,并拷贝父进程的相关资源到新进程的 task_struct 结构里,其中就包括拷贝父进程的虚拟内存空间 mm_struct 结构。这里可以看出子进程在新创建出来之后它的虚拟内存空间是和父进程的虚拟内存空间一模一样的,直接拷贝过来。
通过 fork() 函数创建出的子进程,它的虚拟内存空间以及相关页表相当于父进程虚拟内存空间的一份拷贝,直接从父进程中拷贝到子进程中。
这样一来父进程和子进程的虚拟内存空间就变成共享的了。也就是说父子进程之间使用的虚拟内存空间是一样的,并不是一份拷贝。
子进程共享了父进程的虚拟内存空间,这样子进程就变成了我们熟悉的线程,是否共享地址空间几乎是进程和线程之间的本质区别。Linux 内核并不区别对待它们,线程对于内核来说仅仅是一个共享特定资源的进程而已。
内核线程和用户态线程的区别就是内核线程没有相关的内存描述符 mm_struct ,内核线程对应的 task_struct 结构中的 mm 域指向 Null,所以内核线程之间调度是不涉及地址空间切换的。
内核线程不会访问用户空间的内存,它仅仅只会访问内核空间的内存,所以直接复用上一个用户态进程的虚拟地址空间就可以避免为内核线程分配 mm_struct 和相关页表的开销,以及避免内核线程之间调度时地址空间的切换开销。
父进程与子进程的区别,进程与线程的区别,以及内核线程与用户态线程的区别其实都是围绕着这个 mm_struct 展开的。
现在我们知道了表示进程虚拟内存空间的 mm_struct 结构是如何被创建出来的相关背景,那么接下来我就带大家深入 mm_struct 结构内部,来看一下内核如何通过这么一个 mm_struct 结构体来管理进程的虚拟内存空间的。
1)内核如何划分用户态和内核态虚拟内存空间
进程的虚拟内存空间分为两个部分:一部分是用户态虚拟内存空间,另一部分是内核态虚拟内存空间。
那么用户态的地址空间和内核态的地址空间在内核中是如何被划分的呢?
这就用到了进程的内存描述符 mm_struct 结构体中的 task_size 变量,task_size 定义了用户态地址空间与内核态地址空间之间的分界线。
struct mm_struct {
unsigned long task_size; /* size of task vm space */
}
通过前边小节的内容介绍,我们知道在 32 位系统中用户态虚拟内存空间为 3 GB,虚拟内存地址范围为:0x0000 0000 - 0xC000 000 。
内核态虚拟内存空间为 1 GB,虚拟内存地址范围为:0xC000 000 - 0xFFFF FFFF。
32 位系统中用户地址空间和内核地址空间的分界线在 0xC000 000 地址处,那么自然进程的 mm_struct 结构中的 task_size 为 0xC000 000。
而在 64 位系统中,只使用了其中的低 48 位来表示虚拟内存地址。其中用户态虚拟内存空间为低 128 T,虚拟内存地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 。
内核态虚拟内存空间为高 128 T,虚拟内存地址范围为:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。
64 位系统中用户地址空间和内核地址空间的分界线在 0x0000 7FFF FFFF F000 地址处,那么自然进程的 mm_struct 结构中的 task_size 为 0x0000 7FFF FFFF F000 。
而内核空间的起始地址是 0xFFFF 8000 0000 0000 。在 0x00007FFFFFFFF000 - 0xFFFF 8000 0000 0000 之间的内存区域就是canonical address 空洞。
2)内核如何布局进程虚拟内存空间
在我们理解了内核是如何划分进程虚拟内存空间和内核虚拟内存空间之后,那么那些虚拟内存区域在内核中又是如何划分的呢?
接下来我就为大家介绍下内核是如何划分进程虚拟内存空间中的这些内存区域的,本小节的示例图中,我只保留了进程虚拟内存空间中的核心区域,方便大家理解。
前边我们提到,内核中采用了一个叫做内存描述符的 mm_struct 结构体来表示进程虚拟内存空间的全部信息。在本小节中我就带大家到 mm_struct 结构体内部去寻找下相关的线索。
struct mm_struct {
unsigned long task_size; /* 任务虚拟内存空间大小 */
unsigned long start_code, end_code, start_data, end_data;
/* 代码段和数据段的起始和结束地址 */
unsigned long start_brk, brk, start_stack;
/* 堆的起始地址,当前brk位置,栈的起始地址 */
unsigned long arg_start, arg_end, env_start, env_end;
/* 参数和环境变量的起始和结束地址 */
unsigned long mmap_base; /* 内存映射区域的基地址 */
unsigned long total_vm; /* 映射的总页数 */
unsigned long locked_vm; /* 被锁定页面的数量(PG_mlocked设置) */
unsigned long pinned_vm; /* 引用计数永久增加的页面 */
unsigned long data_vm; /* VM_WRITE & ~VM_SHARED & ~VM_STACK 的页面数 */
unsigned long exec_vm; /* VM_EXEC & ~VM_WRITE & ~VM_STACK 的页面数 */
unsigned long stack_vm; /* VM_STACK 的页面数 */
...... 省略 ........
};
}
内核中用 mm_struct 结构体中的上述属性来定义上图中虚拟内存空间里的不同内存区域。
-
start_code 和 end_code 定义代码段的起始和结束位置,程序编译后的二进制文件中的机器码被加载进内存之后就存放在这里。
-
start_data 和 end_data 定义数据段的起始和结束位置,二进制文件中存放的全局变量和静态变量被加载进内存中就存放在这里。
-
后面紧挨着的是 BSS 段,用于存放未被初始化的全局变量和静态变量,这些变量在加载进内存时会生成一段 0 填充的内存区域 (BSS 段), BSS 段的大小是固定的,
-
下面就是 OS 堆了,在堆中内存地址的增长方向是由低地址向高地址增长, start_brk 定义堆的起始位置,brk 定义堆当前的结束位置。
我们使用 malloc 申请小块内存时(低于 128K),就是通过改变 brk 位置调整堆大小实现的。
-
接下来就是内存映射区,在内存映射区内存地址的增长方向是由高地址向低地址增长,mmap_base 定义内存映射区的起始地址。进程运行时所依赖的动态链接库中的代码段,数据段,BSS 段以及我们调用 mmap 映射出来的一段虚拟内存空间就保存在这个区域。
-
start_stack 是栈的起始位置,在 RBP 寄存器中存储,栈的结束位置也就是栈顶指针 stack pointer 在 RSP 寄存器中存储。在栈中内存地址的增长方向也是由高地址向低地址增长。
-
arg_start 和 arg_end 是参数列表的位置, env_start 和 env_end 是环境变量的位置。它们都位于栈中的最高地址处。
在 mm_struct 结构体中除了上述用于划分虚拟内存区域的变量之外,还定义了一些虚拟内存与物理内存映射内容相关的统计变量,操作系统会把物理内存划分成一页一页的区域来进行管理,所以物理内存到虚拟内存之间的映射也是按照页为单位进行的。这部分内容我会在后续的文章中详细介绍,大家这里只需要有个概念就行。
mm_struct 结构体中的 total_vm 表示在进程虚拟内存空间中总共与物理内存映射的页的总数。
注意映射这个概念,它表示只是将虚拟内存与物理内存建立关联关系,并不代表真正的分配物理内存。
当内存吃紧的时候,有些页可以换出到硬盘上,而有些页因为比较重要,不能换出。locked_vm 就是被锁定不能换出的内存页总数,pinned_vm 表示既不能换出,也不能移动的内存页总数。
data_vm 表示数据段中映射的内存页数目,exec_vm 是代码段中存放可执行文件的内存页数目,stack_vm 是栈中所映射的内存页数目,这些变量均是表示进程虚拟内存空间中的虚拟内存使用情况。
现在关于内核如何对进程虚拟内存空间进行布局的内容我们已经清楚了,那么布局之后划分出的这些虚拟内存区域在内核中又是如何被管理的呢?我们接着往下看~~~
3)内核如何管理虚拟内存区域
在上小节的介绍中,我们知道内核是通过一个 mm_struct 结构的内存描述符来表示进程的虚拟内存空间的,并通过 task_size 域来划分用户态虚拟内存空间和内核态虚拟内存空间。
而在划分出的这些虚拟内存空间中如上图所示,里边又包含了许多特定的虚拟内存区域,比如:代码段,数据段,堆,内存映射区,栈。那么这些虚拟内存区域在内核中又是如何表示的呢?
本小节中,我将为大家介绍一个新的结构体 vm_area_struct,正是这个结构体描述了这些虚拟内存区域 VMA(virtual memory area)。
每个 vm_area_struct 结构对应于虚拟内存空间中的唯一虚拟内存区域 VMA,vm_start 指向了这块虚拟内存区域的起始地址(最低地址),vm_start 本身包含在这块虚拟内存区域内。vm_end 指向了这块虚拟内存区域的结束地址(最高地址),而 vm_end 本身包含在这块虚拟内存区域之外,所以 vm_area_struct 结构描述的是 [vm_start,vm_end) 这样一段左闭右开的虚拟内存区域。
4)定义虚拟内存区域的访问权限和行为规范
vm_page_prot 和 vm_flags 都是用来标记 vm_area_struct 结构表示的这块虚拟内存区域的访问权限和行为规范。
上边小节中我们也提到,内核会将整块物理内存划分为一页一页大小的区域,以页为单位来管理这些物理内存,每页大小默认 4K 。而虚拟内存最终也是要和物理内存一一映射起来的,所以在虚拟内存空间中也有虚拟页的概念与之对应,虚拟内存中的虚拟页映射到物理内存中的物理页。无论是在虚拟内存空间中还是在物理内存中,内核管理内存的最小单位都是页。
vm_page_prot 偏向于定义底层内存管理架构中页这一级别的访问控制权限,它可以直接应用在底层页表中,它是一个具体的概念。
页表用于管理虚拟内存到物理内存之间的映射关系,这部分内容我后续会详细讲解,这里大家有个初步的概念就行。
虚拟内存区域 VMA 由许多的虚拟页 (page) 组成,每个虚拟页需要经过页表的转换才能找到对应的物理页面。页表中关于内存页的访问权限就是由 vm_page_prot 决定的。
vm_flags 则偏向于定于整个虚拟内存区域的访问权限以及行为规范。描述的是虚拟内存区域中的整体信息,而不是虚拟内存区域中具体的某个独立页面。它是一个抽象的概念。可以通过 vma->vm_page_prot = vm_get_page_prot(vma->vm_flags) 实现到具体页面访问权限 vm_page_prot 的转换。
下面我列举一些常用到的 vm_flags 方便大家有一个直观的感受:
| vm_flags | 访问权限 |
|---|---|
| VM_READ | 可读 |
| VM_WRITE | 可写 |
| VM_EXEC | 可执行 |
| VM_SHARD | 可多进程之间共享 |
| VM_IO | 可映射至设备 IO 空间 |
| VM_RESERVED | 内存区域不可被换出 |
| VM_SEQ_READ | 内存区域可能被顺序访问 |
| VM_RAND_READ | 内存区域可能被随机访问 |
VM_READ,VM_WRITE,VM_EXEC 定义了虚拟内存区域是否可以被读取,写入,执行等权限。
- 代码段这块内存区域的权限是可读,可执行,但是不可写。
- 数据段具有可读可写的权限但是不可执行。
- 堆则具有可读可写,可执行的权限(Java 中的字节码存储在堆中,所以需要可执行权限)。
- 栈一般是可读可写的权限,一般很少有可执行权限。
- 而文件映射与匿名映射区存放了共享链接库,所以也需要可执行的权限。
VM_SHARD 用于指定这块虚拟内存区域映射的物理内存是否可以在多进程之间共享,以便完成进程间通讯。
设置这个值即为 mmap 的共享映射,不设置的话则为私有映射。这个等后面我们讲到 mmap 的相关实现时还会再次提起。
VM_IO 的设置表示这块虚拟内存区域可以映射至设备 IO 空间中。通常在设备驱动程序执行 mmap 进行 IO 空间映射时才会被设置。
5)关联内存映射中的映射关系
接下来的三个属性 anon_vma,vm_file,vm_pgoff 分别和虚拟内存映射相关,虚拟内存区域可以映射到物理内存上,也可以映射到文件中,映射到物理内存上我们称之为匿名映射,映射到文件中我们称之为文件映射。
那么这个映射关系在内核中该如何表示呢?这就用到了 vm_area_struct 结构体中的上述三个属性。
当我们调用 malloc 申请内存时,如果申请的是小块内存(低于 128K)则会使用 do_brk() 系统调用通过调整堆中的 brk 指针大小来增加或者回收堆内存。
如果申请的是比较大块的内存(超过 128K)时,则会调用 mmap 在上图虚拟内存空间中的文件映射与匿名映射区创建出一块 VMA 内存区域(这里是匿名映射)。这块匿名映射区域就用 struct anon_vma 结构表示。
当调用 mmap 进行文件映射时,vm_file 属性就用来关联被映射的文件。这样一来虚拟内存区域就与映射文件关联了起来。vm_pgoff 则表示映射进虚拟内存中的文件内容,在文件中的偏移。
当然在匿名映射中,vm_area_struct 结构中的 vm_file 就为 null,vm_pgoff 也就没有了意义。
vm_private_data 则用于存储 VMA 中的私有数据。具体的存储内容和内存映射的类型有关,我们暂不展开论述。
6)针对虚拟内存区域的相关操作
struct vm_area_struct 结构中还有一个 vm_ops 用来指向针对虚拟内存区域 VMA 的相关操作的函数指针。
struct vm_operations_struct {
// 打开一个虚拟内存区域时调用的函数
void (*open)(struct vm_area_struct * area);
// 关闭一个虚拟内存区域时调用的函数
void (*close)(struct vm_area_struct * area);
// 处理虚拟内存区域的页面错误时调用的函数
vm_fault_t (*fault)(struct vm_fault *vmf);
// 当页面需要写入时调用的函数
vm_fault_t (*page_mkwrite)(struct vm_fault *vmf);
// ... 省略其他字段 ...
};
- 当指定的虚拟内存区域被加入到进程虚拟内存空间中时,open 函数会被调用
- 当虚拟内存区域 VMA 从进程虚拟内存空间中被删除时,close 函数会被调用
- 当进程访问虚拟内存时,访问的页面不在物理内存中,可能是未分配物理内存也可能是被置换到磁盘中,这时就会产生缺页异常,fault 函数就会被调用。
- 当一个只读的页面将要变为可写时,page_mkwrite 函数会被调用。
7)虚拟内存区域在内核中是如何被组织的
在上一小节中,我们介绍了内核中用来表示虚拟内存区域 VMA 的结构体 struct vm_area_struct ,并详细为大家剖析了 struct vm_area_struct 中的一些重要的关键属性。
现在我们已经熟悉了这些虚拟内存区域,那么接下来的问题就是在内核中这些虚拟内存区域是如何被组织的呢?
在内核中其实是通过一个 struct vm_area_struct 结构的双向链表将虚拟内存空间中的这些虚拟内存区域 VMA 串联起来的。
vm_area_struct 结构中的 vm_next ,vm_prev 指针分别指向 VMA 节点所在双向链表中的后继节点和前驱节点,内核中的这个 VMA 双向链表是有顺序的,所有 VMA 节点按照低地址到高地址的增长方向排序。
双向链表中的最后一个 VMA 节点的 vm_next 指针指向 NULL,双向链表的头指针存储在内存描述符 struct mm_struct 结构中的 mmap 中,正是这个 mmap 串联起了整个虚拟内存空间中的虚拟内存区域。
在每个虚拟内存区域 VMA 中又通过 struct vm_area_struct 中的 vm_mm 指针指向了所属的虚拟内存空间 mm_struct。
内核中关于这些虚拟内存区域的操作除了遍历之外还有许多需要根据特定虚拟内存地址在虚拟内存空间中查找特定的虚拟内存区域。
尤其在进程虚拟内存空间中包含的内存区域 VMA 比较多的情况下,使用红黑树查找特定虚拟内存区域的时间复杂度是 O( logN ) ,可以显著减少查找所需的时间。
所以在内核中,同样的内存区域 vm_area_struct 会有两种组织形式,一种是双向链表用于高效的遍历,另一种就是红黑树用于高效的查找。
每个 VMA 区域都是红黑树中的一个节点,通过 struct vm_area_struct 结构中的 vm_rb 将自己连接到红黑树中。