博客记录-day025-多线程入门、获取线程结果、线程状态与切换+swap机制、解决预读失效和缓存污染

80 阅读34分钟

一、沉默王二-并发编程

1、多线程入门

  • 进程,是对运行时程序的封装,是系统进行资源调度和分配的基本单位,实现了操作系统的并发。
  • 线程,是进程的子任务,是 CPU 调度和分派的基本单位,实现了进程内部的并发。

image.png

1)进程与线程

1、线程在进程下进行

2、进程之间不会相互影响,主线程结束将会导致整个进程结束

3、不同的进程数据很难共享

4、同进程下的不同线程之间数据很容易共享

5、进程使用内存地址可以限定使用量

2)创建线程的三种方式

1.继承 Thread 类

创建一个类继承 Thread 类,并重写 run 方法。

public class MyThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(getName() + ":打了" + i + "个小兵");
        }
    }
}
2.实现 Runnable 接口

创建一个类实现 Runnable 接口,并重写 run 方法。

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            try {// sleep方法可能会抛出InterruptedException异常,需要显示处理
                Thread.sleep(20);// 当前线程暂停20毫秒
            } catch (InterruptedException e) {
                e.printStackTrace();// 打印异常堆栈信息
            }
            // 输出当前线程的名称和它所"打"的小兵数量
            System.out.println(Thread.currentThread().getName() + "打了:" + i + "个小兵");
        }
    }
}
3.实现 Callable 接口

实现 Callable 接口,重写 call 方法,这种方式可以通过 FutureTask 获取任务执行的返回值。

public class CallerTask implements Callable<String> {
    // 实现Callable接口的call方法
    @Override
    public String call() throws Exception {
        return "Hello, i am running!"; // 返回一个字符串
    }

    public static void main(String[] args) {
        // 创建异步任务
        FutureTask<String> task = new FutureTask<String>(new CallerTask());
        // 启动线程
        new Thread(task).start();
        try {
            // 等待执行完成,并获取返回结果
            String result = task.get();
            System.out.println(result); // 打印结果
        } catch (InterruptedException e) { // 捕获中断异常
            e.printStackTrace();
        } catch (ExecutionException e) { // 捕获执行异常
            e.printStackTrace();
        }
    }
}

3)相关问题

1.为什么要重写 run 方法?

这是因为默认的run()方法不会做任何事情。

为了让线程执行一些实际的任务,我们需要提供自己的run()方法实现,这就需要重写run()方法。

public class MyThread extends Thread {
  @Override
  public void run() {
    System.out.println("MyThread running");
  }
}

在这个例子中,我们重写了run()方法,使其打印出一条消息。当我们创建并启动这个线程的实例时,它就会打印出这条消息。

2.run 方法和 start 方法有什么区别?
  • run()封装线程执行的代码,直接调用相当于调用普通方法。
  • start()启动线程,然后由 JVM 调用此线程的 run() 方法。
3.通过继承 Thread 的方法和实现 Runnable 接口的方式创建多线程,哪个好?

实现 Runable 接口好,原因有两个:

  • 避免了 Java 单继承的局限性,Java 不支持多重继承,因此如果我们的类已经继承了另一个类,就不能再继承 Thread 类了。
  • 适合多个相同的程序代码去处理同一资源的情况,把线程、代码和数据有效的分离,更符合面向对象的设计思想。Callable 接口与 Runnable 非常相似,但可以返回一个结果。

4)控制线程的其他方法

1.sleep()

使当前正在执行的线程暂停指定的毫秒数,也就是进入休眠的状态。

需要注意的是,sleep 的时候要对异常进行处理

try {//sleep会发生异常要显示处理
    Thread.sleep(20);//暂停20毫秒
} catch (InterruptedException e) {
    e.printStackTrace();
}
2.join()

等待这个线程执行完才会轮到后续线程得到 cpu 的执行权,使用这个也要捕获异常。

//创建MyRunnable类
MyRunnable mr = new MyRunnable();
//创建Thread类的有参构造,并设置线程名
Thread t1 = new Thread(mr, "张飞");
Thread t2 = new Thread(mr, "貂蝉");
Thread t3 = new Thread(mr, "吕布");
//启动线程
t1.start();
try {
    t1.join(); //等待t1执行完才会轮到t2,t3抢
} catch (InterruptedException e) {
    e.printStackTrace();
}
t2.start();
t3.start();

来看一下执行后的结果:

3.setDaemon()

