HashMap,HashTable,TreeMap,HashSet,TreeSet

注意:最好先看一下(三)中 树红黑树的数据结构分析,可以的话数组,链表的数据结构也先复习一下,这里默认你懂数组,链表

2.2 map

Map 是一种把键对象和值对象映射的集合,它的每一个元素都包含一对键对象和值对象。 Map没有继承于Collection接口 从Map集合中检索元素时,只要给出键对象,就会返回对应的值对象。

2.2.1 map常用元素

1 添加,删除操作:

   Object put(Object key, Object value): 向集合中加入元素

   Object remove(Object key): 删除与KEY相关的元素

   void putAll(Map t):  将来自特定映像的所有元素添加给该映像

   void clear():从映像中删除所有映射

2 查询操作:

Object get(Object key):获得与关键字key相关的值 。Map集合中的键对象不允许重复,也就说,任意两个键对象通过equals()方法比较的结果都是false.,但是可以将任意多个键独享映射到同一个值对象上。

2.2.2 map功能方法

HashMap实现了Map接口,Map接口对键值对进行映射。Map中不允许重复的键。Map接口有两个基本的实现,HashMap和TreeMap。TreeMap保存了对象的排列次序,而HashMap则不能。HashMap允许键和值为null。HashMap是非synchronized的,但collection框架提供方法能保证HashMap synchronized,这样多个线程同时访问HashMap时,能保证只有一个线程更改Map。

HashMap:Map基于散列表的实现。插入和查询“键值对”的开销是固定的。可以通过构造器设置容量capacity和负载因子load factor,以调整容器的性能。

LinkedHashMap: 类似于HashMap,但是迭代遍历它时,取得“键值对”的顺序是其插入次序,或者是最近最少使用(LRU)的次序。只比HashMap慢一点。而在迭代访问时发而更快,因为它使用链表维护内部次序。

TreeMap :基于红黑树数据结构的实现。查看“键”或“键值对”时,它们会被排序(次序由Comparabel或Comparator决定)。TreeMap的特点在于,你得到的结果是经过排序的。TreeMap是唯一的带有subMap()方法的Map,它可以返回一个子树。

2.2.3 map工作原理

2.2.3.1 HashMap工作原理

HashSet 和 HashMap 之间有很多相似之处,对于 HashSet 而言,系统采用 Hash 算法决定集合元素的存储位置,这样可以保证能快速存、取集合元素;对于 HashMap 而言,系统 key-value 当成一个整体进行处理,系统总是根据 Hash 算法来计算 key-value 的存储位置,这样可以保证能快速存、取 Map 的 key-value 对。

在介绍集合存储之前需要指出一点:虽然集合号称存储的是 Java 对象,但实际上并不会真正将 Java 对象放入 Set 集合中,只是在 Set 集合中保留这些对象的引用而言。也就是说:Java 集合实际上是多个引用变量所组成的集合,这些引用变量指向实际的 Java 对象。就像引用类型的数组一样,当我们把 Java 对象放入数组之时,并不是真正的把 Java 对象放入数组中,只是把对象的引用放入数组中,每个数组元素都是一个引用变量。

HashMap存储的实现(put()方法)

当程序试图将多个key-value放入HashMap中是,以如下代码片段为例:

    HashMap<String , Double> map = new HashMap<String , Double>();

    map.put("语文" , 80.0);

    map.put("数学" , 89.0);

map.put("英语" , 78.2);

HashMap采用了 “Hash算法”来决定每个元素的存储位置。简单描述一下”Hash算法”,当程序执行map.put("语文",80.0)时,系统将调用"语文"(即Key)的hashCode()方法得到其hashCode值---每个java对象都有hashCode()方法,都可以通过该方法获得它的hashCode值。得到这个对象的hashCode值之后,系统根据hashCode值来决定 该元素的存储位置。当然实际的hash过程可能比这个复杂,包括解决hash冲突,解决hash扩容的问题。

我们可以看HashMap类的put(K key,V value)方法的源代码:
    

public V put(K key, V value)

    {

             // 如果 key 为 null,调用 putForNullKey 方法进行处理
             if (key == null)
                       return putForNullKey(value)
             // 根据 key 的 keyCode 计算 Hash 值
             int hash = hash(key.hashCode());
             // 搜索指定 hash 值在对应 table 中的索引
            int i = indexFor(hash, table.length);
             // 如果 i 索引处的 Entry 不为 null,通过循环不断遍历 e 元素的下一个元素
             for (Entry<K,V> e = table[i]; e != null; e = e.next)
             {
                       Object k;
                       // 找到指定 key 与需要放入的 key 相等(hash 值相同
                       // 通过 equals 比较放回 true)
                       if (e.hash == hash && ((k = e.key) == key
                                || key.equals(k))) //已经存在了

                       {
                                V oldValue = e.value;
                                e.value = value;
                                e.recordAccess(this);
                                reurn oldValue;

                       }

             }
             // 如果 i 索引处的 Entry 为 null,表明此处还没有 Entry
             modCount++;
             // 将 key、value 添加到 i 索引处
             addEntry(hash, key, value, i); //新添加的entry加在链表的头部
             return null;

    }

上面程序中用到了一个重要的内部接口:Map.Entry,每个 Map.Entry 其实就是一个 key-value 对。从上面程序中可以看出:当系统决定存储 HashMap 中的 key-value 对时,完全没有考虑 Entry 中的 value,仅仅只是根据 key 来计算并决定每个 Entry 的存储位置。这也说明了前面的结论:我们完全可以把 Map 集合中的 value 当成 key 的附属,当系统决定了 key 的存储位置之后,value 随之保存在那里即可。

上面方法提供了一个根据 hashCode() 返回值来计算 Hash 码的方法:hash(),这个方法是一个纯粹的数学计算,其方法如下:
 

static int hash(int h)

{
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

对于任意给定的对象,只要它的 hashCode() 返回值相同,那么程序调用 hash(int h) 方法所计算得到的 Hash 码值总是相同的。接下来程序会调用 indexFor(int h, int length) 方法来计算该对象应该保存在 table 数组的哪个索引处。indexFor(int h, int length) 方法的代码如下:


static int indexFor(int h, int length)

{
    return h & (length-1);
}

这个方法非常巧妙,它总是通过 h &(table.length -1) 来得到该对象的保存位置——而 HashMap 底层数组的长度总是 2 的 n 次方.

     1. 当 length 总是 2 的倍数时,h & (length-1)将是一个非常巧妙的设计:假设 h=5,length=16, 那么 h & length - 1 将得到 5;如果 h=6,length=16, 那么 h & length - 1 将得到 6 ……如果 h=15,length=16, 那么 h & length - 1 将得到 15;但是当 h=16 时 , length=16 时,那么 h & length - 1 将得到 0 了;当 h=17 时 , length=16 时,那么 h & length - 1 将得到 1 了……这样类似于对hashcode按表取模以后,计算得到的索引值总是位于 table 数组的索引之内。

     2. 并且 2^n-1 --> 111111 & c=> c 可以相对散列均匀分布在链表上。

根据上面 put 方法的源代码可以看出,当程序试图将一个 key-value 对放入 HashMap 中时,程序首先根据该 key 的 hashCode() 返回值决定该 Entry 的存储位置:如果两个 Entry 的 key 的 hashCode() 返回值相同,那它们的存储位置相同。如果这两个 Entry 的 key 通过 equals 比较返回 true,新添加 Entry 的 value 将覆盖集合中原有 Entry 的 value,但 key 不会覆盖。如果这两个 Entry 的 key 通过 equals 比较返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部

addEntry(hash, key, value, i); 代码,其中 addEntry 是 HashMap 提供的一个包访问权限的方法,该方法仅用于添加一个 key-value 对。下面是该方法的代码:
 

void addEntry(int hash, K key, V value, int bucketIndex)

    // 头插法
    // 获取指定 bucketIndex 索引处的 Entry
    Entry<K,V> e = table[bucketIndex];         // ①
    // 将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e); //把e接在后面
    // 如果 Map 中的 key-value 对的数量超过了极限
    if (size++ >= threshold)
        // 把 table 对象的长度扩充到 2 倍
        resize(2 * table.length);           // ② 这里是数组扩容 不是链表扩容
}

