Java集合框架知识总结

前言

Java集合框架主要由两个接口及其下面的实现类构成,这两个接口分别是Map接口Collection接口,下面先通过其对应的UML类图看下这两个接口的具体实现,如下

1、Map接口

在这里插入图片描述
Map接口的主要实现有我们熟悉的HashMapHashTable以及TreeMapConcurrentHashMap等,其中,AbstractMap提供了Map接口的大部分方法的实现,最大限度的减少实现Map接口的工作量,这在AbstractMap的开篇写道this class provides a skeletal implementation of the Map interface, to minimize the effort required to implement this interface.,下面,我们将详细介绍Map接口的几个实现类。

2、Collection接口

在这里插入图片描述
Collection接口实现了Iterator接口,它下面有三个主要接口继承其本身,List、Queue和Set,这三个接口是我们常见的实现类的接口,比如ArrayList、LinkedList、HashSet等等,其中的AbstractCollection、AbstractList等同上面的AbstractMap的作用。这里的Queue接口的实现我画出的是阻塞队列ArrayBlockingQueue和LinkedBlockingQueue,这两个队列我自己的应用是在线程池中设置阻塞队列,后面简单介绍一下。

至此,集合框架的两个大接口的常见实现有了一个大体的了解,下面,针对常用的集合进行具体介绍。

一、Map接口

1、HashMap

提到Map接口,第一个想到的就是HashMap了,对于其组成,有两个版本,jdk8之前,由数组+链表组成,数组是存储的主要位置,链表是为了解决哈希冲突;在jdk8之后,有了一个很大的变化,由数组+链表/红黑树组成,当链表长度大于树化阈值时,会转变成红黑树,从而减少搜索时间,下面基于jdk8做一个分析。

结构

DEFAULT_INITIAL_CAPACITY,默认初始容量(数组大小),16(1<<4)
MAXIMUM_CAPACITY,最大容量,1<<30
DEFAULT_LOAD_FACTOR,默认负载因子,0.75f
TREEIFY_THRESHOLD,树化阈值,8
UNTREEIFY_THRESHOLD,链表化阈值,6
MIN_TREEIFY_CAPACITY,最小树化节点数量,树化的另一个参数,当哈希表中的所有桶个数超过64时,才允许树化,64
threshold,扩容阈值,`capacity *  loadFactory`(容量*加载因子)
size,存储的元素个数,即k-v键值对的个数
modCount,散列表结构变化的次数,例如增删节点(修改值不算),用于快速失败机制

前面说过,HashMap由数组+链表/红黑树组成,其中,数组存储的是node链表,transient Node<K,V>[] table;,node节点如下组成

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash; //元素的hash值
        final K key; //键
        V value; //值
        Node<K,V> next; //指向下一个节点
    }

因此,我们先大概画一下HashMap的结构
在这里插入图片描述
数组的查询速度快,链表的插入速度快,下面,开始分析数据的插入、删除、扩容等相关内容,当某个链表的长度超过树化阈值(8)同时节点个数超过最小树化节点数(64)时,才转变成红黑树;如果没有达到最小树化节点数,那么考虑进行数组扩容(容量变成原来的2倍),而不是转变红黑树,从而减少搜索时间

传入初始化容量

当我们传入容量initialCapacity时,构造函数会调用一个tableSizeFor方法,进行数组的容量初始化,通过这个方法返回一个大于等于initialCapacity的2的幂次方的数,例如传入7,返回的是8

static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

这也引申出,数组的容量是2的幂次方数,这是因为通过哈希值(32位int类型数)计算数组下标时候,做法是hash&(n.length-1),因此,只有是2的n次方,进行-1操作才能保证2进制数每位都是1,这样按位与才能计算出非0的数,保证数据分散均匀

特点

HashMap存储是无序的;键和值都可以为null,但是只能有一个null键;线程不安全

hash算法&hash碰撞

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

hash算法:让key的hashcode异或hashcode无符号右移16位,这样做是为了让hashcode的高16位也能参与运算,从而使得散列均匀,减少hash碰撞
hash碰撞:两个不同的key,a.hashcode == b.hashcode,这样会去比较a.equals(b),如果true,那么覆盖值,否则,将节点插入链表尾部(jdk7插入头部)

put添加流程

注意,put方法是有返回值的,返回的是之前存储的value(即返回老值),hashmap是懒加载,在第一次put的时候才会创建数组

