Java常用集合

182 阅读45分钟

ArrayList

1、JDK1.8中ArrayList的特点

  1. 实现接口:实现了ListRandomAccessCloneableJava.io.Serializable接口

    • 实现RandomAccess接口对ArrayList支持快速随机访问做一个标识【标识是否可以通过下标访问Collections.copy list类型时有区分】
    • 实现Cloneable接口,标识着可以被复制,这里这里的clone()是浅复制
    • 实现serializable接口,标识支持序列化传输
  2. 底层实现+默认容量+扩容:

    底层使用数组实现,默认数组长度为10,当超出数组容量后会自动扩容为原来的1.5倍,新建一个大容量数组,然后将原始数组拷贝到新数组,扩容的性能开销比较高,实际使用中应该尽可能根据需求指定容量,应尽量扩容次数。

  3. fail-fast机制:面对并发修改的场景,迭代器很快就会完全失败,报并发修改异常

    ConcurrentModificationException,而不是冒着未来某个不确定时间发生不确定行为的风险。

  4. remove元素

    remove方法会让删除位置到末尾元素向前移动一个单位,并把最后一位置为null,便于GC;如果删除的元素在末尾性能开销比较低,否则性能开销比较高

  5. 线程安全

    ArrayList不是线程安全的,只能在单线程环境下使用,如果有在多线程环境下适用的需求,考虑Collections.synchronizedList()函数返回一个现成安全的ArrayList类,也可以使用concurrent包下面的CopyOnWriteArrayList

2、核心参数与构造方法

2.1、简单聊一聊几个构造方法

    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);
        }
    }
    // 新建的数组的长度是返回的长度是原来集合的长度
    public ArrayList(Collection<? extends E> c) {
        elementData = c.toArray();
        if ((size = elementData.length) != 0) {
            // c.toArray might (incorrectly) not return Object[] (see 6260652)
            if (elementData.getClass() != Object[].class)
                elementData = Arrays.copyOf(elementData, size, Object[].class);
        } else {
            // replace with empty array.
            this.elementData = EMPTY_ELEMENTDATA;
        }
    }

2.2、为什么ArrayList中有两个静态的final的Object[]类型的数组

/**
* Shared empty array instance used for empty instances. 静态常量, 空数组各个对象之间共享使用
*/
private static final Object[] EMPTY_ELEMENTDATA = {};
​
/**
* 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. 静态常量,默认构造方法创建对象时的空数组实例,用它区别上面的EMPTY_ELEMENTDATA当新增第一个元素的时候扩容多少
*/
private static final Object[] 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);
    }
}
public ArrayList() {// 默认空容量
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
​
public void ensureCapacity(int minCapacity) {// 扩容时判断是哪个空数组
    int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
    // any size if not default element table
    ? 0
    // larger than default for default empty table. It's already
    // supposed to be at default size.
    : DEFAULT_CAPACITY;
​
    if (minCapacity > minExpand) {
        ensureExplicitCapacity(minCapacity);
    }
}

具体用法:

如果创建默认ArrayList,底层使用默认空数组,第一次添加元素的时候会创建一个长度为10的数组(根据判断是否 == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)如果是指定初始容量为0创建ArrayList,则底层使用的是空数组,第一次添加元素创建一个长度为1的数组

2.3、ArrayList中elementData用transient修饰,序列化后数据会丢失吗?

 transient Object[] elementData;

搞明白这个题目,就要先弄懂两个问题:

序列化是什么: 对象不能直接在网络中传输,必须转化为二进制字节流进行传输。序列化就是对象转化为字节流的过程,同理,反序列化就是从字节流构建对象的过程。

  • 对于Java对象来说,如果说用JDK中的序列化来实现,对象只需要实现java.io.Serializable接口
  • 也可以使用ObjectOutputStream()ObjectInputStream()对对象进行手动序列化和反序列化。序列化时会调用writeObject()方法,把对象转为字节流。反序列化的时候调用readObject()方法,把字节流转换为对象。

Transient关键词是什么意思:transient修饰的变量不会被序列化。

正题解答:

ArrayList实现了java.io.Serializable接口,证明是支持序列化的。ArrayList不对elementData进行序列化,而是对elementData中元素进行循环,取出来单独进行序列化。结合代码可以看到wirteObject()方法,把对象转换为字节流。反序列化的时候调用readObject()方法,把字节流转换为对象。

private void writeObject(java.io.ObjectOutputStream s)
    throws java.io.IOException{
    // 其他逻辑。。。。
    // Write out all elements in the proper order.
    for (int i=0; i<size; i++) {
        s.writeObject(elementData[i]);
    }
}
private void readObject(java.io.ObjectInputStream s)
    throws java.io.IOException, ClassNotFoundException {
    // Read in capacity
    s.readInt(); // ignored
    if (size > 0) {
        Object[] a = elementData;
        // Read in all elements in the proper order.
        for (int i=0; i<size; i++) {
            a[i] = s.readObject();
        }
    }
}

2.4、既然ArrayList会序列化elementData中存储的每一个元素,那为什么不直接序列化elementData呢?这样设计有什么好处?

绝大多数情况下,底层的数组都有剩余空间,如果对这个数组进行序列化势必会浪费空间,数组容量越大在扩容之后越明显。而对数组中的每一个元素进行序列化就能更精准地利用空间。

2.5、说说你对transient的理解,举个使用场景的例子

作用:transient修饰的变量,不再是对象持久化的一部分,该变量的内容在对象持久化之后无法被获得。

作用范围: 只能用来修饰变量,不能用来修饰方法和类,本地方法也是不能被transient修饰的。

静态变量和transient 静态i变量无论是否被transient修饰都不能被序列化

场景: 实际开发过程中,常常会遇到一些场景,这个类的有些属性需要被序列化,有些属性不需要被序列化,比如某个敏感性的字段,安全起见,不希望在网络中传输,这些字段就可以加上transient关键字,保证这些字段的生命周期仅存于调用者的内存中而不会写到磁盘进行持久化。

3、新增、删除、查询、扩容

3.1、ArrayList add(E e)方法源码原理

主要有三步:

  1. 确保数组的容量足够容得下这个新加的元素
  2. 将元素添加到size位置上,然后将size自增1
  3. 返回true,表示新增成功

具体说一下确保数组容量足够的过程:

  1. 传入size+1表示当前需要的最小容量
  2. 然后判断数组是否为默认空数组,如果是则返回最小容量和 10 之间的较大值
  3. modCount++
  4. 判断所需要的最小容量是否大于数组长度,超过则进行扩容

3.2、ArrayList add(int index, E element)源码有了解过吗?说说你对这个方法的利弊看法?

public void add(int index, E element) {
    rangeCheckForAdd(index);
​
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    elementData[index] = element;
    size++;
}
private void rangeCheckForAdd(int index) {
    // 只能插入到数组中间或者末尾,不可以向后空起格子插入元素
    if (index > size || index < 0)
    throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

流程:

  1. 检查插入元素的范围:不能空格子
  2. 确保原有size+1之后数组容量仍然够用,否则扩容
  3. 使用System.arraycopy()方法讲index后面的元素统一向后移动一位
  4. 将目标数据存放在指定位置(注意:先拷贝数组将位置腾出来,然后赋值)

分析:

  • 好处:可以在指定位置插入元素,
  • 坏处:如果指定的位置在已经插入的元素中间,涉及到后续元素的搬移操作,比较消耗性能

3.3、ArrayList什么时候触发扩容,扩容原理?

每次新增元素之前都会判断当前数组长度的空余长度不能存储接下来要保存的元素时就要扩容

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);
    }
    // MAX_ARRAY_SIZE:2^31-1-8;Integer.MAX_VALUE:2^31-1;
    private static int hugeCapacity(int minCapacity) {
        if (minCapacity < 0) // overflow
        throw new OutOfMemoryError();
    return (minCapacity > MAX_ARRAY_SIZE) ?
        Integer.MAX_VALUE :
        MAX_ARRAY_SIZE;
}
  1. 将原数组扩容1.5倍给数组(oldCapacity + (oldCapacity >> 1);)
  2. 如果扩容后仍小于最小所需容量,则直接赋予最小所需容量
  3. 如果所需最小容量大于2^31 -1 -8则直接赋予2^31 - 1(所以理论上最大容量为2^31)
  4. 进行数组拷贝

