并行程序调试要比串行程序复杂得多,但幸运的是,现代IDE开发环境可以在一定程度上缓建并发程序调试的难度。在本章中,我想简单介绍一下有关并行程序调试的一些技巧和经验。
8.1 准备实验样本
为了方便讲解,我们定义一个简单的类,作为实验样本:
01 public class UnsafeArrayList { 02 static ArrayList al=new ArrayList(); 03 static class AddTask implements Runnable{ 04 @Override 05 public void run() { 06 try { 07 Thread.sleep(100); 08 } catch (InterruptedException e) {} 09 for(int i=0;i<1000000;i++) 10 al.add(new Object()); 11 } 12 } 13 public static void main(String[] args) throws InterruptedException { 14 Thread t1=new Thread(new AddTask(),"t1"); 15 Thread t2=new Thread(new AddTask(),"t2"); 16 t1.start(); 17 t2.start(); 18 Thread t3=new Thread(new Runnable(){ 19 @Override 20 public void run() { 21 while(true){ 22 try { 23 Thread.sleep(1000); 24 } catch (InterruptedException e) {} 25 } 26 } 27 },"t3"); 28 t3.start(); 29 } 30 }
在这里,我使用的JDK版本为JDK8u5。
上述代码是在多线程下访问ArrayList,因此,是错误的写法。在这里,我们将使用调试,重现这个错误。
8.2 正式起航
在正式开始之前,先让我们熟悉一下Eclipse的调试环境。当你使用Eclipse调试Java程序时,当程序执行到断点处,默认情况下,当前的线程就会被挂起。
图8.1显示了在ArrayList.add()函数内部设置了一个断点:
图8-1 将断点设置在ArrayList.add()内
接着,以调试方式启动上面的代码,可以看到,程序会停留在系统第一次调用ArrayList.add()的地方,如图8.2所示。
图8.2 断点阻止了程序的运行
在上图8.2中,可以看到主线程main停留在ArrayList.add()中,并且显示了完整的调用堆栈。但很不幸的是,其实我们对主函数并没有太大兴趣,因为这些都是JDK内部的代码实现。目前,我们更关心的是在程序中t1和t2线程对ArrayList的调用。因此,我们会更希望忽略这些无关的调用。对于ArrayList这种非常常用的类来说,如果不加识别地进行断点设置,对系统的整个调试会变得异常痛苦。那么应该怎么处理呢?
依托于Eclipse的强大功能,我们很容易实现这点。我们可以为这个断点设置一些额外属性,如图8.3所示。
图8.3 设置断点属性
由于我们不希望主函数启动时被中断,因此在条件断点中指定断点条件是当前线程而不是主线程main,如图8.4所示,取得当前线程名称,并判断是否为主线程:
图8.4 设置条件断点
基于以上设置,再次执行调试这段代码,我们就可以调试t1和t2线程了,如图8.5所示。
图8.5 被中断的t1和t2
从这个调试窗口中可以看到,当前正在执行的几个线程,这里显示了t1、t2和t3。由于t3线程并没有使用ArrayList,因此,它处于Running状态,并保持一直执行。而t1和t2两个线程都在ArrayList.add()方法中被挂起。
如上图8.5所示,当前选中的是t2线程,如果我们进行单步操作,那么t2线程就会执行,而t1不会继续执行,除非,你手工选择t1并进行相应的操作。
8.3 挂起整个虚拟机
在这里,我还想提一个比较重要的功能。在默认情况下,当断点条件成立时,系统会挂起相关的线程,没有断点的线程会继续执行。在实际环境中,那些还在继续执行的线程可能会对整个调试产生不利的影响。为此,我们可以设置断点类型为挂起整个Java虚拟机,而不仅仅是挂起相关线程。如图8.6所示,改变这个断点的类型:
图8.6 设置断点类型为挂起整个虚拟机
当然,默认情况下,调试器只会挂起遇到断点的线程,如果你希望所有断点的模式都是挂起虚拟机而不是挂起线程,则还可以在Eclipse的全局配置中设置,如图8.7所示。
图8.7 设置断点模式行为为挂起虚拟机
在挂起虚拟机模式下,程序进入断点后的状态如图8.8所示。
图8.8 挂起虚拟机时的系统状态
可以看到,当前所有的线程全部处于挂起状态,不论当前线程是否接触到了断点。这种模式可以排除其他线程对被调试线程的干扰。当然,使用这种方法有时候会引起调试器或者虚拟机的一些问题,导致系统不能正常工作。
直接执行上述代码,很可能抛出类似下面的异常:
Exception in thread "t2" java.lang.ArrayIndexOutOfBoundsException: 21079 at java.util.ArrayList.add(ArrayList.java:444) at geym.conc.ch8.UnsafeArrayList$AddTask.run(UnsafeArrayList.java:19) at java.lang.Thread.run(Thread.java:745)
下面,就让我们用单步调试的方法,来重现这个异常吧!
8.4 调试进入ArrayList内部
首先,我们需要理解ArrayList的工作方式。在ArrayList初始化时,默认会分配10个数组空间。当数组空间消耗完毕后,ArrayList就会进行自动扩容。在每次add()操作时,系统总要事先检查一下内部空间是否满足所需的大小,如果不满足,就会扩容,否则就可以正常添加元素。
多线程共同访问ArrayList的问题在于:在ArrayList容量快用完时(只有1个可用空间),如果两个线程同时进入add()函数,并同时判断认为系统满足继续添加元素而不需要扩容,进而两者都不会进行扩容操作。之后,两个线程先后向系统写入自己的数据,那么必然有一个线程会将数据写到边界外,而产生这个ArrayIndexOutOfBoundsException。
基于上述原理,我们在ArrayList.add()函数中设置断点如图8.9所示。
图8.9 ArrayList.add()的断点设置
这个断点意味着在非主线程中(这里就是t1和t2了),当进入add()函数后,如果当前ArrayList的容量为9(当前的最大容量为10),则触发断点。之所以这么设置,是因为当容量没有饱和时,显然不会发生这个ArrayIndexOutOfBoundsException的问题,因此可以直接忽略这些情况。
接着,选中t1线程,让它进行容量检查,并让它停止在追加元素的语句前,如图8.10所示。
图8.10 t1线程完成容量检查
接着,在t1增加元素之前,选中t2线程,并让t2进入add()函数,完成进行容量检查,如图8.11所示。
图8.11 t2完成容量检查
此时,t1和t2都认为ArrayList中的容量是满足它们的需求的,因此,它们都准备开始追加元素。让我们先选择t1,完成追加,如图8.12所示。
图8.12 t1完成元素追加
在t1追加完成后,t2并不知道数据空间实际上已经用完了。而之前的容量检查告诉t2,你可以继续追加元素,因此,t2还会义无反顾地继续执行后续追加操作。选择t2,让t2进行元素追加,此时,当t2试图向ArrayList追加元素时,追加操作并没有如我们预期一样完成,因为,此时,size的值已经超过了elementData的边界。如图8.13所示,可以看到ArrayIndexOutOfBoundsException异常位于t2线程中。
图8.13 t2线程发生异常
让t2继续往下执行的结果就是前文中那段异常信息,之后,t2线程就从线程列表中消失了(执行结束)。