运行时数据区域

主要包括如下几部分:

  • 堆(Heap)
    在这个多线程的架构体系中,咱们知道有的区域是线程私有的,而有的区域是线程共享的。对于堆区,就是线程共享的,不管是堆还是栈,底层都是一个物理机的主存而已,在此基础上,JVM进行了逻辑分块,有了堆、栈的概念,比如JVM认为物理地址0x0000~0x2321为堆区,他就是堆区了。堆是用来干什么的呢?java是面向对象的语言,所以用new创建一个对象时,就需要在堆区分配对应的空间了。
  • 虚拟机栈(VM Stack)
    栈是线程私有的,当你创建一个新的线程时,可能就会分配一个完全隔离的地址空间给这个线程使用了,当线程销毁时,与之关联的栈空间就会回收了。一个虚拟机栈的基本单位是栈帧,当调用一个新的方法时,就会创建一个新的栈帧,所以栈帧是和一个方法的调用关联的,当调用一个方法时会创建一个栈帧,当方法执行退出后会销毁与之关联的栈帧。所以如果采用递归调用时,常常会因为递归深度过深导致创建过多的栈帧,从而出现了StackOverflowError错误。
  • 方法区(Method Area)
    方法区也是各个线程共享的区域,主要用于存储已被虚拟机加载的类信息(类的元数据信息)、常量(这个不会变的)、静态变量(一个类的多个实例共享,静态变量的信息和Class实例放在一个即可)、即时编译器编译后的代码(机器吗之类的信息,不会变的)。(注:具体存储什么信息,我现在也没那个功力验证),我的直观理解就是对于不经常变的数据和不会变的数据信息放在方法区比较好,这样垃圾回收就不会对此区域的内容有太大的影响。
  • 本地方法栈(Native Method Stack)
    本地方法栈和虚拟机栈很类似,只是虚拟机栈是为java方法服务的,而本地方法栈是为Native方法服务的,比如有c++写的一堆代码之类的。
  • 程序计数器(Program Counter Register)
    程序技术器是线程私有的,可以任务其存放的内容是当前线程所执行的字节码的行号指示器,用来记录字节码指令执行到哪一行了,当进行线程上下文切换,然后恢复后就指定接着从哪个地方重新执行了。

对象创建过程、布局和定位

  • 创建过程
    创建一个对象的过程,宏观上是使用Object o = new Object()来进行创建的,字节码执行也有new这个指令,表示创建一个对象,并将引用压入栈顶。当虚拟机遇到new这个指令时,首先根据new的参数判断能否再常量池中定位到一个类的符号引用,如果定位到了,判断当前类是否加载、解析和初始化过,如果没有加载过,得首先进行类的加载等过程,类加载后,就能够确定创建一个对象需要分配多大的空间了,当内存空间分配完成后,虚拟机会将分配到的内存空间初始化为零值(不包括对象头)。接着会对当前对象进行其他必要的设置,比如这个对象属于哪个类的实例、如果查找到类的元数据信息等等,这些信息都是放到对象头之中的。
  • 对象的布局
    对象在内存中的布局主要分配三部分:对象头、实例数据、对齐填充。
    对象头就是用来存在对象的一些额外信息等,是直接与当前对象关联的。
    实例数据主要就是成员变量的数据。
    对齐填充可以忽略,只是为了满足对象分配时的内存结构,没啥作用。
  • 定位
    定位可以分为两种:使用句柄和直接指针。对象在堆上分配,那么每一个对象都会有一个关联的地址信息,如果在方法中需要使用对象的方法时,方法中存放的就是对象的一个句柄或者直接指针。句柄相当于间接寻址,需要首先到一个句柄池中拿取对象的直接地址,然后才能够访问实际的对象,直接指针就好说了,可以用这个值直接访问对象。

内存异常检查

堆溢出

出现堆溢出通常是分配的堆空间过小,同时呢又有大量的对象创建,并且不能够及时回收,这样就会导致堆空间的大小无法满足需求导致OOM现象。
下面给出我写的一个例子,并通过命令跟踪堆内存的变化,看看溢出的发生现象。

