博客记录-day028-volatile关键字、synchronized关键字+Linux虚拟空间管理

106 阅读33分钟

一、沉默王二-并发编程

1、volatile关键字解析

volatile 可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在 JVM 底层 volatile 是采用“内存屏障”来实现的。

观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码就能发现,加入 volatile 关键字时,会多出一个 lock 前缀指令,lock 前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供 3 个功能:

  • 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  • 它会强制将对缓存的修改操作立即写入主存;
  • 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。

volatile 可以保证可见性,但不保证原子性:

  • 当写一个 volatile 变量时,JMM 会把该线程在本地内存中的变量强制刷新到主内存中去
  • 这个写操作会导致其他线程中的 volatile 变量缓存无效

1)volatile 会禁止指令重排

在讲 JMM的时候,我们提到了指令重排,相信大家都还有印象,我们来回顾一下重排序需要遵守的规则:

  • 重排序不会对存在数据依赖关系的操作进行重排序。比如:a=1;b=a; 这个指令序列,因为第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
  • 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。比如:a=1;b=2;c=a+b 这三个操作,第一步 (a=1) 和第二步 (b=2) 由于不存在数据依赖关系,所以可能会发生重排序,但是 c=a+b 这个操作是不会被重排序的,因为需要保证最终的结果一定是 c=a+b=3。

使用volatile 关键字修饰共享变量可以禁止这种重排序。怎么做到的呢?

当我们使用 volatile 关键字来修饰一个变量时,Java 内存模型会插入内存屏障(一个处理器指令,可以对 CPU 或编译器重排序做出约束)来确保以下两点:

  • 写屏障(Write Barrier):当一个 volatile 变量被写入时,写屏障确保在该屏障之前的所有变量的写入操作都提交到主内存。
  • 读屏障(Read Barrier):当读取一个 volatile 变量时,读屏障确保在该屏障之后的所有读操作都从主内存中读取。

换句话说:

  • 当程序执行到 volatile 变量的读操作或者写操作时,在其前面操作的更改肯定已经全部进行,且结果对后面的操作可见;在其后面的操作肯定还没有进行;
  • 在进行指令优化时,不能将 volatile 变量的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。

“也就是说,执行到 volatile 变量时,其前面的所有语句都必须执行完,后面所有得语句都未执行。且前面语句的结果对 volatile 变量及其后面语句可见。”

先看下面未使用 volatile 的代码:

class ReorderExample {
  int a = 0;
  boolean flag = false;
  public void writer() {
      a = 1;                   //1
      flag = true;             //2
  }
  Public void reader() {
      if (flag) {                //3
          int i =  a * a;        //4
          System.out.println(i);
      }
  }
}

因为重排序影响,所以最终的输出可能是 0,重排序请参考上一篇 JMM 的介绍,如果引入 volatile,我们再看一下代码:

class ReorderExample {
  int a = 0;
  boolean volatile flag = false;
  public void writer() {
      a = 1;                   //1
      flag = true;             //2
  }
  Public void reader() {
      if (flag) {                //3
          int i =  a * a;        //4
          System.out.println(i);
      }
  }
}

这时候,volatile 会禁止指令重排序,这个过程建立在 happens before 关系的基础上:

  1. 根据程序次序规则,1 happens before 2; 3 happens before 4。
  2. 根据 volatile 规则,2 happens before 3。
  3. 根据 happens before 的传递性规则,1 happens before 4。

上述 happens before 关系的图形化表现形式如下:

在上图中,每一个箭头链接的两个节点,代表了一个 happens before 关系:

  • 黑色箭头表示程序顺序规则;
  • 橙色箭头表示 volatile 规则;
  • 蓝色箭头表示组合这些规则后提供的 happens before 保证。

这里 A 线程写一个 volatile 变量后,B 线程读同一个 volatile 变量。A 线程在写 volatile 变量之前所有可见的共享变量,在 B 线程读同一个 volatile 变量后,将立即变得对 B 线程可见。

2)volatile 不适用的场景

下面是变量自加的示例:

public class volatileTest {
    // 定义一个volatile修饰的int类型变量inc,初始值为0
    public volatile int inc = 0;

    // 定义一个increase方法,用于增加inc的值
    public void increase() {
        inc++; // 将inc的值增加1
    }

    // 主方法,程序的入口
    public static void main(String[] args) {
        // 创建volatileTest类的实例对象test
        final volatileTest test = new volatileTest();

        // 循环创建10个线程
        for(int i=0;i<10;i++){
            // 创建一个新的匿名内部类线程
            new Thread(){
                // 重写run方法
                public void run() {
                    // 循环1000次调用test对象的increase方法
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start(); // 启动线程
        }

        // 当活跃线程数大于1时,执行Thread.yield(),让出CPU时间片
        while(Thread.activeCount()>1)  
            Thread.yield();

        // 打印最终的inc值
        System.out.println("inc output:" + test.inc);
    }
}

测试输出:

inc output:8182

“因为 inc++不是一个原子性操作,由读取、加、赋值 3 步组成,所以结果并不能达到 10000。”

解决办法:

  • 01、采用 synchronized,把 inc++ 拎出来单独加 synchronized 关键字

  • 02、采用 Lock,通过重入锁 ReentrantLock对 inc++ 加锁

  • 03、采用原子类 AtomicInteger实现:

3)volatile 实现单例模式的双重锁

下面是一个使用"双重检查锁定"(double-checked locking)实现的单例模式(Singleton Pattern)的例子。

public class Penguin {
    // 使用volatile关键字确保m_penguin的可见性和防止指令重排序
    private static volatile Penguin m_penguin = null;

    // 一个成员变量 money,初始值为10000
    private int money = 10000;

    // 私有构造方法,防止外部通过new直接创建对象
    private Penguin() {}

    // 打豆豆方法,打印"打豆豆"和当前的钱数
    public void beating() {
        System.out.println("打豆豆" + money);
    }

    // 静态方法,用于获取Penguin类的唯一实例
    public static Penguin getInstance() {
        // 双重检查锁定,确保m_penguin只被创建一次
        if (m_penguin == null) {
            synchronized (Penguin.class) {
                if (m_penguin == null) {
                    m_penguin = new Penguin();
                }
            }
        }
        return m_penguin;
    }
}

在这个例子中,Penguin 类只能被实例化一次。来看代码解释:

  • 声明了一个类型为 Penguin 的 volatile 变量 m_penguin,它是类的静态变量,用来存储 Penguin 类的唯一实例。
  • Penguin() 构造方法被声明为private,这样就阻止了外部代码使用 new 来创建 Penguin 实例,保证了只能通过 getInstance() 方法获取实例。
  • getInstance() 方法是获取 Penguin 类唯一实例的公共静态方法
  • 第一次 if (null == m_penguin) 检查是否已经存在 Penguin 实例。如果不存在,才进入同步代码块。
  • synchronized(penguin.class) 对类的 Class 对象加锁,确保在多线程环境下,同时只能有一个线程进入同步代码块。在同步代码块中,再次执行 if (null == m_penguin) 检查实例是否已经存在,如果不存在,则创建新的实例。这就是所谓的“双重检查锁定”,一共两次。
  • 最后返回 m_penguin,也就是 Penguin 的唯一实例。

其中,使用 volatile 关键字是为了防止 m_penguin = new Penguin() 这一步被指令重排序。因为实际上,new Penguin() 这一行代码分为三个子步骤:

  • 步骤 1:为 Penguin 对象分配足够的内存空间,伪代码 memory = allocate()
  • 步骤 2:调用 Penguin 的构造方法,初始化对象的成员变量,伪代码 ctorInstanc(memory)
  • 步骤 3:将内存地址赋值给 m_penguin 变量,使其指向新创建的对象,伪代码 instance = memory

如果不使用 volatile 关键字,JVM 可能会对这三个子步骤进行指令重排。

  • 为 Penguin 对象分配内存
  • 将对象赋值给引用 m_penguin
  • 调用构造方法初始化成员变量

不使用 volatile 关键字的重排序会导致 m_penguin 引用在对象完全初始化之前就被其他线程访问到。具体来说,如果一个线程执行到步骤 2 并设置了 m_penguin 的引用,但尚未完成对象的初始化,这时另一个线程可能会看到一个“半初始化”的 Penguin 对象。

假如此时有两个线程 A 和 B,要执行 getInstance() 方法:

  • 线程 A 执行到 if (m_penguin == null),判断为 true,进入同步块。
  • 线程 B 执行到 if (m_penguin == null),判断为 true,进入同步块。

如果线程 A 执行 m_penguin = new Penguin() 时发生指令重排序:

