从字节码到GC那些你应该知道的Java虚拟机

JVM,即Java Virtual Machine。Java虚拟机(JVM)是可运行Java代码的假想计算机。只要根据JVM规格描述将解释器移植到特定的计算机上,就能保证经过编译的任何Java代码能够在该系统上运行。,一个完整的JVM包含的知识体系是很庞大的,例如下面的每一个章节包含的知识点完全可以写成一本厚厚的书籍。本文抽取JVM中的字节码、即时编译器、运行时数据区、对象内存布局、垃圾收集、常用参数等几个方面进行编写。基于篇幅有限,其他的例如:内存模型、类加载、多线程、反射、Javaagent、JVM性能监控等本文就不再赘述了,有兴趣的可以自行搜索相关资料。,Java是一门面向对象的高级语言,它从C++之上发展而来,在代码的编写风格上就很相似。但是不同于C++,Java编译后的产物不能被cpu直接运行,它是生成一种名为“字节码”的中间产物,然后再由不同机器上的JVM识别,翻译成本地cpu指令。,图片,Java之父詹姆斯·高斯林在设计Java的时候,便有一个雄心勃勃的计划“一次编写,到处运行”。为了解决这个问题,于是高斯林提出了字节码的概念,并且借助于和平台无关的“字节码”。实际上,通过字节码不仅能做到跨平台,还能做到跨语言相互调用(而Graal VM这个高科技虚拟机,在跨语言上,更进一步了,限于篇幅这里不多加赘述,有兴趣的可以自行搜索)。,虚拟机常见的实现方式有2种:基于栈和基于寄存器,典型的基于的栈虚拟机有oracle的HotSpot以及微软的.net CLR,而基于寄存器的虚拟机有LuaVM以及谷歌的DalvikVM,JVM采用的是基于栈的指令集架构。实际上2者各有自己的优缺点:,一条字节码指令包含一个操作码(opcode)以及随后跟随的0至多个操作数(operand)。虚拟机中的大部分指令并不包含操作数,只有一个操作码。操作码的大小固定为1个字节,这也限制了字节码的种类无法超过256个。通过限制操作码大小为1个字节,这样能尽可能的获得短小精悍的编译代码。,JVM解释器在解析字节码的时候工作流程类似如下这个样子:,绝大部分的字节码操作是和类型相关的,例如ireturn用于返回一个int类型数据,freturn用于返回一个float类型的数据。根据字节码的用途,这里大概分为这么些种类:,如果想“正确”实现一个的虚拟机其实并没有大家所想的那么高深和困难,只需要正确的去实现class文件的每一条字节码指令,并且能正确的执行这些指令所蕴含的操作即可。,Java的虚拟机的字节码执行系统是以栈为基础执行的,这里的栈即:操作数栈。当一个方法被调用的时候,需要在线程栈中创建一个名为栈帧的数据结构,一个方法栈帧包含 局部变量表、操作数栈、异常表、常量池引用等。,这里举一个很简单的例子,只有一行代码的方法,麻雀虽小,五脏俱全。我们可以通过JVM解释器的执行过程,来窥探从底层理解代码到底是如何运行的。,其对应的代码和字节码如下:,这里讲述这段代码是如何执行的,也就下编号为0~4的4行字节码执行过程(假设x=110)。,图片,图片,把局部变量表1号位置的值压栈,也就是变量x的值压入操作数栈。字节码中有很多类似于xxx_0,xxx_1这种指令,这种指令本身就携带操作数,等价于xxx 1,也就是说_1后面的1就是它的操作数。JVM这么干的目的就是减少类文件的体积,保证短小精悍!,图片,图片,图片,图片,实际上,为了方便我们更好的使用Java语言,Java会对我们的代码进行加工,而这种加工手段就被称之为语法糖。专业点的说法是:语法糖指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。,其中Java的语法糖包括:字符串拼接、Enum、switch-on-string、自动装箱拆箱、常量折叠、条件编译、assert、try-with-resourse、foreach、var声明变量、Lambda等等。篇幅有限,笔者下面挑选有代表意义的Lambda表达式,并且从字节码角度进行原理讲解,希望对大家有所帮助!,Java8的时候,引入了函数式接口和Lambda表达式,这一举动可以说是极大的提高我们的开发效率,从此以后,Java也是一门支持函数式编程的语言。,我们看下下面这段代码:,实际上这段代码是无法通过编译的,因为localInt变量没有定义为final。同时在这里,关于在Lambda表达式中引用外部方法的局部变量的这种写法,也引申了2个问题,读者可以试着思考并解答下:,想要回答这个问题,我们需要知道这么几个知识点:线程栈、栈帧(方法帧)、局部变量表,同一个线程的同一时间只有一个方法栈帧可以处于激活状态,每个方法执行的时候,只能访问自己方法栈帧的数据(局部变量表、操作数栈等),这里我们就可以回答上面的第一个问题了,localInt其实是func1方法的局部变量,也就意味着localInt的生命周期和调用func1方法所创建栈帧是保存一致的。而当add10被执行的时候(调用add10.call()),其实是在调用一个新的方法,也就是说会有一个新的方法栈帧,很明显,add10和func1不在同一个方法栈帧中执行,在add10运行的时候,func1的方法栈帧甚至都可能已经被销毁了。也就是说func1的localInt和add10的localInt处于不同的局部变量表中,言外之意,就是他两其实本质上并不是同一个变量(虽然2个名字是一样的^-^)!,Java语言强制要求localInt变量必须声明为final,原因的话,我觉得应该是为了保持数据的一致,因为如果不定义为final,这个字段被修改了,在其他方法是无法体现的;如add10中的localInt被修改了,在func1中的localInt根本不会有任何影响,因为这2个localInt压根就不是同一个变量,压根就没共享内存。其实localInt必须声明为final只是Java语言要求的,其他语言,例如C#就没这种要求。,到这里我们解答了上面的2个问题,又引入了一个新的问题,那就是既然func1的localInt和add10的localInt不是同一个变量,那么这个变量是怎么传值过去的呢?想回答这个问题,还是得借助 “字节码”。,我们改写下代码,以便通过编译:,使用命令javap查看字节码:,这段字节码表达的意义如下:其中方法LambdaMetafactory.metafactory是和invokedynamic指令有关的一个特殊方法。我们看到编译器自动生成了一个名为lambda$func1$0的方法,这个方法其实就是我们在func1里面定义的Lambda表达式,并把localInt变量按照参数传递给lambda$func1$0方法。因为Java方法参数仅支持按值传递,所以其实相当于把localInt复制了一份然后传递给了lambda$func1$0方法。上面字节码字节反编译的结果如下:,但是,上述反编译代码其实是不能正常运行的。真实过程其实是JVM运行时会生成一个新的内部类,而这个内部类本质上是由InnerClassLambdaMetafactory使用ASM字节码技术动态生成。如果我们添加启动参数:,再运行,运行时动态生成的类会出现在项目中的目录下,找到这个名为,Main$$Lambda$1的class文件,然后再并反编译,结果如下:,而下面这行代码:,被替换成了,为了方便展示结果,下面把相关代码写在一起,原始方法相当于被重写成了这个样子:,可以看到,其实Lambda表达式在运行期间,被转换为了一个“内部类”(注意和编译期间的内部类区分开来)。当然不同的虚拟机可以按照其想用的方式实现Lambda表达式,因为Java虚拟机规范并没有强制说一定只能用内部类来实现。如果想替换实现方式,只需要修改LambdaMetafactory.metafactory里面的逻辑即可,这种方式把Lambda的翻译策略从编译期推迟到运行时,并且未来的JDK版本如何实现Lambda方式可能还会有变化。,如果对字节码足够熟悉和理解,Java的各种语法的面纱将被撕开,变得不再神秘!,相对于那些直接编译成本地cpu的C、C++的语言而言,Java语言首先要将代码编译成字节码,然后再由虚拟机托管,翻译成cpu命令再运行,这种以二次编译(准确的来说,第二次编译的过程其实包含解释+编译)方式运行,总体而言的确是要比这种本地编译成cpu语言慢的。,在1996年1月23号的时候,sun公司推出了JDK的第一个版本JDK1.0。早些的时候,JVM还是采用纯解释的方式运行字节码,那个时候的Java运行慢的出奇,和同时期的C、C++等语言简直就是没法比。而到98年JDK1.2的时候才引入一个组件,并且这个组件在尽自己的努力优化代码,这个组件就是JIT(即时编译器)。有JIT的加持,使得Java的运行速度和C、C++等差距越来越小了。,特别的,对于某些代码,Java跑起来竟然要比C、C++要快:,先举一个样例,来见识见识JIT的威力吧!,我们来看下下面这段荒诞的代码:,我们在main方法内调用了loop方法,loop方法内部是一个循环,循环体调用doNothing方法,循环的次数是2147483647次,现在我们执行这段代码,查看结果:,仅需要3ms就执行21亿次多循环运算?不可思议!倒不是因为笔者电脑多强大,能执行这么快,得归功于JIT的优化手段。JIT在运行期间发现doNothing里面的代码x变量有赋值,但是未被使用,于是代码直接删除。接下来JIT对代码体积比较小的doNothing内联,空方法内联,直接删除调用,接下来,发现这个循环毫无作用,继续优化,这个循环被直接删掉。也就是说上面这段代码被优化之后loop方法实际上是一个空操作,啥都不做!,JIT中执行代码的时候,共有三层编译手段,分别是:,第0层:解释执行,第1层:使用client(c1)模式编译执行,第2层:使用server(c2)模式编译运行,其中第1层还可以继续细分成三层,总的而言如下图所示,(https://www.infoq.cn/article/java-10-jit-compiler-graal):,图片,第0层:解释执行,第1层:使用c1模式编译执行,不附带任何profiling(性能监控),第2层:使用c1模式编译运行,附带调用次&循环回边执行次数profiling,第3层:使用c1模式编译运行,附带所有profiling,第4层:使用c2模式编译运行,采用比较激进优化策略,附带profiling,其中C2代码执行效率要比C1高出30%以上,而C1模式下,按照执行的效率是1>2>3。5个层次中,第1层和第4层是终态。当一个方法被终止态编译之后,如果此代码没有失效,jvm是不会再次发出该方法的编译请求的。,一开始,JIT采用解释的方式,如果JIT发现某个方法是热点代码(这也是HotSpot虚拟机名字的由来),便会触发即时编译,生成本地cpu执行。JIT采用计数的方式统计代码执行次数,在C1模式下默认1500次触发,而在C2模式下,是10000次。,-Xint参数是用于指定让程序以解释的方式运行,而-client是直接指定使用C1模式运行,-server是采用C2模式运行。C2模式运行效率最高,优化过程也比较激进,缺点就是编译耗时会久一点,程序使用内存会更大(代码缓存)(控制台输入java -X能看到服务的的工作模式参数一览),所有的优化策略里面,首当其冲的便是“方法内联”,方法内联不仅可以给体积比较小的方法节省不小的额外开销,而且还是其他优化措施的先行条件。,笔者第一次接触这个名词还是在学习C++的时候,C++语法中,需要显式的使用inline关键字声明方法告诉编译器,这个方法代码体比较简单,需要编译器帮助做内联操作。现在到了Java,不存在什么inline关键字,内联由JIT触发,JIT会自动的将一些代码体积比较小的方法直接内联。那什么是方法内联呢?,先看这段代码,这是一种非常常见的写法,利用get、set方法封装字段:,但是,执行每个方法都有一些额外的开销,包括查找虚方法表、压栈、弹栈等操作,那么相对于只有一条很简单的访问字段语句的方法而言,这些额外的开销不能忽视。所以,JIT会将这种代码体积比较小直接替换代码调用处,以省去这些额外开销,也就是说:,经过方法内联后,这行代码会被变成这个样子,直接访问字段,跳过的调用方法的额外开销:,但是,想法很美好,Java实现内联还是有点问题的,那就是虚方法的内联,首先,解释下什么是虚方法:,无论是C++、C#还是Java,都是面向对象并且支持多态的,基类定义的方法可以被子类重写,而这些可以被子类重写的方法就是虚方法。,对于C++、C#语言而言,一个普通的成员方法不是虚方法,当我们显式使用virtual关键字声明的方法才会被当作虚方法,而Java是相反的,默认就是虚方法,当我们加上private或者final等访问限制后,才会变成非虚方法。,那么在Java里面有哪几种类型的方法是虚方法呢:,假设People有这么2个派生类:,call方法的参数people的getName方法有可能是调用People、Chinese、English的。JIT是不能直接判断出来到底用哪个类的方法进行内联,那么JIT就采取一种名为CHA(类型继承分析)的手段,JIT会分析现在已经加载好的所有类,查看People是否有派生类,派生类是否重写了getName方法,如果没有,说明People的getName方法仅只有一个,于是可以放心的做内联。但是这又有一个新的问题,因为Java是支持运行时动态加载类的,那万一运行时,有新加载的类重写了这个方法呢?JIT的结局方案是会在运行时监控类加载,如果发现People.getName方法被子类重写了,就会推翻当前编译过的代码,重新回到第0层,也就是解释执行。这个过程也叫逆优化。逆优化也是JVM相对而言一个比较强大的功能。,即使使用了CHA,但是还是有些问题,好比一个类有多个实现,好比我上面举的例子,JIT会使用一种名为内联缓存(Inline Cache)的优化措施。开始的时候缓存为空,那么第一次调用后,会缓存方法接受者的信息。并且每次调用都先比较此信息,看缓存是否命中,以减少查找虚方法表的性能损耗。,逃逸分析也是是JIT一个非常重要的优化手段,和CHA一样,并不是直接优化代码,而是为其他优化策略提供分析技术。,逃逸分析的基本原理是:分析在方法体内部创建的对象,是否被当作字段或者数组中的元素传入堆对象中,或者被当作参数传入其他方法,如果没有的话,称之为不逃逸。逃逸力度从不逃逸、方法逃逸、线程逃逸依次递增。,在Java里面,绝大多数的对象都是创建在堆中,而堆中的对象,是能被各个线程共享,但是如果能判断一个对象是非逃逸的,那么就可以直接在栈上给这个分配这个内存,当方法结束后,该对象自动销毁,这样能给堆减少一定的GC压力。不过由于比较复制等原因,这个优化措施在oracle的HotSpot虚拟机中暂时未实现。,栈上分配,这个可以参考C++;在C++中,如果直接创建一个对象(不使用new运算符)的话,这个对象也是分配于栈中的,当变量作用范围结束后,会自动调用析构方法并释放该对象内存,这个优化措施达到的结果和栈上分配类似,内存都是在栈中申请的,能减轻GC的工作量。那什么是标量呢?指的是一个数据已经无法再分解称更小的数据类型,例如Java的基本数据类型(int,long,reference等类型)。假设逃逸分析能证明一个对象不会被外部方法所访问,那么这个对象就可以被分解成一个一个的局部变量,也就是说这些字段的内存直接在栈上分配。就好比说:,经过标量替换再配合方法内联,会变成这个样子:,C#中使用struct定义的类型,被称做为“值类型”,例如,C#所有原生的基本类型(除了String),都是使用struct声明的值类型,值类型相对于使用class声明的“类”而言,是一种非常轻量级别的类型,其内存分配于栈,也就直接等价于JIT的标量替换,线程之间使用同步本身就是一个比较耗时的过程,那么如果JIT发现锁对象不会逃逸出当前线程,也就不会被其他线程访问,那么JIT就可以放心的直接删除这个无效的锁。,本文仅介绍JIT的部分优化策略,其实JIT包含很多很丰富的优化策略。好比:公共子表达式消除、数组边界检查消除、无效代码消除、循环展开、循环条件外提等,介于篇幅优先,不做介绍。有兴趣的可以查阅:​​https://wiki.openjdk.java.net/display/HotSpot/PerformanceTacticIndex​​,Java虚拟机规范里面定义的运行时数据区有:pc寄存器、Jvm栈、本地方法栈、Java堆、方法区、运行时常量池。我们常用的HotSpot虚拟机的内存除了上述之外,还有直接内存、元空间(JDK8之后的叫法)、压缩类空间、代码缓存等。,图片,其中VM栈、本地方法栈以及程序计数器是线程私有的,也就是说每一条线程就有一份,并且不同线程的这些内存是隔离的,不能相互访问,但是堆和方法区是共享的,不同的线程都可以访问这些内存。,堆在JVM启动的时候便已经创建好了,堆中的数据被各个线程共享。Java中创建的对象,均存放在堆中,包括使用关键字new、反序列化、Object.clone、Unsafe.allocateInstance、反射等创建的对象。(当然,此处不考虑JIT的一些优化策略,因为如果考虑栈上分配、标量替换等优化策略,创建出来的对象就不一定是在堆中分配内存了)。堆中的对象,由JVM进行管理,JVM的GC组件负责回收不再使用的对象。,和堆一样,方法区也是被各个线程所共享的区域。关于方法区,还有另外一种叫法no-heap(非堆)。方法区和传统语言中的编译存储区或者操作系统进程的正文段很相似,它存储了每一个类的结构信息,例如:运行常量池、字段、方法数据、构造方法以及普通方法的字节码等。,在Java中,线程使用的是操作系统的线程,也就是说一条Java线程会和一条操作系统的线程做一一映射关系,一条Java线程大小可以通过命令java -XX:+PrintFlagsFinal -version | grep “ThreadStackSize”查看。栈中方法调用一层套一层,每调用一层方法,便会创建一个栈帧,栈帧的大小在编译之后就确定了,这个栈帧的大小直接写入了类文件中,在运行时无需从新计算。在线程执行的某个时间点上,只有目前正在执行的那个方法的栈帧是活动的,这个栈帧也别称为当前栈帧,这个栈帧下的方法也被称为当前方法。对局部变量表和操作数栈的操作,都是针对于当前栈帧而言的。那么一个栈帧又具体包含那些数据呢?,局部变量表中包含方法参数以及声明的局部变量。关于局部变量表,我们就可以对比数组,把变量放在一个名为局部变量表的数组中。在方法执行的开始阶段,会执行“序幕”,所谓的序幕其实就是做一些准备工作,给局部变量表赋值。那么好比如下代码:,局部变量有s、d、o1,还有方法参数x、y,其实这里还漏了一个局部变量,那就是this,并且this总是位于局部变量表的0号位置,在每次发起方法调用的时候,this也需要当作参数传入方法,也就是说我们可以理解为:this引用其实相当于类的实例方法的隐藏的第一个方法参数。这里,我们执行javap -c -p -l class文件路径。,查看字节码:,图片,其中,局部变量表中,boolean、byte、char、short、int占用1个Slot,也就是说,长度低于int(4字节)的变量也和int类型变量一样,占用相同的内存大小,换句话说,就是boolean、byte、char、short在局部变量表中也是4个字节(它们不同于放在堆中内存大小,可不是1个字节或者2个字节哦~)。并且Java操作码中,并没有对应这些类型专门用于计算的操作码,他们会被当作int类型一样,使用和int一样的操作码。double和long占用2个Slot,但是在访问这些变量的时候,使用的是他们第一个Slot的索引位置。引用类型有点特殊,因为在32位虚拟机和64位虚拟机中(启用压缩指针)的时候,占用1Slot,而在64位虚拟机,不开启压缩指针的时候,占用2个Slot(以下所有图例为了方便,引用类型按照1Slot处理)。,这里有个示意图,用来表明这些变量是怎么存储的,值是多少:,图片,前面也提到了字节码是基于栈的,而这个栈指的就是操作数栈。,在执行代码的时候,变量不能凭空计算(i++这种代码除外),必须借助另一个数据结构,这就是“操作数栈”。操作数栈,如其名,是一个FILO的“栈”。执行字节码之前,会将操作数执行压栈操作,计算之后,会弹出栈,把执行结果再压入栈。,操作数栈的大小也是提前就计算好的,在编译成字节码的时候,大小就已经写入了类文件。,图片,我们可以看到每个catch块的第一个字节码都是astore_1。这是因为如果触发异常,这个异常会压入栈顶,通过astore_1字节码,取栈顶的异常,放入局部变量表索引为1的位置。,观察字节码,不难发现,在操作数里面有类似于#1、#2这种标记,实际上通过记录当前方法所在类的常量池引用,便可以将这些标记转换为一个实际的符号引用。,在Java语言中需要调用一些native的本地方法,这些方法都不是用Java语言编写,这个时候就会创建“本地栈”。,在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(比如:Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。,程序计数器本身是一个记录着当前线程所执行的字节码的行号指示器。,JAVA代码编译后的字节码在未经过JIT(实时编译器)编译前,其执行方式是通过“字节码解释器”进行解释执行。简单的工作原理为解释器读取装载入内存的字节码,按照顺序读取字节码指令。读取一个指令后,将该指令“翻译”成固定的操作,并根据这些操作进行分支、循环、跳转等流程。,对象的内存布局在Java虚拟机规范(Java虚拟机规范SE8)里面并没有做强制要求,不同的虚拟机可以以自己的方式给对象分配内存,在oracle的HotSpot虚拟机中,一个对象的内存被划分为了如下三块:,HotSpot是一种准确式内存管理虚拟机,也就是说虚拟机能在运行时候,准确的知道某个对象的类型,那么虚拟机是如何做到的呢?答案就在对象头里面。,每个对象除了自己基本的字段需要占用内存之外,还有一些额外的内存,那就是对象头和填充空白,其中对象头又被划分为三部分,类型指针、标记字(mark word)、数组长度(仅数组对象有)。,类型指针和普通的引用类型一样,在32位和64位虚拟机(未开启压缩指针)中,分别占有4个和8个字节大小,而在64位虚拟机中,如果开启压缩指针(默认开启)的话,占有4个字节。而正是因为对象头里面的类型指针的存在,使得jvm能在运行时刻准确的知道一个对象具体是什么类型。,标记字是用于存储一个对象运行时相关的数据,包括对象哈希值、GC分代年龄、锁状态、线程持有的锁、偏向线程Id、偏向时间戳等。其中内存的话,在32位和64位虚拟机中,分别占有4个和8个字节。,事实上,对象在运行时需要存储的空间很多,已经超过4个或者8个字节的范围,于是,标记字可以通过一个标志位来区分其他空间的作用,下面的是mark word的存储内容示意图(想知道详细点可以查看OpenJdk源码):,想知道详细点可以查看OpenJdk源码,如果一个对象是数组的话,对象头里面还有一个额外的内存,是用于存储数组大小的,数组创建后,可以通过这个值获取数组长度,占4个字节。,字段占有的内存大小和字段的类型密切相关,不同的类型大小不一样(基本数据类型大小和虚拟机位数没关系),引用类型(oop-Ordinary object pointer) 32位虚拟机4个字节;64位虚拟机,未开启指针压缩8个字节,开启4个字节,字段排列有三种方式(参数:-XX:FieldsAllocationStyle,默认是1),无论是上述哪一种方式排列,都会遵循如下两个规则,伪共享指的是2个不同的volatile字段被2个不同的线程访问,然后恰好这2个字段在同一个cpu缓存行内,就会无意之间影响彼此之间的性能。,使用jdk的@Contended注解标记字段,并使用-XX:-RestrictContended启动参数,就会在字段的前后填充一定的空白字符,这样就能让两个不同的字段位于不同的缓存行,从而达到提升性能的作用。,再执行,观察结果,发现在BaseClass.oopField2前后补上了128个空白字节,为了直观的表示一个对象内存排列方式,可以参考下面的图解,括号里面的是占用的字节数(64位虚拟机,开启指针压缩,默认的字段布局方式,启动参数:-XX:-RestrictContended),图片,从上面的代码输出结果就可以看出来,虚拟机会在对象之间或者对象末尾插入一些空白字节,目的是使对象的总字节数达到8*N,其中一个原因是考虑到了cpu缓存行,因为这样的话就能使得更多的将一个对象的字段恰好能放到一个缓存行内。,无论是什么语言编写的应用程序,都一定需要申请内存资源,用于存储计算的结果。而当申请的内存不再被使用的时候,又需要释放以便腾出这段内存给其他功能使用。这种简单的模式却又是导致编程错误的“元凶”之一。想想看,又有多少程序员忘记释放不再使用的内存,又有多少机器因为内存泄漏而频繁的宕机。,进行非托管编程的时候,这种bug往往比其他bug都要更严重,因为一般无法预测他们的后果或者发生时间。,当需要释放不使用的内存,往往需要先解决下面这3个问题:,上述这些操作,如果是C、C++等程序需要程序员手动进行判断,而往往这个工作比较繁琐和枯燥。而对于Java、C#等托管语言而言,这里就变得非常轻松,这些动作交给虚拟机即可。程序员只需要按照自己的需求手动创建对象即可;因为JVM有个专门的组件,GC(garbage collection)来完成上述的3个过程。,有意思的是,虽然不需要自己管理内存,但是往往Java工程师们往往对GC工作原理很感兴趣。而C、C++工程师却因为缺乏一套自动的垃圾回收机制,而导致在释放内存这里程序往往会出现严重错误。正如周志明在《深入理解Java虚拟机》里面说的一句话:,目前主流语言内存管理系统里面,判断对象是否存活的方式,都是通过“可达性分析算法”。如假设ABCDEFG均为Java中创建的对象:,图片,如果对象之间的引用满足如上图所示,那么里面标记黑色的那些对象(ABD)都是存活对象。一个对象是否存活,决定于从GC-Root开始,到对象之间是否存在一条完整的可达路径,如果是,那么就说明对象还在被使用,是不能被释放的。一般而言、不同的垃圾收集器在不同的阶段这个GC-Root都不一样,但是总体而言,有但不完全限定于下面这些会被当作根处理:,如:Universe,JNIHandles,ObjectSynchronizer,FlatProfiler,Management,JvmtiExport,SystemDictionary,StringTable等,某些垃圾收集器的某些阶段,会有一些临时加入的对象,如:G1的YGC或者mixed-GC的时候,Rset里面引用的对象也会被加入根,试着思考一个问题,Java里面的对象之间时可以相互引用,那么如果把这些对象当作一个整体来看,是什么数据结构呢?是的,答案是有向图。那么如果我们对“图”进行遍历,有哪2种方式呢?答案是BFS(广度优先遍历)和DFS(深度优先遍历)。类似CMS、G1、ZGC等垃圾收集器遍历对象采用的就是BFS/DFS,无论是DFS还是BFS进行遍历的时候,都会对对象着色:,例如下面就是G1的遍历算法(采用的是BFS),用一个专门的列表存储灰色的节点;最终H和J会被当作垃圾回收掉。,图片,类似于CMS和G1等并发垃圾收集器,在并发标记的时候 ,允许用户线程同时不受影响的执行,也就是说一边在做并发标记,一遍在更新引用的关系。这就好比你妈妈在清理房间的垃圾的时候,你一边还在往房间丢垃圾。并发标记过程中,存在2中情况,一种是本来是垃圾的标记成了存活对象,当作非垃圾处理了,这种没关系,本次GC没回收掉,等下次可以再回收掉,这种也被称之为“浮动垃圾”。而另外一种就不能忽视了,就是指本来不是垃圾,但是却被错误的标记成了垃圾处理,那么就会出现明明这个对象是可达的,还在被使用,但是已经被GC给回收掉了,这个就比较致命了。产生这种错误必须同时满足下面2个条件:,例如:一开始对象引用关系如下图所示:,图片,然后这个时候,往B节点下插入一个白色的没有遍历过的E节点,图片,然后再F节点遍历之前,删除F到E节点的引用关系。因为每批遍历的时候,都是从灰色节点开始遍历,但是F->E的引用关系被删除,导致E节点无法再继续遍历。然而从GC-Root->B->E这条路径又是可达的,也就是说E节点本身是存活的,但是颜色却又是白色,白色节点都是需要被GC回收的对象,这也就意味着GC会回收一个非垃圾对象,这是一个非常致命的问题。,图片,而解决这个问题的方案,就是破坏上述的2个必要条件之一即可。,对象分配直接关系到内存的使用效率、垃圾回收的效率,不同的分配策略也会影响对象的分配速度,从而影响Muataor的运行。,为Java对象分配内存等同于把一块确定大小的内存块从Java堆中划分出来。假设Java堆中的内存是绝对工整的,所有使用过的内存放在一边,没有使用过的放在另外一边,中间放着一个指针作为分界点的指示器,那么新对象分配的内存仅仅只是把指针往空闲空间方向挪动一段和对象大小相等的距离,这种方式又被称之为“指针加法-Bump The Pointer。,那如果堆里面的内存不是规整的,已使用的和未使用的内存交错在一起(如:C语言等)那就没有办法使用指针加法了,内部就必须维护一个列表,记录那些内存块是可用的,在分配内存的时候,需要遍历列表,查找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种方式又被称之为“空闲列表-Free List”。,对象从内堆中分配,但是堆却又是被所有线程共享的资源,所以申请内存这块需要进行加锁处理。但是加锁又会降低应用的并发度。这里JVM引入一个名为TLAB(Thread Local Allocation Buffer,线程本地分配缓存区)进行优化。具体做法是在堆里面为每条线程划分一块独立的内存,并且在线程里面保存TLAB的首尾地址,如果当前线程需要分配内存,优先从TLAB中分配,因为被划成线程私有的了,所以这里在分配的时候不需要锁定整个堆。虽然在TLAB内分配对象的时候,是无锁的,但是TLAB本身内存在申请的时候,还需要锁堆的。,不同的垃圾收集器因为本身算法不一致,在内存分配的时候还是有差异的,这里拿G1的做样例子,列举的对象分配全景流程图:,图片,如上图所示,对于G1而言,大体分为基于TLAB的快速分配和慢速分配;当不能成功分配对象的时候就会触发垃圾回收。,GC系统往往都很复杂,而且很多术语都是从英文中翻译过来的,讲垃圾收集器之前,为了方便理解,本文先对这些术语进行一些解释。,标记-清除:先对对象 进行标记,标记完成之后,同一回收未被标记的对象。此算法缺点就是会有大量的内存碎片,标记-复制:在标记-清除的基础上,再加一步,挪动存活对象到新位置,并且让存活的对象紧密靠在一起,标记-整理(压缩):如果存活对象多的话,标记-复制的效率就会比较低,所以,标记-整理在标记完毕之后,会移动存活对象往内存空间的一端移动,然后直接清除边界以外的内存,为了方便大家理解,笔者把目前HotSpot包含的的GC和特点整理一份列成一个表格:,虽然G1综合方面不如目前比较新的ZGC、Shenandoah。但是因为在目前市面上毕竟使用率比较高的还是JDK8,G1并不是JDK8中默认的垃圾收集器,但是想必基于G1的优点,绝大部分公司还是会选择G1(例如、本公司”得物”)。,当然介于文章篇幅有限,本文不会全方面讲述G1垃圾收集器,笔者会尽量从“精简”的角度来讲述G1。,分区(Heap Region,HR)或称堆分区,是G1堆和操作系统交互的最小管理单元。,图片,G1中分区大小固定整是1MB,2MB,4MB,8MB,16MB,32MB这几个选项之间。默认情况会自动分成2048个区,当然分区大小和数据均可自由指定。可以看到里面还包含一种特殊的区,就是大对象区,大对象区可以一次连续分配多个Region。什么是大对象呢?超过Region一半的对象即为大对象,直接放入大对象区。,试着思考这么一个问题,例如在YGC或者混合GC的时候,G1并不会回收全部的堆,但是会出现“非收集区对象”指向“收集区对象”这么一种现象,而非收集器因为不参与垃圾收集,所以总是存活对象,也就是说“相当于前面提到的GC-Root”。那么如果把所有的非收集区对象加入根,然后进行对象标记,显然,效率非常第下。所以需要一套机制来降低需要标记对象的数目。例如,在YGC的时候,全部的Y区都是收集区,但是所有的老年代和大对象区(因为大对象区也划分为了老年代,所以一下暂称统为老年代区)都不参与垃圾回收,那么在Y区的对象就会出现这么集中情况:,这里因为3里面的老年代不参与GC,所以这种情况不需要考虑。然后因为回收的是Y区,所以Y区指向Y区的对象也会被回收,也不需要考虑。所以这里就2有问题,如果有老年代指向的Y区间的话,这些对象就不能被回收,因为老年代对象不参与GC,老年代对象是存活的,所以导致老年代对象引用的对象也是存活的。关于如何找出有哪些Y区对象被老年代引用的对象,有两种算法:,遍历所有的老年代分区,并且每个区都按照单个字节维度进行遍历识别出来哪些对象,并且当作根,看有没有引用Y区(当然,因为JVM对象是对齐的,所以实际不需要一个一个字节的挪动)。但是这种方式非常消耗内存,效率很低下。,遍历Y区间中记录过的那些老年代区(也就是一个Y区被老年代引用过,就把这个老年代区的信息记录到这个Y区的数据结果中),并且为了防止一个一个字节的移动识别对象,用一个位为位来标识老年代中一大块内存中是否存在引用过Y区,如果位的值表明有引用的话,因为不确定着一大块内存具体是哪个对象引用了Y区,所以这个时候进需要堆这一大块内存进行一个一个字节的移动识别对象,并且判断这些对象是否有引向Y区间即可(这种做法优点类似咱们做核酸混管,10个人一个管,如果发现一个管中有阳,那么在对这10个管单个单个的校验具体哪几个是阳,这样下来,使用的费用降低了,因为被消耗的管的数目大幅度降低了,同样G1一个位标识一大块内存是否有引用关系的目的也是为了减少这些记录的内存消耗)。,G1采用的的方式是上面的b方案,这里用的数据结构也被称作为“记忆表(Remember Set),简称RSet”,RSet是一种抽象的数据结果,在G1里面记忆表的具体实现方式又被称之为“卡表”(2者的关系相当于HashMap和Map),卡表等同于一个数组,每一位记录老年代区512个字节是否有对象引用Y区间(或者其他老年代区),如果可能引用的存在,同时我们也就认为这张卡是脏的。在YGC的时候,如果发现有脏卡,也就意味着这512个字节的内存里有指向收集区的对象。但是不确定是512个字节中的哪部分,这个时候,只能按照一个对象一个对象(当然,内存对其关系,实际一次会挪动多个字节判断是否是对象)的扫描,看否有指向收集区,如果发现对象符合要求,那么此对象会被加入YGC的“根”。,RSet有2中方式进行记录,一种名为Point-In,一种名为Point-Out。例如有代码(假设objectA在A区,objectB在B区):,很想,标记算法的关键是自己有没有被其他对象引用。所以,如果想实现局部回收,RSet使用Point-In的方式效率更高。,需要分配内存的时候,如果发现剩余空间不能满足要分配的对象的时候,就会优先触发YGC(young GC)。由于大部分引用的对象都是朝生暮死的,所以绝大部分的新对象都是“垃圾”,存活对象很少,再加上“标记算法”仅标记存活对象,这也意味着一般而言,YGC可回收的内存可以更多,消耗的时间也会更少,例如:下面是一台机器的YGC日志,图片,花了40ms就清空了7136M的Y区空间,YGC都是非常给力的!特别是对于“互联网”应用,大部分对象在服务启动的时候都创建的差不多了,都是来了一个请求,然后临时创建一些对象,并且这些对象随着请求的结束而结束,所以大部分对象都无法存活到老年代。,虽然G1又被称之为并行GC,但是G1的大部分时候都是在发生YGC,而YGC却又是“并发”执行的,这也就意味着YGC过程中,是有STW的。,YGC大致流程如下:,尝试回收大对象,如果某个大对象所在的分区没有RSet引用,说明这个大对象已经死亡,可以直接回收,尝试扩展内存,参数有GCTimeRatio和GExpandByPercentOfAvailable来决定是否需要扩展,前者在G1中默认值是9,代表的意思是GC占用的时间必须小于1/(1+9),也就是10%,这个值在之前的垃圾收集器默认值是99。如果吞吐量不达标,就会尝试扩展内存,大小由GExpandByPercentOfAvailable决定,默认20,也就是未提交内存的20%,如果满足条件的,触发并发标记周期;判断方式是YGC之后,老年代内存占总内存超过一定阈值(参数-XX:InitiatingHeapOccupancyPercent决定,默认45%)触发。,Y区间的大部分都是垃圾,都存活不了几次GC,但是老年代对象就不一样了,既然存活到能到“老年代区”,说明对象肯定也不一般,是垃圾的概率也大大降低了;这也意味着对老年代的GC往往不如YGC那么给力,即是是回收同样大小的内存,花费的时间也远远要超过YGC。,例如,下面是一次并发标记所消耗的时间和处理过程:,图片,这里要注意的是,很多人容易在这一步发生误解,认为这里的清理会清理所有的老年代垃圾。在清理阶段正在清理的其实是“空闲的区”,也就是指的是那种全部都是垃圾的区;而其他区的那些垃圾并不会被清理,也不会拷贝任何存活的对象,因为这些清理动作被后置到了后续的mixed-gc里面了。,图片,在一次并发标记周期之后,会根据实际情况触发0~8(G1MixedGCCountTarget)次mixed-GC。一次mixed-gc也需要一次YGC作为前提,并且把存活的YGC对象加入根进行标记和回收。而决定一个老年代区是否能加入mixed-gc的前提条件是由G1MixedGCLiveThresholdPercent(默认85,表示85%)控制的,如果存活对象低于85%,就会加入mixed-gc的CSet。还有一个比较重要的值是G1HeapWastePercent,默认值是5,表示5%。也就是当前CSet可以回收的空间占总空间的比例大于5%才会开始混合GC。,如果想查看本地虚拟机支持的命令,可以使用下面的命令进行查看:,JVM的大部分参数虽然对于当前机器并不是一个最佳的值,但是往往是一边比较合理的理想值。大多数情况下并不需要进行参数修改调优,但是如果线上出现问题或者想彻底优化JVM的时候,还是需要知道有哪些常用参数&参数默认值&参数作用的。所以:这里主要列举一些笔者比较熟悉和常用的JVM参数和默认值和作用(涉及GC方面的这里列举常用的G1的参数):,应用的整体调优,没有一个通用的法制,而且大部分参数值在不同机器上最佳值也往往不一样。为了满足最大的吞吐量和最小的延迟,需要根据应用程序设置不同的参数。可以考虑从优化内存大小、引用处理、并发标记、YGC、回收频率、回收大小、停顿时间、TLAB、线程数、等各个方面优化。,一般而言,调优有三个比较关键的指标:,当然,除了在极端少的情况下,是很难同时满足如上3个指标的,3个指标只能选择2个进行调优,舍去其中1个指标。所以一般这里都是舍去内存(多给应用划一些内存,以便换取其他2个指标).,最后,全文都是自己手打的不容易,帮忙点个赞。,[1]:《深入理解Java虚拟机》 作者:周志明,[2]:《深入拆解Java虚拟机》 作者:郑雨迪,[3]:《JVM G1源码分析和调优》 作者:彭成寒,[4]:《Java虚拟机规范SE8版》 作者:Tim Lindholm、Frank Yellin等,[5]:《深入理解JVM 字节码》 作者:张亚,[6]:《Java 性能优化权威指南》 作者:Charlie Hunt、Binu John,[7]:《新一代垃圾回收器 ZGC设计与实现》 作者:彭成寒

文章版权声明

 1 原创文章作者:cmcc,如若转载,请注明出处: https://www.52hwl.com/17714.html

 2 温馨提示:软件侵权请联系469472785#qq.com(三天内删除相关链接)资源失效请留言反馈

 3 下载提示:如遇蓝奏云无法访问,请修改lanzous(把s修改成x)

 免责声明:本站为个人博客,所有软件信息均来自网络 修改版软件,加群广告提示为修改者自留,非本站信息,注意鉴别

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2023年3月5日 上午12:00
下一篇 2023年3月7日 下午10:34