数组
数组是相同类型数据的集合。
特点
- 有序、可重复。
- 长度是确定的。
数组一旦被创建,它的大小就是不可以改变的。 - 元素必须是相同类型,不允许出现混合类型。
相同类型指的是int,long,String这些,不是基本类型/引用类型 - 数组的元素可以是基本类型/引用类型。
- 数组变量属引用类型,数组也可以看成是对象,数组中的每个元素相当于该对象的成员变量。
数组本身就是对象,java中对象是在堆中的,因此数组无论保存基本类型还是引用类型,数组对象本身是在堆中的。
这个也是特点,但是在面试的时候感觉可说可不说吧,先放着
数组中的默认初始化值
数组类型 | 默认值 |
---|---|
整数数组 | 0 |
小数数组 | 0.0 |
字符数组 | \u0000 |
布尔数组 | false |
引用数组 | null |
public static void main(String[] args) {
// 初始化数组长度
int[] arrayA = new int[3];
// 初始化数组数据
int[] arrayB = new int[]{1,2,3};
// 简化 初始化数组数据
int[] arrayC = {1,2,3};
// 直接打印的话打印的是数组的地址值
for (int i : arrayC) {
System.out.println(i);
}
// 数组的长度
sout(arrayA.length)
}
Arrays
数组的工具类java.util.Arrays
// (数组)用来对指定数组中的元素进行排序(元素值从小到大进行排序)
static void sort(int[] a)
// (数组用来返回指定数组元素内容的字符串形式
static String toString(int[] a)
// 只能转换引用类型的数组,
// 基本类型数组会把数组当成引用类型,就不是把数组中的元素放进list
// 而是把整个数组,当做一个元素放进list
// 转换后的list,不能对元素增删,因为它是由数组转变过来的,数组的长度是固定的,不可更改的
// 上面两个问题解决方法类似
// 将数组转成list
static <T> List<T> asList(T... a)
// java.lang.UnsupportedOperationException
List<Integer> list5 = Arrays.asList(1, 2, 3);
list5.add(6);
System.out.println(list5.toString());
// 上面两个问题解决方法,都是利用Collections创建一个新的集合
Collections.addAll(new ArrayList<Integer>(5), int[]);
package com.example.javasestudy.arrayandlist;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@SpringBootTest
public class ArrayListTest {
@Test
void contextLoads() {
List<Integer> list = Arrays.asList(1, 2, 3);
System.out.println(list.toString());
int[] arr = {1,2,3};
List<int[]> list1 = Arrays.asList(arr);
System.out.println(list1.toString());
Integer[] arr1 = {1,2,3};
List<Integer> list2 = new ArrayList<>();
Collections.addAll(list2,arr1);
System.out.println(list2.toString());
// [1, 2, 3]
//[[I@d48673]
//[1, 2, 3]
}
}
集合
数组和集合的区别
-
数组的长度是固定的。集合的长度是可变的。
-
数组中可以存储
基本类型/引用类型
,基本数据类型存储的是值,引用数据类型存储的是地址值。
集合只能存储引用数据类型,如果想存储基本类型数据需要存储对应的包装类型。 -
数组存储的元素必须是同一数据类型;集合存储的对象可以是不同的数据类型。
集合不规定泛型,那么泛型就是Object,那就能存储不同的数据类型,不过是不推荐这么干的,
因为存入集合的元素都被转化成了Object类型,之后再引用集合中的类型需要强制类型转换,这就导致了集合的类型不安全,以及类型转化的性能损耗
什么时候用数组或者集合
如果元素个数是固定的,推荐用数组;如果元素个数不是固定的, 推荐用集合
集合类结构图
-
List接口,继承Collection接口,值可重复、有序
-
Set接口,继承Collection接口,值不可重复、无序
-
Map,键值对,提供key到value的映射。key无序、唯一;value无序,可重复
集合特性比较
线程安全的效率都比较低,Vector
,已被淘汰,可使用ArrayList替代。Hashtable
,已被淘汰,可使用HashMap替代,如果是高并发的线程安全的实现,推荐使用ConcurrentHashMap
。
加载因子
的系数小于等于1,意指即当元素个数 超过容量长度*加载因子的系数时,进行扩容。
-
List
有序,可重复,元素允许为null-
ArrayList
非线程安全,底层是数组(有索引),查询快、增删慢,
默认初始化大小为10,扩容增量为1.5n(1.7以前是1.5n+1),加载因子为1
可以设置初始大小,不能设置扩容因子 -
LinkedList
非线程安全,底层是双向链表,增删快(链表形式,插入删除只影响相邻节点)、查询慢
不扩容!每次添加元素时,都创建Node(节点),并加入链表 -
Vector
线程安全,底层是数组,查询快,增删慢
默认初始化大小为10,扩容为2n,加载因子为1
可以设置初始大小/扩容因子- 常用子类 Stack
特点:Stack是一个"栈"的实现类具备 “First in Last Out” (先进后出) 的特点,底层是数组,线程安全
- 常用子类 Stack
-
-
Set
非线程安全,无序,值不可重复-
HashSet
无序,允许元素为null,底层是哈希表,插入、查询和删除操作都具有较快的速度。
初始容量为16,加载因子为0.75,扩容增量为2n
线程不安全,需要重写hashCode()和equals()来保证元素唯一性 -
TreeSet
底层数据结构是红黑树。(唯一且有序)
可自然排序或实现Comparable接口定制排序,不允许元素为null,性能比HashSet稍差 -
LinkedHashSet
有序,元素允许为null,底层的数据结构是链表+hash表(LinkedHashMap),由链表保证元素有序(先进先出),由哈希表保存元素唯一。
-
// hashSet本质上就是HashMap
public HashSet() {
map = new HashMap<>();
}
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
// add() 本质就是map的put(),
// map的key是无法重复的
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
-
Map
键都不可重复,值都可重复-
HashMap
key无序,非线程安全,键值都允许为null,底层是哈希表
初始长度默认是16,加载因子为0.75,扩容增量为2n
在JDK1.7之前,HashMap底层是数组+链表。在JDK1.8之后,HashMap底层是数组+链表+红黑树。当链表长度>8,数组长度>64时,链表会转换为红黑树来提高查询效率 -
LinkedHashMap
key有序,非线程安全,键值都允许为null,是HashMap的子类,底层是哈希表(链表是双向链表(有序的原因),其他都一样),
初始长度默认是16,加载因子为0.75,扩容增量为2n实际的元素存储与HashMap一致,依然是数组+链表+红黑树的形式
区别在于:
除了维护数组+链表的结构之外,还根据插入Map先后顺序维护了一个双向链表的头尾head,tail
Node基本结构,相比较HashMap而言,还增加了 before,after 两个分别指向双向链表中前后节点的属性 -
HashTable
key无序,线程安全(全部锁),键值都不允许为null,效率比HashMap低,HashTable是基于陈旧的Dictionary类的,基本弃用
底层是哈希表
初始容量为 11 ,加载因子为0.75,扩容增量为 2n + 1 -
TreeMap
key有序,非线程安全,键不允许,值允许,效率比HashMap低,底层是红黑树
默认是升序排序,也可以指定排序的比较器(Comparator)。 -
ConCurrentHashMap
线程安全(部分锁),key无序,键值都不允许为null,底层是哈希表
初始长度默认是16,加载因子为0.75,扩容增量为2n
-
集合常用方法
单列集合常用方法
// Collection
// 把给定的对象添加到当前集合中
boolean add(E e)
// 清空集合中所有的元素
void clear()
// 把给定的对象在当前集合中删除
boolean remove(E e)
// 判断当前集合中是否包含给定的对象
boolean contains(Object obj)
// 判断当前集合是否为空
boolean isEmpty()
// 返回集合中元素的个数
int size()
// 把集合中的元素,存储到数组中
Object[] toArray()
// List
// 返回此列表中指定位置的元素
E get(int index)
// 用指定的元素(可选操作)替换此列表中指定位置的元素
E set(int index, E element)
// 将一个集合中的元素添加到另一个集合中
boolean addAll(Collection<? extends E> c)
// 返回指定元素的索引
int indexOf(Object o);
// LinkedList
// 将指定元素插入此列表的开头
void addFirst(E e)
// 将指定元素添加到此列表的结尾
void addLast(E e)
// 返回此列表的第一个元素
E getFirst()
// 返回此列表的最后一个元素
E getLast()
// 移除并返回此列表的第一个元素
E removeFirst()
// 移除并返回此列表的最后一个元素
E removeLast()
// 从此列表所表示的堆栈处弹出一个元素
E pop()
// 将元素推入此列表所表示的堆栈
void push(E e)
// 如果列表不包含元素,则返回true
boolean isEmpty()
// stack
// 推入
public E push(E item)
// 弹出
public synchronized E pop()
// 查看栈出口的元素,但是不弹出
public synchronized E peek()
// Collections
// 打乱集合顺序
static void shuffle(List<?> list)
// 批量添加,
// 可变参数本质是数组,所以第二个参数也可以是数组
static <T> boolean addAll(Collection<? super T> c, T... elements)
// 排序
// 默认排序规则,数字从小到大,字母从a到z
// 也可以排序实体类的字段
static <T> void sort(List<T> list)
static <T> void sort(List<T> list, Comparator<? super T> c)
// sort 逆序
// 实体类还可以在实体类内部实现 Comparable ,也可以达到排序的效果
Collections.sort(list, new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2 - o1;
}
});
map常用方法
// 把指定的键与指定的值添加到Map集合中
V put(K key, V value)
// 把指定的键 所对应的键值对元素 在Map集合中删除,返回被删除元素的值
V remove(Object key)
// 根据指定的键,在Map集合中获取对应的值
V get(Object key)
// 获取Map集合中所有的键,存储到Set集合中
Set<K> keySet()
// 获取Map集合中所有的值
Collection<V> values()
// 获取到Map集合中所有的键值对对象的集合(Set集合)
Set<Map.Entry<K,V>> entrySet()
// 判断该集合中是否有此键
boolean containsKey(Object key)
// 判断该集合中是否有此值
boolean containsValue(Object value)
// 将一个map中的键值对放到另一个map中
void putAll(Map<? extends K,? extends V> m)
clear()
Map<Integer,String> map = new HashMap<>();
map.put(1,"zs");
map.put(2,"ls");
map.put(3,"ww");
// keySet 获取所有键遍历
for (Integer integer : map.keySet()) {
System.out.println(integer + ":" + map.get(integer));
}
// entrySet() 获取所有键值对遍历
for (Map.Entry<Integer, String> entry : map.entrySet()) {
System.out.println(entry.getKey() + ":" + entry.getValue());
}
Java从List中删除元素的正确用法
先写一段阿里规约:
【强制】不要在foreach循环里进行元素的remove/add操作,remove元素请使用Iterator方式,如果并发的操作,需要对Iterator对象加锁。
@Test
public void deleteListELe(){
// 根据2006年8月24日国际天文联合大会召开,在会议上经过投票表决,冥王星被降级为矮行星,太阳系目前只剩下八颗行星。
// 为了演示某些删除方法不可靠,重复写了冥王星
List<String> tempList = Arrays.asList("水星","金星","地球","火星",
"木星","土星","天王星","海王星","冥王星","冥王星");
// 1. 普通的for循环的删除(不可靠)。
List<String> list = new ArrayList(tempList);
for (int i = 0; i < list.size(); i++) {
String str = list.get(i);
if ("冥王星".equals(str)) {
list.remove(i);
}
}
System.out.println(list);
// [水星, 金星, 地球, 火星, 木星, 土星, 天王星, 海王星, 冥王星]
// 奇了怪了,没删除干净?
//问题出在 list.size(),因为 list.size() 和 i 都是动态变化的,i 的值一直在累加,list.size() 一直在减少,
// 所以 list 就会早早结束了循环。所以这种方式虽然不会报错,但存在隐患,并且不容易被察觉,不建议使用。
// 2. 普通的for循环提取变量进行删除(这个更不可靠,会报错)。
List<String> list = new ArrayList(tempList);
int size = list.size();
for (int i = 0; i < size; i++) {
String result = list.get(i);
if ("冥王星".equals(result)) {
list.remove(i);
}
}
System.out.println(list);
// java.lang.IndexOutOfBoundsException: Index: 9, Size: 9
// 这更不对了,一下子搞出个下标越界。
// 因为 size 变量是固定的,但 list 的实际大小是不断减小的,而 i 的大小是不断累加的,一旦 i >= list 的实际大小肯定就异常了。
// 3. 普通的for循环倒叙删除(这个用法可以)。
List<String> list = new ArrayList(tempList);
for (int i = list.size() - 1; i > 0; i--) {
String result = list.get(i);
if ("冥王星".equals(result)) {
list.remove(i);
}
}
System.out.println(list);
// [水星, 金星, 地球, 火星, 木星, 土星, 天王星, 海王星]
// 4. 使用增强的for循环删除,不少开发者喜欢用这种方式。
tempList = Arrays.asList("水星","金星","地球","火星",
"冥王星","土星","天王星","海王星","冥王星","木星");
List<String> list = new ArrayList(tempList);
for (String item : list) {
if ("冥王星".equals(item)) {
list.remove(item);
}
}
System.out.println(list);
// java.util.ConcurrentModificationException
// 并发修改异常
// 增强的 for循环,其内部是调用的 Iterator 的方法,取下个元素的时候都会去判断要修改的数量(modCount)和期待修改的数量(expectedModCount)是否一致,不一致则会报错,
// 而 ArrayList 中的 remove 方法并没有同步期待修改的数量(expectedModCount)值,所以会抛异常了。
// 5. 迭代器循环迭代器删除(可靠,也是十分推荐的用法)
List<String> list = new ArrayList<>(tempList);
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()){
String item = iterator.next() ;
if ("冥王星".equals(item)){
iterator.remove();
}
}
System.out.println(list);
// // 6. 迭代器循环集合删除(这个可能很多开发者也会这样写,也可能会抛出异常的)
List<String> list = new ArrayList<>(tempList);
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()){
String item = iterator.next() ;
if ("冥王星".equals(item)){
list.remove(item);
}
}
System.out.println(list);
// java.util.ConcurrentModificationException
// 7. Stream filter 过滤(十分推荐,当然使用这个删除需要JDK的环境在8及其8以上的版本)。
List<String> collect = tempList.stream().filter((item) -> {
return !item.equals("冥王星");
}).collect(Collectors.toList());
System.out.println(collect);
// 这个方法利用了 Stream 的筛选功能,快速过滤所需要的元素,虽然不是进行集合删除,但达到了同样的目的,这种方法要更简洁
//
//看了上面的几个例子,相信你熟悉了List删除元素的用法了,希望你看了上面的例子,开发的时候不会再犯错了。。。
Iterator 迭代器
即Collection集合元素的通用获取方式。在取元素之前先要判断集合中有没有元素,如果有,就把这个元素取出来,继续在判断,如果还有就再取出出来。一直把集合中的所有元素全部取出。这种取出方式专业 术语称为迭代。
todo 这个说法好像有待验证
迭代器其实在另外一个线程复制了一个一摸一样的集合进行遍历的。()当用集合的remove方法删除元素时,迭代器是不会知道的,所以就会抛出并发修改异常。
在使用Iterator遍历集合时,不可以直接使用集合原有的方法
删除元素,否则可能会引发ConcurrentModificationException
异常。
原因如下:当使用迭代器遍历集合时,迭代器内部会维护一个对集合的期望修改次数。每次对集合进行结构性修改(如添加、删除元素等操作)时,这个修改次数会增加。而迭代器在执行遍历操作时会检查这个期望修改次数与实际的修改次数是否一致。如果不一致,就说明在迭代过程中有其他操作修改了集合,此时就会抛出上述异常。
如果要在遍历过程中删除元素,应该使用迭代器的remove()方法。这个方法在迭代过程中是安全的,因为它会同步更新迭代器内部的状态和集合的实际状态,确保期望修改次数和实际修改次数保持一致。
使用迭代器遍历集合中的元素时,不能使用集合本身的方法来删除,需要使用迭代器本身的方法来进行删除。(iterator只能删除,不能增加,增加可以使用Collections.addAll())
增强for循环
,对于数组底层是普通for循环,对于集合底层是迭代器,所以也尽量不要进行增删改的操作
// 获取集合对应的迭代器,用来遍历集合中的元素的。
Iterator iterator()
// 返回迭代的下一个元素
E next()
// 如果仍有元素可以迭代,则返回 true
boolean hasNext()
// 删除前,需要先调用 next(),否则报错
default void remove()
List<Integer> list3 = new ArrayList<>();
list3.add(1);
list3.add(2);
list3.add(3);
List<Integer> list4 = new ArrayList<>();
list3.add(4);
list3.addAll(list4);
// [1, 2, 3, 4]
System.out.println(list3.toString());
自己如何设计一个线程安全的list
设计思路
- 数据存储:
使用一个内部的ArrayList来存储实际的数据。这样可以利用ArrayList的高效存储和随机访问特性。
同步机制:
- 对所有可能引起线程安全问题的方法进行同步。
在这个实现中,使用synchronized关键字来确保在任何时候只有一个线程可以访问这些方法。
例如,当一个线程调用add方法添加元素时,其他线程必须等待直到这个操作完成。同样,当调用get和size方法时也会进行同步,以保证在读取列表状态时不会出现不一致的情况。
package com.example.springbootstudy.juc;
import java.util.ArrayList;
import java.util.List;
public class ThreadSafeList<T> {
private final List<T> list = new ArrayList<>();
// 添加元素的方法
public synchronized void add(T element) {
list.add(element);
}
// 获取指定索引的元素的方法
public synchronized T get(int index) {
return list.get(index);
}
// 获取列表大小的方法
public synchronized int size() {
return list.size();
}
}
package com.example.springbootstudy.juc;
public class Main {
public static void main(String[] args) throws InterruptedException {
ThreadSafeList<Integer> safeList = new ThreadSafeList<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
safeList.add(i);
}
});
Thread t2 = new Thread(() -> {
for (int i = 1000; i < 2000; i++) {
safeList.add(i);
}
});
t1.start();
t2.start();
// 这里join()的作用是先执行t1 t2,执行完成之后才会执行main()
// 否则有可能会先执行main,也就是最下面那行代码,size()就是0
t1.join();
t2.join();
System.out.println("List size: " + safeList.size());
}
}
hashmap为什么要将链表转换为红黑树
当 HashMap 中的链表长度过长时,查找元素的时间复杂度会变为 O (n),其中 n 是链表长度。而红黑树是一种自平衡的二叉查找树,其查找、插入和删除操作的时间复杂度都是 O (log n)。因此,将链表转换为红黑树可以大大提高在数据量较大时的查找效率。
例如,在一个包含大量元素的 HashMap 中,如果某个桶中的元素都存储在链表中,那么查找一个特定元素可能需要遍历整个链表,这可能会非常耗时。而如果链表转换为红黑树,查找操作可以更快地定位到目标元素。
为什么要将链表中转红黑树的阈值设为8?为什么不一开始直接使用红黑树?
可能有很多人会问,既然红黑树性能这么好,为什么不一开始直接使用红黑树,而是先用链表,链表长度大于8时,才转换为红红黑树。
-
因为红黑树的节点所占的空间是普通链表节点的两倍,但查找的时间复杂度低,所以只有当节点特别多时,红黑树的优点才能体现出来。至于为什么是8,是通过数据分析统计出来的一个结果,链表长度到达8的概率是很低的,综合链表和红黑树的性能优缺点考虑将大于8的链表转化为红黑树。
-
链表转化为红黑树除了链表长度大于8,还要HashMap中的数组长度大于64。也就是如果HashMap长度小于64,链表长度大于8是不会转化为红黑树的,而是直接扩容。
为什么ConcurrentHashMap key、value都不能null?
HashMap的键值都允许为null,为什么ConcurrentHashMap key、value都不能null?
先假定ConcurrentHashMap也可以存放value为null的值。那不管是HashMap还是ConcurrentHashMap调用map.get(key)的时候,如果返回了null,那么这个null,都有两重含义
:
-
这个key从来没有在map中映射过。(当key不存在的时候,依然可以使用get()来获取这个key的值,此时值为null)
-
这个key的value在设置的时候,就是null。
在非线程安全的map集合(HashMap)中可以使用map.contains(key)方法来判断,而ConcurrentHashMap却不可以。
hashmap正确使用场景是单线程
下,由于是单线程,当得到的value是null的时候,可以用hashMap.containsKey(key)方法来区分上面说的两重含义(保证在单线程下 线程安全 不存在并发场景,所以不会存在二义性)。
但是如果是ConcurrentHashMap呢?
它的使用场景是多线程
的情况下。假设concurrentHashMap允许存放值为null的value。
这时有A、B两个线程。
线程A调用concurrentHashMap.get(key)方法,返回为null,我们还是不知道这个null是没有映射的null还是存的值就是null。
我们假设此时返回为null的真实情况就是因为这个key没有在map里面映射过。那么我们可以用concurrentHashMap.containsKey(key)来验证我们的假设是否成立,我们期望的结果是返回false。
但是在我们调用concurrentHashMap.get(key)方法之后,containsKey方法之前,有一个线程B执行了concurrentHashMap.put(key,null)的操作。那么我们调用containsKey方法返回的就是true了。这就与我们的假设的真实情况不符合了。
总结:在map容器里面,调用map.get(key)方法得到的值是null,那你无法判断这个key是在map里面没有映射过,还是这个key在map里面根本就不存在。这种情况下,在非并发安全的map中,你可以通过map.contains(key)的方法来判断。但是在考虑并发安全的map中,在两次调用的过程中,这个值是有可能被改变的。
hashmap除了链表转红黑树还有没有更好的方案
一、优化哈希算法
- 更复杂的哈希函数:使用更复杂的哈希函数可以减少哈希冲突的概率。例如,可以采用多个不同的哈希函数组合的方式,或者使用一些经过充分研究和验证的高性能哈希函数。
- 优点:可以有效降低冲突概率,提高 HashMap 的性能。
- 缺点:复杂的哈希函数可能会带来一定的计算开销。
- 动态调整哈希函数:根据数据的分布情况动态调整哈希函数。例如,如果发现某个哈希值的冲突较多,可以调整哈希函数以减少该哈希值的出现概率。
- 优点:可以自适应数据的特点,提高 HashMap 的性能。
- 缺点:实现相对复杂,需要额外的计算资源来监测和调整哈希函数。
二、采用其他数据结构
-
哈希链表与开放寻址法结合:在哈希冲突时,不使用链表存储冲突的元素,而是采用开放寻址法,即在哈希表中寻找下一个空闲的位置来存储冲突的元素。同时,可以结合链表的方式,在冲突较多的位置使用链表来存储多个元素。
-
优点:可以减少链表过长带来的性能问题,同时避免红黑树的复杂性。
-
缺点:开放寻址法可能会导致哈希表的填充率较高时性能下降。
三、分桶优化
- 细粒度分桶:将哈希表分成更多的小桶,每个小桶存储较少的元素。这样可以减少单个桶中的冲突概率。
- 优点:可以降低冲突概率,提高查找性能。
- 缺点:需要更多的内存来存储更多的桶,并且可能会增加计算哈希值的开销。
- 自适应分桶:根据数据的分布情况动态调整桶的大小。例如,如果某个桶中的元素数量超过一定阈值,可以将该桶拆分成多个小桶;如果某个桶中的元素数量较少,可以将多个桶合并成一个大桶。
- 优点:可以根据实际情况优化哈希表的性能和内存使用。
- 缺点:实现复杂,需要额外的计算资源来监测和调整桶的大小。
需要注意的是,任何改进方案都需要在性能、内存使用、实现复杂度等方面进行权衡。在实际应用中,可以根据具体的需求和场景选择合适的方案。
什么是开放寻址法
- 探测序列:
-
当发生哈希冲突时,使用一个探测序列来寻找下一个空闲的位置。探测序列可以是线性的、二次的或者其他形式。
-
线性探测是最简单的探测序列,即依次检查下一个位置,直到找到一个空闲的位置。例如,如果位置 hash(key) 被占用,就检查位置 (hash(key) + 1) % table_size,然后是 (hash(key) + 2) % table_size,以此类推。
- 插入操作:
-
对于插入操作,首先计算键的哈希值,确定初始位置。如果该位置空闲,就将元素插入该位置;如果该位置被占用,就使用探测序列寻找下一个空闲的位置,并将元素插入该位置。
-
例如,要插入键值对 (key, value),先计算 hash(key),如果该位置空闲,就将 value 存储在该位置;如果该位置被占用,就使用线性探测序列寻找下一个空闲的位置,直到找到一个空闲位置并插入 value。
- 查找操作:
-
对于查找操作,同样先计算键的哈希值,确定初始位置。然后,使用探测序列依次检查各个位置,直到找到目标元素或者确定元素不存在。
-
例如,要查找键 key 的对应值,先计算 hash(key),检查该位置是否存储了目标元素。如果不是,就使用线性探测序列依次检查下一个位置,直到找到目标元素或者确定元素不存在。
为什么HashMap会产生死循环?
HashMap 死循环发生在 JDK 1.8 之前的版本中,它是指在并发环境下,因为多个线程同时进行 put 操作,导致链表形成环形数据结构,一旦形成环形数据结构,在 get(key) 的时候就会产生死循环。如下图所示:
死循环原因
HashMap 导致死循环的原因是由以下条件共同导致的:
-
HashMap 使用头插法进行数据插入(JDK 1.8 之前);
-
多线程同时添加;
-
触发了 HashMap 扩容。
什么是头插法?
头插法是指新来的值会取代原有的值,插入到链表的头部,如下图所示。
原链表如下图所示:
此时使用头插入插入一个元素 Z,如下图所示:
头插法会导致 HashMap 在进行扩容时,链表的顺序发生反转,如下图所示:
因为在 HashMap 扩容时,会先从旧 HashMap 的头节点读取并插入到新 HashMap 节点中,旧节点的读取顺序是 A -> B -> C,于是插入到新 HashMap 中的顺序就变成了 C -> B -> A,这样就破坏了链表的顺序,导致了链表反转
。
死循环产生过程
死循环执行步骤1
死循环是因为并发 HashMap 扩容导致的,并发扩容的第一步,线程 T1 和线程 T2 要对 HashMap 进行扩容操作,此时 T1 和 T2 指向的是链表的头结点元素 A,而 T1 和 T2 的下一个节点,也就是 T1.next 和 T2.next 指向的是 B 节点,如下图所示:
死循环执行步骤2
死循环的第二步操作是,线程 T2 时间片用完进入休眠状态,而线程 T1 开始执行扩容操作,一直到线程 T1 扩容完成后,线程 T2 才被唤醒,扩容之后的场景如下图所示:
从上图可知线程 T1 执行之后,因为是头插法,所以 HashMap 的顺序已经发生了改变,但线程 T2 对于发生的一切是不可知的,所以它的指向元素依然没变,如上图展示的那样,T2 指向的是 A 元素,T2.next 指向的节点是 B 元素。
死循环执行步骤3
当线程 T1 执行完,而线程 T2 恢复执行时,死循环就建立了,如下图所示:
因为 T1 执行完扩容之后 B 节点的下一个节点是 A,而 T2 线程指向的首节点是 A,第二个节点是 B,这个顺序刚好和 T1 扩完容完之后的节点顺序是相反的。T1 执行完之后的顺序是 B 到 A,而 T2 的顺序是 A 到 B,这样 A 节点和 B 节点就形成死循环了,这就是 HashMap 死循环导致的原因。
解决方案
HashMap 死循环的常用解决方案有以下几个:
-
升级到高版本 JDK(JDK 1.8 以上),高版本 JDK 使用的是尾插法插入新元素的,所以不会产生死循环的问题;
-
使用线程安全容器 ConcurrentHashMap 替代(推荐使用此方案);
-
使用线程安全容器 Hashtable 替代(性能低,不建议使用);
-
使用 synchronized 或 Lock 加锁 HashMap 之后,再进行操作,相当于多线程排队执行(比较麻烦,也不建议使用)。
小结
HashMap 死循环发生在 JDK 1.7 版本中,形成死循环的原因是 HashMap 在 JDK 1.7 使用的是头插法,头插法 + 多线程并发操作 + HashMap 扩容,这几个点加在一起就形成了 HashMap 的死循环,解决死循环可以采用线程安全容器 ConcurrentHashMap 替代。
集合比较
hashmap、hashtable、ConcurrentHashMap的区别
- Hashtable、ConcurrentHashMap线程安全的,而HashMap是非线程安全的,所以HashMap比HashTable的效率高
- hashMap的键合值都可以为空,但是hashtable、ConcurrentHashMap的键值都不可以为空
ConcurrentHashMap和Hashtable
ConcurrentHashMap和Hashtable都是线程安全的,HashTable是锁住整个hash表,而concurrentHashMap引入了分割,只锁当前需要用到的桶,不论它变得多么大,仅仅需要锁定map的某个部分,而其它的线程不需要等到迭代完成才能访问map,ConcurrentHashMap同步性能更好。当Hashtable的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间,但是HashTable提供更强的线程安全性。
HashTable
-
HashTable 虽然线程安全,但只是
简单的用 synchronized
给所有方法加锁,相当于是对this加锁,也就是对整个HashTable对象进行加锁(非常无脑)一个HashTable对象只有一把锁,如果两个线程访问同一个对象时,就会发生锁冲突
-
HashTable效率非常低,因为无脑加锁原因,比如一些读操作不存在线程不安全问题,所以这样的加锁方式导致效率非常低
比如 某个线程触发了扩容机制,那就会由这个线程完成整个扩容过程,如果元素特别多的情况下,效率非常低,其他线程阻塞等待的时间会特别长
因为HashTable无脑加锁的原因,现在Java官方已经不推荐使用HashTable了 不涉及线程安全问题时使用HashMap,如果要保证线程安全就使用ConcurrentHashMap
ConcurrentHashMap
ConcurrentHashMap 最重要的点要说 线程安全
ConcurrentHashMap 相比比较于HashTable 有很多的优化,
最核心的思路就是:降低锁冲突的概率
(1)锁粒度的控制
ConcurrentHashMap 不是锁整个对象,而是使用多把锁,对每个哈希桶(链表)都进行加锁,只有当两个线程同时访问同一个哈希桶时,才会产生锁冲突,这样也就降低了锁冲突的概率,性能也就提高了
(2)ConcurrentHashMap 只给写操作加锁,读操作没加锁
如果两个线程同时修改,才会有锁冲突
如果两个线程同时读,就不会有锁冲突
如果一个线程读,一个线程写,也是不会有锁冲突的
(这个操作也是可能会锁冲突的,因为有可能,读的结果是一个修改了一半的数据
不过ConcurrentHashMap在设计时,就考虑到这一点,就能够保证读出来的一定是一个“完整的数据”,要么是旧版本数据,要么是新版本数据,不会是读到改了一半的数据;而且读操作中也使用到了volatile保证读到的数据是最新的)
为什么一定是一个完整的数据,暂时先不学习,留待以后
(3)充分利用到了CAS的特性
比如更新元素个数,都是通过CAS来实现的,而不是加锁
(4)ConcurrentHashMap 对于扩容操作,进行了特殊优化
HashTable的扩容是这样:当put元素的时候,发现当前的负载因子已经超过阀值了,就触发扩容。
扩容操作时这样:申请一个更大的数组,然后把这之前旧的数据给搬运到新的数组上
但这样的操作会存在这样的问题:如果元素个数特别多,那么搬运的操作就会开销很大
执行一个put操作,正常一个put会瞬间完成O(1)
但是触发扩容的这一下put,可能就会卡很久(正常情况下服务器都没问题,但也有极小概率会发生请求超时(put卡了,导致请求超时),虽然是极小概率,但是在大量数据下,就不是小问题了)
ConcurrentHashMap 在扩容时,就不再是直接一次性完成搬运了
而是搬运一点,具体是这样的
扩容过程中,旧的和新的会同时存在一段时间,每次进行哈希表的操作,都会把旧的内存上的元素搬运一部分到新的空间上,直到最终搬运完成,就释放旧的空间
在这个过程中如果要查询元素,旧的和新的一起查询;如果要插入元素,直接在新的上插入;如果是要删除元素,那就直接删就可以了
ConcurrentHashMap是如何保证线程安全的
ConcurrentHashMap是HashMap的多线程版本,HashMap在并发操作时会有各种问题,比如死循环问题、数据覆盖等问题。而这些问题,只要使用ConcurrentHashMap就可以完美解决了,那问题来了,
ConcurrentHashMap是如何保证线程安全的?它的底层又是如何实现的?接下来我们一起来看。
在Java中,HashMap是一个非常常用的数据结构,用于存储键值对。然而,尽管HashMap在单线程环境下表现出色,但在多线程环境下却存在线程不安全的问题。这是因为HashMap在多线程环境下可能会发生扩容死循环
,导致程序无法正常运行。
那么,为什么HashMap在多线程环境下会出现线程不安全的情况呢?其中一个主要原因是扩容操作的时候可能会引发竞争条件。当HashMap中存储的键值对数量超过了负载因子与容量的乘积时,就会触发扩容操作。扩容的过程涉及到重新计算每个键值对的哈希值,并将其放入新的桶中。然而,在多线程环境下,多个线程可能同时触发扩容操作,导致竞争条件的出现。
当多个线程同时进行扩容操作时,它们会尝试同时修改HashMap的内部结构,包括桶数组和链表。这就可能导致多个线程在同一个桶中同时插入节点,从而形成环形链表
。当其他线程在遍历链表时,由于环形链表的存在,可能会陷入死循环,导致程序无法正常终止。这种情况下,线程不安全就会变得明显。
为了解决这个问题,Java提供了一种线程安全的HashMap实现,即ConcurrentHashMap。ConcurrentHashMap通过使用锁和分段锁的方式来保证多线程环境下的安全性。在ConcurrentHashMap中,不同的桶被分成了多个段(Segment),每个段都有自己的锁(分段锁)。这样,不同的线程可以同时访问不同的段,从而提高并发性能。通过细粒度的锁机制,ConcurrentHashMap能够在多线程环境下保持高效的并发访问。
除了扩容死循环的问题,HashMap在多线程环境下还存在其他线程安全的隐患。例如,在进行put操作时,多个线程可能同时修改同一个桶的链表结构,导致数据丢失
或者链表断裂。为了解决这个问题,可以使用同步机制,如synchronized关键字或者使用ConcurrentHashMap来保证线程安全。
1.7底层实现
ConcurrentHashMap在不同的JDK版本中实现是不同的,在JDK1.7中它使用的是数组加链表的形式实现的,而数组又分为:大数组Segment和小数组HashEntry
。大数组Segment可以理解为MySQL中的数据库,而每个数据库(Segment)中又有很多张表HashEntry,每个HashEntry中又有多条数据,这些数据是用链表连接的,如下图所示:
从上述源码我们可以看出,Segment本身是基于ReentrantLock实现的加锁和释放锁的操作,这样就能保证多个线程同时访问ConcurrentHashMap时,同一时间只有一个线程能操作相应的节点,这样就保证了ConcurrentHashMap的线程安全了。 也就是说ConcurrentHashMap的线程安全是建立在Segment加锁的基础上的,所以我们把它称之为分段锁或片段锁,如下图所示:
JDK1.8底层实现
在JDK1.7中,ConcurrentHashMap虽然是线程安全的,但因为它的底层实现是数组+链表的形式,所以在数据比较多的情况下访问是很慢的,因为要遍历整个链表,而JDK1.8则使用了数组+链表/红黑树的方式优化了ConcurrentHashMap的实现,具体实现结构如下:
在JDK1.8中ConcurrentHashMap使用的是CAS+volatile或synchronized的方式来保证线程安全的
从上述源码可以看出,在JDK1.8中,添加元素时首先会判断容器是否为空,如果为空则使用volatile加CAS来初始化。如果容器不为空则根据存储的元素计算该位置是否为空,如果为空则利用CAS设置该节点;如果不为空则使用synchronize加锁,遍历桶中的数据,替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。 我们把上述流程简化一下,我们可以简单的认为在JDK1.8中,ConcurrentHashMap是在头节点加锁来保证线程安全的,锁的粒度相比Segment来说更小了,发生冲突和加锁的频率降低了,并发操作的性能就提高了。而且JDK1.8使用的是红黑树优化了之前的固定链表,那么当数据量比较大的时候,查询性能也得到了很大的提升,从之前的O(n)优化到了O(logn)的时间复杂度
总结
-
ConcurrentHashMap在JDK1.7时使用的是数据加链表的形式实现的,其中数组分为两类:大数组Segment和小数组HashEntry,而加锁是通过给Segment添加ReentrantLock锁来实现线程安全的。
-
而JDK1.8中ConcurrentHashMap使用的是数组+链表/红黑树的方式实现的,它是通过CAS或synchronized来实现线程安全的,并且它的锁粒度更小,查询性能也更高。
HashMap
HashMap的实现原理
桶的概念
Entry[] table中的某一个元素及其对应的Entry<Key,Value>又被称为桶(bucket)
首先有一个每个元素都是链表的数组,当添加一个元素(key-value)时,就首先计算元素key的hash值,以此确定插入数组中的位置,但是可能存在同一hash值的元素已经被放在数组同一位置了,这时就添加到同一hash值的元素的后面,他们在数组的同一位置,但是形成了链表,同一个链表上的Hash值是相同的,所以说数组存放的是链表。而当链表长度太长时,链表就转换为红黑树,这样大大提高了查找的效率。
当链表数组的容量超过初始容量的0.75时,再散列将链表数组扩大2倍,把原链表数组的搬移到新的数组中。
HashMap的数据结构
HashMap的底层结构是哈希表的具体实现,通过相应的哈希运算就可以很快查询到目标元素在表中的位置,拥有很快的查询速度,因此,HashMap被广泛应用于日常的开发中。理想的情况就是一个元素对应一个Hash值,这样的查询效果是最优的。
但实际这是不可能的,因为哈希表存在“hash (哈希) 冲突“
的问题。当发生hash冲突时,HashMap采用“拉链法“
进行解决,也就是数组加链表的结构。在HashMap的代码注释中,数组中的元素用 “bucket” (中文读作 桶) 来称呼,而哈希函数的作用就是将key寻址到buckets中的一个位置,如果一个 bucket 有多个元素,那么就以链表的形式存储(jdk1.8之前单纯是这样)。
这是HashMap的存储结构图:
HashMap线程安全的问题
- 多线程下扩容死循环。
JDK1.7中的HashMap使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
- 多线程的put可能导致元素的丢失。
多线程同时执行put操作,如果计算出来的索引位置是相同的,那会造成前一个key被后一个key覆盖,从而导致元素的丢失。此问题在JDK 1.7和JDK1.8中都存在。
- put和get并发时,可能导致get为null。
线程1执行put时,因为元素个数超出threadhold而导致rehash,线程2此时执行get,有可能导致这个问题。此问题在JDK 1.7和JDK1.8中都存在。
解决方案就是使用ConcurrentHashMap
jdk8对HashMap进行了哪些优化
-
在java 1.8中,如果链表的长度超过了8,那么链表将转换为红黑树。
-
发生hash碰撞时,java 1.7 会在链表的头部插入,而java 1.8会在链表的尾部插入
-
在java 1.8中,Entry被Node替代(换了一个马甲)。
代码中如何优化HashMap(提高性能)
在使用HashMap的过程中,你比较明确它要容纳多少Entry<Key,Value>,你应该在创建HashMap的时候直接指定它的容量;
如果你确定HashMap的使用的过程中,大小会非常大,那么你应该控制好 加载因子的大小,尽量将它设置得大些。避免Entry[] table过大,而利用率却很低。
那么hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16 * 0.75=12的时候,就把数组的大小扩展为2 * 16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能
。
比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75 * 1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了性能的问题,也避免了resize的问题。
存储过程
在jdk7的基础上,形成了链表之后,当我们查询一个对象的时候,如果这个位置已经形成了链表,那么此时查询的效率就比较低了,因为我们得遍历这个链表,运气不好的话,可能要查找的元素就是在链表的最后一个,那么此时我们就需要把整个链表都遍历一遍,效率比较低。
在jdk1.8之后,在数组+链表的基础上,还多了一个红黑树。现在的结构就是数组+链表+红黑树,当碰撞的次数大于8并且总容量大于64的时候,链表就会变为红黑树结构,转为红黑树之后,除了添加以外,其他的效率都比链表高,因为在添加的时候,链表是直接加到链表的末尾,而红黑树添加的时候,需要比较大小,然后再进行添加。
HashMap深入源码
两个参数
在具体学习源码之前,我们需要先了解两个HashMap中的两个重要参数,“初始容量” 和 “加载因子”
,
初始容量是指数组的数量。加载因子则决定了 HashMap 中的元素在达到多少比例后可以扩容 (rehash),当HashMap的元素数量超过了加载因子与当前容量的乘积后,就需要对哈希表做扩容操作。
在HashMap中,加载因子默认是0.75,这是结合时间、空间成本均衡考虑后的折中方案,因为 加载因子太大的话发生冲突的可能性会变大,查找的效率反而低;太小的话频繁rehash,降低性能。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。
成员变量
好了,前面说了那么多,现在开始深入源码学习吧,先了解一下HashMap的主要的成员变量:
// 默认初始容量 1 << 4 ,也就是16,必须是2的整数次方。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量, 2^ 30 次方
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认加载因子,大小为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75F;
// 树形阈值,大于这个数就要树形化,也就是转成红黑树。
static final int TREEIFY_THRESHOLD = 8;
// 树形最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
// 哈希表的链接数组,对应桶的下标
transient HashMap.Node<K, V>[] table;
// 键值对集合
transient Set<Entry<K, V>> entrySet;
// 键值对的数量,也就是HashMap的大小
transient int size;
// 阈值,下次需要扩容时的值,等于 容量*加载因子
int threshold;
// 加载因子
final float loadFactor;
四个构造方法
HashMap共有四个构造方法,代码如下:
//加载默认大小的加载因子
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
//加载默认大小的加载因子,并创建一个内容为参数 m 的内容的哈希表
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
//添加整个集合
putMapEntries(m, false);
}
//指定容量和加载因子
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//根据指定容量设置阈值
this.threshold = tableSizeFor(initialCapacity);
}
//指定容量,加载因子默认大小
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
不难发现,上面第三个构造函数可以自定义加载因子和容量
,首先判断传入的加载因子是否符合要求,然后根据制定的容量执行 tableSizeFor() 方法,它会根据容量来指定阈值,为何要多这一步呢?
因为buckets数组的大小约束对于整个HashMap都至关重要,为了防止传入一个不是2次幂的整数,必须要有所防范。tableSizeFor()函数会尝试修正一个整数,并转换为离该整数最近的2次幂。
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
比如传入一个整数244,经过位移,或运算后会返回最近的2次幂 256
get()方法概述
-
通过key的Hash找到唯一的桶位。
-
找到具体桶位,现在有两种情况
-
如果首元素的key和目标key相同,则返回首元素。
-
如果首元素的key不相同。判断有没有第二个元素。如果没有,在该桶位处就没必要再找,直接返回null。
-
如果有第二元素。判断首元素类型
-
如果为链表,采用do-while(循环体会无条件执行一次,然后再根据循环条件来决定是否继续执行)循环遍历桶中链表。
-
如果为红黑树,用红黑树的遍历方式getTreeNode();
首先找到根节点Root,从根节点向下找。然后,根据查找key的hash值和当前node的hash值比较。如果大于,就向右边找。如果小于,就向左找。如果相同并比较equals相同,就返回当前节点。如果左子树没有了,就向右子树找。如果右子树没有了,就向左子树找。如果没有找到就进入下一轮递归寻找。
-
-
根据特定的Key值从HashMap中取Value的结果就比较简单了:
-
获取这个Key的hashcode值,根据此hashcode值决定应该从哪一个桶中查找;
-
遍历所在桶中的Entry<Key,Value>链表,查找其中是否已经有了以Key值为Key存储的Entry<Key,Value>对象;
若已存在,定位到对应的Entry<Key,Value>,返回value;
若不存在,返回null;
插入数据的方法:put()
在集合中最常用的操作是存储数据,也就是插入元素的过程,在HashMap中,插入数据用的是 put() 方法。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
put方法没有做多余的操作,只是传入 key 和 value 还有 hash 值 进入到 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;
//哈希表如果为空,就做扩容操作 resize()
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//要插入位置没有元素,直接新建一个包含key的节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//如果要插入的桶已经有元素,替换
else {
Node<K,V> e; K k;
//key要插入的位置发生碰撞,让e指向p
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//没碰撞,但是p是属于红黑树的节点,执行putTreeVal()方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//p是链表节点,遍历链表,查找并替换
else {
//遍历数组,如果链表长度达到8,转换成红黑树
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;
}
// 找到目标节点,退出循环,e指向p
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 节点已存在,替换value,并返回旧value
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;
}
代码看上去有点复杂,参数有点乱,但理清逻辑后容易理解多了,源码大概的逻辑如下:
-
查看node数组有没有初始化。没有就创建
-
根据hash值计算数组中唯一角标
-
找到具体角标,此时有两种种情况
-
当前角标(桶)下没有元素,为null。直接创建新的node节点,放入key-value即可。
-
当前桶的第一个元素 instanceof TreeNode,也就是说当前桶结构为红黑树,则调用红黑树的putTreeVal()。
-
当前桶结构为链表。循环遍历这个链表,直到node.next为null是才插入该key-value.如果插入之后链表长度大于8,就会进行树化处理。在遍历过程中,如果发现有node的k和插入的key相同,直接退出遍历。注意:在树化过程中,如果元素个数小于64只会通过扩容降低Hash冲突。
-
-
返回旧值情况:用e设置新value,并放回旧的value。注意:此处都会返回原先node上面的value,如果相同也会返回。
没有旧值返回就返回null。(也就是当前桶没有元素的的时候返回null)
-
验证当前集合容量是否达到阈值,如果达到进行resize扩容。
哈希函数:hash()
这段看一小部分就可以了,因为大部分看不懂。。。。。。
hash() 方法是HashMap 中的核心函数,在存储数据时,将key传入中进行运算,得出key的哈希值,通过这个哈希值运算才能获取key应该放置在 “桶” 的哪个位置,下面是方法的源码:
java static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
从源码中可以看出,传入key之后,hash() 会获取key的hashCode进行无符号右移 16 位,然后进行按位异或,并把运算后的值返回,这个值就是key的哈希值。这样运算是为了减少碰撞冲突,因为大部分元素的hashCode在低位是相同的,不做处理的话很容易造成冲突。
之后还需要把 hash() 的返回值与table.length - 1做与运算,得到的结果即是数组的下标(为什么这么算,下面会说),在上面的 putVal() 方法中就可以看到有这样的代码操作,举个例子图:
table.length - 1就像是一个低位掩码(这个设计也优化了扩容操作的性能),它和hash()做与操作时必然会将高位屏蔽(因为一个HashMap不可能有特别大的buckets数组,至少在不断自动扩容之前是不可能的,所以table.length - 1的大部分高位都为0),只保留低位,这样一来就总是只有最低的几位是有效的,就算你的hashCode()实现得再好也难以避免发生碰撞。这时,hash()函数的价值就体现出来了,它对hash code的低位添加了随机性并且混合了高位的部分特征,显著减少了碰撞冲突的发生。
另外,在putVal方法的源码中,我们可以看到有这样一段代码
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
上面的注释也说明了,这是检测要插入位置是否有元素,没有的话直接新建一个包含key的节点,那么这里为什么要用 i = (n - 1) & hash 作为索引运算呢?
这其实是一种优化手段,由于数组的大小永远是一个2次幂,在扩容之后,一个元素的新索引要么是在原位置,要么就是在原位置加上扩容前的容量。这个方法的巧妙之处全在于&运算,之前提到过&运算只会关注n– 1(n =数组长度)的有效位,当扩容之后,n的有效位相比之前会多增加一位(n会变成之前的二倍,所以确保数组长度永远是2次幂很重要),然后只需要判断hash在新增的有效位的位置是0还是1就可以算出新的索引位置,如果是0,那么索引没有发生变化,如果是1,索引就为原索引加上扩容前的容量。
效果图如下:
这样在每次扩容时都不用重新计算hash,省去了不少时间,而且新增有效位是0还是1是带有随机性的,之前两个碰撞的Entry又有可能在扩容时再次均匀地散布开,真可谓是非常精妙的设计。
动态扩容:resize()
在HashMap中,初始化数组或者添加元素个数超过阈值时都会触发 resize() 方法,它的作用是动态扩容,下面是方法的源码:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//‘桶’数组的大小超过0,做扩容
if (oldCap > 0) {
//超过最大值不会扩容,把阈值设置为int的最大数
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//向左移动1位扩大为原来2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//旧数组大小为0,旧阈值>0,说明之前创建了哈希表但没有添加元素,初始化容量等于阈值
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
//旧容量、旧阈值都是0,说明还没创建哈希表,容量为默认容量,阈值为 容量*加载因子
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//新阈值还没有值,重新根据新的容量newCap计算大小
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"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//不为空,代表是扩容操作
if (oldTab != null) {
//遍历旧数组的每一个‘桶’,移动到新数组newTab
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)
((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;
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(下次需要扩容时的值,,等于 容量*加载因子)
-
根据计算出的 newCap 创建新的桶数组,并初始化桶的数组table
-
将键值对节点重新映射到新的桶数组里。如果节点是 TreeNode 类型,则需要拆分红黑树 (调用
HashMap的split()
方法 )。如果是普通节点,则节点按原顺序进行分组。
前面两步的逻辑比较简单,这里不多叙述。重点是第三点,涉及到了红黑树的拆分,这是因为扩容后,桶数组变多了,原有的数组上元素较多的红黑树就需要重新拆分,映射成链表,防止单个桶的元素过多。
红黑树的拆分是调用TreeNode.split() 来实现的,这里不单独讲。放到后面的红黑树一起分析。
节点树化、红黑树的拆分
红黑树的引进是HashMap 在 Jdk1.8之后最大的变化,在1.8以前,HashMap的数据结构就是数组加链表,某个桶的链表有可能因为数据过多而导致链表过长,遍历的效率低下,1.8之后,HashMap对链表的长度做了处理,当链表长度超过8时,自动转换为红黑树,有效的提升了HashMap的性能。
但红黑树的引进也使得代码的复杂度提高了不少,添加了有关红黑树的操作方法。本文只针对这些方法来做解析,不针对红黑树本身做展开。
节点树化
HashMap中的树节点的代码用 TreeNode 表示:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
可以看到就是个红黑树节点,有父亲、左右孩子、前一个元素的节点,还有个颜色值。知道节点的结构后,我们来看有关红黑树的一些操作方法。
先来分析下树化的代码:
//将普通的链表转化为树形节点链表
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 桶数组容量小于 MIN_TREEIFY_CAPACITY,优先进行扩容而不是树化
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
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
// 树形节点不为空,转换为红黑树
hd.treeify(tab);
}
}
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
上面的代码并不太复杂,大致逻辑是根据hash表的元素个数判断是需要扩容还是树形化,然后依次调用不同的代码执行。
值得注意的是,在判断容器是否需要树形化的标准是链表长度需要大于或等于 MIN_TREEIFY_CAPACITY
,前面也说了,它是HashMap的成员变量,初始值是64,那么为什么要满足这个条件才会树化呢?
当桶数组容量比较小时,键值对节点 hash的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。毕竟高碰撞率是因为桶数组容量较小引起的,这个是主因。容量小时,优先扩容可以避免一些列的不必要的树化过程。同时,桶容量较小时,扩容会比较频繁,扩容时需要拆分红黑树并重新映射。所以在桶容量比较小的情况下,将长链表转成红黑树是一件吃力不讨好的事。
这样做的目的是因为数组比较小,尽量避开红黑树结构,这种情况下变为红黑树结构,反而会降低效率,因为红黑树需要逬行左旋,右旋,变色这些操作来保持平衡。同时数组长度小于64时,搜索时间相对要快些。所以结上所述为了提高性能和减少搜索时间,底层阈值大于8并且数组长度大于64时,链表才转换为红黑树,具体可以参考 treeifyBin() 方法。
当然虽然增了红黑树作为底层数据结构,结构变得复杂了,但是阈值大于 8 并且数组长度大于 64 时,链表转换为红黑树时,效率也变的更高效。
所以,HashMap的树化过程也是尽量的考虑了容器性能,再看回上面的代码,链表树化之前是先把节点转为树形节点,然后再调用 treeify() 转换为红黑树,并且树形节点TreeNode 继承自 Node 类,所以 TreeNode 仍然包含 next 引用,原链表的节点顺序最终通过 next 引用被保存下来。
下面看下转换红黑树的过程:
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
if (root == null) { //第一次进入循环,确定头节点,并且是黑色
x.parent = null;
x.red = false;
root = x;
}
else { //后面进入循环走的逻辑,x 指向树中的某个节点
K k = x.key;
int h = x.hash;
Class<?> kc = null;
//从根节点开始,遍历所有节点跟当前节点 x 比较,调整位置,
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
if ((ph = p.hash) > h) //当比较节点的哈希值比 x 大时, dir 为 -1
dir = -1;
else if (ph < h) //哈希值比 x 小时 dir 为 1
dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
// 比较节点和x的key
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;
//把 当前节点变成 x 的父亲
//如果当前比较节点的哈希值比 x 大,x 就是左孩子,否则 x 是右孩子
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;
}
}
}
}
moveRootToFront(tab, root);
}
可以看到,代码的总体逻辑就是拿树中的节点与当前节点做比较,进而确定节点在树中的位置,具体实现的细节还是比较复杂的,这里不一一展开了。
红黑树拆分
介绍了节点的树化后,我们来学习下红黑树的拆分过程,HashMap扩容后,普通的节点需要重新映射,红黑树节点也不例外。
在将普通链表转成红黑树时,HashMap 通过两个额外的引用 next 和 prev 保留了原链表的节点顺序。这样再对红黑树进行重新映射时,完全可以按照映射链表的方式进行。这样就避免了将红黑树转成链表后再进行映射,无形中提高了效率。
下面看一下拆分的方法源码:
//map 容器本身
//tab 表示保存桶头结点的哈希表
//index 表示从哪个位置开始修剪
//bit 要修剪的位数(哈希值)
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
// 修剪后的两个链表,下面用lo树和hi树来替代
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
//如果当前节点哈希值的最后一位等于要修剪的 bit 值,用于区分位于哪个桶
if ((e.hash & bit) == 0) {
//把节点放到lo树的结尾
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
//把当前节点放到hi树
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
// 如果 loHead 不为空,且链表长度小于等于 6,则将红黑树转成链表
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
/*
* hiHead != null 时,表明扩容后,
* 有些节点不在原位置上了,需要重新树化
*/
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
//与上面类似
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
源码的逻辑大概是这样:拆分后,将红黑树拆分成两条由 TreeNode 组成的链表(hi树和lo树)。如果链表长度小于 UNTREEIFY_THRESHOLD,则将链表转换成普通链表。否则根据条件重新将 TreeNode 链表树化。这里用两张图来展示一下拆分前后的变化
红黑树拆分前:
拆分后:
至此,有关红黑树的一些转换操作就介绍完毕了,除此之外,hashMap还提供了很多操作红黑树的方法,原理都差不多,读者们可以自己去研究。
总结
HashMap的源码解析就告一段落了,最后,总结一下HashMap的一些特性:
-
HashMap 允许 key, value 为 null;
-
HashMap源码里没有做同步操作,多个线程操作可能会出现线程安全的问题,建议用Collections.synchronizedMap来包装,变成线程安全的Map,例如:
Map map = Collections.synchronizedMap(new HashMap<String,String>());
-
Jdk1.7以前,当HashMap中某个桶的结构为链表时,遍历的时间复杂度为O(n),1.8之后,桶中过多元素的话会转换成了红黑树,这时候的遍历时间复杂度就是O(logn)。
部分内容参考自:
https://blog.youkuaiyun.com/MinggeQingchun/article/details/121156297
https://blog.51cto.com/NIO4444/3838584
https://blog.youkuaiyun.com/liuyueyi25/article/details/78511278/
https://blog.youkuaiyun.com/wzh70190/article/details/88719272
https://www.cnblogs.com/zzq919/p/12978955.html
https://blog.youkuaiyun.com/java123456111/article/details/123378463
https://blog.youkuaiyun.com/m0_58761900/article/details/127184407
https://blog.youkuaiyun.com/qq_22943729/article/details/129986298
https://blog.youkuaiyun.com/Estrus5/article/details/129710181
https://zhuanlan.zhihu.com/p/461156849
https://blog.youkuaiyun.com/javacn_site/article/details/130818955
没有乐趣困难那一说,我只是感觉我能明白。
当年明月