JVM运行时数据区

简介

JVM运行时数据区包括:JVM栈(虚拟机栈),堆,方法区,本地方法栈,PC寄存器。大概的划分就是栈和堆,以及一些其他的结构。重点在JVM栈,堆,方法区。JVM规范指出:方法区在逻辑上属于堆,但是实际的具体的JVM中并不属于堆的一部分。

在JVM栈中会发生GC和Error,但是在其他的内存区域中,可能没有GC或者Error。

有些区域的生命周期是跟随着虚拟机的,当虚拟机被关闭时,这部分的内存也被释放出来。有些是跟随线程的,当线程结束时,这部分的内存也被释放出来。

下图展示了哪些区域是线程共享和线程私有的。

线程私有的:PC寄存器,栈,本地方法栈

线程间共享的:堆,堆外内存(永久代或元空间,代码缓存)

每个JVM对应Java的RunTime类的对象,RunTime实例在每个JVM中只有一个。

运行时数据区的结构图解:

运行时数据区的结构图解

内存区域的划分

PC寄存器(程序计数器)

简介

类似与计算机组成原理中提及的CPU的PC寄存器,但是CPU的PC寄存器是有实际硬件的,但是JVM的PC寄存器没有实际的硬件部分,这个是每个线程私有的。

JVM的PC寄存器只是一块很小很小的内存区域。

作用

  • PC寄存器用来存储指向下一条指令的地址,也就是即将执行的指令,由执行引擎读取下一条指令,程序的分支,循环,跳转,异常处理,线程恢复等基础功能都需要PC寄存去来完成。
  • PC寄存器是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域。

常见问题

为什么需要PC寄存器

  • 因为CPU需要不停地切换线程,当切换回来的时候就需要知道该线程从哪里开始继续执行。
  • 如果没有这个机制,那么切换线程应当不容易。有了这个机制之后,只需要读取PC寄存去的指令就知道该执行哪一个指令,实现起来比较容易。

为什么PC寄存器是线程私有的

因为需要准确记录各个线程正在执行的当前的字节码指令,必须是线程私有的,如果不是线程私有的,意味着任意线程可以随意更改任意线程的字节码指令,这会导致程序的错误。

JVM栈(虚拟机栈) 重点

简介

栈是运行时的单位,堆是存储的单位,既:栈存放着程序如何执行的问题,堆解决的时候数据存储的问题。

具体特性:

  • JVM栈是每个线程私有的。
  • 每创建一个线程就会创建一个虚拟机栈,虚拟机栈的基本单位是栈帧(Stack Frame),栈帧对应着方法调用。
  • 生命周期和线程一致。
  • 没有GC,有错误(下面提及)。
  • 访问速度仅次于PC寄存器
  • JVM对虚拟机栈的操作只有两个
    • 方法执行,伴随着进栈
    • 方法执行结束后,伴随着出栈

JVM规范中允许JVM栈的大小是动态的或者固定的,以下是JVM栈中会遇到的Error

  • JVM栈大小为动态,遇到以下情况会抛出OutOfMemoryError异常

    • 尝试拓展时不能申请到足够的内存
    • 创建新线程时没有足够的内存创建对应的JVM栈
  • 大小固定,遇到以下情况会抛出StackOverflow异常

    • 线程申请的JVM栈的容量超过虚拟机所允许的最大容量

JVM栈的作用

保存着方法的局部变量,部分结果,并参与方法的调用与返回。

JVM栈的存储单位

JVM栈存储什么

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

  • 每个方法都对应一个栈帧

  • 栈帧时一个内存块,是数据的集合,保存着方法运行中的各种数据信息。

栈运行原理

在一个运行中的线程,一个时间点上只有一个活动的栈帧,这个栈帧被称为当前栈帧,对应的方法是当前方法,对应的类是当前类。

执行引擎运行的所有的字节码指令只针对当前栈帧来操作,如果当前栈帧调用了其他方法,那么对应的栈帧也会被创建出来,进入栈中,成为当前栈帧。

方法与栈桢

第05章_栈桢内部结构

栈帧的内部结构

JVM规范对应链接

存储着以下信息

  • 局部变量表 Local Variables(重要)
  • 操作数栈 Operand Stacks(重要)
  • 方法返回地址
  • 动态连接 Dynamic Linking
  • 其他信息

