跳至主要內容

IntelliJ IDEA 断点追踪

Mayee...大约 17 分钟

前言

正所谓工欲善其事必先利其器,IDEA 是一款很强大的 Java IDE,无论是在项目开发阶段,还是阅读 JDK 或其他框架源码,断点调试都是很有必要的。但大多数情况下我们只使用了简单的调试方方式,这一篇将介绍详细介绍 IDEA 的断点调试方式。

1. 基础篇

首先贴上准备好的示例代码。

import com.mayee.bean.Person;
import com.mayee.service.IService;
import com.mayee.service.impl.IServiceImpl;

import java.math.BigInteger;

public class DebugTest {
    public static void main(String[] args) {
        line();
        detailLine();
        method();
        exception();
        field();
        thread();
    }

    /**
     * 行断点
     */
    private static void line() {
        System.out.println("this is the line break point.");
    }

    /**
     * 详细断点(源断点)
     */
    private static void detailLine() {
        System.out.println("this is the detail line break point.");
    }

    /**
     * 方法断点 | 接口跳转实现类
     */
    private static void method() {
        System.out.println("this is from method.");
        IService iService = new IServiceImpl();
        iService.execute();
    }

    /**
     * 异常断点 | 全局捕获
     */
    private static void exception() {
        Object o = null;
        o.toString();
        System.out.println("this line will never be print.");
    }

    /**
     * 属性断点 | 读写监控
     */
    private static void field() {
        Person person = new Person("maye", 26);
        person.setAge(27);
        System.out.println(person);
    }

    /**
     * 多线程断点
     */
    private static void thread() {
        // 第一个线程计算 100 累加
        AddThread t1 = new AddThread(100);
        // 第二个线程计算 100000 累加
        AddThread t2 = new AddThread(100000);

        t1.setName("t1");
        t2.setName("t2");

        t1.start();
        t2.start();

        try {
            // 线程 join 进主线程(main),以使在 t1 和 t2 执行结束前,主线程不会执行下一步
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        BigInteger result = t1.getResult().add(t2.getResult());
        System.out.println("two thread result add:" + result);
    }

    private static class AddThread extends Thread {
        private BigInteger result = BigInteger.ZERO;
        private final long num;

        public AddThread(long num) {
            this.num = num;
        }

        @Override
        public void run() {
            System.out.printf("%s start calculate: %d%n", Thread.currentThread().getName(), num);
            add(num);
            System.out.println(Thread.currentThread().getName() + "execute complete.");
        }

        // 累加计算
        public void add(long num) {
            for (int i = 1; i <= num; i++) {
                result = result.add(BigInteger.valueOf(i));
            }
        }

        public BigInteger getResult() {
            return result;
        }
    }
}
package com.mayee.bean;

public class Person {

    private String name;
    private int age;

    public Person() {

    }

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}
package com.mayee.service;

public interface IService {
    void execute();
}
package com.mayee.service.impl;

import com.mayee.service.IService;

public class IServiceImpl implements IService {
    @Override
    public void execute() {
        System.out.println("method executed.");
    }
}

接下来开始表演。

1.1. 行断点

main() 方法中注释掉其他方法,只留下 line() 方法。
在如图所示的位置点击鼠标左键便设置了一个断点,然后以 debug 方式启动程序,程序会执行到断点这一行挂起。这是最基本也是最常用的断点调试方式,因此就不再赘述。

1.2. 详细断点

main() 方法中注释掉其他方法,只留下 detailLine() 方法。
在如图所示的位置点击按下 shift + 鼠标左键 便可以打开断点设置面板,如图所示勾选,然后点击右下角的 DONE

我们可以看到断点的颜色是黄色,而不是之前的红色,此时以 debug 方式启动程序,会发现程序直接执行完成,而不会在断点处挂起。
但是我们可以看到控制台输出了断点到达行的详细信息。

如果我们就是想要程序停在断点处呢?
右键单击黄色断点,打开设置面板,勾选 Suspend,意为 挂起,然后点击右下角的 Done。再次以 debug 方式启动程序,便会在停在断点处了。
此时我们发现断点从黄色变成了红色,聪明的你可能会问了,这不是脱了裤子放 P 吗,没错就是给你看看这 PP 有多白 😏。言归正传,这个设置面板还有其他用处,稍后再讲。

1.3. 方法断点

main() 方法中注释掉其他方法,只留下 method() 方法。
mtehod() 方法处点击鼠标左键便设置了一个方法断点。我们可以看到断点是红色菱形的,此时以 debug 方式启动程序,会发现程序会停在方法的第一行。

此时我们按下 F9,程序会停在该方法的最后一行。
这就是方法断点,只需要在方法处下一个断点,IDEA 就会自动在方法的首尾两处挂起,方便查看入参和返回的情况。

在本方法中,我们调用了 IService 接口的方法 execute()。假如 iService 对象是外部调用的入参呢,可能 IService 接口有多个实现类,我们想观察 execute() 方法的内部执行情况,但不知道这里究竟调用的是哪个实例中的 execute() 方法,该如何断点?
此时我们只需要在 IService 接口中的 execute() 方法处进行断点即可,因为在方法出下断点,因此该断点也是红色菱形的。

此时以 debug 方式启动程序,会发现程序停在 IServiceImpl 类中 execute() 方法的第一行。这里有个菱形的断点标识,是程序自动显示的,并不是手动添加的。

若按下 F9,程序会像如上那样停在方法的结尾处,这里就不再图示了。

1.4. 异常断点

main() 方法中注释掉其他方法,只留下 exception() 方法。
我们来看 exception() 方法体中,这里我们故意制造了一个会产生 NullPointerException 异常的调用,这里不需要打任何断点,只需要点击如图所示或按下 Ctrl+Shift+F8,打开更详细的断点设置面板。

如图所示,这里有一个 Java Exception Breakpoints 的选项没有被勾选。

点击左上角的 ,并选择 Java Exception Breakpoints

会弹出一个面板,我们搜索并选中 NullPointerException,然后点击右下角的 OK

此时就添加并自动勾选了 NullPointerException 的选项,然后点击右下角的 Done

表示会对此种异常进行捕捉。当程序中任意位置产生了此种异常时,便会在异常的代码行处挂起。
此时以 debug 方式启动程序,会发现程序停止在了异常的代码行。

1.5. 属性断点

main() 方法中注释掉其他方法,只留下 field() 方法。
例如,我们想要监视 Person 类中的 age 属性,那么我们只需要在 age 属性前打一个断点。我们发现,这个断点是一个红色的眼睛,这很好理解,实心的红色表示断点启用,眼睛表示监视。

此时以 debug 方式启动程序,会发现程序停在了 Person 类的构造方法处。
因为在 field() 方法中,第一次使 age 属性值改变的代码是 Person person = new Person("maye", 26);,因此程序会挂起在 Person 类的构造方法中改变 age 属性值的这一行。

接着,我们按下 F9 会发现程序在停在 setAge() 方法的这一行。因为,在 field() 方法中,第二次使 age 属性值改变的代码是 person.setAge(27);,因此程序会挂起在 Person 类的 setAge() 方法中改变 age 属性值的这一行。

1.6. 多线程断点

前文我们说到在断点设置面板中,有一个 Suspend 的选项,这里我们再来看一下。
在如图所示处下断点,并右键单击断点打开设置选项卡,发现 Suspend 是自动被勾选的,后面还有 AllThread 两个选项。

AllThread 之间的区别:

  • All: 表示任何线程执行这此处都会挂起,此时程序也不会断点到子线程的方法中。
  • Thread: 表示以线程为单位挂起,此时程序会断点到子线程的的方法中。

此时,以 debug 方式启动程序,看到如图所示这一块,这里学名叫做 Call Stack,即为调用栈。
此时可以看到除了 main 线程外,还有 t1t2 两个线程。

按下 F9 让程序执行完。
我们再次打开断点设置选项卡,并切换勾选到 Thread,此时发现后面多出了一个 Make Default 的按钮,这个按钮表示是将 AllThread 设置为默认的断点方式,后续直接下的断点就会以这种方式挂起。因此各位请按照实际情况执行决定默认以哪种方式,这里我就不修改断点的默认方式了,直接点击右下角的 Done

然后,以 debug 方式启动程序,再次查看调用栈,发现只能看到 main 线程。因为断点处的代码是在 main() 方法中,线程上下文是在 main 线程中,而我们是以线程为单位挂起的,所以这里就只能看到 main 线程的调用栈。

按下 F9 让程序执行完。
接着我们在如图所示处打一个断点,再次以 debug 方式启动程序。此时查看调用栈,发现可以看到 t1t2 的线程,但 t1 线程前面有一个 ,这表示当前的线程的上下文是在 t1 这个线程中,我们同时可以看到右侧 num 变量的值为 100。

如果我们在调用栈中选中 t2 这个线程呢,此时便可以看到 t2 线程的 num 值为 100000。

当我们选中 t2 线程时,这是否意味着我们就将线程调度到了 t2 线程呢?此时,我们选中当前断点行处的 Thread.currentThread().getName() 代码,然后点击 Evaluate Expression 按钮,或按下 Alt + F8,呼出 Evaluate 面板。

然后点击 Evaluate 按钮,可以看到当前的线程名是 t1,而我们发现 依然是在 t1 线程前面,说明当前的活动线程依然是 t1 线程,此时在调用栈中切换并没有起到切换线程上下文的效果。因为此处断点的 Suspend 选项 All,即表示所有线程,哪个线程先执行到此处,就将线程上下文给到哪个线程。

按下 F9 让程序执行完。
右键单击此处断点,将 Suspend 切换到 Thread 选项,然后点击 Done

再次以 debug 方式启动程序,然后在调用栈中选中 t2 线程,接着按下 Alt + F8,再次输入 Thread.currentThread().getName(),然后点击 Evaluate,可以看到当前的线程名已经是 t2,并且 也在 t2 线程前面。说明此时的活动线程为 t2,而线程上下文也被切换到了 t2 线程。

多线程调试的意义在于,各线程副本之间互不干扰,可以随时在多个线程之间切换上下文,观察线程内部的执行情况。

2. 进阶篇

首先贴上准备好的示例代码。

import com.mayee.bean.Person;
import com.mayee.service.IService;
import com.mayee.service.impl.IServiceImpl;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class DebugAdvance {

    public static void main(String[] args) {
        conditions();
        printStackTrace();
        evaluate();
        saveRecourse();
        keysExplain();
        sourceCode();
        streamDebug();
    }

    /**
     * 条件表达式
     */
    public static void conditions() {
        for (int i = 0; i < 10; i++) {
            System.out.println(i);
        }

        Runnable myThread = () -> {
            System.out.println(Thread.currentThread().getName() + " -- come in");
            try {
                TimeUnit.MILLISECONDS.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                System.out.println(Thread.currentThread().getName() + " -- leave out");
            }
        };

        Thread t1 = new Thread(myThread, "t1");
        Thread t2 = new Thread(myThread, "t2");
        Thread t3 = new Thread(myThread, "t3");

        t1.start();
        t2.start();
        t3.start();
    }

    /**
     * 打印堆栈信息
     */
    public static void printStackTrace() {
        List<Integer> list = new ArrayList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        list.add(4);
        System.out.println(list);
    }

    /**
     * 表达式解析
     */
    public static void evaluate() {
        System.out.println("evaluate");
        Person person = new Person("maye", 26);

        List<Integer> list = Stream.of(1, 2, 3, 4).map(i -> i * 2).collect(Collectors.toList());
    }

    /**
     * 避免操作资源 | force return
     */
    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");
    }

