Java-集合

集合

集合类型

Java的集合类主要由两个接口派生而出:CollectionMap,Collection和Map是Java集合框架的根接口,这两个接口又包含了一些子接口或实现类

  • Set 接口继承 Collection,集合元素不重复
  • List 接口继承 Collection,允许重复,维护元素插入顺序
  • Map接口是键-值对象

List

List最大的特点就是:有序,可重复
List的实现方式有ArrayListLinkedList

选择问题:

  • 数据结构是否能完成需要的功能

  • 如果都能完成则考虑哪种更高效

Set

Set的特点是无序,不重复的

HashSet实现原理

HashSet 是基于 HashMap 来实现的,底层采用HashMap的key来存储元素,主要特点是 无序,基本操作都是 O(1)的时间复杂度

Set的常用实现类

  • HashSet
  • LinkedHashSet:这个一个 HashSet + LinkedList的结构,特点是拥有了O(1)的时间复杂度,又能保证保留插入的顺序
  • TreeSet :采用红黑树 (自平衡的排序二叉树) 的特点,特点是 有序,可以用自然排序或自定义比较器来排序;缺点:查询速度没有HashSet 快

HashSet

  • 通过 hashCode()equals() 方法来保证元素的唯一性
  • HashSet加入对象时会调用hashCode()来判断对象加入的位置,同时也会与该位置其他已经加入的对象的hashcode值作比较,如果没有相同的hashcode则加入此元素,反之则会调用equals()来检查hashcode相等的对象是否真的相同。如果相同,则取消加入操作,反之则会重新散列到其他位置

TreeSet

  • compareto() 来验证来验证两个元素是否相同,返回值为零表示两数相同,不存

  • 返回正数,表示新来的元素大于已有的元素

  • 返回负数,表示小于已有的元素

Map

