集合结构分析 学习笔记Day 1

69 阅读15分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 4 月更文挑战」的第 24 天,点击查看活动详情


集合回顾

Java中集合类是Java编程中使用最频繁、最方便的类。集合类作为容器类可以存储任何类型的数据,当然也可以结合泛型存储指定的类型(不过泛型仅仅在编译期有效,运行时是会被擦除的)。集合类中存储的仅仅是对象的引用,并不存储对象本身。集合类的容量可以在运行期间进行动态扩展,并且还提供很多很方便的方法,如求集合的并集、交集等。

Java中的集合包含多种数据结构,如链表、队列、哈希表等。从类的继承结构来说,可以分为两大类,一类是继承自Collection接口,这类集合包含List、Set和Queue等集合类。另一类是继承自Map接口,这主要包含了哈希表相关的集合类

List、Set和Queue

图片转存失败,建议将图片保存下来直接上传

图中的绿色的虚线代表实现,绿色实线代表接口之间的继承,蓝色实线代表类之间的继承。

List

我们用的比较多List包括ArrayList和LinkedList,这两者的区别也很明显,从其名称上就可以看出。ArrayList的底层的通过数组实现,所以其随机访问的速度比较快,但是对于需要频繁的增删的情况,效率就比较低了。而对于LinkedList,底层通过链表来实现,所以增删操作比较容易完成,但是对于随机访问的效率比较低。至于Vector,它是ArrayList的线程安全版本。

Queue

一般可以直接使用LinkedList完成,从上述类图也可以看出,LinkedList继承自Deque,所以LinkedList具有双端队列的功能。PriorityQueue的特点是为每个元素提供一个优先级,优先级高的元素会优先出队列(默认排序是自然排序,队头元素是最小元素,可以通过comparator比较器修改排序的比较方式)。

Set

Set与List的主要区别是Set是不允许元素重复的,而List则可以允许元素重复的。判断元素的重复需要根据对象的hash方法和equals方法来决定。这也是我们通常要为集合中的元素类重写hashCode方法和equals方法的原因。

对于集合中元素,hashCode值不同的元素一定不相等,但是不相等的元素,hashCode值可能相同。HashSet和LinkedHashSet的区别在于后者LinkedHashSet可以保证元素插入集合的元素顺序与输出顺序保持一致。而TreeSet的区别在于其排序是按照Comparator来进行排序的,默认情况下按照字符的自然顺序进行升序排列

常见集合比较

集合特点说明
List1. 有序,可重复2. 可以通过索引访问(下标)数组(ArrayList)或者链表(LinkedList)存储
Set1. 无序,不重复使用Map存储(HashSet - HashMap)
ArrayList1. 有序2. 随机访问快(下标)3. 插入,删除慢(移位)底层基于动态数组,内存连续,查询速度快,删除添加要移动数据,性能低下
LinkedList1. 有序2. 查询慢3. 插入,删除快双向链表,内存可以不连续,删除,添加快,查询慢
Vector1. 有序2.线程安全(synchronized)ArrayList的前任数组 , 同步锁保证安全性,性能低,不推荐用
PriorityQueue1. 优先队列2. 默认最小的在前面,按照自然顺序排序二叉小顶堆(二叉树)
TreeSet1. 可以保证元素排序(默认自然顺序,可定制)2. 是线程不安全3. 元素不重复(compareTo判断)4. 插入的元素必须实现Comparable接口5. 不允许放入null值基于TreeMap的KeySet存储数据,使用红黑树实现需要排序时使用TreeSet
HashSet1. 线程不安全2. 元素不重复(通过hashCode和equals方法判断)3. 元素是无序的4. 只能放入一个null基于 HashMap 的KeySet储存数据,使用Hash表实现 性能高于TreeSet,优先使用它
LinkedHasHSet extends HashSet1. 线程不安全2. 元素不重复3. 可以保证元素插入的顺序4. 迭代性能高于HashSet基于LinkHashMap存储数据,哈希表和双向链表
HashMap1. 非线程安全 2. 基于哈希表实现3. 通过hashcode实现元素快速查找4. 元素无序5. 可以出现一个null键,多个null值基于哈希表实现适用于在Map中插入、删除和定位元素
TreeMap1. 非线程安全2. 基于红黑树实现基于红黑树实现适用于按自然顺序或自定义顺序遍历键(key)
HashTable1. 线程安全的2. 性能低下3. 不能有null键和null值哈希表实现,线程安全,性能低,已经不用了,多线程使用concurrentHashMap

Iterable