3.4、为什么ArrayList的Max_array_size是Integer.Max_Value减8而不是减去别的

我们从上面题目的扩容中看到数组的MAX_ARRAY_SIZEInteger.MAX_VALUE - 8

数组比较特殊,既不是基本类型也不是引用类型,在JVM中读取数组长度是用arraylength这个指令;在数组的对象头中有一个_length字段记录着数组长度,减去的8就是存储了数组length这个这个字段

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

3.5、ArrayList的remove方法了解过吗?如果长度为1的ArrayList移除这个元素后方法是怎么考虑垃圾回收的?

先分析删除的元素是否为根据下标进行删除还是根据值进行删除,如果是根据值来进行删除需要找到删除元素的位置(都需要直到删除元素的位置):

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

3.6、ArrayList的contains方法的时间复杂度是多少?

因为ArrayList底层是数组实现的,contains方法入参比较的是对象是否相等,所以此时需要逐个比较,所以时间复杂度为O(n)

public boolean contains(Object o) {
    return indexOf(o) >= 0;
}
// 找到元素的下标,如果不存在则返回-1
public int indexOf(Object o) {
    if (o == null) {
        for (int i = 0; i < size; i++)
            if (elementData[i]==null)
                return i;
    } else {
        for (int i = 0; i < size; i++)
            if (o.equals(elementData[i]))
                return i;
    }
    return -1;
}

4、Fail-Fast机制

4.1、ArrayListFail-fast机制是什么原理

这个问题问的就是ArrayListFail-fast是怎么实现的

设计思想: 再使用foreach这样遍历的时候,会先行拷贝一份modCount的值存储在expectedModCount中,然后再遍历的过程中,判断当前的拿到的修改值是否和实际的一致(判断集合是否在遍历的过程中被修改过)

ArrayList的父类AbstractList中定义了这么一个属性protected transient int modCount = 0;,表示对这个容器进行的结构行修改的次数。

Fail-Fast设计实现: 这个字段在迭代器或者列表迭代器中会用到,在创建迭代器时候赋值给迭代器,然后遍历元素或者删除元素时候会比对modCount,如果不一致就会爆出并发修改异常。

迭代器中删除为什么不会有问题:见下面迭代器的删除的实现细节

ArrayList在执行结构化修改的时候也就是add()remove()中针对该变量做自增1操作;在多线程环境下对集合操作时,迭代器拿到modCount值后ArrayList被修改了那么迭代器的modCount和当前的ArrayList中的modCount值就不一致,这会导致并发修改异常,这就是ArrayList实现的Fail-Fast机制的实现

4.2、ArrayList如果再循环中删除一个元素,有什么办法避开fail-fast机制吗?

可以使用迭代器删除,也可以使用fori循环删除,但是foreach不可以

迭代器为什么可以删除?

  1. 解释modCount的处理:使用迭代器删除后,ArrayList中的modCount会自增1,此时迭代器将被修改的modCount重新赋值到迭代器中来,再次检查的时候二者相等就不会报并发修改异常了,相当于使用迭代器删除之后会有一个同步modCount的操作。(这样设计也很合理:modCount的改变是由于自己的修改引起的,那么我把外部修改的值重新赋值过来)
private class Itr implements Iterator<E> {
    /**
    * 游标:指向迭代的下一个元素
    */
    int cursor = 0;

    /**
    * 也是游标:指向上一个迭代到的元素,最初从-1开始
    */
    int lastRet = -1;

    int expectedModCount = modCount;

    public boolean hasNext() {
        return cursor != size();
    }

