Bootstrap

字节腾讯阿里大厂面经汇总:Java集合(容器)大厂面试题及参考答案

ArrayList 的扩容机制以及删除操作的时间复杂度

ArrayList 是 Java 中非常常用的一个集合类,它是基于数组实现的动态数组。当我们创建一个 ArrayList 时,如果不指定初始容量,它会有一个默认的初始容量(通常是 10)。当我们向 ArrayList 中添加元素时,如果元素的数量达到了当前的容量,ArrayList 就需要进行扩容操作。扩容的过程是这样的:它会创建一个新的数组,新数组的容量通常是原来数组容量的 1.5 倍(在 Java 8 及以后的版本中,这个倍数可能会有所调整,但大致是这样的比例),然后将原来数组中的元素复制到新的数组中。这个过程涉及到创建新数组和复制元素,会消耗一定的性能,尤其是当数组元素数量较多时。例如,我们可以这样来看一个简单的代码示例:

import java.util.ArrayList;

public class ArrayListExpansion {
    public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            arrayList.add(i);
        }
    }
}

在上述代码中,当添加元素的数量超过初始容量时,ArrayList 会自动扩容。

关于删除操作的时间复杂度,ArrayList 的删除操作在一般情况下是 O (n)。因为当我们删除一个元素时,为了保持数组的连续性,需要将删除元素后面的元素依次向前移动一个位置。假设我们要删除 ArrayList 中的第 i 个元素,那么从第 i + 1 个元素开始,后面的元素都要向前移动一个位置,这就涉及到了元素的移动操作。例如,下面的代码展示了如何删除 ArrayList 中的一个元素:

import java.util.ArrayList;

public class ArrayListDeletion {
    public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList<>();
        arrayList.add(1);
        arrayList.add(2);
        arrayList.add(3);
        arrayList.remove(1); 
    }
}

在 arrayList.remove(1); 这行代码中,当删除索引为 1 的元素时,索引为 2 的元素会向前移动到索引为 1 的位置,后面的元素依次类推。这意味着,如果 ArrayList 中的元素数量为 n,删除操作平均需要移动 n/2 个元素,所以时间复杂度是 O (n)。但如果是删除最后一个元素,时间复杂度可以看作是 O (1),因为不需要移动其他元素,只需要将最后一个元素的引用置空即可。

在实际使用中,我们要考虑到这些性能影响。如果我们知道大概会存储多少元素,在创建 ArrayList 时可以指定一个合适的初始容量,这样可以减少扩容的次数,提高性能。同时,如果我们的操作涉及到大量的删除操作,并且对性能要求较高,可能需要考虑使用其他的数据结构,或者使用 ArrayList 时要注意删除元素的位置,尽量避免在中间位置进行频繁删除,以减少元素移动的开销。

ArrayList 和 LinkedList 的区别是什么?为什么常用 ArrayList?

ArrayList 和 LinkedList 都是 Java 中的集合类,但它们在很多方面都有所不同。首先,ArrayList 是基于数组实现的,而 LinkedList 是基于双向链表实现的。这就导致了它们在性能表现和使用场景上有很大的区别。

ArrayList 的优势在于随机访问,因为它是基于数组,所以可以通过索引直接访问元素,时间复杂度是 O (1)。例如,我们可以使用 get(int index) 方法来获取元素:

import java.util.ArrayList;

public class ArrayListAccess {
    public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList<>();
        arrayList.add(1);
        arrayList.add(2);
        arrayList.add(3);
        System.out.println(arrayList.get(1)); 
    }
}

在上述代码中,arrayList.get(1) 可以快速地找到索引为 1 的元素,因为它直接通过计算内存地址偏移来找到元素的位置。

然而,ArrayList 的添加和删除操作在某些情况下性能较差。如前面所说,当添加元素导致扩容时,需要创建新数组并复制元素;删除元素时,需要移动元素,时间复杂度为 O (n)。

LinkedList 则不同,它在添加和删除元素方面有一定的优势,特别是在列表的头部或尾部进行操作。因为链表只需要修改前后节点的指针,所以添加或删除元素的时间复杂度可以达到 O (1)。例如,我们在头部添加元素:

import java.util.LinkedList;

public class LinkedListAdd {
    public static void main(String[] args) {
        LinkedList<Integer> linkedList = new LinkedList<>();
        linkedList.addFirst(1);
    }
}

在 linkedList.addFirst(1); 这行代码中,只需要将新元素的指针指向原头部元素,原头部元素的前指针指向新元素,操作非常迅速。

但是,LinkedList 的随机访问性能较差,因为它需要从头部或尾部开始遍历链表,直到找到指定索引的元素,时间复杂度是 O (n)。

那么为什么 ArrayList 更常用呢?主要是因为在大多数情况下,我们更需要的是随机访问元素的能力。而且在实际应用中,我们添加元素的频率可能并不会高到让 ArrayList 的扩容操作成为性能瓶颈,而 ArrayList 的内存使用相对比较紧凑,对缓存更友好,能够更好地利用 CPU 缓存,提高性能。而且对于一般的开发场景,我们更习惯使用数组这种数据结构,它的操作也更直观,理解起来更容易。

简述 ArrayList 和 LinkedList 的区别和底层实现

ArrayList 是基于数组的动态存储结构,它的底层是一个数组对象。在创建时,可以指定初始容量,如果不指定,会使用默认的初始容量。当添加元素时,如果数组的容量不够,会触发扩容操作,如之前所提到的,会创建一个更大的新数组,并将旧元素复制到新数组中。ArrayList 提供了方便的方法,比如 add(E e) 用于添加元素,remove(int index) 用于删除指定索引的元素,get(int index) 用于获取指定索引的元素等。它的优点在于可以快速地通过索引访问元素,适合频繁读取元素的场景。例如:

import java.util.ArrayList;

public class ArrayListExample {
    public static void main(String[] args) {
        ArrayList<String> arrayList = new ArrayList<>();
        arrayList.add("hello");
        arrayList.add("world");
        String element = arrayList.get(0); 
    }
}

这里的 arrayList.get(0) 利用了数组的随机访问特性,速度非常快。

LinkedList 是基于双向链表实现的,每个节点都包含了指向前一个节点和后一个节点的引用,以及存储的数据元素。LinkedList 提供了一些针对链表操作的特有方法,如 addFirst(E e) 用于在头部添加元素,addLast(E e) 用于在尾部添加元素,removeFirst() 用于删除头部元素等。它的优势在于插入和删除操作,特别是在链表的头部或尾部,只需要修改前后节点的指针,而不需要像 ArrayList 那样移动大量元素。例如:

import java.util.LinkedList;

public static void main(String[] args) {
    LinkedList<String> linkedList = new LinkedList<>();
    linkedList.addFirst("hello");
    linkedList.addLast("world");
    linkedList.removeFirst();
}

在 linkedList.removeFirst(); 中,只需要将头部指针指向下一个节点,原头部节点的后一个节点的前指针修改为 null,就完成了删除操作。

两者的区别还体现在内存使用上,ArrayList 的内存布局是连续的,更有利于 CPU 缓存,而 LinkedList 的每个节点都需要额外存储前后指针,会占用更多的内存空间。在空间性能方面,ArrayList 可能更紧凑,但 LinkedList 更灵活,适合需要频繁插入和删除元素的场景,尤其是在列表的两端进行操作。

Java 中的 ArrayList 了解吗?其插入元素的过程是怎样的?

ArrayList 是 Java 集合框架中的一个重要成员,它为我们提供了动态数组的功能,让我们可以方便地存储和操作元素集合。

当我们向 ArrayList 中插入元素时,根据插入的位置不同,过程会有所不同。如果是在列表的末尾插入元素,相对比较简单。我们可以使用 add(E e) 方法,例如:

import java.util.ArrayList;

public class ArrayListInsertion {
    public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList<>();
        arrayList.add(1);
        arrayList.add(2);
        arrayList.add(3);
    }
}

在上述代码中,使用 add(E e) 方法会将元素添加到列表的末尾。如果 ArrayList 的容量足够,直接将元素添加到数组的下一个可用位置即可;如果容量不够,会触发扩容操作,先创建一个新的、更大容量的数组,将原数组中的元素复制过去,然后再将新元素添加到末尾。

如果是在列表的中间插入元素,使用 add(int index, E element) 方法,情况会复杂一些。例如:

import java.util.ArrayList;

public class ArrayListInsertionMiddle {
    public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList<>();
        arrayList.add(1);
        arrayList.add(2);
        arrayList.add(3);
        arrayList.add(1, 4); 
    }
}

在 arrayList.add(1, 4); 这行代码中,我们要在索引为 1 的位置插入元素 4。首先,会检查是否需要扩容,如果需要,进行扩容操作。然后,从插入位置开始,将后面的元素依次向后移动一个位置,为新元素腾出空间,最后将新元素放入指定位置。这个移动元素的过程是一个 O (n) 的操作,因为需要移动插入位置之后的元素。

ArrayList 内部使用 size 属性来记录元素的数量,添加元素时会先检查 size 是否达到了数组的容量,如果达到,就进行扩容操作。它使用 System.arraycopy() 方法来复制元素,确保元素的正确移动和存储。在实际使用中,我们需要注意插入元素的位置和频率,避免频繁在中间位置插入元素,以免性能下降。

常用的数据结构有哪些?简要说明。

在 Java 编程中,有许多常用的数据结构,它们各有特点和适用场景。

首先是数组,它是一种线性数据结构,存储相同类型的元素,具有固定的大小。可以通过索引直接访问元素,时间复杂度是 O (1)。例如:

int[] array = new int[5];
array[0] = 1;

这里的 array[0] 可以快速访问数组的第一个元素。但是数组的大小是固定的,一旦创建,就不能改变大小,这是它的一个局限。

其次是链表,像前面提到的 LinkedList 就是链表的一种实现,它可以动态地添加和删除元素,分为单向链表、双向链表和循环链表等。链表的节点存储数据和指向下一个节点(或前后节点)的指针,插入和删除操作比较灵活,但随机访问性能较差,需要遍历链表,时间复杂度是 O (n)。

栈(Stack)也是一种常用的数据结构,遵循后进先出(LIFO)的原则。Java 中的 Stack 类提供了 push(E item) 用于添加元素,pop() 用于弹出元素,peek() 用于查看栈顶元素等操作。例如:

import java.util.Stack;

public class StackExample {
    public static void main(String[] args) {
        Stack<String> stack = new Stack<>();
        stack.push("hello");
        stack.push("world");
        String top = stack.pop(); 
    }
}

在 stack.pop(); 这行代码中,会弹出栈顶元素,也就是最后一个添加进去的元素。

队列(Queue)遵循先进先出(FIFO)的原则,Java 中可以使用 LinkedList 来实现队列,它提供了 offer(E e) 用于添加元素,poll() 用于取出元素等操作。例如:

import java.util.LinkedList;

public class QueueExample {
    public static void main(String[] args) {
        LinkedList<String> queue = new LinkedList<>();
        queue.offer("hello");
        queue.offer("world");
        String first = queue.poll(); 
    }
}

这里的 queue.poll(); 会取出队列头部的元素。

还有树结构,比如二叉树,它的每个节点最多有两个子节点,可用于搜索、排序等操作。二叉搜索树(BST)可以快速查找元素,时间复杂度为 O (log n)。例如,当我们查找元素时,从根节点开始,根据元素大小比较,向左或向右遍历,快速缩小查找范围。

图结构则可以用来表示多对多的关系,常用于网络分析、路径规划等。它包含节点和边,节点表示对象,边表示对象之间的关系。图可以使用邻接矩阵或邻接表来存储,不同的存储方式在性能和空间使用上有不同的特点。

哈希表(Hash Table)也是一种非常重要的数据结构,它通过哈希函数将键映射到存储位置,实现快速的查找、插入和删除操作,平均时间复杂度是 O (1)。Java 中的 HashMap 就是哈希表的实现,它可以存储键值对,例如:

import java.util.HashMap;

public class HashMapExample {
    public static void main(String[] args) {
        HashMap<String, Integer> hashMap = new HashMap<>();
        hashMap.put("one", 1);
        hashMap.put("two", 2);
        Integer value = hashMap.get("one"); 
    }
}

在 hashMap.get("one"); 中,通过哈希函数找到键 "one" 对应的存储位置,快速获取值。不同的数据结构适用于不同的场景,我们需要根据具体的需求来选择合适的数据结构,以提高程序的性能和效率。

HashMap 1.8 的扩容流程是怎样的?

在 Java 8 的 HashMap 中,扩容是一个比较重要的操作,它会在元素数量达到一定阈值时触发。当我们向 HashMap 中添加元素,并且元素的数量超过了负载因子乘以当前容量时,就会触发扩容操作。首先,HashMap 会创建一个新的数组,新数组的容量通常是原来数组容量的两倍。然后,它会将原来数组中的元素重新分配到新的数组中。

在扩容过程中,对于链表和红黑树的处理有所不同。对于链表,会将链表中的元素进行重新计算索引,并分配到新数组中。而对于红黑树,如果元素数量较少,会将红黑树转换为链表,然后再进行分配;如果元素数量仍然较多,会将红黑树中的元素重新计算索引并分配到新数组中,同时保持红黑树的结构。

以下是一个简单的代码示例,展示了 HashMap 的使用,但不会直接展示扩容操作,因为扩容操作是在内部进行的:

import java.util.HashMap;

public class HashMapExpansionExample {
    public static void main(String[] args) {
        HashMap<String, Integer> hashMap = new HashMap<>();
        hashMap.put("one", 1);
        hashMap.put("two", 2);
        hashMap.put("three", 3);
        // 不断添加元素,当元素数量超过阈值时会触发扩容
        for (int i = 4; i < 20; i++) {
            hashMap.put("key" + i, i);
        }
    }
}

在这个过程中,HashMap 使用了一种称为 resize() 的方法来完成扩容。它会遍历原数组中的每个元素,对于每个元素,根据其键的哈希值和新数组的容量重新计算在新数组中的索引。对于链表元素,会将链表中的元素依次重新分配,并且在某些情况下会将链表拆分成两个链表,一个放在新数组的原索引位置,另一个放在新数组的原索引加上旧容量的位置,这样可以减少哈希冲突,提高性能。对于红黑树元素,会将红黑树中的元素重新计算索引,并根据新的位置重新构建红黑树或链表。

值得注意的是,扩容操作会消耗一定的性能,因为需要重新计算元素的索引,并且涉及到元素的重新分配。但是 Java 8 中的优化使得扩容过程相对高效,尤其是对于红黑树的处理,减少了哈希冲突的影响。在实际使用中,如果我们能够提前预估 HashMap 中元素的数量,就可以在创建 HashMap 时设置一个合适的初始容量,以减少扩容的次数,提高性能。

ArrayList 是线程安全的吗?哪一步会导致线程不安全?

ArrayList 本身并不是线程安全的。这意味着在多线程环境下,如果多个线程同时对 ArrayList 进行操作,可能会导致一些意外的结果。

其中一个可能导致线程不安全的情况是在添加元素的时候。例如,当多个线程同时调用 add(E e) 方法时,可能会出现这样的情况:假设 ArrayList 的当前容量是 10,有两个线程 A 和 B 同时要添加元素,它们都检查到容量足够,不需要扩容。然后线程 A 开始添加元素,在添加元素的过程中,线程 B 也开始添加元素,这样可能会导致数据覆盖或者数组越界的问题。

以下是一个可能导致问题的简单代码示例:

import java.util.ArrayList;

public class ArrayListThreadUnsafe {
    public static ArrayList<Integer> arrayList = new ArrayList<>();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                arrayList.add(i);
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 1000; i < 2000; i++) {
                arrayList.add(i);
            }
        });

        thread1.start();
        thread2.start();
    }
}

在这个例子中,两个线程同时向 ArrayList 中添加元素,可能会出现数据不一致的情况。

另外,在删除元素时,也可能出现类似的问题。当多个线程同时调用 remove(int index) 方法,可能会导致元素的移动出现混乱,或者在迭代 ArrayList 的时候,一个线程正在迭代,另一个线程删除了元素,可能会导致 ConcurrentModificationException

为了避免这些问题,可以使用 Collections.synchronizedList() 方法将 ArrayList 包装成一个线程安全的列表,或者使用 CopyOnWriteArrayList,它在修改元素时会创建一个新的数组副本,避免了多线程同时修改的冲突,但会带来一定的性能开销,因为每次修改都需要复制数组。

如何删除 ArrayList 中的偶数?(不能从前到后使用 for 循环遍历 remove 删除,可以使用迭代器或从后往前遍历删除)

如果要删除 ArrayList 中的偶数,不能使用普通的从前到后的 for 循环进行删除,因为这样会导致 ConcurrentModificationException,因为在迭代过程中修改了集合的结构。

一种方法是使用迭代器来删除偶数元素。以下是使用迭代器的代码示例:

import java.util.ArrayList;
import java.util.Iterator;

public class ArrayListEvenRemoval {
    public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList<>();
        arrayList.add(1);
        arrayList.add(2);
        arrayList.add(3);
        arrayList.add(4);
        arrayList.add(5);

        Iterator<Integer> iterator = arrayList.iterator();
        while (iterator.hasNext()) {
            Integer element = iterator.next();
            if (element % 2 == 0) {
                iterator.remove();
            }
        }
    }
}

在这个代码中,我们使用 iterator() 方法获取 ArrayList 的迭代器。然后,通过 hasNext() 方法检查是否还有下一个元素,使用 next() 方法获取下一个元素。如果元素是偶数,就使用迭代器的 remove() 方法删除该元素。迭代器的 remove() 方法会确保在删除元素时不会破坏迭代过程,因为它内部会维护迭代的状态。

另一种方法是从后往前遍历删除偶数元素,代码如下:

import java.util.ArrayList;

public class ArrayListEvenRemovalBackward {
    public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList<>();
        arrayList.add(1);
        arrayList.add(2);
        arrayList.add(3);
        arrayList.add(4);
        arrayList.add(5);

        for (int i = arrayList.size() - 1; i >= 0; i--) {
            if (arrayList.get(i) % 2 == 0) {
                arrayList.remove(i);
            }
        }
    }
}

这里,我们从后往前遍历 ArrayList,使用 get(int index) 方法获取元素,如果元素是偶数,使用 remove(int index) 方法删除。这样做的好处是不会影响到前面元素的索引,避免了从前到后删除时因为元素移动而导致的索引混乱问题。

HashMap 1.8 的 get 方法流程是怎样的?

在 Java 8 的 HashMap 中,get(Object key) 方法的流程如下:

首先,它会根据键的哈希值找到对应的存储位置,也就是桶的位置。这个哈希值是通过键的 hashCode() 方法计算得到的,然后对数组的长度取模,得到一个索引。例如:

import java.util.HashMap;

public class HashMapGetExample {
    public static void main(String[] args) {
        HashMap<String, Integer> hashMap = new HashMap<>();
        hashMap.put("key", 1);
        Integer value = hashMap.get("key");
    }
}

在 hashMap.get("key"); 中,会计算 "key" 的哈希值,并找到相应的存储位置。

当找到桶的位置后,如果桶中只有一个元素,那么直接比较键是否相等。如果相等,就返回该元素的值;如果不相等,说明发生了哈希冲突。对于哈希冲突,Java 8 的 HashMap 有不同的处理方式。如果是链表,会遍历链表中的元素,使用键的 equals() 方法来比较元素是否相等,直到找到相等的元素或者遍历完链表。如果是红黑树,会使用红黑树的查找算法,在树中查找元素,时间复杂度是 O (log n)。

具体来说,它会先计算键的哈希值,然后使用 (n - 1) & hash 来计算在数组中的索引(n 是数组的长度)。然后检查该索引处的元素,如果是链表,会遍历链表,使用 equals() 方法进行比较。如果是红黑树,会调用红黑树的查找方法,利用树的结构特性快速查找元素。这样可以提高在有哈希冲突情况下的查找性能,相比 Java 7 中只使用链表的方式,性能有了很大的提升。

HashMap 的 get 方法,如果发生哈希冲突,如何找到目标 key?用什么方法比较?

当 HashMap 的 get(Object key) 方法发生哈希冲突时,HashMap 会根据不同的情况来查找目标键。

如果在存储位置上是一个链表,它会依次遍历链表中的元素,使用键的 equals() 方法来比较元素是否相等。这个 equals() 方法是在键对象的类中定义的,需要开发者根据键的特性来正确实现,以确保键的相等性判断准确。例如,如果键是自定义对象,就需要重写 equals() 方法,确保在哈希值相同的情况下,能够正确判断两个对象是否相等。

以下是一个简单的代码示例,展示了如何使用自定义对象作为键:

import java.util.HashMap;

class CustomKey {
    private int id;
    private String name;

    public CustomKey(int id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public int hashCode() {
        return id;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass()!= obj.getClass()) return false;
        CustomKey other = (CustomKey) obj;
        return id == other.id && name.equals(other.name);
    }
}

public class HashMapHashCollision {
    public static void main(String[] args) {
        HashMap<CustomKey, String> hashMap = new HashMap<>();
        CustomKey key1 = new CustomKey(1, "one");
        CustomKey key2 = new CustomKey(1, "one");
        hashMap.put(key1, "value1");
        // 这里会使用 equals 方法来查找元素
        String value = hashMap.get(key2); 
    }
}

在这个例子中,当我们使用 hashMap.get(key2); 时,会先计算 key2 的哈希值,找到存储位置,如果发生冲突,会使用 equals() 方法来比较 key1 和 key2 是否相等。

如果在存储位置上是一个红黑树,HashMap 会使用红黑树的查找算法,通过比较键的哈希值和 equals() 方法来查找元素,利用红黑树的平衡特性,提高查找效率。这样可以在 O (log n) 的时间复杂度内找到元素,而不是像链表那样在最坏情况下是 O (n)。这种处理方式在 Java 8 中优化了哈希冲突时的查找性能,使得 HashMap 在存储大量元素时,性能更加稳定和高效。

ArrayList 的容量和扩容机制是怎样的,扩容是深拷贝还是浅拷贝?

ArrayList 是 Java 中一个非常常用的集合类,它是基于数组实现的动态数组。关于它的容量,当你创建一个 ArrayList 时,如果不指定初始容量,它会使用一个默认的初始容量,通常是 10。当向 ArrayList 中添加元素时,如果元素的数量达到了当前的容量,ArrayList 就需要进行扩容操作。

扩容的过程是这样的:ArrayList 会创建一个新的数组,这个新数组的容量一般是原数组容量的 1.5 倍(在不同的 Java 版本中可能会有些许差异,但大致是这个倍数)。然后,将原数组中的元素复制到新的数组中。这个复制操作使用的是 System.arraycopy() 方法,它会将原数组的元素复制到新数组的相应位置,复制的是元素的引用而不是元素本身。所以从拷贝类型来看,ArrayList 的扩容是浅拷贝。

让我们来看一个简单的代码示例,以便更好地理解这个过程:

import java.util.ArrayList;

public class ArrayListCapacityExample {
    public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            arrayList.add(i);
        }
    }
}

在上述代码中,当添加元素的数量超过初始容量时,ArrayList 就会自动扩容。例如,在添加第 11 个元素时,它会创建一个新的更大的数组,把原数组中的元素复制过去。这里的复制只是复制了元素的引用,假设原数组中的元素是对象,那么新数组中的元素和原数组中的元素都指向相同的对象,这就是浅拷贝。

对于性能方面的影响,由于扩容需要创建新数组和复制元素,当元素数量较多时,会带来一定的性能开销。所以,如果你事先知道要存储的元素数量大概范围,最好在创建 ArrayList 时就指定一个合适的初始容量,这样可以减少扩容的次数,提高性能。例如:

ArrayList<Integer> arrayList = new ArrayList<>(50);

这样创建的 ArrayList 初始容量为 50,当添加元素数量不超过 50 时,不会触发扩容操作。而且,浅拷贝的特性也意味着如果你修改了原数组或新数组中元素所引用的对象,会影响到另一个数组中的元素,因为它们指向的是同一个对象。在存储基本数据类型时,因为基本数据类型存储的是值本身,不存在这个问题,但存储对象时需要注意这个特性。

讲讲 List 的底层数据结构。

List 是 Java 集合框架中的一个接口,它有多个实现类,不同的实现类底层数据结构不同。

首先,ArrayList 是 List 的一个重要实现类,它的底层是数组。数组是一种连续的内存存储结构,能够存储相同类型的元素。ArrayList 利用数组的特性,提供了随机访问元素的能力,时间复杂度为 O (1)。例如,你可以使用 get(int index) 方法快速获取指定索引的元素:

import java.util.ArrayList;

public class ArrayListExample {
    public static void main(String[] args) {
        ArrayList<String> arrayList = new ArrayList<>();
        arrayList.add("Hello");
        arrayList.add("World");
        String element = arrayList.get(0);
    }
}

在这个代码中,arrayList.get(0) 能够快速定位到索引为 0 的元素,就是因为底层数组的特性。

其次,LinkedList 也是 List 的实现类,它的底层是双向链表。每个节点包含了存储的数据元素,以及指向前一个节点和后一个节点的引用。这种数据结构在添加和删除元素时具有优势,特别是在列表的头部或尾部添加或删除元素时,只需要修改前后节点的指针,时间复杂度为 O (1)。例如:

import java.util.LinkedList;

public class LinkedListExample {
    public static void main(String[] args) {
        LinkedList<String> linkedList = new LinkedList<>();
        linkedList.addFirst("First");
        linkedList.addLast("Last");
    }
}

在 linkedList.addFirst("First"); 操作中,只需要修改几个指针,就能完成元素的添加。

另外,还有 Vector 类,它和 ArrayList 类似,也是基于数组实现的,但是 Vector 是线程安全的,因为它的许多方法都使用了 synchronized 关键字。不过,这也导致它在性能上可能会稍逊一筹,因为同步操作会带来额外的开销。

总的来说,List 接口的实现类根据不同的底层数据结构,提供了不同的性能特点,我们可以根据实际需求选择合适的实现类。如果需要频繁的随机访问元素,ArrayList 是个不错的选择;如果经常在列表的头部或尾部进行添加或删除操作,LinkedList 可能更合适;而在多线程环境中,需要线程安全的列表时,可能会考虑使用 Vector。

