JVM内存结构之程序计数器、虚拟机栈

1,118 阅读18分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

内存结构

  1. 程序计数器
  2. 虚拟机栈
  3. 本地方法栈
  4. 方法区

1. 程序计数器

在这里插入图片描述

从上面这张图可以看出程序计数器在JVM里的位置,可以看到它属于JVM内存结构的组成部分。

1.1 定义

Program Counter Register 程序计数器(寄存器)

其中Program是程序的意思,Counter是计数器,最后Register没有直接翻译,它的含义是寄存器的意思,稍后我们会提到它。
  • 作用:记住下一条jvm指令的执行地址

  • 特点:

    • 是线程私有的

      java程序是支持多线程一起运行的,多个线程一起运行的时候cpu会有一个调动器组件给它们分配时间片,比如说会给线程1分给一个
      时间片,它在时间片内如果它的代码没有执行完,它就会把线程1的状态执行一个暂存,切换到线程2去,执行线程2的代码,等线程2的
      代码执行到了一定程度,线程2的时间片用完了,再切换回来,再继续执行线程1剩余部分的代码。
      我们考虑一下,如果在线程切换的过程中,下一条指令执行到哪里了,是不是还是会用到我们的程序计数器啊。
      每个线程都有自己的程序计数器,因为它们各自执行的代码的指令地址是不一样的呀,所以每个线程都应该有自己的程序计数器。
      
    • 不会存在内存溢出

      程序计数器是在java虚拟机规范中唯一一个不会存在内存溢出的区, 其它的一些区,像堆、栈、方法区它们都会出现内存溢出。而我们的
      java虚拟机规范中就规定了程序计数器部分没有内存溢出,所以它的各个厂商对java虚拟机实现的时候也不用去考虑程序计数器部分的
      它的内存溢出问题。
      

1.2 作用

在这里插入图片描述

右边这段代码就是将System.out赋值给了一个变量,然后通过这个变量去调用System.out的println方法打印1、打印2、打印3······,就是一些很简单的java代码。

java源代码不能直接去执行,它得经过一次编译,编译成我们左侧的二进制字节码,二进制字节码左边的是JVM指令,java虚拟机跨平台的基础就是这套jvm指令,它对所有平台都是一致的。那这些指令能直接交给cpu来执行吗?还不行,这些指令还是不能交给cpu来执行,它必须经过一个解释器。这个解释器也是java虚拟机执行引擎的一个组件,它就专门负责把我们的每一条虚拟机指令(比如说:getstatic)解释为机器码,然后把机器码交给cpu,就可以让cpu来执行它了,cpu只认得机器码。

执行流程:

在这里插入图片描述

程序计数器作用:记住下一条jvm指令的执行地址

执行流程加上程序计数器就是这个样子:拿到第一条getstatic指令,交给了解释器,解释器把它变成了机器码,再交给cpu来运行,但是与此同时它就会把下一条指令,也就是astore_1,把下一条指令的地址,也就是3放入我们的程序计数器,等第一条指令执行完了以后,这时候解释器就会到程序计数器里去取到下一条指令(根据地址3找到下一条指令astore_1),再重复刚才的这个过程。等3这条指令执行的同时,它就会把下一条指令的地址(4)存入程序计数器,等3这条指令执行完了之后,它就会到程序计数器里再取下一条指令(也就是4),再重复这个过程。

总之呢,程序计数器的作用就是记住下一条jvm指令的执行地址(如果没有这个程序计数器,都不知道接下来该执行哪条jvm指令了)。在物理上实现一个程序计数器是通过寄存器来实现的,程序计数器是java对物理硬件的一些屏蔽和抽象,在物理上是通过寄存器来实现的,寄存器可以说是cpu组件里读取速度最快的一个单元,因为读取指令地址这个动作是非常频繁的,所以java虚拟机在设计的时候就把我们cpu中的寄存器当做了程序计数器,用它来存储地址,将来去读取这个地址。

2. 虚拟机栈

在这里插入图片描述

我们知道栈的特点是先进后出。

那么我们java中的虚拟机栈它到底是干什么用的呢?java中每个线程运行的时候需要给每个线程划分一个内存空间。其实我们的虚拟机栈就是线程运行时需要的一个内存空间,一个线程运行的时候需要一个虚拟机栈,多个线程运行的时候就会有多个虚拟机栈。那每个栈内又是由什么组成的呢?一个栈内可以看成是由多个栈帧组成,那么栈帧又是什么呢?其实一个栈帧就对应着一次方法的调用,那大家想,我的线程它最终是要去执行代码的,那这些代码都是由一个个的方法来组成,那所以我们在线程运行的时候每个方法需要的内存我们就称之为一个栈帧。所谓的栈帧就是每个方法运行时需要的内存,大家思考一下,方法运行时需要什么内存呢?方法内有参数、局部变量、返回地址,这些信息都是需要占用内存的,所以每个方法执行时我们就需要预先把这些内存分配好,

那么栈帧和栈是怎么联系起来的呢?

比如说调用第一个方法时,它就会给第一个方法划分一段栈帧空间,并且把它压入栈内,当这个方法执行完了,它就会把这个方法对应的栈帧让它出栈,也就是释放这个方法所占用的内存,这就是栈和栈帧之间的关系。

在这里插入图片描述

那有没有可能一个栈内有多个栈帧存在呢?

答案是有的,比如说我调用了方法1,方法1又间接调用了方法2,就会为方法2产生一个新的栈帧,方法2又调用了方法3,就会为方法3产生一个新的栈帧,方法调用的话总会有一个结束的时间,等方法3的调用结束,它就会把栈帧3的内存释放掉,返回到方法2,方法2调用结束后,它就会把方法2占用的内存释放掉,最后方法1执行完毕,会把方法1占用的栈帧内存释放掉,也是出栈。

在这里插入图片描述

一个栈(虚拟机栈)由多个栈帧组成。

2.1 定义

Java Virtual Machine Stacks (Java虚拟机栈)

  • 每个线程运行所需要的内存,称为虚拟机栈。

  • 每个虚拟机栈由多个栈帧(Frame)组成,一个栈帧就对应着一次方法调用时所占用的内存。

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

    活动栈帧代表线程正在执行的那个方法对应的栈帧就称之为活动栈帧。
    

下面通过代码来清晰地看到栈、栈帧、活动栈帧这些概念。

/**
 * 演示栈帧
 */
public class Demo01 {
    public static void main(String[] args) throws InterruptedException{
        method01();
    }

    private static void method01(){
        method02(1, 2);
    }

    private static int method02(int a, int b){
        int c = a + b;
        return c;
    }
}

下面我们来描述一下流程,我们使用debug的形式来执行:

首先我们执行主方法,主方法也是一个方法,它也对应着一个栈帧,会被放入栈内

在这里插入图片描述

之后再往下执行主方法就会调用method01()方法,method01()也对应着一个栈帧,程序会把method01()对应的栈帧压入虚拟机栈中,我们可以看到它放到了main方法的上方,这里也可以看出就是栈结构

在这里插入图片描述

再往下走method01()方法就会调用method02()方法,程序会给method2()分配一块栈帧,将methodo2()对应的栈帧入栈,可以看到method02()方法对应的栈帧放到了虚拟机栈的最顶部

在这里插入图片描述

当method02()执行完后method02()所占用的内存会随着它所对应的栈帧出栈而被释放掉

在这里插入图片描述

之后再往下执行method01()对应的栈帧会出栈,就会回到主方法了

在这里插入图片描述

主方法再执行整个程序就结束了。

图上的Frames就可以对应成我们java的虚拟机栈,虚拟机栈由多个栈帧组成,主方法调用,主方法也是一个方法,它就对应一段栈帧内存,
会被放入栈内,再往下走它接下来要调用method01(),method01()需要一些自己的内存空间,所以我们对method01()又分配了一块
栈帧内存,并且把这个新的栈帧压入到栈内,可以看到它放到了main方法的上方,这里其实就是栈结构,method01()又调用了method02(),
等调用method02()的时候给method02()又分配了一块栈帧,让method02()的栈帧入栈,可以看到method02()的栈帧放到了栈的最顶部,
可以看到method02()的参数以及内部的局部变量占用了栈帧的空间,那接下来往下走会发生什么呢?再往下走方法2就执行结束了,执行结束了
方法2所占用的内存会随着栈帧的出栈被释放掉,可以看到方法2所占用的栈帧已经出栈了,占用的那些局部变量、参数这些内存地址都被释放掉了,
同理再往下走,method01()调用结束出栈它就回到主方法了,主方法再执行整个程序就结束了。
注:在栈顶部的正在执行的那个方法就称之为活动栈帧。

