第二章 自动内存管理机制
2.1 概述
Java 程序员把内存控制的权力交给了Java 虚拟机,一且出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会成为一项异常艰难的丁作。
2.2 运行时数据区域
Java_虚拟机所管理的内存将会包括以下几个运行时数据区域
-
程序计数器
程序诈数器( Program: Counter Register )是一块较小的内存空间, 它可以看作是当前线—程所执行的字节码的行号指示器。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储,称之为“线程私有”的内存。
程序计数器内存区域是虚拟机中唯一没有规定OutOfMemoryError情况的区域。执行Native 方法,这个计数器则为空(Undefined)
-
Java 虚拟机栈
java虚拟机也是线程私有的,它的生命周期和线程相同。虚拟机栈描述的是Java方法执行的内存模型 :每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
局部变量表:是一组变量值存储空间,主要用于存储方法参数和定义在方法体内的局部变量,包括编译器可知的各种 Java 虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址,已被异常表取代)
由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。
局部变量表所需要的容量大小是编译期确定下来的,并保存在方法的 Code 属性的
maximum local variables
数据项中。在方法运行期间是不会改变局部变量表的大小的。异常:线程请求的栈深度大手虚拟机所允许的深度,将抛出
StackOveflowError
异常;如果虚拟机栈可以动态扩展,且扩展时无法申请到足够的内存,就会抛出OutOfMemoryError
异常。 -
本地方法栈
虚拟机所发挥的作用非常相似,他们之间的区别不过是虚拟机栈执行java服务,本地方法栈执行虚拟机使用的Native服务。
-
Java堆
Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块,Java堆是被所有线程共享的一块内存区域,此内存区域的唯一目的就 ,是存放对象实例,几乎所有的对象实例都在这里分配内存。
Java堆是垃圾收集器管理的主要管理区域,因此很多时候也被称做 “GC堆”。Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们一 的磁盘空间一样。
线程共享,需要考虑线程安全问题,有垃圾回收机制。
Java堆细分:
- 新生带(年轻代):新对象和没达到一定年龄的对象都在新生代
- 老年代(养老区):被长时间使用的对象,老年代的内存空间应该要比年轻代更
-
方法区
方法区( Method Area ) 与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap ( 非堆),目的应该是与 Java堆区分开来。
Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存外,还可以选择不实现垃圾收集。
根据Java.虚拟机规范的规定:当方法区无法满足内存分配需求时,将抛出
OutOfMemoryError
异常。 -
运行时常量池
运行时常量池( Runtime Constant Poor) 是方法区的一部分。存放该类型所用到的常量的有序集合,包括直接常量(如字符串、整数、浮点数的常量)和对其他类型、字段、方法的符号引用。
Java 语言并不要求常呈一定只有编译期才能产生运行期间也可能将新的常批放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern()力法。
-
直接内存
直接内存(Direct Memory ) 并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常。(分配回收成本高,但读写性能高,不受jvm内存回收管理)
在JDK 1.4中新加入了NIO ( Newlnput/Output ) 类 ,引入了一种基于通道(Channel ) 与缓冲区(Buffer ) 的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。(开辟一块系统内存和java堆内存都可以访问的内存,提高性能)
2.3 HotSpot 虚拟机对象探秘
-
对象的创建
new 指令: 虚拟机遇到一条new 指令时,首先将去检查这个指令的参数是否能在常氮池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。
为新创建的对象分配内存
- Java 堆中内存是绝对规整,内存用过的放一边,空闲的放另一边,中间放一个指针作为分界点的指示器
分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为”指针碰撞"(Bump the Pointer) 。
- Java 堆中的内存并不是规整的
此时虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,井更新列表上的记录,这种分配方式称为“空闲列表”( Free List )
解决对象创建在并发情况下不是线程安全的
- 对分配内存空间的动作进行同步处理一一实际上虚拟机采用CAS 配上失败重试的方式保证更新操作的原子性
- 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java 堆中预先分配一小块内存,称为本地线程分配缓冲( Thread Local Allocation Buffer, TLAB ) 。哪个线程要分配内存,就在哪个线程的TLAB 上分配,只有TLAB 用完并分配新的TLAB 时,才需要同步锁定。
-
对象的内存布局
对象在内存中存储的布局可以分为3 块区域:
-
对象头( Header ):存储对象自身的运行时数据,如晗希码( HashCode )、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID 、偏向时间戳等,官方称它为“ Mark Word。
- 对象头的另外一部分是类型指针,即对象指向它的类元数据的指。
- 对象是一个Java 数组,那在对象头中还必须有一块用于记录数组长度的数据
-
实例数据C Instance Data ):对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
- 存储顺序会受到虚拟机分配策略参数和字段在Java 源码中定义顺序的影响。
- 相同宽度的字段总是被分配到一起。
-
对齐填充( Padding ):并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。
- HotSpot VM 的自动内存管理系统要求对象起始地址必须是8 字节的整数倍
-
-
对象的访问定位
对象访问方式
-
如果使用句柄访问的话,那么Java 堆中将会划分出一块内存来作为句柄池, reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息
- 使用句柄来访问的最大好处就是reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference 本身不需要修改。
-
如果使用直接指针访问,那么Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference 中存储的直接就是对象地址
- 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本
-
2.4 实战:OutOfMemoryError 异常
在Java 虚拟机规范的描述中,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError (下文称OOM )异常的可能。
-
Java 堆溢出
Java 堆用于存储对象实例,只要不断地创建对象,并且保证GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常。
public class Test { public static void main(String[] args) throws Exception { List<Integer[]> list = new ArrayList<>(); while (true) { list.add(new Integer[1000000]); } } } // 错误结果 Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at top.liheji.Test.main(Test.java:18) // JetBrains 内存错误日志生成方法 // https://blog.csdn.net/qq_41409120/article/details/121557137
要解决这个区域的异常,一般的手段是先通过内存映像分析工具(如Eclipse MemoryAnalyzer)对Dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏( Memory Leak )还是内存溢出(MemoryOverflow ) 。
-
虚拟机钱和本地万法核溢出,以下代码只能导致 StackOverflowError 异常
异常分类
- 如果虚拟机在扩展战时无法申请到足够的内存空间,则抛出 OutOtMemory Error 异常。
- 如果线程请求的战深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
在单个线程下,无论是由于找帧太大还是虚拟机樵容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError 异常。
public class Test { public static Integer deep = 0; public static void DFS() { deep++; DFS(); } public static void main(String[] args) { try { DFS(); } catch (Throwable err) { System.out.println("递归深度为:" + deep); System.out.println(err.toString()); } } } // 错误结果 递归深度为:12197 Exception in thread "main" java.lang.StackOverflowError at top.liheji.Test.DFS(Test.java:18) at top.liheji.Test.DFS(Test.java:19) at top.liheji.Test.DFS(Test.java:19) ...
解决
虚拟机默认参数,枝深度在大多数情况下达到1000 ~ 2000 完全没有问题,对于正常的方法调用(包括递归),这个深度应该完全够用了。
如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。如果没有这方面的处理经验,这种通过“减少内存”的手段来解决内存溢出的方式会比较难以想到。
-
方法区和运行时常量池溢出
在JDK 1. 6 及之前的版本中,由于常量池分配在永久代内,我们可以通过 -XX: PermSize 和 -XX: MaxPermSize 限制方法区大小,从而间接限制其中常量池的容量,JDK1.7 以后暂未找到直接触发该异常的方法
常量区
-
String.intern() 是一个Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此String 对象的字符串, 则返回代表池中这个字符串的String 对象; 否则,将此String 对象包含的字符串添加到常量池中,并且返回此String 对象的引用。
public class Test { public static void main(String[] args) { List<String> list = new ArrayList<>(); int id = 0; while (true) { list.add(String.valueOf(id++).intern()); } } } // JDK1.8 以上暂未出现错误
-
运行时常量池溢出,在OutOfMemoryError 后面跟随的提示信息是“ PermGen space ”,说明运行时常量池属于方法区C Hotspot 虚拟机中的永久代)的一部分。
方法区
-
方法区用于存放Class 的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。
-
对于这些区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。虽然直接使用Java SE API 也可以动态产生类(如反射时的GeneratedC011structorAccessor 和动态代理等),但在本次实验中操作起来比较麻烦。
public class Test { public static void main(String[] args) { while (true) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(OOMObject.class); enhancer.setUseCache(false); enhancer.setCallback((MethodInterceptor) (o, method, objects, methodProxy) -> methodProxy.invokeSuper(o, args)); enhancer.create(); } } static class OOMObject { } } // JDK1.8 以上暂未出现错误
-
方法区溢出也是一种常见的内存溢出异常, 一个类要被垃圾收集器回收掉, 判定条件是比较苛刻的。在经常动态生成大量Class 的应用中,需要特别注意类的回收状况。
-
场景
- 使用了CGLib 字节码增强和动态语言之外
- 大量JSP 或动态产生JSP 文件的应用( JSP 第一次运行时需要编译为Java 类)
- 基于OSGi 的应用( 即使是同一个类文件, 被不同的加载器加载也会视为不同的类)
-
-
本机直接内存溢出
DirectMemory 容量可通过 -XX:MaxDirectMemorySize 指定, 如果不指定,则默认与Java 堆最大值( -Xmx 指定) 一样。
通过反射获取Unsafe 实例进行内存分配( Unsafe 类的getUnsaf1巳()方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有优jar 中的类才能使用Unsafe 的功能〉。
虽然使用DirectByteBuffer 分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配, 于是手动抛出异常, 真正申请分配内存的方法是unsafe.allocateMemory() 。
public class Test { public static void main(String[] args) throws IllegalAccessException { Field unsafeField = Unsafe.class.getDeclaredFields()[0]; unsafeField.setAccessible(true); Unsafe unsafe = (Unsafe) unsafeField.get(null); while (true) { unsafe.allocateMemory(10234 * 1024 * 8); } } } // 错误信息 Exception in thread "main" java.lang.OutOfMemoryError at sun.misc.Unsafe.allocateMemory(Native Method) at top.liheji.Test.main(Test.java:22)
由 DirectMernory 导致的内存溢出,一个明显的特征是在Heap Dump 文件中不会看见明显的异常,如果读者发现OOM 之后Dump 文件很小,而程序中又直接或间接使用了NIO,那就可以考虑检查一下是不是这方面的原因。
2.5 小结
总体讲解了虚拟机中的内存是如何划分的, 哪部分区域、什么样的代码和操作可能导致内存溢出异常。虽然Java 有垃圾收集机制,但内存溢出异常离我们仍然并不遥远,还讲解了各个区域出现内存溢出异常的原因。
评论区