Java 集合类

Java 集合类

Collections 框架图

List 框架图

Set 框架图

Map 框架图

Queue 框架图

HashMap

并发下的 HashMap 会有哪些安全问题?

这里我只是简单的说了写覆盖不可重复读(联想到数据库了),面试官就放我过了。

添加元素时头插还是尾插?

1.7 头插,1.8 尾插。

数组长度为什么必须是 2 的指数幂

减少哈希冲突,均匀分布元素。

1)通过将 Key 的 hash 值与 length-1 进行 & 运算,实现了当前 Key 的定位,2 的幂次方可以减少冲突(碰撞)的次数,提高 HashMap 查询效率;

2)如果 length 为 2 的次幂,则 length-1 转化为二进制必定是 11111…… 的形式,在于 h 的二进制与操作效率会非常的快,而且空间不浪费;如果 length 不是 2 的次幂,比如 length 为 15,则 length-1 为 14,对应的二进制为 1110,在于 h 与操作,最后一位都为 0,而 0001,0011,0101,1001,1011,0111,1101 这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空间的浪费。

如何扩容的

(1) 初始扩容因子

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final float DEFAULT_LOAD_FACTOR = 0.75f;

为什么是 0.75 这个值呢?

这是因为对于使用链表法的哈希表来说,查找一个元素的平均时间是 O(1+n),这里的 n 指的是遍历链表的长度,因此加载因子越大,对空间的利用就越充分,这就意味着链表的长度越长,查找效率也就越低。如果设置的加载因子太小,那么哈希表的数据将过于稀疏,对空间造成严重浪费。

(2) 何时扩容

首次 put 的时候,如果 table 为空,会执行扩容:

if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;

put 方法中,size++ 之后的值如果大于 threshold,那么也会执行 resize 方法。

if (++size > threshold)
    resize();

(3) 扩容扩多少

首次扩容:

newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

非首次扩容,新的容量和新的 threshold 都是翻一倍

newCap = oldCap << 1;
newThr = oldThr << 1;

元素如何迁移

  • 创建新的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
  • 遍历旧数组
for (int j = 0; j < oldCap; ++j) {
    Node<K,V> e;
    if ((e = oldTab[j]) != null) {
        // ...
    }
}
  • 如果 e.next 不为空,即数组里面只存储了这一个元素
newTab[e.hash & (newCap - 1)] = e;
  • 如果 eTreeNode
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
  • 如果 enext,维持原来的顺序,挂到新的数组上

原来的元素在重新计算 hash 之后,会放到两个数组的坑里面。

// (e.hash & oldCap) == 0 的属于 loHead
newTab[j] = loHead;
// 否则属于 hiHead
newTab[j + oldCap] = hiHead;

头插法设计思路

JDK1.7是考虑新增数据大多数是热点数据,所以考虑放在链表头位置,也就是数组中,这样可以提高查询效率,但这种方式会出现插入数据是逆序的。在JDK1.8开始hashmap链表在节点长度达到8之后会变成红黑树,这样一来在数组后节点长度不断增加时,遍历一次的次数就会少很多,相比头插法而言,尾插法操作额外的遍历消耗已经小很多了。

也有很多人说避免多线程情况下hashmap扩容时的死循环问题,我个人觉得避免死循环的关键不在尾插法的改变,而是扩容时,用了首尾两个指针来避免死循环。这个我会在后面的多线程中讲到hashmap扩容导致死循环的问题。

get 方法如何实现的

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}
  • 首先使用 (n - 1) & key.hash 定位到这个 key 应该落到 table 的哪一个里面,其中 n 是指 table 的长度。
  • 然后看这个桶里面的 first 元素是否是目标值,如果是,直接返回。
  • 否则看 first 是否属于 TreeNode,如果是 TreeNode,则调用 getTreeNode 方法;否则沿着 e.next 往下遍历,并依次检查条件 key.hash == k.hash 并且 key.equals(k)

红黑树