请介绍 List 和 Set 的区别,以及它们的使用场景。

List 和 Set 都是 Java 集合框架中的接口,但它们在功能和特性上有很大的区别。

List 是一个有序的集合,允许存储重复元素,并且可以通过索引来访问元素。这意味着你可以在 List 中存储多个相同的元素,而且可以使用 get(int index) 方法来获取指定索引位置的元素。例如:

import java.util.ArrayList;
import java.util.List;

public class ListExample {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("Apple");
        list.add("Banana");
        list.add("Apple");
        System.out.println(list.get(0));
    }
}

在这个例子中,我们可以看到 "Apple" 被添加了两次,并且可以通过 get(0) 来获取第一个元素。

Set 是一个不允许存储重复元素的集合,它主要用于存储不重复的元素集合。当你添加元素到 Set 中时,它会检查元素是否已经存在,如果存在,不会添加重复元素。而且 Set 通常不提供通过索引访问元素的功能,因为元素的存储顺序是不保证的,取决于具体的实现。例如:

import java.util.HashSet;
import java.util.Set;

public class SetExample {
    public static void main(String[] args) {
        Set<String> set = new HashSet<>();
        set.add("Apple");
        set.add("Banana");
        set.add("Apple");
        System.out.println(set);
    }
}

在这个代码中,最终输出的 Set 中只会有 "Apple" 和 "Banana" 各一个,不会出现重复元素。

使用场景方面,如果你需要存储一组有序的元素,并且允许元素重复,比如存储用户的购物清单,使用 List 是比较合适的,因为你可能需要按照添加的顺序来处理元素,或者通过索引来访问元素。而如果需要存储一组唯一的元素,比如存储用户的唯一标识符,Set 就更合适,它可以帮助你自动去除重复元素。

另外,由于 List 可以通过索引访问元素,它在需要频繁查找、修改和插入元素的场景下更方便;而 Set 更注重元素的唯一性,在进行元素的唯一性检查、去重操作时很有用。例如,如果你要存储一个班级学生的学号,使用 Set 可以确保学号不重复;如果要存储学生的成绩列表,并且可能需要按照添加顺序处理成绩,List 会是更好的选择。

如果 Set 中是对象,如何去重?

当 Set 中存储的是对象时,去重的关键在于对象的 equals() 和 hashCode() 方法。因为 Set 是根据这两个方法来判断元素是否重复的。

首先,对于自定义对象,你需要重写 equals() 方法,以定义什么情况下两个对象被认为是相等的。同时,你也需要重写 hashCode() 方法,确保相等的对象具有相同的哈希值。这样,当你向 Set 中添加对象时,Set 会根据这两个方法来判断是否已经存在相同的对象。

以下是一个示例代码:

import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass()!= o.getClass()) return false;
        Person person = (Person) o;
        return age == person.age && Objects.equals(name, person.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

public class SetObjectDuplication {
    public static void main(String[] args) {
        Set<Person> personSet = new HashSet<>();
        personSet.add(new Person("Alice", 25));
        personSet.add(new Person("Bob", 30));
        personSet.add(new Person("Alice", 25));
    }
}

在这个例子中,Person 类重写了 equals() 和 hashCode() 方法。当我们添加 new Person("Alice", 25) 两次时,由于 equals() 方法会比较对象的 name 和 age 属性,并且 hashCode() 会根据这两个属性计算哈希值,所以第二次添加时,Set 会认为该对象已经存在,不会重复添加。

这样做的好处是,无论对象的属性如何,只要你正确重写了 equals() 和 hashCode() 方法,Set 就能正确地去重。需要注意的是,hashCode() 方法的实现要保证相等的对象具有相同的哈希值,而不同的对象尽量有不同的哈希值,以提高性能,避免过多的哈希冲突。同时,在多线程环境下,可能需要考虑使用线程安全的 Set 实现,如 ConcurrentSkipListSet 或使用 Collections.synchronizedSet() 包装普通的 Set。

Java 集合中 HashSet 底层是什么?说说 TreeSet 和 LinkedHashSet 的区别。

HashSet 是 Java 集合中基于 HashMap 实现的一个集合类,它主要利用 HashMap 的键存储元素,而值则是一个固定的对象,通常是一个静态的 Object 实例,因为 HashSet 只关心键的唯一性,不关心值。

HashSet 的底层是通过 HashMap 来存储元素,当你向 HashSet 中添加元素时,实际上是将元素作为 HashMap 的键添加,值是一个虚拟的值。这样,利用 HashMap 键的唯一性保证了 HashSet 中元素的唯一性。例如:

import java.util.HashSet;

public class HashSetExample {
    public static void main(String[] args) {
        HashSet<String> hashSet = new HashSet<>();
        hashSet.add("One");
        hashSet.add("Two");
    }
}

在这个代码中,添加元素时,实际上是将元素存储在 HashMap 的键中,以确保元素的唯一性。

TreeSet 是一个基于 TreeMap 的有序集合,它会根据元素的自然顺序或者提供的比较器对元素进行排序。例如:

import java.util.TreeSet;

public class TreeSetExample {
    public static void main(String[] args) {
        TreeSet<String> treeSet = new TreeSet<>();
        treeSet.add("Banana");
        treeSet.add("Apple");
        System.out.println(treeSet);
    }
}

在这个例子中,输出的 TreeSet 元素会按照字母顺序排序,因为 String 类实现了 Comparable 接口,TreeSet 会根据元素的比较结果进行排序。如果存储的是自定义对象,需要实现 Comparable 接口或在创建 TreeSet 时提供一个比较器。

LinkedHashSet 是 HashSet 的一个子类,它继承了 HashSet 的元素唯一性,同时还保持元素添加的顺序。它是基于 LinkedHashMap 实现的,在内部使用链表来维护元素的插入顺序。例如:

import java.util.LinkedHashSet;

public class LinkedHashSetExample {
    public static void main(String[] args) {
        LinkedHashSet<String> linkedHashSet = new LinkedHashSet<>();
        linkedHashSet.add("First");
        linkedHashSet.add("Second");
        linkedHashSet.add("Third");
    }
}

在这个代码中,元素的顺序会和添加顺序一致,而不像 HashSet 那样无序。

区别在于,TreeSet 注重元素的排序,适合需要元素有序存储的场景;LinkedHashSet 则侧重于元素的插入顺序,当你希望保留元素的添加顺序同时保证元素的唯一性时,它是一个不错的选择;而 HashSet 只关心元素的唯一性,对元素的顺序不做保证,适用于只需要元素不重复的场景,并且由于它是基于 HashMap 实现,在性能上可能会比 TreeSet 更好,因为不需要进行排序操作。

Set.contains () 方法的时间复杂度是多少?

Set 是 Java 集合框架中的一个接口,不同的 Set 实现类的 contains() 方法的时间复杂度会有所不同。一般来说,对于 HashSetcontains() 方法的平均时间复杂度是 。这是因为 HashSet 是基于 HashMap 来实现的,它使用元素的 hashCode() 来定位元素所在的存储位置(桶),如果元素在桶中,就能很快找到;如果不在,也能快速确定。

例如,我们来看下面这个 HashSet 的简单使用:

import java.util.HashSet;

public class HashSetContainsExample {
    public static void main(String[] args) {
        HashSet<String> set = new HashSet<>();
        set.add("apple");
        set.add("banana");
        set.add("cherry");
        boolean containsApple = set.contains("apple");
    }
}

在 set.contains("apple"); 这一步中,HashSet 会先计算 "apple" 的 hashCode,然后根据 hashCode 找到相应的存储位置。由于哈希函数的均匀分布特性,大多数情况下,元素可以在  的时间内找到。然而,在最坏的情况下,如果出现大量的哈希冲突,导致元素都存储在一个桶中,变成了一个链表,那么 contains() 操作可能会退化成 ,不过这种情况在实际应用中比较少见,因为好的哈希函数会尽量避免这种情况。

对于 TreeSetcontains() 方法的时间复杂度是 。TreeSet 是基于 TreeMap 实现的,它使用红黑树存储元素,利用红黑树的自平衡特性和有序性,在查找元素时,会根据元素的大小比较(通过元素的 compareTo() 或自定义的比较器)进行查找,类似于二分查找,所以时间复杂度是 。比如:

import java.util.TreeSet;

public class TreeSetContainsExample {
    public static void main(String[] args) {
        TreeSet<String> set = new TreeSet<>();
        set.add("apple");
        set.add("banana");
        set.add("cherry");
        boolean containsApple = set.contains("apple");
    }
}

这里,当调用 set.contains("apple"); 时,会从根节点开始,比较元素大小,不断向左或向右移动,直到找到元素或确定元素不存在,这个过程类似于二分查找,因此是 。

对于 LinkedHashSet,它的 contains() 方法的时间复杂度也是  ,因为它是基于 LinkedHashMap 实现的,在查找元素时,首先通过哈希函数找到存储位置,再在链表中查找元素,虽然比 HashSet 多了链表查找的步骤,但整体上还是可以近似看作 ,因为链表的查找通常是非常快速的。

在实际使用中,我们需要根据不同的场景选择合适的 Set 实现。如果需要快速查找元素,并且元素无序,HashSet 是一个不错的选择;如果需要元素有序存储且查找性能较好,TreeSet 更合适;而 LinkedHashSet 则适用于需要元素顺序与插入顺序一致的场景。

请介绍 HashMap,包括它的底层实现,它和 HashTable 的区别,它是否线程安全,不安全时怎么办,HashMap 是否一直使用红黑树?

HashMap 是 Java 集合框架中一个非常重要的数据结构,它实现了 Map 接口,用于存储键值对。

底层实现:HashMap 的底层是一个数组,数组的每个元素是一个链表(在 Java 8 中,当链表长度超过一定阈值会转换为红黑树)。这个数组被称为 “桶数组”,每个元素存储的是一个链表或红黑树的头节点。当我们向 HashMap 中添加键值对时,会根据键的 hashCode() 计算哈希值,然后通过哈希值对数组长度取模得到存储位置。如果这个位置上已经有元素,就形成链表或红黑树,存储多个元素。

例如,以下是一个简单的 HashMap 使用:

import java.util.HashMap;

public class HashMapExample {
    public static void main(String[] args) {
        HashMap<String, Integer> map = new HashMap<>();
        map.put("one", 1);
        map.put("two", 2);
    }
}

在 map.put("one", 1); 这一步,首先计算 "one" 的 hashCode,然后找到存储位置,存储键值对。

与 HashTable 的区别

  • 线程安全:HashMap 不是线程安全的,而 HashTable 是线程安全的,因为 HashTable 的方法基本都被 synchronized 修饰。但这也导致 HashTable 的性能相对较低,因为同步操作会带来额外的开销。
  • 继承关系:HashMap 继承自 AbstractMap,而 HashTable 继承自 Dictionary
  • 允许 null 键和值:HashMap 允许一个 null 键和多个 null 值,而 HashTable 不允许 null 键和值。

线程安全问题及解决办法:如果在多线程环境下使用 HashMap,可能会导致数据不一致的问题,例如多个线程同时修改 HashMap 时可能会出现并发修改异常。可以使用 ConcurrentHashMap 来代替 HashMap,它是一个高效的线程安全的 Map 实现,使用了分段锁机制,允许并发的读操作,提高了性能。也可以使用 Collections.synchronizedMap() 对 HashMap 进行包装,但性能会有所下降。

是否一直使用红黑树:HashMap 并不一直使用红黑树,只有当某个桶中的元素数量达到一定阈值(默认为 8),并且数组长度达到一定大小(默认为 64)时,才会将链表转换为红黑树,这样可以提高查找、插入和删除的性能。

HashMap 底层为什么使用红黑树而不是二叉搜索树?

HashMap 在 Java 8 中引入了红黑树,而不是使用普通的二叉搜索树,这是出于性能和平衡的考虑。

首先,二叉搜索树(BST)在最坏情况下会退化成链表,当插入元素顺序是有序的时候,BST 的高度会变得很大,查找、插入和删除操作的时间复杂度会退化成 。例如,如果我们依次插入 1, 2, 3, 4, 5 到一个基于 BST 的 HashMap 中,BST 会变成一个高度为  的链表。

而红黑树是一种自平衡的二叉搜索树,它具有以下优点:

  • 平衡特性:红黑树通过一些颜色规则和旋转操作,保证树的高度始终保持在 ,避免了 BST 的最坏情况。这使得在红黑树中的查找、插入和删除操作的时间复杂度都能稳定在 ,提高了性能。
  • 性能稳定:即使在动态插入和删除元素的情况下,红黑树可以通过旋转和重新着色操作,快速调整树的结构,保持平衡,从而保证性能的稳定性。

例如,假设我们有大量元素存储在 HashMap 中,如果使用 BST,一旦出现不平衡的情况,性能会急剧下降;而使用红黑树,无论元素如何插入或删除,它都能保持良好的平衡,不会出现性能的大幅波动。

// 这里没有具体代码,但可以想象一个 HashMap 存储大量元素的场景
HashMap<String, Integer> map = new HashMap<>();
for (int i = 0; i < 10000; i++) {
    map.put("key" + i, i);
}

在这个场景中,如果使用 BST,当插入顺序不理想时,性能会很差;但使用红黑树,性能会更加稳定。

另外,红黑树的平衡调整操作相对比较简单和高效,它在保持树的平衡的同时,不会带来过多的额外开销,因此在需要存储大量元素并且可能频繁插入、删除元素的情况下,使用红黑树能保证 HashMap 的性能表现更好,避免了 BST 的性能陷阱。

HashMap 和 TreeMap 的区别是什么?TreeMap 按什么排序?

区别

  • 底层结构:HashMap 的底层是数组加链表(或红黑树),而 TreeMap 的底层是红黑树。
  • 元素顺序:HashMap 不保证元素的顺序,元素存储的位置取决于键的 hashCode(),而 TreeMap 是一个有序的 Map,它会根据键的自然顺序或者自定义的比较器对元素进行排序。
  • 性能特点:HashMap 的查找、插入和删除操作在平均情况下是 ,但在哈希冲突严重时,链表转换为红黑树之前,可能会退化成 ;TreeMap 的操作性能是 ,因为它总是使用红黑树,性能稳定。

以下是一个简单的 TreeMap 示例:

import java.util.TreeMap;

public class TreeMapExample {
    public static void main(String[] args) {
        TreeMap<String, Integer> treeMap = new TreeMap<>();
        treeMap.put("banana", 3);
        treeMap.put("apple", 1);
        treeMap.put("cherry", 2);
    }
}

在这个 TreeMap 中,元素会按照键的自然顺序(对于字符串,是按照字典序)存储,输出会是 "apple", "banana", "cherry" 的顺序。

排序依据:TreeMap 按键的自然顺序进行排序,如果键实现了 Comparable 接口,会使用 compareTo() 方法进行比较。例如,对于 String 键,它会根据字符串的字典序排序。如果键没有实现 Comparable 接口,可以在创建 TreeMap 时提供一个 Comparator 来指定排序规则,如下:

import java.util.Comparator;
import java.util.TreeMap;

public class TreeMapWithComparatorExample {
    public static void main(String[] args) {
        TreeMap<String, Integer> treeMap = new TreeMap<>(new Comparator<String>() {
            @Override
            public int compare(String o1, String o2) {
                return o2.compareTo(o1); // 倒序排序
            }
        });
        treeMap.put("banana", 3);
        treeMap.put("apple", 1);
        treeMap.put("cherry", 2);
    }
}

在这个例子中,我们使用自定义的比较器实现了键的倒序排序。

在实际应用中,如果需要存储键值对且不关心元素顺序,HashMap 是一个不错的选择,因为它在平均情况下性能更好;如果需要对键值对按照键的顺序进行排序,TreeMap 更合适,例如存储学生成绩,按照成绩排序,或者存储日志,按照时间排序等场景。

HashMap 1.7 和 1.8 的优化是什么?HashMap 的底层结构(1.8)是什么?put 方法流程是怎样的?什么情况下需要扩容?为什么要把链表转换为红黑树?H

HashMap 1.7 到 1.8 的优化

  • 底层结构优化:在 Java 7 中,HashMap 仅使用链表来解决哈希冲突,当冲突元素较多时,性能会下降。Java 8 引入了红黑树,当链表长度超过 8 且数组长度达到 64 时,将链表转换为红黑树,提高了性能。
  • 扩容机制优化:Java 8 在扩容时,会将链表拆分成高位和低位两部分,减少了元素的重新哈希操作,提高了扩容的效率。

底层结构(1.8):HashMap 的底层是一个数组,数组元素可以是链表或红黑树。每个数组元素称为一个桶,存储着键值对。当添加元素时,会根据键的 hashCode() 计算哈希值,然后对数组长度取模得到存储位置。

put 方法流程

  1. 首先计算键的 hashCode(),然后通过 (n - 1) & hash 计算在数组中的索引(n 是数组长度)。
  2. 若该位置没有元素,直接插入;若有元素,检查是否相等,相等则更新值;若不相等,判断是链表还是红黑树,若是链表,遍历链表,若不存在则添加到末尾,若存在则更新;若是红黑树,使用红黑树的插入操作。

扩容情况:当元素数量超过负载因子乘以数组长度时,会触发扩容。负载因子通常是 0.75,这是一个权衡空间和时间的选择。例如,如果数组长度是 16,元素数量达到 12()时,会进行扩容操作。

链表转换为红黑树的原因:当链表长度过长时,查找元素的时间复杂度是 ,性能下降。红黑树的时间复杂度是 ,将链表转换为红黑树可以提高性能,尤其是在元素较多的情况下。

HashMap 如何实现,其扩容机制是什么?进行数组位移操作时(hash & table.length)为 1 时,是否需要加上 oldTable.length?put 的流程是什么?什么时候线程不安全?其底层结构是什么?

HashMap 的实现
HashMap 是 Java 集合框架中用于存储键值对的重要数据结构。它的实现主要基于数组和链表(或红黑树)。在 HashMap 中,存储元素的数组被称为 “桶数组”,每个桶可以存储一个或多个元素。当我们向 HashMap 中添加键值对时,首先会根据键的 hashCode() 方法计算出一个哈希值,然后通过某种算法将这个哈希值映射到桶数组的一个索引位置。如果多个键映射到了同一个索引位置,就会在该位置形成一个链表(在 JDK 1.8 中,当链表长度超过一定阈值时会转换为红黑树)。

扩容机制
当 HashMap 中的元素数量达到负载因子乘以当前桶数组的长度时,就会触发扩容操作。扩容时,会创建一个新的桶数组,其容量通常是原数组的两倍。然后,将原数组中的元素重新分配到新数组中。这个过程涉及到元素的重新哈希,因为元素在新数组中的位置是根据其哈希值和新数组的长度计算得到的。在重新分配元素时,会使用 (e.hash & (newCap - 1)) 来计算元素在新数组中的索引。对于数组位移操作,如果 (hash & table.length) 为 1,是不需要加上 oldTable.length 的,因为这是通过位运算来确定元素在新数组中的位置,根据新数组的长度和元素的哈希值就能准确映射,不需要额外添加 oldTable.length

以下是一个简单的代码示例,展示了 HashMap 的使用:

import java.util.HashMap;

public class HashMapExample {
    public static void main(String[] args) {
        HashMap<String, Integer> map = new HashMap<>();
        map.put("key1", 1);
        map.put("key2", 2);
    }
}

put 流程
当调用 put(K key, V value) 方法时,首先会计算键的哈希值,通过 (n - 1) & hash 计算出在桶数组中的索引(n 是当前桶数组的长度)。如果该索引位置没有元素,直接将键值对放入该位置;如果已有元素,会检查该元素的键是否与要插入的键相等,相等则更新值,不相等则判断是链表还是红黑树。如果是链表,会遍历链表,若不存在相同键则添加到末尾,若存在则更新;如果是红黑树,会使用红黑树的插入操作。

线程不安全的情况
HashMap 是非线程安全的。当多个线程同时对 HashMap 进行操作时,可能会出现问题。例如,在扩容时,多个线程同时修改内部结构,可能导致数据丢失或数据不一致。比如,一个线程正在将元素从旧数组复制到新数组,另一个线程插入元素,可能导致元素丢失或链表成环等问题。

底层结构
HashMap 的底层是一个桶数组,数组元素可以是链表或红黑树。初始时,数组是空的,当添加元素时会根据需要进行扩容。对于存储键值对的节点,它包含了键、值、哈希值和指向下一个节点的指针(在链表情况下)或树节点的指针(在红黑树情况下)。

Hashtable、HashMap、ConcurrentHashMap 的实现原理、底层结构、性能差异原因分别是什么?ConcurrentHashMap 如何保证线程安全?put 和 map 加锁时是怎么操作的(详细讲解)?

Hashtable 的实现原理和底层结构
Hashtable 是一个早期的哈希表实现,它的底层结构也是一个数组,每个数组元素存储一个链表。它使用键的 hashCode() 计算哈希值,并通过取模运算得到存储位置。Hashtable 的主要特点是它的方法基本都被 synchronized 关键字修饰,所以是线程安全的。但这也导致了性能问题,因为多个线程访问时会频繁进行同步操作,降低了并发性能。

HashMap 的实现原理和底层结构
HashMap 的实现原理前面已经讲过,它的底层结构是数组加链表(或红黑树)。它使用键的 hashCode() 计算哈希值,并通过 (n - 1) & hash 确定存储位置。它是非线程安全的,因为它没有同步机制,所以在多线程环境下可能会出现数据不一致的问题。

ConcurrentHashMap 的实现原理和底层结构
ConcurrentHashMap 在 JDK 1.8 中的实现有很大改进。它的底层结构仍然是数组加链表(或红黑树),但它采用了分段锁机制来保证线程安全。它将数组分成多个段(段可以理解为一部分桶),不同的线程可以同时操作不同的段,提高了并发性能。例如,多个线程可以同时向不同的段中添加元素,而不会互相阻塞。

性能差异原因

  • Hashtable 由于使用全局锁,性能较差,因为多个线程同时操作时需要排队等待。
  • HashMap 由于没有锁,在单线程环境下性能较好,但在多线程环境下可能出现数据不一致。
  • ConcurrentHashMap 通过分段锁,允许多个线程同时操作不同段,提高了并发性能,减少了线程竞争,所以在多线程环境下性能较好。

ConcurrentHashMap 的线程安全保证
在 JDK 1.8 的 ConcurrentHashMap 中,当进行 put 操作时,它会根据键的哈希值找到对应的桶,如果桶是空的,会使用 CAS(Compare and Swap)操作来尝试添加元素,避免使用锁。如果桶不为空,会根据情况使用 synchronized 锁来保证操作的线程安全。在 put 操作中,它会先计算键的哈希值,找到对应的段或桶,然后根据情况使用不同的同步机制,如 CAS 或锁,来保证线程安全。例如:

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapExample {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
        map.put("key1", 1);
    }
}