Map是使用键值对 (key-value) 存储,Key是不可重复的,value是可重复的,每个键最多映射到一个值

  • HashMap: JDK1.8 之前HashMap由 数组+链表 组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的**。JDK1.8 以后在解决哈希冲突时有了较大的变化**,当链表长度大于阈值(默认为8) (将链表转换成红黑树前会判断,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间
  • LinkedHashMap: LinkedHashMap 继承自 HashMap, 它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表, 使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑
  • Hashtable:映射的常用功能与HashMap类似,不同的是它承自Dictionary类,并且是线程安全
  • TreeMap: TreeMap实现SortedMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator遍历TreeMap时,得到的记录是排过序的。如果使用排序的映射,建议使用TreeMap。在使用TreeMap时,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常

Queue和Deque

  • Queue 是一端进另一端出的线性数据结构;而 Deque 是两端都可以进出的,队列都是先进先出(FIFO)

  • PriorityQueue是个例外,并不按照进去的时间顺序出来,而是按照规定的优先级出去,并且它的操作并不是 O(1) 的

Queue的API

add()和offer()区别

  • add()和offer()都是向队列中添加一个元素。一些队列有大小限制,因此如果想在一个满的队列中加入一个新项,调用 add() 方法就会抛出一个 unchecked 异常

  • 调用 offer() 方法会返回 false

remove()和poll()区别

  • remove() 和 poll() 方法都是从队列中删除第一个元素。如果队列元素为空,调用remove() 的行为与 Collection 接口的版本相似会抛出异常

  • poll() 方法在用空集合调用时只是返回 null

element() 和 peek() 区别

  • element() 和 peek() 用于在队列的头部查询元素,在队列为空时, element() 抛出一个异常
  • peek() 返回 null

ArrayDeque 和 LinkedList

  • 实现普通队列时,推荐使用ArrayDeque,因为效率高,LinkedList还会有其他的额外开销
  • ArrayDeque是一个可扩容的数组,LinkedList是链表结构
  • ArrayDeque 不可以存 null 值,LinkedList 可以
  • ArrayDeque 在操作头尾端的增删操作时更高效,LinkedList当要移除中间某个元素且已经找到了这个元素后的移除才是 O(1) 的
  • ArrayDeque 在内存使用方面更高效

线程安全的集合

Vector

  • Vector 和 List 大同小异,底层都是用数组实现,只是在它的大部分方法上添加了 synchronized 关键字,用来保证线程安全
  • ArrayList 在扩容时是在原来的基础上扩展 0.5 倍,而 Vector 是扩展 1 倍
  • Vector 除了 iterator() 和 listIterator() (两个都支持 fast-fail 机制)比 ArrayList 多一个不支持 fast-fail 机制的迭代器:elements(),只有 hasMoreElements() 和 nextElement() 方法

Stack : 继承于Vector

Hashtable :使用sync修饰

ConcurrentHashMap

Collections.synchronizedXXX

  • Collections.synchronizedList(List list) :根据传入的 List 返回一个支持 线程安全 的 List
    • SynchronizedList的同步,使用的是synchronized代码块对mutex对象加锁,这个mutex对象还能够通过构造函数传进来,也就是说我们可以指定锁定的对象
    • Vector则使用了synchronized方法,同步方法的作用范围是整个方法,所以没办法对同步进行细粒度的控制。而且同步方法加锁的是this对象,没办法控制锁定的对象。这也是vectorSynchronizedList的一个区别
    • 想要使用迭代器,需要用户手动实现同步
  • Collections.synchronizedMap()
  • Collections.synchronizedSet()

CopyOnWriteArrayList

  • ArrayList 的线程安全的变体,其中所有写操作(add,set等)都通过对底层数组进行全新复制来实现,允许存储 null 元素
  • 当对象进行写操作时,使用了Lock锁做同步处理,内部拷贝了原数组,并在新数组上进行添加操作,最后将新数组替换掉旧数组
    • 若进行的读操作,则直接返回结果,操作过程中不需要进行同步
  • 实现方式的核心思想是减少锁竞争,从而提高在高并发时的读取性能,但是它却在一定程度上牺牲了写的性能

如何避免并发线程之间遍历时的干扰呢?

CopyOnWriteArrayList 的迭代器方法在创建迭代器时会创建一个当前数组状态的『快照』。这个数组在迭代器的生命周期内永不更改,因此不可能发生干扰,并且保证迭代器不会引发 ConcurrentModificationException。从迭代器被创建的那一刻起,该迭代器将不会因外界对列表的添加,删除或更改而改变。同时也不支持对迭代器本身进行元素更改操作(删除,设置和添加),这些方法都会抛出 UnsupportedOperationException

内存一致性影响:与其他并发集合一样,能够保证在时间上先在一个线程中向 CopyOnWriteArrayList 中的写入操作,先行发生于后续在另一个线程中对 CopyOnWriteArrayList 的读取或删除操作

CopyOnWriteArrayList、syncList、Vector

  • 在 get() 操作上,Vector 使用了同步关键字,所有的 get() 操作都必须先取得对象锁才能进行
    • 在高并发的情况下,大量的锁竞争会拖累系统性能。反观CopyOnWriteArrayList 的get() 实现,并没有任何的锁操作
    • SynchronizedList的迭代器没有做同步,需要用户自己实现;Vector的迭代器做好了同步,开发人员不需要关心同步
  • 在 add() 操作上,CopyOnWriteArrayList 的写操作性能不如 Vector,原因也在于Copy-On-Write,写入时不止加锁,还使用了Arrays.copyOf()进行了数组复制,性能开销较大,遇到大对象也会导致内存占用较大
  • 在读多写少的高并发环境中,使用 CopyOnWriteArrayList 可以提高系统的性能,但是,在写多读少的场合,CopyOnWriteArrayList 的性能可能不如 Vector
  • SynchronizedList是一个包装类,可以将List子类都包装为同步队列,从非线程安全队列转为线程安全队列,没有性能延迟,直接包装即可;Vector是一个基于数组的同步队列,其他队列想要转换为Vector,需要有数据拷贝

并发 Queue

  • ConcurrentLinkedQueue 为代表的高性能队列,一个是以 BlockingQueue 接口为代表的阻塞队列
  • ConcurrentLinkedQueue 是一个适用于高并发场景下的队列,它通过无锁的方式(CAS),实现了高并发状态下的高性能
    • 通常,ConcurrentLinkedQueue 的性能要好于 BlockingQueue
    • BlockingQueue 的主要功能并不是在于提升高并发时的队列性能,而在于简化多线程间的数据共享
  • BlockingQueue 提供一种读写阻塞等待的机制,即如果消费者速度较快,则 BlockingQueue 则可能被清空,此时消费线程再试图从 BlockingQueue 读取数据时就会被阻塞
    • 如果生产线程较快,则 BlockingQueue 可能会被装满,此时,生产线程再试图向 BlockingQueue 队列装入数据时,便会被阻塞等待

并发 Deque

  • Deque 允许在队列的头部或尾部进行出队和入队操作。LinkedList、ArrayDeque、LinkedBlocingDeque 都实现了双端队列Deque接口
    • 其中LinkedList使用链表实现了双端队列,ArrayDeque使用数组实现双端队列
    • 通常情况下,由于ArrayDeque基于数组实现,拥有高效的随机访问性能,因此ArrayDeque具有更好的遍历性能
    • 但是当队列的大小发生变化较大时,ArrayDeque需要重新分配内存,并进行数组复制,在这种环境下,基于链表的 LinkedList 没有内存调整和数组复制的负担,性能表现会比较好。但无论是LinkedList或是ArrayDeque,它们都不是线程安全的
  • LinkedBlockingDeque 是一个线程安全的双端队列实现。它的内部使用链表结构,每一个节点都维护了一个前驱节点和一个后驱节点
    • LinkedBlockingDeque 没有进行读写锁的分离,因此同一时间只能有一个线程对其进行操作
  • Java 还提供了一个 ConcurrentLinkedDeque,可在多线程并发时进行读写操作

ArrayList

继承关系

image-20220412225237642

  • RandomAccess : 标记接口,用于标记实现该接口的集合支持快速随机访问
  • Serializable :标记接口,用于标记实现该接口的类可以序列化
  • Cloneable :标记接口,用于标记实现该接口的类可以调用 clone 方法,否则会抛异常
  • Iterable :遍历接口,内部提供了支持不同遍历方式的方法,比如顺序遍历迭代器、函数式的 foreach 遍历、并行遍历迭代器
  • Collectionjava 集合体系的根接口,包含了通用的遍历、修改方法,例如 addAll、removeAll
  • AbstractCollection :抽象类,重写了 Collection 中最基础的方法,减少具体集合类的实现成本,比如 contains、isEmpty、toArray,iterator,但是 add 等需要具体集合类自我实现
  • Listjava 有序集合的基础接口,除了 Collection 的方法,还有支持倒序遍历的 listIterator 方法、子列表 subList 方法,另外重写 spliterator 方法的实现
  • AbstractList抽象类,重写了 List 的大部分方法,作用跟 AbstractCollection 类似

底层数据结构

  • ArrayList 是实现了 List 接口大小可以调整的动态数组,适应于查询为主的场景

  • ArrayList 不是一个线程安全的集合。并发修改时,可能会抛出 ConcurrentModificationException 或者得到无法预料的结果。因此如果并发处理,要么更换线程安全的集合,要么依赖线程安全机制去保证 ArrayList 的并发处理

  • ArrayList的底层是一个object数组,并且由trasient修饰

  • //transient Object[] elementData;

  • ArrayList底层数组不会参与序列化,而是使用writeobject方法进行序列化,只复制数组中有值的位置,其他未赋值的位置不进行序列化,可以节省空间

构造方法

public ArrayList(int initialCapacity) {}

创建一个特定长度的 ArrayList,如果可以预估容量,使用该方法构建实例,避免扩容时数组拷贝带来的性能消耗

public ArrayList() {}
创建一个容量为10 的 ArrayList

public ArrayList(Collection<? extends E> c) {}
使用 Collection 的实现比如 Set,List 创建一个 ArrayList,通常是 Collection 的实现进行相互转换

ArrayList增删改查

增(结尾处添加元素)

  • 进入 ensureCapacityInternal(size + 1),获取 默认容量minCapacity 比较的 最大值

  • 然后进入 ensureExplicitCapacity(minCapacity) ,用于 fail-fast 处理的 modcount++ (修改次数) ,如果 minCapacity 大于 elementData 的长度,则进行 扩容处理(grow)

  • 将 元素插入尾部,size++

增(指定位置添加元素)

  • 检查插入的位置 index 是否合法
  • ensureCapacityInternal(size + 1)
  • 通过 System.arraycopy(elementData, index, elementData, index + 1,size - index)
  • elementData 中位置为 index 及其后面的元素都向后移动一个下标(底层是native方法,使用cpp直接操作内存)
  • 将元素插入 indexsize++

  • 删除元素时,同样判断索引是否和法,删除的方式是把被删除元素右边的元素左移,方法同样是使用System.arraycopy 进行拷贝
        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;
        }

