运行时数据区(Runtime Data Area)

不同的JVM对于内存的划分方式和管理机制有一定的区别,我们主要看一些经典的HotSpotVM内存结构。

image-20241213161409066

Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。

image-20241213162714220

1、虚拟机栈

Java虚拟机栈(Java Virtual Machine Stack),也叫Java栈。每一个线程在创建时都会创建一个虚拟机栈,内部是一个个的栈帧(Stack Frame),对应着一次次的Java方法调用。Java栈的生命周期和线程一致

栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。

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

栈中不存在垃圾回收,但是会爆满,即OOM内存溢出。

栈和堆的区别?

  • 栈(Stack):基本单元是栈帧,每一个栈帧中存储的是局部变量表和调用信息,每一个栈帧对应着一个方法,方法执行期间局部变量被创建在栈上,并在方法结束时被销毁。主管方法的运行过程。

  • 堆(Heap):用于存储对象实例和数组,每当new关键字创建对象时,JVM就会在堆中分配内存空间。

  • 栈的速度仅次于程序计数器,反正比堆快。

  • 栈的大小看是哪个平台和哪个JDK版本了,JDK1.5的栈大小是256K,而JDK1.8的时候就1M了。而堆的大小就比栈大了,具体要看操作系统和程序来设定没有固定值。所以栈主管运行,堆主管存储。

Java虚拟机规范允许Java栈的大小可以是固定也可以是动态的,这就代表着有两种内存溢出的情况了

  • StackOverFlowError:

    如果采用的是固定大小的虚拟机栈,那么每个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过了Java虚拟机允许的最大容量,Java虚拟机将会抛出一个StackOverFlowError异常了。

  • OutOfMemoryError(OOM)

    如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存空间,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机会抛出一个OutOfMemoryError异常

比如:

  1. 局部数组过大。当函数内部的数组过大时,有可能导致堆栈溢出。

  2. 递归调用层次太多。递归函数在运行时会执行压栈操作,当压栈次数太多时,也会导致堆栈溢出。

设置栈大小:-Xss size

  • 一般默认为512k-1024k(1M),取决于操作系统。

  • 栈的大小直接决定了函数调用的最大可达深度。

image-20241213172222305

如果设置的栈空间值过大,会导致系统可以用于创建线程的数量减少。一般一个进程中通常有3000-5000个线程。

1.1、栈帧

每一个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在的。

1.1.1、方法和栈帧之间的关系

  • 在这个线程上正在执行的每一个方法都各自对应着一个栈帧;

  • 栈帧是一个内存区块,是一个数据集,维护着方法执行过程中的各种数据信息;

在一条活动的线程中,同时间只有一个活动的栈帧,即只有一个当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧,当前栈帧对应的那个方法被称为当前方法,定义这个方法的类被称为当前类。如果在当前方法中又调用了别的方法,那么新的栈帧会被创建,放在栈的顶部成为当前帧。

执行引擎运行的所有字节码指令只针对当前栈帧进行操作

image-20241215101419091

1.1.2、栈的先进后出原理

JVM直接堆Java栈的操作只有两个:

  • 每个方法的执行伴随着入栈操作;

  • 执行结束之后的出栈操作;

    遵循先进后出的原则

不同线程中包含的栈帧之间不允许相互引用,也就是说不可能在一个栈帧中引用另外一个线程的栈帧。

image-20241215102017251

1.2、栈帧内部结构

每一个栈帧里面都有这几个东西

  • 局部变量表;

  • 操作数栈,或者表达式栈;

  • 动态链接,或者说是指向运行时常量池的方法引用;

  • 方法返回地址,或者方法正常退出或异常退出的定义;

  • 其他杂七杂八附加信息

image-20241215162338482

1.2.1、局部变量表

由于局部变量表是建立在线程之上,是线程的私有数据,所以不存在数据线程安全问题。局部变量表的基本存储单位是槽位。