从这个图里面可以看到Collection类继承自Iterable,该接口的作用是提供元素遍历的功能,也就是说所有的集合类(除Map相关的类)都提供元素遍历的功能。Iterable里面包含了Iterator的迭代器,其源码如下,大家如果熟悉迭代器模式的话,应该很容易理解。

图片转存失败,建议将图片保存下来直接上传

Comparable和Comparator

Comparable接口:

可比较的实现,该接口表示:这个类的实例可以比较大小,可以进行自然排序,定义了默认的比较规则,其实现类需要实现compareTo()方法,compareTo()方法返回正数表示大,负数表示小0表示相等,

Comparator接口

比较工具接口用于定义临时比较规则,而不是默认比较规则,其实现类需要实现compare()方法,Comparable和Comparator都是Java集合框架的成员

Map

图片转存失败,建议将图片保存下来直接上传

图中的绿色的虚线代表实现,绿色实线代表接口之间的继承,蓝色实线代表类之间的继承。

Map类型的集合最大的优点在于其查找效率比较高,理想情况下可以实现O(1)的时间复杂度。Map中最常用的是HashMap,LinkedHashMap与HashMap的区别在于前者LinkedHashMap能够保证插入集合的元素顺序与输出顺序一致。这两者与TreeMap的区别在于TreeMap是根据键值进行排序的,当然其底层的实现也有本质的区别,如HashMap底层是一个哈希表,而TreeMap的底层数据结构是一棵树

HashMap与TreeMap的区别,与之前提到的HashSet与TreeSet的区别是一致的, HashSet和TreeSet本质上分别是通过HashMap和TreeMap来实现的,所以它们的区别自然也是相同的。HashTable现在已经很少使用了,与HashMap的主要区别是HashTable是线程安全的,不过由于其效率比较低,所以通常使用HashMap,在多线程环境下,通常用CurrentHashMap来代替。

注:什么是时间复杂度?

HashMap底层原理分析

(以下分析仅为jdk1.8底层实现)

HashMap也是我们使用非常多的Map,它是基于哈希表的 Map 接口的实现,以key-value的形式存在。在HashMap中,key-value总是会当做一个整体来处理,系统会根据hash算法来来计算key-value的存储位置,我们总是可以通过key快速地存、取value。

HashMap定义

HashMap实现了Map接口,继承AbstractMap。其中Map接口定义了键映射到值的规则,而AbstractMap类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作,其实AbstractMap类已经实现了Map。

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    // 序列号
    private static final long serialVersionUID = 362498820763181265L;    
    // 默认的初始容量是16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;   
    // 最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30; 
    // 默认的负载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 当桶(bucket)上的结点数大于这个值时会转成红黑树
    static final int TREEIFY_THRESHOLD = 8; (jdk1.8以后才有)
    // 当桶(bucket)上的结点数小于这个值时树转链表
    static final int UNTREEIFY_THRESHOLD = 6;
    // 桶中结构转化为红黑树对应的table的最小大小
    static final int MIN_TREEIFY_CAPACITY = 64;
    // 存储元素的数组,总是2的幂次倍
    transient Node<k,v>[] table; 
    // 存放具体元素的集
    transient Set<map.entry<k,v>> entrySet;
    // 存放元素的个数,注意这个不等于数组的长度。
    transient int size;
    // 每次扩容和更改map结构的计数器
    transient int modCount;   
    // 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
    int threshold;
    // 负载因子
    final float loadFactor;
}

注:transient:java语言的关键字,变量修饰符,如果用transient声明一个实例变量,当对象存储时,它的值不需要维持。换句话来说就是,用transient关键字标记的成员变量不参与序列化过程。

HashMap底层结构示意图

Jdk1.8中,HashMap底层基于数组、链表、红黑树实现。示意图如下:

图片转存失败,建议将图片保存下来直接上传

红黑树:

红黑树(Red Black Tree) 是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。

特点:

  1. 每个节点要么是红色,要么是黑色。
  2. 根节点必须是黑色
  3. 红色节点不能连续(也即是,红色节点的孩子和父亲都不能是红色)。
  4. 对于每个节点,从该点至null(树尾端)的任何路径,都含有相同个数的黑色节点。

在树的结构发生改变时(插入或者删除操作),往往会破坏上述条件3或条件4,需要通过调整使得查找树重新满足红黑树的条件。如果有删除或者插入节点,使用左旋和右旋;

HashMap底层代码解析

HashMap提供了4个构造函数:

HashMap():构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。

HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。

HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和加载因子的空 HashMap。

HashMap(Map<? extends K, ? extends V> m):传入一个map以构造一个新的map,使用默认加载因子(0.75)。

