14.集合框架之Set和Map

本章目标

  • Set
  • Map

本章内容

一、Set

1、Set简介

Java中的Set也属于Collection 接口,

2.1、Set有以下特性:

  • 扩展Collection接口
  • 不允许重复元素
  • 不区分顺序
  • 允许包括null的元素,但是最多只能有一个(HashSet)

2.2、分类

  • HashSet类:
  • TreeSet类:
  • LinkedHashSet类:

2、HashSet类

HashSet是采用hash表算法来实现的,其中的元素没有按顺序排列,主要有add()、remove()以及contains()等方法;

     public static void main(String[] args) {
         HashSet dset = new HashSet();
         dset.add("hello");
         dset.add("world");
         dset.add(11);
         dset.add(null);
         Iterator iterator = dset.iterator();
         while (iterator.hasNext()) {
         System.out.println(iterator.next() + " ");
         }
 
     }

输出结果

 null
 world
 hello
 11

3、TreeSet类

TreeSet是采用树结构实现(称为红黑树算法),元素是按顺序进行排列,主要有add()、remove()以及contains()等方法;它还提供了一些处理排序的set方法,如first(), last(), headSet(), tailSet()等

 public class SetDemo {
 
     public static void main(String[] args) {
         TreeSet tree = new TreeSet();
         tree.add(28);
         tree.add(58);
         tree.add(38);
         tree.add(18);
         Iterator iterator = tree.iterator();
         while (iterator.hasNext()) {
         System.out.println(iterator.next() + " ");
         }
 
     }
 
 }

输出结果

 18
 28
 38
 58

4、LinkedHashSet类

LinkedHashSet正好介于HashSet和TreeSet之间,它也是一个hash表,但它同时维护了一个双链表来记录插入的顺序

 public class SetDemo {
 
     public static void main(String[] args) {
         LinkedHashSet dset = new LinkedHashSet();
         dset.add("hello");
         dset.add("world");
         dset.add(11);
         dset.add(null);
         Iterator iterator = dset.iterator();
         while (iterator.hasNext()) {
         System.out.println(iterator.next() + " ");
         }
 
     }
 
 }

结果

 hello
 world
 11
 null

5、拓展–集合去重

集合如何去重?

5.1、List的contains方法去重

 public class Main {
 
     public static void main(String[] args) {
         List<Integer> list = new ArrayList<Integer>();
         List<Integer> result = new ArrayList<Integer>();
         list.add(11);
         list.add(22);
         list.add(33);
         list.add(11);
         for (Integer i : list) {
             if (!result.contains(i)) {
                 result.add(i);
             }
         }
         System.out.println(result);
 
     }
 
 }

5.2、HashSet去重

 public class Main {
 
     public static void main(String[] args) {
         List<Integer> list = new ArrayList<Integer>();
         list.add(11);
         list.add(22);
         list.add(33);
         list.add(11);
         Set<Integer> hashSet = new HashSet<Integer>(list);
         list = new ArrayList<Integer>(hashSet);
         System.out.println(list);
     }
 
 }

二、Map集合

在JAVA 2的集合框架中,主要包括两个接口及其扩展和实现类,即Collection接口和Map接口:

  • Collection接口存储一组对象
  • Map接口则用于维护键/值对(key/value pairs)

1、Map接口简介

Map是将键映射到值的对象。一个映射不能包含重复的键;每个键最多只能映射一个值。

Map 接口提供三种collection 视图,允许以键集值集键-值映射关系集的形式查看某个映射的内容。映射顺序 定义为迭代器在映射的 collection 视图上返回其元素的顺序。某些映射实现可明确保证其顺序,如 TreeMap 类;另一些映射实现则不保证顺序,如 HashMap 类。

1.1、Map集合的特点

  • Map是一个双列集合,一个元素包含两个值(一个key,一个value)
  • Map集合中的元素,key和value的数据类型可以相同,也可以不同
  • Map中的元素,key不允许重复,value可以重复
  • Map里的key和value是一一对应的。

