HashMap、ConcurrentHashMap、HashTable比较

前言

三者的比较一直是面试比较热门的问题,如果从源码的角度去分析各自的实现,则很容易对三者做出区分。本文将从以下几点展开论述:

1.什么是HashMap?HashMap的数据结构以及实现原理
2.什么是ConcurrentHashMap?ConcurrentHashMap的数据结构以及实现原理
3.什么是HashTable?HashTable的数据结构以及实现原理
4.总结。(三者的区别)

1.什么是HashMap?HashMap的数据结构以及实现原理

请看此文。或前往我的主页查看文章《Map接口实现——HashMap源码分析》

2.什么是ConcurrentHashMap?ConcurrentHashMap的数据结构以及实现原理

JDK1.7
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
在JDK1.7中,ConcurrentHashMap采用Segment + HashEntry的数据结构:

transient volatile HashEntry<K,V>[] table;
final Segment<K,V>[] segments;

在这里插入图片描述
如上图所示:
1.一个 ConcurrentHashMap 里包含一个 Segment 对象类型的数组。
2.Segment 的结构和HashMap类似,是一种数组和链表结构(HashEntry 对象类型的数组),segment继承了ReentrantLock。
3.ReentrantLock是可重入锁,是指一个线程获取锁之后再尝试获取锁时会自动获取锁,可重入锁的优点是避免死锁。其实,synchronized 也是可重入锁。ReentrantLock与synchronized 的区别是什么呢?有兴趣的小伙伴可看这里
4.一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。
因此:

该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对,后者用来充当锁的角色;
Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。

JDK1.8 对 HashMap 做了改造,当冲突链表长度大于8,并且数组长度大于64时,会将链表转变成红黑树结构。
JDK1.8后,ConcurrentHashMap 类取消了 Segment 分段锁,采用 CAS + synchronized 来保证并发安全,数据结构跟 jdk1.8 中 HashMap 结构类似,都是数组 + 链表(当链表长度大于8时,链表结构转为红黑二叉树)结构。

因此,我们可以大胆猜测一下,ConcurrentHashMap 的默认初始化大小、默认负载因子、数组最大容量、每次扩容是原来的2倍等等应该和JDK1.8后的HashMap 一样。不同的是ConcurrentHashMap 是线程安全的,HashMap 是线程不安全的。

//官方注释:The array of bins. Lazily initialized upon first insertion.
//采用懒加载,只有在第一次插入操作时,才会初始化数组。和HashMap一样
transient volatile Node<K,V>[] table;
//负载因子
private static final float LOAD_FACTOR = 0.75f;
//默认初始化容量
private static final int DEFAULT_CAPACITY = 16;
//最大容量
 private static final int MAXIMUM_CAPACITY = 1 << 30;

我们看一下ConcurrentHashMap是如何保证并发的
1.多线程访问共享变量的数据一致性问题:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        //value值被volatile 修饰,保证多线程共享变量的一致性
        volatile V val;
        volatile Node<K,V> next;
        方法省略...
}

volatile :
java中的关键字,当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
2.put操作

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;
            //容器数组是否为空,如果为空就初始化数组;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            //要插入的元素f,在当前数组下标是否第一次插入,如果是就通过 CAS 方式插入
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))
                    break; // no lock when adding to empty bin
            }
            //判断f.hash == -1是否成立,如果成立,说明当前f是ForwardingNode节点,表示有其它线程正在扩容,则一起进行扩容操作;
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
            //就是把新的Node节点按链表或红黑树的方式插入到合适的位置;
                V oldVal = null;
                //注意:锁加在了这里
                synchronized (f) {
                    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)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {
                            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) {
                //节点插入完成之后,接着判断链表长度是否超过8,如果超过8个,就将链表转化为红黑树结构;
                //TREEIFY_THRESHOLD = 8
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        //最后,插入完成之后,进行扩容判断。
        addCount(1L, binCount);
        return null;
    }

从面代码我们也可以看到ConcurrentHashMap在初始化时和HashMap采取的机制一样,都是懒加载机制,即在第一次执行put操作向Map中插入数据时,判断table数组是否为空,为空就先初始化。

CAS(Compare And Swap):它是一种乐观锁,认为对于同一个数据的并发操作不一定会发生修改,在更新数据的时候,尝试去更新数据,如果失败就不断尝试。

懒加载机制的好处:防止用户创建了map不使用,浪费资源。

