PS:本文使用的Java源码是JDK1.8。
事情起因很简单,起源于类似you can,you up的玩笑。我这人喜欢较真,尤其是遇见我会的问题的时候。
我们先上一组代码。
public static void main(String[] args) {
Map map = new HashMap();
for (int j = 0; j < 100; j++) {
double i = Math.random() * 100000;
map.put("键" + i, "值" + i);
map.remove("键" + i);
System.out.println(j + "当前时间:" + i + " size = " + map.size());
}
}
结果如图
我添加一个K,然后再移出K,size大小为0,逻辑上说是没有任何问题的。结果证明也没有问题。单线程执行代码一般都是没有任何问题的,是按照逻辑来的。即使指令重排,对结果影响基本为0的。
现在我们上一组多线程代码
public static void main(String[] args) {
Map map = new HashMap();
for (int i = 0; i < 100; i++) {
MyThread myThread = new MyThread(map, "线程名字:" + i);
myThread.start();
}
}
static class MyThread extends Thread {
public Map map;
public String name;
public MyThread(Map map, String name) {
this.map = map;
this.name = name;
}
public void run() {
double i = Math.random() * 100000;
map.put("键" + i, "值" + i);
map.remove("键" + i);
System.out.println(name + "当前时间:" + i + " size = " + map.size());
}
}
结果如图
好像看着没有任何差异,如果我们扩大循环到100000
结果如图
这差距就非常明显了,很明显的有问题的,如果我们线程休眠1ms,再来100个循环。
public void run() {
double i = Math.random() * 100000;
map.put("键" + i, "值" + i);
try{
Thread.sleep(1);
}catch (Exception e){
e.printStackTrace();
}
map.remove("键" + i);
System.out.println(name + "当前时间:" + i + " size = " + map.size());
}
结果如图
不用我多说,铁一般的事实在眼前,HashMap不是线程安全的。我们一起去看看源码。
先看看size()这个方法源码
public int size() {
return size;
}
很简单的逻辑,然后我们看看size这个变量说明
/** * The number of key-value mappings contained in this map. */
transient int size;
大意就是说包含的键值对数量,还是一个不可序列化对象,当然就和我们的讲解无关了。
首先这个size没有用volatile关键字修饰,代表这不是一个内存可见的变量。了解过多线程应该都知道,我们线程操作数据的时候一般是从主存拷贝一个变量副本进行操作。
示意图
能领悟意思就差不多了,线程中的变量,都是从主存拷贝过去,操作完成过后在把size的值写回到主存size的。
接下来我们分析一下源码put(K key,V value)的实现过程。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
好像没有什么操作,就调用了一个putVal(int hash,K key,V value,boolean onlyIfAbsent,boolean evict)方法,我们继续往下看。putVal()方法也没有用synchronized修饰,代表这个方法里面任意的位置时间片耗尽(可以类比休眠状态,休眠是主动进入阻塞,休眠结束进入就绪状态,时间片耗尽是进入直接进入就绪状态)。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//这里是核心,大概就是各种判断,然后赋值的问题,感兴趣的可以自己去了解一下。
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
resize()方法是扩大容器的策略,在这里我们不用管,不是我们讲解的重点,问题出在++size上面的,如果键是以前不存在的,那么必然会执行++size这段逻辑。假设现在我两个线程,每个线程都在执行put方法。
size的大致变化过程就是这样的,理论结果应该是size=3的,而我们实际执行的结果是size=2,remove()方法的原理也是差不多的,在这里就不详细解释。这肯定和我们的预期是有差距的,你想想如果去银行存钱你存了两次100元,银行只给你帐号增加100元,你怕是马上就要找银行麻烦了,闹得天下皆知。但是如果一笔钱你能花两次,你估计会非常开心吧,还会觉得银行真傻的,心里偷着乐。
这只是一个int型变量size,我还没有分析table储存问题的,假设我两个线程分别调用put(1,”111”)和put(1,”222”),那么我get(1)取到的究竟是哪个值呢?比如我线程A先调用get(1)在get(1)还没有执行完成的时候,A线程时间片用尽进入就绪状态,然后B线程调用remove(1),A继续回来执行的get(1)的剩余逻辑,会不会找到的呢?这些答案无从得知,有兴趣的可以自己模拟实验一下的。
或许你会说,哪有那么巧合的事情?世界之大,无奇不有。世界那么大,你应该出去看看。
总结:线程不安全问题应该属于并发问题之一的,属于相对高级的问题了。这个时候的问题已经不仅仅局限于代码层面了,很多时候需要结合JVM一起分析了。