double check java,您能看出这个Double Check里的问题吗?

本文讨论了一段使用DoubleCheck锁定实现线程安全延迟加载的代码中存在的问题。当多个线程尝试访问时,由于字典在加载过程中被提前初始化,可能导致未完全填充的字典被其他线程访问。作者提出了两种解决方案:一是引入初始化标志,二是延迟赋值。文章强调在满足if条件时必须确保初始化完成,并提及了内存一致性模型和编译器重排序对多线程编程的影响。
摘要由CSDN通过智能技术生成

昨天在做code review时看到一位同事写了这样的代码。这段代码的目的使用Double Check的做法来保证线程安全的延迟加载。但是我看到这代码之后发现了一个问题,这个问题不是第一次出现。因此,我打算在博客上记录一笔,希望可以给更多人提个醒吧。

假设,我们有这样一个Category类型,记录的是一个树型的分类结构:

public class Category

{

public int CategoryID { get; set; }

public List Children { get; set; }

}

然后,我们需要一个CategoryLoader,提供一个Get方法从ID获得指定的Category对象:

public class CategoryLoader

{

private object m_mutex = new object();

private Dictionary m_categories;

public Category GetCategory(int id)

{

if (this.m_categories == null)

{

lock (this.m_mutex)

{

if (this.m_categories == null)

{

LoadCategories();

}

}

}

return this.m_categories[id];

}

private void LoadCategories()

{

this.m_categories = new Dictionary();

this.Fill(GetCategoryRoots());

}

private void Fill(IEnumerable categories)

{

foreach (var cat in categories)

{

this.m_categories.Add(cat.CategoryID, cat);

Fill(cat.Children);

}

}

private IEnumerable GetCategoryRoots() { ... }

}

代码的逻辑非常简单:使用一个字典作为容器,在GetCategory方法内部使用Double Check的方式来保证线程安全(即多个线程同时访问同一个对象的GetCategory方法不会出现问题)。如果没有加载,在使用LoadCategories方法构造并填充字典。在LoadCategories方法中会获取所有的“根分类”,并调用Fill方法填充字典。Fill方法会将传入的categories集合添加到字典中,并且递归地将它们的子分类也填充至字典中。

只可惜,上面的代码有一些问题,导致Double Check没有能够实现我们预期的效果。您能看出这个问题来吗?

当然,为了演示代码的简单,我省略了很多细节。例如Category的ID缺失或有重复,Category对象不是immutable,Children属性可能会包含null,这可能都会形成问题。不过,我们就暂时不在这方面考究了吧。

=================================

已经很有很多朋友得到了结果,是由于m_categories过早初始化,而导致double check的验证条件被破坏(或者说,满足)。

private object m_mutex = new object();

private Dictionary m_categories;

public Category GetCategory(int id)

{

if (this.m_categories == null)

{

lock (this.m_mutex)

{

if (this.m_categories == null)

{

LoadCategories();

}

}

}

return this.m_categories[id];

}

private void LoadCategories()

{

this.m_categories = new Dictionary();

this.Fill(GetCategoryRoots());

}

private void Fill(IEnumerable categories)

{

foreach (var cat in categories)

{

this.m_categories.Add(cat.CategoryID, cat);

Fill(cat.Children);

}

}

假设第一个线程进入了GetCategory方法,它自然可以畅通无阻地执行LoadCategories。只可惜,在LoadCategories方法的第一行就为m_categories设置了一个空字典。如果现在立即有另一个线程访问了GetCategory方法,就会发现m_categories字段不是null,并直接执行this.m_categories[id]这行代码——但此时,第一个线程还没有将这个字典填充完毕!

因此,这段代码其实是一个有问题的Double Check实现。那么我们该怎么改呢?

一位匿名朋友提出,可以增加一个标记,用来表示有没有初始化完毕。如下:

private bool m_initialized = false;

public Category GetCategory(int id)

{

if (!this.m_initialized)

{

lock (this.m_mutex)

{

if (!this.m_initialized)

{

LoadCategories();

this.m_initialized = true;

}

}

}

return this.m_categories[id];

}

这是个非常漂亮的做法,完全没有问题。不过我并没有使用这种修改方式。

private void LoadCategories()

{

var categories = new Dictionary();

Fill(categories, GetCategoryRoots());

this.m_categories = categories;

}

private static void Fill(Dictionary container, IEnumerable categories)

{

foreach (var cat in categories)

{

container.Add(cat.CategoryID, cat);

Fill(container, cat.Children);

}

}

我稍稍改变了一下Fill方法,它不再直接访问m_categories字段,而是把内容填充至container参数中。而在LoadCategories方法中,我们创建一个字典,但是直到填充完毕后才将其赋给m_categories字段。这样就保证了在m_categories字段不为null的时候,一定已经初始化完毕了。这也是一种可行的办法。我没有使用第一种做法的原因,并不是因为所谓的“节省空间”,而是……一下子就想到了第二种做法。:)

这里反映了Double Check在使用时的一个准则:在满足if条件的时候,一定要确保所有的初始化已经完成了。或者说,一定要将“满足if条件”的操作放在初始化完毕之后进行。至于是否使用某个标记,倒不是什么大问题。

如果您使用.NET编写代码,目前已经没有问题了,但是在某些情况下这样的代码还是会出现问题。我认为这也是多线程编程时最麻烦的地方——就是所谓的“Memory Consistency Model”。

为了性能考虑,编译器在将文本代码转化为机器码,以及CPU在执行机器码时都会对执行进行“重新排序(reorder)”,reorder的作用是为了提升性能。虽然从单线程的角度来看,reorder不会形成问题,但是在多线程的环境中,reorder就会破坏代码的逻辑了。如果没有一个“标准”在进行统一的话,不同的编译器,虚拟机,CPU架构都会有不同的reorder策略。例如微软并行库之父Joe Duffy在这篇文章中简单地提到了不同平台(JVM / CLR 2.0)或不同CPU架构(x86 / IA64)下reorder规则的区分。

而臭名昭著的Double Check的bug便是由于store reordering造成的。在JVM或普通的C、C++中并不保证store reordering不会发生。也就是说,您在代码中看到的两个变量的“设置”顺序,并不代表CPU在执行的时候,也是同样的效果。因此,如果你观察下面的代码:

class Foo {

private Helper helper = null;

public Helper getHelper() {

if (helper == null)

synchronized(this) {

if (helper == null)

helper = new Helper();

}

return helper;

}

}

看上去这是一段再正常不过的实现Double Check的Java代码,但是由于发生了store reordering,可能在Helper构造函数中的操作还没有全部执行完成之前,就设置了helper字段。因此另一个线程就可能会访问到一个没有初始化完整的Helper对象。如果您对这个话题感兴趣,可以参考《The "Double-Checked Locking is Broken" Declaration》。

而在CLR 2.0中,只会发生load reordering,而不会出现store reordering。于是.NET中编写的Double Check代码不会出现任何问题。那么CLR是如何保证在不同的CPU平台上出现相同的行为呢?那是因为CLR会根据不同的平台,在合适的情况下插入一些辅助代码(如Memory Barrier),可见CLR为我们的并行编程环境已经形成了一个相对比较方便的平台了——虽然,并行编程还是很困难。

(似乎关于Memory Model的有些说法不太确切,随时更新,希望了解这些的朋友们也可以提点意见,我晚上回家后再查些资料)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值