文章目录
- 1.说说有哪些常见的集合框架?
- 2.ArrayList 和 LinkedList 有什么区别?
- 3.ArrayList 的扩容机制了解吗?
- 4.ArrayList 怎么序列化的知道吗? 为什么用 transient 修饰数组?
- 5.快速失败(fail-fast)和安全失败(fail-safe)了解吗?
- 6.有哪几种实现 ArrayList 线程安全的方法?
- 7.CopyOnWriteArrayList 了解多少?
- 8.能说一下 HashMap 的底层数据结构吗?
- 9.你对红黑树了解多少?为什么不用二叉树/平衡树呢?
- 10.红黑树怎么保持平衡的?
- 11.HashMap 的 put 流程知道吗?
- 12.HashMap 怎么查找元素的呢?
- 13.HashMap 的 hash 函数是怎么设计的?
- 14.为什么 hash 函数能降哈希碰撞?
- 15.为什么 HashMap 的容量是 2 的倍数呢?
- 16.如果初始化 HashMap,传一个 17 容量,它会怎么处理?
- 17.你还知道哪些哈希函数的构造方法呢?
- 18.解决哈希冲突有哪些方法呢?
- 19.为什么 HashMap 链表转红黑树的阈值为 8 呢?
- 20.扩容在什么时候呢?为什么扩容因子是 0.75?
- 21.那扩容机制了解吗?
- 22.JDK 8 对 HashMap 主要做了哪些优化呢?为什么?
1.说说有哪些常见的集合框架?
常见的集合框架主要包括以下几大类:
一、List(列表)
- ArrayList:一个基于动态数组的实现,提供了快速的随机访问能力。元素有序、可重复、有索引。
- LinkedList:一个双向链表实现,提供了高效的插入和删除操作。同样,元素有序、可重复、有索引。
- Vector:类似于ArrayList,但它是线程安全的,并且增长方式有所不同。不过在现代Java应用中,由于其同步开销,Vector的使用已经相对较少。
二、Set(集合)
- HashSet:基于哈希表的实现,元素无序、不重复、无索引。提供了快速的添加、删除和查询操作。
- LinkedHashSet:继承自HashSet,但维护了元素的插入顺序。因此,元素有序(按照插入顺序)、不重复、无索引。
- TreeSet:基于红黑树的实现,元素会按照自然顺序或者自定义的比较器进行排序。元素不重复、无索引。
三、Map(映射)
- HashMap:基于哈希表的键值对存储结构,键和值都可以是任意类型。元素无序(不保证映射的顺序)、键不重复。
- LinkedHashMap:继承自HashMap,但维护了键值对的插入顺序。因此,元素有序(按照插入顺序)、键不重复。
- TreeMap:基于红黑树的键值对存储结构,键会按照自然顺序或者自定义的比较器进行排序。键不重复。
此外,还有一些其他的集合类,如Collections、CopyOnWriteArrayList、ConcurrentHashMap等,用于处理并发或多线程环境下的数据集合操作。
总的来说,Java的集合框架提供了丰富的数据结构和算法,以满足不同场景下的数据存储和处理需求。
2.ArrayList 和 LinkedList 有什么区别?
ArrayList和LinkedList在Java集合框架中都是非常常用的数据结构,它们之间的主要区别可以归纳为以下几点:
-
底层实现:
- ArrayList底层是基于动态数组实现的。这意味着它可以通过索引快速访问元素,时间复杂度为O(1)。但在插入或删除元素时,可能需要移动其他元素,时间复杂度为O(n)。
- LinkedList底层是基于双向链表实现的。链表中的一个节点保存了指向前驱和后继节点的引用,以及数据。这使得LinkedList在插入和删除元素时效率较高,但随机访问元素的效率较低,因为需要从头部或尾部开始遍历。
-
内存空间:
- ArrayList在内存中是一片连续的空间,因此空间利用率高,但扩容时可能会有一定的内存浪费,并且扩容操作相对耗时。
- LinkedList的每个节点都独立分配内存,不需要预先分配大片连续内存空间,但每个节点除了存储数据外,还需要存储前后节点的引用,因此空间开销相对较大。
-
操作性能:
- 对于随机访问操作,如get和set,ArrayList的性能优于LinkedList,因为ArrayList可以通过索引直接访问元素。
- 对于插入和删除操作,如果操作在列表的头部或中部进行,LinkedList的性能通常优于ArrayList,因为LinkedList不需要移动大量元素。但如果操作在列表的尾部进行,ArrayList的性能也可能与LinkedList相当或更好。
-
功能特性:
- ArrayList和LinkedList都实现了List接口,因此都支持基本的列表操作。然而,LinkedList还实现了Deque接口,提供了额外的队列操作,如push、pop、offer、poll等。
- LinkedList还实现了Cloneable接口和Serializable接口,支持克隆和序列化操作。
综上所述,ArrayList和LinkedList各有优势,适用于不同的场景。如果需要频繁进行随机访问操作,推荐使用ArrayList;如果需要频繁在列表头部或中部进行插入和删除操作,推荐使用LinkedList。
3.ArrayList 的扩容机制了解吗?
ArrayList的扩容机制是ArrayList实现动态数组功能的关键部分,它允许ArrayList在必要时自动增加容量以适应更多的元素。以下是关于ArrayList扩容机制的详细解释:
-
初始容量与扩容因子:
- ArrayList的默认初始容量为10。这意味着当你创建一个新的ArrayList实例而没有指定初始容量时,它内部会创建一个包含10个元素的数组。
- 扩容因子通常为1.5,这意味着当需要扩容时,新的容量将是原容量的1.5倍。这个因子有助于在性能和内存使用之间找到一个平衡点。
-
扩容触发条件:
- 当向ArrayList中添加元素,并且当前元素个数已经达到了数组的容量上限时,就会触发扩容操作。
-
扩容过程:
- 首先,ArrayList会计算新的容量。新容量通常是原容量的1.5倍,但也可以通过
ensureCapacity
方法来预设一个更大的容量。 - 接下来,ArrayList会创建一个新的、容量更大的数组。
- 然后,将原数组中的所有元素复制到新数组中,确保元素的顺序不变。
- 最后,ArrayList内部的引用会更新为指向新的数组,原数组则会被Java的垃圾回收机制(GC)回收。
- 首先,ArrayList会计算新的容量。新容量通常是原容量的1.5倍,但也可以通过
-
性能考虑:
- 扩容操作涉及到数组的复制,这是一个相对耗时的过程。因此,为了减少扩容操作的次数,可以在添加大量元素之前通过
ensureCapacity
方法提前设定ArrayList的容量。 - 合理地预设容量可以显著提高ArrayList的性能,特别是在需要存储大量数据时。
- 扩容操作涉及到数组的复制,这是一个相对耗时的过程。因此,为了减少扩容操作的次数,可以在添加大量元素之前通过
总的来说,ArrayList的扩容机制是其动态调整大小以适应数据增长的关键特性。通过了解并合理利用这一机制,可以更好地优化程序性能和内存使用。
4.ArrayList 怎么序列化的知道吗? 为什么用 transient 修饰数组?
ArrayList的序列化过程是通过Java的内置序列化机制来实现的。以下是对这一过程的详细解释,以及为什么ArrayList中的数组被transient
修饰的原因:
ArrayList的序列化
-
实现Serializable接口:
- ArrayList类实现了
java.io.Serializable
接口,这是Java中用于标记一个类可以被序列化的标准接口。通过实现这个接口,ArrayList对象就可以被转换成字节流,以便于在网络上传输或保存到磁盘上。
- ArrayList类实现了
-
序列化过程:
- 当尝试序列化一个ArrayList对象时,Java序列化机制会遍历对象的所有非静态和非
transient
的字段,并将它们转换成字节流。 - 这个字节流包含了足够的信息,以便在之后的某个时间点重新构造出原始对象的状态。
- 当尝试序列化一个ArrayList对象时,Java序列化机制会遍历对象的所有非静态和非
为什么用transient
修饰数组
ArrayList内部使用一个名为elementData
的数组来存储元素。这个数组被transient
关键字修饰,意味着在序列化过程中,该数组的内容不会被直接写入字节流。这样做的原因主要有以下几点:
-
优化存储空间:
elementData
数组的长度可能大于实际存储的元素数量。例如,如果ArrayList的当前大小为5,但其内部数组elementData
的长度可能为10。直接使用Java默认的序列化机制会导致序列化整个数组,包括未使用的空间,从而造成存储空间的浪费。
-
自定义序列化逻辑:
- 通过将
elementData
标记为transient
,ArrayList类可以自定义其序列化逻辑。在ArrayList中,这种自定义逻辑只序列化实际存储的元素,而不是整个数组。 - ArrayList通过实现
writeObject
和readObject
方法来控制序列化和反序列化的过程,从而确保只有实际的元素被存储和恢复。
- 通过将
-
安全性和灵活性:
- 使用
transient
修饰符可以防止敏感或不必要的数据被序列化,从而增加数据的安全性。 - 同时,它也提供了更大的灵活性,允许开发者根据需要定制序列化的内容。
- 使用
综上所述,transient
修饰符在ArrayList中的应用是为了优化存储空间、实现自定义的序列化逻辑,并提高数据的安全性和处理的灵活性。
5.快速失败(fail-fast)和安全失败(fail-safe)了解吗?
快速失败(fail-fast)和安全失败(fail-safe)是两种在处理集合迭代时遇到并发修改情况的不同策略。
快速失败(fail-fast)
-
原理:
- 在使用迭代器遍历集合的过程中,如果集合结构发生变化(增加、删除、修改元素),则会抛出
ConcurrentModificationException
异常,从而立即终止遍历。 - 迭代器在遍历时直接访问集合中的内容,并使用一个
modCount
变量来跟踪集合的修改。每当迭代器使用next()
等方法遍历下一个元素前,都会检查modCount
变量是否发生变化。如果检测到变化,说明集合在迭代过程中被修改,此时会抛出异常。
- 在使用迭代器遍历集合的过程中,如果集合结构发生变化(增加、删除、修改元素),则会抛出
-
适用场景:
- 适用于对数据结构状态要求较高的场景,如多线程环境下,希望及时发现错误并防止数据异常的情况。
java.util
包下的集合类(如ArrayList, HashMap等)默认采用这种策略。
-
优缺点:
- 优点:能够及时发现并阻止并发修改,保证数据的一致性。
- 缺点:在迭代过程中,任何对集合的修改都会导致异常,可能使得程序中断。
安全失败(fail-safe)
-
原理:
- 采用这种策略的迭代器在遍历时不是直接在集合内容上访问,而是先复制原有集合内容,在拷贝的集合上进行遍历。
- 由于迭代时是对原集合的拷贝进行遍历,对原集合的修改并不能被迭代器检测到,因此不会触发
ConcurrentModificationException
异常。
-
适用场景:
- 适用于对数据结构状态要求相对较低的场景,如在多线程环境下,希望尽可能完成所有操作,即使部分操作失败也不影响整体的情况。
java.util.concurrent
包下的容器(如ConcurrentHashMap的迭代器)通常采用这种策略。
-
优缺点:
- 优点:避免了
ConcurrentModificationException
异常,允许在迭代过程中并发修改集合。 - 缺点:迭代器遍历的是开始遍历那一刻的集合拷贝,因此它无法访问到遍历期间对原集合所做的修改。
- 优点:避免了
综上所述,快速失败和安全失败是针对集合迭代过程中并发修改问题的两种不同处理策略,各有其适用场景和优缺点。
6.有哪几种实现 ArrayList 线程安全的方法?
确保ArrayList线程安全的方法有多种,以下是一些常见的方法:
-
使用
Collections.synchronizedList
方法:
Java的Collections
类提供了一个静态方法synchronizedList
,它接受一个List参数,并返回一个线程安全的列表。这个方法通过内部使用synchronized关键字对列表的所有访问进行了同步。List<String> list = new ArrayList<>(); List<String> synchronizedList = Collections.synchronizedList(list);
使用这种方式时,需要确保在迭代过程中也保持同步,例如:
synchronized(synchronizedList) { Iterator<String> iterator = synchronizedList.iterator(); // ... 迭代操作 ... }
-
使用
Vector
类:
Vector
是Java早期提供的一个线程安全的动态数组实现。它的方法大多数都是同步的,因此在多线程环境下是安全的。但Vector
的性能通常不如ArrayList
,且在现代Java应用中较少使用。 -
使用
CopyOnWriteArrayList
:
CopyOnWriteArrayList
是Java并发包java.util.concurrent
中的一个类,它是线程安全的。其实现原理是在修改操作(如add、set等)时,复制原数组的内容到一个新的数组中,然后再修改新数组中的数据。这种实现方式使得读操作可以无锁,因此效率很高,但是写操作的开销会比较大,因为每次写都需要复制整个数组。List<String> list = new CopyOnWriteArrayList<>();
-
手动同步:
可以在每次访问ArrayList时使用synchronized关键字进行手动同步。这种方法比较繁琐,容易出错,且可能导致性能下降,因为它会锁定整个ArrayList,即使在读操作时也是如此。List<String> list = new ArrayList<>(); // ... 添加元素 ... synchronized(list) { // 同步块中进行读写操作 }
-
使用读写锁:
使用ReentrantReadWriteLock
来实现读写锁,这样可以在多线程环境下对ArrayList进行更细粒度的控制。读锁允许多个线程同时读取,而写锁则确保在写入时独占访问。ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); List<String> list = new ArrayList<>(); // 使用lock.readLock()或lock.writeLock()进行同步
-
使用并发集合:
如果适用,可以考虑使用其他并发集合类,如ConcurrentLinkedQueue
或ConcurrentHashMap
的values()
或keySet()
,这些集合类内部已经处理了所有的并发问题。
通常,选择哪种方法取决于具体的应用场景和性能需求。例如,如果读操作远多于写操作,CopyOnWriteArrayList
可能是一个好的选择。如果需要频繁的写操作,则可能需要考虑其他选项,如Collections.synchronizedList
或手动同步。
7.CopyOnWriteArrayList 了解多少?
CopyOnWriteArrayList是Java并发包中的一个类,它是一种线程安全的动态数组。以下是对CopyOnWriteArrayList的详细了解:
一、定义与特性
- 写时复制:CopyOnWriteArrayList的核心特性是写时复制(Copy-On-Write)。当往容器中添加或修改元素时,不直接在原容器上进行操作,而是先复制原容器的内容到一个新的容器中,然后在新容器上进行修改操作。修改完成后,再将原容器的引用指向新容器。
- 线程安全性:CopyOnWriteArrayList是线程安全的。它通过写时复制的机制确保了并发访问的一致性,因此可以在多线程环境下并发访问而无需额外的同步措施。
- 读写分离:为了将读取的性能发挥到极致,CopyOnWriteArrayList的读取操作是完全不用加锁的,而且写入操作也不会阻塞读取操作。只有写入和写入之间需要进行同步等待。
二、适用场景与性能考虑
- 读多写少的场景:由于每次写操作都需要复制整个数组,所以写操作的开销较大。因此,CopyOnWriteArrayList适用于读操作频繁、写操作较少的场景,如缓存、日志记录等。在这些场景中,读性能优势可以得到充分发挥。
- 内存消耗:由于每次写操作都需要复制整个数组,所以CopyOnWriteArrayList的内存占用相对较大。对于存储大量数据的情况,应谨慎使用,以免导致内存占用过高。
- 数据一致性:CopyOnWriteArrayList能保证数据的最终一致性,但无法保证数据的实时一致性。因为在写操作完成并替换原数组之前,读操作可能仍然读取的是原数组的数据。
三、与其他线程安全方法的比较
- 与
Collections.synchronizedList
相比,CopyOnWriteArrayList在读操作上具有更高的性能,因为读取操作不需要获取锁。然而,写操作的开销较大,因为需要复制整个数组。 - 与
Vector
相比,CopyOnWriteArrayList在性能上通常更优,因为Vector
的所有操作都是同步的,而CopyOnWriteArrayList仅在写操作时进行同步。
综上所述,CopyOnWriteArrayList是一种适用于读多写少场景的线程安全动态数组。它通过写时复制的机制实现了线程安全性和读写分离的特性,但也需要考虑其写操作开销较大和内存占用较高的问题。
8.能说一下 HashMap 的底层数据结构吗?
HashMap的底层数据结构主要包括以下几个部分:
-
哈希表(数组):HashMap主要依赖于哈希表来存储数据。哈希表中的每个元素被称为“bucket”,数组的每个位置(bucket)都可以存放一个元素(键值对)。数组的索引是通过键的哈希码经过哈希函数计算得来的,这样可以通过键快速定位到数组的某个位置,以取出相应的值。
-
链表:当两个不同的键计算出相同的哈希值,即发生“哈希冲突”时,HashMap会在冲突的bucket位置增加一个链表。新的元素会被添加到链表的末尾,链表中的每个元素都包含了相同哈希值的键值对。在查找元素时,如果遇到哈希冲突,HashMap需要进行一次线性查找。
-
红黑树:从Java 8开始,如果链表的长度超过一定的阈值(默认为8),链表会被转换为红黑树。红黑树是一种自平衡的二叉查找树,它可以提高查找效率,特别是在数据量较大时,其性能优于链表。
-
扩容与重新哈希:HashMap在初始化时会有一个默认的初始容量(如16)和一个加载因子(如0.75)。当HashMap的大小(已存储的键值对数量)超过容量与加载因子的乘积时,HashMap会进行扩容,新的容量通常是原来的两倍,并且会进行重新哈希,将已存在的元素重新放入新的bucket位置。
综上所述,HashMap的底层数据结构是一个结合了哈希表、链表和红黑树的复合结构,这种结构使得HashMap能够在保持较高的查找和插入效率的同时,也能够应对哈希冲突和数据量增长的情况。
9.你对红黑树了解多少?为什么不用二叉树/平衡树呢?
红黑树是一种自平衡的二叉查找树,它在计算机科学中被广泛应用。以下是对红黑树的详细介绍以及为什么在某些情况下它可能比普通的二叉树或平衡二叉树更受欢迎的原因:
一、红黑树的定义与特性:
- 红黑树的每个节点都有一个颜色属性,可以是红色或黑色。
- 红黑树满足以下五个规则:
- 每个节点不是黑色就是红色。
- 根节点为黑色。
- 红色节点的父节点和子节点不能为红色(即两个红色节点不能相邻)。
- 所有的叶子节点都是黑色(在红黑树中,空节点被视为叶子节点,也称为NIL节点)。
- 每个节点到叶子节点的每个路径上黑色节点的个数都相等。
这些规则确保了红黑树的平衡性,从而使得查找、插入和删除操作的时间复杂度都能保持在O(log n)。
二、为什么选择红黑树而不是普通二叉树或平衡二叉树:
-
相对于普通二叉树:
- 普通二叉树在极端情况下可能会退化成链表,导致查找效率大大降低。而红黑树通过颜色标记和旋转操作来保持树的平衡,从而避免了这种情况。
- 红黑树的查找、插入和删除操作的平均和最坏情况时间复杂度都是O(log n),这在处理大量数据时非常高效。
-
相对于平衡二叉树(如AVL树):
- 平衡二叉树要求每个节点的左右子树高度差不超过1,这导致在插入或删除节点时可能需要进行频繁的旋转操作来保持平衡。
- 红黑树通过放宽平衡条件(只要求每个路径上黑色节点的数量相等),减少了旋转操作的频率,从而在某些情况下提高了性能。
- 在实际应用中,红黑树的实现相对简单且性能良好,因此在很多高性能的数据结构库(如C++的STL和Java的集合框架)中都得到了广泛应用。
综上所述,红黑树通过其独特的颜色和规则设计,在保持树平衡的同时降低了操作的复杂性,使得它在处理大量数据时具有高效且稳定的性能表现。
10.红黑树怎么保持平衡的?
红黑树通过以下方式保持平衡:
-
颜色反转与调整:
- 红黑树的节点具有颜色属性,可以是红色或黑色。通过颜色的调整,红黑树能够确保满足其特定的平衡条件。
- 当插入或删除节点导致出现两个连续的红色节点时,会进行颜色反转操作。例如,将父节点和叔叔节点的颜色从红色变为黑色,同时将祖父节点的颜色变为红色,以保持平衡。
-
旋转操作:
- 旋转是红黑树中用于重新平衡树结构的关键操作。主要有两种旋转:左旋和右旋。
- 左旋操作是当某个节点的右子节点需要被提升时进行的。这个操作会使得当前节点成为其右孩子的左孩子,而原本右孩子的左子树会成为当前节点的右子树。
- 右旋操作相反,是当某个节点的左子节点需要被提升时进行的。右旋后,当前节点会成为其左孩子的右孩子,原本左孩子的右子树会成为当前节点的左子树。
-
遵循红黑树的性质:
- 红黑树在插入、删除节点时,始终遵循一系列规则来保持平衡,如每个节点要么是红色,要么是黑色;根节点是黑色;所有叶子节点(NIL节点)是黑色;红色节点的子节点必须是黑色;从任一节点到其每个叶子的所有路径都包含相同数量的黑色节点。
-
插入与删除操作的平衡维护:
- 在插入新节点时,红黑树会按照特定的规则对节点进行着色和可能的旋转操作,以确保树保持平衡。
- 在删除节点时,也会进行类似的平衡调整,可能包括颜色变换和旋转,以保持红黑树的性质不被破坏。
通过这些方法,红黑树能够在动态插入和删除操作中保持高效的平衡状态,从而确保查找、插入和删除操作的时间复杂度维持在O(log n)级别。
11.HashMap 的 put 流程知道吗?
HashMap 的 put 方法流程是 HashMap 工作中非常关键的一部分。以下是 HashMap 的 put 流程的大致步骤:
-
计算键的哈希值:
首先,根据键(key)计算出哈希值。HashMap 使用了一个哈希函数来计算键的哈希值,这个函数会尽量将键均匀地映射到一个范围内。 -
定位桶的位置:
使用计算出的哈希值对数组长度取模(或使用位运算等同于取模的操作),以确定该键值对应该存放在哈希表中的哪个位置(桶的位置)。 -
处理哈希冲突:
- 如果计算出的桶位置为空,则直接在该位置创建一个新的节点存储键值对。
- 如果桶位置非空,表示发生了哈希冲突,此时需要处理冲突。HashMap 使用链表+红黑树的结构来解决哈希冲突。
- 如果桶位置上的节点是链表结构,则将新节点添加到链表的末尾。
- 如果链表长度超过一定阈值(默认为8),则将链表转换为红黑树,以提高搜索效率。
- 在插入新节点前,会检查键是否已经存在,如果存在则更新对应的值。
-
扩容判断:
在插入新元素后,会检查当前 HashMap 的元素数量是否超过了阈值(capacity * load factor)。如果超过了阈值,就会触发扩容操作。- 扩容时,会创建一个新的数组,其大小通常是原数组大小的两倍,并重新计算所有元素的哈希值,将它们重新分布到新的数组中。
- 扩容是一个相对耗时的操作,因为它涉及到重新哈希和分配内存。
-
返回值:
put 方法会返回之前与指定键相关联的值,如果之前没有该键的映射关系,则返回 null。
这个流程保证了 HashMap 能够高效地存储和检索键值对,同时通过扩容机制动态地适应数据量的增长。需要注意的是,HashMap 不是线程安全的,如果在多线程环境下使用,需要额外的同步措施或者选择 ConcurrentHashMap 等线程安全的替代品。
12.HashMap 怎么查找元素的呢?
HashMap 查找元素的过程主要通过以下步骤实现:
-
计算键的哈希值:
首先,根据给定的键(key)使用哈希函数计算出对应的哈希值。这个哈希函数的设计目标是尽量将键均匀地映射到一个固定范围的哈希值,以减少冲突。 -
定位桶的位置:
接下来,使用计算出的哈希值来确定元素可能存在的桶(bucket)的位置。通常,这是通过哈希值对数组长度取模或使用位运算来完成的,这样可以得到一个索引值,指向数组中的特定位置。 -
遍历链表或红黑树:
- 如果定位到的桶是空的,说明该键不存在于 HashMap 中,查找结束。
- 如果桶非空,且桶中的元素与查找的键匹配,则直接返回对应的值。
- 如果桶中的元素与查找的键不匹配,或者桶中存储的是一个链表/红黑树结构,则需要遍历链表或红黑树来查找匹配的键。
- 在遍历过程中,会比较链表/红黑树中每个节点的键与查找的键是否相等。如果找到相等的键,则返回对应的值。
-
处理未找到的情况:
如果遍历完链表或红黑树后仍未找到匹配的键,则说明该键在 HashMap 中不存在,查找结束。
需要注意的是,HashMap 的查找效率与哈希函数的设计、数组大小以及冲突处理方式(链表或红黑树)都有关系。理想情况下,哈希函数能够将键均匀地映射到数组中,从而减少冲突和查找时间。同时,当链表长度过长时,HashMap 会将其转换为红黑树以提高查找效率。
总的来说,HashMap 通过哈希函数快速定位到可能的存储位置,然后通过遍历链表或红黑树来精确查找匹配的键。这种设计使得 HashMap 在大多数情况下能够实现快速的元素查找。
13.HashMap 的 hash 函数是怎么设计的?
HashMap 的 hash 函数设计是 HashMap 性能的关键部分,因为它直接影响到键值对在哈希表中的分布和冲突率。Java 中的 HashMap 使用了一个相对复杂的哈希函数来确保键尽可能均匀地分布在哈希表中。
以下是 HashMap 中 hash 函数设计的一些关键点:
-
利用键的 hashCode() 方法:
HashMap 首先会调用键对象的hashCode()
方法来获取一个初步的哈希值。这个hashCode()
方法是 Object 类中的一个方法,所有的 Java 对象都继承了这个方法。对于自定义对象,通常需要重写hashCode()
方法以确保不同对象有合理的哈希值分布。 -
哈希值的混合与再散列:
HashMap 会对hashCode()
返回的哈希值进行进一步的混合(mixing)或再散列(rehashing),以增加哈希值的复杂性和随机性。这有助于减少哈希冲突,因为即使两个对象的hashCode()
相同,经过再散列后,它们的最终哈希值也可能不同。 -
无符号右移和异或操作:
在 Java 8 及其之后的版本中,HashMap 的 hash 函数使用了一系列位操作,如无符号右移(>>>
)和异或(^
),来进一步打乱初始哈希值。这些操作利用了二进制数的特性,有助于将哈希值更均匀地分布在整个哈希空间内。 -
处理高碰撞率的 hashCode:
如果多个键具有相同的hashCode()
,HashMap 的 hash 函数会尝试通过内部再散列机制来减少冲突。这意味着即使外部对象提供的hashCode()
实现不佳,HashMap 也会尝试通过其内部的 hash 函数来弥补这一点。 -
与数组长度的取模运算:
在计算了最终的哈希值之后,HashMap 会使用这个哈希值与数组长度进行取模运算,以确定键值对应该存储在哈希表中的哪个位置。在 Java 8 中,这个取模运算通常是通过位与(&
)操作来实现的,这要求数组长度是2的幂,从而可以高效地通过位操作来确定索引。
总的来说,HashMap 的 hash 函数设计旨在通过复杂的再散列过程来减少哈希冲突,提高数据分布的均匀性,并优化存储和查找性能。这种设计确保了 HashMap 在处理大量数据时仍能保持高效的性能表现。
14.为什么 hash 函数能降哈希碰撞?
hash 函数能降低哈希碰撞的原因主要有以下几点:
-
均匀分布:hash 函数的设计目标之一是将输入数据均匀映射到一个固定范围的哈希值。这意味着,理想情况下,不同的输入应该被映射到不同的哈希值,从而减少碰撞的机会。通过复杂的计算过程和位操作,hash 函数努力确保输出的哈希值尽可能地分散,不集中在某些特定区域。
-
混淆与扩散:hash 函数中的混淆(confusion)和扩散(diffusion)步骤有助于将输入数据的微小变化放大到哈希值上的显著差异。这增加了不同输入产生相同哈希值的难度,即减少了哈希碰撞的可能性。
-
大数据空间映射:hash 函数通常将较小的输入空间映射到一个相对较大的哈希值空间。这种设计增加了每个输入对应唯一哈希值的可能性。尽管哈希空间的大小是有限的,并且理论上仍然存在碰撞的风险,但通过合理选择哈希函数和哈希空间大小,可以显著降低碰撞的概率。
-
再散列机制:在一些复杂的哈希表实现中,如HashMap,当发生哈希碰撞时,会使用链表或红黑树等数据结构来解决冲突。此外,hash 函数内部可能还包含再散列(rehashing)机制,这意味着在初次哈希计算后,如果检测到潜在的碰撞,会进行额外的计算以进一步区分不同的输入。
-
避免模式化:hash 函数的设计通常要避免任何可能导致输出模式化的因素。模式化意味着某些特定的输入组合总是产生相同的哈希值,从而增加碰撞的风险。因此,hash 函数通过复杂的算法来打破这种模式,确保输出的随机性和不可预测性。
综上所述,hash 函数通过均匀分布、混淆与扩散、大数据空间映射、再散列机制以及避免模式化等方法来降低哈希碰撞的概率。这些设计原则共同确保了哈希表的高效性和准确性。
15.为什么 HashMap 的容量是 2 的倍数呢?
HashMap 的容量是 2 的倍数,主要基于以下几个原因:
-
性能优化:当 HashMap 的容量为 2 的倍数时,可以使用位运算来计算哈希值的桶位置,而不需要进行昂贵的模运算。位运算在计算机中的执行速度通常比模运算要快,这提高了 HashMap 的性能。特别是在数据量大的情况下,这种性能提升会更加明显。
-
空间利用率:2 倍的扩容策略在性能和内存占用之间达到了一个平衡点。它避免了频繁的扩容操作,从而减少了内存浪费,并保持了较高的空间利用率。过于频繁的扩容会消耗大量资源并影响性能,而扩容因子过大则可能导致桶中的元素过多,增加查找时间。
-
均匀分布:当容量为 2 的倍数时,哈希表中的槽位可以更均匀地分布键值对,减少碰撞的可能性。这是因为 2 的倍数容量可以确保哈希函数将键更均匀地映射到各个桶中,从而降低了哈希冲突的概率。
-
历史原因与兼容性:在 HashMap 的早期版本中,选择 2 倍扩容因子也是基于计算机中整数乘法和除法操作的效率考虑。2 的倍数容量使得这些操作更加快速和高效。
综上所述,HashMap 的容量为 2 的倍数是为了在性能、空间利用率以及键值对的均匀分布方面达到最优。这种设计选择确保了 HashMap 在处理大量数据时能够保持高效的性能和合理的内存使用。
16.如果初始化 HashMap,传一个 17 容量,它会怎么处理?
如果初始化 HashMap 时传入一个非 2 的倍数的容量值,如 17,HashMap 会进行以下处理:
-
容量调整:HashMap 内部会将容量调整为大于或等于传入容量的最小 2 的幂。在这个例子中,由于 17 不是 2 的幂,HashMap 会将容量调整为最接近且大于 17 的 2 的幂,即 32。这样做是为了确保后续计算桶位置时可以使用高效的位运算代替模运算。
-
初始化内部数组:根据调整后的容量(在这个例子中是 32),HashMap 会初始化一个相应大小的内部数组来存储键值对。这个数组的每个元素都是一个桶(bucket),用于存放具有相同哈希值的键值对链表或红黑树。
-
哈希函数与桶定位:由于容量是 2 的幂,HashMap 可以使用位运算(通常是哈希值与容量减一的按位与操作)来快速定位键值对应该存放的桶。这种位运算比模运算更高效,有助于提高 HashMap 的性能。
-
扩容与再哈希:当 HashMap 中的元素数量超过容量与负载因子的乘积时,会发生扩容。扩容时,HashMap 会创建一个新的内部数组,其大小是原数组的两倍,并重新计算所有键值对的桶位置。由于初始容量已经是 2 的幂,所以扩容后的容量也将是 2 的幂,保持了使用位运算进行桶定位的优势。
综上所述,如果初始化 HashMap 时传入一个非 2 的倍数的容量值(如 17),HashMap 会自动将其调整为最接近且大于该值的 2 的幂(如 32),以确保后续操作的效率和性能。
17.你还知道哪些哈希函数的构造方法呢?
哈希函数的构造方法有多种,以下是一些常见的构造方法:
-
直接地址法:这种方法是选取关键字的某个线性函数值为哈希地址。例如,如果关键字集合中的每个关键字都是唯一的,可以直接使用关键字本身或其线性变换作为哈希地址。这种方法简单且不会产生冲突,但要求地址集合与关键字集合大小相同,因此不适用于较大的关键字集合。
-
除留余数法:这是一种广泛使用的方法,它选取关键字除以某个数p的余数作为哈希地址。通常,p会选择一个素数,以减少冲突的可能性。这种方法的关键在于选择合适的p值,以确保哈希值分布的均匀性。
-
数字分析法:如果事先知道关键字的分布情况,可以选取关键字中某些取值较分散的数字位作为哈希地址。这种方法适用于关键字已知且分布较为均匀的情况。
-
平方取中法:将关键字平方后,取中间的几位作为哈希地址。这种方法适用于关键字分布不均匀但平方后中间几位分布较均匀的情况。
-
折叠法:将关键字分割成位数相同的几部分,然后取这几部分的叠加和(舍去进位)作为哈希地址。这种方法适用于关键字位数较多的情况。
-
基数转换法:将关键字的数值转换成其他进制数,然后取其中的某些位作为哈希地址。例如,可以将十进制的关键字转换成十六进制,再取其中的几位作为哈希地址。
这些方法各有优缺点,适用于不同的场景和需求。在选择哈希函数时,需要根据实际情况进行权衡和选择,以确保哈希值的均匀分布和减少冲突的可能性。同时,对于特定的应用场景,还可以结合多种方法设计出更合适的哈希函数。
18.解决哈希冲突有哪些方法呢?
解决哈希冲突的方法主要有以下几种:
-
开放定址法:
- 当发生哈希冲突时,通过探测哈希表中的下一个未被占用的位置,直到找到一个空槽来存储数据。
- 具体实现方式包括线性探测法,即按顺序决定值时,如果某数据的值已经存在,则在原来值的基础上往后加一个单位,直至不发生哈希冲突;还有平方探测法(二次探测),即当所需要存放值的位置被占时,会前后寻找而不是单独方向寻找。
-
再哈希法:
- 同时构造多个不同的哈希函数,当发生哈希冲突时,使用第二个、第三个等其他的哈希函数计算地址,直到不发生冲突为止。
- 这种方法虽然不易发生聚集,但增加了计算时间。
-
链地址法:
- 将所有哈希地址相同的记录都链接在同一链表中。
- 这种方法处理冲突简单,无堆积现象,平均查找长度较短,适合总数经常变化的情况,但查询时效率可能较低,因为存储是动态的,查询时跳转需要更多的时间。
-
建立公共溢出区:
- 将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。
这些方法各有优缺点,在选择解决哈希冲突的方法时,需要根据具体的应用场景和需求进行权衡。例如,如果预期哈希表中的数据量会频繁变化,链地址法可能是一个好的选择。而如果更关心查询效率且数据相对稳定,开放定址法或再哈希法可能更合适。
19.为什么 HashMap 链表转红黑树的阈值为 8 呢?
HashMap中链表转换为红黑树的阈值设定为8,这个选择是基于多个因素的综合考虑:
-
性能与效率的平衡:当链表长度较短时,其查找效率尚可接受,因为链表的查找时间复杂度为O(n)。然而,随着链表长度的增加,查找效率会显著下降。红黑树作为一种自平衡的二叉搜索树,其查找、插入和删除的时间复杂度都是O(log n),这在处理较长链表时能显著提高性能。阈值设为8是在链表性能和红黑树性能之间找到一个平衡点。
-
空间占用的考虑:红黑树相比链表会占用更多的内存空间。因此,在链表长度较小时,使用链表作为存储结构是更高效的。将转换阈值设为8,可以避免在链表较短时就进行转换,从而节省内存空间。
-
哈希冲突与概率统计:哈希冲突的频率也会影响链表长度。在理想情况下,使用随机的哈希码,容器中节点分布在hash桶中的频率遵循泊松分布。根据泊松分布的计算,链表中元素个数为8时的概率已经非常小,因此选择8作为转换阈值在统计学上也是合理的。
-
经验性选择与实验验证:这个阈值的选择也是基于JDK开发者的经验和一系列实验验证的结果。开发者通过性能测试和内存占用分析,发现当链表长度达到8时,转换为红黑树能在性能和内存占用之间达到较优的平衡。
综上所述,HashMap中链表转换为红黑树的阈值设定为8,是综合考虑了性能、内存占用、哈希冲突频率以及经验性选择等多个因素的结果。
20.扩容在什么时候呢?为什么扩容因子是 0.75?
HashMap的扩容发生在以下情况:当HashMap中的元素数量超过负载因子与当前存储桶数量的乘积时,就会触发扩容操作。具体来说,如果HashMap中的元素数量达到了容量(即存储桶数量)的75%(如果负载因子设为默认值0.75的话),那么HashMap就会进行扩容。
至于为什么扩容因子是0.75,这主要是基于时间和空间效率的权衡考虑:
-
空间利用率:负载因子决定了HashMap在何时进行扩容。如果负载因子设置得过高,比如接近1,那么HashMap的空间利用率会很高,但这也意味着哈希表中的元素可能会更加密集,增加哈希冲突的概率,从而影响查找和插入的效率。
-
性能考虑:负载因子过低,例如设置为0.5,虽然可以减少哈希冲突,提高查找和插入的效率,但这样会导致空间利用率降低,同时增加了扩容的频率,而每次扩容都需要重新计算哈希值和重新分配元素,这是一个相对耗时的过程。
-
经验值与统计平衡:0.75这个值是经验上得到的一个比较合理的折中值。它既能保证较高的空间利用率,又能保持较低的哈希冲突概率,从而在大多数情况下保持HashMap的性能。这个值也是基于泊松分布等统计原理以及大量实践经验得出的。
总的来说,扩容因子设为0.75是为了在HashMap的空间利用率和操作性能之间找到一个平衡点。这个值既不过高也不过低,能够在保证HashMap性能的同时,也兼顾了空间利用的效率。
21.那扩容机制了解吗?
HashMap的扩容机制是HashMap实现中的一个重要环节,它确保了HashMap在面对大量数据时仍能保持高效的性能。以下是关于HashMap扩容机制的详细解释:
-
触发条件:
- 当HashMap中的元素个数超过容量与负载因子的乘积时,就会触发扩容。负载因子是一个浮点数,用于衡量HashMap在其容量自动增加之前可以达到多满的一种尺度,其默认值为0.75。例如,如果HashMap的初始容量为16,负载因子为0.75,那么当元素个数超过16 * 0.75 = 12时,就会触发扩容。
-
扩容过程:
- 创建一个新的Entry空数组,其长度是原数组的两倍。例如,如果原数组长度为16,则新数组长度为32。
- 对所有元素进行重新哈希(Rehash)。这是因为数组长度发生变化后,哈希计算的规则也随之改变。HashMap会遍历原数组中的所有元素,根据新的数组长度重新计算哈希值,并将元素放入新数组中的适当位置。
-
性能影响:
- 扩容过程涉及到所有元素的重新哈希和数据迁移,这是一个相对耗时的操作。特别是在HashMap中存储了大量数据时,扩容可能会带来较大的性能开销。
- 因此,为了优化性能,可以预先设置一个较大的初始容量,以减少扩容的次数。同时,也可以根据实际情况调整负载因子的值,以在空间和性能之间找到最佳的平衡点。
-
注意事项:
- 在多线程环境下使用HashMap时,如果两个线程同时检测到需要扩容并尝试进行扩容操作,可能会导致死循环或其他不可预知的问题。因此,在多线程环境下建议使用ConcurrentHashMap等线程安全的Map实现。
综上所述,HashMap的扩容机制是其保持性能的关键环节之一。通过合理设置初始容量和负载因子,并了解扩容过程的性能影响,可以更好地使用HashMap来存储和检索数据。
22.JDK 8 对 HashMap 主要做了哪些优化呢?为什么?
JDK 8对HashMap主要做了以下优化:
-
引入红黑树结构:
- 在JDK 8之前,HashMap在处理哈希冲突时,使用的是链表结构。当哈希冲突严重时,链表会变得很长,导致查找效率降低,时间复杂度为O(n)。
- JDK 8中,当链表长度超过一定阈值(默认为8)且HashMap的容量大于等于64时,链表会转换为红黑树。红黑树是一种自平衡的二叉搜索树,其查找、插入和删除操作的时间复杂度都是O(log n),从而显著提高了在哈希冲突严重时的查找效率。
-
扩容机制的改进:
- 在JDK 8之前的版本中,HashMap扩容时可能会因为并发修改导致环形链表,进而引发死循环。
- JDK 8改进了扩容机制,解决了这个问题。尽管新的扩容机制可能引起数据覆盖的问题,但避免了死循环的发生,提高了HashMap的稳定性和性能。
这些优化的主要原因是为了提高HashMap的性能和稳定性。引入红黑树结构是为了解决在哈希冲突严重时链表查找效率低的问题,而扩容机制的改进则是为了解决旧版本中存在的死循环问题,从而提高HashMap的并发性能和稳定性。
总的来说,JDK 8对HashMap的优化主要集中在提高查找效率和解决并发问题两个方面,这些优化使得HashMap在处理大量数据时更加高效和稳定。