JVM8基础结构图理解

1 理解DOS里面的java命令

在装有jdk电脑里面,在dos窗口里面的输入:java -version
 image

HotSpot(TM):虚拟机将class文件加载到虚拟机中,需要把class文件编译成native code(本地代码),本地代码就是虚拟机能直接运行。热点探测,是指虚拟机加载class文件,会根据加载的文件做标记,在做标记过程中,当达到了一定的阈值,会触发一个 即时编译 机制,对频繁使用的class文件直接编译成本地代码缓存起来,而不再进行编译了。热点探测是在jdk1.3之后,但是真正加进去是在1.5了。

Server:是虚拟机的模式 ,有两种:clientserver模式。client是为桌面级应用而提供的,使用的是一个代号为 C1 的轻量级编译器,server启动的虚拟机采用相对重量级,代号为 C2 的编译器. C2 比 C1 编译器编译的相对彻底,服务起来之后,性能更高
可以自己修改jvm模式,路径:JDK 安装目录/jre/lib/(x86、 i386、 amd32、 amd64) /jvm.cfg

-server 和-client 哪一个配置在上,执行引擎就是哪一个。 如果是 JDK1.5版本且是 64 位系统应用时,-client 无效

  • --64 位系统内容   -server KNOWN   -client IGNORE
  • --32 位系统内容   -server KNOWN    -client KNOWN

注意:在部分 JDK1.6 版本和后续的 JDK 版本( 64 位系统 )中, -client 参数已经不起作用了, Server 模式成为唯一

2 JVM内存

2.1 JVM主要组成部分

image

JVM包含两个 子系统两个组件 ,两个子系统为 Class loader(类装载)、 Execution engine(执行引擎) ;两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)。

  • Class loader(类装载):根据给定的全限定名类名(如: java.lang.Object)来装载class文件到Runtime data area中的method area。
  • Execution engine(执行引擎):执行classes中的指令。
  • Native Interface(本地接口):与native libraries交互,是其它编程语 言交互的接口。
  • Runtime data area(运行时数据区域):这就是我们常说的JVM的内存

2.2 JVM内存(运行时数据区域)

JVM8结构示意图:注意图中 线程私有和线程共享
image

2.2.1 虚拟机内存与本地内存区别

Java虚拟机在执行的时候会把管理的内存分配成不同的区域,这些区域被称为虚拟机内存,同时,对于虚拟机没有直接管理的物理内存,也有一定的利用,这些被利用却不在虚拟机内存数据区的内存,我们称它为本地内存,这两种内存有一定的区别:

  • JVM内存受虚拟机内存大小的参数控制,当大小超过参数设置的大小时就会报OOM
  • 本地内存不受虚拟机内存参数限制,只受物理内存容量的限制虽然不受参数的限制,但是如果内存的占用超出物理内存的大小,同样也会报OOM

2.2.2 JVM内存(运行时数据区域)中的JVM内存

JVM内存(运行时数据区域)中的JVM内存又分两种:数据区和运算区,如下图所示
image

2.2.3 程序计数器(Program Counter Register)

程序计数器(Program Counter Register),也有称作为PC寄存器,程序计数器是指CPU中的寄存器,它保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址),当CPU需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加1或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令

位于JVM内存运算区,程序计数器就是当前线程所执行的字节码的行号指示器,通过改变计数器的值,来选取下一行指令,通过它来实现跳转、循环、恢复线程等功能。在任何时刻,一个处理器内核只能运行一个线程,多线程是通过线程轮流切换,分配时间来完成的,这就需要有一个标志来记住每个线程执行到了哪里,这里便需要到了程序计数器。

程序计数器是线程私有的,每个线程都有自己的程序计数器。生命周期与线程一致,生命周期随着线程启动而产生,随线程结束而消亡

唯一 一个没有OOM的区域

PC(Program Counter) 寄存器是每一个线程私有的空间, java 虚拟机会为每一个java线程创建 PC 寄存器。在任意时刻,一个 java 线程总是在执行一个方法,这个正在被执行的方法称为当前方法。如果当前方法不是本地方法, PC 寄存器就会指向当前正在被执行的指令。 如果当前方法是本地方法,那么 PC 寄存器的值就是 undefined

