• 由多线程引起的map取值为null的分析


    昨天写了一个多线程的程序,却发现了一个很奇特的问题,就是我的map对象明明put了,可是get的时候竟然会取到null,而且尝试多次,有时候成功,有时候取到null,并不确定。

    程序代码如下:

    public class ThreadLocal {
        private static Map<Thread, Integer> map;
    
        public static void main(String[] args) {
            map = new HashMap<Thread, Integer>();
            for (int i = 0; i < 2; i++) {
                new Thread(new Runnable() {
    
                    public void run() {
                        int data = new Random().nextInt();
                        map.put(Thread.currentThread(), data);
                        System.out.println(Thread.currentThread() + ", data:"
                                + data);
                        new A().show();
                        new B().show();
                    }
                }).start();
            }
        }
    
        static class A {
            public void show() {
                System.out.println(Thread.currentThread() + "调用A, data:" + map.get(Thread.currentThread()));
            }
        }
    
        static class B {
            public void show() {
                System.out.println(Thread.currentThread() + "调用B, data:" + map.get(Thread.currentThread()));
            }
        }
    }

    运行结果如下:

    Thread[Thread-0,5,main], data:1164116165
    Thread[Thread-1,5,main], data:196549485
    Thread[Thread-0,5,main]调用A, data:null
    Thread[Thread-1,5,main]调用A, data:196549485
    Thread[Thread-0,5,main]调用B, data:null
    Thread[Thread-1,5,main]调用B, data:196549485

    我实在想不明白,我明明已经put了,为什么取不到呢?今天我查了资料,并大概看了看源码,大致理解了,这是由HashMap的非线程安全特性引起的。具体源码分析如下所示:

    先看看get方法:

    public V get(Object key) {  
    
            if (key == null)  
    
                return getForNullKey();  
    
            int hash = hash(key.hashCode());  
    
            // indexFor方法取得key在table数组中的索引,table数组中的元素是一个链表结构,遍历链表,取得对应key的value  
    
            for (Entry e = table[indexFor(hash, table.length)]; e != null; e = e.next) {  
    
                Object k;  
    
                if (e.hash == hash && ((k = e.key) == key || key.equals(k)))  
    
                    return e.value;  
    
            }  
    
            return null; 
    }

    再看看put方法:

    public V put(K key, V value) {  
    
            if (key == null)  
    
                return putForNullKey(value);  
    
            int hash = hash(key.hashCode());  
    
            int i = indexFor(hash, table.length);  
    
            for (Entry e = table[i]; e != null; e = e.next) {  
    
                Object k;  
    
                if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  
    
                    V oldValue = e.value;  
    
                    e.value = value;  
    
                    e.recordAccess(this);  
    
                    return oldValue;  
    
                }  
    
            }  
    
            modCount++;  
    
            // 若之前没有put进该key,则调用该方法  
    
            addEntry(hash, key, value, i);  
    
            return null;  
    }

    那我们再看看addEntry里面的实现:

    void addEntry(int hash, K key, V value, int bucketIndex) {  
    
            Entry e = table[bucketIndex];  
    
            table[bucketIndex] = new Entry(hash, key, value, e);  
    
            if (size++ >= threshold)  
    
                resize(2 * table.length);  
    }

    map里的元素个数(size)大于一个阈值(threshold)时,map将自动扩容,容量扩大到原来的2倍;
    阈值(threshold)是怎么计算的?如下源码: 

    threshold = (int)(capacity * loadFactor);

    阈值 = 容量 X 负载因子;容量默认为16,负载因子(loadFactor)默认是0.75; map扩容后,要重新计算阈值;当元素个数大于新的阈值时,map再自动扩容;
    以默认值为例,阈值=16*0.75=12,当元素个数大于12时就要扩容;那剩下的4(如果内部形成了Entry链则大于4)个数组位置还没有放置对象就要扩容,岂不是浪费空间了?
    这是时间和空间的折中考虑;loadFactor过大时,map内的数组使用率高了,内部极有可能形成Entry链,影响查找速度;loadFactor过小时,map内的数组使用率旧低,不过内部不会生成Entry链,或者生成的Entry链很短,由此提高了查找速度,不过会占用更多的内存;所以可以根据实际硬件环境和程序的运行状态来调节loadFactor。

    继续resize方法:

    void resize(int newCapacity) {  //传入新的容量
    Entry[] oldTable
    = table; //引用扩容前的Entry数组
    int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了 threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
    return; } Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
    transfer(newTable); //!!将数据转移到新的Entry数组里
    table
    = newTable; //HashMap的table属性引用新的Entry数组 threshold = (int) (newCapacity * loadFactor); //修改阈值
    }

    resize里面重新new一个Entry数组,其容量就是旧容量的2倍,这时候,需要重新根据hash方法将旧数组分布到新的数组中,也就是其中的transfer方法:

    void transfer(Entry[] newTable) {  
    
            Entry[] src = table;  //src引用了旧的Entry数组
    int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
    Entry e
    = src[j]; //取得旧Entry数组的每个元素
    if (e != null) { src[j] = null; //释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象) do { Entry next = e.next; int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置 e.next = newTable[i]; //标记[1] newTable[i] = e; //将元素放在数组上 e = next; //访问下一个Entry链上的元素
    }
    while (e != null); } }
    }
    注释标记[1]处,将newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话);
    indexFor()是计算每个元素在数组中的位置,源码: 
    static int indexFor(int h, int length) {
        return h & (length-1); //位AND计算
     }
    这样,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上;
    例如,旧数组容量为16,对象A的hash值是4,对象B的hash值是20,对象C的hash值是36;
    通过indexFor()计算后,A、B、C对应的数组索引位置分别为4,4,4; 说明这3个对象在数组的同一位置上,形成了Entry链;
    旧数组扩容后容量为16*2,重新计算对象所在的位置索引,A、B、C对应的数组索引位置分别为4,20,4; B对象已经被放到别处了;  

    所以,resize时,HashMap使用新数组代替旧数组,对原有的元素根据hash值重新就算索引位置,重新安放所有对象;resize是耗时的操作。
     
     
    说回null的问题,在这个方法里,将旧数组赋值给src,遍历src,当src的元素非null时,就将src中的该元素置null,即将旧数组中的元素置null了,也就是这一句:
    if (e != null) {  
            src[j] = null;  
    }
    此时若有get方法访问这个key,它取得的还是旧数组,当然就取不到其对应的value了。
     
     
    下面,我们重现一下场景:
    Java代码 
    import java.util.HashMap;  
    import java.util.Map;  
    public class TestHashMap {  
        public static void main(String[] args) {  
            final Map map = new HashMap(4, 0.5f);  
            new Thread(){  
                public void run() {  
                    while(true) {   
                        System.out.println(map.get("name1"));  
                        try {  
                            Thread.sleep(1000);  
                        } catch (InterruptedException e) {  
                            e.printStackTrace();  
                        }  
                    }  
                }  
            }.start();  
            for(int i=0; i<3; i++) {  
                map.put("name" + i, "value" + i);  
            }  
    Debug上面这段程序,在map.put处设置断点,然后跟进put方法中,当i=2的时候就会发生resize操作,在transfer将元素置null处停留片刻,此时线程打印的值就变成null了。
     
     
    其它可能由未同步HashMap导致的问题:
    1、多线程put后可能导致get死循环(主要问题在于put的时候transfer方法循环将旧数组中的链表移动到新数组)
    2、多线程put的时候可能导致元素丢失(主要问题出在addEntry方法的new Entry(hash, key, value,e),如果两个线程都同时取得了e,则他们下一个元素都是e,然后赋值给table元素的时候有一个成功有一个丢失)
     
     
    总结:HashMap在并发程序中会产生许多微妙的问题,难以从表层找到原因。所以使用HashMap出现了违反直觉的现象,那么可能就是并发导致的了。最简单的解决办法就是采用线程安全的HashTable。
     
  • 相关阅读:
    UnityWebgl错误-Uncaught DOMException: Blocked a frame with origin "" from accessing a cross-origin frame
    【Oracle123】v$sql 视图
    【QA123】ISO9126软件质量模型
    【测试工具123】HP LoadRunner
    【CSV123】如何使用Excel打开CSV并保留大数字精度
    【Java123】
    【FRM-Level2】2020 FRM二级考纲变化
    【FRM-Level2】2020 FRM二级 Current Issues
    【Oracle123】Oracle数据导入导出
    【中间件123】消息队列性能对比
  • 原文地址:https://www.cnblogs.com/DarrenChan/p/5756491.html
Copyright © 2020-2023  润新知