  • 线程 A 分配内存并设置引用,但尚未调用构造方法完成初始化
  • 线程 B 此时判断 m_penguin != null,直接返回这个“半初始化”的对象

这样就会导致线程 B 拿到一个不完整的 Penguin 对象,可能会出现空指针异常或者其他问题。

于是,我们可以为 m_penguin 变量添加 volatile 关键字,来禁止指令重排序,确保对象的初始化完成后再将其赋值给 m_penguin。

2、synchronized关键字

在 Java 中,关键字synchronized 可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),同时我们还应该注意到 synchronized 的另外一个重要的作用,synchronized 可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代 volatile 功能)。

synchronized 关键字最主要有以下 3 种应用方式:

  • 同步方法,为当前对象(this)加锁,进入同步代码前要获得当前对象的锁;
  • 同步静态方法,为当前类加锁(锁的是 Class 对象),进入同步代码前要获得当前类的锁;
  • 同步代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

1)synchronized同步方法

通过在方法声明中加入 synchronized 关键字,可以保证在任意时刻,只有一个线程能执行该方法

来看代码:

public class AccountingSync implements Runnable {
    //共享资源(临界资源)
    static int i = 0;
    // synchronized 同步方法
    public synchronized void increase() {
        i ++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String args[]) throws InterruptedException {
        AccountingSync instance = new AccountingSync();
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("static, i output:" + i);
    }
}

输出结果:

static, i output:2000000

如果在方法 increase() 前不加 synchronized,因为 i++ 不具备原子性,所以最终结果会小于 2000000。

注意:一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的其他 synchronized 方法,但是其他线程还是可以访问该对象的其他非 synchronized 方法

但是,如果一个线程 A 需要访问对象 obj1 的 synchronized 方法 f1(当前对象锁是 obj1),另一个线程 B 需要访问对象 obj2 的 synchronized 方法 f2(当前对象锁是 obj2),这样是允许的。

与前面不同的是,我们创建了两个对象 AccountingSyncBad,然后启动两个不同的线程对共享变量 i 进行操作,但很遗憾,操作结果是 1224617 而不是期望的结果 2000000。

因为上述代码犯了严重的错误,虽然使用了 synchronized 同步 increase 方法,但却 new 了两个不同的对象,这也就意味着存在着两个不同的对象锁,因此 t1 和 t2 都会进入各自的对象锁,也就是说 t1 和 t2 线程使用的是不同的锁,因此线程安全是无法保证的。

每个对象都有一个对象锁,不同的对象,他们的锁不会互相影响。

解决这种问题的的方式是将 synchronized 作用于静态的 increase 方法,这样的话,对象锁就锁的是当前的类,由于无论创建多少个对象,类永远只有一个,所有在这样的情况下对象锁就是唯一的。

2)synchronized同步静态方法

当 synchronized 同步静态方法时,锁的是当前类的 Class 对象,不属于某个对象。当前类的 Class 对象锁被获取,不影响实例对象锁的获取,两者互不影响,本质上是 this 和 Class 的不同

由于静态成员变量不专属于任何一个对象,因此通过 Class 锁可以控制静态成员变量的并发操作。

需要注意的是如果线程 A 调用了一个对象的非静态 synchronized 方法,线程 B 需要调用这个对象所属类的静态 synchronized 方法,是不会发生互斥的,因为访问静态 synchronized 方法占用的锁是当前类的 Class 对象,而访问非静态 synchronized 方法占用的锁是当前对象(this)的锁

如下代码:

public class AccountingSyncClass implements Runnable {
    // 定义静态变量i,用于计数
    static int i = 0;

    /**
     * 同步静态方法,锁是当前class对象,也就是AccountingSyncClass类对应的class对象
     * 这个方法保证了同时只有一个线程可以执行它,从而保证了i的原子性操作
     */
    public static synchronized void increase() {
        i++;
    }

    /**
     * 非静态同步方法,访问时锁不一样不会发生互斥
     * 这个方法的对象级别的锁,每个AccountingSyncClass实例都有自己的锁
     */
    public synchronized void increase4Obj() {
        i++;
    }

    // 实现Runnable接口的run方法
    @Override
    public void run() {
        // 循环1000000次,调用increase方法
        for(int j=0;j<1000000;j++){
            increase();
        }
    }

