Map接口
Map接口概述
Map与Collection并列存在。用于保存具有映射关系的数据:key-value
Map 中的 key 和 value 都可以是任何引用类型的数据,Map 中的 key 用Set来存放,不允许重复,即同一个 Map 对象所对应的类,须重写hashCode()和equals()方法
常用String类作为Map的“键”
key 和 value 之间存在单向一对一关系,即通过指定的 key 总能找到 唯一的、确定的 value
Map接口的常用实现类:HashMap、TreeMap、LinkedHashMap和Properties。其中,HashMap是 Map 接口使用频率最高的实现类
共有的方法
//添加、删除、修改操作:
Object put(Object key,Object value):将指定key-value添加到(或修改)当前map对象中
void putAll(Map m):将m中的所有key-value对存放到当前map中
Object remove(Object key):移除指定key的key-value对,并返回value
void clear():清空当前map中的所有数据
//元素查询的操作:
Object get(Object key):获取指定key对应的value
boolean containsKey(Object key):是否包含指定的key
boolean containsValue(Object value):是否包含指定的value
int size():返回map中key-value对的个数
boolean isEmpty():判断当前map是否为空
boolean equals(Object obj):判断当前map和参数对象obj是否相等
//元视图操作的方法:
Set keySet():返回所有key构成的Set集合
Collection values():返回所有value构成的Collection集合
Set entrySet():返回所有key-value对构成的Set集合
简单的操作:
public static void main(String[] args) {
HashMap map = new HashMap();
map.put("a",1);
map.put("b",4);
map.put("g",4);
System.out.println("所有的KEY如下:");
Set set = map.keySet(); //获取Key的Set集合
//遍历方式1
Iterator iterator = set.iterator();
while (iterator.hasNext()){
System.out.println(iterator.next());
}
//遍历方式二
for (Object o : set) {
System.out.println(o);
}
System.out.println("======================");
//获取所有的Value
Collection values = map.values();
for (Object value : values) {
System.out.println(value);
}
//获取指定的Value
Object a = map.get("a");
System.out.println(a);
System.out.println("------------------");
Set set1 = map.entrySet();
for (Object o : set1) {
System.out.println(o); //a=1,b=4,g=4
}
}
HashMap实现类
基本知识
HashMap是 Map 接口使用频率最高的实现类。允许使用null键和null值,与HashSet一样,不保证映射的顺序。
所有的key构成的集合是Set:无序的、不可重复的。所以,key所在的类要重写:equals()和hashCode()
所有的value构成的集合是Collection:无序的、可以重复的。所以,value所在的类要重写:equals()
一个key-value构成一个entry,所有的entry构成的集合是Set:无序的、不可重复的
HashMap 判断两个 key 相等的标准是:两个 key 通过 equals() 方法返回 true, hashCode 值也相等。
HashMap 判断两个 value相等的标准是:两个 value 通过 equals() 方法返回 true
- JDK 7及以前版本:HashMap是数组+链表结构(即为链地址法)
- JDK 8版本发布以后:HashMap是数组+链表+红黑树实现。
JDK8源码分析
传统 HashMap 的缺点(JDK7的HashMap的缺点)
- JDK 1.8 以前 HashMap 的实现是 数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。
- 当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n), 完全失去了它的优势。
说明一下:在 java 编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap 也不例外。HashMap 实际上是一个“链表散列”的数据结构,即数组和链表的结合体。
前置知识之hash函数
先来理解什么是hash函数,这个东西啊,就是一种计算的方式,就是把所有的值通过表达式计算得到我们的表达式想要的值。
举个例子
我们假设有一个数组a[10],我们存放数据的时候可以先把要存放的数据计算得到一个0—9的下标,然后存放到对应的位置:(简单直接的代码)
public class firve {
public static void main(String[] args) {
int a[] = new int[10];
// 假设我们存放的数据为23,45.这个时候先获取hash值作为存放在这个数组的下标。
int num1 = getHash(23);
a[num1] = 23;
int num2 = getHash(45);
a[num2] = 45;
//如果我们要取出这个数据呢?同样的,调用这个getHash方法获取下标即可
int n = getHash(23);
int myNum = a[n];
System.out.println(myNum);
}
public static int getHash(int a){
return a % 10;
}
}
这个代码很简单,计算的表达式也很简单,时间复杂度直接降为O(1),如果不用这种方式呢,我们就需要遍历整个数组进行比对。
但是这样还是有问题的,比如假设11和21对10取余都是1,但是只有一个位置怎么办呢,这个就是常说的Hash冲突了。
Hash冲突
显然,一个数组的容量始终是有限的,如果设置的太小,冲突就会非常的频繁,但是如果太大呢,又非常的浪费空间。想到我们有一种东西可以无限容量,有多少数据就放出多少空间的结构,那就是链表。
我们把所有的数据存放在节点之中,对于没有冲突的,把这个节点直接放在数组中,然后把冲突了的放在已有的节点的后面,就像这个样子:
public class Test {
public static void main(String[] args) {
Node node1[] = new Node[10]; //假设长度10
int hash1 = getHash(11); //没有冲突的情况
node1[hash1] = new Node(11,null);
int hash2 = getHash(13);
node1[hash2] = new Node(13,null);
//假设冲突了,这个时候原有的数据不应该被覆盖
int hash3 = getHash(23);
Node node3 = new Node(23,null);
node1[hash2].next = node3; //原来的数据的指针指向这个新的数据
//取数据
int n = getHash(23);
while (node1[n].num != 23){
node1[n] = node1[n].next;
}
System.out.println(node1[n].num);
}
public static int getHash(int a){
return a % 10;
}
}
//Node的结构:
public class Node {
int num;
Node next;
public Node(int num, Node next) {
this.num = num;
this.next = next;
}
}
画个图理解:
从上图中可以看出,采用数组加链表的结构方式,就可以非常好的解决数组长度不够问题,但是这样又会有一个新的问题,如果冲突的元素非常多呢?那冲突的那条链可就太长了,查询就跟普通链表没差别,怎么办呢,这个时候就有会有新的结构来补充现有链表的不足——树(本质依然是链表)
前置知识之二叉树结构
假设我们链表多一个指针,一共两指针,一个指向左边,一个指向右边。就像这样:
public class Tree {
int num;
Tree R;
Tree L;
public Tree(int num, Tree r, Tree l) {
this.num = num;
R = r;
L = l;
}
}
假设每次添加元素都进行比较,如果大于这个元素就放在这个节点的右边,小于放在左边。于是之前的代码写成这个样子:
public class TestTree {
public static void main(String[] args) {
Tree tree[] = new Tree[10];
int n1 = getHash(11);
tree[n1] = new Tree(11,null,null);
int n2 = getHash(13);
tree[n2] = new Tree(13,null,null);
//这个元素比冲突的元素大,于是放在右边
int n3 = getHash(23);
Tree tree1 = new Tree(23,null,null);
tree[n2].R = tree1;
//这个元素比那个小,放在左边
int n4 = getHash(3);
Tree tree2 = new Tree(3,null,null);
tree[n2].L = tree2;
//取数据
int num = getNum(tree[n2], 23);
System.out.println(num);
}
public static int getHash(int a){
return a % 10;
}
//递归遍历
public static int getNum(Tree tree,int num){
if (tree.num > num){
return TestTree.getNum(tree.L,num);
}else if (tree.num < num){
return TestTree.getNum(tree.R,num);
}else{
return tree.num;
}
}
}
画个图理解一下:
这样一来很好的解决了大量数据冲突造成一条长长的链的麻烦。但是这样难道就没有问题了吗:
假设出现这种情况呢,就是那么的碰巧这个数据都比初始节点小,又出现了一条长长的链,这该怎么解决,这个时候又出现了一种改进的树——平衡二叉树。
前置知识之平衡二叉树(AVL)
平衡二叉树就是为了解决二叉查找树退化成一颗链表而诞生了,平衡树具有如下特点
- 具有二叉查找树的全部特性(左边节点比根节点小,右边比这个大)
- 每个节点的左子树和右子树的高度差至多等于1
例如:图一就是一颗平衡树了,而图二则不是(节点右边标的是这个节点的高度)
对于图二,因为节点9的左孩子高度为2,而右孩子高度为0。他们之间的差值超过1了。
平衡树基于这种特点就可以保证不会出现大量节点偏向于一边的情况了。
听起来这种树还不错,可以对于图1,如果我们要插入一个节点3,按照查找二叉树的特性,我们只能把3作为节点4的左子树插进去,可是插进去之后,又会破坏了AVL树的特性,那我们那该怎么弄?
左-左型 右旋
我们把这种倾向于左边的情况称之为 左-左型。这个时候,我们就可以对节点9进行右旋操作,使它恢复平衡。
即:顺时针旋转两个节点,使得父节点被自己的左孩子取代,而自己成为右孩子,同时原来的左孩子的右孩子成为这个还没有变化前的父节点的左孩子(这句话特别拗口,多读几遍,对着图多理解几遍,以为接下来的旋转都是这样类似!)
再比如:
节点4和9高度相差大于1。由于是左孩子的高度较高,此时是左-左型,进行右旋。
这里要注意,节点4的右孩子成为了节点6的左孩子了
用一个动图表示:
右-右型 左旋
左旋和右旋一样,就是用来解决当大部分节点都偏向右边的时候,通过左旋来还原。例如:
我们把这种倾向于右边的情况称之为 右-右型。
右-左型
出现了这种情况怎么办呢?对于这种 右-左型 的情况,单单一次左旋或右旋是不行的,下面我们先说说如何处理这种情况。
处理的方法是先对节点10进行右旋把它变成右-右型。
然后在进行左旋。
所以对于这种 右-左型的,我们需要进行一次右旋再左旋。
同理,也存在 左-右型的。对于左-右型的情况和刚才的 右-左型相反,我们需要对它进行一次左旋,再右旋。
所以对于刚才那种情况处理过后就是这个样子:
总结一下
在插入的过程中,会出现一下四种情况破坏AVL树的特性,我们可以采取如下相应的旋转。
-
左-左型:做右旋。
-
右-右型:做左旋转。
-
左-右型:先做左旋,后做右旋。
-
右-左型:先做右旋,再做左旋。
代码的实现
//节点结构
class AvlNode {
int data;
AvlNode L;//左孩子
AvlNode R;//右孩子
int height;//记录节点的高度
}
//AVL结构
public class AvlTree{
//计算每个节点的高度
static int getHeight(AvlNode node){
if (node == null){
return -1;
}else{
return node.height;
}
}
//对于左左型,右旋操作
static AvlNode R_Rotate(AvlNode node){
//暂存这个节点的左子节点
AvlNode temp = node.L;
//将这个节点变为左孩子的右节点
node.L = temp.R;
temp.R = node;
//重新计算这个根节点的左右节点的高度
node.height = Math.max(getHeight(node.L),getHeight(node.R)) + 1;
temp.height = Math.max(getHeight(temp.L),getHeight(temp.R)) + 1;
return temp;
}
//右右型,左旋
static AvlNode L_Rotate(AvlNode node){
//暂存这个节点的左子节点
AvlNode temp = node.R;
//将这个节点变为左孩子的右节点
node.R = temp.L;
temp.L = node;
//重新计算这个根节点的左右节点的高度
node.height = Math.max(getHeight(node.L),getHeight(node.R)) + 1;
temp.height = Math.max(getHeight(temp.L),getHeight(temp.R)) + 1;
return temp;
}
//左-右型,进行左旋,再右旋
static AvlNode L_R_Rotate(AvlNode node) {
//对左子树进行左旋
node.L = L_Rotate(node.L);
//此时变成了左左型,再进行一次右旋即可
return R_Rotate(node);
}
//右-左型,进行右旋,再左旋
static AvlNode R_L_Rotate(AvlNode node) {
//对右子树进行右旋
node.R = R_Rotate(node.R);
//此时变成了右右型,再进行一次左旋即可
return L_Rotate(node);
}
//插入数值操作
static AvlNode insert(int data, AvlNode T) {
if (T == null) {
T = new AvlNode();
T.data = data;
T.L = T.R = null;
} else if(data < T.data) {
//向左孩子递归插入
T.L = insert(data, T.L);
//进行调整操作
//如果左孩子的高度比右孩子大2
if (getHeight(T.L) - getHeight(T.R) == 2) {
//左-左型
if (data < T.L.data) {
T = R_Rotate(T);
} else {
//左-右型
T = R_L_Rotate(T);
}
}
} else if (data > T.data) {
T.R = insert(data, T.R);
//进行调整
//右孩子比左孩子高度大2
if(getHeight(T.R) - getHeight(T.L) == 2)
//右-右型
if (data > T.R.data) {
T = L_Rotate(T);
} else {
T = L_R_Rotate(T);
}
}
//否则,这个节点已经在树上存在了,我们什么也不做
//重新计算T的高度
T.height = Math.max(getHeight(T.L), getHeight(T.R)) + 1;
return T;
}
}
虽然平衡树解决了二叉查找树退化为近似链表的缺点,能够把查找时间控制在 O(logn),不过却不是最佳的,因为平衡树要求每个节点的左子树和右子树的高度差至多等于1,这个要求实在是太严了,导致每次进行插入/删除节点的时候,几乎都会破坏平衡树的第二个规则,进而我们都需要通过左旋和右旋来进行调整,使之再次成为一颗符合要求的平衡树。
显然,如果在那种插入、删除很频繁的场景中,平衡树需要频繁着进行调整,这会使平衡树的性能大打折扣,为了解决这个问题,于是有了红黑树
前置知识之红黑树
红黑树在原有的二叉树上增加了一些新的特新:
-
根节点是黑色的。
-
每个叶子节点是黑色的空节点(NIL),也就是叶子节点不存储数据
-
任何相邻的节点不能同时为红色,也就是说,红色节点是被黑色节点隔开的。
-
如果一个结点是红色,那么它的子节点是黑色(也就是每个红色节点有两黑色子节点)
-
对于每个节点,从该节点到所有可达的叶子节点中黑色的节点数目相等(简称黑高)
性质4和性质5两个性质作为约束,即可保证任意节点到其每个叶子节点路径最长不会超过最短路径的2倍!!!
原因:
当某条路径最短时,这条路径必然都是由黑色节点构成。当某条路径长度最长时,这条路径必然是由红色和黑色节点相间构成(性质4限定了不能出现两个连续的红色节点)。而性质5又限定了从任一节点到其每个叶子节点的所有路径必须包含相同数量的黑色节点。此时,在路径最长的情况下,路径上红色节点数量 = 黑色节点数量。该路径长度为两倍黑色节点数量,也就是最短路径长度的2倍。举例说明一下,请看下图:
可以认为红黑树是一种近似平衡的概念
平衡二叉查找树的初衷,是为了解决二叉查找树因为动态更新导致的性能退化问题。所以,“平衡”的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化的太严重。棵极其平衡的二叉树(满二叉树或完全二叉树)的高度大约是 log2n,所以如果要证明红黑树是近似平衡的,只需要分析,红黑树的高度是否比较稳定地趋近 log2n 就好了。
也就是说为了高效的增加和删除有了二叉树,但是会出现极端情况造成查询性能降低,于是有了平衡二叉树,但是这种树结构太过于严格,导致增加,修改和删除新能下降,于是可以理解为红黑树是这两种方案的这种,具有不错的增修删的性能,也具有非常好的查询性能。
对于每一个节点,我们假定标记为红色或者黑色,这样在节点变化时候可以通过重新标记节点颜色来调整树,如果不能平衡再通过旋转达到平衡状态。这个过程就是红黑树保持平衡的重要两点。
红黑树的性质:http://blog.csdn.net/cyp331203/article/details/42677833(看这位大神)
JDK8中HashMap源码分析
HashMap 是数组+链表+红黑树(JDK1.8 增加了红黑树部分)实现的:
//JDK 8 中新增的红黑树结构
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;
...
}
HashMap中的属性
//默认的初始容量为 16 数组的大小
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大的容量上限为 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认的负载因子为 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//变成树型结构的临界值为 8
static final int TREEIFY_THRESHOLD = 8;
//恢复链式结构的临界值为 6
static final int UNTREEIFY_THRESHOLD = 6;
//当哈希表中的容量大于这个值时,表中的桶才能进行树形化否则桶内元素太多时会扩容,而不是树形化为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;
//hash表,注意是个数组
transient Node<K,V>[] table;
//哈希表中键值对的个数
transient int size;
//哈希表被修改的次数
transient int modCount;
//它是通过 capacity*loadFactor 计算出来的,当 size 到达这个值时,就会进行扩容操作,这里也就可以知道只有当,比如默认大小为16,负载0.75,所以阈值就是12
int threshold;
//负载因子,假设负载因子 loadFactor=1,即当键值对的实际大小 size 大于 table 的实际大小时进行扩容
final float loadFactor;
Node 类的定义,它是 HashMap 中的一个静态内部类,哈希表中的每一个节点都是 Node 类型。我们可以看到,Node 类中有 4 个属性,其中除了 key 和 value 之外,还有 hash 和 next 两个属性。hash 是用来存储 key 的哈希值的,next 是在构建链表时用来指向后继节点的。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //key的hash值
final K key; //具体的Key
V value; //对应的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;
}
...... //其他方法省略
}
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;
//如果哈希表不为空并且key对应的桶上不为空
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;
//判断是否有后续节点
if ((e = first.next) != null) {
//如果当前的桶是采用红黑树处理冲突,则调用红黑树的get方法去获取节点
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//不是红黑树的话,那就是传统的链式结构了,通过循环的方法判断链中是否存在该key
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
实现步骤大致如下:
1、通过 hash 值获取该 key 映射到的桶。
2、桶上的 key 就是要查找的 key,则直接命中。
3、桶上的 key 不是要查找的 key,则查看后续节点:
-
如果后续节点是树节点,通过调用树的方法查找该 key。
-
如果后续节点是链式节点,则通过循环遍历链查找该 key。
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;
//如果哈希表为空,则先创建一个哈希表,resize()方法用于按照指定属性创建新表
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;
//如果桶上节点的 key 与当前 key 重复,那你就是我要找的节
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果是采用红黑树的方式处理冲突,则通过红黑树的 putTreeVal 方法去插入这个键值对
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//否则就是传统的链式结构
else {
//采用循环遍历的方式,判断链中是否有重复的 key
for (int binCount = 0; ; ++binCount) {
//到了链尾还没找到重复的 key,则说明 HashMap 没有包含该键
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果链的长度大于 TREEIFY_THRESHOLD = 8这个临界值,则把链变为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//找到了重复的 key就结束循环
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;
}
put 方法比较复杂,实现步骤大致如下:
1、先通过 hash 值计算出 key 映射到哪个桶。
2、如果桶上没有碰撞冲突,则直接插入。
3、如果出现碰撞冲突了,则需要处理冲突:
- 如果该桶使用红黑树处理冲突,则调用红黑树的方法插入。
- 否则采用传统的链式方法插入。如果链的长度到达临界值,则把链转变为红黑树。
4、如果桶中存在重复的键,则为该键替换新值。
5、如果 size 大于阈值,则进行扩容。
remove 方法
理解了 put 方法之后,remove 已经没什么难度了,所以重复的内容就不再做详细介绍了。
//remove 方法的具体实现在 removeNode 方法中,所以我们重点看下面的 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;
//如果当前 key 映射到的桶不为空
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;
//如果桶上的节点就是要找的 key,则直接命中
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);
}
}
//比对找到的 key 的 value 跟要删除的是否匹配
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;
}
JDK1.8 扩容机制
假设了我们的 hash 算法就是简单的用 key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组 table 的 size=2, 所以 key = 3、7、5,put 顺 序依次为 5、7、3。在 mod 2 以后都冲突在 table[1]这里了。这里假设负载因子 loadFactor=1, 即当键值对的实际大小 size 大于 table 的实际大小时进行扩容。接下来的三个步骤是哈希桶 数组 resize 成 4,然后所有的 Node 重新 rehash 的过程。
经过观测可以发现,我们使用的是 2 次幂的扩展(指长度扩为原来 2 倍),所以,元素的位置要么是在原位置,要么是在原位置再移动 2 次幂的位置。 看下图可以明白这句话的意思,n 为 table 的长度,图(a)表示扩容前的 key1 和 key2 两种 key 确定索引位置的示例,图(b)表示扩容后 key1 和 key2 两种 key 确定索引位置的示例, 其中 hash1 是 key1 对应的哈希与高位运算结果。
元素在重新计算 hash 之后,因为 n 变为 2 倍,那么 n-1 的 mask 范围在高位多 1bit(红色部分),因此新的 index 就会发生这样的变化:
因此,我们在扩充 HashMap 的时候,不需要像 JDK1.7 的实现那样重新计算 hash,只需要看看原来的 hash 值新增的那个 bit 是 1 还是 0 就好了,是 0 的话索引没变,是 1 的话索引变成“原索引+oldCap”。比如这个例子,16扩容到32,桶的变化
resize 方法
HashMap 在进行扩容时,每次扩容都是翻倍。
HashMap 在进行扩容时,使用的 rehash 方式非常巧妙,因为每次扩容都是翻倍,与原来计算(n-1)& hash 的结果相比,只是多了一个 bit 位,所以节点要么就在原来的位置,要么就被分配到“原位置+旧容量”这个位置。
例如,原来的容量为 32,那么应该拿 hash 跟 31(0x11111)做与操作;在扩容扩到 了 64 的容量之后,应该拿 hash 跟 63(0x111111)做与操作。新容量跟原来相比只 是多了一个 bit 位,假设原来的位置在 23,那么当新增的那个 bit 位的计算结果为 0 时,那么该节点还是在 23;相反,计算结果为 1 时,则该节点会被分配到 23+31 的 桶上。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
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)
newThr = oldThr << 1; // double threshold
}
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;
}
在这里有一个需要注意的地方,当哈希表中的键值对个数超过阈值时,才进行扩容的。
小结
如果一个桶上的冲突很严重的话,是会导致哈希表的效率降低至 O(n),而通过红黑树的方式,可以把效率改进至 O(logn)。相比链式结构的节点,树型结构的节点会占用比较多的空间,所以这是一种以空间换时间的改进方式。
扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。
当HashMap中的其中一个链的对象个数如果达到了8个,此时如果capacity没有 达到64,那么HashMap会先扩容解决,如果已经达到了64,那么这个链会变成 树,结点类型由Node变成TreeNode类型。当然,如果当映射关系被移除后, 下次resize方法时判断树的结点个数低于6个,也会把树再转为链表。
LinkedHashMap实现类
LinkedHashMap 是 HashMap 的子类
在HashMap存储结构的基础上,使用了一对双向链表来记录添加元素的顺序
与LinkedHashSet类似,LinkedHashMap 可以维护 Map 的迭代顺序:迭代顺序与 Key-Value 对的插入顺序一致
HashMap中的内部类Node 和 LinkedHashMap的Entry
//HashMap
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
//LinkedHashMap 的链表节点继承了 HashMap 的节点,而且每个节点都包含了前指针和后指针,所以这里可以看出它是一个双向链表
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);
}
}
属性
我们可以看见 LinkedHashMap 是继承自 HashMap 的,所以它已经从 HashMap 那里继承了与哈希表相关的操作了,那么在 LinkedHashMap 中,它可以专注于链表实现的那部分,所以与链表实现相关的属性如下:
//头指针
transient LinkedHashMap.Entry<K,V> head;
//尾指针
transient LinkedHashMap.Entry<K,V> tail;
/**
默认为 false。当为 true 时,表示链表中键值对的顺序与每个键的插入顺序一致,也就是说重复插入键,也会更新顺序
*/
final boolean accessOrder;
afterNodeAccess 方法
HashMap 中有如下三个方法:
// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }
有三个空方法。其实这三个方法表示的是在访问、插入、删除某个节点 之后,进行一些处理,它们在 LinkedHashMap 都有各自的实现。LinkedHashMap 正是通过重写这三个方法来保证链表的插入、删除的有序性。
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
//当 accessOrder 的值为 true,且 e 不是尾节点
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;
}
}
这段代码的意思简洁明了,就是把当前节点 e 移至链表的尾部。因为使用的是双向链表,所以在尾部插入可以以 O(1)的时间复杂度来完成。并且只有当 accessOrder 设置为 true 时,才会执行这个操作。在 HashMap 的 putVal 方法中,就调用了这个方法。
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);
}
}
afterNodeInsertion 方法是在哈希表中插入了一个新节点时调用的,它会把链表的头节点删除掉,删除的方式是通过调用 HashMap 的 removeNode 方法。
afterNodeRemoval 方法
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;
}
这个方法是当 HashMap 删除一个键值对时调用的,它会把在 HashMap 中删除的那个键值对一并从链表中删除,保证了哈希表和链表的一致性。
TreeMap实现类
TreeMap存储 Key-Value 对时,需要根据 key-value 对进行排序。 TreeMap 可以保证所有的 Key-Value 对处于有序状态。
TreeSet底层使用红黑树结构存储数据
TreeMap 的 Key 的排序:
- 自然排序:TreeMap 的所有的 Key 必须实现 Comparable 接口,而且所有的 Key 应该是同一个类的对象,否则将会抛出 ClasssCastException
- 定制排序:创建 TreeMap 时,传入一个 Comparator 对象,该对象负责对 TreeMap 中的所有 key 进行排序。此时不需要 Map 的 Key 实现 Comparable 接口
TreeMap判断两个key相等的标准:两个key通过compareTo()方法或 者compare()方法返回0。
Hashtable实现类
Hashtable是个古老的 Map 实现类,JDK1.0就提供了。不同于HashMap, Hashtable是线程安全的。
Hashtable实现原理和HashMap相同,功能相同。底层都使用哈希表结构,查询 速度快,很多情况下可以互用。
Hashtable 可以说已经具有一定的历史了,现在也很少使用到 Hashtable 了,更多的是使用 HashMap 或 ConcurrentHashMap。HashTable 是一个线程安全的哈希表,它通过使用 synchronized 关键字来对方法进行加锁,从而保证了线程安全。但这也导致了在单线程 环境中效率低下等问题。Hashtable 与 HashMap 不同,它不允许插入 null 值和 null 键。
源码分析
属性:
private transient Entry<?,?>[] table; //哈希表
private transient int count; //记录哈希表中键值对的个数
private int threshold; //扩容的阈值
private float loadFactor; //负载因子
构造方法:
public Hashtable(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal Load: "+loadFactor);
if (initialCapacity==0)
initialCapacity = 1;
this.loadFactor = loadFactor;
table = new Entry<?,?>[initialCapacity];
threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
}
public Hashtable(int initialCapacity) {
this(initialCapacity, 0.75f);
}
//默认的初始大小为11
public Hashtable() {
this(11, 0.75f);
}
public Hashtable(Map<? extends K, ? extends V> t) {
this(Math.max(2*t.size(), 11), 0.75f);
putAll(t);
}
从构造函数中,我们可以获取到这些信息:Hashtable 默认的初始化容量为 11(与 HashMap 不同),负载因子默认为 0.75(与 HashMap 相同)。
public synchronized V get(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}
跟 HashMap 相比,Hashtable 的 get 方法非常简单。我们首先可以看见 get 方法使用了 synchronized 来修饰,所以它能保证线程安全。并且它是通过链表的方式来处理冲突的。另外,我们还可以看见 HashTable 并没有像 HashMap 那样封装一个哈希函数,而是直接把哈希函数写在了方法中。而哈希函数也是比较简单的,它仅对哈希表的长度进行了取模。
其他的方法也是如此,大都被synchronized关键字修饰。
rehash 方法:
protected void rehash() {
int oldCapacity = table.length;
Entry<?,?>[] oldMap = table;
// //扩容扩为原来的两倍+1
int newCapacity = (oldCapacity << 1) + 1;
if (newCapacity - MAX_ARRAY_SIZE > 0) {
if (oldCapacity == MAX_ARRAY_SIZE)
// Keep running with MAX_ARRAY_SIZE buckets
return;
newCapacity = MAX_ARRAY_SIZE;
}
Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];
modCount++;
threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
table = newMap;
for (int i = oldCapacity ; i-- > 0 ;) {
for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) {
Entry<K,V> e = old;
old = old.next;
int index = (e.hash & 0x7FFFFFFF) % newCapacity;
e.next = (Entry<K,V>)newMap[index];
newMap[index] = e;
}
}
}
Hashtable 的 rehash 方法相当于 HashMap 的 resize 方法。跟 HashMap 那种巧妙的 rehash 方式相比,Hashtable 的 rehash 过程需要对每个键值对都重新计算哈希值,而比起异或和与操作,取模是一个非常耗时的操作,所以这也是导致效率较低的原因之一。
Properties实现类
Properties 类是 Hashtable 的子类,该对象用于处理属性文件。由于属性文件里的 key、value 都是字符串类型,所以 Properties 里的 key 和 value 都是字符串类型
存取数据时,建议使用setProperty(String key,String value)方法和 getProperty(String key)方法
//Properties:常用来处理配置文件。key和value都是String类型
public static void main(String[] args) {
FileInputStream fis = null;
try {
Properties pros = new Properties();
fis = new FileInputStream("jdbc.properties");
pros.load(fis);//加载流对应的文件
String name = pros.getProperty("name");
String password = pros.getProperty("password");
System.out.println("name = " + name + ", password = " + password);
} catch (IOException e) {
e.printStackTrace();
} finally {
if(fis != null){
try {
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
Collections工具类
Collections 是一个操作 Set、List 和 Map 等集合的工具类.
Collections 中提供了一系列静态的方法对集合元素进行排序、查询和修改等操作,还提供了对集合对象设置不可变、对集合对象实现同步控制等方法
常用方法
reverse(List):反转 List 中元素的顺序
shuffle(List):对 List 集合元素进行随机排序
sort(List):根据元素的自然顺序对指定 List 集合元素升序排序
sort(List,Comparator):根据指定的 Comparator 产生的顺序对 List 集合元素进行排序
swap(List,int, int):将指定 list 集合中的 i 处元素和 j 处元素进行交换
Object max(Collection):根据元素的自然顺序,返回给定集合中的最大元素
Object max(Collection,Comparator):根据 Comparator 指定的顺序,返回给定集合中的最大元素
Object min(Collection)
Object min(Collection,Comparator)
int frequency(Collection,Object):返回指定集合中指定元素的出现次数
void copy(List dest,List src):将src中的内容复制到dest中
boolean replaceAll(List list,Object oldVal,Object newVal):使用新值替换 List 对象的所旧值