    // 检查修改异常情况
    final void checkForComodification() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }

    public E next() {
        checkForComodification();
        try {
            int i = cursor;
            E next = get(i);
            lastRet = i;// 将当前的游标置为遍历过的游标
            cursor = i + 1;// 游标向后移动一位
            return next;
        } catch (IndexOutOfBoundsException e) {
            checkForComodification();
            throw new NoSuchElementException();
        }
    }

    public void remove() {
        // 连续调用:防止重复掉都用remove方法,如果第二次调用则报错
        if (lastRet < 0)
            throw new IllegalStateException();
        checkForComodification();
        try {
            AbstractList.this.remove(lastRet);
            // 删除之后会有一个游标cursor--的操作(和外面的)
            if (lastRet < cursor)
                cursor--;
            lastRet = -1;
            // 修改次数的赋值
            expectedModCount = modCount;
        } catch (IndexOutOfBoundsException e) {
            throw new ConcurrentModificationException();
        }
    }
}

5、线程安全

5.1、ArrayList是线程安全吗?为什么不是?给出分析过程

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;// 这里是问题的根源
    return true;
}

添加元素的过程:第一步先确保容量,第二步是位置赋值然后size自增,由于线程不是同步的,可能会出现数组越界问题,也可能会出现值覆盖问题。

数组越界:两个线程进来,容量够用,只剩一位,一个线程读到了领域给线程的修改操作,自增过程中数组越界

值覆盖:两个线程读到了相同的size,并且没有读到另外一个线程对size的修改,导致size覆盖,最终导致值覆盖

5.2、如何使用线程安全的ArrayList

  1. 使用Collections.synchronizedList()包装返回一个SynchronizedList的类,这个类中所有方法都在代码块上加了sync关键字,读读、读写、写写互斥,保证线程安全
  2. 使用CopyOnWriteArrayList

6、出彩的代码设计

6.1、batchRemove(Collection<?> c):双指针标记删除

标记整理然后清楚算法(规避了数组上频繁移动元素的情况),延申到JVM老年代垃圾回收算法

LinkedList

底层是一个双端链表,实现了栈、队列接口

LinkedList和ArrayList的区别

  • ArrayList是实现了基于动态数组的数据结构,LinkedList基于双向链表的数据结构。
  • 对于随机访问,ArrayList要优于LinkedList,因为LinkedList需要移动指针进行遍历
  • 对于新增和删除操作,LinkedList比较占优势,因为ArrayList需要搬移数据
  • ArrayList额外空间是扩容1.5倍导致的空间资源预留,LinkedList是需要对前后指针进行保存,单个元素比ArrayList占用更大的空间。

数据结构

LinkedList底层使用双向链表结构,维护一个头节点first和一个尾节点last,双向链表表示我们可以从头到尾遍历也可以从未到头遍历。配置使用的Node节点类中设置了前驱节点和后驱节点

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

    /**
    * Pointer to first node.
    */
    transient Node<E> first;
    
    /**
    * Pointer to last node.
    */
    transient Node<E> last;

CopyOnWriteArrayList

1、引入背景和介绍

CopyOnWrite之前想要实现读写线程安全怎么实现,以下两种方式:

1、Synchronized方式

读和写都加锁,性能低

2、ReadWriteLock方式

两把锁,读锁和写锁,读写会相互阻塞。比Sync方式好,但是还不够

3、COW机制

就是平时查询的时候,都不需要加锁,随便访问,只有在更新的时候,才会从原来的数据复制一个副本出来,然后修改这个副本,最后把原数据替换成当前的副本。修改操作的同时,读操作不会被阻塞,而是继续读取旧的数据。这点要跟读写锁区分一下

2、实现原理

实现了读写分离,可以分析一下读写不分离保证线程安全的劣势。实现读写分离的几个要点:

  1. 底层数据用volatile修饰,保证线程的可见性
  2. 写入之前先拷贝一份原始数据
  3. 引入ReentrantLock锁,写前加锁写后释放锁
    private transient volatile Object[] array;// 主要是保证线程可见性

	final transient ReentrantLock lock = new 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);// 将newElements赋值给array
            return true;
        } finally {
            lock.unlock();
        }
    }

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

    /**
     * {@inheritDoc}
     *
     * @throws IndexOutOfBoundsException {@inheritDoc}
     */
    public E get(int index) {
        return get(getArray(), index);
    }

3、优缺点分析

优点:

对于一些读多写少的数据,例如配置、黑名单、物流地址等变化非常少的数据,读数据是一种无锁的实现,可以帮我们实现程序更高的并发。

缺点:

  1. 数据一致性问题。这种实现只是保证数据的最终一致性,不能保证数据的实时一致性;在添加到拷贝数据而还没进行替换的时候,读到的仍然是旧数据。
  2. 内存占用问题。在进行写操作的时候,内存里会同时驻扎两个对象的内存,如果对象比较大,频繁地进行替换会消耗内存,从而引发 JavaGC问题,这个时候,我们应该考虑其他的容器,例如 ConcurrentHashMap

4、适用场景

写少读多场景,因为COW机制每写入一个元素就需要将原来数组元素拷贝一遍,产生大量对象,给垃圾收集器带来较大压力。

5、设计思想扩展

扩展延伸1:在kafka中的使用

在Kafka的内核源码中,有这么一个场景,客户端在向Kafka写数据的时候,会把消息先写入客户端本地的内存缓冲,然后在内存缓冲里形成一个Batch之后再一次性发送到Kafka服务器上去,这样有助于提升吞吐量。(PS:这个可以搭配下Kafka章节内容来实现)

kafka中的数据结构:

 private final ConcurrentMap<topicpartition, deque> batches = 
     new CopyOnWriteMap<TopicPartition, Deque>();

客户端会把生产的消息存入这个缓冲池,其中key为分区ID,Deque为存放消息的队列,kafka自己实现了一个CopyOnWriteMap

class CopyOnWriteMap{   
	// 典型的volatile修饰普通Map
    private volatile Map map;

