Java-第十四部分-JVM-方法区和对象存储

JVM全文

方法区

image.png

  • 栈、堆、方法区的交互关系,以new对象的角度看
 方法区   栈        堆
Person person = new Person();
         堆
static Person person = new Person();
复制代码

image.png

概述

  • 数据来源于类加载阶段
  • 方法区在逻辑上属于堆的一部分,但是一些简单的实现不需要垃圾回收或者压缩,对于hotspot vm,方法区也叫Non-heap,与堆分开
  • 是各个线程共享的区域
  • 在jvm启动的时候就被创建,实际物理内存空间可以是不连续的
  • 内存大小,可以选择固定大小或者扩展
  • 方法大小决定系统可以保存多少个类,同样也会有内存溢出错误 java.lang.OutOfMemoryError: PermGen space(永久代/元空间) / Metaspaca(元空间);加载大量第三方jar包,Tomcat部署的工程过多,大量动态生成反射类
  • 关闭jvm就会释放方法区内存
  • 演进
  1. jdk7前,将方法区称为永久代;jdk8之后,称为元空间
  2. 对于hotspot,方法区与永久代并不等价
  3. -XX:MaxPermSize 设置永久代内存大小,8后被移除
  4. 元空间不再虚拟机设置的内存中,使用本地内存,内部结构进行了调整
  • 参数设置
  1. 7以前 -XX:PermSize设置永久代初始分配空间,默认值20.75M;-XX:MaxPermSize设置永久代最大可分配空间,32位默认64M,64位默认82M
  2. 8以后,-XX:MetaspaceSize=Size设置元空间初始分配空间,默认值21M,mac20M;-XX:MaxMetaspaceSize=Size设置元空间最大可分配空间,mac为1G,一般不设置
  3. 当出发初始的内存大小,Full GC将会被触发,卸载没用的类,这些类对应的类加载器不再存活,然后这个初始大小会被重置,如果释放的空间不足,而且不超过MaxMetaspaceSize,提高该值;如果释放过多,降低该值
  4. 如果初始值设置过低,那么调整会发生很多次,影响性能,需要将MetaspaceSize设置为一个较高的值,避免频发触发Full GC
  • 模拟OOM OutOfMemoryError: Compressed class space,是后续的优化,加入了类指针压缩空间,与`metaspace并列
public class MetaspaceDemo1 extends ClassLoader{
    public static void main(String[] args) {
        int j = 0;
        try {
            MetaspaceDemo1 msd = new MetaspaceDemo1();
            for (int i = 0; i < 10000; i++) {
                //创建classwrite对象,用于生成类的二进制字节码,0-这种方式不会自动计算操作数栈和局部临时变量表大小
                ClassWriter classWriter = new ClassWriter(0);
                //指明版本号,修饰符,类名,包名,父类,接口
                classWriter.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
                //返回byte[] 字节码
                byte[] bytes = classWriter.toByteArray();
                //类的加载
                msd.defineClass("Class" + i, bytes, 0, bytes.length);
                j++;
            }
        } finally {
            System.out.println(j);
        }
    }
}
复制代码
  • 解决OOM
  1. 一半手段通过内存映像分析工具dump出来的堆转储快找进行分析,分析清楚是内存泄漏 Memory Leak,还是内存溢出 Memory Overflow
  2. 如果是内存泄漏,通过工具查看泄漏对象到GC Roots(被gc检测的对象)的引用链,找到泄漏对象是通过怎么的路径与GC Roots相关联的并导致垃圾收集器无法自动回收,定位泄漏代码的位置
  3. 如果不存在内存泄漏,就是内存中的对象确实必须活着,那么就需要检查虚拟机的堆参数-Xmx/-Xms,与机器物理内存对比是否可以调大,从代码上检查是否存在某些对象生命周期过长,持有时间过长,减少程序运行期的内存消耗

方法区内部结构

image.png

  • 类型信息,包括域信息、方法信息
  1. 这个类型的完整有效名称 全名=包名.类名
  2. 这个类型直接父类的完整有效名,对于interface或是java.lang.Object,没有父类
  3. 这个类型的修饰符 (public、abstract、final(修饰后,不能有子类)的某个子集)
  4. 这个类型直接接口的一个有序列表
  • 域信息
  1. 成员变量,所有域的相关信息,以及声明顺序
  2. 域名称、域类型、域修饰符(public、private、protected、static、final、volatile(多线程共享变量,保持最新,从域的地址读值,不进行优化)、transient(不被序列化)的某个子集)
  • 方法信息
  1. 声明顺序
  2. 方法名称、返回类型、参数的数量和类型、修饰符(public、private、protected、static、final、synchronized、native、abstract的一个子集)
  3. 字节码、操作数栈、局部变量表及大小(abstract和native方法除外)
  4. 异常表(abstract和native方法除外),每个异常处理的开始位置、结束为止、代码处理在程序计数器中的偏移位置、被捕获的异常类的常量池索引
  • 常量
  • 静态变量,jdk1.8及之后,在堆中
  1. non-final的类变量,不被final修饰的static修饰的属性,随着累的加载而加载,成为类数据在逻辑上的一部分,被类的所有实例共享,即使没有类的实例也可以访问
  2. final static修饰的变量,在编译的时候就赋值了,只有一次赋值机会,且必须初始化
  • 即时编译器编译后的代码缓存
  • 运行时常量池,将字节码文件中的常量池加载到方法区之后,形成了运行时常量池
  1. 包括各种字面量(字符串、值等),对类型、域和方法的符号引用
  2. 将一个clss文件用到的数据以符号引用对应的方式保存在运行时常量池中,方便调用,压缩文件大小
  3. 本质上是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等
  4. 运行时常量池是方法区的一部分常量池表是class文件的一部分,用于存放编译期生成的字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池
  5. 在加载类和接口后,创建对应的运行时常量池;池中的数据项像一个数组项一样,通过索引访问
  6. 运行时常量池包含多种不同的常量,包括编译期已经明确的数值字面量,也包括运行期编译后,才能获得的方法或者字段引用,此时已经转换为真实地址,具有动态性;实际包含的数据比符号表更加丰富
  7. 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区能提供的最大空间,抛出OOM

方法区演进细节

  • 只有hotspot才有永久代
  • jdk1.6及之前,有永久代,静态变量和字符串常量池存放在永久代上
  • jdk1.7 有永久代,逐步去永久代,字符串常量池,静态变量移除,保存在堆中
  • jdk1.8及之后,无永久代,类型信息、字段、方法、常量保存在本地内存的元空间中,但字符串常量池、静态变量仍在堆中
  1. 永久代设置空间大小是很难确定的,如果空间比较小,加载类过多,容易产生OOM,并且容易触发Full GC
  2. 对永久代进行调优很困难
  3. 元空间并不在虚拟机中,而是使用本地内存,元空间大小仅受本地内存限制
  • 字符串常量池,StringTable的调整
  1. 永久代的回收效率很低,在Full GC/Major GC才会触发,而Full GC只有当老年代和永久代空间不足时才会触发,这就导致StringTable的回收效率不高
  2. 开发中,会有大量字符串被创建,回收效率低,导致永久代内存不足
  3. 放到堆中,能够及时回收
  • 静态变量,jdk1.7及以后,将静态变量与类型在java语言一端映射的class对象存放在一起,存储在java堆之中

方法区垃圾回收

  • 类型的卸载,条件相当苛刻
  • 主要回收两部分,常量池中废弃的常量、和不再使用的类型
  1. 常量包括字面量和符号引用,类和接口的全限定名、字段的名称和描述符、方法的名称和描述符
  2. 类允许被回收的条件。类的所有实例已经被回收,java堆中不存在该类及其任何派生子类的实例;加载该类的类加载器已经被回收,通常很难达成,除非是精心设计的可替换类加载器,如OSGI/JSP;类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
  3. -Xnoclassgc是否对类回收;-verbose:class/-XX:+TraceClass-Loading/-XX:+TraceClassUnLoading,查看类加载和卸载信息

运行时数据区

image.png

面试题

  • JVM内存模型
  1. 三个线程私有(程序计数器+虚拟机栈+本地方法栈)+两个线程共享(方法区+堆区)
  2. 程序计数器,PC寄存器,记录要执行的指令地址;执行本地方法时,则为未指定的值
  3. 虚拟机栈,存放执行方法的栈帧。一个栈帧包括局部变量表、操作数栈、动态链接、方法返回地址和附加信息。局部变量表,数字数组,存储方法参数和定义在方法体重的局部变量;操作数栈,存储中间结果;动态链接,指向运行时常量池的方法引用,该帧调用的方法;方法返回地址,保存调用者的PC寄存器调用的地址,并在结束后返回给调用者,调用者的PC寄存器指向下一个指令的地址;附加信息,java虚拟机实现的相关附加信息,对程序调试提供支持
  4. 本地方法栈,调用本地方法,加载本地方法
  5. 堆,new出来的实例对象和数组都存储在堆中,分为年轻代(eden+s0+s1)、老年代;年轻代触发(YGC),老年代触发(OGC);垃圾回收的重点区域;对象放不下年轻代和老年代,触发Full GC
  6. 方法区,存储类型信息(域信息、方法信息)、运行时常量池;运行时常量池,本质上是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等
  • java8内存改进
  1. 永久代移除,转换为元空间
  2. 静态变量和字符串常量池移动到堆中
  • 栈和堆的区别
  1. 堆中有GC和OOM异常
  2. 栈中没有GC,有StackOverFlowErrorOutOfMemoryError
  3. 栈先进后出,是运行时的单位,物理上地址可以不连续;堆时存储时的单位,解决数据存储的问题,物理上不连续
  4. 栈由系统自动分配,速度较快;堆由new分配的内脆,速度较慢
  5. 栈线程私有,堆线程共享
  6. 栈/堆的内存大小在编译时确定/也可以动态分配,都可以是物理上不连续的内存空间;
  • Eden和Survior的比例分配
  1. 默认 8:1:1,但是实际上是 6:1:1
  2. 设置 -XX:SurvivorRatio=8
  3. Eden区过大,导致S1/S0空间过小;当进行Minor GC,对象无法转移到S1/S0,会直接进入老年代,那么Minor GC就没有什么意义,也淡化了分代的作用
  4. S1/S0过大,Eden过小,YGC出现频率过高,产生STW,影响用户进程
  • 为什么要有新生代和老年代
  1. 不同对象的生命周期不同,为了优化GC性能,可以对不同生命周期的对象进行不同的处理
  2. 如果没有分代,每次都要扫描堆的所有区域,浪费性能
  3. 如果分代,将新创建的对象放在某一个地方,GC后,就将这块存储朝生夕死对象的区域进行回收,腾出空间
  • 什么时候对象会进入老年代
  1. s1/s0的对象的年龄计数器达到阈值
  2. YGC后,Eden/s0/s1放不下
  • 为什么要分Eden和Survivor
  1. 如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很 快被填满,触发Major GC。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比 Minor GC长得多,所以需要分为Eden和Survivor。
  2. Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的 预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。
  • 为什么要设置两个Survivor
  1. 碎片化,堆中的对象都是零散的分布,没有一整块大的区域,会出现剩余空间的总和放得下,但是连续空间放不下
  2. 解决了碎片化,刚刚新建的对象在Eden中,经历一次 Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1
  3. 复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生
  • 永久代垃圾回收

永久代的回收效率很低,在Full GC才会触发,而Full GC只有当年轻代、老年代和永久代空间不足时才会触发

  • Full GC触发条件
  1. 调用System.gc(),系统建议执行Full GC,手动调用
  2. 老年代空间不足
  3. 方法区空间不足
  4. Minor GC之后,进入老年代的对象平均大小大于老年代可用内存
  5. Eden和fromto复制时,对象大小大于to,则需要把该对象转移到老年代,但是对象大小也大于老年代的内存大小
  6. 开发和调优中要尽量避免

对象

实例化

image.png

  • 对象创建的方式
  1. new/静态方法/Builder/Factory,调用构造器,最常见的方式
  2. 反射的方式可以调用无参/带参的构造器,权限没有要求
  3. clone(),不调用任何构造器,当前类需要实现Cloneable接口,实现clone(),浅复制
  4. 反序列化,从文件/网络中获取一个对象的二进制流
  • 对象创建的步骤
  1. 虚拟机遇到一条new指令,首先去检查这个指令的参数是否能在Metaspace的常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否被加载过、解析和初始化(判断类元信息是否存在)。如果没有,在双亲委派模式(优先找到顶层类加载)下,使用以ClassLoader+包名+类名为key进行查找对应的.class文件,如果没有找到,则抛出ClassNotFoundException异常;如果找到,则进行类加载(方法区),并生成对应的class对象
  2. 计算对象占用空间大小,在堆中划分一块内存空间给新的对象,如果实例成员变量是引用变量,仅分配引用变量空间即可(指针,4个字节);如果内存是规整的,采用指针碰撞,即所有用过的内存在一边,空闲的内存在另一边,中间放着一个指针作为分界点的指示器,分配内存时,将指针向空闲的一边移动与对象大小相等的距离,垃圾收集器选择的标记压缩算法Serial/ParNew这种基于压缩算法,虚拟机就采用这种方式,一般带有compact(整理)过程的收集器,使用指针碰撞;如果内存是不规整的,已经使用的内存和未使用的内存相互交错,将采用空闲列表法/标记清除算法CMS,即维护一个列表,记录哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象,并更新列表上的内容;具体那种方式,取决于java是否规整,而是否规整取决于采用的垃圾收集器是否带有压缩整理功能
  3. 堆空间共享,会出现并发安全问题
  4. 对对象的属性进行默认初始化(赋值类型的零值)
  5. 将对象的所属类(指向方法区),类元信息、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中,具体方式由JVM实现
  6. 初始化成员变量,执行实例化代码块,调用类的构造器方法,并把堆内对象的首地址赋值给引用变量
  • 实例化过程(简化)
  1. 加载类元信息(方法区)
  2. 为对象分配内存(堆空间)
  3. 处理并发问题
  4. 属性默认初始化(零值初始化)
  5. 设置对象头信息
  6. 属性显示初始化(先进行)/代码块中初始化,构造器初始化
  7. 执行完后,对象算完整的创建出来

内存布局

  • 堆空间

image.png

  1. 堆空间的地址,给栈空间的引用变量;类型指针,指向方法区,确定该类的类型,并不是所有对象都会保存类型指针;如果是数组,还需要保存数组的长度
  2. 先放父类的实例变量,再放本类的

image.png

  • 用klass描述java的类型信息

访问定位

  • 栈帧的引用变量访问对象

image.png image.png

  • 引用对象先指向句柄池,需要为句柄池开辟空间,并且通过句柄池间接访问,效率比较低,浪费空间;对象被移动(垃圾收集时会移动对象,标记整理算法/YGC)时,只会改变句柄中的实例数据指针即可,引用本身不需要改变,很稳定

image.png

  • 引用对象直接指向对象实例,一步到位,效率高,不用专门开辟空间;当对象被移动时,需要修改引用变量地址

image.png

面试题

  • 对象在JVM怎么存储的
  1. 通过类加载,将类元信息加载到方法区中
  2. new对象实例时,将类的对象头信息、实例数据等存放在类中
  • 对象头信息里面有哪些内容
  1. 运行时元数据,哈希值(映射地址,由系统随机给出,是对象的地址值,是一个逻辑地址,模拟的出来的地址,并不是数据实际存储的物理地址),GC分代年龄(年龄计数)、锁状态标志、线程持有的锁、偏向线程id、偏向时间戳
  2. 类型指针,指向类元数据,确定该对象所属的类型(方法区)
  3. 如果是数组,还需要记录数组的长度

实例

  • 对于成员变量的初始化,会在构造函数后进行,先进行显示初始化,在进行静态代码块
 public String name = "dwada";
{
    name = "dwadasdada";
}

//字节码文件,构造函数中
0: aload_0
1: invokespecial #1                  // Method java/lang/Object."<init>":()V
4: aload_0
5: ldc           #2                  // String dwada
7: putfield      #3                  // Field name:Ljava/lang/String;
10: aload_0
11: ldc           #4                  // String dwadasdada
复制代码
  • 字节码标识
  1. Boolean Z
  2. Byte B
  3. Char C
  4. Short S
  5. Int I
  6. Long J
  7. Float F
  8. Double D
  9. Void V
  10. objects对象 以L开头,以;结尾,中间是用/隔开的包及类名。比如:Ljava/lang/String;如果是嵌套类,则用$来表示嵌套。例如(Ljava/lang/String;Landroid/os/FileUtils$FileStatus;)Z

猜你喜欢

转载自juejin.im/post/7017026897972297736