局部变量表Local Variables

  • 被称为局部变量数组或本地变量表。
  • 是一个数字数组,主要存储方法参数和定义在方法内的局部变量,这些数据类型包括基本数据类型引用,对象引用,以及返回地址类型。
  • 没有安全问题
  • 局部变量表的大小是在编译期确定下来的,并且保存在方法的Code属性的maximum local variables。运行期间不会改变局部变量表的大小。

局部变量表的基本单位是Slot(插槽),32位以内的类型占用一个Slot,

64位的类型占用两个Slot。只有long和double类型占用两个插槽,其余都是占用一个插槽,非数值的类型,如boolean,则转为int后再存储。

形参和方法内局部变量会按照顺序被复制到每一个Slot上,但是有一个变量特殊,就是this。

如果是非静态方法和构造器,第一个变量将是this,如果是静态方法,局部变量表没有this这个变量。

可以使用jclasslib软件或idea中的jclasslib插件查看具体的局部变量表,如下图所示。

局部变量表的具体细节

  • 序号:该变量在保存在第几个slot中,从0号开始

  • 起始PC:字节码的第几行开始

  • 长度:变量的有效范围的长度

  • 名字:对应的变量的名字

比如三号变量b,起始PC是30,长度是7,意味着变量b的作用范围是30-37,查询LineNumberTable可知,第30-37号指令对应的行号为42-44。

image-20230401212305804

对应的源码

36 public static void testStatic() {
37    Integer integer = new Integer(1);
38    Date date = new Date();
39    int count = 18;
40    System.out.println(count);
41    {
42        int b = 10;
43        System.out.println(b);
44    }
45    int c = 8;
46 }
//下表为对应的LineNumberTable
//下表表明的是指令所对应的代码行号,对应关系为:起始PC ------行号-1
//
Nr. 起始PC   行号
0	0 	    37
1	9		38
2	17		39
3	20		40
4	27		42
5	30		43
6	37		45
7	40		46

对应的字节码指令

 0 new #10 <java/lang/Integer>
 3 dup
 4 iconst_1
 5 invokespecial #11 <java/lang/Integer.<init> : (I)V>
 8 astore_0
 9 new #12 <java/util/Date>
12 dup
13 invokespecial #13 <java/util/Date.<init> : ()V>
16 astore_1
17 bipush 18
19 istore_2
20 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
23 iload_2
24 invokevirtual #9 <java/io/PrintStream.println : (I)V>
27 bipush 10
29 istore_3
30 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
33 iload_3
34 invokevirtual #9 <java/io/PrintStream.println : (I)V>
37 bipush 8
39 istore_3
40 return

Slot的重复利用

栈帧中的局部变量表中的slot是可以复用的,如果一个局部变量过了其作用域,那么在其作用域之后的声明的新变量就有可能服用过期局部两边的槽位,达到节省资源的目的。

上面的例子刚好是一个slot重复利用的例子,变量b和c都是存放在序号为3的slot上,正是因为在b结束后有一个新的变量c声明出来。

补充说明

在栈帧中,与调优最密切相关的部分就是局部变量表。

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

操作数栈

操作数栈也位于栈帧当中,属于线程私有的,也被称为表达式栈,Expression Stack。

作用

  • 在方法执行中,根据字节码指令,往操作数栈中写入数据或提取数据,也就是入栈和出栈。
  • 主要保存着计算过程的中间结果,也是计算过程中便变量的临时存储空间。

说明

  • 当一个方法刚开始执行时,新的栈帧会被创建出来,这个栈帧的操作数栈时空的 。

  • 因为操作数栈是用数组实现的,所以每一个操作数栈都会有一个明确的数值表示其所需的最大深度,保存在方法的Code属性中,为max_stack的值。

  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行险证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证

  • 和栈帧一样,一个栈的单位深度是32位。

  • 操作数栈的任意元素可以是任意的Java数据类型。

  • 如果调用方法有返回值的话,返回值会被压入当前栈帧的操作数栈中

    栈顶缓存技术

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

动态链接(或指向运行时常量池的方法引用)

介绍

也是栈帧的一部分。每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。

比如:invokedynamic指令在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。

比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的。

动态链接的作用

就是为了将符号引用转换为调用方法的直接引用。

符号引用

可以看作是一个字符串,内容是这个方法的方法头,拿上边局部变量表的具体细节的例子来看,常量池如下方代码块所示,

Constant pool:
   #1 = Methodref          #15.#50        // java/lang/Object."<init>":()V
   #2 = Fieldref           #51.#52        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #53            // aaaa
   #4 = Methodref          #54.#55        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #56            // java/lang/Exception
   #6 = Methodref          #54.#57        // java/io/PrintStream.println:(Ljava/lang/Object;)V
  省略剩下的。。。。。。。。

