Java中集合相关知识点复习

一、List

1、ArrayList

  • ArrayList是一种变长的集合类,基于定长数组实现,使用默认构造方法初始化出来的容量是10(1.7之后都是延迟初始化,即第一次调用add方法添加元素的时候才将elementData容量初始化为10)
  • ArrayList允许空值和重复元素,当往ArrayList中添加的元素数量大于其底层数组容量时,其会通过扩容机制重新生成一个更大的数组。ArrayList扩容的长度是原长度的1.5倍
  • 由于ArrayList底层基于数组实现,所以其可以保证在 O ( 1 ) O(1) 的时间复杂度下完成随机查找操作
  • ArrayList是非线程安全类
  • 删除和插入需要调用System.arraycopy方法复制数组,性能差

2、LinkedList

1)、特性

LinkedList进行节点插入、删除时间复杂度是 O ( 1 ) O(1) ,但是随机访问时间复杂度是 O ( n ) O(n)

2)、底层数据结构

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

二、Set

HashSet、TreeSet、LinkedHashSet底层都是基于其相对应的Map实现的,只使用了Map的key,保证了Set中的元素不重复

三、Map

1、HashMap

HashMap是基于键的hashCode值唯一标识一条数据,同时基于键的hashCode值进行数据的存取,因此可以快速地更新和查询数据,但其每次遍历的顺序无法保证相同。HashMap的key和value允许为null

HashMap是非线程安全的,即在同一时刻有多个线程同时写HashMap时将可能导致数据的不一致。如果需要满足线程安全的条件,则可以用Collections.synchronizedMap使HashMap具有线程安全的能力,或者使用ConcurrentHashMap

1)、底层数据结构

HashMap的数据结构如下图所示,其内部是一个数组,数组中的每个元素都是一个单向链表,链表中的每个元素都是嵌套类Entry的实例,Entry实例包含4个属性:key、value、hash值和用于指向单向链表下一个元素的next

链表主要是为了解决数组中的key发生哈希冲突时,将发生碰撞的key存储到链表中

在这里插入图片描述

在这里插入图片描述

当哈希冲突严重时,在桶上形成的链表会变得越来越长,这样在查询时的效率就会越来越低,时间复杂度为 O ( n ) O(n)

所以在JDK1.8中,当链表长度大于8且HashMap数组长度大于等于64(数组长度小于64进行扩容操作)时,会将链表转换为红黑树,修改为红黑树之后查询效率变为了 O ( l o g n ) O(logn)

在这里插入图片描述
HashMap不直接使用红黑树,是因为树节点所占空间是普通节点的两倍,所以只有当节点足够的时候,才会使用树节点。也就是说,尽管时间复杂度上,红黑树比链表好一点,但是红黑树所占的空间比较大,所以综合考虑之下,只有在链表节点数太多的时候,红黑树占空间大这一劣势不太明显的时候,才会舍弃链表,使用红黑树

2)、put方法流程图

在这里插入图片描述

3)、默认初始化大小是多少?HashMap的扩容方式?负载因子是多少?

    //默认容量
	static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

	//默认的负载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    //用于判断是否需要将链表转换为红黑树的阈值
    static final int TREEIFY_THRESHOLD = 8;

    //JDK1.7中的HashEntry修改为Node
    transient Node<K,V>[] table;

	//HashMap中存放KV的数量
    transient int size;

	//当HashMap的size大于threshold时会执行resize操作,threshold=capacity*loadFactor
    int threshold;

	//负载因子
    final float loadFactor;

	...

给定的默认容量为16负载因子为0.75。Map在使用过程中不断地往里面存放数据,当数量达到了 16 0.75 = 12 16*0.75=12 就需要将当前16的容量进行扩容

扩容过程分为两步:

  • 扩容:创建一个新的Entry空数组,长度是原数组的2倍
  • ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组

长度扩大以后,Hash的规则也随之改变,所以要进行ReHash操作

index = (length - 1) & hash(key)

负载因子需要在时间和空间成本上寻求一种折衷

