Java 集合


1)说说常见的集合有哪些吧?

Map 接口和 Collection 接口是所有集合框架的父接口。

1、Collection 接口的子接口包括:Set 接口、List 接口和 Queue 接口

2、List 接口的实现类主要有:ArrayList、LinkedList、Stack以及Vector 等

3、Set 接口的实现类主要有:HashSet、TreeSet、LinkedHashSet 等

4、Queue 接口的实现类主要有:BlockingQueue、Deque 等

5、Map 接口的实现类主要有:HashMap、TreeMap、Hashtable、ConcurrentHashMap 以及 Properties 等


image-20210813205804570

2)使用集合框架的好处?

1、容量自增长;

2、提供了高性能的数据结构和算法,使编码更轻松,提高了程序速度和质量;

3、允许不同 API 之间的互操作,API之间可以来回传递集合;

4、可以方便地扩展或改写集合,提高代码复用性和可操作性。

5、通过使用JDK自带的集合类,可以降低代码维护和学习新API成本。


3)集合框架底层数据结构

Collection

1、List

  • Arraylist: Object 数组
  • Vector: Object 数组
  • LinkedList: 双向循环链表

2、Set

  • HashSet(无序,唯一):基于 HashMap 实现的,底层采用 HashMap 来保存元素

  • LinkedHashSet: LinkedHashSet 继承与 HashSet,并且其内部是通过 LinkedHashMap来实现的。

    有点类似于我们之前说的LinkedHashMap 其内部是基于 Hashmap 实现一样,不过还是有一点点区别的。

  • TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树)

Map

  • HashMap: JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。

    JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间

  • LinkedHashMap:LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构,

    即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,

    使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。

  • HashTable: 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的

  • TreeMap: 红黑树(自平衡的排序二叉树)


4)迭代器Iterator是什么?

Iterator 接口提供遍历任何 Collection 的接口。我们可以从一个 Collection 中使用迭代器方法来获取迭代器实例。

迭代器取代了 Java 集合框架中的 Enumeration,迭代器允许调用者在迭代过程中移除元素。


5)Iterator和ListIterator有什么区别?

1、Iterator 可以遍历 Set 和 List 集合,而 ListIterator 只能遍历 List。

2、Iterator 只能单向遍历,而 ListIterator 可以双向遍历(向前/后遍历)。

3、ListIterator 实现 Iterator 接口,然后添加了一些额外的功能,

比如添加一个元素、替换一个元素、获取前面或后面元素的索引位置。


6)HashMap与HashTable有什么区别?

1、线程安全

HashMap 是非线程安全的,HashTable 是线程安全的;

HashTable内部的方法基本都经过 synchronized 修饰。

要保证线程安全的话就使用 ConcurrentHashMap。

2、效率

因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;

3、对 Null key 和 Null value 的支持

HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。

但是在 HashTable 中 put 进的键值只要有一个 null,直接抛 NullPointerException。

4、初始容量大小和每次扩充容量大小的不同

① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的 2n+1。

HashMap 默认的初始化大小为16,之后每次扩充,容量变为原来的2倍。

② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap会将其扩充为2的幂次方大小。

也就是说 HashMap 总是使用2的幂作为哈希表的大小,后面会介绍到为什么是2的幂次方。

5、底层数据结构

JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。

Hashtable 没有这样的机制。

6、推荐使用

在 Hashtable 的类注释可以看到,Hashtable 是保留类不建议使用;

推荐在单线程环境下使用 HashMap 替代,如果需要多线程使用则用 ConcurrentHashMap 替代。


7)如何决定使用HashMap还是TreeMap?

对于在 Map 中插入、删除和定位元素这类操作,HashMap 是最好的选择。

然而,假如你需要对一个有序的 key 集合进行遍历,TreeMap 是更好的选择。

基于你的 collection 的大小,也许向 HashMap 中添加元素会更快,或将 map 换为 TreeMap 进行有序 key 的遍历。


8)HashMap的put方法的具体流程?

参考

先分析一下源码

// 向 HashMap put值时,调用 putVal
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

