目录
一、集合类关系图
二、Iterator
三、ListIterator
四、Collection
五、List
(1)ArrayList
1)Array和ArrayList区别
2)实现自己的ArrayList
(2)LinkedList
(3)Vector
六、Map
(1)HashMap
1)HashMap底层原理
2)实现自己的HashMap
3)为什么要加上红黑树,红黑树什么时候才会使用?
4)HashMap如何保证key值唯一
5)HashMap的线程安全问题
(2)LinkedHashMap
1)LinkedHashMap介绍
2)LinkedHashMap如何选择排序方式
3)LinkedHashMap怎么实现的排序
(3)ConcurrentHashMap
1)ConcurrentHashMap特点
2)内部结构
(4)Hashtable
(5)TreeMap
Map小结——如何选择Map:
七、Set
(1)HashSet
(2)LinkedHashSet
(3)TreeSet
Iterator是一个接口,它是集合的迭代器。集合可以通过Iterator去遍历集合中的元素。主要有以下方法:
import java.util.ArrayList;
import java.util.Iterator;
public class Demo1 {
public static void main(String[] args) {
ArrayList a = new ArrayList();
a.add("aaa");
a.add("bbb");
a.add("ccc");
System.out.println("Before :" + a);
Iterator iterator = a.iterator();
iterator.forEachRemaining(str -> System.out.println("没有next()前 ->" + str));
// 若果还有元素
while(iterator.hasNext()){
String str = iterator.next();
if("ccc".equals(str)){
// 删除ccc
iterator.remove();
}
}
System.out.println("After :" + a);
iterator.forEachRemaining(str -> System.out.println("next()后 ->" + str));
}
}
Before :[aaa, bbb, ccc]
没有next()前 ->aaa
没有next()前 ->bbb
没有next()前 ->ccc
After :[aaa, bbb, ccc]
可以看出,最后的一个打印没有打印出来,这是因为iterator使用next()方法跳转到下一步,过程不可逆。
为什么要设计一个迭代器?
可以将各种容器看成是厨房,比如Map集合就是厨房,里面的数据就是一道道菜,那传菜生就是Iterator迭代器,外面客人点的菜由传菜生从厨房取出,顾客和一些不相干的人员不到后厨,后厨是不对外暴露的,就保证了厨房重地的安全。
总结起来,使用迭代器的优点有:
1.可以不了解集合内部的数据结构,就可以直接遍历
2.不暴露内部的数据,可以直接外部遍历;
3.作为标准遍历,适用所有集合的遍历
ListIterator也是一个接口,继承至Iterator接口,包含以下方法:
Iterator和ListIterator之间区别?
public class Demo2 {
public static void main(String[] args) {
ArrayList a = new ArrayList();
a.add("aaa");
a.add("bbb");
a.add("ccc");
System.out.println("Before :" + a);
ListIterator iterator = a.listIterator();
System.out.println(iterator.nextIndex());
System.out.println(iterator.next());
System.out.println(iterator.nextIndex());
iterator.add("ddd");
}
}
Before :[aaa, bbb, ccc]
0
aaa
1
Collection是集合的顶层接口,不能被直接实例化。Collection包含set/list/queue/deque/四种类型集合。
Collection主要方法如下:
List也是接口,有三个实现类:ArrayList、LinkedList、Vector
ArrayList类基于数组,是一种动态数组。是List的实现子类。
那我们就需要研究一下ArrayList容量动态增长的原因了,可以自己实现ArrayList容器,实现它的几个常见方法,这样就能很深刻的理解这个容器:
public class MyArrayList {
private Object[] elementData;
private int size;
public int getSize() {
return size;
}
/**
* 无参构造,初始化容量为10
*/
public MyArrayList() {
elementData = new Object[10];
}
/**
* 根据传参进行初始化
*/
public MyArrayList(int initialCapacity) {
elementData = new Object[initialCapacity];
}
/**
* 扩容:这也是ArrayList的容量可变的原因
*/
private void ensureCapacity(){
// 数组长度不够时
if(size >= elementData.length){
// 两倍扩容
Object[] newArray = new Object[elementData.length*2];
// System.arraycopy(原, 原起始位置, 目标, 目标起始位置, 要copy的数组的长度)
System.arraycopy(elementData, 0, newArray, 0, elementData.length);
elementData = newArray;
}
}
/**
* 边界检测
*/
private void rangeCheck(int index){
if(index < 0 || index >= size){
try {
throw new Exception();
} catch (Exception e) {
e.printStackTrace();
}
}
}
/**
* 实现add()方法
*/
public void add(Object o){
ensureCapacity();
elementData[size] = o;
size ++;
}
/**
* 实现add(int index, Object o)方法
*/
public void add(int index, Object o){
rangeCheck(index);
ensureCapacity();
System.arraycopy(elementData, index, elementData, index + 1,size - index);
elementData[index] = o;
size ++;
}
/**
* 实现remove(int index)方法
*/
public void remove (int index){
rangeCheck(index);
if(size - index - 1 > 0){
System.arraycopy(elementData, index + 1, elementData, index, size - index - 1);
}
elementData[size] = null;
size -- ;
}
/**
* 实现get(int index)方法
*/
public Object get(int index){
rangeCheck(index);
return elementData[index];
}
/**
* 实现isEmpty()方法
*/
public boolean isEmpty(){
return size == 0;
}
public static void main(String[] args) {
MyArrayList myArrayList = new MyArrayList();
myArrayList.add("111");
myArrayList.add("222");
myArrayList.add("333");
myArrayList.add("444");
myArrayList.add("555");
myArrayList.remove(0);
System.out.println(myArrayList.elementData[0]);
System.out.println(myArrayList.get(0));
myArrayList.add(3, "666");
System.out.println(myArrayList.get(3));
System.out.println(myArrayList.isEmpty());
System.out.println(myArrayList.getSize());
}
}
222
222
666
false
5
LinkedList也是List接口的实现类,基于双向链表实现。
链表数据结构的特点是每个元素分配的空间不必连续、插入和删除元素时速度非常快、但访问元素的速度较慢。LinkedList是一个双向链表, 当数据量很大或者操作很频繁的情况下,添加和删除元素时具有比ArrayList更好的性能。但在元素的查询和修改方面要弱于ArrayList。
LinkedList类每个结点用内部类Node表示,Node节点结构如下图:
LinkedList通过first和last引用分别指向链表的第一个和最后一个元素,当链表为空时,first和last都为NULL值,链表结构如下:
可以自己实现下LinkedList,探究一下它的增删改查:
public class MyLinkedList {
private Node first;
private Node last;
private int size;
public int getSize() {
return size;
}
public MyLinkedList() {
}
public MyLinkedList(Node first, Node last, int size) {
this.first = first;
this.last = last;
this.size = size;
}
/**
* 边界检测
*/
private void rangeCheck(int index){
if(index < 0 || index >= size){
try {
throw new Exception();
} catch (Exception e) {
e.printStackTrace();
}
}
}
public void add(Object o){
Node node = new Node();
if (o != null){
node.setData(o);
if (first == null){
node.setPrevious(null);
node.setNext(null);
first = node;
last = node;
}else {
node.setPrevious(last);
node.setNext(null);
last.setNext(node);
last = node;
}
}
size ++;
}
public void add(int index, Object o){
rangeCheck(index);
if(o != null){
Node temp = new Node();
Node newNode = new Node();
if(first != null){
temp = first;
// 定位要插入的节点位置
for (int i = 0; i
1
5
5
需要注意链表遍历的方式,是新建临时的节点,并且将first节点赋值给它,再移动到index的位置。
Vector与ArrayList一样,底层是数组,可实现自动增长,不同的是它线程安全,即某一时刻只有一个线程能够写Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,访问它比访问ArrayList慢。
Map是一个顶层接口(需要注意的是Map并不是Iterator接口的子类,要想用Iterator进行遍历输出Map,需要将Map类型先转换成Set类型),它提供了一种映射关系,
Map借鉴了散列思想,散列将键的信息通过散列函数映射到数组的索引中,以便能够实现快速的查找。首先我们通过键对象生成一个整数,将其作为数组的下标,这个数字就是散列码。散列码不需要是唯一的,但是通过hashCode()和equals()必须能够完全确定对象身份。散列密度关系其效率,可通过负载因子进行调整。
Map接口常见实现类有:HashMap、HashTable、TreeMap、ConcurrentHashMap、LinkedHashMap、weakHashMap。主要Map类特点对比如下:
注意:
线程安全的只有Hashtable和ConcurrentHashMap。
但是Hashtable 线程安全很好理解,因为它每个方法中都加入了Synchronize,在线程竞争激烈的情况下Hashtable的效率非常低下。因为当一个线程访问Hashtable的同步方法时,其他线程也访问这个同步方法时,可能会进入阻塞或轮询状态。因此Hashtable不建议使用,不做重点介绍。
ConcurrentHashMap采用锁分段技术,Hashtable表现出效率低下的原因是因为所有访问HashTable的线程都必须竞争同一把锁,所以会严重影响效率,但是假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。
JDK7前,是由数组+单向链表实现。
JDK8后增加了红黑树,即数组+单向链表+红黑树。
HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对(实际上还有隐含的hash值),HashMap的数据结构如下:
(HashMap源码中是单向链表(只有next,没有previous)实现的,这里为了顺便理解LinkedHashMap为啥支持排序,使用双向链表实现)
class MyEntry implements Map.Entry {
int hash;
final K key;
V value;
MyEntry next;
MyEntry(int hash, K key, V value, MyEntry next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public MyEntry(K key, V value) {
this.key = key;
this.value = value;
}
@Override
public final K getKey() { return key; }
@Override
public final V getValue() { return value; }
@Override
public final String toString() { return key + "=" + value; }
@Override
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
@Override
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
@Override
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;
}
}
public class MyHashMap {
LinkedList[] array = new LinkedList[999];
int size;
public int getSize() {
return size;
}
public MyHashMap(int size) {
this.size = size;
}
public MyHashMap() {
}
/**
* 计算key的hash值
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
public void put(Object key, Object value){
// 先把key和value放入Entry对象中
MyEntry myEntry = new MyEntry(key, value);
// 根据key值计算其hash值,定位数组中可以插入的数组位置
int hashValue = hash(key);
// 若该位置为空直接添加新的链表
if(array[hashValue] == null){
LinkedList linkedList = new LinkedList();
linkedList.add(myEntry);
array[hashValue] = linkedList;
} else {
// 若数组该位置已经有链表,就需要循环判断是否有重复的key值,若有
// 则替换该处的value值,若无则直接在尾部新增
for (int i = 0; i < array[hashValue].size(); i++) {
MyEntry entry = (MyEntry)array[hashValue].get(i);
if(key.equals(entry.key)){
entry.value = value;
}else {
array[hashValue].add(myEntry);
}
}
}
size ++;
}
public Object get(Object key){
int hashValue = hash(key);
Object obj = null;
if(array[hashValue] != null){
for (int i = 0; i < array[hashValue].size(); i++) {
MyEntry entry = (MyEntry)array[hashValue].get(i);
if (key.equals(entry.key)){
obj = entry.value;
}
}
return obj;
}else {
return null;
}
}
public static void main(String[] args) {
MyHashMap myHashMap = new MyHashMap();
myHashMap.put("1", "a");
myHashMap.put("2", "b");
myHashMap.put("3", "c");
System.out.println(myHashMap.get("2"));
System.out.println(myHashMap.size);
}
}
b
3
因为链表查询效率不像数组那样高,当链表长度很长时会降低效率,因此JDK8后加上了红黑树,当链表长度超过阈值8时,会进行红黑树筛选查询(类似二分法,不再全部遍历,而是不断折中遍历,有个红黑树模拟的网站:https://www.cs.usfca.edu/~galles/visualization/RedBlack.html)。红黑树的遍历过程会在本文后面的TreeMap中介绍。
HashMap源码如下:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
//如果table为空或者未分配空间,则resize,放入第一个K-V时总是先resize
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//(n-1)&hash计算K-V存的table位置,如果首节点为null,代表该位置还没放入过结点
if ((p = tab[i = (n - 1) & hash]) == null)
//调用newNode新建一个结点放入该位置
tab[i] = newNode(hash, key, value, null);
// 到这里,表示有冲突,开始处理冲突
else {
Node e; K k;
//这时p指向首个Node,判断table[i]是否与待插入节点有相同的hash,key值,如果是则e=p
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//到这里说明table[i]的第一个Node与待插入Node的hash或key不同,那么要在
//这个节点之后的链表节点或者树节点中查找
else if (p instanceof TreeNode)
e = ((TreeNode)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);
// 如果冲突的节点数已经达到8个,看是否需要改变冲突节点的存储结构
// treeifyBin首先判断当前hashMap的长度,如果不足64,只进行
// resize,扩容table,如果达到64,那么将冲突的存储结构为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果找到同hash或同key的节点,那么直接退出循环,
//此时e等于冲突Node
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//调整p节点,以继续查找
p = e;
}
}
//退出循环后,先判断e是否为null,为null表示已经插入成功,不为null表示有冲突
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;
}
/**
* Replaces all linked nodes in bin at index for given hash unless
* table is too small, in which case resizes instead.
*/
/*树形化*/
final void treeifyBin(Node[] tab, int hash) {
// 定义n:节点数组长度、index:hash对应的数组下标、e:用于循环的迭代变量,代表当前节
int n, index; Node e;
// 若数组尚未初始化或者数组长度小于64,则直接扩容而不进行树形化
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 获取指定数组下标的头结点e
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 定义head节点hd、尾节点tl
TreeNode hd = null, tl = null;
// 该循环主要是将原单向链表转化为双向链表
do {
TreeNode p = replacementTreeNode(e, null);
// 若尾节点为null表明首次循环,此时e为头结点、p为根节点,将p赋值给表示头结点的hd
if (tl == null)
hd = p;
// 负责根节点已经产生过了此时tl尾节点指向上次循环创建的树形节点
else {
p.prev = tl;
tl.next = p;
}
// 将tl指向当前节点
tl = p;
} while ((e = e.next) != null);
// 若指定的位置头结点不为空则进行树形化
if ((tab[index] = hd) != null)
// 根据链表创建红黑树结构
hd.treeify(tab);
}
}
/**
* Forms tree of the nodes linked from this node.
* @return root of tree
*/
final void treeify(Node[] tab) {
// 定义树的根节点
TreeNode root = null;
// 遍历链表,x指向当前节点、next指向下一个节点
for (TreeNode x = this, next; x != null; x = next) {
next = (TreeNode)x.next;
x.left = x.right = null;
// 如果还没有根节点
if (root == null) {
x.parent = null;
// 当前节点的红色属性设为false(把当前节点设为黑色)
x.red = false;
// 根节点指向到当前节点
root = x;
}
else {
// 如果已经存在根节点了
K k = x.key;
int h = x.hash;
Class> kc = null;
// 从根节点开始遍历,此遍历没有设置边界,只能从内部跳出
for (TreeNode p = root;;) {
// dir 标识方向(左右)、ph标识当前树节点的hash值
int dir, ph;
// 当前树节点的key
K pk = p.key;
// 如果当前树节点hash值 大于 当前链表节点的hash值
if ((ph = p.hash) > h)
// 标识当前链表节点会放到当前树节点的左侧
dir = -1;
else if (ph < h)
// 右侧
dir = 1;
/*
* 如果两个节点的key的hash值相等,那么还要通过其他方式再进行比较
* 如果当前链表节点的key实现了comparable接口,并且当前树节点和链表节点是相同
* Class的实例,那么通过comparable的方式再比较两者。
* 如果还是相等,最后再通过tieBreakOrder比较一次
*/
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
// 当前链表节点 作为 当前树节点的子节点
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
// 重新平衡
root = balanceInsertion(root, x);
break;
}
}
}
}
// 把所有的链表节点都遍历完之后,最终构造出来的树可能经历多个平衡操作,根节点目前到
// 底是链表的哪一个节点是不确定的
// 因为我们要基于树来做查找,所以就应该把 tab[N] 得到的对象一定根节点对象,而目前只
// 是链表的第一个节点对象,所以要做相应的处理。
moveRootToFront(tab, root);
}
这里还有个需要关注的地方,就是HashMap的key值是唯一的,这也是为什么Set集合可以保存不重复元素的基础。那么怎么实现key值唯一呢?注意看上面的put()方法中有一行代码:
// 根据key值计算其hash值,定位数组中可以插入的数组位置
int hashValue = hash(key);
实际上,我们往HashMap中存储数据时,会先检查要存的数据的key值,通过hash()方法得到一个hashCode值,这个值用于定位数组中的位置(为了简化概念,这么说实际上不太准确,真实的是还要和这个hashcode的高16位进行异或计算才是hash(key)方法的返回值),若该位置为空则表示这是一个全新的元素,在该数组位置直接新建链表把这个元素保存起来,此时该key值在整个HashMap中只有一个,是唯一的;若该位置不为空,已经有链表了,说明有其他元素的key的hashCode值和现在要保存元素的key的HashCode值一样,那么极有可能key值是一样的,就需要循环判断是否有重复的key值,判断的方法是equals()(默认不重写equals方法时,“equals”和“==”一样,判断对象的引用(地址)是否相同,即判断是否是同一个对象),若有则替换该处的value值,若无则直接在尾部新增,因此key值也是唯一的。
总结起来就是,先用hash()方法判断key的HashCode值是否一样,若一样再用equals()方法判断是否key已经存在,若存在就覆盖该key对应的value值,始终保持key值唯一。
还要注意一个辩证原则:
简单的说就是:“如果两个对象相同,那么他们的hashcode应该相等。”
若重写了equals方法就必须重写hashCode方法,不能违反辩证原则一,即确保通过equals(Object obj)方法判断结果为true的两个对象具备相等的hashcode()返回值,若只重写了equals,不重写hashcode可能导致equals(Object obj)方法判断结果为true而hashcode()返回值不相等。
补充:
为什么不直接返回key.hashCode()呢?还要与 (h >>> 16)异或?源码中并不是直接返回key对象的hashCode,如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
h >>> 16是用来取出h的高16位(>>>是无符号右移) ,因为多数情况下hashcode 的高16位与其自身进行异或运算,会让得到的下标更加散列。
HashMap都说是线程不安全的,其实有些太绝对,准确的说它的get()方法是线程安全的,但是put()方法是线程不安全的,我们分别看下这两个方法的源码(put()方法源码在本文上面已列):
get()方法不会改变数据存储结构,无论哪个线程访问都是一样的结果,因此线程安全。
put()方法会改变原有的存储结构,可能会进行扩容,a线程访问比如A[0]这个位置值为1,等b线程进来后可能会扩容,这时A[0]这个位置的值在扩容后就不一定还在原来A[0]的位置,这时再访问A[0]可能是空或者别的值,因此线程不安全。
注意:
HashMap中resize()方法用于扩容:当HashMap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,在hashmap数组扩容时,最消耗性能的点就是:原数组中的数据必须重新计算其在新数组中的位置,并放进去,System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length);核心方法。
HashMap中有一个静态属性(其他Map子类都有,可能命名和形式有些差别,但作用一样),就是负载因子。
HashMap有一个构造方法为:
HashMap(int initialCapacity, float loadFactor)
这两个参数,一个是 int initCapacity(初始化数组大小,默认值是16),一个是float loadFactor(负载因子,默认值是0.75),首先会根据initCapacity计算出一个大于或者等于initCapacity且为2的幂的值capacity,例如initCapacity为15,那么capacity就会为16,还会算出一个临界值threshold,也就是capacity * loadFactor,initailCapacity,loadFactor会影响到HashMap扩容。threshold相当于是一个扩容机制的阈值,当超过了这个阈值,就会触发扩容机制。
比如说当前的容器容量是16,负载因子是0.75,16*0.75=12,也就是说,当容量达到了12的时候就会进行扩容操作。
负载因子表示一个散列表的空间的使用程度,有这样一个公式:initailCapacity*loadFactor=HashMap的容量。
所以负载因子越大则散列表的装填程度越高,也就是能容纳更多的元素,元素多了,链表大了,所以此时索引效率就会降低。
反之,负载因子越小则链表中的数据量就越稀疏,此时会对空间造成烂费,但是此时索引效率高。负载因子的大小决定了HashMap的数据密度。负载因子越大密度越大,发生碰撞的几率越高,数组中的链表越容易长,造成查询或插入时的比较次数增多,性能会下降。负载因子越小,就越容易触发扩容,数据密度也越小,意味着发生碰撞的几率越小,数组中的链表也就越短,查询和插入时比较的次数也越小,性能会更高。但是会浪费一定的内容空间。HashMap提供了一个构造函数,我们可以自己去定义初始大小和负载因子的值,不调用这个构造函数就默认的16和0.75。
总结:当负载因子较大时,去给table数组扩容的可能性就会少,所以相对占用内存较少(空间上较少),但是每条entry链上的元素会相对较多,查询的时间也会增长(时间上较多)。反之就是,负载因子较少的时候,给table数组扩容的可能性就高,那么内存空间占用就多,但是entry链上的元素就会相对较少,查出的时间也会减少。所以才有了负载因子是时间和空间上的一种折中的说法。所以设置负载因子的时候要考虑自己追求的是时间还是空间上的少。
LinkedHashMap是HashMap的子类,很多方法都是继承自父类,总得来说,LinkedHashMap底层是数组+单项链表+双向链表。数组加单向链表就是HashMap的结构,记录数据用,双向链表,存储插入顺序用。
因此LinkedHashMap是有序的,HashMap是无序的,当我们希望有顺序地去存储key-value时,就需要使用LinkedHashMap了。例如:
public class LinkedHashMapDemo {
public static void main(String[] args) {
Map hashMap = new HashMap();
hashMap.put("knameiuk", "josan1");
hashMap.put("ku", "josan3");
hashMap.put("uk", "josan3");
hashMap.put("nakume4", "josan4");
hashMap.put("nateme5", "josan5");
System.out.println(hashMap);
}
}
{nakume4=josan4, uk=josan3, knameiuk=josan1, ku=josan3, nateme5=josan5}
以上HashMap并未按照插入顺序保存和输出,也就是说是无序的。再看LinkedHashMap:
public class LinkedHashMapDemo {
public static void main(String[] args) {
Map hashMap = new LinkedHashMap<>();
hashMap.put("knameiuk", "josan1");
hashMap.put("ku", "josan3");
hashMap.put("uk", "josan3");
hashMap.put("nakume4", "josan4");
hashMap.put("nateme5", "josan5");
System.out.println(hashMap);
}
}
{knameiuk=josan1, ku=josan3, uk=josan3, nakume4=josan4, nateme5=josan5}
以上,LinkedHashMap是按插入顺序保存和输出。
LinkedHashMap就是HashMap+双向链表,双向链表使得它支持两种排序:插入顺序和访问顺序,且默认为插入顺序,就像上面的示例代码。那么,LinkedHashMap怎么控制这两种顺序的呢?先看下LinkedHashMap的构造方法:
这些构造方法中出镜率最高的就是无参构造,其源码如下:
public LinkedHashMap() {
super();
accessOrder = false;
}
这里的accessOrder是个boolean类型的变量,它就是用来控制生成的LinkedHashMap实例是按插入顺序还是按访问顺序的关键,除了最后一个LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)构造方法传参可以控制accessOrder的值以外,其他构造方法都默认accessOrder = false。
public class LinkedHashMapDemo {
public static void main(String[] args) {
Map linkMap = new LinkedHashMap<>(16, 0.16f, true);
linkMap.put("1", "josan1");
linkMap.put("2", "josan3");
linkMap.put("3", "josan3");
linkMap.put("4", "josan4");
linkMap.put("5", "josan5");
System.out.println("没有get之前: " + linkMap);
linkMap.get("3");
System.out.println("使用get之后: " + linkMap);
}
}
没有get之前: {1=josan1, 2=josan3, 3=josan3, 4=josan4, 5=josan5}
使用get之后: {1=josan1, 2=josan3, 4=josan4, 5=josan5, 3=josan3}
可以看到,初始化时把accessOrder的值赋为true,即设置该LinkedHashMap实例化对象按照访问顺序排序。这里linkMap.get("3");导致了“3”对应的Entry移动到了最后,get()方法的源码如下:
public V get(Object key) {
Node e;
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder)
afterNodeAccess(e); // move node to last
return e.value;
}
可以看出get()方法的逻辑,accessOrder若为true(按访问顺序),则调用内部afterNodeAccess()方法,将被访问的值(key-value)放到链表尾部。若accessOrder为false(按插入顺序),则不会出现上述变化。afterNodeAccess()方法源码如下:
看到这你可能明白了怎么控制LinkedHashMap选择按访问顺序或者按插入顺序,通过布尔类型的accessOrder变量可以切换排序模式,但是你可能有个疑问,LinkedHashMap怎么实现的排序?
与HashMap的单向链表相比,LinkedHashMap增加了双向链表。
从上面自己实现的HashMap来看(实际上是单向链表,我是用双向链表实现的),有first和last两个节点分别表示该链表的头和尾,first头不会改变,last尾随着插入数据向后移动,由first遍历到last就是按照插入顺序获取,实现了按插入顺序排序(first头始终是遍历的入口,在源码中,first即head,他的hash值是-1,也就是说head是不在数组table中的)。
ConcurrentHashMap可以看成是并发的HashMap,默认并发级别为16线程。
ConcurrentHashMap的特点:Hashtable的线程安全性 + HashMap的高性能。
原因:
(1)数据更新的时候只锁对应区域(桶),而其他区域的访问不受影响;
(2)在锁的区域使用读写锁,读异步而写同步,即便在同一个桶中,数据读取依然不受影响。
ConcurrentHashMap当链表节点数超过指定阈值的话,也是会转换成红黑树的,大体结构是一样的。源码入下(主要看注释):
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
//1. 计算key的hash值
int hash = spread(key.hashCode());
int binCount = 0;
for (Node[] tab = table;;) {
Node f; int n, i, fh;
//2. 如果当前table还没有初始化先调用initTable方法将tab进行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//3. tab中索引为i的位置的元素为null,则直接使用CAS将值插入即可
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//4. 当前正在扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
//5. 当前为链表,在链表中插入新的键值对
if (fh >= 0) {
binCount = 1;
for (Node e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node pred = e;
if ((e = e.next) == null) {
pred.next = new Node(hash, key,
value, null);
break;
}
}
}
// 6.当前为红黑树,将新的键值对插入到红黑树中
else if (f instanceof TreeBin) {
Node p;
binCount = 2;
if ((p = ((TreeBin)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 7.插入完键值对后再根据实际大小看是否需要转换成红黑树
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//8.对当前容量大小进行检查,如果超过了临界值(实际大小*加载因子)就需要扩容
addCount(1L, binCount);
return null;
}
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作,第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部,因此,这一种结构的带来的副作用是Hash的过程要比普通的HashMap要长,但是带来的好处是写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响到其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上),所以,通过这一种结构,ConcurrentHashMap的并发能力可以大大的提高。
1.Segment(分段锁)
ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock锁(Segment继承了ReentrantLock实现锁功能)。
2.内部结构
ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。
从上面的结构我们可以了解到,ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作。
第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部。
3.该结构的优劣势
坏处
这一种结构的带来的副作用是Hash的过程要比普通的HashMap要长
好处
写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响到其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上)。所以,通过这一种结构,ConcurrentHashMap的并发能力可以大大的提高。
ConcurrentHashMap通过将完整的表分成若干个segment的方式实现锁分离,每个segment都是一个独立的线程安全的Hash表,当需要操作数据时,HashMap通过Key的hash值和segment数量来路由到某个segment。这里segment继承了ReentrantLock,ReentrantLock可通过构造参数设置时公平锁还是非公平锁,需要明文释放锁,而synchronized是自动释放的。
static final class Segment extends ReentrantLock implements Serializable {
/*
1.segment的读操作不需要加锁,但需要volatile读
2.当进行扩容时(调用reHash方法),需要拷贝原始数据,在拷贝数据上操作,保证在扩容完成前读操作仍可以在原始数据上进行。
3.只有引起数据变化的操作需要加锁。
4.scanAndLock(删除、替换)/scanAndLockForPut(新增)两个方法提供了获取锁的途径,是通过自旋锁实现的。
5.在等待获取锁的过程中,两个方法都会对目标数据进行查找,每次查找都会与上次查找的结果对比,虽然查找结果不会被调用它的方法使用,但是这样做可以减少后续操作可能的cache miss。
*/
private static final long serialVersionUID = 2249069246763182397L;
/*
自旋锁的等待次数上限,多处理器时64次,单处理器时1次。
每次等待都会进行查询操作,当等待次数超过上限时,不再自旋,调用lock方法等待获取锁。
*/
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
/*
segment中的hash表,与hashMap结构相同,表中每个元素都是一个链表。
*/
transient volatile HashEntry[] table;
/*
表中元素个数
*/
transient int count;
/*
记录数据变化操作的次数。
这一数值主要为Map的isEmpty和size方法提供同步操作检查,这两个方法没有为全表加锁。
在统计segment.count前后,都会统计segment.modCount,如果前后两次值发生变化,可以判断在统计count期间有segment发生了其它操作。
*/
transient int modCount;
/*
容量阈值,超过这一数值后segment将进行扩容,容量变为原来的两倍。
threshold = loadFactor*table.length
*/
transient int threshold;
final float loadFactor;
Segment(float lf, int threshold, HashEntry[] tab) {
this.loadFactor = lf;
this.threshold = threshold;
this.table = tab;
}
/*
onlyIfAbsent:若为true,当key已经有对应的value时,不进行替换;
若为false,即使key已经有对应的value,仍进行替换。
关于put方法,很重要的一点是segment最大长度的问题:
代码 c > threshold && tab.length < MAXIMUM_CAPACITY 作为是否需要扩容的判断条件。
扩容条件是node总数超过阈值且table长度小于MAXIMUM_CAPACITY也就是2的30次幂。
由于扩容都是容量翻倍,所以tab.length最大值就是2的30次幂。此后,即使node总数超过了阈值,也不会扩容了。
由于table[n]对应的是一个链表,链表内元素个数理论上是无限的,所以segment的node总数理论上也是无上限的。
ConcurrentHashMap的size()方法考虑到了这个问题,当计算结果超过Integer.MAX_VALUE时,直接返回Integer.MAX_VALUE.
*/
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//tryLock判断是否已经获得锁.
//如果没有获得,调用scanAndLockForPut方法自旋等待获得锁。
HashEntry node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry[] tab = table;
//计算key在表中的下标
int index = (tab.length - 1) & hash;
//获取链表的第一个node
HashEntry first = entryAt(tab, index);
for (HashEntry e = first;;) {
//链表下一个node不为空,比较key值是否相同。
//相同的,根据onlyIfAbsent决定是否替换已有的值
if (e != null) {
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
//链表遍历到最后一个node,仍没有找到key值相同的.
//此时应当生成新的node,将node的next指向链表表头,这样新的node将处于链表的【表头】位置
if (node != null)
//scanAndLockForPut当且仅当hash表中没有该key值时
//才会返回新的node,此时node不为null
node.setNext(first);
else
//node为null,表明scanAndLockForPut过程中找到了key值相同的node
//可以断定在等待获取锁的过程中,这个node被删除了,此时需要新建一个node
node = new HashEntry(hash, key, value, first);
//添加新的node涉及到扩容,当node数量超过阈值时,调用rehash方法进行扩容,并将新的node加入对应链表表头;
//没有超过阈值,直接加入链表表头。
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
/*
hash表容量翻倍,将需要添加的node添加到扩容后的表中。
hash表默认初始长度为16,实际长度总是2的n次幂。
设当前table长度为S,根据key的hash值计算table中下标index的公式:
扩容前:oldIndex = (S-1)&hash
扩容后:newIndex = (S<<1-1)&hash
扩容前后下标变化:newIndex-oldIndex = S&hash
所以,扩容前后node所在链表在table中的下标要么不变,要么右移2的幂次。
根据本方法官方注释说明,大约六分之一的node需要复制操作。
对于每个链表,处理方法如下:
步骤一:对于链表中的每个node,计算node和node.next的新下标,如果它们不相等,记录最后一次出现这种情况时的node.next,记为nodeSpecial。
这一部分什么意思呢,假设table[n]所在的链表共有6个node,计算它们的新下标:
情况1:若计算结果为0:n,1:n+S,2:n,3:n+2,4:n,5:n,那么我们记录的特殊node编号为4;
情况2:若计算结果为0:n,1:n+S,2:n,3:n+2,4:n+4,5:n+8,那么我们记录的特殊node编号为5;
情况3:若计算结果为0:n,1:n,2:n,3:n,4:n,5:n,特殊node为0;
情况4:若计算结果为0:n+S,1:n+S,2:n+S,3:n+S,4:n+S,5:n+S,特殊node为0。
很重要的一点,由于新下标只可能是n或n+S,因此这两个位置的链表中不会出现来自其它链表的node。
对于情况3,令table[n]=node0,进入步骤三;
对于情况4,令table[n+S]=node0,进入步骤三;
对于情况1,令table[n]=node4,进入步骤二;
对于情况2,令table[n+S]=node3,进入步骤二。
步骤二:从node0遍历至nodeSpecial的前一个node,对于每一个node,调用HashEntry构造方法复制这个node,放入对应的链表。
步骤三:计算需要新插入的node的下标index,同样令node.next=table[index],table[index]=node,将node插入链表表头。
通过三步完成了链表的扩容和新node的插入。
在理解这一部分代码的过程中,牢记三点:
1.调用rehash方法的前提是已经获得了锁,所以扩容过程中不存在其他线程修改数据;
2.新的下标只有两种情况,原始下标n或者新下标n+S;
3.通过2可以推出,原表中不在同一链表的node,在新表中仍不会出现在同一链表中。
*/
@SuppressWarnings("unchecked")
private void rehash(HashEntry node) {
//拷贝table,所有操作都在oldTable上进行,不会影响无需获得锁的读操作
HashEntry[] oldTable = table;
int oldCapacity = oldTable.length;
int newCapacity = oldCapacity << 1;//容量翻倍
threshold = (int)(newCapacity * loadFactor);//更新阈值
HashEntry[] newTable =
(HashEntry[]) new HashEntry[newCapacity];
int sizeMask = newCapacity - 1;
for (int i = 0; i < oldCapacity ; i++) {
HashEntry e = oldTable[i];
if (e != null) {
HashEntry next = e.next;
int idx = e.hash & sizeMask;//新的table下标,定位链表
if (next == null)
//链表只有一个node,直接赋值
newTable[idx] = e;
else {
HashEntry lastRun = e;
int lastIdx = idx;
//这里获取特殊node
for (HashEntry last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
//步骤一中的table[n]赋值过程
newTable[lastIdx] = lastRun;
// 步骤二,遍历剩余node,插入对应表头
for (HashEntry p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry n = newTable[k];
newTable[k] = new HashEntry(h, p.key, v, n);
}
}
}
}
//步骤三,处理需要插入的node
int nodeIndex = node.hash & sizeMask;
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
//将扩容后的hashTable赋予table
table = newTable;
}
/*
put方法调用本方法获取锁,通过自旋锁等待其他线程释放锁。
变量retries记录自旋锁循环次数,当retries超过MAX_SCAN_RETRIES时,不再自旋,调用lock方法等待锁释放。
变量first记录hash计算出的所在链表的表头node,每次循环结束,重新获取表头node,与first比较,如果发生变化,说明在自旋期间,有新的node插入了链表,retries计数重置。
自旋过程中,会遍历链表,如果发现不存在对应key值的node,创建一个,这个新node可以作为返回值返回。
根据官方注释,自旋过程中遍历链表是为了缓存预热,减少hash表经常出现的cache miss
*/
private HashEntry scanAndLockForPut(K key, int hash, V value) {
HashEntry first = entryForHash(this, hash);
HashEntry e = first;
HashEntry node = null;
int retries = -1; //自旋次数计数器
while (!tryLock()) {
HashEntry f;
if (retries < 0) {
if (e == null) {
//链表为空或者遍历至链表最后一个node仍没有找到匹配
if (node == null)
node = new HashEntry(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
retries = 0;
else
e = e.next;
}
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
//比较first与新获得的链表表头node是否一致,如果不一致,说明该链表别修改过,自旋计数重置
e = first = f;
retries = -1;
}
}
return node;
}
/*
remove,replace方法会调用本方法获取锁,通过自旋锁等待其他线程释放锁。
与scanAndLockForPut机制相似。
*/
private void scanAndLock(Object key, int hash) {
// similar to but simpler than scanAndLockForPut
HashEntry first = entryForHash(this, hash);
HashEntry e = first;
int retries = -1;
while (!tryLock()) {
HashEntry f;
if (retries < 0) {
if (e == null || key.equals(e.key))
retries = 0;
else
e = e.next;
}
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f;
retries = -1;
}
}
}
/*
删除key-value都匹配的node,删除过程很简单:
1.根据hash计算table下标index。
2.根据index定位链表,遍历链表node,如果存在node的key值和value值都匹配,删除该node。
3.令node的前一个节点pred的pred.next = node.next。
*/
final V remove(Object key, int hash, Object value) {
//获得锁
if (!tryLock())
scanAndLock(key, hash);
V oldValue = null;
try {
HashEntry[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry e = entryAt(tab, index);
HashEntry pred = null;
while (e != null) {
K k;
HashEntry next = e.next;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
V v = e.value;
if (value == null || value == v || value.equals(v)) {
if (pred == null)
setEntryAt(tab, index, next);
else
pred.setNext(next);
++modCount;
--count;
oldValue = v;
}
break;
}
pred = e;
e = next;
}
} finally {
unlock();
}
return oldValue;
}
/*
找到hash表中key-oldValue匹配的node,替换为newValue,替换过程与replace方法类似,不再赘述了。
*/
final boolean replace(K key, int hash, V oldValue, V newValue) {
if (!tryLock())
scanAndLock(key, hash);
boolean replaced = false;
try {
HashEntry e;
for (e = entryForHash(this, hash); e != null; e = e.next) {
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
if (oldValue.equals(e.value)) {
e.value = newValue;
++modCount;
replaced = true;
}
break;
}
}
} finally {
unlock();
}
return replaced;
}
final V replace(K key, int hash, V value) {
if (!tryLock())
scanAndLock(key, hash);
V oldValue = null;
try {
HashEntry e;
for (e = entryForHash(this, hash); e != null; e = e.next) {
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
e.value = value;
++modCount;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
/*
清空segment,将每个链表置为空,count置为0,剩下的工作交给GC。
*/
final void clear() {
lock();
try {
HashEntry[] tab = table;
for (int i = 0; i < tab.length ; i++)
setEntryAt(tab, i, null);
++modCount;
count = 0;
} finally {
unlock();
}
}
}
先要了解HashTable和HashMap的区别与联系:
Entry.
value均可以为null。但是HashTable中是不允许保存null
的HashTable的主要方法的源码实现逻辑,与HashMap中非常相似,有一点重大区别就是所有的操作都是通过synchronized
锁保护的。只有获得了对应的锁,才能进行后续的读写等操作。
put(K key, V value)源码:
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry entry = (Entry)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
get()源码:
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;
}
rehash()扩容方法源码:
protected void rehash() {
int oldCapacity = table.length;
Entry,?>[] oldMap = table;
// overflow-conscious code
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 old = (Entry)oldMap[i] ; old != null ; ) {
Entry e = old;
old = old.next;
int index = (e.hash & 0x7FFFFFFF) % newCapacity;
e.next = (Entry)newMap[index];
newMap[index] = e;
}
}
}
那么既然ConcurrentHashMap那么优秀,为什么还要有Hashtable的存在呢?ConcurrentHashMap能完全替代HashTable吗?
HashTable虽然性能上不如ConcurrentHashMap,但并不能完全被取代,两者的迭代器的一致性不同的,HashTable的迭代器是强一致性的,而ConcurrentHashMap是弱一致的。可能你期望往ConcurrentHashMap底层数据结构中加入一个元素后,立马能对get可见,但ConcurrentHashMap并不能如你所愿。换句话说,put操作将一个元素加入到底层数据结构后,get可能在某段时间内还看不到这个元素,若不考虑内存模型,单从代码逻辑上来看,却是应该可以看得到的。
ConcurrentHashMap的弱一致性主要是为了提升效率,是一致性与效率之间的一种权衡。要成为强一致性,就得到处使用锁,甚至是全局锁,这就与Hashtable和同步的HashMap一样了。
前面介绍了Map接口的实现类LinkedHashMap,LinkedHashMap存储的元素是有序的,可以保持元素的插入顺序,但不能对元素进行自动排序。假如你遇到这样的场景,插入数据后想按照插入数据的大小来排序,该怎么办?当然你可以自己循环排序一下,但是开发效率就降低了,这时用TreeMap就事半功倍了。
TreeMap中的元素默认按照keys的自然排序排列。(对Integer来说,其自然排序就是数字的升序;对String来说,其自然排序就是按照字母表排序)它是通过红黑树(也叫平衡二叉树)实现的,这里简单介绍,后面会用单独的篇幅介绍红黑树,这里简单描述一下:
红黑树首先是一棵二叉树,具有二叉树所有的特性,即树中的任何节点的值大于它的左子节点,且小于它的右子节点,如果是一棵左右完全均衡的二叉树,元素的查找效率将获得极大提高。最坏的情况就是一边倒,只有左子树或只有右子树,这样势必会导致二叉树的检索效率大大降低。为了维持二叉树的平衡,人提出了各种实现的算法,其中平衡二叉树就是其中的一种算法。平衡二叉树的数据结构如下图所示(可以到上面的红黑树模拟网站试试 https://www.cs.usfca.edu/~galles/visualization/RedBlack.html),还有红黑树详解文章 (https://blog.csdn.net/weixin_41231928/article/details/106652325):
public class Demo {
public static void main(String[] args) {
Map treeMap = new TreeMap();
treeMap.put(6, "6");
treeMap.put(5, "5");
treeMap.put(3, "3");
treeMap.put(10, "10");
treeMap.put(9, "9");
treeMap.put(8, "8");
System.out.println("1.输出: " + treeMap);
System.out.println("2.get: " + treeMap.get(3));
treeMap.put(3, "666");
System.out.println("3.输出: " + treeMap);
}
}
1.输出: {3=3, 5=5, 6=6, 8=8, 9=9, 10=10}
2.get: 3
3.输出: {3=666, 5=5, 6=6, 8=8, 9=9, 10=10}
按照key的自然顺序排序。
前面已经说过,Map没有继承Iterator接口,可以先使用entrySet()转换成Set再循环遍历。
/**
* Feng, Ge 2020/3/7 17:20
*/
public class Demo {
public static void main(String[] args) {
Map treeMap = new TreeMap();
treeMap.put(6, "6");
treeMap.put(5, "5");
treeMap.put(3, "3");
treeMap.put(10, "10");
treeMap.put(9, "9");
treeMap.put(8, "8");
Set set = treeMap.entrySet();
Iterator iterator = set.iterator();
while (iterator.hasNext()){
System.out.println(iterator.next());
}
}
}
3=666
5=5
6=6
8=8
9=9
10=10
TreeMap的成员变量:
/**
* Map的自动排序按照我们自己的规则,这个时候你就需要传递Comparator的实现类
*/
private final Comparator super K> comparator;
/**
*红黑树的根节点。
*/
private transient Entry root;
/**
* 红黑树中节点Entry的数量
*/
private transient int size = 0;
/**
* 红黑树结构的调整次数
*/
private transient int modCount = 0;
Entry:
put()方法:
public V put(K key, V value) {
Entry t = root;
/**
* 如果根节点都为null,还没建立起来红黑树,先new Entry并赋值给root把红黑树建立起来,这个时候红
* 黑树中已经有一个节点了,同时修改操作+1。
*/
if (t == null) {
compare(key, key);
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
/**
* 如果节点不为null,定义一个cmp,这个变量用来进行二分查找时的比较;定义parent,是new Entry时必须
* 要的参数
*/
int cmp;
Entry parent;
// 有无自己定义的排序规则,分两种情况遍历执行
Comparator super K> cpr = comparator;
if (cpr != null) {
/**
* 有自定义的排序规则:
* 从root节点开始遍历,通过二分查找逐步向下找
* 第一次循环:从根节点开始,这个时候parent就是根节点,然后通过自定义的排序算法
* cpr.compare(key, t.key)比较传入的key和根节点的key值,如果传入的keyroot.key,
* 那么继续在root的右子树中找,从root的右孩子节点(root.right)开始;如果恰好key==root.key,
* 那么直接根据root节点的value值即可。
* 后面的循环规则一样,当遍历到的当前节点作为起始节点,逐步往下找
*
* 需要注意的是:这里并没有对key是否为null进行判断,建议自己的实现Comparator时应该要考虑在内
*/
do {
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
else {
//从这里看出,当默认排序时,key值是不能为null的
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);
}
/**
* 能执行到这里,说明前面并没有找到相同的key,节点已经遍历到最后了,我们只需要new一个Entry放到
* parent下面即可,但放到左子节点上还是右子节点上,就需要按照红黑树的规则来。
*/
Entry e = new Entry<>(key, value, parent);
if (cmp < 0)
parent.left = e;
else
parent.right = e;
/**
* 节点加进去了,并不算完,我们在前面红黑树原理章节提到过,一般情况下加入节点都会对红黑树的结构造成
* 破坏,我们需要通过一些操作来进行自动平衡处置,如【变色】【左旋】【右旋】
*/
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
private void fixAfterInsertion(Entry x) {
//新插入的节点为红色节点
x.color = RED;
//我们知道父节点为黑色时,并不需要进行树结构调整,只有当父节点为红色时,才需要调整
while (x != null && x != root && x.parent.color == RED) {
//如果父节点是左节点,对应上表中情况1和情况2
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
Entry 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 {
//如果插入节点是黑色,插入的是右子节点,通过【左右节点旋转】(这里先进行父节点左旋)
if (x == rightOf(parentOf(x))) {
x = parentOf(x);
rotateLeft(x);
}
//设置父节点和祖父节点颜色
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
//进行祖父节点右旋(这里【变色】和【旋转】并没有严格的先后顺序,达成目的就行)
rotateRight(parentOf(parentOf(x)));
}
} else {
//父节点是右节点的情况
Entry 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;
}
private void rotateLeft(Entry p) {
if (p != null) {
/**
* 断开当前节点p与其右子节点的关联,重新将节点p的右子节点的地址指向节点p的右子节点的左子节点
* 这个时候节点r没有父节点
*/
Entry r = p.right;
p.right = r.left;
//将节点p作为节点r的父节点
if (r.left != null)
r.left.parent = p;
//将节点p的父节点和r的父节点指向同一处
r.parent = p.parent;
//p的父节点为null,则将节点r设置为root
if (p.parent == null)
root = r;
//如果节点p是左子节点,则将该左子节点替换为节点r
else if (p.parent.left == p)
p.parent.left = r;
//如果节点p为右子节点,则将该右子节点替换为节点r
else
p.parent.right = r;
//重新建立p与r的关系
r.left = p;
p.parent = r;
}
}
get()方法:
public V get(Object key) {
Entry p = getEntry(key);
return (p==null ? null : p.value);
}
/**
* 从root节点开始遍历,通过二分查找逐步向下找
* 第一次循环:从根节点开始,这个时候parent就是根节点,然后通过k.compareTo(p.key)比较传入的key和
* 根节点的key值;
* 如果传入的keyroot.key, 那么继续在root的右子树中找,从root的右孩子节点(root.right)开始;
* 如果恰好key==root.key,那么直接根据root节点的value值即可。
* 后面的循环规则一样,当遍历到的当前节点作为起始节点,逐步往下找
*/
//默认排序情况下的查找
final Entry getEntry(Object key) {
if (comparator != null)
return getEntryUsingComparator(key);
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable super K> k = (Comparable super K>) key;
Entry p = root;
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
return null;
}
/**
* 从root节点开始遍历,通过二分查找逐步向下找
* 第一次循环:从根节点开始,这个时候parent就是根节点,然后通过自定义的排序算法
* cpr.compare(key, t.key)比较传入的key和根节点的key值,如果传入的keyroot.key,
* 那么继续在root的右子树中找,从root的右孩子节点(root.right)开始;如果恰好key==root.key,
* 那么直接根据root节点的value值即可。
* 后面的循环规则一样,当遍历到的当前节点作为起始节点,逐步往下找
*/
//自定义排序规则下的查找
final Entry getEntryUsingComparator(Object key) {
@SuppressWarnings("unchecked")
K k = (K) key;
Comparator super K> cpr = comparator;
if (cpr != null) {
Entry p = root;
while (p != null) {
int cmp = cpr.compare(k, p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
}
return null;
}
LinkedHashMap映射减少了HashMap排序中的混乱,且不会导致TreeMap的性能损失。
提起set集合,Set集合与List集合的区别就是,Set集合的元素不能重复,且是无序的(LinkedHashSet有序),List集合的元素是可以重复的。如下:
public class SetTest {
public static void main(String[] args) {
Set set = new HashSet();
// Set set = new TreeSet();
// Set set = new LinkedHashSet();
set.add("a1");
set.add("a2");
System.out.println("未添加重复元素之前的set集合: " + set);
System.out.println("添加【不重复】元素返回值是: " + set.add("a3"));
System.out.println("添加【重复】元素返回值是: " + set.add("a1"));
System.out.println("未添加重复元素之后的set集合: " + set);
}
}
结果:
未添加重复元素之前的set集合: [a1, a2]
添加【不重复】元素返回值是: true
添加【重复】元素返回值是: false
未添加重复元素之后的set集合: [a1, a2, a3]
可以看出我们想在set集合中添加重复元素是添加不上的,这里无论是HashSet还是LinkedHashSet或者TreeSet都是一样的,都无法保存重复元素。
Set集合的继承关系可以参考本文一开始的继承关系图,set的实现类有很多,包含:AbstractSet , ConcurrentHashMap.KeySetView , ConcurrentSkipListSet , CopyOnWriteArraySet , EnumSet , HashSet , JobStateReasons , LinkedHashSet , TreeSet,这里重点看HashSet 、 LinkedHashSet 和 TreeSet。
实际上set主要基于各种map进行实现,具体的:
HashSet有以下Tips:
HashMap的key值通过hashCode和equals实现了不重复,HashSet正是运用了这个特性实现了不重复保存元素。HashSet的构造方法返回的是一个HashMap对象,把要保存的数据全部保存到该HashMap对象的key中,而该HashMap对象的所有value值则统一用一个Object类型的对象来填充。下面自己简单写个HashSet类:
public class MySet {
private transient HashMap
2
PRESENT对象是模拟所有的value值,源码是这样注释的:“// Dummy value to associate with an Object in the backing Map”
明白了HashSet再去理解LinkedHashSet就简单了,与HashSet不同的是LinkedHashSet除了不允许重复外,可以支持排序(所以那些直接说Set集合是无序的说法不够严谨,LinkedHashSet是支持排序的,即添加顺序和遍历顺序一致)。
为啥LinkedHashSet能支持排序呢?没错!你猜的没错,它的底层是一个LinkedHashMap,自己简单写个LinkedHashSet类如下:
public class MySet {
private transient HashMap
当然源码中LinkedHashSet继承了HashSet,实例化LinkedHashMap是在父类HashSet中完成的。
TreeSet和LinkedHashSet类似,提供有序的Set集合。
TreeSet的构造函数都是通过新建一个TreeMap作为实际存储Set元素的容器。对于TreeMap而言,它采用一种被称为”红黑树”的排序二叉树来保存Map中每个Entry。每个Entry被当成”红黑树”的一个节点来对待。
所以TreeMap添加元素,取出元素的性能都比HashMap低。当TreeMap添加元素时,需要通过循环找到新增的Entry的插入位置,因为比较耗性能。当取出元素时,也需要通过循环才能找到合适的Entry一样比较耗性能。但并不是说TreeMap性能低于HashMap就一无是处,TreeMap中的所有Entry总是按key根据指定的排序规则保持有序状态。从本质上来说TreeMap就是一棵”红黑树”,每个Entry就是一个节点。
备注:红黑树是一种自平衡二叉查找树 , 它们当中每一个节点的比较值都必须大于或等于在它的左子树中的所有节点,并且小于或等于在它的右子树中的所有节点。这确保红黑树运作时能够快速的在树中查找给定的值。