Java集合面试题

一、集合容器概述

常见的集合类有哪些?

Map接口和Collection接口是所有集合框架的父接口。下图中的实线和虚线看着有些乱,其中接口与接口之间如果有联系为继承关系,类与类之间如果有联系为继承关系,类与接口之间则是类实现接口。重点掌握的抽象类有HashMap,LinkedList,HashTable,ArrayList,HashSet,Stack,TreeSet,TreeMap。注意:Collection接口不是Map的父接口。2FEEC0C84794742A6C4BE56036D67EEC.png16A36946196DEBF844CE419A4984C76D.png


说一下List、Se​t和Map三者的区别?

|   | List | Set | Map | | --- | --- | --- | --- | | 特点 | 有序、元素可重复、可添加多个null元素 | 无序、元素不可重复、只可添加一个null | Key-Value键值对,key无序但是唯一 | | 实现类 | ArrayList、LinkedList、Vector | HashSet、LinkedHashSet、TreeSet | HashMap、HashTable、LinkedHashMap、TreeMap、ConcurrentHashMap | | 使用场景 | 如果容器中的元素想按照插入的次序有序存储选择List | 插入的元素唯一 | 以键和值的形式存储 |


哪些集合类是线程安全的?

  1. 遗留的安全集合:Vector、Hashtable
  2. 使用Collections装饰的线程安全集合:利用装饰器模式,将原来的集合类中所有方法重写,然后用synchronized来修饰,从而保证线程安全性。
    1. Collections. synchronizedCollection()
    2. Collections.synchronizedList()
    3. Collections.synchronizedMap()
    4. Collections.synchronizedSet()
  3. JUC包下的集合类(Blocking、CopyOnWrite、Concurrent):
    1. Blocking 大部分实现基于锁,并提供用来阻塞的方法。
    2. CopyOnWrite之类容器修改开销相对较重。
    3. Concurrent 类型的容器(推荐使用)
      1. 内部操作使用CAS优化,一般可以提供较高的吞吐量。
      2. 会存在弱一致性(缺点)
      3. 遍历时弱一致性:使用迭代器遍历的时候,其他线程对容器修改,迭代器仍然可以继续遍历,但是内容是旧的。【safe-fast】(注:对于非安全容器,如果遍历的时候发生修改,会使用fail-fast机制抛出ConcurrentModificationException,不再继续遍历。)
      4. 求大小时弱一致性:size()操作不一定能保证100%正确
      5. 读取弱一致性

数组(Array)和列表(ArrayList)的区别?

|
| Array | ArrayList | | --- | --- | --- | | 存储内容 | 存储基本数据类型、对象 | 对象 | | 长度是否可变 | 一旦初始化,长度不可变 | 长度可以动态改变 | | 是否可以获取实际存储个数 | 无法判断实际存储个数 | 通过调用size()方法可以获得实际存储个数 | | 存储和操作性 | 顺序存储 | 数据结构更加灵活,插入、删除和添加数据更加方便 |


Collection和Collections的区别?

  1. Collection是Set和List接口的上级接口,子接口主要有Set 和List。Map集合虽然也属于集合体系,但是Map并不继承Collection,Map和Collection是平级关系。
  2. Collections是针对Collection类的一个工具类,它包含有各种有关集合操作的静态多态方法,例如常用的sort()方法、搜索排序线程安全化(Collections.synchronizedList())。此类不能实例化,就像一个工具类,服务于Java的Collection框架。

comparable 和 comparator的区别?

  1. comparable接口出自java.lang包,可以理解为一个内比较器,因为实现了Comparable接口的类可以和自己比较,要和其他实现了Comparable接口类比较,可以使用compareTo(Object obj)方法。compareTo方法的返回值是int,有三种情况:
    1. 返回正整数(比较者大于被比较者)
    2. 返回0(比较者等于被比较者)
    3. 返回负整数(比较者小于被比较者)
  2. comparator接口出自java.util 包,它有一个compare(Object obj1, Object obj2)方法用来排序,返回值同样是int,有三种情况,和compareTo类似。

很多包装类都实现了comparable接口,像Integer、String等,所以直接调用Collections.sort()直接可以使用。如果对类里面自带的自然排序不满意,而又不能修改其源代码的情况下,使用Comparator就比较合适。此外使用Comparator可以避免添加额外的代码与我们的目标类耦合,同时可以定义多种排序规则,这一点是Comparable接口没法做到的,从灵活性和扩展性讲Comparator更优,故在面对自定义排序的需求时,可以优先考虑使用Comparator接口。

二、List集合

关于迭代器Iterator

迭代器Iterator是什么?有何特点?

迭代器是常用的设计模式,属于行为型模式。可以对不同的数据结构,提供统一的遍历接口,它支持以不同的方式遍历一个聚合对象。主要提供hasNext()、next()和remove()方法。
迭代器允许调用者在迭代过程中移除元素。【iterator.remove()】底层其实还是用集合自己的remove()。

Iterator怎么使用?

  1. Java.lang.Iterable 接口被 Java.util.Collection 接口继承,Java.util.Collection 接口的 iterator() 方法返回一个Iterator对象。
  2. next()方法获得集合中的下一个元素:通过游标取值,然后游标+1。
  3. hasNext() 检查集合中是否还有元素。【通过比较游标和列表的大小】
  4. remove() 方法将迭代器新返回的元素删除。

    如何边遍历边移除 Collection 中的元素?

边遍历边修改 Collection 的唯一正确方式是使用迭代器Iterator.remove() ```java public class ListTest { public static void main(String[] args) { ArrayList list = new ArrayList<>(); list.add("A"); list.add("B"); Iterator iterator = list.iterator(); while (iterator.hasNext()){ String s = iterator.next(); if(s.equals("A")){ iterator.remove(); } } System.out.println(list.toString()); } }

**一种最常见的错误代码for(Integer i : list){ list.remove(i);}** java public class ListTest { public static void main(String[] args) { ArrayList list = new ArrayList<>(); list.add("A"); list.add("B"); Iterator iterator = list.iterator(); while (iterator.hasNext()){ String s = iterator.next(); if(s.equals("A")){ list.remove("A"); } } } } ``` 运行以上错误代码会报ConcurrentModificationException异常。这是因为使用上述语句会自动生成一个iterator 来遍历该 list,但同时该 list 正在被Iterator.remove() 修改。Java 一般不允许一个线程在遍历 Collection 时另一个线程修改它。
集合每次添加和删除元素的时候,实际修改次数加1,在获取迭代器的时候将实际修改次数赋值给期望修改次数。实际修改次数和期望修改次数不一致,就会抛出异常。所以想要删除集合中的元素要用迭代器对象进行修改。【iterator.remove();】

快速失败(fail-fast)和安全失败(fail-safe)区别?

快速失败

在使用迭代器对集合对象进行遍历的时候,如果 A 线程正在对集合进行遍历,此时 B 线程对集合进行修改(增加、删除、修改),或者 A 线程在遍历过程中对集合进行修改,都会导致 A 线程抛出。ConcurrentModificationException 异常。

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

安全失败

采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,故不会抛 ConcurrentModificationException 异常。
快速失败和安全失败是对迭代器而言的。并发环境下建议使用 java.util.concurrent 包下的容器类(JUC包下的容器类都是线程安全的),除非没有修改操作。


谈一谈你对ArrayList的理解?

ArrayList底层实现原理?

image.png

  1. ArrayList的底层数据结构是动态数组
  • 通过下标可以随机访问元素,时间复杂度为O(1)
  • 顺序添加一个元素的时候非常方便
  • 它的长度可以动态调整
  1. ArrayList采用了快速失败机制,通过记录modCount参数来实现。在面对并发的修改时,迭代器很快就会完全失败,而不是冒着在将来某个不确定时间发生任意不确定行为的风险。
  2. *ArrayList的继承关系 *
  • 继承AbstarctList类:该类提供了List接口的骨架实现。
  • 实现了Serializable标记性接口,因此它支持序列化和反序列化。

可以将ArrayList对象的数据写入到文件,也可以从文件中的数据反序列化成对象。
public interface Serializable {} //接口里面什么东西都没有,只是一个标记

  • 实现了Cloneable标记性接口,因此它支持依据原有的数据,克隆出一份完全一样的数据拷贝。ArrayList需要重写Clonable接口的clone()方法。
  • 实现了RandomAccess标记性接口,它支持快速随机访问,实际上就是通过下标序号进行快速访问。
  1. ArrayList的构造方法
    1. JDK1.8及之后,初始化属于懒汉式,只有当添加第一个元素的时候再创建长度为9的数组,采用懒加载的形式。
    2. JDK1.8之前,初始化属于饿汉式,直接创建一个长度为10的数组。
  2. ArrayList线程不安全

    ArrayList的扩容机制?

image.png

| * * | JDK1.6及以前版本 | JDK1.8及以后 | | | --- | --- | --- | --- | | 扩容策略 | newCapacity = (oldCapacity * 3)/2 + 1 | newCapacity = oldCapacity + (oldCapacity >> 1)
新容量是原容量的1.5倍 | | | 是否无限扩容 | 是 | 否,最大容量Integer.MAX_VALUE - 8 | | | 扩容方式 |   | 以新的容量创建新的数组,把原来的就数组复制到新数组,原数组被GC回收。 | | | 扩容效率 | 位运算的速度远远快于整除运算 | | |

ArrayList线程为什么不安全?

在多个线程进行add操作时可能会导致elementData数组越界或值覆盖情况。 java public boolean add(E e) { ensureCapacityInternal(size + 1); elementData[size++] = e; return true; } image.png

如何复制某个ArrayList到另外一个ArrayList中?

  1. 利用clone():ArrayList list2 = (ArrayList ) list.clone();
  2. 利用addAll():list2.addAll(list);
  3. 利用ArrayList的构造方法:ArrayList list2 = new ArrayList<>(list);

    ArrayList频繁扩容导致添加性能急剧下降,如何处理?

```java public class ListTest { public static void main(String[] args) { ArrayList list1 = new ArrayList<>(200000); long start = System.currentTimeMillis(); for (int i = 0; i < 100000; i++) { list1.add(i + ""); } long end = System.currentTimeMillis(); System.out.println(end - start); System.out.println("=================================="); ArrayList list2 = new ArrayList<>(200000); long start1 = System.currentTimeMillis(); for (int i = 0; i < 100000; i++) { list2.add(i + ""); } long end1 = System.currentTimeMillis(); System.out.println(end1 - start1); } }

输出结果:

17

7 ``` 如果使用ArrayList来添加海量数据的时候,频繁扩容会导致性能下降。解决方案是对ArrayList进行初始化的时候提前设定好容量,而不是频繁扩容,但是这样也会造成内存浪费的情况。

说一下ArrayList和Vector的异同?

| * * | ArrayList | Vector | | --- | --- | --- | | 接口实现 | 实现List接口 | | | 底层实现 | 动态数组 | | | 是否线程安全 | 线程不安全 | Vector使用了Synchronized来实现线程同步,所以是线程安全的 | | 性能 | 性能好 | 由于Vector使用了Synchronized进行加锁,所以性能不如ArrayList​ | | 扩容 | 新容量是旧容量的1.5倍 | 新容量是旧容量的2倍 | | 是否支持快速随机访问 | 都支持快速随机访问 | |


谈一下你对LinkedList的理解?

LinkedList的底层实现原理?

  1. LinkedList底层通过双向链表实现,双向链表的每个节点用内部类Node表示。LinkedList通过first和last引用分别指向链表的第一个和最后一个元素。注意这里没有所谓的哑元,当链表为空的时候first和last都指向null。 java transient int size = 0; transient Node<E> first; transient Node<E> last;

  2. LinkedList同时实现了List接口和Deque接口,也就是说它既可以看作一个顺序容器,又可以看作一个队列(Queue),同时又可以看作一个栈(Stack)。

  3. LinkedList的实现方式决定了所有跟下标相关的操作都是线性时间,而在首段或者末尾删除元素只需要常数时间。
  4. 为追求效率LinkedList没有实现同步(synchronized),如果需要多个线程并发访问,可以先采用Collections.synchronizedList()方法对其进行包装。

    说一下ArrayList和LinkedList的异同?

