浅谈JVM

浅谈JVM

JVM内存布局规定了Java在运行过程中内存申请、分配和管理的策略,保证了JVM高效稳定的运行。结合JVM虚拟机规范,以及自己对JVM的理解,与大家讨论一下JVM内存布局。

在这里插入图片描述

(一) Heap 堆区

Heap存储着几乎所有的实例对象,堆内对象由垃圾回收器自动回收,堆区由各子线程共享使用。显而易见,堆区是内存区域中所占内存最大的。如果没有节制的创建对象,容易消耗完所有空间,产生OutOfMemoryError。

1.1设定堆区内存大小

-Xms256M -Xmx512M,其中-X表示它是JVM运行参数。ms是memory start的简称,表示最小堆容量;mx是memory max的简称,表示最大堆容量。

注意:线上运行环境,Xms和Xmx要设置成一样大。避免在GC后调整堆大小时带来的额外压力 。

2.2 堆内分块

​ 由JVM内存分布图,可以看出,堆分成两大块:新生代和老年代。对象产生之初在新生代,步入暮年时进入老年代,同时老年代也接收在新生代无法容纳的超大对象。新生代= 1 个 Eden 区+ 2 个Survivor 区。绝大部分对象在 Eden 区生成 , 当 Eden 区装填满的时候 , 会触发 Young Garbage Collection , 即 YGC。垃圾回收的时候 , 在 Eden 区实现清除策略 , 没有被引用的对象则直接回收。依然存活的对象会被移送到 Survivor 区 , Survivor 区分为 S0 和 Sl 两块内存空间 , 送到哪块空间呢?每次 YGC 的时候, 它们将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清除 , 交换两块空间的使用状态。如果 YGC 要移送的对象大于 Survivor 区容量的上限 ,则直接移交给老年代。假如一些没有进取心的对象以为可以一直在新生代的Survivor 区交换来交换去,那就错了。每个对象都有一个计数器,每次 YGC 都会加l 。 -XX:MaxTenuringThreshold 参数能配置计数器的值到达某个阐值的时候 , 对象从新生代晋升至老年代。如果该参数配置为 1 ,那么从新生代的 Eden 区直接移至老年代。默认值是 15 , 可以在 Survivor 区交换 14 次之后 , 晋升至老年代。

​ 如果 Survivor 区无法放下,或者超大对象的闹值超过上限,则尝试在老年代中进行分配 ; 如果老年代也无法放下,则会触发 Full Garbage Collection , 即FGC。如果依然无法放下, 则抛出 OutOfMemoryError。

在这里插入图片描述

​ 这也是JVM垃圾回收机制里面,常用的"mark-copy"算法。

1.3 计算对象实例所占内存大小的方法

对象分为三块存储区域,由下图所示,分别是对象头,实例数据,对齐填充。

在这里插入图片描述

  • 对象头

    对象头占用12个字节,存储内容包括对象标记(markOpp)和类元信息(KlassOpp)。对象标记占8个字节。类元信息存储的是对象指向它的类元数据(Klass)的首地址,占4个字节。

  • 实例数据

    存储本类对象的实例成员变量和所有可见的父类成员变量 。

  • 对齐填充

    对象的存储空间分配单位是 8 个字节,如果一个占用大小为 16 个字节的对象,增加一个成员变量 byte 类型,此时需要占用 17 个字节,但是也会分配 24 个字节进行对齐填充操作。

    // 开辟内存空间16B
    public class RefObjOther {   
        // 对象头12个字节
        
        // 此时d是引用类型,占4个字节,并不会开辟8000个字节
        // new double[1000]的时候,已经在堆里开辟了空间
        private double[] d = new double[1000];
        
        public double[] getD() {        
            return d;    
        }    
        public void setD(double[] d) {        
            this.d =   
        }
    }
    
    // 开辟内存空间48B
    public class RefObj {  
        // 12B+4B+4B+8B+1B+4B+4B+4B=41B,对齐补充,48B
        
        // 对象头占12个字节
        
        // 引用类型,占4个字节
        private String desc; 
        // 基本数据类型,int占4个字节
        private int intNum; 
        // 基本数据类型,double占8个字节
        private double doubleNum; 
        // 基本数据类型,byte占1个字节
        private byte b;
        // 引用类型,占4个字节
        private Object obj;
        // 引用类型,占4个字节,注意这里只是引用,不是16个字节
        private RefObjOther refObjOther;
        // 引用类型,占4个字节
        private RefObjOther refObjOther2;
    }
    

    基本数据类型所占空间大小如下(64位):

