02_java集合

2.2 如何决定使用HashMap还是TreeMap?

  • HashMap:插入,删除,定位一个元素快
  • TreeMap:有序遍历集合快

2.3 HashMap实现原理

  1. HashMap基于Hash算法实现的,当我们通过put存储,get获取时,HashMap会根据key.hashCode()计算hash值,根据hash值将value保存在bucket中。当计算出hash值有相同时,即产生了hash冲突,HashMap是通过链表和红黑树来存储相同的hash值得value,如果冲突个数较少使用链表,否则红黑树

2.4 ArrayList与LinkedList的区别

  • ArrayList:查找快
  • LinkedList:增删比较快。

2.5 在Queue中poll()和remove()有何区别?

  • 相同点:都会返回第一个元素。并在队列中删除返回的元素
  • 不同点:
    • 没有元素poll()返回null
    • 没有元素remove()直接抛出NoSuchElementException异常

2.6 LinkedHashMap有什么特点?

LinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,用迭代器遍历会先遍历先插入的。

2.7 HashMap的底层实现原理?(高频)

  1. 从结构上来讲,HashMap是由数组+链表+红黑树(jdk1.8)来实现的
    在这里插入图片描述

  2. Node是HashMap的一个内部类,实现了Map.Entry接口,本质是一个映射(键值对)。HashMap类中有一个非常重要的字段,就是Node[] table,即哈希桶数组。

    static class Node<K,V> implements Map.Entry<K,V> {
    	final int hash; //⽤用来定位数组索引位置
    	final K key;
    	V value;
    	Node<K,V> next; //链表的下⼀一个node
    	Node(int hash, K key, V value, Node<K,V> next) {}
    	public final K getKey(){}
    	public final V getValue() {}
    	public final String toString() {}
    	public final int hashCode() {}
    	public final V setValue(V newValue) {}
    	public final boolean equals(Object o) {}
    }
    
  3. 当我们执行下面代码

    map.put("美团","小美");
    

    系统会调用这个key的hashCode方法,得到hashCode值,然后通过Hash算法运算(之后讲)定位到该节点要对应的存储位置,如果两个key定位到相同的位置,表示发生了Hash碰撞。所以我们希望hash计算的结果分散的均匀,则碰撞概率就小。我们希望哈希桶数组占用空间较小,但是Hash碰撞的概率也要小。要做到这些则需要

    • 好的Hash算法
    • 好的扩容机制

    HashMap的默认构造函数对下面几个字段进行了初始化

    int threshold; // 所能容纳的key-value对极限
    final float loadFactor; // 负载因⼦子
    int modCount;
    int size;
    
    • Node[] table的初始化长度length(默认是16),loadFactor为负载因子(默认为0.75)。
    • threshold=length*loadFactor,HashMap元素数目超过了这个数量,就会发生扩容。
    • modCount:用来记录HashMap内部结构发生变化的次数。但是put新的键值对覆盖了旧的键值对不算结构变化。
    • size:目前HashMap实际存储的键值对大小
  4. HashMap的扩容方案

    1. table的length会在第一次往HashMap里put元素时会初始化

      1. 默认初始化为16
      2. 若指定了容量参数,HashMap会把这个容量修改为2的倍数(这个数不小于指定容量参数),然后创建对应长度的table。
    2. table会在达到threshold时进行扩容,扩容的时候length翻倍

      //当指定的容量不是2的倍数,则通过这个方法可以将容量修改为2的倍数
      static final int tableSizeFor(int cap) {
          //防止本身就是2的倍数,经过以下循环变成又扩大了一倍。
          int n = cap - 1;
          //无符号右移,将所有是1的位置它的下一位变为1。
          n |= n >>> 1;
          //无符号右移,将所有是1的位置它的下下为变为1.
          n |= n >>> 2;
          n |= n >>> 4;
          n |= n >>> 8;
          n |= n >>> 16;
          //最后肯定变成一排的1
          return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
      }
      
    3. 如果要往HashMap中放1000个元素,又不想让HashMap不停的扩容,最好一开始就把容量设为2048,设为1024不行,因为元素添加到七百多的时候达到threshold还是会继续扩容。

    4. 为什么要将HashMap的容量设置为2的n次方?

      1. 常规设计应该是把桶的大小设计为素数,因为素数导致冲突相对概率小于合数。
      2. 桶大小设置为素数在扩容之后不能保证还是素数。所以采用了这种非常规设计。
      3. 采用这种非常规的设计主要是在取模和扩容时做了优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。
  5. Hash算法:通过调用下面两个方法实现

    方法⼀:
    static final int hash(Object key) { 
        //jdk1.8 & jdk1.7
    	int h;
    	// h = key.hashCode() 为第⼀一步 取hashCode值
    	// h ^ (h >>> 16) 为第二步 高位参与运算
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    方法二:
    static int indexFor(int h, int length) { 
        //jdk1.7的源码,jdk1.8没有这个⽅方法,但是实现原理是一样的
    	return h & (length-1); //第三步 取模运算
    }
    

在这里插入图片描述

  1. 这个算法本质就是三步:取key的hashCode值,高位运算、取模运算

  2. 这里的取模运算其实是通过按位与运算完成的,通过取模运算会使元素分布相对均匀,但是模运算消耗还是比较大的。

  3. 高位运算作用是考虑到高地bit都参与到了Hash的计算中。

  4. put方法原理
    在这里插入图片描述

    public V put(K key, V value) {
        // 对key的hashCode()做hash
        return putVal(hash(key), key, value, false, true);
    }
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //步骤1:tab为空则创建
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //步骤2:定位index,若节点没发生冲突直接存入。
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //发生了冲突
        else {
            Node<K,V> e; K k;
            //步骤3;节点key存在,直接覆盖value
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //步骤4:若该链是红黑树
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //步骤5:若该链是链表
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //链表长度大于8转为红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //key已经存在直接覆盖value
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        //如果不是修改了值得情况,就mod++
        ++modCount;
        //步骤6:超过最大容量,就扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    

    扩容机制(jdk7,因为jdk8融入了红黑树,较为复杂,暂时不讨论)

    void resize(int newCapacity) { //传⼊入新的容量量
    	Entry[] oldTable = table; //引⽤用扩容前的Entry数组
    	int oldCapacity = oldTable.length;
    	if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组⼤大⼩小如果已经达到最⼤(2^30)了了
    		threshold = Integer.MAX_VALUE; //修改阈值为int的最⼤大值(2^31-1),这样以后就不不会扩容了了
    		return;
    	}
    	Entry[] newTable = new Entry[newCapacity]; //初始化⼀一个新的Entry数组
    	transfer(newTable); //!!将数据转移到新的Entry数组里
    	table = newTable; //HashMap的table属性引⽤用新的Entry数组
    	threshold = (int)(newCapacity * loadFactor);//修改阈值
    }
    
    void transfer(Entry[] newTable) {
    	Entry[] src = table; //src引⽤用了了旧的Entry数组
    	int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
            Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
            if (e != null) {
                src[j] = null;//释放旧Entry数组的对象引⽤用(for循环后,旧的Entry数组不再引⽤用任何对象)
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
                    e.next = newTable[i]; //标记[1]
                    newTable[i] = e; //将元素放在数组上
                    e = next; //访问下⼀一个Entry链上的元素
                } while (e != null);
            }
        }
    }
    
    1. 采用了单链表的头插入方式。同一位置上新元素总会被放到链表的头部位置;与JDK8有所区别

    2. 旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到新数组的不同位置上。

    3. 举列:table的size=2,key=3,7,5,;put顺序依次为5,7,3;mod2以后都冲突在table[1]这里,这里假设负载因子loadFactor=1;即当键值对的实际大小size大于table的实际大小时进行扩容,接下来的三个步骤就是哈希桶数组resize为4,然后所有的Node重写rehash的过程 在这里插入图片描述

    4. JDK1.8做的优化

      1. 每次扩容为原来的2倍,所以元素要么是在原来位置,要么是在原来位置在移动2二次幂的位置,如下图,n为table的长度。
        在这里插入图片描述

      2. 元素在重新计算hash后,因为n变为原来的2倍,那么n-1的mask范围就再高位多1bit(红色),因此新的index就会发生这样的变化:
        在这里插入图片描述

      3. 因此我们在扩充HashMap的时候,不需要像jdk7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是0还是1,是0不变,是1则索引变为“原索引+oldCap”,如下图

      在这里插入图片描述

      1. 这个设计既省去了重新计算hash值得时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此,resize的过程,均匀地吧之前的冲突的节点分散到新的bucket了。此外jdk8不会将链表元素倒置。

        final Node<K,V>[] resize() {
            Node<K,V>[] oldTab = table;
            int oldCap = (oldTab == null) ? 0 : oldTab.length;
            int oldThr = threshold;
            int newCap, newThr = 0;
            if (oldCap > 0) {
                //超过最大值就不再扩充。只好随你去碰撞吧
                if (oldCap >= MAXIMUM_CAPACITY) {
                    threshold = Integer.MAX_VALUE;
                    return oldTab;
                }
                //没超过就扩充为原来的2倍
                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);
            }
            //计算新的resize上限
            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"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
            table = newTab;
            if (oldTab != null) {
                //把每个bucket都移动到新的buckets中
                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)
                            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                        else { // preserve order
                            //链表优化重hash的代码块
                            Node<K,V> loHead = null, loTail = null;
                            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;
                                }
                                //原索引+oldCap
                                else {
                                    if (hiTail == null)
                                        hiHead = e;
                                    else
                                        hiTail.next = e;
                                    hiTail = e;
                                }
                            } while ((e = next) != null);
                            //原索引放到bucket里
                            if (loTail != null) {
                                loTail.next = null;
                                newTab[j] = loHead;
                            }
                            //原索引+oldCap放到bucket里。
                            if (hiTail != null) {
                                hiTail.next = null;
                                newTab[j + oldCap] = hiHead;
                            }
                        }
                    }
                }
            }
            return newTab;
        }
        
      2. 红黑树的优化还没说,那个太难了,暂时不讲。

