JVM学习(4):Java内存异常

关于Java虚拟机这块内容的学习我是看CSDN的一位博客专家大佬@黄小斜的文章学习的,它写的内容更为全面详细,有兴趣可以去看它的文章:深入理解JVM虚拟机11:Java内存异常原理与实践。
这里仅对此文进行笔记整理。

内存泄露和内存溢出

• 内存泄露:代码中的某个对象本应该被虚拟机回收,但因为拥有GCRoot引用而没有被回收。
• 内存溢出: 虚拟机由于堆中拥有太多不可回收对象没有回收,导致无法继续创建新对象。

Java常见内存溢出(OOM):堆内存溢出、栈内存溢出、方法区内存溢出。
堆内存溢出主要原因是创建了太多对象,比如一个集合类死循环添加一个数,此时设置jvm参数使堆内存最大值为10m,一会就会报oom异常。

栈内存溢出主要与栈空间和线程有关,因为栈是线程私有的,如果创建太多线程,内存值超过栈空间上限,也会报oom。

方法区内存溢出主要是由于动态加载类的数量太多,或者是不断创建一个动态代理,用不了多久方法区内存也会溢出,会报oom,这里在1.7之前会报permgem oom,1.8则会报meta space oom,这是因为1.8中删除了堆中的永久代,转而使用元数据区。

Java是如何管理内存

为了判断Java中是否有内存泄露,我们首先必须了解Java是如何管理内存的。Java的内存管理就是对象的分配和释放问题。在Java中,程序员需要通过关键字new为每个对象申请内存空间 (基本类型除外),所有的对象都在堆 (Heap)中分配空间。另外,对象的释放是由GC决定和执行的。在Java中,内存的分配是由程序完成的,而内存的释放是有GC完成的,这种收支两条线的方法确实简化了程序员的工作。但同时,它也加重了JVM的工作。这也是Java程序运行速度较慢的原因之一。因为,GC为了能够正确释放对象,GC必须监控每一个对象的运行状态,包括对象的申请、引用、被引用、赋值等,GC都需要进行监控。
监视对象状态是为了更加准确地、及时地释放对象,而释放对象的根本原则就是该对象不再被引用。
为了更好理解GC的工作原理,我们可以将对象考虑为有向图的顶点,将引用关系考虑为图的有向边,有向边从引用者指向被引对象。另外,每个线程对象可以作为一个图的起始顶点,例如大多程序从main进程开始执行,那么该图就是以main进程顶点开始的一棵根树。在这个有向图中,根顶点可达的对象都是有效对象,GC将不回收这些对象。如果某个对象 (连通子图)与这个根顶点不可达(注意,该图为有向图),那么我们认为这个(这些)对象不再被引用,可以被GC回收。
以下,我们举一个例子说明如何用有向图表示内存管理。对于程序的每一个时刻,我们都有一个有向图表示JVM的内存分配情况。以下右图,就是左边程序运行到第6行的示意图。
JVM学习(4):Java内存异常_第1张图片
Java使用有向图的方式进行内存管理,可以消除引用循环的问题,例如有三个对象,相互引用,只要它们和根进程不可达的,那么GC也是可以回收它们的。这种方式的优点是管理内存的精度很高,但是效率较低。另外一种常用的内存管理技术是使用计数器,例如COM模型采用计数器方式管理构件,它与有向图相比,精度行低(很难处理循环引用的问题),但执行效率很高。

什么是Java中的内存泄露

在Java中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点,首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象。如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存。
JVM学习(4):Java内存异常_第2张图片
常见的内存泄露:
1、静态集合类引起内存泄露:
像HashMap、Vector等的使用最容易出现内存泄露,这些静态变量的生命周期和应用程序一致,他们所引用的所有的对象Object也不能被释放,因为他们也将一直被Vector等引用着。 例:

Static Vector v = new Vector(10); 
for (int i = 1; i<100; i++) 
{ 
Object o = new Object(); 
v.add(o); 
o = null; 
}// 

在这个例子中,循环申请Object 对象,并将所申请的对象放入一个Vector 中,如果仅仅释放引用本身(o=null),那么Vector 仍然引用该对象,所以这个对象对GC 来说是不可回收的。因此,如果对象加入到Vector 后,还必须从Vector 中删除,最简单的方法就是将Vector对象设置为null。

2、当集合里面的对象属性被修改后,再调用remove()方法时不起作用。例:

public static void main(String[] args) 
{ 
Set set = new HashSet(); 
Person p1 = new Person("唐僧","pwd1",25); 
Person p2 = new Person("孙悟空","pwd2",26); 
Person p3 = new Person("猪八戒","pwd3",27); 
set.add(p1); 
set.add(p2); 
set.add(p3); 
System.out.println("总共有:"+set.size()+" 个元素!"); //结果:总共有:3 个元素! 
p3.setAge(2); //修改p3的年龄,此时p3元素对应的hashcode值发生改变 

set.remove(p3); 
set.add(p3); //重新添加,居然添加成功
System.out.println("总共有:"+set.size()+" 个元素!"); //结果:总共有:4 个元素! 
for (Person person : set) 
{ 
System.out.println(person); 
} 
}

当set调用remove()方法时,此时remove不掉,造成内存泄漏,因为hash值计算依赖于成员变量age,当age发生变化,remove时根据新计算的hash值去集合中找该对象,找不到;再次添加p3时,由于hash值变了,属于“新对象”了,便可加入集合。

3、监听器
在java 编程中,我们都需要和监听器打交道,通常一个应用当中会用到很多监听器,我们会调用一个控件的诸如addXXXListener()等方法来增加监听器,但往往在释放对象的时候却没有记住去删除这些监听器,从而增加了内存泄漏的机会。

4、各种连接
比如数据库连接(dataSourse.getConnection()),网络连接(socket)和io连接,除非其显式的调用了其close()方法将其连接关闭,否则是不会自动被GC 回收的。对于Resultset 和Statement 对象可以不进行显式回收,但Connection 一定要显式回收,因为Connection 在任何时候都无法自动回收,而Connection一旦回收,Resultset 和Statement 对象就会立即为NULL。但是如果使用连接池,情况就不一样了,除了要显式地关闭连接,还必须显式地关闭Resultset Statement 对象(关闭其中一个,另外一个也会关闭),否则就会造成大量的Statement 对象无法释放,从而引起内存泄漏。这种情况下一般都会在try里面去的连接,在finally里面释放连接。

5、内部类和外部模块等的引用
内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放。此外程序员还要小心外部模块不经意的引用,例如程序员A 负责A 模块,调用了B 模块的一个方法如:
public void registerMsg(Object b);
这种调用就要非常小心了,传入了一个对象,很可能模块B就保持了对该对象的引用,这时候就需要注意模块B 是否提供相应的操作去除引用。

6、单例模式
不正确使用单例模式是引起内存泄露的一个常见问题,单例对象在被初始化后将在JVM的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部对象的引用,那么这个外部对象将不能被jvm正常回收,导致内存泄露,考虑下面的例子:

class A{ 
public A(){ 
B.getInstance().setA(this); 
} 
.... 
} 
//B类采用单例模式 
class B{ 
private A a; 
private static B instance=new B(); 
public B(){} 
public static B getInstance(){ 
return instance; 
} 
public void setA(A a){ 
this.a=a; 
} 
//getter... 
} 

显然B采用singleton模式,它持有一个A对象的引用,而这个A类的对象将不能被回收。

什么是Java中的内存溢出

在分析问题之前先给大家讲一讲排查内存溢出问题的方法,内存溢出时JVM虚拟机会退出,那么我们怎么知道JVM运行时的各种信息呢,Dump机制会帮助我们,可以通过加上VM参数-XX:+HeapDumpOnOutOfMemoryError让虚拟机在出现内存溢出异常时生成dump文件,然后通过外部工具(作者使用的是VisualVM)来具体分析异常的原因。

下面从以下几个方面来配合代码实战演示内存溢出及如何定位:Java堆内存异常、Java栈内存异常、方法区内存异常。

Java堆内存异常

/**
    VM Args:
    //这两个参数保证了堆中的可分配内存固定为20M
    -Xms20m
    -Xmx20m  
    //文件生成的位置,作则生成在桌面的一个目录
    -XX:+HeapDumpOnOutOfMemoryError //文件生成的位置,作则生成在桌面的一个目录
    //文件生成的位置,作则生成在桌面的一个目录
    -XX:HeapDumpPath=/Users/zdy/Desktop/dump/ 
 */
public class HeapOOM {
    //创建一个内部类用于创建对象使用
    static class OOMObject {
    }
    public static void main(String[] args) {
        List list = new ArrayList();
        //无限创建对象,在堆中
        while (true) {
            list.add(new OOMObject());
        }
    }
}

Run起来代码后爆出异常如下:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to /Users/zdy/Desktop/dump/java_pid1099.hprof …

可以看到生成了dump文件到指定目录。并且爆出了OutOfMemoryError。分析dump文件后,我们可以知道,OOMObject这个类创建了810326个实例。所以它能不溢出吗?接下来就在代码里找这个类在哪new的。排查问题。(我们的样例代码就不用排查了,While循环太凶猛了)。

