java并发-HashMap并发环形链表详解-jdk1.7

1.      Jdk1.7HashMap并发问题介绍

我们都知道,在并发使用HashMap会造成线程不安全的情况,这种不安全不仅是数据丢失,而且可能在一定情况下出现环形链表,导致数据无法插入。

 

2.      原因1——并发时resize头插法

此处分析参考http://www.importnew.com/22011.html

我们都知道,HashMap默认大小为16,超过threshold就会扩容2倍,下面是扩容方法。

void addEntry(int hashkeyvalue, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hashtable.length);
}

    createEntry(hashkeyvaluebucketIndex);
}

 

void resize(int newCapacity) {
    Entry[] oldTable =
 table;
    int
 oldCapacity = oldTable.length;
    if
 (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
        return;
}

    Entry[] newTable =
 new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}


void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
    for
 (Entry<K,V> e : table) {
while(null != e) {
            Entry<
K,V> next = e.next;
            if
 (rehash) {
                e.
hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
    }
}

 

在单线程情况下,扩容调用transfer方法,会执行头部插入法:

e.next = newTable[i];

newTable[i] = e;

将原链表1->2->3倒叙插入到新扩容链表3->2->1(如果在扩容后还存在于table相同下标的链表中)。

例如下面这个例子。假设hash算法就是最简单的 key mod table.length(也就是数组的长度)。

同时可以发现在Entry中的next是普通类型,也就是说整个链表不具有内存可见性。

 

1)       单线程

最上面的是old hash 表,其中的Hash表的 size = 2, 所以 key = 3, 7, 5,在 mod 2以后碰撞发生在table[1]

接下来的三个步骤是 Hash resize 4,并将所有的 <key,value> 重新rehash到新 Hash 表的过程。如图所示:

java并发-16-HashMap并发环形链表详解-jdk1.7

2)       并发

假设有两个线程执行了put()并准备扩容,执行到transfer方法。


void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
    for
 (Entry<K,V> e : table) {
while(null != e) {
            Entry<
K,V> next = e.next;
            if
 (rehash) {
                e.
hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
    }
}

 

线程1Entry<K,V> next = e.next后挂起,线程2正常完成了扩容,结果如下:


java并发-16-HashMap并发环形链表详解-jdk1.7

此时线程1继续执行,此时e3节点,next7节点,而线程2中链表却被改为7->3->null,后面所说的环形链表正是由于线程1中内部存在的历史链表关系和实际链表关系的冲突导致,此时Entrynext不是volatile类型,是否需要考虑内存可见性,除非线程2将链表节点引用立即刷到主存?这个问题在原因2中会详细讨论。

 

下面继续执行线程1

l  执行e.next=newTable[i],此时线程1newTable[i]null。也就是说此时链表仍然为7->3->null

l  执行newTable[i]=e,即此时newTable[i]=3->null

l  然后更新e=next7节点。

接着开始重新循环:

l  Entry<K,V> next=e.next,此时next3节点

l  执行e.next=newTable[i],此时线程1newTable[i]3节点。也就是说此时链表仍然为7->3->null

l  执行newTable[i]=e,即此时newTable[i]=7->3->null

l  然后更新e=next3节点。

接着开始重新循环:

l  Entry<K,V> next=e.next,此时nextnull

l  执行e.next=newTable[i],此时线程1newTable[i]7节点。也就是说此时链表变成环形链表3->7->3->7…

l  执行newTable[i]=e,即此时newTable[i]=3->7->3->7…

l  然后更新e=nextnull

java并发-16-HashMap并发环形链表详解-jdk1.7

此时仍然可以退出循环。

 

3.      原因2:——循环链表产生的关键:内存可见性

网上写1.7版本HashMap并发产生环形链表问题都没有考虑内存可见性,如果多线程情况下内存不可见,自然也不会有环形链表问题。

【关键问题】此时Entrynext不是volatile类型,是否需要考虑内存可见性,除非线程2将链表节点引用立即刷到主存

答:是的,需要考虑内存可见性。HashMap中没有定义volatile变量,无法保证多线程情况下内存可见性,所以理论上来说,线程1中本地缓存的链表就是3->7->5,线程2虽然修改了链表关系7->3->null,但仍在其本地缓存没有刷新到主存,或即使刷新到主存,线程1在后续执行过程中没有重新读取主存。

所以如果没有其他强迫线程1读取主存操作,线程1就会按照自己缓存的链表3->7->5执行transfer,最终会向线程2执行结果一样。但线程1在循环时的某些情况下执行了hash方法:

void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
    for
 (Entry<K,V> e : table) {
while(null != e) {
            Entry<
K,V> next = e.next;
            if
 (rehash) {
                e.
hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
    }
}

 

 

final int hash(Object k) {
int h = hashSeed;
    if (!= h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}

    h ^= k.hashCode();

// 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);
}

hash方法调用了sun.misc.Hashing.stringHash32地方方法,该方法强迫线程1重读主存,所以此时如果线程2没有将本地缓存写入主存,那么会先将自己的缓存写入主存,然后线程1进而读取更新了主存(类似MESI协议)。

这样,线程1内的链表结构就变得和线程2(主存)一致,从而导致了环形链表的出现。

本人回复于CSDNhttps://blog.csdn.net/zhuqiuhui/article/details/51849692

 

         下面是一个测试用例,可以发现调用了sun.misc.Hashing.stringHash32方法时能够输出“执行结束”,即线程读取了最新主存。

 

public class TestMain {
static MyObject myObject;

    public static void main(String[] args){
        System.out.println(DateFormatUtils.format(new Date(),"yyyy-MM-dd HH:mm:ss"));
testVolatileObject();
}
public static void testVolatileObject() {
int i = 0;
MyObject objectFalse = new MyObject();
MyObject objectFalseNext = new MyObject();
objectFalse.setNext(objectFalseNext);

MyObject objectTrue = new MyObject(true);
MyObject objectTrueNext = new MyObject(true);
objectTrue.setNext(objectTrueNext);
myObject = objectFalse;
        final int a = 0;
Thread backgroundThread = new Thread(new Runnable() {
public void run() {
                System.out.println("执行ing");
                int i = 0;
                while (!myObject.next.getFlag()){
                    i = i + a;
sun.misc.Hashing.stringHash32((String) "12");
//这段System.out语句会导致线程结束,原因?
//                    System.out.println(i);
}
                System.out.println("执行结束");
}
        });

backgroundThread.start();
System.out.println("开始执行");
        try {
            Thread.sleep(1000);
myObject.next = objectTrueNext;
Thread.sleep(2000);
System.out.println("完成");
catch (InterruptedException e) {
            e.printStackTrace();
}
    }

private static class MyObject {
boolean flag;
        public MyObject() {
flag false;
}

public MyObject(boolean flag) {
this.flag = flag;
}
public boolean getFlag() {
return flag;
}
        MyObject next;
        public void setNext(MyObject next) {
this.next = next;
}
public MyObject getNext() {
return next;
}
    }
}

 

参考文献

[1] http://www.importnew.com/22011.html

 

猜你喜欢

转载自blog.csdn.net/liuchaoxuan/article/details/80781495