负载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了

负载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数

所以,选择0.75作为默认的负载因子,完全是时间和空间成本上寻求的一种折衷选择

4)、为什么容量总是为2的n次幂?这样设计的目的是什么?

HashMap的tableSizeFor()方法做了处理,能保证HashMap的容量永远都是2的n次幂

因为在使用2的幂的数字的时候,length-1的值是所有二进制位全为1,这种情况下,index的结果等同于hashCode后几位的值

只要输入的hashCode本身分布均匀,hash算法的结果就是均匀的,这样设计的目的为了实现均匀分布

5)、线程不安全的原因

1)JDK1.7中扩容造成死循环分析过程

    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

在对table进行扩容到newTable后,需要将原来数据转移到newTable中,JDK1.7在转移元素的过程中,使用的是头插法,也就是链表的顺序会翻转

假设:

  • hash算法为简单的用key mod链表的大小
  • 最开始hash表size=2,key=3、7、5,则都在table[1]中
  • 然后进行resize,使size变成4

resize前状态如下:

在这里插入图片描述

如果在单线程环境下,最后的结果如下:

在这里插入图片描述

在多线程环境下,假设有两个线程A和B都在进行put操作。线程A在执行到transfer函数中代码(1)处挂起

    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;//(1)
                e = next;
            }
        }
    }

此时线程A中运行结果如下:

在这里插入图片描述

线程A挂起后,此时线程B正常执行,并完成resize操作,结果如下:

在这里插入图片描述

由于线程B已经执行完毕,根据Java内存模型,现在newTable和table中的Entry都是主存中最新值:7.next=3,3.next=null

此时切换到线程A上,在线程A挂起时内存中值如下:e=3,next=7,newTable[3]=null,代码执行过程如下:

                newTable[i] = e;//newTable[3] = 3
                e = next;//e = 7

在这里插入图片描述

继续循环:

        		e = 7;
                Entry<K,V> next = e.next;//next = 3
                e.next = newTable[i];//e.next = 3
                newTable[i] = e;//newTable[3] = 7
                e = next;//e = 3

在这里插入图片描述

再次循环:

        		e = 3;
                Entry<K,V> next = e.next;//next = null
                e.next = newTable[i];//3.next = 7
                newTable[i] = e;//newTable[3] = 3
                e = next;//e = null

e.next=7,而在上次循环中7.next=3,出现环形链表,并且此时e=null循环结束

在这里插入图片描述

2)JDK1.7中扩容造成数据丢失分析过程

在这里插入图片描述

线程A和线程B进行put操作,同样线程A挂起:

    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;//(1)
                e = next;
            }
        }
    }

此时线程A的运行结果如下:

在这里插入图片描述

此时线程B已获得CPU时间片,并完成resize操作:

在这里插入图片描述

此时切换到线程A,在线程A挂起时:e=7,next=5,newTable[3]=null

执行newtable[i]=e,就将7放在了table[3]的位置,此时next=5。接着进行下一次循环:

        		e = 5;
                Entry<K,V> next = e.next;//next = null
                e.next = newTable[i];//e.next = 5
                newTable[i] = e;//newTable[1] = 5
                e = next;//e = null

将5放置在table[1]位置,此时e=null循环结束,3元素丢失,并形成环形链表

在这里插入图片描述

3)JDK1.8中的HashMap

在JDK1.8中对HashMap进行了优化,在发生哈希碰撞,不再采用头插法方式,而是直接插入链表尾部,因此不会出现环形链表的情况

HashMap的put方法,如果没有哈希碰撞则会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,这样线程A会把线程B插入的数据给覆盖,发生线程不安全

6)、哈希冲突解决方法

1)开放寻址法

开放寻址法的核心思想:如果出现了哈希冲突,就重新探测一个空闲位置,将其插入

线性探测插入操作:当往哈希表中插入数据时,如果某个数据经过哈希函数计算之后,存储位置已经被占用了,就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止

下图中黄色的色块表示空闲位置,橙色的色块表示已经存储了数据

