图文详解jvm中的内存与线程模型

时间:2021-10-10 04:46:57

         本文使用以下两张图来深入探究JVM中的内存与线程模型,对于这两张图的区别以及图中所涉及到的一些名词作者一开始也是云里雾里,不过现在会详细给大家解释作者对这些名词的理解,本文主要由以下三个部分构成

       (1) JVM内存区域划分

       (2) JVM各内存区域存储简介

       (3) 代码示例详解JVM内存存储位置

             图文详解jvm中的内存与线程模型

                                                              图1

                     图文详解jvm中的内存与线程模型

                                                                        图2

JVM内存区域划分
       JVM在内存区域划分上主要分为栈(Stack)、本地方法栈(Native Stack)、程序计数器(PC)、堆(Heap)、非堆(Non Heap)等五个部分。其中栈、本地方法栈与程序计数器是属于线程私有的(可以理解为这三个区域就在线程内部,每个线程都有一份,并且每个线程都只能访问自己内部的这三个区域),不存在线程同步问题。堆和非堆是属于线程共享的,在JVM内存中有且只有一份(所有的线程都能访问这两个区域),所以堆和非堆存在着线程同步问题。

JVM各内存区域存储简介

    

      

   大家都知道运行程序的实体是线程,每个方法的运行需要各种变量以及对变量的各种操作等等,而变量的存储与对变量的操作是需要内存空间的,这部分的内存空间就是线程中的栈。每个线程都有属于自己的栈空间,栈是由一个个栈帧所构成的,一个栈帧对应一个方法。由于栈的结构是先进后出的,所以线程当前所执行的方法在栈的最顶部,如果当前方法如果调用了新的方法,一个新的栈帧会压栈到栈顶,此时这个新的方法就成了当前方法,反之如果方法执行完或抛出未捕获的异常时,最顶部的栈帧就会出栈。在图1中为虚拟机栈区域,在图2 中为Thread的Stack区域,以下两张图片截取自图2:                            

            图文详解jvm中的内存与线程模型     

    由上图可知,栈是由栈帧构成的,而栈帧是由以下四个部分所组成的:

        1)局部变量数组

              局部变量数组对应上图中的Local Variables区域,它包含一个方法执行时的所有变量,包括this引用(当前对象的引用)、方法参数、方法内部的局部变量。如果是静态方法(也称类方法),局部变量数组的下标0为方法参数,如果是对象方法,局部变量数组的下标0为this。另外,long和double类型的变量占用局部变量数组两个连续的位置,其他类型的变量占用一个位置,因为long和double是64位双精度,而其他类型为32位单精度

        2)返回值

              方法的返回分为两种情况,一种是正常退出,退出后会根据方法的定义来决定是否要传返回值给上层的调用者,一种是异常导致的方法结束,这种情况是不会传返回值给上层的调用方法。

              不过无论是哪种方式的方法结束,在退出当前方法时都会跳转到当前方法被调用的位置,如果方法是正常退出的,则调用者的PC计数器的值就可以作为返回地址。如果是因为异常退出的,则是需要通过异常处理表来确定

               在方法的一次调用就对应着栈帧在虚拟机栈中的一次入栈出栈操作,因此方法退出时可能做的事情包括:恢复上层方法的局部变量表以及操作数栈,如果有返回值,则把返回值压入到调用者栈帧的操作数栈中,还会把PC计数器的值调整为方法调用入口的下一条指令。

        3)操作数栈

              JVM的解释执行引擎被称为“基于栈的执行引擎”,这里的栈就是指操作数栈,操作数栈其实可以理解为JVM线程的工作区。操作数栈和局部变量数组一样也是一个数组,但是它不是通过索引来访问的,而是通过标准的栈操作(压栈和出栈)来访问的,另外存储数据的方式和局部变量数组也是一样的。大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。以下示例演示了JVM如何把两个int类型的局部变量相加,再把结果保存到第三个局部变量的:

