当前位置: 首页>后端>正文

JVM 内存详细剖析

内存模型

JVM 内存详细剖析,第1张
JVM 内存模型.png

方法区(Method Area)

假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入 JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待。方法区是 JVM 对内存的”逻辑划分”,在 HotSpot 虚拟机中:JDK1.7 及之前 ,使用永久代; JDK1.8 及以后使用元空间。

  • JDK1.7 中将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在非堆内存中
  • JDK1.8 用元空间(class metadata)代替了之前的永久代,并且存储位置是本地内存,大小只受本机总内存的限制(如果不设置参数的话)。

参数设置(初始值和最大值):

  • JDK1.7:-XX:PermSize -XX:MaxPermSize
  • JDK1.8:-XX:MetaspaceSize -XX:MaxMetaspaceSize

1. Class 常量池(静态常量池,Constant Pool Table)

class 文件中有一项信息是常量池,用于存放编译期间生成的各种字面量和符号引用

  1. 字面量:给基本类型变量赋值的方式就叫做字面量或者字面值。比如:String a=“b” ,这里“b”就是字符串字面量,同样类推还有整数字面值、浮点类型字面量、字符字面量。
  2. 符号引用:符号引用以一组符号来描述所引用的目标,如 org.simple.Tool

2. 运行时常量池(Runtime Constant Pool)

  • 将符号引用替换成直接引用

运行时常量池相对于 Class 常量池的一个重要特征是具备动态性。运行时常量池是每一个类或接口的常量池的运行时表示形式,它包括了若干种不同的常量:从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。

运行时常量池是在类加载完成之后,将 Class 常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用

在类装载器装载类时,可以通过运行时常量池获取 Tool 类的实际内存地址,因此便可以既将符号 org.simple.Tool 替换为Tool 类的实际内存地址。

为什么移除永久代而使用元空间?

  1. 移除永久代是为了融合 HotSpot 与 JRockit 而做出的努力,因为 JRockit 没有永久代,所以不需要配置永久代。
  2. 永久代内存经常不够用或发生内存溢出,抛出异常 java.lang.OutOfMemoryError: PermGen。这是因为在 JDK1.7 版本中,指定的 PermGen 区大小为 8M,由于 PermGen 中类的元数据信息在每次 FullGC 的时候都可能被收集,回收率都偏低,成绩很难令人满意;还有为 PermGen 分配多大的空间很难确定,PermSize 的大小依赖于很多因素,比如,JVM 加载的class 总数、常量池的大小和方法的大小等。

参数:-Xms -Xmx

  • 新生代-Xmn -XX:NewSize -XX:MaxNewSize -XX:SurvivorRatio Eden 与 Survivor 区的比例
    • Eden
    • Survivor
      • From
      • To
  • 老年代 Tenured

字符串常量池(没有这个概念的官方定义)

String 对象是对 char 数组进行了封装实现的对象,主要有2 个成员变量:char 数组,hash 值。

String 对象的不可变性 - final

  1. 保证 String 对象的安全性
  2. 保证 hash 属性值不会频繁变更,确保了唯一性。实现 HashMap key-value 功能。
  3. 可以实现字符串常量池

String 的创建方式及内存分配的方式

  1. String str = “abc”;
    • 首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中被创建。
    • 代码编译加载时,会在常量池中创建常量“abc”,运行时,返回常量池中的字符串引用
  2. String str = new String(“abc”);
    • 首先在编译类文件时,"abc" 常量字符串将会放入到常量结构中,在类加载时,“abc" 将会在常量池中创建;其次,在调用 new 时,JVM 命令将会调用 String 的构造函数,同时引用常量池中的"abc” 字符串,在堆内存中创建一个 String 对象;最后,str 将引用 String 对象。
    • 返回 String 对象的引用,String 对象引用常量池中的 char[] 数组
      1. 代码编译加载时,会在常量池中创建常量“abc"
      2. 在调用 new 时,会在堆中创建 String 对象,并引用常量池中的字符串对象 char[] 数组。
      3. 返回 String 对象的引用。
  3. 对象成员变量是 String,new 这个对象时
    • 引用了常量池中的字符串对象
    • 使用 new,对象会创建在堆中,同时赋值的话,会在常量池中创建一个字符串对象,复制到堆中。具体的复制过程是先将常量池中的字符串压入栈中,在使用 String 的构造方法时,会拿到栈中的字符串作为构造方法的参数。这个构造函数是一个 char 数组的赋值过程,而不是 new 出来的,所以是引用了常量池中的字符串对象。存在引用关系。
  4. String str = "ab"+ "cd"+ "ef";
    • 编译器自动优化为 String str= "abcdef";
  5. 大循环使用,循环体内拼接
    • 编译器自动优化为 StringBuilder 拼接
  6. String#intern
    • String a = new String("king").intern(); String b = new String("king").intern();
    • a 和 b 引用的是同一个对象
    1. new Sting() 会在堆内存中创建一个a 的 String 对象,"king" 将会在常量池中创建
    2. 在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用
    3. 调用 new Sting() 会在堆内存中创建一个 b 的 String 对象
    4. 在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用
    5. 所以 a 和 b 引用的是同一个对象

