JVM程序计数器与虚拟机栈

2022年8月30日13:15:25

jvm内存结构,如下图:

java虚拟机(jvm)在java程序运行的时候,会将它所管理的内存划分为若干个不同的数据区域,这些数据区域有的随着jvm的启动而创建,有的随着用户线程的启动和结束而建立和销毁。

一、程序计数器(Program counter register)

根据上面的内存结构图,我们来了解以下什么是程序计数器

1、什么是程序计数器(program counter register) ?

程序计数器是用来记住下一条jvm指令的执行地址和行号的。

当执行一条指令时,首先需要根据PC中存放的指令地址,将指令由内存中读取到指令寄存器中,此过程称之为"取指令"。

同时,PC中的地址给出下一条指令的地址。此后,经过分析指令,执行指令,完成第一条指令的执行,而后根据PC取出第二条指令的地址,如此循环,执行每一条指令。

2、程序计数器的特点

1)、线程私有,即每个线程都有自己的程序计数器

cpu会为每个线程分配时间片,当当前线程的时间片使用完后,cpu就会去执行另一个线程中的代码。

程序计数器是每个线程所私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一句指令。

2)、不会存在内存溢出,它是唯一的一个在java虚拟机规范中没有任何OutOfMemoryError的区域。

3)、线程隔离性,每个线程工作时都有属于自己的独立计数器

4)、程序计数器占用内存很小,在进行JVM内存计算时,可以忽略不计。

5)、执行native本地方法时,程序计数器的值为空(Undefined)

因为native方法是java通过JNI直接调用本地C/C++库,可以近似的认为native方法相当于C/C++暴露给java的一个接口,java通过调用这个接口从而调用到C/C++方法,由于该方法是通过C/C++而不是java进行实现的,那么自然无法产生相应的字节码,并且C/C++执行时的内存分配是由自己语言决定的,而不是由JVM决定的。

3、jvm的指令也称之为二进制字节码指令

java虚拟机的指令由一个字节长度的,代表某种特定操作含义的操作码(opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(operand)所构成。

虚拟机中许多指令并不包含操作数,只有一个操作码。格式如下:

opcode (1 byte)  operand1 (optional)  operand2 (optional)

在当前执行方法的栈帧里,一条指令可以将值在操作栈中入栈或出栈,可以在本地变量数组中悄悄的加载或者存储值。

下面我们来看一段java代码,如下:

import java.io.PrintStream;

public class ProcessCounter {
    public static void main(String[] args) {
        PrintStream out=System.out;
        out.println(1);
        out.println(2);
        out.println(3);
        out.println(4);
        out.println(5);
    }
}

比如,该java类在F盘test文件夹下,我们通过javac ProcessCounter.java编译生成ProcessCounter.class二进制文件

cmd窗口操作,进入F盘下的test文件夹,进入ProcessCounter.class类所在文件目录

执行如下命令:

javap -c ProcessCounter.class

得到如下结果

上面是二进制字节码,二进制字节码主要先交给解释器来进行解释成机器码,这样CPU才能看懂。

那么问题来了,解释器一次解释一句二进制字节码指令,那么解释器如何知道下一条二进制字节码指令是什么呢?

这时就需要程序计数器了,程序计数器记录着下一条指令的地址,例如此时解释器执行第一条字节码指令,那么解释器中的地址码是0,而程序计数器中记录下一条指令的地址就是3,紧接着就是4,5,6.....

注:说白了,程序计数器就是记录下一条JVM指令的地址。

例如:现有线程1和线程2,假设线程1需要执行的代码量比较大,我们不可能让线程1全部执行完,再执行线程2中的代码,每个线程的执行都会有一个时间片,线程1的时间片用完,就会执行线程2,线程2执行完,接着反过来执行线程1,那么如何知道线程1上1次执行到哪里了,就需要通过程序计数器的记录告知下一条JVM指令的地址,然后根据该地址继续执行后续代码。

二、虚拟机栈(java virtual machine stacks)

1、相关概念

栈:就是线程运行所需要的内存空间

栈帧:每个栈由多个栈帧组成,每个栈帧对应每次调用方法时所占用的内存。

每个线程只能有一个活动栈帧,对应着当前正在执行的方法。

如下:

栈就好比是子弹夹,栈帧就好比是子弹,我们可以将子弹一颗一颗放进子弹夹,遵循:先进后出,后进先出。

如上面:我们依次将栈帧1、栈帧2、栈帧3放入到栈中,出的时候顺序是栈帧3、栈帧2、栈帧1.

一个栈帧对应一次方法的调用,线程是由一个一个方法组成,每个方法运行时需要的内存就是栈帧。

方法运行时需要内存做什么?方法里面的参数、局部变量、返回地址都是需要占用内存存储的。

2、栈和栈帧的示例演练

package com.wzy.test;
/**
 * 栈和栈帧演练
 * */
public class StackTest {
    public static void main(String[] args) {
        method1();
    }

    public static void method1(){
        method2(1,2);
    }

    public static int method2(int a,int b){
        int c=a+b;
        return c;
    }
}

我们在打上三个断点,进入debug调试一下,如下图:

逐步调试如下:

Frames下显示三个栈帧,分别为main、method1、method2。这三个方法的执行符合栈的特点,先执行的先入栈。

3、栈问题引入分析?

问题1、垃圾回收是否需要栈内存?

不需要,因为虚拟机栈是由一个个栈帧组成的, 在方法执行完毕后,对应的栈帧就会被弹出栈。所以不需要垃圾回收机制去回收栈内存。

问题2、栈内存分配得越大越好吗?

不是,因为物理内存都是固定的,如果栈内存设置得越大,可以支持更多的递归调用,但是可执行的线程数就会越少。

如物理内存为500M,栈内存为1M,那么就可以执行500个线程,如果栈内存为2M,那么就只能执行250个线程。

问题3、方法内的局部变量是否是线程安全的?

1)、如果方法内的局部变量没有逃离方法的作用范围,则线程是安全的。

2)、如果局部变量引用了对象,并逃离了方法的作用范围,则需要考虑线程安全的问题。

4、栈内存溢出

java.lang.stackOverflowError就是栈内存溢出

导致栈内存溢出的原因:

1)、虚拟机栈中,栈帧过多(无限递归)

2)、每个栈帧所占用的内存过大

栈帧过多测试,如下:

package com.wzy.test;
/**
 * 栈内存溢出测试
 * */
public class StackTest {
    public static void main(String[] args) {
        method1();
    }

    public static void method1(){
        method1();
    }
}

上面声明一个方法method1,递归调用method1方法。改造上面的代码,我们声明一个count计数器,在程序执行捕获异常时,看看递归调用多少次method1方法才会导致栈内存溢出

package com.wzy.test;
/**
 * 栈内存溢出测试
 * */
public class StackTest {
    private static int count;
    public static void main(String[] args) {
        try{
            method1();
        }catch(Throwable e){
            e.printStackTrace();
            System.out.println(count);
        }

    }

    public static void method1(){
        count++;
        method1();
    }
}

执行结果,如下:

即递归调用method1方法17380次,导致栈内存溢出。

我们可以修改栈内存,设置VM options中参数为-Xss256k,再次测试

再次执行

递归调用3555次方法,才出现栈内存溢出

  • 作者:猿累人生
  • 原文链接:https://blog.csdn.net/sinat_23490433/article/details/116271577
    更新时间:2022年8月30日13:15:25 ,共 3376 字。