JVM简介—1.Java内存区域

大纲

1.运行时数据区的介绍

2.运行时数据区各区域的作用

3.各个版本内存区域的变化

4.直接内存的使用和作用

5.站在线程的角度看Java内存区域

6.深入分析堆和栈的区别

7.方法的出入栈和栈上分配、逃逸分析及TLAB

8.虚拟机中的对象创建步骤

9.对象的内存布局

10.对象的访问定位

11.堆参数设置和内存溢出示例

1.运行时数据区的介绍

(1)运行时数据区的定义

(2)运行时数据区的类型

(3)运行时数据区的展示图

(1)运行时数据区的定义

Java虚拟机在执行Java程序的过程中,会把它所管理的内存划分为若干个不同的数据区域,这些区域各有各的用途以及各自的创建和销毁时间也不一样。有的区域会随着虚拟机的进程启动而存在,有的区域则依赖用户线程的启动和结束而进而跟着建立和销毁。

(2)运行时数据区的类型

程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区(运行时常量池)、直接内存。

(3)运行时数据区的展示图

2.运行时数据区各区域的作用

(1)程序计数器

(2)Java虚拟机栈

(3)本地方法栈

(4)Java堆

(5)方法区

(6)运行时常量池

(1)程序计数器

当前线程所执行的字节码的行号指示器,占用内存空间小,线程私有,各线程间独立存储,互不影响。

字节码解释器工作时:就是通过改变程序计数器的值,来选取下一条需要执行的字节码指令。

程序计数器是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等功能都依赖它完成。

Java多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何时刻,一个单核处理器都只会执行一条线程中的指令。为了使得线程切换后能恢复到正确的执行位置,因此每条线程都需要一个独立的程序计数器。

如果线程正在执行的是一个Java方法,程序计数器记录的是正在执行的虚拟机字节码指令的地址。

如果线程正在执行的是本地(Native)方法,程序计数器记录的值则应为空(Undefined)。

Java虚拟机规范中程序计数器不会出现OOM情况。

(2)Java虚拟机栈

Java虚拟机栈是线程私有的,它的生命周期和线程同生共死。

一.Java方法执行的线程内存模型

每个方法在开始执行时,Java虚拟机会同步创建一个栈帧用于存储:局部变量表、操作数栈、动态链接、方法出口等信息。每个方法的执行,对应着栈帧在虚拟机栈中入栈和出栈的过程。

二.局部变量表里面存放着各种基本数据类型和对象引用

基本数据类型:boolean、byte、char、short、int、float、long、double。对象引用:reference类型,并非对象本身。reference类型的对象引用可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄。

可用-Xss进行虚拟机栈大小的设置:默认为1M。

三.Java虚拟机规范中虚拟机栈有两类异常状态

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。

如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存也会抛出OutOfMemoryError异常。

注意:HotSpot虚拟机的栈容量是不能动态扩展的。

(3)本地方法栈

作用和Java栈一样,本地方法栈保存的是native方法的信息。当一个JVM创建的线程调用native方法后,JVM不再为其创建栈帧。JVM只会简单地动态链接并直接调用native方法,例如Object类的wait方法:

public final native void wait(long timeout) throws InterruptedException;

本地方法栈会在栈深度溢出或栈扩展失败时,分别抛出StackOverflowError和OutOfMemoryError异常。

(4)Java堆

Java中几乎所有对象实例都在堆上分配内存,因为部分对象由于逃逸分析、栈上分配、标量替换而不在堆上分配的。

Java堆是Java开发者需要重点关注的一块区域,因为涉及到内存的分配(new关键字、反射)与回收(回收算法、收集器)。

如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(TLAB)。在Java堆中的这些线程私有的分配缓冲区(TLAB)可以提升对象分配效率,而将Java堆细分的目的就是为了更好地回收内存,或者更快地分配内存。

当前主流的Java虚拟机对Java堆都支持可动态扩展。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

Java堆的相关参数含义:

-Xms:堆的最小值

-Xmx:堆的最大值

-Xmn:新生代的大小

-XX:NewSize:新生代最小值

-XX:MaxNewSize:新生代最大值

(5)方法区

一.方法区概述

方法区(如HotSpot虚拟机中的元空间或者永久代)。方法区和Java堆一样,是共享的区域,所有线程都可以共享这一区域。

方法区主要存放:类信息、常量、静态变量和JIT编译后的代码缓存等。在JVM启动的时候,方法区就被创建为固定大小或可动态扩容的区域。方法区在逻辑上属于堆的一部分,但一些简单的实现不会进行GC回收。因而方法区可看作是独立于Java堆的一块空间。