Java栈内存异常
老实说,在栈中出现异常(StackOverFlowError)的概率小到和去苹果专卖店买手机,买回来后发现是Android系统的概率是一样的。因为作者确实没有在生产环境中遇到过,除了自己作死写样例代码测试。先说一下异常出现的情况,前面讲到过,方法调用的过程就是方法帧进虚拟机栈和出虚拟机栈的过程,那么有两种情况可以导致StackOverFlowError,当一个方法帧(比如需要2M内存)进入到虚拟机栈(比如还剩下1M内存)的时候,就会报出StackOverFlow.这里先说一个概念,栈深度:指目前虚拟机栈中没有出栈的方法帧。虚拟机栈容量通过参数-Xss来控制,下面通过一段代码,把栈容量人为的调小一点,然后通过递归调用触发异常。

/**
 * VM Args:
    //设置栈容量为160K,默认1M
   -Xss160k
 */
public class JavaVMStackSOF {
    private int stackLength = 1;
    public void stackLeak() {
        stackLength++;
        //递归调用,触发异常
        stackLeak();
    }
 
    public static void main(String[] args) throws Throwable {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}

结果如下:
stack length:751
Exception in thread “main” java.lang.StackOverflowError

可以看到,递归调用了751次,栈容量不够用了。
默认的栈容量在正常的方法调用时,栈深度可以达到1000-2000深度,所以,一般的递归是可以承受的住的。如果你的代码出现了StackOverflowError,首先检查代码,而不是改参数。

这里顺带提一下,很多人在做多线程开发时,当创建很多线程时,容易出现OOM(OutOfMemoryError),这时可以通过具体情况,减少最大堆容量,或者栈容量来解决问题,这是为什么呢。请看下面的公式:

线程数*(最大栈容量)+最大堆值+其他内存(忽略不计或者一般不改动)=机器最大内存

当线程数比较多时,且无法通过业务上削减线程数,那么再不换机器的情况下,你只能把最大栈容量设置小一点,或者把最大堆值设置小一点。

方法区内存异常
写到这里时,作者本来想写一个无限创建动态代理对象的例子来演示方法区溢出,避开谈论JDK7与JDK8的内存区域变更的过渡,但细想一想,还是把这一块从始致终的说清楚。在上一篇文章中JVM系列之Java内存结构详解讲到方法区时提到,JDK7环境下方法区包括了(运行时常量池),其实这么说是不准确的。因为从JDK7开始,HotSpot团队就想到开始去"永久代",大家首先明确一个概念,方法区和"永久代"(PermGen space)是两个概念,方法区是JVM虚拟机规范,任何虚拟机实现(J9等)都不能少这个区间,而"永久代"只是HotSpot对方法区的一个实现。为了把知识点列清楚,我还是才用列表的形式:

JDK7之前(包括JDK7)拥有"永久代"(PermGen space),用来实现方法区。但在JDK7中已经逐渐在实现中把永久代中把很多东西移了出来,比如:符号引用(Symbols)转移到了native heap,运行时常量池(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap.
所以这就是为什么我说上一篇文章中说方法区中包含运行时常量池是不正确的,因为已经移动到了java heap;
在JDK7之前(包括7)可以通过-XX:PermSize -XX:MaxPermSize来控制永久代的大小.
JDK8正式去除"永久代",换成Metaspace(元空间)作为JVM虚拟机规范中方法区的实现。
元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但仍可以通过参数控制:-XX:MetaspaceSize与-XX:MaxMetaspaceSize来控制大小。

方法区与运行时常量池OOM
Java 永久代是非堆内存的组成部分,用来存放类名、访问修饰符、常量池、字段描述、方法描述等,因运行时常量池是方法区的一部分,所以这里也包含运行时常量池。我们可以通过 jvm 参数 -XX:PermSize=10M -XX:MaxPermSize=10M 来指定该区域的内存大小,-XX:PermSize 默认为物理内存的 1/64 ,-XX:MaxPermSize 默认为物理内存的 1/4 。String.intern() 方法是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。在 JDK 1.6 及之前的版本中,由于常量池分配在永久代内,我们可以通过 -XX:PermSize 和 -XX:MaxPermSize 限制方法区大小,从而间接限制其中常量池的容量,通过运行 java -XX:PermSize=8M -XX:MaxPermSize=8M RuntimeConstantPoolOom 下面的代码我们可以模仿一个运行时常量池内存溢出的情况:

import java.util.ArrayList;
import java.util.List;
 
public class RuntimeConstantPoolOom {
  public static void main(String[] args) {
    List list = new ArrayList();
    int i = 0;
    while (true) {
      list.add(String.valueOf(i++).intern());
    }
  }
}

运行结果如下:

[root@9683817ada51 oom]# ../jdk1.6.0_45/bin/java -XX:PermSize=8m -XX:MaxPermSize=8m RuntimeConstantPoolOom
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
    at java.lang.String.intern(Native Method)
    at RuntimeConstantPoolOom.main(RuntimeConstantPoolOom.java:9)

还有一种情况就是我们可以通过不停的加载class来模拟方法区内存溢出,《深入理解java虚拟机》中借助 CGLIB 这类字节码技术模拟了这个异常,我们这里使用不同的 classloader 来实现(同一个类在不同的 classloader 中是不同的),代码如下:

import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.HashSet;
import java.util.Set;
 
public class MethodAreaOom {
  public static void main(String[] args) throws MalformedURLException, ClassNotFoundException {
    Set> classes = new HashSet>();
    URL url = new File("").toURI().toURL();
    URL[] urls = new URL[]{url};
    while (true) {
      ClassLoader loader = new URLClassLoader(urls);
      Class loadClass = loader.loadClass(Object.class.getName());
      classes.add(loadClass);
    }
  }
}

运行结果如下:

[root@9683817ada51 oom]# ../jdk1.6.0_45/bin/java -XX:PermSize=2m -XX:MaxPermSize=2m MethodAreaOom
Error occurred during initialization of VM
java.lang.OutOfMemoryError: PermGen space
    at sun.net.www.ParseUtil.(ParseUtil.java:31)
    at sun.misc.Launcher.getFileURL(Launcher.java:476)
    at sun.misc.Launcher$ExtClassLoader.getExtURLs(Launcher.java:187)
    at sun.misc.Launcher$ExtClassLoader.(Launcher.java:158)
    at sun.misc.Launcher$ExtClassLoader$1.run(Launcher.java:142)
    at java.security.AccessController.doPrivileged(Native Method)
    at sun.misc.Launcher$ExtClassLoader.getExtClassLoader(Launcher.java:135)
    at sun.misc.Launcher.(Launcher.java:55)
    at sun.misc.Launcher.(Launcher.java:43)
    at java.lang.ClassLoader.initSystemClassLoader(ClassLoader.java:1337)
    at java.lang.ClassLoader.getSystemClassLoader(ClassLoader.java:1319)

在 jdk1.8 上运行上面的代码将不会出现异常,因为 jdk1.8 已结去掉了永久代,当然 -XX:PermSize=2m -XX:MaxPermSize=2m 也将被忽略,如下:

[root@9683817ada51 oom]# java -XX:PermSize=2m -XX:MaxPermSize=2m MethodAreaOom
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option PermSize=2m; support was removed in 8.0
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=2m; support was removed in 8.0

jdk1.8 使用元空间( Metaspace )替代了永久代( PermSize ),因此我们可以在 1.8 中指定 Metaspace 的大小模拟上述情况

[root@9683817ada51 oom]# java -XX:MetaspaceSize=2m -XX:MaxMetaspaceSize=2m RuntimeConstantPoolOom
Error occurred during initialization of VM
java.lang.OutOfMemoryError: Metaspace
    <>

在JDK8的环境下将报出异常:
Exception in thread “main” java.lang.OutOfMemoryError: Metaspace
这是因为在调用CGLib的创建代理时会生成动态代理类,即Class对象到Metaspace,所以While一下就出异常了。
提醒一下:虽然我们日常叫"堆Dump",但是dump技术不仅仅是对于"堆"区域才有效,而是针对OOM的,也就是说不管什么区域,凡是能够报出OOM错误的,都可以使用dump技术生成dump文件来分析。

在经常动态生成大量Class的应用中,需要特别注意类的回收状况,这类场景除了例子中的CGLib技术,常见的还有,大量JSP,反射,OSGI等。需要特别注意,当出现此类异常,应该知道是哪里出了问题,然后看是调整参数,还是在代码层面优化。

附加-直接内存异常
直接内存异常非常少见,而且机制很特殊,因为直接内存不是直接向操作系统分配内存,而且通过计算得到的内存不够而手动抛出异常,所以当你发现你的dump文件很小,而且没有明显异常,只是告诉你OOM,你就可以考虑下你代码里面是不是直接或者间接使用了NIO而导致直接内存溢出。

你可能感兴趣的:(JVM学习(4):Java内存异常)