【Java集合框架】

集合框架

早在 Java 2 中之前,Java 就提供了特设类。比如:Dictionary, Vector, Stack, 和 Properties 这些类用来存储和操作对象组。

虽然这些类都非常有用,但是它们缺少一个核心的,统一的主题。由于这个原因,使用 Vector 类的方式和使用 Properties 类的方式有着很大不同。

集合框架被设计成要满足以下几个目标。

该框架必须是高性能的。基本集合(动态数组,链表,树,哈希表)的实现也必须是高效的。

该框架允许不同类型的集合,以类似的方式工作,具有高度的互操作性。

对一个集合的扩展和适应必须是简单的。

为此,整个集合框架就围绕一组标准接口而设计。你可以直接使用这些接口的标准实现,诸如: LinkedList, HashSet, 和 TreeSet 等,除此之外你也可以通过这些接口实现自己的集合。

本文主要针对常用集合进行总结。
Alt

HashTable

HashTable<K,V>又称散列表,是一种key-value结构,它继承自Dictionary<K,V>,实现了Map<K,V>和Cloneable以及Serializable接口。

HashTable的操作几乎和HashMap一致,主要的区别在于HashTable为了实现多线程安全,在几乎所有的方法上都加上了synchronized锁,而加锁的结果就是HashTable操作的效率十分低下。

HashTable与HashMap对比

  • 线程安全:HashMap是线程不安全的类,多线程下会造成并发冲突,但单线程下运行效率较高;HashTable是线程安全的类,很多方法都是用synchronized修饰,但同时因为加锁导致并发效率低下,单线程环境效率也十分低;

  • 插入null:HashMap允许有一个键为null,允许多个值为null;但HashTable不允许键或值为null;

    • 因为Hashtable使用的是安全失败机制(fail-safe),这种机制会使你此次读到的数据不一定是最新的数据。

    • 如果使用null值,就会使得其无法判断对应的key是不存在还是为空,因为你无法再调用一次contain(key)来对key是否存在进行判断,ConcurrentHashMap同理。

  • 容量:HashMap底层数组长度必须为2的幂,这样做是为了hash准备,默认为16;而HashTable底层数组长度可以为任意值,这就造成了hash算法散射不均匀,容易造成hash冲突,默认为11;

  • Hash映射:HashMap的hash算法通过非常规设计,将底层table长度设计为2的幂,使用位与运算代替取模运算,减少运算消耗;而HashTable的hash算法首先使得hash值小于整型数最大值,再通过取模进行散射运算;

	// HashTable
	int hash = key.hashCode();
	int index = (hash & 0x7FFFFFFF) % tab.length;
  • 扩容机制:HashMap创建一个为原先2倍的数组,然后对原数组进行遍历以及rehash;HashTable扩容将创建一个原长度2倍的数组,再使用头插法将链表进行反序;

  • 结构区别:HashMap是由数组+链表形成,在JDK1.8之后链表长度大于8时转化为红黑树;而HashTable一直都是数组+链表;

  • 继承关系:HashTable继承自Dictionary类;而HashMap继承自AbstractMap类;

  • 迭代器:HashMap是fail-fast(查看之前HashMap相关文章);而HashTable不是。

不推荐使用HashTable。

HashMap

散列映射,采用数组+(链表 | 红黑树)实现,可理解为数组中放链表 / 红黑树。
JDK1.7 数组 + 链表
JDK1.8 数组 + (链表 | 红黑树)

1. Hash映射