虚拟机栈

栈的生命周期是和线程一样的,每一个方法对应一个栈帧

虚拟机栈的大小缺省为 1M,可用参数 –Xss 调整大小,例如 -Xss256k

栈帧

在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦方法完成相应的调用,则出栈。

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

  1. 局部变量表

    • 就是局部变量的表,用于存放局部变量的(方法中的变量)。首先它是一个32 位的长度,主要存放我们的 Java 的八大基础数据类型,一般 32 位就可以存放下,如果是 64 位的就使用高低位占用两个也可以存放下;如果是局部的一些对象,只需要存放它的引用地址。
  2. 操作数栈

    • 存放 java 方法执行的操作数的。操作数栈,就是用来操作的,操作的元素可以是任意的 java 数据类型,所以一个方法刚刚开始的时候,这个方法的操作数栈就是空的。
    • 操作数栈本质上是 JVM 执行引擎的一个工作区,也就是方法在执行,才会对操作数栈进行操作,如果代码不不执行,操作数栈其实就是空的。
  3. 动态链接

    • 部分符号引用在运行期间转化为直接引用,这种转化就是动态链接

    • 多态(方法重写):动态分派(invokevirtual invokeinterface 字节码指令))

    • Lambda 表达式(invokedynamic字节码指令 )

  4. 返回地址

    • 记录上一个方法执行到哪里,方法执行完传给程序计数器,继续执行上一个方法。
    • 三步曲:
      1. 恢复上层方法的局部变量表和操作数栈
      2. 把返回值(如果有的话)压入调用者栈帧的操作数栈中
      3. 调整程序计数器的值以指向方法调用指令后面的一条指令
        异常的话:(通过异常处理表<非栈帧中的>来确定)异常处理器表在 class 文件里。

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

在一般的模型中,两个不同的栈帧的内存区域是独立的,但是大部分的 JVM 在实现中会进行一些优化,使得两个栈帧出现一部分重叠。主要体现在方法中有参数传递的情况,让下面栈帧的操作数栈和上面栈帧的部分局部变量重叠在一起,这样做不但节约了一部分空间,更加重要的是在进行方法调用时就可以直接公用一部分数据,无需进行额外的参数复制传递了。

本地方法栈

本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法

HotSpot 直接把本地方法栈和虚拟机栈合二为一

程序计数器

较小的内存空间,主要用来记录各个线程执行的字节码的地址(当前线程执行的字节码的行号指示器)。各线程之间独立存储,互不影响。

在运行 Java 方法的时候需要使用程序计数器记录字节码执行的地址或行号,如果是遇到本地方法(native 方法),因为这个方法不是 JVM 来具体执行,所以程序计数器不需要记录了,因为在操作系统层面也有一个程序计数器,会记录本地代码的执行的地址,所以在执行native 方法时,JVM 中程序计数器的值为空(Undefined)

程序计数器是 JVM 中唯一不会 OOM(OutOfMemory) 的内存区域。

直接内存(堆外内存)

可以理解成没有被虚拟机化的操作系统上的其他内存,它不是虚拟机运行时数据区的一部分,也不是 java 虚拟机规范中定义的内存区域。

