Java 基础:ThreadLocal,HashMap

Posted by Piasy on January 13, 2017
本文是 Piasy 原创,发表于 https://blog.piasy.com,请阅读原文支持原创 https://blog.piasy.com/2017/01/13/Android-Basics-ThreadLocal-HashMap/

这一篇可以看做是 Handler 的番外篇。

ThreadLocal

看 Looper 源码的过程中,我们肯定看到过 Looper.myLooper() 的使用,它可以获取与当前线程关联的 Looper 对象,那这个关联是怎么建立起来的?就是通过 ThreadLocal。

我们可能会把 ThreadLocal 对象理解为一个 Map,它的 set 和 get 方法都有一个“隐形的” key,那就是当前的线程对象,所以它才可以为每个线程保存一个数据副本。但实际上并非如此。

我们先看看把它作为一个 Map 有什么问题:

  • 首先,因为所有线程都可以访问它,那它必须是一个线程安全的 Map,一个保存线程局部数据的类却需要保证线程安全性,有味道;
  • 其次,即便保证了线程安全,以 Thread 对象为 key,不同的 Thread 对象可能被当做同一个 key 吗?完全可能(用过 HashMap 的朋友应该知道怎么回事,后面再讲),那即便我们做到了线程安全,它依然是不安全的,另一个线程可以读到这个值,也能修改这个值;

那 ThreadLocal 到底是怎么实现的呢?

为了做到只有该线程自己能够访问,所以它存放在了 Thread 类的成员变量中(当然这并不严格,毕竟利用反射可以做任何事情);一个线程显然可以有任意多个 ThreadLocal 对象,所以用一个 Map 来把它们组织起来,key 是 ThreadLocal 对象,value 是其在该线程存储的值;由于这个 Map 只可能被该线程访问,所以它的实现不需要考虑线程安全问题。

可能有朋友会想,为什么要这么绕?至少我一开始是在想这个问题的,但思考过直接用 Map 实现的问题之后,也就没什么好疑惑的了,还有更优雅的方式吗?

HashMap

ThreadLocal 的实现中用到的 Map 叫 ThreadLocalMap,它是一个定制版的 HashMap,专用于 ThreadLocal,这里我们就顺便复习一下 HashMap。

HashMap 和 HashTable 有什么区别?

HashMap 的实现没有保证线程安全性,HashTable 保证了;HashMap 的 key、value 都允许 null,HashTable 不允许。

HashMap 的遍历顺序不保证和插入顺序一致,而且随着时间变化,遍历顺序可能会发生变化。那 HashTable 呢?文档中并未提及这一点,那我们就应该假设,HashTable 也不保证顺序。

HashMap 的实现原理?

哈希表(hash table)是一种抽象的数据结构,支持 O(1) 的 put 和 get。Java 里面的 HashMap/HashTable 等等具体的类,都是对这一概念的实现。

为了做到 O(1) 访问,哈希表采用数组实现,先把 key 转换为一个整数(基于该对象的 hashCode 方法),这个整数落在数据下标的范围内,这样我们就可以 O(1) 访问了。

这就会引出一个问题:冲突,多个对象可能映射到同一个哈希值。处理冲突的策略大致分为两种,链接法(chaining)和开放寻址法(open addressing)。它们都要求哈希表中同时存储 key 和 value

链接法是指数组的每个元素,都是一个链表,所有映射到这个位置的 pair,都存在这个链表中,查找的时候,先找到链表的头,然后线性查找。HashMap 采用的就是链接法。

开放寻址也叫封闭哈希,这并不矛盾,“开放”是指 pair 最终存储的位置不仅仅由哈希值决定,还由当前哈希表中的内容决定,而“封闭”则指 pair 一定存储在数组中,而不是数组元素指向的链表中(就像链接法那样)。那么哈希值只能作为查找的起点,然后按照某种策略向前/向后进行线性查找。查找的策略有很多种,线性探测、指数探测、双重哈希等。HashTable 采用的是开放寻址法。

由于两种策略都需要对比 key 是否相等,所以作为 key 的类,一定要注意 hashCode 和 equals 方法的效果是否符合预期。

数组初始大小、扩容触发比例

解决了冲突之后,我们就该考虑哈希表内部的数组应该要多大了。

其实这个数组的大小并非一成不变的,当存储了一定比例的数据之后,数组会进行扩容。无论是哪种冲突处理策略,当数据足够“密集”之后,哈希表的性能都会急剧下降,想想也是,数据太多之后,几乎全是冲突,线性查找,怎么不慢?所以我们需要提前扩容数组,扩容之后冲突就会减少很多了(正是扩容导致了遍历顺序会发生变化)。但扩容也是一个很耗时的过程,需要把所有元素重新放进新的数组中。那么这两个参数如何取舍,就需要权衡了。

默认的扩容比例是 0.75,这是一个经验值,统计发现元素比例超过 0.75 之后哈希表的性能会急剧下降。默认的初始大小是 16。

如果我们明确知道哈希表要放很多元素,那我们可以设置一个更大的初始大小,这样使用的时候据不需要耗时的扩容操作了。


更多细节?看源码吧 :)