JVM架构模型
Java编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构是基于寄存器的指令集架构。
基于栈式架构的特点
- 设计和实现更简单,适用于资源受限的系统
- 避开了寄存器的分配难题,使用零地址指令方式分配
- 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现
- 不需要硬件支持,可移植性更好,更好实现跨平台
基于寄存器架构的特点
- 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虚拟机
- 指令集架构则完全依赖硬件,可移植性差
- 性能优秀和执行更高效
- 花费更少的指令去完成一项操作
- 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主
类加载子系统
类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识。ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
类加载过程
加载Loading
通过一个类的全限定名获取定义此类的二进制字节流
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
链接阶段
验证Verify
目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
准备Prepare
为类变量分配内存并且设置该类变量的默认初始值,即零值。
这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化(TO DO)
解析Resolve
将常量池内的符号引用转换为直接引用的过程。
事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT Class info、CONSTANT FieldRef info、CONSTANT MethodRef info
等
初始化阶段
- 初始化过程是执行类构造器
<clint>()
的过程 - 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中语句合并而来
- 构造器方法中指令按语句在源文件中出现的顺序执行
<clinit>()
不同于子类的构造器- 若该类具有父类,JVM会保证子类的
<clint>()
执行前,父类的<clint>()
已经执行完毕(clint执行的是类中的实例属性以及静态代码块部分) - 虚拟机必须保证一个类的
<clint>()
方法在多线程下被同步加锁(即Class文件只会被加载一次)
Java对象实例初始化过程
- 父类的clinit方法,按照在代码出现的顺序依次执行
- 子类的clinit方法,按照在代码出现的顺序依次执行
- 父类的普通成员变量和普通代码块,按照在代码出现的顺序依次执行
- 父类的构造函数
- 子类的普通成员变量和普通代码块,按照在代码出现的顺序依次执行
- 执行子类的构造函数
类加载器
启动类加载器(引导类加载器,Bootstrap ClassLoader)
- 这个类使用C/C++语言实现的,嵌套在JVM内部
- 用来加载Java的核心类库
JAVA_HOME/jre/lib/rt.jar
、resources.jar
等,用于提供JVM自身需要的类 - 并不继承Java.lang.ClassLoader没有父加载器
- 加载扩展类和应用程序类加载器,并指定为他们的父加载器
- 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
扩展类加载器(Extension ClassLoader)
- Java语言编写,由sun.misc.Launcher$ExtClassLoader实现。
- 派生于ClassLoader类
- 父类加载器为启动类加载器
- 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/1ib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。
应用程序类加载器(系统类加载器 AppClassLoader)
- Java语言编写,由sun.misc.LaunchersAppClassLoader实现
- 派生于ClassLoader类
- 父类加载器为扩展类加载器
- 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
- 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
双亲委派机制
Java虚拟机对class文件采用的是按需加载对方式,也就是说需要使用该类时才会将它的class文件加载到内存中生成class对象,而且加载某一个类的class文件时,Java虚拟机采用的双亲委派机制,即把请求交由父类处理。
工作原理
- 如果一个类加载器收到类类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
- 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
双亲委派机制举例(SPI)
当加载jdbc.jar
用于实现数据库连接的时候,首先jdbc.jar
是基于SPI
接口实现的,所以在加载的时候,会进行双亲委派机制,最后从根加载器加载SPI
核心类,然后在加载SPI
接口类,但是根加载器无法加载各个厂商的实现类,只能通过线程上下文类加载器来获取系统类加载器来进行实现类的加载。
双亲委派机制的优点
- 避免类的重复加载
- 保护程序安全,防止核心API被随意篡改
如何判断两个class对象是否相同
- 类的完整类名必须一致,包括包名
- 加载这个类的ClassLoader必须相同
JVM必须知道一个类是由启动类加载器加载还是用户类加载器加载,如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。
运行时数据区
一个JVM只有一个RunTime的实例
JVM允许一个应用有多个线程并行的执行,在Hotspot JVM里,每一个线程都对应着操作系统的本地线程(当一个Java线程准备好执行之后,此时一个操作系统的本地线程也同时创建,Java线程执行终止,本地线程也会回收)。操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()
方法。
程序计数器
并非是广义上所指的物理寄存器,是一块很小的内存空间,是运行速度最快的存储区域。
- 每一个线程都有自己的程序计数器,是线程私有的
- 它是唯一一个在Java虚拟机规范中没有规定任何outotMemoryError情况的区域。
- PC寄存器用来存储指向下一条指令的地址(中断)
使用PC寄存器存储字节码指令地址有什么用呢?
因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。
JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
虚拟机栈
每一个线程在创建时都会创建一个虚拟机栈,其内部保存着一个个栈帧,对应着Java方法的调用**(是线程私有的)**
栈的特点
是一种快速有效的分配存储方式,访问速度仅此于程序计数器,栈不存在垃圾回收的问题,栈会出现溢出的情况。
开发中遇到哪些异常
Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的。
如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackoverflowError
异常。
如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 outofMemoryError
异常。
设置栈内存大小
我们可以使用参数 -Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度
-Xss1m
-Xss1k
栈的存储单位
- 栈中的数据都是以栈帧的格式存在
- 在线程上正在执行的每一个方法都各自对应一个栈帧
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
栈的运行原理
不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
栈的内部结构
栈帧的大小主要由局部变量表 和 操作数栈决定的
- 局部变量表(Local Variables)
- 操作数栈(operand Stack)(或表达式栈)
- 动态链接(DynamicLinking)(或指向运行时常量池的方法引用)
- 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
- 一些附加信息
局部变量表
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量这些数据类型包括各类基本数据类型、对象引用reference
,以及returnAddress
类型。
局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
关于Slot的理解
JVM会为局部变量表中的每一个slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量。
this对象是0索引位置
int float reference32位
long double 64位
Slot的重复利用
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
例:
public class SlotTest() {
public void localVarl() {
int a = 0;
System.out.println(a);
int b = 0;
}
public void localVal2() {
{
int a = 0;
System.out.println(a);
}
// 此时b就会复用a的槽位
b = 0;
}
}
静态变量和局部变量的对比
变量的分类:
- 按照数据类型分:基本数据类型,引用数据类型
- 按类中声明的位置分: 类变量(static),实例变量,局部变量
- 类变量:linking的prepare阶段,给类变量默认赋值,init阶段给类变量显式赋值(静态变量以及静态代码块)
- 实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
- 局部变量:在使用前必须进行显式赋值,不然编译不通过
在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
操作数栈
每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出的操作数栈。
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的max_stack
中
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
栈顶缓存技术
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
动态链接
每一个栈帧内部都包含一个指向运行时运行常量池中该栈帧所属方法的引用包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)
动态链接的作用就是将这些符号引用转换为调用方法的直接引用。
在Java源文件被编译到字节码文件中时,所有的变量和方法的引用都作为符号引用保存在class文件的常量池中。
运行时常量池的作用
因为在不同的方法,都可能调用常量或者方法,所以只需要存储一份即可,节省了空间
常量池的作用:就是为了提供一些符号和常量,便于指令的识别
方法调用,解析和分配
JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关
链接:
- 静态链接(早期绑定):当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期确定下来,且运行期保持不变时,这种情况下降调用方法的符号引用转换为直接引用的过程称之为静态链接
- 动态链接(晚期绑定):如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。
虚方法和非虚方法
- 静态方法,私有方法,final方法,实例构造器,父类方法都是非虚方法
- 其他方法称为虚方法
子类对象的多态的使用前提
- 类的继承关系
- 方法的重写
普通调用指令
invokestatic
:调用静态方法,解析阶段确定唯一方法版本invokespecial
:调用方法、私有及父类方法,解析阶段确定唯一方法版本invokevirtual
:调用所有虚方法invokeinterface
:调用接口方法
动态调用指令
invokedynamic
:动态解析出需要调用的方法,然后执行
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic
指令则支持由用户确定方法版本。其中invokestatic
指令和invokespecial
指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。
本地方法接口
什么是本地方法
一个Native Method是一个Java调用非Java代码的接口,在定义一个native method时,并不提供实现体,因为实现体是由非Java语言在外面实现的。
标识符native可以与其他java的标识符连用,但是abstract除外
为什么要用Native Method
Java使用起来非常方便,然而有些层次的任务用Java实现起来不容易,或者对于效率要求比较高时,就需要使用Native Method
- 与Java环境交互
- 与操作系统交互
- Sun’s Java
本地方法栈
Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。本地方法栈,也是线程私有的。允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方法是相同的)
当某一个线程调用本地方法时,就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
- 可以直接使用本地处理器中的寄存器
- 直接从本地内存的堆中分配任意数量的内存
堆
堆针对一个JVM进程来说是唯一的,也就是一个进程只有一个JVM,但是进程包含了多个线程,共享同一个堆空间。
一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域(堆内存大小是可以调节的)。Java堆区在JVM启动的时候被创建,其空间大小也就确定了。
堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。
-Xms10m: 最小堆内存
-Xmx10m: 最大堆内存
“几乎”所有的对象实例都在这里分配内存。一从实际使用角度看的(因为还有一些对象是在栈上分配的)
在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会删除。
- 也就是触发GC的时候,才会进行回收
- 如果堆中对象马上被回收,那用户线程就会收到影响,因为有stop the world。
堆内存细分
Java7 及之前堆内存逻辑上分为三部分:新生区 + 养老区 + 永久区
- Young Generation Space 新生区Young/New 又被划分为Eden区和Survivor区
- Tenure generation space 养老区 Old/Tenure
- Permanent Space永久区 Perm
Java8 及之后堆内存逻辑上分为三部分:新生区 + 养老区 + 元空间
- Young Generation Space新生区 Young/New 又被划分为Eden区和Survivor区
- Tenure generation space 养老区 Old/Tenure
- Meta Space 元空间 Meta
设置堆内存大小与OOM
Java堆区用于存储Java对象的实例
-Xms : 用于表示堆区的起始内存,等价于-XX:InitialHeapSize
-Xmx : 用于表示堆区的最大内存,等价于-XX:MaxHeapSize
一旦堆区中的内存大小超过“-Xmx所指定的最大内存时,将会跑出OOM异常”
通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
默认情况下
- 初始内存大小:物理电脑内存大小/64
- 最大内存大小:物理电脑内存大小/4
如何查看堆内存的内存分配情况
jps -> jstat -gc 进程id
-XX:+PrintGCDetails
Minor GC Major GC Full GC
- Minor GC: 新生代的GC
- MajorGC:老年代的GC
- Full GC:整堆收集,收集整个Java堆和方法区的垃圾收集
JVM在进行GC的时候,并非每次都对上面的三个内存区域一起回收,大部分回收都是指新生代。针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(FullGC)
MinorGC
当年轻代空间不足时,就会触发MinorGC,这里的年轻代满是指Eden代满,Survivor满不会引发GC
MinorGC会引发STW,暂停其他用户的线程,等待垃圾回收结束,用户线程才能恢复运行
Major GC
指发生在老年代的GC,对象从老年代消失时,我们说 “Major Gc” 或 “Full GC” 发生了
也就是在老年代空间不足时,会先尝试触发MinorGc。如果之后空间还不足,则触发Major GC
Major GC的速度一般会比MinorGc慢10倍以上,STW的时间更长,如果Major GC后,内存还不足,就报OOM了
Full GC
触发Full GC执行的5种情况
- 调用System.gc()时,系统建议执行FullGC,但是不必然执行
- 老年代空间不足
- 方法区空间不足
- 通过MinorGC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区,survivor space(From Space)向survivor space(To space)复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
说明:Full GC是开发或调优中尽量要避免的。这样STW时间会短一些
内存分配策略
- 优先分配到Eden
- 开发中比较长的字符串或者数组,会直接存在老年代,但是因为新创建的对象都是朝生夕死的,所以这个大对象可能也很快被回收,但是因为老年代触发Major GC的次数比 Minor GC要更少,因此可能回收起来就会比较慢
- 大对象直接分配到老年代
- 尽量避免程序中出现过多的大对象
- 长期存活的对象分配到老年代
- 动态对象年龄判断
- 如果survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold 中要求的年龄。
- 空间分配担保: -Xx:HandlePromotionFailure
- 也就是经过Minor GC后,所有的对象都存活,因为Survivor比较小,所以就需要将Survivor无法容纳的对象,存放到老年代中。
为对象分配内存:TLAB
为什么要有TLAB
TLAB:Thread Local Allocation Buffer,也就是为每个线程单独分配一个缓冲区
堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
什么是TLAB
从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计。
尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
在程序中,开发人员可以通过选项-Xx:UseTLAB
设置是否开启TLAB空间。
默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1,当然我们可以通过选项-Xx:TLABWasteTargetPercent
设置TLAB空间所占用Eden空间的百分比大小。
一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
TLAB的分配过程
对象首先是通过TLAB开辟空间,如果不能放入,那么需要通过Eden来进行分配
小结:堆空间的参数设置
-XX: +PrintFlagsInitial: 查看所有的参数的默认初始值
-XX: +PrintFlagsFinal: 查看所有的参数的最终值
-Xms: 初始堆空间内存(默认为物理内存的1/64)
-Xmx: 最大堆空间内存(默认为物理内存的1/4)
-Xmn: 设置新生代的大小(初始值及最大值)
-XX: NewRatio: 配置新生代与老年代在堆结构的占比
-XX: SurvivorRatio: 设置新生代中Eden和S0/S1空间的比例
-XX: MaxTenuringThreshold: 设置新生代垃圾的最大年龄
-XX: +PrintGCDetails: 输出详细的GC处理日志
-XX:HandlePromotionFalilure: 是否设置空间分配担保
在发生MinorGC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间
- 如果大于,则此次Minor GC是安全的
- 如果小于,则虚拟机会查看-xx:HandlePromotionFailure设置值是否允担保失败。
- 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
- 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
- 如果小于,则改为进行一次FullGC。
- 如果HandlePromotionFailure=false,则改为进行一次FullGC。
方法区
方法区看作是一块独立于Java堆的内存空间
方法区主要存放的是Class,而堆中主要存放的是实例化对象
- 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
- 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
- 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误: java.lang.OutOfMemoryError: PermGen space 或者java.lang.OutOfMemoryError: Metaspace- 加载大量的第三方的jar包
- Tomcat部署的工程过多(30~50个)
- 大量动态的生成反射类
- 关闭JVM就会释放这个区域的内存。
HotSpot中方法区的演进
在JDK7及以前,习惯上把方法区,称为永久代。JDK8开始,使用元空间取代了永久代。
JDK1.8后,元空间存放在堆外内存中
而到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替
根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常
设置方法区大小与OOM
方法区的大小不必是固定的,JVM可以根据应用的需要动态调整。
-
JDK7及以前
- 通过
-xx:Permsize
来设置永久代初始分配空间。默认值是20.75M -XX:MaxPermsize
来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M- 当JVM加载的类信息容量超过了这个值,会报异常
OutOfMemoryError: PermGen space
。
- 通过
-
JDK8以后
元数据区大小可以使用参数-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
指定
默认值依赖于平台。windows下,-XX:MetaspaceSize
是21M,-XX:MaxMetaspaceSize
的值是-1,即没有限制。
与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError: Metaspace
-XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-xx:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,FullGC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活)然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值(动态改变MetaSpaceSize的大小)
如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到FullGC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize
设置为一个相对较高的值。
常量池
常量池中有什么?
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
小结:常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型,字面量等类型。
运行时常量池
运行时常量池是方法区的一部分
常量池是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池
JVM为每一已加载的类型(类或接口)都维护一个常量池。池中的数据像数组项一样,是通过索引访问的
运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换成了真实地址。
运行时常量池,相对于Class文件常量池的另一个重要特征:具备动态性(intern)
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutoOfMemoryError异常。
方法区的演进细节
JDK1.6及以前:有永久代,静态变量存储在永久代上
JDK1.7:有永久代,字符串常量池,静态变量移除,保存在堆上
JDK1.8:无永久代,字段,方法,常量保存在本地内存的元空间中,但字符串常量池、静态变量仍然在堆上
为什么永久代要被元空间替代?
在某些场景下,如果动态加载类过多,容易产生Perm区的oom。比如某个实际Web工 程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。
- 为永久代设置空间大小是很难确定的
- 对永久代进行调优是很困难的。主要是为了降低Full GC
- TODO
StringTable为什么要调整位置
JDK7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而fullgc是老年代的空间不足、永久代不足时才会触发。
这就导致stringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
静态变量存放在哪里
静态引用对应的对象实体始终都存在堆空间中
JDK7及其以后版本的HotSpot虚拟机选择把静态变量与类型在Java语言一端的映射class对象存放在一起,存储于Java堆之中。
方法区的垃圾回收
方法区的垃圾是必要的,但是回收的效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型
方法区常量池中主要存放的两大类常量:字面量和符号引用,包括下面三种常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
判断一个常量是否废弃比较简单,而判断一个类型是否属于“不再被使用的类”的条件就比较苛刻了
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如osGi、JSP的重加载等,否则通常是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及oSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
对象实例化
面试题:
- 对象在JVM中是怎么存储的?
- 对象头信心里面有哪些东西?
- Java对象头有什么?
创建对象的方式
- new
- Class的newInstance() 类反射的方式,只能调用空参的构造器,权限必须是public
- Constructor的newInstance方式,反射的方式,可以调用空参,带参的构造器,权限没有要求
- clone(),不调用任何构造器,当前类需要实现Cloneable接口,实现clone()
- 使用反序列化:从文件或者网络中获取对象的二进制流
- 第三方库Objenesis
创建对象的步骤
- 判断对象对应的类是否加载、链接、初始化
虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化。(即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader + 包名 + 类名为key进行查找对应的 .class文件,如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class对象。 - 为对象分配内存
首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小
如果内存规整:指针碰撞
如果内存不规整:虚拟表需要维护一个列表空闲列表分配 - 处理并发问题
采用CAS配上失败重试保证更新的原子性
每个线程预先分配TLAB - 通过设置 -XX:+UseTLAB参数来设置(区域加锁机制)在Eden区给每个线程分配一块区域 - 属性的默认初始化(零值初始化)
- 设置对象头信息
将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。 - 属性的显示初始化,代码块中的初始化,构造器中的初始化
对象的访问定位
-
句柄访问
句柄访问就是说栈的局部变量表中,记录的对象的引用,然后在堆空间中开辟了一块空间,也就是句柄池
-
直接指针(hotspots)
直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据
直接内存
- 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域
- 直接内存是在Java堆外的,直接向系统申请的内存空间
- 来源于NIO(New IO/Non-Blocking IO)通过在堆中
DirectByteBuffer
操作Native内存 - 访问直接内存的速度会优于Java堆。即读写性能高
非直接缓存区
原来采用BIO的架构,需要从用户态转化为内核态
缓存区的概念
存在的问题
由于直接内存在Java堆外,因此大小不会直接受限于-xmx指定的最大堆大小,但是系统内存是有限的
- 分配回收成本比较高
- 不受JVM内存回收管理
直接内存大小通过MaxDirectMemorySize
设置
如果不指定,默认与堆的最大值-xmx参数一致
执行引擎
执行引擎是Java虚拟机核心的组成部分之一,里面包括 解释器、即时编译器、垃圾回收器
“虚拟机”是一个相对于“物理机”,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。
执行引擎是将字节码指令解释/编译为对应平台上的本地机器指令
执行引擎的工作流程
- 执行引擎在执行的过程中需要执行什么样的字节码指令完全依赖于PC寄存器
- 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址
- 当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。
Java代码编译和执行的过程
- 前面橙色是生成字节码文件的过程,和JVM无关
- 后面蓝色和绿色才是JVM需要考虑的过程
Java代码编译是由Javac编译器来完成的
Java字节码的执行是由JVM执行引擎来完成的
什么是解释器
当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
什么是JIT编译器
JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。
为什么Java是半编译半解释型语言
Java也发展出可以直接生成本地代码的编译器。现在JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。
翻译成本地代码后,就可以做一个缓存操作,存储在方法区中
解释器
JVM设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。(同样也是跨语言的,也就是任何可以编译为字节码文件都能运行在JVM上)
解释器分类
在Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。
JIT简介
JIT是just in time的缩写,也就是即使编译器。使用即使编译器技术,能够加速Java程序的执行速度。
在运行时 JIT 会把翻译过的机器码保存起来,以备下次使用,因此从理论上来说,采用该 JIT 技术可以接近以前纯编译技术。下面我们看看,JIT 的工作过程。
JIT编译过程
Hot Spot编译
首先,如果这段代码本身在将来只会被执行一次,那么从本质上看,编译就是在浪费精力。因为将代码翻译成 java 字节码相对于编译这段代码并执行代码来说,要快很多。
如果一段代码被多次执行,那么编译就非常值得了。Hot Spot VM采用了JIT compile技术,将运行频率很高的字节码直接编译为机器指令执行以提高性能。运行时,部分代码可能由 JIT 翻译为目标机器指令(以 method 为翻译单位,还会保存起来,第二次执行就不用翻译了)直接执行。
当 JVM 执行某一方法或遍历循环的次数越多,就会更加了解代码结构,那么 JVM 在编译代码的时候就做出相应的优化。
Java代码的执行分类
第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行
第二种是编译执行(直接编译成机器码)。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT,Just In Time)将方法编译成机器码后再执行
HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。
为什么不直接用JIT编译器而是同时使用解释器和即时编译器
JRockit虚拟机是砍掉了解释器,也就是只采及时编译器。那是因为呢JRockit只部署在服务器上,一般已经有时间让他进行指令编译的过程了,对于响应来说要求不高,等及时编译器的编译完成后,就会提供更好的性能
当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。 编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。
当Java虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。
对应的案例
在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的1/8。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后,当前1/2发布成功的服务器马上全部宕机,此故障说明了JIT的存在。—阿里团队
三种方式的对比
解释执行:优点是启动效率快、占用内存少,缺点是整体的执行速度较慢、占用程序运行时间和运算资源。
即时编译:相比于解释器,即时编译将部分“热点代码”编译成本地代码,并进行优化,提高了执行效率。相比于提前编译,内存占用小(大部分应用仅20%的功能常用,80%可以不编译)、可优化及编译动态加载的Class文件、可进行激进预测性优化(失败后可退回到低级编译器甚至解释器上执行)、可获得热点代码集中优化和分配更好的资源。(JIT)
提前编译:改善启动时间,快速达到最高性能,缺点在即时编译对比中可看出。(AOT)
热点探测技术
一个被多次调用的方法,一个方法题内部循环次数较多都可以被称为“热点代码”,因此被JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此被称之为栈上替换简称为OSR
一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才能达到这个标准呢?
采用基于计数器的热点探测,HotSpot V将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
- 方法调用计数器用于统计方法的调用次数
- 回边计数器则用于统计循环体执行的循环次数
方法调用器
这个计数器就用于统计方法被调用的次数,它的默认阀值在Client模式下是1500次,在Server模式下是10000次。超过这个阈值,就会触发JIT编译。
当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
热点衰减
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)
回边计数器
它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。显然,建立回边计数器统计的目的就是为了触发OSR编译。
HotSpotVM可以设置程序执行的方法
-Xint: 完全采用解释器模型运行程序
-Xcomp: 完全采用即时编译器模式执行程序,如果即时编译出现问题,解释器会介入执行
-Xmixed: 采用解释器+即时编译器的混合模式共同执行程序。
C1和C2编译器不同的优化策略
C1Client mode
编译器进行简单的优化,C2server mode
是耗时较长的优化,以及激进优化
C1的优化策略
-
方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
-
去虚拟化:对唯一的实现樊进行内联
-
冗余消除:在运行期间把一些不会执行的代码折叠掉
C2的优化策略 -
标量替换:用标量值代替聚合对象的属性值
-
栈上分配:对于未逃逸的对象分配对象在栈而不是堆
-
同步消除:清除同步操作,通常指synchronized
StringTable
JDK9改变了结构
private final char value[]; // 以前
private final byte[] value;
String不再使用char[]来存储了,改成了byte[]加上编码标记,节约了一些空间
注意
字符串常量池是不会存储相同内容的字符串
String 的string Pool是一个固定大小的HashTable,默认值大小为1009。如果放进string Pool的string非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用string.intern时性能会大幅下降。
在JDK6中,StringTable可以设置的最小值为1009。
在JDK7中,StringTable的长度默认值是60013。
在JDK8中,StringTable可以设置的最小值为1009。
- 直接使用双引号声明出来的String对象会直接放在常量池中
- 如果不是双引号声明的String对象,可以使用String提供的intern方法将其放入池中
字符串拼接操作
- 常量与常量的拼接结果在常量池
- 只要其中有一个为变量,结果就是在堆中。变量拼接的原理是StringBuilder
- 如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
注意:
如果左右两边都是变量的话,就是需要new StringBuilder
进行拼接,但是如果是用final来进行修饰,则是从常量池中获取
@Test
public void test() {
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4); // true
}
拼接操作和append性能对比
通过StringBuilder的append()方式来添加字符串的效率,要远远高于String的字符串拼接方式
好处:
- StringBuilder的append的方式,自始自终只创建一个StringBuilder的对象
- 对于字符串拼接的方式,还需要创建很多StringBuilder对象和 调用toString时候创建的String对象
- 我们使用的是StringBuilder的空参构造器,默认的字符串容量是16,然后将原来的字符串拷贝到新的字符串中, 我们也可以默认初始化更大的长度,减少扩容的次数
intern()的使用
intern是一个native方式,调用的是底层C的方法
JDK1.6 中,将这个字符串对象尝试放入串池
- 如果串池中有,则不会放入。返回已有的串池中的对象的地址
- 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址
JDK1.7中,将这个字符串对象尝试放入串池
- 如果串池中有,则不会放入。返回已有的串池中的对象的地址
- 如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址
8种基本类型的包装类和常量池
Java 基本类型的包装类的大部分都实现了常量池技术,即 Byte,Short,Integer,Long,Character,Boolean;前面 4 种包装类默认创建了数值[-128,127] 的相应类型的缓存数据,Character创建了数值在[0,127]范围的缓存数据,Boolean 直接返回True Or False。如果超出对应范围仍然会去创建新的对象。
两种浮点数类型的包装类 Float,Double 并没有实现常量池技术。