清空数组

  • ArrayList提供一个清空数组的办法,方法是将所有元素置为null,这样就可以让GC自动回收掉没有被引用的元素了
        public void clear() {
            modCount++;
            // clear to let GC do its work
            for (int i = 0; i < size; i++)
                elementData[i] = null;
            size = 0;
        }

修改元素

  • 修改元素时,只需要检查下标即可进行修改操作
        public E set(int index, E element) {
            rangeCheck(index);

            E oldValue = elementData(index);
            elementData[index] = element;
            return oldValue;
        }
        
        public E get(int index) {
           rangeCheck(index);
           
           return elementData(index);
        }

rangeCheck方法,其实就是简单地检查下标而已

        private void rangeCheck(int index) {
            if (index >= size)
                throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
        }

扩容方式

  • 扩容方式是让 新容量等于旧容量的1.5倍
  • 如果扩容之后仍 小于 (newCapacity 可能整形溢出) 必要存储要求 minCapacity,则取值为 minCapacity
  • 若新的存储能力 大于 MAX_ARRAY_SIZE,则进入 hugeCapacity(int minCapacity),判断是否整形溢出
    • 若溢出则抛出 OOM 异常,否则将数组扩容为 Integer.MAX_VALUE
  • 然后调用 Arrays.copyof() 方法进行对原数组的复制,再通过调用 System.arraycopy() 方法(native修饰)进行复制,达到扩容的目的

