java服务端面试准备(java 集合)

172 阅读8分钟

问题

A线程不安全的:  

序号面试题
0集合接口List,Set,Map的区别、特点
1ArrayList\LinkedList的优点、缺点,使用场景
2Iterator迭代器解决什么问题,如何使用,原理是什么
3HashSet和HashMap的联系
4HashSet如何保证元素不重复
5各种集合类的初始化容量、扩容时机

B特别点出HashMap

序号面试题
1HashMap的扩容怎么发生的,有哪些注意的
2HashMap的实现原理,JDK1.7和1.8
3HashMap的容量为什么总是2的幂?手动指定容量 会不会破坏这个设计?
4put()方法的流程
5负载因子是什么?为什么是0.75
6HashMap怎么解决Hash冲突的
7HashMap的死循环
8为什么要在1.8引入红黑树
9HashMap中的元素为什么要同时覆写equals()和hashCode()

C线程安全的

序号面试题
1HashTable、Vector为什么弃用了
2ConcurrentHashMap在JDK1.7和1.8的实现区别
31.8中为什么放弃了分段锁Segment
4ConcurrentHashMap在扩容的时候,怎么保证线程安全

回答

A0、集合接口List,Set,Map的区别、特点

集合特点使用场景
List有序的、可重复的注重顺序
Set无序的、不可重复的简便去重
Map无序的,key是不可重复的,value是可重复的O(1)查询

A1、ArrayList\LinkedList的优点、缺点,使用场景

时间复杂度

操作Arraylist(数组)LinkedList(链表)
随机访问O(1)O(N)
头部插入O(N)O(1)
头部删除O(N)O(1)
尾部插入O(1)O(1)
尾部删除O(1)O(1)

如果应用程序对数据有较多的随机访问,ArrayList对象要优于LinkedList对象;

如果应用程序有更多的插入或者删除操作,较少的随机访问,LinkedList对象要优于ArrayList对象;

不过ArrayList的插入,删除操作也不一定比LinkedList慢,如果在List靠近末尾的地方插入,那么ArrayList只需要移动较少的数据,而LinkedList则需要一直查找到列表尾部,反而耗费较多时间,这时ArrayList就比LinkedList要快。

A2、Iterator迭代器解决什么问题,如何使用,原理是什么

凡是实现 Collection 接口的集合类都必须实现 iterator 方法 ,返回一个迭代器对象

Iterator<E> iterator();

迭代器也是一种设计模式,将集合的底层实现屏蔽,抽象出迭代器这样一个工具,用来遍历并选择序列中的对象。
此外,迭代器通常被称为轻量级对象:创建它的代价小。因此,经常可以见到对迭代器有些奇怪的限制;例如,Java的Iterator只能单向移动

for-each循环(增强for循环)默认使用的就是集合的迭代器进行遍历

List<Integer> temp = new ArrayList<>();
for(Integer each: temp){
    //doSomething;
}
  • 迭代器Iterator的内部方法
    //判断end
    boolean hasNext();
    //遍历元素
    E next();
    //删除元素,一般实现都需要再override的
    default void remove() {
        throw new UnsupportedOperationException("remove");
    }
    
    
  • 快速失败vs安全失败
    • java.util包下的集合类都是快速失败;在使用迭代器遍历一个集合对象时,如果遍历过程中对集合进行修改(增删改),则会抛出 ConcurrentModificationException 异常
    • java.util.concurrent包下的集合类,都采用安全失败机制(fail—safe).在遍历集合时不是直接访问原有集合,而是先将原有集合的内容复制一份,然后在拷贝的集合上进行遍历。由于是对拷贝的集合进行遍历,所以在遍历过程中对原集合的修改并不会被迭代器检测到,所以不会抛出 ConcurrentModificationException 异常。
  • 使用场景

A3、HashSet和HashMap的联系

HashSet内部使用HashMap来实现,value是一个内部的静态Object,只是占位而已。本质就是用一下HashMap的key

A4、HashSet如何保证元素不重复

HashMap的put()方法中做了处理,详细解释参考HashMap部分。 大致流程是:

  • 假设put()一个重复的的对象(用的是HashMap的Key)
  • 先根据hashCode()算出hash值一样,数组的index就是一样的
  • 现在冲突了,就再看equals()是否相同,