	@Override
    public synchronized V put(K k, V v) {
        // 更新的时候先创建副本,更新副本,然后写回被volatile修饰的变量
        Map copy= new HashMap(this.map);
        V prev = copy.put(k, v);
        this.map = Collections.unmodifiableMap(copy);
        return prev;
    }

	@Override
    public V get(Object k) {
        // 读取的时候直接读volatile变量引用的map数据结构,无需锁
        return map.get(k);
    }
} 

这里之所以这么实现就是因为这个key-value没那么频繁更新,不会频繁新增分区队列,

但是get请求比较高频,需要频繁的读取一个TopicPartition对应的Deque数据结构来对这个队列进行入队出队操作,所以高频的是get操作。所以使用这种CopyOnWrite思想避免更新key-value的时候阻塞高频的读操作,实现无锁效果,优化并发的性能。(PS:这里需要再思索,因为这里是Map不同于List,有天然的隔离性质)

HashMap

JDK1.7 VS JDK1.8

这里只罗列区别点概要,具体内容见下面具体点

  1. 处理哈希碰撞的方式:头插和尾插、拉链法和红黑树
  2. 扩容时对链表和红黑树上元素重新定位的方式有不同

数据结构设计

数据结构是怎么样的

简单描述HashMap底层数据结构,可以引申到通过一个元素的put过程阐述底层结构的设计与好处

底层存储元素的载体是一个数组,HashMap通过哈希算法计算出元素要保存到数组上的下标,然后进行存储;

由于哈希算法有一定几率出现哈希碰撞的情况,HashMap采用拉链法或者红黑树这样的数据组织方式才应对哈希碰撞的场景。

class Node<K,V> implements Map.Entry<K,V> {
    final int hash;// 该节点对应的hash值,这个哈希值是处理过后的哈希值,不是hashcode
    final K key;
    V value;// 存储的具体元素
    Node<K,V> next;// 指向下一个元素
}

描述一个元素被put的过程:当我们向hashMapput(key, value)元素时候,

  1. 首先通过哈希算法计算出哈希值并得到应该存储到数组中的位置,
  2. 如果当前的桶位上没有元素,直接存储在该位置即可;如果该位置上存储元素,也就是出现了哈希碰撞,这时候要把该元素维护在链表中,1.7头插法1.8尾插法。1.8中也会在由于单链表元素过多导致HashMap的访问效率明显降低会将单链表转化为红黑树以提高访问效率。

可以引申:如何确认存储到数组的位置

HashMap的主要成员变量(重要参数)

transient Node[] table; // node类型的数组,其中存储的node都可以构成链表

transient int size; //当前存储的键值对个数

transient int modCount; // hashmap的修改次数

int threshold; // 当前hashmap的装载容量,一旦超过这个数量hashmap就会扩容

final float loadFactor:负载因子
// 默认的初始容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当链表节点数大于该参数转换为红黑树(需要满足转换为树的数组最小长度条件)
static final int TREEIFY_THRESHOLD = 8;
// 当树的节点数小于该参数转成链表
static final int UNTREEIFY_THRESHOLD = 6;
    
// 补充一个64
static final int MIN_TREEIFY_CAPACITY = 64;// 最小的转换为树的数组长度

JDK1.8以后为什么引入红黑树

引入之前的问题:在1.7版本中,底层的数据结构是数组 + 链表,用单链表的方式来处理元素哈希碰撞的场景,单链表会随着节点的增多越来越长,导致原来为O(1)的时间复杂度开始逐渐退化。

引入之后如何解决问题:所以在1.8中,当链表中节点的个数超过8时并满足转换为树的最小数组长度时尝试将单链表中的元素转换为红黑树,该结构可将访问该桶位上元素的平均时间复杂度将为O(logn),依次来应对哈西碰撞比较频繁场景下HashMap读写效率下降的问题。

HashMap为什么选用红黑树儿不用AVL树?

说一下二者的区别:

  • AVL树要求更加严格的平衡,所以理论上来说AVL树能够提供更快的查询速度,在查询密集型任务场景AVL更适合
  • 由于AVL树要求更加严格的平衡,所以AVL树比红黑树的旋转更加难以平衡

整体来看二者都可以提供O(logn)的查询时间复杂度,但是AVL平衡成本要比红黑树的旋转成本更高,而且在HashMap的某个桶位上即使是哈希碰撞也不应该出现大量密集的元素,这是哈西算法设计的失败,所以综合来看红黑树比AVL树更加适合HashMap中处理冲突的场景。

JDK1.8之后的HashMap为什么在链表长度为8的时候变为红黑树?

为什么不全是红黑树或链表?为什么要在超过8的时候转?

  1. 先解释为什么转换为红黑树而不是全部是红黑树或者全链表
    • 时间:理论上红黑树的查询效率要优于链表,在元素越多效果越明显,而在节点较少case下几乎没有差别
    • 空间:由于树节点TreeNode包含的左右指针占用的空间更多几乎是链表的两倍
    • 所以综合二者考虑,只有在节点比较多的场景下转换为红黑树整体收益更高
  2. 再解释为什么选择8作为转换红黑树的临界条件

理想状态下,受随机分布的哈希值的影响,落在数组中各个桶位上的概率遵循泊松分布,根据统计链表中节点数是8的概率已经是千分之一,这个时候性能开始变差,而将链表转换为红黑树也是有性能开销的,所以权衡之后,8是一个折中的数字。

泊松分布是什么

单位时间内随机事件的平均发生的次数

如果链表的节点数大于8,就一定会转化为红黑树吗?

不是的,我们通过读源码能够很清晰看到这一点。(一定要斩钉截铁地告诉他通过读源码知道的,装起来!)

put(key, value)过程中,如果检测到所要存放的桶位上的单链表元素个数已经超过8,确实会调用一个尝试转换为树的方法

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) {
        // do convert treenode work, 这里省略
    }
}

通过源码,我们知道:当单链表元素个数超过8的时候,还要再去判断数组的长度是否超过转换红黑书的最小长度也就是64,如果满足就开始扩容操作,只有两个条件都满足了才会开始转换为树的操作。

