In-depth analysis of the memory model of Java multithreading

In-depth analysis of the memory model of Java multithreading

  • Internal java memory model
  • Hardware-level memory model
  • The relationship between Java memory model and hardware memory model
  • Visibility of shared objects
  • Resource racing

The Java memory model is a good description of how the JVM works in memory. JVM can be understood as an operating system executed by Java. As an operating system, there is a memory model, which is what we often call the JAVA memory model.

If we want to write multi-threaded parallel programs correctly. It is extremely important to understand how the Java memory model works under multithreading, which can help us better understand the underlying working methods.

The java memory model explains how and when different threads can see the values written to shared variables by other threads, and how synchronization programs share variables. The original java memory model was not good enough and there were many shortcomings. Therefore, in java1.5z, the version of java memory model has undergone a major update and improvement, and it is still used in java8.

Internal java memory model

The internal memory model of the JVM is divided into two parts, thread stack and heap, that is, thread stack and heap. We abstract the complex memory model into the following figure:

Each thread running in the JVM will have its own thread stack in the memory. The thread stack generally contains information about the point where the method of this thread is executed. It is also called "call stack". When the thread executes the code, the call stack will change with the execution state.

The thread stack also includes local variables when each method is executed. All methods are also stored on the thread stack. A thread can only access its own thread stack. The local variables created by each thread are invisible to other threads, that is, private, even if two threads call the same method, each thread will save a copy of the local variables, each belongs to its own thread stack .

All basic types of local variables (boolean, byte, short, char, int, long, float, double) are all stored in the thread stack and are invisible to other threads. A thread may pass a copy of the basic type A copy of the variable value is given to another thread, but its own variables cannot be shared, only the copy can be passed.

The new objects in the java program are stored in the heap, no matter which thread the new objects are, they all exist together, and it does not distinguish which thread objects are. These objects also include object versions of primitive types (eg Byte, Integer, Long etc.). No matter whether the object is assigned to a local variable or a member variable, it will eventually be stored in the heap.

The following figure illustrates that local variables are stored in the thread stack, and objects are stored in the heap.

A local variable of primitive data type will be completely stored in the thread stack.

A local variable can also be a reference to an object. In this case, the local variable is stored on the thread stack, but the object itself is stored on the heap.

An object may contain methods. These methods also contain local variables. These local variables are also stored on the thread stack, even if the objects and methods they belong to are stored on the heap.

The member variables of an object are stored on the heap following the object itself, regardless of whether the member variable is a primitive data type or a reference to the object.

Static class variables are generally also stored on the heap, according to the definition of the class.

Objects stored on the heap can be accessed by all threads by reference. When a thread holds a reference to an object, it can also access the member variables of this object at the same time. If two threads call a method of the same object at the same time, they will all have the member variables of this object, but each thread will have its own private local variables.

The following picture illustrates the above content

Two threads have a series of local variables. One of the local variables (Local Variable 2) points to object3 in the heap. Each of these two threads has a different reference to the same object object3. Their references are local variables and are stored in their respective thread stacks, although the two different references point to the same object.

We can also find that the shared object object3 has references to object2 and object4, and these references exist as member variables in object3. Through the reference of member variables in object3, both threads can access object2 and object4.

This figure also illustrates the local variables that point to different objects in the heap. For example, object1 and object5 in the figure are not the same object. In theory, all threads can access objects in the heap, as long as this thread holds a reference to the objects in the heap. But in this figure, each thread has only one reference to these two objects.

Next, we will write a piece of actual code, the memory model of this code is the same as the picture above:

public class MyRunnable implements Runnable() {

    public void run() {
        methodOne();
    }

    public void methodOne() {
        int localVariable1 = 45;

        MySharedObject localVariable2 =
            MySharedObject.sharedInstance;

       //... do more with local variables.

        methodTwo();
    }

    public void methodTwo() {
        Integer localVariable1 = new Integer(99);

       //... do more with local variable.
    }
}
 
public class MySharedObject {

   //static variable pointing to instance of MySharedObject

    public static final MySharedObject sharedInstance =
        new MySharedObject();


   //member variables pointing to two objects on the heap

    public Integer object2 = new Integer(22);
    public Integer object4 = new Integer(44);

    public long member1 = 12345;
    public long member1 = 67890;
}
 

If two threads execute the run method, then the memory model in the figure above will be the result of the execution of this program.

methodOne() declares a local variable of primitive data type, localVariable1 of type int, and a local variable (localVariable2) that points to a reference to an object.

When each thread executes methodOne(), it creates its own copy of local variables, that is, localVariable1 and localVariable2 are in their respective thread stack space. localVariable1 will be completely invisible to other threads, and only exist in each thread's own thread stack space. A thread cannot see the changes and operations made by other threads to localVariable1, and is invisible.

When each thread executes the methodOne() method, a copy of localVariable2 is also created, but different copies of localVariable2 end up pointing to the same object on the heap. This code makes localVariable2 point to the object previously referenced by a static variable. There will only be one copy of static variables, there will be no redundant copies, and static variables are stored in the heap. Therefore, the two copies of localVariable2 point to the same instance of MySharedObject at the same time, and at the same time, there is also a static variable in the heap that points to this instance of the object. This object corresponds to object3 in the figure above.

We found that MySharedObject contains these two member variables. These member variables are stored on the heap like objects. These two member variables point to two integer objects. These two objects correspond to object2 and object4 in the figure above, respectively.