A5、各种集合类的初始化容量、扩容时机

集合类初始容量扩容时机扩容策略
ArrayList10add()的时候触发ensureCapacity(),满了就扩容扩容为1.5倍, 再把老数组的元素存储到新数组里面
HashMap16容量= size * 负载因子的时候,初始是16 * 0.75 = 12扩容为2倍, 将数据rehash, 然后复制过去(扩容时非常影响性能)
HashTable11扩容为2倍 + 1

LinkedList、TreeSet基于链表,不需要扩容
HashSet 同HashMap

| 1 | HashMap的扩容怎么发生的,有哪些注意的| | 4 | put()方法的流程| | 5 | 负载因子是什么?为什么是0.75 | | 7 | HashMap的死循环 |

B2、HashMap的实现原理,JDK1.7和1.8

image.png

B3、HashMap的容量为什么总是2的幂?手动指定容量 会不会破坏这个设计?

Hash 值的范围值 2^32 (-2147483648到2147483647),前后加起来⼤概40亿的映射空间,只要 哈希函数映射得⽐᫾均匀松散,⼀般应⽤是很难出现碰撞的。但问题是⼀个40亿⻓度的数组,内 存是放不下的。所以这个散列值是不能直接拿来⽤的。⽤之前还要先做对数组的⻓度取模运算,即hash % length 而重点是,如果满足下面的条件:

  • length 是2的幂 就可以有这样的等式hash%length==hash&(length-1) (数学证明略)
    这种情况下,求余计算可以简化成位运算&, 位运算大家都知道吧?CPU一个时钟周期就完成啦!

手动指定初始容量,也不会破坏2次幂的设计,因为HashMap的源码里通过tableSizeFor()方法进行优化
    static final int tableSizeFor(int cap) {
            // cap-1后,n的二进制最右一位肯定和cap的最右一位不同,即一个为0,一个为1,例如cap=17(00010001),n=cap-1=16(00010000)
            int n = cap - 1;
            // n = (00010000 | 00001000) = 00011000
            n |= n >>> 1;
            // n = (00011000 | 00000110) = 00011110
            n |= n >>> 2;
            // n = (00011110 | 00000001) = 00011111
            n |= n >>> 4;
            // n = (00011111 | 00000000) = 00011111
            n |= n >>> 8;
            // n = (00011111 | 00000000) = 00011111
            n |= n >>> 16;
            // n = 00011111 = 31
            // n = 31 + 1 = 32, 即最终的cap = 32 = 2 的 (n=5)次方
            return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
        }

B5、负载因子是什么?为什么是0.75

扩容发生在 size = capacity * 负载因子时。
负载因子值的选取, 其实是空间利用率Vs查询的时间 的一个取舍
(和大多数数据结构一样,要么空间换时间,要么时间换空间)

HashMap源码的注释

     * Ideally, under random hashCodes, the frequency of
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million

节点出现的频率在hash桶中遵循泊松分布,同时给出了桶中元素个数和概率的对照表。
从上面的表中可以看到当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。

B6、HashMap怎么解决Hash冲突的

  1. 如果真的冲突了,就使用【链式地址法】, 将 冲突的节点储存在链表后端(直至转化成红黑树)
  2. 使用扰动函数,对hashCode()方法的结果进行扰动,尽量避免不均匀的hash函数的影响

B8、为什么要在1.8引入红黑树

  • 链表转换红黑树的时机
    • 如果length > 64 , 且最长链表长度>8, 就会转换成红黑树。Node节点也会转换成TreeNode
    • 如果length < 64, 就算链表长度>8, 也会先尝试扩容,然后rehash。 (因为长度较短的时候,冲突可能不是hash算法的问题,而是hash%size,其中size较小导致的)
  • 如果hash方法设计得好,hash冲突少,其实链表是不会转换成红黑树的
  • 所以红黑树是为了解决:
    极端情况下,hashMap转成单链表的时候,查询效率低下的问题。get()时间复杂度为O(n),

红黑树是”近似平衡“的, 牺牲了一些查找性能 但其本身并不是完全平衡的二叉树。因此插入删除操作效率略高于AVL树。

B9、为什么要同时重写equals()和hashCode()方法