在这里插入图片描述

当哈希表中插入的数据越来越多时,哈希冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久

2)链表法

在哈希表中,每个桶或者槽会对应一条链表,所有哈希值相同的元素都放到相同槽位对应的链表中

在这里插入图片描述

7)、为什么要重写hashcode和equals方法

class Key {
    private Integer id;

    public Integer getId() {
        return id;
    }

    public Key(Integer id) {
        this.id = id;
    }
}

public class WithoutHashCode {
    public static void main(String[] args) {
        Key k1 = new Key(1);
        Key k2 = new Key(1);

        HashMap<Key, String> hashMap = new HashMap<>();
        hashMap.put(k1, "Key with id is 1");
        System.out.println(hashMap.get(k2));//null
    }
}

当向HashMap中存入k1的时候,首先会调用Key这个类的hashcode方法,计算它的hash值,随后把k1放入hash值所指引的内存位置,在Key这个类中没有定义hashcode方法,就会调用Object类的hashcode方法,而Object类的hashcode方法返回的hash值是对象的地址。这时用k2去拿也会计算k2的hash值到相应的位置去拿,由于k1和k2的内存地址是不一样的,所以用k2拿不到k1的值

重写hashcode方法仅仅能够k1和k2计算得到的hash值相同,调用get方法的时候会到正确的位置去找,但当出现哈希冲突时,在同一个位置有可能用链表的形式存放冲突元素,这时候就需要用到equals方法去对比了,由于没有重写equals方法,它会调用Object类的equals方法,Object的equals方法判断的是两个对象的内存地址是不是一样,由于k1和k2都是new出来的,k1和k2的内存地址不相同,所以这时候用k2还是达不到k1的值

什么时候需要重写hashcode和equals方法?

在HashMap中存放自定义的键时,就需要重写自定义对象的hashcode和equals方法

2、ConcurrentHashMap

1)、JDK1.7

JDK1.7中ConcurrentHashMap的锁分段技术可有效提高并发访问率:ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问

在这里插入图片描述

ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁,在ConcurrentHashMap里扮演着锁的角色;HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁

ConcurrentHashMap重要的成员变量:

    //Segment数组,存放数据时首先需要定位到具体的Segment中
    final Segment<K,V>[] segments;
    transient Set<K> keySet;
    transient Set<Map.Entry<K,V>> entrySet;

Segment是ConcurrentHashMap的一个内部类,主要的组成如下:

    static final class Segment<K,V> extends ReentrantLock implements Serializable {

        private static final long serialVersionUID = 2249069246763182397L;

        static final int MAX_SCAN_RETRIES =
            Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;

        //和HashMap中的HashEntry作用一样,真正存放数据的桶
        transient volatile HashEntry<K,V>[] table;

        transient int count;

        transient int modCount;

        transient int threshold;

        final float loadFactor;

HashEntry的组成:

    static final class HashEntry<K,V> {
        final int hash;
        final K key;
        volatile V value;
        volatile HashEntry<K,V> next;

        HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

和HashMap非常类似,唯一的区别就是其中的核心数据如value,以及链表都是volatile修饰的,保证了获取时的可见性

ConcurrentHashMap采用了分段锁技术,其中Segment继承于ReentrantLock。不会像HashTable那样不管是put还是get操作都需要做同步处理,理论上ConcurrentHashMap支持CurrencyLevel(Segment数组数量)的线程并发。每当一个线程占用锁访问一个Segment时,不会影响到其他的Segment

1)put方法

    public V put(K key, V value) {
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
        int hash = hash(key);
        int j = (hash >>> segmentShift) & segmentMask;
        if ((s = (Segment<K,V>)UNSAFE.getObject          
             (segments, (j << SSHIFT) + SBASE)) == null) 
            s = ensureSegment(j);
        return s.put(key, hash, value, false);
    }

首先通过key定位到Segment,之后在对应的Segment中进行具体的put

        final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            oldValue = e.value;
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        e = e.next;
                    }
                    else {
                        if (node != null)
                            node.setNext(first);
                        else
                            node = new HashEntry<K,V>(hash, key, value, first);
                        int c = count + 1;
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
                unlock();
            }
            return oldValue;
        }

