Java 集合常见问题总结(3)

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
    }
}

此外还要注意:

  1. COW容器写入性能是比较差的,每次写入除了加锁还会拷贝
  2. 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的情况下,是不是就可能是两种情况:

  1. value为null
  2. 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;
}

它的兜底手段比较多,

  1. 如果元素实现了comparable接口,则直接比较,
  2. 否则则使用默认的仲裁方法

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接,否则又变成无序了

相关推荐
沐知全栈开发2 小时前
ionic 对话框:深度解析与最佳实践
开发语言
浅念-2 小时前
C++ string类
开发语言·c++·经验分享·笔记·学习
百锦再2 小时前
Java多线程编程全面解析:从原理到实战
java·开发语言·python·spring·kafka·tomcat·maven
Cosmoshhhyyy2 小时前
《Effective Java》解读第38条:用接口模拟可扩展的枚举
java·开发语言
wangbing11253 小时前
平台介绍-主数据系统-同步消息设计
java
小冷coding3 小时前
【Java】最新Java高并发高可用平台技术选型指南(思路+全栈路线)
java·开发语言
爱华晨宇3 小时前
Python列表入门:常用操作与避坑指南
开发语言·windows·python
寻星探路3 小时前
【前端基础】HTML + CSS + JavaScript 快速入门(三):JS 与 jQuery 实战
java·前端·javascript·css·c++·ai·html
一切顺势而行3 小时前
python 面向对象
开发语言·python