HashSet 源代码详解
第一: 构造函数,可以看到其构造函数内部是new了一个HashMap, 所以HashSet 的底层实现是通过Map实现的
public HashSet() {
map = new HashMap<>();
}
第二: add方法,可以看到,它是通过map的put方法放进去的,里面有两个参数,一个是e:我们传进去的对象,另外一个是一个常量对象。
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
再让我们看一下那个常量对象是什么?可以看到是一个static finnal的对象,意味着HashSet的add方法总是将我们传进去的对象作为Key 值,把那个静态的对象作为value值,value值都是一一致的。
private static final Object PRESENT = new Object();
所以我们可以看到它的get方法,(错误,Set没有get方法),所以我们需要查看它的Iterator方法:返回的是map的key值的集合。
public Iterator iterator() {
return map.keySet().iterator();
}
因此分析HashSet 的底层实现就是要搞懂HashMap的底层实现,
HashMap 源代码分析:
HashMap概述:
HashMap基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了不同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
值得注意的是HashMap不是线程安全的,如果想要线程安全的HashMap,可以通过Collections类的静态方法synchronizedMap获得线程安全的HashMap。
Map map = Collections.synchronizedMap(new HashMap());
HashMap的底层实现原理
HashMap的底层主要是基于数组和链表来实现的,它之所以有相当快的查询速度主要是因为它是通过计算散列码来决定存储的位置。HashMap中主要是通过key的hashCode来计算hash值的,只要hashCode相同,计算出来的hash值就一样。如果存储的对象对多了,就有可能不同的对象所算出来的hash值是相同的,这就出现了所谓的hash冲突。学过数据结构的同学都知道,解决hash冲突的方法有很多,HashMap底层是通过链表来解决hash冲突的。
图中,紫色部分即代表哈希表,也称为哈希数组,数组的每个元素都是一个单链表的头节点,链表是用来解决冲突的,如果不同的key映射到了数组的同一位置处,就将其放入单链表中。
需要注意的是,当一个新的对象被加入到数组中去,首先计算出一个数组的地址下标 i ,如果发现i上面已经有了对象,会将i与位于该位置上链表中的所有元素进行比较,如果发现有相同的key ,则进行对象的覆盖,如果没有则会将该对象添加在Entry[i] 位置上,加到链表的头的位置,而不是链接在末尾。这遵循了一个原则,最近加入的最容易被访问。
HashMap 底层数组中所维护的对象类型: Entry
HashMap 在其内部专门定义了一个类,内部类 Entry类,实现了Map.Entry接口, 其内部还拥有着一些方法,主要包含四个元素,
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry next;//对下一个节点的引用(看到链表的内容,结合定义的Entry数组,是不是想到了哈希表的拉链法实现?!)
final int hash;//哈希值
Entry(int h, K k, V v, Entry n) {
value = v;
next = n;
key = k;
hash = h;
}
}
构造函数
第一:首先看一下其构造函数:构造函数中有两个参数,一个是默认的初始化容量,一个是默认的负载因子,可以看到它的默认容量为16,负载因子为0.75,
关于负载因子:我开始不明白负载因子是干啥用的,负载因子是你现在Map中的实际存放对象的个数与它所申请的空间的大小的比值,如果超过这个比值,Map就会自动扩容,自动扩容的时候以2的指数的方式进行扩容,从16-32-64-128……., 所以负载因子就是程序自动扩容的一个临界值。它关系着Map的时间和空间效率均衡。之后会详细介绍
而且可以看到在jdk 1.7中都是使用的移位操作来实现2的指数的乘除操作,这样速度快
需要解释的一点: Map 在传进集合的时候,申请了Entry对象的空间,并将对象放在空间内,在其他三种构造方法中,都没有首先申请放置(key,value)的Entry对象的空间,只是设置了一些参数,只有在使用到了put方法以后,才为Entry分配了地址,这种思想为 懒汉法:只有对象在被使用到的时候才被创建,否则不创建,节省空间。
//几个重要的参数,会大量的用到
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 默认初始化的大小 为16
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认负载因子 为 0.75
int threshold; //扩容的临界值
//HashMap 提供了四种构造方法
//1. 不带参数的构造方法,使用默认值 16 ,0.75
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
//2. 带一个参数的构造方法,自己定义Map长度,0.75
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//3. 带两个参数的构造方法,为什么会提供这个方法,在之后会讲到,这跟Map的空间和时间效率有关系
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
// 在Map中也定义了容量的最大值,跟ArrayList一样
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
this.loadFactor = loadFactor;
// threshold 为临界值,当集合中元素个数达到时就进行2的指数倍扩容
threshold = initialCapacity;
init();
}
// 将整个集合添加到Map中, 这个过程我们好好分析一下,首先传进来有个Map, 我们可以看到它之后调用了上面的带两个参数的构造函数HashMap(int,float),第一个数据是 (程序定义的最大容量)和(map的容量/负载因子 +1)中的最大值,比如m.size()=21, 那么初始的容量就为 21/0.75+1=29, 执行HashMap(29,0.75), 临界值 threshold =29;
public HashMap(Map extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1, DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
// 然后执行是否要扩容的操作,
inflateTable(threshold);
// 将m创建到新的Map中来
putAllForCreate(m);
}
// 扩容函数,为什么数组的长度必须定义为2的整数次幂呢,原因在后面
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize 首先找到一个数据是 2的幂次方 而且 大于toSize, 传进来的是29 意味着得到的capacity 是32
int capacity = roundUpToPowerOf2(toSize);
// 修改扩容的临界值,根据传进来的值,临界值为24,
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 申请一个32 个空间大小的Entry数组,table就是一个Entry类型的数组
table = new Entry[capacity];
// 初始化需要的hash种子
initHashSeedAsNeeded(capacity);
}
第二: 增加对象的操作,put(),put对象的时候会引申出很多问题,接下来我们要详细的看一下源代码。信息量比较大需要一步一步看
public V put(K key, V value) {
//首先判断是否为一个空表,若为空表则分配空间
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 如果key是Null的时候执行自己的算法,因此可见,HashMap是允许存放Null键和Null值的
if (key == null)
return putForNullKey(value);
// Key不为空的情况
// 首先根据key值计算出一个hash值
int hash = hash(key);
// 根据hash值计算出在table中的下标
int i = indexFor(hash, table.length);
// 判断是否发生了地址冲突
for (Entry e = table[i]; e != null; e = e.next) {
Object k;
// 如果是相同的Key值,则进行覆盖,返回旧的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++;
// 添加一个新的Entry对象到数组中去
addEntry(hash, key, value, i);
}
这是put的一个大致流程,接下来我们一步一步详细分析:
Key值为空的时候执行了 putForNullKey(value),我们看了源代码可以发现,它直接将其放在数组中下标为0的位置,并没有计算hash值,这根地址计算方式有关系,因为所有的Null键都是放在数组中下标为table[0]的位置的,我们需要记住。
private V putForNullKey(V value) {
for (Entry e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
再来观察key不为空 的情况下,数组地址下标的计算方式:
首先调用了hash方法(),可以看出它是根据Key对象的hashcode()值的基础上经过处理得到的,当然对于String对象有它特殊的获取方法,这里就牵扯到了高位运算, 这个方法的主要作用是防止质量较差的哈希函数带来过多的冲突(碰撞)问题。Java中int值占4个字节,即32位。根据这32位值进行移位、异或运算得到一个值,能够减少碰撞的发生,这是hash(int h)方法对key的hashCode的进一步优化,加入了高位计算,就使得只有相同的hash值的两个值才会被放到数组中的同一个位置上形成链表。
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
数组的下标确定函数: 根据计算出来的hash值计算应该存储在数组中的索引,
static int indexFor(int h, int length) { //根据hash值和数组长度算出索引值
return h & (length-1); //这里不能随便算取,用hash&(length-1)是有原因的,这样可以确保算出来的索引是在数组大小范围内,不会超出
}
虽然只有短短的一行,但却包含着巨大的信息量,同时搞明白这个就可以明白为什么定义数组的长度要为2的整数次幂了。
我们一般对哈希表的散列很自然地会想到用hash值对length取模(即除法散列法),Hashtable中也是这样实现的,这种方法基本能保证元素在哈希表中散列的比较均匀,但取模会用到除法运算,效率很低,HashMap中则通过 h&(length-1) 的方法来代替取模,同样实现了均匀的散列,但效率要高很多,这也是HashMap对Hashtable的一个改进。
接下来,我们分析下为什么哈希表的容量一定要是2的整数次幂。
第一:首先,length为2的整数次幂的话,h&(length-1)就相当于对length取模,这样便保证了散列的均匀,同时也提升了效率;
第二:其次,length为2的整数次幂的话,为偶数,这样length-1为奇数,奇数的最后一位是1,这样便保证了h&(length-1)的最后一位可能为0,也可能为1(这取决于h的值),即与后的结果可能为偶数,也可能为奇数,这样便可以保证散列的均匀性,而如果length为奇数的话,很明显length-1为偶数,它的最后一位是0,这样h&(length-1)的最后一位肯定为0,即只能为偶数,这样任何hash值都只会被散列到数组的偶数下标位置上,这便浪费了近一半的空间,因此,length取2的整数次幂,是为了使不同hash值发生碰撞的概率较小,这样就能使元素在哈希表中均匀地散列。
举个例子:
假设数组长度分别为15和16,优化后的hash码分别为8和9,那么&运算后的结果如下:
h & (table.length-1) hash table.length-1
8 & (15-1): 0100 & 1110 = 0100
9 & (15-1): 0101 & 1110 = 0100
———————————————————————————————————————–
8 & (16-1): 0100 & 1111 = 0100
9 & (16-1): 0101 & 1111 = 0101
所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
然后就是添加一个新的对象到指定的数组下标位置了,,我们可以看到有一个判断是否需要扩容的操作。
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);
}
// 创建一个新的Entry对象,我们可以看到原来该位置上的旧的对象作为新的对象的Next属性被添加到Entry数组中,table[bucketIndex]指向了新的对象,也就意味这新的对象被加到了该位置上的链表的首部。
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
扩展容量的操作:也是比较重要的一个方法,与ArrayList不同的是,hashMap扩容的时候,是以*2的进行扩容的,ArrayList是1.5倍扩容,为什么要2倍扩容我们上面已经讲过了。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
// 这个if块表明,如果容量已经到达允许的最大值,即MAXIMUN_CAPACITY,则不再拓展容量,而将装载拓展的界限值设为计算机允许的最大值。
// 不会再触发resize方法,而是不断的向map中添加内容,即table数组中的链表可以不断变长,但数组长度不再改变
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 创建新数组,容量为指定的容量
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
// 将原数组中的对象重新计算数组下标,并复制到新的数组中去
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry e : table) {
while(null != e) {
Entry next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
// 将e插入到newTable[i]指向的链表的头部
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
因此,HashMap之所以不能保持元素的顺序原因也就出来了:
第一,插入元素的时候对元素进行哈希处理,不同元素分配到table的不同位置;
第二,容量拓展的时候又进行了hash处理;
第三,复制原表内容的时候链表被倒置。
第三: get(Key)方法,get方法没有什么难度就是根据key计算出地址下标,进行寻址。containsKey(Object key)方法很简单,只是判断getEntry(key)的结果是否为null,是则返回false,否返回true。remove(key)也是差不多相同的原理
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
// 获取根据下标获取Entry对象
final Entry getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
for (Entry 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;
}
第四: clear()方法,这里就不用一个个删除节点了,而是直接将table数组内容都置空,这样所有的链表都已经无法访问,Java的垃圾回收机制会去处理这些链表。table数组置空后修改size为0。
public void clear() {
modCount++;
Arrays.fill(table, null); // 调用了数组的处理方法,全部置为空
size = 0;
}
第五: 看一下keySet(); entrySet(); values()方法
当我们调用entrySet()方法时,它返回了一个entrySet对象,我理所当然的认为是在什么时候向这个对象里添加了元素,事实发现我错了,找遍代码也没发现对它有什么操作,这是一个陷阱,在看代码的时候我就陷进去了,
private transient Set<Map.Entry<K,V>> entrySet = null;
public Set<Map.Entry<K,V>> entrySet() {
return entrySet0();
}
private Set<Map.Entry<K,V>> entrySet0() {
Set<Map.Entry<K,V>> es = entrySet;
return es != null ? es : (entrySet = new EntrySet());
}
我们看一下这个类的代码:这个类根本就没属性,它只是个代理。因为它内部类,可以访问外部类的内容, 当我们采用Iterator输出里面的元素时,它会调用newEntryIterator()方法,然后一直追溯,发现它最终使用的是HashIterator()这里面的方法来获取的Entry,而HashIterator也是HashMap的一个内部类,所以它使用的还是HashMap中的方法获得的。
values()方法,keySet()方法也是同样的实现原理。
private final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
public Iterator> iterator() {
return newEntryIterator();
}
public boolean contains(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry) o;
Entry candidate = getEntry(e.getKey());
return candidate != null && candidate.equals(e);
}
public boolean remove(Object o) {
return removeMapping(o) != null;
}
public int size() {
return size;
}
public void clear() {
HashMap.this.clear();
}
}
第六: Fail -Fast 机制: 快速失败机制
我们知道java.util.HashMap不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。
这一策略在源码中的实现是通过modCount域,modCount顾名思义就是修改增加删除等等次数,对HashMap内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount。 在迭代过程中,判断modCount跟expectedModCount是否相等,如果不相等就表示已经有其他线程修改了Map:
注意到modCount声明为volatile,保证线程之间修改的可见性。(volatile之所以线程安全是因为被volatile修饰的变量不保存缓存,直接在内存中修改,因此能够保证线程之间修改的可见性,)。
注意,迭代器的快速失败行为不能得到保证,一般来说,存在非同步的并发修改时,不可能作出任何坚决的保证。快速失败迭代器尽最大努力抛出 ConcurrentModificationException。因此,编写依赖于此异常的程序的做法是错误的,正确做法是:迭代器的快速失败行为应该仅用于检测程序错误。
本文参考了:
http://blog.csdn.net/jzhf2012/article/details/8540670
http://www.cnblogs.com/xwdreamer/archive/2012/06/03/2532832.html