虽然HashEntry中的value是用volatile关键词修饰的,但是并不能保证并发的原子性,所以put操作时仍然需要加锁处理

首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用scanAndLockForPut()自旋获取锁

        private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
            HashEntry<K,V> first = entryForHash(this, hash);
            HashEntry<K,V> e = first;
            HashEntry<K,V> node = null;
            int retries = -1; 
            //尝试自旋获取锁
            while (!tryLock()) {
                HashEntry<K,V> f; 
                if (retries < 0) {
                    if (e == null) {
                        if (node == null) 
                            node = new HashEntry<K,V>(hash, key, value, null);
                        retries = 0;
                    }
                    else if (key.equals(e.key))
                        retries = 0;
                    else
                        e = e.next;
                }
                //如果重试的次数达到了MAX_SCAN_RETRIES则改为阻塞锁获取,保证能获取成功
                else if (++retries > MAX_SCAN_RETRIES) {
                    lock();
                    break;
                }
                else if ((retries & 1) == 0 &&
                         (f = entryForHash(this, hash)) != first) {
                    e = first = f; 
                    retries = -1;
                }
            }
            return node;
        }

再结合起来看一下put的逻辑:

        final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            //将当前Segment中的table通过key的hashcode定位到HashEntry
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
                        //遍历该HashEntry,如果不为空则判断传入的key和当前遍历的key是否相等,相等则覆盖旧的value
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            oldValue = e.value;
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        e = e.next;
                    }
                    else {
                        if (node != null)
                            node.setNext(first);
                        //为空则需要新建一个HashEntry并加入到Segment中,同时会先判断是否需要库容弄
                        else
                            node = new HashEntry<K,V>(hash, key, value, first);
                        int c = count + 1;
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
                //最后解除在scanAndLockForPut()方法中获取的锁
                unlock();
            }
            return oldValue;
        }

2)get方法

    public V get(Object key) {
        Segment<K,V> s; 
        HashEntry<K,V>[] tab;
        int h = hash(key);
        long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
        if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
            (tab = s.table) != null) {
            for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
                     (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
                 e != null; e = e.next) {
                K k;
                if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                    return e.value;
            }
        }
        return null;
	}

只需要将key通过hash之后定位到具体的Segment,再通过一次hash定位到具体的元素上

由于HashEntry中的value属性是volatile关键词修饰的,保证了内存可见性,所以每次获取时都是最新值

ConcurrentHashMap的get方法是非常高效的,因为整个过程都不需要加锁

2)、JDK1.8

在这里插入图片描述

JDK1.8中抛弃了原有的Segment分段锁,而采用了volatile+CAS+synchronized来保证并发安全性

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;

        Node(int hash, K key, V val, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.val = val;
            this.next = next;
        }

        public final K getKey()       { return key; }
        public final V getValue()     { return val; }
        public final int hashCode()   { return key.hashCode() ^ val.hashCode(); }
        public final String toString(){ return key + "=" + val; }
        public final V setValue(V value) {
            throw new UnsupportedOperationException();
        }

也将1.7中存放数据的HashEntry改为了Node,但作用都是相同的

其中的val和next都用了volatile修饰,保证了可见性

1)put方法

    public V put(K key, V value) {
        return putVal(key, value, false);
    }

    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        //根据key计算出hashcode	
        int hash = spread(key.hashCode());
        int binCount = 0; 
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            //判断是否需要进行初始化
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            //f为当前key定位出的Node,如果为空表示当前位置可以写入数据,利用CAS尝试写入,失败则自旋保证成功
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   
            }
            //如果当前位置的hashcode==MOVED==-1,则需要进行扩容
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                //如果都不满足,则利用synchronized锁写入数据
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    //如果数量大于TREEIFY_THRESHOLD则要转换为红黑树
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

