jvm(二)--java内存区域之线程独占区

前言

  本笔记作为jvm学习系列的第二篇,接着上一篇来讲java内存区域中的线程独占区的内容,包括虚拟机栈、本地方法栈和程序计数器。

线程独占区

  顾名思义,线程独占区就是这块内存区域在运行过程中,是每个线程独有的内存空间。在这块内存空间中,就有我们平常所认知的 ,专业的术语叫 虚拟机栈,因为还有另外一个区域叫 本地方法栈,然后除了前面说的这两个栈外,还有一个 程序计数器 。线程独占区大概分为这三块内容。

虚拟机栈

2-1虚拟机栈

  虚拟机栈 ,是本质是 。栈,这种数据结构实际上一个一端开口,先进后出的容器。而在虚拟机栈中,先进后出的元素为 ,所以也可以叫 栈帧 。也即, 虚拟机栈就是为栈帧元素服务的容器 。那么,何为栈帧呢?

栈帧

2-2栈帧

  栈帧(stack frame) 是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。

   每一个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。 对于执行引擎来说,活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。

   在 java 程序被编译成 class 文件时,栈帧的大小就已经确定,在 jvm 运行过程时栈帧的大小是不会改变的

  栈帧存储了方法的 局部变量表操作数栈动态连接方法返回地址 等信息。

局部变量表

2-3局部变量表

  我们平常所说的 new 了一个对象,就是在堆中开辟了一段空间,然后在栈中 添加了一个引用 指向了这段堆空间,而这里 添加的一个引用 就是在 局部变量表

  局部变量表 ,是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,而且非 static 方法的 this 指针也会 store 进局部变量表。

   系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值)。也就是说不存在类变量那样的准备阶段

  在 java 程序被编译成 class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了该方法所需要分配的最大局部变量表的容量。

  局部变量表的容量以变量槽(Slot)为最小单位,32 位虚拟机中一个 Slot 可以存放一个 32 位以内的数据类型(boolean、byte、char、short、int、float、reference 和 returnAddress 八种)。

   reference 类型虚拟机规范没有明确说明它的长度,但一般来说,虚拟机实现至少都应当能从此引用中直接或者间接地查找到对象在 java 堆中的起始地址索引和方法区中的对象类型数据。
   returnAddress 类型是为字节码指令 jsr 、 jsr_w 和 ret 服务的,它指向了一条字节码指令的地址。

   对于 64 位的数据类型,虚拟机会以高位在前的方式为其分配两个连续的 Slot 空间。 java 语言中明确规定的 64 位的数据类型只有 long 和 double 。不过,由于局部变量表建在线程的堆栈上,是线程私有的数据,无论读写两个连续的 Slot 是否是原子操作,都不会引起数据安全问题。

  虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始到局部变量表最大的 Slot 数量。如果 32 位数据类型的变量,索引 N 就代表了使用第 N 个 Slot,如果是 64 位数据类型(long、double)的变量,则说明要使用第 N 个和 N+1 两个 Slot。

   虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果是实例方法(非static),那么局部变量表的第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用,在方法中通过this访问。

  为了尽可能节省栈帧空间,局部变量表中的 Slot 是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那这个变量对应的 Slot 就可以交给其他变量使用。

  就是说,局部变量表 Slot 槽位的是可以复用的,当一个变量已经超过其作用域,即后续代码已经不会再对其使用了,那么 jvm 会复用该变量的 Slot 槽位给后续代码的变量使用。那么这里就会带来另外一个问题,当变量超出了其作用范围,jvm 还保留着其 Slot 槽位,在 Slot 槽位还没被复用的时候该变量实际上就还没被回收,如下代码

slot槽位case1
public class TestStackLocalVariableTable {
    private static int _M = 1024 * 1024;
    public static void main(String[] args) throws InterruptedException {
        byte[] array = new byte[60 * _M];
        System.gc();
    }
}

  即 new 了一个 60M 的字节数组,然后就调用 gc 垃圾回收,运行结果如下所示,在 Full GC 中最终也没有去回收这个变量。这里没有回收 array 所占的内存也可以理解,因为在执行 System.gc() 时,变量 array 还处在作用域之内 。

2-4局部变量表代码演示1

(ps1:控制台中的结果是怎么打印出来的呢?其实是在运行的时候在虚拟机上加入参数:-verbose:gc ,如下所示)

2-5idea配置控制台打印gc信息

