☕JVM技术之旅-进阶分析内存布局特性

2021-05-04 06:53:31 Author: juejin.cn
觉得文章还不错?,点我收藏



每日一句

当一个人用工作去迎接光明,光明很快就会来照耀着他。 —— 冯学峰

前提回顾

之前的文章中已经介绍了JVM的内存结构,为什么还要在写本篇文章,觉得之前主要以体系化的方式展示了一下JVM结构和特性,但其中可能存在漏洞和内容的缺失,故此小编承接上篇文章JVM技术之旅-了解分析内存布局区域的内容进行下一步的开展,希望可以做到查缺补漏。 Java虚拟机内存其实主要就是指的JVM运行时数据区,其架构如下图所示:

image.png

  • 方法区和堆是由所有线程共享的数据区。
  • 虚拟机栈,本地方法栈和程序计数器是线程隔离的数据区。

内存结构介绍

下面来具体介绍这几个数据区。

程序计数器(无OOM)

程序计数器是一块较小的内存空间,其作用可以看作是当前线程所执行的字节码的地址值指示器。(但是与计算机系统结构中的PC计数器是不一样的,后者主要是用于指令集机器码级别的地址值指示器)。

功能作用

  1. 字节码解析器工作时通过改变程序计数器的值来选取下一条需要执行的字节码指令地址值

  2. 程序的分支、循环、跳转、异常处理(后续采用了异常表:异常监控范围以及ExceptionHandler处理器从而进行实现代码指令地址值)以及线程恢复等基础功能都是依赖程序计数器来完成

  3. JVM的多线程是通过线程轮流切换并分配处理器执行时间片来实现,在任何一个时刻,一个处理器只会执行一条线程指令。

  4. 如果线程正在执行java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址

  5. 因此,为了确保线程切换之后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,因此程序计数器是线程私有的内存

  6. 程序计数器是java虚拟机中没有规定任何内存溢出OutOfMemoryError情况的内存区域。

如果正在执行的是Native方法(简单地讲,Native Method就是一个java调用非java代码的接口。该方法的实现由非java语言实现,比如C。这个特征并非java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern "C"告知C++编译器去调用一个C的函数。)则计数器的值为空/Undefined

java虚拟机栈

Java虚拟机栈也是线程私有的,它的生命周期与线程相同虚拟机栈描述的是java方法执行的内存模型:每个方法被执行时都会同时创建一个栈帧用于存放局部变量表、操作数栈、动态连接和方法出口等信息每个方法被调用直至执行完成过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程

  • Java虚拟机栈的局部变量表存放了编译器可知的8种java基本类型数据(boolean、byte、char、short、int、float、long、double)、对象引用reference(注意不是对象实例本身)、方法返回地址returnAddress(指向了一条字节码指令的地址)。

  • Java虚拟机栈的局部变量表空间单位是槽(Slot),其中64位长度的double和long类型会占用两个slot,其余的数据类型只占用一个slot。局部变量表所需内存空间在编译期间完成分配,当进入一个方法时,该方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小,而且会由class字节码定义的code属性中的max_locals字段确定

  • Java虚拟机栈有两种异常状况:如果线程请求的栈深度大于虚拟机所允许的最大深度时,抛出StackOverflowError异常;如果虚拟机栈可以动态扩展当扩展时无法申请到足够内存时会抛出OutOfMemoryError异常,如果xss值越小会可能会越容易出现StackOverflowError,而单纯的增大XSS值,只是为了不抛出SFE而会导致OOM。

本地方法栈

本地方法栈与java虚拟机栈作用非常类似,其区别是:java虚拟机栈是为虚拟机执行java方法服务,而本地方法栈是为虚拟机调用的操作系统本地方法服务(如Native方法)。

  • Java虚拟机规范没有对本地方法栈的实现和数据结构做强制规定,Sun HotSpot虚拟机直接把java虚拟机栈和本地方法栈合二为一

  • Java虚拟机栈类似,本地方法栈也会抛出StackOverflowError异常和OutOfMemoryError异常。

堆Heap

堆是java虚拟机所管理的内存区域中最大一块,java堆是被所有线程所共享的一块内存区域,在java虚拟机启动时创建,堆内存的唯一目的就是存放对象实例。几乎所有的对象实例(包括数组)都是在堆分配内存。

  • Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配, 但是随着JIT编译器的发展与逃逸分析技术逐渐成熟, 栈上分配、标量替换优化技术将会导致一些微妙的变化发生,使得所有的对象都分配在堆上也渐渐变得不是那么"绝对"了

  • Java堆是垃圾收集器管理的主要区域,从垃圾回收的角度看,由于现在的垃圾收集器基本都采用的是分代收集算法,因此java堆还可以初步细分为新生代和年老代。

    • 从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等

    • 从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)

  • 不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存或者更快地分配内存。

Java虚拟机规范规定,堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。在实现上即可以是固定大小的,也可以是可动态扩展的。如果在堆中没有内存完成实例分配,并且堆大小也无法在扩展时,将会抛出OutOfMemoryError异常。Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms控制)。

堆设置的常用参数如下:

  • -Xmn: 新生区大小
  • -Xms :初始堆大小
  • -Xmx :最大堆大小,当Xms和Xmx设置相同时,堆就无法进行自动扩展
  • -XX:NewSize=n :设置年轻代大小
  • -XX:NewRatio=n :设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4
  • -XX:SurvivorRatio=n :年轻代中Eden区与Survivor区的比值。注意Survivor区有两个。

如:XX:SurvivorRatio=3,表示Eden区的大小是一个Survivor区的三倍,但Survivor区有两个,那么Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5

  • -XX:MaxPermSize=n/-XX:MaxMetaspace :设置方法区实现大小

方法区

方法区与堆一样,是被各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。虽然java虚拟机规范把方法区描述为堆的一个逻辑部分,但是方法区却有一个别名Non-Heap(非堆)

JDK8之前版本Sun HotSpot虚拟机把方法区叫永久代(Permanent Generation),方法区中最重要的部分是运行时常量池。

  1. Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是Class常量池,用于存放编译期生成的各种字面变量、符号引用、直接引用等。

  2. 这些内容将在类加载后存放到方法区的运行时常量池中,另外在运行期间也可以将新的常量存放到常量池中,如String的intern()方法JDK7以后字符串常量已经存入到了heap中)。

  3. 当调用intern方法时,如果常量池已经包含一个等于此String对象的字符串(该对象由equals(Object) 方法确定),则返回池中的字符串。否则,将此String对象添加到池中,并且返回此String对象的引用。

JDK8及之后的Hotspot虚拟机和其他虚拟机(如BEA JRockit、 IBM J9等)来说是不存在永久代的概念的。

  • 如何实现方法区属于虚拟机实现细节,不受虚拟机规范约束,但使用永久代来实现方法区,并不是一个好主意,因为这样更容易遇到内存溢出问题(永久代有-XX:MaxPermSize(物理内存 1/4)的上限,J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB,就不会出现问题),而且有极少数方法(例如String.intern())会因这个原因导致不同虚拟机下有不同的表现。

  • 因此,对于HotSpot虚拟机,根据官方发布的路线图信息,现在也有放弃永久代并逐步改为采用Native Memory来实现方法区的规划(JDK 1.7的HotSpot中,把原本放在永久代的字符串常量池移出,而在JDK1.8中,已经彻底没有了永久代,将方法区直接放在一个与堆不相连的本地内存区域(元空间))。

方法区和运行时常量池在无法满足内存分配时,也会抛出OutOfMemoryError异常

直接内存

直接内存并不是java虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域,但是在java开发中还是会使用到

  • JDK1.4中新引入的NIO(new I/O),引入了一种基于通道(Channel)和缓冲区(Buffer)的I/O方式,可以使用操作系统本地方法库直接分配堆外内存。
  • 然后通过一个存储在java堆里面的DirectByteBuffer对象作为堆外直接内存的引用进行操作,避免了java堆内存和本地直接内存间的数据拷贝,可以显著提高性能

虽然直接内存并不直接收到java虚拟机内存影响,但是如果java虚拟机各个内存区域总和大于物理内存限制,从而导致直接内存不足,动态扩展时也会抛出OutOfMemoryError异常。 (此外要注意的就是虽然JVM无法动态去回收堆外内存,但是却可以控制对堆外内存的引用句柄变量引用处理以及回收

直接内存的回收过程

  • 直接内存虽然不是JVM虚拟机内存空间,但它的垃圾回收也由JVM负责。
  • 垃圾收集进行时,虚拟机虽然会对直接内存进行回收,但是直接内存却不能像新生代、老年代那样,发现空间不足了就通知收集器进行垃圾回。它只能等老年代满了后Full GC,然后“顺便”帮它清理掉内存的废弃对象
  • 否则只能一直等到抛出内存溢出异常时,先catch掉,再在 catch 块里大喊"System.gc()”。 要是虚拟机还是不听,那就只能眼睁睁看着堆中还有许多空闲内存,自己却不得不抛出内存溢出异常了。(元数据空间出现了OOM)

异常问题介绍

java堆溢出

Java堆用于存储实例对象,只要不断创建对象,并且保证GC Roots到对象之间有引用的可达,来避免垃圾收集器回收实例对象,会在对象数量达到堆最大容量时产生Full GC后,仍然无法分配足够内存后会发生OutOfMemoryError异常

要使用如下java虚拟机参数:

  • -Xms=10m:(最小堆内存为10MB),
  • -Xmx=10m:(最大堆内存为10MB,最小堆内存和最大堆内存相同是为了避免堆动态扩展)。
  • -XX:+HeapDumpOnOutOfMemoryError :可以让java虚拟机在出现内存溢出时产生当前堆内存快照以便进行异常分析。

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

由于Sun的HotSpot虚拟机不区分java虚拟机栈和本地方法栈,因此对于HotSpot虚拟机来说 -Xoss 参数(设置本地方法栈大小)虽然存在,但是实际上是无效的,栈容量只能由 -Xss 参数设定。

由于Java虚拟机栈会出现StackOverflowError和OutOfMemoryError两种异常,所以分别使用两个例子演示这两种情况:

(1)java虚拟机栈深度溢出(递归层次过深)

无论是由于栈帧太大,还是虚拟机栈容量太小,当栈的执行深度超过了阈值的情况,虚拟机就会抛出StackOverflowError异常。使用-Xss128k将java虚拟机栈大小设置为128kb。

(2)java虚拟机栈内存溢出(线程创建过多)

能够创建的线程最大内存=物理内存-最大堆内存-最大方法区内存,在java虚拟机栈内存一定的情况下,单个线程占用的内存越大,所能创建的线程数目越小,所以在多线程条件下很容易产生java虚拟机栈内存溢出的异常。使用-Xss2m参数设置java虚拟机栈内存大小为2MB

运行时常量池溢出(目前已经调整为堆溢出)

运行时常量池属于方法区的一部分,最大内存和最小内存设置为10MB大小,并且由于永久代最大内存和最小内存大小相同,因此无法扩展。

String的intern()方法用于检查字符串常量池中如果有等于此String对象的字符串存在,则直接返回常量池中的字符串对于堆中的引用地址,否则,将此String对象所包含的字符串添加到堆中并将地址值存储到常量池中,并返回此String对象的引用。因此String的intern()方法特别适合演示运行时常量池溢出,例子代码如下:


public class RuntimeConstantPoolOOM{ 

    public static void main(String[] args){ 

    List<String> list = new ArrayList<String>(); 

        int i = 0; 

        while(true){ 

            list.add(String.valueOf(i++).intern()); 

        }   
  } 
} 
复制代码

运行一段时间,永久代内存不够,运行时常量池因无法再添加常量而产生 OutOfMemoryError。

方法区溢出

运行时常量池是方法区的一部分,他们都属于HotSpot虚拟机中的永久代内存区域

方法区用于存放Class的相关信息,Java的反射和动态代理可以动态产生Class,另外第三方的CGLIB可以直接操作字节码,也可以动态产生Class,实验通过CGLIB来演示,同样使用 -XX:PermSize/-XX:MetaSpaceSize=10m-XX:MaxPermSize/-XX:MaxMetaSpaceSize=10m 将方法区最大内存和最小内存设置为10MB大小,并且由于永久代最大内存和最小内存大小相同,因此无法扩展。

例子代码如下:

public class JavaMethodAreaOOM{ 

    public static void main(String[] args){ 

    while(true){ 

    Enhancer enhancer = new Enhancer(); 

    enhancer.setSuperClass(OOMObject.class); 

    enhancer.setUseCache(false); 

    enhancer.setCallback(new MethodInterceptor(){ 

    public Object intercept(Object obj, Method method, Object[] args, 

                      MethodProxy proxy)throws Throwable{ 
        return proxy.invokeSuper(obj, args); 
    } 
    }); 
    enhancer.create(); 
    } 
  } 

  class OOMObject{ 

  } 

} 

复制代码

运行一段时间之后,方法区内存不够,方法区无法再存放CGLIB创建处理的Class信息,产生方法区OutOfMemoryError。

本机直接内存溢出

Java虚拟机可以通过参数 -XX:MaxDirectMemorySize 设定本机直接内存可用大小,如果不指定,则默认与java堆内存大小相同

  • JDK中可以通过反射获取Unsafe类(Unsafe的getUnsafe()方法只有启动类加载器Bootstrap才能返回实例)直接操作本机直接内存。

  • 通过使用-XX:MaxDirectMemorySize=10M,限制最大可使用的本机直接内存大小为10MB,例子代码如下:


public class DirectMemoryOOM{ 

    private static final int _1MB = 1024* 1024 * 1024; 

    publc static void main(String[] args) throws Exception{ 

        Field unsafeField = Unsafe.class.getDeclaredFields()[0]; 

        unsafeField.setAccessible(true); 

        Unsafe unsafe = (Unsafe) unsafeField.get(null); 

        while(true){ 

            //unsafe直接想操作系统申请内存 

            unsafe.allocateMemory(_1MB); 
       } 
    } 
} 

复制代码

当运行一段时间之后,10MB的本机直接内存被分配光,无法在进行直接内存分配时,产生OutOfMemoryError

总结

java虚拟机内存结构中的程序计数器、虚拟机栈和本地方法栈这三个区域随线程创建而生,随线程销毁而灭,因此这三个区域的内存分配和回收是确定的,java垃圾收集器重点关注的是java虚拟机的堆内存和方法区内存




觉得文章还不错?,点我收藏



如果文章侵犯到您的版权,请联系我:buaq.net[#]pm.me