2.2.4 虚拟机栈(JVM Stacks)

2.2.4.1 定义

Java栈也称作虚拟机栈(Java Vitual Machine Stack),也就是我们常常所说的栈,跟C语言的数据段中的栈类似。事实上,Java栈是Java方法执行的内存模型

每当启动给一个线程时,Java虚拟机会为它分配一个Java栈Java栈由许多栈帧组成,一个栈帧包含一个Java方法调用的状态。当线程调用一个Java方法时,虚拟机压入一个新的栈帧到该线程的Java栈中,当该方法返回时,这个栈帧就从Java栈中弹出。Java栈存储线程中Java方法调用的状态–包括局部变量、参数、返回值以及运算的中间结果等。Java虚拟机没有寄存器,其指令集使用Java栈来存储中间数据。这样设计的原因是为了保持Java虚拟机的指令集尽量紧凑,同时也便于Java虚拟机在只有很少通用寄存器的平台上实现。另外,基于栈的体系结构,也有助于运行时某些虚拟机实现的动态编译器和即时编译器的代码优化。

image

Java栈中存放的是一个个的栈帧,每个栈帧对应一个被调用的方法,在栈帧中包括 局部变量表(Local Variables)操作数栈(Operand Stack)指向当前方法所属的类的运行时常量池的引用(Reference to runtime constant pool)方法返回地址(Return Address) 和一些额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。讲到这里,就应该会明白为什么在使用递归方法的时候容易导致栈内存溢出的现象了以及为什么栈区的空间不用程序员去管理了(当然在Java中,程序员基本不用关系到内存分配和释放的事情,因为Java有自己的垃圾回收机制),这部分空间的分配和释放都是由系统自动实施的。对于所有的程序设计语言来说,栈这部分空间对程序员来说是不透明的。下图表示了一个Java栈的模型

image

2.2.4.2 Java栈模型

Java栈的模型介绍:

  • 局部变量表,以 字长 为单位(点击此处了解java栈中引用类型结构)、从0开始计数的数组,类型为int, float, reference和returnAddress的值在数组中占据一项,而类型为byte, short和char的值在存入数组前都被转换为int值,也占据一项。但类型为long和double的值在数组中却占据连续的两项
    image

就是用来存储方法中的局部变量(方法参数局部变量)。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用。局部变量表的大小在编译器就可以确定其大小了,因此在程序执行期间局部变量表的大小是不会改变的。
我们都知道,类属性变量一共要经历两个阶段,分为 准备阶段初始化阶段,而 局部变量是没有准备阶段,只有初始化阶段,而且必须是显示的。如果是非静态方法,则在index[0]位置上存储的是 方法所属对象的实例引用 ,随后存储的是参数局部变量。字节码指令中的STORE指令就是将操作栈中计算完成的局部变量写回局部变量表的存储空间内

  • 操作数栈,栈最典型的一个应用就是用来对表达式求值。一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程,然后把结果压回操作数栈,因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的。
    操作栈是一个 初始状态为空的桶式结构栈,以 字长为单位 的数组。在方法执行过程中,会有各种指令往栈中写入和提取信息。JVM的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈。字节码指令集的定义都是基于栈类型的
  • 动态连接(指向运行时常量池的引用),因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。
    每个栈帧中包含一个 在常量池中对当前方法的引用,目的是 支持方法调用过程的动态连接
  • 方法返回地址,当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。
    方法执行时有两种退出情况:
    正常退出,即 正常执行到任何方法的返回字节码指令,如 RETURNIRETURNARETURN等;
    异常退出,无论何种退出情况,都将返回方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧
    退出可能有三种方式:返回值压入上层调用栈帧异常信息抛给能够处理的栈帧PC 计数器指向方法调用后的下一条指令
  • 附加信息,由于每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的Java栈,互不干扰

2.2.4.3 虚拟机栈特点和异常

