Java中HashMap与TreeMap深入分析及源码解析
参考链接:https://blog.csdn.net/QH_JAVA/article/details/46404439
一、HashMap
此实现提供所有可选的映射操作,并允许使用 null 值和 null 键(除了非同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同),此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
1.2 HashMap的实例有两个参数影响其性能:初始容量和加载因子
容量是哈希表中桶的数量,初始容量是哈希表在创建时的容量(16)。加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。
HashMap底层是哈希表实现(格式像数组链表的组合),当创建一个HashMap对象的时候创建Hash表,哈希表的容量就是哈希中桶的个数,如果在创建对象的时候指定了容量,则创建的哈希表的容量就是桶的个数,而这个桶的个数就是一个比指定容量小的最大值,也就是最接近指定的容量的那个数而且这个数是2的n次幂。
为什么桶的个数不是指定的容量的大小而是比这个小,这个看下面的源码就明白了。如果在创建的时候没有指定初始容量则使用默认值16,容量的值是2的n次幂,负载因子默认为0.75。
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 16;
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
不管是创建时指定了容量还是使用默认的容量,这个值都不等于存储对象的个数,因为在开始就说了它是基于数组和链表实现的,而且还有加载因子,所以容量不等于存储对象个数。
前面已经说了影响实例性能的两个因素,所以在创建实例的时候我们要按照自己的需求来设置这两个值,当空间大而对查询效率要求高的时候,可以将初始容量设置的大一些,而加载因子小一些这样的话查询效率高,但空间利用率不高;而当空间比较小而效率要求不是很高的时候,可以将初始容量设置小一些,而加载因子设置大一些,这样查询速度会慢一些,而空间利用率会高一些,这就是因为HashMap底层使用的是数组和链表的实现方式,具体的分析看下面哈希表结构。
按照key关键字的哈希值和buckets数组的长度取模查找桶的位置,如果key的哈希值相同,Hash冲突(也就是指向了同一个桶),则每次新添加的作为头节点,而最先添加的在表尾。
HashMap中桶的个数就是下图中0- n的数组的长度,存储第一个entry的位置叫‘桶(bucket)’,而桶中只能存一个值也就是链表的头节点,链表的每个节点就是添加的一个值(HashMap内部类Entry的实例Entry有哪些属性之后在详说),也可以这样理解,一个entry 类型的存储链表的数组。数组的索引位置就是一个个桶的索引地址。
通过上面两张图我们了解了哈希表的结构,从两张图也可以看出他的这种格式像是链表的数组。从上图我们可以发现哈希表是由数组+链表组成的,一个长度为16的数组中,每个元素存储的是一个链表的头结点。那么这些元素是按照什么样的规则存储到数组中呢?一般情况是通过hash(key)%len获得,也就是元素的key的哈希值对数组长度取模得到。比如,上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存储在数组下标为12的位置。
1.3 HashMap其实也是一个线性数组实现的
HashMap其实也是一个线性数组实现的,所以可以理解为其存储数据的容器就是一个线性数组。这可能让我们很不解,一个线性的数组怎么实现按键值对来存取数据呢?这里HashMap有做一些处理。
首先HashMap里面实现一个静态内部类Entry,其重要的属性有key , value, next,从属性key,value我们就能很明显的看出来Entry就是HashMap键值对实现的一个基础bean,我们上面说到HashMap的基础就是一个线性数组,这个数组就是Entry[]类型的数组,Map里面的内容都保存在Entry[]里面。
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
{
/**
* The default initial capacity - MUST be a power of two.
* 默认的容量必须为2的幂
*/
static final int DEFAULT_INITIAL_CAPACITY = 16;
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
* 默认最大值
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* The load factor used when none specified in constructor.
* 负载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* The table, resized as necessary. Length MUST Always be a power of two.
* 到这里就发现了,HashMap就是一个Entry[]类型的数组了。
*/
transient Entry<K,V>[] table;
HashMap类构造函数源码:
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and load factor.
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
// 初始容量(必须是2的n次幂),负载因子
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
// Find a power of 2 >= initialCapacity
int capacity = 1;
// 获取最小于initialCapacity的最大值,这个值是2的n次幂,所以我们定义初始容量的时候尽量写2的幂
while (capacity < initialCapacity)
// 使用位移计算效率更高
capacity <<= 1;
this.loadFactor = loadFactor;
//哈希表的最大容量的计算,取两个值中小的一个
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//创建容量为capacity的Entry[]类型的数组
table = new Entry[capacity];
useAltHashing = sun.misc.VM.isBooted() && (capacity >=Holder.ALTERNATIVE_HASHING_THRESHOLD);
init();
}
1.4 HashMap相关源码
HashMap--put:
疑问思考:如果两个key通过hash%Entry[].length得到的index相同,会不会有覆盖的危险?
这里HashMap里面用到链式数据结构的一个概念。上面我们提到过Entry类里面有一个next属性,作用是指向下一个Entry。打个比方, 第一个键值对A进来,通过计算其key的hash得到的index=0,记做:Entry[0] = A。一会后又进来一个键值对B,通过计算其index也等于0,现在怎么办?HashMap会这样做:B.next = A,Entry[0] = B,如果又进来C,index也等于0,那么C.next = B,Entry[0] = C;这样我们发现index=0的地方其实存取了A、B、C三个键值对,他们通过next这个属性链接在一起。所以疑问不用担心。也就是说数组(桶)中存储的是最后插入的元素。如果hash%Entry[].length得到的index相同而且key.equals(keyother) 也相同,则这个Key对应的value会被替换成新值。
put方法源码:
public V put(K key, V value) {
//key为null的entry总是放在数组的头节点上,也就是上面说的"桶"中
if (key == null)
return putForNullKey(value);
// 获取key的哈希值
int hash = hash(key);
// 通过key的哈希值和table的长度取模确定‘桶’(bucket)的位置
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//如果key映射的entry在链表中已存在,则entry的value替换为新value
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);
return null;
}
Entry内部类源码:
static class Entry<K,V> implements Map.Entry<K,V> {
// 关键字key
final K key;
// 关键字key所对应的value值
V value;
// 这个Entry 对象名称为next ,看到这个大体明白了他就是指向下一个节点即指向下一个Entry对象
// entry 链表的构成也是这个属性
Entry<K,V> next;
// key关键字的哈希值
int hash;
/**
* Creates new entry.
* 构造函数创建一个Entry 对象
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
addEntry(hash,key,value,i)方法:
//bucketIndex 桶的索引值,桶中只能存储一个值(一个Entry 对象)也就是头节点
void addEntry(int hash, K key, V value, int bucketIndex) {
// 如果数组中存储的元素个数大于数组的临界值(这个临界值就是 数组长度*负载因子的值 )则进行扩容
if ((size >= threshold) && (null != table[bucketIndex])) {
// 扩容,将大小扩为原来的两倍
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
调用addEntry(hash,key,value,i)方法时,如果size大于临界值threshold,则首先调用resize 方法:当哈希表的容量超过默认容量时,必须调整table的大小也就是需要创建一张新表,将原表映射到新表中。当容量已经达到最大可能值时,那么该方法就将临界值调整到Integer.MAX_VALUE返回。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 创建新哈希表
Entry[] newTable = new Entry[newCapacity];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
// 重新进行散列
transfer(newTable, rehash);
table = newTable;
// 临界值重新赋值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
常量MAXIMUM_CAPACITY:
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
// 定义最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
当哈希表建好后调用transfer(Entry[] newTable,boolean rehash)方法将原有的数据进行重新散列:
/**
* Transfers all entries from current table to newTable.
* 将所有entry对象从当前表复制到NewTable
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//table 就是一个Entry<K,V>[]类型的数组
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
调用createEntry方法时创建一个Entry对象并将其添加到index 0(“桶”)的位置:
/**
* Like addEntry except that this version is used when creating entries
* as part of Map construction or "pseudo-construction" (cloning,
* deserialization). This version needn't worry about resizing the table.
*
* Subclass overrides this to alter the behavior of HashMap(Map),
* clone, and readObject.
*/
void createEntry(int hash, K key, V value, int bucketIndex) {
// 将原来的首节点保存到e变量中
Entry<K,V> e = table[bucketIndex];
// 将新添加的这个节点保存到首节点而且这个节点指向之前的节点
table[bucketIndex] = new Entry<>(hash, key, value, e);
// 元素个数加 1
size++;
}
HashMap里面也包含一些优化方面的实现,这里也说一下。比如:Entry[]的长度一定后,随着map里面数据的越来越长,这样key的哈希值冲突的概率也就越大,同一个index的链就会很长,会不会影响性能?HashMap里面设置一个因子(负载因子),随着map的size越来越大,Entry[]会以一定的规则加长长度。
HashMap-get:
public V get(Object key) {
// map中可以存储key value 为null
// 这个和put对应在put的时候如果key为null则放在“桶中”即头节点
if (key == null)
// 同样取得时候如果key为null则取“桶位置的值”
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
getForNullKey() 获取key为null的value值:
/**
* Offloaded version of get() to look up null keys. Null keys map
* to index 0. This null case is split out into separate methods
* for the sake of performance in the two most commonly used
* operations (get and put), but incorporated with conditionals in
* others.
*/
private V getForNullKey() {
// 通过这个循环知道key为null的时候插叙的就是Index为0的地方的值(桶)
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
getEntry(key)方法 : 获取key对应的entry 对象,如果HashMap不包含关键字为key的则映射返回null
final Entry<K,V> getEntry(Object key) { //获取key的哈希值
int hash = (key == null) ? 0 : hash(key); //通过key的哈希值以及table.length 来确定index的值(桶的索引)
for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
Object k;
if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
确定数组的index(桶的索引):hashcode % table.length取模
HashMap存取时,都需要计算当前key应该对应Entry[]数组哪个元素,即计算数组下标,计算方法如下:
/**
* Returns index for hash code h.
* 返回h这个hashcode的index0的位置(桶的位置)
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
1.3 HashMap简单总结
1、HashMap 是链式数组(存储链表的数组),实现查询速度可以,而且能快速的获取key对应的value;
2、查询速度的影响因素有容量和负载因子,容量大,负载因子小,查询速度快,但浪费空间,反之则相反;
3、数组的index值由hashcode%len的值(key 关键字, hashcode为key的哈希值, len 数组的大小)来确定,如果容量大、负载因子小,则index相同(index相同也就是指向了同一个桶)的概率小,链表长度小,则查询速度快;反之,index相同的概率大,链表比较长,查询速度慢。
4、对于HashMap以及其子类来说,他们是采用hash算法来决定集合中元素的存储位置,当初始化HashMap的时候系统会创建一个长度为capacity的Entry数组,这个数组里可以存储元素的位置称为桶(bucket),每一个桶都有其指定索引,系统可以根据索引快速访问该桶中存储的元素。
5、无论何时HashMap中的每个桶都只存储一个元素(Entry 对象)。由于Entry对象可以包含一个引用变量用于指向下一个Entry,因此可能出现HashMap 的桶(bucket)中只有一个Entry,但这个Entry指向另一个Entry 这样就形成了一个Entry 链。
6、通过上面的源码发现HashMap在底层将key_value对当成一个整体进行处理(Entry 对象),这个整体就是一个Entry对象,当系统决定存储HashMap中的key_value对时,完全没有考虑Entry中的value,而仅仅是根据key的hash值来决定每个Entry的存储位置。
JDK1.8中使用一个Node数组来存储数据,但这个Node可能是链表结构,也可能是红黑树结构,如果插入的key的hashcode相同,那么这些key也会被定位到Node数组的同一个格子里。
如果同一个格子里的key不超过8个,使用链表结构存储。如果超过了8个,那么会调用treeifyBin函数,将链表转换为红黑树。那么即使hashcode完全相同,由于红黑树的特点,查找某个特定元素,也只需要O(log n)的开销,也就是说put/get的操作的时间复杂度最差只有O(log n)。
需要注意:key的对象,必须正确的实现了Compare接口。
二、TreeMap
2.1 定义、条件、原理
红黑树是一种近似平衡的二叉查找树,它能够确保任何一个节点的左右子树的高度差不会超过二者中较低那个的一倍。具体来说,红黑树是满足如下条件的二叉查找树(binary search tree):
-
每个节点要么是红色,要么是黑色。
-
根节点必须是黑色
-
红色节点不能连续(也即是,红色节点的孩子和父亲都不能是红色)。
-
对于每个节点,从该点至null(树尾端)的任何路径,都含有相同个数的黑色节点。
在树的结构发生改变时(插入或者删除操作),往往会破坏上述条件3或条件4,需要通过调整使得查找树重新满足红黑树的条件。
TreeMap的底层使用了红黑树来实现,向TreeMap对象中放入一个key-value 键值对时,就会生成一个Entry对象,这个对象就是红黑树的一个节点,其实这个和HashMap是一样的,一个Entry对象作为一个节点,只是这些节点存放的方式不同。存放每一个Entry对象时都会按照key键的大小按照二叉树的规范进行存放,所以TreeMap中的数据是按照key从小到大排序的。
2.2 TreeMap相关源代码
TreeMap源代码:
public class TreeMap<K,V>
extends AbstractMap<K,V>
implements NavigableMap<K,V>, Cloneable, java.io.Serializable
{
/**
* The comparator used to maintain order in this tree map, or
* null if it uses the natural ordering of its keys.
*
* @serial
*/
private final Comparator<? super K> comparator;
// 根节点
private transient Entry<K,V> root = null;
/**
* The number of entries in the tree
* 树中的节点数,即entry对象的个数
*/
private transient int size = 0;
/**
* The number of structural modifications to the tree.
* 树修改的次数
*/
private transient int modCount = 0;
TreeMap的内部类Entry<K k,V v>即一个节点:
static final class Entry<K,V> implements Map.Entry<K,V> {
// 关键字key 按照key的哈希值来存放
K key;
// key对应的value值
V value;
// 左节点
Entry<K,V> left = null;
// 右节点
Entry<K,V> right = null;
// 父节点
Entry<K,V> parent;
boolean color = BLACK;
/**
* Make a new cell with given key, value, and parent, and with
* {@code null} child links, and BLACK color.
*/
Entry(K key, V value, Entry<K,V> parent) {
this.key = key;
this.value = value;
this.parent = parent;
}
/**
* Returns the key.
*
* @return the key
*/
public K getKey() {
return key;
}
/**
* Returns the value associated with the key.
*
* @return the value associated with the key
*/
public V getValue() {
return value;
}
/**
* Replaces the value currently associated with the key with the given
* value.
*
* @return the value associated with the key before this method was
* called
*/
public V setValue(V value) {
V oldValue = this.value;
this.value = value;
return oldValue;
}
public boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
return valEquals(key,e.getKey()) && valEquals(value,e.getValue());
}
}
TreeMap的put(K key,V value) 方法:添加一个节点
public V put(K key, V value) {
Entry<K,V> t = root;
//判断根节点是否存在,如果不存在
if (t == null) {
compare(key, key); // type (and possibly null) check
// 将新的key-value对创建一个Entry,并将该Entry作为root
root = new Entry<>(key, value, null);
// 计算节点数
size = 1;
modCount++;
return null;
}
int cmp;
Entry<K,V> parent;
// split comparator and comparable paths
// 如果有根节点则,添加的key和root节点的key进行比较,判断是做左节点、右节点
Comparator<? super K> cpr = comparator;
// 如果比较器cpr不为null,即表明采用定制排序方式
if (cpr != null) {
//比较算法的开始,这里完成了比较和存储
do {
// 使用parent暂存上次循环后的t所对应的Entry,如果是首次则是root节点。
parent = t;
// 新插入的key和当前节点(首次是root节点)t的key进行比较
cmp = cpr.compare(key, t.key);
// 如果新插入的key的值小于t的key值,那么t=t.left即再用当前节点的左节点进行比较
if (cmp < 0)
t = t.left;
// 如果新插入的key的值大于t的key的值,那么t等于t的右节点即在用当前节点的右节点进行比较
else if (cmp > 0)
t = t.right;
else
// 如果两个key的值相等,那么新的value覆盖原有的value,并返回原有的value
return t.setValue(value);
//如果t节点的左节点、右节点不为空则继续循环!知道null为止,这样也就找到了新添加key的parent节点。
} while (t != null);
}
else {
if (key == null)
throw new NullPointerException();
Comparable<? super K> k = (Comparable<? super K>) key;
do {
// 使用parent上次循环后的t所引用的Entry
parent = t;
// 拿新插入的key和t的key进行比较
cmp = k.compareTo(t.key);
// 如果新插入的key小于t的key,那么t等于t的左节点
if (cmp < 0)
t = t.left;
// 如果新插入的key大于t的key,那么t等于t的右节点
else if (cmp > 0)
t = t.right;
else
// 如果两个key相等,那么新的value覆盖原有的value,并返回原有的value
return t.setValue(value);
} while (t != null);
}
//新创建一个节点即put进来的key value
Entry<K,V> e =