1. ArrayList
1.0 ArrayList的优缺点场景
ArrayList底层是由数组实现的,数组的长度是固定的,java里面的数组都是定长数组,
Transient 关键字,被这个关键字修饰的成员变量是不可以被序列化的
1.0.1 缺点
缺点1:
比如说数组的大小设置为100,此时你不停的往ArrayList里面塞入这个数据,此时元素数量超过了100以后,此时就会发生一个数组的扩容,就会搞一个更大的数组,把以前的数组拷贝到新的数组中去,这个数组扩容和元素拷贝的过程,相对来说会慢一些,所以说,我们使用ArrayList时候,不要频繁的往arrayList里面去塞数组,导致它频繁的数组扩容,避免扩容的时候较差的性能影响了系统的运行
缺点2:
数组来实现,要往里面去塞一个数据,要把数组中的那些新增的元素后面的元素全部往后面挪动一位,所以说,如果向arrayList中间插入一个元素也是非常耗费性能的。
1.0.2 优点
基于数组来实现,非常适合随机读,你可以随机的去读数组中的某个元素,
1.0.3 使用场景
1.不会频繁的插入数据,不会导致数组扩容,元素移动,就是有一些数据,查询出来,写入到arrayList中去,后面就不会频繁的写入数据了,主要就是遍历集合,或者是随机读取某个元素,那么使用arrayList是比较合适的,如果你要频繁插入元素就不合适了。
2.开发系统的时候,大量的场景,需要一个集合,里面可以按照顺序写入一些数据,ArrayList的话,他的最最主要的功能作用,就是说他里面的元素是有顺序的,我们在系统里的一些数据,都是需要按照我插入的顺序来排序的
1.1 ArrayList核心方法原理
1.1.1 默认构造函数
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
ArrayList的构造函数,首先实例化了一个数组,有一个默认的初始化的数值的大小,是10,也就是我们初始化了一个数组长度为10的一个数组
1.1.2 带参构造函数
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
所以说我们在使用ArrayList的时候,不应该用我们这个默认的构造函数,我们应该是给定一个默认的数组长度的初始值,避免数组的长度太小,我们频繁的向数组中插入数据的时候,导致数组频繁的扩容,数组的拷贝
ArrayList list = new ArrayList(100);
1.1.3 Add()方法
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
ensureCapacityInternal(size + 1);
这个方法,你每次往ArrayList中插入数据的时候,人家都会判断一下,当前数组的元素是否满了,如果满了,那么就会将数组扩容,然后将老数组中的元素拷贝的新数组中去,确保是可以放下所有的数据的
ElementData开始是一个空数组,然后是将elementData[0] = e; 然后就将size++,进行完这个操作以后,那么此时数组就变成了
elementData[e], size = 1;
1.1.4 Set()方法
public E set(int index, E element) {
rangeCheck(index);
E oldValue = elementData(index);
elementData[index] = element;
return oldValue;
}
首先先检查一下数组是否越界,然后将我们的新的值替换到index下的老的数值,然后将老的数值返回。
1.1.5 Add(index,element(元素))方法
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
首先判断数组是否越界,然后人家都会判断一下,当前数组的元素是否满了,如果满了,那么就会将数组扩容,然后将老数组中的元素拷贝的新数组中去,确保是可以放下所有的数据的,然后进行数组拷贝,然后将index下的数值变成我们的新的数值,给size++
如果开始element[1,2,3,4,5] ,我们的方法是add(1,1),此时我们首先进行的数组拷贝是
Element[1,2,2,3,4,5],然后再对index = 1进行数值1的替换替换完毕是
Element[1,1,2,3,4,5]
1.1.6 Get()方法
public E get(int index) {
rangeCheck(index);
return elementData(index);
}
这个方法最简单了,直接elementData[index],基于数组直接定位到这个元素,获取到这个元素,这个ArrayList性能最好的一个
1.1.7 Remove(删除)()方法
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
首先检查数组是否越界,首先先拿到index下的一个旧的值,然后定位到index+1的一个位置上面去,就是王五的这个位置,然后进行数组拷贝,那么就是elementData[“张三”,”王五”,”麻子”],最后将elementData最后一个元素设置为null
比如elementData[“张三”,”李四”,”王五”,”麻子”] size = 4; index = 1;
进行完数组拷贝后
elementData[“张三”,”王五”,”麻子”,”麻子”]
然后将最后一个元素设置为 null
elementData[“张三”,”王五”,”麻子”,null]
那么就是
elementData[“张三”,”王五”,”麻子”] size = 3
这俩个方法,都会导致数组的拷贝,大量数据的拷贝,其实性能都不是很高
1.1.8 扩容和数据拷贝
ensureCapacityInternal(size + 1);
假设我们现在用的是一个默认的数组大小,也就是10,现在已经往这个数组中添加了10个元素了,此时的数组的size = 10; capacity = 10;
此时,调用add方法,那么就是调用第11个元素了,这时肯定是放不进去的,
ensureCapacityInternal(11);
calculateCapacity(elementData, minCapacity);
此时elementData已经填充了10个元素了,此时minCapacity就是11了
最底层的方法是
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
int newCapacity = oldCapacity + (oldCapacity >> 1);
这里相当于是 oldCapacity + (oldCapacity/2);
此时新数组大小是10 + 5 = 15了,然后将elementData中的数据拷贝到新的数组中,size是15
1.1.9 方法总结
Add(),add(index,element),这俩个方法,都会导致数组需要扩容,数组长度是固定的,默认初始大小是10个元素,如果不停的往数组中塞入数组,可能导致数组不停的扩容,数据的拷贝,导致系统性能下降
Set(),get(),这俩个方法,都会定位到我们随机的位置,替换那个元素,或者是获取到那个元素,这个其实还是比较靠谱的,基于数组来实现随机位置的定位,这个性能还是很高的。
2.LinkedList
2.0 LinkedList基本原理及优缺点
2.0.1 基本原理
底层是基双向链表实现的,由基本的Node节点组合而成
2.0.2 优点
往中间插入一些元素,或者往中间不停的插入元素,都没关系,因为人家是链表,中间插入元素不需要跟ArrayList数组那样子,挪动大量的元素的,不需要,人家直接在链表里加一个节点就可以了
所以LinkedList非常适合各种元素频繁的插入到链表中去
2.0.3 缺点
不太适合在随机的位置,获取某个随机的元素,比如说LinkedList.get(10),这种操作,性能就非常的低,因为他需要遍历这个链表,从头开始遍历这个链表,直到找到那个值 index = 10的这个元素为止,
2.0.4 适合场景
适合,频繁的在list中插入和删除某个元素,然后尤其是LinkedList,它其实是可以当作队列来用的,这个东西的话,先进先出,在list尾部插入一个元素,然后从头部拿出来一个元素。如果要在内存里实现一个基本的队列的话,可以用LinkedList
2.1 LinkedList双向链表数据结构
2.1.1 双向链表数据Node节点
transient Node<E> first;
transient Node<E> last;
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;
}
}
2.2 LinkedList插入元素的原理
2.2.1 在尾部插入元素
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
首先,把last节点拿出来,node拿出来,此时再封装一个新的node,新node的prev指针是指向我们的last的节点的,然后element就是我们的node中的e,我们的next节点是指向null的
Offer()==add(),就是在队列尾部入队,将一个元素插入队列的尾部,
Poll() 从队列头部出队
Peek() 获取队列头部的元素,但是头部的元素不出队
2.2.2 在头部插入元素
public void addFirst(E e) {
linkFirst(e);
}
private void linkFirst(E e) {
final Node<E> f = first;
final Node<E> newNode = new Node<>(null, e, f);
first = newNode;
if (f == null)
last = newNode;
else
f.prev = newNode;
size++;
modCount++;
}
2.2.3 在中间插入元素
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
void linkBefore(E e, Node<E> succ) {
// assert succ != null;
final Node<E> pred = succ.prev;
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}
在拿出来的index的那个node前边插入一个node,首先是拿到index前面的那个node,然后又封装了一个新的Node,这个newNode其实就是prev指针指向了index前面的那个node,next指针指向的是我们index的,这个时候相当于是将这个元素插入到里面去了
Node<E> node(int index) {
// assert isElementIndex(index);
// index < size / 2
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
获取index那个位置的node,如果说index是在队列的前半部分,那么就从头部开始遍历整个链表,找到index那个位置的node
如果说index是在队列的后半部分,那么就从尾部开始遍历整个链表,找到index那个位置的node
2.3 LinkedList获取元素的原理
2.3.1 获取头部的元素
public E getFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return f.item;
}
直接返回first指针的item元素
2.3.2 获取尾部的元素
public E getLast() {
final Node<E> l = last;
if (l == null)
throw new NoSuchElementException();
return l.item;
}
返回last指针的item元素
2.3.3 获取中间的元素
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
Node<E> node(int index) {
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
在方法add的时候,插入一个元素其实就用到了这个node的方法,获取到某个随机位的元素,需要进行链表的一个遍历
2.4 LinkedList删除元素的原理
2.4.1 删除头部元素
public E removeFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return unlinkFirst(f);
}
private E unlinkFirst(Node<E> f) {
// assert f == first && f != null;
final E element = f.item;
final Node<E> next = f.next;
f.item = null;
f.next = null; // help GC
first = next;
if (next == null)
last = null;
else
next.prev = null;
size--;
modCount++;
return element;
}
2.4.2 删除尾部元素
public E removeLast() {
final Node<E> l = last;
if (l == null)
throw new NoSuchElementException();
return unlinkLast(l);
}
private E unlinkLast(Node<E> l) {
// assert l == last && l != null;
final E element = l.item;
final Node<E> prev = l.prev;
l.item = null;
l.prev = null; // help GC
last = prev;
if (prev == null)
first = null;
else
prev.next = null;
size--;
modCount++;
return element;
}
这里的以前的last指针指向的节点都为null,那么下次垃圾回收的时候,是会将这个节点给回收掉的
2.4.3 删除中间元素
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
E unlink(Node<E> x) {
// assert x != null;
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null;
size--;
modCount++;
return element;
}
首先先通过node(index)方法来找到那个节点,然后在调用unLink方法,
然后找到index对应节点的前后节点,然后将前后节点的prev和next指针相连,将index节点的prev和next指针还有item元素设置为null,等待垃圾回收
2.5 总结
双向链表来实现linkedList数据结构,应该看到他底层的一个双向队列的数据结构,插入,获取,删除,都可以从对头,队尾来实现,完全可以当做一个队列来用,offer()往队尾插入元素,poll()从对头删除一个元素
如果向链表中不断的疯狂的插入数据,哪怕是大量的数据,优点就是它是基于链表来实现的,不会出现数组扩容和大量数据的拷贝
在中间插入元素性能没有队头和队尾那么好,他要走一个遍历,遍历找到我们index的那个node,用node()方法
3 HashMap
3.0 HashMap数据结构
3.0.1 HashMap的数据结构是什么?
数组+链表+红黑树
初步的介绍一下JDK1.8开始的hashmap的基本的数据结构和原理
Map.put(1,“张三”)
Map.put(2,”李四”)
这里要对你的key进行一个hashCode()的一个运算,获取你的key的hash值,常规的一个做法就是用这个hash值对数组的长度进行取模(hash模算法,这个算法的意思是hash值对数组长度取模以后,就会保证每一个key,都可以分配到数组里面的一个元素中去),根据取模的结果,将key-value对放在数组的某一个元素上去
Map.get(1),这个东西和插入的时候是同理的,首先对key进行一个hash,然后hash值对数组的长度进行一个取模,找到index,然后定位到我们的key-value对的位置上
如果说,某俩个key的hash值是一样的怎么样呢(hash冲突,hash碰撞)?
Hash值一样会导致他们放到同一个数组的索引的位置上去,此时该如何处理
会在数组的同一个位置挂一个链表,放到这个链表上边去,如果同一个index相同的hash值超过8个,那么会自动将链表转换成红黑树
3.0.2 HashMap中hash冲突的时候怎么解决?
链表,用链表来处理
JDK1.8 开始优化了hashmap的数据结构,链表 --> 红黑树,来解决hash冲突的问题
3.0.3 说一说hashmap的原理?
对key进行hash,找到对应的位置,放在里面,查询的时候,也是对key进行hash,去找到对应的key-value对
3.1核心成员变量的作用分析
3.1.1 默认数组大小
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
这个是默认的数组的初始大小,应该是16,这个跟ArrayList是不一样的,ArrayList的默认的初始大小是10
数组的大小一般要自己指定一下,就跟你用ArrayList一样,初始的默认大小是10,你预估一下你要用到多大的数组长度,避免频繁的数组扩容
3.1.2 默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
这个时候默认的负载因子,0.75,如果你在数组中的元素的个数,达到了数组大小(16)* 负载因子(0.75),默认是达到12个元素就会进行数组的扩容
3.1.3 重要的节点Node
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
这是一个很关键的内部类,他其实是代表了一个key-value对,里面包含了key的hash值,key,value,还有就是可以有一个next的指针,指向下一个Node,也就是指向单向链表中的下一个节点,通过next的指针就可以形成一个链表
3.1.4 map的核心数组
transient Node<K,V>[] table;
这个就是所谓的map里的核心的数据结构的数组,数组的元素就是Node类型,天然就可以挂成一个链表
3.1.5 threshold(阈值)
这个值,其实就是说capacity(就是默认的数组的大小),就是说capacity * loadFactory,就是threshold,如果size达到了threshold那么就会进行数组的扩容
3.1.6 loadFactor
默认就是负载因子,默认的值是0.75f,你也可以自己制定,入彀你指定的越大,一般就越是拖慢扩容的速度,一般不要修改
3.3 优化降低冲突概率的hash算法
3.3.1 hash(key)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
首先是hash(key),对key进行一个hash,对key进行hash获取一个对应的hash值,然后将key-value传入到putVal()方法里面去,将key-value对根据其hash值找到对应的数组的位置
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里首先是对key进行hashCode 然后 将其hash值右位移16,然后做一个异或运算
假设hash值是以下的一串东西
1111 1111 1111 1111 1111 1010 0111 1100
h >>> 16,这个是一个位运算的东西,将32位二进制的数字,所有的bit往右移动了16位
0000 0000 0000 0000 1111 1111 1111 1111
h = key.hashCode()) ^ (h >>> 16)
计算出来以后就是
1111 1111 1111 1111 0000 0101 1000 0011
为什么要做这样的一个操作呢,为什么要右移然后再异或?
他这么做,其实是考虑到,将它的高16位和低16位进行了一个异或运算,这里是因为后面在用这个hash值定位到数组的index的时候,也有一个位运算,但是,后面的那个位运算是用低16位进行运算,提前将hash()函数中,就会将高16位和低16位进行一下异或运算,就可以保证,在hash值的低16位里面,同时可以保留他的高16位和低16位的特征,这个目的是通过这样的方式计算出来的hash值,可以降低hash冲突的概率
3.4 put操作原理以及hash寻址算法
3.4.1 putVal()
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
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 {
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;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
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;
}
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
刚开始数组是空的,那么这里将会给它分配一个默认大小的一个数组,数组的大小是16,负载因子是0.75f,threshold是12
3.4.2 hash寻址算法
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
(n-1) & hash操作,定位到数组的位置上,如果这个位置是null,那么就创建一个newNode,放到数组的那个位置上边去
(16-1)&hash
就是以下的表示
0000 0000 0000 0000 0000 0000 0000 1111
&(俩个都是1才是1,要不就是0)
1111 1111 1111 1111 0000 0101 1000 0011
=
0000 0000 0000 0000 0000 0000 0000 0011 就是3,所以它的index = 3
他的hash寻址的算法,并不是说用hash值对数组大小取模,取模就可以将任意一个hash值定位到数组的一个index那去,取模的操作性能不是很高,&操作取模的效果,它优化以后的一个小姑,就是说他的数组刚开始的初始值,以及未来的扩容的值,都是2的n次方,
也就是说他后面的每次扩容,数组的大小就是2的n次方,只要保证数组的大小是2的n次方,就可以保证说,(n-1)&hash,可以保证就是hash % 数组.length取模的一样的效果,也就是说通过(n-1)&hash,就可以将任意的一个hash值定位到数组的某个index里去
3.5 hash冲突时的链表处理
假设某俩个key的hash值是一样的,俩个key不同,hash值是一样的,这个概率很低,如果你重写了hashCode方法,有可能造成hash值一样,也有可能俩个key的hash值不一样,但是通过寻址算法,定位到了数组的同一个key上去,此时就造成了hash冲突,或者是hash碰撞,默认情况下会用单向链表来处理
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
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 {
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;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
3.5.1 相同keyhash定位冲突
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
如果满足上述条件,说面是相同的key,覆盖旧的value
e.value = value;
将数组那个位置的Node的value设置为了新的值
上边那些代码其实就是相同的key然后进行一个value的一个覆盖
3.5.2 不同keyhash寻址冲突
上边的if不成立,那么说明key不一样,但是定位到了同一个index上边去,进入以下的else
else {
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;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (binCount >= TREEIFY_THRESHOLD - 1)
如果当前的链表的长度(binCount),大于等于TREEIFY_THRESHOLD-1的话,如果说链表的长度大于等于8,那么此时就需要将这个链表转换为一个红黑树的数据结构
假设再有一个hash冲突的时候才会走到
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
首先判断是不是同一个key,如果不是的话,那么将p=e,指针转换,下次再次进入
if ((e = p.next) == null)
创建新的节点
3.6 JDK1.8引入红黑树解决hash冲突
3.6.1 JDK1.8前直接挂链表的问题
如果说出现大量的hash冲突以后,假设给某个位置挂的链表特别长,那就很恶心了,如果链表长度太长的话,会导致有一些get()操作的时间复杂度就是O(n)了,正常来说,你基于table[i]数组索引定位的方式,其实是O(1)
所以说JDK1.8以后优化了这块东西,会判断,如果链表的长度达到8的时候,那么就会将链表转换为红黑树,如果用红黑树的话,get()操作,即使对一个很大的红黑树进行二叉查找,那么时间的复杂度会变成O(lgn),性能会有大幅度的提升
3.6.2 JDK1.8引进链表转红黑树
链表转红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
当你遍历到第8个节点,此时binCount是7,同时你挂上了第9个节点,然后就会发现binCount>=7,达到了临界值,也就是说,当你的链表节点的数量超过8的时候,此时就会将链表转换成红黑树
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
这个do while方法主要是将先前的一个单向链表转换成了一个双向链表,都转换完成后跳出do while循环
hd.treeify(tab);
这个方法就是将链表转换成了红黑树
3.6.3总结
当链表的长度超过8的时候,链表就先是变成双向链表,然后是变成红黑树,
3.7 基于数组的扩容原理
hashMap底层是基于数组来实现的核心的数据结构,如果是用数组的话,就天然会有一个问题,和ArrayList一样,数组满了后,就会有扩容的问题,其实非常简单,首先是俩倍扩容,其次是rehash,扩容后,每个key-value对,都会基于key的hash值重新寻址到新的数组的新的位置
本来那个数组的长度是16,扩容后那个新的数组的长度变成了32,
本来所有的key的hash,对16取模的话是一个位置,比如说index = 5,但是如果对32取模的话,可能就是index=11,位置可能会变化
基于key的hash值重新在新的数组里找到新的位置,很多key在新数组的位置都不一样了,如果说是之前冲突的key,可能在新的数组中分配到不同的位置
这个原理是JDK1.7的原理
JDK1.8以后,都是数组的大小是2的n次方扩容,用的是&操作符来实现hash寻址的算法,来进行扩容以后,进行rehash的操作
3.8 JDK1.8高性能rehash算法
3.8.1 rehash算法的原理
JDK1.8以后,为了提升rehash的这个过程的性能,不是说简单的用key的hash值对新数组.length取模,取模性能较低,所以说以后对于hash寻址是用的& * length-1这种方式
假设数组长度开始默认是16那么就有以下的运算
n -1 0000 0000 0000 0000 0000 0000 0000 1111 = 15
hash1 1111 1111 1111 1111 0000 1111 0000 0101
&结果 0000 0000 0000 0000 0000 0000 0000 0101 = 5(index = 5)
n -1 0000 0000 0000 0000 0000 0000 0000 1111 = 15
hash2 1111 1111 1111 1111 0000 1111 0001 0101
&结果 0000 0000 0000 0000 0000 0000 0000 0101 = 5(index = 5)
此时上边的俩个hash值会出现hash碰撞的问题,此时就会使用链表或者红黑树来解决,如果扩容的话,那么会出现什么情况
n -1 0000 0000 0000 0000 0000 0000 0001 1111 = 31
hash1 1111 1111 1111 1111 0000 1111 0000 0101
&结果 0000 0000 0000 0000 0000 0000 0000 0101 = 5(index = 5)
n -1 0000 0000 0000 0000 0000 0000 0001 1111 = 31
hash2 1111 1111 1111 1111 0000 1111 0001 0101
&结果 0000 0000 0000 0000 0000 0000 0001 0101 = 5(index = 5 + 16)
也就是说,JDK1.8,扩容一定是2的倍数,从16到32到64到128,这样的话,就可以保证说,每次扩容后,你的每个hash值要么是停留在原来的那个index的地方,要么是变成了原来index(5)+ oldCap(16) = 21;
hashMap的底层原理
1.hash算法:为什么要高位和地位做^运算
2.hash寻址:为什么hash值和数组.length-1来运算
3.hash冲突的机制:链表 + 红黑树
4.扩容机制:数组俩倍扩容,重新寻址(rehash), hash & (n-1),判断二进制结果中是否多出来一个bit的1,如果没多,那么就是原来的index,如果多出来了,那么就是index+oldCap,通过这个方式就避免了rehash的时候,用每个hash对新数组的.length取模,取模性能不搞,位运算性能比较高
3.8.2 rehash代码实现
if (++size > threshold)
resize();
每次put新的key-value对后,都会++size,每次都会比较一下threshold(数组长度*负载因子),resize()方法就是在扩容
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
newThr = oldThr << 1; 就是乘以2,新数组的大小是老数组的2倍
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
如果e.next是null的话,这个位置的元素就不是链表,也不是红黑树,那么此时就是用e.hash&newCap(新数组的大小) - 1,进行&运算,直接定位大新数组的某个位置,放到了新的数组中
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
如果这个位置是一个红黑树的话,此时会调用split方法,肯定会去里面遍历这颗红黑树,然后将里面每个节点都进行重新hash寻址,找到新数组的某个位置
进入下一个分支,那么就是链表了
do {
next = e.next;
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;
}
大概就是说,判断一下,
(e.hash & oldCap) == 0)主要是这里的判断,不是和oldCap-1进行&运算,而是和 oldCap进行&运算,来判断是否&后最高位还有1,如果没有,那么就是放到数组原来的位置上, 如果有的话,那么就是放在index+ cap上
如果是链表里的元素的话,要不是放在新数组的原来的index,要不就是原来的index+oldCap
3.9 get与remove(删除)操作的原理分析
3.9.1 get()
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
首先判断,是否hash值一样,然后key是否一样,如果都一样的话,那么就直接将node返回了,
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
如果是一棵树,那么就从树上找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
如果是链表的话,那么就循环遍历这个链表来找
3.9.2 remove()
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
4 LinkedHashMap
4.1LinkedHashMap和HashMap的区别
HashMap
比如你放了一堆key-value对进去后,后面的话,如果你要遍历这个HashMap的话,遍历的顺序并不是按照你插入的key-value的顺序来的
LinkedHashMap
你放入的是什么顺序,然后你遍历的顺序是一样的
4.2LinkedHashMap和TreeMap的区别
他们都可以维持key的顺序,知识LinkedHashMap底层是基于链表来实现的,TreeMap是基于红黑树来实现的
4.3LinkedHashMap原理
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
在调用LinkedHashMap的put()方法的时候,一定会调用到HahsMap的put()方法里面去,插入一个key-value对后,其实会调用afterNodeInsertion();这个方法来回调LinkedHashMap里面的子类的实现
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
所以说这里就是,回调了这个方法,这个方法里面,它就是实现了LinkedHashMap的逻辑,来记录插入key-value对的顺序,用一个链表来记录
覆盖,如果我们是做key值的覆盖,可以看到,你多次覆盖一个值,不会改变他的顺序,
LinkedHashMap有一个参数的,你可以在构造的时候传进去,accessOrder,默认他是false,如果默认是false的话,那么你比如说get一个key,或者覆盖这个key的值,都不会改变他在链表里的顺序
但是如果accessOrder是true的话,那么如果你get一个key,或者是覆盖这个key的值,就会导致key-value的顺序会在链表里改变,导致挪动到尾部去
5 TreeMap
底层是基于红黑树的数据结构,不是传统意义上的那种HashMap,他天然就可以按照key的自然顺序来排序
public TreeMap(Comparator<? super K> comparator) {
this.comparator = comparator;
}
可以根据传入的comparator来进行自定义的排序
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;
}
基本的数据结构,entry
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);
}
插入前比较,然后看是左叶子节点还是右叶子节点
形成了一棵树
fixAfterInsertion(e);
然后走到这个方法,给它自平衡,变成一个红黑树
6 Set
Set其实底层就是基于Map来实现的
HashSet,他其实就是一个集合,里面的元素是无须的,然后里面的元素是没有重复的,HashMap的key是无顺序的,你插入进去的顺序,跟你迭代遍历的顺序是不一样的,而且HashMap的key是没有重复的,HashSet是直接基于HashMap来实现的
LinkedHashSet,它是有顺序的set,也就是维持了插入set的这个顺序,你迭代LinkedHashSet的顺序跟你插入的顺序是一样的,底层可以直接基于LinkedHashMap来实现
TreeSet,默认是根据你插入进去的元素来排序的,而且可以定制Comparator,自己决定排序的算法和逻辑,他底层就可以基于TreeMap来实现
7 Iterator迭代器应对多线程并发修改的fail_fast机制
Java集合迭代的fail_fast机制
ConcurrentModificationException 并发修改异常,这个机制就叫做fail fast
modCount就是用来实现fail fast机制的,各个集合都有modCount的概念,只要集合进行修改了,那么就对modCount++,这个是什么意思呢?就是modificationCount,修改次数,只要你修改一次,就会更新这个modCount,add(),remove,set...
比如说在迭代一个ArrayList之前,已经插入了4个元素,此时modCount = 4,在你获取和初始化一个迭代器的时候,里面的expectedModCount就会被初始化为modCount,当另外一个线程再修改这个集合,那么modCount++,此时expectedModCount != modCount 那么就会抛出并发修改异常
其实java集合包下的类,都是非线程安全的,所以说里面都设计了针对并发修改集合的问题,有fail-fast机制,modCount