Java集合知识点集合及面试总结
Java集合知识点集合及面试总结
1 基础知识
1.1 体系结构
1.2 内容总览
* 复习集合:
* 1. 集合的意义:
* |--数组,内容类型固定,格式不可改变,大小不可改变。提供的方法少。
* |--集合:可以加入多种类型的内容,可以改变大小提供的方法很多,功能强大。
*
* 2. 集合的框架结构 + 基本方法的使用
* 单个元素的集合:
* |--List:有序可重复
* |--ArrayList---LinkedList
* ① 两者区别,几乎相同
* linkedList底层是由链表实现,所以在插入删除操作上高效,扩容机制是扩大两倍。
* ArrayList底层是动态数组,扩容默认为 1.5倍,随机访问 get和set,ArrayList觉得优于LinkedList
* ② ArrayList 底层
* jdk7之前,初始化时就直接创建初始大小为 10的底层数组
* jdk8之后,初始化创建一个长度为0的数组,在添加的时候才创建
* ③ LinkedList 底层是双向链表,定义内部类Node,first和 last,用于记录首末元素
*
* |--Vector:古老的类,线程安全,用的很少,初始10,扩 2倍
* 方法都是修饰:public synchronized void set
*
* 面试题:ArrayList、LinkedList、Vector三者的异同?
*
* |--Set:无序不可重复
* 判断两个对象是否相同不是使用 == 运算符,而是根据 equals() 方法
* |--HashSet无序---LinkedHashSet有序
* 添加的底层实现原理,由数组+链表组成,hashcode()方法计算位置。
*
* 1. 为什么要重写hashCode(),不重写两次相同的对象,得到的hashCode()值也不相等。
* 2.重写 equals方法的时候一必须同时重写 hashCode方法。
* hashcode() 计算的是存储的位置,不同的对象可能在同一个位置,所以此时要进一步比较
* equals()是否相等,相等则不添加。
* 3.equals判断的是内容是否相等,==判断的是引用是否相等。
*
* 4.HashSet 源码中,是直接调用 HashMap 的各种方法
*
* LinkedHashSet,它同时使用双向链表维护元素的次序,这使得元素看起来是以插入
* 顺序保存的。
* |--TreeSet:可以根据指定属性进行排序,使用红黑树结构存储数据
* 自定义排序Comparator + 重写 compare()
* 自然排序Comparable + 重写:compareTo()
*
* 对的集合:
* |--Map
* |--HashMap--LinkedHashMap,允许使用null键和null值,
* key 用Set来存放,不允许重复。values 用collection存储。
* 底层实现:数组+链表+红黑树(jdk8之后)
* 1.默认初始容量:16
* 2.加载因子:0.75,数组上有75%的空占满了就会扩容
* 3.扩容临界值:DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR
* 4.链表转红黑树临界值:链表长度为8
* 5.红黑树转链表临界值:总元素64个
*
* 每次扩容,都需要重新计算位置,所以开销比较大。
* 当 HashMap中的其中一个链的对象个数如果达到了8个,此时如果capacity没有
* 达到64,那么HashMap会先扩容解决,如果已经达到了64,那么这个链会变成
* 树,结点类型由Node变成TreeNode类型。当然,如果当映射关系被移除后,
* 下次 resize方法时判断树的结点个数低于6个,也会把树再转为链表。
*
* |--TreeMap
* |-- CurrentHahsMap,采用分段式锁,
* |--HashTab---Properties
* 采用一把锁,线程安全,低效。
* Hashtable 不允许使用 null 作为 key 和 value
*
* ① 创建时如果不指定容量初始值,Hashtable 默认的初始⼤⼩为 11,
* 之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化⼤⼩为16。
* 之后每次扩充,容量变为原来的 2 倍。
* ② 创建时如果给定了容量初始值,那么 Hashtable会直接使⽤你给定的⼤⼩,
* ⽽ HashMap 会将其扩充为 2 的幂次⽅⼤⼩。也就是说 HashMap 总是使⽤ 2 的幂作为哈希表的⼤⼩。
*
* 3.遍历方法
* Iteration:iteration()、增强 for() 循环
*
* 4.实现排序方法:TreeSet
* |--自然排序,直接按照大小升序或降序排列。
* 实现接口:Comparable + 重写:compareTo(),返回不再是equals()
* 大返回 1,小返回 -1, 相等返回 0
*
* |--定制排序:按其它属性进行排序
* 实现接口:Comparator + 重写 compare() 方法
*
* 5. Collections 工具类,相当于Arrays 工具类的实现
* 最重要的是 sychronizedXxx() 线程不安全进,线程安全出
*
1.3 自定义排序
1.3.1 核心思想
Lambda
表达式Arrays.sort
排序操作,Arrays.sort() 是系统提供的默认升序排序,如果要实现降序的话,必须自定义
Arrays.sort(arrays, (a, b) -> a[0] - b[0]);//谁小谁在前
//相当于
Arrays.sort(arrays, new Comparator<int[]>() {
@Override
public int compare(int[] a, int[] b) {
return a[0]-b[0];
}
});
//Arrays.sort() 是系统提供的默认升序排序,如果要实现降序的话,必须自定义
Arrays.sort(a, new Comparator<Integer> {
@Override
public int compare(Integer o1, Integer o2) {
return o2-o1;
});
1.3.2 具体内容
自然排序:类实现了java.lang.Comparable接口,重写compareTo()的规则
//这里固定指:o1表示位于前面的对象,o2表示后面的对象,并且表示o1比o2小
o1.compareTo(o2)
public class Student implements Comparable {
private int age;
private String name;
...
@Override
public int compareTo(Object obj) {
//比如按照年龄比较;当年龄相等返回0,大于返回1,小于返回-1
Student stu = (Student)obj;
if(this.age > stu.age){
return 1;
}else if(this.age<stu.age){
return -1;
}else{
return this.name.compareTo(stu.name);
}
}
}
定制排序:java.util.Comparator,重写compare方法
//这里o1表示位于前面的对象,o2表示后面的对象
compare(o1,o2)==o1.compareTo(o2)
返回-1(或负数),表示不需要交换01和02的位置,o1依旧排在o2前面,asc,升序
返回1(或正数),表示需要交换01和02的位置,o1排在o2后面,desc,降序
//Collections排序降序
Collections.sort(persons, new Comparator<Person>() {
@Override
public int compare(Person o1, Person o2) {
return o2.getAge().compareTo(o1.getAge());//o2比o1小,所以是降序
}
});
总览
Collections.sort(companyList, new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
if(o1.compareTo(o2) < 0 ){//o1比o2小,不调整位置,升序—-->升序
return -1;
}else if(o1.compareTo(o2) == 0){
return 0;
}else{//o2比o1小,调整整位置,降序——>升序
return 1;
}
}
});
1.3.3 比较的类必须重写
hashCode()和equals()方法有何重要性?
HashMap使用Key对象的hashCode()和equals()方法去决定key-value对的索引。当我们试着从HashMap中获取值的时候,这些方法也会被用到。如果这些方法没有被正确地实现,在这种情况下,两个不同Key也许会产生相同的hashCode()和equals()输出,HashMap将会认为它们是相同的,然后覆盖它们,而非把它们存储到不同的地方。同样的,所有不允许存储重复数据的集合类都使用hashCode()和equals()去查找重复,所以正确实现它们非常重要。equals()和hashCode()的实现应该遵循以下规则:
(1)如果o1.equals(o2),那么o1.hashCode() == o2.hashCode()总是为true的。
(2)如果o1.hashCode() == o2.hashCode(),并不意味着o1.equals(o2)会为true。
1.4 Arrays工具类
Modifier and Type | Method and Description |
---|---|
static <T> List<T> | asList(T... a) 返回由指定数组支持的固定大小的列表。 |
static int | binarySearch(byte[] a, byte key) 使用二进制搜索算法搜索指定值的指定字节数组。 |
static int[] | copyOf(int[] original, int newLength) 复制指定的数组,截断或填充,以使副本具有指定的长度。 |
staticint[] | copyOfRange(int[] original, int from, int to) 将指定数组的指定范围复制到新数组中。 |
static boolean | equals(int[] a, int[] a2) 如果两个指定的int数组彼此 相等 ,则返回 true 。 |
static void | fill(int[] a, int val) 将指定的int值分配给指定的int数组的每个元素。 |
static <T> void | setAll(T[] array, IntFunction<? extends T> generator) 使用提供的生成函数来计算每个元素,设置指定数组的所有元素。 |
static void | sort(byte[] a) 按照数字顺序排列指定的数组。 |
static void | sort(byte[] a, int fromIndex, int toIndex) 按升序排列数组的指定范围。 |
static <T> void | sort(T[] a, Comparator<? super T> c) 根据指定的比较器引发的顺序对指定的对象数组进行排序。 |
static DoubleStream | stream(double[] array) 返回顺序DoubleStream 与指定的数组作为源。 |
static String | toString(int[] a) 返回指定数组的内容的字符串表示形式。 |
Arrays.sort()
,对数组进行升序排序
Arrays.asList()
,返回一个受指定数组支持的固定大小的 List,HashSet的带参构造函数
//HashSet(Collection<? extends E> c) 构造一个包含指定 collection 中的元素的新 set。
new HashSet<>(Arrays.asList('a','e','i','o','u','A','E','I','O','U'));
//Arrays.asList(T... a) 返回一个受指定数组支持的固定大小的 List<T>
Arrays.copyOf()
,实现将集合转化成数组
//copyOf(int[] original, int newLength),复制指定的数组,截取或用 0 填充(如有必要),以使副本具有指定的长度
//copyOfRange(int[] original, int from, int to) ,将指定数组的指定范围复制到一个新数组。
//返回新的数组
Arrays.copyOfRange(res, 0, index+1);
ArrayList.toArray()
实现将对应的集合list转化成相同数据类型大小的数组
//list中已经存储了元素
Object[] obj = list.toArray();
//不带参数的toArray()方法,是构造的一个Object数组,然后进行数据copy
Integer[] integers = list.toArray(new Integer[list.size()]);
int[][] ans = list.toArray(new int[list.size()][]);//返回转化之后的int[][]二维数组
//带参数的toArray(T[] a) 方法,根据参数数组的类型,构造了一个对应类型的,长度跟ArrayList的size一致的数组
//源码
public Object[] toArray() {
return Arrays.copyOf(elementData, size);
}
public <T> T[] toArray(T[] a) {
if (a.length < size)
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
总览
import java.util.Arrays;
public class testArrays {
public static void main(String[] args) {
int[] arr1 = {1,5,12,36,55,78,98};
int[] arr2 = {1,36,12,5,78,98,55};
int[] arr3 = {1,5,12,36,55,78,99};
//1. 比较数组:通过`equals`方法比较数组中元素值是否相等结果为`true、false`.(布尔型不能比较)
boolean equals1 = Arrays.equals(arr1, arr2);
boolean equals2 = Arrays.equals(arr1, arr3);
System.out.println("arr1 = arr2 的结果:"+equals1);
System.out.println("arr1 = arr3 的结果:"+equals2);
//2. 把整个数组里的每一个元素的值进行替换为val
System.out.print("Arrays.fill把整个数组里的每一个元素的值进行替换为0: ");
Arrays.fill(arr1,0);
output(arr1);
//3. 对数组排序:通过`sort`方法,按升序。
System.out.print("Arrays.sort对数组排序:通过`sort`方法,按升序: ");
Arrays.sort(arr2);
output(arr2);
//4. `copyof`把一个数组复制出一个新数组(新数组的长度为length)。
//newLength代表截取arr的长度,如果newLength > arr.length的时候回补0
System.out.print("Arrays.copyOf(arr,newLength)把一个数组复制出一个新数组: ");
int[] ints = Arrays.copyOf(arr3, arr3.length+1);
output(ints);
//5. BinarySearch:找到元素在数组当中的下标。
int i = Arrays.binarySearch(arr2, 12);
System.out.println("12在数组arr2中的索引位置为: "+i);
//6. `toString`方法是把数组转换成字符串进行输出。(参数是数组,返回的是字符串)
String s = Arrays.toString(arr3);
System.out.println("Arrays.toString是把数组转换成字符串进行输出: "+s);
}
public static void output(int[] a){
for (int i = 0; i < a.length; i++) {
System.out.print(a[i]);
if (i < a.length - 1){
System.out.print(" ");
}
}
System.out.println("");
}
结果展示
arr1 = arr2 的结果:false
arr1 = arr3 的结果:false
Arrays.fill把整个数组里的每一个元素的值进行替换为0: 0 0 0 0 0 0 0
Arrays.sort对数组排序:通过sort
方法,按升序: 1 5 12 36 55 78 98
Arrays.copyOf(arr,newLength)把一个数组复制出一个新数组: 1 5 12 36 55 78 99 0
12在数组arr2中的索引位置为: 2
Arrays.toString是把数组转换成字符串进行输出: [1, 5, 12, 36, 55, 78, 99]
1.5 迭代器 (Iterator )
Java 为我们提供了一个迭代器的接口就是 Iterator 。
- next():返回序列中的下一个元素。
- hasNext():检查序列中是否还有元素。
- 使用remove():将迭代器新返回的元素删除。
Java 采用了迭代器来为各种容器提供了公共的操作接口。这样使得对容器的遍历操作与其具体的底层实现相隔离,达到解耦的效果。
Iterator 和foreach 遍历集合的区别?
- Iterator 和 foreach 都可以遍历集合;
- foreach 不可以在遍历的过程中删除元素,不然会出现 并发修改异常(ConcurrentModificationException)
- 使用 Iterator 遍历集合时,可以删除集合中的元素:
2 List
2.1 ArraysList
2.1.1 add是怎么加的?
实际就是ArrayList扩容实现步骤
1.扩容: 把原来的数组复制到另一个内存空间更大的数组中;
2.添加元素: 把新元素添加到扩容以后的数组中。
扩容之后,新元素的位置是怎么计算的:直接在原位置——因为是数组
2.1.2 ArraysList的 扩容机制
transient Object[] elementData;//用来保存元素的数组 默认为null
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = new Object[0];//长度为0的数组
//构造模式,new ArrayList() 时,调用 ArrayList 无参的构造方法
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//调用add时
public boolean add(E e) {
// 当前元素个数+1,判断是否需要进行扩容操作
ensureCapacityInternal(size + 1);
// 将此元素添加到数组
elementData[size++] = e;
return true;
}
//确定内部实际需要大小
private void ensureCapacityInternal(int minCapacity) {
// 当第一次添加元素,创建(10,需要大小)中的最大值
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
// 取最大值,DEFAULT_CAPACITY 的值为 10,minCapacity 第一次添加元素传入 1
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
//判断这个大小是不是需要扩容,因为是1.5倍,所以并不是没加一个元素都需要扩容,而是终于加满了之后需要扩容
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// 当容量不够时,进行扩容
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
// 扩容,调用minCapacity确定扩容的大小
private Object[] grow(int minCapacity) {
调用 copyOf 方法,创建新的大小为 newCapacity 的数组,并将原来数组的元素拷贝到新数组
return this.elementData = Arrays.copyOf(this.elementData, this.newCapacity(minCapacity));
}
//确定扩容之后的大小
private int newCapacity(int minCapacity) {
int oldCapacity = this.elementData.length;
//新的数组长度 = 当前数组长度 + (当前数组长度右移 1 位),第一次添加 newCapacity = 0
int newCapacity = oldCapacity + (oldCapacity >> 1);
//如果新的数组大小还是不够大
if (newCapacity - minCapacity <= 0) {
// 判断是不是第一次添加元素,赋值 newCapacity = 10,因此,调用无参构造方法默认数组大小为 10
if (this.elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(10, minCapacity);
} else if (minCapacity < 0) {
throw new OutOfMemoryError();
} else {
return minCapacity;
}
} else {
return newCapacity - 2147483639 <= 0 ? newCapacity : hugeCapacity(minCapacity);
}
}
删除元素的操作
public E remove(int index) {
// 检测指定的索引是否越界
rangeCheck(index);
modCount++;
// 获取待移除的元素,操作完成后返回
E oldValue = elementData(index);
// 计算出 index 之后元素的个数
int numMoved = size - index - 1;
if (numMoved > 0)
// 调用 arraycopy 方法将 index 之后的元素向前移动一位,将 index 位置的元素覆盖掉
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
// 将数组最后位置的值置为 null
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
补充:Arrays.copyOf和System.arraycopy的区别
System.arraycopy
的源码
// 方法上使用native修饰的,说明方法的实现是底层用c++写的
public static native void arraycopy(Object src //源数组, int srcPos, //源数组开始的位置
Object dest //目标数组, int destPos, //目标数组开始的位置
int length);
Arrays.copyOf
的源码
//如果有确定的数组就直接调用重载的copyOf方法,也就是直接调用System.arraycopy方法
public static <T> T[] copyOf(T[] original, int newLength) {
return copyOf(original, newLength, original.getClass());
}
//系统自动在内部新建一个数组,调用arraycopy()将original内容复制到copy中去
public static <T, U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
T[] copy = newType == Object[].class ? new Object[newLength] : (Object[])Array.newInstance(newType.getComponentType(), newLength);
System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength));
return copy;
}
Arrays.copyOf
是基于System.arraycopy
的实现,适合目标数组不明确的情况,在目标数组已经指明的情况下直接调用System.arraycopy
2.2 LinkedList
2.2.1 链表功能
LinkedList 提供不同数据结构的函数用法,功能一样,使用习惯不一样,在二叉树的遍历中用的比较多
* 针对链表
* add:增加元素
* remove:删除元素
*
* 队列
* poll;弹出队头
* offer:在队尾加入新的元素
*
* 栈
* pop:弹出栈顶元素
* pull:栈顶加入元素
2.2.2 LinkedList
底层是双向链表还是循环链表?
//源码分析:双向连链表
transient LinkedList.Node<E> first;
transient LinkedList.Node<E> last;
private static class Node<E> {
E item;
LinkedList.Node<E> next;
LinkedList.Node<E> prev;
Node(LinkedList.Node<E> prev, E element, LinkedList.Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
2.2.3 LinkedList
扩容
由于它的底层是用双向链表实现的,没有初始化大小,也没有扩容的机制
LinkedList 是链表,所以 LinkedList 的新增也就是链表的数据新增了,这时候要根据要插入的位置的区分操作。
二分查找目标位置,然后进行删除操作
2.2.4 Stack,ArrayDeque,LinkedList的区别
都可以实现 栈
* 1. 三者都是 Collection的间接实现类。
ArrayDeque实现Deque接口
Stack继承于Vector
LinkedList实现Deque与List接口
* 2. Stack底层是长度为 10的数组,ArrayDeque底层是长度为 16的数组,LinkedList底层是链表
* 3. Stack是线程安全,其余均为线程不安全。
* 4. 频繁的插入、删除操作:LinkedList
* 频繁的随机访问操作:ArrayDeque
* 未知的初始数据量:LinkedList
2.3 Set
HashSet 和 HashMap 的区别?
-
HashMap是实现了Map接口,存储的是键值对;HashSet 是实现了Set接口,只存储对象。
-
HashMap 使用键来计算哈希值;HashSet 是使用成员对象来计算哈希值;
-
HashMap 比 HashSet 快。
-
HashSet 的底层其实是基于 HashMap 实现的,大部分方法都是直接调用 HashMap中的方法。
HashSet的少数方法是自己实现的:
3 Map
3.1 HahsMap
3.1.1 四种构造函数
//源码分析
static final int DEFAULT_INITIAL_CAPACITY = 16;//初始大小
static final float DEFAULT_LOAD_FACTOR = 0.75F;//加载因子,这两个常量的值都是经过大量的计算和统计得出来的最优解
static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量 2的30次方
int threshold;// threshold = 哈希桶.length * loadFactor
final float loadFactor;
transient HashMap.Node<K, V>[] table;//Java的serialization提供了一种持久化对象实例的机制。
//为了在一个特定对象的一个域上关闭serialization,可以在这个域前加上关键字transient。
//构造方法,一共有四种构造方法,最后一种是拷贝map到新的map中
public HashMap() {
this.loadFactor = 0.75F;//此构造只做了一件事,那就是给负载因子赋初始值:0.75
}
public HashMap(int initialCapacity) {
this(initialCapacity, 0.75F);
}
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0) {
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
} else {
if (initialCapacity > 1073741824) {
initialCapacity = 1073741824;
}
if (loadFactor > 0.0F && !Float.isNaN(loadFactor)) {
this.loadFactor = loadFactor;
// 返回大于输入参数且最近的2的整数次幂的数
// 为什么不是this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;?
// 在构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,
// 在put方法中调用resize()会对threshold重新计算。
this.threshold = tableSizeFor(initialCapacity);
} else {
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
}
}
}
//返回实际的2的幂形式
private static final int tableSizeFor(int c) {
// 让cap-1再赋值给n的目的是另找到的目标值大于或等于原值。
//比如8。如果不对它减1而直接操作,将得到答案10000,减1后二进制为111,再进行操作则会得到原来的数值1000,即8。
int n = c - 1;
// >>>表示无符号右移,即若该数为正,则高位补0,比如7的二进制是111,7>>>2表示右移2位,变成001,即为1
// 先让n 右移,再按位或,该算法让最高位的1后面的位全变为1,最后的+1操作就变成了 100000这种2的某个幂次方
n |= n >>> 1;
//如果移多了,全部变成了0,按位或之后还是自己,所以就找到了离自己最近的2的幂次方
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
// MAXIMUM_CAPACITY = 1073741824
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
补充:为什么table哈希桶要用transient修饰
// 在序列化的时候会调用这个函数来进行序列化,这里面将集合中的元素一个个的写入到文件中。
private void writeObject(java.io.ObjectOutputStream s)
table 以及 elementData
中存储的值数量是小于数组的大小,如果使用默认的序列化,那些没有元素的位置也会被存储,就会产生很多不必要的浪费。- 对于HashMap来说,由于不同的虚拟机对于相同hashCode产生的Code值可能是不一样的,(hashcode的值是对象在内存的地址算出来的,不同的程序运行同一个对象,因为内存地址不一样,生成的hashcode当然不一样。)如果你使用默认的序列化,那么反序列化后,元素的位置和之前的是保持一致的,可是由于hashCode的值不一样了,那么定位函数
indexOf()
返回的元素下标就会不同,这样不是我们所想要的结果
HashMap.Node
类详解
// Node<K,V>类里有一个Node<K,V> next。那以Node<K,V>.next.next.next这种结构形式存储元素就是所说的链表
// 所以Node<K,V>[] tab就是数组,tab所存储元素为每个链表的第一个元素。
static class Node<K, V> implements Entry<K, V> {
final int hash;
final K key;
V value;
HashMap.Node<K, V> next;
Node(int hash, K key, V value, HashMap.Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
3.1.2 put方法
为什么使用扰动函数
hashMap源码这里不只是直接获取哈希值,还进行了一次扰动计算,
(h = key.hashCode()) ^ (h >>> 16)
。把哈希值右移16位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了「随机性」
transient HashMap.Node<K, V>[] table;// 底层数据结构是数组称之为哈希桶,每个桶里面放的是链表
//散列值扰动函数,用于优化散列效果;
static final int hash(Object key) {
int h;
return key == null ? 0 : (h = key.hashCode()) ^ h >>> 16;
}
1 、实际的插入操作调用put函数,put函数内部调用putVal函数
public V put(K key, V value) {
//调用putVal()方法完成
return putVal(hash(key), key, value, false, true);
}
2 、判断哈希桶table是否为空或长度为0,否则初始化扩容操作操作
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
3 、根据哈希值计算下标,如果对应下标正好没有存放数据,则直接插入即可,否则进一步判断
理论上来说字符串的
hashCode
是一个int类型值,那可以直接作为数组下标了,且不会出现碰撞。但是这个hashCode
的取值范围是[-2147483648, 2147483647],有将近40亿的长度,谁也不能把数组初始化的这么大,内存也是放不下的。我们默认初始化的Map大小是16个长度
DEFAULT_INITIAL_CAPACITY = 1 << 4
,所以获取的Hash值并不能直接作为下标使用,需要与数组长度进行取模运算得到一个下标值,也就是我们上面做的散列列子。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
- 判断tab[i]是否为树节点,是则向树中插入节点。否则向链表中插入数据
else if (p instanceof HashMap.TreeNode) {
e = ((HashMap.TreeNode)p).putTreeVal(this, tab, hash, key, value);
}
- 如果链表中插入节点的时候,链表长度大于等于8,则需要把链表转换为红黑树。
//链表长度8,将链表转化为红黑树存储
if (binCount >= 7)
treeifyBin(tab, hash);
//`treeifyBin`,是一个链表转树的方法,但不是所有的链表长度为8后都会转成树,
//还需要判断存放key值的数组桶长度是否小于64 `MIN_TREEIFY_CAPACITY`。
if (tab != null && (n = tab.length) >= 64)
//如果小于则需要扩容,扩容后链表上的数据会被拆分散列的相应的桶节点上,也就把链表长度缩短了。
- 最后所有元素处理完成后,判断是否超过阈值;
threshold
,超过则扩容。
//判断是否需要扩容
if (++size > threshold)
resize();
HashMap是先插入还是先扩容:
HashMap初始化后首次插入数据时,先发生resize扩容再插入数据,之后每当插入的数据个数达到threshold时就会发生resize,此时是先插入数据再resize。
3.1.3 resize() 方法
- 扩容是容量翻倍
newThr = oldThr << 1
; - 扩容操作时,会new一个新的
Node
数组作为哈希桶,HashMap.Node<K, V>[] newTab = new HashMap.Node[newCap];
- 然后将原哈希表中的所有数据(
Node
节点)移动到新的哈希桶中,相当于对原哈希表中所有的数据重新做了一个put操作。
- 如果新位置没元素直接放:
newTab[e.hash & (newCap - 1)] = e
- 如果有元素并且节点数操作8,转红黑树:
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
- 原链表上的每个节点,
(e.hash & oldCap) == 0)
利用哈希值与
旧的容量,可以得到哈希值去模后,等于0代表小于oldCap,应该存放在低位,否则存放在高位。high位= low位+原哈希桶容量
transient HashMap.Node<K, V>[] table;// 底层数据结构是数组称之为哈希桶,每个桶里面放的是链表
final Node<K,V>[] resize() {
//oldTab 为当前表的哈希桶
Node<K,V>[] oldTab = table;
//当前哈希桶的容量 length
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//当前的阈值
int oldThr = threshold;
//初始化新的容量和阈值为0
int newCap, newThr = 0;
//如果当前容量大于0
if (oldCap > 0) {
if (oldCap >= 1073741824) {
this.threshold = 2147483647;
return oldTab;
}
if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
//否则新的容量为旧的容量的两倍。
newThr = oldThr << 1;
}else if (oldThr > 0) {
newCap = oldThr; //如果当前表是空的,但是有阈值,初始化时指定了容量、阈值的情况,新表的容量就等于旧的阈值
} else {
newCap = 16;
newThr = 12;
}
if (newThr == 0) {//如果新的阈值是0,对应的是 当前表是空的,但是有阈值的情况
float ft = (float)newCap * loadFactor;//根据新表容量 和 加载因子 求出新的阈值
//进行越界修复
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//更新阈值
this.threshold = newThr;
HashMap.Node<K, V>[] newTab = new HashMap.Node[newCap];
this.table = newTab;
//如果以前的哈希桶中有元素
//下面开始将当前哈希桶中的所有节点转移到新的哈希桶中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) { //遍历老的哈希桶
Node<K,V> e;//取出当前的节点 e
if ((e = oldTab[j]) != null) { //如果当前桶中有元素,则将链表赋值给e
oldTab[j] = null;//将原哈希桶置空以便GC
if (e.next == null){//如果当前链表中就一个元素,直接将这个元素放置在新的哈希桶里。
newTab[e.hash & (newCap - 1)] = e;
}else if (e instanceof TreeNode){//如果发生过哈希碰撞 ,而且是节点数超过8个,转化成了红黑树
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
}else {
//原链表上的每个节点,可能存放在原来的下标low位, 或者扩容后的下标high位。 high位= low位+原容量
//低位链表的头结点、尾节点
Node<K,V> loHead = null, loTail = null;
//高位链表的头节点、尾节点
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;//临时节点 存放e的下一个节点
do {
next = e.next;
//利用哈希值 与 旧的容量,可以得到哈希值去模后,是大于等于oldCap还是小于oldCap,等于0代表小于oldCap,应该存放在低位,否则存放在高位
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);//循环直到链表结束
//将低位链表存放在原index处,
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//将高位链表存放在新index处
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
3.1.4 为什么hashmap
是线程不安全的?
1)数据插入覆盖
HashMap底层是一个Entry数组。当发生hash冲突的时候,hashmap是采用链表的方式来解决的,在对应的数组位置存放链表的头结点。对链表而言,新加入的节点会从头结点加入。此实现不是同步的。如果多个线程同时访问一个哈希映射第六行代码是判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
3.1.5 底层为什么要用红黑树
红黑树虽然本质上是一棵二叉查找树,但它在二叉查找树的基础上增加了着色和相关的性质使得红黑树相对平衡,从而保证了红黑树的查找、插入、删除的时间复杂度最坏为O(log n)。加快检索速率。红黑树相比avl树,在检索的时候效率其实差不多,都是通过平衡来二分查找。但对于插入删除等操作效率提高很多。红黑树不像avl树一样追求绝对的平衡,他允许局部很少的不完全平衡,这样对于效率影响不大,但省去了很多没有必要的调平衡操作,avl树调平衡有时候代价较大,所以效率不如红黑树。
因为一开始数据存数组如果发生hash冲突,这个时候需要把冲突的数据放到后面的链表中(链地址法),如果hash冲突的数据过多,就会让链表过长,查询效率会变低,所以jdk1.8之后当链表长度大于8时就是转化为红黑树。其中换会牵涉到一个数组扩容,
为什么是红黑树?为什么不直接采用红黑树还要用链表?
- 因为红黑树需要进行左旋,右旋操作, 而单链表不需要,
如果元素小于8个,查询成本高,新增成本低
如果元素大于8个,查询成本低,新增成本高
3.1.6 如果要存放1000个键值对,初始化多大的hashmap不需要动态扩容呢?
table.size == threshold * loadFactor
构造方法传递的 initialCapacity,最终会被 tableSizeFor() 方法动态调整为 2 的 N 次幂,以方便在扩容的时候,计算数据在 newTable 中的位置。
若 thresholdNew * 0.75 > 1000,则 thresholdNew > 1333.3。
而我们上面分析构造传1000的时候,thresholdNew 会被 tableSizeFor() 调整为 1024,1024 < 1333.3不满足。
又我们知道了 tableSizeFor() 这个方法返回大于输入参数且最接近的2的整数次幂的数,则我们构造时传入1024~2048之间的数,就会保证HashMap存1000条数据不需要动态扩容。
3.1.7 HashMap在JDK1.7和JDK1.8中有哪些不同?
不同 | JDK 1.7 | JDK 1.8 |
---|---|---|
存储结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
初始化方式 | 单独函数:inflateTable() | 直接集成到了扩容函数resize() 中 |
hash值计算方式 | 扰动处理 = 9次扰动 = 4次位运算 + 5次异或运算 | 扰动处理 = 2次扰动 = 1次位运算 + 1次异或运算 |
存放数据的规则 | 无冲突时,存放数组;冲突时,存放链表 | 无冲突时,存放数组;冲突 & 链表长度 < 8:存放单链表;冲突 & 链表长度 > 8:树化并存放红黑树 |
插入数据方式 | 头插法(先讲原位置的数据移到后1位,再插入数据到该位置) | 尾插法(直接插入到链表尾部/红黑树) |
扩容后存储位置的计算方式 | 全部按照原来方法进行计算(即hashCode ->> 扰动函数 ->> (h&length-1)) | 按照扩容后的规律计算(即扩容后的位置=原位置 or 原位置 + 旧容量) |
4 CurrentHashMap
Hashtable,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞
public Hashtable() {
this(11, 0.75F);
}
public synchronized int size() {
return this.count;
}
4.1 currentHashMap 在java1.7中的实现
在JDK7的时候,这种安全策略采用的是分段锁的机制,ConcurrentHashMap维护了一个Segment数组,Segment这个类继承了重入锁ReentrantLock,并且该类里面维护了一个 HashEntry<K,V>[] table数组。前者用来封装映射表的键值对,后者用来充当锁的角色;
-
当进行写操作(put,remove,扩容)的时候,只需要对这个key对应的Segment进行加锁操作,只是针对put方法进行了加锁,而对于get方法并没有采用加锁的操作加锁同时不会对其他的Segment造成影响。
-
总的Map包含了16个Segment(默认数量),每个Segment内部包含16个HashEntry(默认数量)。
static final class HashEntry<K,V> {
final K key;
final int hash;
volatile V value; //为了确保读操作能够看到最新的值,将value设置成volatile,这避免了加锁。
final HashEntry<K,V> next; //意味着不能从hash链的中间或尾部添加或删除节点,所有的节点的修改只能从头部开始
}
4.1.1 Segment
Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile int count; //Segment中元素的数量,每次修改操作做了结构上的改变,如增加/删除节点(修改节点的值不算结构上的改变),都要写count值,每次读取操作开始都要读取count的值。
transient int modCount; //对table的大小造成影响的操作的数量(比如put或者remove操作)
transient int threshold;
transient volatile HashEntry<K,V>[] table; //链表数组,数组中的每一个元素代表了一个链表的头部
final float loadFactor;
}
初始化
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
//concurrentLevel:ConcurrentHashMap内部的Segment的数量,ConcurrentLevel一经指定,不可改变,ConrruentHashMap需要扩容,不会增加Segment的数量,而只会增加Segment中链表数组的容量大小
//1.根据concurrentLevel来new出Segment,这里Segment的数量是不大于concurrentLevel的最大的2的指数
//2.根据intialCapacity确定Segment的容量的大小,每一个Segment的容量大小也是2的指数,同样使为了加快hash的过程。
4.1.2 具体操作
get不加锁
1.为输入的Key做Hash运算,得到hash值。
2.通过hash值,定位到对应的Segment对象
3.再次通过hash值,定位到Segment当中数组的具体位置。
public V get(Object key) {
int hash = hash(key.hashCode()); //二次hash,减少哈希冲突,
return segmentFor(hash).get(key, hash); //确定操作应该在哪一个segment中进行
}
//具体的实现采用乐观锁的方式,volitle实现
put操作
1.为输入的Key做Hash运算,得到hash值。
2.通过hash值,定位到对应的Segment对象
3.获取可重入锁
4.再次通过hash值,定位到Segment当中数组的具体位置。
5.插入或覆盖HashEntry对象。
6.释放锁。
V put(K key, int hash, V value, boolean onlyIfAbsent) {
lock();
//Segment的put操作是加锁完成的,
//如果Segment中元素的数量超过了阈值,这需要进行对Segment扩容,并且要进行rehash,
//while循环遍历key,如果找到,就直接更新更新key的value,如果没有找到,则生成一个新的HashEntry并且把它加到整个Segment的头部,然后再更新count的值。
4.2 currentHashMap 在java1.8中的实现
放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现,
从put操作开始讲起,在 HashMap 中这一步操作是很简单的,因为是单线程操作直接初始化就可以了。但是在 ConcurrentHashMap 中就需要考虑并发问题了,因为有可能有多个线程同时 put 元素。这里就用到volatile 和 CAS 原子操作。每个线程初始化数组之前都会先获取到 volatile 修饰的sizeCtl 变量,只有设置了这个变量的值才可以初始化数组,同时数组也是由 volatile 修饰的,以便修改后能被其他线程及时察觉。
static final int MOVED = -1; // 表示正在转移
transient volatile Node<K,V>[] table;//默认没初始化的数组,用来保存元素
private transient volatile Node<K,V>[] nextTable;//转移的时候用的数组
/**
* sizeCtl :默认为0,用来控制table的初始化和扩容操作,具体应用在后续会体现出来。
* -1 代表table正在初始化
* -N 表示有N-1个线程正在进行扩容操作
* 其余情况:
* 1、如果table未初始化,表示table需要初始化的大小。
* 2、如果table初始化完成,表示table的容量,默认是table大小的0.75倍
*/
private transient volatile int sizeCtl;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //key的hash值
final K key; //key
volatile V val; //value
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(); }
}
4.2.1 put操作
- 判断保存这些键值对的数组是不是初始化了,如果没有的话就初始化数组,ConcurrentHashMap 不支持 null 键和 null 值。
- 计算hash值来确定放在数组的哪个位置
- 为空则直接调用 Unsafe 封装好的 CAS 方法插入设置元素,如果不为空的话,则取出这个节点来
- 如果取出来的节点的hash值是MOVED(-1)的话,则表示正在迁移,当前线程要帮忙迁移扩容
- 如果这个节点,不为空,也不在扩容,则通过synchronized来加锁,进行添加操作
- HashMap的后续put操作
ConcurrentHashMap 在 put 过程中,采用了哪些手段来保证线程安全呢?
**数组初始化时的线程安全 **while((tab = this.table) == null || tab.length == 0) {
数组初始化时,首先通过自旋来保证一定可以初始化成功,然后通过 CAS 设置 SIZECTL 变量的值,来保证同一时刻只能有一个线程对数组进行初始化,CAS 成功之后,还会再次判断当前数组是否已经初始化完成,如果已经初始化完成,就不会再次初始化,通过自旋 + CAS + 双重 check 等手段保证了数组初始化时的线程安全
//put:put——putVal
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();//K,V都不能为空,否则的话跑出异常
int hash = spread(key.hashCode()); //取得key的hash值
int binCount = 0; //用来计算在这个节点总共有多少个元素,用来控制扩容或者转移为树
ConcurrentHashMap.Node[] tab = this.table;
while(true) {
int n;
while(tab == null || (n = tab.length) == 0) { //第一次put的时候table没有初始化,则初始化table
tab = this.initTable();
}
ConcurrentHashMap.Node f;
int i;
// 如果hash位置相应位置的Node值为空,则调用CAS插入相应的数据;
if ((f = tabAt(tab, i = n - 1 & hash)) == null) {
//cas的方式尝试添加Node节点,只有当前的i位置的变量是null的时候,才会插入Node节点
if (casTabAt(tab, i, (ConcurrentHashMap.Node)null,
new ConcurrentHashMap.Node(hash, key, value))) {
break;
}
} else {
int fh;
//如果检测到某个节点的hash值是MOVED,则表示正在进行数组扩张的数据复制阶段
if ((fh = f.hash) == -1) {
tab = this.helpTransfer(tab, f);//则当前线程也会参与去复制
} else {
Object fk;
Object fv;
if (onlyIfAbsent && fh == hash && ((fk = f.key) == key || fk != null && key.equals(fk)) && (fv = f.val) != null) {
return fv;
}
synchronized (f) {//进行put操作}
}
}
}
addCount(1L, binCount); //计数
return null;
}
private final ConcurrentHashMap.Node<K, V>[] initTable() {
ConcurrentHashMap.Node[] tab;
while((tab = this.table) == null || tab.length == 0) {
int sc;
if ((sc = this.sizeCtl) < 0) {
Thread.yield();
} else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = this.table) == null || tab.length == 0) {
int n = sc > 0 ? sc : 16;
ConcurrentHashMap.Node<K, V>[] nt = new ConcurrentHashMap.Node[n];
tab = nt;
this.table = nt;
sc = n - (n >>> 2);
}
break;
} finally {
this.sizeCtl = sc;
}
}
}
return tab;
}
//Unsafe方法
static final <K, V> ConcurrentHashMap.Node<K, V> tabAt(ConcurrentHashMap.Node<K, V>[] tab, int i) {
return (ConcurrentHashMap.Node)U.getObjectAcquire(tab, ((long)i << ASHIFT) + (long)ABASE);
}
static final <K, V> boolean casTabAt(ConcurrentHashMap.Node<K, V>[] tab, int i, ConcurrentHashMap.Node<K, V> c, ConcurrentHashMap.Node<K, V> v) {
return U.compareAndSetObject(tab, ((long)i << ASHIFT) + (long)ABASE, c, v);
}
static final <K, V> void setTabAt(ConcurrentHashMap.Node<K, V>[] tab, int i, ConcurrentHashMap.Node<K, V> v) {
U.putObjectRelease(tab, ((long)i << ASHIFT) + (long)ABASE, v);
}
4.2.2 相关概念
在每次添加完元素的addCount方法中,也会判断当前数组中的元素是否达到了sizeCtl的量,如果达到了的话,则会进入transfer方法去扩容
- ForwardingNode类
当进行扩容时,要把链表迁移到新的哈希表,在做这个操作时,会在把数组中的头节点替换为 ForwardingNode 对象。ForwardingNode 中不保存 key 和 value,只保存了扩容后哈希表(nextTable)的引用。此时查找相应 node 时,需要去 nextTable 中查找。
1、标记作用,表示其他线程正在扩容,并且此节点已经扩容完毕
2、关联了nextTable,扩容期间可以通过find方法,访问已经迁移到了nextTable中的数据
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
//hash值为MOVED(-1)的节点就是ForwardingNode
super(MOVED, null, null, null);
this.nextTable = tab;
}
//通过此方法,访问被迁移到nextTable中的数据
Node<K,V> find(int h, Object k) {
...
}
}
- transferIndex属性:扩容索引,表示已经分配给扩容线程的table数组索引位置。主要用来协调多个线程,并发安全地获取迁移任务(hash桶)
private transient volatile int transferIndex; /** 扩容线程每次最少要迁移16个hash桶 */ private static final int MIN_TRANSFER_STRIDE = 16;
1 在扩容之前,transferIndex 在数组的最右边 。此时有一个线程发现已经到达扩容阈值,准备开始扩容。
2 扩容线程,在迁移数据之前,首先要将transferIndex左移(以cas的方式修改 transferIndex=transferIndex-stride(要迁移hash桶的个数)),获取迁移任务。每个扩容线程都会通过for循环+CAS的方式设置transferIndex,因此可以确保多线程扩容的并发安全。
cas设置transferIndex的源码如下:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) { //计算每次迁移的node个数 if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) stride = MIN_TRANSFER_STRIDE; // 确保每次迁移的node个数不少于16个 ... for (int i = 0, bound = 0;;) { ... //cas无锁算法设置 transferIndex = transferIndex - stride if (U.compareAndSwapInt (this, TRANSFERINDEX, nextIndex, nextBound = (nextIndex > stride ? nextIndex - stride : 0))) { ... ... } ...//省略迁移逻辑 } }
4.2.3 何时扩容
1 当前容量超过阈值
2 当链表中元素个数超过默认设定(8个),数组的大小还未超过64,此时进行数组的扩容,如果超过则将链表转化成红黑树
3 当发现其他线程扩容时,帮其扩容
1 当前容量超过阈值
final V putVal(K key, V value, boolean onlyIfAbsent) { ... addCount(1L, binCount); ... }
private final void addCount(long x, int check) { ... if (check >= 0) { Node<K,V>[] tab, nt; int n, sc; //s>=sizeCtl 即容量达到扩容阈值,需要扩容 while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) { //调用transfer()扩容 ... } } }
2 当链表中元素个数超过默认设定(8个),当数组的大小还未超过64的时候,此时进行数组的扩容,如果超过则将链表转化成红黑树
final V putVal(K key, V value, boolean onlyIfAbsent) { ... if (binCount != 0) { //链表中元素个数超过默认设定(8个) if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } ... }
private final void treeifyBin(Node<K,V>[] tab, int index) { Node<K,V> b; int n, sc; if (tab != null) { //数组的大小还未超过64 if ((n = tab.length) < MIN_TREEIFY_CAPACITY) //扩容 tryPresize(n << 1); else if ((b = tabAt(tab, index)) != null && b.hash >= 0) { //转换成红黑树 ... } } }
3 当发现其他线程扩容时,帮其扩容
final V putVal(K key, V value, boolean onlyIfAbsent) { ... //f.hash == MOVED 表示为:ForwardingNode,说明其他线程正在扩容 else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); ... }
4.2.4 扩容过程分析
ConcurrentHashMap 的扩容时机和 HashMap 相同,都是在 put 方法的最后一步检查是否需要扩容,如果需要则进行扩容,但两者扩容的过程完全不同,ConcurrentHashMap 扩容的方法叫做 transfer,从 put 方法的 addCount 方法进去,就能找到 transfer 方法,transfer 方法的主要思路是:
1.首先需要把老数组的值全部拷贝到扩容之后的新数组上,先从数组的队尾开始拷贝;
2.拷贝数组的槽点时,先把原数组槽点锁住,保证原数组槽点不能操作,成功拷贝到新数组时,把原数组槽点赋值为转移节点;
3.这时如果有新数据正好需要 put 到此槽点时,发现槽点为转移节点,就会一直等待,所以在扩容完成之前,该槽点对应的数据是不会发生变化的;
4.从数组的尾部拷贝到头部,每拷贝成功一次,就把原数组中的节点设置成转移节点;
5.直到所有数组数据都拷贝到新数组时,直接把新数组整个赋值给数组容器,拷贝完成。
1、线程执行put操作,发现容量已经达到扩容阈值,需要进行扩容操作,此时transferindex=tab.length=32
2、扩容线程A 以cas的方式修改transferindex=31-16=16 ,然后按照降序迁移table[31]–table[16]这个区间的hash桶
3、迁移hash桶时,会将桶内的链表或者红黑树,按照一定算法,拆分成2份,将其插入nextTable[i]和nextTable[i+n](n是table数组的长度)。 迁移完毕的hash桶,会被设置成ForwardingNode节点,以此告知访问此桶的其他线程,此节点已经迁移完毕。
相关代码如下:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
...//省略无关代码
synchronized (f) {
//将node链表,分成2个新的node链表
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
//将新node链表赋给nextTab
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);//迁移完毕的桶设置为fwd,表示迁移完毕
}
...//省略无关代码
}
4、此时线程2访问到了ForwardingNode节点,如果线程2执行的put或remove等写操作,那么就会先帮其扩容。如果线程2执行的是get等读方法,则会调用ForwardingNode的find方法,去nextTable里面查找相关元素。
5、线程2加入扩容操作
6、如果准备加入扩容的线程,发现以下情况,放弃扩容,直接返回。
- 发现transferIndex=0,即所有node均已分配
- 发现扩容线程已经达到最大扩容线程数
4.2.5 部分源码分析
tryPresize方法
协调多个线程如何调用transfer方法进行hash桶的迁移(addCount,helpTransfer 方法中也有类似的逻辑)
/**
* 扩容表为指可以容纳指定个数的大小(总是2的N次方)
* 假设原来的数组长度为16,则在调用tryPresize的时候,size参数的值为16<<1(32),此时sizeCtl的值为12
*/
private final void tryPresize(int size) {
/*
* MAXIMUM_CAPACITY = 1 << 30 = 1073741824
* 如果给定的大小大于等于数组容量的一半,则直接使用最大容量,
* 否则使用tableSizeFor算出来,tableSizeFor()返回值是入参的二倍
*/
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
while ((sc = sizeCtl) >= 0) { // while循环来进行扩容
Node<K,V>[] tab = table; int n;
/*
* 如果数组table还没有被初始化,则初始化一个大小为sizeCtrl和刚刚算出来的c中较大的一个大小的数组
* 初始化的时候,设置sizeCtrl为-1,初始化完成之后把sizeCtrl设置为数组长度的3/4
* 为什么要在扩张的地方来初始化数组呢?这是因为调用putAll方法直接put一个map的话,在putALl方法中没有调用initTable方法去初始化table,而是直接调用了tryPresize方法,所以这里需要做一个是不是需要初始化table的判断
*/
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { //初始化tab的时候,把sizeCtl设为-1
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; // 扩容一个长度是n的新数组
table = nt; // 把新数组赋值给table变量
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY) {
break;
}
else if (tab == table) {
int rs = resizeStamp(n);
if (sc < 0) {
Node<K,V>[] nt;
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
// 开始转移数据
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2)) {
// 开始转移数据
transfer(tab, null);
}
}
}
}
transfer方法,负责迁移node节点
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//计算需要迁移多少个hash桶(MIN_TRANSFER_STRIDE该值作为下限,以避免扩容线程过多)
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating
try {
//扩容一倍
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
//1 逆序迁移已经获取到的hash桶集合,如果迁移完毕,则更新transferIndex,获取下一批待迁移的hash桶
//2 如果transferIndex=0,表示所以hash桶均被分配,将i置为-1,准备退出transfer方法
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
//更新待迁移的hash桶索引
while (advance) {
int nextIndex, nextBound;
//更新迁移索引i。
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
//transferIndex<=0表示已经没有需要迁移的hash桶,将i置为-1,线程准备退出
i = -1;
advance = false;
}
//当迁移完bound这个桶后,尝试更新transferIndex,,获取下一批待迁移的hash桶
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
//退出transfer
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
//最后一个迁移的线程,recheck后,做收尾工作,然后退出
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
/**
第一个扩容的线程,执行transfer方法之前,会设置 sizeCtl = (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2)
后续帮其扩容的线程,执行transfer方法之前,会设置 sizeCtl = sizeCtl+1
每一个退出transfer的方法的线程,退出之前,会设置 sizeCtl = sizeCtl-1
那么最后一个线程退出时:
必然有sc == (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2),即 (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT
*/
//不相等,说明不到最后一个线程,直接退出transfer方法
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
//最后退出的线程要重新check下是否全部迁移完毕
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
//迁移node节点
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
//链表迁移
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
//将node链表,分成2个新的node链表
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
//将新node链表赋给nextTab
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
//红黑树迁移
else if (f instanceof TreeBin) {
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
4.3 面试题
concurrentHashMap的实现原理是什么?
concurrentHashMap的put方法执行逻辑?
整体流程跟HashMap比较类似,大致是以下几步:
(1)如果桶数组未初始化,则初始化;
(2)如果待插入的元素所在的桶为空,则尝试把此元素直接插入到桶的第一个位置;
(3)如果正在扩容,则当前线程一起加入到扩容的过程中;
(4)如果待插入的元素所在的桶不为空且不在迁移元素,则锁住这个桶(分段锁);
(5)如果当前桶中元素以链表方式存储,则在链表中寻找该元素或者插入元素;
(6)如果当前桶中元素以红黑树方式存储,则在红黑树中寻找该元素或者插入元素;
(7)如果元素存在,则返回旧值;
(8)如果元素不存在,整个Map的元素个数加1,并检查是否需要扩容;
添加元素操作中使用的锁主要有(自旋锁 + CAS + synchronized + 分段锁)。
concurrentHashMap的get方法执行逻辑?
1、计算 hash 值
2、根据 hash 值找到数组对应位置: (n - 1) & h
3、根据该位置处结点性质进行相应查找如果该位置为 null,那么直接返回 null 就可以了
如果该位置处的节点刚好就是我们需要的,返回该节点的值即可如果该位置节点的 hash 值小于 0,说明正在扩容,或者是红黑树,后面我们再介绍 find 方法如果以上 3 条都不满足,那就是链表,进行遍历比对即可
ConcurrentHashMap默认初始容量是多少?
从下面ConcurrentHashMap类的静态变量可以看出它的初始容量为16
ConCurrentHashmap 每次扩容是原来容量的几倍
2倍 在transfer方法里面会创建一个原数组的俩倍的node数组来存放原数据。
4.3.1 ConcurrentHashMap 不支持 key 或者 value 为 null 的原因?
我们先来说value 为什么不能为 null。因为 ConcurrentHashMap 是用于多线程的 ,如果ConcurrentHashMap.get(key)得到了 null ,这就无法判断,是映射的value是 null ,还是没有找到对应的key而为 null ,就有了二义性。
而用于单线程状态的 HashMap 却可以用containsKey(key) 去判断到底是否包含了这个 null 。
我们用反证法来推理:
假设 ConcurrentHashMap 允许存放值为 null 的 value,这时有A、B两个线程,线程A调用ConcurrentHashMap.get(key)方法,返回为 null ,我们不知道这个 null 是没有映射的 null ,还是存的值就是 null 。
假设此时,返回为 null 的真实情况是没有找到对应的 key。那么,我们可以用 ConcurrentHashMap.containsKey(key)来验证我们的假设是否成立,我们期望的结果是返回 false 。
但是在我们调用 ConcurrentHashMap.get(key)方法之后,containsKey方法之前,线程B执行了ConcurrentHashMap.put(key, null)的操作。那么我们调用containsKey方法返回的就是 true 了,这就与我们的假设的真实情况不符合了,这就有了二义性。
4.3.2 JDK1.7 与 JDK1.8 中ConcurrentHashMap 的区别?
- 数据结构:取消了 Segment 分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
- 保证线程安全机制:JDK1.7 采用 Segment 的分段锁机制实现线程安全,其中 Segment 继承自 ReentrantLock 。JDK1.8 采用CAS+synchronized保证线程安全。
- 锁的粒度:JDK1.7 是对需要进行数据操作的 Segment 加锁,JDK1.8 调整为对每个数组元素加锁(Node)。
- 链表转化为红黑树:定位节点的 hash 算法简化会带来弊端,hash 冲突加剧,因此在链表节点数量大于 8(且数据总量大于等于 64)时,会将链表转化为红黑树进行存储。
- 查询时间复杂度:从 JDK1.7的遍历链表O(n), JDK1.8 变成遍历红黑树O(logN)。
4.3.3 ConcurrentHashMap 和 Hashtable 的效率哪个更高?为什么?
ConcurrentHashMap 的效率要高于 Hashtable,因为 Hashtable 给整个哈希表加了一把大锁从而实现线程安全。而ConcurrentHashMap 的锁粒度更低,在 JDK1.7 中采用分段锁实现线程安全,在 JDK1.8 中采用CAS+synchronized实现线程安全。
4 集合类不安全
4.1多线程修改异常
//当多个线程对集合进行操作的时候就会出现 多线程修改异常ConcurrentModificationException
for (int i = 0; i <=10; i++) {
new Thread(()->{
list.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(list);
},String.valueOf(i)).start();
}
4.2 应对集合类不安全的方式
/**
* 解决方案;
* 1、List<String> list = new Vector<>();
* 2、List<String> list = Collections.synchronizedList(new ArrayList<>());
* 3、List<String> list = new CopyOnWriteArrayList<>();
*/
4.2.1 集合转线程安全
1 、Vecotr底层的方法使用线程安全修饰,读写均加锁,锁的粒度太大,导致并发效率较低,因此不推荐使用
public synchronized void addElement(E obj) {
++this.modCount;
this.add(obj, this.elementData, this.elementCount);
}
public synchronized E get(int index) {
if (index >= this.elementCount) {
throw new ArrayIndexOutOfBoundsException(index);
} else {
return this.elementData(index);
}
}
2 、集合类方法,synchronizedList
其中定义了两个成员变量,Collections c集合和Object mutex对象锁。当对集合对象进行操作时,就会加上同步锁实现线程安全。但是方法在执行前需要竞争,通过synchronized竞争互斥锁,锁粒度同样较大
static class SynchronizedCollection<E> implements Collection<E>, Serializable {
private static final long serialVersionUID = 3053995032091335093L;
final Collection<E> c; // Backing Collection
final Object mutex; // Object on which to synchronize
public int size() {
synchronized (mutex) {return c.size();}
}
public void add(int index, E element) {
synchronized (mutex) {list.add(index, element);}
}
4.2.2 CopyOnWriteArrayList
Set同理,一致 CopyOnWriteArraySet
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
final transient ReentrantLock lock = new ReentrantLock();
private transient volatile Object[] array;
}
读不加锁
private E get(Object[] a, int index) {
return (E) a[index];
}
写:先复制一个数组再进行操作,写操作执行结束后再将复制后的数组赋值给原数组。
适合读多写少的应用场景,不适合内存敏感和对数据实时性要求高的场景。不足之处在于:
- 内存占用:复制的新数组需要占用内存空间
- 数据不一致:读操作不能读取实时的数据,即读操作不能读取到写操作还没有同步到源数组中的数据
public boolean add(E e) {
final ReentrantLock lock = this.lock; // 获取锁
lock.lock(); // 加锁
try {
Object[] elements = getArray(); // 获取属性字段定义的array
int len = elements.length; // 获取array的长度
Object[] newElements = Arrays.copyOf(elements, len + 1); // 获取数组的副本
newElements[len] = e; // 在副本上执行添加操作
setArray(newElements); // 将副本复制给原数组
return true;
} finally {
lock.unlock(); // 释放锁
}
}
final void setArray(Object[] a) {
array = a;
}
//jdk11之后的实现:疑惑
public boolean add(E e) {
synchronized(this.lock) {
Object[] es = this.getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1);
es[len] = e;
this.setArray(es);
return true;
}
}
其中的 setArray 方法中的 array 是用 volatile 修饰的,可以保证可见性:
同样,JUC 也有 HashMap, HashSet 对应线程安全的实现:
HashSet => CopyOnWriteArraySet
HashMap => ConcurrentHashMap
Vector和CopyOnWritearrayList
的方法对比:
4.3 阻塞队列
作用:多线程并发处理,线程池
4.3.1. Queue抽象接口
下面有三个接口
- Deque:双端队列,两端都可以取元素
- BlockingQueue:阻塞队列
ArrayBlockingQueue
:数组阻塞队列LinkedBlockingQueue
:链表阻塞队列SynchronousQueue
:同步队列
- AbstractQueue:非阻塞队列
4.3.2 阻塞队列的使用
区分在于当队列已经满了还添加元素,或者队列为空时还取元素,会如何反应
public static void main(String[] args) {
ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<Integer>(4);
for (int i = 0; i < 4; i++) {
queue.add(i);
}
//报错:java.lang.IllegalStateException: Queue full
queue.add(5);
}
4.3.3 同步队列
放 put,取take
和其他的BlockingQueue 不一样, SynchronousQueue 不存储元素 , put了一个元素,必须从里面先take取出来,否则不能在put进去值!
public void test() throws InterruptedException {
SynchronousQueue<Integer> synQueue = new SynchronousQueue<>();
synQueue.put(1);
System.out.println("1添加成功");
//一直在阻塞,元素放不进去
synQueue.put(2);
}
5 Java集合常见面试题
1、ArrayList和LinkedList的区别
2、ArrayList扩容机制
3、HashMap,HashTable,ConcurrentHashMap
4、 HashMap和HashTable的区别
5、 HashMap的底层实现
6、HashMap的扩容机制
7、HashMap为什么是线程不安全的
8、为什么要用红黑树,为什么不用其他平衡二叉树
9、 HashMap和ConcurrentHashMap区别
10、 ConcurrentHashMap1.7和1.8的区别,怎么实现线程安全的
11、知道HashMap 扩容时候的死循环问题吗?
HashMap 1.7 插入数据时,使用的是头插法,并发下扩容时的Rehash,会出现死循环问题;
而 HashMap 1.8 插入数据时,改成了尾插法,解决了扩容时的死循环问题。
12、什么是快速失败(fast-fail)机制?
快速失败是Java集合的一种错误检测机制,当多个线程对集合进行结构上的改变的操作时,有可能会产生fail-fast。
例如:假设存在两个线程(线程1、线程2),线程1通过Iterator在遍历集合A中的元素,在某个时候,线程2 修改了集合A的结构(是结构上面的修改,而不是简单的修改集合元素的内容),那么这个时候程序就可能会抛出 ConcurrentModificationException异常,从而产生fast-fail快速失败。
而迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedModCount值,是的话就返回遍历;否则抛出异常,终止遍历。
可以看下ArrayList中的源码:
那么如何解决这种问题?
- 在遍历过程中,所有涉及到改变modCount值得地方全部加上synchronized。
- 使用 JUC 中的线程安全类来替代,比如使用 CopyOnWriteArrayList 来替代 ArrayList ,使用ConcurrentHashMap 来替代 HashMap
13、Array和ArrayList有何区别?什么时候更适合用Array?
Array可以容纳基本类型和对象,而ArrayList只能容纳对象。
Array是指定大小的,而ArrayList大小是固定的。
Array没有提供ArrayList那么多功能,比如addAll、removeAll和iterator等。尽管ArrayList明显是更好的选择,但也有些时候Array比较好用。
(1)如果列表的大小已经指定,大部分情况下是存储和遍历它们。
(2)对于遍历基本数据类型,尽管Collections使用自动装箱来减轻编码任务,在指定大小的基本类型的列表上工作也会变得很慢。
(3)如果你要使用***数组,使用[][]比List<List<>>更容易。
Arraylist 与 LinkedList 区别?
- 是否保证线程安全:
ArrayList
和LinkedList
都是不同步的,也就是不保证线程安全;- 底层数据结构:
Arraylist
底层使用的是Object
数组;LinkedList
底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!)- 插入和删除是否受元素位置的影响: ①
ArrayList
采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)
方法的时候,ArrayList
会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)
)时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 ②LinkedList
采用链表存储,所以对于add(E e)
方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置i
插入和删除元素的话((add(int index, E element)
) 时间复杂度近似为o(n))
因为需要先移动到指定位置再插入。- 是否支持快速随机访问:
LinkedList
不支持高效的随机元素访问,而ArrayList
支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)
方法)。- 内存空间占用: ArrayList 的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
补充内容:RandomAccess 接口
public interface RandomAccess { }Copy to clipboardErrorCopied
RandomAccess
接口不过是一个标识罢了。标识什么? 标识实现这个接口的类具有随机访问功能。在
binarySearch()
方法中,它要判断传入的 list 是否RamdomAccess
的实例,如果是,调用indexedBinarySearch()
方法,如果不是,那么调用iteratorBinarySearch()
方法public static <T> int binarySearch(List<? extends Comparable<? super T>> list, T key) { if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD) return Collections.indexedBinarySearch(list, key); else return Collections.iteratorBinarySearch(list, key); }Copy to clipboardErrorCopied
ArrayList
实现了RandomAccess
接口, 而LinkedList
没有实现。
HashMap 和 Hashtable 的区别
- 线程是否安全:
HashMap
是非线程安全的,HashTable
是线程安全的,因为HashTable
内部的方法基本都经过synchronized
修饰。(如果你要保证线程安全的话就使用ConcurrentHashMap
吧!);- 效率: 因为线程安全的问题,
HashMap
要比HashTable
效率高一点。另外,HashTable
基本被淘汰,不要在代码中使用它;- 对 Null key 和 Null value 的支持:
HashMap
可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;HashTable 不允许有 null 键和 null 值,否则会抛出NullPointerException
。- 初始容量大小和每次扩充容量大小的不同 : ① 创建时如果不指定容量初始值,
Hashtable
默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而HashMap
会将其扩充为 2 的幂次方大小(HashMap
中的tableSizeFor()
方法保证,下面给出了源代码)。也就是说HashMap
总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。- 底层数据结构: JDK1.8 以后的
HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
HashSet如何检查重复
当你把对象加入
HashSet
时,HashSet
会先计算对象的hashcode
值来判断对象加入的位置,同时也会与其他加入的对象的hashcode
值作比较,如果没有相符的hashcode
,HashSet
会假设对象没有重复出现。但是如果发现有相同hashcode
值的对象,这时会调用equals()
方法来检查hashcode
相等的对象是否真的相同。如果两者相同,HashSet
就不会让加入操作成功。
hashCode()
与equals()
的相关规定:
- 如果两个对象相等,则
hashcode
一定也是相同的- 两个对象相等,对两个
equals()
方法返回 true- 两个对象有相同的
hashcode
值,它们也不一定是相等的综上,如果一个类的
equals()
方法被覆盖过,则hashCode()
方法也必须被覆盖。
hashCode()
的默认⾏为是对堆上的对象产⽣独特值。如果没有重写hashCode()
,即使通过equals()
判断为相同的两个对象,在加入HashSet
时,也不会被HashSet
认为是重复对象。
==与 equals 的区别
对于基本类型来说,== 比较的是值是否相等;
对于引用类型来说,== 比较的是两个引用是否指向同一个对象地址(两者在内存中存放的地址(堆内存地址)是否指向同一个地方);
对于引用类型(包括包装类型)来说,equals 如果没有被重写,对比它们的地址是否相等;如果 equals()方法被重写(例如 String),则比较的是地址里的内容。
集合框架底层数据结构总结
List
Arraylist
:Object[]
数组Vector
:Object[]
数组LinkedList
: 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)Set
HashSet
(无序,唯一): 基于HashMap
实现的,底层采用HashMap
来保存元素LinkedHashSet
:LinkedHashSet
是HashSet
的子类,并且其内部是通过LinkedHashMap
来实现的。有点类似于我们之前说的LinkedHashMap
其内部是基于HashMap
实现一样,不过还是有一点点区别的TreeSet
(有序,唯一): 红黑树(自平衡的排序二叉树)Map
HashMap
: JDK1.8 之前HashMap
由数组+链表组成的。JDK1.8 以后数组+链表+红黑树LinkedHashMap
:LinkedHashMap
继承自HashMap
,数组和链表或红黑树组成。另外,LinkedHashMap
在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。Hashtable
: 数组+链表组成的,数组是HashMap
的主体,链表则是主要为了解决哈希冲突而存在的TreeMap
: 红黑树(自平衡的排序二叉树)
致谢
以上内容不光是自己面试的总结,也参考了许多文章,由于跨度太久,没办法一一列举,在这里像你们表示感谢!!!