Java集合总结

2021/5/8 1:25:13

本文主要是介绍Java集合总结,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

前言

最近复习了一下数据结构,对数据结构有了更深了解,回头再来看一下集合相关知识就感觉豁然开朗,面试中集合也是必考题,便有了这篇集合总结,其中HashMap(包括部分源码分析)篇幅大概有6000+字,希望大家能耐心看完,看完后多少都会有一些收获。

数据结构

先来简单复习一下集合相关的数据结构。
一、数据结构的分类:
1.数据结构包括:逻辑结构和物理结构,其中逻辑结构稍复杂一些;
2.逻辑结构包括:线性结构(如 顺序表、栈、队列)和非线性结构(如 树、图);
3.物理结构包括:顺序存储结构(如 数组)、链式存储结构(如 链表)。

二、集合数据结构:
1. 数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n);
2.链表:对于链表的新增,删除等操作,仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n);
3.二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn);
4.哈希表:在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)。

数据结构本篇不做详细说明,后续会有专题写相关的数据结构及算法。

集合框架图

Java 集合,也称作容器就是用来存放数据的,主要是由两大接口 派生出来的Collection 和 Map,具体请看下图:

Collection

collection主要存储单值类型的数据,主要的子接口有 list、set和queue(队列本篇不做介绍)。
操作集合,无非就是「增删改查」四大类,也叫 CRUD,这里对集合API就不做介绍了。

List

主要特点是 元素有序且元素可重复。

面试题:ArrayList、LinkList和Vector的区别?

ArrayList 底层是数组,查询效率是O(1) ,新增和删除效率是O(n),查询效率高,线程不安全;
LinkList 底层是双向链表,查询效率是O(n),新增和删除效率是O(1),新增和删除效率高,线程不安全;
Vector 底层是数组结构,属于线程安全集合,使用频率较低。

Set

主要特点是 元素无序且元素不可重复。

面试题:HashSet和TreeSet的区别?

HashSet :
1. 数据结构 底层是HashMap实现;
2. 顺序性 不能保证元素的排列顺;
3. null元素 元素可以为null,但只能存放一个null元素;
4. 时间复杂度 add(),remove(),contains()方法的时间复杂度是O(1)。
TreeSet:
1. 数据结构 底层是treeMap(红黑树)实现;
2. 顺序性 元素是自动排好序的;
3. null元素 不能存放null元素;
4. 时间复杂度 add(),remove(),contains()方法的时间复杂度是O(logn)。

Map

主要存放键值对数据,本篇会重点介绍面试率极高的hashmap。

HashMap

JDK1.7 Hashmap由数组和链表组成,JDK1.8做了主要做了2处有优化: 1. 数据结构变成了数组、链表和红黑树,解决是链表太长查询效率低问题;2. 在哈希冲突时头插法变成了尾插法,解决是在hashmap扩容时形成环形链表问题。
以下篇幅介绍的都是JDK1.8的hashmap。

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

HashMap数据结构

JDK 1.8 hashMap的数据结构图:
在这里插入图片描述

扩容条件

先看一下hashMap源码中几个关键的字段:

/**实际存储的key-value键值对的个数*/
transient int size;

/**阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,
threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到*/
int threshold;

/**负载因子,代表了table的填充度有多少,默认是0.75
加载因子存在的原因,还是因为减缓哈希冲突,如果初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了。
所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32。
*/
final float loadFactor;

/**HashMap被改变的次数,由于HashMap非线程安全,在对HashMap进行迭代时,
如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),
需要抛出异常ConcurrentModificationException*/
transient int modCount;

hashMap条件:

  1. 当集合容量超过了阈值(threshold)就会进行扩容;
  2. 当链表长度>=8且数组长度小于64时,也会扩容。
    /**
     * Replaces all linked nodes in bin at index for given hash unless
     * table is too small, in which case resizes instead.
     * 新增数据时链表长度大于8时会进行树化
     */
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        //树化时 判断 数组为空 或 数组长度 < MIN_TREEIFY_CAPACITY =64 则直接扩容
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

扩容机制

  1. 扩容:创建一个新的Entry空数组,长度是原数组的2倍;
	//hashmap扩容方法
    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        //原数组不为空 则原数组容量为 table.length
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //原数组阈值
        int oldThr = threshold;
        //新数组 容量和阈值 都默认为 0
        int newCap, newThr = 0;
        //原数组容量大于0
        if (oldCap > 0) {
        	//原数组容量 大于等于 最大容量 MAXIMUM_CAPACITY = 1 << 30 则直接返回
            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
        }
        ...
  1. 计算hash:index = HashCode(Key) & (Length - 1);
  2. ReHash: 由于新数组的长度变了,则需要遍历原Entry数组,重新计算hash把所有的Entry重新Hash到新数组。

小结: 可见在用hashMap时最好先计算好hashMap的容量,初始化带上容量大小,毕竟扩容是非常消耗性能的。