对于 put 操作的加锁细节,当向 ConcurrentHashMap 中添加元素时,首先会根据键的哈希值找到相应的段或桶,如果该段或桶的元素较少,会尝试使用 CAS 操作添加元素,避免锁开销;如果元素较多或发生冲突,会使用 synchronized 锁来确保操作的一致性,防止多个线程同时修改同一部分元素。

说一下 HashMap 和 TreeMap 的区别以及底层实现。

底层实现

  • HashMap:它的底层是一个数组,数组元素可以是链表或红黑树。通过键的 hashCode() 计算哈希值,再通过 (n - 1) & hash 确定存储位置。当元素添加到同一位置时,形成链表,当链表长度超过一定阈值且数组长度达到一定大小,链表会转换为红黑树。
  • TreeMap:它的底层是红黑树。它会根据键的自然顺序或自定义的比较器对元素进行排序。例如,对于实现了 Comparable 接口的键,会使用 compareTo() 方法进行比较,对于自定义比较器,会使用提供的比较器进行元素排序。

区别

  • 元素顺序:HashMap 不保证元素的存储顺序,元素的位置取决于键的哈希值。而 TreeMap 是一个有序的映射,会根据键的顺序存储元素。
  • 性能
    • HashMap 在查找、插入和删除元素时,平均时间复杂度是 ,但在哈希冲突严重时,可能退化成 (在链表情况下)。
    • TreeMap 的查找、插入和删除操作时间复杂度是 ,因为它始终使用红黑树,性能稳定。
  • 使用场景
    • HashMap 适用于不需要元素有序存储,只需要快速查找、插入和删除元素的场景,例如存储用户信息,通过用户 ID 快速查找用户信息。
    • TreeMap 适用于需要元素有序存储的场景,例如存储一些需要排序的数据,像存储学生成绩并按分数排序,或者存储时间戳并按时间顺序排序。

以下是一个 TreeMap 的简单示例:

import java.util.TreeMap;

public class TreeMapExample {
    public static void main(String[] args) {
        TreeMap<String, Integer> treeMap = new TreeMap<>();
        treeMap.put("apple", 1);
        treeMap.put("banana", 2);
    }
}

在这个 TreeMap 中,元素会根据键的自然顺序(对于字符串,是按照字典序)存储,输出会是有序的。

HashMap 非线程安全,1.8 改用尾插法的原因是什么?

在 JDK 1.7 及以前,HashMap 在扩容时使用头插法。当多个线程同时进行扩容操作时,可能会导致链表成环的问题。因为在头插法中,元素的插入顺序会发生颠倒,在多线程环境下,可能会导致链表的指针指向混乱,形成环,进而导致死循环。

在 JDK 1.8 中,HashMap 改用尾插法。尾插法的好处是在扩容时,会将元素按照原有的顺序依次添加到新数组中,避免了元素顺序的颠倒,从而避免了链表成环的问题。这样可以提高 HashMap 在多线程环境下的稳定性,虽然 HashMap 本身仍然是非线程安全的,但至少避免了严重的死循环问题。

