博客记录-day031-锁分类和 JUC+Linux 物理内存管理

146 阅读38分钟

一、沉默王二-并发编程

1、锁分类和 JUC

来看看synchronized的不足之处吧。

  • 如果临界区是只读操作,其实可以多线程一起执行,但使用 synchronized 的话,同一时间只能有一个线程执行
  • synchronized 无法知道线程有没有成功获取到锁。
  • 使用 synchronized,如果临界区因为 IO 或者 sleep 方法等原因阻塞了,而当前线程又没有释放锁,就会导致所有线程等待

临界区(Critical Section)是多线程中一个 非常重要的概念,指的是在代码中访问共享资源的那部分,且同一时刻只能有一个线程能访问的代码。多个线程同时访问临界区的资源如果没有任何同步(加锁)操作,会导致资源的状态不可预测和不一致,从而产生所谓的“竞态条件”(Race Condition)。在许多并发控制策略中,例如互斥锁 synchronized,目标就是确保任何时候只有一个线程进入临界区。

不过,synchronized 的这些不足之处都可以通过 JUC 包下的其他锁来弥补,下面先来看一下锁的分类吧。

1.1 锁的几种分类

Java 提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。我们可以通过特性将锁进行分组归类。

1.1.1 乐观锁 VS 悲观锁

乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。

先说概念。对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java 中,synchronized 关键字是最典型的悲观锁

而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会加锁只是在更新数据的时候会去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

乐观锁在 Java 中是通过无锁编程来实现的,最常采用的是CAS 算法,Java 原子类的递增操作就通过 CAS 自旋实现的。

根据上面的概念描述我们可以发现:

  • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
  • 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

我们发现悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。那么,为何乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?我们这里再次来温习一下 “CAS” 的技术原理。

CAS 是一种无锁算法,可以在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。JUC 包中的原子类就是通过 CAS 实现的乐观锁。

CAS 算法涉及到三个操作数:

  • 需要读写的内存值 V。
  • 进行比较的值 A。
  • 要写入的新值 B。

当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。

1.1.2 自旋锁 VS 适应性自旋锁

塞或唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间。如果同代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复线程花费的时间可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃 CPU 的执行时间,看看持有锁的线程是否会很快释放锁。

为了让当前线程“稍等一下”,我们需要让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不用阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是 10 次,可以使用-XX:PreBlockSpin 来更改)没有成功获得锁,就应当挂起线程。

自旋锁的实现原理同样也是 CAS,AtomicInteger 中调用 unsafe 进行自增操作的源码中的 do-while 循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

1.1.3 可重入锁和非可重入锁

可重入锁又名递归锁,是指同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提:锁的是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。Java 中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点就是可以一定程度避免死锁。下面用示例代码来进行分析:

public class Widget {
    public synchronized void doSomething() {
        System.out.println("方法1执行...");
        doOthers();
    }

    public synchronized void doOthers() {
        System.out.println("方法2执行...");
    }
}

在上面的代码中,类中的两个方法都是被内置锁 synchronized 修饰的,doSomething()方法中调用了doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作

如果是一个不可重入锁,那么当前线程在调用doOthers()之前,需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已经被当前线程所持有,且无法释放。所以此时会出现死锁

那为什么可重入锁就可以在嵌套调用时自动获得锁呢?

还是打水的例子,有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。这就是可重入锁。

但如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。

之前我们说过 ReentrantLock 和 synchronized 都是重入锁,那么我们通过重入锁 ReentrantLock 以及非可重入锁 NonReentrantLock 的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁。

image.png

1.1.4 公平锁与非公平锁

这里的“公平”,其实通俗意义来说就是“先来后到”,也就是 FIFO。如果对一个锁来说,先对锁获取请求的线程一定会先被满足,后对锁获取请求的线程后被满足,那这个锁就是公平的。反之,那就是不公平的。

一般情况下,非公平锁能提升一定的效率。但是非公平锁可能会发生线程饥饿(有一些线程长时间得不到锁)的情况。所以要根据实际的需求来选择非公平锁和公平锁。

ReentrantLock 支持非公平锁和公平锁两种。

1.1.5 读写锁和排它锁

我们前面讲到的 synchronized和后面要讲的 ReentrantLock,其实都是“排它锁”。也就是说,这些锁在同一时刻只允许一个线程进行访问。

