hashmap扩容_hashMap底层代码探究以及锁的认知

本文深入探讨了HashMap在JDK1.8中的实现细节,包括其内部结构、红黑树的引入以及扩容优化。同时,分析了加载因子为何选择0.75的原因。此外,介绍了并发编程中的锁机制,讲解了死锁的概念及示例,并讨论了悲观锁、乐观锁、可重入锁以及共享锁和独占锁的基本原理和应用场景。
摘要由CSDN通过智能技术生成

1b6070e7397758ae081e5085a7a6ea19.png

1、HashMap分析

HashMap 是使用频率最高的类型之一,同时也是面试经常被问到的问题之一,这是因为 HashMap 的知识点有很多,同时它又属于 Java 基础知识的一部分,因此在面试中经常被问到。 那么,HashMap 底层是如何实现的?在 JDK 1.8 中它都做了哪些优化?

在 JDK 1.7 中 HashMap 是以数组加链表的形式组成的,JDK 1.8 之后新增了红黑树的组成结构,当链表大于 8 并且容量大于 64 时,链表结构会转换成红黑树结构,它的组成结构如下图所示:

6ec480f562c05ebcdffba5ea78504b01.png

数组中的元素我们称之为哈希桶,它的定义如下:

static class Node<K,V> implements Map.Entry<K,V> {

    final int hash;

    final K key;

    V value;

    Node<K,V> next;

    Node(int hash, K key, V value, Node<K,V> next) {

        this.hash = hash;

        this.key = key;

        this.value = value;

        this.next = next;

    }

    public final K getKey()        { return key; }

    public final V getValue()      { return value; }

    public final String toString() { return key + "=" + value; }

    public final int hashCode() {

        return Objects.hashCode(key) ^ Objects.hashCode(value);

    }

    public final V setValue(V newValue) {

        V oldValue = value;

        value = newValue;

        return oldValue;

    }

    public final boolean equals(Object o) {

        if (o == this)

            return true;

        if (o instanceof Map.Entry) {

            Map.Entry<?,?> e = (Map.Entry<?,?>)o;

            if (Objects.equals(key, e.getKey()) &&

                Objects.equals(value, e.getValue()))

                return true;

        }

        return false;

    }

}

可以看出每个哈希桶中包含了四个字段:hash、key、value、next,其中 next 表示链表的下一个节点。

JDK 1.8 之所以添加红黑树是因为一旦链表过长,会严重影响 HashMap 的性能,而红黑树具有快速增删改查的特点,这样就可以有效的解决链表过长时操作比较慢的问题

我们再次深入了解一下,JDK 1.8 HashMap 扩容时做了哪些优化?加载因子为什么是 0.75?当有哈希冲突时,HashMap 是如何查找并确认元素的?HashMap 源码中有哪些重要的方法?HashMap 源码中包含了以下几个属性:

// HashMap 初始化长度static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16// HashMap 最大长度static final int MAXIMUM_CAPACITY = 1 << 30; // 1073741824// 默认的加载因子 (扩容因子)static final float DEFAULT_LOAD_FACTOR = 0.75f;// 当链表长度大于此值且容量大于 64 时static final int TREEIFY_THRESHOLD = 8;// 转换链表的临界值,当元素小于此值时,会将红黑树结构转换成链表结构static final int UNTREEIFY_THRESHOLD = 6;// 最小树容量static final int MIN_TREEIFY_CAPACITY =

什么是加载因子?加载因子为什么是 0.75?

加载因子也叫扩容因子或负载因子,用来判断什么时候进行扩容的,假如加载因子是 0.5,HashMap 的初始化容量是 16,那么当 HashMap 中有 16*0.5=8 个元素时,HashMap 就会进行扩容。

那加载因子为什么是 0.75 而不是 0.5 或者 1.0 呢?

这其实是出于容量和性能之间平衡的结果:

  • 当加载因子设置比较大的时候,扩容的门槛就被提高了,扩容发生的频率比较低,占用的空间会比较小,但此时发生 Hash 冲突的几率就会提升,因此需要更复杂的数据结构来存储元素,这样对元素的操作时间就会增加,运行效率也会因此降低;
  • 而当加载因子值比较小的时候,扩容的门槛会比较低,因此会占用更多的空间,此时元素的存储就比较稀疏,发生哈希冲突的可能性就比较小,因此操作性能会比较高。

所以综合了以上情况就取了一个 0.5 到 1.0 的平均数 0.75 作为加载因子。

HashMap 源码中三个重要方法:查询、新增和数据扩容

查询源码:

// HashMap 初始化长度

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// HashMap 最大长度

static final int MAXIMUM_CAPACITY = 1 << 30; // 1073741824

// 默认的加载因子 (扩容因子)

static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 当链表长度大于此值且容量大于 64 时

static final int TREEIFY_THRESHOLD = 8;

// 转换链表的临界值,当元素小于此值时,会将红黑树结构转换成链表结构

static final int UNTREEIFY_THRESHOLD = 6;

// 最小树容量

static final int MIN_TREEIFY_CAPACITY =

从以上源码可以看出,当哈希冲突时我们需要通过判断 key 值是否相等,才能确认此元素是不是我们想要的元素。新增方法:

public V put(K key, V value) {    // 对 key 进行哈希操作    return putVal(hash(key), key, value, false, true);}final V putVal(int hash, K key, V value, boolean onlyIfAbsent,               boolean evict) {    Node<K,V>[] tab; Node<K,V> p; int n, i;    // 哈希表为空则创建表    if ((tab = table) == null || (n = tab.length) == 0)        n = (tab = resize()).length;    // 根据 key 的哈希值计算出要插入的数组索引 i    if ((p = tab[i = (n - 1) & hash]) == null)        // 如果 table[i] 等于 null,则直接插入        tab[i] = newNode(hash, key, value, null);    else {        Node<K,V> e; K k;        // 如果 key 已经存在了,直接覆盖 value        if (p.hash == hash &&            ((k = p.key) == key || (key != null && key.equals(k))))            e = p;        // 如果 key 不存在,判断是否为红黑树        else if (p instanceof TreeNode)            // 红黑树直接插入键值对            e = ((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) // -1 for 1st                        treeifyBin(tab, hash);                    break;                }                //  key 已经存在直接覆盖 value                if (e.hash == hash &&                    ((k = e.key) == key || (key != null && key.equals(k))))                    break;                p = e;            }        }        if (e != null) { // existing mapping for key            V oldValue = e.value;            if (!onlyIfAbsent || oldValue == null)                e.value = value;            afterNodeAccess(e);            return oldValue;        }    }    ++modCount;    // 超过最大容量,扩容    if (++size > threshold)        resize();    afterNodeInsertion(evict);    return null;}

新增方法的流程如下:

7a70e95c8c5f6c7ca78a296bbd8309c7.png

扩容方法:

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;

        }

        // 扩大容量为当前容量的两倍,但不能超过 MAXIMUM_CAPACITY

        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

        // 如果初始化的值为 0,则使用默认的初始化容量

        newCap = DEFAULT_INITIAL_CAPACITY;

        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

    }

    // 如果新的容量等于 0

    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

    table = newTab;

    // 原数据不为空,将原数据复制到新 table 中

    if (oldTab != null) {

        // 根据容量循环数组,复制非空元素到新 table

        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

                    // 链表复制,JDK 1.8 扩容优化部分

                    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;

                        }

                        // 原索引 + oldCap

                        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;

                    }

                    // 将原索引 + oldCap 放到哈希桶中

                    if (hiTail != null) {

                        hiTail.next = null;

                        newTab[j + oldCap] = hiHead;

                    }

                }

            }

        }

    }

    return newTab;

}

从以上源码可以看出,JDK 1.8 在扩容时通过高位运算(e.hash & oldCap)来确定元素是否需要移动,比如 key1 的信息如下:

  • key1.hash = 10 0000 1010
  • oldCap = 16 0001 0000

使用 e.hash & oldCap 得到的结果,高一位为 0,当结果为 0 时表示元素在扩容时位置不会发生任何变化,而 key 2 信息如下:

  • key2.hash = 10 0001 0001
  • oldCap = 16 0001 0000

这时候得到的结果,高一位为 1,当结果为 1 时,表示元素在扩容时位置发生了变化,新的下标位置等于原下标位置 + 原数组长度,如下图所示:

76b7472bf6bb95f961952d400b83c085.png

其中红色的虚线图代表了扩容时元素移动的位置。

a2ac3274750b42a44a0e9032f12185ca.png

2、趣味谈锁

在并发编程中有两个重要的概念:线程和锁,多线程是一把双刃剑,它在提高程序性能的同时,也带来了编码的复杂性,对开发者的要求也提高了一个档次。而锁的出现就是为了保障多线程在同时操作一组资源时的数据一致性,当我们给资源加上锁之后,只有拥有此锁的线程才能操作此资源,而其他线程只能排队等待使用此锁。今天我们就来讲讲锁。死锁是指两个线程同时占用两个资源,又在彼此等待对方释放锁资源

abddf3c3db634c25091e4dd444b9020b.png

