==和equals的区别
- == 用于判断两个对象引用的内存地址是否相等,如果用在基本类型判断两个值是否相等。
- equals 如果没有重写,默认比较的还是内存地址,可以通过重写实现比较两个对象的内容。
equals()的重写规则
- 自反性 x.equals(x)应返回true
- 对称性 当且仅当:y.equals(x)返回true时,x.equals(y)才返回true。
- 传递性 如果y.equals(x)返回true,y.equals(z)返回true,那么x.equals(z)也应返回true。
hashcode的理解
- 默认情况下一个对象的hashcode就是内存地址通过hash函数得到的哈希值。
- 一般自定义的java类需要对equals与hashcode进行重写。重写后的两个对象hashcode相等equals不一定相等,equals相等则hashcode一定相等。
equals与hashcode只重写一个可以吗
- 如果重写了equals而没有重写hashcode,则会出现两个对象hashcode不相等而equals相等的情况,这样在hashmap中两个内容相同的对象可以会被认为是两个不相同的key。
- 如果重写了hashcode而没有重写equals,则会出现两个对象hashcode相同而equals不同的情况,还是会出现在hashmap中两个内容相同的对象可以会被认为是两个不相同的key的情况。
hashmap中如何判断两个key是否相等
- 存放在hashmap中的对象key都需要重写hashcode和equals方法。
- 在比较时首先比较hashcode,如果hashcode不相等,那么两个key肯定不相等。
- 如果hashcode相等,不能确定内容是否相等,需要使用equals进一步判断是否相等。
- 综合hashcode与equals可以加快比较速度。
hashmap构建过程
- 假设现在有一组键值对,需要根据这些信息构建hashmap。对于每个key都要计算它的hashcode,然后根据hashcode进行一系列运算得到一个数组下标值index如下所示。
("aaa","111") hashcode=***** index=0
("bbb","222") hashcode=***** index=1
("ccc","333") hashcode=***** index=3
("ddd","444") hashcode=***** index=0
("eee","555") hashcode=***** index=0
("fff","666") hashcode=***** index=1
("ggg","777") hashcode=***** index=1
- 对于第一个元素它对于的下标值为0,数组下标0为空将它直接插入到下标0的位置。
- 第二个元素下标值为1,直接插入到下标1的位置。
- 第三个元素,直接插入到下标3的位置。
- 第四个元素下标值为0,此时数组下标0已经有元素,将它作为链表新节点插入到aaa的后面。
- 第五个元素下标为0,继续作为链表新节点插入到ddd后面。
- 第六第七元素同理,插入到链表1的结尾。此时整个哈希表结构如下:
HashMap中put方法的大概逻辑
- 首先会根据当前key的hashcode,然后hashcode与hashcode的高16位做异或运算(使得hash值更加分散)计算一个hash值
- 然后根据hash值与(n-1)按位相与得到一个桶的索引值i。
- 会把新元素放在哈希桶i的位置,如果i位置已经有元素,需要遍历位置i对应的链表,将新节点与链表节点逐一比较,如果有重复,覆盖原有节点,如果没有重复,将新节点添加到链表末尾。
- 另外在put方法中,如果发现哈希表元素数量超过指定阈值,需要触发扩容机制。如果链表元素数量达到8,需要转换为红黑树。
- 如下为put方法的源码
//put主方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//根据key计算一个哈希值
static final int hash(Object key) {
if(key==null)
return 0;
int h=key.hashCode();
//h与h右移16位做异或 这样的目的是使hash值尽可能分散
return h ^ (h >>> 16);
}
//根据key value 和计算出的hash值 执行具体更新方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
// 如果map还是空的,则先开始初始化resize(),table是map中用于存放索引的表
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//发现tab[i] 没有值,直接存入即可
//这里的下标位置i由(n - 1) & hash计算得到,n为map中数组的长度
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 如果走到else这一步,说明key索引到的数组位置上已经存在内容,即出现了碰撞
// 这个时候需要更为复杂处理碰撞的方式来处理,如链表和树
else {
Node<K,V> e; K k;
// 其中p已经在上面通过计算索引找到了,即发生碰撞那一个节点
// 比较,如果该节点的hash和当前的hash相等,而且key也相等则说明两个key是
// 一样的,则将当前节点p用临时节点e保存
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果当前节点p是(红黑)树类型的节点,则说明碰撞已经开始用树来处理
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);
// TREEIFY_THRESHOLD = 8
// 从0开始的,如果到了7则说明满8了,这个时候就需要转
// 重新确定是否是扩容还是转用红黑树了
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 找到了碰撞节点中,key完全相等的节点,则用新节点替换老节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 此时的e是保存的被碰撞的那个节点,即老节点
if (e != null) { // existing mapping for key
V oldValue = e.value;
// onlyIfAbsent是方法的调用参数,表示是否替换已存在的值,
// 在默认的put方法中这个值是false,所以这里会用新值替换旧值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// map变更性操作计数器
// 比如map结构化的变更像内容增减或者rehash,这将直接导致外部map的并发
// 迭代引起fail-fast问题,该值就是比较的基础
++modCount;
// size即map中包括k-v数量的多少
// 当map中的内容大小已经触及到扩容阈值时,则需要扩容了
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
HashMap中get方法的逻辑
- 首先根据key,将key的hashcode与hashcode的高16位进行按位与得到一个hash值。
- 然后根据(n - 1) & hash计算一个哈希桶的索引值i,然后哈希桶i处对应的链表,依次比较各个节点的key进行查询,返回对应节点的value值。
- 需要注意链表可能已经树化,如果是红黑树,就要遍历红黑树进行比较。
- get方法源码如下
//get主方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
//根据key计算一个哈希值
static final int hash(Object key) {
if(key==null)
return 0;
int h=key.hashCode();
//h与h右移16位做异或 这样的目的是使hash值尽可能分散
return h ^ (h >>> 16);
}
//get方法的主逻辑 传入key和key计算出的hash值进行查询
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab;
Node<K,V> first, e;
int n; K k;
//如果哈希表为空 哈希表长度为0 (n - 1) & hash处元素为空 则直接返回空
//否则开始查找逻辑
if ((tab = table) != null &&
(n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//如果当前key与i处节点key相等
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//如果是红黑树开始红黑树的查找逻辑
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//如果是链表就依次比较各个节点 比较的时候先比较hashcode在通过equals进行比较。
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
HashMap的扩容机制
- 当hashmap的元素数量超过数组大小乘上加载因子时,就会进行数组扩容,加载因子的默认值为0.75
首先会判断当前map的元素数量是否大于最大限制的容量,如果大于则不能进行扩容了,设置临界值为int的最大值。 - 否则可以进行扩容,以2倍的方式进行扩容,先创建一个2倍大小的新数组,然后将旧数组的所有元素依次拷贝到新数组,并重新计算每个元素在新数组的索引位置,最后修改临界值。
- 以下为jdk1.8的扩容代码
//map的扩容方法
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; //将原索引表记录为oldTab
int oldCap = (oldTab == null) ? 0 : oldTab.length; //记录原索引表的长度
int oldThr = threshold; //记录原map的临界容量
int newCap, newThr = 0; //新表的索引表长度和新表的临界容量
//以前的容量大于0
if (oldCap > 0) {
//如果以前的容量大于限制的最大容量2^30,则设置临界值为int的最大值2^31-1这样以后就不会扩容了
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果以前容量的2倍小于限制的最大容量,同时大于或等于默认的容量16,
//设置临界值为以前临界值的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
// 此处相当于将上一次的初始容量赋值给了新的容量。什么情况下会执行到这句?当调用
// HashMap(int initialCapacity)构造器,还没有添加元素时
else if (oldThr > 0)
newCap = oldThr;
// 调用了默认构造器,初始容量没有设置,因此使用默认容量DEFAULT_INITIAL_CAPACITY(16),
// 临界值就是16*0.75
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//对临界值做判断,确保其不为0,因为在上面第二种情况(oldThr > 0),并没有计算newThr
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//构造新表,初始化表中数据
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//遍历将原来table中的数据放到扩容后的新表中来
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
//没有链表Node节点,直接放到新的table中下标为【e.hash & (newCap - 1)】位置即可
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//如果是treeNode节点,则树上的节点放到newTab中
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//如果e后面还有链表节点,则遍历e所在的链表,
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
/**
* newTab的容量是以前旧表容量的两倍,因为数组table下标并不是根据循环逐步递增
* 的,而是通过(table.length-1)& hash计算得到,因此扩容后,存放的位置就
* 可能发生变化,那么到底发生怎样的变化呢,就是由下面的算法得到.
*
* 通过e.hash & oldCap来判断节点位置通过再次hash算法后,是否会发生改变,如
* 果为0表示不会发生改变,如果为1表示会发生改变。到底怎么理解呢,举个例子:
* e.hash = 13 二进制:0000 1101
* oldCap = 32 二进制:0001 0000
* &运算: 0 二进制:0000 0000
* 结论:元素位置在扩容后不会发生改变
*/
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
/**
* e.hash = 18 二进制:0001 0010
* oldCap = 32 二进制:0001 0000
* &运算: 32 二进制:0001 0000
* 结论:元素位置在扩容后会发生改变,那么如何改变呢?
* newCap = 64 二进制:0010 0000
* 通过(newCap-1)&hash
* 即0001 1111 & 0001 0010 得0001 0010,32+2 = 34
*/
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
/**
* 若(e.hash & oldCap) == 0,下标不变,将原表某个下标的元素放到扩容表同样
* 下标的位置上
*/
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
/**
* 若(e.hash & oldCap) != 0,将原表某个下标的元素放到扩容表中
* [下标+增加的扩容量]的位置上
*/
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
jdk1.8后对HashMap的改进
- 1 jdk1.7使用数组加链表的结构进行存储,在jdk1.8中如果链表长度大于8就会改用红黑树进行存储,加快查询速率。
- 2 以前hashmap在扩容时,复制链表时采用头插法,在多线程环境下如果两个线程同时用头插法操作链表就会出现环形链表的情况,后续get元素就会出现死循环,而1.8以后改用尾插法的方式复制链表,避免了环形链表出现,但是hashmap依然是线程不安全的。
- 3 jdk1.7扩容时要将旧表的所有数据重新进行hash计算,而1.8中扩容时只需将hashCode和老数组长度做与运算判断是0还是1,是0的话索引不变,是1的话索引变为老索引位置+老数组长度,不需要重新计算hash索引。
- 假设旧数组长度length1 ,新数组长度length2,如果当前哈希值hash与length1取余结果为0,则新数组索引与旧数组索引相同,如果不为0则新数组索引=旧数组索引+length1
比如:旧数组长度 length1 = 16,新数组长度 length2 = 32
假设 当前key的hash = 25 则
length1 = 0000 0000 0000 0000 0000 0000 0001 0000 (16)
length2 = 0000 0000 0000 0000 0000 0000 0010 0000 (32)
hash = 0000 0000 0000 0000 0000 0000 0001 1001 (25)
hash & length1 != 0
旧的索引值 = hash & (length1-1) = 9
新的索引值 = hash & (length2-1) = 25 = 9 + length1
假如当前key的hash = 33 则
length1 = 0000 0000 0000 0000 0000 0000 0001 0000 (16)
length2 = 0000 0000 0000 0000 0000 0000 0010 0000 (32)
hash = 0000 0000 0000 0000 0000 0000 0010 0001 (33)
hash & length1 == 0
旧的索引值 = hash & (length1-1) = 1
新的索引值 = hash & (length2-1) = 1 = length1
为什么Hashmap中hash算法要将hashcode与hashcode高16位异或
- 通过hash算法将hashcode 与 hashcode的低16位做异或运算,混合了高位和低位得出的最终hash值,可以减小发生冲突的概率使得hash值分布更加随机。
- 因为在大多数情况下我们只使用到了低16位的值,高16位都是0,而新计算的hash值混合了高位和低位的信息,掺杂的元素多了,那么最终hash值的随机性越大。
为什么hashmap中get和put方法的平均时间复杂度为1
- get和put操作最浪费时间的地方在于对链表的遍历。而我们以前学过遍历链表的时间复杂度是n。
- 但是当map中元素数量为n,而hash函数保证了冲突的发生是小概率事件,所以链表的长度与n相比可以理解为常数级别。所以可以理解为链表的遍历的时间复杂度是常数级别。
况且链表还可以转化为红黑树进一步降低了时间复杂度。
为什么Hashmap中求hash值的索引下标要使用hash & (length-1)
- 按理来说要让hash值在数组中的下标尽可能分散,可以将hash对数组的长度length取余。
- 但是取余运算的效率比较低,所以使用位运算hash & (length-1)来代替,它们的效果是一样的。
Hashmap的大小为什么是2的幂次
- 这个与数组索引i的计算方式有关,我们知道 i = hash%(length-1)
- 选择length为2的幂次是为了让数据分布更加均匀,减少碰撞的几率。
- 假如数组长度为15,那么所有hash值都要与14即1110相与,最终的结果最后一位一直为0即最终计算的下标都是偶数,这样奇数的下标就没有利用到造成浪费。
hashmap的初始容量为什么设置为16
- 首先解释为什么是2的幂次
- 初始容量设置越大,发生扩容可能性越小,但是内存占用越多,初始容量设置越小,发生扩容可能性越大,但是内存占用越少。所以选择16而不是8或者32是在性能和空间开销上的最佳平衡。
hashmap树化阈值多少,为什么?
- 在hashmap中当链表节点数量大于8会转化为红黑树,当红黑树节点数量小于6会退化为链表。我们知道链表的查找时间复杂度为n,红黑树查找的时间复杂度为log n,所以当n很小时链表查询就很快,而当n达到一定阈值就会转化为红黑树提高查询性能。但是为什么阈值是8呢。
- 这应该是红黑树和链表在性能上权衡的结果,当元素数量比较多时,红黑树的性能优于链表,但是当元素数量比较少只有几个的时候,红黑树的整体性能可能不如链表,因为红黑树还要涉及到平衡性调整等问题,在节点数量少的情况下这些开销是不值当的。
- 为什么退化阈值是6而不是8,主要是为了避免hashmap内部在链表和红黑树之间频繁切换。如果退化阈值是8,当链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
讲讲ConcurrentHashMap与Hashtable的区别
- Hashtable的任何操作都会把整个表锁住,是阻塞的。好处是总能获取最实时的更新,比如说线程A调用putAll写入大量数据,期间线程B调用get,线程B就会被阻塞,直到线程A完成putAll,因此线程B肯定能获取到线程A写入的完整数据。坏处是所有调用都要排队,效率较低。
- ConcurrentHashMap采用的是分段锁机制,在jdk1.8中会给索引数组的每个索引都加一把锁,在更新时会锁住当前索引的数据而不会影响其他索引数据的更新。同步读取操作则是完全非阻塞的。好处是在保证合理的同步前提下,效率很高。坏处是严格来说读取操作不能保证反映最近的更新。例如线程A调用putAll写入大量数据,期间线程B调用get,则只能get到目前为止已经顺利插入的部分数据。