宋红康老师视频传送门ˊᵕˋ
深入理解java虚拟机电子书ˊᵕˋ
提取码9q24
jvm虚拟机
jvm:跨语言的平台
jvm字节码:我们平时常说的java字节码,指的是用java语言编成的字节码。准确的说任何能在jvm平台上执行的字节码格式都是一样的,所以应该统称为jvm字节码
不同的编译器可以编译出相同的字节码文件,字节码文件也可以在不同的jvm运行。
Java虚拟机与Java语言并没有必然的联系,它只与特定的二进制文件格式一Class文件格式所关联, Class文件中包含了Java 虚拟机指令集(或者称为字节码、Bytecodes) 和符号表,还有一些其他辅助信息。
每个进程对应一个jvm虚拟机实例,一个jvm实例就有一个运行时数据区(Runtime Data Area)
jvm的架构模型
java编译器输入的指令流基本是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构
基于栈式架构的特点(更少的指令集,更多的指令)
设计和实现更简单,适用于资源受限的系统
避开了寄存器的分配难题,使用零地址指令方式分配
指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈,指令集更小,编译器更容易实现
不需要硬件支持,可移植性更好,更好实现跨平台
基于寄存器架构的特点(更少的指令,更多的指令集)
典型的应用是X86的二进制指令集,比如传统的pc以及Anroid的Davlik虚拟机
指令集架构则完全依赖硬件,可移植性差
性能优秀和执行更高效
花费更少的指令去完成一些操作
在大部分情况下,基于寄存器的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集确是以零地址指令为主
jvm的生命周期
java虚拟机的启动是通过引导类加载器创建引导类加载器创建一个初始类来完成的,这个类是由虚拟机的具体实现指定的
虚拟机的执行
一个运行中的java虚拟机有一个清晰的任务:执行java程序。
程序开始时虚拟机运行,程序结束时他就停止。
执行一个所谓的java程序的时候,真真正正在执行的是一个叫做java虚拟机的进程。
虚拟机的退出
有如下的几种情况:
程序正常执行结束
程序在执行过程中遇到了异常或错误而异常终止
由于操作系统出现错误而导致Java虚拟机进程终止:
某线程调用Runtime类或system类的exit方法,或Runt ime类的halt方法,并且Javll安全管理器也允许这次exit或halt操作。
除此之外,JNI ( Java Native Interface )规范描述了用JNI Invocation API来 加载或卸载Java虛 拟机时,Java虚拟机的退出情况。
Sun Classic VM
- sun公司发布的第一款商用java虚拟机,于jdk1.4被淘汰
- 这款虚拟机只提供解释器。
- 如果使用JIT编译器,就需要进行外挂,但是一旦使用了JIT编译器,JIT就会接管虚拟机的执行系统。解释器就不再工作。解释器和编译器不能配合工作
- 现在hotspot内置了此虚拟机
*理解执行引擎
翻译机:
解释器:逐行解释代码,响应速度快,执行速度慢。
JIT:寻找热点代码,全部编译,响应速度慢,执行速度快。
类加载子系统
类加载器子系统负责从文件系统或者网络中加载Class文件,class文件开头有特定的文件标识(CAFEBABY)
ClassLoader只负责class文件的加载,至于它是否可以运行,则由ExecutionEngine(执行引擎)决定
加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息(当常量池开始运行时就被称为运行时常量池),可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
类的加载过程
1.通过一个类的全限定名获取定义此类的二进制字节流
2.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
3.在内存中生成一个代表这个类的java. lang.Class对象,作为方法区这个类的各种数据的访问入口
加载. class文件的方式
从本地系统中直接加载
通过网络获取,典型场景: Web Applet
从zip压缩包中读取,成为日后jar、war格式的基础
运行时计算生成,使用最多的是:动态代理技术
由其他文件生成,典型场景: JSP应用
从专有数据库中提取. class文件,比较少见
从加密文件中获取,典型的防Class文件被反编译的保护措施
链接(link)
验证(Verify ) :
●目的在于确保class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。准备(Prepare) :
为类变量分配内存并且设置该类变量的默认初始值,即零值。
● 这里不包含用final修饰的static, 因为final在编译的时候就会分配了,准备阶段会显式初始化;这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。解析(Resolve) :
●将常量池内的符号引用转换为直接引用的过程。
事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。
符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java 虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_ Class_ info、 CONSTANT_ Fieldref_ info、CONSTANT_ Methodref_ info等
初始化(initialization)
将代码中的静态代码块和显示初始化合并在一起,构成
初始化阶段就是执行类构造器方法
此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
构造器方法中指令按语句在源文件中出现的顺序执行。
若该类具有父类,jvm会保证子类的
()执行前,父类的 ()已经执行完毕。 虚拟机必须保证一个类的
()方法在多线程下被同步加锁
public static void main(String args[]){
private static int b = 1;
static{
b = 2;
num = 20;
}
private static int num = 10;//在linking中:num默认初始化零值,之后在initialization中初始化为20,然后覆盖为10
}
类加载器的分类
分为两类:引导类加载器和自定义类加载器,直接或间接继承Class Loader的类加载器都是自定义加载器(java虚拟机规范)
引导类:Bootstrap Class Loader(使用c/c++语言编写,只负责加载java的核心类库,如String)
自定义类(使用java语言编写):Extension Class Loader(扩展类加载器)、System Class Loader(系统类加载器)........
启动类加载器(引导类加载器,Bootstrap ClassLoader)
这个类加载使用C/C++语言实现的,嵌套在JVM内部。
它用来加载Java的核心库(JAVA_ HOME/jre/ lib/rt. jar、resources . jar或sun . boot .class. path路径下的内容) ,用于提供JVM自身需要的类。
并不继承自java. lang. ClassLoader,没有父加载器。
加载扩展类和应用程序类加载器,并指定为他们的父类加载器。出于安全考虑、Bootstrap启动类 加载器只加载包名为java、javax、sun等开头的类。
扩展类加载器(Extension ClassLoader )
Java语言编写,由sun . mi sc. Launcher$ExtClassLoader实现。
派生于ClassLoader类。
父类加载器为启动类加载器。
从java. ext. dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。
应用程序类加载器(系统类加载器,AppClassLoader )
java语言编写,由sun . mi sc . Launcher$AppClassLoader实现。
派生于ClassLoader类。
父类加载器为扩展类加载器。
它负责加载环境变量classpath或系统属性java. class.path指定路径下的类库。
该类加载是程序中默认的类加载器,一- 般来说,Java应用的类都是由它来完成加载。
通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器。
用户自定义类加载器
在Java的日常应用程序开发中,类的加载几乎是由,上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。
为什么要自定义类加载器?
隔离加载类。
修改类加载的方式。
扩展加载源。
防止源码泄漏。
用户自定义类加载器实现步骤:
1.开发人员可以通过继承抽象类java. lang. ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求。
2.在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass ()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findClass()方法中。
3.在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
其他要点
1.在JVM中表示两个class对象是否为同一个类存在两个必要条件:
类的完整类名必须一致,包括包名。
加载这个类的ClassLoader (指ClassLoader实例对象)必须相同。.
即使两个类对象来源于同一个Class文件,被同一个虚拟机所加载,但只要它们的ClassLoader不同,那这两个类对象也是不一样的
2.JVM必须知道-一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。
双亲委派机制
Java,虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。
工作原理
1)如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
2)如果父类加载器还存在其父类加.载器,则进一步向上委托,依次递归,请求最终将到达项层的启动类加载器;
3)如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
优势:
避免类的重复加载
保护程序安全,防止核心API被随意篡改
沙箱安全机制
自定义String类,但是在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java langString.class),报错信息说没有main方法,就是因为加载的是rt. jar包中的String类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制。
运行时数据区间内部结构
Java虚拟机定义了若千种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一 一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。
一个运行时数据区间对应一个Runtime Class(可以通过getRuntime()获取到),所以Runtime Class是单例的。(详见javaSE api)
灰色的为单独线程私有的,红色的为多个线程共享的。即:
每个线程:独立包括程序计数器、栈、本地栈。
线程间共享:堆、堆外内存(永久代或元空间、代码缓存)
(寄存器/程序计数器)Regist介绍
作用:
PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。
任何时间一一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址,或者,如果是在执行native方法,则是未指定值(undefined) 。
常见问题
使用PC寄存器存储字节码指令地址有什么用呢?
为什么使用PC寄存器记录当前线程的执行地址呢?
因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。
JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
PC寄存器为什么会被设定为线程私有?
我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
虚拟机栈
Java虚拟机栈是什么?
Java虚拟机栈(Java Virtual Machine Stack) ,早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame) ,对应着一次次的Java方法调用。
是线程私有的
生命周期:生命周期和线程一致。
作用
主管Java程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。
局部变量VS成员变量(或属性)
基本数据变量VS引用类型变量(类、数组、接口)
可能出现的异常
在这个内存区域中,如果线程请求的栈帧深度大于虚拟机所允许的深度,将抛出StarkOverflowError异常;如果java虚拟机栈容量可以动态扩展,当栈扩展是无法申请到足够的内存时会抛出OutOfMemoryError异常。
设置栈内存大小
可以使用参数-Xss选徐来设置线程最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
private int count = 0;
public static void main(String args[]){
count++;
main(rgs[]);
}
//最后报出StarkOverflowError
栈的存储单位
每个线程都有自己的栈,栈中的数据都是以栈桢(stack Frame)的格式存在。
在这个线程上正在执行的每个方法都各自对应一个栈桢(Stack Frame)
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
栈运行原理
JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”/“后进先出”原则。
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame) ,与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
如果当前方法调用 了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
Java方法有 两种返回函数的方式,一种是正常的函数返回,使用return指令; 另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
栈桢的内部结构
帧数据区
就是指栈帧中的方法返回地址、动态链接和附加信息
每个栈桢中存储着:
局部变量表(Local Variables)
操作数栈(operand stack) (或表达 式栈)
动态链接(Dynamic Linking) ( 或指向运行时常量池的方法引用)
方法返回地址(Return Address) ( 或方法正常退出或者异常退出的定义)
一些附加信息
局部变量表(Local Variables)
关于jclasslib操作在视频的p49
其大小在Class反编译文件中以locals查看,在jclasslib中的misc下
局部变量表也被称之为局部变量数组或本地变量表。
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference) ,以及,returnAddress类型。
由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。
局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。.
方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
Slot(槽)
参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。
局部变量表,最基本的存储单元是Slot (变量槽)
局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。
在局部变量表里,32位以内的类型只占用一个slot (包括returnAddress类型),64位的类型(long和double)占用两个slot。
byte、short、char在存储前被转换为int,boolean 也被转换为int,0表示false,非0表示true。
long和double则占据两个Slot。
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其做用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局变量的槽位,从而达到节省资源的目的。
静态变量与局部变量
参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。我们知道类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。
和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。
ps:变量的分类:
按照数据类型分:基本数据类型 & 引用数据类型
按照类中声明的位置分:
成员变量
在使用前,都经历过默认初始化赋值
有static修饰:类变量-->在linking的prepare阶段给其默认赋值,在init阶段显示赋值
无static修饰:实例变量-->在对象创建时会在堆中分配内存,进行默认赋值
局部变量
在使用时必须进行显示赋值,否则编译不通过
如:
public static void mian(String args[]){
int i;
System.out.println(i);//变量i未初始化
}
其他
在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
操作数栈(Operand Stack)
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈就是JVM执行引擎的一个 工作区,当一个方法刚开始执行的候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_ stack的值。
栈中的任何一个元素都是可以任意的Java数据类型。
32bit的类型占用一个栈单位深度
64bit的类型占用两个栈单位深度操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push) 和出栈(pop) 操作来完成一次数据访问。
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
Public static void main(String args[]){
int i = 1;
int j = 2;
int l = i+j;
System.out.println(l);
}
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1 //stack即为操作数栈深度,如果此方法未用静态修饰,则局部变量表长度为5,索引0是this,在此方法中,由于是main方法,所以索引0是args
0: iconst_1//int i-->初始化int类型常量,压入操作数栈中<--栈顶(深度1)
1: istore_1//出栈,存入局部变量表,索引位置为1
2: iconst_2//itn j-->初始化int类型常量,压入操作数栈中<--栈顶(深度1)
3: istore_2//出栈,存入局部变量表,索引位置为2
4: iload_1//获得局部变量表中索引为1的值,压入操作数栈中(深度1)
5: iload_2//获得局部变量表中索引为2的值,压入操作数栈中(深度2)
6: iadd //取出操作数栈中的值,交由执行引擎执行求和操作,其求和的值再次压入操作数栈中
7: istore_3//出栈,将其存入局部变量表中索引为3的位置
8: getstatic #16 // Field java/lang/System.out:Ljava/io/PrintStream;
11: iload_3//获得局部变量表中索引为3的值,压入操作数栈中,执行输出操作
12: invokevirtual #22 // Method java/io/PrintStream.println:(I)V
15: return
栈顶缓存技术(Top-Of-Stack-Cashing)
前面提过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch) 次数和内存读/写次数。
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,Hotspot JVM的设计者们提出了栈顶缓存(ToS,Top-of-Stack Cashing) 技术,将栈顶元索全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
动态链接(Dynamic Linking)
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking) 。比如: invokedynamic指令。
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference) 保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
在栈桢中存在有常量池,当线程开始运行时,栈桢中的常量池会载入到方法区中,也就是其中的运行时常量池,每一个栈桢的常量池中的符号引用都可以引用运行时常量池中的方法,这样一来,就节省了内存空间。
方法的调用
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。
●静态链接:
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。●动态链接:
如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding) 。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
●早期绑定:
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪-一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。●晚期绑定:
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
随着高级语言的横空出世,类似于Java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。
Java中任何一个普通的方法其实都具备虚函数(晚期绑定)的特征,它们相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虛函数的特征时,则可以使用关键字final来标记这个方法。
虚方法与非虚方法
非虚方法:
●如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。
●静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。
●其他方法称为虚方法。
子类对象的多态性的使用前提:①类的继承关系②方法的重写
虚拟机中提供了以下几条方法调用指令:
普通调用指令:
- invokestatic: 调用静态方法,解析阶段确定唯一方法版本
- invokespecial: 调用
方法、私有及父类方法,解析阶段确定唯一方法版本 - invokevirtual: 调用所有虛方法
- invokeinterface:调用接口方法
动态调用指令:
invokedynamic: 动态解析出需要调用的方法,然后执行前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。
关于invokedynamic指令
在class文件中,若使用了Lambda表达式来定义匿名方法,其在字节码中就会以invokedynamic修饰。
JVM字节码指令集一直比较稳定,直到Java7中才增加了一个invokedynamic指令,这是Jaya为了实现「动态类型语言」支持而做的一种改进。
但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式。
Java7中增加的动态语言类型支持的本质是对Java虛拟机规范的修改,而不是对Java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译器。
动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。
说的再直白一点就是,静态类型语言是判断变量自身的类型信息:动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。
静态类型语言:Java: String info = "abcd";
动态类型语言:Js: var info="abcd" var info=1
动态类型语言:python: info=100.1
方法返回地址(Return Address)
作用:存放调用该方法的pc寄存器的值。(只保存正常退出的方法的pc寄存器的值)
一个方法的结束,有两种方式:
正常执行完成
出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
当一个方法开始执行后,只有两种方式可以退出这个方法:
执行引擎遇到任意一 一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;
一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定;
在字节码指令中,返回指令包含ireturn (当返回值是boolean、 byte、char、short和int类型时使用)、lreturn(long类型)、 freturn(float类型)、 dreturn(double类型)以及areturn(引用类型:String、Date),另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。
- 在方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口。
- 方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
class字节码中的异常处理表
Exception table
from to target type
4 16 19 any//在4-16行中的任何异常,由19行来处理
19 21 19 any
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
*一些附加信息
栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。(不一定有)
本地方法栈
Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。
本地方法栈,也是线程私有的。
允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)
如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个stackoverflowError 异常。
如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虛拟机将会抛出一个outofMemoryError 异常。
本地方法是使用C语言实现的。
它的具体做法是Native Method Stack中 登记native方法,在Execution Engine执行时加载本地方法库。
当某个线程调用一一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虛拟机拥有同样的权限。
本地方法可以通过本地方法接0来访问虚拟机内部的运行时数据区。
它甚至可以直接使用本地处理器中的寄存器
直接从本地内存的堆中分配任意数量的内存。
并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。
在Hotspot JVM中,直接将本地方法栈和虛拟机栈合二为一。
本地方法(Native Method)
本地方法接口&本地方法库
简单地讲,一个Native Method就是一个Java调用非Java代码的接口。一个Native Method是这样-一个Java方法:该方法的实现由非Java语言实现,比如C。这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern "C"告知C++编译器去调用 一个c的函数。
"A native method is a Java method whose implementation is provided by non-java code."
在定义一个native method时, 并不提供实现体(有些像定义一个Javainterface),因为其实现体是由非java语言在外面实现的。本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。
堆(heap)
核心概述
一个JVM实例只存在-一个堆内存,堆也是Java内存管理的核心区域。Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。
堆内存的大小是可以调节的。
《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer, TLAB) 。
《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated )
我要说的是:“几乎”所有的对象实例都在这里分配内存。——从实际使用角度看的。
数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
堆,是GC ( Garbage Collection, 垃圾收集器)执行垃圾回收的重点区域。
堆的内存细分
Java 7及之前堆内存逻辑上分为三部分:新生区+养老区+永久区
➢Young Generation Space 新生区 Young/New
又被划分为Eden区和Survivor区
➢Tenure generation space 养老区 Old/ Tenure
➢Permanent Space 永久区 Perm
Java 8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间
➢Young Generation Space 新生区 Young/New
又被划分为Eden区和Survivor区
➢Tenure generation space 养老区 Old/Tenure
➢Meta Space 元空间 Meta
堆内存大小的设置与查看
Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,可以通过选项”-Xmx"和”-Xms"来进行设置。
“-Xms"用于表示堆区的起始内存,等价于-XX: InitialHeapSize
“-Xmx" 则用于表示堆区的最大内存, 等价于-XX :MaxHeapSize一旦堆区中的内存大小超过“-Xmx"所指定的最大内存时,将会抛出OutOfMemoryError异常。
通常会将-Xms 和- -Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
1.设置堆空间大小的参数
-Xms. 用来设置堆空间(年轻代+老年代)的初始内存大小
-X是jvm的运行参数
ms是memory start
-Xmx用来设置堆空间(年轻代+老年代)的最大内存大小
2.默认堆空间的大小
初始内存大小:物理电脑内存大小/ 64
最大内存大小:物理电脑内存大小/ 4
3.手动设置: -Xms600m -Xmx600m
开发中建议将初始堆内存和最大的堆内存设置成相同的值。
4.查看设置的参数:
方式一(cmd): jps / jstat -gc进程id
方式二(编译器中设置): -XX: +PrintGCDetails
*OOM举例
public class OOMTest{
public static void main(String args[]){
ArrayList<Picture> list = enw ArrayList<>();
while(true){
list.add(new Picture(new Random().nextInt(1026*1024)));
}
}
}
//最后报出OOM
新生代与老年代
存储在JVM中的Java对象可以被划分为两类:
- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
- 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。
Java堆区进一步细分的话, 可以划分为年轻代(YoungGen) 和老年代(0ldGen)
其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)
配置新生代与老年代在堆结构的占比。
默认-XX: NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
在同样内存下,占比少的部分GC也就更频繁。
在HotSpot中,Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1
当然开发人员可以通过选项“-XX: SurvivorRatio"调整这个空间比例。比如-XX: SurvivorRatio=8.
几乎所有的Java对象都是在Eden区被new出来的。
绝大部分的Java对象的销毁都在新生代进行了。
IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”的。
可以使用选项”-Xmn"设置新生代最大内存大小
这个参数一般使用默认值就可以了,一般开发中设置好了堆的大小和比例就等于是确定了新生代的大小。
ps:JVM规范中提到,新生代中各内存比例是8:1:1,但是实际运行中却是6:1:1.原因是其有一个自适应的内存分配策略(此策略是默认使用的)
可以手动设置"-XX: SurvivorRatio = 8"来实现8:1:1
-XX: -UseAdaptivesizePolicy :关闭自适应的内存分配策略( 暂时用不到)
对象分配的一般过程
为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
- new的对象先放伊甸园区。此区有大小限制。
- 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区,然后将伊甸园中的剩余对象移动到幸存者0区。
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
- 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
- 啥时候能去养老区呢?可以设置次数。默认是15次。
- 可以设置参数: -XX :MaxTenuringThreshold=
进行设置。
针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to.
关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。
Minor GC、Major GC、Full GC
JVM在进行GC时,并非每次都对,上面三个内存区域(指Eden s0 s1 )一起回收的,大部分时候回收的都是指新生代。
针对HotSpotVM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)
部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
新生代收集(Minor GC / Young GC) :只是新生代的垃圾收集
老年代收集(MajorGC/0ldGC):只是老年代的垃圾收集。
目前,只有CMS GC会有单独收集老年代的行为。
注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。
混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
目前,只有G1 GC会有这种行为
整堆收集(Fu1l GC): 收集整个java堆和方法区的垃圾收集。
年轻代GC(Minor GC):
触发机制
当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden代满,Survivor满不会引发GC。(每次 Minor GC会清理年轻代的内存。)
因为Java 对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
Minor GC会引发STW, 暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。
老年代GC (Major GC/Fu1l GC)
触发机制:
指发生在老年代的GC,对象从老年代消失时,我们说“Major GC"或“Fu11 GC”发生了。
出现了Major GC,经常会伴随至少一次的Minor GC (但非绝对的,在Paral1elScavenge收集器的收集策略里就有直接进行MajorGC的策略选择过程)。
也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC。
Major GC的速度- °般会比Minor GC慢10倍以上,STW的时间更长。
如果Major GC后,内存还不足,就报00M了。
Full GC
触发机制
(后面细讲)
触发Fu1l GC执行的情况有如下五种:
(1)调用System.gc()时,系统建议执行Fu11 GC,但是不必然执行。
(2)老年代空间不足。
(3)方法区空间不足。
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存。
(5)由Eden区、survivor space0 (From Space)区向survivor space1 (To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
说明: full gc是开发或调优中尽量要避免的。这样暂时时间会短一-些。
为什么需要把Java堆分代?
不分代就不能正常工作了吗?
其实不分代完全可以,分代的唯一理由就是优化Gc性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
内存分配策略 or 对象提升(Promotion)规则
一般情况
如果对象在Eden出生并经过第一次MinorGC 后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对 象年龄设为1。对象在Survivor区中每熬过一 次MinorGC ,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、 每个GC都有所不同)时,就会被晋升到老年代中。
对象晋升老年代的年龄阈值,可以通过选项-XX :MaxTenuringThreshold来设置。
针对不同年龄段的对象分配原则如下所示:
优先分配到Eden
大对象直接分配到老年代
尽量避免程序中出现过多的大对象
长期存活的对象分配到老年代
动态对 象年龄判断
如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
空间分配担保
-XX: HandlePromotionFai lure
线程私有的缓冲区
why?
堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
what?
从内存模型而不是垃圾收集的角度。对Eden区域继续进行划分,JVM为每个线程分配了-一个私有缓存区域,它包含在Eden空间内。
多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计。
尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
在程序中,开发人员可以通过选项“-XX :UseTLAB”设置是否开启TLAB空间。
默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1号,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百分比大小。
一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。.
测试堆空间常用的参数
测试堆空间常用的jvm参数:
-XX: +PrintFlagsInitial :查看所有的参数的默认初始值
-XX: +PrintFlagsFinal : 查看所有的参数的最终值(可能会存在修改,不再是初始值)
具体查看某个参数的指令: jps: 查看当前运行中的进程
cmd中查看: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:是否设置空间分配担保
空间分配担保
在发生Minor Gc之 前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有
对象的总空间。
如果大于,则此次Minor GC是安全的如果小于,则虚拟机会查看-XX : HandlePromot ionFai lure设置值是否允许担保失败。
如果HandlePromotionFailure=true, 那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
v如果大于,则尝试进行一次Minor GC, 但这次Minor GC依然是有风险的;
V如果小于,则改为进行一次Full GC。
V如果HandlePromot ionFailure=false,则改为进行一-次Full GC。
在JDK6 Update24之 后(JDK7) ,HandlePromotionFailure参数不会再影响到虛拟机的空间分配担保策略,观察OpenJDK中的源码变化,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK7之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。
逃逸分析
如何快速的判断是否发生了逃逸分析, 就看new的对象实体是否有可能在方法外被调用。
逃逸分析设置(默认启用): -XX: -DoEscapeAnalysis
随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis) 后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
此外,前面提到的基于OpenJDK深度定制的TaoBaoVM,其中创新的GCIH (GC invisible heap) 技术实现off - heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。
如何将堆上的对象分配到栈,需要使用逃逸分析手段。
这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
通过逃逸分析,Java Hotspot编 译器能够分析出一一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:
当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
当一个对象在方法中被定 义后,它被外部方法所引用, 则认为发生逃逸。例如作为调用参数传递到其他地方中。
没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除。
//此对象的作用空间只在main方法内部,所以没有发生逃逸
public static void main(){
V v = new v();
v = null;
}
//StringBuffer对象被return出去,可能会被其他方法调用,所以此对象发生了逃逸
public StringBuffer strBfer(){
StringBuffer sb = new StringBuffer();
sb.append(s1);
return sb
}
//经过优化后,返回了一个String的一个新对象,StringBuffer没有被return出去,所以没有发生逃逸
public StringBuffer strBfer(){
StringBuffer sb = new StringBuffer();
sb.append(s1);
return sb.toString;
}
代码优化
使用逃逸分析,编译器可以对代码做如下优化:
1.栈上分配。将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
2.同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
3.分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
栈上分配
JIT编译器在编译期间根据逃逸分析的结果,发现如果一一个对象并没有逃逸出方法的话,就可能被优化成栈.上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
常见的栈上分配的场景
在逃逸分析中,已经说明了。分别是给成员变量赋值、方法返回值、实例引用传递。
//测试代码
//-Xms=1G -Xmx=1G -XX:+PrintGCDetails
//-Xms=1G -Xmx=1G -XX: -DoEscapeAnalysis -Xx:+PrintGCDetails
public void main(Strign[] args){
long start = System.currenTimeMillis();
for(int i = 0;i<1000000;i++){
alloc();
}
long end = System.currenTimeMillis();
System.out.println("话费的时间为:"+(end-start));
try{
Thread.sleep(1000000);
}catch(Exception e){
e.pringtSackTrace;
}
}
private static void alloc(){
User user = new User();
}
static class User{
}
同步省略(消除)
线程同步的代价是相当高的,同步的后果是降低并发性和性能。
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
//如下代码
public void sy(){
Object hollis = new Object();
synchronized(hollis){
System.out.println(hollis);
}
}
//由于对象并没有发生逃逸,逃逸分析会自动同步省略,需要主意的一点是,同步省略是在加载到内存之后发生的,在编译期间还是可以看见同步(monitorenter&monitorexitr)的字节码
分离对象或标量替换
标量替换参数设置:
-XX:+EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上。
标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
相对的,那些还可以分解的数据叫做聚合量(Aggregate) ,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现-一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。,
//标量抽象概念
class user{//聚合量
String name;//标量
String age;
Accont Acc;
}
class Accont{//聚合量
double balance;//标量
}
//标量替换
public static void main(String[] args){
alloc();
}
public static alloc(){//point对象作用域只在alloc方法内,未发生逃逸
point p = new point(1,2);
System.out.println(p.a+"--"p.b);
}
class point{
private int a;
private int b;
}
//标量替换优化后-------------------------------------
//可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了。那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了, 那么就不再需要分配堆内存了 。
public static alloc(){
int a = 1;
int b = 2;
System.out.println(p.a+"--"p.b);
}
方法区
堆、栈与方法区的交互关系
Person person = new Person();
方法区 java栈 java堆
方法区在哪里?
《Java虛拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但.一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于HotspotJVM而言,方法区还有一个别名叫做Non-Heap (非堆),目的就是要和堆分开。
所以,方法区看作是一块独立于Java堆的内存空间。
方法区基本理解
方法区 (Method Area) 与Java堆一样,是各个线程共享的内存区域。
方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区--样都可以是不连续的。
方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。方法区的大小决定 了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误: java. lang . OutOfMemoryError :PermGen space或者java. lang.OutOfMemoryError: Metaspace
加载大量的第三方的jar包; Tomcat部署的工程过多(30-50个) ;大量动态的生成反射类
关闭JVM就会释放这个区域的内存。
Hostspot中方法区的演进
到了JDK 8, 终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace) 来代替。
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。
永久代、元空间二者并不只是名字变了,内部结构也调整了。
根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常。
方法区大小设置
元数据区大小可以使用参数-XX:MetaspaceSize和-XX: MaxMetaspaceSize指定,替代上述原有的两个参数。
默认值依赖于平台。windows下,-XX:MetaspaceSize是21M,-XX :MaxMetaspaceSize的值是-1,即没有限制。
与永久代不同, 如果不指定大小,默认情况下,虛拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError: Metaspace
-XX:MetaspaceSize: 设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-XX :MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,FullGC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。
如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX :MetaspaceSize设置为一个相对较高的值。
如何解决OOM
1、要解决OOM异常或heap space的异常,一 般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer) 对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
2、如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GCRoots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置。
3、如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与-Xms) ,与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
ps:内存泄漏->指在堆中存有被过多被引用的无效内存,进而导致内存溢出
方法区的内部结构
类型信息
对每个加载的类型( 类clas3、接口interface.枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
这个类型的完整有效名称(全名=包名.类名)
这个类型直接父类的完整有效名(对于interface或是java. lang .object, 都没有父类)
这个类型的修饰符(public, abstract, final的某个子集)
这个类型直接接口的一个有序列表.
域(Field)信息/属性/成员变量
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
域的相关信息包括:
域名称、域类型、域修饰符(public, private,protected, static, final, volatile, transient的某个子集)
方法信息
JVM必须保存所有方法的一下信息,同域信息一样包括声明顺序:
方法名称.
方法的返回类型(或void)
方法参数的数量和类型(按顺序)
方法的修饰符(public, private, protected, static, final,synchronized, native, abstract的- 一个子集)
方法的字节码(bytecodes)、操作数栈、局部变量表及大小( abstract和native方法除外)
异常表( abstract和native方法除外)
每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、
被捕获的异常类的常量池索引
常量池&运行时常量池
常量池
常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table) ,包括各种字面量和对类型、域和方法的符号引用。
常量池的作用
一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍。
public class SimpleClass{
public void test(){
System.out.println("helloword!");
}
}
虽然只有194字节,但是里面却使用了String、System、 PrintStream及Object等结构。这里代码量其实已经很小了。如果代码多,引用到的结构会更多! 这里就需要常量池了!
常量池里有什么?
数量值、字符串值、类引用、字段引用、方法引用
运行时常量池
运行时常量池( Runtime Constant Pool) 是方法区的一部分。
常量池表(Constant Pool Table) 是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
JVM为每个已加载的类型(类或接口)都维护-一个常量池。池中的数据项像数组项-一样,是通过索引访问的。
运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性。
String. intern()运行时常量池类似于传统编程语言中的符号表(symbol table) ,但是它所包含的数据却比符号表要更加丰富一些。
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。
方法区的演进
为什么要替换永久代?
1.为永久代设置空间大小是很难确定的。
在某些场景下,如果动态加载类过多,容易产生Perm区的0OM。比如某个实际web.工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。
而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
2.对永久代进行调优是很困难的。
StringTable为什么要调整?
jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。这就导致String Table回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
方法区的垃圾回收
有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的zGC收集器就不支持类卸载)
一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug;就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
1、类和接口的全限定名
2、字段的名称和描述符
3、方法的名称和描述符HotSpot,虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
回收废弃常量与回收Java堆中的对象非常类似。
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、 JSP的重加载等,否则通常是很难达成的。
该类对应的java.1ang. Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机被允许对满足.上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose: class以及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看 类加载和卸载信息
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及oSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
对象的实例化和内存布局
对象的实例化
对象实例化的过程
⑧加载类元信息
②为对象分配内存
⑧处理并发问题-
④属性的默认初始化(零值初始化)设置对象头的信息
⑥属性的显式初始化、代码块中初始化、构造器中初始化
创建对象的步骤
1.判断对象对应的类是否加载、链接、初始化
虚拟机遇到一条new指令 ,首先去检查这个指令的参数能香在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。( 即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的.Class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象
2.为对象分配内存
首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。
如果内存规整
如果内存是规整的,那么虚拟机将采用的是指针碰撞法( Burmp The Pointer )来为对象分配内存。
意思是所有用过的内存在一边,空闲的内存在另外一边,中间放着-一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带有compact (整理)过程的收集器时,使用指针碰撞。
如果内存不规整
虚拟机需要维护一个列表
如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存。
意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为“空闲列表( Free List ) "。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有
压缩整理功能决定。
3.处理并发安全问题
采用CAS失败重试、区域加锁保证更新的原子性
每个线程预先分配一块TLAB一一 通过-XX:+/-UseTLAB参数来设定
4.初始化分配到的空间
默认初始化
所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用
5.设置对象的对象头
将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、 锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。
类(方法区)->对象(java堆)->方法(java栈)
6.执行init方法进行初始化
在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
因此一般来说(由字节码中是否跟随有invokespecel指令所决定), new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。
对象的内存布局
public class Customers{
publc void Customer(){
static{
name="匿名客户";
}
int id=1001;
Acct acct = new acct();
}
}
-------------------------------------------------------------
@Setter
@Getter
public class Acct{
private int userCode;
private int money;
public acct(){
}
}
------------------------------------------------------------------
public class test{
public void main(String[] args){
Customers customers = new Customers();
}
}
对象的访问定位
JVM是如何通过栈帧中的对象引用访问到其内部的对象实例的呢?
定位,通过栈上reference访问
对象的访问方式有两种
1.句柄访问
优点:
reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可, reference本身不需要被修改。
缺点:
占用较多内存,效率较低
2.直接指针
优点
节省空间,速度快,效率高
缺点
数据在移动时reference地址也要修改
直接内存(Direct Memory)
不是虚拟机运行时数据区的一部分, 也不是《Java虚拟机规范》中定义的内存区域。
直接内存是在Java堆外的、直接向系统申请的内存区间。
来源于NIO,通过存在堆中的DirectByteBuffer操作Naltive内存
通常,访问直接内存的速度会优于Java堆。 即读写性能高。
因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。
Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
IO与NIO
IO:byte[ ] / char[ ] Stream
NIO:buffer Channel
public class test{
public void main(String[] args){
//直接分配本地内存空间
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
System.out.println("直接内存分配完毕,请求指示! ");
Scanner scanner = new Scanner(System. in);
scanner.next();
system.out.println("直接内存开始释放! ");
byteBuffer = nu1l;
System.gc();
scanner.next();
}
}
读写文件
需要与磁盘交互需要由用户态切换到内核态,在内核态时,需要内存如图的操作。使用IO,见图。这里需要两份内存存储重复数据,效率低。
使用NIO时,如右图。操作系统划出的直接缓存区可以被java代码直接访问,只有一份。NIO适合对大文件的读写操作。
也可能导致OutOfMemoryError异常
由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。
缺点
分配回收成本较高
不受JVM内存回收管理直接内存大小可以通过MaxDirectMemorySize设置,如果不指定,默认与堆的最大值-Xmx参数值一致
执行引擎(Execution Engine)
执行引擎是Java虚拟机核心的组成部分之一。
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之,上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。
那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。
执行 引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器。
每当执行完一项指令操作后,PC寄存器就会更新下一.条需要被执行的指令地址。
当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。
为什么说java是半解释型半半编译型语言?
其实就是因为java的执行引擎既有解释器又有编译器。
解释器
JVM设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。
解释器真正意义上所承担的角色就是一一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。
字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
而模板解释器将每--条字节码和一个模板函数相关联,模板函数中直接产生这
条字节码执行时的机器码,从而很大程度上提高了解释器的性能。在HotSpot VM中,解释器主要由Interpreter模块 和Code模块构成。
Interpreter模块:实现了解释器的核心功能
Code模块:用于管理HotSpot VM在运行时生成的本地机器指令
由于解释器在设计和实现上非常简单,因此除了Java语言之外,还有许多高级语言同样也是基于解释器执行的,比如Python、 Perl、 Ruby等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些C/C++程序员所调侃。
为了解决这个问题,JVM平台支持--种叫作即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。
不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。
即时编译器(JIT)
第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行
第二种是编译执行(直接编译成机器码)。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT, Just In Time) 将方法编译成机器码后再执行
HotSpotVM是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在Java虚拟机运行时候解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。
在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和C/C++程序一较高下的地步,
有些开发人员会感觉到诧异,既然HotSpot VM中已经内置JIT编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?比如JRockit VM内部就不包含解释器字节码全部都依靠即时编译器编译后执行。
首先明确:
当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。
所以:
尽管JRockit VM中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一-个平衡点。在此模式下,当Java虛拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。
同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”。
当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。
注意解释执行与编译执行在线上环境微妙的辩证关系。机器在热机状态可以承受的负载要大于冷机状态。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。
在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的1/8.曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后,当前1/2发布成功的服务器马上全部宕机,此故障说明了JIT的存在。-阿里团队
热点代码以及探测方式
一个被多次调用的方法,或者是一一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为OSR (On Stack Replacement)编译。
一个方法究竟要被调用多少次,或者-一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。
目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。采用基于计数器的热点探测,HotSpot VM将会为每一个方 法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter) 和回边计数器(Back Edge Counter)。
方法调用计数器用于统计方法的调用次数
回边计数器则用于统计循环体执行的循环次数
1.方法调用计数器
这个计数器就用于统计方法被调用的次数,它的默认阈值在Client模式下是1500次,在Server 模式下是10000 次。超过这个阈值,就会触发JIT编译。
这个阀值可以通过虚拟机参数- XX: CompileThreshold来人为设定。
当一个方法被调用时,会先检查该方法是否存在被JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1, 然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度, 如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay) ,而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)
进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
另外,可以使用-XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。
2.回边计数器
它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边” (Back Edge)。显然,建立回边计数器统计的目的就是为了触发OSR编译。
Hostspot VM可以设置程序的执行方式
缺省情况下HotSpot VM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:
-Xint: 完全采用解释器模式执行程序;(cmd中:java -Xint -verson 或者VM options中设置)
-Xcomp: 完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。
-Xmixed: 采用解释器+即时编译器的混合模式共同执行程序。
在HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler和ServerCompiler,但大多数情况下我们简称为C1编译器和C2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器,在64位系统中默认使用c2编译器,如下所示:
client:指定Java虚拟机运行在Client模式下,并使用C1编译器;
C1编译器会对字节码进行简单和可靠的优化,耗时短。以达到更快的编译速度。
server:指定Java,虚拟机运行在Server模式下,并使用C2编译器。
C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。
C1和C2编译器不同的优化策略:
在不同的编译器上有不同的优化策略,C1编译器上主要有方法内联,去虚拟化、冗余消除。
方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
去虚拟化:对唯一的实现类进行内联
冗余消除:在运行期间把一些不会 执行的代码折叠掉
C2的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在C2.上有如下几种优化:
标量替换:用标量值代替聚合对象的属性值
栈上分配:对于未逃逸的对象分配对象在栈而不是堆
同步消除:清除同步操作,通常指synchronized
分层编译(Tiered Compilation) 策略:程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控信息进行激进优化。不过在Java7版本之后,一旦开发人员在程序中显式指定命令“-server”时,默认将会开启分层编译策略,由C1编译器和C2编译器相互协作共同来执行编译任务。
自JDK10起,HotSpot又 加入-一个全新的即时编译器: Graal编译 器编译效果短短几年时间就追评了C2编译器。未来可期。目前,带着“实验状态"标签,需要使用开关参数-XX: +UnlockExper imentalVMOptions -XX: +UseJVMCICompiler去激活,才可以使用。
String的不可变性
String:字符串,使用一对""引起来表示。
String s1 = "hello";//字面量的定义方式
String s2 = new String ("hello") ;
String声 明为final的,不可被继承String实现了Serializable接口:表示字符串是支持序列化的。
实现了Comparable接口:表示String可以比较大小
String在jdk8及以前内部定义了final char[ ] value用于存储字符串数据。jdk9时改为byte[ ]
String:代表不可变的字符序列。简称:不可变性。
当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值。
当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
当调用String的replace ()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。通过字面量的方式(区别于new)给-一个字符串赋值,此时的字符串值声明在字符串常量池中。
字符串常量池中是不会存储相同内容的字符串的。
String的String Pool1是一 个固定大小的Hashtable,默认值大小长度是1009。如果放进String Poo1的String非常多, 就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用string. intern时性能会大幅下降。
使用-XX:StringTableSi ze可设置StringTable的长度
在jdk6中stringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTableSize 设置没有要求
在jdk7中,StringTable的长度默认值是60013
在jdk8中,StringTable1009是可设置的最小值。
String的内存分配
在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了-种常量池的概念。
常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类 型的常量池比较特殊。它的主要使用方法有两种。
直接使用双引号声明出来的String对象会直接存储在常量池中。
比如: String info = "atguigu. com";
如果不是用双引号声明的String对象,可以使用String提供的intern()方法。这个后面重点谈
在jdk7之前,字符串常量池是在永久代中的,jdk8之后都将其存放在堆空间
StringTable为什么要调整?
permSize默认比较小
永久代垃圾回收频率低
字符串拼接操作
常量与常量的拼接结果在常量池,原理是编译期优化
常量池中不会存在相同内容的常量。
只要其中有一一个是变量,结果就在堆中(非字符串常量池)。变量拼接的原理是str ingBui lder
如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。
如下的s1 + s2的执行细节: (变量s是临时定义的)
①StringBuilder s = new StringBuilder();
②s.append("a");
③s.append("b") ;
④s.tostring() --> 约等于new string("ab");
补充:在jdk5. 0之后使用的是stringBuilder,在jdk5. 0之前使用的是StringBuffer
字符串拼接操作不一定使用的是stringBuilder,如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非stringBuilder的方式。
改进的空间:在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用StringBuilder的构造器:
StringBuilder s = new stringBuilder(highLevel);//new char[highLevel]
intern()的使用
如果不是用双引号声明的String对象,可以使用str ing提供的intern方法: intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
比如: String myInfo = new String("I love atguigu") .intern() ;
也就是说,如果在任意字符串上调用String. intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true:
("a" + "b" + "c") . intern() == "abc"
通俗点讲,Interned string就 是确保字符串在内存里只有一'份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池 (String Intern Pool)。
new String( "ab")会创建几个对象?
看字节码,就知道是两个。
一个对象是: new关键字在堆空间创建的
另一个对象是:字符串常量池中的对象。
字节码指令:ldc
new String("a")+new Sting("b")会创建几个对象?
0 new #2 <java/lang/StringBuilder>//第一个对象,new StringBulder()
3 dup
4 invokespecial #3 <java/lang/StringBuilder.<init>>
7 new #4 <java/lang/String>//第二个对象,new String("a")
10 dup
11 ldc #5 <a>//第三个对象 字符串常量池中新建的"a"
13 invokespecial #6 <java/lang/String.<init>>
16 invokevirtual #7 <java/lang/StringBuilder.append>
19 new #4 <java/lang/String>//第四个对象,new String("b")
22 dup
23 ldc #8 <b>//第五个对象,字符串常量池中的"b"
25 invokespecial #6 <java/lang/String.<init>>
28 invokevirtual #7 <java/lang/StringBuilder.append>
31 invokevirtual #9 <java/lang/StringBuilder.toString>//深入剖析toString,返回一个new String,第六个对象.注意,字节码中没有使用ldc,所以字符串常量池中没有"ab"
34 astore_1
35 return
关于intern()的难题
public void mian(String args[]){
String s1 = new String("1");
s.intern();
String s2 = "1"
System.out.println(s1==s2);//false
String s3 = new String("1")+new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3==s4);//jdk6中为false,jdk7以后为true
}
详见视频P127&P128
总结String的intern()的使用:
jdk1.6中,将这个字符串对象尝试放入串池。
如果串池中有,则并不会放入。返回已有的串池中的对象的地址
如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址Jdk1.7起,将这个字符串对象尝试放入串池。
如果串池中有,则并不会放入。返回已有的串池中的对象的地址
如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址
PS:new String()在字符串常量池中存放的是实体,不是new String()地址的引用,所以new String对象的地址与其在常量池中存放的地址不相等;intern在字符串常量池中存放的是其堆地址的引用。