【Java集合】关于集合源码分析

目录

ArrayList

1.概览

2.扩容-重要的方法

关于扩容的总结:

3.ArrayList为什么是线程不安全的?

4.删除元素

5.什么是Fail-Fast(快速失败):

 6.什么是Fail-Safe(安全失败): 

Vector

1.ArrayList 与 Vector 的区别

2.Vector的替代方案

synchronizedList

CopyOnWriteArrayList

LinkedList

1.添加元素

add(E e)

add(int index, E e)

2.删除元素

3.查找元素

get(int index)

4.总结

5.LinkedList 和 ArrayList的区别?如何选择?

HashMap

1.HashMap底层是如何存储数据的?

2.HashMap的几个主要的方法?

hash()

1.7和1.8的hash有什么区别?

为什么大小一定是2的幂次方?

put()

桶的树形化 treeifyBin()

我觉得如果问到具体红黑树,讲一讲红黑树的定义,以及大概一个添加调整的过程就行了。

resize() 扩容机制

为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是8,而不是7或者不是20呢?

哈希表如何解决Hash冲突?

HashMap允许key为空吗?

为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键

HashMap 中的 key若 Object类型, 则需实现哪些方法?

HashMap与HashTable的区别?

线程不安全-1.7和1.8

HashSet

HashTable

ConcurrentHashMap

LinkedHashMap 和 LinkedHashSet

LinkedHashMap的经典用法

因此,需要实现LRU的时候,只需要两个步骤:

实现FIFO,就按照默认的顺序,当size大于缓存阈值时,就把最后一个元素删了。

容器中的设计模式



集合也是面试必问环节之一。

面试官:有了解过Java的集合吗?平时都用过哪些集合?

我:有了解过,ArrayList、HashMap......

面试官:那你说一下ArrayList的一些常用方法吧,HashMap是线程安全的吗?!@#R*!@..

我:...........

总结完这一篇,下次再遇到这个问题,我就可以开始我的表演了。

  • [ArrayList](#arrayList)
  • Vector
  • LinkedList
  • HashMap
  • HashSet
  • HashTable
  • ConcurrentHashMap
  • LinkedHashMap

会列出源码并且给出一个小总结,只看总结其实也可。都是基于JDK1.8的源码,在需要对比时会进行出说明。

ArrayList

1.概览

基于数组实现,默认大小为10

    /**
     *   默认初始大小 = 10
     */
    private static final int DEFAULT_CAPACITY = 10;

两个空数组

  • EMPTY_ELEMENTDATA: 出现在需要用到空数组的地方,其中一处就是使用自定义初始容量构造方法时候如果你指定初始容量为0的时候就会返回。
  • DEFAULTCAPACITY_EMPTY_ELEMENTDATA:是用来使用默认构造方法时候返回的空数组。如果第一次添加数据的话那么数组扩容长度为默认初始大小=10
    /**
     * Shared empty array instance used for empty instances.
     * 出现在需要用到空数组的地方。
     */
    private static final Object[] EMPTY_ELEMENTDATA = {};

    /**
     *使用默认的构造方法时返回的空数组,将它和EMPTY_ELEMENTDATA区分是为了知道我们在添加第一个元素 
     *的时候如何扩容
     */
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

有一个size表示数组的大小,ArrayList包含的元素数目


    private int size;

2.扩容-重要的方法

当向数组添加元素时,可能会触发扩容。

首先,添加元素使用add(E e)方法:

    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;//线程不安全的原因在这里!
        return true;
    }

在这个方法中,首先会去调用ensureCapacityInternal(size + 1)这个方法,判断添加元素后数组是否需要扩容

    private void ensureCapacityInternal(int minCapacity) {
        ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
    }

这个方法里会先调用calculateCapacity(elementData,minCapacity)这个方法判断当前的数组是否是默认构造方法生成的空数组如果是的话会在默认的10和传进来的大小里返回最大值

