JVM | 1 深入理解内存区域

420 阅读14分钟

JVM内存处理流程

下面我们来演示一下执行一段Java代码,JVM内存的处理流程。
执行代码如下:

/**
 * VM 参数
 * 设置堆的初始大小 最大大小 元空间大小
 * -Xms30m -Xmx30m -XX:MaxMetaspaceSize=30m
 */
public class JVMObject {
    public final static String MAN_TYPE="man";//常量
    public static String WOMAN_TYPE = "woman";//静态变量
    //
    public static void main(String[] args) throws InterruptedException {
        Teacher t1 = new Teacher();
        t1.setName("Mark");
        t1.setSexType(MAN_TYPE);
        t1.setAge(36);

        Teacher t2 = new Teacher();
        t2.setName("King");
        t2.setSexType(MAN_TYPE);
        t2.setAge(18);
        Thread.sleep(Integer.MAX_VALUE);
    }
}

class Teacher {
    String name;
    String sexType;
    int age;

    public String getName() {
        return name;
    }

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

    public String getSexType() {
        return sexType;
    }

    public void setSexType(String sexType) {
        this.sexType = sexType;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

  1. JVM申请内存

设置参数:-Xms30m -Xmx30m -XX:MaxMetaspaceSize=30m
当程序启动的时候,由操作系统处理分配内存。
JVM 第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间,根据内存大小找到具体的内存分配表,
然后把内存段的起始地址和终止地址分配给JVM,接下来JVM进行内存分配。

  1. 初始化运行时数据区:方法区、堆、栈

JVM获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小
image.png

  1. 类加载:JVMObject.class Teacher.class 等 静态变量、静态常量等 加载到方法区中

这里主要是把class放入方法区、还有class中的静态变量和常量也要放入方法区
image.png

  1. 执行方法:main运行 在虚拟机栈中栈帧执行main方法
  2. 创建对象:创建Teacher对象,放在堆中,Teacher对象放入到堆中。main方法的t1和t2持有对象的引用

image.png
OK,上述就是这段Java代码,执行时JVM的内存执行流程。

总结一下JVM运行内存的整体流程:

JVM 在操作系统上启动,申请内存,先进行运行时数据区的初始化,然后把类加载到方法区,最后执行方法。 方法的执行和退出过程在内存上的体现就是虚拟机栈中栈帧的入栈和出栈 同时在方法的执行过程中创建的对象一般情况下都是放在堆中,最后堆中的对象也是需要进行垃圾回收清理的

GC的概念:

GC - Garbage Collection 垃圾回收,在JVM中是自动化的垃圾回收机制,我们一般不用去关注,在JVM中GC的重要区域是堆空间。

我们需要更直观的看内存,通过JHSDB 内存的可视化工具,映射JVM的运行信息。

JHSDB 查看堆

JHSDB 是一款基于服务性代理实现的进程外调试工具。服务性代理是HotSpot虚拟机中一组用于映射Java虚拟机运行的信息,主要基于Java语言实现的API集合。

mac  sudo java -cp /Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home/lib/sa-jdi.jar sun.jvm.hotspot.HSDB
window 
image.png
执行 ``java -cp .\sa-jdi.jar sun.jvm.hotspot.HSDB
image.png

**JDK1.9 **及以后的开启方式:进入 JDK 的 bin 目录下,我们可以在命令行中使用 jhsdb hsdb 来启动它

jps命令:Java的ps命令显示进行信息,获取JVMObject的进行ID
image.png
这里需要修改一下代码,在t1对象创建后,手动GC 15次,来观察两个teacher对象在堆的位置

    public static void main(String[] args) throws InterruptedException {
        Teacher t1 = new Teacher();
        t1.setName("Mark");
        t1.setSexType(MAN_TYPE);
        t1.setAge(36);

//        System.gc(); 主动触发GC
        for (int i = 0; i < 15; i++) {
            System.gc();///演示程序 主动触发垃圾回收15次
        }

        Teacher t2 = new Teacher();
        t2.setName("King");
        t2.setSexType(MAN_TYPE);
        t2.setAge(18);

        Thread.sleep(Integer.MAX_VALUE);
    }

运行程序,然后添加进程ID,会显示出JavaThreads的页面,可以看到执行程序的时候JVM会启动多个线程而不是只有main一个线程
image.png

第二步,进入main方法
image.png

第三步,查看对象信息
image.png
如下图进入Teacher的两个对象t1和t2,可以看到这两个对象在堆中的地址信息:
image.png
然后点击Inspect 可以看到更具体的信息如下
t2的对象
image.png
t1的对象
image.png
显示堆的参数
image.png
我们将堆的参数,复制出来如下:

Heap Parameters:
Gen 0:   eden [0x0000000109c00000,0x0000000109d90f18,0x000000010a400000) space capacity = 8388608, 19.577312469482422 used
  from [0x000000010a400000,0x000000010a400000,0x000000010a500000) space capacity = 1048576, 0.0 used
  to   [0x000000010a500000,0x000000010a500000,0x000000010a600000) space capacity = 1048576, 0.0 usedInvocations: 0

Gen 1: concurrent mark-sweep generation
free-list-space[ 0x000000010a600000 , 0x000000010ba00000 ) space capacity = 20971520 used(2%)= 538576 free= 20432944
Invocations: 15


Gen 0 表示的新生代
Eden 区 起始地址: 109c00000 ~ 10a400000
From区 起始地址: 10a400000 ~ 10a500000
To区 起始地址: 10a500000 ~ 10a600000

Gen 1 表示老年代
起始地址: 10a600000 ~ 10ba00000

t1对象的地址:

  • Oop for course02/Teacher @ 0x000000010a683728

t2对象的地址

  • Oop for course02/Teacher @ 0x0000000109c00000


说明:

  • 新生代 GC频繁

Eden区也叫伊甸区,表示声明诞生的地方,实例化的对象在这里分配内存

在Eden区诞生,并且经历一次GC后仍存活的对象 会进入 Survivor幸存者区。也就是From区和To区

  • 老年代 GC不频繁

经历多次GC,年龄达到15(不绝对)仍然存活的对象进入老年代
image.png

可以看到t1对象的地址明显在老年代,而t2对象的地址在新生代的Eden区。t1之后进行了15次的GC,一般经过多次GC,年龄达到15(不绝对)仍然存回的对象会进入老年代,对象并不会被回收掉T1存活。这样看起来是不是更加直观。

参考:

下面我们来分析一下,t1如何进入老年代的。如下图所示:t1在刚创建对象的时候处于新生代的Eden区。
image.png
当一次GC后,进入From区,再次GC进入To区,然后是From和To 区来回进入。
image.png
每次进入From和To区都会记录分代年龄,当分代年龄达到15(不绝对)就会进入老年代
image.png :::tips 不是必须到达15岁才会晋升为老年代,JVM采用动态年龄计算,以防止老年代内存过于宽裕,而新生代内存被撑爆。 :::

JHSDB 查看栈

在Java Threads 查看栈信息
image.png
如下部分是本地方法栈的栈帧,在main方法执行到了Thread.sleep 方法所以在栈顶
image.png
如下图:是main方法的栈帧:栈帧里面有操作数栈、局部变量表等等。
image.png

栈的优化技术:栈帧之间数据共享

比如如下代码,方法调用方法,都会压人到虚拟机栈中,每个方法都是一个栈帧,那么栈帧之间的数据传递就是通过栈的优化技术来实现,两个栈帧共享一块内存区域,节省内存空间。

public class JVMStack {
    public int work(int x) throws InterruptedException {
        int z = (x + 5) * 10;//局部变量表
        Thread.sleep(Integer.MAX_VALUE);
        return z;
    }

    public static void main(String[] args) throws InterruptedException {
        JVMStack jvmStack = new JVMStack();
        jvmStack.work(10);//10 放入main栈帧操作数栈
    }
}

main方法先入栈,然后是work()方法入栈,前两两个栈帧可以共享一部分区域来传递参数,这样可以节省内存空间。
image.png
我们打开HSDB 查看在一个096的地址位置共享了一个内存空间来传递参数
image.png
在上一个栈帧096 是操作数栈 x -> main
在下一个栈帧096 是局部变量表 x -> work 的入参

辨析堆和栈:

  • 功能
    • 以栈帧的方式存储方法调用的过程,并存储方法调用过程中基本数据类型的变量以及对象的引用,其内存分配在栈上,变量出了作用域就会自动释放
    • 而堆内存用来存储Java中的对象,无论是成员变量、局部变量还是类变量,它们指向的对象都存储在堆内存中。
  • 线程独享还是共享
    • 栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在所属线程中可见,即栈内存可以理解为线程的私有内存
    • 堆内存中的对象对所有线程可见,堆内存中的对象可以被所有线程访问
  • 栈的内存要远远小于堆内存

内存溢出

  • 栈溢出

这个比较简单,就不再多说了

/**
 * 栈异常 溢出
 */
public class StackError {
    public static void main(String[] args) {
        A();
    }

    public static void A() {
        A();
    }
}
  • 堆溢出

如下代码:在运行程序时设置VM 参数 固定堆的大小为30m,然后创建一个35m的对象就会出现堆溢出

/**
 * VM Args: -Xms30m -Xmx30m -XX:+PrintGCDetails
 * 设置堆的大小来模拟对溢出异常
 */
public class HeapOom {
    public static void main(String[] args) {
        String[] strings = new String[35*1000*1000];
    }
}


image.png
还有一个堆溢出的情况:

/**
 * VM Args: -Xms30m -Xmx30m -XX:+PrintGCDetails  堆的大小为30m
 * 造成一个堆内存溢出 分析一下JVM的分代收集
 * GC调优---生成服务器推荐开启(默认是关闭的)
 * -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOom2 {
    public static void main(String[] args) throws InterruptedException {
        List<Object> list = new LinkedList<>();//list 当前虚拟机栈 局部变量表中引用的对象
        int i = 0;
        while (true){
            i++;
            if (i%1000==0) Thread.sleep(10);
            list.add(new Object());//不能回收2, 优先回收再来抛出异常
            // GC overhead limit exceeded :当GC 占据了98%的资源 回收不足2%。就抛出OOM
        }
    }
}

image.png

  • 方法区溢出

怎么样会触发方法区溢出呢,方法区存储的就是静态变量和class,不断的往方法区中放class就会出现方法区溢出的异常。

/**
 * 方法区导致的内存溢出
 * 设置VM运行的参数
 * MetaspaceSize :
 * VM Args" -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M
 */
public class MethodAreaOom {
    public static void main(String[] args) {
        while (true){
            //不断的生成class 使用cglib
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(TestObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return  methodProxy.invoke(o,objects);
                }
            });
            enhancer.create();
        }
    }
    public static class TestObject {
        private double a = 34.53;
        private  Integer b = 9999999;
    }
}

