暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

一文吃透JVM类加载和内存管理

阿斌Java之路 2022-08-03
355

JVM基础介绍

先来了解下,为什么JAVA是一门跨平台的语言?这个得益于我们的JVM虚拟机。

Java源码(.java文件)首先会被编译成字节码(.class文件),再由不同平台的JVM进行解析,Java语言在不同的平台上运行时不需要进行重新编译,Java虚拟机在执行字节码的时候,把字节码转换成具体平台上的机器指令。

这就解释了那句话,一次编译,到处运行。靠的就是不同平台的JVM去适配不同平台的机器指令。

哪有什么岁月静好,不过是JVM替我们负重前行罢了

img

同时,我们的JVM是运行在操作系统上的,与硬件并没有直接的交互。

img

可能有的小伙伴就会有疑惑了,为什么要将.java编译成.class字节码文件呢?JVM这么强大,直接把.java转成机器码不就好了?其实这有两点原因

  1. 准备工作:.java在转成.class文件也就是编译阶段每次执行都要进行各种语法、语义检查,这些动作都是耗时的,我们经常在idea编译代码就能感受到。如果把这部分动作后置到jvm运行时来做,极大的影响性能。
  2. 兼容性:由于.class文件的通用性,这样只需要编写不同的编译器,还能让不同的语言遵从一定的规范编译成.class文件,扩充JVM的语言生态。目前已经有许多语言都是在JVM上运行的。
img

从.java文件,编译成.cass。在JVM运行时,会将.class文件装载到系统中,创建出对象。运行时涉及到线程私有的程序计数器,调用操作系统底层的本地方法栈,以及方法运行的虚拟机栈。和线程共享的方法区以及堆。在程序运行的过程中,还设计到重要的执行引擎,其中包括解释器和jit及时编译器。接下来,就会一个个点去拆解这些细节。

img

javac编译器

img

java的编译其实有两个过程,第一阶段是将.java文件通过javac编译成jvm可以识别的.class文件,这个也叫前端编译,其中涉及到的细节:

  1. 词法分析:把源代码中的字符(各个关键字、变量等)转为标记(Token)集合,单个字符的程序编写的最小单元,而token是编译过程的最小单元。

  2. 语法分析:将标记(Token)集合构造为抽象语法树。语法树的每一个节点都代表代码中的一个语法结构(如包、类型、接口、修饰符等等)。

  3. 填充符号表:符号表是有一组符号地址和符号信息构成的表格。填充符号表的过程的出口是一个待处理列表,包含了每一个抽象语法树(和package-info.java)的顶级节点。

  4. 插入式注解处理器处理注解:比如(lombok)注解处理器可以增删改抽象语法树的任意元素。因此每当注解处理器对语法树进行修改时,都将重新执行1,2,3步,直到注解处理器不再对语法树进行修改为止。每一次的循环过程都称为一次Round。

  5. 语义分析:对语法树结构上正确的源程序进行上下文有关的审查。


    1. 标注检查:包括是否变量声明、变量和赋值类型是否匹配等、常量折叠。
    2. 数据和控制流分析:对程序上下文逻辑更进一步验证。包括变量使用前是否赋值、方法是否有返回值、异常是否被正确处理等。
  6. 解语法糖:把高级语法(如:泛型、可变参数、拆箱装箱等)转为基础语法结构,虚拟机运行时不支持这些高级语法。

  7. 生成字节码:把语法树、符号表里的信息转为字节码写到磁盘,同时进行少量的代码添加和转换工作。

类加载子系统

  1. 类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识。
  2. ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。
  3. 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
img

加载阶段

加载:

  1. 通过一个类的全限定名获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

加载class文件的方式:

  1. 从本地系统中直接加载
  2. 通过网络获取,典型场景:Web Applet
  3. 从zip压缩包中读取,成为日后jar、war格式的基础
  4. 运行时计算生成,使用最多的是:动态代理技术
  5. 由其他文件生成,典型场景:JSP应用从专有数据库中提取.class文件,比较少见
  6. 从加密文件中获取,典型的防Class文件被反编译的保护措施

链接阶段

链接分为三个子阶段:验证 -> 准备 -> 解析

验证

  1. 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全
  2. 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。

比如所有.class文件的十六进制开头魔数,均为CAFE BABE

准备

  1. 为类变量(static变量)分配内存并且设置该类变量的默认初始值,即零值
  2. 这里不包含用final修饰的static,因为final在编译的时候就会分配好了默认值,准备阶段会显式初始化
  3. 注意:这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中

默认初始值举例:

我们的a变量,在这个阶段会先被复制为默认的0。到后面的初始化阶段才会被赋值为1

public class HelloApp {
private static int a = 1;//prepare:a = 0 ---> initial : a = 1

public static void main(String[] args) {
System.out.println(a);
}
}

附上一个默认值图表:

数据类型零值
int0
long0L
short(short)0
char‘\u0000’
byte(byte)0
booleanfalse
float0.0f
double0.0d
referencenull

解析

  1. 将常量池内的符号引用转换为直接引用的过程
  2. 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行
  3. 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
  4. 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info等

符号引用:

  • 反编译 class 文件后可以查看符号引用,下面带# 的就是符号引用
img

初始化阶段

类的主动初始化时机

  1. 创建类的实例
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射(比如:Class.forName(“com.atguigu.Test”))
  5. 初始化一个类的子类
  6. Java虚拟机启动时被标明为启动类的类
  7. JDK7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化

除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化,即不会执行初始化阶段(不会调用 clinit() 方法和 init() 方法)

执行类构造器()方法的过程

  • 对类的静态变量初始化为指定的值,执行静态代码块
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  • 虚拟机会保证一个类的()方法在多线程环境中被正确加锁和同步
  • 当范围一个Java类的静态域时,只有真正声名这个域的类才会被初始化

触发类加载的时机

触发类加载过程的时机主要分为隐式加载和显示加载两种情况

隐式加载

  • 创建类对象
  • 使用类的静态域
  • 创建子类对象
  • 使用子类的静态域
  • 在JVM启动时,BootStrapLoader会加载一些JVM自身运行所需的class
  • 在JVM启动时,ExtClassLoader会加载指定目录下一些特殊的class
  • 在JVM启动时,AppClassLoader会加载classpath路径下的class,以及main函数所在的类的class文件

显示加载

  • ClassLoader.loadClass(className):只加载和连接、不会进行初始化
  • Class.forName(String name, boolean initialize,ClassLoader loader):使用ClassLoader进行加载和连接,根据参数initialize决定是否初始化。

类加载后的情况

  • 类加载完成后主要包括类信息以及类Class对象,其中类信息保存在方法区中,类Class对象保存在堆区。
  • 类信息主要包含运行时常量池、类型信息、字段信息、方法信息、类加载器的引用、对应class实例的引用等信息。
  • 类加载器的引用:这个类到类加载器实例的引用
  • 对应class实例的引用:类加载器在加载类信息放到方法区中后,会创建一个对应的Class 类型的对象实例放到堆(Heap)中, 作为开发人员访问方法区中类定义的入口和切入点。

以上类加载过程,涉及到的都是类信息,以及类属性(static变量),请勿和成员属性混淆,目前只是完成了类模板加载,而没有开始创建对象。

类加载器

  1. JVM严格来讲支持两种类型的类加载器 。分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)
  2. 从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器
  3. 无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个,如下所示
img

启动类加载器

启动类加载器(引导类加载器,Bootstrap ClassLoader

  1. 这个类加载使用C/C++语言实现的,嵌套在JVM内部
  2. 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类
  3. 并不继承自java.lang.ClassLoader,没有父加载器
  4. 加载扩展类和应用程序类加载器,并作为他们的父类加载器
  5. 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类

扩展类加载器

扩展类加载器(Extension ClassLoader

  1. Java语言编写,由sun.misc.Launcher$ExtClassLoader实现
  2. 派生于ClassLoader类
  3. 父类加载器为启动类加载器
  4. 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载

系统类加载器

应用程序类加载器(也称为系统类加载器,AppClassLoader

  1. Java语言编写,由sun.misc.LaunchersAppClassLoader实现
  2. 派生于ClassLoader类
  3. 父类加载器为扩展类加载器
  4. 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
  5. 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
  6. 通过classLoader.getSystemclassLoader()方法可以获取到该类加载器

用户自定义类加载器

在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。那为什么还需要自定义类加载器?

  1. 隔离加载类(比如说我假设现在Spring框架,和RocketMQ有包名路径完全一样的类,类名也一样,这个时候类就冲突了。不过一般的主流框架和中间件都会自定义类加载器,实现不同的框架,中间价之间是隔离的),比如tomcat中可以部署两个相同的war包,比如需要用到热部署
  2. 修改类加载的方式
  3. 扩展加载源(还可以考虑从数据库中加载类,路由器等等不同的地方)
  4. 防止源码泄漏(对字节码文件进行解密,自己用的时候通过自定义类加载器来对其进行解密)

如何实现我们的自定义加载器呢?

继承我们的ClassLoader类

img
public class CustomClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {

try {
byte[] result = getClassFromCustomPath(name);
if(result == null){
throw new FileNotFoundException();
}else{
return defineClass(name,result,0,result.length);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
}

throw new ClassNotFoundException(name);
}

private byte[] getClassFromCustomPath(String name){
//从自定义路径中加载指定类:细节略
//如果指定路径的字节码文件进行了加密,则需要在此方法中进行解密操作。
return null;
}

public static void main(String[] args) {
CustomClassLoader customClassLoader = new CustomClassLoader();
try {
Class<?> clazz = Class.forName("One",true,customClassLoader);
Object obj = clazz.newInstance();
System.out.println(obj.getClass().getClassLoader());
} catch (Exception e) {
e.printStackTrace();
}
}
}

双亲委派机制

原理

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式

原理也特别简单,就是类加载器在加载文件时,要不断的把任务交给最顶级的父类去加载,如果父类无法加载,再一层层向下传递,直到子类加载器。

img

通过上面的例子,我们可以知道,双亲机制优点

  1. 避免类的重复加载
  2. 保护程序安全,防止核心API被随意篡改

    • 自定义类:自定义java.lang.String 没有被加载。
    • 自定义类:java.lang.ShkStart(报错:阻止创建 java.lang开头的类)

打破双亲委派

Tomcat打破了双亲委派机制

同一个Tomcat中,可以部署多个web项目,多个web项目中,会用到相同的类但是可能版本不同,为此Tomcat采用了用户自定义classloader,不同的web包采用不同的classloader,打破了双亲委派机制。

spi打破了双亲委派

spi依赖java.util.ServiceLoader
来加载扩展类,但是该包实在java下的,采用引导类加载器。而需要的实现类都是用户实现的,需要使用系统类加载器。违背了双亲委派原则。为此,java采用了线程上下文类加载器的方式Thread.*currentThread*().getContextClassLoader()
,来获取系统类加载器,加载实现类,打破双亲委派。

类加载器的使用

  1. 源代码加密,解密。
  2. 热加载原理:我们知道类只会加载一次,如何做到热更新?检测到代码变动,直接替换新的类加载器,这样所有类就会重新加载最新的。

运行时数据区概述

在我们的类信息被加载进内存开始,我们就已经开始接触到运行时数据区的内容了。接下来就展开介绍

  1. Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区:其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。
  2. 灰色的为单独线程私有的,红色的为多个线程共享的。即:

    • 线程独有:独立包括程序计数器、栈、本地方法栈
    • 线程间共享:堆、堆外内存(永久代或元空间、代码缓存)
img

JVM线程

  1. 线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行
  2. 在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射

    • 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收
  1. 操作系统负责将线程安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法

JVM系统线程

  1. 虚拟机线程:这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型括”stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销
  2. 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行
  3. GC线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持
  4. 编译线程:这种线程在运行时会将字节码编译成到本地代码
  5. 信号调度线程:这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理

程序计数器

程序计数器(Program Counter Registe)也叫PC寄存器,他有一块很小的内存空间,也是运行速度最快的存储区域。

PC寄存器与线程绑定,每个线程在同一时间内只会运行一个方法,用PC寄存器来存储线程当前需要执行的下一条指令地址。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

它也是唯一一个在Java虚拟机规范中没有规定任何OutofMemoryError情况的区域。

img
img

PC寄存器为什么被设定为私有的?

  1. 因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行
  2. JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令
  3. 每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。

虚拟机栈

在虚拟机栈中,每个方法都对应着一个栈帧,每个方法的执行都伴随着入栈,和执行完后的出栈。

虚拟机栈是每个线程独有的,随着线程的结束而结束。所以栈不需要GC,但是可能存在OOM。

img

Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的。

  • 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackoverflowError 异常。
  • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 OutofMemoryError 异常。

栈运行原理

  1. JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循先进后出(后进先出)原则
  2. 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的。这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)
  3. 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
  4. 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
img
  1. 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
  2. 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
  3. Java方法有两种返回函数的方式。

    • 一种是正常的函数返回,使用return指令。
    • 另一种是方法执行中出现未捕获处理的异常,以抛出异常的方式结束。
    • 但不管使用哪种方式,都会导致栈帧被弹出。

栈的内部结构

每个栈帧中存储着:

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)(或表达式栈)
  • 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
  • 一些附加信息
img

并行每个线程下的栈都是私有的,因此每个线程都有自己各自的栈,并且每个栈里面都有很多栈帧,栈帧的大小主要由局部变量表 和 操作数栈决定的

局部变量表

  1. 局部变量表也被称之为局部变量数组或本地变量表
  2. 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress返回值类型。
  3. 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
  4. 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
  5. 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。

    • 对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。
    • 进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
  1. 局部变量表中的变量只在当前方法调用中有效。

    • 在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。
    • 当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
img

一般在非静态方法中,局部变量表的0号位置,存放的都是this对象。

操作数栈

特点

  1. 每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last - In - First -Out)的 操作数栈,也可以称之为表达式栈(Expression Stack)

  2. 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)


    1. 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈,
    2. 比如:执行复制、交换、求和等操作
