JAVA7
Java7的ConcurrentHashMap里有多把锁,每一把锁用于其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率呢。这就是“锁分离”技术。
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁(继承了ReentrantLock),在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。
ConcurrentHashMap底层逻辑结构
一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构,Segment数组中每个Segment里包含一个HashEntry数组,一个HashEntry数组中的每个hashEntry对象是一个链表的头结点,每个链表结构中包含的元素才是Map集合中的key-value键值对。如下图:
由上图可见,ConcurrentHashMap的数据结构;一个Segment对应(锁住)一个HashEntry数组。
每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
确定插入的元素在哪一个Segment的位置,当然也是Hash,这个算法也是左移、右移等,然后再插入到具体的HashEntry数组中。
Java7中ConcurrentHashMap使用的就是锁分段技术,ConcurrentHashMap由多个Segment组成(Segment下包含很多Node,也就是我们的键值对了),每个Segment都有把锁来实现线程安全,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
因此,关于ConcurrentHashMap就转化为了对Segment的研究。这是因为,ConcurrentHashMap的get、put操作是直接委托给Segment的get、put方法。
JAVA8
个人理解:Java8中的锁的粒度比Java7中更细了,Java8锁住的一个某一个数组元素table[i](头节点,该头结点类型是链表头结点或红黑树的头结点),而Java7中segment锁住的是一个HashEntry数组,相当于锁住了多个数组元素;所以我感觉Java8中ConcurrentHashMap多线程环境下put效率更高。
先来看一下HashMap(先理解HashMap再看ConcurrentHashMap更容易)集合底层的数组Node[] table的某一个元素table[i](即某一个链表的头结点),表示Map集合一些泛型对象构成的链表或者红黑树。
static class Node implements Map.Entry<K,V> {
final int hash; //用来定位数组索引位置
final K key;
V value;
Node next; //链表的下一个node
Node(int hash, K key, V value, Node<K,V> next) { ... }
public final K getKey(){ ... }
public final V getValue() { ... }
public final String toString() { ... }
public final int hashCode() { ... }
public final V setValue(V newValue) { ... }
public final boolean equals(Object o) { ... }
}
(重点)
为什么链表长度大于8就要转红黑树?
红黑树的插入、删除和遍历的最坏时间复杂度都是log(n),因此,意外的情况或者恶意使用下导致hashcode()方法的返回值很差时,只要Key具有可比性,性能的下降将会是"优雅"的。
但由于TreeNodes的空间占用是常规Nodes的两倍,所以只有桶中包含足够多的元素以供使用时,我们才应该使用树。那么为什么这个数字会是8呢?
官方文档的一段描述:
Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too small (due to removal or resizing) they are converted back to plain bins. In usages with well-distributed user hashCodes, tree bins are rarely used. Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution (http://en.wikipedia.org/wiki/Poisson_distribution) with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The first values are:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
理想情况下,在随机哈希代码下,桶中的节点频率遵循泊松分布,文中给出了桶长度k的频率表。
由频率表可以看出,同一个Hash数组位置冲突达到8的概率非常非常小(一亿分之六)。所以作者应该是根据概率统计而选择了8作为阀值,由此可见,这个选择是非常严谨和科学的。
所以链表转红黑树概率很低;但是一旦元素个数大于8链表的查询、删除(删除要先查询到才能删)效率综合来看比不上红黑树的效率(本文末有对红黑树简单介绍)。
Java8 HashMap的hash算法,如果key为null,hash值为0;如果不为null,直接将key的hashcode值h与h无符号右移16位后的数进行按位异或^位运算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
ConcurrentHashMap的计算Hash值函数和HashMap不太一样:
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
......
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
(重点)Java8解决Java7中HashMap多线程情况下造成的遍历元素死循环
Java7 HashMap put元素后超过了阈值,就需要扩容,此时如果有多线程同时扩容(resize调用transfer,会倒排原有的一个链表中元素的顺序,因为是头插法,比如原来的顺序是A->B->C,扩容后就可能是A,B->A,C->B->A,而另一个线程来扩容也是头插法为C,B->C,A->B->C,第一个线程有C->B,第二个线程B->C,而这里形成了环形链表)情况下,其中一个A线程挂起,而另外的线程完成扩容后,然后A线程不再挂起,继续扩容,会形成一个环形链表,下一次调用get方法或put方法遍历这个Hash数组的位置的链表,如果元素key不存在,就在这个循环链表中无限循环遍历了,因为不存在尾结点的指针域为null停止循环遍历了。
Java8中的HashMap put时如果有冲突需要插入到链表尾部,Java7是插入到头部;
不管是头插入还是尾部插入,都是需要遍历对应数组位置的所有链表或红黑树节点,因为如果key存在,是更新操作,返回oldValue;
Java8,除了对hashmap增加红黑树结果外,对Java7原有造成死锁的关键原因点(扩容时新table复制在头端添加元素)改进为依次在末端添加新的元素;
Java8利用红黑树改进了链表过长查询遍历慢问题,多线程resize时保持原来链表的元素顺序( loTail、hiTail尾部插入,不再根据key的hash值重新散列到新数组,而是放到原数组下标或下标为(原数组下标+oldCapacity老数组容量)的位置,见Java8 HashMap resize源码),避免出现导致put产生循环链表的bug。
图解:https://blog.csdn.net/linsongbin1/article/details/54708694
Java8HashMap resize部分源码
(重点)下面代码中的"if ((e.hash & oldCap) == 0) "这里按位于位运算,实际上是判断元素的key的hash值与原来老数组的容量的关系来决定扩容后元素放在新数组的下标;
比如老容量为16,二进制表示10000,如果hash值表示的二进制数从右往左数在第5位为1,则e.hash & oldCap 必定为10000不为0,而如果hash值表示的二进制数从右往左数在第5位为0,则则e.hash & oldCap 必定为0;
比如老数组容量为16,A元素的hash值是17,那在原数组中A的下标为j = 17 & (16 - 1) = 1, 扩容后如果是正常散列17应该放在 17 & (32 -1)= 17的位置,而e.hash & oldCap 即17 & 16等于16不等于0,所以扩容后的位置为j+oldCap=17(j =1, oldCap = 16);
比如老数据容量为16,A元素的hash值是33,那在原数组中A的下标为j = 33 & (16 - 1) = 1, 扩容后如果是正常散列33应该放在 33 & (32 -1)= 1的位置,而e.hash & oldCap 即33 & 16等于0,所以扩容后的位置仍然是为j(j =1);
可以简单体会下扩容位运算判断元素散列到原来老数组这一半的下标,还是新数组这一半的下标,与元素正常散列到hash新数组的关系。
final Node<K,V>[] 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
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// (重点)可以简单体会下扩容位运算判断元素散列到原来老数组这一半的下标,还是新数组这一半的下标,与正常散列到hash新数组的关系。
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;
}
JDK1.8的ConcurrentHashMap中Segment虽保留,但已经简化属性,仅仅是为了兼容旧版本。
/*** Stripped-down version of helper class used in previous version, * declared for the sake of serialization compatibility*/staticclassSegmentextendsReentrantLockimplements Serializable {
privatestaticfinallongserialVersionUID = 2249069246763182397L;
finalfloat loadFactor;
Segment(floatlf) {this.loadFactor = lf; }
}
JAVA8中ConcurrentHashMap的底层与Java8的HashMap有相通之处,底层依然由“数组”+链表+红黑树来实现的,底层结构存放的是TreeBin对象,而不是TreeNode对象;TreeBin包装的很多TreeNode节点,它代替了TreeNode的根节点,也就是说在实际的ConcurrentHashMap“数组”中,存放的是TreeBin对象,而不是TreeNode对象,这是与HashMap的区别之一。
ConcurrentHashMap实现中借用了较多的CAS(Compare And Swap)算法(sun.misc.Unsafe对象中有很多native本地方法,如unsafe.compareAndSwapInt(this, valueOffset, expect, update))。
意思是如果valueOffset位置包含的值与expect值相同,则更新valueOffset位置的值为update,并返回true,否则不更新,返回false。
我理解为如果将要更新的变量的值如果和这个线程从Map中取出值相同,那么就更新,否则就不更新(和CAS的思想一样)。
ConcurrentHashMap既然借助了CAS来实现非阻塞的无锁实现线程安全,那么是不是就没有用锁了呢??答案:还是使用了synchronized关键字进行同步了的,在哪里使用了呢?在操作同一Node数组下标的链表或红黑树的头结点还是会synchronized上锁,这样才能保证线程安全。
(重点)
因为如果不锁住该位置的头结点,当一个线程在对该Hash数组的该位置的链表或者红黑树进行写操作时,如果其他线程操作(修改,添加元素,删除等)引起了Map的resize(扩容或缩减),该链表或红黑树的hash值可能会发生变化,而正在进行写操作(如put)的线程会因为hash值改变而找不到该位置对于的元素,还有例如插入到当前尾结点后面,如果当前尾结点正好被删除了就会有问题;
锁住了头结点,其他线程就无法操作(修改,添加元素,删除等)该链表或者红黑树,当持有锁的线程操作完毕,释放头结点锁,其他线程才有机会获得该位置的锁,然后进行操作。
ConcurrentHashMap整个类的源码,和HashMap的实现基本一模一样,当有修改操作时借助了synchronized来对table[i](头结点)进行锁定保证了线程安全以及使用了CAS来保证原子性操作,其它的基本一致,例如:ConcurrentHashMap的get(int key)方法的实现思路为:根据key的hash值找到其在table所对应的位置i,然后在table[i]位置所存储的链表(或者是树)进行查找是否有键为key的节点,如果有,则返回节点对应的value,否则返回null。
思路和HashMap中get方法的思路一样。
(重点)为什么要指定HashMap的容量?
首先创建HashMap指定容量比如1024后,并不是HashMap的size不是1024,而是0,插入多少元素,size就是多少;
然后如果不指定HashMap的容量,要插入768个元素,第一次容量为16,需要持续扩容多次到1024,才能保存1024*0.75=768个元素;
而HashMap扩容是非常非常消耗性能的,Java7中每次先创建2倍当前容量的新数组,然后再将老数组中的所有元素再次通过hash&(length-1)的方式散列到HashMap中;
Java8中HashMap虽然不需要重新根据hash值散列后的新数组下标,但是由于需要遍历每个Node数组的链表或红黑树中的每个元素,根据元素key的hash值与原来老数组的容量的关系来决定放到新Node数组哪一半(2倍扩容),还是需要时间的。
(重点)HashMap指定容量初始化后,底层Hash数组已经被分配内存了吗?
Java8 HashMap指定容量构造方法源码,会调整threshold为2的整数次幂,比如指定容量为1000,则threshold为1024:
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
/**
* 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;
}
可以根据源码看到,在指定的HashMap初始容量后,底层的Hash数组Node<K,V>[] table此时并没有被初始化,依旧为null;
那么是什么时候被初始化的呢?见:https://www.cnblogs.com/theRhyme/p/11763685.html
ConcurrentHashMap类中相关属性的介绍
sizeCtl最重要的属性之一,看源码之前,这个属性表示什么意思,一定要记住。
private transient volatile int sizeCtl;//控制标识符
sizeCtl是控制标识符,不同的值表示不同的意义。
负数代表正在进行初始化或扩容操作 ,其中-1代表正在初始化 ;-N(N>1) 表示有N-1个线程正在进行扩容操作
0表示未被初始化;
正数表示下一次进行扩容的大小,sizeCtl的值 = 当前ConcurrentHashMap容量*0.75,这与loadfactor是对应的。实际容量>=sizeCtl,则扩容。
1、transient volatile Node[] table;
是一个Node数组,第一次插入数据的时候初始化,大小是2的幂次方。这就是我们所说的底层结构:”数组+链表(或树)”。
注意这里的Node数组加了volatile(volatile关键字的作用)进行修饰,table数组在内存中对所有线程都及时可见,如果一个线程修改了table数组的值,其他线程中如果自己的线程栈中有table的副本,就会把table缓存行设置为失效,强制从内存中读取table数组的值。
所以一个线程调用ConcurrentHashMap的get方法也能获得最新的Map集合元素的值(迭代器可能是旧值)。
2、private static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量
3、private static final intDEFAULT_CAPACITY = 16;
4、static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; // MAX_VALUE=2^31-1=2147483647
5、private static finalint DEFAULT_CONCURRENCY_LEVEL = 16;
6、private static final float LOAD_FACTOR = 0.75f;
7、static final int TREEIFY_THRESHOLD = 8; // 链表转树的阈值,如果table[i]下面的链表长度大于8时就转化为数
8、static final int UNTREEIFY_THRESHOLD = 6; //树转链表的阈值,小于等于6是转为链表,仅在扩容tranfer时才可能树转链表
9、static final int MIN_TREEIFY_CAPACITY = 64;
10、private static final int MIN_TRANSFER_STRIDE = 16;
11、private static int RESIZE_STAMP_BITS = 16;
12、private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1; //help resize的最大线程数
13、private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
14、static final int MOVED = -1; // hash for forwarding nodes(节点在Map中的hash值)、标示位
15、static final int TREEBIN = -2; // hash for roots of trees(树根节点的hash值)
16、static final int RESERVED = -3; // hash for transient reservations(保留节点的hash值,为了扩容的时候吧)
在ConcurrentHashMap中有一个构造方法中有一个参数:concurrencyLevel,表示能够同时更新ConccurentHashMap且不产生锁竞争的最大线程数。默认值为16,(即允许16个线程并发可能不会产生竞争)。为了保证并发的性能,我们要很好的估计出concurrencyLevel值,不然要么竞争相当厉害,从而导致线程试图写入当前锁定的段时阻塞。
ConcurrentHashMap的get()方法没有对任何对象加锁,所以可能会读到旧的数据(比如通过迭代器遍历的时候,其他线程修改了数组元素),和HashMap的get方法的原理是一模一样的。
ConcurrentHashMap链表转树时,并不会直接转,正如注释(Nodes for use in TreeBins)所说,只是把这些节点包装成TreeNode放到TreeBin中,再由TreeBin来转化红黑树。
ConcurrentHashMap不允许null key或value,HashMap可以;
ConcurrentHashMap的计算Hash值函数也和HashMap不一样:
还有一些其他区别,就不打算详细说明了。
(重点)
putVal方法
putVal(K key, V value, boolean onlyIfAbsent, boolean evict)大体流程如下:
1、检查key/value是否为null,如果为null,则抛异常,否则进行2
2、进入for死循环,进行3
3、检查table是否初始化了,如果没有,则调用initTable()进行初始化然后进行 2,否则进行4
4、根据key的hash值计算出其应该在table中储存的位置i(根据key的hashcode计算出Hash值,在将Hash值与length-1进行按位与,length是2的整数次幂,减1后的二进制与Hash值进行按位与相当于取余运算,但取余的位运算次数肯定不止1次,而这里一次位运算就得出结果,效率更高),取出table[i]的节点用f表示。
根据f的不同有如下三种情况:
1)如果table[i]==null(即该位置的节点为空,没有发生碰撞),则利用CAS操作直接存储在该位置,如果CAS操作成功则退出死循环。
2)如果table[i]!=null(即该位置已经有其它节点,发生碰撞),碰撞处理也有两种情况
2.1)检查table[i]的节点的hash是否等于MOVED(-1),如果等于,则检测到正在扩容,则帮助其扩容
2.2)说明table[i]的节点的hash值不等于MOVED,synchronized锁住头结点table[i],进行插入操作;
如果table[i]为链表节点,则将此节点插入链表末尾中即可;
如果table[i]为树节点,则将此节点插入树中即可;
插入成功后,进行 5
5、如果table[i]的节点是链表节点,则检查table的第i个位置的链表的元素个数是否大于了8,大于8就需要转化为树,如果需要则调用treeifyBin函数进行转化。
链表转树:将数组tab的第index位置的链表转化为红黑树。
6、插入成功后,如果key已经存在,返回oldValue;key开始不存在,返回null。
上面第4点中的2)中的2.1)帮助扩容:如果当前正在扩容,则尝试协助其扩容,死循环再次发挥了重试的作用,有趣的是ConcurrentHashMap是可以多线程同时扩容的。
这里说协助的原因在于,对于数组扩容,一般分为两步:1.新建一个更大的数组;2.将原数组数据(重新散列Hash值)copy到新数组中。
对于第一步,ConcurrentHashMap通过CAS来控制一个int变量保证新建数组这一步建一个更大的数组只会执行一次;
对于第二步,ConcurrentHashMap采用CAS + synchronized + 移动后标记 的方式来达到多线程扩容的目的。
感兴趣可以查看transfer函数。
目前的猜想多线程扩容可能是:多线程操作不同的table位置的链表或红黑树,将元素重新散列到新的table数组中。
ConcurrentHashMap是如何实现高效的并发操作,这得益于ConcurrentHashMap中的如下三个函数(sun.misc.Unsafe U):
/*
3个用的比较多的CAS操作
*/
@SuppressWarnings("unchecked") // ASHIFT等均为private static final
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) { // 获取索引i处Node
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
// 利用CAS算法设置i位置上的Node节点(将c和table[i]比较,相同则插入v)。
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
// 在链表转树时用到了这个方法;设置节点位置的值,仅在上锁区被调用
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
private final void treeifyBin(Node<K,V>[] tab, int index) {
……
}
treeifyBin方法的思想:检查下table的长度(Map的容量,不是乘以加载因子的那个,也不是目前集合中元素的个数)是否大于等于MIN_TREEIFY_CAPACITY(64),如果不大于,
则调用tryPresize方法将table两倍扩容就可以了,就不降链表转化为树了;如果大于,则就将table[i]的链表转化为树。
public long mappingCount() {
long n = sumCount();
return (n < 0L) ? 0L : n; // ignore transient negative values
}
这是Java8中查询元素个数的方法mappingCount,返回值是long;这个应该用来代替size()方法被使用。这是因为ConcurrentHashMap可能比包含更多的映射结果,即超过int类型的
最大值(size()方法的返回值是int);这个方法返回值是一个估计值,由于存在并发的插入和删除,因此返回值可能与实际值会有出入。
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
元素个数
HashMap中的迭代器
HashMap不是线程安全的,其中的原因之一就是:在多线程环境下,一个线程通过迭代器遍历或删除时,另一个线程修改了HashMap,则modCount++,造成迭代器中的expectedModCount与HashMap中的modCount不一样。
这就是 ConcurrentHashMap 迭代器弱一致的表现。
在这种迭代方式中,当 iterator 被创建后,集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时 new 新的数据 从而不影响原有的数据,iterator 完成后再将头指针替换为新的数据,这样 iterator 线程可以使用原来老的数据,而写线程也可以并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。 总结,ConcurrentHashMap 的弱一致性主要是为了提升效率,是一致性 与效率之间的一种权衡。
红黑树简单介绍
算法导论中lgn默认都是以2为底的,即为log2n。
红黑树使二叉搜索树更加平衡:红黑树是一种二叉搜索树,但在每个节点上增加一个存储位表示节点的颜色,可是 red 或 black,红黑树的查找、插入、删除的时间复杂度最坏为 O(lgn);
(重点)树的高度决定查询性能,让树尽可能平衡,就是为了降低树的高度 ;
因为由 n 个节点随机生成的二叉搜索树的高度为 lgn,所以二叉搜索树的一般操作的执行时间为O(lgn)。
红黑树是牺牲了严格的高度平衡的优越条件为代价,红黑树能够以O(log2 n)的时间复杂度进行搜索、插入、删除操作。
此外,由于它的设计,任何不平衡都会在三次旋转之内解决。
红黑树的查询性能略微逊色于AVL(平衡二叉查找树)树,因为他比avl树会稍微不平衡最多一层,也就是说红黑树的查询性能只比相同内容的avl树最多多一次比较;
但是,红黑树在插入和删除上完胜avl树,avl树每次插入删除会进行大量的平衡度计算,而红黑树为了维持红黑性质所做的红黑变换和旋转的开销,相较于avl树为了维持平衡的开销要小得多
红黑树的特性大致有三个(换句话说,插入、删除节点后整个红黑树也必须满足下面的三个性质,如果不满足则必须进行旋转):
1.根节点与叶节点都是黑色节点,其中叶节点为Null节点
2.每个红色节点的两个子节点都是黑色节点,换句话说就是不能有连续两个红色节点,但是黑色节点可以连续
3.从根节点到所有叶子节点上的黑色节点数量是相同的
上述的性质约束了红黑树的关键:从根到叶子的最长可能路径不多于最短可能路径的两倍长。
得到这个结论的理由是:
红黑树中最短的可能路径是全部为黑色节点的路径;
红黑树中最长的可能路径是红黑相间的路径。
文件系统和数据库的索引为什么用的是B+树而不是红黑树或B树
B树与红黑树比较:
在内存中,红黑树效率更高,但是涉及到磁盘操作,B树更优;
文件系统和数据库的索引都是存在硬盘上的,并且如果数据量大的话,不一定能一次性加载到内存中,B树可以根据索引每次加载一个节点,再继续往下找;
B+树的多路存储,非叶结点保存的索引,而红黑树所有节点保存的是结果值;
红黑树是二叉的,保存大量记录树高度太高了,查询效率不高。
B+树与B树比较
B 树不管叶子节点还是非叶子节点,都会保存数据,这样导致在非叶子节点中能保存的指针数量变少;
B树指针少的情况下要保存大量数据,只能增加树的高度,导致 IO 操作变多,查询性能变低;
树的高度决定查询性能,就是为了降低树的高度。
范围查询B+树叶子节点保存了指向下一个叶子节点的指针,B+树的所有叶结点构成一个有序链表,在查询中范围查询很常见,B树范围查询效率低;
遍历B+树叶子节点就能获得所有的记录,相当于整棵树的记录遍历。
二叉树、平衡树、红黑树
平衡树是为了解决二叉查找树退化为链表的情况,而红黑树是为了解决平衡树在插入、删除等操作需要频繁调整。
https://mp.weixin.qq.com/s/t1J2HnAhzrks-6AD4s8JYw
TODO待写
链表转红黑树、红黑树的插入与删除等没有详细说明。
ConcurrentHashMap中红黑树相关:https://blog.csdn.net/chenssy/article/details/73749297