一大堆的题外话
无论是程序员,学生,或者是编程爱好者,也不管我们是初学编程还是想掌握一门新的编程语言,我们要做的基本都是先在电脑上安装开发环境,然后编程在控制台上输出“hello world”。但是一个程序到底是如何运行起来的呢?首先我们需要一台计算机,为什么不是一个鞋盒子而是一个计算机?什么样的设备可以被称作计算机?它到底是用来做什么的?这和我们说的JVM又有什么关系?
计算机是人类发明的辅助计算的工具,帮助人类解决运算问题。我们这里把计算机比作一个人,来分解一下你的求助过程。
第一步,告诉他问题是什么(输入)
第二步,运算和记录结果(运算器、存储器)
第三步,告诉你结果(输出)
把上述流程连起来(控制器),你就得到了你想要的答案。
这就是计算机的基础结构,虽然现在计算机为了提高运算能力在结构上要比我们讨论的复杂的多的多,但是基本的流程至今也没有发生过变化。
我们的程序运行主要对应上述步骤的第二步,那么很显然,程序的运行需要运算器和存储器。那么运算器和存储器是怎么工作的?上文也说过现在的计算机结构要复杂的多但是从主要功能上来看,运算器我们经常我们把它称为CPU,而存储器对应着内存和硬盘。
首先计算机把程序从硬盘加载到内存当中,然后CPU从内存中读取程序代码,运算得出结果之后记录在内存当中。具体的运算过程就是CPU有一个指令计数器会指向下一个要执行的命令,直到命令执行完毕返回结果。
执行一个函数的的过程可以看做是一个栈结构的出栈,函数所有的变量都保存在栈中,如果当前函数执行完毕所有栈空间都需要被释放,如果出现函数之间的调用,就会把函数的返回值加载到上一级的函数栈中,这也是为什么我们可以在上下级函数里使用同一个变量名。到这里我相信大家应该了解程序是如何在计算机里运行了的,接下来我们进入主题,一起学习下JAVA代码是如何在JVM上运行的。
JVM内存模型
JVM全称Java Virtual Machine,在我理解上来看这个Machine就是计算机,所以JVM和计算机的运行内存结构肯定是有很多联系的,甚至可以说JVM就是计算机的一层“封装”。JVM同样以线程为最小单位运行,上图绿色部分是线程共享的,红色部分是线程独有的。
红色部分,JVM和计算机一样有操作栈和程序计数器,运行的方式也基本一致,不同的是JAVA是一种高级语言是由C语言实现的,所以在调用C语言实现的函数(native)时会生成本地方法栈。绿色部分,堆空间存放着JAVA创建的一些对象以及运行时常量池,对象的指针存放在虚拟机栈中。方法区则存放着class文件和静态变量。大多数情况下,当虚拟机栈被释放,就意味着对应的堆空间应该被回收,这块GC的问题我们留到以后再讨论。
现在我们总结一下,JVM内存结构如下
- 线程独有
– 程序计数器
执行要执行的下一条语句
– 虚拟机栈
每个函数执行会创建一个栈针,栈中存放局部变量,操作数,动态链接,方法返回地址。
如果虚拟机栈超过最大深度会抛出StackOverflowError
对应虚拟机参数为 -Xss 默认值一般为512k
– 本地方法栈
调用native函数则会生成一个本地方法栈 - 线程私有
–方法区
存放class文件,常量和静态的变量
–堆
存放程序运行时创建的对象
如果堆空间不足会抛出OutOfMemoryError
对应虚拟机参数为 -Xms(最小值) -Xmx(最大值)
一般来说会把这两个参数设为一致,避免堆空间自动扩容时造成的性能损耗。
实例分析
在解释JVM线程运行模型之前,我们先来看一段简单的代码
public class Math {
public static void main(String[] args) {
Math math = new Math();
math.compute();
}
public void compute(){
int a = 1;
int b = 2;
int c = (a+b)*10;
}
}
当然这里举代码的例子不是为了让我们去分析它的运行结果,而是根据这段代码去分析JVM线程的运行机制。Java代码会从main函数开始运行,这是我们第一节JAVA课就会学到的,然后这段代码在main()中调用了一个compute()方法,那么问题就来了,是main()先执行完还是compute()先执行完呢?这对大家来说可能太简单,当然是compute()先执行完。先进后执行,很容易让我们想到一种基础数据结构——栈,也正是因为栈和计算机语言有相同的执行顺序,所以JVM选用栈作为线程的运行模型。
JVM线程模型
由上图可以看出方法区和JAVA堆是所有线程共用的,而程序计数器、执行引擎、JAVA栈则是线程私有的,每次方法的调用都会创建一个栈帧,然后将带有方法信息的栈帧压入栈中。接下来咱们具体分析下上面代码在JVM中的运行情况,进入工程的target/classes目录下,执行
javap -c <文件名>
把,class文件转换成JVM指令
Compiled from "Math.java"
public class study.Math {
public study.Math();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class study/Math
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method compute:()V
12: return
public void compute();
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: return
}
可以看出,转换后的Math类包括构造方法,main()和compute(),值得一提的是所有的类的构造方法都会调用Object的构造方法。下面让我们从main()方法逐行分析代码是如何运行的。
首先创建Main()栈帧入栈,然后创建Math对象,把对象的引用值放入局部变量1,然后把局部变量1的引用值装载到操作栈中,此时执行状态如下图所示。
然后创建compute()栈帧入栈,将int类型常量1压入栈,将int类型常量1的值存入局部变量1,将int类型常量2压入栈,然后将int类型常量2存入局部变量2,装载变量1和变量2到操作栈中,此时状态如下图所示。
将栈顶两个元素弹出相加入栈,然后将10压入栈中。
下一个操作是将栈顶两个操作数弹出相乘,并把结果压入栈中,最后再将当前栈顶元素存入局部变量3中,当前状态如下。
当执行到return语句,这个方法对应的内存空间就会被JVM回收。当compute()执行完,main()也就执行到了return语句,此时整个线程会被JVM回收。另外,这里再提一下如果执行函数中调用到Native方法,不会有新的栈帧压入JAVA栈中,JVM会另外申请空间将Native方法压入本地方法栈也就是我们常说的C栈中。
类加载器
因为和JVM关系比较密切,这里提一下JAVA的类加载器。
- BootStrap ClassLoader
根加载器用来加载 Java 的核心类,是用原生代码来实现的,负责加载jre/lib下rt.jar文件中的类。 - Ext ClassLoader
扩展类加载器,负责加载jre/lib/ext下的扩展类,父类为ootStrap ClassLoader。 - Application ClassLoader
应用加载器,负责加载程序中实现的类,也就是我们熟悉的ClASSPATH下的类,父类为Ext ClassLoader。 - 自定义ClassLoader
自定义的加载器,可以自定义加载目录,父类为Application ClassLoader。
如图所示,JAVA的类加载器会自下而上加载对应的Class文件,防止JAVA基础的类被改写,这样保证了程序的安全性。
思考
实践才是认识的目的,学完JVM内存模型以后,我们至少知道了怎么遇到StackOverflowError和OutOfMemoryError问题应该如何去处理,-Xms和-Xmx参数最好设为一致。如果可以更深一步思考,应该想到CPU和内存程序运行的核心性能性能指标,如果在设计阶段就能考虑到这两个指标会在项目上线以后避免很多问题。
越来越快的工作节奏,越来越多的封装屏蔽了我们对底层的了解,提升工作效率的同时也让我们逐渐思维僵化逐步面向需求、面向结果编程,却忘记了磨刀不误砍柴工这个基本的道理。