JDK1.8中HashMap与HashTable的区别

讨论基于JDK 1.8,HashTable产生于JDK 1.1,而HashMap产生于JDK 1.2。

1.Null Key & Null Value

HashMap是支持null键和null值的,允许一个null键和多个null值,而HashTable不允许null键值,在遇到null时,会抛出NullPointerException异常。HashMap在实现时对null做了特殊处理,将null的hashCode值定为了0,从而将其存放在哈希表的第0个bucket中。

以下代码及注释来自java.util.HashTable

 public synchronized V put(K key, V value) {
    // Make sure the value is not null
    if (value == null) {
        throw new NullPointerException();
    }

    // Makes sure the key is not already in the hashtable.
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    @SuppressWarnings("unchecked")
    Entry<K,V> entry = (Entry<K,V>)tab[index];
    for(; entry != null ; entry = entry.next) {
        if ((entry.hash == hash) && entry.key.equals(key)) {
            V old = entry.value;
            entry.value = value;
            return old;
        }
    }

    addEntry(hash, key, value, index);
    return null;
}

以下代码及注释来自java.util.HasMap

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

2.实现原理

2.1数据结构

HashMap和HashTable都使用哈希表来存储键值对。在数据结构上是基本相同的,都创建了一个继承自Map.Entry的私有的内部类Entry,每一个Entry对象表示存储在哈希表中的一个键值对。

Entry对象唯一表示一个键值对,有四个属性:

-K key 键对象
-V value 值对象
-int hash 键对象的hash值
-Entry entry 指向链表中下一个Entry对象,可为null,表示当前Entry对象在链表尾部

采用的是“桶位”,即一个Node数组实现
HashMap的源码

transient Node<K,V>[] table;

 static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
        ...
 }

HashTable的源码

private transient Entry<?,?>[] table;

 private static class Entry<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Entry<K,V> next;
        ...
 }

这里写图片描述

上图画出的是一个桶数量为8,存有5个键值对的HashMap/HashTable的内存布局情况。可以看到HashMap/HashTable内部创建有一个Entry类型的引用数组,用来表示哈希表,数组的长度,即是哈希桶的数量。而数组的每一个元素都是一个Entry引用,从Entry对象的属性里,也可以看出其是链表的节点,每一个Entry对象内部又含有另一个Entry对象的引用。

这样就可以得出结论,HashMap/HashTable内部用Entry数组实现哈希表,而对于映射到同一个哈希桶(数组的同一个位置)的键值对,使用Entry链表来存储(解决hash冲突)。

2.2算法

HashMap的相关参数

默认的数组初始容量:16

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

默认的最大容量:2的30次方

 static final int MAXIMUM_CAPACITY = 1 << 30;

默认的负载因子:0.75

static final float DEFAULT_LOAD_FACTOR = 0.75f;

默认的链表转红黑树的节点数阈值:8

static final int TREEIFY_THRESHOLD = 8;

红黑树转链表的阀值,当链表长度<=6时转为链表(扩容时)。

static final int UNTREEIFY_THRESHOLD = 6;

Hashmap扩容的节点数阈值threshold=capacity*load factor://就是容量乘以负载因子。

通过size记录hashmap中元素的个数,如果size>threshold,则利用resize()对table数组进行扩容。
扩容过程为直接将table的capacity容量乘以2,并且阈值threshold也乘2。

哈希碰撞

当两个key通过hashCode计算相同时,则发生了hash冲突(碰撞),HashMap解决hash冲突的方式是用链表。

当发生hash冲突时,则将存放在数组中的Entry设置为新值的next(这里要注意的是,比如A和B都hash后都映射到下标i中,之前已经有A了,当map.put(B)时,将B放到下标i中,A则为B的next,所以新值存放在数组中,旧值在新值的链表上)

hashmap.put的过程

当向 HashMap 中 put 一对键值时,它会根据 key的 hashCode 值计算出一个位置, 如果该位置没有对象存在,就将此对象直接放进数组当中;如果该位置已经有对象存在了,则顺着此存在的对象的链开始寻找(为了判断是否是否值相同,map 不允许键值对重复), 如果此链上有对象的话,再去使用 equals方法进行比较,如果对此链上的每个对象的 equals 方法比较都为 false,则将该对象放到数组当中,然后将数组中该位置以前存在的那个对象链接到此对象的后面。
jidk1.8后,当链表的size大于等于8后,就会使用红黑树储存Entry对象。

这里写图片描述

HashTable的相关参数

默认的数组初始容量:11
负载因子:0.75
这里写图片描述

Hashtable和hashmap的区别总结

1、 hashmap中key和value均可以为null,但是hashtable中key和value均不能为null。

2、 hashmap采用的是数组(桶位)+链表+红黑树结构实现(jdk1.8之后),而hashtable中采用的是数组(桶位)+链表实现。

3、 hashmap中出现hash冲突时,如果链表节点数小于8时是将新元素加入到链表的末尾,而hashtable中出现hash冲突时采用的是将新元素加入到链表的开头。

4、 hashmap中数组容量的大小要求是2的n次方,如果初始化时不符合要求会进行调整,必须为2的n次方,而hashtable中数组容量的大小可以为任意正整数。

5、 hashmap中的寻址方法采用的是位运算按位与,而hashtable中寻址方式采用的是求余数。

6、 hashmap不是线程安全的,而hashtable是线程安全的,hashtable中的get和put方法均采用了synchronized关键字进行了方法同步。

7、 hashmap中默认容量的大小是16,而hashtable中默认数组容量是11。

3.ConcurrentHashMap

这里写图片描述
大概的意思是,如果一个不需要线程安全的map,建议使用LinkedHashMap代替Hashtable。如果需要一个线程安全的高并发的实现类,然后推荐使用ConcurrentHashMap代替Hashtable。
尽量不要使用HashTable,因为在单线程中,无需做线程控制,运行效率更高;在多线程中,HashTable中的synchronized会造成线程饥饿,死锁,可以用ConcurrentHashMap替代。

3.1ConcurrentHashMap介绍

ConcurrentHashMap在1.8中的实现,相比于1.7的版本基本上都改变了。首先,取消了Segment分段锁的数据结构,取而代之的是数组+链表(红黑树)的结构。而对于锁的粒度,调整为对每个数组元素加锁(Node)。然后是定位节点的hash算法被简化了,这样带来的弊端是Hash冲突会加剧。因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。这样一来,查询的时间复杂度就会由原先的O(n)变为O(logN)。HsahMap处理类似,下面是其基本结构:
这里写图片描述
初始大小为16

private static finalint DEFAULT_CONCURRENCY_LEVEL = 16;

加载因子为0.75

private static final float LOAD_FACTOR = 0.75f;

链表转红黑树的阀值为8(大于等于这个值)

static final int TREEIFY_THRESHOLD = 8;

红黑树转链表的阀值为6(小于等于这个值)

static final int UNTREEIFY_THRESHOLD = 6;

重要的成员变量

table:默认为null,初始化发生在第一次插入操作,默认大小为16的数组,用来存储Node节点数据,扩容时大小总是2的幂 次方。
nextTable:默认为null,扩容时新生成的数组,其大小为原数组的两倍。
sizeCtl :默认为0,用来控制table的初始化和扩容操作,具体应用在后续会体现出来。
-1 代表table正在初始化
-N 表示有N-1个线程正在进行扩容操作
其余情况:
1、如果table未初始化,表示table需要初始化的大小。
2、如果table初始化完成,表示table的容量,默认是table大小的0.75倍,居然用这个公式算0.75(n - (n >>> 2))。
Node:保存key,value及key的hash值的数据结构。
其中value和next都用volatile修饰,保证并发的可见性。

构造函数仅仅是对Map容量,并发级别等做了赋值操作,并没有有初始化table[]数组。

public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
        if (initialCapacity < concurrencyLevel)   // 初始化容量至少要为concurrencyLevel
            initialCapacity = concurrencyLevel;
        long size = (long)(1.0 + (long)initialCapacity / loadFactor);
        int cap = (size >= (long)MAXIMUM_CAPACITY) ?
            MAXIMUM_CAPACITY : tableSizeFor((int)size);
        this.sizeCtl = cap;
    }

put()方法;