上面方法的代码包含了一个非常优雅的设计:系统总是将新添加的 Entry 对象放入 table 数组的 bucketIndex 索引处——如果 bucketIndex 索引处已经有了一个 Entry 对象,那新添加的 Entry 对象指向原有的 Entry 对象(产生一个 Entry 链),如果 bucketIndex 索引处没有 Entry 对象,也就是上面程序①号代码的 e 变量是 null,也就是新放入的 Entry 对象指向 null,也就是没有产生 Entry 链。

Hash 算法的性能选项

根据上面代码可以看出,在同一个 bucket 存储 Entry 链的情况下,新放入的 Entry 总是位于 bucket 中,而最早放入该 bucket 中的 Entry 则位于这个 Entry 链的最末端。

上面程序中还有这样两个变量:

    size:该变量保存了该 HashMap 中所包含的 key-value 对的数量。

    threshold:该变量包含了 HashMap 能容纳的 key-value 对的极限,它的值等于 HashMap 的容量乘以负载因子(load factor)。

从上面程序中②号代码可以看出,当 size++ >= threshold 时,HashMap 会自动调用 resize 方法扩充 HashMap 的容量。每扩充一次,HashMap 的容量就增大一倍。

上面程序中使用的 table 其实就是一个普通数组,每个数组都有一个固定的长度,这个数组的长度就是 HashMap 的容量。HashMap 包含如下几个构造器:

    HashMap():构建一个初始容量为 16,负载因子为 0.75 的 HashMap。

    HashMap(int initialCapacity):构建一个初始容量为 initialCapacity,负载因子为 0.75 的 HashMap。

    HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的负载因子创建一个 HashMap。

当创建一个 HashMap 时,系统会自动创建一个 table 数组来保存 HashMap 中的 Entry,下面是 HashMap 中一个构造器的代码:
 

// 以指定初始化容量、负载因子创建 HashMap

 public HashMap(int initialCapacity, float loadFactor)

 {
          // 初始容量不能为负数
          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(
                    loadFactor);
          // 计算出大于 initialCapacity 的最小的 2 的 n 次方值。
          int capacity = 1;
          while (capacity < initialCapacity)
                    capacity <<= 1;
          this.loadFactor = loadFactor;
          // 设置容量极限等于容量 * 负载因子
          threshold = (int)(capacity * loadFactor);
          // 初始化 table 数组
          table = new Entry[capacity];                             // ①
          init();
 }

上面一个简洁的代码实现:找出大于 initialCapacity 的、最小的 2 的 n 次方值,并将其作为 HashMap 的实际容量(由 capacity 变量保存)。例如给定 initialCapacity 为 10,那么该 HashMap 的实际容量就是 16。

initialCapacity 与 HashTable 的容量

    创建 HashMap 时指定的 initialCapacity 并不等于 HashMap 的实际容量,通常来说,HashMap 的实际容量总比 initialCapacity 大一些,除非我们指定的 initialCapacity 参数值恰好是 2 的 n 次方。当然,掌握了 HashMap 容量分配的知识之后,应该在创建 HashMap 时将 initialCapacity 参数值指定为 2 的 n 次方,这样可以减少系统的计算开销。

程序①号代码处可以看到:table 的实质就是一个数组,一个长度为 capacity 的数组。

对于 HashMap 及其子类而言,它们采用 Hash 算法来决定集合中元素的存储位置。当系统开始初始化 HashMap 时,系统会创建一个长度为 capacity 的 Entry 数组,这个数组里可以存储元素的位置被称为“桶(bucket)”,每个 bucket 都有其指定索引,系统可以根据其索引快速访问该 bucket 里存储的元素。

无论何时,HashMap 的每个“桶”只存储一个元素(也就是一个 Entry),由于 Entry 对象可以包含一个引用变量(就是 Entry 构造器的的最后一个参数)用于指向下一个 Entry,因此可能出现的情况是:HashMap 的 bucket 中只有一个 Entry,但这个 Entry 指向另一个 Entry ——这就形成了一个 Entry 链。如图 1 所示:

图 1. HashMap 的存储示意

key相同的则产生链。

HashMap 的读取实现

当 HashMap 的每个 bucket 里存储的 Entry 只是单个 Entry ——也就是没有通过指针产生 Entry 链时,此时的 HashMap 具有最好的性能:当程序通过 key 取出对应 value 时,系统只要先计算出该 key 的 hashCode() 返回值,在根据该 hashCode 返回值找出该 key 在 table 数组中的索引,然后取出该索引处的 Entry,最后返回该 key 对应的 value 即可。看 HashMap 类的 get(K key) 方法代码: 

public V get(Object key)

 {
          // 如果 key 是 null,调用 getForNullKey 取出对应的 value
          if (key == null)
                    return getForNullKey();
          // 根据该 key 的 hashCode 值计算它的 hash 码
          int hash = hash(key.hashCode());
          // 直接取出 table 数组中指定索引处的值,
          for (Entry<K,V> e = table[indexFor(hash, table.length)];
                    e != null;
                    // 搜索该 Entry 链的下一个 Entry
                    e = e.next)               // ①
          {
                    Object k;
                    // 如果该 Entry 的 key 与被搜索 key 相同
                    if (e.hash == hash && ((k = e.key) == key
                             || key.equals(k)))
                             return e.value;

          }
          return null;

 }

从上面代码中可以看出,如果 HashMap 的每个 bucket 里只有一个 Entry 时,HashMap 可以根据索引、快速地取出该 bucket 里的 Entry;在发生“Hash 冲突”的情况下,单个 bucket 里存储的不是一个 Entry,而是一个 Entry 链,系统只能必须按顺序遍历每个 Entry,直到找到想搜索的 Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),那系统必须循环到最后才能找到该元素。

归纳起来简单地说,HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据 Hash 算法来决定其存储位置;当需要取出一个 Entry 时,也会根据 Hash 算法找到其存储位置,直接取出该 Entry。由此可见:HashMap 之所以能快速存、取它所包含的 Entry,完全类似于现实生活中母亲从小教我们的:不同的东西要放在不同的位置,需要时才能快速找到它。

当创建 HashMap 时,有一个默认的负载因子(load factor),其默认值为 0.75,这是时间和空间成本上一种折衷:

       增大负载因子可以减少 Hash 表所占用的内存空间,但会增加查询数据的时间开销,而查询是最频繁的的操作(HashMap 的 get() 与 put() 方法都要用到查询);

       减小负载因子会提高数据查询的性能,但会增加 Hash 表所占用的内存空间。增大负载因子,减小了hash值的范围,扩大了entry链,增大了循环查询,增加了查询性能的消耗。

       减小了负载因子,增大了的范围,消耗了更多的内存空间,换来了查询性能的改变。

掌握了上面知识之后,我们可以在创建 HashMap 时根据实际需要适当地调整 loadfactor(扩容因子) 的值;如果程序比较关心空间开销、内存比较紧张,可以适当地增加负载因子;如果程序比较关心时间开销,内存比较宽裕则可以适当的减少负载因子。通常情况下,程序员无需改变负载因子的值。

如果开始就知道 HashMap 会保存多个 key-value 对,可以在创建时就使用较大的初始化容量,如果 HashMap 中 Entry 的数量一直不会超过极限容量(capacity * load factor),HashMap 就无需调用 resize() 方法重新分配 table 数组,从而保证较好的性能。当然,开始就将初始容量设置太高可能会浪费空间(系统需要创建一个长度为 capacity 的 Entry 数组),因此创建 HashMap 时初始化容量设置也需要小心对待。

