`

什么是 Java 内存模型,最初它是怎样被破坏的?

阅读更多
活跃了将近三年的 JSR 133,近期发布了关于如何修复 Java 内存模型(Java Memory Model, JMM)的公开建议。原始 JMM 中有几个严重缺陷,这导致了一些难度高得惊人的概念语义,这些概念原来被认为很简单,如 volatile、final 以及 synchronized。在这一期的 Java 理论与实践 中,Brian Goetz 展示了如何加强 volatile 和 final 的语义,以修复 JMM。这些更改有些已经集成在 JDK 1.4 中;而另一些将会包含在 JDK 1.5 中。您可以在本文对应的论坛里与作者及其他读者分享您对本文的看法(您也可以点击文章底部或顶部的 讨论 按钮来访问论坛)。

Java 平台把线程和多处理技术集成到了语言中,这种集成程度比以前的大多数编程语言都要强很多。该语言对于平台独立的并发及多线程技术的支持是野心勃勃并且是具有开拓性的,或许并不奇怪,这个问题要比 Java 体系结构设计者的原始构想要稍微困难些。关于同步和线程安全的许多底层混淆是 Java 内存模型 (JMM)的一些难以直觉到的细微差别,这些差别最初是在 Java Language Specification 的第 17 章中指定的,并且由 JSR 133 重新指定。

 

例如,并不是所有的多处理器系统都表现出 缓存一致性(cache coherency) ;假如有一个处理器有一个更新了的变量值位于其缓存中,但还没有被存入主存,这样别的处理器就可能会看不到这个更新的值。在缓存缺乏一致性的情况下,两个不同的处理器可以看到在内存中同一位置处有两种不同的值。这听起来不太可能,但是这却是故意的 —— 这是一种获得较高的性能和可伸缩性的方法 —— 但是这加重了开发者和编译器为解决这些问题而编写代码的负担。

 

什么是内存模型,我为什么需要一个内存模型?

 

内存模型描述的是程序中各变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存取出变量这样的低层细节。对象最终存储在内存中,但编译器、运行库、处理器或缓存可以有特权定时地在变量的指定内存位置存入或取出变量值。例如,编译器为了优化一个循环索引变量,可能会选择把它存储到一个寄存器中,或者缓存会延迟到一个更适合的时间,才把一个新的变量值存入主存。所有的这些优化是为了帮助实现更高的性能,通常这对于用户来说是透明的,但是对多处理系统来说,这些复杂的事情可能有时会完全显现出来。

 

JMM 允许编译器和缓存以数据在处理器特定的缓存(或寄存器)和主存之间移动的次序拥有重要的特权,除非程序员已经使用 synchronized final 明确地请求了某些可见性保证。这意味着在缺乏同步的情况下,从不同的线程角度来看,内存的操作是以不同的次序发生的。

 

与之相对应地,像 C 和 C++ 这些语言就没有显示的内存模型 —— 但 C 语言程序继承了执行程序处理器的内存模型(尽管一个给定体系结构的编译器可能知道有关底层处理器的内存模型的一些情况,并且保持一致性的一部分责任也落到了该编译器的头上)。这意味着并发的 C 语言程序可以在一个,而不能在另一个,处理器体系结构上正确地运行。虽然一开始 JMM 会有些混乱,但这有个很大的好处 —— 根据 JMM 而被正确同步的程序能正确地运行在任何支持 Java 的平台上。






原始 JMM 的缺点

 

虽然在 Java Language Specification 的第 17 章指定的 JMM 是一个野心勃勃的尝试,它尝试定义一个一致的、跨平台的内存模型,但它有一些细微而重要的缺点。 synchronizedvolatile 的语义很让人混淆,以致于许多有见地的开发者有时选择忽略这些规则,因为在旧的存储模型下编写正确同步的代码非常困难。

 

旧的 JMM 允许一些奇怪而混乱的事情发生,比如 final 字段看起来没有那种设置在构造函数里的值(这样使得想像上的不可变对象并不是不可变的)和内存操作重新排序的意外结果。这也防止了其他一些有效的编译器优化。如果您阅读了关于双重检查锁定问题(double-checked locking problem)的任何文章(参阅 参考资料 ),您将会记得内存操作重新排序是多么的混乱,以及当您没有正确地同步(或者没有积极地试图避免同步)时,细微却严重的问题会如何暗藏在您的代码中。更糟糕的是,许多没有正确同步的程序在某些情况下似乎工作得很好,例如在轻微的负载下、在单处理器系统上,或者在具有比 JMM 所要求的更强的内存模型的处理器上。

 

“重新排序”这个术语用于描述几种对内存操作的真实明显的重新排序的类型:

  • 当编译器不会改变程序的语义时,作为一种优化它可以随意地重新排序某些指令。
  • 在某些情况下,可以允许处理器以颠倒的次序执行一些操作。
  • 通常允许缓存以与程序写入变量时所不相同的次序把变量存入主存。

从另一线程的角度来看,任何这些条件都会引发一些操作以不同于程序指定的次序发生 —— 并且忽略重新排序的源代码时,内存模型认为所有这些条件都是同等的。






JSR 133 的目标

 

JSR 133 被授权来修复 JMM,它有几个目标:

  • 保留现有的安全保证,包括类型安全。

  • 提供 无中生有安全性(out-of-thin-air safety) 。这意味着变量值并不是“无中生有”地创建的 —— 所以对于一个线程来说,要观察到一个变量具有变量值 X,必须有某个线程以前已经真正把变量值 X 写入了那个变量。

  • “正确同步的”程序的语义应该尽可能简单直观。这样,“正确同步的”应该被正式而直观地定义(这两种定义应该相互一致)。

  • 程序员应该要有信心创建多线程程序。当然,我们没有魔法使得编写并发程序变得很容易,但是我们的目标是为了减轻程序员理解内存模型所有细节的负担。

  • 跨大范围的流行硬件体系结构上的高性能 JVM 实现应该是可能的。现代的处理器在它们的内存模型上有着很大的不同;JMM 应该能够适合于实际的尽可能多的体系结构,而不会以牺牲性能为代价。

  • 提供一个同步习惯用法(idiom),以允许我们发布一个对象并且使得它不用同步就可见。这是一种叫做 初始化安全(initialization safety) 的新的安全保证。

  • 对现有代码应该只有最小限度的影响。

值得注意的是,有漏洞的技术(如双重检查锁定)在新的内存模型下仍然有漏洞,并且“修复”双重检查锁定技术并不是新内存模型所致力的一个目标。(但是, volatile 的新语义允许通常所提出的其中一个双重检查锁定的可选方法正确地工作,尽管我们不鼓励这种技术。)

 

从 JSR 133 process 变得活跃的三年来,人们发现这些问题比他们认为重要的任何问题都要微妙得多。这就是作为一个开拓者的代价!最终正式的语义比原来所预料的要复杂得多,实际上它采用了一种与原先预想的完全不同的形式,但非正式的语义是清晰直观的,将在本文的第 2 部分概要地说明。






同步和可见性

 

大多数程序员都知道, synchronized 关键字强制实施一个互斥锁(互相排斥),这个互斥锁防止每次有多个线程进入一个给定监控器所保护的同步语句块。但是同步还有另一个方面:正如 JMM 所指定,它强制实施某些内存可见性规则。它确保了当存在一个同步块时缓存被更新,当输入一个同步块时缓存失效。因此,在一个由给定监控器保护的同步块期间,一个线程所写入的值对于其余所有的执行由同一监控器所保护的同步块的线程来说是可见的。它也确保了编译器不会把指令从一个同步块的内部移到外部(虽然在某些情况下它会把指令从同步块的外部移到内部)。JMM 在缺乏同步的情况下不会做这种保证 —— 这就是只要有多个线程访问相同的变量时必须使用同步(或者它的同胞,易失性)的原因。






问题 1:不可变对象不是不可变的

 

JMM 的其中一个最惊人的缺点是,不可变对象似乎可以改变它们的值(这种对象的不变性旨在通过使用 final 关键字来得到保证)。(Public Service Reminder:让一个对象的所有字段都为 final 并不一定使得这个对象不可变 —— 所有的字段 必须是原语类型或是对不可变对象的引用。)不可变对象(如 String )被认为不要求同步。但是,因为在将内存写方面的更改从一个线程传播到另一个线程时存在潜在的延迟,所以有可能存在一种竞态条件,即允许一个线程首先看到不可变对象的一个值,一段时间之后看到的是一个不同的值。

 

这是怎么发生的呢?考虑到 Sun 1.4 JDK 中 String 的实现,这儿基本上有三个重要的决定性字段:对字符数组的引用、长度和描述字符串开始的字符数组的偏移量。 String 是以这种方式实现的,而不是只有字符数组,因此字符数组可以在多个 StringStringBuffer 对象之间共享,而不需要在每次创建一个 String 时都将文本拷贝到一个新的数组里。例如, String.substring() 创建了一个可以与原始的 String 共享同一个字符数组的新字符串,并且这两个字符串仅仅只是在长度和偏移量上有所不同。

假设您执行以下的代码:

String s1 = "/usr/tmp";
String s2 = s1.substring(4);   // contains "/tmp"


字符串 s2 将具有大小为 4 的长度和偏移量,但是它将同 s1 共享包含“ /usr /tmp ”的同一字符数组。在 String 构造函数运行之前, Object 的构造函数将用它们默认的值初始化所有字段,包括决定性的长度和偏移字段。当 String 构造器运行时,字符串长度和偏移量被设置成所需要的值。但是在旧的内存模型下,在缺乏同步的情况下,有可能另一个线程会临时地看到偏移量字段具有初默认值 0,而后又看到正确的值 4。结果是 s2 的值从“ /usr ”变成了“ /tmp ”。这并不是我们所想要的,而且在所有 JVM 或平台这是不可能的,但是旧的内存模型规范允许这样做。






问题 2:重新排序易失性和非易失性存储

 

另一个主要领域是与 volatile 字段的内存操作重新排序有关,这个领域中现有 JMM 引起了一些非常混乱的结果。现有 JMM 表明易失性的读和写是直接和主存打交道的,这样避免了把值存储到寄存器或者绕过处理器特定的缓存。这使得多个线程一般能看见一个给定变量的最新的值。可是,结果是这种 volatile 定义并没有最初所想像的那样有用,并且它导致了 volatile 实际意义上的重大混乱。

 

为了在缺乏同步的情况下提供较好的性能,编译器、运行库和缓存通常被允许重新排序普通的内存操作,只要当前执行的线程分辨不出它们的区别。(这就是所谓的 线程内似乎是串行的语义(within-thread as-if-serial semantics) 。)但是,易失性的读和写是完全跨线程安排的,编译器或缓存不能在彼此之间重新排序易失性的读和写。遗憾的是,通过参考普通变量的读和写,JMM 允许易失性的读和写被重新排序,这意味着我们不能使用易失性标志作为操作已完成的指示。考虑下面的代码,其意图是假定易失性字段 initialized 用于表明初始化已经完成了。


清单 1. 使用一个易失性字段作为一个“守卫”变量

Map configOptions;
char[] configText;
volatile boolean initialized = false;
 . .
//  In thread A
        

configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
 . .
// In thread B
        

while (!initialized) 
  sleep();
// use configOptions 
      


这里的思想是使用易失性变量 initialized 担任守卫来表明一套别的操作已经完成了。这是一个很好的思想,但是它不能在旧的 JMM 下工作,因为旧的 JMM 允许非易失性的写(比如写到 configOptions 字段,以及写到由 configOptions 引用 Map 的字段中)与易失性的写一起重新排序,因此另一个线程可能会看到 initialized 为 true,但是对于 configOptions volatile 的旧语义只承诺正在读和写的变量的可见性,而不承诺其他的变量。虽然这种方法更容易有效地实现,但结果是没有原来所想的那么有用。 字段或它所引用的对象还没有一个一致的或者说当前的视图。






结束语

 

正如 Java Language Specification 第 17 章中所指定的,JMM 有一些严重的缺点,即允许一些看起来合理的程序发生一些非直观的或不合需要的事情。如果正确地编写并发的类太困难的话,那么我们可以说许多并发的类不能按预期工作,并且这是平台中的一个缺点。幸运的是,我们可以在不破坏在旧的内存模型下正确同步的任何代码的同时,创建一个与大多数开发者的直觉更加一致的内存模型,并且这一切已经由 JSR 133 process 完成。下个月,我们将介绍新的内存模型(它的大部分功能已集成到 1.4 JDK 中)的详细信息。

 

 

 

 

转自http://www.ibm.com/developerworks/cn/java/j-jtp02244/

分享到:
评论

相关推荐

    Java技术与应用---

    4. 安全:Java的沙箱模型确保了代码在执行时不会对系统造成破坏,这使得它在网页应用程序和移动设备上非常受欢迎。 5. 解释型:Java是一种解释型语言,代码可以跨平台运行,只需一次编写,到处运行(Write Once, ...

    jAVA无难事课件整理.ppt

    Java还强调安全性的设计,通过沙箱模型确保代码在执行时不会对系统造成破坏。 另外,Java是一种解释型语言,它的字节码可以在任何支持Java的平台上运行,实现了“一次编写,到处运行”的跨平台目标。同时,Java支持...

    Java经典入门教程

    Java由Sun Microsystems公司于1995年推出,最初被设计用于消费电子产品如电视机顶盒,后来逐渐发展成为一种广泛使用的通用编程语言。随着互联网的发展,Java逐渐成为了企业和互联网应用的主要开发语言之一。 **2.2 ...

    java基础教程

    Java的架构中包括了Java安全性模型,用于防止恶意软件对系统的破坏。Java通过JIT技术实现性能提升,使得程序运行更加高效。 11. Java的分布式和网络能力: Java的网络编程能力非常强大,内置对TCP/IP和URL的支持,...

    java教程全集pdf

    Java是一种流行的面向对象的网络编程语言,自1995年问世以来,它已经成为了IT产业中不可忽视的一部分,并被认为是软件开发的一次革命。Java能够跨平台运行,这意味着它能够在不同的操作系统和硬件平台上编写的程序...

    JAVA 基础入门TXT格式

    它被设计成具有“一次编写,到处运行”的特性,这意味着编写的Java程序可以在任何支持Java的平台上运行,无需重新编译。Java语言的特点包括面向对象、健壮性、安全性、平台独立性等,这些特性使得Java成为构建企业级...

    计算机软件Java编程特点分析.zip

    虽然Java最初因解释执行而被认为性能较低,但随着JIT(Just-In-Time)编译器的发展,现在的Java运行速度已经得到显著提升。JVM能够动态优化热点代码,使得Java在许多场景下都能达到接近C++的性能。 六、多线程 Java...

    计算机软件Java编程特点及其技术分析 (1).zip

    Java的设计高度重视安全性,它有一套严谨的安全模型,包括类加载器、安全策略和访问权限控制等机制,以防止恶意代码对系统的破坏。 五、健壮性 Java有严格的类型检查和异常处理机制,能有效预防程序运行时的错误。...

    JAVA基础教程

    Java是一种广泛使用的面向对象编程语言,具有跨平台、简单易学的特点,它是由美国Sun Microsystems公司(现为甲骨文公司的一部分)于1995年推出的。Java的前身是Oak,这是一种用于网络环境的安全且精巧的编程语言。...

    java中classLoader的使用

    Java中的类加载器(ClassLoader)是Java虚拟机(JVM)的一个重要组成部分,它负责将类的.class文件从文件系统或者网络中加载到内存中,并转换为对应的Class对象。类加载器的工作流程主要包括加载、验证、准备、解析...

    java入门教程

    2. **安全性**:Java拥有严格的安全机制,如沙箱模型(Sandbox model)可以防止恶意代码对系统的破坏,以及内置的加密支持。 3. **动态性**:Java支持动态链接和类加载,这使得它可以在运行时动态地加载类和库,极大地...

    Excel模板 Java程序设计基础教程-完整教案.docx

    - **安全性**:Java提供了一套完整的安全机制,包括沙箱模型、权限检查等,可以防止恶意代码对系统的破坏。 #### 二、Java开发环境搭建 - **安装JDK(Java开发工具包)** - **下载**:访问Oracle官网或其他可信...

    CoreJava笔记.doc

    - **高性能**:尽管Java最初被认为性能较低,但随着JIT编译器和GC技术的进步,其性能已经大大提高。 - **多线程**:Java内置了对多线程的支持,使得编写并发应用程序变得容易。 **1.2 运行原理** Java程序的执行...

    类加载器加载过程.rar

    在Java编程语言中,类加载器(ClassLoader)是至关重要的组成部分,它负责将类的字节码转换为可执行的Java对象。理解类加载器的工作原理对于深入掌握Java虚拟机(JVM)运行机制和进行高效的问题排查至关重要。在这个...

    虚拟机 源码及电子书

    JVM的内存模型、类加载机制、垃圾回收算法等都是深入学习的重点。 3. **解释器与即时编译器(JIT)**:虚拟机通常包含解释器和即时编译器。解释器逐行解释执行字节码,而JIT会将频繁执行的热点代码编译成本地机器码...

    2021-2022计算机二级等级考试试题及答案No.16883.docx

    5. Java组件:Java中的Component类是所有UI组件的基类,Button、Dialog和Label都是其子类,而MenuBar不属于Component的子类,它是Menu的子类。 6. 字符串比较:在C/C++中,字符串比较应使用strcmp函数,如果两个...

    2021-2022计算机二级等级考试试题及答案No.13512.docx

    - **解析**:虽然 ENIAC 不是真正意义上的存储程序计算机,但它是早期电子计算机的重要里程碑。 ### 内存单位换算 18. **内存单位换算**:2GB 等于 2048MB。 - **解析**:1GB = 1024MB,因此 2GB = 2 × 1024MB ...

Global site tag (gtag.js) - Google Analytics