前言
集合与数组一样,也是一个容器(可以存储任意数量的具有共同属性的对象),与数组不同的是,集合的长度不定,可以无限的向集合中添加元素,而且集合中存储的元素类型可以随意。
集合框架包含:接口,抽象类以及完全定义的类,其中,每个完全定义的类继承了一个或多个抽象类,并实现了一个或多个接口。
接口:在JAVA编程语言中是一个抽象类型,是抽象方法的集合,接口通常以interface来声明。一个类通过继承接口的方式,从而来继承接口的抽象方法。
抽象类:具体的实现,重用性很高的数据结构,在一个实现了某个集合框架中的接口的对象身上完成某种有用的,可复用的计算的方法,比如排序,查找,统计等。
完全定义的类:具备某些共同特征的实体的集合。
应用集合框架的优点是和我们设计开发中的公共类同一思想,通过这些在无关API之间的简易的互用性,使你免除了为改编对象或转换代码以便联合这些API而去写大量的代码。它提高了程序速度和质量。
本文提纲
正文
Collection接口
List
是什么?
List:
列表,
元素是有序的(元素
带角标索引
),
可以有重复元素,可以有null元素。
ArrayList:底层的数据结构使用的是数组结构,Object数组。特点:查询速度很快。但是增删稍慢。线程不同步。
|--
因为底层采用数组的数据结构,而数组中的元素在堆内存中是连续分配的,而且有索引,所以查询快,增删稍慢。
|--
在使用迭代器遍历元素时,不能再使用集合的方法操作集合中的元素,不能增加或删除个数。
|--
调用集合的contains()或remove()方法时底层会调用equals方法,如果存入集合的对象没有实现equals(),则调用Object的equals()方法。
LinkedList:底层使用的链表数据结构,双向循环链表。特点:增删速度很快,查询稍慢。线程不同步。
Vector:底层是数组数据结构,线程同步。被ArrayList替代了,因为效率低。
总结:
在需要频繁读取集合中的元素时,更推荐使用 ArrayList,而在插入和删除操作较多时,更推荐使用 LinkedList。
List遍历的最佳实践
1、传统的for循环遍历,基于计数器的:
遍历者自己在集合外部维护一个计数器,然后依次读取每一个位置的元素,当读取到最后一个元素后,停止。主要就是需要按元素的位置来读取元素。
2、迭代器遍历,Iterator:
每一个具体实现的数据集合,一般都需要提供相应的Iterator。相比于传统for循环,Iterator取缔了显式的遍历计数器。所以基于顺序存储集合的Iterator可以直接按位置访问数据。而基于链式存储集合的Iterator,正常的实现,都是需要保存当前遍历的位置。然后根据当前位置来向前或者向后移动指针。
3、foreach循环遍历:
根据反编译的字节码可以发现,foreach内部也是采用了Iterator的方式实现,只不过Java编译器帮我们生成了这些代码。
多线程场景下如何使用ArrayList?
ArrayList 不是线程安全的,如果遇到多线程场景,最常用的方法是通过 Collections 的 synchronizedList 方法将 ArrayList 转换成线程安全的容器后再使用。
List list =Collections.synchronizedList(new ArrayList);list.add("test");
List list =Collections.synchronizedList(new ArrayList);
list.add("test");
Set
HashSet实现(底层)原理
HashSet实现Set接口,由
哈希表
(实际上是一个HashMap实例)支持。
它不保证set 的迭代顺序;特别是它不保证该顺序恒久不变。
此类允许使用null元素。HashSet的存储方式是把HashMap中的Key作为Set的对应存储项。该行的value就是一个Object类型的常量。
(HashMap储存键值对 HashSet仅仅存储对象)
为什么无重复?
HashSet的存储方式是把HashMap中的Key作为Set的对应存储项。因为HashMap的key是不能有重复的。
HashSet的实现:
对于HashSet而言,它是基于HashMap实现的,HashSet底层使用HashMap来保存所有元素,因此HashSet 的实现比较简单,相关HashSet的操作,基本上都是直接调用底层HashMap的相关方法来完成, (实际底层会初始化一个空的HashMap,并使用默认初始容量为16和加载因子0.75。)
对于HashSet中保存的对象,请注意正确重写其equals和hashCode方法,以保证放入的对象的唯一性。
一定要覆盖自定义类的 equals 和 hashCode 方法,hashCode 方法是找到当前对象在 Node 数组的位置,而 equals 是比较当前对象与对应坐标链表中的对象是否相同。
插入
当有新值加入时,底层的HashMap会判断Key值是否存在(HashMap细节请移步深入理解HashMap),如果不存在,则插入新值,同时这个插入的细节会依照HashMap插入细节;如果存在就不插入。
List和Set区别
List | Set | |
特点 | 一个有序(元素存入集合的顺序和取出的顺序一致)容器,元素可以重复,可以插入多个null元素,元素都有索引。常用的实现类有 ArrayList、LinkedList 和 Vector。 | 一个无序(存入和取出顺序有可能不一致)容器,不可以存储重复元素,只允许存入一个null元素,必须保证元素唯一性。Set 接口常用实现类是 HashSet、LinkedHashSet 以及 TreeSet。 |
遍历 | List 支持for循环,也就是通过下标来遍历,也可以用迭代器。 | set只能用迭代,因为他无序,无法用下标来取得想要的值。 |
和数组类似,List可以动态增长,查找元素效率高,插入删除元素效率低,因为会引起其他元素位置改变 | 检索元素效率低下,删除和插入效率高,插入和删除不会引起元素位置改变。 |
hashCode()与equals()的相关规定:
1.如果两个对象相等,则hashcode一定也是相同的 2.两个对象相等,对两个equals方法返回true 3.两个对象有相同的hashcode值,它们也不一定是相等的 4.综上,equals方法被覆盖过,则hashCode方法也必须被覆盖 5.hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写hashCode(),则该class的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)。 ==与equals的区别
1.==是判断两个变量或实例是不是指向同一个内存空间 equals是判断两个变量或实例所指向的内存空间的值是不是相同
2.==是指对内存地址进行比较 equals()是对字符串的内容进行比较3.==指引用是否相同 equals()指的是值是否相同
Map接口
Hash(散列函数)
什么是哈希冲突?当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做
碰撞(哈希碰撞)。
解决哈希冲突
JDK1.8之前
关键字列表为(19,14,23,01,68,20,84,27,55,11,10,79),则哈希函数为H(key)=key MOD 13。则采用
除留余数法
和
链地址法
后得到的预想结果应该为:
JDK1.8之后
相比于之前的版本,jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。
HashMap实现(底层)原理
HashMap概述:
HashMap是基于哈希表的Map接口的非同步实现。此实现提供所有可选的映射操作,并允许使用null值和null键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
HashMap的数据结构:
HashMap实际上是一个“数组+链表+红黑树”的数据结构.(JDK1.8后加入了红黑树)
HashMap的存取实现:
JDK1.8之前
当我们往HashMap中put元素的时候,先根据key的hashCode重新计算hash值,根据hash值得到这个元素在数组中的位置(即下标),如果数组该位置上已经存放有其他元素了,那么在这个位置上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。如果数组该位置上没有元素,就直接将该元素放到此数组中的该位置上。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { HashMap.Node[] tab; HashMap.Node p; int n, i; // 1.如果table为空或者长度为0,即没有元素,那么使用resize()方法扩容 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 2.计算插入存储的数组索引i,此处计算方法同 1.7 中的indexFor()方法 // 如果数组为空,即不存在Hash冲突,则直接插入数组 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); // 3.插入时,如果发生Hash冲突,则依次往下判断 else { HashMap.Node e; K k; // a.判断table[i]的元素的key是否与需要插入的key一样,若相同则直接用新的value覆盖掉旧的value // 判断原则equals() - 所以需要当key的对象重写该方法 if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k)))) e = p; // b.继续判断:需要插入的数据结构是红黑树还是链表 // 如果是红黑树,则直接在树中插入 or 更新键值对 else if (p instanceof HashMap.TreeNode) e = ((HashMap.TreeNode)p).putTreeVal(this, tab, hash, key, value); // 如果是链表,则在链表中插入 or 更新键值对 else { // i .遍历table[i],判断key是否已存在:采用equals对比当前遍历结点的key与需要插入数据的key // 如果存在相同的,则直接覆盖 // ii.遍历完毕后任务发现上述情况,则直接在链表尾部插入数据 // 插入完成后判断链表长度是否 > 8:若是,则把链表转换成红黑树 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; } if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // 对于i 情况的后续操作:发现key已存在,直接用新value覆盖旧value&返回旧value if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; // 插入成功后,判断实际存在的键值对数量size > 最大容量 // 如果大于则进行扩容 if (++size > threshold) resize(); // 插入成功时会调用的方法(默认实现为空) afterNodeInsertion(evict); return null;}
JDK1.8之后
put(): 根据key计算得到key.hash = (h = k.hashCode()) ^ (h >>> 16); 根据key.hash计算得到桶数组的索引index = key.hash & (table.length - 1),这样就找到该key的存放位置了: ① 如果该位置没有数据,用该数据新生成一个节点保存新数据,返回null; ② 如果该位置有数据是一个红黑树,那么执行相应的插入 / 更新操作。 ③ 如果该位置有数据是一个链表,分两种情况一是该链表没有这个节点,另一个是该链表上有这个节点,注意这里判断的依据是key.hash是否一样:如果该链表没有这个节点,那么采用尾插法新增节点保存新数据,返回null;如果该链表已经有这个节点了,那么找到该节点并更新新数据,返回老数据。 get(): 计算需获取数据的hash值(计算过程跟put一样),计算存放在数组table中的位置(计算过程跟put一样),然后依次在数组,红黑树,链表中查找(通过equals()判断),最后再判断获取的数据是否为空,若为空返回null否则返回该数据。 扩容机制的实现
扩容(resize)就是重新计算容量。当向HashMap对象里不停的添加元素,而HashMap对象内部的桶数组无法装载更多的元素时,HashMap对象就需要扩大桶数组的长度,以便能装入更多的元素。 capacity 就是数组的长度/大小,loadFactor 是这个数组填满程度的最大比比例。 size表示当前HashMap中已经储存的Node的数量,包括桶数组和链表 / 红黑树中的的Node。 threshold表示扩容的临界值,如果size大于这个值,则必须调用resize()方法进行扩容。 在jdk1.7及以前,threshold = capacity * loadFactor,其中 capacity 为桶数组的长度。这里需要说明一点,默认负载因子0.75是是对空间和时间(纵向横向)效率的一个平衡选择,建议大家不要修改。jdk1.8对threshold值进行了改进,通过一系列位移操作算法最后得到一个power of two size的值 什么时候扩容
当向容器添加元素的时候,会判断当前容器的元素个数,如果大于等于阈值—即当前数组的长度乘以加载因子的值的时候,就要自动扩容啦。 扩容必须满足两个条件:
1、 存放新值的时候 当前已有元素的个数 (size) 必须大于等于阈值
2、 存放新值的时候当前存放数据发生hash碰撞(当前key计算的hash值换算出来的数组下标位置已经存在值)
如果计算的哈希位置有值(及hash冲突),且key值一样,则覆盖原值value,并返回原值value。
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; }
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
resize()方法: 该函数有2种使用情况1.初始化哈希表 2.当前数组容量过小,需扩容
过程:
HashMap是先插入数据再进行扩容的,但是如果是刚刚初始化容器的时候是先扩容再插入数据。
插入键值对时发现容量不足,调用resize()方法方法,
1.首先进行异常情况的判断,如是否需要初始化,二是若当前容量》最大值则不扩容,
2.然后根据新容量(是就容量的2倍、)新建数组,将旧数组上的数据(键值对)转移到新的数组中,这里包括:(遍历旧数组的每个元素,重新计算每个数据在数组中的存放位置。(原位置或者原位置+旧容量),将旧数组上的每个数据逐个转移到新数组中,这里采用的是尾插法。)
3.新数组table引用到HashMap的table属性上
4.最后重新设置扩容阙值,
Hashtable底层原理
概念:
HashTable类继承自Dictionary类, 实现了Map接口。大部分的操作都是通过synchronized锁保护的,是线程安全的, key、value都不可以为null, 每次put方法不允许null值,如果发现是null,则直接抛出异常。
官方文档也说了:如果在非线程安全的情况下使用,建议使用HashMap替换,如果在线程安全的情况下使用,建议使用ConcurrentHashMap替换。
数据结构:
数组+链表。
存取实现:
put():
限制了value不能为null。
由于直接使用key.hashcode(),而没有向hashmap一样先判断key是否为null,所以key为null时,调用key.hashcode()会出错,所以hashtable中key也不能为null。
Hashtable是在链表的头部添加元素的。
int index = (hash & 0x7FFFFFFF) %tab.length;
获取index的方式与HashMap不同
扩容机制:
Hashtable默认capacity是11,默认负载因子是0.75.。当前表中的Entry数量,如果超过了阈值,就会扩容,即调用rehash方法,重新计算每个键值对的hashCode;
判断新的容量是否超过了上限,没超过就新建一个新数组,大小为原数组的2倍+1,将旧数的键值对重新hash添加到新数组中。
HashMap 与 HashTable 有什么区别
HashMap | HashTable | |
线程安全 | 非线程安全 | 线程安全,内部的方法基本都经过 synchronized 修饰 |
效率 | 基本淘汰 | |
对null键值 | null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null | put 的键值只要有一个 null,直接抛NullPointerException。 |
初始值扩容 | 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。 会将其扩充为2的幂次方大小 | 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。会直接使用你给定的大小 |
底层DS | 当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间 |
总结:
ConcurrentHashMap 结合了 HashMap 和 HashTable 二者的优势。HashMap 没有考虑同步,HashTable 考虑了同步的问题。但是 HashTable 在每次同步执行时都要锁住整个结构。ConcurrentHashMap 锁的方式是稍微 细粒度 的。 对null key和null value支持 :
Hashtable 是不允许键或值为 null ,Ha shtable使用的是安全失败机制(fail-safe),这种机制会使你此次读到的数据不一定是最新的数据. fail-fast(快速失败机制):
机制:
是java集合的一种 错误检测 机制,当多个线程对集合进行结构上的改变的操作时(增加,删除等),有可能会产生 fail-fast 机制。 原理:
迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变 modCount 的值。每当迭代器使用 hashNext()/next() 遍历下一个元素之前,会先比较这两个值是否相等,此时他们不相等,就抛出了 ConcurrentModificationException. ,终止遍历。 解决办法:
1.在遍历过程中,所有涉及到改变modCount值得地方全部加上synchronized。
2.使用CopyOnWriteArrayList来替换ArrayList
ConcurrentHashMap 的工作原理
概念:
ConcurrentHashMap的目标是实现支持高并发、高吞吐量的线程安全的HashMap。
JDK1.8之前:
数据结构:
ConcurrentHashMap是由Segment数组结构和 多个HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,
HashEntry 用来封装散列映射表中的键值对。在 HashEntry 类中,key,hash和 next 域都被声明为 final 型,value 域被声明为
volatile
型。
winter
volatile:
1.保证内存可见性(指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果,另一个线程马上就能看到。)
当对非volatile变量进行读写的时候,每个线程先从主内存拷贝变量到CPU缓存中,如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的CPU cache中。
volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,保证了每次读写变量都从主内存中读,跳过CPU cache这一步。当一个线程修改了这个变量的值,新值对于其他线程是立即得知的。
2 禁止指令重排(关于volatile特性,将会在后边文章展开)
一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap相似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
static final class Segment<K,V> extends ReentrantLock implements Serializable { private static final long serialVersionUID = 2249069246763182397L; // 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶 transient volatile HashEntry[] table; transient int count; // 快速失败(fail—fast) transient int modCount; // 大小 transient int threshold; // 负载因子 final float loadFactor;}
put和get的时候,都是现根据key.hashCode()算出放到哪个Segment中:ConcurrentHashMap中默认是把segments初始化为长度为16的数组
JDK1.8后:
变化:
ConcurrentHashMap的JDK8与JDK7版本的并发实现相比,最大的区别在于JDK8的锁粒度更细,理想情况下talbe数组元素的大小就是其支持并发的最大个数
实现:
改进一:取消segments字段,直接采用transient volatile HashEntry[] table保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。
数据结构:
改进二:将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。对于hash表来说,最核心的能力在于将key hash之后能均匀的分布在数组中。
概念:
JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。
总结:Node+CAS + synchronized来保证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。
一些成员:
Node是ConcurrentHashMap存储结构的基本单元,继承于HashMap中的Entry,用于存储数据。,就是一个链表,但是只允许对数据进行查找,不允许进行修改
通过TreeNode作为存储结构代替Node来转换成黑红树。
TreeBin就是封装TreeNode的容器,它提供转换黑红树的一些条件和锁的控制
// 读写锁状态
static final int WRITER = 1; // 获取写锁的状态
static final int WAITER = 2; // 等待写锁的状态
static final int READER = 4; // 增加数据时读锁的状态
构造器
public ConcurrentHashMap() {
} 初始化其实是一个空实现, 初始化操作并不是在构造函数实现的,而是在put操作中实现。还提供了其他的构造函数,有指定容量大小或者指定负载因子,跟HashMap一样。
存取实现:
put():对当前的table进行无条件自循环直到put成功
新:
版本1:
如果没有初始化就先调用initTable()方法来进行初始化过程
如果没有hash冲突就直接CAS插入
如果还在进行扩容操作就先进行扩容(ForwardingNode的hash值判断)
如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入,
最后一个如果该链表的数量大于阈值8,就要先转换成黑红树的结构,break再一次进入循环(树化)
如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容。
版本2:
1,判空;ConcurrentHashMap的key、value都不允许为null
2,计算hash。利用方法计算hash值。
3,遍历table,进行节点插入操作,过程如下:
如果table为空,则表示ConcurrentHashMap还没有初始化,则进行初始化操作:initTable()
根据hash值获取节点的位置i,若该位置为空,则直接插入,这个过程是不需要加锁的。计算f位置:i=(n - 1) & hash
如果检测到fh = f.hash == -1,则f是ForwardingNode节点,表示有其他线程正在进行扩容操作,则帮助线程一起进行扩容操作
如果f.hash >= 0 表示是链表结构,则遍历链表,如果存在当前key节点则替换value,否则插入到链表尾部。如果f是TreeBin类型节点,则按照红黑树的方法更新或者增加节点
若链表长度 > TREEIFY_THRESHOLD(默认是8),则将链表转换为红黑树结构
4,调用addCount方法,ConcurrentHashMap的size + 1
如果一个或多个线程正在对ConcurrentHashMap进行扩容操作,当前线程也要进入扩容的操作中。这个扩容的操作之所以能被检测到,是因为transfer方法中在空结点上插入forward节点,如果检测到需要插入的位置被forward节点占有,就帮助进行扩容;
如果检测到要插入的节点是非空且不是forward节点,就对这个节点加锁,这样就保证了线程安全。尽管这个有一些影响效率,但是还是会比hashTable的synchronized要好得多。
get()
计算hash值,定位到该table索引位置,如果是首节点符合就返回
如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回。
以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null
概括版:
对于get读操作,如果当前节点有数据,还没迁移完成,此时不影响读,能够正常进行。
如果当前链表已经迁移完成,那么头节点会被设置成fwd节点,get线程会帮助扩容。
对于put/remove写操作,如果当前链表已经迁移完成,那么头节点会被设置成fwd节点,此时写线程会帮助扩容,如果扩容没有完成,当前链表的头节点会被锁住,所以写线程会被阻塞,直到扩容完成。
扩容机制:
总体:
计算每个线程可以处理的桶区间。默认 16.
初始化临时变量 nextTable,扩容 2 倍。
死循环,计算下标。完成总体判断。
如果桶内有数据,同步转移数据。通常会像链表拆成 2 份。
先来看一下单线程是如何完成的:它的大体思想就是遍历、复制的过程。首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素:如果这个位置为空,就在原table中的i位置放入forwardNode节点,这个也是触发并发.
扩容的关键点;
如果这个位置是Node节点(fh>=0),如果它是一个链表的头节点,就构造一个反序链表,把他们分别放在nextTable的i和i+n的位置上
如果这个位置是TreeBin节点(fh<0),也做一个反序处理,并且判断是否需要untreefi,把处理的结果分别放在nextTable的i和i+n的位置上
遍历过所有的节点以后就完成了复制工作,这时让nextTable作为新的table,并且更新sizeCtl为新容量的0.75倍 ,完成扩容。
多线程是如何完成的:
如果遍历到的节点是forward节点,就向后继续遍历,再加上给节点上锁的机制,就完成了多线程的控制。多线程遍历节点,处理了一个节点,就把对应点的值set为forward,另一个线程看到forward,就向后遍历。这样交叉就完成了复制工作。