集合-容器
Collection单列集合
Set
- SortedSet
TreeSet
使用二叉树的原理对新 add()的对象按照指定的顺序排序(升序、降序),每增加一个对象都会进行排序,将对象插入的二叉树指定的位置。
Integer 和 String 对象都可以进行默认的 TreeSet 排序,而自定义类的对象是不可以的, 自己定义的类必须实现 Comparable接口,并且覆写相应的 compareTo()函数,可以正常使用。
在覆写 compare()函数时,要返回相应的值才能使 TreeSet 按照一定的规则来排序。比较此对象与指定对象的顺序。如果该对象小于、等于或大于指定对象,则分别返回负整数、零或正整数。
HashSet
-
应用场景:去重
-
与HashMap的关系:
HashSet底层封装的是HashMap,HashMap的key是不能重复的,而这里HashSet的元素又是作为了map的key,当然也不能重复了。所有元素添加会放到HashMap的key中,value值使用new Object对象作为value;
若要将对象存放到HashSet中并保证对象不重复,应根据实际情况将对象的hashCode方法和equals方法进行重写。
-
-
特点
- 数据不能重复;
- 可以存储null值,数据不能保证插入有序;
LinkedHashSet
- 继承了HashSet ,底层实现是LinkedHashMap
- 应用场景:实现了对数据进行去重,并且对集合类数据实现访问有序 ;
List
ArrayList
ArrayList的默认长度是多少?
它的默认初始化长度为10;同时它也是支持动态扩容的,通过这个方法进行动态扩容新增加的容量大小为原容量大小的50%。 底层调用的就是Arrays.copyOf(elementData, newCapacity)。
LinkedList
LinkedList:可知该链表是双向链表,即可以从头遍历到尾,也可以从尾遍历到头。同样它也是线程不安全的,在这里最可能的造成的并发原因就是链表成环。
Vector
Queue
Map双列集合
Map中的集合核心特性
自动扩容
最小可用原则,容量超过一定阈值便自动进行扩容。
-
扩容是通过resize方法来实现的。扩容发生在putVal方法的最后,即写入元素之后才会判断是否需要扩容操作,当自增后的size大于之前所计算好的阈值threshold,即执行resize操作。
-
通过位运算<<1进行容量扩充,即扩容1倍,同时新的阈值newThr也扩容为老阈值的1倍
扩容时,总共存在三个问题:
-
哈希桶数组中某个位置只有1个元素,即不存在哈希冲突时,则直接将该元素copy至新哈希桶数组的对应位置即可。
-
哈希桶数组中某个位置的节点为树节点时,则执行红黑树的扩容操作。
-
哈希桶数组中某个位置的节点为普通节点时,则执行链表扩容操作,在JDK1.8中,为了避免之前版本中并发扩容所导致的死链问题,引入了高低位链表辅助进行扩容操作。
针对扩容时出现的问题的解答:
-
为什么JDK1.7扩容时会产生并发死锁问题,也就是我们常问的为什么hashmap是线程不安全的?
主要原因是:并发,即多线程同时访问HashMap
hashmap是线程不安全造成的影响主要有两个方面:1 、高并发下,hashmap会出现扩容的死锁问题;2、数据会丢失,会造成数据的脏读
怎么产生的环形链表死循环问题?
JDK1.7 使用的是数组+单链表的数据结构会先进行扩容再插入,执行的是头插法,先将原位置的数据移到后一位,再插入数据到该位置;会出现逆序和环形链表死循环问题
JDK1.8 使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率),会先进行插入再进行扩容,执行的是尾插法,直接插到链表尾部/红黑数树,不会出现逆序和环形链表死循环问题
线程不安全是因为:数组确定了就不会修改,想扩容则申请新的数组,把老数据进行迁移,涉及的源码:Entry[] newTable = new Entry[newCapacity]; 当迁移时,单线程迁移没有问题,当有多个线程需要扩容时,都申请了数组,则可能数据迁移不成功,造成JVM内存溢出,并造成大量GC,则当线程迁移时,造成死锁。
在扩容时,在哈希冲突的时候,产生的链表形成环,当有key在成环链表中时,则成死循环。
-
JDK1.7单线程下的扩容
-
JDK1.7多线程下的扩容:
在JDK1.8及以后,虽然利用尾插法解决了死锁问题,但是还是会在并发执行put操作时发生数据覆盖的情况。
JDK1.8 使用ConcurrentHashMap解决死锁的问题,ConcurrentHashMap是线程安全的对死锁问题进行了改进,采用四组指针,分为高位指针和低位指针,hashcode & 数组容量长度,分成两部分迁移,避免头插链表成环。
ConcurrentHashMap 的线程安全机制:CAS + 锁
ConcurrentHashMap保证线程安全的原理思路:
-
T1线程加元素A,CAS判断节点是否为空,为空则用CAS把节点放入
-
T1线程再加元素B,CAS再判断节点是否为空,不为空则加锁后放入,T1线程都可访问
-
T2线程,get(key)时,当没有锁,则直接拿
扩容原理:ConcurrentHashMap中的put()方法比hashmap()对一个for循环的原因,多个线程需插入值,但CAS算法只能有一个成功,ConcurrentHashMap中加入for循环保证不成功的线程继续放入值,而不失效**(自旋)**
初始化与懒加载
初始化的时候只会设置默认的负载因子,并不会进行其他初始化的操作,在首次使用的时候才会进行初始化。
当new一个新的HashMap的时候,不会立即对哈希数组进行初始化,而是在首次put元素的时候,通过resize()方法进行初始化。
resize()中会设置默认的初始化容量DEFAULT_INITIAL_CAPACITY为16,扩容的阈值为0.75*16 = 12,即哈希桶数组中元素达到12个便进行扩容操作。
最后创建容量为16的Node数组,并赋值给成员变量哈希桶table,即完成了HashMap的初始化操作。
哈希计算
哈希表以哈希命名,足以说明哈希计算在该数据结构中的重要程度。而在实现中,JDK并没有直接使用Object的native方法返回的hashCode作为最终的哈希值,而是进行了二次加工。
以下分别为HashMap与ConcurrentHashMap计算hash值的方法,核心的计算逻辑相同,都是使用key对应的hashCode与其hashCode右移16位的结果进行异或操作。此处,将高16位与低16位进行异或的操作称之为扰动函数,目的是将高位的特征融入到低位之中,降低哈希冲突的概率。
举个例子来理解下扰动函数的作用:
hashCode(key1) = 0000 0000 0000 1111 0000 0000 0000 0010
hashCode(key2) = 0000 0000 0000 0000 0000 0000 0000 0010
若HashMap容量为4,在不使用扰动函数的情况下,key1与key2的hashCode注定会冲突(后两位相同,均为10)。
经过扰动函数处理后,可见key1与key2 hashcode的后两位不同,上述的哈希冲突也就避免了。
hashCode(key1) ^ (hashCode(key1) >>> 16)
0000 0000 0000 1111 0000 0000 0000 1101
hashCode(key2) ^ (hashCode(key2) >>> 16)
0000 0000 0000 0000 0000 0000 0000 0010
这种增益会随着HashMap容量的减少而增加。
此外,ConcurrentHashMap中经过扰乱函数处理之后,需要与HASH_BITS做与运算,HASH_BITS为0x7ffffff,即只有最高位为0,这样运算的结果使hashCode永远为正数。在ConcurrentHashMap中,预定义了几个特殊节点的hashCode,如:MOVED、TREEBIN、RESERVED,它们的hashCode均定义为负值。因此,将普通节点的hashCode限定为正数,也就是为了防止与这些特殊节点的hashCode产生冲突。
-
哈希冲突
通过哈希运算,可以将不同的输入值映射到指定的区间范围内,随之而来的是哈希冲突问题。考虑一个极端的case,假设所有的输入元素经过哈希运算之后,都映射到同一个哈希桶中,那么查询的复杂度将不再是O(1),而是O(n),相当于线性表的顺序遍历。因此,哈希冲突是影响哈希计算性能的重要因素之一。**哈希冲突如何解决呢?主要从两个方面考虑,一方面是避免冲突,另一方面是在冲突时合理地解决冲突,尽可能提高查询效率。**前者在上面的章节中已经进行介绍,即通过扰动函数来增加hashCode的随机性,避免冲突。针对后者,HashMap中给出了两种方案:拉链表与红黑树。
拉链法
在JDK1.8之前,HashMap中是采用拉链表的方法来解决冲突,即当计算出的hashCode对应的桶上已经存在元素,但两者key不同时,会基于桶中已存在的元素拉出一条链表,将新元素链到已存在元素的前面。当查询存在冲突的哈希桶时,会顺序遍历冲突链上的元素。同一key的判断逻辑如下图所示,先判断hash值是否相同,再比较key的地址或值是否相同。
(1)死链
在JDK1.8之前,HashMap在并发场景下扩容时存在一个bug,形成死链,导致get该位置元素的时候,会死循环,使CPU利用率高居不下。这也说明了HashMap不适于用在高并发的场景,高并发应该优先考虑JUC中的ConcurrentHashMap。然而,精益求精的JDK开发者们并没有选择绕过问题,而是选择直面问题并解决它。在JDK1.8之中,引入了高低位链表(双端链表)。
什么是高低位链表呢?——ConcurrentHashMap对死锁的改进问题
在扩容时,哈希桶数组buckets会扩容一倍,以容量为8的HashMap为例,原有容量8扩容至16,将[0, 7]称为低位,[8, 15]称为高位,低位对应loHead、loTail,高位对应hiHead、hiTail。
扩容时会依次遍历旧buckets数组的每一个位置上面的元素:
-
若不存在冲突,则重新进行hash取模,并copy到新buckets数组中的对应位置。
-
若存在冲突元素,则采用高低位链表进行处理。通过e.hash & oldCap来判断取模后是落在高位还是低位。
举个例子:
假设当前元素hashCode为0001(忽略高位),其运算结果等于0,说明扩容后结果不变,取模后还是落在低位[0, 7],即0001 & 1000 = 0000,还是原位置,再用低位链表将这类的元素链接起来。假设当前元素的hashCode为1001, 其运算结果不为0,即1001 & 1000 = 1000 ,扩容后会落在高位,新的位置刚好是旧数组索引(1) + 旧数据长度(8) = 9,再用高位链表将这些元素链接起来。
最后,将高低位链表的头节点分别放在扩容后数组newTab的指定位置上,即完成了扩容操作。这种实现降低了对共享资源newTab的访问频次,先组织冲突节点,最后再放入newTab的指定位置。避免了JDK1.8之前每遍历一个元素就放入newTab中,从而导致并发扩容下的死链问题。
红黑树
**在JDK1.8之中,HashMap引入了红黑树来处理哈希冲突问题,而不再是拉链表。**那么为什么要引入红黑树来替代链表呢?虽然链表的插入性能是O(1),但查询性能确是O(n),当哈希冲突元素非常多时,这种查询性能是难以接受的。因此,在JDK1.8中,如果冲突链上的元素数量大于8,并且哈希桶数组的长度大于64时,会使用红黑树代替链表来解决哈希冲突,此时的节点会被封装成TreeNode而不再是Node(TreeNode其实继承了Node,以利用多态特性),使查询具备O(logn)的性能。
为什么选择红黑树?
红黑树,它是一种平衡的二叉树搜索树,类似地还有AVL树。两者核心的区别是AVL树追求“绝对平衡”,在插入、删除节点时,成本要高于红黑树,但也因此拥有了更好的查询性能,适用于读多写少的场景。然而,对于HashMap而言,读写操作其实难分伯仲,因此选择红黑树也算是在读写性能上的一种折中。
位运算
-
确定哈希桶数组大小
找到大于等于给定值的最小2的整数次幂。否则会强转成2的整数次幂。
tableSizeFor根据输入容量大小cap来计算最终哈希桶数组的容量大小,找到大于等于给定值cap的最小2的整数次幂。
并发
1. 悲观锁
全表锁
HashTable中采用了全表锁,即所有操作均上锁,串行执行,如下图中的put方法所示,采用synchronized关键字修饰。这样虽然保证了线程安全,但是在多核处理器时代也极大地影响了计算性能,这也致使HashTable逐渐不被使用。
分段锁
针对HashTable中锁粒度过粗的问题,在JDK1.8之前,ConcurrentHashMap引入了分段锁机制。
整体的存储结构如下图所示,在原有结构的基础上拆分出多个segment,每个segment下再挂载原来的entry(上文中经常提到的哈希桶数组),每次操作只需要锁定元素所在的segment,不需要锁定整个表。因此,锁定的范围更小,并发度也会得到提升。
2. 乐观锁
Synchronized+CAS
**虽然引入了分段锁的机制,即可以保证线程安全,又可以解决锁粒度过粗导致的性能低下问题,**但是对于追求极致性能的工程师来说,这还不是性能的天花板。因此,在JDK1.8中,ConcurrentHashMap摒弃了分段锁,使用了乐观锁的实现方式。
放弃分段锁的原因:
-
使用segment之后,会增加ConcurrentHashMap的存储空间。
-
当单个segment过大时,并发性能会急剧下降。
JDK 1.8 中,废弃了之前的segment结构,沿用了与HashMap中的类似的Node数组结构。
ConcurrentHashMap中的乐观锁是采用synchronized+CAS进行实现的
举例:
put()方法:
(1) 当put的元素在哈希桶数组中不存在时,则直接CAS进行写操作。
这里涉及到了两个重要的操作,tabAt与casTabAt。可以看出,这里面都使用了Unsafe类的方法。Unsafe这个类在日常的开发过程中比较罕见。我们通常对Java语言的认知是:Java语言是安全的,所有操作都基于JVM,在安全可控的范围内进行。然而,Unsafe这个类会打破这个边界,使Java拥有C的能力,可以操作任意内存地址,是一把双刃剑。这里使用到了前文中所提到的ASHIFT,来计算出指定元素的起始内存地址,再通过getObjectVolatile与compareAndSwapObject分别进行取值与CAS操作。
在获取哈希桶数组中指定位置的元素时为什么不能直接get而是要使用getObjectVolatile呢?
因为在JVM的内存模型中,每个线程有自己的工作内存,也就是栈中的局部变量表,它是主存的一份copy。因此,线程1对某个共享资源进行了更新操作,并写入到主存,而线程2的工作内存之中可能还是旧值,脏数据便产生了。Java中的volatile是用来解决上述问题,保证可见性,任意线程对volatile关键字修饰的变量进行更新时,会使其它线程中该变量的副本失效,需要从主存中获取最新值。虽然ConcurrentHashMap中的Node数组是由volatile修饰的,可以保证可见性,但是Node数组中元素是不具备可见性的。因此,在获取数据时通过Unsafe的方法直接到主存中拿,保证获取的数据是最新的。
-
以上问题涉及,ConcurrentHashMap是如何保证读到的数据不是脏数据的呢?——volatile
对于可见性,Java提供了volatile关键字来保证可见性、有序性。但不保证原子性。
普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
volatile关键字对于基本类型的修改可以在随后对多个线程的读保持一致,但是对于引用类型如数组,实体bean,仅仅保证引用的可见性,但并不保证引用内容的可见性。禁止进行指令重排序。
总结下来:
第一:使用volatile关键字会强制将修改的值立即写入主存。
第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
第三:由于线程1的工作内存中缓存变量的缓存行无效,所以线程1再次读取变量的值时会去主存读取。
(2)当put的元素在哈希桶数组中存在,并且不处于扩容状态时,则使用synchronized锁定哈希桶数组中第i个位置中的第一个元素f(头节点2),接着进行double check,类似于DCL单例模式的思想
double check 是为了防止当前线程获得锁之后,进行扩容操作,元素的位置发生了改变
校验通过后,会遍历当前冲突链上的元素,并选择合适的位置进行put操作。此外,ConcurrentHashMap也沿用了HashMap中解决哈希冲突的方案,链表+红黑树。这里只有在发生哈希冲突的情况下才使用synchronized锁定头节点,其实是比分段锁更细粒度的锁实现,只在特定场景下锁定其中一个哈希桶,降低锁的影响范围。
Java Map针对并发场景解决方案的演进方向可以归结为,从悲观锁到乐观锁,从粗粒度锁到细粒度锁
3. 并发求和
CounterCell是JDK1.8中引入用来并发求和的利器,而在这之前采用的是**【尝试无锁求和】+【冲突时加锁重试】**的策略
SortedMap
TreeMap
TreeMap 实现 SortedMap 接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用 Iterator 遍历 TreeMap 时,得到的记录是排过序的。
如果使用排序的映射,建议使用 TreeMap。在使用 TreeMap 时, key 必须实现 Comparable 接口或者在构造TreeMap 传入自定义的Comparator,否则会在运行时抛出 java.lang.ClassCastException 类型的异常。
-
TreeMap和HashMap的区别
-
HashMap是通过hashcode()对其内容进行快速查找的;HashMap中的元素是没有顺序的;TreeMap中所有的元素都是有某一固定顺序的,如果需要得到一个有序的结果,就应该使用TreeMap;
-
HashMap和TreeMap都不是线程安全的;
-
HashMap继承AbstractMap类;覆盖了hashcode() 和equals() 方法,以确保两个相等的映射返回相同的哈希值;TreeMap继承SortedMap类;他保持键的有序顺序;
-
HashMap:基于hash表实现的;使用HashMap要求添加的键类明确定义了hashcode() 和equals() (可以重写该方法);为了优化HashMap的空间使用,可以调优初始容量和负载因子;
TreeMap:基于红黑树实现的;TreeMap就没有调优选项,因为红黑树总是处于平衡的状态;
- HashMap:适用于Map插入,删除,定位元素;
TreeMap:适用于按自然顺序或自定义顺序遍历键(key);
-
-
两种排序方式
-
自然排序
TreeMap的所有key必须实现Comparable接口,而且所有的key应该是同一个类的对象,否则会抛出ClassCastException;
-
定制排序
创建TreeMap时,传入一个Comparator对象,该对象负责对TreeMap中的所有key进行排序;
-
HashTable
Hashtable是遗留类,很多映射的常用功能与 HashMap 类似,不同的是它承自 Dictionary 类,并且是线程安全的,任一时间只有一个线程能写Hashtable,并发性不如 ConcurrentHashMap,因为 ConcurrentHashMap 引入了分段锁。 Hashtable 不建议在新代码中使用,不需要线程安全的场合可以用 HashMap 替换,需要线程安全的场合可以用 ConcurrentHashMap 替换。
LinkedHashMap
-
底层数据结构
-
数组加链表用来存储数据; header双向链表用来实现数据插入有序或者访问有序;
-
继承了HashMap
- 默认数组大小:16 ==>继承父类
loadFactor(默认加载因子):0.75 ==>继承父类 - 增长方式:继承父类,2*table.length;
- 默认数组大小:16 ==>继承父类
-
特有属性
-
header如何初始化
- header初始化需要调用重写后的 init()方法,创建一个不存储数据的entry实体,而init方法是在父类的构造函数中被调用,子类的初始化都会调用父类的构造函数,从而实现了header的初始化;
-
-
HashMap与LinkedHashMap的不同的点
-
LinkedHashMap可以保证插入有序或者访问有序
-
内部类Entry多了before / after
-
实现两种数据结构
- HashMap只实现数组+链表的数据结构,
- LinkedHashMap实现数组加链表和双向链表环的数据结构
-
LinkedHashMap继承自HashMap。两者数组加链表得数据结构,功能差不多。但是在rehash时,LinkedHashMap直接使用链表环进行hash。这样可以保证链表环相对不变。
-
-
-
应用:LRU缓存算法,其核心数据结构就是哈希链表,它是双向链表和哈希表的结合体。
【重点】HashMap
-
特点
- key,value都可以为null,key值不能重复;
- 不能保证插入有序;
- 通过key进行hash;
- 非线程安全;
-
底层数据结构
-
HashMap底层的实现是数组+链表实现:数组中存储的是一个个entry实体,hash到同一个索引位置的数据通过链表链接起来;
-
Hash表 = 数组 + 线性链表 + >7 红黑树
-
数组的默认长度为16,static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
-
扩容因子为:0.75,使用了75%则扩容,扩为 2 * length
-
其中数组的容量为2的指数次幂,不是的话会强转为2的指数次幂
-
- HashMap的原理介绍:
基于hashing原理,put() 和 get()方法存储和获取对象。当我们将简直对传递给put()方法阿是,调用键对象的hashCode()方法来计算hashcode,然后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。
当两个不同的键对象的hashcode相同时会发生什么?
会发生哈希冲突,HashMap使用数组 + 链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。HashMap在每个链表节点中储存键值对对象,它们会储存在同一个bucket位置的链表中,键对象的equals()方法用来找到键值对。
JDK1.8中采用的是数组+链表+红黑树来解决哈希冲突。
- HashMap中的扩容机制:
装载因子:0-1之间的系数,根据它来确定需要扩容的阈值,默认值是0.75。当数据的大于整个数组的0.75倍的时候整个数组扩大为原来的2倍,再将原来的数据复制到新的数据中。
为什么扩容因子是0.75?
当负载因子是1.0的时候,也就意味着,只有当数组的8个值(这个图表示了8个)全部填充了,才会发生扩容。这就带来了很大的问题,因为Hash冲突时避免不了的。当负载因子是1.0的时候,意味着会出现大量的Hash的冲突,底层的红黑树变得异常复杂。对于查询效率极其不利。这种情况就是牺牲了时间来保证空间的利用率。
扩容后数组中元素的位置会不会发生变化?发生变化是怎么变化的?
JDK7中,HashMap的内部数组保存的都是链表。在准备好新的数组后,map会遍历数组的每个“桶”,然后遍历桶中的每个Entity,重新计算其hash值(也有可能不计算),找到新数组中的对应位置,以头插法插入新的链表。
JDK8中,性能提升了,由于数组的容量是以2的幂次方库扩容的,找新数组中的对应位置采用的是尾插法,那么一个Entity在扩容时,新的位置要么在原位置,要么在原长度+原位置。
为什么不采用AVL树二采用的是的是红黑树?
红黑树,它是一种平衡的二叉树搜索树,类似地还有AVL树。两者核心的区别是AVL树追求“绝对平衡”,在插入、删除节点时,成本要高于红黑树,但也因此拥有了更好的查询性能,适用于读多写少的场景。然而,对于HashMap而言,读写操作其实难分伯仲,因此选择红黑树也算是在读写性能上的一种折中。
-
HashMap多线程的安全问题:
-
数据丢失
HashMap一个并发可能出现的问题是,可能产生元素丢失的现象。考虑在多线程下put操作时,执行addEntry(hash, key, value, i),如果有产生哈希碰撞,导致两个线程得到同样的bucketIndex去存储,就可能会出现覆盖丢失的情况:
-
put非null元素后get出来的却是null
-
高并发时单链表闭环而产生死锁问题
JDk1.7中使用头插法新的hash桶会倒置原hash桶中的单链表,插入在多个线程同时扩容的情况下就可能导致产生一个存在闭环的单链表,从而导致死循环。在JDK1.8中使用尾插法,并且使用了红黑树,避免了链表成环。
-
-
HashMap中初始化大小为什么是16? 为什么链表的长度为8是变成红黑树?为什么为6时又变成链表?
hashMap的源码可知当新put一个数据时会进行计算位于table数组(也称为桶)中的下标: int index =key.hashCode()&(length-1); hashmap每次扩容都是以 2的整数次幂进行扩容。因为是将二进制进行按位与,,(16-1) 是 1111,末位是1,这样也能保证计算后的index既可以是奇数也可以是偶数,并且只要传进来的key足够分散,均匀那么按位与的时候获得的index就会减少重复,这样也就减少了hash的碰撞以及hashMap的查询效率。
16可以,是不是只要是2的整数次幂就可以呢?为什么不是8,4?
因为是8或者4的话很容易导致map扩容影响性能,如果分配的太大的话又会浪费资源,所以就使用16作为初始大小。
**总结: **
1 减少hash碰撞
2 提高map查询效率
3 分配过小防止频繁扩容
4 分配过大浪费资源
为什么链表的长度为8是变成红黑树?为什么为6时又变成链表?
将就是put进去的key进行计算hashCode时 只要选择计算hash值的算法足够好(hash碰撞率极低),从而遵循泊松分布,使得桶中挂载的bin的数量等于8的概率非常小,从而转换为红黑树的概率也小,反之则概率大。
8是bin(bin就是bucket,即HashMap中hashCode值一样的元素保存的地方)从链表转成树的阈值,当bin变得很大的时候,就会被转换成TreeNodes中的bin,其结构和TreeMap相似,也就是红黑树;TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转成TreeNodes,当bin中节点数变少时,又会转成普通的bin。
链表长度达到8就转成红黑树,当长度降到6就转成普通bin。
【重点】ConcurrentHashMap
- ConcurrentHashMap的原理介绍
- 底层数据结构: JDK1.7 的
ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8
的结构一样,数组+链表/红黑二叉树。 - 实现线程安全的方式(重要): 在 JDK1.7 的时候,
ConcurrentHashMap
(分段锁) 对整个桶数组进行了分割分段(Segment
),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了Segment
的概念,而是直接用Node
数组+链表+红黑树的数据结构来实现,并发控制使用synchronized
和 CAS 来操作。(JDK1.6 以后 对synchronized
锁做了很多优化) 整个看起来就像是优化过且线程安全的HashMap
,虽然在 JDK1.8 中还能看到Segment
的数据结构,但是已经简化了属性,只是为了兼容旧版本;
JDK1.7的concurrentHashMap
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
ConcurrentHashMap
是由 Segment
数组结构和 HashEntry
数组结构组成。
Segment 实现了 ReentrantLock
,所以 Segment
是一种可重入锁,扮演锁的角色。HashEntry
用于存储键值对数据。
static class Segment<K,V> extends ReentrantLock implements Serializable {
}
一个 ConcurrentHashMap
里包含一个 Segment
数组。Segment
的结构和 HashMap
类似,是一种数组和链表结构,一个 Segment
包含一个 HashEntry
数组,每个 HashEntry
是一个链表结构的元素,每个 Segment
守护着一个 HashEntry
数组里的元素,当对 HashEntry
数组的数据进行修改时,必须首先获得对应的 Segment
的锁。
JDK1.8的concurrentHashMap
JDK1.8 的 ConcurrentHashMap
不在是 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode
。当冲突链表达到一定长度时,链表会转换成红黑树。
接口
Comparable
Comparator
区别
1. ArrayList与LinkedList的区别?
两者本质上的区别是实现数据结构不同,ArrayList使用的是数组,底层是基于动态数组,LinkedList使用的双向链表,两者的主要区别也是数组和链表的区别
2. Array 和 ArrayList有何区别?
Array可以存储基本数据类型和对象 | ArrayList 只能存储对象 |
---|---|
Array 是指定固定大小的 | 而 ArrayList 大小是自动扩展的。 |
Array 内置方法没有 ArrayList 多 | addAll、removeAll、iteration 等方法只有 ArrayList 。 |
3. HashSet、LinkedHashSet、TreeSet的区别?
- HashSet(无序,唯一):基于HashMap实现的,底层采用HashMap来保存元素
- LinkedHashSet:LinkedHashSet 继承于 HashSet,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的LinkedHashMap 其内部是基于 Hashmap 实现一样。
- TreeSet:(有序,唯一): 红黑树(自平衡的排序二叉树。)
4. LinkedHashMap与LinkedHashSet和HashSet的区别
- LinkedHashSet只实现了LinkedHashMap的访问有序;
- LinkedHashSet实现了对其它两个类部分功能的封装;
- HashSet和LinkedHashSet 都对只对键进行操作,value值都是new Object() ;
5. hashSet与HashMap的区别?
HashMap | HashSet |
---|---|
HashMap实现了Map接口 | HashSet实现了Set接口 |
Array 内置方法没有 ArrayList 多HashMap储存键值对 | HashSet仅仅存储对象 |
使用put()方法将元素放入map中 | 使用add()方法将元素放入set中 |
HashMap中使用键对象来计算hashcode值 | HashSet使用成员对象来计算hashcode值,对于两个对象来说hashcode可能相同,所以equals()方法用来判断对象的相等性,如果两个对象不同的话,那么返回false |
HashMap比较快,因为是使用唯一的键来获取对象 | HashSet较HashMap来说比较慢 |
6. HashMap & ConcurrentHashMap 的区别?
除了加锁,原理上无太大区别。另外,HashMap的键值对允许有null,但是ConcurrentHashMap 都不允许。
7. HashMap 和 Hashtable 有什么区别?
-
Hashtable是线程安全的,通过synchronized实现线程同步。而HashMap是非线程安全的,但是速度比Hashtable快。
-
HashMap可以接受null键值和值,而Hashtable则不能。
解释的答案:因为hashtable,concurrenthashmap它们是用于多线程的,并发的 ,如果map.get(key)得到了null,不能判断到底是映射的value是null,还是因为没有找到对应的key而为空,而用于单线程状态的hashmap却可以用containKey(key) 去判断到底是否包含了这个null。
重新总结HashMap和HashTable之间的异同
-
作者不同,HashMap是 Doug Lea,java并发大神开发的,而HashTable是由Hoff(亚瑟饭霍
夫)开发,这个人参与Java早期的大量开发工作,创立了多家成功的公司 -
产生时间不同,HashMap jdk1.2产生,虽然出现较晚,但是Hashtable基本上被弃用了,可能
一是线程安全,太慢,二是,没有驼峰命名法。 -
继承父类不同,HashMap继承AbstractMap,Hashtable继承Dictionary,但是都实现了map,
Cloneable,Serializable三个接口。Dictionary已经被弃用了。 -
二者对外的接口不同
-
对Null key value支持不同
-
线程安全性不同
-
遍历方式内部实现不同,都使用了Iterator进行迭代,但是Hashtable还使用了Enumeration。
HashMap的Iterator使用了fail-fast迭代器,遍历中迭代会报错。JDK8,Hashtable也使用了
fail-fast; -
初始容量和每次扩容大小不同 Hashtable默认11,HashMap默认容量16,hashTable扩容,
2oldSize + 1,HashMap 2 oldSize.(为什么?Hashtable设计重点是哈希更加均匀,hash表为素
数时,直接简单的取模结果更加均匀。而HashMap是为了加速hash速度,2的次幂,做位运算
更快,但也因此导致hash分布不均匀,所以hash算法做了一些修改)- hash值计算方法不同
hashtable为什么就不能containKey(key) ?
一个线程先get(key)再containKey(key),这两个方法的中间时刻,其他线程怎么操作这个key都会可能发生,例如删掉这个key。
-
8. ConcurrentHashMap 比 HashTable 的区别?
主要体现在实现线程安全的方式上不同
-
底层数据结构的不同
ConcurrentHashMap:JDK1.7 底层采用分段的数组 + 链表;JDK1.8采用数组 + 链表 + 红黑二叉树(HashMap1.8也是这结构)
HashTable:数组+链表 的形式(HashMap1.8之前是这结构),链表主要是为了解决哈希冲突而存在的。
-
线程安全方面的不同
HashTable:
使用一把锁(锁住整个链表结构)处理并发问题,多个线程竞争一把锁,可能会进入阻塞或轮询状态;
ConcurrentHashMap :
JDK1.7使用分段锁(ReentrantLock + Segment + HashEntry)相当于把一个HashMap分成多个段,每段分配一把锁,这样支持多线程访问。锁粒度:基于Segment,包含多个HashEntry。(默认分配16个Segment,比Hashtable效率提高16倍。)
JDK1.8使用 Node 数组 + 链表 + 红黑树 的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。 锁粒度:Node(首结点)(实现Map.Entry<K,V>)。锁粒度降低了。