我一直在阅读很多关于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来强制执行before-before关系。没有它,JIT完全有可能重新加载我们的代码。例如:
线程1位于第6行,内存分配给helper
但是,构造函数尚未运行,因为JIT可以重新排序我们的代码。
线程2在第2行进入并获取尚未完全创建的对象。
我理解这一点,但我并不完全理解JIT对重新排序的限制。
例如,假设我有一个创建MyObject
并将HashMap<String, MyObject>
放入HashMap
的方法(我知道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 ));
}
不是线程安全的,不应该在多个中使用 - 线程环境,但请忍受我)。线程1调用createNewObject:
public MyObject getObject(String key){
return map.get(key);
}
同时线程2从Map调用get。
getObject(String key)
线程2是否可以从new MyObject( val )
接收未完全构造的对象?类似的东西:
getObject(String key)
map.put(key, new MyObject( val ))
或者Object
在完全构建之前,是否会将对象放入地图中?
我想象的答案是,它不会将一个物体放入地图直到完全构造(因为这听起来很糟糕)。那么JIT如何重新排序呢?
简而言之,它只能在创建新Map
时重新排序并将其分配给引用变量,例如双重检查锁定? JIT的完整纲要可能对于答案来说很重要,但我真正好奇的是它如何重新排序写入(如双重检查锁上的第6行)以及阻止它放入对象的原因是什么一个未完全构建的 String [] a = {"a", "b"};
String [] b = {"c", "d"};
String [][] ab = {a,b};
。
答案 0 :(得分:2)
警告:文字墙
你的问题的答案是在水平线之前。我将继续深入解释我的答案的第二部分中的基本问题(这与JIT无关,所以如果你只对JIT感兴趣的话)。问题第二部分的答案位于底部,因为它依赖于我进一步描述的内容。
TL; DR JIT会做任何想做的事情,JMM会做任何想做的事情,在你通过编写线程不安全代码的情况下有效。
注意:&#34;初始化&#34;指的是构造函数中发生的事情,它排除了其他任何东西,例如在构造之后调用静态init方法......
&#34;如果重新排序产生的结果与合法执行一致,则不是非法的。&#34; (JLS 17.4.5-200)
如果一组动作的结果符合JMM的有效执行链,则无论作者是否希望代码产生该结果,都允许结果。
&#34;内存模型描述了程序的可能行为。一个实现可以自由地生成它喜欢的任何代码,只要程序的所有结果执行产生一个可以由内存模型预测的结果。
这为实现者提供了大量的自由来执行无数的代码转换,包括重新排序动作和删除不必要的同步。 (JLS 17.4)。
除非我们不允许使用JMM(在多线程环境中),否则JIT会重新排序它认为合适的任何内容。
JIT可以或将要做的事情的细节是不确定的。查看数百万次运行样本将不会产生有意义的模式,因为重新排序是主观的,它们依赖于非常具体的细节,例如CPU拱,时序,启发式,图形大小,JVM供应商,字节码大小等......我们只知道当JIT不需要符合JMM 时,JIT将假定代码在单线程环境中运行。最后,JIT对您的多线程代码非常重要。如果您想深入挖掘,请参阅此SO answer并对IR Graphs,JDK HotSpot source和编译器文章(如this one)等主题进行一些研究。但同样,请记住,JIT与您的多线程代码转换几乎没有关系。
在实践中,尚未完全创建的&#34;对象&#34;不是JIT的副作用,而是内存模型(JMM)。总之,JMM是一种规范,它保证了某些操作的结果可以和不可以是什么,其中操作是涉及共享状态的操作。更高级别的概念(如atomicity, memory visibility, and ordering)更容易理解JMM,这三个概念是线程安全程序的组成部分。
为了证明这一点,你的第一个代码样本(DCL模式)极不可能被JIT修改,这将产生一个尚未完全创建的对象。&#34;事实上,我认为不可能这样做,因为它不会遵循单线程程序的顺序或执行。
那究竟是什么问题呢?
问题在于,如果操作不是按同步顺序排序,则是先前发生的顺序等...(由JLS 17.4-17.5再次描述),然后线程不能保证看到执行此类行为的副作用。 线程可能无法刷新其缓存以更新字段,线程可能观察写入乱序。特定于此示例,线程允许查看对象不一致的状态,因为它未正确发布。如果您曾经使用多线程工作,那么我确信您之前已经听说过安全发布。
您可能会问,如果JIT无法修改单线程执行,为什么多线程版本可以?
简单地说,它是因为线程被允许思考(&#34;感知&#34;通常在教科书中写入),由于缺乏正确的同步,初始化是无序的。
&#34;如果Helper是一个不可变对象,使得Helper的所有字段都是最终的,那么双重检查锁定将无需使用volatile字段即可工作。我们的想法是对不可变对象(如String或Integer)的引用应该与int或float的行为方式大致相同;读取和写入对不可变对象的引用是原子的&#34; (The "Double-Checked Locking is Broken" Declaration)。
使对象不可变确保状态为fully initialized when the constructor exits。
请记住,对象构造始终是不同步的。正在初始化的对象相对于构造它的线程是唯一可见且安全的。为了让其他线程看到初始化,您必须安全地发布它。以下是这些方法:
&#34;有一些简单的方法可以实现安全发布:
- 通过正确锁定的字段(JLS 17.4.5)
交换引用- 使用静态初始化程序执行初始化存储(JLS 12.4)
- 通过易失性字段(JLS 17.4.5)交换引用,或者作为此规则的结果,通过AtomicX类交换引用
- 将值初始化为最终字段(JLS 17.5)。&#34;
醇>
安全发布可确保其他线程在完成后能够看到完全初始化的对象。
重新审视我们的想法,即线程只保证看到副作用,如果它们是有序的,你需要volatile
的原因是你在线程1中对助手的写入是按照读入的顺序排序的线程2.在读取之后,线程2不允许感知初始化,因为它发生在写入助手之前。它依赖于易失性写入,因此读取必须在初始化之后发生,然后写入易失性字段(传递属性)。
总而言之,初始化只会在创建对象后才会发生,因为另一个线程认为是该命令。由于JIT优化,在构造之后永远不会发生初始化。您可以通过确保通过volatile字段正确发布或使您的帮助程序不可变来解决此问题。
既然我已经描述了JMM中出版物如何运作背后的一般概念,希望了解你的第二个例子如何工作将很容易。
我想象的答案是,它不会将一个物体放入地图直到完全构造(因为这听起来很糟糕)。那么JIT如何重新排序呢?
对于构造线程,它将在初始化后将其放入映射中。
对于读者线程,它可以看到它想要的任何东西。 (在HashMap中构造不正确的对象?这绝对属于可能性范围)。
您在4个步骤中描述的内容完全合法。分配value
或将其添加到地图之间没有顺序,因此线程2 可以感知无序初始化,因为MyObject
已发布不安全。
你实际上可以通过转换为ConcurrentHashMap
来解决这个问题,而getObject()
将是完全线程安全的,因为一旦你将对象放在地图中,初始化将在put之前发生,两者都需要由于get
是线程安全的,ConcurrentHashMap
之前发生。但是,一旦修改了对象,它就会成为一个管理噩梦,因为你需要确保更新状态是可见的和原子的 - 如果一个线程检索一个对象而另一个线程在第一个线程完成修改和放置之前更新该对象,该怎么办?它回到了地图上?
T1 -> get() MyObject=30 ------> +1 --------------> put(MyObject=31)
T2 -------> get() MyObject=30 -------> +1 -------> put(MyObject=31)
或者您也可以使MyObject
不可变,但您仍需要映射映射ConcurrentHashMap
以便其他线程看到put
- 线程缓存行为可能会缓存旧副本而不是冲洗并继续重复使用旧版本。 ConcurrentHashMap
确保其写入对读者可见并确保线程安全。回顾线程安全的3个先决条件,我们通过使用线程安全的数据结构,使用不可变对象的原子性,最后通过搭载ConcurrentHashMap
的线程安全来获得可见性。
总结这整个答案,我会说多线程是一个非常难以掌握的职业,我自己绝对没有。通过了解程序线程安全的概念并考虑JMM允许和保证的内容,您可以确保代码执行您希望它执行的操作。多线程代码中的错误通常是 ,因为JMM允许在其参数内的反直觉结果,而不是JIT执行性能优化。如果您阅读所有内容,希望您能学到更多关于多线程的知识。线程安全应该通过构建一个线程安全的范例来实现,而不是使用规范的一点点不便(Lea或Bloch,甚至不确定是谁这样说)。