Java内存模型

本文翻译自http://tutorials.jenkov.com/java-concurrency/java-memory-model.html,人工翻译,仅供学习交流。

Java内存模型

Java内存模型指定了Java虚拟机如何与计算机的内存(RAM)一起工作。Java虚拟机是整个计算机的模型所以,这个模型自然包含了一个内存模型——也就是Java内存模型。理解Java内存模型非常重要,如果您想设计行为正确的并发程序。Java内存模型指定不同的线程如何以及何时可以看到其他线程写入共享变量的值,以及在必要时如何同步访问共享变量。
原来的Java内存模型是不够的,因此Java内存模型在Java 1.5中进行了修订。这个版本的Java内存模型在今天的Java (Java 14)中仍然在使用。

内部Java内存模型

JVM内部使用的Java内存模型在线程栈和堆之间划分内存。下图从逻辑的角度说明了Java内存模型:在这里插入图片描述
在Java虚拟机中运行的每个线程都有自己的线程栈。线程栈包含关于线程调用了哪些方法以到达当前线程的信息,我将其称为“调用栈”。当线程执行其代码时,调用栈会发生变化。
线程栈还包含正在执行的每个方法的所有局部变量(调用栈上的所有方法),一个线程只能访问它自己的线程栈。除了创建局部变量的线程,局部变量对所有其他线程都是不可见的。即使两个线程执行完全相同的代码,这两个线程仍然会在各自的线程堆栈中创建该代码的局部变量。这两个线程仍然会在各自的线程堆栈中创建该代码的局部变量。所有基本类型(boolean, byte, short, char, int, long, float, double)的局部变量都是完全存储在线程栈上,因此对其他线程是不可见的。一个线程可以将一个原始变量的副本传递给另一个线程,但是它不能共享原始局部变量本身。
堆包含在Java应用程序中创建的所有对象,不管哪个线程创建的对象。这包括基本类型的对象版本(例如Byte, Integer, Long等)。如果创建了一个对象并将其赋值给一个局部变量,或者作为另一个对象的成员变量创建,对象仍然存储在堆上。下面的图表演示了调用堆栈和存储在线程堆栈上的局部变量,,以及存储在堆上的对象:在这里插入图片描述
局部变量可能是原始类型的,在这种情况下,它完全保存在线程栈上。
局部变量也可以是对象的引用。在这种情况下,引用(局部变量)存储在线程栈中,但是对象本身如果存储在堆上。
一个对象可能包含方法,而这些方法可能包含局部变量。这些局部变量也存储在线程堆栈中,即使方法所属的对象存储在堆上。
对象的成员变量和对象本身一起存储在堆上。当成员变量是一个基本类型时或它是一个对象的引用都是如此。
静态类变量也与类定义一起存储在堆上。
堆上的对象可以被所有引用该对象的线程访问。当一个线程可以访问一个对象时,它也可以访问该对象的成员变量。如果两个线程同时调用同一个对象上的一个方法,它们都可以访问对象的成员变量,但是每个线程都有自己的局部变量副本。下面是一张图解,说明了以上几点:在这里插入图片描述
两个线程有一组局部变量。其中一个局部变量(局部变量2)指向堆上的一个共享对象(对象3)。这两个线程对同一个对象有不同的引用。它们的引用是局部变量,因此存储在每个线程的线程栈中(在每个线程上)。不过,这两个不同的引用指向堆上的同一个对象。
注意共享对象(对象3)是如何引用对象2和对象4作为成员变量的(从对象3到对象2和对象4的箭头),通过对象3中的这些成员变量引用,两个线程可以访问对象2和对象4。
该图还显示了一个局部变量,它指向堆上的两个不同对象。在这种情况下,引用指向两个不同的对象(对象1和对象5),而不是相同的对象。理论上,两个线程都可以访问对象1和对象5,如果两个线程都引用了两个对象。但是在上面的图表中,每个线程只对两个对象中的一个有一个引用。
那么,什么样的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 member2 = 67890;
}

如果有两个线程正在执行run()方法,那么结果将如上图所示。run()方法调用methodOne(), methodOne()方法调用methodTwo()。
methodOne()声明了一个原始的局部变量(localVariable1的类型为int),和一个局部变量,它是一个对象引用(localVariable2)。每个执行methodOne()的线程将在线程栈上创建自己的localVariable1和localVariable2副本。localVariable1变量将彼此完全分离,只活在每个线程的线程堆栈上。一个线程不能看到另一个线程对其localVariable1副本所做的更改。
每个执行methodOne()的线程也将创建它们自己的localVariable2副本。localVariable2的两个不同副本最终都指向堆上的同一个对象。代码将localVariable2设置为指向由静态变量引用的对象。静态变量只有一个副本,该副本存储在堆上。localVariable2的两个副本都指向同一个静态变量指向的MySharedObject实例。MySharedObject实例也存储在堆上,它对应于上图中的对象3。
请注意MySharedObject类也包含两个成员变量,成员变量本身与对象一起存储在堆上。两个成员变量指向另外两个Integer对象。这些Integer对象对应于上图中的对象2和对象4。
还请注意methodTwo()如何创建一个名为localVariable1的局部变量。这个局部变量是一个对Integer对象的对象引用。该方法将localVariable1引用设置为指向一个新的Integer实例。localVariable1引用将存储在每个执行methodTwo()的线程的一个副本中。实例化的两个Integer对象将存储在堆上,但由于每次执行该方法都会创建一个新的Integer对象,执行此方法的两个线程将创建单独的Integer实例。在methodTwo()中创建的Integer对象对应于上图中的对象1和对象5。
还请注意MySharedObject类中两个原始long类型的两个成员变量,因为这些变量是成员变量,它们仍然与对象一起存储在堆上。只有局部变量存储在线程堆栈上。