读写锁可以在同一时刻允许多个读线程访问。Java 提供了 ReentrantReadWriteLock类作为读写锁的默认实现,内部维护了两个锁:一个读锁,一个写锁。通过分离读锁和写锁,使得在“读多写少”的环境下,大大地提高了性能。

注意,即使用读写锁,在写线程访问时,所有的读线程和其它写线程均被阻塞

排它锁也叫独享锁,如果线程 T 对数据 A 加上排它锁后,则其他线程不能再对 A 加任何类型的锁。获得排它锁的线程既能读数据又能修改数据。

与之对应的,就是共享锁,指该锁可被多个线程所持有。如果线程 T 对数据 A 加上共享锁后,则其他线程只能对 A 再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

那读锁和写锁的具体加锁方式有什么区别呢?

在了解源码之前我们需要回顾一下其他知识。 在最开始提及 AQS的时候我们也提到了 state 字段(int 类型,32 位),该字段用来描述有多少线程持有锁。

在独享锁中,这个值通常是 0 或者 1如果是重入锁的话 state 值就是重入的次数),在共享锁中 state 就是持有锁的数量。但是在 ReentrantReadWriteLock 中有读、写两把锁,所以需要在一个整型变量 state 上分别描述读锁和写锁的数量(或者也可以叫状态)。

于是将state 变量“按位切割”切分成了两个部分,高 16 位表示读锁状态(读锁个数),低 16 位表示写锁状态(写锁个数)。

先看写锁的加锁源码:

protected final boolean tryAcquire(int acquires) {
	Thread current = Thread.currentThread();
	int c = getState(); // 取到当前锁的个数
	int w = exclusiveCount(c); // 取写锁的个数w
	if (c != 0) { // 如果已经有线程持有了锁(c!=0)
    // (Note: if c != 0 and w == 0 then shared count != 0)
		if (w == 0 || current != getExclusiveOwnerThread()) // 如果写线程数(w)为0(换言之存在读锁) 或者持有锁的线程不是当前线程就返回失败
			return false;
		if (w + exclusiveCount(acquires) > MAX_COUNT)    // 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
      throw new Error("Maximum lock count exceeded");
		// Reentrant acquire
    setState(c + acquires);
    return true;
  }
  if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) // 如果当且写线程数为0,并且当前线程需要阻塞那么就返回失败;或者如果通过CAS增加写线程数失败也返回失败。
		return false;
	setExclusiveOwnerThread(current); // 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者
	return true;
}
  • 这段代码首先取到当前锁的个数 c,然后再通过 c 来获取写锁的个数 w。因为写锁是低 16 位,所以取低 16 位的最大值与当前的 c 做与运算( int w = exclusiveCount(c); ),高 16 位和 0 与运算后是 0,剩下的就是低位运算的值,同时也是持有写锁的线程数目。
  • 取到写锁线程的数目后首先判断是否已经有线程持有了锁。如果已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,**如果写线程数为 0(即此时存在读锁)**或者持有锁的线程不是当前线程就返回失败(涉及到公平锁和非公平锁的实现)。
  • 如果写入锁的数量大于最大数(65535,2 的 16 次方-1)就抛出一个 Error。
  • 如果当前写线程数为 0(那么读线程也应该为 0,因为上面已经处理c!=0的情况),并且当前线程需要阻塞那么就返回失败;如果通过 CAS 增加写线程数失败也返回失败。
  • 如果 c=0,w=0 或者 c>0,w>0(重入),则设置当前线程或锁的拥有者,返回成功!

tryAcquire()除了重入条件(当前线程为获取写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作

因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。写锁的释放与 ReentrantLock 的释放过程基本类似,每次释放均减少写状态,当写状态为 0 时表示写锁已被释放,然后等待的读写线程才能够继续访问读写锁,同时前次写线程的修改对后续的读写线程可见。

接着是读锁的代码:

可以看到在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠 CAS 保证)增加读状态,成功获取读锁。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。所以读写锁才能实现读

众所周知,JDK 中关于并发的类大多都在 JUC 包下。读的过程共享,而读写、写读、写写的过程互斥。**

1.2 JUC 包下的锁

众所周知,JDK 中关于并发的类大多都在 JUC 包下。

