本文主要基于“深入java虚拟机”这本书总结JVM的内存管理机制,并总结了常见的线上问题分析思路。文章最后面是我对线上故障思考的ppt总结。
Java内存区域
虚拟机运行时数据区如下图所示:
方法区:方法区又称为永生代(Permanent Generation)是线程共享的内存区域。它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。当方法区内存溢出时报OOM:PermGen space。编译器生成的各种字节码和符号引用存放在运行时常量池中。
堆:Java堆是Java虚拟机所管理的内存中最大的一块,所有线程共享。此内存区域唯一的目的是存放对象实例。几乎所有的对象实例(非基础类型)都在这里分配内存。Java堆还可以细分为新生代和老年代,其中新生代又可以分为Eden空间、From Survior空间、To Survior空间,对应的默认比例是8:1:1。在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。
虚拟机栈:虚拟机栈是线程私有的,虚拟机栈描述的是java执行的内存模型,每个方法在执行的时候都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每个方法调用到执行的过程对应一个栈帧入栈到出栈的过程。
程序计数区:虚拟机处理多线程时,是通过轮流的切换线程,来获取cpu的执行机会的。在虚拟机执行程序的过程中,当线程执行到某一位置时,虚拟机将cpu的执行机会出让给了其他线程,此时原有线程的执行位置需要被记录下来,而新得到执行机会的线程,又需要提供上次执行的位置,以此来保证程序中的多个线程可以持续的并行的执行下去。程序计数器的作用就是将各个线程下次所执行的(字节码)行号(准确来说是指令的地址)记录下来,以保证其下次执行时可以正确的执行。程序计数器只记录字节码的行号,因此当线程执行本地方法(Native method)时,计数器的值是空。程序计数器所耗费的内存空间非常小,因此这个区域是不会抛出OutOfMemoryError错误的。
本地方法栈:与虚拟机栈的作用非常相似,只是虚拟机栈为虚拟机执行Java方法服务,本地方法栈则为虚拟机使用的Native方法服务。
虚拟机运行时数据区之外的内存叫直接内存(Direct Memory),当我们使用NIO来,会调用Native方法直接分配堆外内存,通过一个存储在java堆中的DirectByteBuffer对象被java程序使用。
垃圾收集器
确定对象存活算法
引用计数算法:当对象被引用,该对象的引用计数器+1,引用失效-1。目前主流的java虚拟机里面都没有选用引用计数算法来管理内存,最主要原因是它很难解决对象之间的循环引用问题。
可达性分析算法:当一个对象到GC Roots没有任何引用链相连时,证明此对象可以回收。第一次GC时不可达对象可以通过finalize方法将自己变成可达从而避免被回收,第一次之后。GC Roots包括:1)虚拟机栈(栈帧中的本地变量表)中的引用对象;2)方法区中类静态属性引用的对象;3)方法区中常量引用的对象;4)本地方法栈中native方法引用的对象。
类回收条件:
- 该类所有的实例都已经被回收
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有任何地方被引用
垃圾收集算法
标记-清除算法:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。该算法效率不高,而且产生大量不连续的内存碎片。
复制算法:将可用内存按容量分成大小相等的两块,每次只使用一块,当一块用完了就将还存活的对象复制到另一块上面,然后把使用完哪块一次清理掉。效率高但可用内存为原来一半。适用于年轻代内存分配回收。
标记-整理算法:复制算法在存活率较高时需要进行较多的复制操作,效率变低。根据老年代的特定,提出标记-整理算法,标记出所有需要回收的对象,然后将所有存活对象移动到一端。
安全点
为了保证GC回收时GC ROOT到堆对象的引用关系图的一致性,采用“串行”执行来保证“原子性”(也就是停止所有线程 STOP THE WORLD)。由于全扫描所有对象的时间成本非常大,HotSpot虚拟机实现采用了一个称为OopMap的数据结构来记录哪些内存地址存放了对象引用,通过生成的汇编代码可以看到OopMap存在编译后的指令中。在OopMap的协助下,HotSpot可以快速且准确完成GC Roots枚举,但一个很现实的问题随之而来:可能引起OopMap内容变化的指令非常多,如果为每一个指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本也会很高。HotSpot只是在“特定的位置”记录了OopMap信息。这些位置称为“安全点”。安全点一般选在长时间执行的指令前,如方法调用、循环跳转、异常跳转等。在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不是安全点,就恢复线程,让它“跑”到安全点上。有些线程处于“sleep状态”或者“blocked状态”,GC不可能等这些线程苏醒,这时就引出“安全区”概念,在安全区的任意位置开始GC都是安全的。类似sleep等指令对应的就是安全区。
垃圾收集器
收集器名称 | 适用堆内存区域 | 描述 |
---|---|---|
Serial | 新生代 | 使用复制算法,使用单线程去完成垃圾回收 |
ParNew | 新生代 | 是Serial的多线程版本,在多核机器下充分利用了CPU |
Parallel Scavenge | 新生代 | 使用复制算法的收集器,是多线程的,Parallel Scavenge收集器的目的是为了更充分的利用CPU,保障用户线程使用CPU的时间是一个固定比例。适用于后台任务系统 |
Serial Old | 老年代 | Serial Old是Serial收集器的老年代版本 |
Parallel Old | 老年代 | Parallel Scavenge的老年代垃圾收集器。但使用多线程和“标记-整理”算法 |
CMS(Concurrent Mark Sweep) | 老年代 | 基于“标记-清除”算法实现,以获取最短回收停顿时间为目标的收集器。CMS垃圾收集过程分为:初始标记、并发标记、重新标记、并发清除。初始标记仅仅标记GC Roots能直接关联对象,并发标记和用户线程同时进行,重新标记则是为了修正并发标记期间用户程序导致产生变化的标记记录。CMS只需要在初始标记和重新标记STOP THE WORLD,所以停顿时间短。 |
G1 | 新生代&老年代 | 使用G1收集器时,Java堆内存划分成多个大小相等的独立区域(Region),新生代和老年代不再是物理隔离了,都是Region的一部分,整个运作过程和CMS很像,分初始标记、并发标记、最终标记、筛选回收。 |
HotSpot垃圾收集器组合方式
内存分配与回收策略
新生代Eden:fromSurvivor:toSurvivor默认比例大小为8:1:1。对象优先分配在新生代的Eden区,每一次新生代GC(Minor GC)对象都是从Eden和from Survivor转到to Survivor区,这时对象年龄+1,当对象年龄增加到一定程度(默认15),对象就被晋升到老年代中。大对象在新生代没有空间时会直接创建到老年代区。
虚拟机监控工具简介
名称 | 主要作用 |
---|---|
jps(JVN Process Status Tool) | 显示制定系统所有的HotSpot虚拟机进程,类似linux的ps命令 |
jstat(JVM Statistics Monitoring Tool) | 用于收集HotSpot虚拟机各方面运行数据,可以显示本地或远程虚拟机进程中的类状态、内存、垃圾收集、JIT编译等运行数据 |
jinfo(Configuration Info for java) | 显示虚拟机配置信息,主要用于查询虚拟机启动参数 |
jmap(Memory Map for java) | 生成虚拟机的内存转储快照,在启动参数重加-XX:+HeapDumpOnOutOfMemoryError参数,可以让虚拟机在OOM异常之后自动生成dump文件,dump文件可以使用MAT工具进行分析 |
jhat(JVM Heap Dump Browser) | 用于分析heapdump文件,它会建立一个Http/Html服务器,让用户可以在浏览器上查看分析结果。分析结果以包进行分组显示,可以用于分析一些简单的内存问题,更专业的还是推荐MAT |
jstack(Stack Trace for Java) | 即时显示虚拟机的线程快照,可以用于定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致长时间等待等问题。 |
HsDis(HotSpot disassembler) | JIT生成代码反生成汇编语句,可以用于分析机器底层时怎么理解执行我们的java语句。[HSDIS安装执行参考] |
其实jdk提供了很多监控JVM运行状态的接口,市场上大部分线上排除工具、分析工具都是基于Instrumentation和Attach相关接口实现的。
基于Instrumentation可以用独立于应用程序之外的代理(agent)程序来监测和协助运行在JVM上的应用程序。这种监测和协助包括但不限于获取JVM运行时状态,替换和修改类定义等。 Instrumentation 的最大作用就是类定义的动态改变和操作。Instrumentation结合字节码编程可以无侵入的实现线上java服务器的监控。
Attach Api家族的成员非常的少。这里我们只关注2个类,”VirtualMachine” and “AttachProvider”,AttachProvider 的实现是针对不同的操作来使用的。正如他的名字提到的, AttachProvider针对每种不同的操作系统提供(provide)一个可以访问的 VirtualMachine的入口。
关于如何利用Instrumentation和Attach接口实现JVM虚拟机监控以及在线排查工具的实现,我后面会有单独的文章剖析。
Java线上问题分析
线上问题是每个程序员在开发过程中不可避免的,线上问题在任何公司都存在,我们能做的只是降低出现的概率和快速定位解决问题。开发者对线上发布必须要有敬畏心,同时也不要怕遇到线上问题。我们总是在发现bug,解决bug中成长的。
我个人将线上问题可以分为以下四类:
- 网络相关类
- 应用性能类
- 机器性能类
- 应用逻辑类
网络相关异常
当我们从系统日志中发现SocketException、ConnectException、SoketTimeoutException、UnknownHostException、BindException等与网络相关异常时,先通过ping或者telnet(或者通过nc –v {ip} {port})等工具检测以下相应的ip端口是否通。这类问题我们一般找运维配置相关环境。网络相关异常一般跟Java虚拟机无关,这里我不再深入分析。
应用性能类
应用性能相关的异常又可以分为以下四类,我们逐一分析:
- 运行类异常
- 应用没响应
- 调用超时
- 内存溢出
运行类异常
现象:当应用日志中出现NoSuchMethodException、ClassNotFoundException、NoClassDefFoundError、ClassCastException等相关异常时。
常见原因:
1)经常遇到的包冲突
2)Java ClassLoader机制引起的加载顺序问题
排查方法:
1)加载顺序:在应用启动的Vm参数中添加-XX:+TraceClassLoading 查看应用启动加载的jar包信息
2)包冲突:通过mvn dependency:tree 打印依赖树
应用没响应
现象:http返回499、502、504等异常码
常见原因:
1)java进程退出
2)资源被耗光(CPU、内存,这种后面单独说)
3)死锁
4)处理线程池耗光
排查方法:
1)死锁:通过jstack –l 打印当前jvm中的所有堆栈信息,查看”wating”状态的线程是否存在“当前线程locking的资源正式另一个线程wating的资源”的环形等待
2)处理线程池耗光:通过jstack –l查看相关线程数
3)java进程退出:jps或者ps aux|grep “java”查看有没有相关进程
调用超时
现象:业务日志各种TimeoutException异常
常见原因:
1)服务端响应慢
2)调用端或者服务端存在FullGC
3)调用端或者服务端load比较高(后面单独说)
4)网络问题(参照之前的方案)
排查方法:
先通过公司的服务链路监控查看相应调用的调用链路耗时,找到异常的服务。再登上对应应用的服务器查看机器的负载信息和服务相应的GC日志。如果服务器load比较高,需要查看服务器IO、CPU、丢包率等更细的指标定为出是哪项资源存在瓶颈,结合服务器流量、操作行为(访问磁盘频率、访问文件大小)定为出具体问题。如果GC比较频繁,那就dump一份内存,分析一下是不是存在内存泄漏或者大量复杂对象等原因。
内存溢出
现象:业务日志出现java.lang.OutOfMemoryError异常,OOM后面可能跟着
1)GC overhead limit exceeded java heap space(堆溢出)
2)Unable to create new native thread(无法创建线程)
3)PermGen Space(永生代异常)
4)Direct buffer memory(直接内存溢出)
常见原因:
1)Java Heap分配不出需要的内存,存在内存泄漏
2)线程数超过了ulimit限制或者线程数超过了kernel.pid_max
3)加载的类、常量等信息超过JVM中永生代的内存限制
4)ByteBuffer.allocateDirect申请的内存块超过 –Xmx的大小
排查方法:
1)堆溢出:通过-XX:+HeapDumpOnOutOfMemeryError拿到内存dump文件或者jmap –dump:file=<文件名>,format=b pid 拿到HeapDump文件,然后通过MAT 相关工具分析上面得到的HeapDump文件
2)无法创建线程:ps -eLf|grep java –c 查看当前所有的线程数 和 cat /proc/[pid]/limits 查看某个进程的资源限制
3)永生代异常:调大PermSize
4)直接内存:通过-XX:MaxDirectMemorySize 调节大小
机器性能类异常
服务器性能又体现在CPU、内存、磁盘IO三块。下面逐个分析
CPU核心指标
us :用户空间占用CPU百分比</br>
sy : 内核空间占用CPU百分比
wa :等待输入输出的CPU时间百分比
load: 综合指标,指的是运行队列(run-queue)的长度(等待进程的数目 + 运行进程的数目)
应用内存核心指标
VIRT: 当前进程对虚拟内存使用量。
RES:当前进程的物理内存使用量。
SHR:当前进程的共享内存使用量。
磁盘IO
r/s:每秒发送到设备的读入请求数.</br>
w/s:每秒发送到设备的写入请求数.</br>
rsec/s:每秒从设备读入的扇区数.</br>
wsec/s:每秒向设备写入的扇区数.
await:I/O请求平均执行时间,包括发送请求和执行的时间,单位是毫秒.
%util:在I/O请求发送到设备期间,占用CPU时间的百分比,用于显示设备的带宽利用率。当这个值接近100%时,表示设备带宽已经占满.
常见问题
us高:代码中出现非常耗CPU的操作或者出现频繁的FullGC
sy高:锁竞争激烈,线程切换频繁
iowait高:io读写操作频繁
load高:一般根据cpu数量去判断,Load值大于CPU的数量才算高。load是可以理解为一个综合指标,一般伴随着CPU、IO异常一起出现。满足以下条件就会进入CPU执行等待队列,就会被load值统计进去:1)它没有在等待I/O操作的结果;2)它没有主动进入等待状态(也就是没有调用’wait’);3)没有被停止(例如:等待终止)
查看这些参数的命令
top (-H):top可以实时的观察cpu的指标状况,尤其是每个core的指标状况,可以更有效的来帮助解决问题,-H则有助于看是什么线程造成的CPU消耗,这对解决一些简单的耗CPU的问题会有很大帮助。
Sar:sar有助于查看历史指标数据,除了CPU外,其他内存,磁盘,网络等等各种指标都可以查看,毕竟大部分时候问题都发生在过去,所以翻历史记录非常重要。
PS:所有的问题都需要具体分析,但是问题分析的前提是我们要知道各个指标的确切定义,不然容易丢失关键信息而一直无法发现真正原因。
业务逻辑异常
其实我们遇到90%以上的线上问题都是逻辑问题,逻辑问题在本地我们可以通过工具一行一行debug确定问题。本地环境和线上环境一般情况下不互通,需要跳板机中转,同时远程DEBUG很有可能将其他正常的业务请求拦下,影响其他用户的使用。推荐一款很好用的在线排查工具grace,grace文档的使用说明已经很详细,我不再累述,在线排查的原理我后面会有单独的文章分析。