假设我们在扩容时有两个线程同时操作,使用头插法可能会出现这样的情况:
线程 A 正在将旧数组中的元素移动到新数组,线程 B 也开始操作,它们会改变元素的插入顺序,导致链表的指针指向混乱。而使用尾插法,无论多个线程如何操作,元素会依次添加到新数组的末尾,不会出现指针混乱的情况。

以下是一个简单的扩容过程中可能出现问题的示意:

// 这里不展示完整代码,只是示意
// 在 JDK 1.7 中
Node<K,V> newNode = new Node<>(hash, key, value, null);
if (e.next == null)
    newTab[j] = newNode;
else
    oldFirst.next = newNode;
newNode.next = e;
// 在 JDK 1.8 中
Node<K,V> loTail = null, hiTail = null;
// 尾插法的部分代码,将元素添加到末尾
if (loTail == null)
    newTab[j] = loHead;
else
    loTail.next = loHead;

通过使用尾插法,HashMap 在多线程环境下虽然不能保证线程安全,但至少避免了链表成环这样的严重问题,使得在一些并发场景下,即使出现并发操作,也不会导致程序陷入死循环,提高了 HashMap 的健壮性。

HashMap 原理,JDK 1.7 之前为什么会成环?0.75 负载因子的原因是什么?

HashMap 原理
在 JDK 1.7 之前,HashMap 的底层是一个数组,数组元素存储的是链表。当添加元素时,会根据键的 hashCode() 计算哈希值,然后对数组长度取模得到存储位置。如果多个元素的哈希值映射到同一个位置,就会形成链表。当查找元素时,会先根据哈希值找到位置,再遍历链表找到元素。

JDK 1.7 之前成环的原因
在扩容时,使用头插法将元素从旧数组转移到新数组。当多个线程同时进行扩容操作时,可能会出现问题。例如,线程 A 正在转移元素,线程 B 也开始转移,它们会共享同一个链表,由于头插法会改变元素的顺序,可能会导致元素的 next 指针形成环。具体来说,线程 A 可能会将元素 A 的 next 指针指向元素 B,而线程 B 可能会将元素 B 的 next 指针指向元素 A,这样就形成了一个环。当遍历链表时,会陷入死循环,导致程序无法正常工作。

0.75 负载因子的原因
负载因子是一个权衡空间和时间的因素。如果负载因子过大,例如设为 1,那么数组会被填满才会扩容,这会导致大量的哈希冲突,因为哈希表中的元素会很拥挤,链表会变得很长,查找元素的时间复杂度会接近 ,性能下降。如果负载因子过小,例如设为 0.5,那么数组会经常扩容,会浪费很多空间,因为很多空间没有被充分利用。而 0.75 是一个折中的选择,既可以避免大量的哈希冲突,又能较好地利用空间。在实际应用中,这个负载因子可以根据具体情况调整,但一般情况下,0.75 是一个经过实践验证的较为合适的值。

例如,假设我们有一个 HashMap 存储元素,如果负载因子为 0.75,当元素数量达到数组长度的 0.75 倍时,就会触发扩容,这样可以在空间和时间性能之间取得一个较好的平衡,保证 HashMap 的性能不会因为哈希冲突过多而下降,也不会因为频繁扩容而浪费太多空间。

HashMap 的结构,如果发生哈希冲突了怎么办?

HashMap 的结构主要由一个数组和链表(或红黑树)组成。它会根据键的 hashCode() 方法计算哈希值,然后将这个哈希值映射到数组的某个位置,这个位置被称为 “桶”。当不同的键计算出的哈希值映射到了同一个桶时,就会发生哈希冲突。

当发生哈希冲突时,在 JDK 1.8 及以后的版本中,会采取以下处理方式:

  • 首先,如果该桶中的元素数量较少,就会以链表的形式存储这些元素。新元素会被添加到链表的末尾。例如,假设我们有两个键 key1 和 key2 发生了哈希冲突,它们会存储在同一个桶中,并且以链表的形式依次存储。这样,当我们查找元素时,会先根据哈希值找到对应的桶,然后遍历链表,使用 equals() 方法来比较键是否相等,从而找到目标元素。以下是一个简单的示例:

import java.util.HashMap;

public class HashMapHashCollisionExample {
    public static void main(String[] args) {
        HashMap<String, Integer> map = new HashMap<>();
        map.put("key1", 1);
        map.put("key2", 2);
    }
}

在这个例子中,如果 key1 和 key2 发生哈希冲突,它们会存储在同一个桶中,形成链表。

  • 其次,如果某个桶中的元素数量超过了一定的阈值(默认为 8),并且数组的长度达到了一定的长度(默认为 64),这个链表会被转换为红黑树。

在实际应用中,哈希冲突是不可避免的,因为哈希函数无法保证不同的键永远映射到不同的桶。但是,通过合理的哈希函数和冲突解决机制,HashMap 可以尽量减少冲突的发生,并且在发生冲突时能够有效地处理。而且,随着元素的添加和删除,当某个红黑树中的元素数量减少到一定程度时,它会被重新转换为链表,以节省空间,因为红黑树存储节点需要更多的额外空间来维护树的结构。

已知有代码 HashMap get 方法,它的时间复杂度是多少?

HashMap 的 get 方法的时间复杂度通常是 ,但在最坏的情况下可能会退化为 。

一般情况下,当调用 get(Object key) 方法时,HashMap 会先根据键的 hashCode() 计算哈希值,然后使用这个哈希值对数组的长度进行取模运算(在 JDK 1.8 中使用 (n - 1) & hash 的位运算),找到对应的桶。如果该桶中只有一个元素,那么可以直接找到目标元素,时间复杂度为 。例如:

import java.util.HashMap;

public class HashMapGetExample {
    public static void main(String[] args) {
        HashMap<String, Integer> map = new HashMap<>();
        map.put("key1", 1);
        map.get("key1");
    }
}

在这个例子中,使用 map.get("key1"); 时,会快速定位到存储位置并找到元素。

然而,当发生哈希冲突,且冲突元素存储在链表中时,如果链表较长,就需要遍历链表,此时时间复杂度会变为 。但在 JDK 1.8 中,当链表长度超过 8 且数组长度达到 64 时,链表会转换为红黑树,这样在发生哈希冲突时,使用红黑树进行查找,时间复杂度会变成 。所以,在理想情况下,由于哈希函数的良好分布,大部分元素可以在  的时间内找到,但如果哈希函数设计不佳,或者元素分布不均匀,可能会导致较多的哈希冲突,使得查找性能下降。

总体而言,为了保证 HashMap 的 get 方法性能,需要一个好的哈希函数,尽量将元素均匀分布在不同的桶中,避免过多的哈希冲突,这样才能使 get 方法接近  的时间复杂度。

看过 HashMap 的源码,除了 resize 之外,还有哪些设计给你留下深刻印象?如果 hashCode 和 equals 不一起重写,会有什么问题(从业务角度来说)?

印象深刻的设计

  • 哈希函数的设计:HashMap 中的哈希函数是一个非常重要的部分,它会将键的 hashCode() 进行一系列的扰动处理,以使得元素能够更均匀地分布在不同的桶中。这种扰动处理能够减少哈希冲突,提高 HashMap 的性能。例如,在 JDK 1.8 中,使用了 (h = key.hashCode()) ^ (h >>> 16) 这样的扰动函数,将哈希值的高 16 位和低 16 位进行异或操作,使得即使是低质量的 hashCode() 函数也能在 HashMap 中得到较好的分布效果。
  • 链表和红黑树的转换机制:当元素数量在桶中达到一定阈值时,会将链表转换为红黑树,这是一个很巧妙的设计。这样可以在元素较多时提高性能,因为红黑树的查找、插入和删除操作的时间复杂度是 ,而链表的时间复杂度是 。当元素数量减少时,又会将红黑树转换为链表,以节省空间,这种动态转换机制体现了对性能和空间的平衡考虑。
  • 元素存储的结构设计:使用数组存储元素,每个元素可能是链表或红黑树,这样的结构既利用了数组的直接寻址优势,又通过链表和红黑树解决了哈希冲突,是一种高效的存储方式。

不重写 hashCode 和 equals 的问题
从业务角度来看,如果只重写 equals() 方法而不重写 hashCode() 方法,或者反之,会导致严重的问题。例如,假设我们有一个存储用户对象的 HashMap,并且根据用户的 ID 作为键。如果不重写 hashCode() 方法,不同的用户对象可能会计算出相同的哈希值,即使它们的 ID 不同,因为默认的 hashCode() 方法是基于对象的内存地址。这会导致哈希冲突增多,性能下降。如果只重写 hashCode() 而不重写 equals(),可能会出现两个对象的 hashCode() 相等,但在 equals() 比较时却认为它们不相等,这会导致在 get 或 remove 操作时无法正确找到目标元素。

以下是一个可能出现问题的示例:

import java.util.HashMap;

class User {
    private int id;
    private String name;

    public User(int id, String name) {
        this.id = id;
        this.name = name;
    }

    // 不重写 hashCode 和 equals 方法
}

public class HashMapIssueExample {
    public static void main(String[] args) {
        HashMap<User, String> map = new HashMap<>();
        User user1 = new User(1, "Alice");
        User user2 = new User(1, "Alice");
        map.put(user1, "user1");
        // 可能无法正确找到 user2
        System.out.println(map.get(user2)); 
    }
}

在这个例子中,如果不重写 hashCode() 和 equals() 方法,user1 和 user2 可能会被认为是不同的键,即使它们在业务上应该是相等的,导致无法正确获取元素,从而影响业务逻辑的正确性,比如无法找到对应的用户信息或误判用户是否存在。

请介绍 ConcurrentHashMap,包括它如何保证线程安全。

ConcurrentHashMap 是 Java 集合框架中一个非常重要的并发集合类,它在多线程环境下提供了线程安全的 Map 操作。

底层结构
ConcurrentHashMap 的底层结构和 HashMap 类似,也是数组加链表(或红黑树)。但是,它采用了一种分段锁(在 JDK 1.8 中使用了更细粒度的锁机制)的方式来保证线程安全。在 JDK 1.8 之前,它将数组分成多个段,每个段是一个独立的锁,不同的线程可以同时操作不同的段,提高了并发性能。而在 JDK 1.8 中,它使用了 CAS(Compare and Swap)和 synchronized 结合的方式,对每个桶进行加锁,而不是对整个表或段加锁。

保证线程安全的机制

  • JDK 1.7 中的分段锁:将整个表分成多个段,每个段相当于一个小的 HashMap,不同的线程可以同时操作不同的段,避免了全局锁带来的性能瓶颈。例如,如果两个线程操作不同段的元素,它们可以同时进行,不会互相阻塞。
  • JDK 1.8 中的锁机制:使用 CAS 操作来实现无锁的并发控制,对于一些简单的操作,如添加元素时,如果桶为空,会使用 CAS 操作尝试添加元素,避免使用锁。当需要更复杂的操作,如桶中元素较多时,会使用 synchronized 对桶进行加锁,这样只有一个线程可以操作该桶,其他线程可以操作其他桶。

在这个例子中,当调用 put 方法时,如果没有冲突,可能会使用 CAS 操作添加元素;如果有冲突,会使用 synchronized 对相应的桶进行加锁,确保操作的线程安全。

ConcurrentHashMap 不仅可以保证线程安全,而且在多线程环境下的性能比传统的同步 Map(如 Hashtable)要好得多,因为它最大限度地减少了锁的竞争,允许更多的并发操作,适用于高并发场景,比如多线程的计数器、缓存等应用场景。

ConcurrentHashMap 如何实现线程安全?

在 ConcurrentHashMap 中,线程安全的实现是通过多种机制共同完成的。

JDK 1.8 的实现细节

  • 使用 CAS 操作:在添加元素时,如果要添加元素的桶是空的,会使用 CAS 操作来尝试添加元素。CAS 操作是一种乐观锁机制,它会比较当前值和预期值,如果相等,就将其更新为新值,这个过程是原子的,不需要加锁。例如,当向一个空桶中添加元素时,会使用 CAS 来检查该桶是否仍然为空,如果为空就添加元素。这样可以避免加锁,提高并发性能。
  • 使用 synchronized 加锁:当需要对桶进行更复杂的操作,比如向已经有元素的桶中添加元素或修改元素时,会使用 synchronized 对桶进行加锁。这种锁是针对单个桶的,而不是整个表,所以不同的线程可以同时操作不同的桶,不会互相干扰。

JDK 1.7 的实现细节

  • 分段锁机制:将整个表分成多个段,每个段可以看作是一个独立的 HashMap。不同的线程可以同时操作不同的段,这样可以提高并发性能。例如,假设有两个线程,一个操作段 1 中的元素,另一个操作段 2 中的元素,它们可以同时进行,不会互相阻塞,因为它们操作的是不同的段,每个段都有自己的锁。

以下是一个更具体的说明:

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapThreadSafeExample {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
        Runnable task1 = () -> {
            for (int i = 0; i < 1000; i++) {
                map.put("key" + i, i);
            }
        };
        Runnable task2 = () -> {
            for (int i = 1000; i < 2000; i++) {
                map.put("key" + i, i);
            }
        };
        new Thread(task1).start();
        new Thread(task2).start();
    }
}

