目录
前言
JavaSE
基础知识也学了大部分了,发现Java中有一个数据结构有着举足轻重的重用,什么面试必考啊,你必须掌握啊~~~,那就是HashMap
,完后谈到这玩意,都拿1.7版本JDK和1.8版本JDK版本作比较。大多数学Java的听说过了,1.7嘛底层数据结构数组+链表,1.8多了个红黑树。完后1.7中它是线程不安全的,它查找效率可能会很低,冲突解决策略是简单用链表把冲突的节点串起来,那必然不会有很高效率,O(n)
查找。因此1.8之后就加了红黑树,就冲突链表长度超过一个阈值,给他把链表转红黑树结构,但他依旧是线程不安全的。红黑树就是一个不是非常严格的平衡二叉树嘛,查找效率O(logn)
级别。
都是线程不安全,有啥区别:
1.7中采用的是头插法,即插在链表的都节点处,而1.8是尾插法,这所谓头插尾插都是在扩容时的操作。1.7多线程头插法可能会导致出现环形链表。
线程安全的HashMap
在java.util.concurrent
包下
以上都是看了很多博客、视频总结得来的东西。完后我自己用的JDK15也去读了读源码,但是感觉还是不够,于是我下载了1.7版本JDK和1.8版本JDK来读一下源码。
接下来分四个部分读源码,1.7版本HashMap
,1.8版本Hashmap
,1.7版本ConcurrentHashMap
,1.8版本ConcurrentHashMap
。
JDK1.7版本HashMap
先看一下如何存键值对,列出static class Entry<K,V> implements Map.Entry<K,V>
属性:
final K key;
V value;
Entry<K,V> next;
int hash;
next
主要用来串出现冲突的键值对。单向链表处理冲突!
new-构造一个HashMap对象
借助强大的IDEA来直接导入1.7版本JDK版本
直接写new HashMap;
package hello;
import java.util.HashMap;
public class Test {
public static void main(String[] args) {
HashMap hashMap = new HashMap();
hashMap.put(1,"code");
hashMap.put(2,"friday");
}
}
Ctrl+鼠标左键
直接进到HashMap
源码,使用快捷键Alt+7
查看这个类中的一些方法以及属性
可以看到有4种构造方法,完后再看看其中的一些属性,直接上源码如下:
//其实源码中的注释已经解释得很清楚,中文备注一下
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//默认初始容量,必须是2的幂次,MUST be a power of two.
static final int MAXIMUM_CAPACITY = 1 << 30;
//最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默认加载因子,0.75
static final Entry<?,?>[] EMPTY_TABLE = {};
//用来比较判断table是否为空用的,后面代码会体现!
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//存储底层数据结构:数组
transient int size;
//已经存的key-value数量
int threshold;
//阈值,容量*加载因子得的,存的键值对超过这个阈值就要进行数组扩容操作
final float loadFactor;
//加载因子
根据我写的代码,我调用了无参构造,查看源码调用方法的过程如下:
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
//就给默认容量16,加载因子0.75,然后调用有参构造
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)//1.
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)//2.
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))//3.
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
threshold = initialCapacity;//此时没有插入键值对,阈值==容量
init();//空函数啥也没做
}
- 1.第一个if判断,传入容量值为负数,抛出一个异常
- 2.第二个if判断,传入容量超过允许最大容量,就按最大容量来
- 3.第三个if判断,加载因子也可以自己给,判断一下是否0-1范围且是一个有效的数,不是就抛出异常
自此可以得到,调用构造函数new一个HashMap
对象,实际用来存键值对的数组并没有创建。
自此,new
操作结束,接下来肯定就是往里存键值对,调用的是put
方法
put方法
put执行流程如下:
- 判断数组是否已经创建
- 判断key是否为空,针对
key==null
插入有一个方法 - 计算哈希值并找一个数组下标去存
- 先判断key是否已经存在,存在就更新value值,返回旧的value
- 不存在就调用
addEntry
插入
public V put(K key, V value) {
if (table == EMPTY_TABLE) {//判断数组是否为空
inflateTable(threshold);
}
if (key == null)//key为空,调用一个插入key==null的方法,由此可知可以存key为null的键值对
return putForNullKey(value);
int hash = hash(key);//计算一下key的hash值
int i = indexFor(hash, table.length);//根据哈希值取得应该存在数组中那个位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
//遍历一个数组下标对应的链表,如果key已经存在,更新Value并返回旧的Value
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++;//修改次数+1
addEntry(hash, key, value, i);//实际插入键值对的方法
return null;
}
到此,有两处需要拓展!
put方法扩展indexFor
注意:看源码时不能看到一个方法中调用了一个方法就马上点进去看,你会发现,可能一直点,点个好几层都没问题,完后回来你就不知道自己是要干嘛了。
人家写的代码的函数一般都见名知意。重要的方法,看完整体点进去验证一下就行~~~
比如这里得去看看indexFor
做了些啥,这会解释了为什么数组容量必须2的幂次,扩容也必须2倍扩容~
/**
* 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";
return h & (length-1);
}
简单的做了一个按位与运算。正常把元素映射到数组,想到的映射方法肯定是用取余操作模上数组长度,这是一种相对平均的散列算法。实际这个地方本质就是模数组长度,但是必须保证length
是2的幂次才能达到这个效果。举个例子:
数组长度保证2的幂次,就可用按位与代替取模操作,位运算的速度比取模运算快很多很多~,可以用个计数程序测试一下。
put方法扩展addEntry
/**传来的参数
*@hash:key的哈希值
*key,value即键值对
*@bucketIndex:键值对需要插入的桶的索引,就是数组索引,数组每一格当作一个桶
*/
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {//如果存的键值对已经超过阈值,就需要扩容
resize(2 * table.length);//扩容,2倍扩容,后续分析源码
hash = (null != key) ? hash(key) : 0;//重新计算一下key的hash值
bucketIndex = indexFor(hash, table.length);//根据hash值重新找应该放在数组哪个位置
}
createEntry(hash, key, value, bucketIndex);//实际放入数组的方法
}
还得点一层createEntry
,源码如下:
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];//把同中键值对取出来
table[bucketIndex] = new Entry<>(hash, key, value, e);//把新键值对加进入,并把e接到后面,这就是头插法!!!
size++;//键值对计数器+1
}
画个图演示一下——头插法
补充:key==null时默认插入数组下标为0的地方
get方法
上源码:
public V get(Object key) {
if (key == null)//key为null调用对应方法
return getForNullKey();
Entry<K,V> entry = getEntry(key);//获取整个Entry对象
return null == entry ? null : entry.getValue();//如果Entry对象为空表示没有这个映射,否则返回value值
}
getEntry
源码如下:
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[indexFor(hash, table.length)];//找到key对应数组中的位置
e != null;
e = e.next) {//遍历桶,也就是遍历链表
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;//找到key返回Entry对象
}
return null;//没找到
}
相对简单,主要还是因为数组+链表实现HashMap
数据结构并不复杂。
接下来必须看看扩容操作了!
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
,这也是并发操作导致双向链表的地方!!!源码如下:
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) {//是否需要重新计算hash值
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);//重新计算数组下标
e.next = newTable[i];
newTable[i] = e;
e = next;//一样的的头插法重新放进去
}
}
}
并发出现环形链表
下一次get查找这个桶时,死循环在里面不出来了!
JDK1.8版本HashMap
进入Project Structure切换JDK版本:
数据结构和辅助函数改变
点进源码后,存储一个键值对的数据结构如下:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
//只列出属性
名字都改了,Entry
改成Node
,内容倒是没变。既然引入了红黑树,那肯定由红黑树节点对应的数据结构:(只列出属性)
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
}
hash函数也改了:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//1.7版本直接获取key的hashCode,现在把hashcode高位和低位做了一下异或操作,这玩意叫扰动函数
**扰动函数作用:**你求于的时候包含了高16位和第16位的特性 也就是说你所计算出来的hash值包含从而使得你的hash值更加不确定 来降低碰撞的概率。
构造函数其实本质和1.7版本还是差不多。有很大不同的地方还是分析put,get,resize方法
put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//直接调用了putVal
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)//根据hash值找到对应存放的数组下标
tab[i] = newNode(hash, key, value, null);//如果该位置空,直接新建一个链表节点
else {//否则就遍历一下链表,看key是否有重复
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)//如果已经树化,调用红黑树的插入方法
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) { // key已经存在,覆盖value并返回原来的
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();//判断一下是否需要扩容+
afterNodeInsertion(evict);
return null;
}
putVal
插入流程:
- 判断数组是否已经创建
- 根据hash值找到对应存放的数组下标
- 分三种情况
- 该位置为空
- 红黑树的插入
- 链表的插入
- 插入如果是覆盖就返回旧值
- 判断一下是否达到阈值,然后扩容一下
get方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
//调用了getNode方法
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {//先判断表不为空,并根据hash索引到数组下标不为空
if (first.hash == hash && // always check first node先检查第一个
((k = first.key) == key || (key != null && key.equals(k))))
return first;
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);
}
}
return null;//找不到返回null
}
执行流程也相对简单,分红黑树和链表的查找方法,重点在扩容(注:我只分析了链表,为了对比1.7版本)
resize方法
源码挺长:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;//保存一下原来的表,不再需要传容量大小的参数,区别于1.7
int oldCap = (oldTab == null) ? 0 : oldTab.length;//获取原来的容量
int oldThr = threshold;//原来的阈值
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {//如果容量已经超过最大容量
threshold = Integer.MAX_VALUE;//只能扩大阈值
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//扩大两倍容量并判断是否小于允许的最大容量,原来的容量是否大于等于16
newThr = oldThr << 1; // double threshold,都满足就扩大阈值,阈值在new的时候没传参数其实就给了默认
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//上面主要是更新容量值和阈值的值
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//创建新表
table = newTab;
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//链表,保留原来的顺序,也就是尾插法
Node<K,V> loHead = null, loTail = null;
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;
}
}
}
}
}
return newTab;
}
重点分析链表数据迁移的过程,定义了四个链表节点
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
其实数据迁移过程,无非是把原来链表拆分成两个链表(不考虑红黑树),而且两个链表中的数据根据哈希值和oldlength-1
求与之后得到的数组索引一定满足以下关系:
OldIndex == OldIndex 或 OldIndex+oldlength
分出去到更高索引的其实就是多看一个二进制位,比如原来容量是8,现在看看第4位(从低到高从1开始计),如果是1那就分到更高索引的数组去。因此定义了一个loHead
和hiHead
两个链表。接下来模拟操作以一下
do-while循环完之后:
接下来两个if判断:就是把链表放到新数组中去:
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
并发操作出现的问题
没有任何同步机制,多线程肯定会出现关键节点线程抢占,比如其中size
表示键值对的数目,其他线程可能对size
的副本做出修改还未更新本来的值,那必然会出现多个线程数据覆盖的问题。实际就会出现,并发插入键值对,实际插入数量!=size,这只是一个不严谨的举例,实际自己写个测试程序运行就会抛出并发操作异常。
JDK1.7版本ConcurrentHashMap
由于目前只有只学习了操作系统导论中的并发,讲的也是C/C++下的,还未学习Java中并发的一些实现进制。只能浅显分析一波~~~
出错以后来改,或者欢迎评论区纠正
首先这是它的结构图:
为了实现互斥的话,最简单的思路就是只允许一个线程操作哈希表,也就是价格锁,但这样自己用HashMap
写好同步代码块就完事了,这东西也就没存在的意义,而且并不是所有线程并发操作哈希表都是会导致出错的,因此可以考虑把哈希表分成很多段,每个段保证只能一个线程进去操作,那就可以实现真正意义上的并发操作哈希表,JDK1.7中就是利用了分段锁的机制实现互斥。
核心属性以及数据结构
新增的属性:
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//默认并发级别,也就是允许多少个线程同时操作
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
//每个段下默认hash表的长度
static final int MAX_SEGMENTS = 1 << 16; // slightly conservative
//允许最大的段数量
数据结构:
final Segment<K,V>[] segments;
transient Set<K> keySet;
transient Set<Map.Entry<K,V>> entrySet;
transient Collection<V> values;
查看Segment
类定义:
static final class Segment<K,V> extends ReentrantLock implements Serializable
实现了ReentrantLock
,其实就是一种锁的类型。jdk中独占锁的实现除了使用关键字synchronized
外,还可以使用ReentrantLock。
上面的东西暂时没学到。
本质和HashMap
没多大区别,只不过用到了volatile
关键字等实现同步互斥。
原理上来说:ConcurrentHashMap
采用了分段锁技术,其中Segment
继承于ReentrantLock
。不会像HashTable
那样不管是put
还是 get
操作都需要做同步处理,理论上ConcurrentHashMap
支持CurrencyLevel
(Segment 数组数量)的线程并发。每当一个线程占用锁访问一个Segmen
t 时,不会影响到其他的 Segment
。
1.7版本解决并发问题之后,但是数组+链表的实现还是会导致查询效率低。
JDK1.8版本ConcurrentHashMap
在JDK1.8版本丢弃了分段锁。
采用了 CAS + synchronized
来保证并发安全性。
CAS
全称CompareAndSwap
,在操作系统导论中这是操作系统中硬件提供的功能强大的原子操作,来实现锁机制用的。
此处我觉得是差不多的思想,实际上这个操作似乎也是C++写的,调用了C++写的包,因为Java不能去搞底处的内存管理。
总结
1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(O(logn)
),甚至取消了ReentrantLock
改为了 synchronized
,这样可以看出在新版的JDK
中对 synchronized
优化是很到位的。