与大多数以前的编程语言相比,Java平台在很大程度上将线程和多处理功能集成到该语言中。 该语言对独立于平台的并发和多线程的支持是雄心勃勃的,并且是开创性的,并且也许并不奇怪,该问题比Java架构师最初认为的要难一些。 Java内存模型(JMM)的非直观的细微之处引起了围绕同步和线程安全性的许多混乱,这些细微之处最初是在Java语言规范的第17章中指定的,并由JSR 133重新指定。
例如,并非所有的多处理器系统都表现出高速缓存一致性 。 如果一个处理器在其高速缓存中具有变量的更新值,但是尚未刷新到主存储器中,则其他处理器可能看不到该更新。 在没有高速缓存一致性的情况下,两个不同的处理器可能会在内存中的同一位置看到两个不同的值。 这听起来可能很可怕,但这是设计使然–它是获得更高性能和可伸缩性的一种手段–但是,这给开发人员和编译器带来了负担,以创建适应这些问题的代码。
什么是内存模型,为什么我需要一个?
内存模型描述了程序中的变量(实例字段,静态字段和数组元素)与在实际计算机系统中将其存储到内存以及从内存中检索的底层细节。 对象最终存储在内存中,但是编译器,运行时,处理器或高速缓存可能会在将值移入或移出变量的已分配内存位置的时间方面享有一些自由。 例如,编译器可以选择通过将循环索引变量存储在寄存器中来优化循环索引变量,或者缓存可以延迟将变量的新值刷新到主存储器中,直到更合适的时间为止。 所有这些优化都是为了提高性能,并且通常对用户是透明的,但是在多处理器系统上,有时可能会显示出这些复杂性。
JMM允许编译器和缓存在处理器特定的缓存(或寄存器)和主内存之间移动数据的顺序上拥有明显的自由,除非程序员明确要求使用synchronized
或volatile
保证某些可见volatile
。 这意味着在没有同步的情况下,从不同线程的角度来看,内存操作可能会以不同顺序发生。
相比之下,诸如C和C ++之类的语言则没有显式的内存模型-C程序会继承执行该程序的处理器的内存模型(尽管给定架构的编译器可能确实了解底层处理器的内存模型,并且合规性的部分责任在于编译器)。 这意味着并发的C程序可以在一种处理器体系结构上正确运行,但不能在另一种体系结构上正确运行。 尽管JMM乍一看可能令人困惑,但是它有一个很大的好处-根据JMM正确同步的程序应该可以在任何支持Java的平台上正确运行。
原始JMM的缺点
虽然Java语言规范的第17章中指定的JMM是雄心勃勃的尝试,以定义一个一致的,跨平台的内存模型,但它有一些细微但重要的缺陷。 synchronized
和volatile
的语义非常混乱,以至于许多知识丰富的开发人员有时选择忽略规则,因为在旧的内存模型下编写正确的同步代码非常困难。
旧的JMM允许发生一些令人惊讶和令人困惑的事情,例如final字段似乎没有在构造函数中设置的值(因此使假定的不可变对象变为不可变的),以及通过内存操作重新排序产生意外结果。 它还阻止了一些其他有效的编译器优化。 如果您已经阅读了有关双重检查的锁定问题的任何文章(请参阅参考资料 ),您会回想起内存操作的重新排序是多么令人困惑,以及当您不执行代码时,细微而严重的问题会潜入您的代码中正确同步(或积极尝试避免同步)。 更糟糕的是,许多错误同步的程序在某些情况下似乎可以正常工作,例如在轻负载下,在单处理器系统上或在具有比JMM所需的内存模型更强的处理器上。
术语重新排序用于描述内存操作的实际和表观重新排序的几类:
- 编译器可以在不更改程序语义的情况下自由地对某些指令进行重新排序以进行优化。
- 在某些情况下,允许处理器无序执行操作。
- 通常允许高速缓存以与程序写入变量不同的顺序将变量写回到主存储器。
这些条件中的任何一种都可能导致从另一个线程的角度来看,操作出现的顺序与程序指定的顺序不同-且无论重新排序的来源如何,内存模型都将其视为等效的。
JSR 133的目标
旨在修复JMM的JSR 133具有以下目标:
- 保留现有的安全保证,包括类型安全。
- 提供空气稀薄的安全性 。 这意味着不会“凭空”创建变量值-因此,要使一个线程观察到变量具有值X,某个线程必须在过去实际上已将值X写入该变量。
- “正确同步”程序的语义应尽可能简单,直观。 因此,应该在形式上和直观上定义“正确同步”(并且两个定义应该彼此一致!)。
- 程序员应该能够创建多线程程序,并确信它们将是可靠且正确的。 当然,没有什么魔术可以使并发应用程序的编写变得容易,但是目标是减轻应用程序编写者的负担,使他们不必了解内存模型的所有细节。
- 跨各种流行的硬件架构的高性能JVM实现应该是可能的。 现代处理器的内存模型大不相同。 JMM应该在不牺牲性能的情况下容纳尽可能多的实际架构。
- 提供一个同步惯用语,使我们可以发布对象,并使对象可见而无需同步。 这是称为初始化安全的新安全保证。
- 对现有代码的影响应该最小。
值得注意的是,在新的内存模型下,诸如双重检查锁定之类的破坏性技术仍然无效,并且“修复”双重检查锁定并非新的存储器模型工作的目标之一。 (但是,尽管仍然不鼓励使用这种技术,但volatile
的新语义允许对双重检查锁定的一种常见提议替代方法可以正常工作。)
自从JSR 133流程启动以来的三年中,已经发现这些问题比任何人都认为的要微妙得多。 这就是成为先锋的代价! 最终的形式语义比最初预期的要复杂,并且实际上采取的形式与最初设想的形式大不相同,但是非正式的语义清晰而直观,并将在本文的第2部分中进行概述。
同步和可见性
大多数程序员都知道, synchronized
关键字会强制执行一个互斥锁(互斥),该互斥锁可一次阻止多个线程进入由给定监视器保护的同步块。 但是同步还有另一个方面:它执行JMM指定的某些内存可见性规则。 它确保在退出同步块时刷新高速缓存,并在进入一个同步块时使高速缓存无效,从而使一个线程在给定监视器保护的同步块期间写入的值对于执行由该监视器保护的同步块的任何其他线程可见。 它还可以确保编译器不会将指令从同步块内部移至外部(尽管在某些情况下可以将指令从同步块内部移至外部)。 在没有同步的情况下,JMM不能保证这一点-这就是为什么当多个线程访问相同的变量时,必须使用同步(或其年轻的同级对象,volatile)。
问题1:不可变对象不是
JMM的最令人惊讶的失败之一是,通过使用final
关键字可以保证不可变性的不可变对象可能会改变其值。 (公共服务提示:将对象的所有字段定为final
不一定会使对象不可变-所有字段也必须是原始类型或对不可变对象的引用。)不可变对象(如String
)应该不需要同步。 但是,由于在将内存写入的变化从一个线程传播到另一个线程时可能存在延迟,因此存在一种可能的竞争条件,该条件将允许线程首先看到不可变对象的一个值,然后在以后的某个时间看到另一个值。
怎么会这样 考虑Sun 1.4 JDK中String
的实现,其中基本上有三个重要的final字段:对字符数组的引用,长度和描述要表示的字符串开始的字符数组的偏移量。 String
是通过这种方式实现的,而不是仅包含字符数组,因此可以在多个String
和StringBuffer
对象之间共享字符数组,而不必每次创建String
都将文本复制到新数组中。 例如, String.substring()
创建一个新字符串,该字符串与原始String
共享相同的字符数组,只是长度和偏移量字段有所不同。
假设您执行以下代码:
String s1 = "/usr/tmp";
String s2 = s1.substring(4); // contains "/tmp"
字符串s2
的偏移量为4,长度为4,但将与s1
共享相同的字符数组,即包含"/usr/tmp"
字符数组。 在运行String
构造函数之前, Object
的构造函数将使用其默认值初始化所有字段,包括最终的length和offset字段。 当String
构造函数运行时,长度和偏移量将被设置为其所需的值。 但是在旧的内存模型下,在没有同步的情况下,另一个线程可能会临时将offset字段视为默认值为0,然后再看到正确的值为4。 s2
从"/usr"
更改为"/tmp"
。 这不是预期的,并且可能并非在所有JVM或平台上都可行,但是旧内存模型规范允许这样做 。
问题2:对易失性和非易失性存储重新排序
现有JMM引起一些非常令人困惑的结果的另一个主要方面是对volatile
字段进行内存操作重新排序。 现有的JMM表示易失性读写将直接进入主存储器,从而禁止在寄存器中缓存值并绕过处理器特定的缓存。 这允许多个线程始终查看给定变量的最新值。 但是,事实证明,这种对volatile
定义不如最初预期的那样有用,并且导致对volatile
的实际含义产生极大的困惑。
为了在没有同步的情况下提供良好的性能,通常允许编译器,运行时和缓存对常规内存操作进行重新排序,只要当前执行的线程无法分辨出区别即可。 (这被称为线程内“如果是串行”语义 。)但是,易失性的读写在线程之间是完全有序的。 编译器或缓存无法对易失性读写进行重新排序。 不幸的是,JMM确实允许相对于普通变量读写对易失性读写进行重新排序,这意味着我们不能使用易失性标志来指示已完成哪些操作。 考虑以下代码,其目的是initialized
的volatile字段应指示初始化已完成:
清单1.将一个volatile字段用作“保护”变量。
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
的volatile变量充当防护,以指示一组其他操作已完成。 这是一个好主意,但是旧的JMM下,它没有工作,因为老的JMM允许非易失性写入(如在写configOptions
领域,以及在写入到的领域Map
通过引用configOptions
)是用易失性写入重新排序。 因此,另一个线程可能会看到已initialized
为true,但尚未对configOptions
字段或其引用的对象具有一致或当前的视图。 volatile
的旧语义仅对读取或写入的变量的可见volatile
作出承诺,而对其他变量没有任何承诺。 尽管此方法更容易有效实施,但结果却没有最初想象的有用。
摘要
正如Java语言规范的第17章所指定的那样,JMM具有一些严重的缺陷,这些缺陷使外观合理的程序发生了一些不直观和不希望的事情。 如果很难正确地编写并发类,那么我们可以保证许多并发类将无法按预期工作,并且这是平台中的一个缺陷。 幸运的是,有可能创建一个与大多数开发人员的直觉更一致的内存模型,同时又不破坏在旧内存模型下正确同步的任何代码,而JSR 133流程正是这样做的。 下个月,我们将研究新内存模型的细节(其中许多已经内置在1.4 JDK中)。
翻译自: https://www.ibm.com/developerworks/java/library/j-jtp02244/index.html