深入集合类(常用的集合类有哪些?比如List如何排序?集合的安全?)

深入集合类(常用的集合类有哪些/比如List如何排序/集合的安全)

1、collections框架(包括列表list,queue队列,set集合,stack栈,map键值对)

提供排序,查找,反转,替换,复制,取最小,最大元素等功能

1.1、set 元素不能重复,使用equals确保对象一致性—>实现类hashset treeset(有序)

只能通过迭代器(Iterator)来遍历元素
方法:add、contains、remove、clear

特点
1、HashSet类 是Set接口的一个子类,无序集合,采用hash存储,所以没有顺序,依赖HashMap来实现,可null;HashSet的数据存储在HashMap的map中,对应于map中的key
2、TreeSet类 TreeSet实现SortedSet接口,有序集合;依赖 TreeMap来实现//插入一个子集,默认升序;不可null

向TreeSet中插入数据

			1、ArrayList<Integer> list = new ArrayList<Integer>(); list.add(300);   list.add(120);
			2、new TreeSet<Integer>().addAll(list);

1.2、list 有序 按进入的顺序存储,元素可以重复

实现类
1、linkedlist(双向链表,查找慢,插入快,不安全)
2、arraylist(基于数组,查找快,插入慢,线程不安全)
3、Stack 类: Stack继承自Vector,实现一个后进先出的堆栈(基于数组,安全)

可以看到 ArrayList、Vector、LinkedList 集合类继承了 AbstractList 抽象类,而 AbstractList 实现了 List 接口,同时也继承了 AbstractCollection 抽象类。ArrayList、Vector、LinkedList 又根据自我定位,分别实现了各自的功能。ArrayList 和 Vector 使用了数组实现,这两者的实现原理差不多,LinkedList 使用了双向链表实现
在这里插入图片描述

Q1:关于ArrayyList的三道面试题?(20191004补充)

id 题目 答案
1 在查看 ArrayList 的实现类源码时,发现对象数组 elementData 使用了 transient 修饰,我们知道 transient 关键字修饰该属性,则表示该属性不会被序列化,然而我们并没有看到文档中说明 ArrayList 不能被序列化,这是为什么? 使用 transient 修饰数组,是防止对象数组被其他外部方法序列化。ArrayList 为了避免这些没有存储数据的内存空间被序列化,内部提供了两个私有方法 writeObject 以及 readObject 来自我完成序列化与反序列化,从而在序列化与反序列化数组时节省了空间和时间
2 在使用 ArrayList 进行新增、删除时,经常被提醒“使用 ArrayList 做新增删除操作会影响效率”。那是不是 ArrayList 在大量新增元素的场景下效率就一定会变慢呢? 如果在初始化时就比较清楚存储数据的大小,就可以在 ArrayList 初始化时指定数组容量大小,并且在添加元素时,只在数组末尾添加元素,那么 ArrayList 在大量新增元素的场景下,性能并不会变差,反而比其他 List 集合的性能要好
3 如果让你使用 for 循环以及迭代循环遍历一个 ArrayList,你会使用哪种方式呢?原因是什么? 由于 ArrayList 是基于数组实现的,所以在获取元素的时候是非常快捷的

Q2:ArrayList和LinkedList内部的实现大致是怎样的?他们之间的区别和优缺点?看过源码!!!(掌握到源码级别,蚂蚁**)
1.2.1、Arraylist解析:
基于动态数组的数据结构,查找快,插入慢,每次增删元素顺序都会操作每个元素,线程不安全 初始10,扩容步长0.5,数组的复制)
分别分析 ArrayList 的构造、add、remove、clear 方法的实现原理

  • 数据结构

    // 默认初始化容量
    private static final int DEFAULT_CAPACITY = 10;
    // 对象数组
    transient Object[] elementData;
    // 数组长度
    private int size;

  • 构造函数

    空参构造:array是一个Object[]类型,当我们new一个空参ArrayList的时候,系统内部使用了一个new Object[0]数组。
    带参构造1:该构造函数传入一个int值,该值作为数组的长度值
    带参构造2:调用构造函数的时候传入了一个Collection的子类

  • add方法 (两个重载:一种是直接将元素加到数组的末尾,另外一种是添加元素到任意位置)

    1、首先将成员变量array赋值给局部变量a,将成员变量size赋值给局部变量s。
    2、判断集合的长度s是否等于数组的长度,重新分配数组的时候需要计算新分配内存的空间大小
    3、将新添加的object对象作为数组的a[s]个元素
    4、修改集合长度size为s+1
    5、modCotun++
    6、return true

  • remove方法(两个重载)

    1、先将成员变量array和size赋值给局部变量a和s
    2、判断形参index是否大于等于集合的长度,如果成了则抛出运行时异常
    3、获取数组中脚标为index的对象result,该对象作为方法的返回值
    4、调用System的arraycopy函数,集合整体向前移动了一位
    5、最后一个元素设置为null
    6、重新给成员变量array和size赋值

  • clear方法

    如果集合长度不等于0,则将所有数组的值都设置为null,然后将成员变量size 设置为0即可,最后让修改记录数加1