Primitive TypeMemory Required(B)
byte, boolean1B
short, char2B
int, float4B
long, double8B
(二) 方法区
2.1MetaSpace(元空间)

​ 在 JDK7 及之前的版本中,只有 Hotspot才有 Perm 区,译为永久代。在某些场景下,如果动态加载类过多,容易产生 Perm 区的 OutOfMemoryError 。区别于永久代,元空间在本地内存中分配。JDK8 里,Perm 区中的所有内容中字符串常量移至堆内存,其他内容包括类元信息、字段、静态属性、方法、常量等都移动至元空间内。方法区是线程共享的。

2.2存放数据

方法区存储的是每个class的信息。

  • 类信息:类的版本,字段,方法,接口描述

    • 字段:每个字段的名字、类型(如类的全路径名、类型或接口) 、修饰符(如public、abstract、final)、属性
    • 方法信息:每个方法的名字、返回类型、参数类型(按顺序)、修饰符、属性
    • 方法代码:每个方法的字节码、操作数栈大小、局部变量大小、局部变量表、异常表和每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
  • 类加载器的引用(ClassLoder)

    JVM必须知道一个类是由什么加载器进行加载的。因为JVM在动态加载的时候需要这个信息,当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的

  • 运行时常量池:字面量(final修饰的变量)和符号引用
    类加载机制的解析过程,就是JVM将常量池内的部分符号引用替换为直接引用的过程
    符号引用:以一组符号来描述所引用的目标,符号可以任何形式的字面量,只要能使用时无歧义地定位到目标即可。
    直接引用:可以是指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
    指向类型的Class对象,类变量,类方法的直接引用都是指向方法区的指针;指向实例变量,实例方法的直接引用都是偏移量。

  • 常量

  • 静态变量

  • 即时编译后代码

2.3总结
  • 创建一个对象,如果方法区没有找到该类的class信息,JVM会完成三件事:(即为加载过程)
    1. 通过classloader在classpath中获取XXX.class文件,将其以二进制流的形式读入内存
    2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构
    3. 在内存中生成一个该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
    随后进行链接和初始化
  • 创建一个对象,如果方法区找到该类的class信息,直接进行链接和初始化

(三) JVM Stack 虚拟机栈

JVM中的虚拟机栈是描述java方法的执行内存区域,它是线程私有的。
栈中的元素用于支持虚拟机进行方法调用,每个方法从开始调用到执行完成的过程,就是栈帧从
入栈到出栈的过程。在活动线程中,只有位于栈顶的帧才是有效的 ,称为当前栈帧。正在执行的
方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前
栈帧进行操作。
StackOverflowError表示请求的栈溢出,导致内存耗尽,常见于递归方法中。
在这里插入图片描述
虚拟机栈通过压栈和出栈的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会
跳到另一个栈帧上。在执行的过程中,如果出现异常,会进行异常回溯,返回地址通过异常处理表确定。
每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法返回地址等信息

  • 局部变量表
    局部变量表是存放方法参数和局部变量的区域。如果是非静态方法,index[0]上存储的是方法所属对象
    实例的引用,随后存储的是方法参数和局部变量。
    局部变量什么时候初始化?
    局部变量只有该方法被调用的时候才初始化,此外,局部变量一定要赋初值,否则会报错。
  • 操作栈
    操作栈在方法执行过程中,会有各种指令往栈里写入和读取信息。字节码指令集的定义都是
    基于栈类型的
  • 动态连接
    每个栈帧都包含一个指向运行时常量池中该栈帧所属方法(即当前方法)的引用。持有这个引用是为了 支持方法调用过程中的动态连接。
    问题来了,什么是方法调用?
    1.方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本
    2所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,
    会将其中的一部分符号引用转化为直接引用。转化的前提,方法在程序真正运行之前就有一个可确定
    的调用版本,并且这个方法的调用版本在运行期是不可改变的,简单说,就是调用目标在程序代码写好、
    编译器进行编译时就必须确定下来。
  • 方法返回地址
    方法执行时会有两种退出情况:第一,正常退出,如RETURN,IRETURN,ARETURN;第二,异常
    退出。无论何种退出情况,都将返回至方法当前被调用的位置。
    退出的方式可能有三种:
    1.返回值压入上层调用栈帧
    2.异常信息抛给能够处理的栈帧
    3.PC 计数器指向方法调用后的下一条指令