1.2.1 抽象类 AQS/AQLS/AOS

这三个抽象类有一定的关系,所以这里放到一起讲。

首先我们来看AQS(AbstractQueuedSynchronizer),它是在 JDK 1.5 发布的,提供了一个“队列同步器”的基本功能实现。

AQS 里面的“资源”是用一个int类型的数据来表示的,有时候业务需求的资源数超出了int的范围,所以在 JDK 1.6 中,多了一个AQLS(AbstractQueuedLongSynchronizer)。它的代码跟 AQS 几乎一样,只是把资源的类型变成了long类型。

AQS 和 AQLS 都继承了一个类叫AOS(AbstractOwnableSynchronizer)。这个类也是在 JDK 1.6 中出现的。

这个类只有几行简单的代码。从源码类上的注释可以知道,它是用于表示锁与持有者之间的关系(独占模式)。可以看一下它的主要方法:

// 独占模式,锁的持有者
private transient Thread exclusiveOwnerThread;

// 设置锁持有者
protected final void setExclusiveOwnerThread(Thread t) {
    exclusiveOwnerThread = t;
}

// 获取锁的持有线程
protected final Thread getExclusiveOwnerThread() {
    return exclusiveOwnerThread;
}
1.2.2 接口 Condition/Lock/ReadWriteLock

locks 包下共有三个接口:ConditionLockReadWriteLock

其中,Lock 和 ReadWriteLock 从名字就可以看得出来,分别是锁和读写锁的意思。Lock 接口里面有一些获取锁和释放锁的方法声明,而 ReadWriteLock 里面只有两个方法,分别返回“读锁”和“写锁”:

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}

Lock 接口中有一个方法可以获得一个Condition:

Condition newCondition();

之前我们提到过每个对象都可以用Objectwait/notify方法来实现等待/通知机制。而 Condition 接口也提供了类似 Object 的方法,可以配合Lock来实现等待/通知模式。

既然有 Object 的监视器方法了,为什么还要用 Condition 呢?这里有一个简单的对比:

对比项Object 监视器Condition
前置条件获取对象的锁调用 Lock.lock 获取锁,调用 Lock.newCondition 获取 Condition 对象
调用方式直接调用,比如 object.notify()直接调用,比如 condition.await()
等待队列的个数一个多个
当前线程释放锁进入等待状态支持支持
当前线程释放锁进入等待状态,在等待状态中不中断不支持支持
当前线程释放锁并进入超时等待状态支持支持
当前线程释放锁并进入等待状态直到将来的某个时间不支持支持
唤醒等待队列中的一个线程支持支持
唤醒等待队列中的全部线程支持支持

Condition 和 Object 的 wait/notify 基本相似。其中,Condition 的 await 方法对应的是 Object 的 wait 方法,而 Condition 的signal/signalAll方法则对应 Object 的 notify/notifyAll()。但 Condition 类似于 Object 的等待/通知机制的加强版。我们来看看主要的方法:

方法名称描述
await()当前线程进入等待状态直到被通知(signal)或者中断;当前线程进入运行状态并从 await()方法返回的场景包括:(1)其他线程调用相同 Condition 对象的 signal/signalAll 方法,并且当前线程被唤醒;(2)其他线程调用 interrupt 方法中断当前线程;
awaitUninterruptibly()当前线程进入等待状态直到被通知,在此过程中对中断信号不敏感,不支持中断当前线程
awaitNanos(long)当前线程进入等待状态,直到被通知、中断或者超时。如果返回值小于等于 0,可以认定就是超时了
awaitUntil(Date)当前线程进入等待状态,直到被通知、中断或者超时。如果没到指定时间被通知,则返回 true,否则返回 false
signal()唤醒一个等待在 Condition 上的线程,被唤醒的线程在方法返回前必须获得与 Condition 对象关联的锁
signalAll()唤醒所有等待在 Condition 上的线程,能够从 await()等方法返回的线程必须先获得与 Condition 对象关联的锁
1.2.3 可重入锁ReentrantLock

ReentrantLock是 Lock 接口的默认实现,实现了锁的基本功能。

从名字上看,它是一个“可重入”锁,从源码上看,它内部有一个抽象类Sync,继承了 AQS,自己实现了一个同步器。