如下抛出:OutOfMemoryError:Metaspace 是方法区抛出oom异常
image.png

  • 本机直接内存溢出
/**
 * 直接内存OOM
 * 设置直接内存的大小
 * VM Args: -XX:MaxDirectMemorySize=100m
 */
public class DirectOom {
    public static void main(String[] args) {
        //直接分配128M给直接内存(100m)
        ByteBuffer bb = ByteBuffer.allocateDirect(128*1024*1024);
    }
}

OutOfMemoryError:Driect buffer memory 直接内存异常
image.png

常量池

Class(静态)常量池

如下所示:编译java的class文件,可以看到Constant pool 这个就是常量池。
用于存放编译期间生成的各种字面量和符号引用

javap -v OperandStack.class
Classfile course01/OperandStack.class
  Last modified 2020-12-11; size 390 bytes
  MD5 checksum 64a24ef5bd64d8618c0c87df6dea0db1
  Compiled from "OperandStack.java"
public class course01.OperandStack
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #5.#16         // java/lang/Object."<init>":()V
   #2 = Class              #17            // course01/OperandStack
   #3 = Methodref          #2.#16         // course01/OperandStack."<init>":()V
   #4 = Methodref          #2.#18         // course01/OperandStack.test:()I
   #5 = Class              #19            // java/lang/Object
   #6 = Utf8               <init>
   #7 = Utf8               ()V
   #8 = Utf8               Code
   #9 = Utf8               LineNumberTable
  #10 = Utf8               test
  #11 = Utf8               ()I
  #12 = Utf8               main
  #13 = Utf8               ([Ljava/lang/String;)V
  #14 = Utf8               SourceFile
  #15 = Utf8               OperandStack.java
  #16 = NameAndType        #6:#7          // "<init>":()V
  #17 = Utf8               course01/OperandStack
  #18 = NameAndType        #10:#11        // test:()I
  #19 = Utf8               java/lang/Object
{
  public course01.OperandStack();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public int test();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: iconst_0
         1: istore_1
         2: iconst_1
         3: istore_2
         4: iload_1
         5: iload_2
         6: iadd
         7: bipush        10
         9: imul
        10: istore_3
        11: iload_3
        12: ireturn
      LineNumberTable:
        line 5: 0
        line 6: 2
        line 7: 4
        line 8: 11

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class course01/OperandStack
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: astore_1
         8: aload_1
         9: invokevirtual #4                  // Method test:()I
        12: pop
        13: return
      LineNumberTable:
        line 12: 0
        line 13: 8
        line 14: 13
}
SourceFile: "OperandStack.java"

  • 字面量 String a="b" int a = 13 ..... 给基本类型变量赋值的方式就叫做字面量或字面值
  • 符号引用:比如Person类引用Tool类,在编译的时候,不知道Tool类的真是内存地址。Org.xx.Tool 就是符号引用,类加载后就可以根据符号引用变成直接引用(直接引用是在运行时常量池),也就是实际的内存地址。
  • 符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,Java在编译的时候每个Java类都会编译成一个class文件,但在编译的时候虚拟机并不知道所引用类的地址(实际地址),就用符号引用来代替,而在类的解析阶段就是为了把这个符号引用转化成真正的地址的阶段。