在这个例子中,两个线程同时向 ConcurrentHashMap 中添加元素,由于使用了分段锁(JDK 1.7)或 CAS 加 synchronized(JDK 1.8)的机制,它们可以并发操作,而不会出现数据不一致或其他线程安全问题。

此外,ConcurrentHashMap 在扩容时也有特殊的机制,它可以允许多个线程协助扩容,提高扩容的效率,同时保证线程安全。例如,在扩容时,多个线程可以同时帮助将元素从旧表转移到新表,而不是像 HashMap 那样只能由一个线程完成,这也是其提高并发性能的一个重要体现。

ConcurrentHashMap 的并发度大小是怎样的?ConcurrentHashMap 的 get 方法是否上锁?

并发度大小
ConcurrentHashMap 的并发度在不同的 Java 版本中实现有所不同。在 Java 7 中,它是通过分段锁来实现并发控制的,并发度是指它被分成的段的数量。你可以在创建 ConcurrentHashMap 时通过构造函数指定并发度,一般情况下,默认的并发度是 16。这意味着 ConcurrentHashMap 会将存储元素的数组分成 16 个段,不同的线程可以同时对不同的段进行操作,这样可以提高并发性能,因为多个线程可以同时访问不同的段,而不会互相阻塞。例如,如果有两个线程,一个要操作第 1 段,另一个要操作第 2 段,它们可以同时进行操作,避免了像传统的全局锁那样的性能瓶颈。

在 Java 8 中,ConcurrentHashMap 的并发度不再通过显式的分段来实现,而是采用了更细粒度的锁机制,主要是基于 CAS(Compare and Swap)操作和 synchronized 锁。这里的并发度更加灵活,它会根据实际情况动态调整。例如,在对不同的桶进行操作时,如果桶为空,使用 CAS 操作,多个线程可以尝试同时操作不同的空桶;如果桶不为空,使用 synchronized 对桶进行加锁,不同线程可以同时操作不同的非空桶,并发度取决于桶的数量和元素的分布,理论上可以支持更多的并发操作。

get 方法是否上锁
ConcurrentHashMap 的 get 方法在一般情况下是不上锁的。因为 get 操作不会修改数据结构,所以它主要是根据键的哈希值找到对应的桶,然后查找元素。在查找过程中,使用 Unsafe 类的一些方法保证原子性和可见性,不会对整个数据结构加锁,这样可以提高并发性能。在 Java 8 中,查找元素时,先根据哈希值找到桶,如果是链表,会遍历链表查找元素;如果是红黑树,会使用红黑树的查找操作,这个过程不会加锁,因为 get 操作不会改变元素的状态,只是读取元素,所以不会影响其他线程的操作。

Hashtable、HashMap、ConcurrentHashMap 的实现原理、底层结构、性能差异原因分别是什么?ConcurrentHashMap 如何保证线程安全?put 和 map 加锁时是怎么操作的(详细讲解)?

实现原理和底层结构

  • Hashtable
    • 实现原理:Hashtable 是早期的哈希表实现,它的实现原理是基于数组和链表。使用键的 hashCode() 计算哈希值,通过取模运算确定元素在数组中的位置。如果发生哈希冲突,元素会存储在同一个位置形成链表。它的主要特点是几乎所有的方法都被 synchronized 关键字修饰,这保证了它的线程安全。
    • 底层结构:是一个数组,数组元素存储链表节点,存储键值对
  • HashMap
    • 实现原理:HashMap 也是基于数组和链表(或红黑树)的结构。通过键的 hashCode() 计算哈希值,使用 (n - 1) & hash 确定元素在数组中的位置。当元素存储在同一位置时,会形成链表,在 Java 8 中,当链表长度超过 8 且数组长度达到 64 时,链表会转换为红黑树。它是非线程安全的,因为没有同步机制。
    • 底层结构:数组存储链表或红黑树的头节点,节点存储键值对。
  • ConcurrentHashMap
    • 实现原理:在 Java 7 中,基于分段锁,将数组分成多个段,不同的线程可以同时操作不同的段。在 Java 8 中,使用 CAS 操作和 synchronized 锁结合,对桶进行操作。对于简单的添加元素操作,使用 CAS 操作,对于复杂的操作,使用 synchronized 对桶加锁。
    • 底层结构:和 HashMap 类似,是数组加链表(或红黑树),但锁机制不同。

性能差异原因

  • Hashtable:因为它的方法都被 synchronized 修饰,多个线程访问时需要等待锁释放,导致并发性能低,只适合低并发场景。
  • HashMap:由于没有线程安全机制,在单线程或少量线程操作时性能较好,但在多线程环境下,可能会出现数据不一致等问题。
  • ConcurrentHashMap:在 Java 7 中,通过分段锁允许不同线程操作不同段,提高了并发性能;在 Java 8 中,更细粒度的 CAS 和 synchronized 锁机制,进一步提升了并发性能,既保证了线程安全又提高了并发性能,适用于高并发场景。

ConcurrentHashMap 保证线程安全的方式
在 Java 8 的 put 操作中,首先计算键的哈希值,找到对应的桶。如果桶为空,使用 CAS 操作尝试添加元素,避免加锁。如果桶不为空,使用 synchronized 对桶加锁,然后进行元素的添加或修改操作。在扩容时,ConcurrentHashMap 也有特殊的机制,多个线程可以协助扩容,提高了扩容效率。

在 put 操作时,例如添加元素:

  • 计算哈希值,找到桶。
  • 若桶空,使用 CAS 尝试添加元素。
  • 若桶不空,使用 synchronized 加锁,添加元素或更新元素。

写一个双向 Map,可以通过 key 得到 value,也可以通过 value 得到 key,要求保证 key 和 value 是一一对应的。

以下是一个实现双向 Map 的示例代码:

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

class BiMap<K, V> {
    private final Map<K, V> forwardMap = new HashMap<>();
    private final Map<V, K> backwardMap = new HashMap<>();

    public void put(K key, V value) {
        if (forwardMap.containsKey(key)) {
            backwardMap.remove(forwardMap.get(key));
        }
        if (backwardMap.containsKey(value)) {
            forwardMap.remove(backwardMap.get(value));
        }
        forwardMap.put(key, value);
        backwardMap.put(value, key);
    }

    public V getValue(K key) {
        return forwardMap.get(key);
    }

    public K getKey(V value) {
        return backwardMap.get(value);
    }

    public boolean containsKey(K key) {
        return forwardMap.containsKey(key);
    }

    public boolean containsValue(V value) {
        return backwardMap.containsKey(value);
    }

    public void removeKey(K key) {
        V value = forwardMap.get(key);
        if (value!= null) {
            forwardMap.remove(key);
            backwardMap.remove(value);
        }
    }

    public void removeValue(V value) {
        K key = backwardMap.get(value);
        if (key!= null) {
            backwardMap.remove(value);
            forwardMap.remove(key);
        }
    }
}

public class BiMapExample {
    public static void main(String[] args) {
        BiMap<String, Integer> biMap = new BiMap<>();
        biMap.put("one", 1);
        biMap.put("two", 2);
        System.out.println(biMap.getValue("one")); 
        System.out.println(biMap.getKey(2)); 
    }
}

代码解释

  • BiMap 类内部使用两个 HashMap,一个 forwardMap 用于存储正常的 key-value 映射,一个 backwardMap 用于存储 value-key 映射。
  • put 方法:添加元素时,会先检查 key 或 value 是否已经存在,如果存在,会先从另一个映射中移除对应的元素,保证一一对应。
  • getValue 和 getKey 方法:分别从 forwardMap 和 backwardMap 中获取对应的元素。
  • containsKey 和 containsValue 方法:检查 key 或 value 是否存在。
  • removeKey 和 removeValue 方法:根据 key 或 value 移除元素,同时从两个映射中移除,保证一致性。

使用过哪些 Java 中的集合?

在 Java 中,有多种常用的集合,以下是一些常见的集合及其使用场景:

  • ArrayList
    • 它是一个基于数组的动态列表,实现了 List 接口。可以存储多个元素,并且可以根据索引快速访问元素,添加和删除元素的操作相对简单。适合存储有序的元素集合,例如存储用户的购物清单,或者存储一个序列的元素。它的优点是随机访问性能好,时间复杂度为 ,但在中间插入或删除元素时,可能需要移动元素,时间复杂度为 。
    • 代码示例:

import java.util.ArrayList;

public class ArrayListExample {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<>();
        list.add("item1");
        list.add("item2");
        System.out.println(list.get(0));
    }
}

  • LinkedList
    • 基于双向链表实现,适合频繁的插入和删除操作,特别是在列表的头部或尾部。例如,在实现队列或栈时,可以使用 LinkedList。它的 addFirstaddLastremoveFirstremoveLast 等方法操作方便,时间复杂度为 ,但随机访问元素性能较差,时间复杂度为 。
    • 代码示例:

import java.util.LinkedList;

public class LinkedListExample {
    public static void main(String[] args) {
        LinkedList<String> list = new LinkedList<>();
        list.addFirst("item1");
        list.addLast("item2");
        System.out.println(list.getFirst());
    }
}

  • HashSet
    • 存储不重复的元素集合,基于 HashMap 实现,使用元素的 hashCode() 确保元素的唯一性。适用于需要存储唯一元素的场景,如存储用户的唯一标识符,或去除重复元素。
    • 代码示例:

import java.util.HashSet;

public class HashSetExample {
    public static void main(String[] args) {
        HashSet<String> set = new HashSet<>();
        set.add("element1");
        set.add("element1");
        System.out.println(set.size()); 
    }
}

  • TreeSet
    • 是一个有序的集合,基于 TreeMap 实现,会根据元素的自然顺序或自定义的比较器对元素进行排序。例如,存储一组需要排序的数据,如学生的成绩并按照分数排序,或者存储时间戳并按照时间顺序排序。
    • 代码示例:

import java.util.TreeSet;

public class TreeSetExample {
    public static void main(String[] args) {
        TreeSet<String> set = new TreeSet<>();
        set.add("banana");
        set.add("apple");
        System.out.println(set.first());
    }
}

  • HashMap
    • 存储键值对,使用键的 hashCode() 找到元素的存储位置,可快速查找、插入和删除元素,适用于不需要元素有序存储的场景,如存储用户信息,通过用户 ID 快速查找用户信息。
    • 代码示例:

import java.util.HashMap;

public class HashMapExample {
    public static void main(String[] args) {
        HashMap<String, Integer> map = new HashMap<>();
        map.put("user1", 1);
        System.out.println(map.get("user1"));
    }
}

线程安全的集合有哪些?挑一个讲讲底层。

在 Java 中,有一些线程安全的集合,比如 VectorHashtableConcurrentHashMap 等。这里以 ConcurrentHashMap 为例讲解底层。

ConcurrentHashMap 的底层结构和原理

  • 底层结构:在 Java 8 中,ConcurrentHashMap 的底层是数组加链表(或红黑树),和 HashMap 类似,但它的锁机制不同。数组存储元素,元素可以是链表或红黑树的头节点。当添加元素时,会根据键的哈希值找到对应的桶,桶中存储元素。
  • 线程安全保证
    • 使用 CAS 操作:对于一些简单操作,如添加元素到空桶,使用 CAS 操作。例如,在添加元素时,会先计算键的哈希值,找到对应的桶,如果桶为空,使用 CAS 操作尝试将元素添加到桶中。这个过程是原子的,不需要加锁,提高了并发性能。
    • 使用 synchronized 加锁:当桶不为空时,对桶进行 synchronized 加锁,确保只有一个线程可以对该桶进行操作,其他线程可以操作其他桶。这样可以避免多个线程同时修改同一个桶导致的数据不一致。
    • 扩容时,多个线程可以协助扩容,提高扩容效率。

以下是一个更详细的代码示例:

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapThreadSafeExample {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
        Runnable task1 = () -> {
            for (int i = 0; i < 1000; i++) {
                map.put("key" + i, i);
            }
        };
        Runnable task2 = () -> {
            for (int i = 1000; i < 2000; i++) {
                map.put("key" + i, i);
            }
        };
        new Thread(task1).start();
        new Thread(task2).start();
    }
}

在这个例子中,两个线程同时向 ConcurrentHashMap 中添加元素,由于 CAS 和 synchronized 的使用,它们可以并发操作,不会出现数据不一致或其他线程安全问题。ConcurrentHashMap 的这种设计既保证了线程安全,又能在高并发环境下提供良好的性能,避免了像 Hashtable 那样全局加锁导致的性能瓶颈。

哈希和底层实现

哈希是一种将任意长度的数据映射为固定长度值(通常称为哈希值)的技术,在 Java 中被广泛应用于集合框架,尤其是 HashMap、HashSet 等数据结构中。哈希的主要目的是为了快速查找、插入和删除元素,提高操作效率。

哈希函数是哈希的核心,它接收一个输入(比如一个对象的键),通过一系列的计算得到一个哈希值。在 Java 中,通常是通过对象的 hashCode() 方法获取哈希值。例如,对于一个自定义对象,我们可以重写 hashCode() 方法:

class MyClass {
    private int id;
    private String name;

    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + id;
        result = 31 * result + (name == null? 0 : name.hashCode());
        return result;
    }
}

在这个例子中,使用了一个简单的算法计算哈希值,将对象的不同属性纳入计算,尽量保证不同对象有不同的哈希值。

哈希的底层实现通常基于数组,数组的每个位置被称为一个 “桶”。当计算出一个元素的哈希值后,会通过某种算法(通常是对数组长度取模)将该元素存储到对应的桶中。以 HashMap 为例,它的底层是一个数组,存储着链表或红黑树的头节点。当添加元素时,先计算元素的哈希值,找到对应的桶位置,如果该位置为空,直接存储元素;如果不为空,说明发生了哈希冲突。

为了更好地理解,假设我们要存储用户信息,将用户对象存储在 HashMap 中:

import java.util.HashMap;

class User {
    private int id;
    private String name;

    public User(int id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + id;
        result = 31 * result + (name == null? 0 : name.hashCode());
        return result;
    }
}

public class HashExample {
    public static void main(String[] args) {
        HashMap<User, String> map = new HashMap<>();
        User user1 = new User(1, "Alice");
        map.put(user1, "user1");
    }
}

这样,根据 user1 的哈希值将其存储在 HashMap 的相应桶中。哈希的底层实现通过数组存储元素,利用哈希函数快速定位元素位置,当发生哈希冲突时,再通过链表或红黑树存储多个元素,确保数据存储和查找的高效性。但需要注意的是,哈希函数的设计很重要,一个好的哈希函数可以使元素均匀分布在不同的桶中,避免过多的哈希冲突,提高性能。

哈希计算时会产生哈希冲突吗?如何解决?链表和红黑树如何转换?红黑树如何退化成链表?

哈希冲突
在哈希计算时,几乎肯定会产生哈希冲突。因为哈希函数将无限的输入映射到有限的输出空间,不同的输入可能会产生相同的哈希值。例如,对于一个简单的取模哈希函数,不同的输入可能会产生相同的余数。

解决哈希冲突的方法

  • 链表:当发生哈希冲突时,最常见的解决方法是使用链表。在 Java 的 HashMap 中,将冲突的元素存储在同一个桶的链表中。当查找元素时,先找到桶,再遍历链表,使用 equals() 方法比较元素是否相等。

  • 红黑树:在 Java 8 的 HashMap 中,如果链表长度超过 8 且数组长度达到 64,会将链表转换为红黑树。因为链表的查找性能是 ,当元素较多时性能下降,而红黑树的查找性能是 。

红黑树和链表的转换

  • 当链表长度超过 8 且数组长度达到 64 时,会将链表转换为红黑树,这是为了优化性能,因为红黑树的查找、插入和删除操作性能更好。
  • 当红黑树中的元素数量减少到 6 以下时,会将红黑树重新转换为链表,因为元素较少时,链表占用空间更小,更节省资源。

红黑树退化成链表
当从红黑树中删除元素,使元素数量减少到一定程度时,会自动退化成链表。这是一种对性能和空间的权衡,当元素数量少,链表的性能和空间占用都优于红黑树,因此会进行这种转换,以优化整体性能。

已知 Java 常见集合,List 有哪些?LinkedList 和 ArrayList 的区别是什么?Set 和 List 的区别是什么?

List 的种类
在 Java 中,常见的 List 有 ArrayList、LinkedList 和 Vector。

  • ArrayList:基于数组实现的动态列表,它的容量可以自动增长。当添加元素时,如果容量不够,会进行扩容操作。它
  • LinkedList:基于双向链表实现,适合频繁的插入和删除操作,尤其是在头部或尾部。它提供了 addFirst()addLast()removeFirst()removeLast() 等操作,时间复杂度为 。例如:

import java.util.LinkedList;

public class LinkedListExample {
    public static void main(String[] args) {
        LinkedList<String> linkedList = new LinkedList<>();
        linkedList.addFirst("item1");
        linkedList.addLast("item2");
        System.out.println(linkedList.getFirst());
    }
}

  • Vector:和 ArrayList 类似,也是基于数组,但它是线程安全的,因为它的方法被 synchronized 修饰,不过这也导致性能相对较低。

LinkedList 和 ArrayList 的区别

  • 存储结构:ArrayList 是基于数组,LinkedList 是基于双向链表。
  • 随机访问:ArrayList 可以通过索引快速访问元素,而 LinkedList 需要从头或尾开始遍历,ArrayList 的随机访问性能更好,时间复杂度为 ,LinkedList 为 。
  • 插入和删除操作:LinkedList 在头部或尾部添加或删除元素更快,时间复杂度为 ,而 ArrayList 在中间插入或删除元素时,需要移动元素,时间复杂度为 。

Set 和 List 的区别

  • 元素重复性:List 允许存储重复元素,而 Set 不允许存储重复元素。
  • 元素顺序:List 是有序的,可以通过索引访问元素;Set 通常是无序的(HashSet),或者根据元素的自然顺序或自定义顺序排序(TreeSet),并且不支持通过索引访问元素。
  • 使用场景:List 适合存储有序、可重复的数据,如购物清单;Set 适合存储唯一元素,如用户的唯一标识符集合。

讲一下队列和栈这两种数据结构。

队列(Queue)
队列是一种遵循先进先出(FIFO,First In First Out)原则的数据结构。就像排队一样,先进入队列的元素先出队列。在 Java 中,常用的实现有 LinkedList,它实现了 Queue 接口。例如:

import java.util.LinkedList;
import java.util.Queue;

public class QueueExample {
    public static void main(String[] args) {
        Queue<String> queue = new LinkedList<>();
        queue.offer("item1");
        queue.offer("item2");
        String firstItem = queue.poll();
    }
}

在这个例子中,使用 offer() 方法向队列添加元素,使用 poll() 方法从队列取出元素。队列常用于任务调度、消息传递等场景,例如在多线程环境中,生产者 - 消费者模式可以使用队列存储待处理的任务,先生产的任务先被消费。

栈(Stack)
栈是一种遵循后进先出(LIFO,Last In First Out)原则的数据结构,就像一摞盘子,最后放上去的盘子最先被拿下来。在 Java 中,Stack 类是栈的实现。例如:

import java.util.Stack;

public class StackExample {
    public static void main(String[] args) {
        Stack<String> stack = new Stack<>();
        stack.push("item1");
        stack.push("item2");
        String topItem = stack.pop();
    }
}

在这个例子中,使用 push() 方法向栈添加元素,使用 pop() 方法从栈取出元素。栈常用于表达式求值、函数调用的存储(调用栈)、撤销操作等场景,因为它可以方便地保存和恢复最近的操作状态。

队列和栈在存储和操作元素的顺序上有明显区别,它们都有各自适用的场景,根据不同的需求可以选择使用队列或栈。在实际应用中,根据它们的特点可以解决很多算法和实际问题,比如使用栈实现括号匹配检查,使用队列实现广度优先搜索等。

说一下红黑树,它相比于链表有哪些优点?

红黑树是一种自平衡的二叉搜索树,它具有以下特点:

  • 平衡性:红黑树通过一系列的规则和操作(包括颜色标记和节点旋转)来保证树的平衡,确保树的高度始终保持在 。而链表是线性结构,查找元素的时间复杂度是 。例如,在存储大量元素时,查找一个元素,红黑树的查找效率更高。
  • 查找、插入和删除性能:由于红黑树的平衡性,查找、插入和删除操作的时间复杂度都是 。对于链表,查找元素需要从头部或尾部开始遍历,时间复杂度是 ;插入和删除操作在链表头部或尾部是 ,但在中间位置是 。

以下是一个简单的对比:

  • 假设我们要存储 1000 个元素,如果使用链表,查找元素可能需要遍历多个元素,平均需要 500 次比较(假设元素均匀分布)。

  • 而使用红黑树,根据  的复杂度,查找元素只需要大约 10 次比较。

  • 有序性:红黑树可以方便地对元素进行排序,因为它是一种搜索树,元素按照一定的顺序存储。而链表通常是无序的,除非使用额外的排序算法。

讲讲 Set 的底层实现。

Set 是 Java 集合框架中的一个接口,它的主要特点是存储不重复的元素。常见的 Set 实现类有 HashSet、TreeSet 和 LinkedHashSet,它们各自有着不同的底层实现。

HashSet 的底层实现
HashSet 是基于 HashMap 实现的。实际上,它使用 HashMap 的键来存储元素,而值是一个虚拟的值,通常是一个固定的对象,因为 HashSet 只关心元素的唯一性,不关心元素的值。当向 HashSet 中添加元素时,将元素作为 HashMap 的键,通过元素的 hashCode() 方法计算哈希值,找到对应的存储位置(桶)。如果桶中已经有元素,说明发生了哈希冲突,会使用链表或红黑树(在 Java 8 中,当链表长度超过 8 且数组长度达到 64 时会将链表转换为红黑树)存储多个元素。

TreeSet 的底层实现
TreeSet 是基于 TreeMap 实现的。它使用红黑树作为底层数据结构,存储元素时会根据元素的自然顺序(如果元素实现了 Comparable 接口)或者提供的比较器进行排序。元素会按照一定的顺序存储在红黑树中,保证了元素的有序性。例如:

import java.util.TreeSet;

public class TreeSetExample {
    public static void main(String[] args) {
        TreeSet<String> set = new TreeSet<>();
        set.add("cherry");
        set.add("apple");
        set.add("banana");
    }
}

在这个 TreeSet 中,元素会按照字母顺序存储,因为 String 实现了 Comparable 接口。如果存储自定义对象,需要确保对象实现 Comparable 接口或者在创建 TreeSet 时提供一个比较器。

LinkedHashSet 的底层实现
LinkedHashSet 是 HashSet 的子类,它在 HashSet 的基础上使用了一个链表来维护元素的插入顺序。它的底层是 LinkedHashMap,不仅保证元素的唯一性,还能保持元素的插入顺序。当添加元素时,除了使用哈希表存储元素外,还会使用链表将元素按照插入顺序连接起来。例如:

import java.util.LinkedHashSet;

public class LinkedHashSetExample {
    public static void main(String[] args) {
        LinkedHashSet<String> set = new LinkedHashSet<>();
        set.add("first");
        set.add("second");
        set.add("third");
    }
}

在这个例子中,元素的存储顺序将和插入顺序一致,而不是像 HashSet 那样无序。

ArrayList 和 HashMap 的内部结构是怎样的?

ArrayList 的内部结构
ArrayList 是基于数组的动态存储结构。它有一个 Object[] 类型的数组,用于存储元素。当创建 ArrayList 时,如果不指定初始容量,会使用一个默认的初始容量(通常是 10)。当添加元素时,如果元素数量达到当前数组的容量,ArrayList 会进行扩容操作。扩容时,会创建一个新的、容量更大的数组(一般是原数组容量的 1.5 倍),并将原数组中的元素复制到新数组中,使用 System.arraycopy() 方法。

HashMap 的内部结构
HashMap 的内部结构是数组加链表(或红黑树)。它有一个存储元素的数组,称为 “桶数组”,数组的每个元素可以是链表或红黑树的头节点。当添加元素时,通过键的 hashCode() 计算哈希值,使用 (n - 1) & hash 计算元素在数组中的存储位置(n 是数组的长度)。如果多个元素映射到同一个位置,就会形成链表或红黑树。

在这个 HashMap 中,当添加元素时,会根据键的哈希值找到存储位置,如果发生哈希冲突,会根据情况存储在链表或红黑树中。在 Java 8 中,当链表长度超过 8 且数组长度达到 64 时,会将链表转换为红黑树,以提高性能,因为红黑树的查找、插入和删除操作的时间复杂度是 ,而链表在元素较多时性能是 。

讲讲 Vector。

Vector 是 Java 中的一个集合类,它和 ArrayList 类似,也是基于数组实现的。但 Vector 是线程安全的,因为它的大部分方法都被 synchronized 修饰。

在创建 Vector 时,可以指定初始容量和容量增量。当添加元素时,如果元素数量达到当前容量,会根据容量增量进行扩容。如果没有指定容量增量,通常会将容量翻倍。

然而,由于 Vector 的同步特性,它在多线程环境下能保证线程安全,但在性能上可能会比 ArrayList 稍差。因为 synchronized 关键字会导致多个线程访问 Vector 时需要获取锁,可能会出现线程等待,降低并发性能。

在实际应用中,如果不需要线程安全,使用 ArrayList 可能会更高效;只有在多线程环境下,并且对线程安全有要求时,才会考虑使用 Vector。另外,由于 Vector 是一个遗留类,在新的 Java 代码中,如果需要线程安全的列表,也可以考虑使用 Collections.synchronizedList() 方法将 ArrayList 包装成线程安全的列表,或者使用 CopyOnWriteArrayList,它采用了写时复制的策略,避免了多线程同时修改的冲突。

为什么使用内部类?内部类可以访问外部成员吗?

