目录
HashMap&ConcurrentHashMap源码探究
一、JDK1.7 HashMap
1、初始化(伪初始化)
// 默认table数组大小16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 默认加载因子 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
public HashMap(int initialCapacity, float loadFactor) {
// 数组大小不能低于0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 数组大小不能超过最大限制MAXIMUM_CAPACITY=1 << 30 2的30次方
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 加载因子得是一个数值并且不能小于等于0
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
threshold = initialCapacity;
// 啥都不干,伪初始化
init();
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
不传参数,默认table大小为16,加载因子为0.75
传table大小,可以自定义table的大小,以及加载因子
2、put(真初始化)
public V put(K key, V value) {
// (2.1)如果是个空数组,就真初始化
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// (2.2)不同于hashtable,与ConcurrentHashMap key可以是null
if (key == null)
return putForNullKey(value);
// (2.3)重新计算key的hash值
int hash = hash(key);
// (2.4)根据hash与数组长度计算当前key的数组下标
int i = indexFor(hash, table.length);
// 判断当前key在table[i]的链表上是否有值
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;
}
}
// (2.9)操作次数加1
modCount++;
// (2.5 2.6 2.7 2.8)新增当前的Entry对象,可能导致扩容,插入方式为头插法
addEntry(hash, key, value, i);
return null;
}
如果table为空,先初始化table--inflateTable(初始化的table大小)
详细步骤如下
2.1 inflateTable
private void inflateTable(int toSize) {
// 计算大于当前toSize的最小2的幂次方就是数组大小
int capacity = roundUpToPowerOf2(toSize);
// 阈值赋值,数组大小*加载因子
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 创建数组
table = new Entry[capacity];
// 判断是否重新打乱hash
initHashSeedAsNeeded(capacity);
}
传入参数如果大于1,就进行2的幂次方扩充,如10扩充16,方法为先减1后位运算左移1位(乘2),然后使用integer的方法highestOneBit
如果小于等于1,就是创建大小为1的table
2.2 如果key为null
private V putForNullKey(V value) {
// 遍历table[i]位置上的链表,发现有key为null就覆盖并返回原值
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// 增加操作次数
modCount++;
// 头插法插入entry对象
addEntry(0, null, value, 0);
return null;
}
就放入table[0]的位置
放入操作就是遍历table[0]的链表,如果发现有key为null就替换返回,没有就使用头插法table[0] = new entry(hash,key,value,table[0])
2.3 重新hash
final int hash(Object k) {
// 获取打乱hash的数值,默认为0,不会导致hash被打乱
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
// 根据打乱hash的数值计算新的hash,默认hashSeed=0,进行或运算后hashcode值不变
h ^= k.hashCode();
// hash算法,尽量打乱低位的散列行
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
根据key的hashcode重新hash,重新hash的原因就行因为尽可能打乱低位,否则低位的重复性可能较大
如:0000 0001
0001 0001
0011 0001
0111 0001
0101 0001 如果进行下标运算,结果位置肯定都是一样的,为啥一样看2.4
2.4 计算下标
static int indexFor(int h, int length) {
// hash值与数组长度相与运算,算出当前key所在数组下标
return h & (length-1);
}
因为key的hashcode肯定很大,不可能直接用来做下标。
计算下标使用了key的hash与table长度减1进行 & 运算。&:都为1,才为1
假设hashcode是 0101 0101
长度16减1 0000 1111
结果 0000 0101 实际就是hashcode的后4位,所以取值范围肯定就是0-15,不会超出。所以2的幂次方的用处在这里也有体现。让范围内每一位都有值
这也是为啥2.3要重新hash的原因
2.5 插入
void addEntry(int hash, K key, V value, int bucketIndex) {
// 如果当前数组元素个数大于了阈值,并且当前插入的数组下标发送了hash冲突,进行扩容
if ((size >= threshold) && (null != table[bucketIndex])) {
// 扩容翻倍
resize(2 * table.length);
// 计算扩容后的hash值
hash = (null != key) ? hash(key) : 0;
// 计算新的数组下标
bucketIndex = indexFor(hash, table.length);
}
// 头插法插入entry对象
createEntry(hash, key, value, bucketIndex);
}
使用同2.2一样的遍历table[i]的链表,有相同key就替换并返回,没有key就使用头插法进行插入。table[i] = new entry(hash,key,value,table[i])
2.6 扩容条件
jdk1.7的扩容条件有两个,1-元素个数大于table长度×负载因子,2-发生了hash冲突。也就是说默认情况下,hash不进行扩充最多可放入12+15=27个元素
2.7 扩容
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];
// 老数组往新数组迁数据,并判断是否需要打乱hash
transfer(newTable, initHashSeedAsNeeded(newCapacity));
// 将hashMap中数组用新数组替换
table = newTable;
// 计算新的阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
在addEntry之前,会有2.6的扩容判断,如果判断符合,则进行原table长度*2的翻倍,
先创建翻倍大小的数组,然后使用双重循环将老数组元素挪到新数组。如果不重新hash的情况下,重新计算数组下标同之前插入一致
使用key的hash & 新数组长度-1。这样得到的结果有两种:1、和原值相同。2、等于原值+扩容的数组长度。原因如下
hash:0110 0101 所以下标还是原值
原长是:0000 1111 & hash相与后:0000 0101
扩容后:0001 1111 & hash相与后:0000 0101
hash:0101 0101 所以下标变成了原值+扩容长度
原长是:0000 1111 & hash相与后:0000 0101
扩容后:0001 1111 & hash相与后:0001 0101
2.8 扩容时打乱hash
final boolean initHashSeedAsNeeded(int capacity) {
// 获取当前打乱hash的值的大小(hashSeed我就叫做打乱hash,学名叫啥我也懒得查了,知道啥意思就行)
boolean currentAltHashing = hashSeed != 0;
// 获取jvm中配置的参数ALTERNATIVE_HASHING_THRESHOLD 也就是jdk.map.althashing.threshold
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
// 只要打乱hash不为0或者新数组大小超过了阈值,就重新计算打乱hash
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
return switching;
}
有个hashSeed参数,正常情况都是0.所以扩容时都不用重新hash
hashSeed是根据新扩容长度是否大于jvm参数jdk.map.althashing.threshold进行判断赋值的。
如果大于就会随机hashSeed值,hash计算时,h ^= k.hashCode();所以新的数组下标就会变化。
然后每次扩容。hash值都会重新变化,之后的每次扩容下标都不一样了
2.9 modCount参数含义
对hashMap的操作次数累计。在一边遍历hashMap,一边对hashMap进行操作,就会导致抛出异常。防止多线程并发的一个提前异常抛出
hashmap实际就是put方法稍微复杂,get方法就简单的hash算出来key的下标,然后遍历链表就行了。就不详细写过程了
3、总结
3.1 初始大小16,加载因子0.75,阈值12,在初始化时,没有初始化数组。只是把大小,加载因子,阈值确定了。
3.2 在第一次put时,进行的初始化操作,允许key为null,key为null时,数组下标就为0。
3.3 在put时,使用头插法。hash算法是对key的hashcode重新加工,不是原有的hashcode。
3.4 有个hashSeed参数,hashSeed是通过jdk.map.althashing.threshold进行判断是否赋值,在扩容时堆hash算法进行打乱。
3.5 hashmap全程不涉及到锁,所以线程不安全,扩容时可能回导致链表的死循环,在同一位置同时头插法put,会导致覆盖等问题。
二、JDK1.7 ConcurrentHashMap
0、数据结构
说ConcurrentHashMap之前,先说下他的一个数据结构,看了数据结构就非常方便理解。(图略丑,不要介意)
不同于HashMap,就一个数组+链表的方式,ConcurrentHashMap采用了,大数组加套小数组上放链表的方式。
之所以能实现线程安全,就是因为在put时,先算出大数组的下标,然后对当前这个下标的小数组lock,就实现了线程安全。也是常说的分段加锁的原理。
大数组称之为Segment数组,小数组称为HashEntry数组
1、初始化(这回真的初始化了一点东西)
public ConcurrentHashMap() {
// 默认大小。16、0.75、16
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
// 1.1 加载因子需要大于0,初始表大小不能小于0(可以等于),并发级别需要大于0
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
// 1.2 并发级别上限设置
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// 1.3 ssize就是大于并发级别的最小2的幂次方。也是Segment数组的真实大小
// sshift计算是算循环的次数,实际值就是ssize的2进制-1后,有值的低位的个数
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
// segmentShift值理解成ssize的2进制-1,无值的高位的个数,segmentMask就是Segment数组的大小-1
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// c就是表大小/并发级别 用于计算出Segment的HashEntry数组初始大小
int c = initialCapacity / ssize;
// 1.4 这段代码我是这没读懂啥意思,希望懂的小伙伴提点一下
if (c * ssize < initialCapacity)
++c;
// 1.5 Segment的HashEntry数组初始大小,默认最小为2.表大小/并发级别的最小的2的幂次方
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// 1.6 初始化默认的Segment对象
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
// 1.7 创建Segment数组
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
// 1.8 使用UNSAFE包进行设置Segment[0]的对象
UNSAFE.putOrderedObject(ss, SBASE, s0);
this.segments = ss;
}
1.1 不同于HashMap,默认有三个参数(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
initialCapacity : 表的大小,除并发级别后用于计算HashEntry数组的大小,HashEntry数组的大小最小为2
loadFactor:加载因子
concurrencyLevel:默认的并发级别,类似于HashMap的初始大小设置,大于自己的2的最小幂次方就是Segment数组大小。最小可为1,表示segment数组大小永远为1
1.2 判断并发级别concurrencyLevel是否大于MAX_SEGMENTS,如果是就赋值MAX_SEGMENTS = 1 << 16
1.3 局部变量sshift与ssize作用
如果ssize小于设置的并发级别,就循环进行位运算左移1位,相当于乘2。一直大于concurrencyLevel为止,如果concurrencyLevel=15,则ssize=16
sshift就是ssize的循环次数,也是ssize减1后,1的个数。ssize=16 则sshift=4,ssize=64 则sshift=6。
全局变量segmentShift就是32-sshift,就是ssize-1的高位0的个数。
如ssize=16 减一则0000 0000 0000 0000 0000 0000 0000 1111,sshift=4 segmentShift=28
全局变量segmentMask就是ssize-1,看过了hashmap就知道这个肯定是用来和hash相互&,计算数组下标的
1.4 接下来有个判断很有意思
int c = initialCapacity / ssize;
if (c ssize < initialCapacity)
++c;
把c套到下面,就成了((initialCapacity / ssize) ssize < initialCapacity) = initialCapacity < initialCapacity?
实在看不懂这么写的意义。
1.5 计算segment内的hashentry数组长度,cap
cap默认为MIN_SEGMENT_TABLE_CAPACITY=2,也就是最少hashentry数组也有2个。
循环判断(cap < c) cap <<= 1,如initialCapacity=16 / ssize=4,c就是4。hashentry数组长度计算出也就为4
1.6 创建segments数组,已经初始化segment[0]。至于为何初始化segment[0],实际就是为了给后面的segment初始化时不用再重复计算。
默认情况下,s0 loadFactor=0.75,caploadFactor=1 阈值为1,new HashEntry[cap] cap=2
ss,segments数组 默认大小为ssize=16
1.7 使用unsafe包。将s0,赋值到ss[0]
1.8 综上可知,一共有两个数组,一个segments,还有一个segment对象中包含的hashEntry数组。
两个数组长度都是计算出来的,不是直接指定的。其中segments的长度是ssize,根据concurrencyLevel并发级别计算
hashEntry长度cap默认为2,如果(cap < c),c是initialCapacity除以segments数组长度。也就是initialCapacity越大,cap也就越大。
如果initialCapacity = 50。ssize算出得16。c=3,计算得出cap=4,hashEntry数组大小也就为4
2、 put(实际是put了两次)
2.0 unsafe
在说put之前,有必要说一下unsafe的作用。这是jdk提供的一个安全包,对于了解JMM内存模型的小伙伴都知道,因为我们真实的数据都在内存里,也就是内存条上。CPU读取数据到自己的缓存中。
因为多核cpu,所以每个核心的缓存都是自己单独的一份,每个线程其实都有自己的一个工作缓存,是总线上的一个副本,在并发读写操作的时候,就会出现,A线程对某个数据修改了,回写到总线。
但是B线程在A回写总线之前就取到了这个旧数据,那么B线程就得不到这个数据的最新值。ConcurrentHashMap大量使用了unsafe包进行操作,就是能及时获取总线上的最新值。
主要操作:
UNSAFE.arrayBaseOffset(sc) 获取sc数据的初始偏移量
UNSAFE.arrayIndexScale(CLASS) 获取一个CLASS类的数组一个元素引用占用的大小,用于计算偏移量
UNSAFE.getObject(s, base) 获取s数组中,base偏移量的值
通过这个三个,我们能先获取到一个数组初始偏移量,还有引用偏移量大小,就能用UNSAFE.getObject来获取各个下标的元素值。通过反射获取unsafe包,写个demo
private static sun.misc.Unsafe UNSAFE;
private static String[] s = new String[] {"a","b","c","d"};
static {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
UNSAFE = (Unsafe) field.get(null);
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
// 计算初始偏移量
int base = UNSAFE.arrayBaseOffset(String[].class);
// 计算每个元素引用的偏移量,也就是大小
int index = UNSAFE.arrayIndexScale(String[].class);
for(int i = 0; i < s.length; i++)
// 初始偏移量就是0的下标,i*index就是每个元素的偏移量
System.out.println(UNSAFE.getObject(s, i*index + base));
}
通过Unsafe就能直接获取到总线内存中的数组的值,防止发生并发,导致缓存不一致。
2.1 定义的一些常量以及两个初始化的局部变量
int ss:UNSAFE.arrayIndexScale(Segment[].class) 获取一个Segment数组一个元素引用占用的大小,用于计算偏移量
int ts:UNSAFE.arrayIndexScale(HashEntry[].class) 获取一个HashEntry数组一个元素引用占用的大小,用于计算偏移量
补充一点,数组中存的是引用。Java对象引用大小是一个非常不确定的值,可能是4个字节或者是8个字节,这个取决于你的JVM设置以及给了多少内存给JVM,
针对32G以上的堆,它就总是8个字节,但是针对小一点的堆就是4个字节除非你在JVM设置里关掉设置-XX:-UseCompressedOops
结果就是,安全的方式获取对像引用的大小就是找到Object[]数组中一个元素的大小
long SBASE:UNSAFE.arrayBaseOffset(sc); 获取Segment数组中第一个元素的偏移量
long TBASE:UNSAFE.arrayBaseOffset(tc); 获取HashEntry数组中第一个元素的偏移量
int SSHIFT:31 - Integer.numberOfLeadingZeros(ss);
int TSHIFT:31 - Integer.numberOfLeadingZeros(ts);
这里再说明一下Integer.numberOfLeadingZeros,原理没有研究,就看下用法
该方法的作用是返回无符号整数i的最高非0位前面的0的个数,包括符号位在内;如果i为负数,这个方法将会返回0,符号位为1。
比如说,10的二进制表示为 0000 0000 0000 0000 0000 0000 0000 1010 java的整型长度为32位。那么这个方法返回的就是28
所以,SSHIFT和TSHIFT就是31减去二进制高位0的个数,也就是二进制低位的个数减1,如ss=4,则SSHIFT=2,ss=8,则SSHIFT=3
全局变量segmentMask:1.3 中计算出来的,segment数组的大小-1
全局变量segmentShift:1.3 中计算出来的,segment数组的大小-1的二进制高位中0的个数
public V put(K key, V value) {
Segment<K,V> s;
// 2.2 value值判断,不能为null
if (value == null)
throw new NullPointerException();
// 2.3 对key进行hash运算
int hash = hash(key);
// 2.4 计算key在segment中的数组下标
int j = (hash >>> segmentShift) & segmentMask;
// 2.5 判断segment的数组下标中是不是null
if ((s = (Segment<K,V>)UNSAFE.getObject
(segments, (j << SSHIFT) + SBASE)) == null)
// 2.6 返回j下标的Segment对象,涉及到2次检查,然后使用CAS更改
s = ensureSegment(j);
// 2.7 对HashEntry进行put,涉及到tryLOCK,边try边创建HashEntry,以及HashEntry扩容
return s.put(key, hash, value, false);
}
2.2 value值判断,不能为null
2.3 对key进行hash运算,没有判断null。如果key为null会抛出异常
hash运算时,hashSeed成为一个初始的随机常量,整个ConcurrentHashMap的生命周期中,是唯一常量值。其实估计也是和Segment数组不会扩容有关系
2.4 计算key在segment中的数组下标
(hash >>> segmentShift) & segmentMask;
看过hashMap后这个 & segmentMask相与就很容量理解。
(hash >>> segmentShift) 就是右移 segment数组的大小-1的二进制高位中0的个数,
比较绕,换言之就是留下hash值的高位放到低位,然后和segmentMask计算数组下标。这个和在计算HashEntry下标不同,HashEntry与HashMap是相同的
为什么这么做,因为,如果对hash的低位和segment数组大小减1计算。hash的低位和HashEntry数组大小减1计算。
如果出现,segment数组大小同HashEntry数组大小一致时,两个数组下标就一致了。
一旦一致就出现,先放入segment[5],再放入HashEntry[5]或者先放入segment[6],再放入HashEntry[6]这种情况。浪费了HashEntry数组的空间
2.5 判断segment的数组下标中是不是null
这个判断也很绕。UNSAFE.getObject(segments, (j << SSHIFT) + SBASE))
正常使用UNSAFE是这样写,UNSAFE.getObject(s, base + j*ns),s是数组,base是初始位置,ns是单个引用大小,j表示取第j个元素。
实际这两个是同一个意思jns = j<<SSHIFT。引用其实要么4,要么8.如果引用是4,SSHIFT=2。左移两位就是乘4。是一个原理不同写法
2.6 ensureSegment方法,返回j下标的Segment对象
创建并初始化segment对象,创建其中的hashEntry数组,int k为在segment数组中的下标
2.6.1 long u:根据segment数组下标计算偏移量
2.6.2 用UNSAFE的getObjectVolatile方法获取总线中的第k个数组下标。
防止工作缓存,
如果为空,没有被并发创建,获取之前初始化的segment[0]上的参数
cap:0上的hashEntry长度 lf:0上的加载因子 threshold:根据长度加载因子算的阈值
根据参数创建出新的hashEntry数组,再次用getObjectVolatile判断出是否在下标上已有数组,重新检查
如果还是为空,就使用while+cas进行把新创建的hashEntry在segment[k]位置上赋值并返回
如果在两次判断中有一次不为空,就进行返回其他线程创建的segment对象
2.7 对HashEntry进行put
每一个segment都有一个HashEntry数组,Segment继承了ReentrantLock可重入锁,所以可以进行lock操作
2.7.1 进行tryLock操作,先看一次就成功的情况。
加锁成功,根据hashEntry的长度&key的hash,计算出在hashEntry中的数组下标。
拿到当前下标中的对象,也是链表中的头结点。对链表遍历,发现了之前的key,就进行替换value操作,跳出遍历
遍历完没有发现key,在判断note在之前tryLock等待的时候有无被创建,被创建了,就将next指向链表的头结点。
数组已存元素长度+1,如果大于了阈值并且没有超过最大限制,判断是否扩容。否则就直接头插法插入。跳出遍历
2.7.2 tryLock失败,循环尝试加锁,并预先创建note。scanAndLockForPut(意义并不大)
就是在加锁失败的时候,看note还是null,如果发现了链表中存在了相同的key,就不做操作,否则就去预创建note
循环加锁次数看cpu核心数,大于1就是64次,否则1次,然后进入内核态
也有判断头结点是否有变化,有变化就重新从头结点再遍历一次,看链表有没有相同的key。
如果获取到锁,就结束方法。(存在一个问题,比如预创建了一个note,然后和其他并发插入的线程是一个key,这个note也没有后续操作,多了个没用的元素)
2.7.3 HashEntry扩容
扩容如果看了HashMap的扩容,那么这个就非常简单。
先对数组翻倍创建一个新的数组,计算新的阈值,计算hash也不会再有重新打乱hash,而是和之前的算法保持一致
这样就能知道,每个链表的元素。只有两个下标的可能,要么是当前下标,要么是当前下标加扩容的大小的值。
唯一有点不同就是,扩容的时候在老数组上会循环遍历各个下标的链表
刚进入每个链表的时候,就出现了一段找相同下标的代码。这段代码意思我理解就是,尽可能第一次移走多个连续的相同下标的note
然后再挨个遍历链表,计算下标,头插法移到新数组。
三、JDK1.8 HashMap
终于将到jdk1.8的hashMap了,以前只是知道红黑树是个啥,里面细节从来没看过,这次专门沉下心看了看,读着代码按规则走勉强能看懂吧,配合这个网址,多插入试验几次。
勉强把插入和左旋右旋读了下,删除真的没有读。有兴趣的小伙伴可以专门找一篇数据结构方面的,我这里就只是把大概红黑树的样子描述下,做个抛砖引玉吧
一、引入红黑树概念:(实现挺繁琐的,就只先看规则)
规则:
1、每个节点要么黑,要么红
2、根节点是黑的
3、叶子节点都是黑的(实际叶子节点都是null的黑节点)
4、如果一个节点是红的,那么自己的子节点都是黑的
5、对每个结点,从自己到其他任何叶子节点的路径上都有相同数量的黑节点
特性(3)中的叶子节点,是只为空(NIL或null)的节点。
特性(5),确保没有一条路径会比其他路径长出俩倍。因而,红黑树是相对是接近平衡的二叉树。时间复杂度是O(lgn)
调整规则:
旋转:
x的右节点是y,进行左旋,意味着"将x变成y的左节点,y的左节点变成x的右节点,x之前的父节点变成y的父节点"。
x的左节点是y,进行右旋,意味着"将x变成y的右节点,y的右节点变成x的左节点,x之前的父节点变成y的父节点"。
左旋示意图: z 右旋示意图: y
x / x \
/ \ --(左旋)--> x / \ --(右旋)--> x
y z / y z \
y z
左旋:伪代码LEFT-ROTATE(T, x)
01 y ← right[x] // 前提:这里假设x的右孩子为y。下面开始正式操作
02 right[x] ← left[y] // 将 “y的左孩子” 设为 “x的右孩子”,即 将β设为x的右孩子
03 p[left[y]] ← x // 将 “x” 设为 “y的左孩子的父亲”,即 将β的父亲设为x
04 p[y] ← p[x] // 将 “x的父亲” 设为 “y的父亲”
05 if p[x] = nil[T]
06 then root[T] ← y // 情况1:如果 “x的父亲” 是空节点,则将y设为根节点
07 else if x = left[p[x]]
08 then left[p[x]] ← y // 情况2:如果 x是它父节点的左孩子,则将y设为“x的父节点的左孩子”
09 else right[p[x]] ← y // 情况3:(x是它父节点的右孩子) 将y设为“x的父节点的右孩子”
10 left[y] ← x // 将 “x” 设为 “y的左孩子”
11 p[x] ← y // 将 “x的父节点” 设为 “y”
右旋:伪代码RIGHT-ROTATE(T, x)
01 x ← left[y] // 前提:这里假设y的左孩子为x。下面开始正式操作
02 left[y] ← right[x] // 将 “x的右孩子” 设为 “y的左孩子”,即 将β设为y的左孩子
03 p[right[x]] ← y // 将 “y” 设为 “x的右孩子的父亲”,即 将β的父亲设为y
04 p[x] ← p[y] // 将 “y的父亲” 设为 “x的父亲”
05 if p[y] = nil[T]
06 then root[T] ← x // 情况1:如果 “y的父亲” 是空节点,则将x设为根节点
07 else if y = right[p[y]]
08 then right[p[y]] ← x // 情况2:如果 y是它父节点的右孩子,则将x设为“y的父节点的左孩子”
09 else left[p[y]] ← x // 情况3:(y是它父节点的左孩子) 将x设为“y的父节点的左孩子”
10 right[x] ← y // 将 “y” 设为 “x的右孩子”
11 p[y] ← x // 将 “y的父节点” 设为 “x”
插入:
将一个节点插入到红黑树中,首先,将红黑树当作一颗二叉查找树,将节点插入;然后,将节点着色为红色;最后,通过旋转和重新着色等方法来修正该树,使之重新成为一颗红黑树。
1、将红黑树当作一颗二叉查找树,将节点插入。
红黑树本身就是一颗二叉查找树,将节点插入后,该树仍然是一颗二叉查找树。也就意味着,树的键值仍然是有序的。
此外,无论是左旋还是右旋,若旋转之前这棵树是二叉查找树,旋转之后它一定还是二叉查找树。
这也就意味着,任何的旋转和重新着色操作,都不会改变它仍然是一颗二叉查找树的事实。
2、将插入的节点着色为"红色"。到了这一步,其实之前的规则中,只有4(如果一个节点是红的,那么自己的子节点都是黑的)不满足了,接下来的操作就是为了使4满足
3、通过一系列的旋转或着色等操作,使之重新成为一颗红黑树。
添加操作:伪代码
RB-INSERT(T, z)
01 y ← nil[T] // 新建节点“y”,将y设为空节点。
02 x ← root[T] // 设“红黑树T”的根节点为“x”
03 while x ≠ nil[T] // 找出要插入的节点“z”在二叉树T中的位置“y”
04 do y ← x
05 if key[z] < key[x]
06 then x ← left[x]
07 else x ← right[x]
08 p[z] ← y // 设置 “z的父亲” 为 “y”
09 if y = nil[T]
10 then root[T] ← z // 情况1:若y是空节点,则将z设为根
11 else if key[z] < key[y]
12 then left[y] ← z // 情况2:若“z所包含的值” < “y所包含的值”,则将z设为“y的左孩子”
13 else right[y] ← z // 情况3:(“z所包含的值” >= “y所包含的值”)将z设为“y的右孩子”
14 left[z] ← nil[T] // z的左孩子设为空
15 right[z] ← nil[T] // z的右孩子设为空。至此,已经完成将“节点z插入到二叉树”中了。
16 color[z] ← RED // 将z着色为“红色”
17 RB-INSERT-FIXUP(T, z) // 通过RB-INSERT-FIXUP对红黑树的节点进行颜色修改以及旋转,让树T仍然是一颗红黑树
主要操作就是去查找自己插入时的父节点,如果大于父节点,就是左节点,否则是右节点。然后将自己设为红色,另外插入肯定是直接到根节点上,和链表会有中间的插入不同
调整操作:伪代码
RB-INSERT-FIXUP(T, z)
01 while color[p[z]] = RED // 若“当前节点(z)的父节点是红色”,则进行以下处理。
02 do if p[z] = left[p[p[z]]] // 若“z的父节点”是“z的祖父节点的左孩子”,则进行以下处理。
03 then y ← right[p[p[z]]] // 将y设置为“z的叔叔节点(z的祖父节点的右孩子)”
04 if color[y] = RED // Case 1条件:叔叔是红色
05 then color[p[z]] ← BLACK ▹ Case 1 // (01) 将“父节点”设为黑色。
06 color[y] ← BLACK ▹ Case 1 // (02) 将“叔叔节点”设为黑色。
07 color[p[p[z]]] ← RED ▹ Case 1 // (03) 将“祖父节点”设为“红色”。
08 z ← p[p[z]] ▹ Case 1 // (04) 将“祖父节点”设为“当前节点”(红色节点)
09 else if z = right[p[z]] // Case 2条件:叔叔是黑色,且当前节点是右孩子
10 then z ← p[z] ▹ Case 2 // (01) 将“父节点”作为“新的当前节点”。
11 LEFT-ROTATE(T, z) ▹ Case 2 // (02) 以“新的当前节点”为支点进行左旋。
12 color[p[z]] ← BLACK ▹ Case 3 // Case 3条件:叔叔是黑色,且当前节点是左孩子。(01) 将“父节点”设为“黑色”。
13 color[p[p[z]]] ← RED ▹ Case 3 // (02) 将“祖父节点”设为“红色”。
14 RIGHT-ROTATE(T, p[p[z]]) ▹ Case 3 // (03) 以“祖父节点”为支点进行右旋。
15 else (same as then clause with "right" and "left" exchanged) // 若“z的父节点”是“z的祖父节点的右孩子”,将上面的操作中“right”和“left”交换位置,然后依次执行。
16 color[root[T]] ← BLACK
如果父节点是红的处理方式,父节点如果是黑的,就直接插入,不用调整操作
如果父节点是左红节点:
1、叔叔不为空是红色->设置,父节点为黑色,叔叔为黑色,爷爷为红色,并且设置自己为爷爷节点重新循环
2、叔叔是黑色右节点,设置父节点为当前节点,以当前节点左旋
3、叔叔是黑色左节点,设置父节点为黑色,爷爷节点设置为红色,以爷爷节点为支点右旋。
如果父节点是右红节点:
1、叔叔不为空是红色->设置,父节点为黑色,叔叔为黑色,爷爷为红色,并且设置自己为爷爷节点重新循环
2、叔叔是黑色左节点,设置父节点为当前节点,以当前节点右旋
3、叔叔是黑色右节点,设置父节点为黑色,爷爷节点设置为红色,以爷爷节点为支点左旋。
四、JDK1.8 ConcurrentHashMap
1.7ConcurrentHashMap先把大致流程写了下,对照代码注解再等。剩下1.8的还没更新,抽时间更新,原创不易,注解都是自己码的,给个点赞关注谢谢~