判断当前数组是否为空,若为空,调用resize方法初始化数组
根据key计算hash值,根据hash值找到数组下标位置(数组长度-1 & hash 计算)
	如果当前下标没有节点,将节点e放入当前位置
	当前位置存在节点p,如果p的hash值等于e的hash值(发生hash冲突)
			如果key存在,覆盖value值
			key不存在,要插入新节点,判断p是不是树节点
				如果是树节点,挂到树上
				否则,判断是否树化,若不树化插入链表的尾部,树化则挂到树节点
	完成这些,根据元素个数是否超过扩容阈值,是否扩容			

resize扩容流程

扩容是在hashmap容量 > 初始容量*负载因子 时发生的,扩容是将数据长度放大到原来的2倍,扩容后节点的存储位置要么是原位置,要么是原位置+原数组长度的位置,扩容分为两大步,计算新的数组容量和迁移旧数据

计算数组容量:
旧容量大于0
	旧容量大于等于最大容量
		扩容因子赋值为Int最大值,返回老容量
		否则新容量 = 2*旧容量,新容量小于最大容量且旧容量大于等于默认容量,新扩容阈值 = 2*旧阈值
旧容量小于0且旧扩容阈值大于0
	新容量 = 旧扩容阈值
旧容量和旧阈值都小于0(还未初始化)
	默认容量和默认阈值		
转移元素:
创建新数组,容量为之前的2倍
遍历旧数组
	如果只有头节点,直接计算新的位置并赋值
	如果是树结构,单独处理
	如果是链表,计算节点下标,链接到新数组的链表上			

存放自定义对象时,需要注意什么

存放自定义对象时,重写对象的hashcode和equals方法。首先我们知道hashmap的key是不允许重复,如果没有重写equals方法,我们看下如下代码

public static void main(String[] args) {
        user user1 = new user("张三");
        user user2 = new user("张三");
        HashMap<user,String>hashMap = new HashMap<>();
        hashMap.put(user1,"1");
        hashMap.put(user2,"2");
        System.out.println(hashMap.size());
    }

上面代码的运行结果是2,这里先引申下== 和 equals的区别

==比较的是两个对象地址是不是相等
	对于基本数据类型,比较的是值
	对于引用数据类型,比较的是内存地址
equals比较的是两个对象是否相等
	若未重写equals方法,那么作用等同于==,比较的是对象地址
	若重写equals方法,那么一般情况是重写比较两个对象的内容是否相等

因此,这里的user1和user2是两个对象,所以结果是2,那么我们重写了equals方法之后是否就输出1了呢?答案是否定的,因为hashmap在放入对象的时候是先通过key计算hash值的,再没重写hashcode方法时,调用的是Object类的一个native方法,返回的是不同值,因此计算数组下标不同,所以可以存进去。那么我们两个方法都重写之后,才能确定是同一个key

public class user {
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    private String name;

    public user(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        user user = (user) o;
        return Objects.equals(name, user.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name);
    }
}

是否线程安全?怎么做到线程安全?

HashMap是非线程安全的,可以通过Collections.synchronizedMap(hashMap);方法将HashMap转变成线程安全,这是因为SynchronizedMap内部维护了一个互斥锁mutex,通过synchronized对HashMap的s方法做了修饰;同时我们可以考虑使用ConcurrentHashMap保证线程安全

为什么String、Integer等包装类适合作为HashMap的key?

包装类的特性能够保证hash值的不可更改性和计算准确性,能够有效减少hash碰撞;包装类都是final类型,即保证key的不可更改性,不会存在获取hash值不同的情况;
包装类内部重写了hashCode()方法和equals()方法;

如何实现HashMap的有序性

可以考虑使用LinkedHashMap或者TreeMap。以LinkedHashMap为例,LinkedHashMap的结构大体上和HashMap没什么两样,其Entry对象增加了两个属性,Entry<K,V> before, after;即增加了一个标识签后节点的before和after,形成了一个双向链表,这样,在存储数据的时候,就已经做到了有序存储

fail-fast机制

在遍历HashMap时删除元素或多线程下修改集合结构,会有一个ConcurrentModificationException报错,在前文中说到HashMap有一个modCount来记录结构修改次数,有一个expectedModCount是迭代器期望的修改次数,只有迭代器能修改这个次数,在每次迭代过程中,都会去判断modCount的值是否和expectedModCount相等,如果在迭代过程中add/remove元素,由于add/remove方法不是迭代器的方法,所以不会修改expectedModCount,这就导致了报错。这个机制适用于java.util包下的所有集合。那么,怎么做到一边遍历一遍删除呢?使用迭代器Iterator.remove方法或者倒序遍历删除。对应fail-fast机制,还有fail-safe机制,用在JUC下的容器中