Jdk1.7 扩容机制

扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。

我们分析下resize的源码,鉴于JDK1.8融入了红黑树,较复杂,为了便于理解我们仍然使用JDK1.7的代码,好理解一些,本质上区别不大,具体区别后文再说。

 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);//修改阈值
}

      这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。

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);
        }
    }
}

newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话),这一点和Jdk1.8有区别,下文详解。在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

下面举个例子说明下扩容过程。假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。


 

Jdk1.8以后 HashMap

 

1.8 以后的源码

 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
        Node<org.checkerframework.checker.units.qual.K,V>[] tab; Node<K,V> p; int n, i;

            //首先是判断是否是空的tab 如果是的话就分配空间
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

            //分配完空间之后如果对应索引上没有值,初始话链表的表头
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash && //如果链表不为空,且正好是hash值不管是什么结构直赋值
                    ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)//如果是红黑树那么添加一个节点
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {//如果是链表 遍历找对应key的节点就赋值退出,如果没有找到就在最后插入。
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) //如果个数大于8个就变形成红黑树
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;//这里配合 e=p->next 达到节点切换遍历的目的
                }
            }
            if (e != null) { // 如果被赋过值,证明是已存在的节点,那么直接改变值就可以了
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
        }

那么为什么要把链表变成红黑树呢?

因为Map中桶的元素初始化是链表保存的,其查找性能是O(n),而树结构能将查找性能提升到O(log(n))。当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。至于为什么阈值是8,我想,去源码中找寻答案应该是最可靠的途径。

那么为什么要在链表长度达到8的时候变成红黑树呢?

TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转成TreeNodes,而是否足够多就是由TREEIFY_THRESHOLD的值决定的。当bin中节点数变少时,又会转成普通的bin。并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成普通bin。这样就解析了为什么不是一开始就将其转换为TreeNodes,而是需要一定节点数才转为TreeNodes,说白了就是trade-off,空间和时间的权衡:

Because TreeNodes are about twice the size of regular nodes, we
use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due to
removal or resizing) they are converted back to plain bins.  In
usages with well-distributed user hashCodes, tree bins are
rarely used.  Ideally, under random hashCodes, the frequency of
nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because of
resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5)*pow(0.5, k)/factorial(k)).
The first values are:
0:    0.60653066
1:    0.30326533
2:    0.07581633
3:    0.01263606
4:    0.00157952
5:    0.00015795
6:    0.00001316
7:    0.00000094
8:    0.00000006
more: less than 1 in ten million

这段内容还说到:当hashCode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好hash算法,因此就可能导致不均匀的数据分布。不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。所以,之所以选择8,是根据概率统计决定的。

泊松分布?

 公司楼下的全家超市

        每天早上六点到十点营业,生意挺好,就是发愁一个事情,应该准备多少个包子

才能既不浪费又能充分供应?老板统计了一周每日卖出的馒头(为了方便计算和讲解,

缩小了数据)以1000/1的比例进行讲解:

均值 avg=3+7+4+6+5/5=5

   按道理讲均值是不错的选择,但是如果每天准备5个馒头的话,从统计表来看,至少有两天不够卖,40% 的时间不够卖:

搞什么饥饿营销啊?老板当然也知道这一点,就拿起纸笔来开始思考。

2. 老板的思考

  老板尝试把营业时间抽象为一根线段,把这段时间用T来表示:

然后把周一的三个馒头)按照销售时间放在线段上

T 均分为四个时间段:

  此时,在每一个时间段上,要不卖出了(一个)馒头,要不没有卖出:在每个时间段,就有点像抛硬币,要不是正面(卖出),要不是反面(没有卖出)T内卖出3个馒头的概率,就和抛了4次硬币(4个时间段),其中3次正面(卖出3个)的概率一样了。这样的概率通过二项分布来计算就是:

但是,如果把周二的七个馒头放在线段上,分成四段就不够了:

  从图中看,每个时间段,有卖出3个的,有卖出2个的,有卖出1个的,就不再是单纯的“卖出、没卖出”了。不能套用二项分布了。解决这个问题也很简单,把 T 分为20个时间段,那么每个时间段就又变为了抛硬币:

这样,T内卖出7个馒头的概率就是(相当于抛了20次硬币,出现7次正面)

3.p的计算

  “那么”,老板用笔敲了敲桌子,“只剩下一个问题,概率 p 怎么求?”
  在上面的假设下,问题已经被转为了二项分布。二项分布的期望为:

对上面的极限公式进行求极限运算

如上所示得到泊松分布的表达式,至于这个泊松分布式怎么求出来的,那么可能要参考一下高等数学,连续函数

求解部分的知识了

这就是教科书中的泊松分布的概率密度函数.他就是泊松分布的一个极限函数

5.全家的问题的解决老板依然蹙眉,不知道u啊?

没关系,刚才不是计算了样本均值:

 X=5

可以用它来近似:

Xμ

画出概率密度函数的曲线就是:

可以看到,如果每天准备8个馒头的话,那么足够卖的概率就是把前8个的概率加起来:

这样 93% 的情况够用,偶尔卖缺货也有助于品牌形象。老板算出一脑门的汗,“那就这么定了!

TreeNode虽然改善了链表增删改查的性能,但是其节点空间大小是链表节点的两倍. 虽然引入TreeNode但是不会轻易转变为TreeNode(如果存在大量转换那么资源代价比较大),根据泊松分布来看转变是小概率事件,性价比是值得的泊松分布是二项分布的极限形式,

两个重点:

      事件独立、有且只有两个相互对立的结果泊松分布是指一段时间或空间中发生成功事件的数量的概率对HashMap table[]中任意一个bin来说,存入一个数据,要么放入要么不放入,这个动作满足二项分布的两个重点概念

      对于HashMap.table[].length的空间来说,放入0.75*length个数据,某一个block中放入节点数量的概率情况如上图注释中给出的数据(表示数组某一个下标存放数据数量为0~8时的概率情况)

举个例子说明,

     HashMap默认的table[].length=16,在长度为16的HashMap中可放入12(0.75*length)个数据,某一个block中存放了8个节点的概率是0.00000006

    1)  扩容一次:

    16*2=32,在长度为32的HashMap中放入24个数据,某一个bin中存放了8个节点的概率是0.00000006

    2)  扩容第二次。

     32*2=64,在长度为64的HashMap中放入48个数据,某一个bin中存放了8个节点的概率是0.00000006所以,当某一个bin的节点大于等于8个的时候,就可以从链表node转换为treenode,其性价比是值得的。

Get 方法