static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;

Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too small (due to removal or resizing) they are converted back to plain bins. In usages with well-distributed user hashCodes, tree bins are rarely used. Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution (http://en.wikipedia.org/wiki/Poisson_distribution) with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-0.5) pow(0.5, k) / factorial(k)). The first values are:

根据泊松分布实验发现,假设hashmap长度length为16,假设放入12(0.75*16)个数据到hashmap中,链表中存放8个节点的概率仅为0.00000006,而链表中存放1~7节点的概率为:

0:    0.60653066
1:    0.30326533
2:    0.07581633
3:    0.01263606
4:    0.00157952
5:    0.00015795
6:    0.00001316
7:    0.00000094
8:    0.00000006

TreeMap

如何保证有序

只需要看 put 方法的部分细节即可:

Comparator<? super K> cpr = comparator;
if (cpr != null) {
    do {
        parent = t;
        cmp = cpr.compare(key, t.key);
        if (cmp < 0)
            t = t.left;
        else if (cmp > 0)
            t = t.right;
        else
            return t.setValue(value);
    } while (t != null);
}

LinkedHashMap

特点

  • 循环双向链表
  • key 和 value 都允许为空
  • key 重复会覆盖
  • 有序的
  • LinkedHashMap 是非线程安全的

Entry

LinkedHashMapEntry<K,V> 继承了 HashMap.Node<K,V> 的数据结构,并且新增了 beforeafter 来维护链表插入/访问顺序。head 存储第一个头结点,tail 存储尾结点,accessOrder=true 链表维护访问顺序,false 链表维护插入顺序。这个访问顺序指的是按照最近被访问的Entry的顺序进行排序(从最近最少访问到最近最多访问)。基于这点可以简单实现一个采用**LRU(Least Recently Used)**策略的缓存。

static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

get

public V get(Object key) {
    Node<K,V> e;
    if ((e = getNode(hash(key), key)) == null)
        return null;
    if (accessOrder)
        afterNodeAccess(e);
    return e.value;
}

ConcurrentHashMap

  • jdk1.8之前,ConcurrentHashMap采用分段锁(Segment),对不同段的Entry进行操作互不影响;
  • jdk1.8之后,ConcurrentHashMap直接使用Node数组+链表+红黑树,通过synchronized和CAS来保证并发的可靠性。

ArrayList

初始容量

/**
 * Default initial capacity.
 */
private static final int DEFAULT_CAPACITY = 10;

/**
 * Shared empty array instance used for default sized empty instances. We
 * distinguish this from EMPTY_ELEMENTDATA to know how much to inflate when
 * first element is added.
 */
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

/**
 * Constructs an empty list with an initial capacity of ten.
 */
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

new ArrayList() 时拥有的是一个空数组,在第一次 add 的时候,才会创建一个长度为 10 的数组。

如何扩容

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    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);
}
  • 扩容后新的数组长度为 oldCapacity + (oldCapacity >> 1),也就是旧容量的 1.5 倍
  • 然后使用 Arrays.copyOf 静态方法将旧数组里面的内容全部拷贝到新的数组里面

放满了会发生什么

抛出 OOM 异常:

private static int hugeCapacity(int minCapacity) {
    if (minCapacity < 0) // overflow
        throw new OutOfMemoryError();
    return (minCapacity > MAX_ARRAY_SIZE) ?
        Integer.MAX_VALUE :
        MAX_ARRAY_SIZE;
}

多线程使用

  • 数组越界异常
  • 元素值覆盖和为空问题
elementData[size++] = e

transient

transient Object[] elementData;

ArrayList在序列化的时候会调用writeObject,直接将sizeelement写入ObjectOutputStream;反序列化时调用readObject,从ObjectInputStream获取sizeelement,再恢复到elementData

为什么不直接用elementData来序列化,而采用上诉的方式来实现序列化呢?原因在于elementData是一个缓存数组,它通常会预留一些容量,等容量不足时再扩充容量,那么有些空间可能就没有实际存储元素,采用上诉的方式来实现序列化时,就可以保证只序列化实际存储的那些元素,而不是整个数组,从而节省空间和时间。

参考