    // 主方法,程序入口
    public static void main(String[] args) throws InterruptedException {
        // 创建两个新的AccountingSyncClass实例
        Thread t1=new Thread(new AccountingSyncClass());
        Thread t2=new Thread(new AccountingSyncClass());
        // 启动两个线程
        t1.start(); 
        t2.start();
        // 等待两个线程执行完毕
        t1.join(); 
        t2.join();
        // 打印最终的i值
        System.out.println(i);
    }
}
/**
 * 输出结果:
 * 2000000
 */

由于 synchronized 关键字同步的是静态的 increase 方法,与同步实例方法不同的是,其锁对象是当前类的 Class 对象

注意代码中的 increase4Obj 方法是实例方法,其对象锁是当前实例对象(this),如果别的线程调用该方法,将不会产生互斥现象,毕竟锁的对象不同,这种情况下可能会发生线程安全问题(操作了共享静态变量 i)。

3)synchronized同步代码块

某些情况下,我们编写的方法代码量比较多,存在一些比较耗时的操作,而需要同步的代码块只有一小部分,如果直接对整个方法进行同步,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹

示例如下:

public class AccountingSync2 implements Runnable {
    // 使用饿汉单例模式创建实例
    static AccountingSync2 instance = new AccountingSync2(); 

    // 定义一个静态变量i,用于计数
    static int i=0;

    // 实现Runnable接口的run方法
    @Override
    public void run() {
        // 省略其他耗时操作....
        
        // 使用同步代码块对变量i进行同步操作,锁对象为instance
        synchronized(instance){
            for(int j=0;j<1000000;j++){
                // 对变量i进行自增操作
                i++;
            }
        }
    }

    // main方法,程序入口
    public static void main(String[] args) throws InterruptedException {
        // 创建两个线程,都执行instance的run方法
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        
        // 启动两个线程
        t1.start();t2.start();       
        // 等待两个线程执行完毕
        t1.join();t2.join();
        // 打印最终的i值
        System.out.println(i);
    }
}

输出结果:

 2000000

我们将 synchronized 作用于一个给定的实例对象 instance,即当前实例对象就是锁的对象,当线程进入 synchronized 包裹的代码块时就会要求当前线程持有 instance 实例对象的锁,如果当前有其他线程正持有该对象锁,那么新的线程就必须等待,这样就保证了每次只有一个线程执行 i++ 操作。

当然除了用 instance 作为对象外,我们还可以当然除了用 instance 作为对象外,我们还可以使用 this 对象(代表当前实例)或者当前类的 Class 对象作为锁,如下代码:

//this,当前实例对象锁
synchronized(this){
    for(int j=0;j<1000000;j++){
        i++;
    }
}
//Class对象锁
synchronized(AccountingSync.class){
    for(int j=0;j<1000000;j++){
        i++;
    }
}

4)synchronized与happens before

看下面这段代码:

class MonitorExample {
    int a = 0;
    public synchronized void writer() {  //1
        a++;                             //2
    }                                    //3
    public synchronized void reader() {  //4
        int i = a;                       //5
        //……
    }                                    //6
}

假设线程 A 执行 writer() 方法,随后线程 B 执行 reader() 方法。根据 happens before 规则,这个过程包含的 happens before 关系可以分为:

  • 根据程序次序规则,1 happens before 2, 2 happens before 3; 4 happens before 5, 5 happens before 6。
  • 根据监视器锁规则,3 happens before 4。
  • 根据 happens before 的传递性,2 happens before 5。

在 Java 内存模型中,监视器锁规则是一种 happens-before 规则,它规定了对一个监视器锁(monitor lock)或者叫做互斥锁的解锁操作 happens-before 于随后对这个锁的加锁操作。简单来说,这意味着在一个线程释放某个锁之后,另一个线程获得同一把锁的时候,前一个线程在释放锁时所做的所有修改对后一个线程都是可见的

上述 happens before 关系的图形化表现形式如下:

在上图中,每一个箭头链接的两个节点,代表了一个 happens before 关系。黑色箭头表示程序顺序规则;橙色箭头表示监视器锁规则;蓝色箭头表示组合这些规则后提供的 happens before 保证。

上图表示在线程 A 释放了锁之后,随后线程 B 获取同一个锁。在上图中,2 happens before 5。因此,线程 A 在释放锁之前所有可见的共享变量,在线程 B 获取同一个锁之后,将立刻变得对 B 线程可见。

也就是说,synchronized 会防止临界区内的代码与外部代码发生重排序writer() 方法中 a++ 的执行和 reader() 方法中 a 的读取之间存在 happens-before 关系,保证了执行顺序和内存可见性。

5)synchronized属于可重入锁

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功

synchronized 就是可重入锁,因此一个线程调用 synchronized 方法的同时,在其方法体内部调用该对象另一个 synchronized 方法是允许的,如下:

public class AccountingSync implements Runnable{
    // 创建AccountingSync类的静态实例
    static AccountingSync instance=new AccountingSync();
    // 定义静态变量i和j,用于计数
    static int i=0;
    static int j=0;

    // 实现Runnable接口的run方法
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            // 使用当前实例对象作为锁,进行同步
            synchronized(this){
                i++; // i自增
                increase(); // 调用increase方法,展示synchronized的可重入性
            }
        }
    }

    // 定义一个同步的方法increase
    public synchronized void increase(){
        j++; // j自增
    }

    // main方法,程序入口
    public static void main(String[] args) throws InterruptedException {
        // 创建两个线程,都使用instance实例
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start(); // 启动线程t1
        t2.start(); // 启动线程t2
        t1.join(); // 等待线程t1完成
        t2.join(); // 等待线程t2完成
        System.out.println(i); // 输出i的值
    }
}

1、AccountingSync 类中定义了一个静态的 AccountingSync 实例 instance 和两个静态的整数 i 和 j,静态变量被所有的对象所共享。

2、在 run 方法中,使用了 synchronized(this) 来加锁。这里的锁对象是 this,即当前的 AccountingSync 实例。在锁定的代码块中,对静态变量 i 进行增加,并调用了 increase 方法。

3、第一个increase 方法是一个同步方法,它会对 j 进行增加。由于第二个 increase 方法也是同步的,所以它能在已经获取到锁的情况下被 run 方法调用,这就是 synchronized 关键字的可重入性。

4、在 main 方法中,创建了两个线程 t1 和 t2,它们共享同一个 Runnable 对象,也就是共享同一个 AccountingSync 实例。然后启动这两个线程,并使用 join 方法等待它们都执行完成后,打印 i 的值。

此程序中的 synchronized(this) 和 synchronized 方法都使用了同一个锁对象(当前的 AccountingSync 实例),并且对静态变量 i 和 j 进行了增加操作,因此,在多线程环境下,也能保证 i 和 j 的操作是线程安全的。

二、小林-图解系统-Linux虚拟空间管理

1、程序编译后的二进制文件如何映射到虚拟内存空间中

经过前边这么多小节的内容介绍,现在我们已经熟悉了进程虚拟内存空间的布局,以及内核如何管理这些虚拟内存区域,并对进程的虚拟内存空间有了一个完整全面的认识。

现在我们再来回到最初的起点,进程的虚拟内存空间 mm_struct 以及这些虚拟内存区域 vm_area_struct 是如何被创建并初始化的呢?

image.png

在 进程虚拟内存空间小节中,我们介绍进程的虚拟内存空间时提到,我们写的程序代码编译之后会生成一个 ELF 格式的二进制文件,这个二进制文件中包含了程序运行时所需要的元信息,比如程序的机器码,程序中的全局变量以及静态变量等。

这个 ELF 格式的二进制文件中的布局和我们前边讲的虚拟内存空间中的布局类似,也是一段一段的,每一段包含了不同的元数据。

比如磁盘文件中的 .text,.rodata 等一些只读的 Section,会被映射到内存的一个只读可执行的 Segment 里(代码段)。而 .data,.bss 等一些可读写的 Section,则会被映射到内存的一个具有读写权限的 Segment 里(数据段,BSS 段)。

  • setup_new_exec 设置虚拟内存空间中的内存映射区域起始地址 mmap_base
  • setup_arg_pages 创建并初始化栈对应的 vm_area_struct 结构。置 mm->start_stack 就是栈的起始地址也就是栈底,并将 mm->arg_start 是指向栈底的。
  • elf_map 将 ELF 格式的二进制文件中.text ,.data,.bss 部分映射到虚拟内存空间中的代码段,数据段,BSS 段中。
  • set_brk 创建并初始化堆对应的的 vm_area_struct 结构,设置 current->mm->start_brk = current->mm->brk,设置堆的起始地址 start_brk,结束地址 brk。 起初两者相等表示堆是空的。
  • load_elf_interp 将进程依赖的动态链接库 .so 文件映射到虚拟内存空间中的内存映射区域
  • 初始化内存描述符 mm_struct

