JVM的性能优化可以分为代码层面和非代码层面。
在代码层面,大家可以结合字节码指令进行优化,比如一个循环语句,可以将循环不相关的代码提取到循环体之外,这样在字节码层面就不需要重复执行这些代码了。
在非代码层面,一般情况可以从内存、gc以及cpu占用率等方面进行优化。
注意,JVM调优是一个漫长和复杂的过程,而在很多情况下,JVM是不需要优化的,因为JVM本身已经做了很多的内部优化操作,所以只有在代码层面没法优化的时候在进行。
内存分配
例子:
每台机器配置2c4G,以每秒3000笔订单为例,整个过程持续60秒
内存溢出(OOM):
(1)大并发情况下
(2)内存泄露导致内存溢出
大并发[秒杀]
内存泄露导致内存溢出
1、top命令查看:
top
top -Hp PID
2、jstack查看线程情况,发现没有死锁或者IO阻塞的情况:
jstack PID
java -jar arthas.jar —> thread
3、查看堆内存的使用,发现堆内存的使用率已经高达88.95%:
jmap -heap PID
java -jar arthas.jar —> dashboard
4、此时可以大体判断出来,发生了内存泄露从而导致的内存溢出,那怎么排查呢?
jmap -histo:live PID | more
获取到jvm.hprof文件,上传到指定的工具分析,比如heaphero.io
NEW
每一个线程,在堆内存中都有一个对应的Thread对象。Thread t = new Thread();当刚刚在堆内存中创建Thread对象,还没有调用t.start()方法之前,线程就处在NEW状态。在这个状态上,线程与普通的java对象没有什么区别,就仅仅是一个堆内存中的对象
RUNNABLE
该状态表示线程具备所有运行条件,在运行队列中准备操作系统的调度,或者正在运行。 这个状态的线程比较正常,但如果线程长时间停留在在这个状态就不正常了,这说明线程运行的时间很长(存在性能问题),或者是线程一直得不得执行的机会(存在线程饥饿的问题)。
BLOCKED
线程正在等待获取java对象的监视器(也叫内置锁),即线程正在等待进入由synchronized保护的方法或者代码块。synchronized用来保证原子性,任意时刻最多只能由一个线程进入该临界区域,其他线程只能排队等待。
WAITING
处在该线程的状态,正在等待某个事件的发生,只有特定的条件满足,才能获得执行机会。而产生这个特定的事件,通常都是另一个线程。也就是说,如果不发生特定的事件,那么处在该状态的线程一直等待,不能获取执行的机会。
比如:A线程调用了obj对象的obj.wait()方法,如果没有线程调用obj.notify或obj.notifyAll,那么A线程就没有办法恢复运行;
如果A线程调用了LockSupport.park(),没有别的线程调用LockSupport.unpark(A),那么A没有办法恢复运行。
TIMED_WAITING
意味着线程调用了限时版本的API,正在等待时间流逝。当等待时间过去后,线程一样可以恢复运行。如果线程进入了WAITING状态,一定要特定的事件发生才能恢复运行;而处在TIMED_WAITING的线程,如果特定的事件发生或者是时间流逝完毕,都会恢复运行
TERMINATED
线程执行完毕,执行完run方法正常返回,或者抛出了运行时异常而结束,线程都会停留在这个状态。这个时候线程只剩下Thread对象了,没有什么用了。
关键状态分析
该状态说明它在等待另一个条件的发生,来把自己唤醒,或者干脆它是调用了 sleep(n)。
此时线程状态大致为以下几种:
java.lang.Thread.State: WAITING (parking):一直等那个条件发生;
java.lang.Thread.State: TIMED_WAITING (parking或sleeping):定时的,那个条件不到来,也将定时唤醒自己。
下图,描述了线程和 Monitor之间关系,以及线程的状态转换图:
如上图,每个Monitor在某个时刻,只能被一个线程拥有,该线程就是 “ActiveThread”,而其它线程都是 “Waiting Thread”,分别在两个队列“Entry Set”和“Wait Set”里等候。在“Entry Set”中等待的线程状态是“Waiting for monitor entry”,而在“Wait Set”中等待的线程状态是“in Object.wait()”。
“Entry Set”里面的线程。我们称被 synchronized保护起来的代码段为临界区。当一个线程申请进入临界区时,它就进入了“Entry Set”队列。对应的 code就像
synchronized(obj) {
.........
}
这时有两种可能性:
在第一种情况下,线程将处于 “Runnable”的状态,而第二种情况下,线程 DUMP会显示处于 “waiting for monitor entry”。
如下:
"Thread-0" prio=10 tid=0x08222eb0 nid=0x9 waiting for monitor entry [0xf927b000..0xf927bdb8]
at testthread.WaitThread.run(WaitThread.java:39)
- waiting to lock <0xef63bf08> (a java.lang.Object)
- locked <0xef63beb8> (a java.util.ArrayList)
at java.lang.Thread.run(Thread.java:595)
临界区的设置,是为了保证其内部的代码执行的原子性和完整性。但是因为临界区在任何时间只允许线程串行通过,这和我们多线程的程序的初衷是相反的。如果在多线程的程序中,大量使用 synchronized,或者不适当的使用了它,会造成大量线程在临界区的入口等待,造成系统的性能大幅下降。如果在线程 DUMP中发现了这个情况,应该审查源码,改进程序。
“Wait Set”里面的线程。当线程获得了 Monitor,进入了临界区之后,如果发现线程继续运行的条件没有满足,它则调用对象(一般就是被 synchronized 的对象)的 wait() 方法,放弃 Monitor,进入 “Wait Set”队列。只有当别的线程在该对象上调用了 notify() 或者 notifyAll(),“Wait Set”队列中线程才得到机会去竞争,但是只有一个线程获得对象的Monitor,恢复到运行态。在 “Wait Set”中的线程, DUMP中表现为: in Object.wait()。
如下:
"Thread-1" prio=10 tid=0x08223250 nid=0xa in Object.wait() [0xef47a000..0xef47aa38]
at java.lang.Object.wait(Native Method)
- waiting on <0xef63beb8> (a java.util.ArrayList)
at java.lang.Object.wait(Object.java:474)
at testthread.MyWaitThread.run(MyWaitThread.java:40)
- locked <0xef63beb8> (a java.util.ArrayList)
at java.lang.Thread.run(Thread.java:595)
综上,一般CPU很忙时,则关注runnable的线程,CPU很闲时,则关注waiting for monitor entry的线程。
Lock
上面提到如果 synchronized和 monitor机制运用不当,可能会造成多线程程序的性能问题。在 JDK 5.0中,引入了 Lock机制,从而使开发者能更灵活的开发高性能的并发多线程程序,可以替代以往 JDK中的 synchronized和 Monitor的 机制。但是,要注意的是,因为 Lock类只是一个普通类,JVM无从得知 Lock对象的占用情况,所以在线程 DUMP中,也不会包含关于 Lock的信息, 关于死锁等问题,就不如用 synchronized的编程方式容易识别。
GC-G1
目前建议使用G1,所以以G1垃圾收集器调优为例
是否选用G1,官网建议:
(1)50%以上的堆被存活对象占用
(2)对象分配和晋升的速度变化非常
(3)垃圾回收时间比较长
G1调优
(1)使用G1GC垃圾收集器: -XX:+UseG1GC
修改配置参数,获取到gc日志,使用GCViewer分析吞吐量和响应时间
(2)调整内存大小再获取gc日志分析
比如设置堆内存的大小,获取到gc日志,使用GCViewer分析吞吐量和响应时间
(3)调整最大停顿时间
-XX:MaxGCPauseMillis=200 设置最大GC停顿时间指标
比如设置最大停顿时间,获取到gc日志,使用GCViewer分析吞吐量和响应时间
(4)启动并发GC时堆内存占用百分比
-XX:InitiatingHeapOccupancyPercent=45 G1用它来触发并发GC周期,基于整个堆的使用率,而不只是某一代内存的使用比例。值为 0 则表示“一直执行 GC循环)'. 默认值为 45 (例如, 全部的 45% 或者使用了45%).
比如设置该百分比参数,获取到gc日志,使用GCViewer分析吞吐量和响应时间
G1调优最佳实战
(1)不要手动设置新生代和老年代的大小,只要设置整个堆的大小
G1收集器在运行过程中,会自己调整新生代和老年代的大小 其实是通过adapt代的大小来调整对象晋升的速度和年龄,
从而达到为收集器设置的暂停时间目标 如果手动设置了大小就意味着放弃了G1的自动调优
(2)不断调优暂停时间目标
一般情况下这个值设置到100ms或者200ms都是可以的(不同情况下会不一样),但如果设置成50ms就 不太合理。
暂停时间设置的太短,就会导致出现G1跟不上垃圾产生的速度。最终退化成Full GC。
所以 对这个参数的调优是一个持续的过程,逐步调整到最佳状态。暂停时间只是一个目标,并不能总是得到 满足。
(3)使用-XX:ConcGCThreads=n来增加标记线程的数量
IHOP如果阀值设置过高,可能会遇到转移失败的风险,比如对象进行转移时空间不足。如果阀值设置过低,
就会使标记周期运行过于频繁,并且有可能混合收集期回收不到空间。
IHOP值如果设置合理,但是在并发周期时间过长时,可以尝试增加并发线程数,调高 ConcGCThreads。
(4)MixedGC调优
-XX:InitiatingHeapOccupancyPercent
-XX:G1MixedGCLiveThresholdPercent
-XX:G1MixedGCCountTarger
-XX:G1OldCSetRegionThresholdPercent
(5)适当增加堆内存大小
(6)不正常的Full GC
有时候会发现系统刚刚启动的时候,就会发生一次Full GC,但是老年代空间比较充足,一般是由 Metaspace区域引起的。可以通过MetaspaceSize适当增加
JVM问题场景总结
CPU飙高,load高,响应很慢
一个请求过程中多次dump;
对比多次dump文件的runnable线程,如果执行的方法有比较大变化,说明比较正常。如果在执行同一个方法,就有一些问题了;
查找占用CPU最多的线程
(1)top
(2)top -Hp PID
(3)查看进程中占用CPU高的线程id,即tid
(4)jstack PID | grep tid
使用命令:top -H -p pid(pid为被测系统的进程号),找到导致CPU高的线程ID,对应thread dump信息中线程的nid,只不过一个是十进制,一个是十六进制;
在thread dump中,根据top命令查找的线程id,查找对应的线程堆栈信息;
CPU使用率不高但是响应很慢
进行dump,查看是否有很多thread struck在了i/o、数据库等地方,定位瓶颈原因;
请求无法响应
多次dump,对比是否所有的runnable线程都一直在执行相同的方法,如果是的,恭喜你,锁住了!
死锁
死锁经常表现为程序的停顿,或者不再响应用户的请求。从操作系统上观察,对应进程的CPU占用率为零,很快会从top或prstat的输出中消失。
线程 Dump中可以直接报告出 Java级别的死锁
"Thread-1" prio=5 tid=0x00acc490 nid=0xe50 waiting for monitor entry [0x02d3f000
..0x02d3fd68]
at deadlockthreads.TestThread.run(TestThread.java:31)
- waiting to lock <0x22c19f18> (a java.lang.Object)
- locked <0x22c19f20> (a java.lang.Object)
"Thread-0" prio=5 tid=0x00accdb0 nid=0xdec waiting for monitor entry [0x02cff000
..0x02cff9e8]
at deadlockthreads.TestThread.run(TestThread.java:31)
- waiting to lock <0x22c19f20> (a java.lang.Object)
- locked <0x22c19f18> (a java.lang.Object)
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x0003f334 (object 0x22c19f18, a java.lang.Object),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x0003f314 (object 0x22c19f20, a java.lang.Object),
which is held by "Thread-1"
活锁
其表现特征为:由于多个线程对临界区,或者锁的竞争,可能出现:
从整体的性能指标看,由于线程热锁的存在,程序的响应时间会变长,吞吐量会降低。
解决方法:
JVM性能优化方向
JVM常见面试题
(1)内存泄漏与内存溢出的区别
(2)Young GC会有stw吗?
(3)Major gc和Full gc的区别
触发Full GC的原因其实有很多:
- 当年轻代晋升到老年代的对象大小,并比目前老年代剩余的空间大小还要大时,会触发 Full GC;
- 当老年代的空间使用率超过某阈值时,会触发 Fu ll GC;
- 当元空间不足时(JDK1.7永久代不足),也会触发 Full GC;
- 当调用 System.gc() 也会安排一次 Full GC。
(4)聊聊Minor GC、Major GC、Full GC发生的时机
(5)为什么要区分新生代和老年代
(6)为什么需要Survivor区
(7)为什么需要两个Survivor区
假设现在只有一个Survivor区,我们来模拟一下流程: 刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。 这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象, 如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。 永远有一个Survivor是空的,另一个非空的Survivor无碎片。
(8)什么样的情况下对象会进入Old区?
(9)堆内存中的对象都是所有线程共享的吗?
(10)Java虚拟机栈的深度越大越好吗?
(11)方法区中的类信息会被回收吗?
“无用的 类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”
(12)CMS与G1的区别
(13)垃圾收集器一般如何选择
不过目前主流的还是jdk8和jdk11,一般情况下建议优先现在G1
(14)什么是方法内联?
正常调用方法时,会不断地往Java虚拟机栈中存放新的栈帧,这样开销比较大,其实jvm虚拟机内部为了节省这样开销,可以把一些方法放到同一个 栈帧中执行。
(15)不可达的对象一定要被回收吗?
即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真 正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行 一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
如果对象被判定为有必要执行 finalize() 方法,则会被放入 F-Queue 队列中,并在稍后由一条 由虚拟机自动创建、低调度优先级的Finalizer线程去 “执行” 他们的 finalize() 方法,收集器稍后会对 F-Queue 队列中的对象进行第二次小规模的标记,如果此时该对象重新与引用链上任何一个对象建立了关联,则该对象就可以逃脱死亡的命运,即可以不用被 GC 回收,否则就会被真的回收。
(16)什么是直接内存
Java的NIO库允许Java程序使用直接内存。直接内存是在java堆外的、直接向系统申请的内存空间。通 常访问直接内存的速度会优于Java堆。因此出于性能的考虑,读写频繁的场合可能会考虑使用直接内存。由于直接内存在java堆外,因此它的大小不会直接受限于Xmx指定的最大堆大小,但是系统内存是 有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。