HashMap线程不安全实例(jdk1.7)
一、前言
jdk1.7中,以put()方法举例,线程不安全的一些情况:
1,初始化HashMap的桶数组的时候,一个线程初始化了桶数组并插入了第一个元素,但是另一个线程不知道初始化好了,也执行了初始化的操作,清除了前面线程已经插入的元素;
2,两个线程同时触发扩容,在翻转同个桶位上的链表时,链表形成环,类似循环依赖;
二、初始化桶数组的例子
1,设计思路
将HashMap的put()方法分为两个步骤,步骤1打探桶数组是否为空,步骤2根据打探的结果(空就初始化数组,不空则使用已有的数组)插入元素。在步骤1和步骤2间打断点,详细流程如下:
a,准备一个空map;
b,创建线程0,向map中插入元素,走到断点处,发现map的桶数组为空;
c,创建线程1,向map中插入元素,走到断点处,发现map的桶数组为空;
d,选择线程1,前进走过断点,完成步骤2,初始化桶数组,并插入了一个元素;
e,选择线程0,前进走过断点,执行步骤2,虽然此时另一个线程已经初始化过桶数组了,但是线程0是不知道的,它只打探一次,不管过多久都使用自己的消息,所以接下来它会把桶数组初始化,reset,归零。最后再插入自己的元素。
import java.util.HashMap; import java.util.Map; import java.util.concurrent.CountDownLatch; final Map<Integer, String> map = new HashMap<>(); // 初始值2,线程执行完后减1 final CountDownLatch countDownLatch = new CountDownLatch(2); Thread thread1 = new Thread(new Runnable() { @Override public void run() { map.put(1, "春"); countDownLatch.countDown(); } }); Thread thread2 = new Thread(new Runnable() { @Override public void run() { map.put(2, "花"); countDownLatch.countDown(); } }); thread1.start(); thread2.start(); Map<Integer, String> testDebugMap = new HashMap<>(); testDebugMap.put(3, "秋"); System.out.println("测试debug有没有阻塞其他线程:" + testDebugMap); // 等两个线程内部逻辑都执行结束后,再执行下面的代码 countDownLatch.await(); System.out.println(map);
2,设置断点
在map的put()方法中,在(1,设计思路)步骤1和步骤2之间设置断点;
右键断点(行号后面的红点)进行详细配置:Suspend选择Thread;Condition填入!"main".equals(Thread.currentThread().getName());
Suspend=Thread:有线程走到这儿的时候,只停住当前线程,当前线程停住的这段时间其他线程该怎么走就怎么走,除非其他线程后来也有走到这儿的了,也会停住;线程与线程之间互不影响;
Suspend=All:有线程走到这儿的时候,世界按下暂停键,所有线程停止运行,留在原地;但是解除暂停键后,其他线程即使走到这儿也不会停住了;
Condition:进一步加个条件,此处配合Suspend=Thread表示线程走到此处是否停住还要看是否满足这个条件。
3,线程0走到断点处停住
点击debug按钮,开始调试;
线程0走到断点处停住了,如下图所示
4,线程1也走到断点处停住
线程1走到断点处也停住了,如下图所示
5,主线程走到CountDownLatch的等待处停住
主线程在countDownLatch.await()处停住了,需要等线程0和1都执行完毕了,才会执行下面的代码,如下图所示
6,断点配置的其他说明
Suspend配置成Thread后,可以选择thread0走两步,再选择thread1走几步,还能再选回thread0走几步,线程之间不受影响;
假如Suspend配置成All后,先走到此处的线程停住,同时其他线程都停在了其他地方;随便选哪个线程往下走,其他线程就会直接走到最后;就只是一个暂停键的作用,暂停用过之后,那个断点就没用了。
7,选择线程1,完成桶数组的初始化,并插入第一个元素
如下图,鼠标左键依次点击控制台的“Debugger”——>"倒三角“——>”线程1“,从而选择线程1
点击步进或者快进,让线程1走完;此时HashMap的桶数组插入了一个元素,如下图
8,选择线程0
鼠标左键依次点击控制台的“Debugger”——>"倒三角“——>”线程0“,从而选择线程0;
发现HashMap的桶数组因为线程1先前的操作存在元素了,如下图
9,线程0步进一步
点击步进按钮,执行断点处的操作,发现线程0初始化了HashMap的桶数组,即又让桶数组回到了初始化状态,全是null,如下图
10,线程0执行剩下的代码
点击步进或者快进,让线程0走完剩下的代码;
最终结果是HashMap中只有线程0的元素,因为线程1的元素被线程0初始化桶数组的时候清除了;但是size=2,因为初始化的是桶数组,并没有初始化size,如下图
11,主线程走完
线程0和线程1走完后,主线程也被唤醒了,走完了剩下的代码;
打印结果也和上面分析一致,如下图
12,其他情况
方法只要不是加了锁,操作共有资源的时候,容易发生线程安全问题,如下面图示的情况等
举个简单的例子:
单线程的时候,老对象是“春”->“花”->null,待插入的元素是“秋”,按照头插法,新元素插到老对象的前面,即最终结果为“秋”->“春”->“花”->null,没有问题;
多线程的时候,
a,线程0的情况是,老对象是“春”->“花”->null,待插入的元素是“秋”,断点打在给变量e赋值之后,插入元素之前;
b,线程1的情况是,老对象是“春”->“花”->null,因为线程0还没有插入,保持原样,待插入的元素是“月”,断点打在给变量e赋值之后,插入元素之前;
c,线程0完成插入动作,桶位上的链表为一个新的new出来的对象,“秋”->“春”->“花”->null;
d,线程1现在的情况是,无法得知线程0的操作,因为线程0断开了桶位和老对象之间的联系,指向了一个新的对象,线程0的改动都体现在新对象上了,而线程1持有的还是老对象。所以,线程1执行插入动作,将新元素插到老对象的前面,变成“月”->“春”->“花”->null,并赋值给了桶位。
最终,桶位的链表是,“月”->“春”->“花”->null,丢失了线程0插入的元素“秋”。
三、扩容翻转链表形成环的例子
1,设计思路
线程0和线程1同时触发扩容翻转,在翻转时,两个线程配合好,使链路出现环路。
2,准备工作
a,准备两个key,使它们落在同一个桶位上,且扩容后还在一个桶位上;同理,再准备三个key,使它们落在同一个桶位上,且扩容后还在一个桶位上,且和前面步骤的桶位有所区分
public static void main(String[] args) { // 打印和1(key)落在同一桶位的key,且扩容后也在同一桶位 printSameBucketKey(1); System.out.println("++++++++++++++++++++++++++++++++++++++++++"); // 打印和2(key)落在同一桶位的key,且扩容后也在同一桶位 printSameBucketKey(2); } /** * 打印和targetKey落在同一桶位的key,且扩容后也在同一桶位 * * @param targetKey */ public static void printSameBucketKey(int targetKey) { for (int i = 1; i < 100; i++) { if (hash(targetKey) % 8 == hash(i) % 8 && hash(targetKey) % 16 == hash(i) % 16) { System.out.println(i); } } } // 直接copy的源码,为jdk1.7中hashMap计算hash值的方法 public static int hash(Object k) { int h = 0; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
所以,key为1和16时,在map容量为8时在一个桶位,且容量为16时也在一个桶位;
key为2、19、32时,在map容量为8时在一个桶位,且容量为16时也在一个桶位;
b,测试代码:
// 初始容量为8,且元素数量达到8*0.3=2时,满足扩容的两个条件之一 final Map<Integer, String> map = new HashMap<>(8, 0.3f); // 在指定桶位上,先后插入两个个元素,按照“头插法”,最后结果为:“花”->"春“->”null“ map.put(1, "春"); map.put(16, "花"); // 在另一个桶位xxx上,插入一个元素,此时不会触发扩容,因为插入前该桶位为空 map.put(2, "扩容翻转"); // 初始值2,线程执行完后减1 final CountDownLatch countDownLatch = new CountDownLatch(2); Thread thread1 = new Thread(new Runnable() { @Override public void run() { // 在桶位xxx上插入元素,同时满足扩容的两个条件,即map的元素数量达到2,且插入前桶位不为空 map.put(19, "扩容翻转1"); countDownLatch.countDown(); } }); Thread thread2 = new Thread(new Runnable() { @Override public void run() { // 在桶位xxx上插入元素,同时满足扩容的两个条件,即map的元素数量达到2,且插入前桶位不为空 map.put(32, "扩容翻转2"); countDownLatch.countDown(); } }); thread1.start(); thread2.start(); // 等两个线程内部逻辑都执行结束后,再执行下面的代码 countDownLatch.await(); System.out.println(map);
c,断点设置
设置在transfer()方法中,遍历链表的地方,如下图所示;
断点配置:Suspend=Thread;Condition=线程的名称不为main
d,链表翻转逻辑预知
简化代码并举例
public class MyNode { public int key; public String value; public MyNode next; public MyNode(int key, String value, MyNode next) { this.key = key; this.value = value; this.next = next; } public static void main(String[] args) { // “花” -> “春” -> null MyNode node = new MyNode(1, "花", new MyNode(16, "春", null)); // 新的桶数组 MyNode[] newTable = new MyNode[1]; MyNode e = node; System.out.println("翻转前:" + e); while (null != e) { MyNode next = e.next; e.next = newTable[0]; newTable[0] = e; e = next; } System.out.println("翻转后:" + newTable[0]); } @Override public String toString() { return "MyNode{" + "key=" + key + ", value='" + value + '\'' + ", next=" + next + '}'; } }
第一次while循环
执行next = e.next;
执行e.next = newTable[0];
执行newTable[0] = e;
执行e = next;
第二次while循环,执行next = e.next;
执行e.next = newTable[0];
执行newTable[0] = e;
执行e = next;
第三次while循环
while条件(e!=null)不满足,跳出while循环;
打印桶数组,和上图结果一样,桶位上的链表发生了翻转
提示:可以使用亿图软件画图,赋值的时候,只需要通过调整箭头指向来模拟。
3,点击debug按钮,开始调试程序
点击debug按钮,线程0和线程1都走到了断点处,如下图
4,选择线程0,步进一步
执行next = e.next;
5,选择线程1,完成翻转操作
选择线程1,快进或跳过断点,执行到最后;
根据“d,链表翻转逻辑预知”的经验,完成翻转后,结果如下图;并同步修改了table(map的桶数组)
6,选择线程0,步进执行翻转操作
因为线程1的操作,堆中各个对象的关系发生了改变,如下图
执行e.next = newTable[0];
无变化;
执行newTable[0] = e;
执行e = next;
第二次while循环,执行next = e.next;
执行e.next = newTable[0];
无变化
执行newTable[0] = e;
执行e = next;
第三次while循环,执行next = e.next;
执行e.next = newTable[0];
至此,链表出现了环路。
四、总结
1,初始化桶数组的例子
是一个覆盖的问题,提交svn、git代码的时候经常发生。比如放假前,领导安排了一个任务,和张三、李四说,放假后,你们在家如果谁闲了的话就去完成这个任务。张三打了一天篮球回到家,更新了下代码发现李四没提交代码,觉得他应该是有事在忙,自己就开始在本地写代码。李四也打了一天麻将回来了,也更新了下代码发现张三没提交代码,也觉得对方应该是有事在忙,自己就开始在本地写代码,李四自己写的快就提交了。张三写的慢,可能边写边看电视了,很晚才提交代码。殊不知,张三覆盖了李四的代码。造成了人力的浪费。
2,扩容翻转链表形成环的例子
如果有一个球,后面是一个环,前面是一个钩子;有多个这样的球,互相钩连在一起。最后有一条链子,链子的最后一个球是null球。还要有一个单独的null球。再来三个人,每个人抓住其中一个球,然后按照上表中4个步骤顺序操作,有时候人要换个球抓,有时候球前面的钩子要解开然后钩到另外一个球上。