可见性」、「原子性」、「有序性 导致的问题常常会违反我们的直觉,成为并发编程 Bug 的来源。

这三者在编程领域中属于 共性问题所有编程语言都会遇到。 而 Java诞生之初就支持多线程,也有针对这三者的解决方案,并且在编程语言领域中处于领先地位。理解 Java 对于并发问题的解决方案,也有助于理解其他语言。 Java 通过 JMM 内存模型 来解决 「可见性」「有序性」 导致的问题。

什么是 JMM 内存模型

Java 内存模型的定义: Java 内存模型是一个 规范 ,可以从不同的角度来解读。从应用程序开发人员的视角来看, Java 内存模型规范了 JVM 如何提供「按需禁用缓存和编译优化的方法。 其具体实现就是 volatilesynchronizedfinal 三个关键字以及 Happens-Before 规则。

  • 导致可见性问题的原因CPU 缓存

  • 导致有序性问题的原因编译优化——指令重排序

直接解决这两个问题的方法:

  • 禁用缓存和指令重排序。

但是这两个问题的背后都是对性能优化产生的问题,禁用了优化,性能也会降低

  • 合理的方案 按需禁用 缓存和指令重排序。
    这也就是 JMM 要做的工作,定义如何按需禁用缓存指令重排序

使用 volatile 的困惑

volatile 并不是 Java 的特产,在 C 语言中就已经存在了

  • volatile 最原始的意义就是 禁用 CPU 缓存

例如使用 volatile 修饰变量 int x = 0 ,它的 语义 是:

  • 告诉编译器,对这个变量的读写 不使用 CPU 缓存必须从内存中读取或写入

《Java 并发编程实战》那本书中对 volatile 的描述则是:

  • 当编译器读取到被 volatile 修饰的变量时,不再进行重排序
  • 同时 volatile 变量不会被缓存在 寄存器或者对其他处理器不可见的地方

例如下面的例子:

  • 假设 线程A 执行 write() 方法,按照 volatile 语义,将 v = true 写入内存。

  • 线程B 执行 reader() 方法,同样按照 volatile 语义,线程B内存中 读取变量 v,当 线程B 看到 v == true 时 ,线程B 看到的变量 x 的值 是多少?

直觉上看,应该是42。 实际上在 Java 1.5 之前,x 可能是42,也可能是0。 如果是在 『Java 1.5』 之后的版本,x 一定等于42。

public class VolatileExample {
    int x = 0;
    volatile boolean v = false;

    public void writer() {
        x = 42;
        v = true;
    }

    public  void reader() {
        if (v == true) {
            log.info("x = {}",x);
        }
    }
}

Java 1.5 版本之前,变量 x 可能 存储在 CPU 的寄存器 中导致可见性问题
在 Java 1.5 中,内存模型对 volatile 语义进行了增强 :

  • volatile 修饰的变量不会被缓存在 「寄存器」 或者 对其他处理器不可见的地方。

这里作者提到了 具体使用 「Happens-Before」 规则对 volatile 进行增强。 这个规则是 《jcip》 中没有提到的。 <--- 这个规则 jcip 提到了,不过是在书的最后一章中,所以是因为当时我还没有看到那个地方。

Happens-Before 规则

Happens-Before字面意思翻译是先行发生,但是真正的意义是 :「前一个操作的结果对后续操作是可见的

Happens-Before 规则比较正式的说法是: 约束了编译器优化行为,虽然允许编译器优化,但是要求编译器优化后遵守 Happens-Before 规则

【而 《jcip》 中的说法则是"编译器与运行时" 会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序"。 而这里作者说的是会进行编译器优化操作,但是遵守 Happens-Before 规则,且往下看 这个规则具体是什么】

Happens-Before规则 1:程序的顺序性规则

这条规则指:「在一个线程中,按照程序顺序前面的操作 Happens-Before 于后续的任意操作。」【也就是后续操作可以看到前面操作的结果。】

public class VolatileExample {
    int x = 0;
  	// 被 volatile 修饰的变量,编译器优化后要遵守 Happens-Before 规则
    volatile boolean v = false;

    public void writer() {
      	// 根据 happens-before 原则,修改 x 在修改 被 volatile 修饰的变量 v之前,所以 reader() 中可以看到对 x 的最新操作结果 x = 42
        x = 42;
        v = true;
    }

    public  void reader() {
        if (v == true) {
            log.info("x = {}",x);
        }
    }
}


Happens-Before规则 2:Volatile 变量规则

「对一个 volatile 变量的写操作Happens-Before 于后续对这个 volatile 变量的读操作。」

单看这个规则,理解起来就是禁用了缓存,但是这和 Java1.5 增强 volatile 语义之前好像就没有区别了?但是联合下面一个规则来看就不一样了:

Happens-Before规则 3:传递性

「如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C

从图中可以看出:

  1. x = 42 Happens-Before 写入 volatile 变量 v = true ,这是**「规则1」** 的内容。
  2. 写变量 v = true Happens-Before 读变量 v = true,这是 规则2 的内容。

再根据 传递性规则,得出结论 x = 42 Happens-Before 读变量 v = true 。这意味着 如果线程B 读到了变量 "v = true", 那么线程A设置的 "x=42"对线程B可见的,也就是说线程B 能看到 "x==42" 这个结果。

这就是 Java1.5 版本对 volatile 语义的增强,这个增强意义重大,1.5 新增的 JUC 并发包就是依靠 volatile 语义来解决 「可见性」 问题的。

Happens-Before规则 4. 管程中锁的规则

这条规则指「对一个锁的解锁 Happens-Before 于 后续对这个锁的加锁

「管程 」 是一种通用的**同步原语**,在 Java 中指的就是 synchronized 关键字,synchronizedJava管程的实现

管程中的锁在 Java 里是**隐式实现的,例如下面的代码,在进入同步代码块之前,会自动加锁,而在代码块执行完自动释放锁**。 这个加锁和释放锁的操作都是 编译器 帮我们实现的。

【读完这段话,理解了《jcip》 中说,内置锁 简化了锁的封装性,当时不理解这个简化的点在哪里,现在明白了 表面上只是加了一个关键字 synchronized 而背后的逻辑都是由编译器生成了加锁解锁的字节码。】

synchronized(this) { // 此处自动加锁
		if(this.x < 12) {
				this.x = 12;
		} 
} // 此处自动解锁

这是对应方法的字节码:

所以结合**「规则4」** —— 管程中所的规则,可以这样理解:

假设 x 的初始值是 10线程A 执行完代码块后 x 的值会变成 12,此时 锁被释放,线程B 进入代码块时,能够看到之前线程Ax 的操作,也就是 线程B 看到的 x 的值 是最终的值是 12。【这个规则也很好理解】

Happens-Before规则 5. 线程 start() 规则

线程A 调用 线程Bstart() 方法,那么该 start() 操作 Happens-Before 线程B 中的任意操作。」

Thread B = new Thread() {() -> {
		// 主线程调用B.start() 之前
		// 所有对共享变量的修改在这里都是可见的
    // 例如,在这个例子中,在这里访问 var 得到值是 77
}}
// 此处对共享变量var激进型修改
var = 77
// 主线程调用线程B的start方法,之前的 var=77 变量修改结果可以被子线程看到
B.start();

Happens-Before规则 6. 线程 join() 规则

这是一条关于线程等待的规则。主线程A 等待子线程B 完成(主线程A 通过调用 子线程Bjoin() 方法实现) 当子线程B 完成后(主线程A 中的 join() 方法返回) 主线程能够看到 子线程中的共享变量的最新值

如果在线程 A 中,调用 线程 Bjoin() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。

Thread B = new Thread(() -> {
		// 此处对共享变量 var 进行修改
		var = 66;
});
//此处对共享变量修改,则整个修改结果对线程 B可见 主线程启动子线程
B.start()
B.join()
// 子线程中所有对共享变量的修改 在主线程调用 B.join() 之后都是对主线程可见的
// 例如 此时主线程中获取 共享变量 var 的值,看到的就是被 子线程B 修改的最新值 66

【↑ 这六条 Happens-Before 规则,是对 volatile 的解释,讲的比 《jcip》 中第三章要详细一些,有收获】

被忽视的 final

【说实话,读了《jcip》 之后,才明白了封装与不可变在并发中的重要性》

final 修饰变量时最初的语义是告诉 」「编译器」,这个量生而不变,可以最大限度的优化。 但是 Java 编译器在 1.5 之前的重排序导致了一些并发问题的出现。

在 1.5 之后 Java 内存模型final 类型的变量重排序进行了约束,现在只要类的构造函数中没有 「逸出」 问题,就不会出问题。下面的例子类似之前利用双重检查创建单例时产生的错误,这里也是构造函数引用逸出导致不变量可能发生变化

这里作者对逸出的解释是:在构造函数中将 this 赋值给了 某个全局变量 global.obj ,线程通过 global.obj 这时读取 x 可能读到 为0 的值。

因此,一定要避免 「逸出」问题的发生。

// 类中定义的 不变量 x
final int x;

// 导致逸出问题发生的构造函数
public FinalFieldExample {
 		x = 3;
    y = 4;
  // 此处就是导致逸出问题发生的地方
  global.obj = this;
}

【这里的逸出指的是,别的类可以通过这个 global.obj 公共的变量来使用这个类,但是使用的这个 this 实例可能是一个没有完全构造完成的实例,这样就会存在问题。】

个人实践与总结: 构造函数中 this 引用 隐式逸出导致并发问题的实例

于是我去找了一个还比较贴切的 「构造函数 this 引用逸出」 的例子:

【**1、**定义一个接口 EventListener 没什么特别的,这个名字和方法都可以随便定义,我懒得改就用例子中的名字了】

public interface EventListener {
    public void onEvent(Object object);
}

2、写一个在构造函数中使用内部类的构造方法,由于 「内部类持有外部类的 this 引用」 所以这个方法会导致 this 的逸出。

/**
 * this 隐式逸出的发生地
 */
public class ThisEscape {
    public final int id;
    public final String name;

    public ThisEscape(EventSource<EventListener> source) {
        id = 1;
      	// 这里创建了一个匿名内部类,隐式持有了外部类 ThisEscape 的引用
        source.registerListener(new EventListener() {
            public void onEvent(Object object) {
              	// 打印 final 值 id 和 name,按理来说此时它们应该都是已经被赋值的
                System.out.println("id: " + ThisEscape.this.id);
                System.out.println("name: " + ThisEscape.this.name);
            }
        });

        try {
            Thread.sleep(1000); // 调用sleep模拟其他耗时的初始化操作
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        name = "Ahri";
    }
}

3、定义 相关的 EventSourceEventListener 对象

public class EventSource<T> {
    private final List<T> eventListeners;

    public EventSource() {
        eventListeners = new ArrayList<>();
    }

    public synchronized void registerListener(T eventListener) {
        this.eventListeners.add(eventListener);
        // 唤醒等待重新注册监听器的线程
        this.notifyAll();
    }

    /**
     * 重新注册 Listener
     *
     * @return
     * @throws InterruptedException
     */
    public synchronized List<T> retrieveListener() throws InterruptedException {
        List<T> dest = null;
        if (eventListeners.size() <= 0) {
            this.wait();
        }
        dest = new ArrayList<>(eventListeners.size());
        dest.addAll(eventListeners);
        return dest;
    }
}
public class ListenerRunnable implements Runnable {
    private EventSource<EventListener> source;

    public ListenerRunnable(EventSource<EventListener> source) {
        this.source = source;
    }

