jdk1.7的Hashmap是数组+链表。
jdk1.8的Hashmap是数组+链表+红黑树。
一个值要存储到Hashmap中,会根据key的值计算出它的hash值,通过hash值确定存放到数组中的位置。如果发生hash冲突就会在数组中维护一个链表的形式存储冲突的值,链表过长时,就会把链表转化为红黑树去存储。
Hashmap 继承自AbstractMap,实现了Map,Cloneable,Serializable接口。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 定义初始容量值,这里也就是16
static final int MAXIMUM_CAPACITY = 1 << 30; // hashmap的最大容量值
transient int size;// 元素个数,注意transient不会被序列化和反序列化,序列化操作属于三种拷贝中的深拷贝
int threshold;//容量阈值,元素超过这个值就会去扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 负载因子
static final int MIN_TREEIFY_CAPACITY = 64; // 树化的最小容量值条件
transient Node<K,V>[] table; // hash数组,也是一个散列表,再resize()中初始化的
主要要知道默认的初始容量,负载因子,hash数组(桶),容量阈值。
我们使用hashmap最主要的还是这个table数组,它存放的是Node 对象,而Node是Hashmap的一个内部类,可以用来表示一个key-value. table数组也就是我们常说的桶数组或者散列表。
具体看table的构造:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
table数组的总结:
这里 table 数组的长度永远为2 的幂次方。
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;
}
为什么长度永远为2 的幂次方?
其实就一个思想,就是Hashmap在构造方法中使用这个tableSizeFor方法去设置容量阈值。值得注意的是:这个方法会为扩容第一次初始化table数组时将容量阈值(threshold)设置数组长度。
优点:当数组的长度为2 的幂次方时,可以使用位运算计算数组下标,增加了hash的随机性,减少哈希冲突,使用位运算也可以提高运算效率。
Hashmap每次扩容都是会建立一个新的数组,长度和容量阈值都会变为原来的两倍,然后把原数组的元素重新映射到新数组上,和ArrayList相似。
具体步骤:
7,8对比:
1.7中会重新一个个的计算元素的hash值。
1.8中就是通过hash&oldcap的值去判断,如果为0 则索引位置不变,如果不为0则按照新索引=原索引+旧数据的长度。
这里涉及到两个红黑树的知识点:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//高位数据与低位数据进行异或,增加hash值的随机性
}
这是我们学习Hashmap的一个关键的操作!其中的步骤和源码分析尤为关键!
具体步骤:
7,8对比:
1.7 是插入链表头部的,因为考虑新插入的元素可能会用的比较多,所以会放到头部减少用到的时候的遍历。
1.8 是插入链表尾部的,因为头插法在多线程环境下可能导致两个节点互相引用,形成死循环,为了安全,还是使用尾插法。
相对来说删除操作还是比较简单的。
遍历也是一个经常被问到的高频面试题,因为可能因为你的一个不小心,就给自己挖了一个大坑。
首先说一下遍历的顺序问题,再说安全问题,hashmap是从桶(table数组)中找到包含链表节点引用的桶,然后对这个桶指向的链表进行遍历。遍历完成后,再继续找下一个包含链表节点引用的桶,找到就继续遍历。找不到,就结束遍历。
对于Hashmap来说,使用for-each遍历时,很可能因为一个modCount的变量抛出异常,也就是常见的fail-fast机制。
所以我们可以使用迭代器,迭代器自带remove方法,会将modCount复制给expectedModCount,这样就不会出现异常。但是我们的hashmap是线程不安全的,那么有个Hashtable可以解决线程安全问题,Hashtable底层是数组+链表,继承自Dictionary,是线程安全的,默认初始化是11,扩容方式是原始容量的2倍+1,底层数组也不会要求容量为2 的整数次幂。虽然它是线程安全的,但我们现在也不推荐使用,因为它跟Vector一样,所有方法都是加上锁的,虽然保证了线程的安全,但是效率低下。所以我们推荐使用java 1.5就推出的线程安全集合类 java.util.concurrent,比如CopyOnWriteArrayList,CopyOnWriteArraySet和ConCurrentHashMap,那么我们针对Hashmap的线程安全就可以选择使用ConCurrentHashMap。
ConCurrentHashMap其实底层和HashMap一样,但是不同的是它使用的是部分加锁和CAS算法实现同步的。其中查询(get)元素不用加锁,通过重写Node类,volatile修饰next实现每次获取的都是最新的值。
因为要支持高并发操作,所以ConCurrentHashMap也是由一个个Segment组成的,默认初始也就是16个Segment,Segment代表的就是一段或一部分的意思,我们也把这个称之为分段锁。所以我们可以这么理解,ConCurrentHashMap就是一个Segment数组,Segment通过继承ReenTranLock进行加锁,那么每次加锁的操作锁住的就是一个Segment,这样就只要保证每个Segment是线程安全的,就ok了。
那么对于线程集合类,使用迭代器反而会抛出ConcurrentModificationException的异常,因为迭代器的fail-fast机制。
CAS也被成为乐观锁的一种,CAS就是Compare and Swap:比较与交换。是一种无锁算法。CAS有三个操作数:内存值V,旧的预期值A,要修改的新值B。那么当预期值A和内存值V相同时,就会把V改成新值B,否则什么都不会做,多线程使用CAS更新变量时,只有一个线程能去更新变量的值,其他线程都会失败,但并不会被挂起,而是告诉它们竞争失败,可以再去尝试的。这也就是比较与交换算法。
面对更专业的面试官,你可能会被问到CAS的底层核心是什么?
其实我们天天挂在嘴边的CAS算法就是concurrent线程集合类下的Atomic包的类中的类,它们基本使用的是Unsafe的包装类。
原理就是:Atomic包的类的实现基本都是调用Unsafe的方法,Unsafe 的底层实际上就是调用C代码,C又去调汇编,最后生成一条CPU指令cmpxchg,完成操作。这也能解释为什么我们这个Atomic是原子性的,因为最后就是一条CPU指令,是不会被打断的,所以就是原子性的了。
使用CAS也需要注意的ABA问题:
案例说明ABA:假设有一个变量count=10 ,现在有三个线程A,B,C。当A,C同时读到count时,A,C的内存值和预期值都是10,这时A用CAS把count改成了20,改完后的同时,B进来读到count值为100,B把count改成10。C拿到执行权后,它发现内存值是10 ,预期也是10 ,C把count改成30。这样看似好像没什么问题,但是C是始终不知道A,B改过的count值的,这样是有一定风险存在的。
解决ABA:可以用AtomicStampedReference和AtomicMarkableReference类。这两个类是干啥的呢?说出来大家其实都知道,其实就是给对象提供一个版本,当前版本如果被改了就会自动更新,像不像MVCC机制?
其原理就是:维护了一个Pair对象,这个Pair对象存储了我们的对象引用和一个stamp值,每次CAS比较的也就是两个Pair对象。
对于Atomic的一些实际应用和迭代器及它的fail-fast机制等等我也会在以后的文章中总结出,这里就不发散太多知识了,觉得这篇文章对你有帮助的话可以给博主点个关注或者赞!后期大量干货免费分享!二叉树和红黑树等等系列过几天有空就写,涉及知识点太多总结起来需要很多时间。望体谅!