Jvm 总结

Java 虚拟机主要分为三个部分:类加载器、运行时数据区和执行引擎,其中类类加载器负责将类的字节码文件加载到内存中,运行时数据区存储jvm运行时产生的数据,执行引擎负责浮动程度的执行。

类加载器

类加载器就是我们经常说的ClassLoader,Java提供了三种类型ClassLoader,分别是BootstrapClassLoader(启动类加载器)、ExtClassLoader(扩展类加载器)和AppClassLoader(应用程序类加载器),其中启动类加载器负责加载java核心类库,扩展类加载器负责加载lib/ext目录下的类库,应用程序类加载器负责加载类路径下的类,这三个类加载器的关系是:

AppClassLoader extends ExtClassLoader extends BootstrapLoader

Java中的类加载采用双亲委派机制,对于加载请求首先会转给父类加载器加载,只有父类加载器加载不到时,在逐级向下传递,双亲委派机制两个好处:

  • 保证沙箱安全
  • 避免类被重复加载

类加载的过程包括加载、验证、准备、解析和初始化,其中加载主要是将class字节码文件以数据流的方式加载到内存,验证阶段会对加载进来的字节码格式进行验证,如果不符合jvm规范则会报错,准备阶段是为静态变量分配内存空间并赋予初始值,解析阶段会将一些静态的符号引用转化为直接引用,初始化阶段对静态变量按照代码逻辑进行初始化赋值。

运行时数据区

运行时数据区是jvm中最核心的内存区域,存储了所有的类元信息、对象实例等数据。运行时数据区分为线程栈、本地方法栈、程序计数器、方法区和堆空间 五个部分。其中线程栈、本地方法栈和程序计数器是线程私有的区域,堆空间和方法区是公共的内存区域。

线程栈用来存储当前线程方法调用的栈帧数据,栈帧是对方法调用的抽象,线程没调用一个方法,就会产生一个栈帧并被压入到线程栈中,方法执行结束后,栈帧从线程栈中弹出。栈帧中存储了方法的临时变量表、操作数栈、动态链接和方法的出口。

本地方法栈的作用和本地方法栈类似,知识它是作用于本地方法调用,而不是java方法的调用。

程序计数器用来记录当前线程的执行位置,以便当发生cpu时间片轮转时,线程能够恢复到正确的位置,继续执行。

方法区用来存储类元信息、常量和静态变量等数据,在jdk1.8以前,这部分被称为永久代,但从jdk1.8以后,永久代被移除了,用元数据区来取代。

堆空间用来存储对象实例,通常分为新生代和老年代,新生代用来存储新创建的对象,老年代用来存储长期存储的对象,新生代又进一步分为Eden区和Survivor区,这主要根具体使用哪种垃圾搜集算法有关系,这部分内存区域也是进行jvm优化的主要区域。

自动垃圾收集机制

Java采用自动垃圾收集机制对内存区域进行回收,避免向c语言那样使用手动回收出现遗漏时,造成内存泄漏。垃圾收集类型主要分为三种,一种是Young GC,对新生代内存进行回收,一种是Old GC,对老年代内存空间进行回收,Full GC是同时对新生代和老年代进行回收,比较耗时,在进行jvm优化时应该尽量避免Full GC的发生。

对于垃圾对象的标记,主要有两种算法:

  • 引用计数
  • 可达性分析

其中,引用计数算法无法解决循环依赖问题,容易造成内存泄漏,所以通常会使用可达性分析来实现垃圾对象的标记,可达性分析的思路是从一个被叫做GC Root根的对象开始,向下搜索,如果一个对象与GC Root根对象之间没有一条链路的话,那就说明这个对象时垃圾对象,即这个对象已经不被使用了,GC Root 根对象主要有三种:

  • 线程栈中的遍历
  • 本地方法栈中的遍历
  • 静态变量

在执行GC时,Jvm对不同类型的引用也有不同的处理策略:

  • 强引用:对象不会被回收;
  • 软引用:当进行Full GC时如果仍然没有足够的空间,则将弱引用对象回收;
  • 弱引用:只要发生GC 就会被回收;
  • 虚引用:一般用不到,不用管它;

另外对于重写了finalize方法的对象,在回收时,会做特殊处理,首先这些对象会被放到一个队列中,然后jvm会在后台启动一个优先级比较低的线程从队列中拿到这些对象并调用finalize方法,如果在finalize方法中重新与GC Root对象建立了联系,那么这个对象最终不会被回收,否则,就会被GC回收掉,所以,在开发中尽量不要通过覆盖finalize方法进行一些操作,一方面finalize方法的执行实际不被jvm保证,另外在进行GC时,这些对象会被特殊处理,影响GC效率。

对于Class对象,正常情况下不会被回收,但也不是绝对的,只要能满足以下条件,方法区中的class对象也是会被回收的:

  • 对应的所有实例对象都已经被回收;
  • Class对象没有在任何地方被引用到;
  • 加载这个类的ClassLoader也被回收;

Jvm中涉及的垃圾收集算法有三种,分别是标记清除算法、标记整理算法、复制算法。Jvm采用分代收集策略,组合这几种不同的算法实现垃圾收集。Jvm目前提供的垃圾收集器有Serial GC、ParNew GC、Parallel GC、CMS GC和G1 GC,其中,Serial GC是一种单线程的GC,它可以用在新生代也可以用在老年在,在新生代采用复制算法,在老年代采用标记整理算法,Serial GC在多核CPU中不能够利用多核的特性,会影响执行效率,这种收集器通常被用在 client 模式下的Jvm中,因为在client 模式下,需要处理的垃圾对象比较少,这种实现方式反而简单高效。ParNew GC可以理解为Serial GC的多线程版,经常被用在新生代,和CMS GC配合使用。Parallel GC是一个能够控制用户线程停顿时间(Stop The World)的垃圾收集器,这种特性通常也被称为吞吐量优先,在新生代采用复制算法,老年代采用标记整理算法,这种垃圾收集器并不常用。CMS GC是一款老年代垃圾收集器,通常与ParNew GC一起配合使用,采用标记清除算法,当然也可以通过参数-XX:UseCMSCompactAtFullCollection来配置是否进行整理,以防止产生内存碎片,CMS GC的目标是尽量减少用户线程的停顿时间,它在执行的时候分为四个步骤:

  • 初始标记:标记所有的GC Root 根对象,会发生Stop The World,但时间非常短;
  • 并发标记:与用户线程并行执行,从前一步找出的GC Root 根对象开始,查找引用链,确定垃圾对象;
  • 重新标记:会出现Stop The World,但是是多线程执行,对第二步用户线程的影响进行修正;
  • 并发清理:不会出现Stop The World,对垃圾对象进行清理,这里会产生浮动垃圾,要等到下次GC才能被清理;

另外,在并发标记和并发清理阶段,由于是和用户线程并行执行,所以会对CPU敏感。

另外还有一种在新近引入的GC ,叫G1 GC,这种GC相比于前集中GC,在实现上有一些区别,GC GC被内存区域分成了若干个等大小的Region区域,每个Region区域可以存储包含Eden、Survivor、Old区的对象,当这些Region内存被回收后,又可以分配其他区域的对象到这个Region,更加灵活。另外,G1 GC的另一个特点是可以通过参数精确控制GC 时间,在G1 GC下,Eden区默认大小为整个堆内存大小的5%,当被分配满后,G1会判断回收这些内存所需时间与用户配置的GC停顿时间的大小,如果小于用户配置的GC停顿时间,那么,就会扩大Eden区的大小,继续分配对象,直到匹配用户配置的GC停顿时间,这样,即提高了GC效率,同时又能保证GC停顿时间可控,基于以上特点,G1 GC一般被用在大内存场景中,例如,32G或64G的服务器,如果此时还采用其他垃圾收集器的话,由于内存空间太大,即使是Young GC,每次话费的时间也会比较长,但G1就可以根据用户的配置来控制GC时间,确保用户线程不会被长时间阻塞。