同时,ReentrantLock 内部有两个非抽象类NonfairSyncFairSync,它们都继承了 Sync。从名字上可以看得出,分别是”非公平同步器“和”公平同步器“的意思。这意味着 ReentrantLock 可以支持”公平锁“和”非公平锁“。

通过看这两个同步器的源码可以发现,它们的实现都是”独占“的。都调用了 AOS 的setExclusiveOwnerThread方法,所以 ReentrantLock 的锁是”独占“的,也就是说,它的锁都是”排他锁“,不能共享。

在 ReentrantLock 的构造方法里,可以传入一个boolean类型的参数,来指定它是否是一个公平锁,默认情况下是非公平的。这个参数一旦实例化后就不能修改,只能通过isFair()方法来查看。

1.2.4 读写锁ReentrantReadWriteLock

ReentrantReadWriteLock 是 ReadWriteLock 接口的默认实现。它与 ReentrantLock 的功能类似,同样是可重入的,支持非公平锁和公平锁。不同的是,它还支持”读写锁“。

ReentrantReadWriteLock 实现了读写锁,但它有一个小弊端,就是在“写”操作的时候,其它线程不能写也不能读。我们称这种现象为“写饥饿”,将在下文的 StampedLock 类继续讨论这个问题。

1.2.5 锁王StampedLock

StampedLock 类是 Java 8 才发布的,也是 Doug Lea 大神所写,有人称它为锁的性能之王。

StampedLock 没有实现 Lock 接口和 ReadWriteLock 接口,但它实现了“读写锁”的功能,并且性能比 ReentrantReadWriteLock 更高。StampedLock 还把读锁分为了“乐观读锁”和“悲观读锁”两种。

前面提到了 ReentrantReadWriteLock 会发生“写饥饿”的现象,但 StampedLock 不会。它是怎么做到的呢?

它的核心思想在于,在读的时候如果发生了写,应该通过重试的方式来获取新的值,而不应该阻塞写操作。这种模式也就是典型的无锁编程思想,和 CAS自旋的思想一样。这种操作方式决定了 StampedLock 在读线程非常多而写线程非常少的场景下非常适用,同时还避免了写饥饿情况的发生。

乐观读锁的意思就是先假定在这个锁获取期间,共享变量不会被改变,既然假定不会被改变,那就不需要上锁。

在获取乐观读锁之后进行了一些操作,然后又调用了 validate 方法,这个方法就是用来验证 tryOptimisticRead 之后,是否有写操作执行过,如果有,则获取一个悲观读锁,这里的悲观读锁和 ReentrantReadWriteLock 中的读锁类似,也是个共享锁。

1、可重入性:ReentrantReadWriteLock 支持可重入,即在一个线程中可以多次获取读锁或写锁。StampedLock 则不支持可重入。

2、乐观读锁:StampedLock 提供了乐观读锁机制,允许一个线程在没有任何写入操作发生的情况下读取数据,从而提高了性能。而 ReentrantReadWriteLock 没有提供这样的机制。

3、锁降级StampedLock 提供了从写锁到读锁的降级功能,这在某些场景下可以提供额外的灵活性。ReentrantReadWriteLock 不直接提供这样的功能。

4、API 复杂性:由于提供了乐观读锁和锁降级功能,StampedLock 的 API 相对复杂一些,需要更小心地使用以避免死锁和其他问题。ReentrantReadWriteLock 的 API 相对更直观和容易使用。

综上所述,StampedLock 提供了更高的性能和灵活性,但也带来了更复杂的使用方式。ReentrantReadWriteLock 则相对简单和直观,特别适用于没有高并发读的场景。

1.3 JUC 包下的其他工具类

locks 包下的锁接口和锁类介绍完了,我们这里再讲一些 JUC 包下的其他工具类,比如 Semaphore、CountDownLatch、CyclicBarrier、Exchanger、Phaser 等。

1.3.1 Semaphore

Semaphore 是一个计数信号量,它的作用是限制可以访问某些资源(物理或逻辑的)的线程数目。Semaphore 的构造方法可以指定信号量的数目,也可以指定是否是公平的。

Semaphore 有两个主要的方法:acquire()release()acquire()方法会尝试获取一个信号量,如果获取不到,就会阻塞当前线程,直到有线程释放信号量。release()方法会释放一个信号量,释放之后,会唤醒一个等待的线程。

