关于后端:掌握这3个技巧你也可以秒懂JAVA性能调优和jvm垃圾回收

30次阅读

共计 6360 个字符,预计需要花费 16 分钟才能阅读完成。

前言

JVM 是一个虚拟化的操作系统,相似于 Linux 和 Window,只是他被架构在了操作系统上进行接管 class 文件并把 class 翻译成零碎辨认的机器码进行执行,即 JVM 为咱们屏蔽了不同操作系统在底层硬件和操作指令的不同。

因而,JVM 最重要的作用浮出水面,即跨平台性。因为 JVM 为 java 程序屏蔽了操作系统底层的细节,Java 只须要关怀如何编译,如何让加载进 JVM 即可。

因为 JVM 接管的是 Class 文件,而不是接管特定的语言,因而只有某种语言能够编译成 Class 文件,就能够在 JVM 上运行,这些语言有 Groovy、Kotlin、Scala 等等。因而 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 只是减少了对象在年老代中的勾留工夫,减少了被垃圾回收的可能性。
《2020 最新 Java 根底精讲视频教程和学习路线!》

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 是如何放弃的?

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 根本不产生,或隔很长很长的工夫才产生一次

链接:https://juejin.cn/post/690443…

正文完
 0