2.8 HashMap并发安全的问题

  • 并发的多线程使用HashMap可能会造成死循环。代码例子如下(便于理解使用的是jdk7的环境)

    public class HashMapInfiniteLoop {
        private static HashMap<Integer,String> map = new HashMap<Integer,String>(20.75f);
        public static void main(String[] args) {
            map.put(5"C");
            new Thread("Thread1") {
                public void run() {
                    map.put(7, "B");
                    System.out.println(map);
                };
            }.start();
            new Thread("Thread2") {
                public void run() {
                    map.put(3, "A");
                    System.out.println(map);
                };
            }.start();
        }
    }
    
  • 其中map初始化为一个长度为2的数组,loadFactor=0.75,threshold=2*0.75=1;也就是说当put第二个key的时候,map就需要进行resize

  • 通过设置断点让线程1与线程2同时debug到transfer方法的首行。注意此时两个线程已经成功添加数据。放开thread1的断点至transfer方法的“Entry next=e.next”这一行;然后放开线程2的断点,让线程2进行resize,结果如下
    在这里插入图片描述

  • 注意,thread1的e指向了key(3),而next指向了key(7),其在线程2 rehash后,指向线程2重组后的链表。

  • 线程1被调度回来执行,先是执行newTable[i]=e,然后e=next,导致e指向了key(7),而下一次循环的next=e.next导致next指向了key(3)。
    在这里插入图片描述

  • e.next=newTable[i]导致key[3].next指向了key(7)。注意:此时key(7).next已经指向了key(3),环形链表就这样出现了。
    在这里插入图片描述

  • 于是,当我们用线程1调用map.get(11)时,就会出现无限循环。(jdk8解决了这个问题,但是还是有其他线程不安全的情况。

2.9 HashMap注意事项

  • 扩容是非常消耗性能的操作。所以能够预估到HashMap的大小的时候就给一个特定大小的初试值。避免hashmap频繁扩容。
  • 负载因子也可以大于1,但是不建议轻易更改
  • HashMap是线程不安全的。多线程并发环境下建议使用ConcurrentHashMap
  • JDK1.8引入红黑树很大程度上优化了HashMap的性能
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值