/**
 * Implements Map.put and related methods
 *
 * @param hash hash for key
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 1、如果 table 为空或者长度为0,即没有元素,那么使用 resize() 方法扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 2、计算插入存储的数组索引i,此处计算方法同 1.7 中的 indexFor() 方法
    // 如果数组为空,即不存在 Hash 冲突,则直接插入数组
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 3、插入时,如果发生 Hash 冲突,则依次往下判断
    else {
        Node<K,V> e; K k;
        // a、判断 table[i] 的元素的 key 是否与需要插入的 key 一样,
        // 若相同则直接用新的 value 覆盖掉旧的 value
        // 判断原则 equals() , 所以需要当 key 的对象重写该方法
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // b、继续判断:需要插入的数据结构是红黑树还是链表
        // 如果是红黑树,则直接在树中插入 or 更新键值对
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 如果是链表,则在链表中插入 or 更新键值对
        else {
            // i .遍历 table[i],判断 key 是否已存在:采用 equals 对比当前遍历结点的 key 与需要插入数据的 key
            //    如果存在相同的,则直接覆盖
            // ii.遍历完毕后任务发现上述情况,则直接在链表尾部插入数据
            //    插入完成后判断链表长度是否 > 8:若是,则把链表转换成红黑树
            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;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 对于 i 情况的后续操作:发现 key 已存在,直接用新 value 覆盖旧value 并且返回旧value
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 插入成功后,判断实际存在的键值对数量 size > 最大容量
    // 如果大于则进行扩容
    if (++size > threshold)
        resize();
    // 插入成功时会调用的方法,默认实现为空
    afterNodeInsertion(evict);
    return null;
}

总结过程为图片:



① 判断键值对数组 table[i] 是否为空或为 null,为空或 null 则执行 resize() 进行扩容;

② 根据键值 key 计算 hash 值得到插入的数组索引 i,如果 table[i]==null,直接新建节点添加,转向⑥;

如果 table[i] 不为空,转向③;

③ 判断 table[i] 的首个元素是否和 key 一样,如果相同直接覆盖 value,否则转向④,这里的相同指的是 hashCode 以及 equals;

④ 判断 table[i] 是否为 treeNode,即 table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;

⑤ 遍历 table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;

遍历过程中若发现 key 已经存在直接覆盖 value 即可;

⑥ 插入成功后,判断实际存在的键值对数量 size 是否超过了最大容量 threshold,如果超过,进行扩容。


9)HashMap的扩容操作是怎么实现的?

通过分析源码我们知道了HashMap通过 resize() 方法进行扩容或者初始化的操作,下面对源码进行一些简单分析:

/**
 * Initializes or doubles table size.  If null, allocates in
 * accord with initial capacity target held in field threshold.
 * Otherwise, because we are using power-of-two expansion, the
 * elements from each bin must either stay at same index, or move
 * with a power of two offset in the new table.
 *
 * @return the table
 */
// 该函数有两中使用情况:1.初始化哈希表;2.当前数组容量过小,需要扩容
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table; // 扩容前的数组(当前数组)
    int oldCap = (oldTab == null) ? 0 : oldTab.length; // 扩容前的数组容量(数组长度)
    int oldThr = threshold; // 扩容前数组的阈值
    int newCap, newThr = 0;
    if (oldCap > 0) {
        // 针对扩容情况:若扩容前的数组容量超过最大值,则不再扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 针对扩容情况:若没有超过最大值,就扩容为原来的2倍(左移1位)
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 针对初始化情况:初始化哈希表(采用指定或者使用默认值的方式)
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 计算新的 resize 上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 把每一个 bucket 都移动到新的 bucket 中去
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        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);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

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

② 每次扩展的时候,都是扩展2倍;

③ 扩展后 Node 对象的位置要么在原位置,要么移动到原偏移量两倍的位置。

在 putVal() 中,在这个函数里面使用到了2次 resize() 方法,resize() 方法表示的,在进行第一次初始化时会对其进行扩容,或者当该数组的实际大小大于其临界值(第一次为12),这个时候在扩容的同时也会伴随的桶上面的元素进行重新分发,这也是 JDK1.8 版本的一个优化的地方;

在1.7中,扩容之后需要重新去计算其 Hash 值,根据 Hash 值对其进行分发;但在1.8版本中,则是根据在同一个桶的位置中进行判断 (e.hash & oldCap) 是否为0,重新进行 hash 分配后,该元素的位置要么停留在原始位置,要么移动到原始位置 + 增加的数组大小这个位置上。


10)HashMap是怎么解决哈希冲突的?

参考

在解决这个问题之前,我们首先需要知道什么是哈希冲突,而在了解哈希冲突之前我们还要知道什么是哈希

