以 DEBUG 方式深入理解线程的底层运行原理

时间:2024-02-01 12:14:39

说到线程的底层运行原理,想必各位也应该知道我们今天不可避免的要讲到 JVM 了。其实大家明白了 Java 的运行时数据区域,也就明白了线程的底层原理,不过把这些东西明明白白写在纸面上的,网络上的文章并不多,所以今天我总结了一下,带着大家一步一步 DEBUG,来看看线程到底是怎么运行的,顺便把 IDEA 的 DEBUG 方法简单讲一下。

工具的使用应该是大部分同学都缺失的,我自己就深受其害,经常不由自主地习惯性用肉眼一行一行排 BUG(狗头)。

Java 运行时数据区域

友情提示:这部分内容可能大部分同学都有一定的了解了,可以跳过直接进入下一小节哈。

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途,以及创建和销毁的时间。

全文我们都将以 JDK 7 的运行时数据区域为例:

先简单解释下线程共享和线程私有是啥意思。

所谓线程私有,通俗来说就是每个线程都会创建一个属于自己的东西,每个线程之间的这块私有区域互不影响,独立存储。比如程序计数器就是线程私有的,每个线程都会拥有一个属于自己的程序计数器,互不干涉。

线程共享就没啥好说的,简单理解为公共场所,谁都能去,存储的数据所有线程都能访问。

OK,然后我们来逐个分析下每个区域都是用来存储什么的。当然了,这里不会做太多详细的说明,不然会使文章显得非常臃肿,在理解本文的基础上能够让大家对各个区域有基本的认知就好了。

首先来看一下线程共享的两个区域:

1)Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在 Java 虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。

2)方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

很多人习惯的把方法区称为永久代(Permanent Generation),但实际上这两者并不等价。通俗来说,方法区是一种规范,而永久代是 HotSpot 虚拟机实现这个规范的一种手段,对于其他虚拟机(比如 BEA JRockit、IBM J9 等)来说是不存在永久代的概念的。

另外,对于 HotSpot 虚拟机来说,它在 JDK 8 中完全废弃了永久代的概念,改用与 JRockit、J9 一样在本地内存中实现的元空间(Meta-space)来代替,把 JDK 7 中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

再来看看线程私有的三个区域:

1)虚拟机栈(Java Virtual Machine Stacks)其实是由一个一个的栈帧(Stack Frame)组成的,一个栈帧描述的就是一个 Java 方法执行的内存模型。也就是说每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法的返回地址等信息。

每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程,当然,出栈的顺序自然是遵守栈的后进先出原则的。

栈帧的概念在接下来的原理解析部分非常重要,各位务必搞懂哈。

2)本地方法栈(Native Method Stack)和上面我们所说的虚拟机栈作用基本一样,区别只不过是本地方法栈为虚拟机使用到的 Native 方法服务,而虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务。

这里解释一下 Native 方法的概念,其实不仅 Java,很多语言中都有这个概念。

"A native method is a Java method whose implementation is provided by non-java code."

就是说一个 Native 方法其实就是一个接口,但是它的具体实现是在外部由非 Java 语言写的。所以同一个 Native 方法,如果用不同的虚拟机去调用它,那么得到的结果和运行效率可能是不一样的,因为不同的虚拟机对于某个 Native 方法都有自己的实现,比如 Object 类的 hashCode 方法。

这使得 Java 程序能够超越 Java 运行时的界限,有效地扩充了 JVM。

3)程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于 Java 虚拟机的多线程是通过轮流分配 CPU 时间片的方式来实现的,因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。

那么程序计数器里存的到底是什么东西呢?

《深入理解 Java 虚拟机:JVM 高级实践与最佳实战 - 第 2 版》给出了答案:如果线程正在执行的是一个 Java 方法,程序计数器中记录的就是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)

用 DEBUG 的方式看线程运行原理

接下来,我们就通过 DEBUG 这段代码来看下线程的运行原理:

上述代码的逻辑非常简单,main 方法调用了 method1 方法,而 method1 方法又调用了 method2 方法。

看下图,我们打了一个断点:

OK,以 DEBUG 的方式运行 Test.main(),虽然这里我们没有显示的创建线程,但是 main 函数的调用本身就是一个线程,也被称为主线程(main 线程),所以我们一启动这个程序,就会给这个主线程分配一个虚拟机栈内存。

上文我们也说了,虚拟机栈内存其实就是个壳儿,里面真正存储数据的,其实是一个一个的栈帧,每个方法都对应着一个栈帧

所以当主线程调用 main 方法的时候,就会为 main 方法生成一个栈帧,其中存储了局部变量表、操作数栈、动态链接、方法的返回地址等信息。

各位现在可以看看 DEBUG 窗口显示的界面:

左边的 Frames 就是栈帧的意思,可以看见现在主线程中只有一个 main 栈帧;

右边的 Variables 就是该栈帧存储的局部变量表,可以看到现在 main 栈帧中只有一个局部变量,也就是方法参数 args。

接下来 DEBUG 进入下一步,我们先来看看 DEBUG 界面上的每个按钮都是啥意思,总共五个按钮(已经了解的各位可以跳过这里):