jvm中提供很多非常好用的命令工具方便定位问题,希望大家都常常用一下,感受一下,比如:jps(查看启动的java进程)、jinfo(实时地查看和调整虚拟机的各项参数)、jstat(监控虚拟机各种运行状态信息)、jmap(生成堆转储,看看到底是哪些对象占用的堆内存,并且占用的比例等等)、jhat(根据jmap生成的转储文件进行数据分析)、jstack(java堆栈的跟踪信息,分析每一个线程的堆栈的情况,比如可以定位一个线程出现长时间停顿的原因等等)

下面我就用jmap、jhat、jstat来检测内存溢出的过程,和怎么可以定位到是什么对象导致了内存溢出。

初始主函数代码:

import java.util.*;
public class Test {
	public static void main(String[] args) throws Exception {
		System.out.println("start");
		List<byte[]> list = new ArrayList<>();
		while(true) {
			list.add(new byte[1024 * 256]);
			Thread.sleep(1000);
		}

	}
}

上面代码很简单,就是创建一个list,不断的往list中放入字节数组,每次放入的字节数组大小为256K。

运行上述main函数的命令行如下:

java虚拟机栈和栈帧的区别在哪_java虚拟机栈和栈帧的区别在哪


初始化堆大小和最大堆大小都配置为50MB,而新生代的大小是20M,所以老年代的大小是30MB,Eden:Survivor=8:1,所以Eden=16384KB,S1和S0都是2048KB。启动之后用jstat跟踪堆内存的变化情况:

java虚拟机栈和栈帧的区别在哪_堆内存_02


java虚拟机栈和栈帧的区别在哪_堆内存_03


java虚拟机栈和栈帧的区别在哪_java虚拟机栈和栈帧的区别在哪_04


上图的指标含义:

  • S0C:S0的空间大小
  • S0U:S0区已使用的大小
  • S1C:S1的空间大小
  • S1U:S1区已使用空间的大小
  • EC:Eden区的空间大小
  • EU:Eden区已使用空间的大小
  • OC:Old区的空间大小
  • OU:Old区的已使用空间大小
  • YGC:young GC的次数
  • FGC:full GC的次数

其他的参数就不说了,如果想知道的自己查查就行

抓了两百次,通过三张图分析下堆内存的变化情况:
(1) 从第一张图可知,第一次GC时是进行了一次young GC,因为此时Eden区快满了,无法存储新来的对象,young GC后,使Eden区存活的对象转移到了Old区和S1区(转移的过程是有一个过渡的,会从S0区过渡到S1区。大致步骤:首先将Eden区存活的对象放到S0区,此时S0区满了,只能将S0区和Eden区存活的对象转移到S1区,此时S1区满了,但是还有很多存活的对象没有地方,只能将Eden区剩余的对象转移到Old区。)
(2)第二次young GC时,将Eden区存活对象直接放到Old区,此时Old区也快满了,超过了JVM配置了阈值,所以触发了一次Full GC,这次GC时我们发现把S1区的内容也放入到Old区了,应该是S1区的对象满足什么条件了吧
(3)接着Eden区又满了,但是呢,S1、S0的空间不够,且Old区的空间也不够,只能尝试进行Full GC了,但是Full GC后,空间还是不够,怎么办?OOM呗。

下面再看看通过jmap、jhat进行分析的结果:

java虚拟机栈和栈帧的区别在哪_句柄_05


java虚拟机栈和栈帧的区别在哪_java虚拟机栈和栈帧的区别在哪_06


java虚拟机栈和栈帧的区别在哪_数据_07


正如上图所示,通过jmap抓一下堆dump,然后通过jhat进行分析一下就知道哪个对象占用大量的堆空间,导致OOM了。

总结:
1、介绍了JVM的运行时数据区域分布,需要知道有哪几块区域,并且每块区域用来存什么的
2、介绍了一些常用的jvm自带的工具,分析堆内存的变化,并且当出现OOM时,如何定位问题