Skip to content

运行时内存篇

运行时内存.png

程序计数器

程序计数器有什么用?

  • 为了保证程序(进程)能够连续的执行下去,CPU 在不同进程之间来回切换时需要确定下一条指令的地址,因此程序计数器通常又称为指令计数器。

  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • PC 寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。执行引擎的字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令

为什么执行 native 方法时,是 undefined

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

基本特征

  • 它是一块很小的内存空间,也是运行速度最快的存储区域,不会随着程序的运行需要更大的空间
  • 在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致
  • 它是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 内存溢出异常的区域

虚拟机栈

如何理解 栈管运行,堆管存储

  • 栈空间小,运行速度快;堆空间大,运行速度慢
  • 栈不存在 GC ,堆 GC
  • 每个一个栈帧都对应着一个方法,方法的执行就在栈里面,也就是栈管运行;对象存储在堆中,也就是堆管存储

栈存在 GC 吗

栈不存在 GC

可能抛出的异常

StackOverFlowOutOfMemory

如何设置栈内存的大小

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

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

栈的单位:栈帧 Stack Frame

方法和栈帧的关系

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

方法与栈帧关系.png

栈的 FILO 原理

JVM 直接对 Java 栈的操作只有两个: 每个方法执行,伴随着入栈;方法执行结束,伴随着出栈。

方法执行结束后 return 会栈帧弹出,抛出异常也会栈帧弹出,比如栈帧4抛出异常弹出后,异常就被抛给了下面的栈帧3(也就是异常方法的调用者)

栈的FILO原理.png

栈帧的内部结构

局部变量表

  • 局部变量表也被称为局部变量数组或本地变量表
  • 定义一个数字数组,主要用于存储方法参数和定义在方法体内部的局部变量,数据类型包括基本数据类型、对象引用、 returnAddress 类型
  • 局部变量表所需的容量大小是在编译器确定下来的,在方法运行期间不会改变局部变量表的大小
  • 方法嵌套调用的次数由栈的大小决定,一般来说,栈越大,方法嵌套调用次数越多。对一个方法而言,参数和局部变量越多,局部变量表越大,栈帧就越大,进而函数调用就会占用更多的栈空间,导致嵌套调用次数越少
  • 局部变量表中的变量只在当前方法调用中有效。在方法调用结束后,局部变量表随着栈帧的销毁而销毁
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 未初始化
    }
    
}

操作数栈

操作数栈主要用于保持计算过程的中间结果,同时作为计算过程中变量临时的存储空间

  • JVM 的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
  • 每一个独立的栈帧中除了包含局部变量表以外,还包含一个操作数栈,也可以称为表达式栈 (Expression Stack)
  • 操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
  • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了
  • 栈中的任何一个元素都可以是任意的 Java 数据类型, 32 bit 的类型占用一个栈单位深度;64 bit 的类型占用两个栈单位深度
  • 如果被调用的方法带有返回值的话,返回值将被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令
text

xload_<n> (x为 i l f d a , n 为 0 ~ 3)
xload (当局部变量的数量超过4个时,一般使用该指令)

说明 :x 的取值类型表示数据类型

const 系列: 用于常量入栈的相关指令

iconst_<i> (i 从 -1 到 5)
比如:
    iconst+m1 将 -1 入栈
    iconst_x (x为 0 到5)将 x 压入栈
    lconst_0 将长整数 0 压入栈
    aconst_null 将 null 压入栈

bipush : 将 8 位整数压入栈
sipush: 将 16 位整数压入栈

ldc: 万能压栈指令,它可以接收一个 8 位的参数,该参数指向常量池中的 int 、 float 或 String 的索引,将其压入栈

store 系列 :将操作数栈中的数据出栈,保存到局部变量表中

何为栈顶缓存技术

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

动态链接

  • 静态链接

当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译器可知,且运行期间保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称为静态链接

  • 动态链接

如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,称为动态链接

动态链接.png

虚拟机栈问题小结

栈溢出的情况?

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

调整栈大小,就能保证不溢出吗?

不能,栈大小不是可以无限扩大的,仍然有可能溢出

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

不是,因为增加栈大小会导致每个线程的栈都变大,在一定的栈空间下,能创建的线程数量就变少了

垃圾回收是否会涉及到虚拟机栈?

不会,垃圾回收只会涉及到方法区和堆中

方法中定义的局部变量是否线程安全?

java

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

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

本地方法和本地方法栈

本地方法就是 Java 调用非Java 代码的接口,它的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++ 程序。

现在本地方法的使用越来越少了,除非是与硬件有关的应用,因为现在异构领域间的通信很发达,生态也更强大,本地方法不是必须的,有的 JVM 实现就不支持本地方法了。

  • 一个 JVM 实例只存在一个堆内存,堆也是 JAVA 内存管理的核心区域
  • Java 堆区在 JVM 启动的时候被创建,其空间大小就确定了,是 JVM 管理的最大一块内存空间
  • 堆内存的大小可以调节
  • 堆可以处于物理上不连续的内存空间中,但在逻辑上应该被视为连续的
  • 堆是 GC 执行垃圾回收的重点区域
  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除

对象都分配在堆上?

  • 《Java虚拟机规范》 中对堆的描述是: 所有的对象实例以及数组都应该在运行时分配在堆上
  • "栈上分配",会把一部分对象信息直接保存在栈上,但又不是完整的对象信息,标量替换

所有的线程都共享堆?

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

堆的内部结构

现代垃圾收集器大部分都基于分代收集理论设计,堆的内部结构如下图所示:

几乎所有的 Java 对象都是在 Eden 区被 new 出来的。大对象会直接分配到老年代

堆的内部结构.png

如何设置堆内存的大小?

  • -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 中要求的年龄

对象分配原则.png

Full GC 触发机制

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

OOM 如何解决

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

什么是 TLAB ?

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

TLAB.png

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

方法区

栈、堆和方法区的关系

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

方法区在哪里?

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

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

方法区的演进

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

方法区常用参数

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

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

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

方法区是否存在 GC? 回收什么?

方法区的垃圾回收主要回收两部分内容:

  • 常量池中废弃的常量
  • 不再使用的类型(回收条件很苛刻)

StringTable 为什么要调整

jdk8 字符串常量池放到堆空间中,因为堆空间 GC 效率高

字符串常量池存放位置.png

StringTable 字符串常量池

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.intern()

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().png

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