为什么HashMap的数组长度要保证为2的幂次方呢?

简要概括就是时间上计算效率更高更加节省时间,空间上利用更加充分更加节省空间(核心:2 的 n 次幂 - 1 的二进制形式是 1111.... 形式),展开说如下:

  1. 时间上可以快速定位到hash值对应的数组位置:只有当数组长度为 2n 次幂时,二进制形式为 1111...hash&(table.len - 1) == hash%table.len,与操作效率很高,可以实现key的快速定位
  2. 空间上可以定位到数组的各个位置:和 1111.... 的二进制做与运算可以充分得到各种结果,如果 table 长度为15这样也就是二进制为 1110 ,和 1110 做与运算那么永远都不可能得到末位为 1 的二进制数据,也就是说末位为1的二进制数代表的位置永远都是空的造成了空间浪费,必然加大哈希碰撞概率。【某位置上有 0 可能导致与运算为 0 ,导致数组某个位置为空,浪费空间】

负载因子为什么能影响HashMap的性能?

负载因子表示一个散列表中空间的使用程度(超过负载因子的容量,哈希表才会扩容)

  1. 负载因子越大则散列表的装填程度越高,也就能容纳更多的元素,元素多了产生哈希碰撞的几率也就高了,所以数组的桶位上产生链表会增多以及链表可能会变得更长,所以搜索效率会降低。
  2. 负载因子越小则散列表的装填程度越低,也就是能容纳的元素更少,此时HashMap的桶位上没有链表或者链表很短,搜索效率比较高,但是空间浪费比较严重。

存储与查找元素

HashMap的put方法实现原理(源代码讲解)

img

1.7中数组+链表:头插法;1.8中数组+链表/红黑树:尾插法

几个主要核心步骤:

  1. 判断数组是否为空来决定是否需要进行扩容

  2. 然后根据hashcode计算出对应的哈希值(hashcodehashcode右移16位的值进行一个异或操作),根据hash值和数组长度减一的值进行一个与操作得到要元素对应的桶位,然后判断桶位上是否有元素以及元素类型做不同的操作:

    • 桶位上没有元素直接创建一个node节点进行所要put的元素值进行存储,指定步骤3
    • 桶位上有元素则证明发生了hash碰撞,判断桶位上存储的元素是否和put的元素相等,如果相等则直接覆盖元素值,不相等则继续判断
    • 判断节点的类型,如果属于TreeNode节点则进行一个红黑树的新增
    • 桶位上的节点时链表类型,则一遍遍历单链表一遍判断是否相等,直到把元素正确地维护到单链表中,并且检查链表的长度是否超过8,如果超过8则会进行一个尝试转换为红黑树的操作
  3. 最后判断HashMap中存储的元素是否已经超过了HashMap的容量,如果超过了则进行一个扩容操作,否则结束put方法。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    // tab:备份下数组,p:对应hash位置上的元素,n:数组长度,i:对应hash位置的下标
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)// 如果数组为空 先执行扩容
        n = (tab = resize()).length;
    // (n - 1) & hash:处理过后的hash和数组长度进行与操作计算出对应的hash位置,判断该位置上时候有元素
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);// 该位置上没有元素 直接创建一个新元素 保存在数组中
    else {// 执行到这里说明已经发生了hash碰撞
        Node<K,V> e; K k;// e:记录和保存元素相等的元素
        // 桶位上节点是否和要保存的元素相等
        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 {// 执行到这里说明是链表,此时p为头节点
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);// 如果p指向的下一个元素为null,则说明将新元素追加到这里
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st,当链表节点数大于8时,尝试转换为树
                        treeifyBin(tab, hash);
                    break;
                }
                // 判断遍历的节点和要保存的节点是否相等,如果相等 则终止遍历 后续直接对e进行操作
                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;
        }
    }
    // 将修改次数+1,如果只是更新元素 则这里不会变更
    ++modCount;
    // 当size + 1 > threshold:容量 执行扩容操作,这里是预判断(不是再保存元素时才进行判断)
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

HashMap的put方法的参数hash是怎样计算的

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  1. key为null时,直接末尾hash值为0,所以HashMapkey可以为null,并且只能存储一个keynull的元素,而HashTable中获取hash值直接调用hashcode(),因此keynull会报异常因此hashtable不支持keynull的元素
  2. key不为null时,调用hashcode()得到hashcode,然后对hashcode进行扰动处理:将hashcodehashcode自身右移16位得到的二进制进行异或操作

HashMap中插入一条数据如何计算数据的下标

分为三步:

  1. 调用hashcode()方法得到对应的hashcode
  2. hashcode进行扰动处理得到对应的hash值:即hashcodehashcode右移16位得到的二进制数进行一个异或操作
  3. 将hash值和数组长度-1进行与操作得到元素对应的桶位下标
// 获取原始hashcode的方法省略
...
    
// 根据hashcode得到hash值
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

// 计算元素对应数组位置的下标
hash & (n - 1)

为什么hash要进行右移16位的异或运算

不进行该操作有什么问题:

调用hashcode()得到的hashcode是32位,最终和length - 1做与运算,绝大多数场景下,数组的长度都是远远小于2的16次方的,这样始终都是hashcode的低16位在和length做与运算,高16位的信息没有参与到与运算,得到的结果不够随机。

该操作解决了什么问题:

hashcode和自身右移16位得到的二进制进行异或操作,混合了原始哈希码的高位和低位信息,以此来加大得到结果的低位随机性,这样拿到的hash值去做与运算结果更加随机。

为什么用^而不用&和 |

结论:相对于&与| 或来说,异或^得到的结果随机性更高一些。 这里举例说明

image-20230322002711389

图中可以看到,异或操作可能使得结果为10的概览各50%

HashMap为什么不直接选用hashcode()处理后的哈希值作为table的下标?

先说一下hashcode的范围,-2^31 ~ 2^31-1hashcode可能是负数

  1. HashMap容量必须是正数,从这点上看得到的hashcode不能直接使用作为下标
  2. 另外Hashcode即便是正数,可能也比较大,如果创建很长的数组可能空间浪费比较严重甚至设备提供不了这么大的空间。

