Java虚拟机之自动内存管理

JVM内存区域

先来看两张大家都很熟悉的图(JVM整个内存结构):
在这里插入图片描述
在这里插入图片描述

  • 类加载器

    负责从文件系统或者网络中加载class文件,并将类信息存放在方法区中

  • 方法区

    也叫永久区,是各个线程共享的内存区域,主要保存已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

  • 运行时常量池

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

  • 虚拟机栈

    针对线程所说的,线程私有的,生命周期与线程相同,每一个线程会创建一个栈。虚拟机栈描述的是Java方法执行的内存模型,保存着很多栈帧信息。栈帧是什么呢?就是Java方法执行的内存模型:每个方法被执行的时候会同时创建一个栈帧用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

  • 本地方法栈

    与虚拟机栈非常类似,也是线程所有的,最大的不同在于虚拟机栈用于方法的调用,而本地方法栈则用于本地方法的调用,java虚拟机允许java直接调用本地方法,即我们通常看源于时底层的native方法(通常使用C编写)。当一个JVM创建的线程调用native方法后,JVM不再为其在虚拟机栈中创建栈帧,JVM只是简单的动态链接并且直接调用native方法。本地方法栈也会抛出StackOverFlowError和OutOfMemoryError异常。

  • 极其重要的内存区域,是java虚拟机所管理的内存中最大的一块。同方法区一样,是各个线程共享的内存区域,保存java对象实例,即new出来的东西,在虚拟机启动时创建。堆是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆”

  • 程序计数器

    较小的内存空间,同虚拟机栈一样,也是每一个线程私有的空间。java虚拟机会为每一个java线程创建PC寄存器。在任意时刻,一个java线程总是在执行一个方法,这个正在被执行的方法称为当前方法。如果当前方法不是本地方法,PC寄存器就会指向当前正在被执行的指令。如果当前方法是本地方法,那么PC寄存器的值就是undefined。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

  • 直接内存

    并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。JDK1.4引入NIO,引入了一种基于通道channel与缓冲区Buffer的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场合中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。该区域虽然不受Java堆大小的限制,但仍能受到本机总内存大小的限制,因此在动态扩展时也会出现OutOfMenoryError的错误

  • 执行引擎

    java虚拟机核心组件之一,负责执行虚拟机的字节码,现代虚拟机为了提高执行效率,会使用即时编译技术将方法编译成机器码后再执行

从线程共享和线程私有的角度来看JVM内存结构:
在这里插入图片描述

JDK各版本的内存结构

JDK 1.6
在这里插入图片描述

  • JDK1.6中,运行时常量池还是方法区的一部分

JDK 1.7
在这里插入图片描述

  • JDK1.7中,运行时常量池从方法区移动到堆中

JDK 1.8
在这里插入图片描述
在这里插入图片描述

  • JDK1.8中,运行时常量池仍是堆中的一部分
  • JDK1.8中,删除了方法区,新增元空间区,存储的是类的元数据,即类名、方法名等,然后将字符串池和静态变量放入java堆中
  • JDK1.8中,去掉了-XX:PermSize和-XX:MaxPermSize,新增了-XX:MetaspaceSize和-XX:MaxMetaspaceSize

对象的创建

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

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可以完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。如何分配呢?有两种方式:

  • 指针碰撞:如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,而空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间挪动一段与对象大小相等的距离
  • 空闲列表:如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

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

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

上面工作完成之后,从虚拟机角度来看,一个新的对象已经产生了,但从Java程序的角度来看,对象创建才刚刚开始,init方法还没有执行,所有的字段都还为零。所以,一般来说,执行new指令之后接着会执行init方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的访问定位

Java程序需要通过栈上的reference数据来操作堆上的具体对象,由于reference类型在java虚拟机规范中规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种。

  • 句柄访问

    java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。使用句柄访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。
    在这里插入图片描述

  • 直接指针访问

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

常见内存溢出异常问题

Java内存溢出异常主要有两个:

  • OutOfMemeoryError:当堆、栈(多线程情况)、方法区、元数据区、直接内存中数据达到最大容量时产生
    除了程序计数器外,虚拟机内存的其他几个运行时区域都有可能发生OOM异常的可能。
  • StackOverFlowError:如果线程请求的栈深度大于虚拟机锁允许的最大深度,将抛出StackOverFlowError,其本质还是数据达到最大容量

