单列集合分为:list和set
- list系列集合:添加的元素有序,可重复,有索引,可以随机元素访问,但是在头部插入删除比较慢。
- set系列集合:添加的元素无序,不重复,无索引,不支持随机元素访问,但是在数组头部和尾部插入删除比较快。
collection方法?
单列集合的顶层接口,既然是接口就不能直接使用,需要通过实现类!~
常用方法
-
方法名 说明
-
boolean add(E e) 添加元素到集合的末尾(追加)
-
boolean remove(Object o) 删除指定的元素,成功则返回true(底层调用equles)
-
void clear() 清空集合
-
boolean contains(Object o) 判断元素在集合中是否存在,存在则返回true(底层调用equles)
-
boolean isEmpty() 判断集合是否为空,空则返回true
-
int size() 返回集合中元素个数
实现方式 -
多态实现
//父类的引用指向子类的对象,形成多态
Collection<String> con = new ArrayList<>();
遍历方式
- 使用迭代器遍历
//迭代器,集合专属的遍历工具
Iterator<String> it = con.iterator();//创建迭代器对象
while (it.hasNext()){//判断下一个位置是否有元素
System.out.print(it.next() + "\t");//获取到下一个位置的元素
}
List?
ArrayList
集合Arraylist和Array(数组)的区别?
- Array声明了它容纳的元素的类型,而ArrayList不声明。ArrayList创建时不需要指定大小,而Array创建时必须指定大小。
- 数组是静态的,一个数组实例具有固定的大小,一旦创建了就无法改变容量了。而集合是可以动态扩展容量,可以根据需要动态改变大小。
- ArrayList 提供了丰富的 API 操作方法,比如 add()、remove()等。而Array 只是一个固定长度的数组,只能按照下标访问其中的元素。
- ArrayList 中只能存储对象,对于基本类型数据,需要将他们转化为对应的包装类(如 Integer)。Array 可以直接存储基本类型数据,也可以存储对象。
- 数组是java语言中内置的数据类型,是线性排列的,执行效率或者类型检查都是最快的。
- 数组的存放的类型只能是一种(基本类型/引用类型),集合存放的类型可以不是一种(不加泛型时添加的类型是Object)。
下面是二者使用的简单对比:
// 初始化一个 String 类型的数组
String[] stringArr = new String[]{"hello", "world", "!"};
// 修改数组元素的值
stringArr[0] = "goodbye";
System.out.println(Arrays.toString(stringArr));// [goodbye, world, !]
// 删除数组中的元素,需要手动移动后面的元素
for (int i = 0; i < stringArr.length - 1; i++) {
stringArr[i] = stringArr[i + 1];
}
stringArr[stringArr.length - 1] = null;
System.out.println(Arrays.toString(stringArr));// [world, !, null]
ArrayList :
// 初始化一个 String 类型的 ArrayList
ArrayList<String> stringList = new ArrayList<>(Arrays.asList("hello", "world", "!"));
// 添加元素到 ArrayList 中
stringList.add("goodbye");
System.out.println(stringList);// [hello, world, !, goodbye]
// 修改 ArrayList 中的元素
stringList.set(0, "hi");
System.out.println(stringList);// [hi, world, !, goodbye]
// 删除 ArrayList 中的元素
stringList.remove(0);
System.out.println(stringList); // [world, !, goodbye]
集合数组之间的转化
//创建数组
int[] arr = {1,3,4,6,6};
//数组装换为集合
Arrays.asList(arr);
//集合转化为数组
List list = new ArrayList();
list.add("a");
list.add("b");
list.toArray();
System.out.println(list.toString());
ArrayList 和 Vector 的区别?(了解即可)
- ArrayList 线程不安全,Vector是线程安全的。随着 Java 并发编程的发展,Vector 和 Stack 已经被淘汰。
Vector 和 Stack 的区别?(了解即可)
- Vector 和 Stack 两者都是线程安全的,都是使用 synchronized 关键字进行同步处理。
- Stack 继承自 Vector,是一个后进先出的栈,而 Vector 是一个列表。
随着 Java 并发编程的发展,Vector 和 Stack 已经被淘汰,推荐使用并发集合类(例如 ConcurrentHashMap、CopyOnWriteArrayList 等)或者手动实现线程安全的方法来提供安全的多线程操作支持。
ArrayList的扩容:
ArrayList扩容的本质就是计算出新的扩容数组的size后实例化,并将原有数组内容复制到新数组中去。(不是原数组,而是新数组然后给予数组对象地址)。
扩容可分为两种情况:
第一种情况,当ArrayList的容量为0时,此时添加元素的话,需要扩容,三种构造方法创建的ArrayList在扩容时略有不同:
1.无参构造,创建ArrayList后容量为0,添加第一个元素后,容量默认为10,此后若需要扩容,则正常扩容。
2.传容量构造,当参数为0时,创建ArrayList后容量为0,添加第一个元素后,容量为1,此时ArrayList是满的,下次添加元素时需正常扩容。
3.传列表(指定collection元素的列表)构造,当列表为空时,创建ArrayList后容量为0,添加第一个元素后,容量为1,此时ArrayList是满的,下次添加元素时需正常扩容。当列表不为空时,创建ArrayList后容量为列表的长度,然后将列表的元素复制到ArrayList中。
第二种情况,当ArrayList的容量大于0,并且ArrayList是满的时,此时添加元素的话,进行正常扩容,每次扩容到原来的1.5倍。
-
数组进行扩容时,会将老数组中的元素重新拷贝一份到新的数组中
-
ArrayList通过grow()方法来计算新数组的长度,
/**
* 要分配的最大数组大小
*/
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
/**
* ArrayList扩容的核心方法。
*/
private void grow(int minCapacity) {
// oldCapacity为旧容量,newCapacity为新容量
int oldCapacity = elementData.length;
// 将oldCapacity 右移一位,其效果相当于oldCapacity /2,
// 我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍,
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量,
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和 MAX_ARRAY_SIZE,
// 如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
ArrayList时间复杂度
对于插入:
- 头部插入: O(n)。
- 尾部插入:O(n)
- 指定位置插入:这个过程需要移动平均 n/2 个元素, O(n)。
对于删除:
- 头部删除: O(n)。
- 尾部删除: O(1)。
- 指定位置删除:需要移动平均 n/2 个元素,时间复杂度为 O(n)。
LinkList?
LinkedList 为什么不能实现 RandomAccess 接口?
RandomAccess 是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。由于 LinkedList 底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现 RandomAccess 接口。
ArrayList 与 LinkedList 区别?
- **是否保证线程安全:**ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
- **底层数据结构:**ArrayList 底层使用的是 Object 数组;LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!
- 插入和删除是否受元素位置的影响:
- ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。
- LinkedList 采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响。头尾插入或者删除元素的时候时间复杂度近似 O(1)
- **是否支持元素随机访问:**LinkedList 不支持高效的随机元素访问,而 ArrayList(实现了 RandomAccess 接口) 支持。
- **在存储结构上的区别:**ArrayList顺序表在物理上和逻辑上都是连续的,但是在扩容的时候,可能会造成空间的浪费。而LinkedList在链表上的元素在空间存储上内存地址不连续。
Set?
比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
- HashSet、LinkedHashSet 和 TreeSet 都是 Set 接口的实现类,都能保证元素唯一,并且都不是线程安全的。
- HashSet 的底层数据结构是哈希表(基于 HashMap 实现)。LinkedHashSet 的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。
- HashSet 用于不需要保证元素插入和取出顺序的场景,LinkedHashSet 用于保证元素的插入和取出顺序满足 FIFO(先进先出) 的场景,TreeSet 用于支持对元素自定义排序规则的场景。
Map?
散列表的作用
散列算法是散列表的核心,也就做哈希算法或 Hash 算法,是一个意思。散列算法是一种将任意长度输入转换为固定长度输出的算法,输出的结果就是散列值。基于散列算法实现的散列表,可以实现快速查找元素的特性。
如何降低散列冲突概率
虽然散列冲突是无法完全避免的,但可以尽可能降低发生散列冲突的概率。例如:
1、优化散列算法,提高散列值随机性: 将散列值尽可能均匀分布到输出值域的范围内,避免出现 “堆积” 线程。否则,当大部分散列值都堆积在一小块区域上时,势必会增大冲突概率。例如,HashMap 保证容量为 2^n 次幂就是提高随机性的方法。
2、扩大输出值域(即扩容): 在散列值尽可能均匀分布的前提下,扩大输出值域可以直接降低冲突概率。例如,HashMap 在达到阈值时执行扩容,本质上是扩大了输出值域。
HashMap解决哈希冲突的方法是什么?
常规的哈希冲突解决方法有开放地址法和拉链法等,而 HashMap 采用的是拉链法来解决哈希冲突。
为什么 HashMap 采用拉链法而不是开放地址法?
开放地址法相对来说容易出现数据堆积,在数据量较大时可能出现连续冲突的情况,性能不够稳定。
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 没有这样的机制。
说一下 HashMap 的底层结构?
HashMap 的底层结构是一个 “数组 + 拉链” 的二维结构,在 Java 7 中使用的是数组 + 链表,而在 Java 8 中当链表长度大于 8 时会转换为红黑树。
那么为什么 HashMap 要采用这样的设计呢?
- HashMap存储key-value结构的数据,为了实现快速访问,背后就需要用到数组的快速访问的特点。通过key 计算 hashcode,再将 hashCode 对数组长度取余得到数组下标,最后通过下标去数组中找到对应的 Value;
- 由于计算不同的key可能会对应不同的数组下标,产生hash冲突,为了解决hash冲突,HashMap 采用的是拉链法来解决哈希冲突。
- Java 8 引入红黑树的原因是,当冲突加剧的时候,链表会变得很长,而链表的查找速度是O(n),影响查找效率。而红黑树的查找复杂度是 O(logn),因此在Java 8 引入红黑树。
为什么 HashMap 用红黑树而不是平衡二叉树?
- 平衡二叉树追求的是一种完全平衡的状态,即左右子树的高度差不超过1。虽然查找速度快,但是构造这种完全平衡状态消耗的资源是非常大的。
- 红黑树是一种弱平衡的状态,就是让整个树最长路径不会超过最短路径的 2 倍。红黑树虽然牺牲了一部分查找的性能效率,但是能够换取一部分维持树平衡状态的成本。
- 平衡二叉树维持完全平衡状态消耗的资源总体上是大于其查找速度快所带来的优势。所以HashMap选择采用红黑树。
为什么HashMap的长度必须是2的n次幂?
- 在计算存入结点下标时,会利用 key 的 hsah 值进行取余操作,而计算机计算时,并没有取余等运算,会将取余转化为其他运算。
- 当n为2次幂时,会满足一个公式:(n - 1) & hash = hash % n,就可以用位运算代替取余运算,计算更加高效。
HashMap 为什么在获取 hash 值时要进行位运算
换种问法:能不能直接使用key的hashcode值计算下标存储?
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 如果使用直接使用hashCode对数组大小取余,那么相当于参与运算的只有hashCode的低位,高位是没有起到任何作用的,所以我们的思路就是让 hashCode取值出的高位也参与运算,进一步降低hash碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动。
- (h >>> 16)是无符号右移16位的运算,右边补0,得到 hashCode 的高16位。(h = key.hashCode()) ^ (h >>> 16) 把 hashCode 和它的高16位进行异或运算,可以使得到的 hash 值更加散列,尽可能减少哈希冲突,提升性能。
- 而这么来看 hashCode 被散列 (异或) 的是低16位,而 HashMap 数组长度一般不会超过2的16次幂,那么高16位在大多数情况是用不到的,所以只需要拿 key 的 HashCode 和它的低16位做异或即可利用高位的hash值,降低哈希碰撞概率也使数据分布更加均匀。
HashMap在JDK1.7和JDK1.8中有哪些不同? HashMap的底层实现
在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。
JDK1.8主要解决或优化了以下问题:
- resize 扩容和 计算hash 优化
- 引入了红黑树,目的是避免单条链表过长而影响查询效率,红黑树算法请参考
- 解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题。
HashMap的put方法的具体流程?
HashMap是懒加载,只有在第一次put时才会创建数组。
总结
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值
对,否则转向⑤;
⑤.遍历table[i],并记录遍历长度,如果遍历过程中发现key值相同的,则直接覆盖value,没有相同的key则在链表尾部插入结点,插入后判断该链表长度是否大等于8,大等于则考虑树化,如果数组的元素个数小于64,则只是将数组resize,大等于才树化该链表;
⑥.插入成功后,判断数组中的键值对数量size是否超过了阈值threshold,如果超过,进行扩容。
HashMap 的 get 方法的具体流程?
总结
首先根据 hash 方法获取到 key 的 hash 值
然后通过 hash & (length - 1) 的方式获取到 key 所对应的Node数组下标 ( length对应数组长度 )
首先判断此结点是否为空,是否就是要找的值,是则返回空,否则判断第二个结点是否为空,是则返回空,不是则判断此时数据结构是链表还是红黑树
链表结构进行顺序遍历查找操作,每次用 == 符号 和 equals( ) 方法来判断 key 是否相同,满足条件则直接返回该结点。链表遍历完都没有找到则返回空。
HashMap的扩容操作是怎么实现的?
不管是JDK1.7或者JDK1.8 当put方法执行的时候,如果table为空,则执行resize()方法扩容。默认长度为16。
JDK1.7扩容
**条件:**发生扩容的条件必须同时满足两点
- 当前存储的数量大于等于阈值
- 发生hash碰撞
可能存在的情况:
- 就是hashmap在存值的时候(默认大小为16,负载因子0.75,阈值12),可能达到最后存满16个值的时候,再存入第17个值才会发生扩容现象,因为前16个值,每个值在底层数组中分别占据一个位置,并没有发生hash碰撞。
- 当然也有可能存储更多值(超多16个值,最多可以存26个值)都还没有扩容。原理:前11个值全部hash碰撞,存到数组的同一个位置(这时元素个数小于阈值12,不会扩容),后面所有存入的15个值全部分散到数组剩下的15个位置(这时元素个数大于等于阈值,但是每次存入的元素并没有发生hash碰撞,所以不会扩容),前面11+15=26,所以在存入第27个值的时候才同时满足上面两个条件,这时候才会发生扩容现象。
特点:
- 先扩容,再添加(扩容使用的头插法)
缺点:
- 头插法会使链表发生反转,多线程环境下可能会死循环
扩容之后对table的调整:
- table容量变为2倍.
- 所有的元素下标需要重新计算,newIndex = hash (扰动后) & (newLength - 1)
JDK1.8扩容
条件:
- 当前存储的数量大于等于阈值
- 当某个链表长度>=8,但是数组存储的结点数size() < 64时
特点:先插后判断是否需要扩容(扩容时是尾插法)
**缺点:**多线程下,1.8会有数据覆盖
举例:
线程A:往index插,index此时为空,可以插入,但是此时线程A被挂起
线程B:此时,对index写入数据,A恢复后,就把B数据覆盖了
扩容之后对table的调整:
- table容量变为2倍,但是不需要像之前一样计算下标,只需要将hash值和旧数组长度相与即可确定位置。
- 如果 Node 桶的数据结构是链表会生成 low 和 high 两条链表,是红黑树则生成 low 和 high 两颗红黑树
依靠 (hash & oldCap) == 0 判断 Node 中的每个结点归属于 low 还是 high。 - 把 low 插入到 新数组中 当前数组下标的位置,把 high 链表插入到 新数组中 [当前数组下标 + 旧数组长度] 的位置
JDK1.7扩容和JDK1.8扩容的区别
- JDK 1.7 采用头插法来添加链表元素,存在链表成环的问题,产生死循环。1.8 中做了优化,采用尾插法来添加链表元素
- HashMap 不管在哪个版本都不是线程安全的,出了并发问题不要怪 HashMap,从自己身上找原因
HashMap 多线程操作导致死循环问题
- JDK1.7 及之前版本的 HashMap 在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。
- 为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在数据覆盖的问题。并发环境下,推荐使用 ConcurrentHashMap 。
一般面试中这样介绍就差不多,不需要记各种细节,个人觉得也没必要记。如果想要详细了解 HashMap 扩容导致死循环问题,可以看看耗子叔的这篇文章:Java HashMap 的死循环open in new window。