虚拟机栈特点:

  • 在函数中定义的一些 基本类型的变量对象的引用变量 都在函数的栈内存中分配
  • 虚拟机栈是线程私有的,随线程生灭。虚拟机栈描述的是线程中的 方法内存模型
  • 虚拟机栈是当前执行线程独占空间,以栈的数据结构形式存在,方法被执行时入栈,执行完后出栈,特点是:先进后出,后进先出。
  • 栈中的数据大小和生命周期是可以确定的,当没有引用指向数据时,这个数据就会消失。当在一段代码块定义一个变量时,Java就在栈中为这个变量分配内存空间,当超过变量的作用域后,Java会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用
  • 虚拟机栈是线程运算执行的区域,它保存着一个线程调用方法的属性和过程
  • 每个方法被执行的时候,都会在虚拟机栈中同步创建一个栈帧(stack frame),以此栈帧的结构压入虚拟机栈
  • 每个栈帧的包含如下的内容:局部变量表( 局部变量表 中存储着方法里的 java基本数据类型 (byte/boolean/char/int/long/double/float/short)以及 对象的引用( 这里的基本数据类型指的是方法内的局部变量 ))操作数栈,动态连接,方法返回地址
  • 储速度比堆快的多,仅次于寄存器,但存在栈中的 数据大小与生存期必须是确定的,这也导致缺乏了其灵活性,因此栈内存灵活性不如堆内存,数据可以共享

虚拟机栈可能会抛出两种异常:

  • 如果线程请求的栈深度大于虚拟机所规定的栈深度,则会抛出 StackOverFlowError即栈溢出
  • 如果虚拟机的栈容量可以动态扩展,那么当虚拟机栈申请不到内存时会抛出OutOfMemoryErrorOOM内存溢出

2.2.5 本地方法栈(Native Method Stacks)

本地方法栈与虚拟机栈的作用是相似的,都会抛出 OutOfMemoryErrorStackOverFlowError,都是线程私有的,主要的区别在于:虚拟机栈执行的是java方法,本地方法栈执行的是native方法

本地方法栈用于本地方法(native method:非java语言代码)的调用,作为对 java 虚拟机的重要扩展,java 虚拟机允许 java 直接调用本地方法(通常使用 C 编写)

Java本地接口,也叫JNIJava Native Interface),是为可移植性准备的。本地方法接口允许本地方法完成以下工作:

  • 传递或返回数据
  • 操作实例变量
  • 操作类变量或调用类方法
  • 操作数组
  • 对堆的对象加锁
  • 装载新的类
  • 抛出异常
  • 捕获本地方法调用Java方法抛出的异常
  • 捕获虚拟机抛出的异步异常
  • 指示垃圾收集器某个对象不再需要

2.2.6 Java堆(Java Heap)

java堆Java虚拟机 运行时数据区 共享数据区最大的区域,java 堆在虚拟机启动的时候建立,堆的生命周期与 JVM进程一致,由所有线程共享,是由垃圾收集器管理的内存区域,主要存放对象实例,当然由于java虚拟机的发展,堆中也多了许多东西,现在主要有:

  • "几乎"所有的对象和数组都在堆中进行分配(对象实例类初始化生成的对象,基本数据类型的数组也是对象实例),特点就是:先进先出,后进后出
  • 字符串常量池原本存放于方法区,jdk7开始放置于堆中。字符串常量池存储的是string对象的直接引用,而不是直接存放的对象,是一张string table
  • 静态变量是有static修饰的变量,jdk7时从方法区迁移至堆中
  • 线程分配缓冲区(Thread Local Allocation Buffer)线程私有,但是不影响java堆的共性增加线程分配缓冲区是为了提升对象分配时的效率
  • 需要运行时动态分配内存,因此存取速度慢
  • 堆中的对象的由垃圾回收器负责回收,因此大小和生命周期不需要确定,具有很大的灵活性

java堆既可以是固定大小的,也可以是可扩展的(通过参数-Xmx-Xms设定),如果堆无法扩展或者无法分配内存时也会报OutOfMemeoryError 

一个可能的堆设计如下:

image

一个句柄池,一个对象池。一个对象的引用就是一个指向句柄池的本地指针。这种设计的好处有利于堆碎片的整理,当移动对象池中的对象时,句柄部分只需更改一下指针指向对象的新地址即可。缺点是每次访问对象的实例变量都要经过两次指针传递

2.2.7 方法区(Method Area)

2.2.7.1 定义

方法区绝对是网上所有关于java内存结构文章争论的焦点,因为方法区的实现在java8做了一次大革新,现在我们来讨论一下:

  • 方法区的生命周期与JVM进程一致,所有线程共享的内存。存储已被虚拟机加载的类型信息,方法信息,域信息,运行时常量,静态变量,即时编译器(JIT)编译后的代码
  • 运行时常量池 属于Method Area中的一部分
    常量池指的是在编译期被确定,并被保存在已编译的 .class 文件中的一些数据。除了包含代码中所定义的各种基本类型(如int、long等等)和对象型(如String及数组)的常量值(final)还包含一些以文本形式出现的符号引用,比如:
    类和接口的全限定名;
    字段的名称和描述符;
    方法和名称和描述符
  • 方法区从逻辑上来理解其本身也属于Heap的一部分,但是为了区分和更好的内存对象的垃圾回收,我们将Mehtod Area又称之为Non-Heap,与Heap进行区分理解(JDK8之前的Method Area实现是Perm SpaceJDK8及之后的Method Area实现叫Meta Space)
  • 方法区内存不足时,将抛出OOM

方法区与其他区域不同的地方在于,方法区在 编译期间和类加载完成后内容有少许不同,不过总的来说分为这两部分:

  • 类元信息(Klass):类元信息在类编译期间放入方法区,里面放置了类的基本信息,包括类的版本、字段、方法、接口以及常量池表(Constant Pool Table)常量池表(Constant Pool Table)存储了类在编译期间生成的字面量、符号引用,这些信息在类加载完后会被解析到运行时常量池中
  • 运行时常量池(Runtime Constant Pool):运行时常量池主要存放在类加载后被解析的字面量与符号引用,但不止这些运行时常量池具备动态性,可以添加数据,比较多的使用就是String类的intern()方法

字面量和符合引用的理解:

  • 字面量java代码在编译过程中是无法构建引用的,字面量就是在编译时对于数据的一种表示:
    int a=1;//这个1便是字面量 ,
    String b="iloveu";//iloveu便是字面量 12
  • 符号引用:由于在编译过程中并不知道每个类的地址,因为可能这个类还没有加载,所以如果你在一个类中引用了另一个类,那么你完全无法知道他的内存地址,那怎么办,我们只能用他的类名作为符号引用,在类加载完后用这个符号引用去获取他的内存地址。
    例子:我在com.demo.Solution类中引用了com.test.Quest,那么我会把com.test.Quest作为符号引用存到类常量池,等类加载完后,拿着这个引用去方法区找这个类的内存地址
    Java之所以是符号引用而不是像c语言那样,编译时直接指定其他类型,是因为 java是动态绑定 的,只有在运行时根据某些规则才能确定具体依赖的类型实例,这正是 java实现多态的基础

2.2.7.2 过去和现在方法区区别

java8以前是放在JVM内存中的,由永久代实现,受JVM内存大小参数的限制,在java8中移除了永久代的内容,方法区由元空间(Meta Space)实现,并直接放到了本地内存中,不受JVM参数的限制,并且将原来放在方法区的字符串常量池和静态变量都转移到了Java堆中

JDK6方法区:
image

JDK1.8方法区:
image

2.2.7.3 元空间和方法区区别

Java8中的JVM元空间是不是方法区?
不是的,应该说是:元空间是方法区的一种具体实现,可以把方法区理解为Java中定义的一个接口,把元空间/永久代看做这个接口的具体实现类,其中方法区是规范,元空间/永久代Hotspot针对该规范进行的实现。
元空间这个东西,是在JDK8以后才存在的,JDK7及以前,只有永久代这个区域,元空间的存储位置是在计算机的内存当中,而永久代的存储位置是在JVM的堆(Heap)中
JVM规范中,方法区被定义为一种逻辑区域,而方法区具体怎么实现是各JVM的实现细节,所以方法区的内容在堆里也好,不在堆里也好都是符合标准的