问题辨析

  1. 垃圾回收是否涉及栈内存?

    不需要,为什么呢?因为我们的栈内存无非就是一次次的方法调用所产生的栈帧内存,而栈帧内存呢在每一次方法调用结束后都会被弹出栈,
    也就是会自动地被回收掉,所以根本就不需要垃圾回收来管理我们的栈内存。垃圾回收只是去回收堆内存中的无用对象,而栈内存呢它不会
    也不需要对它进行垃圾回收的处理。
    
  2. 栈内存分配越大越好吗?

    栈内存可以通过运行代码时通过一个虚拟机参数来指定 。

在这里插入图片描述

栈内存划的越大程序跑得越快吗?
答案不是这样,栈内存划的越大反而会让线程数变少,因为我们物理内存的大小是一定的,比如说一个线程它使用的是栈内存,一个线程使用了
1M内存,总共的物理内存假设有500M,那理论上可以有500个线程同时运行,但是如果给每个线程的栈内存设置了2M的内存,那么理论上最多
只能同时运行250个线程,所以栈内存并不是划分地越大越好,它划分地大了,通常只是能够进行更多次的方法递归调用,而不会增强运行的
效率,反而会影响到线程数目的变少,所以不建议大家设置过大的栈内存,一般采用系统默认的栈内存大小就可以了。
  1. 方法内的局部变量是否线程安全?
  - 如果方法内部局部变量没有逃离方法的作用范围,它是线程安全的,反之局部变量(引用类型变量)当成了返回值返回了,它就会存在线程安全的风险,必须对它施加保护。如果只是一个基本类型局部变量,也可以保证它是线程安全的。
  - 如果是局部变量引用了对象(这里的意思是说这个局部变量是引用类型),并逃离方法作用范围,需要考虑线程安全问题。

  ```markdown
  看一个变量是不是线程安全其实我们就要看它到底是多个线程对这个变量是共享的还是这个变量对每个线程是私有的,是共享的就需要考虑
  线程安全,是每个线程私有的就不需要考虑线程安全
  ```

  
  /**
   * 局部变量的线程安全问题
   */
  public class Demo02 {
  
      // 多个线程同时执行此方法
      static void method01(){
          int x = 0;
          for(int i = 0; i < 5000; i++){
              x ++;
          }
          System.out.println(x);
      }
  }
 

  因为x是每个线程私有的,每个线程都有自己私有的的x,所以不需要考虑线程安全问题。

`` 在这里插入图片描述

  但是如果我们把x设置为static属性,那x就属于多个线程共享的了,这个时候就需要考虑线程安全问题了。

