JVM内存区域

本文聊聊Java的内存分区,其实在谈java内存区域划分时事实上指的就是JVM的内存区域划分。此外,这些分区是基于JVM规范的,不同的虚拟机都可以有各自略微不同的实现。


先来看看java代码执行的基本流程:

java源代码被编译器编译为.class的字节码文件,然后由虚拟机接管,通过类加载器加载完后交给执行引擎执行。执行过程中,用到的一些数据放在一个叫运行时数据区的地方,这个地方就是JVM的内存,即本文讨论的地方。


运行时数据区

包含几个部分:

  1. PC程序计数器

    占用很小的内存,可以看作是当前线程所执行的字节码的行号指示器。

    每个线程都有独立的PC空间,因为Java虚拟机的多线程是通过轮流切换cpu来实现的,所以为了各个线程互不影响,每个线程都要有一个pc空间。这种每个线程都有的空间我们叫做“线程私有”内存。

    如果线程正在执行一个java方法,pc记录的就是正在正在执行的虚拟机字节码指令的地址,如果执行的是Native方法,这个pc则为空。

    此内存区域是唯一一个在虚拟机中没有规定任何OutOfMemoryError的区域。

  2. 虚拟机栈

    这个区域也是线程私有的。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行时都会创建一个栈帧,栈帧放在虚拟机栈中。栈帧中存放局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到被执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。在活动线程中,只有栈顶的栈帧才是可操作的,该帧称为当前栈帧,正在执行的方法称为当前方法,执行引擎的所有指令都只能针对当前栈帧进行操作。

    栈帧:

    1)局部变量表:

    存放方法参数,局部变量。存放的数据类型有8种基本数据类型,对象引用类型(用于指示对象位置,根据不同虚拟机,reference可能时指针也可能是句柄),以及returnAddress类型(指向一条字节码指令的地址)。局部变量表的最小容量以变量槽为单位(即一个字长,32位虚拟机变量槽即为32位)。并且整个表相当于一个数组,通过索引访问。
    如果是非静态方法,则在表头存放的是方法所属对象的实例引用,一个引用变量占4字节。字节码中的STORE指令即是将操作栈中计算完成的局部变量写回局部变量表中。

    2)操作栈:

    也称为操作数栈,一开始是空的。java的执行引擎的操作主要就在操作栈执行。语句的执行归根到底就是计算的过程,而计算就在操作栈中进行。
    举个例子:执行iadd指令,java字节码和流程图如下:
    1.iload_0 //从局部变量表[0]读取数据压入操作栈
    2.iload_1 //从局部变量表[1]读取数据压入操作栈
    3.iadd //执行add操作,结果也压入操作栈
    4.istore_2 //操作栈栈顶元素存到局部变量表[2]位置

    3)动态连接:

    一个指向运行时常量池(JVM 运行时数据区域)中该栈帧所属性方法的引用,用于支持方法调用过程中的动态链接。

    4)方法出口:

    又叫方法返回地址。方法退出后需要返回到方法进入时的地址,正常退出的情况下,调用者的程序计数器的值就是返回地址,而异常退出时,返回地址是通过异常表来确定,栈帧中一般不会保存这部分信息。

    虚拟机栈的异常:

    1)StackOverflowError:线程请求的栈深度超过虚拟机允许的栈深度时抛出。一般是递归过深。

    2)OutOfMemoryError:如果虚拟机栈允许动态扩展,当扩展时无法申请到足够的内存时抛出。一般是对象创建太快快于回收速度,或者程序设计不合理创建对象过多。

  3. 本地方法栈

    作用和虚拟机栈类似,不过是为虚拟机使用到的本地Native方法服务。有的虚拟机将本地方法栈和虚拟机栈合并了,如Sun HotSpot。

  4. 堆区

    一般来说,这块区域最大。堆区是所有线程共享的区域,在虚拟机启动时创建。该区唯一目的即是存放对象实例,几乎所有的对象实例都在这里分配内存。由于垃圾回收主要管理的就是堆区,堆区也被称为GC堆。

    堆区又分为新生代和老年代,这样分类有利于垃圾回收。堆区在物理上不必连续。

  5. 方法区

    方法区用于存放已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

    jdk8之前方法区使用永久代实现,8.0开始使用元数据区(Metaspace)实现方法区,去掉了永久代概念。jdk8将常量池和静态变量放到堆中,我们应该这么理解:虽然物理上移动了,但逻辑上还是属于方法区。即可以说常量池在堆中也可以说在方法区。即根据不同的虚拟机,不同的版本,各种数据存放的位置是有变化调整的,不是完完全全按照jvm规范而来。

    元数据区并不像永久代在虚拟机中,而是使用本地内存。

    永久代迁移到元空间的优点:a)常量池迁移到堆中,避免溢出;b)永久代为GC带来了很多复杂性,使得回收效率低。


  • 几个概念:

    1. 运行时常量池

      常量池分两种形态:静态常量池和运行时常量池。

      静态常量池:即class文件中的常量池,包含字面量(如文本字符串、声明为final的常量等)和符号引用量(类和接口的全限定名、字段名称和描述符、方法名称和描述符)。

      运行时常量池:即类加载到虚拟机中后,class文件的常量池加载到内存,并保存在方法区。两种常量池只是通过它们的状态命名,内容都是一样的。

      运行时常量池相对于 静态常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,用得比较多的便是 String 类的 intern() 方法。

      常量池避免了一些常用对象的频繁创建,并提供了共享。

    2. 直接内存Direct Memory

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

      在 JDK 1.4 中新加入了 NIO,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

      显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError 异常。


补充:

  1. 动态连接过程:

    动态连接是一个将符号引用解析为直接引用的过程。当java虚拟机执行字节码时,如果它遇到一个操作码,这个操作码第一次使用一个指向另一个类的符号引用

    那么虚拟机就必须解析这个符号引用。在解析时,虚拟机执行两个基本任务

    1.查找被引用的类,(如果必要的话就装载它)

    2.将符号引用替换为直接引用,这样当它以后再次遇到相同的引用时,它就可以立即使用这个直接引用,而不必花时间再次解析这个符号引用了。


  2. 方法区,元数据区,永久代

    方法区是JVM规范中的概念,元数据区和永久代是不同虚拟机对方法区的实现。


模拟内存溢出异常

  1. 堆溢出

    堆存放对象实例,所以只要一直新建实例,并且保证gc不会回收就会溢出。

    设置虚拟机参数:Xms20m -Xmx20m (-XX:+HeapDumpOnOutOfMemoryError)

    参数解释:Xms设置初始堆大小,Xmx设置最大堆大小,内存不够不会再扩容,括号内表示可选参数,HeapDumpOnOutOfMemoryError可以让出现内存溢出时Dump出当前内存堆转储存快照以便事后分析。

    测试代码:

    public class OOMObject {
        public static void main(String[] args) {
            List<OOMObject> list = new ArrayList<>();
            while(true){
                list.add(new OOMObject());
            }
        }
    }
    

    运行结果:

    Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    	at java.base/java.util.Arrays.copyOf(Arrays.java:3511)
    	......
    	at java.base/java.util.ArrayList.add(ArrayList.java:467)
    	at JVM.OOMObject.main(OOMObject.java:15)
    

    错误提示给出了是heap space的oom,以及一些简略信息。

  2. 虚拟机栈溢出

    栈溢出就是递归过深,内存用尽。设置参数:-Xss128k(表示每个线程的堆栈大小128k)

    测试代码:

    public class JVMStackOF {
        private int stackLength = 1;
        public void stackLeak(){
            stackLength++;
            stackLeak();
        }
    
        public static void main(String[] args) throws Throwable{
            JVMStackOF sof = new JVMStackOF();
            try {
                sof.stackLeak();
            }catch (Throwable e){
                System.out.println("stack length:"+sof.stackLength);
                throw e;
            }
        }
    }
    

    运行结果:

    stack length:23604  //栈深度23604时溢出(若增加本地变量,使得本地变量表变大,则溢出时栈深度也减小)
    Exception in thread "main" java.lang.StackOverflowError
    	at JVM.JVMStackOF.stackLeak(JVMStackOF.java:11)
    	at JVM.JVMStackOF.stackLeak(JVMStackOF.java:11)
    	......
    

    有的博客说虚拟机栈有两种溢出:StackOverflow栈溢出和OutOfMemory内存溢出。但实际上栈溢出和内存溢出其实本质上都是内存不够,因为栈并没有规定多大就溢出,而是内存不够了才溢出。上述代码就只能抛出栈溢出错误,而不抛出内存溢出,但本质上就是内存不够了。

    操作系统为每个进程分配的内存都是有限的,这些内存主要被堆+方法区+栈瓜分。有一种出现OOM的情形:多线程时,每个线程都要分配私有的栈容量,栈容量分配完后再有线程就会OOM。这种情况,如果仍然需要更多线程,可以减少堆容量和每个线程的栈容量来增加可建立线程数量。

  3. 方法区溢出

    针对jdk8,这里讲的其实是元空间溢出。元空间存储类名,修饰符,描述符等信息,所以不断创建类,就会溢出。

    参数设置:-XX:MetaspaceSize=2m -XX:MaxMetaspaceSize=2m

    测试代码:

    /**
     * 使用CGLib产生大量类,模拟内存溢出
     */
    public class JavaMethodAreaCglibOom {
    
        public static void main(String[] args) {
            while (true) {
                Enhancer enhancer = new Enhancer();
                enhancer.setSuperclass(User.class);
                enhancer.setUseCache(false);
                enhancer.setCallback(new MethodInterceptor() {
                    @Override
                    public Object intercept(Object object, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
                        return methodProxy.invokeSuper(object, args);
                    }
                });
                enhancer.create();
            }
        }
    
        static class User {
        }
    }
    

    运行结果:

    Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
    	at net.sf.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:348)
    	......
    
  4. 常量池溢出

    jdk8之前常量池在永久代,而8后移到了堆,网上搜了也没有常量池大小设置的参数,所以一般应该不存在常量池溢出,或是直接堆溢出。

  5. 本机直接内存溢出

    直接内存通过参数:-XX:MaxDirectMemorySize指定,若不指定,默认和堆最大值一样。


参考:《深入理解Java虚拟机》

posted @ 2020-11-21 19:41  Glaci  阅读(103)  评论(0编辑  收藏  举报