将此线程标记为守护线程,准确来说,就是服务其他的线程,像 Java 中的垃圾回收线程,就是典型的守护线程。

//创建MyRunnable类
MyRunnable mr = new MyRunnable();
//创建Thread类的有参构造,并设置线程名
Thread t1 = new Thread(mr, "张飞");
Thread t2 = new Thread(mr, "貂蝉");
Thread t3 = new Thread(mr, "吕布");

t1.setDaemon(true);
t2.setDaemon(true);

//启动线程
t1.start();
t2.start();
t3.start();

如果其他线程都执行完毕,main 方法(主线程)也执行完毕,JVM 就会退出,也就是停止运行。如果 JVM 都停止运行了,守护线程自然也就停止了。

4.yield()

yield() 方法是一个静态方法,用于暗示当前线程愿意放弃其当前的时间片,允许其他线程执行。然而,它只是向线程调度器提出建议,调度器可能会忽略这个建议。具体行为取决于操作系统和 JVM的线程调度策略。

class YieldExample {
    public static void main(String[] args) {
        Thread thread1 = new Thread(YieldExample::printNumbers, "刘备");
        Thread thread2 = new Thread(YieldExample::printNumbers, "关羽");

        thread1.start();
        thread2.start();
    }

    private static void printNumbers() {
        for (int i = 1; i <= 5; i++) {
            System.out.println(Thread.currentThread().getName() + ": " + i);

            // 当 i 是偶数时,当前线程暂停执行
            if (i % 2 == 0) {
                System.out.println(Thread.currentThread().getName() + " 让出控制权...");
                Thread.yield();
            }
        }
    }
}

运行结果:

从这个结果可以看得出来,即便有时候让出了控制权,其他线程也不一定会执行。

2、获取 Java 线程执行结果

创建线程的 3 种方式,一种是直接继承 Thread,一种是实现 Runnable 接口,另外一种是实现 Callable 接口。前 2 种方式都有一个缺陷:在执行完任务之后无法获取执行结果

如果需要获取执行结果,就必须通过共享变量或者线程通信的方式来达到目的,这样使用起来就比较麻烦。Java 1.5 提供了 Callable、Future、FutureTask,它们可以在任务执行完后得到执行结果,今天我们就来详细的了解一下。

1)无返回值的 Runnable

由于 Runnable 的 run() 方法的返回值为 void:

public interface Runnable {
    public abstract void run();
}

所以在执行完任务之后无法返回任何结果。

2)有返回值的 Callable

Callable 位于 java.util.concurrent 包下,也是一个接口,它定义了一个 call() 方法:

public interface Callable<V> {
    V call() throws Exception;
}

可以看到,call() 方法返回的类型是一个 V 类型的泛型 那怎么使用 Callable 呢?

一般会配合 ExecutorService(后面在讲线程池的时候会细讲,这里记住就行)来使用。

ExecutorService 是一个接口,位于 java.util.concurrent 包下,它是 Java 线程池框架的核心接口,用来异步执行任务。它提供了一些关键方法用来进行线程管理。

3)异步计算结果 Future 接口

在前面的例子中,我们通过 Future 来获取 Callable 任务的执行结果,那么 Future 是什么呢?

Future 位于 java.util.concurrent 包下,它是一个接口:

/**
 * Future接口用于表示异步计算的结果。
 * 它提供了用于检查计算是否完成、等待其完成以及检索计算结果的方法。
 * 计算完成后,不能取消计算。
 */
public interface Future<V> {
    /**
     * 尝试取消此任务的执行。
     * 如果任务已经完成、已经被取消或由于某些其他原因不能取消,则此尝试将失败。
     * 如果成功,并且在调用cancel时此任务尚未启动,则此任务将永不运行。
     * 如果任务已经启动,则mayInterruptIfRunning参数确定是否应该中断执行此任务的线程以尝试停止任务。
     * 
     * @param mayInterruptIfRunning 如果应该中断执行此任务的线程,则为true;否则为false
     * @return 如果任务无法取消,则返回false;否则返回true
     */
    boolean cancel(boolean mayInterruptIfRunning);

    /**
     * 返回true如果此任务在正常完成之前被取消。
     * 
     * @return 如果任务在完成前被取消,则返回true
     */
    boolean isCancelled();

    /**
     * 返回true如果任务已完成。
     * 完成可能是由于正常终止、异常或取消——在所有这些情况下,此方法将返回true。
     * 
     * @return 如果任务已完成,则返回true
     */
    boolean isDone();