img

作用

  1. 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
  2. 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这时方法的操作数栈是空的。
  3. 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为maxstack的值。
  4. 栈中的任何一个元素都是可以任意的Java数据类型

    • 32bit的类型占用一个栈单位深度
    • 64bit的类型占用两个栈单位深度
  1. 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。只不过操作数栈是用数组这个结构来实现的而已
  2. 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
  3. 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
  4. 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

代码举例

public void testAddOperation() {
//byte、short、char、boolean:都以int型来保存
byte i = 15;
int j = 8;
int k = i + j;
}
0 bipush 15
2 istore_1
3 bipush 8
5 istore_2
6 iload_1
7 iload_2
8 iadd
9 istore_3
10 return

1、首先执行第一条语句,PC寄存器指向的是0,也就是指令地址为0,然后使用bipush让操作数15入操作数栈。

img

2、执行完后,PC寄存器往下移,指向下一行代码,下一行代码就是将操作数栈的元素存储到局部变量表1的位置(istore_1),我们可以看到局部变量表的已经增加了一个元素。并且操作数栈为空了

  • 解释为什么局部变量表索引从 1 开始,因为该方法为实例方法,局部变量表索引为 0 的位置存放的是 this
img

3、然后PC下移,指向的是下一行。让操作数8也入栈,同时执行store操作,存入局部变量表中

img

4、然后从局部变量表中,依次将数据放在操作数栈中,等待执行 add 操作

iload_1:取出局部变量表中索引为1的数据入操作数栈

img

5、然后将操作数栈中的两个元素执行相加操作,并存储在局部变量表3的位置

img

通过例子,可以很明显的感知到操作数栈的所用。我们的执行引擎执行命令,以及我们的局部变量表存储对象,都需要通过操作数栈来实现,只会和操作数栈交互

明白了这点,就知道提升操作数栈的运行效率有多么的重要,因此引入了栈顶缓存技术。

栈顶缓存技术:Top Of Stack Cashing

  1. 前面提过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数(也就是你会发现指令很多)和导致内存读/写次数多,效率不高。
  2. 由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
  3. 寄存器的主要优点:指令更少,执行速度快,但是指令集(也就是指令种类)很多

动态链接

  1. 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking),比如:invokedynamic指令
  2. 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
img

为什么需要动态链接呢?我们怎么可能不知道当前运行的是什么方法,还需要动态链接去指明么?其实他是相对于静态链接来说的,详情看下文方法调用。

方法的调用

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

  • 静态链接

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

  • 动态链接

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

早期绑定与晚期绑定

静态链接与动态链接针对的是方法。早期绑定和晚期绑定范围更广。早期绑定涵盖了静态链接,晚期绑定涵盖了动态链接。

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

  • 早期绑定

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

  • 晚期绑定

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

虚拟机中调用方法的指令

  • 普通指令:
  1. invokestatic:调用静态方法,解析阶段确定唯一方法版本
  2. invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
  3. invokevirtual:调用所有虚方法,晚期绑定,虚方法
  4. invokeinterface:调用接口方法,晚期绑定,虚方法
  • 动态调用指令
  1. invokedynamic:动态解析出需要调用的方法,然后执行

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

class Father {
public Father() {
System.out.println("father的构造器");
}

public static void showStatic(String str) {
System.out.println("father " + str);
}

public final void showFinal() {
System.out.println("father show final");
}

public void showCommon() {
System.out.println("father 普通方法");
}
}

public class Son extends Father {
public Son() {
//invokespecial
super();
}

public Son(int age) {
//invokespecial
this();
}

//不是重写的父类的静态方法,因为静态方法不能被重写!
public static void showStatic(String str) {
System.out.println("son " + str);
}

private void showPrivate(String str) {
System.out.println("son private" + str);
}

public void show() {
//invokestatic
showStatic("atguigu.com");
//invokestatic
super.showStatic("good!");
//invokespecial
showPrivate("hello!");
//invokespecial
super.showCommon();

//invokevirtual
showFinal();//因为此方法声明有final,不能被子类重写,所以也认为此方法是非虚方法。
//虚方法如下:

/*
invokevirtual 你没有显示的加super.,编译器认为你可能调用子类的showCommon(即使son子类没有重写,也 会认为),所以编译期间确定不下来,就是虚方法。
*/

showCommon();
info();

MethodInterface in = null;
//invokeinterface
in.methodA();
}

public void info() {

}

public void display(Father f) {
f.showCommon();
}

public static void main(String[] args) {
Son so = new Son();
so.show();
}
}

interface MethodInterface {
void methodA();
}

Java语言中方法重写的本质

  1. 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
  2. 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验。

    • 如果通过则返回这个方法的直接引用,查找过程结束
    • 如果不通过,则返回java.lang.IllegalAccessError 异常
  1. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
  2. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

上面这个过程称为动态分派

虚方法表

  1. 在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(virtual method table)来实现,非虚方法不会出现在表中。使用索引表来代替查找。【上面动态分派的过程,我们可以看到如果子类找不到,还要从下往上找其父类,非常耗时】
  2. 每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
  3. 虚方法表是什么时候被创建的呢?虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的虚方法表也初始化完毕。
img

方法返回地址

  1. 存放调用该方法的pc寄存器的值。一个方法的结束,有两种方式:

    • 正常执行完成
    • 出现未处理的异常,非正常退出
  1. 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
  2. 本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
  3. 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

方法退出的两种方式

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

正常退出:

  1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口
  2. 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
  3. 在字节码指令中,返回指令包含:

    • ireturn:当返回值是boolean,byte,char,short和int类型时使用
    • lreturn:Long类型
    • freturn:Float类型
    • dreturn:Double类型
    • areturn:引用类型
    • return:返回值类型为void的方法、实例初始化方法、类和接口的初始化方法

异常退出:

  1. 在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口
  2. 方法执行过程中,抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码
img

异常处理表:

  • 反编译字节码文件,可得到 Exception table
  • from :字节码指令起始地址
  • to :字节码指令结束地址
  • target :出现异常跳转至地址为 11 的指令执行
  • type :捕获异常的类型
img

一些附加信息

栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。

本地方法栈

1.本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,

2.其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

3.虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

4.与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowErrorOutOfMemoryError异常。

一个线程可能在整个生命周期中都执行Java方法,操作它的Java栈;或者它可能毫无障碍地在Java栈和本地方法栈之间跳转。

img

  1. 堆针对一个JVM进程来说是唯一的。也就是一个进程只有一个JVM实例,一个JVM实例中就有一个运行时数据区,一个运行时数据区只有一个堆和一个方法区。
  2. 但是进程包含多个线程,他们是共享同一堆空间的
img
  1. 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。
  2. Java堆区在JVM启动的时候即被创建,其空间大小也就确定了,堆是JVM管理的最大一块内存空间,并且堆内存的大小是可以调节的。
  3. 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
  4. 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。
  5. 《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)

    • 从实际使用角度看:“几乎”所有的对象实例都在堆分配内存,但并非全部。因为还有一些对象是在栈上分配的(逃逸分析,标量替换)
  1. 数组和对象可能永远不会存储在栈上(不一定),因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
  2. 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

    • 也就是触发了GC的时候,才会进行回收
    • 如果堆中对象马上被回收,那么用户线程就会收到影响,因为有stop the word
  1. 堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域
img

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

  1. Java7 及之前堆内存逻辑上分为三部分:新生代+老年代+永久区

    • Young Generation Space 新生区,又被划分为Eden区和Survivor区
    • Old generation space 老年代 Old/Tenure
    • Permanent Space 永久区 Perm
  1. Java 8及之后堆内存逻辑上分为三部分:新生代+老年代+元空间

    • Young Generation Space 新生区,又被划分为Eden区和Survivor区
    • Old generation space 老年代
    • Meta Space 元空间 Meta
img

堆空间内部结构,JDK1.8之前从永久代 替换成 元空间。也从JVM内存替换成本地内存

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

    • -Xms用于表示堆区的起始内存,初始内存大小:物理电脑内存大小/64
    • -Xmx则用于表示堆区的最大内存,最大内存大小:物理电脑内存大小/4
  1. 一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出OutofMemoryError异常。
  2. 通常会将-Xms和-Xmx两个参数配置相同的值
  • 原因:假设两个不一样,初始内存小,最大内存大。在运行期间如果堆内存不够用了,会一直扩容直到最大内存。如果内存够用且多了,也会不断的缩容释放。频繁的扩容和释放造成不必要的压力,避免在GC之后调整堆内存给服务器带来压力。
  • 如果两个设置一样的就少了频繁扩容和缩容的步骤。内存不够了就直接报OOM

堆空间细节

img

Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen)。其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)

img
  • 配置新生代与老年代在堆结构的占比


    • 默认**-XX:NewRatio=2**,表示新生代占1,老年代占2,新生代占整个堆的1/3
    • 可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
  1. 在HotSpot中,Eden空间和另外两个survivor空间缺省所占的比例是8 : 1 : 1,
  2. 当然开发人员可以通过选项**-XX:SurvivorRatio调整这个空间比例。比如-XX:SurvivorRatio=8**
  3. 几乎所有的Java对象都是在Eden区被new出来的。
  4. 绝大部分的Java对象的销毁都在新生代进行了(有些大的对象在Eden区无法存储时候,将直接进入老年代),IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”的。
  5. 可以使用选项”**-Xmn**”设置新生代最大内存大小,但这个参数一般使用默认值就可以了。

对象分配过程

对象区域分配的过程

  1. new的对象先放伊甸园区。此区有大小限制。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。
  3. 然后将伊甸园中的剩余对象移动到幸存者0区。
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
  5. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
  6. 啥时候能去老年代呢?可以设置次数。默认是15次。可以设置新生区进入养老区的年龄限制,设置 JVM 参数:**-XX:MaxTenuringThreshold**=N 进行设置
  7. 在老年代,相对悠闲。当老年代内存不足时,再次触发GC:Major GC,进行老年代的内存清理
  8. 若老年代执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常。
img

1、我们创建的对象,一般都是存放在Eden区的,当我们Eden区满了后,就会触发GC操作,一般被称为 YGC Minor GC操作

2、当我们进行一次垃圾收集后,红色的对象将会被回收,而绿色的独享还被占用着,存放在S0(Survivor From)区。同时我们给每个对象设置了一个年龄计数器,经过一次回收后还存在的对象,将其年龄加 1

3、同时Eden区继续存放对象,当Eden区再次存满的时候,又会触发一个MinorGC操作,此时GC将会把 Eden和Survivor From中的对象进行一次垃圾收集,把存活的对象放到 Survivor To(S1)区,同时让存活的对象年龄 + 1

下一次再进行GC的时候,

1、这一次的s0区为空,所以成为下一次GC的S1区

2、这一次的s1区则成为下一次GC的S0区

3、也就是说s0区和s1区在互相转换。

4、我们继续不断的进行对象生成和垃圾回收,当Survivor中的对象的年龄达到15的时候,将会触发一次 Promotion 晋升的操作,也就是将年轻代中的对象晋升到老年代中

关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。

img

GC分类

  1. 我们都知道,JVM的调优的一个环节,也就是垃圾收集,我们需要尽量的避免垃圾回收,因为在垃圾回收的过程中,容易出现STW(Stop the World)的问题,而 Major GC 和 Full GC出现STW的时间,是Minor GC的10倍以上
  2. JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(FullGC)
  • 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:


    • 新生代收集(Minor GC/Young GC):只是新生代(Eden,s0,s1)的垃圾收集
    • 老年代收集(Major GC/Old GC):只是老年代的圾收集。
    • 目前,只有CMS GC会有单独收集老年代的行为。
    • 注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。目前,只有G1 GC会有这种行为
  • 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。

Young GC

年轻代 GC(Minor GC)触发机制

  1. 当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden代满。Survivor满不会主动引发GC,在Eden区满的时候,会顺带触发s0区的GC,也就是被动触发GC(每次Minor GC会清理年轻代的内存)
  2. 因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
  3. Minor GC会引发STW(Stop The World),暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行

Major/Full GC

老年代GC(MajorGC)触发机制

  1. 指发生在老年代的GC,对象从老年代消失时,我们说 “Major Gc” 或 “Full GC” 发生了
  2. 出现了MajorGc,经常会伴随至少一次的Minor GC。(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行MajorGC的策略选择过程)

    • 也就是在老年代空间不足时,会先尝试触发Minor GC,如果之后空间还不足,则触发Major GC
  1. Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。
  2. 如果Major GC后,内存还不足,就报OOM了

触发Full GC执行的情况有如下五种:

  1. 调用System.gc()时,系统建议执行FullGC,但是不必然执行
  2. 老年代空间不足
  3. 方法区空间不足
  4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  5. 由Eden区、survivor space0(From Space)区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

说明:Full GC 是开发或调优中尽量要避免的。这样STW时间会短一些

堆空间分代思想