// HashMap
static final int hash(Object key) {
	int h;
	return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 下标index运算
int index = (table.length - 1) & hash(key)

数组位置为 hash(key) 和 (n - 1) 按位相与,而 hash(key) 为 key.hashCode() 中高16位与低16位按位异或,整个过程相当于对 n 取模,通过 hash(key) 解决可能出现的Hash碰撞(均匀散列)。

Hash碰撞:
任意较长字符串变成固定长度较短字符串,即一个输入串对应多个输出串,碰撞便必然存在。(两个输入串Hash函值相同)

解决方法:

  • 拉链法
    将大小为M的数组的每一个元素指向一个链表,链表中的每一个节点都存储散列值为该索引的键值对,这个就是拉链法。
    该方法的基本思想就是选择足够大的M,使得所有的链表都尽可能的短小,以保证查找的效率。对采用拉链法的哈希表实现的查找分为两步,首先是根据散列值找到对应的链表,然后沿着链表的顺序找到相应的键。

  • 线性探索法
    线性探测法是开放寻址法解决哈希冲突的一种方法,基本原理为,使用大小为M的数组来保存N个键值对,其中M>N,我们需要使用数组中的空位来解决碰撞冲突。
    开放寻址法中最简单的是线性探测法,当碰撞发生时即一个键的散列值被另外一个键占用时,直接检查散列表中下一个位置,即将索引值加1,这样的线性探测有三种结果:

    • 命中,该位置的键个被查找的键相同;
    • 未命中,键为空;
    • 继续查找,该位置的键和被查找的键不同。

不管是拉链法还是散列法,这种动态调整链表或者数组的大小以提高查询效率的同时,还应该考虑动态改变链表或者数组大小的成本。散列表长度加倍的插入需要进行大量的探索,这种均摊成本很多时候需要考虑。

2. 链表 & 红黑树

长度 >= 8 时,链表转为红黑树。
长度 <= 6 时,红黑树转为链表。

红黑树查找效率 O(logN)
链表查找效率 O(N/2)

(int)log8 = 3      8 / 2 = 4
(int)log6 = 3      6 / 2 = 3

故长度 >= 8 时,链表转为红黑树效率更高,长度 <= 6 时,红黑树转为链表效率更高,同时以 7 作为缓冲。

红黑树
红黑树时平衡二叉查找树的一种实现,红黑树中的节点,一类被标记为黑色,一类被标记为红色除此之外,一棵红黑树还需要满足这样几个要求:

  • 根节点是黑色的;
  • 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
  • 任何相邻的节点都不能同时为红色,红色节点是被黑色节点隔开的;
  • 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点

另外,红黑树是”近似平衡“的,红黑树相比avl树,检索时效率差不多,都通过平衡来二分查找。但对于插入删除等操作效率提高很多。红黑树不追求绝对的平衡,他允许局部很少的不完全平衡,这样对于效率影响不大,但省去了很多没有必要的调平衡操作,avl树调平衡有时候代价较大,所以效率不如红黑树。

3. 动态扩容(resize)
因为HashMap要求长度必须为 2 的 n 次方【为什么】,默认16,默认加载因子为 0.75,两者相乘为阈值,当存储内容达到阈值时,则进行扩容(乘2)。

比较巧妙的是,JDK1.8 中无需重新计算 hash 且减少了动态扩容时位置的调换,例如:

n     |  16 -> 10000     扩容后:    32 -> 100000
n - 1 |  15 -> 01111     扩容后:    31 -> 011111

其中 n - 1 中低位全为 1,扩容后,仅有一位差异,当 h & (n - 1) 时,h 对应最左边差异位为 0, 就可以保证新的数组下标和旧的一致,减少了动态扩容时位置的调换。
这也就是为什么要求长度必须为 2 的 n 次方,通过位运算提高效率。

4. 线程不安全

JDK1.7 :数据丢失、死循环
由于多线程对HashMap进行扩容,调用HashMap#transfer()

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

某个线程执行过程中,被挂起,其他线程已经完成数据迁移,等CPU资源释放后被挂起的线程重新执行之前的逻辑,数据已经被改变,造成死循环、数据丢失。

而JDK1.8直接在HashMap#resize()中完成了数据迁移,解决了数据丢失、死循环问题。

JDK1.8 :数据覆盖
两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。

ConcurrentHashMap

ConcurrentHashMap 和 Hashtable主要区别就是围绕着锁的粒度以及如何锁。不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。

1. JDK1.7 – 分段锁技术

set 方法中通过加 volatile 关键字保证数据可见性,原子性通过 scanAndLockForPut 自旋获取锁。

  • 每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。
    就是说如果容量大小是16他的并发度就是16,可以同时允许16个线程操作16个Segment而且还是线程安全的。

  • 先定位到Segment,然后再进行put操作。

  • 尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。

  • 如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。

get 方法中不加锁,故效率高,通过 Hash 定位到 Segment 上,再 Hash 定位到数据上。

  • 由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。
  • ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。

2. JDK1.8 – CAS + synchronized技术

也把之前的 HashEntry 改成了 Node,把值和 next 采用 volatile 修饰,保证了可见性。

put操作:

  • 根据 key 计算出 hashcode 。

  • 判断是否需要进行初始化。

  • 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 - CAS 尝试写入,失败则自旋保证成功。

  • 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。

  • 如果都不满足,则利用 synchronized 锁写入数据。

  • 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

    CAS 是乐观锁的一种实现方式,是一种轻量级锁,线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。

    CAS无法判断ABA问题
    一个线程把值改回B,又来一个线程把值又改回A,对于这个时候判断的线程,就发现他的值还是A,所以他就不知道这个值到底有没有被人改过。
    解决ABA

    • 版本号机制,就比如说,我在修改前去查询他原来的值的时候再带一个版本号,每次判断就连值和版本号一起判断,判断成功就给版本号加1。
    • 时间戳,查询的时候把时间戳一起查出来,对的上才修改并且更新值的时候一起修改更新时间。

get操作:

  • 根据 hashcode 寻址
  • 就在桶上:直接返回值。
  • 红黑树:那就按照树的方式获取值。
  • 都不满足:按照链表的方式遍历获取值。

synchronized锁升级
针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。

ArrayList

数组实现,大小动态变化,允许 null 存在,初始容量为 10,扩容 1.5 倍。

  • 优点:遍历快
  • 缺点:插入删除元素需要移动后面的元素

方法:

  • add(Object element): 向列表的尾部添加指定的元素。
  • size(): 返回列表中的元素个数。
  • get(int index): 返回列表中指定位置的元素,index从0开始。
  • add(int index, Object element): 在列表的指定位置插入指定元素。
  • set(int i, Object element): 将索引i位置元素替换为元素element并返回被替换的元素。
  • clear(): 从列表中移除所有元素。
  • isEmpty(): 判断列表是否包含元素,不包含元素则返回 true,否则返回false。
  • contains(Object o): 如果列表包含指定的元素,则返回 true。
  • remove(int index): 移除列表中指定位置的元素,并返回被删元素。
  • remove(Object o): 移除集合中第一次出现的指定元素,移除成功返回true,否则返回false。
  • iterator(): 返回按适当顺序在列表的元素上进行迭代的迭代器。

LinkedList

链表实现的双向链表,元素有序,输入与输出的排序一致,其中每个节点由三部分组成:

  • prev: 指向前端结点
  • item: 指向当前结点
  • next: 指向后端结点

与ArrayList不同的是:

  • 优点:插入删除元素只需改变前后两个节点即可
  • 缺点:遍历以及索引较慢

方法:

  • add(E e):将指定的元素追加到此列表的末尾
  • add(int index , E element):在此列表中的指定位置插入指定的元素
  • boolean addAll(Collection< ? extends E > c):按照指定集合的迭代器返回的顺序将指定集合中的所有元素追加到此列表的末尾
  • boolean addAll(int index , Collection < ? entends E >c):将指定集合中的所有元素插入到此列表中,从指定的位置开始
  • void addFirst(E e):在该列表开头插入指定的元素
  • void addLast(E e):将指定的元素追加到此列表的末尾
  • void clear():从列表中删除所有元素
  • boolean contains(Object o):如果此列表包含指定的元素,则返回 true
  • IteratordescendingIterator():以相反的顺序返回此deque中的元素的迭代器
  • E element():检索但不删除此列表的头(第一个元素)
  • E get(int index):返回此列表中指定位置的元素
  • E gerFirst():返回此列表中的第一个元素
  • E getLast():返回此列表中的最后一个元素
  • int indexOf(Object o):返回此列表中指定元素的第一次出现的索引,如果此列表不包含元素,则返回-1
  • int lastIndexOf(Object o):返回此列表中指定元素的最后一次出现的索引,如果此列表不包含元素,则返回-1
  • ListIteratorlistIterator(int index):从列表中的指定位置开始,返回此列表中元素的列表迭代器(按适当的顺序)
  • boolean offer(E e):将指定的元素添加为此列表的尾部(最后一个元素)
  • boolean offerFirst(E e):在此列表的前面插入指定的元素
  • boolean offerLast(E e):在该列表的末尾插入指定的元素
  • E peek():检索但不删除此列表的头(第一个元素)
  • E peekFirst():检索但不删除此列表的第一个元素,如果此列表为空,则返回 null
  • E peekLast():检索但不删除此列表的最后一个元素,如果此列表为空,则返回 null
  • E poll():检索并删除此列表的头(第一个元素)
  • E pollFirst():检索并删除此列表的第一个元素,如果此列表为空,则返回 null
  • E pollLast():检索并删除此列表的最后一个元素,如果此列表为空,则返回 null
  • E pop():从此列表表示的堆栈中弹出一个元素
  • void push (E e):将元素推送到由此列表表示的堆栈上
  • E remove():检索并删除此列表的头(第一个元素)
  • boolean remove(Object o ):从列表中删除指定元素的第一个出现(如果存在)
  • E removeFirst():从此列表中删除并返回第一个元素
  • boolean removeFirstOccurrence(Object o):删除此列表中指定元素的第一个出现(从头到尾遍历列表时)
  • E removeLast():从此列表中删除并返回最后一个元素
  • boolean removeLastOccurrence(Object o):删除此列表中指定元素的最后一次出现(从头到尾遍历列表时)
  • E set(int index,E element):用指定的元素替换此列表中指定位置的元素。
  • int size():返回此列表中的元素数
  • Object[] toArray():以正确的顺序(从第一个到最后一个元素)返回一个包含此列表中所有元素的数组

HashSet

HashSet 是一个没有重复元素的集合。

由HashMap实现,不保证元素的顺序,而且 HashSet 允许使用 null 元素,且非同步。

方法:

  • add(Object obj):向Set集合中添加元素,添加成功返回true,否则返回false
  • size() :返回Set集合中的元素个数
  • remove(Object obj) : 删除Set集合中的元素,删除成功返回true,否则返回false
  • isEmpty() :如果Set不包含元素,则返回 true ,否则返回false
  • clear() : 移除此Set中的所有元素
  • iterator() :返回在此Set中的元素上进行迭代的迭代器
  • contains(Object o):如果Set包含指定的元素,则返回 true,否则返回false

TreeMap

TreeMap存储K-V键值对,通过红黑树(R-B tree)实现;
TreeMap继承了NavigableMap接口,NavigableMap接口继承了SortedMap接口,可支持一系列的导航定位以及导航操作的方法,当然只是提供了接口,需要TreeMap自己去实现;
TreeMap实现了Cloneable接口,可被克隆,实现了Serializable接口,可序列化;
TreeMap因为是通过红黑树实现,红黑树结构天然支持排序,默认情况下通过Key值的自然顺序进行排序;

方法:

  • void clear():从此TreeMap实例中删除所有映射
  • Object clone():返回此TreeMap实例的浅表副本
  • Comparator comparator():返回用于对此映射进行排序的比较器,如果此映射使用键的自然顺序,则返回null
  • boolean containsKey(Object key):如果此映射包含指定键的映射,则返回true
  • boolean containsValue(Object value):如果此映射将一个或多个键映射到指定值,则返回true
  • Set entrySet():返回此映射中包含的映射的set视图
  • Object firstKey():返回此有序映射中当前的第一个(最低)键
  • Object get(Object key):返回此映射将指定键映射到的值
  • SortedMap headMap(Object toKey):返回此映射的部分视图,键严格小于toKey
  • Set keySet():返回此映射中包含的键的Set视图
  • Object lastKey():返回此有序映射中当前的最后一个(最高)键
  • Object put(Object key, Object value):将指定的值与此映射中的指定键相关联
  • void putAll(Map map):将指定映射中的所有映射复制到此映射
  • Object remove(Object key):从此TreeMap中删除此键的映射
  • int size():返回此映射中键-值映射的数量

TreeSet

TreeSet 是一个有序的集合,它的作用是提供有序的Set集合。是种基于TreeMap 的NavigableSet实现,非线程安全,TreeSet 的性能比 HashSet 差。

TreeSet继承了AbstractSet抽象类,是一个set集合,可以被实例化,且具有set的属性和方法,例如 add、remove、get 等方法,并保证的 log(n) 时间开销。

TreeSet 支持两种排序方式:

  • 自然排序
  • 根据创建TreeSet 时提供的 Comparator 进行排序。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值