深刻理解jvm运行时区域

前言

最近一直在看周志明老师的《深刻理解虚拟机》,老是看了忘,忘了又看,陷入这样无休止的循环当中。抱着纸上得来终觉浅的想法,准备陆续的写几篇学习笔记,梳理知识的脉络并强化一下对知识的掌握。(本文远远谈不上深刻,但为了博浏览量,请原谅我这个标题党)。java

概述

"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的运行结果:
图片描述jvm

jdk1.8的运行结果:
图片描述ide

下面咱们来验证下元空间的例子:函数

/**
*-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.垃圾收集器的介绍

相关文章
相关标签/搜索