Table of Contents
问题4:当hashmap的size超过设定的负载因子定义的阈值是会怎样?
问题5:在hashMap的resize过程中会有什么问题吗?
问题6: 为什么String, Integer还有其他的一些包装类型(wrapper class)被认为是好的key
问题7:我们可以使用任何的自定义对象作为HashMap的key吗?
问题8:我们可以用ConcurrentHashMap替代Hashtable吗?
问题9: HashMap的构造方法可以指定容量大小,如果我想分配一个能存储1000个元素的HashMap,应该指定多大的容量?
jdk1.8对HashMap使用红黑树优化的目的是什么,红黑树的结构是什么?
本篇文章基于jdk1.8
基本知识
jdk1.8中HashMap的整体结构
一个HashMap对象的整体结构是数组加链表的存储结构。
当链表节点数小于8时,则链表为普通单链表的存储形式,当链表节点数目大于8时,则会自动转换为红黑树(二叉平衡树的一种)存储元素。如上图所示。
结合数据结构与算法的知识,数组的特点是查找快,移动数据元素效率低,而链表移动数据方便,但是查找效率较低。两者结合,查找效率也比较高,移动数据元素也比较方便,设计思路也很清晰。ConcurrentHashMap在jdk1.8中也采取了这样的存储结构。数组与链表的结合从容器的角度来讲应该是一个非常经典的组合。也经历了相当的时间检验。
HashMap类中重要的属性有:
//默认的容量,即默认的数组长度 16,必须是2的次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大的容量,即数组可定义的最大长度,即2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//实际存储的键值对个数,即所有的元素个数,和capacity要区分开
transient int size;
//HashMap的数据被修改的次数,用于迭代防止结构性破坏的标量
transient int modCount;
//数组类型的Node
transient Node<K,V>[] table;
//公式:threshold = capacity * loadFactor
//HashMap的扩容阈值,在HashMap中存储的Node键值对超过这个数量时,自动扩容容量为原来的二倍
int threshold;
final float loadFactor;
//默认的负载因子,
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表长度达到8时将链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
//链表长度小于6时,解散红黑树
static final int UNTREEIFY_THRESHOLD = 6;
//默认的最小的扩容量64,为避免重新扩容冲突,至少为4 * TREEIFY_THRESHOLD=32,即默认初始容量的2倍
static final int MIN_TREEIFY_CAPACITY = 64;
1<<4是移位运算。即:1左移四位
移动前: 0000 0000 0000 0001
移动后: 0000 0000 0001 0000
1 <<4对应的十进制数值是16. 但是事实上,HashMap里面的容量大小都是用二进制表示的。只是我们平常会把它说成十进制的便于沟通。为何要用二进制呢?这个问题先放在这里。
数据元素节点类
有两个,一个是Node类,另一个是TreeNode类。从用途上讲,Node就是普通的链表节点类,TreeNode是红黑树的节点类。
Node类十分简单,定义如下:
static class Node<K,V> implements Map.Entry<K,V> {
final int 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; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
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;
}
}
和普通的链表节点差别不大。逻辑图如下:
但是TreeNode类在HashMap里面仅仅从代码量上来讲就占据了600行,可见jdk1.8做的改动还是非常之大的。虽然代码量大,但多的是针对TreeNode操作的方法,其数据结构还是简单的.
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;
父节点,左右子节点,是否为红色节点,prev是前一个节点。
HashMap的get方法实现原理
另外因为查询过程不涉及到HashMap的结构变动,核心逻辑就是遍历table某特定位置上的所有节点,分别与key进行比较看是否相等。代码如下:
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) {
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;
}
比较有意思的一点是,在查找之前,先判断了一下第一个元素是不是,如果是则直接返回了,加这样一个判断在这里也是有他的讲究的,因为如果第一个元素就是我们找的元素,那么后面的一切都不需要做了,直接返回即可。
若不是第一个,则判断是否为TreeNode,是TreeNode就调用方法getTreeNode返回。
若不是,则不停的遍历直到找到一个hash和key都相等的元素返回。
注意,我们强调的先定为在数组中找到对应的数组位置,在代码里并不会体现的那么直接,这里的if(e.hash== hash)为true的时候事实上就找到了这个位置。
除此之外我们需要注意的是,在put元素的时候,也是需要先找到这个元素再进行修改,因此可以肯定的是,在put当中找元素的一部分逻辑就是get方法的实现逻辑。
HashMap的put方法
先抛开HashMap的实现代码和逻辑不管,我们现在已经知道了HashMap的逻辑结构是一个Node类型的数组,每一个数组的元素又是一个链表的第一个元素,即数组加链表的逻辑结构实现。那么基于这样一个逻辑,我们不妨先推测一下它的put方法应该如何实现。
整体的思路无非是:先定位这个节点在数组的哪一个位置,然后再从这个位置开始找这个元素。带着这个思路我们来看下HashMap的实现,看我们的推测是否正确。
HashMap的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;
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)
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;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
代码很多,不管三七二十一,先找它在哪定为了数组元素的位置。
第二个if里的判断条件是: p = tab[i = (n - 1) & hash]) == null. 这个判断就是在计算数组中的位置是否为null,如果为null,则直接把这个put的元素放在那个地方。元素在数组中的位置就是i,在这里就是根据(n - 1) & hash计算出了在数组中的位置。为什么要这么做呢?我们知道n代表的数组的长度,它必须是2的倍数,因此n只可能是:10000,100000....
以n=16即10000为例,则n-1=1111.
而这里的hash值是通过传入的key计算而来的,计算hash值得方法如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
1. 通过key.hashCode()计算出key的哈希值,即h. 例如:
2. 然后将哈希值h右移16位
3.将原来的h与右移16位的h做与运算.
经过这三步后算出来的hash值保留了所有特征。
接下来,根据table的实际长度减1做模运算,这里也能看出为什么HashMap要限制table的长度为2的n次幂,因为这样,n-1可以保证二进制展示形式是(以16为例)0000 0000 0000 0000 0000 0000 0000 1111。在做"与"操作时,就等同于截取hash二进制值得后四位数据作为下标。
在定为了数组位置之后,若为空直接插入,若不为空,则判断节点是否为TreeNode即红黑树节点,如果是则,调用红黑树的put方法。
如果不是红黑树就是链表,若是插入一个新的元素,则在插入之后会判断这个链表的长度是否大于8,大于呢就会将这个链表转换为树。
HashMap是如何扩容的?为何都是2的N次幂?
HashMap中扩容的方法是resize().有两个调用resize方法的地方,第一是在put时,如果当前table为null也就是还未初始化,则调用resize方法进行初始化,因此hashmap是一个lazyloading的机制。初始化为默认容量大小16。
第二个就是在put操作完成后当数组的元素数目size大于临界值threshold之后,就进行resize.
resize方法的实现代码很长,我们先来看他的注释:
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
初始化或者把table的大小翻倍,如果table为null(即第一次put的时候),则根据初始化的容量大小分配。否则,因为我们使用的是power-of-two(次方)扩展机制(即扩展为两倍),每一个元素节点只会要么保持在一样的位置(index),或者移动到一个新table上的次方的偏移(offset)位置上去. 看注释真是一个学习源代码的极其好的方法。
再来看关键代码:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
...
int newCap, newThr = 0;
...
//计算新的容量等等
...
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;
//如果 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 { // 原索引+oldCap
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到新的table里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到新的table里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
结合注释来看基本逻辑是清楚的,在代码中用到了与运算(&)来计算元素的位置. 这意味着在扩容之后,元素是否需要移动位置由其与最扩容之后最高位的1相与的结果决定。例如一个table要从16扩容到32
当size是16时,其对应的二进制为:10000
size-1是15, 其对应的二进制为1111
扩容之后16变为32,对应的二进制为100000
size-1就是31,对应的二进制是11111。
下图假设n位table的size,图片来源。
在这种情况下我们假设,有一个元素的hash值算出来之后后四位为0101,即十进制的5,则它在这次扩容之后是否需要移动位置,就是由它的倒数第五位决定的,若倒数第五位是1,则1与1等于1,此时,元素位置变为10101,即十进制的21(5+16),位置改变,若倒数第五位是0,则0与1仍然是0,元素位置仍然是0101,位置不变。如图:
优点: 因此在jdk1.8中扩容的时候并未重新计算hash值,只需要看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,因此它省去了计算hash值得时间,并且resize也相对均匀,数据元素的顺序并未倒置。这些与jdk1.7都不一样。
红黑树的扩容方式与普通链表的扩容方式本质上是一样的。因此,我们现在可以回答问题为什么扩容始终是2的次幂,因为扩容是2的次幂的话,就会有上述的优点.
总结起来,就是用之前的hashcode值和size值做与运算
一些问题
问题1:HashMap的get方法是怎么工作的
HashMap是根据hashing算法实现的。在HashMap里面我们有put(key, value)和get(key)的方法用于存储和获取对象,当我们把key和value传到HashMap的put方法时,HashMap的实现是根据key调用Object类的HashCode方法计算哈希值(hashcode),再把这个hashcode传入到自己的hash()方法里面,来计算得到存储入口对象的桶位置(即数组上的位置,注意,所谓桶就是数组上的一个节点,因为在这个节点上面是一条链表,链表上面的元素都拥有相同的hash值,因此用了桶bucket这个形象的称谓)。有个很重要的点就是java里的HashMap会把key和value一起存储成桶里的一个Map.Entry。这一点对于HashMap的获取对象逻辑是必须知道的。
问题2:如果两个对象有相同的hashcode会发生什么?
HashCode若相同,则对象在Hash桶中的位置就是一样的,即位于数组的同一个下标处,此时hash碰撞发生。HashMap会使用链表来处理hash碰撞,将相同hashcode的entry(这个entry是一个由key和value组成的Map.Entry对象)放在一个链表(linkedList)里。当然我们知道在jdk1.8之后引入了红黑树来替代链表当entry超过8个时。
问题3:如何获取Key的hashcode相同的对象?
一种典型的错误回答如下:
当调用HashMap的get方法是它会根据key对象的hashcode值去找到它的桶位(bucket location),然后再获取value对象,但是需要主要的是若存在key的hashcode相同的情况,那么同一个桶位则存储有不止一个对象,这个时候你可能会想到好吧那么这个时候我就需要去这个桶位的链表上去遍历,但是现在又有一个问题,你怎么辨认不同的value对象呢,因为你并没有一个value对象来做比较?
要回答这个问题必须清楚的说出:链表里不仅存储了value,也同时是存储了key的,key和value一起构成了链表的node——Map.entry。若回答不出这一点,这无论怎样都解释不清楚这个问题。
因此,对这一点清楚的话,就能最准确答出这个问题:在通过key的hashcode找到桶位之后,HashMap会调用key.equals()方法来确认链表上正确的node然后返回相应key的value。这是最完美的答案。
上面的问题是具有疑惑性的,让人容易感到混淆不清的是我们既先调用了key的hashcode()方法,后来又去调用了key的equals方法。
问题4:当hashmap的size超过设定的负载因子定义的阈值是会怎样?
hashmap有一个变量叫做load factor,默认是0.75,意思是若hashmap填充到75%之后它就会resize。这一点和ArrayList很类似。HashMap会对自身进行resize,它会创建一个新的两倍大小的数组并且把之前的每一个老元素放进去,这个过程也叫做rehashing,在jdk1.8之前,这个方法是需要重新计算hash值得,在jdk1.8开始,不再需要再次计算而是通过一个巧妙的与运算来确定位置,本篇的另一个部分对此有详细介绍
问题5:在hashMap的resize过程中会有什么问题吗?
在并发下hashmap可能会有资源竞争(race condition),如果两个线程在同一时刻发现HashMap需要reszie并且他们都尝试去resize,那么在resize的过程当中,HashMap可能会形成一个无限循环,即一个闭环。但毫无疑问的是,HashMap就不是为并发而设计的容器。这个问题在下一节有更详细的内容。
问题6: 为什么String, Integer还有其他的一些包装类型(wrapper class)被认为是好的key
字符串、整数和其他包装类是HashMap的自然候选者。并且字符串是最常用的键,因为字符串是不可变的和用final修饰的,并且重写了equals方法和hashcode方法。
其他包装类也具有相似的性质。不可变性(Immutability)是必须的,强制要求的,这是为了防止用于计算hashcode的字段发生变化,因为如果在插入和检索(retrieval)过程中,key对象返回不同的哈希代码,那么就不可能从HashMap那里获得对象。
不可变是最好的,因为它提供了其他的优点比如线程安全,如果你能保持你的哈希代码保持不变通过只使某些字段是final修饰的,那么也一样。由于在hashmap中检索值对象时使用了equals和hashcode方法,所以关键对象正确地重写这些方法并遵循相关的准则契约是很重要的。如果不相等的对象返回不同的哈希码,那么碰撞的机会将更少,这将会提高HashMap的性能。
问题7:我们可以使用任何的自定义对象作为HashMap的key吗?
这是先前问题的一个延伸问题。当然,在Java HashMap中,可以使用任何对象作为关键字,只要遵循equals和hashcode契约,它的哈希代码就不会在对象插入到map之后发生变化。如果自定义对象是不可变(immutable)的,那么它将被维护,因为一旦创建,就不能更改它。
问题8:我们可以用ConcurrentHashMap替代Hashtable吗?
这是另一个越来越受欢迎的问题因为ConcurrentHashMap越来越流行。因为我们知道Hashtable是同步的,但是ConcurrentHashMap只能通过并发级别确定的映射的锁定部分来提供更好的并发性。ConcurrentHashMap当然是作为哈希表引入的,可以用来代替它,但是哈希表比并发映射提供了更高的线程安全性。因此并不能完全替代它尤其是当需要非常高的线程安全性时。
问题9: HashMap的构造方法可以指定容量大小,如果我想分配一个能存储1000个元素的HashMap,应该指定多大的容量?
答案隐藏在tableSizeFor方法当中,该方法代码如下:
/**
* Returns a power of two size for the given target capacity.
*/
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;
}
功能是:返回一个大于等于且最接近 cap 的2的幂次方整数,如给定9,返回2的4次方16。检查所传的参数是否为2的幂次方,且不能为负数(负数变为1),且不能超过常量MAXIMUM_CAPACITY(超过变为MAXIMUM_CAPACITY),如果不为2的幂次方,将其变为,比cap大的最小的2的幂次方的值
while (capacity < initialCapacity){ //确保容量为2的n次幂;使capacity为大于initialCapacity的最小的2的幂次方
capacity <<= 1;
}
关于初始化可以参考一篇不错的博客:https://blog.csdn.net/moakun/article/details/80494253
问题10:HashMap容量为什么是2的次方?
HashMap中的数据结构是数组+单链表的组合,我们希望元素存放的更均匀,最理想的效果是Entry数组中每个位置都只有一个元素,这样,查询的时候效率最高,不需要遍历单链表,也不需要通过equals去比较Key,而且空间利用率最大。那么如何计算才会分布最均匀呢?我们首先想到的就是%运算,哈希值%容量=bucketIndex,我们来看源码:
static int indexFor(int h,int length){
return h & (length - 1);
}
h是通过k的hashCode最终计算出来的哈希值,并不是hashCode本身,而是hashCode之上又经过一层运算的hash值,length是目前容量。当容量是2^n时,h & (length -1) == h % length。
这个等式实际上可以推理出来,2^n转换成二进制就是1+n个0,减1之后就是0+n个1,如16 -> 10000,15 -> 01111,那根据&位运算的规则,都为1(真)时,才为1,那0≤15运算后的结果≤15,假设h <= 15,那么运算后的结果就是h本身,h >15,运算后的结果就是最后三位二进制做&运算后的值,最终,就是%运算后的余数。
笼统的说:为了便于计算位置时的运算方便,利用二进制数位运算的特点来使得计算位置更加容易。
问题11:HashMap为什么快?
数组加链表
问题12: 为什么HashMap要使用红黑树?
这个问题下面单独开了一节回答,核心的原因是为了优化在链表上的查找效率。
HashMap在并发下可能会有怎样的问题?
HashMap在put的时候,插入的元素超过了容量(由负载因子决定)的范围就会触发扩容操作,就是rehash,这个会重新将原数组的内容重新hash到新的扩容数组中,在多线程的环境下,存在同时其他的元素也在进行put操作,如果hash值相同,可能出现同时在同一数组下用链表表示,造成闭环,两个线程同时对hashmap的rehash操作可能会让node链表有形成一个环形数据结构,这时去get这个环形数据结构就会进入一个无限循环。
具体的说线程1的rehash执行完成之后之前链表中的节点顺序会被颠倒,此时另一个线程2操作的链表节点先后顺序仍然是之前的节点顺序,在线程2进行rehash之后就会出现死循环画图更好说明。具体的环形链表产生的情况暂时不做详细介绍,但是我们可以对最终出现的情况进行一个分析。 我们知道在链表里找元素的时候,如果当前node不是我们要找的,那么我们会调用node.next去找它的下一个元素,因此,当出现如果一个数据结构如下时:
那么我们只有正在找这三个node的其中一个,并且这几个node都不是我们要找的,那么,这就是一个无限循环了。
关于这个问题,oracle的java bug库里有这样一个bug是由于并发下的HashMap造成的,很有意思,链接。
jdk1.8对HashMap使用红黑树优化的目的是什么,红黑树的结构是什么?
在jdk1.7中我们知道一个hashmap的一条链表中,有很多的元素的时候,这个时候的查找的时间复杂度就是 O(n)。
在jdk1.8引入红黑树的目的就是为了优化这种时候的效率。使用红黑树后在链表上查找元素的时间复杂度是O(logn).
要说红黑树的结构,我们需要先从二叉查找树说起:
二叉查找树
二叉查找树,BST-binary search tree,也叫二叉排序树。它具有以下这样的特点:
- 若左子树非空,则左子树上所有节点均小于根节点
- 若右子树非空,则右子树上所有节点均大于根节点
- 左右子树也分别是一颗BST树
下图是一个例子:
二叉查找树是一个递归的数据结构,可以方便的使用递归算法对它进行各种运算。它的查找较为方便,逻辑是:将给定值与根节点的关键字比较,相等则查找成功,若不相等,当根节点大于给定值的时候,在根节点的左子树中查找,否则在右子树中查找。这显然也是一个递归的过程。
但是,BST可能会存在一个这样的问题,即相同的关键字其插入顺序不同可能生成不同的二叉排序树,如图:
若是一个这样的bst,那么它的查找时间复杂度就是o(n).
平衡二叉树
为了避免BST的高度增长过快,性能降低,规定:在插入和删除BST树节点时,要保证任意节点的左右子树高度差的绝对值不超过1,这样的二叉查找树就成为平衡二叉树。简称平衡树,AVL.
平衡二叉树在插入和删除的时候就涉及到旋转的问题,在这里不做深入讨论
红黑树
但是平衡二叉树的条件又太严苛了,于是又有了红黑树。
1、红黑树放弃了追求完全平衡,追求大致平衡,在与平衡二叉树的时间复杂度相差不大的情况下,保证每次插入最多只需要三次旋转就能达到平衡,实现起来也更为简单。
2、平衡二叉树追求绝对平衡,条件比较苛刻,实现起来比较麻烦,每次插入新节点之后需要旋转的次数不能预知。 红黑树和AVL树类似,都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能。
红黑树和AVL树的区别在于它使用颜色来标识结点的高度,它所追求的是局部平衡而不是AVL树中的非常严格的平衡。 红黑树是牺牲了严格的高度平衡的优越条件为代价红黑树能够以O(log2 n)的时间复杂度进行搜索、插入、删除操作。 此外,由于它的设计,任何不平衡都会在三次旋转之内解决。 当然,还有一些更好的,但实现起来更复杂的数据结构能够做到一步旋转之内达到平衡,但红黑树能够给我们一个比较“便宜”的解决方案。 红黑树的算法时间复杂度和AVL相同,但统计性能比AVL树更高.
红黑树要满足以下要求:
1.节点是红色或黑色。
2.根节点是黑色。
3.每个叶子节点都是黑色的空节点(NIL节点)。
4 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
5.从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
下图是一个典型的红黑树:
因此,红黑的目的就是为了优化链表查找效率。TreeMap, TreeSet也用了这个数据结构。
HashMap和TreeMap的区别
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;
...
}
再明显不过了,这是一个红黑树的节点,因此整个TreeMap都是基于红黑树实现的。HashMap通常比TreeMap快一点(树和哈希表的数据结构使然),建议多使用HashMap,在需要排序的Map时候才用TreeMap。
对于在Map中插入、删除和定位元素这类操作,HashMap是最好的选择。然而,假如你需要对一个有序的key集合进行遍历,TreeMap是更好的选择。基于你的collection的大小,也许向HashMap中添加元素会更快,将map换为TreeMap进行有序key的遍历。
HashMap和HashTable有什么区别
最大区别是线程安全上的区别:两者最主要的区别在于Hashtable是线程安全,而HashMap则非线程安全。Hashtable的实现方法里面都添加了synchronized关键字来确保线程同步,因此相对而言HashMap性能会高一些
数据结构上的区别:HashMap是数组加链表加红黑树,HashTable是数组加链表的实现
计算hash值:两边都是借用了Object类的hashcode方法,但是各自也分别在此基础上做了一些对hashcode的改动