/**

 * Implements Map.get and related methods

 *

 * @param hash hash for key

 * @param key the key

 * @return the node, or null if none

 */

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((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;

}

 JDK1.8 扩容机制
       我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度
   图(a)表示扩容前的key1和key2两种key确定索引位置的示例。

   图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。

 

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) { //老hash表是否有值,如果有大于最大容量就取最大容量,否则老容量扩容一倍,老的扩容上限也增大一倍
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            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);
        }
        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) {
            for (int j = 0; j < oldCap; ++j) {//核心代码,遍历老的的hash表
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)//如果同一链上没有元素了就直接rehash
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)//如果原来是红黑树扩容呢
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null; //初始化两个低位头尾指针
                        Node<K,V> hiHead = null, hiTail = null;//初始化两个高位的头尾指针
                        Node<K,V> next;
                        do {
                            //遍历老的hash bucket
                            next = e.next;
                             //0101 & 1000 =0000=>0
                             //如果是低位元素 比如 5 10 15  构造一个头尾指针指向该元素的链表                           
                            if ((e.hash & oldCap) == 0) {

                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {//如果是高位元素 比如 20 25 这样与运算就不会为0了这个时候就构造一条高位的链表
                                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) { //高位链表不为空就挂在高位比如20 那坑定是挂在 4+16那个位置了
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
再看一下树节点的扩容
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
        TreeNode<K,V> b = this;
        // Relink into lo and hi lists, preserving order
        TreeNode<K,V> loHead = null, loTail = null;
        TreeNode<K,V> hiHead = null, hiTail = null;
        int lc = 0, hc = 0;
        for (TreeNode<K,V> e = b, next; e != null; e = next) {//整个过程和链表的遍历类似,也是构造高低位,唯一的不同就是++lc,++hc
            next = (TreeNode<K,V>)e.next;
            e.next = null;
            if ((e.hash & bit) == 0) {
                if ((e.prev = loTail) == null)
                    loHead = e;
                else
                    loTail.next = e;
                loTail = e;
                ++lc;
            }
            else {
                if ((e.prev = hiTail) == null)
                    hiHead = e;
                else
                    hiTail.next = e;
                hiTail = e;
                ++hc;
            }
        }

        if (loHead != null) {
            if (lc <= UNTREEIFY_THRESHOLD) //这里就判断 如果是低位元素且链表长度小于6那么就取消红黑树的结构,还原为链表结构.如果节点数大于6个,那么就保持原来的红黑树结构,并加入当前节点
                tab[index] = loHead.untreeify(map);
            else {
                tab[index] = loHead;
                if (hiHead != null) // (else is already treeified)
                    loHead.treeify(tab);
            }
        }
        if (hiHead != null) {//这里同理链表
            if (hc <= UNTREEIFY_THRESHOLD)
                tab[index + bit] = hiHead.untreeify(map);
            else {
                tab[index + bit] = hiHead;
                if (loHead != null)
                    hiHead.treeify(tab);
            }
        }
    }

2.2.3.2 TreeMap工作原理

      TreeMap 的实现使用了红黑树数据结构,也就是一棵自平衡的排序二叉树,这样就可以保证快速检索指定节点。对于 TreeMap 而言,它采用一种被称为“红黑树”的排序二叉树来保存 Map 中每个 Entry —— 每个 Entry 都被当成“红黑树”的一个节点对待。

       对于 TreeMap 而言,由于它底层采用一棵“红黑树”来保存集合中的 Entry,这意味这 TreeMap 添加元素、取出元素的性能都比 HashMap 低(红黑树和Hash数据结构上的区别):当 TreeMap 添加元素时,需要通过循环找到新增 Entry 的插入位置,因此比较耗性能;当从 TreeMap 中取出元素时,需要通过循环才能找到合适的 Entry,也比较耗性能。

       TreeMap、TreeSet 比 HashMap、HashSet 的优势在于:TreeMap 中的所有 Entry 总是按 key 根据指定排序规则保持有序状态,TreeSet 中所有元素总是根据指定排序规则保持有序状态。

      TreeMap 集合的 put(K key, V value) 方法实现了将 Entry 放入排序二叉树中,下面是该方法的源代码:    

public V put(K key, V value)

    {

        // 先以 t 保存链表的 root 节点
        Entry<K,V> t = root;
        // 如果 t==null,表明是一个空链表,即该 TreeMap 里没有任何 Entry
        if (t == null)
        {
            // 将新的 key-value 创建一个 Entry,并将该 Entry 作为 root
            root = new Entry<K,V>(key, value, null);
            // 设置该 Map 集合的 size 为 1,代表包含一个 Entry
            size = 1;
            // 记录修改次数为 1
            modCount++
            return null;
        }

        int cmp;
        Entry<K,V> parent;
        Comparator<? super K> cpr = comparator;
        // 如果比较器 cpr 不为 null,即表明采用定制排序
        if (cpr != null)
        {

            do {
                // 使用 parent 上次循环后的 t 所引用的 Entry
                parent = t;
                // 拿新插入 key 和 t 的 key 进行比较
                cmp = cpr.compare(key, t.key);
                // 如果新插入的 key 小于 t 的 key,t 等于 t 的左边节点
                if (cmp < 0
                    t = t.left;
                // 如果新插入的 key 大于 t 的 key,t 等于 t 的右边节点
                else if (cmp > 0)
                    t = t.right;
                // 如果两个 key 相等,新的 value 覆盖原有的 value,
                // 并返回原有的 value
                else
                    return t.setValue(value);
            } while (t != null);
        }
        else
        {

            if (key == null)
                throw new NullPointerException();
            Comparable<? super K> k = (Comparable<? super K>) key;
            do {
                // 使用 parent 上次循环后的 t 所引用的 Entry
                parent = t;
                // 拿新插入 key 和 t 的 key 进行比较
                cmp = k.compareTo(t.key);
                // 如果新插入的 key 小于 t 的 key,t 等于 t 的左边节点
                if (cmp < 0)
                    t = t.left;
                // 如果新插入的 key 大于 t 的 key,t 等于 t 的右边节点
                else if (cmp > 0
                    t = t.right;
                // 如果两个 key 相等,新的 value 覆盖原有的 value,
                // 并返回原有的 value
                else
                    return t.setValue(value);
            } while (t != null);

        }

        // 将新插入的节点作为 parent 节点的子节
        Entry<K,V> e = new Entry<K,V>(key, value, parent);
        // 如果新插入 key 小于 parent 的 key,则 e 作为 parent 的左子节点
        if (cmp < 0)
            parent.left = e;
        // 如果新插入 key 小于 parent 的 key,则 e 作为 parent 的右子节点
        else
            parent.right = e;
        // 修复红黑树
        fixAfterInsertion(e);                               // ①
        size++;
        modCount++;
        return null;
    }

上面这段代码本质上就是红黑树德元素插入操作的代码。看下面红黑树插入操作的伪代码

如上所示就是红黑树插入的伪代码,那么我们接下来再看一下红黑树删除的部分代码

private void deleteEntry(Entry<K,V> p)

 {
    modCount++;
    size--;
    // 如果被删除节点的左子树、右子树都不为空
    if (p.left != null && p.right != null)
    {
        // 用 p 节点的中序后继节点代替 p 节点
        Entry<K,V> s = successor (p);
        p.key = s.key;
        p.value = s.value;
        p = s;
    }
    // 如果 p 节点的左节点存在,replacement 代表左节点;否则代表右节点。
    Entry<K,V> replacement = (p.left != null ? p.left : p.right);
    if (replacement != null)
    {
        replacement.parent = p.parent;
        // 如果 p 没有父节点,则 replacemment 变成父节点
        if (p.parent == null)
            root = replacement;
        // 如果 p 节点是其父节点的左子节点
        else if (p == p.parent.left)
            p.parent.left  = replacement;
        // 如果 p 节点是其父节点的右子节点
        else
            p.parent.right = replacement
            p.left = p.right = p.parent = null;
        // 修复红黑树
        if (p.color == BLACK)
            fixAfterDeletion(replacement);       // ①
    }
    // 如果 p 节点没有父节点
    else if (p.parent == null)
    {
        root = null;
    }
    else
    {
        if (p.color == BLACK)
            // 修复红黑树
            fixAfterDeletion(p);                 // ②
        if (p.parent != null)
        {
            // 如果 p 是其父节点的左子节点
            if (p == p.parent.left)
                p.parent.left = null;
            // 如果 p 是其父节点的右子节点
            else if (p == p.parent.right)
                p.parent.right = null
            p.parent = null;
        }
    }

 }

检索节点

当 TreeMap 根据 key 来取出 value 时,TreeMap 对应的方法如下: 

public V get(Object key)

 {

    // 根据指定 key 取出对应的 Entry
    Entry>K,V< p = getEntry(key);
    // 返回该 Entry 所包含的 value
    return (p==null ? null : p.value);
 }

从上面程序的粗体字代码可以看出,get(Object key) 方法实质是由于 getEntry() 方法实现的,这个 getEntry() 方法的代码如下:

final Entry<K,V> getEntry(Object key)
 {
    // 如果 comparator 不为 null,表明程序采用定制排序
    if (comparator != null)
        // 调用 getEntryUsingComparator 方法来取出对应的 key
        return getEntryUsingComparator(key);
    // 如果 key 形参的值为 null,抛出 NullPointerException 异常
    if (key == null)
        throw new NullPointerException();
    // 将 key 强制类型转换为 Comparable 实例
    Comparable<? super K> k = (Comparable<? super K>) key;
    // 从树的根节点开始
    Entry<K,V> p = root;
    while (p != null)
    {
        // 拿 key 与当前节点的 key 进行比较
        int cmp = k.compareTo(p.key);
        // 如果 key 小于当前节点的 key,向“左子树”搜索
        if (cmp < 0)
            p = p.left;
        // 如果 key 大于当前节点的 key,向“右子树”搜索
        else if (cmp > 0)
            p = p.right;
        // 不大于、不小于,就是找到了目标 Entry
        else
            return p;

    }
    return null;
 }

        上面的 getEntry(Object obj) 方法也是充分利用排序二叉树的特征来搜索目标 Entry,程序依然从二叉树的根节点开始,如果被搜索节点大于当前节点,程序向“右子树”搜索;如果被搜索节点小于当前节点,程序向“左子树”搜索;如果相等,那就是找到了指定节点。

        当 TreeMap 里的 comparator != null 即表明该 TreeMap 采用了定制排序,在采用定制排序的方式下,TreeMap 采用 getEntryUsingComparator(key) 方法来根据 key 获取 Entry。下面是该方法的代码: 

final Entry<K,V> getEntryUsingComparator(Object key)

 {
    K k = (K) key;
    // 获取该 TreeMap 的 comparator
    Comparator<? super K> cpr = comparator;
    if (cpr != null)
    {
        // 从根节点开始
        Entry<K,V> p = root;
        while (p != null)
        {
            // 拿 key 与当前节点的 key 进行比较
            int cmp = cpr.compare(k, p.key);
            // 如果 key 小于当前节点的 key,向“左子树”搜索
            if (cmp < 0)
                p = p.left;
            // 如果 key 大于当前节点的 key,向“右子树”搜索
            else if (cmp > 0)
                p = p.right
            // 不大于、不小于,就是找到了目标 Entry
            else
                return p;

        }
    }
    return null;

 }

       其实 getEntry、getEntryUsingComparator 两个方法的实现思路完全类似,只是前者对自然排序的 TreeMap 获取有效,后者对定制排序的 TreeMap 有效。

        通过上面源代码的分析不难看出,TreeMap 这个工具类的实现其实很简单。或者说:从内部结构来看,TreeMap 本质上就是一棵“红黑树”,而 TreeMap 的每个 Entry 就是该红黑树的一个节点。

2.2.4 map线程安全问题

HashMap是线程不安全的,HashTable是线程安全的,同理TreeMap也是线程不安全的

2.3 set

2.3.1 set常用元素

         Set是最简单的一种集合。集合中的对象不按特定的方式排序,并且没有重复对象。 Set接口主要实现了两个实现类:

    HashSet: HashSet类按照哈希算法来存取集合中的对象,存取速度比较快

    TreeSet :TreeSet类实现了SortedSet接口,能够对集合中的对象进行排序。

2.3.2 set功能方法

       Set具有与Collection完全一样的接口,因此没有任何额外的功能,不像前面有两个不同的List。实际上Set就是Collection,只是行为不同Set不保存重复的元素

      Set : 存入Set的每个元素都必须是唯一的,因为Set不保存重复元素。加入Set的元素必须定义equals()方法以确保对象的唯一性。Set与Collection有完全一样的接口。Set接口不保证维护元素的次序。

       HashSet:实现了Set接口,它不允许集合中有重复的值,当我们提到HashSet时,第一件事情就是在将对象存储在HashSet之前,要先确保对象重写equals()和hashCode()方法,这样才能比较对象的值是否相等,以确保set中没有储存相等的对象。如果我们没有重写这两个方法,将会使用这个方法的默认实现。public boolean add(Object o)方法用来在Set中添加元素,当元素值重复时则会立即返回false,如果成功添加的话会返回true。

       TreeSet: 保存次序的Set, 底层为树结构。使用它可以从Set中提取有序的序列LinkedHashSet:具有HashSet的查询速度,且内部使用链表维护元素的顺序(插入的次序)。于是在使用迭代器遍历Set时,结果会按元素插入的次序显示。

2.3.3 set工作原理

2.3.3.1 HashSet工作原理

对于 HashSet 而言,它是基于 HashMap 实现的,HashSet 底层采用 HashMap 来保存所有元素,因此 HashSet 的实现比较简单,查看 HashSet 的源代码,可以看到如下代码:

 public class HashSet<E>
          extends AbstractSet<E>
          implements Set<E>, Cloneable, java.io.Serializable
 {

          // 使用 HashMap 的 key 保存 HashSet 中所有元素
          private transient HashMap<E,Object> map;
          // 定义一个虚拟的 Object 对象作为 HashMap 的 value
          private static final Object PRESENT = new Object();
          ...
          // 初始化 HashSet,底层会初始化一个 HashMap
          public HashSet()
          {
                    map = new HashMap<E,Object>();
          }
          // 以指定的 initialCapacity、loadFactor 创建 HashSet
          // 其实就是以相应的参数创建 HashMap
          public HashSet(int initialCapacity, float loadFactor)
          {
                    map = new HashMap<E,Object>(initialCapacity, loadFactor);
          }

          public HashSet(int initialCapacity)
          {
                    map = new HashMap<E,Object>(initialCapacity);
          }

          HashSet(int initialCapacity, float loadFactor, boolean dummy)
          {
                   map = new LinkedHashMap<E,Object>(initialCapacity
                             , loadFactor);
          }

          // 调用 map 的 keySet 来返回所有的 key
          public Iterator<E> iterator()
          {
                    return map.keySet().iterator();
          }

          // 调用 HashMap 的 size() 方法返回 Entry 的数量,就得到该 Set 里元素的个数
          public int size()
          {
                    return map.size();
          }

          // 调用 HashMap 的 isEmpty() 判断该 HashSet 是否为空,
          // 当 HashMap 为空时,对应的 HashSet 也为空
          public boolean isEmpty()
          {
                    return map.isEmpty();
          }

          // 调用 HashMap 的 containsKey 判断是否包含指定 key
          //HashSet 的所有元素就是通过 HashMap 的 key 来保存的
          public boolean contains(Object o)
          {
                    return map.containsKey(o);
          }

          // 将指定元素放入 HashSet 中,也就是将该元素作为 key 放入 HashMap
          public boolean add(E e)
          {
                    return map.put(e, PRESENT) == null;
          }

          // 调用 HashMap 的 remove 方法删除指定 Entry,也就删除了 HashSet 中对应的元素
          public boolean remove(Object o)
          {
                    return map.remove(o)==PRESENT;
          }

          // 调用 Map 的 clear 方法清空所有 Entry,也就清空了 HashSet 中所有元素
          public void clear()
          {
                    map.clear();
          }
          ...

 }

      由上面源程序可以看出,HashSet 的实现其实非常简单,它只是封装了一个 HashMap 对象来存储所有的集合元素,所有放入 HashSet 中的集合元素实际上由 HashMap 的 key 来保存,而 HashMap 的 value 则存储了一个 PRESENT,它是一个静态的 Object 对象。

      HashSet 的绝大部分方法都是通过调用 HashMap 的方法来实现的,因此 HashSet 和 HashMap 两个集合在实现本质上是相同的。由于 HashSet 的 add() 方法添加集合元素时实际上转变为调用 HashMap 的 put() 方法来添加 key-value 对,当新放入 HashMap 的 Entry 中 key 与集合中原有 Entry 的 key 相同(hashCode() 返回值相等,通过 equals 比较也返回 true),新添加的 Entry 的 value 将覆盖原来 Entry 的 value,但 key 不会有任何改变,因此如果向 HashSet 中添加一个已经存在的元素,新添加的集合元素(底层由 HashMap 的 key 保存)不会覆盖已有的集合元素。

     掌握上面理论知识之后,接下来看一个示例程序,测试一下自己是否真正掌握了 HashMap 和 HashSet 集合的功能。

class Name

{

    private String first;
    private String last;
    public Name(String first, String last)
    {
        this.first = first;
        this.last = last;
    }

    public boolean equals(Object o)
    {
        if (this == o)
        {
            return true;
        }
             if (o.getClass() == Name.class)
        {
            Name n = (Name)o;
            return n.first.equals(first) && n.last.equals(last);
        }
        return false;
    }

}


public class HashSetTest

{

    public static void main(String[] args)
    {

        Set<Name> s = new HashSet<Name>();
        s.add(new Name("abc", "123"));
        System.out.println(s.contains(new Name("abc", "123")));

    }

}

     上面程序中向 HashSet 里添加了一个 new Name("abc", "123") 对象之后,立即通过程序判断该 HashSet 是否包含一个 new Name("abc", "123") 对象。粗看上去,很容易以为该程序会输出 true。

      实际运行上面程序将看到程序输出 false,这是因为 HashSet 判断两个对象相等的标准除了要求通过 equals() 方法比较返回 true 之外,还要求两个对象的 hashCode() 返回值相等。而上面程序没有重写 Name 类的 hashCode() 方法,两个 Name 对象的 hashCode() 返回值并不相同,因此 HashSet 会把它们当成 2 个对象处理,因此程序返回 false。

      由此可见,当我们试图把某个类的对象当成 HashMap 的 key,或试图将这个类的对象放入 HashSet 中保存时,重写该类的 equals(Object obj) 方法和 hashCode() 方法很重要,而且这两个方法的返回值必须保持一致:当该类的两个的 hashCode() 返回值相同时,它们通过 equals() 方法比较也应该返回 true。通常来说,所有参与计算 hashCode() 返回值的关键属性,都应该用于作为 equals() 比较的标准。

hashCode() 和 equals()

如下程序就正确重写了 Name 类的 hashCode() 和 equals() 方法,程序如下:

class Name

{
    private String first;
    private String last;
    public Name(String first, String last)
    {
        this.first = first;
        this.last = last;
    }

    // 根据 first 判断两个 Name 是否相等
    public boolean equals(Object o)
    {

        if (this == o)
        {
            return true;
        }

        if (o.getClass() == Name.class)
        {

            Name n = (Name)o;
            return n.first.equals(first);
        }
        return false;
    }

          

    // 根据 first 计算 Name 对象的 hashCode() 返回值
    public int hashCode()
    {
        return first.hashCode();
    }


    public String toString()
    {
        return "Name[first=" + first + ", last=" + last + "]";
    }

 }


 public class HashSetTest2

 {

    public static void main(String[] args)

    {

        HashSet<Name> set = new HashSet<Name>();

        set.add(new Name("abc" , "123"));

        set.add(new Name("abc" , "456"));

        System.out.println(set);

    }

}

       上面程序中提供了一个 Name 类,该 Name 类重写了 equals() 和 toString() 两个方法,这两个方法都是根据 Name 类的 first 实例变量来判断的,当两个 Name 对象的 first 实例变量相等时,这两个 Name 对象的 hashCode() 返回值也相同,通过 equals() 比较也会返回 true。

       程序主方法先将第一个 Name 对象添加到 HashSet 中,该 Name 对象的 first 实例变量值为"abc",接着程序再次试图将一个 first 为"abc"的 Name 对象添加到 HashSet 中,很明显,此时没法将新的 Name 对象添加到该 HashSet 中,因为此处试图添加的 Name 对象的 first 也是" abc",HashSet 会判断此处新增的 Name 对象与原有的 Name 对象相同,因此无法添加进入,程序在①号代码处输出 set 集合时将看到该集合里只包含一个 Name 对象,就是第一个、last 为"123"的 Name 对象。

2.3.3.2 TreeSet工作原理

2.3.4 set线程安全问题

 HashSet是线程不安全的,TreeSet也是线程不安全的

2.3.5 hashTable

     HashTable同样是基于哈希表实现的,其实类似HashMap,只不过有些区别,HashTable同样每个元素是一个key-value对,其内部也是通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长。

     HashTable比较古老, 是JDK1.0就引入的类,而HashMap 是 1.2 引进的 Map 的一个实现。

     HashTable 是线程安全的,能用于多线程环境中。Hashtable同样也实现了Serializable接口,支持序列化,也实现了Cloneable接口,能被克隆。

Hashtable 成员变量

private transient Entry[] table;  
// Hashtable中元素的实际数量  
private transient int count;  
// 阈值,用于判断是否需要调整Hashtable的容量(threshold = 容量*加载因子)  
private int threshold;  
// 加载因子  
private float loadFactor;  
// Hashtable被改变的次数  
private transient int modCount = 0;  

Hashtable的基本原理

构造方法

//默认构造函数,容量为11,负载因子是0.75
    public Hashtable() {
        this(11, 0.75f);
    }
    //用指定初始容量和默认的加载印在(0.74)构造一个空的哈希表。
    public Hashtable(int initialCapacity) {
        this(initialCapacity, 0.75f);
    }
    //用指定初始容量和指定加载因子构造一个新的空哈希表。其中initHashSeedAsNeeded方法用于初始化
    //hashSeed参数,其中hashSeed用于计算key的hash值,它与key的hashCode进行按位异或运算。
    //这个hashSeed是一个与实例相关的随机值,主要用于解决hash冲突:

    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;
    //初始化table,获得大小为initialCapacity的table数组  
    //这里是与HashMap的区别之一,HashMap中table
        table = new Entry[initialCapacity];
    //计算阀值    
        threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
    //初始化HashSeed值   
     initHashSeedAsNeeded(initialCapacity);
    }

    public Hashtable(Map<? extends K, ? extends V> t) {
        this(Math.max(2*t.size(), 11), 0.75f);
        putAll(t);
    }

    从上面的代码中我们可以看出,Hashtable中的key和value是不允许为空的,当我们想要想Hashtable中添加元素的时候,首先计算key的hash值,然后通过hash值确定在table数组中的索引位置,最后将value值替换或者插入新的元素,如果容器的数量达到阈值,就会进行扩充。

put()方法

public synchronized V put(K key, V value) {//这里方法修饰符为synchronized,所以是线程安全的。
        // 确保value不为null  
        if (value == null) {
            throw new NullPointerException();//value如果为Null,抛出异常
        }
        Entry tab[] = table;

        //计算key的hash值,确认在table[]中的索引位置  

        int hash = hash(key);
        //hash里面的代码是hashSeed^key.hashcode(),null.hashCode()会抛出异常,所以这就解释了
        Hashtable的key和value不能为null的原因。

        int index = (hash & 0x7FFFFFFF) % tab.length;
        //获取数组元素下标,先对hash值取正,然后取余。
        for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) {
            if ((e.hash == hash) && e.key.equals(key)) {
                //迭代index索引位置,如果该位置处的链表中存在一个一样的key,则替换其value,返回旧值  
                V old = e.value;
                e.value = value;
                return old;
            }
        }

        modCount++;//修改次数。
        if (count >= threshold) {//键值对的总数大于其阀值
            rehash();//在rehash里进行扩容处理
            tab = table;
            hash = hash(key);
            //hash&0x7FFFFFFF是为了避免负值的出现,对newCapacity求余是为了使index
            在数组索引范围之内
            index = (hash & 0x7FFFFFFF) % tab.length;
        }
        //在索引出插入一个新的节点
        Entry<K,V> e = tab[index];
        tab[index] = new Entry<>(hash, key, value, e);
        //容器中元素+1  ;  
        count++;
        return null;
    }
    private int hash(Object k) {
        // hashSeed will be zero if alternative hashing is disabled.
        return hashSeed ^ k.hashCode();//在1.8的版本中,hash就直接为k.hashCode了。
    }

      put方法的流程是:计算key的hash值,根据hash值获得key在table数组中的索引位置,然后迭代该key处的Entry链表(我们暂且理解为链表),若该链表中存在一个这个的key对象,那么就直接替换其value值即可,否则在将改key-value节点插入该index索引位置处。

       当程序试图将一个key-value对放入HashMap中时,程序首先根据该 key的 hashCode() 返回值决定该 Entry 的存储位置:如果两个 Entry 的 key 的 hashCode() 返回值相同,那它们的存储位置相同。如果这两个 Entry 的 key 通过 equals 比较返回 true,新添加 Entry 的 value 将覆盖集合中原有 Entry的 value,但key不会覆盖。如果这两个 Entry 的 key 通过 equals 比较返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部 .

get方法

public synchronized V get(Object key) {
//没有什么特殊性,就是加了一个synchronized,就是根据index来遍历索引处的单链表。
        Entry tab[] = table;
        int hash = hash(key);
        int index = (hash & 0x7FFFFFFF) % tab.length;
        for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) {
            if ((e.hash == hash) && e.key.equals(key)) {
                return e.value;
            }
        }
        return null;
}

     相对于put方法,get方法就会比较简单,处理过程就是计算key的hash值,判断在table数组中的索引位置,然后迭代链表,匹配直到找到相对应key的value,若没有找到返回null。

rehash()方法

     HashTable的扩容操作,在put方法中,如果需要向table[]中添加Entry元素,会首先进行容量校验,如果容量已经达到了阀值,HashTable就会进行扩容处理rehash()

protected void rehash() {  
        int oldCapacity = table.length;  
        //元素  
        Entry<K,V>[] oldMap = table;  
  
        //新容量=旧容量 * 2 + 1  
        int newCapacity = (oldCapacity << 1) + 1;  
        if (newCapacity - MAX_ARRAY_SIZE > 0) { 
        //这里的最大值和HashMap里的最大值不同,这里Max_ARRAY_SIZE的是
        因为有些虚拟机实现会限制数组的最大长度。 
            if (oldCapacity == MAX_ARRAY_SIZE)  
                return;  
            newCapacity = MAX_ARRAY_SIZE;  
        }  
          
        //新建一个size = newCapacity 的HashTable  
        Entry<K,V>[] newMap = new Entry[];  
  
        modCount++;  
        //重新计算阀值  
        threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1);  
        //重新计算hashSeed  
        boolean rehash = initHashSeedAsNeeded(newCapacity);  
  
        table = newMap;  
        //将原来的元素拷贝到新的HashTable中  
        for (int i = oldCapacity ; i-- > 0 ;) {  
            for (Entry<K,V> old = oldMap[i] ; old != null ; ) {  
                Entry<K,V> e = old;  
                old = old.next;  
  
                if (rehash) {  
                    e.hash = hash(e.key);  
                }  
                int index = (e.hash & 0x7FFFFFFF) % newCapacity;  
                e.next = newMap[index];  
                newMap[index] = e;  
            }  
        }  
    }  

     在这个rehash()方法中我们可以看到容量扩大两倍+1,同时需要将原来HashTable中的元素一一复制到新的HashTable中,这个过程是比较消耗时间的,同时还需要重新计算hashSeed的,毕竟容量已经变了。:比如初始值11、加载因子默认0.75,那么这个时候阀值threshold=8,当容器中的元素达到8时,HashTable进行一次扩容操作,容量 = 8 * 2 + 1 =17,而阀值threshold=17*0.75 = 13,当容器元素再一次达到阀值时,HashTable还会进行扩容操作,一次类推。