如何确定一个较为合适的容量

在使用HashMap时,传入一个合适的初始容量可以有效避免扩容,可以参考putAll方法中的一个公式float ft = ((float)s / loadFactor) + 1.0F,只要这个ft不超过容量最大值就返回(int)ft,否则使用最大值。例如,我们期望存储7个元素,那么7/0.75+1最终得到10,然后又因为2的幂次方,因此找大于等于10且最接近的值,就是16,因此,传入16;当然,我们也可以使用Guava中提供的Maps.newHashMapWithExpectedSize(7)方法,直接初始化期望容量的HashMap

2、ConcurrentHashMap

结构

ConcurrentHashMap底层也是由数组+链表组成,在jdk7中ConcurrentHashMap由segment数组(即将hashEntry数组分成几个小段得到的数组)+hashEntry数组构成,每个segment加一把锁(继承ReentrantLock,可重入锁,默认并发度16),互不影响;在jdk8中,与HashMap构成相同,采用Node数组+链表/红黑树组成,采用cas+synchronized,锁的是链表的头节点/红黑树的根节点,最大限度的保证并发性

put流程

计算key的hash值,开始自旋,判断是否初始化数组
定位到Node,去拿首节点f
如果f为null,f = tabAt(tab, i = (n - 1) & hash),通过cas插入节点
如果f.hash==MOVED(-1),参与扩容
synchronized锁住f节点(头节点),判断是链表还是红黑树,插入节点
判断是否达到树化条件,树化操作

get流程&是否需要加锁

计算key的hash值,判断数组是否为空,取到对应位置节点e = tabAt(tab, (n - 1) & h)
如果是头节点直接返回
如果是红黑树,就搜索树节点返回
如果是链表,就遍历链表返回

get是不需要加锁的,这是因为Node节点的value和next指针都是通过volatile关键字修饰的,这个关键字保证了多线程下变量修改的可见性

是否允许key、value为null

答案是不允许,if (key == null || value == null) throw new NullPointerException();直接抛出空指针异常。因为concurrentHashMap是线程安全的,假设允许value为null,那么就会产生歧义(这个key存储的是null,这个key不存在),比如我A线程获取到了null,假设是没有这个key,那么contains(key)应该返回false,但是在A线程判断key是否存在时,B线程插入了key-null,那么A线程contains(key)返回的就是true了,线程不安全。因此,为了避免歧义,value不允许为null

3、HashTable

结构

HashTable底层是由Entry数组构成的,每个Entry对象中都有next属性指向下一个对象,因此形成了一个链表,即数组+链表

初始化&扩容

默认初始化容量为11,默认的负载因子为0.75f,如果传入容量为0,则设置为1。扩容后的新容量=旧容量*2+1newCapacity = (oldCapacity << 1) + 1,前提是计算出来的新容量不超过MAX_ARRAY_SIZE(最大数组容量,Integer.MAX_VALUE - 8)

get流程

计算key的hash值
根据hash值和数组长度计算key的数组下标
根据数组下标获取头节点,遍历链表返回对应节点

put流程

判断value是否为null,为null抛出空指针异常
获取key的hash值
根据hash值和数组长度计算下标
获取下标位置节点,逐个遍历
如果有相同key,覆盖值
否则添加节点

线程安全&是否允许key-value为null

HashTable是线程安全的,它的操作方法,例如put、get、containsKey、remove等都是添加了synchronized关键字修饰;key和value都不允许为null,这里的原因和上面ConcurrentHashMap同理

HashTable和HashMap的区别

HashMap线程不安全;HashTable线程安全
HashMap允许key和value为null;HashTable不允许
HashMap默认初始容量16,扩容后容量为之前的2倍;HashTable默认初始容量为11,扩容后容量为之前的2倍+1
HashMap在jdk8结构调整为数组+链表/红黑树;HashTable结构为数组+链表
HashMap效率比HashTable高,若要保证线程安全,考虑使用ConcurrentHashMap而非HashTable,HashTable是jdk1.0提出,HashMap是jdk1.2提出,因此,日常开发不要使用HashTable,基本被抛弃

二、Collection接口

1、ArrayList

结构