| * * | ArrayList | LinkedList | | --- | --- | --- | | 接口实现 | 都实现List接口 | 不但实现List接口,还实现Deque接口,所以LinkedList还可以当做双端队列来用 | | 底层实现 | 动态数组 | 双向链表 | | 是否线程安全 | 线程不安全 | | | 内存占用 | ArrayList会存在一定的空间浪费,因为每次扩容都是之前的1.5倍 | LinkedList中的每个元素要存放直接后继和直接前驱以及数据,所以对于每个元素的存储都要比ArrayList花费更多的空间 | | 插入、删除和查找 | 插入:1.在尾部插入元素O(1);2.在指定位置插入元素O(N),因为插入后还需要移动元素
删除:1.按照元素位置删除元素O(N),因为删除后需要移动;2.按照元素值删除元素O(N),因为需要先找到元素位置,删除后还需要移动位置
查找:根据下标可以直接找到元素值O(1) | LinkedList的底层数据结构是双向链表,所以插入和删除元素不受位置的影响,平均时间复杂度为O(1),如果是在指定位置插入则是O(n),因为在插入之前需要先找到该位置,读取元素的平均时间复杂度为O(n)。所以插入:1.在尾部插入元素O(1);2.在指定位置插入元素O(N),需要先找到插入的位置删除:1.按照元素位置删除元素O(N),因为删除后需要移动;2.按照元素值删除元素O(N),因为需要先找到元素位置查找:1.根据下标获得元素O(N),需要遍历链表查找;2.可以O(1)获得头和尾元素 | | 应用场景 | ArrayList更加适用于多读,少增删的场景。 | LinkedList更加适用于多增删,少读写的场景。 |

阐述 ArrayList、Vector、LinkedList 的存储性能和特性?

  1. ArrayList底层数据结构为数组,对元素的读取速度快,而增删数据慢,线程不安全。
  2. LinkedList底层为双向链表,对元素的增删数据快,读取慢,线程不安全。
  3. Vector的底层数据结构为数组,用Synchronized来保证线程安全,性能较差,但线程安全。

说出几种线程安全的List?

  1. Vector:遗留的线程安全的list,功能和ArrayList基本一样,但是由于是线程安全的,并发访问需要加锁,所以性能低,现在一般不用了。
  2. 通过Collections的synchronizedList方法将其转换成线程安全的容器后再使用。List synchronizedList = Collections.synchronizedList(new ArrayList<>());
    1. 和ArrayList的功能一样,只不过是在每个方法前面加了synchronized关键字保证线程安全。
  3. 使用JUC并发包下的CopyOnWriteArrayList类。
    1. CopyOnWriteArrayList称为“写时复制”容器,就是在需要对容器进行操作的时候,将容器拷贝一份,对容器的修改等操作都在容器的拷贝中进行,当操作结束,再把容器的拷贝指向原来的容器。这样设计的好处是实现了读写分离,并且不会发生线程阻塞。使用场景:读和写同时操作。

谈一下你对CopyOnWriteArrayList的理解?

image.png

  1. CopyOnWriteArrayList底层通过数组实现。
  2. 写数据要先加ReentrantLock锁,然后复制一个数组进行修改,写操作完成后可以用volatile写的方式,把这个副本数组赋值给volatile修饰的那个数组的引用变量了。只要赋值给那个volatile修饰的变量,立马就会对读线程可见,大家都能看到最新的数组了。 java public boolean add(E e) { final ReentrantLock lock = this.lock; //获得锁 lock.lock(); try { Object[] elements = getArray(); int len = elements.length; //复制一个新的数组 Object[] newElements = Arrays.copyOf(elements, len + 1); //插入新值 newElements[len] = e; //将新的数组指向原来的引用 setArray(newElements); return true; } finally { //释放锁 lock.unlock(); } }

  3. 读操作不需要加锁,在原数组上进行读。

  4. CopyOnWriteArrayList的缺点:
  • 内存占用问题:在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象。
  • 数据一致性问题:只能保证数据的最终一致性,不能保证数据的实时一致性。 > 关联: > 1. CopyOnWriteArrayList的写时复制原理和Redis的RDB子进程持久化时,子进程和主进程同时使用一块内存,当主进程要修改时就是使用【写时复制】机制去copy一个副本给子进程使用 > 1. 和Redis的AOF重写时,子进程和主进程同时使用一块内存,当主进程要修改时就是使用【写时复制】机制去copy一个副本给子进程使用

三、Set集合

HashSet实现原理是什么?有什么特点?

实现原理:HashSet是基于HashMap 实现的,HashSet的值存放于HashMap的key上,所以HashSet不允许重复的值,HashMap的value统一为PRESENT,相关HashSet 的操作,基本上都是直接调用底层HashMap 的相关方法来完成。底层实现:数组+链表特点:

  1. 查询速度特别快。
  2. 无序、不可重复。
  3. 由于HashMap 是支持 key 为 null 值的,所以 HashSet 可添加 null 值
  4.  HashSet 存放自定义类时,自定义类需要重写 hashCode() 和 equals() 方法,确保集合对自定义类的对象的唯一性判断。

    HashSet如何检查重复的?(HashSet的add()操作)

向HashSet中添加元素a,首先计算出a的hashCode(),然后区低16位和高16位进行异或操作得到hash,然后根据hash值求得table的下标。
(h = key.hashCode()) ^ (h >>> 16)
idx = h & (table.length - 1)
判断table[idx]是否有元素:

  • 如果此位置上没其他元素,则元素a添加成功。             --->情况1
  • 如果此位置上其他元素b(或以链表形式存在的多个元素,则比较元素a与元素b的hash值:
    • 如果hash值不相同,则元素a添加成功。                --->情况2
    • 如果hash值相同,进而需要调用元素a所在类的equals()方法:
      • equals()返回true,元素a添加失败
      • equals()返回false,则元素a添加成功。            --->情况3

对于添加成功的情况2和情况3而言:元素a 与已经存在指定索引位置上数据以链表的方式存储。
jdk 7:元素a放到数组中,指向原来的元素。
jdk 8:原来的元素在数组中,指向元素a

对LinkedHashSet的理解?

LinkedHashSet可以按照添加的顺序遍历。在添加数据的同时,每个数据还维护了两个引用,记录此数据前一个数据和后一个数据。对于频繁的遍历操作, LinkedHashSet效率高于HashSet。

说一下HashMap和HashSet的区别?

HashSet底层基于HashMap来实现。

| HashMap | HashSet | | --- | --- | | 实现Map接口 | 实现Set接口 | | 存储键值对 | 存储唯一对象 | | key唯一,value不唯一 | 存储对象唯一 | | HashMap使用键(Key)计算hashCode | HashSet使用成员对象来计算hashCode | | 调用put()向map添加元素 | 调用add ()向set添加元素 |


四、HashMap

Map的遍历方式?

方法一:在for循环中遍历Map的key的集合 java for(String key : map.keySet()){ String value = map.get(key); System.out.println(key + ":" + value); } 方法二:在for循环中遍历Map的value的集合 java Map <String,String> map = new HashMap<String,String>(); for(String value : map.values()){ System.out.println(value); } 方法三:在for循环中使用Entry集合进行遍历 java Map <String,String> map = new HashMap<String,String>(); for(Map.Entry<String, String> entry : map.entrySet()){ String mapKey = entry.getKey(); String mapValue = entry.getValue(); System.out.println(mapKey + ":" + mapValue); } 方法四:通过Iterator遍历 java Iterator<Entry<String, String>> entries = map.entrySet().iterator(); while(entries.hasNext()){ Entry<String, String> entry = entries.next(); String key = entry.getKey(); String value = entry.getValue(); System.out.println(key + ":" + value); }

Map的实现类中,哪些是有序的,哪些是无序的?如何保证其有序性?

Map 的实现类有* HashMap、LinkedHashMap、TreeMap*。

  1. HashMap是有无序的。
  2. LinkedHashMap记录了添加数据的顺序,是有序的;LinkedHashMap 底层存储结构是哈希表+链表,链表记录了添加数据的顺序。
  3. TreeMap默认是按key升序,TreeMap 底层存储结构是二叉树,二叉树的中序遍历保证了数据的有序性。

    说说你对哈希值、哈希表和哈希函数的理解?

Hash也称散列或者哈希。基本原理就是通过一定的散列算法,将任意长度的输入,经过hash算法变成固定长度的输出。原始数据映射后的二进制就是哈希值(int值)。哈希表:存储哈希值的数组。
哈希值应该怎么存,怎么取?通过数组的角标实现数据的存取。哈希函数:将哈希值通过某种运算规则得到哈希表的角标位。

hash算法任意长度的输入转化为了固定长度的输出,会不会有问题?

两个元素通过哈希函数运算后,会发生得到的角标是相同的,这就是哈希冲突。由于hash的原理是将输入空间的映射成hash空间内,所以hash值的空间肯定要小于输入的空间,那么就会出现hash冲突现象。

hash冲突能避免么?怎样解决哈希冲突?

Hash冲突避免不了,就像有10个苹果,将所有苹果放到9个抽屉里面,那么至少有一个抽屉会有两个苹果。Hash冲突现象其实就是“抽屉原理”。处理哈希冲突:溢出区开放定址法、链地址法、再哈希法

HashMap如何解决哈希冲突?

哈希冲突:hashMap在存储元素时会先计算key的hash值来确定存储位置,因为key的hash值计算最后有个对数组长度取余的操作,所以即使不同的key也可能计算出相同的hash值,这样就引起了hash冲突。hashMap的底层结构中的链表/红黑树就是用来解决这个问题的。
HashMap中的哈希冲突解决方式可以主要从三方面考虑(以JDK1.8为背景)

  1. 链地址法HasMap中的数据结构为数组+链表/红黑树,当不同的key计算出的hash值相同时,就用链表的形式将Node结点(冲突的key及key对应的value)挂在数组后面。
  2. hash函数key的hash值经过两次扰动,key的hashCode值与key的hashCode值的右移16位进行异或,然后对数组的长度取余(实际为了提高性能用的是位运算,但目的和取余一样),这样做可以让hashCode取值出的高位也参与运算,进一步降低hash冲突的概率,使得数据分布更平均。
  3. 红黑树在拉链法中,如果hash冲突特别严重,则会导致数组上挂的链表长度过长,性能变差,因此在链表长度大于8时,将链表转化为红黑树,可以提高遍历链表的速度。

    为什么hash冲突后性能变低了?

因为哈希冲突后,冲突的Node是以链表的形式存储的。那么链表的查询时间复杂度是O(n),所以说发生hash冲突后性能就会降低。

你认为好的hash算法,应该考虑点有哪些呢?

  1. 通过hash值不能逆推出原始数据。
  2. 输入数据的微小变化会得到不同的hash值。
  3. 相同的原始数据一定得到相同的hash值。
  4. 哈希算法要高效,对于长文本也能够快速计算出hash值。
  5. Hash算法的冲突概率要小(hash值要分布均匀)。

    key对象的hashcode()返回值是hash字段的值吗?hash值是怎么得到的?为什么将hashCode()的返回值进行二次扰动生成hash字段的值?

hash字段不是key对象的hashCode()返回值。
hash = (key.hashCode()) ^ (key.hashCode()>>> 16);好处:通过两次扰动让自己的低16位与高16位做异或运算,使得高16位也参与到hash的运算能降低哈希碰撞概率也使得数据分布更平均。

为什么不直接使用hashCode()返回值作为桶的下标?

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

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

计算数组的下标公式为(n-1) & hash
假设n是8(1000),n-1是7(111),任何一个hash数和n-1进行与运算的结果范围是0~7,也就是数组的下标范围,实现了求余的功能,但是使用位运算效率更高。假设n不是2的幂次方:

  • N为偶数(n=6):n-1是5(101),任何一个hash数和n-1进行与运算,第二位都是0,那么数组范围会浪费。
  • N为奇数(n=7):n-1是6(110),任何一个hash树和n-1进行与运算,结果都是偶数,数组范围浪费一半。

综上可以看到,如果n不是2的幂次方,那么求数组的下标时,不管n是奇数还是偶数,那么都会使数组中的值分布不均匀。n是2的幂次方,还有一个好处:扩容后数组的长度变成原来的2倍,还是2的幂次方。那么数据进行迁移时,要么在原来位置,要么在原来位置+扩容长度。不需要进行再次哈希计算,提高效率。

HashMap的长度永远都是2次幂,如果我们指定初始容量不为2次幂时呢,是不是就破坏了这个规则?利用new HashMap(6)来创建HashMap,长度是多少?

不会的,HashMap 的tableSizeFor方法做了处理,能保证n永远都是2次幂。如果指定的容量不为2的次幂,可以通过运算找到一个比初始值大的2的幂次方数。 java 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; } 长度是8。因为HashMap的长度一定是2的幂次方,如果初始化的时候指定的容量不是2的幂次方,那么就会使用tableSizeFor()方法将找一个比指定大小大的2的幂次方数当做HashMap的容量。

HashMap默认初始容量是多少?散列表是new HashMap()时创建的么?

  1. 默认初始容量是16。
  2. JDK1.7中散列表是在初始化时创建。
  3. 在JDK1.8及以后散列表的创建懒加载思想,初始化的时候只创建一个空的数组,只有在第一次使用put往HashMap里面添加数据的时候才创建一个长度为16的数组。

    默认负载因子是多少?为什么默认是0.75?负载因子的作用?

默认负载因子是0.75
这个主要是考虑空间利用率和查询成本的一个折中。如果加载因子过高,空间利用率提高,但是会使得哈希冲突的概率增加;如果加载因子过低,会频繁扩容,哈希冲突概率降低,但是会使得空间利用率变低。具体为什么是0.75,不是0.74或0.76,这是一个基于数学分析(泊松分布)和行业规定一起得到的一个结论。
作用:计算扩容阈值 = 数组长度 * 负载因子。举例:使用无参构造方法创建HashMap,默认长度是16,默认的负载因子是0.75,那么第一次的扩容阈值是16*0.75=12,就是当HashMap中的桶位超过12个有元素的时候就要进行扩容。

HashMap中存储数据的结构是什么样的?

JDK1.7的底层由数组+链表组成。image.png
JDK1.8的底层由数组+链表+红黑树组成。每个数据单元都是一个Node结构,里面有四个字段分别是key,value,hash和next。其中hash就是将key的hashCode()的高16位异或低16位的值,next字段就是发生hash冲突后,当前桶位中的Node与冲突Node连成一个链表用的字段。image.png

链表转化为红黑树和红黑树退化成链表需要达到什么条件?

链表转化为红黑树:当桶位中链表长度大于8,且数组的大小大于64时要转化为红黑树,来提高查询效率。如果不满足第二个条件,不会转化为红黑树,只能引发一次resize()。红黑树退化成链表:

  1. 在扩容时,生成的高位链和低位链长度小于6时,会从红黑树退化成链表。
  2. 在remove时,在红黑树的root节点为空或root的右节点、root的左节点、root左节点的左节点为空时说明树都比较小了,红黑树退化成链表。

    JDK1.8的HashMap为什么引入红黑树?能解决什么问题?

解决hash冲突链化严重的问题,当链表长度过长,会使HashMap的查询效率退化为O(n)。红黑树就是一颗特殊的二叉排序树,红黑树的查询时间复杂度为O(logn),能够提高查询效率。

为什么当链表长度大于8的时候转化成红黑树,而不是直接使用红黑树来解决哈希冲突?

因为如果hash算法正常的话,数据在散列表中是很均匀的,不会出现链表很长的现象。在理想情况下,链表的长度符合泊松分布,链表长度大于8的概率小于千万分之一,所以一般很少能够用到红黑树。
当链表长度小于8的时候,红黑树的旋转和变色成本高于链表的查询成本。当链表的长度大于8时,红黑树的查询成本和其他成本的总和小于链表的查询成本。
综上,我认为之所以选择8有两个原因:一是因为概率问题;另外一个原因是8是链表和红黑树在时间和空间成本的一个折中点。

如果使用Object作为HashMap的Key,应该怎么办呢?

将对象放入到HashMapHashSet中时,有两个方法需要特别关心: hashCode()和equals()。hashCode()方法决定了对象会被放到哪个bucket里。当多个对象的哈希值冲突时,equals()方法决定了这些对象是否是“同一个对象”。所以,如果要将自定义的对象放入到HashMap或HashSet中,需要@OverridehashCode()和equals()方法。

为什么基本类型不能做为HashMap的键值?

  1. Java中是使用泛型来约束 HashMap 中的key和value的类型的,HashMap 。
  2. 泛型在Java的规定中必须是对象Object类型的,基本数据类型不是Object类型,不能作为键值。

    为什么HashMap中String、Integer这样的包装类适合做为Key?

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

  4. 内部已重写了equals()、hashCode()等方法,遵守了HashMap内部的规范,不容易出现Hash值计算错误的情况。

    HashMap是线程安全的吗?为什么线程不安全?如何实现线程安全?

HashMap线程不安全。为什么HashMap线程不安全?

  1. 在多线程环境下,JDK1.7的HashMap进行扩容时容易发生死链现象,主要因为往链表里面新添加元素的时候使用头插法。
  2. 在多线程环境下,JDK1.8的HashMap扩容后进行数据迁移使用的时候尾插法,而且会将链表拆分成一个低位链表和一个高位链表,然后分别放在对应的位置上,这样就能防止死链的产生。但是进行扩容时可能发生丢失数据现象。

如何实现线程安全?

  1. 替换成Hashtable,Hashtable通过对整个表上锁实现线程安全,但是效率比较低。
  2. 使用Collections.synchronizedMap(new HashMap ());底层其实使用装饰器模式将HashMap的所有方法重写,然后用synchronized()来修饰每个重写后的方法,从而保证线程安全。
  3. 使用JUC包下的ConcurrentHashMap,它使用分段锁来保证线程安全。

    HashMap在JDK1.7和1.8中关于扩容问题的区别?

| * * | JDK1.7 | JDK1.8 | | --- | --- | --- | | 扩容后的大小 | 原来长度的两倍 | | | 扩容时机 | 判断是否达到阈值,同时是否产生hash冲突,扩容后再添加元素(先扩容再添加) | 先添加元素,然后判断是否达到阈值,如果达到阈值进行扩容(先添加再扩容) | | 扩容后存储位置的计算方式 | 扩容后需要重新计算数组下标 | 扩容后数据迁移时,数据要么在原来位置,要么在原来位置+扩容长度。省去了重新计算hash值的时间。 | | 多线程环境下扩容后会发生什么问题 | 多线程环境下会形成死链 | 多线程环境下会有数据丢失问题 |

JDK1.7HashMap扩容机制?

  1. 先生成扩容后的新数组。
  2. 遍历数组中每个位置上的链表的每个元素
  3. 取每个元素的key,并基于新数组的长度重新计算出每个元素在新数组中的下标。
  4. 将元素添加到新数组中。
  5. 所有元素转移完成后,将新数组赋值给HashMap对象的table属性。

    JDK1.8的HashMap扩容后,老表的数据怎么迁移到扩容后的表的呢?

  6. 如果当前slot为null,重新计算sloat的位置:e.hash&(newCap-1),然后赋值为null。

  7. 如果当前slot不为空,且只有一个Node,说明没有发生过冲突,那么重新计算sloat的位置:e.hash & (newCap - 1)。
  8. 如果当前slot不为空,且头结点的next不为空,如果头结点是树形节点:将对双向链表进行数据迁移。如果发现迁移完数据后,双向链表的结构小于/等于6,会将红黑树退化成单向链表。
  9. 如果当前slot不为空,且头结点的next不为空,如果头结点不是树形节点:e.hash&oldCap,形成一个低位链表和高位链表,然后将低位链表放在老数组的原位置,高位链表放在老数组+扩容长度的位置。能够避免发生死链现象。

    JDK1.8的HashMap的put()流程?

image.png

  1. table为空
    1. 先resize()【jdk1.8是懒加载机制,初始化的时候创建一个空的数组,当第一次put的时候再扩容】
    2. 再将Node根据key的hash插入到相应的桶位中。
  2. table不为空【根据key计算hash值得到的数组桶位】
    1. table[index]为空
      1. 直接将Node插入到数组中相应桶位
    2. table[index]不为空
      1. table[index].key == key:直接覆盖value
      2. table[index].key != key:在首节点后面插入一个Node
      3. table[index]已经树化:直接将Node插入到红黑树
      4. table[index]还未树化:遍历链表
        1. 如果链表中存在table[i].key == key,直接覆盖value
        2. 如果链表中不存在table[i].key == key,将Node插入到链表的尾部
        3. 如果链表的长度大于8,并且数组的大小大于64,将链表树化成红黑树
  3. 判断++size > threashold,如果成立说明需要扩容。

    JDK1.8的HashMap的get()流程?

image.png

  1. 首先通过key的hash&(table.length-1)找到key对应的桶位。
  2. 如果桶位为空:返回null
  3. 如果桶位不为空:
    1. 只有首节点
      1. 首节点的key和目标值相同,直接返回首节点的value。
      2. 首节点的key和目标值不同:返回null
    2. 首节点的next如果为空:直接返回null
    3. 首节点的next如果不为空
      1. 首节点是树形节点:进入红黑树查找流程,并返回结果
      2. 首节点不是树形节点:进入链表查找流程,并返回结果

        HashMap在JDK1.7和JDK1.8中的异同?

| * * | JDK1.7 | JDK1.8 | | --- | --- | --- | | 存储结构 | 数组+链表 | 数组+链表+红黑树 | | 底层数组 | Entry[] | Node[] | | 创建数组的时机 | new HashMap()的时候创建长度为16的Entry[]数组 | 首次调用put()方法时,底层创建长度为16的数组 | | 存放数据的规则 | 无冲突时,存放在数组;
冲突时,存放在链表。 | 无冲突时,存放数组;
冲突&链表长度< 8:存放单链表
冲突 & 链表长度 > 8 & 数组长度 > 64:将链表变成红黑树 | | 冲突的时候,在桶中插入Node方式 | 头插法(原位置的数据移到后1位,再插入数据到该位置) | 尾插法(直接插入到链表尾部/红黑树) | | Hash值计算方式 | 扰动处理:9次扰动 = 4次位移运算 + 5次异或运算 | 扰动处理:2次扰动= 1次位移运算+ 1次异或运算 | | 多线程环境下扩容会出现什么问题 | 由于在链表中插入元素使用头插法,所以在多线程环境下容易发生死链现象 | 在多线程环境下,可能会发生丢失数据问题 | | 扩容后存储位置的计算方式 | 重新进行hash计算 | 原位置或原位置+旧容量
(省去了重新计算hash值的时间) |

说一下HashMap和TreeMap的异同?

| * * | HashMap | | TreeMap | | --- | --- | --- | --- | | 实现接口 | AbstractMap抽象类 | | SortedMap接口 | | 底层实现 | JDK1.7数组+链表
JDK1.8数组+链表+红黑树 | | 红黑树 | | 线程是否安全 | 不安全 | | | | 构造方法 |
1. HashMap():构建一个空的哈希映像
1. HashMap(int initialCapacity): 构建一个拥有特定容量的空的哈希映像
1. HashMap(int initialCapacity,float loadFactor): 构建一个拥有特定容量和加载因子的空的哈希映像HashMap(Map m): 构建一个哈希映像,并且添加映像m的所有映射
| |
1. TreeMap():构建一个空的映像树TreeMap(Map m): 构建一个映像树,并且添加映像m中所有元素TreeMap(Comparator c): 构建一个映像树,并且使用特定的比较器对关键字进行排序。
1. TreeMap(SortedMap s): 构建一个映像树,添加映像树s中所有映射,并且使用与有序映像s相同的比较器排序。
| | 是否支持排序 | 不支持 | | 支持(默认是按照Key升序排序,可指定排序的比较器) | | 使用场景 | 在单线程环境下存储 键值对 | | 适用于按自然顺序或自定义顺序遍历键 |

说一下HashMap和Hashtable的区别?

| * * | HashMap | Hashtable | | --- | --- | --- | | 实现的接口 | 实现Map接口 | | | 底层数据结构 | JDK1.7数组+链表;
JDK1.8数组+链表+红黑树。 | 数组+链表 | | 创建时不指定初始容量 | 初始容量为16,再次扩容大小为2n | 初始容量为11,再次扩容的大小为2n+1 | | 创建时指定初始容量 | 如果初始容量不是2的幂次方,初始化的时候使用tableForSize变成比初始容量大的2的幂次方数 | 直接使用初始容量 | | 线程是否安全 | 线程不安全 | 线程安全(全表锁)使用synchronized保证线程安全,效率低下。 | | 效率 | 效率高 | 效率低 | | 对key = null;value=null 的支持 | key最多设置为一次null | key和value都不允许出现null值 |


五、ConcurrentHashMap

ConcurrentHashMap的底层实现原理(1.7和1.8)?

ConcurrentHashMap 1.7

数据结构

在JDK1.7中,ConcurrentHashMap采用Segment数组 + HashEntry数组 + 链表的方式进行实现,结构如下:image.png
Segment 的结构和HashMap类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。
Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

初始化

Segments 数组默认大小为16,这个容量初始化指定后就不能改变了,并且不是懒惰初始化,空间占用不友好。
初始化的时候会计算两个值:segmentShift和segmentMask
segmentShift默认是32 - 4 = 28,是移动的位数。
segmentMask默认是15, 0000 0000 0000 0000 0000 0000 0000 1111
 this.segmentShift 和 this.segmentMask 的作用是决定将 key 的 hash 结果匹配到哪个 segment。image.png

get()操作

ConcurrentHashMap的get操作跟HashMap类似,只是ConcurrentHashMap第一次需要经过一次hash定位到Segment的位置然后再hash定位到指定的HashEntry,遍历该HashEntry下的链表进行对比,成功就返回,不成功就返回null。

put()操作
  1. 首先会根据key的hash值和segmentShift、segmentMask计算出segment的位置。
  2. 然后进入segment的put流程:

    1. 首先会通过继承ReentrantLocktryLock()方法尝试去获取锁,如果没有成功则继续尝试,如果加锁成功则进行以下操作:
    2. 根据key的hash值获取到table的位置:
      1. 如果HashEntry为空,直接在里面添加上一个节点。如果不为空,执行以下操作:
      2. 依次比较链表的key和添加的key是否相同,如果相同则进行覆盖。
      3. 如果key不相同,且比较到最后,那么创建一个新节点Node
      4. 然后判断是否达到扩容阈值(链表的节点个数大于8,数组长度小于64),如果达到则先进行扩容然后再头结点插入新结点,否则直接将新节点插入到链表的头结点前面。
    3. 释放segment锁。
      size()操作
  3. 第一种方案:使用不加锁的模式去尝试多次计算ConcurrentHashMap的size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的。

  4. 第二种方案:如果第一种方案不符合,他就会给每个Segment加上锁,然后计算ConcurrentHashMap的size返回。
    扩容机制

扩容是基于单独的Segment的,判断里面的数组是否超过阈值。先生成新的数组,然后重新计算元素的下标,然后转移元素到新数组中。

ConcurrentHashMap 1.8

数据结构

image.png
JDK1.8中,ConcurrentHashMap不再采用Segment+HashEntry的结构了,而是和HashMap类似的结构,Node数组+链表/红黑树。当链表长度大于8,链表转化为红黑树
采用CAS+synchronized来保证线程安全。在JDK1.8中synchronized只锁链表红黑树的头节点,是一种相比于segment更为细粒度的锁,锁的竞争变小,所以效率更高。

初始化

在JDK8的ConcurrentHashMap中一共有5个构造方法。不管ConcurrentHashMap的初始化给不给定容量大小,数组初始化是在第一次添加元素(懒加载)时完成,调用put()方法之前,数组长度为0。
构造方法最多有三个参数,第一个参数initialCapacity代表初始容量(默认是16),第二个参数loadFactor代表负载因子大小(默认是0.75),第三个参数concurrencyLevel表示并发级别(默认值是16)
initialCapacity的值如果不是2的幂次方,那么经过tableSizeFor()操作也会将容量转换成比initialCapacity大的2的幂次方。比如,传进来的initialCapacity=11,那么实际初始容量为16。
concurrencyLevel表示并发级别,Segment的个数是大于等于concurrencyLevel的第一个2的n次方的数。比如,如果concurrencyLevel为12,13,14,15,16这些数,则Segment的数目为16(2的4次方)。默认值为static final int DEFAULTCONCURRENCYLEVEL = 16
理想情况下ConcurrentHashMap的真正的并发访问量能够达到concurrencyLevel,因为有concurrencyLevel个Segment,假如有concurrencyLevel个线程需要访问Map,并且需要访问的数据都恰好分别落在不同的Segment中,则这些线程能够无竞争地自由访问(因为他们不需要竞争同一把锁),达到同时访问的效果。

get()操作
  1. 根据 hash 值找到数组对应位置: (n - 1) & h
  2. 根据该位置处结点性质进行相应查找
    1. 如果该位置为 null,那么直接返回 null 就可以了
    2. 如果该位置处的节点刚好就是我们需要的,返回该节点的值即可
    3. 如果该位置节点的 hash 值小于 0,说明正在扩容,或者是红黑树,后面我们再介绍 find 方法
    4. 如果以上 3 条都不满足,那就是链表,进行遍历比对即可 > get操作可以无锁,是由于Node的元素val和指针next是用volatile修饰的,在多线程环境下线程A修改因为hash冲突修改结点的val或者新增节点的时候是对线程B可见的。

put()操作
  1. 首先检查key和value是否为空,如果为空直接抛出空指针异常(HashMap允许key和value为null)。
  2. 否则,继续检查数组是否已经初始化,如果还未初始化,那么进行数组初始化操作(使用CAS+自旋将sizeCtl的值设置为-1,并且对数组进行初始化,最后将数组的阈值赋值给sizeCtl)。
  3. 否则,通过计算出的hash值来判断数组对应的桶位是否有元素,如果hash计算得到的桶位置没有元素,使用CAS+自旋来保证添加元素的安全。
  4. 否则,继续判断hash计算得到的桶位置的hash是否为MOVED(-1),如果是那么说明有其他线程正在扩容,本线程协助扩容。
  5. 否则,hash计算的桶位置元素不为空,且当前没有处于扩容操作。
    1. 对当前桶加synchronized锁,然后判断是否已经树化成红黑树。
    2. 如果还是链表,那么依次比较链表中的key和添加元素的key,如果key相同进行value覆盖,否则插入到链表中。插入成功后检查链表是否达到树化的阈值(链表长度大于8,且数组长度大于64)。
    3. 如果已经树化成红黑树,那么将元素插入到红黑树中。
      size()操作

size 计算实际发生在 put,remove 改变集合元素的操作之中

  1. 没有竞争发生,向 baseCount 累加计数。
  2. 有竞争发生,新建 counterCells,向其中的一个 cell 累加计数。

    1. counterCells 初始有两个 cell。
    2. 如果计数竞争比较激烈,会创建新的 cell 来累加计数。
      扩容机制
  3. 并发扩容的时候,由于操作的table都是同一个,不像JDK7中分段控制,所以这里需要等扩容完之后,所有的读写操作才能进行,所以扩容的效率就成为了整个并发的一个瓶颈点。

  4. Doug lea大神对扩容做了优化,本来在一个线程扩容的时候,如果影响了其他线程的数据(put、get操作),那么其他的线程的读写操作都应该阻塞,但Doug lea说你们闲着也是闲着,一起来协助扩容。
  5. 扩容之前首先生成一个数组;在转移元素时,先将原数组进行分组,将每个分组分给不同的线程(多线程扩容)进行元素转移。

    JDK1.8相对JDK1.7在ConcurrentHashMap改进?

很多人不明白为什么Doug Lea在JDK1.8为什么要做这么大变动,使用重级锁synchronized,性能反而更高,原因如下:

  1. 底层数据结构引入红黑树。
    1. JDK1.7的底层数据结构是Segment数组 + HashEntry数组 + 链表的数据结构,
    2. JDK1.8的底层数据结构是Node数组+链表+红黑树的结构。如果链表长度过长会导致查询的时间复杂度退化成O(n),所以在JDK1.8中当链表长度大于8且数组长度大于64的时候将链表转化成红黑树(因为红黑树的查找时间复杂度为O(logn))
  2. JDK8的锁粒度比JDK1.7更细。
    1. JDK1.7中Segment是一个继承了ReentrantLock的分段锁,在每次put操作时,是将整个Segment分段里面的volatile HashEntry [] table 上锁,最大并发个数就是Segment的个数,默认值是16,可以通过构造函数改变一经创建不可更改,所以JDK1.7中ConcurrentHashMap 的concurrentLevel(并发数)基本上是固定的。这样设计的好处在于数组的扩容是不会影响其他的segment的,简化了并发设计,不足之处在于并发的粒度稍粗。
    2. JDK1.8只有一个volatile Node [] table; 数组里面的元素,链表或红黑树的next节点都使用了volatile来保证对线程的可见性,每次put操作时,先判断改table数组是否有元素,如果没有则采用CAS+while自旋来进行CAS put,如果该table数组中有元素,则把该Node f 元素取出来,加上synchronized(f),只是锁住了数组中的单个元素,再进行put操作,所以jdk1.8中的concurrentLevel是和数组大小保持一致的,每次扩容,并发度扩大一倍。好处在于并发的粒度更细,并发性更好。
  3. JDK1.8获得JVM的支持 ,ReentrantLock毕竟是API这个级别的,后续的性能优化空间很小。 synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:偏向锁、轻量级锁、锁粗化、锁消除、锁自旋等等。这就使得synchronized能够随着JDK版本的升级而不改动代码的前提下获得性能上的提升。

    HashMap、Hashtable和ConcurrentHashMap的区别?

| ​
| Hashtable | HashMap1.8 | ConcurrentHashMap1.8 | | --- | --- | --- | --- | | 底层数据结构 | 数组+链表 | 数组+链表+红黑树 | Node数组+链表+红黑树 | | 线程是否安全 | 线程安全,
Hashtable中采用的锁机制是一次使用sychronized锁住整个hash表,在同一时刻只能由一个线程对其进行操作 | 线程不安全,并发扩容会丢失数据 | 线程安全,
当进行初始化和put没有冲突的时候使用CAS来操作,当有冲突的时候使用sychronized锁住数组中链表的头结点来保证线程安全。 | | 初始容量及扩容 | 初始11,每次扩容成2n+1 | 初始16,每次扩容成2n | 初始16,每次扩容成2n | | 是否对指定容量进行tableSizeFor | 不会,直接使用指定容量创建数组 | 会,因为容量必须是2的幂次方,所以当指定容量不是2的幂次方的时候,会经过一系列异或操作变成比容量刚好要大的2的幂次方。 | | | 初始化是否懒加载 | 不会 | 会 | 会 | | key和value的值是否为null | 不可以 | 可以有一个Null key,Null Value多个 | 不可以,会抛出空指针异常 |

有了ConcurrentHashMap为什么还要有Hashtable?

HashTable虽然性能上不如ConcurrentHashMap,但并不能完全被取代。
HashTable的迭代器是强一致性的,而ConcurrentHashMap(get、clear、iterator )是弱一致的。
Doug Lea 也将这个判断留给用户自己决定是否使用ConcurrentHashMap。

什么是强一致性和弱一致性? 强一致性:put一个元素后,能够立马get得到数据; 弱一致性:put一个元素后,get可能在某段时间内还看不到这个元素

为什么Hashtable和ConcurrentHashmap的key和value不能为null?

  1. ConcurrentHashmap和Hashtable都是支持并发的,这样会有一个问题,当你通过get(k)获取对应的value时,如果获取到的是null时,你无法判断,它是put(k,v)的时候value为null,还是这个key从来没有做过映射。
  2. HashMap是非并发的,可以通过contains(key)来做这个判断。而支持并发的Map在调用m.contains(key)和m.get(key),m可能已经不同了。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Luke@

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值