Semaphore 还有一个tryAcquire()方法,它会尝试获取一个信号量,如果获取不到,就会返回 false,不会阻塞当前线程。

Semaphore 用来控制同时访问某个特定资源的操作数量,它并不保证线程安全,所以要保证线程安全,还需要加上同步锁。

1.3.2 CountDownLatch

CountDownLatch 是一个同步工具类,它允许一个或多个线程一直等待,直到其他线程的操作执行完后再执行。

CountDownLatch 有一个计数器,可以通过countDown()方法对计数器的数目进行减一操作,也可以通过await()方法来阻塞当前线程,直到计数器的值为 0。

CountDownLatch 一般用来控制线程等待,它可以让某个线程一直等待直到倒计时结束,再开始执行。

1.3.3 CyclicBarrier

CyclicBarrier 是一个同步工具类,它允许一组线程互相等待,直到到达某个公共屏障点(common barrier point)。

CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。比如我们用一个 Excel 保存了用户所有银行流水,每个 sheet 保存一个账户近一年的每笔银行流水,现在需要统计用户的日均银行流水,先用多线程处理每个 sheet 里的银行流水,都执行完之后,得到每个 sheet 的日均银行流水,最后,再用 barrierAction 用这些线程的计算结果,计算出整个 Excel 的日均银行流水。

CyclicBarrier 的计数器可以通过reset()方法重置,所以它能处理循环使用的场景。比如,我们将一个大任务分成 10 个小任务,用 10 个线程分别执行这 10 个小任务,当 10 个小任务都执行完之后,再合并这 10 个小任务的结果,这个时候就可以用 CyclicBarrier 来实现。

CyclicBarrier 还有一个有参构造方法,可以指定一个 Runnable,这个 Runnable 会在 CyclicBarrier 的计数器为 0 的时候执行,用来完成更复杂的任务。

1.3.4 Exchanger

Exchanger 是一个用于线程间协作的工具类。Exchanger 用于进行线程间的数据交换它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过 exchange 方法交换数据,如果第一个线程先执行 exchange 方法,它会一直等待第二个线程也执行 exchange 方法,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。

Exchanger 可以用于遗传算法、校对工作和数据同步等场景。

1.3.5 Phaser

Phaser 是一个同步工具类,它可以让多个线程在某个时刻一起完成任务

Phaser 可以理解为一个线程的计数器,它可以将这个计数器加一或减一。当这个计数器的值为 0 的时候,所有调用await()方法而在等待的线程就会继续执行。

Phaser 的计数器可以被动态地更新,也可以被动态地增加或减少。Phaser 还提供了一些方法来帮助我们更好地控制线程的到达。

二、小林-图解系统-Linux 物理内存管理

1、内核如何描述物理内存页

image.png

经过前边几个小节的介绍,我想大家现在应该对 Linux 内核整个内存管理框架有了一个总体上的认识。

如上图所示,在 NUMA 架构下内存被划分成了一个一个的内存节点(NUMA Node),在每个 NUMA 节点中,内核又根据节点内物理内存的功能用途不同,将 NUMA 节点内的物理内存划分为四个物理内存区域分别是:ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM。其中 ZONE_MOVABLE 区域是逻辑上的划分,主要是为了防止内存碎片和支持内存的热插拔。

物理内存区域中管理的就是物理内存页( Linux 内存管理的最小单位),前面我们介绍的内核对物理内存的换入,换出,回收,内存映射等操作的单位就是页。内核为每一个物理内存区域分配了一个伙伴系统,用于管理该物理内存区域下所有物理内存页面的分配和释放

Linux 默认支持的物理内存页大小为 4KB,在 64 位体系结构中还可以支持 8KB,有的处理器还可以支持 4MB,支持物理地址扩展 PAE 机制的处理器上还可以支持 2MB。

内核中的物理内存页有两种类型,分别用于不同的场景

  1. 一种是匿名页,匿名页背后并没有一个磁盘中的文件作为数据来源,匿名页中的数据都是通过进程运行过程中产生的,匿名页直接和进程虚拟地址空间建立映射供进程使用。
  2. 另外一种是文件页文件页中的数据来自于磁盘中的文件,文件页需要先关联一个磁盘中的文件,然后再和进程虚拟地址空间建立映射供进程使用,使得进程可以通过操作虚拟内存实现对文件的操作,这就是我们常说的内存文件映射。

