共计 17946 个字符,预计需要花费 45 分钟才能阅读完成。
1.1 说说汇合容器罕用的汇合类?
Java 汇合框架次要包含两种类型的容器,一种是汇合(Collection),存储一个元素汇合,另一种是图(Map),存储键 / 值对映射。
- Collection 汇合的子接口有 Set、List、Queue 三种子接口, 罕用的 List 接口和 Set 接口。
- List 接口次要实现类:
ArrayList
、LinkedList
、Vector
及Stack
。 - Set 接口的次要实现类:
HashSet
、TreeSet
、LinkedHashSet
等 - Map 接口的次要实现类:
HashMap
、TreeMap
、Hashtable
、LinkedHashMap
、ConcurrentHashMap
以及Properties
等
1.2 List,Set,Map 三者的区别?List、Map、Set 三个接口存取元素时,各有什么特点?
- List:一个有序(元素存入汇合的程序和取出的程序统一)容器,元素能够反复,能够插入多个 null 元素,元素都有索引。
- Set:一个无序(存入和取出程序有可能不统一)容器,不能够存储反复元素,只容许存入一个 null 元素,必须保障元素唯一性。
- Map:是一个键值对汇合,存储键、值和之间的映射。Key 无序,惟一;value 不要求有序,容许反复。
1.3 ArrayList 和 LinkedList 的区别是什么?
- 底层数据结构实现:
ArrayList
底层应用的是动静数组,而LinkedList
底层应用的是双向链表。双向链表 - 随机拜访效率:
ArrayList
比LinkedList
在随机拜访的时候效率要高,ArrayList
实现了RandomAccess
接口,而LinkedList
是线性的数据存储形式,所以须要挪动指针从前往后顺次查找。 - 插入和删除效率 :
ArrayList
底层采纳数组存储,所以插入和删除元素 的工夫复杂度受元素地位的影响。比方: 执行 add(E e) 办法的时候,ArrayList
会默认在将指定的元素追加到此列表的开端,这种状况工夫复杂度就是 O(1)。然而如果要在指定地位 i 插入和删除元素的话 (add(int index, E element)) 工夫复杂度就为 O(n-i)。因为在进 行上述操作的时候汇合中第 i 和第 i 个元素之后的 (n-i) 个元素都要执行向后位 / 向前移一位的 操作。LinkedList
底层采纳链表存储,所以对于 add(E e) 办法的插入,删除元素工夫简单 度不受元素地位的影响,近似 O(1),如果是要在指定地位 i 插入和删除元素的话 add(int index, E element)工夫复杂度近似为 o(n) 因为须要先挪动到指定地位 再插入。 - 内存空间占用:
LinkedList
比ArrayList
更占内存,因为 LinkedList 的节点除了存储数据,还存储了两个援用,一个指向前一个元素,一个指向后一个元素。 - 线程平安:ArrayList 和 LinkedList 都是不同步的,也就是不保障线程平安。
补充内容:RandomAccess接口
public interface RandomAccess {}
查看源码咱们发现实际上 RandomAccess 接口中什么都没有定义。所以,在我看来 RandomAccess 接口不过是一个标识罢了。标识什么? 标识实现这个接口的类具备随机拜访性能。
在 binarySearch()办法中,它要判断传入的 list 是否 RamdomAccess 的实例,如果是,调用 indexedBinarySearch() 办法,如果不是,那么调用 iteratorBinarySearch() 办法
public static <T>
int binarySearch(List<? extends Comparable<? super Tjk list, T key)
{if (list instanceof RandomAccess || list.size() <BINARYSEARCH_THRESHOLD)
return Collections.indexedBinarySearch(list, key); else
}
return Collections.iteratorBinarySearch(list, key);
ArrayList 实现了 RandomAccess 接口,而 LinkedList 没有实现。为什么呢? 我感觉还是 和底层数据结构无关! ArrayList 底层是数组,而 LinkedList 底层是链表。数组人造反对随机 拜访,工夫复杂度为 O(1),所以称为疾速随机拜访。链表须要遍历到特定地位能力拜访特定地位的 元素,工夫复杂度为 O(n),所以不反对疾速随机拜访。,ArrayList 实现了 RandomAccess 接 口,就表明了他具备疾速随机拜访性能。RandomAccess 接口只是标识,并不是说 ArrayList 实 现 RandomAccess 接口才具备疾速随机拜访性能的!
上面再总结一下 list 的遍历形式抉择:
- 实现了 RandomAccess 接口的 list,优先选择一般 for 循环,其次 foreach。
- 未实现 RandomAccess 接口的 list,优先选择 iterator 遍历(foreach 遍历底层也是通过 iterator 实现的,),大 size 的数据,千万不要应用一般 for 循环。
1.4 ArrayList 和 Vector 的区别是什么?
- 线程平安:
Vector
应用了 Synchronized 来实现线程同步,是线程平安的,而 ArrayList 是非线程平安的。 - 性能:
ArrayList
在性能方面要优于Vector
。 - 扩容 :
ArrayList
和Vector
都会依据理论的须要动静的调整容量,只不过在 Vector 扩容每次会减少 1 倍,而 ArrayList 只会减少 50%。
综上所述:
- ArrayList、Vector 底层的实现是数组,反对随机拜访,所以适宜做查问的操作。然而 Vector 中的办法因为加了 synchronized 润饰,因而 Vector 是线程平安容器,但性能上较 ArrayList 差。
- LinkedList 应用双向链表实现存储,按序号索引数据须要进行前向或后向遍历,但插入数据时只须要记录以后项的前后项即可,所以 LinkedList 插入速度较快。
补充阐明:为什么 ArrayList 的 elementData 加上 transient 润饰
ArrayList 中的数组定义如下:
private transient Object[] elementData;
再看一下 ArrayList 的定义:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
能够看到 ArrayList 实现了 Serializable 接口,这意味着 ArrayList 反对序列化。transient 的作用是说不心愿 elementData 数组被序列化,重写了 writeObject 实现:
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException{
*// Write out element count, and any hidden stuff*
int expectedModCount = modCount;
s.defaultWriteObject();
*// Write out array length*
s.writeInt(elementData.length);
*// Write out all elements in the proper order.*
for (int i=0; i<size; i++)
s.writeObject(elementData[i]);
if (modCount != expectedModCount) {throw new ConcurrentModificationException();
}
每次序列化时,先调用 defaultWriteObject() 办法序列化 ArrayList 中的非 transient 元素,而后遍历 elementData,只序列化已存入的元素,这样既放慢了序列化的速度,又减小了序列化之后的文件大小。
1.5 HashSet 如何查看反复?HashSet 是如何保证数据不可反复的?
向 HashSet 中 add ()元素时,判断元素是否存在的根据,不仅要比拟 hash 值,同时还要联合 equles 办法比拟。HashSet 中的 add ()办法会应用 HashMap 的 put()办法。
HashMap 的 key 是惟一的,由源码能够看出 HashSet 增加进去的值就是作为 HashMap 的 key,并且在 HashMap 中如果 K / V 雷同时,会用新的 V 笼罩掉旧的 V,而后返回旧的 V。所以不会反复(HashMap 比拟 key 是否相等是先比拟 hashcode 再比拟 equals)。
以下是 HashSet 局部源码:
private static final Object PRESENT = new Object();
private transient HashMap<E,Object> map;
public HashSet() {map = new HashMap<>();
}
public boolean add(E e) {
// 调用 HashMap 的 put 办法,PRESENT 是一个至始至终都雷同的虚值
return map.put(e, PRESENT)==null;
}
hashCode()与 equals()的相干规定:
- 如果两个对象相等,则 hashcode 肯定也是雷同的
- 两个对象相等, 对两个 equals 办法返回 true
- 两个对象有雷同的 hashcode 值,它们也不肯定是相等的
- 综上,equals 办法被笼罩过,则 hashCode 办法也必须被笼罩
- hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写 hashCode(),则该 class 的两个对象无论如何都不会相等(即便这两个对象指向雷同的数据)。
深刻了解可参考:Java 中 hashCode() 和 equals()的问题解答
1.6 HashSet 与 HashMap 的区别
如果你看过 HashSet 源码的话就应该晓得:HashSet 底层就是基于 HashMap 实现的。(HashSet 的 源码十分非常少,因为除了 clone()、writeObject()、readObject() 是 HashSet 本人不得不 实现之外,其余办法都是间接调用 HashMap 中的办法。
HashMap | HashSet |
---|---|
实现了 Map 接口 | 实现 Set 接口 |
存储键值对 | 仅存储对象 |
调用 put()向 map 中增加元素 | 调用 add()办法向 Set 中增加元素 |
HashMap 应用键(Key)计算 Hashcode | HashSet 应用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能雷同,所以 equals()办法用来判断对象的相等性,如果两个对象不同的话,那么返回 false |
HashMap 绝对于 HashSet 较快,因为它是应用惟一的键获取对象 | HashSet 较 HashMap 来说比较慢 |
1.7 HashMap 在 JDK1.7 和 JDK1.8 中有哪些不同?HashMap 的底层实现
在 Java 中,保留数据有两种比较简单的数据结构:数组和链表。「数组的特点是:寻址容易,插入和删除艰难;链表的特点是:寻址艰难,但插入和删除容易;* 所以咱们将数组和链表联合在一起,施展两者各自的劣势,应用一种叫做 ** 拉链法」的形式能够解决哈希抵触。
JDK1.8 之前
JDK1.8 之前 HashMap 底层是 数组和链表 联合在一起应用也就是 链表散列即采纳的是拉链法。拉链法:将链表和数组相结合。也就是说创立一个链表数组,数组中每一格就是一个链表。若遇到哈希抵触,则将抵触的值加到链表中即可。
jdk1.7 中 HashMap 数据结构
JDK1.8 之后
相比于之前的版本,jdk1.8 在解决哈希抵触时有了较大的变动,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以缩小搜寻工夫。
jdk1.8 中 HashMap 数据结构
JDK1.7 VS JDK1.8 比拟
JDK1.8 次要解决或优化了一下问题:
- resize 扩容优化
- 引入了红黑树,目标是防止单条链表过长而影响查问效率,红黑树算法请参考
- 解决了多线程死循环问题,但仍是非线程平安的,多线程时可能会造成数据失落问题。
1.8 HashMap 的 put 办法的具体流程?
当咱们 put 的时候,首先计算 key 的 hash 值,这里调用了 hash 办法,hash 办法理论是让 key.hashCode()与 key.hashCode()>>>16 进行异或操作,高 16bit 补 0,一个数和 0 异或不变,所以 hash 函数大略的作用就是:高 16bit 不变,低 16bit 和高 16bit 做了一个异或,目标是缩小碰撞。依照函数正文,因为 bucket 数组大小是 2 的幂,计算下标 index = (table.length – 1) & hash,如果不做 hash 解决,相当于散列失效的只有几个低 bit 位,为了缩小散列的碰撞,设计者综合思考了速度、作用、品质之后,应用高 16bit 和低 16bit 异或来简略解决缩小碰撞,而且 JDK8 中用了复杂度 O(logn)的树结构来晋升碰撞下的性能。
putVal 办法执行流程图
public V put(K key, V value) {return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 实现 Map.put 和相干办法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {Node<K,V>[] tab; Node<K,V> p; int n, i;
// 步骤①:tab 为空则创立
// table 未初始化或者长度为 0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤②:计算 index,并对 null 做解决
// (n - 1) & hash 确定元素寄存在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 桶中曾经存在元素
else {
Node<K,V> e; K k;
// 步骤③:节点 key 存在,间接笼罩 value
// 比拟桶中第一个元素 (数组中的结点) 的 hash 值相等,key 相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给 e,用 e 来记录
e = p;
// 步骤④:判断该链为红黑树
// hash 值不相等,即 key 不相等;为红黑树结点
// 如果以后元素类型为 TreeNode,示意为红黑树,putTreeVal 返回待寄存的 node, e 可能为 null
else if (p instanceof TreeNode)
// 放入树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 步骤⑤:该链为链表
// 为链表结点
else {
// 在链表最末插入结点
for (int binCount = 0; ; ++binCount) {
// 达到链表的尾部
// 判断该链表尾部指针是不是空的
if ((e = p.next) == null) {
// 在尾部插入新结点
p.next = newNode(hash, key, value, null);
// 判断链表的长度是否达到转化红黑树的临界值,临界值为 8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 链表构造转树形构造
treeifyBin(tab, hash);
// 跳出循环
break;
}
// 判断链表中结点的 key 值与插入的元素的 key 值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与后面的 e = p.next 组合,能够遍历链表
p = e;
}
}
// 判断以后的 key 曾经存在的状况下,再来一个雷同的 hash 值、key 值时,返回新来的 value 这个值
if (e != null) {
// 记录 e 的 value
V oldValue = e.value;
// onlyIfAbsent 为 false 或者旧值为 null
if (!onlyIfAbsent || oldValue == null)
// 用新值替换旧值
e.value = value;
// 拜访后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性批改
++modCount;
// 步骤⑥:超过最大容量就扩容
// 理论大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
①. 判断键值对数组 table[i]是否为空或为 null,否则执行 resize()进行扩容;
②. 依据键值 key 计算 hash 值得到插入的数组索引 i,如果 table[i]==null,间接新建节点增加,转向⑥,如果 table[i]不为空,转向③;
③. 判断 table[i]的首个元素是否和 key 一样,如果雷同间接笼罩 value,否则转向④,这里的雷同指的是 hashCode 以及 equals;
④. 判断 table[i] 是否为 treeNode,即 table[i] 是否是红黑树,如果是红黑树,则间接在树中插入键值对,否则转向⑤;
⑤. 遍历 table[i],判断链表长度是否大于 8,大于 8 的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现 key 曾经存在间接笼罩 value 即可;
⑥. 插入胜利后,判断理论存在的键值对数量 size 是否超多了最大容量 threshold,如果超过,进行扩容。
1.9 HashMap 的扩容操作是怎么实现的?
①. 在 jdk1.8 中,resize 办法是在 hashmap 中的键值对大于阀值时或者初始化时,就调用 resize 办法进行扩容;
②. 每次扩大的时候,都是扩大 2 倍;
③. 扩大后 Node 对象的地位要么在原地位,要么挪动到原偏移量两倍的地位。
在 putVal()中,咱们看到在这个函数外面应用到了 2 次 resize()办法,resize()办法示意的在进行第一次初始化时会对其进行扩容,或者当该数组的理论大小大于其临界值值 (第一次为 12), 这个时候在扩容的同时也会随同的桶下面的元素进行从新散发,这也是 JDK1.8 版本的一个优化的中央,在 1.7 中,扩容之后须要从新去计算其 Hash 值,依据 Hash 值对其进行散发,但在 1.8 版本中,则是依据在同一个桶的地位中进行判断(e.hash & oldCap) 是否为 0,从新进行 hash 调配后,该元素的地位要么停留在原始地位,要么挪动到原始地位 + 减少的数组大小这个地位上
final Node<K,V>[] resize() {Node<K,V>[] oldTab = table;//oldTab 指向 hash 桶数组
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {// 如果 oldCap 不为空的话,就是 hash 桶数组不为空
if (oldCap >= MAXIMUM_CAPACITY) {// 如果大于最大容量了,就赋值为整数最大的阀值
threshold = Integer.MAX_VALUE;
return oldTab;// 返回
}// 如果以后 hash 桶数组的长度在扩容后依然小于最大容量 并且 oldCap 大于默认值 16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold 双倍扩容阀值 threshold
}
// 旧的容量为 0,但 threshold 大于零,代表有参结构有 cap 传入,threshold 曾经被初始化成最小 2 的 n 次幂
// 间接将该值赋给新的容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 无参结构创立的 map,给出默认容量和 threshold 16, 16*0.75
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 新的 threshold = 新的 cap * 0.75
if (newThr == 0) {float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 计算出新的数组长度后赋给以后成员变量 table
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];// 新建 hash 桶数组
table = newTab;// 将新数组的值复制给旧的 hash 桶数组
// 如果原先的数组没有初始化,那么 resize 的初始化工作到此结束,否则进入扩容元素重排逻辑,使其平均的扩散
if (oldTab != null) {
// 遍历新数组的所有桶下标
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 旧数组的桶下标赋给长期变量 e,并且解除旧数组中的援用,否则就数组无奈被 GC 回收
oldTab[j] = null;
// 如果 e.next==null,代表桶中就一个元素,不存在链表或者红黑树
if (e.next == null)
// 用同样的 hash 映射算法把该元素退出新的数组
newTab[e.hash & (newCap - 1)] = e;
// 如果 e 是 TreeNode 并且 e.next!=null,那么解决树中元素的重排
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// e 是链表的头并且 e.next!=null,那么解决链表中元素重排
else { // preserve order
// loHead,loTail 代表扩容后不必变换下标,见注 1
Node<K,V> loHead = null, loTail = null;
// hiHead,hiTail 代表扩容后变换下标,见注 1
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 遍历链表
do {
next = e.next;
if ((e.hash & oldCap) == 0) {if (loTail == null)
// 初始化 head 指向链表以后元素 e,e 不肯定是链表的第一个元素,初始化后 loHead
// 代表下标放弃不变的链表的头元素
loHead = e;
else
// loTail.next 指向以后 e
loTail.next = e;
// loTail 指向以后的元素 e
// 初始化后,loTail 和 loHead 指向雷同的内存,所以当 loTail.next 指向下一个元素时,// 底层数组中的元素的 next 援用也相应发生变化,造成 lowHead.next.next.....
// 追随 loTail 同步,使得 lowHead 能够链接到所有属于该链表的元素。loTail = e;
}
else {if (hiTail == null)
// 初始化 head 指向链表以后元素 e, 初始化后 hiHead 代表下标更改的链表头元素
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 遍历完结, 将 tail 指向 null,并把链表头放入新数组的相应下标,造成新的映射。if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
1.10 HashMap 是怎么解决哈希抵触的?
什么是哈希?
Hash,个别翻译为“散列”,也有间接音译为“哈希”的,这就是把任意长度的输出通过散列算法,变换成固定长度的输入,该输入就是散列值(哈希值);这种转换是一种压缩映射,也就是,散列值的空间通常远小于输出的空间,不同的输出可能会散列成雷同的输入,所以不可能从散列值来惟一的确定输出值。简略的说就是一种将任意长度的消息压缩到某一固定长度的音讯摘要的函数。
所有散列函数都有如下一个根本个性:依据同一散列函数计算出的散列值如果不同,那么输出值必定也不同。然而,依据同一散列函数计算出的散列值如果雷同,输出值不肯定雷同。
什么是哈希抵触?
当两个不同的输出值,依据同一散列函数计算出雷同的散列值的景象,咱们就把它叫做碰撞(哈希碰撞)。
这样咱们就能够将领有雷同哈希值的对象组织成一个链表放在 hash 值所对应的 bucket 下,但相比于 hashCode 返回的 int 类型,咱们 HashMap 初始的容量大小 DEFAULT_INITIAL_CAPACITY = 1 << 4(即 2 的四次方 16)要远小于 int 类型的范畴,所以咱们如果只是单纯的用 hashCode 取余来获取对应的 bucket 这将会大大增加哈希碰撞的概率,并且最坏状况下还会将 HashMap 变成一个单链表,所以咱们还须要对 hashCode 作肯定的优化。
hash()函数
下面提到的问题,次要是因为如果应用 hashCode 取余,那么相当于参加运算的只有 hashCode 的低位,高位是没有起到任何作用的,所以咱们的思路就是让 hashCode 取值出的高位也参加运算,进一步升高 hash 碰撞的概率,使得数据分布更均匀,咱们把这样的操作称为扰动,在 JDK 1.8 中的 hash()函数如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 与本人右移 16 位进行异或运算(高下位异或)}
这比在 JDK 1.7 中,更为简洁,相比在 1.7 中的 4 次位运算,5 次异或运算(9 次扰动),在 1.8 中,只进行了 1 次位运算和 1 次异或运算(2 次扰动);
「JDK1.8 新增红黑树」
通过下面的链地址法(应用散列表)和扰动函数咱们胜利让咱们的数据分布更均匀,哈希碰撞缩小,然而当咱们的 HashMap 中存在大量数据时,退出咱们某个 bucket 下对应的链表有 n 个元素,那么遍历工夫复杂度就为 O(n),为了针对这个问题,JDK1.8 在 HashMap 中新增了红黑树的数据结构,进一步使得遍历复杂度升高至 O(logn);总结 简略总结一下 HashMap 是应用了哪些办法来无效解决哈希抵触的:
- 应用链地址法(应用散列表)来链接领有雷同 hash 值的数据;
- 应用 2 次扰动函数(hash 函数)来升高哈希抵触的概率,使得数据分布更均匀;
- 引入红黑树进一步升高遍历的工夫复杂度,使得遍历更快;
1.11 HashMap 为什么不间接应用 hashCode()解决后的哈希值间接作为 table 的下标?
答:hashCode()办法返回的是 int 整数类型,其范畴为 -(2 ^ 31)~(2 ^ 31 – 1),约有 40 亿个映射空间,而 HashMap 的容量范畴是在 16(初始化默认值)~2 ^ 30,HashMap 通常状况下是取不到最大值的,并且设施上也难以提供这么多的存储空间,从而导致通过 hashCode()计算出的哈希值可能不在数组大小范畴内,进而无奈匹配存储地位;
那怎么解决呢?
- HashMap 本人实现了本人的 hash()办法,通过两次扰动使得它本人的哈希值高下位自行进行异或运算,升高哈希碰撞概率也使得数据分布更均匀;
- 在保障数组长度为 2 的幂次方的时候,应用 hash()运算之后的值与运算(&)(数组长度 – 1)来获取数组下标的形式进行存储,这样一来是比取余操作更加有效率,二来也是因为只有当数组长度为 2 的幂次方时,h&(length-1)才等价于 h%length,三来解决了“哈希值与数组大小范畴不匹配”的问题;
1.12 HashMap 的长度为什么是 2 的幂次方
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据调配平均,每个链表 / 红黑树长度大致相同。这个实现就是把数据存到哪个链表 / 红黑树中的算法。
这个算法应该如何设计呢?
咱们首先可能会想到采纳 % 取余的操作来实现。然而,重点来了:“取余 (%) 操作中如果除数是 2 的幂次则等价于与其除数减一的与 (&) 操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方;)。”并且 采纳二进制位操作 &,绝对于 % 可能进步运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。
那为什么是两次扰动呢?
答:这样就是加大哈希值低位的随 s 机性,使得散布更平均,从而进步对应数组存储下标地位的随机性 & 平均性,最终缩小 Hash 抵触,两次就够了,曾经达到了高位低位同时参加运算的目标;
1.13 HashMap 与 HashTable 有什么区别?
- 线程平安:
HashMap
是非线程平安的,HashTable
是线程平安的;HashTable 外部的办法根本都通过 synchronized 润饰。(多线程能够应用 ConcurrentHashMap 吧!); - 效率:因为线程平安的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 根本被淘汰,不要在代码中应用它;对 Null key 和 Null value 的反对:HashMap 中,null 能够作为键,这样的键只有一个,能够有一个或多个键所对应的值为 null。然而在 HashTable 中 put 进的键值只有有一个 null,间接抛 NullPointerException。
- 初始容量大小和每次裁减容量大小的不同 :①创立时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次裁减,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次裁减,容量变为原来的 2 倍。②创立时如果给定了容量初始值,那么 Hashtable 会间接应用你给定的大小,而 HashMap 会将其裁减为 2 的幂次方大小。也就是说 HashMap 总是应用 2 的幂作为哈希表的大小。底层数据结构:JDK1.8 当前的 HashMap 在解决哈希抵触时有了较大的变动,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以缩小搜寻工夫。Hashtable 没有这样的机制。
1.14 HashMap 和 ConcurrentHashMap 的区别
- ConcurrentHashMap 对整个桶数组进行了宰割分段(Segment),而后在每一个分段上都用 lock 锁进行爱护,绝对于 HashTable 的 synchronized 锁的粒度更精密了一些,并发性能更好,而 HashMap 没有锁机制,不是线程平安的。(JDK1.8 之后 ConcurrentHashMap 启用了一种全新的形式实现, 利用 CAS 算法。)
- HashMap 的键值对容许有 null,然而 ConCurrentHashMap 都不容许。
1.15 ConcurrentHashMap 和 Hashtable 的区别?
ConcurrentHashMap 和 Hashtable 的区别次要体现在实现线程平安的形式上不同。
- 「底层数据结构」:JDK1.7 的 ConcurrentHashMap 底层采纳 分段的数组 + 链表 实现,JDK1.8 采纳的数据结构跟 HashMap1.8 的构造一样,数组 + 链表 / 红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构相似都是采纳 数组 + 链表 的模式,数组是 HashMap 的主体,链表则是次要为了解决哈希抵触而存在的;
- 「实现线程平安的形式」(重要):① 在 JDK1.7 的时候,ConcurrentHashMap(分段锁)对整个桶数组进行了宰割分段(Segment),每一把锁只锁容器其中一部分数据,多线程拜访容器里不同数据段的数据,就不会存在锁竞争,进步并发访问率。(默认调配 16 个 Segment,比 Hashtable 效率进步 16 倍。)到了 JDK1.8 的时候曾经摒弃了 Segment 的概念,而是间接用 Node 数组 + 链表 + 红黑树的数据结构来实现,并发管制应用 synchronized 和 CAS 来操作。(JDK1.6 当前 对 synchronized 锁做了很多优化)整个看起来就像是优化过且线程平安的 HashMap,尽管在 JDK1.8 中还能看到 Segment 的数据结构,然而曾经简化了属性,只是为了兼容旧版本;② Hashtable(同一把锁) : 应用 synchronized 来保障线程平安,效率十分低下。当一个线程拜访同步办法时,其余线程也拜访同步办法,可能会进入阻塞或轮询状态,如应用 put 增加元素,另一个线程不能应用 put 增加元素,也不能应用 get,竞争会越来越强烈效率越低。
「两者的比照图」:
HashTable:
JDK1.7 的 ConcurrentHashMap:
JDK1.8 的 ConcurrentHashMap(TreeBin: 红黑二叉树节点 Node: 链表节点):
答:ConcurrentHashMap 联合了 HashMap 和 HashTable 二者的劣势。HashMap 没有思考同步,HashTable 思考了同步的问题。然而 HashTable 在每次同步执行时都要锁住整个构造。ConcurrentHashMap 锁的形式是略微细粒度的。
1.16 ConcurrentHashMap 底层具体实现晓得吗?实现原理是什么?
JDK1.7
首先将数据分为一段一段的存储,而后给每一段数据配一把锁,当一个线程占用锁拜访其中一个段数据时,其余段的数据也能被其余线程拜访。
在 JDK1.7 中,ConcurrentHashMap 采纳 Segment + HashEntry 的形式进行实现,构造如下:
一个 ConcurrentHashMap 里蕴含一个 Segment 数组。Segment 的构造和 HashMap 相似,是一种数组和链表构造,一个 Segment 蕴含一个 HashEntry 数组,每个 HashEntry 是一个链表构造的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行批改时,必须首先取得对应的 Segment 的锁。
- 该类蕴含两个动态外部类 HashEntry 和 Segment;前者用来封装映射表的键值对,后者用来充当锁的角色;
- Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个 HashEntry 数组里得元素,当对 HashEntry 数组的数据进行批改时,必须首先取得对应的 Segment 锁。
JDK1.8
在 JDK1.8 中,放弃了 Segment 臃肿的设计,取而代之的是采纳 Node + CAS + Synchronized 来保障并发平安进行实现,synchronized 只锁定以后链表或红黑二叉树的首节点,这样只有 hash 不抵触,就不会产生并发,效率又晋升 N 倍。
构造如下:
1.17Array 和 ArrayList 有何区别?
- Array 能够存储根本数据类型和对象,ArrayList 只能存储对象。
- Array 是指定固定大小的,而 ArrayList 大小是主动扩大的。
- Array 内置办法没有 ArrayList 多,比方 addAll、removeAll、iteration 等办法只有 ArrayList 有。
对于根本类型数据,汇合应用主动装箱来缩小编码工作量。然而,当解决固定大小的根本数据类型的时候,这种形式绝对比较慢。
1.18 如何实现 Array 和 List 之间的转换?
- Array 转 List:Arrays. asList(array);
- List 转 Array:List 的 toArray() 办法。
1.19 comparable 和 comparator 的区别?
- comparable 接口实际上是出自 java.lang 包,它有一个 compareTo(Object obj)办法用来排序
- comparator 接口实际上是出自 java.util 包,它有一个 compare(Object obj1, Object obj2)办法用来排序
个别咱们须要对一个汇合应用自定义排序时,咱们就要重写 compareTo 办法或 compare 办法,当咱们须要对某一个汇合实现两种排序形式,比方一个 song 对象中的歌名和歌手名别离采纳一种排序办法的话,咱们能够重写 compareTo 办法和应用自制的 Comparator 办法或者以两个 Comparator 来实现歌名排序和歌星名排序,第二种代表咱们只能应用两个参数版的 Collections.sort()。
Comparator 定制排序
ArrayList<Integer> arrayList = new ArrayList<Integer>(); arrayList.add(-1);
arrayList.add(3);
arrayList.add(3);
arrayList.add(-5);
arrayList.add(7);
arrayList.add(4);
arrayList.add(-9);
arrayList.add(-7);
System.out.println("原始数组:"); System.out.println(arrayList);
// void reverse(List list): 反转 Collections.reverse(arrayList); System.out.println("Collections.reverse(arrayList):"); System.out.println(arrayList);
// void sort(List list), 按天然排序的升序排序 Collections.sort(arrayList); System.out.println("Collections.sort(arrayList):"); System.out.println(arrayList);
// 定制排序的用法
Collections.sort(arrayList, new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {return o2.compareTo(o1);
} });
System.out.println("定制排序后:"); System.out.println(arrayList);
原始数组:
[-1, 3, 3, -5, 7, 4, -9, -7]
Collections.reverse(arrayList):
[-7, -9, 4, 7, -5, 3, 3, -1]
Collections.sort(arrayList):
[-9, -7, -5, -1, 3, 3, 4, 7]
定制排序后:
[7, 4, 3, 3, -1, -5, -7, -9]
重写 compareTo 办法实现按年龄来排序
// person 对象没有实现 Comparable 接口,所以必须实现,这样才不会出错,才可 以使 treemap 中的数据按顺序排列
// 后面一个例子的 String 类曾经默认实现了 Comparable 接口,具体能够查看 String 类的 API 文档,另外其余
// 像 Integer 类等都曾经实现了 Comparable 接口,所以不须要另外实现了
public class Person implements Comparable<Person> {
private String name;
private int age;
public Person(String name, int age) {super();
this.name = name;
this.age = age;
}
public String getName() {return name;}
public void setName(String name) {this.name = name;}
public int getAge() {return age;}
public void setAge(int age) {this.age = age;}
/**
* TODO 重写 compareTo 办法实现按年龄来排序
*/
@Override
public int compareTo(Person o) {if (this.age > o.getAge()) {return 1;} else if (this.age < o.getAge()) {return -1;}
return age;
}
}
public static void main(String[] args) {TreeMap<Person, String> pdata = new TreeMap<Person, String>(); pdata.put(new Person("张三", 30), "zhangsan");
pdata.put(new Person("李四", 20), "lisi");
pdata.put(new Person("王五", 10), "wangwu");
pdata.put(new Person("小红", 5), "xiaohong");
// 失去 key 的值的同时失去 key 所对应的值
Set<Person> keys = pdata.keySet();
for (Person key : keys) {System.out.println(key.getAge() + "-" + key.getName()); }
}
5- 小红 10- 王五 20- 李四 30- 张三
1.20 Collection 和 Collections 有什么区别?
- java.util.Collection 是一个汇合接口(汇合类的一个顶级接口)。它提供了对汇合对象进行基本操作的通用接口办法。Collection 接口在 Java 类库中有很多具体的实现。Collection 接口的意义是为各种具体的汇合提供了最大化的对立操作形式,其间接继承接口有 List 与 Set。
- Collections 则是汇合类的一个工具类 / 帮忙类,其中提供了一系列静态方法,用于对汇合中元素进行排序、搜寻以及线程平安等各种操作。
1.21 TreeMap 和 TreeSet 在排序时如何比拟元素?Collections 工具类中的 sort()办法如何比拟元素?
TreeSet 要求寄存的对象所属的类必须实现 Comparable 接口,该接口提供了比拟元素的 compareTo()办法,当插入元素时会回调该办法比拟元素的大小。TreeMap 要求寄存的键值对映射的键必须实现 Comparable 接口从而依据键对元素进 行排 序。
Collections 工具类的 sort 办法有两种重载的模式,
第一种要求传入的待排序容器中寄存的对象比拟实现 Comparable 接口以实现元素的比拟;
第二种不强制性的要求容器中的元素必须可比拟,然而要求传入第二个参数,参数是 Comparator 接口的子类型(须要重写 compare 办法实现元素的比拟),相当于一个长期定义的排序规定,其实就是通过接口注入比拟元素大小的算法,也是对回调模式的利用(Java 中对函数式编程的反对)。
文章参考:
Java 8 系列之重新认识 HashMap
Java 汇合容器面试题
看到这里明天的分享就完结了,如果感觉这篇文章还不错,来个 分享、点赞、在看 三连吧,让更多的人也看到~
欢送关注集体公众号 「JavaClub」,定期为你分享一些面试干货。