JVM实战—2.JVM内存设置与对象分配流转
大纲
1.JVM内存划分的原理细节
2.对象在JVM内存中如何分配如何流转
3.部署线上系统时如何设置JVM内存大小
4.如何设置JVM堆内存大小
5.如何设置JVM栈内存与永久代大小
6.问题汇总
1.JVM内存划分的原理细节
(1)背景引入
(2)大部分对象的存活周期都是极短的
(3)少数对象是长期存活的
(4)JVM分代模型:新生代和老年代
(5)为什么要分成新生代和老年代
(6)什么是永久代
(1)背景引入
接下来介绍JVM内存的分代模型:新生代、老年代、永久代。现在已知代码里创建的对象,都会进入到Java堆内存中。如下所示,main()方法会周期性执行loadReplicasFromDisk()方法来加载副本数据。
public class Kafka {
public static void main(String[] args) {
while (true) {
loadReplicasFromDisk();
Thread.sleep(1000);
}
}
private static void loadReplicasFromDisk() {
ReplicaManager replicaManager = new ReplicaManager();
replicaManager.load();
}
}
一.首先执行main()方法,就会把其栈帧压入main线程的Java虚拟机栈,如下图示:
二.然后main线程每次在while循环里调用loadReplicasFromDisk()方法,就会把loadReplicasFromDisk()方法的栈帧压入自己的Java虚拟机栈,如下图示:
三.接着在执行loadReplicasFromDisk()方法时,就会在Java堆内存里创建一个ReplicaManager对象实例。然后loadReplicasFromDisk()方法的栈帧会有一个replicaManager局部变量,replicaManager局部变量会引用Java堆内存的ReplicaManager对象实例,如下图示:
四.接着就会执行ReplicaManager对象实例的load()方法。
(2)大部分对象的存活周期都是极短的
上面代码中的ReplicaManager对象,就是一个短暂存活的对象。在loadReplicasFromDisk()方法中创建这个ReplicaManager对象,然后执行ReplicaManager对象的load()方法。执行完毕后,loadReplicasFromDisk()方法就会结束。一旦方法执行结束,那么loadReplicasFromDisk()方法的栈帧就会出栈。如下图示:
然后一旦这个ReplicaManager对象没被引用了,就会被JVM的垃圾回收线程给回收掉,释放内存空间。如下图示:
继续回到main()方法的while循环里。下次循环执行loadReplicasFromDisk()方法时,又重复一遍上面的过程,把loadReplicasFromDisk()方法的栈帧压入Java虚拟机栈,然后构造一个ReplicaManager实例对象放在Java堆里。一旦执行完ReplicaManager对象的load()方法后,loadReplicasFromDisk()方法又会结束,再次出栈。然后垃圾回收释放掉Java堆内存里的ReplicaManager对象。
所以上面代码的ReplicaManager对象,就是一个存活周期很短的对象。每次执行loadReplicasFromDisk()方法时,该对象就会被创建出来。然后执行对象的load()方法,接着可能1毫秒后,就要被垃圾回收掉。
所以从这段代码就可以明显看出来:代码里大部分创建的对象,其实存活周期都是很短的。
(3)少数对象是长期存活的
接下来看下面代码,用另外的方式来实现同样的功能,也就是给Kafka这个类定义一个静态变量replicaManager。
public class Kafka {
private static ReplicaManager replicaManager = new ReplicaManager();
public static void main(String[] args) {
while (true) {
loadReplicasFromDisk();
Thread.sleep(1000);
}
}
private static void loadReplicasFromDisk() {
replicaManager.load();
}
}
这个Kafka类位于JVM的方法区,它有一个静态变量replicaManager,replicaManager静态变量会引用一个在Java堆内存创建的ReplicaManager对象。如下图示:
main()方法会通过while循环不停调用ReplicaManager对象的load()方法,这时这个ReplicaManager实例对象是会一直被Kafka的静态变量引用的。然后它会一直驻留在Java堆内存里,不会被垃圾回收掉。因为这个实例对象它需要长期被使用,周期性的被调用load()方法,所以这个ReplicaManager实例对象就成为了一个长时间存在的对象。
类似这种被类的静态变量长期引用的对象,就会长期留在Java堆内存里。这种对象就是生命周期很长的对象,它不会轻易被垃圾回收。
(4)JVM分代模型:新生代和老年代
可见,采用不同的方式来创建和使用对象,对象的生命周期是不同的。所以JVM将Java堆内存划分为两个区域:一个是新生代,一个是老年代。其中新生代,就是把创建和使用完之后,马上就要回收的对象放在里面。然后老年代,就是把创建后需要一直长期存在的对象放在里面,如下图示:
下面来看如下代码:
public class Kafka {
private static ReplicaManager fetcher = new ReplicaFetcher();
public static void main(String[] args) {
loadReplicasFromDisk();
while(true) {
fetchReplicasFromRemote();
Thread.sleep(1000);
}
}
private static void loadReplicasFromDisk() {
ReplicaManager replicaManager = new ReplicaManager();
replicaManager.load();
}
private static void fetchReplicasFromRemote() {
fetcher.fetch();
}
}
这段代码的意思是:
一.类的静态变量fetcher引用了ReplicaFetcher对象,要长期驻留内存
所以ReplicaFetcher对象会在新生代停留一会儿,但最终会进入老年代,如下图示:
二.进入main()方法后,会先调用loadReplicasFromDisk()方法
该方法意思是系统启动就从磁盘加载一次数据,这个方法的栈帧会入栈。然后在该方法里会创建一个ReplicaManager对象,这个对象用完就回收。所以ReplicaManager对象会放在新生代里,由栈帧里的局部变量来引用。如下图示:
三.一旦loadReplicasFromDisk()方法执行完毕,其栈帧就会出栈
对应的新生代里的ReplicaManager对象也会被回收掉,如下图示:
四.接着会执行一段while循环代码
即周期性调用ReplicaFetcher的fetch()方法,从远程加载副本数据。由于ReplicaFetcher这个对象被Kafka类的静态变量fetcher给引用了,所以它会长期存在于老年代里的,持续被使用。
(5)为什么要分成新生代和老年代
之所示需要这么区分,是因为这和垃圾回收有关。新生代里的对象,创建后很快就会被回收,所以需要一种垃圾回收算法。老年代里的对象,需要长期存在,所以需要另一种垃圾回收算法。因此才需要分成两个区域来放不同的对象。
(6)什么是永久代
JVM里的永久代其实就是方法区,方法区就是所谓的永久代,可以认为永久代就是放一些类的信息。
(7)问题
每个线程都有Java虚拟机栈,里面也有方法的局部变量等数据,那么这个Java虚拟机栈里的局部变量需要进行垃圾回收吗?
JVM垃圾回收针对的是新生代、老年代、方法区,不针对方法的栈帧。方法一旦执行完毕,栈帧出栈,里面的局部变量就从内存里清理掉了。
2.对象在JVM内存中如何分配如何流转
(1)对象分配的基础知识总结
(2)大部分对象都优先在新生代分配内存
(3)什么情况下会触发新生代的垃圾回收
(4)长期存活的对象会躲过多次垃圾回收
(5)老年代会垃圾回收吗
(6)关于新生代和老年代的对象分配总结
(1)对象分配的基础知识总结
Java代码里创建出来的对象,一般就是两种:第一种是短期存活的,迅速使用完后就会被垃圾回收;第二种是长期存活的,一直存在Java堆内存里。
第一种短期存活的对象,会在Java堆内存的新生代里。第二种长期存活的对象,会在Java堆内存的老年代里。
那么对象什么时候进入新生代?
什么情况下会进入老年代?
(2)大部分对象都优先在新生代分配内存
大部分对象,都会优先在新生代分配内存。
public class Kafka {
private static ReplicaManager fetcher = new ReplicaFetcher();
public static void main(String[] args) {
loadReplicasFromDisk();
while(true) {
fetchReplicasFromRemote();
Thread.sleep(1000);
}
}
private static void loadReplicasFromDisk() {
ReplicaManager replicaManager = new ReplicaManager();
replicaManager.load();
}
private static void fetchReplicasFromRemote() {
fetcher.fetch();
}
}
上述代码中:类的静态变量fetcher引用的ReplicaFetcher对象,会长期存活在内存里。但是该对象刚开始由new ReplicaFetcher()实例化时,是在新生代里的。loadReplicasFromDisk()中创建的ReplicaManager对象,也在新生代中。如下:
(3)什么情况下会触发新生代的垃圾回收
一旦loadReplicasFromDisk()方法执行完毕后,这个方法的栈帧出栈,这个时候便没有任何局部变量引用ReplicaManager实例对象了。如下图示:
此时是否会对没被使用的ReplicaManager实例对象进行垃圾回收?此时是不会马上对失去引用的Java堆实例对象进行垃圾回收的,因为垃圾回收也有触发条件。
其中一个比较常见的触发场景是:假设代码创建了很多对象,然后导致Java堆内存里堆积了大量对象。然后这些对象之前都会被各方法中的局部变量引用,但现在没被引用了。如下图示:
这时如果新生代预先分配的内存空间,几乎都被全部对象给占满了,而代码还在继续运行。那么准备在新生代里分配一个对象时,发现新生代里内存空间不够,就会触发一次新生代内存空间的垃圾回收。新生代内存空间的垃圾回收,也称为Minor GC,有时也叫Young GC。Young GC会尝试把新生代里那些没被引用的垃圾对象,都给回收掉。
比如上图的ReplicaManager实例对象,就是没有被引用的垃圾对象。即会把ReplicaManager对象回收,然后存放一个新的对象到新生代。包括上图中的大量实例对象,其实也没被引用。在这个新生代垃圾回收的过程中,就会把这些垃圾对象也都回收掉。
平时代码中创建的大部分对象,都是这种使用后马上就可回收的对象。当已经在新生代里分配了大量对象,且这些对象使用完后也没被引用了。而新生代又差不多满了,要继续分配新对象时发现新生代内存空间不足,就会触发一次垃圾回收,把所有垃圾对象给回收掉,腾出大量内存空间。如下图示:
(4)长期存活的对象会躲过多次垃圾回收
上图中的ReplicaFetcher实例对象,是一个被Kafka类的静态变量fetcher引用的、长期存活的对象。
所以虽然新生代可能随着系统的运行,不停地创建对象,然后让新生代变满,接着进行垃圾回收,大量对象又会被回收掉。但是这个ReplicaFetcher对象,它会一直存活在新生代里。因为它一直被Kafka类的静态变量引用着,所以它不会被回收。
因此JVM有规定:如果一个实例对象在新生代中成功躲过15次垃圾回收,就进入老年代。对象的年龄就是:每进行一次垃圾回收而没被回收掉,对象年龄就加1。
所以如果ReplicaFetcher对象在新生代中成功躲过15多次垃圾回收,那么ReplicaFetcher对象就会被认为会长期存活在内存里,然后就会被转移到老年代中。老年代会存放一些年龄很大的对象,如下图示:
(5)老年代会垃圾回收吗
老年代里的那些对象会被垃圾回收吗?答案是肯定的。因为老年代的对象也有可能随着代码的运行不再被引用,也要垃圾回收。当越来越多对象进入老年代,一旦老年代满了,也要对老年代垃圾回收。
(6)关于新生代和老年代的对象分配总结
目前已介绍如下机制:
一.对象优先分配在新生代
二.新生代对象满了就会触发垃圾回收把没有被引用的垃圾对象清理掉
三.如果对象躲过了十五次垃圾回收就会进入老年代
四.如果老年代满了也会触发垃圾回收把没有被引用的垃圾对象清理掉
当然还有其他机制,比如:
一.新生代垃圾回收后因为存活对象太多导致大量对象直接进入老年代
二.大对象不经过新生代直接进入老年代
三.对象动态年龄判断机制
四.空间分配担保机制
3.部署线上系统时如何设置JVM内存大小
(1)对象在JVM内存中的分配流转总结
(2)与JVM内存相关的几个核心参数图解
(3)如何在启动系统的时候设置JVM参数
(4)每日百万交易的支付系统JVM参数优化案例
(1)对象在JVM内存中的分配流转总结
代码里创建的对象,都是优先在新生代分配的。然后随着一些方法执行完毕,大部分对象就没有被引用而成为垃圾对象。如下图示:
随着代码持续运行,新生代里的对象会越来越多。而且新生代里面大部分对象都是生命周期短的对象,很快就不会被引用。因此可以认为新生代里的大部分对象都会是一些垃圾对象。
然后代码继续运行,肯定会创建新的对象,需要分配在新生代里。所以一旦新生代里内存不够了,就会触发一次Young GC。此时会把新生代里没被引用的垃圾对象都给回收掉,腾出内存空间。如下图示:
对于那种长周期存活的对象,它会在新生代里持续躲过多次垃圾回收。每躲过一次垃圾回收,年龄会增长1岁。然后当它成为15岁的"老年对象"时,就会被转移到老年代里。如下图示:
所以核心的问题就是:短生存周期的对象和长生存周期的对象分别是什么,它们是如何在新生代里分配的,新生代什么时候触发YGC,长生存周期的对象如何转移到老年代里。
(2)与JVM内存相关的几个核心参数图解
接下来介绍JVM的参数如何设置。在JVM内存分配中,有几个参数是比较核心的,如下所示:
一.-Xms:Java堆内存的大小
二.-Xmx:Java堆内存的最大大小
三.-Xmn:Java堆内存中的新生代大小
四.-XX:PermSize:永久代大小
五.-XX:MaxPermSize:永久代最大大小
六.-Xss:每个线程的栈内存大小
下面对上述参数进行一一说明。
-Xms和-Xmx:用于设置Java堆内存刚开始大小,以及允许的最大大小。对于这对参数,通常都会设置为完全一样的大小。这两个参数是用来限定Java堆内存的总大小的,如下图示:
-Xmn:这个参数用来设置Java堆内存中的新生代的大小,扣除新生代大小之后的剩余内存就是老年代的内存大小,如下图示:
-XX:PermSize和-XX:MaxPermSize:设置永久代大小和最大永久代大小。JDK1.8后被替换为-XX:MetaspaceSize和-XX:MaxMetaspaceSize,如下图示:
-Xss:这个参数限定每个线程的栈内存大小。每个线程都有一个自己的虚拟机栈,然后每次执行一个方法,就会将方法的栈帧压入线程的栈里。方法执行完毕,那么栈帧就会从线程的栈里出栈。如下图示:
(3)如何在启动系统的时候设置JVM参数
比如以"java -jar"方式启动一个jar包里的系统时,可采用下面格式:
$ java -Xms512M -Xmx512M -Xmn256M -Xss1M
-XX:PermSize=128M -XX:MaxPermSize=128M -jar App.jar
(4)每日百万交易的支付系统JVM优化案例
接下来分析一个支付系统的核心业务流程,然后结合JVM相关知识,来一步步探究JVM内存相关的核心参数,在上线一个生产系统时,应如何针对预估的并发压力,给出一个未经调优的比较合理的初始值。
另外分析各种参数在设置时有哪些考虑的点,Java堆内存到底要多大?新生代和老年代的内存分别要多大?永久代和虚拟机栈分别要多大?
其实JVM参数到底该如何设置,一定是根据不同业务场景来调整的。不会有一个通用的配置和模板,一切都要从案例出发,结合场景来分析。
(5)问题
Tomcat、Spring Boot部署启动系统时,JVM参数如何设置?
答:Spring Boot是在启动时可以加上JVM参数的,Tomcat则是在bin目录下的catalina.sh中加入JVM参数的。
4.如何设置JVM堆内存大小
(1)支付系统核心业务流程
(2)每日百万交易的支付系统的压力在哪里
(3)支付系统每秒钟需要处理多少笔支付订单
(4)每个支付订单处理要耗时多久
(5)每个支付订单大概需要多大的内存空间
(6)每秒发起的支付请求对内存的占用
(7)让支付系统运行起来进行分析
(8)对完整的支付系统内存占用需要进行预估
(9)支付系统的JVM堆内存应该怎么设置
(10)总结
(1)支付系统核心业务流程
支付系统的核心业务流程如下图示:
首先用户在商城系统提交一个支付订单的请求,接着商城系统把这个请求提交给支付系统。支付系统就会生成一个支付订单,此时订单状态可能是"待支付"的状态。然后支付系统指引用户跳转到付款页面,选择一个付款方式。然后用户进行支付,支付系统把实际支付请求转交给第三方支付渠道。第三方支付渠道可能是微信或支付宝,由它们处理支付请求转移资金。如果微信或者支付宝处理完支付后,就会返回支付结果给支付系统,支付系统可以更新自己本地的支付订单的状态变成"已完成"。
当然,其实一个完整的支付系统还包含很多内容。比如还要负责对账以及跟合作商户之间的资金清算,支付系统要包含渠道管理、支付交易、对账管理、结算管理等各种功能,但是这里只关注最核心的支付流程即可。
(2)每日百万交易的支付系统的压力在哪里
一个每日百万交易的支付系统的压力到底集中在哪里?比如上面的那个核心支付流程,假设每日要发生百万次交易。
一般达到百万交易,要不然是国内最大的互联网公司,要不就是一个通用型第三方支付平台,对接各种APP的支付交易。
其实通过上图都能明显看到,上述业务流程中,最核心的环节就是在用户发起支付请求时,会生成一个支付订单。这个支付订单需要记录清楚:是谁发起支付、对哪个商品支付等信息。如果每日百万交易,那么在JVM的角度看:就是每天会在JVM中创建百万个支付订单对象。如下图示:
所以这个每日百万交易的支付系统,它的压力有很多方面:如高并发访问、高性能处理、大量的支付订单数据需要存储等技术难点。但抛开这些系统架构层面的东西,单单在JVM层面支付系统最大的压力:就是每天JVM内存里会频繁创建和销毁100万个支付订单对象。
所以这里就牵扯到一些核心问题:
一.支付系统需要部署多少台机器?
二.每台机器需要多大的内存空间?
三.每台机器启动的JVM需要分配多大堆内存空间?
四.设置JVM多大内存才能创建这么多对象而不会导致内存不够而崩溃?
(3)支付系统每秒钟需要处理多少笔支付订单
要解决线上系统最核心的一个参数,也就是合理设置JVM堆内存大小。首先第一个要计算的,就是每秒钟订单系统要处理多少笔支付订单。
假设每天100万个支付订单。那么一般用户交易行为都会发生在每天的高峰期,比如中午或者晚上。假设每天高峰期大概是3个小时,将100万平均分配到3个小时里。那么大概每秒100笔订单,所以就以每秒100笔订单来进行计算。假设支付系统部署3台机器,则每台机器实际上每秒大概处理30笔订单。如下图示,这个图可以反映出支付系统每秒钟的订单处理压力。
(4)每个支付订单处理要耗时多久
下一个问题,必须要弄清楚的是,每个支付订单大概要处理多长时间?
如果用户发起一次支付请求:那么支付需要在JVM中创建一个支付订单对象,填充进数据。然后把这个支付订单写入数据库,以及可能处理一些其他事情。
假设一次支付请求的处理包含一个支付订单的创建,大概需要1秒时间,那么每台机器一秒钟会接收到30笔支付订单的请求。然后会在JVM新生代里创建30个支付订单的对象,进行写库等处理。接着1秒后这30个支付订单就处理完毕,此时栈帧中对这些支付订单对象的引用就被回收了。然后这些订单对象在JVM的新生代里就是没被引用的垃圾对象了。接着下一秒会继续来处理30个支付订单,重复这个步骤。
(5)每个支付订单大概需要多大的内存空间
接下来计算一下,每个支付订单对象大概需要多大的内存空间?
可以直接根据支付订单类中的实例变量的类型来计算。比如支付订单类如下所示:一个Integer类型的变量数据4字节,一个Long类型的变量数据是8字节,还有别的类型的变量数据占据多少字节等,这样就可以计算出每个支付订单对象大致占多少字节了。
public class PayOrder {
private Integer userId;
private Long orderTime;
private Integer orderId;
}
一般像支付订单这种核心类,可以按20个实例变量来计算。然后大概一个订单对象也就一两百字节,可以算它大一点。比如一个支付订单对象占据500字节的内存空间,也不到1K。
(6)每秒发起的支付请求对内存的占用
假设有3台机器,每秒钟处理30笔支付订单的请求。那么在这1秒内,肯定有方法栈帧里的局部变量在引用这些支付订单对象。那么30个支付订单,大概占据的内存空间是30 * 500字节 = 15000字节。大概15K左右,其实是非常小的,如下图示:
(7)让支付系统运行起来进行分析
现在已经把整个系统运行的关键环节的数据都分析清楚了:每秒30个支付请求,每秒创建30个支付对象,每秒占15K的内存空间。接着1秒过后,这30个对象就没有被引用了,成为新生代里的垃圾。下一秒请求过来,系统继续创建30个支付对象放入新生代里,然后新生代里的对象就会持续累积增加。
直到有一刻,发现可能新生代里都有几十万个对象。此时占据了几百M的内存空间,可能新生代空间就快满了。然后就会触发Young GC,把新生代里的垃圾对象都给回收掉。从而腾出内存空间,可以继续在内存里分配新的对象。
这就是该支付系统在创建订单环节的JVM运行模型。
(8)对完整的支付系统内存占用需要进行预估
前面的分析都是基于一个核心业务流程中的一个支付订单对象来分析的,但那其实那只是整个支付系统的一个小部分而已。
真实的支付系统在线上运行时,肯定会每秒创建大量其他对象。所以可以结合这个访问压力以及核心对象的内存占据,大致估算一下整个支付系统每秒钟大致会占据多少内存空间。
如果要估算的话,其实可以把上述的计算结果扩大10到20倍。即每秒除了在内存里创建支付订单对象,还会创建其他数十种对象。
假设一台机器每秒创建100个500字节的支付订单对象,扩大20倍后,那么每秒创建出的被栈内存的局部变量引用的对象,大概占1M内存空间。然后下一秒对新请求,继续创建1M对象放入新生代,一秒后又变成垃圾。循环多次后,新生代里垃圾太多,就会触发Young GC回收掉这些垃圾。这就是一个完整的支付系统在JVM层面的内存使用模型。
(9)支付系统的JVM堆内存应该怎么设置
一般来说这种线上业务系统的机器配置是2核4G或者是4核8G。
一.2核4G的机器来部署则还是有点紧凑的
虽然机器有4G内存,但机器本身也用一些内存,最后JVM最多2G内存。然后这2G还得分配给方法区、栈内存、堆内存几块区域,那么堆内存可能最多就是有1G多的内存空间。然后堆内存还分为新生代和老年代,老年代需要放置系统的一些长期存活的对象,也要占几百M的内存空间,那么这样下来新生代可能只剩下几百M的内存了。
但上述仅仅是针对一个支付订单对象来分析的,实际上如果扩大20倍来对完整支付系统的预估后:一台机器每秒处理100个订单,每秒就会占据1M左右的内存空间。那么此时如果新生代就几百M的内存空间:就会导致运行几百秒后,新生代内存空间就满了,此时就会触发YGC。如果频繁触发YGC,还是会影响线上系统的性能稳定性的。
二.可以考虑采用4核8G的机器来部署支付系统
此时JVM进程至少可以给4G以上内存,新生代至少可分配2G内存空间。这样就可以做到即便新生代每秒消耗1M左右的内存,也要将近半小时到1小时才会让新生代触发YGC,大大降低了GC频率。
举个例子:
机器采用4核8G,-Xms和-Xmx设置为3G,给整个堆内存3G内存空间。-Xmn设置为2G,给新生代2G内存空间。而且假设业务量如果更大,则可以考虑不只部署3台机器,可以考虑横向扩展部署5台机器或者10台机器,这样每台机器处理的请求更少对JVM的压力更小。
(10)总结
从一个日百万交易的支付系统出发,部署3台机器的场景下。每秒钟每台机器需要处理多少笔订单,每笔订单要耗时多久处理。每笔订单的核心对象每秒钟会对JVM占据多大内存空间,根据单个核心对象横向扩展预估整个系统每秒需要占据多大内存空间。接着根据上述数据模型推算出:在不同的机器配置之下,新生代大致会有多大的内存空间。然后在不同的新生代大小下,多久会触发一次Young GC。
为了避免频繁的GC,那么应该选用什么样的机器配置。部署多少台机器,设置JVM堆内存、新生代分别多大的内存空间。
根据这套配置,就可以推算出来整个系统的运行模型了。每秒钟创建多少对象在新生代,然后1秒之后成为垃圾。大概系统运行多久,新生代会触发一次GC,频率有多高。
5.如何设置JVM栈内存与永久代大小
(1)如何设置JVM堆内存总结
(2)不合理设置内存的反面示例
(3)大促期间瞬时访问量增加十倍
(4)少数请求要几十秒处理导致老年代内存占用变大
(5)老年代对象越来越多导致频繁垃圾回收
(6)不合理设置内存的反面示例总结
(7)如何合理设置永久代大小
(8)如何合理设置栈内存大小
(1)如何设置JVM堆内存总结
如果准备上线一个新系统,如何根据这个系统预估的业务量和访问量,去推算系统每秒的并发量。然后推算每秒的请求对内存空间的占用,从而推算出整个系统运行期间的JVM内存运转模型。然后基于推算出的JVM内存运转模型,在上线前选择合理的机器配置,需要多大内存的机器才能让JVM堆内存空间拥有一个合理的大小。
这是一项非常基础的技能,因为对于某些业务新系统,可能上线就会面临很大的访问压力。所以要合理预估内存压力,选择合适的机器配置,设置合理的内存大小。
每个合格的工程师,都应该在上线系统时,对系统压力做出预估。然后对JVM内存、磁盘空间大小、网络带宽、数据库压力做出预估,最后在各方面都给出合理的配置。
(2)不合理设置内存的反面示例
下面介绍一个不合理设置内存大小导致问题的反面案例。假设支付系统因为没有经过合理的内存预估,所以选用了1台2核4G的虚拟机来部署线上系统,而且只用一台机器。然后线上JVM给的堆内存大小仅仅只有1G,扣除老年代后,新生代只有几百M的内存空间。如下图示:
接着业务压力还是每天100万交易,高峰期每秒大概100笔支付交易。那么对应的每秒就有100个支付订单对象有创建出来,每个支付订单对象占据500字节左右,总共是50K。然后假设处理一笔交易总共需要1秒,那么这100个对象在新生代中存在1秒的期间会被栈帧引用,无法被回收。此外进行全局预估时,会从支付订单对象横向扩展到系统其他对象。所以起码要把内存占用扩大10到20倍,比如扩大20倍。
因此只用一台2核4G机器来处理每秒100个创建支付订单的请求,在1秒内总共会创建出大概1M对象,这些对象在这1秒内是无法被回收的。
(3)大促期间瞬时访问量增加十倍
其实按照估算出的内存压力,在系统正常情况下,还不算什么大问题。因为每秒新增1M对象,几百秒过后新生代快满了。自然就会触发Young GC,回收掉里面99%的垃圾对象。如果新生代内存有500M,最多会发现系统每隔几分钟略微卡顿一下。因为这个时候在进行垃圾回收,会影响系统性能。
但是现在假设电商系统搞大促活动,很可能会导致压力瞬间增大10倍。此时可能会发现,支付系统每秒要处理的不是100笔,而是上千笔订单。
这时系统压力本身就会很大,不光是JVM堆内存,尤其是线程资源、CPU资源,都会几乎打满,JVM堆内存就更是岌岌可危了。
(4)少数请求要几十秒处理导致老年代内存占用变大
现在假设一台机器每秒需要处理1000笔交易,那么支付系统每秒对内存的占用就增加到10M以上。甚至再大胆点,预估支付系统每秒对内存占用达到几十M,甚至上百M。因为毕竟大促时流量激增,就一切围绕这来预估。而且最可怕的是,可能每秒过来的1000笔交易,不再是1秒就能处理完。因压力骤增导致性能下降,可能出现处理完一个请求要几秒甚至几十秒。此时如下图示,假设新生代里已经积压了很多的数据,都快满了。
此时内存里有几十M的对象都被引用着,因为少数请求突然处理特别慢。为什么会处理特别慢?因为压力太大,导致系统性能太差了。如下图示:
这时如果要在新生代里分配对象,那么就会导致一次YGC去回收新生代。但可能回收大量对象后,那少数几十M对象还在,因为少数请求特别慢。然后很快新生代继续被填满,再次触发YGC,然后少数几十M对象还在。此时多次YGC之后,这少数几十M对象就会被转移到老年代去。如下图示:
(5)老年代对象越来越多导致频繁垃圾回收
上述流程如果反复来多次,时不时有少数请求特别慢,这些特别慢的请求创建的对象在新生代多次没法回收就会被移到老年代。然后后续处理完,老年代里的对象就没被引用了,成为了垃圾对象。
经常重复这个流程,老年代里的垃圾对象,就会越来越多。一旦老年代的垃圾对象越来越多,那么老年代迟早会满,触发老年代的垃圾回收。而且这个老年代被占满的频率还很快,就会频繁触发老年代的垃圾回收。而老年代垃圾回收是很慢的,老年代频繁垃圾回收会极大影响系统性能。
所以如果设置内存不合理,就会导致新生代内存不充足。在遇到大促等流量暴增时,就会导致偶尔卡顿。偶尔卡顿又会让很多本在新生代的对象不停迁移到老年代,最后导致老年代要不停地进行垃圾回收。
(6)不合理设置内存的反面示例总结
如果内存设置过小,那么当遇到突发巨大流量压力、突发性能抖动时:可能会导致请求卡顿,引发很多新生代对象长期被栈引用,无法被回收。最后本应留在新生代的对象就会持续进入老年代,从而导致老年代内存被频繁占满,频繁触发老年代的垃圾回收。
可见不合理预估业务系统压力、不合理设置内存大小,会导致很大问题。
(7)如何合理设置永久代大小
永久代大小的设置没太多可以参考的规范。一般刚开始上线一个系统时可设置永久代为几百M,基本上都是够用的。因为永久代里主要存放的是类的信息,当然永久代也可能发生内存溢出。
(8)如何合理设置栈内存大小
栈内存大小设置,一般无需特别预估和设置,默认的512K到1M都够了。栈内存大小其实就是指每个线程的栈内存空间大小,一般用来存放线程执行方法期间的各种局部变量,当然栈内存也会发生内存溢出。
6.问题汇总
问题一:
既然栈帧存放了方法对应的局部变量数据,也包括方法执行的其它信息。那为何不把程序计数器记录执行的情况,也放在各个方法自己的栈帧里,而是单独列一个程序计数器去存储呢?
答:这就涉及JVM设计者的设计思想了。程序计数器针对的是代码指令的执行,Java虚拟栈针对的是方法的执行。一个是指令,一个是数据,分开设计。
问题二:
方法区的类什么时候会被回收?为什么?
答:在以下几种情况下,方法区里的类会被回收:
一.该类的所有实例对象都已从堆内存里回收
二.加载该类的ClassLoader已被回收
三.对该类的Class对象没有任何引用
满足上面三个条件就可以回收该类了。
问题三:
方法执行完后,栈帧马上被出栈,那该栈帧中的变量等数据是马上就被回收掉吗?还是需要等垃圾回收线程扫描到再回收?
答:出栈就没了。
问题四:
双亲委派模型的设计出发点是什么?
答:双亲委派模型设计的出发点很重要:对于任意一个类,都需要由加载它的类加载器和这个类本身,来一同确立其在Java虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间。也就是说,判断两个类是否相等,只有在这2个类是由同一个类加载器加载的前提下才有意义。否则即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,这两个类必定不相等。
基于双亲委派模型设计:
那么Java中基础的类,Object类重复多个的问题就不会存在了。因为经过层层传递,加载请求最终都会被启动类加载器所响应,所以加载的Object类最后也会只有一个。否则如果用户自己编写一个java.lang.Object类,并放到ClassPath中,那么就会出现很多Object类,这样应用程序将一片混乱。
问题五:
Tomcat需要破坏双亲委派模型的原因是什么?
答:原因如下:
(1)Tomcat中需要支持不同Web应用依赖同一个第三方类库的不同版本,所以Tomcat中的jar类库需要保证相互隔离。
(2)同一个第三方类库的相同版本在不同的Web应用中可以共享。
(3)Tomcat依赖的类库需要与应用依赖的类库隔离。
(4)JSP需要支持修改后不用重启Tomcat即可生效,Tomcat为了类加载隔离和类更新不用重启,定制开发了各种类加载器。
问题六:
引用Class对象的会是什么?
答:比如用反射可以获取一个对象对应的类的Class对象实例,比如Class clazz = replicaManager.getClass(),可通过replicaManager引用的对象获取ReplicaManager类的Class对象。那个clazz变量,就可以引用这个Class对象。
问题七:
Spring的对象和自定义的POJO对象会分配在哪里?
答:托管给Spring管理的对象(配置了@Configration)会长期存在老年代。自定义那些POJO对象,如果不是类对象,那么就会朝生夕灭、会被分配在新生代。Spring容器的对象,默认采用单例方式加载,这些对象会存在老年代中。但在方法内new出来的对象不会存活太长,方法结束后会在下次垃圾回收的时候被回收。
问题八:
如下代码的变量和实例对象何时会被销毁回收?
public void load() {
A a = new A();
}
答:a这个变量是存放在虚拟机栈的,load()方法执行完后就会被销毁,new A()这个对象是需要等待垃圾回收线程扫描后才回收销毁。
问题九:
软引用和弱引用的回收时机?
答:内存不够才会回收软引用对象,内存足够不会回收软引用对象。弱引用不管内存空间够不够,只能撑到下次垃圾回收之前,就会被回收。
问题十:
类初始化时,类变量引用的是new出来的对象,此时变量引用的对象会被实例化到堆内存吗?
答:会实例化放到堆内存
问题十一:
是不是应该尽量设大新生代,让系统在高峰期不产生GC?
答:是的,尽量是这样。
问题十二:
类初始化的时机都有哪些?
答:类的"加载->验证->准备->解析->初始化"并不是一个连续的动作。也就是说,类即便加载了,也不一定立即会进行初始化。
类初始化的时机如下:
一.当创建某个类的新实例时(如通过new或者反射、克隆、反序列化等)
二.当调用某个类的静态方法时
三.当使用某个类或接口的静态字段时
四.调用Java API中的某些反射方法时,如类Class中的方法、java.lang.reflect中的方法
五.当初始化某个子类时
六.当虚拟机启动某个被标明为启动类的类(即包含main方法的那个类)