【问题标题】:Read field stale value after object construction对象构造后读取字段陈旧值
【发布时间】:2018-11-12 07:54:33
【问题描述】:

我正在阅读 Brian Goetz 的《Java 并发实践》一书。第 3.5 和 3.5.1 段包含我无法理解的陈述。

考虑以下代码:

public class Holder {
  private int value;
  public Holder(int value) { 
    this.value = value;
  }

  public void assertValue() {
    if (value != value) throw new AssertionError("Magic");
  }
}

class HolderContainer {
  // Unsafe publication
  public Holder holder;

  public void init() {
    holder = new Holder(42);  
  }
}

作者声明:

  1. 在 Java 中,Object 构造函数在子类构造函数运行之前首先将默认值写入所有字段。
  2. 因此,可能会将字段默认值视为陈旧值。
  3. 线程第一次读取字段时可能会看到陈旧的值,然后在下一次读取更新的值,这就是为什么 assertN 会抛出 AssertionError。

因此,根据文本,在一些不走运的时机可能 value = 0;下一刻 value = 42。

我同意第 1 点,即 Object 构造函数首先用默认值填充字段。但我不明白第 2 点和第 3 点。

让我们更新作者代码并考虑以下示例:

public class Holder {
  int value;

  public Holder(int value) {
    //Sleep to prevent constructor to finish too early
    try {
     Thread.sleep(3000);
    } catch (InterruptedException e) {
     e.printStackTrace();
    }
    this.value = value;
  }

  public void assertValue()  {
    if(value != value) System.out.println("Magic");
  }
}

我添加了 Thread.sleep(3000),强制线程在对象完全构造之前等待。

public class Tests {

  private HolderContainer hc = new HolderContainer();

  class Initialization implements Runnable {
    public void run() {
      hc.init();
    }
  }

  class Checking implements Runnable {
    public void run() {
      hc.holder.assertValue();
    }
  }

  public void run() {
    new Thread(new Initialization()).start();
    new Thread(new Checking()).start();
  }
}

例如:

  1. 第一个线程初始化持有者对象
  2. 第二个线程调用assertValue

主线程运行两个线程:

  1. 新线程(新初始化()).start();完全构建 Holder 对象需要 3 秒
  2. 新线程(新检查()).start();由于 Holder 对象仍未构造,代码将抛出 NullPointerException

因此,无法模拟字段具有默认值的情况。

我的问题:

  1. 作者对这个并发问题有误?
  2. 或者无法模拟字段默认值的行为?

【问题讨论】:

  • @VinceEmigh,我已经在 J​​ava 6 和 Java 8 上测试过了
  • @AxelH 正如我所写:“new Thread(new Checking()).start(); 因为 Holder 对象仍未构造,代码将引发异常”。我的意思是我们将拥有 NPE。
  • 那么可能需要检查更多的测试用例。明白了;)
  • 只有在线程从构造函数内部启动时才有可能 - see here。我能够使用链接中的示例触发异常,但只有当我将断言更改为if (value != 42)

标签: java multithreading concurrency java-memory-model safe-publication


【解决方案1】:

我尝试用以下代码测试问题。

测试:

public class Test {
    public static boolean flag =true;
    public static HolderContainer hc=new HolderContainer();

    public static void main (String args[]){    
        new Thread(new Initialization()).start();
        new Thread(new Checking()).start();
    }
}

class Initialization implements Runnable {
    public void run() {
        while (Test.flag){
            Test.hc=new HolderContainer();
            Test.hc.init();
            }
    }
}

class Checking implements Runnable {
    public void run() {
        try{
            Test.hc.holder.assertValue();
        }
        catch (NullPointerException e) {
        }    
    }
}

持有人:

public class Holder {
    private int value;
        public Holder(int value) { 
        this.value = value;
    }

    public void assertValue() {
        if (value != value) {
            System.out.println("Magic");
            Test.flag=false;
        }
    }
}

class HolderContainer {
    public Holder holder;
    public void init() {
        holder = new Holder(42);  
    }
}

我从来没有得到程序来评估value!=valuetrue。 我认为这并不能证明什么,也没有运行超过几分钟,但我希望这将是一个设计良好的测试的更好起点,或者至少有助于找出测试中可能存在的一些缺陷。

