【读书会第十二期】深入理解Java虚拟机 第1章 JVM进程如何进行内存管理

龙哥手记 发表于 2022/05/04 15:33:15 2022/05/04
【摘要】 开篇

【说在前面】:为啥要了解JVM的内存区域?原来JAVA最引以为豪就是自动内存管理,跟C++手动管理内存,以及复杂的指针处理,用起来更加方便,所以本篇重点描述下JVM进程是怎么管内存的?

  • 本文的主要内容有:
  1. 程序计数器
  2. 虚拟机栈
  3. 本地方法栈
  4. 方法区
  5. 堆区

线程私有的内存区域

1 程序计数器

看图我们先从简单的说起,程序计数器是JVM所占内存中较小的一块内存,在JVM里面用程序计数器记录编译过程字节码文件所在的行,这个怎么记录的呢?这个计数器说白了就是指向当前行运行的字节码的头指针,字节码在执行过程中程序计数器会记录包括循环,跳转,分支,以及异常处理,线程恢复等这些都要依赖计数器来完成;

我们知道Java多线程是线程切换并分配处理器,所以任意时刻一个处理器只会处理一个线程的指令,为的是线程切换后能切换到正确执行位置,所以每个线程都会有独立的程序计数器;如果说线程它执行加法这个计数器记录的是虚拟字节码的地址;那假如线程里面执行的是本地方法,那么计数器为空,所以说计数器是唯一不会OOM的哦。

2 虚拟机栈

然后来看下虚拟机栈,它和程序计数器一样都是线程私有的内存区域,虚拟机栈的生命周期与线程相同。虚拟机栈里面存的是Java方法执行流程,方法执行都会创建一个栈帧来放局部变量表,操作数栈,动态连接,方法返回地址等;每个方法调用以及结束,对应一个栈帧在虚拟机入栈到出栈过程。局部变量表是比较为人所熟知的,也就是平常所说的“栈”,局部变量表所需的内存空间在编译期间分配完成,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

这里如果出错虚拟机栈会发生两种异常情况:

  • StackOverflowError: 线程请求的栈大于虚拟机栈的深度,特别是方法的递归调用的时候抛出栈溢出异常
  • OutOfMemoryError:虚拟机栈无法满足线程所申请的空间需求,即使经过动态扩展仍然无法满足时抛出该异常

看上面代码,我写了三个方法,A方法调用B方法,B方法调用C方法,然后C方法打印一行日志,然后我们debug,可以看到调试器有三个栈进行压栈,可以看到从上到下ABC一次入栈,那么CBA依次出栈,这个所谓栈帧;当然这个栈帧存的数据如局部变量表,操作数栈以及动态链接等,我们可以看这个局部变量在栈帧里变化是否符合我们预期;

3 本地方法栈

我们看下本地方法栈,与虚拟机栈功能是非常相似的,区别在于本地方法栈顾名思义执行native方法,也就是我们常说的C++代码,同样本地方法栈也会抛出内存与栈溢出异常。

线程共享的区域

1 方法区

我们看下方法区,它是线程共享区域,存储JVM加载过的信息,比如常量,静态变量,类信息以及编译器编译的代码,以及符号引用;前面说了所有线程都能拿到方法区里面的数据,需要注意的是方法区是线程安全的,比如两个线程同时访问方法区同一个类,而这个类还没有加载进JVM,所以只允许一个线程去访问那个类,而其他线程必须等待。

JDK在1.8之后,已经取消永久代改为元空间,而元空间的元数据是放在本地内存的,所以理论上系统能使用内存有多大,那元空间就有多大;所以不会出现元空间内存溢出异常,并且永久代调优是很困难的,虽然说可以设置永久代大小,但是很难确定合适大小,因为其中影响因素有很多,比如类数量多少,动态代理会生成class对象,常量数量的多少(String对象存在方法区);永久代的数据位置也会随着funGC发生移动,也就是进行回收,这个是比较消耗性能的,虚拟机每种垃圾回收器都要特殊处理,永久代中元数据剥离出来,不仅实现元空间无缝管理,还可简化funGC,给以后并发隔离,元数据等进行优化,

2 堆区

image.png

我们内存区域最大一部分就是我们的堆,绝大部分对象都分在Eden区,其中大多数对象都会被回收掉,Eden区域是连续内存空间,因此再分配内存的时候时间很短,当Eden区域不足分配内存不足时候就会yungc,把消亡的对象清理掉,并将复活对象复制到From Survivor与To Survivor区,上面两个区域总是有空的,因为对象会进行拷贝。当Eden空间满了,执行mingc把消亡对象清理掉,将复活对象复制到From Survivor区,然后清理Eden区,把S区对象清理掉,回收年龄都会进入老年代。如果对象在年轻带存活足够时间,而没有清理掉也就是经历GC之后,存活下来也会进入老年代,老年代空间一般比年轻代空间要大,能存放更多对象,在老年代发生GC次数比年轻代要少;当老年代内存不足时,将执行funGC,如果对象比较大,比如说大的字符串,数组;则将对象直接分配到老年代,由于绝大多数对象生命周期比较短,规定新生代占堆空间80%,S区域存活的对象超过内存区域的10%,则将一部分对象分配到老年代,这里原则就是尽可能更多把对象放置到老年代;

  • 方法区同样会抛出OutOfMemoryError异常。

在方法区中有一部分区域用来存储编译期产生的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。这里需要说明一点,常量并不是只能在编译期产生,运行期间也会产生新的常量并被在常量池中,如 String 类的 intern() 方法。

举个例子,创建了两个对象,张三,李四,我们看下运行时这两个对象分布在哪里?person类有两个成员变量num,age,还有一个成员方法eat(),这些字节码存储在方法区,也就是我们1.7的永久代,编译时候会创建一些栈帧,最后实例数据(张三,年龄以及字节码的引用)需要在运行时进行访问,这些数据是放在堆区。程序计数器运行到i–,程序计数器进行赋值,执行到eat()就行 进行压栈,

过程分析

JVM遇到一条new指令时,首先检查这个参数是否正确,是否能在方法区的常量池定位到符号引用,这个符号引用指向class对象,并且检查符号引用代表的类是否是已经加载,解析或者初始化过,如果没有必须先执行相应类加载过程,给新生对象分配空间的任务等同于把一块确定大小内存从java堆中划分出来,然后对象进行默认值初始化,int是0,double是0.0,还有一些对象默认值是空值,在进行默认值之后;如何找到对象的hashcode,数据信息,二级指针,这些存在对象里面;因为还没有执行构造方法,没有执行init(), 现在执行初始化方法,一个完整对象才创建成功。对象如何定位到方法,如何定位到字节码的呢?对象头的class Pointer的指针指向了方法区的已经编译后代码。

开发中,我们不会一直向链表中添加对象,如果是你的代码里面对象没有被移除掉,可能出现堆溢出异常;

这就是堆外内存溢出,因为这个unsafe()是比较危险的,能直接分配一定内存,下面用反射分别获取1MB内存,然后设置堆外内存最大是10MB,最大虚拟机栈是20MB;


逻辑内存模型我们已经看到了,那在java语言中,对象访问是如何进行的呢?对象访问在Java语言中无处不在,是最普通的程序行为,这其中涉及到的java栈、java堆、方法区这三个重要的内存区域,来看如下代码:

public class JVMMemoryTest {
 
    /**
     * jvm自动寻找main方法,执行main方法,此时虚拟机栈中有一个代表main方法的栈帧入栈,
     * 只有main方法执行完毕后才出栈
     * @param args
     */
    public static void main(String[] args) {
 
        /**
         * 1.student是对象的引用,所有会保存在栈帧的局部变量里
         * 2.创建Student的时候,首先进行类的加载工作,类只会加载一次,
         * 将类的类型信息数据加载到jvm的方法区中,如果之前加载过了,那么就不会再加载了
         * 3.类的加载其实就是将.class文件加载进虚拟机内存中,在加载的时候,在java堆中生成对应的Class对象,
         * 最后生成一个Student对象在堆中
         */
        Student student=new Student(18,"tom","007");
 
        /**
         * 声明定义一个int类型的变量a,因为a是基本数据类型,所以在栈中直接分配一个内存保存这个变量
         */
        int a=9;
 
        int b=10;
 
        /**
         * 执行study方法,在栈中加入一个栈帧,执行完毕后这个栈帧将出栈
         * 在study方法中,有两个int类型的局部变量,是保存在栈帧的局部变量内存区中的
         */
        student.study(a,b);
 
    }
 
 
    //静态内部类
    public static class Student extends Person implements IStudyable {
 
        private static int cnt = 5;
 
        static {
            cnt++;
        }
 
        private String sid;
 
        public Student(int age, String name, String sid) {
            super(age, name);
            this.sid = sid;
 
        }
 
        public void run() {
            System.out.println("run()...");
        }
 
        public int study(int a, int b) {
 
            int c = 10;
            int d = 20;
            return a + b * c - d;
 
        }
 
        public static int getCnt() {
            return cnt;
 
        }
    }
 
    //父类
    static class Person {
 
        private String name;
        private int age;
 
 
        public Person(int age, String name) {
            this.age = age;
            this.name = name;
 
        }
 
        public void run() {
 
        }
 
    }
 
    //接口
    interface IStudyable {
        public int study(int a, int b);
    }
 
}

上面例子展示了一个简单的代码,JVM是如何分配内存的,还有需要说明的就是new Student的时候,在java堆里面形成一块存储了Student类型的所有实例数值(instance Data,对象中的各个实例字段数据)的结构化内存,这块内存的长度是不固定的。另外,在java堆中还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据是存储在方法区中的

不同的虚拟机实现对象的访问方式有所不同,主流的方式有两种:

  • 使用句柄和直接指针

如果使用句柄访问方式,java堆中将会划出一块内存来作为句柄池,reference中存储的是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息,如图所示:

如果使用指针直接访问的方式,java对象的布局中就必须放置有访问类型数据的指针,而reference中直接存储的是对象的地址,如图所示:

这两种访问方式各有优势,其中使用直接指针访问方式最大的好处就是访问速度快,可节省程序的执行成本哦。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区),文章链接,文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件至:cloudbbs@huaweicloud.com进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容。
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。