JUC并发编程第三章(共享模型之内存)


JUC并发编程 共享模型之内存

上一章讲解的 Monitor 主要关注的是访问共享变量时,保证临界区代码的原子性,这一章我们进一步深入学习共享变量在多线程间的【可见性】问题与多条指令执行时的【有序性】问题。

1.1 Java 内存模型

Java内存模型(Java Memory ModelJMM)是一种规范,用于定义Java程序中多线程之间如何访问共享内存区域以及如何进行同步。JMM确保多线程程序在访问共享变量时保持一致的可见性、有序性和原子性。在多核处理器和多线程环境下,JMM定义了线程之间如何进行通信以及数据如何在内存中存储。

以下是JMM的一些关键概念和规则:

  1. 主内存(Main Memory): 主内存是所有线程共享的内存区域,存储了所有的共享变量。

  2. 线程本地内存(Thread Local Memory): 每个线程都有自己的本地内存,存储了共享变量的副本。线程本地内存中的变量值在读写时不直接与主内存交互,而是先从主内存读取到本地内存,然后再进行操作,最后同步回主内存。

  3. 共享变量(Shared Variables): 共享变量是多个线程可以访问的变量,包括实例字段、静态字段和数组元素。

  4. 可见性(Visibility): JMM确保一个线程对共享变量的修改对其他线程可见。当一个线程修改了共享变量的值,必须确保其他线程在之后的操作中能够看到这个变化。

  5. 有序性(Ordering): JMM定义了不同操作之间的执行顺序。对于非volatile变量的读写操作可能会发生重排序,但JMM保证了特定情况下的顺序性,例如volatile变量的读写操作不会被重排序。

  6. 原子性(Atomicity): JMM保证一些特定操作的原子性,例如对volatile变量的读写操作都是原子的。

  7. happens-before关系: happens-before关系是JMM中的一个重要概念,它定义了操作之间的顺序。如果一个操作A happens-before另一个操作B,那么操作A在时间上发生在操作B之前。

  8. volatile关键字: 使用volatile修饰的变量具有可见性和有序性的保证。对一个volatile变量的写操作会立即刷新到主内存,对volatile变量的读操作会从主内存中读取最新的值。

  9. synchronized关键字: 使用synchronized关键字可以创建一个互斥区域,确保同一时间只有一个线程可以进入这个区域。synchronized块内的操作具有原子性和可见性。

  10. final关键字: 对于final字段的写入操作在构造对象完成后立即对其他线程可见,这也是一种保证可见性的机制。

JMM 体现在以下几个方面

  • 原子性 - 保证指令不会受到线程上下文切换的影响
  • 可见性 - 保证指令不会受 cpu 缓存的影响
  • 有序性 - 保证指令不会受cpu 指令并行优化的影响

1.2 可见性

1.2.1 退不出的循环

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
        while(run){
            // ....
        }
    });
    t.start();
    Thread.sleep(1000);
    log.debug("t 线程是否停止");
    run = false; // 线程t不会如预想的停下来
}

这段代码可能不会如预期地停止线程 t。原因是在多线程环境下,线程 t 可能无法立即感知到 run 变量的修改,从而无法正常退出循环。

这是因为没有使用任何同步机制(如 volatile 或者 synchronized)来确保线程之间的可见性。在不使用同步的情况下,主线程修改了 run 变量的值,但是线程 t 可能在自己的工作内存中缓存了 run 变量的值,并且没有及时刷新到主内存。这样,线程 t 就会继续循环,不会注意到 run 变量已经被修改。

要解决这个问题,你可以将 run 变量声明为 volatile,这会确保线程之间的可见性,即时刷新到主内存:

public class Main {
    private static volatile boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (run) {
                // ....
            }
        });
        t.start();
        Thread.sleep(1000);
        System.out.println("t 线程是否停止");
        run = false; // 线程 t 现在应该会停下来
    }
}

使用 volatile 关键字可以保证在一个线程修改了 run 变量后,其他线程会立即看到这个变化,从而能够正常退出循环。

为什么呢?分析一下:

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。

  1. 因为 t 线程要频繁从主内存中读取 run 的值,JIT编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。

  2. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

1.2.2 解决方法

volatile(易变关键字)

它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。

volatile 是Java中的一个关键字,用于修饰变量,主要用于确保变量在多线程环境下的可见性和有序性。当一个变量被声明为 volatile,它具有以下特性:

  1. 可见性(Visibility): 当一个线程修改了一个 volatile 变量的值,其他线程会立即看到这个修改。这是因为 volatile 变量的修改会立即刷新到主内存,并且其他线程在读取该变量时会从主内存中获取最新值,而不是从线程自己的缓存中获取。

  2. 有序性(Ordering): 在多线程环境下,volatile 变量的读写操作都不会被重排序,这意味着 volatile 变量的操作在程序中会按照预期的顺序执行,而不会受到编译器或处理器的优化影响。

使用 volatile 关键字的一些注意事项和使用场景:

  • volatile 关键字适用于那些被多个线程访问但不涉及复合操作(例如递增或递减操作)的变量。对于更复杂的操作,可能需要使用其他的同步机制,如 synchronized 或者 Lock

  • volatile 关键字不提供原子性。虽然它确保了可见性和有序性,但是它不能保证多个操作之间的原子性。如果需要保证原子性,还需要使用其他的同步机制。

  • volatile 关键字不能替代锁。虽然它可以用于简单的情况,但在更复杂的并发控制场景中,仍然需要使用锁或其他同步机制。

  • volatile 关键字适用于状态标志等变量,例如控制线程的启动、停止等。

  • volatile 变量的读写操作开销相对较低,因为它不涉及线程的阻塞和解锁等操作。

对于此处,还可以使用synchronized关键字来解决,不过相比于volatile来说,synchronized的开销就更大了

static boolean run = true;
 final static Object lock = new Object();
 public static void main(String[] args) throws InterruptedException {
     Thread t = new Thread(()->{
         while(true){
             synchronized (log){ //使用synchronized关键字也能解决
                 if (!run){
                     break;
                 }
             }
         }
     });
     t.start();
     Thread.sleep(10000);
     log.debug("t 线程是否停止");
     run = false; // 线程t不会如预想的停下来
 }

1.2.3 可见性 vs 原子性

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况: 上例从字节码理解是这样的:

getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
putstatic run // 线程 main 修改 run 为 false, 仅此一次
getstatic run // 线程 t 获取 run false

比较一下之前我们将线程安全时举的例子:两个线程一个 i++ 一个 i-- ,只能保证看到最新值,不能解决指令交错

// 假设i的初始值为0
getstatic i // 线程2-获取静态变量i的值 线程内i=0

getstatic i // 线程1-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1

iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1

注意

synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized 是属于重量级操作,性能相对更低

如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?

其实你查看源代码便知道为什么了。

这个就是原因

1.2.4 同步模式之 Balking

在并发编程中,”Balking” 是一种同步模式,用于在满足一定条件时避免执行操作。这个模式的核心思想是,如果在检查某些条件后发现操作不必执行,就会立即返回,从而避免了不必要的工作。这种模式可以用于防止不必要的并发操作,从而提高性能并减少资源消耗。

“Balking” 模式通常涉及以下几个要素:

  1. Guard条件: 这是一个用于判断是否需要执行操作的条件。如果该条件不满足,操作就会避免执行。

  2. 操作执行: 当 guard 条件满足时,才会执行实际的操作。否则,操作会被跳过。

这个模式在多线程或多任务环境中特别有用,因为它可以避免多个线程同时执行相同的操作,从而减少资源竞争和并发问题。

以下是一个简单的示例,说明如何在 Java 中使用 “Balking” 模式来实现一个线程安全的计数器,只有当计数器的值达到某个特定值时,才执行一些操作:

public class BalkingExample {
    private int count = 0;
    private final int threshold = 5;

    public synchronized void increment() {
        if (count < threshold) {
            count++;
            System.out.println("Incremented count to: " + count);
        } else {
            System.out.println("Threshold reached. Not incrementing.");
        }
    }

    public static void main(String[] args) {
        BalkingExample example = new BalkingExample();
        
        // Simulate multiple threads trying to increment the counter
        for (int i = 0; i < 10; i++) {
            new Thread(() -> example.increment()).start();
        }
    }
}

在这个示例中,increment 方法使用了同步来保证线程安全性。只有当 count 小于 threshold 时,它才会执行递增操作,否则会跳过。

需要注意的是,”Balking” 模式适用于特定的场景,即只在满足一些条件时才执行操作。在其他情况下,它可能并不是最适合的同步模式。

1.3 有序性

JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码:

static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...;
j = ...;

可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是

i = ...;
j = ...;

也可以是

j = ...;
i = ...;

这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。

指令重排(Instruction Reordering)是计算机编译器和处理器为了优化程序性能而进行的一种优化技术。它的目标是通过重新排列指令的执行顺序来减少指令之间的等待时间,从而提高程序的整体执行效率。然而,这可能会导致一些问题,特别是在多线程编程中。

在Java中,由于多线程并发执行的特性,指令重排可能会引发一些隐患,导致多线程程序的行为变得不确定或者不符合预期。Java通过一些机制来解决这些问题,主要包括:

  1. 程序顺序规则(Program Order Rule): 在单线程环境中,所有操作按照程序中的顺序执行。这确保了代码按照开发者的意图执行,不会出现意外的重排。

  2. volatile关键字: 声明一个变量为volatile,可以防止编译器和处理器对该变量的指令重排。同时,使用volatile修饰的变量在写操作完成后会立即同步到主内存,读操作也会从主内存中读取最新值,从而保证了可见性。

  3. synchronized关键字和锁: 通过使用synchronized关键字或其他锁机制,可以保护临界区代码的执行顺序,防止多线程之间的指令重排。

  4. final关键字: 声明一个变量为final可以防止指令重排,因为final变量的值不能被修改,所以对它的读操作不能被重排到写操作之前。

  5. happens-before关系: Java定义了一组happens-before规则,规定了多线程环境中各种操作的执行顺序,以确保多线程程序的行为是可预测的。

1.4 volatile 原理

volatile 的底层实现原理是内存屏障Memory Barrier(Memory Fence)

  • 对 volatile 变量的写指令后会加入写屏障
  • 对 volatile 变量的读指令前会加入读屏障

1.4.1 如何保证可见性

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中

    public void actor2(I_Result r) {
        num = 2;
        ready = true; // ready 是 volatile 赋值带写屏障
        // 写屏障
    }
    

    上面代码ready变量是使用了volatile来修饰了的,所以num变量并不会重排到ready之后,并且ready变量之前的数据全部会被同步到主存中。

  • 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据

    public void actor1(I_Result r) {
        // 读屏障
        // ready 是 volatile 读取值带读屏障
        if(ready) {
            r.r1 = num + num;
            } else {
            r.r1 = 1;
        }
    }
    
sequenceDiagram
participant t1 as t1 线程1 
participant num as num=0
participant ready as volatile ready=false
participant t2 as t2 线程2
t1 ->> t1 : num=2
t1 ->> ready : ready=true
note over t1,ready : 写屏障
note over num,t2 : 读屏障
t2 ->> ready : 读取ready=true
t2 ->> num : 读取num=2

1.4.2 如何保证有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

    public void actor2(I_Result r) {
        num = 2;
        ready = true; // ready 是 volatile 赋值带写屏障
        // 写屏障
    }
    
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

    public void actor1(I_Result r) {
        // 读屏障
        // ready 是 volatile 读取值带读屏障
        if(ready) {
            r.r1 = num + num;
            } else {
            r.r1 = 1;
        }
    }
    

还是那句话,不能解决指令交错:

写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去

而有序性的保证也只是保证了本线程内相关代码不被重排序

1.5 happens-before

happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见:

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见

    static int x;
    static Object m = new Object();
    new Thread(()->{
        synchronized(m) {
            x = 10;
        }
    },"t1").start();
    new Thread(()->{
        synchronized(m) {
            System.out.println(x);
        }
    },"t2").start();
    

    对于synchronized代码块中的内容,当t1线程操作完之后,对于t2线程是可见的。

  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见

    volatile static int x;
    new Thread(()->{
        x = 10;
    },"t1").start();
    new Thread(()->{
        System.out.println(x);
    },"t2").start();
    
  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见

    static int x;
    x = 10;
    new Thread(()->{
        System.out.println(x);
    },"t2").start();
    
  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)

    static int x;
    Thread t1 = new Thread(()->{
        x = 10;
    },"t1");
    t1.start();
    t1.join();
    System.out.println(x);
    
  • 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)

    static int x;
    public static void main(String[] args) {
        Thread t2 = new Thread(()->{
            while(true) {
                if(Thread.currentThread().isInterrupted()) {
                    System.out.println(x);
                    break;
                }
            }
        },"t2");
        t2.start();
        new Thread(()->{
            sleep(1);
            x = 10;
            t2.interrupt();
        },"t1").start();
        while(!t2.isInterrupted()) {
            Thread.yield();
        }
        System.out.println(x);
    }
    
  • 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见

  • 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子

    volatile static int x;
    static int y;
    new Thread(()->{
        y = 10;
        x = 20;
    },"t1").start();
    new Thread(()->{
    // x=20 对 t2 可见, 同时 y=10 也对 t2 可见
        System.out.println(x);
    },"t2").start();
    

变量都是指成员变量或静态成员变量


文章作者: 念心卓
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 念心卓 !
  目录