如果不是默认的空数组而是我们自定义长度而使用的空数组,就会直接返回传进来的这个容量大小

    private static int calculateCapacity(Object[] elementData, int minCapacity) {
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            return Math.max(DEFAULT_CAPACITY, minCapacity);
        }
        return minCapacity;
    }

然后得到计算后容量后,会调用ensureExplicitCapacity(minCapacity)方法。

这里的逻辑是:当传入的这个容量大于数组原本的长度,就要进行扩容。

我们也可以知道,之前的所有操作都是在计算容量、判断是否进行扩容,grow()才是扩容的逻辑。

    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;

        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }

grow的逻辑是这样的:

oldCapacity代表原数组的长度,newCapacity = 旧数组的1.5倍长度,也就是扩容后的长度。

如果newCapacity(扩容后) < minCapacity(计算得到的容量),那么扩容的容量就设置为传入的容量。

如果newCapacity(扩容后) > MAX_ARRAY_SIZE (Integer.MAX_VALUE-8),会调用hugeCapacity进行处理,这里简单说一下hugeCapacity()的实现:
如果传入的容量小于0,说明溢出了(大于Integer的最大值,溢出为负数),那么抛出OOM错误。
否则根据与MAX_ARRAY_SIZE的比较情况确定是返回Integer最大值还是MAX_ARRAY_SIZE。

这里可以看到ArrayList允许的最大容量就是Integer.MAX_VALUE;

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

最后一步使用Array.copyOf方法,生成一个新数组,将旧数组的值拷贝到新数组中,然后更改elementData的引用。

 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);
    }

关于那两个空数组,还需要再多解释一下,这里分情况讨论吧:

1.假如现在使用的是默认构造方法生成了一个ArrayList,并且要添加一个元素进去。

List list = new ArrayList<>();
list.add(100);

在add方法中,首先会去调用ensureCapacityInternal(size + 1)这个方法,也就是ensureCapacity(1).

那么在calculateCapacity(elementData,minCapacity=1)中,会return 10,因为我们用默认的构造方法,它会返回默认大小和我们传进来1两者的最大值。

继续在ensureExplicitCapacity(minCapacity = 10)中,触发扩容条件,进入grow扩容。

此时OldCapacity = 0,newCapacity = 0,进入第一个if分支,此时newCapacity = 10

因此本次扩容为默认初始大小10,以后触发扩容时,会扩大成旧数组的1.5倍。

2.假设现在使用了自定义构造方法并传参为0生成一个ArrayList,添加一个元素进去。

List list = new ArrayList<>(0);
list.add(100);

在add方法中,首先会去调用ensureCapacityInternal(size + 1) 这个方法,也就是ensureCapacity(1).

那么在calculateCapacity(elementData,minCapacity=1)中,会return 1,因为我们用自定义的构造方法,他会直接返回传进来的minCapacity。

继续在ensureExplicitCapacity(minCapacity = 1)中,触发扩容条件,进入grow扩容。

此时oldCapacity  = 0, newCapacity = 0,进入第一个if分支,会将newCapacity更新为1

这边可以看到一个问题,一旦我们执行了初始容量为0,那么根据下面的算法前四次扩容每次都 +1(可以对照grow进行模拟),在5次添加数据进行扩容的时候才是按照当前容量的1.5倍进行扩容。(第五次old = 4, new = 6,1.5倍)

3.假设现在自定义了数组的大小,并且add一个元素

List list = new ArrayList<>(20);
list.add(100);

放一下构造方法:

 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);
        }
    }


    public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

首先初始化就会让elementData指向一个容量为20的数组。

那么add的时候,ensureCapacityInternal(size + 1),即ensureCapacityInternal(1),这里注意size = 0,此时还没有元素

计算calculateCapacity(1)中,会直接return 1

继续在ensureExplicitCapacity中,1 - elementData.length < 0,不触发grow()扩容,直接添加元素

element[size++] = 100;

关于扩容的总结:

除了自定义大小(不为0)之外,用默认构造方法或者自定义为0的时候,都会触发扩容,默认构造方法定义的数组,在第一次添加元素时,会扩容为默认容量10;自定义为0的时候,前4次添加元素都只会扩大1,第5次才是oldCapacity的1.5倍。

因此我们使用ArrayList的时候,尽量定义一个大小,减少扩容,因为他扩容的时候是复制操作,效率不高。

3.ArrayList为什么是线程不安全的?

在讨论add方法时,重心都是放在扩容的情况下,当我们扩容完成之后,会进行添加操作,这个操作本身就是线程不安全的。

elementData[size++] = e;

假如现在size = 5,此时线程A和线程B都去add,那么有可能就会覆盖掉某个线程修改过的元素,造成数据的丢失。

4.删除元素

删除元素实际上就是将被删除元素后面的元素向前移动的一个过程

解释一下System.arraycopy(srcArray, srcPos, desArray,desPos,len),将src数组从srcPos位置开始的len个元素,复制到desArray,从index位置开始。

elementData[-size] = null,复制后最后一个元素是重复的,可以设置它的引用为null,方便GC

public E remove(int index) {
        rangeCheck(index);

        modCount++;
        E oldValue = elementData(index);

        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        elementData[--size] = null; // clear to let GC do its work

        return oldValue;
    }

5.什么是Fail-Fast(快速失败):

在进行序列化或迭代等操作时,如果集合发生了改变,则抛出异常,防止继续遍历。

如果我们不希望在迭代器遍历的时候因为并发等原因,导致集合的结构被改变,进而可能抛出异常的话,我们可以在涉及到会影响到modCount值改变的地方,加上同步锁(synchronized),或者直接使用 Collections.synchronizedList来解决。

 6.什么是Fail-Safe(安全失败): 

采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。

原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception

缺点:基于拷贝内容的优点是避免了Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。

场景:java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。

 

Vector

实现与ArrayList相似,不过它是线程安全的,因为方法使用synchronized修饰了。

    public synchronized boolean add(E e) {
        modCount++;
        ensureCapacityHelper(elementCount + 1);
        elementData[elementCount++] = e;
        return true;
    }
    public synchronized E get(int index) {
        if (index >= elementCount)
            throw new ArrayIndexOutOfBoundsException(index);

        return elementData(index);
    }

1.ArrayList 与 Vector 的区别

1.ArrayList不是线程安全的,Vector是线程安全的。

2.Vector每次扩容是2倍,ArrayList是1.5倍。

2.Vector的替代方案

synchronizedList

为了获得线程安全的 ArrayList,可以使用 Collections.synchronizedList(); 得到一个线程安全的 ArrayList。

List list = new ArrayList<>();
List synList = Collections.synchronizedList(list);

CopyOnWriteArrayList

java.util.concurrent包下边的CopyOnWriteArrayList

List list = new CopyOnWriteArrayList<>();

CopyOnWrite是写时复制的容器,可以这么理解,向容器里添加元素时,不是添加到当前容器中,而是复制一个新容器,在新的里边添加,添加完成之后再将修改引用。

这样的好处是,可以并发的读,而不需要加锁。也是一种读写分离的思想,读和写不同的容器。

add()方法

使用ReentrantLock进行加锁,关于ReentrantLock将会在并发专题总结。

    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();//加锁
        try {
            Object[] elements = getArray(); //获得当前数组
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1); //生成一个新的数组
            newElements[len] = e; //新数组中添加元素
            setArray(newElements); //设置引用
            return true;
        } finally {
            lock.unlock();//释放
        }
    

写的时候锁住的是新的数组,因此读不需要加锁,不过也不会读到最新修改的内容,因为读还是在读旧数组。

    @SuppressWarnings("unchecked")
    private E get(Object[] a, int index) {
        return (E) a[index];
    }

CopyOnWrite的缺点:

1.内存占用。写的时候内存中会有两个数组,如果这些对象占用的内存比较大,比如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的Yong GC和Full GC

2.数据一致性。只能保证最终的一致性,如果需要立刻读取修改后的数据,使用CopyOnWrite是不合适的。

