返回介绍

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

发布于 2024-08-21 22:20:21 字数 8462 浏览 0 评论 0 收藏 0

作为一名软件开发人员,修复程序BUG应该说是基本的日常工作之一。作为Java程序员,也许你经常会被抛出的一大堆的异常堆栈所困扰,因为这可能预示着你又有工作可做了。但我这里想说的是,如果程序出错,你看到了异常堆栈,那你应该感到格外的高兴,因为这也意味着你极有可能可以在两分钟内修复这个问题(当然,并不是所有的异常都是错误)。最可怕的情况是:系统没有任何异常表现,没有日志,也没有堆栈,但是却给出了一个错误的执行结果,这种情况下,才真会让你抓狂。

2.8.1 无提示的错误案例

我在这里想给出一个系统运行错误,却没有任何提示的案例。让大家体会一下这种情况的可怕之处。我相信,在任何一个业务系统中,求平均值,应该是一种极其常见的操作。这里就以求两个整数的平均值为例。请看下面代码:

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的溢出。

把这个问题单独拿出来研究,也许你不会有特别的感触,但是,一旦这个问题发生在一个复杂系统的内部。由于复杂的业务逻辑,很可能掩盖这个看起来微不足道的问题,再加上程序自始至终没有任何日志或异常,再加上你运气不是太好的话,这类问题不让你耗上几个通宵,恐怕也是难有眉目。

所以,我们自然会恐惧这些问题,我们也希望在程序异常时,能够得到一个异常或者相关的日志。但是,非常不幸的是,错误地使用并行,会非常容易产生这类问题。它们难觅踪影,就如同幽灵一般。

2.8.2 并发下的ArrayList

我们都知道,ArrayList是一个线程不安全的容器。如果在多线程中使用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());
  }
}

上述代码中,t1和t2两个线程同时向一个ArrayList容器中添加容器。他们各添加1000000个元素,因此我们期望最后可以有2000000个元素在ArrayList中。但如果你执行这段代码,你可能会得到三种结果。

第一,程序正常结束,ArrayList的最终大小确实2000000。这说明即使并行程序有问题,也未必会每次都表现出来。

第二,程序抛出异常:

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中的同一个位置进行赋值导致的。如果出现这种问题,那么很不幸,你就得到了一个没有错误提示的错误。并且,他们未必是可以复现的。

注意:改进的方法很简单,使用线程安全的Vector代替ArrayList即可。

2.8.3 并发下诡异的HashMap

HashMap同样不是线程安全的。当你使用多线程访问HashMap时,也可能会遇到意想不到的错误。不过和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。

第三,程序永远无法结束。

对于前两种可能,和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){

即可。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文