Java 集合类位于 java.util 包下,JDK1.5 之后还在 java.util.concurrent 包下提供了一些多线程支持的集合类。Java 集合主要由两个接口派生而出:Collection 和 Map。Collection 的父接口是 Iterable(迭代器),所以 Collection 的子接口全部可以使用 Iterable 遍历集合。
Collection 的子接口包括 List、Set 和 Queue 接口。
List集合
- | 线程安全 | 底层实现 | 特点 | 扩容 |
---|---|---|---|---|
ArrayList | 线程不安全 | 数组 | 有序可重复 | 默认大小10,每次扩容容量为原来的1.5倍 |
LinkedList | 线程不安全 | 双向循环链表,既可以作队列,可以作栈 | ||
Vector | 线程安全 | 数组 | 方法都加上了synchronized,保证了线程安全 | 每次扩容容量为原来的2倍 |
ArrayList
ArrayList 的动态扩容原理:其实就是创建新的长度的数组,然后把老数组数据 copy 到新数据,再覆盖掉老数组的过程。
ArrayList 并不是线程安全的,其方法里既没用到锁,也没用到 CAS 操作。
LinkedList
有序可重复,既可以被当作栈(先进后出)使用,也可以当成队列(先进先出)使用。
LinkedList<Integer> linkedList = new LinkedList<Integer>();
linkedList.offer(1); // 将元素加入队列的尾部
linkedList.offerFirst(2); // 将元素添加到队列的头部
Integer element1 = linkedList.peekLast(); // 访问、并不删除队列的最后一个元素
Integer element2 = linkedList.pollLast(); // 访问、并删除队列的最后一个元素
linkedList.push(3); // 将元素加入栈的顶部
Integer element3 = linkedList.peekFirst(); // 访问、并不弹出栈顶的元素
Integer element4 = linkedList.pop(); // 将栈顶的元素弹出
// 以List的方式 (按索引访问的方式) 来遍历集合元素
for (int i = 0; i < linkedList.size(); i++) {
Integer element = linkedList.get(i);
}
LinkedList 线程不安全的,底层是双向循环链表实现的,可以自增扩容。
public class LinkedList<E> extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable {
transient int size = 0;
transient Node<E> first; // 头节点
transient Node<E> last; // 尾节点
public LinkedList() {
}
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;
}
}
// 省略部分代码
}
对于遍历 List 集合元素,ArrayList 最好使用随机访问方法(get)来遍历,这样性能最好;LinkedList 则最好用迭代器(Iterator)来遍历集合元素。
为啥要有迭代器(模式)? 优势是什么?
- 迭代器模式封装集合内部的复杂数据结构,开发者不需要了解如何遍历,直接使用容器提供的迭代器即可;
- 迭代器模式将集合对象的遍历操作从集合类中拆分出来,放到迭代器类中,让两者的职责更加单一;
- 迭代器模式让添加新的遍历算法更加容易,更符合开闭原则。除此之外,因为迭代器都实现自相同的接口,在开发中,基于接口而非实现编程,替换迭代器也变得更加容易。
fail-fast机制?
在通过迭代器来遍历集合元素的同时,增加或者删除集合中的元素,有可能会导致某个元素被重复遍历或遍历不到。
有两种比较干脆利索的解决方案,来避免出现这种不可预期的运行结果。一种是遍历的时候不允许增删元素,另一种是增删元素之后让遍历报错。第一种解决方案比较难实现,因为很难确定迭代器使用结束的时间点。第二种解决方案更加合理。Java 语言就是采用的这种解决方案。增删元素之后,我们选择 fail-fast 解决方式,让遍历操作直接抛出运行时异常。
Java 语言,迭代器类中除了前面提到的几个最基本的方法之外,还定义了一个 remove() 方法,能够在遍历集合的同时,安全地删除集合中的元素。
Set集合
HashSet
Set<Integer> set = new HashSet<Integer>();
Iterator it = set.iterator(); // 使用Iterator遍历集合元素
while (it.hasNext()) {
Integer i = (Integer) it.next();
}
HashSet 判断两个元素相等的标准:两个对象通过 equals() 方法比较相等;两个对象的 hashCode() 返回值也相等。
通过阅读 HashSet 源码,可以看到,HashSet 是通过 HashMap 实现的。
public class HashSet<E> extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable {
private transient HashMap<E, Object> map;
public HashSet() {
map = new HashMap<>();
}
// 省略部分代码
}
简而言之,HashSet 就是一个 value 为空对象的 HashMap。
TreeSet
TreeSet 采用红黑树的数据结构来存储集合元素,支持两种排序方法:自然排序和定制排序,默认自然排序
TreeSet<Integer> treeSet = new TreeSet<Integer>();
Integer first = treeSet.first(); // 获取第一个元素
Integer last = treeSet.last(); // 获取最后一个元素
SortedSet headSet = treeSet.headSet(25); // 获取小于25的子集, 不包含25
SortedSet tailSet = treeSet.tailSet(10); // 获取大于10的子集, 如果Set中包含10,子集中还包含10
SortedSet subSet = treeSet.subSet(-10, 20); // 获取大于等于-10, 小于20的子集
通过阅读 TreeSet 源码,可以看到,TreeSet 是通过 TreeMap 实现的。
public class TreeSet<E> extends AbstractSet<E>
implements NavigableSet<E>, Cloneable, java.io.Serializable {
private transient NavigableMap<E, Object> m;
TreeSet(NavigableMap<E, Object> m) {
this.m = m;
}
public TreeSet() {
this(new TreeMap<E, Object>());
}
// 省略部分代码
}
简而言之,TreeSet 就是一个 value 为空对象的 TreeMap。
Map集合
- | 线程安全 | 底层实现 | 特点 | 扩容 |
---|---|---|---|---|
HashMap | 线程不安全 | hash 表 | 无序,key 不允许null,value 可以null,为快速查询而设计的 | 默认大小16,如果超过当前大小 * 扩容因子 0.75 就会扩容,扩容 2 倍后重排 |
LinkedHashMap | 线程不安全 | hash 表 | 继承 HashMap | |
TreeMap | 线程不安全 | 红黑树 | 自然有序,key 不允许null,value 可以null,速度略慢于 HashMap | |
Hashtable | 线程安全 | hash 表 |
Map 用于保存具有映射关系的数据(key-value),key 不允许重复,value 可以重复。
速度对比:HashMap(最快) > Hashtable > TreeMap
HashMap
hash(哈希、散列)算法的功能:能保证快速查找被检索的对象,hash 算法的价值在于速度,当查询某个元素时,hash 算法可以直接根据该元素的 hashCode 值计算出该元素的存储位置,从而快速定位。
HashMap、HashTable、ConcurrentHashMap 的区别?
HashMap JDK1.8 前采用了数组 + 链表实现的,数组的特点是查询快增删慢,链表的特点是查询慢增删快,HashMap 结合了两者的优势,同时 HashMap 的操作是非 synchronized 的,因此效率比较高。
最坏情况下,hash() 计算后总是会命中同一个数组元素,那么 HashMap 的性能将会从原先的 O(1) 变成 O(n)。
针对这个问题,JDK1.8 及以后变成了数组 + 链表 + 红黑树实现,使用了一个 TREEIFY_THRESHOLD 常量来控制是否将链表转换为红黑树来存储它们,这意味着可以将最坏情况下的性能从 O(n) 提高到 O(logn)。
HashMap put() 方法的逻辑:
- 若 table 数组未被初始化,则进行初始化操作;
- 对 key 计算 hash 值,依据 hash 值计算 table 数组下标;
- 若未发生碰撞,即该下标的 “桶” 还没有存储头节点,则直接放入 “桶” 的头节点中;
- 若发生碰撞,即该下标的 “桶” 已经存储了头节点,则执行链表的添加操作;
- 若链表的长度超过树化阈值 (TREEIFY_THRESHOLD,默认 8)且数组长度
大于等于64 则被改造成红黑树,而当删除操作导致低于最低树化阈值(默认 6)的时候,红黑树则转回成链表,保证更高的性能; - 若键值对已经存在,则用新值替换旧值;
- 若某个 “桶” 满了(默认容量 16 * 扩容因子 0.75 = 12,则默认超过 12 就会扩容),就需要 resize(扩容 2 倍后重排)。
ConcurrentHashMap
早期的 ConcurrentHashMap 是通过分段锁技术来实现的,将 HashMap 的 table 数组逻辑上拆分成多个子数组,默认会分成 16 段,每个段配一把锁,这样多个线程如果操作的是不同的段,则不会被阻塞,理论上会比 HashTable 的效率提升 16 倍。
JDK 1.8 ConcurrentHashMap 的实现取消了分段锁,而 CAS + synchronized 使锁更细化。同时对结构也做了进一步优化,跟 JDK 1.8 的 HashMap 一样,由数据 + 链表 + 红黑树实现,synchronized 只锁定当前链表或者红黑树的首节点,这样只要 hash 不冲突,就不会产品线程安全问题,效率得到了进一步的提高。
ConcurrentHashMap put() 方法的逻辑:
- 若 table 数组未被初始化,则进行初始化操作;
- 对 key 计算 hash 值,依据 hash 值计算 table 数组下标;
- 若未发生碰撞,即该下标的 “桶” 还没有存储头节点,则使用 CAS 操作放入 “桶” 的头节点中,添加失败则循环重试;
- 检查到内部正在扩容,就帮助它一块扩容;
- 若发生碰撞,即该下标的 “桶” 已经存储了头节点,则使用 synchronized 锁住头节点(链表或者红黑树),如果是链表结构则执行链表的添加操作,如果是红黑树结构则执行树添加操作;
- 若链表的长度超过树化阈值 (TREEIFY_THRESHOLD,默认 8)且数组长度
大于等于64 则被改造成红黑树,而当删除操作导致低于最低树化阈值(UNTREEIFY_THRESHOLD,默认 6)的时候,红黑树则转回成链表,保证更高的性能; - 若键值对已经存在,则用新值替换旧值;
- 若某个 “桶” 满了(默认容量 16 * 扩容因子 0.75 = 12,则默认超过 12 就会扩容),就需要 resize(扩容 2 倍后重排)。
Collections
Collections 工具类提供了大量方法对集合元素进行查询、排序、修改等操作:
// 对Collection集合进行查找
Collections.max(collection); // 获取集合最大元素
Collections.min(collection); // 获取集合最小元素
Collections.frequency(collection , 1); // 判断1在集合中出现的次数
// 同步控制, 创建线程安全的集合对象
Collection c = Collections.synchronizedCollection(new ArrayList());
List l = Collections.synchronizedList(new ArrayList());
Set s = Collections.synchronizedSet(new HashSet());
Map m = Collections.synchronizedMap(new HashMap());
// 对List集合元素进行排序
Collections.reverse(list); // 将元素的次序反转
Collections.sort(list); // 将元素的按自然顺序排序
Collections.shuffle(list); // 将元素的按随机顺序排序
// 对List集合元素进行替换
Collections.replaceAll(list , 0 , 1); // 将List中的0使用1来代替
Collections.binarySearch(list , 1); // 使用二分法搜索指定的List集合, 以获得List集合中的索引, 只有排序后的List集合才可用二分法查询
// 设置不可变集合, 如果试图改变, 将引发UnsupportedOperationException异常
List unList = Collections.emptyList(); // 创建一个空的、不可改变的List对象
Set unSet = Collections.singleton(2); // 创建一个只有一个元素, 且不可改变的Set对象
Map<String, Object> map = new HashMap<String, Object>();
map.put("C" , 100);
Map unMap = Collections.unmodifiableMap(map); // 返回普通Map对象对应的不可变版本
常见问题
1.hashmap的哈希算法?
主要分为三步:
- 取 hashCode 值: key.hashCode()
- 高位参与运算:h>>>16
- 取模运算:(n-1) & hash
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
i = (table.length - 1) & hash;//这一步是在后面添加元素putVal()方法中进行位置的确定
为了让数组元素分布均匀,我们首先想到的是把获得的 hash码对数组长度取模运算( hash%length),但是计算机都是二进制进行操作,取模运算相对开销还是很大的,那该如何优化呢?
HashMap 使用的方法很巧妙,它通过 hash & (table.length -1)来得到该对象的保存位,前面说过 HashMap 底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当 length 总是2的n次方时,hash & (length-1)运算等价于对 length 取模,也就是 hash%length,但是&比%具有更高的效率。比如 n % 32 = n & (32 -1)
2.HashMap扩容机制?
JDK1.7的代码:
void resize(int newCapacity) { //传入新的容量
Entry[] oldTable = table; //引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
transfer(newTable); //!!将数据转移到新的Entry数组里
table = newTable; //HashMap的table属性引用新的Entry数组
threshold = (int)(newCapacity * loadFactor);//修改阈值
}
这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话)
JDK 1.8的resize源码:
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;
}
// 没超过最大值,就扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的resize上限
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) {
// 把每个bucket都移动到新的buckets中
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 { // 链表优化重hash的代码块
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;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}