2022-09-27JVM系列00
请注意,本文编写于 786 天前,最后修改于 786 天前,其中某些信息可能已经过时。

内存结构

image-20220927084453999

程序计数器(寄存器)

基本定义

Program Counter Register 程序计数器(寄存器):

  • 作用: 是记住下一条jvm指令的执行地址

  • 特点

    • 是线程私有的
    • 不会存在内存溢出

每一个线程都对应一个程序计数器, 用于存储下一个指令的地址, 不存在内存溢出的原因是 :

首先,我们熟悉的栈和堆,都是可以通过运行时对内存需求的扩增导致内存不够用的情况

比如某个线程递归调用,随着调用层次的加深,可能会出现栈空间不足的情况,这时候如果可以动态扩增,jvm就会向申请更多的内存空间来扩充栈,当没有更多的内存空间得以申请的时候,就会发生OutOfMemoryError。

但是,程序计算器仅仅只是一个运行指示器,它所需要存储的内容仅仅就是下一个需要待执行的命令的地址,无论代码有多少,最坏情况下死循环也不会让这块内存区域超限,因为程序计算器所维护的就是下一条待执行的命令的地址,所以不存在OutOfMemoryError

代码案例

package cn.knightzz.register;

import java.io.PrintStream;

public class TestProgramCounter {

    public static void main(String[] args) {

        PrintStream out = System.out;

        out.println(1);
        out.println(2);
        out.println(3);
        out.println(4);
    }

}

使用 jclassib 插件得到的字节码如下

 0 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
 3 astore_1
 4 aload_1
 5 iconst_1
 6 invokevirtual #3 <java/io/PrintStream.println : (I)V>
 9 aload_1
10 iconst_2
11 invokevirtual #3 <java/io/PrintStream.println : (I)V>
14 aload_1
15 iconst_3
16 invokevirtual #3 <java/io/PrintStream.println : (I)V>
19 aload_1
20 iconst_4
21 invokevirtual #3 <java/io/PrintStream.println : (I)V>
24 return

虚拟机栈

基本定义

  • 每个线程运行时所需要的内存,称为虚拟机栈

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存

  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

image-20220817174911269

面试题

1. 垃圾回收是否涉及栈内存?

  • 垃圾回收机制仅仅作用于堆内存,与栈内存无关
  • 因为栈内存无非就是一次次的方法调用所产生的栈帧内存,而栈帧内存在每一次方法调用结束后,都会被弹出栈,也就是会被自动的回收掉,所以根本不需要垃圾回收来管理栈内存。

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

Java中每个线程运行的时候,需要给每个线程划分一个内存空间。虚拟机栈其实是线程运行时需要的内存空间。一个线程,就需要一个栈,如果是多个线程,就需要多个虚拟机栈

  • 栈内存可以在运行代码时通过虚拟机参数指定,即“-Xss”这个参数,他是给我们栈内存指定大小
  • 如果不指定,Linux、macOS、Solaris等系统默认每个栈的大小都是1024KB(一兆)
  • Windows比较特殊,他是Windows的虚拟内存影响栈的大小。栈内存设置的越大,反而会让线程数变得更少,因为物理内存的大小是一定的

比如说我一个线程使用的是一个栈内存,那一个线程假如用了一兆内存,假如总共的物理内存假设有500兆,那理论上可以有五百个线程同时运行,但假如把每个线程的栈内存设置了2M内存,那么理论上只能同时运行250个线程。所以栈内存并不是划分的越大越好。把他设置大了,通常只是能够进行更多次的方法递归调用,而不会增强运行的效率,反而会影响到线程数目的变少。所以不建议设置为太大,一般采用系统默认的即可

3. 方法内的局部变量是否线程安全?

看一个变量是不是线程安全,要看多个线程对这个变量是共享的还是这个变量被每个线程是私有的 :

	public static void m1() {
		StringBuilder sb = new StringBuilder();
		sb.append(1);
		sb.append(2);
		sb.append(3);
		print(sb.toString());
	}

	public static void m2(StringBuilder sb) {
		sb.append(1);
		sb.append(2);
		sb.append(3);
		print(sb.toString());
	}

	public static StringBuilder m3() {
		StringBuilder sb = new StringBuilder();
		sb.append(1);
		sb.append(2);
		sb.append(3);
		return sb;
	}

如上面的代码, 虽然 sb 是局部变量, 但是它通过返回值在m2和m3中共享, 多个线程之间操作有可能会出现并发问题

栈内存溢出

  • 栈帧过多导致栈内存溢出

  • 栈帧过大导致栈内存溢出

线程运行诊断

cpu占用过多

定位程序的步骤 :

  • 用top定位哪个进程对cpu的占用过高
  • ps H -eo pid,tid,%cpu | grep 进程id (用ps命令进一步定位是哪个线程引起的cpu占用过高)
  • jstack 进程id , 可以根据线程id 找到有问题的线程,进一步定位到问题代码的源码行号

迟迟得不到结果

依然使用上面的方法去定位, 另外使用 jstack 命令去查看返回的信息

本地方法栈

基本介绍

本地方法栈(Native Method Stacks)与 Java 虚拟机栈所发挥的作用是非常相似的,其区别 :

  • 是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,
  • 本地方法栈则是为虚拟机使用到的 Native 方法服务。

Navtive 方法是 Java 通过 JNI 直接调用本地 C/C++ 库,可以认为是 Native 方法相当于 C/C++ 暴露给 Java 的一个接口,Java 通过调用这个接口从而调用到 C/C++ 方法

当线程调用 Java 方法时,虚拟机会创建一个栈帧并压入 Java 虚拟机栈。

然而当它调用的是 native 方法时,虚拟机会保持 Java 虚拟机栈不变,也不会向 Java 虚拟机栈中压入新的栈帧,虚拟机只是简单地动态连接并直接调用指定的 native 方法。

本地方法栈的特点 :

  • 本地方法栈是一个后入先出(Last In First Out)栈。

  • 由于是线程私有的,本地方法栈生命周期随着线程,线程启动而产生,线程结束而消亡。

  • 本地方法栈会抛出 StackOverflowErrorOutOfMemoryError 异常

基本定义

对于绝大多数应用来说,这块区域是 JVM 所管理的内存中最大的一块。线程共享,主要是存放**对象实例和数组**

  • 通过 new 关键字创建的对象都会放入堆内存中
  • 堆中的数据是线程共享的, 堆中的对象需要考虑线程安全问题
  • 堆有垃圾回收机制

堆内存溢出

package cn.knightzz.heap;

import lombok.extern.slf4j.Slf4j;

import java.util.ArrayList;
import java.util.List;

@Slf4j
public class HeadMemoryOutTest {

    public static void main(String[] args) {
        int count = 0;
        try {
            List<String> list = new ArrayList<>();
            String str = "hello";
            while (true){
                list.add(str);
                str += str;
                count++;
            }
        } catch (Exception e) {
            log.debug("count : {} " , count);
            e.printStackTrace();
        }
    }
}

结果如下 :

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3332)
	at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
	at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
	at java.lang.StringBuilder.append(StringBuilder.java:136)
	at cn.knightzz.heap.HeadMemoryOutTest.main(HeadMemoryOutTest.java:27)

在经过多次循环后, JVM堆出现内存溢出

堆内存诊断

常用工具

  • jps 工具 : 查看当前系统中有哪些 java 进程
  • jmap 工具 : 查看堆内存占用情况 jmap -heap 进程id
  • jconsole 工具 : 图形界面的,多功能的监测工具,可以连续监测 (win + r 输入 jconsole)
  • jvisualvm: JVM可视化工具

小案例

package cn.knightzz.heap;

import lombok.extern.slf4j.Slf4j;

import java.util.ArrayList;
import java.util.List;

@Slf4j
public class HeadMemoryOutTest {

    public static void main(String[] args) {
        int count = 0;
        try {
            List<String> list = new ArrayList<>();
            String str = "hello";
            while (true){
                list.add(str);
                log.debug("current count value : {}" , count);
                count++;

            }
        } catch (Exception e) {
            log.debug("count : {} " , count);
            e.printStackTrace();
        }
    }
}

上面的代码会不断的向 list 中添加字符串对象, 因为new关键字创建的对象和数组存储在堆中的,

首先是使用 jconsole , 可以如下图可以看到, JVM堆占用内存随着时间在逐渐增加

image-20220831082011616

另外, 我们也可以通过先使用 jps 命令查看 java 进程 :

$ jps
17828 RemoteMavenServer36
34292 Launcher
7668 
21176 HeadMemoryOutTest
3704 
32236 Jps

可以看到 21176 HeadMemoryOutTest 就是我们的当前的进程, 进程 id 是 21176 , 然后我们通过 jmap -heap 21176 :

$ jmap -heap 21176
Attaching to process ID 21176, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.152-b16

using thread-local object allocation.
Parallel GC with 10 thread(s)

Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 8556380160 (8160.0MB)
   NewSize                  = 178257920 (170.0MB)
   MaxNewSize               = 2852126720 (2720.0MB)
   OldSize                  = 356515840 (340.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 2851078144 (2719.0MB)
   used     = 0 (0.0MB)
   free     = 524288 (0.5MB)
   0.0% used
PS Old Generation
   capacity = 1687683072 (1609.5MB)
   used     = 1336502720 (1274.5883178710938MB)
   free     = 351180352 (334.91168212890625MB)
   79.19156992054015% used

6704 interned Strings occupying 538872 bytes.

通过这种方式可以看到JVM堆内存的占用情况 , 我们也可以在控制台使用 jvisualvm 命令 :

image-20220831083123100

查看当前堆内存占用情况

堆栈对比

  • 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据;堆解决的是数据存储的问题,即数据怎么放、放在哪儿。

  • 栈因为是运行单位,因此里面存储的信息都是跟当前线程相关的信息。包括:局部变量、程序运行状态、方法返回值等等;而堆只负责存储对象信息。

  • 在方法中定义的一些基本类型的变量和对象的引用变量都是在函数的栈内存中分配。堆内存用于存放由new创建的对象和数组。

  • 在Java中一个线程就会相应有一个线程栈与之对应,这点保证了程序的并发运行

  • 而堆则是所有线程共享的,也可以理解为多个线程访问同一个对象,比如多线程去读写同一个对象的值

  • 栈内存溢出包括StackOverflowError和OutOfMemoryError。

    • StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度
    • OutOfMemoryError:如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存;
  • 堆内存溢出是OutOfMemoryError。如果堆中没有内存完成实例分配,并且堆也无法再扩展时,抛出OutOfMemoryError异常

小案例

方法区

方法区定义

JVM 有一个跨所有线程通用的方法区。它包含每个类的元素,如常量池、字段、方法本地数据、方法代码、构造函数代码等,用于类和对象/接口的初始化。

  • 这个方法区是在 JVM 启动期间创建的。它通常是堆区域的一部分。它可以是固定大小或变化的。它的内存可能不是连续的。

  • JVM 实现可以让程序员控制方法区域的创建、其大小等。

  • 如果方法区域内存不足以满足分配请求,则 JVM 会抛出 OutOfMemoryError

内存结构

JDK1.6

image-20220831104836715

JDK1.8

1.8版本的方法区已经是一个概念上的东西, 它被元空间所替代, 并且从JVM内存空间移除, 放入本地内存空间(操作系统进程)

image-20220831105529655

内存溢出

  • 1.8以前会导致永久代内存溢出
  • 1.8以后会导致元空间内存溢出

内存溢出案例

package cn.knightzz.method.area;

import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;
import lombok.extern.slf4j.Slf4j;


@Slf4j(topic = "MetaSpaceOutMemoryTest")
public class MetaSpaceOutMemoryTest extends ClassLoader {

    public static void main(String[] args) {

        // 需要设置参数, 减小方法区的大小 : -XX:MaxMetaspaceSize=8m

        int count = 0;
        try {
            MetaSpaceOutMemoryTest test = new MetaSpaceOutMemoryTest();
            for (int i = 0; i < 100000; i++) {
                // ClassWriter的作用是生成类字节码
                ClassWriter cw = new ClassWriter(0);
                // 版本号, public, 类名, 包名, 父类, 接口
                cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
                // 返回 bytes[]
                byte[] codes = cw.toByteArray();
                test.defineClass("Class" + i, codes, 0, codes.length);
                count++;
            }
        } finally {
            log.debug("count : {}", count);
        }
    }
}

上面的代码是在不断的加载类字节码 , 在运行的时候我们还需要设置 VM option 参数 : -XX:MaxMetaspaceSize=8M

image-20220901091247592

运行结果如下 :

09:15:23.677 [main] DEBUG MetaSpaceOutMemoryTest - count : 4501
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
	at java.lang.ClassLoader.defineClass1(Native Method)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:642)
	at cn.knightzz.method.area.MetaSpaceOutMemoryTest.main(MetaSpaceOutMemoryTest.java:33)

在 Spring 和 Mybatis 场景下, 在运行的时候会出现大量动态生成的类, 有可能会出现元空间的内存溢出

常量池

常量池

每一个class文件都有一个常量池,常量池保存着class的常量信息:字面量和符号引用。通俗来说,常量池就是class文件中的资源仓库,保存了文件运行时需要的常量信息,这些常量都是开发者定义出来的。 我们知道,每一个class文件都是javac编译来的,在编译过程中,java并没有保存方法、字段的内存布局,在运行时必须加载解析这些常量信息才能够将其翻译到具体的内存地址加以使用。

运行时常量池

  • 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
  • 运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址

常量池和运行时常量池的区别 :

  • 常量池是class文件中的一段信息(这些信息中包含类以及方法的各种信息)
  • 运行时常量池可以理解为JVM的一个内存结构, 类加载的时候, 常量池中的信息被放到这个内存结构中

小案例

首先我们定义一个 HelloWorld 的java 文件

package cn.knightzz.method.constant.pool;

/**
 * @author 王天赐
 * @title: HelloWorld
 * @projectName hm-jvm-codes
 * @description:
 * @website <a href="http://knightzz.cn/">http://knightzz.cn/</a>
 * @github <a href="https://github.com/knightzz1998">https://github.com/knightzz1998</a>
 * @create: 2022-09-01 09:38
 */
public class HelloWorld {

    public static void main(String[] args) {

        System.out.println("Hello World");

    }
}

然后编译完成以后, 执行 javap -v HelloWorld.class 使用 -v 显示详细信息

$ javap -v HelloWorld.class
Classfile /F:/JavaCode/hm-jvm-codes/jvm-chapter-01/target/classes/cn/knightzz/method/constant/pool/HelloWorld.class
  Last modified 2022-9-1; size 599 bytes
  MD5 checksum 7a129246b07e9399e7d7b537560133f6
  Compiled from "HelloWorld.java"
public class cn.knightzz.method.constant.pool.HelloWorld
  minor version: 0
  major version: 52 // 内部版本信息
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool: // 常量池信息
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // Hello World
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // cn/knightzz/method/constant/pool/HelloWorld
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcn/knightzz/method/constant/pool/HelloWorld;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               HelloWorld.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               Hello World
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               cn/knightzz/method/constant/pool/HelloWorld
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (Ljava/lang/String;)V
{
  public cn.knightzz.method.constant.pool.HelloWorld();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 12: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcn/knightzz/method/constant/pool/HelloWorld;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello World
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 16: 0
        line 18: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}
SourceFile: "HelloWorld.java"

简单介绍下 : 这些都是类的基本信息, 类名以及版本信息

Classfile /F:/JavaCode/hm-jvm-codes/jvm-chapter-01/target/classes/cn/knightzz/method/constant/pool/HelloWorld.class
  Last modified 2022-9-1; size 599 bytes
  MD5 checksum 7a129246b07e9399e7d7b537560133f6
  Compiled from "HelloWorld.java"
public class cn.knightzz.method.constant.pool.HelloWorld
  minor version: 0
  major version: 52 // 内部版本信息

其次是常量池 :

Constant pool: // 常量池信息
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // Hello World
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // cn/knightzz/method/constant/pool/HelloWorld
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcn/knightzz/method/constant/pool/HelloWorld;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               HelloWorld.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               Hello World
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               cn/knightzz/method/constant/pool/HelloWorld
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (Ljava/lang/String;)V

#20 是对应的符号, 后面是实际的方法或者类 , 例如 getstatic #2 , #2 在常量池中就是 #2 = Fieldref #21.#22

然后再往下面就是方法指令定义 :

 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello World
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 16: 0
        line 18: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
  • getstatic 加载静态变量
  • ldc 加载String、方法类型或方法句柄的符号引用, 上面: ldc #3 的作用就是加载 字符串 Hello World
  • invokevirtual : 用于调用对象的实例方法

JVM解释器在解释指令的时候, 看到的只有上面的指令, 具体的一些常量属性需要去常量池中去找

StringTable

面试题

package cn.knightzz.method.string_table;

import lombok.extern.slf4j.Slf4j;

/**
 * @author 王天赐
 * @title: StringTableQuestion
 * @projectName hm-jvm-codes
 * @description:
 * @website <a href="http://knightzz.cn/">http://knightzz.cn/</a>
 * @github <a href="https://github.com/knightzz1998">https://github.com/knightzz1998</a>
 * @create: 2022-09-01 10:12
 */
@Slf4j(topic = "StringTableQuestion")
@SuppressWarnings("all")
public class StringTableQuestion {

    public static void main(String[] args) {

        String s1 = "a";
        String s2 = "b";
        String s3 = "a" + "b"; // 编译期会直接优化成 "ab"
        String s4 = s1 + s2; // 等价于 new String("ab") , s4 = new StringBuilder("c").append("d").toString(), 对象是存储在堆中的
        String s5 = "ab";
        // 返回与s4的字符串是同一对象的字符串,并且来自同一个常量池
        // 当调用 intern 方法时,如果池中已经包含一个等于该String对象的字符串,由equals(Object)方法确定,则返回池中的字符串。
        // 否则,将此String对象添加到池中并返回对该String对象的引用。
        // 因此,对于任何两个字符串s和t ,当且仅当s.equals(t)为true时, s.intern() == t.intern()才为true 。
        // return : 与此字符串具有相同内容的字符串,但保证来自唯一字符串池。
        String s6 = s4.intern(); // 先查看s4对应的字符串:"ab" 是是否在常量池中, 如果在,直接返回, 如果不在就把 "ab" 放到字符串常量池中, 然后再返回字符串中"ab"的引用

        // question
        System.out.println(s3 == s4);
        System.out.println(s3 == s5);
        System.out.println(s3 == s6);

        // 通过new关键字创建的对象都放在堆中了
        String x2 = new String("c") + new String("d"); // 等价于 new String("cd") ==> x2 = new StringBuilder("c").append("d").toString()
        String x1 = "cd";
        String x3 = x2.intern();

        // question : 如果调换最后两行的顺序, 下面的结果是什么 ? , 如果在JDK1.6 环境下呢?
        System.out.println(x1 == x2); // 这个结果是 false, 原因是 x1 是存放在常量池中, x2 是存在于堆中, 二者不是同一个对象
        System.out.println(x1 == x3);

    }
}

s4.intern() : 首先去看

  • s3 == s4 : false, 因为 s3在编译器就会被优化成 "ab", 然后存储在常量池中, 但是 s4 是直接存储在堆中的

  • s3 == s5 : true, 因为s3在编译器被优化成"ab" , 然后会被到StringTable中(因为字符串常量池中没有"ab"), 执行到s5的时候, JVM将"ab" 符号创建为字符串对象, 然后去StringTable中找, 因为s3已经将"ab" 放到了字符串常量池中

  • s3 == s6 : true , 因为 s4.intern() : s4 的对象是存储在堆中的, 调用 intern() 方法, 会先去 StringTable中查看是否已经有了 "ab" , 如果有的话直接返回常量池中的"ab"对象, 如果没有的话, 就把 s4 对应的字符串变量放入常量池中, 然后再返回常量池中对象的引用, 如果是 JDK1.6的话, 会直接复制一个副本, 然后将字符串对象存到常量池中

  • x1 == x2 : false , x2 存储在堆中, x1 存储在常量池中

  • x1 == x3 : true , x2.intern() 方法先查看StringTable中是否有"cd" , 但是因为 x1先执行, StringTable中已经有了 "cd" , 所以会直接返回StringTable中的"cd", x3 == x1

如果调换 String x1 = "cd";x2.intern(); 的话, x1 == x2 的结果是 ture, 因为 x2.intern();先执行以后, 原本存在于堆中的 String对象会被放到StringTable中, 然后

String x1 = "cd" 执行时, 因为StringTable中已经有了"cd" 的String对象, 所以,会直接返回StringTable的对象

常量池与字符串池

我们使用一个简单的小案例 :

package cn.knightzz.method.string_table;


public class ConstantPoolAndStringPool {

    public static void main(String[] args) {

        // 常量池
        String s1 = "a";
        String s2 = "b";
        String s3 = "c";
    }
}

使用 javap -v ConstantPoolAndStringPool.class 命令生成对应的class信息 :

$ javap -v ConstantPoolAndStringPool.class 
Classfile /F:/JavaCode/hm-jvm-codes/jvm-chapter-01/target/classes/cn/knightzz/method/string_table/ConstantPoolAndStringPool.class
  Last modified 2022-9-1; size 590 bytes
  MD5 checksum 9fd394fc2f79e13141c181a727bed9d6
  Compiled from "ConstantPoolAndStringPool.java"
public class cn.knightzz.method.string_table.ConstantPoolAndStringPool
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#24         // java/lang/Object."<init>":()V
   #2 = String             #25            // a
   #3 = String             #26            // b
   #4 = String             #27            // c
   #5 = Class              #28            // cn/knightzz/method/string_table/ConstantPoolAndStringPool
   #6 = Class              #29            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcn/knightzz/method/string_table/ConstantPoolAndStringPool;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               s1
  #19 = Utf8               Ljava/lang/String;
  #20 = Utf8               s2
  #21 = Utf8               s3
  #22 = Utf8               SourceFile
  #23 = Utf8               ConstantPoolAndStringPool.java
  #24 = NameAndType        #7:#8          // "<init>":()V
  #25 = Utf8               a
  #26 = Utf8               b
  #27 = Utf8               c
  #28 = Utf8               cn/knightzz/method/string_table/ConstantPoolAndStringPool
  #29 = Utf8               java/lang/Object
{
  public cn.knightzz.method.string_table.ConstantPoolAndStringPool();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 12: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcn/knightzz/method/string_table/ConstantPoolAndStringPool;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
            3       7     1    s1   Ljava/lang/String;
            6       4     2    s2   Ljava/lang/String;
            9       1     3    s3   Ljava/lang/String;
}
SourceFile: "ConstantPoolAndStringPool.java"

knight'z'z@DESKTOP-VAQG1TR MINGW64 /f/JavaCode/hm-jvm-codes/jvm-chapter-01/target/classes/cn/knightzz/method/string_table (master)
$ javap -v ConstantPoolAndStringPool.class 
Classfile /F:/JavaCode/hm-jvm-codes/jvm-chapter-01/target/classes/cn/knightzz/method/string_table/ConstantPoolAndStringPool.class
  Last modified 2022-9-1; size 591 bytes
  MD5 checksum 028e1fb80be1ce076cb7ee1dd5b94bd6
  Compiled from "ConstantPoolAndStringPool.java"
public class cn.knightzz.method.string_table.ConstantPoolAndStringPool
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#24         // java/lang/Object."<init>":()V
   #2 = String             #25            // a
   #3 = String             #26            // b
   #4 = String             #27            // ab
   #5 = Class              #28            // cn/knightzz/method/string_table/ConstantPoolAndStringPool
   #6 = Class              #29            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcn/knightzz/method/string_table/ConstantPoolAndStringPool;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               s1
  #19 = Utf8               Ljava/lang/String;
  #20 = Utf8               s2
  #21 = Utf8               s3
  #22 = Utf8               SourceFile
  #23 = Utf8               ConstantPoolAndStringPool.java
  #24 = NameAndType        #7:#8          // "<init>":()V
  #25 = Utf8               a
  #26 = Utf8               b
  #27 = Utf8               ab
  #28 = Utf8               cn/knightzz/method/string_table/ConstantPoolAndStringPool
  #29 = Utf8               java/lang/Object
{
  public cn.knightzz.method.string_table.ConstantPoolAndStringPool();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 12: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcn/knightzz/method/string_table/ConstantPoolAndStringPool;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=4, args_size=1
         0: ldc           #2                  // String a
         2: astore_1
         3: ldc           #3                  // String b
         5: astore_2
         6: ldc           #4                  // String ab
         8: astore_3
         9: return
      LineNumberTable:
        line 17: 0
        line 18: 3
        line 19: 6
        line 20: 9
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  args   [Ljava/lang/String;
            3       7     1    s1   Ljava/lang/String;
            6       4     2    s2   Ljava/lang/String;
            9       1     3    s3   Ljava/lang/String;
}

SourceFile: "ConstantPoolAndStringPool.java"

我们重点看 Code 部分的指令 :

Code:
      stack=1, locals=4, args_size=1
         0: ldc           #2                  // String a
         2: astore_1
         3: ldc           #3                  // String b
         5: astore_2
         6: ldc           #4                  // String ab
         8: astore_3
         9: return

ldc :

  • ldc(Load Constant) : 加载 #2 对应的常量,我们去常量池中找到 #2 -> #25 , #25 对应的是 a 符号
  • ldc #2 会把 a 符号变为 "a" 字符串对象, 然后会去 StringTable => [] (StringTable(字符串池)是HashTable,并且不可扩容) 去找key == "a" 的字符串常量,
  • 如果StringTable没有, 就会把"a"放入StringTable中 , 另外字符串对象只有运行到对应的指令的时候才会创建, 并不会提前创建, 比如 运行到 ldc #2 的时候, 只有 "a" 被创建了
  • "b""ab" 现在还仅仅是常量池中的符号, 并没有被创建, 字符串的创建是懒惰的

astore_1 :

  • astore 将将引用类型或returnAddress类型值存入局部变量
  • ldc #2 创建完成字符串对象以后, astore_1 将对象存入 LocalVariableTable中的局部变量 s1, 可以看到 Slot 1 对应的是 S1
  • astore_1 的 1 是局部变量表 slot 位置的 value
 LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  args   [Ljava/lang/String;
            3       7     1    s1   Ljava/lang/String;
            6       4     2    s2   Ljava/lang/String;
            9       1     3    s3   Ljava/lang/String;

小总结 :

  • 常量池中的信息, 在类加载的时候都会加载到运行时常量池中, 这个时候 a, b, ab 都只是运行时常量池中的符号, 注意还没成为String对象
  • 只有运行到对应的指令的时候, JVM才会创建对应的字符串对象, 然后去 StringTable(字符串池) 中去找是否已经存在, 如果不存在就放入StringTable

字符串拼接

字符串拼接有两种方式 :

  • 方式一 : String s1 = "a" + "b".
  • 方式二 : String s2 = "a" , String s3 = "b" , String s4 = s2 + s3

方式一是在编译的时候会把字符串编译成 : "ab" , 然后放入 StringTable(常量池中)

方式二是创建两个StringBuilder对象, 然后使用append方法拼接, 然后调用toString方法返回一个Stirng对象, 注意 : 这个对象是在堆中保存的, 当调用 intern() 时, 会将当前String对象放入StringTable中, 假设StringTable没有对应字符串对象的情况下.

小总结 :

  • 常量与常量的拼接结果是放在字符串常量池中
  • 字符串拼接只要有一个是变量, 其结果就是在堆中,
  • 如果拼接的结果调用intern() 方法, 返回值就在常量池中

package cn.knightzz.method.string_table;


public class StringContact {

    public static void main(String[] args) {

        // 常量池
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2;
    }
}

String s4 = s1 + s2; 其实就等价于 new StringBuilder().append("a").append("b").toString() , 我们可以查看编译后的字节码指令 :

$ javap -v StringContact.class 
Classfile /F:/JavaCode/hm-jvm-codes/jvm-chapter-01/target/classes/cn/knightzz/method/string_table/StringContact.class
  Last modified 2022-9-1; size 739 bytes
  MD5 checksum 4750ee591a31c2348a6fa119ca934e76
  Compiled from "StringContact.java"
public class cn.knightzz.method.string_table.StringContact
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #10.#29        // java/lang/Object."<init>":()V
   #2 = String             #30            // a
   #3 = String             #31            // b
   #4 = String             #32            // ab
   #5 = Class              #33            // java/lang/StringBuilder
   #6 = Methodref          #5.#29         // java/lang/StringBuilder."<init>":()V
   #7 = Methodref          #5.#34         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
   #8 = Methodref          #5.#35         // java/lang/StringBuilder.toString:()Ljava/lang/String;
   #9 = Class              #36            // cn/knightzz/method/string_table/StringContact
  #10 = Class              #37            // java/lang/Object
  #11 = Utf8               <init>
  #12 = Utf8               ()V
  #13 = Utf8               Code
  #14 = Utf8               LineNumberTable
  #15 = Utf8               LocalVariableTable
  #16 = Utf8               this
  #17 = Utf8               Lcn/knightzz/method/string_table/StringContact;
  #18 = Utf8               main
  #19 = Utf8               ([Ljava/lang/String;)V
  #20 = Utf8               args
  #21 = Utf8               [Ljava/lang/String;
  #22 = Utf8               s1
  #23 = Utf8               Ljava/lang/String;
  #24 = Utf8               s2
  #25 = Utf8               s3
  #26 = Utf8               s4
  #27 = Utf8               SourceFile
  #28 = Utf8               StringContact.java
  #29 = NameAndType        #11:#12        // "<init>":()V
  #30 = Utf8               a
  #31 = Utf8               b
  #32 = Utf8               ab
  #33 = Utf8               java/lang/StringBuilder
  #34 = NameAndType        #38:#39        // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  #35 = NameAndType        #40:#41        // toString:()Ljava/lang/String;
  #36 = Utf8               cn/knightzz/method/string_table/StringContact
  #37 = Utf8               java/lang/Object
  #38 = Utf8               append
  #39 = Utf8               (Ljava/lang/String;)Ljava/lang/StringBuilder;
  #40 = Utf8               toString
  #41 = Utf8               ()Ljava/lang/String;
{
  public cn.knightzz.method.string_table.StringContact();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 13: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcn/knightzz/method/string_table/StringContact;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=5, args_size=1
         0: ldc           #2                  // String a
         2: astore_1
         3: ldc           #3                  // String b
         5: astore_2
         6: ldc           #4                  // String ab
         8: astore_3
         9: new           #5                  // class java/lang/StringBuilder
        12: dup
        13: invokespecial #6                  // Method java/lang/StringBuilder."<init>":()V
        16: aload_1
        17: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        20: aload_2
        21: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        24: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        27: astore        4
        29: return
      LineNumberTable:
        line 18: 0
        line 19: 3
        line 20: 6
        line 22: 9
        line 24: 29
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      30     0  args   [Ljava/lang/String;
            3      27     1    s1   Ljava/lang/String;
            6      24     2    s2   Ljava/lang/String;
            9      21     3    s3   Ljava/lang/String;
           29       1     4    s4   Ljava/lang/String;
}
SourceFile: "StringContact.java"

可以看到对应的字节码指令 :

Code:
      stack=2, locals=5, args_size=1
         0: ldc           #2                  // String a
         2: astore_1
         3: ldc           #3                  // String b
         5: astore_2
         6: ldc           #4                  // String ab
         8: astore_3
         9: new           #5                  // class java/lang/StringBuilder
        12: dup
        13: invokespecial #6                  // Method java/lang/StringBuilder."<init>":()V
        16: aload_1
        17: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        20: aload_2
        21: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        24: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        27: astore        4
        29: return

String s4 = s1 + s2; :

  • 先执行 new #5 指令, 创建 StringBuilder对象

  • dup 复制一个StringBuilder对象的副本

  • invokespecial #6 调用初始化方法 <init>

  • aload_1 加载 s1

  • invokevirtual #7 调用 StringBuilder 对象的append 方法

  • aload_2 加载 s2

  • invokevirtual #7 调用 StringBuilder 对象的append 方法 , #7 在常量池中是append方法的引用

  • invokevirtual #8 调用 StringBuilder 对象的toStirng 方法

编译器优化

package cn.knightzz.method.string_table;

/**
 * @author 王天赐
 * @title: StringContact
 * @projectName hm-jvm-codes
 * @description: 字符串拼接
 * @website <a href="http://knightzz.cn/">http://knightzz.cn/</a>
 * @github <a href="https://github.com/knightzz1998">https://github.com/knightzz1998</a>
 * @create: 2022-09-01 11:12
 */
@SuppressWarnings("all")
public class StringContactOptimizer {

    public static void main(String[] args) {

        // 常量池
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        // 等价于 new StringBuilder().append("a").append("b").toString();
        String s4 = s1 + s2;
        // javac 会对其进行优化. 编译器会将 "a" + "b" 编译成 "ab", 这样的话, 在加载的时候会直接从StringTable中去找
        String s5 = "a" + "b";

        System.out.println("s3 == s5 : " + (s3 == s5));
    }
}

编译器优化 :

  • String s5 = "a" + "b" javac 会对其进行优化. 编译器会将 "a" + "b" 编译成 "ab", 这样的话, 在加载的时候会直接从StringTable中去找

字符串延迟加载

package cn.knightzz.method.string_table;

/**
 * @author 王天赐
 * @title: StringLazyLoad
 * @projectName hm-jvm-codes
 * @description: 字符串延迟加载
 * @website <a href="http://knightzz.cn/">http://knightzz.cn/</a>
 * @github <a href="https://github.com/knightzz1998">https://github.com/knightzz1998</a>
 * @create: 2022-09-04 21:25
 */
public class StringLazyLoad {

    public static void main(String[] args) {

        int x = args.length;
        System.out.println(x);

        String a = "a";
        String b = "b";

        String c = "a";
        String d = "b";

    }
}

初始状态时, StringTable中的字符串个数是 2082 :

image-20220904214508095

并且StringTable中是没有 "a""b" 字符串的, 新增了两个字符串,

image-20220904214738643

而后面的 "a""b" 两个字符串因为StringTable中已经有了相应的字符串:

StringTable特性

特性总结

  • 常量池中的字符串仅是符号,第一次用到时才变为对象
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是 StringBuilder (1.8)
  • 字符串常量拼接的原理是编译期优化
  • 可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池
    • JDK1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,会把串池中的对象返回
    • JDK1.6 将这个字符串对象尝试放入串池,如果有则并不会放入, 如果没有会把对象复制一份放入串池, 然后把串池中的对象返回

字符串的创建方式

Jdk1.7 及以上版本

直接赋值 :

String s = "tom";     // 只会在字符串常量池中,s指向常量池中的引用

创建对象时,JVM会先去常量池中通过 equals(key) 方法,判断是否有相同的对象, 有则直接返回该对象在常量池中的引用,没有则会在常量池中创建一个新对象,再返回引用。

new String()

String s = new String("tom");           // 字符串常量池和堆中都有这个对象,s指向堆内存中的对象引用

创建对象时,先检查字符串常量池中是否有字符串,有则直接去堆内存中创建一个字符串对象,没有则先在字符串常量池里创建一个字符串对象,再去内存中创建一个字符串对象,最后将内存中的引用返回。

案例如下 :

package cn.knightzz.method.string_table;

/**
 * @author 王天赐
 * @title: InternTest
 * @projectName hm-jvm-codes
 * @description:
 * @website <a href="http://knightzz.cn/">http://knightzz.cn/</a>
 * @github <a href="https://github.com/knightzz1998">https://github.com/knightzz1998</a>
 * @create: 2022-09-07 15:10
 */
@SuppressWarnings("all")
public class InternTest {

    public static void main(String[] args) {

        // InternTest
        // 通过 new String 方法创建的对象, 常量池和堆中都会有, s1 是堆内存中引用
        String s1 = new String("a");
        // s1.intern() 返回的是常量池中对象的引用 和 堆中的不是同一个
        String s2 = s1.intern();
        System.out.println(s1 == s2); // false
    }
}

并且debug时, 初始状态的String对象是2478个, 执行完 String s1 = new String("a"); 后是 2480个

String.intern()方法

String s1 = new String("tom"); 
String s2 = s1.intern(); 
System.out.println(s1 == s2);     //false

是一个 native 的方法,当调用 intern方法时,如果字符串常量池已经包含一个等于此字符串对象的字符串(用equals(oject)方法确定),则返回常量池中的字符串。否则,将intern返回的引用指向当前字符串 s1(jdk1.6版本需要将 s1 复制到字符串常量池里)。

StringTable位置

image-20220907163502420

StringTable垃圾回收

打印JVM GC信息 : -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc

打印结果如下 :

i = 100
Heap
 PSYoungGen      total 2560K, used 1958K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 95% used [0x00000000ffd00000,0x00000000ffee9808,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
 ParOldGen       total 7168K, used 0K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 0% used [0x00000000ff600000,0x00000000ff600000,0x00000000ffd00000)
 Metaspace       used 3158K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 343K, capacity 388K, committed 512K, reserved 1048576K
SymbolTable statistics:
Number of buckets       :     20011 =    160088 bytes, avg   8.000
Number of entries       :     13127 =    315048 bytes, avg  24.000
Number of literals      :     13127 =    564288 bytes, avg  42.987
Total footprint         :           =   1039424 bytes
Average bucket size     :     0.656
Variance of bucket size :     0.658
Std. dev. of bucket size:     0.811
Maximum bucket size     :         6
StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :      1843 =     44232 bytes, avg  24.000
Number of literals      :      1843 =    161408 bytes, avg  87.579
Total footprint         :           =    685744 bytes
Average bucket size     :     0.031
Variance of bucket size :     0.031
Std. dev. of bucket size:     0.175
Maximum bucket size     :         2

Process finished with exit code 0

Number of buckets : 桶的数量

Number of entries : key的数量

Number of literals : 键值对的数量 (等于字符串的数量)

StringTable优化

策略一 : 通过-XX:StringTableSize=桶个数调整

StringTable的数据结构是HashTable来实现的。即,当我们的空间足够大的时候,我们的数据就会比较分散,查询的效率也会因此降低,反之,当我们的空间比较小的时候,我们的数据就会比较集中,查询的效率也会因此提高。当然了,StringTable 的空间大小并不是越小越好,太小了,一直进行垃圾回收,导致经常要删除老数据,添加新数据等问题也很难受。要是那些个老数据刚删掉没多久就要用到了呢。

小案例 : 以读取linux.words文件数据为例,里面包含了48w+个单词,,我们利用循环读取每一个单词并存入串池中,直到读取结束退出循环。最终输出花费的时间(单位是秒,ms)。

通过调整桶的个数来查看具体花费的时间

package cn.knightzz.method.string_table;

import lombok.extern.slf4j.Slf4j;

import java.io.*;

/**
 * @author 王天赐
 * @title: StringTablePoolTest
 * @projectName hm-jvm-codes
 * @description: 演示串池大小对性能的影响
 * @website <a href="http://knightzz.cn/">http://knightzz.cn/</a>
 * @github <a href="https://github.com/knightzz1998">https://github.com/knightzz1998</a>
 * @create: 2022-09-08 10:03
 */
@Slf4j(topic = "c.StringTablePoolTest")
public class StringTablePoolTest {

    public static void main(String[] args) throws IOException {

        // 参数 : -Xms500m -Xmx500m -XX:+PrintStringTableStatistics -XX:StringTableSize=1009
        String path = StringTablePoolInternTest.class.getClassLoader().getResource("linux.words").getPath();
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(path), "utf-8"))) {
            String line = null;
            long start = System.nanoTime();
            while (true) {
                line = reader.readLine();
                if (line == null) {
                    break;
                }
                line.intern();
            }
            log.debug("cost: {} ns" , (System.nanoTime() - start) / 1000000);
        }
    }

}

JVM参数 : -Xms500m -Xmx500m -XX:+PrintStringTableStatistics -XX:StringTableSize=1009 , 通过调整 StringTableSize的大小, 可以提高StringTable查询的效率, 因为StringTable越小, 出现hash碰撞的几率越小

参数 : -Xms500m -Xmx500m -XX:+PrintStringTableStatistics -XX:StringTableSize=1009 结果如下 :

10:09:17.002 [main] DEBUG c.StringTablePoolTest - cost: 4726 ns
SymbolTable statistics:
Number of buckets       :     20011 =    160088 bytes, avg   8.000
Number of entries       :     21367 =    512808 bytes, avg  24.000
Number of literals      :     21367 =    852264 bytes, avg  39.887
Total footprint         :           =   1525160 bytes
Average bucket size     :     1.068
Variance of bucket size :     1.065
Std. dev. of bucket size:     1.032
Maximum bucket size     :         7
StringTable statistics:
Number of buckets       :      1009 =      8072 bytes, avg   8.000
Number of entries       :    483657 =  11607768 bytes, avg  24.000
Number of literals      :    483657 =  29881192 bytes, avg  61.782
Total footprint         :           =  41497032 bytes
Average bucket size     :   479.343
Variance of bucket size :   433.555
Std. dev. of bucket size:    20.822
Maximum bucket size     :       547

Process finished with exit code 0

可以看到, 消耗的时间是 4076, 我们通过调整StringTableSize的大小可以提高运行的速度

当我们设置 StringTableSize=2009

10:30:52.863 [main] DEBUG c.StringTablePoolTest - cost: 2692 ns
SymbolTable statistics:
Number of buckets       :     20011 =    160088 bytes, avg   8.000
Number of entries       :     21367 =    512808 bytes, avg  24.000
Number of literals      :     21367 =    852264 bytes, avg  39.887
Total footprint         :           =   1525160 bytes
Average bucket size     :     1.068
Variance of bucket size :     1.065
Std. dev. of bucket size:     1.032
Maximum bucket size     :         7
StringTable statistics:
Number of buckets       :      2009 =     16072 bytes, avg   8.000
Number of entries       :    483657 =  11607768 bytes, avg  24.000
Number of literals      :    483657 =  29881192 bytes, avg  61.782
Total footprint         :           =  41505032 bytes
Average bucket size     :   240.745
Variance of bucket size :   251.876
Std. dev. of bucket size:    15.871
Maximum bucket size     :       302

可以看到上面的结果, 消耗时间由大幅度的减少

策略二 : 考虑是否将字符串对象入池

假如现在让我们存储地球上每个人的家庭住址,我们知道,其实有很多人是住一起的,他们的住址其实没有必要重复存取对吧,而且,我们的家庭住址信息明显就是一个字符串,如果我们让字符串对象入池,那么其实可以省去很多的内存, 我们可以在字符串放入List中之前执行intern方法来减少字符的重复存储.

当我们的系统数据会出现重复字符串的情况下,我们可以让我们对应的字符串对象入池,来减少内存的占用。

package cn.knightzz.method.string_table;

import java.io.*;
import java.util.ArrayList;
import java.util.List;

/**
 * @author 王天赐
 * @title: StringTablePoolInternTest
 * @projectName hm-jvm-codes
 * @description: 演示 intern 减少内存占用
 * @website <a href="http://knightzz.cn/">http://knightzz.cn/</a>
 * @github <a href="https://github.com/knightzz1998">https://github.com/knightzz1998</a>
 * @create: 2022-09-08 10:33
 */
public class StringTablePoolInternTest {

    public static void main(String[] args) throws IOException {

        // -XX:StringTableSize=200000 -XX:+PrintStringTableStatistics
        // -Xsx500m -Xmx500m -XX:+PrintStringTableStatistics -XX:StringTableSize=200000

        String path = StringTablePoolInternTest.class.getClassLoader().getResource("linux.words").getPath();
        List<String> address = new ArrayList<>();
        System.in.read();
        for (int i = 0; i < 10; i++) {
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(path), "utf-8"))) {
                String line = null;
                long start = System.nanoTime();
                while (true) {
                    line = reader.readLine();
                    if(line == null) {
                        break;
                    }
                    address.add(line.intern());
                }
                System.out.println("cost:" +(System.nanoTime()-start)/1000000);
            }
        }
        System.in.read();
    }
}

我们可以使用 jvisualvm 查看内存占用情况, win + r 输入 jvisualvm 即可

image-20220908112423440

直接内存

参考文章

基本定义

直接内存是操作系统的内存, 并不是JVM的内存 , 直接内存也叫堆外内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现 : 《深入理解 Java 虚拟机 第三版》2.2.7 小节

直接内存 :

  • 常见于 NIO 操作时,用于数据缓冲区

  • 分配回收成本较高,但读写性能高

  • 不受 JVM 内存回收管理

  • Direct Memory 并不是虚拟机运行时数据区的一部分;

  • 由于在 JDK 1.4 中引入了 NIO 机制,为此实现了一种通过 native 函数直接分配对外内存的,而这一切是通过以下两个概念实现的:

    • 通道(Channel);
    • 缓冲区(Buffer);
  • 通过存储在 Java 堆里面的 DirectByteBuffer 对象对这块内存的引用进行操作;

  • 因避免了 Java 堆和 Native(native heap)中来回复制数据,所以在一些场景中显著提高了性能;

  • 直接内存出现 OutOfMemoryError 异常的原因是物理机器的内存是受限的,但是我们通常会忘记需要为直接内存在物理机中预留相关内存空间;

img

直接内存结构图

image-20220908140425770

系统的内存 Java是无法访问到的, Java仅仅只能访问到JVM内存, 但是在 IO 时, 系统会专门划分一片区域作为系统缓存区, 这个区域的内存Java是可以直接访问的

在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

直接内存特点

垃圾回收问题

直接内存也是会被 JVM 虚拟机管理进行完全不被引用的对象回收处理 , 但是直接内存中的对象并不是如普通对象样被 GC 管理

内存分配问题

直接内存的最大大小可以通过 -XX:MaxDirectMemorySize=200M 来设置,默认是 64M。

在 Java 中分配内存的方式一般是通过 sun.misc.Unsafe类的公共 native 方法实现的(比如 文件以及网络 IO 类,但是非常不建议开发者使用,使用时一定要确保安全),而类 DirectByteBuffer 类的也是借助于此向物理内存(比如 JVM 运行于 Linux 上,那么 Linux 的内存就被称为物理内存)。

Unsafe 是位于 sun.misc 包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升 Java 运行效率、增强 Java 语言底层资源操作能力方面起到了很大的作用。但由于 Unsafe 类使 Java 语言拥有了类似 C 语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用 Unsafe 类会使得程序出错的概率变大,使得 Java 这种安全的语言变得不再“安全”,因此对 Unsafe 的使用一定要慎重。

直接内存溢出

package cn.knightzz.direct.memory;

import lombok.extern.slf4j.Slf4j;

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

/**
 * @author 王天赐
 * @title: OutOfDirectMemoryTest
 * @projectName hm-jvm-codes
 * @description: 演示直接内存溢出
 * @website <a href="http://knightzz.cn/">http://knightzz.cn/</a>
 * @github <a href="https://github.com/knightzz1998">https://github.com/knightzz1998</a>
 * @create: 2022-09-08 14:38
 */
@SuppressWarnings("all")
@Slf4j(topic = "c.OutOfDirectMemoryTest")
public class OutOfDirectMemoryTest {

    static int _100Mb = 1024 * 1024 * 100;

    public static void main(String[] args) {
        List<ByteBuffer> list = new ArrayList<>();
        int i = 0;
        try {
            while (true) {
                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
                list.add(byteBuffer);
                i++;
            }
        } finally {
            System.out.println(i);
        }
        // 方法区是jvm规范,  jdk6 中对方法区的实现称为永久代
        //                  jdk8 对方法区的实现称为元空间
    }

}

如上面的代码所示, 我们通过修改 ByteBuffer 的内存来使直接内存出现内存泄漏, 因为直接内存的默认大小是 64M, 我们的缓冲去是 100M 就会出现内存泄漏, 结果如下 :

72
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
	at java.nio.Bits.reserveMemory(Bits.java:694)
	at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
	at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
	at cn.knightzz.direct.memory.OutOfDirectMemoryTest.main(OutOfDirectMemoryTest.java:29)

Process finished with exit code 1

分配和回收原理

基本概念

使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法

ByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦ByteBuffer对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调

用 freeMemory 来释放直接内存

直接内存释放

package cn.knightzz.direct.memory;

import java.io.IOException;
import java.nio.ByteBuffer;

/**
 * @author 王天赐
 * @title: MemoryAllocateTest
 * @projectName hm-jvm-codes
 * @description: 禁用显式回收对直接内存的影响
 * @website <a href="http://knightzz.cn/">http://knightzz.cn/</a>
 * @github <a href="https://github.com/knightzz1998">https://github.com/knightzz1998</a>
 * @create: 2022-09-08 19:22
 */
public class MemoryAllocateTest {

    static int _1Gb = 1024 * 1024 * 1024;

    public static void main(String[] args) throws IOException {

        //  -XX:+DisableExplicitGC 显式的

        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
        System.out.println("分配完毕...");
        System.in.read();
        System.out.println("开始释放...");
        byteBuffer = null;
        System.gc(); // 显式的垃圾回收,Full GC
        System.in.read();
    }
}
image-20220908193026008

可以看到啊, ByteBuffer分配了1G的内存, 任务管理器中 Java子线程出现了1G的内存占用, 当我们继续执行的时候, 开始释放 :

image-20220908193142735

可以看到啊原本占用1G的内存被释放后占用17.5M

显式的禁用GC以后 -XX:+DisableExplicitGC 可以让 System.gc() 无效

unsafe内存创建与释放

package cn.knightzz.direct.memory;

import sun.misc.Unsafe;

import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.ByteBuffer;

/**
 * @author 王天赐
 * @title: MemoryAllocateTest
 * @projectName hm-jvm-codes
 * @description: 禁用显式回收对直接内存的影响
 * @website <a href="http://knightzz.cn/">http://knightzz.cn/</a>
 * @github <a href="https://github.com/knightzz1998">https://github.com/knightzz1998</a>
 * @create: 2022-09-08 19:22
 */
public class MemoryAllocateByUnsafeTest {

    static int GB_1 = 1024 * 1024 * 1024;

    public static void main(String[] args) throws IOException {
        Unsafe unsafe = getUnsafe();
        // unsafe 分配内存
        long base = unsafe.allocateMemory(GB_1);
        unsafe.setMemory(base, GB_1, (byte) 0);
        System.in.read();

        // 使用unsafe释放内存
        unsafe.freeMemory(base);
        System.in.read();
    }

    public static Unsafe getUnsafe() {
        try {
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            Unsafe unsafe = (Unsafe) f.get(null);
            return unsafe;
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }
}

我们查看 DirectByteBuffer 类的 run方法 :

 public void run() {
            if (address == 0) {
                // Paranoia
                return;
            }
            unsafe.freeMemory(address);
            address = 0;
            Bits.unreserveMemory(size, capacity);
        }

也是通过 unsafe 对象来释放和申请内存

    DirectByteBuffer(int cap) {                   // package-private

        super(-1, 0, cap, cap);
        boolean pa = VM.isDirectMemoryPageAligned();
        int ps = Bits.pageSize();
        long size = Math.max(1L, (long)cap + (pa ? ps : 0));
        Bits.reserveMemory(size, cap);

        long base = 0;
        try {
            base = unsafe.allocateMemory(size);
        } catch (OutOfMemoryError x) {
            Bits.unreserveMemory(size, cap);
            throw x;
        }
        unsafe.setMemory(base, size, (byte) 0);
        if (pa && (base % ps != 0)) {
            // Round up to page boundary
            address = base + ps - (base & (ps - 1));
        } else {
            address = base;
        }
        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
        att = null;



    }

DirectByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦ByteBuffer对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存

显式的禁用GC

显式的禁用GC以后 -XX:+DisableExplicitGC 可以让 System.gc() 无效

package cn.knightzz.direct.memory;

import java.io.IOException;
import java.nio.ByteBuffer;

public class MemoryAllocateTest {

    static int _1Gb = 1024 * 1024 * 1024;

    public static void main(String[] args) throws IOException {

        //  -XX:+DisableExplicitGC 显式的

        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
        System.out.println("分配完毕...");
        System.in.read();
        System.out.println("开始释放...");
        byteBuffer = null;
        System.gc(); // 显式的垃圾回收,Full GC,
        System.in.read();
    }
}

当我们显式的禁用 GC 以后, 虽然后面 ByteBuffer对象已经没有别的对象引用了, 但是它依然没有被回收, 依然占用1G内存

image-20220908194704348

显式的调用 System.gc() 是 full GC , 影响效率, 我们可以使用 unsafe.freeMemory 来释放直接内存

本文作者:王天赐

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!