29 What Is 'Happen Before' in the Java Memory Model

在Java内存模型中,happen-before(先行发生)是一种确保多线程程序中操作的可见性和顺序的规则。它定义了一组规则,用于确定线程中的操作是否可以被其他线程观察到,并且保证特定顺序的操作能够按照预期的顺序执行。

具体来说,如果一个操作happen-before另一个操作,那么第一个操作的结果对于第二个操作是可见的,并且第一个操作必须在第二个操作之前执行。

Java内存模型中的一些规则可以保证happen-before的关系,比如:

  • 对同一个锁解锁的操作happen-before对该锁的加锁操作。
  • 对volatile字段的写操作happen-before后续对该字段的读操作。
  • 线程的启动操作happen-before线程的任何操作。

通过这些规则,Java内存模型确保多线程程序的正确性和可预测性。程序员可以依赖happen-before的规则来避免竞态条件和数据不一致的问题。

29 What is ‘happen-before’ in the Java memory model #

The happen-before relationship is a mechanism in the Java memory model that ensures the visibility of operations in multithreaded operations, and it provides a precise definition of the vague visibility concept in early language specifications.

Its specific manifestations include much more than just the intuitive aspects of synchronized, volatile, lock operation order, such as:

  • Every operation executed within a thread guarantees that the operations that happen before it are visible. This ensures the basic program order rule, which is the basic convention for writing programs.
  • For a volatile variable, a write operation on it guarantees that the subsequent read operation on that variable happens after it.
  • For unlocking an object, the happen-before relationship guarantees that the lock operation happens before it.
  • When an object is constructed, the happen-before relationship guarantees that it happens before the beginning of the finalizer.
  • Even the completion of operations within a thread guarantees that they happen before other threads’ Thread.join() operations, and so on.

These happen-before relationships have transitivity. If a happens before b and b happens before c, then a happens before c.

I have been using the term “happen-before” instead of simply saying “before and after” because it provides not only guarantees about the order of execution but also guarantees about the order of memory read and write operations. Merely being in chronological order does not guarantee the visibility of thread interactions.

Analysis of the Focal Points #

Today’s question is a common one that tests the basic concepts of the Java Memory Model (JMM). The answer I provided earlier tried to select rules that are relevant to everyday development.

JMM is a hot topic in interviews and can be seen as a necessary condition for understanding Java concurrent programming, compilers, and JVM internal mechanisms in depth. However, it is also a topic that can easily confuse beginners. For learning JMM, I have some personal suggestions:

  • Clearly define your purpose and resist the temptation of technology. Unless you are a compiler or JVM engineer, I recommend against delving into various CPU architectures and getting caught up in different caches, pipelines, and execution units. While these things may be cool, their complexity is beyond imagination and may unnecessarily increase the difficulty of learning, without necessarily having practical value.
  • Resist the temptation of “cheats”. Sometimes, certain programming approaches may appear to have specific effects, but it is difficult to determine whether they are “performances” caused by implementation differences, or behaviors required by “specifications”. In such cases, do not rely on these “performances” for programming and try to follow the language specifications instead. This way, our application behavior can be more reliable and predictable.

In this lecture, taking into account both interviews and programming practices, I will cover the following two points using examples:

  • Why is JMM needed and what problems does it attempt to solve?
  • How does JMM solve various problems, such as visibility? How is it reflected in specific use cases, such as using volatile?

Note that in this column, the Java Memory Model refers specifically to the JMM specification redefined in JSR-133. In specific contexts, it may be confused with JVM (Java) memory structure, and there is no absolute right or wrong. However, it is crucial to understand the interviewer’s intention, as some interviewers may deliberately test if you are clear about the difference between these two concepts.

Knowledge Expansion #

Why do we need JMM and what problems does it attempt to solve?

Java was one of the first languages to attempt to provide a memory model. This was a significant leap in simplifying multi-threaded programming and ensuring program portability. In earlier languages like C and C++, there was no concept of a memory model (although C++11 introduced a standard memory model). The behavior of these languages depended on the memory consistency model of the processor itself[^1]. However, different processors could have significant differences. So a C++ program that runs correctly on processor A does not guarantee consistency when running on processor B.

Even so, the initial Java language specification still had its flaws. The goal at the time was to allow Java programs to fully utilize the computing power of modern hardware while maintaining the ability to “write once, run anywhere”.

However, it became clear that the complexity of the problem was underestimated. As Java was run on more and more platforms, it was discovered that the vague definition of the memory model had many ambiguous areas that did not provide clear specifications for behaviors such as instruction reordering during synchronized or volatile operations. Instruction reordering can be caused by compiler optimizations as well as modern processor features like out-of-order execution[^2].

In other words:

  • It cannot guarantee the correctness of certain multi-threaded programs, such as the famous Double-Checked Locking (DCL) problem, which can lead to accessing incompletely initialized objects. This is known as a failure of safe publication in concurrent programming[^3].
  • It cannot guarantee consistent behavior of the same program on different processor architectures. For example, some processors support cache coherence, while others do not, and they each have their own memory ordering models.