什么是哈希?

Hash,一般翻译为“散列”,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是散列值(哈希值);

这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。

所有散列函数都有如下一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。


什么是哈希冲突?

当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做碰撞(哈希碰撞)。


HashMap的数据结构

在Java中,保存数据有两种比较简单的数据结构:数组和链表。

数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;

所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做链地址法的方式可以解决哈希冲突:



这样我们就可以将拥有相同哈希值的对象组织成一个链表放在 hash 值所对应的 bucket 下,

但相比于 hashCode 返回的 int 类型,我们 HashMap 初始的容量大小 DEFAULT_INITIAL_CAPACITY = 1 << 4(即2的四次方16)要远小于 int 类型的范围,所以我们如果只是单纯的用 hashCode 取余来获取对应的 bucket ,

这将会大大增加哈希碰撞的概率,并且最坏情况下还会将 HashMap 变成一个单链表。

所以我们还需要对 hashCode 作一定的优化。


hash()函数

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

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

这比在JDK 1.7中,更为简洁,相比在1.7中的4次位运算,5次异或运算(9次扰动);

在1.8中,只进行了1次位运算和1次异或运算(2次扰动);


JDK1.8新增红黑树



通过上面的链地址法(使用散列表)扰动函数我们成功让我们的数据分布更平均,哈希碰撞减少,但是当我们的 HashMap 中存在大量数据时,加入我们某个 bucket 下对应的链表有n个元素,那么遍历时间复杂度就为 \(O(n)\)

为了针对这个问题,JDK1.8在 HashMap 中新增了红黑树的数据结构,进一步使得遍历复杂度降低至 \(O(logn)\)


总结

简单总结一下 HashMap 是使用了哪些方法来有效解决哈希冲突的:

1、使用链地址法(使用散列表)来链接拥有相同 hash 值的数据;
2、使用2次扰动函数(hash 函数)来降低哈希冲突的概率,使得数据分布更平均;
3、引入红黑树进一步降低遍历的时间复杂度,使得遍历更快;


11)HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标?

hashCode() 方法返回的是 int 整数类型,其范围为 -(2 ^ 31)~(2 ^ 31 - 1),约有40亿个映射空间,而 HashMap 的容量范围是在 16(初始化默认值)~2 ^ 30,HashMap 通常情况下是取不到最大值的,并且设备上也难以提供这么多的存储空间,从而导致通过 hashCode() 计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置;

那怎么解决呢?

1、HashMap 自己实现了自己的 hash() 方法,通过两次扰动使得它自己的哈希值高低位自行进行异或运算,降低哈希碰撞概率也使得数据分布更平均;

2、在保证数组长度为2的幂次方的时候,使用 hash() 运算之后的值与运算,h &(数组长度 - 1)来获取数组下标的方式进行存储,这样一来是比取余操作更加有效率,二来也是因为只有当数组长度为2的幂次方时,h&(length-1) 才等价于 h%length,三来解决了“哈希值与数组大小范围不匹配”的问题;

为什么数组长度要保证为2的幂次方呢?

1、只有当数组长度为2的幂次方时,h&(length-1) 才等价于 h%length,即实现了 key 的定位,2的幂次方也可以减少冲突次数,提高 HashMap 的查询效率;

2、如果 length 为 2 的次幂 则 length-1 转化为二进制必定是 11111……的形式,在于 h 的二进制与操作效率会非常的快,而且空间不浪费;如果 length 不是 2 的次幂,比如 length 为 15,则 length - 1 为 14,对应的二进制为 1110,在于 h 与操作,最后一位都为 0 ,而 0001,0011,0101,1001,1011,0111,1101 这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空间的浪费。

那为什么是两次扰动呢?

这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性,最终减少Hash冲突,两次就够了,已经达到了高位低位同时参与运算的目的;


12)HashMap在JDK1.7和JDK1.8中有哪些不同?

在Java中,保存数据有两种比较简单的数据结构:数组和链表。

数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;

所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。


JDK1.8之前

JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。

也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

image-20210814130907926

JDK1.8之后

相比于之前的版本,jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。

image-20210814131002392

JDK1.7 VS JDK1.8 比较

JDK1.8主要解决或优化了以下问题:

1、resize 扩容优化

2、引入了红黑树,目的是避免单条链表过长而影响查询效率,红黑树算法可以参考

3、解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题。


不同 JDK 1.7 JDK 1.8
存储结构 数组 + 链表 数组 + 链表 + 红黑树
初始化方式 单独函数:inflateTable() 直接集成到了扩容函数 resize() 中
hash值计算方式 扰动处理 = 9次扰动 = 4次位运算 + 5次异或运算 扰动处理 = 2次扰动 = 1次位运算 + 1次异或运算
存放数据的规则 无冲突时,存放数组;冲突时,存放链表 无冲突时,存放数组;冲突 & 链表长度 < 8:存放单链表;冲突 & 链表长度 > 8:树化并存放红黑树
插入数据方式 头插法(先将原位置的数据移到后1位,再插入数据到该位置) 尾插法(直接插入到链表尾部/红黑树)
扩容后存储位置的计算方式 全部按照原来方法进行计算(即 hashCode ->> 扰动函数 ->> (h&length-1)) 按照扩容后的规律计算(即扩容后的位置=原位置 or 原位置 + 旧容量)

13)为什么HashMap中String、Integer这样的包装类适合作为K?

String、Integer 等包装类的特性能够保证 Hash 值的不可更改性和计算准确性,能够有效的减少 Hash 碰撞的几率

1、都是 final 类型,即不可变性,保证 key 的不可更改性,不会存在获取 hash 值不同的情况

2、内部已重写了 equals()、hashCode() 等方法,遵守了 HashMap 内部的规范(不清楚可以去上面看看 putVal 的过程),不容易出现 Hash 值计算错误的情况;

如果我想要让自己的 Object 作为 Key 应该怎么办呢?

重写 hashCode() 和 equals() 方法

1、重写 hashCode() 是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的 Hash 碰撞;
2、重写 equals() 方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非 null 的引用值 x,x.equals(null) 必须返回 false 的这几个特性,目的是为了保证 key 在哈希表中的唯一性


14)HashMap和ConcurrentHashMap的区别?

1、ConcurrentHashMap 对整个桶数组进行了分割分段 (Segment),然后在每一个分段上都用 lock 锁进行保护,

相对于 HashTable 的 synchronized 锁的粒度更精细了一些,并发性能更好;

而 HashMap 没有锁机制,不是线程安全的。JDK1.8 之后 ConcurrentHashMap 启用了一种全新的方式实现,利用 CAS 算法。

2、HashMap的键值对允许有null,但是ConCurrentHashMap都不允许。


15)ConcurrentHashMap和Hashtable的区别?

ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。

1、底层数据结构

JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;

2、实现线程安全的方式(重要)

① 在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,比Hashtable效率提高16倍。)

到了 JDK1.8 的时候已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后对 synchronized 锁做了很多优化)整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;

Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

两者的对比图:

HashTable

image-20210814135006909

JDK1.7的 ConcurrentHashMap

image-20210814135021291

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

image-20210814135035961

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


16)ConcurrentHashMap的具体实现知道吗?

首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

JDK 1.7

在JDK1.7中,ConcurrentHashMap 采用 Segment + HashEntry 的方式进行实现,结构如下:

一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对
HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。

1、该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对,后者用来充当锁的角色;

2、Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。

JDK 1.8

在JDK1.8中,放弃了 Segment 臃肿的设计,取而代之的是采用 Node + CAS + Synchronized 来保证并发安全进行实现,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。结构如下:

插入元素过程(建议去看看源码):

1、如果相应位置的 Node 还没有初始化,则调用 CAS 插入相应的数据;

else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
        break;                   // no lock when adding to empty bin
}

2、如果相应位置的 Node 不为空,且当前该节点不处于移动状态,则对该节点加 synchronized 锁,如果该节点的 hash 不小于0,则遍历链表更新节点或插入新节点;

if (fh >= 0) {
    binCount = 1;
    for (Node<K,V> e = f;; ++binCount) {
        K ek;
        if (e.hash == hash &&
            ((ek = e.key) == key ||
             (ek != null && key.equals(ek)))) {
            oldVal = e.val;
            if (!onlyIfAbsent)
                e.val = value;
            break;
        }
        Node<K,V> pred = e;
        if ((e = e.next) == null) {
            pred.next = new Node<K,V>(hash, key, value, null);
            break;
        }
    }
}

