public class HashMapMultiThread { static Map<String,String> map = new HashMap<String,String>(); public static class AddThread implements Runnable { int start=0; public AddThread(int start){ this.start=start; } @Override public void run() { for (int i = start; i < 100000; i+=2) { map.put(Integer.toString(i), Integer.toBinaryString(i)); } } } public static void main(String[] args) throws InterruptedException { Thread t1=new Thread(new HashMapMultiThread.AddThread(0)); Thread t2=new Thread(new HashMapMultiThread.AddThread(1)); t1.start(); t2.start(); t1.join();t2.join(); System.out.println(map.size()); } }
上述代码使用t1和t2两个线程同时对HashMap进行put()操作。如果一切正常,我们期望得到的map.size()就是100000。但实际上,你可能会得到以下三种情况(注意,这里使用JDK 7进行试验):
第一,程序正常结束,并且结果也是符合预期的。HashMap的大小为100000。
第二,程序正常结束,但结果不符合预期,而是一个小于100000的数字,比如98868。
第三,程序永远无法结束。
对于前两种可能,和ArrayList的情况非常类似,因此,也不必过多解释。而对于第三种情况,如果是第一次看到,我想大家一定会觉得特别惊讶,因为看似非常正常的程序,怎么可能就结束不了呢?
注意:请读者谨慎尝试以上代码,由于这段代码很可能占用两个CPU核,并使它们的CPU占有率达到100%。如果CPU性能较弱,可能导致死机。请先保存资料,再进行尝试。
打开任务管理器,你们会发现,这段代码占用了极高的CPU,最有可能的表示是占用了两个CPU核,并使得这两个核的CPU使用率达到100%。这非常类似死循环的情况。
使用jstack工具显示程序的线程信息,如下所示。其中jps可以显示当前系统中所有的Java进程。而jstack可以打印给定Java进程的内部线程及其堆栈。
C:\Users\geym >jps 14240 HashMapMultiThread 1192 Jps C:\Users\geym >jstack 14240
我们会很容易找到我们的t1、t2和main线程:
"Thread-1" prio=6 tid=0x00bb2800 nid=0x16e0 runnable [0x04baf000] java.lang.Thread.State: RUNNABLE at java.util.HashMap.put(HashMap.java:498) at geym.conc.ch2.notsafe.HashMapMultiThread$AddThread.run (HashMapMultiThread.java:26) at java.lang.Thread.run(Thread.java:724) "Thread-0" prio=6 tid=0x00bb0000 nid=0x1668 runnable [0x04d7f000] java.lang.Thread.State: RUNNABLE at java.util.HashMap.put(HashMap.java:498) at geym.conc.ch2.notsafe.HashMapMultiThread$AddThread.run (HashMapMultiThread.java:26) at java.lang.Thread.run(Thread.java:724) "main" prio=6 tid=0x00c0cc00 nid=0x16ec in Object.wait() [0x0102f000] java.lang.Thread.State: WAITING (on object monitor) at java.lang.Object.wait(Native Method) - waiting on <0x24930280> (a java.lang.Thread) at java.lang.Thread.join(Thread.java:1260) - locked <0x24930280> (a java.lang.Thread) at java.lang.Thread.join(Thread.java:1334) at geym.conc.ch2.notsafe.HashMapMultiThread.main(HashMapMultiThread. java:36)
可以看到,主线程main正处于等待状态,并且这个等待是由于join()方法引起的,符合我们的预期。而t1和t2两个线程都处于Runnable状态,并且当前执行语句为HashMap.put()方法。查看put()方法的第498行代码,如下所示:
498 for (Entry<K,V> e = table[i]; e != null; e = e.next) { 499 Object k; 500 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { 501 V oldValue = e.value; 502 e.value = value; 503 e.recordAccess(this); 504 return oldValue; 505 } 506 }
可以看到,当前这两个线程正在遍历HashMap的内部数据。当前所处循环乍看之下是一个迭代遍历,就如同遍历一个链表一样。但在此时此刻,由于多线程的冲突,这个链表的结构已经遭到了破坏,链表成环了!当链表成环时,上述的迭代就等同于一个死循环,如图2.9所示,展示了最简单的一种环状结构,Key1和Key2互为对方的next元素。此时,通过next引用遍历,将形成死循环。
图2-9 成环的链表
这个死循环的问题,如果一旦发生,着实可以让你郁闷一把。本章的参考资料中也给出了一个真实的案例。但这个死循环的问题在JDK 8中已经不存在了。由于JDK 8对HashMap的内部实现了做了大规模的调整,因此规避了这个问题。但即使这样,贸然在多线程环境下使用HashMap依然会导致内部数据不一致。最简单的解决方案就是使用ConcurrentHashMap代替HashMap。
2.8.4 初学者常见问题:错误的加锁
在进行多线程同步时,加锁是保证线程安全的重要手段之一。但加锁也必须是合理的,在“线程安全的概念与synchronized”一节中,我已经给出了一个常见的错误加锁的案例。也就是锁的不正确使用。在本节中,我将介绍一个更加隐晦的错误。
现在,假设我们需要一个计数器,这个计数器会被多个线程同时访问。为了确保数据正确性,我们自然会需要对计数器加锁,因此,就有了以下代码:
01 public class BadLockOnInteger implements Runnable{ 02 public static Integer i=0; 03 static BadLockOnInteger instance=new BadLockOnInteger(); 04 @Override 05 public void run() { 06 for(int j=0;j<10000000;j++){ 07 synchronized(i){ 08 i++; 09 } 10 } 11 } 12 13 public static void main(String[] args) throws InterruptedException { 14 Thread t1=new Thread(instance); 15 Thread t2=new Thread(instance); 16 t1.start();t2.start(); 17 t1.join();t2.join(); 18 System.out.println(i); 19 } 20 }
上述代码的第7~9行,为了保证计数器i的正确性,每次对i自增前,都先获得i的锁,以此保证i是线程安全的。从逻辑上看,这似乎并没有什么不对,所以,我们就满怀信心地尝试运行我们的代码。如果一切正常,这段代码应该返回20000000(每个线程各累加10000000次)。
但结果却让我们惊呆了,我得到了一个比20000000小很多的数字,比如15992526。这说明什么问题呢?一定是这段程序并没有真正做到线程安全!但把锁加在变量i上又有什么问题呢?似乎加锁的逻辑也是无懈可击的。
要解释这个问题,得从Integer说起。在Java中,Integer属于不变对象。也就是对象一旦被创建,就不可能被修改。也就是说,如果你有一个Integer代表1,那么它就永远表示1,你不可能修改Integer的值,使它为2。那如果你需要2怎么办呢?也很简单,新建一个Integer,并让它表示2即可。
如果我们使用javap反编译这段代码的run()方法,我们可以看到:
0: iconst_0 1: istore_1 2: goto 36 5: getstatic #20; //Field i:Ljava/lang/Integer; 8: dup 9: astore_2 10: monitorenter 11: getstatic #20; //Field i:Ljava/lang/Integer; 14: invokevirtual #32; //Method java/lang/Integer.intValue:()I 17: iconst_1 18: iadd 19: invokestatic #14; //Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 22: putstatic #20; //Field i:Ljava/lang/Integer; 25: aload_2 26: monitorexit
在第19~22行(对字节码来说,这是偏移量,这里简称为行),实际上使用了Integer.valueOf()方法新建了一个新的Integer对象,并将它赋值给变量i。也就是说,i++在真实执行时变成了:
i=Integer.valueOf(i.intValue()+1);
进一步查看Integer.valueOf(),我们可以看到:
public static Integer valueOf(int i) { assert IntegerCache.high >= 127; if (i >= IntegerCache.low && i <= IntegerCache.high) return IntegerCache.cache[i + (-IntegerCache.low)]; return new Integer(i); }
Integer.valueOf()实际上是一个工厂方法,它会倾向于返回一个代表指定数值的Integer实例。因此,i++的本质是,创建一个新的Integer对象,并将它的引用赋值给i。
如此一来,我们就可以明白问题所在了,由于在多个线程间,并不一定能够看到同一个i对象(因为i对象一直在变),因此,两个线程每次加锁可能都加在了不同的对象实例上,从而导致对临界区代码控制出现问题。
修正这个问题也很容易,只要将
synchronized(i){
改为:
synchronized(instance){
即可。
2.9 参考文献
这篇文章生动形象地描述了线程和进程
http://www.qnx.com/developers/docs/6.4.1/neutrino/getting_started/s1_procs.html
有关线程的状态机
http://www.cnblogs.com/skywang12345/p/3479024.html
对线程中断给出极其详细的描述
http://ibruce.info/2013/12/19/how-to-stop-a-java-thread/
对Java虚拟机的Server和Client模式进行了说明
http://www.uucode.net/201406/jvm-server-client-mode
线程组的概念与使用
http://ifeve.com/thread-management-11/
有关守护线程详尽描述
http://blog.csdn.net/lcore/article/details/12280027
HashMap在多线程卡死的细节分析
http://coolshell.cn/articles/9606.html
WeakHashMap多线程卡死的实际案例
http://www.uucode.net/201412/weakhashmap-endless-loop
HashMap的实现原理
http://www.uucode.net/201503/hashmap-hash-col