Java基础—09:集合之hashmap、hashtable

面试关键点:

  • hashmap实现的数据结构,数组、桶等。

  • hashmap的哈希冲突解决方法:拉链法等。拉链法的优缺点。

  • hashmap的参数及影响性能的关键参数:加载因子和初始容量。

  • Resize操作的过程。

  • hashmap容量为2次幂的原因。

  • hashtable线程安全、synchronized加锁。

  • hashtable和hashmap异同。

  • 为什么hashtable被弃用?

  • concurrenthashmap相比于hashtable做的优化、segment的概念、concurrenthashmap高效的原因。

  • 容器类中fastfail的概念。

  • concurrenthashmap的插入操作是直接操作数组中的链表吗?

一、HashMap

是一个散列表,用来存放key-value键值对

底层实现:(数组+链表+红黑树)

HashMap的主干是一个Entry数组链表则是主要为了解决哈希冲突而存在的。Entry是HashMap的基本组成单元,每一个Entry包含一个key、value、hashcode、next等。

如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找

所以,性能考虑,HashMap中的链表出现越少,性能才会越好。下图中的code为计算出来的hashcode值:

为什么会产生冲突?

当我们要新增或查找某个元素,我们通常把当前元素的关键字通过哈希函数映射到数组中的某个位置,如果两个不同的元素,通过哈希函数得到的存储位置相同就会产生冲突,也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突。