变量槽(Slot)

  • 参数值的存放总是在局部变量数值的index为0的位置开始,到数组长度为-1的索引结束。

  • 局部变量表里,32位以内的类型只占用一个slot,64位的类型(long、double)占用两个solt

  • JVM会为每一个槽位都分配一个索引,通过索引就能拿到所对应的槽位的局部变量值。

  • 当一个实例方法被调用的时候,它的方法参数和方法体中定义的局部变量会按照顺序被复制到局部变量表中的每一个slot上。

  • 如果要访问局部变量表中的一个64位的局部变量时,只需要使用前一个索引即可(long、double)

  • 如果当前栈帧是构造方法或者实例方法创建的,那么该对象的引用this会存放在index为0的槽位中,其他的变量再按照顺序排列。

局部变量表的槽位是可以复用的,如果一个局部变量过了它的作用域,那么在其他的作用域申明的新局部变量就会有可能复用过期的局部变量表的槽位,从而达到节省资源的目的。

静态变量与局部变量的对比

  • 方法的参数参数分配完之后,再根据方法内定义的变量顺序和作用域分配。

  • 我们知道静态变量有两次初始化的过程,第一个就是链接中的“准备阶段”进行静态变量设置默认的0值,第二次在“初始化”阶段才赋予程序员写的初始值。

  • 和静态变量初始化不同的是,局部变量表不存在系统初始化过程,这意味着一旦定义了局部变量就必须人为的赋予初始值,不然没法用。

比如这样的方法中局部变量不写初始值的就是错误的

 public void test(){
     int a;
     System.out.println(a);
 }

局部变量表和GC Roots的关系

局部变量表中的变量也是重要的垃圾回收根节点,只要是被局部变量表中直接或者间接引用的对象都不会被回收

image-20241215171340771

1.2.2、操作数栈

我们说的Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈就指的是操作数栈。

每一个独立的栈帧中除了包含局部变量表以外,还包含一个先进后出的操作数栈,也可以叫表达式栈。操作数栈就是JVM执行引擎的一个工作区,当一个方法开始执行的时候,一个新的栈帧也会被创建出来,这个方法的操作数栈初始是空的。

每一个操作数栈都会有一个明确的栈深度用于存储数值,其所需要的最大深度在编译期间就以及规定好了,保存在方法的Code属性中,叫max_stack的值。

栈里的任何一个元素都可以是任意的Java数据类型

  • 32bit的类型占用1个栈单位的深度;

  • 64bit的类型占用2个栈单位的深度;

操作数栈,在方法执行过程中,根据字节码指令,并非采用索引的方式来进行数据访问的,而是只能按照栈的标准的入栈和出栈操作,往栈中写入数据或者提取数据来完成一次数据的访问。

有些字节码指令将值压入操作数栈,其他的字节码指令将操作数取出栈,写一步可能就是加载到局部变量表中给局部变量赋值,使用之后的把结果再压入栈。

如果被调用的方法有返回值的话,其返回值会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

操作数栈主要就是用于保存计算过程中间结果,同时作为计算过程中变量的临时存储空间,操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,编译器在编译初期就要进行验证,在类加载过程中类的验证阶段对数据流还要分析验证。

栈顶缓存技术:

基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也意味着将需要更多的指令分派次数和内存读/写次数。

由于操作数是存储在内存中的,频繁的执行内存读写必然影响执行速度。为了解决这个问题,栈顶缓存技术(ToS,Top-of-Stack Cashing)出现,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。

1.2.3、动态链接

动态链接或者说是指向运行时常量池的方法引用,每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的是为了支持当前方法的代码能够实现动态链接。比如:invokedynamic指令。

在Java源文件被javac编译成字节码文件的时候,所有的变量和方法引用都作为符号引用(符号引用相当于刚出生的婴儿还没有名字,为了区分打个标签叫某某某之子)保存在class文件的常量池里。比如,描述一个方法调用了其他的方法,就是通过常量池中指向方法的符号引用来表示的,那么动态链接就是为了把这些符号引用转换为调用方法的直接引用(给娃取个名,上个户口有个身份以后就能直接用大名叫了)

那么常量池的作用就是为了提供一些符号和常量,便于指令的识别。

1.2.2、方法返回地址

当一个方法开始执行之后,只有两种方式可以退出这个方法

1、执行引擎遇到了任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称为正常完成出口:

  • 一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型来决定。

  • 在字节码指令中,返回指令包含ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。

2、当方法执行过程中出现了异常,并且这个异常没有在方法内部进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口:

  • 方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。

  • 本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

2、本地方法接口和本地方法栈

什么是本地方?

简单来讲,一个Native Method就是一个Java调用非Java代码的接口。Native Method不是用Java写的代码用C写的,这个特性并非Java所独有,很多编程语言都这样。

为什么要使用Native Method?

  • 与Java环境外交互:

    有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。你可以想想Java需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。

  • JVM支持着Java语言本身和运行时库,它是Java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一些底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用C写的。还有,如果我们要使用一些Java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。

  • Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。

目前本地方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用Web Service等等。

本地方法栈

Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。本地方法栈也是线程私有的。本地方法栈的具体做法就是在本地方法栈中登记本地方法,然后在执行引擎执行的时候加载到本地方法库。

当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。

  • 它甚至可以直接使用本地处理器中的寄存器

  • 直接从本地内存的堆中分配任意数量的内存。

3、

《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。

“几乎”所有的对象实例都在这里分配内存。

  • 一个JVM实例 只存在一个堆内存,堆也是Java内存管理的核心区域。

  • Java堆区在JVM启动的时候就会被创建,其空间大小也就确定了,是JVM管理的最大一块内存空间。

  • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

  • 堆,是GC执行垃圾回收的重点区域。

  • 在方法结束之后,堆中的对象不会被马上移除,仅仅在垃圾回收器的时候才会被移除。

  • 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer, TLAB)。

3.1、堆的内部结构

现代垃圾收集器大部分都是基于分带收集理论设计,堆空间细分为:

image-20241218110645704

Java 8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间

  • Young Generation Space 新生区 Young/New

  • 又被划分为Eden区和区

  • Tenure generation space 养老区 Old/Tenure

  • Meta Space 元空间 Meta

名词等价关系:别换个名字不认识了

新生区<=>新生代<=>年轻代 养老区<=>老年区<=>老年代 永久区<=>永久代

3.2、新生代和老年代

存储在JVM中的Java对象可以被划分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都很迅速。像这种短生命周期的对象就会分配在新生代。

  • 另一类对象就是生命周期很长,在某些极端的情况下还能与JVM的生命周期保持一致。

    像这种生命周期很长,分配在新生代的对象经过多次垃圾回收还在活着的对象,这种老不死的会被晋升到老年代。

Java堆区分为新生代和老年代,新生代里又分为伊甸园(Eden)空间、Survivor0空间和Survivor1空间 (有时也叫做from区、to区)

image-20241218110706104

  • 几乎所有的Java对象都是在伊甸园区被new出来的。(大对象放不下除外)

  • Survivor幸存者区:Eden区中存活的对象会被复制到Survivor区,经过多次的GC存活的对象会被晋升到老年代。

  • 绝大部分的Java对象的销毁都在新生代进行了。

3.2.1、为什么Java的垃圾收集器将堆分为新生代和老年代?

主要就是为了提高垃圾回收效率,根据对象的生命周期特点来进行优化。

要是不分区其实也行,就是所有的对象都放在一起,鱼龙混杂的你只想清理掉没有用的对象,大家都混在一起,你也不好区分,所以只能全堆扫描一遍清理,很多对象又都是朝生夕死的,那扫描频率得多快,每一次扫描都会STW让其他线程暂时停止工作,那么频繁的停止那还咋干活,全扫的效率又那么低。所以给他分分代。

同时,分区分类管理也有助于使用不同的垃圾回收算法回收对象。

  • 新生代的回收算法新生代一般采用的是复制算法,新生代中大部分对象生命周期都很短,基本上在一次的GC中就会被回收,复制算法只需要在内存中保留少部分存活对象,并把它们复制到Survivor空间,回收剩余区域。这种算法的效率高,比较适合新生代频繁的创建对象和回收的特点。效率高,且避免内存碎片。

  • 老年代的回收算法:老年代对象是从新生代对象晋升而来的,存活时间太长,回收的效率很低,使用标记-整理算法或者标记-清除算法来清理。

    • 标记-清除算法:遍历对象图,标记还在存活的对象,清除未标记的对象,但很容易产生内存碎片。

    • 标记-整理算法:标记存活对象之后,把标记的对象整理到堆的一端,清理调没有标记的对象,避免内存碎片化。

3.2.2、新生代中为什么会划分为Eden区和Survivor区?

主要还是为了能够提高新生代内存的利用率

按照新生代中的对象都是朝生夕死的特点,适合复制算法。按照正常的思路给新生代一分为2,每一次只使用一半的内存空间,GC之后把存活的对象复制到另一半,然后再清理老区域里非活动对象,就这样交替的使用这两块区域可以避免内存碎片的产生。

但是,每次只能使用一半的内存空间也太少了吧,内存利用率太低了吧。所以,划分三个区域,分别为Eden区、Survivor0区(简称S0)和Survivor1(简称S1)区,它们的默认比例是Eden区占80% S0占10% S1占10%。8:1:1 我们使用这两个Survivor区交替的接收存活的对象,这样利用率就上来了。

3.3、对象分配规则

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。

分配策略:

如果对象在Eden 出生并经过第一次MinorGC 后仍然存活,并且能被Survivor 容纳的话,将被移动到Survivor 空间中,并将对象年龄设为1 。对象在Survivor 区中每熬过一次MinorGC , 年龄就增加1岁,当它的年龄增加到一定程度(默认为15 岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代中。

内存分配原则:

  • 优先分配到Eden区(在此之前优先分配到TLAB)

  • 大对象直接分配到老年代(要尽量避免出现大对象)

  • 长期存活的对象分到老年代(15次大逃杀)

  • 动态对象年龄判断:

    如果幸存区中相同年龄的所有对象大小的总和大于幸存空间的一半,年龄大于等于这个年龄的对象可以直接进入老年代,不需要等待15次的年龄要求。

空间分配担保

-XX:HandlePromotionFailure

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间

  • 只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

对象在堆活动的过程:

  1. new的对象先放到伊甸园区。大小有限制(占堆的三分之一)

  2. 当伊甸园区满了,程序又要创建新的对象,JVM就得启动垃圾回收器对伊甸园区进行垃圾回收(Minor GC),将对伊甸园区中不再被其他对象引用的对象进行销毁。

  3. 一波销毁之后,对幸存者复制到幸存者0区.

  4. 如果伊甸园区又满了再来一次GC,幸存者0区也会经历一次GC,如果幸存者0区还有活下来的,就复制到幸存者1区。

  5. 如果又有GC,此时会重新把幸存者放到幸存者0区,就这样Survivor0区和Survivor1区交替存留幸存者对象。

  6. 养老区怎么去?默认在经历15次大逃杀之后就可以去了

    • 可以设置参数:-XX:MaxTenuringThreshold=<N> 设置对象晋升老年代的年龄阈值。

  7. 在养老区,相对比较悠闲。当养老区内存不足的时候,再次触发GC:Major GC,进行养老区的内存清理。

  8. 养老区执行了Major GC之后发现还保存不了内存,就会触发OOM异常。

例子:比如当前使用Eden加上S0这两块区域,GC时候把存活对象复制给S1里,清理Eden区和S0区

image-20241218104953251

再来一波新的对象被创建,又一次的清理,继续给存活的对象复制给S0,就这样周而复始轮回复制。

image-20241218111207086

3.4、堆内存大小的设置

新生代和老年代的占比为1:2,即新生代占有堆的三分之一空间,老年代占三分之二。

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

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

  • 可以使用选项-Xmn设置新生代最大内存大小,但是一般默认不调它。

伊甸园区和幸存者区的比例也能自己设置,但一般用默认的8:1:1就行了

  • 开发人员可以通过选项-XX:SurvivorRati调整这个空间比例。比如-XX:SurvivorRatio=8

小结堆大小参数设置

  • -Xms:初始内存 (默认为物理内存的1/64);

  • -Xmx:最大内存(默认为物理内存的1/4);

设置新生代垃圾的最大年龄。超过这个值,仍然没有被回收的话,就进入老年代

  • 默认的是15.

  • -XX:MaxTenuringThreshold=0:表示年轻代对象不经过Survivor区,直接进入老年代。对于老年代比较多的应用,可以提高效率。

  • 如果给这个值设置成一个比较大的值,则新生代对象会在Survivor区进行多次复制,这样可以增加对象在新生代的存活时间,增加年轻代就被回收的概率。

输出详细的GC处理日志

  • -XX:+PrintGCDetails

3.5、MinorGC、MajorGC、FullGC

JVM在进行GC时,并非每一次都对三个内存(新生代、老年代、方法区)区域一起回收,大部分都是新生代在GC

针对于HotSpotVM实现,他里面的GC按照回收区域又分为两种大类

  1. 第一种是部分收集(Parttial GC)

    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集。

    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集。

      • 目前,只有CMS GC会有单独收集老年代的行为。

      • 很多时候Major GC会和Full GC混淆使用,需要具体的分辨是老年代回收还是整堆回收。

    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。

  2. 第二种是整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

3.5.1、MinorGC触发机制

  • 当年轻代空间不足的时候,就会触发Minor GC。这里年轻代指的是Eden区满了,Survivor满不会引发GC。

  • 大多数的对象都是朝生夕死的,所以Minor GC非常的频繁,一般回收速度也比较快。

  • MinorGC会引发STW,暂停其他用户的线程,等垃圾回收结束,用户线程才会恢复运行。

3.5.2、MaiorGC触发机制

MaiorGC是发生在老年代的GC,对象从老年代消失,出现了MajorGC,经常伴随至少一次的MinorGC(但也不是绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择),也就是说,老年代的空间不足了,先尝试触发MinorGC。如果还是不足,再触发MajorGC

MajorGC的速度一般比MinorGC慢10倍以上,STW时间更长。如果MajorGC后,内存还是不足,就OOM了。

3.5.3、FullGC触发机制

收集整个Java堆和方法区的垃圾收集触发有以下5种情况:

  • 调用System.gc()时,系统只是建议执行FullGC,但是不必然执行。

  • 老年代空间不足。

  • 方法区空间不足

  • 通过MinorGC后进入老年代的平均大小大于老年代的可用内存

  • 对于一些很大的对象,从Eden区存活下来开始复制到Survivor区的时候超过了Survivor的大小,然后再往老年代转,如果老年代的当前可用内存也不够,那就FullGC了,FullGC之后还不够那就报OOM了

    Full GC是开发过程中尽量要避免的,这样工作线程暂停时间会短一点。

3.6、TLAB(Thread Local Allocation Buffer)

TLAB(Thread Local Allocation Buffer)是JVM中为每一个线程分配的一小块堆内存,用于加速对象的分配操作每一个线程都有自己的TLAB,大大加速了内存分配的同时避免了多线程竞争共享堆内存的同步开销。

为什么要有TLAB?

  • 堆区是线程共享区域,任何线程都可以访问到堆中共享的数据。

  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆内存中划分内存空间是线程不安全的。

  • 为避免多个线程操作同一个地址,需要使用加锁等机制,进而影响分配速度。

所以,多线程同时分配内存时,使用TLAB可以避免一系列非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略。

工作流程:

  • 每个线程在执行过程中优先会先从自己的那一亩二分地TLAB中创建对象。

  • 自己分配的TLAB空间不够了,线程会重新申请一个新的TLAB或者直接交给Eden区分配。

  • 大对象就直接走Eden区了,线程的那点根本不够(再大可能连幸存区都不够进直接进老年代了)

给线程分配的TLAB只允许这个线程在里面创建对象,其他线程可以访问这块TLAB但不能创建对象

image-20241219153847643

堆的遍历是线性的,创建对象靠创建对象指针往后加对象大小长度就行,那多个线程都要创建对象,你要是堆里只有一个大的指针,那这个指针不就成了抢手货,线程互斥拿到指针的效率很低,给每一个线程都分一个TLAB空间,等给这自己的空间用完了之后再去申请。

  • JVM将TLAB作为内存分配的首选项

  • 通过选项-XX:+/-UseTLAB设置是否开启TLAB空间+是开-是关

  • 默认情况下,TLAB的空间非常小,只有整个Eden空间的1%,可以使用-XX:TLABWasteTargetPercent设置TLAB空间所占Eden空间的百分比大小

  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。也可以再次申请新的TLAB空间。

4、方法区

《Java虚拟机规范》中明确说明: “尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。” 但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。所以,方法区看作是一块独立于Java 堆的内存空间。

  • 方法区和Java堆一样,是线程共享的。

  • 方法区在JVM启动时就被创建,并且它的实际的物理内存空间和Java堆区一样都可以不连续。

  • 方法区的大小取决了系统能保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样也会抛出内存溢出错误:java.lang.OutOfMemoryError: PermGen space或者java.lang.OutOfMemoryError: Metaspace

  • 关闭JVM会释放这个区域的内存

方法区的演进

  • JDK7之前,习惯把方法区称为永久代,永久代就是方法区的实现,JDK8开始使用元空间取代了永久代。

  • 本质上方法区和永久代并不是等价的。仅仅只是对hotspot而言的。永久代会导致Java程序更容易OOM

  • 而到了JDK8,完全废掉了永久代的概念,改用与JRokit、J9一样在本地内存中实现的元空间(Metaspace)来代替。

  • 元空间的本质和永久代是类似的,都是对JVM规范中方法区的实现。永久代和元空间二者并不只是名字变了,内部结构也调整了,它们之间的最大区别在于:

    元空间不足虚拟机设置的内存中,而是使用本地内存。

4.1、方法区中存储的内容

类型信息

对每个加载的类型(类Class、接口Interface、枚举enum、注解annotation),JVM必须在方法区中存储下面几个类型信息:

  • 这个类型的完整有效名称,全名=包名.类名;

  • 这个类型直接父类的完整有效名(对于interface或者顶层的Object类型没有父类);

  • 这个类的修饰符(public,abstract,final的某个子集);

  • 这个类型直接接口的一个有序列表;

域(Field)信息

JVM必须在方法区中保存类型的所有域信息以及域的声明顺序,域的相关信息包括:域名称、域类型、域修饰符(public private protected static final volatile transient的某个子集)

方法相关信息

  • 方法名称;

  • 方法的返回类型或者void;

  • 方法参数的数量和类型按照顺序;

  • 方法的修饰符(public private protected static final volatile transient

  • 方法的字节码、操作数栈、局部变量表以及大小(abstract和native方法除外);

  • 异常表(abstract和native方法除外)中,每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的异常池索引。

non-final的类变量

  • 静态变量和类关联一起,随着类的加载而加载,它们随着类的加载而加载。

  • 类变量被类的所有实例共享,即使没有类的实例你也能访问到。

  • 全局常量:static final被声明为final的类变量在编译的时候就会被分配了

运行时常量池

运行时常量池是方法区的一部分,常量池表是Class文件的一部分,用于存放编译期生成的各种字面量和符号引用,这部分内容在类加载后存放到方法区的运行时常量池中。在加载类和接口到虚拟机之后,就会创建对应的运行时常量池。

JVM为每一个已加载的类型都维护一个常量池。池中的数据项像数组一样,是通过索引访问的。运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括运行期解析后才能获得的方法或者字段引用。此时不再是常量池中的符号地址了,变为直接地址。

运行时常量池相对于Class文件常量池的另一重要特性是:具备动态性

可以把常量池看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。

4.2、永久代和元空间

只有HotSpot才有永久代,原则上如何实现方法区属于虚拟机实现细节,并不受Java虚拟机规范管,没有统一的要求。

HotSpot中永久代的变化

  • jdk1.6之前:有永久代;

  • jdk1.7:有永久代,但是已经逐渐的去永久代,字符串常量池、静态变量移除,保存到堆中。

  • jdk1.8之后:无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池仍然在堆中。

image-20241224101235783

这里我们发现StringTable在JDK7的时间被放到堆空间中,这是因为永久代中的回收效率很低,几乎不回收,只有在fullGC时才会回收一次,而fullGC是老年代的空间不足、永久代不足才触发的。我们开发工程中会大量的创建字符串,回收效率又低,这样会容易导致永久代内存不足,给他放到堆中能及时回收。

方法区是否存在GC,回收哪些?

会一定是会,但是几乎又不会。一般来说这个区域的回收效果比较难令人满意,尤其是类的卸载,条件是相当的苛刻。但是这部分区域的回收又确实很有必要,这部分如果一直不回收很容易导致内存泄露。

方法区的垃圾回收主要是回收两个部分:常量池中废弃的常量和不再使用的类型

常量池中主要存放的两大类常量:字面量和符号引用

  • 字面量就很容易理解了,就是Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。

  • 符号引用就属于偏向于编译原理方面的概念,包括下面三类的常量:

    • 类和接口的全限定名

    • 字段的名称和描述符

    • 方法的名称和描述符

HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。回收废弃常量与回收Java堆中的对象非常类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。

  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。

  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

小全景图

image-20241224102927411

5、StringTable

通过字面量的形式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。

字符串常量池中是不会存储相同内容的字符串的。

 public class StingTableTest {
     public static void main(String[] args) {
         String a = "abc";
         String b = "abc";
         String c = new String("abc");
         System.out.println(a==b);
         System.out.println(b==c);
     }
 }

结果就是true和false,字面量的赋值a和b都是在字符串常量池中相同的位置,而你new出来的String对象是创建在堆内存中的而不是字符串常量池了。

image-20241224103751148

String的不可变性

一旦一个String对象被创建,其内容就不能被改变。当你尝试修改一个String对象的内容时,实际上会创建一个新的String对象来存储修改后的内容,而原始对象的内容不会被改动。

不可变的原因:

  • String类是被声明为了final,这就意味着它不能被继承,这有助于防止子类破坏String的不可变性,维护Stinrg这个最最常用的类的安全性。

  • String类的内部使用了被final修饰的char数组来存储字符数据。由于数组本身是可变的,但是String本身并没有提供任何公共方法来修改这个内部的char数组,所以从外部看来String也是不可变的。

  • Java提供了字符串常量池来优化字符串的存储,当用字面量的形式创建一个字符串常量时,JVM首先会检查一下字符串常量池中有没有已存在的相同的字符串,如果有的话就直接返回常量池中的字符串引用,如果不存在就在字符串常量池中再创建一个新的字符串对象,并返回其引用,这样就减少了相同字符串的内存占用。

image-20241224105037181

String s = new String("abc");方式创建对象,在内存中创建了几个对象?

实际上是两个对象,一个在堆空间中的new对象,另一个是char[]对应的字符串常量池中的数据"abc"

  1. 字符串常量池中的对象

    • 当你在代码中写下 "abc" 这个字符串字面量时,Java编译器会首先检查字符串常量池中是否已经存在这个字面量所表示的字符串。如果不存在,JVM就会在字符串常量池中创建一个新的字符串对象来存储这个字面量 "abc"

  2. 堆内存中的对象

    • new String("abc") 这部分代码会在堆内存中创建一个新的字符串对象。这个新对象的内容与字符串常量池中的 "abc" 相同,但它们是两个不同的对象,分别位于不同的内存区域(字符串常量池在方法区,而堆内存是Java运行时环境用于存储所有对象实例的内存区域)

尽管这两个对象的内容相同,但它们在内存中的地址是不同的。因此,使用 == 运算符比较这两个对象的引用时,结果会是 false。而使用 equals() 方法比较这两个对象的内容时,结果会是 true

 String s1 = new String("abc");
 String s2 = "abc";
 ​
 System.out.println(s1 == s2); // 输出 false,因为比较的是引用(地址)
 System.out.println(s1.equals(s2)); // 输出 true,因为比较的是内容

intern()方法

intern() 方法是 Java 中 String 类的一个本地(native)方法,它的主要作用是将指定的字符串对象的引用添加到字符串常量池中,并返回该字符串在常量池中的引用。

intern() 方法的作用

  • 添加字符串到常量池:当调用 intern() 方法时,如果字符串常量池中已经包含了一个等于此 String 对象的字符串(根据 equals() 方法确定),则返回常量池中该字符串的引用。如果字符串常量池中不包含此 String 对象的字符串,则将此 String 对象添加到常量池中,并返回该字符串在常量池中的引用。

  • 优化内存使用:通过 intern() 方法,Java 应用程序可以减少字符串对象的数量,特别是当应用程序中使用了大量重复的字符串字面量时。因为所有重复的字符串都将引用字符串常量池中的同一个对象,这有助于减少内存占用。

  • 提高性能:使用 intern() 方法后,当需要比较大量字符串是否相等时,可以直接比较字符串对象的引用(如果它们已经被 intern() 处理过且相等),这比比较字符串内容要快得多。

示例:

 String s = new String("1");
 s.intern();
 String s2 = "1";
 System.out.println(s == s2);//false

虽然这里使用了s.intern();把字符串1添加到字符串常量池中并返回1在字符串常量池中的地址了,但是本身这个返回结果并没有再赋值给s,所以当前的s还是引用的堆内存中的字符串对象地址,所以跟s2指向的字符串常量池中的引用地址是不一样的,所以结果是false

 String s = new String("1");
 s = s.intern();
 String s2 = "1";
 System.out.println(s == s2);//false

这样就是true了,此时s和s2引用的都是字符串常量池中的地址。

再来一个:

 String s3 = new String("1") + new String("1");
 String interns3 = s3.intern();
 String s4 = "11";
 System.out.println(s3 == s4);//true
 System.out.println(interns3 == s4);//true

这里情况又不一样了

s3 == s4:这个比较是在比较两个字符串对象的引用。由于s3是在堆上的,而s4是在常量池中的,正常情况下它们不应该相等。但是,由于我们之前对s3调用了intern()方法,并且"11"被添加到了常量池中,此时JVM有可能对s3和常量池中的"11"进行了优化(这种优化在Java 7及以后的版本中非常常见,因为字符串常量池被移动到了堆中)。

具体来说,当s3.intern()被调用后,如果JVM发现常量池中已经有了相同内容的字符串(在这个例子中是通过s4添加的),它可能会将s3的引用直接指向常量池中的那个字符串(尽管这通常不是intern()方法的严格行为定义,但某些JVM实现可能会进行这种优化以提高性能)。因此,在某些JVM实现和特定情况下(比如开启了字符串去重化(String Deduplication)的JVM),s3 == s4可能会返回true。然而,这种行为并不是Java语言规范严格要求的,它依赖于JVM的具体实现和配置。