JMM模型理解

88 阅读8分钟

并发三大特性

原子性

一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器)。不采取任何的原子性保 障措施的自增操作并不是原子性的,比如i++操作。

原子性案例分析

下面例子模拟多线程累加操作

` public class AtomicTest {

private static int counter = 0;

public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
        Thread thread = new Thread(() -> {
            for (int j = 0; j < 10000; j++) {
                counter++;
            }

        });
        thread.start();
    }
    try {
        Thread.sleep(3000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println(counter);
}

} `

执行结果不确定, 与预期结果不符合,存在线程安全问题

image.png

如何保证原子性

  • 通过 synchronized 关键字保证原子性
  • 通过 Lock锁保证原子性
  • 通过 CAS保证原子性

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到 修改的值。

可见性案例分析

下面是模拟两个线程对共享变量操作的例子,用来分析线程间的可见性问题

` public class VisibilityTest {

private boolean flag = true;

public void refresh() {
    // 希望结束数据加载工作
    flag = false;
    System.out.println(Thread.currentThread().getName() + "修改flag:" + flag);

}

public void load() {
    System.out.println(Thread.currentThread().getName() + "开始执行.....");
    while (flag) {
        //TODO 业务逻辑:加载数据

    }
    System.out.println(Thread.currentThread().getName() + "数据加载完成,跳出循环");

}


public static void main(String[] args) throws InterruptedException {
    VisibilityTest test = new VisibilityTest();

    // 线程threadA模拟数据加载场景
    Thread threadA = new Thread(() -> test.load(), "threadA");
    threadA.start();

    // 让threadA先执行一会儿后再启动线程B
    Thread.sleep(1000);

    // 线程threadB通过修改flag控制threadA的执行时间,数据加载可以结束了
    Thread threadB = new Thread(() -> test.refresh(), "threadB");
    threadB.start();

}

} `

运行结果:threadA没有跳出循环,也就是说threadB对共享变量flag的更新操作对threadA不可见, 存在可见性问题。

如何保证可见性

  • 通过 volatile 关键字保证可见性
  • 通过 内存屏障保证可见性
  • 通过 synchronized 关键字保证可见性
  • 通过 Lock锁保证可见性

有序性

即程序执行的顺序按照代码的先后顺序执行。为了提升性能,编译器和处理器常常会对指令做重排 序,所以存在有序性问题。

有序性案例分析

思考:下面的Java程序中x和y的最终结果是什么?

` public class ReOrderTest {

private static int x = 0, y = 0;
private static int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
    int i = 0;
    while (true) {
        i++;
        x = 0;
        y = 0;
        a = 0;
        b = 0;
        /**
         * x,y的值是多少:
         */
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                //用于调整两个线程的执行顺序
                shortWait(20000);
                a = 1;

                x = b;
            }
        });
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                b = 1;
                y = a;
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("第" + i + "次(" + x + "," + y + ")");
        if (x == 0 && y == 0) {
            break;
        }
    }
}
public static void shortWait(long interval) {
    long start = System.nanoTime();
    long end;
    do {
        end = System.nanoTime();
    } while (start + interval >= end);
}

}

` 执行结果:x,y出现了0,0的结果,程序终止。出现这种结果有可能是重排序导致的

image.png

如何保证有序性

  • 通过 volatile 关键字保证有序性
  • 通过 内存屏障保证有序性
  • 通过 synchronized关键字保证有序性
  • 通过Lock锁保证有序性

JMM线程模型

在并发编程中,需要处理的两个关键问题:

1)多线程之间如何通信(线程之间以何种机制来交换数据)。

2)多线程之间如何同步 (控制不同线程间操作发生的相对顺序)。

线程之间常用的通信机制有两种:共享内存和消息传递,Java采用的是共享内存模型。

主内存与工作内存交互协议 lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。

unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用

load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。

write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

可见性案例深入分析

image.png

Java中可见性底层有两种实现:

  1. 内存屏障 (synchronized Threed.sleep(10) volatile)
  2. cup上下文切换 (Threed.yield() Threed.sleep(0) )

锁的内存语义

锁获取和释放的内存语义:

  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效
  • 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

synchronized关键字的作用是确保多个线程访问共享资源时的互斥性和可见性。在获取锁之前,线程 会将共享变量的最新值从主内存中读取到线程本地的缓存中,释放锁时会将修改后的共享变量的值刷 新到主内存中,以保证可见性。

volatile内存语义

volatile写的内存语义:

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

volatile读的内存语义:

当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共 享变量

volatile内存语义的实现原理

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的 编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

volatile禁止重排序规则

为了实现volatile的内存语义,JMM会限制编译器重排序,JMM针对编译器制定了volatile重排序规则 表。 由表中可以看出,volatile禁止重排序场景:

  1. 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。
  2. 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。
  3. 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

happens-before

happens-before的定义

JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线 程之内,也可以在不同的线程之内。因此,JMM可以通过happens-before关系向程序员提供跨线程 的内存可见性保证。

JSR-133规范对happens-before关系的定义如下:

1)如果一个操作happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而 且第一个操作的执行顺序排在第二个操作之前。 这是JMM对程序员的承诺, 注意,这只是JMM向程 序员做出的保证。

2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens￾before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果 一致,那么这种排序并不非法,也就是说,JMM允许这种排序。这是JMM对编译器和处理器重排序的 约束原则

JMM遵循一个基本原则:只要不改变程序的执行结果,编译器和处理器怎么优化都行。

  • as-if-serial语义保证单线程内程序的执行结果不被改变
  • happens-before关系保证正确同步的多线程程序的执行结果不被改变。

这么做的目的是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

happens-before规则

JSR-133规范定义了如下happens-before规则:

1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作;

2)锁定规则:对一个锁的解锁,happens-before于随后对这个锁的加锁;

3)volatile变量规则:对一个volatile变量的写操作,happens-before于任意后续对这个volatile变量 的读操作;

4)传递规则:如果A happens-before B,并且B happens-before C,则A happens-before C;

5)线程启动规则:如果线程A调用线程B的start()方法来启动线程B,则start()操作happens-before 于线程B中的任意操作;

6)线程中断规则:对线程interrupt()方法的调用happens-before于被中断线程的代码检测到中断事 件的发生;

7)线程终结规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作 happens-before于线程A从ThreadB.join()操作成功返回;

8)对象终结规则:一个对象的初始化完成happens-before于它的finalize()方法的开始。