    /**
     * 等待如果必要,直到计算完成,然后检索其结果。
     * 
     * @return 计算结果
     * @throws InterruptedException 如果当前线程在等待时被中断
     * @throws ExecutionException 如果计算抛出异常
     */
    V get() throws InterruptedException, ExecutionException;

    /**
     * 如果必要,最多等待给定时间直到计算完成,然后检索其结果(如果可用)。
     * 
     * @param timeout 等待的最长时间
     * @param unit timeout参数的时间单位
     * @return 计算结果
     * @throws InterruptedException 如果当前线程在等待时被中断
     * @throws ExecutionException 如果计算抛出异常
     * @throws TimeoutException 如果等待超时
     */
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}

Future接口用于表示异步计算的结果,并提供了一系列方法来检查计算是否完成、等待其完成以及检索计算结果。

  • 通过调用cancel方法,可以尝试取消任务的执行,如果任务已经完成或已被取消,则取消尝试将失败。

  • isCancelled方法用于检查任务是否在完成前被取消。

  • isDone方法用于检查任务是否已完成,无论是因为正常终止、异常还是取消。

  • get方法用于等待计算完成并获取结果,而get方法的重载版本允许指定等待的超时时间,如果在指定时间内计算没有完成,则会抛出TimeoutException。

也就是说 Future 提供了三种功能:

  • 1)判断任务是否完成
  • 2)能够中断任务
  • 3)能够获取任务执行结果

由于 Future 只是一个接口,如果直接 new 的话,编译器是会有一个警告的,它会提醒我们最好使用 FutureTask。实际上,FutureTask 是 Future 接口的一个唯一实现类。

4)异步计算结果 FutureTask 实现类

我们来看一下 FutureTask 的实现:

public class FutureTask<V> implements RunnableFuture<V>

FutureTask 类实现了 RunnableFuture 接口,我们看一下 RunnableFuture 接口的实现:

public interface RunnableFuture<V> extends Runnable, Future<V> {
    void run();
}

可以看出 RunnableFuture 继承了 Runnable 接口和 Future 接口,而 FutureTask 实现了 RunnableFuture 接口。所以它既可以作为 Runnable 被线程执行,又可以作为 Future 得到 Callable 的返回值。

FutureTask 提供了 2 个构造器:

// FutureTask类的构造方法,接受一个Callable对象作为参数。
// Callable对象代表一个有返回值的异步计算任务。
public FutureTask(Callable<V> callable) {
}

// FutureTask类的另一个构造方法,接受一个Runnable对象和一个结果值作为参数。
// Runnable对象代表一个没有返回值的异步计算任务。
// 结果值将在计算任务完成后返回。
public FutureTask(Runnable runnable, V result) {
}

当需要异步执行一个计算并在稍后的某个时间点获取其结果时,就可以使用 FutureTask。

3、Java线程的6种状态及切换

我们先来看看操作系统中的线程状态转换。在操作系统中,线程被视为轻量级的进程,所以线程状态其实和进程状态是一致的

操作系统的线程主要有以下三个状态:

  • 就绪状态(ready):线程正在等待使用 CPU,经调度程序调用之后进入 running 状态。
  • 执行状态(running):线程正在使用 CPU。
  • 等待状态(waiting): 线程经过等待事件的调用或者正在等待其他资源(如 I/O)。

然后我们来看 Java 线程的 6 个状态:

// Thread.State 源码
// State枚举定义了线程可能处于的所有状态

public enum State {
    //NEW: 线程尚未启动时的状态
    NEW,
    
    // RUNNABLE: 线程正在Java虚拟机中执行的状态
    RUNNABLE,
    
    // BLOCKED: 线程被阻塞,等待监视器锁定的状态
    BLOCKED,
    
    // WAITING: 线程在等待另一个线程执行特定操作(如通知)的状态
    WAITING,
    
    // TIMED_WAITING: 线程在等待另一个线程执行特定操作,但有一定的等待时间的状态
    TIMED_WAITING,
    
    // TERMINATED: 线程执行完成的状态
    TERMINATED;
}

1)NEW

处于 NEW 状态的线程此时尚未启动。这里的尚未启动指的是还没调用 Thread 实例的start()方法。

private void testStateNew() {
    Thread thread = new Thread(() -> {});
    System.out.println(thread.getState()); // 输出 NEW
}

从上面可以看出,只是创建了线程而并没有调用 start 方法,此时线程处于 NEW 状态。

