目录
HashMap、LinkedHashMap、TreeMap最大的区别是什么 ?
Java 集合, 也叫作容器,主要是由两大接口派生而来:一个是 Collection接口,主要用于存放单一元素;另一个是 Map 接口,主要用于存放键值对。
Collection集合体系
- 绿色代表接口
- 粉红色为抽象类
- 黄色是具体实现类
Collection接口是容器类接口的根类,提供基本的方法,比如添加、删除元素、获取集合数量等。它派生出了三个子接口:List、Set、Queue,实现不同的功能。
List集合
ArraryList
ArraryList特点
- 是有顺序的容器, 底层是数组,会进行自动扩容,动态增大数组的长度
- 允许放入null元素
- 线程不安全,并发修改的时候会抛出ConcurrentModificationException异常
- 构造一个空容器,底层的数组长度默认为10
- 插入时候,会先检查是否需要扩容,如果当前容量+1>数组长度,就会进行扩容。
- ArrayList的扩容是创建一个1.5倍的新数组,然后把原数组的值拷贝过去。
ArrayList源码
1.初始化
List<String> list = new ArrayList<String>(10);
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
/**
* Constructs an empty list with the specified initial capacity.
*
* @param initialCapacity the initial capacity of the list
* @throws IllegalArgumentException if the specified initial capacity
* is negative
*/
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中,比如500个、1000个,那么为了提供性能,减少ArrayList中的拷贝操作,这个时候会直接初始化一个预先设定好的长度。
- 另外,
EMPTY_ELEMENTDATA
是一个定义好的空对象;private static final Object[] EMPTY_ELEMENTDATA = {}
;
//普通方式
ArrayList<String> list = new ArrayList<String>();
list.add("aaa");
list.add("bbb");
list.add("ccc");
//内部类方式
ArrayList<String> list = new ArrayList<String>() \\{
add("aaa");
add("bbb");
add("ccc");
\\};
//Arrays.asList
ArrayList<String> list = new ArrayList<String>(Arrays.asList("aaa", "bbb", "ccc"));
//Collections.ncopies
ArrayList<Integer> list = new ArrayList<Integer>(Collections.nCopies(10, 0));
通过Arrays.asList
传递给ArrayList
构造函数的方式进行初始化,这里有几个知识点:
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// replace with empty array.
this.elementData = EMPTY_ELEMENTDATA;
}
}
通过构造函数可以看到,只要实现Collection
类的都可以作为入参。
在通过转为数组以及拷贝Arrays.copyOf
到Object[]
集合中在赋值给属性elementData
。
注意:c.toArray might (incorrectly) not return Object[] (see 6260652
)
@Test
public void t(){
List<Integer> list1 = Arrays.asList(1, 2, 3);
System.out.println("通过数组转换:" + (list1.toArray().getClass() == Object[].class));
ArrayList<Integer> list2 = new ArrayList<Integer>(Arrays.asList(1, 2, 3));
System.out.println("通过集合转换:" + (list2.toArray().getClass() == Object[].class));
}
//结果
通过数组转换:false
通过集合转换:true
Process finished with exit code 0
public Object[] toArray()
返回的类型不一定就是Object[]
,其类型取决于其返回的实际类型,毕竟 Object 是父类,它可以是其他任意类型。- 子类实现和父类同名的方法,仅仅返回值不一致时,默认调用的是子类的实现方法。
造成这个结果的原因,如下;
- Arrays.asList 使用的是:
Arrays.copyOf(this.a, size,(Class<? extends T[]>) a.getClass());
- ArrayList 构造函数使用的是:
Arrays.copyOf(elementData, size, Object[].class);
- Arrays.asList 构建的集合,不能赋值给 ArrayList
- Arrays.asList 构建的集合,不能再添加元素
- Arrays.asList 构建的集合,不能再删除元素
从以上的类图关系可以看到;
- 这两个List压根不同一个东西,而且Arrasys下的List是一个私有类,只能通过asList使用,不能单独创建。
- 另外还有这个ArrayList不能添加和删除,主要是因为它的实现方式,可以参考Arrays类中,这部分源码;
private static class ArrayList<E> extends AbstractList<E> implements RandomAccess, java.io.Serializable
- 此外,Arrays是一个工具包,里面还有一些非常好用的方法,例如;二分查找
Arrays.binarySearch
、排序Arrays.sort
等
2.插入
List<String> list = new ArrayList<String>();
list.add("aaa");
list.add("bbb");
list.add("ccc");
//源码
/**
* Appends the specified element to the end of this list.
*
* @param e element to be appended to this list
* @return <tt>true</tt> (as specified by {@link Collection#add})
*/
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
插入元素的流程:
- 判断长度充足;
ensureCapacityInternal(size + 1);
- 当判断长度不足时,则通过扩大函数,进行扩容;
grow(int minCapacity)
- 扩容的长度计算;
int newCapacity = oldCapacity + (oldCapacity >> 1);
,旧容量 + 旧容量右移1位,这相当于扩容为原来容量的(int)3/2
。 4. 10,扩容时:1010 + 1010 >> 1 = 1010 + 0101 = 10 + 5 = 15 2. 7,扩容时:0111 + 0111 >> 1 = 0111 + 0011 = 7 + 3 = 10 - 当扩容完以后,就需要进行把数组中的数据拷贝到新数组中,这个过程会用到
Arrays.copyOf(elementData, newCapacity);
,但他的底层用到的是;System.arraycopy。
从根本上分析来说,数组是定长的,如果超过原来定长长度,扩容则需要申请新的数组长度,并把原数组元素拷贝到新数组中,如下图;
- 拷贝数组的过程并不复杂,主要是对
System.arraycopy
的操作。 - 上面就是把数组
oldArr
拷贝到newArr
,同时新数组的长度,采用和ArrayList一样的计算逻辑;oldArr.length + (oldArr.length >> 1)
3.修改
list.add(2, "1");
容量检查
public void add(int index, E element) {
rangeCheckForAdd(index);
...
}
private void rangeCheckForAdd(int index) {
if (index > size || index < 0)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
- 指定位置插入首先要判断
rangeCheckForAdd
,size的长度。 - 通过上面的元素插入我们知道,每插入一个元素,size自增一次
size++
。 - 所以即使我们申请了10个容量长度的ArrayList,但是指定位置插入会依赖于size进行判断,所以会抛出
IndexOutOfBoundsException
异常。
指定位置插入的核心步骤包括;
- 判断size,是否可以插入。
- 判断插入后是否需要扩容;
ensureCapacityInternal(size + 1);
。 - 数据元素迁移,把从待插入位置后的元素,顺序往后迁移。
- 给数组的指定位置赋值,也就是把待插入元素插入进来。
public void add(int index, E element) {
...
// 判断是否需要扩容以及扩容操作
ensureCapacityInternal(size + 1);
// 数据拷贝迁移,把待插入位置空出来
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
// 数据插入操作
elementData[index] = element;
size++;
}
4.删除
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;
}
删除的过程主要包括;
- 校验是否越界;
rangeCheck(index);
- 计算删除元素的移动长度
numMoved
,并通过System.arraycopy
自己把元素复制给自己。 - 把结尾元素清空,null。
在删除元素的时候,不要再for循环中或者foreach中删除元素,可能会出现异常。正确的做法,可以使用迭代器的方式删除或者使用jdk8新提供的removeIf这杨的api。
Iterator<Integer> it = list.iterator();
while(it.hasNext()){
// do something
it.remove();
}
由此可以看出ArraryList的特点:
初始化时是10,每次新增数据都要判断是否需要扩容,扩容是按原来的1.5倍扩容。
无论删除还是修改,插入都是要先判断索引是否存在,不存在就会报索引越界。
因为写入要判断是否需要扩容,删除数据时要删除的这个索引后面的数据都要前移。所以比较消耗时间。而查询时有索引存在所以比较快。(查询快,增删慢)。
不是线程安全的。
保证ArrayList的线程安全可以通过这些方案:
- 使用 Vector 代替 ArrayList。(不推荐,Vector是一个历史遗留类)
- 使用 Collections.synchronizedList 包装 ArrayList,然后操作包装后的 list。
- 使用 CopyOnWriteArrayList 代替 ArrayList。
- 在使用 ArrayList 时,应用程序通过同步机制去控制 ArrayList 的读写。
- CopyOnWriteArrayList采用了一种读写分离的并发策略。CopyOnWriteArrayList容器允许并发读,读操作是无锁的,性能较高。至于写操作,比如向容器中添加一个元素,则首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。
ArraryList使用
-
ArrayList的元素支持null, 所以有时候对数据的判空处理是必不可少的。
-
ArrayList 的 subList 结果不可强转成 ArrayList,否则会抛出 ClassCastException 异常,
java.util.RandomAccessSubList cannot be cast to java.util.ArrayList。
说明:subList() 返回的是 ArrayList 的内部类 SubList,并不是 ArrayList 本身,而是 ArrayList 的一个视图,对于SubList 的所有操作最终会反映到原列表上。
- 在 subList 场景中,高度注意对父集合元素的增加或删除,均会导致子列表的遍历、增加、删除产生 ConcurrentModificationException 异常。
说明:抽查表明,90% 的程序员对此知识点都有错误的认知。
-
使用集合转数组的方法,必须使用集合的 toArray(T[] array),传入的是类型完全一致、长度为0 的空数组。
-
使用 Collection 接口任何实现类的 addAll() 方法时,要对输入的集合参数进行 NPE 判断。 说明:在 ArrayList#addAll 方法的第一行代码即 Object[] a = c.toArray();其中 c 为输入集合参数,如果为 null,则直接抛出异常。
-
不要在 foreach 循环里进行元素的 remove / add 操作。remove 元素请使用 iterator 方式,如果并发操作,需要对 iterator 对象加锁。
-
使用工具类 Arrays.asList() 把数组转换成集合时,不能使用其修改集合相关的方法,它的 add/ remove / clear 方法会抛出 UnsupportedOperationException 异常。
-
Collections 类返回的对象,如:emptyList() / singletonList() 等都是 immutable list,不可对其进行添加或者删除元素的操作。
LinkedList
LinkedList特点
- LinkedList,是基于链表实现,由双向链条next、prev,把数据节点穿插起来。
- 不能说所有的插入都是高效,比如中间区域插入,他还需要遍历元素找到插入位置。
- 插入、删除快;随机访问查询慢;
- 线程不安全。
LinkedList源码
1.初始化
// 初始化方式;普通方式
LinkedList<String> list01 = new LinkedList<String>();
list01.add("a");
list01.add("b");
list01.add("c");
System.out.println(list01);
// 初始化方式;Arrays.asList
LinkedList<String> list02 = new LinkedList<String>(Arrays.asList("a", "b", "c"));
System.out.println(list02);
// 初始化方式;内部类
LinkedList<String> list03 = new LinkedList<String>()\\{
{add("a");add("b");add("c");}
\\};
System.out.println(list03);
// 初始化方式;Collections.nCopies
LinkedList<Integer> list04 = new LinkedList<Integer>(Collections.nCopies(10, 0));
System.out.println(list04);
它是链表不需要像ArraryList那样扩容,也不需要传入初始大小。
2.插入
LinkedList的插入方法比较多,List中接口中默认提供的是add,也可以指定位置插入。但在LinkedList中还提供了头插addFirst
和尾插addLast
。
有的时候LinkedList插入更耗时、有的时候ArrayList插入更好
2.1头插
- ArrayList 头插时,需要把数组元素通过
Arrays.copyOf
的方式把数组元素移位,如果容量不足还需要扩容。 - LinkedList 头插时,则不需要考虑扩容以及移位问题,直接把元素定位到首位,接点链条链接上即可。
LinkedList
头插的源码
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++;
}
- first,首节点会一直被记录,这样就非常方便头插。
- 插入时候会创建新的节点元素,
new Node<>(null, e, f)
,紧接着把新的头元素赋值给first。 - 之后判断f节点是否存在,不存在则把头插节点作为最后一个节点、存在则用f节点的上一个链条prev链接。
- 最后记录size大小、和元素数量modCount。modCount用在遍历时做校验,modCount != expectedModCount。
2.2尾插
- ArrayList 尾插时,是不需要数据位移的,比较耗时的是数据的扩容时,需要拷贝迁移。
- LinkedList 尾插时,与头插相比耗时点会在对象的实例化上。
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++;
}
- 与头插代码相比几乎没有什么区别,只是first换成last
- 耗时点只是在创建节点上,
Node<E>
2.3中间插入
- ArrayList 中间插入,首先我们知道他的定位时间复杂度是O(1),比较耗时的点在于数据迁移和容量不足的时候扩容。
- LinkedList 中间插入,链表的数据实际插入时候并不会怎么耗时,但是它定位的元素的时间复杂度是O(n),所以这部分以及元素的实例化比较耗时。
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
//定位node
Node<E> node(int index) {
// assert isElementIndex(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;
}
}
size >> 1
,这部分的代码判断元素位置在左半区间,还是右半区间,在进行循环查找.
执行插入:
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++;
}
找到指定位置插入的过程就比较简单了,与头插、尾插,相差不大。
整个过程可以看到,插入中比较耗时的点会在遍历寻找插入位置上。
3.删除
- 确定出要删除的元素x,把前后的链接进行替换。
- 如果是删除首尾元素,操作起来会更加容易,这也就是为什么说插入和删除快。但中间位置删除,需要遍历找到对应位置。
序号 | 方法 | 描述 |
---|---|---|
1 | list.remove(); | 与removeFirst()一致 |
2 | list.remove(1); | 删除Idx=1的位置元素节点,需要遍历定位 |
3 | list.remove("a"); | 删除元素="a"的节点,需要遍历定位 |
4 | list.removeFirst(); | 删除首位节点 |
5 | list.removeLast(); | 删除结尾节点 |
6 | list.removeAll(Arrays.asList("a", "b")); | 按照集合批量删除,底层是Iterator删除 |
public boolean remove(Object o) {
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null) {
unlink(x);
return true;
}
}
} else {
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}
元素定位,和unlink(x)
解链。循环查找对应的元素 。
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;
}
- 获取待删除节点的信息;元素item、元素下一个节点next、元素上一个节点prev。
- 如果上个节点为空则把待删除元素的下一个节点赋值给首节点,否则把待删除节点的下一个节点,赋值给待删除节点的上一个节点的子节点。
- 同样待删除节点的下一个节点next,也执行2步骤同样操作。
- 最后是把删除节点设置为null,并扣减size和modeCount数量。
4.遍历
ArrayList与LinkedList的遍历都是通用的。
//普通for循环
@Test
public void test_LinkedList_for0() {
long startTime = System.currentTimeMillis();
for (int i = 0; i < list.size(); i++) {
xx += list.get(i);
}
System.out.println("耗时:" + (System.currentTimeMillis() - startTime));
}
//增强for
@Test
public void test_LinkedList_for1() {
long startTime = System.currentTimeMillis();
for (Integer itr : list) {
xx += itr;
}
System.out.println("耗时:" + (System.currentTimeMillis() - startTime));
}
//迭代器
@Test
public void test_LinkedList_Iterator() {
long startTime = System.currentTimeMillis();
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
Integer next = iterator.next();
xx += next;
}
System.out.println("耗时:" + (System.currentTimeMillis() - startTime))
}
//forEach
@Test
public void test_LinkedList_forEach() {
long startTime = System.currentTimeMillis();
list.forEach(integer -> {
xx += integer;
});
System.out.println("耗时:" + (System.currentTimeMillis() - startTime));
}
//流
@Test
public void test_LinkedList_stream() {
long startTime = System.currentTimeMillis();
list.stream().forEach(integer -> {
xx += integer;
});
System.out.println("耗时:" + (System.currentTimeMillis() - startTime));
}
LinkedList使用
ArrayList与LinkedList对比区别
1.ArraryList是数组结构,LinkedList是链表。
2.数据结构决定了ArraryList查询快,增删慢,LinkedList增删快,查询慢。
3.都是线程不安全的。
4.ArrayList与LinkedList插入不一定LinkedList快。如果能确定你会在集合的首位有大量的插入、删除以及获取操作,那么可以使用LinkedList,因为它都有相应的方法;addFirst
、addLast
、removeFirst
、removeLast
、getFirst
、getLast
,这些操作的时间复杂度都是O(1),非常高效。
5.LinkedList的链表结构不一定会比ArrayList节省空间,首先它所占用的内存不是连续的,其次他还需要大量的实例化对象创造节点。虽然不一定节省空间,但链表结构也是非常优秀的数据结构,它能在你的程序设计中起着非常优秀的作用,例如可视化的链路追踪图,就是需要链表结构,并需要每个节点自旋一次,用于串联业务。
程序的精髓往往就是数据结构的设计,这能为你的程序开发提供出非常高的效率改变。
Vector
Vector原理(数组实现、线程同步)
Vector与ArrayList一样,也是通过数组实现的,不同的是它支持线程的同步,即某一时刻只有一
个线程能够写Vector,,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,
访问它比访问ArrayList慢。
Stack
Stack原理
todo...
Set集合
HashSet(无序,不重复)
HashSet使用
todo
HashSet底层源码
底层是哈希表。
HashSet最大的特点就是 不允许重复的值。
HashSet 是基于 HashMap 实现的,HashSet的值存放于HashMap的key上,
HashMap的value统一为PRESENT,因此 HashSet 的实现比较简单,相关 HashSet 的操作,基本上都是直接调用底层 HashMap 的相关方法来完成。
向HashSet 中add ()元素时,判断元素是否存在的依据,不仅要比较hash值,同时还要结合equles 方法比较。
HashSet 中的add ()方法会使用HashMap 的put()方法。
HashMap 的 key 是唯一的,由源码可以看出 HashSet 添加进去的值就是作为 HashMap 的key,并且在HashMap中如果K/V相同时,会用新的V覆盖掉旧的V,然后返回旧的V。所以不会重复( HashMap 比较key是否相等是先比较 hashcode 再比较equals )。
LinkedHashSet(继承与 HashSet)
LinkedHashSet原理
todo
LinkedHashSet使用
todo
TreeSet(有序,不重复)
TreeSet原理
底层是二叉树。
TreeSet使用
todo
Queue、Deque接口
Queue队列接口通常(但不一定)以FIFO(先进先出)方式对元素排序获取。
Deque双端队列接口继承了Queue接口,支持在两端插入和删除元素的线性集合。
ArraryQueue原理
todo...
Map集合体系
- 绿色代表接口
- 粉红色为抽象类
- 黄色是具体实现类
Map接口实现了键值对的映射,将键映射到值的对象。映射不能包含重复的键;每个键最多只能映射到一个值。它有很多的子类,比如HashMap, TreeMap, LinkedHashMap等。
HashMap: 无序的散列表
HashMap使用
todo
HashMap源码
jdk1.7时:数组+链表。
jdk1.8时:数组+链表+红黑树。数组是用来存储数据元素,链表是用来解决冲突(拉链法),红黑树是为了提高查询的效率。
线程不安全:
- Java 中有 HashTable、Collections.synchronizedMap、以及 ConcurrentHashMap 可以实现线程安全的 Map。
- HashTable 是直接在操作方法上加 synchronized 关键字,锁住整个table数组,粒度比较大,不推荐;
- Collections.synchronizedMap 是使用 Collections 集合工具的内部类,通过传入Map 封装出一个 SynchronizedMap 对象,内部定义了一个对象锁,方法内通过对象锁实现;
- ConcurrentHashMap 在jdk1.7中使用分段锁,在jdk1.8中使用CAS+synchronized。
1.hashMap核心
在HashMap存放元素时候有这样一段代码来处理哈希值,这是java 8
的散列值扰动函数,用于优化散列效果;
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
为什么使用扰动函数
理论上来说字符串的hashCode
是一个int类型值,那可以直接作为数组下标了,且不会出现碰撞。但是这个hashCode
的取值范围是[-2147483648, 2147483647],有将近40亿的长度,谁也不能把数组初始化的这么大,内存也是放不下的。
我们默认初始化的Map大小是16个长度 DEFAULT_INITIAL_CAPACITY = 1 << 4
,所以获取的Hash值并不能直接作为下标使用,需要与数组长度进行取模运算得到一个下标值,也就是我们上面做的散列列子。
那么,hashMap源码这里不只是直接获取哈希值,还进行了一次扰动计算,(h = key.hashCode()) ^ (h >>> 16)
。把哈希值右移16位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性。
扰动函数使用了哈希值的高半区和低半区做异或,混合原始哈希码的高位和低位,以此来加大低位区的随机性。
使用扰动函数就是为了增加随机性,让数据元素更加均衡的散列,减少碰撞。
散列数组需要一个2的幂次方的长度,因为只有2的幂次方在减1的时候,才会出现01111
这样的值。
寻找2的幂次方最小值
在HashMap的初始化中,有这样一段方法;
public HashMap(int initialCapacity, float loadFactor) {
...
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
阈值threshold
,通过方法tableSizeFor
进行计算,是根据初始化来计算的。这个方法也就是要寻找比初始值大的,最小的那个2进制数值。比如传了17,我应该找到的是32(2的4次幂是16<17,所以找到2的5次幂32)。
负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
那么在HashMap中,负载因子决定了数据量多少了以后进行扩容。这里要提到上面做的HashMap例子,我们准备了7个元素,但是最后还有3个位置空余,2个位置存放了2个元素。 所以可能即使你数据比数组容量大时也是不一定能正正好好的把数组占满的,而是在某些小标位置出现了大量的碰撞,只能在同一个位置用链表存放,那么这样就失去了Map数组的性能。
所以,要选择一个合理的大小下进行扩容,默认值0.75就是说当阈值容量占了3/4时赶紧扩容,减少Hash碰撞。
同时0.75是一个默认构造值,在创建HashMap也可以调整,比如你希望用更多的空间换取时间,可以把负载因子调的更小一些,减少碰撞。
扩容元素拆分(扩容就需要将原来的放到新的里面)
拆分元素的过程中,原jdk1.7中会需要重新计算哈希值,但是到jdk1.8中已经进行优化,不再需要重新计算,提升了拆分的性能,设计的还是非常巧妙的。
例如:
@Test
public void test_hashMap() {
List<String> list = new ArrayList<>();
list.add("jlkk");
list.add("lopi");
list.add("jmdw");
list.add("e4we");
list.add("io98");
list.add("nmhg");
list.add("vfg6");
list.add("gfrt");
list.add("alpo");
list.add("vfbh");
list.add("bnhj");
list.add("zuio");
list.add("iu8e");
list.add("yhjk");
list.add("plop");
list.add("dd0p");
for (String key : list) {
//16是因为扩容后新增出来的长度16
int hash = key.hashCode() ^ (key.hashCode() >>> 16);
System.out.println("字符串:" + key + " \tIdx(16):" + ((16 - 1) & hash) + " \tBit值:" + Integer.toBinaryString(hash) + " - " + Integer.toBinaryString(hash & 16) + " \t\tIdx(32):" + ((
System.out.println(Integer.toBinaryString(key.hashCode()) +" "+ Integer.toBinaryString(hash) + " " + Integer.toBinaryString((32 - 1) & hash));
}
}
原哈希值与扩容新增出来的长度16,进行&运算,如果值等于0,则下标位置不变。如果不为0,那么新的位置则是原来位置上加16。这样一来,就不需要在重新计算每一个数组中元素的哈希值了。
老数据迁移:
1. 这张图就是原16位长度数组元素,向32位扩容后数组转移的过程。
2.对31取模保留低5位,对15取模保留低4位,两者的差异就在于第5位是否为1,是的话则需要加上增量,为0的话则不需要改变
3.其中黄色区域元素zuio
因计算结果 hash & oldCap
低位第5位为1,则被迁移到下标位置24。
4.同时还是用重新计算哈希值的方式验证了,确实分配到24的位置,因为这是在二进制计算中补1的过程,所以可以通过上面简化的方式确定哈希值的位置
为什么 e.hash & oldCap == 0 为什么可以判断当前节点是否需要移位, 而不是再次计算hash
新旧indexFor()的差别只是第二次参与位于比第一次左边有一位从0变为1, 而这个变化的1刚好是oldCap, 那么只需要判断原key的hash这个位上是否为1: 若是1, 则需要移动至oldCap + i的槽位, 若为0, 则不需要移动;这也是HashMap的长度必须保证是2的幂次方的原因, 正因为这种环环相扣的设计,HashMap.loadFactor的选值是3/4就能理解了, table.length * 3/4可以被优化为((table.length >> 2) << 2) - (table.length >> 2) == table.length - (table.length >> 2), JAVA的位运算比乘除的效率更高, 所以取3/4在保证hash冲突小的情况下兼顾了效率
2.put插入
-
首先进行哈希值的扰动,获取一个新的哈希值。
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
-
判断tab是否为空或者长度为0,如果是则进行扩容操作。
-
根据哈希值计算下标,如果对应下标正好没有存放数据,则直接插入即可否则需要覆盖。
tab[i = (n - 1) & hash])
-
判断tab[i]是否为树节点,否则向链表中插入数据,是则向树中插入节点。
-
最后所有元素处理完成后,判断是否超过阈值;
threshold
,超过则扩容。 -
treeifyBin
,是一个链表转树的方法,但不是所有的链表长度为8后都会转成树,还需要判断存放key值的数组桶长度是否小于64MIN_TREEIFY_CAPACITY
。如果小于则需要扩容,扩容后链表上的数据会被拆分散列的相应的桶节点上,也就把链表长度缩短了。
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;
// 初始化桶数组 table,table 被延迟到插入新数据时再进行初始化
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;
// 如果键的值以及节点 hash 等于链表中的第一个键值对节点时,则将 e 指向该键值对
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果桶中的引用类型为 TreeNode,则调用红黑树的插入方法
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;
}
// 条件为 true,表示当前链表包含要插入的键值对,终止遍历
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 判断要插入的键值对是否存在 HashMap 中
if (e != null) { // existing mapping for key
V oldValue = e.value;
// onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 键值对数量超过阈值时,则进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
1.1扩容机制
HashMap是基于数组+链表和红黑树实现的,但用于存放key值得的数组桶的长度是固定的,由初始化决定。那么,随着数据的插入数量增加以及负载因子的作用下,就需要扩容来存放更多的数据。而扩容中有一个非常重要的点,就是jdk1.8中的优化操作,可以不需要再重新计算每一个元素的哈希值。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// Cap 是 capacity 的缩写,容量。如果容量不为空,则说明已经初始化。
if (oldCap > 0) {
// 如果容量达到最大1 << 30则不再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 按旧容量和阈值的2倍计算新容量和阈值
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
// initial capacity was placed in threshold 翻译过来的意思,如下;
// 初始化时,将 threshold 的值赋值给 newCap,
// HashMap 使用 threshold 变量暂时保存 initialCapacity 参数的值
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 这一部分也是,源代码中也有相应的英文注释
// 调用无参构造方法时,数组桶数组容量为默认容量 1 << 4; aka 16
// 阈值;是默认容量与负载因子的乘积,0.75
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// newThr为0,则使用阈值公式计算容量
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 初始化数组桶,用于存放key
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 如果旧数组桶,oldCap有值,则遍历将键值映射到新数组桶中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 这里split,是红黑树拆分操作。在重新映射时操作的。
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 这里是链表,如果当前是按照链表存放的,则将链表节点按原顺序进行分组{这里有专门的文章介绍,如何不需要重新计算哈希值进行拆分《HashMap核心知识,扰动函数、负载因子、扩容链表拆分,深度学习》}
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;
}
}
}
}
}
return newTab;
}
- 扩容时计算出新的newCap、newThr,这是两个单词的缩写,一个是Capacity ,另一个是阈Threshold
- newCap用于创建新的数组桶
new Node[newCap];
- 随着扩容后,原来那些因为哈希碰撞,存放成链表和红黑树的元素,都需要进行拆分存放到新的位置中。
1.2链表树化
HashMap这种散列表的数据结构,最大的性能在于可以O(1)时间复杂度定位到元素,但因为哈希碰撞不得已在一个下标里存放多组数据,那么jdk1.8之前的设计只是采用链表的方式进行存放,如果需要从链表中定位到数据时间复杂度就是O(n),链表越长性能越差。因为在jdk1.8中把过长的链表也就是8个,优化为自平衡的红黑树结构,以此让定位元素的时间复杂度优化近似于O(logn),这样来提升元素查找的效率。但也不是完全抛弃链表,因为在元素相对不多的情况下,链表的插入速度更快,所以综合考虑下设定阈值为8才进行红黑树转换操作。
为什么是8?
//链表树化
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 这块就是我们上面提到的,不一定树化还可能只是扩容。主要桶数组容量是否小于64 MIN_TREEIFY_CAPACITY
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 又是单词缩写;hd = head (头部),tl = tile (结尾)
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);
}
}
- 链表树化的条件有两点;链表长度大于等于8、桶容量大于64,否则只是扩容,不会树化。
- 链表树化的过程中是先由链表转换为树节点,此时的树可能不是一颗平衡树。同时在树转换过程中会记录链表的顺序,
tl.next = p
,这主要方便后续树转链表和拆分更方便。 - 链表转换成树完成后,在进行红黑树的转换。先简单介绍下,红黑树的转换需要染色和旋转,以及比对大小。在比较元素的大小中,有一个比较有意思的方法,
tieBreakOrder
加时赛,这主要是因为HashMap没有像TreeMap那样本身就有Comparator的实现。
1.4树转链表
红黑树转链表时候,直接把TreeNode转换为Node即可。
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
// 遍历TreeNode
for (Node<K,V> q = this; q != null; q = q.next) {
// TreeNode替换Node
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
// 替换方法
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
return new Node<>(p.hash, p.key, p.value, next);
}
因为记录了链表关系,所以替换过程很容易。所以好的数据结构可以让操作变得更加容易。
2.查找
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 &&
// 计算下标,哈希值与数组长度-1
(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) {
// TreeNode 节点直接调用红黑树的查找方法,时间复杂度O(logn)
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;
}
- 扰动函数的使用,获取新的哈希值。
- 下标的计算,同样也介绍过
tab[(n - 1) & hash])
- 确定了桶数组下标位置,接下来就是对红黑树和链表进行查找和遍历操作了
3.删除
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 定位桶数组中的下标位置,index = (n - 1) & hash
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 如果键的值与链表第一个节点相等,则将 node 指向该节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
// 树节点,调用红黑树的查找方法,定位节点。
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
// 遍历链表,找到待删除节点
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 删除节点,以及红黑树需要修复,因为删除后会破坏平衡性。链表的删除更加简单。
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
4.遍历
//KeySet
for (String key : map.keySet()) {
System.out.print(key + " ");
}
//EntrySet
for (HashMap.Entry entry : map.entrySet()) {
System.out.print(entry + " ");
}
KeySet是遍历是无序的,但每次使用不同方式遍历包括keys.iterator()
,它们遍历的结果是固定的.
map还只是数组+链表结构时
链表转换为红黑树,树根会移动到数组头部。
LinkedHashMap: 基于插入或者访问顺序的散列表
LinkedHashMap使用
123
LinkedHashMap源码
LinkedHashMap维护了一个双向链表,有头尾节点,同时 LinkedHashMap 节点 Entry内部除了继承 HashMap 的 Node 属性,还有 before 和 after 用于标识前置节点和后置节点。
TreeMap:基于key的排序器排序的散列表
TreeMap使用
123
TreeMap源码
TreeMap 是按照 Key 的自然顺序或者 Comprator 的顺序进行排序,内部是通过红黑树来实现。所以要么 key 所属的类实现 Comparable 接口,或者自定义一个实现了Comparator 接口的比较器,传给 TreeMap 用于 key 的比较。
HashTable
HashTable解析
todo
HashMap、LinkedHashMap、TreeMap最大的区别是什么 ?
- HashMap是无序的,根据 hash 值随机插入
- LinkedHashMap可以实现按插入的顺序或访问顺序排序
- TreeMap 是按照 Key 的自然顺序或者 Comprator 的顺序进行排序。