1.1 匿名页的反向映射

我们通常所说的内存映射是正向映射,即从虚拟内存到物理内存的映射。而反向映射则是从物理内存到虚拟内存的映射用于当某个物理内存页需要进行回收或迁移时,此时需要去找到这个物理页被映射到了哪些进程的虚拟地址空间中,并断开它们之间的映射。

在没有反向映射的机制前,需要去遍历所有进程的虚拟地址空间中的映射页表,这个效率显然是很低下的。有了反向映射机制之后内核就可以直接找到该物理内存页到所有进程映射的虚拟地址空间 VMA ,并从 VMA 使用的进程页表中取消映射,

如下图所示,进程的虚拟内存空间在内核中使用 struct mm_struct 结构表示进程的虚拟内存空间包含了一段一段的虚拟内存区域 VMA,比如我们经常接触到的堆,栈。内核中使用 struct vm_area_struct 结构来描述这些虚拟内存区域。

image.png

根据前几篇文章的内容我们知道,进程利用 fork 系统调用创建子进程的时候,内核会将父进程的虚拟内存空间相关的内容拷贝到子进程的虚拟内存空间中,此时子进程的虚拟内存空间和父进程的虚拟内存空间是一模一样的,其中虚拟内存空间中映射的物理内存页也是一样的,在内核中都是同一份,在父进程和子进程之间共享(包括 anon_vma 和 anon_vma_chain)。

当进程在向内核申请内存的时候,内核首先会为进程申请的这块内存创建初始化一段虚拟内存区域 struct vm_area_struct 结构,但是并不会为其分配真正的物理内存。

当进程开始访问这段虚拟内存时,内核会产生缺页中断,在缺页中断处理函数中才会去真正的分配物理内存(这时才会为子进程创建自己的 anon_vma 和 anon_vma_chain),并建立虚拟内存与物理内存之间的映射关系(正向映射)。

如果当前物理内存页 struct page 是一个匿名页的话,那么 mapping 指针的最低位会被设置为 1 , 指向该匿名页在进程虚拟内存空间中的匿名映射区域 struct anon_vma 结构(每个匿名页对应唯一的 anon_vma 结构),用于物理内存到虚拟内存的反向映射。

如果当前物理内存页 struct page 是一个文件页的话,那么 mapping 指针的最低位会被设置为 0 ,指向该内存页关联文件的 struct address_space(页高速缓存)。pgoff_t index 字段表示该内存页 page 在页高速缓存中的 index 索引,也表示该内存页中的文件数据在文件内部的偏移 offset。偏移单位为 page size。

1.2 内存页回收相关属性

我们接着来看 struct page 中剩下的其他属性,我们知道物理内存页在内核中分为匿名页和文件页,我还提到过两个重要的链表分别为:active 链表和 inactive 链表。

其中active 链表用来存放访问非常频繁的内存页(热页), inactive 链表用来存放访问不怎么频繁的内存页(冷页),当内存紧张的时候,内核就会优先将 inactive 链表中的内存页置换出去。

内核在回收内存的时候,这两个列表中的回收优先级为:inactive 链表尾部 > inactive 链表头部 > active 链表尾部 > active 链表头部。

为什么会有 active 链表和 inactive 链表

内存回收的关键是如何实现一个高效的页面替换算法 PFRA (Page Frame Replacement Algorithm) ,提到页面替换算法大家可能立马会想到 LRU (Least-Recently-Used) 算法。

但是这里会有一个严重的问题,LRU 算法更多的是在时间维度上的考量,突出最近最少使用,但是它并没有考量到使用频率的影响,假设有这样一种状况,就是一个页面被疯狂频繁的使用,毫无疑问它肯定是一个热页,但是这个页面最近的一次访问时间离现在稍微久了一点点,此时进来大量的页面,这些页面的特点是只会使用一两次,以后将再也不会用到。

在这种情况下,根据 LRU 的语义这个之前频繁地被疯狂访问的页面就会被置换出去了(本来应该将这些大量一次性访问的页面置换出去的),当这个页面在不久之后要被访问时,此时已经不在内存中了,还需要在重新置换进来,造成性能的损耗。这种现象也叫 Page Thrashing(页面颠簸)。

