zoukankan      html  css  js  c++  java
  • 9、Hashtable、HashMap、TreeMap

    Hashtable :不支持 null 键和值,线程安全

    HashMap :支持 null 键和值,线程不安全。put 或者 get 操作,可以达到常数时间的性能

    TreeMap :基于红黑树的一种提供顺序访问的 Map,具体顺序可以由指定的 Comparator 来决定,或者根据键的自然顺序来判断。get、put、remove 之类操作都是 O(log(n))的时间复杂度

    Map 虽然通常被包括在 Java 集合框架里,但是其本身并不是狭义上的集合类型(Collection):

    HashMap 等其他 Map 实现则是都扩展了 AbstractMap,里面包含了通用方法抽象。

    HashMap 的性能表现非常依赖于哈希码的有效性,注意 hashCode 和 equals 的一些基本约定,比如:

    • equals 相等,hashCode 一定要相等。

    • 重写了 hashCode 也要重写 equals。

    • hashCode 需要保持一致性,状态改变返回的哈希值仍然要一致。

    • equals 的对称、反射、传递等特性。

     LinkedHashMap TreeMap 都可以保证某种顺序,但二者还是非常不同的。LinkedHashMap 通常提供的是遍历顺序符合插入顺序,它的实现是通过为条目(键值对)维护一个双向链表。注意,通过特定构造函数,我们可以创建反映访问顺序的实例,所谓的 put、get、compute 等,都算作“访问”。LinkedHashMap 可以实现LRU功能。例如,我们构建一个空间占用敏感的资源池,希望可以自动将最不常被访问的对象释放掉,这就可以利用 LinkedHashMap 提供的机制来实现。 TreeMap,它的整体顺序是由键的顺序关系决定的,通过 Comparator 或 Comparable(自然顺序)来决定。

    构建一个具有优先级的调度系统的问题,其本质就是个典型的优先队列场景,Java 标准库提供了基于二叉堆实现的 PriorityQueue,它们都是依赖于同一种排序机制,当然也包括 TreeMap 的马甲 TreeSet(TreeSet是基于TreeMap的)。 TreeMap 的 put 方法实现

    public V put(K key, V value) {
        Entry<K,V> t = …
        cmp = k.compareTo(t.key);
        if (cmp < 0)
            t = t.left;
        else if (cmp > 0)
            t = t.right;
        else  // cmp = 0 ,认为插入的key和TreeMap中的key相同,则覆盖value值
            return t.setValue(value);
            // ...
       }

    HashMap 结构:

    HashMap中的变量:

    • Node<K,V>:链表节点,包含了key、value、hash、next指针四个元素
    • table:Node<K,V>类型的数组,里面的元素是链表,用于存放HashMap元素的实体,数组的容量大小总是2的倍数
    • size:记录了放入HashMap的元素个数
    • loadFactor:负载因子
    • threshold:阈值,决定了HashMap何时扩容,以及扩容后的大小,一般等于table大小乘以loadFactor

    数组(Node<k,v>[] table)和链表结合组成的复合结构,数组被分为一个个桶(bucket),通过哈希值决定了键值对在这个数组的寻址;哈希值相同的键值对,则以链表形式存储。这里需要注意的是,如果链表大小超过阈值(TREEIFY_THRESHOLD, 8),图中的链表就会被改造为树形结构(单个链表长度超过8时会树化)

     HashMap 也许是按照 lazy-load(懒加载) 原则构造完HashMap对象后,只要不进行put 方法插入元素之前,HashMap并不会去初始化或者扩容table,在首次使用时被初始化。

     HashMap的putVal方法(putVal 方法本身逻辑非常集中,从初始化、扩容到树化,全部都和它有关):

     
    final V putVal(int hash, K key, V value, boolean onlyIfAbent,
                       boolean evit) {
            Node<K,V>[] tab; Node<K,V> p; int , i;
            if ((tab = table) == null || (n = tab.length) = 0) // 数组为空则初始化数组
                n = (tab = resize()).legth; // 创建初始存储表格
            if ((p = tab[i = (n - 1) & hash]) == null)   // 具体键值对在哈希表中的位置(数组 index)i = (n - 1) & hash,p为链表头结点
                tab[i] = newNode(hash, key, value, null); // 该链表还为空,new一个头结点放入数组中
            else { // 在链表中插入该节点
            // ...
                if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for first   binCount为这个链表的长度,TREEIFY_THRESHOLD=8
    
                    treeifyBin(tab, hash);  // 链表结构(这里叫 bin),达到门限值是发生树化
            // ...
            }
            // ...
            if (++size > threshold)
                resize(); // 放置新的键值对的过程中,HashMap中键值对的总数量超过阀值(默认为12=16*0.75),就会发生扩容。
        }

    如果表格是 null,resize 方法会负责初始化它,这从 tab = resize() 可以看出。

    resize 方法兼顾两个职责,创建初始存储表格,或者在容量不满足需求的时候,进行扩容(resize)。

     计算hash值的方法:

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

    为什么这里需要将高位数据移位到低位进行异或运算呢?这是因为有些数据计算出的哈希值差异主要在高位,而 HashMap 里的哈希寻址是忽略容量以上的高位的,那么这种处理就可以有效避免类似情况下的哈希碰撞。

     resize()方法:

    final Node<K,V>[] resize() {
        // ...
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACIY && // 数组容量扩大2倍,桶增加
                    oldCap >= DEFAULT_INITIAL_CAPAITY)
            newThr = oldThr << 1; // double there 门限值扩大2倍
           // ... 
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {  
            // zero initial threshold signifies using defaultsfults
            newCap = DEFAULT_INITIAL_CAPAITY; // 默认为16,数组大小,桶的数量
            newThr = (int)(DEFAULT_LOAD_ATOR* DEFAULT_INITIAL_CAPACITY;// 门限阀值=负载因子(默认0.75)*默认容量(16)
        }
        if (newThr ==0) {
            float ft = (float)newCap * loadFator;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);
        }
        threshold = neThr;
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newap]; // 数组大小为16
        table = n;
        // 移动到新的数组结构 e 数组结构 
       }

    不考虑极端情况(容量理论最大极限由 MAXIMUM_CAPACITY 指定,数值为 1<<30,也就是 2 的 30 次方),我们可以归纳为:

    • 门限值等于(负载因子,默认0.75)x(容量,默认16(数组大小)),如果构建 HashMap 的时候没有指定它们,那么就是依据相应的默认常量值。

    • 门限通常是以倍数进行调整 (newThr = oldThr << 1),我前面提到,根据 putVal 中的逻辑,当键值对数量超过门限(默认为12)大小时,则调整 Map 大小。

    • 扩容后,需要将老的数组中的元素重新放置到新的数组,这是扩容的一个主要开销来源。

    容量负载因子决定了可用的桶的数量,空桶太多会浪费空间,如果使用的太满则会严重影响操作的性能。极端情况下,假设只有一个桶,那么它就退化成了链表,完全不能提供所谓常数时间存的性能。

    如果能够知道 HashMap 要存取的键值对数量,可以考虑预先设置合适的容量大小。具体数值我们可以根据扩容发生的条件来做简单预估,根据前面的代码分析,我们知道它需要符合计算条件:负载因子 * 容量 > 元素数量(预估键值对数量)

    所以,预先设置的容量需要满足,大于“预估元素数量 / 负载因子”,同时它是 2 的幂数。

    而对于负载因子

    • 如果没有特别需求,不要轻易进行更改,因为 JDK 自身的默认负载因子是非常符合通用场景的需求的。

    • 如果确实需要调整,建议不要设置超过 0.75 的数值,因为会显著增加冲突,降低 HashMap 的性能。

    • 如果使用太小的负载因子,按照上面的公式,预设容量值也进行调整,否则可能会导致更加频繁的扩容,增加无谓的开销,本身访问性能也会受影响。

    树化:

    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)// 数组大小(默认16) < MIN_TREEIFY_CAPACITY=64
        resize(); // 扩容
      else if ((e = tab[index = (n - 1) & hash]) != null) {
        
    // 树化改造逻辑
      }
    }

    当 bin(单个链表长度) 的数量 > TREEIFY_THRESHOLD(8) 时:

    • 如果容量(数组大小,默认16)小于 MIN_TREEIFY_CAPACITY(64),只会进行简单的扩容

    • 如果容量(数组大小)大于 MIN_TREEIFY_CAPACITY ,则会进行树化改造。

    也就是说,当单链表长度>8时,且此时数组大小 < 64时,则不会树化,而是简单的扩容。

               但如果单链表长度>8时,且此时数组大小>=64时,才会真正发生树化改造。

    树化原因:

    安全问题因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表,我们知道链表查询是线性的,会严重影响存取的性能。而在现实世界,构造哈希冲突的数据并不是非常复杂的事情,恶意代码就可以利用这些数据大量与服务器端交互,导致服务器端 CPU 大量占用,这就构成了哈希碰撞拒绝服务攻击。

    JDK1.8后,HashMap新增树形化相关内容:http://blog.163.com/he_04143164/blog/static/27150310320173293218939/

    HashMap的扩容及树化过程:https://www.aliyun.com/jiaocheng/534000.html    (详细)

    HashMap:https://www.e-learn.cn/content/qita/574596

    HashMap在JDK1.8及以后的版本中引入了红黑树结构,若桶中链表元素个数大于等于8时,链表转换成树结构;若桶中链表元素个数小于等于6时,树结构还原成链表。因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。

    还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

     解决哈希冲突的常用方法有:

    开放地址法(即线性探测法):当关键字key的哈希地址p=H(key)出现冲突时,以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,

                   再以p为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。

    再哈希法:同时构造多个不同的哈希函数

    链地址法:将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插

         入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。

    建立公共溢出区:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。

  • 相关阅读:
    Android UI性能优化详解
    Android开发中一些被冷落但却很有用的类和方法
    Android开发:Translucent System Bar 的最佳实践
    让动画不再僵硬:Facebook Rebound Android动画库介绍
    Android Touch事件传递机制
    转载爱哥自定义View系列--Canvas详解
    Android最佳实践指南
    Android网络请求心路历程
    转载爱哥自定义View系列--文字详解
    ORM是什么?
  • 原文地址:https://www.cnblogs.com/xuan5301215/p/9099686.html
Copyright © 2011-2022 走看看