8、执行引擎
-
执行引擎概述
-
Java 代码编译和执行过程
-
机器码、指令、汇编语言
-
解释器
-
JIT编译器
8.1 执行引擎概述
-
执行引擎是Java虚拟机核心的组成部分之一。
-
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
-
JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符 表,以及其他辅助信息。
-
那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。
执行引擎的工作过程:
-
执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于Pc寄存器。
-
每当执行完一项指令操作后,Pc寄存器就会更新下一条需要被执行的指令地址。
-
当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。
从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
8.2 Java代码编译和执行的过程
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图中的各个步骤:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hYjY7Fpr-1657251898212)(file://D:MyCodeTypora_works自主学习java-JVMimg笔记-12022-05-30-09-18-17-image.pngsec=1657251711965)]
Java代码编译是由Java源码编译器来完成,流程图如下所示:
在 Java 源码级编译器中,源代码 -> 词法分析器 -> Token流 ->语法树/抽象语法树 -> 语义分析器 -> 注解抽象语法树 -> 字节码生成器 -> JVM 字节码 ; 另外包括 符 表
问题:什么是解释器(Interpreter),什么是 JIT 编译器
-
解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
-
JIT (Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。
问题:为什么说 Java 是半编译半解释型语言/p>
-
DK1.0时代,将Java语言定位为“解释执行”还是比较准确的。再后来,ava也发展出可以直接生成本地代码的编译器。
-
现在VM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。
8.3 机器码、指令、汇编语言
机器码:
-
各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它采编写程序,这就是机器语言。
-
机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。
-
用它编写的程序一经输入计算机,CPU直接读取运行,因此和其他语言编的程序相比,执行速度最快。
-
机器指令与CPU紧密相关,所以不同种类的CPU所对应的机器指令也就不同。
指令:
-
由于机器码是有0和1组成的二进制序列,可读性实在太差,于是人们发明了指令。
-
指令就是把机器码中特定的o和1序列,简化成对应的指令(一般为英文简写,如mov,inc等),可读性稍好
-
由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同。
指令集:
-
不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。
-
如常见的
-
x86指令集,对应的是x86架构的平台
-
ARM指令集,对应的是ARM架构的平台
-
汇编语言:
-
由于指令的可读性还是太差,于是人们又发明了汇编语言。
-
在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符 (Symbo1)或标 (Labe1)代替指令或操作数的地址。
-
在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。
- 由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。
高级语言:
-
为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言
-
当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序或编译程序。
总结:
编译过程:是读取源程序(字符流),对之进行词法和语法的分析,将高级语言指令转换为功能等效的汇编代码
汇编过程:实际上指把汇编语言代码翻译成目标机器指令的过程。
字节码:
-
字节码是一种中间状态(中间码)的二进制代码(文件),它比机器码更抽象,需要直译器转译后才能成为机器码
-
字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。
-
字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。
- 字节码的典型应用为Java bytecode。
8.4 解释器
解释器工作机制
-
解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
-
当一条字节码指令被解释执行完成后,接着再根据Pc寄存器中记录的下一条需要被执行的字节码指令执行解释操作。
在Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。
-
字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
-
而模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。
- 在Hotspot vM中,解释器主要由Interpreter模块和code模块构成。
Interpreter模块:实现了解释器的核心功能
Code模块:用于管理HotSpot VM在运行时生成的本地机器指令
- 在Hotspot vM中,解释器主要由Interpreter模块和code模块构成。
8.5 JIT编译器
Java 代码的执行分类:
-
第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行
-
第二种是编译执行(直接编译成机器码)。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT,Just In Time)将方法编译成机器码后再执行
HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。
在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和C/C++程序一较高下的地步
HotSpot JVM的执行方式:
当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。
概念解释:
-
Java 语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把 .java文件转变
成.class文件的过程; -
也可能是指虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程。
-
还可能是指使用静态提前编译器(AOT编译器,Ahead of Time Compiler)直接把.java文件编译成本地机器代码的过程。
热点代码及探测方式:
当然是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能。
-
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为oSR (On StackReplacement)编译。
-
一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准然需要一个明确的阙值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。
-
目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。
-
采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back
Edge Counter) .-
方法调用计数器用于统计方法的调用次数
-
回边计数器则用于统计循环体执行的循环次数
-
方法调用计数器:
-
这个计数器就用于统计方法被调用的次数,它的默认阈值在 client模式下是 1500 次,在 Server模式下是 10000 次。超过这个阈值,就会触发JIT编译。
-
这个阈值可以通过虚拟机参数-xx : compileThreshold来人为设定。
-
当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
热度衰减:
-
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(counter Half Life Time)。
-
进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-xX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
-
另外,可以使用-xX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。
回边计数器:
它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。显然,建立回边计数器统计的目的就是为了触发OSR编译。
HotSpot VM可以设置程序执行方式:
缺省情况下HotSpot VM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:
-
-Xint:完全采用解释器模式执行程序;
-
-Xcomp:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。
-
-Xmixed:采用解释器+即时编译器的混合模式共同执行程序。
HotSpot VM 中 JIT分类:
在HotSpot VM中内嵌有两个JIT编译器,分别为client Compiler和server Compiler,但大多数情况下我们简称为c1编译器和c2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器,如下所示:
-
-client:指定Java虚拟机运行在client模式下,并使用c1编译器;
c1编译器会对字节码进行简单和可靠的优化,耗时短。以达到更快的编译速度。 -
-server:指定Java虚拟机运行在Server模式下,并使用C2编译器。
C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。
C1和C2编译器不同的优化策略∶
-
在不同的编译器上有不同的优化策略,c1编译器上主要有方法内联,去虚拟化、冗余消除。
-
方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减
少参数传递以及跳转过程 -
去虚拟化:对唯一的实现类进行内联
-
冗余消除:在运行期间把一些不会执行的代码折叠掉
-
-
c2的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在c2上有如下几种优化:
-
标量替换:用标量值代替聚合对象的属性值
-
栈上分配:对于未逃逸的对象分配对象在栈而不是堆
-
同步消除:清除同步操作,通常指synchronized
-
分层编译(Tiered compilation)策略:程序解释执行(不开启性能监控)可以触发cl编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控信息进行激进优化。
不过在Java7版本之后,一但开发人员在程序中显式指定命令“-server”时,默认将会开启分层编译策略,由c1编译器和c2编译器相互协作共同来执行编译任务。
总结:
-
一般来讲,JIT编译出来的机器码性能比解释器高。
-
C2编译器启动时长比C1编译器慢,系统稳定执行以后,c2编译器执行速度远远快于c1编译器。
9、StringTable
-
String的基本特性
-
String 的内存分配
-
String 的基本操作
-
字符串拼接操作
-
intern() 的使用
-
StringTable 的垃圾回收
-
G1 中的String 去重操作
9.1 String的基本特性
-
string:字符串,使用一对””引起来表示。
-
string声明为final的,不可被继承
-
string实现了serializable接口:表示字符串是支持序列化的。
实现了comparable接口:表示string可以比较大小 -
string在jdk8及以前内部定义了final char[] value用于存储字符串数据。jdk9时改为byte[]
结论:String再也不用char[]来存储啦,改成了byte[]加上编码标记,节约了一些空间。
-
string :代表不可变的字符序列。简称:不可变性。
-
当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有
的value进行赋值。 -
当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,
不能使用原有的value进行赋值。 -
当调用string的replace ()方法修改指定字符或字符串时,也需要
重新指定内存区域赋值,不能使用原有的value进行赋值。
-
-
通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。
此时成员变量和局部变量指向的地址就不一样了
而局部变量str如果不重新赋值的话也是和成员变量str指向同一个地址,一但重新辅助,因为String的不可变性,就会重新开辟一个内存空间。
-
字符串常量池中是不会存储相同内容的字符串的。
-
String的String Pool是一个固定大小的Hashtable,默认值大小长度是1009。如果放进string Pool的string非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用string.intern时性能会大幅下降。
-
使用-XX :StringTableSize可设置StringTable的长度
-
在jdk6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTableSize设置没有要求
-
在jdk7中,StringTable的长度默认值是60013,StringTableSize 设置没有要求
-
JDK8开始,设置StringTable的长度的话,1009是可设置的最小值。
9.2 String 的内存分配
-
在Java语言中有8种基本数据类型和一种比较特殊的类型string。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
-
常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,string类型的常量池比较特殊。它的主要使用方法有两种。
-
直接使用双引 声明出来的string对象会直接存储在常量池中。
- 比如:string info = “atguigu . com” ;
-
如果不是用双引 声明的string对象,可以使用string提供的
intern ()方法。这个后面重点谈
-
-
Java 6及以前,字符串常量池存放在永久代。
-
Java 7 中 oracle 的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内。
-
所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样
可以让你在进行调优应用时仅需要调整堆大小就可以了。 -
字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java 7 中使用string.intern ( )。
-
-
Java8元空间,字符串常量在堆
StringTable为什么要调整
1、permSize默认比较少 2、永久代垃圾回收频率低
9.3 String 的基本操作
9.4 字符串拼接操作
1.常量与常量的拼接结果在常量池,原理是编译期优化
2.常量池中不会存在相同内容的常量。
3.只要其中有一个是变量,结果就在堆中。变量拼接的原理是stringBuilder
4.如果拼接的结果调用intern ()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。
9.5 intern()的使用
-
如果不是用双引 声明的string对象,可以使用string提供的intern方法: intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
- 比如:string myInfo = new string(“I love atguigu” ) .intern();
-
也就是说,如果在任意字符串上调用string.intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true:
( ” a” +“b” +“c”) .intern () – “abc” -
通俗点讲,Interned string就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(string Intern Pool) 。.
总结string的intern ()的使用:
-
jdk1.6中,将这个字符串对象尝试放入串池。
-
如果串池中有,则并不会放入。返回已有的串池中的对象的地址
-
如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址
-
-
Jdk1.7起,将这个字符串对象尝试放入串池。
-
如果串池中有,则并不会放入。返回已有的串池中的对象的地址
-
如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址
-
9.6 StringTable 的垃圾回收
9.7 G1 中的String 去重操作
-
背景:对许多Java应用(有大的也有小的)做的测试得出以下结果:
-
堆存活数据集合里面string对象占了25%
-
堆存活数据集合里面重复的String对象有13.5%
-
String对象的平均长度是45
-
-
许多大规模的Java应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java堆中存活的数据集合差不多25%是string对象。更进一步,这里面差不多一半string对象是重复的,重复的意思是说:
string1.equals(string2 ) =true。堆上存在重复的string对象必然是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动持续对重复的string对象进行去重,这样就能避免浪费内存。 -
实现
-
当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会
检查是否是候选的要去重的string对象。 -
如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线
程在后台运行,·处理这个队列,处理队列的十个元素意味着从队列删除这个元素,然后尝试去重它引用的string对象。 -
使用一个hashtable来记录所有的被string对象使用的不重复的char数组。
当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。 -
如果存在,string对象会被调整引用那个数组,释放对原来的数组的引用,最
终会被垃圾收集器回收掉。 -
如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共
享这个数组了。
-
命令行选项:
10、垃圾回收概述
10.1 什么是垃圾
-
垃圾收集,不是Java语言的伴生产物。早在1960年,第一门开始使用内存动态分配和垃圾收集技术的Lisp语言诞生。
-
关于垃圾收集有三个经典问题:
-
哪些内存需要回收/p>
什么时候回收/p>
如何回收/p>
-
-
垃圾收集机制是Java的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是面试的热点。
拓展:大厂面试题
蚂蚁金服;
-
你知道哪几种垃圾回收器,各自的优缺点,重点讲一下cms和g1
-
一面:JVM Gc算法有哪些,目前的JDK版本采用什么回收算法
-
—面:G1回收器讲下回收过程
-
Gc是什么什么要有GC
-
一面:GC 的两种判定方法MS收集器与G1 收集器的特点。
百度:
-
说一下GC算法,分代回收说下垃圾收集策略和算法
天猫:
-
一面: jvm Gc原理,JVM怎么回收内存
-
一面: cMs特点,垃圾回收算法有哪些自的优缺点,他们共同的缺点是什么/p>
滴滴:
-
一面: java的垃圾回收器都有哪些,说下g1的应用场景,平时你是如何搭配使用垃圾回收器的
京东:
-
你知道哪几种垃圾收集器,各自的优缺点,重点讲下cms和G1,包括原理,流程,优缺点。垃圾回收算法的实现原理。
阿里: -
讲一讲垃圾回收算法。
-
什么情况下触发垃圾回收/p>
如何选择合适的垃圾收集算法/p>
-
JVM有哪三种垃圾回收器/p>
字节跳动;
-
常见的垃圾回收器算法有哪些,各有什么优劣/p>
-
system.gc(和runtime.gc()会做什么事情
-
一面: Java Gc机制C Roots有哪些/p>
-
二面: Java对象的回收方式,回收算法。
-
CMS和G1了解么,CMS解决什么问题,说一下回收的过程。
-
CMS回收停顿了几次,为什么要停顿两次。
-
什么是垃圾( Garbage)呢/p>
-
位圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
-
外文: An object is considered garbage when it can nolonger be reached from any pointer in the runningprogram.
-
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。
10.2 为什么需要GC
-
对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,
因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫—样。 -
除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象。
-
随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STw的Gc又跟不上实际的需求,所以才会不断地尝试对Gc进行优化。
10.3 早期垃圾回收
在早期的C/C++时代,拉圾回收基本上是手工进行的。开发人员可以使用new关键字进行内存申请,并使用delete关键字进行内存释放。比如以下代码:
这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃
在有了垃圾回收机制后,上述代码块极有可能变成这样:
现在,除了Java以外,C#、Python、 Ruby等语言都使用了自动垃圾回收的思想,也是未来发展趋势。可以说,这种自动化的内存分配和垃圾回收的方式己经成为现代开发语言必备的标准。
10.4 Java垃圾回收机制
-
自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险
- 没有垃圾回收器,java也会和cpp一样,各种悬垂指针,野指针,泄露问题
让你头疼不已。
- 没有垃圾回收器,java也会和cpp一样,各种悬垂指针,野指针,泄露问题
-
自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发
-
oracle官 关于垃圾回收的介绍
https: / / docs.oracle.com/javase/8/docs/technotes/guides/
vm/gctuning/toc.html -
对于Java开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力。
-
此时,了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见outofMemoryError时,快速地根据错误异常日志定位问题和解决问题。
-
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。
-
垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是全堆和方法区的回收。
- 其中,Java堆是垃圾收集器的工作重点。
-
从次数上讲:
-
频繁收集Young区
-
较少收集o1d区
-
基本不动Perm区(或元空间 )
-
11、垃圾回收相关算法
对象存活判断
-
在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,Gc才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。
-
那么在JVM中究竟是如何标记一个死亡对象呢单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
-
判断对象存活一般有两种方式:引用计数算法和可达性分析算法。
11.1 标记阶段:引用计数算法
-
引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用
计数器属性。用于记录对象被引用的情况。 -
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效
时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。 -
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
-
缺点:
-
它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
-
每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
-
引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,
导致在Java的垃圾回收器中没有使用这类算法。
-
-
引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热
的Python,它更是同时支持引用计数和垃圾收集机制。 -
具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,
以提高吞吐量的尝试。 -
Java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处
理循环引用关系。 -
Python如何解决循环引用/p>
-
手动解除:很好理解,就是在合适的时机,解除引用关系。
-
使用弱引用weakref, weakref是Python提供的标准库,旨在解
决循环引用。
-
11.2 标记阶段:可达性分析算法
-
相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
-
相较于引用计数算法,这里的可达性分析就是Java、C#选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection) 。
-
所谓”GC Roots”根集合就是一组必须活跃的引用。
-
基本思路:
-
可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下
的方式搜索被根对象集合所连接的目标对象是否可达。 -
使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间
接连接着,搜索所走过的路径称为引用链(Reference Chain) -
如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
-
在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象
才是存活对象。
-
GC Roots
在 Java 语言中,GC Roots 包括以下几类元素:
-
虚拟机栈中引用的对象
- 比如:各个线程被调用的方法中使用到的参数、局部变量等。
-
本地方法栈内JNI(通常说的本地方法)引用的对象
-
方法区中类静态属性引用的对象
- 比如: Java类的引用类型静态变量
-
方法区中常量引用的对象
- 比如:字符串常量池(string Table)里的引用
-
所有被同步锁synchronied持有的对象
-
Java虚拟机内部的引用。
- 基本数据类型对应的class对象,一些常驻的异常对象(如:
NullPointerException、outofMemoryError),系统类加载器。
- 基本数据类型对应的class对象,一些常驻的异常对象(如:
-
反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
-
除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(Partial GC)。
- 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针
对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。
- 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针
-
小技巧:
- 由于Root 采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root
-
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
-
这点也是导致Gc进行时必须”stop The world”的一个重要原因。
- 即使是 称(几乎)不会发生停顿的cMS收集器中,枚举根节点时
也是必须要停顿的。
- 即使是 称(几乎)不会发生停顿的cMS收集器中,枚举根节点时
11.3 对象的 finalization 机制
-
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
-
当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize ()方法。
-
finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
-
永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用。理由包括下面三点:
-
在finalize ()时可能会导致对象复活。
-
finalize ()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生Gc,则finalize ()方法将没有执行机会。
-
一个糟糕的finalize()会严重影响Gc的性能。
-
-
从功能上来说,finalize ()方法与C++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质上不同于C++中的析构函数。
-
由于finalize ()方法的存在,虚拟机中的对象一般处于三种可能的状态。
生存还是死亡/p>
-
如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:
-
可触及的:从根节点开始,可以到达这个对象。
-
可复活的:对象的所有引用都被释放,但是对象有可能在finalize ()中复活。
-
不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触
及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。
-
-
以上3种状态中,是由于finalize ()方法的存在,进行的区分。只有在对象不可触及时才可以被回收。
具体过程:
-
判定一个对象objA是否可回收,至少要经历两次标记过程:
-
1.如果对象objA到GC Roots没有引用链,则进行第一次标记。
-
2.进行筛选,判断此对象是否有必要执行finalize ()方法
-
如果对象objA没有重写finalize()方法,或者finalize ()方法已经被虚拟机调用过,
则虚拟机视为“没有必要执行”,objA被判定为不可触及的。 -
如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F-Queue
队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize ()方法执行。 -
finalize()方法是对象逃脱死亡的最后机会,稍后cc会对F-Queue队列中的对象进行
第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次。
-
11.4 MAT与JProfiler的GC Roots朔源
11.5 清除阶段:标记-清除算法
垃圾清除阶段:
-
当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。
-
目前在JVM中比较常见的三种垃圾收集算法是标记一清除算法( Mark-Sweep )、复制算法( copying )、标记–压缩算法(Mark-Compact ) 。
背景:
- 标记–清除算法( Mark-Sweep )是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并并应用于Lisp语言。
执行过程:
当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。
-
标记:collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。
-
清除:collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
缺点
-
效率不算高
-
在进行cc的时候,需要停止整个应用程序,导致用户体验差
-
这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表
注意∶何为清除/p>
- 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。
11.6 清除阶段:复制算法
背景:
为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky于1963年发表了著名的论文,“使用双存储区的Lisp语言垃圾收集器cALISP Garbage collector Algorithm Using serial
Secondary storage ) ”。M.L.Minsky在该论文中描述的算法被人们称为复制(copying)算法,它也被M.L.Minsky本人成功地引入到了Lisp语言的一个实现版本中。
核心思想:
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。
优点:
-
没有标记和清除过程,实现简单,运行高效
-
复制过去以后保证空间的连续性,不会出现“碎片”问题。
缺点:
-
此算法的缺点也是很明显的,就是需要两倍的内存空间。
-
对于G1这种分拆成为大量region的Gc,复制而不是移动,意味着cc需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
特别的:
- 如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大,
或者说非常低才行。
11.7 清除阶段:标记-压缩算法
背景:
-
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
-
标记 – 清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。标记–压缩(Mark – compact)算法由此诞生。
1970年前后,G. L. steele .c. J. Chene 和D.s. wise 等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。
执行过程:
-
第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
-
第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间。
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。
二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
指针碰撞:
优点:
-
消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配
内存时,JVM只需要持有一个内存的起始地址即可。 -
消除了复制算法当中,内存减半的高额代价。
缺点:
-
从效率上来说,标记-整理算法要低于复制算法。
-
移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。·移动过程中,需要全程暂停用户应用程序。即:STw
11.8 小结
Mark-Sweep | Mark-Compact | Copying | |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少(会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的2倍大小 |
移动对象 | 否 | 是 | 是 |
11.9 分代收集算法
-
前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。
-
分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
-
在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的session对象、线程、socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如: string对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
目前几乎所有的gc都是采用分代收集(Generational collecting)算法执行垃圾回收的。
年轻代(Young Gen)
- 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。
老年代(Tenured Gen)
-
老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。-
Mark阶段的开销与存活对象的数量成正比。
-
Sweep阶段的开销与所管理区域的大小成正相关。
-
compact阶段的开销与存活对象的数据成正比。
-
以HotSpot中的cMs回收器为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高。而对于碎片问题,cMs采用基于Mark-Compact算法的serial old回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用serial old执行Full cc以达到对老年代内存的整理。
分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。
11.10 增量收集算法、分区算法
上述现有的算法,在垃圾回收过程中,应用软件将处于一种stop the world的状态。在stop the world状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental collecting)算法的诞生。
基本思想
-
如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
-
总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
缺点:
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
分区算法:
-
一般来说,在相同条件下,堆空间越大,一次Gc时所需要的时间就越长,有关Gc产生的停顿也越长。为了更好地控制cc产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次Gc所产生的停顿。
-
分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间region。
-
每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。
12 垃圾回收相关概念
12.1 System.gc()的理解
-
在默认情况下,通过system.gc()或者Runtime.getRuntime ( ).gc ()的调用,会显式触发Full Gc,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。
-
然而system.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用。
-
JVM实现者可以通过system.gc ()调用来决定JVM的Gc行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用system.gc ( )。
12.2 内存溢出与内存泄漏
内存溢出:
-
内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。
-
由于Gc一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现ooM的情况。
-
大多数情况下,Gc会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的Full Gc操作,这时候会回收大量的内存,供应用程序继续使用。
-
javadoc中对outOfMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。
首先说没有空闲内存的情况:说明Java虚拟机的堆内存不够。
原因有二:
(1) Java虚拟机的堆内存设置不够。
比如:可能存在内存泄漏问题;也很存可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JM堆大小或者指定数值偏小。我们可以通过参数-xms.-Xmx来调整。
(2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)对于老版本的oracle JDK,因为永久代的大小是有限的,并且JVM对永久代垃圾回收(如,常量池回收、 卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现outofMemoryError也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致ooM问题。对应的异常信息,会标记出来和永久代相关:“java.lang.outOfMemoryError: PermGen space”。
随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的ooM有所改观,出现0OM,异常信息则变成了:“java.lang.outofMemoryError: Metaspace”。直接内存不足,也会导致oOM。
-
这里面隐含着一层意思是,在抛出outOfMemoryEr
声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!