深入理解java虚拟机(第三版)读书笔记——2.4 实战:OutOfMemoryError异常

提示:此系列博客为博主个人读书笔记,其作用是总结书中内容,个人理解内存,方便复习使用。博客内容分为:原书内容总结和个人理解内容。
注:本文原书内容为博主个人提炼总结内容,方便突出要点。



前言

本人使用idea 2020,调式限制内存如图所示
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.4.1 Java堆溢出

Java堆用于储存对象实例,不断地创建对象,并保证GC Roots到对象之间有可达路径,避免垃圾回收机制清除这些对象,触发最大限制就会内存溢出异常
【个解】并保证GC Roots到对象之间有可达路径,这是书中原文,第三章会解释,这里简单理解 对象一直在用。

-Xms(堆最小内存)
-Xmx(堆最大内存)
设置一样可避免堆自动扩展
【个解】对于设置最大最小一样问题的优缺点,可以看看https://hello.blog.csdn.net/article/details/108462684?spm=1001.2101.3001.6650.3&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-3.pc_relevant_default&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-3.pc_relevant_default&utm_relevant_index=6

设置Java堆的大小为20MB,并添加参数-XX:+HeapDumpOnOutOf-MemoryError(内存溢出异常后Dump出堆转储快照,以便后续分析)

public class Heap {
    static class OOMObject{
        //-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
    }
    public static void main(String[] args) {
        ArrayList<OOMObject> arrayList = new ArrayList<>();
        while (true){
            arrayList.add(new OOMObject());
        }
    }
}

VM options: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
在这里插入图片描述

生成的java_pid4516.hprof就是 Dump文件
在这里插入图片描述
原文用的是如Eclipse Memory Analyzer(Eclipse的插件)博主是用的JProfiler, 启动软件打开快照即可
在这里插入图片描述
首先分析是内存泄漏(Memory Leak)还是内存溢出(MemoryOverflow)。
【个解】简单说,
内存泄漏:对象没用了,但没回收。
内存溢出:内存使用不足,无法创建新的对象。
内存泄漏 可以看做 内存溢出 的原因一种。
以上OOM都属于内存溢出才报的异常。

对于内存泄露:查看泄漏对象到GC Roots的引用链,通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收。
根据泄漏对象的类型信息以及它到GC Roots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内存泄漏的代码的具体位置。
【个解】找到内存泄露地点,查看未被收回原因。

对于内存溢出:查看堆内存大小(-Xmx与-Xms)是否有调整空间、从代码上查找那些对象使用不合理,生命周期过长等。
【个解】对位内存溢出地点,是否进行大批量导入之类的,是优化代码,还是提升内存。如果不是突然增大的,使用工具对内存分析,是否存在时间过长的,或者大对象之类。


2.4.2 虚拟机栈和本地方法栈溢出

HotSpot虚拟机不区分虚拟机栈和本地方法栈,-Xoss参数(设置本地方法栈大小)对它无效果。
《Java虚拟机规范》中描述了两种栈异常:

1.如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
【个解】递归调用
2.如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

而HotSpot虚拟机的选择是不支持扩展,所以第二个情况是不会发生的。
但是,在创建线程申请内存时就因无法获得足够内存而会OutOfMemoryError异常。
【个解】综上所述。栈控件在HotSpot虚拟机,只会创建线程的一开始就报出OOM,而一点运行起来,只会报出StackOverflowError

先跑下第一种情况,-Xss参数减少栈内存容量
VM options: -Xss128k

public class Stack递归 {

    //-Xss128k
    private int stackLength = 1;

    public void stackLeak(){
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        Stack递归 stack递归 = new Stack递归();
        try {
           stack递归.stackLeak();
        } catch (Throwable e){
            System.out.println("stack的长度 = " + stack递归.stackLength);
            throw e;
        }
    }
}

在这里插入图片描述对于不同版本的Java虚拟机和不同的操作系统,栈容量最小值可能会有所限制。
如果用于64位Windows系统下的JDK 11,则会提示栈容量最小不能低于180K,而在Linux下这个值则可能是228K。低于限制,HotSpot虚拟器启动时给出如下提示:
在这里插入图片描述

跑下第二种情况,多占局部变量表空间,代码比较丑陋。
【个解】多占局部变量表,使入栈栈针占用空间更多