1.2.2、linkedlist解析:
基于双向链表的数据结构,有一个内部类作为存放元素的单元,里面有三个属性,用来存放元素本身以及前后2个单元的引用(查找慢,会遍历大量元素,插入快,不安全)

  • 数据结构(LinkedList 定义了一个 Node 结构,Node 结构中包含了 3 个部分:元素内容 item、前指针 prev 以及后指针 next)
private static class Node<E> {
        E item;
        Node<E> next;
        Node<E> prev;
 
        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }
  • LinkedList 实现类

    LinkedList 类实现了 List 接口、Deque 接口,同时继承了 AbstractSequentialList抽象类, LinkedList 既实现了 List 类型又有 Queue 类型的特点 ;LinkedList 也实现了 Cloneable 和 Serializable 接口,同 ArrayList 一样,可以实现克隆和序列化

  • LinkedList 属性(可以看到这三个属性都被 transient 修饰了,原因很简单,我们在序列化的时候不会只对头尾进行序列化,所以 LinkedList 也是自行实现 readObject 和 writeObject 进行序列化与反序列化)
transient int size = 0;
transient Node<E> first;
transient Node<E> last;
  • LinkedList 遍历元素

    LinkedList 的获取元素操作实现跟 LinkedList 的删除元素操作基本类似,通过分前后半段来循环查找到对应的元素。但是通过这种方式来查询元素是非常低效的,特别是在 for 循环遍历的情况下,每一次循环都会去遍历半个 List。所以在 LinkedList 循环遍历时,我们可以使用 iterator 方式迭代循环,直接拿到我们的元素,而不需要通过循环查找 List

1.2.3、总结:

  • 1.对ArrayList和LinkedList而言,在列表末尾增加一个元素所花的开销都是固定的。

    对ArrayList而言,主要是在内部数组中增加一项,指向所添加的元素,偶尔可能会导致对数组重新进行分配;
    对LinkedList而言,这个开销是统一的,分配一个内部Entry对象

  • 2、在 ArrayList的中间插入或删除一个元素意味着这个列表中剩余的元素都会被移动;而在LinkedList的中间插入或删除一个元素的开销是固定的。

  • 3、LinkedList不支持高效的随机元素访问

  • 4、ArrayList的空间浪费主要体现在在list列表的结尾预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗相当的空间

1.2.4、结论:

性能测试 项目 结果 分析
1.ArrayList 和 LinkedList 新增元素操作测试 1、从集合头部位置新增元素,2、从集合中间位置新增元素,3、从集合尾部位置新增元素 ArrayList>LinkedList,ArrayList<LinkedList,ArrayList<LinkedList 2、ArrayList 在添加元素到数组中间时,同样有部分数据需要复制重排,效率也不是很高;LinkedList 将元素添加到中间位置,是添加元素最低效率的,因为靠近中间位置,在添加元素之前的循环查找是遍历元素最多的操作,3、 LinkedList 中多了 new 对象以及变换指针指向对象的过程,所以效率要低于 ArrayList
ArrayList 和 LinkedList 删除元素操作测试 1、从集合头部位置删除元素2、从集合中间位置删除元素3、从集合尾部位置删除元素 ArrayList>LinkedList,ArrayList<LinkedList,ArrayList<LinkedList 原因同上
3.ArrayList 和 LinkedList 遍历元素操作测试 1、for(;;) 循环2、迭代器迭代循环 ArrayList<LinkedList,ArrayList≈LinkedList 因为 LinkedList 基于链表实现的,在使用 for 循环的时候,每一次 for 循环都会去遍历半个 List,所以严重影响了遍历的效率
  • 1、当操作是在一列数据的后面添加数据而不是在前面或中间,并且需要随机地访问其中的元素时,使用ArrayList会提供比较好的性能;

  • 2、当你的操作是在一列数据的前面或中间添加或删除数据,并且按照顺序访问其中的元素时,就应该使用LinkedList了

1.2.5、数组和arraylist的区别:

  • 1、数组可以包含基本类型和对象类型,arraylist只能包含对象类型

  • 2、数组大小固定,arraylist大小可以动态变化

1.2.6、Arraylist与LinkedList区别

Arraylist优点:基于动态数组的数据结构,因为地址连续,查询操作效率会比较高 缺点:插入和删除操作效率比较低
LinkedList优点:基于链表的数据结构,地址是任意的,新增和删除操作占优势,适用于要头尾操作或插入指定位置 缺点:查询操作性能比较低

1.3、map 键值对 键唯一,值可重复。

实现类 简介
hashmap 基于散列表,使用对象的hash值可以快速查询
hashtable 同步的,线程安全的键值对,效率低
TreeMap 根据key值进行升序排序的键值对;使用红黑树实现,按序排序
synchronizedMap() 实现同步控制的键值对,一个静态内部类,实现了Map接口

1.3.1、hashmap详解(基于散列表,使用对象的hash值可以快速查询,允许空键值,containskey,containsvalue不安全 默认16 *1.5 Iterator),

HashMap实现原理,如何保证HashMap的线程安全?
JDK7:位桶(Hash表)+链表的方式
JDK8:位桶(Hash表)+链表/红黑树

从 HashMap 的源码中,我们可以发现,HashMap 是由一个 Node 数组构成,每个 Node 包含了一个 key-value 键值对。 (20191004补充)

transient Node<K,V>[] table;

Node 类作为 HashMap 中的一个内部类,除了 key、value 两个属性外,还定义了一个 next 指针。当有哈希冲突时,HashMap 会用之前数组当中相同哈希值对应存储的 Node 对象,通过指针指向新增的相同哈希值的 Node 对象的引用。

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

HashMap 还有两个重要的属性:加载因子(loadFactor)和边界值(threshold)。在初始化 HashMap 时,就会涉及到这两个关键初始化参数。LoadFactor 属性是用来间接设置 Entry 数组(哈希表)的内存空间大小,在初始 HashMap 不设置参数的情况下,默认 LoadFactor 值为 0.75

int threshold;
final float loadFactor;

1、初始容量表示哈希表的长度,初始是16
2、哈希表中的条目数超出了加载因子0.75与当前容量的乘积时,则要对该哈希表进行 resize 操作
3、加入键值对时,先判断当前已用数组长度是否大于等于阀值,如果大于等于,则进行扩容,容量扩为原容量2倍

hashmap的数据结构?
在这里插入图片描述

  • hashmap的特点?

    1、HashMap处理hash冲突时,会首先存放在链表中去,链表的长度达到阀值8,链表就将转换成红黑树;小于6,转链表,优化存储速度。O(lgn)
    2、HashMap底层维护一个数组,数组中的存储Entry对象组成的链表
    3、Map中的key,value则以Entry的形式存放在数组中,通过key的hashCode计算
    4、map m = collections.synchronizedMap(new hashmap()),返回同步的map,有hashmap所有的方法
    5、concurrenthashmap 系统自带的线程安全的HashMap

  • ConcurrentHashMap和synchronizedMap两者区别:

    1、ConcurrentHashMap的实现更加精细,在性能以及安全性方面更优
    同步操作精确控制到node,其他线程,仍然可以对node执行某些操作
    多个读操作几乎总可以并发地执行
    例如:在遍历map时,其他线程试图对map进行数据修改,不会抛出ConcurrentModificationException***
    2.synchronizedMap()可以接收任意Map实例,实现Map的同步,如TreeMap实现排序,Map<String, Object> map2 = Collections.synchronizedMap(new TreeMap<String, Object>());
    而ConcurrentHashMap只能是HashMap

1.3.2、讲一下hashmap中put方法过程?

HashMap 添加元素优化:初始化完成后,HashMap 就可以使用 put() 方法添加键值对了。从下面源码可以看出,当程序将一个 key-value 对添加到 HashMap 中,程序首先会根据该 key 的 hashCode() 返回值,再通过 hash() 方法计算出 hash 值,再通过 putVal 方法中的 (n - 1) & hash 决定该 Node 的存储位置。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

hash函数是怎么实现的? ****

1、散列算法 hash&(length-1)
2、hash的高16bit和低16bit做了一个异或
3、(n-1)&hash 得到下标
4、使用&代替取模,实现了均匀的散列,但效率要高很多,与运算比取模的效率高(由于计算机组成原理)

if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
    // 通过 putVal 方法中的 (n - 1) & hash 决定该 Node 的存储位置
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

