双重检查锁定 - 陷阱?

4
我使用Java已经一个月了,编程方面还算是一个业余爱好者,所以如果我有错误的地方,请随时纠正。也许我会提供一些多余的细节,但我现在感到非常困惑,无法确定什么才是重要的。
因此,我一直在开发多线程客户端-服务器应用程序。所有线程都使用同一个对象,在这个对象中存储某些配置值和共享记录器;该对象在服务器线程中初始化,然后作为参数传递给客户端线程类构造函数。最初假设该对象的字段仅在服务器启动时更改一次,因此并没有担心并发访问,但现在需要在修改配置文件时重新读取某些配置值,而无需重新启动服务器。
在进行一些研究后,浮现出的第一个想法是创建一个同步方法,在请求该类的某些值时调用它,并且如果我们的配置文件自上次访问以来发生了更改,则重新读取这些值并立即返回,否则直接返回,如下所示:
<This code is inside "config" class, instance of which is shared between threads>
private static long lastModified;
private static File configFile;

public class ChangingVariableSet
    {
    <changing variables go here>
    }

private synchronized void ReReadConfig
    {
    long tempLastMod = configFile.lastModified();
    if(lastModified == tempLastMod)
        return;
    <reread values here>
    lastModified = tempLastMod;
    }

public ChangingVariableSet GetValues()
    {
    ReReadConfig();
    <return necessary values>
    }

(上述代码未经测试,我只是想传达一般的想法)。

但我并不喜欢每次请求值时都阻止它,因为这似乎很昂贵,而且我的应用程序有可能在将来成为一个具有很多线程的高负载系统。所以我有一个“好”思路-在锁定之前检查文件是否已被修改,然后再次在锁定方法内部,尽可能避免锁定:

 public ChangingVariableSet GetValues()
    {
    if(lastModified == configFile.lastModified())
        ReReadConfig();
    <return necessary values>
    }

十分钟后,我了解到这被称为双重检查锁定。再过十分钟,当我阅读这篇文章时,我的世界两次崩溃:第一次是因为我了解到它据说由于内部CPU缓存而不能工作,第二次是当我阅读了关于对长整型和浮点类型的操作不具有原子性的内容时。或者它是否仍然有效,因为没有涉及对象创建?而且,由于长整型的操作是非原子的,将“lastModified”声明为易失性变量是否真的足够?如果可能的话,我更希望能够得到一个关于它是否有效的适当解释。提前致谢。

P.S: 我知道类似的问题已经被解答了几次了,也许停止吹毛求疵并同步整个“getValue”方法而不是“ReReadConfig”会更好,但我正在努力学习更多关于线程安全编程的知识,并避免在未来遇到类似的问题。我还为任何可能存在的语法和拼写错误道歉,我不太懂英语。


编辑: 首先,我修正了最后一个“if”子句中的一个拼写错误。其次 - 警告,上面的代码不是线程安全的,请勿使用!在方法中。

 public ChangingVariableSet GetValues()
    {
    if(lastModified == configFile.lastModified())
        ReReadConfig();
    <return necessary values>
    }

如果在if检查和返回值之间的时间段内更新了文件,则线程B可能会在线程A开始返回值之前启动ReReadConfig,导致必要数据部分更改的危险。似乎正确的做法是使用ReentrantReadWriteLock避免过度阻塞,但我仍然想使用双重检查来避免过多(且昂贵,文件被认为是大型XML)的配置重新读取:

<...>
private static final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private static final Lock read  = readWriteLock.readLock();
private static final Lock write = readWriteLock.writeLock();

private void ReReadConfig
    {
    write.lock();
    long tempLastMod = configFile.lastModified();
    if(lastModified == tempLastMod)
        return;
    <reread values here>
    lastModified = tempLastMod;
    write.release();
    }

 public ChangingVariableSet GetValues()
    {
    if(lastModified == configFile.lastModified())
        ReReadConfig();
    read.lock();
    <get necessary values>
    read.release();
    <return necessary values>
    }

现在它至少看起来是线程安全的,但是问题仍然存在,当检查时依赖于volatile的“lastModified”变量:我曾经在某个地方读到过,volatile变量无法保证非原子操作上的任何东西,并且“long”类型的读/写是非原子的。


顺便说一下,你在这里的英语非常好。 - Kevin Welker
谢谢;不过我得读了三遍才明白 :3 - Timekiller
2个回答

5
你想要使用一个读写锁。 只要没有写入者,这不会阻塞读取器。

看起来正是我需要的!同时我突然意识到我的代码在任何情况下都不是线程安全的,因为“返回必要值”没有考虑其他线程中可能开始的写入;我会相应地修改问题。然而,我仍然不确定关于volatile long变量和依赖它们的双重检查,所以我真的需要一些澄清。谢谢。 - Timekiller

1
如果您能以这样的方式组织代码,使得所有配置数据都在一个单一的不可变对象中,并且有一个共享的易失性引用指向该对象,那么这将是线程安全的。这种用例实际上是对volatile语义修订的目标。
public static volatile Config config;

void rereadConfig() {
  if (modified)
    config = new Config(...);
}

嗯,谢谢建议。我其实考虑过这个问题,但是不确定"volatile"的工作原理,特别是因为"X = new Y()"根本不是原子操作。所以,只是想澄清一下:如果我将共享引用声明为"volatile",然后运行"X = new Y()",那么它会先完全创建新的Y实例,然后再将其分配给X,对吗?如果我没错的话,经典的延迟初始化陷阱就是在构造函数完成之前可能会分配引用。 - Timekiller
是的,这就是volatile的语义。在一个线程中写入volatile之前的所有操作都“先于”在任何其他线程中读取该值之后的所有操作。你可能想查看相关的JLS章节,它是一篇很好的文章。 - Marko Topolnik
谢谢,我现在有点明白了。我不认为我想重新组织代码,但现在我至少知道何时使用“volatile”以及它的工作原理。再次感谢。 - Timekiller

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接