除了程序计数器外,虚拟机内存的其他几个运行时区域都有可能发生OOM异常的可能。

Java堆溢出

Java堆用于存储对象实例,只要不断创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量达到最大堆的容量限制后就会产生内存溢出异常:java.lang.OutOfMemoryError: Java heap space

/**
 * 设置VM参数,使堆大小只有20m
 * -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError 
 */
public class HeapOOM {
	static class OOMObject {}
	public static void main(String[] args) {
		List<OOMObject> list = new ArrayList<OOMObject>();
		while (true) {
			list.add(new OOMObject());
		}
	}
}

运行结果:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid3872.hprof ...
Exception in thread "main" Heap dump file created [28200625 bytes in 0.250 secs]

解决办法:

使用-XX:+HeapDumpOnOutOfMemoryError可以让java虚拟机在出现内存溢出时产生当前堆内存快照以便进行异常分析,主要分析那些对象占用了内存;也可使用jmap将内存快照导出;一般检查哪些对象占用空间比较大,由此判断代码问题,没有问题的考虑调整堆参数

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

栈容量由 -Xss 参数设定,栈溢出产生的原因如下:

  • 如果线程请求的栈深度大于虚拟机锁允许的最大深度,将抛出StackOverFlowError
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,抛出OutOfMemeoryError
/**
 * 设置VM参数,使栈容量只有128K
 * -Xss128k
 */
public class JavaVMStackSOF {
    private int stackLength = 1;

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

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}

运行结果:

Exception in thread "main" stack length:1054
java.lang.StackOverflowError
	at JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:21)
	at JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:21)
	at JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:21)

解决办法:

  • StackOverFlowError 一般是函数调用层级过多导致,比如死递归、死循环;
  • OutOfMemeoryError一般是在多线程环境才会产生,一般用“减少内存的方法”,既减少最大堆和减少栈容量来换取更多的线程支持;

方法区和元数据区溢出

产生原因:

  • jdk 1.6以前,运行时常量池还是方法区一部分,当常量池满了以后(主要是字符串变量),会抛出OOM异常
  • 方法区和元数据区还会用于存放class的相关信息,如:类名、访问修饰符、常量池、方法、静态变量等;当工程中类比较多,而方法区或者元数据区太小,在启动的时候,也容易抛出OOM异常

解决办法:

  • jdk 1.7之前,通过-XX:PermSize,-XX:MaxPerSize,调整方法区的大小
  • jdk 1.8以后,通过-XX:MetaspaceSize ,-XX:MaxMetaspaceSize,调整元数据区的大小
  • 注意:java8去掉了-XX:PermSize和-XX:MaxPermSize,新增了-XX:MetaspaceSize和-XX:MaxMetaspaceSize

可以借助CGLIB动态代理技术动态产生类进行演示(JDK1.7):

/**
 * 设置VM参数,使永久代只有10M
 * -XX:PermSize=10M -XX:MaxPermSize=10M
 */
public class JavaMethodAreaOOM {

    static class OOMObject{}

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

运行结果:

Caused by: java.lang.OutOfMemmoryError: PermGen space

一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常动态生成大量Class的应用中,需要特别注意类的回收情况。常见生成动态类的情况有:CGLIB动态代理、大量JSP或动态产生JSP文件的应用等等

直接内存溢出

产生原因:

jdk本身很少操作直接内存,而直接内存(DirectMemory)导致溢出最大的特征是,Heap Dump文件不会看到明显异常,而程序中直接或者间接的用到了NIO

解决办法:

直接内存容量不受java堆大小限制,但受本机总内存大小限制,可以通过参数 -XX:MaxDirectMemorySize 指定,如果不指定,则默认与Java堆最大值(-Xmx指定)一样。

以下例子是使用Unsafe实例进行内存分配:

/**
 * 配置VM参数,使直接内存为10M
 * -Xmx20M -XX:MaxDirectMemorySize=10M
 */
public class DirectMemoryOOM {

    private static final int _1MB = 1024 * 1024;

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

        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}

运行结果:

Exception in thread "main" java.lang.OutOfMemoryError
	at sun.misc.Unsafe.allocateMemory(Native Method)
	at DirectMemoryOOM.main(DirectMemoryOOM.java:30)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at com.intellij.rt.execution.application.AppMain.main(AppMain.java:144)
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值