1.5倍扩容的原因

  • 一次性扩容太小,需要多次对数组重新分配内存,对性能消耗比较严重。所以1.5倍刚刚好,既能满足性能需求,也不会造成很大的内存消耗

trimToSize()
除了ensureCapacity()这个扩容数组外,trimToSize()可以最小化ArrayList实例的存储量

public void trimToSize() {
    modCount++;
    int oldCapacity = elementData.length;
    if (size < oldCapacity) {
        elementData = Arrays.copyOf(elementData, size);
    }
}

modCount

// protected transient int modCount = 0;

  • 迭代器初始的时候会赋予它调用这个迭代器的对象的modCount,在迭代器遍历的过程中,一旦发现这个对象的modcount和迭代器中存储的modcount不一样那就抛异常

fail-fast策略

  • java.util.ArrayList 是线程不安全的,抛出ConcurrentModificationException,这就是所谓fail-fast策略
  • 这一策略在源码中的实现是通过 modCount 域,modCount 顾名思义就是修改次数,对ArrayList 内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的 expectedModCount,在迭代过程中,判断 modCount 跟 expectedModCount 是否相等,如果不相等就表示已经有其他线程修改了 ArrayList

ArrayList安全删除元素

List<String> list = new ArrayList<String>();
list.add("a");
list.add("b");
list.add("c");

for(int i = 0; i < list.size(); i++) {
    if (list.get(i) == "b") {
        list.remove(i);
        i = i - 1;
    }
}

Iterator<String> it = list.iterator();
while(it.hasNext()){
    String value = (String)it.next();
    if (value == "b") {
        it.remove(); 
    }
}

  • 集合 List 每 remove 掉一个元素,后面的元素都会向前移动一位(数组结构),因此需要 i = i - 1

ArrayList和LinkedList的区别

  • ArrayList的底层是动态数组,所以ArrayList遍历访问非常快 且 支持随便访问,且但是增删比较慢,因为会涉及到数组的拷贝
  • LinkedList的底层是双向链表,所以LinkedList的 增加和删除非常快, 只需把元素删除,把各自的指针指向新的元素即可。但是LinkedList 遍历比较慢,因为只有每次访问一个元素才能知道下一个元素的值
  • 论遍历ArrayList要比LinkedList快得多,ArrayList遍历最大的优势在于内存的连续性,CPU的内部缓存结构会缓存连续的内存片段,可以大幅降低读取内存的性能开销。

HashMap

实现原理

  • JDK1.7中,HashMap 采用数组 + 链表的实现,采用链地址法处理Hash冲突,同一hash值的链表都存在一个数组中。因此当链表较长时,通过key值依次查找的效率较低
  • 基于此,JDK1.8在底层结构做了一些改变**,当链表长度大于阈值(默认为8) 会将链表转换为红黑树,目的是减少搜索的时间**。(将链表转换成红黑树前会判断,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树)

Node[] table,即哈希桶数组,Node是HashMap的一个内部类,实现了Map.Entry接口,本质是一个键值对

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash; //用来定位数组索引位置
    final K key;
    V value;
    Node<K,V> next; //链表的下一个node
	//.....
}

