JVM内存管理

JVM的内存区域划分

方法区

这个方法区是在JDK 1.8以前的版本里,代表JVM中的一块区域。主要是放从“.class”文件里加载进来的类,还会有一些类似常量池的东西放在这个区域里。
但是在JDK 1.8以后,这块区域的名字改了,叫做“Metaspace”,可以认为是“元数据空间”这样的意思。当然这里主要还是存放我们自己写的各种类相关的信息。

程序计数器(Program Counter)

程序计数器就是用来记录当前执行的字节码指令的位置的。
道JVM是支持多个线程的,所以写好的代码可能会开启多个线程并发执行不同的代码,所以就会有多个线程来并发的执行不同的代码指令
因此每个线程都会有自己的一个程序计数器,专门记录当前这个线程目前执行到了哪一条字节码指令了

java虚拟机栈

Java代码在执行的时候,一定是线程来执行某个方法中的代码 哪怕就是下面的代码,也会有一个main线程来执行main()方法里的代码
在main线程执行main()方法的代码指令的时候,就会通过main线程对应的程序计数器记录自己执行的指令位置。
notion image
在方法里,经常会定义一些方法内的局部变量,比如在上面的main()方法里,其实就有一个replicaManager局部变量,他是引用一个ReplicaManager实例对象的,
因此,JVM必须有一块区域是来保存每个方法内的局部变量等数据的,这个区域就是Java虚拟机栈
每个线程都有自己的Java虚拟机栈,比如这里的main线程就会有自己的一个Java虚拟机栈,用来存放自己执行的那些方法的局部变量。

栈帧

如果线程执行了一个方法,就会对这个方法调用创建对应的一个栈帧,栈帧里就有这个方法的局部变量表 、操作数栈、动态链接、方法出口等东西,
比如main线程执行了main()方法,那么就会给这个main()方法创建一个栈帧,压入main线程的Java虚拟机栈 同时在main()方法的栈帧里,会存放对应的“replicaManager”局部变量 上述过程,如下图所示:
notion image
然后假设main线程继续执行ReplicaManager对象里的方法,比如下面这样,就在loadReplicasFromDisk方法里定义了一个局部变量:hasFinishedLoad
notion image
那么main线程在执行上面的“loadReplicasFromDisk”方法时,就会为“loadReplicasFromDisk”方法创建一个栈帧压入线程自己的Java虚拟机栈里面去。
然后在栈帧的局部变量表里就会有“hasFinishedLoad”这个局部变量。整个过程如下图所示:
notion image
然后如果loadReplicasFromDisk方法也执行完毕了,就会把loadReplicasFromDisk方法也从Java虚拟机栈里出栈。

java堆(Heap)

Java堆内存,这里就是存放我们在代码中创建的各种对象的
notion image
上面的new ReplicaManager()这个代码就是创建了一个ReplicaManager类的对象实例,这个对象实例里面会包含一些数据,如下面的代码所示。
这个“ReplicaManager”类里的“replicaCount”就是属于这个对象实例的一个数据。
notion image
类似ReplicaManager这样的对象实例,就会存放在Java堆内存里。
Java堆内存区域里会放入类似ReplicaManager的对象,然后我们因为在main方法里创建了ReplicaManager对象的,那么在线程执行main方法代码的时候,就会在main方法对应的栈帧的局部变量表里,让一个引用类型的replicaManager局部变量来存放ReplicaManager对象的地址
相当于你可以认为局部变量表里的replicaManager指向了Java堆内存里的ReplicaManager对象
notion image
 

本地方法栈

主要用于处理本地方法(native修饰的方法)和虚拟机栈一样本地方法区域会抛出StackOverFlowErrorOutOfMemoryError
比如下面这样的:public native int hashCode();
在调用这种native方法的时候,就会有线程对应的本地方法栈,这个里面也是跟Java虚拟机栈类似的,也是存放各种native方法的局部变量表之类的信息。

运行时常量池

