Skip to content

运行时内存篇

字数: 0 字 时长: 0 分钟

JVM 架构图.webp

我们再来看一下 JVM 架构图,类加载器将 .class 字节码文件加载完毕后,就到了运行时数据区了。

1. 程序计数器 PC Register

从上面的 JVM 架构图可以看出来,程序计数器是线程私有的,它的核心作用是充当执行指令的位置指针,记录当前线程执行字节码的行号指示器

程序计数器记录当前线程正在执行的字节码行号

java
public class CounterExample {
    public static void main(String[] args) {
        int a = 1;  // PC: 0
        int b = 2;  // PC: 1
        int c = a + b; // PC: 2
        System.out.println(c); // PC: 3
    }
}

看得出来,程序计数器的大小在字节码确定时就已经确定了,不会随着程序的运行而变大,因此它是唯一不会 OOM 的区域

提示

由于 native 本地方法不是通过 Java 实现的,未编译成需要执行的字节码指令,所以在计数器当中是空 (undefined)

2. 虚拟机栈 JVM Stack

栈的单位是栈帧 Stack Frame ,每一个栈帧都对应着一个方法。

栈顶的栈帧就是当前栈帧,对应着当前方法;定义该方法的类就是当前类,当前方法执行完毕后,当前栈帧就弹出,执行下一个方法。

当前方法栈帧抛出异常后,当前栈帧也会弹出,比如栈帧4抛出异常,被弹出后,异常就抛给了栈帧3(也就是调用异常方法的方法)

方法与栈帧关系.webp

栈帧的内部结构包含 4 个部分:局部变量表、操作数栈、动态链接、方法返回地址

栈帧内部结构.webp

局部变量表

局部变量表负责存储方法参数局部变量

局部变量表存储内容代码示例

java
public class LocalVariableTableTest {

    private int count;
    
    /**
     *  main() 局部变量表有3个变量   args 、 test 、 num
     * 
     */
    public static void main(String[] args) {
        LocalVariableTableTest test = new LocalVariableTableTest();
        int num = 10;
        test.test1();
    }

    /**
     * test1() 局部变量表有3个变量 date 、 name1 、 this (非静态方法默认有 this)
     */
    public void test1() {
        Date date = new Date();
        String name1 = "猫猫";
        test2(date,name1);
    }

    /**
     * 局部变量表一个变量  this
     */
    public LocalVariableTableTest(){
        this.count = 1;
    }

    /**
     *  局部变量表 5 个变量 : this , date , name , weight , gender
     */
    public String test2(Date date,String name) {
        date = null;
        name = "cat";
        // double 、long 会占用两个 slot 存储
        double weight = 130.5;
        char gender = '男';
        return date + name;
    }


    /**
     * slot 的复用,总 slot 长度 3: this a c 
     */
    public void test3() {
        int a = 0;
        // 出了非静态代码快作用域, 变量 b 就没用了,因此 后面变量 c 会复用 b 的 slot
        {
            int b = 0;
            b = a + 1;
        }
        int c = a + 1;
    }

    /**
     * 只会对成员变量赋初始值,不会对局部变量赋初始值
     */
    public void test4() {
        int num;
        System.out.println(num); // 错误信息,变量 num 未初始化
    }
    
}

操作数栈

操作数栈是执行引擎的工作区,主要用来保持方法执行过程中的中间结果(本地变量)。比如:如果被调用方法带有返回值,返回值将被压入当前栈帧的操作数栈中

java
// 字节码实现:
iload_1    // 加载本地变量1到栈顶
iload_2    // 加载本地变量2到栈顶
iadd       // 栈顶两个int值相加,结果入栈
istore_3   // 结果存储到本地变量3

栈顶缓存技术

由于操作数是存储在内存中,因此频繁地执行内存读写操作必然会影响执行速度。 HotSpot JVM 的设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在物理 CPU 的寄存器中,提升执行引擎效率

如何设置栈内存的大小

-Xss 1m (jdk8 默认为 1m),完整命令: -XX:ThreadStackSize

栈的大小直接决定了函数调用的最大可达深度,设置栈空间值过大,会导致系统用于创建线程的数量减少,一般一个进程中可以有 3000 ~ 5000 个线程

动态链接

  • 静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译器可知,且运行期间保持不变时,将调用方法的符号引用转换为直接引用的过程称为静态链接
  • 动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,称为动态链接

动态链接 动态链接.webp

方法返回地址

  • 正常返回:调用者程序计数器的值
  • 异常返回:异常处理器表

虚拟机栈问题小结

1. 栈溢出的情况?

递归调用的时候,比如在 main 方法中调用 main 方法,就会不断压栈执行,导致 StackOverflowError; 如果设置 JVM 栈大小为动态变化的,就有可能随着越来越大导致 OutOfMemory

2. 分配的栈内存越大越好吗?

虽然栈内存设置得越大,栈溢出风险就越小;但是在一定空间下,每个线程的栈越大,能创建的线程数量就变少了

3. 局部变量表存储的变量是否线程安全

