源码阅读笔记:并发环境下的HashMap

源码阅读笔记:并发环境下的HashMap

通常认为,Java中的HashMap不适合在并发环境下使用。然而在使用过程中,却发现它不总是会出现问题。而HashMap有一个“线程安全”版本HashTable,但是它的同步方式过于“粗暴”,导致并发环境下吞吐量堪忧。那么HashMap在并发环境下究竟有什么问题、我们需要怎么解决、有什么代价?为此我们有必要弄清并发环境下HashMap的症结所在。


哈希表(Hash Table)

HashMap作为我们很常用的一种集合,提供了时间复杂度为O(1)的读写访问,是哈希表的典型实现。所谓的哈希表,实际上将目标数据通过散列函数(亦称为哈希函数)映射到一块提供随机存取的内存中的一种数据结构。散列函数有很多标准实现,如MD5、SHA1、SHA256等等,由于压缩性、容易计算、抗修改性、强抗碰撞,广泛用于数字签名、文件校验等。

  • 压缩性:不论多少数据,其散列值都是固定长度
  • 易计算:散列值的计算复杂度很低
  • 抗修改性:即使原始数据只做了微小的修改,散列值也完全不同
  • 强抗碰撞:给定一个哈希值,找到一个符合的原始数据极其困难

尽管强抗碰撞的特性决定了哈希值几乎是不能伪造的,所以哈希值可以作为数据的“指纹”。但由于彩虹表(Rainbow Table)的存在,这个“指纹”也逐渐变得不可靠。截止目前为止,涉及严格禁止碰撞的场景,已经不使用MD5。同时,Google的[论文][1]中也曾指出,SHA1的原始输入也被认为可以人为伪造。

既然使用了散列函数作为键,哈希表就一定会存在冲突。冲突有很多种解决方式,HashMap使用的是拉链法——用链表维护键值冲突的数据。同时,HashMap的键值空间支持动态扩展。而一切的故事就要从这个动态扩展说起。

抛砖引玉

进入正题前,我们先放个例子,一个简单的工厂类(来源于工作中看到的代码)。下面的代码线程安全吗?

public class ResourceFactory {
    static Map<String, Resource> resourceHolder = new HashMap<>();
    public Resource getResource(String name) {
        if(!resourceHolder.contains(name)) {
            synchronized(this.class) {
                if(!resourceHolder.contains(name)) {
                    resourceHolder.put(name, new Resource());
                }
            }
        }
        return resourceHolder.get(name);
    }

    public class Resource {}
}

线程安全(Thread Safty)

Thread safety is a computer programming concept applicable in the context of multi-threaded programs. A piece of code is thread-safe if it functions correctly during simultaneous execution by multiple threads. In particular, it must satisfy the need for multiple threads to access the same shared data, and the need for a shared piece of data to be accessed by only one thread at any given time. [ 维基百科 ]

线程安全是多线程编程中的概念。线程安全的代码在多个线程并发执行环境中,能保证正确的执行结果。特别地,这段代码必须满足多线程并发访问共享资源以及单线程访问数据时的正确语义。

上面代码中的resourceHolder毫无疑问是一个共享资源。多线程共同访问的时候,程序员期待的语义是:每次调用getResource时,如果不存在同名的Resource实例,就创建它,并保存到resourceHolder中;如果存在,就直接返回。上面的例子有一个显然的问题在DCL(Double-Check Lock),它并不能确保上面的语义,但这涉及其他同步原语,可以参看[另一篇文章][2]。在我实际使用中,它似乎没有其他问题了,然而真的如此吗?

我们常常会说,当一个类是线程安全的时候,我们在多线程环境下使用就不会产生线程安全问题。那么,当我们提线程安全的时候,我们究竟在说什么?多线程环境中,数据的访问存在以下可能性:

  1. 两个线程同时读取一个资源,但不进行写入
  2. 两个线程同时写入一个资源,且写入的值不依赖于原值
  3. 两个线程同时写入一个资源,且写入的值依赖于原值