    /**
     * 快捷键、图标含义
     */
    public static void keysExplain() {
        System.out.println("keys");
        // step over
        System.out.println("step over");

        // step into | step out
        System.out.println("step into | step out");
        IService iService = new IServiceImpl();
        iService.execute();

        // force step into
        StringBuffer buffer = new StringBuffer();
        buffer.append("hello world");
        System.out.println(buffer.toString());

        // run to cursor
        System.out.println("i am here");
        System.out.println("i am here");
        System.out.println("i am here");
        System.out.println("i am here");
    }

    /**
     * 源码调试 | JDK
     */
    public static void sourceCode() {
        ArrayList<Integer> list = new ArrayList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        System.out.println(list.size());

        LinkedList<Integer> linkedList = new LinkedList<>();
        linkedList.add(1);
        linkedList.add(2);
        linkedList.add(3);
        System.out.println(linkedList.size());
    }

    /**
     * stream 调试
     */
    public static void streamDebug() {
        // steam chain
        Stream.of(1, 2, 3, 4, 5, 6)
                .filter(i -> i % 2 == 0 || i % 3 == 0)
                .map(i -> i * i)
                .forEach(System.out::println);

        // evaluate 曲线救国
        String str = Optional.of("hi,")
                .map(s -> s + "Java")
                .map(s -> s + "技术")
                .map(s -> s + "栈")
                .get();
        System.out.println(str);
    }
}

2.1. 条件表达式

main() 方法中注释掉其他方法,只留下 conditions() 方法。
在如图所示的设置一个断点,并在 Condition 处设置一个条件表达式 i % 2 == 0,然后点击右下角的 Done。此时可以看到断点是红色圆形,并且右下侧有一个 ? 标识。

IDEA 会自动识别到当前程序的上下文变量,这个条件表示当变量 i 的值为偶数时,断点才生效。
然后以 debug 方式启动程序,可以看到只有 i 的值为 2, 4, 6, 8 的时候才会进入断点。

那是否说明,只有 i 为偶数时程序才被执行到了呢?并不是,我们可以看控制台,发现 i 从 0 ~ 9 都被打印出来了,仅仅是 i 为偶数才会挂起程序。

同理,我们也可以在线程中设置断点的条件表达式。这里我们设置只有当线程名为 t1 时才将程序挂起。

此时以 debug 方式启动程序,可以看到当前的挂起的线程是 t1,也只有 t1 会被挂起,t2t3 线程均不会被挂起。

2.2. 打印断点堆栈信息

main() 方法中注释掉其他方法,只留下 printStackTrace() 方法。
在如图所示的设置一个断点,并点击 View Breakpoints 或按下 Ctrl + Shift + F8,打开断点详情面板。

然后勾选 "Breakpoint hit" messageStack trace,点击右下角 Done
以 debug 方式启动程序,可以看到控制台打印出了断点处的调用栈信息。

2.3. 表达式解析

main() 方法中注释掉其他方法,只留下 evaluate() 方法。
在如图所示处打断点,以 debug 方式启动程序,然后按下 Alt + F8,可以进行变量计算。注意,只有作用域在断点处上下文中的变量才能被计算。

2.4. 避免操作资源

main() 方法中注释掉其他方法,只留下 saveRecourse() 方法。
在如图所示处打断点,然后以 debug 方式启动程序。
这里我们模拟方法中的资源操作,在业务逻辑 shit happens 的后面有三个操作,分别是将数据存入数据库、Redis,发送消息到 MQ 中。假如我们执行到了 shit happens 处,却发现数据有问题,不想执行后面的三个操作,以免写入脏数据,该如何做呢?
你可能会说,这还不简单,直接停止程序就好了。但是这样真的可以吗?我们在断点处停止了程序,然后看到后面的三行代码依然被打印在了控制台,这意味着这些代码依然被执行了,即使我们停止了程序。

如何才能使后面的代码不被执行呢?
此时,再次以 debug 方式启动程序。然后在调用栈中的 saveRecourse() 方法栈帧处单击右键,选中 Force Return,即表示当前栈帧(saveRecourse() 方法)强制返回,程序会走到上层调用完它的位置,此处即为 main() 方法的结尾处。

2.5. debug 按钮说明

main() 方法中注释掉其他方法,只留下 keysExplain() 方法。
我们可以看到 IDEA 的调试面板,从左到右有 8 个按钮,依次编号为:1 ~ 8。

  1. Show Execution Point(Alt+F10):当我们在看方法调用链的时候,会一层一层的进入到方法内部看,如果想要快速回到断点的位置查看,就可以点击这个按钮。
  2. Setp Over(F8):意为步过,按下表示程序从断点处向下执行一行,如果下一行代码是个方法,它不会进入到方法内部。
  3. Step Into(F7):意为步入,如果断点执行到当前行是一个方法,则可进入到方法内部。
  4. Force Step Into(Alt+Shift+F7):意为强制步入,若当前断点执行处的方法源码在我们本地,则使用步入即可进入方法内部,若当前断点执行处的方法源码不在本地,例如是 Jar 包引入的三方库,则需要用强制步入才可以进入到方法内部。
  5. Step Out(Shift+F8):意为步出,若我们在查看一个方法内部中的执行情况,看到一半不想看了,可以使用步出,程序将走到上层调用此方法的位置(此处表示 main() 方法中调用 keysExplain() 方法返回的位置),keysExplain() 方法不会被再次调用。步入强制步入都可以使用步出
  6. Drop Frame:表示丢弃当前(栈)帧,程序会走到上层调用此方法处(此处表示 main() 方法中调用 keysExplain() 方法的位置)。但是要注意,当程序走到 keysExplain() 方法中时,选择丢弃当前帧,即表示将 keysExplain() 方法从栈帧中丢掉,然后重新调用一次 keysExplain() 方法。
  7. Run to Cursor(Alt+F9):按下吃按钮表示,程序会直接跳转到鼠标光标所在行的代码处。注意,程序只能从上向下执行,若将光标定位到断点前的代码,那么程序直接就结束了。
  8. Evaluate Expression(Alt+F8):表示可以对变量进行表达式计算。

2.6. 源码调试

main() 方法中注释掉其他方法,只留下 sourceCode() 方法。
在如图所示处打断点,然后以 debug 方式启动程序。
当我们想进入方法内部的某一行代码查看时,可以点击 Resume Program 或按下 F9,表示程序跳转到下一个断点处。

2.7. Stream 调试

当我们使用 Java8 使我们在变成方式上有很大变化,其中一个就是流式处理,它允许我们将数据以数据流的方式,进行管道处理。不熟悉流式编程的开发者会很不习惯,认为代码难以阅读,搞不清楚里面做了什么操作。但是当用熟了之后,便会直呼:“妈呀,真香!”。可用上了流式编程后,该如何调试呢?
main() 方法中注释掉其他方法,只留下 sourceCode() 方法。
在如图所示处打断点,但这里有 3 个选项 LineλAll

  • Line:表示将 Stream 流的链式表达当做一行,即不会在流的中间断点显示变量值。
  • λ:表达将 Stream 流的链式表达的每一个节点当做一行,会在流的中间断点显示变量值。
  • All:表示两者兼具,也会在流的中间断点显示变量值。

这里我们将选择以 Line 的方式断点,然后以 debug 方式启动程序。

点击下方的 Trace Current Stream Chain

会打开一个 Stream Trace ,即流的追踪面板。
我们可以看到在此面板中,将程序中流的每一步数据操作的过程及结果都做了对应,可以很直观的看出数据在经过流中每一步操作之后的变化。

默认是 Split Mode 视图分割模式,可以在上面的几个选项卡之间切换视图,当然也可以点击左下角的 Flat Mode 切换为扁平模式,数据将会在一个视图中展示,之后也可以点击走下角的 Split Mode 再切回视图分割模式。


至此,IDEA 的 debug 方式基本介绍完,熟练掌握之后有助于在开发中提高工作效率,也有助于提高阅读源码的效率。