一篇简单易懂的原理文章,让你把JVM玩弄与手掌之中
Posted dreamrecorder
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一篇简单易懂的原理文章,让你把JVM玩弄与手掌之中相关的知识,希望对你有一定的参考价值。
jvm原理
Java虚拟机是整个java平台的基石,是java技术实现硬件无关和操作系统无关的关键环节,是java语言生成极小体积的编译代码的运行平台,是保护用户机器免受恶意代码侵袭的保护屏障。JVM是虚拟机,也是一种规范,他遵循着冯·诺依曼体系结构的设计原理。冯·诺依曼体系结构中,指出计算机处理的数据和指令都是二进制数,采用存储程序方式不加区分的存储在同一个存储器里,并且顺序执行,指令由操作码和地址码组成,操作码决定了操作类型和所操作的数的数字类型,地址码则指出地址码和操作数。从dos到window8,从unix到ubuntu和CentOS,还有MACOS等等,不同的操作系统指令集以及数据结构都有着差异,而JVM通过在操作系统上建立虚拟机,自己定义出来的一套统一的数据结构和操作指令,把同一套语言翻译给各大主流的操作系统,实现了跨平台运行,可以说JVM是java的核心,是java可以一次编译到处运行的本质所在。
一、JVM的组成和运行原理
JVM的毕竟是个虚拟机,是一种规范,虽说符合冯诺依曼的计算机设计理念,但是他并不是实体计算机,所以他的组成也不是什么存储器,控制器,运算器,输入输出设备。在我看来,JVM放在运行在真实的操作系统中表现的更像应用或者说是进程,他的组成可以理解为JVM这个进程有哪些功能模块,而这些功能模块的运作可以看做是JVM的运行原理。JVM有多种实现,例如Oracle的JVM,HP的JVM和IBM的JVM等,而在本文中研究学习的则是使用最广泛的Oracle的HotSpot JVM。
1.JVM在JDK中的位置。
JDK是java开发的必备工具箱,JDK其中有一部分是JRE,JRE是JAVA运行环境,JVM则是JRE最核心的部分。
从最底层的位置可以看出来JVM有多重要,而实际项目中JAVA应用的性能优化,OOM等异常的处理最终都得从JVM这儿来解决。HotSpot是Oracle关于JVM的商标,区别于IBM,HP等厂商开发的JVM。Java HotSpot Client VM和Java HotSpot Server VM是JDK关于JVM的两种不同的实现,前者可以减少启动时间和内存占用,而后者则提供更加优秀的程序运行速度。
2.JVM的组成
JVM由4大部分组成:ClassLoader,Runtime Data Area,Execution Engine,Native Interface。
2.1.ClassLoader是负责加载class文件,class文件在文件开头有特定的文件标示,并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。
2.2.Native Interface是负责调用本地接口的。他的作用是调用不同语言的接口给JAVA用,他会在Native Method Stack中记录对应的本地方法,然后调用该方法时就通过Execution Engine加载对应的本地lib。原本多于用一些专业领域,如JAVA驱动,地图制作引擎等,现在关于这种本地方法接口的调用已经被类似于Socket通信,WebService等方式取代。
2.3.Execution Engine是执行引擎,也叫Interpreter。Class文件被加载后,会把指令和数据信息放入内存中,Execution Engine则负责把这些命令解释给操作系统。
2.4.Runtime Data Area则是存放数据的,分为五部分:Stack,Heap,Method Area,PCRegister,Native Method Stack。几乎所有的关于java内存方面的问题,都是集中在这块。
可以看出它把Method Area化为了Heap的一部分,基于网上的资料大部分认为Method Area是Heap的逻辑区域,但这取决于JVM的实现者,而HotSpot JVM中把Method Area划分为非堆内存,显然是不包含在Heap中的。下图是javacodegeeks.com中,2014年9月刊出的一片博文中关于RuntimeData Area的划分,其中指出,NonHeap包含PermGen和Code Cache,PermGen包含MethodArea,而且PermGen在JAVA SE 8中已经不再用了。查阅资料得知,java8中PermGen已经从JVM中移除并被MetaSpace取代,java8中也不会见到OOM:PermGen Space的异常。目前Runtime Data Area可以用下图描述它的组成:
2.4.1.Stack是java栈内存,它等价于C语言中的栈,栈的内存地址是不连续的,每个线程都拥有自己的栈。栈里面存储着的是StackFrame,在《JVM Specification》中文版中被译作java虚拟机框架,也叫做栈帧。栈帧包含三类信息:局部变量,执行环境,操作数栈。局部变量用来存储一个类的方法中所用到的局部变量。执行环境用于保存解析器对于java字节码进行解释过程中需要的信息,包括:上次调用的方法、局部变量指针和操作数栈的栈顶和栈底指针。操作数栈用于存储运算所需要的操作数和结果,它被设计为一个后进先出的栈。StackFrame在方法被调用时创建,在某个线程中,某个时间点上,只有一个框架是活跃的,该框架被称为Current Frame,而框架中的方法被称为Current Method,其中定义的类为Current Class。局部变量和操作数栈上的操作总是引用当前框架。当StackFrame中方法被执行完之后,或者调用别的StackFrame中的方法时,则当前栈变为另外一个StackFrame。Stack的大小是由两种类型,固定和动态的,动态类型的栈可以按照线程的需要分配。
2.4.2.Heap是用来存放对象信息的,和Stack不同,Stack代表着一种运行时的状态。换句话说,栈是运行时单位,解决程序该如何执行的问题,而堆是存储的单位,解决数据存储的问题。Heap是伴随着JVM的启动而创建,负责存储所有对象实例和数组的。堆的存储空间和栈一样是不需要连续的,它分为Young Generation和Old Generation(也叫Tenured Generation)两大部分。YoungGeneration分为Eden和Survivor,Survivor又分为From Space和 ToSpace。
和Heap经常一起提及的概念是PermanentSpace,它是用来加载类对象的专门的内存区,是非堆内存,和Heap一起组成JAVA内存,它包含MethodArea区(在没有CodeCache的HotSpotJVM实现里,则MethodArea就相当于GenerationSpace)。在JVM初始化的时候,我们可以通过参数来分别指定,PermanentSpace的大小、堆的大小、以及Young Generation和Old Generation的比值、Eden区和From Space的比值,从而来细粒度的适应不同JAVA应用的内存需求。
2.4.3.PC Register是程序计数寄存器,每个JAVA线程都有一个单独的PC Register,他是一个指针,由Execution Engine读取下一条指令。如果该线程正在执行java方法,则PC Register存储的是正在执行的java指令操作码(例如iadd、ladd等),主要它只存储操作码,当java指令执行时,会从PC Register读取操作码,然后在操作数栈中取对应的操作数,如果是本地方法,PC Register的值没有定义。PC寄存器非常小,只占用一个字宽,可以持有一个returnAdress或者特定平台的一个指针。
2.4.4.Method Area在HotSpot JVM的实现中属于非堆区,非堆区包括两部分:Permanet Generation和Code Cache,而Method Area属于Permanert Generation的一部分。Permanent Generation用来存储类信息,比如说:classdefinitions,structures,methods, field, method (data and code) 和 constants。Code Cache用来存储Compiled Code,即编译好的本地代码,在HotSpot JVM中通过JIT(Just In Time) Compiler生成,JIT是即时编译器,他是为了提高指令的执行效率,把字节码文件编译成本地机器代码,如下图:
引用一个经典的案例来理解Stack,Heap和Method Area的划分,就是Sring a=”xx”;Stirngb=”xx”,问是否a==b? 首先==符号是用来判断两个对象的引用地址是否相同,而在上面的题目中,a和b按理来说申请的是Stack中不同的地址,但是他们指向Method Area中Runtime Constant Pool的同一个地址,按照网上的解释,在a赋值为“xx”时,会在RuntimeContant Pool中生成一个String Constant,当b也赋值为“xx”时,那么会在常量池中查看是否存在值为“xx”的常量,存在的话,则把b的指针也指向“xx”的地址,而不是新生成一个String Constant。我查阅了网络上大家关于String Constant的存储的说说法,存在略微差别的是,它存储在哪里,有人说Heap中会分配出一个常量池,用来存储常量,所有线程共享它。而有人说常量池是Method Area的一部分,而Method Area属于非堆内存,那怎么能说常量池存在于堆中?
我认为,其实两种理解都没错。Method Area的确从逻辑上讲可以是Heap的一部分,在某些JVM实现里从堆上开辟一块存储空间来记录常量是符合JVM常量池设计目的的,所以前一种说法没问题。对于后一种说法,HotSpot JVM的实现中的确是把方法区划分为了非堆内存,意思就是它不在堆上。我在HotSpot JVM做了个简单的实验,定义多个常量之后,程序抛出OOM:PermGen Space异常,印证了JVM实现中常量池是在Permanent Space中的说法。JDK1.7中InternedStrings已经不再存储在PermanentSpace中,而是放到了Heap中;JDK8中PermanentSpace已经被完全移除,InternedStrings也被放到了MetaSpace中(如果出现内存溢出,会报OOM:MetaSpace)。
所以在oracle hotspot 1.7中,PermGen Space是非堆内存,方法区属于PermGen Space,而运行时常量池是方法区的一部分。
2.4.5.Native MethodStack是供本地方法(非java)使用的栈。每个线程持有一个Native Method Stack。
3.JVM的运行原理简介
Java 程序被javac工具编译为.class字节码文件之后,我们执行java命令,该class文件便被JVM的ClassLoader加载,可以看出JVM的启动是通过JAVA Path下的java.exe或者java进行的。JVM的初始化、运行到结束大概包括这么几步:
调用操作系统API判断系统的CPU架构,根据对应CPU类型寻找位于JRE目录下的/lib/jvm.cfg文件,然后通过该配置文件找到对应的jvm.dll文件(如果我们参数中有-server或者-client, 则加载对应参数所指定的jvm.dll,启动指定类型的JVM),初始化jvm.dll并且挂接到JNIENV结构的实例上,之后就可以通过JNIENV实例装载并且处理class文件了。class文件是字节码文件,它按照JVM的规范,定义了变量,方法等的详细信息,JVM管理并且分配对应的内存来执行程序,同时管理垃圾回收。直到程序结束,一种情况是JVM的所有非守护线程停止,一种情况是程序调用System.exit(),JVM的生命周期也结束。
二、JVM的内存管理和垃圾回收
JVM中的内存管理主要是指JVM对于Heap的管理,这是因为Stack,PCRegister和Native Method Stack都是和线程一样的生命周期,在线程结束时自然可以被再次使用。虽然说,Stack的管理不是重点,但是也不是完全不讲究的。
1.栈的管理
JVM允许栈的大小是固定的或者是动态变化的。在Oracle的关于参数设置的官方文档中有关于Stack的设置,是通过-Xss来设置其大小。关于Stack的默认大小对于不同机器有不同的大小,并且不同厂商或者版本号的jvm的实现其大小也不同,如下表是HotSpot的默认大小:
我们一般通过减少常量,参数的个数来减少栈的增长,在程序设计时,我们把一些常量定义到一个对象中,然后来引用他们可以体现这一点。另外,少用递归调用也可以减少栈的占用因为栈帧中会存储父栈帧,递归会导致父栈帧也在存或状态,所以如果递归调用过深就会导致栈内存被大量占用,甚至出现StackOverFlow。栈是不需要垃圾回收的,尽管说垃圾回收是java内存管理的一个很热的话题,栈中的对象如果用垃圾回收的观点来看,他永远是live状态,是可以reachable的,所以也不需要回收,他占有的空间随着Thread的结束而释放。
关于栈一般会发生以下两种异常:
1.当线程中的计算所需要的栈超过所允许大小时,会抛出StackOverflowError。
2.当Java栈试图扩展时,没有足够的存储器来实现扩展,JVM会报OutOfMemoryError。
另外栈上有一点得注意的是,对于本地代码调用,可能会在栈中申请内存,比如C调用malloc(),而这种情况下,GC是管不着的,需要我们在程序中,手动管理栈内存,使用free()方法释放内存。
2.堆的管理
上图是 Heap和PermanentSapce的组合图,其中 Eden区里面存着是新生的对象,From Space和To Space中存放着是每次垃圾回收后存活下来的对象,所以每次垃圾回收后,Eden区会被清空。 存活下来的对象先是放到From Space,当From Space满了之后移动到To Space。当To Space满了之后移动到Old Space。Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从Eden复制过来 对象,和从前一个Survivor复制过来的对象,而复制到年老区的只有从第一个Survivor复制过来的对象。而且,Survivor区总有一个是空的。同时,根据程序需要,jvm提供对Survivor区复制次数的配置(-XX:MaxTenuringThreshold参数),即经过多少次复制后仍然存活的对象会被放到老年区,通过增多两个Survivor区复制的次数可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。
Old Space中则存放生命周期比较长的对象,而且有些比较大的新生对象也放在Old Space中,通过-XX:PretenureSizeThreshold设置,超过此大小的新生对象会直接放入老年区。
堆的大小通过-Xms和-Xmx来指定最小值和最大值,通过-Xmn来指定Young Generation的大小(一些老版本也用-XX:NewSize指定), 即上图中的Eden加FromSpace和ToSpace的总大小。然后通过-XX:NewRatio来指定Eden区的大小,在Xms和Xmx相等的情况下,该参数不需要设置。通过-XX:SurvivorRatio来设置Eden和一个Survivor区的比值。
堆异常分为两种,一种是Out ofMemory(OOM),一种是Memory Leak(ML)。MemoryLeak最终将导致OOM。实际应用中表现为:从Console看,内存监控曲线一直在顶部,程序响应慢,从线程看,大部分的线程在进行GC,占用比较多的CPU,最终程序异常终止,报OOM。OOM发生的时间不定,有短的一个小时,有长的10天一个月的。关于异常的处理,确定OOM/ML异常后,一定要注意保护现场,可以dump heap,如果没有现场则开启GCFlag收集垃圾回收日志,然后进行分析,确定问题所在。如果问题不是ML的话,一般通过增加Heap,增加物理内存来解决问题,是的话,就修改程序逻辑。
在此我向大家推荐一个架构学习交流群。交流学习群号:575745314 里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多
3.垃圾回收
JVM中会在以下情况触发回收:对象没有被引用,作用域发生未捕捉异常,程序正常执行完毕,程序执行了System.exit(),程序发生意外终止。
JVM中标记垃圾使用的算法是一种根搜索算法。简单的说,就是从一个叫GC Roots的对象开始,向下搜索,如果一个对象不能达到GC Roots对象的时候,说明它可以被回收了。这种算法比一种叫做引用计数法的垃圾标记算法要好,因为它避免了当两个对象啊互相引用时无法被回收的现象。
JVM中对于被标记为垃圾的对象进行回收时又分为了一下3种算法:
1.标记清除算法,该算法是从根集合扫描整个空间,标记存活的对象,然后在扫描整个空间对没有被标记的对象进行回收,这种算法在存活对象较多时比较高效,但会产生内存碎片。
2.复制算法,该算法是从根集合扫描,并将存活的对象复制到新的空间,这种算法在存活对象少时比较高效。
3.标记整理算法,标记整理算法和标记清除算法一样都会扫描并标记存活对象,在回收未标记对象的同时会整理被标记的对象,解决了内存碎片的问题。
JVM中,不同的 内存区域作用和性质不一样,使用的垃圾回收算法也不一样,所以JVM中又定义了几种不同的垃圾回收器(图中连线代表两个回收器可以同时使用):
1.Serial GC。从名字上看,串行GC意味着是一种单线程的,所以它要求收集的时候所有的线程暂停。这对于高性能的应用是不合理的,所以串行GC一般用于Client模式的JVM中。
2.ParNew GC。是在SerialGC的基础上,增加了多线程机制。但是如果机器是单CPU的,这种收集器是比SerialGC效率低的。
3.Parrallel ScavengeGC。这种收集器又叫吞吐量优先收集器,而吞吐量=程序运行时间/(JVM执行回收的时间+程序运行时间),假设程序运行了100分钟,JVM的垃圾回收占用1分钟,那么吞吐量就是99%。ParallelScavenge GC由于可以提供比较不错的吞吐量,所以被作为了server模式JVM的默认配置。
4.ParallelOld是老生代并行收集器的一种,使用了标记整理算法,是JDK1.6中引进的,在之前老生代只能使用串行回收收集器。
5.Serial Old是老生代client模式下的默认收集器,单线程执行,同时也作为CMS收集器失败后的备用收集器。
6.CMS又称响应时间优先回收器,使用标记清除算法。他的回收线程数为(CPU核心数+3)/4,所以当CPU核心数为2时比较高效些。CMS分为4个过程:初始标记、并发标记、重新标记、并发清除。
7.GarbageFirst(G1)。比较特殊的是G1回收器既可以回收Young Generation,也可以回收Tenured Generation。它是在JDK6的某个版本中才引入的,性能比较高,同时注意了吞吐量和响应时间。
对于垃圾收集器的组合使用可以通过下表中的参数指定:
默认的GC种类可以通过jvm.cfg或者通过jmap dump出heap来查看,一般我们通过jstat -gcutil [pid] 1000可以查看每秒gc的大体情况,或者可以在启动参数中加入:-verbose:gc-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:./gc.log来记录GC日志。
GC中有一种情况叫做Full GC,以下几种情况会触发Full GC:
1.Tenured Space空间不足以创建打的对象或者数组,会执行FullGC,并且当FullGC之后空间如果还不够,那么会OOM:java heap space。
2.Permanet Generation的大小不足,存放了太多的类信息,在非CMS情况下回触发FullGC。如果之后空间还不够,会OOM:PermGen space。
3.CMS GC时出现promotion failed和concurrent mode failure时,也会触发FullGC。promotion failed是在进行Minor GC时,survivor space放不下、对象只能放入旧生代,而此时旧生代也放不下造成的;concurrentmode failure是在执行CMS GC的过程中同时有对象要放入旧生代,而此时旧生代空间不足造成的,因为CMS是并发执行的,执行GC的过程中可能也会有对象被放入旧生代。
4.判断MinorGC后,要晋升到TenuredSpace的对象大小大于TenuredSpace的大小,也会触发FullGC。
可以看出,当FullGC频繁发生时,一定是内存出问题了。
三、JVM的数据格式规范和Class文件
1.数据类型规范
依据冯诺依曼的计算机理论,计算机最后处理的都是二进制的数,而JVM是怎么把java文件最后转化成了各个平台都可以识别的二进制呢?JVM自己定义了一个抽象的存储数据单位,叫做Word。一个字足够大以持有byte、char、short、int、float、reference或者returnAdress的一个值,两个字则足够持有更大的类型long、double。它通常是主机平台一个指针的大小,如32位的平台上,字是32位。
同时JVM中定义了它所支持的基本数据类型,包括两部分:数值类型和returnAddress类型。数值类型分为整形和浮点型。
整形:
returnAddress类型的值是Java虚拟机指令的操作码的指针。
对比java的基本数据类型,jvm的规范中没有boolean类型。这是因为jvm中堆boolean的操作是通过int类型来进行处理的,而boolean数组则是通过byte数组来进行处理。
至于String,我们知道它存储在常量池中,但他不是基本数据类型,之所以可以存在常量池中,是因为这是JVM的一种规定。如果查看String源码,我们就会发现,String其实就是一个基于基本数据类型char的数组。
2.字节码文件
通过字节码文件的格式我们可以看出jvm是如何规范数据类型的。下面是ClassFile的结构:
其中u1、u2、u4分别代表1、2、4个字节无符号数。
magic:
魔数,魔数的唯一作用是确定这个文件是否为一个能被虚拟机所接受的Class文件。魔数值固定为0xCAFEBABE,不会改变。
minor_version、major_version:
分别为Class文件的副版本和主版本。它们共同构成了Class文件的格式版本号。不同版本的虚拟机实现支持的Class文件版本号也相应不同,高版本号的虚拟机可以支持低版本的Class文件,反之则不成立。
constant_pool_count:
常量池计数器,constant_pool_count的值等于constant_pool表中的成员数加1。
constant_pool[]:
常量池,constant_pool是一种表结构,它包含Class文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其它常量。常量池不同于其他,索引从1开始到constant_pool_count -1。
access_flags:
访问标志,access_flags是一种掩码标志,用于表示某个类或者接口的访问权限及基础属性。access_flags的取值范围和相应含义见下表:
this_class:
类索引,this_class的值必须是对constant_pool表中项目的一个有效索引值。constant_pool表在这个索引处的项必须为CONSTANT_Class_info类型常量,表示这个Class文件所定义的类或接口。
super_class:
父类索引,对于类来说,super_class的值必须为0或者是对constant_pool表中项目的一个有效索引值。如果它的值不为0,那constant_pool表在这个索引处的项必须为CONSTANT_Class_info类型常量,表示这个Class文件所定义的类的直接父类。当然,如果某个类super_class的值是0,那么它必定是java.lang.Object类,因为只有它是没有父类的。
interfaces_count:
接口计数器,interfaces_count的值表示当前类或接口的直接父接口数量。
interfaces[]:
接口表,interfaces[]数组中的每个成员的值必须是一个对constant_pool表中项目的一个有效索引值,它的长度为interfaces_count。每个成员interfaces[i] 必须为CONSTANT_Class_info类型常量。
fields_count:
字段计数器,fields_count的值表示当前Class文件fields[]数组的成员个数。
fields[]:
字段表,fields[]数组中的每个成员都必须是一个fields_info结构的数据项,用于表示当前类或接口中某个字段的完整描述。
methods_count:
方法计数器,methods_count的值表示当前Class文件methods[]数组的成员个数。
methods[]:
方法表,methods[]数组中的每个成员都必须是一个method_info结构的数据项,用于表示当前类或接口中某个方法的完整描述。
attributes_count:
属性计数器,attributes_count的值表示当前Class文件attributes表的成员个数。
attributes[]:
属性表,attributes表的每个项的值必须是attribute_info结构。
3.jvm指令集
在Java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。举个例子,iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。这两条指令的操作可能会是由同一段代码来实现的,但它们必须拥有各自独立的操作符。
对于大部分为与数据类型相关的字节码指令,他们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。也有一些指令的助记符中没有明确的指明操作类型的字母,例如arraylength指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。还有另外一些指令,例如无条件跳转指令goto则是与数据类型无关的。
由于Java虚拟机的操作码长度只有一个字节,所以包含了数据类型的操作码对指令集的设计带来了很大的压力(只有256个指令):如果每一种与数据类型相关的指令都支持Java虚拟机所有运行时数据类型的话,那恐怕就会超出一个字节所能表示的数量范围了。因此,Java虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持它,换句话说,指令集将会故意被设计成非完全独立的(Not Orthogonal,即并非每种数据类型和每一种操作都有对应的指令)。有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可被支持的类型。
通过查阅jvm指令集和其对应的数据类型的关系发现,大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。编译器会在编译期或运行期会将byte和short类型的数据带符号扩展(Sign-Extend)为相应的int类型数据,将boolean和char类型数据零位扩展(Zero-Extend)为相应的int类型数据。与之类似的,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。因此,大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的对int类型作为运算类型(Computational Type)。
四、一个java类的实例分析
为了了解JVM的数据类型规范和内存分配的大体情况,下面举个简单的例子来说明一下ClassFile的结构:
通过javap工具我们能看到这个简单的类的结构,如下:
我们可以看到一些信息包括主副版本号、常量池、ACC_FLAGS等,再来打开Class文件看一下:
根据前面所述的ClassFile结构,我们来分析下:
可以看到前4个字节为魔数,也就是0xCAFEBABE,这里都是十六进制。
魔数后2个字节为副版本号,这里副版本号是0.
再后2个字节是主版本号0x0033,转为十进制,主版本号是51,和Javap工具所看到的一样,这里我用的JDK版本是1.7。
这两个字节是常量池计数器,常量池的数量为0x0017,转为十进制是23,也就是说常量池的索引为1~22,这与Javap所看到的也相符。
常量池计数器后面就是常量池的内容,我们根据javap所看到的信息找到最后一个常量池项java/lang/Object,在字节码中找到对应的地方:
常量池后面两个字节是访问标志access_flags:
值为0x0021,在javap中我们看到这个类的标志是
其中ACC_PUBLIC的值为0x0001,ACC_SUPER的值为0x0020,与字节码是相匹配的。
至于ClassFile的其他结构,包括this_class、super_class、接口计数器、接口等等都可以通过同样的方法进行分析,这里就不再多说了。
在此我向大家推荐一个架构学习交流群。交流学习群号:575745314 里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多
五、关于jvm优化
不管是YGC还是Full GC,GC过程中都会对导致程序运行中中断,正确的选择不同的GC策略,调整JVM、GC的参数,可以极大的减少由于GC工作,而导致的程序运行中断方面的问题,进而适当的提高Java程序的工作效率。但是调整GC是以个极为复杂的过程,由于各个程序具备不同的特点,如:web和GUI程序就有很大区别(Web可以适当的停顿,但GUI停顿是客户无法接受的),而且由于跑在各个机器上的配置不同(主要cup个数,内存不同),所以使用的GC种类也会不同。
1. gc策略
现在比较常用的是分代收集(generational collection,也是SUN VM使用的,J2SE1.2之后引入),即将内存分为几个区域,将不同生命周期的对象放在不同区域里:younggeneration,tenured generation和permanet generation。绝大部分的objec被分配在young generation(生命周期短),并且大部分的object在这里die。当younggeneration满了之后,将引发minor collection(YGC)。在minor collection后存活的object会被移动到tenured generation(生命周期比较长)。最后,tenured generation满之后触发major collection。major collection(Full gc)会触发整个heap的回收,包括回收young generation。permanet generation区域比较稳定,主要存放classloader信息。
young generation有eden、2个survivor 区域组成。其中一个survivor区域一直是空的,是eden区域和另一个survivor区域在下一次copy collection后活着的objecy的目的地。object在survivo区域被复制直到转移到tenured区。
我们要尽量减少 Full gc 的次数(tenuredgeneration一般比较大,收集的时间较长,频繁的Full gc会导致应用的性能收到严重的影响)。
JVM(采用分代回收的策略),用较高的频率对年轻的对象(young generation)进行YGC,而对老对象(tenuredgeneration)较少(tenuredgeneration 满了后才进行)进行Full GC。这样就不需要每次GC都将内存中所有对象都检查一遍。
GC不会在主程序运行期对PermGen Space进行清理,所以如果你的应用中有很多CLASS(特别是动态生成类,当然permgen space存放的内容不仅限于类)的话,就很可能出现PermGen Space错误。
2. 内存申请过程
1.JVM会试图为相关Java对象在Eden中初始化一块内存区域;
2.当Eden空间足够时,内存申请结束。否则到下一步;
3.JVM试图释放在Eden中所有不活跃的对象(minor collection),释放后若Eden空间4.仍然不足以放入新对象,则试图将部分Eden中活跃对象放入Survivor区;
5.Survivor区被用来作为Eden及old的中间交换区域,当OLD区空间足够时,Survivor区的对象会被移到Old区,否则会被保留在Survivor区;
6.当old区空间不够时,JVM会在old区进行major collection;
7.完全垃圾收集后,若Survivor及old区仍然无法存放从Eden复制过来的部分对象,导致JVM无法在Eden区为新对象创建内存区域,则出现"Out of memory错误";
3.性能考虑
对于GC的性能主要有2个方面的指标:吞吐量throughput(工作时间不算gc的时间占总的时间比)和暂停pause(gc发生时app对外显示的无法响应)。
1.Total Heap
默认情况下,vm会增加/减少heap大小以维持free space在整个vm中占的比例,这个比例由MinHeapFreeRatio和MaxHeapFreeRatio指定。
一般而言,server端的app会有以下规则:
对vm分配尽可能多的memory;
将Xms和Xmx设为一样的值。如果虚拟机启动时设置使用的内存比较小,这个时候又需要初始化很多对象,虚拟机就必须重复地增加内存。
处理器核数增加,内存也跟着增大。
2.The Young Generation
另外一个对于app流畅性运行影响的因素是younggeneration的大小。young generation越大,minor collection越少;但是在固定heap size情况下,更大的young generation就意味着小的tenured generation,就意味着更多的major collection(major collection会引发minorcollection)。
NewRatio反映的是young和tenuredgeneration的大小比例。NewSize和MaxNewSize反映的是young generation大小的下限和上限,将这两个值设为一样就固定了younggeneration的大小(同Xms和Xmx设为一样)。
如果希望,SurvivorRatio也可以优化survivor的大小,不过这对于性能的影响不是很大。SurvivorRatio是eden和survior大小比例。
一般而言,server端的app会有以下规则:
首先决定能分配给vm的最大的heap size,然后设定最佳的young generation的大小;
如果heap size固定后,增加young generation的大小意味着减小tenured generation大小。让tenured generation在任何时候够大,能够容纳所有live的data(留10%-20%的空余)。
4.经验总结
1.年轻代大小选择
响应时间优先的应用:尽可能设大,直到接近系统的最低响应时间限制(根据实际情况选择).在此种情况下,年轻代收集发生的频率也是最小的.同时,减少到达年老代的对象.
吞吐量优先的应用:尽可能的设置大,可能到达Gbit的程度.因为对响应时间没有要求,垃圾收集可以并行进行,一般适合8CPU以上的应用.
避免设置过小.当新生代设置过小时会导致:1.YGC次数更加频繁 2.可能导致YGC对象直接进入旧生代,如果此时旧生代满了,会触发FGC.
2.年老代大小选择
响应时间优先的应用:年老代使用并发收集器,所以其大小需要小心设置,一般要考虑并发会话率和会话持续时间等一些参数.如果堆设置小了,可以会造成内存碎片,高回收频率以及应用暂停而使用传统的标记清除方式;如果堆大了,则需要较长的收集时间.最优化的方案,一般需要参考以下数据获得:
a.并发垃圾收集信息、持久代并发收集次数、传统GC信息、花在年轻代和年老代回收上的时间比例。
b.吞吐量优先的应用:一般吞吐量优先的应用都有一个很大的年轻代和一个较小的年老代.原因是,这样可以尽可能回收掉大部分短期对象,减少中期的对象,而年老代尽存放长期存活对象.
3.较小堆引起的碎片问题
因为年老代的并发收集器使用标记,清除算法,所以不会对堆进行压缩.当收集器回收时,他会把相邻的空间进行合并,这样可以分配给较大的对象.但是,当堆空间较小时,运行一段时间以后,就会出现"碎片",如果并发收集器找不到足够的空间,那么并发收集器将会停止,然后使用传统的标记,清除方式进行回收.如果出现"碎片",可能需要进行如下配置:
-XX:+UseCMSCompactAtFullCollection:使用并发收集器时,开启对年老代的压缩.
-XX:CMSFullGCsBeforeCompaction=0:上面配置开启的情况下,这里设置多少次Full GC后,对年老代进行压缩
4.用64位操作系统,Linux下64位的jdk比32位jdk要慢一些,但是吃得内存更多,吞吐量更大
5.XMX和XMS设置一样大,MaxPermSize和MinPermSize设置一样大,这样可以减轻伸缩堆大小带来的压力
6.使用CMS的好处是用尽量少的新生代,经验值是128M-256M, 然后老生代利用CMS并行收集, 这样能保证系统低延迟的吞吐效率。实际上cms的收集停顿时间非常的短,2G的内存, 大约20-80ms的应用程序停顿时间
7.系统停顿的时候可能是GC的问题也可能是程序的问题,多用jmap和jstack查看,或者killall -3 java,然后查看java控制台日志,能看出很多问题。(相关工具的使用方法将在后面的blog中介绍)
8.仔细了解自己的应用,如果用了缓存,那么年老代应该大一些,缓存的HashMap不应该无限制长,建议采用LRU算法的Map做缓存,LRUMap的最大长度也要根据实际情况设定。
9.采用并发回收时,年轻代小一点,年老代要大,因为年老大用的是并发回收,即使时间长点也不会影响其他程序继续运行,网站不会停顿
10.-Xnoclassgc禁用类垃圾回收,性能会高一点;
11.-XX:+DisableExplicitGC禁止System.gc(),免得程序员误调用gc方法影响性能
12.JVM参数的设置(特别是 –Xmx –Xms –Xmn -XX:SurvivorRatio -XX:MaxTenuringThreshold等参数的设置没有一个固定的公式,需要根据PV old区实际数据 YGC次数等多方面来衡量。为了避免promotion faild可能会导致xmn设置偏小,也意味着YGC的次数会增多,处理并发访问的能力下降等问题。每个参数的调整都需要经过详细的性能测试,才能找到特定应用的最佳配置。
5.promotion failed:
垃圾回收时promotion failed是个很头痛的问题,一般可能是两种原因产生,第一个原因是救助空间不够,救助空间里的对象还不应该被移动到年老代,但年轻代又有很多对象需要放入救助空间;第二个原因是年老代没有足够的空间接纳来自年轻代的对象;这两种情况都会转向Full GC,网站停顿时间较长。
解决方方案一:
第一个原因我的最终解决办法是去掉救助空间,设置-XX:SurvivorRatio=65536 -XX:MaxTenuringThreshold=0即可,第二个原因我的解决办法是设置CMSInitiatingOccupancyFraction为某个值(假设70),这样年老代空间到70%时就开始执行CMS,年老代有足够的空间接纳来自年轻代的对象。
解决方案一的改进方案:
又有改进了,上面方法不太好,因为没有用到救助空间,所以年老代容易满,CMS执行会比较频繁。我改善了一下,还是用救助空间,但是把救助空间加大,这样也不会有promotionfailed。具体操作上,32位Linux和64位Linux好像不一样,64位系统似乎只要配置MaxTenuringThreshold参数,CMS还是有暂停。为了解决暂停问题和promotion failed问题,最后我设置-XX:SurvivorRatio=1 ,并把MaxTenuringThreshold去掉,这样即没有暂停又不会有promotoinfailed,而且更重要的是,年老代和永久代上升非常慢(因为好多对象到不了年老代就被回收了),所以CMS执行频率非常低,好几个小时才执行一次,这样,服务器都不用重启了。
-Xmx4000M-Xms4000M -Xmn600M -XX:PermSize=500M -XX:MaxPermSize=500M -Xss256K-XX:+DisableExplicitGC -XX:SurvivorRatio=1 -XX:+UseConcMarkSweepGC-XX:+UseParNewGC -XX:+CMSParallelRemarkEnabled-XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=0-XX:+CMSClassUnloadingEnabled -XX:LargePageSizeInBytes=128M-XX:+UseFastAccessorMethods -XX:+UseCMSInitiatingOccupancyOnly-XX:CMSInitiatingOccupancyFraction=80 -XX:SoftRefLRUPolicyMSPerMB=0-XX:+PrintClassHistogram -XX:+PrintGCDetails -XX:+PrintGCTimeStamps-XX:+PrintHeapAtGC -Xloggc:log/gc.log
6.CMSInitiatingOccupancyFraction值与Xmn的关系公式
上面介绍了promontion faild产生的原因是EDEN空间不足的情况下将EDEN与From survivor中的存活对象存入To survivor区时,To survivor区的空间不足,再次晋升到old gen区,而old gen区内存也不够的情况下产生了promontion faild从而导致full gc.那可以推断出:eden+from survivor < old gen区剩余内存时,不会出现promontionfaild的情况,即:
(Xmx-Xmn)*(1-CMSInitiatingOccupancyFraction/100)>=(Xmn-Xmn/(SurvivorRatior+2)) 进而推断出:
CMSInitiatingOccupancyFraction<=((Xmx-Xmn)-(Xmn-Xmn/(SurvivorRatior+2)))/(Xmx-Xmn)*100
例如:
当xmx=128 xmn=36 SurvivorRatior=1时CMSInitiatingOccupancyFraction<=((128.0-36)-(36-36/(1+2)))/(128-36)*100=73.913
当xmx=128 xmn=24 SurvivorRatior=1时CMSInitiatingOccupancyFraction<=((128.0-24)-(24-24/(1+2)))/(128-24)*100=84.615…
当xmx=3000 xmn=600 SurvivorRatior=1时 CMSInitiatingOccupancyFraction<=((3000.0-600)-(600-600/(1+2)))/(3000-600)*100=83.33
CMSInitiatingOccupancyFraction低于70% 需要调整xmn或SurvivorRatior值。
对此,网上牛人们得出的公式是是:(Xmx-Xmn)*(100-CMSInitiatingOccupancyFraction)/100>=Xmn。
jvm的调优
一、JVM内存模型及垃圾收集算法
1.根据Java虚拟机规范,JVM将内存划分为:
New(年轻代)
Tenured(年老代)
永久代(Perm)
其中New和Tenured属于堆内存,堆内存会从JVM启动参数(-Xmx:3G)指定的内存中分配,Perm不属于堆内存,有虚拟机直接分配,但可以通过-XX:PermSize -XX:MaxPermSize等参数调整其大小。
年轻代(New):年轻代用来存放JVM刚分配的Java对象
年老代(Tenured):年轻代中经过垃圾回收没有回收掉的对象将被Copy到年老代
永久代(Perm):永久代存放Class、Method元信息,其大小跟项目的规模、类、方法的量有关,一般设置为128M就足够,设置原则是预留30%的空间。
New又分为几个部分:
Eden:Eden用来存放JVM刚分配的对象
Survivor1
Survivro2:两个Survivor空间一样大,当Eden中的对象经过垃圾回收没有被回收掉时,会在两个Survivor之间来回Copy,当满足某个条件,比如Copy次数,就会被Copy到Tenured。显然,Survivor只是增加了对象在年轻代中的逗留时间,增加了被垃圾回收的可能性。
2.垃圾回收算法
垃圾回收算法可以分为三类,都基于标记-清除(复制)算法:
Serial算法(单线程)
并行算法
并发算法
JVM会根据机器的硬件配置对每个内存代选择适合的回收算法,比如,如果机器多于1个核,会对年轻代选择并行算法,关于选择细节请参考JVM调优文档。
稍微解释下的是,并行算法是用多线程进行垃圾回收,回收期间会暂停程序的执行,而并发算法,也是多线程回收,但期间不停止应用执行。所以,并发算法适用于交互性高的一些程序。经过观察,并发算法会减少年轻代的大小,其实就是使用了一个大的年老代,这反过来跟并行算法相比吞吐量相对较低。
还有一个问题是,垃圾回收动作何时执行?
当年轻代内存满时,会引发一次普通GC,该GC仅回收年轻代。需要强调的时,年轻代满是指Eden代满,Survivor满不会引发GC
当年老代满时会引发Full GC,Full GC将会同时回收年轻代、年老代
当永久代满时也会引发Full GC,会导致Class、Method元信息的卸载
另一个问题是,何时会抛出OutOfMemoryException,并不是内存被耗空的时候才抛出
JVM98%的时间都花费在内存回收
每次回收的内存小于2%
满足这两个条件将触发OutOfMemoryException,这将会留给系统一个微小的间隙以做一些Down之前的操作,比如手动打印Heap Dump。
二、内存泄漏及解决方法
1.系统崩溃前的一些现象:
每次垃圾回收的时间越来越长,由之前的10ms延长到50ms左右,FullGC的时间也有之前的0.5s延长到4、5s
FullGC的次数越来越多,最频繁时隔不到1分钟就进行一次FullGC
年老代的内存越来越大并且每次FullGC后年老代没有内存被释放
之后系统会无法响应新的请求,逐渐到达OutOfMemoryError的临界值。
2.生成堆的dump文件
通过JMX的MBean生成当前的Heap信息,大小为一个3G(整个堆的大小)的hprof文件,如果没有启动JMX可以通过Java的jmap命令来生成该文件。
3.分析dump文件
下面要考虑的是如何打开这个3G的堆信息文件,显然一般的Window系统没有这么大的内存,必须借助高配置的Linux。当然我们可以借助X-Window把Linux上的图形导入到Window。我们考虑用下面几种工具打开该文件:
Visual VM
IBM HeapAnalyzer
JDK 自带的Hprof工具
使用这些工具时为了确保加载速度,建议设置最大内存为6G。使用后发现,这些工具都无法直观地观察到内存泄漏,Visual VM虽能观察到对象大小,但看不到调用堆栈;HeapAnalyzer虽然能看到调用堆栈,却无法正确打开一个3G的文件。因此,我们又选用了Eclipse专门的静态内存分析工具:Mat。
4.分析内存泄漏
通过Mat我们能清楚地看到,哪些对象被怀疑为内存泄漏,哪些对象占的空间最大及对象的调用关系。针对本案,在ThreadLocal中有很多的JbpmContext实例,经过调查是JBPM的Context没有关闭所致。
另,通过Mat或JMX我们还可以分析线程状态,可以观察到线程被阻塞在哪个对象上,从而判断系统的瓶颈。
5.回归问题
Q:为什么崩溃前垃圾回收的时间越来越长?
A:根据内存模型和垃圾回收算法,垃圾回收分两部分:内存标记、清除(复制),标记部分只要内存大小固定时间是不变的,变的是复制部分,因为每次垃圾回收都有一些回收不掉的内存,所以增加了复制量,导致时间延长。所以,垃圾回收的时间也可以作为判断内存泄漏的依据
Q:为什么Full GC的次数越来越多?
A:因此内存的积累,逐渐耗尽了年老代的内存,导致新对象分配没有更多的空间,从而导致频繁的垃圾回收
Q:为什么年老代占用的内存越来越大?
A:因为年轻代的内存无法被回收,越来越多地被Copy到年老代
三、性能调优
除了上述内存泄漏外,我们还发现CPU长期不足3%,系统吞吐量不够,针对8core×16G、64bit的Linux服务器来说,是严重的资源浪费。
在CPU负载不足的同时,偶尔会有用户反映请求的时间过长,我们意识到必须对程序及JVM进行调优。从以下几个方面进行:
线程池:解决用户响应时间长的问题
连接池
JVM启动参数:调整各代的内存比例和垃圾回收算法,提高吞吐量
程序算法:改进程序逻辑算法提高性能
1.Java线程池(java.util.concurrent.ThreadPoolExecutor)
大多数JVM6上的应用采用的线程池都是JDK自带的线程池,之所以把成熟的Java线程池进行罗嗦说明,是因为该线程池的行为与我们想象的有点出入。Java线程池有几个重要的配置参数:
corePoolSize:核心线程数(最新线程数)
maximumPoolSize:最大线程数,超过这个数量的任务会被拒绝,用户可以通过RejectedExecutionHandler接口自定义处理方式
keepAliveTime:线程保持活动的时间
workQueue:工作队列,存放执行的任务
Java线程池需要传入一个Queue参数(workQueue)用来存放执行的任务,而对Queue的不同选择,线程池有完全不同的行为:
SynchronousQueue:一个无容量的等待队列,一个线程的insert操作必须等待另一线程的remove操作,采用这个Queue线程池将会为每个任务分配一个新线程
LinkedBlockingQueue :无界队列,采用该Queue,线程池将忽略maximumPoolSize参数,仅用corePoolSize的线程处理所有的任务,未处理的任务便在LinkedBlockingQueue中排队
ArrayBlockingQueue: 有界队列,在有界队列和maximumPoolSize的作用下,程序将很难被调优:更大的Queue和小的maximumPoolSize将导致CPU的低负载;小的Queue和大的池,Queue就没起动应有的作用。
其实我们的要求很简单,希望线程池能跟连接池一样,能设置最小线程数、最大线程数,当最小数<任务<最大数时,应该分配新的线程处理;当任务>最大数时,应该等待有空闲线程再处理该任务。
但线程池的设计思路是,任务应该放到Queue中,当Queue放不下时再考虑用新线程处理,如果Queue满且无法派生新线程,就拒绝该任务。设计导致“先放等执行”、“放不下再执行”、“拒绝不等待”。所以,根据不同的Queue参数,要提高吞吐量不能一味地增大maximumPoolSize。
当然,要达到我们的目标,必须对线程池进行一定的封装,幸运的是ThreadPoolExecutor中留了足够的自定义接口以帮助我们达到目标。我们封装的方式是:
以SynchronousQueue作为参数,使maximumPoolSize发挥作用,以防止线程被无限制的分配,同时可以通过提高maximumPoolSize来提高系统吞吐量
自定义一个RejectedExecutionHandler,当线程数超过maximumPoolSize时进行处理,处理方式为隔一段时间检查线程池是否可以执行新Task,如果可以把拒绝的Task重新放入到线程池,检查的时间依赖keepAliveTime的大小。
2.连接池(org.apache.commons.dbcp.BasicDataSource)
在使用org.apache.commons.dbcp.BasicDataSource的时候,因为之前采用了默认配置,所以当访问量大时,通过JMX观察到很多Tomcat线程都阻塞在BasicDataSource使用的Apache ObjectPool的锁上,直接原因当时是因为BasicDataSource连接池的最大连接数设置的太小,默认的BasicDataSource配置,仅使用8个最大连接。
我还观察到一个问题,当较长的时间不访问系统,比如2天,DB上的mysql会断掉所以的连接,导致连接池中缓存的连接不能用。为了解决这些问题,我们充分研究了BasicDataSource,发现了一些优化的点:
Mysql默认支持100个链接,所以每个连接池的配置要根据集群中的机器数进行,如有2台服务器,可每个设置为60
initialSize:参数是一直打开的连接数
minEvictableIdleTimeMillis:该参数设置每个连接的空闲时间,超过这个时间连接将被关闭
timeBetweenEvictionRunsMillis:后台线程的运行周期,用来检测过期连接
maxActive:最大能分配的连接数
maxIdle:最大空闲数,当连接使用完毕后发现连接数大于maxIdle,连接将被直接关闭。只有initialSize < x < maxIdle的连接将被定期检测是否超期。这个参数主要用来在峰值访问时提高吞吐量。
initialSize是如何保持的?经过研究代码发现,BasicDataSource会关闭所有超期的连接,然后再打开initialSize数量的连接,这个特性与minEvictableIdleTimeMillis、timeBetweenEvictionRunsMillis一起保证了所有超期的initialSize连接都会被重新连接,从而避免了Mysql长时间无动作会断掉连接的问题。
3.JVM参数
在JVM启动参数中,可以设置跟内存、垃圾回收相关的一些参数设置,默认情况不做任何设置JVM会工作的很好,但对一些配置很好的Server和具体的应用必须仔细调优才能获得最佳性能。通过设置我们希望达到一些目标:
GC的时间足够的小
GC的次数足够的少
发生Full GC的周期足够的长
前两个目前是相悖的,要想GC时间小必须要一个更小的堆,要保证GC次数足够少,必须保证一个更大的堆,我们只能取其平衡。
(1)针对JVM堆的设置一般,可以通过-Xms -Xmx限定其最小、最大值,为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,我们通常把最大、最小设置为相同的值
(2)年轻代和年老代将根据默认的比例(1:2)分配堆内存,可以通过调整二者之间的比率NewRadio来调整二者之间的大小,也可以针对回收代,比如年轻代,通过 -XX:newSize -XX:MaxNewSize来设置其绝对大小。同样,为了防止年轻代的堆收缩,我们通常会把-XX:newSize -XX:MaxNewSize设置为同样大小
(3)年轻代和年老代设置多大才算合理?这个我问题毫无疑问是没有答案的,否则也就不会有调优。我们观察一下二者大小变化有哪些影响
更大的年轻代必然导致更小的年老代,大的年轻代会延长普通GC的周期,但会增加每次GC的时间;小的年老代会导致更频繁的Full GC
更小的年轻代必然导致更大年老代,小的年轻代会导致普通GC很频繁,但每次的GC时间会更短;大的年老代会减少Full GC的频率
如何选择应该依赖应用程序对象生命周期的分布情况:如果应用存在大量的临时对象,应该选择更大的年轻代;如果存在相对较多的持久对象,年老代应该适当增大。但很多应用都没有这样明显的特性,在抉择时应该根据以下两点:(A)本着Full GC尽量少的原则,让年老代尽量缓存常用对象,JVM的默认比例1:2也是这个道理 (B)通过观察应用一段时间,看其他在峰值时年老代会占多少内存,在不影响Full GC的前提下,根据实际情况加大年轻代,比如可以把比例控制在1:1。但应该给年老代至少预留1/3的增长空间
(4)在配置较好的机器上(比如多核、大内存),可以为年老代选择并行收集算法:-XX:+UseParallelOldGC,默认为Serial收集
(5)线程堆栈的设置:每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,对大多数应用而言这个默认值太了,一般256K就足用。理论上,在内存不变的情况下,减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。
(4)可以通过下面的参数打Heap Dump信息
-XX:HeapDumpPath
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-Xloggc:/usr/aaa/dump/heap_trace.txt
通过下面参数可以控制OutOfMemoryError时打印堆的信息
-XX:+HeapDumpOnOutOfMemoryError
请看一下一个时间的Java参数配置:(服务器:Linux 64Bit,8Core×16G)
JAVA_OPTS="$JAVA_OPTS -server -Xms3G -Xmx3G -Xss256k -XX:PermSize=128m -XX:MaxPermSize=128m -XX:+UseParallelOldGC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/aaa/dump -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:/usr/aaa/dump/heap_trace.txt -XX:NewSize=1G -XX:MaxNewSize=1G"
经过观察该配置非常稳定,每次普通GC的时间在10ms左右,Full GC基本不发生,或隔很长很长的时间才发生一次
jvm-垃圾回收
一:为什么需要垃圾回收?
jvm把内存管理权从开发人员收回,开发人员只需要创建数据对象即可,内存的分配和回收都由jvm自动完成。
程序只管创建对象,不管对象的回收,内存最终会被耗尽。
二:怎么判断对象为垃圾?
如果要实现垃圾回收,首先必须能判断哪些对象是垃圾。
对象不再被使用就认为是垃圾。jvm自动回收垃圾,但它如何才能知道一个对象是否不再被使用?
常见的策略有如下两种:引用计数器 、可达性检测。
2.1 引用计数器:
即如果一个对象被外部引用则计数器加 1, 反之减 1。如果计数器为0,则说明当前对外象没有被任何外部使用,则认为是垃圾。
优点:实现简单
缺点:无法解决循环引用的问题;如:对象A,B相互引用,除此再没有被其它对象引用,那么它们两个都是垃圾,但计数器却均为1,而无法回收。
注意事项:引用计数器只是一个理论方案,从来没有一个主流的jvm使用这种方式
2.2 可达性检测
引用计数器无法解决循环引用的问题,因此更好的办法是通过可达性分析。jvm中的任何非垃圾对象通过引用链向上追溯,都可以到达一些根对象(法方区的静态变量、常量、栈中的变量),这些根对象都是存活的对象,那么被活对象引用的对象很有可能会继续使用,因此反过来,从根对象向下追溯到的对象都可以认为是存活的对象。这种从根对象追溯的方法称为可达性分析。
如下:从根对象向下追溯,红色标记的对象是不可达的,因此它们就是垃圾,会被GC回收。
2.3 根对象种类
可以做为GC root(根对象)的对象有以下几种:
虚拟机栈(栈帧中变量引用的对象)
方法区中静态属性(static 属性)
方法区中的常量(static final),(jdk8及以上,为元数据区)
本地方法栈中引用的对象
三:垃圾回收算法
标记出哪些对象是垃圾后,就需要对这些垃圾对象进行回收。
常用的回收算法有:标记-清除、复制、标记-整理
3.1 标记-清除
通过标记、清除两个阶段回收垃圾对象。因为标记的是存活对象,清除的是非存活对象,所以需要两个阶段:先标记,再遍历所有对象,过滤出非存活对象。
如下图:(绿色-存活对象;红色-垃圾;白色-空闲)
首先,通过可达性分析,标记出存活的对象(绿色块)
其次,遍历堆中所有对象,把非存活的对象全部清空。
优点:实现简单,并且是其它算法的基础
缺点:A:标记效率不高,清除算法也不高(遍历所有对象进行清除).
B:产生大量内存碎片
3.2 复制算法
为了解决标记-清除 算法的效率问题,使用复制算法。
复制算法需要一块同样大小额外的内存做为中转。
因为复制的是存活对象,不需再次遍历。
步骤:通过可达性分析,标记出存活对象,并同时把存活对象复制到另一块对等内存。
当所有存活对象都复制完后,直接清空原内存块(不需要遍历,直接移动堆顶指针即可)。
优点: 不需要两阶段,存活对象少时效率高。
没有内存碎片
缺点:需要额片内存,同一时间总有一块内存处于备用状态-浪费内存。
存活对象很多时效率也不高(主要是因为对象复制代价高昂)
使用场景:存活对象多,内存紧张的场景。
复制算法变种:
复制算法最大的缺点是需要一个相同大小的内存块,为了减少内存浪费,复制算法还有一种变种。
如果对象中存活的很少,就不需要一个相同大小的额外内存块,而只需要两个小内存块,交替做为中转站就可以完美解决。
前提:存活的对象很少,IBM研究表明新生代90%以上甚至98%的对象朝生夕死。
步骤:
A:设置三块内存,第一块大内存块,第二第三为两个相等的小内存块
B:创建对象分配置在大内存块和 两小内存块中的任一个,另外一小内存块保持空闲备用。
C:回收:通过可达性分析,标记出第一块和其中使用的小块内存中存活对象,同时把存活对象复制到备用的另一块小内存中
D:清空大内存块和被回收的小块内存。此时:大内存被清空,其中两块小内存:一块清空,一块保存了上次存活的数
E:然后交替使用两块小内存块做为清空大内存和另一块小内存的中转。
优点:减少了内存浪费,同时又保持了复制算法的优点。
缺点:未完全杜绝内存浪费,同时大数据量时,效率低;存活对象数量占比较大时,小内存块无法做为中转站。
使用场景:在存活对象较少,追求高效率,内存无碎片的场景。
3.3 标记-整理
标记清除算法效率低,碎片严重; 复制算法存活对象少时效率高,无碎片,但内存浪费;为了折中两种算法的优点,有人提供另一种算法:标记-整理算法。
步骤:
A:根据可达性分析,标记出所有存活的对象
B:遍历所有对象,过滤出非存活的对象,并把这些对象一个一个,从内存的某一个角落顺序排列。
优点:没有内存浪费,无碎片
缺点:效率最低,小于标记清除(需要两个阶段<标记,移动>;移动类似复制,代价高于直接清除,存活对象越多,移动代价越大)
四:分代算法
准确的讲,分代算法不是一种回收算法,它只是按对象生命周期和特点不同,合理选用以上三种回收算法的手段。
内存模型中,我们大概了解了堆内存的分代结构如下:
为什么需要分代?
因为不同的对象生命周期不同,有的很长(如:session),有的很短(如:方法中的变更);如果不分代,每次可达性分析标记时,都要遍历暂时不会回收的老对象,当老对象越来越多时,重复对老对象的无用遍利检查,会严重影响回收性能。
如果把对象按年龄隔离,分成新生代和老年代,老年代保存生命周期长的对象,新生代保存新创建的对象,那么老年代就可以长时间不回收,而新年代大部分是朝生夕死,就可以频繁回收。即保证了效率,又保证了新生代内存的及时回收。
总结:新生代:时间换空间(频繁回收:由于存活的数据量少,频繁回收的代价也可以接受)
老年代:空间换时间(需要时回收:存活的多,频繁回收严重影响性能;有些对象可能已经变垃圾了,但仍然存在老年代中,等到新生代不够或其它条件时,才回收老年代)
在此我向大家推荐一个架构学习交流群。交流学习群号:575745314 里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多
如何区分新老对象?
这个与垃圾回收器实现有关,对应回收器有相关的配置。
主要有几种情况:
大对象直接进行老年代
年龄达到一定阀值(每经历过一次回收还活着:年龄加1, 默认阀值为:15,可配置)
survivor空间中相同年龄所有对象大小的总和超过survivor空间的一半时,即使没达到年龄阀值
五:垃圾回收器
垃圾回收算法只是垃圾回收的理论基础,垃圾回收器是对垃圾回算法的实现。
垃圾回收器关注三个方法:A:垃圾回收算法选择 B:串行和并行(并发)的选择 C:新老代的选择
下面先了解一下jvm中的垃圾回收器种类:
垃圾回收器根据新老代不同区分,一部分只用于新生代回收(Serial、ParNew、Parallel),一部分只用于老年代(Serial old、CMS、Parallel old); G1是一个特殊的存在,后续再讲。
下面我一个一个分析各自的原理及特点,然后分析他们为什么只能使用新生代或老年代;以及实战中如何选择。
5.1 Serial
serial/serial old 收集示意图(图片来自:JVM系列之垃圾回收(二))
使用于:新生代
垃圾回收算法:复制算法
串行/并行/并发:串行,单线程
stw:是
serial是一个单线程,且用于新生代的垃圾回收器。它运行时,需要stw,暂停所有用户线程。所以,堆配置过大,且垃圾太多时,会导致程序有明显的停顿。
由于新生代是存活量少,回收频繁,所以必须使用最高效的回收算法-复制算法;复制算法大量存活数据,且需要额外内存的情况下是不符合老年代的,因此当前回收器只能用于新生代。
注意:此收集器,只适用于client模式,不适用于生产环境的server模式(当今服务器已经很少有单cpu,单线程在多cpu下,会浪费过多cpu资源,导致垃圾回收停顿时间过长和频繁)
5.2 ParNew
(图片来自:JVM系列之垃圾回收(二))
分代:用于新生代
垃圾回收算法:复制算法
串行/并行/并发:并发,多线程
stw:是
ParNew是serial收集器的多线程模式,除此之外没有任何区别。多线程大大提高了多cpu服务器的垃圾回收效率,减少停顿时间。
5.3 Parallel Scavenge
分代:用于新生代
垃圾回收算法:复制算法
串行/并行/并发:并行,多线程
stw:是
Parallel Scavenge 与 ParNew一样也是多线程,但是与ParNew不同的是,它关注的点是垃圾回收的吞吐量(用户线程时间/(用户线程时间 + 垃圾回收时间)),也就是:它期望尽可能压榨cpu,多用于业务捃,它关注的是整体,而不是一次。
如:假如每分钟执行1000次垃圾回收,每次的停顿时间很短,但1000次总停顿时间要高于 每分种100次的时间。那么100次垃圾回收就是Parallel Scavenge期望的。
5.4 Serial old
分代:用于老年代
垃圾回收算法:标记-整理算法
串行/并行/并发:串行,单线程
stw:是
由于老年代,活的多,死的少,且最好没有碎片:标记整理算法;
跟Serial收集器一样,当前收集器也是单线程,因此也不适合多核时代的服务器上,是默认的client模式,同时做cms收集器失败时的备选收集器(因为cms是并发的,如果并发失败,就不要并发了,所以使用了serial Old)。
5.5 CMS
(图片来自:JVM系列之垃圾回收(二))
分代:用于老年代
垃圾回收算法:标记-清除算法,有碎片
串行/并行/并发:多线程
stw:初始标记stw; 重新标记stw
CMS是首个并发收集器,垃圾回假步骤中的部分阶段可以与用户线程并发执行。
垃圾回收器的最终目标就是:减少垃圾回收对用户线程的影响(停顿频率小、停顿时间少)。
为此,CMS把垃圾回收分为四个阶段,把不需要停顿的阶段与用户线程一起执行:
初始标记
并发标记
重新标记
并发清理
初始标记:从GC ROOTS只标记一级对象(存活的),所以速度很快;但需要stw。
并发标记:从一级对象开始向下追塑引用链,标记引用链上的对象;不需要stw,与用户线程并发执行。速度是慢。
重新标记:修正并发标记过程中,因用户线程继续进行而导致标记变更的那部分对象;速度比初始标记慢,但比并发标记快很多。(但是:到底是修正了标记存活的对象还是其它?如果是修改存活的,那么可以做为浮动垃圾等到下一次回收即可阿???)
并发清理:垃圾回收线程与用户线程并发执行,清除垃圾(如果标记的活着对象,那么不stw如果清除垃圾,此时如果用户线程又产生对象了?通过ooM?暂时没想通)
优点:单次停顿的时间更短
缺点:有碎片
5.6 Parallel old
(图片来自:JVM系列之垃圾回收(二))
分代:用于老年代
垃圾回收算法:标记-整理算法
串行/并行/并发:多线程
stw:是
作者:AI乔治
链接:https://www.jianshu.com/p/63fe09fe1a60
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。
以上是关于一篇简单易懂的原理文章,让你把JVM玩弄与手掌之中的主要内容,如果未能解决你的问题,请参考以下文章