类集框架笔记

LinkedList

LinkedList 双向链表,实现了 Deque (double ended queue),Deque 又继承自 Queue 接口。这样 LinkedList 就具备了队列的功能,因为Dequeue是双向的,所以可以用来替换Stack结构
LinkedList 继承自 AbstractSequentialList,AbstractSequentialList 提供了一套基于顺序访问的接口,如根据索引获取、插入元素。

查找时进行了优化:

Node<E> node(int index) {
    /*
     * 则从头节点开始查找,否则从尾节点查找
     * 查找位置 index 如果小于节点数量的一半,
     */    
    if (index < (size >> 1)) {
        Node<E> x = first;
        // 循环向后查找,直至 i == index
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

linkedList.get(i) 随机访问的性能很差,这种方式每获取一个元素,LinkedList 都需要从头节点(或尾节点)进行遍历

ArrayList

构造时,如果不指定初始容量,默认的底层数组是一个{},存放元素时才进行判断扩容
自动扩容时,按照原大小的1.5倍扩容。没有自动的缩容机制,可以通过手动触发trimToSize来实现,能缩小至元素数量

fail-fast 快速失败机制

该机制被触发时,会抛出并发修改异常ConcurrentModificationException
创建迭代器后,除了迭代器的remove、add方法来修改集合之外,其他任意的修改,会触发快速失败机制
迭代器创建时记录当前集合的一个modCount,每次调用iter.next()时,都会检测当前集合的modCount与迭代器创建时记录的modCount是否一致,不一致则抛出ConcurrentModificationException 

List<String> a = new ArrayList<String>();
a.add("1");
a.add("2");
for (String temp : a) {
    System.out.println(temp);
    if("1".equals(temp)){
        a.remove(temp);
    }
}

 以上代码运行输出1,却没有输出2,也不会抛出异常。

foreach只是迭代器的语法糖,编译器转换后的代码是迭代器,所以代码可以转换为:

while (it.hasNext()) {
    String temp = it.next();
    System.out.println("temp: " + temp);
    if("1".equals(temp)){
        a.remove(temp);
    }
}

hasNext方法会判断当前的游标值cursor是否不等于外部集合的size
每次next调用时,cursor值都会变成当前元素索引 + 1,所以当以上1被删除之后,集合size就由2变成1了,cursor的值为1,与size相同,所以hasNext就返回false,循环就结束了,所以2没输出。

HashMap

HashMap 允许 null 键和 null 值,在计算哈键的哈希值时,null 键哈希值为 0。HashMap 并不保证键值对的顺序,非线程安全
JDK 1.8 中对HashMap的优化:引入了红黑树优化过长的链表、重写resize方法、移除了 alternative hashing 相关方法、避免重新计算键的 hash 等
数据结构 = 数组 + 链表/红黑树
构造方法中和ArrayList一样,并没有做实际的底层数据初始化,而是延迟到放入元素时才初始化

  • initialCapacity HashMap 初始容量,默认的初始容量为 1 << 4 16
  • loadFactor 负载因子,默认为0.75f,可以设置为 > 1。值太小,查询速度很快,但浪费很多空间,典型的空间换时间;太大,节省空间,但冲突概率高,链表长,查询速度慢,一般使用默认值
  • threshold 当前 HashMap 所能容纳键值对数量的最大值,超过这个值,则需扩容,这个值根据前两个值相乘得来

initialCapacity和loadFactor可以通过构造函数进行调整

找到大于或等于 cap 的最小2的幂:

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

  

查找流程

根据key的hash值进行快速求余,得到桶bucket的位置,然后对桶进行判断

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

 HashMap.Node类:

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

 底层数组为Node类型,红黑树节点为TreeNode类型,查找时会判断如果是TreeNode类型,则调用红黑树的查找方法,否则使用链表遍历查询,当key的hash(调用key.hashCode,如果key==null,)相同并且key相等时==比较或 equals比较,才认为找到了链表中的目标

do {
    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
        return e;
} while ((e = e.next) != null);

 计算hash的方法,说明允许key为null,此时的hash为0

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

上面有个抑或操作。回顾定位bucket时,使用求余,即只有hash的低位参与了运算,高位没参与。以上的抑或中,可以使高位与低位参与运算,以此加大低位信息的随机性,变相的让高位数据参与到计算中

关于遍历

一般都是对 HashMap 的 map.keySet()或 map.entrySet()进行遍历,多次对 HashMap 进行遍历时,遍历结果顺序都是一致的,但这个顺序和插入的顺序一般都是不一致的。
遍历策略:先从桶数组中找到包含链表节点引用的桶。然后对这个桶指向的链表进行遍历。遍历完成后,再继续寻找下一个包含链表节点引用的桶,找到继续遍历。找不到,则结束遍历

 插入数据

首先肯定是先定位要插入的键值对属于哪个桶,定位到桶后,再判断桶是否为空。如果为空,则将键值对存入即可。如果不为空,则需将键值对接在链表最后一个位置(1.8使用尾插法),或者更新键值对

public V put(K key, V value) {
    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;
    // 初始化桶数组 table,table 被延迟到插入新数据时再进行初始化
    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;
        // 如果键的值以及节点 hash 等于链表中的第一个键值对节点时,则将 e 指向该键值对
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
            
        // 如果桶中的引用类型为 TreeNode,则调用红黑树的插入方法
        else if (p instanceof TreeNode)  
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 对链表进行遍历,并统计链表长度。binCount理解为链表的元素的“索引”,第一个节点为0,下一个为1
            for (int binCount = 0; ; ++binCount) {
                // 链表中不包含要插入的键值对节点时,则将该节点接在链表的最后
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 如果链表长度大于或等于树化阈值,则进行树化操作
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                
                // 条件为 true,表示当前链表包含要插入的键值对,终止遍历
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        // 判断要插入的键值对是否存在 HashMap 中
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 键值对数量超过阈值时,则进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

  

扩容机制

在 HashMap 中,桶数组的长度均是2的幂,扩容阈值大小为桶数组长度与负载因子的乘积。当 HashMap 中的键值对数量超过阈值时,进行扩容。
按当前桶数组长度的2倍进行扩容,也就意味着扩容阈值也变为原来的2倍。数组的大小最大为2^30,超过这个长度不再进行扩容
扩容之后,要重新计算键值对的位置,并把它们移动到合适的位置上去。jdk8核心resize代码(移除了部分代码):能保留原次序

// 处理每个bucket
for (int j = 0; j < oldCap; ++j) {
    // 获取到链表头节点
    Node<K,V> e = oldTab[j];
    
    // 打算将一条链表划分为两条,需要以下4个指针指向
    Node<K,V> loHead = null, loTail = null;
    Node<K,V> hiHead = null, hiTail = null;
    Node<K,V> next;
    // 遍历链表,并将链表节点按原顺序进行分组
    do {
        next = e.next;
        // 分组的依据是 节点的hash & 旧数组长度 是否等于 0 ,是则分到lo链表,否则分到hi链表
        if ((e.hash & oldCap) == 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);
    // 将分组后的链表放到新的数组中
    // lo链表放在和原来旧数组相同的索引中,hi链表放在旧索引 + 旧数组长度对应的位置中
    if (loTail != null) {
        loTail.next = null;
        newTab[j] = loHead;
    }
    if (hiTail != null) {
        hiTail.next = null;
        newTab[j + oldCap] = hiHead;
    }
}

以上是通过数学规律推导出来的,如:17 % 5 = 2,扩容之后 17 % 10 = 7 = 5 + 2

LinkedHashMap

LinkedHashMap 在 HashMap 基础上,通过维护一条双向链表,解决了 HashMap 不能随时保持遍历顺序和插入顺序一致的问题。

节点类型:

static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

所谓的维护双向链表,就是在每个节点上加了前后节点的指向而已,从而记录了放入次序 

猜你喜欢

转载自www.cnblogs.com/hellohello/p/12222924.html