Java自我修养-Java集合

216 阅读14分钟

声明:本篇内容是对博主「我是祖国的花朵」的《Java开发岗高频面试题全解析》的个人总结和补充

Java集合按照其存储结构可以分为两大类,分别是单列集合java.util.Collection和双列集合java.util.Map,如下图:

image.png

HashMap和Hashtable的区别

  • HashMap没有考虑同步,是线程不安全的;Hashtable使用了Synchronized关键字,是线程安全的;
  • HashMap允许null作为key;Hashtableb允许null作为key,Hashtable的value也不可以为null。

HashMap是线程不安全的是吧?举一个🌰

答:(注意,以下是候选人常见的错误理解!!!,因为下面这个答案是大家背出来的)

有一个快速失败fast-fail机制,当对HashMap遍历的时候,调用了remove方法使其迭代器发生改变的时候会抛出一个异常ConcurrentModificationException。Hashtable因为在方法上做了synchronized处理,所以不会抛出异常。(自信的语气^_^感觉面试官很low)。

先给出正确答案:

  • HashMap线程不安全主要考虑到了多线程环境下进行扩容可能会出现HashMap死循环;
  • HashTable线程安全是由于其内容实现在put和remove等方法上使用synchronized进行同步,所以对单个方法的使用是线程安全的。但是对多个方法进行复合操作是,线程安全无法保证。比如一个线程在进行get然后put更新的操作,这就是两个复合操作,在两个操作之间,可能别的线程已经对这个key做了改动,所以,你接下来的put操作可能会不符合预期。

Java集合中快速失败(fast-fail)机制?

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

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

那么快速失败机制是怎么实现的呐?

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

image.png

前面常见的错误答案,错误的认为快速机制就是HashMap线程不安全的表现。并且坚定的认为Hashtable和Vector等线程安全的集合不会存在并发修改时候的快速失败,这是大错特错。概念和原理理解的不清晰导致掉入了面试官的陷阱里了,大家可以打开JDK源码,会发现Hashtable也会在迭代的时候抛出该异常,可能发生快速失败。

HashMap底层实现结构有了解🐴?