public V put(K key, V value) {
        return putVal(key, value, false);
    }

    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            // 如果table为空,初始化;否则,根据hash值计算得到数组索引i,如果tab[i]为空,直接新建节点Node即可。注:tab[i]实质为链表或者红黑树的首节点。
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {// table[i]后面无节点时,直接创建Node(无锁操作)
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)// 如果当前正在扩容,则帮助扩容并返回最新table[]
                tab = helpTransfer(tab, f);
            else {// 在链表或者红黑树中追加节点
                V oldVal = null;
                synchronized (f) {// 这里并没有使用ReentrantLock,说明synchronized已经足够优化了
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {// 如果为链表结构
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {// 找到key,替换value
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                // 如果没有找到值为key的节点,直接新建Node并加入链表即可。
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {// 如果为红黑树,执行putTreeVal操作。
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)// 如果节点数>=8,到达阀值,变为红黑树结构
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);//执行putTreeVal操作。
        return null;
    }

从上面代码可以看出,put的步骤大致如下:
1.参数校验。
2.若table[]未创建,则初始化。
3.当table[i]后面无节点时,直接创建Node(无锁操作)。
4.如果当前正在扩容,则帮助扩容并返回最新table[]。
5.然后在链表或者红黑树中追加节点。
6.最后还回去判断是否到达阀值,如到达变为红黑树结构。
除了上述步骤以外,还有一点我们留意到的是,代码中加锁片段用的是synchronized关键字,而不是像1.7中的ReentrantLock。这一点也说明了,synchronized在新版本的JDK中优化的程度和ReentrantLock差不多了。

get()方法

public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode());// 定位到table[]中的i
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {// 若table[i]存在
            if ((eh = e.hash) == h) {// 比较链表头部
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            else if (eh < 0)// 若为红黑树,查找树
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {// 循环链表查找
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;// 未找到
    }

get()方法的流程相对简单一点,从上面代码可以看出以下步骤:
1.首先定位到table[]中的i。
2.若table[i]存在,则继续查找。
3.首先比较链表头部,如果是则返回。
4.然后如果为红黑树,查找树。
5.最后再循环链表查找。
从上面步骤可以看出,ConcurrentHashMap的get操作上面并没有加锁。所以在多线程操作的过程中,并不能完全的保证一致性。这里和1.7当中类似,是弱一致性的体现。

项目JDK1.7JDK1.8
概览这里写图片描述这里写图片描述
同步机制分段锁,每个segment继承ReentrantLockCAS + synchronized保证并发更新
存储结构数组+链表数组+链表+红黑树
键值对HashEntryNode
put操作多个线程同时竞争获取同一个segment锁,获取成功的线程更新map;失败的线程尝试多次获取锁仍未成功,则挂起线程,等待释放锁访问相应的bucket时,使用sychronizeded关键字,防止多个线程同时操作同一个bucket,如果该节点的hash不小于0,则遍历链表更新节点或插入新节点;如果该节点是TreeBin类型的节点,说明是红黑树结构,则通过putTreeVal方法往红黑树中插入节点;更新了节点数量,还要考虑扩容和链表转红黑树
size实现统计每个Segment对象中的元素个数,然后进行累加,但是这种方式计算出来的结果并不一样的准确的。先采用不加锁的方式,连续计算元素的个数,最多计算3次:如果前后两次计算结果相同,则说明计算出来的元素个数是准确的;如果前后两次计算结果都不同,则给每个Segment进行加锁,再计算一次元素的个数;通过累加baseCount和CounterCell数组中的数量,即可得到元素的总个数;

最后的最后,ConcurrentHashMap能完全替代HashTable吗?

hash table虽然性能上不如ConcurrentHashMap,但并不能完全被取代,两者的迭代器的一致性不同的,hash table的迭代器是强一致性的,而concurrenthashmap是弱一致的。 ConcurrentHashMap的get,clear,iterator 都是弱一致性的。
下面是大白话的解释:
- Hashtable的任何操作都会把整个表锁住,是阻塞的。好处是总能获取最实时的更新,比如说线程A调用putAll写入大量数据,期间线程B调用get,线程B就会被阻塞,直到线程A完成putAll,因此线程B肯定能获取到线程A写入的完整数据。坏处是所有调用都要排队,效率较低。
- ConcurrentHashMap 是设计为非阻塞的。在更新时会局部锁住某部分数据,但不会把整个表都锁住。同步读取操作则是完全非阻塞的。好处是在保证合理的同步前提下,效率很高。坏处 是严格来说读取操作不能保证反映最近的更新。例如线程A调用putAll写入大量数据,期间线程B调用get,则只能get到目前为止已经顺利插入的部分 数据。

选择哪一个,是在性能与数据一致性之间权衡。ConcurrentHashMap适用于追求性能的场景,大多数线程都只做insert/delete操作,对读取数据的一致性要求较低。

  • 7
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值