1.2、分类

  • HashMap类:
  • TreeMap类:
  • LinkedHashMap类:

我们发现和Set的分类一样,实际上通过源码可以知道,Set底层的实现其实就是Map

2、常用方法

方法 说明
Object put(Object key, Object value) 将互相关联的一组键/值对放入该映像
Object remove(Object key) 从映像中删除与key相关的映射
void putAll(Map t) 将来自特定映像的所有元素添加给该映像
void clear() 从映像中删除所有映射
Object get(Object key) 获得与关键字key相关的值
boolean containsKey(Object key) 判断映像中是否存在关键字key
boolean containsValue(Object value) 判断映像中是否存在值value
int size() 返回当前映像中映射的数量
boolean isEmpty() 判断映像是否为空
Set keySet() 返回映像中所有关键字的视图集
Collection values() 返回映像中所有值的视图集
Set entrySet() 返回Map.Entry对象的视图集,即映像中的关键字/值对

3、HashMap

 public class MapDemo {
 
     /**
      * @param args
      */
     public static void main(String[] args) {
         // TODO Auto-generated method stub
         Map map = new HashMap();
         map.put("stu01", new Student("武松",19,"景阳冈小学"));
         map.put("stu02", new Student("宋江",23,"梁山大学"));
         map.put("stu03", new Student("成吉思汗",29,"内蒙中学"));

         Iterator entryIt = map.entrySet().iterator();
         while(entryIt.hasNext()){
             Map.Entry entry = (Map.Entry)entryIt.next();

             System.out.println(entry.getKey() +"  "+ entry.getValue());
         }

         TreeMap tree=new TreeMap(map);
         Iterator it=tree.entrySet().iterator();
         while(it.hasNext()){
             Map.Entry en=(Map.Entry)it.next();
             System.out.println(en.getKey() +" "+en.getValue());
         }
     }
 
 }

4、TreeMap

SortedMap 接口的基于红黑树的实现。此类保证了映射按照升序顺序排列关键字,根据使用的构造方法不同,可能会按照键的类的自然顺序 进行排序(参见 Comparable),或者按照创建时所提供的比较器进行排序。

由于要排序,所以要求key的数据类型要一致,这样才能根据该类型的对应的的 Comparable 或 Comparator接口映射使用它的 compareTo(或 compare)方法对所有键进行比较

 public static void main(String[] args) {
        TreeMap<Object, Object> map = new TreeMap<>();
         map.put("aa","bb");
         map.put("1001","world");
         map.put("1002",null);
         map.put("aa","cc");
         System.out.println(map.get("aa"));
         System.out.println(map);
 }

5、Hashtable

Hashtable jdk已经不推荐使用,处理多线程推荐采用ConcurrentHashMap,多线程时再涉及。

Hashtable类实现了Map接口,同Vector一样也是一个线程安全的集合。在Hashtable中使用key对象的哈希值,作为对应的对象的相对存储地址,以便实现根据关键字快速查找对象的功能。

  • Hashtable:底层也是哈希表,是同步的,是一个单线程结合,是线程安全的集合,速度慢

  • Hashtable:不能存储null键,null值

    public static void main(String[] args) {
      Hashtable<String, String> map=new Hashtable<>();
      map.put(“1”, “1”);
      map.put(“6”, “6”);
      map.put(“3”, “3”);
      map.put(“7”, null);//value不允许为空
      map.put(null, “2”);//key不允许为空
      Set<Entry<String,String>> entrySet = map.entrySet();
      Iterator<Entry<String, String>> iterator = entrySet.iterator();
      while(iterator.hasNext()) {
      Entry<String, String> next = iterator.next();
      System.out.println(next.getKey()+“:”+next.getValue());
      }
      }
    

三、HashMap存储结构(扩展)

这部分内容我们暂涉及不到,到就业阶段再作为重点,有兴趣的同学可以研究一下

