一、内容概览
二、算法复杂度 要想深入学习集合的底层原理,就要先对复杂度的概念有个清晰的认知。
(1)时间复杂度 时间复杂度表示了算法的执行时间与数据规模n之间的增长关系 。
常见的时间复杂度:O(1)、O(n)、O(n^2)、O(logn)
速记口诀:常对幂指阶
(2)空间复杂度 空间复杂度表示算法占用的额外存储空间和数据规模n之间的增长关系 。
三、List 1.ArrayList (1)数据结构 ArrayList底层使用数组来存储数据的。数组是一种用连续的内存空间存储相同数据类型的线性数据结构。
(2)基础问题 1.数组下标为什么从0开始 这就涉及到了数组元素的内存地址的计算问题了。
寻址公式 :baseAddress + i * dataType ,计算下标的内存地址速率高。
人话:基地址 + 索引下标 * 元素类型占用的空间大小
如果索引是从1开始,那么寻址公式就需要对(i-1),对于cpu来说,相当于多了一个减法指令。
2.查找的时间复杂度
随机(通过下标)查找的时间复杂度是O(1)
查找未知下标的元素的时间复杂度是O(n)
如果未知下标,但数组是排好序的,那么利用二分查找的时间复杂度是O(logn)
3.插入和删除的时间复杂度 插入和删除的时间复杂度,在尾插和尾删的情况下是O(1)。这种情况下不需要 挪动数组的其他元素 。但它们的**平均复杂度均为O(n)**。
(3)源码分析 分析源码主要从三方面进行分析:成员变量、构造函数和关键方法
成员变量 1 2 3 4 5 6 7 8 9 10 private static final int DEFAULT_CAPACITY = 10 ;private static final Object[] EMPTY_ELEMENTDATA = {};private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};transient Object[] elementData; private int size;
区分两个空实例 关键看grow方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 private Object[] grow(int minCapacity) { int oldCapacity = elementData.length; if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { int newCapacity = ArraysSupport.newLength(oldCapacity, minCapacity - oldCapacity, oldCapacity >> 1 ); return elementData = Arrays.copyOf(elementData, newCapacity); } else { return elementData = new Object [Math.max(DEFAULT_CAPACITY, minCapacity)]; } } public static int newLength (int oldLength, int minGrowth, int prefGrowth) { int prefLength = oldLength + Math.max(minGrowth, prefGrowth); if (0 < prefLength && prefLength <= SOFT_MAX_ARRAY_LENGTH) { return prefLength; } else { return hugeLength(oldLength, minGrowth); } }
总之,两个空实例主要区别就是在数组长度为0时,会使用的不同的扩容策略 。使用无参构造器创建的对象,在第一次add元素时,容量直接扩充为10。对于使用有参构造函数创建的对象,扩容策略直接就是使用1.5倍扩容,即使传入的参数是0,也是1.5倍方式扩容为1。
构造函数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public ArrayList () { this .elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } public ArrayList (int initialCapacity) { if (initialCapacity > 0 ) { this .elementData = new Object [initialCapacity]; } else if (initialCapacity == 0 ) { this .elementData = EMPTY_ELEMENTDATA; } else { throw new IllegalArgumentException ("Illegal Capacity: " + initialCapacity); } }
关键方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 public boolean add (E e) { modCount++; add(e, elementData, size); return true ; } private void add (E e, Object[] elementData, int s) { if (s == elementData.length) elementData = grow(); elementData[s] = e; size = s + 1 ; }
(4)面试题 ArrayList底层的实现原理是什么
ArrayList底层是用动态数组 实现的
ArrayList使用无参构造函数创建时,初始容量为0,当第一次添加元素时,容量扩充为10。
而当使用有参构造函数创建时,初始容量为传入的容量值。
无参第一次扩容之后和有参每次扩容都是扩容为原来大小的1.5倍。
ArrayList在添加元素时
会确保数组已使用长度加1之后足够存放下一个数据。
如果已使用长度加1大于当前数组容量,则调用grow()方法进行1.5倍扩容。
添加成功之后就会返回布尔值。
ArrayList list = new ArrayList(10)中list会扩容几次 扩容0次,看有参构造函数的具体实现就知道了,是直接创建一个对应容量的数组进行赋值,根本没进行扩容。
如何实现数组和List之间的转换 1 2 3 4 5 6 7 8 9 10 11 public static void main (String[] args) { Integer[] arr = new Integer [10 ]; int [] a = new int [10 ]; List<Integer> list1 = Arrays.asList(arr); List<int []> list2 = Arrays.asList(a); ArrayList<Integer> list = new ArrayList <>(); Object[] array = list.toArray(); Integer[] array1 = list.toArray(new Integer [10 ]); }
追问,用Arrays.asList转List后,如果修改了数组内容,list受影响吗? 受影响,asList是把原数组作为List的动态数组,也就是List的数组引用的是原来的数组
1 2 3 4 5 6 public static <T> List<T> asList (T... a) { return new ArrayList <>(a); } ArrayList(E[] array) { a = Objects.requireNonNull(array); }
List用toArray转数组之后,如果修改了list内容,数组受影响吗? 不受影响,因为toArray其实是把list中的数组进行了拷贝并返回的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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; }
2.LinkedList (1)数据结构 LinkedList底层使用的是双向链表 的数据结构。
对于传入LinkedList的每个元素,都会封装到Node对象上。Node对象包含了存储元素和前后驱指针。
1 2 3 4 5 6 7 8 9 10 private static class Node <E> { E item; Node<E> next; Node<E> prev; Node(Node<E> prev, E element, Node<E> next) { this .item = element; this .next = next; this .prev = prev; } }
(2)源码分析 成员变量 1 2 3 transient Node<E> first; transient Node<E> last;
LinkedList底层就是基于节点的操作,没什么好分析的,所以就先不写了
(3)面试题 ArrayList跟LinkedList的区别是什么 1.底层数据结构 ArrayList使用动态数组 ,LinkedList使用双向链表
2.操作效率 增删改查的时间复杂度角度
对于查询来说,ArrayList可以使用下标来查询,时间复杂度是O(1)。当未知下标时,ArrayList和LinkedList一样都是O(n)。
对于增删来说,两者基于头尾时间复杂度都是O(1)。其他情况,由于ArrayList增删元素需要挪动数组,Linked需要顺序遍历,所以两者一样都是O(n)。
3.占用空间 LinkedList占用空间更大,因为每个元素都存储了前后驱节点指针。
4.线程是否安全 两者线程都不安全 。
如果需要保证线程安全,有两种方案:
第一种,在方法内使用 来直接避免,因为局部变量是线程安全的。
第二种,使用线程安全的ArrayList和LinkedList。即使用Collections工具类的synchronizedList方法 。
1 2 List<Object> objects = Collections.synchronizedList(new ArrayList <>()); List<Object> objects1 = Collections.synchronizedList(new LinkedList <>());
synchronizedList方法底层就是给方法添加synchronized悲观锁来保证的
1 2 3 4 5 6 7 8 9 10 11 12 @SuppressWarnings("serial") final List<E> list; final Object mutex; public E get (int index) { synchronized (mutex) {return list.get(index);} } public E set (int index, E element) { synchronized (mutex) {return list.set(index, element);} } public void add (int index, E element) { synchronized (mutex) {list.add(index, element);} }
四、Map 1.HashMap (1)数据结构 二叉搜索树 又名二叉查找树、有序二叉树或者排序二叉树。
特点 树的任意一个节点,其左节点的值小于当前节点值,右节点的值大于当前节点值,没有键值(节点值)相同的节点
时间复杂度分析 **通常情况下时间复杂度为O(logn),但最坏情况就是,如果二叉搜索树退化成了链表, 也就是左右子树极度不平衡的情况下,时间复杂度为O(n)**。
红黑树 也叫自平衡的二叉搜索树。
特点
节点要么是红色,要么是黑色
根节点是黑色
叶子节点是黑色的空节点
红色节点的子节点都是黑色
从任一节点到它的后代叶子节点的所有路径上,黑色节点数目相同
这5个特点本质上就是用来保证二叉树的平衡。
当规则被打破时,通过颜色翻转和左旋右旋来恢复规则。
散列表 散列表,又名哈希表。是根据键(key)直接访问在内存存储位置值(value)的数据结构。由数组演化而来,利用率数组支持按照下标随机访问数据的特点。
散列冲突 又名哈希冲突,指多个key映射到同一个数组的下标位置的情况。
解决方法:链表法。
数组的每个下标位置称为桶或者槽。每个桶会对应一个链表。hash冲突后的元素都放到相同槽位对应的链表中或者红黑树中。
(2)源码分析 成员变量 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4 ; static final int MAXIMUM_CAPACITY = 1 << 30 ;static final float DEFAULT_LOAD_FACTOR = 0.75f ;static final int TREEIFY_THRESHOLD = 8 ;static final int UNTREEIFY_THRESHOLD = 6 ;static final int MIN_TREEIFY_CAPACITY = 64 ;static class Node <K,V> implements Map .Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this .hash = hash; this .key = key; this .value = value; this .next = next; } } transient Node<K,V>[] table;transient Set<Map.Entry<K,V>> entrySet;transient int size;transient int modCount;int threshold;final float loadFactor;
构造函数 Hashmap的数组是懒加载,只有第一次put时才会初始化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public HashMap () { this .loadFactor = DEFAULT_LOAD_FACTOR; } 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); } static final int tableSizeFor (int cap) { int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1 ); return (n < 0 ) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1 ; }
关键方法 put方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 public V put (K key, V value) { return putVal(hash(key), key, value, false , true ); } final V putVal (int hash, K key, V value, boolean onlyIfAbsent,boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0 ) n = (tab = resize()).length; if ((p = tab[i = (n - 1 ) & hash]) == null ) tab[i] = newNode(hash, key, value, null ); else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this , tab, hash, key, value); else { for (int binCount = 0 ; ; ++binCount) { if ((e = p.next) == null ) { p.next = newNode(hash, key, value, null ); if (binCount >= TREEIFY_THRESHOLD - 1 ) treeifyBin(tab, hash); break ; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break ; p = e; } } if (e != null ) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null ) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null ; } final void treeifyBin (Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; 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 = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null ); if ((tab[index] = hd) != null ) hd.treeify(tab); } } static final int hash (Object key) { int h; return (key == null ) ? 0 : (h = key.hashCode()) ^ (h >>> 16 ); }
resize方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null ) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0 ; if (oldCap > 0 ) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1 ) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1 ; } else if (oldThr > 0 ) newCap = oldThr; else { newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int )(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } 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 ) { 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 { 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; }
(3)面试题 说下HaspMap的实现原理 HashMap底层使用的是hash表数据结构,就是数组+链表/红黑树。
添加数据时,会通过计算key的hash值来确定元素在数组中的下标。当链表或者红黑树的中的元素key和当前key相同的话,就会进行value替换,不同的话,就会存入链表或者红黑树中。
HashMap的jdk1.7和jdk1.8有什么区别 JDK1.8之前采用的是拉链法,就是数组+链表。
JDK1.8及以后使用的是数组+链表/红黑树的形式。当链表长度大于8且数组长度大于64,就会把链表转化为红黑树。当扩容时,如果红黑树的结构个数小于等于临界值6时,就会退化成链表。
HashMap的put方法的具体流程
HashMap的table数组是懒加载,所以第一次put才会进行初始化。
判断键值对数组table是否为空或为null,执行resize()进行扩容(初始化)。
根据键值key计算hash值得到数组索引。
判断table[i]==null,条件成立,直接新建节点添加。
如果table[i]!=null,则进行判断
先判断table[i]的首个元素是否和key一样,如果相同则直接覆盖value
否则再判断节点类型是否是TreeNode,如果是,则说明是红黑树,那么就在红黑树中进行节点判断和添加。
否则就是链表,那么遍历整个链表,有key相同的节点,就进行value替换,不然就尾插。尾插完成之后,会判断是否需要转化为红黑树,先进行链表节点个数的阈值判断,再进行数组长度的阈值判断,决定是否将该链表转化为红黑树。
5.插入完成之后,会判断当前节点个数是否大于扩容阈值,如果大于就进行扩容。
HashMap的resize方法的具体流程 HashMap的数组是懒加载模式,在第一次put时,就会调用resize方法来进行数组初始化,如果在构造HashMap时,传入了初始化大小,就初始化为大于等于大小的2的幂次,否则使用默认初始化大小16对数组进行初始化。
以后每次扩容都是在容量到达了扩容阈值(数组长度*0.75)时进行扩容,每次扩容的时候,都是扩容前容量的2倍。
扩容之后,会创建一个新的数组,需要把老数组中的数据挪动到新的数组中。
没有hash冲突的节点,则直接使用e.hash &(newCap-1)计算新数组的索引位置。
如果是红黑树,则走红黑树的添加。
如果是链表,则需要遍历链表,判断链表中的每个元素的hash & oldCap的值是否为0,若为0,则该位置的元素要么停留在原始位置,要么移动到原始位置+增加的数组大小的这个位置上。
HashMap的寻址算法 计算对象的HashCode()
再调用hash方法进行二次哈希 ,就是hashcode值右移16位再进行异或运算,让哈希分布更为均匀
最后通过(capacity-1)& hash 得到索引
为什么HashMap数组的长度一定是2的次幂 因为这样子计算索引的效率更高,如果是2的n次幂,可以使用位与运算代替取模
扩容时,重新计算索引效率更高,hash&oldCap == 0 的元素留在原来位置,否则新位置=旧位置 + oldCap
HashMap在jdk1.7情况下的多线程死循环问题
jdk1.7的hashMap在数组进行扩容的时候,因为链表是采用头插法,如果有多个线程对hashMap进行扩容,在数据迁移过程中有可能导致死循环问题。
比如说,有两个线程1和2
线程1,读取到hashmap的数据,在准备扩容时,
线程2介入,线程2先进行了扩容,对数据进行了迁移,由于采用头插法,比如原来链表的顺序是A->B,扩容之后的顺序是B->A,线程2执行结束。
此时,线程1再去扩容就会出现死循环问题。
线程1先将A移入新链表,然后B又插入到链表头。由于线程2的介入,导致B的next不是null,而是A,导致A又重新插入到链表头,从而出现了A -> B -> A的死循环引用。
2.ConcurrentHashMap (1)数据结构 ConcurrentHashMap在JDK1.7及以前采用分段数组+链表实现。
分段数组叫做Segment数组,它是默认固定 长度为16的数组,也可以指定。每个Segment元素(继承了Reentrantlock)内部维护了一个数组,叫做HashEntry数组,但这个数组可扩容,且会通过链表连接相同索引下的元素。当多个线程往同个Segment元素维护的数组添加数据时,需要通过ReentrantLock的tryLock方式获取当前元素的锁,只有获取到锁的线程才可以往该节点指向的数组中添加元素,其他线程会通过CAS进行自旋获取。
JDK1.8及以后,采用跟HashMap一样的数据结构,数组+链表/红黑树。去掉Segment数组这个臃肿的设计。
(2)源码分析 put方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 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 (); int hash = spread(key.hashCode()); int binCount = 0 ; for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; K fk; V fv; if (tab == null || (n = tab.length) == 0 ) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1 ) & hash)) == null ) { if (casTabAt(tab, i, null , new Node <K,V>(hash, key, value))) break ; } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else if (onlyIfAbsent && fh == hash && ((fk = f.key) == key || (fk != null && key.equals(fk))) && (fv = f.val) != null ) return fv; else { V oldVal = null ; synchronized (f) { if (tabAt(tab, i) == f) { if (fh >= 0 ) { binCount = 1 ; for (Node<K,V> e = f;; ++binCount) { K ek; if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { oldVal = e.val; if (!onlyIfAbsent) e.val = value; break ; } Node<K,V> pred = e; if ((e = e.next) == null ) { pred.next = new Node <K,V>(hash, key, value); break ; } } } else if (f instanceof TreeBin) { Node<K,V> p; binCount = 2 ; if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null ) { oldVal = p.val; if (!onlyIfAbsent) p.val = value; } } else if (f instanceof ReservationNode) throw new IllegalStateException ("Recursive update" ); } } if (binCount != 0 ) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (oldVal != null ) return oldVal; break ; } } } addCount(1L , binCount); return null ; }
jdk1.7ConcurrentHashMap源码简化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public class ConcurrentHashMap <K, V> { final Segment<K,V>[] segments; static final class Segment <K,V> extends ReentrantLock { transient volatile HashEntry<K,V>[] table; } static final class HashEntry <K,V> { final int hash; final K key; volatile V value; volatile HashEntry<K,V> next; } }
(3)面试题 聊下ConcurrentHashMap 1.底层数据结构 jdk1.7采用分段数组+链表实现
jdk1.8采用跟HashMap一样的数据结构,数组+链表/红黑树
2.加锁方式 jdk1.7采用分段锁 (Segment数组元素本身就继承了ReentrantLock),使用的是ReentrantLock。
Jdk1.8在数组对应元素为null值时,使用CAS来添加新节点 ,元素不为null时,采用synchronized锁定链表或者红黑树的首节点
Segment分段锁,锁的是一个数组+链表的数据结构;而synchronized方式,锁的是一个链表或者红黑树,锁的粒度更细,性能更好。