数据结构总纲
概述
Java 集合, 也叫作容器,主要是由两大接口派生而来:一个是 Collection
接口,主要用于存放单一元素;另一个是 Map
接口,主要用于存放键值对。对于Collection
接口,下面又有三个主要的子接口:List
、Set
和 Queue
Java 集合框架如下图所示:
List
ArrayList
:Object[]
数组。Vector
:Object[]
数组。LinkedList
:双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)。
Set
HashSet
(无序,唯一): 基于HashMap
实现的,底层采用HashMap
来保存元素。LinkedHashSet
:LinkedHashSet
是HashSet
的子类,并且其内部是通过LinkedHashMap
来实现的。TreeSet
(有序,唯一): 红黑树(自平衡的排序二叉树)。
Queue
PriorityQueue
:Object[]
数组来实现小顶堆。DelayQueue
:PriorityQueue
。ArrayDeque
: 可扩容动态双向数组。
Map
HashMap
:JDK1.8 之前HashMap
由数组+链表组成的,数组是HashMap
的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。LinkedHashMap
:LinkedHashMap
继承自HashMap
,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap
在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。Hashtable
:数组+链表组成的,数组是Hashtable
的主体,链表则是主要为了解决哈希冲突而存在的。TreeMap
:红黑树(自平衡的排序二叉树)
1 LIST - ArrayList和Vector区别
Vector和 Stack(Vector的子类) 是线程安全的,都是使用 synchronized 关键字进行同步处理。随着 Java 并发编程的发展,Vector和Stack已经被淘汰,推荐使用并发集合类(例如CopyOnWriteArrayList等)或者手动实现线程安全的方法来提供安全的多线程操作支持
2 LIST - ArrayList可以添加null值吗
可以但不建议。null 值无意义,会让代码难以维护比如忘记做判空处理就会导致空指针异常
3 LIST - ArrayList与LinkedList的区别
4 LIST - ArrayList扩容机制
ArrayList 有三种方式来初始化,构造方法源码如下(JDK8):
/** * 默认初始容量大小 */ private static final int DEFAULT_CAPACITY = 10; private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; /** * 默认构造函数,使用初始容量10构造一个空列表(无参数构造) */ public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } /** * 带初始容量参数的构造函数。(用户自己指定容量) */ public ArrayList(int initialCapacity) { if (initialCapacity > 0) {//初始容量大于0 //创建initialCapacity大小的数组 this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) {//初始容量等于0 //创建空数组 this.elementData = EMPTY_ELEMENTDATA; } else {//初始容量小于0,抛出异常 throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity); } } /** *构造包含指定collection元素的列表,这些元素利用该集合的迭代器按顺序返回 *如果指定的集合为null,throws NullPointerException。 */ public ArrayList(Collection<? extends E> c) { elementData = c.toArray(); if ((size = elementData.length) != 0) { // c.toArray might (incorrectly) not return Object[] (see 6260652) if (elementData.getClass() != Object[].class) elementData = Arrays.copyOf(elementData, size, Object[].class); } else { // replace with empty array. this.elementData = EMPTY_ELEMENTDATA; } }
以无参数构造方法创建 ArrayList
时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10
- 当我们要
add
进第 1 个元素到ArrayList
时,elementData.length
为 0 (因为还是一个空的 list),因为执行了ensureCapacityInternal()
方法 ,所以minCapacity
此时为 10。此时,minCapacity - elementData.length > 0
成立,所以会进入grow(minCapacity)
方法。 - 当
add
第 2 个元素时,minCapacity
为 2,此时elementData.length
(容量)在添加第一个元素后扩容成10
了。此时,minCapacity - elementData.length > 0
不成立,所以不会进入 (执行)grow(minCapacity)
方法。 - 添加第 3、4···到第 10 个元素时,依然不会执行 grow 方法,数组容量都为 10。
直到添加第 11 个元素,minCapacity
(为 11)比 elementData.length
(为 10)要大。进入 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 每次扩容之后容量都会变为原来的 1.5 倍左右(oldCapacity 为偶数就是 1.5 倍,否则是 1.5 倍左右)! 奇偶不同,比如:10+10/2 = 15, 33+33/2=49。如果是奇数的话会丢掉小数.
总结:
ArrayList 是一个数组结构的存储容器,默认情况下,设置数组长度是 10. 当然我们也可以在构建 ArrayList 对象的时候自己指定初始长度。 随着在程序里面不断的往 ArrayList 中添加数据,当添加的数据达到 10 个的时候, ArrayList 就没有多余容量可以存储后续的数据。 这个时候 ArrayList 会自动触发扩容。 扩容的具体流程很简单, 1. 首先,创建一个新的数组,这个新数组的长度是原来数组长度的 1.5 倍。 2. 然后使用 Arrays.copyOf 方法把老数组里面的数据拷贝到新的数组里面。 扩容完成后再把当前要添加的元素加入到新的数组里面,从而完成动态扩容的过程。
ArrayList 扩容是在第10个元素还是第11个元素触发的 ?
在 Java 中,ArrayList 的扩容是在添加第11个元素时触发的,当 ArrayList 中的元素数量达到了其初始容量(默认为 10)时,ArrayList 会自动扩容,新的容量为原来的 1.5 倍。当然也可以在创建 ArrayList 对象时指定其初始容量,以避免频繁的扩容操作。
5 SET - Comparable 和 Comparator 的区别
Comparable
接口实际上是出自java.lang
包 它有一个compareTo(Object obj)
方法用来排序Comparator
接口实际上是出自java.util
包它有一个compare(Object obj1, Object obj2)
方法用来排序
// Comparator例子 Collections.sort(arrayList, new Comparator<Integer>() { @Override public int compare(Integer o1, Integer o2) { return o2.compareTo(o1); } }); // Comparable例子 public class Person implements Comparable<Person> { private int age; public Person(String name, int age) { super(); this.age = age; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } /** * T重写compareTo方法实现按年龄来排序 */ @Override public int compareTo(Person o) { if (this.age > o.getAge()) { return 1; } if (this.age < o.getAge()) { return -1; } return 0; } }
6 SET - HashSet 如何检查重复?
当你把对象加入HashSet
时,HashSet
会先计算对象的hashcode
值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode
值作比较,如果没有相符的 hashcode
,HashSet
会假设对象没有重复出现。但是如果发现有相同 hashcode
值的对象,这时会调用equals()
方法来检查 hashcode
相等的对象是否真的相同。如果两者相同,HashSet
就不会让加入操作成功。
在 JDK1.8 中,HashSet
的add()
方法只是简单的调用了HashMap
的put()
方法,并且判断了一下返回值以确保是否有重复元素。直接看一下HashSet
中的源码:
// Returns: true if this set did not already contain the specified element // 返回值:当 set 中没有包含 add 的元素时返回真 public boolean add(E e) { return map.put(e, PRESENT)==null; }
而在HashMap
的putVal()
方法中也能看到如下说明:
// Returns : previous value, or null if none // 返回值:如果插入位置没有元素返回null,否则返回上一个元素 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { ... }
也就是说,在 JDK1.8 中,实际上无论HashSet
中是否已经存在了某元素,HashSet
都会直接插入,只是会在add()
方法的返回值处告诉我们插入前是否存在相同元素。
7 Queue - Queue与Deque 的区别
Queue
是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。
Queue
扩展了 Collection
的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。
Queue 接口 | 抛出异常 | 返回特殊值 |
---|---|---|
插入队尾 | add(E e) | offer(E e) |
删除队首 | remove() | poll() |
查询队首元素 | element() | peek() |
Deque
是双端队列,在队列的两端均可以插入或删除元素。
Deque
扩展了 Queue
的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:
Deque 接口 | 抛出异常 | 返回特殊值 |
---|---|---|
插入队首 | addFirst(E e) | offerFirst(E e) |
插入队尾 | addLast(E e) | offerLast(E e) |
删除队首 | removeFirst() | pollFirst() |
删除队尾 | removeLast() | pollLast() |
查询队首元素 | getFirst() | peekFirst() |
查询队尾元素 | getLast() | peekLast() |
事实上,Deque
还提供有 push()
和 pop()
等其他方法,可用于模拟栈。
8 QUEUE - 说一说 PriorityQueue
PriorityQueue
是在 JDK1.5 中被引入的, 其与 Queue
的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
这里列举其相关的一些要点:
PriorityQueue
利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据PriorityQueue
通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。PriorityQueue
是非线程安全的,且不支持存储NULL
和non-comparable
的对象。PriorityQueue
默认是小顶堆,但可以接收一个Comparator
作为构造参数,从而来自定义元素优先级的先后。
PriorityQueue
在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第 K 大的数、带权图的遍历等,所以需要会熟练使用才行。
9 QUEUE - 说一说 BlockingQueue
BlockingQueue
(阻塞队列)是一个接口,继承自 Queue
。BlockingQueue
阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入
public interface BlockingQueue<E> extends Queue<E> { // ... }
BlockingQueue
常用于生产者-消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。
ArrayBlockingQueue
和 LinkedBlockingQueue
是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:
- 底层实现:
ArrayBlockingQueue
基于数组实现,而LinkedBlockingQueue
基于链表实现。 - 是否有界:
ArrayBlockingQueue
是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue
创建时可以不指定容量大小,默认是Integer.MAX_VALUE
,也就是无界的。但也可以指定队列大小,从而成为有界的。 - 锁是否分离:
ArrayBlockingQueue
中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue
中的锁是分离的,即生产用的是putLock
,消费是takeLock
,这样可以防止生产者和消费者线程之间的锁争夺。 - 内存占用:
ArrayBlockingQueue
需要提前分配数组内存,而LinkedBlockingQueue
则是动态分配链表节点内存。这意味着,ArrayBlockingQueue
在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue
则是根据元素的增加而逐渐占用内存空间。
10 MAP - HashMap和Hashtable的区别
HashMap
当链表长度大于阈值8时,将链表转化为红黑树 (如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间. Hashtable
没有这样的机制。/** * Returns a power of two size for the given target capacity. */ 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; }
11 MAP - HashMap 的底层实现
JDK1.8 之前 HashMap
底层是 数组和链表 结合。HashMap 通过 key 的 hashcode
经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash
判断当前元素存放的位置,如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
static final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^:按位异或 // >>>:无符号右移,忽略符号位,空位都以0补齐 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
DK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。
12 HashMap 为什么线程不安全
在多线程环境下,HashMap
扩容时会造成数据丢失的问题。
HashMap
中,多个键值对可能会被分配到同一个桶(bucket),并以链表或红黑树的形式存储。多个线程对 HashMap
的 put
操作会导致线程不安全,具体来说会有数据覆盖的风险。
举个例子:
- 两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突(hash 函数计算出的插入下标是相同的)。
- 不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。
- 随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { // ... // 判断是否出现 hash 碰撞 // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中) if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); // 桶中已经存在元素(处理hash冲突) else { // ... }
13 MAP - ConcurrentHashMap和Hashtable的区别
数据结构: jdk1.7ConcurrentHashMap采用分段数组+链表实现。 jdk1.8采用数组+链表/红黑树. HashTable采用数组+链表
实现线程安全的方式:
jdk1.7ConcurrentHashMap对整个桶数组进行了分段(Segment, 分段锁), 每一把锁只锁容器其中一部分数据, 多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率.
jdk1.8ConcurrentHashMap摒弃了Segment的概念, 直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用synchronized和CAS来操作
Hashtable(同一把锁): 使用synchronized来保证线程安全, 效率非常低下. 当一个线程访问同步方法时,其他线程也同时访问同步方法可能会进入阻塞或者轮询状态。如使用put添加元素, 另一个线程不能使用put添加元素,也不能使用get, 竞争会越来越激烈效率越低。
下面分别为HashTable
jdk1.7ConcurrentHashMap
首先将数据分为一段一段(这个“段”就是 Segment
)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
ConcurrentHashMap
是由 Segment
数组结构和 HashEntry
数组结构组成。
Segment
数组中的每个元素包含一个 HashEntry
数组,每个 HashEntry
数组属于链表结构
Segment
继承了 ReentrantLock
,所以 Segment
是一种可重入锁,扮演锁的角色。HashEntry
用于存储键值对数据。
一个 ConcurrentHashMap
里包含一个 Segment
数组,Segment
的个数一旦初始化就不能改变。 Segment
数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。
jdk1.8ConcurrentHashMap
JDK1.8 的 ConcurrentHashMap
不再是 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode
。当冲突链表达到一定长度时,链表会转换成红黑树。
TreeNode
是存储红黑树节点,被TreeBin
包装。TreeBin
通过root
属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 ConcurrentHashMap
中TreeBin
通过waiter
属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。
ConcurrentHashMap
取消了 Segment
分段锁,采用 Node + CAS + synchronized
来保证并发安全。数据结构跟 HashMap
1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。
Java 8 中,锁粒度更细,synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
14 MAP - JDK1.7和JDK1.8的ConcurrentHashMap实现有什么不同?
- 线程安全实现方式:JDK 1.7 采用
Segment
分段锁来保证安全,Segment
是继承自ReentrantLock
。JDK1.8 放弃了Segment
分段锁的设计,采用Node + CAS + synchronized
保证线程安全,锁粒度更细,synchronized
只锁定当前链表或红黑二叉树的首节点。 - Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。
- 并发度:JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。
15 MAP - ConcurrentHashMap统计size
ConcurrentHashMap是线程安全的HashMap,内部采用分段锁来实现,默认初始容量为16,装载因子为0.75f,分段16。键值都不能为null。每次扩容为原来容量的2倍,ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。在获取size操作的时候,不是直接把所有segment的count相加就可以可到整个ConcurrentHashMap大小,也不是在统计size的时候把所有的segment的put, remove, clean方法全部锁住,这种方法太低效。在累加count操作过程中,之前累加过的count发生变化的几率非常小,所有ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式统计各个Segment大小,如果统计的过程中,容器的count发生了变化,再采用加锁的方式来统计所有的Segment的大小。
16 深度优先搜索和广度优先搜索的理解
广度优先搜索就像水面上的波纹一样一层一层向外扩展。广度优先搜索的具体实现方式为队列
深度优先搜索就是"一条路走到黑",从源顶点开始,一直走到没有后继节点,才回溯到上一顶点,然后继续"一条路走到黑"。深度优先搜索的具体实现方式为栈