如果哈希桶数组很大,即使较差的Hash算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。那么通过什么方式来控制map使得Hash碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少呢

好的Hash算法和扩容机制

为什么使用红黑树?

  • 二叉搜索树在极端的情况下会出现线性结构,O(n)
  • 红黑树相对于AVL不追求 完全平衡,即AVL要求节点的高度差不大于1,红黑树只要求部分达到平衡,提出了为节点增加颜色,红黑是用非严格的平衡来换取增删节点时候旋转次数的降低,任何不平衡都会在三次旋转之内解决。AVL是严格平衡树,因此在增删的时候,根据不同情况,旋转的次数比红黑树要多
  • 增加节点而言,AVL和红黑树都是最多两次旋转实现rebalance,旋转的量级是O(1)
  • 删除节点而言,AVL需要维护从被删除节点到根节点这条路径上所有节点的平衡,旋转的量级是O(logN),而红黑树最多只需要旋转3次实现rebalance,旋转的量级是O(1)
  • AVL的结构相较于红黑树更为平衡,查找性能更好
  • 关于增删节点导致失衡后的rebalance操作,红黑树效率更高,因此红黑树是功能、性能、空间开销的折中结果

HashMap的构造函数

  • HashMap():构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap
  • HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap
  • HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和加载因子的空 HashMap
     int threshold;             // 所能容纳的key-value对极限 
     final float loadFactor;    // 负载因子
     int modCount;  
     int size; //HashMap中实际存在的键值对数量

Node[] table 的初始化长度 length (默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数

threshold = length * Load factor 即数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多,超过这个数目就重新resize (扩容),扩容后的HashMap容量是之前容量的两倍

modCount

  • 主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败
  • 内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化

负载因子0.75

默认的负载因子0.75是对空间和时间效率的一个平衡选择

  • 内存多而对时间效率要求很高,可以降低负载因子Load factor的值
  • 内存少而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1

length大小为2^n

  • 哈希桶数组 table 的长度 length 大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数 (Hashtable初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable扩容后不能保证还是素数))
  • HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程

hash函数

方法一:
static final int hash(Object key) {   //jdk1.8 & jdk1.7
     int h;
     // h = key.hashCode() 为第一步 取hashCode值
     // h ^ (h >>> 16)  为第二步 高位参与运算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

方法二:
static int indexFor(int h, int length) {  //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
     return h & (length-1);  //第三步 取模运算
}
  • JDK1.8中通过key的hashCode()值的高16位异或低16位实现:(h = k.hashCode()) ^ (h >>> 16),在table的length比较小的时候,也能保证高低Bit都参与到Hash的计算中,同时不会有太大的开销
  • JDK1.7中, h & (table.length -1) 来得到该对象的保存位,table.length 总是2的n次方,这是HashMap在速度上的优化,h & (length-1)运算等价于对length取模,也就是 h % length,&比%具有更高的效率

插入方式

 1 public V put(K key, V value) {
 2     // 对key的hashCode()做hash
 3     return putVal(hash(key), key, value, false, true);
 4 }
 5 
 6 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
 7                boolean evict) {
 8     Node<K,V>[] tab; Node<K,V> p; int n, i;
 9     // 步骤①:tab为空则创建
10     if ((tab = table) == null || (n = tab.length) == 0)
11         n = (tab = resize()).length;
12     // 步骤②:计算index,并对null做处理 
13     if ((p = tab[i = (n - 1) & hash]) == null) 
14         tab[i] = newNode(hash, key, value, null);
15     else {
16         Node<K,V> e; K k;
17         // 步骤③:节点key存在,直接覆盖value
18         if (p.hash == hash &&
19             ((k = p.key) == key || (key != null && key.equals(k))))
20             e = p;
21         // 步骤④:判断该链为红黑树
22         else if (p instanceof TreeNode)
23             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
24         // 步骤⑤:该链为链表
25         else {
26             for (int binCount = 0; ; ++binCount) {
27                 if ((e = p.next) == null) {
28                     p.next = newNode(hash, key,value,null);
                        //链表长度大于8转换为红黑树进行处理
29                     if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st  
30                         treeifyBin(tab, hash);
31                     break;
32                 }
                    // key已经存在直接覆盖value
33                 if (e.hash == hash &&
34                     ((k = e.key) == key || (key != null && key.equals(k)))) 
35							break;
36                 p = e;
37             }
38         }
39         
40         if (e != null) { // existing mapping for key
41             V oldValue = e.value;
42             if (!onlyIfAbsent || oldValue == null)
43                 e.value = value;
44             afterNodeAccess(e);
45             return oldValue;
46         }
47     }

48     ++modCount;
49     // 步骤⑥:超过最大容量 就扩容
50     if (++size > threshold)
51         resize();
52     afterNodeInsertion(evict);
53     return null;
54 }

