Java中的容器集合分为两大阵营,一个是Collection
,一个是Map
fail-fast
是一种错误检测机制,Java在适合单线程使用的集合容器中很好地实现了fail-fast机制,举一个简单的例子:在多线程并发环境下,A线程在通过迭代器遍历一个ArrayList集合,B线程同时对该集合进行增删元素操作,这个时候线程A就会抛出并发修改异常,中断正常执行的逻辑。
而fail-safe
机制更像是一种对fail-fast机制的补充,它被广泛地实现在各种并发容器集合中。回头看上面的例子,如果线程A遍历的不是一个ArrayList,而是一个CopyOnWriteArrayList,则符合fail-safe机制,线程B可以同时对该集合的元素进行增删操作,线程A不会抛出任何异常。
要理解这两种机制的表象,我们得了解这两种机制背后的实现原理:
我们同样用ArrayList解释fail-fast背后的原理:首先ArrayList自身会维护一个modCount变量,每当进行增删元素等操作时,modCount变量都会进行自增。当使用迭代器遍历ArrayList时,迭代器会新维护一个初始值等于modCount的expectedModCount变量,每次获取下一个元素的时候都会去检查expectModCount和modCount是否相等。在上面举的例子中,由于B线程增删元素会导致modCount自增,当A线程遍历元素时就会发现两个变量不等,从而抛出异常。
CopyOnWriteArrayList所实现的fail-safe在上述情况下没有抛出异常,它的原理是:当使用迭代器遍历集合时,会基于原数组拷贝出一个新的数组(ArrayList的底层是数组),后续的遍历行为在新数组上进行。所以线程B同时进行增删操作不会影响到线程A的遍历行为。
使用集合迭代器自身的remove方法进行删除
Iterator<Integer> it = list.iterator();
while(it.hasNext())
{
// do something* it.remove();
}
本质的区别来源于两者的底层实现:
数组拥有O(1)的查询效率,可以通过下标直接定位元素;链表在查询元素的时候只能通过遍历的方式查询,效率比数组低。
数组增删元素的效率比较低,通常要伴随拷贝数组的操作;链表增删元素的效率很高,只需要调整对应位置的指针即可。
以上是数组和链表的通俗对比,在日常的使用中,两者都能很好地在自己的适用场景发挥作用。
比如说我们常常用ArrayList代替数组,因为封装了许多易用的api,而且它内部实现了自动扩容机制,由于它内部维护了一个当前容量的指针size,直接往ArrayList中添加元素的时间复杂度是O(1)的,使用非常方便。
而LinkedList常常被用作Queue队列的实现类,由于底层是双向链表,能够轻松地提供先入先出的操作。
两者的底层实现相似,关键的不同在于Vector的对外提供操作的方法都是用synchronized
修饰的,也就是说Vector在并发环境下是线程安全的,而ArrayList在并发环境下可能会出现线程安全问题。
由于Vector的方法都是同步方法,执行起来会在同步上消耗一定的性能,所以在单线程环境下,Vector的性能是不如ArrayList的
除了线程安全这点本质区别外,还有一个实现上的小细节区别:
由于ArrayList有自动扩容机制,所以ArrayList的elementData数组大小往往比现有的元素数量大,如果不加transient直接序列化的话会把数组中空余的位置也序列化了,浪费不少的空间。
ArrayList中重写了序列化和反序列化对应的writeObject和readObject方法,在遍历数组元素时,以size作为结束标志,只序列化ArrayList中已经存在的元素。
首先计算key的hash值,计算过程是:先得到key的hashCode(int类型,4字节),然后把hashCode的高16位与低16位进行异或,得到key的hash值。
接下来用key的hash值与数组长度减一的值进行按位与操作,得到key在数组中对应的下标。
计算key在数组中的下标时,是通过hash值与数组长度减一的值进行按位与操作的。由于数组的长度通常不会超过2^16,所以hash值的高16位通常参与不了这个按位与操作。
为了让hashCode的高16位能够参与到按位与操作中,所以把hashCode的高16位与低16位进行异或操作,使得高16位的影响能够均匀稀释到低16位中,使得计算key位置的操作能够充分散列均匀。
在极端情况下,比如说key的hashCode()返回的值不合理,或者多个密钥共享一个hashCode,很有可能会在同一个数组位置产生严重的哈希冲突。
这种情况下,如果我们仍然使用使用链表把多个冲突的元素串起来,这些元素的查询效率就会从O(1)下降为O(N)。为了能够在这种极端情况下仍保证较为高效的查询效率,HashMap选择把链表转换为红黑树,红黑树是一种常用的平衡二叉搜索树,添加,删除,查找元素等操作的时间复杂度均为O(logN)
至于阈值为什么是8,这是HashMap的作者根据概率论的知识得到的。当key的哈希码分布均匀时,数组同一个位置上的元素数量是成泊松分布的,同一个位置上出现8个元素的概率已经接近千分之一了,这侧面说明如果链表的长度达到了8,key的hashCode()肯定是出了大问题,这个时候需要红黑树来保证性能,所以选择8作为阈值。
如果是7的话,那么链表和红黑树之间的切换范围值就太小了。如果我的链表长度不停地在7和8之间切换,那岂不是得来回变换形态?所以选择6是一种折中的考虑。
在JDK1.7中,迁移数据的时候所有元素都重新计算了hash,并根据新的hash重新计算数组中的位置。
在JDK1.8中,这个过程进行了优化:如果当前节点是单独节点(后面没有接着链表),则根据该节点的hash值与新容量减一的值按位与得到新的地址。
如果当前节点后面带有链表,则根据每个节点的hash值与旧数组容量进行按位与的结果进行划分。如果得到的值为0,这些元素会被分配回原来的位置;如果得到的结果不为0,则分配到新位置,新位置的下标为当前位置下标加上旧数组容量。
还有一种情况是当前节点是树节点,那么会调用一个专门的拆分方法进行拆分。
如果要支持动态缩容,可能就要把缩容安排在remove方法里,这样可能会导致remove方法的时间复杂度从O(1)上升为O(N)。
还有一点可能和我们编写Java代码的习惯有关:由于Java有自动垃圾回收机制,让我们得以可劲地new对象,Java也默认了我们这种吃饭不收拾盘子的行为。既然对象会被回收,HashMap动态缩容在这样的大环境下似乎就显得没那么重要了,这可以说是一种空间换时间的策略吧。
因为这些基础类内部已经重写了hashCode和equals方法,遵守了HashMap内部的规范。
一定要重写hashCode()和equals()方法,而且要遵从以下规则:
equals()是我们判断两个对象是否相同的依据,如果我们重写了equals方法,用自己的逻辑去判断两个对象是否相同,那么一定要保证:
两个equals()返回true的对象,一定要返回相同的hashCode。
这样,在HashMap的put方法中才能正确判断key是否相同。
因为这样能够提高根据key计算数组位置的效率。
HashMap根据key计算数组位置的算法是:用key的hash值与数组长度减1的值进行按位与操作。
在我们正常人的思维中,获取数组的某个位置最直接的方法是对数组的长度取余数。但是如果被除数是2的幂次方,那么这个对数组长度取余的方法就等价于对数组长度减一的值进行按位与操作。
在计算机中,位运算的效率远高于取模运算,所以为了提高效率,把数组的长度设为2的幂次方。
在JDK1.7之前,两者的实现极为相似,最大的区别在于HashTable的方法都用synchronized关键字修饰起来了,表明它是线程安全的。
但是由于直接在方法上加synchronized关键字的同步效率较低,在并发情况下,官方推荐我们使用ConcurrentHashMap。
所以我们看到在JDK1.8中,官方甚至没有对HashTable进行链表转树这样的优化,HashTable已经不被推荐使用了。
在JDK1.7中ConcurrentHashMap
采用了一种分段锁的机制,它的底层实现是一个segment数组,每个segment的底层结构和HashMap相似,也是数组加链表。
当对segment里面的元素进行操作之前,需要获得该segment独有的一把ReentrantLock
。ConcurrentHashMap如果不进行手动设置的话,默认有16个segment
,可以支持16个线程对16个不同的segment进行并发写操作。
在JDK1.8之后摒弃了segment这种臃肿的设计,新的实现和HashMap非常相似,底层用的也是数组加链表加红黑树。
在新实现中,在put方法里使用了CAS
+ synchronized
进行同步。
如果插入元素的位置为空,则使用CAS进行插入。如果插入的位置不为空,则对当前位置的对象进行加锁,也就链表或红黑树的头节点,加锁后再进行后续的插入操作。
这样设计的好处是: