IntelliJ IDEA 调试技巧深度剖析:一网打尽开发中常用调试小技巧_idea调试日常技巧
断点调试
行断点
示例代码如下:
public static void main(String[] args) { basicBreakpointDemo();}// 1. 基本断点调试private static void basicBreakpointDemo() { System.out.println(\"=== 基本断点调试 ===\"); List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5); int sum = 0; // 在这里设置普通断点 for (int num : numbers) { sum += num; System.out.println(\"Current sum: \" + sum); } System.out.println(\"Total sum: \" + sum);}
在如下位置打上断点:
使用断点调试功能,运行 main 方法:
我们打好断点后,以 debug 模式启动程序,程序就会停在断点上
当点击下面该按钮时,程序将跳过第一个断点之后的所有断点,并直接运行至结束:
日志断点
如果我们要打一个日志断点,需要使用shift + 鼠标左键
:
打上详细断点后,IDEA 会跳出关于 断点详细配置
的面板;
Suspend
- Suspend(挂起)选项决定了程序在到达断点时的行为:
- 勾选 Suspend:
程序会在到达断点时暂停
,允许我们检查变量值、调用栈等信息。 - 未勾选 Suspend:
程序不会在断点处暂停,而是继续执行
。即使是一个普通断点,如果未勾选 Suspend,程序也会直接跳过。
- 勾选 Suspend:
如果我们勾选了 Suspend
并点击 Done
按钮,那么该断点将从一个详细断点转变为普通的行断点。
接下来,我们在原位置重新设置一个详细断点,但不勾选
“Suspend”
选项,然后点击“Done”
,这样就成功添加了一个详细断点:
重新以 Debug 的形式运行 main 方法,观察程序运行结果:
此时我们发现:
- 程序不仅执行了
basicBreakpointDemo()
的逻辑 - 在输出结果中间(即详细断点所在的位置)插入了一句提示日志:
“Breakpoint reached at DebugPractice.basicBreakpointDemo”
。
但是转念一想,断点断点,你为啥没给我断呢?
黄色断点
:在 IntelliJ IDEA 中,黄色断点通常表示这是一个日志断点
(Logpoint),而不是普通的断点。日志断点用于在代码执行到该位置时记录日志信息,但不会暂停程序的执行。
普通断点``(红色断点)
:用于在代码执行到该位置时暂停程序,以便进行调试
并且,如果我们未勾选 Suspend:程序不会在断点处暂停,而是继续执行
。即使是一个普通断点,如果未勾选 Suspend,程序也会直接跳过。
方法断点
在方法签名处打的断点,就是方法断点:
使用 Debug,运行 main 方法,程序停在了方法断点下一行的位置:
我们勾选方法断点的配置选项 Method exit
:
然后我们点击Reesume Program
按钮:
这样,在按下 Resume Program
按钮后,程序不会立即执行完该断点所在的方法,而是停留在该方法的末尾位置:
这一功能使我们能够在方法即将结束时,查看与该方法相关的所有变量的当前值:
如果方法断点不勾选 Method exit
,则按Reesume Program
按钮,调试的断点方法会立刻执行完毕:
接下来,我们来看一下,方法断点对接口和对应实现类的调试作用:
public class DebugPractice { public static void main(String[] args) { testGreeters(); } private static void testGreeters() { // 常规的对象创建方式 Greeter englishGreeter = new EnglishGreeter(); Greeter chineseGreeter = new ChineseGreeter(); // 常规的方法调用方式 englishGreeter.greet(); // 第一次调用 chineseGreeter.greet(); // 第二次调用 }}interface Greeter { void greet(); // 设置方法断点}class ChineseGreeter implements Greeter { @Override public void greet() { System.out.println(\"你好!\"); }}class EnglishGreeter implements Greeter { @Override public void greet() { System.out.println(\"Hello!\"); }}
只在接口方法打一个方法断点,其他地方都不打断点
,接一下以 debug 的方式运行 main 方法
程序以 debug 的方式启动后,会自动跳转到该接口对应的实现类的方法中:
因此,如果一个接口有多个实现类
,并且我们想要知道接口中的方法是由哪一个实现类具体实现的
,可以在接口对应的方法上设置一个方法断点
。
启动调试模式(Debug Mode)后,程序会在调用该接口方法时自动暂停,并跳转到具体的实现类方法中
。通过这种方式,我们可以直观地确认是哪一个实现类被调用
。
异常断点
异常断点实例代码如下:
pulic class DebugPractice{ private static void exceptionBreakpointDemo() { Object o = null; o.toString(); // NullPointerException System.out.println(\"this line will never be print!\"); } public static void main(String[] args) { exceptionBreakpointDemo(); } }
如果在没有设置任何断点的情况下直接启动调试模式,程序将立即运行至结束,并且在控制台中输出空指针异常(NullPointerException
)的日志。
接下来,我们来设置异常断点,设置异常断点,可以在程序出现异常前,让程序自动停在出现异常的地方,不继续往下运行;
在调试面板中,点击 view Breakpoint
按钮,会出现断点的控制面板:
接下来,我们在断点控制面板中进行对应的设置:
如果我们要监控某一种特定的断点,可以进行如下设置:
再次 debug ,程序会自动停留在出现空指针异常的地方:
字段断点
public static void main(String[] args) { fieldWatchDemo();}// 字段观察断点 | 读写监控private static void fieldWatchDemo() { User user = new User(\"John\", 30); user.setAge(31); // 在 User 类的 age 字段上设置字段观察断点 System.out.println(\"User updated: \" + user);}
在 User 类的 age 字段上设置字段观察断点
,用于在调试时,监控 age 字段的生命周期:
用 debug 模式启动程序:
点击 Reesume Program
按钮执行下一步:
再次点击 Reesume Program
按钮执行下一步:
此时虽然还存在一个 System.out.println
方法调用,但该调用实际上是通过 toString()
方法实现的,属于读操作。在点击“Resume Program”继续调试时,程序不会因这种读操作而暂停。
多线程调试
Suspend
接下来,我们来看一下关于多线程的调试技巧,代码逻辑:
这段代码实现了一个多线程累加计算的Demo,主要逻辑如下:
创建两个线程:
- thread1 计算1到100的累加和
- thread2 计算1到100000的累加和
线程执行流程:
- 每个线程启动后执行 run() 方法
- 在 run() 中调用 add() 方法进行实际累加计算
- 使用 BigInteger 处理大数运算
主线程控制:
- 主线程通过 join() 等待两个子线程完成计算
- 最后汇总两个线程的结果并打印
计算逻辑:
- add() 方法实现从[1,n]的累加
- 结果存储在 result 变量中
这是一个典型的多线程并行计算示例,展示了线程创建、启动、同步和结果汇总的基本模式。
以下是我们用于调试的多线程示例代码:
import java.math.BigInteger;public class ThreadDebugDemo { // 开启两个线程 public static void main(String[] args) { // 第一个线程计算 100! AddThread thread1 = new AddThread(100); // 第二个线程计算 10000! AddThread thread2 = new AddThread(100000); thread1.setName(\"thread1\"); thread2.setName(\"thread2\"); thread1.start(); thread2.start(); try { thread1.join(); // 线程 join , 以便主线程在 \"线程1\" 和 \"线程2\" 都返回结果之前不会进一步执行 thread2.join(); } catch (InterruptedException e) { e.printStackTrace(); } BigInteger result = thread1.getResult().add(thread2.getResult()); System.out.println(\"讲两个线程的计算结果相加等于: \" + result); } private static class AddThread extends Thread{ private BigInteger result = BigInteger.ONE; private long num; public AddThread(long num){ this.num = num; } @Override public void run() { System.out.println(Thread.currentThread().getName() + \" 开始计算: \" + num); add(num); System.out.println(Thread.currentThread().getName() + \"执行完成\"); } // 累加计算 public void add(long num){ BigInteger f = new BigInteger(\"1\"); for(int i = 2 ; i <= num; i++){ f = f.add(BigInteger.valueOf((i))); } result = f; } public BigInteger getResult(){ return result; } }}
我们先在thread1.join()
的位置打上一个断点:
线程 join , 以便主线程在 “线程1” 和 “线程2” 都返回结果之前不会进一步执行
以 debug 模式运行对应 main 方法:
我们点开多线程调用栈:
我们先点击 Reesume Program
按钮执行完当前调试,右键刚刚打的断点,进入断点设置配置面板:
我们勾选 Thread 模式,然后重新 debug ,我们会发现 call stack 调用栈只剩下 main 线程,Thread1、Thread2 则没有了:
去掉刚刚的断点,我们再在 run() 方法打一个断点,并且将该断点的 Suspend 从 All 设置为 Thread
:
此时的 call stack 有 thread1 、thread2:
并且,在切换栈帧
的过程中,我们可以查看当前线程中字段的具体值
。为此,必须将断点的 Suspend
选项从 All
设置为 Thread
,当断点触发时,挂起范围从整个 JVM 进程的所有线程
都会被挂起,到仅挂起触发断点的当前线程,其他线程继续运行
继续拓展,我们点击下面的按钮 Evaluate Expression
,就是使用运行时表达式
的解析功能:
我们可以让Evaluate Expression
打印当前系统运行的线程名:
上面的功能,都是基于Suspend -> Thread
(以线程为单位进行挂起
)的设置实现的,哪个线程触发断点,哪个线程就被挂起,在 Evaluate Expression
中执行Thread.currentThread().getName()
,从能准确获取线程
如果我们选择将断点挂起设置为 Suspend -> All
,那么程序会暂停所有线程的执行,而不是仅暂停当前线程
。在这种情况下,我们无法直接获取当前系统线程的上下文,因为所有线程都被暂停了。此时,线程的调度是随机的,哪个线程先获得上下文是不确定的。
后续即使通过 Evaluate Expression
功能执行 Thread.currentThread().getName()
,无论我们如何切换栈帧,运行时表达式得到的结果都不会改变,始终是最初获得上下文的线程名称。
Condition
在断点设置面板中,我们不仅可以选择挂起线程的范围(Suspend
),还可以自定义断点的调试条件(Condition
),以下是我们接下来用于调试的示例代码:
import java.util.concurrent.TimeUnit;public class DebugAdvance { public static void main(String[] args) { condition(); } // 条件表达式 public static void condition(){ for (int i = 0; i < 10; i++) { System.out.println(i); } MyThread myThread = new MyThread(); Thread t1 = new Thread(myThread,\"thread1\"); Thread t2 = new Thread(myThread,\"thread2\"); Thread t3 = new Thread(myThread,\"thread3\"); t1.start(); t2.start(); t3.start(); } public static class MyThread implements Runnable{ @Override public void run() { System.out.println(Thread.currentThread().getName() + \"--进入\"); try { TimeUnit.MILLISECONDS.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }finally { System.out.println(Thread.currentThread().getName() + \"--离开\"); } } }}
- 主线程逻辑:
- 先通过一个
for
循环打印数字 0 到 9。- 然后创建三个线程
t1
、t2
和t3
,分别命名为\"thread1\"
、\"thread2\"
和\"thread3\"
,并启动它们。- 线程逻辑:
- 每个线程在执行时,先打印
\"线程名称--进入\"
。- 然后暂停 100 毫秒(模拟耗时操作)。
- 最后打印
\"线程名称--离开\"
。- 运行结果:
- 数字 0 到 9 会先被打印出来。
- 三个线程并发执行,打印进入和离开的信息,具体顺序取决于线程调度。
接下来,我们在代码中如下位置设置断点。如果在该行代码中,i
是一个变量,那么在设置断点时,i
也会被识别为变量。因此,如果我们在断点的条件(Condition)中输入 i
,它会被解释为当前行中的变量 i
:
此时右键点击断点,进入断点设置面板:
注意:断点设置的 condition 并不会影响程序执行的最终结果,当满足 condition 条件时,断点才会被触发,程序才会停下来
;
利用好 condition 功能,就可以设置我们需要重点调试的地方,比如,如果我们在如下位置设置断点:
此时,如果我们比较关系 thread1 线程的执行效果,就在断点的 condition 中进行如下设置,让该断点只要在执行的线程是 thread1 时,才会停顿
:
接下来,我们用 debug 模式运行 main 方法,我们发现
- 当选择
Suspend -> Thread
时,调用栈中仅显示触发断点的线程(例如thread1
)的堆栈信息; - 当选择
Suspend -> All
时,所有线程都会暂停,但只有触发断点的线程(例如thread1
)能够获取当前上下文信息; - 因为我们断点对 condition 的设置,在调试过程中,只有当前线程名为 thread1,程序才会停下来,thread2 、thread3 则因为没有触发断点条件而被直接跳过;
打印堆栈信息
接下来,我们再来看看调试过程中,打印堆栈信息的调试功能,示例代码如下:
public static void main(String[] args) { printStackTrace();}private static void printStackTrace() { ArrayList list = new ArrayList(); list.add(1); list.add(2); list.add(3); list.add(4); System.out.println(list);}
比如我们想看某处代码的堆栈信息时,也可以先在该代码处打一个断点,并在断点中进行对应的设置:
触发断点打印对应堆栈信息的配置如下:
我们重新以 debug 模式运行 main 方法,执行程序过程如下:
表达式分析
示例代码如下:
public static void main(String[] args) { evaluate();}// 表达式解析public static void evaluate(){ System.out.println(\"evaluate\"); User user = new User(\"kris\",33); List<Integer> list = Arrays.asList(1,2,3,4).stream() .map(x -> x*2).collect(Collectors.toList());}
为了更好演示表达式分析功能,我们先去掉上文在 User 类中打的字段断点
;接下来,我们在如下位置打上断点,并重新以 debug 的模式运行 main 方法:
以下是使用表达式分析的过程:
在调试过程中,通过使用
Evaluate Expression
功能,我们可以对已经识别的变量
进行以下操作:
计算表达式
:对变量执行不同于原始代码的运算规则。获取字段值
:访问变量的字段属性。修改字段值
:更改变量的字段属性。
然而,需要注意的是,Evaluate Expression 仅在调试时评估表达式的结果,这些操作不会影响程序的实际运行或最终结果
。
避免操作资源
在日常开发过程中,我们经常会遇到一些意外情况:
- 比如代码中出现了未被
try catch
捕获的异常; - 或者没有通过
if-else
语句进行判断;
导致错误数据被存储到数据库、Redis 中,甚至是一些重要的信息,被发送到消息队列(MQ)中。
public static void main(String[] args) { saveRecourse(); } // 避免操作资源 | drop frames public static void saveRecourse(){ System.out.println(\"shit happens\"); // 模拟未被捕获的异常 System.out.println(\"save to db\"); System.out.println(\"save to redis\"); System.out.println(\"send message to mq for money payout\"); }
这种情况不仅会浪费我们大量的调试时间,还可能带来更严重的问题。
因为一旦这些错误数据进入数据库,我们可能需要花费更多的时间,去恢复或删除这些数据,才能使系统恢复正常运行,从而达到预期的效果。
为了避免这种情况的发生,我们需要学习如何在开发过程中,避免资源的错误操作和数据的异常存储
。
我们在如下位置打上断点,并且以 debug 模式启动程序
经过调试,我们已经知道程序在哪里出现了异常:
那我们是否可以通过下列操作,来停止调试呢?
在调试过程中,即使我们通过断点定位到异常代码,但如果直接终止调试,后续的代码仍然会继续执行
,包括可能将错误数据写入数据库、Redis 或发送到消息队列等操作
。这会导致错误日志被记录,甚至污染数据存储
。
因此,仅仅通过终止调试是不够的。我们需要在定位到异常代码后,采取更有效的方式来避免后续对资源的操作:
源码调试
示例代码
public static void main(String[] args) { sourceCode();}// 调试源码 | JDKpublic static void sourceCode(){ ArrayList arrayList = new ArrayList(); arrayList.add(1); arrayList.add(2); arrayList.add(3); System.out.println(arrayList.size()); LinkedList linkedList = new LinkedList(); linkedList.add(1); linkedList.add(2); linkedList.add(3); System.out.println(linkedList.size());}
以上代码是关于 ArrayList 和 LinkedList 添加数据 add 的代码,如果我们要查看 add() 的实现细节,可以进行如下操作:
打上断点后,以 debug 模式运行 main 方法:
按照以上的调试技巧,我们就可以通过调试,看到调用的 API 中,内部的具体实现细节;
stream 调试
示例代码
public static void main(String[] args) { streamDebug();}// stream 调试public static void streamDebug(){ // stream chain Arrays.asList(1,2,3,45).stream() .filter(i -> i%2 == 0 || i % 3 ==0) .map(i -> i * i) .forEach(System.out::print);}
stream 调试步骤如下: