Java-集合总结(面试向)

Collection

Collection接口子接口:List接口,Set接口,Queue接口

List

List接口继承自Collection接口,主要实现类有:ArrayList、LinkedList、Vector以及Stack等。

1. ArrayList

集合数据存储的结构是动态数组结构。元素查找快,增删慢

2. Vector

集合数据存储的结构是动态数组结构。线程安全,效率低

  • ArrayList是新版的动态数组,线程不安全,效率高,Vector是旧版的动态数组,线程安全,效率低。
  • 动态数组的扩容机制不同,ArrayList扩容为原来的1.5倍,Vector扩容增加为原来的2倍。
  • 数组的初始化容量,如果在构建ArrayList与Vector的集合对象时,没有显式指定初始化容量,那么Vector的内部数组的初始容量默认为10,而ArrayList在JDK1.6及之前的版本也是10,而JDK1.7之后的版本ArrayList初始化为长度为0的空数组,之后在添加第一个元素时,再创建长度为10的数组。
  • Vector因为版本古老,支持Enumeration 迭代器。但是该迭代器不支持快速失败。而Iterator和ListIterator迭代器支持快速失败。如果在迭代器创建后的任意时间从结构上修改了向量(通过迭代器自身的 remove 或 add 方法之外的任何其他方式),则迭代器将抛出 ConcurrentModificationException。因此,面对并发的修改,迭代器很快就完全失败,而不是冒着在将来不确定的时间任意发生不确定行为的风险。
     

3. LinkedList

集合数据存储的结构是链表结构。方便元素添加、删除的集合。

LinkedList是一个双向链表

 JDK1.6之后LinkedList实现了Deque接口。双端队列也可用作 LIFO(后进先出)堆栈。如果要使用堆栈结构的集合,可以考虑使用LinkedList,而不是Stack。

4. Stack

Stack与LinkedList都能作为栈结构,对外表现的功能效果是一样,但是它们的物理结构不同,Stack的物理结构是顺序结构的数组,而LinkedList的物理结构是链式结构的双向链表。我们推荐使用LinkedList。

Set

Set接口继承自Collection的子接口,主要的实现类有:HashSet、TreeSet、LinkedHashSet等。

1. HashSet

HashSet底层的实现是一个java.util.HashMap,然后HashMap的底层物理实现是一个Hash表。

HashSet 按 Hash 算法来存储集合中的元素,因此具有很好的存取和查找性能。HashSet 集合判断两个元素相等的标准:两个对象通过 hashCode() 方法比较相等,并且两个对象的 equals() 方法返回值也相等。因此,存储到HashSet的元素要重写hashCode和equals方法。

HashSet存储的元素无序,不重复,可存储null值。

2. LinkedHashSet

LinkedHashSet是HashSet的子类,它在HashSet的基础上,在结点中增加两个属性before和after维护了结点的前后添加顺序。java.util.LinkedHashSet,它是链表+哈希表组合的一个数据存储结构。LinkedHashSet插入性能略低于 HashSet,但在迭代访问 Set 里的全部元素时有很好的性能。

LinkedHashSet内部维护LinkedHashMap,存储的元素有序。

3. TreeSet

底层结构:内部维护了一个TreeMap,都是基于红黑树实现的(自平衡的排序二叉树)

TreeSet存储的元素有序,不重复,不可存储null值。

List和Linked系集合容器的有序指插入顺序。Tree系集合容器有序指元素根据比较规则按大小排序,与插入顺序无关,可自然排序或定制排序

Map

Map接口的实现类主要有:HashMap、LinkedHashMap、ConcurrentHashMap、TreeMap、HashTable以及Properties等。

1. HashMap

JDK1.8之前HashMap由数组+链表组成的,线程不安全,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8),但是数组长度小于64时会首先进行扩容,否则会调用treeifyBin方法将链表转化为红黑树,以减少搜索时间。

Hash表的物理结构

HashMap和Hashtable是散列表,其中维护了一个长度为2的幂次方的Entry类型的数组table,数组的每一个元素被称为一个桶(bucket),你添加的映射关系(key,value)最终都被封装为一个Map.Entry类型的对象,放到了某个table[index]桶中。使用数组的目的是查询和添加的效率高,可以根据索引直接定位到某个table[index]。

hash算法是一种可以从任何数据中提取出其“指纹”的数据摘要算法,它将任意大小的数据映射到一个固定大小的序列上,这个序列被称为hash code、数据摘要或者指纹。比较出名的hash算法有MD5、SHA。hash是具有唯一性且不可逆的,唯一性是指相同的“对象”产生的hash code永远是一样的。

1.1 JDK1.7

映射关系被封装为HashMap.Entry类型,而这个类型实现了Map.Entry接口。

观察HashMap.Entry类型是个结点类型,即table[index]下的映射关系可能串起来一个链表。因此我们把table[index]称为“桶bucket"。

public class HashMap<K,V>{
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
    // 这个entry是节点类型
    static class Entry<K,V> implements Map.Entry<K,V> {
            final K key;
            V value;
            Entry<K,V> next;
            int hash;
            //...省略
    }
    //...
}

 1.2 JDK1.8

映射关系被封装为HashMap.Node类型或HashMap.TreeNode类型,它俩都直接或间接的实现了Map.Entry接口。

存储到table数组的可能是Node结点对象,也可能是TreeNode结点对象,它们也是Map.Entry接口的实现类。即table[index]下的映射关系可能串起来一个链表或一棵红黑树(自平衡的二叉树)。

public class HashMap<K,V>{
    transient Node<K,V>[] table;
    static class Node<K,V> implements Map.Entry<K,V> {
            final int hash;
            final K key;
            V value;
            Node<K,V> next;
            //...省略
    }
    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;
        boolean red;// 是红结点还是黑结点
        //...省略
    }
    //....
}

 相比于之前的版本,jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8),但是数组长度小于64时会首先进行扩容,否则会将链表转化为红黑树,以减少搜索时间。

1.3 数组的长度始终是2的n次幂

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

如果你手动指定的table长度不是2的n次幂,会通过如下方法给你纠正为2的n次幂

JDK1.7

 private static int roundUpToPowerOf2(int number) {
        // assert number >= 0 : "number must be non-negative";
        return number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
    }

public static int highestOneBit(int i) {
        // HD, Figure 3-1
        i |= (i >>  1);
        i |= (i >>  2);
        i |= (i >>  4);
        i |= (i >>  8);
        i |= (i >> 16);
        return i - (i >>> 1);
    }

JDK1.8

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

1.4 扩容之后数组长度还是2的n次幂,因为每次扩容为原来的两倍

JDK1.7

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);//扩容为原来的2倍
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
        createEntry(hash, key, value, bucketIndex);
    }

JDK1.8

  final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;//oldCap原来的容量
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }//newCap = oldCap << 1  新容量=旧容量扩容为原来的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
   		//......此处省略其他代码
		}

1.5 为什么要保持数组长度始终是2的n次幂

为了减少哈希冲突,使数据分配均匀,每个链表/红黑树长度大致相同,提高HashMap存取效率。

为了达到以上目的设计出取余算法(%),而且要使hash%length == hash&(length-1),则 length 是2的 n 次方。 并且 采用二进制与操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。

// JDK1.8的实现
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)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)  // i = (n - 1) & hash
            tab[i] = newNode(hash, key, value, null);
        //....省略大量代码
}

hash()函数优化

对hashCode进一步hash,主要是因为如果使用hashCode取余,那么相当于参与运算的只有hashCode的低位,高位是没有起到任何作用的,所以我们的思路就是让hashCode取值出的高位也参与运算,进一步降低hash碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动。

// JDK1.8的实现
static final int hash(Object key) {
    int h;
    // 与自己右移16位进行异或运算(高低位异或)
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

因为一个HashMap的table数组一般不会特别大,至少在不断扩容之前,那么table.length-1的大部分高位都是0,直接用hashCode和table.length-1进行&运算的话,就会导致总是只有最低的几位是有效的,那么就算你的hashCode()实现的再好也难以避免发生碰撞,这时让高位参与进来的意义就体现出来了。它对hashcode的低位添加了随机性并且混合了高位的部分特征,显著减少了碰撞冲突的发生。

1.6 JDK1.8会出现红黑树和链表共存

因为当冲突比较严重时,table[index]下面的链表就会很长,那么会导致查找效率大大降低,而如果此时选用二叉树可以大大提高查询效率。

但是二叉树的结构又过于复杂,如果结点个数比较少的时候,那么选择链表反而更简单。

所以会出现红黑树和链表共存。

1.7 树化与反树化

static final int TREEIFY_THRESHOLD = 8;// 树化阈值
static final int UNTREEIFY_THRESHOLD = 6;// 反树化阈值
static final int MIN_TREEIFY_CAPACITY = 64;// 最小树化容量

当某table[index]下的链表的结点个数达到8,并且table.length>=64,那么如果新Entry对象还添加到该table[index]中,那么就会将table[index]的链表进行树化。

当某table[index]下的红黑树结点个数少于6个,此时,如果继续删除table[index]下树结点,一直删除到2个以下时就会变回链表。如果继续添加映射关系到当前map中,如果添加导致了map的table重新resize,那么只要table[index]下的树结点仍然<=6个,那么会变回链表

1.8 扩容与负载因子

几个关键的常量和变量值

// 初始化容量
int DEFAULT_INITIAL_CAPACITY = 1 << 4;// 16
// 默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 阈值:扩容的临界值
int threshold;
threshold = table.length * loadFactor;
// 负载因子,影响扩容频率
final float loadFactor;
// 默认树化阈值8,当链表的长度达到这个值后,要考虑树化
static final int TREEIFY_THRESHOLD = 8;
// 默认反树化阈值6,当树中的结点的个数达到这个阈值后,要考虑变为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 最小树化容量64
// 当单个的链表的结点个数达到8,并且table的长度达到64,才会树化。
// 当单个的链表的结点个数达到8,但是table的长度未达到64,会先扩容
static final int MIN_TREEIFY_CAPACITY = 64;

负载因子的值大小有什么关系?

如果太大,threshold就会很大,那么如果冲突比较严重的话,就会导致table[index]下面的结点个数很多,影响效率。

如果太小,threshold就会很小,那么数组扩容的频率就会提高,数组的使用率也会降低,那么会造成空间的浪费。

JDK1.8 put(key,value)流程

(1)当第一次添加映射关系时,数组初始化为一个长度为16的HashMapNode数组,这个HashMapNode类型是实现了java.util.Map.Entry接口

(2)在计算index之前,会对key的hashCode()值,做一个hash(key)再次哈希的运算,这样可以使得Entry对象更加散列的存储到table中

JDK1.8关于hash(key)方法的实现比JDK1.7要简洁。 key.hashCode() ^ key.Code()>>>16;

(3)计算index = table.length-1 & hash;

(4)如果table[index]下面,已经有映射关系的key与我要添加的新的映射关系的key相同了,会用新的value替换旧的value。

(5)如果没有相同的,

①table[index]链表的长度没有达到8个,会把新的映射关系添加到链表的尾

②table[index]链表的长度达到8个,但是table.length没有达到64,会先对table进行扩容,然后再添加

③table[index]链表的长度达到8个,并且table.length达到64,会先把该分支进行树化,结点的类型变为TreeNode,然后把链表转为一棵红黑树

④table[index]本来就已经是红黑树了,那么直接连接到树中,可能还会考虑考虑左旋右旋以保证树的平衡问题

(6)添加完成后判断if(size > threshold )

if(size >= threshold){

	①会扩容
	②会重新计算key的hash
	③会重新计算index
}

1.9 扩容过程

①在jdk1.8中,resize方法是在hashmap中的键值对大于阀值时或者初始化时,就调用resize方法进行扩容;

②每次扩展的时候,新容量为旧容量的2倍;

③扩展后元素的位置要么在原位置,要么移动到原位置 + 旧容量的位置。

在putVal()中使用到了2次resize()方法,resize()方法在进行第一次初始化时会对其进行扩容,或者当该数组的实际大小大于其临界值(第一次为16 * 0.75 = 12),这个时候在扩容的同时也会伴随的桶上面的元素进行重新分发,这也是JDK1.8版本的一个优化的地方,在1.7中,扩容之后需要重新去计算其Hash值,根据Hash值对其进行分发,但在1.8版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是否为0,重新进行hash分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+旧容量的位置

2 TreeMap

底层利用了红黑树进行实现,红黑树是一种近似平衡的二叉查找树。当进行K-V 键值对存放时,会构造一个entry对象作为存储节点,利用key 的值利用红黑树的存储规则,进行数据比对,节点着色,树形左右旋转得到应用的红黑树结构,并将entry存储在对应的节点上,由于需要利用key值进行大小比对,因此TreeMap 的key 是不允许为null 的,value 可为null 。而在进行map.get(key) 操作时,会利用key 的值进行红黑树遍历,查找对应的entry,最终返回entry的value值。

由于TreeMap 在底层利用红黑树进行存储,在节点的增删时会进行树形的改变,着色修改等,导致其增删效率较差;相对HashMap 而言,其根据hashCode 直接定位到数组下标进行取值,而TreeMap则是排序后再取值,其效率不如HashMap高。但是TreeMap 的内存占用小。

二叉树:简单来说就是,每一个节上可以关联两个子节点。

                        a
                      /     \
                    b          c
                  / \         /  \
                d    e       f    g
              /  \  / \     / \   / \
             h   i  j  k   l   m n   o

红黑树:是一种含有红黑结点并能自平衡的二叉查找树。它必须满足下面性质:

  • 性质1:每个节点要么是黑色,要么是红色。
  • 性质2:根节点是黑色。
  • 性质3:每个叶子节点(NIL)是黑色。[注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!]
  • 性质4:每个红结点的两个子结点一定都是黑色。
  • 性质5:任意一结点到每个叶子结点的路径都包含相同数量的黑结点。
  • 从性质5又可以推出:
  • 性质5.1:如果一个结点存在黑子结点,那么该结点肯定有两个子结点。

红黑树能自平衡,它靠的是什么?三种操作:左旋、右旋和变色。简单点说,旋转和变色的目的是让树保持红黑树的特性。

  • 左旋:以某个结点作为支点(旋转结点),其右子结点变为旋转结点的父结点,右子结点的左子结点变为旋转结点的右子结点,左子结点保持不变。
  • 右旋:以某个结点作为支点(旋转结点),其左子结点变为旋转结点的父结点,左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。
  • 变色:结点的颜色由红变黑或由黑变红。
     

3 HashTable

数组 + 链表实现,线程安全,修改数据时锁住整HashTable,K-V 均不可为null,初始大小为 11,扩容逻辑为 newsize = olesize*2+1,下标索引计算逻辑为 (hash & 0x7FFFFFFF) % tab.length

对比 Hashtable、HashMap、TreeMap 有什么不同?

  • Hashtable、HashMap、TreeMap 都是最常见的 Map 实现,是以键值对的形式存储和操作数据的容器。
  • Hashtable 是早期 Java 类库提供的一个哈希表实现,本身是同步的即线程安全,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用。
  • HashMap 是应用更加广泛的哈希表实现,行为上大致上与 HashTable 一致,主要区别在于 HashMap 不是同步的,支持 null 键和值等。通常情况下,HashMap 进行 put 或者 get 操作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选。
  • TreeMap 则是基于红黑树的一种提供顺序访问的 Map,和 HashMap 不同,它的 get、put、remove 之类操作都是 O(logn) 的时间复杂度,具体顺序可以由指定的 Comparator 来决定,或者根据键的自然顺序来判断。

4 ConCurrentHashMap

在1.7 中ConCurrentHashMap 利用的基于Segment+HashEntry数组,默认将其分成了16个Segment,在进行map 操作时,将对应的Segment 锁住,因此相对于HashTable 而言,ConCurrentHashMap 的效率提升了16倍;在进执行put 操作时,需要进行两次定位,首先进行Segment 定位,然后进行数组下标定位,定位后采用自旋锁和膨胀锁进行加锁,整个操作过程中全程加锁,整个segment 无法被其他线程使用;在获取map 的size 时,采用类似乐观锁的逻辑,先不加锁进行size 统计,统计两次的size 值如果相同,则直接返回,如果不同,则再次尝试统计,如果三次尝试 前后统计的size 都不通,则对每个segment 加锁,进行数量统计。

在1.8 中抛弃了Segment ,采用 CAS+Synchronized 保证并发更新的安全,底层还是利用 数组 + Node数组(链表或红黑树)的模式进行存储;在执行put 方法时,首先判断key 值是否有哈希冲突,如果没有哈希冲突,直接CAS插入,如果出现了哈希冲突,则将冲突的那个Node锁起来,然后再进行插入。

1.7 与1.8 对比,1.7 是锁住一个Segment ,而1.8 是锁住的一个Node,粒度更细,1.8 中加入了红黑树,相对于链表而言,提升了查询速度。

JDK1.7的ConcurrentHashMap:

 JDK1.8的ConcurrentHashMap(TreeBin: 红黑树节点 Node: 链表节点):

1 HashMap、HashTable、ConCurrentHashMap区别

主要区别:

ConcurrentHashMap 结合了 HashMap 和 HashTable 二者的优势。HashMap 没有考虑同步,HashTable 考虑了同步的问题。但是 HashTable 在每次同步执行时都要锁住整个结构。ConcurrentHashMap 锁的方式是稍微细粒度的。

JDK1.7

  • HashMap 数组+ 链表实现,线程不安全,所有操作均不上锁,允许K -V 为null ,默认初始大小为16 ,加载因子为 0.75 ,扩容逻辑为 数组占用大小超过 原数组长度* 加载因子 时,长度扩容到原长度的2倍,所有数据进行rehash 重新摆放存储,下标索引计算逻辑为 index = hash & (tab.length – 1)
  • HashTable 数组+ 链表实现,线程安全,修改数据时锁住整HashTable ,K-V 均不可为null ,初始大小为 11 ,扩容逻辑为 newsize = olesize*2+1 ,下标索引计算逻辑为 (hash & 0x7FFFFFFF) % tab.length
  • ConCurrentHashMap segment+链表实现,线程安全,修改数据时锁住数据所在的分片,value值以volatile修饰,读取可不加锁,默认分16 个片段,相对HashTable 速度提升16倍,扩容对象为每个segment ,当段内存储的数据超过 段长度的75%,则触发扩容 。

JDK1.8

  • HashMap 数组+Node 数组(链表或红黑树)实现,与1.7 的区别在于,当Node数组存储的entry 超过 8个,且 数组的长度超过64 时,就会采用红黑树进行数据存储,而在1.8 中以链表存储时,是采用的尾部插入法,保证扩容前后的元素的顺序,避免链表成环。
  • HashTable 目前暂时未有什么变化
  • ConCurrentHashMap 改为 数组+Node 数组(链表或红黑树)实现,与1.8 的HashMap 实现保持一致,只是在处理并发安全问题上,抛弃了 分片试锁,采用的CAS + Synchronized 实现,未出现哈希冲突,采用CAS 比较交换插入,出现哈希冲突,针对Node进行加锁然后进行数据修改。

2 java如何构建线程安全的map

HashTable

ConCurrentHashMap

Collections.synchronizedMap(map)(推荐)

3 java如何构建线程安全的list

vector

CopyOnWriteArrayList(在插入过程中会创建新的数组)

Collections.synchronizedLst(list)(推荐)

参考:java之Collection_梧桐林.的博客-CSDN博客_collection java

Java集合容器_GeekForGeek的博客-CSDN博客_java集合容器

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值