Java 集合

Java 集合

Java 集合,也叫作容器,主要是由两大接口派生而来:

  • Collection 接口:主要用于存放单一元素。

    对于Collection 接口,下面又有三个主要的子接口:List、Set 和 Queue。

  • Map 接口:主要用于存放键值对。

Java 集合框架如下图所示:

image

List, Set, Queue, Map 的区别

  • List: 存储的元素是有序的、可重复的。

  • Set: 存储的元素无序、不可重复的。

  • Queue: 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。

  • Map: 使用键值对(key-value)存储,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。

集合框架底层数据结构

Collection

List

  • ArrayList:Object[] 数组。

  • Vector:Object[] 数组。

  • LinkedList:双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)。

Set

  • HashSet: 无序,唯一,基于 HashMap 实现的,底层采用 HashMap 来保存元素。

  • LinkedHashSet: 是 HashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。

  • TreeSet:有序,唯一,红黑树(自平衡的排序二叉树)。

Queue

  • PriorityQueue: Object[] 数组来实现二叉堆。

  • ArrayQueue: Object[] 数组 + 双指针。

Map

  • HashMap:JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。

    JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

  • LinkedHashMap:继承自 HashMap,所以,它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。

  • Hashtable:数组+链表组成的,数组是 Hashtable 的主体,链表则是主要为了解决哈希冲突而存在的。

  • TreeMap:红黑树(自平衡的排序二叉树)。

如何选用集合?

  • 如果需要根据键值获取到元素值时就选用 Map 接口下的集合:

    • 如果需要排序时,就选择 TreeMap;

    • 如果不需要排序时,就选择 HashMap;

    • 如果需要保证线程安全,就选用 ConcurrentHashMap。

  • 如果只需要存放元素值时,就选择实现 Collection 接口的集合:

    • 如果需要保证元素唯一时,就选择实现 Set 接口的集合,比如: TreeSet 或 HashSet;

    • 如果不需要保证元素唯一时,就选择实现 List 接口的集合,比如: ArrayList 或 LinkedList。

List

ArrayList 和 Array(数组)的区别

ArrayList 和 Array(数组)的区别如下:

ArrayList Array
实现 动态数组 静态数组
扩容 动态地扩容或缩容 被创建之后就不能改变它的长度
数据类型 允许使用泛型来确保类型安全 不能使用泛型
存储数据 只能存储对象,对于基本类型数据,需要使用其对应的包装类 直接存储基本类型数据,也可以存储对象
操作 支持插入、删除、遍历等常见操作,支持动态扩容、缩容操作,比如 add()、remove() 只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力
构造 创建时不需要指定大小 创建时必须指定大小

转换

ArrayList 转换为数组

ArrayList 转换为数组可以使用 List 接口的 Object[] toArray() 方法,将 ArrayList 对象转换为数组。

数组转换为 ArrayList

Array 转换为 ArrayList 可以使用 Array的静态方法 public static <T> List<T> asList(T... a)

Set

Set 的特点是存取无序,不可以存放重复的元素,不可以用下标对元素进行操作,和 List 有很多不同。

Comparable 和 Comparator 的区别

Comparable 接口和 Comparator 接口都是 Java 中用于排序的接口,它们在实现类对象之间比较大小、排序等方面发挥了重要作用:

Comparable 接口

若一个类实现了 Comparable 接口,就意味着该类支持排序。实现了 Comparable 接口的类的对象的列表或数组可以通过 Collections.sort 或 Arrays.sort 进行自动排序。此外,实现此接口的对象可以用作有序映射中的键或有序集合中的集合,无需指定比较器。

示例:

// Person.java
public class Person implements Comparable<Person> {
    String name;
    int age;

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

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    @Override
    public int compareTo(Person p) {
        return this.age - p.getAge();
    }
}

