两个抽象接口派生:一个是Collection接口,存放单一元素;一个是Map接口存放键值对。
简单,因为官方在可能涉及到线程不安全的操作都进行了synchronized操作,就自身源码就给你加了把锁。
Vector每次扩容两倍大小(capacityIncrement为0时)
相较于ArrayList,Vecotr多了一个构造方法 :
public Vector(int initialCapacity, //向量的初始容量
int capacityIncrement) //向量溢出时容量增加的量
ArrayList的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间。
通过ArrayList扩容源码得知,当列表长度超过数组长度,开始调用 grow() 方法扩容,如果成功扩容1.5倍,就会在列表结尾处留出一定空间。
/**
* ArrayList扩容的核心方法。
*/
private void grow(int minCapacity) {
// oldCapacity为旧容量,newCapacity为新容量
int oldCapacity = elementData.length;
//将oldCapacity 右移一位,其效果相当于oldCapacity /2,
//我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍,
int newCapacity = oldCapacity + (oldCapacity >> 1);
//然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量,
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
//再检查新容量是否超出了ArrayList所定义的最大容量,
//若超出了,则调用hugeCapacity()来比较minCapacity和 MAX_ARRAY_SIZE,
//如果minCapacity大于MAX_ARRAY_SIZE,则新容量则为Interger.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE。
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
RandomAccess接口代码:
public interface RandomAccess {
}
RandomAccess 接口不过是一个标识罢了,ArrayList实现RandomAccess接口表明它有快速访问功能,只是个标识,并不是说它实现RandomAccess接口才具有随机访问功能的。
在添加大量元素前,应用程序可以使用ensureCapacity操作来增加 ArrayList 实例的容量。这可以减少递增式再分配的数量。
以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10。
/**
*默认无参构造函数
*DEFAULTCAPACITY_EMPTY_ELEMENTDATA 为0.初始化为10,也就是说初始其实是空数组 当添加第一个元素的时候数组容量才变成10
*/
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
补:JDK6 new 无参构造的 ArrayList 对象时,直接创建了长度是 10 的 Object[] 数组 elementData。
arraycopy() 需要目标数组,将原数组拷贝到你自己定义的数组里或者原数组,而且可以选择拷贝的起点和长度以及放入新数组中的位置 copyOf() 是系统自动在内部新建一个数组,并返回该数组。
先从二者的字面含义来理解它,Comparable 翻译为中文是“比较”的意思,而 Comparator 是“比较器”的意思。Comparable 是以 -able 结尾的,表示它自身具备着某种能力,而 Comparator 是以 -or 结尾,表示自身是比较的参与者,这是从字面含义先来理解二者的不同。
Comparable 接口只有一个方法 compareTo,实现 Comparable 接口并重写 compareTo 方法就可以实现某个类的排序了,它支持 Collections.sort 和 Arrays.sort 的排序。
Comparator 和 Comparable 的排序方法是不同的,Comparable 排序的方法是 compareTo,而 Comparator 排序的方法是 compare。
Comparator 除了可以通过创建自定义比较器外,还可以通过匿名类的方式,更快速、便捷的完成自定义比较器的功能(平常俺习惯在leetcode上刷题其实就是用了comparator)。
使用 Comparable 必须要修改原有的类,也就是你要排序那个类,就要在那个中实现 Comparable 接口并重写 compareTo 方法,所以 Comparable 更像是“对内”进行排序的接口。
而 Comparator 的使用则不相同,Comparator 无需修改原有类。通过 Comparator 接口可以实现和原有类的解耦,在不修改原有类的情况下实现排序功能,所以 Comparator 可以看作是“对外”提供排序的接口。
Comparable 和 Comparator 都是用来实现元素排序的,它们二者的区别如下:
根据因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。
抛出异常 | 返回特殊值 |
---|---|
add(E e) | offer(E e) |
remove() | poll() |
element() | peek() |
PriorityQueue并没有直接实现 Queue接口,而是通过继承 AbstractQueue 类来实现 Queue 接口的一些方法,在 Java 定义中,PriorityQueue 是一个基于优先级的无界优先队列。
与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。这里列举其相关的一些要点:
下面这个方法保证了 HashMap 总是使用 2 的幂作为哈希表的大小。
/**
* Returns a power of two size for the given target capacity.
*/
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;
}
TreeMap 和HashMap 都继承自AbstractMap ,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap 接口。
实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。
实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。
相比于HashMap来说 TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。
当你把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。
注:在 JDK1.8 中,实际上无论HashSet中是否已经存在了某元素,HashSet都会直接插入,只是会在add()方法的返回值处告诉我们插入前是否存在相同元素。
JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。
JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
注:链表的长度大于 8 的时候,就执行 treeifyBin (转换红黑树)的逻辑。注意这里是执行逻辑,并不是直接转换为红黑树!!(其实就算调用treeifyBin函数进行判断)。treeifyBin 方法中判断是否真的转换为红黑树,判断当前数组的长度是否小于 64,如果当前数组的长度小于 64,那么会选择先进行数组扩容,则才将列表转换为红黑树。
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ (n - 1) & hash”。(n 代表数组长度)。这也就解释了 HashMap 的长度为什么是 2 的幂次方。
为了增强散列性,使元素尽可能的分散开来,从而减少碰撞去使用链表/红黑树,HashMap的发明者采用了位运算的方式来实现一个均匀分布且高效率的函数来求下标。
index = HashCode(Key) & (Length - 1)
而只有当HashMap的长度为2的幂时,length-1 正好相当于一个低位掩码,所有二进制位都为1,才能将哈希值的高位全部归零,只保留低位值用来做数组下标访问,且保证范围在length内。
比如下面:
HashMap 通过 key 的 hashcode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法,换句话说使用扰动函数之后可以减少碰撞。
前面我们知道了下标的运算方式,是通过低位掩码与哈希值相与得到哈希值的低位来做下标,这就意味着这个哈希值的计算方式几乎决定了HashMap的效率(散列值、碰撞率、分散情况),所以哈希算法的实现很关键。扰动函数就是为此而引入。
(jdk1.7中首次引入扰动函数,但是共做了四次扰动,1.8做了优化只用了一次)
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
// 这里是扰动函数
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^ :按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看出扰动函数就是将key的哈希值h与右移16位后的h进行异或运算。
用一句话来概括扰动函数的作用就是:将h的hashCode右移16位并与自身相异或 相当于 使自己的高16位和低16位 相异或,得到的值既包含了自己高位的特性又包含了自己低位的特性,从而增加了之后得到的下标的不确定性,降低了碰撞的概率。
具体来说,如下:
如果table[i]为空,创建新节点存入
如果table[i]不为空,根据HashCode和Key值在链表/红黑树中寻找目标位置并更新其value值
1. 如果没有发生碰撞
- JDK1.7将新节点插在当前链表头部的前面然后再下移(头插法)
- JDK1.8
2.如果发生碰撞,通过比较key的地址或者key的值(equals)遍历链表/红黑树获取旧值,覆盖后返回旧值
3.如果HashMap容量达到阈值initialCapacity*loadFactor,则进行扩容
我个人对于JDK1.8尾插法的理解:
JDK1.8 新增链表转红黑树的阈值,因此在插入的时候必须知道链表的长度,如果长度超出这个阈值就将其转化为红黑树,因此在插入式必须遍历链表得到链表长度,于是在jdk1.8里插入结点时选择直接插在链表尾部,反正都要遍历一次,这样还保证了在扩容的时候对元素进行transfer时链表的顺序不会像1.7一样倒转,也就不会出现死循环链表。
JDK1.7: (size >= threshold) && (null != table[bucketIndex])
存放的键值对数超出阈值,并且新增结点要插入的地方不为空
JDK1.8:++size > threshold
只要存放键值对数超出阈值就扩容
默认扩容16,原数组长度,构建一个原数组长度两倍的新数组,并调用transfer方法将原数组的数组通过重新计算哈希值得到下标再转移到新增数组。
JDK1.7调用indexFor方法重新计算下标,并采用跟插入结点时一致的方式(头插法)挨个移动结点。transfer方法的作用是把原table的Node放到新的table中,使用的是头插法,也就是说,新table中链表的顺序和旧列表中是相反的,在HashMap线程不安全的情况下,这种头插法可能会导致环状节点。比如:线程1准备处理节点,线程2把HashMap扩容成功,链表已经逆向排序,那么线程1在处理节点时就可能出现环形链表。
JDK1.8则是根据规律将原链表拆分为两组,分别记录两个头结点,移动时直接移动头结点。这规律是: HashMap扩容后,原来的元素要么在原位置,要么在原位置+原数组长度 那个位置上。
举个例子:
原来的HashMap长度为4,table[2]上存放了A。现在要进行扩容,先创建了一个长度为8的新数组,现在要进行transfer,那么这个A要放到哪里呢?
我们先来根据他原本所在的位置2来倒推,我们知道index = HashCode(Key) & (Length - 1)
,那么就有:
Hash(key) 可能为010,可能为110。
我们用新的长度(8 = (111)2)和这两个数分别再去通过Hash算法来计算新的下标会发现
在转移链表时,结点的转移和插入是一致的,jdk1.7将采用头插法(转移完后链表反转),jdk1.8在分解完链表后直接移动头结点
主要体现在:
首先要知道,HashMap中有个属性modCount,用于记录当前map的修改次数,在对map进行put、remove、clear等操作时都会增加modCount。
他的作用体现在对map进行遍历的时候,我们知道HashMap不是线程安全的,当对其进行遍历的时候,会先把modCount赋给迭代器内部的expectedModCount属性。当我们对map进行迭代时,他会时时刻刻比较expectedModCount和modCount是否相等,如果不相等,则说明有其他的线程对同一map进行了修改操作,于是迭代器抛出ConcurrentModificationException异常,告诉程序员有人修改过了。。
这也就是Fail-Fast机制。
在系统设计中,快速失效系统一种可以立即报告任何可能表明故障的情况的系统。快速失效系统通常设计用于停止正常操作,而不是试图继续可能存在缺陷的过程。这种设计通常会在操作中的多个点检查系统的状态,因此可以及早检测到任何故障。快速失败模块的职责是检测错误,然后让系统的下一个最高级别处理错误。
例子见上一条。
场景:java.util 包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)。
使用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,再拷贝的集合上进行遍历。由于迭代时候是对原集合的拷贝进行遍历,所以在遍历的过程中对原集合所作的修改不能被迭代器所检测到,不会触发Concurrent Modification Exception。
缺点:基于拷贝内容的优点是避免了 Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。
场景:java.util.concurrent 包下的容器都是安全失败,可以在多线程下并发使用,并发修改。
将查找的时间复杂度从o(n)提升到o(logn)。在二叉查找树的基础上增加了着色和相关的性质使得红黑树相对平衡,从而保证了红黑树的查找、插入、删除的时间复杂度最坏为O(log n),加快检索速率。
概率统计。源码注释里也写了,如果 hashCode的分布离散良好的话,那么红黑树是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,注释中给我们展示了1-8长度的具体命中概率,当长度为8的时候,概率概率仅为0.00000006,趋近于零,因此这是根据概率统计决定的。
下面纯属个人理解。。。
当链表长度达到阈值8的时候会转为红黑树,但是红黑树退化为链表的阈值却是6,为什么不是小于8就退化呢?比如说7的时候就退化,偏偏要小于或等于6?
主要是一个过渡,避免链表和红黑树之间频繁的转换。如果阈值是7的话,删除一个元素红黑树就必须退化为链表,增加一个元素就必须树化,来回不断的转换结构无疑会降低性能,所以阈值才不设置的那么临界。
(其实感觉设计成5也可以,感觉更多就算这样设计的。。。刁难人 )
HashMap 遍历从大的方向来说,可分为以下 4 类:
每种类型又有不同实现方式,具体的遍历方式可分为7种:
性能分析:EntrySet 之所以比 KeySet 的性能高是因为,KeySet 在循环时使用了 map.get(key),而 map.get(key) 相当于又遍历了一遍 Map 集合去查询 key 所对应的值。注意在使用迭代器或者 for 循环时,其实已经遍历了一遍 Map 集合了,因此再使用 map.get(key) 查询时,相当于遍历了两遍。
而 EntrySet 只遍历了一遍 Map 集合,之后通过代码“Entry
所以,EntrySet 的性能比 KeySet 的性能高出了一倍,因为 KeySet 相当于循环了两遍 Map 集合,而 EntrySet 只循环了一遍。
安全性:我们不能在遍历中使用集合 map.remove() 来删除数据,这是非安全的操作方式,但我们可以使用迭代器的 iterator.remove() 的方法来删除数据,这是安全的删除集合的方式。同样的我们也可以使用 Lambda 中的 removeIf 来提前删除数据,或者是使用 Stream 中的 filter 过滤掉要删除的数据进行循环,这样都是安全的,当然我们也可以在 for 循环前删除数据在遍历也是线程安全的。
总结: hashMap作为一个key-value存储的数据结构,我们知道可以使用key去获取到value,所以map提供了两种方式:1. 获取key,map.keySet();可以获取到对应的key的集合,我们遍历key就获取value;2. 获取entrySet,entry是包含key、value,这样也就获取到了对应的key与value。对于这两种我们可以用迭代器进行遍历或者用For Each进行遍历。在JDK1.8+,我们还可以用Lambda语句遍历和Streams API进行遍历。
综合性能和安全性来看,我们应该尽量使用迭代器(Iterator)来遍历 EntrySet 的遍历方式来操作 Map 集合,这样就会既安全又高效了。
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
ConcurrentHashMap 在不同的 JDK 版本中实现是不同的,在 JDK 1.7 中它使用的是数组加链表的形式实现的,而数组又分为:大数组 Segment 和小数组 HashEntry。大数组 Segment 可以理解为 MySQL 中的数据库,而每个数据库(Segment)中又有很多张表 HashEntry,每个 HashEntry 中又有多条数据,这些数据是用链表连接的,如下图所示:
Segment 本身是基于 ReentrantLock 实现的加锁和释放锁的操作,这样就能保证多个线程同时访问 ConcurrentHashMap 时,同一时间只有一个线程能操作相应的节点,这样就保证了 ConcurrentHashMap 的线程安全了。
也就是说 ConcurrentHashMap 的线程安全是建立在 Segment 加锁的基础上的,所以我们把它称之为分段锁或片段锁,如下图所示:
在 JDK 1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组 + 链表的形式,所以在数据比较多的情况下访问是很慢的,因为要遍历整个链表,而 JDK 1.8 则使用了数组 + 链表/红黑树的方式优化了 ConcurrentHashMap 的实现,具体实现结构如下:
在 JDK 1.8 中 ConcurrentHashMap 使用的是 CAS + volatile 或 synchronized 的方式来保证线程安全的。
在 JDK 1.8 中,添加元素时首先会判断容器是否为空,如果为空则使用 volatile 加 CAS 来初始化。如果容器不为空则根据存储的元素计算该位置是否为空,如果为空则利用 CAS 设置该节点;如果不为空则使用 synchronize 加锁,遍历桶中的数据,替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。
我们把上述流程简化一下,我们可以简单的认为在 JDK 1.8 中,ConcurrentHashMap 是在头节点加锁来保证线程安全的,锁的粒度相比 Segment 来说更小了,发生冲突和加锁的频率降低了,并发操作的性能就提高了。而且 JDK 1.8 使用的是红黑树优化了之前的固定链表,那么当数据量比较大的时候,查询性能也得到了很大的提升,从之前的 O(n) 优化到了 O(logn) 的时间复杂度,具体加锁示意图如下:
TreeNode是存储红黑树节点,被TreeBin包装。TreeBin通过root属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 ConcurrentHashMap 中TreeBin通过waiter属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState;
// values for lockState
//信号量
static final int WRITER = 1; // set while holding write lock
static final int WAITER = 2; // set when waiting for write lock
static final int READER = 4; // increment value for setting read lock
...
}