为什么我不应该在我的类的构造函数中使用Thread.start()?

我一直在寻找理由,为什么你不应该在类的构造函数中调用线程的start方法。 请考虑以下代码:

class SomeClass { public ImportantData data = null; public Thread t = null; public SomeClass(ImportantData d) { t = new MyOperationThread(); // t.start(); // Footnote 1 data = d; t.start(); // Footnote 2 } } 

ImportantData是一些通用的东西(可能很重要),而MyOperationThread是一个知道如何处理SomeClass实例的线程的子类。

Footnodes:

  1. 我完全理解为什么这是不安全的。 如果MyOperationThread在以下语句完成之前尝试访问SomeClass.data(并且数据已初始化),那么我将得到一个我没有准备好的exception。 或许我不会。 你不能总是告诉线程。 在任何情况下,我都会为以后的奇怪,意外行为做好准备。

  2. 我不明白为什么这样做是禁止的领土。 此时,所有SomeClass的成员都已初始化,没有其他成员函数更改状态已被调用,因此构造有效地完成。

根据我的理解,这样做被认为是不好的做法的原因是你可以“泄漏对尚未完全构建的对象的引用”。 但是对象已经完全构造,构造函数除了返回之外没有什么可做的。 我已经搜索了其他问题,寻找这个问题的更具体的答案,并且也查看了引用的材料,但没有发现任何说“你不应该因为这样和那种不良行为”,只有“说”你不应该。“

如何在构造函数中启动一个线程在概念上与这种情况不同:

 class SomeClass { public ImportantData data = null; public SomeClass(ImportantData d) { // OtherClass.someExternalOperation(this); // Not a good idea data = d; OtherClass.someExternalOperation(this); // Usually accepted as OK } } 

另外,如果课程是最终的怎么办?

 final class SomeClass // like this { ... 

我看到很多问题都在询问这个和你不应该得到的答案,但没有人提供解释,所以我想我会尝试添加一个有更多细节的答案。

但是对象已经完全构造,构造函数除了返回之外没有什么可做的

是的,不是。 问题是根据Java内存模型,编译器能够重新排序构造函数操作,并在构造函数完成实际完成对象的构造函数。 volatilefinal字段保证在构造函数完成之前初始化,但不能保证(例如)您的ImportantData data字段将在构造函数完成时正确初始化。

然而正如@meriton在评论中指出的那样,在与线程和启动它的线程的关系之前会发生一些事情。 在#2的情况下,你很好,因为必须在线程启动之前完全分配data 。 这是根据Java内存模型保证的。

也就是说,将其构造函数中对象的引用“泄漏”到另一个线程被认为是不好的做法,因为如果在t.start() 之后添加了任何构造函数行,那么如果该线程看到该对象将是一个竞争条件完全建造与否。

这里有更多的阅读:

  • 这是一个很好的问题: 在自己的构造函数中调用thread.start()
  • Doug Lea的内存模型页面讨论了指令重新排序和构造函数。
  • 这是关于安全构造函数实践的一篇很棒的文章,它更多地讨论了这一点。
  • 这就是为什么“双重检查锁定”问题也存在问题的原因。
  • 我对这个问题的回答是相关的: 这是对象的安全发布吗?

理性的,以事实为导向的反对这种实践的论据

考虑以下情况。 您有一个运行调度程序线程的类,该任务线程将任务排队到数据库,编码方式与以下类似:

 class DBEventManager { private Thread t; private Database db; private LinkedBlockingQueue eventqueue; public DBEventManager() { this("127.0.0.1:31337"); } public DBEventManager(String hostname) { db = new OracleDatabase(hostname); t = new DBJanitor(this); eventqueue = new LinkedBlockingQueue(); eventqueue.put(new MyEvent("Hello Database!")); t.start(); } // getters for db and eventqueue } 

数据库是某种数据库抽象接口,MyEvents由需要发出数据库更改信号的任何东西生成,而DBJanitor是Thread的子类,知道如何将MyEvents应用于数据库。 我们可以看到,此实现使用组成的OracleDatabase类作为数据库实现。

这一切都很好,但现在您的项目要求已经改变。 您的新插件必须能够使用现有的代码库,但也必须能够连接到Microsoft Access数据库。 您决定使用子类解决此问题:

 class AccessDBEventManager extends DBEventManager() { public AccessDBEventManager(String filename) { super(); db = new MSAccessDatabase(filename); } } 

但是,我们决定在构造函数中启动线程现在又回来困扰着我们。 在客户端蹩脚的700MHz单核奔腾II上运行,此代码现在具有竞争条件 :每创建一次数据库管理器,创建数据库管理器将创建数据库并启动线程,发送“Hello Database!” 事件到错误的数据库。

这是因为线程在超类构造函数的末尾开始…但这不是构造的结束,我们仍然被子类构造函数初始化,它会覆盖一些超类的成员,所以当线程跳转时在将事件分派给数据库时,它偶尔会在子类构造函数将数据库引用更新到正确的数据库之前进入。

至少有两种解决方案:

  1. 你可以让你的类最终,这将阻止它的子类化。 如果你这样做,你可以确定你的对象在暴露给任何其他对象之前是完全构造的(即使它还没有离开构造函数),从而确保不会发生这种奇怪的行为。

    您还必须采取措施防止在构造函数中重新排序赋值:您可以声明线程将作为volatile访问的字段,或者您可以将它们包装在任何类型的synchronized块中。 这两个选项中的每一个都对JIT编译器可以执行的重新排序应用了额外的限制,这确保了在线程访问它们时正确分配了字段。

    在这种情况下,您可能会与您的老板争论,直到他让您对代码库进行更改,这将涉及将DBEventManager的构造函数更改为如下所示:

     private Thread t; // no getter, doesn't need to be volatile private volatile Database db; private volatile LinkedBlockingQueue eventqueue; public DBEventManager() { this("127.0.0.1:31337"); } public DBEventManager(String hostname) { this(new OracleDatabase(hostname)); } public DBEventManager(Database newdb) { db = newdb; t = new DBJanitor(this); eventqueue = new LinkedBlockingQueue(); eventqueue.put(new MyEvent("Hello Database!")); t.start(); } 

    如果您在开发早期就已经预见到了这个问题,那么您可能已经添加了额外的构造函数。 然后,您可以使用DBEventManager安全地使用DBEventManager(new MSAccessDatabase("somefile.db"));进行Microsoft Access DBEventManager(new MSAccessDatabase("somefile.db"));

  2. 你可以不这样做,并回到使用单独的启动方法和可选的静态工厂方法或调用构造函数然后启动方法的方法,否则通常接受的方法,如下所示:

  public start() { t.start(); } public static DBEventManager getInstance(String hostname) { DBEventManager dbem = new DBEventManager(hostname); dbem.start(); return DBEventManager; } 

我很确定我是理智的,但第二种意见会很好。