プログラムカウンターとJVMメモリ構造の仮想マシンスタック

この記事は、「新人クリエーションセレモニー」イベントに参加し、一緒にゴールドクリエーションの道を歩み始めました。

メモリ構造

  1. プログラムカウンター
  2. 仮想マシンスタック
  3. ネイティブメソッドスタック
  4. ヒープ
  5. メソッドエリア

1.プログラムカウンター

ここに画像の説明を挿入

上の図から、JVM内のプログラムカウンターの場所を確認でき、それがJVMメモリ構造の一部であることがわかります。

1.1定義

プログラムカウンターレジスタプログラムカウンター(レジスタ)

其中Program是程序的意思,Counter是计数器,最后Register没有直接翻译,它的含义是寄存器的意思,稍后我们会提到它。
复制代码
  • 役割:次のjvm命令の実行アドレスを覚えておいてください

  • 特徴:

    • スレッドプライベートです

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

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

1.2機能

ここに画像の説明を挿入

右側のコードは、System.outを変数に割り当て、この変数を使用してSystem.outのprintlnメソッドを呼び出し、非常に単純なJavaコードであるprint 1、print 2、print3...を実行します。

Javaソースコードを直接実行することはできません。一度コンパイルして、バイナリバイトコードの左側にあるバイナリバイトコードにコンパイルする必要があります。バイナリバイトコードの左側はJVM命令です。Java仮想マシンの基盤はクロスです。 -platformは、この一連のJVM命令であり、すべてのプラットフォームで一貫性があります。これらの命令をCPUに直接渡して実行することはできますか?まだ、これらの命令は実行のためにCPUに渡すことができず、インタプリタを経由する必要があります。このインタープリターは、Java仮想マシン実行エンジンのコンポーネントでもあります。これは、各仮想マシン命令(getstaticなど)をマシンコードとして解釈し、マシンコードをcpuに渡すことを特に担当します。 cpuによって実行されます。つまり、cpuはマシンコードのみを認識します。

実装プロセス:

ここに画像の説明を挿入

プログラムカウンタ機能:次のjvm命令の実行アドレスを覚えておいてください

実行フローとプログラムカウンターは次のようになります。最初のgetstatic命令を取得し、それをインタープリターに渡します。インタープリターはそれをマシンコードに変換し、CPUに渡して実行しますが、同時にnext命令、つまり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、time、%cpu | grep 32655

この32655は、CPUを大量に消費するプロセスのIDです。

ここに画像の説明を挿入

jstack + process idコマンド(jdkが提供するツール)を使用します

jstack 32655

32655プロセスのすべてのスレッドを表示

ここに画像の説明を挿入

プロセス32655には非常に多くのスレッドがありますが、どのスレッドに問題があるかをトラブルシューティングするにはどうすればよいですか?

ちょうど今、問題のある32665スレッドを見つけるためにpsコマンドを使用しました

jstackによって出力されるスレッド番号は16進数です

10進数の32665を16進数の7F99に変換します

ここに画像の説明を挿入

Javaコードを開き、8行目を見つけます

ここに画像の説明を挿入

ケース2:プログラムが長時間実行されても結果が得られない

Javaプログラムを実行すると結果が得られるはずですが、結果は得られません。デッドロックが見つかった可能性があります。

ここに画像の説明を挿入

jstack+プロセスIDを使用する

つまり、jstack 32752

ここに画像の説明を挿入

ソースコードを見てみましょう

ここに画像の説明を挿入

おすすめ

転載: juejin.im/post/7082873548263391263