Java集合-1.HashMap,HashTable,ConcurrentHashMap,TreeMap,LinkHashMap,HashSet,TreeSet,LinkedHashSet

HashMap

基础结构:数组+链表

HashMap是一个支持键值对的数据结构,使用数组和链表实现。每个数组保存的是一个链表。
插入元素的步骤

  • 首先计算出key指的hashCode,找出元素应该落在数组的哪个位置上(或称为槽)。
  • 如果对应的槽没有元素,则添加到链表的第一个元素。
  • 如果对应的槽的链表已经有元素了,则往后添加到链表的头。
  • 在java1.8之后,如果链表的长度超过8,会进化为红黑树,优化查询速度。
    在这里插入图片描述

上面的步骤虽然看起来简单,但是如果看过源码,就会很困惑。

定位哈希槽
 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null) //注意这一行,tab是数组,而i=(n-1)&hash则是数组的定位
            tab[i] = newNode(hash, key, value, null);
        else {
            //省略其他代码。。。
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

定位槽的位置的代码是:tab[i = (n - 1) & hash],而不是想象中的取余算法,为什么(n-1)&hash就能够替代取余算法呢?
首先n是数组的长度,那么n-1是数组最大的下标,而且,数组的长度为要求为2的幂次方。
假设n为16,二进制表示为 10000
而15的二进制表示为:01111
因此,任意一个数与01111做与运算,那么最大值只能是01111,最小值只能是0000,因为0111的高位都是0,结果取决于hash的后四位的值。
所以(n - 1) & hash的范围位0~(n-1),n是不变的。似乎也类似于取余算法。
那么这么大费周章的原因是什么,原因是为了提高效率,因为取余的计算效率比较低。而这种位运算效率非常高。

扰动函数

上面提到,实际上一个元素对应的哈希槽位置,只取决于hash的后几位(取决于数组的大小)。那么hash是如何生成的呢?
下面是生成hash的源码(java1.8).

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

key.hashCode()返回的是对象的唯一标志(有说返回的是对象的内存地址,但是在OpenJDK中其实有5种实现方式,其中一种返回的地址,而Oracle JDK看不到源码,所以实际是返回的地址还是其他只能靠猜。
具体看大佬博客:Java的Object.hashCode()的返回值到底是不是对象内存地址?

然后是h>>16是干什么的,为什么要使用异或?
在上面,我们提到,元素哈希槽的位置只取决于hash的后几位,如果直接拿key.hashCode()的值,那么可能会发生严重的碰撞,比如说等差数列可能后几位都是相同的。把所有的元素都放在一个哈希槽里了。
那么,降低这个问题发生的方法就是让高16位和低16位做一个异或运算,就把高16位的特征混合到低16位中了。这样子就降低了发生冲突的概率了。所以实际上这段代码的作用就是把高16位和低16位做一个异或。
为什么是异或而不是其他的位运算呢?因为异或可以保留高16位不发生变化,0^a=a.

红黑树:java1.8的优化

虽然使用了各种方法解决碰撞问题,但是不可避免的还是会发生某些链表过长的问题。为了减少查询时间,java1.8对HashMap进行了优化,当链表长度大于8的时候,链表会进化成红黑树。

  • 为什么是8?
    因为经过统计,链表长度达到8的概率是比较小的,所以红黑树知识解决小概率长度超过8的链表的问题。
  • 红黑树是什么?
    红黑树是一颗自平衡的二叉树,通过在插入数据时,调整树的元素,来保证树时平衡的,而不会退化成链表。虽然这加快了查询速度,但是也降低了插入的速度,所以这也是为什么要长度大于8才开始转化为红黑树。
    在这里插入图片描述
    红黑树的五个性质
  • 节点要么是红节点,要么是黑节点
  • 根节点必须是黑节点
  • 叶子节点必须是黑色的(Null节点)
  • 红节点的子节点必须是黑色的
  • 任意节点到达叶子节点时,经过的黑色节点的个数必须是相等的。

红黑树的五个性质(或者说限制)使得从根节点出发到叶子节点的所有路径中,最长的路径最多只能是最短路径的两倍。最长路径是红黑相隔,而最短路径都是黑节点,所以是两倍。
当红黑树在插入或者删除后不满足以上的五个条件时,就会进行调整,直到符合条件。

扩容:1.7和1.8的区别(重新计算和清除原数组)

在java1.7版本中,扩容的逻辑很简单,就是创建一个新的数组,长度为原数组的两倍,然后遍历所有元素,重新计算hash,放到新的数组中。
这导致两个问题。

  • 重新计算hash效率低
  • 如果是并发操作扩容,可能会导致形成循环链表。(不过并发情况下,不能使用HashMap)

java1.8则对这两个问题进行了修复

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        //省略中间的代码。。。
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; //创建新的数组
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) { //这里判断旧数组元素是否已经被操作过
                    oldTab[j] = null;//设置旧数组元素为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 {
                        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) {//如果等于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;//使用原数组长度oldCap+j(原位置)替代重新计算hash
                        }
                    }
                }
            }
        }
        return newTab;
    }

其中以下这行代码防止已经被操作过的数组元素被重复处理。不过实际上高并发下,还是有可能多个线程进入到条件判断里。

if ((e = oldTab[j]) != null) { //这里判断旧数组元素是否已经被操作过
    oldTab[j] = null;//设置旧数组元素为null,防止重复操作
    //...省略
}

再看这一句,观察这句代码下的其他代码,会发现符合条件的元素,哈希槽的位置不发生变化,直接复制到新的数组的对应的位置中,这是为什么?

 if ((e.hash & oldCap) == 0) {//oldCap是原数组的长度,如果等于0,则哈希槽位置不变化

首先,数组的长度再HashMap中被限制为2的幂次方,而且每次增长都是乘以2.所以每次增长都是相当于1左移一位

16:010000
32:100000

而且上文的描述中,最终哈希槽的位置只取决于hash的后几位,(n-1)&hash

增长前:16-1=15:001111,只考虑低4位。
增长后:32-1=31:011111,只考虑低5位,多考虑了一位

由于每个元素的hash并不会因为扩容而发生变化,所以hash是不会变的。所以只要考虑多出来的这一位是否为1就好了,因为如果为0的话,说明(n-1)&hash增长前和增长后计算结果都是一样的。所以使用oldCap和hash做与运算,只是为了判断新增的一位是否为1.

然后看这一行代码,这段代码的前提条件是多出来的一位是1,需要重新计算哈希槽的位置,这里使用 j + oldCap直接计算出新的索引位置。而不是重新计算,这是为什么?

newTab[j + oldCap] = hiHead;//使用 oldCap(原数组长度)+j(原位置)替代重新计算hash

同样的,最终哈希槽的位置只取决于hash的后几位,进入这个条件的下的hash,新增的需要考虑的一位为1
举个例子

31&52
      31:011111
      52:110100
31&52:010100

      15:001111
      52:110100
15&52:000100

31&52 = 000100 + 010000= 15&52 + 16

通过上面的例子,计算发现低四位的值是相等的,而因为新增的一位是1(刚好等于原数组长度),所以新的位置等于原位置+原数组长度。这样计算的性能,当然是比重新计算(n-1)&hash高。

可以看出,为了提高性能,真是绞劲脑汁啊!

多线程下死循环问题和元素丢失问题

在上文一再强调,HashMap不能用于多线程。多线程下,不仅可能会造成循环链导致循环问题,还会造成元素丢失。java1.8修复了造成循环链的问题,但是没有修复元素丢失的问题。

  • java1.7 HashMap 死循环
    先来看看java1.7多线程环境下HashMap为什么会造成死循环问题。
    两个线程A和B,分别对map进行resize
    在这里插入图片描述

A进行resize,但在e指向3,next指向7时,线程让出了cpu。状况如下。
在这里插入图片描述
此时B运行,直接resize成新的map,如下
在这里插入图片描述

这个时候又切换到A线程执行。注意,这个时候,恢复了A线程的变量,e仍然指向3,next指向7.首先会把3放置到新的位置。
在这里插入图片描述
然后处理完3之后,继续迭代,next指向7,所以下一个元素就是7,接下来就把把7放置再3所在的链表头。如下
在这里插入图片描述
注意,这个时候,由于B线程的操作,7的next是指向3的,而正常的情况下,看原始的map,7的next应该指向5.所以这个时候,又会去访问元素3,并把它放在链表头,于是,循环就形成了。
在这里插入图片描述

  • java1.8 HashMap 死循环修复
    java1.7中的HashMap中导致死循环的原因是,链表会被导致,原始链表是 3->7->5,而新的链表是7->3,元素5被移动到其他槽位暂时不管,但是注意到,3和7的顺序被调换了,原因在于新元素插入链表时,是插入到链表头,而遍历的时候,是从链表头开始遍历的,这就导致了一定情况下,会发生循环的原因。而java1.8中,新插入的元素是放在链表尾部,这与遍历链表的顺序一致,也就不会发生循环了。你可以试下,如果链表不会发生倒置,看看上面的例子还会不会产生循环链。
    在这里插入图片描述

  • 元素丢失问题
    无论是java1.7还是java1.8,都会有多线程元素丢失的问题。
    还是一样的两个线程,A和B。
    A线程一样运行到e指向7,而next指向5的时候,就交出CPU
    在这里插入图片描述
    而B线程也是把map resize完成。
    在这里插入图片描述

这里是java1.7的情况,如果是java1.8,最后的链表应该是:7->3.
不过这并不影响元素丢失,原因在于,由于B线程的操作,元素5的next已经为空了,所以对于线程A,他遇到的链表可以看作为只有7->5,元素3已经丢失了,所以会被resize为以下结果。
在这里插入图片描述

HashTable 和 ConcurrentHashMap

上文一直在强调,HashMap不适用于多线程环境,而使用于多线程的有HashTable和ConcurrentHashMap两种数据结构。

  • 遗留类:HashTable
    HashTable的一个特点是,几乎所有的方法都被synchronized修饰,这能够达到多线程安全使用的效果,但是性能太差了,于是被官方遗弃了。
    另外一些区别于HashMap的点是:

    • HashTable的key和value都不允许是null值,而HashMap可以。
    • HashTable没有红黑树
    • HashTable,也没有各种性能优化,比如定位哈希槽直接用取余算法
  • ConcurrentHashMap
    ConcurrentHashMap是现在官方推荐的替代HashMap的数据结构。他的特点是针对哈希槽位加锁,而不是针对方法加锁。这使得速度要比HashTable快上许多。不过再java1.7使用的分段锁,而在java1.8使用针对每一个哈希槽位的锁,如下图
    java1.7的实现,分段加锁。
    在这里插入图片描述

java1.8的实现,针对每个槽位加锁。另外和Hash Map一样也实现了红黑树。
在这里插入图片描述

TreeMap

HashMap的一个缺点是不能排序,而TreeMap则是提供了根据Key值进行排序排序功能。当遍历一个TreeMap时,会按照key值的顺序访问。

public static void main(String args[]){  
   TreeMap<Integer,String> map=new TreeMap<Integer,String>();    
      map.put(100,"Amit");    
      map.put(102,"Ravi");    
      map.put(101,"Vijay");    
      map.put(103,"Rahul");    
        
      for(Map.Entry m:map.entrySet()){    
         System.out.println(m.getKey()+" "+m.getValue());    
      }    
 }  

打印结果:

100 Amit
101 Vijay
102 Ravi
103 Rahul

TreeMap时如何保证他的顺序的呢?实际上利用的时红黑树。和Hash Map的区别在于,TreeMap并没有使用数组加链表再加红黑树的结构,而是直接使用一整颗红黑树。红黑树实际上是一颗二叉搜索树,也就是左节点<根节点<右节点,如果对红黑树进行中序遍历,那么结果天生就是排序的。

在这里插入图片描述
对这棵搜索二叉树进行中序遍历,结果是:2,3,4,8,9,9,10,13,15,18。

当然,由于使用了红黑树,其查询的时间复制度就是O(logN)了,相比于HashMap的O(1)时间复杂度,当然要慢上不少。

LinkedHashMap

HashMap的另外一个缺点是,并不保存插入的顺序,而 LinkedHashMap 则是在HashMap的基础上,增加了记录插入顺序的功能,LinkedHashMap是HashMap的子类,LinkedHashMap增加了一个链表来保存插入顺序。

HashSet,TreeSet,LinkedHashSet

集合的特点是不能保存重复的值,基本上都是使用对应的Map类来实现,如HashSet使用HashMap类实现。
区别在于,put的时候,会检查hashCode,如果HashCode对应的链表位空,则直接插入,如果对应的链表不为空,则需要使用equals比较链表或者红黑树中的每个元素是否相等。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值