解释 JIT 重新排序的工作原理
Explain how JIT reordering works
我已经阅读了很多关于 Java 中的同步以及所有可能发生的问题。但是,我仍然有点困惑的是 JIT 如何重新排序写入。
例如,一个简单的双重检查锁对我来说很有意义:
class Foo {
private volatile Helper helper = null; // 1
public Helper getHelper() { // 2
if (helper == null) { // 3
synchronized(this) { // 4
if (helper == null) // 5
helper = new Helper(); // 6
}
}
return helper;
}
}
我们在第 1 行使用 volatile 来强制执行事前发生关系。没有它,JIT 完全有可能重新编排我们的代码。例如:
线程 1 在第 6 行,内存分配给 helper
但是,构造函数还没有 运行 因为 JIT 可以重新排序我们的代码。
线程 2 在第 2 行进入并获取一个尚未完全创建的对象。
我理解这一点,但我不完全理解 JIT 对重新排序的限制。
例如,假设我有一个创建 MyObject
并将其放入 HashMap<String, MyObject>
的方法(我知道 HashMap
不是线程安全的,不应在多线程环境,但请耐心等待)。线程 1 调用 createNewObject:
public class MyObject {
private Double value = null;
public MyObject(Double value) {
this.value = value;
}
}
Map<String, MyObject> map = new HashMap<String, MyObject>();
public void createNewObject(String key, Double val){
map.put(key, new MyObject( val ));
}
同时线程 2 从 Map 中调用一个 get。
public MyObject getObject(String key){
return map.get(key);
}
线程2是否有可能从getObject(String key)
接收到一个未完全构造的对象?类似于:
- 线程 1:为
new MyObject( val )
分配内存
- 线程 1:在地图中放置对象
- 线程 2:调用
getObject(String key)
- 线程 1:完成新 MyObject 的构造。
或者 map.put(key, new MyObject( val ))
对象在完全构建之前不会将其放入地图中?
我想答案是,在完全构造之前,它不会将对象放入 Map(因为这听起来很糟糕)。那么 JIT 如何重新排序呢?
简而言之,它只能在创建新的 Object
并将其分配给参考变量时重新排序,例如双重检查锁吗? JIT 的完整 运行down 可能对 SO 答案有很大帮助,但我真正好奇的是它如何重新排序写入(如双重检查锁上的第 6 行)以及阻止它放置的原因一个未完全构建的 Map
对象。
警告:文字墙
你问题的答案在横线之前。我将在我的答案的第二部分继续更深入地解释基本问题(这与 JIT 无关,所以如果你只对 JIT 感兴趣的话就是这样)。你问题第二部分的答案在底部,因为它依赖于我进一步描述的内容。
TL;DR JIT 会为所欲为,JMM 会为所欲为,在您通过编写线程不安全代码让它们执行的条件下有效。
注意:“初始化”指的是构造函数中发生的事情,不包括在构造后调用静态 init 方法等任何其他内容...
"If the reordering produces results consistent with a legal execution, it is not illegal." (JLS 17.4.5-200)
如果一组操作的结果符合 JMM 的有效执行链,则无论作者是否希望代码产生该结果,该结果都是允许的。
"The memory model describes possible behaviors of a program. An implementation is free to produce any code it likes, as long as all resulting executions of a program produce a result that can be predicted by the memory model.
This provides a great deal of freedom for the implementor to perform a myriad of code transformations, including the reordering of actions and removal of unnecessary synchronization" (JLS 17.4).
JIT 将重新排序它认为合适的任何内容,除非我们不允许它使用 JMM(在多线程环境中)。
JIT 能够或将会做什么的细节是不确定的。查看数百万个运行样本不会产生有意义的模式,因为重新排序是主观的,它们取决于非常具体的细节,例如 CPU 结构、时间、启发式方法、图形大小、JVM 供应商、字节码大小等...我们只知道,JIT 在不需要符合 JMM 时,会假定代码在单线程环境下运行 。最后,JIT 对您的多线程代码影响不大。如果您想深入挖掘,请参阅此 and do a little research on such topics as IR Graphs, the JDK HotSpot source, and compiler articles such as this one。但是,请再次记住,JIT 与您的多线程代码转换关系不大。
实际上,“尚未完全创建的对象”不是 JIT 的副作用,而是内存模型 (JMM) 的副作用。总而言之,JMM 是一种规范,它保证什么可以是什么不能是一组特定操作的结果,其中操作是涉及共享状态的操作。 JMM 更容易被更高级别的概念理解,例如 atomicity, memory visibility, and ordering,这三个概念是 thread-safe 程序的组成部分。
为了证明这一点,您的第一个代码样本(DCL 模式)极不可能被 JIT 修改,从而产生“尚未完全创建的对象”。事实上,我认为 不可能这样做,因为它不会遵循 single-threaded 程序的顺序或执行。
那么这里到底有什么问题呢?
问题是,如果操作不是按同步顺序、happens-before 顺序等排序的...(由 JLS 17.4-17.5 再次描述),则线程是 不保证看到执行此类操作的副作用。 线程可能不会刷新它们的缓存来更新字段,线程可能 观察到 写乱序。具体到这个例子,线程是 允许查看处于不一致状态的对象,因为它未正确发布。如果您曾经使用过多线程,那么我相信您以前听说过安全发布。
您可能会问,如果 single-threaded 执行不能被 JIT 修改,为什么多线程版本可以?
简单来说,就是允许线程认为(教科书上通常写的“感知”)由于缺乏适当的同步而导致初始化乱序。
"If Helper is an immutable object, such that all of the fields of Helper are final, then double-checked locking will work without having to use volatile fields. The idea is that a reference to an immutable object (such as a String or an Integer) should behave in much the same way as an int or float; reading and writing references to immutable objects are atomic" (The "Double-Checked Locking is Broken" Declaration).
使对象不可变可确保状态为 fully initialized when the constructor exits。
请记住,对象构造始终是不同步的。正在初始化的对象仅对构造它的线程可见且安全。为了让其他线程看到初始化,您必须安全地发布它。以下是这些方法:
"There are a few trivial ways to achieve safe publication:
- Exchange the reference through a properly locked field (JLS 17.4.5)
- Use static initializer to do the initializing stores (JLS 12.4)
- Exchange the reference via a volatile field (JLS 17.4.5), or as the consequence of this rule, via the AtomicX classes
- Initialize the value into a final field (JLS 17.5)."
安全发布确保其他线程在完成后能够看到完全初始化的对象。
重新审视我们的想法,即线程只有按顺序才能保证看到副作用,您需要 volatile
的原因是您对线程 1 中的帮助程序的写入是相对于在线程 2 中读取。线程 2 不允许感知读取后的初始化,因为它发生在写入 helper 之前。它搭载易失性写入,这样读取必须在初始化之后发生,然后写入易失性字段(传递 属性)。
总而言之,只有在创建对象之后才会进行初始化,因为另一个线程认为这是顺序。由于 JIT 优化,构造后永远不会发生初始化。您可以通过 volatile 字段确保正确发布或使您的助手不可变来解决此问题。
现在我已经描述了 gen了解 JMM 中发布工作原理背后的基本概念,希望 理解您的第二个示例如何无法工作会很容易。
I'd imagine that the answer is, it wouldn't put an object into the Map until it is fully constructed (because that sounds awful). So how can the JIT reorder?
对于构造线程,初始化后会放入map中。
对于reader线程,它可以看到任何它想看到的东西。 (HashMap中对象构造不当?这绝对是有可能的)
您描述的 4 个步骤完全合法。分配 value
或将其添加到映射之间没有顺序,因此线程 2 可以 感知 初始化乱序 因为 MyObject
发布不安全。
您实际上可以通过转换为 ConcurrentHashMap
来解决这个问题,并且 getObject()
将是完全线程安全的,因为一旦您将对象放入映射中,初始化将在 put 之前发生,并且两者由于 ConcurrentHashMap
是线程安全的,因此需要在 get
之前发生。然而,一旦你修改了对象,这将成为管理的噩梦,因为你需要确保更新状态是可见的和原子的——如果一个线程检索一个对象而另一个线程在第一个线程完成修改和放置之前更新该对象怎么办它回到地图上了吗?
T1 -> get() MyObject=30 ------> +1 --------------> put(MyObject=31)
T2 -------> get() MyObject=30 -------> +1 -------> put(MyObject=31)
或者你也可以让 MyObject
不可变,但你仍然需要映射映射 ConcurrentHashMap
以便其他线程看到 put
- 线程缓存行为可能会缓存一个旧副本而不是刷新并继续重复使用旧版本。 ConcurrentHashMap
确保其写入对 readers 可见并确保 thread-safety。回顾我们 thread-safety 的 3 个先决条件,我们通过使用 thread-safe 数据结构获得可见性,通过使用不可变对象获得原子性,最后通过搭载 ConcurrentHashMap
的线程安全获得排序。
为了总结整个答案,我会说多线程是一个非常难以掌握的职业,我自己绝对没有。通过理解什么构成程序的概念 thread-safe 并思考 JMM 允许和保证的内容,您可以确保您的代码将执行您希望它执行的操作。多线程代码中的错误经常 由于 JMM 允许在其参数范围内的违反直觉的结果,而不是 JIT 进行性能优化。如果您阅读了所有内容,希望您对多线程有了更多的了解。线程安全应该通过构建 thread-safe 范例的全部内容来实现,而不是使用规范的小麻烦(Lea 或 Bloch,甚至不确定是谁说的)。
我已经阅读了很多关于 Java 中的同步以及所有可能发生的问题。但是,我仍然有点困惑的是 JIT 如何重新排序写入。
例如,一个简单的双重检查锁对我来说很有意义:
class Foo {
private volatile Helper helper = null; // 1
public Helper getHelper() { // 2
if (helper == null) { // 3
synchronized(this) { // 4
if (helper == null) // 5
helper = new Helper(); // 6
}
}
return helper;
}
}
我们在第 1 行使用 volatile 来强制执行事前发生关系。没有它,JIT 完全有可能重新编排我们的代码。例如:
线程 1 在第 6 行,内存分配给
helper
但是,构造函数还没有 运行 因为 JIT 可以重新排序我们的代码。线程 2 在第 2 行进入并获取一个尚未完全创建的对象。
我理解这一点,但我不完全理解 JIT 对重新排序的限制。
例如,假设我有一个创建 MyObject
并将其放入 HashMap<String, MyObject>
的方法(我知道 HashMap
不是线程安全的,不应在多线程环境,但请耐心等待)。线程 1 调用 createNewObject:
public class MyObject {
private Double value = null;
public MyObject(Double value) {
this.value = value;
}
}
Map<String, MyObject> map = new HashMap<String, MyObject>();
public void createNewObject(String key, Double val){
map.put(key, new MyObject( val ));
}
同时线程 2 从 Map 中调用一个 get。
public MyObject getObject(String key){
return map.get(key);
}
线程2是否有可能从getObject(String key)
接收到一个未完全构造的对象?类似于:
- 线程 1:为
new MyObject( val )
分配内存
- 线程 1:在地图中放置对象
- 线程 2:调用
getObject(String key)
- 线程 1:完成新 MyObject 的构造。
或者 map.put(key, new MyObject( val ))
对象在完全构建之前不会将其放入地图中?
我想答案是,在完全构造之前,它不会将对象放入 Map(因为这听起来很糟糕)。那么 JIT 如何重新排序呢?
简而言之,它只能在创建新的 Object
并将其分配给参考变量时重新排序,例如双重检查锁吗? JIT 的完整 运行down 可能对 SO 答案有很大帮助,但我真正好奇的是它如何重新排序写入(如双重检查锁上的第 6 行)以及阻止它放置的原因一个未完全构建的 Map
对象。
警告:文字墙
你问题的答案在横线之前。我将在我的答案的第二部分继续更深入地解释基本问题(这与 JIT 无关,所以如果你只对 JIT 感兴趣的话就是这样)。你问题第二部分的答案在底部,因为它依赖于我进一步描述的内容。
TL;DR JIT 会为所欲为,JMM 会为所欲为,在您通过编写线程不安全代码让它们执行的条件下有效。
注意:“初始化”指的是构造函数中发生的事情,不包括在构造后调用静态 init 方法等任何其他内容...
"If the reordering produces results consistent with a legal execution, it is not illegal." (JLS 17.4.5-200)
如果一组操作的结果符合 JMM 的有效执行链,则无论作者是否希望代码产生该结果,该结果都是允许的。
"The memory model describes possible behaviors of a program. An implementation is free to produce any code it likes, as long as all resulting executions of a program produce a result that can be predicted by the memory model.
This provides a great deal of freedom for the implementor to perform a myriad of code transformations, including the reordering of actions and removal of unnecessary synchronization" (JLS 17.4).
JIT 将重新排序它认为合适的任何内容,除非我们不允许它使用 JMM(在多线程环境中)。
JIT 能够或将会做什么的细节是不确定的。查看数百万个运行样本不会产生有意义的模式,因为重新排序是主观的,它们取决于非常具体的细节,例如 CPU 结构、时间、启发式方法、图形大小、JVM 供应商、字节码大小等...我们只知道,JIT 在不需要符合 JMM 时,会假定代码在单线程环境下运行 。最后,JIT 对您的多线程代码影响不大。如果您想深入挖掘,请参阅此
实际上,“尚未完全创建的对象”不是 JIT 的副作用,而是内存模型 (JMM) 的副作用。总而言之,JMM 是一种规范,它保证什么可以是什么不能是一组特定操作的结果,其中操作是涉及共享状态的操作。 JMM 更容易被更高级别的概念理解,例如 atomicity, memory visibility, and ordering,这三个概念是 thread-safe 程序的组成部分。
为了证明这一点,您的第一个代码样本(DCL 模式)极不可能被 JIT 修改,从而产生“尚未完全创建的对象”。事实上,我认为 不可能这样做,因为它不会遵循 single-threaded 程序的顺序或执行。
那么这里到底有什么问题呢?
问题是,如果操作不是按同步顺序、happens-before 顺序等排序的...(由 JLS 17.4-17.5 再次描述),则线程是 不保证看到执行此类操作的副作用。 线程可能不会刷新它们的缓存来更新字段,线程可能 观察到 写乱序。具体到这个例子,线程是 允许查看处于不一致状态的对象,因为它未正确发布。如果您曾经使用过多线程,那么我相信您以前听说过安全发布。
您可能会问,如果 single-threaded 执行不能被 JIT 修改,为什么多线程版本可以?
简单来说,就是允许线程认为(教科书上通常写的“感知”)由于缺乏适当的同步而导致初始化乱序。
"If Helper is an immutable object, such that all of the fields of Helper are final, then double-checked locking will work without having to use volatile fields. The idea is that a reference to an immutable object (such as a String or an Integer) should behave in much the same way as an int or float; reading and writing references to immutable objects are atomic" (The "Double-Checked Locking is Broken" Declaration).
使对象不可变可确保状态为 fully initialized when the constructor exits。
请记住,对象构造始终是不同步的。正在初始化的对象仅对构造它的线程可见且安全。为了让其他线程看到初始化,您必须安全地发布它。以下是这些方法:
"There are a few trivial ways to achieve safe publication:
- Exchange the reference through a properly locked field (JLS 17.4.5)
- Use static initializer to do the initializing stores (JLS 12.4)
- Exchange the reference via a volatile field (JLS 17.4.5), or as the consequence of this rule, via the AtomicX classes
- Initialize the value into a final field (JLS 17.5)."
安全发布确保其他线程在完成后能够看到完全初始化的对象。
重新审视我们的想法,即线程只有按顺序才能保证看到副作用,您需要 volatile
的原因是您对线程 1 中的帮助程序的写入是相对于在线程 2 中读取。线程 2 不允许感知读取后的初始化,因为它发生在写入 helper 之前。它搭载易失性写入,这样读取必须在初始化之后发生,然后写入易失性字段(传递 属性)。
总而言之,只有在创建对象之后才会进行初始化,因为另一个线程认为这是顺序。由于 JIT 优化,构造后永远不会发生初始化。您可以通过 volatile 字段确保正确发布或使您的助手不可变来解决此问题。
现在我已经描述了 gen了解 JMM 中发布工作原理背后的基本概念,希望 理解您的第二个示例如何无法工作会很容易。
I'd imagine that the answer is, it wouldn't put an object into the Map until it is fully constructed (because that sounds awful). So how can the JIT reorder?
对于构造线程,初始化后会放入map中。
对于reader线程,它可以看到任何它想看到的东西。 (HashMap中对象构造不当?这绝对是有可能的)
您描述的 4 个步骤完全合法。分配 value
或将其添加到映射之间没有顺序,因此线程 2 可以 感知 初始化乱序 因为 MyObject
发布不安全。
您实际上可以通过转换为 ConcurrentHashMap
来解决这个问题,并且 getObject()
将是完全线程安全的,因为一旦您将对象放入映射中,初始化将在 put 之前发生,并且两者由于 ConcurrentHashMap
是线程安全的,因此需要在 get
之前发生。然而,一旦你修改了对象,这将成为管理的噩梦,因为你需要确保更新状态是可见的和原子的——如果一个线程检索一个对象而另一个线程在第一个线程完成修改和放置之前更新该对象怎么办它回到地图上了吗?
T1 -> get() MyObject=30 ------> +1 --------------> put(MyObject=31)
T2 -------> get() MyObject=30 -------> +1 -------> put(MyObject=31)
或者你也可以让 MyObject
不可变,但你仍然需要映射映射 ConcurrentHashMap
以便其他线程看到 put
- 线程缓存行为可能会缓存一个旧副本而不是刷新并继续重复使用旧版本。 ConcurrentHashMap
确保其写入对 readers 可见并确保 thread-safety。回顾我们 thread-safety 的 3 个先决条件,我们通过使用 thread-safe 数据结构获得可见性,通过使用不可变对象获得原子性,最后通过搭载 ConcurrentHashMap
的线程安全获得排序。
为了总结整个答案,我会说多线程是一个非常难以掌握的职业,我自己绝对没有。通过理解什么构成程序的概念 thread-safe 并思考 JMM 允许和保证的内容,您可以确保您的代码将执行您希望它执行的操作。多线程代码中的错误经常 由于 JMM 允许在其参数范围内的违反直觉的结果,而不是 JIT 进行性能优化。如果您阅读了所有内容,希望您对多线程有了更多的了解。线程安全应该通过构建 thread-safe 范例的全部内容来实现,而不是使用规范的小麻烦(Lea 或 Bloch,甚至不确定是谁说的)。