2)get方法

    public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }

根据计算出来的hashcode寻址,如果就在桶上那么直接返回值。如果是红黑树那就按照树的方式获取值。都不满足那就按照链表的方式遍历获取值

JDK1.8在1.7的数据结构上做了大的改动,采用红黑树之后可以保证查询效率 O ( l o g n ) O(logn) ,甚至取消了ReentrantLock改为了synchronized,这样可以看出在新版的JDK中对synchronized优化是很到位的

3)、ConcurrentHashMap/Hashtable不允许空值的原因

主要是因为会产生歧义,如果支持空值,在使用map.get(key)时,返回值为null,可能有两种情况:该key映射的值为null,或者该key未映射到。如果是非并发映射中,可以使用map.contains(key)进行检查,但是在并发的情况下,两次调用之间的映射可能已经更改了

3、HashMap和Hashtable的区别

1)线程安全

Hashtable是线程安全的,HashMap不是线程安全的

Hashtable所有的元素操作都是synchronized修饰的,而HashMap并没有

2)性能优劣

Hashtable是线程安全的,每个方法都要阻塞其他线程,所以Hashtable性能较差,HashMap性能较好,使用更广

如果要线程安全又要保证性能,建议使用JUC包下的ConcurrentHashMap

3)NULL

Hashtable是不允许键或值为null的,HashMap的键值则都可以为null

Hashtable代码片段

    public synchronized V put(K key, V value) {
        //如果value为null,直接抛出空指针异常
        if (value == null) {
            throw new NullPointerException();
        }
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        @SuppressWarnings("unchecked")
        Entry<K,V> entry = (Entry<K,V>)tab[index];
        for(; entry != null ; entry = entry.next) {
            if ((entry.hash == hash) && entry.key.equals(key)) {
                V old = entry.value;
                entry.value = value;
                return old;
            }
        }

        addEntry(hash, key, value, index);
        return null;
    }

HashMap代码片段

    static final int hash(Object key) {
        int h;
        //key为null做了特殊处理
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

4)实现方式

Hashtable继承源码

public class Hashtable<K,V>
    extends Dictionary<K,V>
    implements Map<K,V>, Cloneable, java.io.Serializable {

HashMap继承源码

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

Hashtable继承了Dictionary,而HashMap继承了HashMap

5)容量扩容

HashMap的初始容量为16,Hashtable初始容量为11,两者的负载因子默认都是0.75

Hashtable代码片段

    public Hashtable() {
        this(11, 0.75f);
    }

HashMap代码片段

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

当现有容量大于总容量*负载因子时,HashMap扩容规则为当前容量翻倍,Hashtable扩容规则为当前容量翻倍+1

4、TreeMap

TreeMap基于红黑树实现。映射根据其键的自然顺序进行排序,或者根据创建映射时提供的Comparator进行排序,具体取决于使用的构造方法

TreeMap因为需要排序,进行key的compareTo()方法,所以key是不能null值,value是可以的

5、LinkedHashMap

在这里插入图片描述

LinkedHashMap直接继承自HashMap,这也就说明了HashMap一切重要的概念LinkedHashMap都是拥有的,这就包括了,hash算法定位hash桶位置,哈希表由数组和单链表构成,并且当单链表长度超过8的时候转化为红黑树,扩容体系,这一切都跟HashMap一样。除此之外,LinkedHashMap比HashMap更加强大,这体现在:

  • LinkedHashMap内部维护了一个双向链表,解决了HashMap不能随时保持遍历顺序和插入顺序一致的问题
  • LinkedHashMap元素的访问顺序也提供了相关支持,也就是常说的LRU(最近最少使用)原则

在这里插入图片描述

图片中红黄箭头代表元素添加顺序,蓝箭头代表单链表各个元素的存储顺序。head表示双向链表头部,tail代表双向链表尾部

LinkedHashMap和HashMap相比,唯一的变化是使用双向链表(图中红黄箭头部分)记录了元素的添加顺序,HashMap的Node节点只有next指针,LinkedHashMap对于Node节点进行了扩展:

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

