JVM②内存结构


内存结构

1、程序计数器

image-20220216183804851

  • 作用:若当前执行的是 JVM 的方法,程序计数器保存当前执行指令的地址;若当前执行的是 native 方法,程序计数器为空。

  • 特点:线程私有,不存在内存溢出。

  • 流程:Java 类经过编译,成为二进制字节码。之后进行以下步骤:

    1. 解释器读取一行 JVM 指令,此时程序计数器存储下一行指令地址;
    2. 解释器将 JVM 转为机器码,交给 CPU 执行;
    3. 解释器从程序计数器获得下一行指令地址,此时程序计数器再存储下一行指令地址;
    4. 重复以上,直到代码结束。
    // 二进制字节码				// Java代码
    0:	getstatic #20 			// PrintStream out = System.out;
    3:	astore_1 				// --
    4:	aload_1 				// out.println(1);
    5:	iconst_1 				// --
    6:	invokevirtual #26 		// --
    9:	aload_1 				// out.println(2);
    10: iconst_2 				// --
    11: invokevirtual #26 		// --
    14: aload_1 				// out.println(3);
    15: iconst_3 				// --
    16: invokevirtual #26 		// --
    19: aload_1 				// out.println(4);
    20: iconst_4 				// --
    21: invokevirtual #26 		// --
    24: aload_1 				// out.println(5);
    25: iconst_5 				// --
    26: invokevirtual #26 		// --
    29: return
    

2、虚拟机栈、本地方法栈

image-20220216211235155

  • 栈:仅允许在线性表的一端进行插入和删除操作,后进先出(LIFO)
  • 虚拟机栈:每个线程运行时所需的内存;
    • 由多个栈帧(Frame)组成,对应每个方法调用时所占内存;
    • 每个线程只能有一个活动栈帧,对应当前正在执行的方法。
  • 本地方法栈:跟虚拟机栈的区别在于,本地方法栈是给 Native 方法使用的。
  • 特点:与线程同时创建,线程私有,不存在内存溢出,不存在垃圾回收。

栈内存

栈内存分配并非越大越好。物理内存大小固定,栈内存分配越大,线程数目越少。

  1. JVM参数:-Xss size,有默认值
    • Linux、macOS、Oracle Solaris:1024 KB;
    • Windows:取决于虚拟内存;
  2. 内存溢出:java.lang.StackoverFlowError
    • 栈帧过大(理论上不会)
    • 栈帧过多:举例如下
      1. 逻辑判断条件永远为 true;
      2. 无限递归:递归没有终止条件;
      3. 代码循环引用,如一对多关系的实体类转 Json。

线程安全问题

针对方法内的变量,判断依据为变量是否线程私有。

  • 线程安全:方法内的变量没有逃离方法的作用范围。
  • 线程不安全:方法内的变量逃离方法的作用范围。
    • 如:方法外部定义的引用类型参数、作为方法返回值、方法参数等;
    • 可能存在一个以上的线程,对该变量进行存取操作。(如数据库的脏读、幻读等)

线程运行诊断

案例1:程序占用过多 CPU 内存

案例2:程序发生死锁(运行长时间没结果)

  • 通过命令定位程序
    1. top 命令:实时显示进程信息(Progress)
    2. :抓取进程快照,查看线程id
  • 工具:jstack 进程id

3、堆

image-20220216222116467

  • 功能:分配所有的类实例和数组。(通过 new 关键字创建的对象)
  • 特点:JVM启动时创建,线程共享(线程安全问题),存在垃圾回收机制。

堆内存

  1. JVM参数:-Xmx size

  2. 内存溢出:java.lang.OutOfMemoryError: Java Heap space(OOM)

    • 原因:在对象的生命周期内,gc 不会对该对象进行回收。

    • 举例:向 ArrayList 中无限添加 String 对象。

内存诊断工具

  1. jps:当前系统中的 Java 进程
  2. jmap:堆内存占用情况,某一时刻的快照( jmap - heap 进程id )
  3. jconsole:可视化,连续监测
  4. jvisualvm:可视化(堆转储:堆 Dump 功能)

4、方法区

image-20220216224205202

方法区是一个概念上的结构。

  • 功能:存储类的结构
    • 方法和构造函数的代码;
    • 运行时常量池、字段和方法数据;
    • 接口初始化、实例初始化、类中的特殊方法;
  • 特点:JVM 启动时创建,线程共享(线程安全问题),存在垃圾回收机制。
  • 方法区在逻辑上,是堆的一部分。
  • 实现位置
    • <= JDK 1.6:永久代(Permanent Generation),位于堆中
    • JDK 1.7:有永久代,逐渐 “去永久代”,部分位于堆中;
    • >= JDK 1.8:元空间(Meta Space),位于本地内存中。

方法区内存

< JDK 8 :永久代

  • JVM参数:-XX:MaxPermSize=size
  • 内存溢出:java.lang.OutOfMemoryError: PermGen space(OOM)

>= JDK 8:元空间

  • JVM参数:-XX:MaxMetaspaceSize=size
  • 内存溢出:java.lang.OutOfMemoryError: Metaspace(OOM)

运行时常量池