动态链接的理解

可以看到有一些是带有Methodref 例如第五行的 #4 = Methodref,带有Methodref 就是符号引用,但是这些只是一个字符串,并不能直接运行, 所以就需要使用符号引用来将静态的字符串转为可以运行的方法的引用,这些可运行方法的引用保存在方法区的运行时常量池中。

举例,有个方法a,对应的运行时常量池的地址为0x00a13,那么就需要一个东西来将a和0x00a13对应起来,这个东西就是动态链接。

方法的调用

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。

  • 静态链接:

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

    • 如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。

对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

  • 早期绑定:

    • 早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
  • 晚期绑定:

    • 如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
虚方法和非虚方法

非虚方法:

  • 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。

  • 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。

其他方法称为虚方法,例如实例方法。

方法调用指令:

普通调用指令:

  1. invokestatic:调用静态方法,解析阶段确定唯一方法版本
  2. invokespecial:调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本
  3. invokevirtual:调用所有虚方法
  4. invokeinterface:调用接口方法

动态调用指令:

5. invokedynamic:动态解析出需要调用的方法,然后执行。

前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。

代码中使用lambda表达式对应到字节码指令就会使用到invokedynamic进行调用。

动态返回地址(return address)

  • 存放该调用方法的pc寄存器的值。例子:假如方法A调用方法B,动态返回地址就是调用方法B指令的下一条指令,调用方法B指令是第五条指令,那么动态返回地址应该是第六条指令。

  • 一个方法的结束,有两种方式

    • 遇到return,将返回值传递给上层方法调用者,简称正常完成出口(返回指令包括ireturn(返回值为boolean,byte,char,short,int),lreturn,freturn,dreturn,以及areturn,还有return 返回为void、实例初始化方法,类和接口的初始化方法)
    • 异常完成出口,即碰到了异常,并且没有在方法内进行处理,就会退出方法。方法在执行过程总抛出异常时的异常处理,储存在一个异常处理表,方法在发生异常时候找到处理异常的代码
  • 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址,而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息

  • 本质上,方法的退出就是当前栈帧出栈的过程,此时,需要恢复上层方法的数据区等信息,让调用者方法继续执行下去

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

本地方法栈

简介

本地方法是指使用了native关键字修饰的方法,没有方法体,是为了对接其他的编程语言。

普通的方法都是在JVM栈中进行管理,但是本地方法是在本地方法栈中进行管理。

  • 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。它甚至可以直接使用本地处理器中的寄存器
  • 直接从本地内存的堆中分配任意数量的内存。
  • 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。
  • 在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一

堆(重点)

简介

  • 每个JVM中只有一个堆,也是JVM最大内存区域
  • 堆大小可调节也可固定
  • 在物理内存上可以不连续,但是逻辑上是连续的
  • 所有的线程共享堆,但是也可以划出线程私有的缓冲区,称为Thread Local Allocation Buffer,简写TLAB
  • 《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。从实际使用角度看,数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
  • 堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点 区域。

栈-堆-方法区三者联系如图

栈-堆-方法区三者联系

堆的内存细分

Java7之前有三部分:新生代 Young Generation Space, 老年代 Old Generation Space ,永久代 Perment Space

Java8及其之后---新生代, 老年代,元空间 Meta Space

具体叫法:

新生代-新生区-年轻代

养老区-老年区-老年代

永久代-永久区

堆大小设置

Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项"—Xmx"和"—Xms"来进行设置。

“-Xms”用于表示堆区的起始内存,等价于-XX:InitialHeapSize

“-Xmx”则用于表示堆区的最大内存,等价于-XX:MaxHeapSize

一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出OutOfMemoryError异常。

通常会将-Xms 和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。

默认情况下,初始内存大小:物理电脑内存大小/64。最大内存大小:物理电脑内存大小/4

堆空间的内部细节

堆分为两个代际区域,新生代和老年代,新生代和老年代的大小比例为1:2。

新生代中各个区域的内存比例为Eden:s0:s1为8:1:1。

注意的是这只是JVM的默认比例,各个区域的比例和大小以及是否固定,都可以在VM Option中进行设置,但是大小会动态调整,所以需要手动设置参数,但一般不设置。

二者在逻辑上是连续的,但是实际内存中可以不连续。

新生代,新生代包括以下部分:

  • Eden区(伊甸园区)

  • Survivors0区(s0区)

  • Survivors1区(s1区)