2、内核虚拟内存空间

现在我们已经知道了进程虚拟内存空间在内核中的布局以及管理,那么内核态的虚拟内存空间又是什么样子的呢?本小节我就带大家来一层一层地拆开这个黑盒子。

之前在介绍进程虚拟内存空间的时候,我提到不同进程之间的虚拟内存空间是相互隔离的,彼此之间相互独立,相互感知不到其他进程的存在。使得进程以为自己拥有所有的内存资源。

image.png

内核态虚拟内存空间是所有进程共享的,不同进程进入内核态之后看到的虚拟内存空间全部是一样的。

什么意思呢?比如上图中的进程 a,进程 b,进程 c 分别在各自的用户态虚拟内存空间中访问虚拟地址 x 。由于进程之间的用户态虚拟内存空间是相互隔离相互独立的,虽然在进程a,进程b,进程c 访问的都是虚拟地址 x 但是看到的内容却是不一样的(背后可能映射到不同的物理内存中)。

但是当进程 a,进程 b,进程 c 进入到内核态之后情况就不一样了,由于内核虚拟内存空间是各个进程共享的,所以它们在内核空间中看到的内容全部是一样的,比如进程 a,进程 b,进程 c 在内核态都去访问虚拟地址 y。这时它们看到的内容就是一样的了。

这里我和大家澄清一个经常被误解的概念:由于内核会涉及到物理内存的管理,所以很多人会想当然地认为只要进入了内核态就开始使用物理地址了,这就大错特错了,千万不要这样理解,进程进入内核态之后使用的仍然是虚拟内存地址,只不过在内核中使用的虚拟内存地址被限制在了内核态虚拟内存空间范围中,这也是本小节我要为大家介绍的主题。

在清楚了这个基本概念之后,下面我分别从 32 位体系 和 64 位体系下为大家介绍内核态虚拟内存空间的布局。

1) 32 位体系内核虚拟内存空间布局

内核通过 TASK_SIZE 将进程虚拟内存空间和内核虚拟内存空间分割开来。32 位体系结构 Linux 的整个虚拟内存空间的布局:

image.png 物理内存 896M 以上的区域被内核划分为 ZONE_HIGHMEM 区域,我们称之为高端内存。

  1. 临时映射区(0xFFFF FFFF - FIXADDR_TOP 0xFFFF F000):

    • 内核又不能直接进行拷贝,因为此时从 page cache 中取出的缓存页 page 是物理地址,而在内核中是不能够直接操作物理地址的,只能操作虚拟地址。
    • 那怎么办呢?所以就需要将缓存页临时映射到内核空间的一段虚拟地址上,这段虚拟地址就位于内核虚拟内存空间中的临时映射区上,然后将用户空间缓存区 DirectByteBuffer 中的待写入数据通过这段映射的虚拟地址拷贝到 page cache 中的相应缓存页中。这时文件的写入操作就已经完成了。
    • 由于是临时映射,所以在拷贝完成之后,调用 kunmap_atomic 将这段映射再解除掉
  2. 固定映射区(FIXADDR_START):

    • 为什么会有固定映射这个概念呢 ? 比如:在内核的启动过程中,有些模块需要使用虚拟内存并映射到指定的物理地址上,而且这些模块也没有办法等待完整的内存管理模块初始化之后再进行地址映射。因此,内核固定分配了一些虚拟地址,这些地址有固定的用途,使用该地址的模块在初始化的时候,将这些固定分配的虚拟地址映射到指定的物理地址上去。
  3. 永久映射区(PKMAP_BASE):

    • 在内核的这段虚拟地址空间中允许建立与物理高端内存的长期映射关系。比如内核通过 alloc_pages() 函数在物理内存的高端内存中申请获取到的物理内存页,这些物理内存页可以通过调用 kmap 映射到永久映射区中。
  4. vmalloc动态映射区(VMALLOC_END - VMALLOC_START):

    • 动态映射区采用动态映射的方式映射物理内存中的高端内存
    • 和用户态进程使用 malloc 申请内存一样,在这块动态映射区内核是使用 vmalloc 进行内存分配。由于之前介绍的动态映射的原因,vmalloc 分配的内存在虚拟内存上是连续的,但是物理内存是不连续的
    • 通过页表来建立物理内存与虚拟内存之间的映射关系,从而可以将不连续的物理内存映射到连续的虚拟内存上。
  5. 8M空闲

    • 这是一块保留的空闲区域,目前没有特定用途。
  6. DMA映射区+NORMAL_映射区=直接映射区

    • 3G -- 3G + 896m 这块 896M 大小的虚拟内存会直接映射到 0 - 896M 这块 896M 大小的物理内存上,这块区域中的虚拟内存地址直接减去 0xC000 0000 (3G) 就得到了物理内存地址。所以我们称这块区域为直接映射区

    • 虽然这块区域中的虚拟地址是直接映射到物理地址上,但是内核在访问这段区域的时候还是走的虚拟内存地址,内核也会为这块空间建立映射页表。

    • 直接映射区的前 16M 专门让内核用来为 DMA 分配内存,这块 16M 大小的内存区域我们称之为 ZONE_DMA。

    • 当进程被创建完毕之后,在内核运行的过程中,会涉及内核栈的分配,内核会为每个进程分配一个固定大小的内核栈(一般是两个页大小,依赖具体的体系结构),每个进程的整个调用链必须放在自己的内核栈中,内核栈也是分配在直接映射区

    • 与进程用户空间中的栈不同的是,内核栈容量小而且是固定的用户空间中的栈容量大而且可以动态扩展。内核栈的溢出危害非常巨大,它会直接悄无声息的覆盖相邻内存区域中的数据,破坏数据。

