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

目录
  • 一、Java 运行时数据区域
  • 二、用 DEBUG 的方式看线程运行原理
  • 三、线程运行原理详细图解
  • 四、用 DEBUG 的方式看多线程运行原理

一、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 的状态。

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

以上就是深入理解以DEBUG方式线程的底层运行原理的详细内容,更多关于DEBUG方式线程运行原理的资料请关注我们其它相关文章!

(0)

相关推荐

  • Java多线程并发执行demo代码实例

    主类:MultiThread,执行并发类 package java8test; import java.util.ArrayList; import java.util.List; import java.util.concurrent.BlockingQueue; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Execut

  • 教你如何监控 Java 线程池运行状态的操作(必看)

    之前写过一篇 Java 线程池的使用介绍文章<线程池全面解析>,全面介绍了什么是线程池.线程池核心类.线程池工作流程.线程池分类.拒绝策略.及如何提交与关闭线程池等. 但在实际开发过程中,在线程池使用过程中可能会遇到各方面的故障,如线程池阻塞,无法提交新任务等. 如果你想监控某一个线程池的执行状态,线程池执行类 ThreadPoolExecutor 也给出了相关的 API, 能实时获取线程池的当前活动线程数.正在排队中的线程数.已经执行完成的线程数.总线程数等. 总线程数 = 排队线程数 +

  • Java使用多线程异步执行批量更新操作方法

    写在前面: 相信不少开发者在遇到项目对数据进行批量操作的时候,都会有不少的烦恼,尤其是针对数据量极大的情况下,效率问题就直接提上了菜板.因此,开多线程来执行批量任务是十分重要的一种批量操作思路,其实这种思路实现起来也十分简单,就拿批量更新的操作举例: 整体流程图 步骤 获取需要进行批量更新的大集合A,对大集合进行拆分操作,分成N个小集合A-1 ~ A-N . 开启线程池,针对集合的大小进行调参,对小集合进行批量更新操作. 对流程进行控制,控制线程执行顺序. 按照指定大小拆分集合的工具类 impo

  • Java 判断线程池所有任务是否执行完毕的操作

    我就废话不多说了,大家还是直接看代码吧~ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class Test { public static void main(String args[]) throws InterruptedException { ExecutorService exe = Executors.newFixedThreadPool(3); f

  • 浅谈Java获得多线程的返回结果方式(3种)

    一:Java创建线程方式 继承Thread类或者实现Runnable接口. 但是Runnable 的 run() 方法是不带返回值的,那如果我们需要一个耗时任务在执行完之后给予返回值,应该怎么做呢? 第一种方法:在 Runnable 的实现类中设置一个变量 V,在 run 方法中将其改变为我们期待的结果,然后通过一个 getV() 方法将这个变量返回. package com.test.thread; import java.util.*; import sun.swing.Accumulati

  • JAVA 自定义线程池的最大线程数设置方法

    一:CPU密集型: 定义:CPU密集型也是指计算密集型,大部分时间用来做计算逻辑判断等CPU动作的程序称为CPU密集型任务.该类型的任务需要进行大量的计算,主要消耗CPU资源.  这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数. 特点:    01:CPU 使用率较高(也就是经常计算一些复杂的运算,逻辑处理等情况)非常多的情况下使用    02:针对单台机

  • Java多线程实现TCP网络Socket编程(C/S通信)

    开篇必知必会 在前一篇<基于TCP协议网络socket编程(java实现C/S通信)>,实际存在一个问题,如果服务器端在建立连接后发送多条信息给客户端,客户端是无法全部接收的,原因在于客户端为单线程,只接受了第一条信息,剩余信息阻塞等待下一次发送.所以,这造成了客户端无法处理消息队列,每次只接收并输出一条服务器信息,出现信息不同步问题. 本篇将解决这个问题,详细记录实现java多线程通信,目标是使客户端可以一次接收服务器发送的多条信息,避免阻塞.方法是将客户端接收信息功能独立为一个线程来完成,

  • java中常见的6种线程池示例详解

    之前我们介绍了线程池的四种拒绝策略,了解了线程池参数的含义,那么今天我们来聊聊Java 中常见的几种线程池,以及在jdk7 加入的 ForkJoin 新型线程池 首先我们列出Java 中的六种线程池如下 线程池名称 描述 FixedThreadPool 核心线程数与最大线程数相同 SingleThreadExecutor 一个线程的线程池 CachedThreadPool 核心线程为0,最大线程数为Integer. MAX_VALUE ScheduledThreadPool 指定核心线程数的定时

  • java多线程数据分页处理实例讲解

    在数据的最终结果上,我们能够通过分类的方法,准备的筛选出不同类别结果的信息.这里我们发散一下思维,在Java中对于数据大量处理的,多线程是一个非常常见的代表,我们可以用分页来处理多线程的数据问题.下面我们对分类的类型进行了解,然后带来两种分页在多线程的逻辑. 1.常见的分页类型 传统的:采用传统的分页方式,可以明确的获取数据信息,如有多少条数据,分多少页显示等. 下拉式:采用下拉式的分页方式,一般无法获取明确的数据数量相关的信息,但在分页操作以后,仍然可以看到之前查询的数据. 2.分页式查询逻辑

  • 在windows下揪出java程序占用cpu很高的线程并完美解决

    我的一个java程序偶尔会出现cpu占用很高的情况 一直不知道什么原因 今天终于抽时间解决了 系统是win2003 jvisualvm 和 jconsole貌似都只能看到总共占用的cpu 看不到每个线程分别占用的cpu呢所以在windows平台上要找出到底是哪个线程占用的cpu还不那么容易,linux用top就简单多了 最后的解决方法: 1.找到java进程对应的pid. 找pid的方法是:打开任务管理器,然后点击 "查看" 菜单,然后点击 "选择列",把pid勾上

随机推荐