一:运行时数据区域

MemoryArea

1.1 程序计数器(Program Counter Register)

程序计数器(Program Counter Register),也有称作为PC寄存器。在汇编语言中,程序计数器是指CPU中的寄存器,它保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址),当CPU需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加1或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令。
虽然JVM中的程序计数器并不像汇编语言中的程序计数器一样是物理概念上的CPU寄存器,但是JVM中的程序计数器的功能跟汇编语言中的程序计数器的功能在逻辑上是等同的,也就是说是用来指示 执行哪条指令的。程序计数器( Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于在JVM中,多线程是通过线程轮流切换来获得CPU执行时间的,因此,在任一具体时刻,一个CPU的内核只会执行一条线程中的指令,因此,为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序,此时程序计数器需要记录当前线程执行到哪一步了,以便下一次CPU可以在这个记录点上继续执行。因此,可以这么说,程序计数器是每个线程所私有的。

在JVM规范中规定,如果线程执行的是非native方法,则程序计数器中保存的是当前需要执行的指令的地址,如果线程执行的是native方法,则程序计数器中的值是undefined。

由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,对于程序计数器是不会发生内存溢出现象(OutOfMemory)的。

1.2 java虚拟机栈

与程序计时器一样,虚拟机栈也是线程私有的,它的生命周期与线程相同,虚拟机中描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)(Java栈中存放的是一个个的栈帧,每个栈帧对应一个被调用的方法),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈道出栈的过程。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。其中局部变量表中存放了编译器可知的各种基本数据类型、对象引用类型(不是对象本身)。

局部变量表,就是用来存储方法中的局部变量(包括在方法中声明的非静态变量以及函数形参)。局部变量表中存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用( reference 类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用。局部变量表所需的内存空间在编译期完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期不会改变局部变量大小。

操作数栈,栈最典型的一个应用就是用来对表达式求值。想想一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的以及参数的传递。

指向运行时常量池的引用,因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。

方法返回地址,当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。
由于每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的Java栈,互不干扰。
对于java虚拟机栈,有两种异常情况:
1)如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常 。
2)如果虚拟机栈扩展时无法申请到足够的内存时会抛出 OutOfMemoryError 异常。
java虚拟机栈

1.3 本地方法栈(Native Method Stack)

