美文网首页
《实战高并发程序设计》读书笔记-隐蔽的并发问题

《实战高并发程序设计》读书笔记-隐蔽的并发问题

作者: 乙腾 | 来源:发表于2021-05-03 16:31 被阅读0次

    程序中的幽灵:隐蔽的错误

    无提示错误

    平均值

    int v1=1073741827;
    int v2=1431655768;
    System.out.println("v1="+v1);
    System.out.println("v2="+v2);
    int ave=(v1+v2)/2;
    System.out.println("ave="+ave);
    

    上述代码中,加粗部分试图计算v1和v2的均值。乍看之下,没有什么问题。目测v1和v2的当前值,估计两者的平均值大约在12亿左右。但如果你执行代码,却会得到以下输出:

    v1=1073741827
    v2=1431655768
    ave=-894784850
    

    v1+v2的结果就已经导致了int的溢出。

    并发下的ArrayList

    public class ArrayListMultiThread {
        static ArrayList<Integer> al = new ArrayList<Integer>(10);
        public static class AddThread implements Runnable {
            @Override
            public void run() {
                for (int i = 0; i < 1000000; i++) {
                    al.add(i);
                }
            }
        }
    
        public static void main(String[] args) throws InterruptedException {
            Thread t1=new Thread(new AddThread());
            Thread t2=new Thread(new AddThread());
            t1.start();
            t2.start();
            t1.join();t2.join();
            System.out.println(al.size());
        }
    }
    

      如果你执行这段代码,你可能会得到三种结果。
      第一,程序正常结束,ArrayList的最终大小确实2000000。这说明<font color=red>即使并行程序有问题,也未必会每次都表现出来</font>。
      第二,程序抛出异常:

    Exception in thread "Thread-0" java.lang.ArrayIndexOutOfBoundsException: 22
        at java.util.ArrayList.add(ArrayList.java:441)
        at geym.conc.ch2.notsafe.ArrayListMultiThread$AddThread.run
    (ArrayListMultiThread.java:12)
        at java.lang.Thread.run(Thread.java:724)
    1000015
    

      这是因为ArrayList在扩容过程中,内部一致性被破坏,但由于没有锁的保护,另外一个线程访问到了不一致的内部状态,导致出现越界问题。
      第三,出现了一个非常隐蔽的错误,比如打印如下值作为ArrayList的大小:

    1793758
    

      显然,这是由于多线程访问冲突,使得保存容器大小的变量被多线程不正常的访问,同时两个线程也同时对ArrayList中的同一个位置进行赋值导致的。

    并发下诡异的HashMap

    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。
      第三,<font color=red>程序永远无法结束。</font>

    这里重点说一下第三种情况:

      打开任务管理器,你们会发现,这段代码占用了极高的CPU,最有可能的表示是占用了两个CPU核,并使得这两个核的CPU使用率达到100%。这非常类似死循环的情况。
      使用jstack工具显示程序的线程信息,如下所示。其中jps可以显示当前系统中所有的Java进程。而jstack可以打印给定Java进程的内部线程及其堆栈。

    C\Users\geym >jps
    14240 HashMapMultiThread
    1192 Jps
    C:\Users\geym >jstack 14240
    
    "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的内部数据。当前所处循环乍看之下是一个迭代遍历,就如同遍历一个链表一样。但在此时此刻,由于多线程的冲突,这个链表的结构已经遭到了破坏,链表成环了!当链表成环时,上述的迭代就等同于一个死循环,比如两个节点的next节点指向对方。

      这个死循环的问题,如果一旦发生,着实可以让你郁闷一把。但这个死循环的问题在JDK 8中已经不存在了。由于JDK 8对HashMap的内部实现了做了大规模的调整,因此规避了这个问题。但即使这样,贸然在多线程环境下使用HashMap依然会导致内部数据不一致。最简单的解决方案就是使用ConcurrentHashMap代替HashMap。

    初学者常见问题:错误的加锁

    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 }
    

      上述代码很容易得到一个远小于20000000的数字,作者的这个例子举得确实精彩,他的锁对象居然选择了Integer,这个线程不安全的例子,如果知识储备不到位,很难解释,来看一下作者的解释:

      要解释这个问题,得从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在第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);
    

      <font color=red>Integer.valueOf()实际上是一个工厂方法,它会倾向于返回一个代表指定数值的Integer实例。因此,i++的本质是,创建一个新的Integer对象,并将它的引用赋值给i</font>。
      <font color=red>如此一来,我们就可以明白问题所在了,由于在多个线程间,并不一定能够看到同一个i对象(因为i对象一直在变),因此,两个线程每次加锁可能都加在了不同的对象实例上,从而导致对临界区代码控制出现问题。</font>

    相关文章

      网友评论

          本文标题:《实战高并发程序设计》读书笔记-隐蔽的并发问题

          本文链接:https://www.haomeiwen.com/subject/zhwfdltx.html