可能发生OOM的区域
除了程序计数器,其他区域都有可能会因为可能的空间不足发生 OutOfMemoryError,简单总结如下:
堆内存不足是最常见的 OOM 原因之一,抛出的错误信息是“java.lang.OutOfMemoryError:Java heap space”,原因可能千奇百怪,例如,可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值偏小;或者出现 JVM 处理引用不及时,导致堆积起来,内存无法释放等。而对于 Java 虚拟机栈和本地方法栈,这里要稍微复杂一点。如果我们写一段程序不断的进行递归调用,而且没有退出条件,就会导致不断地进行压栈。类似这种情况,JVM 实际会抛出 StackOverFlowError;当然,如果 JVM 试图去扩展栈空间的的时候失败,则会抛出 OutOfMemoryError。对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似 Intern 字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:“java.lang.OutOfMemoryError: PermGen space”。随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 OOM 有所改观,出现 OOM,异常信息则变成了:“java.lang.OutOfMemoryError: Metaspace”。直接内存不足,也会导致 OOM。Java 堆对于堆内存,我在上一讲介绍了最常见的新生代和老年代的划分,其内部结构随着 JVM 的发展和新 GC 方式的引入,可以有不同角度的理解,下图就是年代视角的堆结构示意图。
你可以看到,按照通常的 GC 年代方式划分,Java 堆内分为:
新生代:新生代是大部分对象创建和销毁的区域,在通常的 Java 应用中,绝大部分对象生命周期都是很短暂的。其内部又分为 Eden 区域,作为对象初始分配的区域;两个 Survivor,有时候也叫 from、to 区域,被用来放置从 Minor GC 中保留下来的对象。JVM 会随意选取一个 Survivor 区域作为“to”,然后会在 GC 过程中进行区域间拷贝,也就是将 Eden 中存活下来的对象和 from 区域的对象,拷贝到这个“to”区域。这种设计主要是为了防止内存的碎片化,并进一步清理无用对象。从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分,Hotspot JVM 还有一个概念叫做 Thread Local Allocation Buffer(TLAB),据我所知所有 OpenJDK 衍生出来的 JVM 都提供了 TLAB 的设计。这是 JVM 为每个线程分配的一个私有缓存区域,否则,多线程同时分配内存时,为避免操作同一地址,可能需要使用加锁等机制,进而影响分配速度,你可以参考下面的示意图。从图中可以看出,TLAB 仍然在堆上,它是分配在 Eden 区域内的。其内部结构比较直观易懂,start、end 就是起始地址,top(指针)则表示已经分配到哪里了。所以我们分配新对象,JVM 就会移动 top,当 top 和 end 相遇时,即表示该缓存已满,JVM 会试图再从 Eden 里分配一块儿。老年代:放置长生命周期的对象,通常都是从 Survivor 区域拷贝过来的对象。当然,也有特殊情况,我们知道普通的对象会被分配在 TLAB 上;如果对象较大,JVM 会试图直接分配在 Eden 其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM 就会直接分配到老年代。永久代这部分就是早期 Hotspot JVM 的方法区实现方式了,储存 Java 类元数据、常量池、Intern 字符串缓存,在 JDK 8 之后就不存在永久代这块儿了,被元空间取代。参数设置老年代和新生代的比例
-XX:NewRatio=value
默认情况下,这个数值是 2,意味着老年代是新生代的 2 倍大;换句话说,新生代是堆大小的 1/3。
也可以不用比例的方式调整新生代的大小,直接指定下面的参数,设定具体的内存大小数值。
-XX:NewSize=value
Eden 和 Survivor 的大小
Eden 和 Survivor 的大小是按照比例设置的,如果 SurvivorRatio 是 8,那么 Survivor 区域就是 Eden 的 1/8 大小,也就是新生代的 1/10,因为 YoungGen=Eden + 2*Survivor,JVM 参数格式是
-XX:SurvivorRatio=value
这样设置的目的是为了在进行新生代垃圾回收时,尽量减少对象在 Eden 区和 Survivor 区之间的复制次数,从而提高垃圾回收的效率。通过合理设置各个区域的大小和比例关系,可以有效地管理内存并减少垃圾回收的停顿时间,提高应用程序的性能和稳定性。
垃圾回收参考文档:https://blog.csdn.net/Hantou_crazy/article/details/135655106
判断对象存活算法引用计数器引用计数算法(Reference Counting Algorithm):该算法通过维护对象的引用计数来判断对象是否存活。当一个对象被引用时,引用计数加一;当引用失效时,引用计数减一。当对象的引用计数为 0 时,表示对象不再被引用,可以被回收。然而,该算法无法解决循环引用的问题,因此在实际应用中较少使用。
可达算法可达性分析算法(Reachability Analysis Algorithm):该算法是 JVM 中常用的垃圾回收算法。它通过一组称为“GC Roots”的对象作为起点,从这些 GC Roots 开始遍历对象之间的引用关系,能够到达的对象被认为是存活的,无法到达的对象则被判定为垃圾对象。
在 JVM 中,GC Roots 是一组特殊的对象,它们作为起始点,用于标识哪些对象是存活的。常见的 GC Roots 包括以下几种:
1. 虚拟机栈(Java Stack)中的引用对象:每个线程在运行时都有一个对应的虚拟机栈,其中存放着栈帧(Stack Frame),栈帧中包含了局部变量表(Local Variable Table)等信息。虚拟机栈中的局部变量引用的对象就是一种 GC Roots。
2. 本地方法栈(Native Method Stack)中的引用对象:本地方法栈用于存放调用本地方法(Native Method)时的信息,其中也可能包含对对象的引用。
3. 方法区(Method Area)中静态变量引用的对象:方法区中存放类的元信息、静态变量等数据,静态变量引用的对象也是一种 GC Roots。
4. JNI 引用对象(JNI Global Reference):JNI(Java Native Interface)允许 Java 调用本地方法,JNI 引用的对象可以被视为一种 GC Roots。
5. 线程对象(Thread Object):线程对象本身也是一种 GC Roots,因为线程对象可以引用其他对象。
这些 GC Roots 对象是 JVM 垃圾回收的起点,通过它们可以追踪到整个对象图,确定哪些对象是存活的。只有被 GC Roots 直接或间接引用的对象才会被认为是存活的,而无法被 GC Roots 引用到的对象则会被判定为垃圾对象,最终被垃圾回收器回收。
垃圾收集算法复制(Copying)算法,我前面讲到的新生代 GC,基本都是基于复制算法,将活着的对象复制到 to 区域,拷贝过程中将对象顺序放置,就可以避免内存碎片化。这么做的代价是,既然要进行复制,既要提前预留内存空间,有一定的浪费;另外,对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,这个开销也不小,不管是内存占用或者时间开销。
标记 - 清除(Mark-Sweep)算法,首先进行标记工作,标识出所有要回收的对象,然后进行清除。这么做除了标记、清除过程效率有限,另外就是不可避免的出现碎片化问题,这就导致其不适合特别大的堆;否则,一旦出现 Full GC,暂停时间可能根本无法接受。
标记 - 整理(Mark-Compact),类似于标记 - 清除,但为避免内存碎片化,它会在清理过程中将对象移动,以确保移动后的对象占用连续的内存空间。
垃圾收集器Serial GCSerial GC,它是最古老的垃圾收集器,“Serial”体现在其收集工作是单线程的,并且在进行垃圾收集过程中,会进入臭名昭著的“Stop-The-World”状态。当然,其单线程设计也意味着精简的 GC 实现,无需维护复杂的数据结构,初始化也简单,所以一直是 Client 模式下 JVM 的默认选项。从年代的角度,通常将其老年代实现单独称作 Serial Old,它采用了标记 - 整理(Mark-Compact)算法,区别于新生代的复制算法。
Serial GC 的对应 JVM 参数是:
-XX:+UseSerialGCParNew GCParNew GC,很明显是个新生代 GC 实现,它实际是 Serial GC 的多线程版本,最常见的应用场景是配合老年代的 CMS GC 工作,下面是对应参数
-XX:+UseConcMarkSweepGC -XX:+UseParNewGCCMS(Concurrent Mark Sweep) GCCMS(Concurrent Mark Sweep) GC,基于标记 - 清除(Mark-Sweep)算法,设计目标是尽量减少停顿时间,这一点对于 Web 等反应时间敏感的应用非常重要,一直到今天,仍然有很多系统使用 CMS GC。但是,CMS 采用的标记 - 清除算法,存在着内存碎片化问题,所以难以避免在长时间运行等情况下发生 full GC,导致恶劣的停顿。另外,既然强调了并发(Concurrent),CMS 会占用更多 CPU 资源,并和用户线程争抢。
Parallel GCParallel GC,在早期 JDK 8 等版本中,它是 server 模式 JVM 的默认 GC 选择,也被称作是吞吐量优先的 GC。它的算法和 Serial GC 比较相似,尽管实现要复杂的多,其特点是新生代和老年代 GC 都是并行进行的,在常见的服务器环境中更加高效。开启选项是:
-XX:+UseParallelGC另外,Parallel GC 引入了开发者友好的配置项,我们可以直接设置暂停时间或吞吐量等目标,JVM 会自动进行适应性调整,例如下面参数:
-XX:MaxGCPauseMillis=value-XX:GCTimeRatio=N // GC时间和用户时间比例 = 1 / (N+1)G1G1 GC 这是一种兼顾吞吐量和停顿时间的 GC 实现,是 Oracle JDK 9 以后的默认 GC 选项。G1 可以直观的设定停顿时间的目标,相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多。G1 GC 仍然存在着年代的概念,但是其内存结构并不是简单的条带式划分,而是类似棋盘的一个个 region。Region 之间是复制算法,但整体上实际可看作是标记 - 整理(Mark-Compact)算法,可以有效地避免内存碎片,尤其是当 Java 堆非常大的时候,G1 的优势更加明显。
G1 吞吐量和停顿表现都非常不错,并且仍然在不断地完善,与此同时 CMS 已经在 JDK 9 中被标记为废弃(deprecated),所以 G1 GC 值得你深入掌握。
在 G1 实现中,年代是个逻辑概念,具体体现在,一部分 region 是作为 Eden,一部分作为 Survivor,除了意料之中的 Old region,G1 会将超过 region 50% 大小的对象(在应用中,通常是 byte 或 char 数组)归类为 Humongous 对象,并放置在相应的 region 中。逻辑上,Humongous region 算是老年代的一部分,因为复制这样的大对象是很昂贵的操作,并不适合新生代 GC 的复制算法。
从 GC 算法的角度,G1 选择的是复合算法,可以简化理解为:
在新生代,G1 采用的仍然是并行的复制算法,所以同样会发生 Stop-The-World 的暂停。
在老年代,大部分情况下都是并发标记,而整理(Compact)则是和新生代 GC 时捎带进行,并且不是整体性的整理,而是增量进行的。
G1(Garbage First)收集器是 Java 虚拟机中的一种垃圾收集器,主要以提供更加可控的停顿时间和更高的吞吐量为目标。以下是 G1 收集器的一些特点和工作原理:
区域划分:G1 将整个堆内存划分为多个相等大小的区域(Region),每个区域既可以是 Eden 区、Survivor 区或者 Old 区,也可以根据需要动态变化。并行与并发:G1 收集器采用了并行与并发的垃圾收集策略。它通过并行方式来执行年轻代的垃圾收集和部分老年代的垃圾收集,同时通过并发方式来执行标记和清理阶段,以减少停顿时间。基于回收价值优先:G1 收集器根据各个区域的回收价值(Garbage First)来决定优先回收哪些区域,以达到在有限时间内回收最多垃圾对象的目的。停顿时间可控:G1 收集器设计了可预测的停顿时间模型,可以通过设置目标停顿时间来控制最大垃圾收集停顿时间,避免长时间的停顿对应用性能造成影响。混合模式收集:G1 收集器在执行垃圾收集时会结合新生代和老年代的收集行为,通过混合模式收集来实现高效的垃圾回收。总的来说,G1 收集器在尽量保证低停顿时间的同时,也具备较高的吞吐量,适用于需要平衡吞吐量和停顿时间的中大型应用场景。通过合理配置参数和监控性能指标,可以有效地优化应用的垃圾收集性能。
ZGCZGC,这是 Oracle 开源出来的一个超级 GC 实现,具备令人惊讶的扩展能力,比如支持 T bytes 级别的堆大小,并且保证绝大部分情况下,延迟都不会超过 10 ms。虽然目前还处于实验阶段,仅支持 Linux 64 位的平台,但其已经表现出的能力和潜力都非常令人期待。
JVM之逃逸分析参考文档:https://blog.csdn.net/Hellowenpan/article/details/120952560
JVM的内存分配主要在是运行时数据区(Runtime Data Areas),而运行时数据区又分为了:方法区,堆区,PC寄存器,Java虚拟机栈(就是栈区,官方文档还是叫Java虚拟机栈),本地方法区,而内存逃逸主要是对象的动态作用域的改变而引起的,故而内存逃逸的分析就是分析对象的动态作用域
逃逸方式:方法逃逸、线程逃逸
逃逸分析优化内容优化一将堆分配转化为栈分配方法栈上的对象在方法执行完之后,栈桢弹出,对象就会自动回收。这样的话就不需要等内存满时再触发内存回收。这样的好处是程序内存回收效率高,并且GC频率也会减少,程序的性能就提高了
同步锁消除线程同步本身比较耗时,若确定了一个变量不会逃逸出线程,无法被其他线程访问到,那这个变量的读写就不会存在竞争,这个变量的同步措施就可以清除掉。
如果发现某个对象只能从一个线程可访问,那么在这个对象上的操作可以不需要同步。
说明了逃逸分析把锁消除了,并在性能上得到了很大的提升。这里说明一下Java的逃逸分析是方法级别的,因为JIT的即时编译是方法级别。
分离对象或标量替换。这个简单来说就是把对象分解成一个个基本类型,并且内存分配不再是分配在堆上,而是分配在栈上。这样的好处有,一、减少内存使用,因为不用生成对象头。 二、程序内存回收效率高,并且GC频率也会减少,总的来说和上面优点一的效果差不多。
若逃逸分析证明一个对象不会逃逸出方法,不会被外部访问,并且这个对象是可以被分解的,那程序在真正执行的时候可能不创建这个对象,而是直接创建这个对象分解后的标量来代替。这样就无需在对对象分配空间了,只在栈上为分解出的变量分配内存即可
逃逸相关JVM参数①、说明
逃逸分析是比较耗时的,所以性能未必提升很多,因为其耗时性,采用的算法都是不那么准确但是时间压力相对较小的算法来完成的,这就可能导致效果不稳定,要慎用。
由于HotSpot虚拟机目前的实现方法导致栈上分配实现起来比较复杂,所以HotSpot虚拟机中暂时还没有这项优化。
②、相关的JVM参数
-XX:+DoEscapeAnalysis 开启逃逸分析、
-XX:+PrintEscapeAnalysis 开启逃逸分析后,可通过此参数查看分析结果。
-XX:+EliminateAllocations 开启标量替换
-XX:+EliminateLocks 开启同步消除
-XX:+PrintEliminateAllocations 开启标量替换后,查看标量替换情况。
内存溢出&泄漏内存溢出递归
大对象
概念java.lang.OutOfMemoryError,是指程序在申请内存时,没有足够的内存空间供其使用,出现OutOfMemoryError。产生该错误的原因主要包括:JVM内存过小。程序不严密,产生了过多的垃圾。
程序体现:内存中加载的数据量过于庞大,如一次从数据库取出过多数据。
Cglib 不断创建新类
大量 JSP 或动态产生 JSP 文件的应用
集合类中有对对象的引用,使用完后未清空,使得JVM不能回收。
代码中存在死循环或循环产生过多重复的对象实体。
使用的第三方软件中的BUG。
启动参数内存值设定的过小。
错误提示:
tomcat:java.lang.OutOfMemoryError: PermGen space
tomcat:java.lang.OutOfMemoryError: Java heap space
weblogic:Root cause of ServletException java.lang.OutOfMemoryError
resin:java.lang.OutOfMemoryError
java:java.lang.OutOfMemoryError
解决办法增加JVM的内存大小。具体可参考:jvm之内存调优
优化程序,释放垃圾。主要思路就是避免程序体现上出现的情况。避免死循环,防止一次载入太多的数据,提高程序健壮性及时释放。因此,从根本上解决Java内存溢出的唯一方法就是修改程序,及时地释放没用的对象,释放内存空间。
内存泄漏threadLocal
没有用到的对象
概念Memory Leak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。
在Java中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点:
1)首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;
2)其次,这些对象是无用的,即程序以后不会再使用这些对象。
如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存。对于内存泄露的处理也就是提高程序的健壮性,因为内存泄露是纯代码层面的问题。
1.泄漏分类经常发生:发生内存泄露的代码会被多次执行,每次执行,泄露一块内存;
偶然发生:在某些特定情况下才会发生;
一次性:发生内存泄露的方法只会执行一次;
隐式泄露:一直占着内存不释放,直到执行结束;严格的说这个不算内存泄露,因为最终释放掉了,但是如果执行时间特别长,也可能会导致内存耗尽。
2.导致内存泄漏的常见原因循环 过多或者死循环导致产生了大量对象
静态集合类: 引起的内存泄漏,因为静态集合类的生命周期和JVM是一致的。
单例模式: 如果单例对象引用了外部对象,会导致该外部对象一直不回被回收。因为单例的的静态属性会让对象的生命周期和JVM一致。
变量的不合理作用域
public UsingRandom { private String msg;
public void receiveMsg(){
readFromNet();// 从网络中接受数据保存到msg中
saveDB();// 把msg保存到数据库中
}
}
//如上面这个伪代码,通过readFromNet方法把接受的消息保存在变量msg中,然后调用saveDB方法把msg的内容保存到数据库中,此时msg已经就没用了,由于msg的生命周期与对象的生命周期相同,此时msg还不能回收,因此造成了内存泄漏。
//实际上这个msg变量可以放在receiveMsg方法内部,当方法使用完,那么msg的生命周期也就结束,此时就可以回收了。还有一种方法,在使用完msg后,把msg设置为null,这样垃圾回收器也会回收msg的内存空间。
数据连接: 像IO,socket连接他们必须被显示的close掉,否则不回被GC回收。
内部类 对象被外部对象长期持久,会导致外部类也无法被回收
哈希值改变: 当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了。因为,当修改后,所得的哈希值与最初存储进HashSet集合中时的哈希值就不同了。在这种情况下,即使用contains()方法,也将返回找不到对象的结果,但是HashSet却一直持有修改前的对象的实例,导致不能被GC,造成内存泄露。
监听器和回调: 在Java语言中, 往往呢会使用到监听器 ,一个应用可能会使用到多个监听器。 比如说, 在我们Java Web中有底层的网络监听器listener ,监听器的作用就是去监听指定的类或者对象他产生的行为 ,从而做出对应的响应, 因为监听器往往都是全局存在的, 如果对于监听器中所使用这些对象或者是变量 ,你没有有效的控制的话 ,很容易产生内存泄露 。
缓存: 内存泄漏的另一个常见来源是缓存。举个例子,我们有时候为了减少与db的交互次数,会将查询出的对象实例放入缓存中,但是常常会忘记对这个缓存进行管理。比如忘记限制缓存大小。
对于这个问题,可以使用WeakHashMap代表缓存,此种Map的特点是,当除了自身有对key的引用外,此key没有其他引用那么此map会自动丢弃此值。
内存泄漏排查1.查看JVM状态1)查看虚拟机进程,找到需要监控的进程ID
使用 jps | ps 找到对应的进程ID
jps:jps -l
ps:ps -aux | grep java
2)使用 jstat实时的查看一下当前程序的资源和性能。
命令:
jstat -gcutil 进程ID 1000
//每1000毫秒查询一次,一直查。gcutil的意思是已使用空间站总空间的百分比。
执行结果:
查询结果表明:
E:新生代Eden区,使用了5.89%(最后)的空间,
S0和S1:两个Survivor区(S0、S1,表示Survivor0、Survivor1)分别是0和58.4%
O:老年代,使用了14.65%。
M:元数据区已使用的占当前容量百分比,为95.43%
CCS:压缩类空间已使用的占当前容量百分比
YGC:程序运行以来共发生Minor GC,为45247次,(YGCT)总耗时182.626秒,
FGC:发生Full GC2.331次,总的耗时(FGCT)为2.331s
GCT:从应用程序启动到采样时gc用的总时间(s)为184.956秒。
2.定位问题2.1 定位代码
一般问题出现都是在某次上线后导致的,可以先将最近上线的代码,按照本文的2.2分析一下,分析一下看看能不能定位到问题。如果不能定位问题则按照第二步去分析。
2.2 dump文件分析
1)使用 jmap查看存活对象,并生成dump文件。
jmap命令格式:
jmap [ option ] vmid
使用命令如下:
jmap -histo:live 28558| head -20
//查看示Java堆中存活对象的统计信息,包括:对象数量、占用内存大小(单位:字节)和类的完全限定名,
生成heap dump文件:
jmap -dump:live,format=b,file=heap.hprof 3514
2)Java heap分析工具
Ecplise用MAT插件
Idea安装Jprofiler进行分析
堆Dump可视化分析在线工具: https://heaphero.io/
3)JVM调优常用工具:
JVM调优的在线网站
OOM(Out Of Memory)排查通常涉及以下几个步骤:
使用dmesg命令查看系统日志。通过dmesg | grep -E 'kill|oom|out of memory'命令可以查看与内存溢出相关的系统日志。(1)使用[ps](){"sa":"re_dqa_zy","icon":1}\命令查看进程。使用ps -aux | grep java命令可以找到Java进程的进程ID。使用top命令查看进程信息。top命令可以显示正在运行的进程和系统负载信息,包括CPU负载、内存使用、各个进程所占系统资源等。使用jstat命令查看内存使用情况。使用jstat -gcutil命令可以查看指定Java进程的内存使用情况,包括新生代和老年代的内存使用率,以及young gc和full gc的次数。使用jmap命令查看堆内存信息。使用jmap -histo命令可以打印出当前堆中所有每个类的实例数量和内存占用。分析内存快照。如果需要,可以将当前堆内存的快照转储到文件中,然后对内存快照进行分析。此外,OOM可能的原因包括地址空间不足、物理内存已耗光、应用程序的本地内存泄漏、Direct ByteBuffer问题等。解决方案可能包括升级地址空间为64位、使用Arthas检查内存泄漏、调整-XX:MaxDirectMemorySize参数、升级服务器配置或隔离部署等。(2)
对于查询导致的OOM,可能的原因包括统计信息不准确、Join Order不正确、数据倾斜或shard pruning导致负载不均衡等。解决方法可能包括更新统计信息、调整Join Order策略、优化查询计划等。(3)