线程私有。本地方法栈与Java栈的作用和原理非常相似。区别只不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的 Native方法服务。有的虚拟机(譬如 Sun HotSpot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。
与虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutMemoryError错误。

1.4 java堆(Java Heap)

对于大多数应用来说,Java堆是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC 堆”(Garbage Collected Heap)。如果从内存回收的角度看,由于现在收集器基本采用分代回收算法,所以Java堆还可细分为:新生代(Young Generation)与老生代(Old Generation);再细致一点的有Eden空间,From Survivor空间,To Survivor空间等。值得注意的是,从JKD1.7开始,永久代Perm逐渐被移除,最新的JDK1.8中已经使用元空间(MetaSpace)代替永久代。如果从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区。但无论怎么去划分,无论那个区域,java堆中存储的依然是对象的实例。进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。如果堆中没有内存完成实例分配,并且对也无法再扩展时,将会抛出OutOfMemoryError异常。
java堆
如图,新生代还可以分为Eden空间、From Survivor空间、To Survivor空间。
永久代(Permanent Generation)用于存储静态类型数据,与垃圾收集器关系不大。
注意:本图展示的是JVM堆的内存模型,JVM堆内存包括Java堆区域 和 永久代区域。因此,永久代不属于Java堆。

1.5 方法区(Method Area)

方法区是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

方法区与堆一样,是各个线程共享的内存区域。在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。
在JVM规范中,没有强制要求方法区必须实现垃圾回收。很多人习惯将方法区称为“永久代”,是因为HotSpot虚拟机以永久代来实现方法区,从而JVM的垃圾收集器可以像管理堆区一样管理这部分区域,从而不需要专门为这部分设计垃圾回收机制。不过自从JDK7之后,Hotspot虚拟机便将运行时常量池从永久代移除了。
相对而言,垃圾收集行为在这个区域比较少出现,但并非数据进了方法区就永久的存在了,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
JDK1.7中,已经把放在永久代的字符串常量池移到堆中。JDK1.8撤销永久代,引入元空间。

根据java虚拟机规范的规定:当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

1.6 运行时常量池

运行时常量池是方法区的一部分,在Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用。这部分内容将在类加载后进入方法区的运行时常量池中存放

同时运行时常量池具备动态性,并非预置入Class文件中常量池的内存才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,例如String类的intern()方法(方法返回s1在常量池中的引用,没有则创建)。
既然运行时常量池是方法区的一部分,自然受到方法区内存限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

1.7 直接内存(Direct Memory)

直接内存(Direct memory)并不是JVM运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但这部分内存也被频繁使用,而且它也可能导致OutOfMemoryError异常出现。

直接内存不是虚拟机运行时数据区的一部分,在NIO类中引入一种基于通道与缓冲区的IO方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因此避免了在java堆和Native堆中来回复制数据。

本机直接内存的分配不会受到Java堆大小的限制,但是,还是会受到本机总内存(包括RAM及SWAP区或者分页文件)的大小及处理器寻址空间的限制,从而导致动态扩展时出现OutOfMemoryError异常。

二:hotspot虚拟机对象奥秘

2.1 对象的创建过程

Java在语言层面,通过一个关键字new来创建对象。在虚拟机中,当遇到一条new指令后,将开始如下创建过程:

2.1.1 判断类是否加载、解析、初始化

虚拟机遇到一条new指令时,先去检查这个指定的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那先执行相应的类加载过程。

2.1.2 为新对象分配内存

前面说到,对象的内存分配是在Java堆中的,对象所需内存的大小在类加载完便可确实。为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来,此时Java堆中的情况有两种可能,一种是Java堆中内存是绝对规整的,一种是Java堆中的内存并不是规整的。因此有两种分配方式:

  1. Java堆内存是规整的,即所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,此时,分配内存仅需要把这个指针向空闲空间那边挪动一段与对象大小相等的距离,这种方式也称为“指针碰撞”(Bump the Pointer);
  2. Java堆内存不是规整的,即已使用的内存和空闲的内存相互交错,就没有办法简单地进行指针的移动,此时的分配方案是,虚拟机必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的控件划分给对象实例,并更新列表上的记录,这种方式也称为“空闲列表”(Free List);

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,对于Serial、ParNew等带Compact过程的垃圾收集器,系统采用的是指针碰撞算法;对于CMS这种基于Mark-Sweep算法的收集器,通常采用空闲列表算法。

2.1.3 解决并发安全问题

确定了如何划分内存空间之后,还有一个问题就是,对象的创建在虚拟机中是非常频繁的行为,比如,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况,解决这种并发问题,一般有两种方案:

  1. 对分配内存空间的动作进行同步处理,比如,虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;
  2. 另一种方式是,把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的TLAB上分配。只有TLAB用完并分配新的TLAB时,才需要同步锁定,虚拟机是否使用TLAB,可以通过-XX:+/-UserTLAB参数来设定。

2.1.4 初始化分配到的内存空间

内存分配完成后,虚拟机将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作也可以提前至TLAB分配时进行。也正是这一步操作,才保证了我们对象的实例字段在Java代码中可以不赋初值就直接使用。注意,此时对象的实例字段全部为零值,并没有按照程序中的初值进行初始化。

2.1.5 设置对象实例的对象头

上面工作完成后,虚拟机对对象进行必要的设置,主要是设置对象的对象头信息,比如,这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等…

2.1.6 初始化对象方法

其实,上面工作完成后,从虚拟机角度来看,一个新的对象已经产生了,但从Java程序的角度来看,对象创建才刚刚开始,对象实例中的字段仅仅都为零值,还需要通过方法进行初始化,把对象按照程序员的意愿进行初始化。此时,一个真正可用的对象才算完全产生出来。

2.2 对象的内存布局

经过前面的创建工作,一个对象已经成功产生,也已经在Java堆中分配好了内存。那这个对象在Java堆内存中到底是什么形态呢?又包括哪些部分呢?这就涉及到了对象的内存布局了。
不同的虚拟机实现中,对象的内存布局有差别,以最常用的HotSpot虚拟机为例,对象在内存中存储的布局分为3块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。

  • 对象头:包含两部分信息,一部分是用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志等;另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个Java数组,对象头中还有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组大小。
  • 实例数据:真正存储对象有效信息的部分。也就是在程序中定义的各种类型的字段内容,包括从父类继承下来的,以及子类中定义的,都会在实例数据中记录。
  • 对齐填充:不是必然存在的,仅起着占位符的作用,对于HotSpot来说,虚拟机的自动内存管理系统要求对象其实地址必须是8字节的整数倍,因此,如果对象实例数据部分没有对齐时,就需要通过对齐填充的方式来补全。

2.3 对象的访问定位

建立了对象是为了使用对象,我们对数据的使用是通过栈上的reference数据来操作堆上的具体对象,对于不同的虚拟机实现,reference数据类型有不同的定义,主要是如下两种访问方式:

2.3.1 使用句柄访问

此时,Java堆中将会划出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息,如下图:
句柄访问

2.3.2 使用直接指针访问

此时reference中存储的就是对象的地址。如下图:
直接指针访问

上面所说的,所谓对象类型,其实就是指,对象所属的哪个类。

上面两种对象访问方式各有优势,使用句柄访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时,只会改变句柄中的实例数据指针,而reference本身不需要修改;使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销(根据上图,节省的是对象实例数据的指针定位),由于对象的访问在Java中非常频繁,因此,这类开销积少成多后也是一项非常可观的执行成本。对于HotSpot而言,选择的是第二种方式。

三:内存溢出

内存溢出测试
内存区域报错
OOM分为两种情况:内存溢出(Memory Overflow)和内存泄漏(Memory Leak)。

3.1 OutOfMemoryError

是指程序在申请内存时,没有足够的空间供其使用,出现了Out Of Memory,也就是要求分配的内存超出了系统能给你的,系统不能满足需求,于是产生溢出。
内存溢出分为上溢和下溢,比方说栈,栈满时再做进栈必定产生空间溢出,叫上溢,栈空时再做退栈也产生空间溢出,称为下溢。

3.1.1 java堆溢出(OutOfMemoryError:Java heap space)

是被所有线程共享的一块内存区域,该内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都是在这里分配创建。由于他是虚拟机中管理的最大一块内存,所以是主要的收集区域。如果还需要再堆上分配实例,但是无法扩展出足够的内存空间,将会抛出OutOfMemoryError异常。
Java堆用于存储对象实例,我们只要不断的创建对象,而又没有及时回收这些对象(即内存泄漏),就会在对象数量达到最大堆容量限制后产生内存溢出异常。
如下代码限制java堆的大小为1m,不可扩展(就堆的最小值-Xms参数与最大值-Xmx参数设置为一样的即可避免堆自动扩展),通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出时Dump出当前的转储快照以便事后进行分析。
/**

 * VM Args: -Xms1m -Xmx1m -XX:+HeapDumpOnOutOfMemoryError
 * java堆溢出
 * 
 * @author:WangYuanJun
 * @date:2018年2月7日 下午2:22:42
 */
public class HeapOOM {

    static class OOMObejct {
    }

    public static void main(String[] args) {
        List<OOMObejct> list = new ArrayList<>();

        while (true) {
            list.add(new OOMObejct());
        }
    }
}

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid4008.hprof ...
Heap dump file created [2760158 bytes in 0.013 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at com.example.demo.test3.HeapOOM.main(HeapOOM.java:21)

3.1.2 虚拟机栈和本地方法栈溢出(OutOfMemoryError:Java heap space)

虚拟机栈:每个线程有一个私有的栈,随着线程的创建而创建。栈里面存着的是一种叫“栈帧”的东西,每个方法会创建一个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引用)、操作数栈、方法出口等信息。栈的大小可以固定也可以动态扩展。当栈调用深度大于JVM所允许的范围,会抛出StackOverflowError的错误,不过这个深度范围不是一个恒定的值,我们通过下面这段程序可以测试一下这个结果
当应用程序递归太深而发生堆栈溢出时,抛出该错误。因为栈一般默认为1-2m,一旦出现死循环或者是大量的递归调用,在不断的压栈过程中,造成栈容量超过1m而导致溢出。
栈溢出的原因:
(1)递归调用
(2)大量循环或死循环
(3)全局变量是否过多
(4)数组、List、Map数据过大
1)如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常 。

