目录
4、影响 HashMap 的性能因素(key 的 hashCode 函数实现、loadFactor、初始容量);
5、HashMap key 的 hash 值计算方法以及原因(见上面 hash 函数的分析);1.8的
6、1.8 HashMap 内部存储结构:Node 数组 + 链表或红黑树;
7、1.8 table[i] 位置的链表什么时候会转变成红黑树;
8、HashMap 主要成员属性:threshold、loadFactor、HashMap 的懒加载;
9、HashMap 的 get 方法能否判断某个元素是否在 map 中;【不能:null】
10、HashMap 线程安全吗,哪些环节最有可能出问题,为什么?
11、HashMap 的 value 允许为 null,但是 HashTable 和 ConcurrentHashMap 的 value 都不允许为 null,试分析原因?
一、List 总结篇
前面已经充分介绍了有关于 List 接口的大部分知识,如 ArrayList、LinkedList、Vector、Stack,通过这几个知识点可以对List 接口有了比较深的了解了。只有通过归纳总结的知识才是你的知识。所以下面就List接口做一个总结。推荐阅读:
【搞定Java基础-集合篇】第二篇 源码ArrayList、LinkedList和Vector的区别
- 1、ArrayList、LinkedList、HashMap中都有一个字段叫modCount(表示list结构上被修改的次数。add,remove这些都会改变modcount)
- 一、ArrayList源码分析
- 1.1 ArrayList构造方法初始化一个空数组,直到第一次add的时候才初始化这个数组(因为第一次扩充时默认大小10一定是现在大小0*1.5的,所以第一次扩充是扩充到10)
- 1.2 调用add方法插入数据【1、当使用add方法的时候首先调用ensureCapacityInternal方法,传入size+1进去,检查是否需要扩充elementData数组的大小。2、检查完毕之后再将e赋值给elementData数组 ,size再自增1。】【newCapacity = 扩充数组为原来的1.5倍(不能自定义)----->ArrayList中copy数组的核心就是System.arraycopy方法,将original数组的所有数据复制到copy数组中,这是一个本地方法】
- 1.3 add(int index, E element)指定位置插入数据【1、ensureCapacityInternal检查是否要扩充数组,2、数据index及以后都后移一位,index位置插入数据System.arraycopy】
- 1.4 remove 指定位置移除元素[1、numMoved=size-1-index表示删除元素之后要移动元素的总数,将elementData数组从index+1开始的numMoved个元素,往前移动1位(覆盖index位置的元素)。2、接着将elementData数组的最后一个元素设置为空,方便GC回收内存。 System.arraycopy],返回被删除的值
- 1.5 get(int index)查找操作
- 二、Vector源码分析
- 三、LinkedList 源码【双向链表】
- 四、总结
- 1) 从使用方法的角度分析。ArrayList属于非线程安全,而Vector则属于线程安全。如果是开发中没有线程同步的需求,推荐优先使用ArrayList。因为其内部没有synchronized,执行效率会比Vector快很多。
- 2) 从数据结构的角度分析。ArrayList是一个数组结构(Vector同理),数组在内存中是一片连续存在的片段,在查找元素的时候数组能够很方便的通过内存计算直接找到对应的元素内存。但是它也有很大的缺点。我们假设需要往数组插入或删除数据的位置为i,数组元素长度为n,则需要搬运数据n-i次才能完成插入、删除操作,导致其效率不如LinkedList。
- 3) LinkedList的底层是一个双向链表结构,在进行查找操作的时候需要花费非常非常多的时间来遍历整个链表(哪怕只遍历一半),这就是LinkedList在查找效率不如ArrayList快的原因。但是由于其链表结构的特殊性,在插入、删除数据的时候,只需要修改链表节点的前后指针就可以完成操作,其的效率远远高于ArrayList。
- 五、Stack
1、List 接口描述
List 接口,称为有序的 Collection,也就是序列。该接口可以对列表中的每一个元素的插入位置进行精确的控制,同时用户可以根据元素的整数索引(在列表中的位置)访问元素,并搜索列表中的元素。
Collection:Collection 层次结构中的根接口。它表示一组对象,这些对象也称为 Collection 的元素。对于 Collection 而言,它不提供任何直接的实现,所有的实现全部由它的子类负责;
AbstractCollection: 提供 Collection 接口的骨干实现,以最大限度地减少了实现此接口所需的工作(contains,toArray等)。对于我们而言要实现一个不可修改的 Collection,只需扩展此类,并提供 iterator 和 size 方法的实现。但要实现可修改的 Collection,就必须另外重写此类的 add 方法(否则,会抛出 UnsupportedOperationException),iterator 方法返回的迭代器还必须另外实现其 remove 方法;
Iterator: 迭代器;
ListIterator: 列表迭代器,允许程序员按任一方向遍历列表. 迭代期间可修改列表,并获得迭代器在列表中的当前位置;
List: 继承于Collection的接口。它代表着有序的队列;
AbstractList: List 接口的骨干实现,以最大限度地减少实现“随机访问”数据存储(如数组)支持的该接口所需的工作;
Queue: 队列。提供队列基本的插入、获取、检查操作;
Deque: 一个线性 Collection,支持在两端插入和移除元素。大多数 Deque 实现对于它们能够包含的元素数没有固定限制,但此接口既支持有容量限制的双端队列,也支持没有固定大小限制的双端队列;
AbstractSequentialList: 提供了 List 接口的骨干实现,从而最大限度地减少了实现受“连续访问”数据存储(如链接列表)支持的此接口所需的工作。从某种意义上说,此类与在列表的列表迭代器上实现“随机访问”方法;
LinkedList: List 接口的链接列表实现。它实现所有可选的列表操作;
ArrayList: List 接口的大小可变数组的实现。它实现了所有可选列表操作,并允许包括 null 在内的所有元素。除了实现 List 接口外,此类还提供一些方法来操作内部用来存储列表的数组的大小;
Vector: 实现可增长的对象数组。与数组一样,它包含可以使用整数索引进行访问的组件;
Stack: 后进先出(LIFO)的对象堆栈。它通过五个操作对类 Vector 进行了扩展 ,允许将向量视为堆栈;
Enumeration: 枚举,实现了该接口的对象,它生成一系列元素,一次生成一个。连续调用 nextElement 方法将返回一系列的连续元素;
二、Map 总结篇
在前面文章中已经详细介绍了 HashMap、HashTable、TreeMap 的实现方法,从数据结构、实现原理、源码分析三个方面进行阐述,对这个三个类应该有了比较清晰的了解,下面就对 Map 做一个简单的总结。
推荐阅读:
【搞定Java基础 - 集合篇】第三篇、源码Java7 -HashMap、HashTable、ConCurrentHashMap 【java7都是从头插入,且先扩容再插入,只有hashMap默认容量16允许key为null,hashTable默认容量11和ConCurrentHashMap默认并发数16,entry数组2的key和value都不为null】【HashMap和ConCurrentHashMap都是2倍扩容,hashTable是2倍+1】
- 1.1 数据结构:hashMap :Entry数组+链表 【扩容后数组大小为当前的 2 倍。】
- 1.2 put(K key, V value)添加数据,是插入表头,key 为 null,放到 table[0] 中,会modcount++
- 1.4 V get(Object key)
- 2.1 构造方法:默认构造器,容量为:11,加载因子为:0.75,可以指定初始容量和默认加载因子
- 3.2 put 方法:HashTable 中的键 key 和值 value 都不可为空。【在 put 方法中,如果需要向 table[ ] 中添加 Entry 元素,会首先进行容量校验,如果容量已经达到了阀值,HashTable 就会进行扩容处理 rehash()
- 3.3 get 方法
- 3.1 数据结构: ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁(获取同步状态),所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
- 3.2 初始化得到segment数组
- 1)根据你的设置计算并行级别 ssize,要保持并行级别是 2 的 n 次方,默认是16,一旦确定后就不可以扩容
- 2)根据 initialCapacity【整个map初始大小】 计算 Segment 数组中每个位置可以分到的大小,Segment[i] 的默认大小为 2,负载因子是 0.75,得出初始阈值为 1.5,因为这样的话,对于具体的槽上,插入一个元素不至于扩容,插入第二个的时候才会扩容,segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry[] 进行扩容,扩容后,容量为原来的 2 倍
- 3)创建 Segment 数组(),并创建数组的第一个元素 segment[0](因为之后其余位置的初始化要利用segment[0]的参数)
- 3.3 put添加元素【不支持value为null】
- 一)找到元素所在的 segment 段s,然后放到该segment段 s.put()
- 二)在 s 这个segment段中的插入元素,segment对象内部的put方法
- 1)获取锁:在往该 segment 写入前,需要先获取该 segment 的独占锁,如果tryLock()快速获取锁失败,就要scanAndLockForPut获取锁【此方法有两个出口:一个是 tryLock() 成功了,循环终止,另一个就是重试次数超过了 MAX_SCAN_RETRIES,进到 lock() 方法,此方法会阻塞等待,直到成功拿到独占锁。】
- 2)利用 hash 值,求应该放置的数组下标int index = (tab.length - 1) & hash;
- 3)看该位置有无链表,如果有,遍历它找是否有重复元素,重复就覆盖,如果没有重复或者不存在链表,就将它设置为链表表头【都是从头插入】【在插入前要先判断是否超过了该 segment 的阈值,超过了则这个 segment 需要扩容,扩容后再进行插入】
- 4)解锁
- 3.4 get 获取元素
- 3.5 并发问题分析
- JDK1.7中ConcurrentHashMap与HashMap相比,有以下不同点
- HashTable 与 HashMap 的区别
- 第一: HashTable 基于 Dictionary 类,而 HashMap 是基于AbstractMap。Dictionary 是什么?它是任何可将键映射到相应值的类的抽象父类,而 AbstractMap 是基于 Map 接口的骨干实现,它以最大限度地减少实现此接口所需的工作。
- 第二: HashMap 可以允许存在一个为 null 的 key 和任意个为 null 的 value,但是 HashTable 中的 key 和 value 都不允许为null。
- 第三: Hashtable 的方法是同步的,而 HashMap 的方法不是。所以有人一般都建议如果是涉及到多线程同步时采用 HashTable,没有涉及就采用 HashMap,但是在 Collections 类中存在一个静态方法:synchronizedMap(),该方法创建了一个线程安全的 Map 对象,并把它作为一个封装的对象来返回,所以通过 Collections 类的 synchronizedMap 方法是可以同步访问潜在的HashMap。
- Hashtable 与 Collections.synchronizedMap(HashMap) 的区别
【搞定Java基础-集合篇】第四篇 Java8-HashMap和ConCurrentHashMap
- 一、Java8-HashMap源码【2倍扩容】
- 1.1 数据结构【数组+链表+红黑树,当put导致桶的数组长度>=64并且链表元素>8 个(插入的是第九个),会将链表转换为红黑树,降低查找的时间复杂度为 O(logN)。当红黑树节点数量<=6时,会转变为链表结构】【Java7: Entry,Java8 使用 Node来代表每个 HashMap 中的数据节点,,基本没有区别,都是 key,value,hash 和 next 这四个属性,不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。我们根据数组元素中,第一个节点数据类型是 Node 还是 TreeNode 来判断该位置下是链表还是红黑树的】
- 1.2 put【Java7先扩容再插入到链表最前面,Java8先插入再扩容,插入到链表最后面】【key和value可以为null】
- 1.3 HashMap 的 resize 函数源码分析 【重点中的重点】
- 1.4 get
- 二、Java8-ConCurrentHashMap
- 2.1 数据结构【结构上和 Java8 的 HashMap 基本上一样,不过它要保证线程安全性,所以在源码上确实要复杂一些。也引入了红黑树】
- 2.2 初始化【如果你有提供长度initialCapacity,那么数组长度为sizeCtl = 【 (1.5 * initialCapacity + 1),然后向上取最近的 2 的 n 次方】,但一般我们使用的默认构造器,不指定长度,默认数组长度是16】
- 2.3 put【key和value都不能为null】
- 1)如果数组"空",进行数组初始化
- 2)找该 hash 值对应的数组下标【i = (n - 1) & hash)】,得到第一个节点 f
- 3)if: 数组该位置f为空,用一次 CAS 操作将这个新值放入其中即可,如果 CAS 失败,那就是有并发操作,进到下一个循环就好了(1,2,3,4,5步骤都在for循环里,直到我们某一步成功放入,才break)
- 4)else if: 容量不够,扩容,扩容后的数组容量为原来的两倍
- 5)else: 数组该位置f不为空,获取数组该位置的头结点的监视器锁synchronized (f)【对数组的每个位置操作时都会这样加锁头结点】 ,如果是treeNode,则用红黑树的方法插入,如果是链表,判断是否有重复,重复覆盖,不重复则加到链表末尾,如果链表长度大于8,与HashMap不同的是它不一定会转换为红黑树,比如当前数组长度如果小于64,那会选择进行数组扩容,而不是转换为红黑树
- 2.5 初始化数组:initTable
- 2.6 get
【搞定Java基础-集合】第六篇:深入理解 LinkedHashMap 和 LRU 缓存
摘要:HashMap 和双向链表合二为一即是 LinkedHashMap
2.2 成员变量定义:增加了两个独有属性:双向链表头结点 header 和 迭代顺序标志位accessOrder【true=按访问顺序排序,false=按插入顺序排序(默认)】
2.4 基本元素 Entry:重新定义了Entry,增加了两个指针 before 和 after用于维护双向链表
LinkedHashMap 的扩容操作 : resize(),扩容为原来的2倍
LinkedHashMap 的读取实现 :get(Object key)
1、LinkedHashMap 的存取过程基本与 HashMap 类似,只是在细节实现上稍有不同,这是由 LinkedHashMap 本身的特性所决定的,因为它要额外维护一个双向链表用于保持迭代顺序。
5、LinkedHashMap 有序性原理分析【利用双向链表进行迭代输出】
6.1 构造函数【增加了双向链表的head和tail,以及访问标志accessOrder】
三、afterNodeXXXX命名格式的三个函数在HashMap中只是一个空实现,是专门用来让LinkedHashMap重写实现的hook函数
3.1 afterNodeAccess(Node p) { } //处理元素被访问后的情况:其功能为如果accessOrder为true,则将刚刚访问的元素移动到链表末尾
3.3 afterNodeRemoval(Node p) { } //处理元素被删除后的情况:在HashMap.removeNode()的末尾处调用, 将e从LinkedHashMap的双向链表中删除
1、LinkedHashMap 在 HashMap 的数组加链表结构的基础上,将所有节点连成了一个双向链表。
2.0 HashMap 和 TreeMap的不同点
1、HashMap 通过 hashCode 对其内容进行快速查找,而 TreeMap 中所有的元素都保持着某种固定的顺序,如果你需要得到一个有序的结果你就应该使用TreeMap(HashMap中元素的排列顺序是不固定的)。HashMap 中元素的排列顺序是不固定的)。
2、在 Map 中插入. 删除和定位元素,HashMap 是最好的选择。但如果您要按自然顺序或自定义顺序遍历键,那么TreeMap 会更好。使用 HashMap 要求添加的键类明确定义了 hashCode() 和 equals() 的实现。 这个 TreeMap 没有调优选项,因为该树总处于平衡状态。
2.1、Map 概述
首先先看 Map 的结构示意图:
Map: “键值对” 映射的抽象接口。该映射不包括重复的键,一个键对应一个值;
SortedMap: 有序的键值对接口,继承 Map 接口;
NavigableMap: 继承 SortedMap,具有了针对给定搜索目标返回最接近匹配项的导航方法的接口;
AbstractMap: 实现了 Map 中的绝大部分函数接口。它减少了 “Map的实现类” 的重复编码;
Dictionary: 任何可将键映射到相应值的类的抽象父类。目前被 Map 接口取代;
TreeMap: 有序散列表,实现 SortedMap 接口,底层通过红黑树实现;
HashMap: 是基于“拉链法”实现的散列表。底层采用 “数组+链表” 实现;
WeakHashMap: 基于“拉链法”实现的散列表;
HashTable: 基于“拉链法”实现的散列表。
它们之间的区别:
2.2、内部哈希:哈希映射技术
几乎所有通用 Map 都使用哈希映射技术。对于我们程序员来说我们必须要对其有所了解。
哈希映射技术是一种将元素映射到数组的非常简单的技术。由于哈希映射采用的是数组结构,那么必然存在一种用于确定任意键访问数组的索引机制,该机制能够提供一个小于数组大小的整数,我们将该机制称之为哈希函数。在 Java 中我们不必为寻找这样的整数而大伤脑筋,因为每个对象都必定存在一个返回整数值的 hashCode 方法,而我们需要做的就是将其转换为整数,然后再将该值除以数组大小取余即可。如下
int hashValue = Maths.abs(obj.hashCode()) % size;
2.3 Map 优化
首先我们这样假设,假设哈希映射的内部数组的大小只有1,所有的元素都将映射该位置(0),从而构成一条较长的链表。由于我们更新、访问都要对这条链表进行线性搜索,这样势必会降低效率。我们假设,如果存在一个非常大数组,每个位置链表处都只有一个元素,在进行访问时计算其 index 值就会获得该对象,这样做虽然会提高我们搜索的效率,但是它浪费了空间。诚然,虽然这两种方式都是极端的,但是它给我们提供了一种优化思路:使用一个较大的数组让元素能够均匀分布。 在 Map 有两个会影响到其效率,一是容器的初始化大小、二是负载因子。
2.3.1 调整容器初始化的大小
在哈希映射表中,内部数组中的每个位置称作 “存储桶” (bucket),而可用的存储桶数(即内部数组的大小)称作容量 (capacity)。我们为了使 Map 对象能够有效地处理任意数的元素,将 Map 设计成可以调整自身的大小。我们知道当 Map 中的元素达到一定量的时候就会调整容器自身的大小,但是这个调整大小的过程其开销是非常大的。调整大小需要将原来所有的元素插入到新数组中。我们知道 index = hash(key) % length。这样可能会导致原先冲突的键不在冲突,不冲突的键现在冲突的,重新计算、调整、插入的过程开销是非常大的,效率也比较低下。所以,如果我们开始知道 Map 的预期大小值,将 Map调整的足够大,则可以大大减少甚至不需要重新调整大小,这很有可能会提高速度。 下面是 HashMap 调整容器大小的过程,通过下面的代码我们可以看到其扩容过程的复杂性:【1.7】
void resize(int newCapacity) {
Entry[] oldTable = table; // 原始容器
int oldCapacity = oldTable.length; // 原始容器大小
if (oldCapacity == MAXIMUM_CAPACITY) { // 是否超过最大值:1073741824
threshold = Integer.MAX_VALUE;
return;
}
// 新的数组:大小为 oldCapacity * 2
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
/*
* 重新计算阀值 = newCapacity * loadFactor > MAXIMUM_CAPACITY + 1 ?
* newCapacity * loadFactor :MAXIMUM_CAPACITY + 1
*/
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
// 将元素插入到新数组中
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
2.3.2 调整负载因子
为了确认何时需要调整 Map 容器,Map 使用了一个额外的参数并且粗略计算存储容器的密度。在 Map 调整大小之前,使用”负载因子”来指示 Map 将会承担的“负载量”,也就是它的负载程度,当容器中元素的数量达到了这个“负载量”,则 Map 将会进行扩容操作。
例如:如果负载因子大小为 0.75,默认容量为11(HashTable),则 11 * 0.75 = 8.25 = 8,所以当我们容器中插入第 8 个元素的时候,Map 就会调整容器的大小。
负载因子本身就是在空间和时间之间的折衷:
当我使用较小的负载因子时,虽然降低了冲突的可能性,使得单个链表的长度减小了,加快了访问和更新的速度,但是它占用了更多的空间,使得数组中的大部分空间没有得到利用,元素分布比较稀疏,同时由于 Map 频繁的调整大小,可能会降低性能。
但是如果负载因子过大,会使得元素分布比较紧凑,导致产生冲突的可能性加大,从而访问、更新速度较慢。所以我们一般推荐不更改负载因子的值,采用默认值 0.75。
2.4、HashMap 面试“明星”问题汇总
你知道 HashMap 吗,请你讲讲 HashMap?
这个问题不单单考察你对 HashMap 的掌握程度,也考察你的表达、组织问题的能力。个人认为应该从以下几个角度入手(所有常见 HashMap 的考点问题总结):
1、size 必须是 2 的整数次方原因;
2、get 和 put 方法流程;
3、resize 方法;
4、影响 HashMap 的性能因素(key 的 hashCode 函数实现、loadFactor、初始容量);
5、HashMap key 的 hash 值计算方法以及原因(见上面 hash 函数的分析);
6、HashMap 内部存储结构:Node 数组 + 链表或红黑树;
7、table[i] 位置的链表什么时候会转变成红黑树(上面源码中有讲);
8、HashMap 主要成员属性:threshold、loadFactor、HashMap 的懒加载;
9、HashMap 的 get 方法能否判断某个元素是否在 map 中;【不能:null】
10、HashMap 线程安全吗,哪些环节最有可能出问题,为什么?
11、HashMap 的 value 允许为 null,但是 HashTable 和 ConcurrentHashMap 的 value 都不允许为 null,试分析原因?
12、HashMap 中的 hook 函数(在后面讲解 LinkedHashMap 时会讲到,这也是面试时拓展的一个点)
1、size 必须是 2 的整数次方原因;
原因是:
- 1、* CPU对位运算支持较好,即位运算速度很快。当 n 是 2 的整数次幂时:hash & (n - 1) 与 hash % n 是等价的,但是两者效率来讲是不同的,位运算的效率远高于取余 % 运算。****所以,HashMap中使用的是 hash & (n - 1)。
- 2、在1.8中,这还带来了一个好处,就是将旧数组中的 Node 迁移到扩容后的新数组中的时候有一个很方便的特性:【索引为 i 的节点,rehash后的索引只可能是 i 或者 i+oldcap,也就是我们可以这样处理:把 table[i] 这个桶中的 node 拆分为两个链表 l1 和 l2:如果hash & n == 0,那么当前这个 node 被连接到 l1 链表;否则连接到 l2 链表。这样下来,当遍历完 table[i] 处的所有 node 的时候,我们得到两个链表 l1 和 l2,这时我们令 newtab[i] = l1,newtab[i + n] = l2,这就完成了 table[i] 位置所有 node 的迁移(rehash),这也是 HashMap 中容量一定的是2的整数次幂带来的方便之处。 (因为Java8 是尾插,如果你一个一个的来放置的话,那么每个位置你都要遍历到该位置链表的尾部才能插入,耗时长【自己理解的】)】
2、get 和 put 方法流程;
java1.7的hashmap
- 1.2 put(K key, V value)添加数据,是插入表头,key 为 null,放到 table[0] 中,会modcount++[达到阈值后,先扩容再插入]
- 1.4 V get(Object key)
Java1.8的hashMap【实质扩容的条件和1.7一样的,1.7是插入之前>=阈值就扩容再插入,1.8是插入之后发现 此时size > 阈值就扩容,所以都是此次会超过阈值就扩容】
- 1.1 数据结构【数组+链表+红黑树,当put导致桶的数组长度>=64并且链表元素>8 个(插入的是第九个),会将链表转换为红黑树,降低查找的时间复杂度为 O(logN)。当红黑树节点数量<=6时,会转变为链表结构】【Java7: Entry,Java8 使用 Node来代表每个 HashMap 中的数据节点,,基本没有区别,都是 key,value,hash 和 next 这四个属性,不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。我们根据数组元素中,第一个节点数据类型是 Node 还是 TreeNode 来判断该位置下是链表还是红黑树的】
- 1.2 put【Java7先扩容再插入到链表最前面,Java8先插入再扩容,插入到链表最后面】【key和value可以为null】
- 1.4 get
3、resize 方法;
有两种情况会调用resize:
- 1、之前说过 HashMap 是懒加载,第一次调用 HashMap 的 put 方法的时候 table 还没初始化,这个时候会执行 resize,进行table 数组的初始化,table 数组的初始容量保存在 threshold 中(如果从构造器中传入的一个初始容量的话),如果创建HashMap 的时候没有指定容量,那么 table 数组的初始容量是默认值:16。即,初始化 table 数组的时候会执行 resize 函数。
- 2、扩容的时候会执行 resize 函数,插入元素后,当 size 的值 > threshold 的时候会触发扩容(1.8,先插入后发现>threshold),即执行 resize 方法,这时 table 数组的大小会翻倍。
- 注意我们每次扩容之后容量都是翻倍( * 2),所以HashMap的容量一定是2的整数次幂。
4、影响 HashMap 的性能因素(key 的 hashCode 函数实现、loadFactor、初始容量);
- 如果我们开始知道 Map 的预期大小值,将 Map调整的足够大,则可以大大减少甚至不需要重新调整大小,这会提高速度【避免了扩容】。
- 假如你预先知道最多往 HashMap 中存储 64 个元素,那么你在创建 HashMap 的时候:如果选用无参构造器:默认容量16,在存储 16*loadFactor 个元素之后就要进行扩容(数组扩容涉及到连续空间的分配,Node 节点的 rehash,代价很高,所以要尽量避免扩容操作)。如果给构造器传入的参数是 64,这时 HashMap 中在存储 64 * loadFactor 个元素之后就要进行扩容;但是如果你给构造器传的参数为:(int)(64/0.75) + 1,此时就可以保证 HashMap 不用进行扩容,避免了扩容时的代价。
- 负载因子本身就是在空间和时间之间的折衷:最好还是采用默认0.75
当我使用较小的负载因子时,虽然降低了冲突的可能性,使得单个链表的长度减小了,加快了访问和更新的速度,但是它占用了更多的空间,使得数组中的大部分空间没有得到利用,元素分布比较稀疏,同时由于 Map 频繁的调整大小,可能会降低性能。
但是如果负载因子过大,会使得元素分布比较紧凑,导致产生冲突的可能性加大,从而访问、更新速度较慢。所以我们一般推荐不更改负载因子的值,采用默认值 0.75。
5、HashMap key 的 hash 值计算方法以及原因(见上面 hash 函数的分析);1.8的
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
HashMap 允许 key 为null,null 的 hash 为 0。非 null 的 key 的 hash 高 16 位和低 16 位分别由:key 的 hashCode 高 16 位 和 hashCode 的高 16 位异或 hashCode 的低16位组成。主要是为了增强 hash 的随机性,减少 hash & (n - 1) 的随机性,即减小 hash 冲突,提高 HashMap 的性能。
- (因为如果直接使用 hashCode & (n - 1) 来计算 index,此时 hashCode 的高位随机特性完全没有用到,因为 n 相对于hashCode 的值很小,计算 index 的时候只能用到低 16 位。基于这一点,把 hashCode 高 16 位的值通过异或混合到hashCode 的低 16 位,由此来增强 hashCode 低 16 位的随机性。)
6、1.8 HashMap 内部存储结构:Node 数组 + 链表或红黑树;
7、1.8 table[i] 位置的链表什么时候会转变成红黑树;
MIN_TREEIFY_CAPACITY值是64,也就是当链表长度>8的时候(插入后再转),有两种情况:
-
如果table数组的长度<64,此时进行扩容操作;
-
如果table数组的长度>=64,此时进行链表转红黑树结构的操作.
8、HashMap 主要成员属性:threshold、loadFactor、HashMap 的懒加载;
- HashMap 是懒加载,第一次调用 HashMap 的 put 方法的时候 table 还没初始化,这个时候会执行 resize,进行table 数组的初始化
- threshold:threshold 也是比较重要的一个属性:创建 HashMap 时,该变量的值是:初始容量(2 的整数次幂),之后 threshold的值是 HashMap 扩容的门限值,即当前 Nodetable 数组的长度 * loadfactor。
- loadFactor:是空间和时间的一个平衡点。
* DEFAULT_LOAD_FACTOR较小时,需要的空间较大,但是 put 和 get 的代价较小;
* DEFAULT_LOAD_FACTOR较大时,需要的空间较小,但是 put 和 get 的代价较大;
9、HashMap 的 get 方法能否判断某个元素是否在 map 中;【不能:null】
因为HashMap是可以存放值为null的,你并不能分辨到底是 不存在返回null 还是本身值是null;
// 入口,返回对应的value
public V get(Object key) {
Node<K,V> e;
// hash函数上面分析过了
return (e = getNode(hash(key), key))== null ? null : e.value;
}
public boolean containsKey(Object key) {
// 注意与get函数区分,我们往map中put的所有的<key,value>都被封装在Node中,
// 如果Node都不存在显然一定不包含对应的key
return getNode(hash(key), key) != null;
}
10、HashMap 线程安全吗,哪些环节最有可能出问题,为什么?
HashMap 在并发时可能出现的问题主要是两方面:
-
如果多个线程同时使用 put 方法添加元素,而且假设正好存在两个 put 的 key 发生了碰撞(根据 hash 值计算的 bucket 一样),那么根据 HashMap 的实现,这两个 key 会添加到数组的同一个位置,这样最终就会发生其中一个线程 put 的数据被覆盖
-
1.7 (实际是Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, e);) 大致意思就是e.next=table[i];table[i] =e;
-
同样1.8 在尾部插入的时候同时进行也会覆盖另一个线程的put
-
-
如果多个线程同时检测到元素个数超过数组大小 * loadFactor,这样就会发生多个线程同时对 Node 数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给 table,也就是说其他线程的都会丢失,并且各自线程 put 的数据也丢失【这是1.8,如果是1.7头插还会导致死循环】
-
1.8对table[i]是拆分成两个链表,再挂到table[i]和table[i+oldCap]下,一个线程拆分j位置会让 oldTab[j] = null; 另一个线程可能就认为该位置无元素,该线程把它扩容后的数组赋给table,就会丢失其他线程的扩容
-
如果扩容的同时也put元素,比如put正在迁移的位置,那么因为最后会把拆分的两个链表赋给那两个位置,所以会导致各自线程put的数据也丢失。
-
11、HashMap 的 value 允许为 null,但是 HashTable 和 ConcurrentHashMap 的 value 都不允许为 null,试分析原因?
首先要明确 ConcurrentHashMap 和 Hashtable 从技术从技术层面讲是可以允许 value 为 null 。但是它们实际上是不允许的,这肯定是为了解决一些问题,为了说明这个问题,我们看下面这个例子(这里以 ConcurrentHashMap 为例,HashTable 也是类似)。
HashMap 由于允 value 为 null,get 方法返回 null 时有可能是 map 中没有对应的 key;也有可能是该 key 对应的 value 为 null。所以 get 不能判断 map 中是否包含某个 key,只能使用 contains 判断是否包含某个 key。
看下面的代码段,要求完成这个一个功能:如果 map 中包含了某个 key ,则返回对应的 value,否则抛出异常:
if (map.containsKey(k)) {
return map.get(k);
} else {
throw new KeyNotPresentException();
}
1、如果上面的 map 为HashMap,那么没什么问题,因为 HashMap 本来就是线程不安全的,如果有并发问题应该用ConcurrentHashMap,所以在单线程下面可以返回正确的结果。【单线程下,hashmap包含key,返回null或其他值,不包含就抛出异常,能反应真实情况;而ConCurrentHashMap是用于多线程的,在判断key存在后,key能被别的线程删掉了,它会返回null,这就表示不存在能反应真实的情况,所以ConcurrentHashMap不能存放null】
2、如果上面的 map 为ConcurrentHashMap,此时存在并发问题:在 map.containsKey(k) 和 map.get 之间有可能其他线程把这个 key 删除了,这时候 map.get 就会返回 null,而 ConcurrentHashMap 中不允许 value 为 null,也就是这时候返回了 null,一个根本不允许出现的值?
但是因为 ConcurrentHashMap 不允许 value 为 null,所以可以通过 map.get(key) 是否为 null 来判断该 map 中是否包含该 key,这时就没有上面的并发问题了。
12、HashMap 中的 hook 函数
三、afterNodeXXXX命名格式的三个函数在HashMap中只是一个空实现,是专门用来让LinkedHashMap重写实现的hook函数
3.1 afterNodeAccess(Node p) { } //处理元素被访问后的情况:其功能为如果accessOrder为true,则将刚刚访问的元素移动到链表末尾
3.3 afterNodeRemoval(Node p) { } //处理元素被删除后的情况:在HashMap.removeNode()的末尾处调用, 将e从LinkedHashMap的双向链表中删除
三、Set 总结篇
Set 就是 HashMap 将 value 固定为一个object,只存 key 元素包装成一个 entry 即可,其他和 Map 基本一样。
所有 Set 几乎都是内部用一个 Map 来实现,因为 Map 里的 KeySet 就是一个Set,而 value 是假值,全部使用同一个Object 即可。
Set 的特征也继承了那些内部的 Map 实现的特征。
HashSet:内部使用 HashMap 来存储元素和操作元素。
LinkedHashSet:内部使用 LinkedHashMap 来存储元素和操作元素。
TreeSet:内部是TreeMap 的 SortedSet。
ConcurrentSkipListSet:内部是 ConcurrentSkipListMap 的并发优化的 SortedSet。
CopyOnWriteArraySet:内部是 CopyOnWriteArrayList 的并发优化的 Set,利用其 addIfAbsent() 方法实现元素去重,如前所述该方法的性能很一般。
好像少了个 ConcurrentHashSet,本来也该有一个内部用 ConcurrentHashMap 的简单实现,但JDK偏偏没提供。Jetty就自己简单封了一个,Guava 则直接用 java.util.Collections.newSetFromMap(new ConcurrentHashMap()) 实现。
四、对集合的选择
4.1 对 List 的选择
1、对于随机查询与迭代遍历操作,数组比所有的容器都要快。所以在随机访问中一般使用 ArrayList
2、LinkedList 使用双向链表对元素的增加和删除提供了非常好的支持,而 ArrayList 执行增加和删除元素需要进行元素位移。
3、对于 Vector 而已,我们一般都是避免使用。
4、将 ArrayList 当做首选,毕竟对于集合元素而已我们都是进行遍历,只有当程序的性能因为List的频繁插入和删除而降低时,再考虑 LinkedList。
4.2 对 Set 的选择
1、HashSet 由于使用 HashCode 实现,所以在某种程度上来说它的性能永远比 TreeSet 要好,尤其是进行增加和查找操作。
2、虽然 TreeSet 没有 HashSet 性能好,但是由于它可以维持元素的排序,所以它还是存在用武之地的。
4.3 对 Map 的选择
1、HashMap 与 HashSet 同样,支持快速查询。虽然 HashTable 速度的速度也不慢,但是在 HashMap 面前还是稍微慢了些,所以 HashMap 在查询方面可以取代 HashTable。
2、由于TreeMap 需要维持内部元素的顺序,所以它通常要比 HashMap 和 HashTable 慢。
五、Comparable 和 Comparator
实现 Comparable 接口可以让一个类的实例互相使用 compareTo 方法进行比较大小,可以自定义比较规则;
Comparator 则是一个通用的比较器,比较指定类型的两个元素之间的大小关系。