3、如果该节点是 TreeBin 类型的节点,说明是红黑树结构,则通过 putTreeVal 方法往红黑树中插入节点;如果 binCount 不为0,说明 put 操作对数据产生了影响,如果当前链表的个数达到8个,则通过 treeifyBin 方法转化为红黑树,如果 oldVal 不为空,说明是一次更新操作,没有对元素个数产生影响,则直接返回旧值;

4、如果插入的是一个新节点,则执行 addCount() 方法尝试更新元素个数 baseCount;


17)Java集合的快速失败机制 “fail-fast”?

是java集合的一种错误检测机制,当多个线程对集合进行结构上的改变的操作时,有可能会产生 fail-fast 机制。

举个栗子:假设存在两个线程(线程1、线程2),线程1通过 Iterator 在遍历集合 A 中的元素,在某个时候线程2修改了集合 A 的结构(是结构上的修改,而不是简单的修改集合元素的内容),那么这个时候程序就会抛出 ConcurrentModificationException 异常,从而产生 fail-fast 机制

原因:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变 modCount 的值。每当迭代器使用 hashNext()/next() 遍历下一个元素之前,都会检测 modCount 变量是否为 expectedmodCount 值,是的话就返回遍历;否则抛出异常,终止遍历。

解决办法:

1、在遍历过程中,所有涉及到改变 modCount 值得地方全部加上 synchronized。

2、使用 CopyOnWriteArrayList 来替换ArrayList

怎么确保一个集合不能被修改?

可以使用 Collections.unmodifiableCollection(Collection c) 方法来创建一个只读集合,

这样改变集合的任何操作都会抛出 Java. lang. UnsupportedOperationException 异常。


18)说一下HashSet的实现原理?

HashSet 是基于 HashMap 实现的,HashSet 的值存放于 HashMap 的 key 上,

HashMap 的 value 统一为 PRESENT,因此 HashSet 的实现比较简单,相关 HashSet 的操作,

基本上都是直接调用底层 HashMap 的相关方法来完成,HashSet 不允许重复的值。


19)HashSet是如何保证数据不可重复的?

HashSet的底层其实就是HashMap,只不过我们 HashSet 是实现了 Set 接口并且把数据作为 K 值,而 V 值一直使用一个相同的虚值来保存,我们可以看到源码:

public boolean add(E e) {
    return map.put(e, PRESENT)==null;// 调用HashMap的put方法,PRESENT是一个至始至终都相同的虚值
}

由于 HashMap 的 K 值本身就不允许重复,并且在 HashMap 中如果 K/V 相同时,会用新的 V 覆盖掉旧的 V,然后返回旧的 V,那么在 HashSet 中执行这一句话始终会返回一个 false,导致插入失败,这样就保证了数据的不可重复性;

HashMap 比较 key 是否相等是先比较 hashcode 再比较 equals。


20)说一下ArrayList的优缺点?

优点:

  • ArrayList 底层以数组实现,是一种随机访问模式。ArrayList 实现了 RandomAccess 接口,因此查找的时候非常快。
  • ArrayList 在顺序添加一个元素的时候非常方便。

缺点:

  • 删除元素的时候,需要做一次元素复制操作。如果要复制的元素很多,那么就会比较耗费性能。
  • 插入元素的时候,也需要做一次元素复制操作,缺点同上。

ArrayList 比较适合顺序添加、随机访问的场景。


21)ArrayList和LinkedList的区别?

1、数据结构实现:ArrayList 是动态数组的数据结构实现,而 LinkedList 是双向链表的数据结
构实现。

2、随机访问效率:ArrayList 比 LinkedList 在随机访问的时候效率要高,因为 LinkedList 是线
性的数据存储方式,所以需要移动指针从前往后依次查找。

3、增加和删除效率:在非首尾的增加和删除操作,LinkedList 要比 ArrayList 效率要高,因为
ArrayList 增删操作要影响数组内的其他数据的下标。

4、内存空间占用:LinkedList 比 ArrayList 更占内存,因为 LinkedList 的节点除了存储数据,
还存储了两个引用,一个指向前一个元素,一个指向后一个元素。

5、线程安全:ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;

综合来说,在需要频繁读取集合中的元素时,更推荐使用 ArrayList,而在插入和删除操作较多时,更推荐使用 LinkedList。


22)ArrayList和Vector的区别?

这两个类都实现了 List 接口(List 接口继承了 Collection 接口),他们都是有序集合,即存储在这两个集合中的元素位置都是有顺序的,相当于一种动态的数组,我们以后可以按位置索引来取出某个元素。