第08章_堆空间细节

可以使用visualvm来查看JVM栈的具体情况

image-20230402212451881

对象的分配细节

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

一般情况

  1. new的对象先放伊甸园区。此区有大小限制。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
  3. 然后将伊甸园中的剩余对象移动到幸存者0区。
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
  5. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。6.啥时候能去养老区呢?可以设置次数。默认是15次。

特殊情况

内存分配的特殊情况

如图所示,小总结:

  1. 对象大于Eden的存放顺序:Eden->old,对象不大于Survivor为:Eden->Survivor->old
  2. 放不下时会进行GC,至于是哪种GC要看哪个区域大小不够,如果是Eden区,进行Minor GC,Old区进行Major GC。
  3. Survivor区满了不会自动触发垃圾回收,只有在Eden区不够用了,进行 Minor GC的时候,对Eden和Survivor进行回收。只有Eden区才能自动触发Minor GC总结。

频繁在新生代进行回收,较少在老年代回收,几乎不在元空间回收。

堆为什么要分代

研究发现,几乎80%的对象的都是临时对象。

为了优化GC性能,所以需要分代,将对象按照存活时间的不同进行划分,那么在进行垃圾回收的时候就可以重点关注存活时间短的区域了,如果没有分代,那么每次进行垃圾回收都会判断全部对象是否为垃圾,这些判断多了,性能也下降了。

TLAB

TLAB存在的原因

为了提高性能

解释:
  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。

什么是TLAB

从内存的角度来看,是一块线程私有的缓冲区域,位于Eden区。

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

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

  • 据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计。

图示

TLAB

进一步说明

尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选

在程序中,开发人员可以通过选项“-XXUseTLAB”设置是否开启TLAB空间。

默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项

“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百 分比大小。

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

JVM参数总结

官网说明

JVM参数官网

具体的参数

-XX:+PrintGCDetails

​ Enables printing of detailed messages at every GC. By default, this option is disabled.

​ 打印每次GC的细节,默认关闭

-Xms:初始堆空间内存(默认为物理内存的1/64)

-Xmx:最大堆空间内存(默认为物理内存的1/4)

-Xmn:设置新生代的大小。(初始值及最大值)

-XX:NewRatio:配置新生代与老年代在堆结构的占比

-XX:SurvivorRatio:设置新生代中Eden和SO/S1空间的比例

-XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄

其他说明

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

逃逸分析技术

概述

堆是否是对象分配的唯一选择:是,但是有逃逸分析技术,但是HotSpot并没有使用基于逃逸分析技术的代码优化

如何将堆上的对象分配到栈,需要使用逃逸分析手段。

这是一种可以有效减少Java 程序中同步负载和内存堆分配压力的算法。

通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

总结:能使用局部变量的就不要在方法外定义。

具体参数

-XX:+DoEscapeAnalysis 显式开启逃逸分析,在JDK6后默认开启

-XX:+PrintEscapeAnalysis 查看逃逸分析的筛选结果。

基于逃逸分析技术的代码优化

栈上分配

将堆分配转化为栈分配。

JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,I最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。

常见的栈上分配的场景:

给成员变量赋值、方法返回值、实例引用传递。

同步省略

如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步,即使代码中有同步块,但是如果该代码只能被一个线程发现,那么在运行阶段此同步代码块会被取出。

在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。

以下代码便是同步省略的例子,因为变量hollis是在方法f中声明的,那么只有声明该方法的线程才能访问,因为hollis是局部变量,局部变量保存在栈帧中,栈帧是线程私有的。

public void f(){
Object hollis = new Object(); 
	synchronized(hollis){
		System.out.println(hollis); 
    }
} 

分离对象或标量替换

聚合量:可以被分解为更小的数据和其他的聚合量,例如对象

标量:不能被拆分的数据,例如基本数据类型

有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在栈中。