2) 64位体系结构下 Linux 虚拟内存空间整体布局

回顾下 64 位体系结构 Linux 的整个虚拟内存空间的布局:

image.png

3、到底什么是物理内存地址

聊完了虚拟内存,我们接着聊一下物理内存,我们平时所称的内存也叫随机访问存储器也叫 RAM。而 RAM 分为两类:

  • 一类是静态 RAM( SRAM ),这类 SRAM 用于 CPU 高速缓存 L1Cache,L2Cache,L3Cache。其特点是访问速度快,访问速度为 1 - 30 个时钟周期,但是容量小,造价高。

CPU缓存结构.png

  • 另一类则是动态 RAMDRAM ),这类 DRAM 用于我们常说的主存上,其特点的是访问速度慢(相对高速缓存),访问速度为 50 - 200 个时钟周期,但是容量大,造价便宜些(相对高速缓存)。

1)DRAM 芯片的访问

我们现在就以读取上图中坐标地址为(2,2)的 supercell 为例,来说明访问 DRAM 芯片的过程。

DRAM芯片访问.png

  1. 首先存储控制器将行地址 RAS = 2 通过地址引脚发送给 DRAM 芯片
  2. DRAM 芯片根据 RAS = 2 将二维矩阵中的第二行的全部内容拷贝到内部行缓冲区中。
  3. 接下来存储控制器会通过地址引脚发送 CAS = 2 到 DRAM 芯片中。
  4. DRAM芯片从内部行缓冲区中根据 CAS = 2 拷贝出第二列的 supercell 并通过数据引脚发送给存储控制器。

DRAM 芯片的 IO 单位为一个 supercell ,也就是一个字节(8 bit)。

2)CPU 如何读写主存

前边我们介绍了内存的物理结构,以及如何访问内存中的 DRAM 芯片获取 supercell 中存储的数据(一个字节)。本小节我们来介绍下 CPU 是如何访问内存的:

CPU与内存之间的总线结构.png

CPU 与内存之间的数据交互是通过总线(bus)完成的,而数据在总线上的传送是通过一系列的步骤完成的,这些步骤称为总线事务(bus transaction)

其中数据从内存传送到 CPU 称之为读事务(read transaction),数据从 CPU 传送到内存称之为写事务(write transaction)。

总线上传输的信号包括:地址信号,数据信号,控制信号。其中控制总线上传输的控制信号可以同步事务,并能够标识出当前正在被执行的事务信息:

  • 当前这个事务是到内存的?还是到磁盘的?或者是到其他 IO 设备的?
  • 这个事务是读还是写?
  • 总线上传输的地址信号(物理内存地址),还是数据信号(数据)?。