1、对key求hash值,然后再计算下标
2、如果没有碰撞,直接放入桶中;(key相同,替换value值)
3、如果碰撞了,以链表的方式链接到后面(key不同,用链表存)
4、如果链表长度超过阈值8,就把链表转成红黑树
5、如果节点已经存在就替换旧值;
6、如果桶满了(容量*加载因子),就需要resize
在这里插入图片描述

  • HashMap中put操作的源码?
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
    Node<K,V>[] tab;
    Node<K,V> p;
    int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
    //1、判断当table为null或者tab的长度为0时,即table尚未初始化,此时通过 resize() 方法得到初始化的 table
    n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
    //1.1、此处通过(n - 1) & hash 计算出的值作为 tab 的下标 i,并另 p 表示 tab[i],也就是该链表第一个节点的位置。并判断 p 是否为 null
   		tab[i] = newNode(hash, key, value, null);
    	//1.1.1、当 p 为 null 时,表明 tab[i] 上没有任何元素,那么接下来就 new 第一个 Node 节点,调用 newNode 方法返回新节点赋值给 tab[i]
    else {
		//2.1 下面进入 p 不为 null 的情况,有三种情况:p 为链表节点;p 为红黑树节点;p 是链表节点但长度为临界长度 TREEIFY_THRESHOLD,再插入任何元素就要变成红黑树了。
        Node<K,V> e;
        K k;
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
		//2.1.1HashMap 中判断 key 相同的条件是 key 的 hash 相同,并且符合 equals 方法。这里判断了 p.key 是否和插入的 key 相等,如果相等,则将 p 的引用赋给 e
            e = p;
        else if (p instanceof TreeNode)
			//2.1.2 现在开始了第一种情况,p 是红黑树节点,那么肯定插入后仍然是红黑树节点,所以我们直接强制转型 p 后调用 TreeNode.putTreeVal 方法,返回的引用赋给 e
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
			//2.1.3 接下里就是 p 为链表节点的情形,也就是上述说的另外两类情况:插入后还是链表 / 插入后转红黑树。另外,上行转型代码也说明了 TreeNode 是 Node 的一个子类
            for (int binCount = 0; ; ++binCount) {
				// 我们需要一个计数器来计算当前链表的元素个数,并遍历链表,binCount 就是这个计数器
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) 
						// 插入成功后,要判断是否需要转换为红黑树,因为插入后链表长度加 1,而 binCount 并不包含新节点,所以判断时要将临界阈值减 1
                        treeifyBin(tab, hash);
					// 当新长度满足转换条件时,调用 treeifyBin 方法,将该链表转换为红黑树
                    break;
                }
                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;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

1.3.3、为什么哈希表的容量一定要是2的整数次幂?