LinkedList

底层基于双向链表实现。

LinkedList 同时实现了 List 接口和 Deque 接口,也就是说它既可以看作一个顺序容器,又可以看作一个队列(Queue),同时又可以看作一个栈(Stack)。

当你需要使用栈或者队列时,可以考虑使用 LinkedList ,一方面是因为 Java 官方已经声明不建议使用 Stack 类,更遗憾的是,Java里根本没有一个叫做 Queue 的类(它是个接口)。

关于栈或队列,现在的首选是 ArrayDeque,它有着比LinkedList (当作栈或队列使用时)有着更好的性能(因为它底层是数组,get特定index更快)。

public class LinkedList
    extends AbstractSequentialList
    implements List, Deque, Cloneable, java.io.Serializable

有两个指针,指向链表头部和尾部。

 transient Node first;

 transient Node last;

内部使用Node来存储链表节点信息,节点包括元素item以及前后两个指针。

    private static class Node {
        E item;
        Node next;
        Node prev;

        Node(Node prev, E element, Node next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }

1.添加元素

add(E e)

该方法在 LinkedList 的末尾插入元素,因为有last指向链表末尾,在末尾插入元素的花费是常数时间。只需要简单修改几个相关引用。

    public boolean add(E e) {
        linkLast(e);
        return true;
    }

    void linkLast(E e) {
        final Node l = last;
        final Node newNode = new Node<>(l, e, null);
        last = newNode;
        if (l == null)
            first = newNode;
        else
            l.next = newNode;
        size++;
        modCount++;
    }

add(int index, E e)

在指定位置插入元素,如果index == size,就插在表尾,否则将e插在node(index)这个方法返回的元素前面。

node方法是用来确定index位置靠近头还是靠近尾,用 index 和 size >> 1比较,靠近头就从头开始找,靠近尾就从尾开始找,也能发现,如果index是在中间的话每次都要遍历一半的元素

    public void add(int index, E element) {
        checkPositionIndex(index);

        if (index == size)
            linkLast(element);
        else
            linkBefore(element, node(index));
    }


    Node node(int index) {
        // assert isElementIndex(index);

        if (index < (size >> 1)) {
            Node x = first;
            for (int i = 0; i < index; i++)
                x = x.next;
            return x;
        } else {
            Node x = last;
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }

2.删除元素

删除也有两种,一种是删除跟指定元素相等的第一个元素,另一种是删除指定下标位置的元素。线性时间。

3.查找元素

get(int index)

可以看到也调用了node方法,实际上就是判断index距离两端的位置,从而决定是从头开始找还是从尾开始找,如果index在中间,需要遍历一般的元素,效率不高。

    public E get(int index) {
        checkElementIndex(index);
        return node(index).item;
    }

4.总结

1.LinkedList插入只需要移动指针,效率较高;删除需要先找到删除的节点或位置,线性时间,不过删除也只需要移动指针。

2.LinkedList查找是线性时间的,效率不高。

5.LinkedList 和 ArrayList的区别?如何选择?

1.两者在底层的实现上有区别。ArrayList是基于动态数组实现,LinkedList是基于链表实现,双向链表。

2.当随机访问时,ArrayList的效率要高于LinkedList,LinkedList需要指针移动查找,并且查找时根据index的位置决定是从头开始还是从尾开始,ArrayList基于下标可以在O(1)的时间完成。

3.当进行添加和删除时,LinkedList效率要高于ArrayList,LinkedList虽然要找到对应的位置,但只需要更改指针;ArrayList需要移动元素。

4.从利用效率看,ArrayList如果不指定初始大小,或者指定初始大小为0,则会频繁地扩容。而LinkedList不需要指定容量。

如果说操作多是基于索引的查找,只需要在末尾插入或删除元素,用ArrayList。

如果操作多是在指定位置插入或删除元素,使用LinkedList。

HashMap

1.HashMap底层是如何存储数据的?

JDK1.7 采用数组+链表存储,拉链法解决冲突。

JDK1.8 采用数组+链表+红黑树,当链表长度超过阈值8时,转换红黑树(不过也要看是否满足哈希表最小树形化容量)

使用Node数组来作为Hash桶,每个位置存放一个Node节点,相同哈希值的节点用next指针连起来。

transient Node[] table;

【Java集合】关于集合源码分析_第1张图片

2.HashMap的几个主要的方法?

先明确几个重要的参数

当元素数目超过threshold就要扩容,扩容为之前大小的2倍,扩大为2倍也是为了在取模和扩容时做优化。

Map中实际键值对的数量
transient int size;

负载因子(默认值是0.75)
final float loadFactor;

Load factor为负载因子(默认值是0.75) = (capacity * load factor).
int threshold;

还有几个重要参数

数组初始大小 
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

//一个桶的树化阈值 
//当桶中元素个数超过这个值时,需要使用红黑树节点替换链表节点
//这个值必须为 8,要不然频繁转换效率也不高
static final int TREEIFY_THRESHOLD = 8;

//一个树的链表还原阈值 
//当扩容时,桶中元素个数小于这个值,就会把树形的桶元素 还原(切分)为链表结构
//这个值应该比上面那个小,避免频繁转换
static final int UNTREEIFY_THRESHOLD = 6;

//哈希表的最小树形化容量 
//当哈希表中的容量大于这个值时,表中的桶才能进行树形化
//否则桶内元素太多时会扩容,而不是树形化 
//为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;

不管是增加、删除还是查找,都要先定位到哈希桶数组的索引,所以主要是通过他的hash方法来实现:

hash()

如果插入null值,那么hash的结果是0,这里可以看出来HashMap允许key为null。

1.先对key取hashCode(),这个hashCode方法是native方法,不是java实现的。

2.取完hashCode之后,再和低16位进行异或操作
 高低位异或是保证当table的length较小的时候也能保证高低位都参与计算(下一步的计算),同时开销不会太大

3.实际上还有一步,根据计算的结果h & (n - 1)。不过这个操作已经融合到put,get等方法中了。

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

1.7和1.8的hash有什么区别?

在计算hash值的时候,JDK1.7用了9次扰动处理=4次位运算+5次异或,而JDK1.8只用了2次扰动处理=1次位运算+1次异或

为什么大小一定是2的幂次方?

主要是为了在取模扩容时做优化 。

1.扩容为之前的2倍后,元素位置要么在原位,要么在原位的基础再加上oldCapaticy,不用重新计算hash;在扩容的过程中,由于新增的1bit位置可以认为是随机的,因此相当于把原来冲突的元素均匀地分散了,而且不会导致顺序倒置。

2.取模使用h & (n - 1),当大小为2的幂次方时,这个运算等价于取模运算,但却比取模运算有更高的效率

put()

源码太长了,太恐怖了。

主要的逻辑:

如果table为空或table的length == 0,就resize()扩容;

根据key计算hash得到索引i,如果索引位置table[i]没有元素,就直接存入key;(存完检查一下size有没有超过threshold,超过扩容)

如果不为空, 这个key刚好等于table[i]的第一个元素,覆盖;

如果不为空,判断这个节点是什么节点,如果是树节点,就直接插入红黑树;不是树节点,判断链表长度,如果大于8了就转换成红黑树,然后插入红黑树,如果没有超过8,就直接在链表里插入,遍历插入的过程中如果有相同的key就覆盖即可。

插入成功后要检查是否超过threshold,超过就扩容。

 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node[] tab; Node 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 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)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) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

