Java HashMap的死循环的启示

在酷壳上看到博主陈皓写的最新文章疫苗:Java HashMap的死循环。博主看问题非常透彻,代码分析到位,而且图文并茂,很容易让人理解一个死循环是怎么产生的。

在博文中,耗子叔叔分析的最重要的一点就是HashMap在ReHash的这个过程中,由于多线程操作容器,意外地很隐蔽地构造了一个环形链接导致了死循环(Infinite Loop)。

给我的启示简单总结如下:

 

一、单线程改造为多线程也是个技术活

正如我们看到耗子叔叔博客里写的那样,原来是单线程的应用程序,”后来,我们的程序性能有问题,所以需要变成多线程的,于是,变成多线程后到了线上,发现程序经常占了100%的CPU“。

考虑到是淘宝的工程师曝出来的问题,他们的技术基础一般都很扎实,连他们都用错了,所以把单线程改造为多线程并不是想象中的那么简单,我认为。

你可能很不服气地反问,淘宝的工程师又怎么了,单线程改为多线程有什么难的?无非就是应用现有的多线程技术嘛,你看,我有非常强烈的线程安全意识,我知道同步、死锁、竞态条件,还知道lock free和线程安全容器,还知道各种线程安全同步构造……难道还写不出线程安全的应用程序?

实际情况是,线程安全的应用程序并不一定因为你有扎实的线程安全基础和开发经验就能够写好的。

试着举两个例子:

1、使用线程安全容器通过索引取数据

很多人知道的线程安全容器,实际使用的时候并不一定不出现BUG,下面的(有隐患的)代码就比较典型:

        static int GetFirstOrDefault(ThreadSafeList<int> list)
        {
            if (list.Count > 0)
            {
                return list[0];
            }
            return 0;
        }

上面的函数参数list如果一开始传入一个元素总数为1的列表,大家能分析出上面的代码会有什么问题吗?

关于线程安全容器,之前我恰好也总结过一篇文章浅析线程安全容器的实现。线程安全容器并不真正安全,上面有问题的代码就是出自于这里。

 

2、多线程操作邮件的失误

还有就是多线程应用场景的分析可能不正确,曾经因为一个邮件收发程序的性能问题,我也大胆改造过应用程序,改来改去就出现了重大BUG,

大家可以看看我痛心疾首总结过的一个应用程序多线程误用的分析。

 

上面举的这两个例子,我只是想说明,多线程应用程序中,因为线程安全产生的BUG其实是很微妙的,一个考虑不周或者认识不够深刻,出现问题的可能性简直防不胜防。

 

二、ReHash的代价

上面第一点主要是闲谈线程安全,接着我们也说说哈希表,深刻理解消耗成本很大的ReHash。

我们平常理解中的哈希表是“以空间换时间的一种数据结构”。这样说的太久了,大家可能会有一种直观上的错觉,就是哈希表牺牲的是空间,争取的是时间。

但是,ReHash的过程其实是空间和时间的双重重大损失,因为分析源代码,我们知道ReHash的过程其实就是一个动态扩容的过程,而哈希表的扩容是个空间和时间消耗都非常惊人的内部操作。

为什么说ReHash是个空间和时间消耗都非常惊人的内部操作呢?

1、原来当我们对哈希结构的容器进行扩容时,散列表内部要重新new一个更大的数组,然后把原来数组的内容拷贝到新数组,并进行重新散列;

2、new出来的这个更大的新数组容量有多大也是一门学问,一般来说,新数组的大小会设置成原数组双倍大小的相近的一个素数(.NET中这个素数的生成还有一定的技巧)。

从1和2这两点可以看出,ReHash的代价确实非常高。在不久以前我碰巧写过一篇关于.NET容器的动态扩容的文章(从源码分析常见的基于Array的数据结构动态扩容机制),其中也浅显总结了.NET的HashTable的扩容机制,现在对照Java中的HashMap源码,看到熟悉的ReHash函数命名,再看一遍.NET中的实现,果然有比较才能有提高。

至于我们平时所理解的“以空间换时间“,其实是指哈希具有O(1)复杂度的数据检索效率,但它受填充因子影响,空间开销通常很大,空间利用率不高。

所以我们常常说哈希表适用于读操作频繁,写操作较少应用场景,比如把哈希表当做缓存容器,于我心有戚戚焉。

 

最后看到这句“有人把这个问题报给了Sun,不过Sun不认为这个是一个问题。因为HashMap本来就不支持并发。要并发就用ConcurrentHashmap…”

根据实际开发经验,线程安全的容器并不真正线程安全,会用ConcurrentHashmap也只是进入初级阶段,同时忍不住要感慨下当年如日中天风光无限的Sun。

标签