对象的分配策略

  • 大对象直接分配到老年代:大对象的标准由参数指定,如果一个对象的大小达到了配置值,则直接分配在老年代;
  • GC 年龄超过上限的对象直接分配到老年代:默认的GC 年龄上限是15,也可以通过参数进行调整;
  • 执行Young GC后,Survivor放不下的话会直接放到老年代
  • 动态年龄判断机制:这个规则的意思是,如果分配到Survivor中存活的对象的大小综合 > Survivor的一半 时,那么大于等于这批对象年龄最大值的对象都会被放到老年代。例如,例如 年龄1 + 年龄2 + 年龄3 + … + 年龄k + … + 年龄n = Survivor * 80%,年龄1 + … + 年龄k = Survivor * 50 ,那么,年龄在 k ~ n 之间的对象都会被放到老年代。
  • 老年代分配担保机制:当发生Young GC时,首先会判断年轻代对象的总大小是否大于老年代剩余空间的大小,然后再判断有没有开启老年代分配担保机制,如果没有,则直接进行Full GC,如果开启,则判断历次进行Young GC后存活的对象中进入到老年代的对象的平均大小,如果这个值仍然大于老年代剩余空间,则执行Full GC,否则执行Young GC。

JVM 调优

一般来说,JVM调优的目标有一下几个:

  • 减少full gc的次数
  • 减少gc的次数
  • 减少gc的执行时间

针对的系统一般有两种:

  • 新上线的系统,对jvm内存分配策略做评估
  • 以及运行的系统,gc导致用户线程收到了影响

对于减少full gc的次数的优化,首先要明确什么情况下会触发full gc,这就需要结合对象的分配策略来分析,通常,触发full gc是由于老年代剩余空间比较小导致的,那么,我们就要分析什么情况下,对象会被转移到老年代:

  • 大对象会被直接分配到老年代:可以通过参数调节大对象的标准,但是需要根据业务系统的特点,确定大对象的范围,一方面,要避免哪些朝生夕死的较大对象进入老年代,一方面,要让长期存活的大对象尽快进入老年代,这就需要分析业务对象的特点了。
  • GC年龄默认超过15次的,会被分配到老年代:这里有优化点需要注意,一是要让那些确实会长期存活的对象尽早进入老年代,避免在新生代中来回移动,消耗性能,另一方面,要适当增大新生代大小,避免频繁发生Young GC导致对象GC年龄晋升的过快,最终导致不该进入老年代的对象进入了老年代。
  • Young GC后,如果Survivor放不下存活的对象,这些对象就会被放到老年代,所以,可以通过适当调大Survivor大小,来避免对象过早的晋升到老年代。
  • 动态对象年龄判断机制:对于这一点,也可以通过适当调大Survivor大小,来避免对象过早的晋升到老年代。
  • 老年代分配担保机制:可以通过开启老年代分配担保机制,来避免在发生Young Gc时,只有新生代对象总大小大于老年代剩余空间时就触发Full GC。

在对jvm进行优化时,可以借助一些java 提供的命令来辅助我们,比如,可以开启GC log来记录jvm执行gc的详细情况,方便对jvm的gc行为进行分析,另外,还可以通过jmap -heap / jstack -gc 来查看堆内存中各个 区域的使用情况。

对于如何评估jvm 内存配置参数,个人一般的做法是,根据系统的特点,找到业务主线,然后评估出一个主流程走下来所创建的对象的的大小,再根据周边流程的特点,将结果放到10-20倍,预留出足够的缓冲空间,然后再评估业务流量,例如业务qps达到500,一个主流程评估下来需要创建 1MB对象,那么也就是说,每秒钟会创建
500MB的对象,而这些对象都是些朝生夕死的对象,那么就需要保证Yong区有足够的空间,给GC的执行流出余地,避免因为Yong区太小,导致频繁的Yong GC,最终导致大量对象进入老年代触发Full GC。

另外还需要注意的的,在评估内存分配策略时,还需要考虑,当qps很高,系统压力比较大时,系统磁盘、网络等的性能也会有所下降,往往正常1秒钟处理完的业务,可能需要更长时间,所以也要为这些预留出足够的空间。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章