很多参考文章都说,如果没有重写hashCode(), 那么equals相同的对象,就会有不同的hash值,落在数组的不同地方,这种情况下,同一个HashMap就会存在2个这样的对象。
但注意,此时说的对象,是HashMap的key对象,而不是Value。

如果是value对象,hashCode不同
如果是key对象,就会存在两个equals相同的key,同时存在hashMap中(因为如果key的hashCode没冲突,就不会调用equals进行对比,更不会覆盖;这样你在get()的时候就会出现歧义(很多时候会引发BUG)),例如下面的code

HashMap<A, Integer> temp = new HashMap<>();

A a = new A(1);
A b = new A(2);

temp.put(a, 1);
temp.put(b, 2);

System.out.println(temp.get(a));// 结果2
System.out.println(temp.get(new A(3)));// 结果null

C1、ConcurrentHashMap在JDK1.7和1.8的实现区别

C2、1.8中为什么放弃了分段锁Segment

C3、HashTable、Vector为什么弃用了

给几乎所有的public方法都加上了synchronized关键字,导致性能欠佳,已被弃用。

C4、ConcurrentHashMap在扩容时怎么保证线程安全?

等扩容完之后,所有的读写操作才能进行,所以扩容的效率就成为了整个并发的一个瓶颈点。
好在Doug lea教授对扩容做了优化,本来在一个线程扩容的时候,如果影响了其他线程的数据,那么其他的线程的读写操作都应该阻塞。
但Doug lea说你们闲着也是闲着,不如来一起参与扩容任务,这样人多力量大,办完事你们该干啥干啥,别浪费时间,于是在JDK8的源码里面就引入了一个ForwardingNode类来实现多线程协作扩容.

参考文章<<理解Java7和8里面HashMap+ConcurrentHashMap的扩容策略>>

详细知识点

1.ArrayList

  • 一句话原理
    ArrayList底层的数据结构是数组,数组元素类型是Object,即内部是用Object[]实现的

2.LinkedList

  • 一句话原理 LinkedList 底层使⽤的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。)

3.HashSet

  • 一句话原理
    内部使用HashMap来实现,value是一个内部的静态Object,只是占位而已。本质就是用一下HashMap的key

    public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable {
        static final long serialVersionUID = -5024744406713321676L;
        private transient HashMap<E,Object> map;  
        private static final Object PRESENT = new Object();  
    }
    ···
    public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }
    

4.TreeSet

  • 一句话原理
    和HashSet类似,本质通过new了一个TreeMap实现功能, 使用了它的KeySet,value用了内部的静态Object对象

在TreeSet中,元素按照其自然序升序排列和存储,内部使用了红黑树。
其中每个节点都额外保有一个比特,用来指示当前的节点颜色是红色或者黑色。这些“颜色”比特在后续的插入或者删除中,有助于确保树结构保持平衡

5.HashMap

6.TreeMap

线程安全的

7.ConcurrentHashMap

详细内容请参考ConcurrentHashMap 原理浅析

  • 原理:
    • 在 JDK1.7 中 ConcurrentHashMap 采用了数组 + Segment + 分段锁的方式实现。
    • JDK8 中 ConcurrentHashMap 参考了 JDK8 HashMap 的实现,采用了数组 + 链表 + 红黑树的实现方式来设计,内部大量采用 CAS 操作

8.CopyOnWriteArraySet、CopyOnWriteArrayList

9.ConcurrentSkipListMap、ConcurrentSkipListSet、ConcurrentLinkedQueue、ConcurrentLinkedDeque等

至于为什么没有ConcurrentArrayList,原因是无法设计一个通用的而且可以规避ArrayList的并发瓶颈的线程安全的集合类,只能锁住整个list,
可以使用List synArrayList = Collections.synchronizedList(new ArrayList());

10.HashTable:

  • 一句话原理 :
    HashTable和HashMap的用法类似, 它给几乎所有public方法都加上了synchronized关键字
  • tips: HashTable的K,V都不能为null

    多线程的K-V集合基本都不允许value为null, 因为如果允许就会存在二义性:null是有这个value, 还是没get到返回null
    单线程的集合允许为null, 因为当前线程自己知道自己的逻辑,到底是没get到还是存了null。

11.Vector:

  • Vector和ArrayList类似,是长度可变的数组。
  • 一句话原理 :
    给几乎所有的public方法都加上了synchronized关键字,导致性能欠佳,已被弃用。