HashMap是Java API中一个非常强大的工具类,所以有必要分析一下它的源码;强调一下,这篇博文是针对熟悉HashMap基本使用的程序员的。
在jdk1.7中,HashMap的基本数据结构是数组+链表的形式。
米) {}}
//默认数组容量,使用位操作来提高速度,16转换成二进制码比1 << 4慢
静态最终 int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//数组的最大容量
静态最终 int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子
静态最终浮点DEFAULT_LOAD_FACTOR = 0.75f;
// 空数组,初始化数组时使用
静态最终条目,?>[] EMPTY_TABLE = {};
// 这个表数组用来存储Entry对象,这是HashMap保存数据最关键的一步。
//transient关键字的作用是:不参与对象的序列化
瞬态条目[]表=(条目[])EMPTY_TABLE;
//表中存在的的个数(数组+链表)
瞬态整型大小;
//数组扩容的临界值:threshold=loadFactor*capacity
int 阈值;
// 负载系数
最终浮动负载因子;
// 容器的修改次数:添加新数据(并不意味着修改值)、删除数据或清除数据时,使用modCount++
// 这个数据和ConcurrentModificationException相关,后面会解释。
瞬态 int modCount;
//映射容量默认阈值。当高于默认值时,哈希度会降低,需要选择新的哈希表。静态最终 int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
public HashMap(int initialCapacity, float loadFactor) {//容量不能小于0if (initialCapacity < 0)throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);// 容量不能大于最大值 1 << 30if (initialCapacity > MAXIMUM_CAPACITY)initialCapacity = MAXIMUM_CAPACITY;//负载因子不能小于等于0或非数值if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("非法负载因子: " +loadFactor);this.loadFactor = loadFactor;// 临界值临时分配给指定容量,并将后面重新计算的threshold=initialCapacity;// LinkedHashMap重写了,init()并没有真正在HashMap中实现;
}
// 使用默认负载因子
公共HashMap(int初始容量){这个(初始容量,DEFAULT_LOAD_FACTOR);
}
// 使用默认容量和负载因子
公共 HashMap() {this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
//public HashMap(Map extends K, ? extends V> m) {//判断m的Capacity是否小于默认容量。如果小于,则选择默认容量 this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1, DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);// 创建一个 Entry[] 数组,具体内容将在稍后详述 inflateTable(threshold);// 遍历所有键值对并将其添加到新的映射中。这里的具体实现这里就不解释了 putAllForCreate(m);
}
HashMap最常见的使用方式就是put(K key, V value)方法,所以本博文就从put()方法入手,深入剖析。
//向容器中添加新的Entry对象。如果存在,则替换该值并返回oldValuepublic V put(K key, V value) {// 延迟加载模式,每次调用put()方法时,都会先判断数组是否已经初始化 if (table == EMPTY_TABLE) {inflateTable(threshold); }// HashMap 可以用 key = null 保存数据 if (key == null) return putForNullKey(value); // hash()方法在HashMap的各个版本中的实现是不同的 // 读者只需要知道这个方法的作用就是获取一个已经尽可能哈希过的int值 int hash = hash(key );//获取数组下标 int i = indexFor(hash, table.length);//根据下标,遍历下标中的链表,如果找到旧值,则替换并返回旧值 for ( Entry e = table[i]; e != null; e = www.sychzs.cn) {Object k;if (e.hash == hash && ((k = e.key) == key || key. equals(k))) {V oldValue = e.value;e.value = value;//空方法,无需考虑 e.recordAccess(this);return oldValue;}} // 修改次数加一modCount++; // 向数组添加新数据 addEntry(hash, key, value, i); // 如果没有旧值则返回 null return null;
}
inflateTable(阈值)
private void inflateTable(int toSize) {// 取容量为2的指数次方,且大于等于toSize。后面会解释原因 intcapacity = roundUpToPowerOf2(toSize);//最大临界值只能是MAXIMUM_CAPACITY+1//如果不指定capacity和loadFactor,则threshold=12threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);table = new Entry[capacity];//计算hashSeed,不超过MAXIMUM_CAPACITY则保持为0,映射最后一个属性 initHashSeedAsNeeded(capacity);
}
putForNullKey(V值)
private V putForNullKey(V value) {// 这里虽然遍历的是链表,但是在if判断中,e.key == null // 可以得出HashMap只能保存一个key=null的映射 for ( Entry e = table[0]; e != null; e = www.sychzs.cn) {if (e.key == null) {V oldValue = e.value;e.value = value;e. recordAccess(this);返回oldValue;}}modCount++;addEntry(0, null, value, 0);返回null;
}
indexFor(int h, int length)
static int indexFor(int h, int length) {return h & (length-1);
}
这个方法虽然只有一个说法,但是却关系到很多东西。例如,读者一定很好奇为什么默认容量是1 << 4 = 16,为什么会调用roundUpToPowerOf2(toSize)方法?这就给我们带来了HashMap最关键的一点,就是通过哈希值来确定数组下标。因为哈希值的哈希值非常大,而数组的容量有限,所以如何确定下标就非常关键。
程序员最容易想到的方式就是求模运算:hash % length
hash是一个int类型的值。假设length为默认容量16,那么hash % length得到的数组范围为0 - 15,这符合Array存储原理。
然而jdk作者选择的是位运算:hash & (length - 1)
我们通过一个例子来验证一下:假设length=16,那么length-1=15,二进制表示0000 1111
1。哈希= 1001 1010 0101 0111哈希和长度 - 1 = 1001 1010 0101 0111和0000 1111 = 0000 0111 = 71。哈希= 1001 1010 0101 0000哈希和长度 - 1 = 1001 1010 0101 0000& 0000 1111= 0000 0000 = 03. 哈希 = 1111 1111 0101 1111哈希值和长度 - 1 = 1111 1111 0101 1111& 0000 1111= 0000 1111 = 15
从上面验证的结论来看,得到的下标仍然在0-15之间,位运算的运算速度比取模运算要快。影响是容量必须是2的指数次方(二进制数每一位的权重是2的指数次方)。如果不是,那么capacity-1的二进制结果中的低位将不会全部为1,因此会增加哈希冲突的概率;这很容易理解。如果低位中的某个位为0,那么无论该哈希值对应的位是1还是0,和操作的结果都会是0,如果是0,得到的可能性是一样的下标变大。
addEntry(int哈希,K键,V值,intbucketIndex)
void addEntry(int hash, K key, V value, int bucketIndex) {// 如果的个数大于等于临界值且当前数组下标不为空,则扩容数组/ / 注意:这里的大小是映射的数量(包括链表),而不是数组中存储的有效值的数量 if ((size >= Threshold) && (null != table[bucketIndex])) {/ / 因为原来的容量是2的指数次方,所以可以通过resize(2 * table.length)来扩大原来的容量*2;// 扩大后,哈希表可能发生了变化(当超过最大值时value),所以需要重新计算hash值 hash = (null != key)? hash(key) : 0;bucketIndex = indexFor(hash, table.length);}createEntry(hash, key, value, BucketIndex);
}
从扩容条件之一size>=threshold可以看出,临界值threshold与是否需要扩容有关,threshold=capacity * loadFactor,所以真正影响扩容的是负载因子loadFactor。默认负载系数 = 0.75f。有的读者可能会好奇,为什么要取这样的值呢?默认情况下,size = 12时应该扩展容量,因此容量为16的数组会浪费4个存储空间(不一定,理想情况下)。既然造成浪费,为什么不将加载因子设置为1呢? ?原因是数组查找的效率是O(1)。如果数组存储接近满的时候进行扩容,很容易增加哈希冲突的概率。遍历链表会降低查找速度,而使用HashMap是因为它的查找性能优异。使用广泛,需要牺牲空间来换取时间;当然,有些读者会好奇,既然可以牺牲空间来换取时间,为什么不使用较低的loadFactor呢?这也是jdk开发者的一个选择,试图在空间和效率之间取得平衡。在程序开发中,这种平衡选择还是很常见的。
createEntry(int hash, K key, V value, int bucketIndex)
void createEntry(int hash, K key, V value, int bucketIndex) {//获取当前下标的数组元素Entry e = table[bucketIndex];//将获取到的元素作为下一个新的元素值,可以查看Entry的构造方法 table[bucketIndex] = new Entry<>(hash, key, value, e);size++;
}
HashMap中链表节点的添加采用前向插值方法
HashMap 1.7中,多线程扩展时可能会出现死循环
void resize(int newCapacity) {Entry[] oldTable = table;int oldCapacity = oldTable.length;//如果旧数组的容量已经是最大,则无法扩容,扩容临界值只能为Changed if (oldCapacity == MAXIMUM_CAPACITY) {threshold = Integer.MAX_VALUE;return;}//详细说明:这里并没有调用InflateTable()来让容量变成2的指数幂//因为只会根据原非空表resize操作,且原表必须满足容量条件 Entry[] newTable = new Entry[newCapacity]; // 第二个参数一般为false,下面不是重点。 // 和最后一个属性有关,只有超过默认的最大值才会发生改变hashSeed // 如果改变hashSeed,就会出现新的哈希表,然后需要重新计算哈希值transfer(newTable , initHashSeedAsNeeded(newCapacity));table = newTable;//计算临界值,无需赘述 Threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
transfer(Entry[] newTable, boolean rehash)这个方法是死锁的罪魁祸首
void transfer(Entry[] newTable, boolean rehash) {int newCapacity = newTable.length;// 遍历数组,只遍历数组下标元素 for (Entry e : table) {while(null != e ) {条目下一个= www.sychzs.cn; // 只有当生成新的哈希表时,才需要重新计算哈希值 if (rehash) {e.hash = null == e.key ? 0 : hash(e.key );}//读者可以在这里验证: i = oldIndex 或者 i = oldIndex + oldCapacityint i = indexFor(e.hash, newCapacity);//链表前向插值方法 www.sychzs.cn = newTable[ i];newTable[i] = e;e = 下一个;}}
}
假设有两个线程 A 和 B,两个线程都执行 Entry 至此,put()方法就基本解释完了。总结一下: getEntry(对象键) removeEntryForKey(对象键) 从get()和remove()方法可以得出,它们的实现非常相似,所以博主不再解释类似这些函数的方法。 相信很多程序员在多线程使用HashMap时都会遇到这个异常。这种异常通常发生在HashMap在遍历keySet、values、entrySet的时候进行增删操作的时候。那么原理是什么呢? ? 上面代码的分析足以说明异常的原因。下面我将列出一些关于keySet()、values()、entrySet()的源代码。由于代码层次和语法都比较简单,博主将这只是一个列表,而不是解释;如果有任何疑问,可以在下方评论区提问,博文会一一解答。 keySet() 值() entrySet() 本博文仅讲解HashMap1.7源码的重要部分。当然,还有很多细节没有解释清楚;由于空间原因,只能这样做;如果有机会,博主会提供其他细节。部分组织成单独的博客文章。博主认为,在阅读源码时,不仅要掌握源码的整体框架,还要了解jdk作者对很多细节的处理。你不仅要理解它,而且还必须遵循它。
get(对象键)
public V get(Object key) {//获取key=null的值,可能为空 if (key == null)return getForNullKey();Entry
final Entry
删除(对象键)
public V remove(Object key) {Entry
最终条目
揭示ConcurrentModificationException异常
//抽象类,用于继承private abstract class HashIterator
私有最终类 ValueIterator 扩展了 HashIterator
public Set
public Collection
//这是HashMap自有的属性,因为是特有的
私有瞬态Set
总结