Therefore, Java urgently needed a comprehensive JMM that could bring clear consensus to ordinary Java developers, compilers, and JVM engineers. In other words, it should be relatively simple and accurate to determine which execution sequences of a multi-threaded program are compliant with the specification.

As a result:

  • Compiler and JVM developers may focus on how to use techniques like memory barriers to ensure that execution results conform to the JMM inferences.
  • Java application developers may focus more on the semantics of volatile, synchronized, etc., and how to write reliable multi-threaded applications using rules like happen-before, rather than using “cheats” to deceive compilers and JVMs.

I have created a simple role hierarchy diagram to illustrate the different responsibilities of different engineers. The JMM isolates the differences in memory ordering between different processors for Java engineers, which is why I usually do not recommend delving into processor architectures too early. In a sense, this contradicts the original intention of the JMM.

How does JMM solve visibility and other issues?

Here, it is necessary for me to briefly introduce typical problems scenarios.

In Lecture 25, I introduced the runtime data areas inside the JVM, but actual program execution takes place on specific processor cores. You can simply understand it as loading data such as local variables from memory into the cache and registers, and then writing them back to main memory after computation. You can see the corresponding models of these two concepts from the diagram below.

It looks good, but when multiple threads share variables, the situation becomes more complicated. Imagine if a processor modifies a shared variable, it may only be reflected in the cache of that core, which is a local state. Threads running on other cores may still be loading the old state, which can likely lead to consistency problems. In theory, multi-threaded sharing introduces complex data dependencies, and regardless of the reordering done by compilers or processors, they must respect the requirements of data dependencies, otherwise correctness would be violated. This is the problem that JMM aims to solve.

The implementation of JMM usually relies on memory barriers, which prohibit certain reorderings, to provide guarantees of memory visibility. It also implements various happen-before rules. At the same time, more complexity lies in ensuring consistent behavior across various compilers and processor architectures.

Taking volatile as an example, let’s see how memory barriers achieve the visibility defined by JMM.

For a volatile variable:

  • After a write operation to that variable, the compiler inserts a write barrier.
  • Before a read operation to that variable, the compiler inserts a read barrier. Memory barriers can ensure that the modifications made by other threads to a volatile variable are visible to the current thread, or that the local modifications are visible to other threads, for operations like variable reading or writing. In other words, when a thread writes, the write barrier forces the processor cache to be flushed so that other threads can obtain the latest value.

If you are interested in more details about memory barriers or want to understand different processor models of different architectures, I recommend referring to the JSR-133 related documentation. Personally, I believe that these are all hardware-specific and that memory barriers are just technical means to implement the JMM (Java Memory Model) specification, not requirements of the specification itself.

From the perspective of application developers, what does the visibility provided by JMM, reflected in the use of volatile, actually mean?

Let me give you two examples to illustrate gradually.

Firstly, a student asked me a question a few days ago. Look at the code snippet below. The desired effect is for thread A to exit the loop when the condition is assigned as false.

// Thread A
while (condition) {
}

// Thread B
condition = false;

Here, condition needs to be defined as a volatile variable. Otherwise, the change in its value often cannot be perceived by thread A, and thus it cannot exit the loop. Of course, you can also add code within the while loop that can directly or indirectly achieve a similar effect.

Secondly, I want to provide a classic example provided by Brian Goetz, where volatile is used as a guard object to achieve a lightweight form of synchronization. Consider the code snippet below:

Map configOptions;
char[] configText;
volatile boolean initialized = false;

// Thread A
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

// Thread B
while (!initialized)
  sleep();
// use configOptions

The JMM model defined in JSR-133 ensures that thread B obtains the updated value of configOptions.

In other words, the visibility of a volatile variable has been enhanced and can now act as a guardian for its context. The assignment of a volatile variable by thread A forces the flushing of the cache for that variable itself and other variables at the time, providing visibility to thread B. Of course, this comes at a certain performance cost, but it does simplify the behavior of multithreading.

We often say that volatile is lighter than synchronized, but the notion of “lightweight” is relative. The read and write operations of volatile still have a greater overhead compared to normal read/write operations. Therefore, if you are in a performance-sensitive scenario, use volatile with caution unless you are certain that you need its semantic.

Today, starting from the happen-before relationship, I have helped you understand what the Java Memory Model is. To facilitate understanding, I simplified the explanation from the perspective of different roles of engineers and discussed the origin of the problem, as well as how the JMM is implemented through techniques like memory barriers. Finally, I analyzed the typical use case of visibility in a multi-threaded scenario using volatile as an example.

Practice #

Do you have a clear understanding of the topic we discussed today? Today’s question for you to think about is, given a piece of code, how do you verify all possible JMM executions? Are there any tools that can assist?

Please share your thoughts on this question in the comments section. I will select well-considered comments and award you a learning reward voucher. I welcome you to discuss with me.

Are your friends also preparing for interviews? You can “invite friends to read” and share today’s question with them. Perhaps you can help them.