1、概述

对于java程序员来说,在虚拟机内存的自动内存管理机制的帮助下,不再需要为每一个对象去写匹配的 delete/ free 代码,不容易出现内存泄漏或内存溢出的问题。
正是因为把内存控制权交给了java虚拟机,一旦出现了内存泄漏或者是溢出的问题,排查错误会变得困难。

2、运行时数据区域

Java内存区域 - 图1

2.1 程序计数器

程序计数器是一块较小的内存空间,可以看成是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时,就是通过改变这个计数器的值来选取下一条需要执行的字节码指令、分支、循环、异常处理、线程恢复等基础功能都是依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换,分配处理器执行时间的方式来实现的。所以为了线程切换后能恢复到正确的执行位置,每个线程都需要一个独立的程序计数器。(“线程私有内存”)。
如果线程线程执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是一个Native方法简单地讲,一个Native Method就是一个java调用非java代码的接口,这个计数器为空(undefined)。

2.2 Java虚拟机栈

Java虚拟机栈也是线程私有的,它的生命周期与线程相同,虚拟机栈描述的是Java方法执行的内存模型,每个方法被执行的时候都会同时创建一个栈帧用于存储局部变量表,操作栈,动态链接,方法出口等信息。每一个方法被调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。https://blog.csdn.net/zc375039901/article/details/79179465
局部变量表:存放了编译期可知的各种基本类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型)和 returnAddress 类型(指向了一条字节码指令的地址)
局部变量表所需的内存空间在编译期间完成分配,当进入一个方法需要在帧中分配多大的局部变量空间完全确定的,在方法执行期间不会改变局部变量表的大小。
在Java虚拟机规范中,对这个区域定了两种异常情况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。eg:无限递归
  • 虚拟机栈进行动态的扩展时,如果无法申请到足够的内存会抛出OutOfMemoryError eg:创建 > GC回收。
  1. //模拟StackOverflowError
  2. public class Demo1 {
  3. private int index = 1;
  4. public void method() {
  5. index++;
  6. method();
  7. }
  8. @Test
  9. public void testStackOverflowError() {
  10. try {
  11. method();
  12. } catch (StackOverflowError e) {
  13. System.out.println("程序所需要的栈大小 > 允许最大的栈大小,执行深度: " + index);
  14. e.printStackTrace();
  15. }
  16. }
  17. }
  18. //模拟OutOfMemoryError
  19. public class Demo2 {
  20. public static void main(String[] args) {
  21. ArrayList list = new ArrayList();
  22. while (true) {
  23. list.add(new Heap());
  24. }
  25. }、
  26. }

2.3 本地方法栈

本地方法栈与虚拟机所发挥的作用是非常相似的,区别是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈为虚拟机使用到的native方法服务。虚拟机规范中对本地方法栈中使用的语言、方式并没有强制规定。因此虚拟机可以自有的实现它。

2.4 Java堆

对于大多数应用来说,java堆是java虚拟机所管理的内存中最大的一块。java堆是所有线程共享的一块区域,几乎所有的对象实例都在这里分配内存。
java堆是垃圾收集管理器管理的主要区域,也被称为GC堆(Garbage Collected heap)。

  • 堆内存划分JDK1.7及以前
    Java内存区域 - 图2

**JDK1.8Java内存区域 - 图3
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError。

2.5 方法区

方法区存放类的信息(包括类的字节码,类的结构)、常量、静态变量等。字符串常量池就是在方法区中。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做
Non-Heap(非堆),目的是与Java堆区分开来。很多人都更愿意把方法区称为“永久代”(Permanent Generation)。从jdk1.7已经开始准备“去永久代”的规划,jdk1.7的HotSpot中,已经把原本放在方法区中的静态变量、字符串常量池等移到堆内存中。

ps:永久代和元空间

永久代
  绝大部分 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. package com.paddx.test.memory;
  2. public class Test {
  3. }
  4. import java.io.File;
  5. import java.net.URL;
  6. import java.net.URLClassLoader;
  7. import java.util.ArrayList;
  8. import java.util.List;
  9. public class PermGenOomMock{
  10. public static void main(String[] args) {
  11. URL url = null;
  12. List<ClassLoader> classLoaderList = new ArrayList<ClassLoader>();
  13. try {
  14. url = new File("/tmp").toURI().toURL();
  15. URL[] urls = {url};
  16. while (true){
  17. ClassLoader loader = new URLClassLoader(urls);
  18. classLoaderList.add(loader);
  19. loader.loadClass("com.paddx.test.memory.Test");
  20. }
  21. } catch (Exception e) {
  22. e.printStackTrace();
  23. }
  24. }
  25. }

Java内存区域 - 图4
例中使用的 JDK 版本是 1.7,指定的 PermGen 区的大小为 8M。通过每次生成不同URLClassLoader对象来加载Test类,从而生成不同的类对象,这样就能看到我们熟悉的 “java.lang.OutOfMemoryError: PermGen space “ 异常了。
元空间
在JDK1.8中,永久代已经不存在,存储的类信息、编译后的代码数据等已经移动到了MetaSpace(元空间)中,元空间并没有处于堆内存上,而是直接占用的本地内存(NativeMemory)。
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。
不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存
元空间的大小仅受本地内存限制,可以通过以下参数来指定元空间大小:

  • -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值
  • -XX:MaxMetaspaceSize,最大空间,默认是没有限制的
  • -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
  • -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

    2.6 直接内存

    直接内存并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域,但是这部分也被频繁的使用,也可能导致outofMemoryError。
    NIO:引入一种基于通道和缓冲区的io方式,可以使用native函数直接分配堆外内存,然后通过一个存储在java堆里面的DirectByteBuffer 对象作为这块内存的引用来操作。避免了在java堆和native堆来回复制数据。
    直接内存的分配不会受到java堆大小的限制,但是会受到本机总内存大小及处理器寻址空间的限制。在设置虚拟机参数的时候,一般会根据实际内存设置 -Xmx等参数。但会经常忽略掉直接内存,是的各内存区域的总和大于物理内存限制。

    2.7 对象访问

    Object obj = new Object( );
    假设这句代码出现在方法体中,” Object obj “ 这部分语义将会反应到java栈的本地变量表中,作为一个reference类型数据出现。而new Object( )这部分语义将会反应到java堆中。形成一块结构化内存。另外,在java堆中还必须包含能查到此对象类型数据( 如对象类型、父类、实现的接口、方法等 )
    的地址信息,这些数据则存储在方法区中。
    2.7.1 对象的访问定位
    使用对象时,通过栈上的 reference 数据来操作堆上的具体对象。

  • 通过句柄访问

Java 堆中会分配一块内存作为句柄池。reference 存储的是句柄地址。详情见图。

Java内存区域 - 图5

  • 使用直接指针访问

reference 中直接存储对象地址

Java内存区域 - 图6

比较:使用句柄的最大好处是 reference 中存储的是稳定的句柄地址,在对象移动(GC)是只改变实例数据指针地址,reference 自身不需要修改。直接指针访问的最大好处是速度快,节省了一次指针定位的时间开销。如果是对象频繁 GC 那么句柄方法好,如果是对象频繁访问则直接指针访问好。