(四) Native Method Stack 本地方法栈

本地方法栈在JVM内存布局中,也是线程私有的,但是虚拟机栈“主内”,本地方法栈“主外”。
"A native method is a Java method whose implementation is provided by non-java code."
简单一点,本地方法就是java代码调用非java代码的接口。
本地方法可以通过JNI(Java Native Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和JVM相同的能力和权限。但是也存在一定的弊端,当大量本地方法出现时,必然会削弱JVM对系统的控制力。

(五) Program Counter Register 程序计数器

由于CPU时间片轮询限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然会导致经常中断或恢复,如何保证分毫不差呢?
每个线程在创建的时候,都会产生自己的程序计数器和栈帧,程序计数器用来存放执行指令的偏移量和行号指示器等。线程执行或恢复都要依赖程序计数器。程序计数器在各个线程之间互不影响,此区域也不会发生内存溢出异常

(六) 什么样的对象会被回收?

为了判断对象是否存活,JVM引入了GC Roots。
如果 个对象与 GC Roots 之间没有直接或间接的引用关系,比如某个失去任何引用的对象,或者两个互相环岛状循环引用的对象等,这些对象是可以被回收的。
什么对象可以作为GC Roots呢?
静态属性中引用的对象、常量引用的对象、虚拟机栈中引用的对象、本地方法栈引用的对象等。

(七) 关于常量池的后续

  1. 符号引用:字符串,能根据这个字符串定位到指定的数据,比如java/lang/StringBuilder
  2. 直接引用:内存地址
    根据周志明老师的《深入理解Java虚拟机》内容,如下:

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中。


class字节码的数据结构从前至后,包括魔法数(cafe babe)/版本/常量池/访问标志/类索引/父类索引/接口索引/字段表/方法表/属性表。

类加载之后,常量池的内容会进入运行时常量池,这时候里面的数据也许还保持着符号引用。(因为解析的时机由JVM自己设定)
如果在虚拟机栈的栈帧中,我准备调用 main() 函数,那么会通过栈帧中持有的动态链接,找到运行时常量池,然后找到main函数的常量,比如 #2 ,如果这个常量没有被解析过,那么就通过这个常量进行解析过程,其中包括,通过常量找到类名和nameAndType,通过nameAndType找到方法名和返回值。这时候我手里有 类名/方法名/方法返回值,下一步,通过类名和方法名,通过JVM记录的方法列表,找到对应的方法体。而这个方法体实际上是一段内存地址,那么这时候我就把这段内存地址复制给 #2,并且给 #2设定一个已经解析的flag。
这样就完成了 符号引用到直接引用的过程。

常量池包括:

  • 字面量
  • 符号引用
    • 类和接口全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符

(八) 思考

有的资料说,JVM分为stack segment、heap segment、code segment、data segment四部分
code segement 存放方法
data segement 存放常量和静态变量
感兴趣的小伙伴可以研究一下

参考资料

《码出高效》
《深入理解Java虚拟机》

JVM 符号引用和直接引用

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值