相信大家在处理线上问题的时候,一定遇到过让人头疼的OutOfMemoryError异常。当JVM虚拟机内存中没有足够分配内存,并且垃圾收集器也无法提供更多的内存时就会抛出。
对于抛出这个异常信息,排查起来有时候也比较麻烦,是分配的内存空间过小、是内存中加载的数据量过大、还是类似集合中引用对象过多没有及时回收、或者是代码中出现了死循环等等情况。
在这篇文章中,我们不讨论怎么避免上面说的这个异常或者虚拟机怎么调优,相应的博文网上也有很多,在这里就不啰嗦了;在这里只简单介绍一下,从JDK1.8之后,虚拟机的内存中有一块区域将抛出OutOfMemoryError异常的概率减小了,在我们以后出现对这个异常进行问题排查的时候,可以减少对这一部分内存区域的关注;
以下文章中若出现不严谨或者错误的地方,也欢迎大家指正。
我开始说废话了
在讨论这个问题之前,先让我们看一下下面的一段有意思的Java代码在同一台电脑的不同的JDK环境下运行,返回结果的变化,希望可以借此窥探HotSpot虚拟机运行时内存的变化:
下面是第一段代码:
返回结果为true还是false呢?
让我们公布一下答案:
答案是可能是true也有可能是false;假如是在JDK1.8或者JDk1.7的环境下运行,答案是true。但是要是在JDK1.6环境下,返回结果为false。
下面是小编自己本地测试结果的截图:
JDK1.6的运行结果:
JDK1.7的运行结果:
JDK1.8的运行结果:
上面的代码能说明啥呢,好像和JVM虚拟机内存变化没啥特别大的关系吧?
在回答这个问题之前,让我们先大致了解一下JVM虚拟机内存模型的划分,才能明白这段代码结果反映出的问题:
相信看过周志明先生著的《深入理解Java虚拟机》第二版的童鞋,应该都知道关于虚拟机内存区域的划分:程序计数器、栈内存、堆内存和方法区
总结起来就是下面这幅图:
其中程序计数器是一个“线程私有”的一小块内存空间,同时各线程之间计数器互不影响,独立存储,它可以看做是当前线程所执行的字节码的行号指示器。字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令;且如果正在执行的是Native方法,这个计数器值为空。
在HotSpot虚拟机中,Java虚拟机栈和本地方法栈合二为一,就是我们常说的栈内存。Java虚拟机栈描述的是Java方法执行的内存模型,它也是线程私有的,本地方法栈是为使用到的Native方法服务的。
Java虚拟机栈中,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
Java堆内存是线程共享的一块内存区域,一般是Java虚拟机所管理的内存中最大的一块,此内存区域唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。同时,Java堆内存是垃圾回收器管理的主要区域,从内存回收的角度,由于现在的收集器基本都采用分代收集算法,所以Java堆内存还可以分为:新生代和老年代,再细一点的有Eden空间、From Survivor空间、To Survivor空间等。关于堆内存的更多细节以及垃圾回收算法等内容,就不在这里赘述了,感兴趣的童鞋可以找相应的文章了解、学习。
方法区在HotSpot虚拟机也被称为永久代,也是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常亮、静态变量、即时编译器变异后的代码等数据。
介绍完这些,下面才让我们真正揭晓问题的答案:
相信大家也知道,字符串常量一般放在常量池(Constant Pool)中,但是在JDK1.6环境下,常量池放在永久代(PermGen)中,在执行str2.intern()之前,String str2 = new String("test") + new String("01");通过生成了多个对象,str2最终指向Java堆内存中的“test01”的引用地址。 在执行str2.intern()时,因为常量池中没有“test01”这个字符串,会在常量池中生成该字符串的拷贝,将此字符串常量添加到常量池中。在进行String str1 = “test01”字面量赋值的时候,常量池中已经存在该字符串常量,就直接返回了该字符串常量在永久代中的引用地址,因此当调用str2==str1的时候,用Java堆内存中的引用地址和永久代中的引用地址进行比较,一定返回false。
那JDK1.7和JDK1.8的返回结果为true,是不是说他们俩str2和str1指向的是同一个内存的引用地址呢?答案确实是这样。
从JDK1.6到JDK1.7,HotSpot虚拟机,关于永久代中的内存分配模型发生了变化,其中一部分就体现在永久代中常量池的变化,JDK1.7之后将字符串常量池从永久代(PermGen)中移动到Java堆内存中了。
因此在JDK1.7和JDK1.8环境下,当调用str2==str1的时候,str1和str2都指向Java堆内存中的同一个字符串的引用地址,因此结果为true。
通过上面的例子, JDk1.7和JDK1.8相比JDk1.6,常量池由永久代被移动到了Java堆内存中,但是JDK1.7和JDK1.8好像没什么变化嘛?
这个问题,让我们通过下面的第二个例子来进行分析比较。
同样也是相同的第二段Java代码:
同时设置 PermSize 和 MaxPermSize的大小。
相信大家也已经看到了,这个是死循环,但是这段代码的报错结果会是什么呢?
让我们看一下答案:
JDK1.6的运行结果:
JDK1.7的运行结果:
JDK1.8的运行结果:
在JDK1.6环境下,抛出OutOfMemoryError:PermGen space,永久代空间不足。
在JDK1.7和JDK1.8环境下,抛出OutOfMemoryError:Java heap space,堆空间不足。
通过上面的报错信息也正好印证了咱们上面说的将常量池由永久代移动到了Java堆内存中。但是通过比对JDK1.7和JDk1.8的报错信息咱们也可以看到,相比于JDK1.7,上图中JDK1.8的报错信息中多出了一部分红色的警告信息。Ignoring option PermSize/MaxPermSize= XXM;support was removerd in 8.0;意思就是,忽略这两个参数,这两个参数已经被删除了。
这是因为从JDK1.8之后,永久代(PermGen)被完全的移除了,所以永久代的参数-XX:PermSize和-XX:MaxPermSize也被移除了。
从PermGen到Metaspace
回到文章的主题,说了这么多,其实也只是想说明,相比于HotSpot虚拟机的其他内存区域,虚拟机中方法区的内存区域已经变天啦!
对于JDK1.8, HotSpots取消了永久代,那么是不是也就没有方法区了呢?当然不是,方法区是一个规范,规范没变,它就一直在,只不过取代永久代的是元空间(Metaspace)而已。
在原来的永久代划分中,永久代用来存放类的元数据信息、静态常量以及常量池等。现在类的元信息存储在元空间中,静态变量和常量池等并入堆中,相当于原来的永久代中的数据,被元空间和堆内存给瓜分了。
相比于之前的永久代划分,Oracle为什么要做这样的改进呢?
在原来的永久代划分中,每当一个类初次被加载的时候,它的元数据都会放到永久代中。但是永久代的内存空间也是有大小限制的,如果加载的类太多,很有可能导致永久代内存溢出;同时,永久代大小也不容易确定,因为这其中有很多影响因素,比如类的总数,常量池的大小和方法数量等,但是PermSize指定太小又很容易造成永久代内存溢出;同时,HotSpot虚拟机的每种类型的垃圾回收器都需要特殊处理永久代中的元数据。永久代会为GC带来不必要的复杂度,并且回收效率偏低。将元数据从永久代剥离出来,不仅实现了对元空间的无缝管理,还可以简化Full GC以及对以后的并发隔离类元数据等方面进行优化。
❶移除永久代的影响
由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。因此,我们就不会遇到永久代存在时的内存溢出错误,也不会出现泄漏的数据移到交换区这样的事情。最终用户可以为元空间设置一个可用空间最大值,如果不进行设置,JVM会自动根据类的元数据大小动态增加元空间的容量。但是,永久代的移除并不代表自定义的类加载器泄露问题就解决了。
❷Metaspace内存管理
在元空间中,类和其元数据的生命周期和其对应的类加载器是相同的。元空间的内存管理由元空间虚拟机来完成,每一个类加载器的存储区域都称作一个元空间,所有的元空间合在一起就是我们一直说的元空间。
元空间虚拟机负责元空间的分配,其采用的形式为组块分配。组块的大小因类加载器的类型而异。在元空间虚拟机中存在一个全局的空闲组块列表。当一个类加载器需要组块时,它就会从这个全局的组块列表中获取并维持一个自己的组块列表。当一个类加载器不再存活,那么其持有的组块将会被释放,并返回给全局组块列表。类加载器持有的组块又会被分成多个块,每一个块存储一个单元的元信息。组块中的块是线性分配(指针碰撞分配形式),组块分配自内存映射区域。这些全局的虚拟内存映射区域以链表形式连接,一旦某个虚拟内存映射区域清空,这部分内存就会返回给操作系统。由于类信息并不是固定大小,因此有可能分配的空闲区块和类需要的区块大小不同,这种情况下可能导致碎片存在。元空间虚拟机目前并不支持压缩操作,所以碎片化是目前最大的问题。
❸Metaspace 垃圾回收
先前,对于类的元数据我们需要不同的垃圾回收器进行处理,现在只需要执行元空间虚拟机的C++代码即可完成。只要类加载器存活,其加载的类的元数据也是存活的,就不会被回收掉,对于僵死的类及类加载器的垃圾回收将在元数据使用达到“MaxMetaspaceSize”参数的设定值时进行,但是不会单独回收某个类,会把相关的空间整个回收掉。在元空间的回收过程中没有重定位和压缩等操作。但是元空间内的元数据会进行扫描来确定Java引用。
适时地监控和调整元空间对于减小垃圾回收频率和减少延时是很有必要的。持续的元空间垃圾回收说明,可能存在类、类加载器导致的内存泄漏或是大小设置不合适。
通过上面,是不是对元空间有一个大概的了解呢,当再遇到OutOfMemoryError异常的时候,是不是就可以减少对方法区这部分内存区域查找原因呢?
原文地址:https://www.sohu.com/a/252099792_575744