1.关于 start 的两个引申问题
  1. 反复调用同一个线程的 start 方法是否可行?
  • 都不行,在调用 start 之后,threadStatus 的值会改变(threadStatus !=0),再次调用 start 方法会抛出 IllegalThreadStateException 异常
  1. 假如一个线程执行完毕(此时处于 TERMINATED 状态),再次调用这个线程的 start 方法是否可行?
  • threadStatus 为 2 代表当前线程状态为 TERMINATED)。

2)RUNNABLE

表示当前线程正在运行中。处于 RUNNABLE 状态的线程在 Java 虚拟机中运行,也有可能在等待 CPU 分配资源。

我们来看看 Thread 源码里对 RUNNABLE 状态的定义:

/**
 * Thread state for a runnable thread.  A thread in the runnable
 * state is executing in the Java virtual machine but it may
 * be waiting for other resources from the operating system
 * such as processor.
 */
 /**
 * 可运行线程的线程状态。处于可运行状态的线程正在Java虚拟机中执行,
 * 但它可能正在等待操作系统提供的其他资源,如处理器。
 */

也就是说,Java 线程的RUNNABLE状态其实包括了操作系统线程的readyrunning两个状态。

3)BLOCKED

阻塞状态。处于 BLOCKED 状态的线程正等待锁的释放以进入同步区。

我们用 BLOCKED 状态举个生活中的例子:

假如今天你下班后准备去食堂吃饭。你来到食堂仅有的一个窗口,发现前面已经有个人在窗口前了,此时你必须得等前面的人从窗口离开才行。

假设你是线程 t2,你前面的那个人是线程 t1。此时 t1 占有了锁(食堂唯一的窗口),t2 正在等待锁的释放,所以此时 t2 就处于 BLOCKED 状态。

4)WAITING

等待状态。处于等待状态的线程变成 RUNNABLE 状态需要其他线程唤醒。

调用下面这 3 个方法会使线程进入等待状态

  • Object.wait():使当前线程处于等待状态直到另一个线程唤醒它;
  • Thread.join():等待线程执行完毕,底层调用的是 Object 的 wait 方法
  • LockSupport.park():除非获得调用许可,否则禁用当前线程进行线程调度。LockSupport我们在后面会细讲。

5)TIMED_WAITING

超时等待状态。线程等待一个具体的时间,时间到后会被自动唤醒。

调用如下方法会使线程进入超时等待状态:

  • Thread.sleep(long millis):使当前线程睡眠指定时间
  • Object.wait(long timeout)线程休眠指定时间,等待期间可以通过notify()/notifyAll()唤醒;
  • Thread.join(long millis):等待当前线程最多执行 millis 毫秒,如果 millis 为 0,则会一直执行;
  • LockSupport.parkNanos(long nanos): 除非获得调用许可,否则禁用当前线程进行线程调度指定时间;
  • LockSupport.parkUntil(long deadline):同上,也是禁止线程进行调度指定时间;

6)TERMINATED

终止状态。此时线程已执行完毕。

7)线程状态的转换

根据上面关于线程状态的介绍我们可以得到下面的线程状态转换图

1.BLOCKED 与 RUNNABLE 状态的转换

我们在上面说过:处于 BLOCKED 状态的线程在等待锁的释放。假如这里有两个线程 a 和 b,a 线程提前获得了锁并暂未释放锁,此时 b 就处于 BLOCKED 状态。

关于WAITING状态与RUNNABLE状态的转换,Object.wait()Thread.join()方法是关键。当线程调用对象的wait()方法时,它将释放当前持有的锁并进入waiting状态,直到另一个线程调用notify()或notifyAll()方法。join()方法也是进入waiting,使当前线程等待另一个线程执行完毕

这时你可能又会问了,要是我想要打印出 BLOCKED 状态我该怎么处理呢?

BLOCKED 状态的产生需要两个线程争夺锁才行。那我们处理下测试方法里的 main 线程就可以了,让它“休息一会儿”,调用一下Thread.sleep()方法。

在blockedTest中,通过在main线程中调用Thread.sleep(1000L),确保了线程a有足够的时间来获取锁并开始执行testMethod。因此,当线程b启动时,它将尝试获取已经被线程a持有的锁,从而进入BLOCKED状态