运行时常量池(run-time contant pool)是常量池(constant_pool)的运行时表示。

  • 每个类文件或接口都有一个常量池表,包含字面量、方法和字段引用等信息;
  • 在类或接口创建时(class文件被加载),构造运行时常量池;
  • 引用地址
    • 常量池:符号地址;
    • 运行时常量池:真实地址;
  • 所有对象引用在最初都是符号引用,在实际使用时才创建对象。

字节码文件

可通过 javap 反编译工具查看

  • 包含 类基本信息、常量池、类方法定义
  • JVM 在执行指令时,根据符号地址查询常量池表,找到引用的对象;
  • 重复以上动作,直到所有方法执行结束。

实例:在 main 方法中,运行System.out.println("helloworld ")。以下是反编译代码的一部分。

(简单演示一行 JVM 指令的执行过程)

image-20220217150943100

串池:StringTable

串池

  • 存储字面量方式创建的字符串对象,具有不重复的特性。
  • 底层实现:HashTable
    • 字符串对象作为 HashTable 的 Key;
    • Key 具有唯一性,可以保证字符串对象的不重复性。
  • 位置
    • JDK 1.6 前:永久代
    • JDK 1.7 开始:堆

字符串对象创建

常量池中的字符串对象,最初仅是符号引用,在实际使用时才创建对象。

  1. 字面量创建:String str = "abc";
    • 类编译后,字面量 "abc" 存储在常量池;
    • 类加载后,字面量 "abc" 存储在运行时常量池;
    • 执行代码对应的 JVM 指令时
      1. ldc:查询运行时常量池表,找到字面量 "abc";
      2. astore:在本地变量表中为其创建一个 String 对象;
      3. 检查 StringTable 中是否存在该对象:不存在则加入对象,存在则返回 StringTable 中的该对象。
  2. new 方式:String str = new String("abc");
    • 在堆中为字符串对象实例分配空间。
  3. intern 方法:主动将字符串对象放入串池
    • 串池中已有该对象:返回该对象;
    • 串池中没有该对象:
      • JDK 1.6 前:将对象的副本放入串池,返回副本对象;
      • JDK 1.7 开始:将对象放入串池,并返回该对象。

字符串拼接

  • 变量拼接:

    • 变量:编译时无法确定值,运行时才能确定值;
    • 本质上使用 StringBuilder.append().toString()
  • 常量拼接

    • 常量:编译时已确定值;
    • 编译期优化,常量在编译时期就连在一起。

练习

练习1

String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();
String s7 = new String("ab");
// 问
System.out.println(s3 == s4);	
System.out.println(s3 == s5);
System.out.println(s3 == s6);
System.out.println(s4 == s7);

// 答:fasle、true、true、false(堆中的不同地址)

分析

值 说明 位置
s1 "a" 常量,字面量方式 串池
s2 "b" 常量,字面量方式 串池
s3 "ab" 常量拼接,字面量方式(编译期优化) 串池
s4 new String("ab") 变量拼接,使用 new StringBuilder() 堆
s5 "ab" 常量,字面量方式 串池
s6 "ab" intern(),返回串池中的对象 串池
s7 new String("ab") new 方式创建 堆

练习2

String x1 = new String("c") + new String("d");
x1.intern();
String x2 = "cd";
// 问
System.out.println(x1 == x2);

分析

x1 x2 x1.intern() 结果
JDK 1.8 new String("cd") "cd" 串池中已有 cd,不将 x1 放入 false
JDK 1.6 new String("cd") "cd" 串池中已有 cd,不将 x1 放入 false

练习3:(调换练习2 最后两行代码位置)

String x1 = new String("c") + new String("d");
String x2 = "cd";
x1.intern();

// 问
System.out.println(x1 == x2);

分析

x1 x1.intern() x2 结果
JDK 1.8 new String("cd") 串池中没有 cd,将 x1 放入 "cd" true
JDK 1.6 new String("cd") 串池中没有 cd,将 x1 的副本放入 "cd" false

性能调优

  • 设置桶(bucket)数目

    • JVM参数:-XX:StringTableSize=桶个数
    • 桶数目越少,平均每个桶存放的元素越多,查找效率越低;
  • 使用 intern():利用串池保证字符串对象的不重复性,避免重复占用内存。

5、直接内存

Direct Memory

  • 属于操作系统内存,不受 JVM 内存回收管理;
  • 常见于 NIO 操作时,用于数据缓冲区;
  • 分配回收成本较高,但读写性能高。
  • 内存溢出:java.lang.OutOfMemoryError: Direct buffer Memory(OOM)

使用

Java 只能操作堆内存,无法直接操作系统内存,效率低。

image-20220217173222574

Direct Memory 在系统内存中分配一块直接内存(allocateDirect)。

  • Java 可以直接操作直接内存;

  • 省去从系统缓存区到 Java 缓冲区的复制操作;

    image-20220217173428415

分配和回收原理

  • 主动调用 Unsafe 对象的 freeMemory() 方法,回收 Direct Memory 的分配。
  • ByteBuffer 子类:DirectByteBuffer 类
    • 使用 Cleaner(虚引用类型)监测 ByteBuffer 对象;(观察者模式)
    • 当 ByteBuffer 对象被回收,创建 ReferenceHandler 线程
      • 调用 Cleaner 的 clean() 方法;
      • clean() 方法会调用 freeMemory() 来释放直接内存。
JVM