2.4 总结

1. 如果涉及到堆栈,队列等操作,应该考虑用List,对于需要快速插入,删除元素,应该使用LinkedList,如果需要快速随机访问元素,应该使用ArrayList。

2. 如果程序在单线程环境中,或者访问仅仅在一个线程中进行,考虑非同步的类,其效率较高,如果多个线程可能同时操作一个类,应该使用同步的类。

3. 在除需要排序时使用TreeSet,TreeMap外,都应使用HashSet,HashMap,因为他们 的效率更高。

4. 要特别注意对哈希表的操作,作为key的对象要正确复写equals和hashCode方法。

5. 容器类仅能持有对象引用(指向对象的指针),而不是将对象信息copy一份至数列某位置。一旦将对象置入容器内,便损失了该对象的型别信息。

6. 尽量返回接口而非实际的类型,如返回List而非ArrayList,这样如果以后需要将ArrayList换成LinkedList时,客户端代码不用改变。这就是针对抽象编程。

2.5 注意

1、Collection没有get()方法来取得某个元素。只能通过iterator()遍历元素。

2、Set和Collection拥有一模一样的接口。

3、List,可以通过get()方法来一次取出一个元素。使用数字来选择一堆对象中的一个,get(0)...。(add/get)

4、一般使用ArrayList。用LinkedList构造堆栈stack、队列queue。