LinkedHashMap基本存储单元Entry继承自HashMap.Node,并在此基础上添加了before和after这两个指针变量。before变量在每次添加元素的时候将会指向上一次添加的元素,而上一次添加元素的after变量将指向该次添加的元素,来形成双向链接

LinkedHashMap支持两种访问访问顺序,这主要取决于accessOrder这个参数的值,当accessOrder为false时按照插入顺序访问(默认),当accessOrder为true时按照LRU Cache的机制进行访问

  	//initialCapacity:初始化容量 loadFactor:负载因子 accessOrder:访问顺序(true代表使用LRU/false代表使用插入的顺序)
	public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }

当某个位置的数据被命中,通过调整该数据的位置,将其移动至尾部。新插入的元素也是直接放入尾部(尾插法)。这样一来,最近被命中的元素就向尾部移动,那么链表的头部就是最近最少使用的元素所在的位置

LinkedHashMap中并没有覆写任何关于HashMap的put方法,所以调用LinkedHashMap的put方法实际上调用了父类HashMap的方法

HashMap中put方法源码如下:

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    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;
        //根据当前key的hashcode定位到具体的桶中并判断是否为空,为空表明没有Hash冲突就直接在当前位置创建一个新桶即可
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            //如果当前桶有值(Hash冲突),那么就要比较当前桶中的key、key的hashcode与写入的key是否相等,相等就赋值给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 {
                //如果是个链表,就需要将当前的key、value封装成一个新节点写入当前桶的后面(采用尾插法)
                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;
                    }
                    //如果在遍历链表的过程中,找到key相同时直接退出遍历
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //如果e!=null就相当于存在相同的key,那就需要将值覆盖
            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;
    }

在putVal方法中如果map中存在相同的key时,会调用void afterNodeAccess(Node<K,V> p)方法,该方法在HashMap中是空实现,但是在LinkedHasMap中重写了该方法实现了将被访问节点移动到链表最后

  	//将被访问节点移动到链表最后
	void afterNodeAccess(Node<K,V> e) { // move node to last
        LinkedHashMap.Entry<K,V> last;
        //accessOrder为true时才支持LRU Cache
        if (accessOrder && (last = tail) != e) {
            //三个临时变量:p为当前被访问节点,b为其前驱结点,a为其后继节点
            LinkedHashMap.Entry<K,V> p =
                (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
            //访问节点的后驱节点置为null
            p.after = null;
            //如果访问节点的前驱为null,则说明p=head,由于这时p要移动到链表最后,所以a设置为head
            if (b == null)
                head = a;
            //否则b的后继设置为a
            else
                b.after = a;
            
            //如果p不为尾节点,那么将a的前驱设置为b   
            if (a != null)
                a.before = b;
            else
                last = b;
            
            if (last == null)
                head = p;
            else {
                p.before = last;
                last.after = p;
            }
            //将p接在双向链表的最后
            tail = p;
            ++modCount;
        }
    }

举个例子,比如该次操作访问的是13这个节点,而14是其后驱,11是其前驱,且tail=14。在通过get访问13节点后,13变成了tail节点,而14变成了其前驱节点,相应的14的前驱变成11,11的后驱变成了14,14的后驱变成了13

在这里插入图片描述

而在putVal方法的最后会调用一个void afterNodeInsertion(boolean evict)方法,,该方法在HashMap中是空实现,但是在LinkedHasMap中重写了该方法实现了删除头节点(最近最少使用的元素)

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

    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
    }

代码(1)处:evict在put方法调用putVal时传参即为true,所以当map不为空且removeEldestEntry返回true时就会删除头节点,但是在LinkedHasMap中removeEldestEntry方法始终返回true,所以如果要基于LinkedHashMap实现LRU则需要重写removeEldestEntry方法,当map的size大于初始化容量时返回true

猜你喜欢

转载自blog.csdn.net/qq_40378034/article/details/105899337
今日推荐