public class JavaVMStackSOF { 
	private static int stackLength = 0; 
	public static void test() { 
		long unused1, unused2, unused3, unused4, unused5, unused6, unused7, unused8, unused9, unused10, unused11, unused12, unused13, unused14, unused15, unused16, unused17, unused18, unused19, unused20, unused21, unused22, unused23, unused24, unused25,
unused26, unused27, unused28, unused29, unused30, unused31, unused32, unused33, unused34, unused35, unused36, unused37, unused38, unused39, unused40, unused41, unused42, unused43, unused44, unused45, unused46, unused47, unused48, unused49, unused50, unused51, unused52, unused53, unused54, unused55, unused56, unused57, unused58, unused59, unused60, unused61, unused62, unused63, unused64, unused65, unused66, unused67, unused68, unused69, unused70, unused71, unused72, unused73, unused74, unused75, unused76, unused77, unused78, unused79, unused80, unused81, unused82, unused83, unused84, unused85, unused86, unused87, unused88, unused89, unused90, unused91, unused92, unused93, unused94, unused95, unused96, unused97, unused98, unused99, unused100; stackLength ++; test(); unused1 = unused2 = unused3 = unused4 = unused5 = unused6 = unused7 = unused8 = unused9 = unused10 = unused11 = unused12 = unused13 = unused14 = unused15 = unused16 = unused17 = unused18 = unused19 = unused20 = unused21 = unused22 = unused23 = unused24 = unused25 = unused26 = unused27 = unused28 = unused29 = unused30 = unused31 = unused32 = unused33 = unused34 = unused35 = unused36 = unused37 = unused38 = unused39 = unused40 = unused41 = unused42 = unused43 = unused44 = unused45 = unused46 = unused47 = unused48 = unused49 = unused50 = unused51 = unused52 = unused53 = unused54 = unused55 = unused56 = unused57 = unused58 = unused59 = unused60 = unused61 = unused62 = unused63 = unused64 = unused65 = unused66 = unused67 = unused68 = unused69 = unused70 = unused71 = unused72 = unused73 = unused74 = unused75 = unused76 = unused77 = unused78 = unused79 = unused80 = unused81 = unused82 = unused83 = unused84 = unused85 = unused86 = unused87 = unused88 = unused89 = unused90 = unused91 = unused92 = unused93 = unused94 = unused95 = unused96 = unused97 = unused98 = unused99 = unused100 = 0; 
}
	public static void main(String[] args) { 
		try {
			test(); 
		}catch (Error e){ 
			System.out.println("stack length:" + stackLength); 
			throw e;
		} 
	} 
}

在这里插入图片描述
如果在允许动态扩展栈容量的虚拟机上,如远古时代的Classic虚拟机,在Windows上的JDK 1.0.2运行的话(如果这时候要调整栈容量就应该改用-oss参数了)
在这里插入图片描述
HotSpot虚拟机上也可以OOM,上面说过不再赘述。但是,这样的操作导致的OOM跟栈空间并不存在直接的关系,主要是操作系统本身内存情况,换句话就是,每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。
【个解】每个线程分配栈内存越大,可建立的线程就越少。
【警告】以下代码不要轻易运行!博主运行已经死机了。创建线程会对操作系统带来很大的压力,疯狂创建的后果可能是gg

/*** VM Args:-Xss2M (这时候不妨设大些,请在32位系统下运行) * @author zzm */
public class JavaVMStackOOM {
    private void dontStop() {
        while (true) {
        }
    }

    public void stackLeakByThread() {
        while (true) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }

    public static void main(String[] args) throws Throwable { JavaVMStackOOM oom = new JavaVMStackOOM(); oom.stackLeakByThread(); } }

StackOverflowError异常,比较容易定位为题。在HotSpot虚拟机默认参数下,大多数情况,入栈1000-2000左右。如果是建立过多线程导致的内存溢出,在不能减少线程数量或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。这种通过“减少内存”的手段来解决内存溢出的方式,如果没有这方面处理经验,一般比较难以想到。
【个解】个人建议还是少写递归,效率低时间还慢。
减少最大堆和减少栈容量 来 换取更多的线程:这里是说,减少最大堆和减少栈容量使线程总体占用内存变少,从达到能创建更多的线程。


方法区和运行时常量池溢出

运行时常量池是方法区的一部分,这两个区域的溢出测试可以放在一起进行。HotSpot从JDK 8中完全使用元空间来代替永久代,使用“永久代”还是“元空间”来实现方法区,对程序有什么实际的影响?

String::intern()是一个本地方法,它的作用:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。
【个解】本地方法:从源码可知是带native关键字的,并且没有具体实现内容,因为它根本不是用java代码写的,大多数是c/c++,其原因大多数是:操作系统/或者底层系统交互,加快一些底层代码性能,更或者已经一些写好轮子去调。

在这里插入图片描述
先说结论(说结论前还得说件事。。。str2拼的“java”在启动的时候已经添加到常量池了,具体的可以看下R大的回答 https://www.zhihu.com/question/51102308/answer/124441115)
JDK1.6 :str1(false)、str2(false)
JDK1.7及之后 :str1(true)、str2(false)
之所以这种情况是:

JDK1.6 :字符串常量池是在方法区中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用。而StringBuilde创建的字段串来源于堆当中。
JDK1.7 :字符串常量池是在堆中,intern()方法的把首次遇到的字段串对象放在常量池中并返回引用。
从str1中,符合上面的理论。可str2就不是了,因为“java”字符串早在存在常量池中了,intern()方法返回的引用不可能跟堆中对象一致,也就是false了。

在JDK 6或更早之前HotSpot虚拟机中,常量池都是分配在永久代中,通过-XX:PermSize和-XXMaxPermSize限制永久代的大小,可间接限制其中常量池的容量。

/*** VM Args:-XX:PermSize=6M -XX:MaxPermSize=6M * @author zzm */ 
public class RuntimeConstantPoolOOM { 
	public static void main(String[] args) { 
		// 使用Set保持着常量池引用,避免Full GC回收常量池行为 
		Set<String> set = new HashSet<String>(); 
		// 在short范围内足以让6MB的PermSize产生OOM了 
		short i = 0; 
		while (true) { 
			set.add(String.valueOf(i++).intern()); 
		} 
	} 
}

在这里插入图片描述
根据OOM后异常是:PermGen space。说明确实是在方法区的常量池引发异常。
【个解】之前说过,JDK1.7及之前,永久代是方法区的实现,JDK1.8之后元空间是方法区的实现。这里强调一下,怕看的时候懵。

如果换成JDK8及更高版本则不会,就算使用-XX:MaxMeta-spaceSize参数把方法区容量同样限制在6MB,循环会一直下去,也不会异常。
【个解】这里会无限制的循环下去。我个人理解是:short的取值范围是 -32768~32767 而在while (true)下,当short达到最大值时再++会变成short的最小值(主要没找到short转二进制的方法,所以下面用int代替,道理相同)。
在这里插入图片描述
由以上操作可知,当达到最大值时,二进制为: 0111-1111-1111-1111 即为short的32767,
而再+1时,变成了1000-0000-0000-0000 即为short的-32768。
二进制的首位是符号位,0代表正数,1代表负数。
而再+1时,变成了1000-0000-0000-0001 即为short的-32767。
直到一直相加,变成了1111-1111-1111-1111 即为short的-1。
而再+1时,变成了0000-0000-0000-0000 即为short的0。
while (true)的循环操作,也就相当于1 ~ 32767 ~ -32768 ~ 1。周而复始。

后面就是一直把 -32768~32767 之间的数字添加到 字符串常量池中,一个65535个字符串 是固定的。这里固定之后,那么放set也是固定了,haset内存其实也就是个hashmap,所以数组长度最大也就是131070个。所以既然都是固定的,那就周而复始了。

之所以会循序下去,是因为限制永久代(-XX:MaxPermSize)或元空间(-XX:MaxMeta-spaceSize)对字符串常量池没啥意义,但如果限制的堆内存(-Xmx 6m)就会看到两种结果之一,具体哪种得看是那里的对象分配出问题。
在这里插入图片描述
上面的图呢,是原作者的原图。
【个解】OOM一呢,是转成String对象,堆空间满了。OOM二呢,是HashMap数组对象扩容对,堆空间满了。但是,博主直接尴尬,我循环了n次,只报下面的图。
在这里插入图片描述
在这里插入图片描述
百度可知,但是很好奇,一是我执行很多次,只能报这个错;二是如果会经常报,那么作者也不可能不写出来,可能是版本问题?在这里插入图片描述
还有就是关于gc垃圾回收的问题等等,百度的一些说啥都有的。。。我们把这个问题记到心里,继续往后看,书中下一章就是垃圾回收。

方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。
对于方法区的测试,基本思路就是:运行时创建大量的类填满它。
虽然直接使用Java SE API也可以动态产生类(如反射时的GeneratedConstructorAccessor和动态代理等),但在本次实验中操作起来比较麻烦。作者借助了CGLib直接操作字节码运行时生成了大量的动态类。
【个解】CGLib大家看的熟悉不,就是SpringAop两种实现之一,也是默认springboot2.x Aop的实现。

/**
 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
 *
 * @author zzm
 */
public class JavaMethodAreaOOM {

    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj, args);
                }
            });
            enhancer.create();
        }
    }
    static class OOMObject {
    }
}

在这里插入图片描述
在方法区中,一个类要被垃圾回收器回收所要的条件是比较苛刻的。在运行时大量生成的动态类的情况,要特别关注这部分。
常见的有:CGLib字节码增强和动态语言、大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。

在JDK 8以后,元空间替代永久代。在默认设置下,前面列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了。不过为了让使用者有预防类似的破坏性的操作,HotSpot还是提供了一些参数作为元空间的防御措施,主要包括:

·-XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。

·-XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。

·-XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX:Max-MetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比。

2.4.4 本机直接内存溢出

直接内存(Direct Memory)的大小可 -XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致。代码越过了DirectByteBuffer类直接通过反射获取Unsafe实例进行内存分配(Unsafe类 的getUnsafe()方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能,在JDK 10时才将Unsafe的部分功能通过VarHandle开放给外部使用),因为虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是Unsafe::allocateMemory()。
【个解】DirectByteBuffer(操作堆外内存,也就是直接内存)这里文中也说了,之所以不用它,是因为他是先计算内存的值无法分配后,抛出异常。而下面代码,是直接分配内存。
Unsafe类 (这玩意英文本意就是不安全的)使Java拥有了像C语言的指针一样操作内存空间的能力,同时也带来了指针的问题。

/*** VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M * @author zzm */
public class DirectMemory直接内存OOM {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws Exception {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}

在这里插入图片描述
直接内存异常明显的特征就是,Heap Dump文中没有明显异常且Dump文件很小。如果程序中使用了DirectMemory(典型的间接使用就是NIO),就考虑重点查看了。

总结

慢慢来吧,最近确实事多。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值