    @Override
    public void run() {
        List<EventListener> listeners = null;
        try {
            listeners = this.source.retrieveListener();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        for (EventListener listener : listeners) {
            listener.onEvent(new Object());
        }
    }
}


4、测试类 ThisEscapeTest

public class ThisEscapeTest {
    public static void main(String[] args) {
        EventSource<EventListener> source = new EventSource<>();
        ListenerRunnable listenerRunnable = new ListenerRunnable(source);
        Thread thread = new Thread(listenerRunnable);
        thread.start();
        ThisEscape thisEscape = new ThisEscape(source);
    }
}

这里如果直接测试的话,很难看到由于**线程切换** 导致 name 还没有被赋值,构造的对象还未完成就被发布的这种情况,一般都能正确打印,而并发问题的难点就在于隐蔽随机出现,所以我们增加了一个 sleep 模拟一些耗时的动作,这样问题出现的概率就很大了。

运行结果:

id: 1
name: null

可以看到这里当线程 sleep 之后,线程切换,导致 构造函数中的 name 还没有被赋值,该对象就被发布了。

解决方法:

这个方法也是 《jcip》 中说的,不要在构造函数中创建内部类,而是先定义一个引用,在构造函数中对引用进行赋值,同时将构造函数设为私有,通过一个公共的静态方法对外提供构造对象的功能。

改造后的线程安全的 SafeConstruct 类:

/**
 * 不会导致 this 隐式逸出的安全的类
 		1. 构造函数设为私有
 		2. 定义一个引用,将内部类对象赋值给引用
 */
public class SafeConstruct {
    public final int id;
    public final String name;
  	// 定义了一个 Listener 的引用,在构造函数中对这个引用进行赋值,而不是直接创建一个匿名内部类导致 外部类的引用逸出
    EventListener listener;

    private SafeConstruct() {
        id = 1;
        listener = new EventListener() {
            public void onEvent(Object object) {
                System.out.println("id: " + SafeConstruct.this.id);
                System.out.println("name: " + SafeConstruct.this.name);
            }
        };

        try {
            Thread.sleep(1000); // 调用sleep模拟其他耗时的初始化操作
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        name = "Ahri";
    }

    public static SafeConstruct getInstance(EventSource<EventListener> source) {
        SafeConstruct thisEscape = new SafeConstruct();
        source.registerListener(thisEscape.listener);
        return thisEscape;
    }
}


测试代码:

public class ThisEscapeTest {
    public static void main(String[] args) {
        EventSource<EventListener> source = new EventSource<>();
        ListenerRunnable listenerRunnable = new ListenerRunnable(source);
        Thread thread = new Thread(listenerRunnable);
        thread.start();
        SafeConstruct thisEscape = SafeConstruct.getInstance(source);
    }
}
/**
输出
id: 1
name: Ahri
*/

可以看到对象被正确的构造了。

IMG_8483

当时看书的时候最大的感慨就是例子没有真实的可测试可观察的代码,补上之后才感觉理解了这个知识点。

还有另一个常见的在 「构造过程」中使 this 引用的错误是:在构造函数中启动一个线程

这个例子还没有补充。

总结:

Java 内存模型是并发领域中的一次重要创新,再此之后 C++C#Golang 等高级语言都开始支持 内存模型

Java 内存模型中,最晦涩的部分就是Happens-Before规则 ,它最初是在一篇 《Time,Clocks,and the Ordering of Events in a Distribute System》 的论文中被提出来的,在这篇论文中,Happens-Before 的语义是一种因果关系,在现实世界中,如果 A事件是导致B事件的起因,那么 A事件一定在B事件之前发生。

这就是 Happens-Before 语义的现实理解。

在 Java 语言中, Happens-Before 语义的本质上是一种 「可见性」A Happens-Before B 意味着 A事件 对用户 B事件来说是可见的,无论 A事件和B事件是否发生在同一个线程中

例如 A事件 发生在 线程1 上, B事件 发生在 线程2Happens-Before 规则保证了 线程2 上也能看到 A事件的发生

Java 内存模型主要分为两部分,一部分是面向编写并发程序的应用开发人员,另一部分面向 JVM 的实现人员。

我们重点关注前者,也就是和编写并发程序相关的部分,这部分的内容核心就是 Happens-Before 规则

课后精华留言:

有人对 Happens-Before 做了**补充**:

线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

对象终结规则: 一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

感觉这些规则都是对 volatile 的具体描述

Java 内存模型的底层实现:通过 内存屏障memory barrier) 禁止重排序。「 即时编译器」根据具体的 「底层体系架构」 将这些内存屏障替换成具体的 CPU 指令。 对于编译器而言,内存屏障将限制它所能做的重排序优化。 对于处理器而言,内存屏障会导致缓存的刷新操作。

对于 volatile 编译器将在 volatile 字段的读写操作前后各插入一些内存屏障。 为了探究这一点 于是有了这篇文章 ↓

MacOS 环境下使用 hsdis 和 JIT Watch 查看汇编代码

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。