2.2.8 直接内存(Direct Memory)

jdk1.4中加入了 NIO(New Input/Putput) 类,引入了一种基于 通道(channel)与缓冲区(buffer) 的新IO方式,它可以使用native函数直接分配堆外内存,然后通过存储在java堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作,这样可以在一些场景下大大提高IO性能,避免了在java堆和native堆来回复制数据

javaNIO 库允许 java 程序使用直接内存。直接内存是在 java 堆外的、直接向系统申请的内存空间。通常访问直接内存的速度会优于 java 堆。因此出于性能的考虑,读写频繁的场合可能会考虑使用直接内存。由于直接内存在 java 堆外,因此它的大小不会直接受限于 Xmx (虚拟机参数)指定的最大堆大小,但是系统内存是有限的, java 堆和直接内存的总和依然受限于操作系统能给出的最大内存。直接内存位于本地内存,不属于JVM内存,不受GC管理,但是也会在物理内存耗尽的时候报OOM

注意:direct buffer不受GC影响,但是direct buffer归属的JAVA对象是在堆上且能够被GC回收的,一旦它被回收,JVM将释放direct buffer的堆外空间

2.2.9 执行引擎

image

执行引擎是 java 虚拟机的最核心组件之一,它负责执行虚拟机的字节码,有 即时编译和解释执行,通常采用 解释执行 方式。解释执行是指解释器通过每次解释并执行一小段代码来完成 .class 程序的所有操作。即时编译则是将 .class 文件翻译成机器码在执行(比如:经常多次访问的代码可以全部编译)

作为运行时实例的执行引擎就是一个线程。运行中Java程序的每一个线程都是一个独立的虚拟机执行引擎的实例。从线程生命周期的开始到结束,它要么在执行字节码,要么执行本地方法。

执行引擎是JVM执行Java字节码的核心,执行方式主要分为 解释执行、编译执行、自适应优化执行、硬件芯片执行方式JVM的指令集是基于栈 而非寄存器的,这样做的好处在于可以使指令尽可能紧凑,便于快速地在网络上传输(别忘了Java最初就是为网络设计的),同时也很容易适应通用寄存器较少的平台,并且有利于代码优化,由于Java栈PC寄存器是线程私有的,线程之间无法互相干涉彼此的栈。每个线程拥有独立的JVM执行引擎实例。

JVM指令由单字节操作码和若干操作数组成。对于需要操作数的指令,通常是先把操作数压入操作数栈,即使是对局部变量赋值,也会先入栈再赋值。

  1. 解释执行
    和一些动态语言类似,JVM可以解释执行字节码
    解释执行中有几种优化方式:
    栈顶缓存
    将位于操作数栈顶的值直接缓存在寄存器上,对于大部分只需要一个操作数的指令而言,就无需再入栈,可以直接在寄存器上进行计算,结果压入操作数站。这样便减少了寄存器和内存的交换开销。
    部分栈帧共享
    被调用方法可将调用方法栈帧中的操作数栈作为自己的局部变量区,这样在获取方法参数时减少了复制参数的开销。
    执行机器指令
    在一些特殊情况下,JVM会执行机器指令以提高速度。

  2. 编译执行
    为了提升执行速度,Sun JDK提供了将字节码编译为机器指令的支持,主要利用了JIT(Just-In-Time)编译器在运行时进行编译,它会在第一次执行时编译字节码为机器码并缓存,之后就可以重复利用
    自适应优化执行的思想是程序中10%~20%的代码占据了80%~90%的执行时间,所以通过将那少部分代码编译为优化过的机器码就可以大大提升执行效率。自适应优化的典型代表是Sun的Hotspot VM,正如其名,JVM会监测代码的执行情况,当判断特定方法是瓶颈或热点时,将会启动一个后台线程,把该方法的字节码编译为极度优化的、静态链接的C++代码。当方法不再是热区时,则会取消编译过的代码,重新进行解释执行。
    自适应优化不仅通过利用小部分的编译时间获得大部分的效率提升,而且由于在执行过程中时刻监测,对内联代码等优化也起到了很大的作用。由于面向对象的多态性,一个方法可能对应了很多种不同实现,自适应优化就可以通过监测只内联那些用到的代码,大大减少了内联函数的大小。

2.3 实际代码中说明

2.3.1 java对象说明

如下代码说明

public class HeapMemory {
    private Object obj1 = new Object();

    public static void main(String[] args) {
        Object obj2 = new Object();
    }
}

image.gif

上面的代码中,obj1 和obj2在内存中有什么区别?
根据上面可知,方法区存储每个类的结构,比如:运行时常量池、属性和方法数据,以及方法和构造函数等数据。所以我们这个obj1是存在方法区的,而new会创建一个对象实例,对象实例是存储在堆内的,于是就有了下面这幅图(方法区指向堆)

image

而obj2 是属于方法内的局部变量,存储在Java虚拟机栈内的栈帧中的局部变量表内,这就是经典的栈指向堆

image

这里我们再来思考一下,我们一个变量指向了堆,而堆内只是存储了一个实例对象,那么堆内的示例对象是如何知道自己属于哪个Class,也就是说这个实例是如何知道自己所对应的类元信息的呢?这就涉及到了一个Java对象在内存中是如何布局的。
点击了解java对象头分析

2.3.2 java对象句柄访问和直接指针访问

创建好一个对象之后,当然需要去访问它,那么当我们需要访问一个对象的时候,是如何定位到对象的呢?目前最主流的访问对象方式有两种:句柄访问直接指针访问

句柄访问
使用句柄访问的话,Java虚拟机会在堆内划分出一块内存来存储句柄池,那么对象当中存储的就是句柄地址,然后句柄池中才会存储对象实例数据和对象类型数据地址
image

直接指针访问(Hot Spot虚拟机采用的方式)        直接指针访问的话对象中就会直接存储对象实例数据

image

句柄访问和直接指针访问对比:
上面图形中我们很容易对比,就是如果使用句柄访问的时候,会多了一次指针定位,但是它也有一个好处就是,假如一个对象被移动(地址改变了),那么只需要改变句柄池的指向就可以了,不需要修改reference对象内的指向,而如果使用直接指针访问,就还需要到局部变量表内修改reference指向

2.4 逃逸分析

2.4.1 定义

随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化 技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么绝对了。

在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的 堆外存储技术

逃逸分析(Escape Analysis),是一种可能减少有效 Java 程序中同步负载和内存堆分配压力的跨全局函数数据流分析算法。通过逃逸分析, Java Hotspot 编译器能够分析出一个新的对象引用范围从而决定是否要将这个对象分配到堆上,逃逸分析的基本行为就是分析对象的动态作用域。

逃逸分析是一种数据分析算法,基于此算法可以有效减少 Java 对象在堆内存中的分配。Hotspot 虚拟机的编译器能够分析出一个新对象的引用范围,然后决定是否要将这个对象分配到堆上。例如:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为 没有发生逃逸
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为 发生逃逸

2.4.2 分类

方法逃逸:当一个对象在方法里面被定义后,它可能被外部方法所引用,例如调用参数传递到其他方法中,这种称为方法逃逸。
线程逃逸:当一个对象可能被外部线程访问到,比如:赋值给其他线程中访问的实例变量,这种称为线程逃逸。
通过逃逸分析,编译器对代码的优化
如果能够证明一个对象不会逃逸到到方法外或线程外(其他线程方法或者线程无法通过任何方法访问该变量),或者逃逸程度比较低(只逃逸出方法而不逃逸出线程)则可以对这个对象采用不同程度的优化:

  • 栈上分配(Stack Allocations):完全不会逃逸的局部变量和不会逃逸出线程的对象,采用栈上分配,对象就会跟随方法的结束自动销毁。以减少垃圾回收器的压力
  • 标量替换(Scalar Replacement):有个对象可能不需要作为一个连续的存储结果存储也能被访问到,那么对象的部分(或者全部)可以不存储在内存,而是存储在 CPU 寄存器中。
  • 同步消除(Synchronization Elimination):如果一个对象发现只能在一个线程访问到,那么这个对象的操作可以考虑不同步

