草庐IT

Java 中HashMap详解(含HashTable, ConcurrentHashMap)

adeline-tech 2023-04-17 原文

本篇重点:

1.HashMap的存储结构

2.HashMap的put和get操作过程

3.HashMap的扩容

4.关于transient关键字

5.HashMap, HashTable, ConcurrentHashMap 对照

6.关于volatile关键字

 HashMap的存储结构

1. HashMap 总体是数组+链表的存储结构, 从JDK1.8开始,当数组的长度大于64,且链表的长度大于8的时候,会把链表转为红黑树。

2. 数组的默认长度是16。数组中的每一个元素为一个node,也就是链表的一个节点,node的数据包含: key的hashcode, key, value,指向下一个node节点的指针。

部分源码如下:

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

3. 随着put操作的进行,如果数组的长度超过64,且链表的长度大于8的时候, 则将链表转为红黑树,红黑树节点的结构如下,TreeNode继承的LinkedHashMap.Entry是继承HashMap.Node的,所以TreeNode是上面Node的子类。

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }
//...
}

4. HashMap类的主要成员变量:

/* ---------------- Fields -------------- */

    /**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
    transient Node<K,V>[] table;

    /**
     * Holds cached entrySet(). Note that AbstractMap fields are used
     * for keySet() and values().
     */
    transient Set<Map.Entry<K,V>> entrySet;

    /**
     * The number of key-value mappings contained in this map.
     */
    transient int size;

    /**
     * The number of times this HashMap has been structurally modified
     * Structural modifications are those that change the number of mappings in
     * the HashMap or otherwise modify its internal structure (e.g.,
     * rehash).  This field is used to make iterators on Collection-views of
     * the HashMap fail-fast.  (See ConcurrentModificationException).
     */
    transient int modCount;

    /**
     * The next size value at which to resize (capacity * load factor).
     *
     * @serial
     */
    // (The javadoc description is true upon serialization.
    // Additionally, if the table array has not been allocated, this
    // field holds the initial array capacity, or zero signifying
    // DEFAULT_INITIAL_CAPACITY.)
    int threshold;

    /**
     * The load factor for the hash table.
     *
     * @serial
     */
    final float loadFactor;
View Code

HashMap的put操作过程

本小节讲述put操作中的主要步骤,细小环节会忽略。

1. map.put(key, value),首先计算key的hash,得到一个int值。

2.如果Node数组为空则初始化Node数组。这里注意,Node数组的长度length始终应该是2的n次方,比如默认的16, 还有32,64等 

3.用 hash&(length-1) 运算得到数组下标,这里要提一句,其实正常我们最容易想到的,而且也是我之前很长一段时间以为的,这一步应该进行的是求模运算:hash % length,这样得到的正好是0~length-1之间的值,可以作为数组的下标,那么为何此处是位与运算呢?

先说结论:上面提到数组的长度length始终是2^n,在这个前提下,hash & (length-1) 与hash % length是等价的。 而位与运算更快。这里另开一遍进行详解:HashMap的哈希函数为何用(n - 1) & hash 

4.  如果Node[hash&(length-1)]处为空,用传入的的key, value创建Node对象,直接放入该下标;如果该下标处不为空,且对象为TreeNode类型,证明此下标处的元素们是按照红黑树的结构存储的,将传入的key,value作为新的红黑树的节点插入到红黑树;否则,此处为链表,用next找到链表的末尾,将新的元素插入。如果在遍历链表的过程中发现链表的长度超过了8,此时如果数组长度<64则进行扩容,否则转红黑树。

5. 如果key的hash和key本身都相等则将该key对应的value更新为新的value

6. 需要扩容的话则进行扩容。

注意:

1. 如果key是null则返回的hash为0,也就是key为null的元素一直被放在数组下标为0的位置。

2. 在JDK 1.8以前,链表是采用的头部插入的方式,从1.8改成了在链表尾部插入新元素的方式。 这么做是为了防止在扩容的时候,多线程时出现循环链表死循环。具体会新开一遍进行详细演绎。

HashMap的get操作过程

get的过程比较简单。

1. map.get(key). 首先计算key的hash。