(ps2:控制台中的gc信息要怎么看?如 [GC (System.gc()) 64102K->62224K(125952K), 0.0170834 secs]这个结果,表示系统进行了 gc 操作,然后操作的结果是从原来堆内存的使用量 64102K , gc 之后变成了62224K ,只回收了一点点,即其实实际上回收的是其他在运行过程的其他空间,而我们定义 60M 的字节数组并没有回收到,而上面的结果只是 jvm 进行 Minor GC 而已,Minor GC 是只回收堆内存的Eden区域内存,后面讲堆内存的时候会详细讲,这里可以认为 Minor GC 是轻量级的 GC 回收,速度快,而在 Minor GC 没有回收成功之后,jvm 进行了 Full GC,Full GC是扫描整个堆内存的 gcroot 去回收垃圾,后面也会细讲,这里可以认为 Full GC 是重量级的 GC 回收,速度慢,回收的时长可能是 Minor GC 的几倍或 10 倍不止,返回正题,就是从运行结果[Full GC (System.gc()) 62224K->62107K(125952K), 0.0230050 secs]中看,发生了 Full GC 之后,也没有回收 60M 字节数组这部分内存)

slot槽位case2

  我们修改下上面的这段代码,如下所示,其实就是将 byte[] array = new byte[60 * _M] 用代码块包起来而已

public class TestStackLocalVariableTable {
    private static int _M = 1024 * 1024;
    public static void main(String[] args) throws InterruptedException {
        {
            byte[] array = new byte[60 * _M];
        }
        System.gc();
    }
}

  按照原来的理解,在超出代码块的之后, array 对象已经不会再使用了,应该调用 gc 之后回收了才对,但是从下面的结果中可以发现,array 对象还是没有被回收,因为 array 的Slot槽位会被复用, jvm 还保留着 array 在堆中的引用,那么 gc 就不会回收这一部分的内存。

1-5局部变量表代码演示2

slot槽位case3

  再将上面的代码再修改下,如下,

public class TestStackLocalVariableTable {
    private static int _M = 1024 * 1024;
    public static void main(String[] args) throws InterruptedException {
        {
            byte[] array = new byte[60 * _M];
        }
        int b = 0;
        System.gc();
    }
}

  实际上就是在第二段代码的基础上,加了 int b = 0 而已,但是从结果可知, array 对象被回收了,因为这个时候,操作了局部变量表,局部变量 b 复用了 array 的 Slot 槽位,Full GC 时发现 array 对象已经没有了引用,就把 array 回收了。

1-6局部变量表代码演示3

slot槽位case总结

  虽然现在知道了原理,但是实际上问题还没解决,在实际开发中,朝生夕死的对象的内存肯定是要回收的,也不可能在业务代码写完之后,去写一个无用的变量去操作局部变量表,那该怎么办呢?

   所以,当处理完业务流程之后,将需要回收的对象,最好手动赋值为 null,这样有助于 gc 的回收。

public class TestStackLocalVariableTable {
    private static int _M = 1024 * 1024;
    public static void main(String[] args) throws InterruptedException {
//        int a = 0;
        {
            byte[] array = new byte[60 * _M];
            array = null; //手动赋值对象为null,array对象没有了引用,GC会将这个对象回收
        }
//        a = 1; //读取了局部变量表,但是没有复用array的Slot槽位,jvm还保留着array的引用,此时GC不会回收array对象
//        int b = 0; //操作了局部变量表,复用了array的Slot槽位,array对象没有了引用,GC会将array对象回收
        System.gc();
    }
}
操作数栈
public class TestOperandStackExample1 {
    public static void main(String[] args) {
        int a = 1;
        int b = 2;
        int c = a + b;
    }
}

  下面 gif 图的左边 是由上面的 java 代码片段, 用 javap 反汇编内容,图的右边 是当这段 java 代码运行时,jvm的程序计数器虚拟机栈帧中的局部变量表 以及 操作数栈 的变化过程。

2-6操作数栈case1

  每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的 操作数栈 ,也可以称之为表达式栈(Expression Stack)。操作数栈和局部变量表在访问方式上存在着较大差异,操作数栈并非采用访问索引的方式来进行数据访问的,而是通过标准的入栈和出栈操作来完成一次数据访问。每一个操作数栈都会拥有一个明确的栈深度用于存储数值,一个 32bit 的数值可以用一个单位的栈深度来存储,而 2 个单位的栈深度则可以保存一个 64bit 的数值,当然 操作数栈所需的容量大小在编译期就可以被完全确定下来,并保存在方法的Code属性中。

   HotSpot 中任何的操作都需要经过入栈和出栈来完成,那么由此可见,HotSpot 的执行引擎架构必然就是 基于栈式架构 ,而非传统的寄存器架构。简单来说,操作数栈就是 jvm 执行引擎的一个工作区,当一个方法被调用的时候,一个新的栈帧也会随之被创建出来,但这个时候栈帧中的操作数栈却是空的,只有方法在执行的过程中,才会有各种各样的字节码指令往操作数栈中执行入栈和出栈操作。比如在一个方法内部需要执行一个简单的加法运算时,首先需要从操作数栈中将需要执行运算的两个数值出栈,待运算执行完成后,再将运算结果入栈。

