Map:
HashMap:
- HashMap实际上是数组和链表的结合体。HashMap的底层结构是一个Node<>(实现了Entry<>)数组,,数组中的每一项是一条链表(短时使用链表,长时使用红黑树(JDK8以后)),链表中存的是Node,红黑树存的是TreeNode。这样便继承了数组查找快以及链表寻址修改的优点。
- HashMap的实例有俩个参数影响其性能: “初始容量” 和 装填因子。
初始容量:即Node数组的长度,默认是16,在JDK1.8之后,我们人为设置初始容量的话,会自动转成2的n次幂,例如我们设置长为13或16,会转成16,设置成100的话会转成128。这么做的原因是为了加快resize的效率(详见后)。
装填因子默认是0.75,装填因子与resize有关。
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;
}
-
HashMap线程不安全。 HashTable线程安全
-
HashMap可以接受null的key和value,而HashTable不行。
为什么HashTable不允许接受null的key和value?(对于所有并发的map而言都不能,如果map.get(key)得到了null,不能判断到底是映射的value是null,还是因为没有找到对应的key而为空,而用于单线程状态的hashmap却可以用contains(key) 去判断到底是否包含了这个null。) -
hash 冲突解决的方法:
①开放定址法:就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。方法1:+1+1的找;方法2:1,-1,2平方, - 2的平方,…
②拉链法:
③再哈希法:同时构造多个不同的哈希函数,当一个冲突时使用下一个,直到不冲突为止。 -
HashMap的工作原理:
get()具体原理:
put的具体原理:(最好便说便写)
a)对 Key 求 Hash 值,找到数组中对应的位置。
b)如果对应bucket为空,直接放入;如果不为空,使用equals()判断key是否存在,如果存在,替换旧值,如果不存在,存入bucket中。
c)如果链表长度超过阀值(TREEIFY THRESHOLD==8),如果桶长度小于64:resize,否则将链表转成红黑树,链表长度低于6,就把红黑树转回链表
d)如果桶满了(容量16 * 加载因子0.75),就需要 resize(扩容2倍后重排) -
map的遍历方式:
a) 使用Map.Entry<X,X> ( 两个都需要时用,最常见):
Map<Integer, Integer> map = new HashMap<Integer, Integer>();
for (Map.Entry<Integer, Integer> entry : map.entrySet())
System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue());
b) 在for-each循环中遍历keySet或values(只需要其中一个时用):
for (Integer key : map.keySet()) {
System.out.println("Key = " + key);
}
for (Integer value : map.values()) {
System.out.println("Value = " + value);
}
c) 使用Iterator遍历(边遍历边删除时用):
Iterator iter= map.entrySet().iterator();
while (iter.hasNext()) {
iter.remove();
}
- 能否使用任何类作为 Map 的 key? 能否使用任何类作为 Map 的 key?
可以使用任何类作为 Map 的 key,然而在使用之前,需要考虑以下几点:
- 如果类重写了 equals() 方法,也应该重写 hashCode() 方法。
- 用户自定义 Key 类最好是不可变类。
原因在于:如果有一个类 MyKey,在 HashMap 中使用它:
HashMap<MyKey, String> myHashMap = new HashMap<MyKey, String>();
//传递给 MyKey 的 name 参数被用于 equals() 和 hashCode() 中
MyKey key = new MyKey("Pankaj"); // 假设 hashCode=1234
myHashMap.put(key, "Value");
// 以下的代码会改变 key 的 hashCode() 和 equals() 值
key.setName("Amit"); // 假设新的 hashCode=7890
//下面会返回 null,因为 HashMap 会尝试查找存储同样索引的 key,而 key 已被改变了,匹配失败,返回 null
System.out.println(myHashMap.get(new MyKey("Pankaj")));
-
为什么重写了equals()方法,一定要重写hashCode()方法?
首先,我们要确认一点,如果两个对象相同(equals方法为true),那么他们的hashCode也是相同的,如果hashCode不同的话,就可以往set放入两个相同的对象了。
因为不重写的话,就会用Object.hashCode()方法,而Object.hashCode返回的是地址本身。
就可能出现重写了equals后使得两个对象的equals为true,但hashcode不相同的情况。 -
有什么方法可以减少碰撞?有什么方法可以减少碰撞?
a)使用扰动函数。
b)使用不可变、final 的对象 -
为什么 String、Integer 这样的 wrapper 类适合作为键?
因为 String,Integer 是不可变类。 -
hash函数的实现:(前16位和后16位亦或,然后与桶长度-1进行&运算)
(如果hashCode之后直接&(n-1),很容易产生碰撞。因此java设计者权衡了speed, utility, and quality,决定将高16位与低16位异或来减少这种影响)
h = key.hashCode();返回散列值也就是hashcode
// >>>:无符号右移,忽略符号位,空位都以0补齐
//其中n是数组的长度,即Map的数组部分初始化长度
//hash结果为:h和它的后16位亦或
//然后再求index:hash &(桶长度-1)
return (n-1)&(h ^ (h >>> 16));
- 链表过深时,为什么不用二叉查找树代替而选择红黑树?为什么不一直使用红黑树?
之所以选择红黑树是为了解决二叉查找树的缺陷:二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成层次很深的问题),遍历查找会非常慢。
而之所以不一直使用红黑树,是因为红黑树属于平衡二叉树,为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少。如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。 - 说说你对红黑树的见解?
a)每个节点非红即黑
b)根节点总是黑色的
c)如果节点是红色的,则它的子节点必须是黑色的(反之不一定)
d)每个叶子节点都是黑色的空节点(NIL节点)
e)从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)
f)红黑树能够以O(log2(N))的时间复杂度进行增删查操作。
g)红黑树不像 avl 树(平衡树)一样追求绝对的平衡,他允许局部很少的不完全平衡,这样对于效率影响不大,但省去了很多没有必要的调平衡操作。
- 如果 HashMap 的大小超过了负载因子(load factor)定义的容量怎么办?如果 HashMap 的大小超过了负载因子(load factor)定义的容量怎么办?
HashMap 默认的负载因子大小为0.75。也就是说,当一个 Map 填满了75%的 Node 时候,和其它集合类一样(如 ArrayList 等),将会创建原来大小的两倍的 bucket 数组。
然后会将原数组中的数据必须重新计算其在新数组中的位置,并放进去(此过程最消耗性能)。
resize的原理以及多线程会造成的死链:
https://www.cnblogs.com/wang-meng/p/7582532.html
注: 在JDK1.8之后,resize不会倒序插入,同时计算新位置时有优化:
//如果这个oldTab[j]就一个元素,那么就直接放到newTab里面
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if ...
else
{
/*这里的操作就是 (e.hash & oldCap) == 0 这一句,
这一句如果是true,表明(e.hash & (newCap - 1))还会和
e.hash & (oldCap - 1)一样。因为oldCap和newCap是2的次幂,
并且newCap是oldCap的两倍,就相当于oldCap的唯一
一个二进制的1向高位移动了一位
(e.hash & oldCap) == 0就代表了(e.hash & (newCap - 1))还会和
e.hash & (oldCap - 1)一样。
比如原来容量是16,那么就相当于e.hash & 0x1111
(0x1111就是oldCap - 1 = 16 - 1 = 15),
现在容量扩大了一倍,就是32,那么rehash定位就等于
e.hash & 0x11111 (0x11111就是newCap - 1 = 32 - 1 = 31)
现在(e.hash & oldCap) == 0就表明了
e.hash & 0x10000 == 0,这样的话,不就是
已知: e.hash & 0x1111 = hash定位值Value
并且 e.hash & 0x10000 = 0
那么 e.hash & 0x11111 不也就是
原来的hash定位值Value吗?
*/
//初始化两个链表,低位为原本位置oldCap,高位在j + oldCap
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
//加入低位链表
}
else {
//加入高位链表
}
//两个链表的头放入数组对应位置
...
HashTable:
- 数组 + 链表方式存储,默认容量:11(质数为宜)
- put操作:首先进行索引计算 (key.hashCode() & 0x7FFFFFFF)% table.length;若在链表中找到了,则替换旧值,若未找到则继续;当总元素个数超过 容量 * 加载因子 时,扩容为old*2+1;将新元素加到链表头部。
- 对修改 Hashtable 内部共享数据的方法添加了 synchronized,保证线程安全
- HashMap 与 HashTable 区别:
- 默认容量不同,扩容不同(HashMap为old2,HashTable为old2+1)
- 线程安全性:HashTable 安全
- 效率不同:HashTable 要慢,因为加锁
- HashMap可以接受null的key和value,而HashTable不行。 - 可以使用 CocurrentHashMap 来代替 Hashtable 吗?
ConcurrentHashMap 当然可以代替 HashTable,但是 HashTable 提供更强的线程安全性
CocurrentHashMap(JDK 1.7):
- CocurrentHashMap 是由 Segment 数组和 HashEntry 数组和链表组成。
- Segment:一个ReentrantLock,也就是说segment扮演者锁的角色。
- 核心数据如 value,以及链表都是 volatile 修饰的,保证了获取时的可见性
- 虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理
- ConcurrentHashMap会对数据hash后 对摘要值进行二次hash,其目的是减少hash冲突,使元素均匀分布。
ConcurrentHashMap(JDK 1.8) (读不加锁、写加锁)
- JDK1.8 的实现已经摒弃了Segment的概念,恢复成HashMap的结构(bucket数组+链表+红黑树),并发控制使用 Synchronized 和 CAS 来操作,整个看起来就像是优化过且线程安全的HashMap.
- JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry。
- JDK1.8使用内置锁synchronized来代替重入锁ReentrantLock
- 因为JDK1.8中,hashMap引入了红黑树,所以cocurrentHashMap也同样使用了红黑树。
- CocurrentHashMap(1.8)中get操作为什么不加锁?
get操作可以无锁是由于Node的元素val和指针next是用volatile修饰的,这样可保证多线程对node的可见性。 - CocurrentHashMap(1.8)中put操作为什么加锁?
正因为node中元素是用volatile修饰,并不能保证原子性,所以写操作需要加锁来保证原子性。
为什么使用ConcurrentHashMap而不使用hashtable?
它们都可以用于多线程的环境,但是当Hashtable的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间。而ConcurrentHashMap的锁粒度是HashEntry,即便数组长度很长,也能保持比较好的性能。
源码解析:
①table初始化:
table初始化操作会延缓到第一次put行为。但是put是可以并发执行的,所以初始化还是要考虑并发。
sizeCtl默认为0,非默认sizeCtl会是一个2的幂次方的值(详见hashMap)。
具体并发控制操作请看代码注释:
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
//如果一个线程发现sizeCtl<0,意味着另外的线程执行CAS操作成功,当前线程只需要让出cpu时间片
if ((sc = sizeCtl) < 0)
Thread.yield();
//尝试用CAS将sizeCtl设置成-1,成功的话说明由该线程来进行初始化
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
//设置成功,进行初始化
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
put操作:
put操作采用CAS+synchronize来控制
其中table中某一格为空,也就是第一次插入时采用CAS,而之后链表或者红黑树的插入就采用synchronize
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//关键在这一块!CAS操作的条件!
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
//下面使用synchronize来插入链表或红黑树(代码略)
}
addCount(1L, binCount);
return null;
}
LinkedHashMap(HashMap + 双向链表)
双向链表中存放着Entry<>。
LinkedHashMap实现双向链表的核心在于重新定义了Entry<>,里面新添了after和before
private static class Entry<K,V> extends HashMap.Entry<K,V> {
// These fields comprise the doubly linked list used for iteration.
Entry<K,V> before, after;
Entry(int hash, K key, V value, HashMap.Entry<K,V> next) {
super(hash, key, value, next);
}
...
}
注:LinkedHashMap可以选择是插入顺序(不改变顺序)或者是访问顺序(改变顺序,可用于LRUCache)。默认是插入,如果想使用访问排序,方法:new LinkedHashMap(10, 0.75, true);
前两个参数是HashMap的参数,最后的true表明使用访问排序。
TreeMap
TreeMap,其实就是一个红黑树,树的每一个节点都是一个Entry<>, 内部声明了一个Comparator的局部变量。
public class TreeMap<K,V> extends ...
{
private final Comparator<? super K> comparator;
private transient Entry<K,V> root;
}
put操作会使用比较器进行比较,小于放左边,大于放右边。
public V put(K key, V value) {
//前面略
Comparator<? super K> cpr = comparator;
if (cpr != null) {
do {
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
//后面略
}
List
ArrayList(动态数组)
- 查找O(1),但插入与删除耗时。
- 初始化:
(1)ArrayList()构造一个初始容量为 10 的空数组,不够可以扩充。
(2)ArrayList(int initialCapacity)构造一个具有指定初始容量的空数组。 - 当元素超出数组内容,会产生一个新数组(容量是原来的1.5倍),然后使用Arrays.copyOf()将元素放入新数组中.
- 非线程安全
- 多线程场景下如何使用 ArrayList?
最常用的方法是通过 Collections 的 synchronizedList 方法将 ArrayList 转换成线程安全的容器后再使用。
List<Object> list =Collections.synchronizedList(new ArrayList<Object>);
- 为什么 ArrayList 的 elementData 加上 transient 修饰?
ArrayList 中的数组定义如下:
private transient Object[] elementData;
transient 的作用是说不希望 elementData 数组被序列化。
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException{
s.defaultWriteObject();
for (int i=0; i<size; i++)
s.writeObject(elementData[i]);
}
每次序列化时,先调用 defaultWriteObject() 方法序列化 ArrayList 中的非 transient 元素,然后遍历 elementData,只序列化已存入的元素,这样既加快了序列化的速度,又减小了序列化之后的文件大小。
- 遍历一个 List 有哪些不同的方式?每种方法的实现原理是什么?Java 中 List 遍历的最佳实践是什么?
a)for 循环遍历。
b)迭代器遍历,Iterator。
c)foreach 循环遍历。(不能在遍历过程中操作数据集合)
注:如果一个数据集合实现了Random Access接口,例如ArrayList,那么按位置读取元素的平均时间复杂度为 O(1),建议使用for 循环遍历。否则建议使用Iterator 或 foreach 遍历。
LinkedList(链表)
查找O(n),插入和删除O(i)
阻塞队列:(详见并发编程.md)
Vector
- 与 ArrayList 类型一样,内部也是使用数组来存储对象,但是线程安全的。
- 不建议使用(现在也基本不使用),因为性能消耗。
Set:
- Set集合的实现依赖于Map的实现,通过Map的 key值唯一性来实现。
- 补充:list和set都可以存放null。
fail-fast机制(了解)
- fail-fast 是Java集合的一种错误检测机制,当多个线程对集合进行结构上的改变的时,有可能会产生fail-fast机制。
- fail-fast产生的原因就在于程序在对 collection 进行迭代时,某个线程对该 collection 在结构上对其做了修改,这时迭代器就会抛出 ConcurrentModificationException 异常信息,从而产生 fail-fast
补充:
- 如何边遍历边移除 Collection 中的元素?
边遍历边修改 Collection 的唯一正确方式是使用 Iterator.remove() 方法,如下:
Iterator<Integer> it = list.iterator();
while(it.hasNext()){
// do something
it.remove();
}