以 DEBUG 方式深入理解线程的底层运行原理
说到线程的底层运行原理,想必各位也应该知道我们今天不可避免的要讲到 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 的状态。
这个状态就包括每个线程私有的程序计数器和虚拟机栈中每个栈帧的信息等,显然,每次操作系统都需要存储这么多的信息,频繁的线程上下文切换势必会影响程序的性能。
关注公众号 | 飞天小牛肉,即时获取更新
- 博主东南大学硕士在读,携程 Java 后台开发暑期实习生,利用课余时间运营一个公众号『 飞天小牛肉 』,2020/12/29 日开通,专注分享计算机基础(数据结构 + 算法 + 计算机网络 + 数据库 + 操作系统 + Linux)、Java 技术栈等相关原创技术好文。本公众号的目的就是让大家可以快速掌握重点知识,有的放矢。关注公众号第一时间获取文章更新,成长的路上我们一起进步
- 并推荐个人维护的开源教程类项目: CS-Wiki(Gitee 推荐项目,现已累计 1.6k+ star), 致力打造完善的后端知识体系,在技术的路上少走弯路,欢迎各位小伙伴前来交流学习 ~
- 如果各位小伙伴春招秋招没有拿得出手的项目的话,可以参考我写的一个项目「开源社区系统 Echo」Gitee 官方推荐项目,目前已累计 700+ star,基于 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + ... 并提供详细的开发文档和配套教程。公众号后台回复 Echo 可以获取配套教程,目前尚在更新中。
以 DEBUG 方式深入理解线程的底层运行原理的更多相关文章
- MyBatis温故而知新-底层运行原理
准备工作 public class MainClass { public static void main(String[] args) throws Exception { String resou ...
- PHP底层运行原理简括
PHP是一种适用于web开发的动态语言.具体点说,就是一个用C语言实现包含大量组件模块的软件框架.是一个强大的UI框架. 简言之:PHP动态语言执行过程:拿到一段代码后,经过词法解析.语法解析等阶段后 ...
- php底层运行原理
http://www.cnblogs.com/phphuaibei/archive/2011/09/13/2174927.html
- Java线程池底层源码分享和相关面试题(持续更新)
线程池各个参数讲解 public ThreadPoolExecutor(int corePoolSize, //线程池核心工作线程数量,比如newFixedThreadPool中可以自定义的线程数量就 ...
- jdk线程池ThreadPoolExecutor工作原理解析(自己动手实现线程池)(一)
jdk线程池ThreadPoolExecutor工作原理解析(自己动手实现线程池)(一) 线程池介绍 在日常开发中经常会遇到需要使用其它线程将大量任务异步处理的场景(异步化以及提升系统的吞吐量),而在 ...
- 【JUC】如何理解线程池?第四种使用线程的方式
线程池的概念 线程池的主要工作的控制运行的线程的数量,处理过程种将任务放在队列,线程创建后再启动折现任务,如果线程数量超过了最大的数量,则超过部分的线程排队等待,直到其他线程执行完毕后,从队列种取出任 ...
- 多线程系列八:线程安全、Java内存模型(JMM)、底层实现原理
一.线程安全 1. 怎样让多线程下的类安全起来 无状态.加锁.让类不可变.栈封闭.安全的发布对象 2. 死锁 2.1 死锁概念及解决死锁的原则 一定发生在多个线程争夺多个资源里的情况下,发生的原因是 ...
- Java中的线程池用过吧?来说说你是怎么理解线程池吧?
前言 Java中的线程池用过吧?来说说你是怎么使用线程池的?这句话在面试过程中遇到过好几次了.我甚至这次标题都想写成[Java八股文之线程池],但是有点太俗套了.虽然,线程池是一个已经被说烂的知识点了 ...
- 线程池底层原理详解与源码分析(补充部分---ScheduledThreadPoolExecutor类分析)
[1]前言 本篇幅是对 线程池底层原理详解与源码分析 的补充,默认你已经看完了上一篇对ThreadPoolExecutor类有了足够的了解. [2]ScheduledThreadPoolExecut ...
随机推荐
- vue:表格中多选框的处理
效果如下: template中代码如下: <el-table v-loading="listLoading" :data="list" element-l ...
- 微信小程序:block标签
代码中存在block标签,但是渲染的时候会移除掉. 例子: 如果将view改为block: 当你要渲染某些数据时,如果不想额外的加一层外边的标签,此时可以使用block标签来进行占位.
- C语言:贪心算法之装箱问题
#include <stdio.h> #include <stdlib.h> #define N 6 #define V 100 typedef struct box // 使 ...
- Elasticsearch--Logstash定时同步MySQL数据到Elasticsearch
新地址体验:http://www.zhouhong.icu/post/139 一.Logstash介绍 Logstash是elastic技术栈中的一个技术.它是一个数据采集引擎,可以从数据库采集数据到 ...
- Python3.x 基础练习题100例(11-20)
练习11: 题目: 古典问题:有一对兔子,从出生后第3个月起每个月都生一对兔子,小兔子长到第三个月后每个月又生一对兔子,假如兔子都不死,问每个月的兔子总数为多少? 分析: 兔子的规律为数列1,1,2, ...
- dapr学习:dapr介绍
该部分主要是给出学习dapr的入门,描述dapr全貌告诉你dapr是啥以及介绍dapr的主要功能与组件 该部分分为两章: 第一章:介绍dapr 第二章:调试dapr的解决方案项目 1. 介绍dapr ...
- C#连接Excel读取与写入数据库SQL ( 下 )
接上期 dataset简而言之可以理解为 虚拟的 数据库或是Excel文件.而dataset里的datatable 可以理解为数据库中的table活着Excel里的sheet(Excel里面不是可以新 ...
- 使用zap接收gin框架默认的日志并配置日志归档
目录 使用zap接收gin框架默认的日志并配置日志归档 gin默认的中间件 基于zap的中间件 在gin项目中使用zap 使用zap接收gin框架默认的日志并配置日志归档 本文介绍了在基于gin框架开 ...
- 确保某个BeanDefinitionRegistryPostProcessor Bean被最后执行的几种实现方式
目录 一.事出有因 二.解决方案困境 三.柳暗花明,终级解决方案 第一种实现方案 第二种实现方案 第三种实现方案 四.引发的思考 一.事出有因 最近有一个场景,因同一个项目中不同JAR包依赖同一个 ...
- Windows搭建flutter开发环境以及android&idea配置
Flutter:是谷歌新推出的一款能够支持Android和IOS跨平台开发的全新的UI框架. 拥有自己的一套UI渲染引擎,所以目前的测试数据来看,在性能上面,并没有比原生App性能低多少,所以目前来看 ...