BitSet是什么?解释一下底层大概是怎么样的?
首先BitSet是一个工具类,专门用来操作位图的,底层其实就是操作二进制的0/1,相当于有一个bit数组,我们来操作每一个位置上是0还是1
不过Java没有bit类型,所以底层是使用long进行代替,一个long有64位
当我们set进去的时候会先判断在哪个桶,然后才通过左移+按位或运算将对应位置上的bit设置为1
当我们get的时候需要传入一个下标,表示我们想要知道第几位bit是0还是1,会返回一个布尔类型
源码:
java
public void set(int bitIndex) {
if (bitIndex < 0)
throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
int wordIndex = wordIndex(bitIndex);
expandTo(wordIndex);
words[wordIndex] |= (1L << bitIndex); // Restores invariants
checkInvariants();
}
public boolean get(int bitIndex) {
if (bitIndex < 0)
throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
checkInvariants();
int wordIndex = wordIndex(bitIndex);
return (wordIndex < wordsInUse)
&& ((words[wordIndex] & (1L << bitIndex)) != 0);
}
然后就是底层究竟是怎么去申请long数组,要申请多大?
java
public BitSet() {
initWords(BITS_PER_WORD);
sizeIsSticky = false;
}
private void initWords(int nbits) {
words = new long[wordIndex(nbits-1) + 1];
}
// 计算在哪个桶
private static int wordIndex(int bitIndex) {
return bitIndex >> ADDRESS_BITS_PER_WORD;
}
可以看到有两个构造器,看你要不要指定初始化大小,如果没有指定的话就是创建一个64位大小,然后会计算多少个long能存下这么多个位,向上取整
假设我要存储的是100个位,向上取整到最近的64的倍数就是128,那long数组就是申请2个空间大小
那要是什么都没传,它默认是认为你需要64位,也就是1个元素(当然不同版本有不用实现,我是jdk17,网上也说了有些版本是采用懒加载,第一次set的时候才初始化)
然后就是经典的问题,一亿个手机号进行去重,我们首先就是想到使用位图
问题拆分:一个手机号是11位,然后前面3位是有固定规律的,可以分开处理,那就是考虑后8位就行
然后需要按照前三位进行分桶
java
public class Main {
public static void main(String[] args) {
// 135、158、152......
Map<String, BitSet> map = new HashMap<>();
BitSet bitSet = map.computeIfAbsent("135", k -> new BitSet());
// 假设有手机号是 135 17190909
bitSet.set(17190909);
// 需要校验的时候
System.out.println(map.get("135").get(17190909)); // true
}
}
可以计算一下大概占用多少空间:
按照向上取整公式,需要的long数量是 (最大下标+63) / 64 = (99999999 + 63) / 64 = 1562500 个long
转换成MB就是 1562500 * 8 / 1024 / 1024 = 12MB
可以发现确实是非常的省
JDK1.8中HashMap有哪些改变?
最主要的就是引入了红黑树的概念,此外hash、resize等方法也有一些变动
红黑树:
在jdk1.7中主要存储结构还是数组+链表,但是当冲突频繁的时候,链表最坏情况会变成O(n) ,所以在jdk1.8中就改成了当数组长度大于等于64且链表长度大于等于8的时候会进行树化操作
节点类型变化:
首先就是原本的链表节点名字改了,且hash改成了final,这一点主要是为了规范以及避免hash值被修改
在1.7里面叫Entry
java
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
}
在1.8里面叫Node(为了规范)
java
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
此外1.8还引入了TreeNode
java
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
}
这个TreeNode 继承了LinkedHashMap.Entry,但是LinkedHashMap.Entry又继承了Node,所以TreeNode继承了Node,且在它的基础上增加了树的指针之类的
java
LinkedHashMap.Entry
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
然后为什么hash要用final进行修饰呢?
因为红黑树的排序会依赖于hash值,一定要注意,被归为到同一棵红黑树不意味着hash值相同,而是hash值对数组长度取模结果刚好是同一棵树,所以红黑树的排序会依赖hash先进行排,当hash相同再按照key进行排
扩容时,链表的插入方式
jdk1.7中是使用头插法实现扩容迁移,但是存在死循环的风险,所以jdk1.8开始改成了尾插法
hash()方法
jdk1.7
java
final int hash(Object k) {
int h = 0;
if (useAltHashing) {
if (k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
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);
}
jdk1.8
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
在jdk1.7版本中,为了降低哈希碰撞的概率,运用了多次异或运算,而在jdk1.8中则简单粗暴的将高位的16位移动到低位,再和原来的hashcode进行异或运算,目的就是让高位和低位都能充分的参与,避免取模导致不参与
补充:异或运算(^) 相同为0,相反为1
扩容机制:
jdk1.7中
java
// 扩容入口
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
transfer(newTable, rehash);
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
// 数据迁移方法
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
jdk1.8中
java
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
在1.8中将transfer的逻辑合并到resize中,并且引入了对红黑树的处理逻辑
还有就是在jdk1.7中,如果计算出来的rehash 为true的话,会重新调用hash() 方法进行再次扰动,拿到新的hash值,而在jdk1.8中就没有这个过程,要么是在原来的位置,要么在(原来位置+旧容器的容量)的位置【且不再是由rehash控制,而是看hash值新增的那一位是0还是1】
Stream的并行流是如何实现的?
Stream<String> parallelledStream = list.parallelStream();
底层主要是过ForkJoinPool线程池实现的
可以理解就是开多个线程,然后将一个大的任务拆分成多个小的任务给这些线程去执行,再将各自执行的结果收集起来
遍历的同时修改一个List有几种方式?
那么我们这里需要先理清楚什么是可变List,什么是不可变List
常见的可变List:就是自己new出来的
java
List list1 = new ArrayList();
List list2 = new LinkedList();
List list3 = new ArrayList();
List list4 = new CopyOnWriteArrayList();
List list5 = new Vector();
常见的不可变List,直接通过API创建出来的或是直接包装成不可变的
java
// 1. Arrays.asList(固定大小:支持set,不支持增删)
List<Integer> fixedSizeList = Arrays.asList(1,2,3);
fixedSizeList.set(1, 99); // 成功(仅改值)
fixedSizeList.add(4); // 抛 UnsupportedOperationException
// 2. Collections.unmodifiableList(严格不可变)
List<Integer> origin = new ArrayList<>(Arrays.asList(1,2,3));
List<Integer> readOnlyList = Collections.unmodifiableList(origin);
readOnlyList.set(1, 99); // 抛异常
readOnlyList.add(4); // 抛异常
// 3. Java 9+ List.of(严格不可变)
List<Integer> immutableList = List.of(1,2,3);
immutableList.add(4); // 抛异常
immutableList.set(1, 99); // 抛异常
不可变还分成严格不可变和弱不可变,弱不可变就是在不改变数量的情况下可以对原有的值进行修改
那我们这里探讨的就是可变List如何在遍历的时候进行修改(移除、新增)
普通for循环(这两种都行)
java
// 正序
for (int i = 0; i < list.size(); i++) {
if (list.get(i) % 2 == 0) {
list.remove(i);
i --; // 因为下标会变化,这里注意
}
}
System.out.println(list); // [1, 3]
// 倒序
for (int i = list.size() - 1; i >= 0 ; i--) {
if (list.get(i) % 2 == 0) {
list.remove(i);
}
}
System.out.println(list); // [1, 3]
迭代器(注意移除的时候要使用迭代器的remove,不能使用List的)
java
List<Integer> list = new ArrayList<>();
list.add(1); list.add(2); list.add(3); list.add(4);
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
Integer val = iterator.next();
if (val % 2 == 0) {
iterator.remove();
}
}
System.out.println(list); // [1, 3]
转化成线程安全的List
java
List<Integer> list = new ArrayList<>();
list.add(1); list.add(2); list.add(3); list.add(4);
List<Integer> newList = new CopyOnWriteArrayList<>(list);
for (Integer num : newList) {
if (num % 2 == 0) newList.remove(num);
}
System.out.println(list); // [1, 2, 3, 4]
System.out.println(newList); // [1, 3]
stream(推荐,工业上比较好用的做法,因为每次都会生成一个新的集合,不会改变原数据)
java
List<Integer> list = new ArrayList<>();
list.add(1); list.add(2); list.add(3); list.add(4);
List<Integer> newList = list.stream().filter(x -> x % 2 != 0).toList();
System.out.println(newList); // [1, 3]
使用自带的removeIf API(推荐,会改变原来的集合)
java
List<Integer> list = new ArrayList<>();
list.add(1); list.add(2); list.add(3); list.add(4);
list.removeIf(x -> x % 2 == 0);
System.out.println(list); // [1, 3]
你能说出几种集合的排序方式?
实现Comparable接口并重写compareTo制定排序规则,就可以调用Collections的sort方法进行排序
java
List<Test> tests = new ArrayList<>();
tests.add(new Test(1,1)); tests.add(new Test(3,3));
tests.add(new Test(4,4)); tests.add(new Test(2,2));
Collections.sort(tests);
System.out.println(tests); // [Test{a=1, b=1}, Test{a=2, b=2}, Test{a=3, b=3}, Test{a=4, b=4}]
class Test implements Comparable<Test>{
int a;
int b;
public Test(int a, int b) {
this.a = a;
this.b = b;
}
@Override
public String toString() {
return "Test{" +
"a=" + a +
", b=" + b +
'}';
}
@Override
public int compareTo(Test o) {
return this.a - o.a;
}
}
如果不想去实现Comparable接口和实现compareTo方法,也可以直接使用Comparator比较器
就是在Collections的sort方法传入第二个参数指定排序规则
java
List<Test> tests = new ArrayList<>();
tests.add(new Test(1,1)); tests.add(new Test(3,3));
tests.add(new Test(4,4)); tests.add(new Test(2,2));
Collections.sort(tests, (x, y) -> y.a - x.a);
System.out.println(tests); // [Test{a=4, b=4}, Test{a=3, b=3}, Test{a=2, b=2}, Test{a=1, b=1}]
class Test {
int a;
int b;
public Test(int a, int b) {
this.a = a;
this.b = b;
}
@Override
public String toString() {
return "Test{" +
"a=" + a +
", b=" + b +
'}';
}
}
使用Stream的sorted,和前面两种一样,可以采用实现Comparable接口并重写compareTo或是直接Comparator两种,因为和前面的都一样,就不重复展示,我比较喜欢Comparator指定
需要注意的就是要对排序后的结果进行收集
java
List<Test> tests = new ArrayList<>();
tests.add(new Test(1,1)); tests.add(new Test(3,3));
tests.add(new Test(4,4)); tests.add(new Test(2,2));
System.out.println(tests.stream().sorted((x, y) -> y.a - x.a).toList()); // [Test{a=4, b=4}, Test{a=3, b=3}, Test{a=2, b=2}, Test{a=1, b=1}]
有Comparable为什么还要有Comparator呢?
Comparable 是让一个类自生具备排序比较能力,而Comparator是让一个原本没有排序能力的对象可以进行排序,此外Comparator更加的灵活,不用对原代码进行修改
compareTo和equals有什么区别?
compareTo 一般用于排序和BigDecime等值判断
equals则是用于判断两个对象是否相同
如何将集合变成线程安全的?
加锁
java
public class SynchronizedCollectionExample {
private List<Integer> list = new ArrayList<>();
public void add(int value) {
synchronized (SynchronizedCollectionExample.class) {
list.add(value);
}
}
public int get(int index) {
synchronized (SynchronizedCollectionExample.class) {
return list.get(index);
}
}
}
直接每次操作前都需要去获取锁,操作完释放锁就行
与线程绑定(ThreadLocal)
java
public class Main {
private final ThreadLocal<List<Integer>> threadLocal = new ThreadLocal<>();
void add(int i) {
threadLocal.get().add(i);
}
int get(int idx) {
return threadLocal.get().get(idx);
}
}
这样List是和线程绑定的,是线程安全的,其他线程没办法访问
转化成不可变集合
List<Integer> list = List.of(1, 2, 3);
List<Integer> list = Collections.unmodifiableList(list);
转化成线程安全集合
java
// 第一种
CopyOnWriteArrayList<Integer> newList = new CopyOnWriteArrayList<>(list);
// 第二种
List<Integer> newList2 = Collections.synchronizedList(list);
什么是COW,如何保证的线程安全?
也叫写时复制,就是在进行写操作的时候赋值一份新的内容,在这个新的内容上进行修改,然后用新的内容替代旧的内容
比较常用的就是CopyOnWriteArrayList和CopyOnWriteArraySet两种
java
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1);
es[len] = e;
setArray(es);
return true;
}
}
上面这个就是CopyOnWriteArrayList的add方法,可以看到,首先会通过synchronized确保同一时间只能有一个线程进行add操作,然后第一步就是先拷贝一份数组,然后在拷贝出来的这个数组上面进行修改操作,然后再将修改完的数组赋值回去,此外remove和set方法都是类似的思路
下面是CopyOnWriteArraySet进行add操作时,底层的思路,其实也是差不多,先拷贝一份,对拷贝出来的进行操作,将操作完的塞回去
java
private boolean addIfAbsent(E e, Object[] snapshot) {
synchronized (lock) {
Object[] current = getArray();
int len = current.length;
if (snapshot != current) {
// Optimize for lost race to another addXXX operation
int common = Math.min(snapshot.length, len);
for (int i = 0; i < common; i++)
if (current[i] != snapshot[i]
&& Objects.equals(e, current[i]))
return false;
if (indexOfRange(e, current, common, len) >= 0)
return false;
}
Object[] newElements = Arrays.copyOf(current, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
}
}
可以总结一下,COW就是一种读写分离的方式
COW迭代器不能调用remove,否则报错
java
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
list.add(1);
list.add(2);
list.add(3);
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
Integer num = iterator.next();
if (num == 2) {
iterator.remove(); // 抛 UnsupportedOperationException
}
}
CopyOnWriteArraySet<Integer> set = new CopyOnWriteArraySet<>();
set.add(1);
set.add(2);
set.add(3);
Iterator<Integer> iterator1 = set.iterator();
while (iterator1.hasNext()) {
Integer num = iterator1.next();
if (num == 2) {
iterator1.remove(); // 抛 UnsupportedOperationException
}
}
此外还要注意:
- COW容器写入性能是比较差的,每次写入除了加锁还会拷贝
- COW容器的迭代器不支持remove操作,但是容器自身有支持remove
COW容器和Vector的思想有什么区别?
COW读取的时候不加锁,但是写的时候会加锁,还需要复制一份新的
java
public E get(int index) {
return elementAt(getArray(), index);
}
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1);
es[len] = e;
setArray(es);
return true;
}
}
它的思想就是让读写分离(读和写的不是同一个容器),也就是在进行写操作的时候也能读取,只不过读取到的就不是最新的数据,可以看到读取的时候并不加锁
在实际的场景中,大部分以读取为主
Vector并没有那么复制的机制,它只直接对写操作和读操作的方法都加上锁
java
public synchronized E get(int index) {
if (index >= elementCount)
throw new ArrayIndexOutOfBoundsException(index);
return elementData(index);
}
public synchronized boolean add(E e) {
modCount++;
add(e, elementData, elementCount);
return true;
}
这样就做到读写互斥,写写互斥,甚至读读也互斥,反正就是同一时间只能一个线程进行操作数组,无论是读取还是写操作
什么是fail-fast?什么是fail-safe?
这两种方式是并发过程中解决问题的策略
- fail-safe:安全失败,迭代的时候遇到修改操作不会抛出异常,而是返回一个快照版本,可能会导致拿到的快照和实际有一定的不一致,例如CopyOnWriteArrayList
- fail-fast:快速失败,迭代的时候遇到修改操作会直接抛出异常,例如ArrayList
然后我们可以看看这两种机制在Java中是如何来实现的
fail-fast
先看一下 fail-fast,我们先进入到ArrayList,里面有一个变量 modCount,每次进行写操作都会执行加1
java
public boolean add(E e) {
modCount++;
add(e, elementData, size);
return true;
}
public void sort(Comparator<? super E> c) {
final int expectedModCount = modCount;
Arrays.sort((E[]) elementData, 0, size, c);
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
modCount++;
}
当我们调用迭代器的next() 进行获取遍历元素的时候会校验当前的modCount是否和预期的expectedModCount一样,如果不一样说明有其他线程把这个List中的元素改了,直接抛出异常
java
public E next() {
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
可能会有人认为我使用forEach就不会了,可以发现同样是会的
java
public void forEach(Consumer<? super E> action) {
Objects.requireNonNull(action);
final int expectedModCount = modCount;
final Object[] es = elementData;
final int size = this.size;
for (int i = 0; modCount == expectedModCount && i < size; i++)
action.accept(elementAt(es, i));
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
注意啊:你自己改没事,因为你自己改的话modCount修改的同时,expectedModCount也会跟着改,但是要是别的线程改的话就会出事
fail-safe
再看看fail-safe,典型的就是CopyOnWriteArrayList
可以发现它有关修改的方法是加了锁的,确保同一个时间只有一个线程能来操作,并且操作的时候不是直接对原来的容器进行修改,而是拷贝出一份新的去修改,然后再塞回去,这就是一种fail-safe的思想
java
public E set(int index, E element) {
synchronized (lock) {
Object[] es = getArray();
E oldValue = elementAt(es, index);
if (oldValue != element) {
es = es.clone();
es[index] = element;
}
// Ensure volatile write semantics even when oldvalue == element
setArray(es);
return oldValue;
}
}
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1);
es[len] = e;
setArray(es);
return true;
}
}
可以看看它的迭代器有关方法,可以发现并没有判断modCount的逻辑,因为此时即使别的线程来修改,也不是修改原有的容器
java
public E next() {
if (! hasNext())
throw new NoSuchElementException();
return (E) snapshot[cursor++];
}
public void forEach(Consumer<? super E> action) {
Objects.requireNonNull(action);
int i, end; final Object[] es;
synchronized (lock) {
es = getArrayChecked();
i = offset;
end = i + size;
}
for (; i < end; i++)
action.accept(elementAt(es, i));
}
同步容器(如Vector)的所有操作一定是线程安全的吗?
不一定,同步容器只要是通过对单个操作加锁来确保线程安全,只能说可以确保单操作线程安全,但是复合操作不一定
在实际场景中符合操作并不少,必要时只能主动在业务端加锁
可以看看实际的符合操作:
java
public void deleteLast() {
synchronized (v) {
int index = v.size() - 1;
v.remove(index);
}
}
如果我们这里没加锁的话,在多线程的情况下就会出现多个线程交叉执行,可能会抛出索引越界的报错
那么在这种场景下如果要保证性能以及线程安全,一般就会选择并发容器:
java
常见并发容器
ConcurrentHashMap
ConcurrentLinkedDeque
ConcurrentLinkedQueue
ConcurrentSkipListMap
ConcurrentSkipSet
CopyOnWriteArrayList
CopyOnWriteArraySet
它们之所以能确保线程安全,而同步容器不行,其实就是原子性的问题,需要确保复合操作是一致性的
java
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
list.addIfAbsent(1);
list.removeIf(x -> x == 1);
当然如果一些场景确实没有合适的API,同样需要使用加锁解决
为什么ConcurrentHashMap不允许null值?
首先就是代码层面的
java
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
}
之所以这么设计的原因是为了在多线程环境下,避免出现歧义的问题
可以思考一个场景,假设两个线程A、B,A线程的目的是要来执行,
A线程先执行了get(key) 结果返回null,若是允许null的情况下,是不是就可能是两种情况:
- value为null
- key不存在
那这个时候我们通常会再使用containsKey判断一下key到底存不存在
但是并发情况下,
- 假设这个key原本确实是存在的,且值为value,这个时候另一个线程把key删除了,导致我后面判断containsKey为false
- 另一种情况,假设key确实不存在,但是这个时候有另外一个线程进来塞了这样一个key,导致我后面判断到containsKey为true
可以发现,并发场景下,无法判断key是存在还是不存在
在一些缓存场景,就很危险,我们没办法很好的判断一个数据是未加载到缓存中,还是压根不存在,最终只能落库找,导致性能浪费
所以ConcurrentHashMap不允许null-key 和 null-value,而HashMap同样可能会出现歧义,但是它设计初衷是单线程或是没有线程安全的场景
单线程场景下,我们判断到为null,就调用containsKey,结果为true就是value为null,结果为false就是key不存在,没什么歧义
为什么HashMap的Cap必需是2^n,如何保证?
为什么要将cap设置为2^n
就是为了计算hash的时候使用位运算替换取模运算,位运算效率更高
当容量是2^n的时候,我们要计算下标,可以直接使用e.hash & (newCap - 1),快速的定位到当前元素应该存储在哪个槽
e.hash & (newCap - 1) 其实等价于 e.hash % newCap,只要newCap是2^n
如何保证cap一定是2^n
初始化阶段:
- 若是没有传递初始容量,就会直接设置为16
- 要是传了也不会直接用,而是计算出最小且大于你设置的这个值的一个2^n
扩容阶段:扩容的时候都是直接扩容为原来容量的2倍,所以也是2^n,当然也是在不超过最大上限的前提下
为什么HashMap的默认负载因子设置成0.75?
简单的说就是在时间和空间权衡
如果设置为0.5,那么会很浪费空间,但是哈希碰撞的概率很低
如果设置为1,那么会很节省空间,但是哈希碰撞的概率很高
最终根据泊松分布,0.75 是比较好的一个参数,此外就是0.75 和 2^n 的乘积都是整数
为什么在JDK8中HashMap要转成红黑树?
因为当链表的节点个数比较多的时候,查询链表的性能就变成O(n) ,引入树化机制是为了将时间复杂度降低到O(logn),并且需要一种能子平衡的树
那么选择就变成了 AVL 和 红黑树,又因为AVL的判定规则比较严格,可能会导致频繁的树化,所以工程上用红黑树比较多
为什么HashMap的树化界限是8,反树化界限是6?为什么不直接全部用红黑树?
选择8的原因是基于泊松分布来看的,当冲突节点达到8个的概率是极低的,所以选择8作为界限
而反树化的节点就是只要小于8就行,之所以选择6,没选择7的原因是避免频繁的树化和反树化
假设刚刚达到8个节点进行树化,结果又删除了一个节点反树化,这样频繁的转化开销很大
之所以不能全部用红黑树的原因是,红黑树里面包括了链表的属性,还有自己的属性,它的体积是比较大的,比较占内存的,所以全部用红黑树会浪费内存
HashMap的元素没有比较能力,红黑树为什么可以比较?
java
static int tieBreakOrder(Object a, Object b) {
int d;
if (a == null || b == null ||
(d = a.getClass().getName().
compareTo(b.getClass().getName())) == 0)
d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
-1 : 1);
return d;
}
它的兜底手段比较多,
- 如果元素实现了comparable接口,则直接比较,
- 否则则使用默认的仲裁方法
Set既然是无序的,要怎么有序?
这个取决于你是想要插入有序还是数值有序
- 如果想要访问有序的话,可以使用 LinkedHashSet ,它里面维护了一条双向链表记录元素的插入顺序
- 如果是想要数值有序的话,你完全可以使用TreeSet或是使用Stream并制定排序规则
首先我们需要知道,大部分Set都是基于Map实现的,除了CopyOnWriteArraySet是基于CopyOnWriteArrayList实现的以及少部分Set之外
所以我们接下来需要从Map入手来看如何实现有序
访问有序,需要看一下LinkedHashMap
里面有首尾节点,刚好组成一个双向链表
java
transient LinkedHashMap.Entry<K,V> head;
transient LinkedHashMap.Entry<K,V> tail;
java
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
可以看到每一个LinkedHashMap 的 Node节点都是继承了HashMap的Node节点,并且增加了前后指针用来快速定位在链表中的位置
那么LinkedHashSet是基于LinkedHashMap实现的,所以key也会维护在这样一个双向链表中,所以能做到有序
小demo
java
LinkedHashSet<Integer> set = new LinkedHashSet<>();
for (int i = 0; i < 10; i ++) set.add(i);
System.out.println(set); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
数值有序,需要看一下TreeMap
其实它的排序规则是依赖于指定的泛型有没有实现Comparable接口并实现compareTo方法,如果没有的话,指定Comparator也行,具体的可以看上面的 你能说出几种集合的排序方式?
然后TreeSet底层也是依赖于TreeMap
java
TreeSet<Integer> set = new TreeSet<>();
set.add(1);
set.add(3);
set.add(5);
set.add(2);
set.add(4);
System.out.println(set); // [1, 2, 3, 4, 5]
此外如果不想更换数据结构,也是可以依靠Stream实现数值有序的
java
HashSet<Integer> set = new HashSet<>();
set.add(1000);
set.add(100);
set.add(7);
set.add(4);
System.out.println(set); // [100, 4, 7, 1000]
System.out.println(set.stream().sorted((x, y) -> x - y).collect(Collectors.toList())); // [4, 7, 100, 1000]
不过转换完记得不要用Set接,否则又变成无序了