5、Map用 put(k,v) / get(k),还可以使用containsKey()/containsValue()来检查其中是否含有某个key/value。HashMap会利用对象的hashCode来快速找到key。

6、Map中元素,可以将key序列、value序列单独抽取出来。

使用keySet()抽取key序列,将map中的所有keys生成一个Set。

使用values()抽取value序列,将map中的所有values生成一个Collection。

为什么一个生成Set,一个生成Collection?那是因为,key总是独一无二的,value允许重复。

2.6 集合比较

2.6.1 HashTable&HashMap

HashMap和Hashtable都实现了Map接口,但决定用哪一个之前先要弄清楚它们之间的分别。主要的区别有:线程安全性,同步(synchronization),以及速度。

    HashMap几乎可以等价于Hashtable,除了HashMap是非synchronized的,并可以接受null(HashMap allows one null key and any number of null values.,而Hashtable则不行)。这就是说,HashMap中如果在表中没有发现搜索键,或者如果发现了搜索键,但它是一个空的值,那么get()将返回null。如果有必要,用containKey()方法来区别这两种情况。

    HashMap是非synchronized,而Hashtable是synchronized,这意味着Hashtable是线程安全的,多个线程可以共享一个Hashtable;而如果没有正确的同步的话,多个线程不能共享HashMap。 即是说,在多线程应用程序中,不用专门的操作就安全地可以使用Hashtable了;而对于HashMap,则需要额外的同步机制。但HashMap的同步问题可通过Collections的一个静态方法得到解决: Map Collections.synchronizedMap(Map m)

    这个方法返回一个同步的Map,这个Map封装了底层的HashMap的所有方法,使得底

