好了,步入正题,上篇文章Java 集合框架(2)---- List 相关类解析中我们一起看了一下
List
接口的相关具体类(ArrayList
、LinkedList
….),这篇开始我们开始探索 Java 集合框架中的Map
接口及其相关子类。可能有些小伙伴会问了:为什么不先讲Set
接口而讲Map
接口呢?确实在集合框架的第一篇文章中我介绍接口的顺序是先List
在Set
然后才是Map
接口,不过在这里还是决定先讲 Map 接口,因为 Set 接口下的一些具体类(HashSet
….)是通过Map
接口下的一些具体类(HashMap
)实现的,而Map
接口中具体类却不是通过Set
接口(有些许依赖,但是主要逻辑上不是)来实现的。所以我们掌握了Map
接口的一些具体类之后,再去看Set
接口就很容易上手了。
好了, 老规矩,先来看一下 Map
接口的继承关系图:
我们现在可以看到,Map
接口是独立存在的,我们之前看的 List
接口是继承于 Collection
接口的子接口。但是 Map
接口并不依赖 Collection
接口。关于 Map 接口的一些基本概念在 Java 集合框架(2)---- List 相关类解析 中已经介绍过了。下面来看一下 Map
接口下的相关类和接口:
从上面的图中我们知道这个类是一个抽象类,还是先从官方对它的描述开始:
This class provides a skeletal implementation of the Map interface, to minimize the effort required to implement this interface.
To implement an unmodifiable map, the programmer needs only to extend this class and provide an implementation for the entrySet method, which returns a set-view of the map’s mappings. Typically, the returned set will, in turn, be implemented atop AbstractSet. This set should not support the add or remove methods, and its iterator should not support the remove method.
To implement a modifiable map, the programmer must additionally override this class’s put method (which otherwise throws an UnsupportedOperationException), and the iterator returned by entrySet().iterator() must additionally implement its remove method.
The programmer should generally provide a void (no argument) and map constructor, as per the recommendation in the Map interface specification.
The documentation for each non-abstract method in this class describes its implementation in detail. Each of these methods may be overridden if the map being implemented admits a more efficient implementation.
大概意思是:
这个类提供了 Map
接口的骨架实现,以最小化实现Map
接口功能所需的要求。
如果要实现一个不可更改的 map 对象,开发者只需要继承这个类并实现 entrySet()
方法,返回一个包含当前 Map 对象中所有键值对的集合
。通常,这个集合应该基于 AbstractSet
类来实现,并且不应该支持添加
和删除
元素的方法,其迭代器
不应该支持移除元素
的方法。
如果要实现可更改的 map 对象,开发者必须重写 put()
方法(默认抛出 UnsupportedOperationException
异常),并且通过 entrySet().iterator()
方法返回的迭代器必须实现移除元素
的方法。
开发者应该提供一个无参构造
方法,和接受另一个 map 对象的作为参数的构造方法。
这个文档描述了每个非 abstract
方法的实现细节,在继承过程中,如果对应方法有更适应当前类的实现,我们应该重写
这些方法,并添加更好的实现逻辑。
有了基本的了解之后,我们再来看看这个类的部分源码
AbstractMap.java:
public abstract class AbstractMap<K,V> implements Map<K,V> {
protected AbstractMap() {
}
// Query Operations
/**
* 返回当前 map 中键值对元素的数目
*/
public int size() {
return entrySet().size();
}
/**
* 判断当前 map 是否已经没有任何键值对元素
*/
public boolean isEmpty() {
return size() == 0;
}
/**
* 判断参数所给 值 是否存在当前 map 中的某一个键值对元素中(通过 equals 方法判断),
* 如果存在,返回 true,否则返回 false
*/
public boolean containsValue(Object value) {
Iterator<Entry<K,V>> i = entrySet().iterator();
if (value==null) {
while (i.hasNext()) {
Entry<K,V> e = i.next();
if (e.getValue()==null)
return true;
}
} else {
while (i.hasNext()) {
Entry<K,V> e = i.next();
if (value.equals(e.getValue()))
return true;
}
}
return false;
}
/**
* 和上个方法类似,判断参数所给 键 是否存在当前 map 中的某一个键值对元素中(通过 equals 方法判断),
* 如果存在,返回 true,否则返回 false
*/
public boolean containsKey(Object key) {
Iterator<Map.Entry<K,V>> i = entrySet().iterator();
if (key==null) {
while (i.hasNext()) {
Entry<K,V> e = i.next();
if (e.getKey()==null)
return true;
}
} else {
while (i.hasNext()) {
Entry<K,V> e = i.next();
if (key.equals(e.getKey()))
return true;
}
}
return false;
}
/**
* 获取参数所给的 键 所对应的值,如果当前 map 中不存在这个键,那么返回 null,
* 这是默认的实现,通过迭代器遍历,效率低,不同的实体类都会重写该方法
*/
public V get(Object key) {
Iterator<Entry<K,V>> i = entrySet().iterator();
if (key==null) {
while (i.hasNext()) {
Entry<K,V> e = i.next();
if (e.getKey()==null)
return e.getValue();
}
} else {
while (i.hasNext()) {
Entry<K,V> e = i.next();
if (key.equals(e.getKey()))
return e.getValue();
}
}
return null;
}
// Modification Operations
/**
* 在当前 map 中存入一个新的键值对元素,默认抛出 UnsupportedOperationException 异常,
* 即操作不支持
*/
public V put(K key, V value) {
throw new UnsupportedOperationException();
}
/**
* 从当前 map 中移除参数代表的 键 所对应的键值对元素,并返回对应的 值
* 通过迭代器找到对应键值对元素,然后调用迭代器的 remove 方法,
* 如果返回的迭代器没有重写这个方法,则抛出 UnsupportedOperationException 异常
*/
public V remove(Object key) {
Iterator<Entry<K,V>> i = entrySet().iterator();
Entry<K,V> correctEntry = null;
if (key==null) {
while (correctEntry==null && i.hasNext()) {
Entry<K,V> e = i.next();
if (e.getKey()==null)
correctEntry = e;
}
} else {
while (correctEntry==null && i.hasNext()) {
Entry<K,V> e = i.next();
if (key.equals(e.getKey()))
correctEntry = e;
}
}
V oldValue = null;
if (correctEntry !=null) {
oldValue = correctEntry.getValue();
i.remove();
}
return oldValue;
}
// Bulk Operations
/**
* 将参数所代表的 map 对象中所有的键值对元素放入当前 map 对象中
*/
public void putAll(Map<? extends K, ? extends V> m) {
for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
put(e.getKey(), e.getValue());
}
/**
* 移除当前 map 中的所有键值对元素
*/
public void clear() {
entrySet().clear();
}
// Views
/**
* 储存键和值的集合
* Each of these fields are initialized to contain an instance of the
* appropriate view the first time this view is requested. The views are
* stateless, so there's no reason to create more than one of each.
*/
transient volatile Set<K> keySet;
transient volatile Collection<V> values;
/**
* 返回一个包含了当前 map 对象中所有的 “键” 的 Set 对象,
* 返回一个匿名内部类对象,其实现了 Set 接口的基本功能
*/
public Set<K> keySet() {
if (keySet == null) {
keySet = new AbstractSet<K>() {
public Iterator<K> iterator() {
return new Iterator<K>() {
private Iterator<Entry<K,V>> i = entrySet().iterator();
public boolean hasNext() {
return i.hasNext();
}
public K next() {
return i.next().getKey();
}
public void remove() {
i.remove();
}
};
}
public int size() {
return AbstractMap.this.size();
}
public boolean isEmpty() {
return AbstractMap.this.isEmpty();
}
public void clear() {
AbstractMap.this.clear();
}
public boolean contains(Object k) {
return AbstractMap.this.containsKey(k);
}
};
}
return keySet;
}
/**
* 返回一个包含了当前 map 对象中所有的 “值” 的 Collection 对象,
* 返回一个匿名内部类对象,其实现了 Collection 接口的基本功能
*/
public Collection<V> values() {
if (values == null) {
values = new AbstractCollection<V>() {
public Iterator<V> iterator() {
return new Iterator<V>() {
private Iterator<Entry<K,V>> i = entrySet().iterator();
public boolean hasNext() {
return i.hasNext();
}
public V next() {
return i.next().getValue();
}
public void remove() {
i.remove();
}
};
}
public int size() {
return AbstractMap.this.size();
}
public boolean isEmpty() {
return AbstractMap.this.isEmpty();
}
public void clear() {
AbstractMap.this.clear();
}
public boolean contains(Object v) {
return AbstractMap.this.containsValue(v);
}
};
}
return values;
}
// 抽象方法,返回储存了当前 map 对象的所有键值对元素的 Set 对象
public abstract Set<Entry<K,V>> entrySet();
// Comparison and hashing
/**
* 比较当前 map 对象和参数所指定的 map 对象,
* 如果当前 map 对象中所有的键值对元素和参数所指定的 map 对象
* 中的所有键值对元素都相同(通过 equals 方法判断)
*/
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Map))
return false;
Map<?,?> m = (Map<?,?>) o;
if (m.size() != size())
return false;
try {
Iterator<Entry<K,V>> i = entrySet().iterator();
while (i.hasNext()) {
Entry<K,V> e = i.next();
K key = e.getKey();
V value = e.getValue();
if (value == null) {
if (!(m.get(key)==null && m.containsKey(key)))
return false;
} else {
if (!value.equals(m.get(key)))
return false;
}
}
} catch (ClassCastException unused) {
return false;
} catch (NullPointerException unused) {
return false;
}
return true;
}
/**
* 重写 Object 类的方法, 返回当前 map 对象的 hash 值
*/
public int hashCode() {
int h = 0;
Iterator<Entry<K,V>> i = entrySet().iterator();
while (i.hasNext())
h += i.next().hashCode();
return h;
}
/**
* 重写 Object 的方法,返回当前 map 对象的 String 对象表示
*/
public String toString() {
Iterator<Entry<K,V>> i = entrySet().iterator();
if (! i.hasNext())
return "{}";
StringBuilder sb = new StringBuilder();
sb.append('{');
for (;;) {
Entry<K,V> e = i.next();
K key = e.getKey();
V value = e.getValue();
sb.append(key == this ? "(this Map)" : key);
sb.append('=');
sb.append(value == this ? "(this Map)" : value);
if (! i.hasNext())
return sb.append('}').toString();
sb.append(',').append(' ');
}
}
/**
* 重写 Object 类的方法,返回一个当前 map 对象的复制对象,
* 键值对不会被复制
*/
protected Object clone() throws CloneNotSupportedException {
AbstractMap<?,?> result = (AbstractMap<?,?>)super.clone();
result.keySet = null;
result.values = null;
return result;
}
// ...
}
和我们之前文章中介绍的
AbstractList
很类似,利用Java 多态
的特性,提供了对应接口的基本骨架实现
,而其他的扩展功能
留给子类
去实现,我们从开头的图中也知道,图中的Map
接口下的具体类都是继承
于这个AbstractMap
,即为这个类的子类
。
下面我们来看 Map 接口下的另外一个接口 SortedMap
,这个接口官方对它的描述文档有点长,在这里就不贴了,总结一下这个接口的主要功能就是声明一些方法,用于给实现这个接口的容器指定一个约定:
实现这个接口的容器应该要按某个规则对容器内的元素进行排序
,并且可以通过这个接口提供的方法获取容器特定的一些元素。但是接口本身不干预容器的排序规则,具体的排序方式由容器自己决定。
我们来看一下这个接口的源码 SortedMap.java:
public interface SortedMap<K,V> extends Map<K,V> {
/**
* 返回这个 map 对象中用于通过 “键” 来对元素进行排序的 Comparator(比较器)对象,
* 如果当前 map 对象使用 “键” 的自然升序规则排序元素(即未指定排序所用的 Comparator 对象),
* 那么返回 null
*/
Comparator<? super K> comparator();
/**
* 获取当前 map 对象中元素 “键” 的范围在 [fromKey, toKey) 之中的键值对元素,
* 将这些键值对放在一个 SortedMap 对象中并返回
*/
SortedMap<K,V> subMap(K fromKey, K toKey);
/**
* 获取当前 map 中 “键” 小于 toKey 的键值对元素,
* 将这些键值对放在一个 SortedMap 对象中并返回
*/
SortedMap<K,V> headMap(K toKey);
/**
* 获取当前 map 中 “键” 不小于fromKey 的键值对元素,
* 将这些键值对放在一个 SortedMap 对象中并返回
*/
SortedMap<K,V> tailMap(K fromKey);
/**
* 返回当前 map 中的第一个 键
*/
K firstKey();
/**
* 返回当前 map 中的最后一个 键
*/
K lastKey();
/**
* 返回一个 Set 对象,其中元素为当前 map 的键值对中的 “键”,
* 元素顺序按当前 map 对象的排序规则对 “键” 升序的规则排列
*/
Set<K> keySet();
/**
* 返回一个 Collection 对象,其中元素为当前 map 的键值对中的 “值”,
* 元素顺序按当前 map 对象的排序规则对 “键” 升序的规则排列
*/
Collection<V> values();
/**
* 返回一个 Set 对象,其中元素为当前 map 中的所有键值对元素,
* 元素顺序按当前 map 对象的排序规则对 “键” 升序的规则排列
*/
Set<Map.Entry<K, V>> entrySet();
}
我们之后将会看到,
Map
接口中的具体类TreeMap
就是一个实现了SortedMap
接口(实现的是NavigableMap
接口,NavigableMap
继承了SortedMap
接口)方法的类。因此我们已经可以知道TreeMap
是一个按照某个排序规则对“键”
进行比较并以此作为依据来对键值对元素进行排序
的 map 容器。关于这个类的具体实现细节,我们等下再一起探索哈。
HashMap 应该是 Java 集合框架中我们在开发中最常用的容器类之一了,它提供了保存多个键值对的能力,并对其保存的键值对提供获取和操作的相关 API,相信小伙伴们对这个类的用法已经很熟悉了,那么我们从源码入手,来一起看看 HashMap
是怎么实现的:
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// ...
// 默认的初始化容量(16),HashMap 的容量必须是 2 的次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// HashMap 的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的负载因子(用于计算出下一次进行扩容时的容量)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 将链表树化的最小长度,当有多个 key 的 hashCode 相同时,
* 先采用链地址法处理冲突,即将多个相同的元素按先后顺序排成一条链表,
* 当这个链表的元素不小于当前字段的值时,为了保证效率,
* 将这一部分链表转换成平衡二叉树
*/
static final int TREEIFY_THRESHOLD = 8;
// 在调整 HashMap 容量时取消树化链表的长度阀值
static final int UNTREEIFY_THRESHOLD = 6;
// 树化一个链表时要求 HashMap 的最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
在上面的代码中,有一个
DEFAULT_LOAD_FACTOR
常量,意为负载因子
,这个值用于计算出下一次需要对HashMap
进行扩容时 HashMap 中包含的最大元素(即键值对,下同)数,即可以理解为对 HashMap 对象进行下一次扩容的容量阀值,这个阀值也由一个名为threshold
的成员变量保存。
举个例子:
假当前设置的HashMap
对象的容量为默认容量
,即16
,那么当前的threshold
值为16 * 0.75 = 12
,那么如果当前 HashMap 中装的元素个数到达了12
个时,就要进行下一次扩容了。(即阀值=负载因子*HashMap的总容量
)可能有小伙伴会问了,为什么要这么做呢?这样不是浪费内存
吗?确实,这样做确实会浪费一部分内存,但是主要目的是为了减少元素冲突
:当当前的HashMap 容量越大
的时候,给元素的key
计算出来的hashCode
的选择也就越多
,这样就越不容易产生冲突
。
举个例子:如果当前HashMap
还剩下16
个空位置,我们要存10
个元素,那么平均下来每个元素有1.6
个位置,可能产生冲突,但如果当前HashMap
只有8
个位置,那么把10
个元素存进去,必然产生冲突,这样的话就增加了插入和查询元素的时间复杂度
。一个可能产生冲突,一个必然产生冲突,而 HashMap 的任务其实主要是致力于保证在尽可能低的时间复杂度(O(1))
中插入
和查询
元素。所以从这个角度上来说牺牲一点内存是值得的。
需要注意的是,我们在创建HashMap
对象的时候可以自己定义
这个负载因子
,但是我们很难去准确的找到一个最适用我们程序中的负载因子
,如果太小,那么会浪费太多的内存空间,如果太大,又可能会在插入元素时产生较多冲突,提高了插入和查询操作的时间复杂度
,因此除非你很有把握,否则的话我们可以直接用默认的值
,无需特殊指定。
下面来看一下 HashMap
是用Node
类来表示每个元素(键值对)的:
// 描述 HashMap 元素的键值对
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 元素的键的 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;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
// 获取当前元素 “键” 的 hashCode 值
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
// 判断对象 o 是否和当前对象在值上相等
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
我们可以
HashMap
中通过一个名为Node
的静态内部类
来实现这个Map.Entry
接口并实现接口中的方法,而这个接口它是一个描述了 HashMap 中键值对元素信息并提供了一些方法来获取这些键值对
。
我们先来看下Map.Entry
接口的相关方法:
/**
* Entry 接口代表一个 key-value 对(键值对),形成的数据结构,即为映射元素,
* 这个接口为 Map 接口中的子接口,
* 泛型 K 代表键的类型,泛型 V 代表值的类型
*/
interface Entry<K,V> {
/**
* 返回当前键值对中的 键 对象,
* 如果当前键值对不在对应的 Map 中,抛出一个 IllegalStateException 异常(可选)
*/
K getKey();
/**
* 返回当前键值对中的 值 对象,
* 如果当前键值对不在对应的 Map 中,抛出一个 IllegalStateException 异常(可选)
*/
V getValue();
/**
* 设置当前键值对中的 值 对象,
* 如果设置的值参数对象为 null,抛出一个 NullpointException 异常(可选),
* 如果设置的值参数对象不能转换为当前键值对中对应的 值 类型,抛出一个 ClassCastException 异常,
* 如果当前键值对不在对应的 Map 中,抛出一个 IllegalStateException 异常(可选)
*/
V setValue(V value);
/**
* 如果参数对象和当前键值对等价,那么返回 true,否则返回 false,一般可以通过以下代码实现:
*
* (e1.getKey()==null ?
* e2.getKey()==null : e1.getKey().equals(e2.getKey())) &&
* (e1.getValue()==null ?
* e2.getValue()==null : e1.getValue().equals(e2.getValue()))
*
*/
boolean equals(Object o);
/**
* 返回当前键值对的 hashCode ,用于 Map 中形成数组下标值,一般可以通过以下代码实现:
*
* (e.getKey()==null ? 0 : e.getKey().hashCode()) ^
* (e.getValue()==null ? 0 : e.getValue().hashCode())
*
* 设计 hashCode 方法时,确保当两个对象的 equals 方法返回 true 时,
* 这两个对象的 hashCode 方法返回值相同
*/
int hashCode();
// ......
}
这个接口提供了一些方法,用于描述一个
键值对
的行为,即通过这些方法来获取 / 设置键值对
的相关信息。
而在 Map(HashMap
、LinkedHashMap
…) 中正是通过实现了这个接口的类对象来储存键值对
的信息。
好了,其实在整个 HashMap
实际上是通过一个 Node
类型的数组
来保存键值对
信息的,来看看相关的字段定义:
// 保存所有键值对元素信息的表,其长度必须为 2 的次幂
transient Node<K,V>[] table;
// 当前 HashMap 对象包含的键值对元素集合
transient Set<Map.Entry<K,V>> entrySet;
// 当前 HashMap 对象中包含的元素(键值对)的数目
transient int size;
// 记录当前 HashMap 对象已经更改的次数(重新分配尺寸、添加、删除元素...)
transient int modCount;
// 下一次进行重新分配当前 HashMap 容量时 HashMap 中存在的最大元素数目 (capacity * loadFactor).
int threshold;
// 当前 HashMap 的负载因子
final float loadFactor;
相关字段看完了,也算是为了下面的内容打基础,下面就开始分析一下相关的方法,首先从构造方法
开始:
// 构造一个带有 initialCapacity 初始容量和 loadFactor 负载因子的 HashMap 对象
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 指定的初始容量不能大于 HashMap 允许的最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 如果当前指定的负载因子小于 0 或者是一个非数字(0.0/0.0 的情况)
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
// 通过 tableSizeFor 方法得到下一次要进行扩容时 HashMap 对象包含的元素数目(这个是第一次的)
this.threshold = tableSizeFor(initialCapacity);
}
在这个方法里有两个方法可能有点不太熟悉,我们来看一下,首先是 Float.isNaN
,这个方法在 Float.java
中定义:
// 判断一个数字是否是非数字值(not a number),如果是,返回 true,否则返回 false
public static boolean isNaN(float v) {
return (v != v);
}
这是一个
Float
类中的静态方法
,为了判断出一个值是否为“非数字”
值,可能有小伙伴会问了,这句话怎么说的通呢?其实这里说的“非数字”
值指的是类似于0.0 / 0.0
得到的值。是的,在 Java 中,小数除以 0 不会抛出 ArithmeticException 异常
,但是每次0.0 / 0.0
得到的结果都是不同的值
(对象),我们来做个小实验:
public static void main(String[] args) {
System.out.println(0.0/0.0 == 0.0/0.0);
}
看到这个程序,可能有些小伙伴第一反应是:这个输出肯定是 true
,那么我们来看看结果:
很遗憾,它输出的是
false
,因为每次表达式0.0/0.0
得到的值都不一样
,回到我们的上面的代码,isNaN
方法直接返回的是(v != v)
,我们也就可以理解这段代码的含义了,如果 v 不是某次 0.0/0.0 的结果,那么 (v != v) 的值肯定为 false (是吧,自己怎么会不等于自己呢),否则,如果v 是某次 0.0/0.0 的结果,根据我们上面做的实验,它会返回 true,那么将会抛出IllegalArgumentException异常。
那么再回到上面的 HashMap
的构造方法中,我们已经知道第一个方法的作用,下面来看看下一个方法:tableSizeFor()
:
// 对当前 cap 指定的容量进行操作,返回第一个大于等于 cap 的 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;
}
这个方法用到了位运算,
|
为按位或
,而>>>
为无符号右移
,和>>
(有符号右移
)的区别在于无符号右移在左边填充的是 0,而有符号右移在左边填充的是符号位(正数为 0,负数为 1)。而所有位运算均是争对二进制而言,这个方法的作用是什么呢?我们用一个具体的数值带进去走一遍流程,假设当前 cap 为 6:
n = cap - 1 = 5
n 的二进制:000..00(29个0) 101,之后省略前导零
n |= n >>> 1 = (101) | (010) = 111
n |= n >>> 2 = (111) | (001) = 111
n |= n >>> 4 = (0) | (111) = 111
n |= n >>> 8 = (0) | (111) = 111
n |= n >>> 16 = (0) | (111) = 111
看到这可能有小伙伴已经反应过来了:这个其实就是把 n 的二进制数中最左边的那一位 1 之后的 0 全变为 1。接下来在 return 语句中如果 n 的值正常,那么返回 n + 1,这样经过进位将左边所有的 1 变为 0 ,并将最左边的 1 的前一位 0 变为 1,比如当前得到了 n 为 7(111),那么 n + 1 就为 8 (1000)得到的值就是 2 的次幂,那在开始为什么要将 n 赋值为 cap - 1 呢?其实是为了防止当前 cap 值本身就是 2 的次幂的情况会使得得到的值为 cap * 2。整个过程 n 向右移动的位数为(1+2+4+8+16 = 31,正好是 int 类型的位数(32)- 1)。
这里说个题外话:如果操作数的类型是 long
呢?该如何改进?
小伙伴如果对这段代码还不是很清楚的话,我们来看一下这段代码在程序中的表现:
static final int tableSizeFor(int cap) {
int maxValue = Integer.MAX_VALUE - 8;
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= maxValue) ? maxValue : n + 1;
}
public static void main(String[] args) {
for (int i = 0; i < 33; i++) {
System.out.println("i: " + i + ", value: " + tableSizeFor(i));
}
}
我把这段代码复制到了程序中,并将 MAXIMUM_CAPACITY
值用 Integer.MAX_VALUE - 8
代替了,值本身不变
,我们来看看结果:
对比结果,这个结论也很容易理解。关于位运算不懂的小伙伴们,可以参考这篇博客:Java基础-一文搞懂位运算
好了,回到我们最初的构造方法
,构造方法执行完这个方法所在的代码行就结束了,但是我们并没有看到其为 table
字段(储存键值对元素的数组
)申请内存空间,我们看看别的构造方法:
// 创建一个具有指定的初始容量和默认负载因子(0.75)的 HashMap 对象
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 创建一个没有任何元素并带有默认容量(16)和默认负载因子(0.75)的 HashMap 对象
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
/**
* 创建一个具有默认负载因子的 HashMap 对象,并将 Map 类型的参数 m 中的元素存入这个对象中,
* 如果 m 为 null,抛出一个 NullpointException 异常
*/
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
前面两个构造方法
也没看到其为 table 字段申请内存空间
,但是第三个构造方法中我们看到了一个putMapEntries()
方法,那么我们跟进去看一下:
/**
* Implements Map.putAll and Map constructor
* @param m the map
* @param evict false when initially constructing this map, else
* true (relayed to method afterNodeInsertion).
*/
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
if (table == null) { // pre-size
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)
resize();
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
在这里,我们看到了两个新的方法:
resize
和putVal
,而且不管如何这个方法最后都会调用putVal
方法,我们很容易猜到这个方法是存放键值对进入当前 HashMap
的方法,但是我们平时都是用put 方法来存放键值对
的,我们看看put
方法的源码:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
原来
put
方法就是直接调用了putVal
方法,那么我们来看看这个putVal
方法:
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果当前的 table 为 null,证明还未给 table 申请内存空间,
// 那么通过 resize 方法申请并调整 table 容量
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果当前存入的键值对中 (“键” 的 hash 值 % table.length 得到的结果),
// 为什么可以用 & 运算符来模拟 % 操作?
// 上文已经说过,HashMap 的容量必须是 2 的次幂,所以其容量 n 转换成二进制中必然只有一位是 1,
// 那么 n - 1,就是将最左边的那一位 1 变为 0,并且将其右边的 0 变成 1 ,
// 再将得到的值和 hash 通过 & 按位相与,这样的话得到的结果必然不会大于 n-1,
// 即通过位运算达到了 % 操作的目的,还减小了 CPU 资源的消耗(位操作速度一般的操作符快多了)
// 如果得到的结果作为下标在 table 数组所代表的数组元素为 null,
// 即 table[hash(key) % table.length] = null
// 证明这个下标可用(不会产生冲突),那么直接赋值
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 产生冲突
else {
Node<K,V> e; K k;
// 如果插入的键值对的 “键” 和冲突的键值对的 “键” 等价,那么先记录产生冲突的元素,到后面更新值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果产生已存在的键值对元素为 TreeNode 类型,证明当前链表已经被树化(变成一颗红黑树),
// 那么把节点(键值对元素)插入树中,(涉及到红黑树的维护)
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),那么树化链表(变成一颗红黑树)
// 注意 bitCount 从 0 开始
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 同样的,如果插入的键值对元素的 “键” 和某个已经存在的键值对的 “键” 等价,
// 那么直接跳出循环,之后会更新这个等价的键值对的值
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果 e 不为 null,证明有某个键值对的 “键” 和插入的键值对的 “键” 是等价的,
// 更新已经存在的那个键值对的值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 回调方法,供子类实现
afterNodeAccess(e);
// 返回被替换得 “值”
return oldValue;
}
}
++modCount;
// 如果插入键值对后元素数目大于重新分配 HashMap 容量的阀值,那么再次分配 HashMap 容量
if (++size > threshold)
resize();
// 回调方法,供子类实现
afterNodeInsertion(evict);
// 如果没有更新任何键值对的值,证明成功插入了一个新的键值对,此时返回 null
return null;
}
这个方法实际上为在
HashMap
中插入新元素的核心方法,在上面的代码注释中涉及到一些新的概念,首先是冲突
,这里的冲突是指两个键值对元素的 “键” 的 hashCode 相同
,这种情况下有两种情况:
1、要插入的键值对的 “键” 和冲突的键值对的 “键” 等价(两个引用指向一个对象或者两个引用指向的对象的 equals 方法返回 true
)。此时,记录这个键值对,到后面更新替换一下它的值即可。
2、要插入的键值对的 “键” 和冲突的键值对的 “键” 不等价(两个引用指向的对象的 equals 方法返回 false
)。这种情况下就需要进行特殊处理(链化或者树化节点
),来看张图:
这是处理冲突的第一种方式,
将键的 hashCode 值冲突但键本身又不等价的键值对按插入先后顺序链化
,那么还有“树化”
呢?树化其实是将链化的链表转成一颗红黑树(一种平衡二叉树的实现,节点的左子树节点值都小于该节点值,右子树节点值都大于该节点值,并且每个节点的左右子树高度差不超过 1
),可能有些小伙伴对红黑树不太熟悉,但是红黑树的相关操作(主要是节点的旋转比较麻烦)并不是一两句话能够说清楚的,想要了解红黑树的小伙伴们,可以参考本博主的这篇博客jkf
看完的小伙伴,应该对红黑树有了一定的了解了吧,在红黑树中插入和查询操作的时间复杂度都是O(logn)
,即为树的高度
,这里n 为树的节点总数
,我们知道在一个链表中查找某个节点的时间复杂度为 O(n)
,这样的话如果节点数很多的话就会造成插入和查询节点过于耗时的情况,而 HashMap 本身就是用来提供对象和对象之间的映射关系的,即减小通过对象来查询对象的时间复杂度,当我们上面链化的节点过多的时候,链表太长就会影响 HashMap 的插入和查询操作的时间复杂度
。
因此在上面的代码中,如果冲突的元素组成的链表长度不小于 TREEIFY_THRESHOLD(8)
,(即大于等于8 时
),就需要将链表树化,以减小相关操作的时间复杂度(O(n) -> O(logn)
)。
还是简单用一张图来看一下树化的过程:
当我们put的时候,首先计算 key的hash值,这里调用了 hash
方法,hash
方法实际是让key.hashCode()
与key.hashCode()>>>16
进行异或操作,高16bit补0,一个数和0异或不变,所以 hash 函数大概的作用就是:高16bit不变,低16bit和高16bit做了一个异或,目的是减少碰撞
。按照函数注释,因为bucket数组大小是2的幂
,计算下标index = (table.length - 1) & hash
,如果不做 hash 处理,相当于散列生效的只有几个低 bit 位,为了减少散列的碰撞,设计者综合考虑了速度、作用、质量之后,使用高16bit和低16bit异或来简单处理减少碰撞,而且JDK8中用了复杂度 O(logn)的树结构来提升碰撞下的性能
。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//实现Map.put和相关方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 步骤①:tab为空则创建
// table未初始化或者长度为0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤②:计算index,并对null做处理
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素
else {
Node<K,V> e; K k;
// 步骤③:节点key存在,直接覆盖value
// 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给e,用e来记录
e = p;
// 步骤④:判断该链为红黑树
// hash值不相等,即key不相等;为红黑树结点
// 如果当前元素类型为TreeNode,表示为红黑树,putTreeVal返回待存放的node, e可能为null
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);
//判断链表的长度是否达到转化红黑树的临界值,临界值为8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//链表结构转树形结构
treeifyBin(tab, hash);
// 跳出循环
break;
}
// 判断链表中结点的key值与插入的元素的key值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
//判断当前的key已经存在的情况下,再来一个相同的hash值、key值时,返回新来的value这个值
if (e != null) {
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性修改
++modCount;
// 步骤⑥:超过最大容量就扩容
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
具体步骤:
①.判断键值对数组table[i]
是否为空或为null
,否则执行resize()
进行扩容。
②.根据键值key
计算hash
值得到插入的数组索引i
,如果table[i]==null
,直接新建节点添加
,转向⑥,如果table[i]不为空
,转向③。
③.判断table[i]的首个元素是否和key一样
,如果相同直接覆盖value
,否则转向④,这里的相同指的是两个元素的hashCode以及equals都一样。
④.判断table[i] 是否为treeNode
,即table[i] 是否是红黑树
,如果是红黑树
,则直接在树中插入键值对
,否则转向⑤。
⑤.遍历table[i]
,判断链表长度是否>=8
,>=8
的话把链表转换为红黑树
,在红黑树中执行插入操作,否则进行链表的插入操作
;遍历过程中若发现key已经存在直接覆盖value
即可。
⑥.插入成功后,判断实际存在的键值对数量size是否大于最大容量threshold
,如果大于,进行扩容
。
补充:
hashCode值不等
,则两个元素必定是不相同的
。hashCode值相等
,则可能发生了哈希碰撞
,可以通过equals()
方法再进一步的判断:如果equals()方法返回true
,则两个元素必定是相同的
。如果返回false
,则两个元素必定是不同的
。hashCode()
和equals()
确定一个元素的唯一性
,只有hashCode()和equals()都返回true,才表示是同一个对象。
好了,到这里我们已经把 HashMap
中插入
元素的流程分析完了,下面来看看取键值对 “值” 的方法(即 get
方法):
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
好吧,
get
方法通过调用getNode
方法来得到对应的键值对元素,如果为null
,那么返回null
,否则返回对应的值,我们来看看getNode
方法:
/**
* Implements Map.get and related methods
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 如果当前 HashMap 的 table 数组不为 null 并且处理 hash 值得到的结果(hash %= n)作为下标,
// 所指向的数组元素不为 null,那么证明这个 hash 值存在对应的键值对
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 如果第一个键值对的键就和要查询的键等价,那么直接返回键值对元素
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 如果第一个键值对元素的 next 不为 null,证明这个键值对和别的键值对产生冲突
if ((e = first.next) != null) {
// 如果是树节点,那么从红黑树中获取键值对元素
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 如果是链表并且键值对的键和当前要查询的键等价,那么返回这个键值对
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 没有查询到证明当前要查询的 key 不和任何一个键值对的键等价,则返回 null
return null;
}
OK,get 方法的流程我们也知道了,下面看看移除一个键值对元素的方法 remove
:
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
同样的,调用了 removeNode
方法来进行移除,我们赶紧看看这个方法:
/**
* Implements Map.remove and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to match if matchValue, else ignored
* @param matchValue if true only remove if value is equal
* @param movable if false do not move other nodes while removing
* @return the node, or null if none
*/
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 同样的,先确保 table 不为 null,并且处理后的 hash 作为下标所指向的元素不为 null
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 这里的逻辑和上面 getNode 方法的逻辑很相似,先得取到要移除的键值对元素
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
// 从红黑树中取键值对元素
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
// 从链表中取键值对元素
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 如果上面得到的要移除的键值对元素不为 null,那么进行移除
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// 如果是树节点,那么从红黑树中移除元素(涉及到红黑树的维护)
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
// 移除的是链表中的头节点元素,直接将数组下标对应的元素引用赋值为头结点的下一个节点元素
else if (node == p)
tab[index] = node.next;
// 移除的不是链表头结点元素,
// 将 node 的上一个节点元素(即为 p)的 next 字段赋值为 node.next
else
p.next = node.next;
++modCount;
--size;
// 供子类实现的回调方法
afterNodeRemoval(node);
// 返回被移除的元素
return node;
}
}
// 没有找到要移除的键值对元素,返回 null
return null;
}
看完了移除键值对元素
的操作,最后来看一下遍历元素
的方法:
// 1、通过 entrySet() 方法得到 HashMap 的键值对集合,再通过集合提供的迭代器来遍历元素,
// 这个遍历过程其实就是顺序遍历 HashMap 中的 table 数组
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}
// 2、通过得到 HashMap 的所有键值对中 “键” 的集合,然后通过 get() 方法得到值来遍历元素
public Set<K> keySet() {
Set<K> ks;
return (ks = keySet) == null ? (keySet = new KeySet()) : ks;
}
// 3、通过 forEach 方法来实现 HashMap 中的元素遍历(JDK 1.8 以上支持)
@Override
public void forEach(BiConsumer<? super K, ? super V> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e.key, e.value);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
HashMap 虽然是通过提供集合(
entrySet
)的形式来对外提供遍历元素
的接口,但实际上这个集合(entrySet
)遍历元素的顺序就是直接顺序遍历其 HashMap 对象的 table 数组
,关于这点,可以参考以下源码:
final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator<Map.Entry<K,V>> iterator() {
return new EntryIterator();
}
// ...
}
这个
EntrySet
类是HashMap
的一个内部类
,而HashMap
的entrySet
方法中返回的也是一个EntrySet
对象,也就是说我们通过entrySet 方法
得到的其实是一个EntrySet
对象,我们对Set
进行遍历
时是通过其提供的迭代器
进行的,所以我们重点关注其iterator
方法,发现其返回的是一个EntryIterator
对象,我们看看这个类:
final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
同样是一个
HashMap
的内部类
,继承自HashIterator
类,其next 方法
直接返回了其父类对象HashIterator
的nextNode 方法
的调用结果,我们继续跟进这个方法:
final Node<K,V> nextNode() {
Node<K,V>[] t;
// 当前要返回的元素为上一次调用 nextNode 方法后的 next 引用指向的元素对象
Node<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
// 如果当前要返回的元素为 null,抛出一个异常
if (e == null)
throw new NoSuchElementException();
// 如果当前元素的下一个元素为 null,并且当前 HashMap 对象的 table 数组不为 null,
// 则进入循环,这里其实是为了下一个元素做准备,next 引用指向下一个要返回的元素对象
if ((next = (current = e).next) == null && (t = table) != null) {
// 这里循环为了排除 table 数组的 null 元素,index 下标一直++,
// 直到遇到一个不为 null 的元素时结束循环,
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
这个方法在
HashIterator
类中声明,方法的作用在注释中已经写的很清楚了,我们再来看看HashIterator
类的其他信息:
abstract class HashIterator {
Node<K,V> next; // next entry to return
Node<K,V>current; // current entry
int expectedModCount; // for fast-fail
int index; // current slot
HashIterator() {
expectedModCount = modCount;
// HashMap 对象的 table 数组
Node<K,V>[] t = table;
current = next = null;
index = 0;
// 在构造方法中初始化 next 引用为 table 数组中第一个不为 null 的元素
if (t != null && size > 0) { // advance to first entry
do {} while (index < t.length && (next = t[index++]) == null);
}
}
// ...
}
通过
entrySet 方法
来遍历元素的关键代码就是这些。其他的元素遍历方式小伙伴们可以自己参考源码,通过上面的分析我们应该知道,HashMap
中元素的遍历顺序
和元素的插入顺序
是没有任何关系
的,因为插入
元素时主要依据的是元素的键的 hashCode 值
,而每个元素的键的hashCode 没有什么规则
(根据键所属的类的实现而定),所以我们并不能试图按照插入元素的顺序来取出元素
。如果需要使得取出的元素顺序是按照插入元素的先后顺序排序的话
,请使用LinkedHashMap
。关于LinkedHashMap
,我们下面再进行讲解。
①.在jdk1.8
中,resize方法
是在hashmap中的键值对个数大于阀值
时或者初始化
时,就调用resize方法进行扩容
;
②.每次扩展的时候,都是扩展为原来的2倍
;
③.扩展后Node对象
的位置要么在原位置
,要么移动到原偏移量两倍的位置
。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;//oldTab指向hash桶数组
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {//如果oldCap不为空的话,就是hash桶数组不为空
if (oldCap >= MAXIMUM_CAPACITY) {//如果大于最大容量了,就赋值为整数最大的阀值
threshold = Integer.MAX_VALUE;
return oldTab;//返回
}//如果当前hash桶数组的长度在扩容后仍然小于最大容量 并且oldCap大于默认值16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold 双倍扩容阀值threshold
}
// 旧的容量为0,但threshold大于零,代表有参构造有cap传入,threshold已经被初始化成最小2的n次幂
// 直接将该值赋给新的容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 无参构造创建的map,给出默认容量和threshold 16, 16*0.75
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 新的threshold = 新的cap * 0.75
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 计算出新的数组长度后赋给当前成员变量table
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//新建hash桶数组
table = newTab;//将新数组的值复制给旧的hash桶数组
// 如果原先的数组没有初始化,那么resize的初始化工作到此结束,否则进入扩容元素重排逻辑,使其均匀的分散
if (oldTab != null) {
// 遍历新数组的所有桶下标
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 旧数组的桶下标赋给临时变量e,并且解除旧数组中的引用,否则就数组无法被GC回收
oldTab[j] = null;
// 如果e.next==null,代表桶中就一个元素,不存在链表或者红黑树
if (e.next == null)
// 用同样的hash映射算法把该元素加入新的数组
newTab[e.hash & (newCap - 1)] = e;
// 如果e是TreeNode并且e.next!=null,那么处理树中元素的重排
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// e是链表的头并且e.next!=null,那么处理链表中元素重排
else { // preserve order
// loHead,loTail 代表扩容后不用变换下标,见注1
Node<K,V> loHead = null, loTail = null;
// hiHead,hiTail 代表扩容后变换下标,见注1
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 遍历链表
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
// 初始化head指向链表当前元素e,e不一定是链表的第一个元素,初始化后loHead
// 代表下标保持不变的链表的头元素
loHead = e;
else
// loTail.next指向当前e
loTail.next = e;
// loTail指向当前的元素e
// 初始化后,loTail和loHead指向相同的内存,所以当loTail.next指向下一个元素时,
// 底层数组中的元素的next引用也相应发生变化,造成lowHead.next.next.....
// 跟随loTail同步,使得lowHead可以链接到所有属于该链表的元素。
loTail = e;
}
else {
if (hiTail == null)
// 初始化head指向链表当前元素e, 初始化后hiHead代表下标更改的链表头元素
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 遍历结束, 将tail指向null,并把链表头放入新数组的相应下标,形成新的映射。
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
在
putVal()
中,我们看到在这个函数里面使用到了2次resize()方法
,resize()方法
表示的在进行第一次初始化
时会对其进行扩容
,或者当该数组的实际大小大于阀值(第一次为12)
,这个时候在扩容的同时也会伴随的桶上面的元素进行重新分发
,这也是JDK1.8
版本的一个优化
的地方,在1.7中
,扩容之后
需要重新
去计算其Hash值
,根据Hash
值对其进行分发,但在1.8版本
中,则是根据在同一个桶的位置
中进行判断(e.hash & oldCap)
是否为0
,重新进行hash分配后
,该元素的位置要么停留在原始位置
,要么移动到原始位置+旧位桶数组的容量大小这个位置上
。
说到jdk1.7或jdk1.8,HashMap变化还挺大的,所以我们来探讨下两个jdk版本的区别:
在Java中,保存数据有两种比较简单的数据结构:数组
和链表
。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;
所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。
JDK1.8
之前采用的是拉链法
。拉链法:将链表和数组相结合
。也就是说创建一个链表数组
,数组中每一格就是一个链表
。若遇到哈希冲突,
则将冲突的值加到链表中
即可。
相比于之前的版本,jdk1.8
在解决哈希冲突时有了较大的变化,当链表长度大于或等于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。
JDK1.8主要解决或优化了一下问题:
扩容优化
。红黑树
,目的是避免单条链表过长而影响查询效率,红黑树算法请参考。多线程死循环
问题,但仍是非线程安全
的,多线程时可能会造成数据丢失问题。不同点 | JDK 1.7 | JDK 1.8 |
---|---|---|
存储结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
初始化方式 | 单独函数:inflateTable() | 直接集成到了扩容函数resize()中 |
hash值计算方式 | 扰动处理 = 9次扰动 = 4次位运算 + 5次异或运算 | 扰动处理 = 2次扰动 = 1次位运算 + 1次异或运算 |
存放数据的规则 | 无冲突时,存放数组;冲突时,存放链表 | 无冲突时,存放数组;冲突 & 链表长度 < 8:存放单链表;冲突 & 链表长度 >= 8:树化并存放红黑树 |
插入数据方式 | 头插法(先将原位置的数据移到后1位,再插入数据到该位置) | 尾插法(直接插入到链表尾部/红黑树) |
扩容后存储位置的计算方式 | 全部按照原来方法进行计算(即hashCode ->> 扰动函数 ->> (h&length-1)) | 按照扩容后的规律计算(即扩容后的位置=原位置 or 原位置 + 旧位桶数组的容量大小) |
在解决这个问题之前,我们首先需要知道什么是哈希冲突,而在了解哈希冲突之前我们还要知道什么是哈希才行??
什么是哈希??
Hash,一般翻译为
“散列”
,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法
,变换成固定长度的输出
,该输出就是散列值(哈希值)
;这种转换是一种压缩映射
,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。
简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
所有散列函数都有如下一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。
什么是哈希冲突?
当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做碰撞(哈希碰撞)。
HashMap的数据结构:
在Java中,保存数据有两种比较简单的数据结构:
数组
和链表
。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易
;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做链地址法
的方式可以解决哈希冲突
:
这样我们就可以将拥有
相同哈希值
的对象组织成一个链表
放在hash值
所对应的bucket
下,但相比于hashCode
返回的int
类型,我们HashMap
初始的容量大小DEFAULT_INITIAL_CAPACITY = 1 << 4(即2的四次方16)
要远小于int类型的范围,所以我们如果只是单纯的用hashCode取余来获取对应的bucket这将会大大增加哈希碰撞的概率,并且最坏情况下还会将HashMap变成一个单链表
,所以我们还需要对hashCode作一定的优化。
hash()函数:
上面提到的问题,主要是因为如果使用hashCode取余
,那么相当于参与运算的只有hashCode的低位
,高位是没有起到任何作用的,所以我们的思路就是让hashCode取值出的高位也参与运算
,进一步降低hash碰撞的概率
,使得数据分布更平均
,我们把这样的操作称为扰动
,在JDK 1.8
中的hash()
函数如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 与自己右移16位进行异或运算(高低位异或)
}
这比在
JDK 1.7
中,更为简洁,相比在1.7中的4次位运算
,5次异或运算(9次扰动)
,在1.8
中,只进行了1次位运算
和1次异或运算(2次扰动)
。
JDK1.8新增红黑树:
通过上面的
链地址法
(使用散列表)和扰动函数
我们成功让我们的数据分布更平均
,哈希碰撞减少
,但是当我们的HashMap中存在大量数据时,加入我们某个bucket
下对应的链表有n
个元素,那么遍历时间复杂度就为O(n)
,为了针对这个问题,JDK1.8
在HashMap
中新增了红黑树
的数据结构,进一步使得遍历复杂度降低至O(logn),即树的高度。
链地址法
(使用散列表)来链接拥有相同hash值
的数据。2次扰动函数(hash函数)
来降低哈希冲突的概率
,使得数据分布更平均。
红黑树
进一步降低遍历的时间复杂度
,使得遍历更快
。可以使用任何类作为 Map 的 key,然而在使用之前,需要考虑以下几点:
equals()
方法,也应该重写 hashCode()
方法。equals()
和 hashCode()
相关的规则。equals()
,不应该在 hashCode()
中使用它。自定义 Key
类最佳实践是使之为不可变的,这样 hashCode() 值可以被缓存起来
,拥有更好的性能
。不可变的类也可以确保 hashCode() 和 equals() 在未来不会改变,这样就会解决与可变相关的问题了。
答:String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性,能够有效的减少Hash碰撞的几率。
final类型
,即不可变性
,保证key的不可更改性
,不会存在获取hash值不同的情况。equals()
、hashCode()
等方法,遵守了HashMap内部的规范
(不清楚可以去上面看看putValue
的过程),不容易出现Hash值计算错误
的情况。答:重写hashCode()
和equals()
方法。
hashCode()
是因为需要计算存储数据的存储位置
,需要注意不要试图从散列码计算中排除掉一个对象的关键部分
来提高性能,这样虽然能更快但可能会导致更多的Hash碰撞
。equals()
方法,需要遵守自反性
、对称性
、传递性
、一致性
以及对于任何非null的引用值x,x.equals(null)必须返回false
的这几个特性,目的是为了保证key在哈希表中的唯一性
。 答:hashCode()
方法返回的是int
整数类型,其范围为-(2 ^ 31)~(2 ^ 31 - 1)
,约有40亿个
映射空间,而HashMap
的容量范围是在16(初始化默认值)~2 ^ 30
,HashMap通常情况下是取不到最大值的,并且设备上也难以提供这么多的存储空间,从而导致通过hashCode()计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置。
那怎么解决呢?
HashMap
自己实现了自己的hash()
方法,通过两次扰动
使得它自己的哈希值高低位自行进行异或运算
,降低哈希碰撞概率
也使得数据分布更平均。
数组长度为2的幂次方
的时候,使用hash()运算之后的值与运算(&)(数组长度 - 1)
来获取数组下标的方式进行存储,这样一来是比取余
操作更加有效率,二来也是因为只有当数组长度为2的幂次方
时,hash%length才等价于hash&(length-1)
,三来解决了“哈希值与数组大小范围不匹配”的问题。为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树长度大致相同。这个实现就是把数据存到哪个链表/红黑树中的算法。
这个算法应该如何设计呢?
我们首先可能会想到采用%取余
的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。
” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。
答:这样就是加大哈希值低位的随机性
,使得分布更均匀
,从而提高对应数组存储下标位置的随机性&均匀性
,最终减少Hash冲突
,两次就够了,已经达到了高位低位
同时参与运算的目的。
提供 4 个构造方法
)。默认为 16
,如果自定义初始容量
,那么会处理成最小的不小于指定的容量的 2 的次幂数
,注意 HashMap 的容量一定会是 2 的幂次方
)。上一次容量的 2 倍
,如果当前元素数目达到扩容阀值(扩容阀值=负载因子 * 当前 HashMap 总容量
),进行扩容,同时将扩容阈值更新为扩容后的容量大小*负载因子
)。默认 0.75
)。Integer.MAX_VALUE - 8
)等价于1<<30
。1(2^0)
)。8
。6
。这个类名字里面有个 “Tree”,难道又是和树相关?没错,这个具体类就是依赖于红黑树
构建的,可能有小伙伴会说了,怎么又是红黑树
啊,其实红黑树是一种很有用的数据结构,只是维护的时候比一般的二叉搜索树
复杂一点(主要是为了维护高度平衡以保证较高的查找效率
),关于代码中怎么去维护它的高度我们就不去深追究了,为了篇幅简洁,同 HashMap
一样,我们在这里主要分析对键值对元素的相关操作原理和一些 TreeMap
特有的性质。先看看它的源码声明:
public class TreeMap<K,V>
extends AbstractMap<K,V>
implements NavigableMap<K,V>, Cloneable, java.io.Serializable {
// ...
}
TreeMap
本身继承自AbstractMap
抽象类,实现了NavigableMap
接口,这个NavigableMap
接口实际上是继承了SortedMap
接口,并声明了几个额外的方法。
下面看看 TreeMap
的相关字段属性:
/**
* 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;
// 当前 TreeMap 中节点(即键值对元素,下同)的数目
private transient int size = 0;
// 当前 TreeMap 结构化修改(插入、删除元素...)的次数
private transient int modCount = 0;
这里再说一下
comparator
属性,我们知道:TreeMap
的实现原理是红黑树
,即为一种二叉搜索树
,为了简单起见,我们后面就把它当成二叉搜索树
处理,二叉搜索树
本身有一个特点:节点的左子树中的节点值都小于该节点值,而节点的右子树节点值都大于该节点值
,那么我们在插入元素的时候如何判断插入的节点值和当前的节点值的大小呢?其实就是利用这个comparator
字段,说白了,这个对象的任务就是为了判断出两个节点的大小关系的。
Comparator
是提供了一个泛型参数的接口,我们来看看这个接口的定义:
public interface Comparator<T> {
/**
* @param o1 the first object to be compared.
* @param o2 the second object to be compared.
* @return a negative integer, zero, or a positive integer as the
* first argument is less than, equal to, or greater than the
* second.
* 比较两个对象的大小,如果 o1 大于 o2,返回大于 0 的值,如果 o1 等于 o2,返回 0,
* 如果 o1 小于 o2,返回小于 0 的值
*/
int compare(T o1, T o2);
// ...
}
这个接口正好提供了一个方法,用于
比较两个对象的大小
。
接下来看看TreeMap
是用什么数据结构来描述键值对元素
的:
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
// 左右子节点和父节点
Entry<K,V> left;
Entry<K,V> right;
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;
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
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());
}
public int hashCode() {
int keyHash = (key==null ? 0 : key.hashCode());
int valueHash = (value==null ? 0 : value.hashCode());
return keyHash ^ valueHash;
}
public String toString() {
return key + "=" + value;
}
}
我们上面已经说过了
TreeMap
是基于红黑树
的原理实现的,类比树节点的所需的字段,我们很好理解这个Entry
类。下面来看看TreeMap
的构造方法:
public TreeMap() {
comparator = null;
}
public TreeMap(Comparator<? super K> comparator) {
this.comparator = comparator;
}
public TreeMap(SortedMap<K, ? extends V> m) {
// 遵从指定的 SortedMap 的排序规则
comparator = m.comparator();
try {
buildFromSorted(m.size(), m.entrySet().iterator(), null, null);
} catch (java.io.IOException cannotHappen) {
} catch (ClassNotFoundException cannotHappen) {
}
}
public TreeMap(Map<? extends K, ? extends V> m) {
comparator = null;
putAll(m);
}
仍然是 4 个构造方法,第一个和第二个构造方法没有什么讲的,我们先看看第三个构造方法,很明显这个构造方法用于创建一个具有和参数指定的
SortedMap
相同元素的TreeMap
对象,实现上主要是通过调用buildFromSorted
方法,我们来看看这个方法:
private void buildFromSorted(int size, Iterator<?> it,
java.io.ObjectInputStream str,
V defaultVal)
throws java.io.IOException, ClassNotFoundException {
this.size = size;
root = buildFromSorted(0, 0, size-1, computeRedLevel(size),
it, str, defaultVal);
}
这里调用了
buildFromSorted
参数重载的一个方法,并把返回值赋值给了root
,可以猜到这个是一个构建二叉树
(其实就是构建红黑树)的过程,我们继续跟进去:
private final Entry<K,V> buildFromSorted(int level, int lo, int hi,
int redLevel,
Iterator<?> it,
java.io.ObjectInputStream str,
V defaultVal)
throws java.io.IOException, ClassNotFoundException {
/**
* 1、这其实是一个递归构建平衡二叉树(红黑树)的过程,
* 在这个过程中,我们先需要构建出左子树并建立当前节点与左子树的父子关系,
* 然后再构建出右子树并建立当前节点与右子树的父子关系系,
* 最后再返回这个节点,显然,第一次调用这个方法时返回的节点即为整个树的根节点 root。
* 2、我们已经知道这个方法调用的时候传入的是一个 SortedMap 的元素集合的迭代器,
* 而本身 SortedMap 中的元素按照迭代器访问的时候是按照某种规则排好序的,即这个序列是有序的,
* 那么我们在构建平衡二叉树(红黑树)的时候自然要将当前节点的左右子树的高度差不超过 1 ,
* 即在构造的时候我们应该取中间元素作为当前的树的根结点,对于左右子树亦是如此
*/
if (hi < lo) return null;
// 选取中间节点作为左右子树的根结点
int mid = (lo + hi) >>> 1;
Entry<K,V> left = null;
if (lo < mid)
// 如果中间节点的左边有节点,那么先递归构造左子树
left = buildFromSorted(level+1, lo, mid - 1, redLevel,
it, str, defaultVal);
// extract key and/or value from iterator or stream
// 取迭代器元素的键和值用于创建节点
K key;
V value;
// 从迭代器中取
if (it != null) {
if (defaultVal==null) {
Map.Entry<?,?> entry = (Map.Entry<?,?>)it.next();
key = (K)entry.getKey();
value = (V)entry.getValue();
} else {
key = (K)it.next();
value = defaultVal;
}
// 从流中读取
} else { // use stream
key = (K) str.readObject();
value = (defaultVal != null ? defaultVal : (V) str.readObject());
}
// 利用取到的键和值创建当前树的根结点(中间节点)
Entry<K,V> middle = new Entry<>(key, value, null);
// color nodes in non-full bottommost level red
if (level == redLevel)
middle.color = RED;
// 如果构造出来的左子树不为 null,那么建立当前根结点和左子树的父子关系
if (left != null) {
middle.left = left;
left.parent = middle;
}
if (mid < hi) {
// 如果中间节点的右边有节点,那么递归构造右子树
Entry<K,V> right = buildFromSorted(level+1, mid+1, hi, redLevel,
it, str, defaultVal);
// 建立当前根结点和右子树的父子关系
middle.right = right;
right.parent = middle;
}
// 返回建立的树的根结点
return middle;
}
OK,我们将递归构造
平衡二叉树
的过程捋了一遍,第三个构造方法也就结束了,最后是第四个构造方法,可以看到直接调用了putAll
方法:
public void putAll(Map<? extends K, ? extends V> map) {
int mapSize = map.size();
// 如果当前 map 对象是 SortedMap 类型的对象,
// 证明通过迭代器访问它的元素可以得到一个有序的元素序列,
// 同样的使用 buildFromSorted 方法创建红黑树
if (size==0 && mapSize!=0 && map instanceof SortedMap) {
Comparator<?> c = ((SortedMap<?,?>)map).comparator();
if (c == comparator || (c != null && c.equals(comparator))) {
++modCount;
try {
buildFromSorted(mapSize, map.entrySet().iterator(),
null, null);
} catch (java.io.IOException cannotHappen) {
} catch (ClassNotFoundException cannotHappen) {
}
return;
}
}
// 否则利用调用父类的 putAll 方法
super.putAll(map);
}
我们再一次看看其父类( AbstractMap
)的 putAll
方法:
public void putAll(Map<? extends K, ? extends V> m) {
for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
put(e.getKey(), e.getValue());
}
很简单的逻辑,对每个元素调用了
put
方法,由于多态
的特性,它会调用子类(即 TreeMap )的 put 方法
,那么我们再在TreeMap
中看一下其put
方法:
public V put(K key, V value) {
Entry<K,V> t = root;
if (t == null) {
// 这一句是为了防止 key 为 null 的情况
compare(key, key); // type (and possibly null) check
// 如果当前 root 字段为 null,那么先创建 root 节点
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
int cmp;
Entry<K,V> parent;
// split comparator and comparable paths
Comparator<? super K> cpr = comparator;
// 如果当前 TreeMap 指定的 Comparator 对象不为 null,
// 那么使用 TreeMap 的 Comparator 进行比较
if (cpr != null) {
do {
parent = t;
cmp = cpr.compare(key, t.key);
// 结果小于 0 则作为左子结点
if (cmp < 0)
t = t.left;
// 大于 0 则作为右子节点
else if (cmp > 0)
t = t.right;
// 等于 0 则认为是键冲突,直接更新值并返回旧值
else
return t.setValue(value);
} while (t != null);
}
// 否则则调用 key 对象的 compareTo 方法比较(key 的类型必须实现 Comparable 接口)
else {
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
// 小于 0 则作为左子结点,大于 0 则作为右子节点,等于 0 则直接更新值并返回旧值
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
// 新建节点来储存这个键值信息
Entry<K,V> e = new Entry<>(key, value, parent);
// 建立父子关系
if (cmp < 0)
parent.left = e;
else
parent.right = e;
// 调整红黑树,保证其高度平衡
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
好了,看完了这个方法意味着我们把
TreeMap
的构造方法看完了,下面来看看get
方法吧,其实如果你熟悉红黑树
的话,基本上就能猜到get
方法是怎么实现的了:
public V get(Object key) {
Entry<K,V> p = getEntry(key);
return (p==null ? null : p.value);
}
看来是通过 getEntry
方法实现的,那么继续跟进:
final Entry<K,V> getEntry(Object key) {
// Offload comparator-based version for sake of performance
if (comparator != null)
// 如果当前 TreeMap 指定的 Comparator 对象不为 null,
// 那么通过当前 Treemap 的 Comparator 对象来进行元素大小比较
return getEntryUsingComparator(key);
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
Entry<K,V> p = root;
// 循环向下查找,如果要查找的 key 小于当前节点,
// 那么向左子树继续查找,如果要查找的 key 大于当前节点,
// 那么向右子树继续查找,否则的话证明找到了,返回当前节点
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
// 没找到返回 null
return null;
}
当
TreeMap
的Comparator
对象不为null
的时候是通过getEntryUsingComparator
方法查找的,那么继续看一下这个方法:
final Entry<K,V> getEntryUsingComparator(Object key) {
@SuppressWarnings("unchecked")
K k = (K) key;
Comparator<? super K> cpr = comparator;
if (cpr != null) {
Entry<K,V> p = root;
// 同样的,小于向左子树找,大于向右子树找,等于证明找到了,直接返回
while (p != null) {
int cmp = cpr.compare(k, p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
}
// 没找到返回 null
return null;
}
到了这里,我们把
get
方法也看完了,我想你也明白了为什么get
方法查找值的时间复杂度为O(logn)
了,因为 get 方法的时间复杂度主要取决于 while 循环的执行次数
,很明显,这里的while 循环的执行次数为树的高度,即 logn
。最后,来看一下移除元素的方法:
public V remove(Object key) {
// 现寻找要删除的节点,找到了进行删除,没找到就直接返回
Entry<K,V> p = getEntry(key);
if (p == null)
return null;
V oldValue = p.value;
// 进行节点的删除并调整树的结构以保证红黑树的高度平衡
deleteEntry(p);
// 返回删除的节点的 “值”
return oldValue;
}
好了,remove
方法的流程上就到这里了,这里就不过细的介绍红黑树平衡
的维护具体过程了,想要了解的小伙伴们可以参考本博主的这篇博客:sff。
在元素遍历方面, TreeMap
和 HashMap
提供的遍历元素的方法还是差不多的:通过 entrySet
、keySet
、forEach(JDK 1.8)
方法,这里就不列举了,有兴趣的小伙伴参考一下 HashMap 部分的元素遍历
: HashMap遍历元素详解。
我们已经知道
TreeMap默认会依据键值对元素的键来对元素进行排序
。我们也可以通过自定义的Comparator
接口对象来指定其对键的排序方式
,那么可不可以通过指定对元素的值的排序方式来对元素进行排序呢
?答案是可以的
,不过需要动一点脑筋:我们可以利用TreeMap
会利用键来对键值对元素
进行排序
的特点,来自定义一个“键的包装类”
来作为新的键,我们就叫它KeyWrap
吧,这个KeyWrap
内部有两个引用
,分别指向原本的Key
和Value
两个属性,我们使得这个类实现Comparable
接口,并且重写其compareTo
方法,这个方法直接调用Value
的compareTo 方法
作为返回值。同时,因为TreeMap
本身需要用到Key 的 equals 方法
来进行键的等价比较,因此我们实现这两个方法并且调用对应键的方法来作为返回值。
好了,思路就到这里了,下面来看看代码:
import java.util.Map;
import java.util.TreeMap;
public class CustomTreeMapSortMethod {
// 自定义的描述键的类
static class MyKey {
int i;
MyKey(int i) {
this.i = i;
}
@Override
public int hashCode() {
return i;
}
@Override
public boolean equals(Object obj) {
return obj == this || obj instanceof MyKey && ((MyKey) obj).i == i;
}
@Override
public String toString() {
return String.valueOf(i);
}
}
// 自定义的描述值的类,实现 Comparable 接口来自定义排序方式
static class MyValue implements Comparable<MyValue> {
char c;
MyValue(char c) {
this.c = c;
}
// 按属性 c 的值从小到大排序
@Override
public int compareTo(MyValue o) {
if (o == null) {
throw new IllegalArgumentException("The argument other can not be null!");
}
return c - o.c;
}
@Override
public String toString() {
return String.valueOf(c);
}
}
// 自定义的 KeyWrap 类,实现 Comparable 接口来自定义排序方式
static class KeyWrap implements Comparable<KeyWrap> {
// 指向 key 的引用,这里也可以采用直接继承 MyKey 类的方法,
// 这样的可以不用重写 equals 方法
MyKey key;
MyValue val; // 指向 value 的引用
KeyWrap(MyKey key, MyValue val) {
this.key = key;
this.val = val;
}
int compare(KeyWrap other) {
if (other == null) {
throw new IllegalArgumentException("The argument other can not be null!");
}
// 因为要依据“值”来进行排序,所以返回 “值”比较的结果
return val.compareTo(other.val);
}
@Override
public int compareTo(KeyWrap o) {
return compare(o);
}
// 因为是自定义的 KeyWrap ,真正的 key 还是 MyKey 类对象,
// 所以 equals 方法还得用 MyKey 类的对象来进行比较,即 key 属性
@Override
public boolean equals(Object obj) {
return obj == this || obj instanceof KeyWrap && ((KeyWrap) obj).key.equals(key);
}
}
public static void main(String[] args) {
int eleLen = 10;
// 注意这里使用的 KeyWrap 类型来作为键
TreeMap<KeyWrap, MyValue> map = new TreeMap<>();
MyKey[] keys = new MyKey[eleLen];
MyValue[] values = new MyValue[eleLen];
KeyWrap[] keyWraps = new KeyWrap[eleLen];
for (int i = 0; i < eleLen; i++) {
keys[i] = new MyKey(eleLen - 1 - i);
values[i] = new MyValue((char) (i + '0'));
keyWraps[i] = new KeyWrap(keys[i], values[i]);
map.put(keyWraps[i], values[i]);
}
for (Map.Entry<KeyWrap, MyValue> entry : map.entrySet()) {
System.out.println("key: " + entry.getKey().key + ", value: " + entry.getValue());
}
}
}
来看看结果:
OK,成功的验证了我们的想法,代码中也给出了详细的注释,借助这个思想,我们完全可以通过继承 TreeMap来封装一个按照值来对元素进行排序的 ValueSortTreeMap
,有兴趣的小伙伴们可以自己尝试实现一下。
对于在Map中插入
、删除
和定位元素
这类操作,HashMap
是最好的选择。然而,假如你需要对一个有序的key集合进行遍历
,TreeMap
是更好的选择。基于你的collection的大小,也许向HashMap中添加元素会更快,将map换为TreeMap
进行有序key的遍历
。
TreeSet
要求存放的对象所属的类
必须实现 Comparable
接口,该接口提供了比较元素的 compareTo()
方法,当插入元素时会回调该方法比较元素的大小。TreeMap
要求存放的键值对映射的键
必须实现 Comparable
接口从而根据键对元素进行排序。
Collections
工具类的 sort 方法
有两种重载的形式:
Comparable
接口以实现元素的比较。Comparator 接口的子类型(需要重写 compare 方法实现元素的比较
),相当于一个临时定义的排序规则,其实就是通过接口注入
比较元素大小的算法,也是对回调模式的应用(Java 中对函数式编程
的支持)。TreeMap
最大的特点是能根据插入的键值对的键来对键值对元素节点进行排序
,而当我们遍历 TreeMap
对象的时候取得的元素顺序是按照某个规则来进行排序
的,具体规则我们可以在创建 TreeMap
对象的实现传入一个 Comparator 对象
的参数来进行指定。需要注意的是:如果没有指定 TreeMap 的 Comparator 对象
,那么需要保证TreeMap
储存的键值对元素的 “键”
是实现了Comparable
接口的,否则会报类型转换异常(ClassCastException)
。 HashMap
来说,TreeMap
没有什么初始容量
和负载因子
的概念,因为它是用的是红黑树
这种数据结构,即为动态申请内存空间(插入一个元素就申请一个元素的内存空间)
,也因为如此,其插入元素和查询元素的时间复杂度均为 O(logn)
,即为树的高度
,n 为 TreeMap 中节点数
。回想我们使用 HashMap 时,元素的遍历顺序和插入顺序是不一定相同的
,通过前篇的源码解读我们也知道了原因:HashMap 内部使用键值对数组来储存元素,对于每一个键值对,其在数组中的下标完全取决于其键的哈希值(hashCode)
,而我们在通过迭代器遍历 HashMap 的时候实际上相当于顺序遍历其内部的元素储存数组。那么如果我们需要使得元素的遍历顺序和插入顺序相同时 HashMap 就不能很好的实现这个功能了。这个时候就可以通过 LinkedHashMap 来完成这个功能
。下面我们一起来看看这个类:
public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V> {
/**
* LinkedHashMap 中表示键值对元素节点的类,继承于 HashMap.Node
*/
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after; // 当前键值对元素节点的前继和后继节点
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
// 和序列化和反序列化有关,暂时不管
private static final long serialVersionUID = 3801124242820219131L;
/**
* LinkedHashMap 中双向链表的头结点(首元结点)
*/
transient LinkedHashMap.Entry<K,V> head;
/**
* LinkedHashMap 中双向链表的尾节点
*/
transient LinkedHashMap.Entry<K,V> tail;
/**
* 链表元素的排序依据:按访问顺序排序(true),按插入顺序排序(false)
*/
final boolean accessOrder;
// ...
}
从这里我们大概可以知道了,
LinkedHashMap 内部通过双向链表来维持元素的顺序
,同时其继承于 HashMap
,因此可以猜测LinkedHashMap 的一些操作时复用父类
的。
而查看 LinkedHashMap
的结构,发现很多对元素的操作方法都没有直接提供:
可以看到,类似于
put
和remove
方法都没有在LinkedHashMap
中提供,但是我们在使用LinkedHashMap
的时候都是直接使用这些方法来操作元素,那么很显然其是复用了父类(HashMap) 的相关方法
。
如此一来,新的问题又产生了:HashMap 本身在操作元素(插入、删除)时候是并没有考虑元素的插入顺序的,其是通过要插入的键值对元素的键的hashCode (哈希值)来决定元素的插入位置,那么 LinkedHashMap 是怎么实现元素访问顺序和插入顺序相同的功能呢?
对于上面的问题,我们还是看看 HashMap
的源码:
// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }
可以发现,
HashMap
中提供了 3 个方法供LinkedHashMap
重写,在 HashMap 的putVal(HashMap 的 put 方法中会调用)
方法中还会调用newNode
和newTreeNode
方法,我们来看看:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
// !!这里
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
// 如果是树节点,那么创建 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);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// ...
在上述源码中:
// 如果是树节点,那么创建 TreeNode
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
putTreeVal
方法中会调用newTreeNode
方法,显然,这两个方法(newNode、newTreeNode)都是用来新建键值对元素
的,关于上述流程如果小伙伴们还不清楚,可以参考上面对HashMap
的讲解: HashMap
而对于这 5 个方法
,LinkedHashMap
中也对他们进行了重写
:
顺便提一下,这
5
个方法在HashMap
中都是默认修饰符
的,我们知道,默认修饰符的属性只能被同一个类文件或者同一个包中的其他类访问,子类是没办法访问的(没有可见性)
,这里LinkedHashMap 是 HashMap 的子类
,从这个角度上来说,其是没有对这 5 个方法的访问权的
(可以理解为它根本看不到父类的这 5 个方法),但是它还有另外一重身份
:和 HashMap 同包(HashMap 和 LinkedHashMap 都是在 java.utl 包中)
,因此从这方面来说,其可以对这 5 个方法进行重写
。
那么我们看一下这 5
个方法在 LinkedHashMap
中的源码,先是 newNode
:
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
linkNodeLast(p);
return p;
}
这个方法新建的是
LinkedHashMap 提供的表示双向链表节点的类对象
,之后调用了linkNodeLast
方法来连接这个新建的链表元素,不用看我们也知道这个方法将新建的节点连接到链表尾部
:
// link at the end of list
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
// 如果当前链表的尾节点为 null,证明当前链表还没有元素,
// 因此将 head 赋值为 p,这里换成 head == null 判断也可以,写法不同而已。
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
}
这样一来在调用
LinkedHashMap 的 put 方法
(实际上调用的是HashMap 的 put 方法
)时 LinkedHashMap 就可以初步保证元素的顺序和插入顺序是相同的(put -> putVal -> newNode -> linkNodeLast
)。
为什么是初步保证?因为能改变链表的还有删除元素
的操作呀。那么我们来看看 HashMap 的 remove 方法
:
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
可以看到,直接调用了 removeNode
方法:
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
// !! 这里
afterNodeRemoval(node);
return node;
}
}
return null;
}
在移除元素之后,
HashMap
调用了afterNodeRemoval
方法,这不就是在LinkedHashMap 中重写方法
吗:
// LinkedHashMap 类的方法:
void afterNodeRemoval(Node<K,V> e) { // unlink
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.before = p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a == null)
tail = b;
else
a.before = b;
}
不用看我们也能猜到:
既然移除了一个元素,自然要把这个元素从链表中移除,这样才能维护链表顺序的正确性。
好了,通过这里的几个方法,LinkedHashMap 就可以保证链表中元素的顺序是按照插入元素的顺序来排序的
。到这里可能又有小伙伴会问了:那么还有两个方法(afterNodeInsertion
、afterNodeAccess
)呢?还记得在开头给大家介绍 LinkedHashMap
的源码构成中有一个 accessOrder
属性吗?这两个方法就是和这个属性有关,这里允许我小小的买个官子。
我们先来看 LinkedHashMap 是怎么遍历元素的
,之后在来看这两个方法:
和其他 Map 一样,LinkedHashMap 也是通过迭代器(Iterator)来遍历元素
的,当然,在以迭代器作为基础的情况下,其为我们提供了两种方式来遍历元素:
// 得到键的集合,之后通过 get 方法取到对应值
public Set<K> keySet() {
Set<K> ks;
return (ks = keySet) == null ? (keySet = new LinkedKeySet()) : ks;
}
// 得到键值对的集合,之后通过 getKey() 和 getValue() 方法得到键值
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new LinkedEntrySet()) : es;
}
可以看到:两个方法分别返回了一个
LinkedKeySet
对象和LinkedEntrySet
对象,我们来看看这两个类:
LinkedKeySet:
final class LinkedKeySet extends AbstractSet<K> {
public final int size() { return size; }
public final void clear() { LinkedHashMap.this.clear(); }
// 返回了一个 LinkedKeyIterator 迭代器对象
public final Iterator<K> iterator() {
return new LinkedKeyIterator();
}
public final boolean contains(Object o) { return containsKey(o); }
public final boolean remove(Object key) {
return removeNode(hash(key), key, null, false, true) != null;
}
public final Spliterator<K> spliterator() {
return Spliterators.spliterator(this, Spliterator.SIZED |
Spliterator.ORDERED |
Spliterator.DISTINCT);
}
public final void forEach(Consumer<? super K> action) {
if (action == null)
throw new NullPointerException();
int mc = modCount;
for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after)
action.accept(e.key);
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
LinkedEntrySet:
final class LinkedEntrySet extends AbstractSet<Map.Entry<K,V>> {
public final int size() { return size; }
public final void clear() { LinkedHashMap.this.clear(); }
// 返回了一个 LinkedEntryIterator 对象
public final Iterator<Map.Entry<K,V>> iterator() {
return new LinkedEntryIterator();
}
public final boolean contains(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry<?,?>) o;
Object key = e.getKey();
Node<K,V> candidate = getNode(hash(key), key);
return candidate != null && candidate.equals(e);
}
public final boolean remove(Object o) {
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>) o;
Object key = e.getKey();
Object value = e.getValue();
return removeNode(hash(key), key, value, true, true) != null;
}
return false;
}
public final Spliterator<Map.Entry<K,V>> spliterator() {
return Spliterators.spliterator(this, Spliterator.SIZED |
Spliterator.ORDERED |
Spliterator.DISTINCT);
}
public final void forEach(Consumer<? super Map.Entry<K,V>> action) {
if (action == null)
throw new NullPointerException();
int mc = modCount;
for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after)
action.accept(e);
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
可以看到,两个类返回了两个迭代器对象
,我们来看看这两个类:
final class LinkedKeyIterator extends LinkedHashIterator implements Iterator<K> {
public final K next() { return nextNode().getKey(); }
}
final class LinkedEntryIterator extends LinkedHashIterator implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
都继承于 LinkedHashIterator
类,并且都调用了 nextNode
方法,那么来看看吧(LinkedHashIterator
中定义的方法):
final LinkedHashMap.Entry<K,V> nextNode() {
LinkedHashMap.Entry<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
current = e;
next = e.after;
return e;
}
这个方法就很容易理解,就是根据已经有的双向链表的来顺序遍历元素
。至此 LinkedHashMap 储存元素的方式保持元素的遍历顺序和插入顺序相同的元素原理分析完毕
。最后配张图来加深理解一下:
最后,来看一下上面说的 accessOrder
属性,LinkedHashMap 的构造支持我们对这个属性进行赋值
:
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
那么这个值到底有什么作用呢?我们通过实验来看一下:
public class Main {
public static void main(String[] args) {
int capacity = 4;
LinkedHashMap<String, Integer> linkedHashMap =
new LinkedHashMap<>(capacity, 0.75f, true);
for (int i = 0; i < capacity; i++) {
linkedHashMap.put(i + "", i);
}
System.out.println("第一次遍历:");
Set<Map.Entry<String, Integer>> set = linkedHashMap.entrySet();
for (Map.Entry e : set) {
System.out.println(e.getKey() + ", " + e.getValue());
}
// 这里读取了一次元素值
linkedHashMap.get("0");
linkedHashMap.get("1");
System.out.println("第二次遍历:");
for (Map.Entry e : set) {
System.out.println(e.getKey() + ", " + e.getValue());
}
}
}
可以看到,
第二次遍历时,元素顺序不是我们插入的顺序了,“0” 和 “1” 对应的元素被放到后面去了
,由此我们也知道了,accessOrder 为 true 时会将已经访问的元素放到链表末尾
。在LinkedHashMap
对应的实现方法如下(从 HashMap 中继承而来
):
// 该方法只是改变了双向链表中节点的顺序,将节点移至链表尾部
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
最后,上面的 LinkedHashMap
中重写的 HashMap
的 5 个方法
中剩下最后一个 afterNodeInsertion 方法
了,我们来看看:
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
// 移除链表最老的节点(因为采用尾插法建立双向链表,因此头结点是最老的节点)
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
这个方法在插入元素之后(
putVal 之中会调用,即在元素插入完成之后会调用
),并且传递的参数evict 值为 true
,这里面将removeEldestEntry 方法的返回值作为一个条件判断
,看看这个方法返回什么:
/**
* Returns true if this map should remove its eldest entry.
* This method is invoked by put and putAll after
* inserting a new entry into the map. It provides the implementor
* with the opportunity to remove the eldest entry each time a new one
* is added. This is useful if the map represents a cache: it allows
* the map to reduce memory consumption by deleting stale entries.
*
* Sample use: this override will allow the map to grow up to 100
* entries and then delete the eldest entry each time a new entry is
* added, maintaining a steady state of 100 entries.
*
* private static final int MAX_ENTRIES = 100;
*
* protected boolean removeEldestEntry(Map.Entry eldest) {
* return size() > MAX_ENTRIES;
* }
*
*
* This method typically does not modify the map in any way,
* instead allowing the map to modify itself as directed by its
* return value. It is permitted for this method to modify
* the map directly, but if it does so, it must return
* false (indicating that the map should not attempt any
* further modification). The effects of returning true
* after modifying the map from within this method are unspecified.
*
*
This implementation merely returns false (so that this
* map acts like a normal map - the eldest element is never removed).
*/
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
这个方法直接返回了
false
,并且它是protected 修饰
的,因此可以被子类重写
,假设我们自定义一个子类并且将这个方法重写返回 true
的话,在上面的代码中就会调用(当链表头结点不为 null 时
)removeNode 将新添加的节点移除
,这样的话LinkedHashMap 就是一个没有任何元素的空链表
,来看看实践代码:
public class Main {
static class MyLinkedHashMap<K, V> extends LinkedHashMap<K, V> {
MyLinkedHashMap(int capacity) {
super(capacity);
}
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return true;
}
}
public static void main(String[] args) {
int capacity = 4;
LinkedHashMap<String, Integer> linkedHashMap =
new MyLinkedHashMap<>(capacity);
for (int i = 0; i < capacity; i++) {
linkedHashMap.put(i + "", i);
}
Set<Map.Entry<String, Integer>> set = linkedHashMap.entrySet();
for (Map.Entry e : set) {
System.out.println(e.getKey() + ", " + e.getValue());
}
}
}
确实证实了我们的想法,至于这个有什么作用,根据
removeEldestEntry
的介绍来看,主要是用于当LinkedHashMap 作为缓存映射时,可以节省内存而设计的
。
其实我们熟悉的 LRU 缓存算法
就可以通过 LinkedHashMap
中提供的 accessOrder
和 removeEldestEntry
方法来实现,关于 LRU 算法
的相关介绍小伙伴们可以参考这篇文章:缓存淘汰算法–LRU算法。
我们知道 LRU 算法的缓存的思想是每次有新元素加入
时,淘汰最近最少被使用的元素
。其核心思想是 如果数据最近被访问过
,那么将来被访问的几率也更高
。也就是说每当元素被访问时,LRU 就将该元素移至缓存队列顶部,而每次如果需要淘汰元素时,LRU 将缓存队列底部的元素淘汰
。而在 LinkedHashMap
中,我们可以通过accessOrder
属性来控制将每次访问的元素移至链表尾部
,通过 removeEldestEntry
方法来控制是否移除链表头部节点
,只是将链表尾部看成了 LRU 中缓存队列的顶部,将链表头部看成了 LRU 中缓存队列的底部
。关于通过 LinkedHashMap 实现 LRU 的具体代码可以参考:缓存算法的实现 。
LinkedHashMap
的相关介绍就到这里了,下面来看看 Hashtable
的实现机制:
这个类其实已经被标为遗留类(Legacy)
,也就是说这个类已经不建议使用了。这里还是简单介绍一下这个类。其实我一直对这个类的类名有些见解:按照驼峰式命名
的方法,其应该是 HashTable
,但是它现在就是被命名为了 Hashtable
。这个类类似于 HashMap
,不过它相对于 HashMap
而言其中的相关操作元素的方法名前多用了一个 synchronized
关键字修饰,也就是说这个类是多线程安全
的,来看看一些方法:
// 使用了 synchronized 关键字修饰,使得方法是线程安全的
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
// 处理当前的 hash 值可能存在 Entry 对象冲突的情况,这里其实就是遍历单链表
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
// 在这个方法中添加新的 Entry 对象并考虑扩容
addEntry(hash, key, value, index);
return null;
}
再看看 addEntry
方法:
// 因为 addEntry 方法的调用方法中已经做了线程同步处理(例如 put 方法),
// 因此这里无需再用 synchronized 关键字修饰
private void addEntry(int hash, K key, V value, int index) {
modCount++;
Entry<?,?> tab[] = table;
if (count >= threshold) {
// Rehash the table if the threshold is exceeded
// 进行扩容,这个方法中每次扩容的容量为之前容量的 2 倍 + 1
rehash();
tab = table;
hash = key.hashCode();
index = (hash & 0x7FFFFFFF) % tab.length;
}
// Creates the new entry.
@SuppressWarnings("unchecked")
Entry<K,V> e = (Entry<K,V>) tab[index];
tab[index] = new Entry<>(hash, key, value, e);
count++;
}
同样的,其内部也提供了 Entry
类来描述键值对元素
:
/**
* Hashtable bucket collision list entry
*/
private static class Entry<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Entry<K,V> next; // hash 值冲突的下一个元素(采用单链表来处理冲突)
protected Entry(int hash, K key, V value, Entry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
// ...
}
好了,这个类的介绍就到这里了,这里总结一下这个类的相关信息:默认
初始容量为 11
,扩容因子为 0.75
,每次扩容后的容量变为之前容量的2 倍 + 1
。上面说这个类已经不被推荐使用了。
那么有什么类可以替代这个类吗?答案是有的。这里介绍两个方法来得到可以保证线程安全的 Map
:
1、通过 Collections
类中的 synchronizedMap
方法来得到一个保证线程安全的 Map
,方法声明如下:
public static
Map synchronizedMap
(Mapm);
这是一个静态
的方法,返回一个线程安全的 Map
,这个方法只是对参数中的 Map 对象进行了一下包装,返回了一个新的 Map 对象
,将参数中的 Map 对象的相关操作方法都通过使用 synchronized
关键字修饰的方法包装了一下,但是具体的操作流程还是和原来的 Map 对象一样
,来看一个方法的源码:
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
可以看到,
m
才是参数指定的Map 对象
,put 方法
是返回的Map
对象的方法。
2、使用 ConcurrentHashMap
类,这个类是 JDK1.5
新增的一个类,可以非常高效的进行相关的元素操作,同时还保证多线程安全
。内部实现非常巧妙,简单来说就是内部有多个互斥锁
,每个互斥锁负责一段区域
,举个例子:
假设现在内部有 100
个元素,即有一个长度为 100 的元素数组
,那么 ConcurrentHashMap
提供了 10
个锁,每个锁负责 10 个元素
(0~9
, 10~19
, …
, 90~99
),每当有线程操作某个元素时
,通过这个元素的键的 hash 值
可以得到其操作的是哪个区域
,之后就锁住对应区域的锁对象
即可,而其他区域的元素依然可以被其他线程访问
。这就好比一个厕所,里面有多个位置,每个位置每次只能有一个人上厕所,但是不能因为这一个人上厕所就把整个厕所给锁掉,所以就是每个位置设置一把锁,对应只负责这个位置即可。
ConcurrentHashMap
和 Hashtable
的区别主要体现在实现线程安全的方式上不同
。
底层数据结构
: JDK1.7
的 ConcurrentHashMap
底层采用 分段的数组+链表
实现,JDK1.8
采用的数据结构跟HashMap1.8
的结构一样,数组+链表/红黑二叉树
。Hashtable
和 JDK1.8 之前的 HashMap
的底层数据结构类似都是采用 数组+链表
的形式,数组是 HashMap
的主体,链表
则是主要为了解决哈希冲突而存在
的。线程安全
的方式(重要):JDK1.7
的时候,ConcurrentHashMap(分段锁)
对整个桶数组
进行了分割分段(Segment)
,每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,比Hashtable效率提高16倍。)
到了 JDK1.8
的时候已经摒弃了Segment
的概念,而是直接用 Node 数组+链表+红黑树
的数据结构来实现,并发控制使用 synchronized
和 CAS
来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本。
Hashtable(同一把锁)
:使用synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
两者的对比图:
Hashtable:
JDK1.7的ConcurrentHashMap:
JDK1.8的ConcurrentHashMap(TreeBin: 红黑二叉树节点 Node: 链表节点):
总结:
ConcurrentHashMap
结合了HashMap
和Hashtable
二者的优势。HashMap 没有考虑同步
,Hashtable 考虑了同步
的问题。但是Hashtable 在每次同步执行时都要锁住整个结构
。ConcurrentHashMap
锁的方式是稍微细粒度
的。
JDK1.7:
首先将数据分为一段一段的存储
,然后给每一段数据配一把锁
,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
在JDK1.7
中,ConcurrentHashMap
采用Segment + HashEntry
的方式进行实现,结构如下:
一个 ConcurrentHashMap
里包含一个 Segment 数组
。Segment
的结构和HashMap
类似,是一种数组和链表
结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个HashEntry数组里的元素
,当对 HashEntry
数组的数据进行修改
时,必须首先获得对应的 Segment的锁。
1、该类包含两个静态内部类
HashEntry
和Segment
;前者用来封装映射表的键值对
,后者用来充当锁的角色
。
2、Segment
是一种可重入的锁 ReentrantLock
,每个Segment
守护一个HashEntry 数组
里得元素,当对HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。
JDK1.8:
在JDK1.8
中,放弃了Segment臃肿
的设计,取而代之的是采用Node + CAS + Synchronized
来保证并发安全进行实现,synchronized
只锁定当前链表
或红黑二叉树
的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。
插入元素过程(建议去看看源码):
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
如果相应位置的
Node还没有初始化
,则调用CAS
插入相应的数据。
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value, null);
break;
}
}
}
1、如果相应位置的
Node不为空
,且当前该节点不处于移动状态
,则对该节点加synchronized锁
,如果该节点的hash不小于0
,则遍历链表更新节点或插入新节点。
2、如果该节点是TreeBin
类型的节点,说明是红黑树
结构,则通过putTreeVal
方法往红黑树
中插入节点
;如果binCount不为0
,说明put
操作对数据产生了影响,如果当前链表的个数达到8个
,则通过treeifyBin
方法转化为红黑树
,如果oldVal不为空
,说明是一次更新
操作,没有对元素个数产生影响
,则直接返回旧值
。
3、如果插入
的是一个新节点
,则执行addCount()
方法尝试更新元素个数baseCount
。
HashMap
是非线程安全
的,Hashtable
是线程安全
的;Hashtable
内部的方法基本都经过 synchronized
修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!
)。线程安全
的问题,HashMap要比 Hashtable 效率高一点。
另外,Hashtable
基本被淘汰
,不要在代码中使用它;HashMap
中,null
可以作为键
,这样的键只有一个
,可以有一个或多个键所对应的值为 null
。但是在 Hashtable 中 put 进的键值只要有一个 null
,直接抛NullPointerException
。Hashtable
默认的初始大小
为11
,之后每次扩充
,容量变为原来的2n+1
。HashMap
默认的初始化大小
为16
。之后每次扩充
,容量变为原来的2倍
。②创建时如果给定了容量初始值
,那么 Hashtable
会直接使用你给定的大小
,而 HashMap
会将其扩充
为2的幂次方大小
。也就是说 HashMap
总是使用2的幂
作为哈希表的大小
,后面会介绍到为什么是2的幂次方。
JDK1.8
以后的 HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)
时,将链表转化为红黑树
,以减少搜索时间
。Hashtable 没有这样的机制。
推荐使用:在 Hashtable
的类注释可以看到,Hashtable
是保留类
不建议使用,推荐在单线程环境
下使用HashMap
替代,如果需要多线程
使用则用 ConcurrentHashMap
替代。
线程安全
,使用全局锁
,所以操作效率较慢。
容量(默认为 11)
。2 倍+1
,如果当前元素数目达到扩容阀值
(负载因子 * 当前 HashMap 总容量
),进行扩容,同时将扩容阈值更新为扩容后的容量大小*负载因子
)。负载因子(默认 0.75 )
。最大容量
大小(Integer.MAX_VALUE - 8
)等价于1<<30
。我们知道, WeakHashMap
是基于弱引用
实现的,在开始看 WeakHashMap
之前,希望小伙伴们对 Java 中的弱引用和引用队列
有一定的了解,如果对弱引用及引用队列
相关的知识点还不太熟悉,可以参考 详解 Java 的四种引用。为了方便理解接下来的内容,这里简单的介绍一下弱引用
的作用:在 Java 中,弱引用是强度次于软引用的一种引用形式,JVM 垃圾回收器(Garbage Collector)在每一次执行垃圾回收动作时会将所有 有且仅有被引用强度不高于弱引用(即弱引用和虚引用) 指向的对象回收
。那么我们很容易知道:一个仅被弱引用指向的对象时是不会导致OutOfMenoryError
异常的。在 JDK 1.2
之后,提供了 WeakReference
类来实现弱引用
,相关源码如下:
public class WeakReference<T> extends Reference<T> {
public WeakReference(T referent) {
super(referent);
}
/**
* Creates a new weak reference that refers to the given object and is
* registered with the given queue.
*
* @param referent object the new weak reference will refer to
* @param q the queue with which the reference is to be registered,
* or null if registration is not required
*/
public WeakReference(T referent, ReferenceQueue<? super T> q) {
super(referent, q);
}
}
可以看到:
WeakReference
类提供了两个构造方法
,其中第二个构造方法
提供了一个ReferenceQueue
类型的参数,顾名思义,这个参数代表的是引用队列
,即指定当前弱引用对象的引用队列
,这个队列有什么用呢?简单来说就是当 JVM 回收某个弱引用指向的对象时,先会将该弱引用加入其构造时指定的引用队列(如果有的话)中去(这个过程由 JVM 的垃圾回收线程完成,无需开发者控制),这样的话我们就可以通过这个引用队列取到对应的引用对象,就可以知道哪个对象被回收了,进而做出对应的处理
。知道了这个概念之后,我们来看看WeakHashMap
是如何利用弱引用
来管理元素的:
先来看看在 WeakHashMap
中如何表示一个键值对
元素:
/**
* The entries in this hash table extend WeakReference, using its main ref
* field as the key.
*/
private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
V value;
final int hash;
// 指向和当前 Entry 具有相同 hash 值的下一个 Entry 对象,
// WeakHashMap 采用链地址法处理 hash 值冲突的情况
Entry<K,V> next;
Entry(Object key, V value,
ReferenceQueue<Object> queue,
int hash, Entry<K,V> next) {
// 注意这里的 super 调用,为 key 对象建立一个弱引用对象指向 key,
// 这样当 key 对象被回收之后,JVM 会将此处指向 key 对象的弱引用加入 queue 引用队列中
super(key, queue);
this.value = value;
this.hash = hash;
this.next = next;
}
// ...
}
WeakHashMap
内部提供了一个继承于WeakReference
的类Entry
来表示一个键值对
元素。而其构造方法中也提供了一个ReferenceQueue
类型的参数,即为指定当前Entry
中key
的引用队列
。而这个方法仅在WeakHashMap
的put
方法中调用:
public V put(K key, V value) {
Object k = maskNull(key);
int h = hash(k);
Entry<K,V>[] tab = getTable();
int i = indexFor(h, tab.length);
for (Entry<K,V> e = tab[i]; e != null; e = e.next) {
// 如果当前的 key 已经存在 table 中,那么直接更新对应的 value
if (h == e.hash && eq(k, e.get())) {
V oldValue = e.value;1
if (value != oldValue)
e.value = value;
return oldValue;
}
}
modCount++;
Entry<K,V> e = tab[i];
// 新建一个 Entry 对象表示键值对元素,同时处理可能存在的 hash 值冲突情况(头插法建立冲突链表)
tab[i] = new Entry<>(k, value, queue, h, e);
// 判断是否需要扩容(当前容量是否达到扩容阀值)
if (++size >= threshold)
// 翻倍扩容
resize(tab.length * 2);
return null;
}
关于
WeakHashMap
如何插入元素这里不再细讲,之前的文章中已经非常详细的讲解了相关的Map
具体类如何进行元素插入。这里创建Entry
对象时传入的引用队列
对象是一个WeakHashMap
的类成员变量:
/**
* Reference queue for cleared WeakEntries
*/
private final ReferenceQueue<Object> queue = new ReferenceQueue<>();
也就是说:
WeakHashMap
中所有的Entry
对象中指向key
对象的弱引用
共用一个引用队列
,既然这样我们可以知道:WeakHashMap
中任何一个Entry
对象中的key
对象将要被回收
时,这里创建的弱引用
对象都会被加入queue 引用队列
中。我们之后就可以从queue 引用队列
中获取到对应的弱引用
。
图中给了一个思考题:当某个
Entry
对象的key
被回收
了,该怎么处理?如果一个Entry
对象的key
被回收
了,证明该Entry
对象已经不再可用
,我们此时显然需要将这个Entry 对象从 Entry 数组(table) 中清除
。这样才能保证整个WeakHashMap
的正确性
。那么我们怎样知道某个Entry
的key
要被回收
了呢?这时候就体现出上面说的引用队列 queue
的用处了,我们可以通过它来获取指向
当前将要被回收的 Entry 对象的 key 对象的弱引用
。那么在WeakHashMap
中是怎么处理的呢?我们来看对应的源码:
/**
* Expunges stale entries from the table.
* 从 table 中清除过期的 Entry 对象
*/
private void expungeStaleEntries() {
/*
通过 ReferenceQueue 的 poll 方法取得当前队列中第一个引用对象并将该引用对象从队列中移除,
如果 queue 中没有引用对象,则返回 null,该方法不会阻塞线程。
这里采用循环则是当 queue 中还存在引用对象时就一直处理 queue 中的引用对象。
*/
for (Object x; (x = queue.poll()) != null; ) {
synchronized (queue) {
@SuppressWarnings("unchecked")
Entry<K,V> e = (Entry<K,V>) x;
// 得到对应 key 的 hash 值
int i = indexFor(e.hash, table.length);
// 通过 key 的 hash 值得到对应的 Entry 对象,即为要清理的 Entry 对象
Entry<K,V> prev = table[i];
Entry<K,V> p = prev;
// 处理可能存在 hash 值冲突的情况,对应于上图中的情况。
while (p != null) {
Entry<K,V> next = p.next;
if (p == e) {
if (prev == e)
table[i] = next;
else
prev.next = next;
// Must not null out e.next;
// stale entries may be in use by a HashIterator
e.value = null; // Help GC
size--;
break;
}
prev = p;
p = next;
}
}
}
}
这个方法的作用就是
清除 WeakHashMap
中所有将要被回收
的key 对象所对应的 Entry 对象
的,即清除无用对象
。这个方法会在三个地方调用:
// 获取储存元素的 table 数组
private Entry<K,V>[] getTable() {
expungeStaleEntries();
return table;
}
// 得到当前 WeakHashMap 中元素的个数
public int size() {
if (size == 0)
return 0;
expungeStaleEntries();
return size;
}
// 扩容
void resize(int newCapacity) {
Entry<K,V>[] oldTable = getTable();
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry<K,V>[] newTable = newTable(newCapacity);
transfer(oldTable, newTable);
table = newTable;
/*
* If ignoring null elements and processing ref queue caused massive
* shrinkage, then restore old table. This should be rare, but avoids
* unbounded expansion of garbage-filled tables.
*/
if (size >= threshold / 2) {
threshold = (int)(newCapacity * loadFactor);
} else {
expungeStaleEntries();
transfer(newTable, oldTable);
table = oldTable;
}
}
Ok,到了这里,相关的流程都跑通了,关于
WeakHashMap
如何取元素
和如何遍历元素
等操作就不再介绍了,这些操作在之前的篇幅中介绍 HashMap 等相关类时已经详细介绍了,小伙伴们可以参考之前的文章。
WeakHashMap
默认的初始容量
是 16
,最大容量为 1 << 30 。
默认扩容因子为 0.75
;可以指定初始容量,但是处理过后的初始容量一定是 2 的次幂
,好处是可以通过 & 运算来代替 % 运算
提高效率;每次扩容时容量翻倍。
节点(Entry)
之间利用单项链表
之间来处理 hash 值冲突。
这个 Map
和我们之前介绍的一些 Map
有较大的区别,当然,总的思想不会变(为了更快的读取键值对元素
)。和之前介绍的 Map
的不同点在于其存取键值对的方式
:我们之前看到的 Map
具体类都会自定义一个名为 Entry 的内部类
来表示储存的键值对元素
,而在 IdentityHashMap
中我们找不到对应的类:
是把类名换了吗?其实并不是,因为 IdentityHashMap
将键对象和值对象
储存在同一个数组中
,我们来看看这个数组
:
// 保存键值对对象的数组,数组大小一定是 2 的次幂
transient Object[] table; // non-private to simplify nested class access
可以看到:
table 数组是 Object 类型
的,意味着这个数组可以储存任何非基本数据类型的对象
。那么IdentityHashMap
是如何根据所给的键得到对应的值得呢?来看看其get
方法:
public V get(Object key) {
// 如果 key 不为 null,则返回 key,否则返回 NULL_KEY,
// NULL_KEY 是一个 Object 对象,即代表 key 为 null 时的键
Object k = maskNull(key);
Object[] tab = table;
int len = tab.length;
// 求出 key 的 hash 值,传入 len 防止得到的 hash 值越界
int i = hash(k, len);
while (true) {
// 获取 key 的 hash 下标在 table 中所对应的对象
Object item = tab[i];
// 如果该对象和当前的 key 是同一个对象,认为值是数组当前下标中下一个对象
if (item == k)
// 这里会有数组下标越界的风险?
return (V) tab[i + 1];
// 如果数组中当前下标的值为 null,则说明该键在当前 Map 中没有对应的值
if (item == null)
return null;
// 如果当前的 item != k,将 i 往后移
i = nextKeyIndex(i, len);
}
}
上述代码中注释已经写得很清楚了,留下了一个疑问:
return (V) tab[i + 1]
; 会有数组下标越界
的可能吗?要解决这个问题,我们得看一下hash
方法和nextKeyIndex
方法的源码,先看看hash
方法的源码:
/**
* Returns index for Object x.
*/
private static int hash(Object x, int length) {
// 这里直接得到的是 x 的 Object 父类对象的 hashCode() 方法的返回值
int h = System.identityHashCode(x);
// Multiply by -127, and left-shift to use least bit as part of hash
return ((h << 1) - (h << 8)) & (length - 1);
}
注意到这里
System.identityHashCode
方法,来看看这个方法的说明:
/**
* Returns the same hash code for the given object as
* would be returned by the default method hashCode(),
* whether or not the given object's class overrides
* hashCode().
* The hash code for the null reference is zero.
*
* @param x object for which the hashCode is to be calculated
* @return the hashCode
* @since JDK1.1
*/
public static native int identityHashCode(Object x);
通过注释我们了解到这个方法的返回值就相当于直接调用
x.hashCode()
的返回值,也就是相当于调用Object.hashCode
方法。而对于Object.hashCode
方法来说,对于不同的对象,其返回值就不一样。回到IdentityHashMap.hash
方法中:得到了key
的hash
之后,返回了((h << 1) - (h << 8)) & (length - 1)
; 的值,我们知道<<
运算符即为左移运算符
,左移 x 位相当于 * 2^x
,所以原式可以写成((h * 2^1) - (h * 2^8)) & (length - 1)
; 而我们知道:任何一个数乘以 2
,得到的结果都是偶数
,那么我们可以认为(h * 2^1) - (h * 2^8) 是一个偶数
,但是结果可能是一个负数,所以接下来进行& (length - 1)
; 运算。我们知道length
即为IdentityHashMa
p 的table数组的长度
,这个值肯定是大于 0
的,而计算机用补码
来表示数字,正数的二进制最高为为 0
,负数的二进制最高位为 1
(最高位为符号位:正数为 0,负数为 1),此时再进行按位与(&)
运算(只要是位运算
都是现将操作数转换成二进制
,再进行相应的运算),按位与
的规则是对两个操作数的二进制位按位比较
,如果两个位的值都是1
,那么结果就是1
,否则为0
,那么可知一个负数
和一个正数
进行& 运算
,得到的值一定是非负数(第一位符号位为 0)
。这是第一个。第二个是可以通过& 来模拟 % 运算
,在这里通过hash
方法得到的值是要作为数组下标
的,那么数组下标
肯定不能越界
,我们可以通过%
来确保值不大于某个数,为什么这里可以通过& 来模拟 %
操作呢?我们注意到上面说过:table
的长度是2 的次幂值
,熟悉二进制的小伙伴知道:2 的次幂值
中化为二进制只有一个1
,举个例子(32 位 int 值):
2^0 = 1; -> 000...(31 个 0)1
2^1 = 2; -> 000...(30 个 0)10
2^2 = 4; -> 000...(29 个 0)100
...
那么将某个 2 的次幂值 - 1,得到的值是什么呢?
2^0 - 1= 0; -> 000...(31 个 0)0
2^1 - 1= 1; -> 000...(30 个 0)01
2^2 - 1= 3; -> 000...(29 个 0)011
此时再进行
按位 &
运算,得到结果的最大值也就是该2 的次幂值 - 1
。而反过来想:%
运算不就是为了让运算的到的值不大于目标数
吗?所以这里的用法很巧妙。那么为什么要用& 来代替 %
呢?因为位运算的效率比 % 高很多。
好了,现在我们应该知道:hash
方法返回的值是一个正数
,也是一个不大于 table.length 的偶数
。又因为 table.length
本身就是一个偶数
,那么 hash
方法得到的值和 table.length
至少相差 2
,也就是说 hash(key, len) <= table.length - 2
; 是成立的。那么回到 IdentityHashMap
的 get
方法:得到的数组下标是小于 table.length 的偶数
,return (V) tab[i + 1]
; 也就不会有越界的风险。下面是 nextKeyIndex
方法:
/**
* Circularly traverses table of size len.
*/
private static int nextKeyIndex(int i, int len) {
return (i + 2 < len ? i + 2 : 0);
}
理解了上面的,这个也就非常理解了:其实就是为了找到
i 的所代表数组下标的下一个键的下标,如果到了数组末尾就从头来过
。下面来看看IdentityHashMap
的put
方法:
public V put(K key, V value) {
final Object k = maskNull(key);
retryAfterResize: for (;;) {
final Object[] tab = table;
final int len = tab.length;
// 得到 key 的 hash 值
int i = hash(k, len);
// 找到 i 下标的对应的 table 数组元素,如果不为 null 则处理 hash 冲突
for (Object item; (item = tab[i]) != null;
i = nextKeyIndex(i, len)) {
if (item == k) {
@SuppressWarnings("unchecked")
// 先保存被替换的旧值
V oldValue = (V) tab[i + 1];
// 旧值换成新值
tab[i + 1] = value;
// 返回被替换的旧值
return oldValue;
}
}
// 如果到了这里说明没有冲突,需要在 table 数组中新加入两个对象(key、value),
// 这时需要考虑扩容
final int s = size + 1;
// Use optimized form of 3 * s.
// Next capacity is len, 2 * current capacity.
if (s + (s << 1) > len && resize(len))
continue retryAfterResize;
modCount++;
// 键值对象赋值
tab[i] = k;
tab[i + 1] = value;
size = s;
return null;
}
}
下面看看 IdentityHashMap
的扩容机制
:
private boolean resize(int newCapacity) {
// assert (newCapacity & -newCapacity) == newCapacity; // power of 2
// 每次扩容数组容量翻倍
int newLength = newCapacity * 2;
Object[] oldTable = table;
int oldLength = oldTable.length;
// 如果当前数组容量达到最大,扩容失败
if (oldLength == 2 * MAXIMUM_CAPACITY) { // can't expand any further
if (size == MAXIMUM_CAPACITY - 1)
throw new IllegalStateException("Capacity exhausted.");
return false;
}
if (oldLength >= newLength)
return false;
Object[] newTable = new Object[newLength];
for (int j = 0; j < oldLength; j += 2) {
Object key = oldTable[j];
if (key != null) {
Object value = oldTable[j+1];
// 将原数组中键值对引用赋为 null,方便 JVM 进行垃圾回收
oldTable[j] = null;
oldTable[j+1] = null;
// 得到当前键在新数组中的下标
int i = hash(key, newLength);
while (newTable[i] != null)
i = nextKeyIndex(i, newLength);
// 找到空位将键值对象插入新的数组中
newTable[i] = key;
newTable[i + 1] = value;
}
}
table = newTable;
return true;
}
OK,整个 IdentityHashMap
的设计思想到这里就很清晰了。
IdentityHashMap·
将键和值都储存在 table 数组
中,读元素的时候通过先通过键的hash值
得到所在数组的下标
,而对应的值的下标为键的下标 + 1。
即存储键值对元素
的方式为table[i]=key,table[i+1]=value
,这样交替存储。
键所在的数组下标一定是偶数(0、2、4…)
,值所在的数组下标一定是奇数(对应的键下标+1)
。同时,存入元素也按照相同的规则。
如果当前元素个数 * 3 > table.length
。那么进行扩容
,扩容是数组容量翻倍。
table 数组的最大容量
是 1 << 29
,最小容量
大小是4
,默认初始容量
为 32。
Array
可以存储基本数据类型
和对象
,ArrayList
只能存储对象。
Array
是指定固定大小
的,而 ArrayList
大小是自动扩展
的。Array
内置方法没有 ArrayList
多,比如 addAll
、removeAll
、iteration
等方法只有 ArrayList
有。对于基本类型数据
,集合使用自动装箱
来减少编码工作量。但是,当处理固定大小
的基本数据类型
的时候,这种方式相对比较慢
。
comparable
接口实际上是出自java.lang
包,它有一个 compareTo(Object obj)
方法用来排序
。comparator
接口实际上是出自java.util
包,它有一个compare(Object obj1, Object obj2)
方法用来排序
。一般我们需要对一个集合
使用自定义排序
时,我们就要重写compareTo
方法或compare
方法,当我们需要对某一个集合
实现两种排序
方式,比如一个song对象
中的歌名
和歌手名
分别采用一种排序方法
的话,我们可以重写compareTo
方法和使用自制的Comparator
方法或者以两个Comparator
来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的Collections.sort()。
java.util.Collection
是一个集合接口
(集合类的一个顶级接口
)。它提供了对集合对象进行基本操作的通用接口方法
。Collection
接口在Java 类库中有很多具体的实现。Collection
接口的意义是为各种具体的集合提供了最大化的统一操作方式
,其直接继承接口有List
与Set
。Collections
则是集合类的一个工具类/帮助类
,其中提供了一系列静态方法
,用于对集合中
元素进行排序
、搜索
以及线程安全
等各种操作。好了,到这里我们已经基本把 Java 中 Map
的具体类介绍完了,关于Map集合线程安全及关于Key和Value能否为null的问题,请参考集合专栏的第一篇博客Java 集合框架 (1)---- 概述,还有个别 Map
具体类会在后面的文章中和其他的知识点一起介绍。
如果博客中有什么不正确的地方,还请多多指点。如果这篇文章对您有帮助,请不要吝啬您的赞,欢迎继续关注本专栏。
谢谢观看。。。
感谢博主大佬,昵称为:Hiro的支持和昵称为:ThinkWon的支持。