这里大家需要注意总线上传输的地址均为物理内存地址。比如:在 MESI 缓存一致性协议中当 CPU core0 修改字段 a 的值时,其他 CPU 核心会在总线上嗅探字段 a 的物理内存地址,如果嗅探到总线上出现字段 a 的物理内存地址,说明有人在修改字段 a,这样其他 CPU 核心就会失效字段 a 所在的 cache line 。

如上图所示,其中系统总线是连接 CPU 与 IO bridge 的,存储总线是来连接 IO bridge 和主存的。

IO bridge 负责将系统总线上的电子信号转换成存储总线上的电子信号。IO bridge 也会将系统总线和存储总线连接到IO总线(磁盘等IO设备)上。这里我们看到 IO bridge 其实起的作用就是转换不同总线上的电子信号。

3)CPU 从内存读取数据过程

假设 CPU 现在需要将物理内存地址为 A 的内容加载到寄存器中进行运算。

大家需要注意的是CPU 只会访问虚拟内存,在操作总线之前,需要把虚拟内存地址转换为物理内存地址,总线上传输的都是物理内存地址,这里省略了虚拟内存地址到物理内存地址的转换过程,这里我们聚焦如何通过物理内存地址读取内存数据

CPU读取内存.png

首先 CPU 芯片中的总线接口会在总线上发起读事务(read transaction)。 该读事务分为以下步骤进行:

  1. CPU 将物理内存地址 A 放到系统总线上。随后 IO bridge 将信号传递到存储总线上。
  2. 主存感受到存储总线上的地址信号并通过存储控制器将存储总线上的物理内存地址 A 读取出来。
  3. 存储控制器通过物理内存地址 A 定位到具体的存储器模块,从 DRAM 芯片中取出物理内存地址 A 对应的数据 X。
  4. 存储控制器将读取到的数据 X 放到存储总线上,随后 IO bridge 将存储总线上的数据信号转换为系统总线上的数据信号,然后继续沿着系统总线传递。
  5. CPU 芯片感受到系统总线上的数据信号,将数据从系统总线上读取出来并拷贝到寄存器中。

4)如何根据物理内存地址从主存中读取数据

前边介绍到,当主存中的存储控制器感受到了存储总线上的地址信号时,会将内存地址从存储总线上读取出来。

DRAM芯片访问.png

我们知道一个 supercell 存储了一个字节( 8 bit ) 数据,这里我们从 DRAM0 到 DRAM7 依次读取到了 8 个 supercell 也就是 8 个字节,然后将这 8 个字节返回给存储控制器,由存储控制器将数据放到存储总线上。

CPU 总是以 word size 为单位从内存中读取数据,在 64 位处理器中的 word size 为 8 个字节。64 位的内存每次只能吞吐 8 个字节。

CPU 每次会向内存读写一个 cache line 大小的数据( 64 个字节),但是内存一次只能吞吐 8 个字节。

所以在物理内存地址对应的存储器模块中,DRAM0 芯片存储第一个低位字节( supercell ),DRAM1 芯片存储第二个字节,......依次类推 DRAM7 芯片存储最后一个高位字节。

由于存储器模块中这种由 8 个 DRAM 芯片组成的物理存储结构的限制,内存读取数据只能是按照物理内存地址,8 个字节 8 个字节地顺序读取数据。所以说内存一次读取和写入的单位是 8 个字节。

而且在程序员眼里连续的物理内存地址实际上在物理上是不连续的。因为这连续的 8 个字节其实是存储于不同的 DRAM 芯片上的。每个 DRAM 芯片存储一个字节(supercell)

5)CPU 向内存写入数据过程

我们现在假设 CPU 要将寄存器中的数据 X 写到物理内存地址 A 中。同样的道理,CPU 芯片中的总线接口会向总线发起写事务(write transaction)。写事务步骤如下:

  1. CPU 将要写入的物理内存地址 A 放入系统总线上。
  2. 通过 IO bridge 的信号转换,将物理内存地址 A 传递到存储总线上。
  3. 存储控制器感受到存储总线上的地址信号,将物理内存地址 A 从存储总线上读取出来,并等待数据的到达。
  4. CPU 将寄存器中的数据拷贝到系统总线上,通过 IO bridge 的信号转换,将数据传递到存储总线上。
  5. 存储控制器感受到存储总线上的数据信号,将数据从存储总线上读取出来。
  6. 存储控制器通过内存地址 A 定位到具体的存储器模块,最后将数据写入存储器模块中的 8 个 DRAM 芯片中。