方法区的一部分,存放类加载后Class文件的常量池信息,常量池存放编译期生成的各种字面量和符号引用,还会把符号引用解析后的直接引用也存储到运行时常量池.
运行时常批池相对干Class文件常量池的另外一个重要特征是具备动态性, Java语言并不要求常量一定只能在编译期产生, 也就是并非预置入 Class文件中常虽池的内容才能进入方法区运行时常量池, 运行期间也可能将新的常量放入池中, 这种特性被开发人员利用得比较多的便是String类的 intern()方法。
既然运行时常量池是方法区的一部分, 自然会受到方法区内存的限制, 当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
 
直接内存Direct Memory 也叫堆外内存
直接内存并不是虚拟机允许时数据区的一部分,也不是java虚拟机规范定义的内存区域,但是这部分内存也被频繁地使用,也可能导致OutOfMemoryError异常
JDK1.4新加入了NIO类,引入一种基于通道与缓冲区地I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在java堆里面地DirectByteBuffer对象作为这块内
存地引用进行操作.这样在一些场景中显著提高性能,因为避免了在java堆和Native堆中来回复制数据

垃圾回收机制

垃圾回收机制

垃圾收集器

  1. 垃圾回收算法是内存回收的方法论,垃圾收集器是内存回收的具体实现
  1. java针对新生代和老年代提供了多种不同的垃圾收集器
  1. 如果两个收集器之间存在连线,则它们可以搭配使用

Serial 垃圾收集器和Serial Old垃圾回收器

Serial和Serial Old垃圾回收器:分别用来回收新生代和老年代的垃圾对象
工作原理就是单线程运行,垃圾回收的时候会停止我们自己写的系统的其他工作线程,让我们系统直接卡死不动,然后让他们垃圾回收,这个现在一般写后台Java系统几乎不用。

ParNew 垃圾收集器

最常用的新生代垃圾回收器
ParNew垃圾收集器是Serial收集器的多线程本,也使用复制算法,除了使用多线程进行垃圾收集之外,其余的行为和Serial收集器完全一样
,ParNew垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程.
ParNew垃圾收集器默认开启和CPU数目相同的程数,可以通过可以通过-XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。
ParNew垃圾收集器是很多 java虚拟机运行在 Server 模式下新生代的默认垃圾收集器。
启动系统的时候是可以区分服务器模式和客户端模式的,如果你启动系统的时候加入-server就是服务器模式,如果加入-cilent就是客户端模式。 他们俩的区别就是,如果你的系统部署在比如4核8G的Linux服务器上,那么就应该用服务器模式,如果你的系统是运行在比如Windows上的客户端程序,那么就应该是客户端模式。

CMS 收集器

CMS 收集器

G1 收集器

Garbage first 垃圾收集器是目前垃圾收集器理论发展的最前沿成果,相比与 CMS 收集器,G1 收集器两个最突出的改进是:
  1. 基于标记-整理算法,不产生内存碎片。
  1. 可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。
G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,确保G1 收集器可以在有限时间获得最高的垃圾收集效率
G1 收集器原理
 

ZGC

 

GC的概念名词

Minor GC / Young GC

“新生代”也可以称之为“年轻代”,这两个名词是等价的。那么在年轻代中的Eden内存区域被占满之后,实际上就需要触发年轻代的gc,或者是新生代gc。
此时这个新生代gc,其实就是所谓的“Minor GC”,也可以称之为“Young GC”,这两个名词,相信大家就理解了,说白了,就专门针对新生代的gc。
Young GC其实一般就是在新生代的Eden区域满了之后就会触发,采用复制算法来回收新生代的垃圾

Old GC

其实所谓的老年代gc,称之为“Old GC”更加合适一些,因为从字面意义上就可以理解,这就是所谓的老年代gc。

触发时机

  1. 发生Young GC之前进行检查,如果“老年代可用的连续内存空间” < “新生代历次Young GC后升入老年代的对象总和的平均大小”,说明本次Young GC后可能升入老年代的对象大小,可能超过了老年代当前可用内存空间
  1. 此时必须先触发一次Old GC给老年代腾出更多的空间,然后再执行Young GC,Young GC之后有一批对象需要放入老年代,此时老年代就是没有足够的内存空间存放这些对象了,此时必须立即触发一次Old GC
  1. 老年代内存使用率超过了92%,也要直接触发Old GC,当然这个比例是可以通过参数调整的
其实说白了,上述三个条件你概括成一句话,就是老年代空间也不够了,没法放入更多对象了,这个时候务必执行OldGC对老年代进行垃圾回收。
大家在很多地方看到一个说法,意思是说Old GC执行的时候一般都会带上一次Young GC可能很多人不理解,其实如果你把咱们这里的几个条件分析清楚了就知道了,一般Old GC很可能就是在Young GC之前触发或者在Young GC之后触发的,所以自然Old GC一般都会跟一次Young GC连带关联在一起了。
另外一个,在很多JVM的实现机制里,其实在上述几种条件达到的时候,他触发的实际上就是Full GC,这个Full GC会 包含Young GC、Old GC和永久代的GC 也就是说触发Full GC的时候,可能就会去回收年轻代、老年代和永久代三个区域的垃圾对象。

Full GC

对于Full GC,其实这里有一个更加合适的说法,就是说Full GC指的是针对新生代、老年代、永久代的全体内存空间的垃圾回收,所以称之为Full GC。
“Full”就是整体的意思,所以就是对JVM进行一次整体的垃圾回收,把各个内存区域的垃圾都回收掉。

Major GC

可以是Full GC,也可以是Full GC

Mixed GC

Mixed GC是G1中特有的概念,其实说白了,主要就是说在G1中,一旦老年代占据堆内存的45%了,就要触发MixedGC,此时对年轻代和老年代都会进行回收

内存分配与回收策略

对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲TLAB , 将按线程优先在TLAB上分配. 少数情况直接分配在老年代
  1. 多数情况下,对象优先在Eden分配中分配,当Eden区没有足够的空间进行配时,虚拟机将发起一次Minor GC
  1. 大对象直接进入老年代,大对象:需要大量连续内存空间的java对象,如字符串及数组
  1. 长期存活的对象将进入老年代,
    1. 虚拟机给每个对象定义了一个对象年龄(Age)计数器.如果对象在Eden出生并经过第一次Minor GC 后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor中,并将对象年龄设为1,对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁时),就会被晋升到老年代中,阈值可以通过 XX:MaxTenuringThreshold来设置,
  1. 动态对象年龄判断,
    1. 虚拟机并不总是要求对象的年龄必须达到阈值才能移动到老年代,
      如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于等或等于该年龄的对象就可以直接进入老年代
  1. 空间分配担保:
    1. 在发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于,则改为直接进行一次Full GC,如果小于,则查看HandlePromotion Failure 设置是否允许担保失败;如果允许, 那只会进行 Minor GC; 如果不允许,则进行一次Full GC.
 

常见GC问题场景

 
年轻代gc到底多久一次对系统影响不大?
其实通常来说是不大的,其实年轻代gc几乎没什么好调优的,因为他的运行逻辑非常简单,就是Eden一旦满了无法放新对象就触发一次gc。
一般来说,真要说对年轻代的gc进行调优,只要你给系统分配足够的内存即可,核心点还是在于堆内存的分配、新生代内存的分配内存足够的话,通常
来说系统可能在低峰时期在几个小时才有一次新生代gc,高峰期最多也就几分钟一次新生代gc。
而且一般的业务系统都是部署在2核4G或者4核8G的机器上,此时分配给堆的内存不会超过3G,给新生代中的Eden区的内存也就1G左右。
而且新生代采用的复制算法效率极高,因为新生代里存活的对象很少,只要迅速标记出这少量存活对象,移动到Survivor区,然后回收掉其他全部垃圾对象即可,速度很快。 很多时候,一次新生代gc可能也就耗费几毫秒,几十毫秒。大家设想一下,假如说你的系统运行着,然后每隔几分钟或者几十分钟执行一次新生代gc,系统卡顿几十毫秒,就这期间的请求会卡顿几十毫秒,几乎用户都是无感知的,所以新生代gc一般基本对系统性能影响不大。
 
什么时候新生代gc对系统影响很大?
简单,当你的系统部署在大内存机器上的时候,比如说你的机器是32核64G的机器,此时你分配给系统的内存有几十个G,新生代的Eden区可能30G~40G的内存。
比如类似Kafka、Elasticsearch之类的大数据相关的系统,都是部署在大内存的机器上的,此时如果你的系统负载非常的高,对于大数据系统是很有可能的,比如每秒几万的访问请求到Kafka、Elasticsearch上去。
那么可能导致你Eden区的几十G内存频繁塞满要触发垃圾回收,假设1分钟会塞满一次。
然后每次垃圾回收要停顿掉Kafka、Elasticsearch的运行,然后执行垃圾回收大概需要几秒钟,此时你发现,可能每过一分钟,你的系统就要卡顿几秒钟,有的请求一旦卡死几秒钟就会超时报错,此时可能会导致你的系统频繁出错。
 
如何解决大内存机器的新生代GC过慢的问题?
我们针对G1垃圾回收器,可以设置一个期望的每次GC的停顿时间,比如我们可以设置一个20ms。那么G1基于他的Region内存划分原理,就可以在运行一段时间之后,比如就针对2G内存的Region进行垃圾回收,此时就仅仅停顿20ms,然后回收掉2G的内存空间,腾出来了部分内存,接着还可以继续让系统运行。G1天生就适合这种大内存机器的JVM运行,可以完美解决大内存垃圾回收时间过长的问题。
 
要命的频繁老年代gc问题
综上所述,其实新生代gc一般问题不会太大,但是真正问题最大的地方,在于频繁触发老年代的GC。
之前给大家讲过对象进入老年代的几个条件:年龄太大了、动态年龄判断规则、新生代gc后存活对象太多无法放入Survivor中。
给大家重新分析一下这几个条件。
第一个,对象年龄太大了,这种对象一般很少,都是系统中确实需要长期存在的核心组件,他们一般不需要被回收掉,所以在新生代熬过默认15次垃圾回收之后就会进入老年代。
第二个,动态年龄判定规则,如果一次新生代gc过后,发现Survivor区域中的几个年龄的对象加起来超过了Survivor区域的50%,比如说年龄1+年龄2+年龄3的对象大小总和,超过了Survivor区域的50%,此时就会把年龄3以上的对象都放入老年代。
第三个,新生代垃圾回收过后,存活对象太多了,无法放入 Surviovr中,此时直接进入老年代。
其实上述条件中,第二个和第三个都是很关键的,通常如果你的新生代中的Survivor区域内存过小,就会导致上述第二个和第三个条件频繁发生,然后导致大量对象快速进入老年代,进而频繁触发老年代的gc
 
老年代gc通常来说都很耗费时间,无论是CMS垃圾回收器还是G1垃圾回收器,因为比如说CMS就要经历初始标记、并发标记、重新标记、并发清理、碎片整理几个环节,过程非常的复杂,G1同样也是如此。
通常来说,老年代gc至少比新生代gc慢10倍以上,比如新生代gc每次耗费200ms,其实对用户影响不大,但是老年代每次gc耗费2s,那可能就会导致老年代gc的时候用户发现页面上卡顿2s,影响就很大了。
所以一旦你因为jvm内存分配不合理,导致频繁进行老年代gc,比如说几分钟就有一次老年代gc,每次gc系统都停顿几秒钟,那简直对系统就是致命的打击。
此时用户会发现页面上或者APP上经常性的出现点击按钮之后卡顿几秒钟。
JVM性能优化到底在优化什么?
系统真正最大的问题,就是因为内存分配、参数设置不合理,导致你的对象频繁的进入老年代,然后频繁触发老年代gc,导致系统频繁的每隔几分钟就要卡死几秒钟。