目录
提到源码,可能很多人都觉得很难,并且会有些恐惧。但是,当你静下心来去学的时候,就会觉得源码其实也并不是就是不能去学习的。其实不管在牛逼的东西也好,它最终都是离不开人的思维的,因为它在牛逼都是人想出来的,它都要遵循人的思维模式,都是遵循一个所谓的“道”,而我们也是人,又怎么会不能去理解并吸收它呢?!而在本系列hashmap源码(基于jdk1.8)的解读,我就会通过最细致最通俗的语言,让大家都能够掌握hashmap源码,并且通过这个也让大家不再恐惧源码,达到帮助大家开启源码学习之路的作用,最终做一个知其然知其所以然的Java开发工程师!
那么,废话不多说。我们首先,来看看haspmap里面定义的一些常量、成员变量。
一、hashmap里的常量
//hashMap默认的容量16(1左移四位,即为2的4次方,也就是16) static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //默认加载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f;//树化的阈值,同一个槽位上存储元素的个数的阈值 static final int TREEIFY_THRESHOLD = 8;//反树化的阈值 static final int UNTREEIFY_THRESHOLD = 6;//这个是单链表树化成红黑树,数组的最小容量,只有数组达到这个最小容量,才有可能单链表转化成红黑树 static final int MIN_TREEIFY_CAPACITY = 64;
从hashmap定义的常量可以得知,hashmap再进行树化的时候的条件不仅是桶上单链表的长度大于等于8,而且还要满足数组的长度达大于等于64才会进行树化操作,这两个条件必须得同时满足,缺一不可。
那么,为什么要要求数组得长度大于等于64才允许树化操作呢?
这是因为当哈希桶中的链表长度超过阈值(默认为8)时,对于较小的数组长度,使用红黑树进行查找并不一定能够提供比链表更优的性能。这是因为在较小的数组中,链表的遍历开销相对较小,而构建和维护红黑树所需的额外内存和计算开销可能会超过通过链表进行查找的开销。这就是一个经验性的选择,旨在避免在较小的数组中过早地进行树化操作,从而减少额外的内存和计算开销,同时确保在较大的数组中使用红黑树可以提供比链表更好的性能。
二、hashmap的成员变量
//存储元素的数组 //jdk8之前数组类型是Entry<K,V>类型,而jdk1.8之后改成Node<K,V>类型。其实本质还是一样的,都实现了一样的接口:Map.Entry<K,V>,都是用来存储键值对数据的。 //该数组使用了懒加载的设计模式,只在首次对hashmap进行put操作的时候,才会进行初始化 //该数组的长度始终是2的幂次方 transient Node<K,V>[] table;//保存缓存的entrySet() transient Set<Map.Entry<K, V>> entrySet;transient int size; //当前哈希表存在的节点个数/* modCount表示散列表结构修改的次数,替换操作不计在其中. 该变量的作用是用于支持快速失败机制(fail-fast),保证在多线程环境下, 避免使用过期或无效的迭代器。如果没有快速失败机制,可能会导致并发修改引起的不确定行为或数据一致性问题。 * */ transient int modCount;int threshold; //当前哈希表的阈值final float loadFactor; //当前哈希表的加载因子
看到hashmap的成员变量,相信有些人就会有这样的疑问,为什么在hashmap的成员变量里面有些变量使用了transient进行修饰,有些则没有呢?
首先,我们要知道transient关键字的作用是当一个类实现Serializable接口并被序列化时,对于被transient修饰的属性,将不会被序列化。知道这个我们就不难明白,对于hashmap中被transient修饰的这几个变量,也就是设计hashmap的人,不想让几个属性被序列化。那么为什么这几个属性不需要被序列化呢?接下来,我们就逐一分析,为什么这些成员变量不需要被序列化?
为什么不序列化成员变量table数组?
讲道理,这个应该需要被序列化啊,如果不序列化,那在反序列化的时候,里面存储的节点不就丢失了吗?这明显不符合,我们对序列化和反序列化的基本要求,即反序列化之后的对象与序列化之前的对象要是一致的。而此时,hashmap的存储的元素都未被序列化,那肯定是不满足这个要求的。但是,事情真的这么简单吗?显然不是!
虽然,hashmap在这里未直接序列化table数组,但是,在它的方法writeObject方法里的internalWriteEntries方法里面是把table数组里面的节点都序列化了,源码如下(这里也就是没有使用默认的序列化机制,而是通过实现readObject/writeObject两个方法自定义了序列化的内容):
/*
序列化hashmap中的所有节点
* */
void internalWriteEntries(java.io.ObjectOutputStream s) throws IOException {
Node<K, V>[] tab;
//判断如果table不为空,则去遍历table进行序列化
if (size > 0 && (tab = table) != null) {
//遍历table数组
for (int i = 0; i < tab.length; ++i) {
//遍历数组槽位上的单链表
for (Node<K, V> e = tab[i]; e != null; e = e.next) {
//序列化当前节点
//此处的writeObject方法是ObjectOutputStream的,而非HashMap的
s.writeObject(e.key);
s.writeObject(e.value);
}
}
}
}
而为什么不直接序列化成员变量table数组呢?
这样做主要是出于两点考虑:
1、大家都知道,hashmap为了减少哈希冲突,是不会把整个哈希表塞满了,再去扩容的。如果,我们去直接序列化table数组的话,势必会把未使用的部分也序列化了,这样便会造成空间的浪费。
2、大家应该都知道,存储在hashmap中的节点具体在哈希表的哪个槽位上面是受hashcode方法影响的。而hashcode方法是native修饰的方法,而不同的JVM对native修饰的方法,可能会有不同的实现。这样,相同的key,在不同的JVM上,得到的最终hash值,可能就是不一样的。这样如果别的JVMh上对反序列化出来的table进行添加、删除操作可能就会发生错误(当两个JVM对hashcode的实现不同的时候,就会出现错误)。
为什么不序列化成员变量entrySet呢?
这个原因,想必大家应该都容易想出来。这是因为entrySet是通过计算得出的,而不是实际存储数据的一部分。所以,entrySet并没有序列化的必要。
为什么不序列化成员变量size呢?
这主要也是出于提高序列化的性能考虑的。因为size,也就是记录的hashmap中存储的节点的个数。而我们知道,在readObject方法中已经把hashmap的所有节点都序列化了。那么在反序列化的时候,我们将读取到的键值对放入新的table数组的时候便会重新put,而put的时候,它每put一个key不重复的元素的时候,size都会加一。这样,在反序列化完所有节点的时候,成员变量size的值便出来了。可以看readObject的源码如下:
/**
* Reconstitute the {@code HashMap} instance from a stream (i.e.,
* deserialize it).
* hashmap自己实现的反序列化方法
*/
private void readObject(java.io.ObjectInputStream s)
throws IOException, ClassNotFoundException {
// Read in the threshold (ignored), loadfactor, and any hidden stuff
//执行默认的反序列化操作
s.defaultReadObject();
reinitialize(); //初始化hashmap参数
//加载因子为负数或者不是一个数字,则抛出异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new InvalidObjectException("Illegal load factor: " +
loadFactor);
//读取并忽略桶的数量
s.readInt(); // Read and ignore number of buckets
//读取桶的数量,将桶的数量赋值给mappings
int mappings = s.readInt(); // Read number of mappings (size)
//如果桶的数量小于0的话,则抛出异常
if (mappings < 0)
throw new InvalidObjectException("Illegal mappings count: " +
mappings);
//桶的数量大于0,则进行处理
else if (mappings > 0) { // (if zero, use defaults)
// Size the table using given load factor only if within
// range of 0.25...4.0
//计算加载因子,范围在0.25-4.0之间
float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f);
//根据加载因子计算出当前负载因子的倍数
float fc = (float) mappings / lf + 1.0f;
//计算出当前数组的容量
int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
DEFAULT_INITIAL_CAPACITY :
(fc >= MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY :
tableSizeFor((int) fc));
float ft = (float) cap * lf;
//计算出当前数组的阈值
threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
(int) ft : Integer.MAX_VALUE);
@SuppressWarnings({"rawtypes", "unchecked"})
Node<K, V>[] tab = (Node<K, V>[]) new Node[cap]; //定义新的table数组
table = tab;
// Read the keys and values, and put the mappings in the HashMap
//遍历读取所有节点,放入到新的table数组中
for (int i = 0; i < mappings; i++) {
@SuppressWarnings("unchecked")
K key = (K) s.readObject();
@SuppressWarnings("unchecked")
V value = (V) s.readObject();
//把读取到的键值对,放入table数组中
//在这里会把size的值重新计算出来
putVal(hash(key), key, value, false, false);
}
}
//桶的数量为0,不进行处理
}
为什么不序列化成员变量modCount呢?
这是因为modCount只是用于辅助迭代器的快速失败机制,并不参与HashMap的状态恢复和数据一致性,所以在序列化时并没有必要将其也序列化。
总结:为什么hashMap中有些成员变量不需要序列化?
-
不影响反序列化的时候对象的恢复:某些成员变量,例如:entrySet、modCount,只是为了辅助HashMap的内部实现或运行时使用,并不影响HashMap的状态恢复。这些成员变量与HashMap的结构、数据的存储和访问等没有直接关系。
-
可通过其他方式恢复:有些成员变量的值,例如:size,可以通过其他方式重新计算或初始化。在反序列化过程中,可以根据恢复HashMap的键值对的数,来重新计算出成员变量size的值。
-
JVM的差异性导致对hashcode的实现不同:有些成员变量,例如:table,如果直接序列化这个成员变量的化,就可能会因为JVM的差异性导致反序列化table时的节点位置按照当前JVM对hashcode的实现,是不应该在当前槽位的,这便就会造成错误。
总之,不序列化这些成员变量的原因就是为了避免不必要的序列化和反序列化的开销以及考虑JVM的差异性。
下期预告:hashmap的构造方法解读。
欢迎订阅该专栏,该专栏将持续更新源码的学习。。