在这里提到了两个参数:初始容量,加载因子。这两个参数是影响HashMap性能的重要参数,其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。

存值put(K key, V value)

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是否为空,如果为空,则使用扩容函数进行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //如果通过hash值取模得到的桶为空,则直接把新生成的节点放入该桶
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {//以下为该桶不为空的逻辑
        Node<K,V> e; K k;
        //判断桶的第一个元素的key值是否相同(hash值相同,且能equals)
        //如果相同,则返回当前元素(函数末尾进行统一处理)
        if (p.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 {//桶元素采用的是链表结构
            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;
                }
                //如果在遍历过程中,发现了key值相同,则返回当前元素(函数末尾进行统一处理)
                if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //处理相同元素的情况
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            //如果onlyIfAbsent为ture,则在oldValue为空时才替换
            //否则直接替换
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;//修改次数+1
    //map的size加1,然后判断是否达到了threshold,否则进行扩容
    //threshold由Node[] table的长度及loadFactor控制
    if (++size > threshold)
        resize();
    //执行回调函数
    afterNodeInsertion(evict);
    return null;
}

图片转存失败,建议将图片保存下来直接上传

  1. 判断当前桶是否为空,空的就需要初始化数组(resize() 中会判断是否进行初始化)。
  2. 根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
  3. 如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回。
  4. 如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
  5. 如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
  6. 接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
  7. 如果在遍历过程中找到 key 相同时直接退出遍历。
  8. 如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。
  9. 最后判断是否需要进行扩容。

取值 get(Object key)

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;
    //如果table不为空,则再进行查询操作
    if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
        //先检查第一个元素是否key相同
        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;
}

Get 方法看起来就要简单许多了。

  • 首先将 key hash 之后取得所定位的桶。
  • 如果桶为空则直接返回 null 。
  • 否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
  • 如果第一个不匹配,则判断它的下一个是红黑树还是链表。
  • 红黑树就按照树的查找方式返回值。
  • 不然就按照链表的方式遍历匹配返回值。

从这两个核心方法(get/put)可以看出 1.8 中对大链表做了优化,修改为红黑树之后查询效率直接提高到了 O(logn)。

遍历方式

HashMap 的遍历方式,通常有以下几种:

图片转存失败,建议将图片保存下来直接上传

强烈建议使用第一种 EntrySet 进行遍历。第一种可以把 key value 同时取出,第二种还得需要通过 key 取一次 value,效率较低。

总结

PUT的流程:

put过程是先计算hash然后通过hash与table.length取摸计算index值,然后将key放到table[index]位置,当table[index]已存在其它元素时,会在table[index]位置形成一个链表,将新添加的元素放在table[index],原来的元素通过Entry的next进行链接,这样以链表形式解决hash冲突问题,当元素数量达到临界值(capactiy_factor)时,则进行扩容,是table数组长度变为table.length_2;

Hash冲突:

如果通过hash运算计算出来的index值与数组中某个index值相同,说明此存储地址已经被其他元素所占有,这种现象称之为hash碰撞或hash冲突。 HashMap底层采用数组加链表加红黑树的方式,当存储地址index相同的时候,判断当前确定的索引位置是否存在相同hashcode和相同key的元素,如果存在相同的hashcode和相同的key的元素,那么新值覆盖原来的旧值,并返回旧值。

如果存在相同的hashcode,那么他们确定的索引位置就相同,这时判断他们的key是否相同,如果相同,这时就是产生了hash冲突。

Hash冲突后,那么HashMap的单个bucket里存储的不是一个 Entry,而是一个 Entry 链。

系统只能必须按顺序遍历每个 Entry,直到找到想搜索的 Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),那系统必须循环到最后才能找到该元素。

HashTable性能不高,HashMap性能不安全,多线程环境作何选择:

ConcurrentHashMap又安全有效率高. HashMap不安全,效率高,HashTable是安全,但是效率低

分段HashTable,每个HashTable就是都有一个锁,在访问这个HashTable时,其他的不影响.

面试题

  1. HashMap底层用到了那些数据结构?
  2. 为什么要用到链表结构?
  3. 为什么要用到红黑树?
  4. 链表和红黑树在什么情况下转换的?
  5. HashMap在什么情况下扩容?HashMap如何扩容的?
  6. HashMap是如何Put一个元素的?
  7. HashMap是如何Get一个元素的?
  8. 什么是Hash冲突
  9. HashMap是如何解决Hash冲突的?
  10. 还有哪些解决Hash冲突的方式?

此文章为5月Day3学习笔记,内容来源于极客时间《10小时吃透MySQL底层原理》