解释JIT重新排序的工作原理

问题描述 投票:2回答:1

我一直在阅读很多关于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. 线程1在第6行,内存分配给helper但是,构造函数尚未运行,因为JIT可以重新排序我们的代码。
  2. 线程2进入第2行并获取尚未完全创建的对象。

我理解这一点,但我并不完全理解JIT对重新排序的限制。

例如,假设我有一个方法创建并将MyObject放入HashMap<String, MyObject>(我知道HashMapis不是线程安全的,不应该在多线程环境中使用,但请耐心等待)。线程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. 线程1:为new MyObject( val )分配内存
  2. 线程1:将对象放在地图中
  3. 线程2:调用getObject(String key)
  4. 线程1:完成构造新的MyObject。

或者map.put(key, new MyObject( val ))在完全构建之前不会将对象放入地图中?

我想,答案是,在完全构造之前,它不会将对象放入Map中(因为这听起来很糟糕)。那么JIT如何重新排序呢?

简而言之,它只能在创建新的Object并将其分配给引用变量时重新排序,例如双重检查锁定?关于JIT的完整纲要可能对于SO答案来说很多,但我真正好奇的是它如何重新排序写入(如双重检查锁上的第6行)以及阻止它将对象放入Map的原因那是没有完全构建的。

java multithreading synchronization jit
1个回答
2
投票

警告:文字墙

你的问题的答案是在水平线之前。我将继续深入解释我的答案的第二部分中的基本问题(这与JIT无关,所以如果你只对JIT感兴趣就是这样)。问题第二部分的答案位于底部,因为它依赖于我进一步描述的内容。

TL; DR JIT会做任何想做的事情,JMM会做任何想做的事情,在你通过编写线程不安全的代码让它们有效的情况下有效。

注意:“初始化”是指构造函数中发生的事情,它排除了其他任何内容,例如在构造之后调用静态init方法等...