为什么要把Java堆分代?不分代就不能正常工作了吗?经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。

  • 新生代:有Eden、两块大小相同的survivor(又称为from/to或s0/s1)构成,to总为空。
  • 老年代:存放新生代中经历多次GC仍然存活的对象。

其实不分代完全可以,分代的唯一理由就是优化GC性能。

  • 如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。(性能低)
  • 而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。(多回收新生代,少回收老年代,性能会提高很多)
img

针对不同年龄段的对象分配原则如下所示:

  1. 优先分配到Eden:开发中比较长的字符串或者数组,会直接存在老年代,但是因为新创建的对象都是朝生夕死的,所以这个大对象可能也很快被回收,但是因为老年代触发Major GC的次数比 Minor GC要更少,因此可能回收起来就会比较慢
  2. 大对象直接分配到老年代:尽量避免程序中出现过多的大对象
  3. 长期存活的对象分配到老年代
  4. 动态对象年龄判断:如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
  5. 空间分配担保:-XX:HandlePromotionFailure 。

TLAB为对象分配内存(保证线程安全)

为什么有 TLAB

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

什么是 TLAB

TLAB(Thread Local Allocation Buffer)

  1. 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内
  2. 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
  3. 据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计。
img

1、每个线程都有一个TLAB空间

2、当一个线程的TLAB存满时,可以使用公共区域(蓝色)的

TLAB再说明

  1. 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选
  2. 在程序中,开发人员可以通过选项“**-XX:UseTLAB**”设置是否开启TLAB空间。
  3. 默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项“**-XX:TLABWasteTargetPercent**”设置TLAB空间所占用Eden空间的百分比大小。
  4. 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。

堆空间参数设置

/**
* 测试堆空间常用的jvm参数:
* -XX:+PrintFlagsInitial : 查看所有的参数的默认初始值
* -XX:+PrintFlagsFinal :查看所有的参数的最终值(可能会存在修改,不再是初始值)
* 具体查看某个参数的指令:jps:查看当前运行中的进程
* jinfo -flag SurvivorRatio 进程id
*
* -Xms:初始堆空间内存 (默认为物理内存的1/64)
* -Xmx:最大堆空间内存(默认为物理内存的1/4)
* -Xmn:设置新生代的大小。(初始值及最大值)
* -XX:NewRatio:配置新生代与老年代在堆结构的占比
* -XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
* -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
* -XX:+PrintGCDetails:输出详细的GC处理日志
* 打印gc简要信息:① -XX:+PrintGC ② -verbose:gc
* -XX:HandlePromotionFailure:是否设置空间分配担保
*/


堆分配对象是唯一选择么?

在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:

  1. 随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
  2. 在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
  3. 此外,前面提到的基于OpenJDK深度定制的TaoBao VM,其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。

逃逸分析

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

    • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
    • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
//对象v没发生逃逸
public void my_method() {
V v = new V();
// use v
// ....
v = null;
}
//对象sb发生了逃逸,作为返回值返回了
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}

逃逸分析参数设置

  1. 在JDK 1.7 版本之后,HotSpot中默认就已经开启了逃逸分析
  2. 如果使用的是较早的版本,开发人员则可以通过:

    • 选项“-XX:+DoEscapeAnalysis”显式开启逃逸分析
    • 通过选项“-XX:+PrintEscapeAnalysis”查看逃逸分析的筛选结果

代码优化

使用逃逸分析,编译器可以对代码做如下优化:

  1. 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配
  2. 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
  3. 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

栈上分配

  1. JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
  2. 常见的栈上分配的场景:在逃逸分析中,已经说明了,分别是给成员变量赋值、方法返回值、实例引用传递。

同步省略(同步消除)

  1. 线程同步的代价是相当高的,同步的后果是降低并发性和性能。
  2. 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程
  3. 如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除
public void f() {
Object hollis = new Object();
synchronized(hollis) {
System.out.println(hollis);
}
}
//jit优化后
public void f() {
Object hellis = new Object();
System.out.println(hellis);
}

标量替换

分离对象或标量替换

  1. 标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
  2. 相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
  3. 在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
public static void main(String args[]) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x" + point.x + ";point.y" + point.y);
}
class Point {
private int x;
private int y;
}

优化后

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

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

逃逸分析的不足

  1. 关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟的。
  2. 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
  3. 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
  4. 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。
  5. 注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。据我所知,Oracle Hotspot JVM中并未这么做(刚刚演示的效果,是因为HotSpot实现了标量替换),这一点在逃逸分析相关的文档里已经说明,所以可以明确在HotSpot虚拟机上,所有的对象实例都是创建在堆上
  6. 目前很多书籍还是基于JDK7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配所以这一点同样符合前面一点的结论:对象实例都是分配在堆上

综上:对象实例都是分配在堆上

方法区

img

下图可以很明显的看出方法去,java栈

img

方法区的基本理解

方法区主要存放的是 Class,而堆中主要存放的是实例化的对象

  1. 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。多个线程同时加载统一个类时,只能有一个线程能加载该类,其他线程只能等等待该线程加载完毕,然后直接使用该类,即类只能加载一次。
  2. 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
  3. 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
  4. 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutofMemoryError:PermGen space或者java.lang.OutOfMemoryError:Metaspace

    • 加载大量的第三方的jar包
    • Tomcat部署的工程过多(30~50个)
    • 大量动态的生成反射类
  1. 关闭JVM就会释放这个区域的内存。

HotSpot方法区演进

  1. 在 JDK7 及以前,习惯上把方法区,称为永久代。JDK8开始,使用元空间取代了永久代。我们可以将方法区类比为Java中的接口,将永久代或元空间类比为Java中具体的实现类
  2. 本质上,方法区和永久代并不等价。仅是对Hotspot而言的可以看作等价。《Java虚拟机规范》对如何实现方法区,不做统一要求。例如:BEAJRockit IBM J9 中不存在永久代的概念。

    • 现在来看,当年使用永久代,不是好的idea。导致Java程序更容易OOm(超过-XX:MaxPermsize上限)
  1. 而到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替
  2. 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
  3. 永久代、元空间二者并不只是名字变了,内部结构也调整了
  4. 根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常
img
  1. 元数据区大小可以使用参数 -XX:MetaspaceSize-XX:MaxMetaspaceSize 指定
  2. 默认值依赖于平台,Windows下,-XX:MetaspaceSize 约为21M,-XX:MaxMetaspaceSize的值是-1,即没有限制。
  3. 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace
  4. -XX:MetaspaceSize:设置初始的元空间大小。对于一个 64位 的服务器端 JVM 来说,其默认的 -XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。
  5. 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。

方法区内部结构

img

《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

类型信息

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

  1. 这个类型的完整有效名称(全名=包名.类名)
  2. 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)
  3. 这个类型的修饰符(public,abstract,final的某个子集)
  4. 这个类型直接接口的一个有序列表

域(Field)信息

也就是我们常说的成员变量,域信息是比较官方的称呼

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

方法(Method)信息

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

  1. 方法名称
  2. 方法的返回类型(包括 void 返回类型),void 在 Java 中对应的为 void.class
  3. 方法参数的数量和类型(按顺序)
  4. 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
  5. 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
  6. 异常表(abstract和native方法除外),异常表记录每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
Classfile F:/IDEAWorkSpaceSourceCode/JVMDemo/out/production/chapter09/com/atguigu/java/MethodInnerStrucTest.class
Last modified 2020-11-13
; size 1626 bytes
MD5 checksum 0d0fcb54854d4ce183063df985141ad0
Compiled from "MethodInnerStrucTest.java"
//类型信息
public class com.atguigu.java.MethodInnerStrucTest extends java.lang.Object implements java.lang.Comparable<java.lang.String>, java.io.Serializable
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
常量池,加载进内存后变成运行时常量池
Constant pool:
#1
= Methodref #18.#52 // java/lang/Object."<init>":()V
#2 = Fieldref #17.#53 // com/atguigu/java/MethodInnerStrucTest.num:I
#3 = Fieldref #54.#55 // java/lang/System.out:Ljava/io/PrintStream;
#4 = Class #56 // java/lang/StringBuilder
#5 = Methodref #4.#52 // java/lang/StringBuilder."<init>":()V
#6 = String #57 // count =
#7 = Methodref #4.#58 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#8 = Methodref #4.#59 // java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
#9 = Methodref #4.#60 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#10 = Methodref #61.#62 // java/io/PrintStream.println:(Ljava/lang/String;)V
#11 = Class #63 // java/lang/Exception
#12 = Methodref #11.#64 // java/lang/Exception.printStackTrace:()V
#13 = Class #65 // java/lang/String
#14 = Methodref #17.#66 // com/atguigu/java/MethodInnerStrucTest.compareTo:(Ljava/lang/String;)I
#15 = String #67 // 测试方法的内部结构
#16 = Fieldref #17.#68 // com/atguigu/java/MethodInnerStrucTest.str:Ljava/lang/String;
#17 = Class #69 // com/atguigu/java/MethodInnerStrucTest
#18 = Class #70 // java/lang/Object
#19 = Class #71 // java/lang/Comparable
#20 = Class #72 // java/io/Serializable
#21 = Utf8 num
#22 = Utf8 I
#23 = Utf8 str
#24 = Utf8 Ljava/lang/String;
#25 = Utf8 <init>
#26 = Utf8 ()V
#27 = Utf8 Code
#28 = Utf8 LineNumberTable
#29 = Utf8 LocalVariableTable
#30 = Utf8 this
#31 = Utf8 Lcom/atguigu/java/MethodInnerStrucTest;
#32 = Utf8 test1
#33 = Utf8 count
#34 = Utf8 test2
#35 = Utf8 (I)I
#36 = Utf8 value
#37 = Utf8 e
#38 = Utf8 Ljava/lang/Exception;
#39 = Utf8 cal
#40 = Utf8 result
#41 = Utf8 StackMapTable
#42 = Class #63 // java/lang/Exception
#43 = Utf8 compareTo
#44 = Utf8 (Ljava/lang/String;)I
#45 = Utf8 o
#46 = Utf8 (Ljava/lang/Object;)I
#47 = Utf8 <clinit>
#48 = Utf8 Signature
#49 = Utf8 Ljava/lang/Object;Ljava/lang/Comparable<Ljava/lang/String;>;Ljava/io/Serializable;
#50 = Utf8 SourceFile
#51 = Utf8 MethodInnerStrucTest.java
#52 = NameAndType #25:#26 // "<init>":()V
#53 = NameAndType #21:#22 // num:I
#54 = Class #73 // java/lang/System
#55 = NameAndType #74:#75 // out:Ljava/io/PrintStream;
#56 = Utf8 java/lang/StringBuilder
#57 = Utf8 count =
#58 = NameAndType #76:#77 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#59 = NameAndType #76:#78 // append:(I)Ljava/lang/StringBuilder;
#60 = NameAndType #79:#80 // toString:()Ljava/lang/String;
#61 = Class #81 // java/io/PrintStream
#62 = NameAndType #82:#83 // println:(Ljava/lang/String;)V
#63 = Utf8 java/lang/Exception
#64 = NameAndType #84:#26 // printStackTrace:()V
#65 = Utf8 java/lang/String
#66 = NameAndType #43:#44 // compareTo:(Ljava/lang/String;)I
#67 = Utf8 测试方法的内部结构
#68 = NameAndType #23:#24 // str:Ljava/lang/String;
#69 = Utf8 com/atguigu/java/MethodInnerStrucTest
#70 = Utf8 java/lang/Object
#71 = Utf8 java/lang/Comparable
#72 = Utf8 java/io/Serializable
#73 = Utf8 java/lang/System
#74 = Utf8 out
#75 = Utf8 Ljava/io/PrintStream;
#76 = Utf8 append
#77 = Utf8 (Ljava/lang/String;)Ljava/lang/StringBuilder;
#78 = Utf8 (I)Ljava/lang/StringBuilder;
#79 = Utf8 toString
#80 = Utf8 ()Ljava/lang/String;
#81 = Utf8 java/io/PrintStream
#82 = Utf8 println
#83 = Utf8 (Ljava/lang/String;)V
#84 = Utf8 printStackTrace
{
//域信息
public int num;
descriptor: I
flags: ACC_PUBLIC

private static java.lang.String str;
descriptor: Ljava/lang/String;
flags: ACC_PRIVATE, ACC_STATIC

//方法信息
public com.atguigu.java.MethodInnerStrucTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: bipush 10
7: putfield #2 // Field num:I
10: return
LineNumberTable:
line 10: 0
line 12: 4
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lcom/atguigu/java/MethodInnerStrucTest;

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

  1. 方法区,内部包含了运行时常量池
  2. 字节码文件,内部包含了常量池。(之前的字节码文件中已经看到了很多Constant pool的东西,这个就是常量池)
  3. 要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区。
  4. 要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。

常量池中有啥?

  1. 数量值
  2. 字符串值
  3. 类引用
  4. 字段引用
  5. 方法引用
img

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

运行时常量池

  1. 运行时常量池(Runtime Constant Pool)是方法区的一部分。
  2. 常量池表(Constant Pool Table)是Class字节码文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。(运行时常量池就是常量池在程序运行时的称呼)
  3. 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
  4. JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
  5. 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址
  • 运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性。
  1. 运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些。
  2. 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutofMemoryError异常。

运行时常量池是类的常量池在加载进内存后,对所有符号引用变成真实引用后的常量池

方法区演进细节

img
img

JDK6

方法区由永久代实现,使用 JVM 虚拟机内存(虚拟的内存)

img

JDK7

方法区由永久代实现,使用 JVM 虚拟机内存

img

JDK8

方法区由元空间实现,使用物理机本地内存

img

永久代为什么要被元空间替代?

  1. 随着Java8的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace)。

  2. 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。

  3. 这项改动是很有必要的,原因有:


    1. 永久代设置空间大小是很难确定的。在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。Exception in thread 'dubbo client x.x connector' java.lang.OutOfMemoryError:PermGen space而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
    2. 对永久代进行调优是很困难的。方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再用的类型,方法区的调优主要是为了降低Full GC

    1. 有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。
    2. 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻**。但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。