java
//这种方式是线程安全的,因为线程私有,其他线程不会调用
public static void method1() {
    // StringBuilder 线程不安全
    StringBuilder s1 = new StringBuilder();
    s1.append("hello");
}

//这种局部变量不是线程安全的,从外部传进来,有可能被多个线程调用
public static void method2(StringBuilder stringBuilder) {
    stringBuilder.append("hello");
}

3. 本地方法栈

本地方法栈为 JVM 调用本地方法 (C/C++ 实现)提供内存支持,HotSpot 中 JVM栈和本地方法栈合并。

4. 堆 Heap

堆在 JVM 启动的时候就被创建,它是 JVM 管理的最大的一块内存空间,它用来存储所有的对象实例以及数组,因此也是 GC 执行垃圾回收的重点区域

所有的线程共享 Java 堆,但Eden 区还为每个线程分配了一个私有缓存区域 TLAB (Thread Local Allocation Buffer)

堆的内部结构 堆的内部结构.webp

如何设置堆内存的大小?

  • -Xms : 设置堆的起始内存大小
  • -Xmx : 设置堆的最大内存大小
  • 一旦堆区中的内存大小超过 -Xmx 所指定的最大内存时,将会抛出 OutOfMemoryError 异常
  • 通常将 -Xms-Xmx 两个参数配置相同的值,目的是为了能够在 Java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能
  • 默认最大值:物理内存小于 192 Mb,则取物理内存的一半;物理内存大于等于 1G,取最大物理内存的 1/4;
  • 默认最小值: 最少不得 8M,如果物理内存大于等于 1G,那么默认值为 物理内存的 1/64;最小堆内存在 jvm 启动的时候就会被初始化

新生代与老年代比例

  • 默认 -XX:NewRatio=2,表示新生代占 1,老年代占2,新生代占整个堆的 1/3
  • 可以修改为 -XX:NewRatio=4,表示新生代占1,老年代占4

Eden 与 幸存区比例

HotSpot 中,Eden 空间与两外两个 Survivor 空间默认比例为 8:1:1

空间分配担保原则

当新生代的堆空间不足时(主要触发场景是 Eden 区满了),会发生 Minor GC ,此时虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间

  • 如果大于,则此次 Minor GC 是安全的 (理论上极端情况下,这个 Minor GC 没有对象被回收掉的话,都转到老年代,需要保证老年代空间足够)
  • 如果小于,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允许担保失败
  • 如果 HandlePromotionFailure=true 那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
    • 如果大于,则尝试进行一次 Minor GC
    • 如果小于或者 HandlePromotionFailure=false 则进行一次 Full GC
  • HandlePromotionFailure 在 JDK 6 update 24 之后已不由用户控制,源码中写死为 true

对象分配原则

  • 优先分配到 Eden
  • 大对象直接分配到老年代,避免程序出现过多的大对象
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断
    • 如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄

对象分配原则.webp

Full GC 触发机制

  • 调用 System.gc() 时,系统建议执行 Full GC,但不是必然执行
  • 老年代空间不足
  • 方法区空间不足
  • 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存
  • 由 Eden 区、 survivor 区向空的那个 survivor 区复制时,对象大小大于空的那个 Survivor 区的可用内存,则把对象转存老年代,但老年代可用内存小于该对象大小

OOM 如何解决

  • 要解决 OOM 异常或 heap space 异常,一般是首先通过内存映像分析工具分析,确认内存中的对象是否是必要的,先分清楚到底是内存泄露还是内存溢出
  • 如果是 内存泄露 ,可用进一步查看泄露对象到 GC Roots 的引用链,定位出泄露代码的位置
  • 如果不存在内存泄露,换句话说就是内存中的对象确实都还必须存活着,哪就应当检查虚拟机的堆参数设置是否合理;从代码上检查是否存在某些对象生命周期过长的情况

什么是 TLAB ?

从内存模型而不是垃圾收集的角度,对 Eden 区继续进行划分, JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内

TLAB.webp

shell
//默认就是开启的 , 默认只占 Eden 空间的1%,可通过 -XX:TLABWasteTargetPercent 设置该值
PS E:\venti\venti-java> jps
34672 Launcher
42288 GCTest
15460 Jps
26260 Main
35428 Main
9692 Main
PS E:\venti\venti-java> jinfo -flag UseTLAB 42288
-XX:+UseTLAB

5. 方法区

方法区存储内容

尽管方法区在逻辑上是属于堆的一部分,但一些简单的JVM实现可能不会选择去进行垃圾收集或者进行压缩。但对于 HotSpotJVM 而言,方法区还有一个别名叫做 Non-Heap 非堆,目的就是要和堆分开。

所以,方法区看作是一块独立于 Java 堆的内存空间。

jdk7 及之前,方法区被称为永久代;jdk8开始,使用元空间取代了永久代。

栈、堆和方法区的关系 栈、堆和方法区的关系.webp

方法区常用参数

java
//元空间大小默认值依赖平台,Windows 下 默认值 21M ,最大值没有限制,因为使用的是本地内存
-XX:MetaspaceSize=21M
-XX:MaxMetaspaceSize=-1

对于 64 位的服务器端 JVM 来说,默认的 Metaspace 大小是 21MB。这就是初始的高水位线,一旦触及这个水位线将会触发 Full GC 并卸载没用的类(这些类对应的类加载器不再存活),然后这个高水位线将会重置。

如果初始化的高水位线设置过低,上述高水位线调整情况将会发生很多次,通过垃圾回收器的日志可以观察到 Full GC 多次调用。为了避免频繁的 Full GC ,可以设置 -XX:MetaspaceSize 为一个相对较高的值。

6. StringTable 字符串常量池

字符串常量池在 JDK 1.6 之前位于方法区的永久代,JDK 1.7 及之后迁移到了堆空间中,因为堆内存可动态调整而且 GC 效率更高

String 不可变性

String 被声明为 final,一旦声明不再会被改变

java
/**
 * 字面量定义方式,存储在字符串常量池中,不允许存放相同的字符串,一旦声明不再会被改变
 *      字符串常量池是一个固定大小的 Hashtable : 数组加链表的结构
 *      默认值大小是固定的,使用 -XX:StringTableSize 参数可以调整大小
 *      如果常量池中的字符串过多,会造成 Hash 冲突严重,从而链表长度变长,影响 String.intern() 效率
 *      String.intern() 方法会先判断常量池中是否有相同的字符串,如果有,则返回常量池中的字符串,如果没有,则将字符串放入常量池中,并返回字符串的引用
 *      jdk8 开始, StringTableSize 的最小值可设置为 1009,再小会报错
 * 
 */
String str1 = "hello";
//new 方法
String str2 = new String("hello");

String 底层演进

jdk8 中 String 底层是 char[] 实现的;但 jdk9 中 String 底层是 byte[] 加上编码标记实现的,如果是拉丁字母,则使用 byte[] 实现,否则使用 char[] 实现,节省了一些空间。

java
//--------- jdk9 ----------

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence,
               Constable, ConstantDesc {
    
    @Stable
    private final byte[] value;

字符串拼接操作

  • 常量与常量的拼接在常量池中完成,原理是编译器优化
  • 常量与变量的拼接在堆中完成,原理是 StringBuilder
  • String.intern() 方法会先判断常量池中是否有相同的字符串,如果有,则返回常量池中的字符串,如果没有,则将字符串放入常量池中,并返回字符串的引用
java
String s1 = "hello";
String s2 = "world";

String s3 = "helloworld";
String s4 = "hello" + "world";

String s5 = s1 + "world";
String s6 = "hello" + s2;
String s7 = s1 + s2;
String s8 = s6.intern();
final String s9 = "hello";
final String s10 = "world";

System.out.println(s3 == s4);//true 常量池中完成,编译期优化认为他们都是 "helloworld"
System.out.println(s3 == s5);//false
System.out.println(s3 == s6);//false
System.out.println(s3 == s7);//flase
System.out.println(s5 == s6);//flase
System.out.println(s3 == s9 + s10);//true final 修饰的字符串引用仍然使用编译期优化
//最佳实践,对于类、方法、基本数据类型、引用数据类型,能使用上 final 的时候建议都使用上,会提高性能

System.out.println(s3 == s8);//true s8 是 s6 的 intern() 方法返回值

字符串拼接与 StringBuilder.append() 的区别

StringBuilderappend() 方式添加字符串的效率要远高于使用 String 的字符串拼接方式!

  • append() 方式,自始至终只创建一个 StringBuilder 对象,然后通过 append() 添加字符串片段,最后通过 toString() 方法获取拼接后的字符串。
  • 使用 String 字符串拼接方式会创建多个 StringBuilderString 对象,内存占用更大, GC 开销也更大

String 对象创建

java

 /**
         * 这会创建两个对象
         *      new String("ab") 对象创建在堆中
         *      "ab" 创建在字符串常量池中
         *      因此建议直接 String ab = "ab"; 避免不必要的字符串创建
         */
        String ab = new String("ab");  //执行完后会在字符串常量池中生成 "ab"

        /**
         *  对象1: new String("c")
         *  对象2: "c"
         *  对象3: new String("d")
         *  对象4: "d"
         *  对象5: new StringBuilder() 用于拼接操作
         *  对象6: StringBuilder().toString()
         *              new String("cd")
         * 
         */
        String cd = new String("c") + new String("d"); //执行完后不会在字符串常量池中生成 "cd"

intern() 调用

java
  String s3 = new String("1") + new String("1");
  String s4 = "11";
  // 因为 "11" 在常量池中已经存在,则返回常量池中 "11" 的地址
  String intern = s3.intern();
  System.out.println(s3 == s4); //false
  System.out.println(intern == s4); //true
  
  String s5 = new String("3") + new String("4");  
  // 因为 "34" 在常量池中不存在,则将 "34" 放入常量池中,并返回常量池中 "34" 的地址
  s5.intern();
  String s6 = "34"; 
  System.out.println(s5 == s6); //ture

最佳实践

对于程序中大量存在的字符串,特别是重复的字符串时,使用 intern() 会节省内存空间,提高效率。