如何解决哈希冲突?

  1. 开放定址法(也称再散列法:线性探测再散列、二次探测再散列、伪随机探测再散列):会局部聚集
  2. 拉链法(也称链地址法
  3. 再哈希:增加了计算时间
  4. 建立公共溢出区(哈希表分为基本表溢出表

而HashMap则是采用了拉链法(链地址法),也就是数组+链表+红黑树的方式。如果一个桶中链表内的元素个数超过TREEIFY_THRESHOLD(默认是8),就使用红黑树来替换链表。

拉链法的优缺点:

  • 优点:解决了局部聚集现象,减小了平均查找长度;删除结点易于实现。
  • 缺点:额外分配内存用来存储下一个结点的地址;(JDK1.7中)当Hash冲突严重时,链表会越来越长,这样导致查找效率会越来越低,时间复杂度为O(n);当数据量达到临界值,需要进行扩容。

影响性能的两个参数:

  1. 初始容量:哈希表中桶的数量。
  2. 加载因子:哈希表中可以存储键值对的比例。

hashmap容量为2次幂的原因:

我们都知道 hashmap 的底层是一个数组加链表的结构,当向其中添加一个元素的时候,需要根据key的hash值,再去确定其在数组中的具体位置。

看源码,我们可以发现,确定数组位置的实现是 index =(n-1)& hash,其中 n 代表数组的长度,即HashMap的容量。

当n为2的幂次方时,(n-1)& hash 的值是均匀分布的;

当n不为2的幂次方时,(n-1)& hash 的值不是是均匀分布的,可能数组的一些位置永远不会插入数据,浪费数组的空间,加大hash冲突。
另一方面,一般我们可能会想通过 % 求余来确定位置,这样也可以,只不过 & 运算性能更优。而且当n是2的幂次方时:hash & (length - 1) == hash % length  。

因此,HashMap 容量为2次幂的原因,就是为了数据的的均匀分布,减少hash冲突,毕竟hash冲突越大,代表数组中一个链的长度越大,这样的话会降低hashmap的性能。

参考博客:https://blog.csdn.net/eaphyy/article/details/84386313

hashmap的遍历方式:

强烈建议使用第一种  EntrySet  进行遍历。

第一种可以把key value 同时取出,第二种还得需要通过key去取value,效率较低。

	//第一种:entrySet()方式遍历
	Iterator<Map.Entry<String, Integer>> entryIterator = map.entrySet().iterator();
	while(entryIterator.hashNext()) {
		Map.Entry<String, Integer> next = entryIterator.next();
		System.out.println("key=" + next.getKey() + " value=" + next.getValue());
	}
	//第二种:keySet()方式遍历
	Iterator<String> iterator = map.keySet().iterator();
	while(iterator.hasNext()) {
		String key = iterator.next();
		System.out.println("key=" + key + " value=" + map.get(key));
	}

源码解析(基于JDK1.9):


1.类的继承关系

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

2.类的属性

    //序列号
    private static final long serialVersionUID = 362498820763181265L;
    //默认的初始容量是16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    //最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    //默认的加载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    //当桶(bucket)上的结点数大于8时链表会转成红黑树
    static final int TREEIFY_THRESHOLD = 8;
    //当桶(bucket)上的结点数小于6时红黑树转成单链表
    static final int UNTREEIFY_THRESHOLD = 6;
    //桶中结构转化为红黑树对应的数组的最小大小,如果当前容量小于它,就不会将链表转化为红黑树,而是用resize()代替
    static final int MIN_TREEIFY_CAPACITY = 64;
    //存储元素的数组,总是2的幂
    transient Node<K,V>[] table;
    // 存放具体元素的集
    transient Set<Map.Entry<K,V>> entrySet;
    // 存放元素的个数,注意这个不等于数组的长度。
    transient int size;
    // 每次扩容和更改map结构的计数器
    transient int modCount;
    // 临界值 当实际节点个数超过临界值(容量*加载因子)时,会进行扩容
    int threshold;
    // 加载因子
    final float loadFactor;

与1.7、1.8的区别:

  • (1.8)增加了链表转换成红黑树的阈值;HashEntry修改为Node。
  • (1.9)增加了树转换为链表的临界值。

3.Node结点的核心组成

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

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

4.类的构造函数

在HashMap的构造函数中并没有对数组Node<K,V>[] table初始化,而是简单的设置参数,在首次put时调用resize()分配内存

    //指定初始容量和加载因子
    public HashMap(int initialCapacity, float loadFactor) {
    	//初始容量不能小于0,否则报错
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        //初始容量不能大于最大值,否则设为最大值
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //加载因子不能小于或等于0,不能为非数字
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        //初始化加载因子
        this.loadFactor = loadFactor;
        //通过tableSizeFor(cap)计算出不小于initialCapacity的最近的2的幂作为初始容量,
        //将其先保存在threshold里,当put时判断数组为空会调用resize分配内存,并重新计算正确的threshold
        this.threshold = tableSizeFor(initialCapacity);
    }
    
    //指定初始容量
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    //默认构造函数
    public HashMap() {
    	//初始化加载因子
        this.loadFactor = DEFAULT_LOAD_FACTOR; 
    }

    public HashMap(Map<? extends K, ? extends V> m) {
    	//初始化加载因子
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        //将m中的所有元素添加至HashMap中
        putMapEntries(m, false);
    }

其中tableSizeFor(initialCapacity)返回最近的不小于输入参数的2的整数次幂。比如10,则返回16。

    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

原理如下:
先说5个移位操作,会使cap的二进制从最高位的1到末尾全部置为1。

假设cap的二进制为01xx…xx。
对cap右移1位:01xx…xx,位或:011xx…xx,使得与最高位的1紧邻的右边一位为1,
对cap右移2位:00011x..xx,位或:01111x..xx,使得从最高位的1开始的四位也为1,
以此类推,int为32位,所以在右移16位后异或最多得到32个连续的1,保证从最高位的1到末尾全部为1。

最后让结果+1,就得到了最近的大于cap的2的整数次幂。

再看第一条语句:让cap-1再赋值给n的目的是令找到的目标值大于或等于原值。如果cap本身是2的幂,如8(1000(2)),不对它减1而直接操作,将得到16。

通过tableSizeFor(),保证了HashMap容量始终是2的次方,在通过hash寻找index时就可以用逻辑运算来替代取余,即hash%n用hash&(n -1)替代。

5.hash()实现

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

没有直接使用key的hashcode(),而是使key的hashcode()高16位不变,低16位与高16位异或作为最终hash值。

大意是,如果直接使用key的hashcode()作为hash很容易发生碰撞。比如,在n - 1为15(0x1111)时,散列值真正生效的只是低4位。当新增的键的hashcode()是2,18,34这样恰好以16的倍数为差的等差数列,就产生了大量碰撞。

因此,设计者综合考虑了速度、作用、质量,把高16bit和低16bit进行了异或。因为现在大多数的hashCode的分布已经很不错了,就算是发生了较多碰撞也用O(logn)的红黑树去优化了。仅仅异或一下,既减少了系统的开销,也不会造成因为高位没有参与下标的计算(table长度比较小时),从而引起的碰撞。

6.resize方法

resize重新分配内存的两种情况:

  1. 当数组未初始化,按照之前在threashold中保存的初始容量分配内存,没有就使用缺省值
  2. 当超过限制时,就扩充两倍,因为我们使用的是2次幂的扩展,所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置
    final Node<K,V>[] resize() {
    	// 当前table保存
        Node<K,V>[] oldTab = table;
        // 保存table大小
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // 保存当前阈值 
        int oldThr = threshold;
        int newCap, newThr = 0;
        // 之前table大小大于0,即已初始化
        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;
        }
        // 初始容量已存在threshold中
        else if (oldThr > 0)
            newCap = oldThr;
        // 使用缺省值(使用默认构造函数初始化)
        else {               
            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);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        // 初始化table
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        // 之前的table已经初始化过
        if (oldTab != null) {
        	// 复制元素,重新进行hash
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    //桶中只有一个结点
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)			 //红黑树
                    	 //根据(e.hash & oldCap)分为两个,如果哪个数目不大于UNTREEIFY_THRESHOLD,就转为链表
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else {
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        // 将同一桶中的元素根据(e.hash & oldCap)是否为0进行分割成两个不同的链表,完成rehash
                        do {
                            next = e.next;                       //保存下一个节点
                            if ((e.hash & oldCap) == 0) {		 //保留在低部分即原索引
                                if (loTail == null)				 //第一个结点让loTail和loHead都指向它
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {					 //hash到高部分即原索引+oldCap
                                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;
    }

注意:进行扩容,会重新进行内存分配,并且会遍历hash表中所有的元素,是非常耗时的。在编写程序中,要尽量避免resize。

Resize操作的过程:

  1. 判断table是否初始化,如果初始化了就再判断是否大于等于扩容的最大值,是则只设置阈值;否则阈值翻倍;
  2. 如果未初始化,就按照之前在threashold中保存的初始容量分配内存,并且计算新阈值;
  3. 没有就赋默认的初始容量和阈值;
  4. 对初始化了的table复制元素,重新进行hash。

7.put方法

put方法大致的思路为:

  1. 判断当前桶是否为空,空的话就需要初始化(resize中会判断是否进行初始化)
  2. 对key的hashCode()做hash,然后再计算桶的index并判断是否为空,为空则表明没有hash冲突,就直接在当前位置创建一个新桶即可;
  3. 如果当前桶有值(碰撞了),那么就要比较当前桶中的key、key的hashcode 与写入的key 是否相等,相等就赋值给 e ;
  4. 如果当前桶为红黑树,那么就要按照红黑树的方式写入数据;
  5. 如果是个链表,就需要将当前的key、value 封装成一个新结点写入到当前桶的后面(形成链表);
  6. 接着判断当前链表的大小是否大于等于预设的阈值(大于等于TREEIFY_THRESHOLD),大于等于就把链表转换成红黑树(若数组容量小于MIN_TREEIFY_CAPACITY,不进行转换而是进行resize操作)
  7. 如果在遍历过程中,key相同时直接退出遍历;
  8. 如果 e != null 就相当于存在相同的key,那就需要将原来的值覆盖(保证key的唯一性); 
  9. 最后,如果表中实际元素个数超过阈值(超过load factor*current capacity),就要resize
    public V put(K key, V value) {
    	//对key的hashCode()做hash
        return putVal(hash(key), key, value, false, true);
    }
    /**
     * 用于实现put()方法和其他相关的方法
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
            boolean evict) {
		 Node<K,V>[] tab; Node<K,V> p; int n, i;
		 // table未初始化或者长度为0,进行扩容,n为桶的个数
		 if ((tab = table) == null || (n = tab.length) == 0)
		     n = (tab = resize()).length;
		 // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
		 if ((p = tab[i = (n - 1) & hash]) == null)
		     tab[i] = newNode(hash, key, value, null);
		 // 桶中已经存在元素
		 else {
		     Node<K,V> e; K k;
		     // 比较桶中第一个元素的hash值相等,key相等
		     if (p.hash == hash &&
		         ((k = p.key) == key || (key != null && key.equals(k))))
		    	    // 将第一个元素赋值给e,用e来记录
		         	e = p;
		     // hash值不相等或key不相等
		     else if (p instanceof TreeNode)  //红黑树
		    	 // 放入树中
		         e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
		     // 为链表结点
		     else {
		         for (int binCount = 0; ; ++binCount) {
		        	 // 到达链表的尾部
		             if ((e = p.next) == null) {
		            	 // 在尾部插入新结点
		                 p.next = newNode(hash, key, value, null);
		                 // 结点数量达到阈值,调用treeifyBin()做进一步判断是否转为红黑树
		                 if (binCount >= TREEIFY_THRESHOLD - 1)
		                     treeifyBin(tab, hash);
		                 // 跳出循环
		                 break;
		             }
		             // 判断链表中结点的key值与插入的元素的key值是否相等
		             if (e.hash == hash &&
		                 ((k = e.key) == key || (key != null && key.equals(k))))
		            	 // 相等,跳出循环
		                 break;
		             // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
		             p = e;
		         }
		     }
		     // 表示在桶中找到key值、hash值与插入元素相等的结点
		     if (e != null) {
		    	 // 记录e的value
		         V oldValue = e.value;
		         // onlyIfAbsent为false或者旧值为null
		         if (!onlyIfAbsent || oldValue == null)
		        	 //用新值替换旧值
		             e.value = value;
		         // 访问后回调
		         afterNodeAccess(e);
		         // 返回旧值
		         return oldValue;
		     }
		 }
		 // 结构性修改
		 ++modCount;
		 // 实际大小大于阈值则扩容
		 if (++size > threshold)
		     resize();
		 // 插入后回调
		 afterNodeInsertion(evict);
		 return null;
		}
    
    //将指定映射的所有映射关系复制到此映射中
    public void putAll(Map<? extends K, ? extends V> m) {
        putMapEntries(m, true);
    }
    
    //将m的所有元素存入本HashMap实例中,evict为false时表示构造初始HashMap
    final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
        int s = m.size();
        if (s > 0) {
        	// table未初始化
            if (table == null) {
            	//计算初始容量
                float ft = ((float)s / loadFactor) + 1.0F;
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                if (t > threshold)
                    threshold = tableSizeFor(t);//同样先保存容量到threshold
            }
            // 已初始化,并且m元素个数大于阈值,进行扩容处理
            else if (s > threshold)
                resize();
            // 将m中的所有元素添加至HashMap中
            for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                K key = e.getKey();
                V value = e.getValue();
                putVal(hash(key), key, value, false, evict);
            }
        }
    }
    //将链表转换为红黑树
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        //若数组容量小于MIN_TREEIFY_CAPACITY,不进行转换而是进行resize操作
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
            	//将Node转换为TreeNode
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                //重新排序形成红黑树
                hd.treeify(tab);
        }
    }

8.get方法

get方法大致思路如下:

  1.  首先将 key hash 之后取得所定位的桶;
  2. 如果桶为空则直接返回null;
  3. 否则判断桶的第一个位置(有可能是链表、红黑树)的key是否为查询的key, 是就直接返回value;
  4. 如果第一个不匹配,则判断它的下一个是红黑树还是链表;
  5. 红黑树就按照树的查找方式返回值;
  6. 不然就按照链表的方式遍历匹配返回值。
    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
    
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        // table已经初始化,长度大于0,且根据hash寻找table中的项也不为空
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
        	// 比较桶中第一个节点
            if (first.hash == hash && 
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            // 桶中不止一个结点
            if ((e = first.next) != null) {
            	// 为红黑树结点
                if (first instanceof TreeNode)
                	// 在红黑树中查找
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                // 否则,在链表中查找
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }
    
    public boolean containsKey(Object key) {
        return getNode(hash(key), key) != null;
    }
    
    public boolean containsValue(Object value) {
        Node<K,V>[] tab; V v;
        if ((tab = table) != null && size > 0) {
            //外层循环搜索数组
            for (Node<K, V> e : tab) {
            	//内层循环搜索链表
                for (; e != null; e = e.next) {
                    if ((v = e.value) == value ||
                        (value != null && value.equals(v)))
                        return true;
                }
            }
        }
        return false;
    }

9.HashMap总结

  1. Java8中hash计算是通过key的hashCode()的高16位异或低16位实现的,既保证高低bit都能参与到hash的计算中,又不会有太大的开销。
  2. 数组大小n总是2的整数次幂,计算下标时直接( hash & n-1)
  3. 分配内存统一放在resize()中,包括创建后首次put时初始化数组和存放元素个数超过阈值时扩容。
  4. Java8引入红黑树,当链表长度达到8, 执行treeifyBin,当桶数量达到64时,将链表转为红黑树,否则,执行resize()。
  5. 判断Node是否符合,首先判断哈希值要相等,但因为哈希值不是唯一的,所以还要对比key是否相等,最好是同一个对象,能用==对比,否则要用equals()

建议:

  1. String类型的key,不能用==判断或者可能有哈希冲突时,尽量减少长度
  2. 在集合视图迭代的时间与桶的数量加上映射的数量成正比,若迭代性能很重要,不要设置太高的初始容量或过小的负载因子
  3. 如果映射很多,创建HashMap时设置充足的初始容量(预计大小/负载因子 + 1)会比让其自动扩容获得更好的效率,一方面减少了碰撞可能,另一方面减少了resize的损耗
  4. 迭代器是fail-fast的,迭代器创建后如果进行了结构修改(增加或删除一个映射)且不是使用iterator的remove方法,会努力抛出ConcurrentModificationException,所以不能依赖该异常保证程序运行正确,而只可用于检测bug

参考博客:https://blog.csdn.net/qazwyc/article/details/76686915

HashMap并发可能出现什么问题?

当HashMap扩容调用 resize() 方法时,并发操作容易在一个桶上形成环形链表;这样当获取一个不存在的key时,计算出的index 正好是环形链表的下标,这样就会出现死循环。


 二、Hashtable

hashtable线程安全、synchronized加锁

HashTable里使用的是synchronized关键字,这其实是对对象加锁,锁住的都是对象整体

hashtable和hashmap异同

相同点:都实现了Map接口。

不同点:(主要区别有:线程安全性,同步,以及速度)

  1. HashMap是非线程安全的,如果不做任何的同步操作,并发可能出现死循环。HashTable是线程安全的,内部的方法基本都经过synchronized修饰,所以多个线程可以共享一个Hashtable。但在单一线程环境下,HashMap性能更好。
  2. HashMap的键和值都允许有null存在,而HashTable则都不行。在HashTable中put进的键值只要有一个null,直接抛出NullPointerException。
  3. 哈希值的使用不同,HashTable直接使用对象的hashCode。而HashMap重新计算hash值。
  4. HashTable在不指定容量的情况下的默认容量为11,而HashMap为16;Hashtable不要求底层数组的容量一定要为2的整数次幂,而HashMap则要求一定为2的整数次幂。Hashtable扩容时,将容量变为原来的2倍加1,而HashMap扩容时,将容量变为原来的2倍。
  5. HashMap的迭代器是fail-fast迭代器,而HashTable不是。所以当有其他线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException异常;但迭代器本身的remove()方法移除元素不会。
  6. 两个遍历方式的内部实现上不同。Hashtable、HashMap都使用了 Iterator。而由于历史原因,Hashtable还使用了Enumeration的方式 。

  7. 继承的父类不同,Hashtable继承自Dictionary类,而HashMap继承自AbstractMap类。

  8. HashMap把Hashtable的contains方法去掉了,改成containsValue和containsKey,因为contains方法容易让人引起误解。Hashtable则保留了contains,containsValue和containsKey三个方法,其中contains和containsValue功能相同。

  9. HashMap不能保证随着时间的推移Map中的元素次序不变。

为什么hashtable被弃用?

  1. 在线程竞争激烈的情况下,当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,运行效率比较低;
  2. 不符合大小驼峰命名规则;
  3. 且父类也已经过时。

concurrenthashmap相比于hashtable做的优化

Hashtable容器在竞争激烈的并发环境下表现出效率低下的原因是:HashTable 对get,put,remove 方法都使用了同步操作,所有访问HashTable的线程都必须竞争同一把锁。这就造成如果两个线程都只想使用get 方法去读取数据时,因为一个线程先到进行了锁操作,另一个线程就不得不等待,这样必然导致效率低下,而且竞争越激烈,效率越低下。

假如容器里有多把锁,每一把锁用于锁容器中的一部分数据,那么当多线程访问容器里不同数据段的数据时,线程之间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个数据段时,其他段的数据也能被其他线程访问。

注意:HashMap可以通过下面的语句进行同步:

Map map = Collections.synchronizedMap(hashMap);


三、ConcurrentHashMap(Java5以后)

当Hashtable的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间。是HashTable的替代,是对上述问题的优化,比HashTable扩展性更好。是线程安全的HashMap的实现。

Java7 中ConcurrentHashMap的实现:

由Segment[ ]数组、HashEntry[ ] 数组组成,(数组 + 链表)

segment的概念及组成:

Segment本身就相当于一个HashMap对象。同HashMap一样,Segment包含一个HashEntry数组,数组中的每一个HashEntry既是一个键值对,也是一个链表的头节点。像这样的Segment对象,在ConcurrentHashMap集合中有2的N次方个,共同保存在一个名为segments的数组当中。

//Segment数组,存放数据时首先需要定位到具体的Segment中
final Segment<K,V>[] segments;

 Segment是ConcurrentHashMap的一个内部类,主要组成如下:

static final class Segment<K,V> extends ReentrantLock implements Serializable {  
	 
	 private static final long serialVersionUID = 2249069246763182397L;  

	 /**
	  * table 是由 HashEntry 对象组成的数组
	  * 如果散列时发生碰撞,碰撞的 HashEntry 对象就以链表的形式链接成一个链表
	  * table 数组的数组成员代表散列映射表的一个桶
	  * 每个 table 守护整个 ConcurrentHashMap 包含桶总数的一部分
	 */
	 transient volatile HashEntry<K,V>[] table;  


	 //在本 segment 范围内,包含的 HashEntry 元素的个数
	 //该变量被声明为 volatile 型,保证每次读取到最新的数据 
	 transient volatile int count;  

	 //table 被更新的次数 
	 transient int modCount;  
 
	 //当 table 中包含的 HashEntry 元素的个数超过本变量值时,触发 table 的再散列
	 transient int threshold;  
 
	 final float loadFactor;  
}

整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表”部分“或”一段“的意思,所以很多地方都会将其描述为分段锁。Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

用 new ConcurrentHashMap() 无参构造函数初始化完成后,我们得到了一个 Segment 数组:

  • Segment 数组长度为 16,不可以扩容
  • Segment[i] 的默认大小为 2,负载因子是 0.75,得出初始阈值为 1.5,也就是以后插入第一个元素不会触发扩容,插入第二个会进行第一次扩容

HashEntry的组成:

static final class HashEntry<K,V> {
    final int hash;    // 哈希值
    final K key;       // 键
    volatile V value;  // 值
    volatile HashEntry<K,V> next; // 下一个HashEntry节点

    HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
}

核心数据value、next都是volatile关键字修饰,保证了内存可见性,所以每次获取时都是最新值。 

put方法的算法流程(需要加锁):

  1. 通过key定位到Segment,在对应的Segment中进行具体的put;
  2. 尝试获取锁,如果获取失败,则利用scanAndLockForPut ( ) 自旋获取锁,如果重试的次数达到了MAX_SCAN_RETRIES,则改为阻塞锁获取,保证能获取成功;
  3. 将当前Segment中的table通过key的hashcode定位到HashEntry数组;
  4. 遍历该HashEntry数组,如果不为空则判断传入的key和当前遍历的key是否相等,相等则覆盖旧的value;
  5. 不相等则需要新建一个HashEntry并加入到Segment中,同时会先判断是否需要扩容;
  6. 最后会解除在1中所获取的当前Segment的锁。

自旋的方法 scanAndLockForPut ( ):

private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
    // 第一个HashEntry节点
    HashEntry<K,V> first = entryForHash(this, hash);
    // 当前的HashEntry节点
    HashEntry<K,V> e = first;
    HashEntry<K,V> node = null;
    // 重复计数(自旋计数器)
    int retries = -1; // negative while locating node

    // 查找”key-value键值对“在”HashEntry链表上对应的节点“;
    // 若找到的话,则不断的自旋;在自旋期间,若通过tryLock()获取锁成功则返回;否则自旋MAX_SCAN_RETRIES次数之后,强制获取”锁“并退出。
    // 若没有找到的话,则新建一个HashEntry链表。然后不断的自旋。
    // 此外,若在自旋期间,HashEntry链表的表头发生变化;则重新进行查找和自旋工作!
    while (!tryLock()) {
        HashEntry<K,V> f; // to recheck first below
        // 1. retries<0的处理情况
        if (retries < 0) {
            // 1.1 如果当前的HashEntry节点为空(意味着,在该HashEntry链表上上没有找到”要插入的键值对“对应的节点),而且node=null;
            // 则新建HashEntry链表。
            if (e == null) {
                if (node == null) // speculatively create node
                    node = new HashEntry<K,V>(hash, key, value, null);
                retries = 0;
            }
            // 1.2 如果当前的HashEntry节点是”要插入的键值对在该HashEntry上对应的节点“,则设置retries=0
            else if (key.equals(e.key))
                retries = 0;
            // 1.3 设置为下一个HashEntry。
            else
                e = e.next;
        }
        // 2. 如果自旋次数超过限制,则获取“锁”并退出
        else if (++retries > MAX_SCAN_RETRIES) {
            lock();
            break;
        }
        // 3. 当“尝试了偶数次”时,就获取“当前Segment的第一个HashEntry”,即f。
        // 然后,通过f!=first来判断“当前Segment的第一个HashEntry是否发生了改变”。
        //若是的话,则重置e,first和retries的值,并重新遍历。
        else if ((retries & 1) == 0 &&
                 (f = entryForHash(this, hash)) != first) {
            e = first = f; // re-traverse if entry changed
            retries = -1;
        }
    }
    return node;
}

get方法的算法流程(不需要加锁):

  1. 将 key 通过 hash 之后定位到具体的Segment;
  2. 再通过一次 hash 定位到具体的 元素上。

因为get方法的整个过程都不需要加锁,所以非常高效。

1.7 已经解决了并发问题,并且能支持N个 Segment 多次数的并发,但依然存在HashMap在1.7版本中的问题:

“那就是查询遍历链表的效率太低”

因此 1.8 做了一些数据结构上的调整。


Java8 中ConcurrentHashMap的实现:

数组 + 链表 + 红黑树

抛弃了原有的Segment分段锁,而采用了 CAS + synchronized 来保证并发安全性。 

还将1.7中存放数据的 HashMap 改为Node,但作用都是相同的。

put方法的流程:

  1. 根据key计算出hashcode;
  2. 遍历table,判断是否需要进行初始化;
  3. f 即为当前key定位出的Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功;
  4. 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容;
  5. 如果都不满足,则利用synchronized锁写入数据;
  6. 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

get方法的流程:

  1. 根据计算出来的hashcode寻址,如果就在桶上那么直接返回值;
  2. 如果是红黑树,那就按照树的方式获取值;
  3. 都不满足那就按照链表的方式遍历获取值。

1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(O(log n)),甚至取消了ReentrantLock ,改为了 synchronized, 这样可以看出在新版的JDK 中对 synchronized的优化是很到位的。

ConcurrentHashMap并发读写的几种情形: 

  1. 不同Segment的并发写入(可并发执行)
  2. 同一Segment的一写一读(可并发执行)
  3. 同一Segment的并发写入(Segment的写入是需要上锁的,因此对同一Segment的并发写入会被阻塞)

由此可见,ConcurrentHashMap当中每个Segment各自持有一把锁。在保证线程安全的同时降低了锁的粒度,让并发操作效率更高。

concurrenthashmap高效的原因:

  • ConcurrentHashMap 将数据分到多个segment 中(默认16,也可在申明时自己设置,不过一旦设定就不能更改,扩容都是扩充各个segment 的容量),每个segment 都有一个自己的锁,只要多个线程访问的不是同一个segment 就没有锁争用,就没有堵塞,也就是允许16个线程并发的更新而尽量没有锁争用。
  • ConcurrnetHashMap 中get 方法是不涉及到锁。在并发读取时,除了key 对应的value 为null 外,并没有用到锁,所以对于读操作无论多少线程并发都是安全高效的。

容器类中fast-fail:

1.概念:

        fail-fast 机制,即快速失败机制,是java集合(Collection)中的一种错误检测机制。当在迭代集合的过程中该集合在结构上发生改变的时候,就有可能会发生 fail-fast,即抛出 ConcurrentModificationException 异常。fail-fast 机制并不保证在不同步的修改下一定会抛出异常,它只是尽最大努力去抛出,所以这种机制一般仅用于检测 bug。  

   fail-fast 机制出现在 java 集合的 ArrayList、HashMap 等,在多线程或者单线程里面都有可能出现快速报错,即出现 ConcurrentModificationException 异常。

2.有什么用?

为了检测在迭代集合的过程中,这个集合是否发生了增加、删除等(add、remove、clear)使结构发生变化的事


/**

*HashMap这块的内容是面试经常会问的地方,所以整理一下为后面面试和复习做准备,与君共勉!

*/


如有错误,欢迎留言指正  * _ *

猜你喜欢

转载自blog.csdn.net/jingzhi111/article/details/86751711