面试:jdk8前后的结构:数组+链表+红黑树 -> 红黑树:在二叉查找树(BST)上增加了一定规则(性质4和5)的颜色标记,保证了平衡,复杂度为o(logn),统计性能好于平衡二叉树(AVL)-> 但是在hashCode离散型很好的时候,链表转变为红黑树的概率极小,为6/1亿 -> 存储和获取原理 -> 扩容机制(概念:初始容量、加载因子和扩容增量;hashMap长度2的幂次方;扩容步骤rehashing。

答:HashMap底层实现数据结构为数组+链表的形式,jdk8及其以后的版本使用了数组+链表+红黑树实现,解决了链表太长导致的查询速度过慢的问题。如下图:

image.png

拓展知识:

HashMap的初始容量,加载因子,扩容增量是多少?

答:HashMap的初始容量16,加载因子为0.75,扩容增量是原容量的1倍。如果HashMap的容量为16,一次扩容后容量为32。HashMap扩容是指元素个数(数组和链表+红黑树中)超过了16*0.75=12之后开始扩容。

HashMap的长度为什么是2的幂次方?

(key的定位是通过hash&length-1 ->好处)

  • 我们将一个键值对插入HashMap中,通过将Key的hash值与length-1进行&运算,实现了当前Key的定位,2的幂次方可以减少冲突(碰撞)的次数,提高HashMap查询效率
  • 如果length为2的幂次方,则length-1 转化为二进制必定是11111……的形式,在与h的二进制与操作效率会非常的快,而且空间不浪费
  • 如果length不是2的幂次方,比如length为15,则length-1为14,对应的二进制为1110,在与h与操作,最后一位都为0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空间的浪费。

总结:也就是说2的N次幂有助于减少碰撞的几率,空间利用率比较大。这样你就明白为什么第一次扩容会从16 ->32了吧?总不会再说32+1=33或者其余答案了吧?至于加载因子,如果设置太小不利于空间利用,设置太大则会导致碰撞增多,降低了查询效率,所以设置了0.75

HashMap的存储和获取原理?

调用put()方法传递键和值来存储时,先对键调用hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象,也就是找到了该元素应该被存储的桶中(数组)。当两个键的hashCode值相同时,bucket位置发生了冲突,也就是发生了Hash冲突,这个时候,会在每一个bucket后边接上一个链表(JDK8及以后的版本中还会加上红黑树)来解决,将新存储的键值对放在表头(也就是bucket中)。

调用get方法获取存储的值时,首先根据键的hashCode找到对应的bucket,然后根据equals方法来在链表和红黑树中找到对应的值。

HashMap的扩容步骤?

HashMap里面默认的负载因子大小为0.75,也就是说,当Map中的元素个数(包括数组,链表和红黑树中)超过了16*0.75=12之后开始扩容。将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。

当然了,上述的扩容机制是比较低效的。所以,我们伟大的JDK开发人员在1.8版本中做了一个扩容效率方面的优化。因为是2的N次幂扩容,所以一个元素要么在原位置不动,要么移动到当前位置+2的N次幂(也就是oldIndex+OldCap的位置)。

怎么实现呢?

  • 说白了,就是通过新增的bit位置上是0还是1来判断。
  • 0则是原位置,1则是oldIndex+OldCap位置。

这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块是JDK1.8新增的优化点,感兴趣的同学可以去看下源码。

但是,需要注意的是在多线程环境下,HashMap扩容可能会导致死循环。

引用作者评论:先来看下HashMap中的这几个概念吧。transient int size:记录了Map中K-V对的个数; loadFactor:装载印子,用来衡量HashMap满的程度。loadFactor的默认值为0.75f(static final float DEFAULT_LOAD_FACTOR = 0.75f); int threshold:临界值,当实际K-V个数超过threshold时,HashMap会将容量扩容,threshold=容量*加载因子; 容量(capacity):如果不指定,默认容量是16(static final int DEFAULT_INITIAL_CAPACITY = 1 << 4); 在扩容的时候,我们比较的是if (++size > threshold) resize(); 总结:当前HashMap中实际拥有的K-V个数超过threshole即进行扩容操作。

解决Hash冲突的方法有哪些?

  • 拉链法 (HashMap使用的方法)
  • 线性探测再散列法
  • 二次探测再散列法
  • 伪随机探测再散列法

哪些类适合作为HashMap的键?

String和Interger这样的包装类很适合做为HashMap的键,因为他们是final类型的类,而且重写了equals和hashCode方法,避免了键值对改写,有效提高HashMap性能。 为了计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashCode的话,那么就不能从HashMap中找到你想要的对象。

ConcurrentHashMap和Hashtable的区别?

答:ConcurrentHashMap结合了HashMap和Hashtable二者的优势。 HashMap没有考虑同步,Hashtable考虑了同步的问题。但是Hashtable在每次同步执行时都要锁住整个结构。

ConcurrentHashMap锁的方式是稍微细粒度的,ConcurrentHashMap将hash表分为16个桶(默认值),诸如get,put,remove等常用操作只锁上当前需要用到的桶。

ConcurrentHashMap的具体实现方式(分段锁):

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

image.png

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

image.png

在实际的开发中,我们在单线程环境下可以使用HashMap,多线程环境下可以使用ConcurrentHashMap,至于Hashtable已经不被推荐使用了(也就是说Hashtable只存在于面试题目中了)。

TreeMap有哪些特征?

答:TreeMap底层使用红黑树实现,TreeMap中存储的键值对按照键来排序

  • 如果Key存入的是字符串等类型,那么会按照字典默认顺序排序
  • 如果传入的是自定义引用类型,比如说User,那么该对象必须实现Comparable接口,并且覆盖其compareTo方法;或者在创建TreeMap的时候,我们必须指定使用的比较器。如下所示:
// 方式一:定义该类的时候,就指定比较规则
class User implements Comparable{
    @Override
    public int compareTo(Object o) {
        // 在这里边定义其比较规则
        return 0;
    }
}
public static void main(String[] args) {
    // 方式二:创建TreeMap的时候,可以指定比较规则
    new TreeMap<User, Integer>(new Comparator<User>() {
        @Override
        public int compare(User o1, User o2) {
            // 在这里边定义其比较规则
            return 0;
        }
    });
}

Comparable接口和Comparator接口有哪些区别呢?

  • Comparable接口的后缀able表示可比较的;当需要重新定义比较规则时,必须修改源代码,即修改User类里边的compareTo方法;
  • Comparator接口不需要修改源代码,只需要在创建TreeMap的时候重新传入一个具有指定规则的比较器即可

升降序写法傻傻分不清楚:Java的Comparator升序降序的记法

@Override
public int compare(CommentVo o1, CommentVo o2) {
           return o1.getTime().compareTo(o2.getTime());
}
  • 返回-1(或负数),表示不需要交换o1和o2的位置,o1排在o2前面,asc
  • 返回1(或正数),表示需要交换o1和o2的位置,o1排在o2后面,desc

ArrayList和LinkedList有哪些区别?

  • ArrayList底层使用了动态数组实现,实质上是一个动态数组
  • LinkedList底层使用了双向链表实现,可当作堆栈、队列、双端队列使用
  • ArrayList在随机存取方面效率高于LinkedList
  • LinkedList在节点的增删方面效率高于ArrayList
  • ArrayList必须预留一定的空间,当空间不足的时候,会进行扩容操作
  • LinkedList的开销是必须存储节点的信息以及节点的指针信息

HashSet和TreeSet有哪些区别?

  • HashSet底层使用了Hash表实现(其实还是HashMap,只不过只使用了其中的key) 保证元素唯一性的原理:判断元素的hashCode值是否相同。如果相同,还会继续判断元素的equals方法,是否为true。
  • TreeSet底层使用了红黑树来实现 保证元素唯一性是通过Comparable或者Comparator接口实现。

LinkedHashMap和LinkedHashSet有了解吗?

答:LinkedHashMap可以记录元素的插入顺序访问顺序,具体实现如下:

  • LinkedHashMap内部的Entry继承于HashMap.Node,这两个类都实现了Map.Entry<K,V>
  • LinkedHashMap的Entry不光有value,next,还有before和after属性,这样通过一个双向链表,保证了各个元素的插入顺序
  • 通过构造方法public LinkedHashMap(int initialCapacity,float loadFactor,boolean accessOrder), accessOrder传入true可以实现LRU缓存算法(访问顺序)
  • LinkedHashSet 底层使用LinkedHashMap实现,两者的关系类似与HashMap和HashSet的关系,大家可以自行类比。

扩展: 什么是LRU算法?LinkedHashMap如何实现LRU算法?

LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高!”

由于LinkedHashMap可以记录下Map中元素的访问顺序,所以可以轻易的实现LRU算法。只需要将构造方法的accessOrder传入true,并且重写removeEldestEntry方法即可。实现如下:

private static int MAX_ENTRIES = 5;

    @Test
    public void LRUTest() {
        Map<String, String> map = new LinkedHashMap<String, String>(MAX_ENTRIES, 0.75f, true) {
            /**
             * this override will allow the map to grow up to 100
             * entries and then delete the eldest entry each time a new entry is
             * added, maintaining a steady state of 100 entries.
             */
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
                return size() > MAX_ENTRIES;
            }
        };
        map.put("1", "1");
        map.put("2", "2");
        map.put("3", "3");
        map.put("4", "4");
        map.put("5", "5");
        System.out.println(map.toString());
        map.put("6", "6");
        System.out.println(map.toString());
        map.get("3");
        System.out.println(map.toString());
        map.put("7", "7");
        System.out.println(map.toString());
        map.get("3");
        System.out.println(map.toString());
        map.remove("3");
        System.out.println(map.toString());
    }

Iterator是什么?

Iterator其实就是一个迭代器,在遍历集合的时候需要使用。Demo实现如下:

ArrayList<String> list =  new ArrayList<>();
list.add("zhangsan");
list.add("lisi");
list.add("yangwenqiang");
// 创建迭代器实现遍历集合
Iterator<String> iterator = list.iterator();
while(iterator.hasNext()){
    System.out.println(iterator.next());
}

Collection和Collections有什么关系?

  • Collection是一个顶层集合接口,其子接口包括List和Set;
  • 而Collections是一个集合工具类,可以操作集合,比如说排序,二分查找,拷贝集合,寻找最大最小值等。

总而言之:带s的大都是工具类。

数组和List之间的转换?

数组和集合Lis的转换在我们的日常开发中是很常见的一种操作,主要通过Arrays.asList以及List.toArray方法来搞定。demo如下:

public class ConverTest {
    public static void main(String[] args) {
        // list集合转换成数组
        ArrayList<String> list =  new ArrayList<>();
        list.add("zhangsan");
        list.add("lisi");
        list.add("yangwenqiang");
        Object[] arr = list.toArray();
        for (int i = 0; i < arr.length; i++) {
            System.out.println(arr[i]);
        }
        System.out.println("---------------");
        // 数组转换为list集合
        String[] arr2 = {"niuke", "alibaba"};
        List<String> asList = Arrays.asList(arr2);
        for (int i = 0; i < asList.size(); i++) {
            System.out.println(asList.get(i));
        }
 
    }
}

数组转为集合List: 通过Arrays.asList方法搞定,转换之后不可以使用add/remove等修改集合的相关方法,因为该方法返回的其实是一个Arrays的内部私有的一个类ArrayList,该类继承于Abstractlist,并没有实现这些操作方法,调用将会直接抛出UnsupportOperationException异常。这种转换体现的是一种适配器模式,只是转换接口,本质上还是一个数组。

集合转换数组: List.toArray方法搞定了集合转换成数组,这里最好传入一个类型一样的数组,大小就是list.size()。因为如果入参分配的数组空间不够大时,toArray方法内部将重新分配内存空间,并返回新数组地址;如果数组元素个数大于实际所需,下标为list.size()及其之后的数组元素将被置为null,其它数组元素保持原值。所以,建议该方法入参数组的大小与集合元素个数保持一致。

若是直接使用toArray无参方法,此方法返回值只能是Object[]类,若强转其它类型数组将出现ClassCastException错误。

引申:深入理解List的toArray()方法和toArray(T[] a)方法