1)Step Over:F8

程序向下执行一行,如果当前行有方法调用,这个方法将被执行完毕并返回,然后到下一行

2)Step Into:F7

程序向下执行一行,如果该行有自定义方法,则运行进入自定义方法(不会进入官方类库的方法)

3)Force Step Into:Alt + Shift + F7

程序向下执行一行,如果该行有自定义方法或者官方类库方法,则运行进入该方法(也就是可以进入任何方法)

4)Step Out:Shift + F8

如果在调试的时候你进入了一个方法,并觉得该方法没有问题,你就可以使用 Step Out 直接执行完该方法并跳出,返回到该方法被调用处的下一行语句。

5)Drop frame

点击该按钮后,你将返回到当前方法的调用处重新执行,并且所有上下文变量的值也回到那个时候。只要调用链中还有上级方法,可以跳到其中的任何一个方法。


OK,我们点击 Step Into 进入 method1 方法,可以看到,虚拟机栈内存中又多出了一个 method1 栈帧:

再点击 Step Into 直到进入 method2 方法,于是虚拟机栈内存中又多出了一个 method2 栈帧:

当我们 Step Into 走到 method2 方法中的 return n 语句后,n 指向的堆中的地址就会被返回给 method1 中的 m,并且,满足栈后进先出的原则,method2 栈帧会从虚拟机栈内存中被销毁。

然后点击 Step Over 执行完输出语句(Step Into 会进入 println 方法,Force Step Into 会进入 Object.toString 方法)

至此,method1 的使命全部完成,method1 栈帧会从虚拟机栈内存中被销毁。

最后再往下走一步,main 栈帧也会被销毁,这里就不再贴图了。

线程运行原理详细图解

上面写了这么多,其实也就是教会了大家栈帧这个东西,接下来我们通过图解的方式,来带大家详细看看线程运行时,Java 运行时数据区域的各种变化。

首先第一步,类加载。

《深入理解 Java 虚拟机:JVM 高级实践与最佳实战 - 第 2 版》中是这样解释类加载的:虚拟机把描述类的数据从 Class 文件(字节码文件)加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。

加载进来的这些字节码信息,就存储在方法区中。看下图,这里为了各位理解方便,我就不写字节码了,直接按照代码来,大家知道这里存的其实是字节码就行

主线程调用 main 方法,于是为该方法生成一个 main 栈帧:

那么这个参数 args 的值从哪里来呢?没错,就是从堆中 new 出来的:

而 main 方法的返回地址就是程序的退出地址。

再来看程序计数器,如果线程正在执行的是一个 Java 方法,程序计数器中记录的就是正在执行的虚拟机字节码指令的地址,也就是说此时 method1(10) 对应的字节码指令的地址会被放入程序计数器图片中我们仍然以具体的代码代替哈,大家知道就好

OK,CPU 根据程序计数器的指示,进入 method1 方法,自然,method1 栈帧就被创建出来了:

局部变量表和方法返回地址安顿好后,就可以开始具体的方法调用了,首先 10 会被传给 x,然后走到 y 被赋值成 x + 1 这步,也就是程序计数器会被修改成这步代码对应的字节码指令的地址:

走到 Object m = method2(); 这一步的时候,又会创建一个 method2 栈帧:

可以看到,method2 方法的第一行代码会在堆中创建一个 Object 对象:

随后,走到 method2 方法中的 return n; 语句,n 指向的堆中的地址就会被返回给 method1 中的 m,并且,满足栈后进先出的原则,method2 栈帧会从虚拟机栈内存中被销毁:

根据 method2 栈帧指向的方法返回地址,我们接着执行 System.out.println(m.toString()) 这条输出语句,执行完后,method1 栈帧也被销毁了:

再根据 method1 栈帧指向的方法返回地址,发现我们的程序已走到了生命的尽头,main 栈帧于是也被销毁了,就不再贴图了。

用 DEBUG 的方式看多线程运行原理

上面说的是只有一个线程的情况,其实多线程的原理也差不多,因为虚拟机栈是每个线程私有的,大家互不干涉,这里我就简单的提一嘴。

分别在如下两个位置打上 Thread 类型的断点:

然后以 DEBUG 方式运行,你就会发现存在两个互不干涉的虚拟机栈空间:

当然,使用多线程就不可避免的会遇到一个问题,那就是线程的上下文切换(Thread Context Switch),就是说因为某些原因导致 CPU 不再执行当前的线程,转而执行另一个线程。

导致线程上下文切换的原因大概有以下几种:

1)线程的 CPU 时间片用完

2)发生了垃圾回收

3)有更高优先级的线程需要运行

4)线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法

当线程的上下文切换发生时,也就是从一个线程 A 转而执行另一个线程 B 时,需要由操作系统保存当前线程 A 的状态(为了以后还能顺利回来接着执行),并恢复另一个线程 B 的状态。

这个状态就包括每个线程私有的程序计数器和虚拟机栈中每个栈帧的信息等,显然,每次操作系统都需要存储这么多的信息,频繁的线程上下文切换势必会影响程序的性能