运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。

JDK 1.8 和之前的版本略有不同,我们这里以 JDK 1.7 和 JDK 1.8 这两个版本为例介绍。

JDK 1.7

image.png|375

JDK 1.8

image.png|425
image.png|450

线程私有的:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:

  • 方法区
  • 直接内存 (非运行时数据区的一部分)

Java 虚拟机规范对于运行时数据区域的规定是相当宽松的。以堆为例:堆可以是连续空间,也可以不连续。堆的大小可以固定,也可以在运行时按需扩展 。虚拟机实现者可以使用任何垃圾回收算法管理堆,甚至完全不进行垃圾收集也是可以的

程序计数器

程序计数器是一块较小的内存空间,它会记录下一行字节码指令的地址。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

从上面的介绍中我们知道了程序计数器主要有两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

⚠️ 注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡

Java 虚拟机栈

与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。

栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的(后面会提到),其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。

方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。

栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作

image.png|275

1.局部变量表 主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
image.png

image.png

如果该方法是实例方法,则会在0号槽中存放this,即当前对象的引用;可以存放方法的参数,和方法中的局部变量

2.操作数栈 主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
image.png

3.帧数据主要包含动态链接、方法出口、异常表的引用

3.1.动态链接

在Java源文件被编译成Class文件时,类中所有的变量、方法调用都会化为符号引用,然后保存在class文件的常量池中,在class文件中描述一个方法调用另一个方法时,就使用常量池中指向方法的符号引用来表示的

动态链接主要服务一个方法需要调用其他方法的场景Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接

  • 常量池:位于编译后生成的class字节码文件中。
  • 运行时常量池:位于运行期间的元数据空间/方法区中。

image.png|500

栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。

Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。

除了 StackOverFlowError 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

简单总结一下程序运行中栈可能会出现两种错误:

  • StackOverFlowError 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
  • OutOfMemoryError 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

3.2.方法出口

方法出口指的是方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址。
image.png|400

3.3.异常表

异常表存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置
image.png

如下案例:i=1这行源代码编译成字节码指令之后,会包含偏移量2-4这三行指令。其中2-3是对i进行赋值1的操作,4的没有异常就跳转到10方法结束。如果出现异常的情况下,继续执行到7这行指令,7会将异常对象放入操作数栈中,这样在catch代码块中就可以使用异常对象了。接下来执行8-9,对i进行赋值为2的操作。

所以异常表中,异常捕获的起始偏移量就是2,结束偏移量是4,在2-4执行过程中抛出了java.lang.Exception对象或者子类对象,就会将其捕获,然后跳转到偏移量为7的指令

栈空间一般多大

Linux系统中默认为 1 MB

image.png

image.png

本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowErrorOutOfMemoryError 两种错误

在Java内存中,堆空间也是最重要的一块区域,大部分的JVM调优手段都是基于堆空间而进行展开的。Java堆的作用与前面分析的Java栈不同,栈主要是作为运行时的单位,用于临时存储运行时需要以及产生的数据,而Java堆是存储的单位,主要解决的问题是数据存储问题,重点关注的领域是数据怎么存,放哪里,怎么放等

堆空间会在JVM启动时被创建出来,对于JVM来说,堆空间是唯一的,每个JVM只会存在一个堆空间,同时容量大小会在创建时就被确定,当然,我们可以通过参数-Xms-Xmx指定堆的起始内存大小和最大内存大小,当超过-Xmx参数指定的大小时则会抛出OOM

默认情况下,如果不通过参数强制指定堆空间大小,那么JVM会根据当前所在的平台进行自适应调整,起始大小默认为当前物理机器内存的1/64,最大大小默认为当前物理机器内存的1/4。

Java堆同时也是变化比较频繁的区域,在不同Java版本中,堆空间也发生了不同的改变(此句描述是不准确的,堆区分不分代跟JDK版本没关,主要取决于垃圾回收器的):

  • JDK7及之前:堆空间包含新生代、年老代以及永久代。
  • JDK8:堆空间包含新生代和年老代,永久代被改为元数据空间,位于堆之外。
  • JDK9:堆空间从逻辑上保留了分代的概念,但物理上本身不分代。
  • JDK11:堆空间从此以后逻辑和物理上都不分代。

本质上来说,影响堆空间结构的并不是Java版本的不同Java堆结构是跟JVM运行时所使用的垃圾回收器息息相关的,由GC器决定了运行时的堆空间会被划分为何种结构。

在JDK1.8及之前的Java版本中,几乎所有的GC器都会把堆空间划分为至少两个区域:新生代和年老代,但在JDK1.9到之后的GC器中,大多数的GC器开始了不分代的路子

分代堆空间:

分代的含义是指在JVM运行过程中,堆空间是否会被分为不同的区域分别用于存储不同生命周期的对象实例,JDK1.8之前的堆结构是完全分代的,也就是指逻辑+物理上都分代,在运行时物理内存会被划为几块不同的区域,也就是一个Eden区、两个Survivor 区(Form/To区)以及一个Old区,从物理内存上来说各个区域都是完整且连续的内存,每块区域都用于存储不同周期的对象实例,相互之间并不干扰。

不分代堆空间:

到了JDK1.9时,G1正式出道,成为了JVM内嵌的默认GC器,Java堆空间从此出现了不分代的概念,但不分代也分为两种情况,一种是逻辑分代,物理不分代,另一种则是逻辑+物理都不分代。

逻辑分代,物理不分代(G1):对象分配的逻辑上还是存在分代的思想,但是物理内存上不会再分为几块完整的分代空间。
逻辑+物理都不分代(ZGC、ShenandoahGC):无论从对象分配的逻辑上还是物理内存上,都不存在分代的概念。

JDK7及之前的堆空间内存划分

在JDK1.7及之前的JVM中,所有的GC器都是物理+逻辑都分代的,包括内嵌的默认GC器Parallel Scavenge(新生代)+ Parallel Old(老年代)也分代,所以一般堆空间会被划分为三个区域:新生代、年老代以及永久代

  • 新生代:一个Eden区、两个Survivor区(Form/To区),比例:8:1:1
  • 年老代:一个Old
  • 永久代:方法区

image.png|475

  • 新生代主要用于存储未达到年老代分配条件的对象,其中Eden区是专门用来存储刚创建出来的对象实例,两个Survivor区主要用于垃圾回收时给存活对象“避难”。
  • 年老代主要用于存储达到符合分配条件的对象实例,比如达到“年龄”的对象以及过大“体积”的大对象等。
  • 方法区/永久代主要用于存储类的元数据信息,如类描述信息、字段信息、方法信息、静态变量信息、异常表、方法表等。

默认情况下新生代和年老代的空间比例为1:2,新生代占1/3,年老代占2/3,当然也可以通过参数:-XX:NewRatio=x来指定比例,也可以通过-Xmn参数强制指定新生代的内存最大大小,如果和前面的Ratio参数冲突了则以后者为准。
新生代中,一个Eden区、两个Survivor区(Form/To区),默认比例为8:1:1,当然也可以通过参数-XX:SurvivorRatio调整这个空间比例。但实际上初始情况下是6:1:1,因为JVM存在自适应机制,当然也可以通过-XX:-UseAdaptiveSizePolicy参数关闭JVM的自适应机制(不推荐)。

JDK8堆空间内存划分

到了JDK1.8的时候,JVM将永久代,也就是方法区整合成了元数据空间,并且将其移出了堆,将其放在堆空间外的本地内存中。
image.png|352

JDK1.8的时候没啥好讲的,和1.7差距不大,最大区别在于移除了方法区,在本地内存中加入了元数据空间来存储之前方法区中的大部分数据(原方法区中的数据并不是所有都被迁移到了元空间存储,有些数据被分散到了JVM各个区域)。除此之外,常量池在1.8的时候也被移到了堆外

JDK9堆空间内存划分

到了JDK1.9时,堆空间慢慢的开始了划时代的改变,在此之前,堆空间的布局都是采用分代存储的方式,无论从逻辑上还是从物理内存上,都是分代的。但是到了Java9的时候,因为默认GC器改为了G1,所以堆中的内存区域被划为了一个个的Region区。逻辑分代+物理分区的结构是堆空间最佳的方案,所以G1的这种结构也是最理想的结构,但后续的ZGC、ShenandoahGC收集器中,因为实现方面的某些原因,导致最终无法采用这种结构。
image.png|500
在JDK1.9时,G1将Java堆划分为多个大小相等的独立的Region区域,不过在HotSpot的源码TARGET_REGION_NUMBER定义了Region区的数量限制为2048个(实际上允许超过这个值,但是超过这个数量后,堆空间会变的难以管理)。

G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是可以不连续物理内存来组成的Region的集合。

G1中的年老代晋升条件和之前的无差,达到年龄阈值的对象会被转入老年代的Region区中,不同的是对于大对象的分配,在G1中不会让大对象进入年老代,在G1中由专门存放大对象的Region区叫做Humongous,如果在分配对象时,判定出一个对象属于大对象,那么则会直接将其放入Humongous区存储。

在G1中,判定一个对象是否为大对象的方式为:对象大小是否超过单个普通Region区的50%,如果超过则代表当前对象为大对象,那么该对象会被直接放入Humongous区。比如:目前是8GB的堆空间,每个Region区的大小为4MB,当一个对象大小超过2MB时则会被判定为属于大对象。

Humongous区存在的意义:可以避免一些“短命”的巨型对象直接进入年老代,节约年老代的内存空间,可以有效避免年老代因空间不足时的GC开销。

当堆空间发生全局GC(FullGC)时,除开回收新生代和年老代之外,也会对Humongous区进行回收。

JDK11堆空间内存划分

在JDK11的时候,Java又推出了一款新的垃圾回收器ZGC,它也是一款基于Region区内存布局的GC器这款GC器是真正意义上的不分代,无论是从逻辑上还是物理上都不分代。

image.png|475

在ZGC中,也会把堆空间划分为一个个的Region区域,但ZGC中的Region区不存在分代的概念,它仅仅只是简单的将所有Region区分为了大、中、小三个等级:

  • 小型Region区(Small):固定大小为2MB,用于分配小于256KB的对象。
  • 中型Region区(Medium):固定大小为32MB,用于分配>=256KB ~ <=4MB的对象。
  • 大型Region区(Large):没有固定大小,容量可以动态变化,但是大小必须为2MB的整数倍,专门用于存放>4MB的巨型对象。但值得一提的是:每个Large区只能存放一个大对象,也就代表着你的这个大对象多大,那么这个Large区就为多大,所以一般情况下,Large区的容量要小于Medium区,并且需要注意:Large区的空间是不会被重新分配的(GC篇章详细分析)。

PS:实际上,JDK11中的ZGC并不是因为要抛弃分代理念而不设计分代的堆空间的,因为实际上最开始分代理念被提出的本质原因是源于「大部分对象朝生夕死」这个概念的,而实际上大部分Java程序在运行时都符合这个现象,所以逻辑分代+物理不分代是堆空间最好的结构方案。但问题在于:ZGC为何不设计出分代的堆空间结构呢?其实本质原因是分代实现起来非常麻烦且复杂,所以就先实现出一个比较简单可用的单代版本,后续可能会优化改进(但实际上能不能改进成功还不好说,ZGC的研发团队负责人Per是从JRockitGC组过来的,R大在和per聊天时曾聊到过:per之前在JRockitGC器上尝试了四五次都以失败告终,ZGC上能不能成功还是得看未来了)。

如何改变堆区的大小

堆的大小

image.png

image.png

image.png

  • -Xms<size>:设置初始堆大小,total。例如,-Xms1024m表示将初始堆大小设置为 1024MB。
  • -Xmx<size>:设置最大堆大小,max。例如,-Xmx2048m表示将最大堆大小设置为 2048MB。
  • -Xmn<size>:设置新生代大小。例如,-Xmn256m表示将新生代大小设置为 256MB。
  • -XX:NewRatio=<ratio>:设置老年代与新生代的比例。例如,-XX:NewRatio=3表示老年代与新生代的比例为 3:1,即老年代占堆空间的四分之三,新生代占四分之一。
  • -XX:SurvivorRatio=<ratio>:设置 Eden 区与 Survivor 区的比例。例如,-XX:SurvivorRatio=8表示 Eden 区与 Survivor 区的比例为 8:1:1。
  • -XX:MaxDirectMemorySize:设置直接内存(本地内存)的最大值
  • -XX:MaxPermSize 参数来设置永久代的最大值
  • -XX:MaxMetaspaceSize 参数来设置元空间的最大值

在Java中,设置老年代的空间大小通常不是直接进行的,因为老年代的大小是由整个堆内存大小减去新生代的大小得出的,所以可以通过调整新生代和老年代的比例来改变老年代的大小

image.png

方法区

方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。

当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据

静态变量在JDK6是被存放到方法区当中的,在JDK7及其之后,就被放到了堆区中的Class对象中,脱离了永久代;

方法区和永久代(处在堆中)以及元空间(处在本地内存中)是什么关系呢? 方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。

image.png|450

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢

1、整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整(也就是受到 JVM 内存的限制),而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

2、元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。

3、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

方法区和元空间以及永久代是什么关系

方法区、元空间以及永久代是Java运行时环境中用来管理类元数据的不同实现方式。下面我将详细介绍它们之间的关系及其区别:

方法区 (Method Area)

方法区是Java虚拟机规范中定义的一个逻辑区域,它用于存储每个类的信息(如类名、方法信息、常量池等)、静态变量、常量以及编译后的代码等。方法区是Java虚拟机的一部分,并不是由程序直接创建的,而是由JVM实现的。

永久代 (Permanent Generation)

在Java 8之前(即Java 7及更早版本),方法区的具体实现通常被称为“永久代”(PermGen)。永久代是HotSpot虚拟机(Sun/Oracle的官方JVM实现)的一种实现方法,它位于堆内存中,与普通对象共享堆空间。

  • 特点

  • 永久代的大小是有限制的,可以通过 -XX:MaxPermSize 参数来设置最大值。

  • 永久代会进行垃圾回收,但频率较低,主要是回收不再使用的类元数据。

  • 问题

  • 永久代可能会导致OutOfMemoryError,特别是当加载大量类时。

  • 由于它位于堆内存中,可能会影响堆内存的整体性能。

