java 和 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外的人想进来,墙里的人想出来;
运行时数据区域
- java虚拟机在执行java程序过程中会把它所管理的内存区域划分成若干个不同的数据区域,用作不同的用途,一般包含以下几个区域;
- 程序计数器
- 线程私有的,在运行时数据区中划分一小块区域,用来记录某个线程所执行的字节码的行号指示器,各个计数器之间互不影响,独立存储;如果当前方法是java方法的话,记录的是字节码的指令地址,如果是native方法的话,存储的是null
- java 虚拟机栈
- 主要使用参数 Xss进行控制启动过程中的最大内存分配
- 每个方法被执行的时候,java都会同步创建一个栈帧,用于存储局部变量表,操作数栈,动态连接,方法出口信息
- 局部变量表存放了编译期可知的各种java虚拟机基本数据类型,对象的引用和returnAddress类型,存储空间以局部变量槽来表示,局部变量表所需要的内存在编译期间完成分配,这个方法在栈帧中分配多大的局部变量空间是完全确定的,在运行期间不会改变局部变量表的大小
- 如果线程请求的栈的深度大于虚拟机所允许的深度的话mstackOverflowError;如果栈扩展时无法申请到足够的内存的话,OOM;
- hotSpot 虚拟机不支持栈扩展,因此在使用的时候,如果出现了OOM的话,可能是在分配栈内存的时候,分配不到空间抛出的异常
- 主要使用参数 Xss进行控制启动过程中的最大内存分配
- 本地方法栈
- 是虚拟机为使用本地方法服务的,在hotSpot中,将本地方法栈和虚拟方法栈合二为一
- java 堆
- -Xms 和 Xmx来控制堆的大小 方法区
- 虚拟机所管理的内存中最大的一块。java堆是被所有线程所共享的一块内存区域,虚拟机启动的时候创建,存在的唯一目的就是存放对象实例,也是垃圾收集器管理的一个主要区域。java堆进行了各种空间的划分,最终都是为了更好的进行垃圾回收,和提高对象分配时的效率,但是无论如何划分,始终不会改变java堆存储内容的共性,无论哪个区域都,都只能存储java对象的实例
- -Xms 和 Xmx来控制堆的大小 方法区
- 方法区
- java8之前使用 -XX:PermSize 和 -XX:MaxPermSize 来进行初始化分配和最大分配内存,java8之后由于永久代(hotspot虚拟机中,开发者习惯将方法区称之为永久代,在java8之后使用元空间进行替代,内存的分配直接在堆上进行分配,不再方法区中进行分配)退出了历史舞台,使用 -XX:MaxMetaspaceSize 设置元空间的最大值,使用 -XX:MetaspaceSize 指定元空间的初始空间大小,以字节为单位,达到该值就会进行类型的卸载,同时收集器会对该值进行调整,如果方法区被清除了,什么数据会收到影响?使用 -XX:MinMetaspaceFreeRatio 在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因元空间不足导致的垃圾收集频率;使用 -XX:MaxMetaspaceFreeRatio 用于控制最大的元空间剩余容量的百分比
- 方法区也是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息,常量,静态变量,即时编译器编译后的代码缓存等数据(也就是生成的.class文件的信息要放到这个地方么?)
- java8之后,放弃了永久代直接使用元空间
- 对于方法区的垃圾回收目标主要是针对常量池的回收和对类型的卸载,但是这一区域的回收效果不太理想,类型卸载的条件比较苛刻
- 关于永久代的介绍:在jdk8之前,习惯性的将方法区称之为永久代,但是这两者本质上不是一致的,因为仅仅是hotSpot将收集器的分代收集扩展到了方法区上,或者说使用永久代来实现方法区,这样方便hotSpot的垃圾收集器能够像管理java堆一样管理这部分的内容;
- java8之前使用 -XX:PermSize 和 -XX:MaxPermSize 来进行初始化分配和最大分配内存,java8之后由于永久代(hotspot虚拟机中,开发者习惯将方法区称之为永久代,在java8之后使用元空间进行替代,内存的分配直接在堆上进行分配,不再方法区中进行分配)退出了历史舞台,使用 -XX:MaxMetaspaceSize 设置元空间的最大值,使用 -XX:MetaspaceSize 指定元空间的初始空间大小,以字节为单位,达到该值就会进行类型的卸载,同时收集器会对该值进行调整,如果方法区被清除了,什么数据会收到影响?使用 -XX:MinMetaspaceFreeRatio 在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因元空间不足导致的垃圾收集频率;使用 -XX:MaxMetaspaceFreeRatio 用于控制最大的元空间剩余容量的百分比
- 运行时常量池 (是方法区的一部分)
- Class文件中有一项信息是常量池表,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
- 直接内存
- 通过使用 -XX:MaxDirectMemorySize 参数来指定,如果不指定的话,则默认与Java堆最大值(由 -Xmx指定)一致
- 直接内存不是虚拟机运行时数据区域的一部分,但是这一部分被频繁使用也会导致OOM,内存大小受到本机总内存的影响
- 通过使用 -XX:MaxDirectMemorySize 参数来指定,如果不指定的话,则默认与Java堆最大值(由 -Xmx指定)一致
HotSpot 虚拟机对象探秘
- 对象的创建
- 对象的创建过程,简单描述:
- 类加载:当字节码new指令的时候,检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已经被加载,解析和初始化过,如果没有的话,必须先执行类的加载过程
- 分配内存:当类加载检查通过之后,虚拟机为新生的对象分配内存,内存的大小在类加载完成后就可以确定大小,为对象分配内存的时候,如果内存空间规整,以一个指针进行分割,一边是使用过的内存,一边是空余内存,分配完内存之后将指针进行移动,这种分配方式是指针碰撞;如果java堆中的内存不规整,不能简单的直接进行指针碰撞的话,就需要虚拟机维护一个列表,记录哪些内存是可用的,哪些是不可用的,从列表中找到可用的区域,并更新列表上的记录,这种方式称之为空闲列表;
- 当垃圾收集器具压缩整理的能力的时候,分配内存采用指针碰撞,简单高效;否则的话,使用空闲列表进行处理,cms上为了能够在多数情况下快速的分配内存,设计了一个缓冲区,通过空闲列表拿到一大块分配缓冲区之后,里边使用了指针碰撞方式进行分配
- 在分配对象的时候,仅仅是修改指针指向的位置,在并发情况下不是线程安全的,即可能同时指向一块内存区域,解决这个问题的话,有两种方案,第一中是使用cas配上失败重试方式保证原子更新,第二种方案是内存分配动作按照线程划分在不同的空间中进行,即每个线程在java堆上预先分配一小块内存,称之为本地线程分配缓冲区(Thread Local Allocation Buffer)TLAB,只有当本地缓冲区用完了,分配新的缓冲区的时候才需要同步锁定;是否开启TLAB的话,可以通过 -XX:+/-UseTLAB参数决定
- 对象的创建过程,简单描述:
- 对象的内存布局
- 对象在堆内存中的存储布局可以划分为三个部分,对象头,实例数据和对齐填充
- hotspot虚拟机对象头分为两类信息:
- 存储对象自身的运行时数据(哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程id,偏向时间戳,这部分长度在32位和64为之间,官方称之为Mark Word,对象),在32为虚拟机上,32bit中25bit用于存储对象哈希码,4bit存储对象分代年龄,2bit存储锁标志位,1bit存储规定为0,其他状态下对象的存储内容;
- 类型指针,指向它的类型元数据的指针,java虚拟机通过这个指针来确定该对象是哪个类的实例,但是并不是所有查找对象元数据信息一定要通过对象本身;如果对象是一个数组的话,对象头中还必须有一块用于记录数组长度的数据,用于使虚拟机来确定对象的大小
- 实例数据:对象的真正有效信息,即代码中定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段,都必须记录起来。存储内容的顺序会收到虚拟机分配策略参数影响 ,默认分配策略的话,会将相同宽度的字段放在一起,并会将父类中定义的变量优先出现在子类变量之前,虚拟机参数设置成+XX:CompactFields参数值设置成true的话,那子类之中的较窄的变量允许插入到父类变量的间隙中,节省空间
- 对齐填充:虚拟机内存管理系统要求对象起始地址值必须是8字节的整数倍,如果对象实例数据部分没有对齐的话,需要通过对齐填充补全
- 对象的访问定位
- 主流的访问方式主要有使用句柄和直接指针两种
- 句柄访问的话,堆中划分出一块内存来作为句柄池reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据格子具体的地址
- 直接指针访问的话,reference中存储的直接就是对象的地址,访问对象本身的话,不需要一次间接的访问开销
- 两种方式的优缺点,使用句柄的话,对象中的实例数据指针变换的话,对reference本身没有影响,但是会多一次开销,就像一个代理;java中对象访问比较频繁,因此开销也比较大,hotspot虚拟机的话,主要是用的就是直接指针访问,但是使用Shenandoah收集器的话,也会有一次额外的转发),但从整个软件的开发范围来看,在各种语言,框架中使用句柄访问的情况也十分常见;
- 主流的访问方式主要有使用句柄和直接指针两种
oom异常的探索
- 验证不同场景下引发的oom异常,主要是探索java虚拟机中的内存是如何划分的,以及什么操作会造成哪些区域的内存溢出,什么样的代码会造成溢出,然后探索在不同的垃圾收集器是如何在内存溢出异常方向上的努力
- java 堆溢出
//设置堆大小,并不允许进行内存自动扩容,无限创建对象 /** * VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError */ public static void main (String[] args){ List<Object> list = new ArrayList<>(); while (true){ list.add(Object) } } - 虚拟机栈和本地方法栈溢出
- 主要原因有两个:单个线程中栈的深度大于虚拟机所允许的最大深度,会抛出StackOverflowError异常;虚拟机允许动态扩展的时候,当扩展栈容量无法申请到足够多的内存的时候会抛出OOM异常
- 栈的深度抛出异常:
- 使用 -Xss参数减少栈内存容量,然后使用递归进行操作,将抛出StackOverflowError
- 在单个方法中定义大量的变量,然后不断的调用该方法,会触发在分配内存的时候,无法获取足够多的内存,hotspot虚拟机会抛出StackOverflowError异常
- 方法区和运行时常量池溢出
- java8之前,设置方法区的大小,通过不断的添加运行时常量,可以产生oom的情况,但是java8之后,由于元空间完全替代了方法区,内存分配只在堆上进行,因此只有控制堆内存大小才起作用;同时设置元空间的大小和回收比例会生效,永久代参数不在有效
- spring等动态增强的框架在使用的时候,会产生大量的增强类,进入到方法区/元空间,因此需要这只元空间中的参数来放置残生OOM的异常比如用下列参数-XX:MaxMetaspaceSize; -XX:MetaspaceSize; -XX:MinMetaspaceFreeRatio
- 本机直接内存溢出
- 直接内存的容量大小可以通过 -XX:MaxDirectMemorySize进行指定,如果不指定默认设置与java堆大小一致;
- 直接内存导致的内存溢出,一个明显的特征是Heap Dump文件中不会有明显的异常情况,dump文件一般很小,此时就需要考虑是否使用过直接内存,典型的Nio中的零拷贝
- java 堆溢出