begin  
iload_0    // push the int in local variable 0 onto the stack  
iload_1    // push the int in local variable 1 onto the stack  
iadd       // pop two ints, add them, push result  
istore_2   // pop int, store into local variable 2  
end 
              在这个字节码序列里,前两个指令iload_0和iload_1将存储在局部变量中索引为0和1的整数压入操作数栈中,其后iadd指令从操作数栈中弹出那两个整数相加,再将结果压入操作数栈。第四条指令istore_2则从操作数栈中弹出结果,并把它存储到局部变量区索引为2的位置。下图详细表述了这个过程中局部变量和操作数栈的状态变化,图中没有使用的局部变量区和操作数栈区域以空白表示

         图文详解jvm中的内存与线程模型

        4)动态链接

             先看看方法的大概调用过程,首先在虚拟机运行的时候,运行时常量池会保存大量的符号引用,这些符号引用可以看成是每个方法的间接引用,如果代表栈帧A的方法想调用代表栈帧B的方法,那么这个虚拟机的方法调用指令就会以B方法的符号引用作为参数,但是因为符号引用并不是直接指向代表B方法的内存位置,所以在调用之前还必须要将符号引用转换为直接引用,然后通过直接引用才可以访问到真正的方法,这时候就有一点需要注意,如果符号引用是在类加载阶段或者第一次使用的时候转换为直接引用,那么这种转换称为静态解析,如果是在运行期间转换为直接引用,那么这种转换称为动态链接  

                                               图文详解jvm中的内存与线程模型

        栈的大小可以是动态分配的,也可以是固定的。当线程申请的栈深度超过当前线程所允许的最大深度时,就会抛出*Error,当线程需要一个新的栈帧,但是此时没有足够的内存可以分配,就会抛出一个OutOfMemoryError。  

                                                                          

   本地方法栈

   简单的讲,一个本地方法是这样的一个方法:该方法的实现由非java语言实现,比如C语言实现。很多其它的编程语言都有这一机制,比如在C++中,你可以告知C++编译器去调用一个C语言编写的方法。我们知道java是高级编程语言,当对一些底层的如操作系统或某些硬件交换信息时,我们使用java来编程实现起来不容易,再者使用java来编程效率也很地下。这就不得不需要调用本地方法来解决这一问题。在图1中为本地方法栈区域,在图2 中为Thread的Native Stack区域

        本地方法栈为线程私有,功能和上面的栈非常相似。线程在调用本地方法时,来存储本地方法的局部变量表,本地方法的操作数栈等等信息。


   程序计数器

   程序计数器是一块较小的内存空间,可以把它看做当前线程正在执行的字节码的行号指示器。也就是说,程序计数器里面记录的是当前线程正在执行的那一条字节码指令的地址。若当前线程正在执行的是一个本地方法,则此时程序计数器为空。在图1中为程序计数器区域,在图2 中为Thread的Program Counter区域

       程序计数器有两个作用:1.字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。2.在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

        程序计数器特点:1.是一块较小的存储空间  2.线程私有,每个线程都有一个程序计数器   3.是唯一一个不会出现OutOfMemoryError的内存区域   4.生命周期随着线程的创建而创建,随着线程的结束而死亡。


   堆

   java中的堆是JVM所管理的最大的一块内存空间,主要用于存放各种类的实例对象与数组。不能在栈上存储数组和对象。因为栈帧被设计为创建以后无法调整大小。栈帧只存储指向堆中对象或数组的引用。与局部变量数组(每个栈帧中的)中的原始类型和引用类型不同,对象总是存储在堆上以便在方法结束时不会被移除。对象只能被垃圾回收器回收。在图1中为堆区域,在图2 中为Heap区域。

        在java中,堆被划分成两个不同的区域:新生代(Young)、老年代(Old)。新生代(Young)又被划分为三个区域:Eden、From Survivor、To Survivor。这样划分的目的是为了使JVM能够更好的管理堆内存中的对象,包括内存的分配以及回收。堆的内存模型如下图所示:  

                                 图文详解jvm中的内存与线程模型

                                                          新生代(1/3堆空间)                                               老年代(2/3堆空间)


   非堆

   非堆内存指的是那些逻辑上属于JVM一部分对象,但实际上不在堆上创建。非堆内存(对应图2中的Non Heap,在图1中只显示了永久代中的方法区)包括永久代和代码缓存。所有线程共享同一个方法区,因此访问方法区数据和动态链接的进程必须线程安全。如果两个线程试图访问一个还未加载的类的字段或方法,必须只加载一次,而且两个线程必须等它加载完毕才能继续执行。

        1)永久代

              永久代包括方法区和驻留字符串(interned strings):

              方法区存储了每个类的信息,如:

    • Classloader 引用
    • 运行时常量池
      • 数值型常量
      • 字段引用
      • 方法引用
      • 属性
    • 字段数据
      • 针对每个字段的信息
        • 字段名
        • 类型
        • 修饰符
        • 属性(Attribute)
    • 方法数据
      • 每个方法
        • 方法名
        • 返回值类型
        • 参数类型(按顺序)
        • 修饰符
        • 属性
    • 方法代码
      • 每个方法
        • 字节码
        • 操作数栈大小
        • 局部变量大小
        • 局部变量表
        • 异常表
        • 每个异常处理器
        • 开始点
        • 结束点
        • 异常处理代码的程序计数器(PC)偏移量
        • 被捕获的异常类对应的常量池下标

              驻留字符串也称字符串常量池,即字符串的字面量在该区域中。对于java中的字符串直接量,JVM会使用一个字符串驻留池来缓存它们。一般情况下,字符串驻留池中的字符串对象不会被GC(Garbage Collection,垃圾回收)所回收,当再次使用字符串驻留池中已有的字符串对象时候,无需再次创建它,而直接使用它的引用变量指向字符串驻留池中已有的字符串对象。

        2)代码缓存(Code Cache)

              用于编译和存储那些被JIT编译器编译成原生代码的方法。由于java字节码是解释执行的,但是没有直接在JVM宿主执行原生代码快。为了提高性能,Oracle Hotspot虚拟机会找到执行最频繁的字节码片段并把它们编译成原生机器码。编译出的原生机器码被存储在非堆内存的代码缓存中。通过这种方法,Hotspot虚拟机将权衡下面两种时间消耗:将字节码编译成本地代码需要的额外时间和解释执行字节码消耗更多的时间。

代码示例详解JVM内存存储位置
public class Student {

	public static final int NUM = 5;

	public String name;

	public int 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;
	}

	public void study(Student s) {
		System.out.println(s.getName() + " is studying");
	}

	public static void eat() {
		System.out.println("eat");
	}

	public static void main(String[] args) {

		Student s = new Student();
		int age = 11;
		String name = "zhangsan";
		s.setName(name);
		s.setAge(age);
		s.study(s);
		Student.eat();
	}
}

              以一段简短的代码为例说明不同代码在JVM内存中的位置:

              1)栈中:栈中主要存储局部变量与方法返回值,本例中包括以下内容

                              getName()方法中的name返回值

                              setName()方法中的方法参数name与引用this

                              getAge()方法中的age返回值

                              setAge()方法中的方法参数age与引用this

                              study()方法中的方法参数s

                               main()方法中的方法参数args、变量age、变量name、变量s。

              2)堆中:堆中主要存储对象实例与数组,本例中就只有main()方法中的Student对象

              3)驻留字符串:main()方法中的字符串字面量“张三”

              4)方法区:方法区中包含了Classloader引用、运行时常量池、字段数据、方法数据、方法代码等,本例中的其他内容均存储在方法区中