哈希碰撞链表转化为红黑树的时机

链表上节点个数大于8会尝试进行转换为红黑树,并且满足数组容纳元素个数超过了最小转换为红黑树阈值64。

String类适合做hashmap的key的原因是什么

《Java编程思想》中有这么一句话:设计hashcode时要保证对同一个对象调用hashcode()产生相同的哈希值。

String类对这个有天然的支持:

  1. 首先:String天生覆写了hashcode方法,根据对象的内容来计算hashcode,并且String底层对对象的hash做了存储,在计算第一遍的时候将其存储起来,后续使用时不再需要重复计算效率提高。
  2. 第二:由于String时不可变的,并且String底层用final 数组来存储String中字符,多次调用得到的hashcode都是一致的。
  3. 第三:天然重写了equals方法,再发生哈希碰撞时可以根据equals()方法判断key是否相同
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

自定义的对象做hashmap的key,需要进行什么操作

重写对象的hashcode方法和equals方法

重写hashcode方法:如果不重写,hashmap中调用地也就是Objecthashcode方法,获取的其实是对象的地址,会导致业务意义上相等地对象hashcode并不相同

重写equals方法: 如果不重写,在发生哈希碰撞时,便无法根据equals方法判断对象是否相等了

哈希碰撞

什么是哈希?什么是哈希冲突?

哈希介绍:一般也叫做散列,具体指将把任意长度得输入经过哈希算法转换位特定长度的输出,该输出就是哈希值;这种转换是一种压缩算法,用哈希码来标识该输入

哈希冲突或哈希碰撞:散列函数有一个特性,经过同一个散列函数输出得哈希值如果不同那么输入必然不同,如果输出相同则输入不一定相同,这就是哈希碰撞

HashMap什么时候会产生哈希碰撞

put元素对应的hash值对应的桶位上有元素,就表明产生了哈希碰撞。

HashMap如何处理哈希碰撞

1.7中采用拉链法,即出现哈希碰撞,将元素以头插法的方式插入到链表的头部。

1.8中采用链表+红黑树的方式,维护链表式尾插法

扩容机制

触发扩容的时机

  1. 数组为空时进行扩容
  2. 单链表上节点个数超过8并且整个HashMap底层数组长度小于 64
  3. 数组容量大于HashMap扩容门槛threshold

HashMap的扩容原理

1.7和1.8中扩容有什么不同

1.7中,遍历原来的数组使用hash&(newTable.length - 1)重新计算桶位进行存储,单个节点直接迁移;针对链表上的元素,如果迁移后还在同一个桶位上会出现链表倒置的现象,因为是头插法。

1.8中,分桶位上是单节点、链表、红黑树做不同的处理:

  1. 如果是单个节点:依然按照hash&(newtablenth - 1)重新计算索引位置
  2. 如果是链表元素,则使用hash & oldtablength计算,如果结果为 0 表明保留在原位置,如果结果不为 0 表名需要放在当前索引 + oldtablenth位置(记忆一个低位链表一个高位链表)【如果和tablenth与运算得到不为1,表明会有大于等于oldtablenth但是小于newtablength - 1,因此0 - oldtablenth-1位置无法容纳】
  3. 如果是红黑树,将该树打散成两个树存储到新数组中

img

1.8中扩容流程
  1. 明确新的初始容量和扩容门槛

    1. 如果使用默认方法创建对象,则第一次插入元素的时候初始化默认值,容量16,扩容门槛为12
    2. 如果使用的是非默认方法,则第一次插入元素时初始化容量为扩容门槛,扩容门槛等于传入容量向上接近2的n次方
    3. 如果旧容量大于0,则新容量等于旧容量的2倍,新扩容门槛也是旧扩容门槛的2倍;如果旧容量>1 << 30,则不在扩容,而扩容门槛等于2^31-1
  2. 创建一个新容量的桶,具体长度按照上面计算得到的容量

  3. 迁移元素:遍历原来的数组迁移

    1. 单节点元素:按照hash&(newtablen-1)重新计算索引位置
    2. 链表:使用hash&oldtablen == 0来判断存储在当前索引位置还是当前索引位置+oldtablen(记忆一个低位链表一个高位链表)
    3. 红黑树元素,将该树打散成两个树存储到新数组中,使用hash&oldtablen == 0来判断存储在当前索引位置还是当前索引位置+oldtablen。如果遍历完树节点个数少于 6 个,将该桶位转换为链表结构;大于 6 个的场景下,如果高位不为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 { // preserve order,链表情况的处理
            Node<K,V> loHead = null, loTail = null;
            Node<K,V> hiHead = null, hiTail = null;
            Node<K,V> next;
            do {// do while的方式处理链表的
                next = e.next;
                // 如果和oldCap与操作等于0,则位于原位置,否则接入在高位
                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;
            }
        }
    }
}

HashMap JDK1.8中扩容场景中的split方法

这个split方法就是在扩容过程中遇到某个桶位上存储的红黑树节点的处理办法,大致的处理逻辑如下:

  1. 创建低位的头、尾节点,高位的头尾节点(低位是指原数组中桶位的下标,高位指低位下标+扩容的长度也就是旧数组长度)
  2. 从当前的桶位节点开始遍历,然后根据hash值决定该元素应该放在低位还是高位上,并且分别计数低位、高位上有多少个元素,判断的一个逻辑是:使用hash值和oldtablenth进行一个与操作,结尾为 0 就放低位,结果为 1 就放高位
  3. 遍历完树节点元素之后,我们这个时候有了低位、高位的头节点元素和低位、高位的元素的个数,如果元素的个数少于 6 则将树节点转换为链表,或者判断是否需要重新调整红黑树,最终将转换之后的节点放在低位和高位的桶位上。
  4. 至此,完成了树结构节点的扩容操作
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {// 此处的bit为原数组长度
    TreeNode<K,V> b = this;
    // Relink into lo and hi lists, preserving order
    TreeNode<K,V> loHead = null, loTail = null;
    TreeNode<K,V> hiHead = null, hiTail = null;
    int lc = 0, hc = 0;
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        // 和bit做与运算,比如1000这样的二进制,结果为0存储低位桶,结果为1存储高位桶;这里维护next指向关系方便后续转树
        // 虽然这里没有显示维护right、left这种关系,但是他原来就有这种关系存在
        if ((e.hash & bit) == 0) {
            if ((e.prev = loTail) == null)
                loHead = e;
            else
                loTail.next = e;
            loTail = e;
            ++lc;
        }
        else {
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;
        }
    }
    // 如果低位不为null,处理低位元素
    if (loHead != null) {
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else {
            tab[index] = loHead;
            // 如果hiHead不为null,那么证明原始的树结构有变化,所以需要重新维护
            if (hiHead != null) // (else is already treeified)
                loHead.treeify(tab);
        }
    }
    if (hiHead != null) {
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            // 如果loHead不为null,那么证明原始的树结构有变化,所以需要重新维护
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}

