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 条评论
登录 后参与评论

相关文章

来自专栏技术点滴

命令模式(Command)

命令模式(Command) 命令模式(Command)[Action/Transaction] 意图:将一个请求封装为一个对象,从而可用不同的请求对客户参数化。...

1865
来自专栏封碎

Java多线程参考手册 博客分类: 经典文章转载

http://blog.csdn.net/ring0hx/article/details/6858582

732
来自专栏我的博客

PHP5.3~PHP5.5新特性汇总

一.PHP 5.3中的新特性 1. 支持命名空间 (Namespace) 2. 支持延迟静态绑定(Late Static Binding) 3. 支持got...

3818
来自专栏desperate633

深入理解Java多线程(multiThread)多线程的基本概念线程同步wait,notify,notifyAll线程的生命周期

一个java程序启动后,默认只有一个主线程(Main Thread)。如果我们要使用主线程同时执行某一件事,那么该怎么操作呢? 例如,在一个窗口中,同时画两排...

1362
来自专栏跟着阿笨一起玩NET

C#如何控制方法的执行时间,超时则强制退出方法执行

http://www.blue1000.com/bkhtml/c17/2013-01/71047.htm

3022
来自专栏java思维导图

读写一致性的一些思考

先说明下,本文要讨论的多线程读写是指一个线程写,一个或多个线程读,不包括多线程同时写的情况。

822
来自专栏Google Dart

Dart 服务端开发 shelf_bind 包

shelf_bind倾向于约定优于配置,因此您可以编写必要的最小代码,但仍然可以根据需要覆盖默认值。

942
来自专栏Kevin-ZhangCG

[ Java面试题 ]WEB篇

2168
来自专栏猿人谷

用C来实现内存池

介绍:        设计内存池的目标是为了保证服务器长时间高效的运行,通过对申请空间小而申请频繁的对象进行有效管理,减少内存碎片的产生,合理分配管理用户内存,...

5147
来自专栏Java后端技术

JVM 运行时数据区详解

  Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同数据区域。

833

扫码关注云+社区

领取腾讯云代金券