小编典典

解释JIT重新排序的工作方式

java

我已经阅读了很多有关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在重新排序方面的限制。

例如,假设我有一个创建一个方法,并提出一MyObjectHashMap<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. 线程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那个对象中的原因。尚未完全构建。


阅读 334

收藏
2020-11-26

共1个答案

小编典典

警告:文字墙

您的问题的答案在水平线之前。我将在答案的第二部分(与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答案,做等题目有一点研究IR图表中,JDK热点源,和编译器的文章,如这一个。但是再次提醒您,JIT与您的多线程代码转换几乎没有关系。


实际上,“尚未完全创建的对象”不是JIT的副作用,而是内存模型(JMM)。总而言之,JMM是一种规范,它对某些动作集的结果进行保证,其中动作是涉及共享状态的操作。通过更高级的概念(例如原子性,内存可见性和顺序)可以更容易地理解JMM ,这三个概念是线程安全程序的组件。

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

那到底是什么问题呢?

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

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

简而言之,这是因为由于缺少适当的同步,允许线程认为(通常是教科书中所写的“感知”)乱序。

“如果Helper是一个不可变的对象,因此Helper的所有字段都是最终的,那么经过双重检查的锁定将可以工作,而不必使用volatile字段。该想法是引用一个不可变的对象(例如String或Integer)的行为应与int或float大致相同;读取和写入对不可变对象的引用是原子的”(“双重检查锁定已损坏”声明)。

使对象不可变可确保在构造函数退出时完全初始化状态。

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

“有几种简单的方法可以实现安全发布:

  1. 通过适当锁定的字段交换参考(JLS 17.4.5)
  2. 使用静态初始化程序进行初始化存储(JLS 12.4)
  3. 通过volatile字段(JLS 17.4.5)或作为此规则的结果,通过AtomicX类交换引用
  4. 将值初始化为最终字段(JLS 17.5)。”

Java中的安全发布和安全初始化

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

重新考虑我们的想法,即仅保证线程按顺序排列才能保证看到副作用,所以您需要这样做的原因volatile是,相对于线程2的读取,对线程1中的助手的写入是有序的。在读取之后感知到初始化,因为它发生在向辅助程序的写入之前。它背负易失性写操作,以便必须在初始化之后进行读取,然后再对易失性字段(传递属性)进行写操作。

总而言之,仅在创建对象后才进行初始化,这是因为另一个线程按顺序进行了思考。由于JIT优化,构造后永远不会进行初始化。您可以通过在可变字段中确保适当的发布或使助手不可变来解决此问题。


现在,我已经描述了JMM中发布如何工作的基本概念, 希望 很容易理解您的第二个示例将如何工作。

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

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

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

您用4个步骤描述的内容 完全合法 。在value将其分配或添加到映射之间没有顺序,因此线程2 可以 感知
到初始化不正确,
因为它MyObject是不安全发布的。

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

T1 -> get() MyObject=30 ------> +1 --------------> put(MyObject=31)
T2 -------> get() MyObject=30 -------> +1 -------> put(MyObject=31)

或者,您也可以将其MyObject设为不可变的,但仍需要映射该映射ConcurrentHashMap,以使其他线程看到put-线程缓存行为可能会缓存旧副本,而不刷新并继续重用旧版本。ConcurrentHashMap确保其写入对读者可见,并确保线程安全。回顾线程安全的三个先决条件,我们从使用线程安全的数据结构获得可见性,通过使用不可变对象获得原子性,最后通过ity带ConcurrentHashMap线程安全性进行排序。

要总结整个答案,我会说多线程是一个很难掌握的专业,我自己绝对不是。通过了解使程序具有线程安全性的概念,并考虑JMM允许和保证的内容,可以确保您的代码将执行您希望执行的操作。多线程代码中的错误
通常
是由于JMM允许在其参数范围内产生违反直觉的结果而导致的,而不是JIT进行性能优化。如果您阅读了所有内容,希望您会学到更多有关多线程的知识。线程安全应该通过构建线程安全范例集来实现,而不是使用规范的不便之处(Lea或Bloch,甚至不确定谁说了这一点)。

2020-11-26