线程安全

为什么是线程不安全的

  1. 在1.7中扩容过程中由于是头插法导致链表倒序可能导致死链现象
  2. 1.8中也会有数据覆盖的问题,两个线程在判断同一个没有元素的桶位时,都进行赋值操作,发生数据丢失现象。

如何解决

使用 ConcurrentHashMap 代替 HashMap

死链问题

先看1.7中正常的rehash过程是怎么样的

//  从OldTable里摘一个元素出来,然后放到NewTable中
for (int j = 0; j < src.length; j++) {
    Entry<K,V> e = src[j];
    if (e != null) {
        src[j] = null;
        do {
            // 先记录下来next元素,后续需要迭代使用
            Entry<K,V> next = e.next;
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];// 找到对应的桶位,让e指向该桶位
            newTable[i] = e;// 更新桶位指针到e元素
            e = next;// 更新旧位置上的元素
        } while (e != null);
    }
}

img

这段程序再单线程场景下没有任何问题,就是普通的链表节点赋值的处理。

  1. 先记录下来旧位置上的next元素,
  2. e.next指向新的桶位上的元素,让新的桶位指向 e
  3. 迭代 e 指向之前记录的next,继续迭代步骤1
JDK1.7中死链问题如何产生

简要概括就是:处理链表是否采用的是头插法,会将元素插入到头部,扩容后链表节点顺序会反过来,如果有线程并发场景,可能会造成链表中节点首尾相连。

以上程序在并发场景下就会出现问题:

img

结合上图,我们假设有两个线程:

线程1:

  1. 执行Entry<K,V> next = e.next,此时线程1中e = 3next = 7如图
  2. 执行时间片到期,暂时hung住,开始线程2的表演

线程2:

  1. 正常执行所有操作该桶位链表元素迁移完成,此时table[i] = 7
  2. 接下来线程1又拿到调度权开始操作

线程1:

  1. 找到对应的桶位i,直接e.next = newTable[i],导致3.next = 7(注意:现在7.next = 3)
  2. 于是死链出现
JDK1.8中如何解决的?

直接将插入的节点加入到链表的尾部,避免这种头插法导致的链表顺序倒置导致的首尾相连。

总而言之,并发场景下,绝不可使用HashMap。

ConcurrentHashMap

ConcurrentHashMap的JDK1.7版本和1.8版本有什么区别

1.7 版本:

ConcurrentHashMap的设计就是分段锁思想的一个实践,就是一个Segment数组,每一个 segment 中又是 HashEntry 数组,数组中又嵌套了一层hashMap。Segment通过继承 Reentranlock 来进行加锁保证每一个 Segment 线程安全,保证了每一个 Segment 的线程安全也就保证了全局的安全。这个Segment默认是 16 就是 16 个格子,最多支持 16 个线程并发访问。也可以在初始化的时候指定,一旦指定不能再更改。

img

1.8版本

彻底放弃了 Segment 这种思想转而使用了Node,设计思想也不再是分段锁的思想,其中Node结构中包含:key、value、以及key的hash值,

  1. 其中 key 和 value 都是用 volatile修饰,保证并发场景下的Node的可见性
  2. 并发粒度从分段提升到了元素级别:判断桶位上是否有元素时使用CAS操作,在发生哈希碰撞的桶位上使用synchronized来锁住桶位上的链表或者红黑树保证碰撞位置上节点的线程安全,比如说数组中存储了20个值,那么同时对20个值的读写也不会有冲突,虽然消耗更多的CPU,但是时间复杂度最高是O(logn),对CPU的消耗带来的收益是远远大于之前的Blocked状态状态16并发量的收益。
  3. 舍弃Lock接口提升锁的性能: 如果使用ReentrantLock则需要节点继承AQS来获得同步支持,增加内存开销,synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。

线程安全设计

为什么1.8中并发场景下有什么改进?有什么好处?

改进内容:用CAS+sync替换掉segment+lock,使用 CAS 判断对应桶位是否为空,如果为空则使用 Synchronized在桶位上加锁。

好处:

①锁的粒度:将锁的粒度从 segment分段层面提高到了元素层面,本身一个合理的哈希表出现碰撞的频率就不会很高,把锁加在了桶位上理论上将并发提升到了最高。

②锁本身的优化:使用 Synchronized加锁而不是使用 Lock实现类,更高概览的减少线程挂起、唤醒带来的上下文切换导致的阻塞。因为 Synchronized已经优化了很多,偏向锁、轻量级锁,就算发生了线程争抢,也不会在第一次没抢到就开始挂起,而是进行一定次数的自旋,结合哈希表的场景,本身哈希冲突就是小概率事件,持有锁的线程不会占有很久,所以一定次数自旋能有效避免上下文切换。而Lock实现类基于AQS实现,在第一次抢锁失败后,再判断一次再次获取不到就直接进入同步队列等待唤醒,这种方式下线程阻塞的概率更高一些。

HashTable和ConcurrertHashMap多线程下表现有什么区别?为什么

二者都可以保证线程安全,但是 HashTable 的效率要低很多,因为 HashTable 是使用 synchronized 对整个数组进行加锁。

① ConcurrentHashMap 在1.7中采用分段锁的设计思想,最高可以支持 16 的并发度。

②在1.8中对节点使用 CAS + Synchronized 保证线程安全,将并发度提升到元素级别,理论上是最高级别的并发。

TreeMap

底层使用红黑树实现,具备排序的一种Map,底层使用compareTo方法

LinkedHashMap

功能使用

基于HashMap和双向链表来实现维护元素保存顺序或访问顺序的一种Map

LinkedHashMap与hashMap的区别

  • LinkedHashMap继承于HashMap,基于HashMap和双向链表来实现维护元素保存顺序或访问顺序的一种Map
  • 最大的区别就是HashMap是无序的,LinkedHashMap是有序的,有序分为两种,插入顺序和访问顺序(默认是插入顺序),如果是访问顺序,那put和get操作已经存在的entry时都会把entry移动到双向链表的末尾(先删除后插入到末尾)
  • 和HashMap一样使用Entry[]来保存顺序,双向链表只是为了保证顺序。
  • 和hashMap一样,LinkedHashMap也是线程不安全的

要求按照保存元素的顺序来打印Map,选择什么Map

我们知道HashMap不保证元素保存顺序的机制,而LinkedhashMap专门为这个特性而设计,在LinkedhashMap内部可以保持两种顺序,分别是插入顺序和访问顺序,这个可以在对象初始化的时候进行指定,可能是考虑到使用插入顺序进行编排的场景比较多,所以默认是按照插入顺序进行排序。当然也可以设置accessOrder为true即使用访问顺序进行编排。

Map<String, Integer> linkedhashMap = new LinkedHashMap<String, Integer>(2);
linkedhashMap.put("zhangsan", 1);
linkedhashMap.put("lisi", 2);
linkedhashMap.put("wangwu", 3);
for (Map.Entry entry : linkedhashMap.entrySet()) {
    System.out.println(entry.getKey() + "--" + linkedhashMap.get(entry.getKey()));
}
// output
zhangsan--1
lisi--2
wangwu--3

介绍LinkeHashMap维护数据的两种顺序,插入顺序和访问顺序

先说下LinkedHashMap是如何维护元素的顺序的,底层使用双向链表来维护所保存元素的顺序,我们具体启用插入排序还是访问排序在初始化对象时设定。

如果使用默认的构造器,代表使用插入排序来维护元素顺序,此时会将put的元素保存在双向链表末尾;如果要使用访问顺序,需要是用另外一个构造函数来初始化对象,传入初始容量、负载因子、accessOrder为true表示开启访问顺序,此时被访问的元素先从链表中删除然后保存到链表末尾。

数据结构和源码设计

数组组织方式

	// 继承自HashMap中的Node节点维护Hash表数组上下一个元素
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;// hash表上元素指的下一个
    }
	// 定义的内部类,维护了前一个元素和后一个元素的位置
    static class Entry<K,V> extends HashMap.Node<K,V> {
        Entry<K,V> before, after;
    }

img

LinkedHashMap控制访问顺序的原理,看过源代码吗?在哪里控制

非常确定地回答看过源码,控制的地方有两个

1、一个是构造函数源码,将accessOrder声明为true,表示启用访问顺序

public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder;
}

2、另一个是get元素之后,被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;
}

LinkedHashMap的put方法原理

1、LinkedHashMap中没有重写put方法,用的是HashMap中的方法,但是重写了NewNode方法,在创建完节点之后将创建的节点置于链表末尾

Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p =
        new LinkedHashMap.Entry<K,V>(hash, key, value, e);
    linkNodeLast(p);// 将节点置于链表尾部
    return p;
}

在hashMap中确认元素位置后会调用该方法创建节点,有点模板方法的概念

2、在一个就是新增的节点,经过afterNodeAccess方法添加到链表尾部(开启访问顺序且当新增的节点在链表中已经存在时执行此方法)

void afterNodeAccess(Node<K,V> e) { // move node to last
    LinkedHashMap.Entry<K,V> last;
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)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的get方法原理

public V get(Object key) {
    Node<K,V> e;
    // 调用HashMap中方法获取值
    if ((e = getNode(hash(key), key)) == null)
        return null;
    // 如果是维护了访问元素,将节点move到末尾
    if (accessOrder)
        afterNodeAccess(e);// 此方法中也会再次校验,这个方法相当于在HashMap中预留了位置
    return e.value;
}

应用:用LinkedHashMap实现一个简易的缓存清理(LRU算法)

public class LRULinkedHashMap<K, V> extends LinkedHashMap<K, V> {
    private int capacity;
    private static final long serialVersionID = 1L;
    LRULinkedHashMap(int capacity) {
        super(16, 0.75f, true);
        this.capacity = capacity;
    }
    /**
     * 实现LRU的关键方法,如果Map中的个数大于capacity,则删除链表的顶端元素
     */
    @Override
    public boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;
    }
}

// 测试方法
    System.out.println("==========实现LRU=============");
    LRULinkedHashMap<Integer, Integer> lruLinkedHashMap = new LRULinkedHashMap<Integer, Integer>(2);
    lruLinkedHashMap.put(1, 1);
    lruLinkedHashMap.put(2, 2);
    lruLinkedHashMap.put(3, 3);
    lruLinkedHashMap.put(4, 4);
    Iterator<Map.Entry<Integer, Integer>> iterator = lruLinkedHashMap.entrySet().iterator();
    for (; iterator.hasNext();) {
        Map.Entry<Integer, Integer> next = iterator.next();
        System.out.println(next.getKey() + "---" + next.getValue());
    }

必须重写removeEldestEntry()方法,否则永远都不会删除最久的元素

void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}

原来hashMap中removeEldestEntry(first)默认返回false,如果不重写默认永远不会被执行。

HashSet

由HashMap包装而来

LinkedHashSet

由LinkedHashMap包装而来