JVM
欲渡黄河冰塞川,将登太行雪满山。 Docker中跑的JVM,总是有奇奇怪怪的问题,我们先说概念,后谈GC优化以及工具的使用
为什么会有JVM
write once run anywhere - 一次编译到处运行说的是Java语言的跨平台特性。
C语言就不是跨平台的,你写Linux的C和Windows的C,调用同样功能的操作系统API(比如windows上的读文件和Linux上的读文件)可能存在较大差异。
Java语言是跨平台的,你写Linux的Java和Windows的Java,调用的是同一套JVM的API。至于JVM最终会在不同操作系统中如何调用底层API执行,那就不是我们关心的了。
Java虚拟机(JVM)类似于一个操作系统,所有Java程序员无论在MAC还是Windows还是Linux上编码时都面对JVM这个操作系统即可,调用的是JVM提供的API,JVM再根据不同的操作系统将自己本身的API调用转换成为操作系统的API调用。
所以说,Java的跨平台特性与Java虚拟机密不可分。
我们从一个标准的HelloWorld.java文件的编码到运行说起,有三个步骤:
1.编码
在HelloWorld.java中编写代码
2.编译
使用javac HelloWorld.java 得到HelloWolrd.class文件
3.运行
java HelloWorld
4.调试
你是否探究过每一步操作的背后有着怎样具体的操作?怎么就做到跨平台了呢?我们从每一步展开来说
1.编码:无论你是MAC还是Windows,编码这一步的操作是一样的,本质上是新建一个后缀为.java的文本文件
2.编译:使用javac命令的前提是你的电脑中已经安装了JDK。好的,我们在安装JDK时需要根据当前的环境来下载相应的版本, 比如MAC OS版本、Win 64bit版本、Win 32bit版本。编译之后得到HelloWolrd.class文件。.class文件是可以运行在任意版本的Java虚拟机(JVM)上的文件。
3.运行:输入java HelloWorld是在告诉系统使用Java虚拟机(JVM)来运行我的HelloWolrd。JVM是通过安装JRE得到的, 和JDK一样,我们在安装JRE时需要根据当前的环境来下载相应的版本。JVM将.class文件解释翻译成当前机器(目标机器)可识别的目标机器码,然后执行目标机器代码, 在这一步操作中,MAC OS版本的JVM会将.class翻译成MAC OS可以识别的机器码、Win 64bit版本的JVM会将.class翻译成Win 64bit可以识别的机器码...以此类推
在第3步,由于解释执行的速度过慢,于是就有了JIT(即时编译技术),可以将字节码直接转换成高性能的本地机器码来执行,而不是遇到每一行代码都先解释再执行。
在Java8时代,解释执行和即时编译技术混合使用并驾齐驱,热点代码会被直接JIT成目标机器码,非热点代码还是保持解释执行的方式
在Java9时代,"AOT"提供了将所有代码直接编译成机器码的方式
字节码的命名由来
随意打开一个*.class文件,可以看到开头一定是这样的
cafe babe 0000 0034 0052 0a00 1200 2b09
第一个字节是Java之父定义的一个魔法数,标志这个文件是class文件 第二个字节代表了java的版本号,00034是52,代表了JDK-52-version
一个字节8位,可以描述256种指令,每个指令意味着对JVM的一个操作码。 在x86计算机中00001111的字节码很难被人类阅读,于是有了汇编助记符,比如SADD\LOAD 当然,类似这种00001111的字节码,只适合机器阅读,所以JVM也发明出了一套汇编助记符,比如ICONST\IPUSH\ILOAD|GETFIELD
对象实例化的过程
Object o = new Object();
新建一个对象,分为多个步骤,分别是:
- 使用当前类加载器ClassLoader+包名+类名作为key找到.class文件,如果没找到,抛出ClassNotFoundException
- 如果有父类,初始化父类->初始化父类的static变量和方法块->计算占用内存->对父类成员变量设置默认值->设置对象值->调用构造函数
- 初始化子类static变量和方法块
- 计算对象的占用内存
- 对成员变量设置默认值,不同的数据类型有不同的零值
- 设置对象头
- 调用类的构造方法
JVM内存模型
JVM内存区域
堆和非堆。其中堆是程序员可用可控的内存,非堆内存则相反。
堆内存=Heap Space= Old(年老代) + New{Eden,From Survivor,To Survivor}(年轻代)
非堆内存=Persitence Space(持久代)
新建一个对象的内存分配顺序
1.对象被new出来后,首先被放到Eden区。大对象直接进入老年代
2.Eden足够时,内存分配结束。Eden区不够时,执行下一步
3.JVM做YoungGC,将Eden空间中存活的对象放到Survivor区
4.Survivor区用作Eden区和Old区的中间交换区域。当Old区空间足够时,Survivor存活了一定次数的对象会被移到Old区。如果在YounGC后Survivor放不下,将超出的部分挪到老年代
5.当Old区空间不够时,JVM做FullGC
6.若FullGC后,Survivor区及老年代仍然无法存放Eden区复制过来的对象,导致Out Of Memory
复制代码
JVM参数
—Xmx 最大堆内存(与—Xmx一致)
-Xms 初始化堆内存(与—Xms一致)
堆内存大于60%时,会增加到-Xmx;堆内存小于30%时,会减少到-Xms,为了减少频繁调整的次数,两者设置成一样
-Xss 每个线程栈大小
-Xmn 年轻代大小,推荐为堆大小的3/8
-XX:MaxPermSize 持久代最大
-XX:PermSize 持久代初始
-XX:+PrintGCApplicationStoppedTime 打印详细GC日志
-XX:PretenureSizeThreadhold 大于该值的对象直接分配到Old区。避免在Young区之间的大量拷贝
复制代码
两种GC
新创建的对象都在分配在Eden中。YoungGC的过程就是将Eden和使用中的Survivor移动到空闲的Survivor中,有一部分对象在经历了几次GC之后就会被移动到old区(可以通过参数设置)
JVM使用经验
热机批量启动容易踩坑
由于JVM刚启动时,所有的方法被执行次数都是0,热点代码还没有被JIT进行动态编译,所有的方法都是解释执行,这个时候性能是很低的
假如我们有100台机器需要做更新,不应该一次性全部更新100台机器,否则很容易产生因为性能底下导致的全部宕机
合理的方式是:每间隔一段时间更新20台机器,当上一批机器从冷机预备到热机(进入JIT)后,再进行下一批的更新
解决类冲突
我们常常在启动应用的时候,发现ClassNotFoundException,可是从逻辑来讲,我们的类明明应该被加载;
这种情况,要么是两个jar包中有多个同名的类,两者之间进行相互覆盖,使Spring找到两个类而且不知道使用哪个 或者是根本没有加载该jar包
这是可以通过两种方式:
- JVM启动参数加入 -XX:+TraceClassLoading参数,打印出JVM加载的所有类的全限定名
- 在ClassLoader的loadClass(String name, boolean resolve)方法
/Library/Java/JavaVirtualMachines/jdk1.8.0_131.jdk/Contents/Home/src.zip!/java/lang/ClassLoader.java:401
打断点,设置condition为图:
OOM
如果发生OOM后,JVM是直接挂掉了,我们连查GC日志的机会都没有。如果希望得到OOM时的堆信息,我们需要开启-XX:HeapDumpOnOutOfMemoryError, 让JVM发生异常时能输出堆内信息,特别是对几个月才出现一次OOM的应用来说非常有帮助
可能原因
-
Old区溢出 可能是Xmx过小或者内存泄漏导致,例如循环上万次的序列化,创建大量对象。 还有的时候系统一直频繁FullGC,根本无法响应用户的请求
-
持久代溢出 动态加载大量Java类导致,只能通过调大 —XX:MaxPermSize
常用的JVM分析工具
jps
查看当前机器运行的JVM线程
[root@96e6a9290fca /]# jps
1 jar
222 Jps
复制代码
jstack
查看某个Java进程内的线程堆栈信息 这个命令可以帮助我们定位到线程堆栈,再找到对应的代码,比如,我们想找出进程ID为1的耗时最大的线程
找到JVM中最耗时的线程
top -Hp 1
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
120 root 20 0 9981.8m 857652 13880 S 7.0 2.6 0:00.76 java
124 root 20 0 9981.8m 857652 13880 S 4.0 2.6 0:02.56 java
16 root 20 0 9981.8m 857652 13880 S 0.3 2.6 0:00.98 java
复制代码
找到TIME最多的线程号124,计算出16进制数
printf "%x\n" 124
7c
复制代码
找到进程1中的线程ID为7c的堆栈信息
jstack 1 | grep 7c
"XNIO-1 task-41" #108 prio=5 os_prio=0 tid=0x00007f941038b800 nid=0x7c waiting on condition [0x00007f94c8ffb000]
"XNIO-1 task-17" #80 prio=5 os_prio=0 tid=0x00007f941037c800 nid=0x60 waiting on condition [0x00007f94d0d38000]
- locked <0x00000006c7427c48> (a java.util.Collections$UnmodifiableSet)
- locked <0x00000006c72c67c0> (a io.netty.channel.nio.SelectedSelectionKeySet)
复制代码
jmap
统计当前堆中各个对象的大小,到底是谁占用了内存!
jmap -histo:live 2297 | more
num #instances #bytes class name
----------------------------------------------
1: 392 16932248 [B
2: 11198 1229280 [C
3: 6647 445424 [Ljava.lang.Object;
4: 3439 383824 java.lang.Class
5: 11148 267552 java.lang.String
复制代码
jstat
实时监测JVM信息
jstat -gc 1 1000
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
7168.0 7168.0 4200.2 0.0 190464.0 55153.9 3891200.0 149948.4 91648.0 86891.5 11008.0 10242.2 282 3.063 3 0.423 3.486
7168.0 7168.0 4200.2 0.0 190464.0 55227.0 3891200.0 149948.4 91648.0 86891.5 11008.0 10242.2 282 3.063 3 0.423 3.486
7168.0 7168.0 4200.2 0.0 190464.0 57357.0 3891200.0 149948.4 91648.0 86891.5 11008.0 10242.2 282 3.063 3 0.423 3.486
7168.0 7168.0 4200.2 0.0 190464.0 59528.5 3891200.0 149948.4 91648.0 86891.5 11008.0 10242.2 282 3.063 3 0.423 3.486
7168.0 7168.0 4200.2 0.0 190464.0 64430.1 3891200.0 149948.4 91648.0 86891.5 11008.0 10242.2 282 3.063 3 0.423 3.486
复制代码
各列含义:
- S0C、S1C、S0U、S1U:Survivor 0/1区容量(Capacity)和使用量(Used)
- EC、EU:Eden区容量和使用量
- OC、OU:年老代容量和使用量
- PC、PU:永久代容量和使用量
- YGC、YGT:年轻代GC次数和GC耗时
- FGC、FGCT:Full GC次数和Full GC耗时
- GCT:GC总耗时
JVM性能调优实战
JVM速度慢的很大一部分原因是因为无法及时释放所不需要的内存。在编写java程序时,我们不需要自己释放内存,而是交由GC回收。如此一来,对堆内存和GC算法的掌握决定了是否可以发挥JVM的整体性能。
调优原则
1.Young GC尽可能多地回收新生代对象
2.堆内存越大越好
3.一切以减少Full GC为目的:
1.如果大对象过多,新生代没有足够的内存分配,造成新生代的对象往老生代上迁移,老生代逐渐变多,触发Full Gc。
2.如果大对象直接放到老生代,也会产生老生的Full GC频繁的问题,所以,一定要尽量减少使用大对象,如果一定要使用,那就保持其最短的生命周期,最好作为临时变量
4.虽然加大内存有利于减少GC收集的次数,但是大内存的一次Full GC时间也会更长。所以,对于大内存的Java应用(现在似乎都是这样),一定要尽量减少Full GC的次数
手段主要有两个:
1.避免对象生命周期过长,在不需要的时候及时释放,让对象被Young GC回收,避免对象被移动到老年代
2.提高大对象进入老年代的门槛:设置-XX:PretrnureSizeThreshold为一个比较大的值,小于该值的对象都先进入新生代,然后被Young GC回收,只有小概率事件会进入老生代
复制代码
JVM崩溃的几大原因:
1.异步处理请求:对接受到的请求开辟一个线程去保持TCP连接,如果异步处理请求慢,则TCP连接过多,造成线程数过多,JVM崩溃
2.使用了netty等NIO框架,JVM在JVM内存之外分配直接内存,导致直接内存过大,发生内存泄露
高并发例子
JVM使用例子-承受海量访问的动态Web应用
服务器配置:8 CPU, 8G MEM, JDK 1.6.X
参数方案:
-server -Xmx3550m -Xms3550m -Xmn1256m -Xss128k -XX:SurvivorRatio=6 -XX:MaxPermSize=256m -XX:ParallelGCThreads=8 -XX:MaxTenuringThreshold=0 -XX:+UseConcMarkSweepGC
调优说明:
- -Xmx 与 -Xms 相同以避免JVM反复重新申请内存
- -Xmn1256m 设置年轻代大小为1256MB。官方推荐配置年轻代大小为整个堆的3/8。
- -Xss128k 设置较小的线程栈以支持创建更多的线程,支持海量访问,并提升系统性能。
- -XX:SurvivorRatio=6 设置年轻代中Eden区与Survivor区的比值。系统默认是8,根据经验设置为6,则2个Survivor区与1个Eden区的比值为2:6,一个Survivor区占整个年轻代的1/8。
- -XX:ParallelGCThreads=8 配置并行收集器的线程数,即同时8个线程一起进行垃圾回收。此值一般配置为与CPU数目相等。
- -XX:+UseConcMarkSweepGC 设置年老代为并发收集。CMS(ConcMarkSweepGC)收集的目标是尽量减少应用的暂停时间,减少Full GC发生的几率,利用和应用程序线程并发的垃圾回收线程来标记清除年老代内存,适用于应用中存在比较多的长生命周期对象的情况。
几点原则
1.Server端设置-Xms和-Xmx为相同值。为了优化GC,最好让-Xmn值约等于-Xmx的1/3或2/3。
2.堆大小并不决定进程的内存使用量。进程的内存使用量要大于-Xmx定义的值,因为Java为其他任务分配内存,例如每个线程的JVM函数栈等。
3.JVM函数栈的设定每个线程都有他自己的JVM函数栈。-Xss为每个线程的JVM函数栈大小
JVM函数栈的大小限制着线程的数量。如果JVM函数栈过大就会导致内存溢漏。-Xss参数决定JVM函数栈大小,例如-Xss1024K。如果JVM函数栈太小,也会导致JVM函数栈溢漏。
复制代码
调优步骤
Young Gc频率高->新生代太小,总是不够->增大新生代
Young Gc时间长->新生代太大,很久才做一次Gc->减少新生代