☆* o(≧▽≦)o *☆嗨~我是小奥
个人博客:小奥的博客
CSDN:个人CSDN
Github:传送门
面经分享(牛客主页):传送门
文章作者技术和水平有限,如果文中出现错误,希望大家多多指正!
如果觉得内容还不错,欢迎点赞收藏关注哟! ❤️
JDK、JRE和JVM的区别
(1)JDK是Java开发工具包,是功能齐全的SDK,它包含JRE,提供了编译、运行Java程序所需要的各种工具(编译工具javac、打包工具jar)和资源,是整个Java的核心。
(2)JRE是Java运行时环境,它是运行已经编译的Java程序所需要的所有内容的集合,包含JVM以及Java核心类库。
(3)JVM是Java虚拟机,是整个Java实现跨平台的核心部分,负责解释执行字节码文件,是可运行Java字节码文件的虚拟计算机。
JDK包括JRE,JRE包含JVM。
笔记
程序计数器是一块内存比较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。
程序计数器是线程私有的,是为了使线程切换后能够恢复到正确的执行位置,每个线程都需要由一个独立的程序计数器。各个线程的程序计数器互不影响,独立存储。
程序计数器的作用是:
注意:
① 如果线程正在执行的是一个Java方法,那么这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地方法,那么这个计数器的值则应为空(Undefind)。
② 程序计数器是唯一一个不会发生OOM和栈溢出的内存区域。
③ 它的生命周期随着线程的创建而创建,随着线程的结束而停止。
虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都 会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
注意:虚拟机栈也是线程私有的,生命周期与线程相同。
另外,可以使用参数-Xss
来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的。
JVM对于栈的操作只有两个:每个方法执行时的入栈、执行结束后的出栈。
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
执行引擎运行的所有字节码指令只对当前栈帧进行操作。如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
栈帧时一个内存区块,是一个数据集。维系着方法执行过程中的各种数据信息。
每个栈帧中存储着:
每个线程都有自己的栈,并且每个栈中都有很多栈帧,栈帧的大小主要是由局部变量表和操作数栈决定的。
maximum local variables
数据项中,在方法运行期间是不会改变局部变量表的大小的。注意:reference类型并不等同于对象本身,可能是指向对象地址的起始地址的引用指针,也可能是指向一个代表对象的聚标或者其他与此对象相关的位置。
slot
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
每个独立的栈帧中除了包含局部变量表之外,还包含一个后进先出(LIFO)的操作数栈,也可以称为表达式栈。
操作数栈,在方法执行过程中,根据字节码指令,往操作数栈中写入数据或者提取数据,即入栈(psuh)和出栈(pop)。
如果被调用的方法带有返回值的话,其返回值会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中的变量临时的存储空间。
操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值。
栈中的任意一个元素都可以是任意的Java数据类型:
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
代码举例:
public static void main(String[] args) {
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
栈顶缓存技术
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
每个栈帧内部包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了能够实现动态链接。
动态链接的作用就是为了将常量池中的符号引用转换为调用方法的直接引用。
**静态链接:**被调用的目标方法在编译期可知,且运行期保持不变。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
**动态链接:**被调用的目标方法在编译期无法被确定下来,只能够在程序运行期间将调用方法的符号引用转化为直接引用的过程,称之为动态链接。
方法的返回地址用来存放调用该方法的PC寄存器的值。
一个方法的结束有两种形式:正常执行完成或者出现未处理的异常,非正常退出。
当一个方法开始执行后,只有两种方式可以退出这个方法:
本质上,方法的退出就是栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的调用者产生任何的返回值。
另外,一个方法在正常调用完成之后,究竟要使用哪个返回指令,还需要根据方法返回值的实际数据类型而定。在字节码指令中,返回指令包含:
ireturn
:当返回值是boolean、byte、char、short、int类型时使用lreturn
:long类型使用freturn
:float类型使用dreturn
:double类型使用areturn
return
:声明为void的方法、实例初始化方法、类和接口的初始化方法栈帧中还允许携带Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下降调用方法的符号引用转换为直接引用的过程称之为静态链接。
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。
对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。
静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。其他方法称为虚方法。
普通调用指令:
invokestatic
:调用静态方法,解析阶段确定唯一方法版本(非虚方法)invokespecial
:调用
方法、私有及父类方法,解析阶段确定唯一版本方法(非虚方法)invokevirtual
:调用所有虚方法invokeinterface
:调用接口方法动态调用指令:
incokedynamic
:动态解析需要调用的方法,然后执行前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。
JVM字节码指令集一直比较稳定,一直到Java7中才增加了一个invokedynamic指令,这是Java为了实现「动态类型语言」支持而做的一种改进。
但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式。
Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译器。
Java语言中方法重写的本质:
java.lang.IllegalAccessError
异常。java.lang.AbstractMethodError
异常。在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表 (virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。
每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
虚方法表会在类加载的链接阶段被创建并初始化,类的变量初始值准备完成之后,JVM会把该类的方法也初始化完毕。
一个Native Method是一个Java调用非Java代码的接囗。本地接口的作用是融合不同的编程语言为Java所用。
Java虚拟机栈用于执行Java方法(也就是字节码)服务,而本地方法栈用于**管理本地方法(被native修饰的方法)**的调用。
本地方法栈也是线程私有的。
允许被实现成固定或者是可动态扩展的内存大小:
本地方法的调用具体是在本地方法栈中登记native方法,在执行引擎执行时加载本地方法库。
当某个线程调用一个本地方法时,它就进入了一个全新并且不再受虚拟机限制的世界,它和虚拟机拥有同样的权限:
并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。
在HotSpot中,直接将本地方法栈和虚拟机栈合二为一。
对于Java应用程序来说,Java堆是虚拟机所管理内存区域中最大的一块。
Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在堆区分配内存。
Java堆是垃圾收集器管理的内存区域。
如果从内存分配的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。
根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但它在逻辑上应该被视为连续的。
Java7以及之前堆内存逻辑分为三部分:新生代 + 老年代 + 永久代
Java8以及之后堆内存逻辑分为三部分:新生代 + 老年代 + 元空间
堆空间的内部结构(JDK7):
堆空间的内部结构(JDK8):
Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了。
-Xms
:表示堆区的起始内存,等价于-XX:InitialHeapSize
-Xmx
:表示堆区的最大内存,等价于-XX:MAXHeapSize
一旦堆区中的内存大小超过“-Xmx"所指定的最大内存时,将会抛出OutOfMemoryError异常。
通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在ava垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
默认情况下
初始内存大小:物理电脑内存大小 / 64
最大内存大小:物理电脑内存大小 / 4
存储在JVM中的Java对象可以被划分为两类:
堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(OldGen)。其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)。
下面的参数在开发中一般不会调整:
配置新生代与老年代在堆结构中的占比:
-XX:NewRatio=2
,表示新生代占1,老年代占2,新生代占整个堆的1/3-XX:NewRation=4
,表示新生代占1,老年代占4,新生代占整个堆的1/5① new的对象先放在伊甸园区。此区大小有限制。
② 当伊甸园区的空间被填满后,程序又需要创建对象,JVM的垃圾回收器将伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁,再加载新的对象放到伊甸园区。
③ 然后将伊甸园区中的剩余对象移动到幸存者0区。
④ 如果再次触发垃圾回收,此时上次幸存下来的存放到幸存者0区的,如果没有被回收,就会方法幸存者1区。
⑤ 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
⑥ 如果一个对象的幸存次数达到了15次,就会将该对象放到老年代。(可以设置 -XX:MaxTenuringThreshold=N
)。
⑦ 当老年代内存不足时,再次触发Major GC,进行老年代的内存清理。
⑧ 当老年代执行了Major GC后,如果依然无法进行对象的存储,就会产生OOM异常。
流程图如下:
JVM在进行GC时,并非每次都对上面的三个内存区域进行垃圾回收,大部分回收的都是指新生代。
针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)。
部分收集(Partial GC):不是完整收集整个Java堆的垃圾收集。其中又分为:
整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。
新生代GC(Minor GC)触发机制
老年代GC(Major GC 、Full GC)触发机制
(1)Major GC触发时机
(2)Full GC触发时机
System.gc()
时,系统建议执行Full GC,但是不一定会执行。为什么要把Java堆分代?优化GC性能,如果没有分代,所有的对象都在一块,GC的时候就要对堆的所有区域进行扫描。由于很多对象都是朝生夕死的,分代后方便将这些对象进行回收。
如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到survivor空间中,并将对象年龄设为1。对象在survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代。
对象晋升老年代的年龄阀值,可以通过选项-XX:MaxTenuringThreshold
来设置。
针对不同年龄段的对象分配原则如下所示:
MaxTenuringThreshold
中要求的年龄-XX:HandlePromotionFailure
-XX:UseTLAB
”设置是否开启TLAB空间。-XX:TLABWasteTargetPercent
” 设置TLAB空间所占用Eden空间的百分比大小。// 详细的参数内容会在JVM下篇:性能监控与调优篇中进行详细介绍,这里先熟悉下
-XX:+PrintFlagsInitial //查看所有的参数的默认初始值
-XX:+PrintFlagsFinal //查看所有的参数的最终值(可能会存在修改,不再是初始值)
-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:HandlePromotionFalilure://是否设置空间分配担保
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
-XX:HandlePromotionFailure
设置值是否允许担保失败。
HadnlePromotionFailure=true
,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小。
HadnlePromotionFailure=false
,则改为进行一次Full GC。在JDK6 Update24之后,HandlePromotionFailure
参数不会再影响到虚拟机的空间分配担保策略,观察openJDK中的源码变化,虽然源码中还定义了HandlePromotionFailure
参数,但是在代码中已经不会再使用它。JDK6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行FullGC。
在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
如何将堆上的对象分配到栈,需要使用逃逸分析手段。这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:
// 没有发生逃逸的对象,则可以分配到栈上,
// 随着方法执行的结束,栈空间就会被移除
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb; // 发生了逃逸
}
// 如果想使上述代码不发生逃逸,则可以使用以下写法
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
在JDK6之后,HotSpot默认开启了逃逸分析。
如果使用的是较早的版本,开发人员可以通过:
-XX:+DoEscapeAnalysis
显式开启逃逸分析-XX:+PrintEscapeAnalysis
查看逃逸分析的筛选结果通过逃逸分析,编译器可以对代码做出如下优化:
(1)栈上分配
JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
常见的栈上分配场景:给成员变量赋值、方法返回值、实例引用传递。
(2)同步省略
线程同步的代价是相当高的,同步的后果是降低并发性和性能。在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
public void f() {
Object hellis = new Object();
synchronized(hellis) {
System.out.println(hellis);
}
}
// 如果hellis对象的生命周期只存在f方法中,并不会被其他线程访问到,那么在JIT编译阶段就会被优化掉
public void f() {
Object hellis = new Object();
System.out.println(hellis);
}
(3)标量替换
标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在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);
}
标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。 标量替换为栈上分配提供了很好的基础。
标量替换参数设置
参数-XX:EliminateAllocations
:开启了标量替换(默认打开),允许将对象打散分配到栈上。
其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
java.lang.OutOfMemoryError: PermGen space
或者java.lang.OutOfMemoryError: Metaspace
方法区的大小不必是固定的,JVM可以根据应用的需要动态调整。
JDK7之前:
-XX:Permsize
。-XX:MaxPermsize
。OutOfMemoryError:PermGen space
。JDK8之后:
-XX:MetaspaceSize
和 -XX:MaxMetaspaceSize
指定。-XX:MetaspaceSize=21M -XX:MaxMetaspaceSize=-1 //即没有限制
。OutOfMemoryError:Metaspace
。-XX:MetaspaceSize
:设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-XX:MetaspaceSize
值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize
时,适当提高该值。如果释放空间过多,则适当降低该值。-XX:MetaspaceSize
设置为一个相对较高的值。-Xmx
与-Xms
),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
对每个加载的类型(类Class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient的某个子集)。
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
方法区,内部包含了运行时常量池。
字节码文件,内部包含了常量池。
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
一个Java源文件、接口,编译后产生一个字节码文件。而Java中的字节码文件需要数据支持,通常这种数据会很大以至于不能直接存放到字节码中,换另一种方式,可以存到常量池中,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍。
比如下面这段简单的代码:
public class SimpleClass {
public void sayHello() {
System.out.println("hello");
}
}
虽然代码很简单,但是却引用了String、System、PrintStream以及Object结构,这就需要用到常量池了。
常量池中存储的数据类型包括:
例如下面这段代码:
public class MethodAreaTest2 {
public static void main(String args[]) {
Object obj = new Object();
}
}
将会被翻译成如下字节码:
0: new #2 // Class java/lang/Object 创建一个新的Object对象(#2 表示常量池中的Object类)
1: dup // 复制栈顶数值并将复制值压入栈顶
2: invokespecial // Method java/lang/Object ""() V
public class MethodAreaDemo {
public static void main(String args[]) {
int x = 500;
int y = 100;
int a = x / y;
int b = 50;
System.out.println(a+b);
}
}
首先明确:只有Hotspot才有永久代。BEA JRockit、IBMJ9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。
HotSpot中方法区的变化:
JDK版本 | 方法区 |
---|---|
JDK1.6及之前 | 永久代(permanet),静态变量存储在永久代上 |
JDK1.7 | 永久代,字符串常量池,静态变量移除,保存在堆中 |
JDK1.8 | 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间, 但字符串常量池、静态变量仍然在堆中 |
随着Java8的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace)。
由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。
这项改动是很有必要的,原因有:
JDK7中将String Table放到了堆空间中,因为永久代的回收效率很低,在Full GC的时候才会触发,而Full GC是老年代空间不足、永久代不足时才会触发。
这就导致String Table回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
方法区内常量池中主要存放两大类常量:字面量和符号引用。
字面量比较接近Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包含下面三类常量:
HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc
参数进行控制,还可以使用-verbose:class
以及 -XX:+TraceClassLoading
、-XX:+TraceClassUnLoading
查看类加载和卸载信息。
当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。
如果没有,那必须先执行相应的类加载过程。在双亲委派模式下,使用当前类加载器以ClassLoader + 包名 + 类名
为key进行查找对应的.class文件:
ClassNotFoundException
异常在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可以确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。
如果内存规整:
所有被使用过的内存都被放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的举例,这种分配方式称为**“指针碰撞(Bump The Pointer)”**。
如果内存不规整:
已经被使用的内存相互交错在一起,那么就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大小的内存划分给对象实例,并更新列表上的记录,这种分配方式被称为**“空闲列表(Free List)”**。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定的。
因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除( Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
对象创建在虚拟机中是非常频繁的行为,即时仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。
保证线程安全有两种方式:
虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB
参数来设定。
内存分配完成之后,虚拟机必须将分配的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。
这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否使用偏向锁等,对象头会有不同的设置。
从虚拟机视角来看,执行完上面的流程,一个新的对象已经产生了。但是从Java程序的视角来看,对象的创建才刚刚开始-- 构造函数,即Class文件中的
方法还没有执行,所有字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构建好。
初始化成员变量,执行实例化代码,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
一般来说(由字节码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行
方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据( Instance Data)和对齐填充(Padding) 。
HotSpot虚拟机对象头部分包括两部分信息。
第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别占32个比特和64个比特,官方称为“Mark Word”。
例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,其他状态下(轻量级锁定、重量级锁定、GC标记、可偏向)对象的存储内存如下表所示:
**对象头的另一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针确定该对象是哪个类的实例。**此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组长度是不确定的,将无法通过元数据中的信息推断出数组的大小。
实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle
参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认分配策略中可以看到:
+XX:CompactFields
参数值为true(默认为true),那子类中较窄的变量也允许插入父类变量的空隙之中,节省一点点空间。**对齐填充不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。**由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全
public class Customer{
int id = 1001;
String name;
Account acct;
{
name = "匿名客户";
}
public Customer() {
acct = new Account();
}
}
public class CustomerTest{
public static void main(string[] args){
Customer cust=new Customer();
}
}
由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象的访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:
(1)如果使用句柄访问的话,Java堆中可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的地址信息。
(2)如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
这两种对象访问方式各有优势:
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。直接内存是在Java堆外的、直接向系统申请的内存空间。通常,访问直接内存的速度会优于Java堆,即读写性能高。
在JDK 1.4中新加入了NIO (New Input/Output)类,引入了一种基于通道(Channel〉与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
使用IO读写文件,需要与磁盘进行交互,需要由用户态切换到内核态。在内核态时,需要两份内存存储重复数据,效率低。
使用NIO时,操作系统划出的直接缓存区可以被Java代码直接访问,只有一份,NIO适合对大文件的读写操作。
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
at java.nio.Bits.reserveMemory(Bits.java:693)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
at com.atguigu.java.BufferTest2.main(BufferTest2.java:20)
由于直接内存在堆外,所以直接内存的分配不会受到Java堆大小的限制,但是会受到本机总内存大小以及处理器寻址空间的限制。
直接内存大小可以通过MaxDirectMemorySize
设置。如果不指定,默认与堆的最大值-Xmx参数值一致。
执行引擎是Java虚拟机核心的组成部分之一。执行引擎属于JVM的下层,里面包括解释器、即时编译器、垃圾回收器。
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
JVM的主要任务是负责装载字节码到其内部,但字节码并不能直接运行在操作系统上,因为字节码指令并非等价于本地机器指令,它包含的仅仅是一些能够被JVM所识别的字节码指令、符号表以及其他辅助信息。
如果想要一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的机器指令。
① 执行引擎在执行过程中需要执行什么样的字节码指令完全依赖于PC寄存器。
② 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址。
③ 每当方法在执行过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆中的对象实例信息,以及通过对象头的元数据指针定位到目标对象的类型信息。
从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行过程。
大部分的程序代码转化为物理机目标代码或者虚拟机能执行的指令集之前,都需要经过上图中的各个步骤。
Java代码编译是由Java源码编译器(前端编译器)来完成,流程图如下所示:
Java字节码的执行是由JVM执行引擎(后端编译器)来完成,流程图如下:
解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
JIT编译器:虚拟机将源代码直接编译成本地机器平台相关的机器语言。
JDK1.0时代,将Java语言定位为“解释执行”还是比较准确的。再后来,Java也发展出可以直接生成本地代码的编译器。现在JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。
各种用二进制编码方式表示的指令,叫做机器指令码。机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。用它编写的程序一经输入计算机,CPU直接读取运行,因此和其他语言编的程序相比,执行速度最快。机器指令与CPU紧密相关,所以不同种类的CPU所对应的机器指令也就不同。
由于机器码是有0和1组成的二进制序列,可读性实在太差,于是人们发明了指令。指令就是把机器码中特定的0和1序列,简化成对应的指令(一般为英文简写,如mov,inc等),可读性稍好。由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同。
由于指令的可读性还是太差,于是人们又发明了汇编语言。在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用mark地址符号(Symbol)或标号(Label)代替指令或操作数的地址。在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。 由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。
为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言。当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序或编译程序。
字节码是一种中间状态(中间码)的二进制代码(文件),它比机器码更抽象,需要直译器转译后才能成为机器码。**字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。**字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。字节码典型的应用为:Java bytecode。
JVM设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。
为什么Java源文件不直接翻译成机器码?因为直接翻译的代价是比较大的。
解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。
在Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。
在HotSpot虚拟机中,解释器主要由Interpreter模块和Code模块构成。
由于解释器在设计和实现上非常简单,因此除了Java语言之外,还有许多高级语言同样也是基于解释器执行的,比如Python、Perl、Ruby等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些C/C++程序员所调侃
为了解决这个问题,JVM平台支持一种叫作即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。
不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。
第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行 。
第二种是编译执行(直接编译成机器码,但是要知道不同机器上编译的机器码是不一样,而字节码是可以跨平台的)。现代虚拟机为了提高执行效率,会使用**即时编译技术(JIT,Just In Time)**将方法编译成机器码后再执行 。
HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。
既然HotSpot VM中已经内置JIT编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?
当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。
同时,解释器还可以作为编译器激进优化时后备的“逃生门”(如果情况允许, HotSpot虚拟机中也会采用不进行激进优化的客户端编译器充当“逃生门”的角色),让编译器根据概率选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现**“罕见陷阱”(Uncommon Trap)时可以通过逆优化(Deoptimization)**退回到解释状态继续执行,因此在整个Java虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作。
Java 语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把.java文件转变成.class文件的过程;也可能是指虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程;还可能是指使用静态提前编译器(AOT编译器,Ahead of Time Compiler)直接把.java文件编译成本地机器代码的过程。
在运行过程中会被即时编译器编译的目标是**“热点代码”**,这里的热点代码主要有两类:
JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能。
对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。第一种情况,由于 是依靠方法调用触发的编译,那编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的即时编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,热点只是方法的一 部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条字节码指令开始执 行)会稍有不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。这种编译方式因为 编译发生在方法执行的过程中,因此被很形象地称为“栈上替换”(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。
要知道某段代码是不是热点代码,是不是触发即时编译,这个行为称为**“热点探测”(HotSpot Code Detection)**,热点探测并不一定需要知道方法具体被执行了多少次,目前主流的热点探测判断方式有两种。
(1)采样热点探测
基于采样的热点探测(Sample Based Hot Spot Code Detection)。采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方 法”。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
(2)计数器热点探测
基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为 每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为 它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。
目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。
采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译器。
这个计数器就是用于统计方法被调用的次数,它的默认阈值在客户端模式下是1500次,在服务端模式下是10000次,这个阈值可以通过虚拟机参数-XX:CompileThreshold
来人为设定。
当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法的调用计数器值加一,然后判断方法的调用计数器与回边计数器之和是否超过方法调用计数器的阈值。一旦已超过阈值,将会向即时编译器提交一个该方法的代码编译请求。
如果没有做任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入 口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了。
在默认情况下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让 它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay)。而这段时间就称为此方法统计的半衰周期(Counter Half Life Time),进行热度衰减的动作是在虚拟机进行垃圾收集时顺便执行的,可以使用虚拟机参数-XX:UseCounterDecay
来关闭热度衰减,让方法计数器统计方法调用的决定次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。另外还可以使用-XX:CounterHalfLifeTime
参数设置半衰周期的时间,单位是秒。
回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到的控制流向后跳转的指令就称为**“回边(Back Edge)”**,很显然建立回边计数器统计的目的是为了触发栈上的替换编译。
关于回边计数器的阈值,虽然HotSpot虚拟机也提供了一个参数-XX:BackEdgeThreshold
供用户设置,但是当前的HotSpot虚拟机实际上并未使用此参数,我们必须设置另外一个参数-XX:OnStackReplacePercentage
来间接调整回边计数器的阈值,其计算公式有如下两种。
-XX:CompileThreshold
)乘以OSR比率(-XX:OnStackReplacePercentage
)除以100。其中-XX:OnStackReplacePercentage
默认值为933,如果都取默认值,那客户端模式虚拟机的回边计数器的阈值为 13995。-XX:CompileThreshold
)乘以(OSR比率(-XX:OnStackReplacePercentage
)减去解释器监控比率(-XX: InterpreterProfilePercentage
)的差值)除以100。其中-XX:OnStack ReplacePercentage
默认值为140,- XX:InterpreterProfilePercentage
默认值为33,如果都取默认值,那服务端模式虚拟机回边计数器的阈 值为10700。当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有 的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回 边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求, 并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。
与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,被称为**“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler)**。或者简称为C1编译器和C2编译器。第三个是在JDK10才出现的、长期目标是代替C2的Graal编译器。
在**分层编译(Tiered Compilation)**的工作模式出现以前,HotSpot虚拟机通常是采用解释器与其中 一个编译器直接搭配的方式工作,程序使用哪个编译器,只取决于虚拟机运行的模式,HotSpot虚拟机 会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在客户端模式还是服务端模式。
-client
:指定Java虚拟机运行在client模式下,并使用C1编译器;C1编译器会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。-server
:指定Java虚拟机运行在server模式下,并使用C2编译器。C2进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高。无论采用的编译器是客户端编译器还是服务端编译器,解释器与编译器搭配使用的方式在虚拟机中被称为“混合模式”(Mixed Mode),用户也可以使用参数“-Xint”强制虚拟机运行于“解释模式”(Interpreted Mode),这时候编译器完全不介入工作,全部代码都使用解释方式执行。另外,也可以使用参数“-Xcomp”强制虚拟机运行于“编译模式”(Compiled Mode),这时候将优先采用编译方 式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。
$java -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, mixed mode)
$java -Xint -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, interpreted mode)
$java -Xcomp -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, compiled mode)
由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花 费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。
为了在程序启动响应速度与运行效率之间达到最佳平衡, HotSpot虚拟机在编译子系统中加入了分层编译的功能。在JDK 7的服务端模式虚拟机中作为默认 编译策略被开启。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包 括:
以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可调整分层的数量。各层次编译之间的交互、转换关系如下图所示:
实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。
在不同的编译器上有不同的优化策略,C1编译器上主要有方法内联、去虚拟化、冗余消除。
C2的优化主要是在全局层面,逃逸分析(前面讲过,并不成熟)是优化的基础。基于逃逸分析在C2上有如下几种优化:
jdk9引入了AOT编译器(静态提前编译器,Ahead of Time Compiler)。Java 9引入了实验性AOT编译工具jaotc。它借助了Graal编译器,将所输入的Java类文件转换为机器码,并存放至生成的动态共享库之中。
所谓AOT编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。
优点:Java虚拟机加载已经预编译成二进制库,可以直接执行。不必等待及时编译器的预热,减少Java应用给人带来“第一次运行慢” 的不良体验。
缺点:
根据官方文档翻译
动机:
目前String类的实现将字符存储在一个char数组中,每个字符使用两个字节(16位)。从许多不同的应用中收集到的数据表明,字符串是堆使用的主要组成部分,此外,大多数字符串对象只包含Latin-1字符。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部字符数组中有一半的空间没有被使用。
说明:
我们建议将String类的内部表示方法从UTF-16字符数组改为字节数组加编码标志域。新的String类将根据字符串的内容,以ISO-8859-1/Latin-1(每个字符一个字节)或UTF-16(每个字符两个字节)的方式存储字符编码。编码标志将表明使用的是哪种编码。
与字符串相关的类,如AbstractStringBuilder、StringBuilder和StringBuffer将被更新以使用相同的表示方法,HotSpot VM的内在字符串操作也是如此。
String改成了byte[]数组存储,并且加上了编码标记,节省了一些空间。
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value;
}
String:代表不可变的字符序列。简称:不可变性。
在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种。
JDK6及以前,字符串常量池存放在永久代。
JDK7时,将字符串常量池的位置调整到Java堆内。
String.intern()
方法。JDK8及以后,字符串常量存放在堆空间。
为什么要调整StringTable
在JDK7中,内部字符串不再分配在Java堆的永久代中,而是分配在Java堆的主要成分(称为年轻代和老年代),与应用程序创建的其他对象一起。这种变化将导致更多的数据驻留在主Java堆中,而更少的数据在永久代中,因此可能需要调整堆的大小。大多数应用程序将看到由于这一变化而导致的堆使用的相对较小的差异,但加载许多类或大量使用String.intern()方法的大型应用程序将看到更明显的差异。
Java语言规范里要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类型。
public staic void main(String[] args) {
System.out.print1n("1"); //2321
System.out.println("2");
System.out.println("3");
System.out.println("4");
System.out.println("5");
System.out.println("6");
System.out.println("7");
System.out.println("8");
System.out.println("9");
System.out.println("10"); //2330
System.out.println("1"); //2321
System.out.println("2"); //2322
}
举例1
public class Main {
public static void main(String[] args) {
// 都是常量,前端编译器会进行代码优化
// 通过idea直接看对应反编译的class文件,会显示String s1 = "abc"
String s1 = "a" + "b" + "c";
String s2 = "abc";
// true,s1和s2实际上指向字符串常量池中的同一个值
System.out.println(s1 == s2);
}
}
// 上述代码的class文件
public class Main {
public Main() {
}
public static void main(String[] args) {
String s1 = "abc";
String s2 = "abc";
System.out.println(s1 == s2);
}
}
举例2
public class Main {
public static void main(String[] args) {
String s1 = "javaEE";
String s2 = "hadoop";
String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop";
// 字符串拼接过程只有有一个是变量,那么就会存放在堆中,原理是StringBuilder
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;
System.out.println(s3 == s4); // true 编译期优化
System.out.println(s3 == s5); // false s1是变量,不能编译期优化
System.out.println(s3 == s6); // false s2是变量,不能编译期优化
System.out.println(s3 == s7); // false s1、s2都是变量
System.out.println(s5 == s6); // false s5、s6 不同的对象实例
System.out.println(s5 == s7); // false s5、s7 不同的对象实例
System.out.println(s6 == s7); // false s6、s7 不同的对象实例
String s8 = s6.intern();
System.out.println(s3 == s8); // true intern之后,s8和s3一样,指向字符串常量池中的"javaEEhadoop"
}
}
举例3
public class Main {
public static void main(String[] args) {
String s0 = "beijing";
String s1 = "bei";
String s2 = "jing";
String s3 = s1 + s2;
System.out.println(s0 == s3); // false s3指向对象实例,s0指向字符串常量池中的"beijing"
String s7 = "shanxi";
final String s4 = "shan";
final String s5 = "xi";
String s6 = s4 + s5;
System.out.println(s6 == s7); // true s4和s5是final修饰的,编译期就能确定s6的值了
}
}
举例4
public class Main {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4);
}
}
0 ldc #2 <a>
2 astore_1
3 ldc #3 <b>
5 astore_2
6 ldc #4 <ab>
8 astore_3
9 new #5 <java/lang/StringBuilder>
12 dup
13 invokespecial #6 <java/lang/StringBuilder.<init> : ()V>
16 aload_1
17 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
20 aload_2
21 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
24 invokevirtual #8 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
27 astore 4
29 getstatic #9 <java/lang/System.out : Ljava/io/PrintStream;>
32 aload_3
33 aload 4
35 if_acmpne 42 (+7)
38 iconst_1
39 goto 43 (+4)
42 iconst_0
43 invokevirtual #10 <java/io/PrintStream.println : (Z)V>
46 return
从上面字节码中的内容可以看出,s1 + s2
实际上是new了一个StringBuilder对象,并使用了append方法将s1和s2添加进来,最后调用了toString方法赋值给s4。
字符串拼接操作性能对比
public class Main {
public static void main(String[] args) {
int times = 50000;
// String
long start = System.currentTimeMillis();
testString(times);
long end = System.currentTimeMillis();
System.out.println("String: " + (end-start) + "ms");
// StringBuilder
start = System.currentTimeMillis();
testStringBuilder(times);
end = System.currentTimeMillis();
System.out.println("StringBuilder: " + (end-start) + "ms");
// StringBuffer
start = System.currentTimeMillis();
testStringBuffer(times);
end = System.currentTimeMillis();
System.out.println("StringBuffer: " + (end-start) + "ms");
}
public static void testString(int times) {
String str = "";
for (int i = 0; i < times; i++) {
str += "test";
}
}
public static void testStringBuilder(int times) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < times; i++) {
sb.append("test");
}
}
public static void testStringBuffer(int times) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < times; i++) {
sb.append("test");
}
}
}
String: 7066ms
StringBuilder: 2ms
StringBuffer: 2ms
本实验进行5万次循环,String拼接方式的时间是StringBuilder.append方式的约3500倍,StringBuilder和StringBuffer效率差不多,实验数据不一定准确,只是为了说明效率差距的问题。
在实际开发中,对于需要多次或大量拼接的操作,在不考虑线程安全问题时,我们就应该尽可能使用StringBuilder进行append操作。
除此之外,StringBuilder空参构造器的初始化大小为16。那么,如果提前知道需要拼接String的个数,就应该直接使用带参构造器指定capacity,以减少扩容的次数。
当调用intern方法时,如果常量池里已经包含了一个与这个String对象相等的字符串,正如equals(Object)方法所确定的,那么池子里的字符串会被返回。否则,这个String对象被添加到池中,并返回这个String对象的引用。
由此可见,对于任何两个字符串s和t,当且仅当s.equals(t)为真时,s.intern() == t.intern()为真。
intern是一个native方法,调用的是底层C的方法。
public native String intern();
如果不是使用双引号声明的String对象,可以使用String提供的intern方法,它会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
也就是说,如果在任意字符串上调用String.intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true。
("a" + "b" + "c").intern() == "abc"
通俗点讲,Interned string就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)。
举例1
public class Main {
public static void main(String[] args) {
String s = new String("1");
s.intern();
String s2 = "1";
// JDK6 false JDK7/8 false
System.out.println(s == s2);
}
}
① String s = new String("1")
创建了两个对象。堆空间中一个新的字符串对象"1"和s变量本身。
② s.intern()
将字符串对象"1"添加到字符串常量池中。
③ String s2 = "1"
s指向的是堆空间中的对象地址,而s2指向的是堆空间中常量池中的"1"的地址。
举例2
public class Main {
public static void main(String[] args) {
String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
// JDK6 false JDK7/8 true
System.out.println(s3 == s4);
}
}
① String s3 = new String("1") + new String("1")
等价于new String("11")
,但是字符串常量池中并不会生成字符串"11"。
② s3.intern()
由于此时常量池中并无"11",所以s3把记录的对象地址存入常量池。
所以s3和s4是同一个地址。
举例3
String str = new String("ab"); //创建了几个对象?
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: new #2 // class java/lang/String
3: dup
4: ldc #3 // String ab
6: invokespecial #4 // Method java/lang/String."":(Ljava/lang/String;)V
9: astore_1
10: return
LineNumberTable:
line 5: 0
line 6: 10
根据反编译后的字节码分析:
举例4
String str = new String("a") + new String("b"); // 创建了几个对象?
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=2, args_size=1
0: new #2 // class java/lang/StringBuilder
3: dup
4: invokespecial #3 // Method java/lang/StringBuilder."":()V
7: new #4 // class java/lang/String
10: dup
11: ldc #5 // String a
13: invokespecial #6 // Method java/lang/String."":(Ljava/lang/String;)V
16: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
19: new #4 // class java/lang/String
22: dup
23: ldc #8 // String b
25: invokespecial #6 // Method java/lang/String."":(Ljava/lang/String;)V
28: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
31: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
34: astore_1
35: return
LineNumberTable:
line 5: 0
line 6: 35
new StringBuilder()
。new String("a")
。new String("b")
。new String("ab")
,也就是最后的StringBuilder.toString。public class Main {
static final int MAX_COUNT = 1000 * 10000;
static final String[] arr = new String[MAX_COUNT];
public static void main(String[] args) {
Integer [] data = new Integer[]{1,2,3,4,5,6,7,8,9,10};
long start = System.currentTimeMillis();
for (int i = 0; i < MAX_COUNT; i++) {
//arr[i] = new String(String.valueOf(data[i%data.length]));
arr[i] = new String(String.valueOf(data[i%data.length])).intern();
}
long end = System.currentTimeMillis();
System.out.println("使用intern花费的时间为:" + (end - start));
try {
Thread.sleep(1000000);
} catch (Exception e) {
e.getStackTrace();
}
}
}
不使用intern花费的时间为:8998
使用intern花费的时间为:1021
结论:对于程序中大量使用存在的字符串时,尤其存在很多已经重复的字符串时,使用intern()方法能够节省内存空间。
public class Main {
public static void main(String[] args) {
// -Xms15m -Xmx15m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails
for (int i = 0; i < 100000; i++) {
String.valueOf(i).intern();
}
}
}
[GC (Allocation Failure) [PSYoungGen: 4096K->512K(4608K)] 4096K->1057K(15872K), 0.0017201 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 4608K->496K(4608K)] 5153K->1161K(15872K), 0.0014041 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 4608K, used 738K [0x00000000ffb00000, 0x0000000100000000, 0x0000000100000000)
eden space 4096K, 5% used [0x00000000ffb00000,0x00000000ffb3c970,0x00000000fff00000)
from space 512K, 96% used [0x00000000fff80000,0x00000000ffffc010,0x0000000100000000)
to space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
ParOldGen total 11264K, used 665K [0x00000000ff000000, 0x00000000ffb00000, 0x00000000ffb00000)
object space 11264K, 5% used [0x00000000ff000000,0x00000000ff0a6708,0x00000000ffb00000)
Metaspace used 3361K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 366K, capacity 388K, committed 512K, reserved 1048576K
SymbolTable statistics:
Number of buckets : 20011 = 160088 bytes, avg 8.000
Number of entries : 13658 = 327792 bytes, avg 24.000
Number of literals : 13658 = 580696 bytes, avg 42.517
Total footprint : = 1068576 bytes
Average bucket size : 0.683
Variance of bucket size : 0.686
Std. dev. of bucket size: 0.828
Maximum bucket size : 6
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 3308 = 79392 bytes, avg 24.000
Number of literals : 3308 = 244984 bytes, avg 74.058
Total footprint : = 804480 bytes
Average bucket size : 0.055
Variance of bucket size : 0.054
Std. dev. of bucket size: 0.233
Maximum bucket size : 3
目前,许多大规模的Java应用程序在内存上遇到了瓶颈。测量表明,在这些类型的应用程序中,大约25%的Java堆实时数据集被String对象所消耗。此外,这些 "String "对象中大约有一半是重复的,其中重复意味着 "string1.equals(string2) "是真的。在堆上有重复的String对象,从本质上讲,只是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动和持续的String’重复数据删除,以避免浪费内存,减少内存占用。
注意,这里说的重复,是指在堆中的数据,而不是常量池中的,因为常量池中的数据不会重复。
背景:对许多Java应用(有大的也有小的)做的测试得出以下结果:
实现:
一些命令行选项:
# 开启String去重,默认是不开启的,需要手动开启。
UseStringDeduplication(bool)
# 打印详细的去重统计信息
PrintStringDeduplicationStatistics(bool)
# 达到这个年龄的String对象被认为是去重的候选对象
StringpeDuplicationAgeThreshold(uintx)
垃圾收集,不是Java语言的伴生产物。
关于垃圾收集有三个经典问题:
垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序的结束,被保留的空间无法被其它对象使用,甚至可能导致内存溢出。
对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不断生产生活垃圾而从不打扫一样。
除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象。
自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险。自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发。
对于Java开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力。
此时,了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见outofMemoryError时,快速地根据错误异常日志定位问题和解决问题。
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。
GC主要关注 方法区 和 堆区。
垃圾收集器可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收,其中,Java堆是垃圾收集器的工作重点。
从次数上讲:
在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。
判断对象存活一般有两种方式:引用计数算法和可达性分析算法。
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
优点:① 原理简单,垃圾对象便于辨识 。② 判定效率高,回收没有延迟性。
缺点:① 需要占用一些额外内存空间存储计数器。 ② 每次赋值都要更新计数器,增加了时间开销。 ③ 引用计数器有一个最大的缺陷就是无法处理循环引用的情况。
通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
在Java技术体系里面。固定可作为GC Roots的对象包括以下几种:
除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”加入,共同构成完整的GC Roots集合。比如分代收集和局部回收(Partial GC)。
如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GCRoots集合中去考虑,才能保证可达性分析的准确性。
注意:如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。这点也是**导致GC进行时必须“stop The World”**的一个重要原因。
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。
JDK1.2之前,Java中的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。
JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、**弱引用(Weak Reference)和虚引用(Phantom Reference)**4种,这4种引用强度逐渐减弱。
要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的 队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。 这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导 致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对 象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对 象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己 (this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
注意:
最基础的垃圾收集算法。分别“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成之后,同一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收未被标记的对象。标记的过程就是对象是否属于垃圾的判断过程。
它的主要缺点有两个:
标记-复制算法常被简称为复制算法。复制算法是为了解决标记-清除算法面对大量可回收对象时执行效率低的问题。
算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存用完了,就将还存活着的对象复制到另一块上面,然后再把已经使用过的内存空间一次清理掉。
如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
优点:实现简单、运行高效。不会出现空间碎片化问题。
缺点:需要两倍的内存空间。复制对象需要产生额外的开销。
标记-整理算法主要是针对老年代对象的存亡特征而涉及的。
其中的标记过程仍与“标记-清除”一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
标记-清除算法和标记-整理算法的本质差异在于前者是一种非移动式回收算法,而后者是移动式的。
优点:消除了标记-清除算法中内存区域分散的问题、消除了标记-复制算法中内存减半的代价。
缺点:移动对象的同时,如果对象被其他对象引用,还需要调整引用地址。
分代收集算法的思想是:不同对象的生命周期是不一样的,因此,不同生命周期的对象可以采用不同的收集方式,以提高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
特点:区域相对于老年代较小,对象生命周期短、存活率低,回收频繁。
这种情况标记-复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。
特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。
上述现有的算法,在垃圾回收过程中,应用程序将处于Stop the World的状态,在STW的状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序则会被挂起很久,将严重影响用户体验和系统稳定性。
为了解决这个问题,就产生了增量收集算法(Incremental Collecting)。
如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
缺点:由于在垃圾回收的过程中,间断性地还执行了应用程序代码,所以减少了系统的停顿时间,但是,因为线程之间的切换和上下文的消耗,会使得垃圾回收的总成本上升,造成系统吞吐量下降。
一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。
每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。
在默认情况下,通过System.gc()
或者Runtime.getRuntime().gc()
的调用,会显式触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。
然而System.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用。(不能确保立即生效)
System.gc();
// System.java
public static void gc() {
Runtime.getRuntime().gc();
}
// Runntime.java
public native void gc();
从源码可以看出,System.gc
底层就是直接调用了Runtime.getRunTime().gc()
,而底层则是调用了native方法的gc()
。
javadoc中对OutOfMemoryError
的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。
没有空闲内存,也就说明Java虚拟机的堆内存不够,原因如下:
(1)Java虚拟机的堆内存设置不够。
比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过参数-Xms
、-Xmx
来调整。
(2)代码中创建了大量对象,并且长时间不能被垃圾收集器回收(存在引用)。
对于老版本的Oracle JDK,因为永久代的大小是有限的,并且JVM对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现OutOfMemoryError也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致OOM问题。对应的异常信息,会标记出来和永久代相关:“java.lang.OutOfMemoryError: PermGen space
"。
随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的OOM有所改观,出现OOM,异常信息则变成了:“java.lang.OutofMemoryError:Metaspace
"。直接内存不足,也会导致OOM。
在抛出OutOfMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理出空间。例如,在引用机制分析中,涉及到JVM会尝试回收软引用指向的对象。
当然,也不是在任何情况下垃圾收集器都会被触发。比如,去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM可以判断出垃圾收集并不能解决这个问题,所以直接抛出OutOfMemoryError。
严格来说,只有对象不会再被程序使用了,但是GC又不能回收它们的情况,叫做内存泄漏。
尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OutOfMemory异常,导致程序崩溃。
举例:
Stop-the-World,简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。
可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。
STW事件和采用哪款GC无关,所有的GC都有这个事件。STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。开发中不要用System.gc() 会导致Stop-the-World的发生。
在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。并发不是真正意义上的“同时进行”,只是CPU把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于CPU处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。
**当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行(Parallel)。**其实决定并行的因素不是CPU的数量,而是CPU的核心数量,比如一个CPU多个核也可以并行。
并发 VS 并行
并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态,如ParNew、Parallel Scavenge、Parallel Old。
串行(Serial):相较于并行的概念,单线程执行。如果内存不够,则程序暂停,启动JVM垃圾收集器进行回收,回收完,再启动程序的线程。
**并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。**用户线程在继续运行,而垃圾收集器线程运行于另一个CPU上,如:CMS、G1。
程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为**“安全点(Safepoint)”**。
安全点的选择很重要,既不能太少以至于让收集器等待时间过长,也不能太频繁以至于过分增大运行时的内存负荷。安全点的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的。因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而 长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转 等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
如何在垃圾收集时让所有线程都跑到最近的安全点,然后停顿呢?
这里有两种方案可供选择:抢先式中断 (Preemptive Suspension)和主动式中断(Voluntary Suspension)。
抢占式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断地方法不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。
现在几乎没有虚 拟机实现采用抢先式中断来暂停线程响应GC事件。
**主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。**轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
安全点区域保证了程序执行时,在不太长时间内就会遇到可进入垃圾收集过程的安全点。
但是,程序“不执行”的时候呢?所谓的程序不执行就是没有处理分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全点的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。
对于这种情况,就必须引入安全区域(Safe Region)来解决。
**安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。**我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时 间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全 区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的 阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以 离开安全区域的信号为止。
垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的GC版本。从不同角度分析垃圾收集器,可以将GC分为不同的类型。
按线程数分,可以分为串行垃圾回收器和并行垃圾回收器。
串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。
和串行回收相反,并行收集可以运行多个CPU同时执行垃圾回收,因此提高了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-the-World”机制。
按照工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器。
按碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器。
按工作内存分,又可分为年轻代垃圾回收器和老年代垃圾回收器。
吞吐量、暂停时间、内存占用 这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。
**吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间+垃圾收集时间)。**比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。
**“暂停时间”是指一个时间段内应用程序线程暂停,让GC线程执行的状态。**例如,GC期间100毫秒的暂停时间意味着在这100毫秒期间内没有应用程序线程是活动的。
有了虚拟机,就一定需要收集垃圾的机制,这就是Garbage Collection,对应的产品我们称为Garbage Collector。
-XX:+PrintCommandLineFlags // 查看命令行相关参数(包含使用的垃圾收集器)
jinfo -flag 相关垃圾回收器参数 进程ID // 使用命令行指数
在VM环境上添加参数后运行结果如下:
-XX:InitialHeapSize=257798976 -XX:MaxHeapSize=4124783616 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
Serial是最基础、历史最悠久的收集器,在JDK 1.3.1之前是HotSpot虚拟机新生代收集器的唯一选择。
目前仍然是HotSpot虚拟机运行在Client客户端模式下的默认新生代收集器。
这个收集器是一个单线程工作的收集器,更重要的是强调在它进行来及收集时,必须暂停其他所有工作线程,直到它收集结束。
优点:与其他收集器的单线程相比,简单而高效。
在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的 内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一 百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以Serail收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
ParNew收集器实质上是Serail收集器的多线程并行版本。Par是Parallel的缩写,New表明只能处理新生代。
除了同时使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致。
控制参数比如:-XX:SurivivorRatio
、-XX:PretenureSizeThreshold
、-XX:HandlePromotionFailure
等。
ParNew是很多JVM运行在Server服务端模式下新生代的默认垃圾收集器。尤其是JDK7之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但是很重要的原因是:除了Serail收集器外,目前只有它能与CMS收集器配合工作。
由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比serial收集器更高效?
CMS作为老年代的收集器,却无法与JDK1.4.0中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。
ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweepGC
选项)的默认新生代收集器,也可以使用-XX:+/-UseParNewGC
选项来强制指定或者禁用它。
随着被使用的处理器核心数量的增加,ParNew对于垃圾收集时系统资源的高效利用还是有好多好处的,参数-XX:ParallelGCThreads
可以限制线程数量,默认开启和CPU数据相同的线程数。
并行(Parallel)和并发(Concurrent)
并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
**并发(Concurrent):**并发描述的是垃圾收集线程与用户线程之间的关系,说明同一时间垃圾收集线程与用户线程都在运行。由于用户线程并未冻结,所以程序仍然能响应服务请求,但由于垃圾收集线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定的影响。
Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并发收集的多线程收集器。
那么Parallel Scavenge的出现是否多此一举?
高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
参数配置
-XX:MaxGCPauseMillis
:控制最大垃圾收集停顿时间。参数允许的值时一个大于0的毫秒数,收集器将尽力保证内存回收花费的 时间不超过用户设定值。-XX:GCTimeRatio
:直接设置吞吐量大小。参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的 比率,相当于吞吐量的倒数。-XX:+UseAdaptiveSizePolicy
:开启自适应调节策略。在这种模式下,,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区 的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数 了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时 间或者最大的吞吐量。-XX:+UseParallelGC
:手动指定年轻代使用Parallel并行收集器执行回收内存。-XX:+UseParallelOldGC
:手动指定老年代使用并行回收收集器。Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS 收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。
Serial Old收集器的工作过程如图所示:
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器是直到JDK6才开始提供的。
直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重 吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。Parallel Old收集器的工作过程如图所示。
CMS收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了垃圾收集线程与用户线程同时工作。
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。
CMS收集器是基于标记-清除算法实现的,它的运作过程要比前面几种收集器来说更加复杂,分为四个步骤:
初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;
并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长那个但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;
而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记的时间短;
最后是并发清除阶段,清除删掉标记阶段判断的已经死亡的对象,由于不需要移动存活的对象,所以这个阶段也是可以与用户线程同时并发的。
由于在整个过程中耗时最长的并发标记和并发清除阶段,垃圾收集线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
并发收集、低停顿。
① CMS收集器对处理器资源非常敏感。
在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计 算能力)而导致应用程序变慢,降低总吞吐量。CMS默认启动的回收线程数是(处理器核心数量 +3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的 处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时, CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。
② CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Concurrent Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。
在CMS的并发标记和并发清理阶 段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分 垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集 时再清理掉。这一部分垃圾就称为“浮动垃圾”。
同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。
JDK5默认配置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果实际应用中老年代的增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccu-pancy Fraction
的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。
到了JDK 6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满 足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不 得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集, 但这样停顿时间就很长了。
所以参数-XX:CMSInitiatingOccu-pancy Fraction
的值设置的太高将会很容易导致大量的并发失败发生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。
③ **CMS基于标记-清除算法实现,收集结束后会有大量的空间碎片产生。**空间 碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找 到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。
为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection
开关参数(默认是开启的,JDK9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的。
这样空间碎片解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction
(此参数从JDK 9开始废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量 由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。
Mark Sweep会造成内存碎片,为什么不使用Mark Compact算法呢?
因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存环境就无法使用,要保证用户线程继续执行,前提是它运行的资源不受影响,所以Mark Sweep更适合“Stop The World”场景下使用。
拓展
另外,为了缓解CMS收集器对处理器资源敏感出现的问题,虚拟机提供了一种称为**“增量式并发收集器”(Incremental Concurrent Mark Sweep、i-CMS)的CMS收集器变种**,所做的事情和以前单核处理器年代PC机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样, 是在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的 时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变 慢的时间更多了,但速度下降幅度就没有那么明显。实践证明增量式的CMS收集器效果很一般,从 JDK 7开始,i-CMS模式已经被声明为“deprecated”,即已过时不再提倡用户使用,到JDK 9发布后i-CMS模式被完全废弃。
-XX:+UseConcMarkSweepGC
:手动指定使用CMS收集器执行内存回收任务。开启该参数后会自动将-xx:+UseParNewGC
打开。即:ParNew(Young区用)+ CMS(Old区用)+ Serial Old的组合。-XX:CMSInitiatingOccupanyFraction
:设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。
-XX:+UseCMSCompactAtFullCollection
:用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。-XX:CMSFullGCsBeforeCompaction
:设置在执行多少次Full GC后对内存空间进行压缩整理。-XX:ParallelcMSThreads
:设置CMS的线程数量。
JDK9新特性:CMS被标记为Deprecate了(JEP291)
-XX: +UseConcMarkSweepGC
来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃。JDK14新特性:删除CMS垃圾回收器(JEP363)
-XX:+UseConcMarkSweepGC
的话,JVM不会报错,只是给出一个warning信息,但是不会exit。JVM会自动回退以默认GC方式启动JVM。原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1(Garbage-First)垃圾回收器是在Java7 update4之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。
与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。
官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。
因为G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的Region来表示Eden、幸存者0区,幸存者1区,老年代等。
G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。
由于这种方式的侧重点在于回收垃圾最大的区间(Region),所以我们给G1起一个名字:垃圾优先(Garbage First)。
Garbage First(简称G1)收集器是垃圾收集技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。
G1是一款主要面向服务端应用的垃圾收集器。主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。
在JDK1.7版本正式启用,移除了Experimenta1的标识,是JDK9以后的默认垃圾回收器,取代了CMS回收器以及Parallel+Parallel Old组合。被Oracle官方称为“全功能的垃圾收集器”。
与此同时,CMS已经在JDK9中被标记为废弃(deprecated)。在JDK8中还不是默认的垃圾收集器,需要使用-XX:+UseG1GC
来启用。
与其他GC收集器相比,G1使用了全新的分区算法,其特点如下所示:
这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。
从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。
-XX:+UseG1GC
:手动指定使用G1垃圾收集器执行内存回收任务。-XX:G1HeapRegionSize
设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。-XX:MaxGCPauseMillis
设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms(人的平均反应速度)。-XX:+ParallelGCThread
设置STW工作线程数的值。最多设置为8(上面说过Parallel回收器的线程计算公式,当CPU_Count > 8时,ParallelGCThreads 也会大于8)。-XX:ConcGCThreads
设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。-XX:InitiatingHeapOccupancyPercent
设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45。G1的设计原则就是简化JVM性能调优,开发人员只需要简单三步即可完成调优:
G1中提供了三种垃圾回收模式:Young GC、Mixed GC和Full GC,在不同的条件下被触发。
面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)
最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)。
用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好:
HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最 终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
在Java中数据类型分为基本数据类型和引用数据类型。基本数据类型由虚拟机预先定义,引用数据类型则需要进行类的加载。
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。
《Java虚拟机规范》严格规定了有且只有六种情况必须立即对类进行“初始化”:
(1)遇到new
、getstatic
、putstatic
或invokestatic
这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
(2)使用java.lang.reflect
包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
(3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
(4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
(5)当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle
实例最后的解析结果为REF_getStatic
、REF_putStatic
、REF_invokeStatic
、REF_newInvokeSpecial
四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
(6)当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
接口的加载过程与类加载过程稍有不同,针对接口需要做一些特殊说明:接口也有初始化过程, 这点与类是一致的,上面的代码都是用静态语句块“static{}”来输出初始化信息的,而接口中不能使用“static{}”语句块,但编译器仍然会为接口生成
类构造器,用于初始化接口中所定义的 成员变量。
接口与类真正有所区别的是前面讲述的六种“有且仅有”需要触发初始化场景中的第三种: 当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。
所谓加载,简而言之就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java的原型 – 类模板对象。
所谓类模板对象,其实就是Java类在]VM内存中的一个快照,JVM将从字节码文件中解析出的常量池、类字段、类方法等信息存储到类模板中,这样]VM在运行期便能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用。
反射的机制即基于这一基础。如果JVM没有将Java类的声明信息存储起来,则JVM在运行期也无法反射。
在加载阶段,Java虚拟机需要完成以下三件事情:
**类模型的位置:**加载的类在JVM中创建相应的类结构,类结构会存储在方法区(JDKl.8之前:永久代;J0Kl.8及之后:元空间)。
**Class实例的位置:**类将.class文件加载至元空间后,会在堆中创建一个Java.lang.Class对象,用来封装类位于方法区内的数据结构,该Class对象是在加载类的过程中创建的,每个类都对应有一个Class类型的对象。
创建数组类的情况稍微有些特殊,因为数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载器去创建。
一个数组类C创建的过程遵循如下规则:
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段 尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部 分,这两个阶段的开始时间仍然保持着固定的先后顺序。
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为 载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是Java虚拟 机保护自身的一项必要措施。
验证大致会完成下面四个阶段的校验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。
第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:
该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。
这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,这个阶段可能包括的验证点如下:
第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在与《Java语言规范》定义相悖的元数据信息。
第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。
在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要 对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害 虚拟机安全的行为,例如:
如果一个类型中有方法体的字节码没有通过字节码验证,那它肯定是有问题的;但如果一个方法 体通过了字节码验证,也仍然不能保证它一定就是安全的。即使字节码验证阶段中进行了再大量、再 严密的检查,也依然不能保证这一点。
由于数据流分析和控制流分析的高度复杂性,在JDK 6之后的Javac编译器和Java虚拟机里进行了一项联合优化,把尽可能 多的校验辅助措施挪到Javac编译器里进行。
具体做法是给方法体Code属性的属性表中新增加了一项名 为**“StackMapTable(栈映射帧)”的新属性,这项属性描述了方法体所有的基本块(Basic Block,指按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java虚拟机就不需要根据程序推导这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可。这样就将字节码验证的类型推导转变为类型检查**,从而节省了大量校验时间。
JDK 6的HotSpot虚拟机中提供了-XX:-UseSplitVerifier
选项来关闭掉这项优化,或者使用参数-XX:+FailOverToOldVerifier
要求在类型校验失败的时候退回到旧的类型推导方式进行校验。而到了 JDK 7之后,尽管虚拟机中仍然保留着类型推导验证器的代码,但是对于主版本号大于50(对应JDK 6)的Class文件,使用类型检查来完成数据流分析校验则是唯一的选择,不允许再退回到原来的类型 推导的校验方式。
最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。
符号引用验证可以看作是对类自身以外(常量池中的各种符号 引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部 类、方法、字段等资源。
本阶段通常需要校验下列内容:
)是否可被当前类访问。符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError
的子类异常,典型的如: java.lang.IllegalAccessError
、java.lang.NoSuchFieldError
、java.lang.NoSuchMethodError
等。
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。
强调:
另外,会存在一些特殊情况:如果类字段 的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值。例如:
public static final int value = 123;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据Con-stantValue的设置 将value赋值为123。
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。
**符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。**符号引用与虚拟机实现的内存布局无关,引 用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同, 但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
**直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。**直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚 拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
解析的触发时机
《Java虚拟机规范》之中并未规定解析阶段发生的具体时间,只要求了在执行ane-warray
、 checkcast
、getfield
、getstatic
、instanceof
、invokedynamic
、invokeinterface
、invoke-special
、 invokestatic
、invokevirtual
、ldc
、ldc_w
、ldc2_w
、multianewarray
、new
、putfield
和putstatic
这17个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现可以根据需要来自行判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。
多次解析问题
对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。
无论是否真正执行了多次解析动作,Java虚拟机都需要保证的是在 同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能够 成功;同样地,如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪 怕这个请求的符号在后来已成功加载进Java虚拟机内存之中。
不过对于invokedynamic指令,上面的规则就不成立了。当碰到某个前面已经由invokedynamic指令 触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令也同样生效。因为 invokedynamic指令的目的本来就是用于动态语言支持,它对应的引用称为“动态调用点限定符 (Dynamically-Computed Call Site Specifier)”,这里“动态”的含义是指必须等到程序实际运行到这条指 令时,解析动作才能进行。相对地,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶 段,还没有开始执行代码时就提前进行解析。
解析的对象
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7 类符号引用进行,分别对应于常量池的CONSTANT_Class_info
、CON-STANT_Fieldref_info
、 CONSTANT_Methodref_info
、CONSTANT_InterfaceMethodref_info
、 CONSTANT_MethodType_info
、CONSTANT_MethodHandle_info
、CONSTANT_Dyna-mic_info
和 CONSTANT_InvokeDynamic_info
8种常量类型。
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接 引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:
针对上面第3点访问权限验证,在JDK 9引入了模块化以后,一个public类型也不再意味着程序任 何位置都有它的访问权限,我们还必须检查模块间的访问权限。
如果我们说一个D拥有C的访问权限,那就意味着以下3条规则中至少有其中一条成立:
要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index 项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。
如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那把这个字段所属的类或接口用C表示,《Java虚拟机规范》要求按照如下步骤对C进行后续字段的搜索:
如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError
异常。
方法解析的第一个步骤与字段解析一样,也是需要先解析出方法表的class_index项中索引的方 法所属的类或接口的符号引用,如果解析成功,那么我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的方法搜索:
java.lang.IncompatibleClassChangeError
异常。java.lang.AbstractMethodError
异常。java.lang.NoSuchMethodError
。最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此 方法的访问权限,将抛出java.lang.IllegalAccessError
异常。
接口方法也是需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:
java.lang.IncompatibleClassChangeError
异常。java.lang.NoSuchMethodError
异常。在JDK 9之前,Java接口中的所有方法都默认是public的,也没有模块化的访问约束,所以不存在 访问权限的问题,接口方法的符号解析就不可能抛出java.lang.IllegalAccessError异常。但在JDK 9中增 加了接口的静态私有方法,也有了模块化的访问约束,所以从JDK 9起,接口方法的访问也完全有可 能因访问权限控制而出现java.lang.IllegalAccessError异常。
类的初始化阶段是类加载过程的最后一个步骤,直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。
进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。
初始化阶段就是执行类构造器
方法的过程。
并不是程序员在Java代码中直接编写 的方法,它是Javac编译器的自动生成物。
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的 语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
方法与类的构造函数(即在虚拟机视角中的实例构造器
方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的
方法执行前,父类的
方法已经执行完毕。因此在Java虚拟机中第一个被执行的
方法的类型肯定是java.lang.Object。
方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成
方法。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成
方法。但接口与类不同的是,执行接口的
方法不需要先执行父接口的
方法, 因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也 一样不会执行接口的
方法。
**Java虚拟机必须保证一个类的
方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的
方法,其他线程都需要阻塞等待,直到活动线程执行完毕
方法。**如果在一个类的
方法中有耗时很长的操作,那就 可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
使用static + final修饰的字段的显式赋值的操作,是在哪个阶段进行的赋值?
()
中赋值说明:
()
中赋值的情况: 排除上述的在准备环节赋值的情况之外的情况。Java程序对类的使用分为两种:主动使用和被动使用。
主动使用:
Class只有在必须要首次使用的时候才会被装载,Java虚拟机不会无条件地装载Class类型。Java虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。这里指的“使用”,是指主动使用,主动使用只有下列几种情况:(即:如果出现如下的情况,则会对类进行初始化操作。而初始化操作之前的加载、验证、准备已经完成。
被动使用:
除了以上的情况属于主动使用,其他的情况都属于被动使用。被动使用不会引起类的初始化。
任何一个类型在使用之前都必须经历过完整的加载、链接和初始化3个类加载步骤。一旦一个类型成功经历过这3个步骤之后便可以在程序中访问和调用它的静态类成员信息(比如:静态字段、静态方法),或者使用new关键字为其创建对象实例。
在类加载器的内部实现中,用一个Java集合来存放所加载类的引用。另一方面,一个Class对象总是会引用它的类加载器,调用Class对象的getClassLoader()方法,就能获得它的类加载器。由此可见,代表某个类的Class实例与其类的加载器之间为双向关联关系。
一个类的实例总是引用代表这个类的Class对象。在Object类中定义了getClass()方法,这个方法返回代表对象所属类的Class对象的引用。此外,所有的java类都有一个静态属性class,它引用代表这个类的Class对象。
当类被加载、链接和初始化后,它的生命周期就开始了。当代表类的Class对象不再被引用时,即不可触及时,Class对象就会结束生命周期,类在方法区内的数据也会被卸载,从而结束类的声明周期。
(1)启动类加载器加载的类型在整个运行期间是不可能被卸载的(jvm和jls规范)。
(2)被系统类加载器和扩展类加载器加载的类型在运行期间不太可能被卸载,因为系统类加载器实例或者扩展类的实例基本上在整个运行期间总能直接或者间接的访问的到,其达到unreachable的可能性极小。
(3)被开发者自定义的类加载器实例加载的类型只有在很简单的上下文环境中才能被卸载,而且一般还要借助于强制调用虚拟机的垃圾收集功能才可以做到。可以预想,稍微复杂点的应用场景中(比如:很多时候用户在开发自定义类加载器实例的时候采用缓存的策略以提高系统性能),被加载的类型在运行期间也是几乎不太可能被卸载的(至少卸载的时间是不确定的)。
综合以上三点,一个已经加载的类型被卸载的几率很小至少被卸载的时间是不确定的。同时我们可以看的出来,开发者在开发代码时候,不应该对虚拟机的类型卸载做任何假设的前提下,来实现系统中的特定功能。
类加载器(Class Loader):Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节 流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为类加载器。
**作用:ClassLoader是Java的核心组件,所有的Class都是由ClassLoader进行加载的,ClassLoader负责通过各种方式将Class信息的二进制数据流读入JVM内部,转换为一个与目标类对应的java.lang.Class对象实例。**然后交给Java虚拟机进行链接、初始化等操作。因此,ClassLoader在整个装载阶段,只能影响到类的加载,而无法通过ClassLoader去改变类的链接和初始化行为。至于它是否可以运行,则由Execution Engine决定。
一般情况下,Java开发人员并不需要在程序中显式地使用类加载器,但是了解类加载器的加载机制却显得至关重要。理由有以下几个方面:
java.lang.ClassNotFoundException
异常或java.lang.NoClassDefFoundError
异常时,手足无措。只有了解类加载器的 加载机制才能够在出现异常的时候快速地根据错误异常日志定位问题和解决问题。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确定其在Java虚拟机中的唯一性,每个类加载器,都拥有一个独立的类名称空间。即比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
命名空间:
JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)。
从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。无论类加载器的类型如何划分,在程序中我们最常见的类加载器结构主要是如下情况:
启动类加载器(Bootstrap Class Loader):这个类加载器负责加载存放在 \lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够 识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类 库加载到虚拟机的内存中。
启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时, 如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。
JAVAHOME/jre/lib/rt.jar
或sun.boot.class.path
路径下的内容)。用于提供JVM自身需要的类。-XX:+TraceClassLoading
参数可以查看被启动类加载器加载的类。**扩展类加载器(Extension):**这个类加载器是在类sun.misc.Launcher$ExtClassLoader 中以Java代码的形式实现的。它负责加载\lib\ext目录中,或者被java.ext.dirs系统变量所 指定的路径中所有的类库。
根据“扩展类加载器”这个名称,就可以推断出这是一种Java系统类库的扩 展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能,在JDK 9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由Java代码实现 的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。
应用程序类加载器(Application Class Loader):这个类加载器由 sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。
它负责加载用户类路径 (ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有 自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
ClassLoader与现有类的关系:
另外,还有两个ExtClassLoader和AppClassLoader两个加载器通过URLClassLoader实现。
除了以上虚拟机自带的加载器外,用户还可以定制自己的类加载器。Java提供了抽象类java.lang.ClassLoader,所有用户自定义的类加载器都应该继承ClassLoader类。
返回用于委托的父类加载器,如果为null,则表示是引导类加载器。
@CallerSensitive
public final ClassLoader getParent() {
if (parent == null)
return null;
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
// 检查对父类加载器的访问权限,如果调用者的类加载器与此类加载器相同,则执行权限检查
checkClassLoaderPermission(parent, Reflection.getCallerClass());
}
return parent;
}
加载具有指定二进制名称的类,默认按以下顺序搜索类:
如果使用上述步骤找到该类,并且resolve为true,则此方法将调用结果Class对象上的resolveClass()方法。
注意,该方法中的逻辑就是双亲委派模式的实现。
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先,检查这个类是否已经被加载。
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 父类不为空,则委托给父类加载
c = parent.loadClass(name, false);
} else {
// 父类为空,则调用BootstrapClassLoader加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果找不到类,抛出ClassNotFoundException
}
if (c == null) {
// 如果当前加载器的所有父类加载器都失败了,则由当前加载器重写findClass自定义类加载器
long t1 = System.nanoTime();
c = findClass(name);
// 定义类加载器以及统计数据
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c); // 链接指定的类
}
return c;
}
}
查找具有指定二进制名称的类,此方法应该被遵循加载类的委托模型的类加载器实现覆盖,并且在检查父类加载器中请求的类之后,将由loadClass方法调用,默认实现抛出ClassNotFoundException异常。
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
查找二进制名称为name的类,返回结果为java.lang.Class类的实例。这是一个受保护的方法,JVM鼓励我们重写此方法,需要自定义加载器遵循双亲委托机制,该方法会在检查完父类加载器之后被loadClass()方法调用。
一般情况下,在自定义类加载器时,会直接覆盖ClassLoader的findClass()方法并编写加载规则,取得要加载的类的字节码后转换成流,然后调用defineClass()方法生成类的Class对象。
深入理解Java类加载器(ClassLoader) - 掘金 (juejin.cn)
将字节数组转换为类class的实例,带有可选的ProtectionDomain。如果域为null,则将按照defineClass(String, byte[], int, int)的文档中指定的方式将默认域分配给该类。在使用类之前,必须对其进行解析。
包中定义的第一个类确定该包中定义的所有后续类必须包含的确切证书集。类的证书集是从类的ProtectionDomain中的CodeSource中获得的。添加到该包中的任何类都必须包含相同的证书集,否则将抛出SecurityException。注意,如果name为空,则不执行此检查。应该始终传入正在定义的类的二进制名称以及字节数。这确保了正在定义的类确实是认为的类。
指定的名称不能以“java”开头,因为“java. net”中的所有类。包只能由引导类加载器定义。如果name不为空,它必须等于字节数组"b"指定的类的二进制名称,否则将抛出NoClassDefFoundError。
protected final Class<?> defineClass(String name, byte[] b, int off, int len)
throws ClassFormatError
{
return defineClass(name, b, off, len, null);
}
protected final Class<?> defineClass(String name, byte[] b, int off, int len,
ProtectionDomain protectionDomain)
throws ClassFormatError
{
protectionDomain = preDefineClass(name, protectionDomain);
String source = defineClassSourceLocation(protectionDomain);
Class<?> c = defineClass1(name, b, off, len, protectionDomain, source);
postDefineClass(c, protectionDomain);
return c;
}
根据给定的字节数组b转换为Class的实例,off和len参数表示实际Class信息在byte数组中的位置和长度,其中byte数组b是ClassLoader从外部获取的。这是受保护的方法,只有在自定义ClassLoader子类中可以使用。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请 求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
优点:
缺点:
检查类是否加载的委托过程是单向的,这个方式虽然从结构上说比较清晰,使各个ClassLoader的职责非常明确,但是同时会带来一个问题,即顶层的ClassLoader无法访问底层的ClassLoader所加载的类。
通常情况下,启动类加载器中的类为系统核心类,包括一些重要的系统接口,而在应用类加载器中,为应用类。按照这种模式,应用类访问系统类自然是没有问题,但是系统类访问应用类就会出现问题。比如在系统类中提供了一个接口,该接口需要在应用类中得以实现,该接口还绑定一个工厂方法,用于创建该接口的实例,而接口和工厂方法都在启动类加载器中。这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题。
双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的 类加载器实现方式。
直到Java 模块化出现为止,双亲委派模型主要出现过3次较大规模“被破坏”的情况。
双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK 1.2面世以前的“远古”时代。由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类 java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一个新的 protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在 loadClass()中编写代码。上面我们已经分析过loadClass()方法,双亲委派的具体逻辑就实现在这里面, 按照loadClass()方法的逻辑,如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样 既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。
双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类 加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在。
但是如果基础类型又要回调用户的代码,那该怎么办呢?
一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务, 它的代码由启动类加载器来完成加载(在JDK 1.3时加入到rt.jar的),肯定属于Java中很基础的类型 了。但JNDI存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程 序的ClassPath下的JNDI服务提供者接口(Service Provider Interface,SPI)的代码。
启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?
为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器 (Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContext-ClassLoader()方 法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行 为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性 原则,但也是无可奈何的事情。Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、 JDBC、JCE、JAXB和JBI等。不过,当SPI的服务提供者多于一个的时候,代码就只能根据具体提供 者的类型来硬编码判断,为了消除这种极不优雅的实现方式,在JDK 6时,JDK提供了 java.util.ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式,这才算是给SPI的加 载提供了一种相对合理的解决方案。
双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态 性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。
IBM公司主导的JSR-291(即OSGiR4.2)实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(osGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bund1e连同类加载器一起换掉以实现代码的热替换。在oSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构。
当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:
说明:只有开头两点仍然符合双亲委派模型的原则,其余的类查找都是在平级的类加载器中进行的。
热替换是指在程序的运行过程中,不停止服务,只通过替换程序文件来修改程序的行为。
热替换的关键需求在于服务不能中断,修改必须立即表现在正在运行的系统之中。基本上大部分脚本语言都是天生支持热替换的。
但对Java来说,热替换并非天生就支持,如果一个类已经加载到系统中,通过修改类文件,并无法让系统再来加载并重定义这个类。因此,在Java中实现这一功能的一个可行的方法就是灵活运用ClassLoader。
注意:由不同ClassLoader加载的同名类属于不同的类型,不能相互转换和兼容。即两个不同的ClassLoader加载同一个类,在虚拟机内部,会认为这2个类是完全不同的。
根据这个特点,可以用来模拟热替换的实现,基本思路如下图所示:
沙箱安全机制:
**Java安全模型的核心就是Java沙箱(sandbox)。**简单来说,沙箱就是一个限制程序运行的环境。
沙箱机制就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问。通过这样的措施来保证对代码的有限隔离,防止对本地系统造成破坏。
所有的Java程序运行都可以指定沙箱,可以定制安全策略。
在Java中将执行程序分成本地代码和远程代码两种,本地代码默认视为可信任的,而远程代码则被看作是不受信的。对于授信的本地代码,可以访问一切本地资源。而对于非授信的远程代码在早期的Java实现中,安全依赖于沙箱(Sandbox)机制。如下图所示JDK1.0安全模型。
JDK1.0中如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件时候,就无法实现。因此在后续的Java1.1版本中,针对安全机制做了改进,增加了安全策略。允许用户指定代码对本地资源的访问权限。
如下图为JDK1.1的安全模型:
在Java1.2版本中,再次改进了安全机制,增加了代码签名。不论本地代码或是远程代码,都会按照用户的安全策略设定,由类加载器加载到虚拟机中权限不同的运行空间,来实现差异化的代码执行权限控制。如下图所示JDK1.2安全模型:
当前最新的安全机制实现,则引入了**域(Domain)**的概念。
虚拟机会把所有代码加载到不同的系统域和应用域。系统域部分专门负责与关键资源进行交互,而各个应用领域部分则通过域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护与(Protected Domain),对应不一样的权限(Permission)。存在于不同域中的类文件就有了当前域的所有权限,如下图:
**① 隔离加载类。**在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境。比如:阿里内某容器框架通过自定义类加载器确保应用中依赖的jar包不会影响到中间件运行时使用的jar包。再比如:Tomcat这类Web应用服务器,内部自定义了好几种类加载器,用于隔离同一个Web应用服务器上的不同应用程序。
② 修改类加载的方式。 类的加载模型并非强制,除Bootstrap外,其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需进行动态加载。
**③ 扩展加载源。**比如从数据库、网络、甚至是电视机机顶盒进行加载 。
**④ 防止源码泄漏。**Java代码容易被编译和篡改,可以进行编译加密。那么类加载也需要自定义,还原加密的字节码。
Java提供了抽象类java.lang.ClassLoader,所有用户自定义的类加载器都应该继承ClassLoader类。
在自定义ClassLoader的子类的时候,常见的有两种做法:
这两种方法本质上差不多,毕竟loadClass()也会调用findClass(),但是从逻辑上讲我们最好不要直接修改loadClass()的内部逻辑。建议的做法是只在findClass()里重写自定义类的加载方法,根据参数指定类的名字,返回对应的Class对象的引用。
loadClass()方法是实现双亲委派模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。**因此我们最好在双亲委派模型框架内进行小范围的改动,不破坏原有的稳定结构。**同时也避免了自己重写loadClass()方法的过程中必须写双亲委派的重复代码,从代码的复用性来看,不直接修改这个方法始终是比较好的选择。
当编写好自定义类加载器后,便可以在程序中调用loadClass()方法来实现类加载操作。
在JDK 9中引入的**Java模块化系统(Java Platform Module System,JPMS)**是对Java技术的一次重 要升级,为了能够实现模块化的关键目标——可配置的封装隔离机制,Java虚拟机对类加载架构也做出了相应的变动调整,才使模块化系统得以顺利地运作。
JDK 9的模块不仅仅像之前的JAR包那样只是 简单地充当代码的容器,除了代码外,Java的模块定义还包含以下内容:
可配置的封装隔离机制需要解决的问题:
(1)JDK9之前基于类路径(ClassPath)来查找依赖的可靠性问题。
如果类路径中缺失了运行时依赖的类型,那就只能等程序运行到发生该类型的加载、链接 时才会报出运行的异常。
而在JDK 9以后,如果启用了模块化进行封装,模块就可以声明对其他模块的显式依赖,这样Java虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备,如有缺失那就直接启动失败,从而避免了很大一部分由于类型依赖而引发的运行时异常。
(2)原来类路径上跨JAR文件的public类型的可访问性问题。
JDK 9中 的public类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性 控制,必须明确声明其中哪一些public的类型可以被其他哪一些模块访问,这种访问控制也主要是在类 加载过程中完成的。
为了使可配置的封装隔离机制能够兼容传统的类路径查找机制,JDK 9提出了与“类路 径”(ClassPath)相对应的“模块路径”(ModulePath)的概念。简单来说,就是**某个类库到底是模块还是传统的JAR包,只取决于它存放在哪种路径上。**只要是放在类路径上的JAR文件,无论其中是否包 含模块化信息(是否包含了module-info.class文件),它都会被当作传统的JAR包来对待;相应地,只 要放在模块路径上的JAR文件,即使没有使用JMOD后缀,甚至说其中并不包含module-info.class文 件,它也仍然会被当作一个模块来对待。
模块化系统将按照以下的规则来保证使用传统类路径依赖的Java程序可以不经修改地直接运行在Java9及以后的Java板块上。
为了保证兼容性,JDK9并没有从根本上改动三层类加载器架构以及双亲委派机制。但是为了模块化系统的顺利施行,模块化下的类加载器仍然发生了一些应该被 注意到变动,主要包括以下几个方面。
① 扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。
既然整个JDK都基于模块化进行构建(原来的rt.jar和tools.jar被拆分 成数十个JMOD文件),其中的Java类库就已天然地满足了可扩展的需求,那自然无须再保留 \lib\ext目录,此前使用这个目录或者java.ext.dirs系统变量来扩展JDK功能的机制已经没 有继续存在的价值了,用来加载这部分类库的扩展类加载器也完成了它的历史使命。
类似地,在新版 的JDK中也取消了\jre目录,因为随时可以组合构建出程序运行所需的JRE来,譬如假 设我们只使用java.base模块中的类型,那么随时可以通过以下命令打包出一个“JRE”:
jlink -p $JAVA_HOME/jmods --add-moudles java.base --output jre
② 平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader,如果有程序直接 依赖了这种继承关系,或者依赖了URLClassLoader类的特定方法,那代码很可能会在JDK 9及更高版本的JDK中崩溃。
现在启动类加载器、平台类加载器、应用程序类加载器全都继承于 jdk.internal.loader.BuiltinClassLoader,在BuiltinClassLoader中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。
最后,JDK 9中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了变动。
当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,也许这可以算是对双亲委派的第四次破坏。
在Java模块化系统明确规定了三个类加载器负责各自加载的模块,即前面所说的归属关系,如下所示:
(1)启动类加载器负责加载的模块:
(2)平台类加载器负责加载的模块:
(3)应用程序类加载器负责加载的模块:
public class Main {
public static void main(String[] args) {
System.out.println(Main.class.getClassLoader()); // 系统类加载器
System.out.println(Main.class.getClassLoader().getParent()); // 扩展类加载器
System.out.println(Main.class.getClassLoader().getParent().getParent()); // 引导类加载器或平台类加载器
//获取系统类加载器
System.out.println(ClassLoader.getSystemClassLoader());
//获取平台类加载器
System.out.println(ClassLoader.getPlatformClassLoader());
//获取类的加载器的名称
System.out.println(Main.class.getClassLoader().getName());
}
}