层的HashMap即使是在多线程的环境中也是安全的。

    而且Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的扩展性更好。

    另一个区别是HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。但这并不是一个一定发生的行为,要看JVM。这条同样也是Enumeration和Iterator的区别。

    由于Hashtable是线程安全的也是synchronized,所以在单线程环境下它比HashMap要慢。如果不需要同步,只需要单一线程,那么使用HashMap性能要好过Hashtable。

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

    哈希值的使用不同,HashTable直接使用对象的hashCode,代码是这样的:

          int hash = key.hashCode();

          int index = (hash & 0x7FFFFFFF) % tab.length;

    而HashMap重新计算hash值,而且用与代替求模:

    int hash = hash(k);

    int i = indexFor(hash, table.length);

要注意的一些重要术语:

1) sychronized意味着在一次仅有一个线程能够更改Hashtable。就是说任何线程要更新Hashtable时要首先获得同步锁,其它线程要等到同步锁被释放之后才能再次获得同步锁更新Hashtable。

2) Fail-safe和iterator迭代器相关。如果某个集合对象创建了Iterator或者ListIterator,然后其它的线程试图“结构上”更改集合对象,将会抛出ConcurrentModificationException异常。但其它线程可以通过set()方法更改集合对象是允许的,因为这并没有从“结构上”更改集合。但是假如已经从结构上进行了更改,再调用set()方法,将会抛出IllegalArgumentException异常。