import java.util.concurrent.TimeUnit;

public class LockExample {

    public static void main(String[] args) {

        deadLock(); // 死锁

    }

    /**

     * 死锁

     */

    private static void deadLock() {

        Object lock1 = new Object();

        Object lock2 = new Object();

        // 线程一拥有 lock1 试图获取 lock2

        new Thread(() -> {

            synchronized (lock1) {

                System.out.println("获取 lock1 成功");

                try {

                    TimeUnit.SECONDS.sleep(3);

                } catch (InterruptedException e) {

                    e.printStackTrace();

                }

                // 试图获取锁 lock2

                synchronized (lock2) {

                    System.out.println(Thread.currentThread().getName());

                }

            }

        }).start();

        // 线程二拥有 lock2 试图获取 lock1

        new Thread(() -> {

            synchronized (lock2) {

                System.out.println("获取 lock2 成功");

                try {

                    TimeUnit.SECONDS.sleep(3);

                } catch (InterruptedException e) {

                    e.printStackTrace();

                }

                // 试图获取锁 lock1

                synchronized (lock1) {

                    System.out.println(Thread.currentThread().getName());

                }

            }

        }).start();

    }

}
以上程序执行结果如下:

获取 lock1 成功获取 lock2 成功

可以看出当我们使用线程一拥有锁 lock1 的同时试图获取 lock2,而线程二在拥有 lock2 的同时试图获取 lock1,这样就会造成彼此都在等待对方释放资源,于是就形成了死锁。

锁是指在并发编程中,当有多个线程同时操作一个资源时,为了保证数据操作的正确性,我们需要让多线程排队一个一个地操作此资源,而这个过程就是给资源加锁和释放锁的过程,就好像去公共厕所一样,必须一个一个排队使用,并且在使用时需要锁门和开门一样。

那么,什么是乐观锁和悲观锁?他们的应用有哪些呢?还有什么是可重入锁,实现的原理是什么呢?什么是共享锁和独占锁呢?让我们一探究竟吧。

1> 悲观锁和乐观锁

悲观锁指的是数据对外界的修改采取保守策略,它认为线程很容易会把数据修改掉,因此在整个数据被修改的过程中都会采取锁定状态,直到一个线程使用完,其他线程才可以继续使用。

我们来看一下悲观锁的实现流程,以 synchronized 为例,代码如下:

public class LockExample {

    public static void main(String[] args) {

        synchronized (LockExample.class) {

            System.out.println("lock");

        }

    }

}
我们使用反编译工具查到的结果如下:

Compiled from "LockExample.java"

public class com.lagou.interview.ext.LockExample {

  public com.lagou.interview.ext.LockExample();

    Code:

       0: aload_0

       1: invokespecial #1                  // Method java/lang/Object."<init>":()V

       4: return

 

  public static void main(java.lang.String[]);

    Code:

       0: ldc           #2                  // class com/lagou/interview/ext/LockExample

       2: dup

       3: astore_1

       4: monitorenter // 加锁

       5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;

       8: ldc           #4                  // String lock

      10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V

      13: aload_1

      14: monitorexit // 释放锁

      15: goto          23

      18: astore_2

      19: aload_1

      20: monitorexit

      21: aload_2

      22: athrow

      23: return

    Exception table:

       from    to  target type

           5    15    18   any

          18    21    18   any

}

可以看出被 synchronized 修饰的代码块,在执行之前先使用 monitorenter 指令加锁,然后在执行结束之后再使用 monitorexit 指令释放锁资源,在整个执行期间此代码都是锁定的状态,这就是典型悲观锁的实现流程

乐观锁和悲观锁的概念恰好相反,乐观锁认为一般情况下数据在修改时不会出现冲突,所以在数据访问之前不会加锁,只是在数据提交更改时,才会对数据进行检测。

Java 中的乐观锁大部分都是通过 CAS(Compare And Swap,比较并交换)操作实现的,CAS 是一个多线程同步的原子指令,CAS 操作包含三个重要的信息,即内存位置、预期原值和新值。如果内存位置的值和预期的原值相等的话,那么就可以把该位置的值更新为新值,否则不做任何修改。

CAS 可能会造成 ABA 的问题,ABA 问题指的是,线程拿到了最初的预期原值 A,然而在将要进行 CAS 的时候,被其他线程抢占了执行权,把此值从 A 变成了 B,然后其他线程又把此值从 B 变成了 A,然而此时的 A 值已经并非原来的 A 值了,但最初的线程并不知道这个情况,在它进行 CAS 的时候,只对比了预期原值为 A 就进行了修改,这就造成了 ABA 的问题。