树化条件

树化必须同时满足2个条件:

  1. 链表长度>=8 (binCount >=7 因为binCount 是从0开始算) ;
				//binCount 从0开始自增
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //当 binCount == 7时 则树化 并跳出循环
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
  1. 数组长度>=64(具体看以上treeifyBin源码分析);

数据查询

 /**
     *
     * @param hash  需要被获取元素的hash值
     * @param key 需要被获取的元素
     * @return 返回被需要到的元素,没有获取到则返回null
     */
    final HashMap.Node<K, V> getNode(int hash, Object key) {
        //临时变量储存table数组
        HashMap.Node[] tab;
        //临时变量获取第一个元素
        HashMap.Node first;
        //n为table的长度
        int n;
        // first = tab[n - 1 & hash]) 计算数组下标 获取到数组上第一个node 且 node的key就是要查询key 则直接返回 node数组
        if ((tab = this.table) != null && (n = tab.length) > 0 && (first = tab[n - 1 & hash]) != null) {
            Object k;
            //first元素存在,切first元素即锁需要查找的元素,直接返回first.
            if (first.hash == hash && ((k = first.key) == key || key != null && key.equals(k))) {
                return first;
            }
            //数组上的node不是查询目标且指向下一个node不为空
            HashMap.Node e;
            if ((e = first.next) != null) {
                //判断node是否为红黑树
                if (first instanceof HashMap.TreeNode) {
                	//按照红黑树方式去遍历
                    return ((HashMap.TreeNode)first).getTreeNode(hash, key);
                }
                //如果链表没有被树化,则使用链表的方式查询.
                do {
                    //循环判断当前的临时变量e是否与所需元素相同
                    if (e.hash == hash && ((k = e.key) == key || key != null && key.equals(k))) {
                        return e;
                    }
                } while((e = e.next) != null);
            }
        }
 
        return null;
    }

通过分析源码查询过程可以分为3部分:

  1. 计算hashCode 获取到数组上的node,且node.key==key则数组上的就是就是查询目标;
  2. 是链表结构则按照链表方式遍历查询目标;
  3. 是红黑树结构则按照红黑树方式遍历查询目标。

数据存储

先上源码:

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为空,则进行必要字段的初始化 默认为16
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;    
        // 如果根据hash值获取的node为空,则直接新增
        if ((p = tab[i = (n - 1) & hash]) == null)     
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            // 如果新插入的结点和table中p结点的hash值,key值相同的话
            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 {
            	//如果不是红黑树则为链表按照链表方式插入 binCount从0开始自增
                for (int binCount = 0; ; ++binCount) {
                    // 代表这个单链表只有一个头部结点,则直接新建一个结点即可
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 当binCount>=7 即 链表长度>=8时,将链表转红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    // 及时更新p
                    p = e;
                }
            }
            // 如果存在这个映射就覆盖
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                // 判断是否允许覆盖,并且value是否为空
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                // 回调以允许LinkedHashMap后置操作
                afterNodeAccess(e);     
                return oldValue;
            }
        }
        ++modCount;     
        // 如果容量超过阈值则进行扩容
        if (++size > threshold)     
            resize();  
        // 回调以允许LinkedHashMap后置操作
        afterNodeInsertion(evict);
        return null;
    }

从以上源码分析hashMap数据存储也可简单分为4部分:

  1. 在数组上计算hashCode的node为空则直接新增;
  2. 获取的node不为空,如果是红黑树就按照红黑树方式插入;
  3. 获取的node不为空,如果是链表则按照链表方式插入(会触发树化或扩容操作);
  4. 新增成功后,还需要判断数组容量是否有超过阈值,超过则需要扩容。

面试题:HashMap负载因子是多少?为什是这么多?

默认为0.75,这个是在时间和空间之间平衡的一个数值,负载因子是可以自定义的(不推荐)

面试题:HashMap和HashTable区别

  1. 初始化容量 HashMap初始容量为16,HashTable初始容量为11(负载因子都相同);
  2. 线程安全 HashMap为非线程安全,HashTable为线程安全(get和put方法都用synchronized修饰,效率较差);
  3. 扩容容量 HashMap扩容时容量:capacity2,HashTable扩容时容量:capacity2+1
  4. 遍历方式 HashMap仅支持Iterator的遍历方式,Hashtable支持Iterator和Enumeration两种遍历方式;
  5. null值存储 HashMap中key和value都允许为null,HashTable在遇到null时,会抛出NullPointerException异常。

ps:一般问到hashMap就一定会问到currentHashMap,由于篇幅较长了下次会单独写一篇currentHashMap。

总结思维导图

在这里插入图片描述
最后奉上自己总结集合的思维导图,希望能帮助大家。

既然都看到最后了请帮忙一键三连哦!



这篇关于Java集合总结的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程