/**
 * VM Args: -Xss160k
 * 
 * 
 * @author:WangYuanJun
 * @date:2018年2月7日 下午2:45:03
 */
public class JavaVMStackSOF {
    private int stackLen = 1;

    public void stackLeak() {
        stackLen++;
        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.stackLen);
            throw e;
        }
    }
}

stack length:771Exception in thread "main" 
java.lang.StackOverflowError
    at com.wdm.mem.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:11)
    at com.wdm.mem.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12)
    at com.wdm.mem.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12)
    at com.wdm.mem.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12)

2)如果虚拟机栈扩展时无法申请到足够的内存时会抛出 OutOfMemoryError 异常。

 /**
 * VM Args: -Xss2m
 * 
 * 
 * @author:WangYuanJun
 * @date:2018年2月7日 下午3:04:59
 */
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) {
        JavaVMStackOOM javaVMStackOOM = new JavaVMStackOOM();
        javaVMStackOOM.stackLeakByThread();
    }
}

Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread

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

1.方法区溢出
绝大部分 Java 程序员应该都见过 “java.lang.OutOfMemoryError: PermGen space “这个异常。这里的 “PermGen space”其实指的就是方法区。不过方法区和“PermGen space”又有着本质的区别。前者是 JVM 的规范,而后者则是 JVM 规范的一种实现,并且只有 HotSpot 才有 “PermGen space”,而对于其他类型的虚拟机,如 JRockit(Oracle)、J9(IBM) 并没有“PermGen space”。由于方法区主要存储类的相关信息,所以对于动态生成类的情况比较容易出现永久代的内存溢出。最典型的场景就是,在 jsp 页面比较多的情况,容易出现永久代内存溢出。我们现在通过动态生成类来模拟 “PermGen space”的内存溢出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* jdk1.7
* VM Args: -XX:PermSize=8M -XX:MaxPermSize=8M
*
* @author:WangYuanJun
* @date:2018年2月2日 下午1:42:48
*/
public class PermGenOomMock{
public static void main(String[] args) {
URL url = null;
List<ClassLoader> classLoaderList = new ArrayList<ClassLoader>();
try {
url = new File("/tmp").toURI().toURL();
URL[] urls = {url};
while (true){
ClassLoader loader = new URLClassLoader(urls);
classLoaderList.add(loader);
loader.loadClass("com.example.demo.test2.test");
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space

本例中使用的 JDK 版本是 1.7,指定的 PermGen 区的大小为 8M。通过每次生成不同URLClassLoader对象来加载Test类,从而生成不同的类对象,这样就能看到我们熟悉的 “java.lang.OutOfMemoryError: PermGen space “ 异常了。这里之所以采用 JDK 1.7,是因为在 JDK 1.8 中, HotSpot 已经没有 “PermGen space”这个区间了,取而代之是一个叫做 Metaspace(元空间) 的东西。下面我们就来看看 Metaspace 与 PermGen space 的区别。可参考Java8内存模型—永久代(PermGen)和元空间(Metaspace)

2.运行常量池溢出

其实,移除永久代的工作从JDK1.7就开始了。JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。我们可以通过一段程序来比较 JDK 1.6 与 JDK 1.7及 JDK 1.8 的区别,以字符串常量为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
运行常量池溢出(jdk1.6及jdk1.6之前)
/**
* VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M -Xmx16m
* 使用jdk1.6及jdk1.6之前
*
* @author:WangYuanJun
* @date:2018年2月2日 下午2:10:12
*/
public class RuntimeConsPoolOOM {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M -Xmx16m
* 使用jdk1.7
*
* @author:WangYuanJun
* @date:2018年2月2日 下午2:10:12
*/
public class RuntimeConsPoolOOM {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* VM Args: -Xmx16m
* 使用jdk1.8
*
* @author:WangYuanJun
* @date:2018年2月2日 下午2:10:12
*/
public class RuntimeConsPoolOOM {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

  从上述结果可以看出,JDK 1.6下,会出现“PermGen Space”的内存溢出,而在 JDK 1.7和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。因此,可以大致验证 JDK 1.7 和 1.8 将字符串常量由永久代转移到堆中,并且 JDK 1.8 中已经不存在永久代的结论。现在我们看看元空间到底是一个什么东西?

  元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:

  -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
  -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。

  除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:
  -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
  -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

现在我们在 JDK 8下重新运行一下代码段 ,不过这次不再指定 PermSize 和 MaxPermSize。而是指定 MetaSpaceSize 和 MaxMetaSpaceSize的大小。输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* jdk1.8
* -XX:MetaspaceSize=8m -XX:MaxMetaspaceSize=8m
* 1.8无PermGen space,取而代之是一个叫做 Metaspace(元空间)
* Metaspace(元空间)内存溢出
* 解决:增大perm区,允许class回收
*
* @author:WangYuanJun
* @date:2018年2月2日 下午1:42:48
*/
public class PermGenOomMock{
public static void main(String[] args) {
URL url = null;
List<ClassLoader> classLoaderList = new ArrayList<ClassLoader>();
try {
url = new File("/tmp").toURI().toURL();
URL[] urls = {url};
while (true){
ClassLoader loader = new URLClassLoader(urls);
classLoaderList.add(loader);
loader.loadClass("com.example.demo.test2.test");
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace

3.1.4 本机直接内存溢出

通过参数-XX:MaxDirectMemorySize指定DirectMemory容量,若不指定则与Java堆最大值一样。可以直接通过反射获取Unsafe实例并进行内存分配,使用unsafe.allocateMemory()申请分配内存。不足时会出现OutOfMemoryError。

3.2 内存泄露(memory leak)

是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光,举个例子,就是说系统的篮子(内存)是有限的,而你申请了一个篮子,拿到之后没有归还(忘记还了或是丢了),于是造成一次内存泄漏。在你需要用篮子的时候,又去申请,如此反复,最终系统的篮子无法满足你的需求,最终会由内存泄漏造成内存溢出。

四:总结

4.1 内存区域模型小结:

(1)线程私有的区域:程序计数器、虚拟机栈、本地方法栈;
(2)所有线程共享的区域:Java堆、方法区;(注:直接内存不属于虚拟机内存模型的部分)
(3)没有异常的区域:程序计数器;
(4)StackOverflowError异常:Java虚拟机栈、本地方法栈;
(5)OutOfMemoryError异常:除程序计数器外的其他四个区域,Java虚拟机栈、本地方法栈、Java堆、方法区;直接内存也会出现OutOfMemoryError。

4.2 类和对象在运行时的内存里是怎么样的?以及各类型变量、方法在运行时是怎么交互的?

- 在程序运行时类是在方法区,实例对象本身在堆里面。
- 方法字节码在方法区。
- 线程调用方法执行时创建栈帧并压栈,方法的参数和局部变量在栈帧的局部变量表。
- 对象的实例变量和对象一起在堆里,所以各个线程都可以共享访问对象的实例变量。
- 静态变量在方法区,所有对象共享。字符串常量等常量在运行时常量池。
- 各线程调用的方法,通过堆内的对象,方法区的静态数据,可以共享交互信息。

对于JVM的内存管理, 最重要的还是与OS内存管理知识进行类比以及结合实践来学习。理解JVM内存区域的目的也是为了在工程中出现内存相关异常时能够准确的定位所在区域,及时处理。

参考:
周志明:《深入理解Java虚拟机:JVM高级特性与最佳实践》