操作方法:

  1. NIO DirectByteBuffer
    • 可以通过 -XX:MaxDirectMemorySize 来限制大小
    • 默认与堆最大值(最大可使用值,减去一个幸存区的大小)一样
  2. Unsafe:不推荐使用
  3. JNI: Java Native Interface。
    • 可以确保代码在不同的平台上方便移植
  4. JNA: Java Native Access
    • 提供一组 Java 工具类用于在运行期间动态访问系统本地库(native library:如 Window 的 dll)而不需要编写任何 Native/JNI 代码。开发人员只要在一个 java 接口中描述目标 native library 的函数与结构,JNA 将自动实现 Java 接口到 native function 的映射。
    • JNA 是建立在 JNI 技术基础之上的一个 Java 类库,它使您可以方便地使用 java 直接访问动态链接库中的函数。原来使用 JNI,必须手工用 C 写一个动态链接库,在 C 语言中映射 Java 的数据类型。JNA 中,提供了一个动态的 C 语言编写的转发器,可以自动实现 Java 和C 的数据类型映射,不再需要编写 C 动态链接库。使用 JNA 技术比使用 JNI 技术调用动态链接库会有略微的性能损失。但总体影响不大,因为 JNA 也避免了 JNI 的一些平台配置的开销。

JVM 源码中的堆最大值的大小

需要减去一个幸存区的大小。

java:

ByteBuffer#allocateDirect -> Bits#reserveMemory -> VM#maxDirectMemory

    // 这只是初始化值,并不是真的默认值
    private static long directMemory = 64 * 1024 * 1024;
    public static long maxDirectMemory() {
        return directMemory;
    }

System#initPhase1 -> VM#saveProperties

// 这里才是给 directMemory 赋值的语句
directMemory = Runtime.getRuntime().maxMemory();
package java.lang;
public class Runtime {
    // native 方法对应 JVM 源码: Java_java_lang_Runtime_maxMemory()
    public native long maxMemory();
}

JVM:

Runtime.c

JNIEXPORT jlong JNICALL
Java_java_lang_Runtime_maxMemory(JNIEnv *env, jobject this)
{
    return JVM_MaxMemory();
}

jvm.cpp

JVM_ENTRY_NO_ENV(jlong, JVM_MaxMemory(void))
    // max_capacity() 获得堆最大值
  size_t n = Universe::heap()->max_capacity();
  return convert_size_t_to_jlong(n);
JVM_END

defNewGeneration.cpp

size_t DefNewGeneration::max_capacity() const {
  const size_t reserved_bytes = reserved().byte_size();
    // 堆最大值 = 配置的最大值 - 一个 survivor 的大小
  return reserved_bytes - compute_survivor_size(reserved_bytes, SpaceAlignment);
}

直接内存的优缺点

优点:

  • 减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作
  • 加快了复制的速度:因为堆内在 flush 到远程时,会先复制到直接内存(非堆内存),然后再发送,而堆外内存相当于省略掉了这个工作
  • 可以在进程间共享,减少 JVM 间的对象复制,使得 JVM 的分割部署更容易实现
  • 可以扩展至更大的内存空间:比如超过 1TB 甚至比主存还大的空间

缺点:

  • 堆外内存难以控制,如果内存泄漏,那么很难排查
  • 堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象比较适合

比如:EhCache 可以设置将数据存在非堆上,RocketMQ 走的堆外内存。

内存溢出

类型 JVM 参数 异常类型及原因 说明
栈溢出 -Xss1m StackOverflowError无限递归
OOM机器没有足够的内存
只限制单个虚拟机栈的大小
堆溢出 -Xms -Xmx OOM:Java heap space堆不够
OOM:GC overhead limit exceededGC 占据 98% 的资源,回收不足 2%
内存泄漏
堆参数调整
代码不合理
方法区溢出 -XX:PermSize
-XX:MaxPermSize
-XX:MetaspaceSize
-XX:MaxMetaspaceSize
OOM:Metaspace运行时常量池溢出
方法区中保存的 Class 对象没有被及时回收掉或者 Class 信息占用的内存超过了配置
直接内存溢出 -XX:MaxDirectMemorySize OOM:Direct buffer memory 发生了 OOM,同时 Dump 文件很小且没有什么明显的异常情况

对象

对象的创建过程

  1. 类加载:就是把 class 加载到 JVM 的运行时数据区的过程
  2. 检查加载(遇到 new 指令):如果没被类加载器加载,先执行加载过程
    • 首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查类是否已经被加载、解析和初始化过。
  3. 分配内存
    1. 划分内存
      • 指针碰撞
      • 空闲列表
    2. 并发安全
      • CAS 加失败重试
      • TLAB 本地线程分配缓冲
  4. 内存空间初始化:注意不是构造方法,赋零值
    • 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值。
  5. 设置:设置对象头
  6. 对象初始化:构造方法