// Main.java
public class Main {
    public static void main(String[] args) {
        Person[] people = new Person[]{
                new Person("jack", 20), new Person("larry", 10)
        };

        System.out.println("排序前:");
        for (Person person : people) {
            System.out.print(person.getName() + ": " + person.getAge()+ "\n");
        }

        Arrays.sort(people);
        System.out.println("\n排序后:");
        for (Person person : people) {
            System.out.print(person.getName() + ": " + person.getAge() + "\n");
        }
    }
}

输出:

排序前:
jack: 20
larry: 10

排序后:
larry: 10
jack: 20

Comparator 接口

Comparator 是比较接口,如果我们需要控制某个类的顺序,而该类本身不支持排序(即没有实现Comparable接口),那么,我们就可以建立一个“该类的比较器”来进行排序,这个“比较器”只需要实现 Comparator 接口即可。也就是说,我们可以通过实现 Comparator 来新建一个比较器,然后通过这个比较器对类进行排序。

示例:

// PersonComparator.java
import java.util.Comparator;

public class PersonComparator implements Comparator<Person> {
    @Override
    public int compare(Person o1, Person o2) {
        return o1.getAge() - o2.getAge();
    }
}

// Main.java
import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        Person[] people = new Person[]{
                new Person("jack", 20), new Person("larry", 10)
        };

        System.out.println("排序前:");
        for (Person person : people) {
            System.out.print(person.getName() + ": " + person.getAge() + "\n");
        }

        Arrays.sort(people, new PersonComparator());
        System.out.println("\n排序后:");
        for (Person person : people) {
            System.out.print(person.getName() + ": " + person.getAge() + "\n");
        }
    }
}

输出:

排序前:
jack: 20
larry: 10

排序后:
larry: 10
jack: 20

总结

Comparable 相当于“内部比较器”,而Comparator 相当于“外部比较器”。

两种方法各有优劣:

  • 用 Comparable 简单, 只要实现 Comparable 接口的对象直接就成为一个可以比较的对象,但是需要修改源代码。

  • 用 Comparator 的好处是不需要修改源代码,而是另外实现一个比较器,当某个自定义的对象需要作比较的时候,把比较器和对象一起传递过去就可以比大小了,并且在 Comparator 里面用户可以自己实现复杂的可以通用的逻辑,使其可以匹配一些比较简单的对象,那样就可以节省很多重复劳动了。

HashSet

HashSet 其实是由 HashMap 实现的,只不过值由一个固定的 Object 对象填充,而键用于操作。

LinkedHashSet

LinkedHashSet 虽然继承自 HashSet,其实是由 LinkedHashMap 实现的。

Queue

Queue 与 Deque 的区别

  • Queue 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。

    Queue 扩展了 Collection 的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。

    Queue 接口 抛出异常 返回特殊值
    插入队尾 add(E e) offer(E e)
    删除队首 remove() poll()
    查询队首元素 element() peek()
  • Deque 是双端队列,在队列的两端均可以插入或删除元素。

    Deque 扩展了 Queue 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:

    Deque 接口 抛出异常 返回特殊值
    插入队首 addFirst(E e) offerFirst(E e)
    插入队尾 addLast(E e) offerLast(E e)
    删除队首 removeFirst() pollFirst()
    删除队尾 removeLast() pollLast()
    查询队首元素 getFirst() peekFirst()
    查询队尾元素 getLast() peekLast()

Deque 还提供有 push() 和 pop() 等其他方法,可用于模拟栈。

ArrayDeque 与 LinkedList 的区别

ArrayDeque 和 LinkedList 都实现了 Deque 接口,两者都具有队列的功能,但两者有什么区别呢?

  • ArrayDeque 是基于可变长的数组和双指针来实现,而 LinkedList 则通过链表来实现。

  • ArrayDeque 不支持存储 NULL 数据,但 LinkedList 支持。

  • ArrayDeque 是在 JDK1.6 才被引入的,而LinkedList 早在 JDK1.2 时就已经存在。

  • ArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。

从性能的角度上,选用 ArrayDeque 来实现队列要比 LinkedList 更好。此外,ArrayDeque 也可以用于实现栈。

PriorityQueue

PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队

  • PriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据;

  • PriorityQueue 通过堆元素的上浮和下沉,实现了在 \(O(log_2n)\) 的时间复杂度内插入元素和删除堆顶元素;

  • PriorityQueue 是非线程安全的,且不支持存储 NULL 和 non-comparable 的对象;

  • PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。

BlockingQueue

JDK7 提供了 7 个阻塞队列。分别是

  • ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。

  • LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。

  • PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。

  • DelayQueue:一个使用优先级队列实现的无界阻塞队列。

  • SynchronousQueue:一个不存储元素的阻塞队列。

  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。

  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

阻塞队列 BlockingQueue 是一个先进先出的队列(Queue)。BlockingQueue 阻塞的原因是:其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。

BlockingQueue 对插入操作、移除操作、获取元素操作提供了四种不同的方法 :

Throws exception Special value Blocks Times out
插入 add(e) offer(e) put(e) offer(e, time, unit)
移除 remove() poll() take() poll(time, unit)
检索 element() peek() not applicable not applicable

对于 BlockingQueue,我们的关注点应该在 put(e) 和 take() 这两个方法,因为这两个方法是带阻塞的。

BlockingQueue 不接受 null 值的插入,相应的方法在碰到 null 的插入时会抛出 NullPointerException 异常。null 值在这里通常用于作为特殊值返回(表格中的第三列),代表 poll 失败。所以,如果允许插入 null 值的话,那获取的时候,就不能很好地用 null 来判断到底是代表失败,还是获取的值就是 null 值。

BlockingQueue 的实现都是线程安全的,但是批量的集合操作如 addAll, containsAll, retainAll 和 removeAll 不一定是原子操作。

BlockingQueue 常用于生产者-消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。

image

一个线程将会持续生产新对象并将其插入到队列之中,直到队列达到它所能容纳的临界点。也就是说,它是有限的。如果该阻塞队列到达了其临界点,负责生产的线程将会在往里边插入新对象时发生阻塞。它会一直处于阻塞之中,直到负责消费的线程从队列中拿走一个对象。

负责消费的线程将会一直从该阻塞队列中拿出对象。如果消费线程尝试去从一个空的队列中提取对象的话,这个消费线程将会处于阻塞之中,直到一个生产线程把一个对象丢进队列。

BlockingQueue 的实现类

ArrayBlockingQueue

ArrayBlockingQueue 是一个有界的阻塞队列,其内部实现是将对象放到一个数组里。

image

ArrayBlockingQueue 是 BlockingQueue 接口的有界队列实现类,底层采用数组来实现。其并发控制采用可重入锁来控制,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。

ArrayBlockingQueue 实现并发同步的原理就是,读操作和写操作都需要获取到 AQS 独占锁才能进行操作。如果队列为空,这个时候读操作的线程进入到读线程队列排队,等待写线程写入新的元素,然后唤醒读线程队列的第一个等待线程。如果队列已满,这个时候写操作的线程进入到写线程队列排队,等待读线程将队列元素移除腾出空间,然后唤醒写线程队列的第一个等待线程。

对于 ArrayBlockingQueue,我们可以在构造的时候指定以下三个参数:

  • 队列容量,其限制了队列中最多允许的元素个数;

  • 指定独占锁是公平锁还是非公平锁。非公平锁的吞吐量比较高,公平锁可以保证每次都是等待最久的线程获取到锁;

  • 可以指定用一个集合来初始化,将此集合中的元素在构造方法期间就先添加到队列中。

DelayQueue

DelayQueue 对元素进行持有直到一个特定的延迟到期。注入其中的元素必须实现 java.util.concurrent.Delayed 接口,该接口定义:

public interface Delayed extends Comparable<Delayed< {
    public long getDelay(TimeUnit timeUnit);
}

DelayQueue 将会在每个元素的 getDelay() 方法返回的值的时间段之后才释放掉该元素。如果返回的是 0 或者负值,延迟将被认为过期,该元素将会在 DelayQueue 的下一次 take 被调用的时候被释放掉。

