hashmap咱们都用过不少次了,主要目的就是为了加快咱们的查找速度。咱们学过数据结构的都知道,数组的查询和修改速度很快,可是增长一个元素或者删除一个元素就很慢,可是链表就反过来,链表是增长和删除一个元素很快,查询和修改就很慢。 一般来讲,咱们为了提升查询的速度,那么在插入元素的时候就要定义各类规则,就是增长复杂度了。 有不理解的能够 查询相关算法书里 查找这一章。而咱们的hashmap正是将数组和链表结合起来增长查询速度的一种数据结构,对应的,咱们 就要好好理解hashmap的put操做。java
#图解hashmap的数据结构算法
前面咱们说过hashmap是数组和链表结合起来的一种结构。数组
那么看这张图,数组的每个元素实际上就是一个链表。咱们都知道hashmap在使用的时候是key-value的键值对查找。 那么在最理想的时候 应该是数组的每个元素,也就是对应的链表只有一个节点。这样的效率就是最快的。 由于这样几乎就至关于在数组里查找一个元素了。安全
可是若是脸很差,或者hash算法写的通常。就会出现 这个数组其余元素都是空,而后咱们插入的数据所有在一个位置上对应的链表里:bash
这样查找起来就很慢了。前者o(1) 后者o(n)数据结构
看图也能够了解到,咱们的hashmap是容许key为null的,可是最多也只能有一个元素的key为null,虽然容许key为null, 可是咱们并不鼓励这么作。多线程
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
复制代码
这是一段我电脑上jdk8的hashmap的源码,注意这里咱们能够知道,默认咱们构造一个hashmap的时候默认大小是16个元素的数组。并发
那么这个DEFAULT_LOAD_FACTOR是干啥的呢?app
咱们都知道若是咱们的hashmap的数组大小若是只有16个的话,当咱们的数据也恰好是16的时候,最好的状况就是 一个元素 对应的链表只有2个结点。若是要插入32个数据的话,最好的结果 一个链表有2个节点。若是要插入128个数据的话,一个链表 就有8个节点了。查找起来速度确定会下降。 为了提升咱们的查找速度,咱们显然应该扩充数组的大小, 那么什么时候扩充数组大小?就是当元素个数 大于 数组长度*负载因子 的时候,咱们就要扩充数组大小为原来的一倍了。 比方说: 1.初始默认数组长度为16 2.当插入的元素已经到了12个以上的时候,咱们就要扩充数组长度到32了。函数
为啥这个负载因子的值是0.75,我也不知道。。。。反正你知道有这么个东西便可。0.75应该是最好的算法把。
前面咱们说到,扩充数组长度之后能够增长查找数据的速度。可能有人理解的不够透彻,这里咱们举一个简单的例子帮助你们理解。
假设咱们的数组长度只有2. 而且要插入5,7,9 这3个元素。 hash算法咱们选择 元素值和数组长度取模。
那么显然, 579和2 取模之后,值都为1,那么 5,7,9 这3个元素 都会插入在这个长度为2的数组的位置1上。
那么咱们要查到579这3个元素,显然就比较慢了,由于都要去a[1]这个位置对应的链表上去找。因此咱们开始增长数组长度
扩充数组长度为4. 那么579对4 取模之后,分别的值就为 1,3,1, 显然 5和9就被放到了a[1]这个位置上。7被放到了 a[3] 这个位置上。
再扩充一次长度,那么数组长度就是8,579对8取模之后,分别对应的值就是5,7,1 显然的5,7,9这3个元素对应的位置 就 是 a[5],a[7],a[1],此时咱们的查找效率和只有长度为2时候的数组对比 效率明显提升。
上述的这过程,咱们称之为rehash
前文说过,对于hashmap来讲,put操做是最重要的。咱们就来看看jdk7种的put操做
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with <tt>key</tt>, or
* <tt>null</tt> if there was no mapping for <tt>key</tt>.
* (A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>.)
*/
public V put(K key, V value) {
//若是数组为空 先构造一个数组
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//若是key为null的状况要特殊处理
if (key == null)
return putForNullKey(value);
//经过传进来的key的值 咱们计算出一个hash的值
int hash = hash(key);
//用hash的值和数组长度一块儿计算出 这个key-value应该放入数组的位置,也就是数组中的索引值
int i = indexFor(hash, table.length);
//看看数组这个索引位置下的链表有没有key和传进来的key是相等的,若是有那么就替换掉,而且把老的值返回
//发生这种状况时,由于return了 因此函数到这里就结束掉了
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//若是没发生上述状况 就意味着一个新的元素被增长进来
addEntry(hash, key, value, i);
return null;
}
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
//h是计算出来的hash值,length是数组长度 实际上这里就是取模操做。
//和咱们前文中的例子是同样的,就等于 h%length.这种写法效率更高而已
return h & (length-1);
}
void addEntry(int hash, K key, V value, int bucketIndex) {
//这里也好理解哈,若是发生了要扩充数组长度的状况,那么hash值要从新计算
//从新计算的hash值 也要再利用一次从新计算出再数组中的索引位置
//threshold其实就是前文咱们提到的 数组长度*负载因子
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);
}
//这里咱们重点看一下这个resize的操做
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//构造出一个新的数组 容量固然翻倍啦
Entry[] newTable = new Entry[newCapacity];
//而后把老的数组的值放到新的数组里面
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//而后把新的数组的索引赋值
table = newTable;
//最后从新计算这个阀值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
//接着看咱们的transfer函数 实际上jdk7和8 关于hashmap最大的不一样就在这里了
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//就跟前文中的例子同样,构建新数组的时候,老数组中的哪些元素在新数组中的索引咱们都要从新计算一遍
//仅此而已
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
//这个也比较简单,实际上插入元素的时候,咱们都是把新来的元素 放入链表的头部,而后让新来的元素的next指针指向
//来以前的链表的头部元素,因此jdk7的插入元素是在链表头部插入。
//在这个地方咱们也能够再想明白一个问题,在transfer函数中,咱们从新计算索引的时候,先去老的链表里从链表头部
//取一个元素出来放入到新数组的索引里,取完第一个再取第二个
//那么后面的元素也是后计算出索引放入到新的数组对应的链表里。既然是后放入,那么确定后放入的会在链表的头部了
//这就表明一个结果:由于咱们每次插入元素是在链表头部插,因此若是新的数组构造出来之后,咱们的索引计算出来
//仍旧相等的话,这2个元素仍旧会放到一个索引对应的链表中,只不过以前在头部的,如今去了尾部。
//也就是说在transfer的过程当中,链表的顺序被倒置
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
复制代码
主要有两点。
1.hash冲突,也就是若是计算出来hash值相同的时候,咱们不是放到一个链表里面吗?jdk7是在头部插入新的元素, jdk8是在尾部插入新来的元素。
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;
//若是hash相等key也相等 那么先拿着引用 等会直接替换掉老值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)//这边是红黑树的逻辑,jdk8在链表长度超过8的时候会转红黑树,这属于数据结构
//的范畴 咱们往后再说。
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//这边应该明显的看出来,新来的元素在链表的位置是在尾部的,而不是jdk7种的头部
p.next = newNode(hash, key, value, null);
//转红黑树的操做 若是链表长度大于8的话 红黑树的问题属于数据结构问题 咱们往后再说
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;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
复制代码
第二点也就是最大的不一样,jdk8的resize()操做比jdk7快了不少,而且jdk7 resize链表会倒过来,而jdk8不会。
考虑以下场景:
初始长度为16的数组,咱们加入2个元素 一个hashcode是5 一个hashcode是21. 咱们对16取余以后, 计算出来的索引位置 5 对应的是a[5],21对应的是也是a[5] 因而这2个就存在同一个链表中。
当元素愈来愈多终于超过阈值的时候,数组扩充到32这个长度,这个时候5 和21 再对32取余 5对应的仍是a[5],而21对应的就是a[21] 这个位置了,咱们注意 21这个hashcode,以前对应的位置 是在a[5] 扩充一倍之后在21 位置增长了16 ,这个16 实际上就是扩充的长度,这个数学公式能够抽象为
扩充前位置 oldIndex 扩充后位置 要么保持不变 要么是oldIndex+扩充前的长度.
有了这套数学规律,咱们在resize的时候就能够优化一下了,不须要像jdk7中从新计算hash和index了。
下面就是jdk8种resize的主要过程
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//这个链表用来表示扩充之后 新增bit为0的链表 也就是说这个链表上的元素扩充先后位置不变
Node<K,V> loHead = null, loTail = null;
//这个用来表示扩充之后要挪动位置的链表 挪动位置也就是说新增的bit为1了。
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
//这个地方 对应着上文咱们的公式
newTab[j + oldCap] = hiHead;
}
}
}
}
}
复制代码
众所周知的hashmap明显是不支持线程同步的,最大的缘由就是hashmap的resize过程当中极易被线程干扰, 颇有可能中间的resize操做 transfer操做 的执行顺序被打乱,要知道transfer操做的是链表, 很容易出现 你指向我我指向你这种循环链表,一旦出现循环链表的状况,基本程序就是死循环要报错了。
况且即便不出现这种极端状况,put操做不加锁的话,随意的修改值,也会致使get出来的和你put进去的并不一致。
咱们的hashtable就是采用的比较极端的方法,直接对put方法进行加锁了。这样虽然一劳永逸,可是效率极低。 不推荐使用。
固然咱们还能够换一种方法。
HashMap hm=new HashMap();
Collections.synchronizedMap(hm);
复制代码
这样也能够保持线程同步。看看原理:
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
return new SynchronizedMap<>(m);
}
/**
* @serial include
*/
private static class SynchronizedMap<K,V>
implements Map<K,V>, Serializable {
private static final long serialVersionUID = 1978198479659022715L;
private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize
SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNonNull(m);
mutex = this;
}
SynchronizedMap(Map<K,V> m, Object mutex) {
this.m = m;
this.mutex = mutex;
}
public int size() {
synchronized (mutex) {return m.size();}
}
public boolean isEmpty() {
synchronized (mutex) {return m.isEmpty();}
}
public boolean containsKey(Object key) {
synchronized (mutex) {return m.containsKey(key);}
}
public boolean containsValue(Object value) {
synchronized (mutex) {return m.containsValue(value);}
}
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
public V remove(Object key) {
synchronized (mutex) {return m.remove(key);}
}
public void putAll(Map<? extends K, ? extends V> map) {
synchronized (mutex) {m.putAll(map);}
}
public void clear() {
synchronized (mutex) {m.clear();}
}
private transient Set<K> keySet;
private transient Set<Map.Entry<K,V>> entrySet;
private transient Collection<V> values;
复制代码
比hashtable好一点,可是锁加的仍是太多了。有提高可是依旧不够好。
ConcurrentHashMap 才是解决此类问题的最终方案。
简单来讲,ConcurrentHashMap比上述方案效率都要更高的缘由主要就是
咱们能够把hashmap当作银行的集合,好比说 这个集合里面 有工商银行,有建设银行,招商银行,农业银行,等等。
前面2者几乎就是 只要你来存钱,无论你是想去哪一个银行存,你都得排队。
而ConcurrentHashMap的粒度会下降到,只要你来存钱,只会在你想去的银行门口排队。效率明显更高。
要真正理解ConcurrentHashMap的源码,咱们须要对volatile transient 关键字有很深的了解,同时还要了解ReentrantLock这个锁机制,这里先卖个关子,ConcurrentHashMap咱们往后再说,你们了解一下便可。
(1) HashMap:它根据键的hashCode值存储数据,大多数状况下能够直接定位到它的值,于是具备很快的访问速度,但遍历顺序倒是不肯定的。 HashMap最多只容许一条记录的键为null,容许多条记录的值为null。HashMap非线程安全,即任一时刻能够有多个线程同时写HashMap,可能会致使数据的不一致。若是须要知足线程安全,能够用 Collections的synchronizedMap方法使HashMap具备线程安全的能力,或者使用ConcurrentHashMap。
(2) Hashtable:Hashtable是遗留类,不少映射的经常使用功能与HashMap相似,不一样的是它承自Dictionary类,而且是线程安全的,任一时间只有一个线程能写Hashtable,并发性不如ConcurrentHashMap,由于ConcurrentHashMap引入了分段锁。Hashtable不建议在新代码中使用,不须要线程安全的场合能够用HashMap替换,须要线程安全的场合能够用ConcurrentHashMap替换。
(3) LinkedHashMap:LinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先获得的记录确定是先插入的,也能够在构造时带参数,按照访问次序排序。
(4) TreeMap:TreeMap实现SortedMap接口,可以把它保存的记录根据键排序,默认是按键值的升序排序,也能够指定排序的比较器,当用Iterator遍历TreeMap时,获得的记录是排过序的。若是使用排序的映射,建议使用TreeMap。在使用TreeMap时,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,不然会在运行时抛出java.lang.ClassCastException类型的异常。