在put的过程中,涉及了红黑树的操作。

桶的树形化 treeifyBin()

如果一个桶中的元素个数超过 TREEIFY_THRESHOLD(默认是 8 ),就使用红黑树来替换链表,从而提高速度。这个转换就叫树形化。

源码太恐怖了。

源码逻辑:

  • 根据哈希表中元素个数确定是扩容还是树形化
    如果当前哈希表为空,或者哈希表中元素的个数小于进行树形化的阈值(默认为 64),就去新建/扩容。
  • 如果是树形化 
    • 遍历桶中的元素,创建相同个数的树形节点,复制内容,建立起联系
    • 然后让桶第一个元素指向新建的树头结点,替换桶的链表内容为树形内容

最后调用树形节点 hd.treeify(tab) 方法进行塑造红黑树

//头回进入循环,确定头结点,为黑色

//后面进入循环走的逻辑,x 指向树中的某个节点

//又嵌套一个循环,从根节点开始,遍历所有节点跟当前节点 x 比较,调整位置,

把当前节点变成 x 的父亲,如果当前比较的节点比x大,x就是左孩子,否则x就是右孩子,保持一个BST的性质。

    final void treeifyBin(Node[] tab, int hash) {
        int n, index; Node e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode hd = null, tl = null;
            do {
                TreeNode 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);
        }
    }

我觉得如果问到具体红黑树,讲一讲红黑树的定义,以及大概一个添加调整的过程就行了。

红黑树的定义

1.每个节点都是红色or黑色。

2.根节点是黑色。

3.红节点的孩子是黑色。

4.每个叶子结点是黑色。

5.从任意节点到叶子结点,经过的黑节点数目相同。

红黑树是一个保持黑平衡的树,严格意义上不是平衡的二叉树。最大高度为2logn。

红黑树中添加节点,永远都添加红节点。

添加完之后根据定义进行旋转和颜色的调整。

 

resize() 扩容机制

扩容主要是重新生成一个新数组,来代替原来的数组,扩容为原来的两倍。

1.7 需要对每个entry重新计算hash值,而且链表采用头插法,当有两个hash一样的时候,会导致原有顺序倒置在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

1.8 不需要重新计算hash值,我们在扩充HashMap的时候,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,而且链表使用尾插法,不会形成环。

为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是8,而不是7或者不是20呢?

  • 如果选择6和8(如果链表小于等于6树还原转为链表,大于等于8转为树),中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
  • 还有一点重要的就是由于treeNodes的大小大约是常规节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们,当它们变得太小(由于移除或调整大小)时,它们会被转换回普通的node节点,容器中节点分布在hash桶中的频率遵循泊松分布,桶的长度超过8的概率非常非常小。所以作者应该是根据概率统计而选择了8作为阀值

来自:https://blog.csdn.net/qq_36520235/article/details/82417949

哈希表如何解决Hash冲突?

è¿éåå¾çæè¿°

HashMap允许key为空吗?

允许,key 为 null时,hash为0,也就是只能有一个为null的key,后序插入key为null,会覆盖掉之前的null。

为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键

String 和Integer的特性保证了hash值的不可更改性。有效减少了碰撞。

具体的:

都是final修饰的,保证key的不可更改。

都重写了equals和hashCode 不容易出现计算错误。

HashMap 中的 key若 Object类型, 则需实现哪些方法?

重写equals()和hashCode()。

hashCode():计算存储位置,实现不恰当容易出现碰撞。

equals():保证这个key的唯一性。

HashMap与HashTable的区别?

HashTable使用synchronized实现同步。

Hashtable不允许 null 值(key 和 value 都不可以),HashMap允许 null 值(key和value都可以)

哈希值的使用不同,Hashtable直接使用对象的hashCode。而HashMap重新计算hash值,而且用于代替求模

Hashtable中hash数组默认大小是11,增加的方式是 old*2+1。HashMap中hash数组的默认大小是16,而且一定是2的指数
 

线程不安全-1.7和1.8

https://blog.csdn.net/swpu_ocean/article/details/88917958

HashSet

HashSet 是对 HashMap 的简单包装,对 HashSet 的函数调用都会转换成合适的 HashMap 方法,因此 HashSet的实现非常简单,只有300多行代码(适配器模式)

主要还是用HashMap来存数据,Value则存入一个固定的PRESENT

    private transient HashMap map;

    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();