运行时常量池

直接引用就是放在运行时常量池。JDK 1.7以后,实现在堆,逻辑在方法区。
运行时常量池(Runtime Constant Pool) 是每一个类或接口的常量池(Constant_Pool)的运行时表示形式,它包括了若干种不同的常量:从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。
运行时常量池是在类加载完成之后,将Class常量池中的符号引用值转存到运行时常量池中,类解析之后,将符号引用直接替换成直接引用。运行时常量池在JDK1.7版本之后,就移到堆内存中了,这里指的是物理内存,而逻辑上还是属于方法区(方法区是逻辑分区)。
在JDK1.8中,使用元空间代替永久代来实现方法区,但是方法区并没有改变。变动的知识方法区中内容的物理存放位置,但是运行时常量池和字符串常量池被移动到堆中了

字符串常量池

字符串常量池是解决String的效率的问题

String 类的分析,如下图
String的定义了一个final的char数组,就是不可变的,那么为什么设置成不可变呢?

  • 安全性
  • hash 唯一,hashmap key-value
  • 这种实现,可以实现字符串常量池

image.png

String的创建方式

下面来讲解String的创建方式的区别,以及设计
第一种创建方式:

    public void mode1(){
        String str ="abc";
    }

这种创建方式,在代码编译加载时,会在常量池中创建常量"abc",运行时返回常量池中的字符串引用,也就是说 先创建了String str = "abc" 那么这个"abc"会在常量池中创建,当我们在写代码String k="abc" 如果字符串常量池中有对应的字符串,就会直接返回常量池中的"abc"引用
image.png
第二种创建方式:

    public void mode2(){
        String str =new String("abc");
    }