JDK1.7已经把原本放在永久代的字符串常量池、静态变量等移出。JDK1.8则完全放弃了永久代的概念,由在本地内存中实现的元空间代替,把JDK1.7中永久代还剩余的内容(主要是类型信息)全移到元数据空间里。

JDK1.7及以前:

-XX:PermSize; -XX:MaxPermSize

JDK1.8及以后:

-XX:MetaspaceSize; -XX:MaxMetaspaceSize

二.方法区的内部结构

三.栈、堆、方法区的交互

四.方法区的垃圾回收

方法区的垃圾回收主要包含两部分内容:废弃的常量、不再使用的类。判断一个常量是否废弃:没有任何地方引用该常量。

判断一个类是否不再使用的条件如下:

条件1:该类所有的实例都已被回收,堆中不存在该类及其子类的实例。

条件2:加载该类的类加载器已被回收,通常该条件很难达成。

条件3:该类对应的java.lang.class对象没有在任何地方被引用(如反射)。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,但仅仅是被允许,Java虚拟机规范中不要求在方法区实现垃圾回收。

方法区的无用类仅仅被允许回收,不像对象那样没有引用就必然会回收,可以使用-Xnoclassgc参数控制是否要对类型进行回收。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常需要JVM具备卸载类的能力,保证不会对方法区造成过大内存压力。

(6)运行时常量池

运行时常量池是方法区的一部分,运行时常量池用于存放编译期生成的各种字面量和符号引用。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table)。

常量池表用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行常量池中。

3.各个版本内存区域的变化

(1)JDK1.6的内存区域

(2)JDK1.7的内存区域

(3)JDK1.8的内存区域

JDK1.8的方法区在运行时数据区消失了,因为永久代(方法区)来存储类信息、常量、静态变量等数据不是个好主意,很容易遇到内存溢出的问题,而且对永久代(方法区)进行调优也很困难,所以JDK1.8将这些信息挪出来放到了元数据空间里。

同时JDK1.8将元数据空间与堆的垃圾回收进行了隔离,避免由于永久代(方法区)引发的Full GC和OOM等问题。

从理论上讲,在JDK1.8之后,这个元数据空间只受限于物理内存的大小,而不会再受限于整个虚拟机所管理的内存大小。

平时使用时,最好还是对元空间进行限制,否则会一直增长直到物理内存满了,导致服务器宕机。

4.直接内存的使用和作用

(1)通过DirectByteBuffer对象使用直接内存

(2)直接内存可以避免在Java堆和Native堆中来回复制数据

(1)通过DirectByteBuffer对象使用直接内存

注意:元数据空间不是在直接内存里,而是在本地内存里。直接内存通常在网络通讯的时候使用较多。

直接内存不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。

如果使用了NIO,这块区域会被频繁使用。在Java堆内可以用DirectByteBuffer对象直接引用并操作直接内存。这块内存不受Java堆大小限制,但受本机总内存的限制。可以通过MaxDirectMemorySize来设置直接内存的大小,所以也会OOM。在平时使用时,直接内存最好也进行限制。

(2)直接内存可以避免在Java堆和Native堆中来回复制数据

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。

在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用native函数库直接分配直接内存(也可以说是堆外内存),然后通过一个存储在Java堆中的DirectByteBuffer对象来操作其数据,这能在一些场景中提高性能,避免在Java堆和Native堆中来回复制数据。其中这个DirectByteBuffer对象其实可以看成是直接内存的引用。

5.站在线程的角度看Java内存区域

一个线程在一个时刻只能运行一个方法,只能运行一行代码,所以一个线程只需要一个本地方法栈,只需要一个程序计数器。

虚拟机栈、本地方法栈、程序计数器都是和线程同生共死的。Java堆、方法区则是和Java进程同生共死的。GC是不发生在栈上的,GC只发生在堆和方法区上。一个线程调用本地方法的话,会开辟一个虚拟机栈和本地方法栈。

6.深入分析堆和栈的区别

(1)堆和栈在功能上的区别

(2)堆和栈是线程独享还是线程共享

(3)堆和栈的空间大小对比

(4)区别堆和栈的示例代码

(5)堆中创建出来的类的对象不包含类的成员方法

(6)多个线程时的内存区域描述

(1)堆和栈在功能上的区别

栈内存会以栈帧的方式存储方法调用的过程和所使用的变量。方法调用过程中使用的变量包括:基本数据类型变量和对象的引用变量。其内存分配在栈上,里面的变量出了作用域就会自动释放。

堆内存用来存储Java中的对象。无论是成员变量、局部变量、还是类变量,这些变量指向的对象都是存储在堆内存的。

类的成员变量存放在堆,类的方法和静态变量存放在方法区。注意:JDK1.7之后,字符串常量和静态变量移出了方法区到堆里去了。

(2)堆和栈是线程独享还是线程共享

栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见。

栈内存可理解成线程的私有内存,堆内存中的对象对所有线程可见,堆内存中的对象可被所有线程访问。

(3)堆和栈的空间大小对比

栈的内存要远远小于堆内存,栈的深度是有限制的,可能发生StackOverFlowError问题。

(4)区别堆和栈的示例代码

public class SimpleHeap {
    private int id;
    public SimpleHeap(int id) {
        super();
        this.id = id;
    }
    public void print() {
        System.out.println("My id is "+id);
    }
    public static void main(String[] args) {
        SimpleHeap s1 = new SimpleHeap(1);
        SimpleHeap s2 = new SimpleHeap(2);
        s1.print();
        s2.print();
    }
}

SimpleHeap类的main方法运行时,堆、方法区、Java栈如下图示:main方法中的s1局部变量实际上指向的是s1实例对象的引用,s1局部变量是在栈帧里,而s1实例对象是在堆上。所以可以理解为:对于"A a = new A();",a在栈上,new A()在堆上。

(5)堆中创建出来的类的对象不包含类的成员方法

Java堆是用来存放动态产生的数据,比如new出来的对象,注意创建出来的对象只包含属于各自的成员变量,并不包括成员方法。因为同一个类的对象拥有各自的成员变量,存储在堆中,但是它们共享该类的方法,并不是每创建一个对象就复制成员方法一次。

(6)多个线程时的内存区域描述

7.方法的出入栈和栈上分配、逃逸分析及TLAB

(1)方法会打包成栈帧

(2)栈上分配

(3)逃逸分析

(4)如何启用栈上分配

(5)线程本地分配缓冲TLAB

(6)栈上分配的效果

(1)方法会打包成栈帧

一个栈帧至少要包含局部变量表、操作数栈和帧数据区。执行任何一个方法时,方法会打包成一个栈帧。下图展示的是一个方法打包成栈帧的流程:

(2)栈上分配

几乎所有的对象都是在堆上分配的,栈上分配就是虚拟机提供的一种优化技术。其基本思想是将线程私有的对象打散分配在栈上,而不分配在堆上。这样的好处是对象跟着方法调用自行销毁,不需要进行垃圾回收,从而提高性能。

如下test方法里定义了一个对象u,其他线程是访问不到的。那么对于对象u,就可以在栈上进行分配:

public void test(int x, int y) {
    String x = "";
    User u = new User();
}

(3)逃逸分析

栈上分配需要的技术基础:逃逸分析。逃逸分析的目的是判断对象的作用域是否会逃逸出方法体。

注意:任何可以在多个线程间共享的对象,一定都属于逃逸对象。在JDK1.8+,逃逸分析默认是开启的。一个线程频繁创建相同对象,就可以使用逃逸分析。

如下的User类型的对象u就逃逸出方法test,这个u作为test方法的返回值传出去了,其他的线程或方法会使用。此时可称u逃出了test方法的作用域,这个u就不能使用栈上分配了。

public User test(int x, int y) {
    String x = "";
    User u = new User();
    return u;
}

(4)如何启用栈上分配

 -server:这是JVM运行的模式之一,server模式下才能进行逃逸分析
 -Xmx10m和-Xms10m:设置堆的大小
 -XX:+DoEscapeAnalysis:启用逃逸分析(默认打开)
 -XX:+EliminateAllocations:标量替换(默认打开),标量替换就是通过逃逸分析是否允许将对象打散分配在栈上而不在堆上 
 -XX:+PrintGC:打印GC日志
 -XX:-UseTLAB:关闭每个线程的私有内存分配

所以,对栈上分配发生影响的参数有三个:

-server
-XX:+DoEscapeAnalysis
-XX:+EliminateAllocations

关掉任何一个参数都不会发生栈上分配,由于逃逸分析和标量替换默认是打开的,所以JVM的参数只用-server一样可以有栈上替换的效果。

(5)线程本地分配缓冲TLAB