2.4.3 示例

对象逃逸是指当我们在某个方法里创建了一个对象,这个对象除了被这个方法引用,还在方法体之外被其它的变量引用。这样的后果是当这个方法执行完毕后,GC无法回收这个对象,就被称为对象逃逸了。逃逸的对象的内存在堆中,未逃逸的对象内存分配在栈中。

public stringBuffer append(String apple,String pear){
   StringBuffer buffer=new StringBuffer();
   buffer.append(apple);
   buffer.append(pear);
   return buffer;
}

这种写法直接返回的是对象,用处就是被别的变量所引用,会造成对象逃逸,从而增加了GC的压力,引发STW(stop the world)现象,不推荐这样写。我们可以做修改如下代码:

public string append(String apple,String pear){
   StringBuffer buffer=new StringBuffer();
   buffer.append(apple);
   buffer.append(pear);
   return buffer.toString();
}

这种写法就避免了对象逃逸,从而减小了GC的压力。下面这种写法就是在方法内创建了一个对象,没有被外界方法所引用,称为未逃逸对象 

3 堆结构及对象分代

整个堆的大小 = 年轻代大小 + 年老代大小,堆的大小不包含元空间大小,如果增大了年轻代,年老代相应就会减小,官方默认的配置为年老代大小/年轻代大小=2/1 左右
image

3.1 分代,分代的必要性

Java 虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为 新生代、老年代(对 HotSpot 虚拟机而言),新版本中老年代不在堆范畴内,这就是JVM 的内存分代策略。
堆内存是虚拟机管理的内存中最大的一块,也是垃圾回收最频繁的一块区域,我们程序所有的对象实例都存放在堆内存中。给堆内存分代是为了提高对象内存分配和垃圾回收的效率

试想一下,如果堆内存没有区域划分,所有的新创建的对象和生命周期很长的对象放在一起,随着程序的执行,堆内存需要频繁进行垃圾收集,而每次回收都要遍历所有的对象,遍历这些对象所花费的时间代价是巨大的,会严重影响我们的 GC 效率

有了内存分代,情况就不同了,新创建的对象会在新生代中分配内存,经过多次回收仍然存活下来的对象存放在老年代中, 静态属性、类信息等存放在永久代中,新生代中的对象存活时间短,只需要在新生代区域中频繁进行 GC老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收,永久代中回收效果太差,一般不进行垃圾回收,还可以根据不同年代的特点采用合适的垃圾收集算法。分代收集大大提升了收集效率,这些都是内存分代带来的好处。

3.2 分代的划分

Java 虚拟机将堆内存划分为新生代、老年代和永久代,永久代是 HotSpot 虚拟机特有的概念(JDK1.8 之后为 metaspace 元空间替代永久代),它采用永久代的方式来实现方法区,其他的虚拟机实现没有这一概念,而且 HotSpot 也有取消永久代的趋势,在 JDK 1.7 中 HotSpot 已经开始了 去永久化,把原本放在永久代的字符串常量池移出。永久代主要存放 常量、类信息、静态变量 等数据,与垃圾回收关系不大,新生代和老年代是垃圾回收的主要区域。

堆内存简图如下:
image

3.2.1 新生代(Young Generation)

新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收 70% ~ 95% 的空间,回收效率很高。

HotSpot 将新生代划分为三块,一块 较大的 Eden(伊甸) 空间和两块较小的 Survivor(幸存者) 空间,默认比例为 8: 1: 1。划分的目的是因为 HotSpot 采用复制算法来回收新生代,设置这个比例是为了充分利用内存空间,减少浪费。新生成的对象在 Eden 区分配(大对象除外,大对象直接进入老年代),当 Eden 区没有足够的空间进行分配时,虚拟机将发起一次 Minor GC

