本文聊聊Java的内存分区,其实在谈java内存区域划分时事实上指的就是JVM的内存区域划分。此外,这些分区是基于JVM规范的,不同的虚拟机都可以有各自略微不同的实现。
先来看看java代码执行的基本流程:
java源代码被编译器编译为.class的字节码文件,然后由虚拟机接管,通过类加载器加载完后交给执行引擎执行。执行过程中,用到的一些数据放在一个叫运行时数据区的地方,这个地方就是JVM的内存,即本文讨论的地方。
运行时数据区
包含几个部分:
-
PC程序计数器
占用很小的内存,可以看作是当前线程所执行的字节码的行号指示器。
每个线程都有独立的PC空间,因为Java虚拟机的多线程是通过轮流切换cpu来实现的,所以为了各个线程互不影响,每个线程都要有一个pc空间。这种每个线程都有的空间我们叫做“线程私有”内存。
如果线程正在执行一个java方法,pc记录的就是正在正在执行的虚拟机字节码指令的地址,如果执行的是Native方法,这个pc则为空。
此内存区域是唯一一个在虚拟机中没有规定任何OutOfMemoryError的区域。
-
虚拟机栈
这个区域也是线程私有的。虚拟机栈描述的是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:如果虚拟机栈允许动态扩展,当扩展时无法申请到足够的内存时抛出。一般是对象创建太快快于回收速度,或者程序设计不合理创建对象过多。
-
本地方法栈
作用和虚拟机栈类似,不过是为虚拟机使用到的本地Native方法服务。有的虚拟机将本地方法栈和虚拟机栈合并了,如Sun HotSpot。
-
堆区
一般来说,这块区域最大。堆区是所有线程共享的区域,在虚拟机启动时创建。该区唯一目的即是存放对象实例,几乎所有的对象实例都在这里分配内存。由于垃圾回收主要管理的就是堆区,堆区也被称为GC堆。
堆区又分为新生代和老年代,这样分类有利于垃圾回收。堆区在物理上不必连续。
-
方法区
方法区用于存放已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
jdk8之前方法区使用永久代实现,8.0开始使用元数据区(Metaspace)实现方法区,去掉了永久代概念。jdk8将常量池和静态变量放到堆中,我们应该这么理解:虽然物理上移动了,但逻辑上还是属于方法区。即可以说常量池在堆中也可以说在方法区。即根据不同的虚拟机,不同的版本,各种数据存放的位置是有变化调整的,不是完完全全按照jvm规范而来。
元数据区并不像永久代在虚拟机中,而是使用本地内存。
永久代迁移到元空间的优点:a)常量池迁移到堆中,避免溢出;b)永久代为GC带来了很多复杂性,使得回收效率低。
-
几个概念:
-
运行时常量池
常量池分两种形态:静态常量池和运行时常量池。
静态常量池:即class文件中的常量池,包含字面量(如文本字符串、声明为final的常量等)和符号引用量(类和接口的全限定名、字段名称和描述符、方法名称和描述符)。
运行时常量池:即类加载到虚拟机中后,class文件的常量池加载到内存,并保存在方法区。两种常量池只是通过它们的状态命名,内容都是一样的。
运行时常量池相对于 静态常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,用得比较多的便是 String 类的 intern() 方法。
常量池避免了一些常用对象的频繁创建,并提供了共享。
-
直接内存Direct Memory
直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。
在 JDK 1.4 中新加入了 NIO,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError 异常。
-
补充:
-
动态连接过程:
动态连接是一个将符号引用解析为直接引用的过程。当java虚拟机执行字节码时,如果它遇到一个操作码,这个操作码第一次使用一个指向另一个类的符号引用
那么虚拟机就必须解析这个符号引用。在解析时,虚拟机执行两个基本任务
1.查找被引用的类,(如果必要的话就装载它)
2.将符号引用替换为直接引用,这样当它以后再次遇到相同的引用时,它就可以立即使用这个直接引用,而不必花时间再次解析这个符号引用了。
-
方法区,元数据区,永久代
方法区是JVM规范中的概念,元数据区和永久代是不同虚拟机对方法区的实现。
模拟内存溢出异常
-
堆溢出
堆存放对象实例,所以只要一直新建实例,并且保证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,以及一些简略信息。
-
虚拟机栈溢出
栈溢出就是递归过深,内存用尽。设置参数:-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。这种情况,如果仍然需要更多线程,可以减少堆容量和每个线程的栈容量来增加可建立线程数量。
-
方法区溢出
针对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) ......
-
常量池溢出
jdk8之前常量池在永久代,而8后移到了堆,网上搜了也没有常量池大小设置的参数,所以一般应该不存在常量池溢出,或是直接堆溢出。
-
本机直接内存溢出
直接内存通过参数:-XX:MaxDirectMemorySize指定,若不指定,默认和堆最大值一样。
参考:《深入理解Java虚拟机》