划分内存

选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

如果是 Serial、ParNew 等带有压缩的整理的垃圾回收器的话,系统采用的是指针碰撞,既简单又高效。如果是使用 CMS 这种不带压缩(整理)的垃圾回收器的话,理论上只能采用较复杂的空闲列表。

指针碰撞

如果 Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。

空闲列表

如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。

并发安全

CAS 加失败重试

对分配内存空间的动作进行同步处理,保证更新操作的原子性。

TLAB 本地线程分配缓冲(Thread Local Allocation Buffer)

参数:-XX:+UseTLAB

把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块私有内存,也就是本地线程分配缓冲 ,JVM 在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个 Buffer,如果需要分配内存,就在自己的 Buffer 上分配,这样就不存在竞争的情况,可以大大提升分配效率,当 Buffer 容量不够的时候,再重新从Eden 区域申请一块继续使用。

TLAB 的目的是在为新对象分配内存空间时,让每个 Java 应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。

TLAB 只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个 TLAB 用满(分配指针 top 撞上分配极限 end 了),就新申请一个TLAB。

TLAB 一般是 Eden 区的 1%,对象空间大,超过 TLAB 的大小就使用 CAS

对象的分配策略

JVM 内存详细剖析,第2张
JVM 对象分配策略.png
  1. 栈上分配:开启逃逸分析;不需要垃圾回收了

  2. 大对象直接进入老年代 -XX:PretenureSizeThreshold

    • 避免大量内存复制
    • 避免提前进行垃圾回收,明明内存有空间进行分配
  3. 对象优先在 Eden 区分配

  4. 本地线程分配缓冲(TLAB)

  5. 长期存活对象进入老年区 -XX:MaxTenuringThreshold

    • 对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1
    • 当 GC 年龄到达 15 (4 位二进制,最大表示到 15)、CMS 是 6 时,进入老年代
  6. 对象年龄动态判定

    • 如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代
  7. 空间分配担保 -XX:HandlePromotionFailure

    • 在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间

      • 如果这个条件成立,那么Minor GC 可以确保是安全的。

      • 如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。

        如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
        如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的,如果担保失败则会进行一次Full GC;
        如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC。

对象的内存布局

  • 对象头

    • Mark Word:64 bit
    • 类型指针 klass pointer:默认使用了指针压缩技术为 32 bit,否则是 64 bit
    • 若为对象数组,还应有记录数组长度的数据
  • 实例数据

  • 对齐填充:8 字节的整数倍

Mark Word

JVM 内存详细剖析,第3张
Mark Word.png

判断对象的存活

可达性分析

来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots 没有任何引用链相连时,则证明此对象是不可用的。

GC Roots

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI 引用的对象:Native 方法
  • JVM 的内部引用
    • class 对象
    • 异常对象 NullPointException、OutofMemoryError
    • 系统类加载器
  • 所有被同步锁(synchronized)持有的对象
  • JVM 内部的 JMXBean、JVMTI 中注册的回调、本地代码缓存等
  • JVM 实现中的“临时性”对象,跨代引用的对象

Class 对象的回收条件

Class 要被回收,条件比较苛刻,仅仅是可以,不代表必然。

  1. 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例
  2. 加载该类的 ClassLoader 已经被回收
  3. 该类对应的Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
  4. 参数控制,-Xnoclassgc

Finalize 方法(不建议使用)

  • 在垃圾回收时执行,可在该方法中拯救(连接GC Root)
  • finalize 执行第一次,但是不会执行第二次
  • 执行优先级低,无法控制执行时机

对象的访问定位

通过栈上的 reference 数据来操作堆上的具体对象。

句柄

Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

使用句柄来访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要修改.

直接指针

reference 中存储的直接就是对象地址。

使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

HotSpot 使用直接指针的方式。

引用类型

  • 强引用=:根可达就不会回收
  • 软引用 SoftReference:OOM 前回收。可以构建缓存
  • 弱引用 WeakReference:GC 时回收。WeakHashMap ThreadLocal
  • 虚引用 PhantomReference:随时会被回收掉,一般用不到

https://www.xamrdz.com/backend/3xt1940344.html

相关文章: