深入理解jvm运行时区域
前言
最近一直在看周志明老师的《深入理解虚拟机》,总是看了忘,忘了又看,陷入这样无休止的循环当中。抱着纸上得来终觉浅的想法,准备陆续的写几篇学习笔记,梳理知识的脉络并强化一下对知识的掌握。(本文远远谈不上深入,但为了博浏览量,请原谅我这个标题党)。
概述
"Write Once,Run Anywhere"是sun公司用来展示java语言跨平台特性的口号。这标示着java语言可以在任何机器上开发,并编译成标准的字节码,在任何具有jvm虚拟机上的设备运行,这也是java语言早期兴起的关键。java另一大特性是其虚拟机的内存自动管理机制,这使得java程序员在创建任何一个对象时都不需要去写与之配对的delete/free代码(释放内存),不容易出现因为粗心大意而导致的内存泄漏和内存溢出的问题。可是因为将内存管理的权利交给虚拟机,一旦出现内存泄漏和内存溢出的问题,如果我们不了解虚拟机相关的知识,排查问题将是一件极为艰难的事情。
java内存区域
java虚拟机在运行java程序时,会将其管理的内存区域划分成若干个不同的数据区域。接下来的知识如果没有指明jdk版本号,统一以jdk1.6为标准,内存区域如下图所示:
- 程序计数器
程序计数器是一块较小的内存区域,可以把它看成是当前线程执行字节码的行号指示器。由于java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式实现的。在任意一个确定的时刻,一个cpu核心只会执行一个线程,因此为了cpu在切换线程后可以找到上次运行的位置,每条线程都应该有一个独立的程序计数器。各个线程间的程序计数器应互不影响并独立存储。如果此时运行的是java方法,这个记录器记录的是正在执行虚拟机字节码指令的地址,如果执行的是native方法,则这个计数器为空。此内存区域也是唯一一个java虚拟机规范里没有规定任何OutOfMemoryError情况的区域。 - java虚拟机栈
虚拟机栈也是线程私有的,它的生命周期和线程是相同的,它描述的就是java方法执行的内存区域。每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到执行完成就对应着一个栈帧在虚拟机中从入栈到出栈的过程。如果线程请求的深入大于栈所允许的深度,就会抛出StackOverflowError异常,大部分虚拟机支持动态扩展,如果扩展时无法申请到足够的内存,则会抛出OutOfMemoryError异常.
局部变量表:存放了编译器可知的各种基本数据类型(8种基础类型)、对象的引用(reference类型)和returnAddress类型(指向了一条字节码指令的地址),局部变量表在编译期是就可确定其大小。
操作数栈:也是栈的一种,虚拟机把操作数栈作为它的工作区,大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。
动态链接: Class 文件中存放了大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接.可以简单的理解为为了支持在方法中使用静态变量和常量...
方法出口:一般来说只有两种方法出口。一种是正常执行完毕,可以讲程序计数器作为返回地址返回,另外一种就是抛出异常,此时返回地址为空,需要异常处理器来确定返回地址。 - 本地方法栈
本地方法栈和虚拟机栈的作用是非常相似的。他们之间的区别不过就是一个为java方法服务,另外一个为native方法使用。本地方法栈的实现由java虚拟机规范所定义,各大虚拟机厂商在虚拟机规范的基础上自由实现. - java堆
java堆是所有线程共享的内存区域,也是大多数应用中虚拟机管理内存区域最大的一块,在虚拟机启动时创建。其作用就是为了存放对象实例。从内存回收的角度看,现在的收集器基本都采用分代收集算法。所以java堆还可以分为新生代和老年代。其中新生代又可分为Eden空间、From Survivor空间、To Survivor空间。堆内存区域的大小是通过-Xmx和-Xms来控制。如果在堆中没有完成内存实例分配,并且堆也无法扩展,将会抛出OutOfMemoryError异常。 - 方法区
方法区也是所有线程共享的内存区域,它用于存储已经被虚拟机加载的类信息、常量、静态变量等数据。它有一个别名叫做Non-heap(非堆),目的就是为了和堆做区分。对于经常在hotspot虚拟机上的开发者来说,更愿意将方法区成为永久代,本质上两者并不等价。只不过jvm设计团队选择把gc分代收集扩展至方法区,或者说使用永久代来实现方法区。但就目前发展来看,这样并不是一个好做法。jdk1.7中已经将原本放在永久代的字符串常量池移出,jdk1.8已经完全废除永久代这个概念,改用metaspace(元空间)。这块区域的回收主要针对常量池的回收和对类型的卸载,条件相当的苛刻,一般回收成绩也很难让人满意,但对其回收是非常有必要的。Sun公司的bug列表中,曾经出现多个严重的bug就是因为低版本的虚拟机未对方法区进行回收。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。 - 运行时常量池
运行时常量池是方法区的一部分。Class文件除了有类的版本、字段、方法、接口等描述信息等,还有一项信息是常量池,常量池在经过类加载后进入方法区的运行时常量池中存放。运行时常量池的一个重要特征就是具备动态性,java语言允许在运行期加新的常量放入池中。运行时常量池是方法去的一部分,自然会受到方法区内存的限制,当无法申请到内存时将会抛出OutOfMemory异常。 - 直接内存
直接内存并不是jvm内存管理区域的一部分,但也被频繁的使用,并可能导致OOM一场出现。在jdk1.4之后新加入了nio(new Input/Output)类,引入了一种基于通道(channel)和缓冲区(Buffer)的I/O方式,它使用native函数分配堆外内存。它不会说到java堆大小的限制,但是会受到本机总内存的限制。在配置虚拟机参数时,经常会忽略直接内存,从而导致动态扩展时出现OOM异常。
hotspot虚拟机对象探秘
- 对象的创建
在java语言层面,对象的创建通过new关键字来就可以实现。在jvm层面,对象(仅限于普通对象,不包括数组和class对象)的创建又是什么样子的呢?
当虚拟机接收到一条new指令时,会先跟据new指令的参数去常量池查询这个类的符号引用,并检查这个类是否已经被虚拟机加载、解析、初始化。如果没有,则要先执行相应的类加载过程。接下来要为对象分配内存,假设堆内存是绝对规整的,只需要一个指针作为临界点来标记内存已使用和内存未使用的区域,每次分配对象只需要移动与对象大小相等的距离即可,这种内存分配方式叫做"指针碰撞"。如果堆内存不是绝对规整的,我们无法通过简单的指针碰撞去分配内存,这时就需要虚拟机去维护一个列表,记录哪些内存区域是未使用的和其内存区域的大小,给对象分配内存只需要去空闲列表里找到一个块足够大的内存划分给对象实例即可,这种方式叫做“空闲列表”。
在一个应用程序中,创建对象是非常频繁的行为,仅仅是一个指针的分配在并发情况下都不是绝对安全的。很有可能正在给A对象分配内存,指针还没来得及修改位置,又发生着使用原来的指针给B对象分配内存。jvm提供了两种解决方案,1.jvm使用cas配上失败重试来保证指针更新操作的原子性。2.将内存分配的动作按照线程分区域进行,也就是预先给每个线程申请一部分区域,这种方式称为本地缓冲(Thread Local Allocate Buffer,TLAB).哪个线程要分配对象就在哪个线程的tlab上分配。只有当tlab用完并分配新的tlab才需要同步锁定,虚拟机是否开启tlab可以通过参数-XX:+/UseTLAB来决定。
内存分配好后,jvm需要分配的内存都初始化为零值(不包括对象头),以便java代码中变量不赋值,也可以访问到其数据类型对应的零值。接下来需要对对象头部分来做一个设置,对象头中主要包括类的元信息,对象的哈希码,对象的gc分代年龄以及锁记录等,在上面这些工作都完成时,从虚拟机的角度来说一个对象就已经创建好了。但从java语言的角度来看,还需要其执行构造方法让其按照程序员的意愿去构造这个对象,这样一个真正可用的对象才算完全产生出来。 - 对象的内存布局
对象的主要分为三部分对象头(Object Header),实例数据(Instance Data)和对齐填充(Padding).
对象头主要分为两部分,一部分是类型指针,通过类型指针指向类的元数据(确定对象是哪个类的实例)。另外一部分官方称为"Mark Word",用于存储自身运行时的数据,比如哈希值、gc年龄、锁状态标志、偏向线程id等。“Mark word”的存储内容如下图所示:
实例数据存储的是真正有效的数据,也是我们业务所关心的数据。
对齐填充并不是必须存在的,只是因为hotspot要求对象的大小必须是8bit的整数倍,而"Mark Word"又一定是8的整数倍,实例数据大小不确定,所以用对齐填充来补充其空余的地方。 - 对象的访问定位
创建对象是为了访问对象。我们在需要通过java虚拟机栈的reference引用去获取堆上的具体对象。但是并没有规定如何通过一个引用具体的定位访问到一个对象,所以对想得访问方式也是由虚拟机的实现定义的。主流的实现方式有使用句柄和直接指针两种。如下图所示:
使用句柄池其最大的好处就是保证reference引用中句柄的稳定,reference引用存放的是句柄池的地址,句柄中保存了指向对象实例数据和对象类型数据的指针,在虚拟机gc的时候,对象会发生非常频繁的移动,这个时候只要修改句柄指向对象数据的指针即可,不需要修改reference.
使用直接指针的好处就是块,可以减少一次指针定位。由于访问对象在一个程序中将是非常频繁的操作,积少成多,所以这也是一个非常可观的优化。
OOM异常--例子分析
经过一长串的的理论分析,我们已经大致清楚java的内存区域,现在我们使用具体的例子来验证。会将jvm的参数放在代码注释中。
- java堆溢出
/** * -XX:+PrintGCDetails -Xmx20m -Xms20m */ public class HeapOOM { static class OOMObjectt { } public static void main(String[] args) { List<OOMObjectt> list = new ArrayList<OOMObjectt>(); try { while (true){ list.add(new OOMObjectt()); } } catch (Exception e) { } } }
其运行结果如下:
[GC [PSYoungGen: 5898K->480K(6656K)] 5898K->3769K(20480K), 0.0043241 secs] [Times: user=0.09 sys=0.00, real=0.00 secs] [GC [PSYoungGen: 6315K->488K(6656K)] 9604K->8320K(20480K), 0.0064706 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] [Full GC [PSYoungGen: 6632K->0K(6656K)] [ParOldGen: 10997K->13393K(13824K)] 17629K->13393K(20480K) [PSPermGen: 3164K->3163K(21504K)], 0.1786099 secs] [Times: user=0.58 sys=0.00, real=0.18 secs] [Full GC [PSYoungGen: 3031K->3001K(6656K)] [ParOldGen: 13393K->13393K(13824K)] 16425K->16394K(20480K) [PSPermGen: 3163K->3163K(21504K)], 0.1063835 secs] [Times: user=0.64 sys=0.02, real=0.11 secs] [Full GC [PSYoungGen: 3001K->3001K(6656K)] [ParOldGen: 13393K->13377K(13824K)] 16394K->16378K(20480K) [PSPermGen: 3163K->3163K(21504K)], 0.0873232 secs] [Times: user=0.28 sys=0.02, real=0.09 secs] Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:2245) at java.util.Arrays.copyOf(Arrays.java:2219) at java.util.ArrayList.grow(ArrayList.java:242) at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:216) at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:208) at java.util.ArrayList.add(ArrayList.java:440) at HeapOOM.main(HeapOOM.java:17) Heap PSYoungGen total 6656K, used 3144K [0x00000000ff900000, 0x0000000100000000, 0x0000000100000000) eden space 6144K, 51% used [0x00000000ff900000,0x00000000ffc12240,0x00000000fff00000) from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000) to space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000) ParOldGen total 13824K, used 13377K [0x00000000feb80000, 0x00000000ff900000, 0x00000000ff900000) object space 13824K, 96% used [0x00000000feb80000,0x00000000ff890578,0x00000000ff900000) PSPermGen total 21504K, used 3194K [0x00000000f9980000, 0x00000000fae80000, 0x00000000feb80000) object space 21504K, 14% used [0x00000000f9980000,0x00000000f9c9ebc8,0x00000000fae80000)
- 虚拟机栈和本地方法栈溢出
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 (Exception e) { System.out.println("e.length:"+oom.stackLength); e.printStackTrace(); } } }
Exception in thread "main" java.lang.StackOverflowError at JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:6) at JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:7) at JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:7) at JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:7)
- 方法区和运行时常量池溢出
前文提到hotspot虚拟机栈中方法区是由永久代来实现的,可以用参数-XX:PermSize -XX:MaxPermSize来限制其空间,当无法申请到足够的内存时,会出现“permgen space”异常。但在jdk1.7中已经将永久代的字符串常量池移除,将其移入到Class对象末尾(也就是gc heap)。在jdk1.8将废除永久代,引用元空间概念,使用native memory来实现,可以通过参数:-XX:MetaspaceSize -XX:MaxMetaspaceSize来指定元空间大小。
/** * vm args:-XX:PermSize=4m -XX:MaxPermSize=4m -Xmx6m * Created by zhizhanxue on 18-3-26. */ public class MethodAreaOOM { public static void main(String[] args) { long i=0; List<String> list = new ArrayList<>(); while (true){ list.add(String.valueOf(i++).intern()); } } }
jdk1.6的运行结果:
jdk1.7的运行结果:
jdk1.8的运行结果:
下面我们来验证下元空间的例子:
/** *-XX:MetaspaceSize=8m -XX:MaxMetaspaceSize=8m */ public class SpringTest { static class OOM implements MethodInterceptor{ public Object getInstance(){ Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(OOM.class); enhancer.setCallback(this); enhancer.setUseCache(false); return enhancer.create(); } @Override public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable { return methodProxy.invoke(o,objects); } } public static void main(String[] args) throws ExecutionException, InterruptedException { List<Object> list = new ArrayList<>(); OOM oom = new OOM(); while (true){ list.add(oom.getInstance()); } } }
运行结果:
- 本机直接内存溢出
DirectMemory容量可以通过参数-XX:MaxDirectMemorySize来指定,如果不指定则默认与java堆最大值(-Xmx指定一样),代码通过unsafe.allocateMemory()去申请堆外内存模拟本地内存溢出异常。
/** * -Xmx220m -XX:MaxDirectMemorySize=10m */ public class LocalOOM { public static void main(String[] args) throws IllegalAccessException { Field field = Unsafe.class.getDeclaredFields()[0]; field.setAccessible(true); Unsafe unsafe = (Unsafe) field.get(null); while (true){ unsafe.allocateMemory(1024*1024); } } }
运行结果:
Exception in thread "main" java.lang.OutOfMemoryError at sun.misc.Unsafe.allocateMemory(Native Method) at LocalOOM.main(LocalOOM.java:12)
由堆外内存导致的内存溢出,一般都是gc日志很少,且堆dump文件不会看到明显的异常,如果情况和上述类似,你的项目中又使用了NIO,可以着重检查下是不是这方面的原因。
下节预告
1.对象已死?(如何判断对象是否存活)
2.垃圾收集的四种基础算法
3.垃圾收集器的介绍