2. 根据hash&(length-1)定位到Node数组中的一个下标。如果该下标的元素(也就是链表/红黑树的第一个元素)中key的hash的key本身都和传入的key相同,则证明找到了元素,直接返回即可。

3.如果第一个元素不是要找的,如果第一个元素的类型是TreeNode,则按照红黑树的查找方法查找元素,如果不是则证明是链表,按照next指针找下去,直到找到或者到达队尾。

HashMap的扩容

先说这里的两个概念: size, length.

size:是map.size() 方法返回的值,表示的是map中有多少个key-value键值对儿

length: 这里是指Node数组的长度,比如默认长度是16.

如下面的代码:

        Map<Integer,String> map = new HashMap<>();
        map.put(1,"a");
        map.put(2,"b");
        map.put(3,"c");    

没有在构造函数中指定HashMap的大小,则数组的长度length取默认的16,put了3个元素,则size为3.

Q: 何时需要扩容呢?

A: 在put方法中,每次完成了put操作,都判断一下++size是否大于threshold,如果大于则进行扩容: 调用resize()方法。

Q: 那么threshold又是如何得到的呢?

A: 简单来讲threshold = length * loadfactor(默认为0.75)。 也就是说默认情况下,map中的键值对的个数(size)大于Node数组长度(length)的75%时,就需要扩容了。 

Q: 扩容时具体做什么呢?

A: 首先计算出新的数组长度和新的threshold(阈值). 简单来讲,新的length/capacity 是原来的2倍(位运算左移一位),新的threshold为原来的2倍。 还有一些细节此处不再赘述。创建新的Node数组,将原来数组中的元素重新映射到新的数组中。

关于transient关键字

transient关键字的作用:用transient关键字修饰的字段不会被序列化

查看下面的例子:

public class TransientExample implements Serializable{
    private String firstName;
    private transient String middleName;
    private String lastName;

    public TransientExample(String firstName,String middleName,String lastName) {
        this.firstName = firstName;
        this.middleName = middleName;
        this.lastName = lastName;
    }
    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("firstName:").append(firstName).append("\n")
                .append("middleName:").append(middleName).append("\n")
                .append("lastName:").append(lastName);
        return sb.toString();


    }


    public static void main(String[] args) throws Exception {
        TransientExample e = new TransientExample("Adeline","test","Pan");

        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("/path/testObj"));
        oos.writeObject(e);

        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("/path/testObj"));
        TransientExample e1 = (TransientExample) ois.readObject();

        System.out.println("e:"+e.toString());
        System.out.println("e1:"+e1.toString());


    }
}
View Code

输出结果:

e:firstName:Adeline
middleName:test
lastName:Pan
e1:firstName:Adeline middleName:
null lastName:Pan

被transient关键字修饰的middleName字段没有被序列化,反序列化回来的值是null

Q:HashMap类是实现了Serializable接口的,那么为何其中的table, entrySet变量都标为transient呢?

A:我们知道,table数组中元素分布的下标位置是根据元素中key的hash进行散列运算得到的,而hash运算是native的,不同平台得到的结果可能是不相同的。举一个简单的例子,假设我们在目前的平台有键值对 key1-value1,计算出key1的hash为1, 计算后存在table数组中下标为1的地方,假设table被序列化了,并传输到了另外的平台,并反序列化为了原来的HashMap,key1-value1仍然存在下标1的位置,当在这个平台运行get("key1")的时候,可能计算出key1的hash为2,就有可能到下标为2的地方去找该元素,这样就出错了。

Q:那么HashMap是如何实现的序列化呢?

A:HashMap是通过实现如下方法直接将元素数量(size), key, value等写入到了ObjectOutputStream中,实现的定制化的序列化和反序列化。在Serializable接口中有关于这种做法的说明。

private void writeObject(java.io.ObjectOutputStream out) throws IOException

private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException;

HashMap, HashTable, ConcurrentHashMap 对照

这里只记录主要的不同点, 实现细节的不同忽略。

1. HashMap允许key 和 value为null, key为null的元素会存储在数组下标为0的位置,HashTable 中key和value都不允许为null, 否则会抛NPE

2. HashTable中put, get, remove等方法都使用synchronized关键字修饰, 也就是HashTable是线程安全的,HashMap不是线程安全的

3. HashTable是在方法级别做的线程同步, 虽然线程安全,但是对性能的影响较大。ConcurrentHashMap进行了优化, 下面结合多线程下可能产生冲突的地方分析ConcurrentHashMap的不同之处:

1)在put或者remove元素时,这个元素映射到的table数组下标处的链表/红黑树, 如果有其他线程同时操作这个链表/红黑树则可能造成混乱。所以冲突的资源是table数组中某一个需要被操作的下标处,所以操作时只要锁住这个下标处第一个Node节点即可。

 

 2)对于何时resize和如何resize。在多线程情况下,不能用一个简单的size是否到达阈值来判断是否需要resize,这样很有可能造成两个线程同时进行resize而造成混乱。我查看了一下ConcurrentHashMap的源代码,果然没有看到size这个变量了,put操作的最后,也不是简单的进行++size>threshold来判断是否进行resize了,而是一个新的addCount方法,目前还没太看懂。

3)在一个线程A进行get(key)操作时,可能有另外的线程B也正在操作这个key,比如可能修改,或者删除。那么线程B的操作结果在写回主存之前是对线程A不可见的,也就是线程A可能会读到一个已经无效的数据。但是get方法中并没有synchronized关键字,此处是使用的volatile来保证的, 下一小节再多说一点volatile关键字。

关于volatile关键字

volatile关键字的作用

volatile关键字是保证共享变量在一个线程中的修改, 在其他线程中可见。我们知道Java运行过程中,会把变量从主内存中拷贝到各自的工作内存中(cpu cache),可能会对变量进行改变,操作完后会把改变后的值再写会到主存。如果两个线程同时读取主存的某个变量,其中一个线程对变量进行了修改,那么这个修改可能对另一个线程不可见。如下图(这个懒得画了,Google的图): Thread 1 和Thread 2同时从主存中读取了counter = 0, 然后Thread1将其改为了7,那么这个修改Thread2是不知道的,还当counter = 0。如果counter这个变量是用volatile修饰就可以避免这种情况。

 

如下面的例子:

public class VolatileExample1 {
    private static volatile int[] num = new int[] {1,1};

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            System.out.println("Thread 1 starts to sleep 1s");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            num[0] = 0;
            System.out.println("Thread 1 finished and changed  values");

        }).start();

        while(true) {
            if(num[0] == 0) {
                System.out.println("see the change, main thread ends");
                break;
            }
        }
    }
}

如狗num变量用volatile修饰, 则结果如下:

Thread 1 starts to sleep 1s
Thread 1 finished and changed  values
see the change, main thread ends

主线程可以看到num[0]被修改成了0, 可以退出

如果去掉volatile,输出只有前两句,主线程迟迟观察不到数据的修改,没有退出。

ConcurrentHashMap(CHM)中使用volatile

 接上一小节,get方法中没有使用synchronized关键字,那是如果保证get的时候对于其他线程可能的修改的可见性的呢?我们在源码中使用了volatile关键字

下面截取部分代码:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;
...
}

/* ---------------- Fields -------------- */

    /**
     * The array of bins. Lazily initialized upon first insertion.
     * Size is always a power of two. Accessed directly by iterators.
     */
    transient volatile Node<K,V>[] table;

    /**
     * The next table to use; non-null only while resizing.
     */
    private transient volatile Node<K,V>[] nextTable;

...

可以看到table数组,和数组中的Node节点里面的元素都声明为了volatile.

1) table数组的volatile是保证在resize的时候,由于要重新创建一个数组,操作好后将table指向新数组,所以此处是保证在多线程的情况下,线程对于table指向的内存地址的修改对于其他线程是可见的

2)因为对于数组声明的volatile只保证数组指向的内存地址的可见性,所以数组里面的Node节点中的变量也声明了volatile,保证节点内容变化的可见性。

3)还要说明一点, 考虑下面的例子:

 1 public class VolatileExample2 {
 2 
 3 
 4     static class testV {
 5         int key;
 6         String value; //对象中的字段没有声明为volatile
 7         public testV(int key, String value) {
 8             this.key = key;
 9             this.value = value;
10         }
11     }
12     //数组为volatile
13     private static volatile testV[] tvs = new testV[]{new testV(1,"1"), new testV(2,"2")};
14 
15     public static void main(String[] args) throws InterruptedException {
16         new Thread(() -> {
17             System.out.println("Thread 1 starts to sleep 1s");
18             try {
19                 Thread.sleep(1000);
20             } catch (InterruptedException e) {
21                 e.printStackTrace();
22             }
23 
24             tvs[0].value = "11";
25             tvs[1].value = "22";
26             System.out.println("Thread 1 finished and changed  values");
27 
28         }).start();
29 
30         while(true) {
31             if(tvs[0].value.equals("11") && tvs[1].value.equals("22")) {
32                 System.out.println("see the change in object array, main thread ends");
33                 break;
34             }
35         }
36     }
37 }

输出为:

Thread 1 starts to sleep 1s
Thread 1 finished and changed  values
see the change in object array, main thread ends

变量tvs是TestV对象数组,tvs为volatile,但是不同于CHM,TestV里面的field我没有声明为volatile。 从输出结果可以看到,在新创建的线程中对于数组中TestV对象的修改在主线程中是可见的,也就是主线程可以看到子线程中对数组中对象的修改,与之前的猜测不符(之前推断既然数组的volatile声明只能保证数指向的内存地址的可见性,不保证期内部元素的可见性,而其内部的对象元素TestV中的field并没有指明volatile,理论上在主线程是看不到修改的,主线程应该会卡住不退出)

 

以下是通过查询资料对实验的个人分析,不保证正确:

大部分的系统(JVM)在JDK1.5以的实现逻辑是这样的:

(1) 在写一个volatile变量的时候,会把之前其他线程所做的所有的修改(包括其他变量)都写回主存。

(2) 读取一个Volatile变量时,会去主存中读取该变量的值,同时也会将在该行为之后的变量的值一并从主存中拿取

我的理解是要访问数组元素比如tvs[0]的时候,肯定是要先读tvs这个变量本身从而获得数组的内存地址,而tvs这个变量是volatile的,所以它后面需要访问的tvs[0]和tvs[1]也是从主存读取的。

虽然如此,但我猜测此处是为了确保正确性,CHM将Node类中的变量(fields)也声明为volatile.

有关Java 中HashMap详解(含HashTable, ConcurrentHashMap)的更多相关文章

  1. java - 等价于 Java 中的 Ruby Hash - 2

    我真的很习惯使用Ruby编写以下代码:my_hash={}my_hash['test']=1Java中对应的数据结构是什么? 最佳答案 HashMapmap=newHashMap();map.put("test",1);我假设? 关于java-等价于Java中的RubyHash,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.com/questions/22737685/

  2. java - 从 JRuby 调用 Java 类的问题 - 2

    我正在尝试使用boilerpipe来自JRuby。我看过guide从JRuby调用Java,并成功地将它与另一个Java包一起使用,但无法弄清楚为什么同样的东西不能用于boilerpipe。我正在尝试基本上从JRuby中执行与此Java等效的操作:URLurl=newURL("http://www.example.com/some-location/index.html");Stringtext=ArticleExtractor.INSTANCE.getText(url);在JRuby中试过这个:require'java'url=java.net.URL.new("http://www

  3. java - 我的模型类或其他类中应该有逻辑吗 - 2

    我只想对我一直在思考的这个问题有其他意见,例如我有classuser_controller和classuserclassUserattr_accessor:name,:usernameendclassUserController//dosomethingaboutanythingaboutusersend问题是我的User类中是否应该有逻辑user=User.newuser.do_something(user1)oritshouldbeuser_controller=UserController.newuser_controller.do_something(user1,user2)我

  4. java - 什么相当于 ruby​​ 的 rack 或 python 的 Java wsgi? - 2

    什么是ruby​​的rack或python的Java的wsgi?还有一个路由库。 最佳答案 来自Python标准PEP333:Bycontrast,althoughJavahasjustasmanywebapplicationframeworksavailable,Java's"servlet"APImakesitpossibleforapplicationswrittenwithanyJavawebapplicationframeworktoruninanywebserverthatsupportstheservletAPI.ht

  5. Observability:从零开始创建 Java 微服务并监控它 (二) - 2

    这篇文章是继上一篇文章“Observability:从零开始创建Java微服务并监控它(一)”的续篇。在上一篇文章中,我们讲述了如何创建一个Javaweb应用,并使用Filebeat来收集应用所生成的日志。在今天的文章中,我来详述如何收集应用的指标,使用APM来监控应用并监督web服务的在线情况。源码可以在地址 https://github.com/liu-xiao-guo/java_observability 进行下载。摄入指标指标被视为可以随时更改的时间点值。当前请求的数量可以改变任何毫秒。你可能有1000个请求的峰值,然后一切都回到一个请求。这也意味着这些指标可能不准确,你还想提取最小/

  6. 【Java 面试合集】HashMap中为什么引入红黑树,而不是AVL树呢 - 2

    HashMap中为什么引入红黑树,而不是AVL树呢1.概述开始学习这个知识点之前我们需要知道,在JDK1.8以及之前,针对HashMap有什么不同。JDK1.7的时候,HashMap的底层实现是数组+链表JDK1.8的时候,HashMap的底层实现是数组+链表+红黑树我们要思考一个问题,为什么要从链表转为红黑树呢。首先先让我们了解下链表有什么不好???2.链表上述的截图其实就是链表的结构,我们来看下链表的增删改查的时间复杂度增:因为链表不是线性结构,所以每次添加的时候,只需要移动一个节点,所以可以理解为复杂度是N(1)删:算法时间复杂度跟增保持一致查:既然是非线性结构,所以查询某一个节点的时候

  7. 【Java入门】使用Java实现文件夹的遍历 - 2

    遍历文件夹我们通常是使用递归进行操作,这种方式比较简单,也比较容易理解。本文为大家介绍另一种不使用递归的方式,由于没有使用递归,只用到了循环和集合,所以效率更高一些!一、使用递归遍历文件夹整体思路1、使用File封装初始目录,2、打印这个目录3、获取这个目录下所有的子文件和子目录的数组。4、遍历这个数组,取出每个File对象4-1、如果File是否是一个文件,打印4-2、否则就是一个目录,递归调用代码实现publicclassSearchFile{publicstaticvoidmain(String[]args){//初始目录Filedir=newFile("d:/Dev");Datebeg

  8. java - 为什么 ruby​​ modulo 与 java/other lang 不同? - 2

    我基本上来自Java背景并且努力理解Ruby中的模运算。(5%3)(-5%3)(5%-3)(-5%-3)Java中的上述操作产生,2个-22个-2但在Ruby中,相同的表达式会产生21个-1-2.Ruby在逻辑上有多擅长这个?模块操作在Ruby中是如何实现的?如果将同一个操作定义为一个web服务,两个服务如何匹配逻辑。 最佳答案 在Java中,模运算的结果与被除数的符号相同。在Ruby中,它与除数的符号相同。remainder()在Ruby中与被除数的符号相同。您可能还想引用modulooperation.

  9. java - Ruby 相当于 Java 的 Collections.unmodifiableList 和 Collections.unmodifiableMap - 2

    Java的Collections.unmodifiableList和Collections.unmodifiableMap在Ruby标准API中是否有等价物? 最佳答案 使用freeze应用程序接口(interface):Preventsfurthermodificationstoobj.ARuntimeErrorwillberaisedifmodificationisattempted.Thereisnowaytounfreezeafrozenobject.SeealsoObject#frozen?.Thismethodretur

  10. java - Java 的 StringReader 的 Ruby 等价物是什么? - 2

    在Java中,可以像这样从一个字符串创建一个IO流:Readerr=newStringReader("mytext");我希望能够在Ruby中做同样的事情,这样我就可以获取一个字符串并将其视为一个IO流。 最佳答案 r=StringIO.new("mytext")和here'sthedocumentation. 关于java-Java的StringReader的Ruby等价物是什么?,我们在StackOverflow上找到一个类似的问题: https://st

随机推荐