复习和准备一些集合常见的问题,也加强了一下原理的知识
ArrayList
有以下需要注意的:
- 底层采用的是动态数组实现的,这意味着有很多数组的特性
- ArrayList内部维护了一个数组,最开始的时候数组是空的,初始容量为10,等待有元素插入时才会进行扩容,扩容的大小是原来的1.5倍
- 在指定索引的增删是,会把后面位置的元素进行复制移动,这很消耗性能,也说明为什么增删慢了,查询快了
- 由于底层是数组,而数组是保存在堆中的,所以数组扩容是有极限的,超过极限就会发生OOM异常
- 数组与arraylist的转换,数组转list,指向的是同一片内存地址,所以数组修改了,list也会修改。而list转数组是拷贝的,所以不会影响。
LinkedList
有以下需要注意的:
- 底层采用双向链表实现,一个节点里包括三部分:数据+前驱节点+后继节点
- 它内存是不连续的,并且由于是链表,所有不需要做动态扩容
- 不支持随机访问,查询慢,增删快
ArrayList和LinkedList的都是线程不安全的,如何使用线程安全呢?
- 使用工具类Collections.synchronizedList()
- ArrayList还可以使用CopyOnWriteArrayList(),性能会更好,而LinkedList没有这个实现类
散列表(哈希表)
定义:
散列表(Hash Table)又名哈希表/Hash表,是根据**键(Key)**直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性。(key经过哈希函数的计算生成一个值,该值映射到数组的索引下标,即能通过key去访问存放在数组中的value了。其中关键就是哈希函数)
哈希冲突:
不同的key经过哈希函数计算后的值相同,这就发生了哈希冲突,数组的一个位置称为一个桶或一个槽
拉链法:
当发生了哈希冲突后,即在该桶中存放一个链表,依次存放。当链表的长度大于8时,就会转换为时间复杂度更低的红黑树,避免了查询链表的线性退化。链表里存储的数据是包含key的,就是说,通过key去遍历查询链表中的数据。
HashMap
-
底层采用数组+链表实现,即散列表
-
key可以为null
-
寻值算法
-
key.hashCode(),第一次获取哈希值
-
使用扰动函数,二次计算哈希值,使哈希分布的更加均匀
(h = key.hashCode()) ^ (h >>> 16)
/** * 1、h>>>6,将哈希值向右移动了16位 * 2、^ (异或运算),二进制时,同一位上相同,则该位为0,否则为1 * 3、该方法能有效的扰动哈希值,减少了哈希冲突和使哈希的分布更加均匀 */ static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
-
获取索引位置
(n - 1) & hash]
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; // 获取索引还需要进行 i = (n - 1) & hash],n为数组的容量 // 当索引位置为null时,直接插入 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { // 此处省略 // 插入到链表,或者添加到红黑树 } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
-
-
put扩容的过程
-
HashMap的懒惰加载,即虽然创建了HashMap,但是没有初始化table数组,构造方法中只是将默认的加载因子赋值
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
-
由于涉及到数组,就需要对进行动态扩容。当数据达到(加载因子*原数组容量)使即进行扩容。比如默认的加载因子是0.75,而数组的容量是16,所有当数据达到12时就会调用resize()方法进行扩容了。resize()方法扩容时,移动数据到新的数组,计算新的位置的方法:
if(hash & oldCap = 0){ // 留在原位置 }else{ // 原位置 + oldcap }
使用位运算扩大了两倍
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; } // 使用了位运算,往左移动了1位,即 2的1次幂,扩大了两倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } .... return newTab; }
-
-
为什么扩容是2的幂次方?
使用&取代模运算,效率高,在两处用到了:
- 计算索引位置
- 扩容时,重新计算索引位置
-
线程安全的类ConcurrentHashMap()
底层使用了synchronized将哈希桶锁住了,就是说同时间只会有一个线程操作桶里的链表或红黑树
<<、>>、>>>的理解
-
<<表示将数的二进制向左移动,右侧低位补0,这个时候不论有没有符号都不影响,往左移动一位就是等于乘以2
8<<2 // 表示8 * 2次幂,结果为32
-
有符号数:位运算>>表示将数的二进制向右移动,左侧补0,左侧最高位为符号位,补完0后,将保存原来的符号。往右移动一位等于除以2
8>>2 // 结果为2 -8>>2 // 结果为-2
-
无符号数:位运算>>>表示将数的二进制向右移动,左侧补0,这时候不会恢复原来的符号。即说明这个数为无符号的二进制数。往右移动一位等于除以2
8>>>2 // 结果为2 -8>>> // 结果为1073741822
LinkedHashMap
基于HashMap实现,在其外还维护了一个链表,保证了其顺序
HashSet
Set具有特点:
- 无序性:不能通过索引查找,数据没有顺序
- 唯一性:后续重复的数据不能添加
- 快速去重:快速查找(只遍历的速度快)
- 可以简单理解为HashMap没有实现后续的链表,存储的值即是key也是value
- 可以为null
- 使用哈希表实现
Set<String> set = new HashSet<>();
set.add("hello");
set.add("ad");
// 可以添加null
set.add(null);
for (String s : set) {
System.out.println(s);
}
TreeSet
特点:
-
底层使用平衡树实现
-
同样具有无序性、唯一性等特点
-
不可以添加null
// 是平衡树实现 Set<String> treeSet = new TreeSet<>(); treeSet.add("hello"); treeSet.add("ad"); //set2.add(null); for (String s : treeSet) { System.out.println(s); }
LinkedHashSet
特点:
-
有序:HashSet外维护了一个双向循环链表,这也提供存储顺序和插入顺序
-
可以添加null
LinkedHashSet<String> h = new LinkedHashSet<>(); h.add("hello"); h.add("ad"); h.add(null); for (String s : h) { System.out.println(s); }
ArrayBlockingQueue
ArrayBlockingQueue是一个基于数组实现的有界阻塞队列,队列就是FIFO,它是线程安全的。队列可以用于多线程的通信。它可以设置公平策略。
常见的方法:
-
添加方法
方法名称 描述 add() 在队列尾部插入数据,队列已满时,抛出异常 IllegalStateException offer() 在队列尾部插入数据,队列已满时,返回false offer(E e, long timeout, TimeUnit unit) 可以在队列满的时候指定等待时间,超出等待时间,而队列还是满的,则返回false put() 在队列尾部插入数据,队列已满时,阻塞等待 -
获取方法
方法名称 描述 peek() 获取队列头部的数据,但是不会移除头部数据,队列为空时返回null poll() 获取队列头部的数据,会移除头部数据,队列为空时返回null take() 获取队列头部的数据,会移除头部数据,队列为空时一直阻塞 poll(long timeout, TimeUnit unit) 获取队列头部的数据,会移除头部数据,队列为空时阻塞指定时间 -
其他方法
方法名称 描述 remainingCapacity() 获取剩余可用容量 size() 获取队列中元素数量 remove() 移除指定元素 -
设置公平策略,设置fair后,就会按照创建线程去take的顺序获取,否则各线程随机竞争
public class Test03 { public static void main(String[] args) throws InterruptedException { // 设置fair后,就会按照创建线程去take的顺序获取,否则各线程随机竞争 ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(2,true); for (int i = 0; i < 10; i++) { new Thread(() -> { try { // 创建线程获取 System.out.println(Thread.currentThread().getName() + "获取" + queue.take()); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); } Thread.sleep(1000); queue.offer("a"); queue.offer("b"); } }
LinkedBlockingQueue
基于链表实现的阻塞队列,FIFO,常用的方法和ArrayBlockingQueue的一致,不同点:
ArrayBlockingQueue | LinkedBlockingQueue | |
---|---|---|
数据结构 | 数组 | 链表 |
是否有界 | 有界阻塞,需要指定固定数组大小 | 默认无界,可以有界 |
线程安全 | 内部一把锁,线程安全 | 内部有头尾各一把锁,即两把锁。读写互不干扰,性能更好 |
是否支持公平 | 是 | 否 |
内部有两把锁,读写互不影响:
private final ReentrantLock putLock = new ReentrantLock();
private final ReentrantLock takeLock = new ReentrantLock();
示例:
public class Test02 {
public static void main(String[] args) throws InterruptedException {
// 底层使用链表实现,其中默认的队列大小很大,也可以自行指定
LinkedBlockingQueue<String> queue = new LinkedBlockingQueue<>(2);
// add:队列满时,抛出异常
queue.add("a");
queue.add("b");
iteratorQueue(queue);
// 队列满时,线程阻塞
// queue.put("c");
// 队列满时,返回false
queue.offer("c");
iteratorQueue(queue);
// 获取头部元素,不移除,队列为空时,返回null
queue.peek();
iteratorQueue(queue);
// 获取头部元素,移除,队列为空时,返回null
queue.poll();
iteratorQueue(queue);
queue.poll();
iteratorQueue(queue);
// 获取头部元素,移除,队列为空时,阻塞
// queue.take();
queue.offer("a");
queue.offer("b");
}
/**
* 打印队列
*
* @param t
* @param <T>
*/
public static <T extends Queue<String>> void iteratorQueue(T t) {
System.out.println("+++++++打印队列元素++++++++");
for (String s : t) {
System.out.println(s);
}
System.out.println("+++++++打印结束++++++++");
}
}
SynchronousQueue
内部没有容量,写线程写入元素都需要等待读线程读取元素,反之也是如此
public class Test04 {
public static void main(String[] args) throws InterruptedException {
SynchronousQueue<String> queue = new SynchronousQueue<>();
Thread threadIn = new Thread(() -> {
try {
queue.put("a");
System.out.println(Thread.currentThread().getName() + "成功放入元素a");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread threadOut = new Thread(() -> {
try {
System.out.println(queue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
});
threadOut.start();
Thread.sleep(2000);
threadIn.start();
}
}
ps:方法peek()无效,因为它内部没有存储元素,而peek是获取元素,却不删除,所以没有意义。