总述
一般公司目前所使用的是JDK1.8,而本人目前接触得多的也是1.8比较多,所以这里重点总结下我所理解的JVM。
JVM叫做虚拟机,出现的目的主要有两个:
- 是为了跨平台屏蔽平台之间的差异性,一次编译到处运行
- 内存管理,早期C语言的内存需要开发人员自己控制,java用了JVM这种方式来管理内存,将这部分工作交给JVM。
但是所有的设计都有缺陷,而且程序做久了,目前发现所谓的优化和好的设计绝大部分是时间换空间或者是空间换时间。JVM也是如此,使用JVM之后内存不需要管了,但是JVM需要人管,因为在特殊场景下默认的参数是不满足需求的。
虚拟机内存管理分代
在知道内存回收之前首先需要知道JVM内存模型,在JVM中为了方便管理,将内存划分不同的逻辑区,大致如下:
- 程序计数器: 一小块内存空间,当前线程所执行的行号指示器,他控制这跳转,分支,异常处理,线程回复等
- java虚拟机栈:这个栈是线程执行的主要环境,里面有栈帧(这是一个数据结构),栈帧中含有局部变量表和操作数栈等,每个线程都有一个
- 本地方法栈:这个可以理解为java底层调用的哪些native方法,一般我们不用关注
- java堆:我们也称为垃圾堆,因为这里是所有对象实例存放的地方,也主要回收这里
- 方法区:简答理解class文件存放地
- 运行时常量池:简单理解接口类的常量都在这,方法的不在啊,方法的在栈里
- 直接内存:这个要了解下linux的零拷贝,然后在关注NIO操作api零拷贝的api就知道了,这个主要就是给NIO操作零拷贝时候用的,同时他不收JVM管理。就是操作系统内存,JVM管不了。
上述主要就是JVM的内存逻辑区,在这个基础上,他又将内存主要分为新生代来年代,还有方法区。
- 新生代:新产生的且不符合大对象的实例都在这里,对象大部分朝阳夕灭
- 老年代:大对象和符合规则晋升的对象存放的内存。
其实就是管理界的分类管理,这里也是,新生代普遍使用复制清除算法,老年代普遍使用标记清除。
了解完分类思想,再了解下收集器。
虚拟机收集器
虚拟机总体来说他本身也是有规范的,你如果可以你自己也可以写个虚拟机,比如布尔值底层是什么?大多数人说是0,1.这其实就是看虚拟机自己的实现,大部分是0,1,也不排除底层是TRUE,FALSE的字符串。
先来一张经典的图:
对于这么多收集器很多都是过去式,我们主要研究1.8的收集器。这里要提一嘴,因为分代思想不同的代会有不同的收集器,上述图中上部分年轻代收集器,下部分老年代收集器,至于新出的G1是新的收集器,使用分而治之思想,后续再述。
1.8默认收集器PS(Parallel Scavenge) + PO(Parallel Old)。收集器可以配置的,你的不一定是这个。
可以查看下:java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=268435456
-XX:MaxHeapSize=4294967296
-XX:+PrintCommandLineFlags
-XX:+UseCompressedClassPointers
-XX:+UseCompressedOops
-XX:+UseParallelGC //代表Parallel Scavenge + Serial Old
java version “1.8.0_162”
Java™ SE Runtime Environment (build 1.8.0_162-b12)
Java HotSpot™ 64-Bit Server VM (build 25.162-b12, mixed mode)
我的:这里的算法和收集器有关系也没关系,因为新生代基本都是复制清除,老年代标记整理
Parallel Scavenge:复制清除算法,用于新生代
Parallel Old:标记整理法,用于老年代
这个就是parallel old的收集流程:给两张图大概了解下收集器的执行。深色的就是执行器执行步骤
Serial Old:
这是CMS收集流程:
了解完收集器 再了解下 分代的细节
分代细节
上面知识大概的讲了分代,这里具体讲下分代:
新生代和老年代主要就是在堆上划分的。
老年代与新生代比默认为2/1
老年代只有一块:由上可知默认为堆内存的2/3
新生代:默认为1/3,但他自身本身有三块,1块eden和2块suvivor
为什么新生代要这么分?
标记清除,当然是要准备至少两块,不然挑选出存货对象往哪搬,但是为啥是三块呢?
因为懒得动,假设第一次我将eden整理好,存活对象搬到suvivor上,难道我还得搬回去,当然不,下次在清理我清理eden和这块survivor,搬到另一块survivor。
对象是如何分代的
默认新生对象都是放在新生代,如果进入老年代三种
不是每个收集器都是符合这些参数的
- 超过设置的大对象参数:-XX:PretenureSizeThreshold=1000 -XX:+UseSerialGC (Parallel 设置没用)
- 超过分代年龄:-XX:MaxTenuringThreshold = 7 (存活七次)
- 开启了担保分配,而且新生代不够用:1.6后默认开启,担保机制不同的收集器还有点不一样(大致都是差不多,放不下就放老年代)
GC的时机
上面里了解了整个内存模型,下面就是如何GC,首先新生代满了,那直接youngGC,这个很快只要短时间次数不多,一般这不是要调优的点,如果新生代GC还不够那么就是old GC,还不行就是FULL GC,youngGC > OLD GC > FULL GC。
如何确定对象已死?
就是可达性分析,利用Gcroot等根对象,来确定对象的可达性。
GCroot到底是什么?
高一张图过来:明白大概意思即可
- 虚拟机栈(栈帧中的本地变量表)中引用的对象:这个要理解下虚拟机栈是干什么,里面装的啥就好关联上
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
啥时候回收?
用户线程再跑,满了就立马回收是会出问题的,因为不管啥收集器最终都要停顿,虽然发展出异步多线程和用户工作线程并行回收,但是在整个回收过程中,还是会有某一步会有停顿。
所以jvm回收规定必须到达安全点,什么是安全点?
Safepoint 可以理解成是在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,线程可以暂停。在 SafePoint 保存了其他位置没有的一些当前线程的运行信息,供其他线程读取。这些信息包括:线程上下文的任何信息,例如对象或者非对象的内部指针等等。我们一般这么理解 SafePoint,就是线程只有运行到了 SafePoint 的位置,他的一切状态信息,才是确定的,也只有这个时候,才知道这个线程用了哪些内存,没有用哪些;并且,只有线程处于 SafePoint 位置,这时候对 JVM的堆栈信息进行修改,例如回收某一部分不用的内存,线程才会感知到,之后继续运行,每个线程都有一份自己的内存使用快照,这时候其他线程对于内存使用的修改,线程就不知道了,只有再进行到 SafePoint 的时候,才会感知。
开始GC但是要等代码跑到安全点,但是如果跑不到呢,那么就要等,jvm默认设置了很多代码安全点的地方,同时每个线程也会主动去检测自己是否在安全点,但是还是也有意外。比如你用最大的INT去循环,没其他情况下,这段代码是不会进入安全点的,那么就会内存溢出。
关于安全点我们一般不需要关注,关注较多的是可数循环会因为等待使主线程阻塞,可数循环也会回收的,如果两个例线程,一个负责计算,不增加内存。另一个负责增加内存。如果触发GC,会等待计算的走到安全点。但是如果两个线程都是增加内存,那么jvm会处理主动加上安全点,直接触发GC。
怎么回收
到达安全点之后,所有线程确定信息都会暴露,其中可用信息会由jvm用oopmap的承载,然后jvm只需要扫描oopmap,对相应的内存进行处理即可。
一般的指令和优化操作
优化的目的
一般我们要带着目标去优化,平常JVM也不会有啥问题,毕竟是一款稳定运行几十年的项目。一般在不出问题的情况下优化主要是减少GC次数,尤其是FGC,因为FGC除了新生代和老年代还有方法区也要清理,方法区清理比较严格所以时间也多。而其它问题具体看情况而定。
假设现在出了问题,首先要收集数据,查看java的工具原生的java bin目录下就有:
- VisualVM:这是一个功能强大的JVM性能监控和分析工具,它内置在Java Development Kit(JDK)中,无需额外安装。VisualVM提供直观的用户界面,用于监视JVM的运行状态、线程、堆内存、垃圾回收等信息。它支持远程监控,可以通过“远程”选项卡连接到远程JVM进程。
- Mission Control:这是Oracle JDK附带的一个工具套件,用于监控和管理Java应用程序的性能。它包括飞行记录器(Flight
- Recorder)和控制台功能,可以实时监控和分析JVM的行为。 JVisualVM插件 - Visual GC:Visual
- GC是VisualVM的一个插件,用于可视化垃圾回收器的活动。
- JPS:这是一个简单的命令行工具,用于查看Java进程及相关信息,如进程ID、主类全名或jar路径等。23
- JStat:这是一个用于监视虚拟机运行时状态信息的命令行工具,可以显示类加载、内存、垃圾收集、即时编译等数据。
- JMap:用于生成堆快照信息,查看堆的内存使用信息。24 JHat:用于分析堆快照文件。 JStack:用于分析线程状态信息。
这些自己可以去看,这里简单看下jstat的情况:
jstat命令:
S0C :年轻代中第一个survivor(幸存区)的容量 (字节)
S1C :年轻代中第二个survivor(幸存区)的容量 (字节)
S0U :年轻代中第一个survivor(幸存区)目前已使用空间 (字节)
S1U :年轻代中第二个survivor(幸存区)目前已使用空间 (字节)
EC :年轻代中Eden(伊甸园)的容量 (字节)
EU :年轻代中Eden(伊甸园)目前已使用空间 (字节)
OC :Old代的容量 (字节)
OU :Old代目前已使用空间 (字节)
PC :Perm(持久代)的容量 (字节)
PU :Perm(持久代)目前已使用空间 (字节)
YGC :从应用程序启动到采样时年轻代中gc次数
YGCT :从应用程序启动到采样时年轻代中gc所用时间(s)
FGC :从应用程序启动到采样时old代(全gc)gc次数
FGCT :从应用程序启动到采样时old代(全gc)gc所用时间(s)
我们在知道问题之后,可以查看这些区的大小,如果YGC短时间太多,说明新生代要调大一些,如果老年代次数多,除了考虑设置大些还得考虑是不是那些对象不应该进入老年代。所以在内存大的时候不出问题的时候,一般没人优化JVM,因为内存在性能上不需要管。
注意:上述查看jvm数据是会引起STW的,所以生产不是排查问题没必要操作打印。
代码中需要注意的问题
一个很经典的问题,threadLocalMap问题,这里面简单理解为key为弱引用指向当前线程,value正常值。
这个设计本身是为了当前线程不用ThreadLocal那么,这个threadLocal就会被gc回收。但是这会导致一个问题,那就是Entry会产生null,value问题,所以记得使用完remove下。
实战
排查内存过大
第一步:找到服务PID
第二步:top -p PID 也就是 top -p 20022
第三步:在此情况下按大写H,他就会刷出其下子线程
第四步:看到那个CPU过大,导出服务信息,有些是子线程是瞬时的,要把握好时机,比如我们查看第一个子线程
第五步:将20064转为16进制 也就是 4e64,然后打开导出的java.txt查找该线程,就可以找到对应线程,是JDBC,不关我们的事。
具体问题是因为写了一个for循环,这里只是一个排查步骤,如何定位到点。
但是这个问题瞬时的:
如果不是瞬时就的利用专门的分析工具监控一定时间,观察他的周期变化
visualVM,JMC,Arthas,JPofiler
如果发生oom,可以直接用java的配置JVM:用于OOM时自动保存堆栈信息;保存GC日志信息。
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/tmp/gc-%t.log
GC堆内存日志
上面是运行堆栈信息,这个是GC内存信息。
发出来看看,这些知道大概方向,可以自己研究看看
关于跨代引用
记忆集与卡表
一般来说是分代收集,但是有可能老年代会指向新生代,那么此时你的去扫描整个表,然后就出现了记忆集合,顾名思义就是将指向和不指向的记录下来。