在这个例子中两个线程的状态转换如下:

  • a 的状态转换过程:RUNNABLE(a.start()) -> TIMED_WATING(Thread.sleep())->RUNABLE(sleep()时间到)->BLOCKED(未抢到锁)  -> TERMINATED
  • b 的状态转换过程:RUNNABLE(b.start()) -> BLOCKED(未抢到锁)  ->TERMINATED
2.WAITING 状态与 RUNNABLE 状态的转换

根据转换图我们知道有 3 个方法可以使线程从 RUNNABLE 状态转为 WAITING 状态。我们主要介绍下Object.wait()和Thread.join()

Object.wait()

调用wait()方法前线程必须持有对象的锁。

线程调用wait()方法时,会释放当前的锁,直到有其他线程调用notify()/notifyAll()方法唤醒等待锁的线程。

需要注意的是,其他线程调用notify()方法只会唤醒单个等待锁的线程,如有有多个线程都在等待这个锁的话不一定会唤醒到之前调用wait()方法的线程。

同样,调用notifyAll()方法唤醒所有等待锁的线程之后,也不一定会马上把时间片分给刚才放弃锁的那个线程,具体要看系统的调度。

Thread.join()

调用join()方法,会一直等待这个线程执行完毕(转换为 TERMINATED 状态)

我们再把上面的例子线程启动那里改变一下:

public void blockedTest() {
    ······
    a.start();
    a.join();
    b.start();
    System.out.println(a.getName() + ":" + a.getState()); // 输出 TERMINATED
    System.out.println(b.getName() + ":" + b.getState());
}

要是没有调用 join 方法,main 线程不管 a 线程是否执行完毕都会继续往下走

a 线程启动之后马上调用了 join 方法,这里 main 线程就会等到 a 线程执行完毕,所以这里 a 线程打印的状态固定是TERMINATED

至于 b 线程的状态,有可能打印 RUNNABLE(尚未进入同步方法),也有可能打印 TIMED_WAITING(进入了同步方法)。

3.TIMED_WAITING 与 RUNNABLE 状态转换

TIMED_WAITING 与 WAITING 状态类似,只是 TIMED_WAITING 状态等待的时间是指定的。

Thread.sleep(long)

使当前线程睡眠指定时间。需要注意这里的“睡眠”只是暂时使线程停止执行,并不会释放锁。时间到后,线程会重新进入 RUNNABLE 状态

Object.wait(long)

wait(long)方法使线程进入 TIMED_WAITING 状态。这里的wait(long)方法与无参方法 wait()相同的地方是,都可以通过其他线程调用notify()notifyAll()方法来唤醒。

不同的地方是,有参方法wait(long)就算其他线程不来唤醒它,经过指定时间 long 之后它会自动唤醒,拥有去争夺锁的资格。

Thread.join(long)

join(long)使当前线程执行指定时间,并且使线程进入 TIMED_WAITING 状态

我们再来改一改刚才的示例:

public void blockedTest() {
    ······
    a.start();
    a.join(1000L);
    b.start();
    System.out.println(a.getName() + ":" + a.getState()); // 输出 TIEMD_WAITING
    System.out.println(b.getName() + ":" + b.getState());
}

这里调用a.join(1000L),因为是指定了具体 a 线程执行的时间的,并且执行时间是小于 a 线程 sleep 的时间,所以 a 线程状态输出 TIMED_WAITING

b 线程状态仍然不固定(RUNNABLE 或 BLOCKED)。

8)线程中断

在某些情况下,我们在线程启动后发现并不需要它继续执行下去时,需要中断线程。目前在 Java 里还没有安全方法来直接停止线程,但是 Java 提供了线程中断机制来处理需要中断线程的情况。

线程中断机制是一种协作机制。需要注意,通过中断操作并不能直接终止一个线程,而是通知需要被中断的线程自行处理。

简单介绍下 Thread 类里提供的关于线程中断的几个方法:

  • Thread.interrupt():中断线程。这里的中断线程并不会立即停止线程,而是设置线程的中断状态为 true(默认是 flase);
  • Thread.isInterrupted():测试当前线程是否被中断。
  • Thread.interrupted():检测当前线程是否被中断,与 isInterrupted() 方法不同的是,这个方法如果发现当前线程被中断,会清除线程的中断状态。

在线程中断机制里,当其他线程通知需要被中断的线程后,线程中断的状态被设置为 true,但是具体被要求中断的线程要怎么处理,完全由被中断线程自己决定,可以在合适的时机中断请求,也可以完全不处理继续执行下去。

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

1、在 4GB 物理内存的机器上,申请 8G 内存会怎么样?

1)Swap 机制的作用

申请的虚拟内存超过物理内存后会怎么样?

  • 在 32 位操作系统,因为进程最大只能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。
  • 在 64 位操作系统,因为进程最大只能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。

程序申请的虚拟内存,如果没有被使用,它是不会占用物理空间的。当访问这块虚拟内存后,操作系统才会进行物理内存分配。

如果申请物理内存大小超过了空闲物理内存大小,就要看操作系统有没有开启 Swap 机制:

  • 如果没有开启 Swap 机制,程序就会直接 OOM
  • 如果有开启 Swap 机制,程序可以正常运行

什么是 Swap 机制?

当系统的物理内存不够用的时候,就需要将物理内存中的一部分空间释放出来,以供当前运行的程序使用。那些被释放的空间可能来自一些很长时间没有什么操作的程序,这些被释放的空间会被临时保存到磁盘,等到那些程序要运行时,再从磁盘中恢复保存的数据到内存中。

另外,当内存使用存在压力的时候,会开始触发内存回收行为,会把这些不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。

这种,将内存数据换出磁盘,又从磁盘中恢复数据到内存的过程,就是 Swap 机制负责的。

Swap 就是把一块磁盘空间或者本地文件,当成内存来使用,它包含换出和换入两个过程:

  • 换出(Swap Out)  ,是把进程暂时不用的内存数据存储到磁盘中,并释放这些数据占用的内存;
  • 换入(Swap In) ,是在进程再次访问这些内存的时候,把它们从磁盘读到内存中来;

Swap 换入换出的过程如下图:

使用 Swap 机制优点是,应用程序实际可以使用的内存空间将远远超过系统的物理内存。由于硬盘空间的价格远比内存要低,因此这种方式无疑是经济实惠的。当然,频繁地读写硬盘,会显著降低操作系统的运行速率,这也是 Swap 的弊端。

Linux 中的 Swap 机制会在内存不足和内存闲置的场景下触发:

  • 内存不足:当系统需要的内存超过了可用的物理内存时,内核会将内存中不常使用的内存页交换到磁盘上为当前进程让出内存,保证正在执行的进程的可用性,这个内存回收的过程是强制的直接内存回收(Direct Page Reclaim)。直接内存回收是同步的过程,会阻塞当前申请内存的进程。
  • 内存闲置:应用程序在启动阶段使用的大量内存在启动后往往都不会使用,通过后台运行的守护进程(kSwapd),我们可以将这部分只使用一次的内存交换到磁盘上为其他内存的申请预留空间。kSwapd 是 Linux 负责页面置换(Page replacement)的守护进程,它也是负责交换闲置内存的主要进程,它会在空闲内存低于一定水位 (opens new window)时,回收内存页中的空闲内存保证系统中的其他进程可以尽快获得申请的内存。kSwapd 是后台进程,所以回收内存的过程是异步的,不会阻塞当前申请内存的进程。

Linux 提供了两种不同的方法启用 Swap,分别是Swap 分区(Swap Partition)和 Swap 文件(Swapfile)

  • Swap 分区是硬盘上的独立区域,该区域只会用于交换分区,其他的文件不能存储在该区域上,我们可以使用 swapon -s 命令查看当前系统上的交换分区;
  • Swap 文件是文件系统中的特殊文件,它与文件系统中的其他文件也没有太多的区别;

Swap 换入换出的是什么类型的内存?

内核缓存的文件数据,因为都有对应的磁盘文件,所以在回收文件数据的时候, 直接写回到对应的文件就可以了。

但是像进程的堆、栈数据等,它们是没有实际载体,这部分内存被称为匿名页。而且这部分内存很可能还要再次被访问,所以不能直接释放内存,于是就需要有一个能保存匿名页的磁盘载体,这个载体就是 Swap 分区。

匿名页回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。

2、如何避免预读失效和缓存污染的问题?

传统的 LRU 算法法无法避免下面这两个问题:

  • 预读失效导致缓存命中率下降
  • 缓存污染导致缓存命中率下降

为了避免「预读失效」造成的影响,Linux 和 MySQL 对传统的 LRU 链表做了改进:

  • Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active list)和非活跃 LRU 链表(inactive list)
  • MySQL Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:young 区域 和 old 区域

但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么还存在缓存污染的问题

为了避免「缓存污染」造成的影响,Linux 操作系统和 MySQL Innodb 存储引擎分别提高了升级为热点数据的门槛:

  • Linux 操作系统:在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里。

  • MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行停留在 old 区域的时间判断

    • 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域;
    • 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就从 old 区域升级到 young 区域;