使用内部类的原因

  • 封装性:内部类可以将一些只与外部类相关的类进行封装,将它们隐藏在外部类内部,使得代码更加模块化和清晰。例如,如果一个类只在另一个类的内部使用,将其作为内部类可以避免将其暴露给外部,提高了代码的封装性。
  • 实现多重继承:在 Java 中,类不支持多重继承,但内部类可以实现类似的功能。一个内部类可以继承一个类,同时外部类可以继承另一个类,这样可以在一定程度上实现多重继承的效果。
  • 方便访问外部类成员:内部类可以方便地访问外部类的成员,包括私有成员,这在某些情况下可以简化代码结构。例如,内部类可以直接访问外部类的属性和方法,无需通过对象引用。

内部类对外部成员的访问
内部类可以访问外部类的成员,包括私有成员。以下是一个示例:

class OuterClass {
    private int outerValue = 10;

    class InnerClass {
        void display() {
            System.out.println(outerValue);
        }
    }
}

public class InnerClassExample {
    public static void main(String[] args) {
        OuterClass outer = new OuterClass();
        OuterClass.InnerClass inner = outer.new InnerClass();
        inner.display();
    }
}

在这个例子中,InnerClass 可以直接访问 OuterClass 的私有成员 outerValue,这是因为内部类对象会持有一个对外部类对象的隐式引用。

内部类分为成员内部类、局部内部类、匿名内部类和静态内部类。成员内部类可以访问外部类的所有成员;局部内部类可以访问外部方法的局部变量(需要是 final 修饰的,在 Java 8 中,局部变量只要是事实上的 final 也可以);匿名内部类常用于创建实现接口或继承抽象类的实例;静态内部类不能访问外部类的非静态成员,但可以访问静态成员。

介绍 Java 集合框架(如 ArrayList、LinkedList 等),并对比它们的特点。

Java 集合框架概述
Java 集合框架提供了一系列用于存储和操作元素的数据结构,包括 List、Set、Map 等接口和它们的实现类,方便我们在不同的场景下使用不同的数据结构。

ArrayList

  • 基于数组实现,提供了动态存储元素的能力。可以根据索引快速访问元素,通过 get(int index) 方法,时间复杂度是 。
  • 当添加元素时,如果容量不够,会进行扩容操作,涉及元素的复制,性能开销较大。在中间插入或删除元素时,需要移动元素,时间复杂度是 。

LinkedList

  • 基于双向链表实现,适合频繁的插入和删除操作,特别是在头部或尾部,使用 addFirst()addLast()removeFirst()removeLast() 等操作时,时间复杂度是 。
  • 随机访问元素性能较差,需要从头或尾开始遍历,时间复杂度是 。

HashSet

  • 基于 HashMap 实现,存储不重复元素,使用元素的 hashCode() 来确定存储位置。
  • 不保证元素的顺序,适用于存储唯一元素的场景。

TreeSet

  • 基于 TreeMap 实现,使用红黑树存储元素,元素是有序的,根据元素的自然顺序或自定义比较器排序。
  • 适合需要元素有序存储的场景,但插入和删除元素的性能是 。

HashMap

  • 存储键值对,使用键的 hashCode() 找到存储位置,当发生哈希冲突时,使用链表或红黑树存储多个元素。
  • 平均查找、插入和删除操作的时间复杂度是 ,但在哈希冲突严重时,性能可能下降。

LinkedHashMap

  • 是 HashMap 的子类,在 HashMap 的基础上使用链表维护元素的插入顺序或访问顺序,适用于需要保留元素顺序的键值对存储。例如:

import java.util.LinkedHashMap;

public class LinkedHashMapExample {
    public static void main(String[] args) {
        LinkedHashMap<String, Integer> map = new LinkedHashMap<>();
        map.put("key1", 1);
        map.put("key2", 2);
    }

  • 可以根据需要设置访问顺序,在迭代时可以按照插入顺序或访问顺序输出元素。

这些集合框架的不同实现类在不同的场景下各有优劣,我们可以根据具体需求选择合适的集合类,比如需要随机访问时使用 ArrayList,需要频繁插入删除元素时考虑 LinkedList,需要存储唯一元素时使用 HashSet 或 TreeSet,需要存储键值对时考虑 HashMap 或 LinkedHashMap。

Java 集合有哪些?简要介绍。

Java 集合框架是一组接口、实现类和算法,用于存储、检索和操作对象集合。它提供了丰富的数据结构,可满足各种编程需求。

List 接口
List 是一个有序的集合,允许存储重复元素,并且可以通过索引访问元素。它有多个实现类,比如 ArrayList 和 LinkedList。

  • ArrayList:基于数组实现,当元素数量超过容量时会自动扩容,通常扩容为原容量的 1.5 倍。它支持快速随机访问,使用 get(int index) 方法的时间复杂度为 。例如:

import java.util.ArrayList;

public class ArrayListExample {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<>();
        list.add("first");
        list.add("second");
        System.out.println(list.get(0)); 
    }
}

在这个例子中,我们可以轻松添加元素并通过索引获取元素。但在中间插入或删除元素时,由于需要移动元素,时间复杂度为 。

  • LinkedList:基于双向链表实现,对于在列表头部或尾部的添加和删除操作,时间复杂度为 ,使用 addFirst()addLast()removeFirst() 和 removeLast() 方法很方便。不过,随机访问元素的性能较差,时间复杂度为 。例如:

import java.util.LinkedList;

public class LinkedListExample {
    public static void main(String[] args) {
        LinkedList<String> list = new LinkedList<>();
        list.addFirst("last");
        list.addLast("first");
        System.out.println(list.getFirst()); 
    }
}

Set 接口
Set 存储不重复的元素集合,主要实现类有 HashSet、TreeSet 和 LinkedHashSet。

  • HashSet:基于 HashMap 实现,利用元素的 hashCode() 方法存储元素,不保证元素的顺序。例如:

import java.util.HashSet;

public class HashSetExample {
    public static void main(String[] args) {
        HashSet<String> set = new HashSet<>();
        set.add("apple");
        set.add("banana");
    }
}

  • TreeSet:基于 TreeMap 实现,使用红黑树存储元素,元素会根据自然顺序或自定义比较器排序,适合需要元素有序存储的场景。例如:

import java.util.TreeSet;

public static void main(String[] args) {
    TreeSet<String> set = new TreeSet<>();
    set.add("cherry");
    set.add("apple");
}

  • LinkedHashSet:继承 HashSet,使用链表维护元素的插入顺序,既能保证元素唯一,又能保持元素的插入顺序。

Map 接口
存储键值对,主要实现类有 HashMap、TreeMap 和 LinkedHashMap 等。

  • HashMap:通过键的 hashCode() 计算存储位置,当发生哈希冲突时,会使用链表或红黑树存储元素。例如:

import java.util.HashMap;

public class HashMapExample {
    public static void main(String[] args) {
        HashMap<String, Integer> map = new HashMap<>();
        map.put("key", 1);
    }
}

  • TreeMap:基于红黑树,元素按键的自然顺序或自定义比较器排序,适合需要键值对按序存储的场景。
  • LinkedHashMap:继承 HashMap,维护元素的插入顺序或访问顺序。

介绍 HashMap,以及 ConcurrentHashMap 和 HashMap 的区别。

HashMap
HashMap 是 Java 集合框架中用于存储键值对的数据结构,其底层是数组加链表(或红黑树)。当添加元素时,会根据键的 hashCode() 计算哈希值,再通过 (n - 1) & hash 确定元素在数组中的存储位置。如果多个元素映射到同一位置,会形成链表,在 Java 8 中,当链表长度超过 8 且数组长度达到 64 时,链表会转换为红黑树。

HashMap 允许一个 null 键和多个 null 值,但它是非线程安全的,多个线程同时操作时可能导致数据不一致。

ConcurrentHashMap
ConcurrentHashMap 是线程安全的 Map 实现,与 HashMap 有一些重要区别。

  • 线程安全机制:在 Java 7 中,ConcurrentHashMap 使用分段锁,将整个表分成多个段,不同线程可同时操作不同段。在 Java 8 中,采用 CAS(Compare and Swap)和 synchronized 结合的方式,对每个桶进行加锁,性能更好。
  • 性能:由于 ConcurrentHashMap 的线程安全机制,它在高并发环境下性能优于 HashMap。HashMap 没有同步机制,在多线程环境下可能出现数据错误,而 ConcurrentHashMap 允许并发读写,能更好地处理并发操作。
  • 操作:ConcurrentHashMap 的部分操作,如 put 和 get,在实现上进行了优化,确保线程安全的同时,尽量减少锁的使用,提高并发性能。

ConcurrentHashMap 底层原理。

ConcurrentHashMap 的底层结构类似于 HashMap,是数组加链表(或红黑树),但在实现线程安全方面有独特之处。

在 Java 7 中,ConcurrentHashMap 采用分段锁机制,将存储元素的数组分成多个段,每个段相当于一个小的 HashMap,不同线程可同时操作不同段,提高了并发性能。例如,两个线程操作不同段时不会互相阻塞。

在 Java 8 中,ConcurrentHashMap 结合 CAS 和 synchronized 来保证线程安全。对于 put 操作:

  • 首先计算键的哈希值,找到对应的桶。
  • 若桶为空,使用 CAS 操作尝试添加元素,避免加锁。
  • 若桶不为空,使用 synchronized 对桶加锁,再进行添加或修改操作。

ConcurrentHashMap 在扩容时也更高效,多个线程可以协助扩容。当进行 get 操作时,一般不需要加锁,通过 Unsafe 类的方法保证原子性和可见性,查找元素时根据哈希值找到桶,若是链表则遍历,若是红黑树则使用红黑树的查找方法。这种机制使得 ConcurrentHashMap 在高并发场景下性能出色,同时保证数据的一致性和完整性。

依次删除 List 中的所有元素应该如何删除?

要依次删除 List 中的所有元素,不能简单地使用普通的 for 循环,因为在删除元素时,List 的大小会发生变化,可能导致 ConcurrentModificationException 异常。以下是几种正确的方法:

方法一:使用迭代器的 remove 方法

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class ListRemoveExample {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("one");
        list.add("two");
        list.add("three");
        Iterator<String> iterator = list.iterator();
        while (iterator.hasNext()) {
            iterator.next();
            iterator.remove();
        }
    }
}

在这个例子中,使用迭代器遍历元素,通过 iterator.remove() 方法删除元素,它会正确地维护迭代器的状态,避免异常。

方法二:从后往前删除元素

import java.util.ArrayList;
import java.util.List;

public class ListRemoveBackwards {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("one");
        list.add("two");
        list.add("three");
        for (int i = list.size() - 1; i >= 0; i--) {
            list.remove(i);
        }
    }
}

这里从后往前删除元素,因为从后往前删除不会影响前面元素的索引,避免了从前往后删除时因元素移动导致的问题。

方法三:使用 removeAll 方法(适用于清空列表)

import java.util.ArrayList;
import java.util.List;

public class ListRemoveAll {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("one");
        list.add("two");
        list.add("three");
        list.removeAll(list);
    }
}

removeAll 方法会移除列表中包含的所有元素,适用于清空列表的场景。不同的方法可根据具体需求选择,使用迭代器删除更灵活,适用于需要逐个检查元素的情况;从后往前删除适合简单的清空操作;removeAll 则更简洁,适合一次性清空列表。

ArrayList、HashMap 继承了哪些接口?

ArrayList

  • ArrayList 继承自 AbstractList 并实现了 List 接口,这使得它具有了列表的基本特性,例如可以存储有序的元素集合,允许元素重复,可以根据索引添加、获取、修改和删除元素。以下是一个简单的示例:

import java.util.ArrayList;
import java.util.List;

public class ArrayListInheritance {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("item1");
        list.add("item2");
        System.out.println(list.get(0));
    }
}

  • 同时,ArrayList 实现了 RandomAccess 接口,这表示它支持快速随机访问,也就是可以通过 get(int index) 方法快速定位元素,时间复杂度为 。这个接口只是一个标记接口,表明实现类支持快速随机访问,方便算法根据这个特性优化性能。
  • 它还实现了 Cloneable 接口,允许对象进行克隆操作。
  • 另外,ArrayList 实现了 Serializable 接口,这意味着它可以被序列化,能够将对象转换为字节流进行存储或传输,例如存储到文件或通过网络传输。

HashMap

  • HashMap 继承自 AbstractMap 并实现了 Map 接口,这使得它可以存储键值对,根据键来存储和检索值。例如:

import java.util.HashMap;
import java.util.Map;

public class HashMapInheritance {
    public static void main(String[] args) {
        Map<String, Integer> map = new HashMap<>();
        map.put("key1", 1);
        System.out.println(map.get("key1"));
    }
}

  • 同样,HashMap 实现了 Cloneable 接口,允许对象克隆。
  • 而且 HashMap 实现了 Serializable 接口,方便对象的序列化和反序列化操作,可用于存储或传输键值对数据。
  • HashMap 不保证元素的顺序,通过键的 hashCode() 计算存储位置,在 Java 8 中使用数组加链表(或红黑树)的结构存储元素,利用哈希表的特性实现快速查找、插入和删除操作(在理想情况下是 ),但需要注意哈希冲突时的性能影响。

这些继承和实现的接口为 ArrayList 和 HashMap 提供了丰富的功能,使其可以灵活地应用在不同的编程场景中,同时也为它们的操作提供了规范和约束,确保它们的行为符合 Java 集合框架的标准。

;