如下图所示:new String 会进行两步的操作:

  1. 在代码编译加载时,会在常量池中创建常量"abc"
  2. 在调用new时,会在堆中创建String对象,并引用常量池中的字符串对象char[]数组,我们知道String类的源码,字符串就是通过一个final char[] 数组保存的。

使用new String 会在堆中多一个对象,所以一般我们创建字符串不会这样创建。

image.png
第三种方式:

    public void mode3(){
        Location location = new Location();
        location.setCity("深圳");
        location.setRegion("南山");
    }
public class Location {
    private String city;
    private String region;
}

如上述的代码,在运行时,创建的String对象会在堆中直接创建,不会再常量池中创建了。这里我们注意一点,上述的前两种创建方式,都是在一个方法中创建字符串,而第三种方式是在对象中创建字符串,而对象都是在堆中的。所以第三种方式不会在字符串常量池中创建字符串了。

第四种方式:

    public void mode4(){
        String str2= "ab" + "cd" + "ef";//3个对象。效率最低。
        //java -》class- java
    }

上述代码中的,这种拼接字符串,会生成三个对象:ab abcd abcdef 效率最低,编译器会进行一定的优化str2="abcdef"

第五种方式:

    public void mode5(){
        //拼接字符串,一般主流的编译器会进行优化
//        String str = "abcdef";
//        for(int i=0; i<1000; i++) {
//            str = str + i;
//        }
        //优化
        String str = "abcdef";
        for(int i=0; i<1000; i++) {
            str = (new StringBuilder(String.valueOf(str)).append(i).toString());
        }
    }

第六种方式:推荐使用的方式
intern() 方法会在常量池中查找,如果查找到了则直接返回常量池的引用,就不会在堆中创建String对象了

    public void mode6(){
        //去字符串常量池找到是否有等于该字符串的对象,如果有,直接返回对象的引用。
        String a =new String("king").intern();// new 对象、king 字符常量池创建
        String b = new String("king").intern();// b ==a。
        if(a==b) {
            System.out.print("a==b");
        }else{
            System.out.print("a!=b");
        }
    }

需要注意:对象是在堆中的;而引用可以在栈、方法区中等。

如下代码:巩固一下上述学习的知识

        String str1="abc";//常量池中的引用
        String str2 = new String("abc");//str2是在堆中的
        String str3 = str2.intern();//常量池中的引用
        System.out.println(str1==str2);//false
        System.out.println(str2==str3);//false
        System.out.println(str1==str3);//true

JDK 1.7版本及之前,理解JVM的处理方式即可。
image.png
注意在JDK 1.8版本,运行时常量池和字符串常量池是被移动到了堆中