ArrayList底层是一个Object类型的数组,这也就导致了ArrayList增删慢,查询快的特性,默认数组长度为10,支持传入初始化容量,若传入值为0,则返回ArrayList内部定义的空数组Object[] EMPTY_ELEMENTDATA,如果不传入数组容量,实际上也是初始化一个空数组Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA,在第一次添加元素的时候进行容量的计算和初始化,先判断是否是DEFAULTCAPACITY_EMPTY_ELEMENTDATA数组,如果是,初始化默认容量和传入最小容量(数组长度+1)的最大值

if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            return Math.max(DEFAULT_CAPACITY, minCapacity);
        }

可以添加null元素,可以重复

扩容

ArrayList的扩容相对来说较为容易,首先计算新容量oldCapacity + (oldCapacity >> 1)
新容量 = 旧容量+旧容量的一半(旧容量右移一位),即新容量=1.5*旧容量
判断新容量是否小于最小容量(即扩容时传入的容量),如果小于,则保持当前容量
判断新容量是否大于最大容量(MAX_ARRAY_SIZE,值为Integer.MAX_VALUE - 8),如果大于,判断当前容量>最大容量(MAX_ARRAY_SIZE)?Integer.MAX_VALUE:MAX_ARRAY_SIZE
确定新容量后,通过Arrays.copyOf将旧数据复制到新数组

线程安全

ArrayList是非线程安全的,例如两个线程向同一个ArrayList添加元素,那么ArrayList可能最终处于不一致的状态,两个线程添加的元素之一可能无法添加到列表中。如果想要保证线程安全,那么考虑使用CopyOnWriteArrayList,在jdk1.0中还有vector,也实现了动态数组,使用了大量的synchronized保证线程安全,但是效率极低,目前很少使用,当然也可以使用Collections.synchronizedList方法处理,它也是通过synchronized关键字保证线程安全

补充:Vector
vector是线程安全的,操作方法通过synchronized关键字修饰,因此效率极低;vector默认初始容量是10,默认扩容为原来的2倍;vector底层也是Object类型的数组

2、CopyOnWriteArrayList

为什么线程安全

首先,CopyOnWriteArrayList和ArrayList在结构上没有太大的区别,我们注意到源码中有一个可重入锁的定义final transient ReentrantLock lock = new ReentrantLock();,而且数组是通过volatile关键字修饰的,保证了可见性,copyOnWrite翻译过来是写时复制,也就是说在操作写数据(增删改)操作时,是先通过重入锁加锁,然后复制一个新数组,对新数组进行操作,最后指向新数组。

3、LinkedList

结构

LinkedList底层是由node节点组成的双向链表,每个节点包含两部分:数据和对序列中前后节点的引用。添加元素可以为null,元素可以重复,添加时,从链表尾部添加;同时,LinkedList还实现了Deque接口,因此具有双端队列功能;非线程安全

ArrayList和LinkedList的区别

  • 底层结构:ArrayList底层是Object类型的数组;LinkedList底层是Node节点组成的双向链表
  • 增删元素:ArrayList受元素位置影响,默认是增加元素到数组尾部,时间复杂度O(1),如果固定位置,那就是O(n-i),需要移动其他元素;LinkedList删除元素的时间复杂度是O(1),只需要断开该元素的连接即可,增加元素默认到链表尾部,如果指定位置那时间复杂度接近O(n)
  • 快速随机访问:ArrayList由于底层是数组,通过数组下标可以实现快速访问;LinkedList不能快速随机访问

4、HashSet&LinkedHashSet

结构

HashSet底层定义了一个HashMap,它的无参构造器就是实现一个HashMap,代码如下public HashSet() {map = new HashMap<>()},因此,HashSet基本上就是靠HashMap的结构和接口实现的,存储的是HashMap中的key,不允许重复,可以为null,无序;HashSet的有参构造可以有三个参数,除了初始容量和加载因子这两个HashMap中的参数外,还有一个布尔类型的dummy参数,通过它来控制实现的map是HashMap还是LinkedHashMap,同理,LinkedHashSet底层就是实现的LinkedHashMap

为什么数据不重复

HashSet 内部使用 HashMap 存储元素,对应的键值对的键为 Set 的存储元素,值为一个默认的 Object 对象PRESENT,通过比较key的hash值和value来确定是否是同一个元素,也就是hashCode方法和equals方法

补充,hashCode和equals的关系:
两个对象相等,那么hashCode一定相等,equals方法返回true
两个对象的hashCode相等,这两个对象也不一定相等
如果重写了equals方法,那么一定要重写hashCode方法
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

雅俗共赏zyyyyyy

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值