“如果重新排序产生与法律执行一致的结果,那么这不是非法的。” (JLS 17.4.5-200

如果一组动作的结果符合JMM的有效执行链,则无论作者是否希望代码产生该结果,都允许结果。

“内存模型描述了程序的可能行为。一个实现可以自由地生成它喜欢的任何代码,只要程序的所有结果执行产生可以由内存模型预测的结果。

这为实现者提供了大量的自由来执行无数的代码转换,包括动作的重新排序和删除不必要的同步“(JLS 17.4)。

除非我们不允许它使用JMM(在多线程环境中),否则JIT将重新排序它认为合适的任何内容。

JIT可以或将要做的事情的细节是不确定的。查看数百万次运行样本将不会产生有意义的模式,因为重新排序是主观的,它们依赖于非常具体的细节,例如CPU拱,时序,启发式,图形大小,JVM供应商,字节码大小等......我们只知道JIT将假定代码在不需要符合JMM的情况下在单线程环境中运行。最后,JIT对您的多线程代码非常重要。如果你想深入挖掘,请参阅此SO answer并对IR GraphsJDK HotSpot source和编译器文章(如this one)等主题进行一些研究。但同样,请记住,JIT与您的多线程代码转换几乎没有关系。


实际上,“尚未完全创建的对象”不是JIT的副作用,而是内存模型(JMM)。总之,JMM是一种规范,它保证了某些动作的结果可以和不可以是什么,其中动作是涉及共享状态的动作。更高级别的概念(例如atomicity, memory visibility, and ordering)更容易理解JMM,这三个概念是线程安全程序的组件。

为了证明这一点,您的第一个代码示例(DCL模式)极不可能被JIT修改,这将产生“尚未完全创建的对象”。事实上,我认为不可能这样做,因为它不会遵循单线程程序的顺序或执行。

那究竟是什么问题呢?

问题是,如果动作不按同步顺序排序,发生先前顺序等等(由JLS 17.4-17.5再次描述),则线程不能保证看到执行此类动作的副作用。线程可能无法刷新其缓存以更新字段,线程可能会观察到无序写入。特定于此示例,允许线程以不一致的状态查看对象,因为它未正确发布。如果您曾经使用多线程工作,那么我确信您之前已经听说过安全发布。

您可能会问,如果JIT无法修改单线程执行,为什么多线程版本可以?

简而言之,这是因为允许线程思考(“感知”通常在教科书中写的)由于缺乏适当的同步而导致初始化失序。

“如果Helper是一个不可变对象,Helper的所有字段都是final,那么双重检查锁定就可以工作而不必使用volatile字段。这个想法是对不可变对象的引用(例如String或者整数)的行为方式与int或float的行为方式大致相同;读取和写入对不可变对象的引用是原子的“(The "Double-Checked Locking is Broken" Declaration)。

使对象不可变确保状态为fully initialized when the constructor exits

请记住,对象构造始终是不同步的。正在初始化的对象相对于构造它的线程是唯一可见且安全的。为了让其他线程看到初始化,您必须安全地发布它。以下是这些方法:

“实现安全发布有一些简单的方法:

  1. 通过正确锁定的字段交换引用(JLS 17.4.5)
  2. 使用静态初始化程序来执行初始化存储(JLS 12.4)
  3. 通过易失性字段(JLS 17.4.5)或作为此规则的结果,通过AtomicX类交换引用
  4. 将值初始化为最终字段(JLS 17.5)。“

(Qazxswpoi)

安全发布确保其他线程在完成后能够看到完全初始化的对象。

重新审视我们的想法,线程只保证看到副作用,如果它们是有序的,你需要Safe Publication and Safe Initialization in Java的原因是你在线程1中对助手的写入是相对于线程2中的读取排序的。线程2不是允许在读取之后感知初始化,因为它发生在写入助手之前。它依赖于易失性写入,使得读取必须在初始化之后发生,然后写入易失性字段(传递属性)。

总而言之,只有在创建对象之后才会进行初始化,因为另一个线程认为是顺序。由于JIT优化,在构造之后永远不会发生初始化。您可以通过确保通过volatile字段正确发布或使您的帮助程序不可变来解决此问题。


现在我已经描述了JMM中发布如何工作背后的一般概念,希望了解你的第二个例子如何不起作用将很容易。

我想,答案是,在完全构造之前,它不会将对象放入Map中(因为这听起来很糟糕)。那么JIT如何重新排序呢?

对于构造线程,它将在初始化后将其放入映射中。

对于读者线程,它可以看到它想要的任何东西。 (在HashMap中构造不正确的对象?这绝对属于可能性范围)。

你用4个步骤描述的是完全合法的。在分配volatile或将其添加到地图之间没有顺序,因此线程2可以无序地感知初始化,因为value发布不安全。

你实际上可以通过转换为MyObject解决这个问题,ConcurrentHashMap将完全是线程安全的,因为一旦你把对象放在地图中,初始化将在put之前发生,并且两者都需要在getObject()之前发生get线程安全。但是,一旦修改了对象,它就会成为一个管理噩梦,因为你需要确保更新状态是可见的和原子的 - 如果一个线程检索一个对象而另一个线程在第一个线程完成修改和放置之前更新该对象,该怎么办?它回到了地图中?

ConcurrentHashMap

或者你也可以使T1 -> get() MyObject=30 ------> +1 --------------> put(MyObject=31) T2 -------> get() MyObject=30 -------> +1 -------> put(MyObject=31) 不可变,但你仍然需要映射地图MyObject,以便其他线程看到ConcurrentHashMap - 线程缓存行为可能缓存旧副本而不是刷新并继续重用旧版本。 put确保其写入对读者可见并确保线程安全。回顾线程安全的3个先决条件,我们通过使用线程安全的数据结构,使用不可变对象的原子性,最后通过搭载ConcurrentHashMap的线程安全来获得可见性。

总结一下这个问题,我会说多线程是一个非常难以掌握的职业,我自己绝对没有。通过了解程序线程安全的概念并考虑JMM允许和保证的内容,您可以确保代码执行您希望它执行的操作。多线程代码中的错误经常发生,因为JMM允许在其参数内的反直觉结果,而不是JIT执行性能优化。如果您阅读所有内容,希望您能学到更多关于多线程的知识。线程安全应该通过构建一个线程安全的范例来实现,而不是使用规范的一点点不便(Lea或Bloch,甚至不确定是谁说的)。

© www.soinside.com 2019 - 2024. All rights reserved.