Java内存模型

原文:Java Memory Model by Jakob Jenkov on 2014-12-18 翻译:陈同学, 注:原文撰写于14年,部分小知识点描述已不准确。

Java内存模型(简称JMM)指定了JVM如何利用计算机内存(RAM)进行工作。JMM与整个计算机的模型类似,这个模型自然也包含内存模型,即Java内存模型(AKA)。

如果你想设计出良好的并发程序,理解JMM十分重要。JMM定义了不同线程 何时 以及 如何 看到其它线程写入的共享变量值,以及在有必要时如何以同步的方式访问共享变量。

由于最初的JMM无法胜任工作,因此在Java 1.5中对JMM进行了升级,该版本在Java 8中依然在使用。

JMM技术内幕

JVM中的JMM将内存划分为 线程栈(Thread Stack)堆(Heap),下图从逻辑上展示了JMM。

JVM中运行的每个线程都拥有自己的线程栈,它存储了线程调用过程中的所有方法以及当前执行点等信息,我更乐意将线程栈称为 调用栈(Call Stack)。调用栈会随着代码的不断执行而不断改变。

线程栈也包含了每个方法被执行时所需的局部变量(所有方法都在调用栈中)。一个线程仅能访问自己的线程栈,它所创建的局部变量对其他线程都是不可见的。即使两个线程执行完全相同的代码,它们也会在自己的线程栈中创建对应的局部变量。

所有基础数据类型(boolean,byte,short,char,int,long,float,double) 的局部变量全部存储在线程栈中,线程之间相互不可见。一个线程可以将基础类型变量的副本(copy)传递给其他线程,但是无法在线程之间共享这种变量。

堆中存储了Java应用中所有线程创建的对象,包含了基础数据类型的包装类(如:Byte,Integer,Long等)。对象创建后无论是赋值给局部变量,亦或是作为某对象的成员变量,它都将存储在堆中。

下图展示了调用栈,线程栈中的局部变量,以及堆中的对象。

  • 若局部变量是基础数据类型,将存储于线程栈
  • 若局部变量是一个指向其他对象的引用,引用值存储在线程栈中,引用的对象存储在堆中
  • 若一个对象的方法中包含局部变量,尽管对象存储在堆中,但方法执行时的局部变量将存储在线程栈中
  • 对象的成员变量将与对象本身一起存储在堆中,无论成员变量是基础类型还是引用类型。
  • 类的静态变量与类的定义信息一起存储在堆中
  • 线程之间通过引用的方式共享堆中的对象。当一个线程访问一个对象时,它也可以访问其成员变量。如果两个线程执行某对象的同一个方法时,两个线程都可以访问对象的成员变量,但每个线程将拷贝一份方法所需的局部变量到自己的线程栈。

下图演示了上述知识点。

两个线程都有一系列局部变量。两个线程的 Local Variable 2 都指向堆中的共享对象 Object3,两个引用分别存储在每个线程的线程栈中。

注意,Object3以成员变量的形式拥有Object2和Object4的引用。两个线程可以通过Object3的成员变量访问到Object2和Object4。

那么,什么样的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;
}

译者注:由于图+代码已非常浅显易懂,笔者省略了作者对于上述代码的大段描述。

硬件内存结构

当代硬件内存结构与JVM内部的内存模型稍有不同。为了理解JMM如何与其打交道,知晓硬件内存结构十分重要。本部分描述了通用硬件内存结构,后续将讲述JMM如何与之协同工作。

下面是一张当代计算机硬件结构的简图:

现在的计算机经常有2个或多个CPU,一些CPU可能有多核。这意味着,多CPU计算机可以同时运行多个线程,每个CPU在任何给定的时间内都有能力运行一个线程。那么,如果Java应用是多线程的,每个CPU都可以并发的运行一个线程。

每个CPU包含一系列寄存器,CPU在寄存器上执行操作的速度远远超过操作主内存(简称主存)中变量的速度,这是因为CPU访问寄存器的速度比访问主存的速度快很多。

每个CPU也可能包含CPU cache层。实际上,现在大多数CPU都有一定大小的cache,CPU访问cache的速度远超访问主存的速度,当然肯定比不上访问寄存器的速度。因此,CPU cache的访问速度介于寄存器与主存之间。有的CPU可能有多级cache(Level1 和 Level2),但这对于理解JMM如何与内存交互并不重要,只需要知道CPU有cache层即可。

计算机包含一块主内存(RAM)。所有CPU都可以访问主存,主存的大小比CPU的cache大很多。

通常,当CPU需要访问主存时,它会先从主存中读取一部分数据到CPU cache,同样也可能读取部分CPU cache到寄存器再进行操作。当CPU需要回写结果到主存时,首先会将数据从寄存器flush到CPU cache,然后在某个时间点再将数据flush到主存。

一般当CPU cache需要存储其他数据时,会将cache中存储的数据flush到主存。CPU cache可以每次向主存回写一部分数据,并且刷新cache中的部分数据,并不需要每次读/写cache的所有数据。cache通常以更小的内存块—— cache line 为单位进行更新,每次可以将一个或多个 cache line读入CPU cache,每次也可以将一个或多个cache line flush到主存。

连接JMM和硬件内存

上面已提到,JMM和硬件内存存在差异。硬件内存并不区分堆和线程栈,在硬件上,堆和线程栈都在主存中,部分线程栈和堆内存可能在CPU cache或寄存器中。如下图所示:

由于对象和变量可以存储在计算机的不同内存区域,可能会出现某些问题。两个主要问题是:

  • 线程更新共享变量时的可见性问题
  • reading、checking、writting 共享变量时的竞争条件(Race Condition)问题

下面聊聊这两个问题。

共享对象的可见性(Visibility of Shared Objects)

如果两个或更多线程共享一个对象,若没有使用 volatile 进行修饰或使用同步机制,一个线程更新该共享对象后对其他线程将可能不可见。

想象一下,一个共享对象初始化后存储在主存中。运行在CPU1上的一个线程读取了该对象到自己的CPU cache中,然后对对象做了变更,由于CPU1 cache还没flush回主存,运行在其他CPU上的线程将看不到变更后的对象。这样的话,每个线程都可以拥有一个共享对象的副本,每个副本都位于不同CPU的 cache中。

下图演示了这种场景。一个运行在左边CPU的线程拷贝了一份共享对象并存储到自己的CPU cache,然后将对象的成员变量count 从1变更为2,这个变更对于运行在右边CPU上的线程来说并不可见,因为变更后的 count 还没flush回主存。

为了解决这个问题,你可以使用 Java volatile 关键字。volatile关键字可以确保一个变量总是从主存直接读取,而且在更新后总是会将新的变量值flush回主存。

竞争条件(Race Condition)

如果多个线程共享一个对象,当多个线程都更新了该对象中的变量时,就会发生 Race Condition 问题。

想象一下,如果线程A读取了共享对象的 count 属性到自己的CPU cache中,然后线程B也将其读取到了自己的CPU cache中。现在,线程A将count的值加1,线程B也一样加1,变量在每个CPU中都进行了加1操作。

如果这些操作顺序执行,变量count将执行2次加1操作,而且会将count + 2的值回写到主存。

然而,如果两次加1的操作并发执行(也未使用同步),无论是线程A还是线程B将更新后的 count 写回主存,count 的值只会被加1,而不是被加2。

下图展示了上述过程:

你可以使用 Java synchronized block(同步块) 来解决这个问题。同步块保证每次只有一个线程可以进入临界区执行代码,它同时保证了无论变量是否使用 volatile 修饰,同步块中的变量都会从主存中读取,并在退出同步块时将更新后的变量flush回主存。


欢迎关注陈同学的公众号,一起学习,一起成长

原文链接:http://tutorials.jenkov.com/java-concurrency/java-memory-model.html

原文作者:Jakob Jenkov

我来说两句

0 条评论
登录 后参与评论

相关文章

  • JVM 栈和栈帧

    对于没有深度递归的函数来说,无需担心上篇文章中的算法。当知道正在处理数据集有限时,我会使用这种简单的基本递归形式。由于你并不知道在应用程序中会处理多少数据,因此...

    码代码的陈同学
  • jstack是如何获取threaddump的?

    JDK提供了许多命令行工具用于监视JVM,让我们可以了解其异常堆栈、GC日志、threaddump、heapdump等信息。一时好奇,想看看jstack是如何实...

    码代码的陈同学
  • 一键清理 Nexus 中无用的 Docker 镜像

    现许多团队使用 Nexus 来管理 Docker 镜像,产品不断迭代,镜像仓库占用的磁盘空间也越来越大。由于 Nexus 的控制台并未提供批量操作镜像功能,清理...

    码代码的陈同学
  • 广告行业利用机器学习的5种方式

    现在广告行业要处理的信息量越来越大,传统的数据管理和分析方法效率越来越低,已远远无法满足广告商们的需求。所以越来越多的广告商将人工智能和机器学习作为解决问题的新...

    AiTechYun
  • 深入理解并发/并行,阻塞/非阻塞,同步/异步

    1、阻塞,非阻塞 首先,阻塞这个词来自操作系统的线程/进程的状态模型中,如下图: ? 进程状态 一个线程/进程经历的5个状态,创建,就绪,运行,阻塞,终止。各个...

    用户1332428
  • 深入理解并发/并行,阻塞/非阻塞,同步/异步

    版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/sinat_35512245/articl...

    大黄大黄大黄
  • 用深度学习每次得到的结果都不一样,怎么办?

    AI研习社按:本文作者 Jason Brownlee 为澳大利亚知名机器学习专家、教育者,对时间序列预测尤有心得。原文发布于其博客。AI研习社崔静闯、朱婷编译。...

    AI研习社
  • DAY97:阅读 Stream Attach With Multithreaded Host Programs

    我们正带领大家开始阅读英文的《CUDA C Programming Guide》,今天是第97天,我们正在讲解Unified Memory Programmin...

    GPUS Lady
  • 没有社保的码农,年后可以考虑跳槽了

    上周发了一篇文章「程序员最好不要频繁跳槽」,后台有很多朋友给我留言,其中有个朋友问:土哥,你在选择一家公司的时候,优先考虑什么? 我回复他,当然是看这家公司他...

    闰土大叔
  • OpenDaylight Hydrogen版本应用SampleTap研究(一)

    最近在网上看到了一个以OpenDaylight为基础的SDN应用,所以下载下来研究了一番,简单的对其相关功能进行了研究,由于精力有限,只完成了代码编译和Open...

    SDNLAB

扫码关注云+社区

领取腾讯云代金券