并且其中的数据是允许重复的,这是与 HashSet 之类的集合的最大不同处,HashSet 之类的集合不可以按索引号去检索其中的元素,也不允许有重复的元素。

ArrayList 与 Vector 的区别主要包括两个方面:

1、同步性

Vector 是线程安全的,也就是说它的方法之间是线程同步(加了 synchronized 关键字)的;

而 ArrayList 是线程不安全的,它的方法之间是线程不同步的。

如果只有一个线程会访问到集合,那最好是使用 ArrayList,因为它不考虑线程安全的问题,所以效率会高一些;

如果有多个线程会访问到集合,那最好是使用 Vector,因为不需要我们自己再去考虑和编写线程安全的代码。

2、数据增长

ArrayList 与 Vector 都有一个初始的容量大小,当存储进它们里面的元素的个人超过了容量时,就需要增加 ArrayList 和 Vector 的存储空间,每次要增加存储空间时,不是只增加一个存储单元,而是增加多个存储单元,每次增加的存储单元的个数在内存空间利用与程序效率之间要取得一定的平衡。

Vector 在数据满时(加载因子1)增长为原来的两倍;

而 ArrayList 在数据量达到容量的一半时(加载因子 0.5)增长为原容量的 0.5 倍 + 1 个空间。


23)List和Set的区别?

List , Set 都是继承自 Collection 接口

List 特点:一个有序(元素存入集合的顺序和取出的顺序一致)容器,元素可以重复,可以插入多个null元素,元素都有索引。常用的实现类有 ArrayList、LinkedList 和Vector。

Set 特点:一个无序(存入和取出顺序有可能不一致)容器,不可以存储重复元素,只允许存入一个null元素,必须保证元素唯一性。Set 接口常用实现类是 HashSet、LinkedHashSet 以及 TreeSet。

另外 List 支持 for 循环,也就是通过下标来遍历,也可以用迭代器,但是 set 只能用迭代,因为他无序,无法用下标来取得想要的值。

Set和List对比

Set:检索元素效率低下,删除和插入效率高,插入和删除不会引起元素位置改变。

List:和数组类似,List可以动态增长,查找元素效率高,插入删除元素效率低,因为会引起其他元素位置改变


24)BlockingQueue是什么?

Java.util.concurrent.BlockingQueue 是一个队列,在进行检索或移除一个元素的时候,它会等待队列变为非空;当在添加一个元素时,它会等待队列中的可用空间。

BlockingQueue 接口是 Java 集合框架的一部分,主要用于实现生产者-消费者模式。我们不需要担心等待生产者有可用的空间,或消费者有可用的对象,因为它都在 BlockingQueue 的实现类中被处理了。

Java 提供了集中 BlockingQueue 的实现,比如 ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue,、SynchronousQueue 等。


25)在Queue中poll()和remove()有什么区别?

相同点:都是返回第一个元素,并在队列中删除返回的对象。

不同点:如果没有元素 poll() 会返回 null,而 remove() 会直接抛出 NoSuchElementException 异常。


26)Array和ArrayList有何区别?

1、Array 可以存储基本数据类型和对象,ArrayList 只能存储对象。

2、Array 是指定固定大小的,而 ArrayList 大小是自动扩展的。

3、Array 内置方法没有 ArrayList 多,比如 addAll、removeAll、iteration 等方法只有ArrayList 有。

对于基本类型数据,集合使用自动装箱来减少编码工作量。但是,当处理固定大小的基本数据类型的时候,这种方式相对比较慢。


27)如何实现Array和List之间的转换?

Array 转 List:使用 Arrays.asList(array) 进行转换。

List 转 Array:使用 List 自带的 toArray() 方法。


28)Collection和Collections有什么区别?

  • java.util.Collection 是一个集合接口(集合类的一个顶级接口)。它提供了对集合对象进行基本操作的通用接口方法。

    Collection 接口在 Java 类库中有很多具体的实现。

    Collection 接口的意义是为各种具体的集合提供了最大化的统一操作方式,其直接继承接口有 List 与 Set。

  • Collections 则是集合类的一个工具类/帮助类,其中提供了一系列静态方法,

    用于对集合中元素进行排序、搜索以及线程安全等各种操作。


参考文献

Java集合必会14问


posted @ 2021-08-14 18:38  distance66  阅读(46)  评论(0编辑  收藏  举报