JVM内存模型、性能调优和应用性能管理、监控分析总结
一、工具使用
下图列出的工具都是程序员必不可少的工具
1.1 JVM性能调优
1.2 JVM调优工具
Jconsole,jProfile,VisualVM
Jconsole : jdk自带,功能简单,但是可以在系统有一定负荷的情况下使用。对垃圾回收算法有很详细的跟踪。
JProfiler:商业软件,需要付费。功能强大。
VisualVM:JDK自带,功能强大,与JProfiler类似。推荐。
JVM调优总结: https://www.cnblogs.com/andy-zhou/p/5327288.html#_caption_1
为什么要性能优化: 由于日常开发业务代码有太多垃圾不合理的代码、性能非常低、不合理的设计、(扩展性和可维护性差)
使用设计模式,并发编程,优秀算法替换解决;使用缓存,异步,中间件代替。
VisualVM使用
jdk自带有个VisualVM工具、该工具是用来监控java运行程序的cpu、内存、线程等的使用情况。并且使用图表的方式监控java程序、还具有远程监控能力。
1、windows键+R键 输入jvisualvm回车
2、右键远程添加远程主机
二、JVM
jvm是java的核心和基础,在java编译器和os平台之间的虚拟处理器,可在上面执行字节码程序。
java编译器只要面向jvm,生成jvm能理解的字节码文件。java源文件经编译成字节码程序,通过jvm将每条指令翻译成不同的机器码。
2.1 JVM类加载
1、类加载,即将字节码class文件加到载到jvm中或者说到内存中。并不执行任何的方法和属性,类的静态代码块是类加载即执行。
2、方法和属性的执行,是在调用的时候,去内存中定位取值或是计算,类加载是不对他们进行计算的。
3、new一个对象:即是调用该类的构造方法,也就是执行一次,执行了什么就会计算什么。
4、方法的加载:方法的加载是和class一起的,即代码块被加载进内存,并不会被执行。
5、方法的执行:即将代码块调入jvm解释器解释执行,当然方法执行一次则代码块就会被jvm调入解释执行一次。
可以这么理解:加载就好比把面包先放进加工厂,但是没有加工成面包片呢,当你执行属性和方法时候,才执行加工成面包片? 我明白了,所有的方法和属性(其实就是咱们写的代码内容)已经全在内存里了,但是没有调用的话,cup是不会去计算和执行的,只有你调用哪个才去计算和执行哪个属性和方法的代码,不过,类的静态代码块除外,如static { ........ } 这种情况之下,类一加载,该代码块就会被执行。
2.2 JVM内存模型
名称 | 特征 | 作用 | 配置 | 异常 |
---|---|---|---|---|
栈区(java虚拟机栈) |
线程私有,使用一段连续的内存空间 |
每个方法在执行的时候都会创建一个栈帧,用来存放局部变量表、操作栈、动态链接、方法出口 局部变量表(基本数据类型和对象引用) |
-XSs | 线程请求的栈深度不够会报StackOverflowError异常 栈动态扩展的容量不够会报OutOfMemoryError异常 |
程序计数器 | 线程私有、占用内存小 | 当前线程所执行的字节码行号 | 无 | 无 |
本地方法栈 | 线程私有 | 存放的是native方法帧 | -XX:PermSize -XX:MaxPermSize | StackOverflowError OutOfMemoryError |
方法区 |
线程共享(jdk1.8之后转移到了元空间) |
运行时常量池、加载的类信息(类的版本、字段、方法、出入口)、final常量、static静态变量、即时编译器编译后Class字节码等数据 | ||
堆 | 线程共享,生命周期与虚拟机相同 |
1.差不多所有对象实例 2.从JDK1.8:把运行时常量池、final字符串常量、static静态变量从方法区移到堆 运行时常量池:符号引用和字面量 |
-Xms -Xmx -Xmn | OutOfMemoryError |
元数据区(元空间) |
线程共享(不属于JVM) 类及相关的元数据的生命周期与类加载器的一致 |
加载的类信息(类的版本、字段、方法、出入口)、编译器编译后Class字节码 |
-XX:MetaSpaceSize 和 -XX:MaxMetaSpaceSize |
OutOfMemoryError |
JDK1.8之后
1. 堆(Heap)
划分:新生代和老年代;新生代又有Eden空间、From Survivor空间、To Survivor空间三部分。
堆是java虚拟机所管理的内存中最大的一块内存区域,也是被各个线程共享的内存区域,该内存区域存放了对象实例及数组(但不是所有的对象实例都在堆中)。其大小通过-Xms(最小值)和-Xmx(最大值)参数设置(最大最小值都要小于1G),前者为启动时申请的最小内存,默认为操作系统物理内存的1/64,后者为JVM可申请的最大内存,默认为物理内存的1/4,默认当空余堆内存小于40%时,JVM会增大堆内存到-Xmx指定的大小,可通过-XX:MinHeapFreeRation=来指定这个比列;当空余堆内存大于70%时,JVM会减小堆内存的大小到-Xms指定的大小,可通过XX:MaxHeapFreeRation=来指定这个比列,当然为了避免在运行时频繁调整Heap的大小,通常-Xms与-Xmx的值设成一样。堆内存 = 新生代+老生代+持久代。在我们垃圾回收的时候,我们往往将堆内存分成新生代和老生代(大小比例1:2),新生代中由Eden和Survivor0,Survivor1组成,三者的比例是8:1:1,新生代的回收机制采用复制算法,在Minor GC的时候,我们都留一个存活区用来存放存活的对象,真正进行的区域是Eden+其中一个存活区,当我们的对象时长超过一定年龄时(默认15,可以通过参数设置),将会把对象放入老生代,当然大的对象会直接进入老生代。老生代采用的回收算法是标记整理算法。
复制算法:主要用在新生代的回收上,通过from区和to区的来回拷贝。
标记清除/标记整理:主要用在老生代回收上,通过根搜的标记然后清除或者整理掉不需要的对象。
具体的垃圾收集器
新生代收集器:有Serial收集器、ParNew收集器、Parallel Scavenge收集器。
老生代收集器:Serial Old收集器、Parallel Old收集器、CMS收集器、G1收集器。
2. 方法区(永久代)——>元数据区(元空间)Metaspace
方法区也称"永久代",它用于存储虚拟机运行时常量池、加载的类信息(类的版本、字段、方法、出入口)、final常量、static静态变量、即时编译器编译后Class字节码等数据,是各个线程共享的内存区域。默认最小值为16MB,最大值为64MB(64位JVM由于指针膨胀,默认是85M),可以通过-XX:PermSize 和 -XX:MaxPermSize 参数限制方法区的大小。它是一片连续的堆空间,永久代的垃圾收集是和老年代(old generation)捆绑在一起的,因此无论谁满了,都会触发永久代和老年代的垃圾收集。不过,一个明显的问题是,当JVM加载的类信息容量超过了参数-XX:MaxPermSize设定的值时,应用将会报OOM的错误。参数是通过-XX:PermSize和-XX:MaxPermSize来设定的
运行时常量池(Runtime Constant Pool):是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种符号引用,这部分内容将在类加载后放到方法区的运行时常量池中。
从JDK7开始移除永久代(但并没有移除,还是存在),贮存在永久代的一部分数据已经转移到了Java Heap或者是Native Heap:符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。
随着JDK8的到来,JVM不再有方法区(PermGen),原方法区存储的信息被分成两部分:1、虚拟机加载的类信息,2、运行时常量池。分别被移动到了元空间和堆中。
为什么要废除1.7的永久区?
1.对永久代的调优过程非常困难,永久代的大小很难确定,其中涉及到太多因素,如类的总数,常量池大小和方法数量等,而且永久代的数据可能会随着每一次Full GC而发生移动。
2.而在jdk1.8中,类的元数据保存在本地内存中,元空间的最大可分配空间就是系统可用内存空间。
2.1 运行时常量池:(jdk8中放在堆中)
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant PoolTable),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
a、jvm在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。
b、运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只能在编译期产生,也就是并非预置入Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String 类的intern() 方法。
c、既然运行时常量池是方法区的一部分,自然会受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError 异常
d、符号引用和字面量
字面量(literal)是用于表达源代码中一个固定值的表示法,int i = 1;把整数1赋值给int型变量i,整数1就是Java字面量, String s = "abc";中的abc也是字面量
符号引用以一组符号来描述所引用的目标, 符号可以是任何形式的字面量, 只要使用时能够无歧义的定位到目标即可. 例如, 在Java中, 一个Java类将会编译成一个class文件. 在编译时, Java类并不知道所引用的类的实际地址, 因此只能使用符号引用来代替. 比如org.simple.People类引用了org.simple.Language类, 在编译时People类并不知道Language类的实际内存地址, 因此只能使用符号org.simple.Language来表示Language类的地址.
f、直接引用
直接指向目标的指针.(个人理解为: 指向方法区中类对象, 类变量和类方法的指针)
直接引用 程序运行时可以定位到引用的东西(类, 对象, 变量或者方法等)的地址.
总结
- 1.全局常量池在每个VM中只有一份,存放的是字符串常量的引用值。(比如 String s="abc",堆中生成驻留字符串的实例对象"abc",然后将这个对象的引用存入全局常量池)
- 2.class常量池是在编译的时候每个class都有的,在编译阶段,存放的是常量的符号引用。
- 3.运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中,也就是说,每个class都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。
编译之后,在该类的class常量池中存放一些符号引用,然后类加载之后,将class常量池中存放的符号引用转存到运行时常量池中,最后在解析阶段,要把运行时常量池中的符号引用替换成直接引用,与全局常量池中的引用值保持一致。
3. 虚拟机栈(JVM Stack)
描述的是java方法执行的内存模型:每个方法被执行的时候都会创建一个"栈帧",用于存储局部变量表(包括参数)、操作栈、方法出口等信息。每个方法被调用到执行完的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。声明周期与线程相同,是线程私有的。栈帧由三部分组成:局部变量区、操作数栈、帧数据区。局部变量区被组织为以一个字长为单位、从0开始计数的数组,和局部变量区一样,操作数栈也被组织成一个以字长为单位的数组。但和前者不同的是,它不是通过索引来访问的,而是通过入栈和出栈来访问的,可以看作为临时数据的存储区域。除了局部变量区和操作数栈外,java栈帧还需要一些数据来支持常量池解析、正常方法返回以及异常派发机制。这些数据都保存在java栈帧的帧数据区中。
局部变量表: 存放了编译器可知的各种基本数据类型、对象引用(引用指针,并非对象本身),其中64位长度的long和double类型的数据会占用2个局部变量的空间,其余数据类型只占1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量是完全确定的,在运行期间栈帧不会改变局部变量表的大小空间。
4.本地方法栈(Native Stack)
与虚拟机栈基本类似,区别在于虚拟机栈为虚拟机执行的java方法服务,而本地方法栈则是为Native方法服务。(栈的空间大小远远小于堆)
5.程序计数器(PC Register)
是最小的一块内存区域,它的作用是当前线程所执行的字节码的行号指示器,在虚拟机的模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、异常处理、线程恢复等基础功能都需要依赖计数器完成。
6.直接内存
直接内存并不是虚拟机内存的一部分,也不是Java虚拟机规范中定义的内存区域。jdk1.4中新加入的NIO,引入了通道与缓冲区的IO方式,它可以调用Native方法直接分配堆外内存,这个堆外内存就是本机内存,不会影响到堆内存的大小。
2.3 JVM GC算法 (GC的对象是Java堆和方法区)
在系统运行过程当中所产生的一些无用的对象,这些对象占据着一定的内存空间,如果长期不被释放,可能导致OOM
频繁GC:导致98%的资源(线程),回收不到2%的内存区域
1、对象存活判断
引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,老牌垃圾回收算法。无法处理循环引用,没有被Java采纳
根搜索算法:设立若干种根对象,当任何一个根对象到某一个对象均不可达时,则认为这个对象是可以被回收的。
可达性分析:从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,不可达对象
根(GC Roots):
说到GC roots(GC根),在JAVA语言中,可以当做GC roots的对象有以下几种:
1、栈(栈帧中的本地变量表)中引用的对象。
2、方法区中的静态成员。
3、方法区中的常量引用的对象(全局变量)
4、本地方法栈中JNI(一般说的Native方法)引用的对象。
5、所有Class对象;
垃圾搜集的算法主要有三种,分别是标记-清除算法、复制算法、标记-整理算法。这三种算法都扩充了根搜索算法
2、复制算法
复制算法:主要用在新生代的回收上,通过from区和to区的来回拷贝。
将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
- 与标记-清除算法相比,复制算法是一种相对高效的回收方法
- 不适用于存活对象较多的场合,如老年代(复制算法适合做新生代的GC)
- 复制算法的最大的问题是:空间的浪费
3、标记清除/标记整理
主要用在老生代回收上,通过根搜的标记然后清除或者整理掉不需要的对象。
标记-清除算法是现代垃圾回收算法的思想基础。标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象;然后,在清除阶段,清除所有未被标记的对象。
标记-清除算法详解:
它的做法是当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被成为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。
- 标记:标记的过程其实就是,遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。
- 清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。
也就是说,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。
来看下面这张图:
上图代表的是程序运行期间所有对象的状态,它们的标志位全部是0(也就是未标记,以下默认0就是未标记,1为已标记),假设这会儿有效内存空间耗尽了,JVM将会停止应用程序的运行并开启GC线程,然后开始进行标记工作,按照根搜索算法,标记完以后,对象的状态如下图:
标记-清除算法的缺点:
(1)首先,它的缺点就是效率比较低(递归与全堆对象遍历),导致stop the world的时间比较长,尤其对于交互式的应用程序来说简直是无法接受。试想一下,如果你玩一个网站,这个网站一个小时就挂五分钟,你还玩吗?
(2)第二点主要的缺点,则是这种方式清理出来的空闲内存是不连续的,这点不难理解,我们的死亡对象都是随即的出现在内存的各个角落的,现在把它们清除之后,内存的布局自然会乱七八糟。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。
4、标记-整理算法:(老年代的GC)
引入:
如果在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选中这种算法。
概念:
标记-压缩算法适合用于存活对象较多的场合,如老年代。它在标记-清除算法的基础上做了一些优化。和标记-清除算法一样,标记-压缩算法也首先需要从根节点开始,对所有可达对象做一次标记;但之后,它并不简单的清理未标记的对象,而是将所有的存活对象压缩到内存的一端;之后,清理边界外所有的空间。
- 标记:它的第一个阶段与标记/清除算法是一模一样的,均是遍历GC Roots,然后将存活的对象标记。
- 整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。
上图中可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。
- 但是,标记/整理算法唯一的缺点就是效率也不高。
不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。
标记-清除算法、复制算法、标记整理算法的总结:
三个算法都基于根搜索算法去判断一个对象是否应该被回收,而支撑根搜索算法可以正常工作的理论依据,就是语法中变量作用域的相关内容。因此,要想防止内存泄露,最根本的办法就是掌握好变量作用域,而不应该使用C/C++式内存管理方式。
在GC线程开启时,或者说GC过程开始时,它们都要暂停应用程序(stop the world)。
它们的区别如下:(>表示前者要优于后者,=表示两者效果一样)
(1)效率:复制算法>标记/整理算法>标记/清除算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
(2)内存整齐度:复制算法=标记/整理算法>标记/清除算法。
(3)内存利用率:标记/整理算法=标记/清除算法>复制算法。
注1:可以看到标记/清除算法是比较落后的算法了,但是后两种算法却是在此基础上建立的。
注2:时间与空间不可兼得。
5、分代收集算法:(新生代的GC+老年代的GC)
当前商业虚拟机的GC都是采用的“分代收集算法”,这并不是什么新的思想,只是根据对象的存活周期的不同将内存划分为几块儿。一般是把Java堆分为新生代和老年代:短命对象归为新生代,长命对象归为老年代。
- 少量对象存活,适合复制算法:在新生代中,每次GC时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成GC。
- 大量对象存活,适合用标记-清理/标记-整理:在老年代中,因为对象存活率高、没有额外空间对他进行分配担保,就必须使用“标记-清理”/“标记-整理”算法进行GC。
注:老年代的对象中,有一小部分是因为在新生代回收时,老年代做担保,进来的对象;绝大部分对象是因为很多次GC都没有被回收掉而进入老年代。
八、可触及性:
所有的算法,需要能够识别一个垃圾对象,因此需要给出一个可触及性的定义。
可触及的:
从根节点可以触及到这个对象。
其实就是从根节点扫描,只要这个对象在引用链中,那就是可触及的。
可复活的:
一旦所有引用被释放,就是可复活状态
因为在finalize()中可能复活该对象
不可触及的:
在finalize()后,可能会进入不可触及状态
不可触及的对象不可能复活
要被回收。
6、常见的垃圾回收器
G1收集器
G1,Garbage First,在JDK 1.7版本正式启用,是当时最前沿的垃圾收集器。G1可以说是CMS的终极改进版,解决了CMS内存碎片、更多的内存空间登问题。虽然流程与CMS比较相似,但底层的原理已是完全不同。
高效益优先。G1会预测垃圾回收的停顿时间,原理是计算老年代对象的效益率,优先回收最大效益的对象。
堆内存结构的不同。以前的收集器分代是划分新生代、老年代、持久代等。
G1则是把内存分为多个大小相同的区域 Region ,每个Region拥有各自的分代属性,但这些分代不需要连续。
这样的分区可以有效避免内存碎片化问题。
但是这样同样会引申一个新的问题,就是分代的内存不连续,导致在GC搜索垃圾对象的时候需要全盘扫描找出引用内存所在。
为了解决这个问题,G1对于每个Region都维护一个Remembered Set,用于记录对象引用的情况。当GC发生的时候根据Remembered Set的引用情况去搜索。
两种GC模式:
- Young GC,关注于所有年轻代的Region,通过控制收集年轻代的Region个数,从而控制GC的回收时间。
- Mixed GC,关注于所有年轻代的Region,并且加上通过预测计算最大收益的若干个老年代Region。
整体的执行流程:
- 初始标记(initial mark),标记了从GC Root开始直接关联可达的对象。STW(Stop the World)执行。
- 并发标记(concurrent marking),并发标记初始标记的对象,此时用户线程依然可以执行。
- 最终标记(Remark),STW,标记再并发标记过程中产生的垃圾。
- 筛选回收(Live Data Counting And Evacuation),评估标记垃圾,根据GC模式回收垃圾。STW执行。
在Region层面上,整体的算法偏向于Mark-Compact。因为是Compact,会影响用户线程执行,所以回收阶段需要STW执行。
令人惊叹的ZGC
在JDK 11当中,加入了实验性质的ZGC。它的回收耗时平均不到2毫秒。它是一款低停顿 高并发 的收集器。
ZGC几乎在所有地方并发执行的,除了初始标记的是STW的。所以停顿时间几乎就耗费在初始标记上,这部分的实际是非常少的。那么其他阶段是怎么做到可以并发执行的呢?
ZGC主要新增了两项技术,一个是 着色指针Colored Pointer ,另一个是 读屏障Load Barrier 。
着色指针Colored Pointer
ZGC利用指针的64位中的几位表示Finali zab le、Re map ped、Marked1、Marked0(ZGC仅支持64位平台),以标记该指向内存的存储状态。相当于在对象的指针上标注了对象的信息。注意,这里的指针相当于Java术语当中的引用。
在这个被指向的内存发生变化的时候(内存在Compact被移动时),颜色就会发生变化。
在G1的时候就说到过,Compact阶段是需要STW,否则会影响用户线程执行。那么怎么解决这个问题呢?
读屏障Load Barrier由于着色指针的存在,在程序运行时访问对象的时候,可以轻易知道对象在内存的存储状态(通过指针访问对象),若请求读的内存在被着色了。那么则会触发读屏障。读屏障会更新指针再返回结果,此过程有一定的耗费,从而达到与用户线程并发的效果。
把这两项技术联合下理解,引用R大(RednaxelaFX)的话
与标记对象的传统算法相比,ZGC在指针上做标记,在访问指针时加入Load Barrier(读屏障),比如当对象正被GC移动,指针上的颜色就会不对,这个屏障就会先把指针更新为有效地址再返回,也就是,永远只有单个对象读取时有概率被减速,而不存在为了保持应用与GC一致而粗暴整体的Stop The World。
ZGC虽然目前还在JDK 11还在实验阶段,但由于算法与思想是一个非常大的提升,相信在未来不久会成为主流的GC收集器使用。
三、APM工具性能分析总结
目前主要的一些 APM (应用性能管理)工具有: Cat、Zipkin、Pinpoint、SkyWalking,这里主要介绍 SkyWalking ,它是一款优秀的国产 APM 工具,包括了分布式追踪、性能指标分析、应用和服务依赖分析等。
SkyWalking: https://www.cnblogs.com/yyhh/p/6106472.html
Pinpoint:https://www.jianshu.com/p/2fd56627a3cf