第二种问法:实际应用中,我们设置初始容量,一般得是 2 的整数次幂。你知道原因吗?(面试题:2的幂次方减1后每一位都是1,让数组每一个位置都能添加到元素。减少哈希冲突,均匀分布元素

h&(length-1),散列的均匀、空间利用充足
1、2的整数次幂为偶数,length-1为奇数,最后一位是1,保证h&(length-1)的最后一位可能为0或1,保证散列的均匀性,且空间利用充足
2、length为奇数的话,length-1为偶数,最后一位是0,h&(length-1)的最后一位为0,浪费了近一半的空间
3、h是key的hashcode的高16位和低16位的异或运算,之所以异或,是因为生成0/1的概率相等

1.3.4、hashmap怎样解决冲突、讲一下扩容过程,假设一个值在原数组中,现在移动了新数组,位置肯定变了,那么怎么定位到这个值在新数组中的位置?

新建一个HashMap的底层数组,而后调用transfer方法,将就HashMap的全部元素添加到新HashMap
要重新计算元素在新的数组中的索引位置

方式 细节
在 JDK1.7 中 HashMap 整个扩容过程就是分别取出数组元素,一般该元素是最后一个放入链表中的元素,然后遍历以该元素为头的单向链表元素,依据每个被遍历元素的 hash 值计算其在新数组中的下标,然后进行交换。这样的扩容方式会将原来哈希冲突的单向链表尾部变成扩容后单向链表的头部。
在 JDK 1.8 中 HashMap 对扩容操作做了优化。由于扩容数组的长度是 2 倍关系,所以对于假设初始 tableSize = 4 要扩容到 8 来说就是 0100 到 1000 的变化(左移一位就是 2 倍),在扩容中只用判断原来的 hash 值和左移动的一位(newtable 的值)按位与操作是 0 或 1 就行,0 的话索引不变,1 的话索引变成原索引加上扩容前数组。

1.3.5、子类:

1、LinkedHashMap维护了插入的先后顺序【FIFO】,适合LRU算法做缓存(最近最少使用)
2、WeakHashMap是改进的HashMap,它对key实行“弱引用”,如果一个key不再被外部所引用,那么该key可以被GC回收

1.3.6、hash冲突如何解决?

方式有很多,比如,开放定址法、再哈希函数法和链地址法

方法 细节 是否推荐
开放定址法 当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把 key 存放到冲突位置的空位置上去。这种方法存在着很多缺点,例如,查找、扩容等,所以我不建议你作为解决哈希冲突的首选。
再哈希法 在同义词产生地址冲突时再计算另一个哈希函数地址,直到冲突不再发生,这种方法不易产生“聚集”,但却增加了计算时间
链地址法 先找到下标i,KEY值找Entry对象,新值存放在数组中,旧值在新值的链表上,将存放在数组中的Entry设置为新值的next 推荐

1.3.7、get操作:

对key进行null检查:如果key是null,返回table[0]位置元素
key的hashcode()方法被调用,然后计算hash值
indexFor(hash,table.length)用来计算要获取的Entry对象在table数组中的精确的位置
遍历链表,调用equals()方法检查key相等性,如果equals()方法返回true,get方法返回Entry对象的value,否则,返回null

性能上:在链表长度过长的情况下,性能将明显降低,红黑树的使用很好地解决了这个问题,使得查询的平均复杂度降低到了 O(log(n)),链表越长,使用黑红树替换后的查询效率提升就越明显。

1.3.8、hashmap从1.7到1.8,新元素为什么要从头部调整到尾部呢?

id 原因
1、 为了防止死循环(hashmap扩容导致死循环的问题。后续再整理)
2、 JDK1.7是考虑新增数据大多数是热点数据,所以考虑放在链表头位置,也就是数组中,这样可以提高查询效率,但这种方式会出现插入数据是逆序的。在JDK1.8开始hashmap链表在节点长度达到8之后会变成红黑树,这样一来在数组后节点长度不断增加时,遍历一次的次数就会少很多,相比头插法而言,尾插法操作额外的遍历消耗已经小很多了。

1.3.9 hashmap的put和get的时间复杂度算多少啊?

hashmap的最优时间复杂度是O(1),而最坏时间复杂度是O(n)
在没有产生hash冲突的情况下,查询和插入的时间复杂度是O(1);
而产生hash冲突的情况下,如果是最终插入到链表,链表的插入时间复杂度为O(1),而查询的时候,时间复杂度为O(n);
在产生hash冲突的情况下,如果最终插入的是红黑树,插入和查询的平均时间复杂度是O(logn)。

2、Iterator:遍历集合

next方法返回第一个元素,hasNext判断容器是否还有元素,remove删除元素,遍历时对容器进行增加,删除操作会导致并发修改异常,
解决方法:把要删除的对象保存到一个集合中,遍历结束调用removeAll方法,或是Iterator.remove方法。
多线程中:并发异常如何避免:concurrenthashmap,copyonwritearraylist,线程安全;或是迭代器遍历时,放在synchronized代码块中,性能有影响。
子类 listIterator:继承自Iterator,可以双向遍历,支持元素的修改

3、collection集合接口 实现接口的类list和set

4、collections包装类,提供一系列静态方法实现集合的搜索,排序,线程安全化。

4.1、比如List如何排序?

使用集合工具包的collections.sort()方法排序,可以重写里面的compare方法

4.2、集合的安全?(在并发编程中常用)

Collections工具类提供了相关的API
Collections.synchronizedList(list)
Collections.synchronizedCollection©
Collections.synchronizedMap(m)

5、 容器中的设计模式?

5.1、迭代器模式

Collection实现了Iterable接口,其中的iterator()方法能够产生一个Iterator对象,通过这个对象就可以迭代遍历Collection中的元素
从JDK1.5之后可以使用foreach方法来遍历实现了Iterable接口的聚合对象

List list = new ArrayList<>();
list.add(“a”);list.add(“b”);
for(String item:list){syso(item);}

5.2、适配器模式

java.util.Arrays#asList()可以把数组类型转换为List类型
应该注意的是asList()的参数为泛型的变长参数,不能使用基本类型数组作为参数,只能使用相应的包装类型数组

Integer[] arr = {1, 2, 3}; List list = Arrays.asList(arr);//数组转为list
List list= Arrays.asList(1,2,3);

发布了26 篇原创文章 · 获赞 18 · 访问量 9754

猜你喜欢

转载自blog.csdn.net/qq_28959087/article/details/85419247