一、List
1、ArrayList
- ArrayList是一种变长的集合类,基于定长数组实现,使用默认构造方法初始化出来的容量是10(1.7之后都是延迟初始化,即第一次调用add方法添加元素的时候才将elementData容量初始化为10)
- ArrayList允许空值和重复元素,当往ArrayList中添加的元素数量大于其底层数组容量时,其会通过扩容机制重新生成一个更大的数组。ArrayList扩容的长度是原长度的1.5倍
- 由于ArrayList底层基于数组实现,所以其可以保证在 的时间复杂度下完成随机查找操作
- ArrayList是非线程安全类
- 删除和插入需要调用
System.arraycopy
方法复制数组,性能差
2、LinkedList
1)、特性
LinkedList进行节点插入、删除时间复杂度是 ,但是随机访问时间复杂度是
2)、底层数据结构
LinkedList底层实现是一个双向链表
private static class Node<E> {
//节点的值
E item;
//后继节点
Node<E> next;
//前驱结点
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
二、Set
HashSet、TreeSet、LinkedHashSet底层都是基于其相对应的Map实现的,只使用了Map的key,保证了Set中的元素不重复
三、Map
1、HashMap
HashMap是基于键的hashCode值唯一标识一条数据,同时基于键的hashCode值进行数据的存取,因此可以快速地更新和查询数据,但其每次遍历的顺序无法保证相同。HashMap的key和value允许为null
HashMap是非线程安全的,即在同一时刻有多个线程同时写HashMap时将可能导致数据的不一致。如果需要满足线程安全的条件,则可以用Collections.synchronizedMap
使HashMap具有线程安全的能力,或者使用ConcurrentHashMap
1)、底层数据结构
HashMap的数据结构如下图所示,其内部是一个数组,数组中的每个元素都是一个单向链表,链表中的每个元素都是嵌套类Entry的实例,Entry实例包含4个属性:key、value、hash值和用于指向单向链表下一个元素的next
链表主要是为了解决数组中的key发生哈希冲突时,将发生碰撞的key存储到链表中
当哈希冲突严重时,在桶上形成的链表会变得越来越长,这样在查询时的效率就会越来越低,时间复杂度为
所以在JDK1.8中,当链表长度大于8且HashMap数组长度大于等于64(数组长度小于64进行扩容操作)时,会将链表转换为红黑树,修改为红黑树之后查询效率变为了
HashMap不直接使用红黑树,是因为树节点所占空间是普通节点的两倍,所以只有当节点足够的时候,才会使用树节点。也就是说,尽管时间复杂度上,红黑树比链表好一点,但是红黑树所占的空间比较大,所以综合考虑之下,只有在链表节点数太多的时候,红黑树占空间大这一劣势不太明显的时候,才会舍弃链表,使用红黑树
2)、put方法流程图
3)、默认初始化大小是多少?HashMap的扩容方式?负载因子是多少?
//默认容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//用于判断是否需要将链表转换为红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;
//JDK1.7中的HashEntry修改为Node
transient Node<K,V>[] table;
//HashMap中存放KV的数量
transient int size;
//当HashMap的size大于threshold时会执行resize操作,threshold=capacity*loadFactor
int threshold;
//负载因子
final float loadFactor;
...
给定的默认容量为16,负载因子为0.75。Map在使用过程中不断地往里面存放数据,当数量达到了 就需要将当前16的容量进行扩容
扩容过程分为两步:
- 扩容:创建一个新的Entry空数组,长度是原数组的2倍
- ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组
长度扩大以后,Hash的规则也随之改变,所以要进行ReHash操作
index = (length - 1) & hash(key)
负载因子需要在时间和空间成本上寻求一种折衷
负载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了
负载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数
所以,选择0.75作为默认的负载因子,完全是时间和空间成本上寻求的一种折衷选择
4)、为什么容量总是为2的n次幂?这样设计的目的是什么?
HashMap的tableSizeFor()
方法做了处理,能保证HashMap的容量永远都是2的n次幂
因为在使用2的幂的数字的时候,length-1的值是所有二进制位全为1,这种情况下,index的结果等同于hashCode后几位的值
只要输入的hashCode本身分布均匀,hash算法的结果就是均匀的,这样设计的目的为了实现均匀分布
5)、线程不安全的原因
1)JDK1.7中扩容造成死循环分析过程
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
在对table进行扩容到newTable后,需要将原来数据转移到newTable中,JDK1.7在转移元素的过程中,使用的是头插法,也就是链表的顺序会翻转
假设:
- hash算法为简单的用key mod链表的大小
- 最开始hash表size=2,key=3、7、5,则都在table[1]中
- 然后进行resize,使size变成4
resize前状态如下:
如果在单线程环境下,最后的结果如下:
在多线程环境下,假设有两个线程A和B都在进行put操作。线程A在执行到transfer函数中代码(1)处挂起
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;//(1)
e = next;
}
}
}
此时线程A中运行结果如下:
线程A挂起后,此时线程B正常执行,并完成resize操作,结果如下:
由于线程B已经执行完毕,根据Java内存模型,现在newTable和table中的Entry都是主存中最新值:7.next=3,3.next=null
此时切换到线程A上,在线程A挂起时内存中值如下:e=3,next=7,newTable[3]=null,代码执行过程如下:
newTable[i] = e;//newTable[3] = 3
e = next;//e = 7
继续循环:
e = 7;
Entry<K,V> next = e.next;//next = 3
e.next = newTable[i];//e.next = 3
newTable[i] = e;//newTable[3] = 7
e = next;//e = 3
再次循环:
e = 3;
Entry<K,V> next = e.next;//next = null
e.next = newTable[i];//3.next = 7
newTable[i] = e;//newTable[3] = 3
e = next;//e = null
e.next=7,而在上次循环中7.next=3,出现环形链表,并且此时e=null循环结束
2)JDK1.7中扩容造成数据丢失分析过程
线程A和线程B进行put操作,同样线程A挂起:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;//(1)
e = next;
}
}
}
此时线程A的运行结果如下:
此时线程B已获得CPU时间片,并完成resize操作:
此时切换到线程A,在线程A挂起时:e=7,next=5,newTable[3]=null
执行newtable[i]=e,就将7放在了table[3]的位置,此时next=5。接着进行下一次循环:
e = 5;
Entry<K,V> next = e.next;//next = null
e.next = newTable[i];//e.next = 5
newTable[i] = e;//newTable[1] = 5
e = next;//e = null
将5放置在table[1]位置,此时e=null循环结束,3元素丢失,并形成环形链表
3)JDK1.8中的HashMap
在JDK1.8中对HashMap进行了优化,在发生哈希碰撞,不再采用头插法方式,而是直接插入链表尾部,因此不会出现环形链表的情况
HashMap的put方法,如果没有哈希碰撞则会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,这样线程A会把线程B插入的数据给覆盖,发生线程不安全
6)、哈希冲突解决方法
1)开放寻址法
开放寻址法的核心思想:如果出现了哈希冲突,就重新探测一个空闲位置,将其插入
线性探测插入操作:当往哈希表中插入数据时,如果某个数据经过哈希函数计算之后,存储位置已经被占用了,就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止
下图中黄色的色块表示空闲位置,橙色的色块表示已经存储了数据
当哈希表中插入的数据越来越多时,哈希冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久
2)链表法
在哈希表中,每个桶或者槽会对应一条链表,所有哈希值相同的元素都放到相同槽位对应的链表中
7)、为什么要重写hashcode和equals方法
class Key {
private Integer id;
public Integer getId() {
return id;
}
public Key(Integer id) {
this.id = id;
}
}
public class WithoutHashCode {
public static void main(String[] args) {
Key k1 = new Key(1);
Key k2 = new Key(1);
HashMap<Key, String> hashMap = new HashMap<>();
hashMap.put(k1, "Key with id is 1");
System.out.println(hashMap.get(k2));//null
}
}
当向HashMap中存入k1的时候,首先会调用Key这个类的hashcode方法,计算它的hash值,随后把k1放入hash值所指引的内存位置,在Key这个类中没有定义hashcode方法,就会调用Object类的hashcode方法,而Object类的hashcode方法返回的hash值是对象的地址。这时用k2去拿也会计算k2的hash值到相应的位置去拿,由于k1和k2的内存地址是不一样的,所以用k2拿不到k1的值
重写hashcode方法仅仅能够k1和k2计算得到的hash值相同,调用get方法的时候会到正确的位置去找,但当出现哈希冲突时,在同一个位置有可能用链表的形式存放冲突元素,这时候就需要用到equals方法去对比了,由于没有重写equals方法,它会调用Object类的equals方法,Object的equals方法判断的是两个对象的内存地址是不是一样,由于k1和k2都是new出来的,k1和k2的内存地址不相同,所以这时候用k2还是达不到k1的值
什么时候需要重写hashcode和equals方法?
在HashMap中存放自定义的键时,就需要重写自定义对象的hashcode和equals方法
2、ConcurrentHashMap
1)、JDK1.7
JDK1.7中ConcurrentHashMap的锁分段技术可有效提高并发访问率:ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁,在ConcurrentHashMap里扮演着锁的角色;HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁
ConcurrentHashMap重要的成员变量:
//Segment数组,存放数据时首先需要定位到具体的Segment中
final Segment<K,V>[] segments;
transient Set<K> keySet;
transient Set<Map.Entry<K,V>> entrySet;
Segment是ConcurrentHashMap的一个内部类,主要的组成如下:
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
//和HashMap中的HashEntry作用一样,真正存放数据的桶
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int modCount;
transient int threshold;
final float loadFactor;
HashEntry的组成:
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
和HashMap非常类似,唯一的区别就是其中的核心数据如value,以及链表都是volatile修饰的,保证了获取时的可见性
ConcurrentHashMap采用了分段锁技术,其中Segment继承于ReentrantLock。不会像HashTable那样不管是put还是get操作都需要做同步处理,理论上ConcurrentHashMap支持CurrencyLevel(Segment数组数量)的线程并发。每当一个线程占用锁访问一个Segment时,不会影响到其他的Segment
1)put方法
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject
(segments, (j << SSHIFT) + SBASE)) == null)
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
首先通过key定位到Segment,之后在对应的Segment中进行具体的put
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
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 {
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
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;
}
虽然HashEntry中的value是用volatile关键词修饰的,但是并不能保证并发的原子性,所以put操作时仍然需要加锁处理
首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用scanAndLockForPut()自旋获取锁
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1;
//尝试自旋获取锁
while (!tryLock()) {
HashEntry<K,V> f;
if (retries < 0) {
if (e == null) {
if (node == null)
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
retries = 0;
else
e = e.next;
}
//如果重试的次数达到了MAX_SCAN_RETRIES则改为阻塞锁获取,保证能获取成功
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f;
retries = -1;
}
}
return node;
}
再结合起来看一下put的逻辑:
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//将当前Segment中的table通过key的hashcode定位到HashEntry
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
//遍历该HashEntry,如果不为空则判断传入的key和当前遍历的key是否相等,相等则覆盖旧的value
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 {
if (node != null)
node.setNext(first);
//为空则需要新建一个HashEntry并加入到Segment中,同时会先判断是否需要库容弄
else
node = new HashEntry<K,V>(hash, key, value, first);
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 {
//最后解除在scanAndLockForPut()方法中获取的锁
unlock();
}
return oldValue;
}
2)get方法
public V get(Object key) {
Segment<K,V> s;
HashEntry<K,V>[] tab;
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
只需要将key通过hash之后定位到具体的Segment,再通过一次hash定位到具体的元素上
由于HashEntry中的value属性是volatile关键词修饰的,保证了内存可见性,所以每次获取时都是最新值
ConcurrentHashMap的get方法是非常高效的,因为整个过程都不需要加锁
2)、JDK1.8
JDK1.8中抛弃了原有的Segment分段锁,而采用了volatile+CAS+synchronized来保证并发安全性
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
Node(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return val; }
public final int hashCode() { return key.hashCode() ^ val.hashCode(); }
public final String toString(){ return key + "=" + val; }
public final V setValue(V value) {
throw new UnsupportedOperationException();
}
也将1.7中存放数据的HashEntry改为了Node,但作用都是相同的
其中的val和next都用了volatile修饰,保证了可见性
1)put方法
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
//根据key计算出hashcode
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//判断是否需要进行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//f为当前key定位出的Node,如果为空表示当前位置可以写入数据,利用CAS尝试写入,失败则自旋保证成功
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break;
}
//如果当前位置的hashcode==MOVED==-1,则需要进行扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//如果都不满足,则利用synchronized锁写入数据
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> 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<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
//如果数量大于TREEIFY_THRESHOLD则要转换为红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
2)get方法
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
根据计算出来的hashcode寻址,如果就在桶上那么直接返回值。如果是红黑树那就按照树的方式获取值。都不满足那就按照链表的方式遍历获取值
JDK1.8在1.7的数据结构上做了大的改动,采用红黑树之后可以保证查询效率 ,甚至取消了ReentrantLock改为了synchronized,这样可以看出在新版的JDK中对synchronized优化是很到位的
3)、ConcurrentHashMap/Hashtable不允许空值的原因
主要是因为会产生歧义,如果支持空值,在使用map.get(key)
时,返回值为null,可能有两种情况:该key映射的值为null,或者该key未映射到。如果是非并发映射中,可以使用map.contains(key)
进行检查,但是在并发的情况下,两次调用之间的映射可能已经更改了
3、HashMap和Hashtable的区别
1)线程安全
Hashtable是线程安全的,HashMap不是线程安全的
Hashtable所有的元素操作都是synchronized修饰的,而HashMap并没有
2)性能优劣
Hashtable是线程安全的,每个方法都要阻塞其他线程,所以Hashtable性能较差,HashMap性能较好,使用更广
如果要线程安全又要保证性能,建议使用JUC包下的ConcurrentHashMap
3)NULL
Hashtable是不允许键或值为null的,HashMap的键值则都可以为null
Hashtable代码片段
public synchronized V put(K key, V value) {
//如果value为null,直接抛出空指针异常
if (value == null) {
throw new NullPointerException();
}
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)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;
}
HashMap代码片段
static final int hash(Object key) {
int h;
//key为null做了特殊处理
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
4)实现方式
Hashtable继承源码
public class Hashtable<K,V>
extends Dictionary<K,V>
implements Map<K,V>, Cloneable, java.io.Serializable {
HashMap继承源码
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
Hashtable继承了Dictionary,而HashMap继承了HashMap
5)容量扩容
HashMap的初始容量为16,Hashtable初始容量为11,两者的负载因子默认都是0.75
Hashtable代码片段
public Hashtable() {
this(11, 0.75f);
}
HashMap代码片段
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final float DEFAULT_LOAD_FACTOR = 0.75f;
当现有容量大于总容量*负载因子时,HashMap扩容规则为当前容量翻倍,Hashtable扩容规则为当前容量翻倍+1
4、TreeMap
TreeMap基于红黑树实现。映射根据其键的自然顺序进行排序,或者根据创建映射时提供的Comparator进行排序,具体取决于使用的构造方法
TreeMap因为需要排序,进行key的compareTo()
方法,所以key是不能null值,value是可以的
5、LinkedHashMap
LinkedHashMap直接继承自HashMap,这也就说明了HashMap一切重要的概念LinkedHashMap都是拥有的,这就包括了,hash算法定位hash桶位置,哈希表由数组和单链表构成,并且当单链表长度超过8的时候转化为红黑树,扩容体系,这一切都跟HashMap一样。除此之外,LinkedHashMap比HashMap更加强大,这体现在:
- LinkedHashMap内部维护了一个双向链表,解决了HashMap不能随时保持遍历顺序和插入顺序一致的问题
- LinkedHashMap元素的访问顺序也提供了相关支持,也就是常说的LRU(最近最少使用)原则
图片中红黄箭头代表元素添加顺序,蓝箭头代表单链表各个元素的存储顺序。head表示双向链表头部,tail代表双向链表尾部
LinkedHashMap和HashMap相比,唯一的变化是使用双向链表(图中红黄箭头部分)记录了元素的添加顺序,HashMap的Node节点只有next指针,LinkedHashMap对于Node节点进行了扩展:
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
LinkedHashMap基本存储单元Entry继承自HashMap.Node
,并在此基础上添加了before和after这两个指针变量。before变量在每次添加元素的时候将会指向上一次添加的元素,而上一次添加元素的after变量将指向该次添加的元素,来形成双向链接
LinkedHashMap支持两种访问访问顺序,这主要取决于accessOrder这个参数的值,当accessOrder为false时按照插入顺序访问(默认),当accessOrder为true时按照LRU Cache的机制进行访问
//initialCapacity:初始化容量 loadFactor:负载因子 accessOrder:访问顺序(true代表使用LRU/false代表使用插入的顺序)
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
当某个位置的数据被命中,通过调整该数据的位置,将其移动至尾部。新插入的元素也是直接放入尾部(尾插法)。这样一来,最近被命中的元素就向尾部移动,那么链表的头部就是最近最少使用的元素所在的位置
LinkedHashMap中并没有覆写任何关于HashMap的put方法,所以调用LinkedHashMap的put方法实际上调用了父类HashMap的方法
HashMap中put方法源码如下:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断当前桶是否为空,空的就需要初始化(resize中会判断是否需要初始化)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//根据当前key的hashcode定位到具体的桶中并判断是否为空,为空表明没有Hash冲突就直接在当前位置创建一个新桶即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//如果当前桶有值(Hash冲突),那么就要比较当前桶中的key、key的hashcode与写入的key是否相等,相等就赋值给e,后面统一进行赋值及返回
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 {
//如果是个链表,就需要将当前的key、value封装成一个新节点写入当前桶的后面(采用尾插法)
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;
}
//如果在遍历链表的过程中,找到key相同时直接退出遍历
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果e!=null就相当于存在相同的key,那就需要将值覆盖
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;
}
在putVal方法中如果map中存在相同的key时,会调用void afterNodeAccess(Node<K,V> p)
方法,该方法在HashMap中是空实现,但是在LinkedHasMap中重写了该方法实现了将被访问节点移动到链表最后
//将被访问节点移动到链表最后
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
//accessOrder为true时才支持LRU Cache
if (accessOrder && (last = tail) != e) {
//三个临时变量:p为当前被访问节点,b为其前驱结点,a为其后继节点
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
//访问节点的后驱节点置为null
p.after = null;
//如果访问节点的前驱为null,则说明p=head,由于这时p要移动到链表最后,所以a设置为head
if (b == null)
head = a;
//否则b的后继设置为a
else
b.after = a;
//如果p不为尾节点,那么将a的前驱设置为b
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
//将p接在双向链表的最后
tail = p;
++modCount;
}
}
举个例子,比如该次操作访问的是13这个节点,而14是其后驱,11是其前驱,且tail=14。在通过get访问13节点后,13变成了tail节点,而14变成了其前驱节点,相应的14的前驱变成11,11的后驱变成了14,14的后驱变成了13
而在putVal方法的最后会调用一个void afterNodeInsertion(boolean evict)
方法,,该方法在HashMap中是空实现,但是在LinkedHasMap中重写了该方法实现了删除头节点(最近最少使用的元素)
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {//(1)
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
代码(1)处:evict在put方法调用putVal时传参即为true,所以当map不为空且removeEldestEntry返回true时就会删除头节点,但是在LinkedHasMap中removeEldestEntry方法始终返回true,所以如果要基于LinkedHashMap实现LRU则需要重写removeEldestEntry方法,当map的size大于初始化容量时返回true