我试图在Test.hc=new HolderContainer();Test.hc.init(); 之间、public Holder holder;public void init() { 之间以及public void init() { 之后插入睡眠。

我还担心检查值是否为null 或捕获NullPoiterException 可能会过多地影响计时。

请注意,目前接受的对Improper publication of Java Object Reference 的回答说这个问题在x86 架构下可能是不可能的。它也可能依赖于 JVM。

【讨论】:

  • 我的意见 - 我不太确定 - 在 x86 Arch 上不可能的原因是编译器优化将 'value != value' 替换为 false。编辑 - 抱歉,现在我看到在链接的问题中正在讨论读写的不同方面。
  • 我尝试了“-Djava.compiler=NONE”和“value != 42”。不确定这会有所帮助。另外以防万一这里是比较的字节码:0:aload_0 / 1:getfield#7/4:aload_0/5:getfield#7/8:if_icmpeq 23.根据我得到的,编译器没有任何简化.
  • @Rade_303 据我所知,优化可以发挥重要作用。最初的问题似乎是关于规范不保证这个问题不会发生,而不是关于这个问题在某些特定实现中实际发生并且重现这个问题似乎是唯一的,可以说是有趣和/或有用的,保持这个的理由作为一个单独的问题,摆弄 jvm 优化对于这项任务来说似乎是合法的。它需要很多不同层次的知识,我不是更适合这方面的人,欢迎任何帮助和/或解决问题。
【解决方案2】:

您可能正在尝试模拟并发场景,我认为使用几个线程很难模拟。

您编写的以下测试用例根本不正确,更有可能抛出NullPointerException.

public class Tests {

  private HolderContainer hc = new HolderContainer();

  class Initialization implements Runnable {
    public void run() {
      hc.init();
    }
  }

  class Checking implements Runnable {
    public void run() {
      hc.holder.assertValue();
    }
  }

  public void run() {
    new Thread(new Initialization()).start();  
    new Thread(new Checking()).start(); 
  }
}

如果您的检查线程在初始化之前执行怎么办? 同样在此处设置睡眠仅意味着执行线程将睡眠并确实告诉您到那时正在执行的内部原子操作。

【讨论】:

  • 我的问题是关于是否有可能获得默认值,然后是非默认值。正如我在问题中提到的,我的代码给出了 100% 合乎逻辑的 NullPointerExtecion。但作者表示以某种方式有可能获得 value != value case。
  • 是的,在你有数百万个请求的真实世界场景中是可能的
  • 请不要字面意思(数百万个请求),所有应用程序都使用池来处理请求
  • 那么问题是如何重现它?
  • 增加访问同一对象的线程数。为了测试,您可以将其提高到 ~300-500 也可以使用终端/cmd javac & java 命令快速连续执行它并将所有内容写入日志文件。不管有没有例外。有了这个数量的线程,您也许可以重现它
【解决方案3】:

没有用您的代码重现它。这是一个模拟不安全发布的示例。策略是让一个线程发布Holder,让另一个线程检查它的值。

class Holder {
    private volatile int value;

    public Holder(int value, HolderContainer container) {
        container.holder = this;  // publication this object when it is not initilized properly
        try {
            Thread.sleep(10);  
        } catch (Exception e) {

        }
        this.value = value; // set value
    }

    public int getValue() {
        return value;
    }
}

class HolderContainer {

    public Holder holder;

    public Holder getHolder() { 
        if (holder == null) { 
            holder = new Holder(42, this);
        }
        return holder;
    }
}


public class Tests {

    public static void main(String[] args) {
        for (int loop = 0; loop < 1000; loop++) {
            HolderContainer holderContainer = new HolderContainer();
            new Thread(() -> holderContainer.getHolder()).start();
            new Thread(() -> {
                Holder holder = holderContainer.getHolder();
                int value1 = holder.getValue();  // might get default value
                try {
                    Thread.sleep(10);
                } catch (Exception e) {

                }
                int value2 = holder.getValue(); // might get custom value
                if (value1 != value2) {
                    System.out.println(value1 + "--->" + value2);
                }
            }).start();
        }
    }

}

【讨论】:

  • 我不确定这个例子是否合适;这里操作的顺序是颠倒的。书中的一致性问题是关于线程以不同的顺序查看操作。在这里,即使两个线程在执行操作的顺序上达成一致,显然也可以在读取器线程中看到 value=0。
  • @Daniele 我不能说 100% 合适。我发布它是因为我认为重点是关于安全发布。如果社区认为这没有意义,我会删除它。
  • @NengLiu 不安全的发布是 100% 合乎逻辑的事情。我的问题是关于 Holder 的 super() (即对象构造函数)构造函数已经执行但 Holder 构造函数仍然没有执行的情况。在那种情况下,作者表示我们可以看到陈旧值 = 0。对我来说,似乎不可能重现这种行为。
【解决方案4】:

在构造函数中分配字段之前睡 3 秒并不重要,因为对于 value != valuetruevalue 的第一次读取必须产生与第二次读取不同的结果,后者立即发生。

Java 内存模型不保证在构造函数完成后分配给构造函数中的字段的值对其他线程可见。 要获得此保证,该字段必须为 final

这是一个在 x86 上产生错误的程序。 它必须使用 VM 选项运行:-XX:CompileCommand=dontinline,com/mypackage/Holder.getValue

package com.mypackage;

public class Test {
    public static void main(String[] args) {
        new Worker().start();
        int i = 1;
        while (true) {
            new Holder(i++);
        }
    }
}

class Holder {
    private int value;

    Holder(int value) {
        Worker.holder = this;
        this.value = value;
    }

    void assertSanity() {
        if (getValue() != getValue()) throw new AssertionError();
    }

    private int getValue() { return value; }
}

class Worker extends Thread {
    static Holder holder = new Holder(0);

    @Override
    public void run() {
        while (true) {
            holder.assertSanity();
        }
    }
}

通过禁止内联 Holder#getValue(),我们可以防止对 value 的两个后续读取合并为一个。

这种优化可以防止书中的代码产生错误。 不过书的作者还是正确的,因为这个优化不是强制的,所以从Java内存模型的角度来看,代码是不正确的。

assertSanity() 方法等于:

int snapshot1 = getValue();
                            // <--- window of vulnerability, where the observed value can change
                            //      if you chose to sleep 3 seconds, you would want to do it here
                            //      takes very little time, less than 1 nanosecond
int snapshot2 = getValue();
if (snapshot1 != snapshot2) throw new AssertionError();

所以value 的第一次读取可以产生int 的默认值0(称为陈旧值 并在Object() 构造函数中分配),第二次读取read 可以产生在Holder(int) 构造函数中分配的值。 例如,如果在构造函数中分配的值被传播到调用 assertSanity() 的线程,恰好在两次加载 value(漏洞窗口)之间的时刻,就会发生这种情况。

如果我们以其他方式延迟第二次读取,也会发生同样的情况,例如:

int snapshot1 = this.value;
Thread.interrupted();
int snapshot2 = this.value;
if (snapshot1 != snapshot2) throw new AssertionError();

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2019-04-04
    • 2015-11-29
    • 2020-03-01
    • 2018-09-10
    • 2013-05-02
    • 2012-08-03
    • 2014-10-31
    相关资源
    最近更新 更多