LinkedBlockingQueue

LinkedBlockingQueue 底层基于单向链表实现的阻塞队列,内部以 FIFO(先进先出) 的顺序对元素进行存储,可以当做无界队列也可以当做有界队列来使用。

LinkedBlockingQueue 的构造函数:

// 构造无界队列,容量为 Integer.MAX_VALUE
LinkedBlockingQueue()

// 构造有界队列,容量为 capacity
LinkedBlockingQueue(int capacity)

LinkedBlockingQueue 的并发读写控制:

image

SynchronousQueue

SynchronousQueue 是一个特殊的同步队列,它的内部同时只能够容纳单个元素。当一个线程往队列中写入一个元素时,写入操作不会立即返回,需要等待另一个线程来将这个元素拿走;同理,当一个读线程做读操作的时候,同样需要一个相匹配的写线程的写操作。这里的 Synchronous 指的就是读线程和写线程需要同步,一个读线程匹配一个写线程。

SynchronousQueue 的队列其实是虚的,其不提供任何空间(一个都没有)来存储元素。数据必须从某个写线程交给某个读线程,而不是写到某个队列中等待被消费。

因此,我们不能在 SynchronousQueue 中使用 peek 方法(在这里这个方法直接返回 null),peek 方法的语义是只读取不移除,显然,这个方法的语义是不符合 SynchronousQueue 的特征的。

另外,SynchronousQueue 也不能被迭代,因为根本就没有元素可以拿来迭代的。虽然 SynchronousQueue 间接地实现了 Collection 接口,但是如果你将其当做 Collection 来用的话,那么集合是空的。当然,这个类也是不允许传递 null 值的。

PriorityBlockingQueue

PriorityBlockingQueue 是带排序的 BlockingQueue 实现,它是 PriorityQueue 的线程安全版本,其并发控制采用的是 ReentrantLock,队列为无界队列。PriorityBlockingQueue 只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容。

PriorityBlockingQueue 不可以插入 null 值,同时,所有插入到 PriorityBlockingQueue 的元素必须实现 java.lang.Comparable 接口,否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。

PriorityBlockingQueue 使用了基于数组的二叉堆来存放元素,所有的 public 方法采用同一个 lock 进行并发控制。

二叉堆:一颗完全二叉树,它非常适合用数组进行存储,对于数组中的元素 a[i],其左子节点为 a[2*i+1],其右子节点为 a[2*i + 2],其父节点为 a[(i-1)/2],其堆序性质为,每个节点的值都小于其左右子节点的值。二叉堆中最小的值就是根节点,但是删除根节点是比较麻烦的,因为需要调整树。

如下图所示,二叉堆这种数据结构的优点是一目了然的,最小的元素一定是根元素,它是一棵满的树,除了最后一层,最后一层的节点从左到右紧密排列。

image

Map

HashTable

Hashtable 底层数据结构采用的是 数组 + 链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;

image

Hashtable 使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

HashMap

JDK 1.7 的 HashMap 实现

JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列

HashMap 通过 key 的 hashcode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。

“拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

image

hash 方法的源码实现:

static int hash(int h) {
    // 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);
}

JDK 1.8 的 HashMap 实现

相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

image

TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。

hash 方法的源码实现:

