Skip to content

极客时间 ——《Java并发编程实战》 02 | 内存模型:Java如何解决可见性和有序性问题 #37

@funnycoding

Description

@funnycoding

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

这三者在编程领域中属于**「共性问题」**,所有编程语言都会遇到。 而 Java 从诞生之初就支持多线程,也有针对这三者的解决方案,并且在编程语言领域中处于领先地位。

理解 Java 对于并发问题的解决方案,也有助于理解其他语言。

Java 通过 JMM 内存模型来解决 「可见性」 和 「有序性」 导致的问题。

什么是 JMM Java 内存模型

导致可见性的原因:缓存。

导致有序性的原因:编译优化——指令重排序。

直接解决这两个问题的方法 —— 禁用缓存和指令重排序。

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

所以合理的方案 —— 按需禁用缓存和指令重排序。

「Java 内存模型」 规范了 JVM 如何提供按需禁用缓存和编译优化的方法,这些方法包括 volatilesynchronizedfinal 这三个关键字,以及 「六项 Happens-Before 规则」 这些内容是本期的重点。

使用 volatile 的困惑

volatile 并不是 Java 的特产,在 C 语言中就已经存在了,它最原始的意义就是 「禁用 CPU 缓存」

例如使用 volatile 修饰变量 int x = 0 ,它的语义是:告诉编译器,对这个变量的读写 不使用 CPU 缓存,必须从内存中读取或写入。【《jcip》 对 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》 中没有提到的。

Happens-Before 规则

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

作者举了个例子: 就像2个人,虽然相隔很远,但是他们的想法能被互相所知晓。

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

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

1. 程序的顺序性规则

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

public class VolatileExample {
    int x = 0;
    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);
        }
    }
}

2. Volatile 变量规则

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

这条规则关联 规则3一起进行理解

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 中看到的 x 的值 是线程A 中设置的值 42

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

4. 管程中锁的规则

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

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

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

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

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

这是对应方法的字节码:

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

假设 x 的初始值是 10线程A 执行完代码块后 x 的值会变成 12,此时 锁被释放,线程B 进入代码块时,能够看到线程A 对 x 的操作,也就是 线程B 看到的 x 的值 是最终的 12

5. 线程 start() 规则

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

Thread B = new Thread() {() -> {
		// 线程B 被调用之前所有对共享变量的修改,在这里都能看到
		// 例子中 子线程看到的  var 值是被修改后的 77
}}
// 对共享变量的修改
var = 77
// 主线程启动子线程
B.start();

6. 线程 join() 规则

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

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

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

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

被忽视的 final

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

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

在 1.5 之后 Java 内存模型对 final 类型的变量重排序进行了约束,现在只要类的构造函数中没有 「逸出」 问题,就不会出问题。

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

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;
        source.registerListener(new EventListener() {
            public void onEvent(Object object) {
                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;
    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 等高级语言都开始支持 内存模型。

Happens-Before 是内存模型中比较晦涩的一个部分,它最初是在一篇 《Time,Clocks,and the Ordering of Events in a Distribute System》 的论文中被提出来的,其语义在 Java 中的本质就是可见性

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

课后精华留言:

有人兑 Happens-Before 做了补充:

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

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

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

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

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

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions