(翻译)JVM——Java物理内存模型(JMM)及运行时内存模型

本人翻译自 http://tutorials.jenkov.com/java-concurrency/java-memory-model.html

 

Java内存模型规定了JVM如何基于计算机内存工作。JVM就是一个完整的计算机模型因此这个模型中包含一个内存模型——AKA java内存模型。

如果你想设计一个在多线程环境表现正常的程序的话,理解Java内存模型是至关重要的。Java内存模型规定了不同线程该如何以及在何时能看见被其他线程写入的共享变量,如果有必要,还会规定该如何同步地访问共享变量。

最初的Java内存模型还不够强大,因此JDK1.5修改了旧模型,目前在JDK1.8中依然使用它。

Java运行时内存模型(The Internal Java Memory Model)

在JVM内部使用的java内存模型(即java运行时内存模型,本人注)将内存分为线程栈和堆两大类。下面这张图展示了java内存模型的逻辑视图。

2fc906d74d437af849a95d6ef9ca3372cb4.jpg

JVM中执行的每个线程都有自己单独的线程栈,线程中包含了一些方法调用信息,这些信息记录了这个线程执行到当前位置调用了哪些方法。我把这个称为“调用栈”。随着线程执行代码,调用栈中的内存也就跟着变化。

线程栈中也包含了所调用方法中的所有本地变量。线程只能访问到自己的线程栈。线程创建的本地变量在其他线程中是不可见的。即使两个线程执行相同的代码,对于相同的变量名称,这两个线程依然会在自己的线程栈中创建属于自己的本地变量,这样一来,每个线程也就有各自不同版本的变量。

任何线程创建的对象,都将存放在堆内存中,堆中包含了在应用程序中创建的所有对象。这其中就包含了原始类型的包装类对象(例如Byte,Integer,Long等等)。无论一个对象被创建并赋值给本地变量,还是作为其他对象的成员,对象始终都是存放在堆中。

下图展示了(方法)调用栈和本地变量存储在线程栈中,而对象存储在堆内存中,

8c615aefb453130bcbf8696fa21f03a4646.jpg

当本地变量是一个原始类型时,将保存在线程栈中。

一个本地变量也可能是一个对象的引用。这种情况下这个引用(本地变量)将存储在线程栈中,但对象本身则存储在堆内存中。

一个对象可能包含一些方法,这些方法中可能包含一些本地变量。这些本地变量也保存在线程栈中,即使这些对象和方法都保存在堆中。

而对于对象的成员变量,无论它是一个原始变量类型,还是其他对象的引用,它都跟对象一起保存在堆中。

静态类中的变量也跟类定义一起保存在堆中。

堆中的对象能被所有线程中引用了这个对象的变量访问。如果一个线程能够访问一个对象,它也能访问这个对象的成员变量。如果两个线程同时调用同一个对象中的方法,他们都将能访问这个对象的成员变量,但是每个线程都将有属于自己的本地变量拷贝。

下图说明了以上几点

e08b71e30fb79968f2a77f32b7106a7b034.jpg

两个线程中都有一些本地变量。其中一个本地变量 (local variable 2) 指向堆中一个共享对象(object3). 两个线程分别有各自不同的指向相同对象的引用。这些应用是本地变量,因此保存在各自线程栈中,即使两个引用指向堆中相同的对象。

注意共享对象(object3)是如何将object2和object3引用为自己的成员变量的(用object3与object2和object4之间的箭头标明)。通过这些成员变量的引用,以上两个线程就能访问object2和object4.

图中还有一个指向堆中不同对象的本地变量。上面例子中,这个引用指向两个不同对象(object1和object5),而不是同一个对象。理论上如果所有线程都有到object1和object5得引用,那么就能访问这两个对象。但上图中的的线程只引用了其中的一个。

那么什么样的java代码能出现图中情景呢?以下简单代码即可:

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;
}

 

如果两个线程同时执行run()方法,上图的情景就会出现。run()方法中调用了methodOne(), methodOne()中则调用了methodTwo().

methodOne()定义了一个本地原始变量(local variable1 of type int)以及一个指向对象的本地变量(local variable2).

每个执行methodOne()的线程都会在自己的线程栈中创建local Variable1和local Variable2的副本。两个线程的local Variable1变量将会彻底隔离,只会在各自线程栈存活。变量local Variable1在各自线程中的副本是看不到其他线程中副本的变化的。

每个执行methodTwo()的线程也在自己的线程栈中创建各自的local Variable2变量的副本。不过这两个线程中的同variable2副本都指向堆中相同的一个对象。因为代码中让variable2指向了一个被静态变量引用的对象。静态变量仅有一份拷贝并且保存在堆中。这样,两个线程中的Variable2变量就指向那个静态变量所指向的同一个MySharedObject 的实例(即sharedInstance,本人注)。MySharedObject 的实例也保存在堆中,这跟上图中的Object3对应。

请注意MySharedObject 类是如何包含两个成员变量的。这两个成员变量跟对象一起保存在堆中。并且这两个成员变量指向另外两个Interge对象。这两个Integer对象就对应上图中的Object2和Object4。