以警匪剧为例,假如某人把装了 100W 现金的箱子放在了家里,几分钟之后要拿它去赎人,然而在趁他不注意的时候,进来了一个小偷,用空箱子换走了装满钱的箱子,当某人进来之后看到箱子还是一模一样的,他会以为这就是原来的箱子,就拿着它去赎人了,这种情况肯定有问题,因为箱子已经是空的了,这就是 ABA 的问题。

ABA 的常见处理方式是添加版本号,每次修改之后更新版本号,拿上面的例子来说,假如每次移动箱子之后,箱子的位置就会发生变化,而这个变化的位置就相当于“版本号”,当某人进来之后发现箱子的位置发生了变化就知道有人动了手脚,就会放弃原有的计划,这样就解决了 ABA 的问题。

JDK 在 1.5 时提供了 AtomicStampedReference 类也可以解决 ABA 的问题,此类维护了一个“版本号” Stamp,每次在比较时不止比较当前值还比较版本号,这样就解决了 ABA 的问题。

源码如下

public class AtomicStampedReference<V> {

    private static class Pair<T> {

        final T reference;

        final int stamp; // “版本号”

        private Pair(T reference, int stamp) {

            this.reference = reference;

            this.stamp = stamp;

        }

        static <T> Pair<T> of(T reference, int stamp) {

            return new Pair<T>(reference, stamp);

        }

    }

    // 比较并设置

    public boolean compareAndSet(V   expectedReference,

                                 V   newReference,

                                 int expectedStamp, // 原版本号

                                 int newStamp) { // 新版本号

        Pair<V> current = pair;

        return

            expectedReference == current.reference &&

            expectedStamp == current.stamp &&

            ((newReference == current.reference &&

              newStamp == current.stamp) ||

             casPair(current, Pair.of(newReference, newStamp)));

    }

    //.......省略其他源码

}

可以看出它在修改时会进行原值比较和版本号比较,当比较成功之后会修改值并修改版本号。2> 可重入锁

可重入锁也叫递归锁,指的是同一个线程,如果外面的函数拥有此锁之后,内层的函数也可以继续获取该锁。在 Java 语言中 ReentrantLock 和 synchronized 都是可重入锁。下面我们用 synchronized 来演示一下什么是可重入锁,代码如下:

public class LockExample {

    public static void main(String[] args) {

        reentrantA(); // 可重入锁

    }

    /**

     * 可重入锁 A 方法

     */

    private synchronized static void reentrantA() {

        System.out.println(Thread.currentThread().getName() + ":执行 reentrantA");

        reentrantB();

    }

    /**

     * 可重入锁 B 方法

     */

    private synchronized static void reentrantB() {

        System.out.println(Thread.currentThread().getName() + ":执行 reentrantB");

    }

}

以上代码的执行结果如下:

main:执行 reentrantA
main:执行 reentrantB

从结果可以看出 reentrantA 方法和 reentrantB 方法的执行线程都是“main” ,我们调用了 reentrantA 方法,它的方法中嵌套了 reentrantB,如果 synchronized 是不可重入的话,那么线程会被一直堵塞。

可重入锁的实现原理,是在锁内部存储了一个线程标识,用于判断当前的锁属于哪个线程,并且锁的内部维护了一个计数器,当锁空闲时此计数器的值为 0,当被线程占用和重入时分别加 1,当锁被释放时计数器减 1,直到减到 0 时表示此锁为空闲状态。

3> 共享锁和独占锁

只能被单线程持有的锁叫独占锁,可以被多线程持有的锁叫共享锁。

独占锁指的是在任何时候最多只能有一个线程持有该锁,比如 synchronized 就是独占锁,而 ReadWriteLock 读写锁允许同一时间内有多个线程进行读操作,它就属于共享锁。

独占锁可以理解为悲观锁,当每次访问资源时都要加上互斥锁,而共享锁可以理解为乐观锁,它放宽了加锁的条件,允许多线程同时访问该资源

最后,总结一下,悲观锁的典型应用为 synchronized,它的特性为独占式互斥锁;而乐观锁相比于悲观锁而言,拥有更好的性能,但乐观锁可能会导致 ABA 的问题,常见的解决方案是添加版本号来防止 ABA 问题的发生。同时,还讲了可重入锁,在 Java 中,synchronized 和 ReentrantLock 都是可重入锁。最后,讲了独占锁和共享锁,其中独占锁可以理解为悲观锁,而共享锁可以理解为乐观锁。 最后,祝大家生活愉快!

20dd70f4a0d69da8046e577a9a4dfaa2.png
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值