GC 开始时,对象只会存在于 Eden 区和 From Survivor 区, To Survivor 区是空的(作为保留区域)。 GC 进行时, Eden 区中所有存活的对象都会被复制到 To Survivor 区,而在 FromSurvivor 区中,仍存活的对象会根据它们的 年龄值决定去向,年龄值达到年龄阀值(默认为15,新生代中的对象 每熬过一轮垃圾回收,年龄值就加 1GC 分代年龄存储在对象的 header中) 的对象会被移到老年代中,没有达到阀值的对象会被复制到 To Survivor 区。

接着清空Eden 区和 From Survivor 区,新生代中存活的对象都在 To Survivor区。接着, From Survivor区和 To Survivor 区会交换它们的角色,也就是新的 To Survivor 区就是上次 GC 清空的 FromSurvivor 区,新的 From Survivor 区就是上次 GCTo Survivor 区,总之,不管怎样都会保证To Survivor 区在一轮 GC 后是空的。 GC 时当 To Survivor 区没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。

Minor GC会一直重复这样的过程,直到To区被填满 即Eden区存活的对象和from区存活的对象很多了,被复制到to区域时,to区域一下子接收装不下了,则To区被填满时,就不会再进行角色互换变成from了,而是“To”区被填满之后,会将所有对象移动到年老代中,则“To”区是空的了
即“To”区变成空有两种方式:

  • 一是对象从from移动到to区后,角色互换,为空的区域from变成toto就变成空了的
  • 二是Edenfrom中未达到15岁的对象两者加起来太多,移动到to区填满了,则把填满了to区的对象移动到老年代,此时eden区和from区对象变少了,to区也没经过角色互换,变成空的

注意:假如说S0区或者S1区空间对象复制移动了之后还是放不下,那就说明这时候是真的满了,那就去老年区借点空间过来(这就是 担保机制 ,老年代需要提供这种空间 分配担保 ),假如说老年区空间也不够了,那就会触发Full GC,如果还是不够,那就会抛出OutOfMemeoyError异常了

3.2.2 老年代(Old Generationn)

在新生代中经历了多次(具体看虚拟机配置的阀值) GC 后仍然存活下来的对象会进入老年代中。老年代中的对象生命周期较长,存活率比较高,在老年代中进行 GC 的频率相对而言较低,而且回收的速度也比较慢

3.2.3 永久代(Permanent Generationn)

永久代存储类信息、常量、静态变量、即时编译器编译后的代码等数据,对这一区域而言, Java 虚拟机规范指出可以不进行垃圾收集,一般而言不会进行垃圾回收。但是,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因。

Java8 中, 永久代已经被移除,被一个称为 元数据区(元空间)的区域所取代。元空间的本质和永久代类似,元空间与永久代之间最大的区别在于: 元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。 类的元数据放入 native memory,字符串池和类的静态变量放入 java 堆中, 这样可以加载多少类的元数据就不再由MaxPermSize 控制, 而由系统的实际可用空间来控制

3.3 对象生命周期和名词解释

  • 垃圾回收:简称GC。
  • Minor GC:针对新生代的GC
  • Major GC:针对老年代的GC,一般老年代触发GC的同时也会触发Minor GC,也就等于触发了Full GC。
  • Full GC:新生代+老年代同时发生GC。
  • Young区:新生代
  • Old区:老年代
  • Eden区:暂时没发现有什么中文翻译(伊甸园?)
  • Surcivor区:幸存区
  • S0S1:也称之为from区和to区,注意from和to两个区是不断互换身份的,且S0和S1一定要相等,并且保证一块区域是空的

一个对象的人生轨迹图
从上面的介绍大家应该有一个大致的印象,一个对象会在Eden区,S0区,S1区,Old区不断流转(当然,一开始就会被回收的短命对象除外),我们可以得到下面的一个流程图:
image

附录:以上参考于官网java8虚拟机
image

posted @ 2023-01-13 17:04  上善若泪  阅读(87)  评论(0编辑  收藏  举报