字符串常量池 StringTable 为什么要调整位置?

  • JDK7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在Full GC的时候才会执行永久代的垃圾回收,而Full GC是老年代的空间不足、永久代不足时才会触发。
  • 这就导致StringTable回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存

方法区的垃圾回收

  1. 有些人认为方法区(如Hotspot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。
  2. 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。
  3. 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型
  4. 先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:

    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符
  1. HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
  2. 回收废弃常量与回收Java堆中的对象非常类似。(关于常量的回收比较简单,重点是类的回收)

下面也称作类卸载

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

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

2、Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class 以及 -XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看类加载和卸载信息

3、在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

运行时数据区总结:

对象实例化

img

对象创建的方式

  1. new:最常见的方式、单例类中调用getInstance的静态类方法,XXXFactory的静态方法
  2. Class的newInstance方法:在JDK9里面被标记为过时的方法,因为只能调用空参构造器,并且权限必须为 public
  3. Constructor的newInstance(Xxxx):反射的方式,可以调用空参的,或者带参的构造器
  4. 使用clone():不调用任何的构造器,要求当前的类需要实现Cloneable接口中的clone方法
  5. 使用序列化:从文件中,从网络中获取一个对象的二进制流,序列化一般用于Socket的网络传输
  6. 第三方库 Objenesis

对象创建的步骤

1、判断对象对应的类是否加载、链接、初始化

  1. 虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化。(即判断类元信息是否存在)。
  2. 如果该类没有加载,那么在双亲委派模式下,使用当前类加载器以ClassLoader + 包名 + 类名为key进行查找对应的.class文件,如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class对象。

2、为对象分配内存

  1. 首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小
  2. 如果内存规整:采用指针碰撞分配内存

    • 如果内存是规整的,那么虚拟机将采用的是指针碰撞法(Bump The Point)来为对象分配内存。
    • 意思是所有用过的内存在一边,空闲的内存放另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针往空闲内存那边挪动一段与对象大小相等的距离罢了。
    • 如果垃圾收集器选择的是Serial ,ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带Compact(整理)过程的收集器时,使用指针碰撞。
    • 标记压缩(整理)算法会整理内存碎片,堆内存一存对象,另一边为空闲区域
  1. 如果内存不规整

    • 如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表来为对象分配内存。
    • 意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为了 “空闲列表(Free List)”
    • 选择哪种分配方式由Java堆是否规整所决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定
    • 标记清除算法清理过后的堆内存,就会存在很多内存碎片。

3、处理并发问题

  1. 采用CAS+失败重试保证更新的原子性
  2. 每个线程预先分配TLAB - 通过设置 -XX:+UseTLAB参数来设置(区域加锁机制)
  3. 在Eden区给每个线程分配一块区域

4、初始化分配到的空间

  • 所有属性设置默认值,保证对象实例字段在不赋值可以直接使用
  • 给对象属性赋值的顺序:
  1. 属性的默认值初始化
  2. 显示初始化/代码块初始化(并列关系,谁先谁后看代码编写的顺序)
  3. 构造器初始化

5、设置对象的对象头

将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。

6、执行init方法进行初始化

  1. 在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量
  2. 因此一般来说(由字节码中跟随invokespecial指令所决定),new指令之后会接着就是执行init方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完成创建出来。
/**
* 测试对象实例化的过程
* ① 加载类元信息 - ② 为对象分配内存 - ③ 处理并发问题 - ④ 属性的默认初始化(零值初始化)
* - ⑤ 设置对象头的信息 - ⑥ 属性的显式初始化、代码块中初始化、构造器中初始化
*
*
* 给对象的属性赋值的操作:
* ① 属性的默认初始化 - ② 显式初始化 ③ 代码块中初始化 - ④ 构造器中初始化
*/

/**
*init() 方法的字节码指令:
属性的默认值初始化:id = 1001;
显示初始化/代码块初始化:name = "匿名客户";
构造器初始化:acct = new Account();
*/

public class Customer{
int id = 1001;
String name;
Account acct;

{
name = "匿名客户";
}
public Customer(){
acct = new Account();
}

}
class Account{

}

对象的内存布局

img

对象头

  1. Mark Word
  2. 指向元空间的类型信息的指针
  3. 如果是数组还有数据长度的记录

实例数据:存放类的属性数据信息,包括父类的属性信息;

对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。原因是为了寻址最优,64位机器正好8个字节;

img

JVM升级锁的过程

可以去查看单独的章节,看完大呼过瘾

JVM内存图:

img

执行引擎

img
  1. 执行引擎是Java虚拟机核心的组成部分之一。
  2. “虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式
  3. JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。
  4. 那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。
img

1、前端编译:从Java程序员-字节码文件的这个过程叫前端编译

2、执行引擎这里有两种行为:一种是解释执行,一种是编译执行(这里的是后端编译)。

大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图中的各个步骤:

  1. 前面橙色部分是编译生成生成字节码文件的过程(javac编译器来完成,也就是前端编译器),和JVM没有关系。
  2. 后面绿色(解释执行)和蓝色(即时编译)才是JVM需要考虑的过程
img

Java 代码执行的分类

  1. 第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行
  2. 第二种是编译执行(直接编译成机器码)。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT,Just In Time)将方法编译成机器码后再执行
  3. HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。
  4. 在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和C/C++ 程序一较高下的地步。

JVM中同时拥有解释器和即时编译器

  1. 解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
  2. JIT(Just In Time Compiler)编译器:就是虚拟机将源代码一次性直接编译成和本地机器平台相关的机器语言,但并不是马上执行

为什么Java是半编译半解释型语言?

  1. JDK1.0时代,将Java语言定位为“解释执行”还是比较准确的。再后来,Java也发展出可以直接生成本地代码的编译器。
  2. 现在JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。
  3. JIT编译器将字节码翻译成本地代码后,就可以做一个缓存操作,存储在方法区的JIT 代码缓存中(执行效率更高了),并且在翻译成本地代码的过程中可以做优化。
img

解释器

  1. JVM设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式由高级语言直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法(也就是产生了一个中间产品字节码)。
  2. 解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
  3. 当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。

JIT编译器

现代虚拟机为了提高执行效率,会使用即时编译技术(JIT,Just In Time)将方法编译成机器码后再执行

