浅谈 Java 内存模型

Java 内存模型(JMM)描述了 JVM 如何使用计算机的内存(RAM)。JVM 是一个完整计算机的模型,因此该模型包含了内存模型的设计 —— JMM。

如果要正确地设计并发程序,了解 JMM 非常重要。JMM 描述了不同线程间如何以及何时可以看到其它线程写入共享变量的值,以及如何在必要时同步访问共享变量。

最初的 JMM 设计不充分,因此 JMM 在 Java 1.5 进行了修订。此版本的 JMM 仍在 Java 8 中使用。

Java Memory Model 内部实现

JVM 内部使用的 JMM 将内存划分为线程栈和堆。下图从逻辑角度说明了 JMM:

在 JVM 中运行的每个线程都有它自己的线程栈,线程栈包含了线程调用了哪些方法以到达当前执行点的信息,我们把它成为“调用栈(Call Stack)“。当线程执行其代码时,调用栈会发生变化。

线程栈还包含了正在执行的每个方法的所有的局部变量(调用栈上的所有方法)。一个线程只能访问它自己的线程栈,由线程创建的局部变量对于创建它的线程以外的所有其他线程都是不可见的。即使两个线程正在执行完全相同的代码,两个线程仍将在各自的线程栈中创建自己的局部变量。因此,每个线程都有自己的每个局部变量的版本。

基本类型(boolean,byte,short,char,int,long,float,double)完全存储在线程栈里,因此对其他线程是不可见的。一个线程可以将一个基本类型的变量副本传递给另一个线程,但它不能共享原始局部变量本身。

堆包含了 Java 应用程序中创建的所有对象,不管对象是哪个线程创建的,这包括基本类型的包装版本(如 Byte,Integer,Long 等)。无论对象是创建成局部变量,还是作为另一个对象的成员变量被创建,对象都存储在堆中。

下图说明了调用栈和局部变量存储在线程栈中,而对象存储在堆中。

局部变量如果是基本类型,这种情况下,变量完全存储在线程栈上。

局部变量如果是对象的引用,这种情况下,引用(局部变量)存储在线程栈上,但对象本身存储在堆上。

对象中可能包含方法,而这些方法中可能包含局部变量,这种情况下,即使方法所属的对象存储在堆上,但这些局部变量却是存储在线程栈上的。

对象的成员变量与对象本身一起存储在堆上,当成员变量是基本类型以及是对象的引用时都是如此。

静态类型变量与类定义一起存储在堆上。

所有线程通过拥有对象引用去访问堆中的对象。当一个线程有权访问一个对象时,它也能访问该对象的成员变量。如果两个线程同一时间调用同一对象的一个方法,它们都可以访问该对象的成员变量,但每个线程都有自己局部变量的副本。

这是一个说明上述要点的图表:

两个线程各有一组局部变量,其中一个局部变量(Local Variable 2)指向堆中的共享对象(Object 3)。两个线程各自对同一各对象拥有不同的引用,它们的引用是局部变量,因此它们存储在各自线程的线程栈中。但是,这两个不同引用指向堆中的同一个对象。

请注意,共享对象(Object 3)将 Object 2 和 Object 4 作为成员变量引用(如从 Object 3 到 Object 2 和 Object 4 的箭头所示),通过对象 3 中的这些成员变量引用,两个线程可以访问对象 2 和 对象 4。

上图还显示了一个局部变量指向堆中的两个不同对象。这种情况下,引用指向两个不同的对象(Object 1 和 Object 5),而不是同一个对象。理论上,如果两个线程都引用了两个对象,那两个线程都可以访问对象 1 和 对象 5。但在上图中,每个线程只引用了两个对象中的一个。

那么,什么样的 Java 代码可以导致上面的内存图?好吧,代码就如下面的代码一样简单:

public class MyRunnable implements Runnable() {

    public void run() {
        methodOne();
    }

    public void methodOne() {
        int localVariable1 = 45;

        MySharedObject localVariable2 =
            MySharedObject.sharedInstance;

        //... 使用局部变量做更多事情.

        methodTwo();
    }

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

        //... 使用局部变量做更多事情.
    }
}
public class MySharedObject {

    // 指向MySharedObject实例的静态变量

    public static final MySharedObject sharedInstance =
        new MySharedObject();


    // 成员变量指向堆上的两个对象

    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() 中声明了一个基本类型的局部变量(localVariable1 类型 int)和一个对象引用的局部变量(localVariable2)。

每个执行 methodOne() 的线程将在各自的线程栈上创建自己的 localVariable1 和 localVariable2 副本。localVariable 1 变量将完全分离,只存在于每个线程的线程栈中。一个线程无法看到另一个线程对其 localVariable 1 副本所做的更改。

执行 methodOne() 的每个线程还将创建它们自己的 localVariable2 副本。然而,localVariable 2 的两个不同副本最终都指向堆上的同一个对象。代码将 localVariable 2 设置为指向静态变量引用的对象。静态变量只有一个副本,这个副本存储在堆上。因此,localVariable 2 的两个副本最终都指向静态变量所指向的 MySharedObject 的同一个实例。MySharedObject 实例也存储在堆中,它对应于上图中的对象 3。

注意 MySharedObject 类也包含两个成员变量。成员变量本身同对象一起存储在堆中。这两个成员变量指向另外两个 Integer 对象,这些 Integer 对象对应于上图中的对象 2和对象 4。

还要注意 methodTwo() 创建的一个名为 localVariable 1 的本地变量。这个局部变量是一个指向 Integer 对象的对象引用。该方法将 localVariable 1 引用设置为指向一个新的 Integer 实例。localVariable 1 引用将存储在每个执行 methodTwo() 的线程的一个副本中。实例化的两个 Integer 对象存储在堆上,但是由于方法每次执行都会创建一个新的 Integer 对象,因此执行该方法的两个线程将创建单独的 Integer 实例。methodTwo() 中创建的 Integer 对象对应于上图中的对象 1和对象 5。还要注意类 MySharedObject 中的两个成员变量,它们的类型是 long,这是一个基本类型。由于这些变量是成员变量,所以它们仍然与对象一起存储在堆中。只有本地变量存储在线程堆栈中。

硬件内存架构

现代硬件内存架构与 Java 内存模型略有不同。了解硬件内存架构也很重要,以了解 Java 内存模型如何与其一起工作。本节介绍了常见的硬件内存架构,后面的部分将介绍 Java 内存模型如何与其配合使用。

这是现代计算机硬件架构的简化图:

现代计算机通常有两个或更多的 CPU,其中一些 CPU 也可能有多个内核。关键是,在具有2个或更多 CPU 的现代计算机上,可以同时运行多个线程。每个 CPU 都能够在任何给定时间运行一个线程。这意味着如果您的 Java 应用程序是多线程的,那么每个 CPU 可能同时(并发地)运行 Java 应用程序中的一个线程。

每个 CPU 包含一组寄存器,这些寄存器本质上是在 CPU 内存中。CPU 在这些寄存器上执行操作的速度要比在主内存中执行变量的速度快得多。这是因为 CPU 访问这些寄存器的速度要比访问主内存快得多。

每个 CPU 还可以有一个 CPU 缓存内存层。事实上,大多数现代 CPU 都有某种大小的缓存内存层。CPU 访问缓存内存的速度比主内存快得多,但通常没有访问内部寄存器的速度快。因此,CPU 高速缓存存储器介于内部寄存器和主存储器的速度之间。某些 CPU 可能有多个缓存层(L1 和 L2),但要了解 Java 内存模型如何与内存交互,这一点并不重要。重要的是要知道 CPU 可以有某种缓存存储层。

计算机还包含一个主内存区域(RAM)。所有 CPU 都可以访问主存,主内存区域通常比 CPU 的缓存内存大得多。

通常,当 CPU 需要访问主内存时,它会将部分主内存读入 CPU 缓存。它甚至可以将缓存的一部分读入内部寄存器,然后对其执行操作。当 CPU 需要将结果写回主内存时,它会将值从内部寄存器刷新到缓存内存,并在某个时候将值刷新回主内存。

当CPU需要在高速缓存中存储其他内容时,通常会将存储在高速缓存中的值刷新回主内存。CPU 缓存可以一次将数据写入一部分内存,并一次刷新一部分内存。它不必每次更新时都读取/写入完整的缓存。通常,缓存是在称为“缓存线(Cache Line)”的较小内存块中更新的。可以将一条或多条高速缓存线读入高速缓存内存,并将一条或多条高速缓存线再次刷新回主内存。

JMM 和硬件内存结构之间的差别

如前所述,JMM 和硬件内存结构是不同的。硬件内存体系结构不区分线程栈和堆。在硬件上,线程栈和堆都位于主内存中。线程栈和堆的一部分有时可能存在于 CPU 高速缓存和内部 CPU 寄存器中。如下图所示:

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

线程更新(写入)对共享变量的可见性

读取、检查和写入共享变量时的竞争条件

这两个问题将在下面几节中进行解释。

共享对象的可见性

如果两个或多个线程共享一个对象,而没有正确使用 volatile 声明或同步,那么一个线程对共享对象的更新可能对其他线程不可见。

假设共享对象最初存储在主内存中。在 CPU 1 上运行的线程然后将共享对象读入它的 CPU 缓存。在这里,它对共享对象进行更改。只要没有将 CPU 缓存刷新回主内存,在其他 CPU 上运行的线程就不会看到共享对象的更改版本。这样,每个线程都可能最终拥有自己的共享对象副本,每个副本位于不同的 CPU缓 存中。

下图说明了大致的情况。在左 CPU 上运行的一个线程将共享对象复制到其 CPU 缓存中,并将其 count 变量更改为2。此更改对运行在正确 CPU 上的其他线程不可见,因为尚未将更新刷新回主内存。

要解决这个问题,可以使用 Java 的 volatile 关键字。volatile 关键字可以确保直接从主内存读取给定的变量,并在更新时始终将其写回主内存。

竞态条件

如果两个或多个线程共享一个对象,且多个线程更新该共享对象中的变量,则可能出现竞争条件

假设线程 A 将共享对象的变量计数读入其 CPU 缓存。再想象一下,线程 B 执行相同的操作,但是进入了不同的 CPU 缓存。现在线程 A 向 count 加一,线程 B 也这样做。现在 var1 已经增加了两次,每次在每个 CPU 缓存中增加一次。

如果按顺序执行这些增量,变量计数将增加两次,并将原始值 + 2 写回主内存。

但是,这两个增量是同时执行的,没有适当的同步。无论哪个线程 A 和线程 B 将其更新版本的 count 写回主内存,更新后的值只比原始值高1,尽管有两个增量。

该图说明了上述竞态条件问题的发生情况:


要解决这个问题,可以使用 Java synchronized 块。同步块保证在任何给定时间只有一个线程可以进入代码的给定临界段。Synchronized 块还保证在 Synchronized 块中访问的所有变量都将从主内存中读入,当线程退出 Synchronized 块时,所有更新的变量将再次刷新回主内存,而不管变量是否声明为 volatile。

在此我向大家推荐一个架构学习交流群。交流学习群号:833145934 里面资深架构师会分享一些整理好的录制视频录像和BATJ面试题:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多。

注:本文转载自 linkedkeeper.com (文/张松然)

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容