TLAB全称是ThreadLocalAllocBuffer,线程本地分配缓冲。创建对象是在堆上分配的,需要在堆上申请指定大小内存的。一个堆一块区域,线程A进来分配区域a,线程B进来分配区域b。

如果有大量线程同时申请堆上的内存,为避免两个线程申请内存时不会申请同一块内存,需要对申请进行加锁。加锁不仅在并发编程时会有,虚拟机在实现时同样要考虑并发而加锁;如果不加锁,就有可能两个线程同时分配到同一块内存,导致数据错乱。

我们经常会new一个对象出来,所以内存分配是一个非常频繁的动作。因此内存分配时也就需要频繁加锁,而频繁加锁就会影响性能。一旦加锁,这种动作就会变成串行的模式,对性能影响很大。

所以TLAB的作用就是:它会事先在堆里面为每个线程分配一块私有内存,在线程A中new出的对象只在线程A的私有内存上进行分配。

所以TLAB的好处就是:由于线程的堆内存事前分配好了,因此同时分配时就不存在竞争了。从而大大提高了分配的效率,当私有内存用完了再重新申请继续使用。不过要注意的是,重新申请堆内存的动作还是需要保证原子性的。

TLAB涉及到线程私有,每个线程在new对象时会在私有内存上分配内存。尽管线程A在私有内存区域a位置拥有一块私有内存并在上面分配了对象,但是这些对象对所有线程都是可见并可用的。也就是说这些A线程的对象在分配的时候只能在a区域分配而已,B线程、C线程也是可以看见它们并使用它们的。

注意:堆中所有对象对所有线程理论上都是可见的。

(6)栈上分配的效果

同样的User的对象实例,分配100000000次。启用栈上分配,只需7ms,不启用,需要3S:

//启用栈上分配
//-XX:+EliminateAllocations
public class StackAlloc {
    public static class User {
        public int id = 0;
        public String name = "";
        User() {
        }
    }
    public static void alloc() {
        User u = new User();
        u.id = 5;
        u.name = "mark";
    }


    //配置了VM Options之后: 
    //-server -Xmx10m -Xms10m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations -XX:-UseTLAB 
    //输出如下:
    //[GC (Allocation Failure)  2047K->504K(9728K), 0.0013795 secs]
    //7ms
    public static void main(String[] args) {
        long b = System.currentTimeMillis();
        for (int i=0; i<100000000; i++) {
            alloc();
        }
        long e = System.currentTimeMillis();
        System.out.println((e-b)+"ms");
    }
}

不启用栈上分配:

//不启用栈上分配
//-XX:-EliminateAllocations
public class StackAlloc {
    public static class User {
        public int id = 0;
        public String name = "";
        User() {
        }
    }
    public static void alloc() {
        User u = new User();
        u.id = 5;
        u.name = "mark";
    }


    //运行配置了VM Options之后: -server -Xmx10m -Xms10m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations -XX:-UseTLAB
    //输出如下:
    //[GC (Allocation Failure)  2519K->471K(9728K), 0.0002605 secs]
    //...
    //[GC (Allocation Failure)  2519K->471K(9728K), 0.0002440 secs]
    //2969ms
    public static void main(String[] args) {
        long b = System.currentTimeMillis();
        for (int i=0; i<100000000; i++) {
            alloc();
        }
        long e = System.currentTimeMillis();
        System.out.println((e-b)+"ms");
    }
}

8.虚拟机中的对象创建步骤

Java程序几乎无时无刻都有对象被创建出来,虚拟机碰到一个new关键字时是如何创建对象的呢?

步骤一:进行类加载

步骤二:为对象分配内存

步骤三:为分配的内存空间初始化零值

步骤四:设置对象的对象头

步骤五:对象初始化

步骤一:进行类加载

首先检查new指令的参数是否能在常量池中定位到一个类的符号引用,并检查该符号引用代表的类是否被加载、解析和初始化过。如果没有,则执行相应的类加载过程。

步骤二:为对象分配内存

类加载完成后,虚拟机就要为这个新生对象分配内存,也就是把一块确定大小的内存从Java堆中划分出来。

如果Java堆中的内存是绝对规整的,所有用过的内存都放一边,空闲的内存放另一边,并且中间放一个指针作为已用内存和空闲内存的分界点的指示器,分配内存时就把该指针向空闲空间那边移动一段与对象大小相等的距离,这种分配方式称为指针碰撞。

如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法进行指针碰撞了,此时虚拟机就必须要维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;当使用CMS这种基于清除算法的收集器时,理论上采用空闲列表来分配内存,但实际为了分配更快也加入指针碰撞。