private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        while ((tab = table) == null || tab.length == 0) {
            if ((sc = sizeCtl) < 0)
                Thread.yield(); // lost initialization race; just spin
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    if ((tab = table) == null || tab.length == 0) {
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        @SuppressWarnings("unchecked")
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = tab = nt;
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }

使用CAS锁控制只有一个线程初始化tab数组;
sizeCtl在初始化后存储的是扩容门槛;
扩容门槛写死的是tab数组大小的0.75倍,tab数组大小即map的容量,也就是最多存储多少个元素。
总结:
在JDK1.8中,ConcurrentHashMap底层的数据结构实现和HashMap一致。在线程安全上,放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

3.什么是HashTable?HashTable的数据结构以及实现原理

Hashtable 是早期 Java 类库提供的一个哈希表实现,本身是同步的即线程安全,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用。
hashtable和jdk1.8之前的hashmap的底层数据机构类似都是采用数组+链表的形式,数组是hashmap的主体,链表则是主要为了解决哈希冲突而存在的。

private transient Entry<?,?>[] table;
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//Hashtable的构造方法
public Hashtable() {
		//无参构造方法,默认初始化容量为11,负载因子是0.75
        this(11, 0.75f);
    }
//有参构造方法
public Hashtable(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal Load: "+loadFactor);

        if (initialCapacity==0)
            initialCapacity = 1;
        this.loadFactor = loadFactor;
        //从这里可以看出,Hashtable的初始化不是懒加载机制
        table = new Entry<?,?>[initialCapacity];
        threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
    }
//方法被synchronized 修饰,是线程安全的
public synchronized boolean containsKey(Object key){省略....}
public synchronized V get(Object key){省略...}

Hashtable的扩容机制

protected void rehash() {
        int oldCapacity = table.length;
        Entry<?,?>[] oldMap = table;

        // overflow-conscious code
        int newCapacity = (oldCapacity << 1) + 1;
        if (newCapacity - MAX_ARRAY_SIZE > 0) {
            if (oldCapacity == MAX_ARRAY_SIZE)
                // Keep running with MAX_ARRAY_SIZE buckets
                return;
            newCapacity = MAX_ARRAY_SIZE;
        }
        Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];

        modCount++;
        threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
        table = newMap;

        for (int i = oldCapacity ; i-- > 0 ;) {
            for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) {
                Entry<K,V> e = old;
                old = old.next;

                int index = (e.hash & 0x7FFFFFFF) % newCapacity;
                e.next = (Entry<K,V>)newMap[index];
                newMap[index] = e;
            }
        }
    }

在put操作时并没有判断table数组是否为空!证实了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;
    }

4.总结。(三者的区别)

4.1 Hashtable、HashMap的区别

HashMap:

1.JDK1.8之后,数据结构的实现是数组+链表+红黑树
2.默认初始化容量是16,负载因子是0.75
3.发生冲突时,且链表长度大于8时:
数组长度小于64,选择扩容;
数组长度大于64,链表转换为红黑树。
4.初始化是懒加载机制,即在第一次往Map中添加数据时初始化。
5.HashMap 不是同步的,支持 null 键和值
6.等等(大家底层实现明白后自己便能总结 )

HashTable:

1.数据结构的实现是数组+链表。
2.默认初始化容量是11,负载因子是0.75
3.发生冲突时,直接将结点插入到链表中
4.直接初始化(创建出却暂时不使用时,浪费资源)
5.方法是同步的(线程安全),不支持 null 键和值(看上面put方法的源码)

4.2 HashMap和ConcurrentHashMap 的区别

JDK1.8之后,两者的数据结构实现是一样的;
默认初始化容量、负载因子一样的
初始化 都是懒加载机制。

HashMap的键值对允许有null,但是ConCurrentHashMap不允许。
HashMap线程不安全,ConcurrentHashMap 线程安全

4.3 ConcurrentHashMap 和 Hashtable的区别

ConcurrentHashMap 和 Hashtable 的区别主要体现在底层数据结构和实现线程安全的方式上不同

1.Hashtable和HashMap的区别(除了线程安全以及不支持键值对的null)
底层数据结构:

JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;

实现线程安全的方式(重要):

1.在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,比Hashtable效率提高16倍。) 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
2.Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈,效率越低。

两者的对比图: (大佬的图,来自技术人之路)
HashTable:
在这里插入图片描述
JDK1.7的ConcurrentHashMap:
在这里插入图片描述
JDK1.8的ConcurrentHashMap(TreeBin: 红黑树节点 Node: 链表节点):
在这里插入图片描述

ConcurrentHashMap 结合了 HashMap 和 HashTable 二者的优势。HashMap 没有考虑同步,HashTable 考虑了同步的问题。但是 HashTable 在每次同步执行时都要锁住整个结构。ConcurrentHashMap 锁的方式是稍微细粒度的。

写在最后:
拥抱开源,成就自己,侵权必删。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值