add方法保证了Set中只会有不重复的元素,因为hashmap的key就不能重复,即便添加重复的,也只会有一个。

    public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }

HashTable

和HashMap的对比已经在之前写了。

ConcurrentHashMap

它是线程安全的。

1.7的实现是Segment + ReentrantLock

1.8的实现是synchronized + CAS

在JVM的讨论中,有说到这两个版本的区别。

1.7中的ConcurrentHashMap的Segment属于减小锁力度。(1.8不用Segment了)

使用Segment+ReentrantLock的好处:

首先,当我们使用put方法的时候,是对我们的key进行hash拿到一个整型,然后将整型对16取模,拿到对应的Segment,之后调用Segment的put方法,然后上锁,请注意,这里lock()的时候其实是this.lock(),也就是说,每个Segment的锁是分开的

其中一个上锁不会影响另一个,此时也就代表了我可以有十六个线程进来,而ReentrantLock上锁的时候如果只有一个线程进来,是不会有线程挂起的操作的,也就是说只需要在AQS里使用CAS改变一个state的值为1,此时就能对代码进行操作,这样一来,我们等于将并发量/16了。

为什么使用synchronized+CAS呢?

putVal的源码得到的结论:

Synchronized是靠对象的对象头和此对象对应的monitor来保证上锁的,也就是对象头里的重量级锁标志指向了monitor,而monitor呢,内部则保存了一个当前线程,也就是抢到了锁的线程.

那么这里的这个f是什么呢?它是Node链表里的每一个Node,也就是说,synchronized是将每一个Node对象作为了一个锁,这样做的好处是什么呢?将锁细化了,也就是说,除非两个线程同时操作一个Node,注意,是一个Node而不是一个Node链表哦,那么才会争抢同一把锁.

如果使用ReentrantLock其实也可以将锁细化成这样的,只要让Node类继承ReentrantLock就行了,这样的话调用f.lock()就能做到和Synchronized(f)同样的效果,但为什么不这样做呢?

请大家试想一下,锁已经被细化到这种程度了,那么出现并发争抢的可能性还高吗?还有就是,哪怕出现争抢了,只要线程可以在30到50次自旋里拿到锁,那么Synchronized就不会升级为重量级锁,而等待的线程也就不用被挂起,我们也就少了挂起和唤醒这个上下文切换的过程开销.

但如果是ReentrantLock呢?它则只有在线程没有抢到锁,然后新建Node节点后再尝试一次而已,不会自旋,而是直接被挂起,这样一来,我们就很容易会多出线程上下文开销的代价.当然,你也可以使用tryLock(),但是这样又出现了一个问题,你怎么知道tryLock的时间呢?在时间范围里还好,假如超过了呢?

所以,在锁被细化到如此程度上,使用Synchronized是最好的选择了.这里再补充一句,Synchronized和ReentrantLock他们的开销差距是在释放锁时唤醒线程的数量,Synchronized是唤醒锁池里所有的线程+刚好来访问的线程,而ReentrantLock则是当前线程后进来的第一个线程+刚好来访问的线程.

如果是线程并发量不大的情况下,那么Synchronized因为自旋锁,偏向锁,轻量级锁的原因,不用将等待线程挂起,偏向锁甚至不用自旋,所以在这种情况下要比ReentrantLock高效。

来自:https://www.cnblogs.com/yangfeiORfeiyang/p/9694383.html

LinkedHashMap 和 LinkedHashSet

LinkedHashSet也是将LinkedHashMap包装了一下,和HashMap与HashSet一样。

LinkedHashMap是增强的HashMap,它在HashMap的基础采用双向链表将entry都连接起来了。

对于它的操作,就是在Hash Map的基础上维护一个双向的指针,遍历的时候不需要像HashMap那样遍历整个table,而是只遍历header指向的链表即可,也就是说遍历只和entry数量有关,与table大小无关。

LinkedHashMap的经典用法

1.LRU缓存

2.FIFO缓存

重要的构造方法:

其中accessOrder是实现LRU以及FIFO必要的参数

当accessOrder = true时,按照访问顺序排序,先访问的会被放在链表尾部;

当accessOrder = false时,按照插入顺序排序。

public LinkedHashMap(int initialCapacity,float loadFactor,boolean accessOrder) {

        super(initialCapacity, loadFactor);

        this.accessOrder = accessOrder;

}

按照LRU的思想,在给定容量的情况下,如果无法放置新元素,则需要淘汰掉最近最久没有使用的元素;同时如果某个元素被访问了,我们应该更新这个元素所处的位置。

那么,应当在put和get方法中去完成淘汰或者更新位置。

LinkedHashMap是怎么做的?

get():

当accessOrder为true时,调用afterNodeAccess(e)方法

public V get(Object key) {

        Node e;

        if ((e = getNode(hash(key), key)) == null)

            return null;

        if (accessOrder)

            afterNodeAccess(e);

        return e.value;

}

如果当前链表最后一个元素是我们需要往后移动的元素,不需要做任何操作。afterNodeAccess:主要是将被访问过的元素放在链表尾部。

如果当前链表最后的元素不是传来的e,我们需要将当前e放在链表末尾。

void afterNodeAccess(Node e) { // move node to last

        LinkedHashMap.Entry last;

        if (accessOrder && (last = tail) != e) {

            LinkedHashMap.Entry p =

                (LinkedHashMap.Entry)e, b = p.before, a = p.after;

            p.after = null;

            if (b == null)

                head = a;

            else

                b.after = a;

            if (a != null)

                a.before = b;

            else

                last = b;

            if (last == null)

                head = p;

            else {

                p.before = last;

                last.after = p;

            }

            tail = p;

            ++modCount;

        }

    }

 

LinkedHashMap里边没有put方法的重写,去HashMap里边看一眼,在put方法执行完加入一个entry后,会调用afterNodeInsertion(evict)方法:

当removeEldestEntry为true时,会删除链表第一个节点。

void afterNodeInsertion(boolean evict) { // possibly remove eldest

        LinkedHashMap.Entry first;

        if (evict && (first = head) != null && removeEldestEntry(first)) {

            K key = first.key;

            removeNode(hash(key), key, null, false, true);

        }

    }

该方法默认返回false,不过可以根据需要重写该方法,当前容量>一个阈值时,就返回true。removeEldestEntry:

protected boolean removeEldestEntry(Map.Entry eldest) {

        return false;

    }

因此,需要实现LRU的时候,只需要两个步骤:

1.继承LinkedHashMap类,并在构造方法中设置accessOrder = true

2.重写removeEldestEntry

public class LRUCache extends LinkedHashMap {

    private final int cache;

    public LRUCache(int capacity) {

        super(capacity, 0.75f, true);
        cache = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > cache;
    }
}

实现FIFO,就按照默认的顺序,当size大于缓存阈值时,就把最后一个元素删了。

class FIFOCache extends LinkedHashMap{

    private final int cacheSize;

    public FIFOCache(int cacheSize){
        this.cacheSize = cacheSize;
    }

    // 当Entry个数超过cacheSize时,删除最老的Entry
    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > cacheSize;
    }
}

 

容器中的设计模式

迭代器模式

就是提供一种方法对一个容器对象中的各个元素进行访问,而又不暴露该对象容器的内部细节。

Collection 实现了 Iterable 接口,其中的 iterator() 方法能够产生一个 Iterator 对象,通过这个对象就可以迭代遍历 Collection 中的元素

1.5之后可以使用 foreach 方法来遍历实现了 Iterable 接口的聚合对象

适配器模式

把一个类接口转换成另一个用户需要的接口

Arrays.asList() 可以把数组类型转换为 List 类型 不能使用基本类型数组作为参数,只能使用相应的包装类型数组 ,还有HashSet是对HashMap的简单包装,LinkedHashSet是对LinkedHashMap的简单包装。

你可能感兴趣的:(Java,笔试&面试)