除如何划分可用空间外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为。即使仅仅修改一个指针所指向的位置,在并发情况下也不是线程安全的。可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。

解决这个问题有两种方案:

一种是对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS+失败重试的方式保证更新操作的原子性。另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块私有内存TLAB。

开启使用TLAB本地线程分配缓冲(Thread Local Allocation Buffer)时,在线程初始化时会申请一块指定大小的内存,只给当前线程使用。这样每个线程都单独拥有一个Buffer,如果要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况。当Buffer容量不够的时候,再重新从Eden区域申请一块内存继续使用。

TLAB的目的是在为新对象分配内存内存时,让每个Java应用线程用自己专属的分配指针来分配内存,减少同步开销。TLAB只是让每个线程拥有私有的分配指针,创建的对象还是线程共享的。当一个TLAB用完了(分配指针top撞上end了),那么就重新申请一个TLAB。

步骤三:为分配的内存空间初始化零值

内存分配完成后,虚拟机就需要将分配到的内存空间都初始化为零值。这一步操作保证了对象的实例字段在代码中可以不赋初始值就直接使用,也就是程序能访问到这些字段的数据类型所对应的零值。

步骤四:设置对象的对象头

内存空间初始化零值后,虚拟机就要对对象进行必要的设置,需要在对象的对象头中设置这些内容:这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。

步骤五:对象初始化

设置完对象的对象头信息后,从虚拟机的视角来看,一个新的对象已产生。但从Java程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。所以,执行new指令之后会接着把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

如下是详细的对象创建流程图:

9.对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头的第一部分是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁等。

对象头的第二部分是类型指针,即对象指向它的类的元数据的指针,JVM虚拟机可以通过这个指针确定这个对象是哪个类的实例。

对象头的第三部分是对齐填充,它仅仅起着占位符的作用。因为HotSpot的自动内存管理系统要求对象的大小必须是8字节的整数倍。当对象其他数据部分没有对齐时,就需要通过对齐填充来补全。

10.对象的访问定位

建立对象是为了使用对象,Java程序需要通过栈上的reference数据来操作堆上的具体对象。目前主流的访问方式有两种:使用句柄和直接指针。

一.通过句柄访问对象

如果使用句柄访问,那么Java堆中将会划分出一块内存来作为句柄池。reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自的具体地址信息。

二.通过直接指针访问对象

如果使用直接指针访问, reference中存储的直接就是对象地址:

这两种对象访问方式各有优势。

使用句柄访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄的实例数据指针,reference本身无需修改,而在垃圾收集时移动对象又是非常普遍的行为。

使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

对于HotSpot而言,它是使用直接指针访问方式进行对象访问的。

11.堆参数设置和内存溢出示例

使用CGLib技术操作字节码生成大量的动态类可能会导致永久代OOM。Spring和Dubbo会通过反射来生成类实例,刚开始启动不会永久代溢出。但运行时间久了,加载的类越多就越有可能造成永久代溢出。

(1)Java堆溢出的例子

一.出现java.lang.OutOfMemoryError: GC overhead limit exceeded

运行前配置参数:

-Xms5m -Xmx5m -XX:+PrintGC

一般是某个循环里可能性在不停地分配对象,但分配太多把堆撑爆了,如下代码所示:

//在运行之前需要先进行VM Options参数配置, 设置最小最大堆都是5M并打印GC:
//-Xms5m -Xmx5m -XX:+PrintGC
public class OOM {
    //输出结果如下:
    //[Full GC (Ergonomics)  5048K->5048K(5632K), 0.0189781 secs]
    //[Full GC (Ergonomics) Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded 
    //  5049K->5049K(5632K), 0.0210863 secs]
    //[Full GC (Ergonomics)   at java.util.LinkedList.linkLast(LinkedList.java:142)
    //  at java.util.LinkedList.add(LinkedList.java:338)
    //  at com.demo.OOM.main(OOM.java:18)
    //  5066K->374K(5632K), 0.0039215 secs]
    public static void main(String[] args) {
        List<Object> list = new LinkedList<>();
        int i=0;
        while(true) {
            i++;
            //每循环10000次进行一次打印
            if (i % 10000 == 0) System.out.println("i=" + i);
            list.add(new Object());
        }
    }
}

二.出现java.lang.OutOfMemoryError: Java heap space

一般是分配了巨型对象,如下代码所示:运行前配置参数 :-Xms5m -Xmx5m -XX:+PrintGC