image-20220313143751496

  1. 首先判断数组 table[i] 是否为空或为null,否则执行resize()进行扩容
  2. 根据key值计算hash值得到插入的数组索引 i , 如果table[i] == null直接新建节点添加,转向【6】;如果table[i]不为空,转向 【3】
  3. 判断table[i]的首个元素是否和key一样,如果相同直接覆盖 value,否则转向 【4】 (这里的相同指hashCode以及equals)
  4. 判断table[i]是否为红黑树,如果是则在树中插入键值对;否则转【5】
  5. 遍历table【i】,判断链表长度是否大于 8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在则直接覆盖value
  6. 插入成功后,判断 实际存在的键值对数量 size 是否大于 最大容量 threshold,如果超过则进行扩容

时间复杂度分析

  • key.hashcode(),时间复杂度O(1)
  • 找到桶以后,判断桶里是否有元素,如果没有,直接new一个entey节点插入到数组中。时间复杂度O(1)
  • 如果桶里有元素,并且元素个数小于6,则调用equals方法,比较是否存在相同名字的key,不存在则new一个entry插入都链表尾部。时间复杂度O(1)+O(n)=O(n)
  • 如果桶里有元素,并且元素个数大于6,则调用equals方法,比较是否存在相同名字的key,不存在则new一个entry插入都链表尾部。时间复杂度O(1)+O(logn)=O(logn)。红黑树查询的时间复杂度是logn

扩容机制

JDK1.7

  • 传入newCapacity(新的容量),引用扩容前的Entry数组
    • 判断如果扩容前的数组大小如果达到了 230,修改阈值为int的最大值(231 - 1) return;
  • 初始化一个容量为 newCapacity的数组,将数组转到新的Entry数组中,HashMap的table值引用新的Entry数组,修改阈值
    • transfer(Entry[] newTable) 中使用了单链表的头插入方式,同一个位置上新元素总被放在链表的头部位置
    • 即先放在一个索引上的元素终会被放在Entry链的尾部(发生hash冲突的话)。在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置

JDK1.8

  • 2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置,元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化
  • 在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成 “原索引+oldCap”

线程不安全

  • 多线程下扩容死循环。JDK1.7中的HashMap使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题
  • 多线程的put可能导致元素的丢失。多线程同时执行put操作,如果计算出来的索引位置是相同的,那会造成前一个key被后一个key覆盖,从而导致元素的丢失。此问题在JDK1.7和JDK1.8中都存在
  • put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题,此问题在JDK1.7和JDK1.8中都存在

解决线程安全问题

Java中有HashTable、Collections.synchronizedMap、以及ConcurrentHashMap可以实现线程安全的Map

  • HashTable是直接在操作方法上加synchronized关键字,锁住整个数组,粒度比较大
  • Collections.synchronizedMap是使用Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现
  • ConcurrentHashMap使用分段锁,降低了锁粒度,让并发度大大提高

HashMap 的遍历方式

HashMap 遍历的基类是 HashIterator,它是一个 Hash 迭代器,它是一个 HashMap 内部的抽象类,它的构造比较简单,只有三种方法,hasNextremovenextNode 方法,其中 nextNode 方法是由三种迭代器实现的

  • KeyIterator ,对 key 进行遍历
  • ValueIterator,对 value 进行遍历
  • EntryIterator, 对 Entry 链进行遍历
final class KeyIterator extends HashIterator
        implements Iterator<K> {
        public final K next() { return nextNode().key; }
    }

final class ValueIterator extends HashIterator
  implements Iterator<V> {
  public final V next() { return nextNode().value; }
}

final class EntryIterator extends HashIterator
  implements Iterator<Map.Entry<K,V>> {
  public final Map.Entry<K,V> next() { return nextNode(); }
}

HashMap和HashTable的区别

在这里插入图片描述
在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值