HashMap底是哈希表,查询速度非常快(jdk1.8之前是数组+单向链表,1.8之后是数组+单向链表/红黑树 ,链表长度超过8时,换成红黑树),可参考:https://www.jianshu.com/p/51fd34de86c9

  1. 数组优点:通过数组下标可以快速实现对数组元素的访问,效率极高;
  2. 链表优点:插入或删除数据不需要移动元素,只需修改节点引用,效率极高。

1、JDK1.8之前HashMap存在的问题?

HashMap通过hash方法计算key的哈希码,然后通过(n-1)&hash公式(n为数组长度)得到key在数组中存放的下标。当两个key在数组中存放的下标一致时,数据将以链表的方式存储(哈希冲突,哈希碰撞)。在链表中查找数据必须从第一个元素开始一层一层往下找,直到找到为止,时间复杂度为O(N),所以当链表长度越来越长时,HashMap的效率越来越低。

解决思想

JDK1.8开始采用数组+链表+红黑树的结构来实现HashMap。当链表中的元素超过8个(TREEIFY_THRESHOLD)并且数组长度大于64(MIN_TREEIFY_CAPACITY)时,会将链表转换为红黑树,转换后数据查询时间复杂度为O(logN)。

红黑树的节点使用TreeNode表示:

 static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
     TreeNode<K,V> parent;  // red-black tree links
     TreeNode<K,V> left;
     TreeNode<K,V> right;
     TreeNode<K,V> prev;    // needed to unlink next upon deletion
     boolean red;
     TreeNode(int hash, K key, V val, Node<K,V> next) {
         super(hash, key, val, next);
     }
     ...
 }

2、底层结构

HashMap内部使用数组存储数据,数组中的每个元素类型为Node<K,V>

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

Node包含了四个字段:hash、key、value、next,其中next表示链表的下一个节点。

HashMap包含几个重要的变量:

// 数组默认的初始化长度16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// 数组最大容量,2的30次幂,即1073741824
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认加载因子值
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 链表转换为红黑树的长度阈值
static final int TREEIFY_THRESHOLD = 8;

// 红黑树转换为链表的长度阈值
static final int UNTREEIFY_THRESHOLD = 6;

// 链表转换为红黑树时,数组容量必须大于等于64
static final int MIN_TREEIFY_CAPACITY = 64;

// HashMap里键值对个数
transient int size;

// 扩容阈值,计算方法为 数组容量*加载因子
int threshold;

// HashMap使用数组存放数据,数组元素类型为Node<K,V>
transient Node<K,V>[] table;

// 加载因子
final float loadFactor;

// 用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),直接抛出ConcurrentModificationException异常
transient int modCount;

上面这些字段在下面源码解析的时候尤为重要,其中需要着重讨论的是加载因子是什么。

3、 put源码

put方法源码如下:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

put方法通过hash函数计算key对应的哈希值,hash函数源码如下:

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

对于 32 位的处理器,会分成高(左边的) 16 位和低(右边的) 16 位

int类型的数值是4个字节的,右移16位异或可以同时保留高16位于低16位的特征

如果key为null,返回0,不为null,则通过(h = key.hashCode()) ^ (h >>> 16)公式计算得到哈希值。该公式通过hashCode的高16位异或低16位得到哈希值,主要从性能、哈希碰撞角度考虑,减少系统开销,不会造成因为高位没有参与下标计算从而引起的碰撞。