因此,内核为了将页面使用频率这个重要的考量因素加入进来,于是就引入了 active 链表和 inactive 链表。工作原理如下:

  1. 首先 inactive 链表的尾部存放的是访问频率最低并且最少访问的页面,在内存紧张的时候,这些页面被置换出去的优先级是最大的。
  2. 对于文件页来说,当它被第一次读取的时候,内核会将它放置在 inactive 链表的头部,如果它继续被访问,则会提升至 active 链表的尾部。如果它没有继续被访问,则会随着新文件页的进入,内核会将它慢慢的推到 inactive 链表的尾部,如果此时再次被访问则会直接被提升到 active 链表的头部。大家可以看出此时页面的使用频率这个因素已经被考量了进来。
  3. 对于匿名页来说,当它被第一次读取的时候,内核会直接将它放置在 active 链表的尾部,注意不是 inactive 链表的头部,这里和文件页不同。因为匿名页的换出 Swap Out 成本会更大,内核会对匿名页更加优待。当匿名页再次被访问的时候就会被被提升到 active 链表的头部。
  4. 当遇到内存紧张的情况需要换页时,内核会从 active 链表的尾部开始扫描,将一定量的页面降级到 inactive 链表头部,这样一来原来位于 inactive 链表尾部的页面就会被置换出去。

内核在回收内存的时候,这两个列表中的回收优先级为:inactive 链表尾部 > inactive 链表头部 > active 链表尾部 > active 链表头部。

为什么会把 active 链表和 inactive 链表分成两类,一类是匿名页,一类是文件页

swappiness 用于表示 Swap 机制的积极程度,数值越大,Swap 的积极程度,越高越倾向于回收匿名页。数值越小,Swap 的积极程度越低,越倾向于回收文件页

因为回收匿名页和回收文件页的代价是不一样的,回收匿名页代价会更高一点,所以引入 swappiness 来控制内核回收的倾向。

注意: swappiness 只是表示 Swap 积极的程度,当内存非常紧张的时候,即使将 swappiness 设置为 0 ,也还是会发生 Swap 的。

假设我们现在只有 active 链表和 inactive 链表,不对这两个链表进行匿名页和文件页的归类,在需要页面置换的时候,内核会先从 active 链表尾部开始扫描,当 swappiness 被设置为 0 时,内核只会置换文件页,不会置换匿名页。

由于 active 链表和 inactive 链表没有进行物理页面类型的归类,所以链表中既会有匿名页也会有文件页,如果链表中有大量的匿名页的话,内核就会不断的跳过这些匿名页去寻找文件页,并将文件页替换出去,这样从性能上来说肯定是低效的。

因此内核将 active 链表和 inactive 链表按照匿名页和文件页进行了归类,当 swappiness 被设置为 0 时,内核只需要去 nr_zone_active_file 和 nr_zone_inactive_file 链表中扫描即可,提升了性能。

1.3 物理内存页属性和状态的标志位 flag

struct page {
    unsigned long flags;
} 

内核为了能够更灵活地管理粒度更小的连续物理内存,于是就此引入了 SPARSEMEM 稀疏内存模型。

image.png

SPARSEMEM 稀疏内存模型的核心思想就是提供对粒度更小的连续内存块进行精细的管理,用于管理连续内存块的单元被称作 section 。内核中用于描述 section 的数据结构是 struct mem_section。

由于 section 被用作管理小粒度的连续内存块,这些小的连续物理内存在 section 中也是通过数组的方式被组织管理(图中 struct page 类型的数组)。

每个 struct mem_section 结构体中有一个 section_mem_map 指针用于指向连续内存的 page 数组。而所有的 mem_section 也会被存放在一个全局的数组 mem_section 中。

那么给定一个具体的 struct page,在稀疏内存模型中内核如何定位到这个物理内存页到底属于哪个 mem_section 呢? 