热点代码及探测方式

  1. 当然是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。

  2. 关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能。

  3. 一个被多次调用的方法,或者是一-个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为OSR (On StackReplacement)编译。

  4. 一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。

  5. 目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测

  6. 采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。


    1. 方法调用计数器用于统计方法的调用次数
    2. 回边计数器则用于统计循环体执行的循环次数

方法调用计数器

  1. 这个计数器就用于统计方法被调用的次数,它的默认阀值在Client模式下是1500次,在Server模式下是10000次。超过这个阈值,就会触发JIT编译。
  2. 这个阀值可以通过虚拟机参数 -XX:CompileThreshold 来人为设定。
  3. 当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本

    • 如果存在,则优先使用编译后的本地代码来执行
    • 如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。

    • 如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
    • 如果未超过阈值,则使用解释器对字节码文件解释执行

回边计数器

它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。显然,建立回边计数器统计的目的就是为了触发OSR编译。

热度衰减

  1. 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)(半衰周期是化学中的概念,比如出土的文物通过查看C60来获得文物的年龄)
  2. 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:-UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样的话,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
  3. 另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

C1和C2编译器不同的优化策略

  1. 在不同的编译器上有不同的优化策略,C1编译器(client)上主要有方法内联,去虚拟化、元余消除。

    • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
    • 去虚拟化:对唯一的实现樊进行内联
    • 冗余消除:在运行期间把一些不会执行的代码折叠掉
  1. C2(server)的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在C2上有如下几种优化:

    • 标量替换:用标量值代替聚合对象的属性值
    • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
    • 同步消除:清除同步操作,通常指synchronized

StringTable(字符串常量池)

基本特性

String s1 = "atguigu" ;   			// 字面量的定义方式
String s2 = new String("hello"); // new 对象的方式

  1. String:字符串,使用一对 “” 引起来表示
  2. String被声明为final的,不可被继承
  3. String实现了Serializable接口:表示字符串是支持序列化的。实现了Comparable接口:表示String可以比较大小
  4. String在jdk8及以前内部定义了final char value[]用于存储字符串数据。JDK9时改为byte[]

为什么1.9把String底层改为byte[]?

  1. String类的当前实现将字符存储在char数组中,每个字符使用两个字节(16位)。
  2. 从许多不同的应用程序收集的数据表明,字符串是堆使用的主要组成部分,而且大多数字符串对象只包含拉丁字符(Latin-1)。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部char数组中有一半的空间将不会使用,产生了大量浪费。
  3. 之前 String 类使用 UTF-16 的 char[] 数组存储,现在改为 byte[] 数组 外加一个编码标识存储。该编码表示如果你的字符是ISO-8859-1或者Latin-1,那么只需要一个字节存。如果你是其它字符集,比如UTF-8,你仍然用两个字节存
  4. 结论:String再也不用char[] 来存储了,改成了byte [] 加上编码标记,节约了一些空间
  5. 同时基于String的数据结构,例如StringBuffer和StringBuilder也同样做了修改

内存分配

  1. 在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
  2. 常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种。

    • 直接使用双引号声明出来的String对象会直接存储在常量池中。比如:String info="atguigu.com";
    • 如果不是用双引号声明的String对象,可以使用String提供的intern()方法。这个后面重点谈
  1. Java 6及以前,字符串常量池存放在永久代
  2. Java 7中 Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内

    • 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
    • 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java 7中使用String.intern()。
  1. Java8元空间,字符串常量在堆
img

为什么1.7以后存在堆中?

  1. 为什么要调整位置?

    • 永久代的默认空间大小比较小
    • 永久代垃圾回收频率低,大量的字符串无法及时回收,容易进行Full GC产生STW或者容易产生OOM:PermGen Space
    • 堆中空间足够大,字符串可被及时回收
  1. 在JDK 7中,interned字符串不再在Java堆的永久代中分配,而是在Java堆的主要部分(称为年轻代和年老代)中分配,与应用程序创建的其他对象一起分配。
  2. 此更改将导致驻留在主Java堆中的数据更多,驻留在永久生成中的数据更少,因此可能需要调整堆大小。

引用字符串常量池的两种方式

String s1 = "atguigu" ;// 字面量的定义方式
//这种方案在垃圾回收后才会真正节省内存
String s2 = new String(""atguigu"").intern() ;//intern()

相关面试题

**
* 如何保证变量s指向的是字符串常量池中的数据呢?
* 有两种方式:
* 方式一:String s = "shkstart";//字面量定义的方式
* 方式二:调用intern()
* String s = new String("shkstart").intern();
* String s = new StringBuilder("shkstart").toString().intern();
*
*/
public class StringIntern {
public static void main(String[] args) {

String s = new String("1");
s.intern();//调用此方法之前,字符串常量池中已经存在了"1"
String s2 = "1";
System.out.println(s == s2);//jdk6:false jdk7/8:false

/*
1、s3变量记录的地址为:new String("11")
2、经过上面的分析,我们已经知道执行完pos_1的代码,在堆中有了一个new String("11")
这样的String对象。但是在字符串常量池中没有"11"
3、接着执行s3.intern(),在字符串常量池中生成"11"
3-1、在JDK6的版本中,字符串常量池还在永久代,所以直接在永久代生成"11",也就有了新的地址
3-2、而在JDK7的后续版本中,字符串常量池被移动到了堆中,此时堆里已经有new String("11")了
出于节省空间的目的,直接将堆中的那个字符串的引用地址储存在字符串常量池中。没错,字符串常量池
中存的是new String("11")在堆中的地址
4、所以在JDK7后续版本中,s3和s4指向的完全是同一个地址。
*/

String s3 = new String("1") + new String("1");//pos_1
s3.intern();

String s4 = "11";//s4变量记录的地址:使用的是上一行代码代码执行时,在常量池中生成的"11"的地址
System.out.println(s3 == s4);//jdk6:false jdk7/8:true
}


}

关键点在于jdk6,是直接在永久代创建新的字符串常量。jdk7是将已经存在的引用地址给了字符串常量池

/**
* 思考会创建几个对象:
* new String("a") + new String("b")呢?
* 对象1:new StringBuilder()
* 对象2:new String("a")
* 对象3:常量池中的"a"
* 对象4:new String("b")
* 对象5:常量池中的"b"
*
* 深入剖析:StringBuilder的toString():
* 对象6 :new String("ab")
* 强调一下,toString()的调用,在字符串常量池中,没有生成"ab"
*
*/

public class StringNewTest {
public static void main(String[] args) {

String str = new String("a") + new String("b");
}
}




END



后台回复关键词 JVM 获取今日推荐资料

微信8.0新增了一万的好友数,之前没加上好友的可以加一下我的个人微信,再晚又满了,一起抱团取暖结伴内卷。



扫码拉群,学习打卡,交流经验


每周一读




文章转载自阿斌Java之路,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论