元素的比较
Comparable和Comparator
在元素排序中,常用的两个接口是Comparable
和Comparator
Comparable
是自己和自己比,比较方法是:compareTo
public class SearchResult implements Comparable<SearchResult> {
int relativeRatio;
long count;
int recentOrder;
public SearchResult(int relativeRatio, long count) {
this.relativeRatio = relativeRatio;
this.count = count;
}
@Override
public int compareTo(SearchResult o) {
// 按照relativeRatio降序排序
if (this.relativeRatio != o.relativeRatio) {
return this.relativeRatio > o.relativeRatio ? 1 : -1;
}
// 按照count降序排序
if (this.count != o.count) {
return this.count > o.count ? 1 : -1;
}
return 0;
}
public void setRecentOrder(int recentOrder) {
this.recentOrder = recentOrder;
}
}
Comparator
是第三方比较器,比较方法是:compare
public class SearchResultComparator implements Comparator<SearchResult> {
@Override
public int compare(SearchResult o1, SearchResult o2) {
// 按照relativeRatio降序排序
if (o1.relativeRatio != o2.relativeRatio) {
return o1.relativeRatio > o2.relativeRatio ? 1 : -1;
}
if (o1.count != o2.count) {
return o1.count > o2.count ? 1 : -1;
}
return 0;
}
}
注意:无论是Comparable
还是Comparator
,小于的情况返回-1,等于的情况返回0,大于的情况返回1
**也可以这么理解:返回大于0的数表示当前顺序符合预期,返回小于0的数表示当前顺序不符合预期。**比如o1
和o2
,如果o1
的relativeRatio
比o2
的要大,返回1,表示当前的顺序就是先o1
再o2
,符合按照relativeRatio
降序排序。
hashCode和equals
这两个方法用来标识对象,用来判断两个对象是否相等。
顺序:先判断hashCode
是否相等,不等直接判为不同;若相等,则再调用equals
判断。
任何时候覆写equals
,都必须同时覆写hashCode
。
在Map和Set类集合中,自定义的对象必须覆写这两个方法,如果仅覆写equals
,那么会在比较对象或者健相等的时候去调用Object
中的hashCode
。但是Object
中的hashCode
的实现是默认为每一个对象生成不同的int数值,只与对象的内存地址有关。
String覆写了hashCode
,所以可以在自定义的hashCode()
中直接调用。
在调用equals的时候,建议使用Objects.equals(Object a, Object b)
,可以避免NPE
问题。
fail-fast机制
-
fail-fast:对集合遍历操作时的错误检测机制。这种机制出现在多线程下,当前线程会维护一个针对目标集合的计数比较器,记录已修改的次数。在进入遍历前,如果实时修改次数与已修改的次数不一样,会抛出异常。
public static void main(String[] args) { List masterList = new ArrayList(); masterList.add("one"); masterList.add("two"); masterList.add("three"); masterList.add("four"); masterList.add("five"); List branchList = masterList.subList(0, 3); // 以下三行代码需要删除,否则运行时会报错 masterList.remove(0); masterList.add("ten"); masterList.clear(); branchList.clear(); branchList.add("six"); branchList.add("seven"); branchList.remove(0); // 输出seven for (Object o : branchList) { System.out.println(o); } // 输出[seven, four, five] System.out.println(masterList); }
解释:
subList
的修改会导致主列表的修改,所以最后的输出是[seven, four, five]
。- 那三行代码需要删除的原因在于:计数比较器记录的对
branchList
的修改次数为0次(显式调用修改方法的次数),但是对于其主列表的修改实际上也算对于子列表的修改,所以在调用branchList.clear();
前实际修改次数是4次,这两者不一样,所以会在这里报错。
建议使用
Iterator
机制进行遍历时修改的操作 -
fail-safe:在安全的副本上进行遍历,存在修改的时候先复制一份,在复制品上进行修改,这样集合修改与副本遍历没有任何关系。并发包的集合中都是采用这种机制实现,这种机制会导致读取不到最新的数据。常见的例如并发容器
CopyOnWriteArrayList
。
Map类集合
面对频繁的插入和删除,红黑树更为合适;面对低频修改,大量查询时,AVL
树更合适。
原因:在插入和删除后,AVL
树需要向上回溯,回溯次数最差为O(logn)
,而红黑树回溯步长为2。由于红黑树只是大致平衡,所以高度相比于AVL
可能会更高,所以查询效率较低。
TreeMap
注意:HashMap
使用hashCode
和equals
实现去重,而TreeMap
依靠Comaparable
或Comparator
来实现去重。
源码解析:
- 类名和关键属性:
public class TreeMap<K,V>
extends AbstractMap<K,V>
implements NavigableMap<K,V>, Cloneable, java.io.Serializable{
// 排序使用的比较器
private final Comparator<? super K> comparator;
// 根结点
private transient Entry<K,V> root;
private static final boolean RED = false;
private static final boolean BLACK = true;
// 存储红黑结点的载体类
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;
}
// ...
}
插入新节点前的三个前提条件:
- 需要调整的新节点总是红色的;
- 如果插入新节点的父节点是黑色的,无需调整;
- 如果插入新节点的父节点是红色的,因为不能出现两个相邻的红色节点,所以需要进入循环判断,或重新着色,或左右旋转,最终达到约束条件后结束。
插入的整体流程:按照Key的对比往下遍历,先按照二叉查找树的特性操作,无需关心颜色与树的平衡,后续会重新着色和旋转。
- put的源码分析:
public V put(K key, V value) {
// t表示当前节点,先讲root赋值给当前节点
Entry<K,V> t = root;
// 当前节点为空,即为空树,新增的节点即为根节点
if (t == null) {
// 检测Key是否可以比较
compare(key, key); // type (and possibly null) check
// 构造出新的Entry对象,根节点没有父节点
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
// cmp用于接收比较结果
int cmp;
Entry<K,V> parent;
// 构造方法中置入的外部比较器
Comparator<? super K> cpr = comparator;
// 重点步骤:根据二叉查找树的性质,找到新节点插入的合适位置
if (cpr != null) {
// 根据参数Key与当前节点Key不断地进行对比
do {
// 当前节点赋值给父节点
parent = t;
// 比较输入的参数和当前节点Key的大小
cmp = cpr.compare(key, t.key);
// 利用二叉查找树的性质,小则往左边走,大往右边走
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
// 相等,则根据Map的性质,覆盖掉当前节点的值,由于此时没有改变树的结构,所以直接返回
else
return t.setValue(value);
} while (t != null);
}
// 没有指定比较器则调用自然排序的Comparable
else {
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
// 创建Entry对象,并把parent置入参数
Entry<K,V> e = new Entry<>(key, value, parent);
// 上面查找结束的时候t=null,此时parent为待插入位置的父结点
if (cmp < 0)
parent.left = e;
else
parent.right = e;
// 对这个节点进行着色和旋转操作,以达到平衡
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
fixAfterInsertion
源码分析:
private void fixAfterInsertion(Entry<K,V> x) {
// 新节点一律先赋值为红色
x.color = RED;
// 新节点是根节点或者其父节点为黑色
// 插入红色节点不会破坏性质,无须调整
// x值的改变过程是在不断地向上游遍历(或内部调整) 直到父亲为黑色或者到达根节点
while (x != null && x != root && x.parent.color == RED) {
// 如果父亲是爷爷的左子节点
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
// 看爷爷的右子节点的颜色(右叔)
Entry<K,V> y = rightOf(parentOf(parentOf(x)));
// 右叔为红,此时通过局部颜色调整,可以满足性质
if (colorOf(y) == RED) {
// 父亲置为黑色
setColor(parentOf(x), BLACK);
// 右叔置为黑色
setColor(y, BLACK);
// 爷爷置为红色
setColor(parentOf(parentOf(x)), RED);
// 爷爷成为新的节点,进入下一轮循环
x = parentOf(parentOf(x));
// 右叔为黑,需要加入旋转
} else {
// 如果x是父亲的右子节点,先对父亲做一次左旋操作,转化x是父亲的左子节点
if (x == rightOf(parentOf(x))) {
// 对父亲做一次左旋转操作,红色的父亲沉入左侧,父亲赋值给x
x = parentOf(x);
rotateLeft(x);
}
// 重新着色并对爷爷进行右旋操作
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateRight(parentOf(parentOf(x)));
}
// 思路相同,操作相反
} else {
Entry<K,V> y = leftOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
x = parentOf(parentOf(x));
} else {
if (x == leftOf(parentOf(x))) {
x = parentOf(x);
rotateRight(x);
}
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateLeft(parentOf(parentOf(x)));
}
}
}
// 根结点始终黑色
root.color = BLACK;
}
rotateLeft
源码:
private void rotateLeft(Entry<K,V> p) {
if (p != null) {
Entry<K,V> r = p.right;
p.right = r.left;
if (r.left != null)
r.left.parent = p;
r.parent = p.parent;
if (p.parent == null)
root = r;
else if (p.parent.left == p)
p.parent.left = r;
else
p.parent.right = r;
r.left = p;
p.parent = r;
}
}
红黑树调整总结:
1.节点的父亲是黑色的,则直接插入,不需要调整或者着色;
2.节点的父亲是红色的:
- 叔叔是红色的,则重新着色;
- 叔叔是黑色的,新节点是父亲的左节点,进行右旋;
- 叔叔是黑色的,新节点是父亲的右节点,进行左旋。
HashMap
HashMap存在死链问题以及扩容数据丢失问题,所以除了局部方法或绝对线程安全的情形外,推荐使用ConcurrentHashMap,两者性能接近。
通过indexFor()
计算得出来相同index
的entry
放在同一个哈希桶中,一般使用链表连接,后续增大可能改为红黑树。
indexFor()
主要是根据key
的hashCode
以及table的长度计算得出下标。
默认容量大小是16,默认负载因子大小是0.75,当Map中的数量超过容量大小*负载因子时会扩容,容量大小为原来的2倍。此处的扩容先是创建2倍大小恶毒存储空间,随后将当前存在的数据复制过去,但是在并发条件下会存在死链问题以及扩容数据丢失问题。
ConcurrentHashMap
JDK8
中的ConcurrentHashMap
通过避免锁的使用来提高效率,因为加锁会导致性能的进一步下降。转而使用CAS
作为替代,
CAS
:Compare And Swap
,解决轻微冲突的多线程并发场景下使用锁造成性能损耗的一种机制。不加锁而实现操作原子化的并发编程方式,JDK8
之前采用了分段锁的设计理念。
ConcurrentHashMap
在JDK11
中相较于JDK7
的修改:
-
取消分段锁机制,降低了冲突概率
-
引入红黑树结构,同一个哈希槽上的元素个数超过一定阈值后,单向链表改为红黑树结构。
当某个槽内的元素个数增加到超过8个且table(存放数据的地方,可能会有扩容)的容量大于等于84时,由链表转为红黑树;当某个槽内的元素个数减少到6个时,由红黑树重新转回链表。
-
使用更加优化的方式计算集合内元素数量
统计数量时,难以百分之百准确,在
put()
、remove()
和size()
中,涉及元素总数的更新和计算,彻底避免锁的使用,使用的是CAS
操作。
ConcurrentHashMap
中包含的各种Node
:
Node
:存储单个KV
数据节点,内部有key
、value
、hash
、next
,有以下四种子类;TreeBin
:不存储实际的数据,维护对桶内红黑树的读写锁,存储对红黑树节点的引用,相当于就是控制线程对于某个槽内的读写;TreeNode
:在红黑树结构中,实际存储数据的节点;ForwardingNode
:扩容转发节点,在table
扩容时使用。在原table
槽内放置,内部记录了扩容后的table
;ReservationNode
:占位加锁节点,当执行某些方法时,对其加锁。