得到key对应的哈希值后,再调用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;
    // 如果数组(哈希表)为null或者长度为0,则进行数组初始化操作
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 根据key的哈希值计算出数据插入数组的下标位置,公式为(n-1)&hash,数组长度为16为例,(n-1)&hash得到的值都小于16
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 如果该下标位置还没有元素,则直接创建Node对象,并插入
        tab[i] = newNode(hash, key, value, null);
    else {
        // e用来存放被覆盖的节点元素
        Node<K,V> e; K k;
        // 如果目标位置key已经存在,则直接覆盖,覆盖操作在if (e != null) 处统一执行
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 如果目标位置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);
                    // 如果链表长度大于等于TREEIFY_THRESHOLD,则考虑转换为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash); // 转换为红黑树操作,内部还会判断数组长度是否小于MIN_TREEIFY_CAPACITY,如果是的话不转换
                    break;
                }
                // 如果链表中已经存在该key的话,直接覆盖替换
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                // 如果上述两个条件都不成立,执行下一次循环操作,p指向当前节点
                p = e;
            }
        }
         // e不等于null时代表在数组中、或树的节点中、或链表中有被覆盖的节点
        if (e != null) {
            V oldValue = e.value;
             // onlyIfAbsent默认false,条件成立,则用新值替换旧值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
             // 返回被替换的值
            return oldValue;
        }
    }
    // 模数递增
    ++modCount;
    // 当键值对个数大于等于扩容阈值的时候,进行扩容操作
    if (++size > threshold)
     // 扩容为当前值乘以2
        resize();
    afterNodeInsertion(evict);
    return null;
}

put操作过程总结:

  1. 判断HashMap数组是否为空,是的话初始化数组(由此可见,在创建HashMap对象的时候并不会直接初始化数组);
  2. 通过(n-1) & hash计算key在数组中的存放索引;
  3. 目标索引位置为空的话,直接创建Node存储;
  4. 目标索引位置不为空的话,分下面三种情况:
    • key相同,覆盖旧值;
    • 该节点类型是红黑树的话,执行红黑树插入操作;
    • 该节点类型是链表的话,遍历到最后一个元素尾插入,如果期间有遇到key相同的,则直接覆盖。如果链表长度大于等于TREEIFY_THRESHOLD,并且数组容量大于等于MIN_TREEIFY_CAPACITY,则将链表转换为红黑树结构;
  5. 判断HashMap元素个数是否大于等于threshold,是的话,进行扩容操作。

4、 get源码

get和put相比,就简单多了,下面是get操作源码:

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;
    // 判断数组是否为空,数组长度是否大于0,目标索引位置下元素是否为空,是的话直接返回null
    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;
}

5、 resize源码

通过put源码分析我们知道,数组的初始化和扩容都是通过调用resize方法完成的:

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;
        }
        // 扩大容量为当前容量的两倍,但不能超过 MAXIMUM_CAPACITY
        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
        // 如果初始化的值为 0,则使用默认的初始化容量,默认值为16
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 如果新的容量等于 0
    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
    table = newTab;
    // 原数据不为空,将原数据复制到新 table 中
    if (oldTab != null) {
        // 根据容量循环数组,复制非空元素到新 table
        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
                    // 链表复制,JDK 1.8 扩容优化部分
                    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);
                    // 将原索引放到哈希桶中
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 将原索引 + oldCap 放到哈希桶中
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

6、 与JDK1.7主要区别

6.1、数组元素类型不同

JDK1.8 HashMap数组元素类型为Node<K,V>,JDK1.7 HashMap数组元素类型为Entry<K,V>

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

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

    ......
}

实际就是换了个类名,并没有什么本质不同。

6.2、hash计算规则不同

JDK1.7 hash计算规则为:

final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }

    h ^= k.hashCode();

    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

相比于JDK1.8的hash方法,JDK1.7的hash方法的性能会稍差一点。

6.3、put操作不同

JDK1.7并没有使用红黑树,如果哈希冲突后,都用链表解决。区别于JDK1.8的尾部插入,JDK1.7采用头部插入的方式:

public V put(K key, V value) {
    // 键为null,将元素放置到table数组的0下标处
    if (key == null)
        return putForNullKey(value);
    // 计算hash和数组下标索引位置
    int hash = hash(key.hashCode());
    int i = indexFor(hash, table.length);
    // 遍历链表,当key一致时,说明该key已经存在,使用新值替换旧值并返回
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    modCount++;
    // 插入链表
    addEntry(hash, key, value, i);
    return null;
}