硬件内存架构

现代硬件内存体系结构与内部Java内存模型有些不同。理解硬件内存架构也很重要,来理解Java内存模型如何使用它。本节介绍常用的硬件内存架构,后面的一节将描述Java内存模型如何使用它。以下是现代计算机硬件架构的简化图:在这里插入图片描述
现代计算机通常有2个或更多的cpu。其中一些cpu可能也有多核。在具有2个或更多cpu的现代计算机上,可能会有多个线程同时运行。每个CPU在任何给定时间都能够运行一个线程。这意味着如果你的Java应用程序是多线程的,在Java应用程序中,每个CPU可以同时运行一个线程。
每个CPU包含一组寄存器,这些寄存器本质上是CPU内存。CPU可以在这些寄存器上更快地执行操作比在主存中执行变量。这是因为CPU访问这些寄存器的速度要比访问主存快得多。
每个CPU也可以有一个CPU缓存存储器层。事实上,大多数现代cpu都有一定大小的缓存存储层。CPU访问高速缓存的速度比主存快得多,但通常没有访问内部寄存器的速度快,所以,CPU高速缓存的速度介于内部寄存器和主存之间。一些cpu可能有多个缓存层(Level 1和Level 2),但是要理解Java内存模型如何与内存交互并不重要,重要的是要知道cpu可以有某种类型的缓存存储层。
计算机还包含一个主存区(RAM)。所有的cpu都可以访问主存。主存区域通常比cpu的缓存存储器大得多。
通常,当CPU需要访问主存时,它会将部分主存读到它的CPU缓存中。它甚至可以将部分缓存读入它的内部寄存器,然后对其执行操作。当CPU需要将结果写回主存时,它会将值从内部寄存器刷新到缓存内存,并在某个时候将值刷新回主存。
当中央处理器需要在高速缓存中存储其他东西时,存储在缓存内存中的值通常会被刷新回主存。CPU缓存可以一次将数据写入一部分内存,一次刷新一部分内存。它不需要在每次更新时读取/写入完整的缓存。通常,缓存被更新在更小的内存块中,称为“缓存行”。一个或多个高速缓存行可以被读入高速缓存存储器,并且一条或多条高速缓存行可能会再次刷新到主存中。

在Java内存模型和硬件内存体系结构之间架起桥梁

如前所述,Java内存模型和硬件内存体系结构是不同的,硬件内存体系结构不区分线程堆栈和堆。在硬件上,线程堆栈和堆都位于主存中。部分线程堆栈和堆有时会出现在CPU缓存和内部CPU 寄存器中,如图所示:在这里插入图片描述
当对象和变量可以存储在计算机中不同的内存区域时,可能会出现某些问题。两个主要问题是:

  • 线程更新(写入)共享变量的可见性。
  • 读取、检查和写入共享变量时的竞争条件。
    这两个问题将在下面的部分中进行解释。

共享对象的可见性

如果两个或多个线程共享一个对象,如果没有正确使用volatile声明或同步,一个线程对共享对象的更新可能对其他线程不可见。
假设共享对象最初存储在主存中,在CPU上运行的线程将共享对象读入CPU缓存。在那里,它对共享对象进行了更改。只要CPU缓存没有被刷新回主存,改变后的共享对象对其他cpu上运行的线程不可见。这样,每个线程都可以拥有自己的共享对象副本,每个副本都在不同的CPU缓存中。
下图说明了大致的情况。在左侧CPU上运行的一个线程将共享对象复制到它的CPU缓存中,将count变量改为2。在右侧CPU上运行的其他线程是不可见的,因为计数的更新还没有被刷新回主存。在这里插入图片描述
要解决这个问题,可以使用Java的volatile关键字。关键字volatile可以确保直接从主存读取给定变量,当更新时总是写回主存。

静态条件

如果两个或多个线程共享一个对象,多个线程更新共享对象中的变量,静态条件就可能发生。
假设线程A将共享对象的变量count读入其CPU缓存。再想象一下,线程B做同样的事情,但是在不同的CPU缓存中。现在线程A给count加上1,线程B做同样的事情。现在var1已经增加了两次,在每个CPU缓存中增加一次。
如果这些增量按顺序执行,变量count将增加两次,并将原始值2写回主存中。
这两个增量在没有适当同步的情况下同时执行。无论哪个线程A和B将更新后的count写回主存,更新后的值只比原始值高1,尽管增加了两次。下图展示了上述竞态条件下出现的问题:在这里插入图片描述
要解决这个问题,可以使用Java同步块。同步块保证只有一个线程可以进入给定的代码临界部分,还保证在同步块中访问的所有变量都将从主存中读取,当线程退出同步块时,所有更新的变量将再次刷新到主存,不管变量是否声明为volatile。

下一节:Java Happens Before Guarantee

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值