We found that methodTwo() creates a local variable called localVariable1. This local variable is a reference to an object, which points to an integer object. This method points the local variable localVariable1 to a new value. When methodTwo() is executed, each thread will hold a copy of localVariable1. These two Integer objects will be initialized on the heap, but because each time this method is executed, this method will create a new object, so the two threads will have independent object instances. These two objects correspond to object1 and object5 in the figure above.

We found that the member variables in MySharedObject are primitive data types, but because they are member variables, they are still stored on the heap. Only local variables are stored in the thread stack.

Hardware-level memory model

The memory structure at the hardware level is different from the memory structure in the JVM. It is necessary for us to correctly understand and master the memory model at the hardware level. This can help us understand the underlying mechanism of java multithreading, and we must understand How does the java memory model work on the hardware memory structure. This chapter will describe the hardware-level memory model, and the next part will describe how java works in conjunction with hardware.

The following figure is a simplified diagram of modern computer hardware structure:

Modern computers usually have two or more CPUs. These CPUs may also have multiple cores. This means that a computer with multiple CPUs may have multiple threads executing at the same time, and each CPU can be executed at any time. Run a thread at a given time. This means that if our java program is multi-threaded, internally, each thread will have a cpu executing at the same time.

Each cpu will have a series of registers in the memory of the cpu, and these registers are very important. The cpu is much faster to perform calculations on registers than to perform calculations in main memory. This is because the CPU accesses registers much faster than access memory.

Each cpu will also have a cpu cache memory. This is because the cpu accesses the cache much faster than the memory, but it is slower than the accessed register, so the cache speed is between the register and the memory. Some CPUs also have multiple levels of cache, such as (Level 1 and Level 2), but this is not relevant to our understanding of the Java memory model. We only need the cpu to have a three-layer memory structure, register-cache-memory (RAM).

A computer generally has main memory, which is RAM, and all CPUs can access the main memory. The capacity of the main memory is generally much larger than that of the cache.

Generally, when the cpu needs to access the memory, it will first read a part of the main memory to the cache, or even read a part of the cache to the internal registers, and then perform calculation operations in the registers. When the cpu writes the calculation result back to the memory, it flushes the data in the register and cache, and then writes the value back to the memory.

When the cpu asks the cache to store other content, it will flush the content in the cache to the memory. The cpu cache can write part of the data to the memory while writing part of it to its own cache, so when updating the data, it is not necessary to empty the cache all, you can read and write. Generally, the cache actually updates data in smaller memory blocks called "cache lines". Multiple "cache lines" may be reading data into the cache, while another part may be writing data back to memory.

The relationship between Java memory model and hardware memory model

As mentioned above, the Java memory model and the hardware memory model are different. The hardware memory model does not distinguish between heap and stack. At the hardware level, all thread stacks and heaps are stored in main memory, and some thread stacks and heaps may sometimes appear in the cpu cache and cpu registers. The following figure can illustrate this problem:

When objects and variables are stored in different memory areas, many problems may occur, mainly the following two types of problems:

  • When threads update or write some shared data, visibility issues
  • When reading and writing shared data generates resource racing problems, the next part will discuss these two problems

Visibility of shared objects

If multiple threads are sharing an object, and the volatile or synchronize statement is not used correctly, the problem of invisible to other threads may occur when the shared object is updated.

We assume that the shared object is initialized in main memory. A thread running in the cpu reads the shared object into the cache. At this time, with the execution of the program, some changes may occur in the shared object. As long as the cpu cache has not been written back to the main memory, the changes in this shared object will be invisible to other threads running on the cpu. In this case, each thread will hold a copy of its own shared object, which is stored in the cache of its own CPU and is invisible to other threads.

The following figure illustrates the general situation. The thread executed by the cpu on the left reads the shared object into the cache and changes its value to 2. This change is invisible to other threads of the cpu on the right because of the variable count The update has not been written back to main memory.

To solve the problem of visibility of shared objects, you can use java's volatile keyword (see the author's other volatile blog post). This keyword can ensure that the given variables are directly read from the main memory. And every time it is updated, it is written back to the memory immediately, so it can be guaranteed that the change is visible in time.

Resource racing

If multiple threads share an object, and multiple threads need to update the variables in the shared object, then resource races may occur.

Suppose that thread A reads the variable count of a shared object into the cpu cache. At the same time, thread B performs the same steps, but reads it into the cache of a different CPU. Now thread A adds one to count. Thread B does the same thing. Now this variable has been added twice, in different cpu caches.

If these two increment operations are executed in sequence, the variable count will be added twice and increased by 2 from the original value, and written back to the main memory.

However, if these two increment operations are executed concurrently and the synchronization operation is not performed correctly, when writing back to the memory, the updated value will only be incremented by one, even though the increment operation is actually performed twice. The following figure illustrates the problem of resource competition when the program is executed concurrently:

To solve this problem, we can use the synchronize keyword in java. Synchronize can ensure that only one thread can enter the code segment that is declared as synchronize. A synchronized thread can ensure that all variables in the synchronized code segment will be read from memory, and when the thread leaves the code block, all updated values will be written back to the main memory, regardless of whether the variable is declared volatile or not.

summary

This article analyzes the Java memory model and the hardware-level memory model in detail, and analyzes how the hardware and Java cooperate in the memory model. This is extremely important for us to understand the concept of java multithreading, and laid a solid foundation.