通过提高了进入 active list (或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。

1)Linux 和 MySQL 的缓存

1.Linux 操作系统的缓存

在应用程序读取文件的数据的时候,Linux 操作系统是会对读取的文件数据进行缓存的,会缓存在文件系统中的 Page Cache(如下图中的页缓存)。

Page Cache 属于内存空间里的数据,由于内存访问比磁盘访问快很多,在下一次访问相同的数据就不需要通过磁盘 I/O 了,命中缓存就直接返回数据即可。

因此,Page Cache 起到了加速访问数据的作用。

2.MySQL 的缓存

MySQL 的数据是存储在磁盘里的,为了提升数据库的读写性能,Innodb 存储引擎设计了一个缓冲池(Buffer Pool),Buffer Pool 属于内存空间里的数据。

有了缓冲池后:

  • 当读取数据时,如果数据存在于 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则再去磁盘中读取。
  • 当修改数据时,首先是修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页,最后由后台线程将脏页写入到磁盘。

2)传统 LRU 是如何管理内存数据的?

Linux 的 Page Cache 和 MySQL 的 Buffer Pool 的大小是有限的,并不能无限的缓存数据,对于一些频繁访问的数据我们希望可以一直留在内存中,而一些很少访问的数据希望可以在某些时机可以淘汰掉,从而保证内存不会因为满了而导致无法再缓存新的数据,同时还能保证常用数据留在内存中。

要实现这个,最容易想到的就是 LRU(Least recently used)算法。

LRU 算法一般是用「链表」作为数据结构来实现的,链表头部的数据是最近使用的,而链表末尾的数据是最久没被使用的。那么,当空间不够了,就淘汰最久没被使用的节点,也就是链表末尾的数据,从而腾出内存空间。

因为 Linux 的 Page Cache 和 MySQL 的 Buffer Pool 缓存的基本数据单位都是页(Page)单位,所以后续以「页」名称代替「数据」

传统的 LRU 算法的实现思路是这样的:

  • 当访问的页在内存里,就直接把该页对应的 LRU 链表节点移动到链表的头部
  • 当访问的页不在内存里,除了要把该页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的页。

传统的 LRU 算法并没有被 Linux 和 MySQL 使用,因为传统的 LRU 算法无法避免下面这两个问题:

  • 预读失效导致缓存命中率下降
  • 缓存污染导致缓存命中率下降

3)预读失效,怎么办?

1.什么是预读机制?

Linux 操作系统为基于 Page Cache 的读缓存机制提供预读机制,一个例子是:

  • 应用程序只想读取磁盘上文件 A 的 offset 为 0-3KB 范围内的数据,由于磁盘的基本读写单位为 block(4KB),于是操作系统至少会读 0-4KB 的内容,这恰好可以在一个 page 中装下。
  • 但是操作系统出于空间局部性原理(靠近当前被访问数据的数据,在未来很大概率会被访问到),会选择将磁盘块 offset [4KB,8KB)、[8KB,12KB) 以及 [12KB,16KB) 都加载到内存,于是额外在内存中申请了 3 个 page;

下图代表了操作系统的预读机制:

上图中,应用程序利用 read 系统调动读取 4KB 数据实际上内核使用预读机制(ReadaHead) 机制完成了 16KB 数据的读取,也就是通过一次磁盘顺序读将多个 Page 数据装入 Page Cache。

这样下次读取 4KB 数据后面的数据的时候,就不用从磁盘读取了,直接在 Page Cache 即可命中数据。因此,预读机制带来的好处就是减少了 磁盘 I/O 次数,提高系统磁盘 I/O 吞吐量

MySQL Innodb 存储引擎的 Buffer Pool 也有类似的预读机制,MySQL 从磁盘加载页时,会提前把它相邻的页一并加载进来,目的是为了减少磁盘 IO。

2.预读失效会带来什么问题?

如果这些被提前加载进来的页,并没有被访问,相当于这个预读工作是白做了,这个就是预读失效

如果使用传统的 LRU 算法,就会把「预读页」放到 LRU 链表头部,而当内存空间不够的时候,还需要把末尾的页淘汰掉。

如果这些「预读页」如果一直不会被访问到,就会出现一个很奇怪的问题,不会被访问的预读页却占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是热点数据,这样就大大降低了缓存命中率 。

3.如何避免预读失效造成的影响?

我们不能因为害怕预读失效,而将预读机制去掉,大部分情况下,空间局部性原理还是成立的。

要避免预读失效带来影响,最好就是让预读页停留在内存里的时间要尽可能的短,让真正被访问的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在内存里的时间尽可能长

那到底怎么才能避免呢?

Linux 操作系统和 MySQL Innodb 通过改进传统 LRU 链表来避免预读失效带来的影响,具体的改进分别如下:

  • Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)
  • MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:young 区域 和 old 区域

这两个改进方式,设计思想都是类似的,都是将数据分为了冷数据和热数据,然后分别进行 LRU 算法。不再像传统的 LRU 算法那样,所有数据都只用一个 LRU 算法管理。

接下来,具体聊聊 Linux 和 MySQL 是如何避免预读失效带来的影响?

Linux 是如何避免预读失效带来的影响?

Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)

  • active list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页
  • inactive list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页

有了这两个 LRU 链表后,预读页就只需要加入到 inactive list 区域的头部,当页被真正访问的时候,才将页插入 active list 的头部。如果预读的页一直没有被访问,就会从 inactive list 移除,这样就不会影响 active list 中的热点数据。

image.png

MySQL 是如何避免预读失效带来的影响?

MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域,young 区域 和 old 区域

young 区域在 LRU 链表的前半部分,old 区域则是在后半部分,这两个区域都有各自的头和尾节点,如下图:

young 区域与 old 区域在 LRU 链表中的占比关系并不是一比一的关系,而是 63:37(默认比例)的关系。

划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。

4)缓存污染,怎么办?

1.什么是缓存污染?

虽然 Linux (实现两个 LRU 链表)和 MySQL (划分两个区域)通过改进传统的 LRU 数据结构,避免了预读失效带来的影响。

但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么还存在缓存污染的问题

当我们在批量读取数据的时候,由于数据被访问了一次,这些大量数据都会被加入到「活跃 LRU 链表」里,然后之前缓存在活跃 LRU 链表(或者 young 区域)里的热点数据全部都被淘汰了,如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃 LRU 链表(或者 young 区域)就被污染了

2.缓存污染会带来什么问题?

缓存污染带来的影响就是很致命的,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,系统性能就会急剧下降。

我以 MySQL 举例子,Linux 发生缓存污染的现象也是类似。

当某一个 SQL 语句扫描了大量的数据时,在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool 里的所有页都替换出去,导致大量热数据被淘汰了,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,MySQL 性能就会急剧下降。

注意, 缓存污染并不只是查询语句查询出了大量的数据才出现的问题,即使查询出来的结果集很小,也会造成缓存污染。

可能这个查询出来的结果就几条记录,但是由于这条语句会发生索引失效,所以这个查询过程是全表扫描的,接着会发生如下的过程:

  • 从磁盘读到的页加入到 LRU 链表的 old 区域头部;
  • 当从页里读取行记录时,也就是页被访问的时候,就要将该页放到 young 区域头部
  • 接下来拿行记录的 name 字段和字符串 xiaolin 进行模糊匹配,如果符合条件,就加入到结果集里;
  • 如此往复,直到扫描完表中的所有记录。

经过这一番折腾,由于这条 SQL 语句访问的页非常多,每访问一个页,都会将其加入 young 区域头部,那么原本 young 区域的热点数据都会被替换掉,导致缓存命中率下降。那些在批量扫描时,而被加入到 young 区域的页,如果在很长一段时间都不会再被访问的话,那么就污染了 young 区域。

3.怎么避免缓存污染造成的影响?

前面的 LRU 算法只要数据被访问一次,就将数据加入活跃 LRU 链表(或者 young 区域),这种 LRU 算法进入活跃 LRU 链表的门槛太低了!正式因为门槛太低,才导致在发生缓存污染的时候,很容就将原本在活跃 LRU 链表里的热点数据淘汰了。

所以,只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉

Linux 操作系统和 MySQL Innodb 存储引擎分别是这样提高门槛的:

  • Linux 操作系统:在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里。

  • MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行停留在 old 区域的时间判断

    • 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域;
    • 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就从 old 区域升级到 young 区域;

提高了进入活跃 LRU 链表(或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。

在批量读取数据时候,如果这些大量数据只会被访问一次,那么它们就不会进入到活跃 LRU 链表(或者 young 区域) ,也就不会把热点数据淘汰,只会待在非活跃 LRU 链表(或者 old 区域)中,后续很快也会被淘汰。