显然,场景1不存在线程安全问题。场景2下,由于写入是一个覆盖操作,有一者的写入会被覆盖,由于不依赖于原值(比如原值是null,就不写,这就是典型的依赖于原值),一般情况下是没有问题的。我们通常遇到问题的,都在场景3的“读-写”操作,即读写之间有依赖关系。实际上,上面的DCL也是想解决场景3下的写HashMap。那么,非线程安全就会造成写入操作的丢失。这只是笼统上的理解,HashMap的情况,就是场景3,但是复杂得多、也隐蔽得多:

  • 两个线程同时调用put时,实际上是在同时在链表的末尾追加一个元素,可能丢失元素。上面的DCL实际上就是想解决这个问题。然而,它并不能完成任务。
  • 元素增加到一定程度,HashMap会动态扩容。当两个线程同时进行扩容操作时,实际上是同时对链表进行移动。后面我们会看到这个移动过程是一个节点一个节点逐个进行,每次操作都可能会出现问题,最严重的是可能会产生“循环链表”

以上的问题的产生,实际上都是由于链表结构在新增元素时,操作会分为两步:

  • 读,找到并读取插入位置的节点,比如要在链表头部插入,就需要读取头节点、要在链表尾部插入,就需要循环遍历找到尾节点
  • 写,将新增节点插入读取到的节点之后

源码分析

HashMap初始化时,会将容量强制置为2的整次幂(向上取整)。

/**
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    //对于32bit整型来说cap最高位的1之后都将被置为1
    n |= n >>> 1; //非零的最高2位变为11
    n |= n >>> 2; //非零的最高4位变为1111
    n |= n >>> 4; //依次类推
    n |= n >>> 8;
    n |= n >>> 16;
    //最终得到一个2的幂
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

下面是HashMap中的resize()源码(JDK1.8),该函数可以完成初始化或扩容操作。在与put相关的动作时,会自动被调用。为方便阅读,对代码进行了改动。

/**
 * Initializes or doubles table size.  If null, allocates in
 * accord with initial capacity target held in field threshold.
 * Otherwise, because we are using power-of-two expansion, the
 * elements from each bin must either stay at same index, or move
 * with a power of two offset in the new table.
 *  * @return the table
*/
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = this.table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    Integer newCap, newThr = 0;
    fetchNewCapacityAndThreshHold(oldCap, oldThr, newCap, newThr);
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    //创建新表
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    this.table = newTab; //newTab还未初始化就
    //数据拷贝
    copyData(oldTab, newTab);
}

fetchNewCapacityAndThreshHoldcopyData的具体源码可以参见附录。copyData的过程如下所示:
数据拷贝
不失一般性,假设newTab扩增为oldTab的2倍(事实上HashMap也是按2倍扩展的),元素将重新分配。遍历oldTab的所有Node节点,对每个Node,按一定的规则(源码中的规则是node.hash & oldCap == 0)分为两组:

  • oldTab,哈希值满足条件的,保存到临时链表low
  • oldTab,哈希值不满足条件的,保存到临时链表high
  • newTablow中的节点相对位置不变
  • newTabhigh中的节点向后平移一个oldCap

上述依赖关系是,后两步的写入,依赖前两步中的读取。因此,多线程环境下进行扩容,只要有其他线程中写入了oldTab的相同位置,就会造成并发问题。可能出现的问题如下:

  • 其他线程有可能读取到还未拷贝完成的新表
  • 拷贝过程中,其他线程的写入可能会丢失

可以看出,JDK1.8的哈希表已经修正了一些会引起严重bug的线程安全问题,也就是“环形链表”,详情参看这篇文章

解决方案

HashTable

HashTable的所有public方法都使用synchronize修饰。可以确保多线程环境下不产生安全问题。也就是说,多线程同时进行单一的getput都是安全的,也就是通常所说的原子操作(要么所有线程都看见,要么所有线程都看不见,包括对执行写入的线程)。顺便说一句,如果put依赖于get,仍然需要程序进行同步:

HashTable<String, int> hashTab = new HashTable<>();
...
hashTable.put(hashTab.get("foo") + 1); //非线程安全

ConcurrentHashMap

HashTable会损失一些并发性能,所以通常情况下,多线程环境会使用ConcurrentHashMap。它通过将节点分组,对每组节点分别加锁的方式,使得多线程并发性能成倍提升。

附录

private void fetchNewCapacityAndThreshHold(int oldCap, int oldThr, Integer newCap, Integer newThr) {
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY 
                    && oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
        } else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
}
private void copyData(Node<K, V>[] oldTab, Node<K, V>[] oldTab) {
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    //newCap只会是2的整数次幂,所以该操作完成了取余
                    //如: newTab & (2^4 - 1) → newTab & 0x1111
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    //low组会被放到新表相同的位置
                    Node<K,V> loHead = null, loTail = null;
                    //high组会被平移一个oldCap
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值