也请注意methodTwo()是如何创建叫做Variable1的本地变量,这是一个指向了Integer对象的引用。这个方法将Varible1设置成指向新的Integer对象的引用。Variable1的副本将保存在执行methodTwo()线程的各自线程栈中。这两个Integer将会保存在堆中,但由于每次方法执行时都创建一个新的Integer对象,所以当这两个线程执行这个方法时,都将单独创建新实例。methodTwo()方法中创建的两个对象分别对应上图中的Object1和Object5。

同时请注意MySharedObject 类中的两个原始类型的成员变量。由于这些变量是成员变量,所以他们仍然和对象一起保存在堆中。只有本地变量才会保存在线程栈中。

物理内存结构(Hardware Memory Architecture)

物理内存结构是跟java运行时内存模型不同的东西。理解物理内存结构以及工作原理也非常重要。下面本节讲解常见物理内存模型,接下来要将他们是如何工作的。

下面是计算机内存模型简图

29835a7c5c3fbdcf184b3810b19d48bcea2.jpg

现代计算机通常有两个以CPU。这些CPU可能有多个内核。关键是拥有两个或更多CPU的现代计算机中,很可能有多个线程同时执行。每个CPU都能在任何时间独立执行一个线程。这意味着如果你的java程序是多线程的,每个CPU的线程都可能同时(并发)执行。

这些CPU都包含一些对CPU内存至关重要的寄存器。CPU在这些寄存器上的运行速度比在主存中的运行速度快得多。因为CPU访问寄存器要比访问主存快得多。

CPU可能还会有CPU缓存器。实际上现代计算机的CPU都拥有不同大小的缓存器,CPU访问缓存器也比访问主存要快,当然通常速度还是比不上访问CPU内部的寄存器。所以缓存器的速度介于寄存器和主存之间。有些CPU可能会有多个缓存器(一级,二级。。),但知道Java内存模型如何与计算机内存交互这并不是很重要。重要的是要知道CPU有多级缓存。

计算机包含一个主存(RAM).所有CPU都能访问主存。主存容量通常比缓存容量大得多。

通常,当CPU想读取主存中的数据时,将会先读取一部分到缓存中,再从缓存中读取一部分到寄存器中,然后执行。当CPU想将数据写回主存时,又会按与读取相反的顺序,从寄存器写入缓存器,再在合适的时机写入主存。

缓存器中的数据通常在CPU需要存储数据到主存时写入主存。缓存器可以边读边写。数据更新时并不需要读取缓存器的全部空间。通常缓存器只会有一小块更新,这一小块被称为"缓存段(cache lines)",一条或多条缓存段会读入缓存,另外一些缓存段则可能正写入主存。

桥接Java运行时内存模型和物理内存

上面已经说过,Java运行时内存模型和计算机物理内存结构是不一样的。计算机物理内存结构并不区分栈和堆。在物理内存结构中,栈和堆都位于主存中。一些线程栈和堆有时候可能在CPU寄存器或缓存器中,像下面这张图这样

ba8f0d7dfee6032df30ba3866b615a522ea.jpg

 

当对象和变量可以保存在计算机内存不同区域中,会发生一些严重问题,两个主要方面是:

  • 线程更新(写)共享变量时的可见性
  • 检查和读写共享变量时的竞态条件

共享对象的可见性

如果两个或更多线程共享一个对象,若没有正确地使用volatile或者synchronization的话,一个线程更新了共享变量,但其他线程可能并不可见。

设想共享变量最初存放在主存中。其中一个CPU中的线程将共享对象读取进CPU的缓存器,并修改了共享变量。只要这个CPU寄存器没有将数据写回主存,则共享变量的修改对于在其他CPU中运行的线程不可见。这样每个线程结束时都有自己版本的对象副本,分别保存在各自CPU的缓存器中。

下面这张图描述了上面的情景。左边CPU中的一个线程将共享对象拷贝进自己的缓存器,并且将变量count变成2。这个改变对右边CPU中的线程不可见,因为左边对count的更新还没有回写到主存中。

091f81cf8d7fda3534db3286aed882e6c4e.jpg

你可以用Java关键字volatile来解决这个问题。这个关键字能保证所给的变量是直接从主存中读取,并总是在更新后马上回写主存。

竞态条件

如果两个或者更多线程共享一个对象,并且有超过一个线程更新这个共享对象,就会发生竞争条件。

设想线程A读将共享对象的变量count读入它的CPU缓存器中。同时,线程B也做了相同的事,但是读进了不同CPU的缓存器中。现在线程A将count加1,线程B也加1。现在变量应该被加了两次,每个CPU各一次。

如果加法操作有序进行,变量就会被加两次,最终写回主存的变量应该是被加2.

但是,两次加法同时执行了,而且没有很好地进行同步控制。无论AB哪个线程将自己更新后的变量回写到主存,更新的变量都只会比原来大1,虽然事实上是两个现在一共做了两次加法操作。

下面这张图描述了上面所说的竞态条件:

f738a6f252ef28cf78d27a45b9585a5abcb.jpg

你可以用Java synchronied代码块解决上面的问题。synchronized代码块可以保证在任何时间都只能有一个线程进入代码块。synchronized代码块还能保证所有在synchronized代码块中的变量都从主存中读取,当线程中存在synchronized代码块时,不管变量是否用volatile关键字修饰,所有的更新都会回写进主存中。

 

 

 

 

转载于:https://my.oschina.net/u/3300976/blog/3030006

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值