static final int hash(Object key) {
    int h;
    // key.hashCode():返回散列值也就是hashcode
    // ^:按位异或
    // >>>:无符号右移,忽略符号位,空位都以0补齐
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

源码分析

下面我们来结合源码分析一下 HashMap 链表到红黑树的转换。

链表转红黑树

链表的长度大于 8 的时候,就执行 treeifyBin (转换红黑树)的逻辑。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    HashMap.Node<K, V>[] tab; HashMap.Node<K, V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 判断是否出现 hash 碰撞
    if ((p = tab[i = (n - 1) & hash]) == null) // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
        tab[i] = newNode(hash, key, value, null);
    else {  // 桶中已经存在元素,处理hash冲突
        HashMap.Node<K, V> e; K k;
        // 首先,判断该位置的第一个数据和我们要插入的数据,key 是不是"相等",如果是,取出这个节点
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 如果该节点是代表红黑树的节点,调用红黑树的插值方法
        else if (p instanceof HashMap.TreeNode) 
            e = ((HashMap.TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
        else {
            // 到这里,说明数组该位置上是一个链表
            for (int binCount = 0; ; ++binCount) {  // 遍历链表
                if ((e = p.next) == null) {  // 遍历到链表最后一个节点
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // 如果链表元素个数大于等于8,其中,TREEIFY_THRESHOLD = 8
                        treeifyBin(tab, hash);  // 将链表转换为红黑树
                    break;
                }
                // 如果在该链表中找到了"相等"的 key(== 或 equals)
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break; // 此时 break,那么, e 为链表中[与要插入的新值的 key "相等"]的 node
                p = e;
            }
        }
        if (e != null) {  // e!=null 说明存在旧值的key与要插入的key"相等"
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null) // 进行 "值覆盖",然后返回旧值
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 如果 HashMap 由于新插入这个值导致 size 已经超过了阈值,需要进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);  // 插入后回调
    return null;
}
判断是否真的转换为红黑树

将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 判断当前数组的长度是否小于 64
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        // 如果当前数组的长度小于 64,那么会选择先进行数组扩容
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 否则才将列表转换为红黑树
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

HashMap 的长度为什么是 2 的幂次方

为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。因为 Hash 值的范围值 -2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。

这个数组下标的计算方法是: (n - 1) & hash,其中,n 代表数组长度。这也就解释了 HashMap 的长度为什么是 2 的幂次方。

HashMap 多线程操作导致死循环问题

JDK1.7 及之前版本的 HashMap 在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。

为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在数据覆盖的问题,并发环境下,推荐使用 ConcurrentHashMap。

HashMap 为什么线程不安全

JDK1.7 及之前版本,在多线程环境下,HashMap 扩容时会造成死循环和数据丢失的问题。数据丢失问题在 JDK1.7 和 JDK 1.8 中都存在。

JDK 1.8 后,在 HashMap 中,多个键值对可能会被分配到同一个桶(bucket),并以链表或红黑树的形式存储。多个线程对 HashMap 的 put 操作会导致线程不安全,会有数据覆盖的风险。

失效场景一:插入数据时多个线程命中了同一个槽,可能导致数据被覆盖

  • 两个线程 1、2 同时进行 put 操作,并且发生了哈希冲突。

  • 不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起,线程 2 先完成了插入操作。

  • 随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所以,此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。

失效场景二:

  • 线程 1 执行 if(++size > threshold) 判断时,假设获得 size 的值为 10,由于时间片耗尽挂起。

  • 线程 2 也执行 if(++size > threshold) 判断,获得 size 的值也为 10,并将元素插入到该桶位中,并将 size 的值更新为 11。

  • 随后,线程 1 获得时间片,它也将元素放入桶位中,并将 size 的值更新为 11。

  • 线程 1、2 都执行了一次 put 操作,但是 size 的值只增加了 1,也就导致实际上只有一个元素被添加到了 HashMap 中。

HashMap 和 Hashtable 的区别

  • 线程是否安全

    HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过 synchronized 修饰。

  • 效率

    因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它。

  • 对 Null key 和 Null value 的支持

    • HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;

    • Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException。

  • 初始容量大小和每次扩充容量大小的不同:

    • 创建时如果不指定容量初始值

      • Hashtable 创建时如果不指定容量初始值,默认的初始大小为 11,之后每次扩充,容量变为原来的 2n + 1;

      • HashMap 默认的初始化大小为 16,之后每次扩充,容量变为原来的 2 倍。

    • 创建时如果给定了容量初始值

      • Hashtable 会直接使用你给定的大小;

      • HashMap 会将其扩充为 2 的幂次方大小。

  • 底层数据结构

    JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。Hashtable 没有这样的机制。

HashSet

HashSet 是基于 HashMap 实现的,底层采用 HashMap 来保存元素

元素的哈希值是通过元素的 hashcode 方法 来获取的, HashSet 首先判断两个元素的哈希值,如果哈希值一样,接着会比较 equals 方法 如果 equls 结果为 true ,HashSet 就视为同一个元素。如果 equals 为 false 就不是同一个元素。

ConcurrentHashMap

ConcurrentHashMap 底层实现

  • JDK1.7 采用的数据结构是:分段的数组 + 链表;

  • JDK1.8 采用的数据结构是:数组 + 链表/红黑二叉树。

ConcurrentHashMap 实现线程安全的方式

JDK 1.7 的 ConcurrentHashMap 实现

JDK 1.7 的 ConcurrentHashMap 使用:Segment 数组HashEntry 数组结构来实现。其中,Segment 数组中的每个元素包含一个 HashEntry 数组,每个 HashEntry 数组属于链表结构。

image

JDK 1.7 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。其中,Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色;HashEntry 用于存储键值对数据。

ConcurrentHashMap 首先将数据分段存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的个数一旦初始化就不能改变。Segment 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。

也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的。

JDK 1.8 的 ConcurrentHashMap 实现

JDK 1.8 的 ConcurrentHashMap 使用:Node 数组 + 链表 / 红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。其中,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。当冲突链表达到一定长度时,链表会转换成红黑树。

image

TreeNode 是存储红黑树节点,被 TreeBin 包装。TreeBin 通过 root 属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 ConcurrentHashMap 中 TreeBin 通过 waiter 属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。

在链表长度超过一定阈值(8)时,将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(logN))。

Java 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。

对比

  • 线程安全实现方式

    • JDK 1.7 采用 Segment 分段锁来保证安全,Segment 是继承自 ReentrantLock;

    • JDK 1.8 采用 Node + CAS + synchronized 保证线程安全,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点。

  • Hash 碰撞解决方法

    • JDK 1.7 采用拉链法;

    • JDK 1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。

  • 并发度

    • JDK 1.7 最大并发度是 Segment 的个数,默认是 16。

    • JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。

ConcurrentHashMap 为什么 key 和 value 不能为 null

ConcurrentHashMap 的 key 和 value 不能为 null 主要是为了避免二义性。

null 是一个特殊的值,表示没有对象或没有引用。如果你用 null 作为键,那么,就无法区分这个键是否存在于 ConcurrentHashMap 中,还是根本没有这个键。同样,如果用 null 作为值,那么,就无法区分这个值是否是真正存储在 ConcurrentHashMap 中的,还是因为找不到对应的键而返回的。

以 get 方法为例,返回的结果为 null 存在两种情况:

  • 值没有在集合中;

  • 值本身就是 null。

这也就是二义性的由来。

多线程环境下,存在一个线程操作该 ConcurrentHashMap 时,其他的线程将该 ConcurrentHashMap 修改的情况,所以无法通过 containsKey(key) 来判断否存在这个键值对,也就没办法解决二义性问题了。

也就是说,多线程下无法正确判定键值对是否存在(存在其他线程修改的情况),单线程是可以的(不存在其他线程修改的情况)。

ConcurrentHashMap 的复合操作的原子性

ConcurrentHashMap 提供了一些原子性的复合操作,如 putIfAbsent、compute、computeIfAbsent 、computeIfPresent、merge等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。

示例:

// 线程 A
map.putIfAbsent(key, value);
// 线程 B
map.putIfAbsent(key, anotherValue);

// 线程 A
map.computeIfAbsent(key, k -> value);
// 线程 B
map.computeIfAbsent(key, k -> anotherValue);

虽然可以通过加锁同步,但是,一般不建议使用加锁的同步机制,因为,它违背了使用 ConcurrentHashMap 的初衷。因此,在使用 ConcurrentHashMap 的时候,尽量使用这些原子性的复合操作方法来保证原子性。


参考:

posted @ 2023-10-10 17:45  LARRY1024  阅读(36)  评论(0编辑  收藏  举报