` 在这里插入图片描述

接下来我们再来看下一个例子:

   /**
    * 局部变量的线程安全问题
    */
   public class Demo03 {
       public static void main(String[] args) {
           StringBuilder sb = new StringBuilder();
           sb.append(4);
           sb.append(5);
           sb.append(6);
           new Thread(() -> {
               m2(sb);
           }).start();
       }
   
       /**
        * 对于m1方法来说,如果多个线程同时执行m1()方法,是不会有线程安全问题的
        * 因为内部的StringBuilder对象sb是线程私有的对象,其他线程不可能同时
        * 访问到StringBuilder对象,所以这个方法是线程安全的
        */
       public static void m1(){
           StringBuilder sb = new StringBuilder();
           sb.append(1);
           sb.append(2);
           sb.append(3);
           System.out.println(sb.toString());
       }
   
       /**
        * m2()方法不是线程私有的,因为StringBuilder对象可能被多个线程共享
        * 所以这个方法不是线程共享的,可以改成StringBuffer就是线程安全的了
        */
       public static void m2(StringBuilder sb){
           sb.append(1);
           sb.append(2);
           sb.append(3);
           System.out.println(sb.toString());
       }
   
       /**
        * m3()方法不是线程安全的,虽然StringBuilder对象是方法内的局部变量,但是方法把它当成
        * 返回结果返回了,返回了就意味着其他线程有可能拿到这个线程的引用,去并发地修改它,也会造成线程安全的问题
        */
       public static StringBuilder m3(){
           StringBuilder sb = new StringBuilder();
           sb.append(1);
           sb.append(2);
           sb.append(3);
           System.out.println(sb.toString());
           return sb;
       }
   }

从上面对三个方法的线程安全问题的分析中我们可以得出结论:要判断一个变量是不是线程安全的,不仅要看它是不是方法内的局部变量,还要看它是否逃离了方法的作用范围,如果这个变量作为返回值逃离了方法的作用范围,那它就有可能被别的线程访问到了,就不再是线程安全的了。

2.2 栈内存溢出

  • 栈帧过多导致栈内存溢出

    栈的大小是固定的,调用方法1之后栈帧1入栈,在方法1还没调用完就调用了方法2,之后方法2还没有调用完就又调用了方法3,这样不断的调
    用,一直入栈但是没有出栈,直到某一次调用导致栈帧的内存超过了整个栈的内存,放不下了,无法分配新的栈帧内存了,这就会导致栈内存
    溢出。
    可以思考一下什么情况栈帧会这么多呢?
    其实有一种情况,就是方法的递归调用,如果在方法递归调用里没有设置一个正确的结束条件,那么就会导致自己调用自己,自己再调用自己
    ······,这样不断调用,每次调用都会产生一个栈帧,那即使栈内存再大,也终有会用完的一天,所以就会导致栈内存溢出这个错误,
    

在这里插入图片描述

  • 栈帧过大导致栈内存溢出

    栈帧过大导致栈内存溢出的问题不太容易出现,因为一个方法内部的int类型的变量才4个字节,栈内存一般为1M,所以这种情况几乎不太可能
    出现,一般都是由于栈帧过多导致栈内存溢出。
    

在这里插入图片描述

下面结合两个具体的案例来看一下栈内存溢出的几个场景:

案例一:

/**
 * 演示栈内存溢出  	java.lang.StackOverflowError
 * -Xss256k
 *
 * 演示栈帧过多导致栈内存溢出
 * method1()方法自己调用自己,但是没有设置递归终止条件,这样每调用
 * 一次都会产生一个新的栈帧,肯定会把栈内存耗尽。
 */
public class Demo04 {
    private static int count;

    public static void main(String[] args) {
        try {
            method1();
        } catch (Throwable e) {
            e.printStackTrace();
            System.out.println(count);
        }
    }

    private static void method1(){
        count++;
        method1();
    }
}
/*
下面这个结果使用的是默认的栈内存
*/
结果:
java.lang.StackOverflowError    // 栈内存溢出,是个Error(错误)
at Memory.JVMstacks.Demo04.method1(Demo04.java:25)
at Memory.JVMstacks.Demo04.method1(Demo04.java:25)
at Memory.JVMstacks.Demo04.method1(Demo04.java:25)
······
23252  						 	// 说明调用了23252次导致了栈内存溢出

我们可以使用-Xss256k这个虚拟机参数来设置栈内存大小,我们可以把这个栈内存设置的小一些,那么看看是不是它的方法调用的递归次数也会减小,那么怎么设置呢?

在idea里打开程序的运行设置

在这里插入图片描述

如果使用的idea是最先版的话,注意之后可能没有出现VM options这个参数,我们要点击这里

在这里插入图片描述

之后勾上Add VM options,就会出现设置栈内存的那一行了。

在这里插入图片描述

之后设置栈内存为256k

在这里插入图片描述

之后重新运行代码,就会发现依然会导致栈内存溢出,但是这回只循环了3千多次就会导致栈内存溢出,因为我们设置栈的总大小变小了。

java.lang.StackOverflowError
at Memory.JVMstacks.Demo04.method1(Demo04.java:25)
at Memory.JVMstacks.Demo04.method1(Demo04.java:25) 
······
3863

案例二:

import java.util.Arrays;
import java.util.List;

/**
 * json数据转换
 */
public class Demo05 {
    public static void main(String[] args) {
        Dept d = new Dept();
        d.setName("Market");

        Emp e1 = new Emp();
        e1.setName("zhang");
        e1.setDept(d);

        Emp e2 = new Emp();
        e2.setName("li");
        e2.setDept(d);

        d.setEmps(Arrays.asList(e1, e2));

        // 转化为json对象
        // { name: 'Market', emps: [{ name: 'zhang', dept: { name:'', emps: [{}] } }] }  
        // 部门里面有员工,员工里面有部门,无限循环下去了
        ObjectMapper mapper = new ObjectMapper();
        System.out.println(mapper.writeValueAsString(d));


    }





}
class Emp{
    private String name;
    private Dept dept;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Dept getDept() {
        return dept;
    }

    public void setDept(Dept dept) {
        this.dept = dept;
    }
}

class Dept{
    private String name;
    private List<Emp> emps;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Emp> getEmps() {
        return emps;
    }

    public void setEmps(List<Emp> emps) {
        this.emps = emps;
    }
}

结果

在这里插入图片描述

在这里插入图片描述

有时候并不是你写的代码会导致栈溢出发生,这个场景就是由于两个类之间的循环引用问题导致json解析时会出现栈溢出,那怎么解决呢?
一定要在json转换时打破这种循环引用,比如说在一方把它中断,可以通过加上@JsonIgnore注解,把双向管理改成了单向管理,只通过
部门去管理员工,员工这边就不再管理部门了,

在这里插入图片描述

修改完代码之后我们再运行一下

在这里插入图片描述

2.3 线程运行诊断

线程是和虚拟机栈息息相关的,这里准备了几个和线程诊断相关的案例,通过这些案例我们要学习一些有用的工具。

案例1:cpu占用过多

定位

  • 用top命令定位哪个进程对cpu的占用过高
  • ps -H -eo pid,tid,%cpu | grep 进程id (用ps命令进一步定位是哪个线程引起的cpu占用过高)
  • jstack 进程id(jdk提供的工具)
    • 可以根据线程id找到有问题的线程,进一步定位到问题代码的源码行号
有一个应用程序在运行时它的cpu占用居高不下,就导致其他程序运行受到影响,这是一个很危险的信号,如果某个程序cpu占用高达%90以上,
那肯定是程序中某些代码出现问题了,那我们怎么诊断和排查这些问题呢?那我们就来看一下。

在Linux虚拟机上运行一个java程序, 使用top命令可以监测到后台进程对cpu的使用、对内存的占用情况

在这里插入图片描述

可以看到有一个java代码占了cpu时间的%97以上,其他程序都被挤没了,就它一个人在不断地使用cpu在跑,

在这里插入图片描述

ps H -eo pid,tid,%cpu

ps命令可以查看线程对cpu的占用情况

H 是把进程里所有的线程信息展示出来

-eo参数规定输出哪些感兴趣的内容

pid 跟在-eo后面,表示输出进程id

tid表示输出线程id

%cpu查看对cpu的占用情况

这样的话我们就能看到所有线程的进程id,线程id,对CPU的占用情况这三项指标了

在这里插入图片描述

线程数如果太多,因为已知哪个进程导致cpu占用过高了

可以使用:

ps H -eo pid,tid,%cpu | grep 32655

这个32655是我们定位到的占用cpu过高的进程的id

在这里插入图片描述

使用 jstack + 进程id 命令(jdk提供的工具)

jstack 32655

展示32655进程的所有线程

在这里插入图片描述

进程32655这么多线程,怎么排查哪个线程有问题呢?

刚才我们用ps命令已经定位到了32665的线程是有问题的

用jstack输出的线程编号是16进制的

10进制的32665转换为16进制为7F99

在这里插入图片描述

打开java代码,找到第8行

在这里插入图片描述

案例2:程序运行很长时间没有结果

运行java程序本应该出现结果结果没有出现结果,可能是发现了死锁

在这里插入图片描述

使用 jstack + 进程id

也就是 jstack 32752

在这里插入图片描述

我们看一下源代码

在这里插入图片描述