面试上岸篇之多线程并发编程

多线程的一些概念

线程与进程之间的关系

  • 进程:是操作系统分配系统资源的基本单位,是一个程序执行的实例,可以理解为正在执行的程序,一个程序包括多个进程
  • 线程:一个进程里面有多个线程,线程是进程内的执行单位,是进行系统调度的最小单位

举个例子:你可以把进程想象成一个工厂,线程就是工厂里的工人。每个工厂(进程)都有自己的资源和工作空间,工人(线程)在工厂内部共享这些资源来完成任务。如果一个工厂(进程)内的一个工人(线程)出了问题,可能会影响到同一工厂内的其他工人(线程),但是不会影响到其他工厂(进程)。

如何创建线程

  • 继承 Thread 类 :创建一个新的类,该类继承自 Thread 类,然后重写 run() 方法。你可以在 run() 方法中放入需要执行的代码。然后创建这个类的对象,并调用它的 start() 方法来启动线程。

    java 复制代码
     class MyThread extends Thread {
         public void run(){
             // 你的代码
         }
     }
     ​
     public class Main {
         public static void main(String[] args){
             MyThread myThread = new MyThread();
             myThread.start();
         }
     }
     ​
  • 实现 Runnable 接口 :创建一个新的类,该类实现 Runnable 接口,然后重写 run() 方法。你可以在 run() 方法中放入需要执行的代码。然后创建这个类的对象以及一个新的 Thread 对象,将你的类的对象作为参数传递给 Thread 对象的构造函数,最后调用 Thread 对象的 start() 方法来启动线程。

    java 复制代码
     class MyRunnable implements Runnable {
         public void run(){
             // 你的代码
         }
     }
     ​
     public class Main {
         public static void main(String[] args){
             MyRunnable myRunnable = new MyRunnable();
             Thread thread = new Thread(myRunnable);
             thread.start();
         }
     }
     ​
  • 实现Callable接口:可以抛出异常,需要借助FutureTask类,获取返回结果,支持泛型的返回值,可以使用isDone方法精准控制线程是否完成

    java 复制代码
     import java.util.concurrent.Callable;
     import java.util.concurrent.ExecutorService;
     import java.util.concurrent.Executors;
     import java.util.concurrent.Future;
     ​
     class MyCallable implements Callable<Integer> {
         public Integer call(){
             // 你的代码
             return 123;
         }
     }
     ​
     public class Main {
         public static void main(String[] args) throws Exception {
             MyCallable myCallable = new MyCallable();
             ExecutorService executorService = Executors.newFixedThreadPool(10);
             Future<Integer> future = executorService.submit(myCallable);
             Integer result = future.get();  // 这会阻塞,直到结果可用
             System.out.println(result);
             executorService.shutdown();
         }
     }
     ​
  • 使用 ExecutorService :可以使用 ExecutorService 创建一个线程池,然后提交实现了 Runnable 接口的对象或者 Callable 接口的对象给这个线程池。线程池会为你处理线程的生命周期。

    java 复制代码
     import java.util.concurrent.ExecutorService;
     import java.util.concurrent.Executors;
     ​
     class MyRunnable implements Runnable {
         public void run(){
             // 你的代码
         }
     }
     ​
     public class Main {
         public static void main(String[] args){
             MyRunnable myRunnable = new MyRunnable();
             ExecutorService executorService = Executors.newFixedThreadPool(10);
             executorService.submit(myRunnable);
             executorService.shutdown();
         }
     }

wait()和sleep()方法的区别

wait()是用于线程间通信的,而sleep()是用于短时间暂停当前线程 。更加明显的一个区别在于,当一个线程调用wait()方法的时候,会释放它锁持有的对象的管程和锁,但是调用sleep()方法的时候,不会释放他所持有的管程

线程状态

(Thread:new、runnable(就绪状态,运行状态)、BLOCKED,WAITING,TIMED_WAITING,TERMINATED)

创建状态、就绪状态、运行状态、阻塞状态、销毁/死亡状态