在Linux内核的稀疏内存模型中,给定一个具体的struct page,内核定位这个物理内存页属于哪个mem_section的过程如下:

  1. 获取页帧号(PFN):首先,通过struct page获取对应的物理页帧号(PFN)。PFN是物理内存页的索引,表示页在物理内存中的位置。
  2. 转换成section号:在稀疏内存模型中,物理内存被划分为多个section。每个section包含多个连续的页帧。PFN可以用来计算它所属的section号。这通常通过将PFN除以每个section中的页帧数来实现。
  3. 定位mem_section:一旦有了section号,内核就可以直接访问mem_section数组来找到对应的struct mem_sectionmem_section数组是一个全局数组,其索引与section号相对应。
  4. 验证和访问:在定位到mem_section后,内核通常会进行一些验证,确保访问的section是有效的。之后,就可以通过这个mem_section来访问相关的内存管理数据结构,例如,页表项(PTE)或页全局目录(PGD)。

这个过程是Linux内核管理物理内存的一部分,特别是在处理大量内存和内存热插拔等高级特性时。稀疏内存模型允许内核更有效地管理分散的物理内存资源。

在 NUMA 架构下,物理内存被划分成了一个一个的内存节点(NUMA 节点),在每个 NUMA 节点内部又将其所管理的物理内存按照功能不同划分成了不同的内存区域 zone,每个内存区域管理一片用于特定具体功能的物理内存 page。

物理内存在内核中管理的层级关系为:None -> Zone -> page

image.png

那么在 NUMA 架构下,给定一个具体的 struct page,内核又该如何确定该物理内存页究竟属于哪个 NUMA 节点,属于哪块内存区域 zone 呢?

在NUMA(非一致性内存访问)架构下,给定一个具体的struct page,内核确定该物理内存页属于哪个NUMA节点和哪块内存区域(zone)的过程如下:

  1. 获取页帧号(PFN):首先,通过struct page获取对应的物理页帧号(PFN)。PFN是物理内存页的索引,表示页在物理内存中的位置。
  2. 通过PFN查找节点和区域:在NUMA系统中,每个物理内存页都关联到一个特定的NUMA节点和内存区域。内核使用PFN来查找这个关联。这通常涉及到内存管理的数据结构,如struct pglist_data(代表NUMA节点)和struct zone(代表内存区域)。
  3. 使用内存节点和区域数据结构:一旦确定了NUMA节点和内存区域,内核就可以使用这些信息来执行各种内存管理操作,如页分配、页回收等。NUMA节点和内存区域的信息对于优化内存访问和负载均衡非常重要。
  4. 考虑内存区域类型:Linux内核将内存划分为不同的区域(zone),如ZONE_DMA、ZONE_NORMAL、ZONE_HIGHMEM等。这些区域代表不同类型的内存,例如,ZONE_DMA通常包含可以直接访问的内存,而ZONE_HIGHMEM包含需要通过内核页表才能访问的内存。
  5. 访问和操作:确定了NUMA节点和内存区域后,内核就可以进行相应的内存访问和操作。例如,如果需要分配内存,内核会优先在当前处理器的NUMA节点上分配,以减少跨节点访问的开销。

1.4 复合页 compound_page 相关属性

我们都知道 Linux 管理内存的最小单位是 page,每个 page 描述 4K 大小的物理内存,但在一些对于内存敏感的使用场景中,用户往往期望使用一些巨型大页。

巨型大页就是通过两个或者多个物理上连续的内存页 page 组装成的一个比普通内存页 page 更大的页,

因为这些巨型页要比普通的 4K 内存页要大很多,所以遇到缺页中断的情况就会相对减少,由于减少了缺页中断所以性能会更高。

另外,由于巨型页比普通页要大,所以巨型页需要的页表项要比普通页要少,页表项里保存了虚拟内存地址与物理内存地址的映射关系,当 CPU 访问内存的时候需要频繁通过 MMU 访问页表项获取物理内存地址,由于要频繁访问,所以页表项一般会缓存在 TLB 中,因为巨型页需要的页表项较少,所以节约了 TLB 的空间同时降低了 TLB 缓存 MISS 的概率,从而加速了内存访问。

虽然巨型页(compound_page)是由多个物理上连续的普通 page 组成的,但是在内核的视角里它还是被当做一个特殊内存页来看待。

下图所示,是由 4 个连续的普通内存页 page 组成的一个 compound_page:

image.png

组成复合页的第一个 page 我们称之为首页(Head Page),其余的均称之为尾页(Tail Page)。