三 Java集合面试篇
概念
数组和集合的区别,用过哪些?
数组和集合的区别:
- 数组是固定长度的数据结构,长度无法改变。而集合是动态长度的数据结构。
- 数组可以包含基本数据类型和对象,而集合只能包含对象。
- 数组可以直接访问元素,而集合需要通过迭代器或其他方法访问元素
我用过的Java集合类:
- ArrayList:动态数组,实现了List接口,支持动态增长
- LinkedList:双向链表,也实现了List接口,支持快速插入和删除。
- HashMap:基于哈希表的Map实现,存储键值对,通过键快速查找值。
- HashSet:基于HashMap实现的Set集合。用于存储唯一元素
- TreeMap:基于红黑树实现的有序Map集合,可以按照键的顺序进行排序。
- LinkedHashMap:基于哈希表和双向链表实现的Map集合,保持插入顺序或访问顺序。
- PriorityQueue:优先队列,可以按照比较器或元素的自然顺序进行排序。
说说Java中的集合
List是有序的Collection。实现List的类有LinkedList,ArrayList,Vector,Stack。
- ArrayList是非线程安全列表,底层用数组实现。ArrayList随机访问很快,但插入和删除很慢。
- LinkedList本质是一个双向链表,与ArrayList相比,插入和删除速度快,但随机访问速度变慢。
Set是去重集合。Set无序,常用的实现有 HashSet,LinkedHashSet,TreeSet。
- HashSet通过HashMap实现,HashMap的key即HashSet存储的元素。所有的Key都使用相同Value:一个名为PRESENT的Object类型变量。使用Key保证元素唯一性,但不保证有序。HashSet线程不安全
- LinkedHashSet继承HashSet,通过LinkedHashMap实现,使用双向链表维护元素插入顺序
- TreeSet通过TreeMap实现,添加元素到集合时按照比较规则将其插入到合适位置,保证插入后集合仍有序。
Map是一个键值对集合,存储键、值和之间的映射。Key无序、唯一;value无序,可重复。
Map没有继承Collection接口。实现有TreeMap,HashMap,HashTable,LinkedHashMap,ConcurrentHashMap。
- HashMap:长度小于阈值(默认为8)时使用数组+链表实现,大于时将链表转为红黑树,以减少搜索时间。
- LinkedHashMap:继承自HashMap,所以其底层仍然基于拉链式散列结构,即由数组和链表或红黑树组成。LinkedHashMap还加了一条双向链表,使其可以保持键值对的插入顺序。
- HashTable:数组+链表组成,数组是HashTable的主体,链表则是主要为了解决哈希冲突而存在的。
- TreeMap:红黑树(自平衡的排序二叉树)
- ConcurrentHashMap:Node数组+链表+红黑树实现,是线程安全的(1.8后volatile + CAS 或者synchronized)
Java中线程安全的集合是什么?
在java.util 包中的线程安全的类主要 2 个,其他都是非线程安全的:
- Vector:其内部方法基本都经过synchronized修饰。Vector内部是使用对象数组来保存数据。当数组已满时,会创建新的数组,并拷贝原有数组数据。如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。
- Hashtable:线程安全的哈希表。其枷锁方法是给每个方法加上synchronized关键字,这样锁住的是整个Table对象,不支持null键和值。很少使用。如果要保证线程安全的哈希表,可以使用ConcurrentHashMap
java.util.concurrent 包提供的都是线程安全的集合:
并发Map:
- ConcurrentHashMap:在Jdk1.8,它直接在table元素上加锁,实现对每一行进行加锁,进一步见笑了并发冲突和概率。对于put操作,①如果key对应的数组元素为null,则通过CAS(Compare and Swap)将其设置为当前值。②如果key对应的数组元素不为null,则对该元素使用synchronized关键字申请锁,从而提高寻址效率。
- ConcurrentSkipListMap:实现了一个基于SkipList(跳表)算法的可排序的并发集合,SkipList是一种可以在对数预期时间内完成搜索、插入和删除熬做的数据结构,是通过维护多个指向其他元素的跳跃链接来实现高校查找。
并发Set:
- ConcurrentSkipListSet:线程安全的有序集合。底层使用ConcurrentSkipListMap。
- CopyOnWriteArraySet:是线程安全的HashSet。CopyOnWriteArraySet和HashSet都继承共同的父类AbstractSet,但HashSet是通过散列表实现,CopyOnWriteArraySet通过动态数组(CopyOnWriteArrayList)实现的,并不是散列表。
并发List:
- CopyOnWriteArrayList:它是ArrayList的线程安全的变体,当对象进行写操作时,使用Lock锁做同步处理,同步拷贝了原数组,并在新数组上进行添加操作,最后将新数组替换旧数组。若读操作,则直接返回结果,操作过程不需要进行同步处理。
并发Queue:
- ConcurrentLinkedQueue:是一个适用于高并发场景下的队列,它通过无锁的方式(CAS),实现了高并发状态下的高性能。
- BlockingQueue:主要用于简化多线程间的数据共享。BlockingQueue提供一种读写阻塞等待的机制,即如果消费者速度较快则BlockingQueue可能被清空,此时消费线程再试图从BlockingQueue读取数据时会被阻塞。反之如果生产队列较快,则BlockingQueue可能会被装满,此时生产线程再试图像BlockingQueue队列装入数据时便会被阻塞等待。
并发Deque:
- LinkedBlockingDeque:线程安全的双端队列。内部使用链表实现,每个节点有前驱节点后继结点。LinkedBlockingDeque没有进行读写锁的分离,因此同一时间只能有一个线程对其操作。
- ConcurrentLinkedDeque:ConcurrentLinkedDeque是一种基于链表节点的无限并发链表。可以安全地并发执行插入、删除和访问操作。当许多线程访问一个公共集合时,ConcurrentLinkedDeque是一个合适的选择。
Collections和Collection的区别
- Collection是所有集合类的基础接口,定义了通用的操作,如添加、遍历和删除等。Collection接口有许多实现类,如List,Set和Queue等。
- Collections是位于java.util包中的工具类。提供了一系列静态方法,用于对集合进行操作和算法。包含排序、查找、替换、反转、随机化等。这些方法可以实现对Collection接口的集合进行操作,如List和Set。
集合遍历的方法有哪些?
在Java中,集合的遍历方法主要有下面几种:
- 普通 for 循环
java
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
for (int i = 0; i < list.size(); i++) {
String element = list.get(i);
System.out.println(element);
}
- 增强 for 循环(for-each循环)
java
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
for (String element : list) {
System.out.println(element);
}
- Iterator 迭代器: 可以使用迭代器来遍历集合,特别适用于需要删除元素的情况。
java
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
Iterator<String> iterator = list.iterator();
while(iterator.hasNext()) {
String element = iterator.next();
System.out.println(element);
}
- ListIterator 列表迭代器: ListIterator是迭代器的子类,可以双向访问列表并在迭代过程中修改元素。
java
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
ListIterator<String> listIterator= list.listIterator();
while(listIterator.hasNext()) {
String element = listIterator.next();
System.out.println(element);
}
- 使用 forEach 方法
java
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
list.forEach(element -> System.out.println(element));
- Stream API: Java 8的Stream API提供了丰富的功能,可以对集合进行函数式操作,如过滤、映射等。
java
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
list.stream().forEach(element -> System.out.println(element));
List
把ArrayList变成线程安全有哪些方法?
- 使用Collections类的synchronizedList方法将ArrayList包装成线程安全的List:
java
List<String> synchronizedList = Collections.synchronizedList(arrayList);
- 使用CopyOnWriteArrayList类代替ArrayList,它是一个线程安全的List实现:
java
CopyOnWriteArrayList<String> copyOnWriteArrayList = new CopyOnWriteArrayList<>(arrayList);
使用Vector类代替ArrayList,Vector是线程安全的List实现:
java
Vector<String> vector = new Vector<>(arrayList);
ArrayList线程不安全的体现在哪里?
在高并发下,ArrayList会暴露三个问题:
- 部分值为null
- 索引越界异常
- size和我们add的数量不符。
三种情况分别是如何产生的:
- 部分值为null:当线程1走到了扩容那里发现当前size是9,而数组容量是10,所以不用扩容,这时候cpu让出执行权,线程2也进来了,发现size是9,而数组容量是10,所以不用扩容,这时候线程1继续执行,将数组下标索引为9的位置set值了,还没有来得及执行size++,这时候线程2也来执行了,又把数组下标索引为9的位置set了一遍,这时候两个先后进行size++,导致下标索引10的地方就为null了。
- 索引越界异常:线程1走到扩容那里发现当前size是9,数组容量是10不用扩容,cpu让出执行权,线程2也发现不用扩容,这时候数组的容量就是10,而线程1 set完之后size++,这时候线程2再进来size就是10,数组的大小只有10,而你要设置下标索引为10的就会越界(数组的下标索引从0开始);
- size与我们add的数量不符:这个基本上每次都会发生,这个理解起来也很简单,因为size++本身就不是原子操作,可以分为三步:获取size的值,将size的值加1,将新的size值覆盖掉原来的,线程1和线程2拿到一样的size值加完了同时覆盖,就会导致一次没有加上,所以肯定不会与我们add的数量保持一致的;
ArrayList的扩容机制是怎么样的
主要步骤如下:
- 计算新的容量:新的容量会扩大为原容量的1.5倍,然后检查是否超过了最大容量限制。
- 创建新的数组:根据计算得到的新容量,创建一个新的更大的数组
- 将元素复制:将原来数组的元素逐个复制到新数组中。
- 更新引用:将ArrayList内部指向原数组的引用指向新数组。
- 完成扩容:扩容完成后,可以继续添加新元素。
ArrayList的扩容操作涉及到数组的复制,内存的重新分配,所以在频繁添加大量元素时,扩容操作可能会影响性能。为了减少扩容的性能损耗,可以在初始化ArrayList时预分配足够大的容量,避免频繁触发扩容操作。
扩容1.5倍是因为 1.5 可以充分利用移位操作,减少浮点数或运算时间和运算次数。
java
// 新容量计算
int newCapacity = oldCapacity + (oldCapacity >> 1);
线程安全的List,CopyonWriteArraylist 是如何实现线程安全的?
CopyonWriteArraylist 底层也是通过一个数组保存数据,使用volatile关键字修饰数组,保证当前线程对数组对象重新赋值后,其他线程可以及时感知到:
java
private transient volatile Object[] array;
在写操作,加入了一把互斥锁 ReentrantLock 以保证线程安全。
在读操作是没有加锁的,所以读是一直都能读。
Map
HashMap的原理
JDK 1.7之前,HashMap的数据结构是数组和链表,通过哈希算法将元素的键 key 映射到数组中的槽位(bucket)。如果冲突,会以链表的形式存储在一个槽位上。
在 JDK 1.8 版本后,当一个链表的长度超过 8 时,将链表转为红黑树,查找为O(logn)。然后在数量小于6时,将红黑树转回链表。
哈希冲突解决方法有哪些?
- 链接法
- 开放寻址法:包含线性探测、二次探测和双重散列。
- 再哈希法
- 哈希桶扩容:当哈希冲突过多时,可以动态地扩大哈希桶的数量,重新分配键值对以减少冲突的概率。
HashMap是线程安全的吗?
- 数组+链表存储,多线程背景下数组扩容时,存在 Entry 链死循环和数据丢失问题。
- 数组+链表+红黑二叉树,多线程背景下,put方法存在数据覆盖的问题。
要保证线程安全,可以通过这些方法来保证:
- 使用Collections.synchronizedMap同步加锁方式,还可以使用ConcurrentHashMap。
- ConcurrentHashMap在JDK1.7使用Segment+HashEntry分段锁的方式实现,1.8则改为用CAS+synchronized+Node实现,同样加入了红黑树,以避免链表过长导致的性能问题。
HashMap的put过程介绍一下
- 根据要添加的键计算哈希,得到这数组中的索引。
- 检查该位置是否为空(有无键值对存在)
- 如果为空,则直接在该位置创建一个新的Entry对象来存储键值对。将HashMap的修改次数(modCount)加1,以便在进行迭代时发现和并发修改。
- 如果该位置已存在键值对,检查该位置第一个键值对的哈希码和键是否与要添加的键值对相同?
- 如果相同,则直接将新的值替换旧的值(键都是相同的),完成更新动作。
- 如果第一个键值对的哈希码和键不同,则需要遍历链表或红黑树来潮找是否有相同的键:
- 是链表:则从头部逐个比较键的哈希玛和equals()方法,直到找到相同的键或到达链表末尾。
- 找到相同的键:用新值代替旧值,更新键对应的值。
- 找不到相同的键:则将新的键值对添加到链表头
- 是红黑树:在红黑树中使用哈希码和equals()方法进行查找。根据键的哈希码,定位到某个节点,然后逐个比较键,直到找到相同的键或到达红黑树的末端。
- 检查链表长度是否到达阈值 8
- 如果链表长度超过8,且HashMap的数组长度大于等于64,则会将链表转为红黑树。
- 检查负载因子是否超过阈值(默认为0.75)
- 如果键值对的数量(size)和数组的长度的比值大于阈值,则需要进行扩容操作。
- 扩容操作:
- 创建一个新的 2 倍大小的数组
- 将旧数组中的键值对重新计算哈希码并分配到新数组。
- 更新HashMap的数组引用和阈值参数。
- 完成添加操作。
HashMap调用get方法一定安全吗?为什么?
- 空指针异常:如果HashMap没有被初始化,则会抛出空指针异常。反之则使用null作为键是允许的,因为HashMap支持null。
- 线程安全:HashMap本身不是线程安全的。如果在多线程中用HashMap,可以用ConcurrentHashMap。
HashMap为什么一般用String作为key?
用String做key,是因为String对象是不可变的,一旦创建就不能被修改,保证了key的稳定性。
为什么HashMap要用红黑树而不是平衡二叉树?
- 平衡二叉树过于严格,任何节点的左右字数高度差不会超过1。但是每次插入/删除时,几乎都会破坏规则,所以都需要通过左旋 右旋来调整。
- 红黑树追求一种弱平衡:整个树最长路径不会长过最短路径的 2 倍。和平衡二叉树不同的是,红黑树在插入、删除等操作时,不会像平衡二叉树那样频繁破坏规则,所以不需要频繁调整,这也是我们大多数情况下用红黑树的原因。
HashMap的key可以为null吗?
可以为null。
- hashMap使用hash()方法来计算key的哈希值,当key为null时,直接令key的哈希值是0,不走key.hashCode()方法:
- hashMap虽然支持key和value为null,但是null作为key只能有一个,null作为value可以有多个;
- 因为hashMap中,如果key值一样,那么会覆盖相同key值的value为最新,所以key为null只能有一个。
重写HashMap的equal和hashcode方法需要注意什么?
从HashMap中取值时,要用到key对象的hashCode()和equals方法。
此外,所有不允许重复存储数据的集合类都使用hashCode()和equals()去查找重复。equals()和hashCode()的实现应遵循如下规则:
- 如果o1.equals(o2),则o1.hashCode() == o2.hashCode()总为true。
- 如果o1.hashCode() == o2.hashCode(),并不一定o1.equals(o2)成立。
重写HashMap的equal方法不当会出现什么问题?
HashMap在比较元素时,会先通过hashCode进行比较,相同的情况下再通过equals进行比较。所以 equals相等的两个对象,hashCode一定相等。hashCode相等的两个对象,equals不一定相等(比如散列冲突的情况)
重写了equals方法,不重写hashCode方法时,可能会出现equals方法返回为true,而hashCode方法却返回false,这样的一个后果会导致在hashmap等类中存储多个一模一样的对象,导致出现覆盖存储的数据的问题,这与hashmap只能有唯一的key的规范不符合。
列举HashMap在多线程中存在的问题:
- JDK1.7中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
- 多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在JDK 1.7和 JDK 1.8 中都存在。
HashMap的扩容机制介绍一下(不懂⭐️)
hashMap默认的负载因子是0.75,即如果hashmap中的元素个数超过了总容量75%,则会触发扩容,扩容分为两个步骤:
- 对哈希表长度的扩展(2倍)
- 是将旧哈希表中的数据放到新的哈希表中。
因为我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成"原索引+oldCap"。可以看看下图为16扩充为32的resize示意图:
HashMap的大小为什么是2的n次方大小呢?(不懂⭐️)
而在 JDK 1.8 中,HashMap 对扩容操作做了优化。由于扩容数组的长度是 2 倍关系,所以对于假设初始 tableSize = 4 要扩容到 8 来说就是 0100 到 1000 的变化(左移一位就是 2 倍),在扩容中只用判断原来的 hash 值和左移动的一位(newtable 的值)按位与操作是 0 或 1 就行,0 的话索引不变,1 的话索引变成原索引加上扩容前数组。(旧哈希值和新哈希值右边的位数都完全一样。)
往hashmap存20个元素,会扩容几次?
初始容量:16
插入第 1 到第 12 个元素时,不需要扩容。
插入第 13 个元素时,达到负载因子限制,需要扩容。此时,HashMap 的容量从 16 扩容到 32。
扩容后的容量:32
插入第 14 到第 24 个元素时,不需要扩容。
因此,总共会进行一次扩容。
Hashmap和Hashtable有什么不一样的?Hashmap一般怎么用?
- HashMap线程不安全,效率高一点,可以存储null的key和value,null的key只能有一个,null的value可以有多个。默认初始容量为16,每次扩充变为原来2倍。创建时如果给定了初始容量,则扩充为2的幂次方大小。底层数据结构为数组+链表,插入元素后如果链表长度大于阈值(默认为8),先判断数组长度是否小于64,如果小于,则扩充数组,则链表转化为红黑树,以减少搜索时间。如果负载因子大于0.75,则扩容。
- HashTable线程安全,效率低一点,其内部方法基本都经过synchronized修饰,不可以有null的key和value。默认初始容量为11,每次扩容变为原来的2n+1。创建时给定了初始容量,会直接用给定的大小。底层数据结构为数组+链表。它基本被淘汰了,要保证线程安全可以用ConcurrentHashMap。
ConcurrentHashMap怎么实现的?
- JDK 1.7 ConcurrentHashMap
使用数组+链表形式实现的。数组又分为:大数组Segment和小数组HashEntry。Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap中扮演锁的角色。
HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组 (多个Segment元素),一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构元素。
JDK1.7ConcurrentHashMap 分段锁技术将数据分成一段段存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段数据也能被其他线程访问,能够实现真正的并发访问。
- JDK 1.8 ConcurrentHashMap
JDK1.8使用数组+链表/红黑树的方式优化ConcurrentHashMap,优化数据比较多情况下访问慢的情况。
JDK 1.8 ConCurrentHashMap 主要通过 volatile + CAS 或者synchronized来实现的线程安全。添加元素时首先会判断容器是否为空: - 如果为空则使用 volatile + CAS 来初始化。
- 如果容器不为空,则根据存储的元素来计算该位置是否为空。
- 如果根据存储的元素计算结果为空,则利用CAS设置该节点;
- 如果根据存储的元素计算结果不为空,则使用synchronized,然后遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。
如果把上面的一句话归纳,就是ConcurrentHashMap通过对头节点(根节点)加锁来保证线程安全,锁的粒度相比Segment来说更小了,发生冲突和加锁的频率降低了。并发操作的性能就提高了。
分段锁怎么加锁的?
在 ConcurrentHashMap 中,将整个数据结构分为多个 Segment,每个 Segment 都类似于一个小的 HashMap,每个 Segment 都有自己的锁,不同 Segment 之间的操作互不影响,从而提高并发性能。
在 ConcurrentHashMap 中,对于插入、更新、删除等操作,需要先定位到具体的 Segment,然后再在该 Segment 上加锁,而不是像传统的 HashMap 一样对整个数据结构加锁。这样可以使得不同 Segment 之间的操作并行进行,提高了并发性能。
分段锁是可重入的吗?
JDK 1.7 ConcurrentHashMap中的分段锁是用了 ReentrantLock,是一个可重入的锁。
(什么是可重入 :可重入就是说某个线程已经获得某个锁,可以再次获取锁而不会出现死锁
换言之:可重入就是一个线程不用释放,可以重复的获取一个锁n次,只是在释放的时候,也需要相应的释放n次。(简单来说:A线程在某上下文中或得了某锁,当A线程想要在次获取该锁时,不会应为锁已经被自己占用,而需要先等到锁的释放)假使A线程即获得了锁,又在等待锁的释放,就会造成死锁。)
- 悲观锁:
顾名思义,就是比较悲观的锁,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。
悲观锁的实现方式为:synchronized
和ReentrantLock
。 - 乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。
乐观锁实现方式:版本号控制
、CAS算法
CAS算法是compare and swap(比较和交换)
ConcurrentHashMap用了悲观锁还是乐观锁?
添加元素时首先会判断容器是否为空。
- 如果为空则使用volatile加CAS(乐观锁)来初始化。
- 如果容器不为空,则根据存储的元素计算该位置是否为空。
- 如果根据存储的元素计算结果为空,则利用 CAS(乐观锁) 设置该节点;
- 如果根据存储的元素计算结果不为空,则使用 synchronized(悲观锁) ,然后,遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。
HashTable底层实现原理是什么?
- Hashtable的底层数据结构主要是数组加链表。数组是主体,链表是解决hash冲突存在的。
- HashTable是线程安全的,实现方式是Hashtable的所有公共方法均采用synchronized关键字(悲观锁)。当一个线程访问同步方法,另一个线程也访问的时候,就会陷入阻塞或轮询的状态。
HashTable线程安全是怎么实现的?
可以看到,Hashtable是通过使用了 synchronized 关键字来保证其线程安全。
在Java中,可以使用synchronized关键字来标记一个方法或者代码块,当某个线程调用该对象的synchronized方法或者访问synchronized代码块时,这个线程便获得了该对象的锁,其他线程暂时无法访问这个方法,只有等待这个方法执行完毕或者代码块执行完毕,这个线程才会释放该对象的锁,其他线程才能执行这个方法或者代码块。
hashtable和concurrentHashMap的区别
底层数据结构:
- jdk7之前concurrenthashmap底层采用分段数组+链表 实现,jdk8后采用的是数组+链表/红黑树
- HashTable采用数组+链表,数组是主体,链表是解决hash冲突存在的 。
实现线程安全的方式:
- jdk8前,concurrenthashmap采用分段锁,对整个数组进行了分段分割,每一把锁只锁容器里的一部分数据,多线程访问不同数据段里的数据就不会存在锁竞争,提高并发访问。jdk8后,直接采用数组+链表/红黑树,并发控制采用CAS和synchronized操作,更加提高了速度。
- Hashtable:所有的方法都加了悲观锁来控制。当一个线程访问同步方法,此时另一个方法也来访问时,就会陷入阻塞或者轮询的状态。
说一下HashMap和Hashtable、ConcurrentMap的区别 (不熟⭐️⭐️)
- HashMap线程不安全,效率高一点,可以存储null的key和value,null的key只能有一个,null的value可以有多个。默认初始容量为16,每次扩充变为原来2倍。创建时如果给定了初始容量,则扩充为2的幂次方大小。底层数据结构为数组+链表,插入元素后如果链表长度大于阈值(默认为8),先判断数组长度是否小于64,如果小于,则扩充数组,反之将链表转化为红黑树,以减少搜索时间。
- HashTable线程安全,效率低一点,其内部方法基本都经过synchronized修饰,不可以有null的key和value。默认初始容量为11,每次扩容变为原来的2n+1。创建时给定了初始容量,会直接用给定的大小。底层数据结构为数组+链表。它基本被淘汰了,要保证线程安全可以用ConcurrentHashMap。
- ConcurrentHashMap是Java中的一个线程安全的哈希表实现,它可以在多线程环境下并发地进行读写操作,而不需要像传统的HashTable那样在读写时加锁。ConcurrentHashMap的实现原理主要基于分段锁和CAS操作。它将整个哈希表分成了多Segment(段),每个Segment都类似于一个小的HashMap,它拥有自己的数组和一个独立的锁。在ConcurrentHashMap中,读操作不需要锁,可以直接对Segment进行读取,而写操作则只需要锁定对应的Segment,而不是整个哈希表,这样可以大大提高并发性能。
Set
Set集合有什么特点?如何实现key无重复的?
- set集合特点:Set集合中的元素是唯一的,不会出现重复的元素。
- set实现原理:Set集合通过内部的数据结构(如哈希表、红黑树等)来实现key的无重复。当向Set集合中插入元素时,会先根据元素的hashCode值来确定元素的存储位置,然后再通过equals方法来判断是否已经存在相同的元素,如果存在则不会再次插入,保证了元素的唯一性
有序的Set是什么?记录插入顺序的集合是什么?
- 有序的 Set 是TreeSet和LinkedHashSet 。TreeSet是基于红黑树实现,保证元素的自然顺序。LinkedHashSet是基于双重链表和哈希表的结合来实现元素的有序存储,保证元素添加的顺序
- 记录插入顺序的集合通常指的是LinkedHashSet,它不仅保证元素的唯一性,还可以保持元素的插入顺序。当需要在Set集合中记录元素的插入顺序时,可以选择使用LinkedHashSet来实现。