元空间 (Metaspace)

Java 8中引入了元空间作为方法区的新实现。元空间不在堆内存中,而是使用本机内存。这意味着元空间的大小不再受堆内存大小的影响,而是受到系统的物理内存限制。

  • 特点
  • 元空间默认情况下没有固定的最大值,可以根据需要动态扩展。
  • 也可以通过 -XX:MaxMetaspaceSize 参数来显式指定其最大值。
  • 元空间同样会进行垃圾回收,但频率很低,因为类元数据通常不会经常变化。

总结

  • 方法区:Java虚拟机规范定义的概念,用于存储类元数据。
  • 永久代:Java 8之前HotSpot JVM对方法区的一种具体实现,位于堆内存中。
  • 元空间:Java 8及以后HotSpot JVM对方法区的一种新实现,位于本机内存中。

运行时常量池

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table)

运行时常量池(Runtime Constant Pool)是方法区的一部分,它用于存储编译期生成的各种字面量和符号引用。在 Java 中,每个类或接口都有自己的运行时常量池,它是类文件常量池的内存映射

字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量。常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号

常量池表会在类加载后存放到方法区的运行时常量池中。

运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误

image.png|450

类似的:Class文件结构中的常量池 常量池(Constant Pool)

JVM内存区域中的常量池和Class文件中的常量池一样吗

  1. 定义
  • Class 文件中的常量池
    • Class 文件中的常量池是 Class 文件结构的一部分。当 Java 源文件被编译成 Class 文件时,编译器会将 Java 源文件中的字面量(如字符串字面量、基本数据类型的常量值等)和符号引用(类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等)放到常量池中。
    • 例如,在 Java 源文件中有一个字符串常量"Hello World",编译后这个字符串常量会被存储到 Class 文件的常量池中。Class 文件常量池是一种表结构,每个常量项都有一个对应的类型和值。
  • JVM 内存区域中的常量池(运行时常量池)
    • 运行时常量池是 JVM 内存区域的一部分,它是方法区的一部分内容。当类加载器将 Class 文件加载到 JVM 内存中时,会将 Class 文件中的常量池解析并放入运行时常量池中
    • 运行时常量池在运行时还可以动态地将新的常量放入其中,比如通过String.intern()方法可以将字符串常量放入运行时常量池。
  1. 内容差异
  • Class 文件常量池
    • 主要包含编译期确定的字面量和符号引用。这些内容是在编译阶段就确定下来的,是一种相对静态的结构。例如,一个类中定义的所有字符串字面量、方法的符号引用等都会在编译时被写入 Class 文件常量池。
  • 运行时常量池
    • 它除了包含从 Class 文件常量池解析得到的内容外,还可以包含运行时动态生成的常量。例如,在运行过程中,如果调用String.intern()方法,就可能会将新的字符串常量添加到运行时常量池中。并且,运行时常量池中的符号引用在类加载过程中可能会被解析成直接引用。
  1. 内存管理差异
  • Class 文件常量池
    • 它存在于磁盘上的 Class 文件中,随着 Class 文件的存在而存在,不需要考虑内存的动态分配和回收等问题,是一种编译时的结构。
  • 运行时常量池
    • 作为 JVM 内存区域的一部分,它需要考虑内存的管理。在 JVM 启动时,会根据配置参数为运行时常量池分配一定的内存空间。如果运行时常量池中放入的常量过多,可能会导致内存溢出(OutOfMemory)错误。并且,当一个类被卸载时,其对应的运行时常量池中的部分内容(如果没有被其他类共享)也会被回收。
  1. 数据结构表示差异
  • Class 文件常量池
    • 在 Class 文件中,常量池是一种表结构,通过索引来访问其中的常量项。例如,在字节码指令中,如果要使用一个字符串常量,会使用该字符串常量在常量池中的索引。
  • 运行时常量池
    • 虽然它是从 Class 文件常量池解析而来,但在 JVM 内存中,它的具体数据结构是由 JVM 实现决定的,并且在运行时会进行各种优化操作,比如常量池中的符号引用解析为直接引用等操作。

综上所述,JVM 内存区域中的常量池(运行时常量池)和 Class 文件中的常量池是不一样的,它们在定义、内容、内存管理和数据结构表示等方面都存在差异。

字符串常量池

字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

1
2
3
4
5
6
// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true

HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 可以简单理解为一个固定大小的HashTable ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置),保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象

JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量永久代移动了 Java 堆中。

image.png|450