如何终止一个正在运行的线程

  • 使用 interrupt() 方法 :Java 的 Thread 类提供了一个 interrupt() 方法,可以用来中断一个线程。当一个线程被中断时,它的中断状态将被设置为 true。你可以在你的代码中检查这个状态,并决定是否停止执行。

    java 复制代码
     // 看一下代码片段
     Thread thread = new Thread(runnable);
     thread.start();
     ​
     // 在某个条件满足时,中断线程
     thread.interrupt();
     ​
     // 在线程的运行代码中,可以这样检查中断状态:
     public void run() {
       while (!Thread.currentThread().isInterrupted()) {
         // 执行任务
       }
     }
     ​
  • 使用 volatile 关键字 :使用一个 volatile 布尔字段来控制线程的运行。当想要停止线程时,可以将这个字段设置为 false

    java 复制代码
     // 以下是代码片段
     public class MyRunnable implements Runnable {
       private volatile boolean running = true;
     ​
       public void run() {
         while (running) {
           // 执行任务
         }
       }
     ​
       public void stopRunning() {
         running = false;
       }
     }
     ​
     // 然后,可以这样使用这个类:
     MyRunnable runnable = new MyRunnable();
     Thread thread = new Thread(runnable);
     thread.start();
     ​
     // 在某个条件满足时,停止线程
     runnable.stopRunning();
     ​
  • 使用线程池

    • shutdown() :这个方法会停止接收新的任务,但是经提交的任务会继续执行直到完成。当所有任务都完成后,线程池中的线程会退出。

      java 复制代码
       ExecutorService executorService = Executors.newFixedThreadPool(10);
       // 提交任务
       executorService.submit(task);
       // 停止接收新的任务,并在所有任务完成后关闭线程池
       executorService.shutdown();
    • shutdownNow() :这个方法会试图立即停止所有正在执行的任务,停止处理还在队列中的任务,并返回队列中等待的任务。这个方法尝试通过调用线程的 interrupt() 方法来停止线程,但是无法保证实际的停止。

      java 复制代码
       ExecutorService executorService = Executors.newFixedThreadPool(10);
       // 提交任务
       executorService.submit(task);
       // 立即停止所有任务,并返回队列中等待的任务
       List<Runnable> waitingTasks = executorService.shutdownNow();
       ​

      请注意,shutdownNow() 方法并不能保证能够立即停止所有的任务。它只是尝试通过调用每个线程的 interrupt() 方法来实现这一点。如果任务不响应中断,那么 shutdownNow() 方法可能无法停止这些任务。

      在使用线程池时,应该始终在不再需要线程池时关闭它,以避免资源泄露。你可以使用 try-finally 块来确保线程池总是被关闭:executorService.shutdown();

  • 避免使用 Thread.stop(),而是使用其他更安全的方式来停止线程,为什么这个不安全。此方法会立即停止线程,不管它是否正在执行一个重要的操作。例如线程正在执行 try-finally 块或者其他清理资源的代码,那么这些清理代码可能永远不会被执行。这可能会导致资源泄露。

什么是线程安全

线程安全是多线程编程中的一个重要概念。当多个线程访问一个对象或方法时,如果不需要考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象或方法就被认为是线程安全的。

也就是说,线程安全是关于如何在多线程环境中正确、有效地管理对共享资源的访问,以防止数据的不一致和数据污染。

举个例子

  • 假设我们有一个银行账户类,它有一个余额字段和一个提款方法。如果我们想要在多线程环境中使用这个类,我们需要确保提款方法是线程安全的。否则,可能会出现两个线程同时提款,导致余额变成负数的情况。

    java 复制代码
     public class BankAccount {
         private double balance;
     ​
         public BankAccount(double balance) {
             this.balance = balance;
         }
     ​
         public synchronized void withdraw(double amount) {
             if (balance >= amount) {
                 balance -= amount;
                 System.out.println("Withdrawal of " + amount + " succeeded.");
             } else {
                 System.out.println("Not enough balance for the withdrawal of " + amount);
             }
         }
     ​
         public double getBalance() {
             return balance;
         }
     }
     ​
  • 在这个例子中,withdraw 方法使用了 synchronized 关键字,这意味着在任何时刻,只有一个线程可以访问这个方法。如果一个线程正在执行这个方法,其他的线程必须等待,直到这个线程完成这个方法。这就保证了在多线程环境中,不会出现两个线程同时修改余额的情况。

如何解决线程安全问题

线程安全问题就是由于并发引起的数据不一致的问题,那么我们就从这里入手,既然是并发引起的那我就想办法让这些共享资源同时只能有一个线程拥有

解决多线程访问同一共享资源

  1. 使用synchronized关键字

静态方法:锁对象是类的Class对象

非静态方法:锁对象是this

修饰代码块:对某一段代码进行加锁控制,需要自己传入锁对象,并且要求锁对象是唯一的。

java 复制代码
 synchronized(object) {
   // 访问共享资源
 }
 public synchronized void method() {
   // 访问共享资源
 }
  1. 使用Lock接口下的实现类

是类,只能通过修饰代码块,显式的加锁和释放锁,底层通过java代码赖进行控制实现,Lock(),unLock();只能手动的添加,手动的释放。

java 复制代码
 ReentrantLock lock = new ReentrantLock();
 lock.lock();
 try {
   // 访问共享资源
 } finally {
   lock.unlock();
 }
 ​

请注意,虽然这些方法可以解决多线程访问共享资源的问题,但是如果不正确使用,可能会导致死锁或者资源竞争,所以需要谨慎使用。在设计多线程程序时,尽量减少对共享资源的访问,这是避免多线程问题的最佳实践。

volatile

volatile 是 Java 提供的最轻量级的同步机制。当一个字段被 volatile 修饰时,它可以确保所有线程都能看到这个变量的最新值。这就解决了所谓的可见性问题

Atomic

  • Atomic 类(如 AtomicInteger,AtomicLong 等)提供了一种在多线程环境中进行原子操作的方式。Atomic 类的方法可以保证在多线程环境中的原子性,即这些方法在一个线程中不会被其他线程中断。
  • Atomic 类的方法使用了一种称为 CAS(Compare and Swap,比较并交换)的技术。CAS 是一种无锁的技术,它在硬件层面上保证了操作的原子性

线程本地变量ThreadLocal

使用 ThreadLocal 线程本地变量也可以解决线程安全问题,它是给每个线程独自创建了一份属于自己的私有变量,不同的线程操作的是不同的变量,所以也不会存在非线程安全的问题

线程死锁

造成死锁的原因可以简单概括为:当前线程拥有其他线程需要的资源,当前线程等待其他线程已拥有的资源,都不放弃自己拥有的资源。

  • 如张三欠李四 100 块钱,李四欠王五 100 块钱,王五欠张三 100 块钱,张三叫李四还钱,李四说没钱,得等王五还了才能还你,王五说我也没钱,等张三还了才能还......如此就陷入了死循环了,在没有外界因素的作用下,这个就永远死循环了。如何解决以上问题呢
  • 外界因素打破平衡:现在出现一个好心人,好心人借了 100 块钱给张三,张三拿这 100 块钱还给李四,李四再把这 100 块钱还给王五,王五再还给张三,张三再还给好心人,这时候这个死循环就破解了。
  • 自身放弃自己的资源:张三说,我放弃这 100 块钱了,李四不用还了,李四说我也不要了,王五也不要了,这三人就又能愉快的玩耍了。

所以用专业的术语说,线程 A拥有资源 A,线程 B 拥有资源 B

  • 此时,两个线程的资源都不会自己主动释放资源,需要等到线程解释才释放资源
  • 线程 A 拥有资源 A,现在需要获取线程 B 拥有的资源 B,才能结束线程,释放资源
  • 线程 B 拥有资源 B,现在需要获取线程 A 拥有的资源 A,才能结束线程,释放资源
  • 此时两个线程陷入了死循环

总结造成死锁的四个条件

  1. 互斥条件:一个资源每次只能被一个线程使用。
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在未使用完之前,不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

如何解决死锁

针对上面死锁的四个条件,一一破解死锁

  1. 破坏互斥条件 :这意味着允许多个线程同时访问某一资源,但这种方法并不适用于所有资源。可能会造成线程安全问题
  2. 破坏请求与保持条件一次性申请所有的资源,这样就不存在部分资源的情况,但可能会导致资源的利用率不高。
  3. 破坏不剥夺条件:允许其他线程剥夺某线程的资源,但这可能导致程序的执行出现问题。
  4. 破坏循环等待条件 :实现资源的有序分配策略,避免循环等待。

根据上述几个总结,一般有几个方案

  • 主动释放资源:假如线程 A 拥有了资源 A,需要获取资源 B 的时候,发现无法获取锁,处于等待中,这里给一个等待时间,假如 50ms 之内无法获取资源 B 的锁,那么就主动释放自己手中的资源 A,等待下次 CPU 的调度
  • 固定枷锁的顺序:假如上面的资源 AB,我在线程 A 获取资源 A 之前,判断资源 B 是否已经有线程锁定了,如果没有则获取资源 A 的锁。同理,如果线程 B 想获取资源 B,则先判断资源 A 的锁在不在锁池,假如上面线程 A 已经把锁拿走了,那么此时线程 B 处于等待状态,等到线程 A 释放两个锁的资源,才能继续。

线程间的通信

指多个线程通过相互牵制,相互调度,即线程间的相互作用。

  • wait()、notify()和notifyAll() 必须在同步代码块中执行
  • wait():线程阻塞,并同时释放同步锁
  • notify():唤醒被wait() 的一个线程,如果有多个线程被wait(); 会唤醒一个优先级高的线程
  • notifyAll():唤醒所有被wait()的线程

并发编程

悲观锁

悲观锁是一种预防数据冲突的锁,它假设数据会发生冲突,因此在修改数据之前就会把数据锁住,然后再对数据进行读写。在它释放锁之前,任何人都不能对其数据进行操作。这种锁的特点是可以完全保证数据的独占性和正确性,但由于每次请求都会先对数据进行加锁,然后进行数据操作,最后再解锁,这个过程会造成消耗,所以性能不高。

乐观锁

乐观锁是对于数据冲突保持一种乐观态度,操作数据时不会对操作的数据进行加锁,只有到数据提交的时候才通过一种机制(CAS)来验证数据是否存在冲突。乐观锁的特点是可以一定程度的提高操作的性能,但在并发非常高的情况下,会导致大量的请求冲突,冲突导致大部分操作无功而返而浪费资源,所以在高并发的场景下,乐观锁的性能却反而不如悲观锁

总的来说,乐观锁和悲观锁各有优缺点,适用的场景也不同。如果需要非常高的响应速度,建议采用乐观锁方案,成功就执行,不成功就失败,不需要等待其他并发去释放锁。如果冲突频率非常高,建议采用悲观锁,保证成功率。

CAS

CAS(Compare and Swap)是一种并发编程中常用的原子操作,用于实现多线程环境下的同步。CAS操作包括三个核心的参数:

  • 主内存中存放的共享变量的值:V
  • 工作内存中共享变量的副本值,也叫预期值:A
  • 需要将共享变量更新到的最新值:B
  • 在CAS机制中,如果预期值A与主内存中的值V相等,那么就将主内存中的值V更新为B。如果A和V不相等,说明V值已经被其他线程改变,此时会重新读取V值(自旋),然后再进行比较和交换。

CAS机制是乐观锁的一种典型实现,它假设在更新数据的时候,其他线程争抢这个共享变量的概率非常小,所以更新数据的时候不会对共享数据加锁。但是在正式更新数据之前会检查数据是否被其他线程改变过,如果未被其他线程改变过就将共享变量更新成最新值,如果发现共享变量已经被其他线程更新过了,就重试,直到成功为止。

CAS带来的隐患

  • ABA问题:这是一个经典的问题,假设有两个线程A和B,A读取了共享变量的值为3,然后被挂起。此时,线程B将共享变量的值从3改为4,然后又改回3。接着,线程A恢复运行,执行CAS操作,发现共享变量的值仍然为3,于是CAS操作成功。但实际上,在这个过程中,共享变量的值已经被其他线程改变过了。

解决ABA问题的一种方法是使用版本号。每次变量更新的时候把变量的版本号加1,那么A-B-A就会变成A1-B2-A3。只要变量被某一线程修改过,该变量对应的版本号就会发生递增变化,从而解决了ABA问题。

  • 自旋开销问题:CAS操作是通过无限循环来获取数据的,如果在第一轮循环中,线程A获取地址里面的值被线程B修改了,那么线程A需要自旋,到下次循环才有可能机会执行。如果自旋一直不成功,将会一直占用CPU。

解决方法:破坏掉for死循环,当超过一定时间或者一定次数时,return退出。

  • 只能保证单个共享变量的原子操作:当需要对多个共享变量进行原子操作时,CAS就无法满足需求了。

解决方法:可以通过加锁来解决,或者将多个共享变量封装成一个对象来进行CAS操作。

那为什么要用CAS呢?

  • 如果每个共享变量都使用synchronized锁每次只会让一个线程去操作共享资源,那这个效率就会很慢
  • 而CAS相当于没有加锁,多个线程都可以直接操作共享资源,在实际去修改的时候才去判断能否修改成功,所以在很多的情况下会比synchronized锁要高效很多
  • 比如,对一个值进行累加,就没必要使用synchronized锁,使用juc包下的Atomic类就足以

Java 内存模型(JMM)

上面提到了几个东西,主内存、工作内存,这些是什么东西呢?

  • 主内存 :JMM规定所有的共享变量都存储在主内存中,包括实例变量和静态变量。这些变量可以被所有的线程访问。
  • 工作内存(栈空间、本地缓存)局部变量和方法参数并不存储在主内存中。它们存储在每个线程的工作内存中,也被称为栈空间。每个线程的工作内存是私有的,其他线程无法访问。

JMM是一种抽象的概念,并不真实存在,它描述的一组规则或者规范,通过这些规则、规范定义了程序中各个变量的访问方式。

JMM规定了Java程序中所有的共享变量都存储在主内存中,每个线程都有自己的本地缓存,线程对变量的读取和写入操作都必须在本地缓存和主内存之间进行同步,以保证数据的可见性、原子性和有序性。

JMM的主要目标是定义程序中各种变量(实例字段,静态字段和构成数组对象的元素)的访问规则,即在同一个线程中读取到的变量值是由哪些因素决定的,包括共享变量的读/写,线程同步和线程交互等。
如上图所示

  • JMM 定义了所有的共享变量都存放在主内存中,每个线程都有自己独有的本地缓存(栈空间),栈空间是不共享的
  • 当线程 A 要读取共享变量 count 时,是不会直接操作主内存中的共享变量的,会直接读取共享变量 count 并在工作内存中复制一个工作副本count=1
  • 此时线程 B 读取也是一样读取主内存 count=1,但当此时线程 B 读取了 count 并修改成 count=2,并且回写给了主内存,那此时线程 A 中的 count 是多少呢?
  • 想这个问题前,先了解一下 JMM 的三个特性

JMM 的三大特性

  • 可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
  • 原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  • 有序性:程序执行的顺序按照代码的先后顺序执行。

可见性

在介绍可见性之前,先了解一下几个缓存

  • 高速缓存(Cache) 是一种特殊的存储器,它位于CPU和主内存之间,用于存储频繁访问的数据和指令,以减少CPU访问主内存的时间。高速缓存通常由静态RAM(SRAM)构成,比动态RAM(DRAM)更快,但成本更高。高速缓存可以分为多级,如L1、L2、L3等,级别越小的缓存,越接近CPU,速度越快,但容量越小。
  • 存储缓冲区(Store Buffer) 是一种硬件结构,位于CPU和高速缓存之间,用于缓存CPU的写操作。当CPU执行写操作时,无论高速缓存是否命中,数据都会先写入存储缓冲区,然后由存储缓冲区以FIFO(先进先出)的顺序写入高速缓存。存储缓冲区的存在可以进一步降低内存写延迟。
  • 如上图有了高速缓存的存在以后,每个 CPU 的处理过程是,先将计算需要用到的数据缓存在 CPU 高速缓存中,在 CPU进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中。在整个运算过程完成后,再把缓存中的数据同步到主内存
  • 由于在多 CPU 中,每个线程可能会运行在不同的 CPU 内,并且每个线程拥有自己的高速缓存(L1、L2)。同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题,那么怎么解决缓存一致性问题呢?
  • CPU层面提供了两种解决方法:总线锁缓存锁

总线锁和缓存锁都是为了解决多处理器环境下的内存一致性问题,但是它们的工作方式和开销有所不同

总线锁

当一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,使得其他处理器无法通过总线来访问到共享内存中的数据。这样,该处理器就可以独占共享内存。但是,总线锁定会阻止被阻塞处理器和所有内存之间的通信,而输出LOCK#信号的CPU可能只需要锁住特定的一块内存区域,因此总线锁定的开销较大。

缓存锁

缓存锁定是某个CPU对缓存数据进行更改时,会通知缓存了该数据的CPU抛弃缓存的数据或者从内存重新读取。这种机制是基于缓存一致性协议来实现的,一个处理器的缓存回写到内存会导致其他处理器的缓存无效

MESI协议

MESI协议是经典的缓存一致性协议,它通过在每个缓存行要求设置两个状态位,使得每个缓存行处于Modified(修改),Exclusive(独占),Shared(共享)和Invalid(无效)四个状态之一。当一个CPU读取缓存行数据时,如果缓存行状态为I,则需要从内存重新读取,并把自己状态改为S。当CPU需要写数据时,只有缓存行状态为M或者E时才可以执行。

  • Modified(修改):此状态的缓存行是由于相应的CPU进行了写操作,同时也表示该数据不会存在于其他CPU的缓存中。这意味着这个最新的数据目前是被执行写操作的CPU缓存行独占的。
  • Exclusive(独占):此状态和Modified状态类似,区别是CPU还没有修改缓存行中的数据。在Exclusive状态下CPU可以不通知其他CPU而直接对缓存行进行操作。
  • Shared(共享):此状态的缓存行,其数据可能在一个或者是多个CPU缓存中,此时CPU不能直接修改缓存行中的数据,而是需要先通知其他CPU缓存。
  • Invalid(无效):此状态时缓存行是空的。

举个例子

假设我们有两个CPU,CPU A和CPU B,它们都有一个共享变量count的副本,初始状态下,count的值为0,且在两个CPU的缓存中都处于Shared状态。

  1. CPU A要对变量i进行写操作,将count的值修改为1。

  2. CPU A会向总线发出BusUpgr信号,表示它要更新缓存上的数据。

  3. CPU B在接收到总线上的BusUpgr信号后,会主动将自己的状态变为Invalid,表示它的副本已经失效,并返回确定收到 ack

  4. CPU A收到 ack信号后,将自己的状态变为Modified,表示它现在独占了变量count的副本。

  5. CPU B发出读取count的请求。由于CPU B的缓存中count的状态是Invalid(无效),所以它需要从主内存或者其他CPU的缓存中获取count的值。

  6. CPU B在总线上发出BusRd(总线读)信号,请求读取count的值。

  7. CPU A检测到了地址冲突,因为它的缓存中x的状态是Modified(修改)。这时,CPU A会将count的值写回主内存,并将其缓存中count的状态改为Shared(共享)。

  8. CPU B从总线上或者主内存中读取到count的值,然后将其缓存中x的状态改为Shared(共享)。

Store Buffer

MESI协议可知,CPU 修改缓存的数据时,会先想其他 CPU 发出修改通知,这期间 CPU 则会等待其他CPU 返回的 ack,此时 CPU 就一直处于阻塞状态,所以想要提高性能,则引用Store Buffer中间缓存

  • 有了Store Buffer之后,处理器可以先将数据写入Store Buffer,然后CPU继续执行其他操作,等到收到其他处理器的响应后,再将数据从Store Buffer写入高速缓存。这样就可以避免因等待其他处理器的响应而导致的性能下降。
  • 然而,引入Store Buffer也会带来一些问题,比如可能会导致数据的可见性问题指令的重排序问题。为了解决这些问题,硬件工程师在Store Buffer的基础上,又实现了"Store Forwarding"技术,store forwarding 就是当 CPU 执行读操作时,会从 store buffer 和 cache 中读取数据, 如果 store buffer 中有数据会使用 store buffer 中的数据,这样就解决了同一个 CPU 中数据不一致的问题。

指令重排

指令重排是计算机编译器或处理器为了提高性能而对指令执行顺序进行的一种优化手段。在多核和多线程的计算机系统中,指令重排的目标是通过优化执行顺序来提高指令级别的并行度,充分发挥计算资源,加速程序的执行。

  • 假设你正在准备一顿大餐,你需要完成以下三个任务:洗菜、切菜和炒菜。在理想情况下,你会按照这个顺序来完成任务,因为这是最自然的顺序。
  • 但是,如果你有一个厨师助手可以帮你洗菜,那么你就可以在洗菜的同时切菜,这样可以更快地完成准备工作。这就像是指令重排,你改变了任务的执行顺序,以便更有效地使用资源
  • 但是,这并不意味着你可以随意地改变任务的顺序。例如,你不能在洗菜之前就开始炒菜,因为这样做就没有菜可以炒。这就像是内存屏障,它确保某些任务(指令)必须在其他任务之前完成。
  • 所以,指令重排就像是在准备一顿大餐时调整任务的顺序,以便更有效地使用资源。而内存屏障就像是一个规则,确保任务按照正确的顺序完成

指令重排的类型

  • 编译器优化的重排:编译器(包括 JVM、JIT 编译器等)出于优化的目的,例如当前有了数据 a,把对 a 的操作放到一起效率会更高,避免读取 b 后又返回来重新读取 a 的时间开销,此时在编译的过程中会进行一定程度的重排。
  • 指令集并行的重排:处理器采用了指令集并行技术来讲多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应的机器指令执行顺序。
  • 内存的"重排序":内存系统内不存在真正的重排序,但是内存会带来看上去和重排序一样的效果,所以这里的"重排序"打了双引号。由于内存有缓存的存在,在 JMM 里表现为主存和本地内存,而主存和本地内存的内容可能不一致,所以这也会导致程序表现出乱序的行为。

需要注意的是,重排序并不意味着可以任意排序,它需要保证重排序后,不改变单线程内的语义。在多线程环境下,由于编译器和处理器无法识别多个线程之间存在的数据依赖性,因此可能会出现问题。为了解决这个问题,Java提供了内存屏障来禁止指令重排。

内存屏障

内存屏障,也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。如上述例子,在炒菜指令执行之前的所有指令(洗菜、切菜)必须执行完成才能执行此指令。

为什么需要内存屏障

内存屏障主要解决两个问题:指令重排和可见性。

  • 指令重排:现代CPU为了提高性能,会采用乱序执行技术,即CPU可以不按照代码的顺序来执行指令。然而,这种技术可能会导致数据的状态在多线程中变得不可预测。内存屏障可以阻止特定操作的重排。
  • 可见性:在多处理器系统中,每个处理器都有自己的缓存(有的甚至L1,L2,L3),缓存的目的就是为了提高性能,避免每次都要向内存取。然而,这就可能导致一个处理器对内存的修改,其他处理器不能立即看到,即缺乏"可见性"。内存屏障可以强制将某个处理器的本地缓存刷新到主内存,或者使其他处理器的缓存无效,从而保证可见性。

内存屏障的分类

Java中的内存屏障主要有以下四种类型:

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,该屏障可以确保store1操作对其他处理器可见(刷新到内存)之后才能读取 Load2 的数据到缓存。

volatile

为什么volatile关键字在Java中可以保证可见性和有序性。其实原理非常简单,它的底层就是使用了内存屏障MESI缓存一致性协议

内存屏障:volatile关键字的读写操作都会添加内存屏障,防止指令重排序。这就保证了volatile变量的有序性。

java 复制代码
 volatile int a = 1;
 volatile int b = 2;
 ​
 int x = a;  // Load1
 // LoadLoad barrier 读读屏障 确保在读取 b 之前,必须先把 a 变量读取,确保这两个指令的有序性
 int y = b;  // Load2
 ​

MESI缓存一致性协议:volatile关键字的写操作会使得其他CPU中缓存了该变量的缓存行无效。当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。这就保证了volatile变量的可见性。

总结

所以讲到这里JMM的三大特性基本都清楚了吧,难点就是有序性跟可见性

  • 因为 CPU 会根据指令的类型或者是否存在数据依赖等情况将指令重排,执行的循序未必是代码写好的循序,这就可能会出现异常,所以引入了内存屏障,这个就可以让在同步点的指令之前的指令先执行完成,保证不改变单线程内的语义
  • 因为在 JMM 的内存模型中,所有的共享变量都会存放在主内存中,为了避免过多读取主内存中的数据,则将需要的共享变量通过主线主内存缓存到高速缓存中(L3 cache)。
  • 而每一个 CPU 都有独立的高速缓存(L1 cache、L2 cache),需要用到共享变量则将其从L3 cache 读取到各自的 cache 中,因为要确保数据的可见性,则如 CPU0 修改了数据,其他 CPU 中的缓存则知道数据已经被修改
  • 此时引入了总线锁,因为总线锁会将所有主内存到高速缓存的路阻塞,性能太低,有可能只需要锁住某个变量就行了,所以引入了缓存锁
  • 缓存锁即使用了MESI(缓存一致协议),如 CPU0 需要修改变量数据(如将 count=0 修改成 count=1),则先通过总线通知其他 CPU 他们手中的 count 变量将废弃,如果他们有缓存这个变量则将这个变量状态修改成Invalid,并返回 ack 确认收到
  • 此时 CPU0 收到 ack 之后,则修改其状态为Modified,因为在这等待 ack 的时间内,CPU0 是处于阻塞状态的,为了提高性能,此时又引用了 store buffer
  • 无论缓存中是否有数据,CPU 修改的数据都要放到 store buffer,则上述 CPU0 修改的 count=1 先放到 store buffer 中,此时 CPU0 可以继续执行其他指令,直到收到总线的 ack 之后,将数据回写到主内存
  • 所以此时其他 CPU 还想使用 count 这个变量时,发现已经是Invalid废弃状态了,所以将重新从主内存获取,这就是 JMM 的可见性原理
相关推荐
未秃头的程序猿4 小时前
Java 26正式发布!这3个新特性,让代码量直接减半
java·后端·面试
小旭Coding4 小时前
卧靠!Go 传给前端的 int64 竟然变成了这个?
后端
用户298698530144 小时前
Word 文档文本查找与替换的 Java 实现方案
java·后端
阿哉4 小时前
Nacos 服务发现源码:藏在背后的两套事件机制,90%的人只讲了一半
java
kunge20134 小时前
深度剖析Claude Code 的CLAUDE.md加载逻辑
后端·vibecoding
米沙AI4 小时前
MSYS2 快速使用版本
后端
Csvn5 小时前
Docker 进阶 — 网络模型、数据持久化与多阶段构建
后端
用户4279254051715 小时前
《微博开放平台官方CLI开源了:70+API一行搞定,AI Agent原生支持》
后端
Csvn5 小时前
文本处理三剑客 — grep、sed、awk 实战精讲
后端