3) 结构上的更改指的是删除或者插入一个元素,这样会影响到map的结构。

2.6.2 HashSet&HashMap

HashSet和HashMap的区别

*HashMap*    *HashSet*

HashMap实现了Map接口 HashSet实现了Set接口

HashMap储存键值对   HashSet仅仅存储对象(且无重复对象)

使用put()方法将元素放入map中 ,使用add()方法将元素放入set中

HashMap中使用键对象来计算hashcode值,HashSet使用成员对象来计算hashcode值,对于两个对象来说hashcode可能相同,所以equals()方法用来判断对象的相等性,如果两个对象不同的话,那么返回false

HashMap比较快,因为是使用唯一的键来获取对象 ,HashSet较HashMap来说比较慢

2.6.3 TreeMap&TreeSet

TreeMap 和 TreeSet 是 Java Collection Framework 的两个重要成员,其中 TreeMap 是 Map 接口的常用实现类,而 TreeSet 是 Set 接口的常用实现类。虽然 TreeMap 和 TreeSet 实现的接口规范不同,但 TreeSet 底层是通过 TreeMap 来实现的(如同HashSet底层是是通过HashMap来实现的一样),因此二者的实现方式完全一样。而 TreeMap 的实现就是红黑树算法。

1. TreeSet和TreeMap的关系

与HashSet完全类似,TreeSet里面绝大部分方法都市直接调用TreeMap方法来实现的。

相同点:

TreeMap和TreeSet都是有序的集合,也就是说他们存储的值都是排好序的。

TreeMap和TreeSet都是非同步集合,因此他们不能在多线程之间共享,不过可以使用方法Collections.synchroinzedMap()来实现同步

运行速度都要比Hash集合慢,他们内部对元素的操作时间复杂度为O(logN),而HashMap/HashSet则为O(1)。

不同点:

最主要的区别就是TreeSet和TreeMap非别实现Set和Map接口

TreeSet只存储一个对象,而TreeMap存储两个对象Key和Value(仅仅key对象有序)

TreeSet中不能有重复对象,而TreeMap中可以存在

2.6.4 TreeMap&TreeSet对比HashMap&HashSet

缺点:

    对于 TreeMap 而言,由于它底层采用一棵“红黑树”来保存集合中的 Entry,这意味着 TreeMap 添加元素、取出元素的性能都比 HashMap (O(1))低:

    当 TreeMap 添加元素时,需要通过循环找到新增 Entry 的插入位置,因此比较耗性能(O(logN))

当从 TreeMap 中取出元素时,需要通过循环才能找到合适的 Entry,也比较耗性能(O(logN))

优点:

    TreeMap 中的所有 Entry 总是按 key 根据指定排序规则保持有序状态,TreeSet 中所有元素总是根据指定排序规则保持有序状态。

 常见问题

“你知道HashMap的工作原理吗?” “你知道HashMap的get()方法的工作原理吗?”

答:“HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象。”这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Entry。这一点有助于理解获取对象的逻辑。如果你没有意识到这一点,或者错误的认为仅仅只在bucket中存储值的话,你将不会回答如何从HashMap中获取对象的逻辑。这个答案相当的正确,也显示出面试者确实知道hashing以及HashMap的工作原理。

“当两个对象的hashcode相同会发生什么?” 从这里开始,真正的困惑开始了,一些面试者会回答因为hashcode相同,所以两个对象是相等的,HashMap将会抛出异常,或者不会存储它们。然后面试官可能会提醒他们有equals()和hashCode()两个方法,并告诉他们两个对象就算hashcode相同,但是它们可能并不相等。一些面试者可能就此放弃,而另外一些还能继续挺进,他们回答“因为hashcode相同,所以它们的bucket位置相同,‘碰撞’会发生。因为HashMap使用链表存储对象,这个Entry(包含有键值对的Map.Entry对象)会存储在链表中。”这个答案非常的合理,虽然有很多种处理碰撞的方法,这种方法是最简单的,也正是HashMap的处理方法。但故事还没有完结,面试官会继续问:

“如果两个键的hashcode相同,你如何获取值对象?” 面试者会回答:当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,然后获取值对象。面试官提醒他如果有两个值对象储存在同一个bucket,他给出答案:将会遍历链表直到找到值对象。面试官会问因为你并没有值对象去比较,你是如何确定确定找到值对象的?除非面试者知道HashMap在链表中存储的是键值对,否则他们不可能回答出这一题。

其中一些记得这个重要知识点的面试者会说,找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。完美的答案!

许多情况下,面试者会在这个环节中出错,因为他们混淆了hashCode()和equals()方法。因为在此之前hashCode()屡屡出现,而equals()方法仅仅在获取值对象的时候才出现。一些优秀的开发者会指出使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。

“如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?”除非你真正知道HashMap的工作原理,否则你将回答不出这道题。默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。如果你能够回答这道问题,下面的问题来了:

“你了解重新调整HashMap大小存在什么问题吗?”多线程的情况下,可能产生条件竞争(race condition)。

当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。

为什么TreeMap采用红黑树而不是二叉查找树?“

其实这个问题就是在问红黑树相对于排序二叉树的优点。我们都知道排序二叉树虽然可以快速检索,但在最坏的情况下:如果插入的节点集本身就是有序的,要么是由小到大排列,要么是由大到小排列,那么最后得到的排序二叉树将变成链表:所有节点只有左节点(如果插入节点集本身是大到小排列);或所有节点只有右节点(如果插入节点集本身是小到大排列)。在这种情况下,排序二叉树就变成了普通链表,其检索效率就会很差。

为了改变排序二叉树存在的不足,Rudolf Bayer 与 1972 年发明了另一种改进后的排序二叉树:红黑树,他将这种排序二叉树称为“对称二叉 B 树”,而红黑树这个名字则由 Leo J. Guibas 和 Robert Sedgewick 于 1978 年首次提出。

红黑树是一个更高效的检索二叉树,因此常常用来实现关联数组。典型地,JDK 提供的集合类 TreeMap 本身就是一个红黑树的实现。

红黑树在原有的排序二叉树增加了如下几个要求:

Java 实现的红黑树

上面的性质 3 中指定红黑树的每个叶子节点都是空节点,而且并叶子节点都是黑色。但 Java 实现的红黑树将使用 null 来代表空节点,因此遍历红黑树时将看不到黑色的叶子节点,反而看到每个叶子节点都是红色的。

    性质 1:每个节点要么是红色,要么是黑色。

    性质 2:根节点永远是黑色的。

    性质 3:所有的叶节点都是空节点(即 null),并且是黑色的。

    性质 4:每个红色节点的两个子节点都是黑色。(从每个叶子到根的路径上不会有两个连续的红色节点)

    性质 5:从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点。

连接下文: 二叉查找树,平衡二叉树,红黑树_worn.xiao的博客-CSDN博客

https://blog.csdn.net/worn_xiao/article/details/106066492 JAVA并发下的容器

https://blog.csdn.net/worn_xiao/article/details/105901275  二叉查找树,平衡二叉树,红黑树

https://blog.csdn.net/worn_xiao/article/details/105900828 List&LinkList,Queue&Deque

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值