private V putForNullKey(V value) {
    // 一样的,新旧值替换
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        if (e.key == null) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    modCount++;
    // 插入到数组下标为0位置
    addEntry(0, null, value, 0);
    return null;
}

void addEntry(int hash, K key, V value, int bucketIndex) {
    // 新值头部插入,原先头部变成新的头部元素的next
    Entry<K, V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K, V>(hash, key, value, e);
    // 计数,扩容
    if (size++ >= threshold)
        resize(2 * table.length);
}

6.4、扩容操作不同

JDK1.8在扩容时通过高位运算e.hash & oldCap结果是否为0来确定元素是否需要移动,JDK1.7重新计算了每个元素的哈希值,按旧链表的正序遍历链表、在新链表的头部依次插入,即在转移数据、扩容后,容易出现链表逆序的情况:

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

/**
 * Transfers all entries from current table to newTable.
 */
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

7、其它问题

7.1、问一: 加载因子为什么默认值为0.75f ?

加载因子也叫扩容因子,用于决定HashMap数组何时进行扩容。比如数组容量为16,加载因子为0.75,那么扩容阈值为16*0.75=12,即HashMap数据量大于等于12时,数组就会进行扩容。我们都知道,数组容量的大小在创建的时候就确定了,所谓的扩容指的是重新创建一个指定容量的数组,然后将旧值复制到新的数组里。扩容这个过程非常耗时,会影响程序性能。所以加载因子是基于容量和性能之间平衡的结果:

  • 当加载因子过大时,扩容阈值也变大,也就是说扩容的门槛提高了,这样容量的占用就会降低。但这时哈希碰撞的几率就会增加,效率下降;
  • 当加载因子过小时,扩容阈值变小,扩容门槛降低,容量占用变大。这时候哈希碰撞的几率下降,效率提高。

可以看到容量占用和性能是此消彼长的关系,它们的平衡点由加载因子决定,0.75是一个即兼顾容量又兼顾性能的经验值。

7.2、问二:HashMap如何实现序列化和反序列化(学完IO之后研究)

此外用于存储数据的table字段使用transient修饰,通过transient修饰的字段在序列化的时候将被排除在外,那么HashMap在序列化后进行反序列化时,是如何恢复数据的呢?HashMap通过自定义的readObject/writeObject方法自定义序列化和反序列化操作。这样做主要是出于以下两点考虑:

  1. table一般不会存满,即容量大于实际键值对个数,序列化table未使用的部分不仅浪费时间也浪费空间;
  2. key对应的类型如果没有重写hashCode方法,那么它将调用Object的hashCode方法,该方法为native方法,在不同JVM下实现可能不同;换句话说,同一个键值对在不同的JVM环境下,在table中存储的位置可能不同,那么在反序列化table操作时可能会出错。

所以在HashXXX类中(如HashTable,HashSet,LinkedHashMap等等),我们可以看到,这些类用于存储数据的字段都用transient修饰,并且都自定义了readObject/writeObject方法。readObject/writeObject方法。

然后再点进putVal 方法,则会看到有下面的代码:

tab[i = (n - 1) & hash]

7.3、HashMap 中为什么需要一个hashCode 值?

原因就是需要用它来对HashMap 数组的位置来定位,如果向HashMap 里存一个数,单纯的依次使用equals 方法比较key 是否相同来确定当前数据是否已存储过,那效率非常低,而通过比较hashCode 值,效率就会大大提高,那它是如何定位数组位置呢,如果你使用的是jdk 1.8,那在put方法中的putVal方法里会看到如下内容:

对于上边的n-1 后边会说到,先说一下上边的写法,& 为二进制中的与运算 ,它的运算特点是,两个数进行& ,如果都为1,则运算结果为1,否则为0。 因为hashMap 的数组长度都是2的n次幂 ,那么对于这个数再减去1,转换成二进制的话,就肯定是最高位为0,其他位全是1 的数,那以数组长度为8为例(默认HashMap初始数组长度是16),那8-1 转成二进制的话,就是0111 。 那我们举一个随便的hashCode值,与0111 进行与运算 看看结果如何,如下:

 第一个key:      hashcode值:10101000
   与0111进行&运算        &       0111
                                0000  (十进制为0)
   ------------------------------------------
 第二个key:      hashcode值:11101000
   与0111进行&运算        &       0111
                                0000  (十进制为0)
 --------------------------------------------
 第三个key:      hashcode值:11101010
   与0111进行&运算        &       0111
                                0010  (十进制为2)

你可以随便变hashcode 值来测试,最终得到的数都会小于8 ,当然会像上边一样,出现相同的数据,那样的话,就会以链表的形式存在那个数组元素上了。 回过头来再设想一下,那我们就可以通过把key转为hashCode值,然后与数组长度进行与运算 ,来确定HahsMap 中当前key对应的数据在数组中的位置 。 原本,需要查找数组下的每个元素,以及他们对应的链,疯狂的调用equals 方法,誓死遍历出数据的方式,变成了仅仅是查找数组下的一个元素,然后,只需要equals 比较这一条链上的数据就可以了,这样equals 的使用次数降低了很多。 通过上边的说明,不知道大家是否理解到了hashCode 值,在HashMap 中的作用呢?

7.4、为什么hashMap 的数组长度为2的n次幂?

HashMap构造方法

 public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

数组长度为2的n次方

/**
     * Returns a power of two size for the given target capacity.
     */
    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;
    }

得到数组下标

tab[i = (n - 1) & hash]

我们知道了& 的作用,但是n-1 到底是什么意思呢? 其实上边,已经提到过了,就是2的n次幂 是很特殊的数,随便满足这个条件的数,对它减去1,转换成二进制,都会有这样的特点,即,最高位为0,其他低位全为1 。 就以数组长度分别为8和7 为例,看下边的情况: 数字8减去1转换成二进制是0111,即下边的情况

 第一个key:      hashcode值:10101001
   与0111进行&运算        &       0111

                                0001  (十进制为1)
   ------------------------------------------

 第二个key:      hashcode值:11101000
   与0111进行&运算        &       0111

                                0000  (十进制为0)
--------------------------------------------

 第三个key:      hashcode值:11101110
   与0111进行&运算        &       0111
                                0110  (十进制为6)

这样得到的数,就会完整的得到原hashcode 值的低位值,不会受到与运算对数据的变化影响。 数字7减去1转换成二进制是0110,即下边的情况

第一个key:      hashcode值:10101001
   与0111进行&运算        &       0110

                                0000  (十进制为0)
   ------------------------------------------

 第二个key:      hashcode值:11101000
   与0111进行&运算        &       0110

                                0000  (十进制为0)
--------------------------------------------

 第三个key:      hashcode值:11101110
   与0111进行&运算        &       0111
                                0110  (十进制为6)

通过上边可以看到,当数组长度不为2的n次幂 的时候,hashCode 值与数组长度减一做与运算 的时候,会出现重复的数据,因为不为2的n次幂 的话,对应的二进制数肯定有一位为0 ,这样,不管你的hashCode 值对应的该位,是0 还是1 ,最终得到的该位上的数肯定是0 ,这带来的问题就是HashMap 上的数组元素分布不均匀,而数组上的某些位置,永远也用不到。如下图所示: 这里写图片描述 这将带来的问题就是你的HashMap 数组的利用率太低,并且链表可能因为上边的(n - 1) & hash 运算结果碰撞率过高,导致链表太深。(当然jdk 1.8已经在链表数据超过8个以后转换成了红黑树的操作,但那样也很容易造成它们之间的转换时机的提前到来)。

思维导图

image

posted @ 2025-03-31 17:53  icui4cu  阅读(15)  评论(0)    收藏  举报