//在运行之前需要先进行VM Options参数配置, 设置最小最大堆都是5M并打印GC:
//-Xms5m -Xmx5m -XX:+PrintGC
public class OOM {
    //输出结果如下:
    //[Full GC (Allocation Failure)  392K->374K(5632K), 0.0033209 secs]
    //Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    // at com.demo.OOM.main(OOM.java:22)
    public static void main(String[] args) {
        String[] strings = new String[100000000];
    }
}

(2)虚拟机栈和本地方法栈溢出的例子

需要不停调用方法才有可能出现虚拟机栈和本地方法栈溢出,比如出现无限递归的情况时,就可能会出现虚拟机栈溢出。一般的方法调用是很难出现的,如果出现了就要考虑是否有无限递归。

出现java.lang.StackOverflowError异常:

//在运行之前需要先进行VM Options参数配置: -Xss256k
//-Xss:默认为1M
public class StackOOM {
    //记录递归的深度(同时也是栈的深度)
    private int stackLength = 1;
    private void diGui() {
        stackLength++;
        diGui();
    }
    
    //输出结果如下:
    //stackLength = 2239
    //java.lang.StackOverflowError
    //  at com.demo.StackOOM.diGui(StackOOM.java:14)
    //  ...(2239行)
    //@param args
    public static void main(String[] args) {
        StackOOM oom = new StackOOM();
        try {
           oom.diGui();
        } catch (Throwable e) {
           System.out.println("stackLength = "+oom.stackLength);
           e.printStackTrace();
        }
    }
}

在上面StackOverflowError的例子中,栈的深度是2239。假如diGui()函数增加入参,-Xss还是256k,那么栈的深度变为1598。这说明栈帧变大了,因为执行任何一个方法时,方法会打包成一个栈帧。栈帧的内容包括:局部变量表(包括方法的入参)、操作数栈、帧数据区。

//在运行之前需要先进行VM Options参数配置: -Xss256k
//-Xss:默认为1M
public class StackOOM {
    //记录递归的深度(同时也是栈的深度)
    private int stackLength = 1;
    private void diGui(int x, String y) {
        stackLength++;
        diGui(x, y);
    }


    //输出结果如下:
    //stackLength = 1598
    //java.lang.StackOverflowError
    //  at com.demo.StackOOM.diGui(StackOOM.java:14)
    //  ...(1598行)
    //@param args
    public static void main(String[] args) {
        StackOOM oom = new StackOOM();
        try {
            oom.diGui(12, "TestStackOOMLength");
        } catch (Throwable e) {
            System.out.println("stackLength = "+oom.stackLength);
            e.printStackTrace();
        }
    }
}

虚拟机栈带给的启示:方法的执行因为要打包成栈桢,所以天生要比实现同样功能的循环慢。所以树的遍历算法中:递归和非递归(循环来实现)都有存在的意义。递归代码简洁,非递归代码复杂但速度快。

(3)直接内存溢出的例子

出现java.lang.OutOfMemoryError: Direct buffer memory异常,这种情况通常发生在NIO通讯上。

//在运行之前需要先进行VM Options参数配置, 设置堆最大为10M, 直接内存最大为10M
//-Xmx10M -XX:MaxDirectMemorySize=10M
public class DirectMem {
    //代码里分配了14M的直接内存
    //运行后输出结果如下:
    //Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
    //  at java.nio.Bits.reserveMemory(Bits.java:694)
    //  at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
    //  at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
    //  at com.demo.DirectMem.main(DirectMem.java:8)
    public static void main(String[] args) {
        ByteBuffer b = ByteBuffer.allocateDirect(1024*1024*14);
    }
}
相关推荐
工程师老罗7 小时前
Android笔试面试题AI答之SQLite(2)
android·jvm·sqlite
Qzer_40711 小时前
jvm字节码中方法的结构
jvm
奇偶变不变16 小时前
RTOS之事件集
java·linux·jvm·单片机·算法
Qzer_40720 小时前
JVM中的方法绑定机制
java·开发语言·jvm
ZERO空白20 小时前
深入理解 JVM 垃圾回收机制
jvm
Qzer_40720 小时前
jvm栈帧中的动态链接
jvm
ac-er888820 小时前
Go 语言GC(垃圾回收)的工作原理
java·jvm·golang
猿与禅20 小时前
jdk17用jmap -hea打印JVM堆信息报错Cannot connect to core dump or remote debug server
jvm·报错·jmap·堆信息
工程师老罗20 小时前
Android笔试面试题AI答之SQLite(3)
android·jvm·sqlite