动态连接

   每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。 在 Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。

返回地址

  当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址,以 恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令

本地方法栈

  本地方法,即为 native 方法,像 Object 的 getClass 方法、 hashCode 方法等都是 native 方法。这些方法不是用 java 实现的,本地方法本质上是依赖于实现的,虚拟机实现的设计者们可以自由地决定使用怎样的机制来让 java 程序调用本地方法。

  当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。本地方法可以通过本地方法接口来访问虚拟机的运行时数据区,但不止如此,它还可以做任何它想做的事情。

  任何本地方法接口都会使用某种本地方法栈。当线程调用 java 方法时,虚拟机会创建一个新的栈帧并压入 java 栈。然而当它调用的是本地方法时,虚拟机会保持 java 栈不变,不再在线程的 java 栈中压入新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。

  如果某个虚拟机实现的本地方法接口是使用 “C” 连接模型的话,那么它的本地方法栈就是 C栈 。当 C程序 调用一个 C函数 时,其栈操作都是确定的。传递给该函数的参数以某个确定的顺序压入栈,它的返回值也以确定的方式传回调用者。同样,这就是虚拟机实现中本地方法栈的行为。

  很可能本地方法接口需要回调 java 虚拟机中的 java 方法,在这种情况下,该线程会保存本地方法栈的状态并进入到另一个 java 栈。

  下图描绘了这样一个情景,就是当一个线程调用一个本地方法时,本地方法又回调虚拟机中的另一个 java 方法。这幅图展示了 java 虚拟机内部线程运行的全景图。一个线程可能在整个生命周期中都执行 java 方法,操作它的 java 栈;或者它可能毫无障碍地在 java 栈和本地方法栈之间跳转。

1-7一个java方法调用本地方法时的栈

  该线程首先调用了两个 java 方法,而第二个 java 方法又调用了一个本地方法,这样导致虚拟机使用了一个本地方法栈。假设这是一个 C 语言栈,其间有两个 C 函数,第一个 C 函数被第二个 java 方法当做本地方法调用,而这个 C 函数又调用了第二个 C 函数。之后第二个 C 函数又通过本地方法接口回调了一个 java 方法(第三个 java 方法),最终这个 java 方法又调用了一个 java 方法(它成为图中的当前方法)。

   本地方法栈与虚拟机栈其实很类似,只是虚拟机栈为虚拟机执行 java 方法服务,而本地方法栈为虚拟机执行本地方法服务。

程序计数器

  程序计数器是一块较小的内存空间,他的作用可以看做是当前线程所执行的字节码的 行号指示器 。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  由于 java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称 这类内存区域为“线程独占区”的内存(其实这点也很好理解,每个线程在执行字节码指令的时候,肯定是读取各自的指令的行号位置去执行,如果是线程共享,那指令不就全乱套了吗)

   如果线程正在执行一个 java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器为空(undefined)

   此内存区域是唯一一个在 java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。 (因为这部分空间是完全 jvm 自己管理的,与程序员无关,而且所占内存也很小)

1. 通俗解释:

  对于一个运行中的 java 程序而言,其中的每一个线程都有他自己的 PC (程序计数器)寄存器,他是在该线程启动时创建的, PC 寄存器的大小是一个字长,因此它既能够持有一个本地指针,也能持有一个returnAddress(returnAddress 类型会被 java 虚拟机的 jsr 、ret 和 jsr_w 指令所使用。returnAddress 类型的值只想一条虚拟机指令的操作码。与前面介绍的那些数值类的原生类型不同,returnAddress 类型在 java 语言之中并不存在相应的类型,也无法在程序运行期间更改 returnAddress 类型的值)。当线程执行某个 java 方法时 ,PC 寄存器的内容总是下一条被执行指令的”地址”,这里的”地址”可以是一个本地指针,也可以是在方法字节码中相对于该方法起始指令的偏移量。如果该线程正在执行一个本地方法,那么 PC 寄存器的值是 ”Undefined” 。

2.本地方法和 java 方法:

  java 中有两种方法:java方法本地方法 。java 方法是有 java 语言编写,编译成字节码,存储在 class 文件中的。本地方法是有其它语言(比如 C,C++,或者是会变语言)编写的,编译成和处理器相关的机器代码。本地方法保存在动态连接库中,格式是各个平台专用的。java 方法是与平台无关的,但是在本地方法却不是。运行中的 java 程序调用本地方法时,虚拟机装载包含这个本地方法的动态库,并调用这个方法。

  • 7
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值