标量(Scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

经过标量替换前的alloc方法

public static void main(String[] args) { alloc();
 }
private static void alloc(){ 
    Point point = new Point (1,2);
	System.out.printin("point.x="+point.x+"; point.y="+point.y); 
}
class Point{ 
    private int x;
    private int y;
}

经过标量替换后的alloc方法

private static void alloc(){ 
    int x=1;
    int y=2;
	System.out.printin("point.x="+x+"; point.y="+y); 
}

可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了。那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。

标量替换为栈上分配提供了很好的基础。

具体参数

-XX:+EliminateAllocations:开启了标量替换(默认打 开),允许将对象打散分配在栈上。

逃逸分析技术总结

该技术到目前为止没有特别成熟。

其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。

注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。

Oracle HotspotJVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。

目前很多书籍还是基于JDK 7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配。

方法区

官方文档

下图是栈、堆、方法区的关系。

栈保存着的是局部变量

堆保存着变量对应的数据

方法区保存着变量类型的信息

img

方法区 在哪个部分

JVM规范指出:方法区在逻辑上属于堆,但是实际的具体的JVM中并不属于堆的一部分。

基本理解

  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
  • 元空间,永久代是方法区的一个实现
  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutofMemoryError: PermGen space 或者 java.lang.OutOfMemoryError: Metaspace
    • 例如加载大量第三方的jar包,Tomcat部署过多的工程(30-50),大量的动态生成反射类。
  • 关闭JVM就会释放这个区域的内存。

HotSpot中方法区的演进

在jdk7及以前,习惯上把方法区,称为永久代。jdk8开始,使用元空间取代了永久代。

In JDK 8, classes metadata is now stored in the native heap and this space is called Metaspace.

本质上,方法区和永久代并不等价。仅是对hotspot而言的。《Java虚拟机规范》

对如何实现方法区,不做统一要求。例如:BEA JRockit/ IBM J9中不存在永久代的概念。

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。

元空间和永久代区别

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

永久代、元空间二者并不只是名字变了,内部结构也调整了。

根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常。

设置方法区的大小与OOM

方法区大小可以动态调整。JDK8之前和之后有所不同,这里只介绍JDK8及其之后的参数设置

元数据区大小可以使用参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize指定,设置方法区大小。

默认值依赖于平台。windows下,-XX:MetaspaceSize是21M,如果- XX:MaxMetaspaceSize 的值是-1,即没有限制。

方法区存储内容

《深入理解Java 虚拟机》书中对方法区(Method Area)存储内容描述如下:

它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

类型信息

对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
①这个类型的完整有效名称(全名=包名.类名)
②这个类型直接父类的完整有效名(对于interface或是java.lang.object,都没有父类)
③这个类型的修饰符(public,abstract,final的某个子集)
④这个类型直接接口的一个有序列表

可以看作是类型所有静态信息

域(Field)信息

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

方法(Method)信息

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  • 方法名称
  • 方法的返回类型(或 void)
  • 方法参数的数量和类型(按顺序)
  • 方法的修饰符(public,private, protected, static等)
  • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
  • 异常表 abstract和native方法除外)
    每个异常处理的开始位置,结束位置,等信息

non-final的类变量

  • 静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。

  • 类变量被类的所有实例共享,即使没有类实例时你也可以访问它。

运行时常量池(重要)

简介

方法区,内部包含了运行时常量池。 字节码文件,内部包含了常量池。

要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区。 要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。

ClassFile中的常量池是保存着静态的信息,包括数值,方法的符号引用,类引用,字符串值,字段引用。ClassFile中的常量池可以看作一张表,虚拟机指令通过这张表找到要执行的类名,方法名,参数名,参数类型,字面量等信息。

常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。

JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。

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

java规范并不要求常量只能在运行时才产生,也就是说运行时常量池的内容并不全部来自class常量池,在运行时可以通过代码生成常量并将其放入运行时常量池中,这种特性被用的最多的就是String.intern()。

运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些。

总结:常量池和运行时常量池就像是类和实例的关系,常量池是类,运行时常量池是对象。运行时常量池存储着一些真正的可以执行的东西

方法区的演变细节

jdk1.6及之前 有永久代,静态变量放在永久代上
jdk1.7 有永久代,但在去永久代化,字符串常量池,静态变量被移除,保存到堆中
jdk1.8及之后 无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池,静态变量还是在堆

为什么要将永久代替换为元空间

  • 为永久代设置大小是很难确定的,如果类很多,过小会OOM,过大会导致浪费

  • 很难对永久代进行调优

StringTable(字符串常量池)为什么要放到堆

jdk7将StringTable放到堆中,因为永久代的垃圾回收效率低,就导致StringTable回收效率低,就会占用很多永久代的内存,如果放到堆中,就可以即使回收了

方法区的GC

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

判定一个常量是否“废弃”还是相对简单,

而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

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

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

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

内容来源于网络如有侵权请私信删除

文章来源: 博客园

原文链接: https://www.cnblogs.com/xiuer211/p/17461441.html

你还没有登录,请先登录注册
  • 还没有人评论,欢迎说说您的想法!

相关课程

3636 0元 限免